1.4.1 UCC的使用
通过第1.3节的例子ucc\examples\sc,我们对如何根据语言的文法来编写语法分析器,建立语法树,之后在语法树的基础上生成中间代码有了一个感性的直观认识。当然,光有这些中间代码,C程序员还不能得到其所要的计算结果。C编译器还要由中间代码产生汇编代码。在剖析UCC编译器前,让我们先熟悉一下UCC编译器的使用。UCC编译器的大部分代码都是用标准C语言并调用C标准库来编写,可在Linux或Windows系统上生成32位的x86汇编代码,这些代码需要32位的函数库的支持才能在相应系统中运行。在后续的章节中,为节省篇幅,我们主要以32位Ubuntu系统为例来讨论。在VMware等虚拟机上安装32位Ubuntu系统不算太复杂,这里不再画蛇添足。需要在ucc/makefile第1行和ucc/driver/linux.c第7行配置UCC的安装目录UCCDIR,比如” /home/iron/bin”。如果ucc的源代码被解压到” /home/iron/src/ucc”目录,则经过以下步骤就可构建并安装UCC到” /home/iron/bin”中。
iron@ubuntu:ucc$ pwd
/home/iron/src/ucc
iron@ubuntu:ucc$ make -s
iron@ubuntu:ucc$ make -s install
iron@ubuntu:ucc$ make -s test
为了使用户能方便地使用ucc命令,我们还需要设置一下环境变量PATH,具体的操作如下所示,由” cd ~ ”进入当前用户主目录,在gedit打开的.bashrc文件末尾添加一行”exportPATH=$PATH:/home/iron/bin”, 其中”/home/iron/bin”即为前文所设定的UCCDIR,保存后退出,重新打开一个终端,即可使用ucc命令。
iron@ubuntu:ucc$ cd ~
iron@ubuntu:~$ gedit .bashrc
接下来,我们用一个简单的例子来解释一下UCC的大致工作流程。编写以下C代码,存为文件”hello.c”。这份代码用于求阶乘,其中有if语句、while语句、库函数调用及递归函数。
#include
int f(int n){
if(n < 1){
return 1;
}else{
return n *f(n-1);
}
}
int main(){
int i = 1;
while(i <= 10){
printf("f(%d)= %d\n",i,f(i));
i++;
}
return 0;
}
C源代码hello.c需要先经过预处理器(C PreProcessor)预处理。预处理器会根据预设的include目录去查找并包含头文件,并对宏定义进行展开,如果找不到对应的头文件,则报错,这类错误是预处理报的错,还未到编译器阶段。
预处理器后的结果hello.i,才是作为编译器的输入,诚如UCC的原作者在”ucc\doc\UCC User Manual.txt”中所言” Before reporting bugs: ……. , send the prepocessed files [email protected]” ,编译器看到的是preprocessed后的文件。有时侯,编译器可能报出数以百计的语法错误,其原因可能仅是宏定义时出了点差错,这时打开预处理后的文件看看,就能很清楚哪里出问题了。
编译器会对hello.i进行词法分析、语法分析、语义检查和中间代码生成,经过前面几节的准备,我们对这些概念应有点感觉了,这几个阶段被称为编译器的前端,它们与具体的机器无关。C编译器再根据中间代码生成不同硬件平台的汇编语言,这部分工作被称为编译器的后端,与具体的机器相关,因为不同机器的机器指令是各不相同的。当然,编译器还有“优化”这样的重点戏需要完成,这也是编译相关研究的当前热点。而中间代码实际上起到了连结前端和后端的桥梁作用。
有了汇编代码hello.s后,还需要借助汇编器assembler根据汇编语言来“装配”成机器码,由此产生了目标代码hello.o,其中为.o代表的是object,译为“目标”,与面向对象的object是同一个英文单词。既然不同硬件平台的机器代码是不同的,那能不能定义一套中间代码,我们假设有一个虚拟的机器,其机器代码正好就是我们的中间代码。这样,程序员所编写的高级语言被编译成中间代码,再把这些中间代码送给用软件实现的虚拟机来解释执行,各个平台上预先写好各自的中间代码虚拟机,那生成的中间代码就可以跨平台了。这一定让你想起了Java和” Write Once , RunAnywhere”那让人热血沸腾的Slogan。当然,与运行平台相关的工作及优化的重头戏就交给了Java 虚拟机。”天之道损有余 而补不足”,所以总体而言,上帝是公平的。正如在星际争霸里的人族、神族和虫族一样,如果参数太失衡,就不好玩了。Java得到跨平台的代价是牺牲了一部分的运行效率,但在程序员比CPU和内存贵的今天,这种牺牲还是有经济上的意义的。生成中间代码之后解释执行,比”生成机器代码之后直接运行速度更低”的原因,就好比有一本英语原版书,翻译的方法可以有口译和笔译,口译的方式每次都要找口译员帮你解释一下,讲完你可能就忘了;而笔译的好处是笔译员翻译一遍后,你就有了一份中文版的书,以后就不用再麻烦笔译员了。Java虚拟机采取的加速方案有即时编译Just InTime,如果运行时发现有些中间代码要被多次解释执行,那我们干脆就在动态运行时,把相应的中间代码翻译成机器代码吧,这就是所谓的”即时”。当然,道理很简单,做起来很难。
目标模块hello.o还需要携手函数库和其他的目标代码才能得到可执行程序hello,这个工作被称为Linking,即连接,也有写为链接的,哪个是错误字,已经傻傻分不清楚了,权当IT世界的通假字吧,这个工作由连接器Linker完成。在此阶段出现的错误有,全局变量(或函数)重复定义,全局变量(或函数)未定义等。
hello.c ---> hello.i ---> hello.s ---> hello.o ---> hello
我们可通过以下命令来验证上述过程。
iron@ubuntu:demo$ ls
build.bat hello.c Makefile
iron@ubuntu:demo$ ucc -E hello.c -o hello.i
iron@ubuntu:demo$ ls
build.bat hello.c hello.i Makefile
iron@ubuntu:demo$ ucc --dump-ast --dump-IR -S hello.i -o hello.s
iron@ubuntu:demo$ ls
build.bat hello.ast hello.c hello.i hello.s hello.uil Makefile
iron@ubuntu:demo$ ucc -c hello.s -o hello.o
iron@ubuntu:demo$ ls
build.bat hello.ast hello.c hello.i hello.o hello.s hello.uil Makefile
iron@ubuntu:demo$ ucc hello.o -o hello
iron@ubuntu:demo$ ls
build.bat hello.ast hello.i hello.s Makefile
hello hello.c hello.o hello.uil
iron@ubuntu:demo$ ./hello
f(1) = 1
f(2) = 2
f(3) = 6
f(4) = 24
f(5) = 120
f(6) = 720
f(7) = 5040
f(8) = 40320
f(9) = 362880
f(10) = 3628800
按上面的分析,我们可以发现,要由C源代码得到一份可执行程序,我们需要“预处理器、编译器、汇编器和连接器”这么多工具的配合,这实际上是一套工具链,平时我们讲用GCC编译器,实际上我们是用这套工具链中的编译器来作为这套工具链的代表了。UCC的原作者完成的是C编译器,而预处理器、汇编器和连接器仍然用Linux 或Windows上的相应工具。当然在上述命令中,我们只用了ucc,但给ucc传递了–E等参数来通知ucc调用预处理器等工具。在UCC编译器阅读和分析时,比较有用的参数还有”--dump-ast --dump-IR”,用于生成字符串形式表达的抽象语法树AST,及优化后的中间代码IR。
换言之,上述的ucc命令实际上是个编译器驱动driver,用来驱动编译工具链的预处理器、编译器、汇编器和连接器。UCC驱动的代码在ucc\driver目录中,而我们要剖析的UCC编译器的源代码则在\ucc\ucl。按“扫外围,剪裙边”的思路,下一小节,我们会先剖析一下UCC编译器驱动的代码。
本节附录,由hello.c生成的语法树、中间代码和汇编代码如下所示:
抽象语法树hello.ast
function f
{
(if (< n
1)
(then
{
(ret 1)
}
end-then)
(else
{
(ret (* n
(call f
(- n
1))))
}
end-else)
end-if)
}
function main
{
(while (<= i
10)
{
(call printf
str0,
i,
(call f
i))
(+= i
(+ i
1))++
}
end-while)
(ret 0)
}
中间代码hello.uil
function f
if (n >= 1) goto BB3;
BB1: ref= 0, sym->ref = 0 ,npred = 1 , nsucc = 1
return 1;
goto BB4;
goto BB4;
BB3: ref= 1, sym->ref = 0 ,npred = 1 , nsucc = 1
t0 : n + -1;
t1 : f(t0);
t2 : n * t1;
return t2;
BB4: ref= 2, sym->ref = 0 ,npred = 3 , nsucc = 0
ret
function main
i = 1;
goto BB7;
BB6: ref= 1, sym->ref = 0 ,npred = 1 , nsucc = 1
t0 :&str0;
t1 : f(i);
printf(t0, i, t1);
++i;
BB7: ref= 1, sym->ref = 0 ,npred = 2 , nsucc = 2
if (i <= 10) goto BB6;
BB8: ref= 0, sym->ref = 0 ,npred = 1 , nsucc = 0
return 0;
ret
x86汇编代码 hello.s
# Codeauto-generated by UCC
.data
.str0: .string "f(%d)= %d\012"
.text
.globl f
f:
pushl %ebp
pushl %ebx
pushl %esi
pushl %edi
movl %esp, %ebp
subl $12, %esp
.BB0:
cmpl $1, 20(%ebp)
jge .BB3
.BB1:
movl $1, %eax
jmp .BB4
.BB2:
jmp .BB4
.BB3:
movl 20(%ebp), %eax
addl $-1, %eax
pushl %eax
call f
addl $4, %esp
movl 20(%ebp), %ecx
imull %eax, %ecx
movl %ecx, %eax
.BB4:
movl %ebp, %esp
popl %edi
popl %esi
popl %ebx
popl %ebp
ret
.globl main
main:
pushl %ebp
pushl %ebx
pushl %esi
pushl %edi
movl %esp, %ebp
subl $12, %esp
.BB5:
movl $1, -4(%ebp)
jmp .BB7
.BB6:
leal .str0, %eax
pushl -4(%ebp)
movl %eax, -8(%ebp)
call f
addl $4, %esp
pushl %eax
pushl -4(%ebp)
pushl -8(%ebp)
call printf
addl $12, %esp
incl -4(%ebp)
.BB7:
cmpl $10, -4(%ebp)
jle .BB6
.BB8:
movl $0, %eax
movl %ebp, %esp
popl %edi
popl %esi
popl %ebx
popl %ebp
ret
其中”BB”是基本块Basic Block的意思。如前文1.3节在为if和while语句生成中间代码时所述,有条件或无条件的跳转会引起控制流的转移。当然,函数调用也可视为一种无条件跳转,因为函数调用所做的工作包括把传递实参、返回地址入栈及无条件跳转到函数起始地址。在进行代码优化时,我们需要考虑控制流的变化。我们期望控制流只能从基本块的第一条指令进入,离开时从基本块中的最后一条指令离开。而整个程序就由若干个基本块构成,而跳转指令就可看成连接两个基本块的边,于是在中间代码生成后,我们就把整个程序看成数据结构中的“图”,这个由基本块构成的图被称为控制流图control flow graph,简写为CFG。后续的分析和优化都是基于“控制流图”这样的数据结构进行。当然,需要说明的是,UCC并未把函数调用视为控制流的转移,例如上述基本块“.BB6:”,其中” call printf”是对printf函数的调用,但UCC并未将其当作基本块的最后一条指令。原因在于UCC的没有做太多的优化,并未作复杂的过程间分析,其优化几乎只在基本块内进行,在分析ucc\ucl\simp.c中的PeepHole()函数和ucc\ucl\x86.c的EmitBBlock()函数时我们会进一步展开。对照着hello.c的源代码,即便从未学过汇编语言,也能对hello.ast、hello.uil和hello.s中的代码猜出个大概意思。