C语言编译过程——预处理、编译汇编和链接详解

简介: C语言编译过程——预处理、编译汇编和链接详解

引言

C语言经典的 “hello world ” 程序,伴随着每个程序员一起步入编程世界的大门。从编写、编译到运行,看到屏幕上输出的“hello world ”,那么你知道它都经历了什么吗?今天我们就来聊聊这个话题。

一、从hello.c聊起

hello world.c

#include <stdio.h>
int main(){
    printf("hello,world!\n");
    return 0;
}

在linux下,使用 gcc 编译hello.c源文件,会在当前目录下默认生成 a.out 可执行文件,在终端输出hello,world!。

[Panda@centos test]$ gcc hello.c
[Panda@centos test]$ ./a.out
[Panda@centos test]$ hello,world!

预编译器、汇编器as、链接器ld,实际上gcc 命令只是对这些不同程序的封装,根据不同的参数去调用不同的程序。

从 hello.c 到可执行文件的全过程,可分为4个步骤:

  1. 预处理
    gcc -E hello.c -o hello.i 得到预处理文件,其中,-E 表示只进行预编译。
    源文件在预编译阶段会被编译器生成.i文件,主要处理源代码文件中以“#”开头的预编译指令。如:宏定义展开,将被包含的文件插入到该编译指令的位置等。
  2. 编译
    gcc -S hello.i -o hello.s 得到汇编文件,其中,-S 表示生成汇编文件。
    编译就是把预处理完的文件,进行语法分析、词法分析、语义分析及优化后生成相应的汇编代码文件,这个过程是整个程序构建的核心过程,也是最复杂的部分。
  3. 汇编
    as hello.s -o hello.o 或者 gcc -c hello.s -o hello.o,其中,-c 表示只编译不链接。
    将汇编代码文件转变成机器可以执行的指令文件,即目标文件。也可以直接使用:gcc -c hello.c -o hello.o 经过预处理、编译、汇编直接输出目标文件。
    为什么汇编器不直接生成可执行程序,而是一个目标文件呢?为什么要链接?这个我们后面会详细讨论。
  4. 链接
    随着代码量的增多,所有代码若是都放在同一个文件里,那将是一场灾难。现代大型软件,动辄由成千上万的模块组成,每个模块相互依赖又相互独立。将这些模块组装起来的过程就是链接。
    这些模块如何形成一个单一的程序呢?无非就是两种方式:1、模块间的函数调用;2、模块间的变量访问。函数访问必须知道函数地址,变量访问必须知道变量地址,所以终归到底就是一种方式,不同模块间符号的引用

二、什么是静态链接

比如:我们在模块main.c中,调用了另一个模块func.c中的foo()函数。我们在main.c中每一处调用foo的时候,都需要确切的知道foo函数的地址,但是每个模块都是独立编译的,在编译main.c的时候并不知道foo函数的地址,这些foo的地址会先跳过,链接器会在链接的时候根据你所引用的符号foo,自动去func.c的模块查找foo的地址,然后将main.c中所有调foo函数的指令全部修正,这就是静态链接最基本的作用。

三、目标文件里有什么

源代码在经理预处理、编译、汇编后生成的未进行链接的中间文件,也叫目标文件(windows下是.obj文件,linux下是.o文件)。那么目标文件里到底存放的是什么呢?

3.1 目标文件的格式

PC平台流行的可执行文件格式主要有一下两种:

  • Windows下的PE
  • Linux下的ELF
    不光是可执行文件按照可执行文件的格式存储,**动态链接库(Windows下的.dll和linux下的.so)静态链接库(Windows下的.lib和linux下的.a)**文件也都是按照可执行文件的格式存储的。
3.2 目标文件长啥样

目标文件里除了保存着源代码编译后的机器指令、数据,还包括链接时所需要的信息,如:符号表等。目标文件将这些信息按照不同的属性,以“段”的方式存储。

下面让我们来看一个简单的程序被编译成目标文件后的结构:

simpleSection.c

#include <stdio.h>
int global_int_var = 84;
int global_unint_var;
void func(int i) {  printf(" %d\n", i); }
int main(void){
    static int static_var = 85;
    static int static_var2;
    int a = 1;
    int b;
    func(static_var + static_var2 + a + b);
    return 0;
}

从图中可以看到,ELF文件的开头,“File Header”描述了整个文件的属性,如:是可执行文件、静态链接、动态链接,如果是可执行文件,还会记录可执行文件的入口地址。文件头还包括一个段表,段表实际上是记录了该文件中所有段的偏移位置和属性。

一般C语言编译后的机器代码保存在代码段(.text段);已初始化的全局变量和局部静态变量保存在.data段;未初始化的全局变量和局部静态变量保存在.bss段,默认为0,因为是0所以为其在.data段分配空间并存放0是没有意义的,在文件中.bss段不占空间。

总体来说,程序源代码被编译后主要分为两段:程序指令和程序数据,也就是代码段和数据段。指令和数据分开存储好处多多:

  1. 程序被装载后,数据和指令被映射到不同的虚拟内存其区域。代码段通常是只读的,数据段对于进程来说是可读写的,所以,这两块区域就可以设置不同的权限。
  2. 对于CPU来说,他们有着极为强大的缓存体系,所以,程序应尽量提高缓存的命中率。指令和数据分离可以提高缓存的命中率;
  3. 当系统中运行这多个该程序时,内存中只需要保存一份该程序的指令部分即可,大大节约了内存的使用。
3.3 objdump工具

objdump是一款可以查看目标文件的工具。“-h”参数就是把ELF文件中各个段的信息打印出来。

Size 列式对应段的大小,如:.text 段大小为0x55,即85字节。

size 命令可以查看ELF文件中,代码段、数据段和.bss段的总长度。(dec十进制,hex十六进制)

objdump -s -d hello.o 其中,-s 表示使用十六进制打印信息,-d 可以将所有包含指令的段进行反汇编。

3.4 ELF文件结构

可以使用readelf详细查看ELF文件。readelf -h simpleSection.o

魔数:魔数用来确认文件的类型,操作系统加载可执行文件的时候会确认魔数是否正确,正确才加载。所有的ELF文件魔数的最开始的四个字节都相同,都是0x7f 0x45 0x4c 0x46,其中:0x7f 对应ASCII码的DEL控制符,另外三个分别对应 ELF 的ASCII码。

段表:我们知道ELF文件中有各种段,段表就是保存这些段的属性。比如:段名、段的长度在文件中的偏移、读写权限等等。编译器、链接器、加载器都是依赖段表来定位和访问某个段的。上图中,段表起始位置在1064(0x428)偏移的位置。

前面说得 objdump -h 只能查看部分的段信息,详细的段信息使用 readelf -S查看,如下:可以看到有13个段信息,以数组的方式存储,第一个段的Type为NULL,是无效的段描述符,所以,该ELF文件有12个有效的段。每个段描述符占40个字节,总共13*40=520字节。

注意到simpleSection.o 中有个 “rela.text” 段,它就是重定位表。也就是链接器在链接目标文件时,对代码段和数据段中那些对绝对地址引用的位置进行重定位。这些重定位的信息就是保存在重定位表中,每个需要重定位的代码段或者数据段,都会有一个对应的重定位表。比如:simpleSection.o 中的 “rela.text” 就是针对 “.text”段的重定位表,这是因为“.text”段中的“printf” 函数的调用;而“.data” 段中因为没有对绝对地址的引用,所以它没有对应的“rela.data”的重定位表。

字符串表:ELF文件中很多地方用到了字符串,如段名、变量名、函数名等。ELF使用字符串表的方式存储字符串,使用的时候只需要传偏移量即可。比如你想:用world,偏移量从6开始,遇到‘\0’结束,取出来的就是world。

字符串表在ELF文件中也以段的方式保存,通常保存在“.strtab” 和 “.shstrtab” 段中。前者表示普通字符串,后者表示段表中所用的字符串,如段名,有点专用感觉。

符号表:在ELF文件中也以段的方式保存,通常保存在“.symtab”中。

上图总共有16 个符号,第一个符号永远是未定义的,不用管。

func和main函数都是定义在simpleSection.c里的,并且保存在“.text”段,所以它们所在Ndx位置为1,因为“.text”段的下标1。

符号 printf 在simpleSection.c 里被引用,但是没有定义,所以它的Ndx是UDN。

符号global_int_var 已经是初始化后的全局变量,保存在.data段,对应下标为3。

四、链接的接口——符号

链接本质上处理的是目标文件之间函数和变量地址的引用。比如目标文件B用到了目标文件A中的“foo”函数,那么就称目标文件B引用了A中的函数“foo”,目标文件A定义了函数“foo”。

在链接过程中,我们将函数和变量统称为符号,函数名和变量名就是符号名。每个目标文件都会有一个符号表,表里记录了所有的符号及其对应的符号值,对应到函数和变量就是函数地址和变量地址。我们列出在链接过程中需要关注的符号:

  • 定义在本目标文件的全局符号,可以被其他目标文件引用。如simpleSection.o中的func函数和全局变量“global_int_var”。
  • 在本目标文件中引用的全局符号,但是定义不在本目标文件的,这种符号叫“外部符号”,如simpleSection.o中的printf函数。

文章参考于<零声教育>的C/C++linux服务期高级架构,及书籍(程序员的自我修养)。

相关文章
|
1月前
|
存储 自然语言处理 编译器
【C语言】编译与链接:深入理解程序构建过程
【C语言】编译与链接:深入理解程序构建过程
|
1月前
|
存储 自然语言处理 编译器
|
1月前
|
编译器 Linux C语言
【C语言篇】编译和链接以及预处理介绍(下篇)
【C语言篇】编译和链接以及预处理介绍(下篇)
32 1
【C语言篇】编译和链接以及预处理介绍(下篇)
|
1月前
|
自然语言处理 编译器 Linux
C语言中抽象的编译和链接原理
C语言中抽象的编译和链接原理
20 1
|
1月前
|
Linux C语言 iOS开发
MacOS环境-手写操作系统-06-在mac下通过交叉编译:C语言结合汇编
MacOS环境-手写操作系统-06-在mac下通过交叉编译:C语言结合汇编
19 0
|
1月前
|
存储 C语言
【C语言篇】编译和链接以及预处理介绍(上篇)2
【C语言篇】编译和链接以及预处理介绍(上篇)
36 0
|
1月前
|
C语言 C++
C语言 之 内存函数
C语言 之 内存函数
33 3
|
C语言 网络协议
C语言及程序设计进阶例程-8 预处理之宏定义
贺老师教学链接  C语言及程序设计进阶 本课讲解 宏定义 #include &lt;stdio.h&gt; #define PI 3.1415926 int main ( ) { float r,l,s,sq,vq; printf("please enter r:"); scanf("%f", &amp;r); l=2 * PI *r; s=r * r * PI;
1007 0
|
C语言
C语言预处理之二-----宏定义那点事儿
1、关于宏的副作用,请看下面代码:   #include stdio.h> #define GOODDEF (input+3) #define POORDEF input+3   //这里是宏的副作用最经典的例子,不穿裤子!!!如果你这样用,下面你就知错!! ...
993 0
|
6天前
|
C语言
c语言调用的函数的声明
被调用的函数的声明: 一个函数调用另一个函数需具备的条件: 首先被调用的函数必须是已经存在的函数,即头文件中存在或已经定义过; 如果使用库函数,一般应该在本文件开头用#include命令将调用有关库函数时在所需要用到的信息“包含”到本文件中。.h文件是头文件所用的后缀。 如果使用用户自己定义的函数,而且该函数与使用它的函数在同一个文件中,一般还应该在主调函数中对被调用的函数做声明。 如果被调用的函数定义出现在主调函数之前可以不必声明。 如果已在所有函数定义之前,在函数的外部已做了函数声明,则在各个主调函数中不必多所调用的函数在做声明
21 6