ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C/C++整数溢出:从回绕到未定义行为,检测与防御全解析

C/C++整数溢出:从回绕到未定义行为,检测与防御全解析 先看一段几乎每天都可能出现在生产代码里的校验逻辑bool check_packet(size_t len, const uint8_t *data) { if (len 16 4096) { return false; /* 超出上限拒绝 */ } memcpy(recv_buf, data, len); return true; }你盯着这段代码五秒钟大概率看不出毛病。但就是这种“看着完全正常”的代码曾经让某移动设备厂商的固件在特定网络环境下反复崩溃。问题出在len 16上当调用方传入的len非常接近SIZE_MAX64 位系统下是 18446744073709551615时这个加法会直接回绕成一个很小的数比如 15。于是 15 4096 不成立检查通过紧跟着的memcpy就会把几百 MB 的数据写进几 KB 的缓冲区。这就是无符号整数溢出问题。在这个领域里还有一票更难缠的兄弟带符号整数溢出、有符号无符号混用、类型截断、下溢……它们表面上是“数值超出了表示范围”实际上的后果却往往是内存损坏、远程代码执行、死循环以及各种让你半夜爬起来看日志的线上事故。这篇文章我想把无符号整数和带符号整数的溢出问题从头到尾捋一遍从二进制表示开始讲到 C 标准为什么对这两种溢出“区别对待”再到真实漏洞的演变路径最后给出一套可以直接抄进项目的检测和防御方案。适合所有写 C/C 的开发者、嵌入式工程师、做安全评审的同行以及那些正在被“数组越界却找不到根因”折磨的人。1. 先从根上理解无符号与带符号整数到底差在哪1.1 一样的二进制不一样的世界从位宽与范围说起整数在现代 CPU 里的表示方式本质上就是若干位二进制数。8 位、16 位、32 位、64 位位宽定了能表达的“组合数量”也就定了N 位二进制数一共有 2^N 种组合。无符号整数把全部 2^N 种组合都用来表示非负数所以uint8_t能表示 0 到 255uint32_t能表示 0 到 4294967295。有符号整数需要把一半组合分给负数所以int8_t能表示的范围是 -128 到 127int32_t是 -2147483648 到 2147483647。这个“一半分给负数”不是随便切一刀。C 和现代主流硬件都采用补码Two‘s Complement表示负数这带来一个很多人没意识到的数学事实有符号整数的取值范围是不对称的负数那边总是比正数多一个。拿 8 位举例127 是最大正数-128 是最小负数INT8_MIN的绝对值比INT8_MAX大 1。这个不对称性会在后面谈除法、取反和溢出检查时反复出现。我经常用一个类比帮助新人理解这种边界一个 3 位数里程表最大显示 999再走 1 公里就回到 000。无符号溢出就是里程表回零而带符号溢出更像一个设计上有 bug 的仪表盘——它回零后你甚至不知道现在是 -000 还是 000标准里干脆不管了。实际开发中很多人喜欢“哪个类型都差不多”地胡乱用反正能塞进去就行。这种态度在范围足够大时确实没什么问题可一旦数据量增长、或者代码被复用到其他位宽的平台上当初偷的懒就会加倍奉还。1.2 补码的巧妙设计为什么负数要这么存先看一个关键公式对任意 N 位整数 x它的相反数 -x 的补码等于按位取反再加 1也就是-x ~x 1。听起来抽象实际用 8 位验证一下就懂了x 1二进制是00000001取反是11111110加 1 是11111111也就是 -1。再验证一下 1 (-1)00000001 11111111 1_00000000溢出的最高位被丢弃剩下的 8 位全是 0结果就是 0数学上完全自洽。为什么需要这个设计因为 CPU 的加减法电路不需要区分“正数加负数”和“正数减正数”一律按加法处理。减法指令在硬件层面等价于“把减数取补码再加过来”。如果用原码存负数加法器就得单独处理符号位还要在符号位不同时改成执行减法电路复杂性大幅上升。补码让加法和减法统一成了一件事这是整个整数运算体系的根基。这里顺便提一个面试里常见的硬件概念CPU 的标志寄存器里有进位标志 CF 和溢出标志 OF。对于无符号运算看最高位有没有进位出去CF1 说明回绕对于有符号运算要看最高位进位和次高位进位是否一致两者不一致异或为 1则说明结果超出了正负范围。很多人背过这个结论但不理解其实它就是补码电路的自然结果——回绕和溢出的判断逻辑在硬件层面就是两套不同的检查。1.3 边界上的数字那些容易翻车的临界值我先列一张 8 位整数对照表这比死背公式管用得多二进制无符号 uint8_t有符号 int8_t00000000000111111112712710000000128-12811111111255-1注意最后两行。同一个11111111无符号看是 255有符号看是 -1。这也是为什么有符号数打印出来时用错%d和%u会得到完全不同的值——那通常不是打印的问题而是数值本身被解释成了另一种语义。实际开发里最容易被忽略的是这些边界INT32_MAX 1在有符号视角下会回绕到INT32_MIN也就是 2147483647 变成 -2147483648符号翻转。UINT32_MAX加 1 变成 0直接归零。SIZE_MAXsize_t 的最大值减 1 再减 1……一路减到 0再减 1 又变回SIZE_MAX。下溢和上溢是一回事只是方向不同。很多初学 C 的人在写循环时直觉上认为“i 会变成负数从而退出循环”但遇到无符号类型时这个直觉是错的。i 从 0 减 1 不会变成 -1而是回绕成那个类型的最大值循环条件依然成立于是死循环就诞生了。2. 两类溢出的本质差异一个回绕一个未定义2.1 无符号整数溢出C 标准里的模运算规则C 标准对无符号整数有一种“很体贴”的说法无符号运算永远不会溢出因为结果会以 2^N 为模自动约简。什么意思就是uint8_t x 200; x 100;最终 x 等于 44因为 200 100 300而 300 mod 256 44。整个计算过程在数学上是确定的程序怎么运行结果都一样不管什么编译器、什么优化等级。这个行为对底层开发者其实是有用的。比如网络协议里的序列号、嵌入式里的滚动计数器、TCP 的时间戳比较这些场景都依赖固定的回绕规则来做差值和轮回判断。只要参与运算的操作数都是无符号类型并且你能接受“模 2^N”这个数学法则那么结果就是可预期的。正是因为“可预期”很多人才会低估它的破坏力。无符号溢出真正危险的地方不是数值算错而是算错之后还被拿去当内存长度、数组索引、循环边界使用。数值错一点可能是小事但长度错 4GB 的偏差后面跟着的就一定是缓冲区灾难。2.2 有符号整数溢出为什么它是“未定义行为”有符号溢出可没这么温和。C 标准明确写如果在表达式求值期间出现异常条件例如有符号溢出行为未定义。未定义行为意味着什么它不是说“结果是一个随机值”而是说“程序的一切行为都不再受约束”。编译器可以假设有符号溢出永远不发生然后基于这个假设对代码做各种优化。同一段代码在不同优化级别、不同编译器版本、甚至同一编译器不同版本下表现可能截然不同。举一个非常经典也是我经常拿去考校新人的例子int is_overflow(int x) { return x 1 x; }数学直觉告诉我们当 x INT32_MAX 时x 1 溢出变成负数于是 x 1 x 不成立函数应该返回 0。但编译器在 -O2 下会直接把这个函数优化成“永远返回 1”。因为编译器先假设 x 1 不会溢出有符号溢出是 UB被假设为不可能发生那么 x 1 在数学上必然大于 x整个表达式恒为真于是代码被优化成一句return 1;。这个例子生动地展示了 UB 的可怕之处。你写的代码本来想“检测溢出”结果在优化后的二进制里那个检测代码根本不存在。这就是为什么我反复强调有符号溢出不是“算错了”而是你精心构造的防御逻辑可能在编译器眼皮底下被整体抹掉。2.3 编译器优化如何让有符号溢出雪上加霜再来看一个更“狠”的例子它展示了 UB 对整个控制流的影响int process(int n) { int next n 1; if (next n) { return -1; /* 检测到溢出走错误分支 */ } return next; }看起来这个函数处理了 n INT32_MAX 的情况。但编译器在优化时发现next n这个判断涉及有符号加法它假定 n 1 永远不溢出于是next n永远为假。接下来编译器会做什么它会认为第 4 行的 if 分支是死代码直接删掉。于是你精心写的错误处理路径在高优化等级下彻底蒸发。这种情况在真实项目中非常容易踩到。尤其是从网上抄来的“溢出检查”代码片段很多都是这种反模式。真正正确的检查方式是把比较放在加法之前例如if (n INT32_MAX - 1) { /* 会溢出 */ }这样不触发 UB编译器也无法基于“不可能溢出”来做删除优化。还有一个值得警惕的场景编译器看到a b又看到a c 1; b c;它会利用“c 1 不溢出”的假设推断出a b恒成立然后把整个 if 分支优化掉。这听起来像是优化带来的额外福利但一旦 c 实际运行时真的处于边界值程序行为就和源码完全对不上号了。2.4 别把几类“溢出”混为一谈整数、内存、堆栈、DOM明确了整数溢出的两类形态之后我要花点篇幅把它们和日常开发中遇到的其他“溢出”概念区分开。这个话题特别适合放在一起讲因为不少同行在排查问题时会把这些概念搅成一锅粥导致方向完全走偏。整数溢出算数溢出数值运算的结果超出该整数类型能表示的范围。这是本文主角分为无符号回绕和有符号 UB 两类。缓冲区溢出 / 堆栈溢出程序向一个固定大小的内存区域写入超出容量的数据。堆栈溢出往往不是根因而是整数溢出的“下游症状”。比如前面len 16回绕后memcpy写越界这就是缓冲区溢出如果那个缓冲区恰好定义在栈上就会表现为栈溢出。FreeRTOS 的堆栈溢出检测configCHECK_FOR_STACK_OVERFLOW检测的也是这一类运行时内存问题而不是整数运算问题。内存溢出OOM程序向系统申请的内存超过了可供给量。比如用 Apache POI 的XSSFWorkbook读取超大 Excel 时整个工作簿被加载进内存导致堆耗尽或者 RedisTemplate 批量操作 ZSet 时序列化数据量过大导致栈上临时对象爆炸。这类问题通常从“减少对象创建、用流式解析、分批处理”入手和整数溢出也有关联如果长度计算错了程序可能会在循环里反复申请超大内存。数据截断truncation把一个宽类型窄化赋值时高位被丢弃。比如uint32_t x 0x12345678; uint16_t y x;y 会变成 0x5678。它跟整数溢出的关系是表亲溢出是“运算结果超过范围后被模约简”截断是“赋值时低位保留、高位丢弃”。两者叠加时最危险——先截断再运算或者先运算再截断都可能出现匪夷所思的数值。前端 DOM 溢出元素内容尺寸超过容器尺寸比如el.scrollWidth el.clientWidthVue 3 里用自定义指令判断是否显示 Tooltip 就是这类。这属于视觉排版层面的“溢出”和整数运算没有任何关系但它也叫溢出排查时别被关键字带偏。把这些概念单独列出来是希望大家遇到“内存溢出”四个字时第一时间先判断这是堆内存不够还是栈被写穿了还是长度计算回绕后导致的问题定位层级错了后面再努力都是在瞎忙。3. 真实世界里的溢出事故从长度校验到内存灾难3.1 malloc 前的长度计算一个回绕引发的堆溢出上一部分铺垫了那么多理论现在看一个完整的攻击链。假设有一段网络报文处理代码uint32_t header_len; uint32_t body_len; size_t total header_len body_len; uint8_t *buf malloc(total); if (!buf) return; memcpy(buf header_len, body, body_len);这里的header_len和body_len都来自网络报文攻击者可控。如果它们的和超过了UINT32_MAXtotal就会回绕成一个很小的值比如header_len 0xFFFFFFFF、body_len 0x00000002时total 1。malloc(1)成功分配一个字节然后memcpy往buf header_len这个指针加法本身已经飞到天边去了拷贝 2 字节。这已经不是简单的堆溢出而是任意地址写。这种“长度计算 → 内存分配 → 数据拷贝”三连是二进制漏洞里最经典的套路从早期 Windows 的 JPEG 解析漏洞到各种格式解析库的崩溃无数 CVE 都遵循这个模式。防御方法无非两点一是加法前检查是否超过上限二是用不溢出的安全算术函数后面会讲。在安全评审时我只要看到“内存分配大小”这个位置出现了加法或乘法就必然要追查操作数的来源和类型。这是最容易出问题的地方没有之一。还有一个隐蔽变种不是相加而是“减去结构体大小”。比如size_t total file_size - HEADER_SIZE;当file_size小于HEADER_SIZE时结果会回绕成极大的值malloc很可能返回 NULL但如果代码不检查空指针就直接memcpy照样崩溃。所以凡是减法后直接用于分配或拷贝的都要对“被减数小于减数”的情况做处理。3.2 循环与索引下溢如何带来死循环和越界除了长度计算循环索引是另一个翻车重灾区。看这段代码void remove_elements(int *arr, size_t n) { for (size_t i n - 1; i 0; --i) { arr[i] 0; } }当n 0时n - 1直接回绕成SIZE_MAX而i 0对无符号类型恒成立这个循环会从SIZE_MAX一路往下写到负地址空间。即使程序不崩溃它也把栈上或者堆相邻区域全清零了。稍微“正常”一点的版本是for (int i n - 1; i 0; --i)如果n是size_t而且值为 0n - 1回绕成SIZE_MAX然后被转换成int有符号变成 -1循环条件i 0不成立循环根本不会执行。看起来“没问题”但这是侥幸而非保证了正确性。处理这类倒序循环的安全姿势是for (size_t i n; i 0; --i) { arr[i - 1] 0; }这样n 0时循环体一次都不执行也不会碰触下溢边界。数组索引相关的典型事故还有“索引类型是int但数组大小是size_t”。比如int idx get_index(); if (idx array_size) { use array[idx]; }这里idx array_size会把有符号的idx转换成无符号再比较一旦idx是负数转换后就是一个巨大的无符号数比较结果变成“负数小于任意数组大小”为假于是索引被拦下来了。但如果代码写的是if (array_size idx)同样的转换规则依然生效看起来安全。真正危险的是把idx直接当数组下标用还没做边界检查——负的idx在 C 里是合法的指针算术方向直接访问数组前面的内存。3.3 热搜场景排查思路内存溢出、堆栈溢出、tooltip 溢出的关联与区分最近总有人问我几个看起来“不相干”的溢出问题XSSFWorkbook内存溢出、redistemplate.opsForZset().add栈内存溢出、FreeRTOS 堆栈溢出检测、Vue 3 里用指令判断 el-tooltip 是否溢出。它们虽然不全是整数溢出但排查逻辑有很多相通的地方我按场景拆解一下。XSSFWorkbook 内存溢出这是 Apache POI 读取 Excel 时最常见的坑。XSSFWorkbook默认把整个 Excel 文件解析成一个巨大的对象图行、列、单元格、样式、公式全在内存里。一个 10MB 的 xlsx 展开后占用几百 MB 堆内存很正常。解决思路一般是用SXSSFWorkbook或 SAX 事件模型流式解析只保留当前行数据避免全量驻留。和整数溢出的关联点在于如果业务代码用int存行号、列号并且计算单元格区域大小时出现了溢出也会导致多读或少读单元格进而引发内存累积异常。所以排查这种问题先看读取范围计算是否准确再看解析方式是否需要切换。RedisTemplate 操作 ZSet 栈内存溢出常见于opsForZSet().add(key, value, score)在一个循环里批量塞入大量成员或者 value/score 的序列化结果异常膨胀。Spring Data Redis 在序列化和反序列化时会在栈上创建大量中间对象数据量大到一定程度就会触发栈溢出或堆压力。处理办法是分批提交、检查 Redis 连接池配置、确认 value 的序列化器确实是你要的那种比如 Jackson 序列化远比 String 序列化膨胀。排查这类问题的关键同样是“先看数据量增长是否正常”如果某个计数变量类型不够大导致循环次数失控那就是整数溢出问题换了一身衣服。FreeRTOS 堆栈溢出检测FreeRTOS 的configCHECK_FOR_STACK_OVERFLOW提供两种检测手段一种是在任务切换时检查栈指针是否越界一种是在任务创建时往栈顶写一个标记值任务切换时检查标记值是否被破坏。两种方法都无法 100% 覆盖所有溢出场景因为溢出可能发生在两次检查之间也可能发生在中断上下文里。我在实际项目中会把钩子函数vApplicationStackOverflowHook接上在钩子里保存当前任务名和剩余栈量方便事后定位。这个场景和整数溢出的关系在于很多栈溢出的根因是局部数组长度算错了而长度又来自某个无符号减法最后数组写穿、栈被踩烂。Vue 3 自定义指令判断 Tooltip 是否溢出这是前端里的“溢出”判断原理是el.scrollWidth el.clientWidth如果内容宽度大于容器可显示宽度就显示 Tooltip。实现上就是在mounted和updated里检查并动态设置v-show或直接绑定disabled属性。这个场景和整数溢出完全无关但它说明“溢出”这个词在不同领域各有各的含义排查问题前先把术语对齐能省去大量无用功。3.4 计数器和时间戳溢出不只在崩溃那一刻整数溢出造成的危害不一定是立即崩溃更多时候是“潜伏一段时间后才触发”。这也让它成为最难排查的 bug 类型之一。最著名的潜伏型溢出是 2038 年问题如果系统用 32 位有符号time_t表示时间那么在 2038 年 1 月 19 日之后这个值会超出INT32_MAX变成负数。所有依赖时间排序、时间差、时间显示的模块都会同时暴雷。嵌入式领域里为了省资源还在大量使用 32 位time_t这个问题至今没有完全消失。另一个我亲身踩过的坑是毫秒计数器。设备上有段逻辑用uint16_t记录系统运行毫秒数用来判断“两个事件是否在 50ms 内”。当计数器到了 65535 再 1 回绕成 0 时如果直接用b - a和 50 比较在回绕边界会得到巨大的差值事件顺序判断完全错误。正确做法是利用无符号减法的回绕特性写成(uint16_t)(b - a) 50因为无符号减法的结果也是模 2^16 的回绕在这个表达式中恰好是自洽的。很多时间相关的库如 Linux 内核的jiffies都是靠这种规则来规避回绕问题的关键在于“相减的两边都要是无符号类型且差值要小于类型范围的一半”这样才能通过判断符号位来区分新旧的先后。4. 现在就能上手的检测与防御组合拳4.1 编译器警告与 Sanitizer 的正确配置防御整数溢出不能只靠人肉 review第一道防线必须是编译器。我在团队里强制要求以下编译选项-Wall -Wextra -Wconversion -Wsign-conversion -Werror-Wconversion能抓出隐式类型转换可能导致的值改变比如long赋给int、int赋给uint32_t这类隐式窄化。-Wsign-conversion专门盯着有符号无符号之间的隐式转换这是最容易诱发整数语义混乱的源头。但编译器警告只能抓静态可见的类型问题抓不到运行时的溢出。所以第二道防线是 Sanitizer这是现代 C/C 开发里性价比最高的动态检测工具没有之一。在测试和 CI 环境里一定要开# GCC / Clang gcc -fsanitizeundefined -fsanitizeaddress -g main.c -o main ./main-fsanitizeundefinedUBSan会在有符号溢出发生时打印一条运行时错误比如runtime error: signed integer overflow: 2147483647 1 cannot be represented in type int-fsanitizeaddressASan能检测缓冲区溢出、栈越界、堆越界、释放后使用等内存错误它和 UBSan 配合使用基本能覆盖“溢出值被拿去写内存”这一整条链路。在 CMake 里可以这样组织 Debug 和 CI 构建add_compile_options(-Wall -Wextra -Wconversion -Wsign-conversion) if(CMAKE_BUILD_TYPE STREQUAL Debug OR ENABLE_SANITIZER) add_compile_options(-fsanitizeundefined -fsanitizeaddress -fno-sanitize-recoverall) add_link_options(-fsanitizeundefined -fsanitizeaddress) endif()重点说一下-fno-sanitize-recoverall这个选项让 Sanitizer 在检测到错误时立即中止程序而不是打印完继续跑。如果让它继续跑程序可能带着被污染的状态走到后面反而掩盖了真正的错误现场。4.2 写出不会溢出的安全代码检查方法、内置函数与库编译和动态检测是事后补救最重要的还是写代码时就避开陷阱。我把实践中验证过有效的方法整理成几条。加法溢出检查不要写成if (a b LIMIT)因为a b本身可能已经溢出了。正确姿势是if (a SIZE_MAX - b) { /* 溢出处理错误 */ }它的原理是把比较挪到加法之前相当于“b 还能加多少就接近上限了”。乘法溢出检查类似的套路if (a ! 0 b SIZE_MAX / a) { /* 溢出 */ }先判断 0再判断乘数是否超过“最大值除以另一个乘数”的商。上面两种写法需要开发者细心处理边界。如果有条件更推荐直接使用编译器内置的溢出检测函数。GCC 和 Clang 都提供了一组__builtin_*_overflow函数size_t a, b, result; if (__builtin_add_overflow(a, b, result)) { /* 溢出 */ } if (__builtin_mul_overflow(a, b, result)) { /* 溢出 */ }a b的结果会写到result函数返回 1 表示溢出返回 0 表示正常。这套函数在 GCC 5.0 和 Clang 3.4 之后都可用性能开销也很小在 PC 和嵌入式平台都能用。Windows 上对应的是 MSVC 的_UIntAdd、_UIntMul等 intrinsics。另外C23 标准还引入了stdc_add_overflow等标准库函数未来的 C 代码可以跨编译器使用统一的溢出检查 API。如果团队里不允许用编译器特性可以用现成库。开源的 SafeInt微软出品和 C 的 Boost.SafeInt 都在内部做了一整套溢出检查重载了所有算术运算符遇到溢出会抛异常或触发错误回调。缺点是多多少少有点侵入性大型老项目改造起来成本不低。最后是类型选择的经验在“分配内存大小”“数组索引”“循环计数”这三类场景里我倾向于统一使用size_t但在减法、倒序循环、负值判断处格外小心在“协议解析”“帧序号”“业务计数”等场景优先用有符号的int32_t因为它和 0 比较、做差值、传给第三方库时的行为都更符合直觉。用无符号类型之前先问自己一句这个变量的值这辈子会变成负数吗如果会就别用无符号。这句灵魂拷问能帮我挡掉不少坑。4.3 不同语言对溢出的态度回绕、异常还是任意精度如果你不只写 C/C会接触多种语言那么了解它们对整数溢出的不同策略尤其重要。因为从一种语言迁移到另一种语言时最容易带着“旧的溢出预期”去理解新的行为。语言有符号溢出行为无符号溢出行为备注C/C未定义行为模 2^N 回绕需要开发者自行防御Java回绕无内置无符号类型注意Integer.MIN_VALUE / -1Go定义良好的回绕定义良好的回绕需要手写判断才知溢出RustDebug 下 panicRelease 下回绕同左提供checked_/saturating_/wrapping_系列方法Swift算术溢出直接崩溃无内置无符号类型提供、-、*显式回绕Python任意精度不溢出无此类型但 numpy 里的定宽整数会溢出JS数值是 IEEE 754 双精度同左但BigInt是任意精度Java 里有个冷门但经典的有符号溢出特例Integer.MIN_VALUE / -1的结果不是 2147483648那超出 int 范围了而是Integer.MIN_VALUE本身即除法结果也是溢出的但 JVM 定义它回绕成 MIN_VALUE不会抛异常。Rust 的调试模式会在溢出时直接 panic这是很多新手第一次被它教育的地方Release 模式下会静默回绕。Rust 的checked_add、saturating_add、wrapping_add三件套非常实用我经常拿它们当作 C 代码设计的参照——大部分业务代码应该用 checked 语义计数和序列号用 wrapping 语义信号处理用 saturating 语义。Python 的任意精度整数让人很安心但 Python 和底层打交道时依然要小心struct模块、ctypes、numpy数组在转换回定宽整数时会出现和 C 一样的溢出行为。用 numpy 做图像或科学计算时uint8数组加 1 从 255 变 0这依然是那个熟悉的回绕问题。4.4 代码审查中的溢出 Checklist动态检测工具能抓运行时但代码审查仍然是性价比最高的防线。我总结了一份审查清单每次评审涉及整数运算的改动都会对照走一遍查所有服务于内存分配和memcpy/strncpy长度的加法、乘法表达式确认操作数来源是否可信、计算结果是否会被截断。查循环变量是否用了无符号类型循环条件里有没有i 0这种恒真式倒序循环是否用了while (i 0)的变体。查无符号和有符号的隐式比较特别是size_t与int直接比较的地方确认转换方向是否会造成负数变巨大正数。查跨类型赋值链比如uint64_t到uint32_t到uint16_t到int中间任何一次截断都可能把大数变成负数或变成奇怪的小数。查所有把int改成size_t的改动这是最容易引入下溢的无症状操作肉眼看着只是消除警告实际上行为可能完全变了。查减法操作的结果是否可能为负尤其是“大数减小数”假设成立吗有没有对“被减数 减数”做防御。查时间戳、计数器、序号等长期运行后会触顶的数值确认类型位宽是否足够覆盖业务生命周期。这份清单不追求大而全但每一条背后都是真实事故换来的教训。我在团队里把它做成了 commit 提示里的一行 check每次提交代码提醒一次效果比事后 review 好很多。5. 遇到诡异现象时的排查思路复盘5.1 从崩溃现场反推根因的分析顺序就算防御做得再好线上还是可能出现“看起来完全不像整数溢出”的崩溃。这类问题最折磨人因为现象离根因隔着好几层。我按照自己排查此类问题的先后顺序整理了一个分析路径。第一步看崩溃现场。拿到 backtrace 和寄存器之后先判断是“写崩”还是“读崩”。写崩的典型标志是栈回溯里的函数恰好有memcpy、memset、strcpy这类拷贝调用或者某个数组下标异常巨大读崩更可能要往前追查索引值的计算来源。第二步追数据。找出所有参与长度、索引、下标计算的变量逐个打印它们的值和声明类型。这里有一个非常容易踩的坑打印时格式化占位符和实际类型不匹配。printf(%d, size_t_value)在部分平台上打印出的值根本不对会误导排查方向。正确做法是用printf(%zu, value)打印size_t用PRIu32、PRIi32这些宏打印定宽整数。第三步用 Sanitizer 重建现场。把出现问题的模块单独拉出来用-fsanitizeundefined -fsanitizeaddress -fno-sanitize-recoverall编译喂相同的输入。绝大多数情况下Sanitizer 会精准报出溢出发生的行号。第四步如果 Sanitizer 没报玩“最小复现”。写一个小的测试程序绕开业务逻辑直接用相同的数据调用出问题的计算函数。我曾经靠这个办法把一个“看起来是并发问题”的崩溃定位到一个uint8_t计数器的回绕上——计数器回绕后另一条线程读到的“会话序号”就是旧值导致状态机错乱。这种现象和并发 bug 的表现一模一样但根因完全不同。5.2 我踩过的几个溢出坑真实记录举几个我亲身经历过的案例给读者一些具体感知。第一个坑为了消除编译警告把 int 改成 size_t。当时某接口的入参是int count代码里有个循环for (int i 0; i count - 1; i)。静态检查报“有符号/无符号比较”我顺手把count改成size_t。结果某个调用方传了 0count - 1回绕成SIZE_MAX循环体执行了 18446744073709551615 次直接把服务的响应时间从毫秒级拖到超时。这个教训让我确认了一件事看到无符号类型的减法第一个反应应该是“这个减法的结果会被用到哪里”。第二个坑有符号溢出检测代码被优化掉。有段时间我在优化一个图像处理库开启-O3之后某个校验函数突然永远走不到错误分支。用反汇编一看编译器把我写的if (x y x)整个删了。那之后我彻底转变了写溢出检查的方式所有检查全部前置到运算之前一步都不依赖“先算出结果再比较”。第三个坑序号回绕导致会话错乱。一个嵌入式项目里用uint8_t存消息序号序号从 0 到 255 循环。业务逻辑里要判断“收到的消息序号是否比当前新”直接用的if (new_seq current_seq)。一切正常的时候没问题可一旦序号从 255 回绕到 0所有想表达“新消息”的判断全部错误大量消息被当成旧消息丢弃。后来改成if ((int8_t)(new_seq - current_seq) 0)回绕问题才解决。这个案例让我记住了做序号比较时要用“差值 符号位判断”而不是直接比大小。5.3 日常开发中能救命的小习惯最后一个部分分享几个我在日常开发里养成的习惯。它们看起来不起眼但关键时刻能救命。习惯一项目里所有涉及内存分配的地方统一封装。我习惯写一个safe_malloc_checked(len, max)的包装函数入口处做一次大小校验任何原始malloc都禁止直接出现在业务代码里。这样即使计算逻辑变了至少分配这一关能把超大的长度拦住。习惯二给常用类型做别名并加注释。using PacketLen uint32_t;让每个长度变量的语义更明确代码审查时一眼就能看出“这里用了定宽类型需要考虑 4GB 上限”。反过来如果看到不加别名、随手用的int审查时就要提高警惕。习惯三CI 脚本里强制跑 UBSan。不只是单元测试最好把模糊测试和压测脚本也挂上 Sanitizer。我有一次就是靠模糊测试在一个协议解析库里抓到了十几个历史遗留的乘法溢出它们平时根本不会被触达但被恶意报文编织出来时就是灾难。习惯四看到size_t先想减法看到int先想转换链。这两句话几乎是我做代码审查的口头禅。很多溢出问题都藏在这两个不起眼的“常识”里。我到现在做代码审查最常贴的一句评论还是那句“这里真的确保不会溢出吗”这句话问过几百次每次都有新发现。整数溢出的坑藏得很深但只要你保持这种“不信任任何运算结果”的警惕心绝大多数问题都可以在上线前被拦下来。这个习惯比任何工具都好用。
返回列表