编译过程剖析。第一部分。
本文以及后续文章均基于我几年前教授的软件开发课程。该课程的学生是非程序员,他们被聘用来接收编译器产品的错误报告。作为分析师,他们必须详细了解软件编译过程,即使他们中的一些人从未编写过一行代码。这是一门有趣的课程,所以我希望这个主题能转化为有趣的阅读材料。
在本文中,我将讨论计算机将源代码编译成可执行程序所经历的过程。我不会像在课堂上那样,用 Make 环境或版本控制来混淆这个问题。在本文中,我们只讨论在您键入 gcc test.c 后发生的事情。
广义上讲,编译过程分为 4 个步骤:预处理、编译、汇编和链接。我们将依次讨论每个步骤。
在我们讨论编译程序之前,我们真的需要有一个程序来编译。我们的程序需要足够简单,以便我们可以详细讨论它,但也要足够广泛,以便它可以练习我想讨论的所有概念。这是一个我希望符合要求的程序
#include <stdio.h> // This is a comment. #define STRING "This is a test" #define COUNT (5) int main () { int i; for (i=0; i<COUNT; i++) { puts(STRING); } return 1; }
如果我们把这个程序放在一个名为 test.c 的文件中,我们可以用简单的命令 gcc test.c 编译这个程序。我们最终得到的是一个名为 a.out 的可执行文件。名称 a.out 有一些历史渊源。在 PDP 计算机时代,a.out 代表“汇编器输出”。今天,它仅仅意味着一种较旧的可执行文件格式。现代版本的 Unix 和 Linux 使用 ELF 可执行文件格式。ELF 格式要复杂得多。因此,即使 gcc 输出的默认文件名是“a.out”,但它实际上是 ELF 格式。历史就讲到这里,让我们运行我们的程序。
当我们键入 ./a.out 时,我们得到
This is a test This is a test This is a test This is a test This is a test
当然,这并不令人惊讶,所以让我们讨论一下 gcc 为了从 test.c 文件创建 a.out 文件而经历的步骤。
如前所述,编译器执行的第一步是将其源代码发送到 C 预处理器。C 预处理器负责 3 个任务:文本替换、剥离注释和文件包含。文本替换和文件包含在我们的源代码中使用预处理器指令请求。我们代码中以“#”字符开头的行是预处理器指令。第一个指令请求将标准头文件 stdio.h 包含到我们的源文件中。另外两个指令请求在我们的代码中进行字符串替换。通过使用 gcc 的“-E”标志,我们可以看到仅在我们的代码上运行 C 预处理器的结果。stdio.h 文件相当大,所以我将稍微清理一下结果。
gcc -E test.c > test.txt # 1 "test.c" # 1 "/usr/include/stdio.h" 1 3 4 # 28 "/usr/include/stdio.h" 3 4 # 1 "/usr/include/features.h" 1 3 4 # 330 "/usr/include/features.h" 3 4 # 1 "/usr/include/sys/cdefs.h" 1 3 4 # 348 "/usr/include/sys/cdefs.h" 3 4 # 1 "/usr/include/bits/wordsize.h" 1 3 4 # 349 "/usr/include/sys/cdefs.h" 2 3 4 # 331 "/usr/include/features.h" 2 3 4 # 354 "/usr/include/features.h" 3 4 # 1 "/usr/include/gnu/stubs.h" 1 3 4 # 653 "/usr/include/stdio.h" 3 4 extern int puts (__const char *__s); int main () { int i; for (i=0; i<(5); i++) { puts("This is a test"); } return 1; }
首先变得明显的是,C 预处理器为我们的小程序添加了很多内容。在我清理它之前,输出超过 750 行。那么,添加了什么,为什么?好吧,我们的程序请求将 stdio.h 头文件包含到我们的源代码中。stdio.h 反过来又请求了大量其他头文件。因此,预处理器记录了发出请求的文件和行号,并将此信息提供给编译过程的后续步骤。因此,以下行,
# 28 "/usr/include/stdio.h" 3 4 # 1 "/usr/include/features.h" 1 3 4
表明 features.h 文件是在 stdio.h 的第 28 行请求的。预处理器在可能对后续编译步骤“有趣”的内容之前创建行号和文件名条目,以便如果出现错误,编译器可以准确地报告错误发生的位置。
当我们看到以下行时,
# 653 "/usr/include/stdio.h" 3 4 extern int puts (__const char *__s);
我们看到 puts() 被声明为一个外部函数,它返回一个整数并接受一个常量字符数组作为参数。如果此声明出现严重错误,编译器可以告诉我们该函数是在 stdio.h 的第 653 行声明的。有趣的是,请注意 puts() 没有被定义,只是被声明了。也就是说,我们看不到实际使 puts() 工作的代码。稍后我们将讨论 puts() 以及其他常用函数是如何定义的。
另请注意,我们的程序注释都没有留在预处理器输出中,并且所有字符串替换都已执行。此时,程序已准备好进行下一步处理,编译成汇编语言。
我们可以使用 gcc 的 -S 标志来检查编译过程的结果。
gcc -S test.c
此命令会生成一个名为 test.s 的文件,其中包含我们程序的汇编代码实现。让我们简要地看一下。
.file "test.c" .section .rodata .LC0: .string "This is a test" .text .globl main .type main, @function main: leal 4(%esp), %ecx andl $-16, %esp pushl -4(%ecx) pushl %ebp movl %esp, %ebp pushl %ecx subl $20, %esp movl $0, -8(%ebp) jmp .L2 .L3: movl $.LC0, (%esp) call puts addl $1, -8(%ebp) .L2: cmpl $4, -8(%ebp) jle .L3 movl $1, %eax addl $20, %esp popl %ecx popl %ebp leal -4(%ecx), %esp ret .size main, .-main .ident "GCC: (GNU) 4.2.4 (Gentoo 4.2.4 p1.0)" .section .note.GNU-stack,"",@progbits
我的汇编语言技能有点生疏,但是我们可以很容易地发现一些特性。我们可以看到我们的消息字符串已被移动到内存的不同部分,并被命名为 .LC0。我们还可以看到,启动和退出我们的程序需要相当多的步骤。您可能能够在 .L2 处跟踪 for 循环的实现;它只是一个比较 (cmpl) 和一个“小于则跳转” (jle) 指令。初始化是在 .L3 标签正上方的 movl 指令中完成的。对 puts() 的调用很容易发现。不知何故,汇编器知道它可以按名称调用 puts() 函数,而不是像其余内存位置那样使用古怪的标签。接下来,当我们讨论编译的最后阶段,链接时,我们将讨论这种机制。最后,我们的程序以返回 (ret) 结束。
编译过程的下一步是将生成的汇编代码汇编成目标文件。当我们讨论链接时,我们将更详细地讨论目标文件。可以肯定地说,汇编是将(相对)人类可读的汇编语言转换为机器可读的机器语言的过程。
链接是最终阶段,它要么生成可执行程序文件,要么生成可以与其他目标文件组合以生成可执行文件的目标文件。在链接阶段,我们最终解决了对 puts() 的调用问题。请记住,puts() 在 stdio.h 中声明为外部函数。这意味着该函数实际上将在其他地方定义或实现。如果我们的程序中有多个源文件,我们可能已将我们的一些函数声明为 extern,并在不同的文件中实现它们;通过声明为 extern,此类函数将在我们的源文件中的任何地方都可用。在编译器确切知道所有这些函数在何处实现之前,它只是使用函数调用的占位符。链接器将解析所有这些依赖项,并插入函数的实际地址。
链接器还为我们执行一些额外的任务。它将我们的程序与运行我们的程序所需的一些标准例程组合在一起。例如,在我们的程序开头需要标准代码来设置运行环境,例如传入命令行参数和环境变量。此外,在我们的程序结束时,还需要运行代码,以便它可以传回返回代码以及其他任务。事实证明,这并不是少量代码。让我们来看一下。
如果我们像上面那样编译我们的示例程序,我们将得到一个大小为 6885 字节的可执行文件。但是,如果我们指示编译器不要经过链接阶段,通过使用 -c 标志(gcc -c test.c -o test.o),我们将得到一个大小为 888 字节的目标模块。文件大小的差异是启动和终止我们程序的代码,以及允许我们在 libc.so 中调用 puts() 函数的代码。
至此,我们已经详细了解了编译过程。我希望这对您来说很有趣。下次,我们将更详细地讨论链接过程,并考虑 gcc 提供的一些优化功能。