6.1 汇编代码生成简介
历经词法分析、语法分析、语义检查和中间代码生成阶段,我们终于来到了“目标代码生成阶段”,由于UCC编译器的目标代码即为32位x86汇编代码,因此我们就把本章称为“汇编代码生成”。UCC编译器中的大部分源代码都适用于Windows和Linux平台,但Windows平台上缺省的汇编器支持Intel风格的x86汇编代码,而Linux平台默认的汇编器则采用AT&T风格的x86汇编代码,两者在汇编语法上有一些差别,为节省篇幅,我们主要针对Linux平台的AT&T汇编进行讨论。到了这一章,UCC编译器面对的输入早已不再是C源代码,而主要是由各个基本块构成的链表,我们要把基本块中的中间代码翻译成x86汇编代码。我们在“第1.5节结合C语言来学汇编”中,已介绍过x86汇编代码的相关语法和语义,这里不再重复。
还是通过一个简单的例子来了解一下UCC编译器的汇编代码生成,如图6.1.1所示,第1至6行是一个简单的C程序,第11至58行是由UCC编译器产生的汇编代码。形如第13行C++风格的注释是我们人为加上去的,用于说明第14行的汇编代码“.data”是由Segment()函数产生。我们省略了main函数对应的汇编代码。
图6.1.1 汇编代码生成的例子
在C程序中出现的字符串可被视为全局的字符数组(当然对C程序员而言,该字符数组的名称不可见),通过第15行的EmitStrings()函数,UCC编译器可以为字符串产生形如第16行的字符数组,而通过第18行的EmitFloatConstants函数,则可以为形如“1.0”的浮点常数分配存储空间。UCC编译器会隐式地为字符串和浮点常数命名,例如第16行的“.str0”和第19行的“.flt0”,这些名字都以“.”开始。按C语言的语法,由C程序员命名的变量名或函数名不会以“.”开始,这就可保证不会发生名称上的重名。在汇编代码中,符号名是允许用“.”开始的。第21行的EmitGlobals函数用于处理C程序员定义的全局变量,这会产生形如第22至23行的汇编代码。第26行的EmitFunction()用于产生函数f对应的汇编代码,如图第27至58行所示。
图6.1.1第30至34行的代码用于保存寄存器的值,第35行用于在栈空间中预留内存空间,用来存放局部变量和临时变量,这部分工作被称为“Prologue序言”,即在函数开始执行时要处理的工作。而图6.1.1第53至57行被称为“Epilogue尾声”,用于恢复原先保存的寄存器值,第58行的汇编指令ret用于从栈中取出返回地址并返回。而函数f的返回值在第50至51行进行计算,并保存于寄存器eax。第37行的EmitBlock函数用来为某一基本块生成汇编代码。UCC编译器内部用英文单词generate来表示中间代码的生成,而用emit来表示汇编代码的生成,这里我们统一翻译为“生成”。
我们还注意到,图6.1.1第2行的形参num在汇编代码中,对应的名称是第50行的“20(%ebp)”,而第3行的局部变量b对应的是第40行的“-8(%ebp)”,这再次提醒我们,在汇编层次,我们需要为局部变量和形式参数设置新的名称。为了得到某个符号对象p在汇编代码中的名称,UCC编译器定义了函数GetAccessName,其接口如下所示,稍后,我们会对这个函数进行分析。
static char* GetAccessName(Symbol p);
下面,我们来分析一下用于生成图6.1.1汇编代码的函数EmitTranslationUnit,如图6.1.2所示,第1至11行的函数EmitTranslationUnit会为整个翻译单元产生汇编代码,我们已在图6.1.1的注释中标出BeginProgram等函数的作用。第8行的ImportFunctions用于在Windows平台Intel风格的汇编中,产生形如“EXTRN
f:NEAR32”的函数声明,在Linux平台上ImportFunctions并无作用。图6.1.2第12至26行为EmitFunctions的代码,通过第16行的while循环,我们在第21行调用EmitFunction来为各个函数产生汇编代码。
图6.1.2 EmitTranslationUnit()
图6.1.2第27至58行为函数EmitFunction的代码,在第33行调用的Export函数用于在Linux平台上产生形如“.globl f”的函数声明,第35行的DefineLabel函数用于产生形如“f:”的标号。按C标准的规定,当“函数的返回值是结构体对象,且该对象的大小不落在{1,2,4,8}”时,C编译器会隐式地为该函数添加一个参数,该参数的类型是指向结构体对象的指针。例如,以下结构体struct
Data的对象要占32字节,C编译器会为函数GetData隐式地添加一个structData * recvaddr参数,图6.1.2第36至47行的if语句用于对此进行处理。
struct Data {int dt[8];};
// C程序员设定的函数接口
struct Data GetData(int num);
//被C编译器隐式地改为
void GetData(struct Data * recvaddr, int num);
图6.1.2第48行调用的LayoutFrame函数用来计算“形式参数、局部变量和临时变量”在活动记录中的偏移,并返回“局部变量和临时变量”在栈中所占内存的总和,我们会在稍后对这个函数进行分析。图6.1.2第50行调用EmitPrologue来产生“序言”,如图6.1.1第29至35行所示,图6.1.1第35行“subl $16,%esp”中的常数16,就是“函数f里的局部变量和临时变量所占栈内存的总和”。通过图6.1.2第51至55行的while循环,我们可为各基本块产生汇编代码,这主要是由第54行调用的EmitBlock函数来完成。图6.1.2第56行调用的EmitEpilogue函数来产生“尾声”部分,如图6.1.1第52至58行所示。
图6.1.2中调用的EmitStrings和EmitFloatConstants等函数并不复杂,我们就不再展开讨论,在后续的章节中,我们把分析的焦点放在基本块的翻译上,即EmitBlock函数。在本节中,我们再来分析一下前面遇到的LayoutFrame和GetAccessName这两个函数,其中LayoutFrame函数的代码如图6.1.3所示。按照C标准的规定,在被调函数返回后,寄存器ebx、esi和edi的值要和函数调用前的值一样,这些寄存器被称为“保值寄存器”。UCC编译器会在“函数的序言”中把这几个寄存器的值入栈;而在“函数的尾声”中恢复这几个寄存器的值,从而实现“保值”的要求。另外,当被调函数返回时,寄存器ebp需要再次指向主调函数的活动记录,因此被调函数也要在栈中先保存ebp寄存器的值,因此总共需要由被调函数保护的寄存器有4个,即6.1.3第16行的宏PRESERVE_REGS所对应的值。
图6.1.3 LayoutFrame()
图6.1.3第3至13行给出了栈的布局图,第1个形参的位置为“20(%ebp)”,而第1个局部变量或临时变量的位置为“-4(%ebp)”。第24至35行的while循环用于为各个形参计算其偏移,其偏移从20开始依次递增;第39至54行的while循环用于为局部变量和临时变量计算偏移,其偏移从“-4”开始依次递减。第56行返回“局部变量和临时变量所占用的栈空间的总和”。
接下来,我们来看一下Linux版的函数GetAccessName,其代码如图6.1.4所示,第6至8行用于处理整型常数,在AT&T汇编指令中其符号形如”$4”;浮点常数对应的名称已在图6.1.1第18行的EmitFloatConstants函数中,被设置为形如“.flt0”;第9至12行用来产生形如“.str0”的字符串名和“.BB2”标号。全局变量名和“处于函数体外的静态变量名”,仍可用于汇编代码中,第17行的赋值语句对此进行设置。为了避免重名,UCC编译器会对“位于函数体内的静态变量名”进行改名,得到的名称形如第23行的“c.1”,第18至26行对此进行处理。
图6.1.4 GetAccessName()
对于局部变量、形式参数和临时变量,在汇编代码中,我们用形如“20(%ebp)”这样的符号来表示,图6.1.4第27至32行会根据我们在图6.1.3的LayoutFrame函数中计算出来的偏移,来设置相应的符号名。在汇编代码中,函数名仍然可以直接使用,图6.1.4第33至34行对此进行处理。对于全局或静态的“数组元素和结构体成员”,我们可用形如第42行的“arr+12”符号来表示,而对于局部的数组或结构体对象,则使用形如“20(%ebp)”的符号,如图6.1.4第35至56行所示。当我们已经得到某一符号p在汇编代码中的名称p->aname后,就没有必要再重复计算,第2行的if语句会对此进行判断。
在这小节中,结合图6.1.1的例子,讨论了汇编代码生成的总体流程。在汇编代码生成中,一个很重要的问题就是寄存器的分配。在后面的章节中,我们会对“用于为基本块产生汇编代码”的函数EmitBlock进行讨论。