上一篇介绍了目标文件的格式,有了对结构的认识,这篇讲静态链接,主要是关于目标文件如何链接起来组成可执行文件。笔记后面把ld链接脚本语法省略,暂时用不到这么牛逼的武器。
本文导图
实验代码
实验代码
- 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;
}
对上面内容的解释:
- b中定义两个全局符号,变量shared和函数swap
- a中定义了一个全局符号main
- a中引用到了b中的swap和shared
空间地址分配
链接额过程就是将输入的目标文件合并为一个输出的可执行文件。如何将目标文件的各个段合并到可执行文件中,也就是空间如何分配,总体有如下两种方式,按序叠加与相似段合并。——合并规则
按序叠加很简单,就是按照目标文件的顺序叠加起来,可以用下图说明:
- 如果可执行文件成千上百个目标文件组成,就会出现很多零散的段,每个目标文件都有三个最为核心的段,这样就会非常浪费空间,因为每个段都需要有一定的地址和空间对其要求。比如x86来说,段的装载地址和空间对其单位是页,也就是4096字节(一个页大小是4096字节),会造成极大的内存空间碎片。
- 关于内存地址对其可以看看内存对齐规则之我见,简单来讲就是因为CPU是按字读取内存。所以内存对齐的话,不会出现某个类型的数据读一半的情况,需要再二次读取内存。可以提升访问效率。编译器想通过空间换时间,通过适当增加padding,使每个成员的访问都在一个指令里完成,而不需要两次访问再拼接。
相似段合并这种方式更加实际,比如讲.text段合并到可执行文件的.text段,各个段一次合并。如下图所示:
链接器为目标文件分配地址空间有两层含义:
- 一个是在输出可执行文件中的空间
- 另一个是装载后的虚拟地址中的虚拟地址空间
- 之前提到过.bbs段,在可执行文件中并不占用文件空间,但是在占用虚拟地址空间,因为.bbs段在在文件中并没有内容。
目前只讨论关于虚拟地址空间的分配
具体来讲链接器空间分配策略都是用第二中,并且采用两步链接。
- 第一步:空间与地址分配——扫描所有目标文件,得到各个段的长度,将所有目标文件的符号表中的符号定义及引用信息统一放到一个全局符号表。可以根据目标文件的段长度,将他们合并,建立映射关系。
- 第二步:符号解析与重定位——根据上面的信息,读取文件中的段数据,重定位信息,进行符号解析与重定位,调整代码地址。这一步才是狠心,尤其是重定位。
用ld将a.o、b.o连接起来
Linux
ld a.o b.o -e main -o ab
Mac
ld a.o b.o -e _main -o ab
ld命令的两个参数含义是:
-o:指定输出文件名;
-e:指定程序的入口符号。
链接之后各个段的属性
Linux
Mac
$ objdump -h a.o
a.o: file format Mach-O 64-bit x86-64
Sections:
Idx Name Size Address Type
0 __text 0000002e 0000000000000000 TEXT
1 __compact_unwind 00000020 0000000000000030 DATA
2 __eh_frame 00000040 0000000000000050 DATA
$ objdump -h b.o
b.o: file format Mach-O 64-bit x86-64
Sections:
Idx Name Size Address Type
0 __text 0000002c 0000000000000000 TEXT
1 __data 00000004 000000000000002c DATA
2 __compact_unwind 00000020 0000000000000030 DATA
3 __eh_frame 00000040 0000000000000050 DATA
$ objdump -h ab
ab: file format Mach-O 64-bit x86-64
Sections:
Idx Name Size Address Type
0 __text 0000005c 0000000000001f20 TEXT
1 __eh_frame 00000080 0000000000001f80 DATA
2 __data 00000004 0000000000002000 DATA
在Linux中VMA表示的是虚拟地址,LMA表示的是加载地址,一般这两个值一样,但是有些嵌入式系统中会不一样。Mac中只有一个地址也就是虚拟地址。
现在直接看VMA和SIZE,暂时忽略文件偏移。在链接之前虚拟地址都是零(MAC上起始的.text段为0),因为虚拟地址空间还没有分配,所以默认都是0,但是链接之后,可执行文件ab各个段都分配了相应的虚拟地址,所以可以看到text已经分配到地址。
对应到Linux中ELF文件,.text
段分配到了0x08048094,大小是0x72字节,.data
段从地址0x08049108开始,大小为四字节。总体来说如下图:
为什么不从虚拟地址的0地址开始分配呢。涉及到操作系统进程虚拟地址的分配规则。Linux下ELF文件默认从0x08048000开始分配的。
符号地址的确定
第一步过程中确定了在可执行文件中的空间分布。比如.text
其实段0x08040894,.data
段其实地址0x08049108.
第一步完成之后,链接器就开始计算各个符号的虚拟地址。符号在段内的位置是固定的,比如main、shared、wap地址已经是确定的了,只不过需要链接器给每个符号添加一个偏移量。
比如a.o中的main函数相对于a.o的text偏移量是X,经过链接之后a.o的text段位于虚拟地址0x08048094,那么main的地址就是0x08048094+X。从前面的objdump可以看到main位于a.o的text段偏移是0。所以main这个符号最终在可执行文件中的地址是0x08048094+0。
符号解析、重定位
完成了空间和地址分配,链接器开始进行符号解析及重定位。
使用objdump的参数d查看反汇编结果
未链接a.o反汇编结果
Mac 下
$ objdump -d a.o
a.o: file format Mach-O 64-bit x86-64
Disassembly of section __TEXT,__text:
_main:
0: 55 pushq %rbp
1: 48 89 e5 movq %rsp, %rbp
4: 48 83 ec 10 subq $16, %rsp
8: 48 8d 7d fc leaq -4(%rbp), %rdi
c: 48 8b 35 00 00 00 00 movq (%rip), %rsi
13: c7 45 fc 64 00 00 00 movl $100, -4(%rbp)
1a: b0 00 movb $0, %al
1c: e8 00 00 00 00 callq 0 <_main+0x21>
21: 31 c9 xorl %ecx, %ecx
23: 89 45 f8 movl %eax, -8(%rbp)
26: 89 c8 movl %ecx, %eax
28: 48 83 c4 10 addq $16, %rsp
2c: 5d popq %rbp
2d: c3 retq
Linux下:
可执行文件ab反汇编结果:
Mac 下:
objdump -d ab
ab: file format Mach-O 64-bit x86-64
Disassembly of section __TEXT,__text:
......
_main:
1f20: 55 pushq %rbp
1f21: 48 89 e5 movq %rsp, %rbp
1f24: 48 83 ec 10 subq $16, %rsp
1f28: 48 8d 7d fc leaq -4(%rbp), %rdi
1f2c: 48 8d 35 cd 00 00 00 leaq 205(%rip), %rsi
1f33: c7 45 fc 64 00 00 00 movl $100, -4(%rbp)
1f3a: b0 00 movb $0, %al
1f3c: e8 0f 00 00 00 callq 15 <_swap>
1f41: 31 c9 xorl %ecx, %ecx
1f43: 89 45 f8 movl %eax, -8(%rbp)
1f46: 89 c8 movl %ecx, %eax
1f48: 48 83 c4 10 addq $16, %rsp
1f4c: 5d popq %rbp
1f4d: c3 retq
1f4e: 90 nop
1f4f: 90 nop
......
Linux下
需要懂点汇编才能理解上面的不同。主要是想说明,被引用的函数或者变量的地址,在链接之后被重新定位了。如上面的swap函数、shared变量。
关于汇编的学习后面会专门写一篇!
重定位表
链接器通过重定位表才能知道哪些指令需要被调整,重定位表往往是一个或多个段。ELF必须包含重定位表来重新定位符号。
比如代码段.text
有符号需要重定位,则就会有一个.rel.text
的段保存了代码段重定位的信息,如果.data
段中有重定位的地方,就会有一个对应的.rel.data
段保存了数据端的重定位表。可以使用objdump的r
参数查看。
Mac下:
objdump -r a.o
a.o: file format Mach-O 64-bit x86-64
RELOCATION RECORDS FOR [__text]:
000000000000001d X86_64_RELOC_BRANCH _swap
000000000000000f X86_64_RELOC_GOT_LOAD _shared@GOTPCREL
RELOCATION RECORDS FOR [__compact_unwind]:
0000000000000000 X86_64_RELOC_UNSIGNED __text
Linux下:
可以看到a.o有两个重定位入口,重定位入口的偏移表示该符号入口在重定位段中的位置。
对上面代码的解析:因为还未进行链接,首先main函数其实地址为0x00000000。这个main函数占用了0x33个字节,17条指令。shared的引用是一条mov指令,一共8个字节,将shared的地址复制到ESP寄存器+4的偏移地址中,前四个是指令码,后面是shared的地址。暂时认为shared的地址是0x00000000,并且是绝对地址指令。。
其次对swap的调用的指令一个共5个字节,0xE8是操作码,这是个相对位移调用指令,后面四个字节就是函数 相对于调用指令的下一条指令的偏移量。没重定位之前,相对偏移量为0xFFFFFFFC(小端),常量
-4
的补码形式。
- 第一行表示这个重定位表是对代码段的重定位,所以偏移表示代码段中需要被调整的位置。对照前面的反汇编结构。这里的0x1c和0x27分别对应了代码段中的mov和call指令部分。
重定位表中保存的是一个ELF32_Rel(intelx86)结构数组,表中每一个元素都对应一个重定位入口
typedef struct {
ELF32_Addr r_offset;
ELF32_Word r_info;
}
具体含义如下:
重定位表项里面包含了如何定位该符号的所有信息,偏移位置,类型,符号等。
符号解析
符号未定义错误在链接的时候经常出现。对应上面的例子,
ld a.o
ld: warning: -macosx_version_min not specified, assuming 10.11
ld: warning: object file (a.o) was built for newer OSX version (10.12) than being linked (10.11)
Undefined symbols for architecture x86_64:
"_shared", referenced from:
_main in a.o
"_swap", referenced from:
_main in a.o
"start", referenced from:
implicit entry/start for main executable
ld: symbol(s) not found for inferred architecture x86_64
导致符号未定义的原因很多(根本原因找不到符号),比如:
- 链接时少了某个库
- 符号目标文本路径不正确
为什么缺少符号定义会导致链接错误?其实重定位过程伴随着符号解析的过程,每个目标文件可能定义一些符号,也可能引用的到其他文件中定义的符号,每个重定位入口都是对一个符号的引用。当链接器对某个符号进行重定位的时候,就需要确定这个符号的目标地址,这个时候就会去查找输入目标文件的符号表组成的全局符号表,找到这个符号然后重定位。
使用readelf -s查看符号表。
a.o中的符号表
注意上面的main、shared、swap都是GLOBAL。main函数定义在代码段之外,shared和swap是UND为定义。因为他们是定义哎其他目标文件中的。稳定一的都是需要在全局符号表中找到。
指令修正
这部分需要结合在重定位反汇编用到的偏移量。
不同处理器对于地址的格式和方式都不一样。常见的有跳转指令(jump 11种)、子程序调用指令(call 10种)、数据传送指令(mov 34中)寻址千差万别。差别如下:
- 近址或远址寻址
- 绝对与相对寻址
- 寻址长度8、16、32、64位
- 相对近址32位寻址
- 绝对近址32位寻址
每个被修正的长度为32位,4字节,都是近址寻址。区别就是相对或者绝对。之前说过,重定位入口r_info成员低八位表示重定位入口类型
对应到上面的内容。swap符号的引用类型为R_386_PC32,代表是相对位移调用指令,而shared是R_386_32类型,他修正的是一条传输指令的原,shared的绝对地址。
绝对寻址修正和相对寻址修正的区别就是前者修正后的地址就是该符号的实际地址,而后者修正后的地址为符号距离被修正位置的地址差。
现在来计算一下,假设链接之后main函数的虚拟地址为0x1000,swap函数的地址为0x2000,shared变量的虚拟地址为0x3000。现在开始修正这两个重定位符号的地址。
shared
根据上面的分析,首先shared是一个绝对寻址修正,结果应该是S(实际虚拟地址——假设的)+ A(修正位置的值——从符号解析中得到的value)
那么修正之后的地址就是:0x3000 + 0x0000000 = 0x3000。那么就应该是:
swap
swap需要相对修正,器修正地址就是S + A - P(被修正的位置,当链接的时候这个值就是被修正位置的虚拟地址 就为0x1000(main函数地址)+ 0x27)
对应下来:0x2000 + (-4) - (0x1000 + 0x27) = 0xFD5:
那么这条相对唯一调用指令地址是改指令下一条指令其实地址加上偏移量。那就是0x1026 + 0xfd5 = 0x2000。这就是swap函数的地址。
COMMON块
编译器将未初始化的全局变量定义作为弱符号,如上一篇的例子中global_uninit_var。使用readelf -s
查看
类型是一个SH_COMMON类型。
当不同目标文件需要的COMMON块空间大小不一致的时候,以最大的那块为准。
需要使用COMMON机制的原因是编译器和链接器允许不同类型的弱符号存在,但是最本质的还是链接器不支持符号类型,也就是链接器无法判断各个符号类型是否一致。
小结:如果编译单元包含了弱符号(比如未初始化的全局变量就是典型的),那么弱符号最终占有多大空间不知道,所以编译器无法为改符号在BSS段分配空间。但是在链接过程中,弱符号大小可以确定了,所以最终在输出可执行文件的BBS段为弱符号分配空间。最终未初始化的全局变量还是放在BBS段
C++ 相关问题
C++语言特性太复杂,必须有编译器和链接器共同支持才能完成工作。关键在于:
- 重复代码消除
- 全局构造与析构
- 特性:虚函数、函数重载、继承、异常等。这些数据结构复杂,往往在不同编译器和链接器之间不能通用,二进制兼容性很麻烦。
重复代码消除
C++中的模板本质来讲很像宏,当被实例化的时候,并不知道自己是否在别的编译单元被实例化,所以必定出现重复代码。如果不管这些重复代码的话,主要会有如下问题:
- 空间浪费
- 地址教易出错:可能有两个指向同一个函数的指针不相等。
- 指令运行效率低:因为现代CPU都会对指令和数据进行缓存,同一份指令有多份副本,那么Cache的命中率就会很低。
比较现实的做法:每个目标的实例代码都单独存放到一个段(段命名如.gnu.linkonce.name
,name就是该函数模板修饰后的名称)里,每个段只包含一个模板实例。当别的编译单元也有同样模板实例的时候,就会生成相同的名字,最终链接器将他们合并到最后的代码段。
函数级别链接
现在的程序和库都非常庞大,一个目标文件可能包含成千上百个函数或者变量,当我们需要用到某个目标文件的一个函数或变量的时候,需要把珍格格文件链接进来,这样导致输出的文件也很多。
在C++编译器中,有个编译选项叫做函数几倍链接,这个的作用就是让所有的函数像前面的模板一样,单独保存到一个段里面。当链接器需要用到这个函数的时候,就会将它合并到输出文件,没有用到的函数就会被抛弃。这样的方式同样有问题,虽然减少了输出文件的长度,但是会减慢编译和链接的过程,并且所有函数保存到独立的短中,目标函数的短数量增加,重定位会因为短的数目增加而变得复杂,目标文件也会变得相对较大。
全局构造、析构
C/C++程序都是从main函数开始执行的,随着main函数结束而结束。在main函数之前为了程序能够顺利执行。需要初始化进程执行环境、如堆分配初始化、线程子系统等。C++的全局对象构造函数在这一时期被执行
Linux下一班程序的入口是_start
,这个函数时Linux系统库(Glibc)的一部分。当程序与Glibc链接在一起形成可执行文件之后,_start
就是程序初始化入口。程序初始化之后,会调用main函数来执行程序,main函数执行之后就会进行一些清理工作,然后结束进程。
在ELF定义了两个特殊的段,
-
.init
段在main函数之前的可执行指令。构成进程的初始化代码。所以在main函数调用之前,Glibc初始化部分会执行这个段的代码。 -
.fini
段保存着进程的终止代码指令。所以当main函数正常退出时,Glib会安排执行这个段中的代码。
C++、ABI
编译器有很多种,那么不同编译器产生的目标文件可不可以进链接呢?
如果两个不同的编译器想要产生的目标文件能够正确的链接起来,那么这两个目标文件必须满足:
- 采用相同的目标文件格式
- 拥有相同的符号修饰标准
- 变量的内存分配方式相同
- 函数调用方式相同
把符号修饰标准、变量内存布局、函数调用方式等这些跟可执行的二进制文件兼容性相关的内容统称为ABI(应用程序二进制接口)。如果想弄清API和ABI的区别,可以看最后的扩展阅读内容呢。
- 简单来讲就是API往往指源代码级别的接口,如POSIX是一个API标准,而ABI是二进制层面的级别。比如C++对象内存布局是C++ABI的一部分。
- 就拿POSIX规定printf()函数的原型为例,POSIX保证这个函数所有遵循POSIX标准的系统之间都一样,但是不保证printf在每个系统执行时,是否按照从右到左的参数压入堆栈。参数如何在堆栈中分配等这些实际运行时的二进制级别问题。
- 由于各大硬件拼图,编程语言,编译,链接器和操作系统之间的ABI互相不兼容,所以哥哥目标文件之间无法互相链接。
实际例子
对于C语言的目标来讲,一下几个方面会决定二进制是否兼容:
API相同ABI不一定相同。
链接过程控制
一般情况下用链接器默认的链接规则就可以了,但是在一些特殊的情况下就需要自定义一些参数了,比如引导程序、内核驱动程序就需要特殊的链接过程。
链接过程需要确定的内容:
- 使用哪些目标文件
- 使用安歇库文件
- 是否在最终的可执行文件保留调试信息
- 输出文件格式(可执行文件、动态库、静态库)
- ....
链接控制脚本
链接控制脚本就是用来控制链接行为的
一般链接器有如下几种方式控制链接行为:
- 使用命令行:给链接器指定参数,比如之前用的ld的-o、-e参数就属于这类。
- 链接指令存放到目标文件里面:编译器经常会通过这种方式给链接器传递指令。具体来讲比如在PE目标文件的.drectve段用来链接传递参数 。
- 链接控制脚本:最为灵活也是最为强大的控制方式
之前我们在使用ld命令链接的时候,没有指定链接脚本,其实ld如果没有指定链接脚本,则会使用默认的链接脚本。在Linux上使用ld -verbose
查看默认链接脚本。为了更加精确的控制链接过程,可以自己写一个链接脚本,然后指定该脚本控制脚本,比如ld -T link.script
一个例子
在这里例子中,作者没有使用main函数和c中的printf函数来打印helloword。而是使用了自定义的一套方式,使用了GCC内嵌汇编(不是很懂,没弄过),并且自定义了将所有段合并到一个叫做tinytext
段中。
TinyHelloWord.c源码
看到后面懵逼了,暂时停下来去了解下汇编、复习下终端等知识。
- 句柄与普通指针的区别:指针包含的是引用对象的内存地址,而句柄则是由系统所管理的引用标识,该标识可以被系统重新定位到一个内存地址上。这种间接访问对象的模式增强了系统对引用对象的控制。
使用ld链接脚本
无奈是输出文件还是输入文件,主要的数据就是文件中的各个段。它们中的段我们称作输入段、输出段。控制链接果果就是把控制输入段如何变成输出段,比如
- 哪些输入段要合并为一个输出段
- 哪些输入段要丢弃
- 指定输出段的名字、装载地址、属性
比如上面TinyHelloword的链接脚本TinyHelloWorld.lds
第一行是ENTRY(nomain)指定了程序入口为nomain()函数
-
SECTIONS是链接脚本的主体,指定了各个输入段到输出段的交换。里面的大括号包含的是SECTIONS的变化规则。
- 第一条是赋值语句
. = 0x08048000 + SIZEOF_HEADERS
表示把当前虚拟地址设置成为0x08048000 + SIZEOF_HEADERS -
tinytext:{*(.text)*(.data)*(.rodata)}
第二条是个段转换规则,也就是后面的三个段合并输出到文件tinytext中 -
/DISCARD/:{*{comment}}
第三个意思就是丢弃所有输入文件中的名字为.comment
内容,不保存到输出文件中。
默认情况下,
.shstrtab、.symtab、.strtab
代表段名字符串表,符号表和字符串表,这三种表,链接器在产生可执行文件的时候会自动生成。——可执行文件中,符号表和字符串是可选的,段名字符串表保存段名,必不可少。 - 第一条是赋值语句
ld链接脚本语法
这一小节直接看资料,ld链接脚本文件语法解析。平时很难有机会用到这块知识。
扩展阅读
ABI-Application binary interface
GCC内嵌汇编
Linux 内核中断内幕
句柄是什么?
Purpose of memory alignment
内存地址对齐提升程序性能