前段时间帮另一个组的同学排查一个线上问题现象特别怪某跨平台设备上报的序号跑着跑着突然变成负数再往后直接跳成一个很大的数。业务层翻过来覆过去查了大半天最后我在日志里加了一行十六进制输出才看到这个“序号”在底层早就不是一开始那个用法了——底层用一个signed int存只做了1就发生了溢出回绕从2147483647直接翻到-2147483648。整条链路看起来是业务逻辑问题本质上其实是整数在计算机里的表示规则没对齐。这个事让我想起一个反复出现的规律越是看起来简单的基础越会在边界状态下把你坑得猝不及防。所以我想把整数在计算机中的表示这整块内容从头到尾捋一遍不搞教科书式的大而全全部站在一个经常需要在内存和二进制之间来回折腾的开发者视角来写。这篇内容适合两类人一类是刚入门的程序员想真正弄懂为什么int的最大值是2^31-1为什么负数要那样存另一类是写过几年代码、但平时大多停留在框架和业务层的朋友遇到诡异问题时想快速建立排查直觉。1. 先从一个计数器变负数的诡异 bug 说起1.1 一次“序号变负数”的排查记录那次设备端上报的协议里序号本身是 32 位无符号整数跨平台设备按网络协议发出一个uint32_t到了服务端后被丢进一个业务对象字段类型却声明成了int。业务代码里有一行record.seq msg-seq 1;单看这一行好像没什么问题。问题在于msg-seq这个字段被错误地定义成了有符号int。当协议里的序号超过0x7FFFFFFF时读出来就是一个负数再加1自然还是负数。日志里看到的就是“好好的序号怎么突然变成 -2147483648 了”。定位过程其实不复杂难得是大家一开始想歪了。所有中间打印都用%d输出负数就显示成负数没有人在第一时间想到应该看一眼十六进制。等我把打印改成%u和%x真相立刻出来了——底层位模式是0x80000000按无符号解释是2147483648按有符号解释却是-2147483648。同一串比特解释方式不对看到的数字完全是两回事。这个案例给我最大的提醒是排查整数相关 bug 时第一步要确认的是类型语义而不是急着查业务逻辑。1.2 这不只是 C/C 的问题有人可能会想我用 Java、Go、Python是不是就不用关心这些了并不是。Java 的int溢出之后也会回绕只是它把这种行为定义为确定结果Go 的无符号整数同样会按位宽取模Python 的整数虽然可以自动扩展位数但你一旦用struct模块、ctypes或者 socket 和底层二进制数据打交道最终还是绕不开“某个字段占多少位、按什么符号解释”这件事。更不用说大量嵌入式设备、网络协议、音视频封装格式、文件系统、数据库存储几乎处处都在和定长整数的位模式打交道。换句话说理解整数在计算机中的表示不是为了应付期末考试而是为了在真实工作中遇到诡异问题时心里有方向感。它决定了你看到0xFFFFFFFF时是它只是一个很大的无符号数还是-1还是某种特殊标志位。2. 二进制、字长与取值范围先搞清楚数的边界2.1 为什么是二进制以及怎么快速看十六进制计算机里的整数本质上就是一串固定长度的比特每个比特只有0和1两种状态。为什么不用十进制因为硬件上判断“有没有电”比判断“电压到底是多少”可靠得多。两个状态可以做很稳定的逻辑门再复杂的计算最终都拆解成这些门电路。但这不代表我们在日常调试里也要对着10110100这种长串看。十六进制是二进制的压缩表示1 位十六进制数正好对应4 位二进制数因为2^4 16。所以看到0xB4的时候心里要能立刻浮现出1011 0100反过来看到1011 0100也应该知道压缩成0xB4。这个换算能力在排查问题时非常实用。比如我前面说的0x80000000它只有最高位是 1其他 31 位全是 0。只要你对二进制和十六进制的对应关系够熟一眼就能判断出“这串比特绝不是普通的正整数”因为它把最高位符号位顶到了 1。2.2 一个整数类型能表示多大的数就看你有多少个 bit一个字节是 8 位这个没什么好说的。关键是整数类型占多少字节决定了它有多少种状态n位二进制数一共有2^n种组合。如果是无符号数它表示的范围就是0到2^n - 1如果是有符号数用补码表示时范围是-2^(n-1)到2^(n-1) - 1。不同环境下基础类型宽度有差异这也是很多跨平台问题的源头。与其纠结long到底是多少位不如直接使用固定宽度类型在stdint.h/cstdint里定义得很清楚类型宽度最小值最大值int8_t8-128127uint8_t80255int16_t16-3276832767uint16_t16065535int32_t32-21474836482147483647uint32_t3204294967295int64_t64-92233720368547758089223372036854775807uint64_t64018446744073709551615注意观察一个细节有符号类型的负数绝对值最大值总是比正数最大值大 1。比如 8 位有符号是-128到127不是-127到127。这背后就是补码设计的产物后面我们会专门讲。我的建议很直接写底层逻辑、协议解析、字节流处理时尽量用int32_t、uint16_t这类固定宽度类型少用平台相关的int、long。至少能让代码的行为可预期得多。3. 原码、反码与补码理解负数为什么这样存3.1 原码、反码、补码各自解决什么问题人类最直觉的负数表示法是“符号位 绝对值”用最高位表示正负剩下位表示大小。这就是原码。比如 8 位下5是0000 0101-5是1000 0101。看起来很好理解但计算机做减法会很痛苦5 - 3如果用原码硬件要先把符号位拆开判断谁大谁小再决定是加法还是减法最后还要修正符号。一个简单减法绕一大圈。于是出现了反码正数不变负数对数值部分按位取反。-5就成了1111 1010。反码让“减去一个数”变成“加上它的反码”减法电路省了不少事。但它有个尴尬问题会出现两个零0000 0000和1111 1111都表示零。用两个位模式表示同一个数这在边界判断上特别碍事。补码解决了负零问题也让加减法彻底统一。补码求法一句话正数不变负数等于“取反后加 1”。-5在 8 位下的表示是5 0000 0101 取反 1111 1010 加1 1111 1011所以 8 位二进制1111 1011就是-5写成十六进制是0xFB。3.2 补码的精髓让减法和加法共用一套电路为什么补码能一举解决这么多问题我用一个生活类比来解释。钟表盘只有 12 个小时从 3 点往回拨 4 个小时和往前拨 8 个小时最终到达同一个位置。因为在“模 12”的条件下-4 ≡ 8 (mod 12)。二进制也是一样的道理8 位整数的所有运算都在“模 256”这个圈里转。-1在模 256 下等价于255所以 8 位里的1111 1111表示-1完全顺理成章。这套设计让处理器的加法器同时完成加法和减法10 - 1实际执行的是10 255硬件不需要一套独立的减法电路。更重要的是1000 0000这个组合不再是无意义的负零而是当作-128来用。正因为如此有符号 8 位整数范围变成了-128到127比正数方向多出一个数。理解补码还有一个实际价值你会明白“有符号数的符号扩展”为什么是补符号位。uint8_t的0x80要扩展成 16 位只补 0 变成0x0080就行但int8_t的-128要想保持数值不变必须扩展成0xFF80也就是把最高位的 1 往高位不断复制。这就是“符号扩展”的来源。很多人做类型转换时数值突然变了就是因为没意识到编译器在窄转宽时的这个行为。4. 有符号与无符号类型混用才是最大的雷区4.1 无符号和有符号不是“带不带负号”这么简单同一个位模式按不同类型解释会得到完全不同的数值。0xFFFFFFFF按uint32_t解释是4294967295按int32_t解释是-1。类型本身不算“错”关键是解释规则必须和场景匹配。实际开发里最容易踩的雷区是协议和文件格式里几乎全是无符号类型而业务模型里大家都习惯用有符号int去接。于是就会出现我在开头说的那种“序号变负数”的故障。另一个常见场景是循环和下标。很多人写for (int i n - 1; i 0; --i)当n是size_t类型时n - 1会被先转成size_t一旦n为 00 - 1就变成18446744073709551615然后赋值给int又变成-1。这个过程每一步都符合标准但稍不留神就会让循环逻辑彻底崩掉。4.2 隐式类型转换的真实案例C/C 里的隐式转换规则可以用一句话概括类型等级不同时低等级向高等级转换有符号和无符号相遇时如果无符号能覆盖所有有符号值有符号转换为无符号如果不能两个都转换为无符号。最经典的一道题#include stdio.h int main(void) { unsigned int a 1; int b -1; if (a b) { printf(a b\n); } else { printf(a b\n); } return 0; }直觉告诉你1 -1一定成立。但实际输出是a b因为比较前b被隐式转换成了unsigned int-1变成了4294967295。这种问题极其隐蔽因为你很难靠肉眼从源码里看出问题。类似的情况还有sizeof运算符。sizeof返回的是size_t一种无符号类型。如果你拿它和一个有符号的int比较或者参与减法运算很容易得到错误结果。比如某段代码从网络包中取一个长度字段直接做len - 8而没有检查len 8的情况结果len是size_t一下子就回绕成天文数字后续内存拷贝直接越界。我的处理原则就三条不要用有符号类型去接收无符号来源的字段除非你明确知道它在整个生命周期里不会超过符号范围。所有涉及size_t、协议长度、文件偏移量的运算尽量保持同类型并在减法前做边界判断。编译器警告里带sign-compare、conversion的提示当成错误看待不要随手忽略。5. 溢出与回绕整数运算的边界行为5.1 有符号溢出是未定义行为编译器会“乱优化”C 和 C 标准对有符号整数的溢出直接定性为“未定义行为”。这比“得到一个错误结果”严重得多因为编译器在执行优化时会假设程序中不存在未定义行为然后在这个假设上做各种推断。举一个实际例子。旧代码里常有人写这样的判断if (x 1 x) { // 打开某些功能 }如果x是有符号整数逻辑上只要不发生溢出x 1 x恒为真。于是编译器会认为这个条件永远成立优化时直接把它替换成无条件执行。而你原本想用这个判断来防止x到达最大值后溢出结果优化完保护逻辑直接消失了。更麻烦的是不同优化级别下行为可能不一样-O0能测出来问题-O2反而测不出来。还有一个更危险的场景安全校验。某模拟项目里用signed int存储数据包长度然后做len 8判断剩余数据是否足够。攻击者传一个0xFFFFFFF0在int里就变成了负数后续的“大于等于”校验全部失效再往后就是缓冲区越界。这类漏洞在真实历史上出现过很多次根因就是“有符号溢出未定义安全检查依赖溢出后的结果”。要检测溢出不要用运算后的结果反推而是用更宽的中间类型或者直接使用编译器内置函数#include stdio.h int main(void) { int a 2147483647; int b 1; int result 0; if (__builtin_add_overflow(a, b, result)) { printf(overflow detected\n); } else { printf(result %d\n, result); } return 0; }GCC 和 Clang 都支持__builtin_add_overflow/__builtin_sub_overflow/__builtin_mul_overflow这套函数会把结果写入指定变量同时返回是否溢出。我自己的代码里涉及不可信输入的加法、乘法基本都会走这条路。5.2 无符号回绕是良定义的但同样到处都是坑无符号数的溢出标准定义为“对2^n取模”所以uint8_t的255 1等于0这不是错误是规则本身。但它依然会造成严重的逻辑问题。最典型的死循环写法for (uint32_t i 10; i 0; --i) { // 永远退不出来 }因为当i为 0 时--i不会变成-1而是回绕到4294967295循环条件依然满足。写代码时我几乎不会让无符号变量往反方向递减如果真要倒计时就让终点变成显式的边界判断for (uint32_t i 10; i 0; --i) { // 这个循环只跑 10 次 }无符号回绕在真实世界中还有一个著名的例子32 位无符号秒数会在 2106 年回绕32 位有符号秒数则在 2038 年就会出问题。很多旧系统把时间戳存成 32 位整数一旦跨过回绕点日期计算直接错乱。今天我们设计新系统时时间戳至少用 64 位或者干脆用字符串存就是不想让后代在数十年后替我们填坑。判断无符号减法是否安全永远坚持一个顺序先判断再减。if (a b) { c a - b; }写出来虽然平淡但在协议解析和长度计算中能挡掉一大半漏洞。6. 位运算实战用二进制视角读写数据6.1 移位运算和符号位的纠缠左移x n在数学上接近“乘以2^n”但在二进制层面它就是把比特整体向左挪右边补 0左边溢出部分丢掉。有符号左移一旦把符号位移出去结果就会变成未定义行为所以涉及移位时我会先把操作数转成无符号类型。右移分两种逻辑右移和无符号数的右移一样高位补 0算术右移则把符号位复制到高位用于保持负数的符号。C 标准对“有符号数右移”只说是“实现定义”实际上主流编译器都选择算术右移。但这里有个容易混淆的点负数右移不等于整除 2。在常见平台上-3 1的结果是-2而-3 / 2的结果是-1。因为右移倾向向下取整而 C 整数除法向零截断。如果你用右移代替除二一旦遇到负数结果就悄悄不一样了这在算法题和图形代码里都非常坑。另外绝对不要写超过类型位宽的移位。在 x86 架构上int移位计数只取低 5 位(1 31)可能等于-2147483648那还算有点意义但如果写(1 32)硬件实际执行(1 0)结果变成 1。这种平台相关行为没有任何可移植性代码评审里看到这种写法我基本直接判否。6.2 用位运算处理协议字段和颜色打包位运算最常见的场景是从一个字节里抽出某些位。比如你从协议里读到一个状态字节0xB4二进制是1011 0100想提取高 2 位、低 4 位、以及第 3 位uint8_t b 0xB4; // 1011 0100 uint8_t high2 (b 6) 0x03; // 10 2 uint8_t low4 b 0x0F; // 0100 4 uint8_t bit3 (b 3) 0x01; // 1这里的核心是“先移位再掩码”。如果你要提取的是高 2 位右移 6 位后目标位落在最低 2 位上然后 0x03把高位清空。提取单一位就用 1。颜色打包是另一个经典例子。假设要把 R、G、B 三个通道打包成一个 32 位像素值uint8_t r 0x12; uint8_t g 0x34; uint8_t b 0x56; uint32_t pixel ((uint32_t)r 16) | ((uint32_t)g 8) | b;注意这里必须显式把r转成uint32_t再左移。如果r本身是uint8_t参与表达式时会被提升为int一旦r 0x80r 16可能直接溢出有符号int的符号位结果就不可预期了。我见过好几次同事在这个问题上栽跟头打印出来的颜色值跟预期完全对不上。提取时反过来uint8_t r2 (pixel 16) 0xFF; uint8_t g2 (pixel 8) 0xFF; uint8_t b2 pixel 0xFF;这种手法也常见于权限位设计用一个uint32_t当 32 个开关用。设置第 n 位用flags | (1u n)清除用flags ~(1u n)判断用(flags n) 1u。比定义一堆bool省内存而且在标志位需要整体序列化传输时特别方便。7. 在调试器里看整数内存、字节序与跨平台7.1 用调试器和打印函数看整数的本质写代码时不只要把整数当成“数学上的数”还要习惯把它看成“内存里的一串字节”。GDB 里有两个命令我经常用p/x var // 按十六进制打印 p/t var // 按二进制打印一旦看到0xFFFFFFFF你就能立刻意识到它按无符号是很大按有符号是 -1而不是只看到那个打印出来的十进制。如果调试器不方便写一个 dump 函数也能快速看清字节排列#include stdio.h void dump_bytes(const void *p, size_t n) { const unsigned char *b (const unsigned char *)p; for (size_t i 0; i n; i) { printf(%02X , b[i]); } printf(\n); } int main(void) { uint32_t value 0x01020304; dump_bytes(value, sizeof(value)); return 0; }如果你在小端机器上跑输出会是04 03 02 01在大端机器上是01 02 03 04。这个打印结果基本能告诉你机器字节序也能让你对“整数存储顺序”有直观感受。7.2 大小端同一串字节在不同平台读出来不同大小端描述的是多字节整数在内存中的排列顺序。小端把低字节放在低地址大端把高字节放在低地址。x86 和主流 ARM 都是小端而网络协议标准规定用大端也叫网络字节序。这意味着如果你把主机内存里的int32_t直接通过 socket 发出去接收方如果是不同字节序的机器读出来的数值很可能就是颠倒的。所以跨平台传输整数决不能把结构体直接memcpy过去——除了字节序问题还有struct对齐问题两者叠加会让数据彻底没法解析。业内通行做法是使用固定宽度无符号类型并且在发送/接收时通过htonl/ntohl转换或者自己用移位手动组包// 手动把 uint32_t 拼成网络字节序的 4 个字节 uint32_t value 0x12345678; unsigned char buf[4]; buf[0] (value 24) 0xFF; buf[1] (value 16) 0xFF; buf[2] (value 8) 0xFF; buf[3] value 0xFF;接收时反过来读回来就行。这种逐字节组包方式的优点是格式一目了然完全不依赖平台类型宽度和字节序缺点就是手动代码多一点。但在嵌入式设备、网络协议、文件格式这类必须保证跨平台一致性的场景里这点麻烦非常值得。顺便提一句位域C/C 结构体里的位域在内存中的分配顺序和补齐规则是编译器实现定义的跨编译器基本没法保证。除非你能确认整个代码链路用的是同一套工具链否则处理协议字段时我更推荐用“移位 掩码”的方式而不是位域。8. 常见问题速查与日常排错技巧8.1 常见问题速查表我把这些年遇到过的符号、溢出、字节序相关问题整理成一张表方便你排查时直接翻现象根因处理方式大数突然变成负数再变成很大的正数有符号整数溢出回绕检查字段是否应该用无符号类型打印%u/%x对照循环明明写了i 0却死循环无符号变量回绕到极大值改用i 0或显式判断边界1 -1比较结果却是假有符号被隐式转换为无符号开启-Wsign-compare比较前显式转型负数右移得到-2而不是-1算术右移与整数除法向零截断语义不同需要整除时用/不要用颜色/像素值打包后和预期不符uint8_t提升为int左移溢出先转成uint32_t再移位同一个整数在不同平台解析结果不同大小端不一致统一用网络字节序或手动组包sizeof(x)参与减法后结果巨大size_t是无符号回绕先判断再减或显式转有符号结构体长度不等于字段长度之和对齐填充 padding用offsetof查看偏移协议解析时逐字段处理这张表看起来简单但每一种现象背后都可能藏着一个线上事故。我自己排查时遇到第一反应“这不可能”的整数问题都会先回到这张表的某一栏静下心来推一遍类型和边界。8.2 两个让我少踩坑的编码习惯第一个习惯是编译时把警告开满。GCC/Clang 至少开-Wall -Wextra -WconversionIDE 里把 sign-compare、conversion、overflow 相关提示当成编译错误处理。很多隐式类型转换在没有警告时看起来毫无问题一旦警告亮出来你才发现自己写的if (a b)里混进了两种完全不同的类型。十年下来我觉得这一条是性价比最高的防御手段没有之一。第二个习惯是遇到类型转换和边界运算时强制自己写一行注释。比如从uint32_t转int32_t时我通常会在旁边写清楚“这里取值范围已知是0 ~ 0x7FFFFFFF不会溢出”。这看起来像是废话但它能逼着你在写每一处转换前先确认前提条件。三个月后代码被别人改坏时这行注释就能立刻告诉你“原设计者已经考虑过边界问题出在输入前提被破坏”。我最想强调的体会是把整数理解成“一串比特 一套解释规则”而不是简单的数学数字。写代码时心里要同时存在两个数——业务上的数值和内存里的位模式。养成这个习惯之后很多看似玄学的问题其实都能在写下的那一刻提前躲开。
阅读完成 · 觉得有帮助?