Linux 底层深入:目标文件、ELF 格式与程序加载全解析
🔥草莓熊Lotso:个人主页
❄️个人专栏: 《C++知识分享》《Linux 入门到实践:零基础也能懂》
✨生活是默默的坚持,毅力是永久的享受!
🎬 博主简介:
文章目录
前言:
在 Linux 开发中,我们每天都在和可执行程序打交道,但你是否好奇:gcc编译后生成的.o文件是什么?可执行程序为什么能直接运行?动态库又是如何被加载到内存中的?这些问题的答案,都藏在目标文件、ELF 格式和程序加载的底层逻辑里。本文从目标文件的本质入手,深入剖析 ELF 文件的结构、section 与 segment 的关系,最终讲透程序加载与进程虚拟地址空间的映射逻辑,帮你打通从源代码到运行程序的全链路认知。
一. 目标文件:编译后的 “半成品”
1.1 目标文件的本质
当我们用gcc -c编译 C/C++ 源代码时,编译器会将源码翻译成 CPU 能识别的机器码,但不会进行最终链接 —— 这个中间产物就是目标文件(后缀.o)。
- 核心作用:作为程序的 “半成品”,存储单个模块的代码和数据,等待链接器组合成可执行程序;
- 关键特性:修改单个源码文件后,只需重新编译对应的目标文件,无需全量编译,提升开发效率;
- 文件格式:Linux 下目标文件遵循
ELF 格式(Executable and Linkable Format),是二进制文件的标准化封装。
1.2 目标文件的生成与验证
实战示例:生成并查看目标文件
// hello.c#include<stdio.h>voidrun();// 声明外部函数intmain(){printf("hello world!\n");run();return0;}// code.c#include<stdio.h>voidrun(){printf("running...\n");}编译生成目标文件:
# 编译源码生成目标文件(-c:只编译不链接) gcc -c hello.c code.c # 查看生成的目标文件ls-l *.o # 验证文件类型(确认是ELF格式)file hello.o 
relocatable:表示该 ELF 文件是 “可重定位文件”(目标文件类型);
not stripped:表示文件保留了符号表等调试信息。

1.3 目标文件的核心问题:未解析的外部符号
目标文件是独立编译的,因此会存在 “不知道外部函数 / 变量地址” 的问题。例如:
hello.o中的printf(来自 C 标准库)和run(来自code.o),编译时无法确定其内存地址,编译器会暂时将跳转地址设为 0;- 这些未确定的地址,需要在链接阶段由链接器修正 —— 这就是 “重定位” 的核心目的。这个我们后面还会详细讲述的
二. ELF 文件:Linux 下的 “万能二进制格式”
ELF 是 Linux 系统中可执行程序、目标文件、动态库、核心转储文件(core dump)的统一格式标准。理解 ELF,就掌握了 Linux 二进制文件的 “通用语言”。
2.1 ELF 文件的四大类型
| ELF 文件类型 | 后缀 | 用途 | 示例 |
|---|---|---|---|
| 可重定位文件 | .o | 目标文件,用于链接生成可执行程序 / 动态库 | hello.o、code.o |
| 可执行文件 | 无(或 .out) | 可直接运行的程序 | /bin/ls、a.out |
| 共享目标文件 | .so | 动态库,运行时加载 | /lib64/libc.so.6 |
| 核心转储文件 | .core | 进程崩溃时的内存快照,用于调试 | core.12345 |
2.2 ELF 文件的核心结构
无论哪种 ELF 文件,都由四大核心部分组成,从文件开头到末尾依次排列:

2.2.1 ELF 头:文件的 “身份证”
- 核心作用:描述 ELF 文件的全局属性,定位其他部分的位置;
- 关键信息:
- 魔数(Magic):
7f 45 4c 46(ELF 文件的标识); - 文件类型(Type):可重定位 / 可执行 / 动态库等;
- 目标架构(Machine):x86-64、ARM 等;
- 入口点地址(Entry):可执行程序运行时的起始地址(目标文件为 0);
- 程序头表偏移(e_phoff)和节头表偏移(e_shoff):定位程序头表和节头表的位置。
- 魔数(Magic):
- 实战查看 ELF 头:
# 查看目标文件hello.o的ELF头 readelf -h hello.o # 查看可执行程序a.out的ELF头 gcc hello.o code.o -o a.out readelf -h a.out 
2.2.2 节(Section):文件的 “功能模块”
节是 ELF 文件的基本组成单位,按功能划分,核心节如下:
好的,已根据您提供的内容整理为表格。
| 节名称 | 类型 | 用途 |
|---|---|---|
.text | 代码节 | 存储可执行机器指令(程序核心逻辑) |
.data | 数据节 | 存储已初始化的全局变量和局部静态变量 |
.bss | 未初始化数据节 | 为未初始化的全局变量 / 静态变量预留空间(不占文件空间,加载时分配内存) |
.symtab | 符号表 | 存储函数名、变量名与地址的映射关系 |
.reloc | 重定位表 | 记录需要链接时修正的地址(如外部函数调用) |
.rodata | 只读数据节 | 存储字符串常量等只读数据(如 printf 的字符串) |
- 实战查看节信息
# 查看a.out的所有节 readelf -S a.out 2.2.3 节头表(Section Header Table):节的 “索引目录”
- 核心作用:存储每个节的描述信息,包括节的名称、类型、大小、在文件中的偏移量等;
- 链接器(如
ld):通过节头表找到各个节,进行合并、重定位等操作。

2.2.4 程序头表(Program Header Table):加载的 “指南”
- 核心作用:仅存在于可执行文件和动态库中,告诉操作系统如何将文件加载到内存,就像一个操作指南一样。
关键信息:描述 “段(Segment)” 的信息 —— 段是节的 “合并分组”(将属性相同的节合并,如只读可执行的.text 和.rodata 合并为一个段)。


2.3 关键概念:Section(节)与 Segment(段)的关系
很多初学者会混淆节和段,核心区别在于 “视角不同”:
- 节(Section):链接视角(用于编译链接)—— 粒度细,按功能划分(如代码、数据、符号表),方便链接器处理;
- 段(Segment):执行视角(用于加载运行)—— 粒度粗,按内存属性划分(如只读可执行、可读可写),方便操作系统加载和权限管理。
✅️ 为什么要合并节为段?减少内存碎片(减少空间浪费):例如.text(4097 字节)和.init(512 字节),分开加载需 3 个 4KB 内存页,合并后仅需 2 个;统一权限管理:相同属性的节合并后,操作系统可一次性设置权限(如所有只读节合并为一个只读段)。
实战查看段信息:
# 查看a.out的程序头表(段信息) readelf -l a.out 输出关键信息解读(主要是LOAD加载这个部分):
Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flags Align LOAD 0x0000000000000000 0x0000000000400000 0x0000000000400000 0x0000000000000744 0x0000000000000744 R E 200000 LOAD 0x0000000000000e10 0x0000000000600e10 0x0000000000600e10 0x0000000000000218 0x0000000000000220 RW 200000LOAD:表示该段需要加载到内存;R E:只读可执行(对应.text、.rodata 等节);RW:可读可写(对应.data、.bss 等节);VirtAddr:段加载到内存后的虚拟地址下图中的A应该是R我这里就不改了

‼️小补充:对于 程序头表 和 节头表 又有什么用呢,其实 ELF 文件提供 2 个不同的视图/视角来让我们理解这两个部分:链接视图 (Linking view) - 对应节头表 Section header table文件结构的粒度更细,将文件按功能模块的差异进行划分,静态链接分析的时候一般关注的是链接视图,能够理解 ELF 文件中包含的各个部分的信息。为了空间布局上的效率,将来在链接目标文件时,链接器会把很多节(section)合并,规整成可执行的段(segment)、可读写的段、只读段等。合并了后,空间利用率就高了,否则,很小的很小的一段,未来物理内存页浪费太大(物理内存页分配一般都是整数倍一块给你,比如
4k),所以,链接器趁着链接就把小块们都合并了。执行视图 (execution view) - 对应程序头表 Program header table告诉操作系统,如何加载可执行文件,完成进程内存的初始化。一个可执行程序的格式中,一定有 program header table。
说白了就是:一个在链接时作用,一个在运行加载时作用。

三. ELF 的生命周期:从源码到运行
ELF 文件的完整生命周期分为 “编译链接” 和 “加载运行” 两个阶段,每个阶段都有明确的核心操作:我们这里主要讲讲编译链接就可以了,运行可以继续往下看看虚拟地址空间先。
3.1 阶段 1:编译链接(生成可执行 ELF,研究静态链接)
无论是自己的 .o , 还是静态库中的 .o ,本质都是把.o文件进行连接的过程,所以:研究静态链接,本质就是研究 .o 是如何链接的,我们这里就不打包成静态库来研究了。
核心目标:将多个目标文件(.o)和库文件合并,修正未解析的符号地址,生成可执行 ELF。
关键步骤:
- 编译:gcc -c将源码(hello.c、code.c)翻译成目标文件(hello.o、code.o),每个目标文件包含独立的.text、.data 等节;
- 合并节:通过链接,链接器将所有目标文件的同名节合并(如所有.text 节合并为一个大的.text 节,.data 节同理);
- 符号解析与重定位:链接器通过符号表(.symtab)找到未解析的符号(如 hello.o 中的 run 函数),修正其地址(指向 code.o 中 run 函数的实际位置);
- 生成程序头表:根据合并后的节的属性,划分段(如只读可执行段、可读可写段),写入程序头表。
实战验证重定位效果:
# 反汇编目标文件hello.o,查看未重定位的call指令 objdump -d hello.o |grep callq # 反汇编可执行程序a.out,查看重定位后的call指令 objdump -d a.out |grep callq 输出对比:
- 目标文件 hello.o 中,call 指令地址为e8
00 00 00 00(地址未修正); - 可执行程序 a.out 中,call 指令地址为e8
dc fe ff ff(地址已修正为实际函数地址)。 下面的图示中讲解更加详细一点,大家可以仔细看看

补充一个查符号表的操作

静态链接就是把库中的.o进行合并,和上述过程一样,所以链接其实就是将编译之后的所有目标文件连同用到的一些静态库运行时库组合,拼装成一个独立的可执行文件。其中就包括我们之前提到的地址修正,当所有模块组合在一起之后,链接器会根据我们的.o文件或者静态库中的重定位表找到那些需要被重定位的函数全局变量,从而修正它们的地址。这其实就是静态链接的过程。


所以,链接过程中会涉及到对.o中外部符号进行地址重定位。
3.2 阶段 2:加载运行(可以暂时先不看,继续往下理解)
核心目标:操作系统根据 ELF 的程序头表,将文件加载到内存,创建进程并执行。
关键步骤:
- 创建进程:操作系统调用
fork创建新进程,分配进程控制块(task_struct)和虚拟地址空间; - 解析程序头表:读取 ELF 的程序头表,识别需要加载的段(LOAD 类型);
- 内存映射:通过
mmap系统调用,将 ELF 文件中的段映射到进程虚拟地址空间的对应区域(如只读可执行段映射到 0x400000 开始的地址); - 初始化内存:
- 为.bss 节分配内存并清零;
- 将.data 节的数据从文件复制到内存;
- 设置程序入口:将 CPU 的程序计数器(PC)指向 ELF 头中的入口点地址(Entry),程序开始执行。
- 注意:建议大家先往下看,这里我们可以暂时先不去理解,主要还是需要理解下面哪些图里面的一些逻辑过程。
四. 进程虚拟地址空间:ELF 的 “运行舞台”
4.1 虚拟地址的核心作用
现代操作系统都采用 “虚拟地址机制”,程序加载时使用的是虚拟地址,而非物理内存地址:
- 隔离进程:每个进程有独立的虚拟地址空间,互不干扰;
- 简化编程:程序编译时使用
“平坦地址空间”(从 0 开始的连续地址)(加载到内存之前在磁盘上我们系统叫它逻辑地址,加载到内存之后我们习惯叫虚拟地址(线性地址)),无需关心物理内存布局; - 高效利用内存:通过页表映射物理内存,支持内存共享(如动态库)和交换(Swap)。
- 大家可以仔细看下下面的图示解析,有点多但都很重要





4.2 核心总结和补充
- 统一格式:ELF 统一了目标文件、可执行程序、动态库等二进制文件的格式,让编译器、链接器、操作系统能无缝协作;
- 分离视角:通过节(链接视角)和段(执行视角)的分离,兼顾了编译链接的灵活性和加载运行的高效性;
- 虚拟地址抽象:ELF 编译时采用虚拟地址布局,屏蔽了物理内存的细节,让程序开发和加载更简单;
- 模块化支持:目标文件的设计支持模块化开发,单个模块修改后无需全量编译,提升开发效率。
EIP -> mmu -> 通过CR3拿到页表物理地址 -> 查表 -> 去内存中找到物理地址页表初始化,数据从哪里来?ELF解决了虚拟地址(逻辑地址,Linux下两个都行),加载使具有物理地址EIP怎么找到下一条地址,当前地址 + 当前指令长度 = 下一条指令地址虚拟地址空间 -> mm_struct <- 数据从哪里来?(ELF->section->segment)进程虚拟地址空间,不仅仅是需要OS支持,也需要CPU本身在硬件上支持,比如CR3 + MMU + 页表,也需要编译器在编译上支持,比如统一编址。
补充:如下图,结合最后的关键要点一起看

上图中关键要点:
结尾:
🍓 我是草莓熊 Lotso!若这篇技术干货帮你打通了学习中的卡点: 👀 【关注】跟我一起深耕技术领域,从基础到进阶,见证每一次成长 ❤️ 【点赞】让优质内容被更多人看见,让知识传递更有力量 ⭐ 【收藏】把核心知识点、实战技巧存好,需要时直接查、随时用 💬 【评论】分享你的经验或疑问(比如曾踩过的技术坑?),一起交流避坑 🗳️ 【投票】用你的选择助力社区内容方向,告诉大家哪个技术点最该重点拆解 技术之路难免有困惑,但同行的人会让前进更有方向~愿我们都能在自己专注的领域里,一步步靠近心中的技术目标! 结语:
✨把这些内容吃透超牛的!放松下吧✨ʕ˘ᴥ˘ʔづきらど