(Linux)ELF格式与库的链接原理

ELF格式

基本概念

在Linux下,以.o后缀的目标文件、.so后缀的动态库文件、可执行文件等都是二进制文件,它们有一个共同的格式------可执行可链接格式,简称ELF格式, 这些文件也被称为ELF文件。示意图如下

这些文件在磁盘中就是以这种格式存储的。其中ELF Header(ELF 头) 是 ELF 文件结构的核心和起点,它描述了整个文件的组织结构和基本信息。使用命令readelf -h 文件名可以查看 ELF Header,第一行的magic(魔数)就是用于识别该文件是不是ELF文件的。

ELF文件示意图(即第一张图)中最后一个区域是节头表(Section Header Table),第二个区域是程序头表(Program headers),这两张表分别为我们提供了ELF文件的两种视图。一个是链接视图,一个是执行视图。

链接视图

从示意图中可以看到一个ELF格式的文件有多个不同的节(Section),节头表中记录了各个Section的信息,它是一个数组,使用命令readelf -S 文件名就可以查看对应文件的节头表的内容。

不同的Section存放不同的数据,如代码中已初始化的全局变量放在.data中,未初始化的则放在.bss中,二者分开存放主要原因是未初始化的全局变量不需要使用额外的空间存储初始化信息。程序主体已经转化为二进制指令,存放在.text中。

最后一列的奇数行是各个Section起始位置的相对于指定地址Address的偏移量。这个Address暂且不管,主要了解一下偏移量。我们知道操作系统是将文件视作一个一维数组进行管理,只需在段头表中记录各区域起始位置的偏移量和大小就能将区分文件的不同区域。

程序链接的本质就是将多个ELF格式的文件中相同的Section进行合并,如下

当然实际的合并过程要复杂一些,里面还会涉及到对库的合并,这里不过多展开。

执行视图

当可执行文件和动态库等可以运行的ELF文件加载到内存时也会对自身的Section进行另一种形式的合并,不同点在于这里是将一个文件下多个不同的Section合并成段(segment) ,具体的合并规则则记录在程序头表(第一张图中的Program header table)中。要注意的是一般只有可以运行的ELF文件才有程序头表,所以上面的链接示意图其实有一点细节上的纰漏。

使用命令 readelf -l 文件名 即可查看合并后的各个segment的信息,这些信息也来自程序头表。

当我们要运行程序时,操作系统会从可执行文件的程序头表中查看要读取的区域的大小和位置,然后将对应区域的内容读取到内存中。在Program headers中,还有各区域的访问权限信息,即读、写、执行权限,操作系统会根据这些信息来填写页表中的权限信息。

将Sectionn合并为segment主要是为了提高内存使用效率。和磁盘类似,内存也有基本存储单元,通常是4字节。将文件内容加载到内存中时会占据4字节的整数倍。如果内容很少或者只比4字节的整数倍大一点点,就会出现内存空间资源的浪费,而内存中的空间可比磁盘的要宝贵得多,所以将Section合并是很有必要的。

链接原理

静态链接

在链接之前,程序中使用的库函数是什么形式呢?我们先写一个简单的程序并编译成.o文件。

cpp 复制代码
#include<stdio.h>
int main(){
        printf("test\n");
        return 0;
}

使用命令 objdump -d .o文件名 对.o文件进行反汇编,结果如下图。其中的e8对应右边call,是调用函数的意思,e8旁边就是函数的地址,我们可以看到地址是全0,这是因为我们还没有对库进行链接,编译器并不知道函数的地址,所以暂时写全0,链接时再进行修正。

具体的修正方法和ELF格式有关。我们在代码中使用的各个变量名、函数名都有对应的变量和函数地址,这些对应关系都记录在符号表(symtab)中,它是ELF文件的一个Section。里面的对应关系是程序找到各符号对应函数和变量的关键。使用命令readelf -s 文件名 可以查看文件的symtab,如下图

最后一列就是变量或函数名,比较特殊的是puts对应printf,倒数第二列为UND表示该函数是未知的。当编译器进行链接时,会先合并所有的目标文件,然后在合并后的文件中查找这些标记为UND的未知函数,找到后修正函数地址,这就是静态链接 的过程,这个修正地址的操作叫做地址重定位。这也是为什么.o文件被称为可重定位目标文件。

这里存在一个问题,地址是加载到内存后才有的,可执行程序在加载到内存之前,为什么会有地址呢?答案是程序其实并不关心加载到内存后的物理地址,因为它使用的是虚拟地址。在编译器进行编译和链接时,会从地址0开始为代码和数据分配地址。可执行程序加载到内存时,操作系统会在页表中填写从虚拟地址到物理地址的映射。当程序修改变量或调用函数时就通过页表访问真实的物理地址。严格来说,存储在磁盘的可执行文件中的地址是逻辑地址,但它和虚拟地址区别并不大。

在下图的ELF Header中,中间的Entry point address是该程序的入口地址,当我们运行程序时,操作系统会将该地址加载到CPU的EIP寄存器中以开始运行程序,但这个地址是虚拟地址,CPU会先通过一些方式访问页表,将其转换成物理地址再进行寻址。事实上,CPU拿到的地址都是虚拟地址,都要通过页表进行转化。注意这个起始地址entry point address来自存储在磁盘中的ELF Header,这也表明了在程序还没有加载到内存时就有了地址。

动态链接

当我们运行程序时,操作系统会将程序的代码、数据和相关联的动态库加载到内存中。如果同时再运行其它程序,同一个动态库不会重复加载。此外,操作系统不会为动态库分配PCB,而是通过虚拟地址使其归属于各个进程(也就是程序)的一部分,所以动态库本身并不是进程 ,如下**。**

简单来说,操作系统通过虚拟地址让每个程序都认为动态库就在自己的进程地址空间的共享区中,但实际上动态库只有一份,当使用动态库的进程都退出后才将动态库占据的内存空间回收。

我们知道,当我们运行程序时,实际上并不是从main函数开始,而是从_start函数。它是由C运行时库或链接器提供的一个特殊函数。在_start中会进行多个初始化操作,其中就包括了调用动态链接器的代码来解析和加载程序依赖的动态库,动态链接器会处理所有的符号解析和重定位。没错,这就是动态链接,在程序的初始化阶段进行,此时的程序已经加载到内存。

动态库中的函数和全局变量的访问方式比较特殊。程序直到加载完成都不知道动态库中函数和全局变量的具体地址,要等到操作系统通过虚拟地址将动态库映射到程序地址空间中的共享区,程序才能得到具体地址。如果程序中有调用动态库中的函数,那么函数的地址肯定需要修正,而此时的调用函数的指令已经加载到代码段 ,它是不能修改的。

为此,程序加载时,动态链接器会在可读写的.data(上图中的数据区)中专门预留一块区域用来存放库函数的跳转地址,它被叫做全局偏移表(GOT),表中每一项都是程序要引用的一个全局变量或函数的地址。当动态库映射到共享区后会初始化GOT,这样通过修改GOT即可让程序找到动态库中的函数和全局变量。

GOT使得动态库可以被各程序共享。在程序调用外部函数时会先在GOT中查找,找到后根据表中的地址进行跳转。一这种方式实现的动态链接就被叫做**地址无关代码(PIC)。**但在不同程序的程序地址空间的共享区中,各动态库的绝对地址、相对位置都不同,因而每个程序的每个动态库都有独立的GOT表。绝对地址是指在虚拟地址空间中的一个确定的地址,如0x400500,相对地址则是指通过起点+偏移量的形式表示的地址。另外要注意的是动态库中也会调用其它的动态库,所以动态库中也有GOT。

相关推荐
小稻穗5 小时前
市场调研样本选型坐标系:2026六家国内样本平台能力横向校验
大数据·运维·数据分析
栩栩云生5 小时前
AI 写代码犯的错,早被写进了错题集
linux·安全·ai编程
AOwhisky5 小时前
Python 学习笔记(第十三期)——运维自动化(下·前篇):远程命令执行——paramiko基础篇
运维·python·学习·云原生·自动化·运维开发·paramiko
AOwhisky6 小时前
Python 学习笔记(第十四期)——运维自动化(下·中篇):远程文件传输——paramiko进阶篇
运维·python·学习·云原生·自动化·文件传输·paramiko
imc.116 小时前
linux基础IO
linux·运维·服务器
躺不平的理查德6 小时前
让Ubuntu 的第二块网卡 ens37 永久固定为 192.168.30.111
运维·服务器
bitbrowser7 小时前
Facebook 验证反复回到原页面,应该从哪里排查
运维·服务器·facebook
Land03297 小时前
AI网页元素变化无法自动修复?自带元素自愈的自动化解决方案
运维·人工智能·ai·自动化·rpa
fengyehongWorld7 小时前
Jenkins 安装与简单配置
运维·jenkins
云智慧AIOps社区7 小时前
2026 国产化 ITSM 替代指南:横向测评 ServiceNow、轻帆云、Jira等五款主流IT服务管理平台
运维·人工智能·运维开发·it服务管理·itsm平台