从c源程序到Linux可执行代码的过程

你写了一个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

经过预处理的输出时以扩展名.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^@^@^@^@^@^@^@^@^@^@^@^
...
...
…

我们唯一可以解释的是字符串 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语言程序在编程可执行程序之前都发生了什么。你知道了
预处理、编译、汇编和链接。链接阶段还有更多内容,我们会在以后的文章中谈到。

原文链接:这里

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值