3.2. C语言到LoongArch的编译过程
GCC最初名为GNU C Compiler,即GNU C语言编译器,主要用途是把C语言源程序翻译成特定处理器架构能够识别和执行的目标文件,其中包含该架构的机器指令。随着程序设计语言的发展,GCC逐步扩展到C++、Objective-C、Fortran、Java、Ada、Go等语言,因此名称也变为GNU Compiler Collection,即GNU编译器套件。不同语言的编译原理和基本流程具有相似之处,通常包括词法分析、语法分析、语义分析、中间代码生成、汇编指令生成、目标机器指令生成和链接等阶段。目前,GCC支持x86、ARM、PowerPC、RISC-V、s390、MIPS、LoongArch等主流架构。若读者使用基于龙芯5000系列处理器的计算机,系统一般已经内置GCC;若使用非龙芯架构计算机,则需要从龙芯官网下载LoongArch架构的GCC交叉编译器。
理解GCC的编译流程,有助于认识高级语言如何逐步转换为机器指令。下面通过一个C语言示例,说明GCC在龙芯平台上的基本工作过程。
首先编写一个简单C程序,用于向终端输出一句话。文件名为hello.c,内容如下:
/*this is test file*/
#include <stdio.h>
#define STR "Hello World!"
int main(){
printf("%s\n", STR);
return 0;
}
程序写好后,使用GCC编译hello.c,命令如下:
$ gcc -v --save-temps hello.c -o hello
gcc 版本 4.9.4 20160726 (Red Hat 4.9.4-14) (GCC)
cc1 -E -quiet -v hello.c -o hello.i
cc1 -fpreprocessed hello.i -o hello.s
as -v -EL-o hello.o hello.s
collect2 -o hello crt1.o crti.o crtbegin.o hello.o crtend.o crtn.o
Tip
为了便于后续分析,这里对输出信息做了删减和整理。
gcc命令的基本形式为:
gcc [options] file...
使用最简单的gcc hello.c命令,就可以把hello.c中的C程序编译为默认名为a.out的目标文件。为了观察编译过程及其中间结果,本示例增加了几个参数,含义如下。
-v:显示编译器执行的详细过程。--save-temps:保留编译过程中产生的临时文件。本示例执行后,会在当前目录生成hello.i、hello.s、hello.o这3个中间文件。-o <file>:指定最终输出文件名;如果不使用该参数,默认输出文件名为a.out。例如,-o hello表示最终文件名为hello。
从前面的输出可以看出,GCC编译过程主要调用了3个工具:cc1、as和collect2。cc1对应第01章所说的编译器,它先对高级语言源文件(如hello.c)做预处理,生成中间文件hello.i;随后再将预处理后的文件翻译成汇编源文件hello.s。as是汇编器,负责把汇编源文件翻译为包含机器指令的目标文件hello.o。collect2是链接器,用于把多个目标文件(*.o)组合成最终可以在特定指令架构计算机上运行的文件hello。图2-5展示了这一流程。
下面按使用工具的不同,将GCC编译过程概括为3个阶段。
3.2.1. 预处理和编译阶段
编译阶段的目标,是把C语言源程序转换为与目标体系架构相关的汇编语言,使用的工具是cc1。从内部过程看,它可以分为预处理和编译两个步骤。
预处理主要处理各类预处理命令,也就是源文件hello.c中以#开头的语句,包括头文件包含、宏定义展开、条件编译选择等,最终生成供下一步使用的.i文件。预处理的主要规则如下。
(1) 处理所有以#开头的语句。例如,遇到#if、#else、#endif等条件判断语句时,预处理器会保留条件成立的部分并删除其余内容;遇到#define宏定义时,会把宏展开到使用位置,再删除宏定义语句。因此,hello.i中已经看不到hello.c里的宏定义STR,它已经被替换到printf语句中。原语句:
printf("%s\n", STR);
会被扩展为:
printf("%s\n", "Hello World!");
遇到\#include语句时,预处理器会把被包含文件的相关位置信息插入到当前位置,并删除该包含语句。例如,示例中的\#include <stdio.h>会扩展为:
"/usr/include/stdio.h"
(2) 删除源文件中的注释,包括//和/*...*/标注的内容。因此,hello.i中不会再出现hello.c中的/*this is test file*/。
(3) 添加用于调试的行号和文件名标识。例如,hello.i中可能出现# 1 "/usr/include/stdio.h" 1 3 4这样的内容,用于在编译阶段保留调试行号信息,并在产生错误或警告时显示准确位置。
编译阶段以预处理后的hello.i为输入,仍由cc1生成包含汇编代码的hello.s。该阶段主要完成语法分析、语义分析和汇编代码生成。语法分析用于检查源程序是否符合语言语法,例如括号是否匹配、语句是否以;结束等。语义分析主要做类型检查,例如数组下标是否为非法小数、函数实参类型是否匹配、局部变量是否重复定义等。汇编代码生成则把前面阶段得到的中间表示,翻译成与目标体系架构相关的汇编代码,并输出到.s文件。
代码优化也是编译阶段的重要工作。常见优化包括方法内联(Inlining)、循环展开(Unrolling)、死代码消除(Dead Code Elimination)等。GCC提供-O0(默认)、-O1、-O2、-O3等优化选项,数字越大通常表示优化程度越高,但编译耗时也会增加。
Note
方法内联是指编译器在编译过程中,将被调用方法的代码副本放置到每个调用位置,以降低方法调用产生的性能开销。
Note
循环展开是指编译器在编译过程中多次复制循环体内部指令,使循环次数减少或被消除,以降低循环分支指令带来的性能开销。
Note
死代码消除是指编译器在编译过程中识别并删除那些永远不会被执行的代码。
如果只想查看源文件生成的汇编文件,也可以在编译时使用-S参数。例如:
$ gcc -S hello.c
$ gcc -S hello.c -o hello.s
hello.s是普通文本文件,可以直接用文本工具打开查看。生成内容大致如下:
.LC0:
.ascii "Hello World!\000"
.LC1:
.ascii "%s \n\000"
main:
addi.d $r3, $r3, -16
la.local $r5, .LC0
la.local $r4, .LC1
bl %plt(printf)
or $r4, $r0, $r0
addi.d $r3, $r3, 16
jr $r1
其中,.LC0:、.LC1:和main:都属于标签,可用于标识字符串区域的起始位置或函数入口地址。
main:标签后共有7条汇编指令,它们等价实现了hello.c中的main函数。指令addi.d $r3,$r3,-16和addi.d $r3,$r3,16分别用于申请和释放栈空间;la.local $r5,.LC0和la.local $r4,.LC1用于加载调用printf所需的两个参数;bl %plt(printf)用于调用printf函数;or $r4,$r0,$r0用于设置main函数返回值0;jr $r1用于函数返回。
Tip
在龙芯架构中,寄存器r3被定义为栈指针(Stack Pointer,SP);用于函数参数传递的寄存器是r4~r11,同时寄存器r4也用作函数返回值寄存器。更详细的指令讲解和寄存器使用规则请阅读第03章和第05章。
Tip
指令la.local是汇编宏指令,并不是龙芯指令集中的真实指令,不能被CPU直接识别和运行,只能被汇编器识别。这里的宏指令是龙芯架构为方便开发人员快速编写龙芯汇编程序而提供的,会被GCC汇编器识别并转换为相应的真实汇编指令。其他架构也存在类似的宏指令。
3.2.2. 机器指令生成阶段
机器指令生成阶段使用汇编器as。它的主要工作是解析汇编源文件hello.s,并按照指令码表把其中的汇编语句编码为处理器能够识别的机器指令,最终生成目标文件hello.o。
以hello.o为例,目标文件不是文本文件,不能直接阅读。若要查看其内容,可以使用反汇编工具objdump。objdump可以把目标文件转换为人类可读的文本形式,命令格式为:
$objdump <option> <objfile>
option可取-f、-h、-d、-t等参数,分别用于显示目标文件头信息、段信息、文件内容、符号表信息等。更多参数可以通过objdump --help查看。
下面使用-d参数反汇编hello.o中的所有代码段指令,并将结果重定向到a.txt。
$objdump -d hello.o > a.txt
文件a.txt会包含相关函数的反汇编信息。下面只列出main函数的反汇编内容:
0000000000000000 <main>:
0: 02ffc063 addi.d $r3,$r3,-16(0xff0)
4: 1c000005 pcaddu12i $r5,0
8: 02c000a5 addi.d $r5,$r5,0
c: 1c000004 pcaddu12i $r4,0
10: 02c00084 addi.d $r4,$r4,0
14: 54000000 bl 0 # 18 <main+0x18>
18: 00150004 or $r4,$r0,$r0
24: 02c04063 addi.d $r3,$r3,16(0x10)
28: 4c000020 jirl $r0,$r1,0
为了便于阅读,objdump通常按三列显示机器指令。第一列是当前机器指令所在地址,用十六进制表示。不过在这一阶段,每个函数的起始地址都暂为0,最终内存地址要等链接阶段才能确定。龙芯指令为定长指令,每条4字节,所以后续指令地址依次增加4,即4、8、c等。第二列是机器指令本身,也用十六进制表示。第三列是对机器指令的等价汇编翻译,把不易阅读的机器码转换为相对直观的汇编形式。
可以看到,hello.o中的指令和hello.s中的指令大体一致,例如addi.d $r3,$r3,-16(0xff0)、or $r4,$r0,$r0等。差别在于,.s文件中除了汇编指令,还包含只供汇编器使用的汇编器指令(如.LC0)和汇编宏指令(如la.local)。汇编器指令用于指导汇编器工作,例如.LC0告诉汇编器这里是本文件中第一个变量的内存位置。汇编宏指令则是对真实架构指令的封装,例如la.local $r5,.LC0会被翻译为LoongArch中的pcaddu12i $r5,0和addi.d $r5,$r5,0。
还需要注意,指令bl 0表示跳转地址为0,此时还不能正确调用printf。这说明机器指令生成阶段得到的目标文件尚不能直接正确执行。只有经过链接阶段,把该跳转地址修正为有效内存地址后,程序才能正常运行。
机器指令生成阶段既可以直接使用as完成,也可以使用带-c参数的gcc命令完成,例如:
$ as hello.s -o hello.o
$ gcc -c hello.c -o hello.o
3.2.3. 链接阶段
链接阶段使用collect2,它是链接器ld的一层封装。该阶段的主要任务,是把机器指令生成阶段得到的多个.o文件正确组合为一个文件。例如,本节开头输出中的命令collect2 -o hello crt1.o crti.o crtbegin.o hello.o crtend.o crtn.o,就是把crt1.o到crtn.o等目标文件链接起来,并将结果写入最终文件hello。对于不能直接静态链接进来的动态库(如libc.so),链接器也要在引用处计算相关地址,使程序运行时能够找到并加载这些库。
链接阶段主要完成两个工作:符号解析和重定位。符号包括函数名和变量名。每个待链接目标文件(如hello.o)都有自己的符号表,记录当前文件中定义和引用的所有符号,可通过objdump -t hello.o查看。符号解析会把不同符号表中的符号定义和符号引用对应起来,并形成全局符号表。重定位则重新安排所有输入文件中的信息,并依据全局符号表计算各符号的最终位置。
使用objdump反汇编链接后生成的hello文件,可得到如下结果:
0000000000000570 <printf@plt>:
000000570: 1c00010f pcaddu12i $r15,8(0x8)
000000574: 28ea81ef ld.d $r15,$r15,-1376(0xaa0)
000000578: 1c00000d pcaddu12i $r13,0
00000057c: 4c0001e0 jirl $r0,$r15,0
0000000000000580 <main>:
000000580: 02ffc063 addi.d $r3,$r3,-16(0xff0)
000000584: 1c000005 pcaddu12i $r5,0
000000588: 02c000a5 addi.d $r5, $r5,0
00000058c: 1c000004 pcaddu12i $r4,0
000000590: 02c00084 addi.d $r4,$r4,0
000000594: 54000000 bl -40(0xfffffd8) # 000000570 <printf@plt>
00000059c: 00150004 or $r4,$r0,$r0
0000005a0: 02c04063 addi.d $r3,$r3,16(0x10)
0000005ac: 4c000020 jirl $r0,$r1,0
与机器指令生成阶段的hello.o相比,链接后的指令内容基本没有变化,但指令存放地址已经改变,如第一列所示。main函数起始地址从0变为0x000000580,后续指令仍按4字节递增。该地址是hello程序运行时的有效虚拟地址。经过重定位后,原来的bl 0变为bl -40(0xfffffd8),指令类型没有改变,但跳转目标已经被修正:从当前地址0x000000594减去40字节,即可得到printf函数地址。注释# 000000570 <printf@plt>也给出了计算结果,说明printf地址为0x000000570。