Nuttx - ELF Loader
一个 ELF 文件躺在文件系统里,NuttX 怎么把它变成一块可运行的内存、一个独立的任务?本文从
exec()开始,逐函数追踪——打开文件、解析 header、分配内存、修正符号引用,直到第一条用户指令执行。
阅读指南: 本文回答以下问题:NuttX 的 binfmt 框架如何识别和加载不同格式的二进制文件?ELF 文件的哪些信息被用来决定内存布局?重定位阶段如何将未定义符号链接到内核导出表?Kernel Build 模式下地址环境如何为每个进程创建独立的虚拟地址空间?读完后,你将能够从源码级别理解 NuttX ELF 加载的完整链路,具备独立调试 ELF 加载失败问题的能力,并能为新架构移植重定位支持。
1. 引言:为什么需要 ELF 加载器
嵌入式 RTOS 传统上将所有代码静态链接为一个单体镜像烧写到 Flash。这意味着每次修改一个应用都要重新编译整个系统。NuttX 的 ELF 加载器打破了这个限制——它允许在运行时从文件系统加载独立编译的 ELF 可执行文件,像 Linux 一样用 exec() 启动新进程。
NuttX 官方文档(Documentation/components/binfmt.rst)这样描述 Binary Loader 的设计意图:
The purpose of a binary loader is to load and execute modules in various binary formats that reside in a file system. Loading refers to instantiating the binary module in some fashion, usually copy all or some of the binary module into memory and then linking the module with other components.
要实现这个目标,ELF 加载器需要解决四个核心问题:
- 格式识别:如何判断文件是合法的 ELF 二进制?
- 内存布局:代码段和数据段分别放在哪里、分配多大空间?
- 符号链接:ELF 中引用的内核函数(如
printf、open)地址在哪? - 地址隔离:Kernel Build 模式下,每个进程如何拥有独立虚拟地址空间?
接下来从 binfmt 框架的整体架构开始,逐步深入每个环节。
2. binfmt 框架:注册与分发
NuttX 支持多种二进制格式(ELF、NXFLAT、Builtin),它们通过统一的 binfmt 框架注册和分发。这个框架的核心是一个链表——每种格式注册一个 handler,加载时依次尝试。
2.1 数据结构
文件:include/nuttx/binfmt/binfmt.h:121-137
1 | struct binfmt_s |
每个二进制格式只需实现两个回调:load 负责识别并加载文件,unload 负责卸载时释放资源。框架通过全局链表 g_binfmts 管理所有已注册的 handler。
文件:binfmt/binfmt_globals.c:47
1 | FAR struct binfmt_s *g_binfmts; |
2.2 ELF 格式注册
文件:binfmt/elf.c:73-78
1 | static struct binfmt_s g_elfbinfmt = |
系统启动时,binfmt_initialize() 调用 elf_initialize() 将 ELF handler 注册到链表头部:
文件:binfmt/elf.c:274-289
1 | int elf_initialize(void) |
register_binfmt() 的实现极为简单——头插法加入链表:
文件:binfmt/binfmt_register.c:57-71
1 | int register_binfmt(FAR struct binfmt_s *binfmt) |
2.3 加载分发流程
当用户调用 exec() 时,框架遍历 g_binfmts 链表,逐个尝试每个 handler 的 load 回调。第一个返回 OK 的 handler 获胜,后续 handler 不再尝试。
文件:binfmt/binfmt_loadmodule.c:101(load_absmodule())
1 | for (binfmt = g_binfmts; binfmt; binfmt = binfmt->next) |
如果 ELF handler 发现文件不是 ELF 格式(magic 不匹配),会返回 -ENOEXEC,框架继续尝试下一个 handler。这种设计让多种格式可以和平共存。
了解了框架如何找到 ELF handler 并调用它,下面进入 ELF 加载的第一步——初始化与头部验证。
3. 初始化:打开文件、验证 ELF 头
注册在 2.2 节的 elf_loadbinary() 是 ELF 加载的总入口。它的内部结构非常清晰——三个大阶段:
1 | static int elf_loadbinary(FAR struct binary_s *binp, ...) |
本章展开阶段 1——libelf_initialize() 打开文件并验证 ELF 头。
文件:libs/libc/elf/elf_init.c:99-158
1 | int libelf_initialize(FAR const char *filename, |
这段代码完成三件事:(1) 打开文件并获取文件大小/UID/GID/权限;(2) 从偏移 0 读取 52 字节的 ELF 头(32 位系统);(3) 验证头部合法性。
3.1 ELF 头结构:Elf32_Ehdr
Loadinfo->ehdr 的类型是 Elf32_Ehdr,定义在 include/elf32.h:75-91:
1 | typedef struct |
e_ident 16 字节魔数区布局:
| 偏移 | 名称 | 合法值 | 含义 |
|---|---|---|---|
| 0 | EI_MAG0 |
0x7f |
ELF 魔数首字节 |
| 1 | EI_MAG1 |
'E' |
|
| 2 | EI_MAG2 |
'L' |
前三字节合为 "\x7fELF" |
| 3 | EI_MAG3 |
'F' |
|
| 4 | EI_CLASS |
ELFCLASS32(1) / ELFCLASS64(2) |
32 位还是 64 位 |
| 5 | EI_DATA |
ELFDATA2LSB(1) / ELFDATA2MSB(2) |
小端还是大端 |
| 6 | EI_VERSION |
EV_CURRENT(1) |
ELF 版本 |
| 7 | EI_OSABI |
ELFOSABI_NONE(0) / ELFOSABI_LINUX(3) |
目标 OS ABI |
| 8 | EI_ABIVERSION |
— | ABI 次版本 |
| 9-15 | EI_PAD |
— | 保留填充 |
加载阶段的关键字段:
- **
e_type**:决定是重定位(.o)、可执行还是共享库,libelf_verifyheader()校验 - **
e_machine**:up_checkarch()确认指令集(ARM = 40) - **
e_entry**:Kernel Build 模式下的进程入口 VA,任务创建时写入 PC - **
e_phoff/e_phnum**:段加载阶段定位 Program Header,决定代码段和数据段在内存中的位置 - **
e_shoff/e_shnum**:重定位阶段定位.rel.text、.symtab、.strtab等 Section
3.2 ET_REL vs ET_EXEC:两种格式的本质区别(选读)
e_type 不是简单的标签——它决定了二进制文件中的地址是”占位值”还是”最终值”,进而决定了加载器的工作量。
以 printf("hello") 调用的 ARM 代码为例,同一个 helloworld 程序编译为两种格式后:
ET_REL(默认,ld -r 部分链接):
1 | .text 中的调用指令: |
ET_EXEC(完整链接):
1 | .text 中的调用指令: |
| 差异点 | ET_REL | ET_EXEC |
|---|---|---|
| 链接方式 | ld -r 部分链接,基址为 0 |
ld 完整链接,基址为 TEXT_VBASE |
| 指令中的地址 | 占位值(0 或基址偏移) | 最终绝对地址 |
| 重定位表 | 有 .rel.text,加载器需处理 |
无(或极小) |
| 加载器工作 | 分配内存 + 查符号 + 修改指令 | 只做 MMU 映射 |
| 加载位置 | 放任意地址(地址是相对的) | 必须放固定 VA(需 MMU) |
printf 的地址从哪来?对于 ET_EXEC,链接器把用户空间的 libc.a 直接静态链接进 app,所以 printf 就在 app 自己的地址空间里。内核服务通过 libproxies.a 中的 svc 系统调用代理间接访问——app 不知道内核任何地址。ET_REL 则相反:未解析的符号留在重定位表中,由加载器在运行时查询内核导出表补全。
3.3 ELF 文件布局:Section 与 Program Header(选读)
ELF 文件有两种不同的”视图”,服务于两个不同阶段:
| Section(链接视图) | Segment(执行视图) | |
|---|---|---|
| 描述者 | Section Header Table (e_shoff) |
Program Header Table (e_phoff) |
| 服务对象 | 链接器(ld) |
加载器(exec()) |
| 粒度 | 按功能细分:.text、.data、.symtab… |
按权限合并:代码段、数据段… |
| 存在条件 | 总是存在(即使不加载) | 仅 ET_EXEC / ET_DYN 有;ET_REL 不存在 |
常用 Section:
| Section | 类型 | 内容 |
|---|---|---|
.text |
PROGBITS + AX |
可执行代码 |
.data |
PROGBITS + WA |
已初始化的全局变量 |
.bss |
NOBITS + WA |
未初始化的全局变量(不占文件空间) |
.rodata |
PROGBITS + A |
只读数据(字符串常量) |
.symtab |
SYMTAB |
符号表(函数名→地址映射) |
.strtab |
STRTAB |
字符串表(符号名存储池) |
.shstrtab |
STRTAB |
Section 名称字符串表 |
.rel.text |
REL / RELA |
对 .text 的重定位记录 |
.init_array |
INIT_ARRAY |
C++ 构造器函数指针数组 |
常用 Program Header(Segment)类型:
| 类型 | 含义 |
|---|---|
PT_LOAD |
需要加载到内存的段(最常见的类型) |
PT_DYNAMIC |
动态链接信息(.dynamic section 的位置) |
PT_INTERP |
动态链接器路径(/lib/ld-linux.so.2) |
PT_PHDR |
Program Header 表自身的位置和大小 |
PT_GNU_STACK |
栈是否可执行(NX 标志) |
实例:同一个 hello.c 编译为 ET_REL 和 ET_EXEC
ET_REL(hello.o)有 Section Header 但 没有 Program Header:
1 | $ readelf -S hello.o |
注意所有 Section 的 Address 都是 00000000——这是 ET_REL 的”未定位”状态。
ET_EXEC(hello.exec)则两者都有,Program Header 尤其关键:
1 | $ readelf -S hello.exec $ readelf -l hello.exec |
Program Header 的”Section to Segment mapping”展示了合并关系——多个 Section 被打包进同一个 PT_LOAD 段:
1 | Segment Sections... |
为什么 ET_REL 不需要 Program Header?因为 ET_REL 的加载器不看 Segment,而是直接遍历 Section Header——它需要找到 .symtab(符号表)、.strtab(字符串表)、.rel.text(重定位表)来做符号解析,这些信息全在 Section 里。ET_EXEC 用 Program Header 是因为它不需要重定位:加载器只需要根据 PT_LOAD 段把代码和数据映射到内存即可。
3.4 ELF 头验证
读取 Elf32_Ehdr 之后,libelf_verifyheader() 校验上述关键字段:
文件:libs/libc/elf/elf_verify.c:64-102
1 | int libelf_verifyheader(FAR const Elf_Ehdr *ehdr) |
验证分三层:
- Magic 校验:前 4 字节必须是
\x7fELF - 类型校验:必须是
ET_REL(可重定位)、ET_DYN(共享对象)或ET_EXEC(可执行文件)之一 - 架构校验:调用架构特定的
up_checkarch()确认e_machine、字长和字节序匹配
实例:ARMv7-A 上的架构校验
文件:libs/libc/machine/arm/armv7-a/arch_elf.c:56-104
对于 qemu-armv7a 板级,up_checkarch() 检查:
e_machine == EM_ARM(值为 40)e_ident[EI_CLASS] == ELFCLASS32(32 位 ELF)e_ident[EI_DATA] == ELFDATA2LSB(小端序)e_entry对齐:Thumb 模式要求 2 字节对齐,ARM 模式要求 4 字节对齐
如果加载一个 x86_64 的 ELF 文件到 ARMv7-A,up_checkarch() 会在 e_machine 检查处失败,返回 -ENOEXEC。
头部验证通过后,加载器知道这是一个合法的 ARM ELF 文件。下一步是将代码和数据实际加载到内存中。
4. 段加载:从文件到内存
验证通过后,elf_loadbinary() 调用 libelf_load_with_addrenv()(或 libelf_load())进入段加载阶段。这一阶段要解决三个问题:代码段多大?数据段多大?分别放到哪块内存?
段加载不是一个函数完成的,而是由 libelf_load() 串联起四个子步骤。文件:libs/libc/elf/elf_load.c:554-685
4.1 调用总览:libelf_load()
1 | static int libelf_load(FAR struct mod_loadinfo_s *loadinfo) |
KERNEL 模式(
CONFIG_ARCH_ADDRENV=y)走的是libelf_load_with_addrenv(),流程相同,但 Step 3 的lib_memalign()替换为up_addrenv_create()——分配物理页并映射到固定虚拟地址(详见第 7 节”地址环境”)。
4.2 加载 Section/Program Headers
第一步是把 ELF 文件中的 Section Header Table 和 Program Header Table 读到内存。libelf_loadhdrs() 负责这件事。文件:libs/libc/elf/elf_loadhdrs.c:58-141
1 | int libelf_loadhdrs(FAR struct mod_loadinfo_s *loadinfo) |
shdr[] 和 phdr[] 存储在 mod_loadinfo_s 中(include/nuttx/lib/elf.h:220-221),是后续所有步骤的数据来源:
| 后续步骤 | 用 shdr[] | 用 phdr[] |
|---|---|---|
libelf_elfsize() |
遍历 section 统计 text/data 大小 | ET_DYN 分支遍历 PT_LOAD |
libelf_loadfile() |
按 section 将 .text/.data 拷到内存 | 不用 |
libelf_bind() |
查找 .symtab、.strtab、.rel.text |
不用 |
两类 header 的读取条件不同:Section Header Table 总是存在(e_shnum ≥ 1),而 Program Header Table 只在 ET_EXEC / ET_DYN 中存在(ET_REL 的 e_phnum = 0,phdr 置为 NULL)。
4.3 计算内存需求
文件:libs/libc/elf/elf_load.c:151-246
1 | static void libelf_elfsize(FAR struct mod_loadinfo_s *loadinfo, bool alloc) |
逻辑非常清晰:遍历所有 SHF_ALLOC 段,按 SHF_WRITE 标志分为 text(只读/可执行)和 data(可写)两类,分别累加对齐后的大小。
对于 ET_DYN(动态共享对象),则基于 Program Header 的 PT_LOAD 段计算,按 PF_X(可执行)标志区分。
4.4 内存分配
libelf_elfsize() 计算出 textsize/datasize 后,紧接着在 libelf_load() 中分配内存。文件:libs/libc/elf/elf_load.c:587-651
1 | /* ===== 分支 1:ET_REL 和 ET_EXEC —— 分别分配 text 和 data ===== */ |
关键差异在于 text 和 data 是否分开分配:
| ET_REL / ET_EXEC | ET_DYN | |
|---|---|---|
| 分配方式 | text 和 data 分别 lib_memalign() |
text + data 一次性 lib_memalign() |
| datastart | 独立地址,可能离 text 很远 | textalloc + textsize + segpad,紧挨 text |
| 原因 | 地址由重定位修正,不需要固定偏移 | GOT 中的偏移依赖 text-data 之间固定距离,分开会导致所有 GOT 条目失效 |
PIC 与 GOT(选读)—— 为什么 ET_DYN 必须连续分配
PIC(Position-Independent Code,位置无关代码) 是指无论加载到内存的哪个地址,不修改代码段就能正确运行的代码。ET_DYN(动态共享对象)必须用 PIC,因为多个进程可能同时加载同一个 .so 到不同的虚拟地址——没法像 ET_EXEC 那样预知地址。
PIC 的代价是不能直接在代码里写绝对地址。ARM 的做法是:在数据段放一张 GOT(Global Offset Table,全局偏移表),代码通过 PC 相对寻址找到 GOT 条目,间接获取目标地址:
1 | 代码段: 数据段 (GOT): |
GOT 条目在链接时计算的是相对于加载基址的偏移,不是绝对地址。链接器假设 text 和 data 紧挨在一起:
1 | 【链接时 — 链接器看到的布局,也是写入 GOT 的"承诺"】 |
连续分配(ET_DYN 用的方式)—— 偏移仍然正确:
1 | text=0x80100000 GOT=0x80101000 data=0x80102000 |
分开分配(如果错误地这样做了)—— 偏移失效:
1 | text=0x80100000 GOT=0x80101000 |
一句话总结:GOT 存的不是绝对地址,是”数据在 GOT 后面多远”。这个”多远”链接时就定了,加载时必须保持 text-data 间距不变。 所以 ET_DYN 必须一次性连续分配,不能把 text 和 data 分开 lib_memalign()。
ET_REL 不需要这个——ET_REL 根本不依赖 GOT,所有符号引用都有对应的 .rel.text 重定位表记录,加载器会逐条修正。ET_EXEC 也不需要——ET_EXEC 已经完整链接,代码里是绝对地址,没有 GOT。
为什么 ET_EXEC 不在这一步用固定 VA? 因为此处代码运行在
#ifndef CONFIG_LIBC_ELF_LOADTO_LMA且#ifndef CONFIG_ARCH_ADDRENV路径下。CONFIG_ARCH_ADDRENV=y(KERNEL 模式)时走的是另一条路径——libelf_addrenv_alloc()调用up_addrenv_create()分配物理页并映射到TEXT_VBASE/DATA_VBASE固定虚拟地址(详见第 7 节”地址环境”)。
下图展示了 ELF 文件从磁盘到虚拟地址空间再到物理内存的映射关系:

4.5 将段数据读入内存
文件:libs/libc/elf/elf_load.c:331-526
分配完内存后,libelf_loadfile() 将文件中的段内容复制到已分配的内存区域(以下为关键逻辑摘录,省略了 CONFIG_ARCH_USE_SEPARATED_SECTION、CONFIG_LIBC_ELF_LOADTO_LMA 等条件分支):
1 | static inline int libelf_loadfile(FAR struct mod_loadinfo_s *loadinfo) |
关键细节:
SHT_NOBITS段(BSS)不需要从文件读取——只需memset清零。BSS 段在 ELF 文件中不占空间,但运行时需要实际内存。sh_addr被覆写为内存地址——后续重定位阶段需要知道每个段在内存中的实际位置,而不是文件中的 VMA。原始 VMA 保存在sh_offset中。- 指针按对齐递进——每加载完一个段,text/data 指针前进到下一个对齐边界,确保后续段的对齐要求被满足。
段加载完成后,代码和数据已在内存中就位,但其中的符号引用仍指向错误的地址。下一步是符号绑定与重定位。
5. 符号绑定与重定位
段加载完成后,ELF 文件中的函数调用和全局变量引用仍然使用编译时的占位值。重定位阶段的任务是:找到所有需要修改的位置,查询符号的真实地址,然后修补(patch)指令或数据字。
5.1 何时需要绑定
文件:binfmt/elf.c:125-149
1 | if (loadinfo.ehdr.e_type == ET_REL || loadinfo.gotindex >= 0) |
- **
ET_REL**(可重定位文件):必须绑定,入口点 = textalloc + e_entry - **
ET_DYN**(有 GOT):必须绑定,入口点 = textalloc + e_entry - **
ET_EXEC**(完全链接):不需要绑定,入口点直接使用 e_entry 绝对地址
5.2 绑定主流程
文件:libs/libc/elf/elf_bind.c:915
libelf_bind() 是符号绑定的入口。简化后的流程:
1 | libelf_bind() |
这条流程的核心设计是”地址空间包裹”:绑定操作的前后各有一次地址空间切换。进入时 addrenv_select() 使 CPU 能访问目标进程的虚拟页以修补指令;退出时 addrenv_restore() 恢复内核地址空间。中间的 libelf_relocate() 循环是计算密集型操作——遍历所有重定位段,对每个条目执行符号查找 + 架构特定修补 + GOT 更新。最后 up_coherent_dcache() 确保 I-cache 能看到修补后的指令,防止 CPU 从旧缓存行取指。
5.3 符号解析:libelf_symvalue()
在 5.2 的流程中,libelf_relocate() 处理每条重定位条目时,第一步就是调用 libelf_symvalue()——把符号名(如 printf)查表解析为内存地址(如 0x40001000),然后才交给 up_relocate() 修补指令。本节展开符号解析这一步。
文件:libs/libc/elf/elf_symbols.c:340-457
解析策略基于 sym->st_shndx:
st_shndx |
含义 | 处理方式 |
|---|---|---|
SHN_ABS |
绝对符号 | 直接使用 st_value,无需修改 |
SHN_UNDEF |
未定义符号(外部引用) | 在内核导出表 + 已加载模块中查找 |
| 其他值 | 定义在某个段中 | st_value += shdr[st_shndx].sh_addr(加上段基址) |
实例:三种 st_shndx 的解析结果
假设 hello.elf 加载后 .text 段基址为 0x80000000,.data 段基址为 0x80100000:
- 符号
main:st_shndx = 3(.text 段索引),st_value = 0x1A0→ 解析后st_value = 0x80000000 + 0x1A0 = 0x800001A0 - 符号
printf:st_shndx = SHN_UNDEF,st_value = 0→ 从内核导出表查到printf = 0x40001000,解析后st_value = 0x40001000 - 符号
__aeabi_unwind_cpp_pr0:st_shndx = SHN_ABS,st_value = 0→ 不修改,保持st_value = 0
对于 SHN_UNDEF(未定义符号),解析顺序为:
- 遍历已加载模块的导出表:
libelf_registry_foreach(libelf_symcallback, ...) - 搜索内核全局符号表:
symtab_findbyname(exports, name, nexports)
如果两层查找都失败,返回 -ENOENT,整个加载过程失败。 这意味着:如果你的 ELF 程序调用了一个内核没有导出的函数,加载时就会报错,而不是运行时崩溃。
与 Linux 的关键区别: Linux 使用动态链接器(ld.so)在用户空间完成符号解析。NuttX 在内核中一次性完成——没有独立的动态链接器进程。这使得加载更简单快速,但也意味着所有需要的符号必须在加载时就可用。
5.4 重定位处理:libelf_relocate()
文件:libs/libc/elf/elf_bind.c:180
libelf_relocate() 处理 SHT_REL 类型的重定位段。ARM 使用 REL(不带 addend),addend 嵌入在指令编码中。
核心循环的关键步骤(摘录自 elf_bind.c:306-411,省略了批量读取和缓存管理代码):
1 | for (i = 0; i < nrels; i++) |
每次重定位都涉及一次符号查找和一次架构特定的指令修补。为了优化性能,NuttX 使用 LRU 缓存(大小由 CONFIG_LIBC_ELF_SYMBOL_CACHECOUNT 控制)避免重复读取相同符号。
下图详细展示了单次重定位处理的内部交互,包括 LRU 缓存查询、三种 st_shndx 的符号解析路径,以及 ARM 架构的指令修补分支:

符号绑定完成后,所有指令中的地址引用都已指向正确位置。但具体的指令修补逻辑是高度架构相关的——下面以 ARM 为例详细展示。
6. ARM 架构重定位实现
ARM 的重定位处理在 libs/libc/machine/arm/armv7-a/arch_elf.c 中实现。这个文件的核心是 up_relocate() 函数——一个 477 行的 switch-case,处理 ARM 指令集特有的各种编码格式。
6.1 支持的重定位类型总览
| 重定位类型 | ID | 作用 | 典型场景 |
|---|---|---|---|
R_ARM_NONE |
0 | 无操作 | 占位 |
R_ARM_PC24 / R_ARM_CALL / R_ARM_JUMP24 |
1/28/29 | ARM 24-bit PC-relative 分支 | bl function / b label |
R_ARM_ABS32 / R_ARM_TARGET1 |
2/38 | 32-bit 绝对地址 | 全局变量引用、函数指针 |
R_ARM_V4BX |
40 | BX → MOV PC 转换 | ARMv4 兼容 |
R_ARM_PREL31 |
42 | 31-bit PC-relative | 异常表 (.ARM.exidx) |
R_ARM_MOVW_ABS_NC / R_ARM_MOVT_ABS |
43/44 | ARM MOVW/MOVT 立即数 | 32-bit 地址加载(MOVW+MOVT 对) |
R_ARM_THM_MOVW_ABS_NC / R_ARM_THM_MOVT_ABS |
47/48 | Thumb MOVW/MOVT 立即数 | Thumb 模式 32-bit 地址加载 |
R_ARM_THM_CALL / R_ARM_THM_JUMP24 |
10/30 | Thumb 24-bit BL/B.W | Thumb 模式函数调用 |
6.2 R_ARM_ABS32:最简单的重定位
文件:libs/libc/machine/arm/armv7-a/arch_elf.c:198-208
1 | case R_ARM_ABS32: |
这是最直观的重定位:目标地址处有一个 32-bit 数据字(通常是编译器放入的占位值 0 或段内偏移),直接加上符号的绝对地址即可。
实例:全局变量引用
假设 ELF 中有一个对内核导出符号 g_uart_base 的引用:
- 重定位前:
*(uint32_t *)0x80000100 = 0x00000000(占位零值) sym->st_value = 0x40009000(UART 寄存器基址,从内核符号表查到)- 重定位后:
*(uint32_t *)0x80000100 = 0x40009000
6.3 R_ARM_CALL / R_ARM_JUMP24:ARM 分支指令重定位
文件:libs/libc/machine/arm/armv7-a/arch_elf.c:159-196
1 | case R_ARM_PC24: |
ARM BL/B 指令编码:高 8 位是条件码+操作码,低 24 位是有符号字偏移(实际字节偏移 = 字偏移 * 4)。算法步骤:
- 提取原始偏移:从指令低 24 位取出,左移 2 位得到字节偏移,符号扩展
- 计算新偏移:
new_offset = old_offset + symbol_address - relocation_address - 范围检查:ARM 分支范围为 +/-32MB(26 位有符号),超出报错
- 回写指令:右移 2 位后写入低 24 位
实例:BL printf 重定位
假设:
- 重定位地址
addr = 0x80000040(ELF .text 中的 BL 指令位置) - 指令原始值
0xEBFFFFFE(BL -8,编译时的占位值) sym->st_value = 0x40001000(printf 在内核中的地址)
计算过程:
1 | Step 1: offset = (0x00FFFFFE) << 2 = 0x03FFFFF8 |
如果跳转目标超出 +/-32MB 范围,up_relocate() 返回 -EINVAL,加载失败。这是 ARM 架构的硬限制——如果用户程序虚拟地址与内核符号距离超过 32MB,需要使用 veneer(跳板)或不同的重定位方式。
6.4 R_ARM_THM_CALL:Thumb 分支重定位
文件:libs/libc/machine/arm/armv7-a/arch_elf.c:340-467
Thumb 的 BL 是一条 32-bit 指令(由两个 16-bit halfword 组成),分支偏移编码在 S/J1/J2/imm10/imm11 五个字段中。解码公式:
1 | offset[24] = S |
回写时的编码公式:
1 | S = offset[24] |
分支范围为 +/-16MB(25 位有符号)。此外,如果目标符号类型是 STT_FUNC,偏移必须是奇数(bit[0]=1),表示目标是 Thumb 代码——这是 ARM-Thumb 互操作的要求。
6.5 R_ARM_MOVW_ABS_NC / R_ARM_MOVT_ABS:立即数加载
文件:libs/libc/machine/arm/armv7-a/arch_elf.c:237-258
ARM 加载 32-bit 常量通常用 MOVW+MOVT 指令对:MOVW 加载低 16 位,MOVT 加载高 16 位。16-bit 立即数编码在指令的 imm4(bit[19:16]) 和 imm12(bit[11:0]) 字段中。
1 | case R_ARM_MOVW_ABS_NC: |
实例:MOVW + MOVT 加载 0x40009000
1 | MOVW R0, #0x9000 → R_ARM_MOVW_ABS_NC, sym=0x40009000 |
重定位完成后,R0 将包含完整的 32-bit 地址 0x40009000。
了解了重定位如何修补指令,下面看 Kernel Build 模式下地址环境如何为每个进程提供独立的虚拟地址空间。
7. 地址环境:进程级虚拟地址隔离
在 CONFIG_BUILD_KERNEL 模式下,每个用户进程拥有独立的虚拟地址空间。ELF 加载器需要为新进程创建 MMU 页表,分配物理页,并建立虚拟地址映射。这由 libelf_addrenv_* 系列函数完成。
7.1 分配地址环境
文件:libs/libc/elf/elf_addrenv.c:83-143
1 | int libelf_addrenv_alloc(FAR struct mod_loadinfo_s *loadinfo, |
up_addrenv_create() 是架构特定函数,在 ARMv7-A 上会:
- 分配 L1 页表(16KB,4096 个条目)
- 为 text/data/heap 区域分配 L2 页表
- 从 page pool 分配物理页并填充页表条目
7.2 临时激活地址空间
加载段数据和执行重定位时,需要写入新进程的虚拟地址。但此时 CPU 运行在内核地址空间中,新进程的页表尚未激活。libelf_addrenv_select() 解决这个问题:
文件:libs/libc/elf/elf_addrenv.c:159-183
1 | int libelf_addrenv_select(FAR struct mod_loadinfo_s *loadinfo) |
关键设计:.text 段运行时应该是只读+可执行(W^X 安全原则),但加载阶段需要写入数据和修补重定位。解决方案是临时赋予 .text 写权限(PROT_READ | PROT_WRITE | PROT_EXEC),加载完成后恢复为只读+可执行(PROT_READ | PROT_EXEC)。
ARMv7-A 实现细节:
up_addrenv_mprot()在arch/arm/src/armv7-a/arm_addrenv_perms.c中是空函数——直接return OK。这是因为 ARMv7-A 创建页表时(up_addrenv_create()),.text页的 AP 位已设为PTE_AP_RW12_R0(内核可读写、用户只读),CPU 在内核特权态下天然就能写.text,无需运行时改页表。libelf_addrenv_select()调用mprotect是为了 API 兼容性(其他架构可能真的需要),但 ARMv7-A 上这个调用不做任何事。
7.3 恢复地址空间
文件:libs/libc/elf/elf_addrenv.c:200-224
1 | int libelf_addrenv_restore(FAR struct mod_loadinfo_s *loadinfo) |
如果没有地址环境会怎样? 在 FLAT build 模式下(无 MMU 或不启用地址隔离),所有进程共享同一地址空间。此时 libelf_load() 直接用 lib_memalign() 从内核堆分配内存,无需页表操作。代码更简单,但没有进程间内存保护。
段加载和重定位都完成后,ELF 文件已经成为一段可执行的内存映像。最后一步是创建任务并让它运行起来。
8. 任务创建与执行
elf_loadbinary() 返回后,控制回到 exec_internal(),后者调用 exec_module() 完成从”内存中的代码”到”可调度的任务”的转变。
文件:binfmt/binfmt_execmodule.c:152-354
8.1 关键步骤
以下是 exec_module() 的核心路径摘录(binfmt/binfmt_execmodule.c:182-354,省略了错误处理和条件编译分支):
1 | /* Allocate a TCB for the new task */ |
8.2 入口点确定
入口点在 elf_loadbinary() 中确定:
| 条件 | 入口点计算 | 说明 |
|---|---|---|
ET_REL 或有 GOT |
textalloc + ehdr.e_entry |
e_entry 是相对于 .text 起始的偏移 |
ET_EXEC |
ehdr.e_entry 直接使用 |
完全链接,e_entry 是绝对虚拟地址 |
实例:qemu-armv7a 上加载 hello 程序
假设 hello ELF 的 e_entry = 0x000001A0,加载后 textalloc = 0x80000000:
- 入口点 =
0x80000000 + 0x000001A0 = 0x800001A0 - 这个值存入
tcb->entry.main(nxtask_init()→nxtask_setup_scheduler(),task_setup.c:423) - 但任务首次被调度时,
up_initial_state()设置的REG_PC是nxtask_start(内核 trampoline),不是用户入口 nxtask_start()在task_start.c:99调用tcb->entry.main(argc, argv),此时才真正跳转到0x800001A0- 该地址对应 hello 程序的
main()函数(或 C runtime 的_start)
8.3 exec_swap:进程替换
当通过 exec() 调用(而非 posix_spawn())时,新任务替换当前任务:
1 | if (!spawn) |
exec_swap() 交换两个 TCB 的 PID 和进程组信息,实现 POSIX 语义:exec() 后进程 PID 不变,但执行的代码完全不同。
8.4 任务激活
1 | nxtask_activate(tcb); |
这使新任务进入 ready-to-run 队列。当调度器下次选择它时,CPU 先执行 nxtask_start()(内核 trampoline),由它调用 tcb->entry.main 跳转到 ELF 入口点 0x800001A0。至此,一个文件系统中的 ELF 文件变成了一个独立运行的进程。
9. 完整调用链总览
从用户调用 exec("/system/bin/hello", ...) 到 hello 程序开始运行,完整时序如下图所示:

对应的文本形式调用链:
1 | exec() binfmt/binfmt_exec.c:266 |
这条完整链路体现了 NuttX ELF 加载的分层设计:binfmt 框架负责格式分发和任务创建(顶层 exec() / exec_module()),libelf 库负责 ELF 格式的通用解析(段加载、符号解析),而架构特定代码只在最底层的 up_relocate() 和 up_addrenv_create() 中出现。移植新架构时只需实现这两个函数——其余 900+ 行的加载逻辑完全复用。注意链路中有两次地址空间切换:一次在 libelf_load_with_addrenv() 中(写入段数据),一次在 libelf_bind() 中(修补重定位)。每次切换都遵循 select → 操作 → restore 的对称模式。
10. NuttX vs Linux vs FreeRTOS 对比
| 特性 | NuttX ELF Loader | Linux ELF Loader | FreeRTOS |
|---|---|---|---|
| 加载器位置 | 内核中 (binfmt) | 内核 + 用户空间 (ld.so) | 无内置(需第三方库) |
| 支持的 ELF 类型 | ET_REL / ET_DYN / ET_EXEC | ET_DYN / ET_EXEC | 通常仅 ET_REL |
| 动态链接器 | 无(内核一次性完成) | ld-linux.so(用户空间) | 无 |
| 地址隔离 | 可选 (CONFIG_ARCH_ADDRENV) | 必需 (MMU) | 无 |
| 符号表来源 | 编译时生成的静态导出表 | 共享库 .dynsym | 手动提供 |
| 重定位时机 | 加载时全部完成 | 加载时 + 延迟绑定 (PLT/GOT) | 加载时 |
| C++ 构造器 | 支持 (.init_array) | 支持 (.init_array + .init) | 依实现而定 |
| 代码大小 | ~3000 行 (libs/libc/elf/) | ~10000+ 行 (fs/binfmt_elf.c + ld.so) | N/A |
关键设计差异:
NuttX 没有独立的动态链接器。Linux 的
ld.so在用户空间运行,支持延迟绑定(PLT stub 第一次调用时才解析符号)。NuttX 在加载时就完成所有重定位——更简单、更确定性,但无法支持延迟绑定优化。NuttX 的符号表是静态的。内核导出哪些符号在编译时就决定了(通过
CONFIG_SYMTAB_ORDEREDBYNAME+ 编译生成的符号数组)。Linux 的共享库可以动态加载/卸载,符号表动态变化。NuttX 支持 ET_REL 加载。Linux 内核模块也用 ET_REL,但用户空间可执行文件必须是 ET_DYN/ET_EXEC。NuttX 三种都支持,给嵌入式开发提供了更大灵活性。
11. 关键要点
binfmt 框架是可扩展的——通过链表注册机制,ELF/NXFLAT/Builtin 三种格式和平共存,加载时自动识别。
ELF 加载分三阶段:初始化(验证头)→ 段加载(分配内存 + 复制数据)→ 绑定(符号解析 + 重定位)。每个阶段失败都会回滚前序操作。
重定位是架构强相关的——ARM 的 10+ 种重定位类型对应不同的指令编码格式。每种类型的提取/计算/回写逻辑完全不同。
Kernel Build 模式下每个 ELF 进程有独立页表——
libelf_addrenv_alloc()创建地址空间,.text 临时可写用于重定位,完成后恢复为只读+可执行。符号查找失败 = 加载失败——与 Linux 延迟绑定不同,NuttX 在加载时就验证所有外部引用。如果内核没有导出某个函数,加载立即报错
-ENOENT。up_coherent_dcache()不可省略——重定位修改了 .text 段的指令字,必须刷新 D-cache 并无效化 I-cache,否则 CPU 可能执行旧的(未修补的)指令。
12. 参考文件
| 文件 | 内容 | 关键行号 |
|---|---|---|
binfmt/elf.c |
ELF 格式注册、elf_loadbinary 入口 | 73-78, 93-238, 274-289 |
binfmt/binfmt_exec.c |
exec() / exec_internal() 入口 | 77, 266 |
binfmt/binfmt_globals.c |
g_binfmts 全局链表头 | 47 |
binfmt/binfmt_register.c |
register_binfmt() 链表操作 | 57-71 |
binfmt/binfmt_loadmodule.c |
load_module() 遍历格式链表 | 101, 160 |
binfmt/binfmt_execmodule.c |
exec_module() 创建任务 | 152-354 |
binfmt/binfmt_initialize.c |
启动时注册所有格式 | 49-78 |
libs/libc/elf/elf_init.c |
libelf_initialize() 打开+验证 | 99-158 |
libs/libc/elf/elf_verify.c |
libelf_verifyheader() 三层验证 | 64-102 |
libs/libc/elf/elf_load.c |
段大小计算 + 内存分配 + 段加载 | 151-246, 331-526, 545-777 |
libs/libc/elf/elf_bind.c |
libelf_bind() + libelf_relocate() | 180, 915 |
libs/libc/elf/elf_symbols.c |
libelf_symvalue() 符号解析 | 340-457 |
libs/libc/elf/elf_addrenv.c |
地址环境分配/激活/恢复 | 83-143, 159-183, 200-224 |
libs/libc/machine/arm/armv7-a/arch_elf.c |
ARM up_relocate() 所有重定位类型 | 56-104, 128-477 |
include/nuttx/binfmt/binfmt.h |
struct binary_s, struct binfmt_s | 63-137 |
include/nuttx/lib/elf.h |
struct mod_loadinfo_s | 196-254 |
include/elf32.h |
Elf32_Ehdr, Elf32_Shdr, Elf32_Rel, Elf32_Sym | 75-134 |
boards/arm/qemu/qemu-armv7a/configs/knsh/defconfig |
Kernel Build ELF 配置 | 全文 |
| 外部参考 | 内容 |
|---|---|
NuttX 官方文档 Documentation/components/binfmt.rst |
Binary Loader 设计意图、API 说明 |
| ARM ELF specification (ARM IHI 0044F) | ARM 重定位类型定义、指令编码 |
| ELF specification (Tool Interface Standard) | ELF 文件格式、段类型、符号绑定规则 |