[TOC]
0. 问题的提出与笔者的目标
石老师对于为ArceOS适配Linux ELF文件动态链接提出了两个不同思路:
在为ArceOS实现动态加载的时候,同组张明洋同学贡献了这样的思路:
通过访问.rela.plt实现链接时重定位,通过在.rela.plt中得到需要重定位的函数条目,然后在自行用Rust实现的兼容musl库中寻找目标函数条目,通过访问r_offset,搜寻对应地址的解析函数GOT的表项地址,将Rust兼容库对应函数的地址(Rust中的)填入GOT的对应表项目中。属于上图中的第一种思路。
我希望通过这篇文章的总结,能够对整个Linux的动态加载机制与SO文件的组成有足够的了解,深入分析第一种实现方式,探索第二种实现方式的可能性。
我的期望是,通过最小化修改musl库(即尽可能通过复制的方式),通过传递abi table的方式,对musl库中的关键节点进行替换,在不需要实现一个Rust版的兼容musl的C库的情况下,在加载原始Linux应用的同时,向内存中加载替换版so库,并且通过修改GOT的方式,以及修改PLT函数的方式,扩展支持到延迟加载动态库中函数的地步。
1. 静态链接与动态链接概念的简单区分
静态链接是将所有第三方库函数全部打包到了一个可执行文件中,其体积较大,加载简单,其使用的库常用名称是*.a,是通过ar构建的;
动态链接不链接库文件,而是在运行时去加载,体积小,实现复杂,其使用的库常用名称为*.so,通过gcc类编译器构建的。
我们把编译后但是还未链接的二进制机器码文件称为目标文件(Object File),那些第三方库是其他人编译打包好的目标文件,这些库里面包含了一些函数,我们可以直接调用而不用自己动手写一遍。在编译构建自己的可执行文件时,使用静态链接的方式,其实就是将所需的静态库与目标文件打包到一起。最终的可执行文件除了有自己的程序外,还包含了这些第三方的静态库,可执行文件比较臃肿。
动态链接不将所有的第三方库都打包到最终的可执行文件上,而是只记录用到了哪些动态链接库,在运行时才将那些第三方库装载(Load)进来。装载是指将磁盘上的程序和数据加载到内存上。
注意的是,不同操作系统的动态链接库格式不同,linux的是共享目标文件(Shared Object)
.so,windows的是动态链接库(Dynamic Link Library).dll
2. 位置无关码PIC的原理、动态链接库、代码重定位
问题:动态链接库在编译时并未确定其在内存中的具体位置,而是在运行时加载,因此必须进行加载时重定位。
NOTE!: 本节参考文章:https://blog.csdn.net/tilblackout/article/details/135585340
1. 位置无关的编译器选项
这四个编译选项与位置无关代码(Position Independent Code,PIC)和位置无关可执行文件(Position Independent Executable,PIE)有关。它们的作用主要是为了提高代码的可重定位性,使得代码更适用于共享库和在内存中的不同位置加载的情况:
-fPIC(Position Independent Code)- 作用:生成位置无关代码,适用于共享库。
- 用途:当编译共享库时,通常需要使用
-fPIC,以确保库中代码可以在内存中的不同位置加载。
-fPIE(Position Independent Executable)- 作用: 生成位置无关的可执行文件,适用于可执行文件。
- 用途: 当编译可执行文件时,使用
-fPIE会生成一个可以在内存中的不同位置加载的可执行文件。
-pie(Position Independent Executable)- 作用: 生成位置无关的可执行文件,与
-fPIE类似。 - 用途: 在链接阶段,使用
-pie可以生成位置无关的可执行文件,这也是为了提高安全性。与-fPIE不同的是,-pie在链接时指定,而不是在编译时。
- 作用: 生成位置无关的可执行文件,与
-fno-pic- 作用: 禁用位置无关代码。
- 用途: 当不需要位置无关代码时,使用
-fno-pic禁用,生成与地址相关的代码。
2. 加载动态链接库
2.1. 问题引入
构建动态链接库的时候,链接器事先无法知道任何给定共享库将在进程的虚拟内存中的哪个位置加载。这个问题的解决方法视操作系统不同而不同,这里以Linux为例来讲解一个实现的思路。
1 | int test = 10; |
上述代码编译成动态链接库后,涉及到用mov指令将全局变量test的值从内存的位置取到寄存器中。但是mov指令需要绝对地址,而动态库是没有预定义的加载地址,所以这个地址将在运行时确定。
LinuxELF共享库中,主要有两个解决这个问题的方法:加载时重定位和位置无关代码。
2.2. 加载时重定位(Load-time relocation)
- 实时性: 在加载时进行地址重定位,即在将共享库加载到进程的地址空间时,需要根据实际的加载地址对库中的数据和代码引用进行修正。这就是为什么称之为“加载时”重定位。
- 非位置无关: 共享库中的代码和数据引用是使用绝对地址的,因此必须在加载时将这些地址调整为实际的加载地址。
存在的问题:
- 性能问题
当一个应用程序加载与加载时重定位条目关联的共享库时,尽管只需加载重定位的条目,但如果一个复杂的软件在启动时加载多个大型共享库,并且每个库都需要进行加载时重定位,会导致应用程序启动时间明显延迟。 - 代码段无法共享
共享库的初衷之一是为了节省RAM,使得一些常见的共享库能够被多个应用程序共享。这意味着对于每个应用程序,共享库都必须完全加载到内存中,导致相当大量的RAM浪费。 - 要求代码段可写
为了允许在加载时动态地修改其中的绝对地址,将其调整为实际的加载地址,加载时重定位要求代码段保持可写状态,这带来了潜在的代码安全风险。
对于加载时重定位这种方法,实际上已经过时了,甚至最新的编译器已经不支持这种方法。PIC是目前常见的解决方案,接下来我们就深入讨论一下位置无关代码。
3. 位置无关代码(Position Independent Code)
PIC的原理很简单:在代码中对所有全局数据和函数引用添加一个额外的中间层。通过巧妙地利用链接和加载过程中的结果,使共享库的代码部分实现位置无关。
3.1. 代码段和数据段之间的偏移
PIC的一个关键点是利用链接时已知的代码段和数据段之间的偏移。当链接器合并多个目标文件时,它会整合它们的各个部分,形成一个大的代码段。因此,链接器了解各个部分的大小和它们的相对位置。
举例来说,代码段可能直接跟在数据部分后面,这意味着从代码部分中的任意指令到数据段开头的偏移量等于代码部分的大小减去指令距离代码部分开头的偏移量。这两个量都是链接器已知的。
当然,其实在代码段和数据段之间有别的段,或者两个段的位置关系不是如此,都不影响链接器知晓它们的位置,并了解到所有段的大小。
3.2. 全局偏移表(Global Offset Table)
全局偏移表GOT可以帮我们实现位置无关数据寻址。实际上GOT就是一个地址表,存储在数据段中。假设代码段中的某个指令想要引用一个变量。它会引用GOT中的一个条目,而不是直接使用绝对地址引用(这将需要进行重定位)。由于GOT位于数据段的一个已知位置,这个引用是相对的,并且在链接器中是已知的,而GOT条目本身将包含变量的绝对地址。
通过将变量引用重定向到GOT,我们避免了在代码段中直接使用绝对地址,而是通过GOT中的条目进行引用,从而减少了需要在加载时进行的具体地址修正。但是,我们在数据段中引入了一个新的重定位,因为全局偏移表仍然需要包含变量的绝对地址。那么,这样做的优点有哪些呢?
- 加载时重定位需要对每个变量的引用都进行重定位,而在全局偏移表中,只需要对每个变量进行一次重定位
- 数据段是可写的,并且在进程之间不共享
实际上这就是解决前面提到的加载时重定位的三个缺点。
==这里我们需要重点关注的是==:Linux的动态链接器如何在运行时对全局偏移表进行修改。我们将通过了解这个内容,获得在ArceOS中直接加载SO文件的必要知识。
3.3. 函数的重定位
前面介绍的是全局变量的重定位,对于函数也需要重定位,它有着另一种机制:懒绑定。
当共享库引用某个函数时,函数的真实地址在加载时未知。为了加速这个过程,引入了过程链接表(PLT)。PLT包含对函数进行间接调用的代码,而不是直接包含函数地址。在程序执行时,当函数首次调用时,PLT代码负责将函数的真实地址填充到全局偏移表(GOT)中的相应条目。此后的调用直接通过GOT访问函数地址,避免了每个函数调用时的绑定延迟。这种机制减少了不必要的解析工作,提高了程序执行效率。
==这里我们可以知道==:存在某种方式,能够让我们在ArceOS中进行函数重定位。
4. 总结
至此解释了什么是位置无关代码,以及它如何帮助创建具有可共享只读文本段的共享库。位置无关代码(PIC)通过引入全局偏移表(GOT)和过程链接表(PLT)实现,解决了共享库加载时的重定位问题。GOT提供了数据和函数的间接引用,PLT实现了懒绑定,推迟函数地址的解析。当然这也伴随额外的内存加载和寄存器使用成本,但在权衡之下,现代的编译器都更倾向于使用PIC。
3. 位置无关代码的核心:GOT和PLT解析
3.1. GOT和PLT是什么?
-
PLT: Procedure Link Table,程序链接表 -
GOT: Global Offset Table,全局偏移表
通过将这两个表互相配合解决外部函数符号地址,解决运行时重定位的问题。这种方法可以让函数在调用时才确定地址,进程的启动时间加快,只需要一次绑定,又叫延迟绑定。
如果调用者使用了共享库的符号,则调用者的数据段会有一个GOT,用于记录共享库符号的地址;如果共享库A作为调用者使用了共享库B的符号,则共享库A的数据段也会有一个GOT。由于编译的时候不能知道共享库的符号地址,所以调用者通过GOT获取共享库的符号地址,运行时链接只需要修改位于数据段的GOT的内容,不需要对调用者的代码段重定位。
共享库有数据段和代码段,数据段是每个应用程序各自有一份,代码段是每个应用程序共享一份。
3.2. 示例引入与具体分析
1 |
|
上述的代码使用了一个外部的函数printf,这个函数会在一个共享库中存在。经过编译和链接之后,上述代码可执行文件中的print_banner函数的汇编指令如下:
1 | 0000000000001149 <print_banner>: |
可以看到,print_banner调用了puts函数(函数内部比较简单,直接被优化成puts了),而puts函数位于glibc动态库内,所以在编译和链接阶段,链接器无法知道进程运行起来后puts函数加载的地址。所以,上述的**<puts函数的地址>**一项是无法填充的,只有进程运行起来,puts函数的地址才能确定。
问题来了:进程运行起来之后,glibc动态库也装载了,puts函数地址亦已确定,上述call指令如何修改(重定位)呢?
2.2.节中提到的“加载时重定位”就是通过将*<puts函数的地址>*修改为puts函数的真正地址解决的。如前所说:
- 当进程启动,
libc.so装载完毕,那么puts对应二进制代码所在.text的地址也确定了,就是说puts函数的地址是明确的,修改call puts对应汇编二进制代码,改为puts正确的地址即可。但现代操作系统在不做特殊操作情况下,是不允许我们修改代码段的(实际还是可以修改的)。
==不过,既然我们自己在写ArceOS,其实完全可以这么做。==张明阳同学的代码就是直接将对应的函数加载到跳转的地址(原本是@plt的)。
更好的,我们可以加一层处理,call一个特定的内存地址(相对寻址)中存放的地址——存放了printf函数地址。如果这个puts地址放到.data段,这个段系统规定可读可写,访问这个变量就可以了,如果这些函数很多,对应的变量也很多,就可以看成表了。这也是GOT表的概念,GOT表就是存这些函数的地址,不过这些编译器帮我们做了。GOT表但还有一些问题,后面再说。 - 就刚才的问题,就算可以直接修改代码且也更改了代码,就会打破操作系统文件共用的原则,做不了所有进程共用一个动态库的原则。
==同样的,鉴于ArceOS的Unikernel的特殊性,其实完全可以这么做==。
所以我们要有更巧妙的方法来解决这个问题。如果我们通过PLT表,放在代码段,不再改变代码,这块代码能准确指引到正确的GOT,第一次时还能修改GOT表值,做到这样的效果,好像也能解决问题,实际就是这么做的。
因此,printf函数地址只能回写到数据段,而绝不能回写到代码段上。
回写:是指运行时修改,更专业的称谓应该是运行时重定位,与之相对应的还有链接时重定位。
运行重定位:通过运行时解决了地址问题,延迟加载的动态绑定技术。
链接重定位:编译的链接过程,就完成函数地址替换。
3.3. PLT的引入
3.3.1. 思路分析
根据前面讨论,运行时重定位是无法修改代码段的,只能将puts重定位到数据段。那在编译阶段就已生成好的call指令,怎么感知这个已重定位好的数据段内容呢?
答案是:链接器生成一段额外的小代码片段,通过这段代码获取puts函数地址,并完成对它的调用。额外代码如下:
1 | .text |
链接阶段发现puts定义在动态库时,链接器生成一段小代码puts_stub,然后pputs_stub地址取代原来的puts。因此转化为链接阶段对puts_stub做链接重定位,而运行时才对puts做运行时重定位。
存放函数地址的数据表,称为全局偏移表(GOT, Global Offset Table),而那个额外代码段表,称为程序链接表(PLT,Procedure Link Table)。
GOT表即:全局偏移表 ,Global Offset Table。当动态链接库中的代码使用PIC模式编译出地址无关代码时,在调用方需要生成相应的.got段来存储函数的地址。相当于一个函数指针来寻址动态链接库中的函数,这样做的原因:把原本在.text section中对一个动态库中函数地址的相对地址调用,转换为从数据段.got中的函数指针进行间接调用,这样就可以使主模块中(只是这里举例的场景, 调用者不一定是主模块)的.text section的代码中的函数地址不用重定位。
当启用延时加载特性时,我们的函数的间接调用会再加一层间接调用,也就是.plt段。此时,调用时:caller -> targetFun@plt -> targetFun@got.plt -> 目标函数。而.plt段的目的是提供一个特殊代码序列,当没有加载地址时,会在plt段顺序执行以触发延时加载。当加载完成后,以后都如上面的链条一样两次跳转后到达真正的函数。当有使用.plt段的时候,原来的.got不再存储函数的指针。此时会有另外一个类似的段来承担 相应的工作:.plt.got。
3.3.2. 代码解析
.text的main函数
1 | 0000000000001149 <print_banner>: |
可以看到,跳转到的是<puts@plt>,地址为0150。分析操作码:e8 f0 fe ff ff,e8,偏移跳转,而ff15是绝对跳转。
1 | Disassembly of section .plt.sec: |
在这里,我们看到了跳转到0x2f75(%rip)处执行(endbr64和bnd jmp中的bnd都可以认为与此问题无关的),即目标地址是当前 RIP 的值加上 0x2f75。问题是,此时的rip是多少呢?
plt函数的反汇编如下:
1 | Disassembly of section .plt: |
3.3.3. GDB调试PLT流程
1 | (gdb) l |
可以看出,0x555555557fd0是puts@got.plt的位置,进一步查找GOT表中的内存值:
1 | (gdb) x /gx 0x555555557fd0 |
这里是第一次,并没有被填充到puts函数的地址,而是一路指向了.plt函数,而在.plt函数中,进行了对应的操作,然后完成了GOT的填写。由于这里我无法继续实验,附上一个能够实现的博客:
https://blog.arg.pub/2023/04/18/linux/Linux%E5%8A%A8%E6%80%81%E9%93%BE%E6%8E%A5%E4%B8%AD%E7%9A%84GOT%E5%92%8CPLT/index.html
1
2
3
4
5
6
7 # 这里原文是反汇编了.plt函数
(gdb) x /5i $pc
=> 0x401020: pushq 0x2fe2(%rip) # 0x404008
0x401026: jmpq *0x2fe4(%rip) # 0x404010
0x40102c: nopl 0x0(%rax)
0x401030 <printf@plt>: jmpq *0x2fe2(%rip) # 0x404018 <printf@got.plt>
0x401036 <printf@plt+6>: pushq $0x0先看一下 GOT 表对应的内存
1
2
3 (gdb) x /4xg 0x404000
0x404000: 0x0000000000403e20 0x00007fffff7df190
0x404010: 0x00007fffff7c8bb0 0x0000000000401036同上面分析 RIP 偏移法,将 0x404008h 地址所对应的内容放到栈中,然后跳转到 0x404010 来执行,我们看一下这一内存的值,然后跳转到了 ld-linux-x86-64.so.2 这个 so 我们调用完,就是将 printf 函数的地址,写入到 Got 表中,404018 printf@GLIBC_2.2.5,GOT 表以 404000 开始,我们是 64 位的,也就是偏移 + 3 的地方,第 4 个数据。这里我们有些问题,为什么放到表中第 4 个数据呢?我们再观察一下前面的.plt 段,就会发现,printf 对应 push 0,memset 对应 push 1,按理论应该 printf 放到 GOT 表中第一项,memset 放到表中第 2 项,实际是因为 GOT 表中前三项已被占用了,有特别的用处。 GOT [0]: 自身模块的 dynamic 段地址,这里是 0x0000000000403e20, GOT [1]: 本模块的 link_map 地址,这里是 0x00007fffff7df190 GOT [2]: 系统模块中的_dl_runtime_resolve 函数地址,这里是 0x00007fffff7c8bb0 所以 printf 函数地址放到 GOT [3], 从第 4 项开始,是正常的,这里第一次是 0x0000000000401036。
从这里我们明白了,jmpq *0x2fe4 (% rip) 就是跳到了 GOT [2] 中_dl_runtime_resolve 的函数里面了,这个函数在 linux 的库中,算也挺复杂的,这里调用完这个函数,会将 printf 的地址写到 GOT [3] 中,并跳转到 printf 函数执行。
1
2
3
4
5
6
7
8
9 //调用形式为:
_dl_runtime_resolve((link_map*)(got[1]),0);
// 第一个参数为.plt开头处刚刚0x401026: jmpq *0x2fe4(%rip) # 0x404010
// 地址为got[1]的值,也就是0x00007fffff7df190
// 第二个参数0,为<printf@plt>:中push 0;
// 0x401036 <printf@plt+6>: pushq $0x0
// 同理如果是memset,就是<memset@plt>:中push 1;
// 0x401046 <memset@plt+6>: pushq $0x1我们看一下内存,注意 GOT [3] 已经被写成了 0x00007fffff614e10,为 printf 的函数内存地址
1
2
3 (gdb) x /4xg 0x404000
0x404000: 0x0000000000403e20 0x00007fffff7df190
0x404010: 0x00007fffff7c8bb0 0x00007fffff614e10为了这个,我们可以设置条件断点来监视一下,从此当我们下次调用 printf 函数,就不再走这一大圈的流程,直接就会调到 GOT [3] 中真正的 printf 地址。
GOT 表数据第一次填充: 进程从二进制装载时,GOT 表函数第一次是被 ld-linux-x86-64.so.2 填充为.plt 表中地址,然后直到调用真正函数,才像上面分析的那样,填充为真正的函数地址。
我只能通过bless工具直接查看:

根据加载进入的偏移量,直接查询GOT:(偏移量为0x555555554000)
1 | (gdb) x /10gx 0x555555557FB8 |
可以看到,其实原始的GOT中GOT[0], GOT[1], GOT[2]的值与执行开始的时候是不一样的,推测是Linux加载的过程中修改的。
这个是错误想法,恰恰相反,是因为我没有开启延迟加载才导致出现了这种情况。
3.4. 总结
plt 和 got 配合的延迟绑定加载,可以降低程序的启动时间,只有外部函数被调用了才真正动态加载,只需要加载一次,后续再调用,无需重复,但略为增加了开销,最致命的是,GOT 表是可写的,可以被外挂利用起来,达到替换函数攻击。==而这个就是我目前存在的想法,通过在执行loader.rs加载可执行文件进入内存的时候,将GOT表进行重写,然后执行自己的函数==
==问题==:我们需要了解_dl_runtime_resolve做了什么,并且尝试在ArceOS中替换这个函数,实现对GOT的填写。同时还要注意,ld-linux-x86-64.so.2是一个及其重要的东西。
==截止目前,我能想到的思路就是,可以在加载的时候直接修改GOT,替换上述函数,或者是直接修改PLT函数,用自定义的PLT函数指定修改OPT==
附录:整个plt.c编译出的汇编如下:
1 | plt: 文件格式 elf64-x86-64 |
4. ELF文件格式
4.1. ELF中的各个节区
ELF节区信息概述
| 节区名 | 节区说明 | 备注 |
|---|---|---|
| .rodata1 | 和rodata类似,只存放只读数据 | |
| .comment | 存放编译器版本信息,如字符串 “GCC:(GNU)4.2.0” | |
| .debug | 存放调试信息 | |
| .shstrtab | 节区头部表名字的字符串表(Section Header String Table) | |
| .plt | 过程链接表(Procedure Linkage Table),用来保存长跳转格式的函数调用 | |
| .got | 全局偏移表(Global Offset Table),在地址无关代码中才需要,所有只读段需要修复的位置都间接引用到此表, 因此只读段自身就无需修复,只需修复此got表即可. .got表是在编译期间确定,静态链接期间生成的 而.plt表是在静态链接期间确定,静态链接期间生成的 |
可执行文件通常不论如何编译都有got表,这是因为是否加入got表是由编译(cc1)期间决定的,而可执行文件默认连接的多个目标文件默认都有got表元素. |
| .got.plt | 实际上其本质是从.got表中拆除来的一部分,当开启延迟绑定(Lazy Binding)时,会将plt表中的长跳转(函数)的重定位信息单独放到此表中,以满足后续实际的延迟绑定. | |
| .symtab | (静态链接)符号表的作用是保存当前 目标文件中所有对符号的定义和引用. * 符号表中UND的符号不是当前目标文件定义的,也就是对符号的引用 * 符号表中其他非UND的符号,全部是定义在当前目标文件中的,也就是对符号的定义 |
默认所有非.L开头的符号都要输出,.L开头的不输出 |
| .strtab | 静态链接字符串表,其中记录的是静态链接符号表中使用到的字符串,这些字符串仅供静态链接符号表使用,strip的时候会将.symtab和.strtab两个段完全清除. | 符号表的第一个元素必须是 STN_UNDEF,其代表一个未定义的符号索引,此符号表项内部所有值都为0 |
| .group | 是用来记录多个节区的相关信息的,比如说代码段引用了数据段,这种信息是为了保证二进制处理时候不会操作错误的,像是elf自动生成的,细节见链接 |
动态链接相关节区:
| 节区名 | 节区说明 | 备注 |
|---|---|---|
| .interp | .interp整个段的内容就是一个字符串,此字符串为系统中动态链接器的路径,如: /lib/ld-linux-aarch64.so.1 linux的可执行文件加载时会去寻找可执行文件所需的动态链接器 |
|
| .dynamic | .interp保存的是动态链接器路径,.dynamic中保存的是动态链接器用到的基本信息,如动态链接符号表(.dynsym),字符串表(.dynstr),重定位表(.rela.dyn/rela.plt),依赖的运行时库,库查找路径等 | |
| .rela.dyn | 记录所有变量的动态链接重定位信息(.rela.plt记录的是函数),与.rela.plt一起,是系统中唯二的两张动态链接重定位表。 .rela.dyn记录除了.plt段之外所有段的动态链接重定位信息,若开启了地址无关代码,那么这些信息都应该只与.got段的地址有关. |
|
| .rela.plt | 过程连接表的动态链接重定位表,只要有过程链表,通常就会有此表,因为plt导致了绝对跳转,那么所有plt表中所有需要动态链接/重定位的绝对地址(可能在.got.plt或.got中,依赖于是否开启延迟绑定),都需要通过.rela.plt记录 此表中记录所有全局函数(长跳转函数)的动态链接重定位信息,与.rela.dyn一起,是系统中唯二的两张动态链接重定位表。 .rela.plt实际上记录的是.plt段的动态链接重定位信息,若未开启lazy binding,则这这些信息应该都只与.got段的地址有关;若开启lazy binding,则这些信息应该都只与.got.plt段的地址有关; |
需要动态链接重定位的原因主要是模块有导入符号的存在,这些符号在运行时才能确定, 地址无关代码并不能改变未定符号的本质(即不影响模块是否需要动态链接重定位), 但地址无关代码可以让重定位变得简单(如仅重定位 .got/ .data/ .got.plt) |
| .dynsym | 动态链接符号表,其格式和.symtab一样,readelf -s会尝试同时输出.dynsym和.symtab,如右图. 动态链接符号表是静态链接符号表的子集,其只保留了与动态链接相关的符号信息,所有模块内部符号则不保留(因此静态符号表是可以被strip的,其只对于目标文件有用). 动态链接符号表中未定义的符号(符号引用),又称为导入符号(类似导入表) 动态链接符号表中已定义的符号(符号定义),又称为导出符号(类似导出表) |
全局符号默认是直接导出到动态链接重定位表的 |
| .dynstr | 动态链接符号表用到的字符串表,其与静态链接字符串表(.strtab)分开的原因应该是.strtab是可以完全strip的 |
参考文章:
https://blog.csdn.net/weixin_46645965/article/details/136506249
https://blog.csdn.net/xuehuafeiwu123/article/details/72963229
https://blog.csdn.net/qq_36488756/article/details/129462449
https://blog.csdn.net/passenger12234/article/details/123429547
https://blog.csdn.net/lidan113lidan/article/details/119901186
4.2. ELF的结构体
4.2.1. ELF header
64位和32位ELF文件头结构包含了同名的成员域,只是某些成员域的长度不同
1 |
|
4.2.1.1. 基本类型
以下类型用于N位体系结构(N = 32,64,ElfN代表Elf32或Elf64,uintN_t代表uint32_t或uint64_t)
Elf64_Addr(作用为Unsigned program address,表示程序内的地址,无符号)为8字节长,ELF32_Addr为4字节,等同于64或32位平台的指针类型。
Elf64_Off(作用为Unsigned file offset,表示文件偏移量,无符号)为8字节长(等同于64位平台的long),Elf32_Off为4字节(等同于32位平台的int)。
Elf64_Half(作用为Unsigned medium integer,表示中等大小的整数,无符号)和Elf32_Half都是2字节, uint16_t。
Elf64_Word(作用为Unsigned integer,无符号整型)和Elf32_Word都是4字节,等同于int32_t。
4.2.1.2. e_ident
e_ident[0-3]:前4个元素构成魔幻数(Magic Number),取值分别为’0x7f’、’E’、’L’、’F’。
e_ident[EL_CLASS=4]:ELF文件是32位的(取值为1)还是64位的(取值为2)。
e_ident[EL_DATA=5]:数据的字节序是小端(Little Endian,取值为1)还是大端(Big Endian,取值为2)。
e_ident[EL_VERSION=6]:ELF文件版本,正常情况下该元素取值为1。
e_ident其余元素为字节对齐用。
4.2.1.3. e_type
该成员域的长度为2个字节(类型为Elf64_Half),指明ELF文件的类型。
4.2.1.4. e_machine
该成员域长度也为2个字节,指明该ELF文件对应哪种CPU架构。
4.2.1.5. e_flags
和处理器相关的标识。其取值和解释依赖e_machine
以ARM平台为例,介绍它的取值情况。
- 在ARM32位平台(e_machine被定义为标记符EM_ARM,值为40)上,e_flags取值为0x02(标记符为EF_ARM_HASENTY),表示该ELF文件包含有效e_entry值。为什么头结构中已经定义了e_entry,而ARM平台上还需要这个参数呢?原来,在ARM平台上,e_entry取值可以为0。而这和ELF规范中ELF文件头结构的e_entry为0表示没有e_entry的含义相冲突。所以在ARM平台上,e_entry为0的真正含义就由e_flags来决定。
- 在ARM64位平台(e_machine取值为183,标记符为EM_AARCH64)上,e_flags就没有特殊的取值
4.2.1.6. e_version
该成员取值同e_ident[EL_VERSION]。
4.2.1.7. e_entry
如果ELF文件是一个可执行程序的话,操作系统加载它后将跳转到e_entry的位置去执行该程序的代码。e_entry是虚拟内存地址,不是实际内存地址
4.2.1.8. e_phoff
ph是program header的缩写。由图4-1可知,program header table是执行视图中必须要包含的信息。e_phoff指明ph table在该ELF文件的起始位置(从文件头开始算起的偏移量)。
4.2.1.9. e_shoff
sh是section header的缩写。同e_phoff类似,如果该ELF文件包含sh table的话,该成员域指明sh table在文件的起始位置。
4.2.1.10. e_ehsize
eh是elf header的缩写。该成员域表示ELF文件头结构的长度,64位ELF文件头结构长度为64。
4.2.1.11. e_phentsize和e_phnum
这两个成员域指明ph table中每个元素的长度和该table中包含多少个元素。注意,ph表元素的长度是固定的,由此可计算ph table的大小是e_phentsize(ph entry size,每个元素的长度)×e_phnum(entry number,元素个数)。
4.2.1.12. e_shentsize和e_shum
说明sh table中每个元素的长度以及sh table中包含多少个元素
4.2.1.13. e_shstrndx
根据ELF规范,每个section都会有一个名字(用字符串表示)。这些字符串存储在一个类型为String的section里。这个section在sh table中的索引号就是e_shstrndx。
4.2.2. Program Header Table
Execution View中ELF必须包含Program Header Table,PH Table描述的是segment的信息
1 |
|
查看PH Table:readelf -l a.out
4.2.3. Section Header Table
1 | typedef struct { |
sh_name:每个section都有一个名字。ELF有一个专门存储Section名字的Section(Section Header String Table Section,简写为shstrtab)。这里的sh_name指向shstrtab的某个位置,该位置存储了本Section名字的字符串。sh_type:section的类型,不同类型的Section存储不同的内容。比如.shstrtab的类型就是SHT_STRTAB,它存储字符串。
sh_flags:Section的属性。下文将详细介绍sh_type和sh_flags。
sh_addr:如果该Section被加载到内存的话(可执行程序或动态库),sh_addr指明应该加载到内存什么位置(进程的虚拟地址空间)。
sh_offset:表明该Section真正的内容在文件什么位置。
sh_size:section本身的大小。不同类型的Section分别对应不同的数据结构。
查看 Section Header Table
1 | readelf --sections main.o |
根据ELF规范,sh table表第0项是占位用的,所以其值全为0
4.2.3.1. .shstrtab section
Section Header String Table的简写,Section的名字是字符串,这些字符串信息存储在Section Header String Table中
将指定名字或索引的section的内容转换成字符信息打印出来:readelf -p [section名|section索引] main.o
4.2.3.2. .text section
用于存储程序的指令
.text section的sh_type为SHT_PROGBITS(取值为1),意为Program Bits,即完全由应用程序自己决定(程序的机器指令当然是由程序自己决定的),sh_flags为SHF_ALLOC(当ELF文件加载到内存时,表示该Section会分配内存)和SHF_EXECINSTR(表示该Section包含可执行的机器指令)
用 “objdump-S-d main.o”可反编译.text的内容。”-S”参数表示结合源码进行反汇编。这要求编译main.o的时候使用gcc-g参数。
4.2.3.3. .bss section
block storage segment的缩写
.bss section包含了一块内存区域,这块区域在ELF文件被加载到进程空间时会由系统创建并设置这块内存的内容为0。注意,.bss section在ELF文件里不占据任何文件的空间,所以其sh_type为SHF_NOBITS(取值为8)
.bss的sh_flags取值必须为SHF_ALLOC和SHF_WRITE(表示该区域的内存是可写的。同时,因为该区域要初始化为0,所以要求该区域内存可写)。
打印指定section的内容readelf -x section名 main.o
4.2.3.4. .data section
.data和.bss类似,但是它包含的数据不会初始化为0。这种情况下就需要在文件中包含对应的信息了。所以.data的sh_type为SHF_PROGBITS,但sh_flags和.bss一样。读者可以尝试在main.c中定义一个比如”char c=’f’”这样的变量就能看到.data section的变化了。
4.2.3.5. .rodata section
包含只读数据的信息,比如main.c中printf里的字符串就属于这一类。它的sh_flags只能为SHF_ALLOC。
4.2.3.6. .symtab section
里边存储的是符号表(Symbol Table)。.symtab section的类型为SHT_SYMTAB。一般而言,符号表主要用于编译链接,也可以参与动态库的加载。
1 | typedef struct { |
st_name:该符号的名称,指向.strtab section某个索引位置。
st_info:说明该符号的类型和绑定属性(binding attributes)。
st_other:说明该符号的可见性(Visibility)。它往往和st_info配合使用,用法见上图所示的三个宏。
st_shndx:symbol table中每一项元素都和其他section有关系。st_shndx就是这个相关section的索引号
st_value:符号的值,不同类型的ELF文件该变量的含义不同。比如:对于relocatable类型,st_value:表示该符号位于相关section(索引号为st_shndx)的具体位置。而对于shared和executable类型,st_value为该符号的虚拟内存地址。
st_size:和这个符号关联的数据的长度
5. SO文件的操作手撕姿势
找到Symbol Table符号表,然后对其中的符号的入口地址进行查询
结论
在仔细研究上述流程后,我目前的设想如下:
与在张同学修改GOT的基础上,拓展支持从PLASH中加载SO的模式:
首先加载完整的动态链接库进入内存中。
1)延迟加载的动态绑定技术。在加载可执行文件的时候,直接替换GOT中的GOT[2]表项,重定位到自己的解析函数,然后进一步对GOT进行操作,将内存中的动态库的对应函数入口写入GOT。
2)加载时绑定。在加载可执行文件的时候,通过遍历可执行文件的rela.dyn表项,将对应的,在内存中存在的动态链接库的函数入口填入GOT表格中。
思路二的优势:
1)能减小ArceOS本身的大小,不需要涵盖so库。在不需要动态链接的时候可以不加载SO库,而需要的时候可以去加载。不用像是思路一一样需要重新构建ArceOS本身才能完成两个状态的切换。
2)有利于快速开发,拓展库支持。只需要替换原有库的一些底层组件,就可以大概率实现正确,而不需要像思路一一样还要再搞一遍库的支持。
思路二的劣势:
1)需要花费额外的时间加载so库,相比思路一要慢一些。但是考虑到Unikernel的特性,我怀疑这个不是一个劣势。
2)在发布ArceOS的时候,需要将修改的so库与对应的组件一同发布,比直接思路一可以直接发布一个组件要麻烦很多。