原文:https://www.linuxjournal.com/content/examining-compilation-process-part-1

参考:1. https://www.cnblogs.com/qytan36/archive/2010/05/25/1743955.html

  1. https://zhuanlan.zhihu.com/p/44922656

GCC参数详解:https://www.runoob.com/w3cnote/gcc-parameter-detail.html

编译过程可分为四部分:

  • 预处理(preprocessing)
  • 编译(compilation)
  • 汇编(assembly)
  • 链接(linking)

Linux编译过程 - 图1

用下面的一个简单代码示例来解释:

  1. //该代码所在文件名为“test.c”
  2. #include <stdio.h>
  3. // This is a comment.
  4. #define STRING "This is a test"
  5. #define COUNT (5)
  6. int main () {
  7. int i;
  8. for (i=0; i<COUNT; i++) {
  9. puts(STRING);
  10. }
  11. return 1;
  12. }

一步到位生成可执行文件的命令: gcc -o test.out(指定生成的可执行文件名) test.c

如果我们直接使用命令(一步到位):gcc test.c,我们会得到一个可执行文件“a.out”。a.out有一段历史。很早之前在使用PDP电脑的时候,a.out表示“汇编程序输出”。如今,a.out只是简单的表示一个可执行文件的格式。现代的Unix和Linux使用的是ELF可执行文件格式(ELF格式比较复杂)。所以,上面生成的a.out可执行文件的格式是ELF。

当我们键入./a.out,我们会看到:

  1. This is a test
  2. This is a test
  3. This is a test
  4. This is a test
  5. This is a test

Screenshot from 2021-01-20 20-39-01.png
接下来,讨论一下gcc从test.c到创建a.out所经历的步骤:

1 预处理

首先,第一步就是预处理。C预处理器主要负责:文本替换(text substitution)、注释剥离(stripping comments)、文件包含(file inclusive)。

  • 在我们的源代码中使用预处理指令来请求 文本替换 和 文件包含。在代码中使用#来表示这是预处理指令。
    • 第一个是头文件stdio.h,他是包含在源文件中的。(文件包含)
    • 第二个是字符串替换。(文本替换)

通过使用gcc的“-E”标志,我们可以看到只在我们的代码上运行C预处理器的结果。这一步需要用到参数Egcc -E test.c(源文件) -o test.i(生成文件名)

其中的头文件stdio.h预处理之后会变得很多行。下面只截取一小部分:

  1. gcc -E test.c > test.txt
  2. # 1 "test.c"
  3. # 1 "/usr/include/stdio.h" 1 3 4
  4. # 28 "/usr/include/stdio.h" 3 4
  5. # 1 "/usr/include/features.h" 1 3 4
  6. # 330 "/usr/include/features.h" 3 4
  7. # 1 "/usr/include/sys/cdefs.h" 1 3 4
  8. # 348 "/usr/include/sys/cdefs.h" 3 4
  9. # 1 "/usr/include/bits/wordsize.h" 1 3 4
  10. # 349 "/usr/include/sys/cdefs.h" 2 3 4
  11. # 331 "/usr/include/features.h" 2 3 4
  12. # 354 "/usr/include/features.h" 3 4
  13. # 1 "/usr/include/gnu/stubs.h" 1 3 4
  14. ......
  15. # 653 "/usr/include/stdio.h" 3 4
  16. extern int puts (__const char *__s);
  17. int main () {
  18. int i;
  19. for (i=0; i<(5); i++) {
  20. puts("This is a test");
  21. }
  22. return 1;
  23. }

首先显而易见的是,C预处理器已经向我们这个简单的小程序添加了很多东西。我的总共生成了742行。为什么会变这么长呢?
答:首先,我们需要将头文件stdio.h的源代码包含进去到我们的代码中。而在头文件stdio.h实现的时候又包含了其他的头文件。因此,预处理程序记录了发出请求的文件和行号,并使编译过程中的下一步可以使用这些信息。例如:

  1. # 28 "/usr/include/stdio.h" 3 4
  2. # 1 "/usr/include/features.h" 1 3 4

如上所示,指示在stdio.h的第28行请求了features.h文件。

预处理器在后续编译步骤可能“感兴趣”的内容之前创建行号和文件名条目,这样,如果出现错误,编译器可以准确地报告错误发生的位置。如下面: # 653 "/usr/include/stdio.h" 3 4 extern int puts (__const char *__s); 我们看到puts()被声明为一个外部函数,该函数返回一个整数,并接受单个常量字符数组作为参数。如果这个声明出现了严重的错误,编译器会告诉我们这个函数是在stdio.h的第653行声明的。有趣的是,puts()没有定义,只是声明了。也就是说,我们没有看到实际使put()工作的代码。稍后我们将讨论如何定义put()和其他常用函数。

还要注意的是,在预处理器输出中没有留下任何程序注释,而且所有的字符串替换都已执行。在这一点上,程序已经为过程的下一步—— 编译成汇编语言 ——做好了准备。

2 编译

使用参数S

第二步进行的是编译阶段,在这个阶段中,Gcc首先要检查代码的规范性、是否有语法错误等,以确定代码的实际要做的工作,在检查无误后,Gcc把代码翻译成汇编语言。用户可以使用”-S”选项来进行查看,该选项只进行编译而不进行汇编,生成汇编代码。
gcc –S test.i(预处理后文件) –o test.s(生成的汇编代码文件)

执行之后,生成的test.s文件内容为:

  1. .file "test.c"
  2. .section .rodata
  3. .LC0:
  4. .string "This is a test"
  5. .text
  6. .globl main
  7. .type main, @function
  8. main:
  9. leal 4(%esp), %ecx
  10. andl $-16, %esp
  11. pushl -4(%ecx)
  12. pushl %ebp
  13. movl %esp, %ebp
  14. pushl %ecx
  15. subl $20, %esp
  16. movl $0, -8(%ebp)
  17. jmp .L2
  18. .L3:
  19. movl $.LC0, (%esp)
  20. call puts
  21. addl $1, -8(%ebp)
  22. .L2:
  23. cmpl $4, -8(%ebp)
  24. jle .L3
  25. movl $1, %eax
  26. addl $20, %esp
  27. popl %ecx
  28. popl %ebp
  29. leal -4(%ecx), %esp
  30. ret
  31. .size main, .-main
  32. .ident "GCC: (GNU) 4.2.4 (Gentoo 4.2.4 p1.0)"
  33. .section .note.GNU-stack,"",@progbits

3 汇编阶段(Assembling)

使用参数c
汇编阶段是把编译阶段生成的汇编代码文件”test.s”转成二进制目标代码(机器可读的机器语言)。
gcc –c test.s(汇编代码文件) –o test.o(生成的二进制目标代码)

4 链接阶段(Link)

使用参数:o
gcc test.o(二进制目标代码) –o test.out(可执行文件)

链接是产生可执行程序文件或可与其他目标文件结合产生可执行文件的最后阶段。

在链接阶段,我们最终解决了调用puts()的问题。记住,puts()在stdio.h中声明为一个外部函数。这意味着函数实际上将在其他地方定义或实现。如果我们的程序中有几个源文件,我们可能会将一些函数声明为extern,并在不同的文件中实现它们;

这样的函数在我们的源文件的任何地方都可以使用,因为它们被声明为extern。

在编译器确切地知道所有这些函数的实现位置之前,它只是为函数调用使用一个占位符。链接器将解析所有这些依赖项,并插入函数的实际地址。

链接器还为我们做了一些额外的任务。它将我们的程序与使我们的程序运行所需的一些标准例程结合起来。例如,在我们的程序开始时需要一些标准代码来设置运行环境,比如传入命令行参数和环境变量。此外,还有一些代码需要在我们的程序结束时运行,以便它可以在其他任务中传递返回代码。事实证明,这是相当多的代码。

在这里涉及到一个重要的概念:函数库。
读者可以重新查看这个小程序,在这个程序中并没有定义”printf”的函数实现,且在预编译中包含进的”stdio.h”中也只有该函数的声明,而没有定义函数的实现,那么,是在哪里实现”printf”函数的呢?最后的答案是:系统把这些函数实现都被做到名为libc.so.6的库文件中去了,在没有特别指定时,gcc会到系统默认的搜索路径”/usr/lib”下进行查找,也就是链接到libc.so.6库函数中去,这样就能实现函数”printf” 了,而这也就是链接的作用。
你可以用ldd命令查看动态库加载情况:
image.png

补充: 链接也分为静态链接和动态链接,其要点如下:

  • 静态链接是指在编译阶段直接把静态库加入到可执行文件中去,这样可执行文件会比较大。链接器将函数的代码从其所在地(不同的目标文件或静态链接库中)拷贝到最终的可执行程序中。为创建可执行文件,链接器必须要完成的主要任务是:符号解析(把目标文件中符号的定义和引用联系起来)和重定位(把符号定义和内存地址对应起来然后修改所有对符号的引用)。(后缀一般为.a
  • 动态链接则是指链接阶段仅仅只加入一些描述信息,而程序执行时再从系统中把相应动态库加载到内存中去。(后缀一般为.so
    • 在Linux系统中,gcc编译链接时的动态库搜索路径的顺序通常为:首先从gcc命令的参数-L指定的路径寻找;再从环境变量LIBRARY_PATH指定的路径寻址;再从默认路径/lib、/usr/lib、/usr/local/lib寻找。
    • 在Linux系统中,执行二进制文件时的动态库搜索路径的顺序通常为:首先搜索编译目标代码时指定的动态库搜索路径;再从环境变量LD_LIBRARY_PATH指定的路径寻址;再从配置文件/etc/ld.so.conf中指定的动态库搜索路径;再从默认路径/lib、/usr/lib寻找。
    • 在Linux系统中,可以用ldd命令查看一个可执行程序依赖的共享库。