首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >当你说“运行程序”时,操作系统到底看到了什么?——ELF文件结构深度解析

当你说“运行程序”时,操作系统到底看到了什么?——ELF文件结构深度解析

作者头像
海棠蚀omo
发布2026-01-12 17:28:58
发布2026-01-12 17:28:58
5140
举报

我们在上一篇中详细讲了动静态库从制作到使用的各种细节,但是和动静态库相关联的不仅是上一篇的知识,还有动静态链接,而要说到动静态链接,就不得不了解一下ELF文件,所以我们今天就来好好聊聊ELF文件的各种细节。

一.何为ELF文件?

ELF其实是一种文件格式,正因为某些文件的文件格式是ELF格式,故叫做ELF文件,下面我们先来看看都有哪些文件属于ELF文件:

可重定位⽂件(Relocatable File) :即 xxx.o ⽂件。包含适合于与其他⽬标⽂件链接来创 建可执⾏⽂件或者共享⽬标⽂件的代码和数据。 可执⾏⽂件(Executable File) :即可执⾏程序。 共享⽬标⽂件(Shared Object File) :即 xxx.so⽂件。 内核转储(core dumps) ,存放当前进程的执⾏上下⽂,⽤于dump信号触发。

上面的四种文件都属于ELF文件,其中有我们比较熟悉的.o可重定位目标文件,可执行文件和我们曾经讲过的.so动态库文件。

拿我们在上一篇中生成的myexe文件为例,我们通过file命令来查看myexe的相关信息,可以看到在开头就有ELF的相关字眼,表明当前文件属于ELF文件。

那么在介绍了都有哪些文件属于ELF文件后,相信大家都比较好奇:既然ELF是一种文件格式,那么是一种怎样的文件格式呢?下面我们就来看看。

二.认识ELF格式

一个ELF文件由四部分构成,下面用图来演示:

这四个部分分别是:ELF Header(ELF头),Program Header Table(程序头表),Section(节),Section Header Table(节头表)

在讲解这四部分内容之前,我们先做一些准备工作:

代码语言:javascript
复制
#include<stdio.h>

void run();

int main() {
    printf("hello world!\n");
    run();
    return 0;
}

这是hello.c文件的代码。

代码语言:javascript
复制
#include <stdio.h>

void run() {
     printf("running...\n");
}

这是code.c文件的代码。

这里我们将这两个文件一起编为一个可执行文件。这种做法在linux中是可行的,虽然两个文件并没有包含关系,在hello.c中只有run函数的声明,code.c中有run函数的实现,但这两个文件是可以互补形成可执行文件的。

有了这个可执行文件后,准备工作就齐全了,下面我们来看看上面的四个部分都是何方神圣。

2.1ELF Header(ELF头)

要研究这个部分是干什么,我们既要看到这个部分的具体内容:

我们要查看ELF Header这部分的内容可以通过readelf -h + 文件名的命令,-h就表示Header,这里面属性还不少,我们挑一些来说。

就比如说:Class这个属性,从后面的ELF64就可以看出来这是来表示文件的类别的;然后下面的Data属性,前面的我们看不懂,但是后面的little endian想必都不会陌生,这就是小端序的意思,表明这是一个小端机器。

而剩下的我们聚焦于这一部分:

从名字我们就可以看出来这是在描述其他区域的信息,我们一个一个来介绍:

Start of program headers:这表示Program Header Table这个部分的开始位置,就是从第64个比特位置处开始的。

Start of sections headers:这部分表示Section这个部分是从哪里开始的。

Size of this header:这表示当前的ELF Header该部分的大小,正好对应着下面Program Header Table的起始位置。

Size of program headersNumber of program headers:这两个就表示Program Header Table这块部分单个程序头的大小和程序头的个数,这个下面将到这部分时我们再细讲。

Size of section headersNumber of section headers:这两个就表示Section这里面section头的大小和section的个数,从上面的整体结构图中我们可以看到,整个Section部分是有很多的section一起组成的。

在简单介绍了里面的内容之后,我们大概就知道了ELF Header这部分的作用,那就是:描述文件的主要特性。其位于文件的开始位置,它的主要目的是定位文件的其他部分

2.2Section(节)和Section Header Table(节头表)

下面我把这两个部分放在一块讲,我们首先来讲Section这部分:

Section这部分的作用我已经写在了上面,说白了就是Section这部分中的section1,section2等等这些节都是用来保存不同类型的数据的,包括我们写的代码和数据。

而它下面的Section Header Table就是来描述整个Section部分的,下面我们来看看它里面都有哪些内容:

只需要将上面查看ELF Header命令中的-h改为-S即可查看Section Header Table部分的内容,在这里面我们可以看到整个Section部分中有31个section,每个section部分都有自己对应的名字和其他的各种信息。

在这里面呢就有.text和.data这两部分,这两部分就是存储我们的代码和数据的,而它们后面的AX,WX就是里面的Flags属性,这个属性在上面我已经将其列举了出来。

A是alloc,就是需要开辟空间的意思,而X是execute,表示可执行的意思,W是Write,就是可写的意思,这些Flags准确描述了这些section的状态。

2.3Program Header Table(程序头表)

这个部分我们就需要好好聊聊了,这个部分是这里面最不好理解的,下面我直接先说这个部分的作用:Program Header Table中列举了所有有效的段(segments)和他们的属性。表⾥记着每个段的开始的位置和位移(offset)、⻓度,毕竟这些段,都是紧密的放在⼆进制⽂件中,需要段表的描述信息,才能把他们每个段分割开

相信看到它的作用大家都会对里面的段(segments)比较疑惑:上面提到了section(节),这段又是什么东西呢?

在解决这个问题之前我们要达成两个共识:

我们在之前就已经了解站在OS的角度,进行IO的时候,是以4kb为单位的进行访问的,而当它访问到Section部分,但是上面的一个个section节一定是4kb吗?

答案当然不是了,每个section大小都是不固定的,就拿我们自己写的代码为例,我们自己写的代码很容易就比4kb大吧,所有这里我们要达成第一个共识因为每个section的大小不固定,所以不能满足OS以4kb为单位访问的要求!!!

下面我们回头看,在介绍上面的Section Header Table部分时:

我们简单介绍了后面AX,WA等Flags属性,我们可以看出,虽然是不同的section,但也有相同的部分,那么我们就可以达成第二个共识:多个section,可能会有相同的属性,比如:可读,可写,可执行,需要加载是申请空间等!!!

而达成了上面的两个共识后,我们来思考一个问题:既然OS要以4kb为单位来进行访问,多个section又有相同的属性,那该怎么办呢?

不卖关子,答案就是把多个section进行合并,以4kb为单位进行对齐!!!

由多个section合并后的产物我们称为segment(段),也就是当我们的可执行文件加载到内存的时候,在OS的视角来看不再是一个个section了,而是合并后的一个个segment!!!

那么合并是在什么时候进行的呢?又是按照谁来进行的呢?

答案是在加载时进行的,按照Program Header Table进行的,最上面对Program Header Table的描述我们可能看不太明白,说的不是人话,下面我来告诉你:Program Header Table简单来讲一个将section合并为segment的方法表,加载时就是按照这个方法表将不同的section合并在一起的!!!

怎么证明呢?我们来看看Program Header Table中具体的内容:

通过readelf -l +文件名的命令我们就可以查看Program Header Table中的内容,可以看到我们整整31个section最后被合并为13个segment了,每个segment都有自己的类型,里面的LOAD我们都认识,这就是需要加载的。

而第二张图就揭示了每个segment都由哪些section所构成,可以看到我们熟悉的.text和.data,也就是代码和数据都在各自的segment中,并且他们所在的segment都是需要加载的。

之后我们看后面的Flags一列,感觉熟悉吗?

这表示的不就是权限嘛,R是读,W是写嘛,你猜进程被创建时,同时也创建了页表,在页表中有描述每个部分的权限的一栏,你猜页表是怎么知道每个部分的权限的

当然是根据上面Program Header Table中的每个segment得来的啊,不仅是权限,从上面可以看到每个segment还有自己的虚拟地址,这个在下面我会细讲,而后面的物理地址我们不必关心,它的内容和虚拟地址是一样的,并不是真的物理地址。

至此我们就完成了对ELF文件四个部分的了解,在这里面我们要注意的就是Program Header TableSection header table这两部分,对于这两个部分我们可以用两个视角来理解这两部分:

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

这个视图就是站在gcc编译器的视角看待ELF文件,整个Section部分里面就是一个个的section节。

执⾏视图(execution view) - 对应程序头表 Program header table

这个的视图就是 站在OS的视角看待ELF文件,整个Section部分就是一个个的segment段。

最后用一张图让大家对ELF的各个部分有更直观的感受。

三.形成ELF和加载问题

3.1静态链接

在有了对ELF文件的初步理解后,我们就来简单了解一下链接的过程,知道链接的过程到底是在干什么。

既然要了解链接的过程是在干什么,那我们就要知道在链接之前也就是都是.o文件时的状态,上面我们通过objdump -d + 文件名的命令来对.o文件进行反汇编,我们来查看里面的信息。

我们可以看到在hello.o和code.o这两个文件中,hello.c中调用了printf和run这两个函数,code.c中调用了printf函数,在反汇编中也可以看到在通过call指令来调用相应的函数,但是我们也发现虽然通过call指令来调用了,但是这两个函数的地址全都为0。

最前面的e8表示操作码,表示后面的call指令,所以我们只需要看e8后面的内容即可。

那为什么调用的函数的地址全部为0呢?

其实就是在编译 hello.c 的时候,编译器是完全不知道 printf 和 run 函数的存在的,⽐如它们位于内存的哪个区块,代码⻓什么样都是不知道的。因此,编译器只能将这两个函数的跳转地址先暂时设为0。

这里我们通过查看两个文件的节头表,可以看到在两个函数前都有UND的字眼,意思就是Undefined,表示这两个函数并没有被定义,puts表示的就是printf函数,它的底层是由puts函数所实现的。

链接前的状态我们看到了,那么下面我们就来看看链接后的状态:

通过相同的命令我们来查看myexe可执行文件中的内容,我们可以发现main函数和run函数同时出现在了myexe文件中,而在链接之前他们分别在不同的.o文件中,这说明什么呢?

说明通过链接将这些.o文件完成了合并!!!

只有将这些.o文件中的内容进行了合并,run函数和main函数才能出现在同一个文件中啊,并且我们看到run函数和main函数此时也都有了自己的地址。

同时也可以发现main函数中调用的printf函数和run函数此时都有了相应的地址,run函数中调用的printf函数也有了地址,main函数中调用的run函数的地址正好对应着上面的run函数的地址。

原本调用printf函数和run函数的地址由0变为了具体的地址,这叫什么啊?

这不正是地址重定位吗?把要调用的函数地址,从0重定位到最终目标函数的地址,所以你的程序,链接静态库和.o文件,链接就是在做地址重定位!!!

所以为什么.o文件被叫做可重定位目标文件啊?

正是因为链接的时候,那些.o文件中的调用的函数地址进行了地址重定位,它的可重定位正因此而来。

而有了上面的信息后,我们来总结一下链接的具体作用:

1.将多个.o文件的合并在一起,并进行了统一的编址(在.o文件中run函数和main函数的地址都为0)

2.修改.o文件中没有确定的函数地址,也就是进行地址重定位,进行相关call地址,进而完成代码调用

所以链接其实就是将编译之后的所有⽬标⽂件连同⽤到的⼀些静态库运⾏时库组合,拼装成⼀个独⽴的可执⾏⽂件。其中就包括我们之前提到的地址重定位,当所有模块组合在⼀起之后,链接器会根据我们的.o⽂件或者静态库中的重定位表找到那些需要被重定位的函数全局变量,从⽽修正它们的地址。这其实就是静态链接的过程。

而动态链接则更为复杂,今天这一篇我们讲不到,我们下一篇就会讲。

3.2链接过程的扩展知识

一个问题引出这部分的内容:一个ELF可执行程序,在没有加载到内存的时候,有没有地址呢?是什么地址?

从myexe的反汇编中我们就可以看出来已经有地址了,linux系统编译形成可执行程序的时候需要对代码和数据进行编址。

而当代的CPU,计算机和操作系统,对ELF编制的时候,采用的做法都是“ 平坦模式 ”进行编址!!!

何为“ 平坦模式 ”呢?

就是对地址空间中的地址按照线性地址进行统一编址,线性地址宏观上意味着地址空间是从低地址到高地址依次增大的一个连续序列。

编址的范围就是从000000....000到ffffffff....fffff也就是从全0到全f

我们从上面的图中可以看出确实从上到下地址是依次增大的,而一个函数包含多个地址,也就是说 函数的本质其实就是相邻地址的集合!!!

那么这些地址是什么地址呢?

就是我们之前提到过的虚拟地址,没错,在这里也是有虚拟地址空间的概念的,不过这种地址还有一个名字,叫做逻辑地址。

逻辑地址就是起始地址+偏移量的一种地址,而磁盘的可执行文件的起始地址为0,所以在整体表现上和虚拟地址是一样的。

虚拟地址和逻辑地址 它们是从不同视角看待的、但几乎等同的同一个东西。

3.3ELF和虚拟地址空间之间的关系

用一张图来概括我们要讲的内容,下面我将这部分的内容分为了三部分,分别用三种不同颜色给圈画起来了。那么废话不多说,我们接着来看。

第一部分:

当myexe可执行文件加载到内存中形成进程的时候,操作系统就要给里面的每条指令分配对应的物理地址,上面我就随便列举了几个物理地址。

那么我们大家一个问题: 形成进程的同时也会形成页表,页表中存的就是虚拟地址与物理地址之间的映射,那么页表是怎么知道这二者之间的映射关系呢?

从上图我们就可以得到答案了,可执行文件在加载到内存之前就已经有了虚拟地址,而在加载到内存后,又有了相对应的物理地址,那虚拟地址和物理地址之间的映射关系不就有了吗?

操作系统只需要将这份关系写到页表中,页表中自然就有了虚拟地址和物理地址之前的映射关系。

第二部分:

这里问大家一个问题: cpu是怎么知道从哪里开始是属于可执行程序的地址的?

正是 通过ELF Header中的Entry point address这个属性,也就是可执行程序的入口地址得知的。我们再次查看myexe的反汇编,可以看到这个地址对应的是_start,这个_start才是用户态程序的真正入口,并不是我们以为的main函数

那么此时回答我们最上面的问题:

操作系统会将这个可执行程序的入口地址交给CPU中的EIP寄存器,之后位于CR3中的MMU自动管理单元会拿着这个虚拟地址找到页表,查询所对应的物理地址,之后拿着这个物理地址去访问物理内存,然后CPU就会根据物理地址去读取相应的指令内容,进而去执行。

也就是从CPU中进入的是虚拟地址,出去的是物理地址,不只是入口地址,后面每条指令的执行都是通过上面的方式。

第三部分:

最后的第三部分,也是最后一个问题: OS中各个区域的虚拟地址空间范围的初始值是从哪里来的?也就是虚拟地址空间中的代码段,数据段,栈,堆等等区域,它们空间范围的初始值只从哪儿来的?

其实这个这个问题在上面介绍Program Header Table时答案就已经出来了:

这里面不是已经帮我们分好了吗?

需要加载的不就是我们的代码和数据吗?也就是虚拟地址空间中的代码段和数据段。

这不正是虚拟地址空间中的栈区吗?

所以上面问题的答案就是在操作系统为进程创建的虚拟地址空间中,各个区域的初始布局、位置和属性,正是根据链接器合并后生成的 程序头表中的段(segment) 来设定的。

以上就是当你说“运行程序”时,操作系统到底看到了什么?——ELF文件结构深度解析的全部内容。

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2025-11-16,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一.何为ELF文件?
  • 二.认识ELF格式
    • 2.1ELF Header(ELF头)
    • 2.2Section(节)和Section Header Table(节头表)
    • 2.3Program Header Table(程序头表)
  • 三.形成ELF和加载问题
    • 3.1静态链接
    • 3.2链接过程的扩展知识
    • 3.3ELF和虚拟地址空间之间的关系
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档