前言
大家好,欢迎来到我们今天的主题:定点数的编码表示这一章节。
相信很多学C语言或C++的学弟学妹们都经历过这样一个"灵异事件":你定义了一个 int 类型的变量,给它赋值为大约 21 亿(具体是 231−12^{31}-1231−1),然后手贱给它加了个 1。结果呢?这个数瞬间变成了一个巨大的负数!
当时你的表情大概是这样的:🤯(WDF)。
为什么?计算机坏了?CPU 抽风了?
Nonono,这是计算机底层为你精心准备的一场"数字魔术"。要看懂这场魔术,我们就得钻进计算机肚子里,看看它是怎么用那一堆冷冰冰的"0"和"1"来表示我们现实世界中丰富多彩的数字的。
今天这篇博文,咱们不搞照本宣科那一套,我尽量用最通俗的话和最硬核的原理,带你彻底搞懂定点数、原码、反码、补码和移码这几个让无数初学者头大的概念。
定点数的"百变戏法"(原码、反码、补码、移码)
1. 开胃菜:定点数 VS 浮点数
在计算机的世界里,所有数据最终都是 0 和 1。要表示一个数,最大的问题是:小数点点在哪儿?
根据小数点位置是否固定,我们将数字的表示分为两大门派:
-
定点数 (Fixed-Point Number):
顾名思义,小数点的位置是死板的、固定不变的。这就好比我们平时去超市买菜,价格标签上写着 12.50 元,小数点永远在那两位小数前面。在计算机里,我们通常约定小数点要么在最后面(表示纯整数),要么在最前面(表示纯小数)。
-
浮点数 (Floating-Point Number):
小数点的位置是飘忽不定的。这就像科学计数法,比如 1.23×1051.23 \times 10^51.23×105 和 1.23×10−51.23 \times 10^{-5}1.23×10−5,虽然数字部分都是 123,但因为指数不同,小数点的位置实际上发生了"浮动"。浮点数是为了解决超大或超小数值表示而生的(这一部分我们后续章节细聊)。

本篇的主角,是定点数。 我们要看看,在小数点锁死的情况下,计算机怎么玩出花来。
2. "裸奔"的数字:无符号数 (Unsigned Number)
最简单粗暴的表示方式,就是无符号数。
所谓"无符号",就是说整个机器字长(比如一个 8 bit 的寄存器)里所有的二进制位,全部用来表示数值本身,没有哪一位是拿来表示正负号的。默认大家都是正数(或者零)。
2.1 表示与计算
这跟我们小学学的二进制转十进制一模一样。每一位二进制数乘以它对应的"位权",然后相加。
假设我们有一个 8 位的无符号数,二进制是 10011100:
数值=1×27+0×26+0×25+1×24+1×23+1×22+0×21+0×20=128+0+0+16+8+4+0+0=156\begin{aligned} 数值 &= 1 \times 2^7 + 0 \times 2^6 + 0 \times 2^5 + 1 \times 2^4 + 1 \times 2^3 + 1 \times 2^2 + 0 \times 2^1 + 0 \times 2^0 \\ &= 128 + 0 + 0 + 16 + 8 + 4 + 0 + 0 \\ &= 156 \end{aligned}数值=1×27+0×26+0×25+1×24+1×23+1×22+0×21+0×20=128+0+0+16+8+4+0+0=156
2.2 表示范围
对于一个 nnn 位的无符号数:
-
最小值: 显然是所有位全为 0,即 00...000\dots000...0,真值为 000。
-
最大值: 显然是所有位全为 1,即 11...111\dots111...1。
这个全为 1 的值是多少呢?
-
笨办法: 2n−1+2n−2+⋯+202^{n-1} + 2^{n-2} + \dots + 2^02n−1+2n−2+⋯+20,这是一个等比数列求和,结果是 2n−12^n - 12n−1。
-
聪明办法: 假设这个数是 XXX (n个1)。如果我给 XXX 加 1,会发生什么?末位 1+11+11+1 变 000 进 111,一直连锁反应,最后变成了 111 后面跟 nnn 个 000,也就是 2n2^n2n。既然 X+1=2nX+1 = 2^nX+1=2n,那么 X=2n−1X = 2^n - 1X=2n−1。
结论:nnn 位无符号数的表示范围是 0,2n−10, 2\^n - 10,2n−1。
例如,8位无符号数(C语言中的 unsigned char)的范围是 0,28−1=0,2550, 2\^8-1 = 0, 2550,28−1=0,255。
无符号数通常只用来表示整数(在 C/C++ 中有 unsigned int, unsigned long 等,但没有 unsigned float)。
其最大的坑在于"下溢出"。
请大家看看这段代码,会不会死循环?
c
unsigned int i;
for (i = 10; i >= 0; --i) {
printf("%u\n", i);
}
答案是:会!
因为当 i 等于 0 时,再减 1,它不会变成 -1,而是会变成无符号数的最大值(比如 32位下的 232−12^{32}-1232−1),这永远满足 i >= 0 的条件。这就是无符号数回绕(Wrapping)现象。

3. 有符号数的"四大金刚":原、反、补、移
现实世界是有负数的,计算机必须得能表示负数。对于定点数,我们通常把最高位腾出来,当作符号位 ,我们称为这个是有符号数。比如:1000 0010,这个最高位的1,代表着这个数是一个负数,数值由右边七位来决定,即:-2,0000 0010就是正2.
-
符号位 = 0 ,表示 正数。
-
符号位 = 1 ,表示 负数。
剩下的位(称为尾数)才用来表示数值的大小。
为了解决负数的存储和运算问题,聪明的计算机先驱们发明了四种编码方式,我称之为"四大金刚":原码、反码、补码、移码。
为了后续演示方便,我们统一假设机器字长为 8位。
3.1 原码 (Sign-Magnitude):所见即所得
原码是最符合人类思维习惯的表示方法。
- 定义: 最高位为符号位,其余位表示数值的绝对值。
3.1.1 定点整数的原码
小数点隐含在最后一位的后面。
-
例:+19+19+19
-
符号位:正数 →0\rightarrow 0→0
-
数值位:19 的二进制是 100111001110011。我们要凑够 7 位尾数,前面补两个 0,变成 001001100100110010011。
-
+19原=00010011+19_{原} = 00010011+19原=00010011
-
-
例:−19-19−19
-
符号位:负数 →1\rightarrow 1→1
-
数值位:绝对值还是 19,尾数还是 001001100100110010011。
-
−19原=10010011-19_{原} = 10010011−19原=10010011
-
3.1.2 定点小数的原码
小数点隐含在符号位的后面,尾数的第一位权值是 2−1=0.52^{-1}=0.52−1=0.5,第二位是 2−2=0.252^{-2}=0.252−2=0.25,以此类推。
-
例:+0.75+0.75+0.75
-
0.75=0.5+0.25=2−1+2−20.75 = 0.5 + 0.25 = 2^{-1} + 2^{-2}0.75=0.5+0.25=2−1+2−2,二进制是 0.110.110.11。
-
符号位:000
-
数值位(凑够7位):110000011000001100000
-
+0.75原=0.1100000+0.75_{原} = 0.1100000+0.75原=0.1100000 (书上为了看得清楚,可能会写成 0.11000000.11000000.1100000 或者 0,11000000,11000000,1100000,那个点或逗号是给人看的,机器里不存在)
-
-
例:−0.75-0.75−0.75
-
符号位:111
-
数值位:110000011000001100000
-
−0.75原=1.1100000-0.75_{原} = 1.1100000−0.75原=1.1100000
-

3.1.3 原码的缺陷
原码看起来很美好,简单直观。但是它有两个致命的缺陷,导致现代计算机基本废弃了用它来进行整数运算:
-
"0"的表示不唯一:
-
+0原=00000000+0_{原} = 00000000+0原=00000000
-
−0原=10000000-0_{原} = 10000000−0原=10000000
-
一个数学上的 0,竟然占用了两个编码状态,我们称为正零与负零!这在硬件设计上是一个浪费,判断一个数是不是 0 还需要比较两次。
-
-
加减法运算极其复杂:
-
如果要做 1+(−1)1 + (-1)1+(−1),用原码表示就是 00000001+1000000100000001 + 1000000100000001+10000001。如果直接相加,结果是 100000101000001010000010,这是原码的 −2-2−2。结果显然错了!
-
为了用原码做加减法,CPU 的 ALU(算术逻辑单元)必须得先判断两个操作数的符号。如果是同号相加,就加绝对值;如果是异号相加(即减法),还得比较绝对值谁大,用大的减小的,最后还要确定结果的符号......
-
CPU 设计师:我太难了,这得增加多少逻辑电路啊!累吐血了!
-
为了解决这个问题,反码和补码应运而生。
3.2 反码 (One's Complement):尴尬的中间商
反码的出现是为了解决原码计算困难的问题,它是通往补码的必经之路。
-
定义:
-
正数: 反码和原码一模一样。(如假包换)
-
负数: 原码的符号位不变,尾数各位全部取反(0变1,1变0)。
-
-
例:+19+19+19
-
+19原=00010011+19_{原} = 00010011+19原=00010011
-
+19反=00010011+19_{反} = 00010011+19反=00010011 (不变)
-
-
例:−19-19−19
-
−19原=10010011-19_{原} = \mathbf{1}0010011−19原=10010011
-
符号位保持 1 ,尾数 001001100100110010011 全部取反变成 110110011011001101100。
-
−19反=11101100-19_{反} = 11101100−19反=11101100
-
-
例:−0.75-0.75−0.75
-
−0.75原=1.1100000-0.75_{原} = \mathbf{1}.1100000−0.75原=1.1100000
-
−0.75反=1.0011111-0.75_{反} = 1.0011111−0.75反=1.0011111
-

反码的地位:
反码在现代计算机中几乎不作为最终的数据存储格式。它最大的问题是,它依然有两个0!
+0反=00000000+0_{反} = 00000000+0反=00000000
−0反=10000000→取反11111111-0_{反} = 10000000 \xrightarrow{取反} 11111111−0反=10000000取反 11111111
正零是全0,负零是全1。这依然很尴尬。反码现在更多的身份是"原码转补码的中间跳板"。
3.3 补码 (Two's Complement):计算机界的"真神"
这是本篇最重要的部分,也是面试最高频的考点。现代计算机系统中,整数一律采用补码来存储和运算!

3.3.1 补码的定义与转换
-
定义:
-
正数: 补码和原码、反码统统一样。(正数真是个好孩子)
-
负数: 在反码的基础上,末位加 1。
-
简而言之,负数补码的口诀:"符号位不变,尾数取反,末位加一"。
-
例:−19-19−19 求补码
-
写出原码:100100111001001110010011
-
求反码(尾数取反):111011001110110011101100
-
末位加一:11101100+1=1110110111101100 + 1 = 1110110111101100+1=11101101
-
−19补=11101101-19_{补} = 11101101−19补=11101101
-
-
例:−0.75-0.75−0.75 求补码
-
写出原码:1.11000001.11000001.1100000
-
求反码:1.00111111.00111111.0011111
-
末位加一:1.0011111+0.0000001=1.01000001.0011111 + 0.0000001 = 1.01000001.0011111+0.0000001=1.0100000 (注意进位!)
-
−0.75补=1.0100000-0.75_{补} = 1.0100000−0.75补=1.0100000
-
如何补码转回原码
已知一个负数的补码,怎么求原码?
很多同学会想着逆运算:"先减1,再取反"。这当然对。
但是,有一个更骚的操作:对补码再求一次补码,就回到了原码!
试一下:已知 −19补=11101101-19_{补} = 11101101−19补=11101101
符号位不变,尾数取反:100100101001001010010010
末位加一:10010010+1=1001001110010010 + 1 = 1001001110010010+1=10010011
这不就是 −19原-19_{原}−19原 吗?神不神奇!
记住:负数原码与补码的转换互为逆运算,操作逻辑完全一致。
3.3.2 深度硬核:为什么计算机要用补码?(核心考点)
补码不止解决了"0"的唯一性问题,这只是表象。补码真正的牛X之处在于它统一了加法和减法,拯救了 ALU 的设计师。
我们要引入一个概念:模运算 (Modulo Arithmetic)。
想象一个圆形的挂钟,上面有 0 到 11 一共 12 个刻度。这就构成了一个"模 12"的系统。

现在指针指向 2 点。如果不动指针,我想让它指向 1 点,有两种办法:
-
逆时针 拨 1 个格(相当于做减法:2−1=12 - 1 = 12−1=1)。
-
顺时针 拨 11 个格(相当于做加法:2+11=132 + 11 = 132+11=13)。在模 12 的世界里,13 实际上就是 1(13mod 12=113 \mod 12 = 113mod12=1)。
发现了吗?在模运算系统中,减去一个数,等于加上这个数对应的"补数"。 在模 12 系统中,-1 的补数就是 11(因为 ∣−1∣+11=12|-1| + 11 = 12∣−1∣+11=12)。
计算机的 nnn 位寄存器,本质上就是一个"模 2n2^n2n"的计数器。对于 8 位系统,模就是 28=2562^8 = 25628=256。
当我们用补码表示负数 −X-X−X 时,实际上存储的是 2n−∣X∣2^n - |X|2n−∣X∣。
现在来看看补码如何用加法器搞定减法运算 A−BA - BA−B:
我们把它看作 A+(−B)A + (-B)A+(−B)。我们在计算机里计算 A补+−B补A{补} + -B{补}A补+−B补。
A补+−B补=A+(2n−B)=2n+(A−B)\begin{aligned} A{补} + -B{补} &= A + (2^n - B) \\ &= 2^n + (A - B) \end{aligned}A补+−B补=A+(2n−B)=2n+(A−B)
由于我们的寄存器只有 nnn 位,这个结果中最前面的那个 2n2^n2n(也就是最高位进位产生的一个 1,后面跟 n 个 0)会被直接丢弃(溢出舍弃)。
所以,最终寄存器里剩下的结果就是 A−BA - BA−B 的补码形式!
实战案例:用补码计算 2−22 - 22−2 (8位)
-
222 的补码:000000100000001000000010
-
−2-2−2 的补码:原码 10000010→10000010 \rightarrow10000010→ 反码 11111101→11111101 \rightarrow11111101→ 补码 111111101111111011111110
直接相加:
00000010 (+2)
+ 11111110 (-2)
----------
1 00000000
↑
最高位进位,丢弃!
结果是 00000000,正是 0 的补码!完美!
ALU 设计师喜极而泣: 有了补码,我的 ALU 里面只需要设计一个加法器(Adder),就可以同时搞定加法和减法运算了!电路复杂度大大降低,运行效率咔咔提升。这就是补码被称为"神"的原因。
3.3.3 补码的细节:唯一的 0 和多出来的负数
1. "0"的唯一性
-
+0补=+0原=00000000+0{补} = +0{原} = 00000000+0补=+0原=00000000
-
−0补-0_{补}−0补:原码 10000000→10000000 \rightarrow10000000→ 反码 11111111→11111111 \rightarrow11111111→ 末位加一,产生连续进位 →100000000\rightarrow \mathbf{1}00000000→100000000。最高位的 1 被丢弃,剩下 000000000000000000000000。
看到了吗?正负零的补码殊途同归,都是全 0。完美解决了原码和反码的问题。
- 那个多出来的负数
细心的你可能会发现,在原码和反码中,100000001000000010000000 表示 −0-0−0。现在在补码中,−0-0−0 被合并没了,那 100000001000000010000000 这个二进制编码空出来了,它表示谁呢?
在补码系统中,我们人为规定,符号位为 1,尾数全为 0 的数,表示当前字长下能表示的最小负数。
-
对于 8 位整数,x补=10000000x_{补} = 10000000x补=10000000,它表示的真值是 −27=−128-2^7 = -128−27=−128。
-
对于 8 位定点小数,x补=1.0000000x_{补} = 1.0000000x补=1.0000000,它表示的真值是 −1-1−1。
这个数很特殊,它是没有对应的原码和反码的(因为原码反码表示不了 -128)。

3.4 移码 (Excess Code / Offset Binary):为了比较而生
移码通常只用于表示整数。它的定义非常简单。
-
定义: 在补码的基础上,将符号位取反。
-
例:+19+19+19
-
+19补=00010011+19_{补} = \mathbf{0}0010011+19补=00010011
-
符号位 0 变 1:+19移=10010011+19_{移} = \mathbf{1}0010011+19移=10010011
-
-
例:−19-19−19
-
−19补=11101101-19_{补} = \mathbf{1}1101101−19补=11101101
-
符号位 1 变 0:−19移=01101101-19_{移} = \mathbf{0}1101101−19移=01101101
-

注意: 也有教科书定义移码为"真值 + 偏置值(Bias)"。对于 n 位整数,偏置值通常取 2n2^n2n 或 2n−12^n-12n−1。我们这里采用王道书和大多数教材的定义:补码符号位取反(这等价于真值 + 2n2^n2n)。
为什么要有移码?
看看上面的例子,正数的移码符号位是 1,负数的移码符号位是 0。这看起来很反直觉是不是?
移码最大的妙处在于,它把所有的负数映射到了正数区间,把正数映射到了更大的正数区间。这使得我们可以像比较无符号数一样,直接比较两个移码的大小!
-
最小的数(例如 -128),其移码是 000000000000000000000000。
-
最大的数(例如 +127),其移码是 111111111111111111111111。
从全 0 到全 1,随着移码的值(当做无符号数看)增大,其对应的真值也是单调递增的。

应用场景: 移码主要用于**浮点数的阶码(指数部分)**表示。因为阶码即要有正也要有负,用移码表示后,硬件在比较两个浮点数的指数大小时,不需要考虑符号,直接按位比较即可,电路设计更简单。
4. 知识点大一统与表示范围盘点
这部分是考试的重灾区,一定要背下来,理解着背!我们假设机器字长为 n+1n+1n+1 位(1位符号位,n位尾数)。
| 编码方式 | 整数表示范围 | 小数表示范围 | 零的表示 | 备注 |
|---|---|---|---|---|
| 原码 | −(2n−1),2n−1- (2\^n - 1), \\quad 2\^n - 1−(2n−1),2n−1 | −(1−2−n),1−2−n- (1 - 2\^{-n}), \\quad 1 - 2\^{-n}−(1−2−n),1−2−n | 双重 (+0, -0) | 直观,计算复杂 |
| 反码 | −(2n−1),2n−1- (2\^n - 1), \\quad 2\^n - 1−(2n−1),2n−1 | −(1−2−n),1−2−n- (1 - 2\^{-n}), \\quad 1 - 2\^{-n}−(1−2−n),1−2−n | 双重 (+0, -0) | 中间状态 |
| 补码 | −2n,2n−1- 2\^n, \\quad 2\^n - 1−2n,2n−1 | −1,1−2−n- 1, \\quad 1 - 2\^{-n}−1,1−2−n | 唯一 (全0) | 现代计算机标准,多表示一个最小负数 |
| 移码 | −2n,2n−1- 2\^n, \\quad 2\^n - 1−2n,2n−1 | (通常不用于小数) | 唯一 (符号位1,其余0) | 用于浮点数阶码,便于比较 |
重点记忆:
-
补码的整数范围比原码多一个最小负数(比如 8 位是 -128 到 +127)。
-
补码的小数范围可以取到 -1,但取不到 +1。
-
补码和移码的 0 都是唯一的。

5. 实战演练与快速转换秘籍
光说不练假把式。我们拿两个数来练练手,假设是 8 位机器数。
演练 1:真值 x=+50x = +50x=+50
-
二进制: 50=32+16+2=00110010250 = 32 + 16 + 2 = 00110010_250=32+16+2=001100102
-
原码: 正数符号位为 0 →00110010\rightarrow \mathbf{0}0110010→00110010
-
反码: 正数同原码 →00110010\rightarrow \mathbf{0}0110010→00110010
-
补码: 正数同原码 →00110010\rightarrow \mathbf{0}0110010→00110010
-
移码: 补码符号位取反 →10110010\rightarrow \mathbf{1}0110010→10110010
演练 2:真值 x=−100x = -100x=−100
-
二进制绝对值: 100=64+32+4=011001002100 = 64 + 32 + 4 = 01100100_2100=64+32+4=011001002
-
原码: 负数符号位为 1 →11100100\rightarrow \mathbf{1}1100100→11100100
-
反码: 原码符号位不变,尾数取反 →10011011\rightarrow \mathbf{1}0011011→10011011
-
补码: 反码末位加 1 →10011011+1=10011100\rightarrow 10011011 + 1 = \mathbf{1}0011100→10011011+1=10011100
-
移码: 补码符号位取反 →00011100\rightarrow \mathbf{0}0011100→00011100
手算补码的"骚操作"
考试或者面试时,为了求快,求负数补码(或从负数补码求原码)有一个无需经过反码的快速方法:
口诀:"从右往左找第一个 1,这个 1 和它右边的 0 保持不变,它左边的所有位(包括符号位)全部取反。"
来试一下求 −100-100−100 的补码:
-
原码:11100100111001\mathbf{00}11100100
-
从右往左看,第一个
1在第三位。 -
保留这个
1和右边的00,即...100。 -
左边的
1110全部取反变成0001。 -
合体:10011100\mathbf{1}001110010011100。
对比一下上面一步步算出来的结果,是不是一模一样?这个方法在做题时能帮你节省大量时间!
总结

洋洋洒洒写了这么多,希望能帮你把这块硬骨头啃下来。
-
记住定点数就是小数点固定。
-
记住无符号数容易发生回绕。
-
记住原码最直观但计算最废柴。
-
记住补码是计算机计算的核心,它用模运算的魔法统一了加减法,还解决了 0 的唯一性问题。
-
记住移码是为了方便比较大小而生的。


如果你觉得这篇博文对你有帮助,别忘了点赞、收藏、关注,我们下期见!
