Linux ELF 文件

敲下 ./program 命令的那一刻,计算机是怎么把这个文件变成一个活蹦乱跳的进程的?这背后的"黑魔法"到底是什么?

答案就是 ELF(Executable and Linkable Format)可执行与可链接格式。可以把它理解为 Linux 世界里程序的"灵魂容器"!

一、什么是 ELF 文件?

简单来说,ELF 是 Linux 下的可执行文件格式,就像 Windows 下的 .exe 一样。但别被这个简单的解释骗了,ELF 可比 .exe 复杂得多,也强大得多!

ELF 文件可以是:

  • 可执行文件(比如你的 ./program
  • 目标文件(编译后但还没链接的 .o 文件)
  • 共享库文件(就是 .so 文件,类似 Windows 下的 .dll
  • 核心转储文件(程序崩溃时的那个 core dump)

本质上,ELF 就是一个容器,里面装着代码、数据以及程序运行所需的各种信息,按照特定的格式组织起来。

二、初见 ELF:第一印象很重要

想知道一个文件是不是 ELF 格式的?超简单:

bash 复制代码
$ file ./program
program: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, \
    interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, not stripped

只要文件输出信息的开头是 "ELF",那它就是 ELF 格式的!

再来点儿硬核的,直接看一下 ELF 文件的前几个字节:

bash 复制代码
$ xxd -l 16 ./program
00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000  .ELF............

这里最开始的 7f 45 4c 46 就是 ELF 文件的"魔数"(Magic Number)。其中 45 4c 46 是 ASCII 码中的 "ELF" 三个字母,前面的 7f 是一个特殊字符。这四个字节就是 ELF 文件的"身份证",操作系统首先会检查这四个字节,确认它是不是一个 ELF 文件。

三、ELF 文件的内部结构:化繁为简

很多教程一上来就画个复杂的结构图,看得人头晕眼花。这里用一个简单的类比来辅助理解:

把 ELF 文件想象成一本"程序说明书",这本书有三部分组成:

  1. 文件头(ELF Header):相当于书的封面和目录,告诉你这本书有什么内容,怎么看
  2. 程序头表(Program Header Table):相当于给"阅读器"(操作系统)看的指南,告诉它怎么把这本书变成一个活的程序
  3. 节区头表(Section Header Table):相当于给"编辑器"(链接器、调试器)看的指南,告诉它这本书的内部结构

然后,书的主体内容就是各种节区 (Sections)或(Segments),里面装着代码、数据等实际内容。

直观一点,用图来表示就是

复制代码
ELF Header
├── Program Header Table   ← 加载器视角
│     ├── Segment (LOAD, R+X)   ┐
│     ├── Segment (LOAD, R+W)   ├─ 每个段由若干节区拼成
│     └── Segment (DYNAMIC)     ┘
└── Section Header Table   ← 链接器视角
      ├── .text  ├── .rodata  ├── .data
      ├── .bss   ├── .symtab  ├── .strtab ...

哎,你可能会问:什么是节区(Section)?什么又是段(Segment)?它们有什么区别?

简单来说:

  • 节区(Section):是 ELF 文件存储的基本单位,针对链接器
  • 段(Segment):是运行时内存的基本单位,针对加载器

一个段通常包含多个功能相似的节区。比如,包含代码的所有节区会被归入到一个叫做 "TEXT" 的段中。

四、深入解剖 ELF 文件:逐层剥开

1. ELF 头(ELF Header)

ELF 头是整个文件的"门面",包含了文件的基本信息和指向其他部分的指针。用 readelf -h 命令可以查看:

bash 复制代码
$ readelf -h ./program
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF64
  Type:                              EXEC (Executable file)
  Machine:                           Advanced Micro Devices X86-64
  Entry point address:               0x401000
  Start of program headers:          64 (bytes into file)
  Start of section headers:          13872 (bytes into file)
  ...

这里面最重要的信息是:

  • Entry point address:程序执行的起点地址
  • Start of program headers:程序头表的位置
  • Start of section headers:节区头表的位置

2. 程序头表(Program Header Table)

程序头表告诉操作系统如何创建进程映像,用 readelf -l 命令查看:

bash 复制代码
$ readelf -l ./program
Type   Offset   VirtAddr           FileSiz  MemSiz   Flg  Align
INTERP 0x000318 0x0000000000400318 0x00001c 0x00001c  R    1
LOAD   0x000000 0x0000000000400000 0x000818 0x000818  R E  1000
LOAD   0x000e10 0x0000000000401e10 0x000240 0x000248  RW   1000
DYNAMIC 0x000e28 0x0000000000401e28 0x0001d0 0x0001d0 RW   8

最重要的是那些类型为 LOAD 的段,它们会被加载到内存中。

注意看 Flags:

  • R 表示可读(Read)
  • W 表示可写(Write)
  • E 表示可执行(Execute)

这就是为什么有的内存区域可执行,有的只能读不能写,这些权限在 ELF 文件里就定义好了!

3. 节区头表(Section Header Table)

节区头表描述了文件中各个节区的信息,用 readelf -S 查看:
readelf -S 输出(节选)

bash 复制代码
$ readelf -S ./program
There are 29 section headers, starting at offset 0x3630:
  [Nr] Name              Type            Address          Off    Size   ES Flg
   [1] .interp           PROGBITS        0000000000400318 000318 00001c 00   A
   [2] .dynsym           DYNSYM          0000000000400340 000340 0000c0 18   A
   [7] .text             PROGBITS        0000000000401060 001060 000115 00  AX
   [8] .rodata           PROGBITS        0000000000402000 002000 000004 00   A
   [11] .data            PROGBITS        0000000000404000 003000 000010 00  WA
   [12] .bss             NOBITS          0000000000404018 003010 000008 00  WA
   [14] .comment         PROGBITS        0000000000000000 003018 00002a 01
   [27] .symtab          SYMTAB          0000000000000000 003180 000168 18
   [28] .strtab          STRTAB          0000000000000000 0032e8 0000f9 00

常见的重要节区包括:

  • .text:存放程序的机器代码
  • .data:已初始化的全局变量和静态变量
  • .bss:未初始化的全局变量和静态变量(不占用文件空间)
  • .rodata:只读数据(如字符串常量)
  • .symtab:符号表,存储程序中定义和引用的函数、变量
  • .strtab:字符串表,通常存储符号名
  • .dynamic:动态链接信息

五、ELF 文件的生命周期:从编译到执行

为了彻底搞懂 ELF 文件,需要了解它的整个生命周期。

1. 编译阶段:生成目标文件(.o)

当你写完 C 代码,运行 gcc -c hello.c 时,会得到一个 hello.o 的目标文件。这个文件已经是 ELF 格式的了,但它还不能直接执行,因为里面有很多"坑"等着被填上。

这些"坑"在 ELF 文件中表现为"重定位表",用 readelf -r 可以看到:

bash 复制代码
$ readelf -r hello.o

Relocation section '.rela.text' at offset 0x... contains 2 entries:
  Offset          Info           Type            Symbol's Value  Symbol's Name + Addend
000000000000000b  000000050002  R_X86_64_PLT32   0000000000000000 printf - 4
000000000000001a  000000060002  R_X86_64_PLT32   0000000000000000 exit - 4

这表示代码中调用了 printfexit 函数,但编译器不知道它们在哪儿,所以留了个"坑"等着链接器来填。

2. 符号表:程序的"通讯录"

说到这些函数(printfexit),不得不提 ELF 文件中的"符号表"。简单来说,符号表就像是程序的"通讯录",记录了程序中所有函数和变量的名字和位置。

来看看符号表长啥样:

bash 复制代码
$ readelf -s hello.o

Symbol table '.symtab' contains 13 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND
     1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS hello.c
     7: 0000000000000000    36 FUNC    GLOBAL DEFAULT    1 main
    11: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND printf
    12: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND exit

这里面有 main 函数(我们自己定义的),还有 printfexit(外部函数)。注意它们的 Ndx(索引)列:main 是 1,表示在第 1 个节区;而 printfexit 是 UND,表示"未定义",这就是前面说的"坑"。

这个目标文件的符号表就像一张"半成品通讯录",只记录了自己有什么函数,以及自己需要哪些外部函数,但还不知道那些外部函数在哪里。所以它还不能独立工作,需要链接器来帮忙找到这些外部函数。

3. 动态链接:程序的"即插即用"

说到外部函数,就不得不提 ELF 的一个超强功能:动态链接。

还记得 Windows 上安装软件时经常冒出的 "DLL 缺失" 错误吗?Linux 上也有类似概念,不过实现得更优雅,这就是动态链接库(.so 文件)。

动态链接的好处简直不要太多:

  • 节省内存:多个程序共享同一个库
  • 节省磁盘:不用把所有代码都打包进可执行文件
  • 方便升级:库更新后,程序自动用上新版本,不用重新编译

那么问题来了:程序怎么知道自己需要哪些库?又是如何找到这些库的呢?

ELF 文件中有一个特殊的 .dynamic 节区,专门记录这些信息:

bash 复制代码
$ readelf -d /bin/ls

Dynamic section at offset 0x... contains 27 entries:
  Tag        Type                         Name/Value
 0x00000001 (NEEDED)                      Shared library: [libselinux.so.1]
 0x00000001 (NEEDED)                      Shared library: [libc.so.6]
 0x0000000c (INIT)                        0x3000

这告诉我们,ls 命令依赖于这两个共享库(具体依赖哪几个跟发行版有关,需验证)。如果想更直观地看到所有依赖及它们的实际位置,可以用 ldd 命令:

bash 复制代码
$ ldd /bin/ls
	linux-vdso.so.1 (0x00007ffd1c3f9000)
	libselinux.so.1 => /lib/x86_64-linux-gnu/libselinux.so.1 (0x00007f1c4e2b0000)
	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f1c4e0c0000)
	/lib64/ld-linux-x86-64.so.2 (0x00007f1c4e310000)

看到没?ldd 不仅告诉你需要哪些库,还告诉你它们的实际位置和加载地址。

那程序又是怎么找到这些库的呢?它会按照以下顺序查找:

  1. 环境变量 LD_LIBRARY_PATH 指定的目录
  2. 可执行文件的 RPATH 属性指定的目录
  3. /etc/ld.so.cache 缓存中记录的位置
  4. 默认目录如 /lib/usr/lib

(RPATH、RUNPATH 与 LD_LIBRARY_PATH 的先后关系在不同 glibc 版本下有差异,精确顺序以文末 ld.so 手册页为准,需验证。)

动态链接器(ld.so)会在程序启动时自动处理这些依赖关系,把所有需要的库都加载进来,就像乐高积木一样把程序拼装完整,非常巧妙!

4. 链接阶段:生成可执行文件

链接器会把多个目标文件和库文件链接在一起,解决那些"坑"(重定位),最终生成可执行文件。

那么链接器具体是怎么解决这些"坑"的呢?简单来说就是做个"牵线搭桥"的活:

  1. 收集所有目标文件中的符号表,建立一个全局符号表
  2. 找到所有标记为"未定义"(UND)的符号
  3. 在全局符号表或者库文件中寻找这些符号的定义
  4. 把找到的地址填回原来的"坑"中

比如当链接器找到 printf 函数在 libc.so 中的实际地址后,就会修改原来调用 printf 的指令,让它指向正确的地址。

链接完成后,再看同一个程序的符号表,会发现那些 UND 的符号要么有了实际地址(静态链接),要么指向了动态链接的跳转表(动态链接)。

在动态链接的情况下,还会在 ELF 文件中记录运行时需要哪些共享库,前面已经说过了。

5. 加载阶段:从文件到进程

当你执行 ./program 时,操作系统(确切地说是加载器 ld.so)会做这些事:

  1. 检查 ELF 头的合法性
  2. 根据程序头表,将需要的段加载到内存
  3. 如果是动态链接的,还会找到并加载所需的共享库
  4. 跳转到 Entry Point 开始执行

这个过程可以用 strace 命令观察:
strace ./program 输出(节选)

bash 复制代码
$ strace ./program
execve("./program", ["./program"], 0x7ffd... /* 34 vars */) = 0
brk(NULL)                               = 0x55e931a2c000
access("/etc/ld.so.preload", R_OK)      = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
mmap(NULL, 2005120, PROT_READ, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f1c4e0c0000
mmap(0x55e930e2f000, 4096, PROT_READ|PROT_EXEC, MAP_PRIVATE|..., 3, 0) = ...
...
exit_group(0)                           = ?
+++ exited with 0 +++

execve 就是创建新进程的系统调用,后面一系列操作就是在加载和准备程序运行环境。

六、ELF 实用工具箱:玩转 ELF 文件

了解了 ELF 的原理后,来看看有哪些工具可以操作 ELF 文件:

工具 用途 典型用法
file 判断文件类型 file ./program
readelf 查看 ELF 文件的所有信息 readelf -a ./program
objdump 反汇编 ELF 文件 objdump -d ./program
nm 列出符号表 nm -u hello.o
ldd 查看动态依赖 ldd /bin/ls
strings 提取文件中的字符串 `strings ./program
strip 移除符号表和调试信息 strip --strip-all ./program
patchelf 修改 ELF 文件的属性 patchelf --set-rpath ./lib ./program

七、实际应用:ELF 文件的那些神奇玩法

ELF 文件的知识不仅仅是理论,来看看一些实际的例子。

1. 程序加固与混淆

开发了一个软件不想被轻易破解:

bash 复制代码
$ ls -lh program
-rwxr-xr-x 1 user user 25K  program
$ strip --strip-all ./program
$ ls -lh program
-rwxr-xr-x 1 user user 14K  program

文件体积一下减少了几十 k,因为符号信息都被删掉了!

2. 程序补丁与热修复

假设想修改程序使用的解释器路径:

bash 复制代码
$ patchelf --set-interpreter /lib/ld-linux.so.2 ./program

这样程序就会使用你自定义的动态链接器,而不需要重新编译!

更酷的是,Linux 还提供了一种不用重启程序就能热修复的黑科技------LD_PRELOAD 环境变量!它可以让你悄悄地"替换"程序中的函数实现。

来看一个简单实用的例子 ------ 监控程序的内存分配:

创建一个简单的内存跟踪库memtrace.c

c 复制代码
#define _GNU_SOURCE
#include <stdio.h>
#include <dlfcn.h>

// 原始 malloc 函数指针
static void *(*real_malloc)(size_t) = NULL;

// 拦截 malloc 函数
void *malloc(size_t size) {
    // 延迟初始化原始函数
    if (real_malloc == NULL) {
        real_malloc = dlsym(RTLD_NEXT, "malloc");
    }

    // 调用原始 malloc
    void *ptr = real_malloc(size);

    // 打印跟踪信息
    fprintf(stderr, "malloc(%zu) = %p\n", size, ptr);

    return ptr;
}

编译成共享库:

bash 复制代码
$ gcc -shared -fPIC memtrace.c -o libmemtrace.so -ldl

接着使用库监控任何程序的内存分配:

bash 复制代码
$ LD_PRELOAD=./libmemtrace.so ./my_program

输出:

text 复制代码
malloc(100) = 0x55e930e2f6b0
malloc(200) = 0x55e930e2f720
malloc(300) = 0x55e930e2f7f0

只用了十几行代码,就实现了一个能够监控任何程序内存分配的工具!这个例子的工作原理很简单:

  1. 定义一个与系统函数同名的 malloc
  2. dlsym(RTLD_NEXT, "malloc") 找到真正的 malloc 函数
  3. 在调用真正的 malloc 前后添加我们的代码(这里是打印日志)
  4. 通过 LD_PRELOAD 让系统优先加载我们的库

这种技术经常用于:

  • 调试内存问题
  • 给程序添加日志
  • 修改程序行为而不用改源码
  • 临时修复运行中的服务

当然,这项技术也常被黑客利用来劫持程序函数,所以理解它不仅能提升编程能力,也对安全防护很重要!

八、总结:ELF 文件的精髓

来总结一下 ELF 文件的核心要点:

  1. ELF 是容器:装载了代码、数据和各种元数据
  2. 分层结构:ELF 头、程序头表、节区、节区头表
  3. 两种视角
    • 执行视角:段(Segments)--- 加载器关心
    • 链接视角:节(Sections)--- 链接器关心
  4. 生命周期:从源代码到目标文件,再到可执行文件,最后变成进程

当你理解了 ELF 文件的本质,Linux 下的很多问题就迎刃而解了:为什么有些程序不能在不同版本的 Linux 上运行?为什么动态库版本不匹配会导致程序崩溃?为什么有些恶意软件难以检测?------这些问题的答案都藏在 ELF 文件的结构中!

记住,ELF 文件不仅仅是一个格式,它是 Linux 世界中程序的"灵魂容器",承载着程序从编译到执行的整个生命周期。

延伸阅读

相关推荐
Escalating_xu41 分钟前
【Makefile 进阶】从自动发现源文件、模式规则到目录分离与多模块递归构建
linux·开发语言
遇见小修修41 分钟前
打印机连不上电脑无法打印
大数据·运维·python·电脑
星辰徐哥1 小时前
鸿蒙PC平台 Gnote 笔记应用适配实战:从 Linux 到 鸿蒙PC 的 Electron 迁移
linux·笔记·electron·harmonyos·gnote
一直C1 小时前
Linux应用软件编程|嵌入式轻量级数据库SQLite3完整学习笔记(命令行+C语言API)
linux·sqlite
Jay Kay1 小时前
RDMA中CQ为什么用轮询?——从CQ、Doorbell到GID的深度解析
运维·服务器·网络
司小豆1 小时前
第七课:DeepSeek Harness 服务与依赖注入
java·服务器·开发语言·github·ai编程
会周易的程序员1 小时前
5Draft使用说明书
服务器·c++·分布式·raft·共识
Shadow(⊙o⊙)1 小时前
Linux网络——IP协议 子网 路由 IP的分片字段
服务器·网络·tcp/ip
神威难绷泪1 小时前
TCP并发服务器:多进程 多线程 IO多路复用(select poll epoll)
linux·ipc·tcp并发服务器