
说实话第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 0U4294967295U 0U假-1 0U4294967295U 0U真sizeof(int) -14U 4294967295U假-1U -14294967295U 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] }要么调整条件避免 0for (unsigned i len; i 0; i--) { unsigned idx i - 1; // 处理 arr[idx] }另外编译时打开-Wall -WextraGCC 会对这类符号比较给出-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_overflowint 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)另外几个高频恒等式也值得单独列一遍。它们看着不起眼解题时却能省很多时间。提取最低位的 1x -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; // x0 时是 0x0 时是 0xFFFFFFFF return (x ^ mask) - mask; }推导也简单mask为 0 时结果就是xmask为-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 上比背下来更能让你在以后的日子里记得住、用得上。