目录
1、计算机指令
计算机的指令即cpu能理解的操作,也就是我们所说的机器语言。不同的cpu能理解的语言不一样,如intel的cpu,苹果使用的ARM的cpu。不同的语言即不同的计算机指令集。所以电脑上写好的程序COPY到手机运行不了,因为二者cpu指令集不相同
高级语言,汇编语言,计算机指令的关系
- 一条高级语言 可翻译成 多条汇编指令(一对多)
- 一条汇编指令 可翻译成 一条计算机指令 (一对一)
- 一条条的计算机指令 即 一条条机器码(由0和1组成)
- 高级语言也可以直接翻译成机器码,也可以先翻译成汇编语言再由汇编器翻译成机器码
- 通常我们会把高级语言翻译成汇编,来看计算机执行的每个步骤
2、代码执行过程
寄存器是cpu内部的组成部分,特殊的有三类:PC寄存器(存储下一条指令的内存地址,也叫作程序计数器)、指令寄存器(存储正在执行的指令码)、条件码寄存器(用里面的一个一个标记位(Flag),存放 CPU 进行算术或者逻辑计算的结果)。除了这些特殊的寄存器,CPU里面还有更多用来存储数据和内存地址的寄存器。这样的寄存器通常一类里面不止一个。我们通常根据存放的数据内容来给它们取名字,比如整数寄存器、浮点数寄存器、向量寄存器和地址寄存器等等。有些寄存器既可以存放数据,又能存放地址,我们就叫它通用寄存器。
查看高级语言对应的汇编及机器码,以linux下c语言为例
int main()
{
int a = 0;
for (int i = 0; i < 3; i++)
{
a += i;
}
}
通过gcc 和 objdump命令查看代码对应的汇编指令
gcc -g -c test.c
objdump -d -M intel -S test.o
下面为代码对应的汇编指令,可以看到for循环是通过 逻辑判断+指令跳转 来实现
for (int i = 0; i < 3; i++)
b: c7 45 f8 00 00 00 00 mov DWORD PTR [rbp-0x8],0x0
12: eb 0a jmp 1e <main+0x1e>
{
a += i;
14: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8]
17: 01 45 fc add DWORD PTR [rbp-0x4],eax
for (int 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 <main+0x14>
24: b8 00 00 00 00 mov eax,0x0
}
对于cpu而言,我们写好的代码变成计算机指令后,是一条一条顺序执行的。CPU 会根据 PC 寄存器里的地址,从内存里面把需要执行的指令读取到指令寄存器里面执行,然后根据指令长度地址自增,开始顺序读取下一条指令。可以看到,一个程序的一条条指令,在内存(虚拟内存上是连续的,虚拟内存映射物理内存即可找到物理内存上的指令数据)里面是连续保存的,也会一条条顺序加载。对于有些指令,则会修改pc寄存器的里的值,达到程序跳转的目的。
3、函数执行的原理
https://time.geekbang.org/column/article/93014
https://time.geekbang.org/column/article/94427
- 每个线程都有一个自己的栈(内存中开辟的空间),线程执行中,所有方法公用这个栈。栈底到栈顶内存地址不变变小,这样只用判断地址是否小于0即可知道栈内是否还有空间。
- 每个方法在栈里保存着各自的信息。每个方法占用的栈的空间叫做一个栈帧(逻辑层面的)。
- RSP是栈顶指针寄存器,一直指向栈顶位置,入栈操作 Push 和出栈操作 Pop 指令,会自动调整 RSP 的值。
- RBP是栈基地址指针寄存器,指向当前栈帧的最底部。 rsp到rbp 表示当前栈帧范围。
- RBP和RSP在函数调用的过程中,以及调用完返回的过程中。只需要记住,RSP会一直指向栈顶,RBP会一直指向当前最新栈帧的的栈底即可
- rdi、rsi、rdx、rcx、r8、r9 这 6 个寄存器,传递的参数会存储在寄存器中。如果超过 6 的时候,还是需要放到调用者自身的栈帧里,其入栈的顺序也是有讲究的,为了方便被调用者访问。
分析代码如下,可以看汇编中的注释:
// 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);
}
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 //函数结果放入eax
}
12: 5d pop rbp
13: c3 ret // 主函数返回地址出栈且更新到pc寄存器
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 // 获取add函数结果
3c: b8 00 00 00 00 mov eax,0x0
}
41: c9 leave
42: c3 ret
1.参数如何传递到另一个函数?
- 参数不超过六个时,直接通过特定的寄存器传递参数。被调用者直接从寄存器中获取参数即可。
- 超过六个的参数,则会入栈。被调用者自身知道参数个数,当前RBP栈基地址值,只要越过【前面栈帧的栈基地址】【返回地址】固定大小,则可以拿到第七个参数以及后续参数。
2.返回值如何返回?
- 看汇编代码可以看到,add函数的结果存储在EAX寄存器中;在主函数中只需要去EAX寄存器中获取返回结果即可。
3.被调用的函数执行完后如何回到调用的地方继续往下执行呢?
- 在主函数中,调用 call 的时候,会把pc寄存器的地址入栈(下一条指令的地址)。
- 在add函数中,最后调用 ret 的时候,会把入栈的地址出栈且更新到pc寄存器中。
4.方法为何是线程安全的呢?
- 一个方法在不同线程并行执行,首先不同线程有各自的栈,每个方法在各自线程的栈里也会有各自的栈帧,里面有方法自己的局部变量。所以是线程安全的。
5.函数内联有什么利弊?
- 缺点:我们把可以复用的程序指令在调用它的地方完全展开了。如果一个函数在很多地方都被调用了,那么就会展开很多次,整个程序占用的空间就会变大了。
- 优点:内联可以提升代码执行效率,不用像调用函数一样有多余的操作(调用,入栈,出栈等),执行的指令变少了
- 限制:如果a调用b,b里面又调用a。如果直接内联,这样代码会无穷无尽。
6. 参数过多的坏处?
- 不利于理解。
- 参数超过六个,会入栈,比单纯的通过寄存器传递和获取参数,效率更低。
总结:看汇编代码可以得出,其操作其实都是建立在一个非常严谨的数据结构上。每一步操作会影响指针,会影响下一步操作的数据。数据结构跟代码执行是完全同步的。
4、我们写的代码如何编译链接,被加载到内存并执行
4.1、背景
- 我们自己编写的源代码文件可以有多个,源文件中涉及到变量和函数的引用,可能是单个文件内的引用,也可能是文件之间的引用
- 除了自己编写的,还有共享代码库。例如glibc共享库。源文件中可能有对glibc库中变量和函数的引用
- 某个共享库,也会对其他共享库中的变量及函数有引用
4.2、问题
- 对于cpu而言,执行指令,其实就是通过获取不同地址的指令数据,译码并执行。所以代码中对变量和函数的引用,最终都应该变成有效的地址。
- 所以最终要解决的问题就是,源代码变成机器码后,被cpu执行时,我的代码和数据(int b = &a)中涉及到的引用全部都要变成有效的地址。
4.3、解决方案
根本问题就是要把对数据和函数的引用替换为有效地址,这个行为叫做重定位
-
方案一:生成的机器码,在加载到内存前,进行重定位处理
实现思路:这种方案叫做静态链接,把整个程序用到的所有代码,全部合并成一个大文件,然后把大文件内的所有针对变量和函数的引用全部替换为有效地址
缺点:对于glibc这种共享库,如果每个程序都要把它合并到文件里,在磁盘存储和内存占用上,都是极大的浪费。
改良:针对每个程序都会用到的代码(例如glibc共享库),我们就不能采用静态链接这种方式,所以我们有了方案二。
-
方案二:程序对共享库的引用,共享库和共享库之间的引用,在加载到内存后进行重定位
实现思路:这种方案叫做动态链接,由于不能把共享库合并到大文件,所以无法提前预知共享库的地址。只能在装载程序到内存时,把依赖的共享库也装载进内存。此时共享库的在物理内存的位置已经确定。而每个程序对应的进程都可以建立‘虚拟地址’到‘物理地址’的映射,来实现不同程序引用同一个共享库代码。此时地址都已经确定,可以进行重定位。
缺点:每次重定位,都会对代码进行更改
改良:就像我们写代码,能否把变化转移到数据上,而代码不用变,所以有了方案三
-
方案三:代码中对其他共享库的引用(地址值),使用自定义的变量,数组的某一项表示
实现思路:创建一个全局变量数组 GOT[], 代码中针对其他共享库的引用,会使用数组的值来表示。例如: call printf 表示未 call GOT[index],index的值是编译器决定的。这样我们针对printf 重定位的时候,代码是通用的,只需要去更改数据GOT[index]为printf的实际虚拟地址。而GOT在虚拟内存中的位置相对于整个程序而言偏移量是固定的,可以预知。
缺点:装载程序时,需要把所有共享库的引用全部重定位,启动速度慢
改良:能否把重定位工作放到运行时去处理呢,所以有了方案四
ps:共享库数据部分每个程序各自都会有一份副本,各自修改互不干扰。针对数据部分的重定位(int a=&b),使用方案二的方式即可
-
方案四:cpu执行到该代码时,才去进行重定位
实现思路:让cpu执行到该代码时,能有如下的逻辑判断,if(已重定位){跳转正确的地址} else{进行重定位}
这种实现方式叫做延迟绑定(PLT),PLT其实就是一串代码,大致实现如下:
printf@plt
// 1. 如果未重定位, GOT[index]其实就是 push n 这条指令的地址(加载的时候初始化)继续执行后续的指令;如果重定位了,则正确跳转到printf的位置,不会继续往下执行
jmp GOT[index]
// 2. 参数传递:哪个函数需要重定位,在哪个模块下
push n
push moduleId
// 3. 跳转到重定位程序,获取模块和函数,进行重定位,修改GOT[index]的值
jump 重定位程序
4.4、编译 -> 链接 -> 装载
静态链接:针对某一个程序,程序在链接的时候,把不同文件的代码段,合并到一起,成为最后的可执行文件。这样我们写一个方法,在这个程序每个地方都只需要链接就可以使用,达到复用的目的。
动态链接:针对很多程序,当他门之间很多功能代码都是重复时。把他们都生成可执行文件并都装载到内存里,会浪费很多的磁盘和内存空间。这时我们可以把这部分功能代码提炼出来,加载到内存,让所有程序在运行时可以通过动态链接来使用。这样可以充分节省磁盘和内存空间。(动态链接库,例如dll文件)既可以在开发阶段复用,也可以在运行阶段复用。
每个源文件编译得到对应的目标文件,多个目标文件通过链接器链接得到可执行文件(包含静态链接)。可执行文件装载时,也会加载它依赖的动态共享文件,并在加载期间把数据部分进行重定位。在运行期间把代码部分对应的GOT表进行重定位(动态链接)。
可执行文件在linux下是ELF格式,在windows下是PE格式。Linux 下的装载器只能解析 ELF 格式而不能解析 PE 格式。所以在windows下生成的可执行文件在linux下无法执行。
ELF格式的文件:目标文件,可执行文件,动态共享文件
ELF格式文件重要结构大致如下:
- 文件头,表示这个文件的基本属性,比如是否是可执行文件,对应的 CPU、操作系统等等。
- 代码段,用来保存程序的代码和指令。
- 数据段,保存程序里面设置好的初始化数据信息。
- 重定位表,保留的是当前的文件里面,哪些跳转地址其实是我们不知道的。比如调用函数 printf 在链接发生之前,我们并不知道该跳转到哪里,这些信息就会存储在重定位表里。
- 符号表,保留了当前文件里面定义的函数名称和对应地址的地址簿。
- 字符串表
静态链接的过程:链接器会扫描所有输入的目标文件,然后把所有符号表里的信息收集起来,构成一个全局的符号表。然后再根据重定位表,把所有不确定要跳转地址的代码,根据符号表里面存储的地址,进行一次修正。最后,把所有的目标文件的对应段进行一次合并,形成可执行文件。理解下图三个表之间的关系,从而理解静态目标文件的大致结构,并理解静态链接的大致过程。
动态链接的过程这里就不列举了,有兴趣可以看书《程序员的自我修养 链接装载库》
4.5、程序如何加载到内存
虚拟内存:指令里用到的内存地址叫虚拟地址(比如可执行文件中地址)。
物理内存:指令加载到内存中,实际的硬件的地址。
虚拟内存与物理内存映射表:指令还是按照虚拟内存中的地址来进行跳转及运行,所以需要一个映射表,通过虚拟地址找到物理地址。
内存交换:有多个程序需要加载到内存并运行,若内存不够时,则需要把其他程序占用的内存先转移到硬盘,等需要使用时再从硬盘恢复到内存。
内存分页:由于程序执行的时候,程序计数器是顺序往下读的,所以需要程序在内存中的地址是连续的。但是若整个程序都要求连续,这样多个程序运行内存不足时,需要把整个程序都进行一次内存交换,延迟太高。若把程序拆分成多个固定大小(一般为4kb),这样我们运行程序时可以只加载用到的页进入内存,进行内存交换的时候也可以只针对某些页,不会产生很大的延迟。
程序运行到某页,若该页未加载到内存,触发cpu缺页异常,操作系统捕捉到异常则把该页加载到内存,再由cpu读取执行。