C可执行文件生成过程概述(深入理解计算机系统第七章01)
C可执行文件生成过程概述(深入理解计算机系统第七章01)
我一直有这样一个疑问,每当我在编辑器中写下一行行代码,完成一个又一个程序时,写好的.c
文件里其实都是写满了使用特定编码(ASCII、UTF-8、GBK等等)的字符,电脑或运行程序的机器是如何转化为一个可执行文件的呢?
以一个HelloWorld程序为例,在Linux系统中可以通过图中的gcc命令生成可执行文件,这实际上经历了下面几个过程,这里把整个过程分解采用手动链接的方式生成可执行程序。
gcc命令实际上是具体程序(如ccp、cc1、as等)的包装命令,
用户通过gcc命令来使用具体的预处理程序cpp、编译程序cc1和汇编程序as等。
预处理
首先是将.c
文件通过预处理程序(c preprocessor)cpp
生成预处理程序hello.i
(通常以.i
扩展名结尾),经过预处理后的文件还是一个可读的高级语言源程序文本文件,只不过不包含任何宏定义,为后续编译做准备。
这里直接使用cpp
预处理器,也可以使用gcc -E -o hello.i hello.c
命令进行预处理,-E
参数表示只激活预处理。我们可以看下生成的hello.i
文件有733行。
实际上预处理过程只是处理了源文件中以#
开头的预编译指令,包括:
- 删除
#define
并展开所定义的宏。 - 处理所有条件预编译指令,如
#if
、#ifdef
、#endif
等。 - 插入头文件到
#include
处,可以递归方式进行处理。 - 删除所有的注释
//
和/* */
。 - 添加行号和文件名标识,以便编译时编译器产生调试用的行号信息。
- 保留所有#pragma编译指令(编译器需要用)。
编译
下面进行编译,将.i
文件通过编译程序(编译器)cc1
编译生成汇编语言程序hello.s
文件(通常以.s
扩展名结尾),经过编译后的文件依然是可读的文本文件,只不过内容已经编译成了对应的汇编语言源程序。
这里使用c编译器(c compiler)cc
,同样也可以使用gcc -S -o hello.s hello.i
命令编译,-S
参数表示只激活预处理和编译,因为编译后生成的依旧是文本文件我们可以直接打开查看。
可以看到经过编译的词法分析、语法分析、语义分析并优化后我们的C语言程序代码已经变成了由汇编指令构成的汇编代码文件,由于还是文本文件计算机依旧不能理解和执行它。
汇编
下一步就是将.s
文件通过汇编程序(汇编器)as
进行汇编,也就是将汇编语言源程序转换为机器语言序列,生成可重定位目标文件hello.o
,这个文件里就是由二进制的机器指令代码,计算机可以直接识别。汇编指令和机器指令一一对应,前者是后者的符号表示,它们都属于机器级指令,所构成的程序成为机器级代码。
这里使用汇编器(assembler)as
,同样可以使用gcc -c -o hello.o hello.s
命令汇编,-c
参数表示只激活预处理,编译,和汇编。汇编结果是可重定位目标文件,其中包含的是不可读的二进制代码,无法被当做普通文本文件打开,只能用相应的工具软件来查看其内容。
比如可以用objdump -S
命令反汇编查看我们的目标文件包含的指令和数据。可以看到代码和数据的地址都是从0开始,因为可重定位目标文件还不清楚每个符号实际的地址,下面链接的重定位过程会计算每个定义的符号在虚拟地址空间的绝对地址并将可执行文件中的符号引用处的地址修改为重定位后的地址信息。
汇编生成的可重定位目标文件或是链接后生成的可执行目标文件都是ELF (Executable and Linkable Format)格式的文件,可以使用readelf -a
命令查看包括ELF头(ELF header)、程序头表(Program header table)和节头表(Section header table)的目标文件详细信息。
链接
一个程序可能会包含许多其他模块,也会使用到一些库,如这里使用到的printf
函数就是定义在标准库libc
中定义的。通过链接器ld
将多个可重定位目标文件通过符号解析(symbol resolution)和重定位(relocation)合并以生成可执行目标程序,可执行目标文件程序也是由机器可以直接识别执行的二进制代码构成的。
当我们手动调用链接器ld
来构造可执行程序时,除了需要用到汇编阶段得到的hello.o
之外还需要加上crt库和crt入口等参数。
所以这里我们直接使用gcc
命令来自动链接我们的可重定位目标文件hello.o
。-static
参数表示静态链接,如不指定则默认动态链接。-o
参数指定目标名称,如不指定则默认为a.out
。
连接操作得到的就是可执行目标文件。
链接过程的本质就是合并相同的“节”(session)到虚拟地址空间。
运行
最后确认得到的可执行目标文件prog是否能正确运行。
通过shell调用操作系统中的加载器(loader)函数,将可执行目标文件中的代码和数据复制到内存中然后将CPU的控制权转移到prog程序的开头。这里只测试生成的可执行程序是否正常运行,关于可执行目标文件的加载和运行后面再详细说明。