《深入理解计算机系统》(CSAPP)2.2:整数数据类型与底层机器级表示
在计算机的微观世界里所有的数字最终都只是一串冷冰冰的、没有感情的 0 和 1。内存里的二进制位本身毫无意义真正赋予它们灵魂的是我们定义的“数据类型”。为什么在 C 语言中把一个大整数强行塞进小空间后正数会突然变成负数为什么看似简单的强制类型转换常常是底层安全漏洞的罪魁祸首以下是 CSAPP 中关于整型数据类型的核心知识点、通俗解释以及实例。1. 整数数据类型的大小在 C 语言中不同的数据类型分配到的字节数Byte1 Byte 8 bits是不同的而且这还取决于你的机器字长32位或64位机器。C 语言数据类型字节数 (32位机器)字节数 (64位机器)作用与特点char11通常用来存字符但本质是8位小整数short22短整型int44最常用的整数类型long48长整型注意在 64 位机器上变大了int32_t44推荐用法精确指定为32位包含在stdint.h中核心提示为了避免代码在不同机器上跑出不一样的结果现代 C 语言编程非常推荐使用int32_t、uint64_t这种显式标明位数的类型而不是依赖模糊的int或long。2. 无符号整数Unsigned无符号整数只能表示非负数即的数。它的编码方式就是最基础的二进制转十进制。假设我们有一个位的二进制序列记作。无符号整数的转换公式为实例解析假设我们用 4个 bit 来表示整数为了方便演示我们有一串二进制1011。第 0 位最右第 1 位第 2 位第 3 位最左总和所以无符号的1011在十进制中就是11。3. 有符号整数补码Twos Complement计算机如何用 0 和 1 表示负数现代计算机几乎全部使用补码来表示有符号整数。补码的核心在于最高位最左边那一位变成了“符号位”并且它代表的是一个负权重。转换公式为实例解析依然使用 4个 bit 的二进制1011第 0 位第 1 位第 2 位第 3 位最高位负权重总和所以同样的二进制1011如果按照有符号整数补码来解释它就是十进制的-5。思考为什么计算机喜欢用补码因为有了补码计算机底层的加法器就不需要区分正数和负数了。比如3 (-5)在 4 bit 补码下就是0011 1011 1110而1110算出来正好是十进制的-2。一套硬件电路直接搞定所有加减法4. 有符号与无符号之间的强制类型转换在 C 语言中你可以把int强制转换成unsigned int。CSAPP 在这里强调了一个极其重要的反直觉规则强制类型转换不会改变底层的二进制位它只改变了计算机“看待”这串二进制位的方式即改变了映射规则。short int v -12345; unsigned short uv (unsigned short) v;-12345作为 16 位有符号整数底层的二进制补码是11001111 11000111。当你把它转换成无符号整数uv时内存里依然是11001111 11000111。但是按照无符号数的计算方式最高位的负权重变成了正权重这串数字现在表示的值变成了53191。注意当 C 语言在执行包含既有有符号数又有无符号数的运算时会隐式地将有符号数转换为无符号数这常常是各种奇葩 Bug 的来源。5. 扩展与截断当我们需要把一个较小的数据类型放到一个较大的数据类型中时比如从short转到int计算机需要“扩展”位。零扩展Zero Extension用于无符号数。直接在前面补 0。比如无符号的 4 位1011(11) 变成 8 位就是00001011(11)。符号扩展Sign Extension用于有符号数。把最高位的符号位复制出去。比如有符号的 4 位1011(-5)最高位是 1。变成 8 位就是11111011。你可以套用前面的公式算一下11111011的十进制依然是 -5。这就保证了数值大小不变。当把大的数据类型转为小的比如int转short时发生的是截断。计算机会直接丢弃多余的高位比特这可能会改变数值甚至改变正负号如果新的最高位刚好变了的话。在C语言和底层硬件的交互中“截断”Truncation是一个极易引发潜在Bug的机制。当你试图把一个占用字节数较多的大数据类型比如 32位的int强行塞进一个占用字节数较小的数据类型比如 16位的short或 8位的char时就会发生截断。CSAPP 对截断的解释非常暴力且直接无视数值本身的意义直接像切香肠一样把多出来的高位左侧二进制比特全部砍掉只保留低位右侧的比特。下面我们分情况详细剖析。1. 无符号整数的截断本质是“取模”运算假设我们将一个位的无符号数截断为一个位的无符号数。动作丢弃最高的位。数学意义相当于对原来的数值取的模余数。公式表示实例解析从 8位 截断到 4位假设我们有一个 8 位的无符号整数值为十进制的27。底层二进制0001 1011发生截断我们要把它存入一个 4 位的类型中。计算机会毫不留情地砍掉左边的 4 位0001。截断后结果剩下低 4 位1011。重新解释无符号的1011在十进制中是11。验证数学公式。结果完全吻合。只要数值超出了小类型能表示的范围它就会像钟表一样“绕回去”。2. 有符号整数的截断不仅变大小还会“变性”有符号整数补码的截断在位操作上和无符号数一模一样同样是砍掉高位但可怕的是砍完之后留下来的最高位会被强行当做新的符号位。这就导致不仅数值的大小会突变连正负号都可能发生翻转。公式表示即先像无符号数那样取模然后再把结果按照补码规则重新解释。实例解析正数突变成负数假设我们有一个 8 位的有符号整数值为十进制的11。底层补码0000 1011因为是正数最高位是 0发生截断将其塞入 4 位的类型中砍掉左边的 4 位0000。截断后结果剩下低 4 位1011。重新解释现在的最高位变成了 1。在 4 位补码的规则下最高位权重为1011的计算过程是 -8 0 2 1 -5。结论原本的正数11经过截断后摇身一变成了负数-5#include stdio.h int main() { int big_number 53191; // 53191 的 32 位十六进制是 0x0000CFC7 short small_number (short)big_number; // short 是 16 位截断后只保留后四位十六进制即 0xCFC7 // 0xCFC7 作为有符号的 16 位补码最高位是 1负数 printf(原始的 int: %d\n, big_number); printf(截断后的 short: %d\n, small_number); return 0; }输出结果将会是原始的 int: 53191截断后的 short: -12345总结计算机在做截断时是没有“智商”和“安全检查”的它只负责机械地丢弃多余的比特位。这也是为什么在处理用户输入、网络数据包或者文件解析时随意将int赋值给char或short常常会引发严重的安全漏洞。你可能会问像 Python 或者 JavaScript 这样的高级语言数字再大也不会截断为什么 C 语言不行Python 的做法当数字变大时Python 在底层会自动去内存里寻找一块更大的新空间把旧数据搬过去换一个更大的“盒子”。这叫动态类型。C 语言的做法C 语言是用来写操作系统和底层驱动的它的第一法则是绝对的执行速度和对内存的绝对控制。如果每次赋值都要去检查“盒子够不够大不够大就去申请新内存”这会消耗大量的 CPU 时钟周期额外执行很多指令。C 语言选择把数据结构写死直接对应 CPU 的寄存器大小。这样虽然不够安全但极度高效。既然物理上装不下那计算机在发生截断时为什么不干脆直接报错崩溃或者**把数值锁定在小盒子的最大值比如封顶在 32767**呢为什么不自动报错在 C 语言的哲学里有一条铁律“相信程序员Trust the programmer”。C 语言假设你完全知道自己在干什么。很多时候程序员是故意利用截断来做事情的比如提取颜色值RGB、计算哈希值、或者做密码学里的取模运算。在这些场景下截断丢弃高位正是算法需要的一步。如果编译器每次都报错这些底层算法就没法写了。为什么不封顶饱和截断把超出的值直接变成最大值比如把 50000 变成short的最大值 32767这种做法叫“饱和算术”。这在处理音频比如声音太大直接破音或图像时很有用。但是在基础数学逻辑中直接截断即取模运算能保持更好的数学一致性比如的规则依然成立而直接封顶会破坏很多基础算法的连贯性。因此通用 CPU 的默认指令都是直接截断。其实“截断”本身不是一个动作而是“强行把大数据放进小空间”所产生的自然物理后果。C 语言为了追求极致的速度并且为了让程序员能随心所欲地操作内存选择把这个“危险的权利”直接交给了你。这就好比 C 语言给了你一把极其锋利、没有护手的电锯切木头很快但一不小心就会切到手。正因为如此现代编程中有很多规范在避免这种错误比如静态代码检查工具会警告你不要把大类型赋值给小类型。