第十章: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,代码才能变成一个可以运行的实体。
而他——林小源——连这座桥的入口都还没找到。
/*
* 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");#include <stdio.h>
#include <stdint.h>
/*
* 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;
int main() {
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");
return 0;
}/*
* 段 (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");#include <stdio.h>
/*
* 段 (Segment) 和 节 (Section) 的区别:
*
* Section 是链接时的概念:
* - 描述代码/数据的逻辑分类
* - 用于链接器 (ld) 合并目标文件
* - .text, .data, .bss 等都是 section
*
* Segment 是加载时的概念:
* - 描述如何映射到进程地址空间
* - 用于加载器 (execve) 创建进程
* - 多个 section 可以合并成一个 segment
*
* 类比:
* Section = 书的章节 (逻辑划分)
* Segment = 书的页面 (物理划分)
*/
int main() {
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");
return 0;
}/*
* 符号 (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");#include <stdio.h>
#include <stdint.h>
/*
* 符号 (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 /* 函数 */
int main() {
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");
return 0;
}/*
* 内核加载 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 可执行文件的核心函数是什么?