首页 / 资讯中心 / 文章详情

ELF文件格式完全解读:从加载原理到Linux实战排错

ELF文件格式完全解读:从加载原理到Linux实战排错 ★ FEATURED ARTICLE
如果你在 Linux 机器上跑过任何一个程序那你其实已经和 ELF 格式打过无数次交道只是大多数时候没意识到而已。ls、bash、nginx、MySQL甚至那个 8 字节的 Hello World全是 ELFExecutable and Linkable Format。可以说ELF 是 Linux 生态里最底层的“通用语言”可执行文件是它共享库是它.o 目标文件是它内核模块 .ko 是它连内核崩溃时的 core dump 也是它。把 ELF 搞清楚你才算真正看懂了 Linux 上从敲下命令到进程跑起来那条完整的链路。这篇文章会从应用、内核、嵌入式、安全加固几个面展开把 ELF 的来龙去脉、加载过程、常见坑和面试问题都捋一遍。已经有一定基础的人可以重点看第 3 节的内核视角和第 5 节的排错实战刚入门的人建议从头读到尾尤其是第 2 节动态库和 GOT/PLT 的部分这是理解现代 Linux 运行机制的关键。1. 先搞清楚ELF 到底是什么凭什么说它是 Linux 的基石1.1 你其实每天都在和 ELF 打交道在 Linux 里跑一个可执行文件通常就一行命令./hello但这背后并不是“内核直接执行文件”这么简单。内核拿到这个文件要先判断它是什么格式再按对应格式的规范解析出代码段、数据段、动态链接信息最后才把控制权交给进程。Linux 支持的二进制格式不止 ELF 一种历史上还有过 a.out、COFF但最终 ELF 统一了天下。原因很简单ELF 设计得足够灵活既能描述可重定位的目标文件也能描述可执行文件、共享库和核心转储一套规范通吃。在 macOS 和 Windows 上对应的角色分别是 Mach-O 和 PE三者思路类似但 ELF 在 Linux 生态里的地位更彻底连内核镜像 vmlinux 和内核模块 .ko 都直接采用 ELF 格式。所以很多时候做嵌入式开发、看内核日志、分析驱动加载问题最终都会落到“用 readelf 看一眼这个文件”这件事上。1.2 从源码到可执行文件编译和链接到底做了什么写一段最简单的 C 代码#include stdio.h int main(void) { printf(hello elf\n); return 0; }然后执行gcc -o hello hello.cgcc 内部做了什么其实是一条流水线预处理cpp、编译成汇编cc1、汇编成目标文件as、链接collect2/ld。中间产物 hello.o 就是 ELF 的一种类型——ET_REL 可重定位文件。真正得到可运行的 hello是靠链接器把 hello.o 和 glibc 相关的启动代码、动态库信息合并出来的。这里有个新手很容易忽略的点-o hello得到的文件和hello.o的“长相”完全不同。hello.o 里的地址都是相对地址需要链接器把各个节的符号位置重新安排而 hello 已经包含了程序入口、段表、动态链接信息内核可以直接加载。如果你好奇可以对同一个源文件分别跑file hello.o和file hello输出里会明确标注 ELF 的 type 分别是 REL 和 PIE/DYN。1.3 拆开一个 ELF核心结构就三块想理解 ELF不需要把整个规范背下来抓三个层次就够了。第一层是 ELF 头ELF Header。文件开头固定 4 字节魔数0x7f 45 4c 46翻译成 ASCII 就是\x7fELF。后面还记录了很多“元信息”比如是 32 位还是 64 位Class、大端还是小端Data、文件类型Type、目标架构Machine、程序入口地址Entry point、以及两张表的偏移。第二层是程序头表Program Header Table描述的是“加载视图”。内核加载 ELF 时主要看这张表哪些内容映射到内存哪个地址、权限是 rwx 怎么组合、是否包含解释器信息。可以用 readelf -l 直观看到readelf -l hello第三层是节头表Section Header Table描述的是“链接视图”。链接器、调试器、反汇编工具关心的是这个代码在 .text数据在 .data只读数据在 .rodata符号表在 .symtab调试信息在 .debug_info。运行时的进程其实不需要节头内核只管段。注意段Segment和节Section不是一个概念。段是加载维度节是链接维度。readelf -S 看节表readelf -l 看段表实战中别搞混。2. 应用视角编译、链接、动态库背后的 ELF 学问2.1 静态链接和动态链接为什么动态库能“共享”链接方式直接决定你最终二进制的大小和运行依赖。静态链接是把所有用到的代码全部拷贝进最终文件动态链接则只在 ELF 里记录依赖的库名和一个解释器路径由系统在运行前负责把库加载进来。gcc -static -o hello_static hello.c gcc -o hello_dynamic hello.c对比这两个文件file hello_static会显示 statically linked体积明显大。file hello_dynamic会显示 dynamically linked并且可以用ldd查看依赖。动态库能“共享”的关键在于 ELF 里有个.dynamic节专门记录依赖列表DT_NEEDED、符号表位置、重定位表位置等信息。运行时加载器读取.dynamic就能知道该加载哪些 .so以及每个符号该去哪里找。这样做的好处是节省磁盘和内存坏处是依赖关系一旦断裂缺库、版本不匹配程序直接跑不起来。2.2 符号表和重定位链接器是怎么拼积木的一个 ELF 文件里有大量符号。函数名、全局变量名都属于符号。编译每个 .o 时并不知道外部符号的最终地址只能生成一个“待修正”的引用称为重定位项。链接器的工作就是把这些符号引用解析成具体地址并写入对应位置。nm hello.o readelf -r hello.o窄看到U puts这样的符号U 表示 undefined意思是这个符号由外部提供。在动态链接里这种情况更常见可执行文件自身没有任何 puts 的实现只有一条“去 libc.so.6 里找 puts”的记录。链接器不需要把所有地址写死而是交给运行时动态解析。静态链接时同样的 undefined 符号会被解析成 libc.a 中对应函数的真实地址直接填进代码里。2.3 GOT 和 PLT延迟绑定的经典设计说到动态链接就绕不开 GOTGlobal Offset Table和 PLTProcedure Linkage Table。这是现代 ELF 动态链接的一个精妙设计函数调用不直接调用外部地址而是先跳到 PLT 里的一个小桩再由桩跳转到 GOT 里记录的真正地址。最初 GOT 里对应的地址指向 PLT 桩里的解析代码。第一次调用某个外部函数时解析器找到真实地址回写到 GOT后续再调用就直接从 GOT 取地址了。这就是“懒绑定”也叫延迟绑定。好处很明显启动时不需要把所有外部符号都解析一遍尤其对库很多的程序能省不少时间。objdump -d hello | grep -A 20 putsplt你会看到 putsplt 其实就几条指令核心是跳转到 GOT 表项。理解了这个机制后面看 RELRO 安全加固、看攻击者为什么会盯上 GOT 表项就全都串起来了。2.4 PIE 和 ASLR现代系统的默认防线老版 Linux 可执行文件是固定加载地址的ELF 头里直接写死入口地址比如 0x400000。但这有安全隐患攻击者如果能预测代码地址就能借缓冲区溢出等漏洞精准改写控制流。为了缓解这类问题现代工具链默认生成 PIEPosition Independent Executable配合系统 ASLR让每次加载基址都随机化。gcc -fPIE -pie -o hello_pie hello.c gcc -no-pie -o hello_old hello.c readelf -h hello_pie | grep TypePIE 的文件类型通常是DYN和共享库一样非 PIE 才是EXEC。你可以连续执行同一个 PIE 程序然后用cat /proc/pid/maps观察它的加载基址每次都不一样。静态链接时如果忘了加 PIE也是个常见的面试坑。3. 内核视角从 execve 到进程启动内核如何“读懂”ELF3.1 execve 系统调用内核从哪一步开始认文件在 Linux 里启动一个新程序最常见的入口是 fork/execve 组合。execve 是系统调用参数包括路径、argv、envp。内核在执行 execve 时并不假设文件一定是 ELF而是按照注册顺序依次尝试各种二进制格式处理器。ELF 对应的处理器是load_elf_binary此外还有处理脚本的binfmt_script、处理内核模块加载等场景的其他 handler。内核一开始会读取文件头部几个字节和 ELF 魔数做比对。对上了就调用后续的加载逻辑对不上就回退给下一个 handler。这也是为什么你在 Linux 上执行一个 Windows PE 文件系统会直接报 Exec format error——因为没有对应的 handler 认可它的格式。3.2 内核加载 ELF 的完整流程execve的执行路径大致是do_execve准备参数和环境变量把文件名从用户态拷贝到内核态。打开文件读取头部遍历formats链表寻找匹配的linux_binfmt。命中 ELF handler 后调用load_elf_binary。解析 ELF 头和程序头表校验合法性。根据 PT_LOAD 段创建内存映射把代码段、数据段映射到对应地址权限按段描述设置。如果存在 PT_INTERP 段映射动态链接器通常是/lib64/ld-linux-x86-64.so.2否则直接用 ELF 头里的 entry 作为入口。设置栈、argv、envp填入辅助向量auxv然后切换到用户态。/proc/pid/maps里你能看到 ELF 的几个 PT_LOAD 映射以及[vdso]、[vvar]、加载器的映射。我经常用这招排查“程序明明很小为什么 RSS 很大”的问题——多数时候是动态链接器、libc、栈映射都占着地址空间。3.3 动态链接器真正干活的“总导演”内核把控制权交给 ELF entry 之后如果程序是动态链接的entry 并不是 main 函数而是_start它来自 crt1.o。_start会调用__libc_start_main进而触发动态链接器的初始化逻辑加载所有依赖的 .so、解析重定位、执行各库的 .init 构造函数最后才进入 main。这也是为什么你能在.init_array、.fini_array里注册构造函数和析构函数。C 全局对象的构造、__attribute__((constructor))的函数都在 main 之前执行底层的执行顺序就是由启动代码和动态链接器约定的。分析某些“莫名其妙的初始化崩溃”时顺序错乱是非常常见的排查方向。3.4 内核模块 .ko 也是一等公民的 ELFELF 不只是用户态程序的专利。insmod 一个驱动时加载的是.ko文件它同样是个 ELF类型通常是 ET_REL。和可执行文件不同.ko 不经过完整加载流程而是由内核模块加载器把它当作可重定位对象在内核地址空间里完成重定位然后执行模块的 init 函数。file hello.ko readelf -s hello.ko | grep init_module模块里有个特殊节.gnu.linkonce.this_module存放 struct module 的实例内核靠它维护模块的引用计数、状态和参数。调试嵌入式内核、DSA switch 驱动这类问题时readelf 和 modinfo 的组合能很快确认模块的依赖、作者、参数和 vermagic 是否匹配当前内核。4. 嵌入式与特殊场景ELF 不只在 PC 上4.1 嵌入式里的 ELF裁剪、交叉编译、镜像文件嵌入式中 ELF 最常见的两个用途是交叉编译产物和内核镜像。交叉编译工具链生成的目标文件也是 ELF只是因为目标架构不同ELF 头里的 Machine 字段可能是 ARM、AArch64、RISC-V 等。用file一眼就能看穿架构不匹配的报错。嵌入式还有一个特色操作ELF 文件常常被“瘦身”。开发调试阶段保留符号表和调试节发布阶段用strip、objcopy去掉调试信息甚至抽取纯二进制aarch64-linux-gnu-objcopy -O binary hello hello.bin这也是为什么同样一段逻辑开发板里的固件和 PC 上的程序长得完全不同——前者可能只是裸数据谈不上 ELF。4.2 DSA switch 驱动、外设驱动为什么也叫 ko在嵌入式 Linux 里网络处理器、交换芯片往往通过 DSADistributed Switch Architecture框架接入。这类驱动的形态一般也是 .ko 或者直接编进内核。因为 dtype 是 ET_REL内核在加载时做的重定位和用户态链接器做的重定位本质上是一回事只是地址空间一个是用户态、一个是内核态。碰到“驱动加载失败insmod 报 Invalid module format”时先不要怀疑代码逻辑去查一下模块的 vermagic 和内核版本是否匹配modinfo xxx.ko | grep vermagic uname -r不一致的话重新编译模块或者检查内核源码版本通常比修改代码更快。4.3 虚拟化、容器、Android 里ELF 依然无处不在虚拟机里装的 Linux 内核是 ELF 编译出来的 vmlinuz 或 vmlinux容器镜像里的每个二进制依然是 ELF只是运行环境多了一层 namespace 隔离。Android 上 WebView 用的 Chromium、各种系统服务底层的 .so 也都是 ELF只是多了 Android 特有的命名规则和 RELR 压缩重定位表。理解 ELF 的通用结构跨平台排查问题的基础就打好了一半。做 AI 框架算子开发时编译出来的 kernel 也可能落成 ELF 或者由运行时在内存中动态生成 ELF 再映射——这也是为什么很多 profiler 能直接给出指令级的执行热点。4.4 eventfd、等待队列、内核缓冲进程与内核的沟通方式很多嵌入式开发同学会疑惑用户态程序和内核模块怎么互相通知eventfd 就是一种典型的内核对象进程通过 read/write 操作一个 fd内核模块通过 eventfd_signal 唤醒等待的进程。这里面进程本身是 ELF却在一块内核维护的事件对象上等待看起来“不相关”实际内核调度、等待队列和用户态栈的切换全都参与其中。事件驱动的系统里一个常见问题就是“内核缓冲”到底指什么可能是 socket 发送缓冲、也可能映射到用户态的一段 mmap 缓冲还有可能是页缓存。遇到这类问题先看strace的读写调用再看cat /proc/pid/maps确认映射的匿名区和文件区基本能定位到是哪个缓冲出了问题。5. 实战必备从查到排错这套命令请收好5.1 一套顺手的 ELF 命令全家桶不用记全部参数多数场景下面几条就够用了命令用途典型参数file快速识别文件类型和架构file helloreadelf查看 ELF 头、段表、节表、动态信息readelf -h/-l/-S/-d/-s helloobjdump反汇编、查看节内容objdump -d hellonm查看符号表nm -D libc.so.6ldd查看动态库依赖会执行程序慎用替代readelf -d hello | grep NEEDEDpatchelf修改解释器路径、RPATH、符号版本patchelf --set-interpreter /path/ld hellostrip去掉符号表和调试信息strip --strip-all hello我最推荐的组合是filereadelf -dobjdump -d。file定位格式readelf看依赖objdump看具体指令基本能覆盖 80% 的 ELF 问题。5.2 常见报错与排查实录“No such file or directory”到底缺什么在执行一个可执行文件时报./app: No such file or directory但文件明明存在。这个报错十有八九是动态链接器路径不对。比如程序是为另一个发行版编译的或者交叉编译时指向/lib/ld-linux-aarch64.so.1但在当前系统不存在。用file app查看 Interpreter 字段再用patchelf --print-interpreter确认基本瞬间定位。“cannot open shared object file”这个报错是在告诉你是运行时找不到某个 .so。优先用readelf -d查看实际依赖的 NEEDED 名称再去系统的/lib、/usr/lib或者目标机的LD_LIBRARY_PATH里找。不要直接往系统目录硬塞库除非你确认版本兼容。如果你只改自己的环境变量可以用LD_LIBRARY_PATH/opt/mylib ./app“Exec format error”典型场景是把在 x86 上编译的程序拷到 ARM 板或者直接执行一个脚本但没有可执行权限、没有正确 shebang。file一看架构就能确认。“Text file busy”替换正在运行的程序时直接mv会报 Text file busy。把新文件先改个名再 mv或者先停掉用这个文件的进程就能避开。5.3 Linux 面试常考的几个 ELF 问题答案思路全在这里结合我看到的面试题和这些年带新人的经验整理几个高频问题Q1静态链接和动态链接的区别静态链接把库代码复制进最终文件运行时不再依赖外部库动态链接在 ELF 的 .dynamic 里记录依赖和符号运行时由动态链接器解析。前者体积大、部署简单后者体积小、节省内存但依赖环境。Q2为什么现代 Linux 默认用 PIEPIE 允许加载基址随机化配合 ASLR 让攻击者难以预测代码地址提高利用难度。老式 EXEC 型二进制固定加载地址更容易被精准攻击。Q3GOT 和 PLT 分别是什么GOT 是全局偏移表存放全局符号和外部函数的实际地址PLT 是过程链接表提供桩代码首次调用触发动态解析把解析结果回填 GOT后续调用直接命中。Q4内核是怎么加载 ELF 的execve 触发二进制格式匹配ELF 处理器读取头部和程序头按 PT_LOAD 映射内存设置入口有 PT_INTERP 就加载动态链接器最后返回用户态执行。Q5如何减小 ELF 体积strip 去掉符号、编译优化选项、裁剪动态链接、去掉不需要的调试段、用更小的 C 运行时或静态裁剪版本。5.4 安全加固三板斧RELRO、栈保护、PIE一段 C 代码用默认参数编出来可能已经开了部分防护但为了稳妥编译大型项目时我习惯显式加上gcc -fPIE -pie -fstack-protector-strong -z relro -z now -o app app.c-fstack-protector-strong栈保护检测栈溢出覆盖返回地址。-z relro -z now把 GOT 等只读重定位表标为只读并且启动时彻底完成重定位防止 GOT 表项被改。-fPIE -pie地址无关配合 ASLR。用readelf -l检查输出里的 GNU_RELRO 是否存在、GNU_STACK 权限是否只读不可执行是判断一个二进制是否加固到位的基本手段。内核提权攻击很多时候会尝试劫持内核数据或用户态地址PIE、RELRO、栈保护和地址随机化虽然不能杜绝问题但能极大抬高利用门槛。系统层面及时更新内核和用户态组件才是防止“内核权限提升”类漏洞落地的最根本手段。5.5 顺手一提内核调试错误代码、启动内核切换、进程名修改如果你在虚拟机里装 Linux 时遇到“此设备已为 Windows 内核调试程序预留代码 53”之类的报错通常和宿主机虚拟化穿透、Hyper-V 调试器共存的配置有关。先检查 BIOS 的 VT-x 是否开启再确认 Windows 侧 Hyper-V 的内核调试功能是否占用了资源一般能解决。这不是 ELF 问题但环境坏了什么都跑不了顺手记一下很有用。多内核环境切换启动项也是排查内核问题的常备技能。RHEL/CentOS 系用 grubbyDebian/Ubuntu 系用 grub-mkconfig在/etc/default/grub里设置 GRUB_DEFAULT 然后重新生成 grub.cfg 即可切换默认启动内核。如果只想临时体验老内核可以在 GRUB 菜单里手动选。修改进程名这个需求我也踩过坑。用prctl(PR_SET_NAME)只能改内核线程名也就是/proc/pid/comm的内容想改ps -ef看到的 argv[0]需要直接覆盖 argv 区域。很多程序改名工具就是基于这两个原理实现的改完以后整个/proc视图才会一致。6. 我踩过的坑和一些心得给你当参考6.1 交叉编译后的 glibc 版本不匹配开发板上跑一个在 PC 上交叉编译的程序启动时报GLIBC_2.34 not found。我当时的第一反应是缺库反复拷贝 libc 过去完全没用。后来用readelf --version-info查看符号版本要求才发现目标系统的 glibc 太老。正确做法是换用目标系统相近的交叉工具链或者考虑静态链接。这个问题的排查思路和“No such file or directory”类似先怀疑解释器和库版本再怀疑业务代码。6.2 动态库依赖像蜘蛛网一次改动牵一发动全身曾经优化一个服务想把业务模块换成新版本 .so没注意旧版本被另一个模块依赖结果线上启动直接报符号版本冲突。从那以后我改任何一个 .so 之前都会先跑一遍readelf -d和objdump -T把依赖图谱打出来再动手。这里提醒一点ldd虽然好用但它会实际加载程序在自己不信任的程序上使用有一定风险更稳妥的方式是readelf -d只看 NEEDED 列表。6.3 看到 [vdso] 不要慌排查内存映射时/proc/pid/maps里出现[vdso]、[vvar]是很正常的。vdso 是内核映射到每个进程地址空间的一小段代码用来加速 gettimeofday、clock_gettime 这类高频系统调用让用户态直接读时钟而不用陷入内核。它不是漏洞产物而是标准机制。理解了它对“为什么进程还什么都没干就有好几个映射”这件事就不会再犯迷糊。6.4 内核模块里发生死锁先看是否在锁里睡了在 ARM64 上调试一个 DSA 驱动的内核模块遇到过进程卡死。内核日志用CONFIG_DEBUG_ATOMIC_SLEEP编译时会直接提示在某处出现了 atomic context 不允许睡眠的问题。那次根因就是我在 spinlock 临界区里调用了一个可能睡眠的分配函数。正确做法是把需要睡眠的操作挪到锁外面或者改用 mutex。这类问题在用户态很难复现只有多开内核调试选项、看串口日志才能快速定位。6.5 几十个 ELF 相关命令新手的记忆捷径不用背命令先记住“file 定格式、readelf 看结构、objdump 看指令、nm 看符号、strace 看运行”。后面遇到具体问题对着这个框架去查参数就够了。常用的 readelf、objdump 参数我上面积累了一张表建议保存下来用到时翻一下比死记强得多。最后再分享一个小技巧排查任何“程序跑不起来”的问题第一步永远是file和readelf -d。这两个命令能过滤掉一半以上的低级错误剩下的一半才会真正进入代码逻辑层面。ELF 这块搞懂了后续学内核、学安全、学嵌入式都会顺畅很多。
阅读完成 · 觉得有帮助?
咨询建站