跳转至

编译,静态链接,可执行文件的生成

本篇讲述可执行文件的生成,内容上以 C 语言为讲解对象,概括了汇编、链接、静态库等概念。

这是令人困扰的开始。我相信每个人都曾经思考过这些问题:

  • #include是如何发挥作用的
  • C 语言的编译器是怎么写出来的
  • 头文件和库文件说的是一种东西吗
  • ……

这些问题的解答并不能用三言两语描述清楚,而是需要对整个编译过程的理解。

1.编译的本质

计算机软件能实现的功能很多,但归根结底,CPU 的指令大致只有三种:

  • 把某个数据放到某个位置
  • 对某些数据进行某种运算
  • 根据条件取出下一条指令

这其实对应着高级语言中的赋值、运算、流程控制。当然,CPU 的指令相当复杂,但从本质上来看,基本都可以归为这三种。

人们把每个指令用一个二进制串编码,CPU 的门电路被设计为输入对应的二进制信号(指令),就产生相应的信号作为结果,这就形成了所谓指令集(Instruction Set Architecture,即 ISA)。以某 x86 指令集的 CPU 为例,用指令集编程,会得到这样的程序:

01010101100010011110010110
00101101000101000011000000
00110100010100001000100010
01111011000101110111000011

机器码并不等同于可执行文件。为了能够让操作系统装载并运行程序,可执行文件中还包括很多除代码之外的信息和数据。

虽然可以实现功能,但用 01 写代码对人类来说有着诸多不足,人们希望用一些有意义的字符串来表示指令和参数,也就是汇编语言(Assembly language),人们通过汇编器(Assembler,即 as)将汇编代码转化为机器码。于是上面的程序就变成了:

sum:
    pushl %ebp
    movl %esp,%ebp
    movl 12(%ebp),%eax
    addl 8(%ebp),%eax
    movl %ebp,%esp
    popl %ebp
    ret

即使没学过汇编,我们也能窥探出movl %esp,%ebp似乎是把某个地方的数据移动到另一个地方;而addl 8(%ebp),%eax似乎是把一个地方的数据加上另一个地方的数据等。

然而,汇编的编程思路并没有脱离指令集,编写功能复杂的软件是十分困难的。也许在 C 中操作一个具有多个成员变量的类只需要一行代码,而汇编中则需要对上百个地址的数据进行变动;另一方面,不同 CPU 的指令集不同,汇编对平台的依赖性也很强。某些汇编中用到的寄存器地址,在另一个 CPU 中也许根本不存在。

因此,人们需要高级语言(比如 C)。C 对汇编进行了高一层级的抽象,人们利用编译器将 C 语言编译为汇编,再由汇编器生成机器码。最终,上面的程序可以表示为:

int sum(int x, int y){
    int t = x + y;
    return t;
}

事实上,人们平时所说的编译器更应被称作“编译器驱动程序”。完整的编译系统提供了语言预处理器、编译器、汇编器和链接器,用户调用编译器驱动程序,即可实现整套系统的运作。例如,在命令行中,通过gcc -S hello.c即可生成 C 代码对应的汇编代码;通过gcc hello.c -o hello可生产可执行文件hello

2.静态库与链接

在编程中,许多功能是频繁被人们所使用的,比如 hello.c 中的 printf 函数。在大型软件的开发中,有些功能会被多个组件需要,比如一个网站的多种业务都需要向数据库查询、修改数据。为了提高兼容性,减少重复开发的工作量,我们希望将一些常用、基础的功能封装起来。

假如,我实现了这样一个函数:

// sum.c
int sum(int x, int y){
    return x + y;
}

我需要考虑如何将 sum 函数复用到下面的代码中:

// main.c
#include <stdio.h>

int sum(int x, int y);

int main(){
    int x=1, y=1;
    printf("%d\n", sum(x, y));
}

一个简单的方案是,将sum.c的实现复制到main.c中。在本例中,这当然是最简单高效的方案。然而,当需要的功能越来越多,越来越复杂时,这会导致代码极其臃肿,难以调试。并且,不断地粘贴冗长的代码是一件烦人的工作。人们最终采用的方式是,将sum.cmain.c别编译为二进制文件(称为可重定位目标文件,即sum.omain.o),然后用一个程序(链接器)将两份目标文件合并成为一个可执行文件(当然,合并时还需要 printf 函数的模块),这个过程就叫做链接。

# 生成 sum.o 可重定位目标模块
$ gcc -c sum.c
# 将 main.c 编译为 main.o,并将 main.o 和 sum.o 与标准库链接产生可执行文件 main
$ gcc main.c sum.o -o main

# 上面两步可以简化为
$ gcc main.c sum.c -o main

现在,让我们考虑 C 如何向程序员提供标准函数(例如上面的 printf)。

一种方案是让编译器识别标准函数的调用,然后自动生成代码。Pascal 就是采用了这种方式,然而这对标准函数众多的 C 而言并不合理:这将使编译器的开发难度显著增大,且对标准函数的修改将导致编译器版本的频繁变化。

另一种方法是把所有的标准 C 函数放到同一个可重定位目标模块中,程序员在编译自己的代码时链接该模块。然而,对标准函数的修改将导致整个模块的重新编译;同时,这个巨大的模块将导致每个可执行文件中都包含一个所有标准函数的实现副本,这是对空间的巨大浪费。当然,我们可以将不同函数编译为不同的独立模块(如 printf.o,scanf.o...),并由程序员在编译时手动加入需要的模块,然而这将导致编译指令冗长且易错。

因此,静态库的概念被提出,以解决上述方法的缺点。人们将不同函数编译为独立的目标模块(.o),然后封装成为一个单独的静态库文件(.a)。在编译时,人们指定需要链接到的静态库,链接器将只复制被程序引用的目标模块,以减少可执行文件的大小;同时,程序员输入的编译指令只需要包含较少的库文件(C 编译器驱动程序会默认将 libc.a 传送给链接器)。

在 Linux 系统中,静态库以一种称为存档(archive) 的特殊文件格式存放在磁盘中。存档文件是一组连接起来的可重定位目标文件的集合,有一个头部用来描述每个成员目标文件的大小和位置。存档文件名由后缀.a 标识。我们可以利用 ar 工具从目标模块制作静态库,或更新替换其中的内容。

我们也许注意到了这一点,在sum.c中定义的 sum 函数,在main.c中也被声明了一次,这个细节将引出头文件的概念。

在每个可重定位目标模块中,都有一个符号表,它包含本模块定义和引用的符号的信息。所谓的符号,对应着我们在程序中声明的全局变量、函数(不含局部变量);链接器需要符号表来整合不同模块,生成可执行文件。如果我们不在main.c中声明 sum 函数,编译器会给出警告而不是报错:implicit declaration of function,即函数隐式声明;然而,如果我们在sum.c中定义了全局变量int a;,那么在main.c中对 a 的操作就必须要先对其声明:extern int a;,以告知编译器 a 是一个外部变量(函数的声明不需要加extern,如本模块未定义某函数,编译器会默认到外部寻找其定义)。

当程序员需要的标准函数越来越多时,对大量全局变量和函数的声明又成了新的负担。于是,人们通过头文件的形式,一次性将所有变量声明在同一个.h 文件中,在编写代码时加入一句#include <xxx.h>,就完成了所有的声明工作,编译器将负责把 xxx.h 中的内容插入到我们的代码中(生成一份中间文件,以.i 为后缀)。当然,头文件中并不只有声明,也包含宏等内容,我们暂且按下不表。

3.编译过程总览

总的来看,对下面这份代码:

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

编译系统进行了这些工作,以生成一份可执行程序:

  1. stdio.h中的内容替换#include <stdio.h>生成中间文件hello.i
  2. 编译器将hello.i编译为当前平台的汇编代码hello.s
  3. 汇编器将hello.s编译为机器码,并整合其他信息生成可重定位目标文件hello.o
  4. 链接器将printf.o,或是libc.a中的 printf 函数和hello.o链接生成和执行文件hello

这四步的指令分别为:

gcc -E hello.c -o hello.i
gcc -S hello.i -o hello.s
gcc -c hello.s -o hello.o
gcc hello.o -o hello

4.其他

今天,我们常用的 C 语言编译器有 gcc,clang,llvm 等。以 gcc 为例,我们也许会相当好奇,一个 C 语言的编译器究竟是如何用 C 语言开发出来的。事实上,这个先有鸡还是先有蛋的问题在细节上相当复杂,但整个开发的思路或许不难理解:先通过汇编实现简单的编译器,再通过这个编译器实现一个更复杂的编译器,以此迭代开发下去。

需要注意的是,在 linux 系统中,文件的后缀名并没有任何在 windows 里那样的作用。linux 奉行 Unix 中“一切皆文件”的哲学,后缀名仅仅是为了帮助人类理解该文件的形式。

限于篇幅,这里没有讲到动态链接,也没有详细讲述可执行文件。同时,这些需要对进程、内存有初步的认识才能更好的理解。在下一篇文章中,我们将介绍进程等内容。