(2021) 19 [代码讲解] 从零实现动态加载
南京大学操作系统课蒋炎岩老师网络课程笔记。
视频:https://www.bilibili.com/video/BV1N741177F5?p=15
讲义:http://jyywiki.cn/OS/2021/slides/C9.slides#/
背景
回顾:
ELF可执行文件
只要能完成手册上要求的内容,就可以自己实现一个execve
本次课内容与目标
静态链接与加载
- hello程序的链接与加载
动态链接与加载
- 理解动态加载的动机
- 自己实现动态加载
- “lazy” 的延迟符号绑定
- ELF文件的动态链接与加载
不同的ELF文件的格式
同为Linux中的可执行二进制文件ELF,也有不同的具体的格式。
我们拿一个hello程序举例:
#include <stdio>int main(){printf("Hello\n");
}
我们用不同的编译选项来编译它,注意gcc默认是动态链接:
gcc hello.c -o dynamic_hello
gcc -static hello.c -o static_hello
两个文件dynamic_hello
和static_hello
都可以正常运行输出正常的结果,并且它们也都是ELF可执行文件。但是当我们用file
命令来查看它们时,会发现有些许不同。
file dynamic_hello
file static_hello
输出分别为:
dynamic_hello: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=83a317b39ca06b8945b090871745ff8287463528, not stripped
static_hello: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, for GNU/Linux 3.2.0, BuildID[sha1]=d9ac8fd547a3c93a5cafa2d47c2416b4a147180f, not stripped
注意上面红字标出的地方指明了这两个ELF文件之间的不同,它们一个是动态链接加载的,一个是静态链接加载的。
静态链接与加载
编译、链接的需求
为了节省空间和时间,不将所有的代码都写在同一个文件中是一个很基本的需求。
为此,我们的C语言需要实现这样的需求:允许引用其他文件(C标准成为编译单元,Compilation Unit)里定义的符号。C语言中不禁止你随便声明符号的类型,但是类型不匹配是Undefined Behavior。
假如我们有三个c文件,分别是a.c
,b.c
,main.c
:
// a.c
int foo(int a, int b){return a + b;
}
// b.c
int x = 100, y = 200;
// main.c
extern int x, y;
int foo(int a, int b);
int main(){printf("%d + %d = %d\n", x, y, foo(x, y));
}
我们在main.c
中声明了外部变量x,y
和函数foo
,C语言并不禁止我们这么做,并且在声明时,C也不会做什么类型检查。当然,在编译main.c
的时候,我们看不到这些外部变量和函数的定义,也不知道它们在哪里。
我们编译链接这些代码,Makfile如下:
CFLAGS := -Osa.out: a.o b.o main.ogcc -static -Wl,--verbose a.o b.o main.oa.o: a.cgcc $(CFLAGS) -c a.cb.o: b.cgcc $(CFLAGS) -c b.cmain.o: main.cgcc $(CFLAGS) -c main.cclean:rm -f *.o a.out
结果生成的可执行文件可以正常地输出我们想要的内容。
make
./a.out
# 输出:
# 100 + 200 = 300
我们知道foo
这个符号是一个函数名,在代码区。但这时,如果我们将main.c
中的foo
声明为一个整型,并且直接打印出这个整型,然后尝试对其加一。即我们将main.c
改写为下面这样,会发生什么事呢?
// main.c (changed)
#include <stdio.h>
extern int x, y;
// int foo(int a, int b);
extern int foo;
int main(){printf("%x\n", foo);foo += 1;// printf("%d + %d = %d\n", x, y, foo(x, y));
}
输出:
c337048d
Segmentation fault (core dumped)
我们发现,其实是能够打印出四个字节(整型为4个字节),但这四个字节是什么东西呢?
C语言中的类型:C语言中的其实是可以理解为没有类型的,在C语言的眼中只有内存和指针,也就是内存地址,而所谓的C语言中的类型,其实就是对这个地址的一个解读。比如有符号整型,就按照补码解读接下来的4个字节地址;又比如浮点型,就是按照IEEE754的浮点数规定来解读接下来的4字节地址。
那我们这里将符号foo
定义为了整型,那编译器也会按照整型4个自己来解读它,而这个地址指针指向的其实还是函数foo
的地址。那这四个字节应该就是函数foo
在代码段的前四个字节。我们不妨用objdump反汇编来验证我们的想法:
objdump -d a.out
输出(节选):
我们看到,foo
函数在代码段的前四个字节的地址确是就是我们上面打印输出的c3 37 04 8d
(注意字节序为小端法)。
那我们接下来试图对foo
进行加一操作相当于是对代码段的写操作,而我们知道内存中的代码段是 可读可执行不可写 的,这就对应了上面输出的Segmentation fault (core dumped)
。
总结一下,通过这个例子,我们应当理解:
- 编译链接的需求:允许引用其他文件(C标准成为编译单元,Compilation Unit)里定义的符号。C语言中不禁止你随便声明符号的类型,但是类型不匹配是Undefined Behavior。
- C语言中类型的概念:C语言中的其实是可以理解为没有类型的,在C语言的眼中只有内存和指针,也就是内存地址,而所谓的C语言中的类型,其实就是对这个地址的一个解读。
程序的编译 - 可重定向文件
我们先用file命令来查看main.c
编译生成的main.o
文件的属性:
file main.o
输出:
main.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
我们看到这里的main.o
文件是可重定向( relocatable) 的ELF文件,这里的重定向指的就是我们链接过程中对外部符号的引用。也就是说,编译过的main.o
文件对于其中声明的外部符号如foo
,x,y
,是不知道的。
既然外部的符号是在链接时才会被main程序知道,那在编译main程序,生成可重定向文件时这些外部的符号是怎么处理的呢?我们同样通过objdump工具来查看编译出的main.o
文件(未修改的原版本):
objdump -d main.o
输出:
main在编译的时候,引用的外部符号就只能 ”留空(0)“ 了。
我们看到,在编译但还未链接的main.o
文件中,对于引用的外界符号的部分是用留空的方式用0暂时填充的。即上图中红框框出来的位置。注意图中的最后一列是笔者添加的注释,指明了本行中留空的地方对应那个外部符号。
另外注意这里的%rip
相对寻址的偏移量都是0,一会儿我们会讲到,在静态链接完成之后,它们的偏移量会被填上正确的数值。
我们已经知道在编译时生成的文件中外部符号的部分使用0暂时留空的,这些外部符号是待链接时再填充的。那么,我们在链接时究竟需要填充哪些位置呢?我们可以使用readelf工具来查看ELF文件的重定位信息:
readelf -r main.o
这个图中上方是readelf的结果,下面是objdump的结果,笔者在这里已经将前两个外部符号的偏移量的对应关系用红色箭头指了出来,其他的以此类推。这种对应也可以证明我们上面的分析是正确的的。
应当讲,可重定向ELF文件(如main.o
)已经告诉了我们足够多的信息,指示我们应该将相应的外部符号填充到哪个位置。
另外,注意%rip
寄存器指向了当前指令的末尾,也就是下一条指令的开头,所以上图中最后的偏移量要减4(如 y - 4)。
程序的静态链接
简单讲,程序的静态链接是会把所需要的文件链接起来生成可执行的二进制文件,将相应的外部符号,填入正确的位置(就像我们上面查看的那样)。
我们可以通过使用gcc的 -Wl,--verbose
将--verbose
传递给链接器ld,从而直接观察到整个静态链接的过程,包括:
- ldscript里面各个section是按照何种顺序 “粘贴”
- ctors / dtors (constructors / destructores) 的实现,( 我们用过__attribute__((contructor)) )
- 只读数据和读写数据之间的padding,. = DATA_SEGMENT_ALIGN …
我们可以通过objdump来查看静态链接完成以后生成的可执行文件a.out
的内容:
objdump -d a.out
注意,这个a.out
的objdump结果图要与我们之前看到的main.o
的objdump输出对比着来看。
我们可以看到,之前填0留空的地方都被填充上了正确的数值,%rip
相对寻址的偏移量以被填上了正确的数值,而且objdump也能够正确地解析出我们的外部符号名(最后一列)的框。
静态ELF加载器:加载 a.out 执行
静态ELF文件的加载:将磁盘上静态链接的可执行文件按照ELF program header,正确地搬运到内存中执行。
操作系统在execve时完成:
- 操作系统在内核态调用mmap
- 进程还未准备好时,由内核直接执行 ”系统调用“
- 映射好 a.out 的代码、数据、堆区、堆栈、vvar、vdso、vsyscall
- 更简单的实现:直接读入进程的地址空间
加载完成之后,静态链接的程序就开始从ELF entry开始执行,之后就变成我们熟悉的状态机,唯一的行为就是取指执行。
我们通过readelf来查看a.out
文件的信息:
readelf -h a.out
输出:
我们这里看到,程序的入口地址是:Entry point address: 0x400a80
。我们接着用gdb来调试:
上图是笔者在gdb中调试的一些内容:
- 我们用
starti
来使得程序在第一条指令就停下,可以看到,程序确实是从0x400180
开始的,与我们上面查到的入口地址一致。 - 而我们用
cat /proc/[PID]/maps
来查看这个程序中内存的内容,看到我们之前提到的代码、数据、堆区、堆栈、vvar、vdso、vsyscall都已经被映射进了内存中。
调试的结果符合我们对静态程序加载时操作系统的行为的预期。
总结:静态链接与加载
- 由于我们不想将所有的代码都写到同一个文件中,所以我们需要分文件编写并编译,然后链接。
- 对于静态链接而言。编译某个文件时会将外部符号先留空为0,然后在静态链接时,将所有的留空的地方正确地进行填充。
- 在静态加载时,操作系统会调用mmap将我们运行这个程序所需要的代码、数据、堆区、堆栈、vvar、vdso、vsyscall等信息都映射进内存,然后从ELF entry的入口地址开始执行。
动态链接与加载
为什么需要动态链接/加载?
libc.so
中有300K 条指令,2 MiB 大小,每个程序如果都静态链接,浪费的空间很大,最好是整个系统里只有一个 libc 的副本,而每个用到 libc 的程序在运行时都可以用到 libc 中的代码。
- 文件系统里只有一个副本 (libc.so)
- 内存里只有一个副本
问题:真的整个操作系统里只有一个 libc 的副本吗?
- 方法 1: 看 Linux Kernel 的 trace
- 方法 2: 调试 Linux Kernel, 查看内存映射 (QEMU monitor)
- 方法 3: 简单做个实验
实验验证
我们通过创建一个动态链接库 libhuge.so
, 然后创建1000个进程去调用这个库中的foo
函数,该函数是128M 个 nop。如果程序不是动态链接的话,1000 * 128MB的内存占用足以撑爆大多数个人电脑的内存。而如果程序确实是动态链接的,即内存中只有一份代码,那么只会有很小的内存占用。我们是这样做的:
首先我们有huge.S
:
.global foo
foo:# 128MiB of nop.fill 1024 * 1024 * 128, 1, 0x90ret
这就是我们刚才说的一个动态链接库的源代码。我们一会儿会把他编译成 libhuge.so
供我们的huge.c
调用,我们的huge.c
是这样的:
#include <unistd.h>
#include <stdio.h>
int main(){foo(); // huge code, dynamic linkedprintf("pid = %d\n", getpid());while (1) sleep(1);
}
它会调用foo
函数,并在结束后打印自己的PID,然后睡眠。Makefile
如下:
LIB := /tmp/libhuge.soall: $(LIB) a.out$(LIB): huge.Sgcc -fPIC -shared huge.S -o $@a.out: huge.c $(LIB)gcc -o $@ huge.c -L/tmp -lhugeclean:rm -f *.so *.out $(LIB)
正如我们刚才所介绍的,我们会先将huge.S
编译成动态链接库libhuge.so
放在/tmp
下,然后我们的huge.c
回去动态链接这个库,并完成自己的代码。这还不够,我们要创建1000个进程来执行上述行为。这样才能验证我们的动态链接是不是在内存中真的只有一份代码,我们用下面的脚本来完成:
#!/bin/bash# for i in {1...1000}
for i in `seq 1 100`
doLD_LIBRARY_PATH=/tmp ./a.out &
donewait
# ps | grep "a.out" | grep -Po "^(\d)*" | xargs kill -9 用于清空生成的进程
实验证明,我们的操作系统能够很好地运行这1000个进程,并且内存只多占用了 400MB。也就是说,库中的foo
函数确实是动态链接的,内存中只有一份foo
的副本。
这在操作系统内核不难实现:所有以只读方式映射同一个文件的部分(如代码部分)时,都指向同一个副本,这个过程中会创建引用计数。
实现动态链接与加载
我们要实现动态链接,需要具体做到哪些事情呢?我们希望有一个库函数,其中包含一些代码,所有的进程链接这一段代码,这段代码在内存中只有一份拷贝。
实现动态加载(1)
需求1:加载纯粹的代码
编译成位置无关代码(Position Independent Code, PIC)即可,即引用代码(跳转)全部使用PC相对寻址。x86已经是这样了。直接把代码mmap进地址空间就行了。
# foo.S
.global fool
foo:movl $1, %eaxret
比如上面这段代码,它很简单,就是返回1。
loader.c
实现动态加载(2)
需求2:动态链接库只有纯粹的代码没有数据可不行,我们要能加载代码,并且代码有附带的数据。
这也好办,将代码和数据放在一起,然后都使用PC相对寻址就好了。
对于x86不支持rip相对寻址,我们可以通过 call __i686. get_pc_thunk.bx
来得到下条指令的地址。
我们有这样一段代码:
# bar.S
x: # 数据不能共享 (MAP_PRIVATE 方式映射).int 0.global bar
bar:addl $1, x(%rip)movl x(%rip), %eaxret
这相当于这样一段C代码:
int bar(){static int x = 0;return ++x;
}
即在静态区定义一个变量x,然后每次调用bar
函数时都会将x加一并返回。这也是一段位置无关代码,也可以直接mmap到内存中去执行。
实现动态加载(3)
需求3:比较难的是,一个文件或者一个动态链接库想要访问另外一个动态链接库导出的符号。因为我们想要知道的符号(比如bar
)也是动态加载的,也就是说,符号的地址是运行(加载)的时候才能确定的。而我们在编译(比如编译baz
时)的时候无法知道动态加载的符号bar
的地址。即允许访问其他动态链接库导出的符号(代码 / 数据)。
解决方法是我们用一张表,编译时编译成:call *table[bar]
。bar.o
会先被映射到进程的地址空间中,然后,我们要将baz.o
映射到地址空间时,我们会给baz
所保有的这张表中bar
所对应的表项填上正确的数值,即此时已知的bar
的地址。即我们为每个动态加载的符号(代码 / 数据)创建一张变,在运行时每次用到这些动态符号时,才解析符号的地址。
.global ..bar
..bat: bar:.quad 0.global baz
baz:movq baz(%rip), %rdicall *%rdiret
重填(相当于在运行时重做静态链接),这样行吗?不行,因为这样违背了我们动态链接的初衷:希望整个内存中只有一份代码的副本,而每次重填会导致每次都在内存中多一份代码的副本。而上面的解决方案,只有这张表,是需要复制的,这大幅减少了系统中冗余的内存。
总结
总结一下,实现动态链接和加载就是两个关键点:
- PIC位置无关代码,不管是代码还是数据,我们全部都要通过PC相对寻址,来使得它们是位置无关代码。
- 要引用动态链接库中的符号(编译时不知道)时,我们创建一张表,在运行(加载)时将其填上正确的地址。
例子
假如我们是十几种有这样一个动态链接(共享代码)的需求:
main
需要调用libc
中的printf
printf
需要调用libfoo
中的foo
我们知道,动态加载的程序最先并不是从main
的入口地址开始执行的。而是需要先由加载器libld
进行动态加载。libld
由操作系统加载,按照相互依赖相反的方向加载:
libld
加载libfoo
,一切顺利libld
加载libc
libc
对foo
的调用在编译时,被编译为call *libc.tab[FOO]
libld
调用dl_runtime_resolve
解析符号,填入libc.tab[FOO]
,因为此时libfoo
已经被加载到地址空间中了,foo
地址是已知的
libld
完成main
的初始化a.out
对printf
的调用在编译时,被编译成call *a.out.tab[PRINTF]
libld
机械printf
的地址,填入call *a.out.tab[PRINTF]
,因为此时libc
已经被加载到地址空间中了,printf
地址是已知的
所有的填表都完成之后,就跳转到main
的入口地址开始执行。
ELF 动态链接与加载
上面一种简化版的动态加载过程,实际的ELF动态加载比这要复杂一点。
GOT (Global Offset Table)
GOT
GOT:shared object用来存储动态符号的表格。库函数有,可执行文件也有。
所以用file
命令查看a.out
和libc.so
时都是 ”shared object“ 。也就是说我们生成的可执行文件其实和库函数是同一种文件格式。它们都需要调用其他的动态链接库中的符号。
GOT中储存的数据
- GOT[0]:.dynamic节的地址
- GOT[1]:link map,用于遍历依赖的动态链接库
- GOT[2]:dl_runtime_resolve 的地址,即call *GOT[2] 可以完成符号解析
- GOT[i]:程序所需的动态符号的地址(printf, …)
lazy symbol resolution 延迟符号解析
新需求:能否降低实际没有调用到的符号的开销?
程序可能会引用很多符号,但执行时可能大部分符号都没用到,逐个dl_runtime_resolve的话会造成不必要的开销。
lazy symbol resolution
想法:加载时设置为NULL,加载时来判断 / 解析
使用一小段 ”trampoline code“ 跳板代码
- 如果符号还未解析,就解析
- 跳转到解析后的符号执行
int print_internal(const char *fmt, ...){if (GOT[PRINRF]){GOT[PRINTF] = call_dl_runtime_reslove("printf");}return GOT[PRINTF]{...};
}
需要编译器把向printf(动态链接库)的调用翻译成call printf_internal
坏处:fast path多做一次判断:call + load + 判断 + jump,会损失一定的性能。
黑科技:让printf@GOT指向trampoline的下一条指令。
- 只有两条指令:call print@plt; jmp *a.out.GOT[PRINTF]
- 对现代处理器非常友好,因为有branch-target-buffer(BTB),几乎不损失性能。
Takeaways and Wrap-up
我们通过逐步把需求进行分解,从加载的视角理解链接:
- 需要加载一段代码(foo):PIC(通过使用PC相对寻址)+ mmap
- 代码需要伴随数据(bar):数据也使用PC相对寻址 + mmap
- 需要解析动态符号(baz):查表(GOT)、优化:PLT,lazy symbol resolve