在本章中,我们将学习程序如何与硬件交互,以及它们如何与特定系统软件中的软件交互。我们将通过研究一个叫做链接的过程开始,这能帮助我们弄清楚系统是如何构建一个程序的。

链接就是将不同部分的代码和数据收集和组合成为一个单一文件的过程,这个文件可被加载到存储器并执行。链接可以在编译时、加载时甚至运行时执行。现代系统中,链接是有链接器自动执行的。

链接的基础知识

我们先来看一个例子,由两个代码文件构成,在main函数中调用了函数sum计算数组的和:

我们使用下面的命令来编译执行:

1
2
linux> gcc -Og -o prog main.c sum.c
linux> ./prog

GCC 会分别预处理、编译、汇编两个文件,得到两个可重定位.o文件,再通过链接器将它们链接到一起:

链接

为什么要链接?

  1. 模块化:可以将相关功能放入单独的源文件中,可以定义函数库,所以这是一种很好的技术,可以让代码分解成很好的模块化的部分。
  2. 效率:代码分解成多个模块后,如果需要修改其中的某一段代码,只需要修改其所在的模块,重新编译这个模块,再和其他部分链接在一起,而不需要重新编译整个代码,这在时间和空间上都是很高效的。

链接器做了什么?

  1. 符号解析:程序会定义和引用符号,实际上就是全局变量和函数。汇编器将符号定义存储在目标文件.o中,在这个符号表中,全是一系列结构体数组,每个结构体包含有关该符号的信息。链接器在链接过程中将每个符号引用与它的定义相关联。一旦链接器和一个独一无二的目标建立联系,每个引用都会有一个唯一的符号定义。
  2. 重定位:将原先分开的代码和数据片段合并成一个文件,并将.o文件中的相对地址转换成可执行文件中的绝对地址,更新对应的符号。

在更详细的描述这两步工作之前,需要先理解一些概念。

目标文件的三种形式

  1. 可重定位目标文件 Relocatable object file (.o file)

    • 这是汇编器的输出,包含二进制代码和数据,可以与其他可重定位文件合并起来,创建一个可执行目标文件。
  2. 可执行目标文件 Executable object file (a.out file)

    • 包含二进制代码和数据,可以被加载到存储器中执行。
  3. 共享目标文件 Shared object file (.so file)

    • 这是一种用于创建共享库的现代技术,可以在加载或运行时被动态的加载到存储器并链接。

ELF 文件格式

现代 Unix 系统中,比如 Linux 等,上述三种目标文件均采用统一的格式,即 Executable and Linkable Format(ELF)。

链接器的三种符号

  1. 全局符号 Global symbols

    • 在当前模块中定义,且可以被其他代码引用的符号,例如非静态 C 函数和非静态全局变量。
  2. 外部符号 External symbols

    • 同样是全局符号,但是是在其他模块(也就是其他的源代码)中定义的,但是可以在当前模块中引用。
  3. 本地符号 Local symbols

    • 在当前模块中定义,只能被当前模块引用的符号,例如静态函数和静态全局变量。

    • 注意,本地链接器符号并不是本地程序变量,本地程序变量符号在运行时在栈中被管理。

链接过程

接下来,我们详细了解一下链接的过程。

符号解析

再来看一下上面的简单程序,我们从链接器的视角来看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 文件 main.c
int sum(int *a, int n);

int array[2] = {1, 2}; // 数组 array 在此定义

int main() // 定义了一个全局函数
{
int val = sum(array, 2); // 全局引用了 sum 函数和 array
// val 是局部变量,链接器并不知道
return val;
}

// -----------------------------------------
// 文件 sum.c
int sum(int *a, int n) // 定义了一个全局函数 sum
{
int i, s = 0;
// i 和 s 是局部变量,链接器并不知道
for (i = 0; i < n; i++)
s += a[i];

return s;
}

现在我们来理解局部静态变量和局部非静态变量的区别:

  • 局部非静态变量:保存在栈中
  • 局部静态变量:保存在.data.bss
1
2
3
4
5
6
7
8
9
10
11
int f()
{
static int x = 0;
return x;
}

int g()
{
static int x = 1;
return x;
}

上述示例中,两个静态局部变量都只能在定义它们的函数中被引用。因为它们使用静态属性声明的,所以不存储在栈中,而是像全局变量一样存储在.data中。那么编译器会做什么呢?它将为每个 x 分配空间,在符号表中可能会是x.1x.2

链接器如何解析多处定义的全局符号

如果两个文件中定义了同名的全局变量,这时会发生什么呢?在弄清楚之前,要先知道不同的符号是有强弱之分的:

  • 强符号:函数和初始化的全局变量;
  • 弱符号:未初始化的全局变量。

我们可以来看看下面的例子

1
2
3
4
5
6
7
8
// 文件 p1.c
int foo = 5; // 强符号,已初始化
p1() { ... } // 强符号,函数

// -----------------------------------------
// 文件 p2.c
int foo; // 弱符号,未初始化
p2() { ... } // 强符号,函数

链接器在处理强弱符号的时候遵守以下规则:

  1. 不能出现多个同名的强符号,不然就会出现链接错误
  2. 如果有同名的强符号和弱符号,选择强符号,也就意味着弱符号是的
  3. 如果有多个弱符号,随便选择一个

我们可以看看下面几个例子:

1
2
3
4
5
6
7
// 文件 p1.c
int x;
p1() { ... }

// -----------------------------------------
// 文件 p2.c
p1() { ... }

可以看到上面代码中声明了两个同名的函数,都是强符号,所以会出现链接错误。

1
2
3
4
5
6
7
8
// 文件 p1.c
int x;
p1() { ... }

// -----------------------------------------
// 文件 p2.c
int x;
p2() { ... }

上面的两个 x 实际上在执行时会引用同一个未初始化的整型,并不是两个独立的变量。

1
2
3
4
5
6
7
8
9
// 文件 p1.c
int x;
int y;
p1() { ... }

// -----------------------------------------
// 文件 p2.c
double x;
p2() { ... }

上面这个例子很有趣,这里 p1 和 p2 中定义的变量都是弱符号,我们对 p2 中的 x 进行写入时,居然可能会影响到 p1 中的 y!想想为什么?其实原因很简单,因为 x 实际上引用的是同一个地址,而 double 的字节数是 int 的两倍,所以 y 就被影响了。

1
2
3
4
5
6
7
8
9
// 文件 p1.c
int x = 7;
int y = 4;
p1() { ... }

// -----------------------------------------
// 文件 p2.c
double x;
p2() { ... }

这个例子是强弱符号间的引用了,p1 中的变量因为初始化的缘故,是强符号,所以在 p2 中引用 x 时,实际上操作的是 p1 中定义的全局变量的值,而因为 p2 中 x 是 double 类型,所以一旦进行改动,实际上就 p1 中 x 和 y 都会受到影响。

从这些例子中,我们已经能够看出链接中可能会出现的问题,更可怕的是两个同名的弱结构体引用,不同的编译器可能有不同的对齐方式,真正编译运行的时候,就会出现非常奇怪的行为,这种 bug 一旦出现,几乎是很难在短时间内发现并解决的。

因此我们可以得到一条很重要的编程建议:

如果可能,尽量避免使用全局变量

如果一定要用的话,注意下面几点:

  • 使用静态变量
  • 定义全局变量的时候初始化
  • 注意使用 extern 关键字