尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

原码、反码、补码与有符号无符号数:从二进制本质到实战排查

原码、反码、补码与有符号无符号数:从二进制本质到实战排查 1. 数字表示的核心一切从二进制说起如果让我选一个所有计算机课程里最容易被“背会但没学会”的知识点原码、反码、补码绝对排第一。大多数教材都把这几个概念列成一张表格让人对着8位二进制反复背诵正数的补码等于原码负数的补码是反码加一……背完考完就忘然后在做C语言的位运算、读二进制文件、处理传感器数据时被各种“灵异现象”折磨得怀疑人生。这个项目标题本身就很典型——原码、反码、补码、有符号数、无符号数之间的关系。今天我不打算把它写成另一个“背诵版总结”而是想从“为什么需要这一堆码”的角度把这块内容彻底讲透。你看完这篇内容不光是能背出那张表更重要的是后面遇到任何涉及底层数据表示的问题都能自己推演、自己排查。先给出这篇文章的服务对象如果你是刚接触计算机组成原理、单片机、嵌入式或者C语言的初学者这篇文章可以帮你把这些知识点串成一条线如果你已经工作几年但偶尔还会被“符号位扩展”“溢出”这类概念坑到这篇文章同样值得花十五分钟读完。后面每一段我都会结合真实场景讲不会干讲理论。2. 从无符号数到有符号数符号位的诞生2.1 无符号数最简单也最容易理解的数字系统无符号数Unsigned Number是所有数字表示方式的起点。它的规则只有一条二进制每一位都代表数值大小没有符号位。比如一个8位的无符号数它能表示的范围是0到255也就是2^8 - 1。举几个实际例子十进制 0 → 二进制 00000000十进制 127 → 二进制 01111111十进制 128 → 二进制 10000000十进制 255 → 二进制 11111111你看无符号数的二进制和十进制之间就是纯粹的进制转换关系没有任何弯弯绕绕。正因如此凡是“天生就不可能是负数”的场景比如内存地址、数组下标、RGB颜色分量、灰度图的像素值系统底层几乎都用无符号数来表示。这里有一个容易犯迷糊的点无符号数的最前面一位不是“符号位”它就是普通的数值位。一个8位无符号数每一位的权重分别是128、64、32、16、8、4、2、1。所以 10000001 代表的十进制是 129而不是“负数”或“负1”。2.2 符号位引入如何用二进制表示负数现实世界不可能全是正数。温度有零下、海拔有负值、账目有欠款。计算机里要表示正负就需要一个“符号标记”。最直观的想法是拿出一位来专门表示符号约定这位是0代表正数是1代表负数。放在最左边称为符号位。这种“符号位 数值位”的表示方式就是原码Sign-Magnitude。它的定义非常直白原码的符号位0表示正1表示负原码的数值位就是该数绝对值的二进制表示以8位为例5 → 原码 00000101-5 → 原码 10000101127 → 原码 01111111-127 → 原码 11111111原码的优点是符合人类的直觉肉眼一看就知道正负也方便判断大小。但它在计算机硬件实现上有一个致命缺陷符号位不能直接参与运算。你设想一下如果用原码直接做加法00000101 5 10000101 -5 ----------- 10001010 结果是 -10数学上 5 (-5) 应该等于0但原码直接相加得到的是 -10完全错了。要是靠硬件去额外判断符号位再决定做加法还是减法电路设计会变得非常复杂而且还要处理“是正数大还是负数大”的额外判断效率很低。2.3 一个不得不面对的核心问题减法如何变成加法计算机硬件本质上只会做一种算术运算——加法。减法、乘法以至于更复杂的操作最终都会在硬件层面转成加法来完成。那减法怎么变成加法只需要一个等式a - b a (-b)所以问题的关键变成了如何表示一个负数使得“用正数和这个负数相加”的效果能恰好等于“正数减正数”的效果。从纯数学角度看-5 是满足“5加它等于0”的那个数。在二进制世界里我们就需要找到这样一个二进制编码让“正数 这个编码”的结果在有限的位数里正好归零。这其实就是同余和模运算的思想也是补码Twos Complement最终能胜出的根本原因。我不展开抽象的群论直接用8位二进制来演示我们希望 5 的二进制和 -5 的二进制相加后结果应该是 0。8位二进制能容纳256个值如果让 5 和 (-5的表示) 相加后等于 256那么在8位机器上溢出截断留下来的就是全0。很简单地算一下256 - 5 251251的8位二进制是 11111011。也就是说如果我们把 -5 表示成 11111011那么00000101 5 11111011 -5 ----------- 1 00000000 进位被8位机器丢弃剩 00000000即 0这就完美实现了“加法做减法”。这个 11111011 其实就是 -5 的补码。看到这里你应该明白了补码不是死记硬背的规则它是由“硬件只需要加法”这个约束反推出来的必然选择。3. 原码、反码、补码三种编码的完整拆解3.1 原码最直观但也很受限制的编码方式原码的定义已经有了我再补充几个实际应用中的细节。原码不为0提供唯一的表示。8位原码中0 → 00000000-0 → 10000000也就是说0在原码里有两种表示。这在硬件判断两个数是否相等时会有麻烦——判断“x是否为0”需要额外检查两种编码。原码的另一个问题是表示范围有一点“浪费”。8位原码理论上能表示 2^8 256 种组合但由于正0和负0占用了两种实际能表示的整数范围只有 -127 到 127一共255个不同的数值。原码在工程中倒不是完全没用。浮点数标准IEEE 754里的“符号位 阶码 尾数”结构其符号部分本质上就是沿用了“独立符号位”的思路另外在一些只需要人眼查看、不需要计算的场景比如调试工具里显示原始位模式原码形式反而更直观。但作为主流的整数运算表示方式它已经被补码完全取代。3.2 反码向补码过渡的中间台阶反码Ones Complement的定义也有两条正数的反码 正数的原码负数的反码 原码除符号位外其余各位按位取反举例8位5 的原码 00000101反码同样是 00000101-5 的原码 10000101反码是 11111010反码相较于原码的进步在于负数之间的某些运算更容易用逻辑电路实现因为按位取反操作在硬件层面很简单就是一大堆非门而已。但反码并没有彻底解决“原码无法正确做加法”的问题。你可以自己试一下00000101 5 11111010 -5的反码 ----------- 11111111 这对应的是 -0 的反码结果是 11111111按照反码的定义这是 -0。虽然 -0 在数学意义上可以认为是0但“结果是-0”这件事本身就说明反码没有做到“归零”而且仍然保留着“0”和“-0”两种编码并存的问题。所以反码在实际系统中很少被直接用于算术运算它更像是一个数学上的中间产物。不过“按位取反”这个操作本身太常用了至今在C语言里~x这样的位运算符干的还是反码时代定下来的活。3.3 补码现代计算机的事实标准补码Twos Complement的定义在不同教材里有好几种等价说法我用最实用的方式来描述正数的补码 正数的原码负数的补码 负数的反码再加1最低位加1发生进位就往更高位传播还是用 -5 走一遍完整流程原码10000101 反码11111010 补码11111011这个结果正好就是前面用“256 - 5”推出来的 11111011。两种方法殊途同归这说明补码的“反码加一”规则和“溢出归零”的数学本质是同一个东西。补码能成为现代计算机整数表示的事实标准有四个硬核理由第一加法和减法统一成加法。计算机只需设计加法器电路。这是所有优势里最核心的一条。第二0的表示唯一。8位补码中 00000000 既是 0 也是 -0没有歧义判断是否为0只需要一条比较指令。第三多出来一个最小值。8位补码能表示的范围是 -128 到 127比原码反码多了一个 -128没有浪费任何编码组合。第四符号位可以参与运算。你不需要单独判断符号按统一的二进制加法规则算完取最低8位结果天然是正确的。下面这个表格把8位三种编码对几个典型值的表示放在一起方便对照十进制原码反码补码5000001010000010100000101-51000010111111010111110110000000000000000000000000-01000000011111111无已废弃127011111110111111101111111-127111111111000000010000001-128无法表示无法表示100000003.4 快速换算技巧已知补码怎么求原码工作中经常遇到的一个问题拿到一个十六进制的数想知道它对应的十进制是多少。这在读通信协议、解析采集芯片数据时尤其常见。已知补码求原码逻辑上就是把“原码转补码”的过程倒过来如果符号位是0补码就是原码直接按位转十进制即可。如果符号位是1对这个补码再求一次补码按位取反再加1得到的就是原码的数值部分。举例8位补码 10000000符号位为1。取反得 01111111加1得 10000000二进制即128所以它代表 -128。举例8位补码 11111100取反得 00000011加1得 00000100即4所以它代表 -4。这个“对补码再求补码得到原码”的技巧在手动计算时特别快你不需要每次都先减一再取反。写代码的时候更简单如果你只是想知道一个十六进制数在有符号解释下是多少直接用int8_t、int16_t等类型去读取就行编译器会按补码帮你解释。4. 有符号数与无符号数同一个二进制两种解读4.1 同一个比特模式两种完全不同的数值这是本项目标题里最容易让人困惑的部分。前面讲的是“数值在二进制里怎么编码”现在要讲的是“同一个二进制可以按不同规则解读成不同的数”。一个8位二进制 10000011按无符号数解读2^7 2^1 2^0 128 2 1 131按有符号数补码解读符号位为1说明是负数。取反加101111100 1 01111101即125所以是 -125同一个 10000011可以说它是131也可以说它是-125。哪个对取决于你把它当成有符号数还是无符号数来处理。计算机自己并不知道哪个“更正确”它只知道一堆位模式是读取方给它赋予了含义。我见过很多项目里的bug本质就是读取方和写入方对符号性的理解不一致。比如两个设备之间用串口传输数据发送方把温度值-10存成了int8_t在内存里是 11110110接收方如果按uint8_t去读拿到的是246传出来一个莫名其妙的温度246度。4.2 范围与边界有符号和无符号各自能装下什么不同位数的有符号无符号数范围如下表所示位数无符号数范围有符号数补码范围8位0 ~ 255-128 ~ 12716位0 ~ 65535-32768 ~ 3276732位0 ~ 4294967295-2147483648 ~ 214748364764位0 ~ 18446744073709551615-9223372036854775808 ~ 9223372036854775807注意两个规律无符号数的最大值恰好是“所有位全为1”比如8位就是 11111111 255。有符号数的最小值一定是 1000...000符号位1其余全0比如8位就是 10000000 -128最大值一定是 0111...111符号位0其余全1比如8位就是 01111111 127。这个边界问题的实战意义很大。我遇到过一位做嵌入式开发的同事把定时器计数值定义成了int16_t结果计数器累积超过32767后直接变成负数导致延时时长飘忽不定。检查了半天最终发现是这个计数变量在有符号和无符号之间来回隐式转换导致的。4.3 隐式类型转换编译器帮你“翻译”时的坑C/C这类语言里有符号数和无符号数之间可以进行隐式转换。如果表达式中同时出现int和unsigned int会发生一件让很多人第一次遇到都很懵的事情有符号数会被转换成无符号数。举例int a -1; unsigned int b 1; if (a b) { printf(a b); } else { printf(a b); }直觉上 -1 肯定小于1但这段代码的输出是a b。原因就是比较前a被转换成了无符号数。内存中 -1 的32位补码是0xFFFFFFFF按无符号数解读是4294967295自然大于1。这类“符号性陷阱”在条件判断、循环边界、数组下标、比较表达式里很容易出现。我的建议是尽量避免在有符号和无符号之间做隐式比较编译器开了-Wall -Wsign-compare警告后不要无视明确给比较双方都加上类型转换或者统一用同一种符号性的类型4.4 实际场景Matlab中十六进制转有符号数现在讲一个热词里反复出现的应用Matlab中十六进制转有符号数。这也是很多人实际干活时遇到的问题。从串口、网口、仪器里采回来的数据经常是十六进制字符串或字节数组比如0xFF1A你需要在 MATLAB 里把它解析成有符号数但 MATLAB 默认的hex2dec是按无符号整数处理的hex2dec(FF1A)结果是 65306。但如果你知道这条数据本身是一个带符号的16位整数45度可能是 -230也就是 65306 - 65536 -230。正确做法是借typecast或swapbytes这类函数先把十六进制转成对应位数的无符号整数再通过类型重解释转换成有符号数u uint16(hex2dec(FF1A)); % 得到无符号数 65306 s typecast(u, int16); % 按有符号16位重新解释这样 s 就会被正确算成 -230。这个过程背后的原理就是本章的核心思想0xFF1A 只有一种底层比特模式按无符号解释是65306按有符号补码解释是-230两种解释都成立关键看你要哪个。MATLAB里还常涉及大小端问题。如果数据是从大端设备传过来的你可能还需要swapbytesu uint16(hex2dec(FF1A)); u swapbytes(u); % 处理大小端 s typecast(u, int16);这里面的核心不是hex2dec这行代码本身而是“先按无符号读出位模式再通过类型转换按有符号解释”的两步思想。5. 补码世界的几个经典边界问题与实战排查5.1 溢出比最大值多1会发生什么有符号数补码的一个有趣特性是最高位既是数值的一部分也充当符号。当你对一个数不断加1越过最大值后会“翻滚”到最小值。8位补码演示01111111 127 00000001 --------- 10000000 -128127 1 的结果是 -128这在数学上当然是错的但在硬件层面却是“正确的溢出结果”因为 128 在8位补码里根本无法表示所有位进位后被压缩成了 -128 的编码。很多C语言程序里int类型如果取值超过INT_MAX结果是未定义行为UB编译器可能做任何事。但在嵌入式汇编层面如果你明确知道寄存器宽度这种“有符号溢出”是可以预测的。这也是为什么做底层开发的人必须对补码宽度和溢出规律烂熟于心无符号数溢出超出最大值后回到0比如uint8_t x 255; x1结果是0有符号数溢出超出最大值后回到最小值比如int8_t y 127; y1结果是-128这个规律在处理计数器、定时器、增量编码器数据时尤其重要。你写一个限位判断如果用int8_t存储编码器位置转动超过127格后位置直接跳变到-128整个控制逻辑全乱。5.2 符号扩展与截断从8位到16位再到32位在实际编程中经常要把一个小位数的有符号数扩展到大位数。比如读取一个8位的传感器值想把它放到16位的变量里参与运算。如果原数是正数扩展很简单高位补0即可。如果原数是负数直接补0会出错——这又是符号位在作祟。举例8位的 -5 补码是11111011。如果把它扩展成16位错误做法补000000000 11111011 251不再是 -5正确做法补符号位11111111 11111011 -5规则扩展有符号数时新增加的高位全部填充原来的符号位。这称为符号扩展。x86指令集里有MOVSX专门干这件事C语言里把int8_t赋给int16_t时编译器会自动做符号扩展。相反的操作是截断。比如把一个16位有符号数存入8位变量低8位保留高8位直接丢弃。这时结果只能通过低8位的补码重新解释如果原数超出了8位表示范围截断后的数值会发生改变。这本质上是一种“溢出”也是数据解析中常见的坑源。5.3 经典谜题为什么 abs(-128) 还是 -128这是很多C语言笔试和面试喜欢出的题目。abs(INT_MIN)是多少在补码体系里结果是INT_MIN本身。道理很简单8位补码能表示的数值范围是 -128 到 127。128 根本不存在。当你想算出 -128 的相反数时硬件只能做“求补码的补码”对 10000000 取反加1还是 10000000也就是 -128。这不是语言的问题是补码体系本身的数学边界。只要你不越界算补码的加减法都好使一旦越界结果就不符合初中数学了。这提醒我写防御性代码时凡是涉及“取反”“求绝对值”的操作都要先判断是否等于最小值。否则就可能出现“绝对值越算越小”的诡异场景。5.4 常见问题速查表一表读懂典型坑问题场景表现形式根源处理建议if (a b)中a为负b为无符号数判断结果与数学直觉相反有符号数隐式转无符号统一类型或显式强制转换uint8_t x 255; x1x变成0无符号溢出回绕预判范围或使用更大类型int8_t y 127; y1y变成-128有符号溢出回绕预判范围或使用更大类型abs(INT_MIN)仍然等于 INT_MIN补码无法表示 2147483648调用前先判断最小值小负数扩展到大位数数值变成很大的正数未做符号扩展用有符号类型赋值或手动扩展符号位Matlabhex2dec(FF1A)得到65306而不是-230hex2dec按无符号处理用typecast按有符号重解释读取串口负温度值显示为200多度的正数收发两端符号性理解不一致协议里明确数据类型按相同规则解析这张表值得保存。项目里遇到类似现象直接对照基本能定位。6. 理论与实践从二进制编码到日常排错6.1 读内存数据时如何判断该用有符号还是无符号这是一个很实际的问题。调试时打开内存窗口看到一堆十六进制字节怎么判断哪些是有符号数、哪些是无符号数我的经验是判断依据完全取决于数据的语义而不是字节本身。你要先问自己三个问题这个数据在业务上有可能是负数吗比如温度、位移、海拔这类物理量很可能为负应该按有符号解读而地址、计数器、掩码几乎都是非负按无符号解读更合理。协议文档里是怎么定义的很多通信协议会明确写“uint16_t”或“int16_t”按文档来。如果按无符号读数特别大比如接近65535是不是其实是想表达一个负值这往往是排查数据异常的突破口。我在调试一个气象站项目时遇到过这样一件事采集到的气压数据里偶尔出现 65535 这种极大值起初以为是传感器故障后来发现是发送方用-1代表“数据无效”接收方按uint16_t解析就变成了65535。修改解析逻辑后无效值判断就一切正常了。6.2 充分理解位运算与补码的结合位运算里的按位取反~、左移、右移都和补码有千丝万缕的联系。先说右移。对有符号数的右移不同语言有不同的处理策略算术右移Arithmetic Shift Right高位补充符号位。C/C对有符号负数右移一般是算术右移逻辑右移Logical Shift Right高位补0。无符号数的右移就是逻辑右移举例8位 -511111011右移1位算术右移11111101 -3相当于除以2后向下取整逻辑右移01111101 125数值完全变了所以在写x 1之前一定要想清楚 x 是什么类型。这个操作在有符号负数上等价于“除以2并向下取整”不是严格的数学除以2。按位取反和补码的关系前面提过这里再补一个实战例子想要一个“最低N位全为1、其余全为0”的掩码可以用(1 N) - 1。但一旦 N 等于该类型位数1 N会触发溢出或未定义行为所以要用~0做更稳妥的基础掩码来构造。6.3 无符号数与有符号数在循环中的陷阱for循环是另一个高频踩坑区。一个常见的写法for (unsigned int i n; i 0; i--) { // 处理 }这个循环永远无法退出。因为unsigned int永远不会小于0。当 i 递减到0之后再执行i--结果会变成 4294967295也就是0xFFFFFFFF循环条件i 0恒为真。正确写法有两种。要么用有符号数做循环变量要么把条件改写成其它形式for (int i n; i 0; i--) { // 处理 }或for (unsigned int i n 1; i-- 0; ) { // 处理 }第二种写法利用了“先判断后自减”的次序当i从1减到0时先判断0 0为假循环退出但i--这个表达式本身的值还是1判断成立时执行最后一次循环体的 i 是0。这种写法虽然简洁但可读性一般新手容易看不懂所以我更推荐直接用有符号数循环变量。6.4 大型项目中数据解析的分层约定随着项目规模变大有符号无符号的问题就不再是单个函数的问题而是整个数据链路的问题。我给自己的项目定了几条规矩第一通信协议中的数据字段必须显式标注类型。无论是自定义二进制协议还是使用现成序列化格式每个字段都要写清楚是uint8_t还是int16_t避免不同模块间各猜各的。第二所有从外部读进来的数据在进入核心逻辑前统一转成有符号类型或者统一转成无符号类型并写一个数据转换层。不要让二进制层面的类型选择散落到各个业务函数里。第三在代码里对关键阈值做显式的边界检查。比如你从16位有符号数读到一个 -32768它是合理的物理值还是某种错误标志这类判断建议集中在一个函数里处理并加上详细注释。第四使用现代编译器的告警选项。GCC和Clang的-Wsign-compare、-Wconversion这些开关能提前暴露很多符号性不一致的隐患。虽然警告有时候会很多但逐个看一遍比上线后出问题再排查要高效得多。7. 面试与竞赛视角为什么考官总喜欢问原码反码补码7.1 常见面试题的考察意图面试官问“原码、反码、补码的关系”不是真的想让你背定义而是想考察三个层面的能力你是否理解计算机用二进制存储数据的本质你是否理解硬件设计尤其是加法器对数据表示的限制你是否具备把底层知识迁移到实际问题排查中的能力。所以面试里看到这类题不要只回答“负数的补码是反码加一”而是从“为什么需要补码”开始讲讲到“因为硬件只有加法器所以需要一种编码让减法变成加法”再补一句“补码还解决了0的表示唯一性问题”。这个回答的深度和逻辑性会明显高于死记硬背的答案。7.2 几个高频率考察点与答题思路问题一为什么补码的负数范围比正数范围大1答案要从“0的表示唯一”和“符号位参与编码”两个角度讲。最高位的1在负数那边被定义为 -128 的数值贡献而不是单纯的符号位但正数这边只能到127。问题二-1的补码为什么是全1因为 -1 补码本质上是 2^N - 1在N位二进制里就是所有位都为1。这个结论在掩码构造中特别常用。问题三如何判断一个补码是正还是负看最高位。最高位为0是正数含0最高位为1是负数。但要小心这是针对补码的判据不是针对任意二进制的判据。如果你把一个无符号数直接拿来“看符号位”它可能不是你想的那个意思。问题四一个8位补码 10101010对应的十进制是多少计算过程符号位为1取反得 01010101加1得 01010110即86所以是 -86。也可以用更快的公式如果是负数数值等于 -(取反加1后的值)或者等于 该值 - 256即 170 - 256 -86。7.3 如何向初学者讲清核心逻辑如果你是在教别人这个概念我建议不要上来就画“原码反码补码转换表”。试试这个思路先让对方看一个未初始化的LED屏问“如果只知道一个16位二进制值你能说出它表示的整数是多少吗”对方会发现答案不唯一因为我们可以选择把它当有符号还是无符号。再抛一个问题“如果计算机硬件只会加法减法怎么实现”带他走一遍“5加某个数等于0”的推理让他自己推出来 -5 的表示。最后再用“反码加一”的规则去验证刚才推出的结果对方会恍然大悟原来规则不是天上掉下来的是数学推导的必然结果。这种从“为什么”到“是什么”的教学顺序比从“是什么”到“为什么”更自然也更容易让人记住。8. 一些实操中的个人心得这部分内容不完全算理论更像是我在一个个项目里踩坑之后沉淀下来的习惯。不一定适合所有人但你可以参考。第一凡是需要手工查看十六进制数据的时候我总会先确认这批数据是“有符号解释”还是“无符号解释”。很多调试工具默认按无符号显示导致负数看起来变成很大的正数这个现象和实际业务数值匹配不上时八成就是符号位理解错了。第二判断溢出不要只靠肉眼盯数值可以借助编译器的__builtin_add_overflowGCC/Clang里这类内置函数它能给出是否溢出的布尔结果比事后看结果猜要可靠得多。第三遇到由位操作实现的协议字段画一张位图比盯着代码猜更有效。把每一段bit的偏移、长度、符号性和实际含义列出来再写代码出错的概率会小非常多。我常用一个简单的表格记录这种位图。第四写得多了之后我反而不怎么用“手工把负数转成补码再算”的方式了更多时候是直接在代码里用printf(%d, signed_value)或调试器看变量让编译器和调试器替我完成解释。但一旦涉及底层协议、固件开发、汇编或硬件的问题这些手工推算能力又会立刻变成救命稻草。原码、反码、补码、有符号数、无符号数这几组概念说到底是一件事的一体两面一方面是如何把抽象的整数编码成二进制另一方面是如何把同一串二进制解码出有意义的数。把“编码”和“解读”两个层次区分开很多困惑就会迎刃而解。最后再分享一个小经验强烈建议你自己拿一张纸把 -128 到 127 之间的关键数值0、1、-1、最大值、最小值的8位补码手写一遍再随便找几个十六进制数做“读码—转十进制”练习。这个过程看起来简单但对加深理解的效果远好于看十篇总结文章。
返回列表