【Linux系统】ELF 文件格式的硬核揭秘

文章目录

1. ELF格式

我们写的代码需要经过编译链接形成可执行程序,编译的过程就是将我们的代码翻译成CPU能直接运行的机械代码,链接就是将编译得来的目标文件(.o)与所需要的动态库文件进行链接形成可执行程序。

想要理解编译链接的细节,我们需要引入ELF文件格式(对二进制代码的封装),ELF文件包括:

  1. 可重定位文件(Relocatable File):我们的源代码经过编译形成的目标文件,包含代码与数据。
  2. 共享目标文件((Shared Object File):动态库文件(.so)。
  3. 可执行文件(Executable File):目标文件与动态库链接形成的可执行程序(.exe)。
  4. 内存转储(core dumps):存放当前进程的执行上下文,用于dump信号触发。

ELF文件包括以下四部分:

  • ELF头(ELF header):描述文件的主要特性。其位于文件的开始位置,它的主要目的是定位文件的其他部分。
  • 程序头表 (Program header table) :列举了所有有效的段(segments)和他们的属性。表里记着每个段的开始的位置和位移(offset)、长度,毕竟这些段,都是紧密的放在⼆进制文件中, 需要段表的描述信息,才能把他们每个段分割开。
  • 节头表 (Section header table) :包含对节(sections)的描述。
  • 节( Section ):ELF文件中的基本组成单位,包含了特定类型的数据。ELF文件的各种信息和数据都存储在不同的节中,如代码节(.text)存储了可执行代码,数据节(.data)存储了已初始化的全局变量和静态数据等。

接着具体地去认识下ELF文件格式的具体部分,使用readelf指令去读取程序的各个section,这里以ls程序为例:

bash 复制代码
readelf -S /usr/bin/ls

每个section可以看作一位数组的元素,前面的编号就是下标。

  • [16] .text:代码节
  • [26] .data:数据节
  • [27] .bss:记录未初始化的全局变量信息(比如说有多少个),加载时在内存再去开辟空间,这样可以节省磁盘空间,bss可以理解为Better space save

使用以下指令去读取ls程序的Program Header

bash 复制代码
readelf -l /usr/bin/ls

值得注意的是Segment mapping,这个记录了多个ELF在链接时的合并原则,各个section按照相同属性(可读、可写、可执行)进行合并,这样做可以有效减少页面碎片。提高内存使用效率。

我们来总结一下程序表头与节表头的作用,提供两个视角去理解:

  1. 链接视图(Linking View) - 对应节表头Section header table

    • 为了空间布局上的效率,将来在链接目标文件时,链接器会把很多节(section)合并,规整成可执行的段(segment)、可读写的段、只读段等。
  2. 执行视图(execution view)- 对应程序头表Program header table

    • 让操作系统知道如何加载可执行文件,完成进程内存的初始化(虚拟内存、页表的初始化),所以可执行程序一定需要有程序头表。

接着我们再来看看ELF Header,使用如下命令:

bash 复制代码
readelf -h /usr/bin/ls
  • Magic:用于标识ELF文件格式的随机值。
  • Entry point address:程序的入口地址(虚拟地址)

2. 静态链接

静态链接本质上是将各个.o文件链接起来,引入以下文件:

hello.c

c 复制代码
#include <stdio.h>
void run();
int main()
{
    printf("Hello Linux!\n");
    run();
    return 0;
} 

code.c

c 复制代码
#include <stdio.h>
void run()
{
    printf("run fuction\n");
}

将这两个文件编译成.o文件:

bash 复制代码
gcc -c *.c

接着我们查看下这两个文件的汇编代码,需要用到objdump命令:

bash 复制代码
objdump -d code.o
objdump -d hell.o

可以发现这里的call指令,对应着printfrun函数,由于编译hello.c时,编译器不知道printfrun函数的存在,因此编译器将这两个函数的跳转地址暂时设置为0,这个地址在链接的时候就会被修正,链接时链接器会根据数据块(.data)的重定位表进行修正。

注:printf涉及动态库的链接,暂不作说明。

接着我们将两个.o文件链接形成可执行程序:

bash 复制代码
gcc -o main *.o

再使用objdump指令查看main的汇编代码:

bash 复制代码
objdump -d main

可以看到这两个函数的跳转地址已经被修正了。

所以总的来说,链接其实就是将编译之后的所有⽬标文件连同⽤到的⼀些静态库或者动态库组合,拼装成⼀个独立的可执行文件。其中就存在上述的地址修正,当所有模块组合在⼀起之后,链接器会根据我们的.o⽂件或者静态库中的重定位表找到那些需要被重定位的函数全局变量,从而修正它们的地址。这其实就是静态链接的过程。

3. 虚拟地址空间的初始化

ELF文件采用平坦模式进行编址,也就是说对于每个ELF文件都是从0开始编址,这个地址严格来说是逻辑地址(起始地址+偏移量),而进程的虚拟地址空间采用的也是从0开始编址,所以ELF文件的编址就是进程的虚拟地址,ELF中的每个segment的起始地址和长度用于初始化进程的虚拟地址空间(mm_sttructvm_area_struct等结构体的[start, end]),并且同时用虚拟地址和物理地址初始化填充页表。

我们还是用上面提到的main可执行程序来说明,查看它的汇编代码:

bash 复制代码
objdump -d main

可以看到左边的编号是依次递增的,这其实就是ELF的平坦编址,也就是初始化进程虚拟地址空间的虚拟地址。

注:这里利用汇编代码来说明,是因为便于理解,汇编可以清晰的看出每个segment的起始地址和结束地址,实际上用于初始化虚拟地址空间的各个segment的起始地址和长度是存在于ELF文件的Program Header Table,汇编和ELF是描述同一信息的不同格式。

其中在这段汇编代码中有个比较重要的地址就是<_start>的起始地址,这个地址是程序的入口,也是ELF格式中ELF HeaderEntry point address

4. 动态链接

实际上,动态链接比静态链接常用的多,比如来查看下上述的main依赖的动态库:

  • libc.soC语言的标准库,提供常用的输入输出以及字符串处理等功能。

一般而言,编译器默认使用动态链接,因为静态链接产生的文件体积大,非常浪费内存资源,会使得操作系统很臃肿,相同部分的代码很多。

而动态链接就是将相同共享的代码单独提取出来,形成一个动态链接库,程序运行时跳转到动态库执行函数,这样的做法能有效节省内存资源。

动态链接实际上将链接的过程推迟到程序加载的时候,比如运行一个程序,操作系统首先将数据代码和用到的动态库加载到内存,每个动态库的加载地址不固定(操作系统根据当前内存的使用情况来分配),动态库加载到内存后就可以去修正动态库函数的跳转地址。

可执行程序的初始流程

C/C++程序运行时并不会直接跳转到main函数,而是先开始调用_start函数(glibc提供的特殊函数),这个函数才是程序真正的入口。

还是以main可执行程序为例子,查看汇编代码:

_start函数通常会做如下操作:

  1. 设置堆栈: 为程序创建一个初始的堆栈环境。
  2. 初始化数据段: 将程序的数据段(全局变量和静态变量)从初始化数据段拷贝到相应的内存位置,并且清零未初始化的数据段。
  3. 动态链接: 该函数调用动态链接器来解析和加载程序依赖的动态库,会对所有的符号进行解析和重定位。
  4. 调用__libc_start_main 动态链接完成后调用__libc_start_main函数,这个函数负责执行一些额外的初始化操作。
  5. 调用main函数:__libc_start_main函数去调用main函数,执行用户编写的代码。
  6. 处理main 函数的返回值: __libc_start_main函数最终会处理main函数的返回值,最终调用_exit函数来终止程序。

加载动态库的过程如图所示:

调用动态库

动态库文件也是ELF格式,一样会有ELF HeaderProgram Header table等,编址方式也是采用平坦模式(起始地址0 + 偏移量),也会有虚拟起始地址Entry Point Address,那么我们的可执行程序只需要知道库的虚拟起始地址+具体方法偏移量即可找到库中对应的方法。

具体的调用会从代码区跳转到共享区,调用完毕后返回代码区,整个过程在进程虚拟地址空间进行。

动态库加载到内存时就有了库的虚拟起始地址,当程序运行到一个依赖动态库的函数时,就会将函数的跳转地址动态修改成库的虚拟起始地址+函数偏移量,然后就去调用动态库函数。

注:对应函数的偏移量在编译的时候已经确定,也就是编译链接的时候可执行程序和动态库产生关联,将函数的偏移量填入,但是注意这里的链接不是真正的动态链接,真正的要在后面有了库的虚拟起始地址+偏移量时才能真正的调用动态库函数。

![[Pasted image 20250318214153.png]]

但是由于代码是不能修改的,所以其实在.data专门预留出一片空间用于存放函数的跳转地址,这块空间就是全局偏移表got,表中记录着函数依赖库的虚拟起始地址和偏移量。

有了全局偏移表got,函数的跳转地址就可以支持动态修改。

  • 由于在不同的进程空间中,动态库的相对位置都不同,所以每一个进程都已一张独立的got表,进程之间不能共享这张got表。
  • 在动态库中,got表与.text(代码段)的相对位置固定,可以利用相对寻址来找到got表。
  • 调用函数时先去查询got表,根据表中的地址进行跳转。
  • 这种方式实现的动态链接就是 PIC与地址无关码 ,也就是说动态库可以加载在任意内存,并且被所有进程共享。

PLT延迟绑定技术

由于动态链接在程序加载时需要对大量函数进行重定位(查询函数的偏移地址),这一步非常耗费的时间较长,而动态库的函数在程序运行时一次都不会调用,与其在一开始对所有函数进行重定位,不如推迟到函数第一次被调用的时候,这样做降低了开销。

GOT中的跳转地址默认会指向⼀段辅助代码,它也被叫做桩代码/stup。在我们第⼀次调⽤函数的时候,这段代码会负责查询真正函数的跳转地址,并且去更新GOT表。于是我们再次调⽤函数的时候,就会直接跳转到动态库中真正的函数实现。

相关推荐
Tony__Ferguson2 小时前
抽奖系统测试报告
java·功能测试·模块测试
zhglhy2 小时前
Jaccard相似度算法原理及Java实现
java·开发语言·算法
啥都不懂的小小白2 小时前
Java日志篇3:Logback 配置全解析与生产环境最佳实践
java·开发语言·logback
江沉晚呤时2 小时前
延迟加载(Lazy Loading)详解及在 C# 中的应用
java·开发语言·microsoft·c#
草根站起来2 小时前
局域网内网IP能不能申请SSL证书
服务器·tcp/ip·ssl
怀旧,2 小时前
【Linux系统编程】12. 基础IO(下)
linux·运维·服务器
Winter_Sun灬2 小时前
CentOS 7 编译安卓 arm64-v8a 版 OpenSSL 动态库(.so)
android·linux·centos
谷哥的小弟2 小时前
Spring Framework源码解析——ConfigurableApplicationContext
java·spring·源码
ベadvance courageouslyミ3 小时前
系统编程之进程
linux·进程·pcb结构体