内容简介:促成这个解释的一个典型例子是,在我帮助某人处理如下链接错误时:
原文地址: http://www.lurklurk.org/linkers/linkers.html
本文目的在于帮助 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 文件里各个部分的一个快速回顾。如果下面列出的简单 C 文件里的每样东西对你都是顺理成章的,你可以跳到下一节。
第一个要理解的分类是声明与定义。定义将一个名字与该名字的实现关联起来,这可以是数据或代码:
- 一个变量的定义使编译器为该变量保留空间,并可能向该空间填充特定的值。
- 一个函数的定义使编译器为该函数产生代码。
声明告诉 C 编译器某个东西(带有特定名字)的定义在程序的某处,可能在另一个的 C 文件里。(注意,定义也被视为声明——它是一个同时恰好告知特定“某处”的声明)。
对于变量,定义分为两类:
- 全局变量,在在整个程序的生命期(静态范围, static extent )里存在,通常在许多函数中可访问它
- 局部变量,它仅存在于将要执行的一个特定函数里(局部范围, local extent ),仅在该函数中可访问
说得更清楚些,“可访问”我们指“通过变量定义,使用关联的名字可以援引”。存在几个不是那么显而易见的特殊情形:
- static 局部变量实际上是全局变量,因为它们存在于程序的生命期,即使它们仅在一个函数内可见
- 类似的, static 全局变量也计为全局变量,即使它们仅能由定义它们的特定文件里的函数访问
在我们谈到关键字 static 主题时,值得指出的是,使得一个函数成为静态也减少了通过名字能援引该函数的地方(具体地,同一个文件里的其他函数)。
对全局与局部变量定义,我们还可以区分变量是否被初始化了——也就是说,与该特定名字关联的空间是否预先填充了一个特定值。
最后,我们可以在使用 malloc 或 new 动态分配的内存里保存信息。没有办法通过名字援引这个空间,因此我们必须使用指针——持有内存匿名片段的地址的一个具名变量(指针)。这片内存也可以使用 free 或 delete 释放,因此这个空间被称为具有“动态范围, dynamic extent ”。
总结起来:
代码 |
数据 |
|||||
全局 |
局部 |
动态 |
||||
初始化 |
未初始化 |
初始化 |
未初始化 |
|||
声明 |
int fn(int x); |
extern int x; |
extern int x; |
N/A |
N/A |
N/A |
定义 |
int fn(int x) { ... } |
int x = 1; (文件作用域) |
int x; (文件作用域) |
int x = 1; (函数作用域) |
int x; (函数作用域) |
int* p = malloc(sizeof(int)); |
理解这一个更简单的方法是看这个样例程序:
/* This is the definition of a uninitialized global variable */
int x_global_uninit;
/* This is the definition of a initialized global variable */
int x_global_init = 1;
/* This is the definition of a uninitialized global variable, albeit
* one that can only be accessed by name in this C file */
static int y_global_uninit;
/* This is the definition of a initialized global variable, albeit
* one that can only be accessed by name in this C file */
static int y_global_init = 2;
/* This is a declaration of a global variable that exists somewhere
* else in the program */
extern int z_global;
/* This is a declaration of a function that exists somewhere else in
* the program (you can add "extern" beforehand if you like, but it's
* not needed) */
int fn_a(int x, int y);
/* This is a definition of a function, but because it is marked as
* static, it can only be referred to by name in this C file alone */
static int fn_b(int x)
{
return x+1;
}
/* This is a definition of a function. */
/* The function parameter counts as a local variable */
int fn_c(int x_local)
{
/* This is the definition of an uninitialized local variable */
int y_local_uninit;
/* This is the definition of an initialized local variable */
int y_local_init = 3;
/* Code that refers to local and global variables and other
* functions by name */
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 编译器的工作是将一个 C 文件从人类(通常)可理解的文本形式,翻译为计算机可以理解的内容。编译器把这个输出为一个目标文件。在 Unix 平台上,这些目标文件通常有一个 .o 后缀;在 Windows 上,它们有一个 .obj 后缀。目标文件的内容主要有两类:
- 代码,对应 C 文件中的函数的定义
- 数据,对应 C 文件中全局变量的定义(对于初始化的全局变量,变量的初始值还必须保存在目标文件里)。
这些东西的实例将有关联的名字——产生它们的变量或函数定义的名字。
目标代码是对应 程序员 编写的 C 指令——那些 if 、 while 甚至 goto 的(恰当编码的)机器指令序列。所有这些指令需要操作某种信息,而这个信息需要被保存在某处——这就是变量的任务。代码还可以引用其他代码,特别的程序中的其他 C 函数。
在任何情况下,代码援引一个变量或函数,编译器仅在之前看到该变量或函数的一个声明时才允许——该声明是一个定义存在于整个程序某处的承诺。
链接器的任务是实现这些承诺,但在生成目标文件时,编译器如何处理这些承诺?
基本上,编译器留下一个空白。该空白(“引用”)有一个关联的名字,但对应这个名字的值尚未知。
记住这,我们可以将对应上面给出的程序的目标文件像这样展示:
分解目标文件
目前为止我们把一切都保持在一个高级的层次;看一下这在实践中如何工作是有用的。对此,关键的 工具 是命令 nm ,在 UNIX 平台上它给出关于目标文件中符号的信息。在 Windows 上,带有 /symbols 选项的 dumpbin 命令大致相同;也有 GNU binutils 工具的 Windows 移植版,这包括一个 nm.exe 。
让我们看一下从上面 C 文件产生的目标文件 nm 会给出什么:
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 ),但给出的关键信息有每个符号的类别,其大小(在可用时)。类别可以有若干不同的值:
- 类别 U 表示一个未定义的引用,之前提到的“空白”之一。这种对象有两类: fn_a 与 z_global (某些版本的 nm 可能还会输出一个节( section ),在这个情形里将是 *UND* 或 UNDEF )
- 类别 t 或 T 表示代码被定义的地方;不同的类别表示函数是这个文件局部的( t ),还是局部的( T )——即,函数是否一开始使用 static 声明。同样,某些系统还可能显示一个节,有点像 .text
- 类别 d 或 D 表示一个初始化的全局变量,同样特定的类别表示变量是本地( d )还是全局的( D )。如果存在一个节,它将有点像 .data
- 对于一个非初始化的全局变量,如果它是 static/local ,我们得到 b ,不是时 B 或 C 。在情形里节看起来有点像 .bss 或 *COM* 。
我们还可能得到某些不是原始输入 C 文件部分的符号;我们将忽略这些,因为它们通常是为了使你的程序得到链接的编译器的邪恶的内部机制。
链接器做什么:第一部分
之前我们提到,一个函数或变量的声明是,对 C 编译器,在程序某处有该函数或变量定义的承诺,而链接器的工作是实现这个承诺。面前摆着一个目标文件的图,我们也将这描述为“填充空白”。
为了展示这,对之前给出的 C 文件我们有一个伴随 C 文件:
/* Initialized global variable */
int z_global = 11;
/* Second global named y_global_init, but they are both static */
static int y_global_init = 2;
/* Declaration of another global variable */
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);
}
通过这两张图,我们可以看到所有点可以连起来(如果它们不能,链接器将发出一条错误消息)。一切按部就班,链接器可以如所示那样填充所有的空白(在 UNIX 系统上,通常使用 ld 来调用链接器)。
至于目标文件,我们可以使用 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++ 中,该情形是简单明了的。该语言有一个称为一次定义规则( one definition rule )的约束,它宣称在链接时刻,一个符号只能有一个定义,不多不少( C++ 标准的相关部分是 3.2 ,它还提到了某些例外,我们后面会提到)。
至于 C ,事情稍微模糊些。任何函数或初始化的全局变量必须恰好有一个定义,但未初始化全局变量的定义可处理为一个暂时定义( tentative definition )。 C 允许(或至少不禁止)不同的源文件对同一个对象有暂时定义。
不过,链接器还必须处理 C 与 C++ 以外的其他程序语言,对它们一次定义规则不总是适用的。例如, Fortran 的普通模式实际上在每个引用全局变量的文件中有一个拷贝;链接器被要求通过挑选其中一个拷贝(如果它们有不同的大小,选最大的)来取消重复(这个模式有时称为链接的“通用模型”,因为 Fortran 的 COMMON 关键字)。
因此,对 UNIX 链接器不抱怨符号的重复定义——至少,在重复符号是未初始化的全局变量时,是相当常见的(这有时称为链接的“ relaxed ref/def 模型”)。如果这使你担忧(很可能),查看你的编译器链接器文档——可能存在一个 --work-properly 选项来收紧这个行为。例如,对于 GNU 工具链,编译器选项 -fno-common 强制它将未初始化变量放入 BBS 段,而不是产生 common 块。
操作系统怎么做
现在链接器已经产生了一个可执行程序,符号的所有引用连接道路这些符号合适的定义处,我们需要暂停一下来理解在运行该程序时,操作系统做什么。
运行该程序显然涉及执行机器代码,因此操作系统显然必须将机器代码从硬盘上的可执行文件传输到计算机的内存,在那里 CPU 可以获取它。程序的内存块称为代码段或文本段。
没有数据,代码什么也不是,因此在计算机内存中,所有的全局变量还需要有某些空间。不过,在已初始化与未初始化全局变量间存在区别。已初始化变量有一开始需要使用的特定值,这些值保存在目标文件及可执行文件中。在程序启动时, OS 将这些值拷贝到在数据段的程序内存中。
至于未初始化变量, OS 可以假设它们都以初始值 0 开始,因此无需拷贝任何值。这块初始化为 0 的内存称为 bbs 段。
这意味着硬盘上的可执行文件中可以节省这块空间;已初始化变量的初始值必须保存在文件里,但未初始化变量,我们只需要它们需要多少空间的一个计数。
你可能注意到目前对目标文件及链接器的所有讨论仅涉及全局变量;没有提到局部变量及之前讲到的动态分配内存。
这些数据无需涉及链接器,因为它们的生命期仅是程序运行时——远在链接器完成了它的工作之后。不过,为了完整起见,这里我们可以很快指出:
- 局部变量分配在一块称为栈的内存上,栈随着函数的调用与完成生长、收缩
- 动态分配内存取自称为堆的区域, malloc 函数记录这个区域中所有可用的空间。
我们可以加入这些内存块来补全我们的运行进程内存空间的图景。因为堆与栈随着程序运行大小会变化,因此栈向一个方向增长,而堆向另一个方向增长是常见的。这样,当它们在中间相遇时,程序将耗尽内存(这时,内存空间真的填满了)。
链接器做什么:第二部分
既然我们已经看过了链接器操作非常基本的部分,我们可以继续描述更复杂的细节——大致是这些特性加入链接器的历史次序。
影响链接器功能的主要观察是:如果许多不同的程序需要做相同的事情(向屏幕输出,从硬盘读文件等),将这个代码集中在一个地方,让不同的程序使用它,是合理的。
在链接不同的程序时,使用相同的目标文件完全可行,但如果整组相关的目标文件被保存在一个容易访问的地方:库,会更容易得多。
(技术之外:本节完全跳过了链接器的一个主要特性:重定位。不同的程序有不同的大小,因此在共享库映射到不同程序的地址空间时,它将在不同的地址上。这反过来意味着库里所有的函数与变量在不同的位置里。现在,如果所有访问地址的方法都是相对的(距离这里值 +1020 字节),而不是绝对地址(在 0x102218BF处的值 ),这不是问题,但这不总是可能的。如果不可能,所有这些绝对地址需要加上一个合适的偏移值——这就是重定位。我不准备再提及这个话题,因为它几乎总是对 C/C++ 程序员不可见——因为重定位导致的链接问题很少见)
静态库
库最基本的化身是静态库。前一节提到通过重用目标文件,你可以共享代码;静态库被证明不比这复杂更多。
在 UNIX 系统上,生成静态库的命令通常是 ar ,它产生的库文件通常有一个 .a 扩展名。这些库文件通常也带有前缀“ lib ”,通过后跟没有扩展名或前缀的库名字的“ -l ”选项传递给链接器(因此“ -lfred ”将选中“ libfred.a ”)。
(历史上,一个称为 ranlib 的程序过去用于静态库,以在库开头构建符号索引。今天 ar 工具倾向于自己来做)。
在 Windows 上,静态库有 .LIB 扩展名,由 LIB 工具生成,但这令人混淆,因为相同的扩展名也用于“导入库( import library )”,导入库仅包含一个 DLL 中可用对象的列表——参考 Windows DLL 一节。
随着链接器闯过其要合并的目标文件集,它构建了一组尚不能解析的符号列表。在处理所有显式指定的对象时,现在链接器有另一个地方可以查找这个未解析列表中的对象——库。如果未解析符号定义在库的其中与目标文件里,那么加入这个目标文件,就像用户第一时间在命令行上给出它那样,链接继续。
注意从库导入的粒度:如果需要某个特定符号的定义,包含该符号的整个目标文件被包含。这意味着这个过程可以是前进的一步,可以是后退的一步——新加入的目标文件可能解决了一个未定义引用,但它可能带来自己的一组新未定义引用要链接器解决。
另一个要注意的重要细节是事件的次序;仅在完成普通的链接时,才询问库,它们依次处理,从左到右。这意味着如果从库导入的一个目标文件,在链接路线上需要一个更早出现的库的符号时,链接器将不能自动找到它。
一个例子有助于澄清这个问题;假设我们有以下目标文件,导入 a.o , b.o , -lx 与 -ly 的链接路线。
a.o |
b.o |
libx.a |
liby.a |
|||||
对象 |
a.o |
b.o |
x1.o |
x2.o |
x3.o |
y1.o |
y2.o |
y3.o |
定义 |
a1, a2, a3 |
b1, b2 |
x11, x12, x13 |
x21, x22, x23 |
x31, x32 |
y11, y12 |
y21, y22 |
y31, y32 |
未定义 引用 |
b2, x12 |
a3, y22 |
x23, y12 |
y11 |
y21 |
x31 |
一旦链接器处理了 a.o 与 b.o ,它将解决对 b2 与 a3 的引用,留下 x12 与 y22 仍未解析。这时,链接器对第一个库 libx.a 检查这些符号,发现它可以导入 x1.o 来满足对 x12 的引用;不过,这样做还引入了未定义引用 x23 与 y12 (因此,列表现在是 y22 , x23 与 y12 )。
链接器仍然在处理 libx.a ,因此通过从 libx.a 导入 x2.o , x23 的引用很容易解决。不过,这还向未定义列表加入了 y11 (现在是 y22 , y12 与 y11 )。这些都不能通过 libx.a 解决,因此链接器移到 liby.a 。
这里,应用相同的过程,链接器将读入 y1.o 与 y2.o 。 y1.o 首先加入了对 y21 的引用,但因为 y2.o 无论如何都被导入,这个引用容易解决。这个过程的净效应是所有未定义引用被解析,库的一些但不是全部目标文件被包含到最终的可执行文件里。
注意到,情形将稍有不同,如果,比如 b.o 还包含对 y32 的引用。如果是这样, libx.a 的链接将是相同的,但 liby.a 的处理还将导入 y3.o 。导入这个目标文件将加入未解析符号 x31 ,链接将失败——在这个阶段,链接器已经完成 libx.a 的处理,不能为这个符号找到定义(在 x3.o 中)。
(顺便提一下,这个例子在两个库 libx.a 与 liby.a 间存在循环依赖;这通常是一件坏事,特别在 Windows 上)
共享库
对像 C 标准库(通常 libc )这样的流行库,静态库显然是一个劣势——每个可执行程序都有相同代码的一份拷贝。这会占据大量不必要的硬盘空间,如果每个可执行文件都有 printf , fopen 等等的一个拷贝。
一个不那么明显的坏处是,一旦程序被静态链接,它的代码就被永久固定了。如果有人找到并修复了 printf 里的一个 bug ,每个程序必须重新链接以获取修正的代码。
为了绕开这些、那些的问题,引入了共享库(通常由 .so 扩展名来表示,或者在 Windows 机器上 .dll ,在 Mac OS X 上 .dylib )。对这些类型的库,正常的命令行链接器不一定会把所有的点都连接起来。相反,正常的链接器获取一类 IOU “纸币”,该“纸币”的支付推迟到该程序实际运行时。
这可以归结为:如果链接器发现一个特定符号的定义在一个共享库中,那么它不会在最终的可执行文件里包含该符号的定义。相反,链接器记录符号的名字,以及它应该从可执行文件中获得的库。
在程序运行时,操作系统会安排链接的剩余部分在程序运行时“及时”完成。在 main 函数运行之前,一个较小的链接器(通常称为 ld.so )仔细检查这些约定的“支付”,当场执行链接的最后步骤——导入库的代码,将所有点连接起来。
这意味着没有可执行文件拥有 printf 代码的拷贝。如果一个新的、修正的 printf 版本可用,可以通过改变 libc.so 偷偷插入——在程序下次运行时,它将被选中。
与静态库相比,共享库的工作有另一个大的差异,体现在链接的粒度上。如果从一个特定共享库导入一个特定符号(比如 libc.so 里的 printf ),那么整个共享库被映射到程序的地址空间。这与静态库非常不同,静态库仅导入持有未定义符号的特定目标文件。
换而言之,共享库自己是作为链接器运行的结果产生的(而不是像 ar 那样形成一大堆目标文件),同一个库里的目标文件间的引用得到解析。
再一次的, nm 是展示这的有用工具:对上面的例子库,当运行在该库的一个静态版本时,它将对单独的目标文件产生若干组结果,但对该库的共享版本, liby.so 仅有一个未定义符号 x31 。同样,对于前一节结尾的库次序例子,这将不是问题:把 y32 的引用加入 b.c 没有区别,因为 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)
这个更大粒度的原因是因为现代操作系统足够聪明,可以节省的不仅仅是静态库中发生的重复磁盘空间;使用相同共享库的不同执行进程还可以共享代码段(但不是数据 /bss 段——毕竟对两个不同的进程,它们的 strtok 可以在不同的地方)。为了这样做,整个库必须被一次映射,这样内部引用都集中到同一个地方——如果一个进程导入 a.o 与 c.o ,另一个导入 b.o 与 c.o ,将不存在 OS 可资利用的共同之处。
Windows DLL
虽然在 Unix 平台与 Windows 上共享库的一般性原则大致类似,有几个细节会使人大意失荆州。
导出符号
两者间最主要的差别是 Windows 库不会自动导出符号。在 Unix 上,来自构成共享库所有目标文件的所有符号,都对该库的使用者可见。在 Windows 上,程序员必须显式选择,使得特定的符号可见——即,导出它们。
有 3 中方式从一个 Windows DLL 导出一个符号(在同一个库中,所有这 3 种方式可以混合使用)。
- 在源代码中,将符号声明为 __declspec(dllexport) ,因而:
__declspec(dllexport) int my_exported_function(int x, double y);
- 在调用链接器时,对 LINK.e.xe 使用 /export:symbol_to_export 选项
LINK.exe /dll /export:my_exported_function
- 使链接器导入一个模块定义( .DEF )文件(通过使用 /DEF:def_file 选项),在该文件中包括一个包含希望导出符号的 EXPORTS 节。
EXPORTS
my_exported_function
my_other_exported_function
一旦 C++ 加入混战,第一个选择是最简单的,因为编译器会为你做好名字重整。
.LIB 与其他库相关文件
这干净利落地引出了 Windows 库的第二个复杂性:链接器组装所需的导出符号的信息不保存在 DLL 本身。相反,这个信息保存在一个对应的 .LIB 文件里。
与一个 DLL 相关的 .LIB 文件描述了在该 DLL 中出现哪些(导出)符号,连同它们的位置。任何其他使用该 DLL 的二进制代码需要看这个 .LIB 文件,使它可以正确连接符号。
为了添乱, .LIB 扩展名还用于静态库。
事实上,有很多不同的文件与 Windows 库相关。除了 .LIB 文件及前面提到的(可选的) .DEF 文件,你还会看到以下文件与你的 Windows 库相关。
- 链接输出文件:
- library .DLL :库代码本身;任何使用该库的可执行文件(运行时)需要。
- library .LIB :一个描述了在输出 DLL 中何处有哪些符号的“导入库”文件。仅在 DLL 导出某些符号时,才产生这个文件;如果不导出符号,没有理由存在 .LIB 文件。该文件为任何使用这个库的对象在链接时所需。
- library .EXP :用于要链接的库的一个“导出文件”,在链接具有循环依赖的二进制代码时所需。
- library .ILK :如果向链接器指定了 /INCREMENTAL 选项,使能了增量链接,这个文件保存了增量链接的状态。为该库将来的质量链接所需。
- library .PDB :如果向链接器指定了 /DEBUG 选项,这个文件是包含了该库所需调试信息的程序数据库。
- library .MAP :如果向链接器指定了 /MAP 选项,这个文件保存了该库内部布局的描述。
- 链接输入文件
- library .LIB :一个描述了为被链接对象所需的任何 DLL 中何处有哪些符号的“导入库”文件。
- library .LIB :一个包含了为被链接对象所需的一组目标文件的静态库。注意 .LIB 扩展名使用的二义性。
- library .DEF :一个允许控制已链接库各种细节,包括符号导出的“模块定义”文件。
- library .EXP :用于将被链接库的“导出文件”,它可以表示用于库的 LIB.EXE 之前的运行已经为该库创建了 .LIB 文件。与链接具有循环依赖的二进制代码相关。
- library .ILK :增量链接状态文件;参考上面。
- library .RES :包含可执行文件使用的各种 GUI 小玩意信息的资源文件;这些被包含在最终的二进制文件里。
这与 Unix 形成对比,在这些额外文件中保存的大部分信息,在 Unix 中(通常)包含在库本身。
导入符号
除了要求 DLL 显式声明它们要导出哪些符号, Windows 还允许使用库代码的二进制代码显式声明它们要导入哪些符号。这是可选的,但由于 16 位 Windows 的某些历史性的特性,会给速度优化。
为此,在源代码中把符号声明为 __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 被定义,因此进行符号的导出。其他使用这个头文件的代码不定义这个符号,因此表明这些符号的导入。
循环依赖
DLL 的最后一个复杂之处是, Windows 比 Unix 更严格,在于它要求在链接时刻每个符号必须被解析。在 Unix 上,链接一个带有链接器看不到的未解析符号的共享库是可以的;在这种情况下,载入这个共享库的代码必须提供这个符号,否则程序会载入失败。 Windows 不允许这种放纵。
这大多数系统中,这不是个问题。可执行文件依赖高级库,高级库依赖较低级的库,以反序每个对象得到链接——首先是低级库,然后高级库,最后完全依赖它的可执行文件。
不过,如果在二进制代码间存在循环依赖,事情就变得棘手了。如果 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 。这几乎与普通的完全相同,但传统上它包括了在第一步创建的 X.EXP 文件。如常,这个链接将导入前面步骤的 Y.LIB 文件,并创建一个 X.DLL 文件。不同于一般,链接将跳过 X.LIB 文件的创建过程,因为在第一步已经创建了(这就是 .EXP 文件所指出的)。
当然,更好的想法通常是重新组织库,使得不存在循环依赖……
向图景中加入 C++
C++ 在 C 的基础上提供了若干额外的特性,其中一些特性与链接器操作交互。这不是最初的情形——第一个 C++ 实现作为一个 C 编译器前端出现,因此链接器的后端无需改动——但随着时间推移,加入了复杂的特性,链接器必须增强来支持它们。
函数重载与名字重整
C++ 允许的第一个改变是重载函数的能力,因此可以有同名函数的不同版本,区别在于函数接受的类型(函数的签名, signature ):
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 时,它指的是哪个?
对此采取的解决方案称为名字重整,因为关于该函数签名的所有信息被融合为一个文本形式,成为链接器看到的符号的实际名字。不同签名的函数重整为不同的名字,因此唯一性问题消失了。
我不准备进入所用方案的细节(不同的平台上它不一样),但快速看一下对应上面代码的目标文件会给出某些暗示(记住, 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
这里,我们可以看到我们 3 个称为 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
这里的提示是,错误消息包括函数的签名——它不只是抱怨缺少简单、旧式的 findmax 。换而言之, C++ 代码实际上查找像 _Z7findmaxii 这样的东西,但仅找到 findmax ,因此链接失败。
随便提一下,对成员函数 extern “C” 链接声明被忽略( C++ 标准的 7.5.4 )。
静态对象的初始化
C++ 超过 C ,影响链接器的下一个特性是,拥有对象构造函数的能力。构造函数是一段设置一个对象内容的代码;就这点而言,理论上它等同于一个变量的初始化值,但关键的、实际区别是它涉及任意代码片段。
回忆前面的章节,一个全局变量一开始可以有一个特定值。在 C 里,构造这样一个全局变量的初始值是简单的:特定的值只是从可执行文件的数据段拷贝到很快会运行的程序内存的相关位置。
在 C++ 中,允许比拷贝一个固定值复杂得多的构造过程;在程序本身开始正确运行之前,必须运行类层次中各个构造函数里的代码。
为了处理这,对每个 C++ 文件,编译器在目标文件里包含了一些额外信息;特别地,需要对这个特定文件调用的构造函数列表。在链接时,链接器将这些列表合并为一个大列表,并包括遍历该列表,依次调用所有这些全局对象构造函数的代码。
注意,全局对象所有这些构造函数被调用的次序是没有定义的——完全取决于链接器怎么选择(更多细节参考 Scott Meyer 的 Effective C++—— 第二版的第 47 项,第三版的第 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);
对于这个代码, nm 的(去重整)输出给出:
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
这里有各种东西,但我们感兴趣的是类别为 W 的两项(这表示一个“弱”符号),以及名字像 .gnu.linkonce.t. stuff 的节。这些是全局对象构造函数的标记,我们可以看到对应的 Name 域看起来是合理的——这两个构造函数的使用各对应一个。
模板
在之前的章节,我们给出了 max 函数 3 个不同版本的例子,每个接受不同类型的实参。不过,我们可以看到这 3 个函数的源代码完全相同,拷贝、黏贴相同的代码是羞耻的。
C++ 引入了模板的思想,允许像这样的代码为所有的定义只写一次。我们可以创建头文件 max_template.h ,带有唯一的 max 代码:
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); // Compiler automatically figures out that max<int>(int,int) is needed
double x = 1.1;
float y = 2.2;
double z;
z = max<double>(x,y); // Compiler can't resolve, so force use of max(double,double)
return 0;
}
这个 C++ 文件使用 max<int>(int, int) 与 max<double>(double, double) ,但别的 C++ 文件可能使用该模板不同的具现——比如 max<float>(float, float) ,甚至 max<MyFloatingPointClass>(MyFloatingPointClass, MyFloatingPointClass) 。
该模板每个这些不同的具现涉及实际不同的机器代码,因此在程序最终被链接时,编译器与链接器需要确保程序包含了该模板被使用的每个具现的代码(没有包含未使用的模板具现,从而使程序膨胀)。
那么它们怎么做到的?这通常有两种解决方式:折叠重复具现,或者将具现推迟到链接时刻(我喜欢把这些称为理智( sane )方式与 Sun 方式)。
对重复具现的做法,每个目标文件包含所使用的所有模板的代码。对于上面特定的 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, int) 与 max<double>(double, double) 都出现了。
这些定义被列为弱符号,这意味着在链接器生成最后的可执行程序时,它可以扔掉所有这些重复定义,除了一个(而如果它觉得资源足够,它可以检查所有重复定义实际上看起来是相同的代码)。这个做法最主要的缺点是,所有单独的目标文件在硬盘上占据多得多的空间。
另一个做法(由 Solaris C++ 编译器套件使用)是在目标文件中不包含任何模板定义,而是把它们留作未定义符号。在链接时刻,链接器收集起所有实际对应于模板具现的未定义符号,当场为这些具现生成机器代码。
这节省了在单独目标文件里的空间,但缺点是链接器需要记录头文件在哪里包含了源代码,需要能够在链接时调用 C++ 编译器(这会减慢链接速度)。
动态载入库
本文里我们讨论的最后一个特性是动态载入共享库。之前的章节描述了使用共享库如何意味着最后的链接被推迟到程序运行的时刻。在现代系统上,推迟链接到更晚也是可能的。
这由一对系统调用完成, dlopen 与 dlsym ( Windows 大致对等调用是 LoadLibrary 与 GetProcAddress )。第一个调用接受一个共享库名,把它载入运行进程的地址空间。当然,这个额外的库自己可能有未定义符号,因此这个对 dlopen 的调用也可能触发其他共享库的载入。
dlopen 也允许选择是在库载入一刻解析所有的这些引用( RTLD_NOW ),还是在命中每个未定义引用时依次解析( RTLD_LAZY )。第一个方式意味着 dlopen 调用会需要长得多的时间,但第二个方式稍有风险,程序稍后可能会发现存在不能解析的未定义引用——这时程序将终止。
当然,来自动态载入库的符号没有方法拥有名字。不过,如同编程问题,这很容易通过增加额外一层间接性来解决——在这个情形里,对符号使用指向该空间的一个指针,而不是通过名字援引它。 dlsym 调用接受一个过程要查找符号名的字符串参数,并返回指向其位置的一个指针(或者 NULL ,如果不能找到)。
与C++特性交互
这个动态载入特性非常闪耀,但它如何与各种影响链接器整体行为的 C++ 特性交互呢?
第一个观察是,重整名有点棘手。
在调用 dlsym 时,它接受包含要查找符号名的字符串。这必须是该名字的链接器可见版本;换而言之,该名字的重整版本。
因为特定的名字重整方案随平台及编译器而不同,这意味着以一个可移植的方式动态定位一个 C++ 符号几乎不可能。即使你很高兴地坚守一个特定的编译器,并探究其内部,还有更多问题要发生——除了普通的类 C 函数外的一切,你必须操心虚表导入,诸如此类。
总而言之,坚持众所周知的,可被 dlsym 的单个 extern “C” 入口通常是最好的;这个入口可以是一个返回指向一个 C++ 类完整实例指针的工厂方法,允许使用 C++ 的所有好处。
编译器可以在一个 dlopen 打开的库中挑选出全局对象的构造函数,因为在该库中可以定义几个特殊符号,在动态载入或卸载该库时,链接器可以调用(不管载入时还是运行时)——因此必要的构造函数与析构函数可以放在那里。在 Unix 中,这些函数称为 _init 与 _fini ,至于使用 GNU 工具链的较新的系统,这些是任何被标记为 __attribute__((constructor)) 或者 __attribute__((destructor)) 的函数。在 Windows 里,相关的函数是带有一个 reason 参数或者 DLL_PROCESS_ATTACH 或 DLL_PROCESS_DETACH 的 DllMain 。
最后,动态载入与模板具现的“折叠重复”的方法工作良好,但与“链接时编译模板”的方法就要棘手得多——在这个情形里,“链接时刻”在程序运行后(可能在保存源代码以外的机器上)。绕过的方法参考编译器与链接器文档。
更多细节
本文的内容故意跳过了关于链接器如何工作的大量细节,因为我发现这里描述的程度已经覆盖了程序员在他们程序的链接步骤中遭遇的日常问题的 95% 。
如果你希望深入,一些额外的参考文献有:
- John Levine, Linkers and Loaders :包含了关于链接器与载入器工作细节的大量信息,包括所有我在这里跳过的(重定位)。 这里 看起来有一个在线版本(或者它一个早期的草稿)
- Excellent link on the Mach-O format for binaries on Mac OS X [Added 27-Mar-06]
- Peter Van Der Linden, Expert C Programming :极好的书,包括了比我见过的任何其他书更多的关于C代码如何翻译为运行程序的信息
- Scott Meyers, More Effective C++ :项目34讨论了在同一个程序里组合C与C++的陷阱(不管是否链接器相关)。
- Bjarne Stroustrup, The Design and Evolution of C++ :11.3节讨论了C++里的链接以及它是怎么产生的
- Margaret A. Ellis & Bjarne Stroustrup, The Annotated C++ Reference Manual :7.2c节描述了一个特定的名字重整方案
- ELF format reference [PDF]
- Two interesting articles on creating tiny Linux executables and a minimal Hello World in particular.
- "How To Write Shared Libraries" [PDF] by Ulrich Drepper has more details on ELF and relocation.
非常感谢 Mike Capp 与 Ed Wilson 对本文提出的有用的建议。
Copyright (c) 2004-2005, 2009-2010 David Drysdale
Permission is granted to copy, distribute and/or modify this document under the terms of the GNU Free Documentation License, Version 1.1 or any later version published by the Free Software Foundation; with no Invariant Sections, with no Front-Cover Texts, and with no Back-Cover Texts. A copy of the license is available here .
以上所述就是小编给大家介绍的《[译]给初学者的链接器指南》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!
猜你喜欢:本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
内容创业:内容、分发、赢利新模式
张贵泉、张洵瑒 / 电子工业出版社 / 2018-6 / 49
越来越多的内容平台、行业巨头、资本纷纷加入内容分发的战争中,竞争非常激烈。优质的原创性内容将成为行业中最宝贵的资源。在这样的行业形势下,如何把内容创业做好?如何提高自身竞争力?如何在这场战争中武装自己?是每一位内容创业者都应该认真考虑的问题。 《内容创业:内容、分发、赢利新模式》旨在帮助内容创业者解决这些问题,为想要进入内容行业的创业者出谋划策,手把手教大家如何更好地进行内容创业,获得更高的......一起来看看 《内容创业:内容、分发、赢利新模式》 这本书的介绍吧!
HTML 压缩/解压工具
在线压缩/解压 HTML 代码
CSS 压缩/解压工具
在线压缩/解压 CSS 代码