7.7-11 重定位过程描述+可执行目标文件的加载+共享库动态链接

一、重定位

当链接器完成符号解析的步骤后,每个符号引用和符号定义都对应到了一起。此时,链接器已经知道了输入目标模块中的代码和数据段的大小,准备开始重定位工作,在重定位阶段链接器会将输入模块整合到一起并为每个符号赋予运行时地址。重定位分为两个步骤:

  • 重定位段和符号定义:在这个步骤,链接器将所有相同类型的段都整合进入一个新的聚合的段中。例如,所有的输入模块中的.data段都会被整合进入输出的可执行文件的.data段中。链接器接下来为新生成的段,原模块中的段以及原模块中的符号赋予运行时地址。当这步完成后,程序中的每一个指令和全局变量都有了一个独一无二的运行时地址内存地址。
  • 用段重定位符号引用:在这步中,链接器会修改代码和数据段中的每个符号引用,这时符号引用会指向正确的运行时地址。为了执行这步,链接器依赖一种在可重定位目标文件中的数据结构,称为重定位实体(relocation entries)

1.1 重定位实体

当汇编器生成目标模块时,它并不知道代码和数据最终在内存空间中的地址。它也不知道任何本模块引用的函数或全局变量的地址。所以每当汇编器遇到了一个最终位置未知的对象引用时,它就会生成一个重定位实体,来告知链接器如何在整合可重定位目标文件的时候修改这个引用。代码中的重定位实体放置在.rel.text段中。数据中的重定位实体放置在.rel.data中。

下图展示了ELF重定位实体的格式:
在这里插入图片描述
图7.8

其中:

  • offset是需要修改的引用的段内偏移量
  • symbol指代修改过的引用需要指向的符号
  • type告知编译器如何修改新的引用

ELF定义了11种不同的可重定位类型,其中很多都是晦涩难懂的,我们只需要关注两个最基本的:

  • R_386_PC32:指代重定位一个使用32位PC相对寻址(PC-relative address)的引用。一个PC相对寻址的地址是一个相对于当前程序计数器(program counter)的偏移量。当CPU执行到一个使用PC相对寻址的指令时(例如call指令),它会用当前程序计数器的值加上一个编码在指令内的32位值,来获取下一条要执行指令的内存地址。
  • R_386_32:重定位一个使用32位绝对寻址的引用。当使用绝对引用时,CPU直接利用指令内的32位值作为有效地址,不进行任何的修改。

1.2 重定位符号引用

下图是链接器重定位算法的伪代码:

在这里插入图片描述
第一行和第二行在每个段内都进行迭代,且遍历了段内的每一个可重定位实体。为了准确性,我们需要对图中的算法做一些假定,设每个段s都是一个字节码数组,每个重定位实体都是类型为Elf32_Rel的结构体(图7.8所示)。并且我们假定,当算法运行时,链接器已经为每个段选择了运行时地址(用ADDR(S)表示),且已经为每个符号选择好了运行时地址(用ADDR(r.symbol)表示)。

第三行计算了s中需要进行重定位的四字节大小的引用的地址。如果这个引用使用PC相对寻址,那么接下来它就被5-9行的代码所处理。如果这个引用使用绝对寻址方式,那么就会被11-13行的代码所处理。

refptr存储的值在后面的例子中进行了解释,它最终存储的值,在相对寻址方式下就是偏移量,在绝对寻址方式下就是地址值。而refaddr实际上存储的是当前指令寄存器PC-4的值(ADDR(s)+r.offset实际上是call X时X的偏移量,这个X就是PC-4的值)

1.2.1 重定位PC相对寻址引用的例子

在这里插入图片描述
上面是我们在图7.1(a)中的程序例子,其中main.o的.text部分中的main例程调用swap例程,swap例程在swap.o中定义。下面是由GNU objdump工具生成的call指令的反汇编列表:

6:	e8 fc ff ff ff				call 7<main+0x7>   swap();
								7:R_386_PC32 swap  relocation entry

从这个列表中,我们可以看到call指令从段偏移量0x6开始,它包含1字节操作码0xe8,后跟32位引用0xfffffc(代表小端编址表示下的十进制值−4,因为ADDR(s)+r.offset的值是PC-4,+4之后可以获得PC的值),这个引用的偏移量为7,因此这里是call 7。我们在下面的行中看到了此引用的重定位条目。(重定位条目和指令实际上存储在目标文件的不同段中。为了方便起见,objdump工具将它们放在一起显示。)重定位条目r由三个字段组成:

  • r.offset=0x7
  • r.symbol=swap
  • r.type=R_386_PC32

这些字段告知链接器从偏移量0x7处修改32位PC相对引用,以便在运行时指向swap例程。现在,假设链接器已经确定

ADDR(s) = ADDR(.text) = 0x80483b4

以及

ADDR(r.symbol) = ADDR(swap) = 0x80483c8

使用图7.9所示的算法,链接器首先计算出引用的运行时地址:

refaddr = ADDR(s)   + r.offset
		= 0x80483b4 + 0x7
		= 0x8048bb

接下来链接器的引用值从-4修改为0x9,这样引用在运行时就会指向swap例程:

*refptr = (unsigned)(ADDR(r.symbol) + *refptr - refaddr)
        上面的一个更便于理解的描述方式是:(unsigned)(ADDR(r.symbol) + (refaddr-*refptr)),中间括号内的值就是PC的值
	    = (unsigned)(0x80483c8      + (-4)    - 0x80483bb)
	    =(unsigned)(0x9) 

在最后得到的可执行文件中,call指令就有着如下的形式:

80483ba: e8 09 00 00 00 call 80483c8 <swap>   

在运行时,call指令将存储在地址0x80483ba处。当CPU执行call指令时,PC的值为0x80483bf,这是紧接call指令之后的指令的地址。为了执行call指令,CPU执行以下步骤:

  1. 当前PC入栈
  2. PC = PC + 0x9 = 0x80483bf + 0x9 = 0x80483c8

这时,程序下一条要执行的语句就变成了swap例程的第一条指令,这时就达到了我们想要的效果。

在call指令中创建了初始值为-4的引用的作用是很巧妙的。汇编程序使用此值来确保PC始终指向当前指令后面的指令。在具有不同指令大小和编码的不同机器上,该机器的汇编程序将使用不同的偏移。这是一个有效的技巧,允许链接器在无需了解特定机器的情况下进行重定位引用。

1.2.2 重定位绝对引用

仍然以图7.1中的示例程序为例,swap.o模块将全局指针bufp0初始化为全局buf数组的第一个元素的地址:

int *bufp0 = &buf[0];

由于bufp0是一个已经初始化了的数据对象,它将存储在swap.o可重定位目标模块的.data部分。但由于它被初始化为全局数组的地址,因此需要重定位它。以下是swap.o中.data节的反汇编列表(listing):

00000000 <bufp0>:
	0: 00 00 00 00 					int *bufp0 = &buf[0];
	                                0: R_386_32 buf 				Relocation entry

我们看到.data部分包含一个32位引用,即bufp0指针,其值为0x0。重定位实体告知链接器这是一个32位绝对引用,从偏移量0开始,必须重定位它,使其指向符号buf。现在,假设链接器已经确定:

ADDR(r.symbol) = ADDR(buf) = 0x8049454

接下来链接器就会使用图7.9中算法的第13行更新引用:

*refptr = (unsigned) (ADDR(r.symbol) + *refptr)
        = (unsigned) (0x8049454 + 0)
        = (unsigned) (0x8049454)

在我们最后得到的可执行目标文件中,引用就变成了如下形式:

0804945c <bufp0>:
	804945c: 54 94 04 08

换句话说,链接器已经决定在运行时变量bufp0将位于内存地址0x804945c,并且将被初始化为0x8049454,这是buf数组的运行时地址。

图7.10展示了在可执行文件中重定位后的.text段和.data段信息。

在这里插入图片描述
图7.10

练习7.4

下面的几个问题都与图7.10有关

A. 重定位后的swap函数的十六进制地址是什么?
B. 重定位后的swap函数的十六进制值是多少?
C. 如果链接器决定将.text段的值从0x80483b8修改至0x80483b4,此时第五行的重定位引用的十六进制值将会变为多少?

答案:
A. 0x80483bb
B. 0x9
C. 0x9 关键点在于,无论.text的地址如何变化,引用和swap函数间的相对距离都是不变的。

二、可执行目标文件

C程序最初是ASCII文本文件的集合,经过编译链接后已经转换成一个二进制文件,其中包含了将程序加载到内存并运行它所需的所有信息。图7.11展示了ELF可执行文件中的各种信息:
在这里插入图片描述
可执行目标文件的格式很类似于可重定位目标文件。ELF首部描述了文件的整体格式。它还包括程序的入口点,即程序运行时要执行的第一条指令的地址。.text、.rodata和.data节与可重定位对象文件中的节类似,只是这些节已重定位到其最终运行时内存地址。.init节中定义了一个小函数,称为_init,它将由程序的初始化代码调用。因为可执行文件是完全链接(重定位过)的,所以它不需要.rel节。

ELF可执行文件被设计为易于加载到内存,可执行文件的连续块会映射到连续内存段中。这个映射由段头表(segment header table)描述。图7.12显示了示例可执行文件p的段头表(利用OBJDUMP):
在这里插入图片描述
图 7.12

从段头表中,我们看到两个内存段将用可执行目标文件的内容初始化。第1行和第2行告诉我们,第一个段(代码段)与4KB( 2 12 2^{12} 212)边界对齐,具有读/执行权限,从内存地址0x08048000开始,总内存大小为0x448字节,并用可执行目标文件的第一个0x448字节初始化,该部分文件包括ELF头、段头表和.init、.text和.rodata段。

第3行和第4行告诉我们,第二个段(数据段)与4KB边界对齐,具有读/写权限,从内存地址0x08049448开始,总内存大小为0x104字节,并用从文件偏移量0x448开始的0xe8字节进行初始化,在本例中,它是.data节的开始。段中的剩余字节对应于运行时将初始化为零的.bss数据。

三、加载可执行文件

要运行一个可执行目标文件p,我们可以在Unix的命令行打上这个文件的名字:

unix> ./p

由于p并不属于内置shell命令,此时shell假定p是一个可执行目标文件,它通过调用称为loader的常驻内存的操作系统代码来运行p。任何Unix程序都可以通过调用execve函数来调用加载程序。加载程序将可执行目标文件中的代码和数据从磁盘复制到内存中,然后跳转到第一条指令或入口点来运行程序。将程序复制到内存中然后运行它的过程称为加载(loading)。

每个Unix程序都有一个类似于图7.13中的运行时内存映像:
在这里插入图片描述
图 7.13

在32位Linux系统上,代码段从地址0x08048000开始。数据段紧跟在下一个4KB对齐的地址处。运行时堆在跟随读/写段之后的下一个4KB对齐地址,并通过调用malloc库来实现增长。还有一个为共享库保留的段。用户堆栈总是从最大的合法用户地址开始,然后向下增长(朝着较低的内存地址)。从用户堆栈上方开始的段是为操作系统的内存驻留部分(称为内核)中的代码和数据保留的。

当加载程序运行时,它会创建图7.13所示的内存映像。在可执行文件中的段头表的指导下,它将可执行文件的块复制到代码和数据段中。接下来,加载程序跳转到程序的入口点,该入口点始终是_start符号的地址。位于_start处的启动代码在目标文件crt1.o中定义,对于所有C程序都是相同的。图7.14显示了启动代码中调用的特定序列:
在这里插入图片描述
在从.text和.init节调用初始化例程之后,启动代码将调用atexit例程,该例程将在应用程序正常终止时附加应调用的例程列表。exit函数运行atexit注册的函数,然后通过调用_exit将控制权返回给操作系统。现在,启动代码会调用应用程序的主例程,该例程开始执行我们的C代码。在应用程序返回后,启动代码调用_exit例程,该例程将控制权返回给操作系统。
在这里插入图片描述

练习 7.5

A 为什么每一个C程序都需要一个main函数
B 为什么一个C main函数可以通过调用exit、通过return语句或者不通过上面两种方式均可以正常中止

答案:

A 因为在位于_startC启动代码中的特定执行序列中会调用main函数
B 如果main函数通过return语句结束,那么控制权会返回到启动例程中,接下来启动例程会通过调用_exit将控制权返回操作系统中。如果用户省略return语句效果也是一样的。如果main函数通过exit结束,那么exit最终也会通过调用_exit将控制权返回给操作系统。所以三种情况是相同的,控制权最终均会返还给操作系统

四、利用共享库进行动态链接

静态库有一些显著的缺点,它和所有软件一样,需要定期维护和更新。如果应用程序程序员想要使用库的最新版本,他们必须意识到库已经更改,然后根据更新的库重新链接程序。

另一个问题是几乎每个C程序都使用标准的I/O函数,比如printf和scanf。在运行时,这些函数的代码会在每个正在运行的进程内存中的文本段中进行复制。通常系统会运行50–100个进程,这是对稀缺内存系统资源的严重浪费。(内存的一个有趣的特性是,不管系统中有多少内存,它始终是一种稀缺资源。)

共享库是解决静态库缺点的一个方法。它是一种特殊的目标模块,这个模块在运行时可以在任意内存地址加载,并与内存中的程序进行链接。这个过程称为动态链接,由一个称为动态链接器的程序执行。

共享库也称为共享对象,Unix系统上通常用.so后缀表示。Microsoft操作系统也大量使用共享库,但称之为DLL(动态链接库)。

共享库以两种不同的方式“共享”。首先,在任何给定的文件系统中,都有一个特定库的.so文件。这个.so文件中的数据和代码由所有引用这个库的可执行文件共享,这与静态库相反,静态库的内容是复制的,并嵌入到引用库的可执行文件中。其次,内存中共享库的.text节的一个副本可以由不同的运行过程共享。在第9章中,我们将更详细地研究虚拟内存。

图7.15总结了图7.6中示例程序的动态链接过程:
在这里插入图片描述
为了构建图7.5中所需的libvector.so向量算术例程,我们将使用以下指向链接器的特殊指令调用编译器驱动程序:

unix> gcc -shared -fPIC -o libvector.so addvec.c multvec.c

-fPIC标志指示编译器生成与位置无关(position-independent)的代码。-shared标志指示链接器创建共享目标文件。

当我们创建了库后,我们就可以将它链接到图7.6所示的示例程序中:

unix> gcc -o p2 main2.c ./libvector.so

这将创建一个可执行对象文件p2,它的格式支持在运行时和libvector.so库进行链接。其基本思想是在创建可执行文件时静态地进行一部分链接,然后在加载程序时动态地完成链接过程。

需要注意的是,在此时libvector.so中还没有代码或数据被拷贝进入可执行文件p2中。链接器只会复制一些重定位和符号表信息,这些信息将允许libvector.so中的代码和数据的引用在运行时被解析。

当加载器加载并运行可执行文件p2时,它会加载部分链接过的可执行文件p2。接下来加载七会注意到p2存在一个.interp节,这个节中包含了动态链接器的路径明程,这个动态链接器本身也是一个共享目标(例如Linux系统中的LD-Linux.SO)。加载器此时不会将控制权移交给应用程序,而是运行动态链接器。

接下来动态链接器会通过如下的操作来完成重定位过程:

  • 将libc.so的text及data内容重定位进入某个内存段中
  • 将libvector.so的text及data内容重定位进入另外的内存段中
  • 将p2中的引用重定位到libc.so和libvector.so中定义的符号中

最终,动态链接器将控制权移交给引用程序。从这里开始,共享库的地址是固定的,在整个程序的执行期间不会发生改变。

五、从其他应用程序中加载并链接共享库

待补充 719

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值