编译过程探究,第 2 部分。
在我的上一篇文章中,我相当详细地讨论了 GCC 用来将 C 源代码文件转换为可执行程序文件的过程。这些步骤包括预处理源代码以删除注释、根据需要包含其他文件以及字符串替换。然后将生成的文件编译成汇编语言。然后使用汇编语言输出创建包含机器语言的目标文件,然后将其与其他标准化库链接以创建可执行文件。
正如上一篇文章中提到的,这篇文章以及这一篇都基于我几年前教授的软件开发课程。有些人会觉得这是一个相当枯燥的主题;另一些人会很高兴看到编译器对我们的作品执行的一些魔法,以使其可执行。我碰巧属于后一类,我希望你也一样。
因此,上次我在文章的结尾对链接过程进行了非常简单的讨论。我打算在本文中更深入地探讨链接过程,以及关于 GCC 可以为您执行的一些优化的讨论。
在我们深入探讨之前,让我们看一个快速示例,了解链接过程为我们做了什么。对于这个例子,我们有两个文件,main.c
和 funct.c
main.c: #include <stdio.h> extern void funct(); int main () { funct(); }
是的,这是一个非常简单的程序。请注意,我们没有定义函数 funct()
,只是将其声明为一个不接受参数且不返回值的外部函数。我们将在下一个文件 funct.c
中定义此函数
void funct () { puts("Hello World."); }
你们中的大多数人现在都看到了方向。这是众所周知的“Hello World”程序,只是为了教学目的,我们将其分解为两个单独的文件。在实际项目中,您将使用 make 程序来安排编译所有文件,但我们将手动进行编译。
首先,我们将 main.c
编译成 main.o
文件
gcc main.c -c -o main.o
此命令告诉 GCC 编译源文件,但不运行链接器,以便我们留下一个目标文件,我们希望将其命名为 main.o
。
编译 funct.c
非常相似
gcc funct.c -c -o funct.o
现在我们可以再次调用 GCC,只是这次,我们希望它运行链接器
gcc main.o funct.o -o hello
在这个例子中,我们提供了几个“.o”目标文件的名称,请求将它们全部链接,并将生成的可执行文件命名为 hello。
如果执行 ./hello 导致“Hello World.”,您会感到惊讶吗?
我不这么认为。那么,我们为什么要采用最简单的程序并将其拆分为两个单独的文件呢?嗯,因为我们可以。我们这样做的好处是,如果我们只更改其中一个文件,我们不必重新编译任何未更改的文件;我们只需将已存在的目标文件重新链接到我们在编译更改后的源文件时创建的新目标文件。这就是 make 实用程序派上用场的地方,因为它可以跟踪哪些文件自上次编译以来已更改,从而确定哪些文件需要重新编译。
本质上,假设我们有一个非常大的软件项目。我们可以将其编写为一个文件,并在需要时简单地重新编译它。但是,这将使多个人员难以在项目上工作,因为一次只能我们中的一人工作。此外,这意味着编译过程将非常耗时,因为它必须编译数千行 C 源代码。但是,如果我们将项目拆分为几个较小的文件,则可以有多个人员在项目上工作,并且我们只需要编译那些被更改的文件。
Linux 链接器非常强大。链接器能够将目标文件链接在一起,如上面的示例所示。它还能够创建共享库,这些共享库可以在运行时加载到我们的程序中。虽然我们不会讨论共享库的创建,但我们将看到一些系统已经有的例子。
在我的上一篇文章中,我使用了一个名为 test.c
的源文件进行讨论
#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; }
我们可以使用以下命令编译此程序
gcc test.c -o test
我们可以使用 ldd 命令获取程序依赖的共享库列表。
ldd test
我们看到
linux-gate.so.1 => (0xffffe000) libc.so.6 => /lib/libc.so.6 (0xb7e3c000) /lib/ld-linux.so.2 (0xb7f9a000)
libc.so.6 条目相当容易理解。它是标准的 C 库,其中包含诸如 puts()
和 printf()
之类的东西。我们还可以看到哪个文件提供了这个库,/lib/libc.so.6。其他两个更有趣。ld-linux.so.2 是一个库,用于查找和加载程序运行所需的所有其他共享库,例如前面提到的 libc。Linux-gate.so.1 条目也很有趣。这个库实际上只是 Linux 内核创建的一个虚拟库,它让程序知道如何进行系统调用。有些系统支持 sysenter 机制,而另一些系统通过中断机制调用系统调用,这要慢得多。接下来我们将讨论系统调用。
系统调用是与操作系统交互的标准化接口。长话短说,你如何分配内存?你如何将字符串输出到控制台?你如何读取文件?这些功能由系统调用提供。让我们仔细看看。
我们可以使用 strace 命令查看程序使用的函数调用。例如,让我们使用 strace 程序查看上面的 test 程序。
strace ./test
此命令产生类似于我们下面看到的输出,只是为了方便参考,我添加了行号。
1 execve("./test", ["./test"], [/* 56 vars */]) = 0 2 brk(0) = 0x804b000 3 access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory) 4 open("/etc/ld.so.cache", O_RDONLY) = 3 5 fstat64(3, {st_mode=S_IFREG|0644, st_size=149783, ...}) = 0 6 mmap2(NULL, 149783, PROT_READ, MAP_PRIVATE, 3, 0) = 0xb7f79000 7 close(3) = 0 8 open("/lib/libc.so.6", O_RDONLY)= 3 9 read(3, "\177ELF\1\1\1\0\0\0\0\0\0\0\0\0\3\0\3\0\1\0\0\0\220g\1\0004\0\0\0"..., 512) = 512 10 fstat64(3, {st_mode=S_IFREG|0755, st_size=1265948, ...}) = 0 11 mmap2(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xb7f78000 12 mmap2(NULL, 1271376, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0xb7e41000 13 mmap2(0xb7f72000, 12288, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x131) = 0xb7f72000 14 mmap2(0xb7f75000, 9808, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0xb7f75000 15 close(3) = 0 16 mmap2(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xb7e40000 17 set_thread_area({entry_number:-1 -> 6, base_addr:0xb7e406c0, limit:1048575, seg_32bit:1, contents:0, read_exec_only:0, limit_in_pages:1, seg_not_present:0, useable:1}) = 0 18 mprotect(0xb7f72000, 8192, PROT_READ) = 0 19 mprotect(0x8049000, 4096, PROT_READ)= 0 20 mprotect(0xb7fb9000, 4096, PROT_READ) = 0 21 munmap(0xb7f79000, 149783) = 0 22 fstat64(1, {st_mode=S_IFCHR|0600, st_rdev=makedev(136, 3), ...}) = 0 23 mmap2(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xb7f9d000 24 write(1, "This is a test\n", 15This is a test 25 )= 15 26 write(1, "This is a test\n", 15This is a test 27 )= 15 28 write(1, "This is a test\n", 15This is a test 29 )= 15 30 write(1, "This is a test\n", 15This is a test 31 )= 15 32 write(1, "This is a test\n", 15This is a test
第 1 行和第 2 行只是 shell 执行外部命令所需的调用。在第 3 行到第 8 行中,我们看到系统尝试加载各种共享库。第 8 行显示的是系统尝试加载 libc 的位置。第 9 行向我们展示了第一次读取库文件的结果。在第 8-15 行中,我们看到系统将 libc 文件的内容映射到内存中。这就是系统将库加载到内存中以供我们的程序使用的方式;它只是将文件读取到内存中,并为我们的程序提供一个指向库加载到的内存块的指针。现在我们的程序可以像调用程序的一部分一样调用 libc 中的函数。
第 22 行是系统分配 tty 以将其输出发送到其中的位置。
最后,我们在第 24-32 行中看到我们的输出被发送出去。strace 命令让我们看到我们的程序在底层做了什么。它非常适合学习系统,就像本文一样,以及帮助找出行为不端的程序试图做什么。我有很多次在程序明显“锁定”时运行 strace,结果发现它阻塞在某种文件读取或其他操作上。Strace 是定位这些类型问题的可靠方法。
最后,GCC 支持各种级别的优化,我想讨论一下这到底意味着什么。
让我们看一下另一个程序,test1.c
#include <stdio.h> int main () { int i; for(i=0;i<4;i++) { puts("Hello"); } return 0; }
当我们使用 gcc -s
命令将其转换为汇编语言时,我们得到
.file "t2.c" .section .rodata .LC0: .string "Hello" .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 $3, -8(%ebp) jle .L3 movl $0, %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
我们可以看到 for 循环从 .L3: 开始。它一直运行到 .L2 之后的 jle 指令。现在让我们将这个程序编译成汇编语言,但开启 03 优化
gcc -S -O3 test.c
我们得到的是
main: leal 4(%esp), %ecx andl $-16, %esp pushl -4(%ecx) pushl %ebp movl %esp, %ebp pushl %ecx subl $4, %esp movl $.LC0, (%esp) call puts movl $.LC0, (%esp) call puts movl $.LC0, (%esp) call puts movl $.LC0, (%esp) call puts movl $.LC0, (%esp) call puts addl $4, %esp movl $1, %eax 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
在这里我们可以看到 for 循环已被完全分解出来,并且 gcc 已将其替换为 5 个单独的 puts 系统调用。整个 for 循环都消失了!真棒。
GCC 是一个极其复杂的编译器,甚至能够分解循环不变量。考虑以下代码片段
for (i=0; i<5; i++) { x=23; do_something(); }
如果您编写一个快速程序来练习此代码片段,您将看到对 x 变量的赋值被分解到 for 循环之外的点,只要 x 的值不在循环内部使用。本质上,带有 -O3 的 GCC 将代码重写为这样
x=23; for (i=0; i<5; i++) { do_something(); }
非常好。
对于任何可以猜到 gcc -O3
对这个程序做了什么的人,奖励积分
#include <stdio.h> int main () { int i; int j; for(i=0;i<4;i++) { j=j+2; } return 0; }
不,我一直讨厌奖励问题,所以我直接告诉你答案。GCC 完全分解了我们的程序。由于它什么都不做,GCC 什么也不写。这是该程序的输出
.file "t3.c" .text .p2align 4,,15 .globl main .type main, @function main: leal 4(%esp), %ecx andl $-16, %esp pushl -4(%ecx) xorl %eax, %eax pushl %ebp movl %esp, %ebp pushl %ecx 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
如您所见,程序启动并立即终止。for 循环消失了,对 j 变量的赋值也消失了。非常非常好。
所以,GCC 是一个非常复杂的编译器,能够处理非常大的项目,并对给定的源文件执行一些非常复杂的优化。我希望阅读本文以及之前的文章,能让您更欣赏 Linux 编译器套件的智能化程度,并让您获得一些可用于调试自己程序的理解。