opoojkk

从模拟信号到补码:计算机如何存储和计算数字

lxx
目次

模电 #

总常听到计算机的世界中只有0和1这两个,现实也是这样。但是自然界中的数据通常不是这样不是0就是1这般非黑即白的。像是声音、电流等等,都是连续的。

为了能存储这些数据,就要转换成计算机的方式存储,那就是用二进制表示,怎么将连续的数值转换成二进制呢?先后经过模电(模拟电路)中的采样、量化、编码。

如果不想理解其中的经过,那么作用像是下图这种

ADC

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

采样 #

ADC

上面是不同采样频率下得到的数据,得到的数据量是不同的,对应的,存储这些数据需要的空间也就不同。频率越高,损失的数据越少。

量化 #

刚刚看的是横坐标,现在看纵坐标。每个点对应的纵坐标都对应着在这次采样中取到的数值,想要能记录下来,要把数值以一个相对值记录对吧。纵坐标上可能是许多不同的值,但是我们保存的时候会将这些数据按照不同的阶段对应到不同的值。

拿电压举例:

假设模拟信号是一个连续电压曲线:

1
-1.0V → +1.0V

量化就是给这段范围分成若干"台阶",比如 8 级:

电压范围量化后值
-1.0V ~ -0.75V0
-0.75V ~ -0.5V1
-0.5V ~ -0.25V2
-0.25V ~ 0.0V3
0.0V ~ +0.25V4
+0.25V ~ +0.5V5
+0.5V ~ +0.75V6
+0.75V ~ +1.0V7

如果测得电压是 +0.63V,量化后结果是 6。

编码 #

经过量化步骤得到的是十进制的数,开始说过计算机需要用二进制的数保存,这一步要做的就是这个——将十进制转换成二进制。

数电 #

数电中通过不同的门电路和二进制完成数值的计算。补码的作用是为了简化减法计算,将减法转换成加法操作。

为什么这样呢?

  1. 加法操作比起减法需要更少的门电路,只是用一个完成加法即可。
  2. 加法的进位比起减法的借位更容易,加法只可能向高位进一,不可能进更多,也就是每一位上计算最多需要用到低位上进来的一个数;减法不一样,可能会遇到连续借位的情况,这时候会更复杂。

硬件实现:

直接减法器需要:

加法器 + 补码只需要:

补码 #

铺垫完了,什么是补码呢?怎么执行?为什么这么设计?

说白了补码是为了构建出和一个数比如是5,相加等于0的数,为了达成这个目的,有很多途径可以走。

为了理解补码,我们先看看其他编码方式:

原码(符号+原数字) #

1
2
+5 = 0000 0101
-5 = 1000 0101

这样其实和减法没什么差别了,先要知道两个数的正负,再比较两个数的绝对值,决定结果的符号。

反码 #

1
2
+5 = 0000 0101
-5 = 1111 1010   // 只是取反,没有 +1

这样看起来可以,但实际上有问题,会有两个0:

1
2
+0 = 0000 0000
-0 = 1111 1111

两种形式都是0。

还有另外一个问题,让我们举个例子计算5-3的结果。

1
2
3
4
5
6
7
8
5 的原码:  0101
3 的原码:  0011
3 的反码:  1100 (符号位不变,其他位取反)

5 + (-3):  0101
         + 1100
         -------
           10001  (产生进位)

为什么需要把进位加到最低位呢?

反码的数学本质 #

对于 n 位二进制数,负数 -x 的反码定义为:

1
反码(-x) = (2^n - 1) - x

这个定义直接决定了反码是模 2^n - 1 系统!

为什么是 2^n - 1? #

关键在于:反码是通过"按位取反"得到的

按位取反的数学本质 #

对于 4 位二进制:

1
2
3
4
5
3 的二进制: 0011

按位取反:   1100

这个1100代表什么数?

让我们计算:

1
2
3
4
5
原数:    0011 = 0×8 + 0×4 + 1×2 + 1×1 = 3
取反后:  1100 = 1×8 + 1×4 + 0×2 + 0×1 = 12

它们的关系:
3 + 12 = 15 = 1111

15 就是 2^4 - 1

通用规律 #

对于任意 n 位二进制数 x:

1
x 的按位取反 = (2^n - 1) - x

证明:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
n位全1 = 111...1 (n个1) = 2^n - 1

例如:
4位: 1111 = 2^4 - 1 = 15
8位: 11111111 = 2^8 - 1 = 255

当我们对x按位取反时:
- x的某位是1 → 取反后是0
- x的某位是0 → 取反后是1

这等价于:
(2^n - 1) - x

综上,反码实际上是基于模 (2^n - 1) 的运算系统。对于 4 位二进制:

这意味着在反码系统中,-3 实际上表示为:

1
2
-3 ≡ 15 - 3 = 12 (mod 15)
12 的二进制 = 1100

为什么需要循环进位 #

让我们看完整的计算过程:

5 - 3 的反码运算:

1
2
3
4
  0101 (5)
+ 1100 (-3的反码)
------
  10001

现在关键来了:

数学上的解释:

1
2
3
4
5
6
7
5 + 12 = 17 (十进制)
17 mod 15 = 2  ← 这是我们要的答案

在二进制中:
10001 = 16 + 1
16 mod 15 = 1
所以: 10001 mod 15 = 1 + 1 = 2

这个 “mod 15” 的操作在硬件上就是:把进位加回到最低位

1
2
3
4
  0001 (丢弃最高位)
+    1 (循环进位)
------
  0010 = 2 ✓

这就是使用反码麻烦的原因。

补码的设计 #

对于 n 位二进制数,负数 -x 的补码定义为:

1
补码(-x) = 2^n - x

这个定义直接决定了补码是模 2^n 系统!

为什么不能是其他的数呢?刚刚的反码是2n-1,证明过不行了。那么模能不能更大一些呢?比如如果模是 2n + 1:

1
2
3
4
5
6
定义: 补码(-x) = (2^n + 1) - x

验证: x + (-x) = x + (2^n + 1 - x)
                = 2^n + 1 = 10001

丢弃进位后:0001,还是不是0! ✗

所以只有 2^n 这个模才能让补码系统完美工作。

为什么内存中使用补码 #

说完了补码的设计原理,你可能会想:既然补码是为了简化硬件计算,那在内存里存储数据时,为什么也用补码呢?直接用原码存不是更直观吗?

统一性原则 #

想象一下,如果内存用原码存储,ALU(算术逻辑单元)用补码计算,会发生什么?

1
2
3
4
5
内存里的 -5: 1000 0101 (原码)
    需要转换
ALU 里的 -5: 1111 1011 (补码)

每次从内存读取数据到CPU进行计算,都要做一次转换;计算完再存回内存,又要转换回去。这不是白白增加了复杂度吗?

不如从头到尾都用同一套规则——这就是内存也使用补码的根本原因。

存储也需要比较 #

内存里的数据不只是存着不动,还要做很多操作:

  1. 比较大小:判断 a > b 吗?
  2. 查找:在数组里找最大值
  3. 排序:给一堆数排序

如果用原码,比较两个负数就很麻烦:

1
2
3
4
5
6
原码中:
-3 = 1000 0011
-5 = 1000 0101

直接按二进制比较: 1000 0101 > 1000 0011
得出结论: -5 > -3  ✗ 错了!

因为原码的符号位和数值部分是分开的,比较负数时要单独处理。

但在补码中:

1
2
3
4
5
6
补码中:
-3 = 1111 1101
-5 = 1111 1011

直接按二进制比较: 1111 1101 > 1111 1011
得出结论: -3 > -5  ✓ 对了!

补码的巧妙之处在于:所有数(包括正数和负数)都可以直接按二进制大小比较,不需要特殊逻辑。

节省硬件成本 #

如果内存用原码,CPU用补码,就需要:

  1. 数据总线上要有转换电路
  2. 缓存控制器要处理转换逻辑
  3. DMA(直接内存访问)也要知道怎么转换

这些都是额外的硬件成本和功耗。

而统一使用补码:

历史也是这么选择的 #

早期计算机确实尝试过不同的方案:

为什么最后都选择了补码?因为它在存储、传输、计算三个环节都是最优的选择。

C++中的自然溢出:补码的意外之喜 #

用了补码之后,还有一个意想不到的好处——溢出处理变得非常自然。

什么是溢出 #

先看一个简单的例子,8位有符号整数(int8_t)的取值范围是 -128 到 127:

1
2
int8_t a = 127;  // 最大值
a = a + 1;       // 发生了什么?

在数学上,127 + 1 = 128,但 128 已经超出了 int8_t 的表示范围。这就是溢出。

补码下的溢出行为 #

让我们看看在补码系统中实际发生了什么:

1
2
3
4
127 的补码:  0111 1111
  +1:       0000 0001
           ----------
结果:       1000 0000  = -128

127 + 1 = -128?这看起来很奇怪,但其实非常合理!

这正是模 2^n 运算的自然结果:

1
2
3
127 + 1 = 128
128 mod 256 = 128
但在补码中,128 被解释为 -128

为什么这是"自然"的? #

关键在于:硬件不需要做任何额外的事情。

看看如果用原码会怎样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
原码系统中 127 + 1:
0111 1111 (127)
+ 0000 0001 (1)
-----------
1000 0000

这是 -0 还是 -128?硬件需要:
1. 检测到溢出
2. 判断是上溢还是下溢
3. 设置溢出标志位
4. 可能触发异常

但在补码系统中:

1
2
3
4
5
硬件只管:
1. 做加法
2. 丢弃最高位的进位

就这么简单!结果自动"绕回"到正确的位置

实际例子:循环计数器 #

这个特性在实际编程中非常有用,比如实现一个循环计数器:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
uint8_t counter = 0;

// 不断递增
for (int i = 0; i < 300; i++) {
    counter++;
    // 当 counter 到达 255 后,自动变成 0
    // 不需要任何 if 判断!
}

// counter 最终值是 44
// 因为 300 mod 256 = 44

如果没有这种自然溢出,你需要这样写:

1
2
3
4
5
6
7
8
uint8_t counter = 0;

for (int i = 0; i < 300; i++) {
    counter++;
    if (counter > 255) {  // 需要额外判断
        counter = 0;
    }
}

有符号数的环形世界 #

补码让整数形成了一个环形结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
8位有符号整数的"环":

        -128
       /    \
    -127    127
     /        \
   -126      126
    |          |
    |   ...    |
    |          |
    -1    →    0    →    1

不断加 1:... → 126 → 127 → -128 → -127 → ...

不断减 1:... → -127 → -128 → 127 → 126 → ...

这个环形结构让很多算法的实现变得优雅:

例子:判断两个时间戳的先后关系

1
2
3
4
5
6
7
uint32_t timestamp1 = 0xFFFFFFFE;  // 接近最大值
uint32_t timestamp2 = 0x00000001;  // 溢出后的值

// 即使 timestamp2 的数值小于 timestamp1
// 通过有符号减法可以正确判断
int32_t diff = (int32_t)(timestamp2 - timestamp1);
// diff = 3,说明 timestamp2 在后面

C++ 标准的规定 #

需要注意的是,C++ 标准对溢出有不同的规定:

无符号整数(unsigned):

1
2
uint8_t a = 255;
a = a + 1;  // 确保结果是 0

有符号整数(signed):

1
2
3
int8_t a = 127;
a = a + 1;  // C++20: 结果是 -128 (但编译器可以优化)
            // C++17: 未定义行为(危险!)

虽然实际上所有现代编译器都用补码,但在 C++17 及之前写代码时,最好避免依赖有符号数溢出的行为。

硬件层面的简洁 #

最后总结一下,补码让溢出处理变得如此简单的原因:

硬件需要做的:

  1. 执行加法
  2. 丢弃超出位数的进位

硬件不需要做的:

这就是为什么补码不仅简化了减法,还让整个整数运算系统变得如此优雅。

总结一下:

内存使用补码不是因为"不得不这样",而是因为"这样最好":

从内存到寄存器,从磁盘到网络传输,整个计算机系统都在用补码——这是一套经过时间检验的、最优雅的设计。

标签:
Categories: