算是读书笔记吧
极客时间--深入浅出计算机组成原理
计算机指令与CPU
-
CPU
CPU 就是一个超大规模集成电路,通过电路实现了加法、乘法乃至各种各样的处理逻辑。
-
计算机指令--机器码
各种用二进制编码方式表示的指令,叫做机器指令码。
好比一门 CPU 能够听得懂的语言,我们也可以把它叫作机器语言(Machine Language)。
-
汇编
程序员很难记住二进制的机器码,而汇编代码其实就是“给程序员看的机器码”。它机器码一一对应。
要让一段程序在操作系统上跑起来,我们需要把整个程序翻译成一个汇编语言(ASM,Assembly Language)的程序,这个过程我们一般叫编译(Compile)成汇编代码。之后再用汇编器(Assembler)翻译成机器码(Machine Code)。
虽然gcc 和 objdump之类的命令,编译出来的机器码是16进制,但也只是方便程序员阅读,本质还是2进制机器码。
-
计算机指令集
不同的 CPU 能够听懂的语言不太一样,因为内部支持着两组不同的计算机指令集。
比如,我们的个人电脑用的是 Intel 的 CPU,苹果手机用的是 ARM 的 CPU。
所以电脑上写一个程序,然后把这个程序复制一下,装到自己的手机上无法正常运行。
-
常见的计算机指令
我们日常用的 Intel CPU,有 2000 条左右的 CPU 指令。不过一般来说,常见的指令可以分成五大类。
- 算术类指令
我们的加减乘除,在 CPU 层面,都会变成一条条算术类指令。 - 数据传输类指令
给变量赋值、在内存里读写数据,用的都是数据传输类指令。 - 逻辑类指令
逻辑上的与或非,都是这一类指令。 - 条件分支类指令
日常我们写的“if/else”,其实都是条件分支类指令。 - 无条件跳转指令
写一些大一点的程序,我们常常需要写一些函数或者方法。在调用函数的时候,其实就是发起了一个无条件跳转指令。
-
编程语言的"自举"
编程语言是自举的,指的是说,我们能用自己写出来的程序编译自己。
但是自举,并不要求这门语言的第一个编译器就是用自己写的。
比如,先是有了 Go 语言,我们通过 C++ 写了编译器 A。然后呢,我们就可以用这个编译器 A,来编译 Go 语言的程序。接着,我们再用 Go 语言写一个编译器程序 B,然后用 A 去编译 B,就得到了 Go 语言写好的编译器的可执行文件了。
这个之后,我们就可以一直用 B 来编译未来的 Go 语言程序,这也就实现了所谓的自举了。所以,即使是自举,也通常是先有了别的语言写好的编译器,然后再用自己来写自己语言的编译器。
第一台通用计算机 ENIAC,它的各种输入都是一些旋钮,可以认为是类似用机器码在编程,后来才有了汇编语言、C 语言这样越来越高级的语言。
指令的执行
本质上写好的代码变成了指令之后,会一条一条顺序执行。
-
寄存器
CPU 其实就是由一堆寄存器组成的。而寄存器就是 CPU 内部,由多个触发器(Flip-Flop)或者锁存器(Latches)组成的简单电路。
N 个触发器或者锁存器,就可以组成一个 N 位(Bit)的寄存器,能够保存 N 位的数据。
三种基本寄存器
- PC 寄存器(Program Counter Register)
也叫指令地址寄存器(Instruction Address Register)。顾名思义,它就是用来存放下一条需要执行的计算机指令的内存地址
。 - 指令寄存器(Instruction Register)
存放当前正在执行的指令
。 - 条件码寄存器(Status Register)
里面的一个一个标记位(Flag),存放 CPU 进行算术或者逻辑计算
的结果。
还有很多其他的寄存器。我们通常根据存放的数据内容来给它们取名字,比如整数寄存器、浮点数寄存器、向量寄存器和地址寄存器等等。有些寄存器既可以存放数据,又能存放地址,我们就叫它通用寄存器。
-
顺序执行
程序执行的时候,CPU 会根据 PC 寄存器里的地址,从内存里面把需要执行的指令读取到指令寄存器里面执行,然后根据指令长度自增,开始顺序读取下一条指令。可以看到,一个程序的一条条指令,在内存里面是连续保存的,也会一条条顺序加载。
-
if/else
以一段代码举例
int main(){
srand(time(NULL));
int r = rand() % 2;
int a = 10;
if (r == 0)
{ a = 1;
} else {
a = 2;
}
}
ifelse对应的汇编代码如下:
if (r == 0)
33: 83 7d fc 00 cmp DWORD PTR [rbp-0x4],0x0 //cmp 指令的比较结果,会存入到条件码寄存器当中去。然后PC 寄存器会自动自增
37: 75 09 jne 42 //jne 指令,是 jump if not equal 的意思。也就是说比对如果不相等,将会跳转到42。
{
a = 1;
39: c7 45 f8 01 00 00 00 mov DWORD PTR [rbp-0x8],0x1 //mov 指令把 1 设置到对应的寄存器里去
} else {
a = 2;
42: c7 45 f8 02 00 00 00 mov DWORD PTR [rbp-0x8],0x2 //mov 指令把 2 设置到对应的寄存器里去
}
jne( jump if not equal )是条件分支类指令、jmp是无条件跳转指令。二者都会让PC 寄存器就不再是自增变成下一条指令的地址,而是被直接设置成这里的对应的地址。
-
for/while
int main()
{
int a = 0;
for (int i = 0; i < 3; i++)
{
a += i;
}
}
汇编之后的代码:
int i;
for (i = 0; i < 3; i++)
b: c7 45 f8 00 00 00 00 mov DWORD PTR [rbp-0x8],0x0
12: eb 0a jmp 1e
{
a += i;
14: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8]
17: 01 45 fc add DWORD PTR [rbp-0x4],eax
for (i = 0; i < 3; i++)
1a: 83 45 f8 01 add DWORD PTR [rbp-0x8],0x1
1e: 83 7d f8 02 cmp DWORD PTR [rbp-0x8],0x2
22: 7e f0 jle 14
}
这里的jle也是条件跳转指令、只不过他跳转的之前的位置14,也就是循环的开始位置。
同理,goto语句也是一样的地址跳转指令
程序栈
通过加入了程序栈,我们相当于在指令跳转的过程种,加入了一个“记忆”的功能,能在跳转去运行新的指令之后,再回到跳出去的位置,能够实现更加丰富和灵活的指令执行流程。
int static add(int a, int b)
{
return a+b;
}
int main()
{
int x = 5;
int y = 10;
int u = add(x, y);
}
汇编代码:
int static add(int a, int b)
{
0: 55 push rbp //压栈 //push rbp 就把之前调用函数,也就是 main 函数的栈帧的栈底地址,压到栈顶。
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 //把 call 调用的时候压入的 PC 寄存器里的下一条指令出栈,更新到 PC 寄存器中,将程序的控制权返回到出栈后的栈顶。
0000000000000014 :
int main()
{
14: 55 push rbp //通常以 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函数,并把当前的 PC 寄存器里的下一条指令的地址压栈,保留函数调用结束后要执行的指令地址。
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
- 栈帧(Stack Frame)
整个函数 A 所占用的所有内存空间,就是函数 A 的栈帧(Stack Frame)
压栈的不只有函数调用完成后的返回地址,参数数据在寄存器不够用的时候也会被压入栈中。
每个线程都有一个自己的栈
程序栈布局
底在最上面,顶在最下面
这样的布局是因为栈底的内存地址是在一开始就固定的。而一层层压栈之后,栈顶的内存地址是在逐渐变小而不是变大。
- 函数内联(Inline)
把一个实际调用的函数产生的指令,直接插入到的位置,来替换对应的函数调用指令
栈帧的入栈出栈也是要耗费性能的,内存操作是很慢的相对于cpu,如果不能加载到高速缓存里面,反复直接操作主内存会耗费相当多的性能(相对而言)。
- 叶子函数(或叶子过程)
没有调用其他函数,只会被调用的函数
程序装载
-
虚拟内存,虚拟内存地址,物理内存,物理内存地址
程序运行时,需要一个连续的地址空间让寄存器自增。但实际上并不可能在物理层面上实现,于是有了虚拟、物理之分。
虚拟内存、物理内存
物理内存就是内存条。虚拟内存是进程运行时所有内存空间的总和、他可以大于内存条的容量。当我们访问一个不在物理内存中的地址,实际上就是被虚拟内存映射到其他介质(比如硬盘)上去读取了。虚拟内存地址、物理内存地址
这张图可以很好的说明虚拟内存地址与物理内存地址的关系。它可以保证一个程序的内存地址在逻辑(虚拟)上,是连续的。
- 缺页错误(Page Fault)
当要读取特定的页,却发现数据并没有加载到物理内存里的时候,就会触发一个来自于 CPU 的缺页错误(Page Fault)
我们的操作系统会捕捉到这个错误,然后将对应的页,从存放在硬盘上的虚拟内存里读取出来,加载到物理内存里。如果空间不够,就用LRU找到一个最少使用的页帧。
通过引入虚拟内存、页映射和内存交换,我们的程序本身,就不再需要考虑对应的真实的内存地址、程序加载、内存管理等问题了。任何一个程序,都只需要把内存当成是一块完整而连续的空间来直接使用。
静态链接
-
可执行文件
操作系统可以运行的,是
可执行文件
。而非单纯的二进制文件。
对于单个文件,其地址都是从0开始的,系统并不知道改如何调用。我们需要将不同的文件链接起来。
-
链接
在Linux中,可执行文件的格式为ELF(Execuatable and Linkable File Format),中文名字叫可执行与可链接文件格式。对应着Mac上为Mach-O。
一个项目在编译时,需要经过编译(Compile)、汇编(Assemble)以及链接(Link)三大步。
-
符号表(Symbols Table)
可执行文件中,符号表相当于一个地址簿,把名字和地址关联了起来。
链接器会扫描所有输入的目标文件,然后把所有符号表里的信息收集起来,构成一个全局的符号表。
然后再根据重定位表,把所有不确定要跳转地址的代码,根据符号表里面存储的地址,进行一次修正。
最后,把所有的目标文件的对应段进行一次合并,变成了最终的可执行代码。
通过一个装载器在运行运行这些程序,解析 ELF 或者 PE 格式的可执行文件。装载器会把对应的指令和数据加载到内存里面来,让 CPU 去执行。
所以,很多软件不可以跨平台运行,就是因为对应平台的装载器无法识别其可执行文件的格式。
**
动态链接
在动态链接的过程中,我们想要“链接”的,不是存储在硬盘上的目标文件代码,而是加载到内存中的共享库(Shared Libraries)。
静态链接有这样一个问题,就是如果所有使用到的代码都需要加载到软件的内存空间中,那么系统库代码将会被copy许多份,动态链接可以保证同样共享库只在内存中加载一份。
-
地址无关码(Position-Independent Code)
无论加载在哪个内存地址,都能够正常执行
不同的程序在被加载进内存时,同一个动态库的虚拟地址并不相同。
动态库开始地址都是未知的,动态库内部的指令和数据都根本不知道绝对地址会是何处。不能使用绝对地址进行定位。
-
相对地址(Relative Address)
各种指令中使用到的内存地址,给出的不是一个绝对的地址空间,而是一个相对于当前指令偏移量的内存地址。
如果绝对地址是:前进 500 米,左转进入天安门广场,再前进 500 米。
相对地址就是:前进 500 米,左转,再前进 500 米
参考资料
虚拟内存,虚拟内存地址,物理内存,物理内存地址
稍微了解地址无关代码(Position-Independent Code)