ARTICLE DETAIL

资讯详情

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

C语言补码原理:原码反码补码、模运算与有符号无符号陷阱

C语言补码原理:原码反码补码、模运算与有符号无符号陷阱 1. 从一个打印负数的诡异现象说起printf(%d, -1) 屏幕老老实实显示 -1没人觉得有问题可把格式符换成 %x输出变成 ffffffff一串八位十六进制的 f很多人当场就懵了——我明明存的是 -1哪来的这么多 f。这就是 C语言 里 反码 和 补码 最早跟人打照面的地方。我带了这么多届新人几乎每次讲到整型的二进制表示都要从这个例子开刀因为它把抽象概念变成了眼前看得见的怪事内存里根本没有负号这种东西只有 0 和 1那 -1 究竟是怎么被存进去、又是怎么被还原出来的这篇文章我打算把原码、反码、补码这一条线从头到尾捋一遍不讲空话重点放在为什么是这样设计和在 C 语言里怎么验证。适合已经能写基本 C 程序、但对位运算和整型存储一直半懂不懂的同学也适合准备嵌入式、笔试面试、想把底层补扎实的人。读完之后你至少应该能做到三件事手算任意整数的三种码、用代码打印验证、看懂那些因为有符号无符号混用而产生的诡异 bug。1.1 -1 打印成 ffffffff 到底发生了什么要理解这个现象先得知道%x是干什么的。它做的是把内存里的这 4 个字节当作一个无符号十六进制数来看待而不是当作有符号数。也就是说%d和%x读取的是同一块内存、同一串二进制位只是解释方式不同。%d认为最高位是符号位看到全 1 就理解成负数%x干脆放弃符号这个概念把 32 个二进制位原样按十六进制分组打印。-1 在 32 位有符号整型下内存里是 32 个 1也就是1111 1111 1111 1111 1111 1111 1111 1111。按每 4 位一组拆成十六进制正好是 8 个 f。所以%x输出ffffffff不是错误是恰好暴露了补码的真面目。你可以自己动手试试把%x换成%u会看到 4294967295也就是 2³² 减 1这个数字后面还会提到它和补码的模运算有直接关系。提示%d把ffffffff读成 -1%x/%u把它读成 4294967295两者都没错。错的只是我们脑子里默认负数就该带个负号。这个例子说明一个核心事实负号不是存储出来的是被解释出来的。同一串二进制用什么类型、什么格式符去读就得到什么结果。补码的本质就是让这串二进制既能表示负数又能让 CPU 直接用加法器去算减法。1.2 为什么计算机不肯专门留一位存正负号有人会自然地想这有什么难的我拿最高位当符号位0 表示正、1 表示负剩下的位存绝对值不就完事了这个方案叫原码确实最符合人的直觉但它会在硬件层面带来两个大麻烦这也是它被淘汰的原因。第一是减法电路无法复用加法电路。如果正负号只是标记、数值部分各算各的那么做a - b的时候硬件就得先判断符号再看绝对值谁大谁小然后决定结果取正还是取负最后还要处理借位。这套逻辑做出来电路又复杂又慢。而补码把减法统一成了加法a - b直接算a (-b 的补码)一个加法器全搞定这就是它最大的价值。第二是出现了两个零。在原码里0000 0000是 01000 0000是 -0同一个数值占两个编码判断是否为零就得多写一条分支。反码也继承了这个问题只有补码把 -0 这个坑填掉了让0000 0000独占零的位置多出来的那个编码还能拿去表示别的数后面讲 INT_MIN 时会说到。这两个理由足够让所有主流 CPU 都选择补码作为整数的存储方式。2. 原码、反码、补码一次完整的推导过程理解了为什么需要用补码接下来就把三种表示法的定义、计算方式和优缺点一次性讲透。它们不是三套毫不相干的规则而是一条逐步演进的思路原码是起点反码是过渡补码是终点。明白这条演进线你就不会再把它们当成三条需要死记的口诀。下面我以 8 位二进制为例因为 8 位刚好一个字节手算最方便位数一多容易看花眼。所有结论平移到 16 位、32 位、64 位都是成立的只是位数不同。2.1 原码最符合直觉也最容易出错原码的规则简单到一句话最高位是符号位0 正 1 负其余位放绝对值的二进制。比如以 8 位为例5 的原码是0000 0101-5 的原码是1000 0101只差最高位。看起来清爽问题却在运算里。拿1 (-1)做实验原码下分别是0000 0001和1000 0001。如果你傻乎乎地把两个位模式直接相加会得到1000 0010这是 -2显然不对。原因就是符号位参与了不该参与的运算。要想算对硬件必须先剥离符号位、比较大小、定符号再算数值绕了一大圈。更要命的是 0 和 -0 的问题。8 位原码可以表示的范围是-127 ~ 127但零有两个编码0000 0000和1000 0000。这就意味着一个 8 位原码能表示的不同数值其实只有 255 个白白浪费了一个编码。零的双重身份会让相等判断、查表、哈希都变得别扭。就凭这两点原码注定只能停留在好懂的层面进不了运算核心。2.2 反码想绕开减法却留下两个零反码是在原码基础上改进一步正数不变负数的符号位保持 1其余各位按位取反。还是以 8 位为例5 还是0000 0101而 -5 变成1111 1010就是0000 0101整体取反的结果。这样改的目的是什么是为了让加法器能处理减法。先看个例子用反码计算5 (-3)。5 是0000 0101-3 的反码是1111 11003 的原码0000 0011取反。两数相加得到1 0000 0001最高位溢出的 1 落到了第 9 位把它回卷加到最低位这叫循环进位就变成0000 0010也就是 2答案正确。这套循环进位的规则是反码时代硬件里真实存在过的设计。但反码的毛病也很明显一是零还是两个0000 0000和1111 1111都表示零二是循环进位这个机制需要对最高位的进位做额外处理电路还是不够干净。所以反码只是一个中间态是从原码走向补码的过渡方案。2.3 补码把减法变成加法的临门一脚补码的规则在反码基础上再走一小步正数不变负数的补码 反码 1。还是 -5原码1000 0101反码1111 1010反码加 1 得到1111 1011这就是 -5 的补码。注意这里的末位进 1——这就是很多资料里说的负数补码末位进 1它不是什么特殊操作就是从反码到补码的那一步加法。补码到底为什么能把减法变成加法核心在模运算。先记住一个结论8 位二进制能表示的状态有 2⁸ 256 个也就是模是 256。在这个系统里-5和256 - 5 251是等价的因为 251 加上 5 正好等于 256溢出后归零。251 的二进制是1111 1011跟刚才手算的 -5 补码一模一样。所以补码的本质是用正数去表达负数任何负数-x在 N 位系统里都可以写成2^N - x。这样硬件只需要一个加法器减法a - b直接变成a (2^N - b)算完之后超出的位自然丢失结果就是正确的。这才是补码真正的设计动机也是它胜过原码、反码的关键。3. 补码背后的数学模运算与取反加一的证明很多人会背取反加一这句口诀但一旦被问为什么是加一、不是加二就答不上来了。这一节我们把这个加一到底从哪来彻底讲清楚顺便把边界值、特殊值都过一次。这部分内容看着偏数学但它是后面所有 C 语言坑的根源不理解这里后面只能靠死记。3.1 用钟表理解模运算先说模运算因为它才是补码的骨架。想象一个只有 12 个刻度的钟表指针从 3 点往回拨 2 格、和往前拨 10 格停在同一个位置——都是 1 点。为什么因为 12 是模-2和10在模 12 下等价-2 ≡ 10 (mod 12)。这就是为什么减法和加法可以互相转化减去一个数等于加上这个数的模补数。计算机的二进制整型就是这个钟表只不过刻度数是 2 的 N 次方。8 位整型的模是 25616 位是 6553632 位是 4294967296。-1在 32 位下的补码之所以是ffffffff正是因为ffffffff的十进制是 4294967295而4294967295 4294967296 - 1也就是-1的模补数。这一下就把前面%x输出ffffffff的谜团解释清楚了。提示模补数不是计算机凭空发明的技巧它是数论里本来就存在的东西。计算机只是恰好选了 2 的幂作为模运算才这么自然。理解了模你就会明白补码的减法根本不是减法它从头到尾都是加法。CPU 里的 ALU算术逻辑单元只需要一个加法器就能同时处理加减两种运算这是硬件成本上的巨大胜利。3.2 取反加一的来龙去脉现在来看那个加一从哪儿来。设一个 N 位数 x我们要算2^N - x也就是 -x 的补码。把2^N拆开2^N (2^N - 1) 1。而2^N - 1这东西很特别——它是一串全 1。比如 8 位时2^8 - 1 255 1111 1111。那么2^N - x (2^N - 1) - x 1关键在(2^N - 1) - x这一步。因为2^N - 1是全 1拿一个全 1 的数去减 x每一位的结果和 x 的对应位是相反的——这正好就是按位取反。于是整个式子变成-x 的补码 (~x) 1这就是那句取反加一的完整推导一点都不神秘。取反得到2^N - 1 - x加一补上那个1的缺口最终凑成2^N - x。理解了这一步你就再也不用把口诀当咒语背了。同样的道理也可以从反码推过来反码本身就是(2^N - 1) - x对负数取反得到这部分所以补码 反码 1。两条路殊途同归说明这个加一是有数学必然性的。3.3 边界值与 INT_MIN 的孤独补码有一个特性是所有位模式下都成立的N 位有符号整型能表示的范围是 [-2^(N-1), 2^(N-1) - 1]。负数比正数多一个这多出来的那一个就是前面原码里被浪费掉的-0编号。以 8 位为例范围是 -128 到 127其中 -128 没有对应的正数。为什么 -128 没有正数对应因为 128 需要 8 位有符号数存不下——最高位是符号位剩下 7 位最多表示 127。所以在补码体系里1000 0000这个本应是 -0的编码被规定为 -128。它的补码就是它自己取反加一反而会溢出这一点在写代码时特别容易踩坑。32 位的INT_MIN-2147483648也是一样-INT_MIN是未定义行为因为它超出了正数能表示的范围。为了手算方便我把 8 位下几个关键值的三种码列成表你可以对照着验证十进制原码反码补码50000 01010000 01010000 0101-51000 01011111 10101111 101110000 00010000 00010000 0001-11000 00011111 11101111 11111270111 11110111 11110111 1111-128无无1000 000000000 0000 / 1000 00000000 0000 / 1111 11110000 0000从表里能清楚看到正数三种码完全相同这是很多人记混的地方。记住正数三码一致能省下一半的死记功夫。4. 在 C 语言里动手验证补码纸上推导完得用代码把它钉死。我个人的习惯是——凡是脑子里没法用代码验证的底层知识都当成没学会。这一节我们写几个小函数把整数的二进制打出来手工实现三种码的转换再看看类型转换和溢出会闹出什么幺蛾子。代码都很短可以在本机直接跑。4.1 写一个二进制打印函数C 语言标准库里没有直接打印二进制位的函数但用移位和按位与可以自己拼一个。核心思路是从最高位开始每次取出一位判断它是 0 还是 1。#include stdio.h void print_bin(int x) { for (int i 31; i 0; i--) { // 取第 i 位(x i) 1 putchar(((x i) 1) ? 1 : 0); if (i % 8 0 i ! 0) putchar( ); // 每 8 位空一格 } putchar(\n); } int main(void) { int a -5; print_bin(a); // 11111111 11111111 11111111 11111011 printf(%x\n, a); // fffffffb printf(%u\n, a); // 4294967291 return 0; }跑一下就能看到 -5 的内存位模式倒数第二、三字节全是 1最低字节是11111011跟前面手算的 8 位补码后 8 位完全一致高 24 位因为 32 位符号扩展全部补了 1。这个函数是我调试位运算问题时的常备工具比盯着%x猜要直观得多。注意(x i) 1这个写法里x是负数时右移的行为依赖实现大多数平台是算术右移补符号位但因为我们只取最低一位再和 1 做与运算结果不受影响可以放心用。4.2 手工实现三种码的转换光理解原理还不够自己动手实现一遍转换记忆会牢得多。下面这段代码把原码、反码、补码的转换都写出来配合前面的二进制打印函数一起看整个逻辑链就闭合了。#include stdio.h void print_bin(int x); // 打印 8 位形式的三种码只取低 8 位演示 void show_codes(int val) { unsigned char abs_bits (val 0) ? (unsigned char)(-val) : (unsigned char)val; unsigned char sign (val 0) ? 0x80 : 0x00; unsigned char yuan sign | abs_bits; // 原码 unsigned char fan (val 0) ? (unsigned char)(~abs_bits | 0x80) : yuan; // 反码 unsigned char bu (val 0) ? (unsigned char)(fan 1) : yuan; // 补码 printf(值 %4d | 原码 , val); print_bin(yuan); printf( 反码 ); print_bin(fan); printf( 补码 ); print_bin(bu); printf( ~x1 验证 ); print_bin(~val 1); }这段代码里最值得玩味的是最后一行~val 1直接算出来的结果和手工拼出的补码一致。这就印证了 3.2 节的推导——取反加一就是2^N - x的另一种写法。你可以拿几个正负数分别跑一遍尤其是-1、-128这类边界值看看它们在表格和代码里是否对得上。注意这里我用的是unsigned char8 位来演示为的是和手算的 8 位例子对上。如果你用int符号扩展会让高 24 位补 1视觉上会多出一堆 1容易被绕晕。所以演示补码时我建议先锁定在 8 位或 16 位等概念清楚了再放开到 32 位。4.3 类型转换、整型提升与溢出理解了位模式再来看 C 语言里最容易出错的几个地方它们几乎都跟补码有关。第一类是有符号和无符号混用。看这段代码int a -1; unsigned int b 1; if (a b) printf(a b\n); // 你以为会走这里 else printf(a b\n); // 实际走这里为什么因为a和b比较时a会被转换成unsigned int也就是前面说的 4294967295显然大于 1。这就是著名的负数比正数大的 bug。不是编译器错了是 C 语言在做整型提升时按无符号解释位模式而补码恰好让 -1 变成了一个巨大的正数。第二类是溢出。有符号整型溢出在 C 语言里是未定义行为UB不是简单的回绕。比如INT_MAX 1很多平台结果是INT_MIN但这本质上是因为补码回绕标准并不保证。相比之下无符号整型的溢出是明确定义的回绕结果就是模 2^N。所以写跨平台代码时涉及可能溢出的运算宁可先转成无符号算再转回来也不要让有符号数裸奔着溢出。第三类是右移。对负数做时是补符号位算术右移还是补 0逻辑右移取决于实现。绝大多数平台是算术右移-8 1得到 -4符合除以 2的直觉。但如果你要的是逻辑右移必须先把数转成unsigned再移。这一点在写哈希、位图、协议解析时经常被忽略。5. 踩过的坑与常见问题速查补码这东西理论上一通百通但落到实际项目里还是有一堆细节会咬人。这一节我把这些年踩过的典型问题整理成表格和心得方便你日后遇到诡异输出时快速对上号。5.1 常见问题对照表现象根本原因处理办法%x打印负数出现一堆 f负数的补码高位全是 1想按无符号看就转unsigned否则改用%da b判断和预期相反有符号与无符号混用负数被提升为巨大正数显式统一类型必要时加断言-INT_MIN结果不对补码下 INT_MIN 取反溢出属 UB用负数域或加范围判断右移负数结果与预期不符算术右移补符号位需要逻辑右移先转无符号~x 1对 INT_MIN 出错取反加一在这点上溢出特判边界不要对 INT_MIN 套公式位模式看着像正数却是负数最高位是符号位补码约定用二进制打印函数直观确认表格里任意一行都对应一个我实际见过或者自己写出来的 bug。尤其是有符号无符号混用和INT_MIN 取反这两条新人几乎必踩一次。5.2 几条实操心得第一条永远别用背口诀来应对补码。取反加一、正数三码一致这些规律当然要记但它们是结果不是原因。真正要理解的是模运算和2^N - x这层关系。理解了它你看到任何位运算的奇技淫巧都能自己推出来而不是靠背几十条技巧。第二条调试位运算时先把值窄化成 8 位再打印。我早期调试常常直接对 32 位数打印结果一屏的 1 看得人头大找不出哪一位错了。后来养成习惯遇到问题先切到 8 位或 16 位把值限在一个字节里看问题往往一眼就现形。第三条涉及符号的运算显式写类型转换。C 语言的隐式转换规则很绕加上补码的位模式解释组合起来特别容易出意外。与其事后排查a b为什么反了不如在写的时候就明确写出(int)或(unsigned)让阅读代码的人也能一眼看懂你的意图。最后分享一个小练习写一个函数只用位运算判断一个int是不是 2 的幂或它的相反数再用补码的知识解释为什么x (x - 1)能消掉最低位的 1。这个练习做下来你对补码和位运算的结合会有全新的体感。我在实际使用中发现凡是能用位运算顺手解决的问题代码都又快又短前提是你得对补码熟到骨子里。
返回列表