2026/4/17 0:05:04
网站建设
项目流程
整站优化推广,龙华、宝安最新通告,wordpress编辑器不行,十大广告设计公司1. 前言
深入理解计算机系统#xff08;简称CSAPP#xff09;作为计算机领域的一本经典之作#xff0c;它不仅教会我们知识#xff0c;更重要的是能改变我们看待程序和系统的方式。
第二章信息的表示和处理详细描述了计算机如何将所有类型的信息都转化为最基础的二进制进…1. 前言深入理解计算机系统简称CSAPP作为计算机领域的一本经典之作它不仅教会我们知识更重要的是能改变我们看待程序和系统的方式。第二章信息的表示和处理详细描述了计算机如何将所有类型的信息都转化为最基础的二进制进行存储和操作从 C 语言的视角一点点地深入底层带我们看清楚位、字节、整数、浮点数以及各种位级运算的真实面貌。对我个人而言我作为一个学生在接触本章之前我觉得数字就是数字字符就是字符编程只需要关注逻辑。但读完这一章我意识到每行代码背后都有一套精密的二进制机制只去谈所谓的表层逻辑而不去关注底层原理这可能会酿成大祸。本文将作为我对 CSAPP 第二章的总结笔记除了涵盖书中第二章的核心概念之外还希望能通过我的理解和一些代码示例帮助我自己和大家更好地掌握这些至关重要的知识。2. 信息存储计算机不像我们人类那样能直接认识数字100和字符‘A’在计算机看来所有的一切都是位bit。大多数计算机使用的都是 8 位的块也就是字节 byte作为最小的可寻址内存单位而程序将内存视为一个巨大的字节数组。2.1 十六进制和字长虽然计算机只认识 0 和 1但如果让我们直接读写一长串二进制这无疑是在折磨人所以十六进制便出现了。我知道十六进制是非常基础的内容但是为了内容的完整性和上下文的逻辑性我还是简单介绍一下。它用0-9和A-F来表示十六进制数字的每一位 二进制数字的四位这使得二进制和十六进制之间的转换非常直观同时我们阅读十六进制的效率也是远高于二进制的。另一个比较重要的概念是字长它决定了指针数据的大小。在 32 位机器上指针是 4 字节。在 64 位机器上指针是 8 字节。无论在 32 位还是 64 位机器上int类型都是 4 字节。但long类型在 32 位机器上是 4 字节在 64 位机器上是 8 字节。所以说原则上在涉及到跨平台移植代码时一般是要谨慎使用long这种数据类型的。2.2 寻址和字节顺序不知道大家有没有想过当一个对象跨越多个字节时就拿int类型来说它跨越了 4 个字节这个对象在内存中的地址是什么而这些字节在内存中又是怎么排列的呢这里有两种流派大端法最高有效字节在最前面也就是低地址。这符合我们人类从左到右的阅读习惯。小端法最低有效字节在最前面。大多数 Intel 兼容机我们用的 PC都采用这种方式。举个例子假设变量x的类型为int位于地址0x100处其十六进制值为0x01234567。如上表大小端分别在内存中的布局可以看出小端看起来在内存中是反的。现在我们可以回答上面的问题了一个对象在内存中的地址就是他所使用的字节中最小的那个地址这些字节在内存中是连续排列的具体布局要视情况而定大端或者小端。2.3 实践检验真理书中最经典的一个例子就是通过 C 语言代码来打印对象的字节表示这能让我们直观地看到小端法是如何工作的。上面提到我们使用的 PC 机大多都是采用的小端法。下面我们来看一下具体实现的核心原理我们需要使用强制类型转换让编译器把一个int指针看作是一个unsigned char指针这样当我们做指针加法或数组索引时步长就从 4 字节变成了 1 字节方便我们逐字节地观察数据。代码如下#includestdio.htypedefunsignedchar*byte_pointer;//打印从start位置开始的len个字节voidshow_bytes(byte_pointer start,size_tlen){size_ti;for(i0;ilen;i){printf( %.2x,start[i]);//以至少两位16进制打印}printf(\n);}voidshow_int(intx){printf(Int %d (Hex: 0x%x) 的内存表示: \t,x,x);//将int*强转为unsigned char*show_bytes((byte_pointer)x,sizeof(int));}voidshow_float(floatx){printf(Float %f 的内存表示: \t\t,x);show_bytes((byte_pointer)x,sizeof(float));}voidshow_pointer(void*x){printf(Pointer %p 的内存表示: \t,x);show_bytes((byte_pointer)x,sizeof(void*));}intmain(){intval12345;//十六进制是0x00003039show_int(val);//测试一下浮点数看看它和整数有多大区别floatf_val(float)val;show_float(f_val);//测试指针show_pointer(val);return0;}运行结果如下图可以看到内存布局正如我们上面描述的小端法的结果。指针的大小是 8 个字节对应我使用的是 64 位机器。除此之外浮点数12345.0的二进制表示00 e4 40 46和整数12345的二进制39 30 00 00完全不同这里面的玄机我将会在后面的章节解释。2.4 小结这段代码最让我印象深刻的是(byte_pointer)x这一句在 C 语言中指针的类型不仅仅告诉机器地址在哪里更重要的是它告诉了机器看待数据的方式。比如对于int *一次看 4 个字节并把它认为是一个整数。而对于unsigned char *一次只看 1 个字节。这种通过改变指针类型来操作内存的技巧在底层系统编程中非常常见如果阅读过一些 Linux 内核源码对这种操作会非常熟悉。上面的运行结果中初看39 30 00 00确实很别扭因为这和我们书写0x00003039的习惯完全相反。在硬件层面小端法有它独特的优势当处理器读取内存时首先读到的是低位字节如果进行加法运算先处理低位是符合逻辑的这样方便进位。但是在网络编程中网络协议TCP/IP通常采用大端法也叫网络字节序。如果我们直接把一个 Intel 机器上的整数通过网络发送出去接收方可能会收到一个完全错误的值。因此在发送数据前必须使用htonl等函数进行转换。3. 整数表示在数学中整数是无限大的。但在计算机中受限于位长32 位或 64 位我们能表示的整数范围是有限的。计算机主要使用两种方式来编码整数无符号编码和补码编码。3.1 无符号数与补码无符号编码很好理解没有符号位所有的位都代表正的权重并且它只能表示非负数。补码编码是最常见的有符号数表示法很多教科书教我们取反加一来计算负数但这只是操作层面上的技巧。而 CSAPP 给出了更本质的数学解释最高有效位符号位具有负权重。对于一个w位的整数最高位的权重是- 2^(w-1)而其他位的权重仍然是正的。对正数来说最高位是0和无符号数一样。对负数来说最高位是1它代表一个很大的负数加上后面位的正数值最终结果就是一个负数。这也解释了为什么补码范围是不对称的最小值1000...000符号位为负权重其余位为0对应的值为- 2^(w-1)。最大值0111...111符号位为0其余全为1值为2^(w-1) - 1。3.2 扩展与截断当我们把一个较小的数据类型转换成较大的类型时比如short转int需要进行扩展。零扩展用于无符号数直接在高位补 0。符号扩展用于补码数在高位复制符号位。零扩展比较好理解这里我们举一个符号扩展的例子比如4位的-5是1011扩展到8位就要把最高位1复制填满也就是1111 1011。而截断则相对简单粗暴直接丢弃高位但这可能会导致数值发生剧烈变化且无规则可循。3.3 有符号与无符号的转换在 C 语言中当我们在有符号数和无符号数之间进行强制类型转换时位模式保持不变改变的是解释这些位的方式。这听起来没问题但如果表达式中同时存在有符号数和无符号数C 语言会隐式地将有符号数强制转换为无符号数来进行计算这会导致非常反直觉的结果。请看下面代码#includestdio.hintmain(){inti-1;unsignedintu1;//理论上-1 1if(iu){printf(-1 1:正常\n);}else{printf(-1 1:有点诡异\n);}//让我们看看发生了什么printf(int -1 的十六进制: 0x%x\n,i);printf(当 -1 被看作 unsigned 时: %u\n,(unsigned)i);return0;}我们比较有符号数i和无符号数u的大小按照正常人的思维逻辑1当然是大于-1的。但是这里同一个表达式中同时出现了有符号数和无符号数有符号数就会被隐式转换为无符号数从而导致了异常的结果。请看下面运行结果从运行结果可以看出当int类型的数-1被编译器以unsigned解析时结果是一个超大的正数。在本小节的第一句话中我加粗了一句话位模式保持不变改变的是解释这些位的方式。位模式不变其实就体现在这里有兴趣的话可以试试将0xffffffff转换为十进制看看是哪个数。正是4294967295位模式不变就是内存中的二进制数始终都保持那个样子改变的是解释这些位的方式意思就是当编译器以int来解释这串二进制时值为-1而使用unsigned来解释时值为4294967295。现在看来这个值当然要比1大得多。3.4 小结计算机使用补码并不是为了让人类看着舒服毕竟我们其实很难读懂而是为了硬件设计的统一。在补码体系下加法运算不需要区分正数和负数CPU 只需要一套加法器电路就能搞定所有整数加减法。3.3 节的那个隐式转换是无数程序 bug 的源头比如for循环中如果用unsigned变量做倒计时for (unsigned i 10; i 0; i--)这个循环永远不会停止因为unsigned永远大于等于 0。4. 整数运算在数学中两个正数相加结果一定更大。但在计算机中这不一定成立。4.1 溢出由于整数的位长是有限的当计算结果超出了这个限制就会发生溢出。无符号溢出当数字达到最大值后它会归零重新开始。这在数学上被称为模运算。补码溢出这个非常危险。两个很大的正数相加结果可能变成负数正溢出。两个很大的负数相加结果可能变成正数负溢出。4.2 代码演示我们通过代码来演示一下溢出的情况#includestdio.h#includelimits.hintmain(){//有符号数溢出inti_maxINT_MAX;//值为2147483647inti_nexti_max1;printf(有符号数溢出测试:\n);printf(最大整数:%d\n,i_max);printf(最大整数 1 %d\n,i_next);//无符号数溢出unsignedintu_maxUINT_MAX;//值为4294967295unsignedintu_nextu_max1;printf(\n无符号数溢出测试\n);printf(最大无符号数:%u\n,u_max);printf(最大无符号数 1 %u\n,u_next);return0;}INT_MAX和UINT_MAX两个宏定义在limits.h中看名称就知道这两个宏的含义了很好理解。下面看看测试结果在 C 语言中无符号溢出是标准定义的行为模运算程序员有时甚至会利用到这一特点。但有符号溢出是未定义行为。虽然在我们的 PC 上它通常表现为环绕但在某些特殊架构或激进的编译器优化下程序可能会直接崩溃或产生不可预测的结果这也是为什么很多安全漏洞都源于此。5. 浮点数CSAPP 第二章的最后一部分内容是浮点数。这是计算机表示实数小数的标准即IEEE 754。5.1 浮点数表示并不完美可能有不少人认为浮点数就是精确的小数这是大错特错的浮点数本质上是对实数的近似。IEEE 754 将位分为三个部分符号位s决定正负。阶码E表示对浮点数的加权。尾数M表示有效数字。5.2 一个经典的坑由于计算机是二进制的像0.1这种在十进制里很简单的小数在二进制里却是无限循环小数就像十进制里的1/3一样。由于尾数位数有限计算机只能截断这就导致了精度丢失。请看如下测试代码#includestdio.hintmain(){floata0.1;floatb0.2;floatsumab;//理论上0.1 0.2应该等于0.3if(sum0.3){printf(没问题: 0.1 0.2 0.3\n);}else{printf(暗藏玄机: 0.1 0.2 ! 0.3\n);}//看看值到底是多少printf(sum 的实际值: %.10f\n,sum);return0;}我们话不多说直接看运行结果可以看到即使是再简单不过的0.1和0.2相加得到的结果也不是精确的只是个近似值。这个实验清楚地告诉我们永远不要使用 来比较两个浮点数是否相等。同时也解释了为什么浮点数运算不满足结合律即 (a b) c 不一定等于 a (b c)。6. 总结读完第二章我最大的一个感触是抽象是有代价的。高级语言为我们抽象出了整数和小数的概念让我们不用去关心底层的位模式但是我们又必须要注意一些额外的问题比如警惕溢出和类型转换带来的 bug跨平台通信时要注意大端和小端还有浮点数的不精确性。回顾整章最核心的一句话依然是 CSAPP 第一章提到的信息 位 上下文。同样的二进制位0x3039如果是int它是12345。如果是float它是一个极小的数。如果是机器指令它可能是一条跳转命令。我们必须时刻透过这层抽象看到底层的本质这样写出的代码才能更加健壮。本文完。