静态链接
当有两个目标文件时,如何将它们连接起来形成一个可执行文件?其中发生了什么?
使用两个源代码文件作为研究例子:
a.c
extern int shared;
int main()
{
int a = 100;
swap(&a,&shared);
}
b.c
int shared = 1;
void swap(int *a,int *b)
{
*a^=*b^=*a^=*b;
}
使用gcc将两个文件编译成目标文件a.o和b.o
gcc -c a.c b.c
图示:
图1.pngb.c里面定义了两个全局符号,
- shared
- 函数swap
a.c里面定义了一个全局符号
- main
a.c引用到了b.c里面的swao与shared,接下来就是把这两个目标文件链接在一起最终形成一个可执行文件
空间与地址分配
可执行文件中代码段和数据段都是输入的目标文件合并而来的,那么链接过程产生的第一个问题:对于多个输入目标文件,链接器如何将它们的各个段合并到输出文件?
按序叠加
一个方案就是按照输入次序叠加起来:
图2.png这种做法很简单,但是有一个问题:
在很多输入文件的时候,输出文件将会有很多零散的段,这种做法很浪费空间,因为每个段都需要有一定的地址和空间对齐要求,而且还会造成碎片问题,所以这不是一个好的方案
相似段合并
一个更实际的方法是将相同性质的段合并到一起,比如将所有输入文件的.text合并到输出文件的.text,接着是.data,.bss
图示:
图3.png.bss段在目标文件和可执行文件并不占用文件空间,但是在装载时占用地址空间,所以链接器在合并各个段的时候,需要将.bss也合并,并且分配虚拟空间
链接器为目标文件分配地址和空间中,地址和空间有两个含义:
-
一个是在输出的可执行文件中的空间
-
一个是在装载后的虚拟地址中的虚拟地址空间
对于有实际数据的段,如.text和.data,它们在文件中和虚拟地址中都有分配空间,因为它们在这两者中都存在,而对于.bss这样的段来说,分配空间的意义只局限于虚拟地址空间,因为它在文件中并没有内容
现在链接器空间分配策略采用第二种方法,使用这种方法的链接器一般采用两步链接的方法:
- 空间和地址分配
扫描所有的输入目标文件,并且获得它们的各个段的长度,属性,位置,并且将输入目标文件中的符号表中所有的符号定义和符号引用收集起来,统一放在一个全局符号表。这一步,链接器能够获得所有输入目标文件的段长度,并且将它们合并,计算出输出文件中各个段合并后的长度和位置,并建立映射关系。
- 符合解析与重定位
使用上面的第一步中收集到的所有信息,读取输入文件中段的数据,重定位信息,并且进行符合解析与重定位,调整代码中的地址等。
用链接器将a.o和b.o链接起来:
ld a.o b.o -e main -o ab
接下来使用objdump来查看链接前后地址的分配情况:
objdump -h a.o
输出:
a.o: 文件格式 elf64-x86-64
节:
Idx Name Size VMA LMA File off Algn
0 .text 00000051 0000000000000000 0000000000000000 00000040 2**0
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000000 0000000000000000 0000000000000000 00000091 2**0
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000000 0000000000000000 0000000000000000 00000091 2**0
ALLOC
3 .comment 0000002a 0000000000000000 0000000000000000 00000091 2**0
CONTENTS, READONLY
4 .note.GNU-stack 00000000 0000000000000000 0000000000000000 000000bb 2**0
CONTENTS, READONLY
5 .eh_frame 00000038 0000000000000000 0000000000000000 000000c0 2**3
CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA
objdump -h b.o
输出
b.o: 文件格式 elf64-x86-64
节:
Idx Name Size VMA LMA File off Algn
0 .text 0000004b 0000000000000000 0000000000000000 00000040 2**0
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 .data 00000004 0000000000000000 0000000000000000 0000008c 2**2
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000000 0000000000000000 0000000000000000 00000090 2**0
ALLOC
3 .comment 0000002a 0000000000000000 0000000000000000 00000090 2**0
CONTENTS, READONLY
4 .note.GNU-stack 00000000 0000000000000000 0000000000000000 000000ba 2**0
CONTENTS, READONLY
5 .eh_frame 00000038 0000000000000000 0000000000000000 000000c0 2**3
CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA
objdump -h ab
输出:
ab: 文件格式 elf64-x86-64
节:
Idx Name Size VMA LMA File off Algn
13 .text 00000222 0000000000000560 0000000000000560 00000560 2**4
CONTENTS, ALLOC, LOAD, READONLY, CODE
14 .fini 00000009 0000000000000784 0000000000000784 00000784 2**2
CONTENTS, ALLOC, LOAD, READONLY, CODE
15 .rodata 00000004 0000000000000790 0000000000000790 00000790 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
16 .eh_frame_hdr 00000044 0000000000000794 0000000000000794 00000794 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
17 .eh_frame 00000128 00000000000007d8 00000000000007d8 000007d8 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
18 .init_array 00000008 0000000000200db8 0000000000200db8 00000db8 2**3
CONTENTS, ALLOC, LOAD, DATA
19 .fini_array 00000008 0000000000200dc0 0000000000200dc0 00000dc0 2**3
CONTENTS, ALLOC, LOAD, DATA
20 .dynamic 000001f0 0000000000200dc8 0000000000200dc8 00000dc8 2**3
CONTENTS, ALLOC, LOAD, DATA
21 .got 00000048 0000000000200fb8 0000000000200fb8 00000fb8 2**3
CONTENTS, ALLOC, LOAD, DATA
22 .data 00000014 0000000000201000 0000000000201000 00001000 2**3
CONTENTS, ALLOC, LOAD, DATA
23 .bss 00000004 0000000000201014 0000000000201014 00001014 2**0
ALLOC
24 .comment 00000029 0000000000000000 0000000000000000 00001014 2**0
CONTENTS, READONLY
//省略了剩余内容
上述输出中:VMA:虚拟地址,LMA:加载地址
链接前后的程序中所使用的的地址已经是程序在进程中的虚拟地址,即各个段里面的VMA和Size,可以看到,在链接之前,目标文件中所有段的VMA都是0,因为虚拟地址空间还没分配,所以默认为0,链接之后,可执行文件ab中各个段都被分配到了相应的虚拟地址,
符号地址的确定
在第一步的扫描和空间分配阶段,链接器按照上面的空间分配方法进行分配,这时候输入文件中各个段在链接后的虚拟地址就已经确定了。
当前面这步完成后,链接器开始计算各个符号的虚拟地址,因为各个符号在段内的相对位置是固定的,只不过链接器要给每个符号加上一个偏移量,使得它们能够调整到正确的虚拟地址。
符号解析与重定位
重定位
完成空间与地址分配后,链接器就进入了符号解析与重定位的步骤
在分析符号解析与重定位之前,先看看a.o里面是怎样使用这两个外部符号的,也就是源代码里面的shared变量以及swap函数
编译器将a.c编译成指令后,如何访问shared和调用swap?
使用objdump的-d参数可以查看反汇编结果
objdump -d a.o
a.o: 文件格式 elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: 48 83 ec 10 sub $0x10,%rsp
8: 64 48 8b 04 25 28 00 mov %fs:0x28,%rax
f: 00 00
11: 48 89 45 f8 mov %rax,-0x8(%rbp)
15: 31 c0 xor %eax,%eax
17: c7 45 f4 64 00 00 00 movl $0x64,-0xc(%rbp)
1e: 48 8d 45 f4 lea -0xc(%rbp),%rax
22: 48 8d 35 00 00 00 00 lea 0x0(%rip),%rsi # 29 <main+0x29>
29: 48 89 c7 mov %rax,%rdi
2c: b8 00 00 00 00 mov $0x0,%eax
31: e8 00 00 00 00 callq 36 <main+0x36>
36: b8 00 00 00 00 mov $0x0,%eax
3b: 48 8b 55 f8 mov -0x8(%rbp),%rdx
3f: 64 48 33 14 25 28 00 xor %fs:0x28,%rdx
46: 00 00
48: 74 05 je 4f <main+0x4f>
4a: e8 00 00 00 00 callq 4f <main+0x4f>
4f: c9 leaveq
50: c3 retq
程序代码里面使用的都是虚拟地址,可以发现main的起始地址为 0x00000000,这是因为在未进行空间分配之前,目标文件代码段中的起始地址以 0x00000000开始,等到空间分配完成以后,各个函数才会确定自己在虚拟地址中的位置
上边反汇编输出中:
- 最左边那列是每条指令的偏移量。一行代表一条指令
接着反汇编输出ab的代码段,可以发现main函数的两个重定位入口被修正到了正确的位置:
objdump -d ab
000000000000066a <main>:
66a: 55 push %rbp
66b: 48 89 e5 mov %rsp,%rbp
66e: 48 83 ec 10 sub $0x10,%rsp
672: 64 48 8b 04 25 28 00 mov %fs:0x28,%rax
679: 00 00
67b: 48 89 45 f8 mov %rax,-0x8(%rbp)
67f: 31 c0 xor %eax,%eax
681: c7 45 f4 64 00 00 00 movl $0x64,-0xc(%rbp)
688: 48 8d 45 f4 lea -0xc(%rbp),%rax
68c: 48 8d 35 7d 09 20 00 lea 0x20097d(%rip),%rsi # 201010 <shared>
693: 48 89 c7 mov %rax,%rdi
696: b8 00 00 00 00 mov $0x0,%eax
69b: e8 1b 00 00 00 callq 6bb <swap>
6a0: b8 00 00 00 00 mov $0x0,%eax
6a5: 48 8b 55 f8 mov -0x8(%rbp),%rdx
6a9: 64 48 33 14 25 28 00 xor %fs:0x28,%rdx
6b0: 00 00
6b2: 74 05 je 6b9 <main+0x4f>
6b4: e8 87 fe ff ff callq 540 <__stack_chk_fail@plt>
6b9: c9 leaveq
6ba: c3 retq
00000000000006bb <swap>:
重定位表
链接器如何知道哪些指令需要被调整?这些指令的哪些部分要被调整?怎么调整?
在ELF文件中,有一个叫做重定位表的结构专门用来保存这些与重定位相关的信息
对于可重定位的ELF文件来说,它必须具有重定位表,用来描述如何修改相应的段里的内容,而对于每个要被重定位的ELF段都有一个对应的重定位表,这一个重定位表往往就是ELF文件中的一个段,比如代码段.text如有要被重定位地方,那么会有一个相对于叫做.rel.text的段保存了代码段的重定位表
使用objdump查看:
objdump -r a.o
a.o: 文件格式 elf64-x86-64
RELOCATION RECORDS FOR [.text]:
OFFSET TYPE VALUE
0000000000000025 R_X86_64_PC32 shared-0x0000000000000004
0000000000000032 R_X86_64_PLT32 swap-0x0000000000000004
000000000000004b R_X86_64_PLT32 __stack_chk_fail-0x0000000000000004
RELOCATION RECORDS FOR [.eh_frame]:
OFFSET TYPE VALUE
0000000000000020 R_X86_64_PC32 .text
每个要被重定位的地方叫一个重定位入口,重定位入口的偏移OFFSET 表示该入口在要被重定位的段中的位置,“RELOCATION RECORDS FOR [.text]:”表示这个重定位表是代码段的重定位表
符号解析
之所以链接时因为目标文件中用到的符号被定义在其他目标文件,所以要将它们链接起来,比如直接用ld来链接a.o,而不将b.o作为输入,就会报错
ld a.o
ld: 警告: 无法找到项目符号 _start; 缺省为 00000000004000b0
a.o:在函数‘main’中:
a.c:(.text+0x25):对‘shared’未定义的引用
a.c:(.text+0x32):对‘swap’未定义的引用
a.c:(.text+0x4b):对‘__stack_chk_fail’未定义的引用
重定位的过程也伴随着符号的解析过程,每个目标文件中都可能定义一些符号,也可能引用到定义在其它目标文件的符号,重定位的过程,每个重定位的入口都是对一个符号的引用,那么当链接器要对某个符号的引用进行重定位时,它就要确定这个符号的目标地址,这是链接器就去查找所有输入目标文件的符号表组成的全局符号,找到后进行重定位
比如查看a.o的符号表:
readelf -s a.o
Symbol table '.symtab' contains 13 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS a.c
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1
3: 0000000000000000 0 SECTION LOCAL DEFAULT 3
4: 0000000000000000 0 SECTION LOCAL DEFAULT 4
5: 0000000000000000 0 SECTION LOCAL DEFAULT 6
6: 0000000000000000 0 SECTION LOCAL DEFAULT 7
7: 0000000000000000 0 SECTION LOCAL DEFAULT 5
8: 0000000000000000 81 FUNC GLOBAL DEFAULT 1 main
9: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND shared
10: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND _GLOBAL_OFFSET_TABLE_
11: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND swap
12: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND __stack_chk_fail
可以发现"shared"和"swap"都是UND,即“undefined”未定义类型
COMMON块
弱符号机制允许同一个符号的定义存在于多个文件中。目前的链接器本身并不支持符号的类型,即变量类型对于链接器来说是透明的,它只知道一个符号的名字,并不知道类型是否一致。
多个符号(同名)定义类型不一致的几种情况
- 两个或两个以上强符号类型不一致
-
有一个强符号,其他都是弱符号,出现类型不一致
-
两个或两个以上弱符号类型不一致
针对第一种,无需额外处理,因为多个强符号定义本身就是非法的
现在的编译器和链接器都支持一种叫COMMON块的机制,来源于Fortran,早起Fortran没有动态分配空间的机制,程序员必须事先声明它所需的临时使用空间的大小
COMMON类型的链接规则:原则上讲最终链接后的输出文件中,弱符号的大小以输入文件中最大(所占空间)的那个为准
如果有 一个符号为强符号,那么最终输出结果所占空间大小与强符号相同。【如果链接过程中,有弱符号大于强符号,编译器会报警告】
未初始化的全局变量最终还是被放在BSS段的
一旦有一个未初始化的全局变量不是以COMMON块的形式存在,那么它就相当于一个强符号。
静态库链接
程序之所以有用,因为它会有输入输出,这些输入输出对象可以是数据,也可以是人,也可以是另外一个程序,还可以是另外一台计算机。但是一个程序如何做到输入输出呢?最简单的办法是使用操作系统提供的应用程序编程接口(API)。当然,操作系统也是一个程序。
程序如何使用操作系统提供的 API ? 一般情况下,一种语言的开发环境往往会附带语言库,这些库就是对操作系统 API 的包装。库里面还有一些很常用的函数,这部分函数不调用操作系统 API。
静态库可以简单的看成一组目标文件的集合,即很多目标文件打包后形成的一个文件
-
Linux中最常用的C语言静态库libc位于/usr/lib/libc.a,属于glibc项目的一部分
-
Windows上,最常用的C语言库是IDE附带的运行库
链接过程控制
绝大部分情况下,我们使用链接器提供的默认规则对目标文件进行链接。一般情况下没有问题,但对于一些特殊程序,比如操作系统内核,BIOS或在一些没有操作系统的情况下运行的程序(如引导程序 Boot Loader或者嵌入式系统的程序),以及另外的一些需要特殊的链接过程的程序,如一些内核驱动程序,它们往往受限于一些特殊的条件,如需要指定输出文件的各个段虚拟地址,段的名称,段的存放顺序等,因为这些特殊的环境,特别是某些硬件的限制,往往对程序的各个段的地址有特殊的要求。
由于整个链接过程有很多内容需要确定:
-
使用哪些目标文件
-
使用哪些库文件
-
是否在最终可执行文件中保存调试信息
-
输出文件格式(可执行文件还是动态链接库)
-
还要考虑是否要导出某些符号以供调试器或程序本身或其他程序使用等
链接控制脚本
链接器一般都提供多种控制整个链接过程的方法,以用来产生用户所需要的文件。一般链接器有如下3种方法:
- 1.使用命令行来给链接器指定参数,如 ld 使用的 -o, -e
- 2.将链接器指令存放在目标文件里面,编译器进程会通过这种方法向链接器传递指令。
- 3.使用链接控制脚本,也是最为灵活,最为强大的链接控制方法
最小的程序
这个最小的程序,功能就是在终端上输出:"Hello world!",但这个经典的hello wrold程序有点不同:
-
之前的程序使用了printf函数,该函数是C语言库的一部分,这次希望这个小程序能够摆脱C语言运行库,使得它成为一个独立于任何库的纯正的程序
-
经典的版本程序使用了库,所以必须由main函数,所以此次不适用main这个函数名了用nomain作为整个程序的入口
- 经典的程序会产生许多段,这次将所有段都合并到一个叫做tinytext的段,这个段的名字是任意起的
TinyHelloWorld.c
char* str = "Hello World!\n";
void print(){
asm( "movl $14, %%edx \n\t"
"movl %0, %%ecx \n\t"
"movl $0, %%ebx \n\t"
"movl $4, %%eax \n\t"
"int $0x80 \n\t"
::"r"(str):"edx","ecx","ebx");
}
void exit() {
asm( "movl $42,%ebx \n\t"
"movl $1,%eax \n\t"
"int $0x80 \n\t");
}
void nomain() {
print();
exit();
}
程序入口函数为nomain函数,调用了print函数,打印“hello world”,接着调用exit函数,结束进程,上面使用了GCC内嵌汇编,
现在先使用普通命令行的方式来编译和链接TinyHelloWorld.c:
(1)gcc -c -fno-builtin -m32 TinyHelloWorld.c
(2)ld -static -m elf_i386 -e nomain -o TinyHelloWorld TinyHelloWorld.o
第一步用GCC将TinyHelloWorld.c编译成TinyHelloWorld.o,接着用ld将TinyHelloWorld.o链接成可执行文件TinyHelloWorld
其中一些参数的含义:
- -fno-builtin,GCC提供了很多内置函数(Built-in Function),它会把一些常用的C库函数替换成编译器的内置函数,以达到优化的功能
-
-static,表示ld将使用静态链接的方式来链接程序,而不是使用默认的动态链接
-
-e nomain,表示该程序的入口函数为nomain
-
-o TinyHelloWorld,指定输出可执行文件名为TinyHelloWorld
ld链接脚本
如果把整个链接过程比作一台计算机,那么ld链接器就是计算机的CPU,所有的目标文件,库文件就是输入,链接结果输出的可执行文件就是输出,而链接控制脚本正是这台计算机的“程序”,
不论是输出文件还是输入文件,它们的主要的数据就是文件中的各个段,我们把输入文件中的段,叫输入段,输出文件中的段称为输出段。简单的说,控制链接过程无非就是控制输入段如何变成输出段,比如哪些输入段要合并成一个输出段,哪些输入段要丢弃;指定输出段的名字,装载地址,属性等。
TinyHelloWorld的链接脚本TinyHelloWorld.lds:
ENTRY(nomain)
SECTIONS
{
. = 0x0804800 + SIZEOF_HEADERS;
tinytext : { *(.text) *(.data) *(.rodata) }
/DISCARD/ : { *(.comment) }
}
ENTRY :指定了程序的入口为 nomain() 函数
SECTIONS : 链接脚本的主体,这个命令制定了各个输入端到输出端的变换,SECTIONS 后面紧跟着一对大括号里面包含了 SECTIONS 变换规则,其中有3条语句,每条语句一行。第一行是赋值语句,后面2条是段转换规则,它们的含义如下:
- . = 0x0804800 + SIZEOF_HEADERS; 第一条赋值语句的意思是将当期虚拟地址设置为 0x0804800 + SIZEOF_HEADERS, SIZEOF_HEADERS 为输出文件的文件大小。
"." 表示当前的虚拟地址,因为这条语句后面紧跟着输出端 "tinytext", 所以 "tinytext" 段的起始虚拟地址为 0x0804800 + SIZEOF_HEADERS。它将当期虚拟地址
设置成一个比较巧妙的值,以便于装载时页面映射更为方便。
- . = 0x0804800 + SIZEOF_HEADERS; 第一条赋值语句的意思是将当期虚拟地址设置为 0x0804800 + SIZEOF_HEADERS, SIZEOF_HEADERS 为输出文件的文件大小。
- tinytext : { *(.text) *(.data) *(.rodata) } 第二条是段转换规则,它的意思即所有的输入文件中的名字为 ".text", ".data" 或者 ".rodata" 的段依次合并
到输出文件的 "tinytext"
- tinytext : { *(.text) *(.data) *(.rodata) } 第二条是段转换规则,它的意思即所有的输入文件中的名字为 ".text", ".data" 或者 ".rodata" 的段依次合并
- /DISCARD/ : { *(.comment) } 第三天规则为:将所有输入文件中的名字为 ".comment" 的段丢弃,不保存到输出文件中。
通过命令行来编译TinyHelloWorld,并启用该链接控制脚本:
ld -static -m elf_i386 -T TinyHelloWorld.lds -e nomain -o TinyHelloWorld TinyHelloWorld.o
使用objdum查看TinyHelloWorld的段,可以发现整个程序只有一个段“tinytext”
图4.png参考资料
<<程序员的自我修养—链接、装载与库>>
网友评论