2.1.2 编译
编译程序对预处理过的结果进行词法分析、语法分析、语义分析,然后生成中间代码,并对中间代码进行优化,目标是使最终生成的可执行代码执行时间更短、占用的空间更小,最后生成相应的汇编代码。
以foo2.c为例,我们可以使用如下命令指定编译过程只进行编译,不进行汇编和链接。
编译后产生的汇编文件为foo2.s,其内容如下:
在文件foo2.c中,除定义了一个全局变量foo2外,仅定义了一个函数foo2_func,而该函数体中也只有区区一行代码,但为什么产生的汇编代码如此之长?事实上,仔细观察可以发现,文件foo2.s中相当一部分是汇编器的伪指令。伪指令是不参与CPU运行的,只指导编译链接过程。比如,代码中以".cfi"开头的伪指令是辅助汇编器创建栈帧(stack frame)信息的。
在终端上调试程序的程序员一般都会有这样的经历:某个程序出现Segment Fault了,然后终端中会输出回溯(backtrace)信息。或者,我们在调试程序时,也经常需要回溯,查找一些变量或查看函数调用信息。这个过程,就是所谓的栈的回卷(unwind stack)。事实上,在每个函数调用过程中,都会形成一个栈帧,以main函数调用foo2_func为例,形成的栈帧如图2-2所示。
图 2-2 函数调用中的栈帧
frame pointer和base pointer均指向栈桢的底部,只是叫法不同,在IA32架构中,通常使用寄存器ebp保存这个位置。因为main并不是程序中第一个运行的函数,所以main也是一个被调函数,其也有栈帧。事实上,即使程序中第一个被调用的函数_start(该函数实现在启动代码中),也会自己模拟一个栈帧。
理论上,调试器或异常处理程序完全可以根据frame pointer来遍历调用过程中各个函数的栈帧,但是因为gcc的代码优化,可能导致调试器或异常处理很难甚至不能正常回溯栈帧,所以这些伪指令的目的就是辅助编译过程创建栈帧信息,并将它们保存在目标文件的段".eh_frame"中,这样就不会被编译器优化影响了。
去掉这些伪指令后,函数foo2_func中CPU真正执行的代码如下:
在汇编语言中,在函数的开头和结尾处分别会插入一小段代码,分别称为Prologue和Epilogue,如foo2_func中的第1、2、3行代码就是Prologue,第6、7行代码就是Epilogue。
Prologue保存主调函数的frame pointer,这是为了在子函数调用结束后,恢复主调函数的栈帧。同时为子函数准备栈帧。其主要操作包括:
❑保存主调函数的frame pointer,如第1行代码所示,就是将保存在寄存器ebp中的frame pointer压栈。在退出子函数时可以从栈中恢复主调函数的frame pointer。
❑将esp赋值给ebp,即将子函数的frame pointer指向主调函数的栈顶,如第2行代码所示。换句话说,这行代码的意义就是记录了子函数的栈帧的底部,从这里就开始了子函数的栈帧。
❑修改栈顶指针esp,为子函数的本地变量分配栈空间,如第3行代码。注意虽然这里的foo2_func中只有一个局部变量ret,占据4字节,但是还是预留了16字节的栈空间,这根据的是IA32的ABI(Application Binary Interface)的16字节的对齐要求。
Epilogue功能与Prologue恰恰相反,如果说Prologue相当于构造函数,那么Epilogue就相当于析构函数。其主要操作包括:
❑将栈指针esp指向当前子函数的栈帧的frame pointer,也就是说,指向当前栈桢的栈底,而在这个位置,恰恰是Prologue保存的主调函数的frame pointer。然后,通过指令pop将主调函数的frame pointer弹出到ebp中,如此,一方面释放了被调函数foo2_func的栈帧,同时也回到了主调函数main的栈帧。IA32提供了指令leave来完成这个功能,即第6行代码,这个指令相当于:
❑将调用子函数时call指令压栈的返回地址从栈顶pop到EIP中,并跳转到EIP处继续执行。如此,CPU就返回到主调函数继续执行。IA32提供了指令ret来完成这个功能,即第7行代码。
除了Prologue和Epilogue,foo2_func的核心代码就剩下第4行和第5行两行了。这两行代码对应的就是C语言中的赋值语句"int ret=foo2"。首先,即第4行代码,CPU从数据段中读取全局变量foo2的值到寄存器EAX中。然后,即第5行代码,将eax中的内容,即foo2的值,复制到栈中的局部变量ret的位置。代码中根据局部变量相对于栈的frame pointer(在ebp中保存)的偏移来访问局部变量,如变量ret位于相对于栈底偏移为-4的内存处。