mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-19 07:33:47 +08:00
mod
This commit is contained in:
170
极客时间专栏/深入浅出计算机组成原理/原理篇:指令和运算/05 | 计算机指令:让我们试试用纸带编程.md
Normal file
170
极客时间专栏/深入浅出计算机组成原理/原理篇:指令和运算/05 | 计算机指令:让我们试试用纸带编程.md
Normal 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操作系统上跑起来,我们需要把整个程序翻译成一个**汇编语言**(ASM,Assembly 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 <main>:
|
||||
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
|
||||
|
||||
```
|
||||
|
||||
这个时候你可能又要问了,我们实际在用GCC(GUC编译器套装,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是0,rs代表第一个寄存器s1的地址是17,rt代表第二个寄存器s2的地址是18,rd代表目标的临时寄存器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把这个的汇编代码和机器码打出来。
|
||||
|
||||
欢迎你在留言区写下你的思考和疑问,你也可以把今天的文章分享给你朋友,和他一起学习和进步。
|
||||
170
极客时间专栏/深入浅出计算机组成原理/原理篇:指令和运算/06 | 指令跳转:原来if...else就是goto.md
Normal file
170
极客时间专栏/深入浅出计算机组成原理/原理篇:指令和运算/06 | 指令跳转:原来if...else就是goto.md
Normal 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 <time.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
|
||||
int main()
|
||||
{
|
||||
srand(time(NULL));
|
||||
int r = rand() % 2;
|
||||
int a = 10;
|
||||
if (r == 0)
|
||||
{
|
||||
a = 1;
|
||||
} else {
|
||||
a = 2;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们用rand生成了一个随机数r,r要么是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 <main+0x4a>
|
||||
{
|
||||
a = 1;
|
||||
41: c7 45 f8 01 00 00 00 mov DWORD PTR [rbp-0x8],0x1
|
||||
48: eb 07 jmp 51 <main+0x51>
|
||||
}
|
||||
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,就把**零标志条件码**(对应的条件码是ZF,Zero Flag)设置为1。除了零标志之外,Intel的CPU下还有**进位标志**(CF,Carry Flag)、**符号标志**(SF,Sign Flag)以及**溢出标志**(OF,Overflow 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 < 3; i++)
|
||||
{
|
||||
a += i;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们再看一段简单的利用for循环的程序。我们循环自增变量i三次,三次之后,i>=3,就会跳出循环。整个程序,对应的Intel汇编代码就是这样的:
|
||||
|
||||
```
|
||||
for (int i = 0; i <= 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语言程序,编译成汇编代码看一看。
|
||||
|
||||
欢迎留言和我分享你的思考和疑惑,你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
|
||||
215
极客时间专栏/深入浅出计算机组成原理/原理篇:指令和运算/07 | 函数调用:为什么会发生stack overflow?.md
Normal file
215
极客时间专栏/深入浅出计算机组成原理/原理篇:指令和运算/07 | 函数调用:为什么会发生stack overflow?.md
Normal 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 <stdio.h>
|
||||
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 <main>:
|
||||
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 <add>
|
||||
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还可以调用函数B,B还能调用函数C。这一层又一层的调用并没有数量上的限制。在所有函数调用返回之前,每一次调用的返回地址都要记录下来,但是我们CPU里的寄存器数量并不多。像我们一般使用的Intel i7 CPU只有16个64位寄存器,调用的层数一多就存不下了。
|
||||
|
||||
最终,计算机科学家们想到了一个比单独记录跳转回来的地址更完善的办法。我们在内存里面开辟一段空间,用栈这个**后进先出**(LIFO,Last 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函数入口在0~1行,add函数结束之后在12~13行。
|
||||
|
||||
我们在调用第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 <stdio.h>
|
||||
#include <time.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
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("u = %d\n", 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的指令集。
|
||||
|
||||
## 课后思考
|
||||
|
||||
在程序栈里面,除了我们跳转前的指令地址外,还需要保留哪些信息,才能在我们在函数调用完成之后,跳转回到指令地址的时候,继续执行完函数调用之后的指令呢?
|
||||
|
||||
你可以想一想,查一查,然后在留言区留下你的思考和答案,也欢迎你把今天的内容分享给你的朋友,和他一起思考和进步。
|
||||
|
||||
|
||||
@@ -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 <stdio.h>
|
||||
int main()
|
||||
{
|
||||
int a = 10;
|
||||
int b = 5;
|
||||
int c = add(a, b);
|
||||
printf("c = %d\n", 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 <add>:
|
||||
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 <main>:
|
||||
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 <main+0x2a>
|
||||
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 <main+0x39>
|
||||
39: b8 00 00 00 00 mov eax,0x0
|
||||
3e: e8 00 00 00 00 call 43 <main+0x43>
|
||||
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 <main>:
|
||||
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 <add>
|
||||
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 <_IO_stdin_used+0x4>
|
||||
6fd: b8 00 00 00 00 mov eax,0x0
|
||||
702: e8 59 fe ff ff call 560 <printf@plt>
|
||||
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格式,我推荐你阅读《程序员的自我修养——链接、装载和库》的1~4章。这是一本难得的讲解程序的链接、装载和运行的好书。
|
||||
|
||||
## 课后思考
|
||||
|
||||
你可以通过readelf读取出今天演示程序的符号表,看看符号表里都有哪些信息;然后通过objdump读取出今天演示程序的重定位表,看看里面又有哪些信息。
|
||||
|
||||
欢迎留言和我分享你的思考和疑惑,你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
|
||||
|
||||
|
||||
84
极客时间专栏/深入浅出计算机组成原理/原理篇:指令和运算/09 | 程序装载:“640K内存”真的不够用么?.md
Normal file
84
极客时间专栏/深入浅出计算机组成原理/原理篇:指令和运算/09 | 程序装载:“640K内存”真的不够用么?.md
Normal 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这样使用虚拟机的编程语言里面,我们写的程序是怎么装载到内存里面来的呢?它也和我们讲的一样,是通过内存分页和内存交换的方式加载到内存里面来的么?
|
||||
|
||||
欢迎你在留言区写下你的思考和疑问,和大家一起探讨。你也可以把今天的文章分享给你朋友,和他一起学习和进步。
|
||||
|
||||
|
||||
179
极客时间专栏/深入浅出计算机组成原理/原理篇:指令和运算/10 | 动态链接:程序内部的“共享单车”.md
Normal file
179
极客时间专栏/深入浅出计算机组成原理/原理篇:指令和运算/10 | 动态链接:程序内部的“共享单车”.md
Normal 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 Libary(DLL,动态链接库)。在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 <stdio.h>
|
||||
|
||||
|
||||
void show_me_the_money(int money)
|
||||
{
|
||||
printf("Show me USD %d from lib.c \n", money);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然后,show_me_poor.c 调用了 lib 里面的函数。
|
||||
|
||||
```
|
||||
// show_me_poor.c
|
||||
#include "lib.h"
|
||||
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 <show_me_the_money@plt-0x10>:
|
||||
400540: ff 35 12 05 20 00 push QWORD PTR [rip+0x200512] # 600a58 <_GLOBAL_OFFSET_TABLE_+0x8>
|
||||
400546: ff 25 14 05 20 00 jmp QWORD PTR [rip+0x200514] # 600a60 <_GLOBAL_OFFSET_TABLE_+0x10>
|
||||
40054c: 0f 1f 40 00 nop DWORD PTR [rax+0x0]
|
||||
|
||||
0000000000400550 <show_me_the_money@plt>:
|
||||
400550: ff 25 12 05 20 00 jmp QWORD PTR [rip+0x200512] # 600a68 <_GLOBAL_OFFSET_TABLE_+0x18>
|
||||
400556: 68 00 00 00 00 push 0x0
|
||||
40055b: e9 e0 ff ff ff jmp 400540 <_init+0x28>
|
||||
……
|
||||
0000000000400676 <main>:
|
||||
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 <show_me_the_money@plt>
|
||||
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 <show_me_the_money@plt>
|
||||
|
||||
```
|
||||
|
||||
这里后面有一个@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 <_GLOBAL_OFFSET_TABLE_+0x18>
|
||||
|
||||
```
|
||||
|
||||
在动态链接对应的共享库,我们在共享库的data section里面,保存了一张**全局偏移表**(GOT,Global 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的内存空间也会一下子就用出去了。这个问题在过去计算机的内存较少的时候更加显著。
|
||||
|
||||
通过动态链接这个方式,可以说彻底解决了这个问题。就像共享单车一样,如果仔细经营,是一个很有社会价值的事情,但是如果粗暴地把它变成无限制地复制生产,给每个人造一辆,只会在系统内制造大量无用的垃圾。
|
||||
|
||||
过去的05~09这五讲里,我们已经把程序怎么从源代码变成指令、数据,并装载到内存里面,由CPU一条条执行下去的过程讲完了。希望你能有所收获,对于一个程序是怎么跑起来的,有了一个初步的认识。
|
||||
|
||||
## 推荐阅读
|
||||
|
||||
想要更加深入地了解动态链接,我推荐你可以读一读《程序员的自我修养:链接、装载和库》的第7章,里面深入地讲解了,动态链接里程序内的数据布局和对应数据的加载关系。
|
||||
|
||||
## 课后思考
|
||||
|
||||
像动态链接这样通过修改“地址数据”来进行间接跳转,去调用一开始不能确定位置代码的思路,你在应用开发中使用过吗?
|
||||
|
||||
欢迎你在留言区写下你的思考和疑问,和大家一起探讨。你也可以把今天的文章分享给你朋友,和他一起学习和进步。
|
||||
|
||||
|
||||
@@ -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之间有什么关系。理解了这些,相信以后遇到任何乱码问题,你都能手到擒来了。
|
||||
|
||||
## 理解二进制的“逢二进一”
|
||||
|
||||
二进制和我们平时用的十进制,其实并没有什么本质区别,只是平时我们是“逢十进一”,这里变成了“逢二进一”而已。每一位,相比于十进制下的0~9这十个数字,我们只能用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表示0,1000在这样的情况下表示-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,通过原码和补码是如何进行的?
|
||||
|
||||
欢迎你在留言区写下你的思考和疑问,和大家一起探讨。你也可以把今天的文章分享给你朋友,和他一起学习和进步。
|
||||
@@ -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”的操作,其实就是来自于门电路,叫作组合逻辑电路。
|
||||
|
||||
## 推荐阅读
|
||||
|
||||
《编码:隐匿在计算机软硬件背后的语言》的第6~11章,是一个很好的入门材料,可以帮助你深入理解数字电路,值得你花时间好好读一读。
|
||||
|
||||
## 课后思考
|
||||
|
||||
除了与、或、非之外,还有很多基础的门电路,比如“异或(XOR)门”。你可以想一想,试着搜索一些资料,设计一个异或门的电路。
|
||||
|
||||
欢迎你在留言区写下你的思考和疑问,和大家一起探讨。你也可以把今天的文章分享给你朋友,和他一起学习和进步。
|
||||
89
极客时间专栏/深入浅出计算机组成原理/原理篇:指令和运算/13 | 加法器:如何像搭乐高一样搭电路(上)?.md
Normal file
89
极客时间专栏/深入浅出计算机组成原理/原理篇:指令和运算/13 | 加法器:如何像搭乐高一样搭电路(上)?.md
Normal 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(通常是&符号)”“ 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 “加法器优化”一节,了解一下超前进位加法器的实现原理,以及我们为什么要使用它。
|
||||
|
||||
## 课后思考
|
||||
|
||||
这一讲,我给你详细讲解了无符号数的加法器是怎么通过电路搭建出来的。那么,如果是使用补码表示的有符号数,这个加法器是否可以实现正数加负数这样的运算呢?如果不行,我们应该怎么搭建对应的电路呢?
|
||||
|
||||
欢迎你在留言区写下你的思考和疑问,和大家一起探讨。你也可以把今天的文章分享给你朋友,和他一起学习和进步。
|
||||
|
||||
|
||||
99
极客时间专栏/深入浅出计算机组成原理/原理篇:指令和运算/14 | 乘法器:如何像搭乐高一样搭电路(下)?.md
Normal file
99
极客时间专栏/深入浅出计算机组成原理/原理篇:指令和运算/14 | 乘法器:如何像搭乐高一样搭电路(下)?.md
Normal 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节。
|
||||
|
||||
## 课后思考
|
||||
|
||||
这一讲里,我为你讲解了乘法器是怎么实现的。那么,请你想一想,如果我们想要用电路实现一个除法器,应该怎么做呢?需要注意一下,除法器除了要计算除法的商之外,还要计算出对应的余数。
|
||||
|
||||
欢迎你在留言区写下你的思考和疑问,和大家一起探讨。你也可以把今天的文章分享给你朋友,和他一起学习和进步。
|
||||
@@ -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”,然后看看你会得到一个什么样的结果。
|
||||
|
||||
```
|
||||
>>> 0.3 + 0.6
|
||||
0.8999999999999999
|
||||
|
||||
```
|
||||
|
||||
不知道你有没有大吃一惊,这么简单的一个加法,无论是在Python还是在JavaScript里面,算出来的结果居然不是准确的0.9,而是0.8999999999999999这么个结果。这是为什么呢?
|
||||
|
||||
在回答为什么之前,我们先来想一个更抽象的问题。通过前面的这么多讲,你应该知道我们现在用的计算机通常用16/32个比特(bit)来表示一个数。那我问你,我们用32个比特,能够表示所有实数吗?
|
||||
|
||||
答案很显然是不能。32个比特,只能表示2的32次方个不同的数,差不多是40亿个。如果表示的数要超过这个数,就会有两个不同的数的二进制表示是一样的。那计算机可就会一筹莫展,不知道这个数到底是多少。
|
||||
|
||||
40亿个数看似已经很多了,但是比起无限多的实数集合却只是沧海一粟。所以,这个时候,计算机的设计者们,就要面临一个问题了:我到底应该让这40亿个数映射到实数集合上的哪些数,在实际应用中才能最划得来呢?
|
||||
|
||||
## 定点数的表示
|
||||
|
||||
有一个很直观的想法,就是我们用4个比特来表示0~9的整数,那么32个比特就可以表示8个这样的整数。然后我们把最右边的2个0~9的整数,当成小数部分;把左边6个0~9的整数,当成整数部分。这样,我们就可以用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个比特能够表示的整数空间,就是0~255。我们在这里用1~254映射到-126~127这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应该是0,f应该是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=0,e = 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.1~0.9这9个数,其中只有0.5能够被精确地表示成二进制的浮点数,也就是s = 0、e = -1、f = 0这样的情况。
|
||||
|
||||
而0.3、0.6乃至我们希望的0.9,都只是一个近似的表达。这个也为我们带来了一个挑战,就是浮点数无论是表示还是计算其实都是近似计算。那么,在使用过程中,我们该怎么来使用浮点数,以及使用浮点数会遇到些什么问题呢?下一讲,我会用更多的实际代码案例,来带你看看浮点数计算中的各种“坑”。
|
||||
|
||||
## 推荐阅读
|
||||
|
||||
如果对浮点数的表示还不是很清楚,你可以仔细阅读一下《计算机组成与设计:硬件/软件接口》的3.5.1节。
|
||||
|
||||
## 课后思考
|
||||
|
||||
对于BCD编码的定点数,如果我们用7个比特来表示连续两位十进制数,也就是00~99,是不是可以让32比特表示更大一点的数据范围?如果我们还需要表示负数,那么一个32比特的BCD编码,可以表示的数据范围是多大?
|
||||
|
||||
欢迎你在留言区写下你的思考和疑问,和大家一起探讨。你也可以把今天的文章分享给你朋友,和他一起学习和进步。
|
||||
|
||||
|
||||
172
极客时间专栏/深入浅出计算机组成原理/原理篇:指令和运算/16 | 浮点数和定点数(下):深入理解浮点数到底有什么用?.md
Normal file
172
极客时间专栏/深入浅出计算机组成原理/原理篇:指令和运算/16 | 浮点数和定点数(下):深入理解浮点数到底有什么用?.md
Normal 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("c is " + c);
|
||||
float d = c - a;
|
||||
System.out.println("d is " + d);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
对应的输出结果就是:
|
||||
|
||||
```
|
||||
c is 2.0E7
|
||||
d is 0.0
|
||||
|
||||
```
|
||||
|
||||
## Kahan Summation算法
|
||||
|
||||
那么,我们有没有什么办法来解决这个精度丢失问题呢?虽然我们在计算浮点数的时候,常常可以容忍一定的精度损失,但是像上面那样,如果我们连续加2000万个1,2000万的数值都会被精度损失丢掉了,就会影响我们的计算结果。
|
||||
|
||||
一个常见的应用场景是,在一些“积少成多”的计算过程中,比如在机器学习中,我们经常要计算海量样本计算出来的梯度或者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 < 20000000; i++) {
|
||||
float x = 1.0f;
|
||||
sum += x;
|
||||
}
|
||||
System.out.println("sum is " + 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 < 20000000; i++) {
|
||||
float x = 1.0f;
|
||||
float y = x - c;
|
||||
float t = sum + y;
|
||||
c = (t-sum)-y;
|
||||
sum = t;
|
||||
}
|
||||
System.out.println("sum is " + 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位浮点数的加法,两个数相差多少的情况后,较小的哪个数在加法过程中会完全丢失呢?
|
||||
|
||||
欢迎你在留言区写下你的思考和疑问,和大家一起探讨。你也可以把今天的文章分享给你朋友,和他一起学习和进步。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user