🌟 各位看官好,我是!****
🌍 Linux == Linux is not Unix !
🚀 今天来学习Linux的指ELF格式及重新理解进程虚拟地址空间。
👍 如果觉得这篇文章有帮助,欢迎您一键三连,分享给更多人哦!
目录
[程序头表(Program head table)](#程序头表(Program head table))
[节头表(Section header table)](#节头表(Section header table))
ELF文件
要理解编译链链接的细节,我们不得不了解⼀下ELF⽂件。其实有以下四种⽂件其实都是ELF⽂件:
- 可重定位⽂件(Relocatable File) :即 xxx.o ⽂件。包含适合于与其他⽬标⽂件链接来创建可执行文件或者共享⽬标⽂件的代码和数据。
- 可执行⽂件(Executable File) :即可执行程序。
- 共享⽬标⽂件(Shared Object File) :即 xxx.so⽂件。
- 内核转储(core dumps) ,存放当前进程的执行上下文,用于dump信号触发。
我们的可执行程序也是ELF格式,我咋不知道呢 ?
file 用于辨识文件类型


认识ELF格式
文件 = 属性 + 内容 --> ELF是将可执行程序的内容分为四部分
ELF的格式如图所示,分为4部分讲解:

ELF头
ELF 头 (ELF header) :描述⽂件的主要特性。其位于⽂件的开始位置,它的主要⽬的是定位文件的其他部分。
查看目标文件 : readelf -h myexe
问题1:如何理解ELF Header?
编译器和操作系统,都要认识ELF Header!(因为程序要加载到内存中,这就要求OS要认识EH)
问题2:如何看待文件位置?
磁盘文件,就是一个一维数组,无论是二进制文件还是文本文件!
问题3:一个文件记录着Entry point address,这是做什么用的呢?这里留个疑问
程序头表(Program head table)
程序头表 (Program header table) :列举了所有有效的段(segments)和他们的属性。表里记着每个段的开始的位置和位移(offset)、⻓度,毕竟这些段,都是紧密的放在⼆进制⽂件中,需要段表的描述信息,才能把他们每个段分割开。
合并成为segment的方法表(合并在加载的时候进行),按照PHT进行.
查看section合并的segment : readelf -l myexe

节头表(Section header table)
节头表 (Section header table) :包含对节(sections)的描述。
查看可执行程序的section : readelf -S a.out
节(Section)
节( Section ):ELF⽂件中的基本组成单位,包含了特定类型的数据。ELF⽂件的各种信息和
数据都存储在不同的节中,如代码节存储了可执⾏代码,数据节存储了全局变量和静态数据等。
最常见的节:
- 代码节(.text):用于保存机器指令,是程序的主要执行部分。
- 数据节(.data):保存已初始化的全局变量和局部静态变量。
形成ELF和加载问题(虚拟地址空间)
ELF形成可执行
- 将多份 C/C++ 源代码,翻译成为⽬标 .o ⽂件
- 将多份 .o ⽂件section进⾏合并
注意:实际合并是在链接时进⾏的,但是并不是这么简单的合并,也会涉及对库合并,此处不做过多追究

ELF可执行文件加载
- ⼀个ELF会有多种不同的Section,在加载到内存的时候,也会进⾏Section合并,形成segment
- 合并原则:相同属性,⽐如:可读,可写,可执⾏,需要加载时申请空间等.
- 这样,即便是不同的Section,在加载到内存中,可能会以segment的形式,加载到⼀起
- 很显然,这个合并⼯作也已经在形成ELF的时候,合并⽅式已经确定了,具体合并原则被记录在了ELF的 程序头表(Program header table) 中
查看可执行程序的section : readelf -S myexe > myexe.S
查看section合并的segment : readelf -l myexe > myexe.txt
为什么要将section合并成为segment ?
- Section合并的主要原因是为了减少页面碎片,提高内存使⽤效率。如果不进⾏合并,假设页面大小为4096字节(内存块基本大小,加载,管理的基本单位),如果.text部分为4097字节,.init部分为512字节,那么它们将占⽤3个⻚⾯,而合并后,它们只需2个页面。
- 此外,操作系统在加载程序时,会将具有相同属性的section合并成⼀个⼤的segment,这样就可以实现不同的访问权限,从⽽优化内存管理和权限访问控制。
对于 程序头表和节头表又有什么用呢,其实 ELF文件 提供 2 个不同的视图/视⻆来让我们理解这
两个部分:
- 链接视图(Linking view) - 对应节头表 Section header table
- ⽂件结构的粒度更细,将⽂件按功能模块的差异进⾏划分,静态链接分析的时候⼀般关注的是链接视图,能够理解 ELF ⽂件中包含的各个部分的信息。
- 为了空间布局上的效率,将来在链接⽬标⽂件时,链接器会把很多节(section)合并,规整成可执⾏的段(segment)、可读写的段、只读段等。合并了后,空间利⽤率就高了,否则,很⼩的很⼩的⼀段,未来物理内存页浪费太大(物理内存⻚分配⼀般都是整数倍⼀块给你,比如4k),所以,链接器趁着链接就把小块们都合并了。
- 执⾏视图(execution view) - 对应程序头表 Program header table
- 告诉操作系统,如何加载可执⾏⽂件,完成进程内存的初始化。⼀个可执⾏程序的格式中,⼀定有 program header table 。
- 说白了就是:⼀个在链接时作⽤,⼀个在运⾏加载时作⽤。
从 链接视图 来看:
- 命令 readelf -S hello.o 可以帮助查看ELF⽂件的 节头表。
- .text节 :是保存了程序代码指令的代码节。
- .data节 :保存了初始化的全局变量和局部静态变量等数据。
- .rodata节 :保存了只读的数据,如⼀⾏C语⾔代码中的字符串。由于.rodata节是只读的,所
- 以只能存在于⼀个可执⾏⽂件的只读段中。因此,只能是在text段(不是data段)中找到.rodata节。
- .BSS节 :为未初始化的全局变量和局部静态变量预留位置
- .symtab节 : Symbol Table 符号表,就是源码⾥⾯那些函数名、变量名和代码的对应关系。
- .got.plt节 (全局偏移表-过程链接表):.got节保存了全局偏移表。.got节和.plt节⼀起提供了对导⼊的共享库函数的访问⼊⼝,由动态链接器在运⾏时进⾏修改。对于GOT的理解,我们后⾯会说。
使⽤ readelf 命令查看 .so ⽂件可以看到该节。
从 执行视图 来看:
- 告诉操作系统哪些模块可以被加载进内存。
- 加载进内存之后哪些分段是可读可写,哪些分段是只读,哪些分段是可执行的。
我们可以在 ELF 头 中找到文件的基本信息,以及可以看到ELF头是如何定位程序头表和节头表的。
静态链接
目标文件
编译和链接这两个步骤,在Windows下被我们的IDE封装的很完美,我们⼀般都是⼀键构建⾮常⽅便,但⼀旦遇到错误的时候呢,尤其是链接相关的错误,很多⼈就束⼿⽆策了。在Linux下,我们之前也学习过如何通过gcc编译器来完成这⼀系列操作。
接下来我们深⼊探讨⼀下编译和链接的整个过程,来更好的理解动静态库的使⽤原理。
先来回顾下什么是编译呢?编译的过程其实就是将我们程序的源代码翻译成CPU能够直接运⾏的机器代码。
⽐如:在⼀个源⽂件 hello.c ⾥便简单输出"hello world!",并且调⽤⼀个run函数,⽽这个函数被
定义在另⼀个原⽂件 code.c 中。这⾥我们就可以调⽤ gcc -c 来分别编译这两个原⽂件。
bash
// hello.c
#include<stdio.h>
void run();
int main()
{
printf("hello world!\n");
run();
return 0;
}
// code.c
#include<stdio.h>
void run()
{
printf("running...\n");
}
可以看到,在编译之后会⽣成两个扩展名为 .o 的⽂件,它们被称作⽬标⽂件。要注意的是如果我们修改了⼀个原⽂件,那么只需要单独编译它这⼀个,⽽不需要浪费时间重新编译整个工程。⽬标文件是⼀个二进制的文件,文件的格式是 ELF ,是对⼆进制代码的⼀种封装。
- ⽆论是自己的.o, 还是静态库中的.o,本质都是把.o文件进行连接的过程
- 所以:研究静态链接,本质就是研究.o是如何链接的
objdump -d 命令:将代码段(.text)进行反汇编查看
hello.o中的 main 函数不认识 printf 和run 函数
code.o 不认识 printf 函数
我们可以看到这⾥的call指令,它们分别对应之前调⽤的printf和run函数,但是你会发现他们的跳转地址都被设成了0。那这是为什么呢?
其实就是在编译 hello.c 的时候,编译器是完全不知道 printf 和 run 函数的存在的,⽐如他们
位于内存的哪个区块,代码长什么样都是不知道的。因此,编辑器只能将这两个函数的跳转地址先暂时设为0。
这个地址会在哪个时候被修正?链接的时候!为了让链接器将来在链接时能够正确定位到这些被修正的地址,在代码块(.data)中还存在⼀个重定位表,这张表将来在链接的时候,就会根据表里记录的地址将其修正。
由于 printf 涉及到动态库,就暂不做说明
整个过程
多个.o彼此不知道对方

读取.o文件的符号表
puts:就是printf的实现
UND就是:undefine,表⽰未定义说白了就是本.o文件找不到
在hello.o中:run在我们自己的方法在hello.o未定义.
在code.o中:定义了run函数,自然可以确定.

读取可执行程序的符号表:
两个 .o 进⾏合并之后,在最终的可执⾏程序中,就找到了 run
0000000000001149 :其实是地址,后⾯说
FUNC: 表⽰ run 符号类型是个函数
16 :就是 run 函数所在的 section 被合并最终的那⼀个 section 中了 ,16 就是下标

读取可执行程序最终的所有的 section 清单:
hello.o 和 code.o 的 .text 被合并了,是 main.exe 的第 16 个 section

如何证明上述说法呢?
关于 hello.o 或者 code.o call 后⾯的 00 00 00 00 有没有被修改成为具体的最终函数地址呢?
最终:
- 两个 .o 的代码段合并到了⼀起,并进⾏了统⼀的编址
- 链接的时候,会修改 .o 中没有确定的函数地址,在合并完成之后,进⾏相关 call 地址,完成代码调用
静态链接就是把库中的.o进⾏合并,和上述过程⼀样
所以链接其实就是将编译之后的所有⽬标⽂件连同⽤到的⼀些静态库运⾏时库组合,拼装成⼀个独⽴的可执⾏⽂件。其中就包括我们之前提到的地址修正,当所有模块组合在⼀起之后,链接器会根据我们的.o⽂件或者静态库中的重定位表找到那些需要被重定位的函数全局变量,从⽽修正它们的地址。这其实就是静态链接的过程。
所以,链接过程中会涉及到对.o中外部符号进行地址重定位。
虚拟地址
一个ELF可执行程序,在没有加载到内存的时候,有没有地址呢? --> 有
为什么?是什么地址?
那得了解是如何形成地址的:
最左侧的就是ELF的虚拟地址,其实,严格意义上应该叫做逻辑地址(起始地址+偏移量), 但是我们认为起始地址是0.也就是说,其实虚拟地址在我们的程序还没有加载到内存的时候,就已经把可执行程序进行统⼀编址了.因此
Linux系统里面 +平坦模式:
逻辑地址 和 虚拟地址(线性地址) 是 一个硬币的两面
- ELF文件中称逻辑地址
- 内存中称虚拟地址
进程mm_struct、vm_area_struct在进程刚刚创建的时候,初始化数据从哪⾥来的?
进程mm_struct、vm_area_struct在进程刚刚创建的时候,初始化数据从哪⾥来的?从ELF各个
segment来,每个segment有自己的起始地址和自己的长度,用来初始化内核结构中的[start, end]
等范围数据,另外在⽤详细地址,填充页表.
重新理解进程虚拟地址空间
ELF 在被编译好之后,会把自己未来程序的⼊⼝地址记录在ELF header的Entry字段中:

所以: 虚拟地址空间技术,不光光需要OS支持,编译器、CPU硬件也要支持!!!
总结
本文深入探讨了Linux中ELF文件格式及进程虚拟地址空间的核心概念。主要内容包括:
- ELF文件结构解析:详细介绍了ELF头、程序头表、节头表及各节区的作用,通过readelf工具演示查看方法;
- ELF加载机制:阐述section合并为segment的原理,解释链接视图与执行视图的区别,分析静态链接时符号重定位过程;
- 虚拟地址空间:揭示程序未加载时已有逻辑地址,说明ELF入口地址与进程初始化的关系,强调编译器、OS和CPU协同支持虚拟内存的技术本质。文章通过具体示例和命令输出,系统性地梳理了从源代码到可执行文件的内存映射全过程。
