很多初学者甚至工作几年的朋友一看到无符号整数和二进制补码这两个词就觉得是大学课本里早就翻过去的章节不值得再看。但我自己这些年修的bug里有一大批查到最后都落在数值怎么表示和类型怎么转换上你写一个strlen(s) -1的判断字符串明明有长度条件却死活不成立循环变量用unsigned写了个i 0作为终止条件程序直接死循环-1 0u这种表达式的结果更是让无数人拍桌子。这篇文章想把这几个基础概念彻底讲透无符号整数的范围怎么定义、回绕为什么是刻意设计、二进制补码凭什么成为负数表示的事实标准、有符号和无符号混用时编译器到底在背后做了什么。适合正在学C/C的初学者也适合做嵌入式、网络、音视频等需要和字节打交道的开发者以及偶尔要读底层代码的写业务的朋友。1. 无符号整数的取值边界与回绕的底层逻辑1.1 最大值为什么是2^N-1而不是2^NN位无符号整数的取值范围是0到2^N-1这个公式很多人都背过但没想过为什么不是2^N。其实原因很简单每一位不是0就是1N个二进制位能组合出2^N种模式但这些模式从全0开始计数所以最大模式对应的数值是2^N-1。类型位宽最小值最大值最大值十六进制unsigned char802550xFFunsigned short16065,5350xFFFFunsigned int3204,294,967,2950xFFFFFFFFunsigned long long64018,446,744,073,709,551,6150xFFFFFFFFFFFFFFFF一个很好记的口诀是最大值就是所有位全是1。32位的全1十六进制写作0xFFFFFFFF64位就是16个F拼在一起。这也解释了无符号数的一个特征——它没有负数因为根本没有位来存放符号信息。1.2 回绕是特性不是缺陷无符号数加到头会回到0减到0再继续减会跳到最大值这个行为在C标准里被明确定义为按2^N取模。比如32位无符号数4294967295加1得到00减1得到4294967295。可以拿钟表来类比11点过了2小时是1点而不是13点。钟表的表盘是12个刻度围成的环无符号数则是2^N个刻度围成的环走完一圈自然回到起点。底层场景里这个环非常有用循环队列的尾指针加到头自动回到0哈希表的探测步进会在整个表空间里循环计数器的溢出重来对很多协议状态机来说也是可接受的。关键点在于无符号回绕是确定的、可预测的你不用猜测编译器会把这个行为优化成什么。这和后面要讲的有符号溢出形成鲜明对比。1.3 什么场景真正适合无符号我的判断标准很简单如果这个数据在物理意义上不可能是负数就用无符号。具体来说下面这些场景几乎是无符号数的天下内存大小和偏移量比如malloc、memcpy、read、write的size参数标准库统一用size_t位掩码、权限位、协议标志字段它们只关心位模式不关心数值大小循环队列、环形缓冲区的读写下标希望它自己回绕哈希值和随机数生成器的内部状态数值本身没有正负语义只关心位的变化比较大小但不做差值的序号比如数据包ID、消息序号1.4 一个容易忽略的点无符号不适合求差值有时候我们拿到两个无符号数想判断谁大谁小没问题但想计算它们之间差了多少就要小心。比如用无符号直接算5减8在8位场景下结果是253因为回绕了。如果业务上需要的是-3这个结果就是错的。这类问题经常出现在RTT往返时间测量、定时器差值计算、流量速率统计里。我的习惯是必须先判断两个数谁大再用大数减小数的方式避免回绕如果差值本身允许为负就把操作数先转成有符号类型再算不要指望无符号减法给你一个负数结果。2. 二进制补码为什么负数要用取反加一来表示2.1 三种负数表示方案的历史竞争用定长二进制表示负数理论上至少有三种办法原码最高位做符号位其余位表示绝对值。8位里00000011是310000011是-3反码正数不变负数对除符号位外的其他位取反。00000011是311111100是-3补码在反码基础上加一。00000011是311111101是-3三种方案都能表达负数但只有补码笑到最后。原因主要有两个补码只有一个零加法和减法可以共用同一套电路。这两点放到今天看是压倒性的硬件优势。2.2 唯一的一个零避免了无数边界判断原码和反码都有正零和负零两个表示。8位原码里00000000和10000000都表示0反码里00000000和11111111都表示0。硬件上判断是不是0就得检查两个模式软件上也比较容易出幺蛾子。补码干脆利落全0就是0压根不存在-0这个概念。于是底层代码里判断是否为0直接用!x或者x 0按位比较一次搞定。协议解析和状态机里这种判断到处都是一个唯一的零省掉了大量边界分支。2.3 加法和减法共用加法器这是补码最大的设计动机假设CPU要计算3加负1。如果采用原码加法器看到一个看起来怪异的负数表示没法直接参与加法必须先判断符号、把负数转换成正数、做减法再修正结果符号。这一串控制逻辑下来电路复杂度和延迟都上去了。补码的思路是把负数也表示成一个看起来像无符号数的位模式让加法器不用分辨正负全部按无符号相加结果自然正确。拿8位举例3 (-1)写成补码是00000011 11111111二进制相加得到100000010溢出位丢掉剩下00000010也就是2正好是3减1的结果。这就是为什么几乎所有体系结构教材都会说补码让CPU可以只造一个加法器——因为减法被归约成了加法减去一个数等于加上它的补码。2.4 取反加一背后的环模型补码的另一个直观理解是把它想象成时钟。8位补码能表示-128到127共256个值按顺序排在一个圆环上0在正上方顺时针依次是1、2、...、127再往下是-128、-127、...、-1最后绕回0。在这个环上取相反数就是找到与当前点关于环心对称的点落到位的操作就是按位取反再加一。以-3为例3的二进制是00000011取反得到11111100加一得到11111101。验证一下3 (-3) 00000011 11111101 100000000溢出位丢弃后剩下00000000结果确确实实是0。还有一个数学视角更通透把补码按加权和展开最高位的权值是负的。8位里第7位的权值是-128而不是128。那么11111101 -128 64 32 16 8 4 1 -3算得明明白白。2.5 别再说最高位是符号位严格讲补码的最高位不是一个独立存在的符号位它的作用是给整个数加上一个大负数权重叫权重位更准确。用这个视角有两个好处第一能理解为什么最高位为1就是负数在绝大多数情况下成立——因为那个负权重往往压过其他位的正权重第二能理解为什么0x80000000是个特殊值它作为有符号数是-2147483648而不是负零更不是什么神秘数字。3. 补码视野下的运算细节溢出、扩展与移位3.1 同一套加法器两种解释硬件层面无符号加法和有符号加法用的是同一条二进制加法电路。0xFFFFFFFF这个位模式按无符号读是4294967295按有符号读是-1但无论怎么解释它加1后的位模式都是0x00000000。差别只在于事后你怎么把这个位模式翻译成数值。这个视角解释了为什么类型在底层本质上是一种解释方式的标签。同样的位模式声明成有符号类型还是无符号类型业务结果可能天差地别。写代码时必须时刻清楚这个位模式接下来按什么语义来读尤其是做协议解析、序列化和跨语言交互的时候。3.2 有符号溢出是未定义行为无符号回绕是定义行为这一点我希望所有C/C程序员都刻进脑子里C标准规定无符号整数溢出按2^N取模行为确定而有符号整数溢出是未定义行为编译器有权做任何它想做的事情。未定义行为的实际后果常常让人抓狂。在没开优化的调试版本里你可能看到有符号溢出后回绕成负数一切看起来还算合理但开了-O2优化之后编译器会直接假设有符号溢出不会发生于是把依赖溢出的代码整个优化掉某些硬件上还可能触发异常。同一个源码换个编译选项就换一种行为这才是最可怕的。实操建议如果只是想要计数到最大值后从头再来的语义老老实实用无符号。如果担心有符号运算可能溢出先判断数值范围再运算别依赖溢出后的结果。3.3 符号扩展为什么负数的扩展要补1把窄类型提升到宽类型时无符号数高位补0有符号正数高位也补0但有符号负数高位要补1这个操作叫符号扩展。比如8位的-311111101转成32位int结果是11111111111111111111111111111101数值仍然是-3。如果负数扩展时直接补0-3会变成253数值就完全错了。编译器做隐式类型提升时会自动处理符号扩展不需要你操心但如果你在手工拼装字节流、写序列化代码、或者和汇编打交道就很容易在这上面翻车。从网络字节流读一个16位有符号整数时正确的做法是先把原始字节读成无符号值再通过强制转换让编译器补符号扩展而不是自己手动去右移复制符号位。3.4 右移逻辑右移和算术右移的区别左移比较好说右侧补0即可但有符号正数左移顶到符号位可能触发未定义行为这点要先记住。右移则分成两种逻辑右移高位移入0适用于无符号数算术右移高位移入符号位适用于有符号负数以8位的-4举例二进制是11111100。算术右移1位得到11111110也就是-2相当于除以2并向下取整逻辑右移1位得到01111110也就是126完全是另一个世界。C标准对负数右移只说是实现定义不同编译器理论上可以给出不同结果。主流编译器基本都实现成算术右移但如果你要写跨平台、跨编译器的代码最好别依赖这个行为需要无符号语义就用无符号类型需要除法语义就直接用除法把意图表达清楚。3.5 取模与取余补码世界里的两个运算符C/C里%对有符号数的结果符号跟随被除数这跟数学意义上的取模并不完全一致。比如-7 % 3在C里结果是-1而很多算法语境里期望的是2这类非负结果。这个差异在负数表示成补码之后变得更加微妙。如果确实需要非负模结果常用的写法是(a % b b) % b先加一个b把负数掰正再取一次模。这个公式不复杂但用到的时候能省不少排查功夫。4. 有符号与无符号混用编译器在你眼皮底下改类型4.1 通常算术转换是怎么生效的当一个表达式里同时出现有符号和无符号类型时C/C会触发通常算术转换。核心规则是先做整数提升小于int的小类型提升为int或unsigned int再按哪个类型能完整表示对方所有值就用哪个的原则统一类型。比如int和unsigned int混用因为unsigned int装得下int的所有取值int就被转成unsigned intlong和unsigned int混用时如果long能完整表示unsigned int的范围就用long否则两者都转成unsigned long。规则本身是理性的后果却经常反直觉。最经典的就是-1 1u-1被转成无符号后变成42949672954294967295 1显然不成立于是表达式的结果是假。很多人第一次看到这个结果都以为编译器坏了实际上编译器只是执行了转换规则。4.2 三个真实的事故现场事故一strlen(s) -1。strlen返回size_t也就是无符号类型-1转成size_t后是SIZE_MAX一个巨大的值。strlen的结果不可能比它还大所以这个条件永远是假的。如果你本意是判断strlen出错返回了-1这是典型的误用strlen出错本来也不是靠返回值判断的。事故二for (i n; i 0; i--)其中i是无符号类型。i减到0之后还能继续减下一位变成UINT_MAX于是循环永远不结束。几乎所有刚学C的人都踩过这个坑说它是新手村第一杀手都不为过。事故三混用int和size_t做比较。假设int a func(); size_t b strlen(s); if (a b)当a为负数时它会被转成巨大的无符号数于是负数的a反而大于b。这类bug不容易现形大多数时候a是正数代码跑得挺好只在边界条件下突然翻车。4.3 排查这类bug的完整思路我自己排查这类问题的步骤通常是这样先把参与比较的所有变量类型列出来尤其注意size_t、unsigned这类容易被忽略的类型把所有操作数以十六进制形式打印直接看位模式。位模式能告诉你编译器实际面对的到底是什么如果问题只在开了优化的版本出现关掉优化重新编译对比行为如果行为变了大概率是未定义行为或者优化后的类型转换问题对可疑表达式做显式强制转换逐步缩小范围定位到底是哪个表达式出了问题这套方法第一次用时挺费时间但用多了会形成一种闻到混合符号味道就警惕的本能。排查速度会越来越快。4.4 编译器告警是你最好的朋友在GCC/Clang下开-Wall -Wextra -Wsign-compare在MSVC下开/W4都能对大多数符号比较问题给出告警。我强烈建议把这类告警当成错误处理-Werror因为符号混用问题一旦潜伏进代码库排查成本往往远超顺手改个类型的成本。如果项目用了静态分析工具把跨类型数值比较相关的规则也打开。这类规则通常能直接定位到文件行号比人工翻代码高效得多。5. 落到实际项目选型原则、位操作技巧与我的踩坑清单5.1 什么时候用无符号什么时候用有符号我给自己定的规则很朴素数据本身有正负语义——温度差、偏移、分数、速度变化——用有符号数据本质是非负数量——长度、大小、个数、位掩码——用无符号变量命名里最好带u后缀方便阅读索引和计数必须全项目统一。团队约定用int做索引就别在某个函数里突然用size_t混着用是bug温床凡是和I/O大小、内存大小、协议字段长度打交道的一律用size_t或uintN_t因为这些值本来就要和标准库接口对齐还有一条对外接口的语义要明确。接收长度参数的函数参数类型就直接用size_t接收可以为负的偏移就用ptrdiff_t或int。别让调用方去猜。5.2 值得背下来的几个位操作公式无符号和补码的基础打牢之后很多位操作就是直接推论判断x是否为2的幂x !(x (x - 1))把x向上对齐到8的倍数(x 7) ~7取低N位x ((1 N) - 1)判断两个数符号是否相反(a ^ b) 0无符号环形计数前进idx (idx 1) (cap - 1)前提是cap是2的幂这些公式不用死记但最好能现场推导。比如向上对齐到8的倍数的原理就是先加7再把低3位清零。理解了位模式和数学含义这类公式是顺手就能写出来的。5.3 我这几年反复踩的几个坑在无符号变量上写减少到负数的逻辑无符号根本到不了负数到0就绕回最大值把表示长度的有符号int直接传给接受size_t的接口负数被转成巨大无符号值然后内存申请失败或者拷贝动作直接爆炸从文件或网络流读取字节时没有先做符号扩展就做算术运算负数被误读成正数用%处理负数时想当然C的余数语义和数学取模不一致负负得正还是负全看被除数的符号对有符号负数右移的结果有期待但标准只保证实现定义跨编译器可能翻车5.4 一个收尾的经验我自己现在写任何涉及数值比较、长度计算、位运算的代码写完都会先过一遍变量类型再问一句这里会不会发生隐式转换。这个习惯看起来原始但它帮我拦下了相当多线上问题。数值表示这层基本盘虽然老却是整个软件系统里最不容易变的底座之一。把无符号整数和二进制补码想透之后再去看协议解析、内存管理、哈希算法这些领域的源码会顺畅很多。