一般高级语言程序编译的过程:预处理
、编译
、汇编
、链接
。
gcc在后台实际上也经历了这几个过程,我们可以通过-v
参数查看它的编译细节,如果想看某个具体的编译过程,则可以分别使用-E
, -S
, -c
和 -O
,对应的后台工具则分别为cpp,cc1,as,ld。
下面我们将逐步分析这几个过程以及相关的内容,诸如语法检查、代码调试、汇编语言等。
预处理
预处理是C语言程序从源代码变成可执行程序的第一步,主要是C语言编译器对各种预处理命令进行处理,包括头文件的包含、宏定义的扩展、条件编译的选择等。打印出预处理之后的结果:gcc -E hello.c
或者 cpp hello.c
这样我们就可以看到源代码中的各种预处理命令是如何被解释的,从而方便理解和查错。
gcc调用了cpp的(虽然我们通过gcc的-v仅看到cc1),cpp即The C Preprocessor,主要用来预处理宏定义、文件包含、条件编译等。下面介绍它的一个比较重要的选项-D。在命令行定义宏:gcc –Dmacro=1 hello.c
或者 cpp –Dmacro=1 hello.c
等同于在文件的开头定义宏,即#define maco
,但是在命令行定义更灵活。例如,在源代码中有这些语句:
1 |
|
编译
编译之前,C语言编译器会进行词法分析、语法分析(-fsyntax-only),接着会把源代码翻译成中间语言,即汇编语言。如果想看到这个中间结果,可以用-S选项。
编译程序工作时,先分析,后综合,从而得到目标程序。所谓分析
,是指词法分析
和语法分析
;所谓综合是指代码优化,存储分配和代码生成。为了完成这些分析综合任务,编译程序采用对源程序进行多次扫描的办法,每次扫描集中完成一项或几项任务,也有一项任务分散到几次扫描去完成的。
下面举一个四遍扫描的例子:
- 第一遍扫描做词法分析;
- 第二遍扫描做语法分析;
- 第三遍扫描做代码优化和存储分配;
- 第四遍扫描做代码生成。
值得一提的是,大多数的编译程序直接产生机器语言的目标代码,形成可执行的目标文件,但也有的编译程序则先产生汇编语言一级的符号代码文件,然后再调用汇编程序进行翻译加工处理,最后产生可执行的机器语言目标文件。
语法检查之后是翻译动作,gcc提供了一个优化选项-O
,以便根据不同的运行平台和用户要求产生经过优化的汇编代码。例如,
1 | $ gcc -o hello hello.c #采用默认选项,不优化 |
根据上面的简单演示,可以看出gcc有很多不同的优化选项,主要看用户的需求了,目标代码的大小和效率之间貌似存在一个纠缠
,需要开发人员自己权衡。
下面我们通过-S
选项来看看编译出来的中间结果,汇编语言,还是以之前那个hello.c为例。
1 | $ gcc -S hello.c #默认输出是hello.s,可自己指定,输出到屏幕-o -,输出到其他文件-o file |
和intel的汇编语法不太一样,这里用的是AT&T语法格式。这里需要补充的是,在写C语言代码时,如果能够对编译器比较熟悉(工作原理和一些细节)的话,可能会很有帮助。包括这里的优化选项(有些优化选项可能在汇编时采用)和可能的优化措施。
汇编
把作为中间结果的汇编代码翻译成了机器代码,即目标代码,不过它还不可以运行。如果要产生这一中间结果,可用gcc的-c
选项,当然,也可通过as命令汇编语言源文件来产生。
1 | $ file hello.s |
gcc和as默认产生的目标代码都是ELF格式的,因此这里主要讨论ELF格式的目标代码。目标代码不再是普通的文本格式,无法直接通过文本编辑器浏览,需要一些专门的工具。
binutils(GNU Binary Utilities)的很多工具都采用这个库来操作目标文件,这类工具有objdump, objcopy, nm, strip
等,不过另外一款非常优秀的分析工具readelf并不是基于这个库,所以你也应该可以直接用elf.h头文件中定义的相关结构来操作ELF文件。
ELF文件的结构:
- ELF Header (ELF文件头)说明了文件的类型,大小,运行平台,节区数目等。
- Porgram Headers Table (程序头表,实际上叫段表好一些,用于描述可执行文件和可共享库)
Section 1
Section 2
…- Section Headers Table(节区头部表,用于链接可重定位文件成可执行文件或共享库)
可以分别通过 readelf文件的-h
,-l
和-S
参数查看ELF文件头(ELF Header)、程序头部表(Program Headers Table,段表)和节区表(Section Headers Table)。
下面通过这几段代码来演示通过readelf -h
参数查看ELF的不同类型。期间将演示如何创建动态连接库(即可共享文件)、静态连接库,并比较它们的异同。
1 | $ gcc -c myprintf.c test.c #编译产生两个目标文件myprintf.o和test.o,它们都是可重定位文件(REL) |
可重定位文件本身不可以运行,仅仅是作为可执行文件、静态连接库(也是可重定位文件)、动态连接库的 “组件”。
下面来看看ELF文件的主体内容,节区(Section)。ELF文件具有很大的灵活性,它通过文件头组织整个文件的总体结构,通过节区表 (Section Headers Table)和程序头(Program Headers Table或者叫段表)来分别描述可重定位文件和可执行文件。在可重定位文件中,节区表描述的就是各种节区本身;而在可执行文件中,程序头描述的是由各个节区组成的段(Segment),以便程序运行时动态装载器知道如何对它们进行内存映像,从而方便程序加载和运行。
可以通过readelf的-S参数查看ELF的节区。先来看看可重定位文件的节区信息,通过节区表来查看:
1 | $ gcc -c myprintf.c #默认编译好myprintf.c,将产生一个可重定位的文件myprintf.o |
从上表可以看出,对于可重定位文件,会包含这些基本节区.text, .rel.text, .data, .bss, .rodata, .comment, .note.GNU-stack, .shstrtab, .symtab和.strtab
。
看一看 myprintf.c 产生的汇编代码。
1 | $ gcc -S myprintf.c |
链接
链接是处理可重定位文件,把它们的各种符号引用和符号定义转换为可执行文件中的合适信息(一般是虚拟内存地址)的过程。链接又分为静态链接和动态链接,前者是程序开发阶段程序员用ld(gcc实际上在后台调用了ld)静态链接器手动链接的过程,而动态链接则是程序运行期间系统调用动态链接器(ld-linux.so)自动链接的过程。比如,如果链接到可执行文件中的是静态连接库libmyprintf.a,那么.rodata节区在链接后需要被重定位到一个绝对的虚拟内存地址,以便程序运行时能够正确访问该节区中的字符串信息。而对于puts,因为它是动态连接库libc.so中定义的函数,所以会在程序运行时通过动态符号链接找出puts函数在内存中的地址,以便程序调用该函数。
静态链接过程主要是把可重定位文件依次读入,分析各个文件的文件头,进而依次读入各个文件的节区,并计算各个节区的虚拟内存位置,对一些需要重定位的符号进行处理,设定它们的虚拟内存地址等,并最终产生一个可执行文件或者是动态链接库。这个链接过程是通过ld来完成的,ld在链接时使用了一个链接脚本(linker scripq),该链接脚本处理链接的具体细节。这里主要介绍可重定位文件中的节区(节区表描述的)和可执行文件中段(程序头描述的)的对应关系以及gcc编译时采用的一些默认链接选项。
下面先来看看可执行文件的节区信息,通过程序头(段表)来查看:
1 | $ readelf -S test.o #为了比较,先把test.o的节区表也列出 |
上表给出了可执行文件的如下几个段(segment),
- PHDR: 给出了程序表自身的大小和位置,不能出现一次以上。
- INTERP: 因为程序中调用了puts(在动态链接库中定义),使用了动态连接库,因此需要动态装载器/链接器(ld-linux.so)
- LOAD: 包括程序的指令,.text等节区都映射在该段,只读(R)
- LOAD: 包括程序的数据,.data, .bss等节区都映射在该段,可读写(RW)
- DYNAMIC: 动态链接相关的信息,比如包含有引用的动态连接库名字等信息
- NOTE: 给出一些附加信息的位置和大小
- GNU_STACK: 这里为空,应该是和GNU相关的一些信息
这里的段可能包括之前的一个或者多个节区,也就是说经过链接之后原来的节区被重排了,并映射到了不同的段,这些段将告诉系统应该如何把它加载到内存中。这些新的节区来自哪里?它们的作用是什么呢?先来通过gcc的-v
参数看看它的后台链接过程。
1 | $ gcc -v -o test test.o myprintf.o #把可重定位文件链接成可执行文件 |