从模拟信号到补码:计算机如何存储和计算数字
目次
模电 #
总常听到计算机的世界中只有0和1这两个,现实也是这样。但是自然界中的数据通常不是这样不是0就是1这般非黑即白的。像是声音、电流等等,都是连续的。
为了能存储这些数据,就要转换成计算机的方式存储,那就是用二进制表示,怎么将连续的数值转换成二进制呢?先后经过模电(模拟电路)中的采样、量化、编码。
如果不想理解其中的经过,那么作用像是下图这种

想要更详细的理解,就需要付出一些时间了。
采样 #

上面是不同采样频率下得到的数据,得到的数据量是不同的,对应的,存储这些数据需要的空间也就不同。频率越高,损失的数据越少。
量化 #
刚刚看的是横坐标,现在看纵坐标。每个点对应的纵坐标都对应着在这次采样中取到的数值,想要能记录下来,要把数值以一个相对值记录对吧。纵坐标上可能是许多不同的值,但是我们保存的时候会将这些数据按照不同的阶段对应到不同的值。
拿电压举例:
假设模拟信号是一个连续电压曲线:
| |
量化就是给这段范围分成若干"台阶",比如 8 级:
| 电压范围 | 量化后值 |
|---|---|
| -1.0V ~ -0.75V | 0 |
| -0.75V ~ -0.5V | 1 |
| -0.5V ~ -0.25V | 2 |
| -0.25V ~ 0.0V | 3 |
| 0.0V ~ +0.25V | 4 |
| +0.25V ~ +0.5V | 5 |
| +0.5V ~ +0.75V | 6 |
| +0.75V ~ +1.0V | 7 |
如果测得电压是 +0.63V,量化后结果是 6。
编码 #
经过量化步骤得到的是十进制的数,开始说过计算机需要用二进制的数保存,这一步要做的就是这个——将十进制转换成二进制。
数电 #
数电中通过不同的门电路和二进制完成数值的计算。补码的作用是为了简化减法计算,将减法转换成加法操作。
为什么这样呢?
- 加法操作比起减法需要更少的门电路,只是用一个完成加法即可。
- 加法的进位比起减法的借位更容易,加法只可能向高位进一,不可能进更多,也就是每一位上计算最多需要用到低位上进来的一个数;减法不一样,可能会遇到连续借位的情况,这时候会更复杂。
硬件实现:
直接减法器需要:
- 借位逻辑电路(比进位复杂)
- 大小比较电路
- 符号处理电路
- 可能的操作数交换电路
- 结果取反电路
加法器 + 补码只需要:
- 一个加法器
- 一个取反器(NOT门)
- 一个+1电路
补码 #
铺垫完了,什么是补码呢?怎么执行?为什么这么设计?
说白了补码是为了构建出和一个数比如是5,相加等于0的数,为了达成这个目的,有很多途径可以走。
为了理解补码,我们先看看其他编码方式:
原码(符号+原数字) #
| |
这样其实和减法没什么差别了,先要知道两个数的正负,再比较两个数的绝对值,决定结果的符号。
反码 #
| |
这样看起来可以,但实际上有问题,会有两个0:
| |
两种形式都是0。
还有另外一个问题,让我们举个例子计算5-3的结果。
| |
为什么需要把进位加到最低位呢?
反码的数学本质 #
对于 n 位二进制数,负数 -x 的反码定义为:
| |
这个定义直接决定了反码是模 2^n - 1 系统!
为什么是 2^n - 1? #
关键在于:反码是通过"按位取反"得到的
按位取反的数学本质 #
对于 4 位二进制:
| |
让我们计算:
| |
15 就是 2^4 - 1
通用规律 #
对于任意 n 位二进制数 x:
| |
证明:
| |
综上,反码实际上是基于模 (2^n - 1) 的运算系统。对于 4 位二进制:
- 模是 2^4 - 1 = 15
这意味着在反码系统中,-3 实际上表示为:
| |
为什么需要循环进位 #
让我们看完整的计算过程:
5 - 3 的反码运算:
| |
现在关键来了:
- 我们得到了
10001(5位) - 但在 4 位反码系统中,模是 15 (1111)
- 最高位的
1代表进位,意味着我们"超出了模"
数学上的解释:
| |
这个 “mod 15” 的操作在硬件上就是:把进位加回到最低位
| |
这就是使用反码麻烦的原因。
补码的设计 #
对于 n 位二进制数,负数 -x 的补码定义为:
| |
这个定义直接决定了补码是模 2^n 系统!
为什么不能是其他的数呢?刚刚的反码是2n-1,证明过不行了。那么模能不能更大一些呢?比如如果模是 2n + 1:
| |
所以只有 2^n 这个模才能让补码系统完美工作。
为什么内存中使用补码 #
说完了补码的设计原理,你可能会想:既然补码是为了简化硬件计算,那在内存里存储数据时,为什么也用补码呢?直接用原码存不是更直观吗?
统一性原则 #
想象一下,如果内存用原码存储,ALU(算术逻辑单元)用补码计算,会发生什么?
| |
每次从内存读取数据到CPU进行计算,都要做一次转换;计算完再存回内存,又要转换回去。这不是白白增加了复杂度吗?
不如从头到尾都用同一套规则——这就是内存也使用补码的根本原因。
存储也需要比较 #
内存里的数据不只是存着不动,还要做很多操作:
- 比较大小:判断 a > b 吗?
- 查找:在数组里找最大值
- 排序:给一堆数排序
如果用原码,比较两个负数就很麻烦:
| |
因为原码的符号位和数值部分是分开的,比较负数时要单独处理。
但在补码中:
| |
补码的巧妙之处在于:所有数(包括正数和负数)都可以直接按二进制大小比较,不需要特殊逻辑。
节省硬件成本 #
如果内存用原码,CPU用补码,就需要:
- 数据总线上要有转换电路
- 缓存控制器要处理转换逻辑
- DMA(直接内存访问)也要知道怎么转换
这些都是额外的硬件成本和功耗。
而统一使用补码:
- 数据从内存到CPU寄存器,直接传输,零开销
- 存储和计算用同一套规则,硬件设计简单
- 所有的加法器、比较器都可以共用
历史也是这么选择的 #
早期计算机确实尝试过不同的方案:
- IBM 7090(1959):用符号-数值表示(类似原码)
- CDC 6600(1964):用补码
- 现代所有处理器:都用补码
为什么最后都选择了补码?因为它在存储、传输、计算三个环节都是最优的选择。
C++中的自然溢出:补码的意外之喜 #
用了补码之后,还有一个意想不到的好处——溢出处理变得非常自然。
什么是溢出 #
先看一个简单的例子,8位有符号整数(int8_t)的取值范围是 -128 到 127:
| |
在数学上,127 + 1 = 128,但 128 已经超出了 int8_t 的表示范围。这就是溢出。
补码下的溢出行为 #
让我们看看在补码系统中实际发生了什么:
| |
127 + 1 = -128?这看起来很奇怪,但其实非常合理!
这正是模 2^n 运算的自然结果:
| |
为什么这是"自然"的? #
关键在于:硬件不需要做任何额外的事情。
看看如果用原码会怎样:
| |
但在补码系统中:
| |
实际例子:循环计数器 #
这个特性在实际编程中非常有用,比如实现一个循环计数器:
| |
如果没有这种自然溢出,你需要这样写:
| |
有符号数的环形世界 #
补码让整数形成了一个环形结构:
| |
不断加 1:... → 126 → 127 → -128 → -127 → ...
不断减 1:... → -127 → -128 → 127 → 126 → ...
这个环形结构让很多算法的实现变得优雅:
例子:判断两个时间戳的先后关系
| |
C++ 标准的规定 #
需要注意的是,C++ 标准对溢出有不同的规定:
无符号整数(unsigned):
- 溢出行为是明确定义的
- 结果等于模 2^n
- 完全依赖补码的自然特性
| |
有符号整数(signed):
- C++20 之前:溢出是未定义行为(UB)
- C++20 开始:有符号整数必须用补码,溢出变成实现定义行为
| |
虽然实际上所有现代编译器都用补码,但在 C++17 及之前写代码时,最好避免依赖有符号数溢出的行为。
硬件层面的简洁 #
最后总结一下,补码让溢出处理变得如此简单的原因:
硬件需要做的:
- 执行加法
- 丢弃超出位数的进位
硬件不需要做的:
- 不需要检查溢出
- 不需要特殊处理边界值
- 不需要区分有符号和无符号(加法器是同一个)
- 不需要任何 if-else 逻辑
这就是为什么补码不仅简化了减法,还让整个整数运算系统变得如此优雅。
总结一下:
内存使用补码不是因为"不得不这样",而是因为"这样最好":
- 和CPU的计算逻辑统一,无需转换
- 比较操作可以直接用二进制大小判断
- 硬件实现最简单,成本最低
- 溢出行为自然,不需要额外处理逻辑
从内存到寄存器,从磁盘到网络传输,整个计算机系统都在用补码——这是一套经过时间检验的、最优雅的设计。