当前位置: 首页 > news >正文

Linux 可执行程序核心知识笔记:ELF、加载、虚拟地址与动态库

在 Linux 系统中,可执行程序的运行涉及诸多底层机制,掌握 ELF 格式、程序加载过程、虚拟地址空间及动态库加载等知识,能帮助我们深入理解程序的运行原理。

一、ELF 格式:可执行文件的 “骨架”

ELF(Executable and Linkable Format)是 Linux 下主流的二进制文件格式,涵盖可执行程序、目标文件(.o)、共享库(.so)等。它规定了二进制数据的存储方式,是操作系统和编译器之间的 “约定说明书”。

1. ELF 的 3 种主要类型

  • 可执行文件:如 /bin/ls,包含可直接执行的代码。
  • 目标文件:如 test.o,是编译后的中间文件,需经链接器合并为可执行文件。
  • 共享库:如 libc.so,可被多个程序共享使用。

2. ELF 的核心结构

ELF 文件头:位于文件起始位置,包含文件类型、机器架构、入口地址、程序头表和节区表偏移量等关键信息,是操作系统加载程序时首先读取的部分。

查看 ELF 文件头(核心元信息)

选一个可执行程序(如系统 ls )which ls  # 输出:/usr/bin/ls(ELF 可执行文件)readelf -h /usr/bin/ls  

参数解释:

1. Magic(魔术数)

  • 7f 45 4c 46 ...7f 开头,后接 ELF ASCII 码:45=E4c=L46=F )

  • 作用:标识文件是 ELF 格式。操作系统加载程序时,先检查魔术数,确认是 ELF 才继续加载。

2. Class(文件类型)

  • ELF64

  • 作用:表示这是 64 位 ELF 文件(对应 64 位 CPU 架构,如 x86-64 )。若为 ELF32 则是 32 位文件。

3. Data(数据字节序)

  • 2's complement, little endian(补码,小端序)

  • 作用:说明文件中数据的存储方式是小端序(低字节存低地址,如 0x1234 存为 34 12 ),与 x86-64 架构的字节序一致。

4. Version(ELF 版本)

  • 1 (current)

  • 作用:ELF 标准版本(当前为版本 1 ),保证兼容性。

5. OS/ABI、ABI Version(操作系统 / ABI 信息)

  • UNIX - System V0

  • 作用:说明该 ELF 文件遵循的 ABI(应用二进制接口)是 UNIX System V 标准,ABI 版本为 0。用于操作系统判断文件是否兼容。

6. Type(文件类型)

  • DYN (Position-Independent Executable file)

  • 作用:表示这是位置无关可执行文件(PIE)

    • 普通可执行文件类型是 EXEC,但现代系统为了安全(地址随机化),默认编译为 DYN(PIE),加载时地址会随机化(与笔记中 “实践 2 关闭地址随机化” 对应,若关闭则为 EXEC )。

7. Machine(机器架构)

  • Advanced Micro Devices X86-64

  • 作用:说明该文件只能在 x86-64 架构的 CPU 上运行(如 Intel/AMD 的 64 位处理器)。

8. Entry point address(程序入口地址)

  • 0x6aa0

  • 作用:程序加载到内存后,CPU 第一条指令执行的虚拟地址。对应笔记中 “程序加载后从该地址开始执行”。

9. Start of program headers /section headers(程序头表 / 节区表偏移)

  • 64(程序头表在文件中的偏移)、136232(节区表在文件中的偏移)

  • 作用:告诉操作系统 “程序头表、节区表在 ELF 文件中的位置”,加载时按偏移读取。

10. Size of this header(文件头大小)

  • 64 (bytes)

  • 作用:ELF 文件头本身的大小(固定 64 字节 for ELF64 )。

11. Number of program headers /section headers(表头数量)

  • 13(程序头表项数量)、31(节区表项数量)

  • 作用:程序头表有 13 个段描述(如 LOAD 段、INTERP 段),节区表有 31 个节区描述(如 .text.data )。

关键结论:文件头是 ELF 的 “身份证”,记录程序类型、入口地址、架构等核心信息。

程序头表:为操作系统加载器服务,描述如何将 ELF 文件中的数据映射到内存。其中,LOAD 类型的段最为重要,包含代码段(权限 R-X)和数据段(权限 RW-)等可加载到内存的段;INTERP 段指定动态链接器路径,仅动态链接的程序有此段。

查看程序头表(加载时的段信息)


readelf -l /usr/bin/ls  

关键结论:程序头表是 “加载路线图”,指导操作系统如何把 ELF 段映射到虚拟地址。

节区表:供编译器和链接器使用,描述文件中具体的代码、数据等节区。常见节区有 .text(存放机器指令)、.data(存放已初始化的全局 / 静态变量)、.bss(存放未初始化的全局 / 静态变量)、.symtab(符号表)、.plt/.got(动态链接相关)等。

查看节区表(编译时的节区划分)

readelf -S /usr/bin/ls  

关键结论:节区表是 “编译零件清单”,记录代码、数据、符号等节区的位置和权限。

3. 段与节区的区别

  • :面向加载执行,是内存中的概念,由操作系统处理,一个段可包含多个节区。
  • 节区:面向编译链接,是文件中的概念,由编译器处理,更细粒度地划分数据类型。

4.关闭地址随机化(-no-pie)的影响

当使用gcc -g -no-pie test.c -o test编译程序时,-no-pie选项关闭了地址随机化功能。这会带来以下影响:

  • 编译时确定虚拟地址:编译器在编译过程中,根据链接脚本和相关规则,直接为程序中的各个符号(函数、变量等)确定虚拟地址。例如,main函数被编译到虚拟地址0x400526global_var变量被编译到0x601034,这些地址是固定的,记录在 ELF 文件的符号表等相关结构中。

  • 加载时直接映射:在程序加载阶段,操作系统的加载器会根据 ELF 文件的程序头表信息,将文件中的各个段(如代码段、数据段)按照编译时确定的虚拟地址直接映射到内存中。由于关闭了地址随机化,加载到内存后的虚拟地址与编译时确定的虚拟地址是一致的,所以在使用gdb进行调试时,通过info address获取到的编译时虚拟地址和程序运行时打印出的虚拟地址是相同的。

5.实践 :编译调试,看程序加载前后地址(理解加载)

目标:用 GDB 调试自定义程序,验证 “虚拟地址在编译时确定,加载时直接映射”。

1. 编写测试代码(test.c

#include <stdio.h>
#include <stdlib.h>int global_var = 10;  // .data 节区(已初始化)
int bss_var;          // .bss 节区(未初始化)int main() {int stack_var = 20;       // 栈(局部变量)int *heap_var = malloc(4); // 堆(动态分配)*heap_var = 30;printf("虚拟地址分布:\n""代码段(main):%p\n"".data(global_var):%p\n"".bss(bss_var):%p\n""栈(stack_var):%p\n""堆(heap_var):%p\n",main, &global_var, &bss_var, &stack_var, heap_var);free(heap_var);return 0;
}

2. 编译程序(关闭地址随机化)

gcc -g -no-pie test.c -o test  # -no-pie:关闭地址随机化

3. 用 GDB 调试,对比加载前后地址

gdb ./test  

对比可发现程序运行前后地址一致。

关键结论:虚拟地址在编译时已确定,加载时直接映射到内存,地址不变(因关闭了地址随机化)。

二、可执行程序的加载过程:从磁盘到运行

当在终端输入 ./a.out 运行程序时,Linux 系统通过 execve 系统调用完成程序加载,具体过程如下:

壳程序准备:当前 Shell 进程 fork 一个子进程,子进程调用 execve 系统调用请求加载新程序。

内核验证文件合法性:检查文件是否为 ELF 格式(通过魔术数 7f 45 4c 46 验证)、是否有执行权限以及架构是否匹配。

创建新的虚拟地址空间:内核销毁子进程原有的虚拟地址空间,为新程序创建全新的虚拟地址空间。

映射段到虚拟地址:内核根据 ELF 程序头表,将 LOAD 类型的段映射到虚拟地址空间,此时不直接分配物理内存,采用按需分配机制。

处理动态链接:静态链接的程序直接跳转到 ELF 头指定的入口地址执行;动态链接的程序入口地址指向动态链接器,由其完成库加载后再跳转到程序真正入口。

切换到用户态执行:内核完成加载后,将 CPU 从内核态切换到用户态,程序开始运行。

三、虚拟地址空间:进程的 “私有内存世界”

虚拟地址空间是进程独有的 “内存视角”,通过 CPU 的 MMU 映射到实际物理内存,具有独立性、连续性和保护机制等特点。

1. 虚拟地址空间布局(64 位 Linux)

  • 用户空间(0~0x7fffffffffff):进程私有,包含程序运行所需数据。
  • 内核空间(0xffff800000000000~):所有进程共享,存放内核代码和数据。

2. 用户空间关键区域(从低到高)

  • 代码段(.text):存放机器指令,只读、可执行,地址固定。
  • 数据段(.data + .bss):存放全局变量和静态变量,可读可写,位于代码段上方。
  • 堆(Heap):动态内存分配区域,从低地址向高地址增长。
  • 共享库区域:加载动态库的地方,地址随机化以提高安全性。
  • 栈(Stack):存放函数调用的参数、局部变量、返回地址等,从高地址向低地址增长。
  • 环境变量与命令行参数:位于栈的最顶端。

3. 虚拟地址空间的作用

  • 安全性:进程无法直接访问物理内存,防止恶意程序篡改其他进程数据。
  • 内存利用率:通过按需分配和换页机制,提高物理内存利用率。
  • 简化编程:程序员无需关心物理内存布局,使用连续的虚拟地址即可。

四、动态库加载过程:共享代码的 “按需调用”

动态库(.so)在程序运行时加载,多个程序可共享,能节省内存和磁盘空间。其加载过程由动态链接器主导,具体如下:

启动动态链接器:内核加载完程序段后,若程序依赖动态库,会先运行动态链接器(通过程序头表的 INTERP 段找到路径)。

查找并加载依赖的动态库:动态链接器读取程序的 .dynamic 节区,按 LD_LIBRARY_PATH 环境变量、/etc/ld.so.cache 缓存、默认路径(/lib、/usr/lib 等)查找库,并将其映射到当前进程的虚拟地址空间。

符号解析与重定位:动态链接器找到程序引用的符号(如函数)并确定其实际地址,更新程序中引用这些符号的指令。采用延迟绑定机制,函数第一次被调用时才进行解析和重定位,依赖 .plt(每个动态函数对应一个条目,存放跳转到 .got 的指令)和 .got(存放函数实际地址)实现。

移交控制权给程序:完成库加载和符号解析后,动态链接器将程序计数器指向程序真正的入口地址,程序开始执行。

动态库的优点

  • 节省内存:多个程序共享同一库的物理内存。
  • 便于更新:更新库文件后,依赖它的程序无需重新编译。
  • 减小可执行文件体积:无需将库代码打包到程序中。

实践:验证 “延迟绑定” 的关键步骤和现象

“延迟绑定”(Lazy Binding)是动态链接的核心优化机制,指的是动态库中的函数地址在 “第一次被调用时才会解析”,而不是程序启动时就一次性解析所有函数,这样能加快程序启动速度。

先明确:什么是 “延迟绑定”?

动态库的函数(如add)在编译时并不知道最终加载到内存的地址,因此程序中调用add的指令无法直接指向真实地址。

  • 程序启动时,动态链接器(ld-linux-x86-64.so.2)只会做最基本的初始化,不会立即解析add的地址。
  • add第一次被调用时,动态链接器才会解析其真实地址并记录下来,后续调用直接使用已解析的地址(不再重复解析)。

以编写的main.c调用动态库libmylib.so中的add函数为例:

// main.c
int main() {int result = add(3, 5);  // 第一次调用addprintf("3 + 5 = %d\n", result);return 0;
}

步骤 1:用 GDB 在main函数开头打断点,此时add未被调用
gdb ./main
(gdb) b main  # 断点设在main函数入口
(gdb) run     # 运行程序,停在main函数第一行(尚未调用add)
步骤 2:查看add函数的 “初始地址”(未解析时)

main函数中,还未执行add(3,5)时,用p &add查看add的地址:

(gdb) p &add
$1 = (int (*)(int, int)) 0x400520  # 示例地址,实际可能不同

这个地址不是add在动态库中的真实地址,而是指向程序中的PLT(过程链接表)桩代码add@plt)。PLT 是延迟绑定的关键结构,负责在第一次调用时触发动态链接器解析真实地址。

步骤 3:单步执行到add调用前,反汇编查看调用指令

disassemble main查看main函数的汇编代码,找到调用add的指令:

(gdb) disassemble main
Dump of assembler code for function main:0x00000000004005a6 <+0>:     push   %rbp...0x00000000004005c8 <+34>:    callq  0x400520 <add@plt>  # 调用add,目标是PLT桩代码...

这里的callq 0x400520 <add@plt>证明:程序调用add时,先跳转到add@plt(PLT 中的桩代码),而不是直接跳转到动态库中的真实地址 —— 这是延迟绑定的 “前奏”。

步骤 4:第一次调用add时,触发动态链接器解析

step(缩写s)单步执行callq指令,会进入add@plt的桩代码,最终触发动态链接器的_dl_runtime_resolve函数(负责解析真实地址):

(gdb) s  # 单步执行callq指令,进入PLT桩代码
0x0000000000400520 in add@plt ()
(gdb) s  # 继续单步,会进入动态链接器的解析过程(可能显示在_dl_runtime_resolve中)

此时动态链接器会:

  1. 查找libmylib.soadd的真实地址(比如0x7ffff7fbe610);
  2. 将真实地址写入GOT(全局偏移表) 中(GOT 是存储已解析地址的表格);
  3. 跳转到add的真实地址执行。
步骤 5:第二次调用add(若有),直接使用已解析的地址

如果在main中多次调用add

int main() {add(3,5);  // 第一次调用:触发解析add(4,6);  // 第二次调用:直接使用已解析的地址return 0;
}

第二次调用add时,再用disassemble main查看,会发现callq指令依然指向add@plt,但此时add@plt会直接从 GOT 中读取已解析的真实地址,不再触发动态链接器 —— 这就是 “延迟绑定” 的核心:只在第一次调用时解析,后续直接复用

总结:哪里体现了 “延迟绑定”?

  1. 未调用时add的地址指向 PLT 桩代码(非真实地址);
  2. 第一次调用:通过 PLT 触发动态链接器解析真实地址,并写入 GOT;
  3. 后续调用:直接从 GOT 读取真实地址,无需再次解析。

这些细节在 GDB 调试中通过查看地址、反汇编、单步执行可以清晰观察到,完美验证了 “延迟绑定” 机制 —— 动态库函数的地址不是启动时一次性解析,而是 “用的时候才解析”,以此优化程序启动速度。

五,总结

ELF 格式定义了二进制文件的结构,程序加载是将 ELF 段映射到虚拟地址空间并做好执行准备的过程,虚拟地址空间为进程提供独立的内存视角,动态库加载实现了代码的共享和灵活调用。这几部分相互配合,保障了 Linux 系统中可执行程序的正常运行。实际学习中,可借助 readelf、ldd、pmap 等命令加深理解。

理论 + 实践对比表

知识点理论描述实践验证方式对比图核心结论
ELF 格式文件头→程序头→节区表,分层描述加载和链接readelf -h/-l/-S程序头指导加载,节区表指导编译
程序加载虚拟地址编译时确定,加载时映射到内存GDB info address + run加载前后地址一致,验证虚拟地址映射
虚拟地址空间代码段→数据段→堆→动态库→栈,分层布局pmap -x地址规律与理论完全匹配
动态库加载运行时加载,符号延迟绑定ldd + GDB 调试动态库地址在运行时解析,验证延迟绑定

http://www.lryc.cn/news/618296.html

相关文章:

  • 鸿蒙本地与云端数据双向同步实战:从原理到可运行 Demo 的全流程指南
  • Web学习笔记5
  • Linux环境gitlab多种部署方式及具体使用
  • 深入理解二维数组创建与使用
  • 使用正则中的sub实现获取我们匹配的字符串,然后追加指定字符
  • Linux图形化登录界面不显示root
  • SQL Server增加对UTF-8的支持
  • C语言(03)——斐波那契数列的理解和运用(超详细版)
  • 编程与数学 03-003 计算机操作系统 19_操作系统性能优化(二):内存与I/O性能优化
  • python3.10.6+flask+sqlite开发一个越南留学中国网站的流程与文件组织结构说明
  • 一起来聊聊GPT-5
  • PostgreSQL——数据查询
  • [GESP202309 六级] 2023年9月GESP C++六级上机题题解,附带讲解视频!
  • 後端開發技術教學(五) 魔術方法、類、序列化
  • demo 英雄热度榜 (条件筛选—高亮切换—列表渲染—日期显示)
  • Langchain入门:文本摘要
  • [论文阅读] (41)JISA24 物联网环境下基于少样本学习的攻击流量分类
  • 视频抽取关键帧算法
  • imx6ull-驱动开发篇19——linux信号量实验
  • 【跨服务器的数据自动化下载--安装公钥,免密下载】
  • n8n、Workflow实战
  • 快速了解自然语言处理
  • QT多线程全面讲解
  • NTP常见日志分析
  • MySQL User表入门教程
  • Mysql GROUP_CONCAT函数数据超长导致截取问题记录
  • 测试自动化框架全解读:为什么、类型、优势与最佳实践
  • 分布式光伏气象站:为光伏电站的 “气象感知眼”
  • 【opencv-Python学习笔记(2): 图像表示;图像通道分割;图像通道合并;图像属性】
  • 云原生应用的DevOps3(CI/CD十大安全风险、渗透场景)