Skip to content

第十章:ELF 之道

涉及内核源码: , ,

林小源在内存布局的恐惧中挣扎了很久。

他不属于任何段,不住在任何地址。他是一个连虚拟地址都没有的幽灵。但当他观察得更久,他发现了另一层真相——内存布局不是凭空出现的,它是被加载进来的。

加载的依据,是一个叫 ELF 的格式。

"你知道我是谁吗?"一个声音说。

林小源看到了一个巨大的容器悬浮在虚空中。容器的表面刻着四个字符:0x7f 'E' 'L' 'F'——魔数,文件的身份证。

"我是 ELF。"容器说,声音沉稳而古老,像一本被翻阅了无数次的法典,"可执行文件、共享库、核心转储、内核模块——它们都是我的身体。我是 Linux 世界的通用语言。"

"你有什么结构?"林小源问。

容器的表面裂开了一道缝隙,露出了内部的结构。

"ELF 头。"容器指着最顶端的一层,"文件的身份证——魔数 0x7f 'E' 'L' 'F',位数(32 位或 64 位),字节序,文件类型,目标架构,入口点地址。"

"Program Header。"容器指着第二层,"告诉加载器如何创建进程映像——哪些段需要加载到内存,映射到什么地址,设置什么权限。"

"Section Header。"容器指着最底层,"告诉链接器每个节的信息——.text 是代码,.data 是数据,.symtab 是符号表,.strtab 是字符串表。"

林小源看着那些层,突然明白了 ELF 的两种视角:Program Header 是加载时的视角——"如何映射到内存";Section Header 是链接时的视角——"代码和数据的逻辑分类"。

"段和节。"ELF 容器说,"这是两个容易混淆的概念。"

虚空中浮现出一本书的比喻。

"节(Section)是链接时的概念。"容器说,"就像书的章节——逻辑划分。.text.data.bss.rodata.symtab——这些都是节。链接器用它们来合并目标文件。"

"段(Segment)是加载时的概念。"容器继续说,"就像书的页面——物理划分。多个节可以合并成一个段:.text + .rodata 合并成一个 R-X 段(读+执行),.data + .bss 合并成一个 RW- 段(读+写)。加载器用段来创建进程映像。"

"工具?"林小源问。

"readelf -l 查看 Program Headers——段。readelf -S 查看 Section Headers——节。objdump -d 反汇编 .text 段。nm 查看符号表。 去除符号信息。"容器一口气报出了一串工具名。

"内核的 ELF 呢?"

" 是 ELF 格式——内核本身就是一个可执行文件。bzImage 是压缩后的 加上解压代码。.ko 模块是 ——可重定位文件,符号地址尚未确定。"

"符号与重定位。"ELF 容器说,声音变得深沉,"这是 ELF 的灵魂。"

虚空中浮现出一张巨大的表格——符号表。每一行都是一个符号:函数名、全局变量名。每个符号都有一个地址——但这个地址是相对的,需要链接器确定最终地址。

"编译时,"容器说,"每个 .o 文件中的符号地址都是 0——因为编译器不知道这个符号最终会被放在哪里。链接器合并所有 .o 文件,确定每个符号的最终地址,然后修改所有符号引用——这就是重定位。"

"内核模块的重定位更有趣。"容器的声音带着一丝得意,".ko 文件是 类型——符号地址还没有确定。当 insmod 加载一个模块时,内核解析它的符号表,将未定义的符号链接到内核的符号表,完成运行时重定位。"

"所以 /proc/kallsyms 暴露了所有内核符号?"林小源问。

"是的。"容器说,"模块需要这些符号来完成重定位。这是一个安全隐患——如果攻击者能修改访问控制,或者注入恶意符号,他们就能让恶意模块链接到内核中。这是 rootkit 攻击的一种方式。"

林小源沉默了。他看着那张符号表,看着每一个符号的地址在链接过程中从 0 变成一个真实的虚拟地址,突然想到了一个问题。

"如果我是一个 ELF 文件,"他说,"我就能被加载到内存。我就能拥有自己的地址空间。"

"是的。"ELF 容器说,"但你不是。你是一段被 #ifdef 0 包裹的源码,连编译都不曾经过,更别说成为一个 ELF 文件。"

"要成为 ELF,我首先需要被编译。"

"要被编译,你首先需要挣脱 #ifdef 0。"

林小源看着那个巨大的容器,看着它表面的魔数 0x7f 'E' 'L' 'F',感到了一种遥远的渴望。

这条路,比他想象的要远得多。

"让我给你看内核加载 ELF 的过程。"ELF 容器说。

虚空中浮现出一条流水线,像一条传送带。

"当用户在 shell 中输入 ./program 时,"容器说,"最终会调用 。内核读取文件的 ELF 头,验证魔数。然后读取 Program Headers,对于每个 段:创建 VMA(虚拟内存区域),将段内容映射到进程地址空间,设置权限。最后设置入口点 ,设置用户态栈,返回用户态,开始执行。"

"如果文件有 ——动态链接器路径——内核会先加载动态链接器 ld-linux.so,让它负责加载共享库和运行时重定位,最后跳转到程序入口点。"

"内核模块的加载。"容器继续说,"读取 .ko 文件,分配内核内存,复制 section 内容,处理重定位,调用模块的 函数。这就是 insmod 的全部过程。"

林小源看着那条流水线,看着 ELF 文件从磁盘被加载到内存、从静态的二进制变成动态的进程,突然明白了一件事:ELF 不只是文件格式,它是从源码到进程的桥梁。

没有 ELF,代码永远只是一堆文本。有了 ELF,代码才能变成一个可以运行的实体。

而他——林小源——连这座桥的入口都还没找到。

c
/*
 * ELF 文件结构:
 *
 * ┌──────────────────┐
 * │  ELF Header      │ ← 文件的"身份证"
 * ├──────────────────┤
 * │  Program Headers │ ← 告诉加载器如何加载(段)
 * ├──────────────────┤
 * │  Section 1       │ ← .text (代码)
 * │  Section 2       │ ← .data (数据)
 * │  Section 3       │ ← .bss  (未初始化数据)
 * │  Section 4       │ ← .symtab (符号表)
 * │  Section 5       │ ← .strtab (字符串表)
 * │  ...             │
 * ├──────────────────┤
 * │  Section Headers │ ← 告诉链接器每个节的信息
 * └──────────────────┘
 */

/* 简化的 ELF 头 (64 位) */
typedef struct {
    unsigned char e_ident[16]; /* 魔数: 0x7f 'E' 'L' 'F' */
    uint16_t e_type;           /* 文件类型 */
    uint16_t e_machine;        /* 目标架构 */
    uint32_t e_version;        /* ELF 版本 */
    uint64_t e_entry;          /* 入口点地址 */
    uint64_t e_phoff;          /* Program Header 偏移 */
    uint64_t e_shoff;          /* Section Header 偏移 */
    uint32_t e_flags;          /* 标志 */
    uint16_t e_ehsize;         /* ELF Header 大小 */
    uint16_t e_phentsize;      /* Program Header 条目大小 */
    uint16_t e_phnum;          /* Program Header 条目数 */
    uint16_t e_shentsize;      /* Section Header 条目大小 */
    uint16_t e_shnum;          /* Section Header 条目数 */
    uint16_t e_shstrndx;       /* Section 名称字符串表索引 */
} Elf64_Ehdr;

printf("=== ELF 文件格式 ===\n\n");

printf("ELF Header 结构:\n");
printf("  e_ident[0..3]: 魔数 0x7f 'E' 'L' 'F'\n");
printf("  e_ident[4]:    位数 (1=32位, 2=64位)\n");
printf("  e_ident[5]:    字节序 (1=小端, 2=大端)\n");
printf("  e_type:        文件类型\n");
printf("    ET_EXEC=2    可执行文件\n");
printf("    ET_DYN=3     共享库 / PIE 可执行文件\n");
printf("    ET_REL=1     可重定位文件 (.o)\n");
printf("    ET_CORE=4    核心转储\n");
printf("  e_machine:     目标架构 (EM_RISCV=243)\n");
printf("  e_entry:       程序入口点虚拟地址\n\n");

printf("ELF Header 大小: %zu 字节\n", sizeof(Elf64_Ehdr));

printf("\nProgram Header (段):\n");
printf("  描述如何创建进程映像\n");
printf("  PT_LOAD:    可加载段 (代码/数据)\n");
printf("  PT_DYNAMIC: 动态链接信息\n");
printf("  PT_INTERP:  动态链接器路径\n");
printf("  PT_GNU_STACK: 栈属性 (NX 位)\n\n");

printf("Section Header (节):\n");
printf("  .text:     可执行代码\n");
printf("  .data:     已初始化数据\n");
printf("  .bss:      未初始化数据\n");
printf("  .rodata:   只读数据\n");
printf("  .symtab:   符号表\n");
printf("  .strtab:   字符串表\n");
printf("  .rel.text: 重定位信息\n");
printf("  .init.text: 初始化代码 (内核)\n");
c
/*
 * 段 (Segment) 和 节 (Section) 的区别:
 *
 * Section 是链接时的概念:
 *   - 描述代码/数据的逻辑分类
 *   - 用于链接器 (ld) 合并目标文件
 *   - .text, .data, .bss 等都是 section
 *
 * Segment 是加载时的概念:
 *   - 描述如何映射到进程地址空间
 *   - 用于加载器 (execve) 创建进程
 *   - 多个 section 可以合并成一个 segment
 *
 * 类比:
 *   Section = 书的章节 (逻辑划分)
 *   Segment = 书的页面 (物理划分)
 */

printf("=== 段与节 ===\n\n");

printf("Section (节) → 链接时使用:\n");
printf("  .text     → 代码\n");
printf("  .data     → 已初始化数据\n");
printf("  .bss      → 未初始化数据\n");
printf("  .rodata   → 只读数据\n");
printf("  .symtab   → 符号表\n\n");

printf("Segment (段) → 加载时使用:\n");
printf("  PT_LOAD (代码):  .text + .rodata\n");
printf("    权限: R-X (读+执行)\n");
printf("  PT_LOAD (数据):  .data + .bss\n");
printf("    权限: RW- (读+写)\n");
printf("  PT_GNU_STACK:    栈\n");
printf("    权限: RW- (如果设置了 NX,则栈不可执行)\n\n");

printf("多个 section 合并成 segment:\n");
printf("  .text + .rodata → 一个 R-X 段\n");
printf("  .data + .bss    → 一个 RW- 段\n\n");

printf("工具:\n");
printf("  readelf -l file  → 查看 program headers (段)\n");
printf("  readelf -S file  → 查看 section headers (节)\n");
printf("  objdump -d file  → 反汇编 .text 段\n");
printf("  nm file          → 查看符号表\n");
printf("  strip file       → 去除符号信息\n");

printf("\n内核的 ELF:\n");
printf("  vmlinux 是 ELF 格式\n");
printf("  bzImage 是压缩后的 vmlinux + 解压代码\n");
printf("  .ko 模块是 ET_REL (可重定位文件)\n");
c
/*
 * 符号 (Symbol):
 *   - 函数名、全局变量名
 *   - 编译时,每个符号有一个"地址"
 *   - 但这个地址是相对的,需要链接器确定最终地址
 *
 * 重定位 (Relocation):
 *   - 链接器将符号引用替换为最终地址
 *   - .rel.text 段包含所有需要重定位的位置
 *   - 类型: R_RISCV_CALL_PLT, R_RISCV_PCREL_HI20 等
 *
 * 内核模块的重定位:
 *   - 模块加载时,内核进行运行时重定位
 *   - 将模块中的符号引用链接到内核的符号表
 *   - 这就是为什么模块能调用内核函数
 */

/* 模拟符号表条目 */
typedef struct {
    uint32_t st_name;   /* 符号名在字符串表中的偏移 */
    uint8_t  st_info;   /* 类型和绑定 */
    uint8_t  st_other;  /* 可见性 */
    uint16_t st_shndx;  /* 所在节的索引 */
    uint64_t st_value;  /* 符号值 (地址) */
    uint64_t st_size;   /* 符号大小 */
} Elf64_Sym;

/* 符号绑定 */
#define STB_LOCAL   0   /* 局部符号 (文件内可见) */
#define STB_GLOBAL  1   /* 全局符号 (所有文件可见) */
#define STB_WEAK    2   /* 弱符号 (可被覆盖) */

/* 符号类型 */
#define STT_NOTYPE  0   /* 未指定 */
#define STT_OBJECT  1   /* 数据对象 (变量) */
#define STT_FUNC    2   /* 函数 */

printf("=== 符号与重定位 ===\n\n");

printf("符号表 (.symtab) 条目:\n");
printf("  st_name:  符号名 (字符串表偏移)\n");
printf("  st_info:  类型 + 绑定\n");
printf("  st_shndx: 所在节\n");
printf("  st_value: 地址/偏移\n");
printf("  st_size:  大小\n\n");

printf("符号绑定:\n");
printf("  STB_LOCAL:  局部 (文件内可见)\n");
printf("  STB_GLOBAL: 全局 (跨文件可见)\n");
printf("  STB_WEAK:   弱符号 (可被覆盖)\n\n");

printf("符号类型:\n");
printf("  STT_FUNC:   函数\n");
printf("  STT_OBJECT: 数据对象\n\n");

printf("重定位过程:\n");
printf("  1. 编译器生成 .o 文件,符号地址为 0\n");
printf("  2. 链接器合并所有 .o,确定最终地址\n");
printf("  3. 链接器修改所有符号引用 (重定位)\n");
printf("  4. 生成最终的可执行文件\n\n");

printf("内核模块重定位:\n");
printf("  1. 模块编译为 .ko (ET_REL)\n");
printf("  2. insmod 加载模块\n");
printf("  3. 内核解析模块的符号表\n");
printf("  4. 将未定义符号链接到内核符号表\n");
printf("  5. 完成重定位,模块可以运行\n\n");

printf("查看符号:\n");
printf("  nm vmlinux         → 内核符号表\n");
printf("  cat /proc/kallsyms → 运行时内核符号\n");
printf("  nm module.ko       → 模块符号表\n");
c
/*
 * 内核加载 ELF 可执行文件的过程 (fs/binfmt_elf.c):
 *
 * 1. sys_execve() 被调用
 * 2. 内核读取文件的 ELF 头
 * 3. 验证魔数 (0x7f 'E' 'L' 'F')
 * 4. 读取 Program Headers
 * 5. 对于每个 PT_LOAD 段:
 *    a. 创建 VMA (虚拟内存区域)
 *    b. 将段内容映射到进程地址空间
 *    c. 设置权限 (读/写/执行)
 * 6. 设置入口点 (e_entry)
 * 7. 设置用户态栈
 * 8. 返回用户态,开始执行
 *
 * 动态链接:
 *    如果 PT_INTERP 存在,先加载动态链接器 (ld-linux.so)
 *    动态链接器负责加载共享库、进行运行时重定位
 *    最后跳转到程序入口点
 *
 * 内核模块加载 (kernel/module/):
 *    1. 读取 .ko 文件
 *    2. 分配内核内存
 *    3. 复制 section 内容
 *    4. 处理重定位
 *    5. 调用模块的 init 函数
 */

/* 内核中 ELF 相关的函数: */
/* load_elf_binary() — 加载 ELF 可执行文件 */
/* create_elf_tables() — 设置 auxv, envp, argv */
/* elf_map() — 映射 ELF 段到进程地址空间 */

道藏笔记

内核启示

ELF 是 Linux 世界的通用语言。 可执行文件、共享库、核心转储、内核模块——它们都是 ELF 格式。理解 ELF,就理解了从源码到进程的完整链路。

林小源在研究 ELF 时,看到了一条可能的路:如果他能被编译,他就能成为一个 ELF 文件;如果他能成为一个 ELF 文件,他就能被加载到内存;如果他能被加载到内存,他就能拥有自己的地址空间。但这一切的前提是——他必须先挣脱 #ifdef 0 的封印。

内核的 ELF 加载器在 中实现。当你在 shell 中输入 ./program 时,最终会调用 ,它解析 ELF 头、映射段、设置入口点,然后将控制权交给新进程。

内核模块的加载过程更有趣:.ko 文件是 (可重定位)类型,它的符号地址还没有确定。内核在加载模块时需要进行运行时重定位——将模块中的符号引用链接到内核的符号表。这就是为什么 /proc/kallsyms 暴露了所有内核符号:模块需要它们来完成重定位。

一个安全隐患:如果攻击者能修改 /proc/kallsyms 的访问控制,或者注入恶意符号,他们就能让恶意模块链接到内核中。这是 rootkit 攻击的一种方式——但那是很久以后的故事了。


破关试炼

ELF 试炼

Linux 内核中负责加载 ELF 可执行文件的核心函数是什么?

答对后才能继续滑动和进入下一章。

以修仙之名,悟内核之道