下 GLFW 的时候被安利的文章,觉得比较好,翻译一下方便以后看。(有时候就很奇怪他们都从哪些稀奇古怪的地方知道这些文章…)

原文出处

这篇文旨在帮助 C&C++ 程序员理解链接器所做的重要工作。这些年来我已经向同事们解释了太多次,我想是时候把它写下来让更多人知道(我就不用再反复解释)。【更新至2009年3月,包含更多关于 Windows 上的特殊链接和一些对定义规则的阐述】

一般我遇到的情况是,有人求救说遇到了一个链接错误:

g++ -o test1 test1a.o test1b.o
test1a.o(.text+0x18): In function `main’:
undefined reference to `findmax(int, int)’
collect2: ld returned 1 exit status

如果此刻你的反应是 “这肯定是没写 extern “C”” ,那么你很可能已经了解这篇文章的内容。

内容目录

  • 给部分命名:C 文件是什么
  • C 编译器做了什么
    • 解剖对象文件
  • 链接器做了什么 (1)
    • 重复符号
  • 操作系统做了什么
  • 链接器做了什么 (2)
    • 静态库
    • 动态库
    • Windows DLLs
      • 导出符号
      • .LIB 和其他库相关文件
      • 导入符号
      • 循环依赖
  • 引入 C++
    • 函数重载 & 名字修饰
    • 静态初始化
    • 模板
  • 动态加载库
    • 与 C++ 特性的交互
  • 更多细节

给部分命名:C 文件是什么

本节快速回顾下 C 文件的不同部分。如果你能完全理解以下样例 C 文件,你或许可以选择跳过。

/* 这是未初始化的全局变量的定义 */
int x_global_uninit;

/* 这是初始化的全局变量的定义 */
int x_global_init = 1;

/* 这是未初始化的全局变量的定义,尽管它只能被这个 C 文件中的名称访问 */
static int y_global_uninit;

/* 这是初始化的全局变量的定义,尽管它只能被这个 C 文件中的名称访问 */
static int y_global_init = 2;

/* 这是存在于程序中其它地方的全局变量的声明 */
extern int z_global;

/* 这是存在于程序中其它地方的函数的声明,如果你喜欢可以加上 "extern" ,它不是必须的 */
int fn_a(int x, int y);

/* 这是函数定义,但它被声明为静态,故只能被这个 C 文件中的名称访问 */
static int fn_b(int x)
{
  return x+1;
}

/* 这是函数定义 */
/* 函数参数是局部变量 */
int fn_c(int x_local)
{
  /* 这是未初始化的局部变量的定义 */
  int y_local_uninit;
  /* 这是初始化的局部变量的定义 */
  int y_local_init = 3;

  /* 这些代码可以通过名称引用到局部,全局变量和其他函数 */
  x_global_uninit = fn_a(x_local, x_global_init);
  y_local_uninit = fn_a(x_local, y_local_init);
  y_local_uninit += fn_b(z_global);
  return (y_global_uninit + y_local_uninit);
}

首先要理解定义声明的区别。定义是一个名称的具体实现,可以是数据或者代码:

  • 变量的定义包括编译器为此变量留存空间,可能为此空间填充一个特定值。
  • 函数的定义包括编译器为此函数生成代码。

声明则告诉 C 编译器 (具有特定名称的) 某物的定义存在于程序中的某处,可能在别的 C 文件里。(注意一个定义也可以算作一个声明———它也是一种声明只不过恰好就在“某处”)

对于变量来说,定义分为两种:

  • 全局变量,存在于程序的整个生命周期 (global extent) 而且通常能被多个不同的函数访问
  • 局部变量,只存在于某个特定函数 (local extent) 并且只能在此函数内被访问

说的详细点,“访问”即“能够通过变量名称联系到其定义”。

以下几个特例中,这些规则并不那么明显:

  • 静态局部变量实际上是全局变量,因为其存在于程序的整个生命周期,即使它们只能在某个函数内部被访问
  • 静态全局变量同样是全局变量,即使他们只能被定义在同一文件中函数访问

当我们谈论 static 关键字时,有一点值得指出的是,使一个函数成为静态只不过是缩小了可以通过函数名称引用到函数的范围(特别地,对与同一文件中的其他函数来说)。

我们可以根据变量是否被初始化来区分局部和全局变量——即与某个名称关联的空间是否被预先用一个特殊值填充。

最后,我们可以使用 malloc 或 new 往内存中动态存储信息。动态分配的内存空间无法通过某个名称引用到,所以我们使用指针——一种存有一块未命名内存地址的已经命名的变量——来代替。这块空间可以用 free 或 delete 来释放,故称此空间具有动态范围 (dynamic extent) 。

让我们来总结:

linkertable

C 编译器做了什么

C 编译器的工作是把 C 文件从人类能理解的文本转换成计算机能理解的东西。编译器的输出是对象文件。在 UNIX 类平台上这些文件通常是是 .o 后缀;在 Windows 上则是 .obj 。对象文件的内容本质上有两种东西:

  • 代码,对应 C 文件中函数的定义
  • 数据,对应 C 文件中全局变量的定义 (对于那些需要初始化的全局变量,初始值保存在对象文件中)

这两种东西都有联系到特定的名称——即变量或函数的名称。

对象文件里的代码 (被正确编码过的) 是一串机器指令对应着程序员编写的 C 指令——所有的 if, while, goto 指令。所有的这些指令都要去操纵某些信息,这些信息需要在某处被保存——这就是变量所做的工作。这些代码引用一些其它代码——比如,程序中的其他 C 函数。

无论这些代码引用的是变量还是函数,如果编译器预先看见过这些变量或函数的声明,它就允许这些引用行为。(声明就是一种“定义存在程序中某处”的承诺) 。

链接器的工作就是保证这些承诺都“好好的”。那么编译器在生成这些对象文件时如何使用这些承诺?

简单来说,编译器留下一些空白,这些空白有相关联的名称,但是名称对应的的值未知。

有了这些概念在脑子里,我们可以描绘出这样的对象文件 (对应上一节样例 C 文件) :

c_parts

解剖对象文件

我们目前还站在制高点俯视;再看看实际上发生了什么会更有帮助。UNIX 类平台上,nm 命令可以告诉我们对象文件中的符号的信息。在 Windows 上,带 /symbols 的 dumpbin 命令基本达到同样效果,或者使用 Windows 上的 GNU binutils 工具 nm.exe。

让我们来看看 nm 给了我们什么信息 (同样对应上一节样例 C 文件) :

Symbols from c_parts.o:

Name                  Value   Class        Type         Size     Line  Section

fn_a                |        |   U  |            NOTYPE|        |     |*UND*
z_global            |        |   U  |            NOTYPE|        |     |*UND*
fn_b                |00000000|   t  |              FUNC|00000009|     |.text
x_global_init       |00000000|   D  |            OBJECT|00000004|     |.data
y_global_uninit     |00000000|   b  |            OBJECT|00000004|     |.bss
x_global_uninit     |00000004|   C  |            OBJECT|00000004|     |*COM*
y_global_init       |00000004|   d  |            OBJECT|00000004|     |.data
fn_c                |00000009|   T  |              FUNC|00000055|     |.text

不同平台的输出可能略有差异 (可以查阅 man 手册得到更多特殊版本的 nm 命令的信息),但是重点在于每个符号的 Class 和 Size (部分能确定的) 。Class 有几种不同的值:

  • U 指未定义的引用,即之前提到的“空白”部分。这样的有两个:”fn_a” 和 “z_global”。(有些版本的 nm 命令会大引出 Section ,那么这种情况下就是 UND 或者 UNDEF )
  • T/t 指函数定义;t 和 T 的区别在于是否声明为 static 。同样,有些系统显示 Section 为 .text
  • D/d 指初始化的全局变量,d 和 D 的区别在于是否声明为 static。如果是 Section 为 .data
  • 至于那些未初始化的全局变量,当它是静态的是 b ,否则是 B 或 C 。Section 为 .bss 或者 COM

我们还会得到一些源 C 文件中没有出现的符号;暂且忽略这些由编译器邪恶的内部机制产生的用来链接你程序的东西。

链接器做了什么 (1)

我们之前提到函数或变量的声明是对 C 编译器承诺它们的定义将会存在程序中的某处,而链接器的工作则是保证这些承诺可用。就上一节的对象文件图来说,我们可以称之为“填满空白”。

为了解释它,让我们来看看与之前的 C 文件样例相关的另一个 C 文件:

/* 初始化全局变量 */
int z_global = 11;
/* 第二个叫 y_global_init 的全局变量,但它们都是静态的 */
static int y_global_init = 2;
/* 另一个全局变量的声明 */
extern int x_global_init;

int fn_a(int x, int y)
{
  return(x+y);
}

int main(int argc, char *argv[])
{
  const char *message = "Hello, world";

  return fn_a(11,12);
}

c_rest

有了以上两张图表,我们可以看到所有的点都连上了 (如果没有,链接器会报错) 。每个萝卜都有个坑,每个坑都有个萝卜,链接器就填满了所有的空白 (在 UNIX 系统上,通常使用 ld 命令调用链接器) 。

sample1

至于对象文件,我们可以对生成的可执行文件键入 nm 命令来验证结果:

Symbols from sample1.exe:

Name                  Value   Class        Type         Size     Line  Section

_Jv_RegisterClasses |        |   w  |            NOTYPE|        |     |*UND*
__gmon_start__      |        |   w  |            NOTYPE|        |     |*UND*
__libc_start_main@@GLIBC_2.0|        |   U  |              FUNC|000001ad|     |*UND*
_init               |08048254|   T  |              FUNC|        |     |.init
_start              |080482c0|   T  |              FUNC|        |     |.text
__do_global_dtors_aux|080482f0|   t  |              FUNC|        |     |.text
frame_dummy         |08048320|   t  |              FUNC|        |     |.text
fn_b                |08048348|   t  |              FUNC|00000009|     |.text
fn_c                |08048351|   T  |              FUNC|00000055|     |.text
fn_a                |080483a8|   T  |              FUNC|0000000b|     |.text
main                |080483b3|   T  |              FUNC|0000002c|     |.text
__libc_csu_fini     |080483e0|   T  |              FUNC|00000005|     |.text
__libc_csu_init     |080483f0|   T  |              FUNC|00000055|     |.text
__do_global_ctors_aux|08048450|   t  |              FUNC|        |     |.text
_fini               |08048478|   T  |              FUNC|        |     |.fini
_fp_hw              |08048494|   R  |            OBJECT|00000004|     |.rodata
_IO_stdin_used      |08048498|   R  |            OBJECT|00000004|     |.rodata
__FRAME_END__       |080484ac|   r  |            OBJECT|        |     |.eh_frame
__CTOR_LIST__       |080494b0|   d  |            OBJECT|        |     |.ctors
__init_array_end    |080494b0|   d  |            NOTYPE|        |     |.ctors
__init_array_start  |080494b0|   d  |            NOTYPE|        |     |.ctors
__CTOR_END__        |080494b4|   d  |            OBJECT|        |     |.ctors
__DTOR_LIST__       |080494b8|   d  |            OBJECT|        |     |.dtors
__DTOR_END__        |080494bc|   d  |            OBJECT|        |     |.dtors
__JCR_END__         |080494c0|   d  |            OBJECT|        |     |.jcr
__JCR_LIST__        |080494c0|   d  |            OBJECT|        |     |.jcr
_DYNAMIC            |080494c4|   d  |            OBJECT|        |     |.dynamic
_GLOBAL_OFFSET_TABLE_|08049598|   d  |            OBJECT|        |     |.got.plt
__data_start        |080495ac|   D  |            NOTYPE|        |     |.data
data_start          |080495ac|   W  |            NOTYPE|        |     |.data
__dso_handle        |080495b0|   D  |            OBJECT|        |     |.data
p.5826              |080495b4|   d  |            OBJECT|        |     |.data
x_global_init       |080495b8|   D  |            OBJECT|00000004|     |.data
y_global_init       |080495bc|   d  |            OBJECT|00000004|     |.data
z_global            |080495c0|   D  |            OBJECT|00000004|     |.data
y_global_init       |080495c4|   d  |            OBJECT|00000004|     |.data
__bss_start         |080495c8|   A  |            NOTYPE|        |     |*ABS*
_edata              |080495c8|   A  |            NOTYPE|        |     |*ABS*
completed.5828      |080495c8|   b  |            OBJECT|00000001|     |.bss
y_global_uninit     |080495cc|   b  |            OBJECT|00000004|     |.bss
x_global_uninit     |080495d0|   B  |            OBJECT|00000004|     |.bss
_end                |080495d4|   A  |            NOTYPE|        |     |*ABS*

以上就是两个对象里所有的符号,所有的未定义引用都消失了。这些符号被重排了,相同种类的符号聚在一起。还有一些附加的符号帮助操作系统把所有的部分拼凑成一个可执行文件。

提示:实际上输出信息中还有很多复杂的东西,但如果你不看那些下划线开头的符号,事情会简单很多。

重复符号

上一节提到链接器不能找到符号的定义就会报错。那如果链接时同一个符号找到了两个定义呢?

在 C++ 里,情况很明朗。语言规定了 “the one definition rule” ,即链接时,一个符号只能对应一个定义,不多不少。(之后会提到一些例外。C++ 标准的相关章节是 3.2 )

而在 C 里,没有这么清晰。函数和已初始化的全局变量的定义是唯一的,而未初始化的全局变量的定义称为 tentative definition 。C 允许 (至少也没有禁止) 不同的源文件具有同一个对象的 tentative definition 。

然而,链接器还要对付除了 C 和 C++ 外的语言,而 “the one definition rule” 不一定都适用。比如, 每个全局变量在每个文件中都有引用对于普通模式的 fortran 代码来说是高效的;链接器被要求从这些副本中选出一个,然后舍弃剩下的。(这种模式被称为“命令模式”的链接,跟在 Fortran 的 COMMON 关键字后面)

结果,UNIX 链接器通常不对重复的符号报错——至少,不在这些符号是未定义的全局变量时报错 (这被称为链接的 “relaxed ref/def” 模式)。如果这困扰到你 (可能会),查阅你编译器的文档——不过很可能只是增加一个 –work-properly 选项的问题。例如,拿 GNU 工具链来说,-fno-common 选项强制编译器把未初始化变量放到 BSS 段而不是生成在一般区域。

操作系统做了什么

现在链接器已经产生了可执行程序并且所有萝卜 (符号) 都找到了合适的坑。我们需要暂停下来理解在运行程序时,操作系统做了什么。

运行程序显然执行了机器码,所以操作系统已经把机器码可执行文件从硬盘上载入到 CPU 可以访问到的内存中。这块内存被称为代码段 (code segment) 或文本段 (text segment) 。

代码是不包括数据的,所以所有的全局变量还需要占用内存中的空间。然而这对于已初始化和未初始化的全局变量是不一样的。
要被初始化的变量需要存在对象文件和可执行文件里的特殊值,当程序开始运行,操作系统拷贝这些值到程序内存的数据段 (data segment) 里。

而那些未初始化的变量,操作系统假定它们初始值为 0,所以不需要拷贝任何值。这块内存称为 bss 段,被初始化为 0。

这就意味着可以节省可执行文件所占的磁盘空间;要被初始化变量的值要被存在文件里,但未初始化的变量只需要计算它们占多少空间就可以了。

os_map1

你可能注意到目前我们讨论的对象文件和链接器都是关于全局变量;没有提到局部变量和动态分配的内存。

有些数据和链接器是不沾边,因为它们的生命周期之出现在程序运行时——即链接器完成工作后。但是呢,为了完整,我们在这儿还是说一下:

  • 局部变量所占用的内存区域被称为栈,其增长和消亡与函数的调用与完成一致
  • 动态分配的内存区域称为堆,malloc 函数追踪其所有可用空间的位置

我们可以增加这些区域的内存来完成我们的运行时内存图。因为堆栈空间在运行时都可能改变大小,所以规定栈往一个方向增长,堆就往另一个方向增长。这样,在程序内存被耗尽时,它们会在中间相遇 (到这时,内存空间已经满当当了)。

os_map2

链接器做了什么 (2)

现在我们已经介绍了链接器最基本的工作内容,接下来更深入一点——大致按照特性被增加的时间顺序来介绍。

一个显而易见的问题:如果许多不同的程序需要都做同一些事情 (比如输出到屏幕,从硬盘中读文件之类),那么把这些共同的代码抽取出来让更多的程序可以使用将会变得非常有意义。

不同程序链接同样的对象文件是完全可行的,但如果把这些相关的对象文件集合放在特定的地方会更好:这就是库。

(技术之外:本节完全跳过了链接器的一个主要特性:重定位 (relocation) 。不同的程序大小不同,所以当动态库被映射到不同程序的地址空间时,地址将会有所不同。这就意味着,库里的所有函数和变量都在不同的地方。如果我们用相对地址 (“值 = 当前值 + 1020 字节”) 会比绝对地址 (“值 = 0x102218B”) 好很多,但相对地址不一定总是管用。如果使用绝对地址,那么这些地址必须都有合适的偏移值——这就是重定位。我不会再次提到它,因为它对于 C/C++ 程序员几乎是不可见的——重定位引发的链接问题也比较少)

静态库 (Static Libraries)

库最常见的形式是静态库。前面的章节已经提到你可以共享重用对象文件集合;静态库也差不多就是这个意思。

在 UNIX 系统上,ar 命令可以生成静态库。静态库一般以 .a 结尾,以 lib 开头,当作链接器参数时需要在 (去前缀后缀的) 库名前加上 -l (例如,”-lfred” 对应着 “libfred.a” ) 。

(历史原因,静态库还需要使用一个 ranlib 命令来从头开始创建符号目录。现在 ar 工具似乎都自己干完了)

在 Windows 上,静态库的后缀是 .LIB ,使用 LIB 工具生成。这很容易跟 “import library” 混淆。下下节介绍 Windows DLLs 时再讨论。

当一系列对象文件集合起来输入链接器时,链接器就能够构建那些之前不认识 (unresolved) 的符号。当查阅完所有明确指定的特殊对象文件,链接器还要去库中寻找还未被识别的符号。如果在某库中的某对象文件找到了某符号,那么此对象文件就会被加入链接过程中,就跟用户在命令行中直接给出了此对象文件参数一样。

注意从库中提取内容的粒度:如果需要特定符号的定义,那么包含它的整个对象文件都会被加入链接过程。也就是说这个过程可以前进一步或后退一步——新添加的对象会解决一个未定义符号,同时也可能带来一堆新的未定义符号。

另一个重要细节是这些事情发生的顺序;正常链接完成后会按“从左到右”的顺序查询库。这样就意味着,即使后处理的对象需要先处理的对象中的符号定义,链接器也不会自动去寻找它。

下面这个例子会让你更好地理解;我们有下面这些对象文件,然后链接命令是 a.o b.o -lx -ly 。

linkertable1

一旦链接器处理了 a.o 和 b.o ,就能认出 b2 和 a3 ,但 x12 和 y22 还是未定义的。此时,链接器去找第一个库 libx.a ,发现如果把 x1.o 加进来就能得到 x12 的定义。然而,这样做同时又添加了 x23 和 y12 这两个未定义符号 (现在未定义的有 y22, x23, y12) 。

链接器继续在 libx.a 里找,发现把 x2.o 加入就得到 x23 的定义,同时又增加一个未定义符号 y11 (现在未定义的有 y22, y12, y11 )。好吧,现在 libx.a 里没东西了,接着转到 liby.a 。

然后是同样的过程,链接器添加 y1.o 和 y2.o 。 前者引入新的未定义符号 y21,但 y2.o 加入后,所有的符号都找到了源头。整个过程就是把库中的某些 (不一定是全部) 对象文件 添加到最终的可执行文件 中,使得所有未定义符号找到定义。

注意,如果 b.o 里有一个 未定义的 y32,情况就不太一样。此时,对 libx.a 的情况没差,但是在添加 liby.a 中的对象文件时,为了识别 y32 还要加入 y3.o。而 y3.o 带来的是 x31 这个未定义符号,此时链接会失败——因为链接器已经完成了 libx.a 的链接工作,不会再回去找 x31 的定义了 (在 x3.o 里) 。

(顺带一提,这个例子就是在库 libx.a 和 liby.b 之间的“循环依赖”。这绝对是件坏事,在 Windows 中更是)

动态库 (Shared Libraries)

像那些非常普遍的 C 标准库 (一般是 libc ),使用静态库的缺点很明显——每个程序都有同样代码的副本。如果每个可执行文件都有一份 printf 和 fopen 的拷贝,无疑会占用很多不必要的磁盘空间。

一个比较隐蔽的缺点是一旦一个程序静态链接后,代码就永远固定下来了。如果有人想修复一个 printf 的 bug,他必须要重新链接一遍再得到新的“固定代码”。

为了绕过上面提到的问题和其它没提到的问题,动态库应运而生 (通常 UNIX 上以 .so 结尾,Windows 上 .dll 结尾,Mac OS X 上以 .dylib 结尾) 。对于这种类型的库,链接器不需要填充满所有的“点”。链接器作一个 “IOU” 记号,推迟填充工作到程序运行时。

总结一下:如果链接器在动态库中找到指定的符号,它不是把符号的定义加入最终的可执行文件,而是记录下符号的名字和它来自哪个库。

程序运行时,操作系统会安排好这些剩余的一点链接工作。在进入 main 函数之前,一个小号的链接器 (一般是 ld.so ) 会遍历这些记号,从对应的库中提取相应代码,填充所有的“点”。

这样可执行文件就不会有 printf 的拷贝。如果 printf 要更新,只需改变 libc.so 即可——它会在下一次程序运行时被链接。

还有另一个很大的差别是:动态库和静态库链接的粒度。如果要链接一个动态库中的符号 (比如 libc.so 中的 printf ) ,整个动态库都会被映射到程序地址空间中。而静态库只是添加了包含这个符号的对象文件。

换句话说,动态库更像是链接器运行的结果 (而不是 ar 产出的一摞对象文件),同一个库中不同对象的引用问题得到解决。nm 能作一个很好的解释:就上节那些库的例子来说,如果用静态链接,每个对象文件都会产生结果,而动态链接只有 liby.a 会有一个 x31 是未定义符号 (x31 在 libx.a 中)。库的链接顺序的问题都不再是问题:在 b.c 中增加一个 y32 的引用也没区别,因为所有 y3.o 和 x3.o 的内容都被囊括了。

补充:ldd 也是一个很有用的工具;在 UNIX 平台上,它显示出可执行文件(或动态库)所依赖的动态库集和这些库的位置。如果程序运行成功,装载器就能找到这些库以及其依赖项。(一般地,装载器的查找目录被保存在 LD_LIBRARY_PATH 环境变量中)。

/usr/bin:ldd xeyes
        linux-gate.so.1 =>  (0xb7efa000)
        libXext.so.6 => /usr/lib/libXext.so.6 (0xb7edb000)
        libXmu.so.6 => /usr/lib/libXmu.so.6 (0xb7ec6000)
        libXt.so.6 => /usr/lib/libXt.so.6 (0xb7e77000)
        libX11.so.6 => /usr/lib/libX11.so.6 (0xb7d93000)
        libSM.so.6 => /usr/lib/libSM.so.6 (0xb7d8b000)
        libICE.so.6 => /usr/lib/libICE.so.6 (0xb7d74000)
        libm.so.6 => /lib/libm.so.6 (0xb7d4e000)
        libc.so.6 => /lib/libc.so.6 (0xb7c05000)
        libXau.so.6 => /usr/lib/libXau.so.6 (0xb7c01000)
        libxcb-xlib.so.0 => /usr/lib/libxcb-xlib.so.0 (0xb7bff000)
        libxcb.so.1 => /usr/lib/libxcb.so.1 (0xb7be8000)
        libdl.so.2 => /lib/libdl.so.2 (0xb7be4000)
        /lib/ld-linux.so.2 (0xb7efb000)
        libXdmcp.so.6 => /usr/lib/libXdmcp.so.6 (0xb7bdf000)

动态库更大的链接粒度是因为现代操作系统非常聪明,能节省的绝不仅是静态库中需要的重复磁盘空间;不同的进程也能共享动态库代码段 (code segment。但 data/bss segment 不能——毕竟不同进程可能在不同的地方)。所以整个动态库的链接应当一气呵成,这样内部引用就可以到同一个地方排队——如果一个进程需要 a.o 和 b.o 而另一个需要 b.o 和 c.o, 操作系统就没有任何共性可以利用。

Windows DLLs

尽管动态库的一般原则在 Windows 和 UNIX 上大体相同,但还是有些细节可以让人不知所措。

导出符号

首先最主要的区别是 Windows 的库是不会自动导出符号的。在 UNIX 上,所有链接到动态库的对象文件的符号对库用户是可见的。而 Windows 上,程序员必须明确选择让这些符号可见才行——比如,导出它们。

下面列出三种导出 Windows DLL 中的符号的方法 (三种方法都可以在同一个库中混用)。

  • 在源码中声明 __declspec(dllexport),就像这样:
    __declspec(dllexport) int my_exported_function(int x, double y);
    
  • 使用链接器的时候,加上选项 /export:symbol_to_export option to LINK.EXE

    LINK.EXE /dll /export:my_exported_function

  • 使用 /DEF:def_file 链接选项让链接器生成 module definition (.DEF) 文件,文件中的 EXPORTS 部分包含导出的符号。

    EXPORTS
    my_exported_function
    my_other_exported_function

一旦 C++ 参与进来,第一种方法就变得很容易。因为编译器会处理好名称修饰问题 (name mangling)。

.LIB 和其他库相关文件

链接器需要链接的导出符号信息不保存在 DLL 本身,而是在相应的 .LIB 文件中,这巧妙地导致了 Windows 库的第二个复杂性。

与 DLL 关联的 .LIB 文件描述了 DLL 中有哪些符号以及它们的位置。使用DLL的任何其他二进制文件都需要查看.LIB文件来正确链接符号。(容易混淆的是,.LIB 后缀也用于静态库)

其实 Windows 库有很多不同的相关文件,除了之前提到的 .LIB 和 .DEF,你可能会看到以下所有文件。

  • 链接输出文件:
    • library.DLL:库的代码;可执行程序运行时需要。
    • library.LIB:库的 “import library” 文件,描述输出 DLL 中的符号。只有在 DLL 导出一些符号时才会存在这个文件。任何使用这个库的程序链接时都需要此文件。
    • library.EXP:库的 “export library” 文件,当链接的二进制文件有循环依赖时需要此文件。
    • library.ILK:如果指定了链接选项 /INCREMENTAL ,启用增量链接 (incremental linking),此文件保存增量链接的状态。任何未来增量链接都需要此文件。
    • library.PDB:如果指定了链接选项 /DEBUG 才会有。此文件是一个包含库的调试信息的程序数据库。
    • library.MAP:如果指定了链接选项 /MAP 才会有。此文件包含库内部布局的说明。
  • 链接输入文件:
    • library.LIB:库的 “import library” 文件,用于描述链接所需的任何其他 DLL 中的符号。
    • library.LIB:静态库文件,其中包含所链接所需的对象文件集合。请注意 LIB 扩展名的模糊用法。
    • library.DEF:“module definition” 文件,允许控制链接库的各种细节,包括符号的导出。
    • library.EXP:要链接的库的 “export library” 文件,可以显示上一次 LIB.EXE 运行已创建的 .LIB 文件。循环依赖相关。
    • library.ILK:增量链接状态文件;同上。
    • library.RES:资源文件,包含有关可执行文件使用的各种 GUI 小部件的信息;这些都包含在最终的二进制文件中。

而 UNIX 的库本身就包含了这些额外文件中保存的大部分信息 (通常来说)。

导入符号

既然 DLL 需要显式声明导出了哪些符号, Windows 同样允许二进制文件显式声明它们导出了哪些符号。这是可选项,但由于 16 位窗口的历史原因,有一些优化。

在源码中声明 __declspec(dllimport) ,就像这样:

__declspec(dllimport) int function_from_some_dll(int x, double y);
__declspec(dllimport) extern int global_var_from_some_dll;

C 语言中,在头文件中保存任何函数或全局变量的单个声明是个好习惯。但这导致了一些难题:包含函数/变量定义的 DLL 中的代码需要导出符号,但 DLL 外部的任何代码都需要导入符号。

最普通的解决方法是在头文件中使用预处理器宏。

#ifdef EXPORTING_XYZ_DLL_SYMS
#define XYZ_LINKAGE __declspec(dllexport)
#else
#define XYZ_LINKAGE __declspec(dllimport)
#endif

XYZ_LINKAGE int xyz_exported_function(int x);
XYZ_LINKAGE extern int xyz_exported_variable;

DLL 中定义了函数和变量的 C 文件保证预处理器变量 EXPORTING_XYZ_DLL_SYMS 在包含此头文件之前是#defined (定义了的),符号的导出也是一样。而任何需要此头文件的其他代码都不定义 EXPORTING_XYZ_DLL_SYMS,因而表示符号需要被导入。

循环依赖

最后一个 DLL 的复杂因素是 Windows 比 UNIX 对链接时的分辨率 (resolution) 要求更严格。UNIX 上,链接一个含有没见过的未定义符号的动态库是可能的;在这种情况下,任何用到此动态库的代码必须提供这些符号,否则链接失败。而 Windows 不允许这种事情发生。

再大多数系统上这不算个问题。可执行文件依赖于高级 (high-level) 库,高级库依赖于低级 (low-level) 库,所有内容都以相反的顺序链接——首先是低级库,然后是高级库,最后是依赖于它们的可执行文件。

但是,如果二进制文件之间存在循环依赖关系,事情就更棘手了。如果 X.DLL 需要来自 Y.DLL 的符号,并且 Y.DLL 需要来自 X.DLL 的符号,就会产生鸡和蛋的问题:无论哪个库首先链接都无法找到它的全部符号。

Windows 确实提供了解决此问题的方法,大致如下。

  • 首先,“假造”一个库 X 的链接。运行 LIB.EXE (不是 LINK.EXE) 来生成 X.LIB 文件 (LINK.EXE 也会生成同样的文件)。不会生成 X.DLL 文件,但会生成 X.EXP 文件。
  • 正常链接库 Y;这一步将会加入上一步生成的 X.LIB 文件,生成 Y.DLL 和 Y.LIB 文件。
  • 最后正确链接库 X。这一步基本和平常没差,加入了上一步生成的 Y.LIB 文件来生成 X.DLL。和平常不一样的是,链接会跳过构建 X.LIB 文件,因为它已经在第一步生成了 (就是 .EXP 文件指示的)。

当然,最好的办法是重新组织这些库,让它们不要存在循环依赖关系……

加入 C++

C++ 提供了更多 C 没有的特性,其中许多特性与链接器的操作相互影响。其实最开始的时候并不是这样——最早的 C++ 实现是作为 C 编译器的前端 (front end),所以作为后端 (back end) 的链接器并不需要有太大改动——随着时间推移,增加了更多复杂的特性,所以链接器必须做出改变来保证和支持这些特性。

函数重载 & 名字修饰

第一个改变是 C++ 允许函数重载,所以同名函数可以有多个版本,只要他们的参数不同 (函数签名中):

int max(int x, int y)
{
  if (x>y) return x;
  else return y;
}
float max(float x, float y)
{
  if (x>y) return x;
  else return y;
}
double max(double x, double y)
{
  if (x>y) return x;
  else return y;
}

这显然抛给链接器一个问题:当其他代码引用到 max 的时候,指的是哪个版本呢?

解决的方法是名字修饰 (name mangling),因为所有关于函数签名的信息都被重整 (mangle) 成文本形式,这些文本才是链接器最终看到的符号。不同的函数签名都被重整成不同的名字,这样唯一性问题就消失了。

我不打算深入讲下去 (因为各个平台处理的差异),但是可以快速地从对应上方代码地对象文件得到一些提示 (记住,nm 是你的好朋友!) :

Symbols from fn_overload.o:

Name                  Value   Class        Type         Size     Line  Section

__gxx_personality_v0|        |   U  |            NOTYPE|        |     |*UND*
_Z3maxii            |00000000|   T  |              FUNC|00000021|     |.text
_Z3maxff            |00000022|   T  |              FUNC|00000029|     |.text
_Z3maxdd            |0000004c|   T  |              FUNC|00000041|     |.text

现在我们可以看到在对象文件里所有叫 max 的函数都有了不同的名字,我们可以自以为聪明地猜想 max 后面的两个字母其实是—— i 代表 int,f 代表 float ,d 代表 double (如果涉及到类、命名空间、模板和重载操作符,情况会更复杂!) 。

值得注意的是,通常会有某种办法在用户可见名称(未重整名称)和链接器可见名称(重整名称)之间进行转换。可能是一个单独的程序(例如c ++ filt)或命令行选项(例如 GNU nm 的一个选项 –demangle ),它给出了如下结果:

Symbols from fn_overload.o:

Name                  Value   Class        Type         Size     Line  Section

__gxx_personality_v0|        |   U  |            NOTYPE|        |     |*UND*
max(int, int)            |00000000|   T  |              FUNC|00000021|     |.text
max(float, float)            |00000022|   T  |              FUNC|00000029|     |.text
max(double, double)            |0000004c|   T  |              FUNC|00000041|     |.text

在 C 和 C ++ 代码混合的区域,上面这种重整方式最容易让人困惑。所有 C++ 编译器产生的符号将被重整;所有 C 编译器产生的符号将和源码中的相同。为了解决这个问题,C++ 允许给函数加上 extern “C” 声明 & 定义。这个声明基本上等同于告诉 C++ 编译器这个特殊的名称不需要重整——要么是 C 代码需要调用 C++ 的函数,要么是 C++ 代码需要调用 C 的函数。

文章开始给出的例子就是在链接 C 和 C++ 时缺少了 extern “C” 声明。

g++ -o test1 test1a.o test1b.o
test1a.o(.text+0x18): In function `main’:
undefined reference to `findmax(int, int)’
collect2: ld returned 1 exit status

(顺便一提,在 7.5.4 的 C++ 标准中,成员函数的 extern “C” 链接声明会被忽略)

静态初始化

下一个会影响链接器的 C 之上的 C++ 特性是对象的构造器。构造器构造物体的基本内容;这个概念等同于变量的初始值但是这个初始值可以是任意代码。

回忆之前的章节,全局变量可以初始化为特殊值。在 C 里,构造全局变量的初始值很简单:只是从可执行文件的数据段 (data segment) 复制特定值到待运行的程序内存罢了。

而在 C++ 中,构造过程就比单纯的复制值复杂得多;在程序开始正常运行之前,必须运行类层次结构中各种构造函数中的所有代码。

为了解决这个问题,编译器在对象文件中为每个 C++ 文件增加了额外信息。特别是文件中需要被调用的构造函数列表。链接时,链接器将所有这些单独的列表组合成一个大列表,然后逐个遍历列表的代码,调用所有这些全局对象构造函数。

注意,调用这些全局对象的构造函数的顺序是未定义的——完全取决于链接器自己怎么做。(参照 Scott Meyers 的 Effective C++ for more details,第二版的 Item 47,第三版的 Item 4) 。

我们可以用 nm 命令来查看这些列表。对下面这个 C++ 文件:

class Fred 
{ 
private:
    int x;
    int y; 
public:
    Fred() : x(1), y(2) {}
    Fred(int z) : x(z), y(3) {} 
}; 

    Fred theFred;
    Fred theOtherFred(55);

(解除名字重整后) 输出的是 :

Symbols from global_obj.o:

Name                  Value   Class        Type         Size     Line  Section

__gxx_personality_v0|        |   U  |            NOTYPE|        |     |*UND*
__static_initialization_and_destruction_0(int, int)|00000000|   t  |              FUNC|00000039|     |.text
Fred::Fred(int)        |00000000|   W  |              FUNC|00000017|     |.text._ZN4FredC1Ei
Fred::Fred()        |00000000|   W  |              FUNC|00000018|     |.text._ZN4FredC1Ev
theFred             |00000000|   B  |            OBJECT|00000008|     |.bss
theOtherFred        |00000008|   B  |            OBJECT|00000008|     |.bss
global constructors keyed to theFred  |0000003a|   t  |              FUNC|0000001a|     |.text

这里有很多东西,我们感兴趣的是 Class 为 W (意思是弱 (weak) 符号) 或 Section 为 “.gnu.linkonce.t.stuff” 。这是全局对象构造器的标记,我们可以看到相应的 Name 区域很合理——两个构造函数每个都被使用了一次。

模板

之前我们给出了 max 函数的三个版本,参数各不相同。然而我们可以看到这三个函数的代码其实相同,复制粘贴代码并不好。

C++ 引入了模板的概念,模板允许同样的代码只写一次。我们创建一个只包含一个 max 函数的 max_template.h 头文件:

template <class T>
T max(T x, T y)
{
  if (x>y) return x;
  else return y;
}

使用此头文件的 C++ 代码:

#include "max_template.h"

int main()
{
  int a=1;
  int b=2;
  int c;
  c = max(a,b);  // 编译器自动找到 max<int>(int,int) 
  double x = 1.1;
  float y = 2.2;
  double z;
  z = max<double>(x,y); // 编译器解决不了,于是强制调用 max(double,double)
  return 0;
}

这个 C++ 文件用到了 max<int>(int, int)max<double>(double, double) ,但其他的 C++ 文件还可能调用 max<float>(float,float) 甚至 max<MyFloatingPointClass>(MyFloatingPointClass, MyFloatingPointClass)

每个模板的实例都对应实际的机器代码,所以编译器和链接器必须保证每个模板实例都被正确链接 (而且不包括未被使用的实例来膨胀程序占用空间) 。

所以它们是怎么做的?一般有两种方法:折叠重复实例,或推迟实例化到链接时 (我喜欢称它们为 the sane way 和 the Sun way) 。

第一种方法,每个对象文件包含它使用到的所有模板代码。像上面的 C++ 代码,它的对象文件是:

Symbols from max_template.o:

Name                  Value   Class        Type         Size     Line  Section

__gxx_personality_v0|        |   U  |            NOTYPE|        |     |*UND*
double max<double>(double, double)   |00000000|   W  |              FUNC|00000041|     |.text._Z3maxIdET_S0_S0_
int max<int>(int, int)   |00000000|   W  |              FUNC|00000021|     |.text._Z3maxIiET_S0_S0_
main                |00000000|   T  |              FUNC|00000073|     |.text

你可以看到 ` max(int, int)` 和 `max(double, double)` 。这些定义被作为弱符号,意味着链接器在最后生成可执行文件时可以丢弃重复定义 (如果链接器愿意,它会检查所有看起来相同的代码)。这种方法的一个明显缺陷是占用了更多的磁盘空间。

另一种方法 (被 Solaris C++ 编译器套件使用) 不在对象文件中包含模板定义,而是把这些未定义符号留到链接时。链接器汇总这些对应模板实例的未定义符号来生成相应的机器代码。

这样就节省了单个对象文件所占用的空间,但缺点是链接器需要跟踪包含源代码的头文件的位置,并且要能够在链接时调用 C ++ 编译器(这可能减慢链接速度)。

动态加载库

这是最后一个我们要讨论的特性了。之前说过动态库其实是把链接延迟到程序运行时。在现代操作系统中,还能更往后延迟。

这由一对系统调用 dlopendlsym ( Windows 的 LoadLibraryGetProAddress 类似) 完成。首先通过动态库的名字将其加载到正在运行的进程的地址空间中。当然,这个库可能有未定义的符号,因此对 dlopen 的调用可能会触发其他动态库的加载。

(dlopen 还允许选择是否在加载库的立即解析所有这些引用 (RTLD_NOW),或者一个个解析 (RTLD_LAZY) 。前者意味着花费更多时间,但后者有出现后面的程序发现有无法解决的未定义引用的可能——此时程序将被终止)

当然,如果一个动态库中的符号没有名字就麻烦了。不过就像任何编程问题一样,存在曲线救国的可能性——这里可以用指针指向符号而不是用名称来引用。 dlsym 接收一个符号名称的字符串参数,返回其地址 (没找到返回 NULL) 。

与 C++ 特性的交互

这种动态加载功能非常简洁,那么它如何影响链接器整体行为和各种C ++功能的相互作用?

第一个问题在于名字修饰会有的棘手。当调用 dlsym 时,它会获取一个包含要找的符号名称的字符串,必须是链接器可见版本名称;换句话说,名称的重整版本。

因为特定的名称修改方案可能因平台和编译器而异,所以意味着以可移植的方式动态定位 C ++ 符号几乎是不可能的。

即使你很乐意坚持使用一个特定的编译器并深入研究其内部机制,但存储中存在更多问题 —— 除了类 的 C vanilla 函数之外,你还要担心 vtable 等等。

总而言之,通常最好只有一个单一的,众所周知的 extern “C” 入口点可以被 dlsym 使用。这个入口点可以作为一个工厂方法返回所有 C++ 类的指针,可以利用所有 C++ 的优点。

编译器还可以在使用过 dlopen 的库中为全局对象的构造函数们排序,因为有一些特殊符号可以在库中定义,并且当动态加载或卸载库时,链接器(无论是加载时还是运行时)都会调用它们——故必要的构造函数和析构函数调用可以放在那里。在 UNIX 上这些函数叫做 _init 和 _fini,或在是更多使用 GNU 工具链的系统上标记为 __attribute__((constructor)) 或者 __attribute__((destructor)) 的函数。在 Windows 上,相关函数是 DllMain,带有 reason 参数或 DLL_PROCESS_ATTACH 或 DLL_PROCESS_DETACH 。

最后,动态加载对模板实例的“折叠重复方法”适应得很好,但对“链接时编译模板方法”比较不适应——这里的链接时指程序运行之后 (可能是不同的拥有源码的不同机器上) 。实际使用时请参阅编译器和链接器的文档。

更多细节

本文已经跳过了太多关于链接器工作的细节,因为我发现这个程度的描述已经足以覆盖程序员们在工作中遇到的 95% 的关于链接器的问题了。

如果你想更深入,参考以下:请看原文

非常感谢 Mike Capp 和 Ed Wilson 对这篇文章的大力支持。

Copyright (c) 2004-2005,2009-2010 David Drysdale