19、可执行文件格式:ELF可执行文件结构,程序头表,入口点

说实话,搞了这么多年系统编程,我越来越觉得——理解 ELF 文件格式,是区分「普通 C 程序员」和「系统级 C 程序员」的一道分水岭。

你写了一个 hello.c,gcc 编译完,生成了一个 a.out。然后你 ./a.out,程序就跑起来了。这中间发生了什么?操作系统是怎么知道从哪里开始执行你的代码的?它怎么知道你的代码段、数据段分别放在内存的什么位置?

嗯,答案就藏在 ELF 文件里。

ELF 是什么?

ELF 全称 Executable and Linkable Format,可执行与可链接格式。Linux 下的可执行文件、共享库(.so)、目标文件(.o)、甚至核心转储文件(core dump),都用的是 ELF 格式。

我当年刚接触嵌入式 Linux 时,有一次调试一个段错误,死活找不到原因。后来用 readelf 一看,发现程序头表里某个段的加载地址配错了。那次之后我就老老实实把 ELF 规范啃了一遍。

ELF 文件从宏观上分为三部分:

  • ELF 头(ELF Header):文件最开头,描述文件类型、架构、入口点等元信息。
  • 程序头表(Program Header Table):告诉操作系统如何将文件加载到内存。只有可执行文件和共享库才有。
  • 节头表(Section Header Table):用于链接和调试,描述各个节(.text、.data、.bss 等)的信息。链接器用这个,运行时基本用不到。

你可以把 ELF 头想象成书的封面,程序头表是目录(告诉你怎么读),节头表是索引(告诉你每个章节的细节)。

ELF 头:一切从这里开始

每个 ELF 文件的前 64 字节(32 位系统是 52 字节)就是 ELF 头。我习惯用 readelf -h 来查看它:

$ readelf -h /bin/ls
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF64
  Data:                              2's complement, little endian
  Entry point address:               0x5850
  Start of program headers:          64 (bytes into file)
  Start of section headers:          132928 (bytes into file)
  Number of program headers:         13
  Number of section headers:         30
  Size of program headers:           56 (bytes)
  Size of section headers:           64 (bytes)

这里有几个关键字段:

  • Magic:前 4 个字节固定为 7f 45 4c 46,也就是 \x7fELF。这是 ELF 文件的身份证。
  • Class:32 位还是 64 位。32 位用 ELF32,64 位用 ELF64。
  • Entry point address:入口点地址。程序启动时,CPU 第一条指令从这里开始执行。
  • Start of program headers:程序头表在文件中的偏移量。
  • Number of program headers:程序头表中有多少个条目。

你想想看,操作系统加载 ELF 文件时,第一步就是读 ELF 头。它先检查 Magic 确认这是 ELF 文件,然后根据 Entry point 找到入口,再根据程序头表的信息把各个段加载到内存。

程序头表:加载的蓝图

程序头表才是可执行文件的核心。它告诉内核:「把文件中的哪些部分,加载到内存的哪些地址,赋予什么权限。」

readelf -l 可以查看:

$ readelf -l /bin/ls

Elf file type is EXEC (Executable file)
Entry point 0x5850
There are 13 program headers, starting at offset 64

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  PHDR           0x0000000000000040 0x0000000000000040 0x0000000000000040
                 0x00000000000002d8 0x00000000000002d8  R      0x8
  INTERP         0x0000000000000318 0x0000000000000318 0x0000000000000318
                 0x000000000000001c 0x000000000000001c  R      0x1
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
  LOAD           0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x000000000000d6d8 0x000000000000d6d8  R E    0x1000
  LOAD           0x000000000000de00 0x000000000000ee00 0x000000000000ee00
                 0x0000000000002e50 0x0000000000002e50  RW     0x1000
  DYNAMIC        0x000000000000ef18 0x000000000000ff18 0x000000000000ff18
                 0x00000000000001e0 0x00000000000001e0  RW     0x8
  NOTE           0x0000000000000338 0x0000000000000338 0x0000000000000338
                 0x0000000000000020 0x0000000000000020  R      0x8
  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000000 0x0000000000000010  RW     0x10
  GNU_RELRO      0x000000000000de00 0x000000000000ee00 0x000000000000ee00
                 0x0000000000000200 0x0000000000000200  R      0x1

常见的程序头类型:

类型 说明
PHDR 程序头表自身,加载到内存中以便调试器访问
INTERP 指定动态链接器的路径,比如 /lib64/ld-linux-x86-64.so.2
LOAD 可加载段,这是最重要的类型。告诉内核把数据从文件拷贝到内存
DYNAMIC 动态链接信息,用于动态链接器解析符号
NOTE 附加信息,比如 ABI 版本、构建 ID 等
GNU_STACK 栈的可执行权限标记。现代系统通常标记为不可执行(NX)
GNU_RELRO 只读重定位,用于保护 GOT 表

注意看那两个 LOAD 段

  • 第一个 LOAD 段:从文件偏移 0 开始,加载到虚拟地址 0,大小 0xd6d8 字节,权限是 R E(可读可执行)。这就是代码段。
  • 第二个 LOAD 段:从文件偏移 0xde00 开始,加载到虚拟地址 0xee00,大小 0x2e50 字节,权限是 RW(可读可写)。这就是数据段。

这里有个细节:文件偏移和虚拟地址往往不一样。因为段要对齐到页边界(通常是 4KB),所以数据段在文件里可能从 0xde00 开始,但加载到内存时地址变成了 0xee00。中间的差值就是页对齐导致的偏移。

核心要点:操作系统加载 ELF 时,遍历程序头表中的每个 LOAD 段,根据 VirtAddr 和 FileSiz 把文件内容映射到内存。然后根据 MemSiz 和 FileSiz 的差值,在内存中补零(比如 .bss 段)。最后设置权限(R、W、X)。

入口点:程序的第一条指令

入口点(Entry Point)是 ELF 头里的一个字段。它指向程序执行时 CPU 要跳转的第一条指令的虚拟地址。

但这里有个坑——入口点通常不是 main 函数!

对于 C 程序,真正的入口点是 _start,它由 C 运行时库(crt0 或 crt1)提供。_start 会做一些初始化工作,比如:

  • 设置栈指针
  • 初始化全局变量
  • 调用构造函数(.init 段)
  • 准备好 argc 和 argv
  • 最后调用 main 函数

我曾经调试过一个嵌入式程序,发现程序一启动就崩溃。用 GDB 看,PC 直接跳到了 main 函数,跳过了 _start。后来发现是链接脚本里把入口点设成了 main。嗯,这种低级错误,犯过一次就再也不会忘了。

你可以用 readelf -h 查看入口点,然后用 objdump -d 反汇编入口点附近的代码:

$ objdump -d /bin/ls | grep -A 20 '<_start>:'
0000000000005850 <_start>:
    5850:       endbr64
    5854:       xor    %ebp,%ebp
    5856:       mov    %rdx,%r9
    5859:       pop    %rsi
    585a:       mov    %rsp,%rdx
    585d:       and    $0xfffffffffffffff0,%rsp
    5861:       push   %rax
    5862:       push   %rsp
    5863:       lea    0x1d(%rip),%r8
    586a:       lea    0x1e(%rip),%rcx
    5871:       lea    0x1(%rip),%rdi
    5878:       call   __libc_start_main

看到了吗?_start 最后调用了 __libc_start_main,这个函数才是真正调用 main 的地方。

动态链接与解释器

如果你的程序是动态链接的(大部分都是),那么程序头表里会有一个 INTERP 段。它指向动态链接器(ld-linux.so)。

加载流程是这样的:

  1. 内核加载 ELF 文件,读取程序头表。
  2. 内核把 LOAD 段映射到内存。
  3. 内核看到 INTERP 段,知道需要动态链接器。
  4. 内核加载动态链接器(ld-linux.so)到内存。
  5. 内核把控制权交给动态链接器的入口点。
  6. 动态链接器解析所有共享库依赖,重定位符号。
  7. 动态链接器跳转到原程序的入口点(_start)。

说白了,你的程序在真正跑起来之前,已经被「代理」过一次了。这个代理就是动态链接器。

小技巧:如果你想看一个程序依赖哪些共享库,用 ldd 命令。它本质上就是解析 ELF 的 DYNAMIC 段里的 NEEDED 条目。

SVG:ELF 可执行文件加载流程

下面这张图展示了从 ELF 文件到进程内存的完整映射关系:

ELF 可执行文件 → 进程内存映射 ELF 文件 ELF 头 入口点: 0x5850 程序头表 LOAD (R E) 代码段 LOAD (RW) 数据段 INTERP → ld-linux.so DYNAMIC → 动态链接信息 节头表 .text .data .bss .symtab .strtab 进程虚拟内存 栈 (Stack) — 高地址 堆 (Heap) 数据段 (RW) .data (已初始化全局变量) .bss (未初始化全局变量,补零) 代码段 (R E) .text (机器指令) 入口点 → _start → main 只读数据 (R) — .rodata ELF头→入口点 程序头→加载段 节头→调试/链接 内核根据程序头表的 LOAD 段,将 ELF 文件映射到进程虚拟内存的对应区域

避坑指南

我曾经踩过的坑:

  • 入口点被覆盖:链接多个目标文件时,如果多个文件都定义了 _start,链接器会报错。但如果你用 -e 选项强行指定了错误的入口点,程序启动就会崩溃。
  • 段对齐问题:LOAD 段的 VirtAddr 必须按 Align 对齐。我遇到过嵌入式平台上因为对齐不对,mmap 失败的情况。
  • GNU_STACK 权限:如果你在栈上执行代码(比如某些 JIT 编译器),需要显式设置栈为可执行。但现代系统默认 NX,你得用 -z execstack 编译选项。

调试利器

  • readelf -l:查看程序头表
  • readelf -S:查看节头表
  • objdump -d:反汇编
  • gdb info proc mappings:查看进程的内存映射,和程序头表对照着看,理解更深刻

好了,ELF 可执行文件的结构就讲到这里。你想想看,从 ELF 头到程序头表,再到入口点和动态链接,这一整套机制其实非常优雅。理解了它,你就能真正掌控你的程序从磁盘到内存、从加载到执行的完整生命周期。


公众号:蓝海资料掘金营,微信deep3321