一个程序要想在内存中运行,除了编译之外还要经过链接和装入这两个步骤。从程序员的角度来看,引入这两个步骤带来的好处就是可以直接在程序中使用 printf和errno这种有意义的函数名和变量名,而不用明确指明printf和errno在标准C库中的地址。当然,为了将程序员从早期直接使用地址编程的梦魇中解救出来,编译器和汇编器在这当中做出了革命性的贡献。编译器和汇编器的出现使得程序员可以在程序中使用更具意义的符号来为函数和变量命名,这样使得程序在正确性和可读性等方面都得到了极大的提高。但是随着C语言这种支持分别编译的程序设计语言的流行,一个完整的程序往往被分割为若干个独立的部分并行开发,而各个模块间通过函数接口或全局变量进行通讯。这就带来了一个问题,编译器只能在一个模块内部完成符号名到地址的转换工作,不同模块间的符号解析由谁来做呢?比如前面所举的例子,调用printf的用户程序和实现了printf的标准C库显然就是两个不同的模块。实际上,这个工作是由链接器来完成的。
为了解决不同模块间的链接问题,链接器主要有两个工作要做――符号解析和重定位:
符号解析:当一个模块使用了在该模块中没有定义过的函数或全局变量时,编译器生成的符号表会标记出所有这样的函数或全局变量,而链接器的责任就是要到别的模块中去查找它们的定义,如果没有找到合适的定义或者找到的合适的定义不唯一,符号解析都无法正常完成。
重定位:编译器在编译生成目标文件时,通常都使用从零开始的相对地址。然而,在链接过程中,链接器将从一个指定的地址开始,根据输入的目标文件的顺序以段为单位将它们一个接一个的拼装起来。除了目标文件的拼装之外,在重定位的过程中还完成了两个任务:一是生成最终的符号表;二是对代码段中的某些位置进行修改,所有需要修改的位置都由编译器生成的重定位表指出。
举个简单的例子,上面的概念对读者来说就一目了然了。假如我们有一个程序由两部分构成,m.c中的main函数调用f.c中实现的函数sum:
m.c
-------------------------------
1 int i = 1;
2 int j = 2;
3 extern int sum();
4 int main()
5 {
6 int s;
7 s = sum(i, j);
8 return s;
9 }
f.c
-------------------------------
1 int sum(int i, int j)
2 {
3 return i + j;
4 }
在Linux用gcc分别将两段源程序编译成目标文件:
$ gcc -c m.c
$ gcc -c f.c
我们通过objdump来看看在编译过程中生成的符号表和重定位表:
$ objdump -x m.o
......
SYMBOL TABLE:
......
00000000 g O .data 00000004 i
00000004 g O .data 00000004 j
00000000 g F .text 00000021 main
00000000 *UND* 00000000 sum
RELOCATION RECORDS FOR [.text]:
OFFSET TYPE VALUE
00000007 R_386_32 j
0000000d R_386_32 i
00000013 R_386_PC32 sum
$ nm m.o
00000000 D i
00000004 D j
00000000 T main
---------U sum
首先,我们注意到符号表里面的sum被标记为UND(undefined),也就是在m.o中没有定义,所以将来要通过ld(Linux下的链接器)的符号解析功能到别的模块中去查找是否存在函数sum的定义。另外,在重定位表中有三条记录,指出了在重定位过程中代码段中三处需要修改的位置,分别位于7、 d和13。下面以一种更加直观的方式来看一下这三个位置:
$ objdump -dx m.o
01 Disassembly of section .text:
02 00000000 <main>:
03 0: 55 push %ebp
04 1: 89 e5 mov %esp,%ebp
05 3: 83 ec 04 sub $0x4,%esp
06 6: a1 00 00 00 00 mov 0x0,%eax
07 7: R_386_32 j
08 b: 50 push %eax
09 c: a1 00 00 00 00 mov 0x0,%eax
10 d: R_386_32 i
11 11: 50 push %eax
12 12: e8 fc ff ff ff call 13 <main+0x13>
13 13: R_386_PC32 sum
14 17: 83 c4 08 add $0x8,%esp
15 1a: 89 c0 mov %eax,%eax
16 1c: 89 45 fc mov %eax,0xfffffffc(%ebp)
17 1f: c9 leave
18 20: c3 ret
$ nm f.o
00000000 T sum
以sum为例,对函数sum的调用是通过call指令实现的,使用IP相对寻址方式。可以看到,在目标文件m.o中,call指令位于从零开始的相对地址 12的位置,这里存放的e8是call的操作码,而从13开始的4个字节存放着sum相对call的下一条指令add的偏移。显然,在链接之前这个偏移量是不知道的,所以将来要来修改13这里的代码。那现在这里为什么存放着0xfffffffc(注意Intel的CPU使用little endian的编址方式)呢?这大概是出于安全的考虑,因为0xfffffffc正是-4的补码表示(读者可以在gdb中使用p /x -4查看),而call指令本身占用了5个字节,因此无论如何call指令中的偏移量不可能是-4。我们再看看重定位之后call指令中的这个偏移量被修改成了什么:
$ gcc m.o f.o
$ objdump -dj .text a.out | less
Disassembly of section .text:
......
080482c4 <main>:
......
80482d6: e8 0d 00 00 00 call 80482e8 <sum>
80482db: 83 c4 08 add $0x8,%esp
......
080482e8 <sum>:
......
$ nm a.out
08049468 d _DYNAMIC
0804953c d _GLOBAL_OFFSET_TABLE_
0804844c R _IO_stdin_used
---------w _Jv_RegisterClasses
08049458 d __CTOR_END__
08049454 d __CTOR_LIST__
08049460 d __DTOR_END__
0804945c d __DTOR_LIST__
08048450 r __FRAME_END__
08049464 d __JCR_END__
08049464 d __JCR_LIST__
08049564 A __bss_start
08049550 D __data_start
08048400 t __do_global_ctors_aux
080482f0 t __do_global_dtors_aux
08049554 D __dso_handle
---------w __gmon_start__
080483fa T __i686.get_pc_thunk.bx
08049454 d __init_array_end
08049454 d __init_array_start
08048390 T __libc_csu_fini
080483a0 T __libc_csu_init
---------U __libc_start_main@@GLIBC_2.0
08049564 A _edata
08049568 A _end
0804842c T _fini
08048448 R _fp_hw
08048254 T _init
080482c0 T _start
08049564 b completed.5843
08049550 W data_start
08048320 t frame_dummy
0804955c D i
08049560 D j
08048344 T main
08049558 d p.5841
0804837c T sum
可以看到经过重定位之后,call指令中的偏移量修改成0x0000000d了,简单的计算告诉我们:
0x080482e8 - 0x80482db = 0xd。这样,经过重定位之后最终的可执行程序就生成了。
可执行程序生成后,下一步就是将其装入内存运行。Linux下的编译器(C语言)是cc1,汇编器是as,链接器是ld,但是并没有一个实际的程序对应装入器这个概念。实际上,将可执行程序装入内存运行的功能是由execve(2)这一系统调用实现的。简单来讲,程序的装入主要包含以下几个步骤:
读入可执行文件的头部信息以确定其文件格式及地址空间的大小;
以段的形式划分地址空间;
将可执行程序读入地址空间中的各个段,建立虚实地址间的映射关系;
将bbs段清零;
创建堆栈段;
建立程序参数、环境变量等程序运行过程中所需的信息;
启动运行。
链接器和装入器的基本工作原理
最新推荐文章于 2021-02-27 22:08:48 发布