深入理解操作系统(19)第七章:链接(3)加载可执行目标文件+动态链接共享库(main开始前的操作/加载器的工作过程/共享库背景/PIC/廷迟绑定)

1. 加载可执行目标文件

1.1 可执行目标文件a.out执行过程

可执行目标文件a.out执行过程:

1. ./a.out
	要运行可执行目标文件a.out,我们可以在的命令行中输入它的名字:./a.out。
	因为a.out不是一个内置的shell命令,所以shell会认为a.out是一个可执行目标文件
	
2. 通过execve调用加载器来运行a.out
	通过调用某个驻留在存储器中称为加载器(loader)的操作系统代码来为我们运行它。
	任何Unix程序都可以通过调用execve函数来调用加载器
	
3. 拷贝数据至存储器
	加载器将可执行目标文件中的代码和数据从磁盘拷贝到存储器中
	
4. 运行 a.out
	然后通过跳转到程序的第1条指令,即入口点(entry point)来运行该程序。

1.1.1 加载的定义

这个将程序拷贝到存储器并运行的过程叫做加载(loading)

1.2 进程运行时存储器映像(虚拟地址空间)

每个Unix程序都有一个运行时存储器映像,如下图所示。

图7.13
在这里插入图片描述

说明:

1. 在Linux系統中代码段总是从地址0x08048000处开始。
2. 数据段是在接下来的下一个4KB对齐的地址处。
3. 运行时堆在接下来的读/写段之后的第一个4对齐的地址处,并通过调用malloc库往上增长。
4. 开始于地址0x40000000处的段是为其享库保留的。
5. 用户栈总是从地址0xbfffffff处开始,并向下增长的(向低存储器地址方向增长)。
6. 从栈的上部开始于地址0xc0000000处的段是为操作系统驻留存储器的部分(也就是内核)的代码和数据保留的。

虚拟地址空间参考:
https://blog.csdn.net/lqy971966/article/details/119378416

1.3 main 开始前的操作

当加载器运行时,它创建如上图7.13所示的存储器映像。

1. 在可执行文件中段头表的指导下,加载器将可执行文件的相关内容拷贝到代码和数据段。
	段头表:一种将可执行文件中的组块映射到连续存储器段的映射结构
2. 接下来,加载器跳转到程序的入口点,也就是符号_start的地址。
3. 在_start地址处的启动代码(startup code〕是在目标文件ctrl.o中定义的,
	ctrl.o对所有的C程序都是一样的。
4. 在.text和.init节中调用了初始化例程后,启动代码调用atexit例程,
	这个程序附加了一系列在应用调用exit函数时应该调用的程序。
	图7.14展示了启动代码中特殊的调用序列。
	exit函数运行atexit注册的函数,然后通过调用_exit将控制返回给操作系统。
5. 接着.启动代码调用应用程序的main程序,这就开始执行我们的c代码了。
6. 在应用程序返回之后,启动代码调用_exit程序,它将控制返回给操作系统。

图7.14
在这里插入图片描述

1.4 加载器的工作过程

1. Unix系统中的每个程序都运行在一个进程上下文中,这个进程上下文有自己的虚拟地址空间。
2. 当shell运行一个程序时,父shell迸程生成一个子进程,它是父进程的一个复制品。
3. 子进程通过execve系统调用启动加载器、加载器删除子进程已有的虚拟存储器段,
	并创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零。
4. 通过将虚拟地址空间中的页映射到可执行文件的页大小的组块(chunks)。
5. 新的代码和数据段被初始化为可执行文件的内容。
6. 最后,加载器跳转到_start地址,它最终会调用应用的maim函数。

除了一些头部信息,在加载过程中没有任何从磁盘到存储器的数据拷贝。直到CPU引用一个被映射的虚拟页,才会进行拷贝,此时,操作系統利用它的页面调度机制自动将页面从磁盘传送到存储器。

2. 动态链接共享库

2.1 共享库背景:

2.1.1 静态库的缺点1:版本更新麻烦

静态库和所有的软件一样,需要定期维护和更新。如果应用程序员想要使用一个库的最新版本,他们必须以某种方式了解到该库的更新情况,然后显式地将他们的程序与新的库重新链接。

1. 静态链接库的版本更新麻烦。如lib更新了,还需要重新编译可执行文件
	可能是一个很小的改动,却导致整个程序重新下载,全量更新

2.1.2 静态库的缺点2:内存空间浪费

另一个问题是几乎每个C程序都使用标准I/O函数,比如printf和scanf在运行时,这些函数的代码会被复制到每个运行进程的文本段中。在一个运行50一100个进程的典型系统上,这会是对稀少的存储器系统资源的极大浪费。

2.2 共享库

共享库是致力于解决静态库缺陷的一个现代创新产物。

共享库是一个目标模块,在运行时,可以加载到任意的存储器地址,并在存储器中和一个程序链接起来。
这个过程称为动态链接,是由一个叫做动态链接器的程序来执行的。

共享库也称为共享目标(sharedobject),在Unix系统中通常用.so后缀来表示。
微软的操作系统大量地利用了共享库,它们称为DLL(动态链接库)

2.3 共享库的"共享"的两个不同之处

共享库的"共享"在两个方面有所不同。

1. 在任何给定的文件系统中,对于一个库只有一个.so文件。
	所有引用该库的可执行目标文件共享这个.so文件中的代码和数据,
	而不是像静态库的内容那样被拷贝和嵌入到引用它们的可执行的文件中

2. 在存储器中,一个共享库的.text节只有一个副本可以被不同的正在运行的进程共享。

2.4 例子说明

图 7.15
在这里插入图片描述

-fPIC 选项指示编译器生成与位置无关的代码
-shared 选项指示链接器创建一个共享的目标文件

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

这样就创建了一个可执行目标文件p2,而此文件的形式使得它在运行时可以和libvector.so链接。

2.4.1 动态链接库链接过程 VIP!!!

1. 基本的思路是当创建可执行文件时,静态执行一些链接,然后在程序加载时,动态完成链接过程。

2. 认识到这一点是很重要的:
	在此时刻,没有任何libvector.so的代码和数据节被真的拷贝到可执行文件p2中。
	取而代之的是,链接器拷贝了一些重定位和符号表信息,
	它们使得运行时可以解析对libvector.so中代码和数据的引用。

当加载器加载和运行可执行文件p2时,加载部分链接的可执行文件p2。接着,它注意到p2包含一个.interp节。这个节包含动态链接器的路径名,动态链接器本身就是一个共享目标(比如,在Linux系统上的LD-LINUX.SO)。加载器不再像它通常那样将控制传递给应用,取而代之的是加载和运行这个动态链接器。然后,动态链接器通过执行下面的重定位完成链接任务:

1. 重定位libc.so的文本和数据到某个存储器段。
	在IA32/Linux系统中,共享厍被加载到从地址Ox4000000开始的区域中
2. 重定位libvector.so的文本和数据到另一个存储器段。
3. 重定位p2中所有对由libc.so和libvector.so定义的符号的引用。
4. 最后,动态链接器将控制传递给应用程序。
	从这个时刻开始,共享库的位置就固定了,并且在程序执行的过程中都不会改变,

参考:
静态链接库和动态链接库
https://blog.csdn.net/lqy971966/article/details/105207532

3. 从应用程序中加载和链接共享库

3.1 共享库应用场景

到此刻为止,我们己经讨论了在应用程序执行之前,即应用程序被加载时,动态链接器加载和链接共享库的情景。
然而,应用程序还可能在它运行时要求动态链接器加载和链接任意共享库,而无需在编译时链接那些库到应用中。

3.2 动态链接现实场景

动态链接是一项强大有用的技术。下面是一些现实世界中的例子:

1.分发软件。

微软Windows应用的开发者常常利用共享库来分发软件更新。他们生成一个共享库的新版本,然后用户可以下载,并用它替代当前的版本。下一次他们运行应用程序时,应用将自动链接和加载新的共享库。

2.构建高性能web服务器。

许多Web服务器生成动态内容,比如个性化的web页面、账户余额和广告标语。早期的web服务器通过使用fork和execve创建一个子进程,并在该子进程的上下文中运行程序,来生成动态内容。然而,现代高性能的b服务器可以使用基于动态链接的更有效和完善的方法来生成动态内容。
其思路是将生成动态内容的每个函数打包在共享库中。当一个来自Web浏览器的请求到达时,服务器动态地加载和链接适当的函数,然后直接调用它,而不是使用fork和execve在子进程的上下文中运行函数函数会一直缓存在服务器的地址空间中,所以只要一个简单的函数调用开销就可以实现。

3.3 例子:

参考:
dlopen、dlsym、dlclose
https://blog.csdn.net/lqy971966/article/details/90942219

4. 位置无关代码 PIC

4.1 位置无关代码背景及PIC出现

共享库的一个主要目的就是允许多个正在运行的进程共享存储器中相同的库代码,因而节约宝贵的存储器的资源。

4.1.1 问题:多进程如何共享库代码?

那么,多个进程是如何共享一个程序的一个拷贝的呢?

4.1.2 解决:方法一:预先分配好专用地址空间

一种方法是给每个共享库分配一个事先预备的专用的地址空间组块(chunk),然后要求加载器总是在这个地址加载共享库。
虽然这种方法很简单,但是它也造成了一些严重的问题。

缺点:
首先,它对地址空间的使用效率不高,因为即使一个进程不使用这个库,那部分空间还是会被分配出来。
第二,它也难以管理,我们不得不保证没有组块会重叠。每次当一个库修改了之后,我们必须确认它的己分配的组块还适合它的大
。如果不适合了,我们必须找一个新的组块。并且,如果我们创建了一个新的库,我们还必须为它寻找空间。随着时间的进展,假设在一个系统中有了成百个库和各种版本的库,就很难避免地址空间分裂成大量小的、未使用而又不再能使用的小洞.甚至更糟的是,对每个系统而言,从库到存储器的分配都是不同的,这就引起了更多令人头痛的管理问题。

4.1.2 解决:方法二:位置无关代码 PIC

一种更好的方法是编译库代码,使得不需要链接器修改库代码,就可以在任何地址加载和执行这些代码。

这样的代码叫做与位置无关的代码(position-independent code,PIC)。

用户对GCC使用-PIC选项指示GNU编译系统生成PIC代码。

在一个I32系统中,对同一个目标模块中过程的调用是不需要特殊处理的,因为引用是PC相关的,已知偏移量,就己经是PIC了。然而.对外部定义的过程调用和对全局变量的引用通常不是PIC,因为它们都要求在链接时重定位。

4.2 PIC 数据引用

4.2.1 全局偏移量表 GOT

编译器通过运用以下有趣的事实来生成对全局变量的PIC引用:

无论我们在存储器中的何处加载一个目标模块(包括共享目标模块),数据段总是分配为紧随在代码段后面.

因此,代码段中任何指令和数据段中任何变量之间的距离都是一个运行时常量,与代码段和数据段的绝对存储器位置是无关的。

为了运用这个事实,编泽器在数据段开始的地方创建了一个表,叫做全局偏移量表
(Global offset table,GOT)。

GOT包含每个被这个目标模块引用的全局数据目标的表目。编译器还为GOT中每个表目生成一个重定位记录。在加载时,动态链接器会重定位GOT中的每个表目,使得它包含正确的绝对地址。每个引用全局数据的目标模块都有一张自己的GOT。

在运行时,使用下面的代码形式,通过GOT间接地引用每个全局变量;
在这里插入图片描述
在这段代码中,对LI的调用将返回地址(正好就是popl指令的地址)压人栈中。随后,pl指令把这个地址弹出到%ebx中,这两条指令的最终效果是将PC的值移到寄存器%ebx中。
指令addl给%ebx增加一个常量偏移量,使得它指向GOT中适当的表目,该表目包含数据项的绝对地址。此时,就可以通过包含在%ebx中的GOT表目间接地引用全局变量了。在这个示例中,两条movl指令(间接地通过GOT)加载全局变量的内容到寄存器%eax中。
PIC代码有性能缺陷。现在每个全局变量引用需要五条指令而不是一条,还需要一个额外的对GOT的存储器引用。而且,PIC代码还要用一个额外的寄存器来保持GOT表目的地址,在具有大寄存器文件的机器上,这不是一个大问题。然而,在寄存器供应不足的IA32系统中,即使失掉一个寄存器也会造成寄存器溢出到栈中。

4.3 PIC 函数调用

PIC代码当然可以用相同的方法来解析外部过程调用。
在这里插入图片描述

不过,这种方法对每一个运行时过程调用都要求三条额外的指令。取而代之,ELF编译系统使用一种有趣的技术,叫做廷迟绑定(lazybinding)'将过程地址的绑定推迟到第一次调用该过程时。第一次调用过程的运行时开销很大,但是其后的每次调用都只会花费一条指令和一个间接的存储器引用。
延迟绑定是通过两个数据结构之间简洁但又有些复杂的交互来实现的,这两个数据结构是:

GOT和PLT(procedure linkage table,过程链接表)。

如果一个目标模块调用定义在共享库中的任何函数,那么它就有自己的GOT和PLT。GOT是.data节的一部分,PLT是.text节的一部分。
下图展示了示例程序main.o的GOT的格式。头三条GOT表目是特殊的:GOT[0]包含dynamic段的地址,这个段包含了动态链接器用来绑定过程地址的信息,比如符号表的位置和重定位信息;GOT[1]包含一些定义这个模块的信息:GOT[2]包含动态链接器的延迟绑定代码的入口点。

5. 处理目标文件的工具

在linux系统中有大量可用的工具可以帮助你理解和处理目标文件。
如:

1. ar:创建静态库,插入,删除,列出和提取成员。
	1. 创建myuhello.a静态库 
		ar -cr libmyhello.a hello.o 
	2. 列出库中已有成员
		ar -t /usr/lib/x86_64-linux-gnu/libc.a	
2. strings:列出一个目标文件中所有可打印的字符串。
3. strip:从目标文件中删除符号表信息。
4. nm:列出一个目标文件的符号表中定义的符合。
5. readelf:显示目标文件的完整结构。包括elf头中编码的所有信息。
	包含size和nm功能。
6. objdump:所有二进制工具之母。
	能够显示目标文件中所有的信息。
	它最有用处的功能是反汇编.text节中的二进制指令。
7. ldd:列出一个可执行文件在运行时所需要的共享库。

参考:
readelf 和 objdump 例子详解及区别
https://blog.csdn.net/lqy971966/article/details/106905237

6. 第七章总结:

1. 链接可以在编译时由静态编译器来完成,也可以在加载时和运行时由动态链接器来完成。
2. 链接器处理称为目标文件的二进制文件,它有三种不同的形式:
	可重定位的、可执行的和共享的。
3. 可重定位的目标文件由静态链接器组合成一个可执行的目标文件,它可以加载到存储器中并执行。
4. 共享目标文件(共享库)是在运行时由动态链接器链接和加载的,
	或者隐含地在调用程序被加载和开始执行时,
	或者根据需要在程序调用dlopen库的函数时。
5. 链接器的两个主要任务是符号解析和重定位。
	符号解析将目标文件中的每个全局符号都绑定到一个惟一的定义,
	而重定位确定每个符号的最终有储器地址,并修改对那些目标的引用。
5. 静态链接器是由像GCC这样的编译器调用的。
	它们将多个可重定位目标文件组合成一个单独的可执行目标文件。
6. 多个目标文件可以定义相同的符号,
	而链接器用来悄悄地解析这些多处定义的规则可能在用户程序中引人的微妙错误。
7. 多个目标文件可以被连接到一个单独的静态库中。
8. 链接器用库来解析其他目标模块中的符号引用。
	许多链接器通过从左到右的顺序扫描来解析符号引用,这是另一个引起令人迷惑的链接时错误的来源。
9. 加载器将可执行文件的内容映射到存储器,并运行这个程序。
10. 链接器还可能生成部分链接的可执行目标文件,
		这样的文件中有末解析的到定义在共享库中的程序和数据的引用。
11. 在加载时,加载器将部分链接的可执行文件映射到存储器,
		然后调用动态链接器,它通过加载共享库和重定位程序中的引用来完成链接任务。
12. 被编译为位置无关代码的共享库可以加载到任何地方,也可以在运行时被多个进程共享。
13. 为了加载、链接和访问共享库的函数和数据,应用程序还可以在运行时使用动态链接器。
  • 1
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值