C/C++程序从编译到链接的过程

时间:2023-03-08 18:40:31
C/C++程序从编译到链接的过程

  编译器是一个神奇的东西,它能够将我们所编写的高级语言源代码翻译成机器可识别的语言(二进制代码),并让程序按照我们的意图按步执行。那么,从编写源文件代码到可执行文件,到底分为几步呢?这个过程可以总结为以下5步:

  1、编写源代码

  2、编译

  3、链接

  4、装载

  5、执行

  今天主要说明的过程是编译和链接是怎么进行的。

  首先是编译,那么什么是编译?从广义上讲,编译就是将某种编程语言编写的代码转换成另一种编程语言描述的代码,严格一点儿来说,编译其实就是将高级语言编写的源代码翻译成低级语言(通常是汇编语言,甚至是机器代码)描述的代码的过程。这个过程由编译器完成,因此,我们可以把编译器看成这样的一种机器,它的输入是多个编译单元(编译代码是一个源代码文本文件),输出的是和多个编译单元一一对应的目标文件。

  为了简化说明,我们使用如下代码来演示这个过程。

  function.h

 //function.h
#ifndef FIRST_OPTION
#define FIRST_OPTION
#define MULTIPLIER (3.0)
#endif float add_and_multiply(float x,float y);

  function.c

 #include "function.h"
int ncompletionstatus=;
float add(float x,float y){
float z=x+y;
return z;
}
float add_and_multiply(float x,float y){
float z=add(x,y);
z*=MULTIPLIER;
return z;
}

  main.c

 #include "function.h"
extern int ncompletionstatus;
int main(){
float x=1.0;
float y=5.0;
float z;
z=add_and_multiply(x,y);
ncompletionstatus=;
return ;
}

  编译器要完成编译的功能,需要一系列的步骤。粗略的讲,编译的过程可分为预处理阶段、语言分析阶段、汇编阶段、优化阶段和代码生成阶段。

  预处理阶段:

    (1)、将#include关键字表示的含有定义的文件包含到源代码文件中

    (2)、处理#define,在代码中调用宏的位置将宏转化为代码

    (3)、根据#ifndef ,#ifdef,#elif和#endif指定的位置包含或者排除特定部分的代码

  对于上面的function.c文件,我们可以使用gcc命令--gcc -E function.c -o function.i对它只进行预处理而不进行相应的后续处理。生成的i文件如下所示。

#  "function.c"
# "<built-in>"
# "<command-line>"
# "function.c"
# "function.h" float add_and_multiply(float x,float y);
# "function.c"
int ncompletionstatus=;
float add(float x,float y){
float z=x+y;
return z;
}
float add_and_multiply(float x,float y){
float z=add(x,y);
z*=(3.0);
return z;
}

  可以看到,宏定义被替换成了(3.0)。

  语言分析阶段:

    (1)、词法分析阶段:将源代码分割成不可分割的单词

    (2)、语法分析阶段:将提取出来的单词连接成单词序列,并根据编程语言规则验证其顺序是否合理

    (3)、语义分析阶段:目的是发现符合语法规则的语句是否具有实际意义,比如讲两个整数相加并将结果赋值给一个对象的语句,虽然能通过语法规则的检查,但是可能无法通过语义的检查,例如这个对象的类没有重载赋值操作符

  汇编阶段:当源代码经过校验,其中不包含任何的语法错误时,编译器才会执行汇编阶段。在这个阶段中,编译器会将标准的语言集合转换成特定的CPU指令集的语言集合,不同的CPU会包含不同的指令集、寄存器和中断,所以不同的处理器要有不同的编译器对其支持。gcc编译器支持将输入的文件源代码转换成对应的ASCII编码的文本文件,其中包含了对应的汇编指令的代码行,汇编指令的格式包括AT&T和Intel两种,在Centos6.4上也是。

  我们对function.c文件运行gcc -S -masm=att function.c -o function.s命令,可以得到function.c文件的汇编文件,如下所示。

         .file   "function.c"
.globl ncompletionstatus
.bss
.align
.type ncompletionstatus, @object
.size ncompletionstatus,
ncompletionstatus:
.zero
.text
.globl add
.type add, @function
add:
pushl %ebp
movl %esp, %ebp
subl $, %esp
flds (%ebp)
fadds (%ebp)
fstps -(%ebp)
movl -(%ebp), %eax
movl %eax, -(%ebp)
flds -(%ebp)
leave
ret
.size add, .-add
.globl add_and_multiply
.type add_and_multiply, @function
add_and_multiply:
pushl %ebp
movl %esp, %ebp
subl $, %esp
movl (%ebp), %eax
movl %eax, (%esp)
movl (%ebp), %eax
movl %eax, (%esp)
call add
fstps -(%ebp)
flds -(%ebp)
flds .LC1
fmulp %st, %st()
fstps -(%ebp)
movl -(%ebp), %eax
movl %eax, -(%ebp)
flds -(%ebp)
leave
ret
.size add_and_multiply, .-add_and_multiply
.section .rodata
.align
.LC1:
.long
.ident "GCC: (GNU) 4.4.7 20120313 (Red Hat 4.4.7-3)"
.section .note.GNU-stack,"",@progbits

  代码优化阶段:由源代码文件生成的最初版本的汇编代码之后,优化就开始了,优化的只要功能是将程序的寄存器使用率最小化,此外,通过分析能够预测出来实际上不需要执行的部分代码并删除

  代码生成阶段:优化完成的汇编代码会在这个阶段转换成对应的机器指令的二进制值,并写入目标文件的特定位置,每一个源文件都对应一个目标文件,每一个目标文件都将包含所有相关的节信息(也就是.text/.code/.bss),同时也会包含部分的描述信息,我们可以使用gcc -c function.c -o function.o对function.c文件只进行编译处理,生成的文件是function.o文件。

  对于.o文件,不能用vi直接打开,打开也是一对乱码。我们可以使用objdump的工具来查看.o文件的反汇编代码(我的Centos6.4上有这个软件,所以你的电脑上如果没有,可以装一个),使用objdump -D function.o即可在终端上打印出.o文件的反汇编代码了,代码如下所示。

  

 [song@localhost Desktop]$ objdump -D function.o

 function.o:     file format elf32-i386

 Disassembly of section .text:

  <add>:
: push %ebp
: e5 mov %esp,%ebp
: ec sub $0x14,%esp
: d9 flds 0x8(%ebp)
: d8 0c fadds 0xc(%ebp)
c: d9 5d fc fstps -0x4(%ebp)
f: 8b fc mov -0x4(%ebp),%eax
: ec mov %eax,-0x14(%ebp)
: d9 ec flds -0x14(%ebp)
: c9 leave
: c3 ret 0000001a <add_and_multiply>:
1a: push %ebp
1b: e5 mov %esp,%ebp
1d: ec 1c sub $0x1c,%esp
: 8b 0c mov 0xc(%ebp),%eax
: mov %eax,0x4(%esp)
: 8b mov 0x8(%ebp),%eax
2a: mov %eax,(%esp)
2d: e8 fc ff ff ff call 2e <add_and_multiply+0x14>
: d9 5d fc fstps -0x4(%ebp)
: d9 fc flds -0x4(%ebp)
: d9 flds 0x0
3e: de c9 fmulp %st,%st()
: d9 5d fc fstps -0x4(%ebp)
: 8b fc mov -0x4(%ebp),%eax
: ec mov %eax,-0x14(%ebp)
: d9 ec flds -0x14(%ebp)
4c: c9 leave
4d: c3 ret Disassembly of section .bss: <ncompletionstatus>:
: add %al,(%eax)
... Disassembly of section .rodata: <.rodata>:
: add %al,(%eax)
: inc %eax
: inc %eax Disassembly of section .comment: <.comment>:
: add %al,0x43(%edi)
: inc %ebx
: 3a cmp (%eax),%ah
: 4e sub %al,0x4e(%edi)
: push %ebp
a: sub %esp,(%eax)
c: 2e xor $0x2e,%al
e: 2e xor $0x2e,%al
: aaa
: and %dh,(%edx)
: xor %dh,(%ecx)
: xor (%eax),%dh
: xor (%ecx),%esi
: xor (%eax),%esp
1b: sub %dl,0x65(%edx)
1e: and %cl,%fs:0x61(%eax)
: je <add_and_multiply+0x2a>
: 2e xor $0x2e,%al
: 2e xor $0x2e,%al
: aaa
: 2d .byte 0x2d
2a: xor (%ecx),%ebp
...

  可以看到,里面包含了.tex/.bss/.data节的内容。以上就是所有编译阶段所完成的任务,我们现在得到的是一个个的目标文件。

  当编译完成后,下一步就是将编译出来的各个目标文件链接成一个可执行的文件,这个过程就是链接。

  最终生成的二进制文件中包含了多个相同类型的节(.text/.data/.bss),而这些节是从每一个独立的目标文件中摘取下来的,也就是说,如果我们把一个个的目标文件看成一块简单的拼贴,进程的内存映射看做是一副巨幅镶嵌的画,链接的过程就是将拼贴组合在一起,放置在镶嵌画的恰当的位置。链接的过程由链接器执行,它的最终任务是将独立的节组合成最终的程序内存映射节,与此同时解析所有的引用。

  链接阶段主要包括重定位和解析引用两个阶段。

    重定位:链接过程的第一个阶段仅仅进行拼接,其过程是将分散在单独目标文件中不同类型的节拼接到程序的内存映射节中,在每一个目标文件中,代码的地址范围都是从0开始的,但是在程序的内存映射中,地址范围并不都是从0开始的,所以我们要将目标文件中的地址范围转换成最终程序内存映射中更具体的地址范围。

    解析引用:在重定位结束后,就开始了解析引用。所谓解析引用,就是在位于不同部分的代码之间建立关联,使得程序变成一个紧凑的整体。引发链接问题的根本原因是--代码片段在不同的编译单元内,它们之间尝试相互引用,但是将目标文件拼接成程序内存映射之前,又不知道要引用对象的地址。,比如我们引用了其他源文件中的函数,怎么知道该函数的入口点呢,这就是链接阶段解析引用所解决的问题。我们使用在本文开头所使用的示例代码来说明这个问题。

    1、在function.c文件中,add_and_multiply函数调用了函数add,这两个函数在同一个源文件中,在这种情况下,函数add的内存映射地址值是一个已知量,因此这个调用是没有问题的;

    2、在main函数中,调用了add_and_multiply函数,并且引用了外部变量ncompletestatus,这时就会出现问题,我们不知道该函数和该外部变量的内存映射地址,实际上,编译器会假设这些符号未来会在进程的内存映射中存在,但是,直到生成完整的内存映射之前,这两项引用会一直被当成为解析引用。

    为了完成解析引用的任务,链接器需要完成:  

      (1)、检查拼接到内存映射的节

      (2)、找出哪些部分代码产生了外部调用

      (3)、计算该引用在程序内存映射中的具体位置

      (4)、最后,将机器指令中的伪地址替换成程序内存映射的实际地址,从而完成解析引用。

    为了展示示例程序的链接过程,我们需要先编译main.c和function.c

    运行命令gcc -c function.c main.c和gcc function.o main.o -o demoapp生成可执行的文件demoapp

    利用objdump查看main.o中的反汇编代码

 Disassembly of section .text:

  <main>:
: push %ebp
: e5 mov %esp,%ebp
: e4 f0 and $0xfffffff0,%esp
: ec sub $0x20,%esp
: b8 3f mov $0x3f800000,%eax
e: mov %eax,0x14(%esp)
: b8 a0 mov $0x40a00000,%eax
: mov %eax,0x18(%esp)
1b: 8b mov 0x18(%esp),%eax
1f: mov %eax,0x4(%esp)
: 8b mov 0x14(%esp),%eax
: mov %eax,(%esp)
2a: e8 fc ff ff ff call 2b <main+0x2b> //注意这里
2f: d9 5c 1c fstps 0x1c(%esp)
: c7 movl $0x1,0x0 //注意这里
3a:
3d: b8 mov $0x0,%eax
: c9 leave
: c3 ret

  上述代码中,在第16行和18中,main函数分别调用了自己和访问了地址0的值,这都是不应该出现的情况(其实我不懂汇编......囧),然后我们再来查看demoapp的反汇编代码,看一下和main函数的节

 080483e4 <main>:
80483e4: push %ebp
80483e5: e5 mov %esp,%ebp
80483e7: e4 f0 and $0xfffffff0,%esp
80483ea: ec sub $0x20,%esp
80483ed: b8 3f mov $0x3f800000,%eax
80483f2: mov %eax,0x14(%esp)
80483f6: b8 a0 mov $0x40a00000,%eax
80483fb: mov %eax,0x18(%esp)
80483ff: 8b mov 0x18(%esp),%eax
: mov %eax,0x4(%esp)
: 8b mov 0x14(%esp),%eax
804840b: mov %eax,(%esp)
804840e: e8 9b ff ff ff call 80483ae <add_and_multiply> //注意这里
: d9 5c 1c fstps 0x1c(%esp)
: c7 movl $0x1,0x8049698 //注意这里
804841e:
: b8 mov $0x0,%eax
: c9 leave
: c3 ret
: nop
: nop
804842a: nop
804842b: nop
804842c: nop
804842d: nop
804842e: nop
804842f: nop

  在main.o中,main起始的位置是0,而在demoapp中main起始地址变为0x080483e4,这就是重定位现象,另外,与上述main.o对应,第14行调用了函数add_and_multiply而不是调用了main自己,所以链接器完成了函数引用解析的功能,同时,在main.o中的第18行的0x0被修改为0x8049698,我们可以通过objdump来查看0x8049698地址中到底放了什么数据。

  执行objdump -x -j .bss demoapp,可以看到

 SYMBOL TABLE:
l d .bss .bss
l O .bss completed.
l O .bss dtor_idx.
g O .bss ncompletionstatus //注意这里

  在.bss段,地址0x08049698中放置着外部变量ncompletionstatus,于是,我们可以看到,链接器成功的完成了重定位和解析引用的功能。(但是我有一个疑问,ncompletionstatus在function.c中已经被初始化为0,为什么不是在.data段存放,而是在.bss中存放?请路过的大神解释一下)

  以上,就是程序编译和链接的全部过程,经过链接后的文件是一个可被执行的文件,可执行的文件总是会包含.data, .bss, .text节和其他的一些特殊的节,这些节通过拼接单独的目标文件中的节得到。

  需要注意的一点是,main不是程序执行时首先执行的代码,启动程序是整个程序首先执行的代码,而且启动程序时在链接之后才添加到程序的内存映射当中的,也就是说,可执行的文件并不完全是通过编译项目源代码文件生成的。启动代码有两种不同的形式:

  crt0:它是纯粹的入口点,这是程序代码的第一部分,在内核的控制下执行;

  crt1:它是更现代化的启动例程,可以在main函数执行前和程序终止后完成一些任务。

  这部分启动代码是OS自动添加给应用程序的,这也是可执行文件和动态库的唯一区别,动态库没有启动程序代码。

参考书籍:《高级C/C++编译技术》