动态链接和加载(1)
本次课要回答的问题
- 可执行文件是如何被操作系统加载的
- 什么是动态链接/动态加载
静态ELF加载器:实现
加载器
- 解析数据结构 + 复制到内存 + 跳转
- 创建进程运行时的初始状态(argv,envp,…)
loader-static.c
- 可以加载任何静态链接的代码, minimal.S, dfs-fork.c
- 并可以正确处理参数/环境变量 env.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <assert.h>
#include <elf.h>
#include <fcntl.h>
#include <sys/mman.h>
#define STK_SZ (1 << 20)
#define ROUND(x, align) (void *)(((uintptr_t)x) & ~(align - 1))
#define MOD(x, align) (((uintptr_t)x) & (align - 1))
#define push(sp, T, ...) ({ *((T*)sp) = (T)__VA_ARGS__; sp = (void *)((uintptr_t)(sp) + sizeof(T)); })
void execve_(const char *file, char *argv[], char *envp[]) {
// WARNING: This execve_ does not free process resources.
int fd = open(file, O_RDONLY);
assert(fd > 0);
Elf64_Ehdr *h = mmap(NULL, 4096, PROT_READ, MAP_PRIVATE, fd, 0);
assert(h != (void *)-1);
assert(h->e_type == ET_EXEC && h->e_machine == EM_X86_64);
Elf64_Phdr *pht = (Elf64_Phdr *)((char *)h + h->e_phoff);
for (int i = 0; i < h->e_phnum; i++) {
Elf64_Phdr *p = &pht[i];
if (p->p_type == PT_LOAD) {
int prot = 0;
if (p->p_flags & PF_R) prot |= PROT_READ;
if (p->p_flags & PF_W) prot |= PROT_WRITE;
if (p->p_flags & PF_X) prot |= PROT_EXEC;
void *ret = mmap(
ROUND(p->p_vaddr, p->p_align), // addr, rounded to ALIGN
p->p_memsz + MOD(p->p_vaddr, p->p_align), // length
prot, // protection
MAP_PRIVATE | MAP_FIXED, // flags, private & strict
fd, // file descriptor
(uintptr_t)ROUND(p->p_offset, p->p_align)); // offset
assert(ret != (void *)-1);
memset((void *)(p->p_vaddr + p->p_filesz), 0, p->p_memsz - p->p_filesz);
}
}
close(fd);
static char stack[STK_SZ], rnd[16];
void *sp = ROUND(stack + sizeof(stack) - 4096, 16);
void *sp_exec = sp;
int argc = 0;
// argc
while (argv[argc]) argc++;
push(sp, intptr_t, argc);
// argv[], NULL-terminate
for (int i = 0; i <= argc; i++)
push(sp, intptr_t, argv[i]);
// envp[], NULL-terminate
for (; *envp; envp++) {
if (!strchr(*envp, '_')) // remove some verbose ones
push(sp, intptr_t, *envp);
}
// auxv[], AT_NULL-terminate
push(sp, intptr_t, 0);
push(sp, Elf64_auxv_t, { .a_type = AT_RANDOM, .a_un.a_val = (uintptr_t)rnd } );
push(sp, Elf64_auxv_t, { .a_type = AT_NULL } );
asm volatile(
"mov $0, %%rdx;" // required by ABI
"mov %0, %%rsp;"
"jmp *%1" : : "a"(sp_exec), "b"(h->e_entry));
}
int main(int argc, char *argv[], char *envp[]) {
if (argc < 2) {
fprintf(stderr, "Usage: %s file [args...]\n", argv[0]);
exit(1);
}
execve_(argv[1], argv + 1, envp);
}解释一下我们这里的loader(它是动态链接的): 给我们的loader传入一个参数,它执行到某一个状态的时候,把我们的ELF文件(argv[1])搬到我们的loader程序的状态上,相当于我们的程序被替换了,但这个过程并没有执行execve,只是简单地用mmap系统调用(当然操作系统加载loader这个程序的时候会用execve)。
初始化堆栈
状态机是well-defined的。
表头 | 表头 | 长度(字节) |
---|---|---|
其他信息 | 未知 | |
Null auxiliary vector entry | 1 eightbyte each | |
Auxiliary vector entries | 2 eightbytes each | |
0 | 8 | |
Environment pointers | 8 bytes each | |
0 | 8 + 8*argc + %rsp | 8 |
Argument pointers | 8 + %rsp | argc 8 |
Argument count | %rsp | 8 |
Undefined | Low Address |
有趣之处
这是在操作系统上实现的。用 open , mmap, close实现了一个 execve。
动态链接和加载
为什么要动态加载
- 减少库函数的磁盘和内存拷贝
- 每个可执行文件里面都有所有的库函数拷贝那也太浪费了
- 只要遵守约定,不挑战库函数的版本(否则发布一个新版本就要重新编译全部程序)
这就有了”拆解应用程序”的需求
随着库函数越来越大,希望项目能够运行时链接。
动态链接,但不讲ELF,换一种方法。
- 如果编译器、链接器、加载器都受你控制
- 那你怎么设计实现一个“最直观”的动态链接格式?
- 如何改进,就得到了ELF!
- 假设编译器可以为你生成位置无关代码(PIC)
来看一下蒋神的设计(main part)
头文件
- dl.h(数据结构定义)
全家桶工具集
- dlbox.c(gcc, readdl, objdump, interp)
示例代码
- libc.S - 提供 putchar 和 exit
- libhello.S - 调用 putchar, 提供 hello
- main.S - 调用 hello, 提供 main
使用说明
1 |
|
会生成.dl格式的自定义可执行文件。这个可执行文件是不可以在操作系统上执行,需要自己的加载器。
并且我们的加载器是在当前目录中动态加载.dl文件(根据)的,如果先前没有生成所需要的.dl文件的话,我们的加载器会出现错误。
演示一下下
1 |
|
这个文件需要使用外部的putchar函数,所以需要call DSYM(putchar), DSYM表示动态链接的,也需要手动指明putchar函数所在的库libc.dl。它定义有一个hello函数,所以需要导出。也就是EXPORT(hello).
代码解析
首先来看一下dl.h文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35#define REC_SZ 32
#define DL_MAGIC "\x01\x14\x05\x14"
#ifdef __ASSEMBLER__
#define DL_HEAD __hdr: \
/* magic */ .ascii DL_MAGIC; \
/* file_sz */ .4byte (__end - __hdr); \
/* code_off */ .4byte (__code - __hdr)
#define DL_CODE .fill REC_SZ - 1, 1, 0; \
.align REC_SZ, 0; \
__code:
#define DL_END __end:
#define RECORD(sym, off, name) \
.align REC_SZ, 0; \
sym .8byte (off); .ascii name
#define IMPORT(sym) RECORD(sym:, 0, "?" #sym "\0")
#define EXPORT(sym) RECORD( , sym - __hdr, "#" #sym "\0")
#define LOAD(lib) RECORD( , 0, "+" lib "\0")
#define DSYM(sym) *sym(%rip)
#else
#include <stdint.h>
struct dl_hdr {
char magic[4];
uint32_t file_sz, code_off;
};
struct symbol {
int64_t offset;
char type, name[REC_SZ - sizeof(int64_t) - 1];
};
#endif- __ASSEMBLER__是一个内置的宏,它由编译器预定义,用于判断当前代码是否为汇编代码。在编写汇编代码时,编译器会自动定义这个宏。
- 怎么用汇编语言定义结构体呢? 为什么变量名前面要加.呢?这表示这是在当前偏移量下定义的。
- RECORD宏定义:.align REC_SZ, 0 表示将当前位置对齐到 REC_SZ 字节边界。
这行代码定义了一个标签 sym,并将 off 表示的偏移量存储到该标签处。.8byte 指令告诉汇编器为该标签分配一个 8 字节的存储空间,即使用一个 64 位无符号整数来存储偏移量。
.ascii name 表示将 name 参数表示的记录名称作为 ASCII 字符串嵌入到汇编代码中。.ascii 指令用于将一个字符串常量嵌入到汇编代码中。 - #define DSYM(sym) 这是间接跳转,先将 %rip 寄存器中存储的当前指令地址加上 hello 符号相对于当前指令的偏移量,得到函数地址,然后再根据这个地址的值进行跳转,而符号表结构体前八个字节就是函数的地址。
dlbox.c文件
1 |
|
来解释一下(按顺序):
首先需要dl_gcc各.S文件得到.dl。
1
2
3
4
5
6
7
8
9void dl_gcc(const char *path) {
char buf[256], *dot = strrchr(path, '.');
if (dot) {
*dot = '\0';
sprintf(buf, "gcc -m64 -fPIC -c %s.S && "
"objcopy -S -j .text -O binary %s.o %s.dl", path, path, path);
system(buf);
}
}原来命令行还可以这么写! 前一句生成64位的位置无关代码,然后再把代码段拷贝成.dl文件。这里主要是一些宏替换,并且由于我们的汇编代码格式是很严格地按照dl_hdr的形式写的,所以我们得到的其实是一个dl_lib的结构体。更直观地来感受一下,我们使用命令gcc -E main.S,得到宏替换展开的文件(如下):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27# 0 "main.S"
# 0 "<built-in>"
# 0 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 0 "<command-line>" 2
# 1 "main.S"
# 1 "dl.h" 1
# 2 "main.S" 2
__hdr: .ascii "\x01\x14\x05\x14"; .4byte (__end - __hdr); .4byte (__code - __hdr)
.align 32, 0; .8byte (0); .ascii "+" "libc.dl" "\0"
.align 32, 0; .8byte (0); .ascii "+" "libhello.dl" "\0"
.align 32, 0; hello: .8byte (0); .ascii "?" "hello" "\0"
.align 32, 0; .8byte (main - __hdr); .ascii "#" "main" "\0"
.fill 32 - 1, 1, 0; .align 32, 0; __code:
main:
call *hello(%rip)
call *hello(%rip)
call *hello(%rip)
call *hello(%rip)
movq $0, %rax
ret
__end:xxd main.dl命令得到二进制文件
1
2
3
4
5
6
7
8
9
10
11
12
13
1400000000: 0114 0514 e000 0000 c000 0000 0000 0000 ................
00000010: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000020: 0000 0000 0000 0000 2b6c 6962 632e 646c ........+libc.dl
00000030: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000040: 0000 0000 0000 0000 2b6c 6962 6865 6c6c ........+libhell
00000050: 6f2e 646c 0000 0000 0000 0000 0000 0000 o.dl............
00000060: 0000 0000 0000 0000 3f68 656c 6c6f 0000 ........?hello..
00000070: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000080: c000 0000 0000 0000 236d 6169 6e00 0000 ........#main...
00000090: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000000a0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000000b0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000000c0: ff15 9aff ffff ff15 94ff ffff ff15 8eff ................
000000d0: ffff ff15 88ff ffff 48c7 c000 0000 00c3 ........H.......libc.dl、libhello.dl、hello这三个符号都是填零的。只有main函数已经填上了正确的偏移。
可以反推,我们得到的.dl文件的格式是这样的:首先是__hdr头,这里有模数,文件的大小,以及代码段的偏移。 然后是符号表;符号表结束后,再32个字节填0作为分界线,然后是代码段。 妙哇妙哇!原来宏定义还可以这么用!然后是dl_interp函数来解释执行
1
2
3
4
5
6
7
8
9
10void dl_interp(const char *path) {
struct dlib *h = dlopen_chk(path);
int (*entry)() = NULL;
for (struct symbol *sym = h->symtab; sym->type; sym++)
if (strcmp(sym->name, "main") == 0)
entry = (void *)((char *)h + sym->offset);
if (entry) {
exit(entry());
}
}找到main函数,exit(entry()) 的作用就是在程序结束时执行 main 函数,并将其返回值作为程序的退出码。
1
2
3
4
5
6
7
8
9struct dlib *dlopen_chk(const char *path) {
struct dlib *lib = dlopen(path);
if (!lib) {
fprintf(stderr, "Not a valid dlib file: %s.\n", path);
exit(1);
}
return lib;
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29static struct dlib *dlopen(const char *path) {
struct dl_hdr hdr;
struct dlib *h;
int fd = open(path, O_RDONLY);
if (fd < 0) goto bad;
if (read(fd, &hdr, sizeof(hdr)) < sizeof(hdr)) goto bad;
if (strncmp(hdr.magic, DL_MAGIC, strlen(DL_MAGIC)) != 0) goto bad;
h = mmap(NULL, hdr.file_sz, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE, fd, 0);
if (h == (void *)-1) goto bad;
h->symtab = (struct symbol *)((char *)h + REC_SZ);
h->path = path;
for (struct symbol *sym = h->symtab; sym->type; sym++) {
switch (sym->type) {
case '+': dlload(sym); break; // (recursively) load
case '?': sym->offset = (uintptr_t)dlsym(sym->name); break; // resolve
case '#': dlexport(sym->name, (char *)h + sym->offset); break; // export
}
}
return h;
bad:
if (fd > 0) close(fd);
return NULL;
}- 这里要打开.dl文件,并且把这个文件用mmap函数映射到dlbox进程的地址空间,此时其他.dl文件的代码段就会映射到dlbox进程的地址空间中,也就可以确定函数的地址了,这也就和动态链接的链接时绑定思想不谋而合。打开这个可执行程序时,h->symtab = (struct symbol *)((char *)h + REC_SZ);这里是初始化符号表。
- 如果遇到.dl作为符号表项(‘+’),则用dlload递归加载,dlload则是调用dlopen实现的。
- 如果遇到符号表项的某一项标记位’?’,表示引用外部符号,我们通过查表dlsym函数来填表。
- 可以想象,这是一个递归的过程,递归地填表。如果变量是这个main程序的函数(‘#’),我们直接可以确定该函数的地址。也就是表头的地址加上偏移。
- 如果这个符号是个.dl文件,则需要调用dlopen把这个文件整体映射进进程的地址空间(递归),映射完后,所有符号的地址都会被确定,然后我们就可以遍历来填sym表了。这个sym表记录了所有符号,也是一个结构体,一开始所有的项的name字段初始化为NULL。
1
case '#': dlexport(sym->name, (char *)h + sym->offset); break;
1
2
3
4
5
6
7
8
9static void dlexport(const char *name, void *addr) {
for (int i = 0; i < LENGTH(syms); i++)
if (!syms[i].name[0]) {
syms[i].offset = (uintptr_t)addr; // load-time offset
strcpy(syms[i].name, name);
return;
}
assert(0);
}- 如果这个符号是外部的函数,由于我们先包含.dl的库文件,所以外部符号这时候都会解析完毕,我们就可以直接填入正确的地址。
- 上面的思想主要是:我们在装载动态库的时候,我们不像静态链接那样可以知道这个模块是装载在哪个位置的。我们的解决方法是通过间接跳转在本模块的某个位置(这是可以确定的),这个位置就有这个函数的地址的信息。由于我们都是按模块装载的,所以这一点并不难实现,通过添加一个sym的全局变量结构数组,在每装载一个模块(.dl)时,就把该模块的所有这个模块的export类型的变量全部填入这个sym数组中。
然后,有了这个sym数组,就可以开始回填到每个模块的符号表中带有(‘?’)的符号offset字段了。
反思与改进(最精彩的部分)
一些小缺陷
- 存储保护和加载位置。允许将.dl中的一部分以某个指定的权限映射到内存的某个位置—>程序头表
- 允许自由指定加载器—>加入INTERP
- 空间浪费 —>字符串存储在常量池,统一通过“指针”访问(这也是ELF难读的原因)
另一个大缺陷
1 |
|
一种写法,两种情况
- 来自于其他编译单元(静态链接)
- 动态链接库
例如,有a.o和b.o静态链接再和lib.so动态链接,如果a.o中引用了一个外部符号foo,那么该如何判断这个符号究竟是属于哪个单元呢?如果只是简单地宏替换, call *foo(%rip),但其实这样是效率很低的。
“发明”PLT & GOT
先编译为相对于%rip的简单的call调用,在链接的时候,如果发现这是一个本单元的符号,直接相对于rip寻址;如果发现这是一个外部(动态链接)库的话,就需要plt这条entry,再把地址填上去。
我们的“符号表”就是Global Offset Table(GOT).
1 |
|
咦,这条jmp指令不是有点熟悉吗?和我们的DSYM很相似。这不印证了我们的猜想吗?
最后一个问题:数据
不管多少个静态库动态库,但我们的程序只有一个errno,environ,stdout。