深度探索Linux操作系统 —— 从内核空间到用户空间1:https://developer.aliyun.com/article/1598082
4、加载动态链接器
在现代操作系统中,绝大部分程序都是动态链接的。对于动态链接的程序,除了加载可执行程序外,其依赖的动态库也要加载。对于动态链接的程序和库,编译时并不能确定引用的外部符号的地址,因此在加载后,还要进行符号重定位。
为了降低内核的复杂度,上述工作并没有包含在内核中,而是转移到了用户空间,由用户空间的程序来完成这个过程。这个程序被称为动态加载/链接器(dynamic linker/loader),一般也将其简称为动态链接器。后续行文中,凡是没有使用 “动态” 二字修饰的链接器,均指编译时的链接器。内核只负责将动态链接器加载到内存,其他的都交由动态链接器去处理。
为了更大的灵活性,内核不会假定系统中使用动态链接器,而是由可执行程序主动告诉内核谁是动态链接器。当编译一个可执行程序时,链接器将创建一个类型为 “INTERP” 的段,这个段非常简单,就是包含一个字符串,这个字符串就是动态链接器的名字,以可执行程序 hello 为例:
由上可见,类型为 “INTERP” 的段就是一个19(0x13)个字符长的字串 “/lib/ld-linux.so.2” ,正是动态链接器。
当内核加载可执行程序时,其将检查可执行程序的 Program Header Table 中是否包含有类型为 “INTERP” 的段。
加载动态链接器与加载可执行程序的过程基本完全相同,函数 load_elf_interp 就是一个简化版的 load_elf_binary ,这里我们不再赘述。完成动态链接器加载后,需要跳转到动态链接器的入口继续执行。那么,如何确定动态链接器的入口地址呢?动态链接器的 ELF 头中将记录一个入口地址:
难道编译时链接器计算错了?0x1050 不太像进程地址空间的虚拟地址。没错,0x1050 是虚拟地址,只不过是因为在编译时不能确定动态库的加载地址,所以动态库中地址分配从 0 开始,见下面动态库的 Program Header Table :
函数 load_elf_interp 返回的是动态链接器在进程地址空间中的映射的基址,所以在这个基址加上入口地址 0x1050 后才是动态链接器的入口的真正的运行时地址。计算好动态链接器的入口地址后,内核调用函数 start_thread ,伪造了用户现场。在进程切换到用户空间时,将跳转到动态链接器的入口处开始执行。
我们看看动态链接器入口地址对应的符号:
可见,动态链接器的入口是符号 _start:
函数 _start 调用 _dl_start 在进行一些自身的必要的准备工作。其中最重要的一点是动态链接器也是一个动态库,其在进程地址空间中的地址也是加载时才确定的,因此动态链接器也需要重定位,我们将在 5.4.8 节讨论这一过程。
然后,_dl_start 调用函数 dl_main 加载动态库以及重定位工作。其中,加载动态库的过程在 5.4.5 节讨论,重定位动态库的过程在 5.4.6 节讨论,有关重定位可执行程序的部分将在 5.4.7 节讨论。
在完成加载及重定位后,函数 _dl_start 将返回可执行程序的入口地址。因此,汇编指令从寄存器 eax 中取出可执行程序的入口地址,并临时保存到寄存器 edi 。在这段程序的最后,通过指令 “jmp%edi*” 跳转到可执行程序的入口处开始执行可执行程序。
另外,我们再留意一下上面代码中的标号 _dl_start_user 。从这个标号处开始,到最后跳转到可执行程序的入口前,动态链接器将调用动态库相关的一些初始化函数。前面在第 2 章中最后在动态库的初始化部分添加的那个函数,就是在这里执行的。
我们以一个具体的例子看看动态链接器在进程地址空间中映射的情况:
可见,对于这个进程,动态链接器被映射到进程地址空间从 0xb7736000 开始的地方,这个就是我们前面提到的动态库在进程地址空间中映射的基址。其中 0xb7736000~0xb7756000 这个段的权限是 “rx” ,显然这个段应该是代码段和一些只读的数据;0xb7756000~0xb7757000 和 0xb7757000~0xb7758000 都对应的是数据段。但是为什么数据段被划分为两个段?其实不只是动态链接器如此,包括其他动态库和动态链接的可执行程序都是如此,具体原因我们将在 5.4.9 节讨论。
5、加载动态库
加载动态库前,首先需要知道这个可执行程序依赖的动态库,当然也包括这些动态库依赖的动态库,因此,这是一个递归的过程。那么动态链接器是如何知道这些依赖的动态库呢?动态链接器不是一个人在战斗,在编译时, 链接器已经为动态链接做了很多铺垫,其中之一就是在 ELF 文件中创建了一个段 “.dynamic” ,保存的全部是与动态链接相关的信息。
段 “.dynamic” 中记录了多组与动态库有关的信息息,每一组信息都使用如下格式保存:
// glibc-2.15/elf/elf.h typedef struct { Elf32_Sword d_tag; // Dynamic entry type union { Elf32_Word d_val; // Integer Value Elf32_Addr d_ptr; // Address value } d_un; } Elf32_Dyn;
可见,每组信息使用的是 tag/value 的形式保存,只不过 value 有的是个整数值,有的是地址而已。
其中类型(Type)为 “NEEDED” 的项记录的就是可执行程序依赖的动态库。可以看到,hello 依赖动态库 libc.so.6 和 libf1.so 。
动态链接器设计了一个数据结构来代表每个加载到内存的动态库(包括可执行程序),定义如下:
// glibc-2.15/include/link.h: struct link_map { ElfW(Addr) l_addr; char *l_name; ElfW(Dyn) *l_ld; struct link_map *l_next, *l_prev; ... ElfW(Dyn) * l_info[DT_NUM + DT_THISPROCNUM + DT_VERSIONTAGNUM + DT_EXTRANUM + DT_VALNUM + DT_ADDRNUM]; ... };
这个数据结构中记录了动态库重定位需要的关键两项信息:l_addr 和 l_ld 。l_addr 记录的是动态库在进程地址空间中映射的基址,有了这个参照,动态链接器才可以修订符号的运行时地址;l_ld 指向动态库的段 “.dynamic” ,通过这个参数,动态链接器可以知道一切与动态重定位相关的信息。为了方便,结构体 link_map 中定义了一个数组 l_info ,将段 “.dynamic” 中的信息记录在这个数组中,就不必每次使用时再去重新解析 “.dynamic” 了。
当内核将控制权转交给动态链接器时,链接器首先为即将处理的可执行程序创建一个 link_map 对象,在动态链接器代码中将其命名为 main_map 。然后,动态链接器找到这个可执行程序依赖的动态库,当然也包括其依赖的动态库也依赖的动态库,依次链接在 main_map 的后面,形成一个 link_map 对象链表。动态链接器作为动态库依赖的一个动态库,自然也包含在这个链表中。沿着这个链表,动态链接器将动态库映射进进程地址空间,并进行重定位。
函数 dl_main 调用 _dl_map_object_deps 加载可执行程序依赖的所有动态库,代码如下:
// glibc-2.15/elf/rtld.c static void dl_main (const ElfW(Phdr) *phdr, ElfW(Word) phnum, ElfW(Addr) *user_entry, ElfW(auxv_t) *auxv) { ... _dl_map_object_deps (main_map, preloads, npreloads, mode == trace, 0); ... }
最初,当共享库映射进内存后,代码段和数据段在物理内存中分别都只有一份副本,并且都是只读的,进程 A 和进程 B 共享只读的代码段和数据段。在进程运行过程中,当任一个进程试图修改数据段时,则内核将为这个进程复制一份私有的数据段的副本,而且这个数据段的权限被设置为可读写的。这里使用的策略就是所谓的写时复制(COW, Copy On Write)。但是这个复制动作不会影响进程的地址空间,对进程是透明的,只是同一段地址通过页面表映射到不同的物理页面而已。
6、重定位动态库
动态库在编译时,链接器并不知道最后被加载的位置,所以在编译时,共享库的地址是相对于 0 分配的。以动态库 libf1.so 为例:
根据动态库 libf1.so 的 Program Header Table ,注意列 VirtAddr,显然地址是从 0 开始分配的。因此,在映射到具体进程的地址空间后,需要修订其中那些通过绝对方式引用的符号的地址。
函数 dl_main 从 main_map 开始,调用函数 _dl_relocate_object 重定位 link_map 链表中的所有动态库和可执行程序,顺序是从后向前。如果有符号重定义了,那么后面发现的符号的地址将覆盖掉前面的符号地址。换句话说,链接时排在前面的动态库中的符号将被优先使用。另外,还有一点要注意,这个列表中的动态链接器将不再需要重定位,因为其已经在前面自己重定位好了。
常用的重定位方式有两种:加载时重定位(Load-time relocation)和 PIC 方式。
加载时重定位与编译时的重定位非常相似。动态链接器在加载动态库后,遍历动态库的重定位表,对于重定位表中的每一项记录,解析这个记录中指明的符号的地址,然后使用解析到的地址修订这个记录中指定的偏移处,当然这个偏移需要加上动态库映射的基址。
但是动态库是多个进程共享的,不同的进程映射的动态库的地址不同,因此,如果某个进程按照动态库在自己进程空间中映射的基址修改了动态库的代码段,那么这个动态库的显然就不能被其他进程所共享了,除非所有的进程映射动态库的位置相同,但是这又带来太多的限制和问题。
基于以上原因,开发者们又设计了另外一种方式 —— PIC(Position-Independent Code)。PIC 基于两个关键的事实:
数据段是可写的。既然代码段是不能更改的,但是数据段总是可以更改的。于是 PIC 把重定位战场从代码段转移到数据段,在数据段中增加了一个 GOT(GLOBAL OFFSET TABLE)表。在编译时,链接器将所有需要重定位的符号在这个表中分配一项,其中记录了符号以及其实际所在的地址。重定位时,动态链接器只修改 GOT 表中的值。
代码和数据的相对位置不变。在代码中凡是引用 GOT 表中的符号,只需要找到 GOT 表的地址,再加上变量在 GOT 表中的偏移即可。但是,如此还是没有避开代码段被修改的命运,因为动态库在进程地址空间中的位置只有在加载时才能确定,所以,GOT 表的地址在加载时也需要重定位。但是,我们也注意到这样一个事实:对动态库来说,虽然其映射的地址在编译时不确定,但是在映射到进程的地址空间时,代码段和数据段依然按照编译时分配好的地址映射,也就是说,指令和数据的相对位置却是固定的。因此,GOT 表作为数据段中的一员,代码段中的任一指令与 GOT 表基址之间的偏移是固定的,在编译时就可以确定。PIC 恰恰是基于这个事实,在代码中凡是访问 GOT 表的地方,都是使用这个固定的相对偏移来引用 GOT 表以及其中的变量,因此,代码中引用 GOT 表的地址不再需要重定位,从而避开了代码段被修改的问题。接下来我们结合具体的实例进一步解释这个过程。
1. GOT 表
显然,PIC 技术中,GOT 表是一个非常重要的数据结构,在继续深入探讨前,我们先来认识一下这个数据结构,如图 5-32 所示。
由图 5-32 可见,这么大名鼎鼎的 GOT 表却如此简单,其就是一个一维数组。对于 32 位 CPU 来说,每个数组元素就是 32 位的地址。GOT 表分成两个部分:.got 和 .got.plt 。 .got 中存储的是变量的地址。.got.plt 中存储的是函数的地址。
在编译时,链接器将定义一个符号 _GLOBAL_OFFSET_TABLE_ ,指向 .got 和 .got.plt 的连接处,凡是访问 GOT 表中的地址时,都使用基于这个符号的偏移。比如,访问变量 var 1,那么使用:
_GLOBAL_OFFSET_TABLE_ - 4
访问函数 func1 则使用:
_GLOBAL_OFFSET_TABLE_ + 12
GOT 表中除了记录变量和函数的地址外,还有另外三个特殊的表项,我们在图 5-32 中也已经标出,它们就是 .got.plt 的前三项。其中第 1 项记录的是动态库或者可执行文件的 .dynamic 段的地址;第 2 项记录的是代表动态库或者可执行文件的 link_map 对象;第 3 项记录的是动态链接器提供的解析符号地址的函数 _dl_runtime_resolve 的地址。我们以动态库 libf1.so 为例,看看在一个已经编译好的动态库中,这三项的值:
从地址 0x2000 处起,就是 .got.plt 开始的地方。其中使用黑体标识的 3 个 32 位地址就分别是这三项的值。可见,除了第 1 项被赋予了具体的值外,其余两项全部是 0 。原因是段 .dynamic 的地址是编译时就确定的。我们查看动态库 libf1.so 的段 .dynamic 的值:
上面,使用 “-x” 显示段 .got.plt 的内容时,是以 little-endian 表示的,所以 .dynamic 段的地址 “00001ef8” 被显示为 “f81e0000” 。
记录动态库信息的 link_map 是在加载后创建的,编译时当然不知道这个运行时创建的对象的地址。同理,因为动态链接器也是以动态库的形式加载到进程地址空间的,其映射地址也是加载时才确定的,所以动态链接器中的函数 _dl_runtime_resolve 的地址也是在动态链接器加载后才能确定。因此,与段 .dynamic 的地址在编译时就可确定不同,这两项是由动态链接器动态填充的。
2. 重定位变量
变量的重定位在动态库加载时进行,注意不要将这里的加载时与前面特指的 “加载时重定位” 混淆,这里指的是使用 PIC 技术在加载时进行的变量重定位的过程。我们分别从代码中引用变量以及动态链接器修订 GOT 表两个角度来讨论 PIC 中的变量重定位。
(1)代码中引用变量
我们以库 libf1 中的函数 foo1_func 引用库 libf2 中的符号 foo2 为例,具体看一下 PIC 中的变量重定位。我们反汇编动态库 libf1.so ,其中引用全局变量 foo2 的反汇编代码片段如下:
1)获取下一条指令的运行时地址。
注意偏移 0x587 处的指令,其调用了偏移 0x57b 处的函数 __x86.get_pc_thunk.cx 。在调用这个函数时,call 指令会将下一条指令的地址 0x58c 压入到栈中。而在进入函数 __x86.get_pc_thunk.cx 后,其将栈顶的值取出到寄存器 ebx 中,然后返回。显然,调用这个函数的目的就是取得下一条指令的运行时地址。这里之所以这么做,是因为 x86 指令集中没有提供获取指令指针值的指令,不得以才采用的一个小技巧。
2)计算 GOT 表的运行时地址。
现在,下一条指令的绝对地址保存在寄存器 ebx 中,而下一条指令与 GOT 之间的偏移又是固定的,因此寄存器 ebx 加上这个固定的偏移后,就确定了 GOT 表在运行时所在的地址。
编译时,链接器定义了一个变量 _GLOBAL_OFFSET_TABLE_ 代表 GOT 表的基址,库 libf1 中该符号地址如下:
因此,库 libf1 中偏移 0x58c 处的指令到 GOT 表所在位置的差为:0x2000-0x58c=0x1a74,这就是地址 0x58c 处的值 0x1a74 的由来。也就是说,这个 0x1a74 就是指令与 GOT 表之间的那个固定偏移。
3)计算符号 foo2 在 GOT 表中的偏移。
取得了 GOT 表的绝对地址后,如要访问变量 foo2 ,还要加上变量 foo2 在 GOT 表中的偏移。那这个偏移是多少呢?我们看看动态库 libf1 的重定位表:
根据重定位表可见,符号 foo2 在偏移 0x00001fe8 处。而 GOT 表基址在 0x2000 处,因此,根据这两个值之差就可以确定符号 foo2 在 GOT 表中的偏移:0x1fe8-0x2000=-0x18,也就是说,变量 foo2 相对 GOT 表的偏移是 -0x18 。根据 ELF 文件中段的布局:
可见,GOT 表的基址是介于 .got 和 .got.plt 之间的。对于 .got 部分来说,GOT 表的基址位于 .got 部分的底部,这就是偏移为负的原因。之所以将 GOT 表的基址设置在 .got 和 .got.plt 之间,并无特别的目的,这样访问 .got.plt 就是正值了。所以,我们看到在库 libf1 的地址 0x592 处在 ebx 的基础上又加了偏移 -0x18 。
(2)动态链接器修订 GOT 表
我们还是以库 libf1 中引用的库 libf2 中的符号 foo2 为例,来看看在加载时,动态链接器是如何解析这个符号并修订 GOT 表的。
1)获取动态库 libf1 的重定位表。
重定位信息保存在重定位表中,因此,动态链接器首先要找到重定位表。段 .dynamic 中类型为 REL 的条目记录的就是重定位表的位置,动态库 libf1 段 .dynamic 中记录的重定位表如下:
可见,保存重定位变量的表位于 0x38c 处。因此,动态链接器按照如下公式计算重定位表的地址:
2)根据重定位表,确定需要修订的位置。
确定重定位表后,动态链接器就遍历重定位表中的每一条记录。以 libf1.so 中的引用的全局变量 dummy、foo2 和 foo1 的重定位记录为例:
其中第一条重定位记录表示需要使用符号 dummy 的值修订下面位置处的值:
第二条重定位记录表示需要使用符号 foo2 的值修订下面位置处的值:
第三条重定位记录表示需要使用符号 foo1 的值修订下面位置处的值:
3)寻找动态符号表。
需要修订的位置确定后,那么接下来就需要解析符号的值。动态链接器从 link_map 这个链表的表头,即代表可执行程序的 main_map 开始,依次在它们的动态符号表中查找符号。所以,要解析符号的地址,首先
要确定动态符号表的地址。以动态库 libf2 为例,动态链接器确定其动态符号表的过程如下。
动态链接器根据代表库 libf2 的 link_map 中的字段 l_ld 找到段 .dynamic ,然后在该段中取出动态符号表的地址:
段 .dynamic 中类型为 SYMTAB 的项记录的是动态符号表的地址。可见,libf2 的动态符号表的地址是0x178,因此,其在运行时的绝对地址使用如下公式计算:
4)解析符号地址。
动态链接器找到了动态符号表后,进一步在动态符号表中查找符号的地址。以全局变量 foo2 为例,动态链接器将在库 libf2 的动态符号表中找到这个符号的信息:
上述动态符号表中符号的地址是相对于 0 的,因此需要加上 libf2 在进程地址空间中映射的基址,所以符号 foo2 的运行时地址是:
然后,动态链接器使用上述这个地址,修订前面确定的需要修订的位置。
前面是静态的分析,下面我们将这个例子运行起来,动态地观察一下全局变量 foo2 的重定位过程。
我们在另外一个终端中查看动态库libf2在进程hello的地址空间中映射的基址:
可见,库 libf1 和 libf2 在 hello 进程的地址空间中映射的基址分别是 0xb7fd8000 和 0xb7e15000 。那么 libf1 中需要修订的地址是:
符号foo2的地址是:
下面我们使用 gdb 查看内存 0xb7fd9fe8 处的值,如果计算正确,那么该内存处的值应该已经被动态链接器修订为 0xb7e17018:
根据输出结果可见,内存 0xb7fd9fe8 处输出的值与我们理论上计算的符号 foo2 的地址完全吻合。
综上可知,变量 foo2 的重定位过程如图 5-33 所示。
不知道读者注意到没有,在例子中,我们在可执行文件 hello 和动态库 libf1 中分别定义了全局变量 dummy 。这不是我们的笔误,而是故意为之。不知读者想过没有,对于变量 foo2 ,其定义在动态库 libf2 中,编译时动态库 libf1 对其一无所知,所以在加载时进行重定位,我们没有任何疑义。但是,对于变量 dummy,其在动态库 libf1 中已经定义了,既然指令和数据的相对位置是固定的,那么为什么不采用与寻址 GOT 表一样的方法,编译时就直接定义好位置,而还是通过 GOT 表,在加载时进行重定位呢?
我们先反过来问读者一个问题:动态库 libf1 中函数 foo1_func 中引用的变量 dummy 是动态库 libf1 中定义的,还是可执行程序 hello 中定义的?答案是后者。对于一个全局符号,包括函数,其可能在本地定义,但在其他库中、甚至包括使用动态库的可执行程序中也可能有定义。在动态链接器解析符号时,将沿着以可执行程序的 link_map 对象 main_map 开头的这个链表依次查找动态符号表,使用最先找到的符号值。如我们的例子中,可执行程序 hello 的动态符号表将先于动态库 libf1 的动态符号表被查找,所以,库 libf1 中的函数 foo1_func 将使用可执行程序 hello 中 dummy 的定义。
除此之外,还有一种所谓的 Copy Relocation,也要求即使引用同一个动态库中定义的全局变量,也要使用重定位的方式,我们在 5.4.7 节讨论这种重定位情况。
深度探索Linux操作系统 —— 从内核空间到用户空间3:https://developer.aliyun.com/article/1598087