静态链接器以一组可重定位目标文件和命令行参数作为输入,生成一个完全链接的、可以加载和运行的可执行目标文件作为输出
其中重定位目标文件可以是:
.o
目标文件.a
静态链接库文件二者本质都是可重定位文件
若要将自己写的文件生成静态库.a
文件,通过命令ar
完成
首先将.c
文件编译生成目标文件.o
再通过ar
生成静态库文件
例如:若我们要用libmymath.o
创建静态库文件,则其对应的命令为:
ar -cr libmymath.a libmymath.o
-c
创建
-r
替换
表示当前插入的模块名已经在库中存在,则替换同名的模块。如果若干模块中有一个模块在库中不存在,ar
显示一个错误信息,并不替换其他同名的模块。默认的情况下,新的成员增加在库的结尾处
之后若要使用该静态库编译链接出可执行文件则:
gcc -o main main.c -L. libmymath.a
-L.
指定库的查找位置,后面跟着.
就表示在当前目录下查找通过观察静态链接的流程,着重关注其中重定位的过程以及静态链接后文件的布局,以小见大理解当静态链接标准静态库时的过程,并为接下来理解动态链接库打基础
sub.c
int nSubData = 100;
int fnSub(int num)
{
return num - 1;
}
main.c
extern int nSubData;
extern int fnSub(int num);
int main(void)
{
int result = fnSub(nSubData);
return 0;
}
编译链接
gcc -fno-pie -m32 -c sub.c main.c
# 关闭 pie
ld -m elf_i386 sub.o main.o -e main -o mainNone
# 其中 -e 用于指定 main 作为程序的入口,ld默认的为 _start
由于main.c
与sub.c
中并没有引入使用标准库的函数,若引入了标准库并使用了其中的函数,不建议使用ld
来进行链接,因为需要找对应所依赖的静态库文件,直接使用gcc -static
即可
文本链接器再进行静态链接时一般采用 两步链接(Two-pass Linking
)的方法,将链接的过程分为两步:
先来简单介绍这两步骤各自的任务:
对于多个输入文件(可重定位文件),若将其按序叠加会产生许多零散的段,每个段又有地址和空间的对齐要求,使内存空间会产生大量的内部碎片,非常浪费空间
所以链接器采用 相似段合并的方案合并到输出文件,以我们刚刚的程序为例画一个简图(接下来会不断完善)就是:
有关.bss
段:之前在介绍 ELF 的文章中说到,.bss
在目标文件和可执行文件中是不占用文件空间的。但在链接器合并各个 Section 的同时,也会将.bss
进行合并,并分配虚拟空间
扫描所有的输入可重定位文件,获得每个Section
的属性信息(长度、位置等),并将其合并,计算合并后,各个Section
的长度与位置关系,建立映射关系。
收集所有输入可重定位文件中的符号表中所有的符号定义和符号引用,统一放到全局符号表中
链接器为目标文件分配地址和空间中地址和空间其实有两个含义:
对于有实际数据的段,它们在文件中和虚拟地址中都要分配空间,但恰好.bss
是个特例,对于它来说仅在装载进内存时分配虚拟地址空间
通过第一步收集到的信息,以及全局符号表中的内容,读取输入文件中Section的数据、重定位信息,进行符号解析与重定位,调整代码中的地址
main.o
与sub.o
的Section
信息main.o
sub.o
main.o
的符号表与可重定位信息readelf -s main.o
查看main.o
的符号表
可以看到其中符号nSubData
与fnSub
的Ndx
类型为UND
即 该符号未定义,说明该符号在当前文件中只是被引用,实际定义在其他文件中,那也就更表示这些符号是需要在接下来被重定位的,由于当静态链接为可执行文件时,必须有确定的地址(虚拟地址VMA
),所以链接器在链接过程中确定这两个符号在可执行文件中的地址,然后再将这两个地址回填入main
的代码段中对应使用他们的地方
接下来看一下main.o
中<main>
函数的代码段:
图中两个红框中分别对应了代码中nSubData
的入栈,与调用fnSub
可以看出此处无论是要入栈的值还是要调用函数的地址都不是真实虚拟地址
其中00 00 00 00
代表的是nSubData
数据的地址,由于在链接前并不知道这个位于其他文件中的符号会被安排在什么地方,所以只好先用0
来代替其位置,之后在进行替换
但为什么函数的地址不也用00 00 00 00
来代替,链接时再替换呢?还是要在 call 的地址处写入十进制的-4
这就涉及到了 指令地址修正 的几种方式,先来说一下
再次之前再来看一下main.o
的重定位表
其中的Offset
偏移地址代表的是在对应section
中,比如说.text
中,要进行修正地址的位置:
nSubData
在.text
中 要修正的地址就是那一串00 00 00 00
的位置,也就是11 + 1 = 12
偏移位置fnSub
在.text
中要修正的位置就是 -4 (fc ff ff ff
)对应的位置,也就是1a + 1 = 1b
偏移位置对于32
位x86
平台下的ELF
文件的重定位入口所修正的指令寻址方式只有两种:
R_386_32
R_386_PC32
宏定义 | 值 | 重定位修正方法 |
---|---|---|
R_386_32 | 1 | 绝对寻址修正 S + A |
R_386_PC32 | 2 | 相对寻址修正 S + A -P |
A = 保存在被修正位置的值
P = 被修正的位置相对于段开始的偏移量或者虚拟地址(对于可执行文件)
S = 符号的实际地址(物理地址)
所以对于 :
nSubData
采用绝对近址寻址:在链接合并后实际就是将nSubData
这个位于.data
的全局变量的虚拟地址直接覆盖到00 00 00 00
位置,而这个fnSub
采用相对近址寻址,既然是相对,那么一定是不是个地址,而是一个偏移:mainNone
修正后的地址
main.o
与sub.o
链接为mainNone
首先验证几个结论:
.text
的大小为0x3d
正好= 0x32 + 0xb
( 参照上面main.o
与sub.o
的Section header table
).data
的大小为0x4
正好= 0x0 + 0x4
链接器第一遍扫描文件时会把section进行合并安排到对应的地址
其中main()
的虚拟地址为0x0804900
大小为0x32
,所以main()
的结尾地址为0x0804900 + 0x32 = 0x08049032
正是fnSub
的地址,说明fnSub
紧跟在main
后面,其次要关注的是nSubData
位于0x0804c000
位置处,所以此时我们关注的几个符号的虚拟地址空间的布局为:
nSubData
R_386_32
绝对近址32位寻址当链接器第二次扫描目标文件时,会检查目标文件中需要重定位的符号
为了将.text
的main()
中使用nSubData
地方的00 00 00 00
替换为其虚拟地址,需要进行以下两步:
main
中什么位置来填写这个绝对地址(虚拟地址)从前面我们知道要填写这个虚拟地址的位置是在.text
段中,由可执行文件main
的Section header table
可知.text
在文件中的偏移为0x1000
,又因为先存放的为main.o
中的代码,所以直接从刚刚main.o
的重定位表中可知,要替换的位置在main.o
中的偏移为0x1b
,所以0x12 + 0x1000 = 0x1012
(DEC 4114) 这个位置就是要要填入nSubData
虚拟地址的位置
通过od
查看该偏移od -Ax -t x1 -j 4114 -N 4 mainNone
可以看到正是nSubData
的虚拟地址0x0804c000
可以看到该地址中填写的正式,nSubData
的虚拟地址,这也印证了 绝对近址32位寻址R_386_32
的寻址方式
fnSub
R_386_PC32
相对近址32位寻址对于相对近址寻址,要考虑这样两个问题:
main
中什么位置来填写这个相对地址(虚拟地址)由于 call 指令需要一个相对地址(偏移量),所以要计算出 当前要填入地址的位置距离fnSub
虚拟地址之间的偏移
fnSub()
虚拟地址:0x08049032
main()
虚拟地址:0x0804900
call fnSub
的语句在main()
内的偏移量:1b
虚拟地址0x080491b
所以:
0x08049032
-0x080491b
= 0x17
这样算不完全对,因为在执行call
指令时,PC
的值自动增加到下一条指令的开始处(本条指令末尾),所以实际PC
应该上移动
0x8049032 - (0x080491b + 0x4) = 0x13
在编译的时候,编译器已经替我们算出了这个-4
所以之前在main.o
中,call
的地址处写的是-4
再用od
查看一下od -Ax -t x1 -j 4123 -N 4 mainNone