Linux加载启动可执行程序的过程(一)内核空间加载ELF的过程

 

linux内核启动时将ELF格式注册到内核可支持的文件格式链表中,也就是通过register_binfmt 函数将定义的elf_format结构体添加到链表中。该结构体如下:

当我们执行一个可执行程序的时候, 内核会list_for_each_entry遍历所有注册的linux_binfmt对象, 对其调用load_binrary方法来尝试加载, 直到加载成功为止。上面代码可以看倒,ELF中加载程序即为load_elf_binary,内核中已经注册的可运行文件结构linux_binfmt会让其所属的加载程序load_binary逐一前来认领需要运行的程序binary,如果某个格式的处理程序发现相符后,便执行该格式映像的装入和启动。

接下来,我们具体分析load_elf_binary函数。

第一步,填充并且检查目标程序ELF头部。

首先是填充映像的文件头,使用了内核之前对bprm->buf填充的128个字节信息。然后比较了文件头的前4个字节,查看是否是标准的ELF文件魔数(”\177ELF”),然后还需要确认该文件是可执行文件还是动态链接库文件,也就是代码中的ET_EXEC和ET_DYN

第二步,通过load_elf_phdrs加载目标程序的程序头表。

该函数有两个参数,elf_ex表示需要程序头表需要被加载的二进制映像的ELF头部;elf_file表示这个打开的ELF二进制映像文件。

函数首先检查该文件是否包含至少一个段,且所有段的大小之和是否超过64k。如果符合条件,调用kernel_read读入程序头表。

第三步,处理解释器段。

通过遍历每个段,找到PT_INTERP类型的段,也即解释器段,找到就说明运行过程中需要动态链接。同样也是通过kernel_read函数将解释器段的内容读入缓冲区。readelf命令可以查看到程序的解释器段其实就是一个字符串,也就是解释器的文件名,比如“/lib/ld-linux.so.2”。再调用open_exec()函数根据这个文件名打开解释器文件,和前面一样,再读入128个字节,也就是解释器映像的头部。

第四步,检查并读取解释器的程序头表。

可以看到加载解释器,其实原理和前面加载ELF可执行程序一样,也是线检查解释器头部信息,然后通过load_elf_phdrs加载解释器的程序头表。

完成了解释器初始化工作,并加载了目标执行执行的程序头表后,开始加载程序的段信息。

第六步,装入可执行文件的PT_LOAD段。

先遍历每个段,找到类型为PT_LOAD的段,检查地址和页面的信息,确定装入地址后,通过elf_map()建立用户空间虚拟地址与目标映像文件中某个连续区间的映射,返回值就是实际映射的起始地址。

第七步,填入程序的入口地址

前面的步骤已经完成了目标映像和解释器的加载,并且将目标程序的各个段家在近内存,但是,一个程序成功执行,操作系统还需要知道程序的入口地址,才能开始执行加载好的映像。

如果需要动态链接,就通过load_elf_interp装入解释器映像, 并把将来进入用户空间的入口地址设置成load_elf_interp()的返回值,即解释器映像的入口地址。

而若不需要装入解释器,那么这个入口地址就是目标映像本身的入口地址。

第八步,填写目标文件的参数环境变量等必要信息

通过create_elf_tables,为目标映像和解释器准备一些有关的信息,包括argc、envc等,这些信息需要复制到用户空间,使它们在CPU进入解释器或目标映像的程序入口时出现在用户空间堆栈上。

最后一步,通过start_thread准备进入新的程序入口。

 

接着上一篇博客。前面的工作都是在内核完成的,接下来会回到用户空间。

第一步,解释器(也可以叫动态链接器)首先检查可执行程序所依赖的共享库,并在需要的时候对其进行加载。

ELF 文件有一个特别的节区: .dynamic,它存放了和动态链接相关的很多信息,例如动态链接器通过它找到该文件使用的动态链接库。不过,该信息并未包含动态链接库的绝对路径,但解释器通过 LD_LIBRARY_PATH 参数可以找到(它类似 Shell 解释器中用于查找可执行文件的 PATH 环境变量,也是通过冒号分开指定了各个存放库函数的路径)该变量实际上也可以通过/etc/ld.so.conf 文件来指定,一行对应一个路径名。为了提高查找和加载动态链接库的效率,系统启动后会通过 ldconfig 工具创建一个库的缓存 /etc/ld.so.cache 。如果用户通过 /etc/ld.so.conf 加入了新的库搜索路径或者是把新库加到某个原有的库目录下,最好是执行一下 ldconfig 以便刷新缓存。

找到动态链接库后,就可以将其加载到内存中。

第二步,解释器对程序的外部引用进行重定位,并告诉程序其引用的外部变量/函数的地址,此地址位于共享库被加载在内存的区间内。动态链接还有一个延迟定位的特性,即只有在“真正”需要引用符号时才重定位,这对提高程序运行效率有极大帮助。(如果设置了 LD_BIND_NOW 环境变量,这个动作就会直接进行)

下面具体说明符号重定位的过程。

首先了解几个概念。符号,也就是可执行程序代码段中的变量名、函数名等。重定位是将符号引用与符号定义进行链接的过程,对符号的引用本质是对其在内存中具体地址的引用,所以本质上来说,符号重定位要解决的是当前编译单元如何访问「外部」符号这个问题。动态链接是在程序运行时对符号进行重定位,也叫运行时重定位(而静态链接则是在编译时进行,也叫链接时重定位)

现代操作系统中,二进制映像的代码段不允许被修改,而数据段能被修改。

编写如下代码

通过gcc编译成.o文件后,再通过objdump-d命令得到文件的汇编指令,如下所示

call指令的操作数是fc ff ff ff,翻译成16进制数是0xfffffffc,看成有符号是-4。这里应该存放printf函数的地址,但由于编译阶段无法知道printf函数的地址,所以预先放一个-4在这里。所以程序为了正确执行,需要在链接时对其地址进行修正。这里的原理对静态链接和动态链接来说都是一样的。

但对于动态链接来说,有两个不同的地方:

(1)因为不允许对可执行文件的代码段进行加载时符号重定位,因此如果可执行文件引用了动态库中的数据符号,则在该可执行文件内对符号的重定位必须在链接阶段完成,为做到这一点,链接器在构建可执行文件的时候,会在当前可执行文件的数据段里分配出相应的空间来作为该符号真正的内存地址,等到运行时加载动态库后,再在动态库中对该符号的引用进行重定位:把对该符号的引用指向可执行文件数据段里相应的区域。

(2)ELF 文件对调用动态库中的函数采用了所谓的"延迟绑定"(lazy binding)策略, 只有当该函数在其第一次被调用发生时才最终被确认其真正的地址,因此我们不需要在调用动态库函数的地方直接填上假的地址,而是使用了一些跳转地址作为替换,这样一来连修改动态库和可执行程序中的相应代码都不需要进行了,当然延迟绑定的目的不是为了这个,具体先不细说。

可执行程序对符号的访问又分为模块内和模块间的访问,这里只介绍模块间的访问,也就是访问动态链接库中的符号。

通过gcc生成test可执行文件,然后同样用objdump-d得到可执行文件的汇编指令,如下所示

可以看到这里的call指令指向了80482e0地址处,也即是PLT。

PLT就是程序链接表(Procedure Link Table),属于代码段。用于把位置独立的函数调用重定向到绝对位置。每个动态链接的程序和共享库都有一个PLT,PLT表的每一项都是一小段代码,从对应的GOT表项中读取目标函数地址。程序对某个函数的第一次访问都被调整为对 PLT入口也就是PLT0的访问,也就是说所有的PLT首次执行时,最后都会跳转到第一个PLT中执行。PLT0是一段访问动态链接器的特殊代码,是动态链接做符号解析和重定位的公共入口。这样做的好处是不用每个PLT表都有重复的一份指令,可以减少PLT指令条数。

PLT表结构如下图所示

可以看到,PLT会先执行jmp指令跳转到某一个地址,而这个地址就对应的GOT表项。

GOT就是全局偏移表(Global Offset Table),属于数据段。为了能使得代码段里对数据及函数的引用与具体地址无关,只能再作一层跳转,ELF 的做法是在动态库的数据段中加一个表项,也就是GOT 。GOT表格中放的是数据全局符号的地址,该表项在动态库被加载后由动态加载器进行初始化,动态库内所有对数据全局符号的访问都到该表中来取出相应的地址,即可做到与具体地址了,而该表作为动态库的一部分,访问起来与访问模块内的数据是一样的。

GOT表结构如下图所示

GOT[0]对应本ELF动态段(.dynamic段)的装载地址,GOT[1]对应本ELF的link_map数据结构描述符地址,GOT[2]:对应_dl_runtime_resolve动态链接器函数的地址。3个特殊项后面依次是每个动态库函数的GOT表项

上面讲到PLT通过jmp指令跳转到GOT表中去取函数的真实地址,而符号所对应的表项开始是没有这个地址的,而是存放了该PLT表项jmp指令的下一条指令地址,也就是push指令。回到了PLT表项对应的指令中继续执行,最后一条jmp指令跳转到了PLT0中执行。

PLT0对应的指令执行了下列过程:首先pushl把 804a004(GOT[1])这块内存里的qword入栈,这个qword是link_map的地址,根据这个地址可以找到动态库的符号表。然后jmp跳转到GOT表中的第三项,找到动态链接器的_dl_runtime_resolve函数地址,开始执行该函数。回想前面讲到的内核中加载目标映像的过程,可执行文件在Linux内核通过exeve装载完成之后,不直接执行,而是先跳到动态链接器(ld-linux-XXX)执行。在ld-linux-XXX里将link_map地址、_dl_runtime_resolve地址写到GOT表项内。所以在此时,该GOT表项的不为空。(前面三个GOT表项都是这样被写入的)然后当程序加载其它动态库的时候,会把动态库的符号信息插入link_map


_dl_runtime_resolve函数得到动态链接库中函数的地址后(该过程以后再分析),写回到对应的GOT表项中。

这就是函数第一次被调用时执行的过程。以后每次被调用直接从GOT表中取到函数地址就可以了。

总的来说,动态重定位的过程可以由下图表示


————————————————
版权声明:本文为CSDN博主「chrisnotfound」的原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/chrisnotfound/article/details/80082463

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值