Linking

编译

说来惭愧,大一写代码的时候以为代码必须要在ide才能运行。后来在使用VScode的过程中逐渐明白了代码从一段文本到一个可执行程序背后发生的事情。

以c程序为例,最终成为可被机器执行的代码,包含以下步骤:

  • 预处理:处理c程序中#define,#include部分,类似作文本替换

  • 编译器: 将经过预处理的c代码转换成汇编代码(gcc -s)生成.s汇编文件

  • 汇编器:将汇编代码转成可重定位目标文件(.o) 对应命令(gcc -c)

  • 链接器: 将各个可重定位目标文件链接成为计算机可执行文件

让我们来看一个具体的例子,假设我们有两段代码main.c和sum.c,其中main调用了sum

1
2
3
4
5
6
7
8
9
int sum(int *a, int n);

int array[2] = {1, 2};

int main()
{
    int val = sum(array, 2);
    return val;
}
1
2
3
4
5
6
7
8
int sum(int *a, int n)
{
    int i, s = 0;
    for (i = 0; i < n; i++)
        s += a[i];
    
    return s;
}
gcc -c main.c sum.c

-c参数 表示只编译不进行链接,因此我们会得到main.o 和sum.o两个目标文件(但是未经过链接还不能被机器直接运行)

gcc -o prog main.o sum.o

-o 表示为生成的文件命名,通过链接器我们会得到一个prog的可执行文件

当然,在实际编译中,我们往往直接使用

gcc -o prog main.c sum.c

会直接得到一个prog的可执行文件,编译器帮我们做了许多背后的工作。

接下来本文重点介绍其中的链接部分。

链接器

链接器在构造可执行文件中主要完成两个任务:

  • 符号解析:

    为c语言中的每个函数、变量创建一个符号,方便在链接的过程中引用这些函数和变量

  • 重定位:

    我们知道,.o文件实际上就是一些字节序列,链接器需要将各个经过符号解析的符号与真实的内存位置对应起来,进行重定位,从而组织成一个新的可执行文件。

链接器的目标对象就是我们上文中提到的.o文件(可重定位目标文件),除此之外还有.out(可执行目标文件)和.so(共享目标文件,下文动态链接中会提到)。

在Linux中,这三种对象文件都有一种统一的格式——ELF。

其中的细节部分读者若感兴趣可以翻阅CSAPP第7章相关内容。

下面介绍链接的两种方式:静态链接和动态链接

静态链接

在实际的使用当中,人们常常将一些常用的函数封装成库。实际上,所有的编译系统都提供一种机制,将所有相关的目标模块打包成一个单独的文件,称为静态库。当链接器构造一个可执行文件时,它只复制静态库里被应用程序引用的目标模块。

在Linux中,静态库以存档(.a)文件的格式存储。我们可以使用AR工具来打包一个静态库。例如:

我们有两个函数addvec和mulvec分别存储于addvec.c和mulvec.c

int addcnt=0;
void addvec(int *x,int *y,int *z){
    z[0]=x[0]+y[0];
    z[1]=x[1]+y[1];
    addcnt++;
}
int addcnt=0;
void addvec(int *x,int *y,int *z){
    z[0]=x[0]+y[0];
    z[1]=x[1]+y[1];
    addcnt++;
}
gcc -c addvec.c mulvec.c
ar rcs libvector.a addvec.o mulvec.o

我们会得到一个libvector.a文件,这就是我们打包完成的库了。

接下来再与main.c进行链接生成prog的可执行文件。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#include<stdio.h>
extern int addcnt;
extern int mulcnt;
void addvec(int *x,int *y,int * z);
void mulvec(int *x,int *y,int * z);
int x[2]={1,2};
int y[2]={3,4};
int z[2];
int main()
{
    addvec(x,y,z);
    printf("z[0]=%d,z[1]=%d,addcnt=%d,mulcnt=%d\n",z[0],z[1],addcnt,mulcnt);
}
gcc -static -o prog main.o libvector.a

另外值得一提的是,当我们将主程序文件与库进行链接时,需要将静态库放在最后,原因是当链接器使用静态库来解析引用时是从左到右按照它们在命令行出现的顺序来扫描可重定位文件和存档文件。

例如,刚刚的例子中,我们如果将libvec.a放在main.o前面,就会出现这种情况(链接器根据顺序搜索找不到addvec函数)

还有一种情况,假设foo.c调用libx.a的函数,而libx.a和liby.a又相互依赖,那么libx.a就需要重复出现

gcc foo.c libx.a liby.a libx.a

动态链接共享库

之前提到的静态链接方式其实是一种古老的链接方式,现在的技术往往采用动态链接。主要原因是几乎每个c程序都会使用标准函数,例如printf和scanf,使用静态链接会将这些函数的代码重复复制到每个运行进程的文本段中。导致有限的内存中存在着大量的标准函数的副本,这是一种极大的内存浪费。

共享库就是一种解决这个问题的创新产物。在Linux中为.so后缀文件,在windows被称作DLL(动态链接库)。

首先还是以上面的例子创建一个动态库

gcc -shared -fpic -o libvector.so addvec.c mulvec.c

然后将动态库与main.c进行链接

gcc -o prog2 main.c ./libvector.so

乍一看是不是和静态库的操作没什么区别,但其实隐藏在命令背后的原理是完全不同的。

动态链接的基本思路是当创建可执行文件时,静态执行一些链接,然后在程序加载时动态完成链接过程。此时,没有任何libvector.so的代码和数据真的被复制到prog2中,链接器只是复制了一些重定位和符号表信息。也就是说,prog运行的时候必须依靠本地的libvector.so,而静态链接完成后即使本地不存在libvecot.a,prog运行也能运行,这也是笔者认为两者链接方式的最大区别。

我们可以看下两种链接方式产生的prog可执行文件大小。

可以看到,动态链接的文件大小小得多。

顺带一提,我们可以使用ldd filename来查看可执行文件的动态库依赖

gcc目前默认采用动态链接,同时会默认添加libc.so标准c共享库