汇编语言
概念
CPU 只负责计算,本身不具备智能。你输入一条指令(instruction),它就运行一次,然后停下来,等待下一条指令。
这些指令都是二进制的,称为操作码(opcode),比如加法指令就是00000011。编译器的作用,就是将高级语言写好的程序,翻译成一条条操作码。
对于人类来说,二进制程序是不可读的,根本看不出来机器干了什么。为了解决可读性的问题,以及偶尔的编辑需求,就诞生了汇编语言。
汇编语言是二进制指令的文本形式,与指令是一一对应的关系。比如,加法指令00000011
写成汇编语言就是 ADD。只要还原成二进制,汇编语言就可以被 CPU 直接执行,所以它是最底层的低级语言。
寄存器
CPU 本身只负责运算,不负责储存数据。数据一般都储存在内存之中,CPU 要用的时候就去内存读写数据。但是,CPU 的运算速度远高于内存的读写速度,为了避免被拖慢,CPU 都自带一级缓存和二级缓存。基本上,CPU 缓存可以看作是读写速度较快的内存。
但是,CPU 缓存还是不够快,另外数据在缓存里面的地址是不固定的,CPU 每次读写都要寻址也会拖慢速度。因此,除了缓存之外,CPU 还自带了寄存器(register),用来储存最常用的数据。也就是说,那些最频繁读写的数据(比如循环变量),都会放在寄存器里面,CPU 优先读写寄存器,再由寄存器跟内存交换数据。
寄存器不依靠地址区分数据,而依靠名称。每一个寄存器都有自己的名称,我们告诉 CPU 去具体的哪一个寄存器拿数据,这样的速度是最快的。有人比喻寄存器是 CPU 的零级缓存。
寄存器的种类
早期的 x86 CPU 只有8个寄存器,而且每个都有不同的用途。现在的寄存器已经有100多个了,都变成通用寄存器,不特别指定用途了,但是早期寄存器的名字都被保存了下来。
1 | EAX |
前六个为通用寄存器,还有两个特殊的寄存器:
EBP又叫做基址指针寄存器,其内存放着一个指针(帧指针),该指针永远指向系统栈最上面一个栈帧的底部。所以ebp指向的是栈的栈底的数据。
ESP又叫做栈指针寄存器,其内存放着一个指针(栈指针),该指针永远指向系统栈最上面一个栈帧的栈顶。所以esp指向的是栈的栈顶的数据。
32位/64位指寄存器的大小,32位为4字节,64位为8字节。
内存模型:Heap
寄存器只能存放很少量的数据,大多数时候,CPU 要指挥寄存器,直接跟内存交换数据。所以,除了寄存器,还必须了解内存怎么储存数据。
程序运行的时候,操作系统会给它分配一段内存,用来储存程序和运行产生的数据。这段内存有起始地址和结束地址,比如从0x1000
到0x8000
,起始地址是较小的那个地址,结束地址是较大的那个地址。
程序运行过程中,对于动态的内存占用请求(比如新建对象,或者使用malloc
命令),系统就会从预先分配好的那段内存之中,划出一部分给用户,具体规则是从起始地址开始划分(实际上,起始地址会有一段静态数据,这里忽略)。举例来说,用户要求得到10个字节内存,那么从起始地址0x1000
开始给他分配,一直分配到地址0x100A
,如果再要求得到22个字节,那么就分配到0x1020
。
这种因为用户主动请求而划分出来的内存区域,叫做 Heap(堆)。它由起始地址开始,从低位(地址)向高位(地址)增长。Heap 的一个重要特点就是不会自动消失,必须手动释放,或者由垃圾回收机制来回收。
内存模型:Stack
除了 Heap 以外,其他的内存占用叫做 Stack(栈)。简单说,Stack 是由于函数运行而临时占用的内存区域。
例
1 | int main() { |
上面代码中,系统开始执行main
函数时,会为它在内存里面建立一个帧(frame),所有main
的内部变量(比如a
和b
)都保存在这个帧里面。main
函数执行结束后,该帧就会被回收,释放所有的内部变量,不再占用空间。
如果函数内部调用了其他函数,会发生什么情况?
1 | int main() { |
上面代码中,main
函数内部调用了add_a_and_b
函数。执行到这一行的时候,系统也会为add_a_and_b
新建一个帧,用来储存它的内部变量。也就是说,此时同时存在两个帧:main
和add_a_and_b
。一般来说,调用栈有多少层,就有多少帧。
等到add_a_and_b
运行结束,它的帧就会被回收,系统会回到函数main
刚才中断执行的地方,继续往下执行。通过这种机制,就实现了函数的层层调用,并且每一层都能使用自己的本地变量。
所有的帧都存放在 Stack,由于帧是一层层叠加的,所以 Stack 叫做栈。生成新的帧,叫做”入栈”,英文是 push;栈的回收叫做”出栈”,英文是 pop。Stack 的特点就是,最晚入栈的帧最早出栈(因为最内层的函数调用,最先结束运行),这就叫做”后进先出”的数据结构。每一次函数执行结束,就自动释放一个帧,所有函数执行结束,整个 Stack 就都释放了。
Stack 是由内存区域的结束地址开始,从高位(地址)向低位(地址)分配。比如,内存区域的结束地址是0x8000
,第一帧假定是16字节,那么下一次分配的地址就会从0x7FF0
开始;第二帧假定需要64字节,那么地址就会移动到0x7FB0
。
CPU指令
实例
1 | int add_a_and_b(int a, int b) { |
用gcc转成汇编语言,为了方便调试,我们需要关闭优化、栈保护、内存随机化等gcc -m32 -O0 -fno-stack-protector -fno-pie -no-pie -fcf-protection=none -S example.c
64位:
1 | .file "example.c" |
简化一下:
1 | _add_a_and_b: |
原程序的两个函数add_a_and_b
和main
,对应两个标签_add_a_and_b
和_main
。每个标签里面是该函数所转成的 CPU 运行流程。
每一行就是 CPU 执行的一次操作。它又分成两部分,就以其中一行为例。
1 | push %ebx |
push
是 CPU 指令,%ebx
是该指令要用到的运算子。一个 CPU 指令可以有零个到多个运算子。
push指令
程序从_main
标签开始执行,这时会在 Stack 上为main
建立一个帧,并将 Stack 所指向的地址,写入 ESP 寄存器。后面如果有数据要写入main
这个帧,就会写在 ESP 寄存器所保存的地址。
然后开始执行第一行代码。
1 | push 3 //注:前两个图在windows环境下使用mov,没有压栈,第三张图在linux环境下32位进行了压栈 |
push
指令用于将运算子放入 Stack,这里就是将3
写入main
这个帧。
虽然看上去很简单,push
指令其实有一个前置操作。它会先取出 ESP 寄存器里面的地址,将其减去4个字节,然后将新地址写入 ESP 寄存器。使用减法是因为 Stack 从高位向低位发展,4个字节则是因为3
的类型是int
,占用4个字节。得到新地址以后, 3 就会写入这个地址开始的四个字节,注意下面寄存器的变化。
栈状态如下:
寄存器状态如下,此时已经取出了ESP中的值到EBP:
执行第一条push执行后寄存器的状态,发现此时ESP的值减去4个字节:
查看栈状态发现已经将32位的3压入了栈原来ESP的地址:
接着同样执行第二条push指令,push
指令将2
写入main
这个帧,位置紧贴着前面写入的3
。这时,ESP 寄存器会再减去 4个字节(累计减去8)。
1 | call _add_a_and_b |
此时可以看到EBP的值减去了16,这一步是为了后面取值做准备。
接下来的两条mov指令,分别在栈内取了ebp+8和ebp+0xc后的四个字节内存中的值,并分别写入edx和eax寄存器。
add指令
add
指令用于将两个运算子相加,并将结果写入第一个运算子。
这条指令将 EAX 寄存器的值(即2)加上 EBX 寄存器的值(即3),得到结果5,再将这个结果写入第一个运算子 EAX 寄存器。
pop指令
pop
指令用于取出 Stack 最近一个写入的值(即最低位地址的值),并将这个值写入运算子指定的位置。
这条指令用于取出 Stack 最近写入的值(即 EBP 寄存器的原始值),再将这个值写回 EBP 寄存器(因为加法已经做完了,EBP 寄存器用不到了)。
注意,pop
指令还会将 ESP 寄存器里面的地址加4,即回收4个字节。
ret指令
ret
指令用于终止当前函数的执行,将运行权交还给上层函数。也就是,当前函数的帧将被回收。
在执行call
指令的时候,我们首先进行了一个push
指令将ebp的值压入了栈,此时栈顶正好回收完毕到达了这个地址。
执行 ret
指令会从栈顶弹出一个地址,该地址就是调用该函数之前的下一个指令的地址,即函数的返回地址。
执行ret
指令,先从栈顶弹出4个字节,此时esp-4,再进行跳转
leave指令
ret
指令结束之后,回到了main函数执行call指令的下一条指令
将 ESP 寄存器里面的地址,手动加上8个字节,再写回 ESP 寄存器。这是因为 ESP 寄存器的是 Stack 的写入开始地址,前面的pop
操作已经回收了4个字节,这里再回收8个字节,等于全部回收。
可以看到正好还剩8个字节,将esp+8后成功回收了add_a_and_b
函数的栈
最后通过leave
清空栈
完成整个函数调用。
if条件判断汇编分析
实例
这里我们写一个简单的if条件判断语句。
1 |