glibc动态链接实现方式
注:由于本文主要是从glibc的源码出发,研究动态链接的细节实现方式,因此不会介绍动态链接的基本概念或者过程。
装载时
首先,一个问题是,共享库本身是何时被加载到进程虚拟地址空间的?当我们运行一个动态链接的可执行文件时,Linux内核会将控制权转移给可执行文件的interpreter字段(也就是动态链接器ld)。可执行文件本身的加载,都是由动态链接器来处理的。动态链接器会通过mmap将可执行文件映射到虚存空间。有一个问题是,这个可执行文件所依赖的共享库,究竟是刚刚加载这个可执行文件的时候,就一并加载齐全,还是在该共享库中的某个函数第一次被调用的时候才被加载?加载的具体过程又是什么样的?
首先明确“一个可执行文件所依赖的共享库”是什么。实际上一个可执行文件本身可能依赖于很多共享库,而这些共享库可能又依赖于其他共享库。从链接视图看,每一个共享库都有一个.dynamic节。.dynamic节由很多entry构成,每个entry包含一个类型和一个名称。下面是elf.h当中.dynamic节的构成。
typedef struct
{
Elf64_Sxword d_tag; /* Dynamic entry type */
union
{
Elf64_Xword d_val; /* Integer value */
Elf64_Addr d_ptr; /* Address value */
} d_un;
} Elf64_Dyn;
通过readelf -d命令,即可获得.dynamic节。
$ readelf -d hello
Dynamic section at offset 0x2dc8 contains 27 entries:
标记 类型 名称/值
0x0000000000000001 (NEEDED) 共享库:[libc.so.6]
0x000000000000000c (INIT) 0x1000
0x000000000000000d (FINI) 0x11f8
0x0000000000000019 (INIT_ARRAY) 0x3db8
0x000000000000001b (INIT_ARRAYSZ) 8 (bytes)
0x000000000000001a (FINI_ARRAY) 0x3dc0
0x000000000000001c (FINI_ARRAYSZ) 8 (bytes)
0x000000006ffffef5 (GNU_HASH) 0x3a0
0x0000000000000005 (STRTAB) 0x470
0x0000000000000006 (SYMTAB) 0x3c8
0x000000000000000a (STRSZ) 132 (bytes)
0x000000000000000b (SYMENT) 24 (bytes)
0x0000000000000015 (DEBUG) 0x0
0x0000000000000003 (PLTGOT) 0x3fb8
0x0000000000000002 (PLTRELSZ) 24 (bytes)
0x0000000000000014 (PLTREL) RELA
0x0000000000000017 (JMPREL) 0x5e8
0x0000000000000007 (RELA) 0x528
0x0000000000000008 (RELASZ) 192 (bytes)
0x0000000000000009 (RELAENT) 24 (bytes)
0x000000000000001e (FLAGS) BIND_NOW
0x000000006ffffffb (FLAGS_1) 标志: NOW PIE
0x000000006ffffffe (VERNEED) 0x508
0x000000006fffffff (VERNEEDNUM) 1
0x000000006ffffff0 (VERSYM) 0x4f4
0x000000006ffffff9 (RELACOUNT) 3
0x0000000000000000 (NULL) 0x0
在执行视图中,按道理讲许多节都被抛弃了。然而.dynamic节会被保留在执行视图中。.dynamic节被加载到执行视图中类型为PT_DYNAMIC的段。里面的内容和.dynamic节的内容是一样的格式。所有程序需要的共享库,都会被保存在.dynamic节当中d_tag为NEEDED的entry当中。这样,动态链接器只需要找出PT_DYNAMIC段,然后从中找到所有的NEEDED的entry,从中定位到共享库即可。然后递归地对每个共享库找依赖。直到所有的依赖已经找到,即可找到程序的所有直接/间接依赖共享库。这一个过程通常使用宽度优先搜索完成。
示例程序hello.c
#include <stdio.h>
volatile int a = -1;
int main() {
while (a == -1) {}
printf("Hello world!");
return 0;
}
我们首先构造了一个死循环,以验证程序使用到的共享库,是否在共享库中的函数被调用之前,就已经被全部加载到进程虚拟地址空间内。
$ cat /proc/13805/maps
55e6edc51000-55e6edc52000 r--p 00000000 103:02 12466308 /home/hhusjr/hello
55e6edc52000-55e6edc53000 r-xp 00001000 103:02 12466308 /home/hhusjr/hello
55e6edc53000-55e6edc54000 r--p 00002000 103:02 12466308 /home/hhusjr/hello
55e6edc54000-55e6edc55000 r--p 00002000 103:02 12466308 /home/hhusjr/hello
55e6edc55000-55e6edc56000 rw-p 00003000 103:02 12466308 /home/hhusjr/hello
7fe1a9709000-7fe1a970b000 rw-p 00000000 00:00 0
7fe1a970b000-7fe1a9731000 r--p 00000000 103:02 4726182 /usr/lib/x86_64-linux-gnu/libc-2.32.so
7fe1a9731000-7fe1a989e000 r-xp 00026000 103:02 4726182 /usr/lib/x86_64-linux-gnu/libc-2.32.so
7fe1a989e000-7fe1a98ea000 r--p 00193000 103:02 4726182 /usr/lib/x86_64-linux-gnu/libc-2.32.so
7fe1a98ea000-7fe1a98eb000 ---p 001df000 103:02 4726182 /usr/lib/x86_64-linux-gnu/libc-2.32.so
7fe1a98eb000-7fe1a98ee000 r--p 001df000 103:02 4726182 /usr/lib/x86_64-linux-gnu/libc-2.32.so
7fe1a98ee000-7fe1a98f1000 rw-p 001e2000 103:02 4726182 /usr/lib/x86_64-linux-gnu/libc-2.32.so
7fe1a98f1000-7fe1a98f7000 rw-p 00000000 00:00 0
7fe1a9915000-7fe1a9916000 r--p 00000000 103:02 4725966 /usr/lib/x86_64-linux-gnu/ld-2.32.so
7fe1a9916000-7fe1a993a000 r-xp 00001000 103:02 4725966 /usr/lib/x86_64-linux-gnu/ld-2.32.so
7fe1a993a000-7fe1a9943000 r--p 00025000 103:02 4725966 /usr/lib/x86_64-linux-gnu/ld-2.32.so
7fe1a9943000-7fe1a9944000 r--p 0002d000 103:02 4725966 /usr/lib/x86_64-linux-gnu/ld-2.32.so
7fe1a9944000-7fe1a9946000 rw-p 0002e000 103:02 4725966 /usr/lib/x86_64-linux-gnu/ld-2.32.so
7fffd17e1000-7fffd1802000 rw-p 00000000 00:00 0 [stack]
7fffd192d000-7fffd1931000 r--p 00000000 00:00 0 [vvar]
7fffd1931000-7fffd1933000 r-xp 00000000 00:00 0 [vdso]
ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0 [vsyscall]
查看内存映射表,可以看出尽管printf还没被调用,libc已经被加载到虚拟地址空间了。我们可以推测动态链接器在装载可执行文件的时候,就立刻将所有依赖的共享库加载到进程虚拟地址空间了。下面通过研究glibc的源码,理清动态链接器装载可执行文件的详细过程。
内核将控制权交给ld.so的入口点处。第一个问题是,ld.so的入口点是谁。ld.so的入口点,是rtld.c中的_dl_start函数。这个函数首先完成ld.so的自重定位,然后进入_dl_main函数,开始进行真正的装载。省略一些无关紧要的部分。下面展示部分核心代码。
首先是link_map的创建。link_map包含了可执行文件的链接信息。(rtld.c)
main_map = _dl_new_object ((char *) "", "", lt_executable, NULL,
__RTLD_OPENEXEC, LM_ID_BASE);
assert (main_map != NULL);
main_map->l_phdr = phdr;
main_map->l_phnum = phnum;
main_map->l_entry = *user_entry;
/* Even though the link map is not yet fully initialized we can add
it to the map list since there are no possible users running yet. */
_dl_add_to_namespace_list (main_map, LM_ID_BASE);
assert (main_map == GL(dl_ns)[LM_ID_BASE]._ns_loaded);
然后是一趟扫描,扫描程序头表。
for (ph = phdr; ph < &phdr[phnum]; ++ph)
switch (ph->p_type)
{
case PT_PHDR:
/* Find out the load address. */
/* 这里可以看出,l_addr存放的是程序头表在实际内存中地址和在ELF文件内的地址之差 */
/* 后续,假设知道了一个程序头在文件中的偏移地址,只需要将其加上l_addr,即可知道其内存中的地址 */
main_map->l_addr = (ElfW(Addr)) phdr - ph->p_vaddr;
break;
case PT_DYNAMIC:
/* This tells us where to find the dynamic section,
which tells us everything we need to do. */
/* 这样就可以找到.dynamic节了,l_ld中存放.dynamic节内存中的起始地址 */
main_map->l_ld = (void *) main_map->l_addr + ph->p_vaddr;
break;
case PT_LOAD:
/* PT_LOAD段需要被加载到虚拟地址空间 */
{
ElfW(Addr) mapstart;
ElfW(Addr) allocend;
/* Remember where the main program starts in memory. */
/* 计算出这个段要被加载到虚拟地址空间的哪个位置,l_map_start中保存着所有PT_LOAD段最开始的位置。 */
mapstart = (main_map->l_addr
+ (ph->p_vaddr & ~(GLRO(dl_pagesize) - 1)));
if (main_map->l_map_start > mapstart)
main_map->l_map_start = mapstart;
/* Also where it ends. */
/* 所有PT_LOAD段结束的位置 */
allocend = main_map->l_addr + ph->p_vaddr + ph->p_memsz;
if (main_map->l_map_end < allocend)
main_map->l_map_end = allocend;
if ((ph->p_flags & PF_X) && allocend > main_map->l_text_end)
main_map->l_text_end = allocend;
}
break;
经过一些复杂的操作(省略),然后将dynamic段内容存到link_map中。
if (! rtld_is_main)
{
/* Extract the contents of the dynamic section for easy access. */
elf_get_dynamic_info (main_map, NULL);
/* Set up our cache of pointers into the hash table. */
_dl_setup_hash (main_map);
}
dynamic段的内容会被保存到link_map的info字段中。然后又是一堆复杂的处理。然后
/* Load all the libraries specified by DT_NEEDED entries. If LD_PRELOAD
specified some libraries to load, these are inserted before the actual
dependencies in the executable's searchlist for symbol resolution. */
{
RTLD_TIMING_VAR (start);
rtld_timer_start (&start);
_dl_map_object_deps (main_map, preloads, npreloads, mode == trace, 0);
rtld_timer_accum (&load_time, start);
}
这段代码将所有DT_NEEDED所需要的共享库,通过_dl_map_object_deps函数全部加载。这个函数与函数的symbol resolution非常重要,但是实现的非常复杂。
依赖库的加载:_dl_map_object_deps
核心的部分是一个BFS:
/* Process each element of the search list, loading each of its
auxiliary objects and immediate dependencies. Auxiliary objects
will be added in the list before the object itself and
dependencies will be appended to the list as we step through it.
This produces a flat, ordered list that represents a
breadth-first search of the dependency tree.
The whole process is complicated by the fact that we better
should use alloca for the temporary list elements. But using
alloca means we cannot use recursive function calls. */
这里提到了search list。前面加载的时候,创建了一个link_map。link_map下有一个l_searchlist字段。这个字段的含义是,包含了动态链接可执行文件的所有直接/间接的依赖的link_map(且按照依赖的顺序排列,后面符号解析的时候会有优先级的问题)构成的数组。这里再次可见,一个link_map会与一个共享对象关联,里面记录了这个共享对象的许多信息。
struct r_scope_elem
{
/* Array of maps for the scope. */
struct link_map **r_list;
/* Number of entries in the scope. */
unsigned int r_nlist;
} l_searchlist;
而这个BFS,就是不断进行下面的迭代。处理search_list里面的每一个元素,将它的依赖按顺序放入search_list数组当中。同时,每遇到一个依赖的模块,就会通过_dl_map_object将其文件打开并映射到虚存空间。
for (d = l->l_ld; d->d_tag != DT_NULL; ++d)
if (__builtin_expect (d->d_tag, DT_NEEDED) == DT_NEEDED)
{
/* Map in the needed object. */
struct link_map *dep;
/* Recognize DSTs. */
name = expand_dst (l, strtab + d->d_un.d_val, 0);
/* Store the tag in the argument structure. */
args.name = name;
// openaux内部会调用_dl_map_object
int err = _dl_catch_exception (&exception, openaux, &args);
...
_dl_map_object首先根据依赖模块的名称找到实际依赖共享库的路径。然后打开依赖的共享库得到fd,然后调用_dl_map_object_from_fd函数完成最终的映射。此过程中,将使用dl_new_object函数,为新映射的共享库构造link_map结构,然后通过调用_dl_add_to_namespace_list函数将该link_map添加到已加载的link_map链表当中。
/* Add the new link_map NEW to the end of the namespace list. */
void
_dl_add_to_namespace_list (struct link_map *new, Lmid_t nsid)
{
/* We modify the list of loaded objects. */
__rtld_lock_lock_recursive (GL(dl_load_write_lock));
if (GL(dl_ns)[nsid]._ns_loaded != NULL)
{
struct link_map *l = GL(dl_ns)[nsid]._ns_loaded;
while (l->l_next != NULL)
l = l->l_next;
new->l_prev = l;
/* new->l_next = NULL; Would be necessary but we use calloc. */
l->l_next = new;
}
else
GL(dl_ns)[nsid]._ns_loaded = new;
++GL(dl_ns)[nsid]._ns_nloaded;
new->l_serial = GL(dl_load_adds);
++GL(dl_load_adds);
__rtld_lock_unlock_recursive (GL(dl_load_write_lock));
}
这里的这个链表,因为引入了namespace,比较复杂。实际上早期的ld直接使用的是一个全局变量保存已经加载好的link_map。
装载时重定位
现在,可执行文件本身及所有直接/间接的依赖共享库,已经全部被加载到进程虚拟地址空间。并且它们每个都对应了一个link_map。我们可以将他们每一个都称为一个“模块”。下一步,就需要对每一个模块进行装载时重定位。这个步骤体现在dl_main函数中,按顺序对每一个模块调用了_dl_relocate_object函数。
/* Now we have all the objects loaded. Relocate them all except for
the dynamic linker itself. We do this in reverse order so that copy
relocs of earlier objects overwrite the data written by later
objects. We do not re-relocate the dynamic linker itself in this
loop because that could result in the GOT entries for functions we
call being changed, and that would break us. It is safe to relocate
the dynamic linker out of order because it has no copy relocs (we
know that because it is self-contained). */
int consider_profiling = GLRO(dl_profile) != NULL;
/* If we are profiling we also must do lazy reloaction. */
GLRO(dl_lazy) |= consider_profiling;
RTLD_TIMING_VAR (start);
rtld_timer_start (&start);
unsigned i = main_map->l_searchlist.r_nlist;
while (i-- > 0)
{
struct link_map *l = main_map->l_initfini[i];
/* While we are at it, help the memory handling a bit. We have to
mark some data structures as allocated with the fake malloc()
implementation in ld.so. */
struct libname_list *lnp = l->l_libname->next;
while (__builtin_expect (lnp != NULL, 0))
{
lnp->dont_free = 1;
lnp = lnp->next;
}
/* Also allocated with the fake malloc(). */
l->l_free_initfini = 0;
if (l != &GL(dl_rtld_map))
_dl_relocate_object (l, l->l_scope, GLRO(dl_lazy) ? RTLD_LAZY : 0,
consider_profiling);
/* Add object to slot information data if necessasy. */
if (l->l_tls_blocksize != 0 && tls_init_tp_called)
_dl_add_to_slotinfo (l, true);
}
装载时重定位有下面两个作用:
- 重定位所有DT_TEXTREL项。如果共享库本身不是PIC的,它内部的一些绝对地址引用(比如函数指针),没法使用RIP相对寻址来实现,只能在装载时,由动态链接器来修正这些地址。这一点在现在已经很不常见了。
- 重定位所有GOT表中的项。这一点是最为重要的。比如一个模块引用另一个模块的某一个变量,因为在模块被加载前,变量的绝对地址是不知道的,这个也需要在装载时进行修复。此外,延迟绑定(PLT)当中,_dl_runtime_resolve函数的地址也需要保存到GOT表当中去,以便于用户程序调用。
dl_relocate_object的实现中,我们跳过对DT_TEXTREL的处理的代码分析(做一下概括:TEXTREL类型的重定位,指的是地址在不可写入的内存区域(可以理解为代码段)的重定位。ld没法直接修改TEXTREL里面的值,于是需要先通过mprotect增加写权限,然后修改完了再用mprotect给他去除掉写权限)。
然后看对于GOT表的处理。在_dl_relocate_object中,我们发现它直接调用了一个宏来实现
ELF_DYNAMIC_RELOCATE (l, lazy, consider_profiling, skip_ifunc);
观察这个宏的定义:
do { \
int edr_lazy = elf_machine_runtime_setup ((map), (lazy), \
(consider_profile)); \
ELF_DYNAMIC_DO_REL ((map), edr_lazy, skip_ifunc); \
ELF_DYNAMIC_DO_RELA ((map), edr_lazy, skip_ifunc); \
} while (0)
首先,调用了elf_machine_runtime_setup函数。首先判断是否有需要进行PLT重定位的字段。所谓PLT重定位,在链接视图上看,实际上就是.rel.plt节。这个节记录了很多条需要在延迟加载的时候进行重定位的记录。里面包含需要被延迟加载的函数在.got.plt当中的地址(重定位的时候,就把这个地址里面的值填成绝对地址即可),以及符号的下标(index)。这个下标会对应到.dynsym节(也就是说这个下标指的是在.dynsym节当中的索引)。.dynsym节则记录了符号信息(比如名称字符串等等)。
在运行视图上,很多的节已经被抛弃了,也没有节头可以用。那这些节是如何被动态连接器访问到的?实际上在程序头当中,在.dynamic段下面,有下面的字段,可以直接映射到这些重定位有关的节:
- DT_JMPREL字段:记录了.rel.plt节的起始地址。
- DT_PLTGOT字段:记录了.got.plt节的起始地址。
/* Set up the loaded object described by L so its unrelocated PLT
entries will jump to the on-demand fixup code in dl-runtime.c. */
static inline int __attribute__ ((unused, always_inline))
elf_machine_runtime_setup (struct link_map *l, int lazy, int profile)
{
Elf64_Addr *got;
extern void _dl_runtime_resolve_fxsave (ElfW(Word)) attribute_hidden;
extern void _dl_runtime_resolve_xsave (ElfW(Word)) attribute_hidden;
extern void _dl_runtime_resolve_xsavec (ElfW(Word)) attribute_hidden;
extern void _dl_runtime_profile_sse (ElfW(Word)) attribute_hidden;
extern void _dl_runtime_profile_avx (ElfW(Word)) attribute_hidden;
extern void _dl_runtime_profile_avx512 (ElfW(Word)) attribute_hidden;
// 首先判断有没有需要延迟绑定的函数,通过判断有没有DT_JMPREL字段即可(如果没有,则说明不存在.rel.plt节,那自然没有函数需要重定位)。如果没有需要延迟绑定的,就不用填充.got.plt表里面这些为延迟绑定准备的项目了。
if (l->l_info[DT_JMPREL] && lazy)
{
/* The GOT entries for functions in the PLT have not yet been filled
in. Their initial contents will arrange when called to push an
offset into the .rel.plt section, push _GLOBAL_OFFSET_TABLE_[1],
and then jump to _GLOBAL_OFFSET_TABLE_[2]. */
// 这里存放了.got.plt的起始地址
got = (Elf64_Addr *) D_PTR (l, l_info[DT_PLTGOT]);
/* If a library is prelinked but we have to relocate anyway,
we have to be able to undo the prelinking of .got.plt.
The prelinker saved us here address of .plt + 0x16. */
if (got[1])
{
l->l_mach.plt = got[1] + l->l_addr;
l->l_mach.gotplt = (ElfW(Addr)) &got[3];
}
/* Identify this shared object. */
// 这个模块的本身的link_map的地址存放在.got.plt的第一项
*(ElfW(Addr) *) (got + 1) = (ElfW(Addr)) l;
/* The got[2] entry contains the address of a function which gets
called to get the address of a so far unresolved function and
jump to it. The profiling extension of the dynamic linker allows
to intercept the calls to collect information. In this case we
don't store the address in the GOT so that all future calls also
end in this function. */
// 第二项是符号解析函数(也就是_dl_runtime_resolve)的地址
if (__glibc_unlikely (profile))
{
if (CPU_FEATURE_USABLE (AVX512F))
*(ElfW(Addr) *) (got + 2) = (ElfW(Addr)) &_dl_runtime_profile_avx512;
else if (CPU_FEATURE_USABLE (AVX))
*(ElfW(Addr) *) (got + 2) = (ElfW(Addr)) &_dl_runtime_profile_avx;
else
*(ElfW(Addr) *) (got + 2) = (ElfW(Addr)) &_dl_runtime_profile_sse;
if (GLRO(dl_profile) != NULL
&& _dl_name_match_p (GLRO(dl_profile), l))
/* This is the object we are looking for. Say that we really
want profiling and the timers are started. */
GL(dl_profile_map) = l;
}
else
{
/* This function will get called to fix up the GOT entry
indicated by the offset on the stack, and then jump to
the resolved address. */
if (GLRO(dl_x86_cpu_features).xsave_state_size != 0)
*(ElfW(Addr) *) (got + 2)
= (CPU_FEATURE_USABLE (XSAVEC)
? (ElfW(Addr)) &_dl_runtime_resolve_xsavec
: (ElfW(Addr)) &_dl_runtime_resolve_xsave);
else
*(ElfW(Addr) *) (got + 2)
= (ElfW(Addr)) &_dl_runtime_resolve_fxsave;
}
}
if (l->l_info[ADDRIDX (DT_TLSDESC_GOT)] && lazy)
*(ElfW(Addr)*)(D_PTR (l, l_info[ADDRIDX (DT_TLSDESC_GOT)]) + l->l_addr)
= (ElfW(Addr)) &_dl_tlsdesc_resolve_rela;
return lazy;
}
可以看出,这个过程主要是将link_map和_dl_runtime_resolve的地址,分别保存到.got.plt的第一项和第二项。便于后面的调用。
然后看ELF_DYNAMIC_DO_REL(A)这两个宏。REL和RELA是两种类似(但是有一些不同的重定位表)。可以先不考虑它们的区别。
# define ELF_DYNAMIC_DO_REL(map, lazy, skip_ifunc) \
_ELF_DYNAMIC_DO_RELOC (REL, Rel, map, lazy, skip_ifunc, _ELF_CHECK_REL)
# define ELF_DYNAMIC_DO_RELA(map, lazy, skip_ifunc) \
_ELF_DYNAMIC_DO_RELOC (RELA, Rela, map, lazy, skip_ifunc, _ELF_CHECK_REL)
我们发现它们都是使用了_ELF_DYNAMIC_DO_RELOC宏。
do { \
// ranges;记录了所有需要进行重定位的重定位项。lazy用来区分这个重定位项是装载时重定位,还是运行时重定位(PLT)。
struct { ElfW(Addr) start, size; \
__typeof (((ElfW(Dyn) *) 0)->d_un.d_val) nrelative; int lazy; } \
ranges[2] = { { 0, 0, 0, 0 }, { 0, 0, 0, 0 } }; \
\
// 如果存在重定位表
if ((map)->l_info[DT_##RELOC]) \
{ \
// 存储重定位表的起始位置
ranges[0].start = D_PTR ((map), l_info[DT_##RELOC]); \
// 存储重定位表的长度
ranges[0].size = (map)->l_info[DT_##RELOC##SZ]->d_un.d_val; \
// 存储重定位表中的相对重定位项数量
if (map->l_info[VERSYMIDX (DT_##RELOC##COUNT)] != NULL) \
ranges[0].nrelative \
= map->l_info[VERSYMIDX (DT_##RELOC##COUNT)]->d_un.d_val; \
} \
// 防止对.rel.plt内的内容进行重定位(因为它们需要被延迟加载)
// 首先判断是否存在.rel.plt,然后判断.rel.plt的是否使用的是当前的这种重定位表
if ((map)->l_info[DT_PLTREL] \
&& (!test_rel || (map)->l_info[DT_PLTREL]->d_un.d_val == DT_##RELOC)) \
{ \
// .rel.plt的起始位置和大小
ElfW(Addr) start = D_PTR ((map), l_info[DT_JMPREL]); \
ElfW(Addr) size = (map)->l_info[DT_PLTRELSZ]->d_un.d_val; \
\
// 如果.rel.plt在重定位表的最后,那就把直接通过减去.rel.plt的大小,以从装载时重定位表当中删除掉.rel.plt表
if (ranges[0].start + ranges[0].size == (start + size)) \
ranges[0].size -= size; \
if (ELF_DURING_STARTUP \
|| (!(do_lazy) \
&& (ranges[0].start + ranges[0].size) == start)) \
{ \
/* Combine processing the sections. */ \
ranges[0].size += size; \
} \
else \
{ \
// 然后将需要运行时重定位的.rel.plt放到ranges[1]当中
ranges[1].start = start; \
ranges[1].size = size; \
ranges[1].lazy = (do_lazy); \
} \
} \
\
if (ELF_DURING_STARTUP) \
elf_dynamic_do_##reloc ((map), ranges[0].start, ranges[0].size, 表 \
ranges[0].nrelative, 0, skip_ifunc); \
else \
{ \
int ranges_index; \
for (ranges_index = 0; ranges_index < 2; ++ranges_index) \
// 然后针对ranges[0]和ranges[1]分别作重定位
elf_dynamic_do_##reloc ((map), \
ranges[ranges_index].start, \
ranges[ranges_index].size, \
ranges[ranges_index].nrelative, \
ranges[ranges_index].lazy, \
skip_ifunc); \
} \
} while (0)
可以发现装载时重定位就是在这个地方最终完成的。此时,GOT表里面已经填入了正确的地址。所有可能的TEXTREL也被修复。至此,装载的过程基本完成。接下来控制权就被转交给用户程序的入口。开始可执行程序的执行了。可以发现装载时重定位就是在这个地方最终完成的。此时,GOT表里面已经填入了正确的地址。所有可能的TEXTREL也被修复。至此,装载的过程基本完成。接下来控制权就被转交给用户程序的入口。开始可执行程序的执行了。此时,除了延迟加载之外,所有的绝对地址引用均已被修复。
运行时重定位
现在,用户的程序正在正常执行当中。但是ld的任务还没有完成。当用户程序调用另一个模块内的程序的时候,仍然需要由ld进行协调。
#include <stdio.h>
volatile int a = 3;
int main() {
printf("hello, %d\n", a);
}
这段代码调用了glibc当中的printf函数。下面分析其执行流程。使用gdb。
Dump of assembler code for function main:
0x0000555555555149 <+0>: endbr64
0x000055555555514d <+4>: push %rbp
0x000055555555514e <+5>: mov %rsp,%rbp
0x0000555555555151 <+8>: mov 0x2eb9(%rip),%eax # 0x555555558010 <a>
0x0000555555555157 <+14>: mov %eax,%esi
0x0000555555555159 <+16>: lea 0xea4(%rip),%rdi # 0x555555556004
0x0000555555555160 <+23>: mov $0x0,%eax
=> 0x0000555555555165 <+28>: callq 0x555555555050 <printf@plt>
0x000055555555516a <+33>: mov $0x0,%eax
0x000055555555516f <+38>: pop %rbp
0x0000555555555170 <+39>: retq
si
Dump of assembler code for function printf@plt:
=> 0x0000555555555050 <+0>: endbr64
0x0000555555555054 <+4>: bnd jmpq *0x2f75(%rip) # 0x555555557fd0 <printf@got.plt>
0x000055555555505b <+11>: nopl 0x0(%rax,%rax,1)
End of assembler dump.
可以发现,我们进入了.plt节的printf@plt。这里面只有一条jmp指令。jmp的目标,是printf@got.plt,也就是在.got.plt节当中,printf对应的地址。
在重定位之后,我们很容易理解这个过程。因为此时,.got.plt当中,printf对应的地址已经被填入glibc库printf的正确地址。通过一个jmp*就可以跳转过去。但是在函数第一次被调用的时候,这个.got.plt里面的值是什么呢?实际上,这里面的值默认将会被填入.plt当中,1030位置对应的地址。
Disassembly of section .plt:
0000000000001020 <.plt>:
1020: ff 35 9a 2f 00 00 pushq 0x2f9a(%rip) # 3fc0 <_GLOBAL_OFFSET_TABLE_+0x8>
1026: f2 ff 25 9b 2f 00 00 bnd jmpq *0x2f9b(%rip) # 3fc8 <_GLOBAL_OFFSET_TABLE_+0x10>
102d: 0f 1f 00 nopl (%rax)
1030: f3 0f 1e fa endbr64
1034: 68 00 00 00 00 pushq $0x0
1039: f2 e9 e1 ff ff ff bnd jmpq 1020 <.plt>
103f: 90 nop
然后,程序将把函数在.rel.plt当中的id放入到栈中,然后跳转到.plt的开始位置。开始位置将GOT当中的第一项(也就是之前已经装载时重定位阶段设定好的link_map地址)放入栈中,然后跳转到GOT中的第二项(也就是_dl_runtime_resolve的地址)。
_dl_runtime_resolve是一个汇编语言编写的trampoline。
.globl _dl_runtime_resolve
.hidden _dl_runtime_resolve表表
.type _dl_runtime_resolve, @function
.align 16
cfi_startproc
_dl_runtime_resolve:
cfi_adjust_cfa_offset(16) # Incorporate PLT
_CET_ENDBR
# if DL_RUNTIME_RESOLVE_REALIGN_STACK
# if LOCAL_STORAGE_AREA != 8
# error LOCAL_STORAGE_AREA must be 8
# endif
pushq %rbx # push subtracts stack by 8.
cfi_adjust_cfa_offset(8)
cfi_rel_offset(%rbx, 0)
mov %RSP_LP, %RBX_LP
cfi_def_cfa_register(%rbx)
and $-STATE_SAVE_ALIGNMENT, %RSP_LP
# endif
# ifdef REGISTER_SAVE_AREA
sub $REGISTER_SAVE_AREA, %RSP_LP
# if !DL_RUNTIME_RESOLVE_REALIGN_STACK
cfi_adjust_cfa_offset(REGISTER_SAVE_AREA)
# endif
# else
# Allocate stack space of the required size to save the state.
# if IS_IN (rtld)
sub _rtld_local_ro+RTLD_GLOBAL_RO_DL_X86_CPU_FEATURES_OFFSET+XSAVE_STATE_SIZE_OFFSET(%rip), %RSP_LP
# else
sub _dl_x86_cpu_features+XSAVE_STATE_SIZE_OFFSET(%rip), %RSP_LP
# endif
# endif
# 首先保存所有被使用到的寄存器
# Preserve registers otherwise clobbered.
movq %rax, REGISTER_SAVE_RAX(%rsp)
movq %rcx, REGISTER_SAVE_RCX(%rsp)
movq %rdx, REGISTER_SAVE_RDX(%rsp)
movq %rsi, REGISTER_SAVE_RSI(%rsp)
movq %rdi, REGISTER_SAVE_RDI(%rsp)
movq %r8, REGISTER_SAVE_R8(%rsp)
movq %r9, REGISTER_SAVE_R9(%rsp)
# ifdef USE_FXSAVE
fxsave STATE_SAVE_OFFSET(%rsp)
# else
movl $STATE_SAVE_MASK, %eax
xorl %edx, %edx
# Clear the XSAVE Header.
# ifdef USE_XSAVE
movq %rdx, (STATE_SAVE_OFFSET + 512)(%rsp)
movq %rdx, (STATE_SAVE_OFFSET + 512 + 8)(%rsp)
# endif
movq %rdx, (STATE_SAVE_OFFSET + 512 + 8 * 2)(%rsp)
movq %rdx, (STATE_SAVE_OFFSET + 512 + 8 * 3)(%rsp)
movq %rdx, (STATE_SAVE_OFFSET + 512 + 8 * 4)(%rsp)
movq %rdx, (STATE_SAVE_OFFSET + 512 + 8 * 5)(%rsp)
movq %rdx, (STATE_SAVE_OFFSET + 512 + 8 * 6)(%rsp)
movq %rdx, (STATE_SAVE_OFFSET + 512 + 8 * 7)(%rsp)
# ifdef USE_XSAVE
xsave STATE_SAVE_OFFSET(%rsp)
# else
xsavec STATE_SAVE_OFFSET(%rsp)
# endif
# endif
# Copy args pushed by PLT in register.
# %rdi: link_map, %rsi: reloc_index
mov (LOCAL_STORAGE_AREA + 8)(%BASE), %RSI_LP
mov LOCAL_STORAGE_AREA(%BASE), %RDI_LP
call _dl_fixup # Call resolver.
mov %RAX_LP, %R11_LP # Save return value
# Get register content back.
# ifdef USE_FXSAVE
fxrstor STATE_SAVE_OFFSET(%rsp)
# else
movl $STATE_SAVE_MASK, %eax
xorl %edx, %edx
xrstor STATE_SAVE_OFFSET(%rsp)
# endif
movq REGISTER_SAVE_R9(%rsp), %r9
movq REGISTER_SAVE_R8(%rsp), %r8
movq REGISTER_SAVE_RDI(%rsp), %rdi
movq REGISTER_SAVE_RSI(%rsp), %rsi
movq REGISTER_SAVE_RDX(%rsp), %rdx
movq REGISTER_SAVE_RCX(%rsp), %rcx
movq REGISTER_SAVE_RAX(%rsp), %rax
# if DL_RUNTIME_RESOLVE_REALIGN_STACK
mov %RBX_LP, %RSP_LP
cfi_def_cfa_register(%rsp)
movq (%rsp), %rbx
cfi_restore(%rbx)
# endif
# Adjust stack(PLT did 2 pushes)
add $(LOCAL_STORAGE_AREA + 16), %RSP_LP
cfi_adjust_cfa_offset(-(LOCAL_STORAGE_AREA + 16))
# Preserve bound registers.
PRESERVE_BND_REGS_PREFIX
jmp *%r11 # Jump to function address.
cfi_endproc
.size _dl_runtime_resolve, .-_dl_runtime_resolve
#endif
可以看出,这段指令的核心是调用_dl_fixup函数。
DL_FIXUP_VALUE_TYPE
attribute_hidden __attribute ((noinline)) ARCH_FIXUP_ATTRIBUTE
_dl_fixup (
# ifdef ELF_MACHINE_RUNTIME_FIXUP_ARGS
ELF_MACHINE_RUNTIME_FIXUP_ARGS,
# endif
struct link_map *l, ElfW(Word) reloc_arg)
{
// 动态符号表
const ElfW(Sym) *const symtab
= (const void *) D_PTR (l, l_info[DT_SYMTAB]);
// 字符串表
const char *strtab = (const void *) D_PTR (l, l_info[DT_STRTAB]);
// .got.plt表表
const uintptr_t pltgot = (uintptr_t) D_PTR (l, l_info[DT_PLTGOT]);
// reloc_arg表示函数的编号(之前已经push到栈里了)。这个变量可以索引到重定位项
const PLTREL *const reloc
= (const void *) (D_PTR (l, l_info[DT_JMPREL])
+ reloc_offset (pltgot, reloc_arg));
// 符号
const ElfW(Sym) *sym = &symtab[ELFW(R_SYM) (reloc->r_info)];
const ElfW(Sym) *refsym = sym;
// 重定位项在虚拟地址空间中的地址(重定位实际上就是填上这个地址指向的一个地址值)
void *const rel_addr = (void *)(l->l_addr + reloc->r_offset);
lookup_t result;
DL_FIXUP_VALUE_TYPE value;
/* Sanity check that we're really looking at a PLT relocation. */
assert (ELFW(R_TYPE)(reloc->r_info) == ELF_MACHINE_JMP_SLOT);
/* Look up the target symbol. If the normal lookup rules are not
used don't look in the global scope. */
if (__builtin_expect (ELFW(ST_VISIBILITY) (sym->st_other), 0) == 0)
{
const struct r_found_version *version = NULL;
if (l->l_info[VERSYMIDX (DT_VERSYM)] != NULL)
{
const ElfW(Half) *vernum =
(const void *) D_PTR (l, l_info[VERSYMIDX (DT_VERSYM)]);
ElfW(Half) ndx = vernum[ELFW(R_SYM) (reloc->r_info)] & 0x7fff;
version = &l->l_versions[ndx];
if (version->hash == 0)
version = NULL;
}
/* We need to keep the scope around so do some locking. This is
not necessary for objects which cannot be unloaded or when
we are not using any threads (yet). */
int flags = DL_LOOKUP_ADD_DEPENDENCY;
if (!RTLD_SINGLE_THREAD_P)
{
THREAD_GSCOPE_SET_FLAG ();
flags |= DL_LOOKUP_GSCOPE_LOCK;
}
#ifdef RTLD_ENABLE_FOREIGN_CALL
RTLD_ENABLE_FOREIGN_CALL;
#endif
// 调用lookup_symbol查找符号
result = _dl_lookup_symbol_x (strtab + sym->st_name, l, &sym, l->l_scope,
version, ELF_RTYPE_CLASS_PLT, flags, NULL);
/* We are done with the global scope. */
if (!RTLD_SINGLE_THREAD_P)
THREAD_GSCOPE_RESET_FLAG ();
#ifdef RTLD_FINALIZE_FOREIGN_CALL
RTLD_FINALIZE_FOREIGN_CALL;
#endif
/* Currently result contains the base load address (or link map)
of the object that defines sym. Now add in the symbol
offset. */
// 获取符号的对应的地址
value = DL_FIXUP_MAKE_VALUE (result,
SYMBOL_ADDRESS (result, sym, false));
}
else
{
/* We already found the symbol. The module (and therefore its load
address) is also known. */
value = DL_FIXUP_MAKE_VALUE (l, SYMBOL_ADDRESS (l, sym, true));
result = l;
}
/* And now perhaps the relocation addend. */
value = elf_machine_plt_value (l, reloc, value);
if (sym != NULL
&& __builtin_expect (ELFW(ST_TYPE) (sym->st_info) == STT_GNU_IFUNC, 0))
value = elf_ifunc_invoke (DL_FIXUP_VALUE_ADDR (value));
/* Finally, fix up the plt itself. */
if (__glibc_unlikely (GLRO(dl_bind_not)))
return value;
// 等价于*rel_addr = value,即在.got.plt的对应项中填入地址
return elf_machine_fixup_plt (l, result, refsym, sym, reloc, rel_addr, value);
}
上面出现了一个重要的函数:_dl_lookup_symbol_x。下面看_dl_lookup_symbol_x的实现方式。
lookup_t
_dl_lookup_symbol_x (const char *undef_name, struct link_map *undef_map,
const ElfW(Sym) **ref,
struct r_scope_elem *symbol_scope[],
const struct r_found_version *version,
int type_class, int flags, struct link_map *skip_map)
_dl_lookup_symbol_x最关键的部分是会返回undef_name所在的模块,并且设置*ref。_dl_lookup_symbol_x将从各个模块的符号表(SYMTAB)当中找到符号对应的项。
SYMTAB项的定义如下
typedef struct
{
Elf64_Word st_name; /* Symbol name (string tbl index) */
unsigned char st_info; /* Symbol type and binding */
unsigned char st_other; /* Symbol visibility */
Elf64_Section st_shndx; /* Section index */
Elf64_Addr st_value; /* Symbol value */
Elf64_Xword st_size; /* Symbol size */
} Elf64_Sym;
有一些SYM可能是待查找的符号。它们的st_value为0。而有一些SYM是本模块内已经定义的符号,它的st_size就是符号在文件内的地址(只需要加上前面所说的l_addr,即可得到符号在内存当中的地址)。
这个过程相当于用一个给定的Elf_Sym(不过没有st_value)去查找一个设定了st_value的Elf_Sym。查找的结果将会被更新到*ref当中,而这个符号所在的模块的link_map会被作为返回值(lookup_t类型实际上就是struct link_map*类型)。
然后查找到了之后,很容易猜想如何获取符号的地址。只需要将目标模块link_map的l_addr,加上查找到的符号的st_value即可。实际上glibc库确实是这么做的:
// 获取符号的对应的地址
value = DL_FIXUP_MAKE_VALUE (result,
SYMBOL_ADDRESS (result, sym, false));
SYMBOL_ADDRES宏就是一个加法运算。
/* Calculate the address of symbol REF using the base address from map MAP,
if non-NULL. Don't check for NULL map if MAP_SET is TRUE. */
#define SYMBOL_ADDRESS(map, ref, map_set) \
((ref) == NULL ? 0 \
: (__glibc_unlikely ((ref)->st_shndx == SHN_ABS) ? 0 \
: LOOKUP_VALUE_ADDRESS (map, map_set)) + (ref)->st_value)
经过这样的过程,最终符号被解析,.got.plt当中的地址被更新成符号的实际地址。运行时符号解析的过程完成。