hello程序是如何变成可执行文件的

前言

hello程序几乎是我们每个人学习C语言写的第一个程序,但是它是如何从.c文本变成可以打印出”hello world“的可执行文件的呢?本文将简单介绍其过程。

Hello World

hello world程序我们再熟悉不过:

1
2
3
4
5
6
7
8
/*include head file*/
#include<stdio.h>
/*the main function*/
int main(int argc,char *argv[])
{
printf("Hello World!\n");
return 0 ;
}

编译并运行:

1
2
3
gcc -o helloWorld helloWorld.c
./helloWorld
Hello World!

整个过程一气呵成,但是实际上上面的过程并非像看起来那么简单。它可以大体分为4个步骤:预处理,编译,汇编,链接。接下来我们一一简单介绍这四个步骤做了什么。

预处理

预处理主要是处理源代码中以#开头的指令(#pragma 除外),例如本文hello world程序中的#include,预处理之后会将stdio.h的内容插入到预处理指令的位置。
想要只生成预处理之后的内容,可以使用下面的方式:

1
gcc -E -o helloWorld.i helloWorld.c #-E参数表示只进行预处理

生成的helloWorld.i即为预处理之后的内容,有兴趣的可以打开文件查看里面的内容,会发现stdio.h的位置被其实际内容所替代。预处理之后,注释内容也会被删除,宏定义会被展开。

编译

预处理之后就需要对生成的预处理文件进行词法分析,语法分析,语义分析,最终产生汇编代码文件,说白点可以简单理解为将C代码“翻译”成汇编代码。该过程是核心同时也是较复杂的一个过程。我们可以通过命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
gcc -S -o helloWorld.s helloWorld.c #-S参数表示只到生成汇编为止
cat helloWorld.s
.file "helloWorld.c"
.section .rodata
.LC0:
.string "Hello World!"
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq $16, %rsp
movl %edi, -4(%rbp)
movq %rsi, -16(%rbp)
movl $.LC0, %edi
call puts
movl $0, %eax
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.10) 5.4.0 20160609"
.section .note.GNU-stack,"",@progbits

上面的内容即为编译之后得到的汇编代码。

汇编

汇编是将汇编代码翻译成机器可执行的指令,生成目标文件。整个过程较为简单,几乎只是按照汇编指令和机器指令进行一一翻译。我们可以用下面的命令获得汇编后的内容:

1
2
3
4
5
6
7
8
gcc -o helloWorld.o -c helloWorld.c
od helloWorld.o #查看二进制内容
0000000 042577 043114 000402 000001 000000 000000 000000 000000
0000020 000001 000076 000001 000000 000000 000000 000000 000000
0000040 000000 000000 000000 000000 001260 000000 000000 000000
0000060 000000 000000 000100 000000 000000 000100 000015 000012
0000100 044125 162611 101510 010354 076611 044374 072611 137760
(其他内容未显示)

链接

链接是以某种方式将各个目标文件整个在一起,生成最后的可执行文件。我们的hello程序中调用了printf函数,但是并不存在于helloWorld.o中,而是存在于libc.so或libc.a中。

1
gcc -o helloWorld helloWorld.c

执行上面的命令之后,就得到了我们的helloWorld程序了。
我们通过ldd命令看到helloWorld程序链接了系统的库:

1
2
3
4
ldd helloWorld
linux-vdso.so.1 => (0x00007ffe9ef11000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f0d9f038000)
/lib64/ld-linux-x86-64.so.2 (0x00007f0d9f402000)

有兴趣的也可以尝试一下,如果删除系统中的libc.so库(记得事先备份),发现能够编译过,却在最后链接失败。

总结

  • 我们大致总结整个编译过程如下:
    1
    2
    3
    4
    5
    6
    7
    st=>start: .c源程序
    e=>end
    op1=>operation: 预处理将.c处理为.i(处理后的源程序)
    op2=>operation: 编译器将.i编译为.s(汇编程序)
    op3=>operation: 汇编器将.s汇编成.o(可重定位目标文件)
    op4=>operation: 链接器将可重定位文件链接成可执行文件
    st->op1->op2->op3->op4->

而正是由于整个编译过程分阶段进行,我们可以看到不同类型的问题在不同阶段出现并且有先后顺序。正因如此,链接问题在编译的最后阶段才会出现。

  • gcc编译系统本身调用了很多其他相关工具,可以加上—verbose观察其详细编译过程,发现gcc命令调用了预处理器,编译器,汇编器,链接器等命令。

本文只是粗略介绍其整个过程,更多地了解编译过程能够帮助我们优化代码、处理令人困扰的链接问题或避免安全漏洞,本文不展开介绍具体的编译过程。

守望 wechat
关注公众号[编程珠玑]获取更多原创技术文章
出入相友,守望相助!