给初学者的链接器指南
日期: 2018-11-09 分类: 个人收藏 374次阅读
原文地址: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.
除特别声明,本站所有文章均为原创,如需转载请以超级链接形式注明出处:SmartCat's Blog
标签:编译器技术 链接 C
上一篇: 【设计模式】-01单例模式
精华推荐