[b:6cc125f321]目标代码库[/b:6cc125f321]
所有的连接器都以一种形式或另一种形式支持目标代码库,很多连接器还提供对多种共享库的支持。
目标代码库的基本原则非常简单,如图2所示。一个库和一组目标代码文件差不多。(甚至,在一些系统上你可以逐字地将一组目标文件绑定到一起并作为一个连接库。)在连接器处理完所有的常规输入文件后,如果仍然有某些导入的名字是未定义的,它会继续处理库并连接那些能够导出这些未定义名字的库。
图1-2:目标代码库
目标文件首先进入连接器,接着是包含很多文件的库。
[img:6cc125f321]http://bbs.chinaunix.net/forum/uploadfile/figer1-2.jpg[/img:6cc125f321]
共享库使得这个任务变得有些复杂,因为它将连接时的一部分工作转移到了加载时。连接器在运行的时候识别能够解决未定义名字的共享库,但不想程序中连接任何东西,连接器向输出文件中注明能够在哪个库中找到相应的符号,以使得相应的库能够在程序加载的时候被绑定。细节请参见第9章和第10章。

|
| | compnik 回复于:2004-10-11 17:47:39
| [b:f9c4c236e6]重定位和代码修正[/b:f9c4c236e6]
连接器和加载期的动作的核心就是重定位和代码修正。当一个编译器或汇编器生成一个目标文件时,它所生成的代码使用文件中定义的代码和数据的非重定位地址,而且通常用零表示在其他地方定义的代码和数据。作为连接过程的一部分,连接器修正目标代码以反映实际的地址分配。例如,考虑这段用于通过使用 eax寄存器将变量a中的内容移到变量b中的x86代码。
mov a, %eax
mov %eax, b
如果a在同一个文件的十六进制位置1234处定义而b是从其他地方导入的,生成的目标代码将是:
A1 34 12 00 00 mov a, %eax
A3 00 00 00 00 mov %eax, b
每个指令都包含了一个单字节的操作码后跟一个四字节的地址。第一条指令有一个对1234(字节颠倒的,因为x86使用自优至左的字节顺序)的引用,而第二条指令有一个对0的引用,因为b是未知的。
现在假设连接器连接了这段代码,导致a所在的节被定位在十六进制位置10000字节处,而b出现在十六进制位置9A12。连接器将代码修正为:
A1 34 12 01 00 mov a, %eax
A3 12 9A 00 00 mov %eax, b
也就是说,它将10000加到第一条指令的地址上使其引用a的重定位地址11234,并且修正了b的地址。这些调整不仅影响到指令,一个目标文件的数据部分中的所有指针都要同时调整。
在老式的、具有很小的地址空间并且是直接寻址的计算机上,这个修正过程相当简单,连接器仅仅仅仅需要处理一种或两种地址格式。现代计算机,包括所有的RISC,都要求相当复杂的代码修正。没有哪个单独的指令能够包含足够的位以保存一个直接地址,因此编译器和连接器必须使用更加复杂的寻址技巧以处理任意地址上的数据。在某些情况下,它可能需要两条或三条指令来合成一个地址,其中每一条指令包含地址的一部分,然后使用位操作来将这些部分组合为一个完整的地址。在这种情况下,连接器必须准备好对每一条指令进行适当的修正,如向每条指令中插入一个地址的某些位。其他情况下,一个或一组例程中用到的所有地址被放到一个数组中作为一个“地址池(address pool),初始化代码将一个机器寄存器设置为指向该数组的指针,而代码在需要加载该地址池之外的指针时将以该寄存器作为一个基址寄存器。连接器可能必须通过一个程序中用到的所有地址来建立这个数组,然后修正指令使得它们能够引用地址池的适当入口。我们将这些内容放到了第7章。
一些系统要求不论加载到哪些地址空间都能正确工作的地址无关代码(position independent code)。连接器通常必须提供附加的技巧以支持它,如将不能做到地址无关的部分分离出来,以及安排这两部分进行通信。(参见第8章)
[b:f9c4c236e6]编译驱动器[/b:f9c4c236e6]
在很多情况下,连接器的操作对于程序员来说是不可见的,或者几乎是这样,因为它作为编译过程的一部分被自动地运行了。很多编译系统具有一个编译驱动器(compiler driver),可以根据需要自动地调用编译器的各个阶段。例如,如果程序员有两个C语言源文件,Unix系统上的编译驱动器将会像这样运行一系列程序:
在文件A上运行C预处理器,创建经过预处理的A
在经过预处理的A上运行C编译器,创建汇编文件A
在汇编文件A上运行汇编器,创建目标文件A
在文件B上运行C预处理器,创建经过预处理的B
在经过预处理的B上运行C编译器,创建汇编文件B
在汇编文件B上运行汇编器,创建目标文件B
在目标文件A和B以及系统C库上运行连接器
也就是说,它将每一个文件编译为汇编代码然后是目标代码,并且将这些目标代码包括需要的系统C库中的例程连接到一起。
编译驱动器通常比这要聪明。它们通常比较源文件和目标文件的创建日期,并且只编译更改过的源文件。(Unix中的make程序是一个典型的例子。)尤其是当编译C++和其他面向对象语言时,编译驱动器运用各种技巧来围绕着连接器或对象格式的限制工作。例如,C++模板定义了一个可能无穷的相关例程组,因此通过找到程序实际使用的有限的模板例程组,一个编译驱动器能够不使用模板例程而将程序的目标文件连接到一起、通过读取连接器的错误消息来得知什么是未定义的、调用C++编译器来产生必要的模板例程的目标代码以及重连接。我们将在第11章涵盖了一些这样的技巧。
[b:f9c4c236e6]连接器命令语言[/b:f9c4c236e6]
每个连接器都有一些命令语言来控制连接过程。至少连接器需要包含目标文件和要连接的库的列表。通常还需要一个包含可能的选项的很长的列表:是否要保存调试符号、是使用共享库还是非共享库、使用多种可能的输出格式中的哪一个等等。很多连接器允许通过一些途径来指定连接后的代码所绑定的位置,这在使用一个这样的连接器来连接一个系统内核或其他不需要受操作系统控制的程序时是派得上用场的。在支持多重代码和数据段的连接器中,连接器命命令语言可以指定段被连接的顺序、对某些段进行特殊对待以及其他一些应用指定的选项。
有四种通用技术用于将命令传递给连接器:
写在命令行中:很多系统具有一个命令行或其等价物,通过它我们可以传递混合了的文件名和选项开关。这是Unix和Windows连接器的常用途径。在那些具有命令行长度限制的系统上,通常可以指示连接器去从一个文件中读取命令并将它们视为是通过命令行读取的。
混合在目标文件中:有些连接器,如IBM大型机连接器,在一个单独的输出文件中可以接受可选的目标文件和连接器命令。这要追溯到打孔卡片的年月,当时的人们积累目标卡片并在一个读卡机上对命令卡片进行手工打孔。
嵌入到目标文件中:有些连接器,特别如Microsoft的,允许将连接器命令嵌入到目标文件中。这允许一个编译器把对于一个文件所需要的连接命令放在这个文件自身中进行传递。例如,C编译器可以传递命令以搜索标准C库。
分离的配置语言:很少一部分连接器具有一个羽翼丰满的用于控制连接的配置语言。GUN连接器,可以处理数量庞大的目标文件格式、机器架构以及地址空间转换,它有一个复杂的控制语言,能够允许程序员指定要连接的段的顺序、相似段的结合规则、段地址以及范围广阔的其他选项。其他连接器具有不那么复杂的语言来处理特定的特性如程序员定义的复用。
| | compnik 回复于:2004-10-11 17:49:12
| [b:86cddc92b0]连接:一个真实的例子[/b:86cddc92b0]
我们以一个很小但真实的连接实例来结束对连接的介绍。图3显示了一对C语言源文件,m.c有一个主程序,调用了名为a的例程,而a.c包含了这个例程,它又调用了库例程strlen和write。
图1-3:源文件
源文件m.c
extern void a(char *);
int main(int ac, char **av)
{
static char string[] = "Hello, world!\n";
a(string);
}
源文件a.c
#include <unistd.h>
#include <string.h>
void a(char *s)
{
write(1, s, strlen(s));
}
主程序m.c在我的Pentium机上被GCC编译为一个165字节的目标文件,具有典型的a.out目标格式,如图4所示。这个目标文件包含一个固定长度的头、16字节的包含只读程序代码的text段和16字节的包含了string的data段。这些之后是两个重定位入口,其中一个标记了在准备调用a时用来将string的地址放到栈顶的pushl指令,令一个标记了用于将控制转移到a中的call指令。符号表导出对_main的定义,导入 _a,并为调试器包含了两个其他符号。(每个全局符号都带有一个前导下划线,其原因在第五章中讲述。)注意pushl指令引用了十六进制地址10—— string的暂时地址,因为它在同一个目标文件中;而call引用了地址0,因为_a的地址是未知的。
图1-4:m.o的目标代码
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000010 00000000 00000000 00000020 2**3
1 .data 00000010 00000010 00000010 00000030 2**3
Disassembly of section .text:
00000000 <_main>:
0: 55 pushl %ebp
1: 89 e5 movl %esp, %ebp
3: 68 10 00 00 00 pushl $0x10
4: 32 .data
8: e8 f3 ff ff ff call 0
9: DISP32 a
d: c9 leave
e: c3 ret
...
子程序文件a.c被编译为一个160字节的目标文件,如图5所示,具有头、一个28字节的text段并且没有data段。两个入口标记了对strlen和write的调用,符号表导出_a并且导入_strlen和_write。
图1-5:a.o的目标代码
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 0000001c 00000000 00000000 00000020 2**2
CONTENTS, ALLOC, LOAD, RELOC, CODE
1 .data 00000000 0000001c 0000001c 0000003c 2**2
CONTENTS, ALLOC, LOAD, DATA
Disassembly of section .text:
00000000 <_a>:
0: 55 pushl %ebp
1: 89 e5 movl %esp, %ebp
3: 53 pushl %ebx
4: 8b 5d 08 movl 0x8(%ebp), %ebx
7: 53 pushl %ebx
8: e8 f3 ff ff ff call 0
9: DISP32 _strlen
d: 50 pushl %eax
e: 53 pushl %ebx
f: 6a 01 pushl $0x1
11: e8 ea ff ff ff call 0
12: DISP32 _write
16: 8d 65 fc leal -4(%ebp), %esp
19: 5b popl %ebx
1a: c9 leave
1b: c3 ret
为了产生可执行程序,连接器要合并这两个目标文件和一个C程序的标准启动初始化例程,以及C库中必要的例程。产生的部分可执行文件如图6所示。
图1-6:可执行程序选段
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000fe0 00001020 00001020 00000020 2**3
1 .data 00001000 00002000 00002000 00001000 2**3
2 .bss 00000000 00003000 00003000 00000000 2**3
Disassembly of section .text:
00001020 <start-c>:
...
1092: e8 0d 00 00 00 call 10a4 <_main>
...
000010a4 <_main>:
10a4: 55 pushl %ebp
10a5: 89 e5 movl %esp, %ebp
10a7: 68 24 20 00 00 pushl $0x2024
10ac: e8 03 00 00 00 call 10b4 <_a>
10b1: c9 leave
10b2: c3 ret
...
000010b4 <_a>
10b4: 55 pushl %ebp
10b5: 89 e5 movl %esp, %ebp
10b7: 53 pushl %ebx
10b8: 8b 5d 08 movl 0x8(%ebp), %ebx
10bb: 53 pushl %ebx
10bc: e8 37 00 00 00 call 10f8 <_strlen>
10c1: 50 pushl %eax
10c2: 53 pushl %ebx
10c3: 6a 01 pushl $0x1
10c5: e8 a2 00 00 00 call 116c <_write>
10ca: 8d 65 fc leal -4(%ebp), %esp
10cd: 5b popl %ebx
10ce: c9 leave
10cf: c3 ret
...
000010f8 <_strlen>:
...
0000116c <_write>:
...
连接器合并了每个文件中的对应段,因此这里有一个合并了的text段、一个合并了的data段以及一个bss段(初始化为0的段,两个输入文件都没有使用)。每个段都被填充至4K边界以匹配x86页面尺寸,因此text段为4K(减去一个出现在文件中但不是短的逻辑部分的20字节的a.out 头),data和bss段也分别是4K。
合并了的text段包含成为start-c的库启动代码;然后是来自m.o的text段,被重定位到10a4;来自a.o的,被重定位到 10b4;以及连接自C库的例程,被重定位到text的更高的地址处。合并后的data段这里没有显示,它的合并次序和text段的合并次序相同。由于_main的代码被重定位到十六进制地址10a4,因此这个地址被填到了 start-c中的call指令里。在_main例程中,对string的引用被重定位到十六进制地址2024——string在data段中的最终地址,其中的调用地址被修正为10b4——_a的最终地址。在_a中,对_strlen和_write的调用地址也被修正为这两个例程的最终地址。
最后的可执行程序中还包括了很多其他来自C库的例程,这里没有显示,它们直接或间接地由启动代码或_write调用(如出错时调用的错误处理例程)。可执行程序不包含重定位数据,因为文件格式不是可重连接的,而且操作系统会将它加载到一个可知的固定地址处。它还会包含一个符号表以备调试器所用,尽管这个可执行程序并不使用符号而且符号表可以被去除以节省空间。
在这个例子中,连接自库的代码比程序本身的代码要大很多。这很平常,尤其是当程序使用了巨大的图形或窗口库时,这正是促进共享库(参见第9章和第 10章)出现的原因。连接后的程序为8K,而同样的程序使用共享库则只有264字节。当然,这只是一个玩具性的例子,但真实的程序也能够同样戏剧性地节省空间。
[b:86cddc92b0]练习[/b:86cddc92b0]
将连接器和加载器划分为分离的程序有什么好处?在哪些情况下一个组合的连接加载器才是有用的?
过去的50年里产生的几乎所有操作系统都包含了一个连接器。为什么?
在这一章里我们讨论了对汇编或编译过的代码所进行的连接和加载。在一个能够直接解释源语言代码的纯解释型系统中,连接器和加载器是否有用呢?在一个能够将源代码转换为中间表示的系统中,如P-code或Java虚拟机中呢?
| | 小雨加雪 回复于:2004-10-11 19:02:47
| 好象是《linder & loader》翻译过来的吧!
| | dj_ukyo 回复于:2004-10-11 20:31:54
| 还没有看完,要好好学习
|
|
|