Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 55f65b7

Browse files
committedAug 28, 2019
添加 计算机组成原理 专题
1 parent 305d3ea commit 55f65b7

12 files changed

+2506
-0
lines changed
 

‎README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
- [并发编程](#并发编程)
2828
- [集合框架](#集合框架)
2929
- [职业发展](#职业发展)
30+
- [计算机组成原理](#计算机组成原理)
3031
- [面试系列](#面试系列)
3132

3233
# 公众号
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
![](https://ask.qcloudimg.com/http-save/1752328/g6cdrb45jg.png)
2+
3+
# 1 计算机的基本硬件组成
4+
5+
早期,DIY一台计算机,要先有三大件
6+
7+
- CPU
8+
- 内存
9+
- 主板
10+
11+
## 1.1 CPU
12+
13+
计算机最重要的核心配件,中央处理器(Central Processing Unit)。
14+
15+
计算机的所有“计算”都是由CPU来进行的。
16+
17+
- CPU是一个超级精细的印刷电路版
18+
![](https://ask.qcloudimg.com/http-save/1752328/1ya6ng3q8l.png)
19+
20+
## 1.2 内存(Memory)
21+
22+
你撰写的程序、打开的浏览器、运行的游戏,都要加载到内存里才能运行。
23+
24+
程序读取的数据、计算得到的结果,也都要放在内存里。内存越大,能加载的东西自然也就越多。
25+
26+
内存通常直接可以插在主板上,存放在内存里的程序和数据,需要被CPU读取,CPU计算完之后,还要把数据写回到内存。然而CPU不能直接插到内存上,反之亦然。于是,就带来了最后一个大件——主板(Motherboard)。
27+
28+
- 内存通常直接可以插在主板上
29+
![](https://ask.qcloudimg.com/http-save/1752328/fzz62il1s0.png)
30+
31+
## 1.3 主板
32+
33+
主板是一个有着各种各样,有时候多达数十乃至上百个插槽的配件。
34+
35+
我们的CPU要插在主板上,内存也要插在主板上。
36+
37+
主板的芯片组(Chipset)和总线(Bus)解决了CPU和内存之间如何通信的问题。
38+
39+
- 芯片组控制了数据传输的流转,也就是数据从哪里到哪里的问题
40+
- 总线则是实际数据传输的高速公路。总线速度(Bus Speed)决定了数据能传输得多快。
41+
- 计算机主板上通常有着各种各样的插槽![](https://ask.qcloudimg.com/http-save/1752328/lyress98vj.png)
42+
43+
有了三大件,只要配上**电源**供电,计算机差不多就可以跑起来了。
44+
45+
但是现在还缺少各类输入(Input)/输出(Output)设备,也就是我们常说的**I/O设备**
46+
47+
如果你用的是自己的个人电脑,那显示器肯定必不可少,只有有了显示器我们才能看到计算机输出的各种图像、文字,这也就是所谓的**输出设备**
48+
49+
同样的,鼠标和键盘也都是必不可少的配件。这样我才能输入文本,写下这篇文章。它们也就是所谓的**输入设备**
50+
51+
最后,你自己配的个人计算机,还要配上一个硬盘。这样各种数据才能持久地保存下来。
52+
53+
绝大部分人都会给自己的机器装上一个机箱,配上风扇,解决灰尘和散热的问题。
54+
55+
不过机箱和风扇,算不上是计算机的必备硬件,我们拿个纸板或者外面放个电风扇,也一样能用。
56+
57+
显示器、鼠标、键盘和硬盘这些东西并不是一台计算机必须的部分。
58+
59+
其实只需要有I/O设备,能让我们从计算机里输入和输出信息就可以了。
60+
61+
很多网吧的计算机就没有硬盘,而是直接通过局域网,读写远程网络硬盘里面的数据。
62+
63+
各类云服务器,只要让计算机能通过网络,SSH远程登陆访问就好了,因此也没必要配显示器、鼠标、键盘这些东西。
64+
65+
这样不仅能够节约成本,还更方便维护。
66+
67+
还有一个很特殊的设备,就是**显卡**(Graphics Card)。
68+
69+
现在,使用图形界面操作系统的计算机,无论是Windows、Mac OS还是Linux,显卡都是必不可少的。
70+
71+
有人可能要说了,我装机的时候没有买显卡,计算机一样可以正常跑起来啊!那是因为,现在的主板都带了内置的显卡。
72+
73+
如果你用计算机玩游戏,做图形渲染或者跑深度学习应用,你多半就需要买一张单独的显卡,插在主板上。
74+
75+
显卡之所以特殊,是因为显卡里有除了CPU之外的另一个“处理器”,也就是GPU(Graphics Processing Unit,图形处理器),GPU一样可以做各种“计算”的工作。
76+
77+
鼠标、键盘以及硬盘都是插在主板上的。作为外部I/O设备,它们是通过主板上的**南桥**(SouthBridge)芯片组,来控制和CPU之间的通信的。
78+
79+
“南桥”芯片的名字很直观
80+
81+
- 它在主板上的位置,通常在主板的“南面”
82+
- 它的作用就是作为“桥”,来连接鼠标、键盘以及硬盘这些外部设备和CPU之间的通信。
83+
84+
有了南桥,自然对应着也有“北桥”。
85+
86+
是的,以前的主板上通常也有“北桥”芯片,用来作为“桥”,连接CPU和内存、显卡之间的通信。
87+
88+
不过,随着时间的变迁,现在的主板上的“北桥”芯片的工作,已经被移到了CPU的内部,所以你在主板上,已经看不到北桥芯片了。
89+
90+
# 2 冯·诺依曼体系结构
91+
92+
刚才我们讲了一台计算机的硬件组成,这说的是我们平时用的个人电脑或者服务器。那我们平时最常用的智能手机的组成,也是这样吗?
93+
94+
我们手机里只有SD卡(Secure Digital Memory Card)类似硬盘功能的存储卡插槽,并没有内存插槽、CPU插槽这些东西。
95+
96+
没错,因为手机尺寸的原因,手机制造商们选择把
97+
98+
CPU、内存、网络通信,乃至摄像头芯片,都封装到一个芯片,然后再嵌入到手机主板上。
99+
100+
这种方式叫**SoC**,也就是System on a Chip(系统芯片)。
101+
102+
看起来,个人电脑和智能手机的硬件组成方式不太一样。
103+
104+
可是,我们写智能手机上的App,和写个人电脑的客户端应用似乎没有什么差别,都是通过“高级语言”这样的编程语言撰写、编译之后,一样是把代码和数据加载到内存里来执行。
105+
106+
无论是个人电脑/服务器/智能手机,还是Raspberry Pi这样的微型卡片机,都遵循着同一个“计算机”的抽象概念。
107+
108+
这是怎么样一个“计算机”呢?这其实就是,计算机鼻祖冯·诺依曼提出的**冯·诺依曼体系结构**(Von Neumann architecture),也叫**存储程序计算机**
109+
110+
什么是存储程序计算机呢?这里面其实暗含了两个概念
111+
112+
- “可编程”计算机
113+
- “存储”计算机
114+
115+
什么是“不可编程”???
116+
117+
计算机是由各种门电路组合而成的,然后通过组装出一个固定的电路版,完成一个特定的计算程序。
118+
119+
一旦需要修改功能,就要重新组装电路。这样的话,计算机就是“不可编程”的,因为程序在计算机硬件层面是“写死”的。
120+
121+
最常见的就是老式计算器,电路板设好了加减乘除,做不了任何计算逻辑固定之外的事情。
122+
123+
- 计算器的本质是一个不可编程的计算机
124+
![](https://ask.qcloudimg.com/http-save/1752328/efi2z2i6f1.png)
125+
126+
我们再来看“存储”计算机。
127+
128+
程序本身是存储在计算机的内存里,可以通过加载不同的程序来解决不同的问题。
129+
130+
有“存储程序计算机”,自然也有不能存储程序的计算机。
131+
132+
典型的就是早年的“Plugboard”这样的插线板式的计算机。整个计算机就是一个巨大的插线板,通过在板子上不同的插头或者接口的位置插入线路,来实现不同的功能。这样的计算机自然是“可编程”的,但是编写好的程序不能存储下来供下一次加载使用,不得不每次要用到和当前不同的“程序”的时候,重新插板子,重新“编程”。
133+
134+
- 著名的Engima Machine就用到了Plugboard来进行“编程”
135+
![](https://ask.qcloudimg.com/http-save/1752328/tni9s3p8vf.png)
136+
可以看到,无论是“不可编程”还是“不可存储”,都会让使用计算机的效率大大下降。
137+
而这个对于效率的追求,也就是“存储程序计算机”的由来。
138+
139+
冯,基于当时在秘密开发的EDVAC写了一篇报告First Draft of a Report on the EDVAC,描述了他心目中的一台计算机应该长什么样。这篇报告在历史上有个很特殊的简称,叫First Draft。这样,现代计算机的发展就从祖师爷写的一份草案开始了。
140+
141+
#### First Draft里面说了一台计算机应该有哪些部分组成
142+
143+
首先是一个包含
144+
145+
- 算术逻辑单元(Arithmetic Logic Unit,ALU)
146+
- 处理器寄存器(Processor Register)
147+
148+
**处理器单元**(Processing Unit),用来完成各种算术和逻辑运算。
149+
150+
因为它能够完成各种数据的处理或者计算工作,因此也有人把这个叫作数据通路(Datapath)或者运算器。
151+
152+
然后是一个包含
153+
154+
- 指令寄存器(Instruction Reigster)
155+
- 程序计数器(Program Counter)
156+
157+
**控制器单元**(Control Unit/CU),用来控制程序的流程,通常就是不同条件下的分支和跳转。
158+
159+
在现在的计算机里,上面的算术逻辑单元和这里的控制器单元,共同组成了我们说的CPU。
160+
161+
接着是用来存储数据(Data)和指令(Instruction)的**内存**。以及更大容量的**外部存储**,在过去,可能是磁带、磁鼓这样的设备,现在通常就是硬盘。
162+
163+
最后就是各种**输入和输出设备**,以及对应的输入和输出机制。
164+
165+
我们现在无论是使用什么样的计算机,其实都是和输入输出设备在打交道。
166+
167+
- 个人电脑的鼠标键盘是输入设备,显示器是输出设备
168+
- 我们用的智能手机,触摸屏既是输入设备,又是输出设备
169+
- 跑在各种云上的服务器,则是通过网络来进行输入和输出。这个时候,网卡既是输入设备又是输出设备
170+
171+
> 任何一台计算机的任何一个部件都可以归到运算器、控制器、存储器、输入设备和输出设备中,而所有的现代计算机也都是基于这个基础架构来设计开发的
172+
173+
而所有的计算机程序,也都可以抽象为从**输入设备**读取输入信息,通过**运算器****控制器**来执行存储在**存储器**里的程序,最终把结果输出到**输出设备**中。而我们所有撰写的无论高级还是低级语言的程序,也都是基于这样一个抽象框架来进行运作的。
174+
175+
- 冯·诺依曼体系结构示意图
176+
![](https://ask.qcloudimg.com/http-save/1752328/6u485dctzr.png)
177+
178+
# 3 总结
179+
180+
冯·诺依曼体系结构确立了我们现在每天使用的计算机硬件的基础架构。
181+
182+
因此,学习计算机组成原理,其实就是学习和拆解冯·诺依曼体系结构。
183+
184+
具体来说,其实就是
185+
186+
- 学习控制器、运算器的工作原理,也就是CPU是怎么工作的,以及为何这样设计
187+
- 学习内存的工作原理,从最基本的电路,到上层抽象给到CPU乃至应用程序的接口是怎样的
188+
- 学习CPU是怎么和输入设备、输出设备打交道的。=
189+
190+
学习组成原理,就是在理解从控制器、运算器、存储器、输入设备以及输出设备,从电路这样的硬件,到最终开放给软件的接口,是怎么运作的,为什么要设计成这样,以及在软件开发层面怎么尽可能用好它。
191+
192+
# 4 推荐阅读
193+
194+
- First Draft of a Report on the EDVAC
195+
对于工程师来说,直接读取英文论文的原文,既可以搞清楚、弄明白对应的设计及其背后的思路来源,还可以帮你破除对于论文或者核心技术的恐惧心理。
196+
197+
# 5 思考
198+
199+
计算机行业的两大祖师爷之一,除了冯·诺依曼机之外,还有一位就是著名的图灵(Alan Mathison Turing)。对应的,我们现在的计算机也叫图灵机(Turing Machine)。那么图灵机和冯·诺依曼机是两种不同的计算机么?图灵机是一种什么样的计算机抽象呢?
200+
201+
欢迎留言分享你的思考和疑惑,也可以把本文分享给你的朋友,一起学习和进步!
202+
203+
# 参考
204+
205+
深入浅出计算机组成原理
206+
207+
# X 交流学习
208+
![](https://img-blog.csdnimg.cn/20190504005601174.jpg)
209+
## [Java交流群](https://jq.qq.com/?_wv=1027&k=5UB4P1T)
210+
## [博客](https://blog.csdn.net/qq_33589510)
211+
## [Github](https://github.com/Wasabi1234)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
![](https://ask.qcloudimg.com/http-save/1752328/pbod43bmvp.png)
2+
3+
既然程序最终都被变成了一条条机器码去执行,那为什么同一个程序,在同一台计算机上,在Linux下可以运行,而在Windows下却不行呢?
4+
5+
反过来,Windows上的程序在Linux上也是一样不能执行的
6+
7+
可是我们的CPU并没有换掉,它应该可以识别同样的指令呀!!!
8+
9+
如果你和我有同样的疑问,那这一节,我们就一起来解开。
10+
11+
# 1 编译、链接和装载:拆解程序执行
12+
13+
写好的C语言代码,可以通过编译器编译成汇编代码,然后汇编代码再通过汇编器变成CPU可以理解的机器码,于是CPU就可以执行这些机器码了
14+
15+
你现在对这个过程应该不陌生了,但是这个描述把过程大大简化了
16+
17+
下面,我们一起具体来看,C语言程序是如何变成一个可执行程序的。
18+
19+
过去几节,我们通过gcc生成的文件和objdump获取到的汇编指令都有些小小的问题
20+
21+
我们先把前面的add函数示例,拆分成两个文件
22+
23+
- add\_lib.c
24+
![](https://ask.qcloudimg.com/http-save/1752328/27iteclwnt.png)
25+
- link\_example.c![](https://ask.qcloudimg.com/http-save/1752328/1runyilel1.png)
26+
27+
通过gcc来编译这两个文件,然后通过objdump命令看看它们的汇编代码。
28+
29+
![](https://ask.qcloudimg.com/http-save/1752328/d5lueryn06.png)
30+
31+
- objdump -d -M intel -S link\_example.o
32+
![](https://ask.qcloudimg.com/http-save/1752328/xgggoaji7p.png)
33+
34+
既然代码已经被我们“编译”成了指令
35+
36+
不妨尝试运行一下 **./link\_example.o**
37+
38+
- 不幸的是,文件没有执行权限,我们遇到一个Permission denied错误![](https://ask.qcloudimg.com/http-save/1752328/55wl57iwwt.png)
39+
40+
即使通过**chmod**命令赋予**link\_example.o**文件可执行的权限,运行 **./link\_example.o** 仍然只会得到一条**cannot execute binary file: Exec format error**的错误。
41+
42+
仔细看一下objdump出来的两个文件的代码,会发现**两个程序的地址都是从0开始**
43+
44+
如果地址一样,程序如果需要通过call指令调用函数的话,怎么知道应该跳到哪一个文件呢?
45+
46+
无论是这里的运行报错,还是objdump出来的汇编代码里面的重复地址
47+
48+
都是因为 **add\_lib.o** 以及 **link\_example.o** **并不是一个可执行文件(Executable Program),而是目标文件(Object File)**
49+
50+
只有通过**链接器(Linker)** 把多个目标文件以及调用的各种函数库链接起来,我们才能得到一个**可执行文件**
51+
52+
- gcc的-o参数,可以生成对应的可执行文件,对应执行之后,就可以得到这个简单的加法调用函数的结果。
53+
![](https://ask.qcloudimg.com/http-save/1752328/8tiprqk6at.png)
54+
55+
**C语言代码-汇编代码-机器码** 过程,在我们的计算机上进行的时候是由两部分组成:
56+
57+
- 第一个部分由编译(Compile)、汇编(Assemble)以及链接(Link)三个阶段组成
58+
三阶段后,就生成了一个可执行文件link\_example:
59+
60+
```
61+
file format elf64-x86-64
62+
Disassembly of section .init:
63+
...
64+
Disassembly of section .plt:
65+
...
66+
Disassembly of section .plt.got:
67+
...
68+
Disassembly of section .text:
69+
...
70+
71+
6b0: 55 push rbp
72+
6b1: 48 89 e5 mov rbp,rsp
73+
6b4: 89 7d fc mov DWORD PTR [rbp-0x4],edi
74+
6b7: 89 75 f8 mov DWORD PTR [rbp-0x8],esi
75+
6ba: 8b 55 fc mov edx,DWORD PTR [rbp-0x4]
76+
6bd: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8]
77+
6c0: 01 d0 add eax,edx
78+
6c2: 5d pop rbp
79+
6c3: c3 ret
80+
00000000000006c4 <main>:
81+
6c4: 55 push rbp
82+
6c5: 48 89 e5 mov rbp,rsp
83+
6c8: 48 83 ec 10 sub rsp,0x10
84+
6cc: c7 45 fc 0a 00 00 00 mov DWORD PTR [rbp-0x4],0xa
85+
6d3: c7 45 f8 05 00 00 00 mov DWORD PTR [rbp-0x8],0x5
86+
6da: 8b 55 f8 mov edx,DWORD PTR [rbp-0x8]
87+
6dd: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
88+
6e0: 89 d6 mov esi,edx
89+
6e2: 89 c7 mov edi,eax
90+
6e4: b8 00 00 00 00 mov eax,0x0
91+
6e9: e8 c2 ff ff ff call 6b0 <add>
92+
6ee: 89 45 f4 mov DWORD PTR [rbp-0xc],eax
93+
6f1: 8b 45 f4 mov eax,DWORD PTR [rbp-0xc]
94+
6f4: 89 c6 mov esi,eax
95+
6f6: 48 8d 3d 97 00 00 00 lea rdi,[rip+0x97] # 794 <\_IO\_stdin\_used+0x4>
96+
6fd: b8 00 00 00 00 mov eax,0x0
97+
702: e8 59 fe ff ff call 560 <printf@plt>
98+
707: b8 00 00 00 00 mov eax,0x0
99+
70c: c9 leave
100+
70d: c3 ret
101+
70e: 66 90 xchg ax,ax
102+
...
103+
Disassembly of section .fini:
104+
```
105+
106+
...你会发现,可执行代码dump出来内容,和之前的目标代码长得差不多,但是长了很多
107+
108+
因为在Linux下,可执行文件和目标文件所使用的都是一种叫**ELF(Execuatable and Linkable File Format)**的文件格式,中文名字叫**可执行与可链接文件格式**
109+
110+
这里面不仅存放了编译成的汇编指令,还保留了很多别的数据。
111+
112+
- 第二部分,我们通过装载器(Loader)把可执行文件装载(Load)到内存中
113+
CPU从内存中读取指令和数据,来开始真正执行程序
114+
![](https://ask.qcloudimg.com/http-save/1752328/bz6uwgudne.png)2 ELF格式和链接:理解链接过程程序最终是通过装载器变成指令和数据的,所以其实生成的可执行代码也并不仅仅是一条条的指令
115+
我们还是通过objdump指令,把可执行文件的内容拿出来看看。
116+
117+
比如我们过去所有objdump出来的代码里,你都可以看到对应的函数名称,像add、main等等,乃至你自己定义的全局可以访问的变量名称,都存放在这个ELF格式文件里
118+
119+
这些名字和它们对应的地址,在ELF文件里面,存储在一个叫作符号表(Symbols Table)的位置里。符号表相当于一个地址簿,把名字和地址关联了起来。
120+
121+
我们先只关注和我们的add以及main函数相关的部分
122+
123+
你会发现,这里面,main函数里调用add的跳转地址,不再是下一条指令的地址了,而是add函数的入口地址了,这就是EFL格式和链接器的功劳
124+
125+
![](https://ask.qcloudimg.com/http-save/1752328/qhfnlsqnmi.png)
126+
127+
ELF文件格式把各种信息,分成一个一个的Section保存起来。ELF有一个基本的文件头(File Header),用来表示这个文件的基本属性,比如是否是可执行文件,对应的CPU、操作系统等等。除了这些基本属性之外,大部分程序还有这么一些Section:
128+
129+
- 首先是.text Section,也叫作代码段或者指令段(Code Section),用来保存程序的代码和指令;
130+
- 接着是.data Section,也叫作数据段(Data Section),用来保存程序里面设置好的初始化数据信息;
131+
- 然后就是.rel.text Secion,叫作重定位表(Relocation Table)。重定位表里,保留的是当前的文件里面,哪些跳转地址其实是我们不知道的。比如上面的 link\_example.o 里面,我们在main函数里面调用了 add 和 printf 这两个函数,但是在链接发生之前,我们并不知道该跳转到哪里,这些信息就会存储在重定位表里;
132+
- 最后是.symtab Section,叫作符号表(Symbol Table)。符号表保留了我们所说的当前文件里面定义的函数名称和对应地址的地址簿。
133+
134+
链接器会扫描所有输入的目标文件,然后把所有符号表里的信息收集起来,构成一个全局的符号表。然后再根据重定位表,把所有不确定要跳转地址的代码,根据符号表里面存储的地址,进行一次修正。最后,把所有的目标文件的对应段进行一次合并,变成了最终的可执行代码。这也是为什么,可执行文件里面的函数调用的地址都是正确的
135+
136+
![](https://ask.qcloudimg.com/http-save/1752328/k2iz4yx0u1.png)
137+
138+
在链接器把程序变成可执行文件之后,要装载器去执行程序就容易多了。装载器不再需要考虑地址跳转的问题,只需要解析 ELF 文件,把对应的指令和数据,加载到内存里面供CPU执行就可以了。
139+
140+
# 3 总结
141+
142+
讲到这里,相信你已经猜到,为什么同样一个程序,在Linux下可以执行而在Windows下不能执行了。其中一个非常重要的原因就是,两个操作系统下可执行文件的格式不一样。
143+
144+
我们今天讲的是Linux下的ELF文件格式,而Windows的可执行文件格式是一种叫作PE(Portable Executable Format)的文件格式。Linux下的装载器只能解析ELF格式而不能解析PE格式。
145+
146+
如果我们有一个可以能够解析PE格式的装载器,我们就有可能在Linux下运行Windows程序了。这样的程序真的存在吗?
147+
148+
没错,Linux下著名的开源项目Wine,就是通过兼容PE格式的装载器,使得我们能直接在Linux下运行Windows程序的。
149+
150+
而现在微软的Windows里面也提供了WSL,也就是Windows Subsystem for Linux,可以解析和加载ELF格式的文件。
151+
152+
我们去写可以用的程序,也不仅仅是把所有代码放在一个文件里来编译执行,而是可以拆分成不同的函数库,最后通过一个静态链接的机制,使得不同的文件之间既有分工,又能通过静态链接来“合作”,变成一个可执行的程序。
153+
154+
对于ELF格式的文件,为了能够实现这样一个静态链接的机制,里面不只是简单罗列了程序所需要执行的指令,还会包括链接所需要的重定位表和符号表。
155+
156+
# 4 推荐阅读
157+
158+
更深入了解程序的链接过程和ELF格式,推荐阅读《程序员的自我修养——链接、装载和库》的1~4章。这是一本难得的讲解程序的链接、装载和运行的好书。
159+
160+
# X 交流学习
161+
![](https://img-blog.csdnimg.cn/20190504005601174.jpg)
162+
## [Java交流群](https://jq.qq.com/?_wv=1027&k=5UB4P1T)
163+
## [博客](https://blog.csdn.net/qq_33589510)
164+
## [Github](https://github.com/Wasabi1234)
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
![](https://ask.qcloudimg.com/http-save/1752328/uskvyzme4j.png)
2+
3+
在上一篇中,我们谈到过
4+
5+
```
6+
程序的CPU执行时间 = 指令数×CPI×Clock Cycle Time
7+
```
8+
9+
要提升计算机的性能,可以从上面这三方面着手。
10+
11+
通过指令数/CPI,好像都太难了。
12+
13+
因此工程师们,就在CPU上多放晶体管,不断提升CPU的时钟频率,让CPU更快,程序的执行时间就会缩短。
14+
15+
![](https://ask.qcloudimg.com/http-save/1752328/09hlntlacb.png)
16+
17+
- 从1978年Intel发布的8086 CPU开始,计算机的主频从5MHz开始,不断攀升
18+
- 1980年代中期的80386能够跑到40MHz
19+
- 1989年的486能够跑到100MHz
20+
- 直到2000年的奔腾4处理器,主频已经到达了1.4GHz
21+
22+
# 1 功耗:CPU的“人体极限”
23+
24+
[奔腾4的CPU主频从来没有达到过10GHz,最终它的主频上限定格在3.8GHz](https://zh.wikipedia.org/wiki/%25E5%25A5%2594%25E8%2585%25BE4)
25+
26+
而且奔腾4的主频虽然高,但是实际性能却配不上同样的主频
27+
28+
想要用在笔记本上的奔腾4 2.4GHz处理器,其性能只和基于奔腾3架构的奔腾M 1.6GHz匹配
29+
30+
于是不仅让Intel的对手AMD获得了喘息之机,更是代表着“主频时代”的终结。
31+
32+
后面几代Intel CPU主频不但没有上升,反而下降了。
33+
34+
到如今,2019年的最高配置[Intel i9 CPU](https://zh.wikipedia.org/wiki/Intel_Core_i9),主频也不过是5GHz
35+
36+
相较于1978年到2000年,这20年里300倍的主频提升,从2000年到现在的这19年,CPU的主频大概提高了3倍
37+
38+
- CPU的主频变化,奔腾4时进入瓶颈期![](https://ask.qcloudimg.com/http-save/1752328/mxpvmi6gzl.png)
39+
40+
奔腾4的主频为什么没能超3.8GHz?
41+
42+
就因为功耗.
43+
44+
一个3.8GHz的奔腾4处理器,满载功率是130瓦
45+
46+
130瓦是什么概念呢?机场允许带上飞机的充电宝的容量上限是100瓦时
47+
48+
如果我们把这个CPU安在手机里面,不考虑屏幕内存之类的耗电,这个CPU满载运行45分钟,充电宝里面就没电了
49+
50+
而iPhone X使用ARM架构的CPU,功率则只有4.5瓦左右。
51+
52+
CPU,也称作**超大规模集成电路(Very-Large-Scale Integration,VLSI**
53+
54+
由一个个晶体管组成
55+
56+
CPU的计算过程,其实就是让晶体管里面的“开关”不断“打开”/“关闭”,组合完成各种运算和功能。
57+
58+
要想算得快
59+
60+
- 增加密度
61+
在CPU同样的面积,多放晶体管
62+
- 提升主频
63+
让晶体管“打开”/“关闭”得快点
64+
65+
这两者,都会增加功耗,带来耗电和散热的问题!!!
66+
67+
可以把CPU想象成一个工厂,有很多工人
68+
69+
就如CPU上面的晶体管,互相之间协同工作。
70+
71+
为了工作快点完成,在工厂里多塞一点人
72+
73+
你可能会问,为什么不把工厂造得大点?
74+
75+
这是因为,人和人之间如果离得远了,互相之间走过去需要花的时间就会变长,这也会导致性能下降!
76+
77+
这就如如果CPU的面积大,晶体管之间的距离变大,电信号传输的时间就会变长,运算速度自然就慢了。
78+
79+
除了多塞一点人,还希望每个人动作快点,同样时间就可多干活了
80+
81+
这就相当于提升CPU主频,但是动作快,每个人就要出汗散热
82+
83+
要是太热了,对工厂里面的人来说会休克,对CPU来说就会崩溃出错。
84+
85+
我们会在CPU上面抹硅脂、装风扇,乃至用上水冷或者其他更好的散热设备
86+
87+
就好像在工厂里面装风扇、空调,发冷饮一样
88+
89+
但是同样的空间下,装上风扇空调能够带来的散热效果也是有极限的
90+
91+
因此,在CPU里面,能够放下的晶体管数量和晶体管的“开关”频率也都是有限的。
92+
93+
一个CPU的功率,可以用这样一个公式来表示:
94+
95+
```
96+
功耗 ≈ 1/2 ×负载电容 × 电压的平方 × 开关频率 × 晶体管数量
97+
```
98+
99+
为了提升性能,要不断地增加晶体管数量
100+
101+
同样的面积下,想要多放一点晶体管,就要把晶体管造得小一点
102+
103+
这个就是平时我们所说的提升“制程”
104+
105+
从28nm到7nm,相当于晶体管本身变成了原来的1/4大小
106+
107+
这个就相当于我们在工厂里,同样的活儿,我们要找瘦小一点的工人,这样一个工厂里面就可以多一些人
108+
109+
我们还要提升主频,让开关的频率变快,也就是要找手脚更快的工人
110+
111+
![](https://ask.qcloudimg.com/http-save/1752328/4r5uwxufnv.png)
112+
113+
但功耗增加过多,CPU散热就跟不上
114+
115+
这时就需要降低电压
116+
117+
这里有一点非常关键,在整个功耗的公式里面,功耗和电压的平方是成正比的
118+
119+
这意味着电压下降到原来的1/5,整个的功耗会变成原来的1/25。
120+
121+
事实上,从5MHz主频的8086到5GHz主频的Intel i9,CPU的电压已经从5V左右下降到了1V左右
122+
123+
这也是为什么我们CPU的主频提升了1000倍,但是功耗只增长了40倍
124+
125+
# 2 并行优化 - 阿姆达尔定律
126+
127+
虽然制程的优化和电压的下降,在过去的20年里,让CPU性能有所提升
128+
129+
但是从上世纪九十年代到本世纪初,软件工程师们所用的“面向摩尔定律编程”的套路越来越用不下去了
130+
131+
“写程序不考虑性能,等明年CPU性能提升一倍,到时候性能自然就不成问题了”,这种想法已经不可行了。
132+
133+
于是,从奔腾4开始,Intel意识到通过提升主频比较“难”去实现性能提升
134+
135+
开始推出Core Duo这样的多核CPU,通过提升“吞吐率”而不是“响应时间”,来达到目的。
136+
137+
提升响应时间,就好比提升你用的交通工具的速度
138+
139+
原本你是开汽车,现在变成了高铁乃至飞机
140+
141+
但是,在此之上,再想要提升速度就不太容易了
142+
143+
CPU在奔腾4的年代,就好比已经到了飞机这个速度极限
144+
145+
那你可能要问了,接下来该怎么办呢?
146+
147+
相比于给飞机提速,工程师们又想到了新的办法,可以一次同时开2架、4架乃至8架飞机,这就好像我们现在用的2核、4核,乃至8核的CPU。
148+
149+
虽然从上海到北京的时间没有变,但是一次飞8架飞机能够运的东西自然就变多了,也就是所谓的“吞吐率”变大了。所以,不管你有没有需要,现在CPU的性能就是提升了2倍乃至8倍、16倍。
150+
151+
这也是一个最常见的提升性能的方式,**通过并行提高性能**
152+
153+
这个思想在很多地方都可以使用
154+
155+
举个例子,我们做机器学习程序的时候,需要计算向量的点积,比如向量
156+
157+
```
158+
$W = [W_0, W_1, W_2, …, W_{15}]$
159+
```
160+
161+
和向量
162+
163+
```
164+
$X = [X_0, X_1, X_2, …, X_{15}]$
165+
```
166+
167+
```
168+
$W·X = W_0 * X_0 + W_1 * X_1 +$
169+
```
170+
171+
```
172+
$W_2 * X_2 + … + W_{15} * X_{15}$
173+
```
174+
175+
这些式子由16个乘法和1个连加组成。如果你自己一个人用笔来算的话,需要一步一步算16次乘法和15次加法。
176+
177+
如果这个时候我们把这个人物分配给4个人,同时去算$W_0~W\_3$, $W\_4~W\_7$, $W\_8~W_{11}$, $W_{12}~W_{15}$这样四个部分的结果,再由一个人进行汇总,需要的时间就会缩短。
178+
179+
![](https://ask.qcloudimg.com/http-save/1752328/z6nx9lsihq.png)
180+
181+
但并不是所有问题,都可以通过并行提高性能来解决
182+
183+
要使用这种思想,需要满足以下条件:
184+
185+
- 需要进行的计算,本身可以分解成几个可以并行的任务
186+
好比上面的乘法和加法计算,几个人可以同时进行,不会影响最后的结果。
187+
- 需要能够分解好问题,并确保几个人的结果能够汇总到一起
188+
- 在“汇总”这个阶段,是没有办法并行进行的,还是得顺序执行,一步一步来。
189+
190+
这就引出了性能优化中一个经验定律
191+
192+
- 阿姆达尔定律(Amdahl’s Law)
193+
对于一个程序进行优化之后,处理器并行运算之后效率提升的情况
194+
195+
具体可以用这样一个公式来表示:
196+
197+
```
198+
优化后的执行时间 = 受优化影响的执行时间/加速倍数+不受影响的执行时间
199+
```
200+
201+
在刚刚的向量点积例子里,4个人同时计算向量的一小段点积,就是通过并行提高了这部分的计算性能
202+
203+
但是,这4个人的计算结果,最终还是要在一个人那里进行汇总相加
204+
205+
这部分汇总相加的时间,是不能通过并行来优化的,也就是上面的公式里面**不受影响的执行时间**部分
206+
207+
比如上面的各个向量的一小段
208+
209+
- 点积,需要100ns
210+
- 加法需要20ns
211+
212+
总共需要120ns。这里通过并行4个CPU有了4倍的加速度。那么最终优化后,就有了100/4+20=45ns
213+
214+
即使我们增加更多的并行度来提供加速倍数,比如有100个CPU,整个时间也需要100/100+20=21ns。
215+
216+
![](https://ask.qcloudimg.com/http-save/1752328/m00nhlporg.png)
217+
218+
# 3 总结
219+
220+
无论是简单地通过提升主频,还是增加更多的CPU核心数量,通过并行提升性能,都会遇到相应的瓶颈
221+
222+
仅靠简单地通过“堆硬件”的方式,在今天已经不能很好地满足我们对于程序性能的期望了。
223+
224+
于是,工程师们需要从其他方面开始下功夫了。
225+
226+
在“摩尔定律”和“并行计算”之外,在整个计算机组成层面,还有这样几个原则性的性能提升方法。
227+
228+
## 3.1 加速大概率事件
229+
230+
深度学习,整个计算过程中,99%都是向量和矩阵计算
231+
232+
于是,工程师们通过用GPU替代CPU,大幅度提升了深度学习的模型训练过程
233+
234+
本来一个CPU需要跑几小时甚至几天的程序,GPU只需要几分钟就好了
235+
236+
Google更是不满足于GPU的性能,进一步地推出了TPU
237+
238+
> 通常我们使用 O 表示一个算法的好坏,我们优化一个算法也是基于 big-O
239+
> 但是 big-O 其实是一个近似值,就好比一个算法时间复杂度是 O(n^2) + O(n)
240+
> 这里的 O(n^2) 是占大比重的,特别是当 n 很大的时候,通常我们会忽略掉 O(n),着手优化 O(n^2) 的部分
241+
242+
## 3.2 通过流水线提高性能
243+
244+
现代的工厂里的生产线叫“流水线”。
245+
246+
我们可以把装配iPhone这样的任务拆分成一个个细分的任务,让每个人都只需要处理一道工序,最大化整个工厂的生产效率。
247+
248+
我们的CPU其实就是一个“运算工厂”
249+
250+
我们把CPU指令执行的过程进行拆分,细化运行,也是现代CPU在主频没有办法提升那么多的情况下,性能仍然可以得到提升的重要原因之一
251+
252+
## 3.3 通过预测提高性能
253+
254+
预测下一步该干什么,而不是等上一步运行结果,提前进行运算,也是让程序跑得更快一点的办法
255+
256+
在一个循环访问数组的时候,凭经验,你也会猜到下一步我们会访问数组的下一项
257+
258+
后面要讲的“分支和冒险”、“局部性原理”这些CPU和存储系统设计方法,其实都是在利用我们对于未来的“预测”,提前进行相应的操作,来提升我们的程序性能。
259+
260+
> 深度优先搜索算法里面的 “剪枝策略”,防止没有必要的分支搜索,这会大幅度提升算法效率
261+
262+
- 整个组成乃至体系结构,都是基于冯·诺依曼架构组成的软硬件一体的解决方案
263+
- 这里面的方方面面的设计和考虑,除了体系结构层面的抽象和通用性之外,核心需要考虑的是“性能”问题
264+
265+
# 参考
266+
267+
深入浅出计算机组成原理
268+
269+
# X 交流学习
270+
![](https://img-blog.csdnimg.cn/20190504005601174.jpg)
271+
## [Java交流群](https://jq.qq.com/?_wv=1027&k=5UB4P1T)
272+
## [博客](https://blog.csdn.net/qq_33589510)
273+
## [Github](https://github.com/Wasabi1234)
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
![](https://ask.qcloudimg.com/http-save/1752328/d0uvxb1xo6.png)
2+
3+
把对应的不同文件内的代码段,合并到一起,成为最后的可执行文件
4+
5+
链接的方式,让我们在写代码的时候做到了“复用”。
6+
7+
同样的功能代码只要写一次,然后提供给很多不同的程序进行链接就行了。
8+
9+
“链接”其实有点儿像我们日常生活中的**标准化、模块化**生产。
10+
11+
有一个可以生产标准螺帽的生产线,就可生产很多不同的螺帽。
12+
13+
只要需要螺帽,都可以通过链接的方式,去复制一个出来,放到需要的地方
14+
15+
但是,如果我们有很多个程序都要通过装载器装载到内存里面,那里面链接好的同样的功能代码,也都需要再装载一遍,再占一遍内存空间。
16+
17+
这就好比,假设每个人都有骑自行车的需要,那我们给每个人都生产一辆自行车带在身边,固然大家都有自行车用了,但是马路上肯定会特别拥挤。
18+
19+
![](https://ask.qcloudimg.com/http-save/1752328/q70qtwc7rw.png)
20+
21+
# 1 链接可以分动、静,共享运行省内存
22+
23+
我们上一节解决程序装载到内存的时候,讲了很多方法。说起来,最根本的问题其实就是**内存空间不够用**
24+
25+
如果能够让同样功能的代码,在不同的程序里面,不需要各占一份内存空间,那该有多好啊!
26+
27+
就好比,现在马路上的共享单车,我们并不需要给每个人都造一辆自行车,只要马路上有这些单车,谁需要的时候,直接通过手机扫码,都可以解锁骑行。
28+
29+
这个思路就引入一种新的链接方法,叫作**动态链接(Dynamic Link)**
30+
31+
相应的,我们之前说的合并代码段的方法,就是**静态链接(Static Link)**
32+
33+
在动态链接的过程中,我们想要“链接”的,不是存储在硬盘上的目标文件代码,而是加载到内存中的**共享库(Shared Libraries)**
34+
35+
这个加载到内存中的共享库会被很多个程序的指令调用到。
36+
37+
- 在Windows下,这些共享库文件就是.dll文件,也就是Dynamic-Link Libary(DLL,动态链接库)
38+
用了“动态链接”的意思
39+
- 在Linux下,这些共享库文件就是.so文件,也就是Shared Object(一般我们也称之为动态链接库)。
40+
用了“共享”的意思
41+
42+
正好覆盖了两方面的含义。
43+
44+
![](https://ask.qcloudimg.com/http-save/1752328/8x932anaiq.png)
45+
46+
# 2 地址无关很重要,相对地址解烦恼
47+
48+
要在程序运行的时候共享代码,这些机器码必须“**地址无关**
49+
50+
也就是说,我们编译出来的共享库文件的指令代码,是地址无关码(Position-Independent Code)
51+
52+
换句话说就是,这段代码,无论加载在哪个内存地址,都能够正常执行
53+
54+
> 如果还不明白,我给你举一个生活中的例子
55+
> 如果我们有一个骑自行车的程序,要“前进500米,左转进入天安门广场,再前进500米”。
56+
> 它在500米之后要到天安门广场了,这就是地址相关的。
57+
> 如果程序是“前进500米,左转,再前进500米”,无论你在哪里都可以骑车走这1000米,没有具体地点的限制,这就是地址无关的。
58+
59+
大部分函数库其实都可以做到地址无关,因为它们都接受特定的输入,进行确定的操作,然后给出返回结果就好了。
60+
61+
无论是实现一个向量加法,还是实现一个打印的函数,这些代码逻辑和输入的数据在内存里面的位置并不重要。
62+
63+
而常见的地址相关的代码,比如绝对地址代码(Absolute Code)、利用重定位表的代码等等,都是地址相关的代码
64+
65+
回想一下我们之前讲过的重定位表。在程序链接的时候,我们就把函数调用后要跳转访问的地址确定下来了,这意味着,如果这个函数加载到一个不同的内存地址,跳转就会失败。![](https://ask.qcloudimg.com/http-save/1752328/uu6kphr669.png)
66+
67+
对于所有动态链接共享库的程序来讲,虽然我们的共享库用的都是同一段物理内存地址,但是在不同的应用程序里,它所在的虚拟内存地址是不同的。
68+
69+
没办法、也不应该要求动态链接同一个共享库的不同程序,必须把这个共享库所使用的虚拟内存地址变成一致。
70+
71+
如果这样的话,我们写的程序就必须明确地知道内部的内存地址分配。
72+
73+
那么问题来了,我们要怎么样才能做到,动态共享库编译出来的代码指令,都是地址无关码呢?
74+
75+
动态代码库内部的变量和函数调用都很容易解决,我们只需要使用**相对地址(Relative Address)**
76+
77+
各种指令中使用到的内存地址,给出的不是一个绝对的地址空间,而是一个相对于当前指令偏移量的内存地址
78+
79+
因为 **整个共享库是放在一段连续的虚拟内存地址中的,无论装载到哪一段地址,不同指令之间的相对地址都是不变的**
80+
81+
# 3 动态链接的解决方案
82+
83+
**PLT和GOT**
84+
85+
要实现动态链接共享库,也并不困难,和前面的静态链接里的符号表和重定向表类似
86+
87+
拿出一小段代码来看一看。
88+
89+
- lib.h
90+
定义了动态链接库的一个函数 _**show\_me\_the\_money**_
91+
![](https://ask.qcloudimg.com/http-save/1752328/hir3c9dtp1.png)
92+
- lib.c
93+
包含了lib.h的实际实现
94+
![](https://ask.qcloudimg.com/http-save/1752328/viykg31uft.png)
95+
- show\_me\_poor.c
96+
调用了 lib 里面的函数
97+
![](https://ask.qcloudimg.com/http-save/1752328/0wxnef9c43.png)
98+
- 把 lib.c 编译成了一个动态链接库,也就是 .so 文件
99+
![](https://ask.qcloudimg.com/http-save/1752328/iartw2ewo.png)
100+
- 最终生成文件集
101+
![](https://ask.qcloudimg.com/http-save/1752328/vdy19ol1be.png)
102+
103+
在编译的过程中,指定了一个 _**-fPIC**_ 的参数
104+
105+
其实就是Position Independent Code意,也就是要把这个编译成一个地址无关代码
106+
107+
然后,我们再通过gcc编译 _**show\_me\_poor**_ 动态链接了 _**lib.so**_ 的可执行文件
108+
109+
- 在这些操作都完成了之后,我们把 _**show\_me\_poor**_ 这个文件通过**objdump**出来看一下。![](https://ask.qcloudimg.com/http-save/1752328/rcgq9l6eac.png)
110+
111+
```
112+
0000000000400540 <show_me_the_money@plt-0x10>:
113+
400540: ff 35 12 05 20 00 push QWORD PTR [rip+0x200512] # 600a58 <_GLOBAL_OFFSET_TABLE_+0x8>
114+
400546: ff 25 14 05 20 00 jmp QWORD PTR [rip+0x200514] # 600a60 <_GLOBAL_OFFSET_TABLE_+0x10>
115+
40054c: 0f 1f 40 00 nop DWORD PTR [rax+0x0]
116+
117+
0000000000400550 <show_me_the_money@plt>:
118+
400550: ff 25 12 05 20 00 jmp QWORD PTR [rip+0x200512] # 600a68 <_GLOBAL_OFFSET_TABLE_+0x18>
119+
400556: 68 00 00 00 00 push 0x0
120+
40055b: e9 e0 ff ff ff jmp 400540 <_init+0x28>
121+
……
122+
0000000000400676 <main>:
123+
400676: 55 push rbp
124+
400677: 48 89 e5 mov rbp,rsp
125+
40067a: 48 83 ec 10 sub rsp,0x10
126+
40067e: c7 45 fc 05 00 00 00 mov DWORD PTR [rbp-0x4],0x5
127+
400685: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
128+
400688: 89 c7 mov edi,eax
129+
40068a: e8 c1 fe ff ff call 400550 <show_me_the_money@plt>
130+
40068f: c9 leave
131+
400690: c3 ret
132+
400691: 66 2e 0f 1f 84 00 00 nop WORD PTR cs:[rax+rax*1+0x0]
133+
400698: 00 00 00
134+
40069b: 0f 1f 44 00 00 nop DWORD PTR [rax+rax*1+0x0]
135+
```
136+
137+
我们还是只关心整个可执行文件中的一小部分内容
138+
139+
- 在main函数调用**show\_me\_the\_money**的函数的时候,对应的代码是这样的:
140+
![](https://ask.qcloudimg.com/http-save/1752328/ibbojhjzom.png)
141+
142+
这里后面有一个@plt的关键字,代表了我们需要从PLT,也就是程序链接表(Procedure Link Table)里面找要调用的函数。对应的地址呢,则是400580这个地址。
143+
144+
那当我们把目光挪到上面的 400580 这个地址,你又会看到里面进行了一次跳转,
145+
146+
- 这个跳转指定的跳转地址,你可以在后面的注释里面可以看到:
147+
![](https://ask.qcloudimg.com/http-save/1752328/0f0oy8yy3r.png)这里的 _**GLOBAL\_OFFSET\_TABLE**_,就是我接下来要说的全局偏移表。
148+
149+
在动态链接对应的共享库,我们在共享库的data section里面,保存了一张**全局偏移表(GOT,Global Offset Table)**
150+
151+
**虽然共享库的代码部分的物理内存是共享的,但是数据部分是各个动态链接它的应用程序里面各加载一份的。**
152+
153+
所有需要引用当前共享库外部的地址的指令,都会查询GOT,来找到当前运行程序的虚拟内存里的对应位置
154+
155+
而GOT表里的数据,则是在我们加载一个个共享库的时候写进去的。
156+
157+
不同的进程,调用同样的 _**lib.so**_,各自GOT里面指向最终加载的动态链接库里面的虚拟内存地址是不同的。
158+
159+
这样,虽然不同的程序调用的同样的动态库,各自的内存地址是独立的,调用的又都是同一个动态库,但是不需要去修改动态库里面的代码所使用的地址,
160+
161+
而是**各个程序各自维护好自己的GOT,能够找到对应的动态库就好了**
162+
163+
![](https://ask.qcloudimg.com/http-save/1752328/aw185d2obl.png)
164+
165+
GOT表位于共享库自己的数据段里
166+
167+
**GOT表在内存里和对应的代码段位置之间的偏移量,始终是确定的**
168+
169+
这样,**共享库就是地址无关的代码,对应的各个程序只需在物理内存里加载同一份代码**
170+
171+
而我们又要通过各个可执行程序在加载时,**生成的各不相同的GOT表,找到它需要调用到的外部变量和函数的地址**
172+
173+
这是一个典型的、不修改代码,而是通过修改“**地址数据**”来进行关联的办法
174+
175+
> 它有点像我们在C语言里面用函数指针来调用对应的函数,并不是通过预先已经确定好的函数名称来调用,而是利用当时它在内存里面的动态地址来调用。
176+
177+
# 4 总结
178+
179+
终于在静态链接和程序装载后,利用动态链接把我们的内存利用到了极致
180+
181+
同样功能的代码生成的共享库,我们只要在内存里面保留一份就好了
182+
183+
这样
184+
185+
- 不仅能够做到代码在开发阶段的复用
186+
- 也能做到代码在运行阶段的复用。
187+
188+
实际上,在进行Linux程序开发,一直会用到各种各样的动态链接库。
189+
190+
C语言的标准库就在1MB以上。
191+
192+
撰写任何一个程序可能都需要用到这个库,常见的Linux服务器里,/usr/bin下面就有上千个可执行文件。
193+
194+
如果每一个都把标准库静态链接进来的,几GB乃至几十GB的磁盘空间一下子就用出去了。如果我们服务端的多进程应用要开上千个进程,几GB的内存空间也会一下子就用出去了。这个问题在过去计算机的内存较少的时候更加显著。
195+
196+
通过动态链接这个方式,可以说_彻底解决了这个问题_
197+
198+
就像共享单车一样,如果仔细经营,是一个很有社会价值的事情,但是如果粗暴地把它变成无限制地复制生产,给每个人造一辆,只会在系统内制造大量无用的垃圾。
199+
200+
已经把程序怎么从源代码变成指令、数据,并装载到内存里面,由CPU一条条执行下去的过程讲完了。希望你能有所收获,对于一个程序是怎么跑起来的,有了一个初步的认识。
201+
202+
# 5 推荐阅读
203+
204+
想要更加深入地了解动态链接,推荐你可以读一读[《程序员的自我修养:链接、装载和库》的第7章](https://book.douban.com/subject/3652388/)
205+
206+
![](https://ask.qcloudimg.com/http-save/1752328/t1hge7r02d.png)
207+
208+
![](https://ask.qcloudimg.com/http-save/1752328/62djlnynxd.png)
209+
210+
里面深入地讲解了,动态链接里程序内的数据布局和对应数据的加载关系。
211+
212+
# 参考
213+
214+
- 深入浅出计算机组成原理
215+
216+
# X 交流学习
217+
![](https://img-blog.csdnimg.cn/20190504005601174.jpg)
218+
## [Java交流群](https://jq.qq.com/?_wv=1027&k=5UB4P1T)
219+
## [博客](https://blog.csdn.net/qq_33589510)
220+
## [Github](https://github.com/Wasabi1234)

‎计算机组成原理/重学计算机组成原理(二)- 制定学习路线,攀登“性能”之巅.md

Lines changed: 382 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
![](https://ask.qcloudimg.com/http-save/1752328/57mlmnq3i5.png)
2+
3+
CPU执行的也不只是一条指令,一般一个程序包含很多条指令
4+
5+
因为有if…else、for这样的条件和循环存在,这些指令也不会一路平直执行下去。
6+
7+
一个计算机程序是怎么被分解成一条条指令来执行的呢
8+
9+
# 1 CPU如何执行指令
10+
11+
CPU里差不多几百亿个晶体管
12+
13+
实际上,一条条计算机指令执行起来非常复杂
14+
15+
好在CPU在软件层面已经为我们做好了封装
16+
17+
对于程序员来说,我们只要知道,写好的代码变成了指令之后,是一条一条**顺序执行**
18+
19+
不管几百亿的晶体管的背后是怎么通过电路运转起来的
20+
21+
逻辑上,我们可以认为,CPU其实就是由一堆寄存器组成的
22+
23+
而寄存器就是CPU内部,由多个触发器(Flip-Flop)或者锁存器(Latches)组成的简单电路。
24+
25+
> 触发器和锁存器,其实就是两种不同原理的数字电路组成的逻辑门
26+
> 如果想要深入学习的话,可以学习数字电路的相关课程
27+
28+
N个触发器或者锁存器,就可以组成一个N位(Bit)的寄存器,能够保存N位的数据
29+
30+
比方说,我们用的64位Intel服务器,寄存器就是64位的
31+
32+
![](https://ask.qcloudimg.com/http-save/1752328/uc2jhcvs4t.png)
33+
34+
CPU里有很多种不同功能的
35+
36+
## 1.1 寄存器
37+
38+
寄存器(Register),是中央处理器内的其中组成部分。寄存器是有限存贮容量的高速存贮部件,它们可用来暂存指令、数据和地址。在中央处理器的控制部件中,包含的寄存器有指令寄存器(IR)和程序计数器。在中央处理器的算术及逻辑部件中,包含的寄存器有累加器。
39+
40+
在计算机体系结构里,处理器中的寄存器是少量且速度快的计算机存储器,借由提供快速共同地访问数值来加速计算机程序的运行:典型地说就是在已知时间点所作的之计算中间的数值。
41+
42+
寄存器是存储器层次结构中的最顶端,也是系统操作数据的最快速途径。寄存器通常都是以他们可以保存的比特数量来估量,举例来说,一个8位寄存器或32位寄存器。寄存器现在都以寄存器数组的方式来实现,但是他们也可能使用单独的触发器、高速的核心存储器、薄膜存储器以及在数种机器上的其他方式来实现出来。
43+
44+
这个名词通常都用来意指由一个指令之输出或输入可以直接索引到的寄存器组群。更适当的是称他们为“架构寄存器”。例如,x86指令集定义八个32位寄存器的集合,但一个实现x86指令集的CPU可以包含比八个更多的寄存器。
45+
46+
## 1.1.1 PC寄存器(Program Counter Register)
47+
48+
亦称指令地址寄存器(Instruction Address Register)
49+
50+
存放下一条需要执行的计算机指令的内存地址
51+
52+
## 1.1.2 指令寄存器(Instruction Register)
53+
54+
存放当前正在执行的指令
55+
56+
### 1.1.3 条件码寄存器(Status Register)
57+
58+
用里面的一个一个标记位(Flag),存放CPU进行算术或者逻辑计算的结果
59+
60+
![](https://ask.qcloudimg.com/http-save/1752328/5dmz0r317w.png)
61+
62+
CPU里面还有更多用来存储数据和内存地址的寄存器
63+
64+
这样的寄存器通常一类里面不止一个
65+
66+
通常根据存放的数据内容来给它们取名字,比如
67+
68+
- 常量寄存器
69+
用来持有只读的数值(例如0、1、圆周率等等)。由于“其中的值不可更改”这一特殊性质,这些寄存器未必会有实体的硬件电路相对应,例如将从零常数寄存器读的操作实现为接通目标寄存器的下拉电阻。
70+
一般而言,即使真正在硬件中放置常数寄存器也未必会是出于体系结构理论上的考虑,而很可能是由硬件描述语言为了简化操作而自动生成的电路
71+
- 整数寄存器
72+
用来存储整数数字(参考以下的浮点寄存器)。在某些简单(或旧)的CPU,特别的数据寄存器是累加器,作为数学计算之用。
73+
- 浮点数寄存器(FPRs)
74+
用来存储浮点数字。
75+
- 向量寄存器
76+
用来存储由向量处理器运行SIMD指令所得到的数据。
77+
- 地址寄存器
78+
持有存储器地址,以及用来访问存储器。在某些简单/旧的CPU里,特别的地址寄存器是索引寄存器(可能出现一个或多个)。
79+
80+
有些寄存器既可以存放数据,又能存放地址,我们就叫它通用寄存器(GPRs)。
81+
82+
![](https://ask.qcloudimg.com/http-save/1752328/fk0nvaoeg9.png)
83+
84+
程序执行的时候,CPU会
85+
86+
1. 根据PC寄存器里的地址
87+
2. 从内存里面把需要执行的指令读取到指令寄存器里面执行
88+
3. 然后根据指令长度自增
89+
4. 开始顺序读取下一条指令
90+
91+
可以看到,一个程序的一条条指令,在**内存里是连续保存**的,也会**一条条顺序加载**
92+
93+
而有些特殊指令,比如上一讲我们讲到J类指令,也就是跳转指令,会修改PC寄存器里面的地址值
94+
95+
这样,下一条要执行的指令就不是从内存里面顺序加载的了
96+
97+
事实上,这些跳转指令的存在,也是我们可以在写程序的时候,使用
98+
99+
- if…else条件语句
100+
- while/for循环语句
101+
102+
的原因
103+
104+
# 2 从if/else看程序的执行和跳转
105+
106+
我们现在就来看一个包含if…else的简单程序。
107+
108+
- test.c
109+
![](https://ask.qcloudimg.com/http-save/1752328/iwwo4jmpu5.png)
110+
111+
用rand生成了一个随机数r(0/1)
112+
113+
- 当r是0,我们把之前定义的变量a设成1
114+
- 不然就设成2
115+
116+
我们把这个程序编译成汇编代码。你可以忽略前后无关的代码,只关注于这里的if…else条件判断语句
117+
118+
- 对应的汇编代码是这样的
119+
![](https://ask.qcloudimg.com/http-save/1752328/9pdcnxkxi9.png)
120+
121+
对于r == 0的条件判断,被编译成了cmp和jne两条指令。
122+
123+
- cmp指令比较了前后两个操作数的值
124+
DWORD PTR 代表操作的数据类型是32位的整数
125+
rbp-0x4则是一个寄存器的地址
126+
第一个操作数就是从寄存器里拿到的变量r的值
127+
第二个操作数0x0就是我们设定的常量0的16进制表示
128+
129+
cmp指令的比较结果,会存入到**条件码寄存器**
130+
131+
> 状态寄存器又名条件码寄存器,它是计算机系统的核心部件——运算器的一部分
132+
> 状态寄存器用来存放两类信息:
133+
> 一类是体现当前指令执行结果的各种状态信息(条件码),如有无进位(CF位)、有无溢出(OF位)、结果正负(SF位)、结果是否为零(ZF位)、奇偶标志位(P位)等
134+
> 另一类是存放控制信息(PSW:程序状态字寄存器),如允许中断(IF位)、跟踪标志(TF位)等
135+
> 有些机器中将PSW称为标志寄存器FR(Flag Register)。
136+
137+
如果比较结果 True,即 r == 0,就把**零标志条件码**(对应的条件码是ZF,Zero Flag)设置为1
138+
139+
> 条件码是CPU根据运算结果由硬件设置的位,体现当前指令执行结果的各种状态信息
140+
> 例如:算术运算产生的正、负、零或溢出等的结果。条件码可被测试,作为分支运算的依据,此外,有些条件码可被设置,例如对于最高位进位标志C,可用指令对它置位和复位。
141+
142+
Intel的CPU下还有
143+
144+
- 进位标志(CF,Carry Flag)
145+
最近的操作使最高位产生了进位。可以用来检查无符号操作数据的溢出。
146+
- 符号标志(SF,Sign Flag)
147+
最近的操作得到的结果为负数。
148+
- 溢出标志(OF,Overflow Flag)
149+
最近的操作导致一个补码溢出--正溢出或负溢出
150+
151+
用在不同的判断条件下。
152+
153+
cmp指令执行完成之后,**PC寄存器**会自增,开始执行下一条jne的指令
154+
155+
跟着的jne指令(jump if not equal),它会查看对应的零标志位
156+
157+
如果为0,会跳转到后面跟着的操作数4a的位置
158+
159+
4a,对应汇编代码的行号,也就是else条件里的第一条指令
160+
161+
当跳转发生,PC寄存器不再是自增变成下一条指令的地址,而被直接设置4a这个地址
162+
163+
这个时候,CPU再把4a地址里的指令加载到指令寄存器执行。
164+
165+
跳转到执行地址为4a的指令,实际是一条mov指令
166+
167+
第一个操作数和前面的cmp指令一样,是另一个32位整型的寄存器地址,以及对应的2的16进制值0x2
168+
169+
mov指令把2设置到对应的寄存器里去,相当于一个赋值操作
170+
171+
然后,PC寄存器里的值继续自增,执行下一条mov指令。
172+
173+
这条mov指令的第一个操作数eax,代表**累加寄存器**
174+
175+
> 在中央处理器中,累加器 (accumulator) 是一种寄存器,用来储存计算产生的中间结果。如果没有像累加器这样的寄存器,那么在每次计算 (加法,乘法,移位等等) 后就必须要把结果写回到 内存,也许马上就得读回来。然而存取主存的速度是比从算术逻辑单元到有直接路径的累加器存取更慢。
176+
177+
第二个操作数0x0则是16进制的0的表示。这条指令其实没有实际的作用,它的作用是一个占位符
178+
179+
if条件如果满足,在赋值的mov指令执行完成之后,有一个jmp的无条件跳转指令
180+
181+
跳转的地址就是这一行的地址51
182+
183+
我们的main函数没有设定返回值,而mov eax, 0x0 其实就是给main函数生成了一个默认的为0的返回值到累加器里面
184+
185+
if条件里面的内容执行完成之后也会跳转到这里,和else里的内容结束之后的位置是一样的。
186+
187+
![](https://ask.qcloudimg.com/http-save/1752328/8ionvro99a.png)
188+
189+
上一讲我们讲打孔卡的时候说到,读取打孔卡的机器会顺序地一段一段地读取指令,然后执行。
190+
191+
执行完一条指令,它会自动地顺序读取下一条指令
192+
193+
如果执行的当前指令带有跳转的地址,比如往后跳10个指令,那么机器会自动将卡片带往后移动10个指令的位置,再来执行指令
194+
195+
同样的,机器也能向前移动,去读取之前已经执行过的指令
196+
197+
这也就是我们的while/for循环实现的原理。
198+
199+
如何通过if…else和goto来实现循环?
200+
201+
![](https://ask.qcloudimg.com/http-save/1752328/i34zi77nz8.png)
202+
203+
我们再看一段简单的利用for循环的程序。我们循环自增变量i三次,三次之后,i>=3,就会跳出循环。整个程序,对应的Intel汇编代码就是这样的:
204+
205+
![](https://ask.qcloudimg.com/http-save/1752328/se8ba2pt92.png)
206+
207+
可以看到,对应的循环也是用1e这个地址上的cmp比较指令
208+
209+
和紧接着的jle条件跳转指令来实现的
210+
211+
主要的差别在于,这里的jle跳转的地址,在这条指令之前的地址14,而非if…else编译出来的跳转指令之后
212+
213+
往前跳转使得条件满足的时候,PC寄存器会把指令地址设置到之前执行过的指令位置,重新执行之前执行过的指令,直到条件不满足,顺序往下执行jle之后的指令,整个循环才结束。
214+
215+
![](https://ask.qcloudimg.com/http-save/1752328/rc2n8yi1jl.png)
216+
217+
如果你看一长条打孔卡的话,就会看到卡片往后移动一段,执行了之后,又反向移动,去重新执行前面的指令。
218+
219+
jle和jmp指令,有点像程序语言里面的goto命令,直接指定了一个特定条件下的跳转位置
220+
221+
虽然我们在用高级语言开发程序的时候反对使用goto,但是实际在机器指令层面,无论是if…else…也好,还是for/while也好,都是用和goto相同的跳转到特定指令位置的方式来实现的。
222+
223+
# 3 总结
224+
225+
学习了程序里的多条指令,究竟是怎么样一条一条被执行的
226+
227+
除了简单地通过PC寄存器自增的方式顺序执行外
228+
229+
条件码寄存器会记录下当前执行指令的条件判断状态
230+
231+
然后通过跳转指令读取对应的条件码
232+
233+
修改PC寄存器内的下一条指令的地址
234+
235+
最终实现if…else以及for/while这样的程序控制流程。
236+
237+
虽然我们可以用高级语言,可以用不同的语法,比如 if…else 这样的条件分支,或者 while/for 这样的循环方式,来实现不用的程序运行流程
238+
239+
但是回归到计算机可以识别的机器指令级别,其实都只是一个简单的地址跳转而已,也就是一个类似于goto的语句。
240+
241+
想要在硬件层面实现这个goto语句,除了本身需要用来保存下一条指令地址,以及当前正要执行指令的PC寄存器、指令寄存器外
242+
243+
我们只需要再增加一个条件码寄存器,来保留条件判断的状态。这样简简单单的三个寄存器,就可以实现条件判断和循环重复执行代码的功能。
244+
245+
# 4 推荐阅读
246+
247+
- 《深入理解计算机系统》的第3章
248+
详细讲解了C语言和Intel CPU的汇编语言以及指令的对应关系,以及Intel CPU的各种寄存器和指令集。
249+
250+
Intel指令集相对于之前的MIPS指令集要复杂一些
251+
252+
- 所有的指令是变长的
253+
从1个字节到15个字节不等
254+
- 即使是汇编代码,还有很多针对操作数据的长度不同有不同的后缀
255+
256+
# 参考
257+
258+
- [状态寄存器](https://baike.baidu.com/item/%25E7%258A%25B6%25E6%2580%2581%25E5%25AF%2584%25E5%25AD%2598%25E5%2599%25A8)
259+
- [寄存器](https://zh.wikipedia.org/wiki/%25E5%25AF%2584%25E5%25AD%2598%25E5%2599%25A8)
260+
- [条件码](https://baike.baidu.com/item/%25E6%259D%25A1%25E4%25BB%25B6%25E7%25A0%2581)
261+
- [累加器](https://zh.wikipedia.org/wiki/%25E7%25B4%25AF%25E5%258A%25A0%25E5%2599%25A8)
262+
- 深入浅出计算机组成原理
263+
264+
# X 交流学习
265+
![](https://img-blog.csdnimg.cn/20190504005601174.jpg)
266+
## [Java交流群](https://jq.qq.com/?_wv=1027&k=5UB4P1T)
267+
## [博客](https://blog.csdn.net/qq_33589510)
268+
## [Github](https://github.com/Wasabi1234)
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
![](https://ask.qcloudimg.com/http-save/1752328/f502abne5u.png)
2+
3+
比尔·盖茨在上世纪80年代说的“640K ought to be enough for anyone”
4+
5+
也就是“640K内存对哪个人来说都够用了”
6+
7+
那个年代,微软开发的还是DOS操作系统,程序员们还在绞尽脑汁,想要用好这极为有限的640K内存
8+
9+
而现在,我手头的Mac Book Pro已经是16G内存了,上升了一万倍还不止。
10+
11+
那比尔·盖茨这句话在当时也是完全的无稽之谈么?有没有哪怕一点点的道理呢?这一讲里,我就和你一起来看一看。
12+
13+
# 1 程序装载的挑战
14+
15+
在运行这些可执行文件的时候,我们其实是通过一个装载器,解析ELF或者PE格式的可执行文件
16+
17+
装载器会把对应的指令和数据加载到内存里面来,让CPU去执行。
18+
19+
**装载到内存**,装载器需要满足两个要求
20+
21+
- 可执行程序加载后**占用的内存空间应该是连续的**
22+
执行指令的时候,程序计数器是顺序地一条一条指令执行。这意味着,这一条条指令需要连续地存储在一起
23+
- **需要同时加载很多个程序,并且不能让程序自己规定在内存中加载的位置**
24+
虽然编译出来的指令里已经有了对应的各种各样的内存地址,但是实际加载的时候,我们其实没有办法确保,这个程序一定加载在哪一段内存地址上
25+
因为现在的计算机通常会同时运行很多个程序,可能你想要的内存地址已经被其他加载了的程序占用
26+
27+
要满足这两个基本的要求,我们很容易想到一个办法。那就是我们可以在内存里面,找到一段连续的内存空间,然后分配给装载的程序,然后把这段连续的内存空间地址,和整个程序指令里指定的内存地址做一个映射。
28+
29+
指令里用到的内存地址叫作**虚拟内存地址(Virtual Memory Address)**
30+
31+
实际在内存硬件里面的空间地址,我们叫**物理内存地址(Physical Memory Address)**
32+
33+
程序里有指令和各种内存地址,我们只需要关心虚拟内存地址就行了
34+
35+
对于任何一个程序来说,它看到的都是同样的内存地址。我们维护一个虚拟内存到物理内存的映射表,这样实际程序指令执行的时候,会通过虚拟内存地址,找到对应的物理内存地址,然后执行。因为是连续的内存地址空间,所以我们只需要维护映射关系的起始地址和对应的空间大小就可以了。
36+
37+
# 2 内存分段
38+
39+
这种找出一段连续的物理内存和虚拟内存地址进行映射的方法,我们叫**分段(Segmentation)**
40+
41+
这里的段,就是指系统分配出来的那个连续的内存空间。
42+
43+
![](https://ask.qcloudimg.com/http-save/1752328/cm3kvg5lk5.png)
44+
45+
分段的办法很好,解决了程序本身不需要关心具体的物理内存地址的问题,但它也有一些不足之处,第一个就是**内存碎片(Memory Fragmentation)**
46+
47+
**举个例子**
48+
49+
电脑有1GB的内存
50+
51+
先启动一个图形渲染程序,占用了512MB的内存
52+
53+
接着启动一个Chrome浏览器,占用了128MB内存
54+
55+
再启动一个PY程序,占用了256MB内存
56+
57+
这个时候,我们关掉Chrome,于是空闲内存还有1024 - 512 - 256 = 256MB
58+
59+
按理来说,我们有足够的空间再去装载一个200MB的程序。但是,这256MB的内存空间不是连续的,而是被分成了两段128MB的内存
60+
61+
因此,实际情况是,我们的程序**没办法加载进来**
62+
63+
![](https://ask.qcloudimg.com/http-save/1752328/hzrjurkssp.png)
64+
65+
当然了,有办法解决 --- **内存交换(Memory Swapping)**
66+
67+
我们可以把Python程序占用的256MB内存写到硬盘,再从硬盘上读回来到内存里面
68+
69+
不过读回来的时候,我们不再把它加载到原来的位置,而是紧紧跟在那已经被占用了的512MB内存后面
70+
71+
这样,我们就有了连续的256MB内存空间,就可以去加载一个新的200MB的程序。如果你自己安装过Linux操作系统,你应该遇到过分配一个swap硬盘分区的问题
72+
73+
这块分出来的磁盘空间,其实就是专门给Linux操作系统进行**内存交换**用的。
74+
75+
**虚拟内存、分段,再加上内存交换**
76+
77+
看起来似乎已经解决了计算机同时装载运行很多个程序的问题
78+
79+
不过三者的组合仍然会遇到一个性能瓶颈
80+
81+
- 硬盘的访问速度要比内存慢很多
82+
- 而每一次内存交换,我们都需要把一大段连续的内存数据写到硬盘上
83+
84+
所以,如果内存交换的时候,交换的是一个很占内存空间的程序,这样整个机器都会显得卡顿。
85+
86+
# 3 内存分页
87+
88+
既然问题出在内存碎片和内存交换的空间太大上,那么解决问题的办法就是,少出现一些内存碎片。
89+
90+
另外,当需要进行内存交换的时候,让需要交换写入或者从磁盘装载的数据更少一点,这样就可以解决这个问题。
91+
92+
这个办法,在现在计算机的内存管理里面,就叫作**内存分页(Paging)**
93+
94+
\*\*和分段这样分配一整段连续的空间给到程序相比
95+
96+
分页则是把整个物理内存空间切成一段段固定尺寸的大小\*\*
97+
98+
而对应的程序所需要占用的虚拟内存空间,也会同样切成一段段固定尺寸的大小。
99+
100+
这样一个连续并且尺寸固定的内存空间,我们叫**页(Page)**
101+
102+
从虚拟内存到物理内存的映射,不再是拿整段连续的内存的物理地址,而是按照一个个页来的。
103+
104+
页的尺寸一般远远小于整个程序的大小。
105+
106+
**在Linux下,我们通常只设置成4KB**。你可以通过命令看看你手头的Linux系统设置的页的大小。
107+
108+
![](https://ask.qcloudimg.com/http-save/1752328/zswxol3en7.png)
109+
110+
由于内存空间都是预先划分好的,也就没有不能使用的碎片,而只有被释放出来的很多4KB的页。
111+
112+
即使内存空间不够,需要让现有的、正在运行的其他程序
113+
114+
通过内存交换释放出一些内存的页出来,一次性写入磁盘的也只有少数的一个页或者几个页,不会花太多时间,让整个机器被内存交换的过程给卡住。
115+
116+
![](https://ask.qcloudimg.com/http-save/1752328/634ez5hzoc.png)
117+
118+
分页的方式使得加载程序的时候,不再需要一次性把程序加载到物理内存中
119+
120+
可以在进行虚拟内存和物理内存的页之间的映射后,并不真的把页加载到物理内存里,而是只在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存里面去。
121+
122+
实际上,我们的操作系统,的确是这么做的
123+
124+
当要读取特定的页,却发现数据并没有加载到物理内存里的时候,就会触发一个来自于CPU的**缺页错误(Page Fault)**
125+
126+
操作系统会捕捉到这个错误,然后将对应的页,从存放在硬盘上的虚拟内存里读取出来,加载到物理内存里。这种方式,使得我们可以运行那些远大于我们实际物理内存的程序。同时,这样一来,任何程序都不需要一次性加载完所有指令和数据,只需要加载当前需要用到就行了。
127+
128+
通过虚拟内存、内存交换和内存分页这三个技术的组合,我们最终得到了一个让程序不需要考虑实际的物理内存地址、大小和当前分配空间的解决方案。
129+
130+
这些技术和方法,对于我们程序的编写、编译和链接过程都是透明的。这也是我们在计算机的软硬件开发中常用的一种方法,就是**加入一个间接层**
131+
132+
通过引入虚拟内存、页映射和内存交换,我们的程序本身,就不再需要考虑对应的真实的内存地址、程序加载、内存管理等问题了。任何一个程序,都只需要把内存当成是一块完整而连续的空间来直接使用。
133+
134+
# 4 总结
135+
136+
电脑只要640K内存就够了吗?很显然,现在来看,比尔·盖茨的这个判断是不合理的,那为什么他会这么认为呢?因为他也是一个很优秀的程序员啊!
137+
138+
在虚拟内存、内存交换和内存分页这三者结合之下,你会发现,其实要运行一个程序,“必需”的内存是很少的。CPU只需要执行当前的指令,极限情况下,内存也只需要加载一页就好了。再大的程序,也可以分成一页。每次,只在需要用到对应的数据和指令的时候,从硬盘上交换到内存里面来就好了。以我们现在4K内存一页的大小,640K内存也能放下足足160页呢,也无怪乎在比尔·盖茨会说出“640K ought to be enough for anyone”这样的话。
139+
140+
不过呢,硬盘的访问速度比内存慢很多,所以我们现在的计算机,没有个几G的内存都不好意思和人打招呼。
141+
142+
那么,除了程序分页装载这种方式之外,我们还有其他优化内存使用的方式么?下一讲,我们就一起来看看“动态装载”,学习一下让两个不同的应用程序,共用一个共享程序库的办法。
143+
144+
# 5 推荐阅读
145+
146+
想要更深入地了解代码装载的详细过程,推荐你阅读《程序员的自我修养——链接、装载和库》的第1章和第6章。
147+
148+
# 6 思考
149+
150+
在Java这样使用虚拟机的编程语言里面,我们写的程序是怎么装载到内存里面来的呢?它也和我们讲的一样,是通过内存分页和内存交换的方式加载到内存里面来的么?
151+
152+
jvm已经是上层应用,无需考虑物理分页,一般更直接是考虑对象本身的空间大小,物理硬件管理统一由承载jvm的操纵系统去解决吧
153+
154+
# 参考
155+
156+
深入浅出计算机组成原理
157+
158+
# X 交流学习
159+
![](https://img-blog.csdnimg.cn/20190504005601174.jpg)
160+
## [Java交流群](https://jq.qq.com/?_wv=1027&k=5UB4P1T)
161+
## [博客](https://blog.csdn.net/qq_33589510)
162+
## [Github](https://github.com/Wasabi1234)
Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
![](https://ask.qcloudimg.com/http-save/1752328/ejqq3ckf3d.png)
2+
3+
用Google搜异常信息,肯定都访问过[Stack Overflow网站](https://stackoverflow.com/)
4+
5+
> 全球最大的程序员问答网站,名字来自于一个常见的报错,就是栈溢出(stack overflow)
6+
7+
从函数调用开始,在计算机指令层面函数间的相互调用是怎么实现的,以及什么情况下会发生栈溢出
8+
9+
# 1 栈的意义
10+
11+
先看一个简单的C程序
12+
13+
- function.c
14+
![在这里插入图片描述](https://ask.qcloudimg.com/http-save/1752328/p736hs2ow8.png)
15+
- 直接在Linux中使用GCC编译运行
16+
17+
```
18+
[hadoop@JavaEdge Documents]$ vim function.c
19+
[hadoop@JavaEdge Documents]$ gcc -g -c function.c
20+
[hadoop@JavaEdge Documents]$ objdump -d -M intel -S function.o
21+
22+
function.o: file format elf64-x86-64
23+
24+
25+
Disassembly of section .text:
26+
27+
0000000000000000 <add>:
28+
#include <stdio.h>
29+
int static add(int a, int b)
30+
{
31+
0: 55 push rbp
32+
1: 48 89 e5 mov rbp,rsp
33+
4: 89 7d fc mov DWORD PTR [rbp-0x4],edi
34+
7: 89 75 f8 mov DWORD PTR [rbp-0x8],esi
35+
a: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8]
36+
d: 8b 55 fc mov edx,DWORD PTR [rbp-0x4]
37+
10: 01 d0 add eax,edx
38+
12: 5d pop rbp
39+
13: c3 ret
40+
41+
0000000000000014 <main>:
42+
return a+b;
43+
}
44+
45+
46+
int main()
47+
{
48+
14: 55 push rbp
49+
15: 48 89 e5 mov rbp,rsp
50+
18: 48 83 ec 10 sub rsp,0x10
51+
int x = 5;
52+
1c: c7 45 fc 05 00 00 00 mov DWORD PTR [rbp-0x4],0x5
53+
int y = 10;
54+
23: c7 45 f8 0a 00 00 00 mov DWORD PTR [rbp-0x8],0xa
55+
int u = add(x, y);
56+
57+
2a: 8b 55 f8 mov edx,DWORD PTR [rbp-0x8]
58+
2d: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
59+
30: 89 d6 mov esi,edx
60+
32: 89 c7 mov edi,eax
61+
34: e8 c7 ff ff ff call 0 <add>
62+
39: 89 45 f4 mov DWORD PTR [rbp-0xc],eax
63+
return 0;
64+
3c: b8 00 00 00 00 mov eax,0x0
65+
}
66+
41: c9 leave
67+
42: c3 ret
68+
```
69+
70+
main函数和上一节我们讲的的程序执行区别不大,主要是把jump指令换成了函数调用的call指令,call指令后面跟着的,仍然是跳转后的程序地址
71+
72+
**看看add函数**
73+
74+
add函数编译后,代码先执行了一条push指令和一条mov指令
75+
76+
在函数执行结束的时候,又执行了一条pop和一条ret指令
77+
78+
这四条指令的执行,其实就是在进行我们接下来要讲**压栈(Push)和出栈(Pop)**
79+
80+
函数调用和上一节我们讲的if…else和for/while循环有点像
81+
82+
都是在原来顺序执行的指令过程里,执行了一个内存地址的跳转指令,让指令从原来顺序执行的过程里跳开,从新的跳转后的位置开始执行。
83+
84+
**但是,这两个跳转有个区别**
85+
86+
- if…else和for/while的跳转,是跳转走了就不再回来了,就在跳转后的新地址开始顺序地执行指令,后会无期
87+
- 函数调用的跳转,在对应函数的指令执行完了之后,还要再回到函数调用的地方,继续执行call之后的指令,地球毕竟是圆的
88+
89+
**有没有一个可以不跳回原来开始的地方,从而实现函数的调用呢**
90+
91+
似乎有.可以把调用的函数指令,直接插入在调用函数的地方,替换掉对应的call指令,然后在编译器编译代码的时候,直接就把函数调用变成对应的指令替换掉。
92+
93+
不过思考一下,你会发现**漏洞**
94+
95+
如果函数A调用了函数B,然后函数B再调用函数A,我们就得面临在A里面插入B的指令,然后在B里面插入A的指令,这样就会产生无穷无尽地替换。
96+
97+
就好像两面镜子面对面放在一块儿,任何一面镜子里面都会看到无穷多面镜子
98+
99+
![](https://ask.qcloudimg.com/http-save/1752328/74k9htdn0d.png)
100+
101+
**Infinite Mirror Effect**
102+
103+
如果函数A调用B,B再调用A,那么代码会无限展开
104+
105+
那就换一个思路,能不能把后面要跳回来执行的指令地址给记录下来呢?
106+
107+
就像PC寄存器一样,可以专门设立一个“程序调用寄存器”,存储接下来要跳转回来执行的指令地址
108+
109+
等到函数调用结束,从这个寄存器里取出地址,再跳转到这个记录的地址,继续执行就好了。
110+
111+
**但在多层函数调用里,只记录一个地址是不够的**
112+
113+
在调用函数A之后,A还可以调用函数B,B还能调用函数C
114+
115+
这一层又一层的调用并没有数量上的限制
116+
117+
在所有函数调用返回之前,每一次调用的返回地址都要记录下来,但是我们CPU里的寄存器数量并不多
118+
119+
> 像我们一般使用的Intel i7 CPU只有16个64位寄存器,调用的层数一多就存不下了。
120+
121+
最终,CSer们想到了一个比单独记录跳转回来的地址更完善的办法
122+
123+
在内存里面开辟一段空间,用栈这个后进先出(LIFO,Last In First Out)的数据结构
124+
125+
> 栈就像一个乒乓球桶,每次程序调用函数之前,我们都把调用返回后的地址写在一个乒乓球上,然后塞进这个球桶
126+
> 这个操作其实就是我们常说的压栈。如果函数执行完了,我们就从球桶里取出最上面的那个乒乓球,很显然,这就是出栈。
127+
128+
拿到出栈的乒乓球,找到上面的地址,把程序跳转过去,就返回到了函数调用后的下一条指令了
129+
130+
如果函数A在执行完成之前又调用了函数B,那么在取出乒乓球之前,我们需要往球桶里塞一个乒乓球。而我们从球桶最上面拿乒乓球的时候,拿的也一定是最近一次的,也就是最下面一层的函数调用完成后的地址
131+
132+
乒乓球桶的底部,就是**栈底**,最上面的乒乓球所在的位置,就是**栈顶**
133+
134+
![](https://ask.qcloudimg.com/http-save/1752328/gon9gfqvs4.png)
135+
136+
**压栈的不只有函数调用完成后的返回地址**
137+
138+
比如函数A在调用B的时候,需要传输一些参数数据,这些参数数据在寄存器不够用的时候也会被压入栈中
139+
140+
整个函数A所占用的所有内存空间,就是函数A的栈帧(Stack Frame)
141+
142+
> Frame在中文里也有“相框”的意思,所以,每次到这里,都有种感觉,整个函数A所需要的内存空间就像是被这么一个“相框”给框了起来,放在了栈里面。
143+
144+
而实际的程序栈布局,顶和底与我们的乒乓球桶相比是倒过来的
145+
146+
底在最上面,顶在最下面,这样的布局是因为栈底的内存地址是在一开始就固定的。而一层层压栈之后,栈顶的内存地址是在逐渐变小而不是变大
147+
148+
![](https://ask.qcloudimg.com/http-save/1752328/kufrh763at.png)
149+
150+
对应上面函数add的汇编代码,我们来仔细看看,main函数调用add函数时
151+
152+
- add函数入口在0~1行
153+
- add函数结束之后在12~13行
154+
155+
在调用第34行的call指令时,会把当前的PC寄存器里的下一条指令的地址压栈,保留函数调用结束后要执行的指令地址
156+
157+
- 而add函数的第0行,push rbp指令,就是在压栈
158+
这里的rbp又叫**栈帧指针(Frame Pointer)**,存放了当前栈帧位置的寄存器。push rbp就把之前调用函数,也就是main函数的栈帧的栈底地址,压到栈顶。
159+
- 第1行的一条命令mov rbp, rsp,则是把rsp这个栈指针(Stack Pointer)的值复制到rbp里,而rsp始终会指向栈顶
160+
这个命令意味着,rbp这个栈帧指针指向的地址,变成当前最新的栈顶,也就是add函数的栈帧的栈底地址了。
161+
- 在函数add执行完成之后,又会分别调用第12行的pop rbp
162+
将当前的栈顶出栈,这部分操作维护好了我们整个栈帧
163+
- 然后调用第13行的ret指令,这时候同时要把call调用的时候压入的PC寄存器里的下一条指令出栈,更新到PC寄存器中,将程序的控制权返回到出栈后的栈顶。
164+
165+
# 2 构造Stack Overflow
166+
167+
通过引入栈,我们可以看到,无论有多少层的函数调用,或者在函数A里调用函数B,再在函数B里调用A
168+
169+
这样的递归调用,我们都只需要通过维持rbp和rsp,这两个维护栈顶所在地址的寄存器,就能管理好不同函数之间的跳转
170+
171+
不过,栈的大小也是有限的。如果函数调用层数太多,我们往栈里压入它存不下的内容,程序在执行的过程中就会遇到栈溢出的错误,这就是**stack overflow**
172+
173+
**构造一个栈溢出的错误**
174+
175+
并不困难,最简单的办法,就是我们上面说的**Infiinite Mirror Effect**的方式,让函数A调用自己,并且不设任何终止条件
176+
177+
这样一个无限递归的程序,在不断地压栈过程中,将整个栈空间填满,并最终遇上stack overflow。
178+
179+
```
180+
int a()
181+
{
182+
return a();
183+
}
184+
185+
186+
int main()
187+
{
188+
a();
189+
return 0;
190+
}
191+
```
192+
193+
除了无限递归,递归层数过深,在栈空间里面创建非常占内存的变量(比如一个巨大的数组),这些情况都很可能给你带来stack overflow
194+
195+
相信你理解了栈在程序运行的过程里面是怎么回事,未来在遇到stackoverflow这个错误的时候,不会完全没有方向了。
196+
197+
# 3 利用函数内联实现性能优化
198+
199+
上面我们提到一个方法,把一个实际调用的函数产生的指令,直接插入到的位置,来替换对应的函数调用指令。尽管这个通用的函数调用方案,被我们否决了,但是如果被调用的函数里,没有调用其他函数,这个方法还是可以行得通的。
200+
201+
事实上,这就是一个常见的编译器进行自动优化的场景,我们通常叫**函数内联(Inline)**
202+
203+
只要在GCC编译的时候,加上对应的一个让编译器自动优化的参数-O,编译器就会在可行的情况下,进行这样的指令替换。
204+
205+
- 案例
206+
![](https://ask.qcloudimg.com/http-save/1752328/ko83ajuodf.png)
207+
为了避免编译器优化掉太多代码,小小修改了一下function.c,让参数x和y都变成了,通过随机数生成,并在代码的最后加上将u通过printf打印
208+
209+
```
210+
[hadoop@JavaEdge Documents]$ vim function.c
211+
[hadoop@JavaEdge Documents]$ gcc -g -c -O function.c
212+
[hadoop@JavaEdge Documents]$ objdump -d -M intel -S function.o
213+
214+
function.o: file format elf64-x86-64
215+
216+
217+
Disassembly of section .text:
218+
219+
0000000000000000 <main>:
220+
{
221+
return a+b;
222+
}
223+
224+
int main()
225+
{
226+
0: 53 push rbx
227+
1: bf 00 00 00 00 mov edi,0x0
228+
6: e8 00 00 00 00 call b <main+0xb>
229+
b: 89 c7 mov edi,eax
230+
d: e8 00 00 00 00 call 12 <main+0x12>
231+
12: e8 00 00 00 00 call 17 <main+0x17>
232+
17: 89 c3 mov ebx,eax
233+
19: e8 00 00 00 00 call 1e <main+0x1e>
234+
1e: 89 c1 mov ecx,eax
235+
20: bf 67 66 66 66 mov edi,0x66666667
236+
25: 89 d8 mov eax,ebx
237+
27: f7 ef imul edi
238+
29: d1 fa sar edx,1
239+
2b: 89 d8 mov eax,ebx
240+
2d: c1 f8 1f sar eax,0x1f
241+
30: 29 c2 sub edx,eax
242+
32: 8d 04 92 lea eax,[rdx+rdx*4]
243+
35: 29 c3 sub ebx,eax
244+
37: 89 c8 mov eax,ecx
245+
39: f7 ef imul edi
246+
3b: c1 fa 02 sar edx,0x2
247+
3e: 89 d7 mov edi,edx
248+
40: 89 c8 mov eax,ecx
249+
42: c1 f8 1f sar eax,0x1f
250+
45: 29 c7 sub edi,eax
251+
47: 8d 04 bf lea eax,[rdi+rdi*4]
252+
4a: 01 c0 add eax,eax
253+
4c: 29 c1 sub ecx,eax
254+
#include <time.h>
255+
#include <stdlib.h>
256+
257+
int static add(int a, int b)
258+
{
259+
return a+b;
260+
4e: 8d 34 0b lea esi,[rbx+rcx*1]
261+
{
262+
srand(time(NULL));
263+
int x = rand() % 5;
264+
int y = rand() % 10;
265+
int u = add(x, y);
266+
printf("u = %d\n", u);
267+
51: bf 00 00 00 00 mov edi,0x0
268+
56: b8 00 00 00 00 mov eax,0x0
269+
5b: e8 00 00 00 00 call 60 <main+0x60>
270+
60: b8 00 00 00 00 mov eax,0x0
271+
65: 5b pop rbx
272+
66: c3 ret
273+
```
274+
275+
上面的function.c的编译出来的汇编代码,没有把add函数单独编译成一段指令顺序,而是在调用u = add(x, y)的时候,直接替换成了一个add指令。
276+
277+
除了依靠编译器的自动优化,你还可以在定义函数的地方,加上inline的关键字,来提示编译器对函数进行内联。
278+
279+
内联带来的优化是,CPU需要执行的指令数变少了,根据地址跳转的过程不需要了,压栈和出栈的过程也不用了。
280+
281+
不过内联并不是没有代价,内联意味着,我们把可以复用的程序指令在调用它的地方完全展开了。如果一个函数在很多地方都被调用了,那么就会展开很多次,整个程序占用的空间就会变大了。
282+
283+
这样没有调用其他函数,只会被调用的函数,我们一般称之为**叶子函数(或叶子过程)**
284+
285+
![](https://ask.qcloudimg.com/http-save/1752328/tbxu52xt3z.png)
286+
287+
# 3 总结
288+
289+
这一节,我们讲了一个程序的函数间调用,在CPU指令层面是怎么执行的。其中一定需要你牢记的,就是**程序栈**这个新概念。
290+
291+
我们可以方便地通过压栈和出栈操作,使得程序在不同的函数调用过程中进行转移。而函数内联和栈溢出,一个是我们常常可以选择的优化方案,另一个则是我们会常遇到的程序Bug。
292+
293+
通过加入了程序栈,我们相当于在指令跳转的过程种,加入了一个“记忆”的功能,能在跳转去运行新的指令之后,再回到跳出去的位置,能够实现更加丰富和灵活的指令执行流程。这个也为我们在程序开发的过程中,提供了“函数”这样一个抽象,使得我们在软件开发的过程中,可以复用代码和指令,而不是只能简单粗暴地复制、粘贴代码和指令。
294+
295+
# 4 推荐阅读
296+
297+
可以仔细读一下《深入理解计算机系统(第三版)》的3.7小节《过程》,进一步了解函数调用是怎么回事。
298+
299+
另外,我推荐你花一点时间,通过搜索引擎搞清楚function.c每一行汇编代码的含义,这个能够帮你进一步深入了解程序栈、栈帧、寄存器以及Intel CPU的指令集。
300+
301+
# 参考
302+
303+
深入浅出计算机组成原理
304+
305+
# X 交流学习
306+
![](https://img-blog.csdnimg.cn/20190504005601174.jpg)
307+
## [Java交流群](https://jq.qq.com/?_wv=1027&k=5UB4P1T)
308+
## [博客](https://blog.csdn.net/qq_33589510)
309+
## [Github](https://github.com/Wasabi1234)
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
![](https://ask.qcloudimg.com/http-save/1752328/jfhxac13bw.png)
2+
3+
人用纸和笔来做运算,都是用十进制,直接用十进制和我们最熟悉的符号不是最简单么?
4+
5+
为什么计算机里我们最终要选择二进制呢?
6+
7+
来看看,计算机在硬件层面究竟是怎么表示二进制的,你就会明白,为什么计算机会选择二进制。
8+
9+
# 1 怎么做到“千里传书”
10+
11+
> 马拉松的故事相信你听说过。公元前490年,在雅典附近的马拉松海边,发生了波斯和希腊之间的希波战争。雅典和斯巴达领导的希腊联军胜利之后,雅典飞毛腿菲迪皮德斯跑了历史上第一个马拉松,回雅典报喜。这个时候,人们在远距离报信的时候,采用的是派人跑腿,传口信或者送信的方式。
12+
13+
但是,这样靠人传口信或者送信的方式,实在是太慢了
14+
15+
在军事用途中,信息能否更早更准确地传递出去经常是事关成败的大事
16+
17+
所以我们看到中国古代的军队有“击鼓进军”和“鸣金收兵”,通过打鼓和敲钲发出不同的声音,来传递军队的号令。
18+
19+
如果我们把军队当成一台计算机,那“金”和“鼓”就是这台计算机的“1”和“0”
20+
21+
我们可以通过不同的编码方式,来指挥这支军队前进、后退、转向、追击等等。
22+
23+
“金”和“鼓”比起跑腿传口信,固然效率更高了,但是能够传递的范围还是非常有限,超出个几公里恐怕就听不见了。于是,人们发明了更多能够往更远距离传信的方式,比如海上的灯塔、长城上的烽火台。因为光速比声速更快,传的距离也可以更远。
24+
25+
- 亚历山大港外的法罗斯灯塔,位列世界七大奇迹之一,可惜现在只剩下遗迹了。可见人类社会很早就学会使用类似二进制信号的方式来传输信息
26+
![](https://ask.qcloudimg.com/http-save/1752328/zmmgt39vdy.png)
27+
但是,这些传递信息的方式都面临一个问题,就是受限于只有“1”和“0”这两种信号,不能传递太复杂的信息,那电报的发明就解决了这个问题。
28+
29+
从信息编码的角度来说,金、鼓、灯塔、烽火台类似电报的二进制编码
30+
31+
电报传输的信号有两种,一种是短促的点信号(dot信号),一种是长一点的划信号(dash信号)
32+
33+
我们把“点”当成“1”,把“划”当成“0”。这样一来,我们的电报信号就是另一种特殊的二进制编码了
34+
35+
电影里最常见的电报信号是“SOS”,这个信号表示出来就是 “点点点划划划点点点”。
36+
37+
比起灯塔和烽火台这样的设备,电报信号有两个明显的优势
38+
39+
- 信号的传输距离迅速增加
40+
电报本质上是通过电信号来进行传播的,所以从输入信号到输出信号基本上没有延时
41+
- 输入信号的速度加快了很多
42+
电报机只有一个按钮,按下就是输入信号,按的时间短一点,就是发出了一个“点”信号
43+
按的时间长一些,就是一个“划”信号
44+
一个手指,就能快速发送电报。
45+
- 一个摩尔斯电码的电报机
46+
![](https://ask.qcloudimg.com/http-save/1752328/csg7idsg3y.png)
47+
48+
制造一台电报机也非常容易
49+
50+
电报机本质上就是一个“蜂鸣器+长长的电线+按钮开关”
51+
52+
> 蜂鸣器装在接收方手里,开关留在发送方手里。双方用长长的电线连在一起。当按钮开关按下的时候,电线的电路接通了,蜂鸣器就会响。短促地按下,就是一个短促的点信号;按的时间稍微长一些,就是一个稍长的划信号。
53+
> ![](https://ask.qcloudimg.com/http-save/1752328/s8yfwfci8l.png)
54+
55+
有了电池开关和铃铛,你就有了最简单的摩尔斯电码发报机
56+
57+
# 2 理解继电器,给跑不动的信号+1s
58+
59+
有了电报机,只要铺设好电报线路,就可以传输我们需要的讯息了
60+
61+
但是这里面又出现了一个新的挑战,就是随着电线的线路越长,电线的电阻就越大
62+
63+
当电阻很大,而电压不够的时候,即使你按下开关,蜂鸣器也不会响。
64+
65+
你可能要说了,我们可以提高电压或者用更粗的电线,使得电阻更小,这样就可以让整个线路铺得更长一些
66+
67+
但是这个再长,也没办法从北京铺设到上海吧
68+
69+
要想从北京把电报发到上海,我们还得想些别的办法。
70+
71+
对于电报来说,电线太长了,使得线路接通也没有办法让蜂鸣器响起来
72+
73+
那么,我们就不要一次铺太长的线路,而把一小段距离当成一个线路,也和驿站建立一个小电报站。我们在小电报站里面安排一个电报员,他听到上一个小电报站发来的信息,然后原样输入,发到下一个电报站去
74+
75+
这样,我们的信号就可以一段段传输下去,而不会因为距离太长,导致电阻太大,没有办法成功传输信号。为了能够实现这样接力传输信号,在电路里面,工程师们造了一个叫作**继电器(Relay)** 的设备。
76+
77+
- 中继,其实就是不断地通过新的电源重新放大已经开始衰减的原有信号![](https://ask.qcloudimg.com/http-save/1752328/s4uucnv1z8.png)
78+
79+
事实上,这个过程中,我们需要在每一阶段原样传输信号,是不是可以设计一个设备来代替这个电报员?
80+
81+
相比使用人工听蜂鸣器的声音,来重复输入信号,利用电磁效应和磁铁,来实现这个事情会更容易。
82+
83+
我们把原先用来输出声音的蜂鸣器,换成一段环形的螺旋线圈,让电路封闭通上电。因为电磁效应,这段螺旋线圈会产生一个带有磁性的电磁场。我们原本需要输入的按钮开关,就可以用一块磁力稍弱的磁铁把它设在“关”的状态。这样,按下上一个电报站的开关,螺旋线圈通电产生了磁场之后,磁力就会把开关“吸”下来,接通到下一个电报站的电路。
84+
85+
如果我们在中间所有小电报站都用这个“螺旋线圈+磁性开关”的方式,来替代蜂鸣器和普通开关,而只在电报的始发和终点用普通的开关和蜂鸣器,我们就有了一个拆成一段一段的电报线路,接力传输电报信号。这样,我们就不需要中间安排人力来听打电报内容,也不需要解决因为线缆太长导致的电阻太大或者电压不足的问题了。我们只要在终点站安排电报员,听写最终的电报内容就可以了。这样是不是比之前更省事了?
86+
87+
事实上,继电器还有一个名字就叫作电驿,这个“驿”就是驿站的驿,可以说非常形象了
88+
89+
这个接力的策略不仅可以用在电报中,在通信类的科技产品中其实都可以用到。
90+
91+
比如说,你在家里用WiFi,如果你的屋子比较大,可能某些房间的信号就不好。你可以选用支持“中继”的WiFi路由器,在信号衰减的地方,增加一个WiFi设备,接收原来的WiFi信号,再重新从当前节点传输出去。这种中继对应的英文名词和继电器是一样的,也叫Relay。
92+
93+
再比如说,我们现在互联网使用的光缆,是用光信号来传输数据。随着距离的增长、反射次数的增加,信号也会有所衰减,我们同样要每隔一段距离,来增加一个用来重新放大信号的中继。
94+
95+
有了继电器之后,我们不仅有了一个能够接力传输信号的方式,更重要的是,和输入端通过开关的“开”和“关”来表示“1”和“0”一样,我们在输出端也能表示“1”和“0”了。
96+
97+
输出端的作用,不仅仅是通过一个蜂鸣器或者灯泡,提供一个供人观察的输出信号,通过“螺旋线圈 + 磁性开关”,使得我们有“开”和“关”这两种状态,这个“开”和“关”表示的“1”和“0”,还可以作为后续线路的输入信号,让我们开始可以通过最简单的电路,来组合形成我们需要的逻辑。
98+
99+
通过这些线圈和开关,我们也可以很容易地创建出 “与(AND)”“或(OR)”“非(NOT)”这样的逻辑。我们在输入端的电路上,提供串联的两个开关,只有两个开关都打开,电路才接通,输出的开关也才能接通,这其实就是模拟了计算机里面的“与”操作。
100+
101+
我们在输入端的电路,提供两条独立的线路到输出端,两条线路上各有一个开关,那么任何一个开关打开了,到输出端的电路都是接通的,这其实就是模拟了计算机中的“或”操作。
102+
103+
当我们把输出端的“螺旋线圈+磁性开关”的组合,从默认关掉,只有通电有了磁场之后打开,换成默认是打开通电的,只有通电之后才关闭,我们就得到了一个计算机中的“非”操作。输出端开和关正好和输入端相反。这个在数字电路中,也叫作**反向器(Inverter)**
104+
105+
![](https://ask.qcloudimg.com/http-save/1752328/z3c8dedjuj.png)
106+
107+
反向器的电路,其实就是开关从默认关闭变成默认开启而已
108+
109+
与、或、非的电路都非常简单,要想做稍微复杂一点的工作,我们需要很多电路的组合。不过,这也彰显了现代计算机体系中一个重要的思想,就是通过分层和组合,逐步搭建起更加强大的功能。
110+
111+
回到我们前面看的电报机原型,虽然一个按钮开关的电报机很“容易”操作,但是却不“方便”操作。因为电报员要熟记每一个字母对应的摩尔斯电码,并且需要快速按键来进行输入。一旦输错很难纠正。但是,因为电路之间可以通过与、或、非组合完成更复杂的功能,我们完全可以设计一个和打字机一样的电报机,每按下一个字母按钮,就会接通一部分电路,然后把这个字母的摩尔斯电码输出出去。
112+
113+
虽然在电报机时代,我们没有这么做,但是在计算机时代,我们其实就是这样做的。我们不再是给计算机“0”和“1”,而是通过千万个晶体管组合在一起,最终使得我们可以用“高级语言”,指挥计算机去干什么。
114+
115+
# 3 总结延伸
116+
117+
可以说,电报是现代计算机的一个最简单的原型。它和我们现在使用的现代计算机有很多相似之处。我们通过电路的“开”和“关”,来表示“1”和“0”。就像晶体管在不同的情况下,表现为导电的“1”和绝缘的“0”的状态。
118+
119+
我们通过电报机这个设备,看到了如何通过“螺旋线圈+开关”,来构造基本的逻辑电路,我们也叫门电路
120+
121+
- 一方面,我们可以通过继电器或者中继,进行长距离的信号传输
122+
- 另一方面,我们也可以通过设置不同的线路和开关状态,实现更多不同的信号表示和处理方式,这些线路的连接方式其实就是我们在数字电路中所说的门电路。而这些门电路,也是我们创建CPU和内存的基本逻辑单元。我们的各种对于计算机二进制的“0”和“1”的操作,其实就是来自于门电路,叫作组合逻辑电路。
123+
124+
# 4 推荐阅读
125+
126+
《编码:隐匿在计算机软硬件背后的语言》第6~11章,是一个很好的入门材料,可以帮助深入理解数字电路,值得你花时间好好读一读
127+
128+
![](https://ask.qcloudimg.com/http-save/1752328/vd7igp0ezh.png)
129+
130+
# X 交流学习
131+
![](https://img-blog.csdnimg.cn/20190504005601174.jpg)
132+
## [Java交流群](https://jq.qq.com/?_wv=1027&k=5UB4P1T)
133+
## [博客](https://blog.csdn.net/qq_33589510)
134+
## [Github](https://github.com/Wasabi1234)
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
![](https://ask.qcloudimg.com/http-save/1752328/behwbrbqz3.png)
2+
3+
> 程序 = 算法 + 数据结构
4+
5+
对应到计算机的组成原理(硬件层面)
6+
7+
- 算法 --- 各种计算机指令
8+
- 数据结构 --- 二进制数据
9+
10+
计算机用0/1组成的二进制,来表示所有信息
11+
12+
- 程序指令用到的机器码,是使用二进制表示的
13+
- 存储在内存里面的字符串、整数、浮点数也都是用二进制表示的
14+
15+
万物在计算机里都是0和1,搞清楚各种数据在二进制层面是怎么表示的,是我们的必修课。
16+
17+
在实际应用中最常遇到的问题,也就是文本字符串是怎么表示成二进制的,特别是我们会遇到的乱码究竟是怎么回事儿
18+
19+
在开发的时候,所说的Unicode和UTF-8之间有什么关系。
20+
21+
理解了这些,相信以后遇到任何乱码问题,你都能手到擒来了。
22+
23+
# 1 理解二进制的“逢二进一”
24+
25+
二进制和我们平时用的十进制,并没有本质区别,只是平时是“逢十进一”,这里变成了“逢二进一”
26+
27+
每一位,相比于十进制下的0~9这十个数字,我们只能用0和1这两个数字。
28+
29+
任何一个十进制的整数,都能通过二进制表示出来
30+
31+
把一个二进制数,对应到十进制,非常简单,就是把从右到左的第N位,乘上一个2的N次方,然后加起来,就变成了一个十进制数
32+
33+
当然,既然二进制是一个面向程序员的“语言”,这个从右到左的位置,自然是从0开始的。
34+
35+
比如_0011_这个二进制数,对应的十进制表示,就是
36+
37+
> $0×2^3+0×2^2+1×2^1+1×2^0$
38+
> $=3$
39+
40+
代表十进制的3
41+
42+
对应地,如果我们想要把一个十进制的数,转化成二进制,使用**短除法**就可以了
43+
44+
也就是,把十进制数除以2的余数,作为最右边的一位。然后用商继续除以2,把对应的余数紧靠着刚才余数的右侧,这样递归迭代,直到商为0就可以了。
45+
46+
- 比如,我们想把13这个十进制数,用短除法转化成二进制,需要经历以下几个步骤:
47+
![](https://ask.qcloudimg.com/http-save/1752328/q88074vf1x.png)
48+
因此,对应的二进制数,就是1101
49+
50+
刚才我们举的例子都是正数,对于负数来说,情况也是一样的吗?
51+
52+
我们可以把一个数最左侧的一位,当成是对应的正负号,比如0为正数,1为负数,这样来进行标记。
53+
54+
这样,一个4位的二进制数, 0011就表示为+3。而1011最左侧的第一位是1,所以它就表示-3。这个其实就是整数的**原码表示法**
55+
56+
原码表示法有一个很直观的缺点就是,0可以用两个不同的编码来表示,1000代表0, 0000也代表0。习惯万事一一对应的程序员看到这种情况,必然会被“逼死”。
57+
58+
于是,我们就有了另一种表示方法。我们仍然通过最左侧第一位的0和1,来判断这个数的正负。但是,我们不再把这一位当成单独的符号位,在剩下几位计算出的十进制前加上正负号,而是在计算整个二进制值的时候,在左侧最高位前面加个负号。
59+
60+
比如,一个4位的二进制补码数值1011,转换成十进制,就是
61+
62+
> $-1×2^3+0×2^2+1×2^1+1×2^0$
63+
> $=-5$
64+
65+
如果最高位是1,这个数必然是负数;最高位是0,必然是正数。并且,只有0000表示0,1000在这样的情况下表示-8。一个4位的二进制数,可以表示从-8到7这16个整数,不会白白浪费一位。
66+
67+
当然更重要的一点是,用补码来表示负数,使得我们的整数相加变得很容易,不需要做任何特殊处理,只是把它当成普通的二进制相加,就能得到正确的结果。
68+
69+
我们简单一点,拿一个4位的整数来算一下,比如 -5 + 4 = -1,-5 + 6 = 1
70+
71+
我们各自把它们转换成二进制来看一看。如果它们和无符号的二进制整数的加法用的是同样的计算方式,这也就意味着它们是同样的电路。
72+
73+
![](https://ask.qcloudimg.com/http-save/1752328/uwxc817k5l.png)
74+
75+
# 2 字符串的表示,从编码到数字
76+
77+
不仅数值可以用二进制表示,字符乃至更多的信息都能用二进制表示
78+
79+
最典型的例子就是**字符串(Character String)**
80+
81+
最早计算机只需要使用英文字符,加上数字和一些特殊符号,然后用8位的二进制,就能表示我们日常需要的所有字符了,这个就是我们常常说的**ASCII码(American Standard Code for Information Interchange,美国信息交换标准代码)**
82+
83+
![](https://ask.qcloudimg.com/http-save/1752328/nkc6zbx230.png)
84+
85+
ASCII码就好比一个字典,用8位二进制中的128个不同的数,映射到128个不同的字符里
86+
87+
> 比如,小写字母a在ASCII里面,就是第97个,也就是二进制的0110 0001,对应的十六进制表示就是 61。而大写字母 A,就是第65个,也就是二进制的0100 0001,对应的十六进制表示就是41。
88+
89+
在ASCII码里面,数字9不再像整数表示法里一样,用0000 1001来表示,而是用0011 1001 来表示。字符串15也不是用0000 1111 这8位来表示,而是变成两个字符1和5连续放在一起,也就是 0011 0001 和 0011 0101,需要用两个8位来表示。
90+
91+
我们可以看到,最大的32位整数,就是2147483647。如果用整数表示法,只需要32位就能表示了。但是如果用字符串来表示,一共有10个字符,每个字符用8位的话,需要整整80位。比起整数表示法,要多占很多空间。
92+
93+
这也是为什么,很多时候我们在存储数据的时候,要采用二进制序列化这样的方式,而不是简单地把数据通过CSV或者JSON,这样的文本格式存储来进行序列化。不管是整数也好,浮点数也好,采用二进制序列化会比存储文本省下不少空间。
94+
95+
ASCII码只表示了128个字符,一开始倒也堪用,毕竟计算机是在美国发明的
96+
97+
然而随着越来越多的不同国家的人都用上了计算机,想要表示譬如中文这样的文字,128个字符显然是不太够用的。于是,计算机工程师们开始各显神通,给自己国家的语言创建了对应的**字符集(Charset)和字符编码(Character Encoding)**
98+
99+
## 字符集
100+
101+
表示的可以是字符的一个集合
102+
103+
比如“中文”就是一个字符集,不过这样描述一个字符集并不准确
104+
105+
想要更精确一点,我们可以说,“第一版《新华字典》里面出现的所有汉字”,这是一个字符集。这样,我们才能明确知道,一个字符在不在这个集合里面
106+
107+
比如,我们日常说的Unicode,其实就是一个字符集,包含了150种语言的14万个不同的字符。
108+
109+
## 字符编码
110+
111+
则是对于字符集里的这些字符,怎么一一用二进制表示出来的一个字典
112+
113+
我们上面说的Unicode,就可以用UTF-8、UTF-16,乃至UTF-32来进行编码,存储成二进制。所以,有了Unicode,其实我们可以用不止UTF-8一种编码形式,我们也可以自己发明一套 GT-32 编码,比如就叫作Geek Time 32好了。只要别人知道这套编码规则,就可以正常传输、显示这段代码。
114+
115+
![](https://ask.qcloudimg.com/http-save/1752328/sm2e79eor5.png)
116+
117+
同样的文本,采用不同的编码存储下来。如果另外一个程序,用一种不同的编码方式来进行解码和展示,就会出现乱码。这就好像两个军队用密语通信,如果用错了密码本,那看到的消息就会不知所云。在中文世界里,最典型的就是“手持两把锟斤拷,口中疾呼烫烫烫”的典故。
118+
119+
没有经验的同学,在看到程序输出“烫烫烫”的时候,以为是程序让CPU过热发出报警,于是尝试给CPU降频来解决问题。
120+
121+
既然今天要彻底搞清楚编码知识,我们就来弄清楚“锟斤拷”和“烫烫烫”的来龙去脉。
122+
123+
## “锟斤拷”的来源
124+
125+
如果我们想要用Unicode编码记录一些文本,特别是一些遗留的老字符集内的文本,但是这些字符在Unicode中可能并不存在。于是,Unicode会统一把这些字符记录为U+FFFD这个编码
126+
127+
如果用UTF-8的格式存储下来,就是\xef\xbf\xbd。如果连续两个这样的字符放在一起,\xef\xbf\xbd\xef\xbf\xbd,这个时候,如果程序把这个字符,用GB2312的方式进行decode,就会变成“锟斤拷”。这就好比我们用GB2312这本密码本,去解密别人用UTF-8加密的信息,自然没办法读出有用的信息。
128+
129+
而“烫烫烫”,则是因为如果你用了Visual Studio的调试器,默认使用MBCS字符集
130+
131+
“烫”在里面是由0xCCCC来表示的,而0xCC又恰好是未初始化的内存的赋值。于是,在读到没有赋值的内存地址或者变量的时候,电脑就开始大叫“烫烫烫”了。
132+
133+
# 3 总结延伸
134+
135+
到这里,相信你发现,我们可以用二进制编码的方式,表示任意的信息。只要建立起字符集和字符编码,并且得到大家的认同,我们就可以在计算机里面表示这样的信息了。所以说,如果你有心,要发明一门自己的克林贡语并不是什么难事。
136+
137+
不过,光是明白怎么把数值和字符在逻辑层面用二进制表示是不够的。我们在计算机组成里面,关心的不只是数值和字符的逻辑表示,更要弄明白,在硬件层面,这些数值和我们一直提的晶体管和电路有什么关系。下一讲,我就会为你揭开神秘的面纱。我会从时钟和D触发器讲起,最终让你明白,计算机里的加法,是如何通过电路来实现的。
138+
139+
# 4 推荐阅读
140+
141+
- 《编码:隐匿在计算机软硬件背后的语言》
142+
![](https://ask.qcloudimg.com/http-save/1752328/d1owkw56nq.png)
143+
从电报机到计算机,这本书讲述了很多计算设备的历史故事,当然,也包含了二进制及其背后对应的电路原理。
144+
145+
# 参考
146+
147+
- 深入浅出计算机组成原理
148+
149+
# X 交流学习
150+
![](https://img-blog.csdnimg.cn/20190504005601174.jpg)
151+
## [Java交流群](https://jq.qq.com/?_wv=1027&k=5UB4P1T)
152+
## [博客](https://blog.csdn.net/qq_33589510)
153+
## [Github](https://github.com/Wasabi1234)

0 commit comments

Comments
 (0)
Please sign in to comment.