C 程序到 Linux 可执行文件的 4 个阶段的旅程

举报
Tiamo_T 发表于 2022/05/17 06:46:12 2022/05/17
【摘要】 你编写一个 C 程序,使用 gcc 编译它,然后你得到一个可执行文件。 您有没有想过在编译过程中会发生什么以及 C 程序如何转换为可执行文件?

你编写一个 C 程序,使用 gcc 编译它,然后你得到一个可执行文件。

您有没有想过在编译过程中会发生什么以及 C 程序如何转换为可执行文件?

源代码要经过四个主要阶段才能最终成为可执行文件。

C 程序成为可执行文件的四个阶段如下:

  1. Pre-processing
  2. Compilation
  3. Assembly
  4. 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 – 启用所有警告的 g​​cc 标志。-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. 预处理

这是源代码通过的第一个阶段。在此阶段完成以下任务:

  1. 宏替换
  2. 删除注释
  3. 扩展包含的文件

为了更好地理解预处理,您可以使用标志 -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;
}

在上面的输出中,您可以看到源文件现在充满了大量的信息,但在它的最后我们仍然可以看到我们编写的代码行。让我们先分析一下这几行代码。

  1. 第一个观察结果是 printf() 的参数现在直接包含字符串“Hello World”而不是宏。事实上,宏定义和用法已经完全消失了。这证明了在预处理阶段扩展所有宏的第一个任务。
  2. 第二个观察是我们在原始代码中编写的注释不存在。这证明所有的评论都被剥离了。
  3. 第三个观察结果是,在“#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 命令,我们大致了解了输出文件的大小如何从目标文件增加到可执行文件。这完全是因为链接器与我们的程序结合了额外的标准代码。

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。