- 论坛徽章:
- 0
|
连接器和加载器之连接和加载
连接:一个真实的例子
我们以一个很小但真实的连接实例来结束对连接的介绍。图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字节。当然,这只是一个玩具性的例子,但真实的程序也能够同样戏剧性地节省空间。
练习
将连接器和加载器划分为分离的程序有什么好处?在哪些情况下一个组合的连接加载器才是有用的?
过去的50年里产生的几乎所有操作系统都包含了一个连接器。为什么?
在这一章里我们讨论了对汇编或编译过的代码所进行的连接和加载。在一个能够直接解释源语言代码的纯解释型系统中,连接器和加载器是否有用呢?在一个能够将源代码转换为中间表示的系统中,如P-code或Java虚拟机中呢? |
|