在实际编程过程中,我们大多是使用高级语言如 Java 语言编程。多数时候,高级语言因其高度封装,使得程序易于编写,亦是拥有较高的可读性,因而广受欢迎。但由于屏蔽了程序在机械级的具体实现过程,我们如隔层玻璃观物一般,无法触及底层实质。
当然,我们无法直接和机器交流。不过我们可以通过一种迂回的方式,达到交流的目的——让它们知道我们想要它做什么。那怎么让那“榆木脑袋”知道我们想要什么呢?我们可以使用汇编来理解程序的底层逻辑。
在我们启动程序的时候,编译器会产生一个汇编代码文件,而这个汇编代码就十分接近于计算机实际过程中执行的机器代码。和机械代码那种二进制数字相比,汇编使用的是更易于读的字符,方便我们理解底层逻辑。我们知道它是怎么想的,那它想做什么不就由我们掌控了嘛。
有些时候,高级语言所提供的抽象层会隐藏一些我们想要理解的信息。就像英译中,阅读英文原本和译本的差别是挺大的,有时候原本里某段很精彩的描述翻译过来不过寥寥几笔勾画,虽然看起来不影响整体,不过缺失的那部分感觉挺可惜的。
回到编码,我们常常望“洋”兴叹,怎么就和我们想的不一样呢?这时候,我们可以通过阅读汇编代码,理解程序其中可做的优化,分析代码中潜在的低效率问题等。而且在用线程包写并发程序的时候,知道用什么存储器 (storage) 来保存各种变量是很重要的。这些信息在汇编代码就一览无余啦。
阅读和理解汇编代码能帮助我们进入另一个层次看待程序。接下来,让我们进入小人国的世界。
机械级程序
在类似 C 语言的高级语言中大多提供了一种模型,可以在存储器中声明和分配各种数据类型的对象。但在汇编代码中,它们只是一个很大的、按字节寻址的数组。像在 C 中,我们所熟知的数组和结构,在汇编代码中是用连续的字节表示的。
我们生来没有什么不同,只是隔了层地址,最终老死不相往来。
访问信息
下图为寄存器,它用以存储数据和指针。在大多数情况下,前六个寄存器是通用寄存器,对于它们的使用没有严格限制。最后两个寄存器 ( %ebp 和 %esp ) 保存着指向程序栈中重要位置的指针, %ebp 指向栈帧开始处, %esp 指向栈顶,只有根据栈管理的标准惯例才能修改这两个寄存器的值。
大多数指令有一个或多个操作数,指示出执行一个操作中要引用的源数据值,以及放置结果的位置。而各种操作数依各自特性分为三种类型:
立即数 即常数值,依规定,立即数的书写方式是“$”后面跟一个整数。
寄存器表示某个寄存器的内容。对双字操作来说,可以是八个32位寄存器中的一个,如 %eax。
存储器引用它会根据计算出的地址访问某个存储器的位置。
栈帧结构
栈用来传递过程参数、存储返回信息、保存寄存器以供以后恢复只用,以及用于本地存储。为单个过程分配的那部分栈称为栈帧。下图描绘了栈帧的通用结构。
假设函数 P (调用者) 调用函数 Q (被调用者)。 Q 的参数放在 P 的栈帧中。当 P 调用 Q 时, P 中的返回地址被压入栈中,形成 P 的栈帧的末尾,返回地址就是当程序从 Q 返回时应该继续执行的地方。Q 的栈帧从保存的帧指针的值 (如 %ebp)开始,后面是保存的其他寄存器的值。
或许说的有些抽象,你可以将函数调用想象成俄罗斯套娃,大的套小的,层层递进。而栈也是如此,栈向下增长,高地址在上,低地址在下, 即栈顶元素在最底部,而 %esp 一直指向栈顶元素。我们可以通过 pushl 和 popl 指令将数据存入栈中和从栈中取出。当然,也可以通过将 %esp 的值减小适当的值来分配未指定初始值的数据的空间。相反,也可以通过增加 %esp 来释放空间。
因为寄存器是一个资源共享区,而我们在调用函数的时候,为了避免当一个函数调用另一个函数时,被调用者覆盖某个调用者等一下会使用到的寄存器的值。在这里有一个寄存器使用惯例:
寄存器 %eax、%edx、%ecx 被划分为调用者保存( caller save ) 寄存器。当函数 P 调用 Q 时, Q 可以覆盖这些寄存器,而不会破坏任何 P 所需要的数据。另外,寄存器 %ebx、%esi、%edi 被划分为被调用者保存( callee save ) 寄存器。即 Q 必须在覆盖它们之前,将这些寄存器的值保存到栈中,并在返回前恢复它们。
术语不好记的话,我们可以想象以下场景:
int P() {
int x = f();;
Q();
return x;
}
函数 P 希望它计算出来的 x 值在调用了 Q 之后任然有效,如果 x 放在一个调用者保存寄存器中,而 P 必须在调用 Q 之前保存这个值,并在 Q 返回后恢复这个值。如果 x 在一个被调用者保存寄存器中,Q 想使用这个寄存器,那么 Q 在使用这个寄存器之前,必须保存这个值,并在返回前恢复。在这两种情况中,保存就是将寄存器值压入栈中,而恢复时从栈中弹出到寄存器。
接下来,我们简单说下汇编指令:
数据传送指令
数据传送指令在底层是最频繁使用的指令。通常一条简单的传送指令能完成许多机器中需要好几条指令才能完成的操作,而最常用的莫过于传送双字的 movl 指令。
movl $0x3051,%eax //将 0x3051 这个值放入寄存器 eax
将一个值从一个存储器位置考到另一个存储器位置需要两条指令——第一条指令将源值加载到寄存器中,第二条将寄存器值写入目的位置。源操作数指定一个值,它可以是立即数,可以存放在寄存器中,也可以放在存储器中。目的操作数指定一个位置,它可以是寄存器,也可以是存储器地址。
加载有效地址
加载有效地址指令 leal 实际上是 movl 指令的变形。它的第一个操作数看上去是一个存储器引用,但该指令并不是从指定的位置读入数据,而是将有效地址写入目的操作数 (如寄存器)。
leal -12(%ebp) %eax //将 %ebp 减去12得到的地址,放入 eax 寄存器
了解部分汇编代码含义之后,我们就可以综合实践一下了。接下来,看看程序机械层次是如何运行的。
下面是 C 语言编写的一段小程序:
int demo(){
int x = 10;
int y = 20;
int sum = add(&x, &y);
printf(“the sum is %d\n”,sum);
return sum;
}
int add(int *xp, int *yp){
int x = *xp;
int y = *yp;
return x+y;
}
让我们将它们转成汇编代码来看:
demo:
1 pushl %ebp //将寄存器 ebp 的值压人栈中
2 movl %esp %ebp //将寄存器 esp 的值放入 ebp
3 subl %24 esp //将 esp 寄存器的值减去24
4 movl $10 -4(%ebp) //将10这个值存放到 ebp 寄存器地址减去4的地方
5 movl $20 -8(%ebp) //将20这个值存放到 ebp 寄存器地址减去8的地方
6 leal -8(%ebp) %eax //将 ebp 减去8得到的地址,放到 eax 寄存器当中
7 movl %eax 4(%esp) //将 eax 的值存放到 esp 寄存器地址增加4的地方
8 leal -4(%ebp) %eax //将 ebp 减去4得到的地址,放到 eax 寄存器当中
9 movl %eax esp //将 eax 的值存放到 esp 寄存器
10 call add //调用 add 函数,将返回值地址压人栈中
打印结果(略)
如图,1-2两条代码是将栈帧开始处 ebp 寄存器的地址压入栈顶,esp 寄存器的地址自动减去4个字节,地址为 800。之后将指向栈顶的 esp 寄存器的地址放入 ebp 寄存器,ebp 寄存器的地址被 esp 寄存器的地址取代,即地址也为 800。
第3行代码是将 esp 寄存器的地址减去24个字节,相当于给栈帧分配一定的自由空间。为什么会减去24个字节呢?因为系统的编程指导方针为了严格的数据对齐所至,即一个函数使用的栈空间必须是16个字节的整数倍。
4-5行代码是将两个立即数 10 和 20 分别放入栈中,需要注意的是此时 ebp 寄存器中的地址不会改变。之后 leal 指令代码是将 ebp 减去8得到的地址,即792,放到 eax 寄存器当中。接着 movl 指令代码是将 eax 的值,存放到 esp 寄存器地址增加4的地方,即780。这两段指令就是将变量 x 的值的地址放到栈中,即是指针。8-9段代码同上,你可以检验一下。
第10行代码是函数调用,调用 add 函数。执行这一行指令的时候,会将返回地址写入栈中,指向栈顶的 esp 寄存器会自动的减去4个字节,指向返回地址。至此,调用者的函数栈帧完成。
接下来,进入 add 函数调用。我们会在上面栈帧的下面开辟调用者栈帧的空间,以便于函数值的存储。
下面是 add 函数的汇编代码:
add:
1 pushl %ebp
2 movl %esp %ebp
3 pushl %ebx
4 movl 8(%ebp) %edx
5 movl 12(%ebp) %ecx
6 movl (%edx) %ebx
7 movl (%ecx) %eax
8 add %ebx %eax
9 popl %ebx
10 popl %ebp
11 ret
由于和上面代码类似,具体解析就此省略,你可以当作练习,试着解析代码。
附:本文大多来自《深入理解计算机系统》一书以及刘欣老师的编程课程内容。