你写了一个C程序,然后用gcc编译之后得到一个可执行程序。看起来相当简单,是吗?
你有没有想过编译的过程中发生了什么,C程序怎么转变成二进制程序的呢?
其实,源程序最终成为可执行程序经历了如下4个阶段:
1、预处理
2、编译
3、汇编
4、连接
在这篇文章的第一部分,我们讨论一下:c程序源代码被编译成可执行程序过程,gcc编译器经过的步骤。
在深入讨论前,通过一个hello world程序,我们简单的了解一下怎样用gcc编译和运行C程序
$ vi print.c
#include <stdio.h>
#define STRING "Hello World"
int main(void)
{
/* 使用宏来打印出 'Hello World'*/
printf(STRING);
return 0;
}
现在,我们用gcc编译器 运行源程序,产生一个可执行程序
$ gcc -Wall print.c -o print
在上面的命令中:
*gcc -调用GNU C编译器
*-Wall -gcc的参数,显示出所有警告,-W代表warning,all代表所有
*print.c -输入的源程序
*-o print -指示C编译器产生可执行程序:print,如果不指定 -o,编译器会默认产生一个可执行程序a.out
最后,运行print,它会运行c程序并打印出hello world
$ ./print
Hello World
注意:当你在做一个包含很多C程序的大型项目时,可以用make来实现对编译的管理
现在,我们对如何利用gcc将源程序转换成二进制程序有了基本的了解,我们会重新回顾一下源程序转换成二进制可执行程序的4个阶段。
1、预处理
这个阶段是源代码必须经历的第一个阶段,主要做的工作是:
1.替换宏
2.去除注释
3.扩展头文件
为了更好的理解预处理,你可以在编译‘print.c’的时候加上 -E参数,这样可以产生一个预处理之后的标准输出
$ gcc -Wall -E print.c
更好的方法是,你可以用标志‘-save-temps’,‘save-temps’标志指示gcc编译器保存在当前目录使用的临时中间文件
$ gcc -Wall -save-temps print.c -o print
因此,当你使用‘save-temps’标志编译‘print.c’后,我们可以在当前目录得到下面的中间文件(还有print可执行文件)
$ ls
print.i
print.s
print.o
现在我们打开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"而不是宏。事实上,宏定义和使用完全不见了。这就证明了预处理的第一步就是替换所有的宏。extern int printf (__const char *__restrict __format, ...);
关键字‘extern’告诉我们printf()函数没有在这里定义,而是在本文件之外。我们之后会看到gcc是如何获得printf()函数的定义。
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^@^@^@^@^@^@^@^@^@^@^@^
...
...
…
我们唯一可以解释的是字符串 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命令我们大概认识到从目标文件到可执行文件文件大小怎么如何增加的这都是链接器将外部标准代码与我们的程序连接起来的缘故。
现在,有知道了一个C语言程序在编程可执行程序之前都发生了什么。你知道了
预处理、编译、汇编和链接。链接阶段还有更多内容,我们会在以后的文章中谈到。