C 程序到 Linux 可执行文件的 4 个阶段的旅程
你编写一个 C 程序,使用 gcc 编译它,然后你得到一个可执行文件。
您有没有想过在编译过程中会发生什么以及 C 程序如何转换为可执行文件?
源代码要经过四个主要阶段才能最终成为可执行文件。
C 程序成为可执行文件的四个阶段如下:
- Pre-processing
- Compilation
- Assembly
- Linking
在本系列文章的第 I 部分中,我们将讨论 gcc 编译器在将 C 程序源代码编译为可执行文件时所经历的步骤。
在继续之前,让我们使用一个简单的 hello world 示例快速了解如何使用 gcc 编译和运行“C”代码。
$ vi print.c
#include <stdio.h>
#define STRING "Hello World"
int main(void)
{
/* Using a macro to print 'Hello World'*/
printf(STRING);
return 0;
}
现在,让我们在这个源代码上运行 gcc 编译器来创建可执行文件。
$ gcc -Wall print.c -o print
在上面的命令中:
- gcc – 调用 GNU C 编译器
- -Wall – 启用所有警告的 gcc 标志。-W 代表警告,我们将“全部”传递给 -W。
- print.c - 输入 C 程序
- -o print - 指示 C 编译器将 C 可执行文件创建为 print。如果不指定 -o,默认情况下 C 编译器将创建名为 a.out 的可执行文件
最后,执行 print,它将执行 C 程序并显示 hello world。
$ ./print
Hello World
注意:当您处理包含多个 C 程序的大型项目时,请使用make 实用程序来管理您的 C 程序编译,正如我们之前讨论的那样。
现在我们对如何使用 gcc 将源代码转换为二进制有了基本的了解,我们将回顾 C 程序必须经过的 4 个阶段才能成为可执行文件。
1. 预处理
这是源代码通过的第一个阶段。在此阶段完成以下任务:
- 宏替换
- 删除注释
- 扩展包含的文件
为了更好地理解预处理,您可以使用标志 -E 编译上述 'print.c' 程序,这会将预处理的输出打印到标准输出。
$ gcc -Wall -E print.c
更好的是,您可以使用标志“-save-temps”,如下所示。'-save-temps' 标志指示编译器将 gcc 编译器使用的临时中间文件存储在当前目录中。
$ gcc -Wall -save-temps print.c -o print
因此,当我们使用 -save-temps 标志编译程序 print.c 时,我们会在当前目录中获得以下中间文件(以及打印可执行文件)
$ ls
print.i
print.s
print.o
预处理后的输出存储在扩展名为 .i 的临时文件中(即本例中的“print.i”)
现在,让我们打开 print.i 文件并查看内容。
$ vi print.i
......
......
......
......
# 846 "/usr/include/stdio.h" 3 4
extern FILE *popen (__const char *__command, __const char *__modes) ;
extern int pclose (FILE *__stream);
extern char *ctermid (char *__s) __attribute__ ((__nothrow__));
# 886 "/usr/include/stdio.h" 3 4
extern void flockfile (FILE *__stream) __attribute__ ((__nothrow__));
extern int ftrylockfile (FILE *__stream) __attribute__ ((__nothrow__)) ;
extern void funlockfile (FILE *__stream) __attribute__ ((__nothrow__));
# 916 "/usr/include/stdio.h" 3 4
# 2 "print.c" 2
int main(void)
{
printf("Hello World");
return 0;
}
在上面的输出中,您可以看到源文件现在充满了大量的信息,但在它的最后我们仍然可以看到我们编写的代码行。让我们先分析一下这几行代码。
- 第一个观察结果是 printf() 的参数现在直接包含字符串“Hello World”而不是宏。事实上,宏定义和用法已经完全消失了。这证明了在预处理阶段扩展所有宏的第一个任务。
- 第二个观察是我们在原始代码中编写的注释不存在。这证明所有的评论都被剥离了。
- 第三个观察结果是,在“#include”行旁边,我们看到了很多代码。因此可以安全地得出结论,stdio.h 已被扩展并包含在我们的源文件中。因此我们了解编译器如何能够看到 printf() 函数的声明。
当我搜索 print.i 文件时,我发现函数 printf 被声明为:
extern int printf (__const char *__restrict __format, ...);
关键字“extern”表示函数 printf() 未在此处定义。它在此文件的外部。我们稍后会看到 gcc 如何得到 printf() 的定义。
您可以使用gdb 来调试您的 c 程序。现在我们对预处理阶段发生的事情有了一个很好的理解。让我们进入下一阶段。
2. 编译
在编译器完成预处理器阶段之后。下一步是将 print.i 作为输入,对其进行编译并生成中间编译输出。此阶段的输出文件是“print.s”。print.s 中的输出是汇编级指令。
在编辑器中打开 print.s 文件并查看内容。
$ vi print.s
.file "print.c"
.section .rodata
.LC0:
.string "Hello World"
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
movq %rsp, %rbp
.cfi_offset 6, -16
.cfi_def_cfa_register 6
movl $.LC0, %eax
movq %rax, %rdi
movl $0, %eax
call printf
movl $0, %eax
leave
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (Ubuntu 4.4.3-4ubuntu5) 4.4.3"
.section .note.GNU-stack,"",@progbits
虽然我不太喜欢汇编级编程,但快速浏览一下就可以得出结论,这个汇编级输出是某种形式的指令,汇编器可以理解并将其转换为机器级语言。
3. 组装
在此阶段,将 print.s 文件作为输入,并生成中间文件 print.o。该文件也称为目标文件。
该文件由汇编程序生成,该汇编程序理解并转换带有汇编指令的“.s”文件为包含机器级指令的“.o”目标文件。在这个阶段,只有现有的代码被转换成机器语言,像 printf() 这样的函数调用没有被解析。
由于此阶段的输出是机器级文件(print.o)。所以我们无法查看它的内容。如果您仍然尝试打开 print.o 并查看它,您会看到一些完全不可读的内容。
$ vi print.o
^?ELF^B^A^A^@^@^@^@^@^@^@^@^@^A^@>^@^A^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@0^
^@UH<89>å¸^@^@^@^@H<89>ǸHello World^@^@GCC: (Ubuntu 4.4.3-4ubuntu5) 4.4.3^@^
T^@^@^@^@^@^@^@^AzR^@^Ax^P^A^[^L^G^H<90>^A^@^@^\^@^@]^@^@^@^@A^N^PC<86>^B^M^F
^@^@^@^@^@^@^@^@.symtab^@.strtab^@.shstrtab^@.rela.text^@.data^@.bss^@.rodata
^@.comment^@.note.GNU-stack^@.rela.eh_frame^@^@^@^@^@^@^@^@^@^@^@^
...
...
…
通过查看 print.o 文件,我们唯一可以解释的是字符串 ELF。
ELF 代表可执行和可链接格式。
这是 gcc 生成的机器级目标文件和可执行文件的一种相对较新的格式。在此之前,使用了一种称为 a.out 的格式。据说 ELF 格式比 a.out 更复杂(我们可能会在以后的其他文章中深入探讨 ELF 格式)。
注意:如果您在编译代码时未指定输出文件的名称,则生成的输出文件的名称为“a.out”,但格式现在已更改为 ELF。只是默认的可执行文件名保持不变。
4. 链接
这是完成函数调用与其定义的所有链接的最后阶段。如前所述,直到这个阶段 gcc 还不知道 printf() 等函数的定义。在编译器确切地知道所有这些函数在哪里实现之前,它只是使用一个占位符来进行函数调用。正是在这个阶段,解析了 printf() 的定义,并插入了函数 printf() 的实际地址。
链接器在这个阶段开始行动并执行此任务。
链接器还做了一些额外的工作;它为我们的程序组合了一些额外的代码,这些代码在程序启动和程序结束时都是必需的。例如,有一些用于设置运行环境的标准代码,例如传递命令行参数、将环境变量传递给每个程序。类似地,将程序的返回值返回给系统所需的一些标准代码。
编译器的上述任务可以通过一个小实验来验证。从现在开始,我们已经知道链接器将 .o 文件 (print.o) 转换为可执行文件 (print)。
因此,如果我们比较 print.o 和 print 文件的文件大小,我们会看到差异。
$ size print.o
text data bss dec hex filename
97 0 0 97 61 print.o
$ size print
text data bss dec hex filename
1181 520 16 1717 6b5 print
通过 size 命令,我们大致了解了输出文件的大小如何从目标文件增加到可执行文件。这完全是因为链接器与我们的程序结合了额外的标准代码。
- 点赞
- 收藏
- 关注作者
评论(0)