说实话,第2章是很多人读这本书时第一次真正“卡住”的地方。正文读起来字不多,概念也都能看懂,一翻开习题集却发现大脑一片空白:不是不会写代码,而是不知道题目在问什么。这篇文章不打算逐题讲解,而是换个思路——把第2章习题里真正反复出现的考点挑出来,按照“为什么这么考—怎么解—对应到工程里会遇到什么”来拆一遍。适合正在啃这本书的人,也适合那些工作几年后回来补基础、看数据表示和溢出问题总觉得“哪里不太对”的开发者。
1. 先搭骨架:第2章真正考核的知识点只有这几类
1.1 从存储到运算,这一章其实只讲了“机器怎么记数”
整章内容表面上有十六进制、字节序、整数编码、浮点数编码、位运算、溢出检测,但剥开看就是一条主线:计算机用固定长度的二进制位保存信息,同一串位模式换个“解释方式”就会变成完全不同的值。搞清楚这一点,半数习题的思路就有了。
教材的推进顺序大致是:先讲信息存储(字节、字、大端小端、布尔代数、移位),再讲整数表示(无符号数、补码、位扩展与截断),然后是整数运算(加法、乘法、除法、溢出),最后是浮点数(IEEE 754、舍入、精度)。这张表是按习题高频考点整理的,方便对照着看:
| 考点模块 | 核心问题 | 典型习题长什么样 |
|---|---|---|
| 存储与字节序 | 一个多字节整数在内存里怎么排 | 写代码判断机器是大端还是小端 |
| 整数表示 | 十六进制串当成 int / unsigned 分别是多少 | 给出 0x 串求十进制值 |
| 扩展与截断 | 短类型转长类型、长类型被截断后发生什么 | 判断(char)(short)0xFF之类的结果 |
| 整数运算 | 加法溢出、乘法溢出怎么检测 | 写tadd_ok一类函数 |
| 移位与掩码 | 算术右移和逻辑右移的区别 | 用位运算实现绝对值、取最低位 |
| 浮点编码 | 同一串位作为 float 时代表多少 | 按舍入规则填表格、比较浮点值 |
我在刷题之前如果先画一遍这样一张图,后面会顺畅很多。很多人觉得习题“跳”,其实就是没意识到:这一章的题目从来不是考C语言语法,而是考“位模式下,数字换了件马甲”。
1.2 为什么靠数学直觉做题必翻车
数学里的整数是无限的,想多大就多大。机器里的整数只有一个固定宽的寄存器,32 位下总共就2^32个状态。所以看似普通的加法,在计算机里其实是“模2^32”的运算。这就好比钟表:7 点加 8 点,你不会说 15 点,而是 3 点。表盘只有 12 格,超过就绕回来了。
习题里最容易出错的,全是这种“绕回来”的地方。比如-1 < 0U,直觉告诉你是真,但答案恰恰相反。为什么会这样?因为-1的位模式和4294967295U一模一样,转换之后比较的根本不是“负一和零”。这就是我标题里说的:先别急着用数学直觉答题,先把数用二进制或十六进制展开,看看两侧真正的位模式是什么,再套上题目要求的类型重新解释。刷这章题,草稿纸上的十六进制写得越勤,后面走得越顺。
1.3 整理一份“考点—工程映射”清单
光知道考什么还不够,还得知道“考这个到底有什么用”,否则刷完就忘。我把自己做过的映射列在下面,每个考点都能对应到一类真实工程问题:
- 无符号与有符号转换:对应
sizeof、strlen、文件长度、网络包长度等size_t场景,是长度校验漏洞的温床。 - 溢出检测:对应金额计算、计数器累加、数据结构扩容前的容量判断。
- 字节序:对应网络协议、文件格式、跨平台反序列化。
- 移位与掩码:对应权限位、状态标志、低层驱动寄存器操作。
- 浮点精度:对应科学计算、统计报表、几何运算里的误差问题。
做完整章习题后再回头看这张表,你会明显感觉题目“有了画面感”。不着急也没关系,先带着这个意识往下看具体问题怎么解。
2. 无符号与有符号混用:看起来是送分题,实际上是送命题
2.1 经典题:-1 < 0U到底是真还是假
教材里有两道配套选择题,名气非常大,其中一道就是判断-1 < 0U的真假。刚看到时几乎所有人都会脱口而出:这当然是真啊。但正确答案是假。
推导过程值得一步步来。在常见的 32 位平台上,-1的补码位模式是0xFFFFFFFF,即 32 个 1。当它和0U做比较时,C 语言的隐式类型转换规则要求把int转换为unsigned int,所以-1不再被解释成负一,而是被解释成UINT_MAX,也就是4294967295这个无符号大数。于是比较变成了:
4294967295U < 0U一个无符号数当然不可能小于 0,所以结果是假。
题目的反直觉点就在这:同一串位模式,你把它当 int 看是 -1,当 unsigned 看是最大无符号数。这个“换马甲”的过程,就是全章最核心的思维转换。换皮题也层出不穷,比如比较2147483647U和-2147483648的大小、判断某个表达式“永远为真吗”,本质都在考同一件事:两边类型不同时,C 到底按谁的标准解释这一串位。
2.2 隐式转换规则不是靠背的,靠“位模式换马甲”理解
C 语言的转换规则总结起来不算难:当int和unsigned int混用时,int会被转换为unsigned int,然后再比较或运算。难的是很多人记了规则却没法内化,遇到新式子还是靠猜。
我的办法是把“转换”想象成“换马甲”。举个例子:
| 表达式 | 转换后的比较 | 结果 |
|---|---|---|
-1 < 0U | 4294967295U < 0U | 假 |
-1 > 0U | 4294967295U > 0U | 真 |
sizeof(int) > -1 | 4U > 4294967295U | 假 |
-1U > -1 | 4294967295U > 4294967295U | 假 |
第一眼看去全是“送分题”,动笔一算基本全反。特别是sizeof(int) > -1,由于sizeof返回size_t,也就是无符号类型,右侧的-1会被换马甲成巨大的无符号数,所以“4 大于某个 40 亿级数字”当然是假。这个式子我见过不下十次被当成真。
这类题型在实际工程里最典型的映射就是strlen。函数返回size_t,如果你拿一个有符号的len去和它比较,一旦len是负数,负值会被转换成超大无符号数,长度校验直接失效。这种问题隐蔽在,代码单测往往只传正数,根本测不出来。
2.3 循环变量和无符号比较:一个能亲手复现的死循环案例
讲完题再讲一个实操中一定会撞上的坑:无符号循环变量。某同学写过这样一段倒序遍历代码:
unsigned len = 5; for (unsigned i = len - 2; i >= 0; i--) { // 处理 arr[i] }直觉上i从 3 开始,减到 0 就该停了,但这段代码会死循环。原因是i是无符号数,i >= 0永远为真。当i等于 0 再执行i--时,结果不是 -1,而是UINT_MAX,也就是 4294967295。循环重新开始,条件依然满足,永不停歇。
调试经验:遇到这类循环卡死,先在循环里打印一次i,看到类似4294967295的数字,问题基本就定位了。修法也简单——要么把循环变量改成有符号类型:
for (int i = (int)len - 2; i >= 0; i--) { // 处理 arr[i] }要么调整条件,避免>= 0:
for (unsigned i = len; i > 0; i--) { unsigned idx = i - 1; // 处理 arr[idx] }另外,编译时打开-Wall -Wextra,GCC 会对这类符号比较给出-Wsign-compare警告。警告不是摆设,它几乎是在直白地告诉你:“你这里类型混了。”
3. 整数溢出:补码运算为什么总在边界处“卡住”
3.1 求相反数的题目,INT_MIN是唯一的例外
教材讲到补码时一定会提“取反加一”这个求相反数的技巧:要算-x,就把x的位模式全部取反再加 1。大多数情况这都成立,但有一个边界值例外——INT_MIN。
32 位下,INT_MIN的位模式是0x80000000,也就是最高位为 1、其余全 0。对它取反得到0x7FFFFFFF,加 1 回到0x80000000。换句话说:
int x = INT_MIN; int y = -x; // y 还是 INT_MIN补码的取值范围本身不对称:最小数是-2^31,最大数是2^31-1,正方向少了一个数。所以-INT_MIN在数学上应该是+2^31,但2^31这个值在 32 位补码里根本不存在,溢出后就绕回了自己。
这个知识点在 C 标准里还带来一个推论:abs(INT_MIN)是未定义行为。很多新人写“判断两个数相加后是否为正数”之类的代码时,喜欢用abs先取绝对值再比较,一旦输入是INT_MIN,整个判断就是错的。凡是会走到边界值的代码,都要单独把这个情况列出来。
3.2 溢出检测题的标准解法
习题里有一类高频题:写一个tadd_ok(x, y),判断补码加法x + y是否溢出,不溢出返回 1。很多人的第一反应是“直接相加,看结果是否落在合法区间”,但这在逻辑上绕不过去——你相加的时候溢出已经发生了。
正确的推理是:补码加法的溢出只有两种可能——两个正数相加得到负数,或者两个负数相加得到正数。一正一负相加,结果必然落在两个操作数之间,不可能溢出。代码如下:
int tadd_ok(int x, int y) { int sum = x + y; int pos_over = (x > 0 && y > 0 && sum < 0); int neg_over = (x < 0 && y < 0 && sum >= 0); return !(pos_over || neg_over); }课程教材讲原理时给的就是这个思路。实际编译运行时,GCC 默认情况下也按补码的“回绕”行为处理,所以sum能拿到那个溢出后的位模式。判断逻辑就一句话:溢出一定改变符号。正数加正数结果符号变成负,说明绕回了负半区;负数加负数结果符号变成非负,说明绕回了正半区。
注意:C 标准其实不保证带符号整数溢出后的行为,严格讲测试环境里的“绕回”只是常见实现。真实工程里我不建议依赖这种行为,下面会讲更稳的方式。
有了这个函数,顺带就能解一批变体题:判断减法是否溢出、判断乘法是否能安全执行、判断y是否为 0 或INT_MIN时除法是否合法。核心逻辑都一样,抓住“符号有没有被翻转”。
3.3 溢出检查在工程里的更稳写法
如果这是刷题,手写tadd_ok没问题。但如果写的是线上代码,我的建议是用编译器提供的内建函数。GCC 和 Clang 都支持__builtin_add_overflow:
int a = INT_MAX; int b = 1; int result; if (__builtin_add_overflow(a, b, &result)) { // 处理溢出 } else { // 使用 result }这个函数返回 1 表示溢出,同时会把“溢出后截断的结果”写进第三个参数,行为和练习题里的tadd_ok一致,但不需要依赖具体编译器的溢出行为。类似的还有__builtin_sub_overflow和__builtin_mul_overflow。
工作中另一个常见的折中做法是:先把操作数转到更宽的类型再运算。比如 32 位环境里做加法前转成long long或uint64_t比较结果,再把结果截回原类型。这样判断逻辑直观,而且可读性好。很多基础库在处理数值范围时的内部逻辑,本质上就是这几行的延伸。
4. 位运算题:记住这几个恒等式,比现场推导数更快
4.1x & (x - 1):清零最低位的 1
第2章练习题里有大量“仅用位运算实现某功能”的题目,比如判断一个数是不是 2 的幂、统计二进制中 1 的个数。它们的基础是一个恒等式:
x & (x - 1)这个表达式的作用是:把x的最低位的 1 变成 0,其余位保持不变。以x = 0b101100为例:
x = 101100 x - 1 = 101011 按位与 = 101000最低位的那个 1 被清掉了。原因在于:x - 1为了让低位的 0 能减 1,必须向最近的那个 1 借位,于是这个 1 变成 0,它后面的低位全部从 0 变 1;按位与之后,这些新出现的 1 又和原来的 0 对消,只有借位路径上那个 1 被清零。
这个恒等式有两个最常用的落地场景。判断 2 的幂:
int is_power_of_two(unsigned x) { return x > 0 && (x & (x - 1)) == 0; }统计 1 的个数:
int count_ones(unsigned x) { int count = 0; while (x) { x = x & (x - 1); count++; } return count; }第一种做法比循环移位快在“跳过了所有 0”,第二种在x稀疏时优势更明显。面试里被问到“怎么快速判断 2 的幂”,很多人第一反应是循环试除,其实这行位运算就是标准答案。
4.2 取绝对值、提取最低位、取第 k 位
除了x & (x-1),另外几个高频恒等式也值得单独列一遍。它们看着不起眼,解题时却能省很多时间。
提取最低位的 1:
x & -x原理是-x = ~x + 1,补码取负时最低位的那个 1 被原样保留,更高位全部取反后和原高位对消。比如x = 0b101100,结果是0b000100。
取第 k 位和取低 k 位:
(x >> k) & 1 // 第 k 位的值 x & ((1 << k) - 1) // 低 k 位的值这里注意移位优先级,x >> k & 1在 C 里其实是(x >> k) & 1,因为移位优先级高于按位与。写成带括号的形式能少踩不少坑。
另一道常见习题是“只用位运算求绝对值”:
int my_abs(int x) { int mask = x >> 31; // x>=0 时是 0,x<0 时是 0xFFFFFFFF return (x ^ mask) - mask; }推导也简单:mask为 0 时结果就是x;mask为-1时,x ^ (-1)等于~x,再减-1等于~x + 1,恰好是-x。这题埋在练习里的目的,就是让你理解算术右移生成的符号掩码怎么参与运算。
注意:这个
my_abs在x = INT_MIN时仍然会返回INT_MIN,因为正半区容不下它的绝对值。位运算本身能给出一个“看起来自洽”的结果,但数学上这个值是错的,这是位运算题的固有边界。
4.3 解位运算题的习惯:先写十六进制,不要写十进制
线上一排查问题时候,我发现很多同事解位运算题喜欢盯十进制,这是最容易出错的地方。十进制和二进制之间隔着一次换算,而十六进制和二进制是表格对应的:一个十六进制位正好是四个二进制位,一眼就能看清哪些位是 1。
看到0x55,马上知道它是01010101;看到0x80,知道是10000000。你再去看0x55555555这类练习里常见的掩码时,就能直接看出它是在隔一位取 1,完全不用在脑子里转成十进制再分解。
调试小技巧也顺手分享一个:需要看某个数的位模式时,printf("%#x\n", x)比printf("%d\n", x)直观十倍。配一个好看的十六进制数值,比对着十进制数反推位模式快得多。这个习惯我从刷第2章一直用到现在的线上问题排查。
5. 浮点习题:每一个“约等于”背后都有一套严格规则
5.10.1 + 0.2为什么不等于0.3
浮点部分是很多人的噩梦,因为题目不再给你整齐的位模式,而是让你理解“近似”。最普及的现象就是:
if (0.1 + 0.2 == 0.3) { // 永远不会进入 }原因得从二进制小数的角度理解:十进制的 0.1 和 0.2,在二进制里其实是无限循环小数。double只能保存 53 位有效尾数,所以每个十进制小数存进去的时候已经做了一次舍入。两个被舍入过的近似值相加,得到的近似值不一定等于 0.3 被舍入后的近似值。
教材在这一块反复考察“舍入”的四条规则,默认模式叫“就近舍入,平局向偶”。翻译成人话就是:精确值落在两个可表示数之间时,选接近的那个;如果正好在中间,选尾数末位为偶数的那一个。为什么要这个看起来更复杂的规则?因为如果总是逢五进一,大量舍入误差会在统计上朝单一方向累积,“向偶”能让误差在对半场景下互相抵消。
再补充一个我认为很值得现场复现的例子:printf("%.2f", 1.005)在很多环境下输出的是1.00而不是1.01。原因是1.005的二进制存储值可能略小于十进制 1.005,格式化输出按舍入取两位时,值没有达到进位门槛。这个例子不适合拿来当绝对结论验证平台,但非常适合用来理解“十进制小数和二进制小数不是一回事”。
5.2 大整数转浮点:16777217会去哪里
浮点精度问题在工程里最容易被忽视的形态是:大整数转成浮点数后悄悄变了值。float的尾数有 23 位,加上隐含的 1 位,一共 24 位有效精度,也就是它能精确表示的整数最多到:
2^24 = 16777216从16777217开始,相邻两个可表示的float之间的间隔变成 2。所以:
int x = 16777217; float f = (float)x; printf("%d\n", (int)f); // 常见输出:1677721616777217在转float时被舍入成了 16777216。double因为尾数更大,54 位左右的有效位数,能精确表示的整数上限是2^53 = 9007199254740992,超过这个数同样会丢精度。
这个知识点跟业务代码直接相关:如果有人用double存订单号、用户 ID 或者时间戳之类的大整数,当 ID 超过2^53就会出现“两个 ID 一算变相等”的离奇 bug。你在调试这种问题时,八成最后会绕回这里。
5.3 浮点比较和打印的落地姿势
浮点习题刷完,真正记住的不只是规则,更重要的是工程里怎么落地。第一件事:不要用==比较浮点数。我见过太多代码写:
if (a == b) { ... }两个在数学上应该相等的浮点数,因为舍入路径不同,最后几位不一样,判断直接不成立。常见替换方案是绝对误差:
if (fabs(a - b) < 1e-9) { ... }但绝对误差有个问题:当数值本身很大时,比如在1e12这个量级,两个数的差小于1e-9几乎不可能,因为连表示精度都远大于这个误差。更稳的是相对误差:
int nearly_equal(double a, double b, double rel_eps) { double diff = fabs(a - b); double scale = fmax(fabs(a), fabs(b)); return diff <= rel_eps * scale; }第二件事:调试浮点数时打印要足够给够位数。printf("%f", x)默认只有 6 位小数,两个数明明不同,打印出来看着一模一样。我会用printf("%a", x)看十六进制浮点,或者printf("%.17g", x)保留足够多的有效数字,让值能“原样往返”不被隐藏差异。刷完这一章再回头看线上数据比对,很多“莫名其妙相等/不相等”的问题,其实一眼就能定位到精度上。
6. 刷完这一章,我沉淀下来的几条编码习惯
6.1 类型混用排查三步法
写代码时完全避免有符号和无符号混用几乎不可能,但可以把风险压到最低。我总结了一个三步法,适合在代码评审和排查问题时直接用:
第一步,编译时把警告打开。GCC 和 Clang 至少开-Wall -Wextra,条件允许再加-Wconversion和-Wsign-compare。警告信息指出“比较双方符号不同”时,不要无视。
第二步,审查任何和sizeof、strlen、容器的size()有关的比较或运算。这些函数返回的都是无符号size_t,一旦有符号变量参与,就进入第2章习题的世界。
第三步,统计数量、记录长度这类变量,要么统一用无符号类型,要么统一用足够大的有符号类型,不要心情好就int,心情好就又加一个unsigned。我见过一个日志模块,len是int,却被拿去和strlen的结果比较,某次len为负数时长度检查直接失效,数据流被破坏,排查了很久才定位到“只是类型没对齐”。
6.2 写整数函数前先问过这组边界值
第2章习题做多了以后,我写任何涉及整数的函数都会先问自己几个问题:输入可能出现 0 吗?可能出现INT_MIN吗?可能出现INT_MAX吗?加法和乘法会不会越过边界?这三个边界值对应的位模式分别是0、0x80000000、0x7FFFFFFF,出现频率远超想象。
判断溢出时不要先做运算再判断,因为运算本身可能已经越界。要用我前面写过的符号判别法、更宽类型,或者编译器内建函数。abs(INT_MIN)不存在、INT_MIN / -1存在整数溢出风险,这类边界陷阱背下来比现推更快。
这些小点看似零碎,但它们几乎都是第2章习题的原型。刷题时多花十分钟把边界值代入运行一遍,比盲目做十道新题管用得多。
6.3 我最后悔的是没早点背下来的几个位模式
最后说点个人体会。当年第一次做第2章时,我觉得这些位模式、溢出检测离日常开发太远,草草刷完就翻篇了。直到后来有一次排查跨平台文件格式的解析问题,数据总是差几个字节,怀疑了编码、怀疑了协议,最后才发现是字节序和大端小端判断写错——那一瞬间,第2章那句“信息在内存里就是一堆位,解释权在你”直接砸回脑海里。
如果让我重刷一遍,我会把三件事做在前面:把0x80000000、0x7FFFFFFF、0xFFFFFFFF这三个数的位模式刻进脑子;把类型转换、溢出检测、位运算、浮点舍入这四类题优先做透,而不是按页码从头刷到尾;准备一张草稿纸,强制自己把所有数先写十六进制再比较。
这章的习题确实不适合“追求数量”,它更适合反复琢磨“为什么”。把每个反直觉的结论对应到一位真实的线上 bug 上,比背下来更能让你在以后的日子里记得住、用得上。