This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,170 @@
<audio id="audio" title="05 | 计算机指令:让我们试试用纸带编程" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/33/91/33098f112efdff4fa649d29450603091.mp3"></audio>
你在学写程序的时候,有没有想过,古老年代的计算机程序是怎么写出来的?
上大学的时候我们系里教C语言程序设计的老师说他们当年学写程序的时候不像现在这样都是用一种古老的物理设备叫作“打孔卡Punched Card”。用这种设备写程序可没法像今天这样掏出键盘就能打字而是要先在脑海里或者在纸上写出程序然后在纸带或者卡片上打洞。这样要写的程序、要处理的数据就变成一条条纸带或者一张张卡片之后再交给当时的计算机去处理。
<img src="https://static001.geekbang.org/resource/image/5d/d7/5d407c051e261902ad9a216c66de3fd7.jpg" alt="">
你看这个穿孔纸带是不是有点儿像我们现在考试用的答题卡那个时候人们在特定的位置上打洞或者不打洞来代表“0”或者“1”。
为什么早期的计算机程序要使用打孔卡而不能像我们现在一样用C或者Python这样的高级语言来写呢原因很简单因为计算机或者说CPU本身并没有能力理解这些高级语言。即使在2019年的今天我们使用的现代个人计算机仍然只能处理所谓的“机器码”也就是一连串的“0”和“1”这样的数字。
那么我们每天用高级语言的程序最终是怎么变成一串串“0”和“1”的这一串串“0”和“1”又是怎么在CPU中处理的今天我们就来仔细介绍一下“机器码”和“计算机指令”到底是怎么回事。
## 在软硬件接口中CPU帮我们做了什么事
我们常说CPU就是计算机的大脑。CPU的全称是Central Processing Unit中文是中央处理器。
我们上一节说了,从**硬件**的角度来看CPU就是一个超大规模集成电路通过电路实现了加法、乘法乃至各种各样的处理逻辑。
如果我们从**软件**工程师的角度来讲CPU就是一个执行各种**计算机指令**Instruction Code的逻辑机器。这里的计算机指令就好比一门CPU能够听得懂的语言我们也可以把它叫作**机器语言**Machine Language
不同的CPU能够听懂的语言不太一样。比如我们的个人电脑用的是Intel的CPU苹果手机用的是ARM的CPU。这两者能听懂的语言就不太一样。类似这样两种CPU各自支持的语言就是两组不同的**计算机指令集**英文叫Instruction Set。这里面的“Set”其实就是数学上的集合代表不同的单词、语法。
所以如果我们在自己电脑上写一个程序然后把这个程序复制一下装到自己的手机上肯定是没办法正常运行的因为这两者语言不通。而一台电脑上的程序简单复制一下到另外一台电脑上通常就能正常运行因为这两台CPU有着相同的指令集也就是说它们的语言相通的。
一个计算机程序不可能只有一条指令而是由成千上万条指令组成的。但是CPU里不能一直放着所有指令所以计算机程序平时是存储在存储器中的。这种程序指令存储在存储器里面的计算机我们就叫作**存储程序型计算机**Stored-program Computer
说到这里你可能要问了难道还有不是存储程序型的计算机么其实在没有现代计算机之前有着聪明才智的工程师们早就发明了一种叫Plugboard Computer的计算设备。我把它直译成“插线板计算机”。在一个布满了各种插口和插座的板子上工程师们用不同的电线来连接不同的插口和插座从而来完成各种计算任务。下面这个图就是一台IBM的Plugboard看起来是不是有一股满满的蒸汽朋克范儿
<img src="https://static001.geekbang.org/resource/image/99/51/99eb1ab1cdbdfa2d35fce456940ca651.jpg" alt="">
## 从编译到汇编,代码怎么变成机器码?
了解了计算机指令和计算机指令集接下来我们来看看平时编写的代码到底是怎么变成一条条计算机指令最后被CPU执行的呢我们拿一小段真实的C语言程序来看看。
```
// test.c
int main()
{
int a = 1;
int b = 2;
a = a + b;
}
```
这是一段再简单不过的C语言程序即便你不了解C语言应该也可以看懂。我们给两个变量 a、b分别赋值1、2然后再将a、b两个变量中的值加在一起重新赋值给了a这个变量。
要让这段程序在一个Linux操作系统上跑起来我们需要把整个程序翻译成一个**汇编语言**ASMAssembly Language的程序这个过程我们一般叫编译Compile成汇编代码。
针对汇编代码我们可以再用汇编器Assembler翻译成机器码Machine Code。这些机器码由“0”和“1”组成的机器语言表示。这一条条机器码就是一条条的**计算机指令**。这样一串串的16进制数字就是我们CPU能够真正认识的计算机指令。
在一个Linux操作系统上我们可以简单地使用gcc和objdump这样两条命令把对应的汇编代码和机器码都打印出来。
```
$ gcc -g -c test.c
$ objdump -d -M intel -S test.o
```
可以看到左侧有一堆数字这些就是一条条机器码右边有一系列的push、mov、add、pop等这些就是对应的汇编代码。一行C语言代码有时候只对应一条机器码和汇编代码有时候则是对应两条机器码和汇编代码。汇编代码和机器码之间是一一对应的。
```
test.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 &lt;main&gt;:
int main()
{
0: 55 push rbp
1: 48 89 e5 mov rbp,rsp
int a = 1;
4: c7 45 fc 01 00 00 00 mov DWORD PTR [rbp-0x4],0x1
int b = 2;
b: c7 45 f8 02 00 00 00 mov DWORD PTR [rbp-0x8],0x2
a = a + b;
12: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8]
15: 01 45 fc add DWORD PTR [rbp-0x4],eax
}
18: 5d pop rbp
19: c3 ret
```
这个时候你可能又要问了我们实际在用GCCGUC编译器套装GNU Compiler Collectipon编译器的时候可以直接把代码编译成机器码呀为什么还需要汇编代码呢原因很简单你看着那一串数字表示的机器码是不是摸不着头脑但是即使你没有学过汇编代码看的时候多少也能“猜”出一些这些代码的含义。
因为汇编代码其实就是“给程序员看的机器码”也正因为这样机器码和汇编代码是一一对应的。我们人类很容易记住add、mov这些用英文表示的指令而8b 45 f8这样的指令由于很难一下子看明白是在干什么所以会非常难以记忆。尽管早年互联网上到处流传大神程序员着拿小刀在光盘上刻出操作系统的梗但是要让你用打孔卡来写个程序估计浪费的卡片比用上的卡片要多得多。
<img src="https://static001.geekbang.org/resource/image/67/5b/67cf3c90ac9bde229352e1be0db24b5b.png" alt="">
从高级语言到汇编代码再到机器码就是一个日常开发程序最终变成了CPU可以执行的计算机指令的过程。
## 解析指令和机器码
了解了这个过程,下面我们放大局部,来看看这一行行的汇编代码和机器指令,到底是什么意思。
我们就从平时用的电脑、手机这些设备来说起。这些设备的CPU到底有哪些指令呢这个还真有不少我们日常用的Intel CPU有2000条左右的CPU指令实在是太多了所以我没法一一来给你讲解。不过一般来说常见的指令可以分成五大类。
第一类是**算术类指令**。我们的加减乘除在CPU层面都会变成一条条算术类指令。
第二类是**数据传输类指令**。给变量赋值、在内存里读写数据,用的都是数据传输类指令。
第三类是**逻辑类指令**。逻辑上的与或非,都是这一类指令。
第四类是**条件分支类指令**。日常我们写的“if/else”其实都是条件分支类指令。
最后一类是**无条件跳转指令**。写一些大一点的程序,我们常常需要写一些函数或者方法。在调用函数的时候,其实就是发起了一个无条件跳转指令。
你可能一下子记不住,或者对这些指令的含义还不能一下子掌握,这里我画了一个表格,给你举例子说明一下,帮你理解、记忆。
<img src="https://static001.geekbang.org/resource/image/eb/97/ebfd3bfe5dba764cdcf871e23b29f197.jpeg" alt="">
下面我们来看看,汇编器是怎么把对应的汇编代码,翻译成为机器码的。
我们说过不同的CPU有不同的指令集也就对应着不同的汇编语言和不同的机器码。为了方便你快速理解这个机器码的计算方式我们选用最简单的MIPS指令集来看看机器码是如何生成的。
MIPS是一组由MIPS技术公司在80年代中期设计出来的CPU指令集。就在最近MIPS公司把整个指令集和芯片架构都完全开源了。想要深入研究CPU和指令集的同学我这里推荐[一些资料](https://www.mips.com/mipsopen/),你可以自己了解下。
<img src="https://static001.geekbang.org/resource/image/b1/bf/b1ade5f8de67b172bf7b4ec9f63589bf.jpeg" alt="">
MIPS的指令是一个32位的整数高6位叫**操作码**Opcode也就是代表这条指令具体是一条什么样的指令剩下的26位有三种格式分别是R、I和J。
**R指令**是一般用来做算术和逻辑操作,里面有读取和写入数据的寄存器的地址。如果是逻辑位移操作,后面还有位移操作的位移量,而最后的功能码,则是在前面的操作码不够的时候,扩展操作码表示对应的具体指令的。
**I指令**,则通常是用在数据传输、条件分支,以及在运算的时候使用的并非变量还是常数的时候。这个时候,没有了位移量和操作码,也没有了第三个寄存器,而是把这三部分直接合并成了一个地址值或者一个常数。
**J指令**就是一个跳转指令高6位之外的26位都是一个跳转后的地址。
```
add $t0,$s2,$s1
```
我以一个简单的加法算术指令add $t0, $s1, $s2,为例,给你解释。为了方便,我们下面都用十进制来表示对应的代码。
对应的MIPS指令里opcode是0rs代表第一个寄存器s1的地址是17rt代表第二个寄存器s2的地址是18rd代表目标的临时寄存器t0的地址是8。因为不是位移操作所以位移量是0。把这些数字拼在一起就变成了一个MIPS的加法指令。
为了读起来方便我们一般把对应的二进制数用16进制表示出来。在这里也就是0X02324020。这个数字也就是这条指令对应的机器码。
<img src="https://static001.geekbang.org/resource/image/8f/1d/8fced6ff11d3405cdf941f6742b5081d.jpeg" alt="">
回到开头我们说的打孔带。如果我们用打孔代表1没有打孔代表0用4行8列代表一条指令来打一个穿孔纸带那么这条命令大概就长这样
<img src="https://static001.geekbang.org/resource/image/31/9c/31b430f9e4135f24a998b577cae8249c.png" alt="">
好了,恭喜你,读到这里,你应该学会了怎么作为人肉编译和汇编器,给纸带打孔编程了,不用再对那些用过打孔卡的前辈们顶礼膜拜了。
## 总结延伸
到这里,想必你也应该明白了,我们在这一讲的开头介绍的打孔卡,其实就是一种存储程序型计算机。
只是这整个程序的机器码不是通过计算机编译出来的而是由程序员用人脑“编译”成一张张卡片的。对应的程序也不是存储在设备里而是存储成一张打好孔的卡片。但是整个程序运行的逻辑和其他CPU的机器语言没有什么分别也是处理一串“0”和“1”组成的机器码而已。
这一讲里我们看到了一个C语言程序是怎么被编译成为汇编语言乃至通过汇编器再翻译成机器码的。
除了C这样的编译型的语言之外不管是Python这样的解释型语言还是Java这样使用虚拟机的语言其实最终都是由不同形式的程序把我们写好的代码转换成CPU能够理解的机器码来执行的。
只是解释型语言是通过解释器在程序运行的时候逐句翻译而Java这样使用虚拟机的语言则是由虚拟机对编译出来的中间代码进行解释或者即时编译成为机器码来最终执行。
然而单单理解一条指令是怎么变成机器码的肯定是不够的。接下来的几节我会深入讲解包含条件、循环、函数、递归这些语句的完整程序是怎么在CPU里面执行的。
## 推荐阅读
这一讲里我们用的是相对最简单的MIPS指令集作示例。想要对我们日常使用的Intel CPU的指令集有所了解可以参看《计算机组成与设计软/硬件接口》第5版的2.17小节。
## 课后思考
我们把一个数字在命令行里面打印出来背后对应的机器码是什么你可以试试通过GCC把这个的汇编代码和机器码打出来。
欢迎你在留言区写下你的思考和疑问,你也可以把今天的文章分享给你朋友,和他一起学习和进步。

View File

@@ -0,0 +1,170 @@
<audio id="audio" title="06 | 指令跳转原来if...else就是goto" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d3/42/d3ef84a47025fcbc8b9b9da742826142.mp3"></audio>
上一讲我们讲解了一行代码是怎么变成计算机指令的。你平时写的程序中肯定不只有int a = 1这样最最简单的代码或者指令。我们总是要用到if…else这样的条件判断语句、while和for这样的循环语句还有函数或者过程调用。
对应的CPU执行的也不只是一条指令一般一个程序包含很多条指令。因为有if…else、for这样的条件和循环存在这些指令也不会一路平铺直叙地执行下去。
今天我们就在上一节的基础上来看看,一个计算机程序是怎么被分解成一条条指令来执行的。
## CPU是如何执行指令的
拿我们用的Intel CPU来说里面差不多有几百亿个晶体管。实际上一条条计算机指令执行起来非常复杂。好在CPU在软件层面已经为我们做好了封装。对于我们这些做软件的程序员来说我们只要知道写好的代码变成了指令之后是一条一条**顺序**执行的就可以了。
我们先不管几百亿的晶体管的背后是怎么通过电路运转起来的逻辑上我们可以认为CPU其实就是由一堆寄存器组成的。而寄存器就是CPU内部由多个触发器Flip-Flop或者锁存器Latches组成的简单电路。
触发器和锁存器,其实就是两种不同原理的数字电路组成的逻辑门。这块内容并不是我们这节课的重点,所以你只要了解就好。如果想要深入学习的话,你可以学习数字电路的相关课程,这里我们不深入探讨。
好了现在我们接着前面说。N个触发器或者锁存器就可以组成一个N位Bit的寄存器能够保存N位的数据。比方说我们用的64位Intel服务器寄存器就是64位的。
<img src="https://static001.geekbang.org/resource/image/cd/6f/cdba5c17a04f0dd5ef05b70368b9a96f.jpg" alt="">
一个CPU里面会有很多种不同功能的寄存器。我这里给你介绍三种比较特殊的。
一个是**PC寄存器**Program Counter Register我们也叫**指令地址寄存器**Instruction Address Register。顾名思义它就是用来存放下一条需要执行的计算机指令的内存地址。
第二个是**指令寄存器**Instruction Register用来存放当前正在执行的指令。
第三个是**条件码寄存器**Status Register用里面的一个一个标记位Flag存放CPU进行算术或者逻辑计算的结果。
除了这些特殊的寄存器CPU里面还有更多用来存储数据和内存地址的寄存器。这样的寄存器通常一类里面不止一个。我们通常根据存放的数据内容来给它们取名字比如整数寄存器、浮点数寄存器、向量寄存器和地址寄存器等等。有些寄存器既可以存放数据又能存放地址我们就叫它通用寄存器。
<img src="https://static001.geekbang.org/resource/image/ad/8a/ad91b005e97959d571bbd2a0fa30b48a.jpeg" alt="">
实际上一个程序执行的时候CPU会根据PC寄存器里的地址从内存里面把需要执行的指令读取到指令寄存器里面执行然后根据指令长度自增开始顺序读取下一条指令。可以看到一个程序的一条条指令在内存里面是连续保存的也会一条条顺序加载。
而有些特殊指令比如上一讲我们讲到J类指令也就是跳转指令会修改PC寄存器里面的地址值。这样下一条要执行的指令就不是从内存里面顺序加载的了。事实上这些跳转指令的存在也是我们可以在写程序的时候使用if…else条件语句和while/for循环语句的原因。
## 从if…else来看程序的执行和跳转
我们现在就来看一个包含if…else的简单程序。
```
// test.c
#include &lt;time.h&gt;
#include &lt;stdlib.h&gt;
int main()
{
srand(time(NULL));
int r = rand() % 2;
int a = 10;
if (r == 0)
{
a = 1;
} else {
a = 2;
}
```
我们用rand生成了一个随机数rr要么是0要么是1。当r是0的时候我们把之前定义的变量a设成1不然就设成2。
```
$ gcc -g -c test.c
$ objdump -d -M intel -S test.o
```
我们把这个程序编译成汇编代码。你可以忽略前后无关的代码只关注于这里的if…else条件判断语句。对应的汇编代码是这样的
```
if (r == 0)
3b: 83 7d fc 00 cmp DWORD PTR [rbp-0x4],0x0
3f: 75 09 jne 4a &lt;main+0x4a&gt;
{
a = 1;
41: c7 45 f8 01 00 00 00 mov DWORD PTR [rbp-0x8],0x1
48: eb 07 jmp 51 &lt;main+0x51&gt;
}
else
{
a = 2;
4a: c7 45 f8 02 00 00 00 mov DWORD PTR [rbp-0x8],0x2
51: b8 00 00 00 00 mov eax,0x0
}
```
可以看到这里对于r == 0的条件判断被编译成了cmp和jne这两条指令。
cmp指令比较了前后两个操作数的值这里的DWORD PTR代表操作的数据类型是32位的整数而[rbp-0x4]则是一个寄存器的地址。所以第一个操作数就是从寄存器里拿到的变量r的值。第二个操作数0x0就是我们设定的常量0的16进制表示。cmp指令的比较结果会存入到**条件码寄存器**当中去。
在这里,如果比较的结果是 True也就是 r == 0就把**零标志条件码**对应的条件码是ZFZero Flag设置为1。除了零标志之外Intel的CPU下还有**进位标志**CFCarry Flag、**符号标志**SFSign Flag以及**溢出标志**OFOverflow Flag用在不同的判断条件下。
cmp指令执行完成之后PC寄存器会自动自增开始执行下一条jne的指令。
跟着的jne指令是jump if not equal的意思它会查看对应的零标志位。如果为0会跳转到后面跟着的操作数4a的位置。这个4a对应这里汇编代码的行号也就是上面设置的else条件里的第一条指令。当跳转发生的时候PC寄存器就不再是自增变成下一条指令的地址而是被直接设置成这里的4a这个地址。这个时候CPU再把4a地址里的指令加载到指令寄存器中来执行。
跳转到执行地址为4a的指令实际是一条mov指令第一个操作数和前面的cmp指令一样是另一个32位整型的寄存器地址以及对应的2的16进制值0x2。mov指令把2设置到对应的寄存器里去相当于一个赋值操作。然后PC寄存器里的值继续自增执行下一条mov指令。
这条mov指令的第一个操作数eax代表累加寄存器第二个操作数0x0则是16进制的0的表示。这条指令其实没有实际的作用它的作用是一个占位符。我们回过头去看前面的if条件如果满足的话在赋值的mov指令执行完成之后有一个jmp的无条件跳转指令。跳转的地址就是这一行的地址51。我们的main函数没有设定返回值而mov eax, 0x0 其实就是给main函数生成了一个默认的为0的返回值到累加器里面。if条件里面的内容执行完成之后也会跳转到这里和else里的内容结束之后的位置是一样的。
<img src="https://static001.geekbang.org/resource/image/b4/fa/b439cebb2d85496ad6eef2f61071aefa.jpeg" alt="">
上一讲我们讲打孔卡的时候说到读取打孔卡的机器会顺序地一段一段地读取指令然后执行。执行完一条指令它会自动地顺序读取下一条指令。如果执行的当前指令带有跳转的地址比如往后跳10个指令那么机器会自动将卡片带往后移动10个指令的位置再来执行指令。同样的机器也能向前移动去读取之前已经执行过的指令。这也就是我们的while/for循环实现的原理。
## 如何通过if…else和goto来实现循环
```
int main()
{
int a = 0;
for (int i = 0; i &lt; 3; i++)
{
a += i;
}
}
```
我们再看一段简单的利用for循环的程序。我们循环自增变量i三次三次之后i&gt;=3就会跳出循环。整个程序对应的Intel汇编代码就是这样的
```
for (int i = 0; i &lt;= 2; i++)
b: c7 45 f8 00 00 00 00 mov DWORD PTR [rbp-0x4],0x0
12: eb 0a jmp 1e
{
a += i;
14: 8b 45 f8 mov eax,DWORD PTR [rbp-0x4]
17: 01 45 fc add DWORD PTR [rbp-0x8],eax
1a: 83 45 f8 01 add DWORD PTR [rbp-0x4],0x1
1e: 83 7d f8 02 cmp DWORD PTR [rbp-0x4],0x2
22: 7e f0 jle 14
24: b8 00 00 00 00 mov eax,0x0
}
```
可以看到对应的循环也是用1e这个地址上的cmp比较指令和紧接着的jle条件跳转指令来实现的。主要的差别在于这里的jle跳转的地址在这条指令之前的地址14而非if…else编译出来的跳转指令之后。往前跳转使得条件满足的时候PC寄存器会把指令地址设置到之前执行过的指令位置重新执行之前执行过的指令直到条件不满足顺序往下执行jle之后的指令整个循环才结束。
<img src="https://static001.geekbang.org/resource/image/fb/17/fb50fe39181abb0f70fcfec53cf12317.jpg" alt="">
如果你看一长条打孔卡的话,就会看到卡片往后移动一段,执行了之后,又反向移动,去重新执行前面的指令。
其实你有没有觉得jle和jmp指令有点像程序语言里面的goto命令直接指定了一个特定条件下的跳转位置。虽然我们在用高级语言开发程序的时候反对使用goto但是实际在机器指令层面无论是if…else…也好还是for/while也好都是用和goto相同的跳转到特定指令位置的方式来实现的。
## 总结延伸
这一节我们在单条指令的基础上学习了程序里的多条指令究竟是怎么样一条一条被执行的。除了简单地通过PC寄存器自增的方式顺序执行外条件码寄存器会记录下当前执行指令的条件判断状态然后通过跳转指令读取对应的条件码修改PC寄存器内的下一条指令的地址最终实现if…else以及for/while这样的程序控制流程。
你会发现,虽然我们可以用高级语言,可以用不同的语法,比如 if…else 这样的条件分支,或者 while/for 这样的循环方式来实现不用的程序运行流程但是回归到计算机可以识别的机器指令级别其实都只是一个简单的地址跳转而已也就是一个类似于goto的语句。
想要在硬件层面实现这个goto语句除了本身需要用来保存下一条指令地址以及当前正要执行指令的PC寄存器、指令寄存器外我们只需要再增加一个条件码寄存器来保留条件判断的状态。这样简简单单的三个寄存器就可以实现条件判断和循环重复执行代码的功能。
下一节我们会进一步讲解如果程序中出现函数或者过程这样可以复用的代码模块对应的指令是怎么样执行的会和我们这里的if…else有什么不同。
## 推荐阅读
《深入理解计算机系统》的第3章详细讲解了C语言和Intel CPU的汇编语言以及指令的对应关系以及Intel CPU的各种寄存器和指令集。
Intel指令集相对于之前的MIPS指令集要复杂一些一方面所有的指令是变长的从1个字节到15个字节不等另一方面即使是汇编代码还有很多针对操作数据的长度不同有不同的后缀。我在这里没有详细解释各个指令的含义如果你对用C/C++做Linux系统层面开发感兴趣建议你一定好好读一读这一章节。
## 课后思考
除了if…else的条件语句和for/while的循环之外大部分编程语言还有switch…case这样的条件跳转语句。switch…case编译出来的汇编代码也是这样使用jne指令进行跳转吗对应的汇编代码的性能和写很多if…else有什么区别呢你可以试着写一个简单的C语言程序编译成汇编代码看一看。
欢迎留言和我分享你的思考和疑惑,你也可以把今天的内容分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,215 @@
<audio id="audio" title="07 | 函数调用为什么会发生stack overflow" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8a/7d/8abffbbdfdc9930fb3578bdfb24d1f7d.mp3"></audio>
在开发软件的过程中我们经常会遇到错误如果你用Google搜过出错信息那你多少应该都访问过[Stack Overflow](https://stackoverflow.com/)这个网站。作为全球最大的程序员问答网站Stack Overflow的名字来自于一个常见的报错就是栈溢出stack overflow
今天,我们就从程序的函数调用开始,讲讲函数间的相互调用,在计算机指令层面是怎么实现的,以及什么情况下会发生栈溢出这个错误。
## 为什么我们需要程序栈?
和前面几讲一样我们还是从一个非常简单的C程序function_example.c看起。
```
// function_example.c
#include &lt;stdio.h&gt;
int static add(int a, int b)
{
return a+b;
}
int main()
{
int x = 5;
int y = 10;
int u = add(x, y);
}
```
这个程序定义了一个简单的函数add接受两个参数a和b返回值就是a+b。而main函数里则定义了两个变量x和y然后通过调用这个add函数来计算u=x+y最后把u的数值打印出来。
```
$ gcc -g -c function_example.c
$ objdump -d -M intel -S function_example.o
```
我们把这个程序编译之后objdump出来。我们来看一看对应的汇编代码。
```
int static add(int a, int b)
{
0: 55 push rbp
1: 48 89 e5 mov rbp,rsp
4: 89 7d fc mov DWORD PTR [rbp-0x4],edi
7: 89 75 f8 mov DWORD PTR [rbp-0x8],esi
return a+b;
a: 8b 55 fc mov edx,DWORD PTR [rbp-0x4]
d: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8]
10: 01 d0 add eax,edx
}
12: 5d pop rbp
13: c3 ret
0000000000000014 &lt;main&gt;:
int main()
{
14: 55 push rbp
15: 48 89 e5 mov rbp,rsp
18: 48 83 ec 10 sub rsp,0x10
int x = 5;
1c: c7 45 fc 05 00 00 00 mov DWORD PTR [rbp-0x4],0x5
int y = 10;
23: c7 45 f8 0a 00 00 00 mov DWORD PTR [rbp-0x8],0xa
int u = add(x, y);
2a: 8b 55 f8 mov edx,DWORD PTR [rbp-0x8]
2d: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
30: 89 d6 mov esi,edx
32: 89 c7 mov edi,eax
34: e8 c7 ff ff ff call 0 &lt;add&gt;
39: 89 45 f4 mov DWORD PTR [rbp-0xc],eax
3c: b8 00 00 00 00 mov eax,0x0
}
41: c9 leave
42: c3 ret
```
可以看出来在这段代码里main函数和上一节我们讲的的程序执行区别并不大它主要是把jump指令换成了函数调用的call指令。call指令后面跟着的仍然是跳转后的程序地址。
这些你理解起来应该不成问题。我们下面来看一个有意思的部分。
我们来看add函数。可以看到add函数编译之后代码先执行了一条push指令和一条mov指令在函数执行结束的时候又执行了一条pop和一条ret指令。这四条指令的执行其实就是在进行我们接下来要讲**压栈**Push和**出栈**Pop操作。
你有没有发现函数调用和上一节我们讲的if…else和for/while循环有点像。它们两个都是在原来顺序执行的指令过程里执行了一个内存地址的跳转指令让指令从原来顺序执行的过程里跳开从新的跳转后的位置开始执行。
但是这两个跳转有个区别if…else和for/while的跳转是跳转走了就不再回来了就在跳转后的新地址开始顺序地执行指令就好像徐志摩在《再别康桥》里面写的“我挥一挥衣袖不带走一片云彩”继续进行新的生活了。而函数调用的跳转在对应函数的指令执行完了之后还要再回到函数调用的地方继续执行call之后的指令就好像贺知章在《回乡偶书》里面写的那样“少小离家老大回乡音未改鬓毛衰”不管走多远最终还是要回来。
那我们有没有一个可以不跳转回到原来开始的地方来实现函数的调用呢直觉上似乎有这么一个解决办法。你可以把调用的函数指令直接插入在调用函数的地方替换掉对应的call指令然后在编译器编译代码的时候直接就把函数调用变成对应的指令替换掉。
不过仔细琢磨一下你会发现这个方法有些问题。如果函数A调用了函数B然后函数B再调用函数A我们就得面临在A里面插入B的指令然后在B里面插入A的指令这样就会产生无穷无尽地替换。就好像两面镜子面对面放在一块儿任何一面镜子里面都会看到无穷多面镜子。
<img src="https://static001.geekbang.org/resource/image/0b/06/0b4d9f07a7d15e5e25908bbf1532e706.jpg" alt="">
看来把被调用函数的指令直接插入在调用处的方法行不通。那我们就换一个思路能不能把后面要跳回来执行的指令地址给记录下来呢就像前面讲PC寄存器一样我们可以专门设立一个“程序调用寄存器”来存储接下来要跳转回来执行的指令地址。等到函数调用结束从这个寄存器里取出地址再跳转到这个记录的地址继续执行就好了。
但是在多层函数调用里简单只记录一个地址也是不够的。我们在调用函数A之后A还可以调用函数BB还能调用函数C。这一层又一层的调用并没有数量上的限制。在所有函数调用返回之前每一次调用的返回地址都要记录下来但是我们CPU里的寄存器数量并不多。像我们一般使用的Intel i7 CPU只有16个64位寄存器调用的层数一多就存不下了。
最终,计算机科学家们想到了一个比单独记录跳转回来的地址更完善的办法。我们在内存里面开辟一段空间,用栈这个**后进先出**LIFOLast In First Out的数据结构。栈就像一个乒乓球桶每次程序调用函数之前我们都把调用返回后的地址写在一个乒乓球上然后塞进这个球桶。这个操作其实就是我们常说的**压栈**。如果函数执行完了,我们就从球桶里取出最上面的那个乒乓球,很显然,这就是**出栈**。
拿到出栈的乒乓球找到上面的地址把程序跳转过去就返回到了函数调用后的下一条指令了。如果函数A在执行完成之前又调用了函数B那么在取出乒乓球之前我们需要往球桶里塞一个乒乓球。而我们从球桶最上面拿乒乓球的时候拿的也一定是最近一次的也就是最下面一层的函数调用完成后的地址。乒乓球桶的底部就是**栈底**,最上面的乒乓球所在的位置,就是**栈顶**。
<img src="https://static001.geekbang.org/resource/image/d0/be/d0c75219d3a528c920c2a593daaf77be.jpeg" alt="">
在真实的程序里压栈的不只有函数调用完成后的返回地址。比如函数A在调用B的时候需要传输一些参数数据这些参数数据在寄存器不够用的时候也会被压入栈中。整个函数A所占用的所有内存空间就是函数A的**栈帧**Stack Frame。Frame在中文里也有“相框”的意思所以每次到这里我都有种感觉整个函数A所需要的内存空间就像是被这么一个“相框”给框了起来放在了栈里面。
而实际的程序栈布局,顶和底与我们的乒乓球桶相比是倒过来的。底在最上面,顶在最下面,这样的布局是因为栈底的内存地址是在一开始就固定的。而一层层压栈之后,栈顶的内存地址是在逐渐变小而不是变大。
<img src="https://static001.geekbang.org/resource/image/23/d1/2361ecf8cf08f07c83377376a31869d1.jpeg" alt="">
对应上面函数add的汇编代码我们来仔细看看main函数调用add函数时add函数入口在01行add函数结束之后在1213行。
我们在调用第34行的call指令时会把当前的PC寄存器里的下一条指令的地址压栈保留函数调用结束后要执行的指令地址。而add函数的第0行push rbp这个指令就是在进行压栈。这里的rbp又叫栈帧指针Frame Pointer是一个存放了当前栈帧位置的寄存器。push rbp就把之前调用函数也就是main函数的栈帧的栈底地址压到栈顶。
接着第1行的一条命令mov rbp, rsp里则是把rsp这个栈指针Stack Pointer的值复制到rbp里而rsp始终会指向栈顶。这个命令意味着rbp这个栈帧指针指向的地址变成当前最新的栈顶也就是add函数的栈帧的栈底地址了。
而在函数add执行完成之后又会分别调用第12行的pop rbp来将当前的栈顶出栈这部分操作维护好了我们整个栈帧。然后我们可以调用第13行的ret指令这时候同时要把call调用的时候压入的PC寄存器里的下一条指令出栈更新到PC寄存器中将程序的控制权返回到出栈后的栈顶。
## 如何构造一个stack overflow
通过引入栈我们可以看到无论有多少层的函数调用或者在函数A里调用函数B再在函数B里调用A这样的递归调用我们都只需要通过维持rbp和rsp这两个维护栈顶所在地址的寄存器就能管理好不同函数之间的跳转。不过栈的大小也是有限的。如果函数调用层数太多我们往栈里压入它存不下的内容程序在执行的过程中就会遇到栈溢出的错误这就是大名鼎鼎的“stack overflow”。
要构造一个栈溢出的错误并不困难最简单的办法就是我们上面说的Infiinite Mirror Effect的方式让函数A调用自己并且不设任何终止条件。这样一个无限递归的程序在不断地压栈过程中将整个栈空间填满并最终遇上stack overflow。
```
int a()
{
return a();
}
int main()
{
a();
return 0;
}
```
除了无限递归递归层数过深在栈空间里面创建非常占内存的变量比如一个巨大的数组这些情况都很可能给你带来stack overflow。相信你理解了栈在程序运行的过程里面是怎么回事未来在遇到stackoverflow这个错误的时候不会完全没有方向了。
## 如何利用函数内联进行性能优化?
上面我们提到一个方法,把一个实际调用的函数产生的指令,直接插入到的位置,来替换对应的函数调用指令。尽管这个通用的函数调用方案,被我们否决了,但是如果被调用的函数里,没有调用其他函数,这个方法还是可以行得通的。
事实上,这就是一个常见的编译器进行自动优化的场景,我们通常叫**函数内联**Inline。我们只要在GCC编译的时候加上对应的一个让编译器自动优化的参数-O编译器就会在可行的情况下进行这样的指令替换。
我们来看一段代码。
```
#include &lt;stdio.h&gt;
#include &lt;time.h&gt;
#include &lt;stdlib.h&gt;
int static add(int a, int b)
{
return a+b;
}
int main()
{
srand(time(NULL));
int x = rand() % 5
int y = rand() % 10;
int u = add(x, y)
printf(&quot;u = %d\n&quot;, u)
}
```
为了避免编译器优化掉太多代码我小小修改了一下function_example.c让参数x和y都变成了通过随机数生成并在代码的最后加上将u通过printf打印出来的语句。
```
$ gcc -g -c -O function_example_inline.c
$ objdump -d -M intel -S function_example_inline.o
```
上面的function_example_inline.c的编译出来的汇编代码没有把add函数单独编译成一段指令顺序而是在调用u = add(x, y)的时候直接替换成了一个add指令。
```
return a+b;
4c: 01 de add esi,ebx
```
除了依靠编译器的自动优化你还可以在定义函数的地方加上inline的关键字来提示编译器对函数进行内联。
内联带来的优化是CPU需要执行的指令数变少了根据地址跳转的过程不需要了压栈和出栈的过程也不用了。
不过内联并不是没有代价,内联意味着,我们把可以复用的程序指令在调用它的地方完全展开了。如果一个函数在很多地方都被调用了,那么就会展开很多次,整个程序占用的空间就会变大了。
<img src="https://static001.geekbang.org/resource/image/dc/85/dca83475560147d4dd492ff283ae0c85.jpeg" alt="">
这样没有调用其他函数,只会被调用的函数,我们一般称之为**叶子函数(或叶子过程)**。
## 总结延伸
这一节我们讲了一个程序的函数间调用在CPU指令层面是怎么执行的。其中一定需要你牢记的就是**程序栈**这个新概念。
我们可以方便地通过压栈和出栈操作使得程序在不同的函数调用过程中进行转移。而函数内联和栈溢出一个是我们常常可以选择的优化方案另一个则是我们会常遇到的程序Bug。
通过加入了程序栈,我们相当于在指令跳转的过程种,加入了一个“记忆”的功能,能在跳转去运行新的指令之后,再回到跳出去的位置,能够实现更加丰富和灵活的指令执行流程。这个也为我们在程序开发的过程中,提供了“函数”这样一个抽象,使得我们在软件开发的过程中,可以复用代码和指令,而不是只能简单粗暴地复制、粘贴代码和指令。
## 推荐阅读
如果你觉得还不过瘾可以仔细读一下《深入理解计算机系统第三版》的3.7小节《过程》,进一步了解函数调用是怎么回事。
另外我推荐你花一点时间通过搜索引擎搞清楚function_example.c每一行汇编代码的含义这个能够帮你进一步深入了解程序栈、栈帧、寄存器以及Intel CPU的指令集。
## 课后思考
在程序栈里面,除了我们跳转前的指令地址外,还需要保留哪些信息,才能在我们在函数调用完成之后,跳转回到指令地址的时候,继续执行完函数调用之后的指令呢?
你可以想一想,查一查,然后在留言区留下你的思考和答案,也欢迎你把今天的内容分享给你的朋友,和他一起思考和进步。

View File

@@ -0,0 +1,218 @@
<audio id="audio" title="08 | ELF和静态链接为什么程序无法同时在Linux和Windows下运行" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/94/3c/943d00c868be98c76610f94799f8673c.mp3"></audio>
过去的三节你和我一起通过一些简单的代码看到了我们写的程序是怎么变成一条条计算机指令的if…else这样的条件跳转是怎么样执行的for/while这样的循环是怎么执行的函数间的相互调用是怎么发生的。
我记得以前我自己在了解完这些知识之后产生了一个非常大的疑问。那就是既然我们的程序最终都被变成了一条条机器码去执行那为什么同一个程序在同一台计算机上在Linux下可以运行而在Windows下却不行呢反过来Windows上的程序在Linux上也是一样不能执行的。可是我们的CPU并没有换掉它应该可以识别同样的指令呀
如果你和我有同样的疑问,那这一节,我们就一起来解开。
## 编译、链接和装载:拆解程序执行
[第5节](https://time.geekbang.org/column/article/93359)我们说过写好的C语言代码可以通过编译器编译成汇编代码然后汇编代码再通过汇编器变成CPU可以理解的机器码于是CPU就可以执行这些机器码了。你现在对这个过程应该不陌生了但是这个描述把过程大大简化了。下面我们一起具体来看C语言程序是如何变成一个可执行程序的。
不知道你注意到没有过去几节我们通过gcc生成的文件和objdump获取到的汇编指令都有些小小的问题。我们先把前面的add函数示例拆分成两个文件add_lib.c和link_example.c。
```
// add_lib.c
int add(int a, int b)
{
return a+b;
}
```
```
// link_example.c
#include &lt;stdio.h&gt;
int main()
{
int a = 10;
int b = 5;
int c = add(a, b);
printf(&quot;c = %d\n&quot;, c);
}
```
我们通过gcc来编译这两个文件然后通过objdump命令看看它们的汇编代码。
```
$ gcc -g -c add_lib.c link_example.c
$ objdump -d -M intel -S add_lib.o
$ objdump -d -M intel -S link_example.o
```
```
add_lib.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 &lt;add&gt;:
0: 55 push rbp
1: 48 89 e5 mov rbp,rsp
4: 89 7d fc mov DWORD PTR [rbp-0x4],edi
7: 89 75 f8 mov DWORD PTR [rbp-0x8],esi
a: 8b 55 fc mov edx,DWORD PTR [rbp-0x4]
d: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8]
10: 01 d0 add eax,edx
12: 5d pop rbp
13: c3 ret
```
```
link_example.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 &lt;main&gt;:
0: 55 push rbp
1: 48 89 e5 mov rbp,rsp
4: 48 83 ec 10 sub rsp,0x10
8: c7 45 fc 0a 00 00 00 mov DWORD PTR [rbp-0x4],0xa
f: c7 45 f8 05 00 00 00 mov DWORD PTR [rbp-0x8],0x5
16: 8b 55 f8 mov edx,DWORD PTR [rbp-0x8]
19: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
1c: 89 d6 mov esi,edx
1e: 89 c7 mov edi,eax
20: b8 00 00 00 00 mov eax,0x0
25: e8 00 00 00 00 call 2a &lt;main+0x2a&gt;
2a: 89 45 f4 mov DWORD PTR [rbp-0xc],eax
2d: 8b 45 f4 mov eax,DWORD PTR [rbp-0xc]
30: 89 c6 mov esi,eax
32: 48 8d 3d 00 00 00 00 lea rdi,[rip+0x0] # 39 &lt;main+0x39&gt;
39: b8 00 00 00 00 mov eax,0x0
3e: e8 00 00 00 00 call 43 &lt;main+0x43&gt;
43: b8 00 00 00 00 mov eax,0x0
48: c9 leave
49: c3 ret
```
既然代码已经被我们“编译”成了指令,我们不妨尝试运行一下 ./link_example.o。
不幸的是文件没有执行权限我们遇到一个Permission denied错误。即使通过chmod命令赋予link_example.o文件可执行的权限运行./link_example.o仍然只会得到一条cannot execute binary file: Exec format error的错误。
我们再仔细看一下objdump出来的两个文件的代码会发现两个程序的地址都是从0开始的。如果地址是一样的程序如果需要通过call指令调用函数的话它怎么知道应该跳转到哪一个文件里呢
这么说吧无论是这里的运行报错还是objdump出来的汇编代码里面的重复地址都是因为 add_lib.o 以及 link_example.o并不是一个**可执行文件**Executable Program而是**目标文件**Object File。只有通过链接器Linker把多个目标文件以及调用的各种函数库链接起来我们才能得到一个可执行文件。
我们通过gcc的-o参数可以生成对应的可执行文件对应执行之后就可以得到这个简单的加法调用函数的结果。
```
$ gcc -o link-example add_lib.o link_example.o
$ ./link_example
c = 15
```
实际上,“**C语言代码-汇编代码-机器码**” 这个过程,在我们的计算机上进行的时候是由两部分组成的。
第一个部分由编译Compile、汇编Assemble以及链接Link三个阶段组成。在这三个阶段完成之后我们就生成了一个可执行文件。
第二部分我们通过装载器Loader把可执行文件装载Load到内存中。CPU从内存中读取指令和数据来开始真正执行程序。
<img src="https://static001.geekbang.org/resource/image/99/a7/997341ed0fa9018561c7120c19cfa2a7.jpg" alt="">
## ELF格式和链接理解链接过程
程序最终是通过装载器变成指令和数据的所以其实我们生成的可执行代码也并不仅仅是一条条的指令。我们还是通过objdump指令把可执行文件的内容拿出来看看。
```
link_example: file format elf64-x86-64
Disassembly of section .init:
...
Disassembly of section .plt:
...
Disassembly of section .plt.got:
...
Disassembly of section .text:
...
6b0: 55 push rbp
6b1: 48 89 e5 mov rbp,rsp
6b4: 89 7d fc mov DWORD PTR [rbp-0x4],edi
6b7: 89 75 f8 mov DWORD PTR [rbp-0x8],esi
6ba: 8b 55 fc mov edx,DWORD PTR [rbp-0x4]
6bd: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8]
6c0: 01 d0 add eax,edx
6c2: 5d pop rbp
6c3: c3 ret
00000000000006c4 &lt;main&gt;:
6c4: 55 push rbp
6c5: 48 89 e5 mov rbp,rsp
6c8: 48 83 ec 10 sub rsp,0x10
6cc: c7 45 fc 0a 00 00 00 mov DWORD PTR [rbp-0x4],0xa
6d3: c7 45 f8 05 00 00 00 mov DWORD PTR [rbp-0x8],0x5
6da: 8b 55 f8 mov edx,DWORD PTR [rbp-0x8]
6dd: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
6e0: 89 d6 mov esi,edx
6e2: 89 c7 mov edi,eax
6e4: b8 00 00 00 00 mov eax,0x0
6e9: e8 c2 ff ff ff call 6b0 &lt;add&gt;
6ee: 89 45 f4 mov DWORD PTR [rbp-0xc],eax
6f1: 8b 45 f4 mov eax,DWORD PTR [rbp-0xc]
6f4: 89 c6 mov esi,eax
6f6: 48 8d 3d 97 00 00 00 lea rdi,[rip+0x97] # 794 &lt;_IO_stdin_used+0x4&gt;
6fd: b8 00 00 00 00 mov eax,0x0
702: e8 59 fe ff ff call 560 &lt;printf@plt&gt;
707: b8 00 00 00 00 mov eax,0x0
70c: c9 leave
70d: c3 ret
70e: 66 90 xchg ax,ax
...
Disassembly of section .fini:
...
```
你会发现可执行代码dump出来内容和之前的目标代码长得差不多但是长了很多。因为在Linux下可执行文件和目标文件所使用的都是一种叫**ELF**Execuatable and Linkable File Format的文件格式中文名字叫**可执行与可链接文件格式**,这里面不仅存放了编译成的汇编指令,还保留了很多别的数据。
比如我们过去所有objdump出来的代码里你都可以看到对应的函数名称像add、main等等乃至你自己定义的全局可以访问的变量名称都存放在这个ELF格式文件里。这些名字和它们对应的地址在ELF文件里面存储在一个叫作**符号表**Symbols Table的位置里。符号表相当于一个地址簿把名字和地址关联了起来。
我们先只关注和我们的add以及main函数相关的部分。你会发现这里面main函数里调用add的跳转地址不再是下一条指令的地址了而是add函数的入口地址了这就是EFL格式和链接器的功劳。
<img src="https://static001.geekbang.org/resource/image/27/b3/276a740d0eabf5f4be905fe7326d9fb3.jpg" alt="">
ELF文件格式把各种信息分成一个一个的Section保存起来。ELF有一个基本的文件头File Header用来表示这个文件的基本属性比如是否是可执行文件对应的CPU、操作系统等等。除了这些基本属性之外大部分程序还有这么一些Section
<li>
首先是.text Section也叫作**代码段**或者指令段Code Section用来保存程序的代码和指令
</li>
<li>
接着是.data Section也叫作**数据段**Data Section用来保存程序里面设置好的初始化数据信息
</li>
<li>
然后就是.rel.text Secion叫作**重定位表**Relocation Table。重定位表里保留的是当前的文件里面哪些跳转地址其实是我们不知道的。比如上面的 link_example.o 里面我们在main函数里面调用了 add 和 printf 这两个函数,但是在链接发生之前,我们并不知道该跳转到哪里,这些信息就会存储在重定位表里;
</li>
<li>
最后是.symtab Section叫作**符号表**Symbol Table。符号表保留了我们所说的当前文件里面定义的函数名称和对应地址的地址簿。
</li>
链接器会扫描所有输入的目标文件,然后把所有符号表里的信息收集起来,构成一个全局的符号表。然后再根据重定位表,把所有不确定要跳转地址的代码,根据符号表里面存储的地址,进行一次修正。最后,把所有的目标文件的对应段进行一次合并,变成了最终的可执行代码。这也是为什么,可执行文件里面的函数调用的地址都是正确的。
<img src="https://static001.geekbang.org/resource/image/f6/12/f62da9b29aa53218f8907851df27f912.jpeg" alt="">
在链接器把程序变成可执行文件之后,要装载器去执行程序就容易多了。装载器不再需要考虑地址跳转的问题,只需要解析 ELF 文件把对应的指令和数据加载到内存里面供CPU执行就可以了。
## 总结延伸
讲到这里相信你已经猜到为什么同样一个程序在Linux下可以执行而在Windows下不能执行了。其中一个非常重要的原因就是两个操作系统下可执行文件的格式不一样。
我们今天讲的是Linux下的ELF文件格式而Windows的可执行文件格式是一种叫作**PE**Portable Executable Format的文件格式。Linux下的装载器只能解析ELF格式而不能解析PE格式。
如果我们有一个可以能够解析PE格式的装载器我们就有可能在Linux下运行Windows程序了。这样的程序真的存在吗没错Linux下著名的开源项目Wine就是通过兼容PE格式的装载器使得我们能直接在Linux下运行Windows程序的。而现在微软的Windows里面也提供了WSL也就是Windows Subsystem for Linux可以解析和加载ELF格式的文件。
我们去写可以用的程序,也不仅仅是把所有代码放在一个文件里来编译执行,而是可以拆分成不同的函数库,最后通过一个静态链接的机制,使得不同的文件之间既有分工,又能通过静态链接来“合作”,变成一个可执行的程序。
对于ELF格式的文件为了能够实现这样一个静态链接的机制里面不只是简单罗列了程序所需要执行的指令还会包括链接所需要的重定位表和符号表。
## 推荐阅读
想要更深入了解程序的链接过程和ELF格式我推荐你阅读《程序员的自我修养——链接、装载和库》的14章。这是一本难得的讲解程序的链接、装载和运行的好书。
## 课后思考
你可以通过readelf读取出今天演示程序的符号表看看符号表里都有哪些信息然后通过objdump读取出今天演示程序的重定位表看看里面又有哪些信息。
欢迎留言和我分享你的思考和疑惑,你也可以把今天的内容分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,84 @@
<audio id="audio" title="09 | 程序装载“640K内存”真的不够用么" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/06/c3/068aa199c89bf246db4978f9873abec3.mp3"></audio>
计算机这个行业的历史上有过很多成功的预言最著名的自然是“摩尔定律”。当然免不了的也有很多“失败”的预测其中一个最著名的就是比尔·盖茨在上世纪80年代说的“640K ought to be enough for anyone”也就是“640K内存对哪个人来说都够用了”。
那个年代微软开发的还是DOS操作系统程序员们还在绞尽脑汁想要用好这极为有限的640K内存。而现在我手头的开发机已经是16G内存了上升了一万倍还不止。那比尔·盖茨这句话在当时也是完全的无稽之谈么有没有哪怕一点点的道理呢这一讲里我就和你一起来看一看。
## 程序装载面临的挑战
上一讲我们看到了如何通过链接器把多个文件合并成一个最终可执行文件。在运行这些可执行文件的时候我们其实是通过一个装载器解析ELF或者PE格式的可执行文件。装载器会把对应的指令和数据加载到内存里面来让CPU去执行。
说起来只是装载到内存里面这一句话的事儿,实际上装载器需要满足两个要求。
**第一,可执行程序加载后占用的内存空间应该是连续的**。我们在[第6讲](https://time.geekbang.org/column/article/94075)讲过,执行指令的时候,程序计数器是顺序地一条一条指令执行下去。这也就意味着,这一条条指令需要连续地存储在一起。
**第二,我们需要同时加载很多个程序,并且不能让程序自己规定在内存中加载的位置。**虽然编译出来的指令里已经有了对应的各种各样的内存地址,但是实际加载的时候,我们其实没有办法确保,这个程序一定加载在哪一段内存地址上。因为我们现在的计算机通常会同时运行很多个程序,可能你想要的内存地址已经被其他加载了的程序占用了。
要满足这两个基本的要求,我们很容易想到一个办法。那就是我们可以在内存里面,找到一段连续的内存空间,然后分配给装载的程序,然后把这段连续的内存空间地址,和整个程序指令里指定的内存地址做一个映射。
我们把指令里用到的内存地址叫作**虚拟内存地址**Virtual Memory Address实际在内存硬件里面的空间地址我们叫**物理内存地址**Physical Memory Address**。**
程序里有指令和各种内存地址,我们只需要关心虚拟内存地址就行了。对于任何一个程序来说,它看到的都是同样的内存地址。我们维护一个虚拟内存到物理内存的映射表,这样实际程序指令执行的时候,会通过虚拟内存地址,找到对应的物理内存地址,然后执行。因为是连续的内存地址空间,所以我们只需要维护映射关系的起始地址和对应的空间大小就可以了。
## 内存分段
这种找出一段连续的物理内存和虚拟内存地址进行映射的方法,我们叫**分段**Segmentation**。**这里的段,就是指系统分配出来的那个连续的内存空间。
<img src="https://static001.geekbang.org/resource/image/24/18/24596e1e66d88c5d077b4c957d0d7f18.png" alt="">
分段的办法很好,解决了程序本身不需要关心具体的物理内存地址的问题,但它也有一些不足之处,第一个就是**内存碎片**Memory Fragmentation的问题。
我们来看这样一个例子。我现在手头的这台电脑有1GB的内存。我们先启动一个图形渲染程序占用了512MB的内存接着启动一个Chrome浏览器占用了128MB内存再启动一个Python程序占用了256MB内存。这个时候我们关掉Chrome于是空闲内存还有1024 - 512 - 256 = 256MB。按理来说我们有足够的空间再去装载一个200MB的程序。但是这256MB的内存空间不是连续的而是被分成了两段128MB的内存。因此实际情况是我们的程序没办法加载进来。
<img src="https://static001.geekbang.org/resource/image/57/d1/57211af3053ed621aeb903433c6c10d1.png" alt="">
当然,这个我们也有办法解决。解决的办法叫**内存交换**Memory Swapping
我们可以把Python程序占用的那256MB内存写到硬盘上然后再从硬盘上读回来到内存里面。不过读回来的时候我们不再把它加载到原来的位置而是紧紧跟在那已经被占用了的512MB内存后面。这样我们就有了连续的256MB内存空间就可以去加载一个新的200MB的程序。如果你自己安装过Linux操作系统你应该遇到过分配一个swap硬盘分区的问题。这块分出来的磁盘空间其实就是专门给Linux操作系统进行内存交换用的。
虚拟内存、分段,再加上内存交换,看起来似乎已经解决了计算机同时装载运行很多个程序的问题。不过,你千万不要大意,这三者的组合仍然会遇到一个性能瓶颈。硬盘的访问速度要比内存慢很多,而每一次内存交换,我们都需要把一大段连续的内存数据写到硬盘上。所以,如果内存交换的时候,交换的是一个很占内存空间的程序,这样整个机器都会显得卡顿。
## 内存分页
既然问题出在内存碎片和内存交换的空间太大上,那么解决问题的办法就是,少出现一些内存碎片。另外,当需要进行内存交换的时候,让需要交换写入或者从磁盘装载的数据更少一点,这样就可以解决这个问题。这个办法,在现在计算机的内存管理里面,就叫作**内存分页**Paging
**和分段这样分配一整段连续的空间给到程序相比,分页是把整个物理内存空间切成一段段固定尺寸的大小**。而对应的程序所需要占用的虚拟内存空间,也会同样切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间,我们叫**页**Page。从虚拟内存到物理内存的映射不再是拿整段连续的内存的物理地址而是按照一个一个页来的。页的尺寸一般远远小于整个程序的大小。在Linux下我们通常只设置成4KB。你可以通过命令看看你手头的Linux系统设置的页的大小。
```
$ getconf PAGE_SIZE
```
由于内存空间都是预先划分好的也就没有了不能使用的碎片而只有被释放出来的很多4KB的页。即使内存空间不够需要让现有的、正在运行的其他程序通过内存交换释放出一些内存的页出来一次性写入磁盘的也只有少数的一个页或者几个页不会花太多时间让整个机器被内存交换的过程给卡住。
<img src="https://static001.geekbang.org/resource/image/0c/f0/0cf2f08e1ceda473df71189334857cf0.png" alt="">
更进一步地,分页的方式使得我们在加载程序的时候,不再需要一次性都把程序加载到物理内存中。我们完全可以在进行虚拟内存和物理内存的页之间的映射之后,并不真的把页加载到物理内存里,而是只在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存里面去。
实际上我们的操作系统的确是这么做的。当要读取特定的页却发现数据并没有加载到物理内存里的时候就会触发一个来自于CPU的**缺页错误**Page Fault。我们的操作系统会捕捉到这个错误然后将对应的页从存放在硬盘上的虚拟内存里读取出来加载到物理内存里。这种方式使得我们可以运行那些远大于我们实际物理内存的程序。同时这样一来任何程序都不需要一次性加载完所有指令和数据只需要加载当前需要用到就行了。
通过虚拟内存、内存交换和内存分页这三个技术的组合,我们最终得到了一个让程序不需要考虑实际的物理内存地址、大小和当前分配空间的解决方案。这些技术和方法,对于我们程序的编写、编译和链接过程都是透明的。这也是我们在计算机的软硬件开发中常用的一种方法,就是**加入一个间接层**。
通过引入虚拟内存、页映射和内存交换,我们的程序本身,就不再需要考虑对应的真实的内存地址、程序加载、内存管理等问题了。任何一个程序,都只需要把内存当成是一块完整而连续的空间来直接使用。
## 总结延伸
现在回到开头我问你的问题我们的电脑只要640K内存就够了吗很显然现在来看比尔·盖茨的这个判断是不合理的那为什么他会这么认为呢因为他也是一个很优秀的程序员啊
在虚拟内存、内存交换和内存分页这三者结合之下你会发现其实要运行一个程序“必需”的内存是很少的。CPU只需要执行当前的指令极限情况下内存也只需要加载一页就好了。再大的程序也可以分成一页。每次只在需要用到对应的数据和指令的时候从硬盘上交换到内存里面来就好了。以我们现在4K内存一页的大小640K内存也能放下足足160页呢也无怪乎在比尔·盖茨会说出“640K ought to be enough for anyone”这样的话。
不过呢硬盘的访问速度比内存慢很多所以我们现在的计算机没有个几G的内存都不好意思和人打招呼。
那么,除了程序分页装载这种方式之外,我们还有其他优化内存使用的方式么?下一讲,我们就一起来看看“动态装载”,学习一下让两个不同的应用程序,共用一个共享程序库的办法。
## 推荐阅读
想要更深入地了解代码装载的详细过程推荐你阅读《程序员的自我修养——链接、装载和库》的第1章和第6章。
## 课后思考
请你想一想在Java这样使用虚拟机的编程语言里面我们写的程序是怎么装载到内存里面来的呢它也和我们讲的一样是通过内存分页和内存交换的方式加载到内存里面来的么
欢迎你在留言区写下你的思考和疑问,和大家一起探讨。你也可以把今天的文章分享给你朋友,和他一起学习和进步。

View File

@@ -0,0 +1,179 @@
<audio id="audio" title="10 | 动态链接:程序内部的“共享单车”" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4c/f7/4cbe011e6435ea6f292bbd963e9bd6f7.mp3"></audio>
我们之前讲过,程序的链接,是把对应的不同文件内的代码段,合并到一起,成为最后的可执行文件。这个链接的方式,让我们在写代码的时候做到了“复用”。同样的功能代码只要写一次,然后提供给很多不同的程序进行链接就行了。
这么说来,“链接”其实有点儿像我们日常生活中的**标准化、模块化**生产。我们有一个可以生产标准螺帽的生产线,就可以生产很多个不同的螺帽。只要需要螺帽,我们都可以通过**链接**的方式,去**复制**一个出来,放到需要的地方去,大到汽车,小到信箱。
但是,如果我们有很多个程序都要通过装载器装载到内存里面,那里面链接好的同样的功能代码,也都需要再装载一遍,再占一遍内存空间。这就好比,假设每个人都有骑自行车的需要,那我们给每个人都生产一辆自行车带在身边,固然大家都有自行车用了,但是马路上肯定会特别拥挤。
<img src="https://static001.geekbang.org/resource/image/09/51/092dfd81e3cc45ea237bb85557bbfa51.jpg" alt="">
## 链接可以分动、静,共享运行省内存
我们上一节解决程序装载到内存的时候,讲了很多方法。说起来,最根本的问题其实就是**内存空间不够用**。如果我们能够让同样功能的代码,在不同的程序里面,不需要各占一份内存空间,那该有多好啊!就好比,现在马路上的共享单车,我们并不需要给每个人都造一辆自行车,只要马路上有这些单车,谁需要的时候,直接通过手机扫码,都可以解锁骑行。
这个思路就引入一种新的链接方法,叫作**动态链接**Dynamic Link。相应的我们之前说的合并代码段的方法就是**静态链接**Static Link
在动态链接的过程中,我们想要“链接”的,不是存储在硬盘上的目标文件代码,而是加载到内存中的**共享库**Shared Libraries。顾名思义这里的共享库重在“共享“这两个字。
这个加载到内存中的共享库会被很多个程序的指令调用到。在Windows下这些共享库文件就是.dll文件也就是Dynamic-Link LibaryDLL动态链接库。在Linux下这些共享库文件就是.so文件也就是Shared Object一般我们也称之为动态链接库。这两大操作系统下的文件名后缀一个用了“动态链接”的意思另一个用了“共享”的意思正好覆盖了两方面的含义。
<img src="https://static001.geekbang.org/resource/image/29/60/2980d241d3c7cbfa3724cb79b801d160.jpg" alt="">
## 地址无关很重要,相对地址解烦恼
不过,要想要在程序运行的时候共享代码,也有一定的要求,就是这些机器码必须是“**地址无关**”的。也就是说我们编译出来的共享库文件的指令代码是地址无关码Position-Independent Code。换句话说就是这段代码无论加载在哪个内存地址都能够正常执行。如果不是这样的代码就是地址相关的代码。
如果还不明白我给你举一个生活中的例子。如果我们有一个骑自行车的程序要“前进500米左转进入天安门广场再前进500米”。它在500米之后要到天安门广场了这就是地址相关的。如果程序是“前进500米左转再前进500米”无论你在哪里都可以骑车走这1000米没有具体地点的限制这就是地址无关的。
你可以想想,大部分函数库其实都可以做到地址无关,因为它们都接受特定的输入,进行确定的操作,然后给出返回结果就好了。无论是实现一个向量加法,还是实现一个打印的函数,这些代码逻辑和输入的数据在内存里面的位置并不重要。
而常见的地址相关的代码比如绝对地址代码Absolute Code、利用重定位表的代码等等都是地址相关的代码。你回想一下我们之前讲过的重定位表。在程序链接的时候我们就把函数调用后要跳转访问的地址确定下来了这意味着如果这个函数加载到一个不同的内存地址跳转就会失败。
<img src="https://static001.geekbang.org/resource/image/8c/4a/8cab516a92fd3d7e951887808597094a.jpg" alt="">
对于所有动态链接共享库的程序来讲,虽然我们的共享库用的都是同一段物理内存地址,但是在不同的应用程序里,它所在的虚拟内存地址是不同的。我们没办法、也不应该要求动态链接同一个共享库的不同程序,必须把这个共享库所使用的虚拟内存地址变成一致。如果这样的话,我们写的程序就必须明确地知道内部的内存地址分配。
那么问题来了,我们要怎么样才能做到,动态共享库编译出来的代码指令,都是地址无关码呢?
动态代码库内部的变量和函数调用都很容易解决,我们只需要使用**相对地址**Relative Address就好了。各种指令中使用到的内存地址给出的不是一个绝对的地址空间而是一个相对于当前指令偏移量的内存地址。因为整个共享库是放在一段连续的虚拟内存地址中的无论装载到哪一段地址不同指令之间的相对地址都是不变的。
## PLT和GOT动态链接的解决方案
要实现动态链接共享库,也并不困难,和前面的静态链接里的符号表和重定向表类似,还是和前面一样,我们还是拿出一小段代码来看一看。
首先lib.h 定义了动态链接库的一个函数 show_me_the_money。
```
// lib.h
#ifndef LIB_H
#define LIB_H
void show_me_the_money(int money);
#endif
```
lib.c包含了lib.h的实际实现。
```
// lib.c
#include &lt;stdio.h&gt;
void show_me_the_money(int money)
{
printf(&quot;Show me USD %d from lib.c \n&quot;, money);
}
```
然后show_me_poor.c 调用了 lib 里面的函数。
```
// show_me_poor.c
#include &quot;lib.h&quot;
int main()
{
int money = 5;
show_me_the_money(money);
}
```
最后,我们把 lib.c 编译成了一个动态链接库,也就是 .so 文件。
```
$ gcc lib.c -fPIC -shared -o lib.so
$ gcc -o show_me_poor show_me_poor.c ./lib.so
```
你可以看到,在编译的过程中,我们指定了一个 **-fPIC** 的参数。这个参数其实就是Position Independent Code的意思也就是我们要把这个编译成一个地址无关代码。
然后我们再通过gcc编译show_me_poor 动态链接了lib.so的可执行文件。在这些操作都完成了之后我们把show_me_poor这个文件通过objdump出来看一下。
```
$ objdump -d -M intel -S show_me_poor
```
```
……
0000000000400540 &lt;show_me_the_money@plt-0x10&gt;:
400540: ff 35 12 05 20 00 push QWORD PTR [rip+0x200512] # 600a58 &lt;_GLOBAL_OFFSET_TABLE_+0x8&gt;
400546: ff 25 14 05 20 00 jmp QWORD PTR [rip+0x200514] # 600a60 &lt;_GLOBAL_OFFSET_TABLE_+0x10&gt;
40054c: 0f 1f 40 00 nop DWORD PTR [rax+0x0]
0000000000400550 &lt;show_me_the_money@plt&gt;:
400550: ff 25 12 05 20 00 jmp QWORD PTR [rip+0x200512] # 600a68 &lt;_GLOBAL_OFFSET_TABLE_+0x18&gt;
400556: 68 00 00 00 00 push 0x0
40055b: e9 e0 ff ff ff jmp 400540 &lt;_init+0x28&gt;
……
0000000000400676 &lt;main&gt;:
400676: 55 push rbp
400677: 48 89 e5 mov rbp,rsp
40067a: 48 83 ec 10 sub rsp,0x10
40067e: c7 45 fc 05 00 00 00 mov DWORD PTR [rbp-0x4],0x5
400685: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
400688: 89 c7 mov edi,eax
40068a: e8 c1 fe ff ff call 400550 &lt;show_me_the_money@plt&gt;
40068f: c9 leave
400690: c3 ret
400691: 66 2e 0f 1f 84 00 00 nop WORD PTR cs:[rax+rax*1+0x0]
400698: 00 00 00
40069b: 0f 1f 44 00 00 nop DWORD PTR [rax+rax*1+0x0]
……
```
我们还是只关心整个可执行文件中的一小部分内容。你应该可以看到在main函数调用show_me_the_money的函数的时候对应的代码是这样的
```
call 400550 &lt;show_me_the_money@plt&gt;
```
这里后面有一个@plt的关键字代表了我们需要从PLT也就是**程序链接表**Procedure Link Table里面找要调用的函数。对应的地址呢则是400550这个地址。
那当我们把目光挪到上面的 400550 这个地址你又会看到里面进行了一次跳转这个跳转指定的跳转地址你可以在后面的注释里面可以看到GLOBAL_OFFSET_TABLE+0x18。这里的GLOBAL_OFFSET_TABLE就是我接下来要说的全局偏移表。
```
400550: ff 25 12 05 20 00 jmp QWORD PTR [rip+0x200512] # 600a68 &lt;_GLOBAL_OFFSET_TABLE_+0x18&gt;
```
在动态链接对应的共享库我们在共享库的data section里面保存了一张**全局偏移表**GOTGlobal Offset Table。**虽然共享库的代码部分的物理内存是共享的,但是数据部分是各个动态链接它的应用程序里面各加载一份的。**所有需要引用当前共享库外部的地址的指令都会查询GOT来找到当前运行程序的虚拟内存里的对应位置。而GOT表里的数据则是在我们加载一个个共享库的时候写进去的。
不同的进程调用同样的lib.so各自GOT里面指向最终加载的动态链接库里面的虚拟内存地址是不同的。
这样虽然不同的程序调用的同样的动态库各自的内存地址是独立的调用的又都是同一个动态库但是不需要去修改动态库里面的代码所使用的地址而是各个程序各自维护好自己的GOT能够找到对应的动态库就好了。
<img src="https://static001.geekbang.org/resource/image/11/c8/1144d3a2d4f3f4f87c349a93429805c8.jpg" alt="">
我们的GOT表位于共享库自己的数据段里。GOT表在内存里和对应的代码段位置之间的偏移量始终是确定的。这样我们的共享库就是地址无关的代码对应的各个程序只需要在物理内存里面加载同一份代码。而我们又要通过各个可执行程序在加载时生成的各不相同的GOT表来找到它需要调用到的外部变量和函数的地址。
这是一个典型的、不修改代码,而是通过修改“**地址数据**”来进行关联的办法。它有点像我们在C语言里面用函数指针来调用对应的函数并不是通过预先已经确定好的函数名称来调用而是利用当时它在内存里面的动态地址来调用。
## 总结延伸
这一讲,我们终于在静态链接和程序装载之后,利用动态链接把我们的内存利用到了极致。同样功能的代码生成的共享库,我们只要在内存里面保留一份就好了。这样,我们不仅能够做到代码在开发阶段的复用,也能做到代码在运行阶段的复用。
实际上在进行Linux下的程序开发的时候我们一直会用到各种各样的动态链接库。C语言的标准库就在1MB以上。我们撰写任何一个程序可能都需要用到这个库常见的Linux服务器里/usr/bin下面就有上千个可执行文件。如果每一个都把标准库静态链接进来的几GB乃至几十GB的磁盘空间一下子就用出去了。如果我们服务端的多进程应用要开上千个进程几GB的内存空间也会一下子就用出去了。这个问题在过去计算机的内存较少的时候更加显著。
通过动态链接这个方式,可以说彻底解决了这个问题。就像共享单车一样,如果仔细经营,是一个很有社会价值的事情,但是如果粗暴地把它变成无限制地复制生产,给每个人造一辆,只会在系统内制造大量无用的垃圾。
过去的0509这五讲里我们已经把程序怎么从源代码变成指令、数据并装载到内存里面由CPU一条条执行下去的过程讲完了。希望你能有所收获对于一个程序是怎么跑起来的有了一个初步的认识。
## 推荐阅读
想要更加深入地了解动态链接我推荐你可以读一读《程序员的自我修养链接、装载和库》的第7章里面深入地讲解了动态链接里程序内的数据布局和对应数据的加载关系。
## 课后思考
像动态链接这样通过修改“地址数据”来进行间接跳转,去调用一开始不能确定位置代码的思路,你在应用开发中使用过吗?
欢迎你在留言区写下你的思考和疑问,和大家一起探讨。你也可以把今天的文章分享给你朋友,和他一起学习和进步。

View File

@@ -0,0 +1,93 @@
<audio id="audio" title="11 | 二进制编码:“手持两把锟斤拷,口中疾呼烫烫烫”?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/42/13/428611743babe804048a3f5a38297013.mp3"></audio>
上算法和数据结构课的时候,老师们都会和你说,程序 = 算法 + 数据结构。如果对应到组成原理或者说硬件层面,算法就是我们前面讲的各种计算机指令,数据结构就对应我们接下来要讲的二进制数据。
众所周知现代计算机都是用0和1组成的二进制来表示所有的信息。前面几讲的程序指令用到的机器码也是使用二进制表示的我们存储在内存里面的字符串、整数、浮点数也都是用二进制表示的。万事万物在计算机里都是0和1所以呢搞清楚各种数据在二进制层面是怎么表示的是我们必备的一课。
大部分教科书都会详细地从整数的二进制表示讲起,相信你在各种地方都能看到对应的材料,所以我就不再啰啰嗦嗦地讲这个了,只会快速地浏览一遍整数的二进制表示。
然后呢我们重点来看一看大家在实际应用中最常遇到的问题也就是文本字符串是怎么表示成二进制的特别是我们会遇到的乱码究竟是怎么回事儿。我们平时在开发的时候所说的Unicode和UTF-8之间有什么关系。理解了这些相信以后遇到任何乱码问题你都能手到擒来了。
## 理解二进制的“逢二进一”
二进制和我们平时用的十进制其实并没有什么本质区别只是平时我们是“逢十进一”这里变成了“逢二进一”而已。每一位相比于十进制下的09这十个数字我们只能用0和1这两个数字。
任何一个十进制的整数都能通过二进制表示出来。把一个二进制数对应到十进制非常简单就是把从右到左的第N位乘上一个2的N次方然后加起来就变成了一个十进制数。当然既然二进制是一个面向程序员的“语言”这个从右到左的位置自然是从0开始的。
比如0011这个二进制数对应的十进制表示就是$0×2^3+0×2^2+1×2^1+1×2^0$<br>
$=3$代表十进制的3。
对应地,如果我们想要把一个十进制的数,转化成二进制,使用**短除法**就可以了。也就是把十进制数除以2的余数作为最右边的一位。然后用商继续除以2把对应的余数紧靠着刚才余数的右侧这样递归迭代直到商为0就可以了。
比如我们想把13这个十进制数用短除法转化成二进制需要经历以下几个步骤
<img src="https://static001.geekbang.org/resource/image/a2/d8/a2b6f2a92bcf99e9f96367bbb90383d8.jpg" alt="">
因此对应的二进制数就是1101。
刚才我们举的例子都是正数对于负数来说情况也是一样的吗我们可以把一个数最左侧的一位当成是对应的正负号比如0为正数1为负数这样来进行标记。
这样一个4位的二进制数 0011就表示为+3。而1011最左侧的第一位是1所以它就表示-3。这个其实就是整数的**原码表示法**。原码表示法有一个很直观的缺点就是0可以用两个不同的编码来表示1000代表0 0000也代表0。习惯万事一一对应的程序员看到这种情况必然会被“逼死”。
于是我们就有了另一种表示方法。我们仍然通过最左侧第一位的0和1来判断这个数的正负。但是我们不再把这一位当成单独的符号位在剩下几位计算出的十进制前加上正负号而是在计算整个二进制值的时候在左侧最高位前面加个负号。
比如一个4位的二进制补码数值1011转换成十进制就是$-1×2^3+0×2^2+1×2^1+1×2^0$<br>
$=-5$。如果最高位是1这个数必然是负数最高位是0必然是正数。并且只有0000表示01000在这样的情况下表示-8。一个4位的二进制数可以表示从-8到7这16个整数不会白白浪费一位。
当然更重要的一点是,用补码来表示负数,使得我们的整数相加变得很容易,不需要做任何特殊处理,只是把它当成普通的二进制相加,就能得到正确的结果。
我们简单一点拿一个4位的整数来算一下比如 -5 + 1 = -4-5 + 6 = 1。我们各自把它们转换成二进制来看一看。如果它们和无符号的二进制整数的加法用的是同样的计算方式这也就意味着它们是同样的电路。
<img src="https://static001.geekbang.org/resource/image/bf/ae/bf4cfd001308da2be317b08d1f40a7ae.jpg" alt="">
## 字符串的表示,从编码到数字
不仅数值可以用二进制表示,字符乃至更多的信息都能用二进制表示。最典型的例子就是**字符串**Character String。最早计算机只需要使用英文字符加上数字和一些特殊符号然后用8位的二进制就能表示我们日常需要的所有字符了这个就是我们常常说的**ASCII码**American Standard Code for Information Interchange美国信息交换标准代码
<img src="https://static001.geekbang.org/resource/image/be/05/bee81480de3f6e7181cb7bb5f55cc805.png" alt="">
ASCII码就好比一个字典用8位二进制中的128个不同的数映射到128个不同的字符里。比如小写字母a在ASCII里面就是第97个也就是二进制的0110 0001对应的十六进制表示就是 61。而大写字母 A就是第65个也就是二进制的0100 0001对应的十六进制表示就是41。
在ASCII码里面数字9不再像整数表示法里一样用0000 1001来表示而是用0011 1001 来表示。字符串15也不是用0000 1111 这8位来表示而是变成两个字符1和5连续放在一起也就是 0011 0001 和 0011 0101需要用两个8位来表示。
我们可以看到最大的32位整数就是2147483647。如果用整数表示法只需要32位就能表示了。但是如果用字符串来表示一共有10个字符每个字符用8位的话需要整整80位。比起整数表示法要多占很多空间。
这也是为什么很多时候我们在存储数据的时候要采用二进制序列化这样的方式而不是简单地把数据通过CSV或者JSON这样的文本格式存储来进行序列化。**不管是整数也好,浮点数也好,采用二进制序列化会比存储文本省下不少空间。**
ASCII码只表示了128个字符一开始倒也堪用毕竟计算机是在美国发明的。然而随着越来越多的不同国家的人都用上了计算机想要表示譬如中文这样的文字128个字符显然是不太够用的。于是计算机工程师们开始各显神通给自己国家的语言创建了对应的**字符集**Charset和**字符编码**Character Encoding
字符集表示的可以是字符的一个集合。比如“中文”就是一个字符集不过这样描述一个字符集并不准确。想要更精确一点我们可以说“第一版《新华字典》里面出现的所有汉字”这是一个字符集。这样我们才能明确知道一个字符在不在这个集合里面。比如我们日常说的Unicode其实就是一个字符集包含了150种语言的14万个不同的字符。
而字符编码则是对于字符集里的这些字符怎么一一用二进制表示出来的一个字典。我们上面说的Unicode就可以用UTF-8、UTF-16乃至UTF-32来进行编码存储成二进制。所以有了Unicode其实我们可以用不止UTF-8一种编码形式我们也可以自己发明一套 GT-32 编码比如就叫作Geek Time 32好了。只要别人知道这套编码规则就可以正常传输、显示这段代码。
<img src="https://static001.geekbang.org/resource/image/99/3e/9911c58d79e8a1f106d48a83457d193e.jpg" alt="">
同样的文本,采用不同的编码存储下来。如果另外一个程序,用一种不同的编码方式来进行解码和展示,就会出现乱码。这就好像两个军队用密语通信,如果用错了密码本,那看到的消息就会不知所云。在中文世界里,最典型的就是“手持两把锟斤拷,口中疾呼烫烫烫”的典故。
我曾经听说过这么一个笑话没有经验的同学在看到程序输出“烫烫烫”的时候以为是程序让CPU过热发出报警于是尝试给CPU降频来解决问题。
既然今天要彻底搞清楚编码知识,我们就来弄清楚“锟斤拷”和“烫烫烫”的来龙去脉。
<img src="https://static001.geekbang.org/resource/image/5c/fd/5c6e03705f50c250ccb5300849c281fd.png" alt="">
首先“锟斤拷”的来源是这样的。如果我们想要用Unicode编码记录一些文本特别是一些遗留的老字符集内的文本但是这些字符在Unicode中可能并不存在。于是Unicode会统一把这些字符记录为U+FFFD这个编码。如果用UTF-8的格式存储下来就是\xef\xbf\xbd。如果连续两个这样的字符放在一起\xef\xbf\xbd\xef\xbf\xbd这个时候如果程序把这个字符用GB2312的方式进行decode就会变成“锟斤拷”。这就好比我们用GB2312这本密码本去解密别人用UTF-8加密的信息自然没办法读出有用的信息。
而“烫烫烫”则是因为如果你用了Visual Studio的调试器默认使用MBCS字符集。“烫”在里面是由0xCCCC来表示的而0xCC又恰好是未初始化的内存的赋值。于是在读到没有赋值的内存地址或者变量的时候电脑就开始大叫“烫烫烫”了。
了解了这些原理,相信你未来在遇到中文的编码问题的时候,可以做到“手中有粮,心中不慌”了。
## 总结延伸
到这里,相信你发现,我们可以用二进制编码的方式,表示任意的信息。只要建立起字符集和字符编码,并且得到大家的认同,我们就可以在计算机里面表示这样的信息了。所以说,如果你有心,要发明一门自己的克林贡语并不是什么难事。
不过光是明白怎么把数值和字符在逻辑层面用二进制表示是不够的。我们在计算机组成里面关心的不只是数值和字符的逻辑表示更要弄明白在硬件层面这些数值和我们一直提的晶体管和电路有什么关系。下一讲我就会为你揭开神秘的面纱。我会从时钟和D触发器讲起最终让你明白计算机里的加法是如何通过电路来实现的。
## 推荐阅读
关于二进制和编码,我推荐你读一读《编码:隐匿在计算机软硬件背后的语言》。从电报机到计算机,这本书讲述了很多计算设备的历史故事,当然,也包含了二进制及其背后对应的电路原理。
## 课后思考
你肯定会计算十进制整数的加减法,二进制的加减法也是一样的。如果二进制的加法中,有数是负数的时候该怎么处理呢?我们今天讲了补码的表示形式,如果这个负数是原码表示的,又应该如何处理?如果是补码表示的呢?请你用二进制加法试着算一算,-5+4=-1通过原码和补码是如何进行的
欢迎你在留言区写下你的思考和疑问,和大家一起探讨。你也可以把今天的文章分享给你朋友,和他一起学习和进步。

View File

@@ -0,0 +1,87 @@
<audio id="audio" title="12 | 理解电路:从电报机到门电路,我们如何做到“千里传信”?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c2/bb/c2e2074a9abdc9c0efff056f919555bb.mp3"></audio>
我们前面讲过机器指令你应该知道所有最终执行的程序其实都是使用“0”和“1”这样的二进制代码来表示的。上一讲里我也向你展示了对应的整数和字符串其实也是用“0”和“1”这样的二进制代码来表示的。
那么你可能要问了,我知道了这个有什么用呢?毕竟我们人用纸和笔来做运算,都是用十进制,直接用十进制和我们最熟悉的符号不是最简单么?为什么计算机里我们最终要选择二进制呢?
这一讲,我和你一起来看看,计算机在硬件层面究竟是怎么表示二进制的,以此你就会明白,为什么计算机会选择二进制。
## 从信使到电报,我们怎么做到“千里传书”?
马拉松的故事相信你听说过。公元前490年在雅典附近的马拉松海边发生了波斯和希腊之间的希波战争。雅典和斯巴达领导的希腊联军胜利之后雅典飞毛腿菲迪皮德斯跑了历史上第一个马拉松回雅典报喜。这个时候人们在远距离报信的时候采用的是派人跑腿传口信或者送信的方式。
但是,这样靠人传口信或者送信的方式,实在是太慢了。在军事用途中,信息能否更早更准确地传递出去经常是事关成败的大事。所以我们看到中国古代的军队有“击鼓进军”和“鸣金收兵”,通过打鼓和敲钲发出不同的声音,来传递军队的号令。
如果我们把军队当成一台计算机那“金”和“鼓”就是这台计算机的“1”和“0”。我们可以通过不同的编码方式来指挥这支军队前进、后退、转向、追击等等。
“金”和“鼓”比起跑腿传口信,固然效率更高了,但是能够传递的范围还是非常有限,超出个几公里恐怕就听不见了。于是,人们发明了更多能够往更远距离传信的方式,比如海上的灯塔、长城上的烽火台。因为光速比声速更快,传的距离也可以更远。
<img src="https://static001.geekbang.org/resource/image/48/f9/486201eca454fbda5b3a77ef29d27bf9.png" alt="">
但是这些传递信息的方式都面临一个问题就是受限于只有“1”和“0”这两种信号不能传递太复杂的信息那电报的发明就解决了这个问题。
从信息编码的角度来说,金、鼓、灯塔、烽火台类似电报的二进制编码。电报传输的信号有两种,一种是短促的**点信号**dot信号一种是长一点的**划信号**dash信号。我们把“点”当成“1”把“划”当成“0”。这样一来我们的电报信号就是另一种特殊的二进制编码了。电影里最常见的电报信号是“SOS”这个信号表示出来就是 “点点点划划划点点点”。
比起灯塔和烽火台这样的设备,电报信号有两个明显的优势。第一,信号的传输距离迅速增加。因为电报本质上是通过电信号来进行传播的,所以从输入信号到输出信号基本上没有延时。第二,输入信号的速度加快了很多。电报机只有一个按钮,按下就是输入信号,按的时间短一点,就是发出了一个“点”信号;按的时间长一些,就是一个“划”信号。只要一个手指,就能快速发送电报。
<img src="https://static001.geekbang.org/resource/image/5d/a4/5da409e31bd130129a5d669b143fa1a4.jpg" alt="">
而且,制造一台电报机也非常容易。电报机本质上就是一个“**蜂鸣器+长长的电线+按钮开关**”。蜂鸣器装在接收方手里,开关留在发送方手里。双方用长长的电线连在一起。当按钮开关按下的时候,电线的电路接通了,蜂鸣器就会响。短促地按下,就是一个短促的点信号;按的时间稍微长一些,就是一个稍长的划信号。
<img src="https://static001.geekbang.org/resource/image/28/12/283742f3a72eba22f6b4ae97e21c4112.jpg" alt="">
## 理解继电器,给跑不动的信号续一秒
有了电报机,只要铺设好电报线路,就可以传输我们需要的讯息了。但是这里面又出现了一个新的挑战,就是随着电线的线路越长,电线的电阻就越大。当电阻很大,而电压不够的时候,即使你按下开关,蜂鸣器也不会响。
你可能要说了,我们可以提高电压或者用更粗的电线,使得电阻更小,这样就可以让整个线路铺得更长一些。但是这个再长,也没办法从北京铺设到上海吧。要想从北京把电报发到上海,我们还得想些别的办法。
对于电报来说,电线太长了,使得线路接通也没有办法让蜂鸣器响起来。那么,我们就不要一次铺太长的线路,而把一小段距离当成一个线路。我们也可以跟驿站建立一个小电报站,在小电报站里面安排一个电报员。他听到上一个小电报站发来的信息,然后原样输入,发到下一个电报站去。这样,我们的信号就可以一段段传输下去,而不会因为距离太长,导致电阻太大,没有办法成功传输信号。为了能够实现这样**接力传输信号**,在电路里面,工程师们造了一个叫作**继电器**Relay的设备。
<img src="https://static001.geekbang.org/resource/image/11/ea/1186a10341202ea36df27cba95f1cbea.jpg" alt="">
事实上,这个过程中,我们需要在每一阶段**原样传输信号**,所以你可以想想,我们是不是可以设计一个设备来代替这个电报员?相比使用人工听蜂鸣器的声音,来重复输入信号,利用电磁效应和磁铁,来实现这个事情会更容易。
我们把原先用来输出声音的蜂鸣器,换成一段环形的螺旋线圈,让电路封闭通上电。因为电磁效应,这段螺旋线圈会产生一个带有磁性的电磁场。我们原本需要输入的按钮开关,就可以用一块磁力稍弱的磁铁把它设在“关”的状态。这样,按下上一个电报站的开关,螺旋线圈通电产生了磁场之后,磁力就会把开关“吸”下来,接通到下一个电报站的电路。
如果我们在中间所有小电报站都用这个“**螺旋线圈+磁性开关**”的方式,来替代蜂鸣器和普通开关,而只在电报的始发和终点用普通的开关和蜂鸣器,我们就有了一个拆成一段一段的电报线路,接力传输电报信号。这样,我们就不需要中间安排人力来听打电报内容,也不需要解决因为线缆太长导致的电阻太大或者电压不足的问题了。我们只要在终点站安排电报员,听写最终的电报内容就可以了。这样是不是比之前更省事了?
事实上,继电器还有一个名字就叫作**电驿**,这个“驿”就是驿站的驿,可以说非常形象了。这个接力的策略不仅可以用在电报中,在通信类的科技产品中其实都可以用到。
比如说你在家里用WiFi如果你的屋子比较大可能某些房间的信号就不好。你可以选用支持“中继”的WiFi路由器在信号衰减的地方增加一个WiFi设备接收原来的WiFi信号再重新从当前节点传输出去。这种中继对应的英文名词和继电器是一样的也叫Relay。
再比如说,我们现在互联网使用的光缆,是用光信号来传输数据。随着距离的增长、反射次数的增加,信号也会有所衰减,我们同样要每隔一段距离,来增加一个用来重新放大信号的中继。
有了继电器之后我们不仅有了一个能够接力传输信号的方式更重要的是和输入端通过开关的“开”和“关”来表示“1”和“0”一样我们在输出端也能表示“1”和“0”了。
输出端的作用,不仅仅是通过一个蜂鸣器或者灯泡,提供一个供人观察的输出信号,通过“螺旋线圈 + 磁性开关”使得我们有“开”和“关”这两种状态这个“开”和“关”表示的“1”和“0”还可以作为后续线路的输入信号让我们开始可以通过最简单的电路来组合形成我们需要的逻辑。
通过这些线圈和开关,我们也可以很容易地创建出 “与AND”“或OR”“非NOT”这样的逻辑。我们在输入端的电路上提供串联的两个开关只有两个开关都打开电路才接通输出的开关也才能接通这其实就是模拟了计算机里面的“与”操作。
我们在输入端的电路,提供两条独立的线路到输出端,两条线路上各有一个开关,那么任何一个开关打开了,到输出端的电路都是接通的,这其实就是模拟了计算机中的“或”操作。
当我们把输出端的“螺旋线圈+磁性开关”的组合,从默认关掉,只有通电有了磁场之后打开,换成默认是打开通电的,只有通电之后才关闭,我们就得到了一个计算机中的“非”操作。输出端开和关正好和输入端相反。这个在数字电路中,也叫作**反向器**Inverter
<img src="https://static001.geekbang.org/resource/image/97/5e/977b09f3a334304c2861c6b420217b5e.jpg" alt="">
与、或、非的电路都非常简单,要想做稍微复杂一点的工作,我们需要很多电路的组合。不过,这也彰显了现代计算机体系中一个重要的思想,就是通过分层和组合,逐步搭建起更加强大的功能。
回到我们前面看的电报机原型,虽然一个按钮开关的电报机很“容易”操作,但是却不“方便”操作。因为电报员要熟记每一个字母对应的摩尔斯电码,并且需要快速按键来进行输入,一旦输错很难纠正。但是,因为电路之间可以通过与、或、非组合完成更复杂的功能,我们完全可以设计一个和打字机一样的电报机,每按下一个字母按钮,就会接通一部分电路,然后把这个字母的摩尔斯电码输出出去。
虽然在电报机时代我们没有这么做但是在计算机时代我们其实就是这样做的。我们不再是给计算机“0”和“1”而是通过千万个晶体管组合在一起最终使得我们可以用“高级语言”指挥计算机去干什么。
## 总结延伸
可以说电报是现代计算机的一个最简单的原型。它和我们现在使用的现代计算机有很多相似之处。我们通过电路的“开”和“关”来表示“1”和“0”。就像晶体管在不同的情况下表现为导电的“1”和绝缘的“0”的状态。
我们通过电报机这个设备,看到了如何通过“螺旋线圈+开关”来构造基本的逻辑电路我们也叫门电路。一方面我们可以通过继电器或者中继进行长距离的信号传输。另一方面我们也可以通过设置不同的线路和开关状态实现更多不同的信号表示和处理方式这些线路的连接方式其实就是我们在数字电路中所说的门电路。而这些门电路也是我们创建CPU和内存的基本逻辑单元。我们的各种对于计算机二进制的“0”和“1”的操作其实就是来自于门电路叫作组合逻辑电路。
## 推荐阅读
《编码隐匿在计算机软硬件背后的语言》的第611章是一个很好的入门材料可以帮助你深入理解数字电路值得你花时间好好读一读。
## 课后思考
除了与、或、非之外还有很多基础的门电路比如“异或XOR门”。你可以想一想试着搜索一些资料设计一个异或门的电路。
欢迎你在留言区写下你的思考和疑问,和大家一起探讨。你也可以把今天的文章分享给你朋友,和他一起学习和进步。

View File

@@ -0,0 +1,89 @@
<audio id="audio" title="13 | 加法器:如何像搭乐高一样搭电路(上)?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f0/e4/f0c2ab7ba1ca54add23e734341a65ce4.mp3"></audio>
上一讲,我们看到了如何通过电路,在计算机硬件层面设计最基本的单元,门电路。我给你看的门电路非常简单,只能做简单的 “与AND”“或OR”“NOT”和“异或XOR这样最基本的单比特逻辑运算。下面这些门电路的标识你需要非常熟悉后续的电路都是由这些门电路组合起来的。
<img src="https://static001.geekbang.org/resource/image/94/f6/94194480bcfd3b5366e4649ee80de4f6.jpg" alt="">
这些基本的门电路是我们计算机硬件端的最基本的“积木”就好像乐高积木里面最简单的小方块。看似不起眼但是把它们组合起来最终可以搭出一个星球大战里面千年隼这样的大玩意儿。我们今天包含十亿级别晶体管的现代CPU都是由这样一个一个的门电路组合而成的。
<img src="https://static001.geekbang.org/resource/image/2f/b7/2f20b26b1ed7f9d26c5a0858ad6770b7.jpg" alt="">
## 异或门和半加器
我们看到的基础门电路输入都是两个单独的bit输出是一个单独的bit。如果我们要对2个8 位bit的数计算与、或、非这样的简单逻辑运算其实很容易。只要连续摆放8个开关来代表一个8位数。这样的两组开关从左到右上下单个的位开关之间都统一用“与门”或者“或门”连起来就是两个8位数的AND或者OR的运算了。
比起AND或者OR这样的电路外要想实现整数的加法就需要组建稍微复杂一点儿的电路了。
我们先回归一个最简单的8位的无符号整数的加法。这里的“无符号”表示我们并不需要使用补码来表示负数。无论高位是“0”还是“1”这个整数都是一个正数。
我们很直观就可以想到要表示一个8位数的整数简单地用8个bit也就是8个像上一讲的电路开关就好了。那2个8位整数的加法就是2排8个开关。加法得到的结果也是一个8位的整数所以又需要1排8位的开关。要想实现加法我们就要看一下通过什么样的门电路能够连接起加数和被加数得到最后期望的和。
<img src="https://static001.geekbang.org/resource/image/28/66/281879883d285478b7771f576f4b3066.jpg" alt="">
要做到这一点,我们先来看看,我们人在计算加法的时候一般会怎么操作。二进制的加法和十进制没什么区别,所以我们一样可以用**列竖式**来计算。我们仍然是从右到左一位一位进行计算只是把从逢10进1变成逢2进1。
<img src="https://static001.geekbang.org/resource/image/18/d1/1854b98fcac2c6bf4949ac5e2247d9d1.jpg" alt="">
你会发现其实计算一位数的加法很简单。我们先就看最简单的个位数。输入一共是4种组合00、01、10、11。得到的结果也不复杂。
一方面我们需要知道加法计算之后的个位是什么在输入的两位是00和11的情况下对应的输出都应该是0在输入的两位是10和01的情况下输出都是1。结果你会发现这个输入和输出的对应关系其实就是我在上一讲留给你的思考题里面的“异或门XOR”。
讲与、或、非门的时候我们很容易就能和程序里面的“AND通常是&amp;符号)”“ OR通常是 | 符号)”和“ NOT通常是 !符号”对应起来。可能你没有想过为什么我们会需要“异或XOR这样一个在逻辑运算里面没有出现的形式作为一个基本电路。**其实,异或门就是一个最简单的整数加法,所需要使用的基本门电路**。
算完个位的输出还不算完输入的两位都是11的时候我们还需要向更左侧的一位进行进位。那这个就对应一个与门也就是有且只有在加数和被加数都是1的时候我们的进位才会是1。
所以,通过一个异或门计算出个位,通过一个与门计算出是否进位,我们就通过电路算出了一个一位数的加法。于是,**我们把两个门电路打包,给它取一个名字,就叫作半加器**Half Adder
<img src="https://static001.geekbang.org/resource/image/58/1e/5860fd8c4ace079b40e66b9568d2b81e.jpg" alt="">
## 全加器
你肯定很奇怪为什么我们给这样的电路组合取名叫半加器Half Adder莫非还有一个全加器Full Adder你猜得没错。半加器可以解决个位的加法问题但是如果放到二位上来说就不够用了。我们这里的竖式是个二进制的加法所以如果从右往左数第二列不是十位我称之为“二位”。对应的再往左就应该分别是四位、八位。
二位用一个半加器不能计算完成的原因也很简单。因为二位除了一个加数和被加数之外还需要加上来自个位的进位信号一共需要三个数进行相加才能得到结果。但是我们目前用到的无论是最简单的门电路还是用两个门电路组合而成的半加器输入都只能是两个bit也就是两个开关。那我们该怎么办呢
实际上,解决方案也并不复杂。**我们用两个半加器和一个或门,就能组合成一个全加器**。第一个半加器我们用和个位的加法一样的方式得到是否进位X和对应的二个数加和后的结果Y这样两个输出。然后我们把这个加和后的结果Y和个位数相加后输出的进位信息U再连接到一个半加器上就会再拿到一个是否进位的信号V和对应的加和后的结果W。
<img src="https://static001.geekbang.org/resource/image/3f/2a/3f11f278ba8f24209a56fb3ee1ca9e2a.jpg" alt="">
这个W就是我们在二位上留下的结果。我们把两个半加器的进位输出作为一个或门的输入连接起来只要两次加法中任何一次需要进位那么在二位上我们就会向左侧的四位进一位。因为一共只有三个bit相加即使3个bit都是1也最多会进一位。
这样,通过两个半加器和一个或门,我们就得到了一个,能够接受进位信号、加数和被加数,这样三个数组成的加法。这就是我们需要的全加器。
有了全加器我们要进行对应的两个8 bit数的加法就很容易了。我们只要把8个全加器串联起来就好了。个位的全加器的进位信号作为二位全加器的输入信号二位全加器的进位信号再作为四位的全加器的进位信号。这样一层层串接八层我们就得到了一个支持8位数加法的算术单元。如果要扩展到16位、32位乃至64位都只需要多串联几个输入位和全加器就好了。
<img src="https://static001.geekbang.org/resource/image/68/a1/68cd38910f526c149d232720b82b6ca1.jpeg" alt="">
唯一需要注意的是对于这个全加器在个位我们只需要用一个半加器或者让全加器的进位输入始终是0。因为个位没有来自更右侧的进位。而最左侧的一位输出的进位信号表示的并不是再进一位而是表示我们的加法是否溢出了。
这也是很有意思的一点。以前我自己在了解二进制加法的时候一直有这么个疑问既然int这样的16位的整数加法结果也是16位数那我们怎么知道加法最终是否溢出了呢因为结果也只存得下加法结果的16位数。我们并没有留下一个第17位来记录这个加法的结果是否溢出。
看到全加器的电路设计,相信你应该明白,在整个加法器的结果中,我们其实有一个电路的信号,会标识出加法的结果是否溢出。我们可以把这个对应的信号,输出给到硬件中其他标志位里,让我们的计算机知道计算的结果是否溢出。而现代计算机也正是这样做的。这就是为什么你在撰写程序的时候,能够知道你的计算结果是否溢出在硬件层面得到的支持。
## 总结延伸
相信到这里,你应该已经体会到了,通过门电路来搭建算术计算的一个小功能,就好像搭乐高积木一样。
我们用两个门电路,搭出一个半加器,就好像我们拿两块乐高,叠在一起,变成一个长方形的乐高,这样我们就有了一个新的积木组件,柱子。我们再用两个柱子和一个长条的积木组合一下,就变成一个积木桥。然后几个积木桥串接在一起,又成了积木楼梯。
当我们想要搭建一个摩天大楼,我们需要很多很多楼梯。但是这个时候,我们已经不再关注最基础的一节楼梯是怎么用一块块积木搭建起来的。这其实就是计算机中,无论软件还是硬件中一个很重要的设计思想,**分层**。
<img src="https://static001.geekbang.org/resource/image/8a/94/8a7740f698236fda4e5f900d88fdf194.jpg" alt="">
从简单到复杂,我们一层层搭出了拥有更强能力的功能组件。在上面的一层,我们只需要考虑怎么用下一层的组件搭建出自己的功能,而不需要下沉到更低层的其他组件。就像你之前并没有深入学习过计算机组成原理,一样可以直接通过高级语言撰写代码,实现功能。
在硬件层面我们通过门电路、半加器、全加器一层层搭出了加法器这样的功能组件。我们把这些用来做算术逻辑计算的组件叫作ALU也就是算术逻辑单元。当进一步打造强大的CPU时我们不会再去关注最细颗粒的门电路只需要把门电路组合而成的ALU当成一个能够完成基础计算的黑盒子就可以了。
以此类推后面我们讲解CPU的设计和数据通路的时候我们以ALU为一个基础单元来解释问题也就够了。
## 补充阅读
出于性能考虑实际CPU里面使用的加法器比起我们今天讲解的电路还有些差别会更复杂一些。真实的加法器使用的是一种叫作**超前进位加法器**的东西。你可以找到北京大学在Coursera上开设的《计算机组成》课程中的Video-306 “加法器优化”一节,了解一下超前进位加法器的实现原理,以及我们为什么要使用它。
## 课后思考
这一讲,我给你详细讲解了无符号数的加法器是怎么通过电路搭建出来的。那么,如果是使用补码表示的有符号数,这个加法器是否可以实现正数加负数这样的运算呢?如果不行,我们应该怎么搭建对应的电路呢?
欢迎你在留言区写下你的思考和疑问,和大家一起探讨。你也可以把今天的文章分享给你朋友,和他一起学习和进步。

View File

@@ -0,0 +1,99 @@
<audio id="audio" title="14 | 乘法器:如何像搭乐高一样搭电路(下)?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9f/01/9f3414356b443bc6f0ef76a5b8a07f01.mp3"></audio>
和学习小学数学一样学完了加法之后我们自然而然就要来学习乘法。既然是退回到小学我们就把问题搞得简单一点先来看两个4位数的乘法。这里的4位数当然还是一个二进制数。我们是人类而不是电路自然还是用列竖式的方式来进行计算。
十进制中的13乘以9计算的结果应该是117。我们通过转换成二进制然后列竖式的办法来看看整个计算的过程是怎样的。
<img src="https://static001.geekbang.org/resource/image/49/4b/498fdfa2dc95631068d65e0ff5769c4b.jpg" alt="">
## 顺序乘法的实现过程
从列出竖式的过程中你会发现二进制的乘法有个很大的优点就是这个过程你不需要背九九乘法口诀表了。因为单个位置上乘数只能是0或者1所以实际的乘法就退化成了位移和加法。
在13×9这个例子里面被乘数13表示成二进制是1101乘数9在二进制里面是1001。最右边的个位是1所以个位乘以被乘数就是把被乘数1101复制下来。因为二位和四位都是0所以乘以被乘数都是0那么保留下来的都是0000。乘数的八位是1我们仍然需要把被乘数1101复制下来。不过这里和个位位置的单纯复制有一点小小的差别那就是要把复制好的结果向左侧移三位然后把四位单独进行乘法加位移的结果再加起来我们就得到了最终的计算结果。
对应到我们之前讲的数字电路和ALU你可以看到最后一步的加法我们可以用上一讲的加法器来实现。乘法因为只有“0”和“1”两种情况所以可以做成输入输出都是4个开关中间用1个开关同时来控制这8个开关的方式这就实现了二进制下的单位的乘法。
<img src="https://static001.geekbang.org/resource/image/02/9c/02ae32716bc3bf165d177dfe80d2c09c.jpg" alt="">
至于位移也不麻烦,我们只要不是直接连线,把正对着的开关之间进行接通,而是斜着错开位置去接就好了。如果要左移一位,就错开一位接线;如果要左移两位,就错开两位接线。
<img src="https://static001.geekbang.org/resource/image/e4/95/e4c7ddb75731030930d38adf967b2d95.jpg" alt="">
这样你会发现我们并不需要引入任何新的、更复杂的电路仍然用最基础的电路只要用不同的接线方式就能够实现一个“列竖式”的乘法。而且因为二进制下只有0和1也就是开关的开和闭这两种情况所以我们的计算机也不需要去“背诵”九九乘法口诀表不需要单独实现一个更复杂的电路就能够实现乘法。
为了节约一点开关也就是晶体管的数量。实际上像13×9这样两个四位数的乘法我们不需要把四次单位乘法的结果用四组独立的开关单独都记录下来然后再把这四个数加起来。因为这样做需要很多组开关如果我们计算一个32位的整数乘法就要32组开关太浪费晶体管了。如果我们顺序地来计算只需要一组开关就好了。
我们先拿乘数最右侧的个位乘以被乘数,然后把结果写入用来存放计算结果的开关里面,然后,把被乘数左移一位,把乘数右移一位,仍然用乘数去乘以被乘数,然后把结果加到刚才的结果上。反复重复这一步骤,直到不能再左移和右移位置。这样,乘数和被乘数就像两列相向而驶的列车,仅仅需要简单的加法器、一个可以左移一位的电路和一个右移一位的电路,就能完成整个乘法。
<img src="https://static001.geekbang.org/resource/image/cb/e9/cb809de19088d08767279715f07482e9.jpg" alt="">
你看这里画的乘法器硬件结构示意图。这里的控制测试其实就是通过一个时钟信号来控制左移、右移以及重新计算乘法和加法的时机。我们还是以计算13×9也就是二进制的1101×1001来具体看。
<img src="https://static001.geekbang.org/resource/image/06/71/0615e5e4406617ee6584adbb929f9571.jpeg" alt="">
这个计算方式虽然节约电路了,但是也有一个很大的缺点,那就是慢。
你应该很容易就能发现,在这个乘法器的实现过程里,我们其实就是把乘法展开,变成了“**加法+位移**”来实现。我们用的是4位数所以要进行4组“位移+加法”的操作。而且这4组操作还不能同时进行。因为**下一组的加法要依赖上一组的加法后的计算结果,下一组的位移也要依赖上一组的位移的结果。这样,整个算法是“顺序”的,每一组加法或者位移的运算都需要一定的时间**。
所以最终这个乘法的计算速度其实和我们要计算的数的位数有关。比如这里的4位就需要4次加法。而我们的现代CPU常常要用32位或者是64位来表示整数那么对应就需要32次或者64次加法。比起4位数要多花上8倍乃至16倍的时间。
换个我们在算法和数据结构中的术语来说就是,这样的一个顺序乘法器硬件进行计算的时间复杂度是 O(N)。这里的N就是乘法的数里面的**位数**。
## 并行加速方法
那么我们有没有办法把时间复杂度上降下来呢研究数据结构和算法的时候我们总是希望能够把O(N)的时间复杂度降低到O(logN)。办法还真的有。和软件开发里面改算法一样在涉及CPU和电路的时候我们可以改电路。
32位数虽然是32次加法但是我们可以让很多加法同时进行。回到这一讲开始我们把位移和乘法的计算结果加到中间结果里的方法32位整数的乘法其实就变成了32个整数相加。
前面顺序乘法器硬件的实现办法,就好像体育比赛里面的**单败淘汰赛**。只有一个擂台会存下最新的计算结果。每一场新的比赛就来一个新的选手实现一次加法实现完了剩下的还是原来那个守擂的直到其余31个选手都上来比过一场。如果一场比赛需要一天那么一共要比31场也就是31天。
<img src="https://static001.geekbang.org/resource/image/07/ef/07f7b0eedbf1a00fc72be7e2bd0d96ef.jpg" alt="">
加速的办法就是把比赛变成像世界杯足球赛那样的淘汰赛32个球队捉对厮杀同时开赛。这样一天一下子就淘汰了16支队也就是说32个数两两相加后你可以得到16个结果。后面的比赛也是一样同时开赛捉对厮杀。只需要5天也就是O(log<sub>2</sub>N)的时间就能得到计算的结果。但是这种方式要求我们得有16个球场。因为在淘汰赛的第一轮我们需要16场比赛同时进行。对应到我们CPU的硬件上就是需要更多的晶体管开关来放下中间计算结果。
<img src="https://static001.geekbang.org/resource/image/66/98/6646b90ea563c6b87dc20bbd81c54b98.jpeg" alt="">
## 电路并行
上面我们说的并行加速的办法,看起来还是有点儿笨。我们回头来做一个抽象的思考。之所以我们的计算会慢,核心原因其实是“顺序”计算,也就是说,要等前面的计算结果完成之后,我们才能得到后面的计算结果。
最典型的例子就是我们上一讲讲的加法器。每一个全加器,都要等待上一个全加器,把对应的进入输入结果算出来,才能算下一位的输出。位数越多,越往高位走,等待前面的步骤就越多,这个等待的时间有个专门的名词,叫作**门延迟**Gate Delay
每通过一个门电路我们就要等待门电路的计算结果就是一层的门电路延迟我们一般给它取一个“T”作为符号。一个全加器其实就已经有了3T的延迟进位需要经过3个门电路。而4位整数最高位的计算需要等待前面三个全加器的进位结果也就是要等9T的延迟。如果是64位整数那就要变成63×3=189T的延迟。这可不是个小数字啊
除了门延迟之外,还有一个问题就是**时钟频率**。在上面的顺序乘法计算里面,如果我们想要用更少的电路,计算的中间结果需要保存在寄存器里面,然后等待下一个时钟周期的到来,控制测试信号才能进行下一次移位和加法,这个延迟比上面的门延迟更可观。
那么,我们有什么办法可以解决这个问题呢?实际上,在我们进行加法的时候,如果相加的两个数是确定的,那高位是否会进位其实也是确定的。对于我们人来说,我们本身去做计算都是顺序执行的,所以要一步一步计算进位。但是,计算机是连结的各种线路。我们不用让计算机模拟人脑的思考方式,来连结线路。
那怎么才能把线路连结得复杂一点,让高位和低位的计算同时出结果呢?怎样才能让高位不需要等待低位的进位结果,而是把低位的所有输入信号都放进来,直接计算出高位的计算结果和进位结果呢?
我们只要把进位部分的电路完全展开就好了。我们的半加器到全加器,再到加法器,都是用最基础的门电路组合而成的。门电路的计算逻辑,可以像我们做数学里面的多项式乘法一样完全展开。在展开之后呢,我们可以把原来需要较少的,但是有较多层前后计算依赖关系的门电路,展开成需要较多的,但是依赖关系更少的门电路。
我在这里画了一个示意图,展示了一下我们加法器。如果我们完全展开电路,高位的进位和计算结果,可以和低位的计算结果同时获得。这个的核心原因是电路是天然并行的,一个输入信号,可以同时传播到所有接通的线路当中。
<img src="https://static001.geekbang.org/resource/image/0c/69/0c2c69f9bbd1d8eca36f560cbe092169.jpg" alt="">
如果一个4位整数最高位是否进位展开门电路图你会发现我们只需要3T的延迟就可以拿到是否进位的计算结果。而对于64位的整数也不会增加门延迟只是从上往下复制这个电路接入更多的信号而已。看到没我们通过把电路变复杂就解决了延迟的问题。
这个优化,本质上是利用了电路天然的并行性。电路只要接通,输入的信号自动传播到了所有接通的线路里面,这其实也是硬件和软件最大的不同。
无论是这里把对应的门电路逻辑进行完全展开以减少门延迟,还是上面的乘法通过并行计算多个位的乘法,都是把我们完成一个计算的电路变复杂了。而电路变复杂了,也就意味着晶体管变多了。
之前很多同学在我们讨论计算机的性能问题的时候,都提到,为什么晶体管的数量增加可以优化计算机的计算性能。实际上,这里的门电路展开和上面的并行计算乘法都是很好的例子。我们通过更多的晶体管,就可以拿到更低的门延迟,以及用更少的时钟周期完成一个计算指令。
## 总结延伸
讲到这里相信你已经发现我们通过之前两讲的ALU和门电路搭建出来了乘法器。如果愿意的话我们可以把很多在生活中不得不顺序执行的事情通过简单地连结一下线路就变成并行执行了。这是因为硬件电路有一个很大的特点那就是信号都是实时传输的。
我们也看到了通过精巧地设计电路用较少的门电路和寄存器就能够计算完成乘法这样相对复杂的运算。是用更少更简单的电路但是需要更长的门延迟和时钟周期还是用更复杂的电路但是更短的门延迟和时钟周期来计算一个复杂的指令这之间的权衡其实就是计算机体系结构中RISC和CISC的经典历史路线之争。
## 推荐阅读
如果还有什么细节你觉得还没有彻底弄明白,我推荐你看一看《计算机组成与设计:硬件/软件接口》的3.3节。
## 课后思考
这一讲里,我为你讲解了乘法器是怎么实现的。那么,请你想一想,如果我们想要用电路实现一个除法器,应该怎么做呢?需要注意一下,除法器除了要计算除法的商之外,还要计算出对应的余数。
欢迎你在留言区写下你的思考和疑问,和大家一起探讨。你也可以把今天的文章分享给你朋友,和他一起学习和进步。

View File

@@ -0,0 +1,101 @@
<audio id="audio" title="15 | 浮点数和定点数怎么用有限的Bit表示尽可能多的信息" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2a/95/2a8a2c58a1258d3ca597e5b75258d395.mp3"></audio>
在我们日常的程序开发中不只会用到整数。更多情况下我们用到的都是实数。比如我们开发一个电商App商品的价格常常会是9块9再比如现在流行的深度学习算法对应的机器学习里的模型里的各个权重也都是1.23这样的数。可以说,在实际的应用过程中,这些有零有整的实数,是和整数同样常用的数据类型,我们也需要考虑到。
## 浮点数的不精确性
那么,我们能不能用二进制表示所有的实数,然后在二进制下计算它的加减乘除呢?先不着急,我们从一个有意思的小案例来看。
你可以在Linux下打开Python的命令行Console也可以在Chrome浏览器里面通过开发者工具打开浏览器里的Console在里面输入“0.3 + 0.6”,然后看看你会得到一个什么样的结果。
```
&gt;&gt;&gt; 0.3 + 0.6
0.8999999999999999
```
不知道你有没有大吃一惊这么简单的一个加法无论是在Python还是在JavaScript里面算出来的结果居然不是准确的0.9而是0.8999999999999999这么个结果。这是为什么呢?
在回答为什么之前我们先来想一个更抽象的问题。通过前面的这么多讲你应该知道我们现在用的计算机通常用16/32个比特bit来表示一个数。那我问你我们用32个比特能够表示所有实数吗
答案很显然是不能。32个比特只能表示2的32次方个不同的数差不多是40亿个。如果表示的数要超过这个数就会有两个不同的数的二进制表示是一样的。那计算机可就会一筹莫展不知道这个数到底是多少。
40亿个数看似已经很多了但是比起无限多的实数集合却只是沧海一粟。所以这个时候计算机的设计者们就要面临一个问题了我到底应该让这40亿个数映射到实数集合上的哪些数在实际应用中才能最划得来呢
## 定点数的表示
有一个很直观的想法就是我们用4个比特来表示09的整数那么32个比特就可以表示8个这样的整数。然后我们把最右边的2个09的整数当成小数部分把左边6个09的整数当成整数部分。这样我们就可以用32个比特来表示从0到999999.99这样1亿个实数了。
<img src="https://static001.geekbang.org/resource/image/f5/b3/f5a0b0f2188ebe0d18f4424578a588b3.jpg" alt="">
这种用二进制来表示十进制的编码方式,叫作[**BCD编码**](https://zh.wikipedia.org/wiki/%E4%BA%8C%E9%80%B2%E7%A2%BC%E5%8D%81%E9%80%B2%E6%95%B8)Binary-Coded Decimal。其实它的运用非常广泛最常用的是在超市、银行这样需要用小数记录金额的情况里。在超市里面我们的小数最多也就到分。这样的表示方式比较直观清楚也满足了小数部分的计算。
不过,这样的表示方式也有几个缺点。
**第一,这样的表示方式有点“浪费”。**本来32个比特我们可以表示40亿个不同的数但是在BCD编码下只能表示1亿个数如果我们要精确到分的话那么能够表示的最大金额也就是到100万。如果我们的货币单位是人民币或者美元还好如果我们的货币单位变成了津巴布韦币这个数量就不太够用了。
**第二,这样的表示方式没办法同时表示很大的数字和很小的数字。**我们在写程序的时候实数的用途可能是多种多样的。有时候我们想要表示商品的金额关心的是9.99这样小的数字;有时候,我们又要进行物理学的运算,需要表示光速,也就是$3×10^8$这样很大的数字。那么,我们有没有一个办法,既能够表示很小的数,又能表示很大的数呢?
## 浮点数的表示
答案当然是有的,就是你可能经常听说过的**浮点数**Floating Point也就是**float类型**。
我们先来想一想。如果我们想在一张便签纸上用一行来写一个十进制数能够写下多大范围的数因为我们要让人能够看清楚所以字最小也有一个限制。你会发现一个和上面我们用BCD编码表示数一样的问题就是纸张的宽度限制了我们能够表示的数的大小。如果宽度只放得下8个数字那么我们还是只能写下最大到99999999这样的数字。
<img src="https://static001.geekbang.org/resource/image/c3/56/c321a0b9d95ba475439f9fbdff07bf56.png" alt="">
其实这里的纸张宽度就和我们32个比特一样是在空间层面的限制。那么在现实生活中我们是怎么表示一个很大的数的呢比如说我们想要在一本科普书里写一下宇宙内原子的数量莫非是用一页纸用好多行写下很多个0么
当然不是了,我们会用科学计数法来表示这个数字。宇宙内的原子的数量,大概在 10的82次方左右我们就用$1.0×10^82$这样的形式来表示这个数值不需要写下82个0。
在计算机里,我们也可以用一样的办法,用科学计数法来表示实数。浮点数的科学计数法的表示,有一个**IEEE**的标准它定义了两个基本的格式。一个是用32比特表示单精度的浮点数也就是我们常常说的float或者float32类型。另外一个是用64比特表示双精度的浮点数也就是我们平时说的double或者float64类型。
双精度类型和单精度类型差不多,这里,我们来看单精度类型,双精度你自然也就明白了。
<img src="https://static001.geekbang.org/resource/image/91/41/914b71bf1d85fb6ed76e1135f39b6941.jpg" alt="">
单精度的32个比特可以分成三部分。
第一部分是一个**符号位**,用来表示是正数还是负数。我们一般用**s**来表示。在浮点数里,我们不像正数分符号数还是无符号数,所有的浮点数都是有符号的。
接下来是一个8个比特组成的**指数位**。我们一般用**e**来表示。8个比特能够表示的整数空间就是0255。我们在这里用1254映射到-126127这254个有正有负的数上。因为我们的浮点数不仅仅想要表示很大的数还希望能够表示很小的数所以指数位也会有负数。
你发现没我们没有用到0和255。没错这里的 0也就是8个比特全部为0 和 255 也就是8个比特全部为1另有它用我们等一下再讲。
最后是一个23个比特组成的**有效数位**。我们用**f**来表示。综合科学计数法,我们的浮点数就可以表示成下面这样:
$(-1)^s×1.f×2^e$
你会发现这里的浮点数没有办法表示0。的确要表示0和一些特殊的数我们就要用上在e里面留下的0和255这两个表示这两个表示其实是两个标记位。在e为0且f为0的时候我们就把这个浮点数认为是0。至于其它的e是0或者255的特殊情况你可以看下面这个表格分别可以表示出无穷大、无穷小、NAN以及一个特殊的不规范数。
<img src="https://static001.geekbang.org/resource/image/f9/4c/f922249a89667c4d10239eb8840dc94c.jpg" alt="">
我们可以以0.5为例子。0.5的符号为s应该是0f应该是0而e应该是-1也就是
$0.5= (-1)^0×1.0×2^{-1}=0.5$对应的浮点数表示就是32个比特。
<img src="https://static001.geekbang.org/resource/image/51/50/5168fce3f313f4fc0b600ce5d1805c50.jpeg" alt="">
$s=0e = 2^{-1}$需要注意e表示从-126到127个-1是其中的第126个数这里的e如果用整数表示就是$2^6+2^5+2^4+2^3+2^2+2^1=126$$1.f=1.0$。
在这样的浮点数表示下,不考虑符号的话,浮点数能够表示的最小的数和最大的数,差不多是$1.17×10^{-38}$和$3.40×10^{38}$。比前面的BCD编码能够表示的范围大多了。
## 总结延伸
你会看到在这样的表示方式下浮点数能够表示的数据范围一下子大了很多。正是因为这个数对应的小数点的位置是“浮动”的它才被称为浮点数。随着指数位e的值的不同小数点的位置也在变动。对应的前面的BCD编码的实数就是小数点固定在某一位的方式我们也就把它称为**定点数**。
回到我们最开头为什么我们用0.3 + 0.6不能得到0.9呢这是因为浮点数没有办法精确表示0.3、0.6和0.9。事实上我们拿出0.10.9这9个数其中只有0.5能够被精确地表示成二进制的浮点数也就是s = 0、e = -1、f = 0这样的情况。
而0.3、0.6乃至我们希望的0.9,都只是一个近似的表达。这个也为我们带来了一个挑战,就是浮点数无论是表示还是计算其实都是近似计算。那么,在使用过程中,我们该怎么来使用浮点数,以及使用浮点数会遇到些什么问题呢?下一讲,我会用更多的实际代码案例,来带你看看浮点数计算中的各种“坑”。
## 推荐阅读
如果对浮点数的表示还不是很清楚,你可以仔细阅读一下《计算机组成与设计:硬件/软件接口》的3.5.1节。
## 课后思考
对于BCD编码的定点数如果我们用7个比特来表示连续两位十进制数也就是0099是不是可以让32比特表示更大一点的数据范围如果我们还需要表示负数那么一个32比特的BCD编码可以表示的数据范围是多大
欢迎你在留言区写下你的思考和疑问,和大家一起探讨。你也可以把今天的文章分享给你朋友,和他一起学习和进步。

View File

@@ -0,0 +1,172 @@
<audio id="audio" title="16 | 浮点数和定点数(下):深入理解浮点数到底有什么用?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c9/7f/c92842f221bcdca5e0571a540ac9807f.mp3"></audio>
上一讲,我们讲了用“浮点数”这样的数据形式,来表示一个不能确定大小的数据范围。浮点数可以大到$3.40×10^{38}$,也可以小到$1.17×10^{-38}$这样的数值。同时我们也发现其实我们平时写的0.1、0.2并不是精确的数值只是一个近似值。只有0.5这样,可以表示成$2^{-1}$这种形式的,才是一个精确的浮点数。
你是不是感到很疑惑,浮点数的近似值究竟是怎么算出来的?浮点数的加法计算又是怎么回事儿?在实践应用中,我们怎么才用好浮点数呢?这一节,我们就一起来看这几个问题。
## 浮点数的二进制转化
我们首先来看,十进制的浮点数怎么表示成二进制。
我们输入一个任意的十进制浮点数背后都会对应一个二进制表示。比方说我们输入了一个十进制浮点数9.1。那么按照之前的讲解,在二进制里面,我们应该把它变成一个“**符号位s+指数位e+有效位数f**”的组合。第一步,我们要做的,就是把这个数变成二进制。
首先我们把这个数的整数部分变成一个二进制。这个我们前面讲二进制的时候已经讲过了。这里的9换算之后就是1001。
接着我们把对应的小数部分也换算成二进制。小数怎么换成二进制呢我们先来定义一下小数的二进制表示是怎么回事。我们拿0.1001这样一个二进制小数来举例说明。和上面的整数相反我们把小数点后的每一位都表示对应的2的-N次方。那么0.1001,转化成十进制就是:
$1×2^{-1}+0×2^{-2}+0×2^{-3}+$<br>
$1×2^{-4}=0.5625$
和整数的二进制表示采用“除以2然后看余数”的方式相比小数部分转换成二进制是用一个相似的反方向操作就是乘以2然后看看是否超过1。如果超过1我们就记下1并把结果减去1进一步循环操作。在这里我们就会看到0.1其实变成了一个无限循环的二进制小数0.000110011。这里的“0011”会无限循环下去。
<img src="https://static001.geekbang.org/resource/image/f9/ae/f9213c43f5fa658a2192a68cd26435ae.jpg" alt="">
然后我们把整数部分和小数部分拼接在一起9.1这个十进制数就变成了1001.000110011…这样一个二进制表示。
上一讲我们讲过,浮点数其实是用二进制的科学计数法来表示的,所以我们可以把小数点左移三位,这个数就变成了:
$1.0010$$0011$$0011… × 2^3$
那这个二进制的科学计数法表示我们就可以对应到了浮点数的格式里了。这里的符号位s = 0对应的有效位f=0010**0011**0011…。因为f最长只有23位那这里“0011”无限循环最多到23位就截止了。于是f=0010**0011001100110011** **001**。最后的一个“0011”循环中的最后一个“1”会被截断掉。对应的指数为e代表的应该是3。因为指数位有正又有负所以指数位在127之前代表负数之后代表正数那3其实对应的是加上127的偏移量130转化成二进制就是130对应的就是指数位的二进制表示出来就是1000**0010**。
<img src="https://static001.geekbang.org/resource/image/9a/27/9ace5a7404d1790b03d07bd1b3cb5a27.jpeg" alt="">
然后我们把“s+e+f”拼在一起就可以得到浮点数9.1的二进制表示了。最终得到的二进制表示就变成了:
01000**0010** 0010 **0011001100110011** **001**
如果我们再把这个浮点数表示换算成十进制, 实际准确的值是9.09999942779541015625。相信你现在应该不会感觉奇怪了。
我在这里放一个[链接](https://www.h-schmidt.net/FloatConverter/IEEE754.html)这里提供了直接交互式地设置符号位、指数位和有效位数的操作。你可以直观地看到32位浮点数每一个bit的变化对应的有效位数、指数会变成什么样子以及最后的十进制的计算结果是怎样的。
这个也解释了为什么在上一讲一开始0.3+0.6=0.899999。因为0.3转化成浮点数之后和这里的9.1一样并不是精确的0.3了0.6和0.9也是一样的,最后的计算会出现精度问题。
## 浮点数的加法和精度损失
搞清楚了怎么把一个十进制的数值转化成IEEE-754标准下的浮点数表示我们现在来看一看浮点数的加法是怎么进行的。其实原理也很简单你记住六个字就行了那就是**先对齐、再计算**。
两个浮点数的指数位可能是不一样的,所以我们要把两个的指数位,变成一样的,然后只去计算有效位的加法就好了。
比如0.5,表示成浮点数,对应的指数位是-1有效位是00…后面全是0记住f前默认有一个1。0.125表示成浮点数,对应的指数位是-3有效位也还是00…后面全是0记住f前默认有一个1
那我们在计算0.5+0.125的浮点数运算的时候,首先要把两个的指数位对齐,也就是把指数位都统一成两个其中较大的-1。对应的有效位1.00…也要对应右移两位因为f前面有一个默认的1所以就会变成0.01。然后我们计算两者相加的有效位1.f就变成了有效位1.01,而指数位是-1这样就得到了我们想要的加法后的结果。
实现这样一个加法,也只需要位移。和整数加法类似的半加器和全加器的方法就能够实现,在电路层面,也并没有引入太多新的复杂性。
<img src="https://static001.geekbang.org/resource/image/d7/f0/d7a6e87da9c0d0b874980ca4306a55f0.jpg" alt="">
同样的,你可以用刚才那个链接来试试看,我们这个加法计算的浮点数的结果是不是正确。
回到浮点数的加法过程,你会发现,其中指数位较小的数,需要在有效位进行右移,在右移的过程中,最右侧的有效位就被丢弃掉了。这会导致对应的指数位较小的数,在加法发生之前,就**丢失精度**。两个相加数的指数位差的越大位移的位数越大可能丢失的精度也就越大。当然也有可能你的运气非常好右移丢失的有效位都是0。这种情况下对应的加法虽然丢失了需要加的数字的精度但是因为对应的值都是0实际的加法的数值结果不会有精度损失。
32位浮点数的有效位长度一共只有23位如果两个数的指数位差出23位较小的数右移24位之后所有的有效位就都丢失了。这也就意味着虽然浮点数可以表示上到$3.40×10^{38}$,下到$1.17×10^{-38}$这样的数值范围。但是在实际计算的时候,只要两个数,差出$2^{24}$也就是差不多1600万倍那这两个数相加之后结果完全不会变化。
你可以试一下我下面用一个简单的Java程序让一个值为2000万的32位浮点数和1相加你会发现+1这个过程因为精度损失被“完全抛弃”了。
```
public class FloatPrecision {
public static void main(String[] args) {
float a = 20000000.0f;
float b = 1.0f;
float c = a + b;
System.out.println(&quot;c is &quot; + c);
float d = c - a;
System.out.println(&quot;d is &quot; + d);
}
}
```
对应的输出结果就是:
```
c is 2.0E7
d is 0.0
```
## Kahan Summation算法
那么我们有没有什么办法来解决这个精度丢失问题呢虽然我们在计算浮点数的时候常常可以容忍一定的精度损失但是像上面那样如果我们连续加2000万个12000万的数值都会被精度损失丢掉了就会影响我们的计算结果。
一个常见的应用场景是在一些“积少成多”的计算过程中比如在机器学习中我们经常要计算海量样本计算出来的梯度或者loss于是会出现几亿个浮点数的相加。每个浮点数可能都差不多大但是随着累积值的越来越大就会出现“大数吃小数”的情况。
我们可以做一个简单的实验用一个循环相加2000万个1.0f最终的结果会是1600万左右而不是2000万。这是因为加到1600万之后的加法因为精度丢失都没有了。这个代码比起上面的使用2000万来加1.0更具有现实意义。
```
public class FloatPrecision {
public static void main(String[] args) {
float sum = 0.0f;
for (int i = 0; i &lt; 20000000; i++) {
float x = 1.0f;
sum += x;
}
System.out.println(&quot;sum is &quot; + sum);
}
}
```
对应的输出结果是:
```
sum is 1.6777216E7
```
面对这个问题,聪明的计算机科学家们也想出了具体的解决办法。他们发明了一种叫作[Kahan Summation](https://en.wikipedia.org/wiki/Kahan_summation_algorithm)的算法来解决这个问题。算法的对应代码我也放在文稿中了。从中你可以看到同样是2000万个1.0f相加用这种算法我们得到了准确的2000万的结果。
```
public class KahanSummation {
public static void main(String[] args) {
float sum = 0.0f;
float c = 0.0f;
for (int i = 0; i &lt; 20000000; i++) {
float x = 1.0f;
float y = x - c;
float t = sum + y;
c = (t-sum)-y;
sum = t;
}
System.out.println(&quot;sum is &quot; + sum);
}
}
```
对应的输出结果就是:
```
sum is 2.0E7
```
其实这个算法的原理其实并不复杂,就是在每次的计算过程中,都用一次减法,把当前加法计算中损失的精度记录下来,然后在后面的循环中,把这个精度损失放在要加的小数上,再做一次运算。
如果你对这个背后的数学原理特别感兴趣,可以去看一看[Wikipedia链接](https://en.wikipedia.org/wiki/Kahan_summation_algorithm)里面对应的数学证明,也可以生成一些数据试一试这个算法。这个方法在实际的数值计算中也是常用的,也是大量数据累加中,解决浮点数精度带来的“大数吃小数”问题的必备方案。
## 总结延伸
到这里,我们已经讲完了浮点数的表示、加法计算以及可能会遇到的精度损失问题。可以看到,虽然浮点数能够表示的数据范围变大了很多,但是在实际应用的时候,由于存在精度损失,会导致加法的结果和我们的预期不同,乃至于完全没有加上的情况。
所以,一般情况下,在实践应用中,对于需要精确数值的,比如银行存款、电商交易,我们都会使用定点数或者整数类型。
比方说你一定在MySQL里用过decimal(12,2)来表示订单金额。如果我们的银行存款用32位浮点数表示就会出现马云的账户里有2千万我的账户里只剩1块钱。结果银行一汇总总金额那1块钱在账上就“不翼而飞”了。
而浮点数呢则更适合我们不需要有一个非常精确的计算结果的情况。因为在真实的物理世界里很多数值本来就不是精确的我们只需要有限范围内的精度就好了。比如从我家到办公室的距离就不存在一个100%精确的值。我们可以精确到公里、米,甚至厘米,但是既没有必要、也没有可能去精确到微米乃至纳米。
对于浮点数加法中可能存在的精度损失特别是大量加法运算中累积产生的巨大精度损失我们可以用Kahan Summation这样的软件层面的算法来解决。
好了到了这里我已经把浮点数讲透了。希望你能从数据的表示、加法的实现乃至实践应用、数值算法层面能够体会到搞清楚一个计算机问题的基本原理其实能够帮助你理解它的实践应用乃至找到在特定问题下的可行解决方案。接下来我们要深入到CPU的构造去理解计算机组成原理。
## 推荐阅读
浮点数的加法我们讲完了。想要更深入地了解乘法乃至除法,可以参看《计算机组成与设计 硬件/软件接口》的3.5.2和3.5.3小节。
## 课后思考
这两节我讲的都是32位浮点数那么对于64位浮点数的加法两个数相差多少的情况后较小的哪个数在加法过程中会完全丢失呢
欢迎你在留言区写下你的思考和疑问,和大家一起探讨。你也可以把今天的文章分享给你朋友,和他一起学习和进步。