深度探索Linux操作系统 —— 从内核空间到用户空间

在这里插入图片描述

系列文章目录


深度探索Linux操作系统 —— 编译过程分析
深度探索Linux操作系统 —— 构建工具链
深度探索Linux操作系统 —— 构建内核
深度探索Linux操作系统 —— 构建initramfs
深度探索Linux操作系统 —— 从内核空间到用户空间
深度探索Linux操作系统 —— 构建根文件系统
深度探索Linux操作系统 —— 构建桌面环境
深度探索Linux操作系统 —— Linux图形原理探讨



一、Linux操作系统加载

    PC 上电或复位后,处理器跳转到 BIOS ,开始执行 BIOSBIOS 首先进行加电自检,初始化相关硬件,然后加载 MBR 中的程序到内存 0x7c00 处并跳转到该地址处,接着由 MBR 中的程序完成操作系统的加载工作。通常,MBR 中的程序也被称为 Bootloader 。当然,鉴于现代操作系统的复杂性,Bootloader 已远远不止一个扇区大小。这一节,我们就以一个具体的 Bootloader —— GRUB 为例,探讨操作系统的加载过程。为简单起见,我们只讨论典型的从硬盘加载操作系统的过程,所以后续的讨论全部是针对从硬盘启动的情况。

    PC 上硬盘的传统分区方式是 MBR 分区方案。但是 MBR 最大能表示的分区大小为 2TB。因此,随着硬盘容量的不断扩大,为了突破 MBR 分区方式的一些限制,20 世纪 90 年代 Intel 提出了 GPT 分区方案。对于不同的分区方式,加载操作系统的方式还是有些许不同的。也是为了简单起见,我们结合现在依然广泛使用的传统的 MBR 分区方案进行讨论。

1、GRUB映像构成

    对于仅有 512 字节大小的 MBR,又要留给分区表 64 字节,在这么小的一个空间,已经很难容纳加载一个现代操作系统的代码。于是 GRUB 采取了分阶段的策略,MBR 中仅存放 GRUB 的第一阶段的代码,MBR 中的代码负责把 GRUB 的其余部分载入内存。

    但是 GRUB 分成几段合适呢?要回答这个问题我们还得从 DOS 谈起。

    DOS 的系统映像是不能跨柱面存放的,所以在 DOS 时代,磁盘的第一个分区索性并没有紧接在 MBR 的后面,而是直接从下一个柱面的边界开始。而且,按照柱面对齐,对系统的性能有很大好处,这对于现代操作系统同样适用。于是,在 MBR 与第一个分区之间,就出现了一块空闲区域。从那时起,这种分区方式成为了一个约定俗成,基本上所有的分区工具都把这种分区方式保留了下来。如果硬盘是 MBR 分区方案,用分区工具 fdisk 就可以看到这一点。

    根据 fdisk 的输出可见,每个磁道划分为 63 个扇区。硬盘的第一个分区起始于第 63 个扇区(从 0 开始计数)。也就是说,对于第 0 个磁道,除了 MBR 占据的一个分区,其余 62 个分区是空闲的。

    于是,GRUB 的开发人员就打算把 GRUB “嵌入”到这个空闲区域,这样做的好处就是相对来说比较安全。因为某些文件系统的一些特性或者一些修复文件系统的操作,有可能导致文件系统中的文件所在的扇区发生改变。因此,单纯依靠扇区定位文件是有一定的风险的。而对于 GRUB 来说,在其初始阶段,由于尚未加载文件系统的驱动,因此,它恰恰需要通过 BIOS 以扇区的方式访问 GRUB 的后续的阶段。但是,一旦 GRUB 嵌入到这个不属于任何分区的特殊区域,则将不再受文件系统的影响。当然将 GRUB 嵌入到这个区域也不是必须的,但是因为这个相对安全的原因,GRUB 的开发人员推荐将 GRUB 嵌入到这个区域。

    但是这个区域的大小是有限的,通常,一个扇区 512 字节,一个柱面最多包含 63 个扇区。因此,除去 MBR ,这个区域的大小是 62 个扇区,即 31KB。因此,嵌入到这里的 GRUB 的映像最大不能超过 31KB 。为了控制嵌入到这个区域中的映像的尺寸不超过 31KB,GRUB 采用了模块化的设计方案。

    GRUB 在嵌入的映像中包含硬件及文件系统的驱动,因此,一旦嵌入的映像载入内存,GRUB 即可访问文件系统。其他模块完全可以存储在文件系统上,通过文件系统的接口访问这些模块,避开了因为如修复文件系统而引起文件所在扇区的变化而带来的风险。另外也可以很好地控制嵌入到空闲扇区的映像的尺寸。

    由上述内容可知,GRUB 将映像分为三个部分:MBR 中的 boot.img、嵌入空闲扇区的 core.img 以及存储在文件系统中的模块。这三个部分也对应着 GRUB 执行的三个阶段。在 MBR 分区模式下,以嵌入方式安装的 GRUB 的各个部分在硬盘上的分布如图5-1所示。

在这里插入图片描述

    core.img 包括多个映像和模块,以从硬盘启动为例,core.img 包含的内容如图 5-2 所示。

在这里插入图片描述

2、安装GRUB

    通常,在安装操作系统的最后,操作系统安装程序将会为用户安装 GRUB 。当然,有时我们也会手动安装 GRUB 。但是都是通过 GRUB 提供的工具,执行的命令如下:

grub-install /dev/sda

    事实上,在这个安装命令的背后,GRUB 的安装过程分为两个阶段:第一阶段是创建 core.img ,GRUB 为此提供的工具是 grub-mkimage ;第二阶段是安装 boot.imgcore.img 到硬盘,GRUB 提供的工具是 grub-setup 。为了方便,GRUB 将这两个过程封装到脚本 grub-install 中。

二、解压内核

    根据构建内核时的分析,我们知道,内核的保护模式部分包括非压缩部分以及压缩部分,压缩部分才是内核正常运转时的部分,而非压缩部分只是一个过客,其主要作用是解压内核的压缩部分,解压完成后,非压缩部分也将退出历史舞台。

    内核的解压缩过程几经演进,现在的解压过程不再是首先将内核解压到另外的位置,然后再合并到最终的目的地址。而是采用了所谓的就地解压(in-place decompression)方法,内核解压时并不需要解压到另外的位置,从而避免覆盖其他部分的数据。

    以不可重定位的内核的解压过程为例,其解压过程如图 5-7 所示。

在这里插入图片描述

  • 移动内核映像
  • 解压
  • 重定位

三、内核初始化

    虽然操作系统的功能包括进程管理、内存管理、设备管理等,但是操作系统的终极目标是创造一个环境,承载进程。但是由于进程运行时,可能需要和各种外设打交道,因此,操作系统初始化时,也会将这些外设等子系统进行初始化,这也导致内核初始化过程异常复杂。虽然这些过程很重要,但是忽略它们并不妨碍理解操作系统的本质。本节我们并不关心这些子系统的初始化,比如 USB 系统是如何初始化的,我们只围绕进程来讨论内核相关部分的初始化。

1、初始化虚拟内存

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

2、初始化进程0

    POSIX 标准规定,符合 POSIX 标准的操作系统采用复制的方式创建进程,但是内核总得想办法创建第一个原始的进程,否则其他进程复制谁呢?因此,内核静态的创建了一个原始进程,因为这个进程是内核的第一个进程,Linux 为其分配的进程号为 0,所以也被称为进程 0。进程 0 不仅作为一个模板,在没有其他就绪任务时进程 0 将投入运行,所以其又称为 idle 进程。下面我们就看看内核是如何为进程 0 分配任务结构和内核栈这两个关键数据结构的。

// linux-3.7.4/init/init_task.c:

struct task_struct init_task = INIT_TASK(init_task);

// linux-3.7.4/include/linux/init_task.h:
#define INIT_TASK(task)	{	\
	.state = 0,		\
	.stack = &init_thread info,	\
	.usage = ATOMIC_INIT(2) ,	\
	...
}	

在这里插入图片描述

3、创建进程1

// linux-3.7.4/init/main.c:
static noinline void __init_refok rest_init (void) {
	...
	kernel_thread(kernel_init, NULL, CLONE_FS | CLONE_SIGH AND);
	...
}

// linux-3.7.4/kernel/fork.c
pid_t kernel_thread(int (*fn) (void *), void *arg, ...) {
	return do fork (flags| CLONE VM CLONE UNTRACED,
		(unsigned long)fn, NULL, (unsigned long) arg, NULL, NULL);
}

四、进程加载

    根据 POSIX 标准的规定,操作系统创建一个新进程的方式是进程调用操作系统的 fork 服务,复制当前进程作为一个新的子进程,然后子进程使用操作系统的服务 exec 运行新的程序。前面,我们看到内核已经静态地创建了一个原始进程,进程 1 复制这个原始进程,然后加载了用户空间的可执行文件。这一节,我们就来探讨用户进程的加载过程,大致上整个加载过程包括如下几个步骤:

  1. 内核从磁盘加载可执行程序,建立进程地址空间;
  2. 如果可执行程序是动态链接的,那么加载动态链接器,并将控制权转交到动态链接器;
  3. 动态链接器重定位自身;
  4. 动态链接器加载动态库到进程地址空间;
  5. 动态链接器重定位动态库、可执行程序,然后跳转到可执行程序的入口处继续执行。

1、加载可执行程序

    一个进程的所有指令和数据并不一定全部要用到,比如某些处理错误的代码。某些错误可能根本不会发生,如果也将这些错误代码加载进内存,就是白白占据内存资源。而且对于某些特别大的程序,如果启动时全部加载进内存,也会使启动时间延长,让用户难以忍受。因此,内核初始加载可执行程序(包括动态库)时,并不将指令和数据真正的加载进内存,而仅仅将指令和数据的 “地址” 加载进内存,通常我们也将这个过程形象地称为映射。

    对于一个程序来说,虽然其可以寻址的空间是整个地址空间,但是这只是个范围而已,就比如某个楼层的房间编号可能是 4 位的,但是并不意味着这个楼层 0000~9999 号房间都可用。对于某个进程而言,一般也仅仅使用了地址空间的一部分。那么一个进程如何知道自己使用了哪些虚拟地址呢?这个问题就转化为是谁为进程分配的运行时地址呢?没错,是链接器分配的,那么当然从 ELF 程序中获取了。所以内核首先将磁盘上 ELF 文件的地址映射进来。

    除了代码段和数据段外,进程运行时还需要创建保存局部变量的栈段(Stack Segment)以及动态分配的内存的堆段(Heap Segment),这些段不对应任何具体的文件,所以也被称为匿名映射段(anonymous map)。对于一个动态链接的程序,还会依赖其他动态库,在进程空间中也需要为这些动态库预留空间。

    通过上述的讨论可见,进程的地址空间并不是铁板一块,而是根据不同的功能、权限划分为不同的段。某些地址根本没有对应任何有意义的指令或者数据,所以从程序实现的角度看,内核并没有设计一个数据结构来代表整个地址空间,而是抽象了一个结构体 vm_area_struct 。进程空间中每个段对应一个 vm_area_struct 的对象(或者叫实例),这些对象组成了 “有效” 的进程地址空间。进程运行时,首先需要将这个有效地址空间建立起来。

    内核支持多种不同的文件格式,每种不同格式的加载都实现为一个模块。比如,加载 ELF 格式的模块是 binfmt_elf ,加载脚本的模块是 binfmt_script ,它们都在内核的 fs 目录下。对于每个要加载的文件,内核都读入其文件头部的一部分信息,然后依次调用这些模块提供的函数 load_binary 根据文件头的信息判断其是否可以加载。前面,initramfs 中的 init 程序是使用 shell 脚本写的,显然,它是由内核中负责加载脚本的模块 binfmt_script 加载。模块 binfmt_script 中的函数指针 load_binary 指向的具体函数是 load_script,代码如下:

// fs/binfmt_script.c
static int load_script(struct linux_binprm *bprm,struct pt_regs *regs) {
	...
	if ((bprm->buf[0] != '#') || (bprm->buf[1] != '!') ||
	    (bprm->recursion_depth > BINPRM_MAX_RECURSION))
		return -ENOEXEC;
	...
	for (cp = bprm->buf+2; (*cp == ' ') || (*cp == '\t'); cp++);
	...
	file = open_exec(interp);
	...
	bprm->file = file;
	...
	return search_binary_handler(bprm,regs);			
}

    linux_binprm 是内核设计的一个在加载程序时,临时用来保存一些信息的结构体。其中,buf 中保存的就是内核读入的要加载程序的头部。函数 load_script 首先判断 buf,也就是文件的前两个字符是否是 “#!” 。这就是脚本必须以 “#!” 开头的原因。

    如果要加载的程序是一个脚本,则 load_script 从字符 “#!” 后的字符串中解析出解释程序的名字,然后重新组织 bprm,以解释程序为目标再次调用函数 search_binary_handler,开始寻找加载解释程序的加载器。而脚本文件的名字将被当作解释程序的参数压入栈中。

    对于 initramfs 中的 init 程序,其是使用 shell 脚本编写的,所以加载 init 的过程转变为加载解释程序 “/bin/bash” 的过程,而 init 脚本则作为 bash 程序的一个参数。

    可见,脚本的加载,归根结底还是 ELF 可执行程序的加载。

    ELF 文件“一人分饰二角”,既作为链接过程的输出,也作为装载过程的输入。在第 2 章中,我们从链接的角度讨论了 ELF 文件格式,当时我们看到 ELF 文件是由若干 Section 组成的。而为了配合进程的加载,ELF 文件中又引入了 Segment 的概念,每个 Segment 包含一个或者多个 Section 。相应于 Section 有一个 Section Header Table,ELF 文件中也有一个 Program Header Table 描述 Segment 的信息,如图5-21所示。

在这里插入图片描述

    Program Header Table 中有多个不同类型的 Segment ,但是如果仔细观察图 5-21,我们会发现,两个类型为 LOADSegment 基本涵盖了整个 ELF 文件,而一些 Section,如 “.comment”、“.symtab” 等,包括 Section Header Table,只是链接时需要,加载时并不需要,所以没有包含到任何 Segment 中。基本上,这两个类型为 LOAD 的 Segment ,在映射到进程地址空间时,一个映射为代码段,一个映射为数据段:

  • 代码段(code segment)具有读和可执行权限,但是除了保存指令的 Section 外,一些仅具有只读属性的 Section ,比如记录解释器名字的 “.interp” ,动态符号表 “.dynsym” ,以及重定位表 “.rel.dyn”、“.rel.plt” ,甚至是 ELF HeaderProgram Header Table,也包含到了这个段中。这些是程序加载和重定位时需要的信息,随着讨论的深入,我们慢慢就会理解它们的作用。

  • 数据段(data segment)具有读写权限,除了典型保存数据的 Section 外,一些具有读写权限的 Section,如 GOT 表,也包含到这个段中。

    除了这两个 LOAD 类型的 Segment 外,ELF 规范还规定了几个其他的 Segment ,它们都是辅助加载的。仔细观察 Program Header Table,我们会发现,其他类型的 Segment 都包括在 LOAD 类型的段中。所以,在加载时,内核只需要加载 LOAD 类型的 Segment 。

2、进程的投入运行

1. 用户现场的保护

(1)从用户栈切换到内核栈

    当一个进程正在用户空间运行时,一旦发生中断,那么进程将从用户空间切换到内核空间运行。进程在内核空间运行时,CPU 各个寄存器同样将被使用,因此,为了在处理完中断后,程序可以在用户空间的中断处得以继续执行,需要在穿越的一刻保护这些寄存器的值,以免被覆盖,即所谓的保护现场。Linux 使用进程的内核栈保存进程的用户现场。因此,在中断时,CPU 做的第一件事就是将栈从用户栈切换到内核栈。

在这里插入图片描述

(2)保存用户空间的现场

    切换完栈后,CPU 在进程的内核栈中保存了进程在用户空间执行时的现场信息,包括 eflags、cs、eip、ssesp,如图 5-24 所示。
在这里插入图片描述

(3)穿越中断门

    接下来,进程就将进行最后的穿越了,当然,内核在初始化时就已经为 CPU 初始化了中断相关的部分。

在这里插入图片描述

2. 内核现场的保护

    当进程在内核空间运行时,在发生进程切换时,依然需要保护切换走的进程的现场,这是其下次运行的起点。那么进程的内核现场保存在哪里合适呢?前面我们看到进程的用户现场保存在进程的内核栈,那么进程的内核现场当然也可以保存在进程的内核栈。

    但是,当调度函数准备切换到下一个进程时,下一个进程的内核栈的栈指针从何而来?在前面讨论进程从用户空间切换到内核空间时,我们看到,CPU 从进程的 TSS 段中获取内核栈的栈指针。那么当在内核空间发生切换时,调度函数如何找到准备切入进程的内核栈的栈指针?

    除了进程的内核栈外,进程在内核中始终存在另外一个数据结构 —— 进程的户口,即任务结构。因此,进程的内核栈的栈指针可以保存在进程的任务结构中。在任务结构中,特意抽象了一个结构体 thread_struct 来保存进程的内核栈的栈指针、返回地址等关键信息。

    调度函数使用宏 switch_to 切换进程,我们来仔细观察以下这段代码,为了看起来更清晰,删除了代码中的注释:
在这里插入图片描述
在这里插入图片描述

    在每次进程切换时,调度函数将准备切出的进程的寄存器 esp 中的值保存在其任务结构中,见第 5 行代码。然后从下一个投入运行的进程的任务结构中恢复 esp,见第 6 行代码。除了栈指针外,程序下一次恢复运行时的地址也有一点点复杂,不仅仅是简单的保存 eip 中的值,有一些复杂情况需要考虑,比如稍后我们会看到对于新创建的进程,其恢复运行的地址的设置。所以调度函数也将 eip 保存到了任务结构中,第 7 行代码就是保存被切出进程下次恢复时的运行地址。第 8 行代码和第 10 行的 jmp ,以及函数 __switch_to 最后的 ret 指令联手将投入运行的进程的地址,即 next->thread.ip,恢复到寄存器 eip 中。

    除了 eip、esp 外,宏 switch_to 将其他寄存器如 eflags、ebp 等保存到了进程内核栈中。

    每次中断时,CPU 会从 TSS 段中取出当前进程的内核栈的栈指针,因此,当发生任务切换时,TSS 段中的 esp0 的值也要更新为下一个投入运行任务的内核栈的栈指针。

3、按需载入指令和数据

    在建立进程的地址空间时,我们看到,内核仅仅是将地址映射进来,没有加载任何实际指令和数据到内存中。这主要还是出于效率的考虑,一个进程的所有指令和数据并不一定全部要用到,比如某些处理错误的代码。某些错误可能根本不会发生,如果也将这些错误代码加载进内存,就是白白占据内存资源。而且特别对于某些大型程序,如果启动时全部加载进内存,也会使启动时间延长,让用户难以忍受。所以,在实际需要这些指令和数据时,内核才会通过缺页中断处理函数将指令和数据从文件按需加载进内存。这一节,我们就来具体讨论这一过程。

  1. 获取引起缺页异常的地址
    IA32 架构的缺页中断的处理函数 do_page_fault 调用函数 __do_page_fault 处理缺页中断。

  2. 更新页表
    在复制子进程时,子进程也需要复制或者共享父进程的页表。如果没有页表,子进程寸步难行,指令或者数据的地址根本没有办法映射到物理地址,更不用提从物理内存读取指令了。当子进程替换(exec)为一个新的程序时,无论子进程是共享或者复制了父进程的页表,子进程都需要创建新的页表。

  3. 从文件载入指令和数据
    页表准备就绪后,handle_mm_fault 最后准备载入指令和数据了。

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~0xb77570000xb7757000~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.6libf1.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_addrl_ldl_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.soProgram Header Table ,注意列 VirtAddr,显然地址是从 0 开始分配的。因此,在映射到具体进程的地址空间后,需要修订其中那些通过绝对方式引用的符号的地址。

在这里插入图片描述

    函数 dl_mainmain_map 开始,调用函数 _dl_relocate_object 重定位 link_map 链表中的所有动态库和可执行程序,顺序是从后向前。如果有符号重定义了,那么后面发现的符号的地址将覆盖掉前面的符号地址。换句话说,链接时排在前面的动态库中的符号将被优先使用。另外,还有一点要注意,这个列表中的动态链接器将不再需要重定位,因为其已经在前面自己重定位好了。

    常用的重定位方式有两种:加载时重定位(Load-time relocation)和 PIC 方式。

    加载时重定位与编译时的重定位非常相似。动态链接器在加载动态库后,遍历动态库的重定位表,对于重定位表中的每一项记录,解析这个记录中指明的符号的地址,然后使用解析到的地址修订这个记录中指定的偏移处,当然这个偏移需要加上动态库映射的基址。

    但是动态库是多个进程共享的,不同的进程映射的动态库的地址不同,因此,如果某个进程按照动态库在自己进程空间中映射的基址修改了动态库的代码段,那么这个动态库的显然就不能被其他进程所共享了,除非所有的进程映射动态库的位置相同,但是这又带来太多的限制和问题。

    基于以上原因,开发者们又设计了另外一种方式 —— PICPosition-Independent Code)。PIC 基于两个关键的事实:

  • 数据段是可写的。既然代码段是不能更改的,但是数据段总是可以更改的。于是 PIC 把重定位战场从代码段转移到数据段,在数据段中增加了一个 GOTGLOBAL 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 ,还要加上变量 foo2GOT 表中的偏移。那这个偏移是多少呢?我们看看动态库 libf1 的重定位表:

在这里插入图片描述
    根据重定位表可见,符号 foo2 在偏移 0x00001fe8 处。而 GOT 表基址在 0x2000 处,因此,根据这两个值之差就可以确定符号 foo2GOT 表中的偏移: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 中的引用的全局变量 dummyfoo2foo1 的重定位记录为例:

在这里插入图片描述

其中第一条重定位记录表示需要使用符号 dummy 的值修订下面位置处的值:

在这里插入图片描述
第二条重定位记录表示需要使用符号 foo2 的值修订下面位置处的值:

在这里插入图片描述
第三条重定位记录表示需要使用符号 foo1 的值修订下面位置处的值:
在这里插入图片描述

3)寻找动态符号表。

    需要修订的位置确定后,那么接下来就需要解析符号的值。动态链接器从 link_map 这个链表的表头,即代表可执行程序的 main_map 开始,依次在它们的动态符号表中查找符号。所以,要解析符号的地址,首先
要确定动态符号表的地址。以动态库 libf2 为例,动态链接器确定其动态符号表的过程如下。

    动态链接器根据代表库 libf2link_map 中的字段 l_ld 找到段 .dynamic ,然后在该段中取出动态符号表的地址:

在这里插入图片描述

    段 .dynamic 中类型为 SYMTAB 的项记录的是动态符号表的地址。可见,libf2 的动态符号表的地址是0x178,因此,其在运行时的绝对地址使用如下公式计算:

在这里插入图片描述

4)解析符号地址。

    动态链接器找到了动态符号表后,进一步在动态符号表中查找符号的地址。以全局变量 foo2 为例,动态链接器将在库 libf2 的动态符号表中找到这个符号的信息:
在这里插入图片描述

    上述动态符号表中符号的地址是相对于 0 的,因此需要加上 libf2 在进程地址空间中映射的基址,所以符号 foo2 的运行时地址是:

在这里插入图片描述

    然后,动态链接器使用上述这个地址,修订前面确定的需要修订的位置。

    前面是静态的分析,下面我们将这个例子运行起来,动态地观察一下全局变量 foo2 的重定位过程。

在这里插入图片描述
我们在另外一个终端中查看动态库libf2在进程hello的地址空间中映射的基址:

在这里插入图片描述
    可见,库 libf1libf2hello 进程的地址空间中映射的基址分别是 0xb7fd80000xb7e15000 。那么 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 将使用可执行程序 hellodummy 的定义。

    除此之外,还有一种所谓的 Copy Relocation,也要求即使引用同一个动态库中定义的全局变量,也要使用重定位的方式,我们在 5.4.7 节讨论这种重定位情况。

3. 重定位函数

    前面我们讨论了变量的重定位,本小节我们讨论函数的重定位。理论上,函数的重定位使用与变量相同的方法即可。但是,因为相对比较少的全局变量的引用,函数引用的数量可能要大得多,因此函数重定位的时间不得不考虑。

    事实上,读者回想一下我们日常开发的程序,其实很多代码不一定能全部执行,比如有些分支、错误处理等。而且,即使可执行程序本身使用的函数数量并不大,但是可执行程序依赖的动态库可能还会引用其他动态库中的函数,这些动态库再依赖其他的动态库,如此,需要重定位的函数的数量不容小觑。更重要的是,可执行程序可能根本就用不到这些动态库中的函数,因此,加载时重定位函数只会延长程序启动的时间,但是重定位的某些函数却可能根本就用不到。出于以上考虑, PIC 对于函数的重定位引入了延迟绑定技术(lazy binding)。

    也就是说,在加载时,动态链接器不解析任何一个需要重定位的函数的地址,而是在运行时真正调用时,再去重定位。为此,开发者们引入了 PLTProcedure Linkage Table)机制。在 GOT 表的巧妙配合下,PIC 将函数地址的解析推迟到了运行时。

    在编译时,链接器在代码段中插入了一个 PLT 代码片段,每个外部函数在 PLT 中都占据着一小段代码。我们可以将这些片段看作外部函数在本地代码中的代理。代码段中所有引用外部函数的地方,全部指向其相应的本地代理。其他具体的事情就交由本地代理去处理。

    PLT 的代码片段的逻辑如图 5-34 所示。

在这里插入图片描述

由图 5-34 可见:

    1)代码中所有引用函数如 func1func2 的地方全部替换为指向 PLT 中的代码片段。因为这里使用的是相对寻址,所以运行时代码段无须再进行任何修订,也就是说,代码段不需要重定位了。保证了代码段的可读属性,从而在多个进程间可以共享。

    2)PLT 中每个函数的代码片段除了两处数据外,基本完全相同。以调用函数 func1 为例,它的基本逻辑是:如果不是第一次调用 func1 ,就说明函数 func1 的地址已经被解析,并且 GOT 表中对应的 func1 的地址的项也已经被正确修订了,那么直接跳转到 GOT 表中对应的项即可,也就是说,这样就直接跳转到了函数 foo2 的开头。这里,因为 GOT 表的前 3 项有特殊的用途,所以 func1 的地址占据 GOT 表的第 4 项。ELF 标准规定,在调用 PLT 中的代码片段前,主调函数需要将 GOT 表的基址装载进寄存器 ebx ,所以,PLT 中凡是访问 got 的地方,都使用 ebx,*0xc(%ebx) 就是 GOT 表中第 4 项的值,即函数 func1 的地址。读者可以回顾一下前面讨论的重定位变量一节,那里讨论确定 GOT 的基地址时,正是将 GOT 表的地址装入了寄存器 ebx

    3 )如果是第一次调用,那么将调用动态链接器提供的函数 _dl_runtime_resolve 解析函数 foo1 的地址。这里显然不能将函数 _dl_runtime_resolve 的地址直接写在 PLT 代码中,如果这样的话,那么 PLT 也需要重定位这个函数,除非使用前面提到的加载时重定位,但前面已经提到了其种种弊端。因此,动态链接器在加载库时,将函数 _dl_runtime_resolve 的地址填充到动态库的 GOT 表的第 3 项,而在 PLT 表中,则直接跳转到 GOT 表中第 3 项保存的地址,即 *0x8(%ebx)

    4)在跳转到函数 _dl_runtime_resolve 的地址前,有两条 push 指令,它们就是为函数 _dl_runtime_resolve 准备参数的。在具体看这两条直指令前,我们先来看一下修订 GOT 表中的函数地址时需要的信息:

    ◆ 第一个需要的信息是当前重定位的函数在重定位表中的偏移。根据这个偏移, _dl_runtime_resolve 找到相应的重定位条目,从而确定需要解析的符号的名字,以及需要修订的位置。对于函数在重定位表中的偏移,这个在编译时就可以确定,所以我们看到 PLT 中直接使用了确定的数字。如函数 func1 在重定位表中占据第 1 个条目,那么偏移就是 0x0,这就是汇编指令 “push $0x0” 的作用。而对于函数 foo2 ,因为其在重定位表中占据第 2 个条目,所以偏移就是 0x8

    ◆ 第二个是需要个代表当前动态库的 link_map 对象。要获得重定位表,当然需要知道动态库映射的基址以及段 .dynamic 所在的地址,而这些信息记录在库的 link_map 对象中。在查找符号时,其需要遍历可执行程序的 link_map 链表,因此,函数 _dl_runtime_resolve 要根据动态库的 link_map 对象找到 link_map 链表。而 link_map 也是在动态链接器加载库时填充到 GOT 表中的,它占据 GOT 表的第 2 项,这就是 PLT 代码中汇编语句 “push 0x4(%ebx)” 的作用。

    5)准备好参数后,_dl_runtime_resolve 将开始寻找符号,最后修订 GOT 表中的地址。相关代码如下:

在这里插入图片描述

    _dl_runtime_resolve 中核心的是调用函数 _dl_fixup 进行符号解析,并修订 GOT 表。这里使用的是寄存器传参,所以 _dl_runtime_resolve 在调用 _dl_fixup 前,将动态库的 link_map 存储在寄存器 eax 中,作为传给 _dl_fixup 的第 1 个参数;将重定位函数在重定位表中的偏移存储在寄存器 edx ,作为传给 _dl_fixup 的第 2 个参数。

    然后,在 _dl_fixup 执行完毕后,会将解析的函数的地址返回。这个返回值会放在寄存器 eax 中,所以我们看到 _dl_runtime_resolve 在 _dl_fixup 执行完毕后,会将保存在寄存器 eax 中的值放到栈顶,然后调用 ret 指令,将这个返回地址弹出到指令指针之中,从而跳转到解析后的地址运行。

在这里插入图片描述

    我们看到,PLT 中的代码片段不再进行任何判断,而是直接跳转到 GOT 表中用来保存解析的函数的地址的表项。这里面最关键的一个技巧就是图 5-35 中用黑体标识的 GOT 表中的两项。编译时,编译器将函数对应的项的地址初始化为 PLT 代码片段中 jmp 语句的下一条地址。在动态库加载时,动态器会在此基础上,再加上动态库的映射的基址。如此,当第一次执行这个函数时,jmp 语句并没有跳转到真正的函数的地址处,而是直接相当于执行 PLT 代码片段中的下一条语句,即压栈参数,然后调用 _dl_runtime_resolve 解析函数地址,使用解析的符号的地址修订 GOT 表中的项,然后跳转到解析的函数的地址,执行函数。

    这里不知是否有读者有过这样的设想:程序加载时,将函数的 GOT 表项直接填写为函数 _dl_runtime_resolve 的地址,是不是更合理?非也,GOT 表一项只有 4 字节,只能保存一个地址,而调用 _dl_runtime_resolve 之前,还需要其他指令准备参数。

    经过第一次调用后,GOT 表中的函数对应的项已经变为真正的函数的地址,下次再次调用时,将直接跳转到函数的地址继续执行,如图 5-36 所示。

在这里插入图片描述

    观察图 5-36 会发现,PLTfunc1@plt 中的地址为 0x70x8 处两行的代码,以及 func2@plt 中地址 0xe0xf 处的代码完全一样。事实上,所有函数的 PLT 片段的最后两行都完全相同。于是,PLT 将这两行代码独立为一个 “子函数” plt0 。进一步改进后 PLT 的代码如图 5-37 所示。

在这里插入图片描述
    下面我们以库 libf1 中的函数 foo1_func 调用库 libf2 中的函数 foo2_func 为例,来具体体会一下前面的理论分析。反汇编库 libfoo2,并截取引用函数 foo2_func 的有关部分:

在这里插入图片描述

    先来看地址 0x5b3 处的指令。汇编指令 call 的操作数 0xfffffe98(补码)对应的原码是 -0x168call 指令的操作数是一个相对寻址,因此 -0x168 是目标地址和下一条指令的差值。因为下一条指令的地址是 0x5b8 ,所以跳转的目的地址是:

在这里插入图片描述

    地址 0x450 处正是 PLT 中对应函数 foo2_func 的片段。我们看到地址 0x450 处的汇编指令跳转到 GOT 表中偏移为 0x14 处中的值表示的地址处。那么 GOT 表中这个位置处保存的是什么呢?我们需要到记录函数重定位的表 —— .rel.plt 中寻找答案:

在这里插入图片描述

    动态库 libf1GOT 表的基址为 0x2000 ,所以偏移 0x14 处的地址即为 0x2014,也就是重定位表中的第3条记录。可见,这条重定位记录要求动态链接器使用符号 foo2_func 的值填充地址为 0x2014 处的 GOT 表项。根据前面的理论分析,初始时,这个地址指向下一条 push 指令,即地址 0x456 处的指令。所以,当首次调用 foo2_func 时,地址 0x450 处的指令跳转到了地址 0x456 处。

    地址 0x456 处的指令压栈了一个立即数 0x10 。根据前面的理论分析,这是为符号解析函数 _dl_runtime_resolve 压栈的一个参数,即需要重定位的函数在重定位表中的偏移。根据重定位表中的信息,函数 _dl_runtime_resolve 就可以找到与重定位函数相关的信息,如重定位函数的符号名称、需要修订的位置等。0x10 用十进制表示是 16,也就是从重定位表 .rel.plt 开始偏移 16 字节,重定位表中每个条目占据 8 字节,因此偏移 16 字节处的第 3 条重定位记录正是记录函数 foo2_func 的重定位信息。

    继续看下一条指令,即地址 0x45b 处的指令。也是一条相对跳转指令,补码 0xffffffc0 的原码是 -0x40,所以跳转的目的地址是:

在这里插入图片描述

    objdump 工具虽然显示地址 0x420 处的函数的名字是 “__cxa_finalize@plt-0x10” ,实际上与函数 “__cxa_finalize” 没有任何关系,这里解析的有一点 bug,忽略即可。地址 0x420 处就是 PLT 表的第 0 项。我们看到 plt0 首先将 GOT 表中偏移 0x4 处,即 GOT 表第 2 项的值(库 libf1link_map )压栈,显然是给解析函数传参。然后跳转到 GOT 表的偏移 0x8 处,即第 3 项,也就是解析函数 _dl_runtime_resolve 的地址处执行,该函数解析符号 foo2_func ,然后使用解析得到的符号 f002-func 的运行时地址修订 GOT 表中偏移 0x14 处,即第 6 项,然后跳转到函数 foo2_func 执行。

    首次调用函数 foo2_func 后,GOT 表中第 6 项保存的就是 foo2_func 的地址了。以后再次调用该函数时, PLT 中的 foo2_func@plt 将不再跳转到函数 _dl_runtime_resolve 处解析函数了,而是直接跳转到函数 foo2_func 处。

    在静态分析后,下面我们再动态观察一下函数 foo2_func 的重定位过程。

    我们首先来看一下编译时库 libf1GOT 表中第 6 项,即偏移 0x2014 处,保存的内容是什么,前面我们已经讨论过了,理论上这里应该是 foo2_func@pltpush 指令的地址:

在这里插入图片描述
在这里插入图片描述
    注意上面使用黑体标识的部分,编译时偏移 0x2014 处的 4 字节初始化为 0x0456 ,正是 foo2_func@pltpush 指令的地址。

    我们将 hello 运行起来,观察一下 GOT 表中第 6 项的变化情况:

在这里插入图片描述
我们在另外一个终端中查看库 libf1 在进程 hello 的地址空间中映射的基址:

在这里插入图片描述
    根据输出可见库 libf1 在进程 hello 的地址空间中映射基址是 0xb7fd8000 。虽然说函数 foo2_func 的地址是在使用时再去重定位,但是加载时动态链接器还是要做一个重定位。读者不禁要问,重定位什么呢?我们以 GOT 表的第 6 项,即偏移 0x2014 处的值为例。在编译时,我们看到链接器将此处的地址填充为 0x0456,即 jmp 后的 push 指令的地址。但是不知读者是否注意到,这个地址是相对于 0 的地址,在加载后,当动态库 libf1 的映射基址确定为 0xb7fd8000 后,显然需要修订这个地址为:

在这里插入图片描述

我们通过gdb看一下实际的输出:

在这里插入图片描述

    可见,GOT 表中的这一项在加载时确实修订了。

    在 foo2_func 第一次执行后,这个 GOT 表中的地址就应该修订为 foo2_func 的地址,我们看一下库 libf2 中为 foo2_func 分配的地址:

在这里插入图片描述
而动态库 libf2 在进程 hello 的地址空间中映射的基址是:

在这里插入图片描述
所以,符号 foo2_func 的运行时地址是:

在这里插入图片描述
    我们通过 gdb 来查看一下 foo2_func 执行一次后,GOT 表中的保存这个函数的地址被修订成了什么:

在这里插入图片描述
可见,在首次调用后,GOT 表中的值已经修订为符号 foo2_func 的运行时地址。

7、重定位可执行程序

    可执行程序如果引用的是自身定义的函数和变量,这些符号在编译时就已经确定,不需要任何重定位。即使其他动态库中也定义了与可执行程序中相同的符号,链接器也优先使用可执行程序自身定义的函数和变量。

    如果引用了动态库中的函数和全局变量,那么编译时可执行程序根本不知道这些符号最终的地址,在重定位了动态库之后,可执行程序也需要重定位这些符号。可执行程序的重定位与共享库原理基本一致,只有一点差别,我们这里简单讨论一下它们之间的差别。

(1)重定位引用的动态库中的函数

    我们以 hello 中引用动态库 libf1 中的函数 foo1_func 为例,来看关于函数的重定位。可执行程序 hello 中调用 foo1_func 的反汇编代码如下:

在这里插入图片描述
可见,可执行程序也使用了延迟绑定的技术。再来看看 PLT 部分的代码:

在这里插入图片描述
在这里插入图片描述
    与动态库不同,可执行程序的地址在编译时就已经分配好了,所以,GOT 的地址在编译时就确定了,不必再如动态那样在运行时动态获取 GOT 表的基址。我们来看看 helloGOT 表的基址:

在这里插入图片描述

    GOT 表的基址为 0x0804a000,所以任何以 GOT 表基址为参照的偏移,直接使用这个地址即可。比如访问 GOT 表中的第 3 项,即函数 _dl_runtime_resolve 时,直接在此地址上加两个 4 字节偏移即可(因为 _dl_runtime_resolve 占据 GOT 表的第 3 项,所以偏移 8 字节):

在这里插入图片描述

    观察 helloplt0 部分,即地址 0x8048486 处,我们看到,指令中也确实是这么做的,jmp 的目标地址在编译时就计算好了,就是 *0x804a008

    除 GOT 表的基址固定外,可执行程序函数的重定位与动态库中函数的重定位完全一致。

(2)重定位引用的动态库中的变量

    可执行程序与动态库不同,一般而言,其地址是编译时分配好的,是固定的(这里我们不考虑为了安全而使用 PIE 技术)。如果编译时没有传给编译器参数 “-fPIC” ,那么对于引用的外部的全局变量,可执行程序不使用 GOT 表的方式寻址。换句话说,可执行程序引用的变量,在编译链接时就需要在编译链接时确定好地址,不能在加载时再进行重定位。

    但是,编译时动态库都不能确定自己的变量的最终加载地址,更别提可执行程序了。那怎么办呢?于是 ELF 标准定义了一种新的重定位类型 —— R_386_COPY。对于这种重定位类型,编译器、链接器和动态链接器是这样协作的:编译时,编译器将偷偷地在可执行程序的 BSS 段创建了一个变量,这样就解决了编译时,变量地址不确定的问题。在程序加载时,动态链接器将动态库中的变量的初值复制到可执行程序的 BSS 段中来。然后,动态库(包括其他动态库)在引用这个变量时,因为可执行程序在 link_map 的最前面,所以解析符号都将使用可执行程序中的这个偷偷创建的变量。

    下面我们结合 hello 引用动态库 libf1 中的变量 foo1 来具体的讨论一下。先来看一下 hello 的动态符号表:

在这里插入图片描述
    虽然我们没有在可执行程序中定义变量 foo1,但是根据动态符号表可见,可执行程序 hello 中却定义了变量 foo1,其所在地址是 0x0804a028,而且在第 25 个段中。我们来看看第 25 个段是什么:

在这里插入图片描述
    可见,第 25 个段是 .bss 。也就是说,编译时,链接器为可执行程序 hello 定义了一个未初始化的全局变量 foo1 。而 hello 中,使用的恰恰是 hello 自己的 foo1 ,而不是库 libf1 中的 foo1。观察下面中引用的符号 foo1 的地址,正是 hello 中定义的符号 foo1 的地址:

在这里插入图片描述

    链接器将 hello 的重定位表中 foo1 的重定位类型设置为 R_386_COPY ,当处理这个类型的重定位时,动态链接器将在加载时,将库 libf1 中变量 foo1 的值复制到 hello 中的 foo1

在这里插入图片描述

下面我们将程序运行起来,动态观察一下 R_386_COPY 类型的重定位过程。

在这里插入图片描述

    理论上,动态链接器应该将库 libf1 中的 foo1 的初值 10 复制到 hello 中定义的 foo1 处。我们将 hello 中定义变量 foo1 所在地址实际的值打印出来:

在这里插入图片描述
    可见,hello 中的 foo1 已经被赋值为库 libf1 中的 foo1 的初值 10 了。

    另外,库 libf1GOT 表中保存的 foo1 的地址,也应该指向 hello 中定义的 foo1 的地址,而不是库 libf1 中的变量 foo1 的地址。原因是链接时,可执行程序排在链表 link_map 的表头,所以 hello 中的符号 foo1 当然要优先于库 libf1 中的 foo1 。我们来实际验证一下这一点,首先找到库 libf1 中变量 foo1 所在位置:

在这里插入图片描述

在另外一个终端中查看库 libf1 在进程 hello 的地址空间中映射的基址:

在这里插入图片描述

libf1GOT 表中记录符号 foo1 的地址是:

在这里插入图片描述

我们打印一下 GOT 表中的值:

在这里插入图片描述

    根据输出可见,地址 0x0804a040 正是 hello 中定义的符号 foo1 的地址。可见,动态库 libf1 中使用的 foo1 变量是可执行程序中创建的这个副本。显然,虽然这个副本仅仅是编译器为其偷偷分配的,但是实际已经取代了库 libf1 中的 foo1,已经转正了。

    当然,在编译可执行程序时也可以给其传递参数 “-fPIC” ,如此,可执行程序中对外部变量的应用也将采用 GOT 表的方式,但是这对可执行程序没有任何意义。

8、重定位动态链接器

    在 Linux 中,动态链接器被实现为一个动态库的形式,而且这个动态库是自包含的(self-contained),没有引用其他库的符号,但是与普通动态库一样的道理,它在编译时也不知道自己的确切位置,所以它也难逃重定位的命运。事实上,当 C 库加载后,动态链接使用了 C 库中的内存管理相关函数替换了自身的实现。

    查看一下动态链接器的重定位表就可见其需要重定位的符号:

在这里插入图片描述
    但是,与动态库和可执行程序不同,它们有动态链接器负责为它们重定位,而动态链接器则没有这么好的命。在内核跳转到动态链接器时,它是非常残酷的,并没有给动态链接器如 link_map 信息。好在动态链接器不依赖其他的动态库,只需要确定自己被加载的基地址,然后找到动态链接需要的段 .dynamic 就可以解决问题,后续的重定位过程与动态库的过程基本完全相同。因此,动态链接器重定位自己的关键是:

    ◆ 确定自己被加载的基地址;
    ◆ 找到段 .dynamic

    动态链接器被加载的地址就相当于 link_map 中的 l_addr 了。运行后,动态链接器可以获取到某个符号的地址,但是这并不足以计算出动态链接器在进程地址空间中映射的基址,只有对比,才能求出基址。因此,动态链接器还是需要编译时的链接器作一点小小的配合。在编译时,链接器定义了一个符号 “_DYNAMIC” :

在这里插入图片描述
定义这个符号的目的就是为了标识段 .dynamic 所在的地址,看下面动态链接器的 Section Header Table

在这里插入图片描述

    由上可见,符号 _DYNAMIC 的地址正是段 .dynamic 的地址。在运行时,动态链接器使用如 x86 指令 lea 读取符号 _DYNAMIC 的运行时地址,实际就是读取运行时段 .dynamic 的地址。

    除了定义了这个符号外,在编译时,段 .dynamic 的地址也被装载到了 GOT 表中的第 1 项。读者回忆一下在 5.4.6 节讨论 GOT 表时的内容。其中,第 2 项的 link_map 和第 3 项的解析函数我们都已经看到其作用了,但是尚未看到第 1 项的意义。在重定位动态链接器时,这一项发挥了关键作用。前面我们就已经看到过 ,编译时定义了另外一个符号 _GLOBAL_OFFSET_TABLE_ ,目的与 _DYNAMIC 相似,是为了标识 GOT 表的地址。因此,动态链接器就可以使用符号 _GLOBAL_OFFSET_TABLE_ 找到 GOT 表,从而取出 GOT 表中第 1 项的值。

    然后,使用取得的符号 _DYNAMIC ,也就是段 .dynamic 的运行时地址,与 GOT 表第一项在编译时保存的段 .dynamic 的地址(其是相对于 0 的)做差,得出的就是动态链接器在进程地址空间映射的基址了。相关代码如下:

在这里插入图片描述
    注意变量 bootstrap_map,相信从名字读者已经猜出来了,相当于代表普通动态库和执行程序的 link_map。而且根据这个变量的名字,我们也可以揣摩到开发者的用意是在表达这是动态链接器的自举过程。变量 bootstrap_map 中的关键两项读者应该非常熟悉了,l_addr 是代表动态链接器自己被映射的地址,l_ld 代表动态链接器的段 .dynamic 所在的地址。找到段 .dynamic 后,动态链接器调用 elf_get_dynamic_info 读取了这个段的信息。

    我们来看看获取 l_addrl_ld 这两个地址的函数:

在这里插入图片描述

    函 数 elf_machine_dynamic 利用在编译时定义的符号 _GLOBAL_OFFSET_TABLE_ 读取 GOT 表中第 0 项的值。

    函数 elf_machine_load_address 计算动态链接器加载的地址。其首先取得符号 _DYNAMIC 的运行时地址,对于 x86 来说,可以使用指令 lea ,然后与 GOT 表中保存的编译时的地址做差,从而得出动态库在进程地址空间中映射的基址。

    事实上,动态连接器重定位表中的那些动态内存管理的函数,如 mallocfree 等,最初动态链接器使用的是自己内部的实现:

在这里插入图片描述

    但是一旦 C 库加载后,动态链接器将再次重定位这几个函数,使用 C 库中的相应实现。

9、段 RELRO

    最初,编译时链接器并没有过多考虑 ELF 文件中各个段的布局,一个 ELF 文件各个段的大致布局如图 5-38 所示。

在这里插入图片描述

    可见,动态链接器重定位涉及的 GOT 表、段 .dynamic 都位于数据段的后面,一旦数据段发生溢出,动态链接器使用的 GOT 表、段 .dynamic 都可能受到破坏,尤其是作为函数跳转表的 GOT 表,更容易被攻击者利用。而事实上,除了函数被延迟到运行时重定位外,变量等的重定位在加载时就已经完成了,后续动态链接器不再会对这些段进行写操作,也就是说完全可以在完成加载时重定位后,把这部分数据修改为只读。

    因此,如今的链接器重新安排了各个段的布局,将动态链接器涉及到的段提到了数据段的前面,并将 GOT 拆分为两个部分:.got.got.plt.got 部分用于记录需要重定位的变量,.got.plt 部分用于记录需要重定位的函数。在加载时完成重定位后,除了 .got.plt 仍然保留可写属性,允许在运行时进行重定位外,包括 .got 在内的其余部分全部更改为只读,减少被攻击的可能。

    这些在重定位后更改为只读的段被称为 RELRO 段。从 Program Header Table 的角度看,段 RELRO 仍然包含于数据段中,只不过是数据段开头部分一块只读的数据而已。经过上述调整后,一个 ELF 文件的大致布局演化为如图 5-39 所示的形式。

在这里插入图片描述

    在加载时完成重定位后,动态链接器将检查 ELF 文件的 Program Header Table 中是否存在段 RELRO 。如果这个段存在,则将这个段更改为只读,从而达到保护更多数据的目的。

   

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值