概念

CPU 只负责计算,本身不具备智能。你输入一条指令(instruction),它就运行一次,然后停下来,等待下一条指令。
这些指令都是二进制的,称为操作码(opcode),比如加法指令就是00000011。编译器的作用,就是将高级语言写好的程序,翻译成一条条操作码。
对于人类来说,二进制程序是不可读的,根本看不出来机器干了什么。为了解决可读性的问题,以及偶尔的编辑需求,就诞生了汇编语言。

汇编语言是二进制指令的文本形式,与指令是一一对应的关系。比如,加法指令00000011写成汇编语言就是 ADD。只要还原成二进制,汇编语言就可以被 CPU 直接执行,所以它是最底层的低级语言。

寄存器

CPU 本身只负责运算,不负责储存数据。数据一般都储存在内存之中,CPU 要用的时候就去内存读写数据。但是,CPU 的运算速度远高于内存的读写速度,为了避免被拖慢,CPU 都自带一级缓存和二级缓存。基本上,CPU 缓存可以看作是读写速度较快的内存。

但是,CPU 缓存还是不够快,另外数据在缓存里面的地址是不固定的,CPU 每次读写都要寻址也会拖慢速度。因此,除了缓存之外,CPU 还自带了寄存器(register),用来储存最常用的数据。也就是说,那些最频繁读写的数据(比如循环变量),都会放在寄存器里面,CPU 优先读写寄存器,再由寄存器跟内存交换数据。
20240327102551.png
寄存器不依靠地址区分数据,而依靠名称。每一个寄存器都有自己的名称,我们告诉 CPU 去具体的哪一个寄存器拿数据,这样的速度是最快的。有人比喻寄存器是 CPU 的零级缓存。

寄存器的种类

早期的 x86 CPU 只有8个寄存器,而且每个都有不同的用途。现在的寄存器已经有100多个了,都变成通用寄存器,不特别指定用途了,但是早期寄存器的名字都被保存了下来。

1
2
3
4
5
6
7
8
EAX
EBX
ECX
EDX
EDI
ESI
EBP
ESP

前六个为通用寄存器,还有两个特殊的寄存器:
EBP又叫做基址指针寄存器,其内存放着一个指针(帧指针),该指针永远指向系统栈最上面一个栈帧的底部。所以ebp指向的是栈的栈底的数据。
ESP又叫做栈指针寄存器,其内存放着一个指针(栈指针),该指针永远指向系统栈最上面一个栈帧的栈顶。所以esp指向的是栈的栈顶的数据。
20240327112702.png
32位/64位指寄存器的大小,32位为4字节,64位为8字节。

内存模型:Heap

寄存器只能存放很少量的数据,大多数时候,CPU 要指挥寄存器,直接跟内存交换数据。所以,除了寄存器,还必须了解内存怎么储存数据。
程序运行的时候,操作系统会给它分配一段内存,用来储存程序和运行产生的数据。这段内存有起始地址和结束地址,比如从0x10000x8000,起始地址是较小的那个地址,结束地址是较大的那个地址。
20240327115439.png
程序运行过程中,对于动态的内存占用请求(比如新建对象,或者使用malloc命令),系统就会从预先分配好的那段内存之中,划出一部分给用户,具体规则是从起始地址开始划分(实际上,起始地址会有一段静态数据,这里忽略)。举例来说,用户要求得到10个字节内存,那么从起始地址0x1000开始给他分配,一直分配到地址0x100A,如果再要求得到22个字节,那么就分配到0x1020
20240327115703.png
20240327115734.png
20240327115748.png
这种因为用户主动请求而划分出来的内存区域,叫做 Heap(堆)。它由起始地址开始,从低位(地址)向高位(地址)增长。Heap 的一个重要特点就是不会自动消失,必须手动释放,或者由垃圾回收机制来回收。

内存模型:Stack

除了 Heap 以外,其他的内存占用叫做 Stack(栈)。简单说,Stack 是由于函数运行而临时占用的内存区域。
20240327115946.png

1
2
3
4
int main() {
int a = 2;
int b = 3;
}

上面代码中,系统开始执行main函数时,会为它在内存里面建立一个帧(frame),所有main的内部变量(比如ab)都保存在这个帧里面。main函数执行结束后,该帧就会被回收,释放所有的内部变量,不再占用空间。
20240327120101.png
如果函数内部调用了其他函数,会发生什么情况?

1
2
3
4
5
int main() {
int a = 2;
int b = 3;
return add_a_and_b(a, b);
}

上面代码中,main函数内部调用了add_a_and_b函数。执行到这一行的时候,系统也会为add_a_and_b新建一个帧,用来储存它的内部变量。也就是说,此时同时存在两个帧:mainadd_a_and_b。一般来说,调用栈有多少层,就有多少帧。
20240327120221.png
等到add_a_and_b运行结束,它的帧就会被回收,系统会回到函数main刚才中断执行的地方,继续往下执行。通过这种机制,就实现了函数的层层调用,并且每一层都能使用自己的本地变量。

所有的帧都存放在 Stack,由于帧是一层层叠加的,所以 Stack 叫做栈。生成新的帧,叫做”入栈”,英文是 push;栈的回收叫做”出栈”,英文是 pop。Stack 的特点就是,最晚入栈的帧最早出栈(因为最内层的函数调用,最先结束运行),这就叫做”后进先出”的数据结构。每一次函数执行结束,就自动释放一个帧,所有函数执行结束,整个 Stack 就都释放了。
20240327134010.png
20240327134014.png
Stack 是由内存区域的结束地址开始,从高位(地址)向低位(地址)分配。比如,内存区域的结束地址是0x8000,第一帧假定是16字节,那么下一次分配的地址就会从0x7FF0开始;第二帧假定需要64字节,那么地址就会移动到0x7FB0
20240327134551.png

CPU指令

实例

1
2
3
4
5
6
7
int add_a_and_b(int a, int b) {
return a + b;
}

int main() {
return add_a_and_b(2, 3);
}

用gcc转成汇编语言,为了方便调试,我们需要关闭优化、栈保护、内存随机化等
gcc -m32 -O0 -fno-stack-protector -fno-pie -no-pie -fcf-protection=none -S example.c
64位:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
        .file   "example.c"
.text
.globl add_a_and_b
.type add_a_and_b, @function
add_a_and_b:
.LFB0:
.cfi_startproc
pushl %ebp
.cfi_def_cfa_offset 8
.cfi_offset 5, -8
movl %esp, %ebp
.cfi_def_cfa_register 5
movl 8(%ebp), %edx
movl 12(%ebp), %eax
addl %edx, %eax
popl %ebp
.cfi_restore 5
.cfi_def_cfa 4, 4
ret
.cfi_endproc
.LFE0:
.size add_a_and_b, .-add_a_and_b
.globl main
.type main, @function
main:
.LFB1:
.cfi_startproc
pushl %ebp
.cfi_def_cfa_offset 8
.cfi_offset 5, -8
movl %esp, %ebp
.cfi_def_cfa_register 5
pushl $3
pushl $2
call add_a_and_b
addl $8, %esp
leave
.cfi_restore 5
.cfi_def_cfa 4, 4
ret
.cfi_endproc
.LFE1:
.size main, .-main
.ident "GCC: (Ubuntu 9.4.0-1ubuntu1~20.04.2) 9.4.0"
.section .note.GNU-stack,"",@progbits

简化一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
_add_a_and_b:
push %ebx
mov %ebp, %esp
mov %eax, [%esp+8]
mov %ebx, [%esp+12]
add %eax, %ebx
pop %ebx
ret

_main:
push 3
push 2
call _add_a_and_b
add %esp, 8
ret

原程序的两个函数add_a_and_bmain,对应两个标签_add_a_and_b_main。每个标签里面是该函数所转成的 CPU 运行流程。
每一行就是 CPU 执行的一次操作。它又分成两部分,就以其中一行为例。

1
push   %ebx

push是 CPU 指令,%ebx是该指令要用到的运算子。一个 CPU 指令可以有零个到多个运算子。

push指令

程序从_main标签开始执行,这时会在 Stack 上为main建立一个帧,并将 Stack 所指向的地址,写入 ESP 寄存器。后面如果有数据要写入main这个帧,就会写在 ESP 寄存器所保存的地址。
20240327151530.png
20240327152412.png
20240327154605.png
然后开始执行第一行代码。

1
push   3    //注:前两个图在windows环境下使用mov,没有压栈,第三张图在linux环境下32位进行了压栈

push指令用于将运算子放入 Stack,这里就是将3写入main这个帧。
虽然看上去很简单,push指令其实有一个前置操作。它会先取出 ESP 寄存器里面的地址,将其减去4个字节,然后将新地址写入 ESP 寄存器。使用减法是因为 Stack 从高位向低位发展,4个字节则是因为3的类型是int,占用4个字节。得到新地址以后, 3 就会写入这个地址开始的四个字节,注意下面寄存器的变化。
20240327155808.png
栈状态如下:
20240327155251.png
寄存器状态如下,此时已经取出了ESP中的值到EBP:
20240327155348.png
执行第一条push执行后寄存器的状态,发现此时ESP的值减去4个字节:
20240327155459.png
查看栈状态发现已经将32位的3压入了栈原来ESP的地址:
20240327160130.png
接着同样执行第二条push指令,push指令将2写入main这个帧,位置紧贴着前面写入的3。这时,ESP 寄存器会再减去 4个字节(累计减去8)。
20240327160300.png

20240327160319.png 20240327160519.png ### call指令 第三行的call指令用来调用函数。
1
call   _add_a_and_b
上面的代码表示调用`add_a_and_b`函数。这时,程序就会去找`_add_a_and_b`标签,并为该函数建立一个新的帧。 下面就开始执行`_add_a_and_b`的代码。 20240327161218.png 我们可以看到首先就进行了一个push操作 这一行表示将 EBX 寄存器里面的值,写入`_add_a_and_b`这个帧,将当前指令的下一个地址(即函数的返回地址)压入栈中,并跳转到被调用函数的入口点,这一步是为了保存上一个函数的返回地址,后面会用到。 20240327161440.png 这时,`push`指令会再将 ESP 寄存器里面的地址减去4个字节(累计减去12)。 > Ubuntu20.04上会默认编译出endbr32指令,用于验证是否位32位,在不存在这条指令的时候可以忽略 ### mov指令 `mov`指令用于将一个值写入某个寄存器。 20240327164950.png > 注意这里有一个 mov ebp, esp的操作,这一步是用于设置新的基址指针,为了把上一栈帧的顶部变成这个栈帧的底部,有些环境下存在,有些环境下没有。虽然将ESP的值赋给EBP并不是绝对必需的,但它是一种编写可读性更好的汇编代码的常见实践。在大多数情况下,它可以简化对参数和局部变量的访问,并且使得调试代码更加方便。

此时可以看到EBP的值减去了16,这一步是为了后面取值做准备。
接下来的两条mov指令,分别在栈内取了ebp+8和ebp+0xc后的四个字节内存中的值,并分别写入edx和eax寄存器。
20240327162713.png

add指令

add指令用于将两个运算子相加,并将结果写入第一个运算子。
这条指令将 EAX 寄存器的值(即2)加上 EBX 寄存器的值(即3),得到结果5,再将这个结果写入第一个运算子 EAX 寄存器。
20240327172317.png

pop指令

pop指令用于取出 Stack 最近一个写入的值(即最低位地址的值),并将这个值写入运算子指定的位置。
这条指令用于取出 Stack 最近写入的值(即 EBP 寄存器的原始值),再将这个值写回 EBP 寄存器(因为加法已经做完了,EBP 寄存器用不到了)。
注意,pop指令还会将 ESP 寄存器里面的地址加4,即回收4个字节。
20240327165452.png

ret指令

ret指令用于终止当前函数的执行,将运行权交还给上层函数。也就是,当前函数的帧将被回收。
在执行call指令的时候,我们首先进行了一个push指令将ebp的值压入了栈,此时栈顶正好回收完毕到达了这个地址。
执行 ret 指令会从栈顶弹出一个地址,该地址就是调用该函数之前的下一个指令的地址,即函数的返回地址。
20240327171724.png
执行ret指令,先从栈顶弹出4个字节,此时esp-4,再进行跳转
20240327172626.png

leave指令

ret指令结束之后,回到了main函数执行call指令的下一条指令
将 ESP 寄存器里面的地址,手动加上8个字节,再写回 ESP 寄存器。这是因为 ESP 寄存器的是 Stack 的写入开始地址,前面的pop操作已经回收了4个字节,这里再回收8个字节,等于全部回收。
20240327172825.png
可以看到正好还剩8个字节,将esp+8后成功回收了add_a_and_b函数的栈
20240327173016.png
最后通过leave清空栈
20240327173312.png
完成整个函数调用。

if条件判断汇编分析

实例

这里我们写一个简单的if条件判断语句。

1