以下是对您提供的博文《基于ARM的可执行文件格式解析:ELF结构深度剖析》的全面润色与优化版本。本次改写严格遵循您的所有要求:
- ✅彻底去除AI痕迹:语言自然、有“人味”,像一位深耕嵌入式系统十余年的工程师在技术博客中娓娓道来;
- ✅打破模板化结构:删除所有“引言/概述/总结”等程式化标题,代之以逻辑递进、场景驱动的叙述流;
- ✅强化ARM平台真实感:融入大量一线开发经验(如裸机调试踩坑、内核加载失败排查、
readelf与objdump联用技巧); - ✅代码即文档:每段关键代码均附带“为什么这么写”的工程语境说明,而非孤立贴出;
- ✅去学术化,重实战性:不堆砌术语,而是讲清楚“这个字段不设对会怎样”、“那个标志位被忽略会导致什么异常”;
- ✅全文无总结段、无展望句、无参考文献列表,结尾落在一个具象的技术动作上,干净收束。
当readelf -l ./app输出第一行LOAD时,ARM 芯片其实已经准备好了页表
你有没有试过,在 Cortex-A9 开发板上烧写了一个看似完美的 Linux 可执行文件,却在execve()后收到SIGSEGV?
或者更隐蔽一点:程序能跑起来,但第一次调用malloc()就卡死在dl_runtime_resolve里,gdb连栈都看不到?
又或者,你在裸机环境手写一个启动跳转到.text段的汇编 stub,结果 CPU 直接进入Undefined Instruction异常——而objdump -d显示那条指令明明是合法的BL?
这些问题,80% 的根源不在 C 代码逻辑,也不在驱动初始化顺序,而藏在一个你每天都在生成、却极少打开细看的二进制文件里:ELF 可执行文件。
它不是一段静态数据,而是一份给 ARM MMU、Linux 内核加载器、动态链接器共同阅读的运行契约。这份契约的每一个字段,都在回答一个硬件级问题:
“这段代码,要放在哪?以什么权限放?怎么对齐?谁来修正地址?出错了该怪谁?”
下面,我们就从一次真实的execve("./hello")开始,一层层剥开这份契约——不讲标准定义,只讲它在 ARM 上真正起作用的样子。
加载器看到的第一张图:不是节(Section),而是段(Segment)
当你敲下./hello,Shell 调用execve(),内核做的第一件事,不是反汇编.text,也不是读符号表,而是定位并解析Program Header Table(程序头表)。
为什么?因为对加载器而言,节(.text,.data,.bss)根本不存在——那是链接器和调试器的世界。加载器只认一种东西:可映射的内存段(Segment),由PT_LOAD类型的程序头条目定义。
每个PT_LOAD条目,本质上是一条给 MMU 下达的指令:
“请把文件偏移
p_offset开始、长度为p_filesz的数据,映射到虚拟地址p_vaddr,映射总长p_memsz(含.bss零页),并按p_flags设置页表权限。”
这看起来简单,但在 ARM 上,它立刻撞上一道硬门槛:对齐。
ARMv7-A 的 Thumb-2 指令必须 2 字节对齐,但取指单元(Fetch Unit)内部按 4 字节块工作;ARMv8-A(AArch64)更进一步,ADR和ADRP指令的立即数编码粒度是12 位(4KB)和 21 位(2MB),意味着目标地址的低 12 位或低 21 位必须能被 PC 值精确覆盖。如果p_vaddr和p_offset不满足p_vaddr ≡ p_offset (mod p_align),内核就会在load_elf_binary()里直接return -ENOEXEC。
这不是警告,是拒绝加载。
你可以亲手验证:用arm-linux-gnueabihf-ld链接时加-Ttext=0x10001(故意错开 4B),再readelf -l ./app,会发现p_vaddr=0x10001,但p_align=0x1000,于是0x10001 % 0x1000 = 1,而p_offset % 0x1000几乎肯定是0—— 对齐校验失败,execve()返回-8(-ENOEXEC)。
所以,工具链默认设p_align=0x1000(4KB),不是为了“好看”,而是为了让mmap()创建的 VMA 能被 TLB 高效缓存,同时满足ADR/ADRP的寻址约束。如果你在资源极度受限的裸机系统里想把.text放到0x200000,没问题;但若放到0x200001,CPU 在取第一条指令时就可能触发Alignment Fault,哪怕那条指令本身完全合法。
这也是为什么你在内核源码fs/exec.c里能看到这样一段校验:
if ((phdr->p_vaddr & (phdr->p_align - 1)) != (phdr->p_offset & (phdr->p_align - 1))) { return -ENOEXEC; }它不是可选的健壮性检查,而是ARM 平台加载器的准入门槛。
符号不是给人看的,是给 PLT/GOT 和dl_runtime_resolve看的
当内核完成mmap(),把.text、.data映射进用户空间,控制权就交给了动态链接器/lib/ld-linux-armhf.so.3(ARM32)或/lib/ld-linux-aarch64.so.1(ARM64)。此时,链接器第一眼找的,不是.text的入口,而是.dynamic段。
.dynamic是一个Elf64_Dyn结构体数组,本质是一张键值表。其中最关键的几项是:
d_tag | 含义 | ARM 实战意义 |
|---|---|---|
DT_HASH | SysV hash 表地址 | dl_runtime_resolve查符号的起点,哈希桶数量影响首次调用延迟 |
DT_STRTAB | 动态字符串表(.dynstr)地址 | 所有依赖库名(libc.so.6)、函数名(printf)都存在这里 |
DT_SYMTAB | 动态符号表(.dynsym)地址 | dlsym()和 PLT 绑定的唯一依据,.symtab里的符号它根本看不见 |
DT_JMPREL | .rela.plt地址 | PLT stub 的“待办清单”,每调用一个外部函数,这里就有一项等着被填地址 |
注意:.dynsym和.symtab是两套符号表。前者是动态链接的“公开简历”,后者是调试器用的“完整档案”。static函数只在.symtab里,dlsym("my_helper")永远找不到它;而printf必须同时出现在.dynsym和.dynstr中,否则 PLT 第一次跳转就会失败。
这就解释了为什么strip ./app后程序还能跑,但gdb ./app却显示No symbol table——你只是删掉了.symtab,.dynsym还在,动态链接照常进行。
而.dynsym里的每个符号,其st_value(运行时地址)在 ARM64 上还有一个隐形约定:必须 16 字节对齐。因为 PLT stub 生成的ADR x16, #imm指令,其立即数imm是(target_addr >> 12) - (pc >> 12),如果target_addr低 4 位不为 0,计算出的imm就会溢出 21 位范围,导致非法指令。
所以,当你用readelf -s ./app | grep printf看到printf的Value是0x4005c0,别只记这个数字——心里得默念一句:“0x4005c0 & 0xF == 0,OK”。
PLT 不是跳转表,是 ARM64 的“地址搬运工”
说到 PLT(Procedure Linkage Table),很多人以为它就是一堆jmp *GOT[xx]。在 x86 上差不多是这样。但在 ARM64 上,PLT stub 是一段精心编码的机器指令序列,专为ADR/ADRP+ADD的两指令寻址模式设计。
比如,你的代码里写了printf("hello");,编译器不会直接BL printf(那需要知道printf的绝对地址,而它在加载前根本未知),而是生成:
bl plt_printf // 跳转到 PLT 第一项 ... plt_printf: adrp x16, #imm_hi // x16 = (base_addr >> 12) + imm_hi add x16, x16, #imm_lo // x16 = x16 << 12 | imm_lo → real printf addr br x16这个imm_hi和imm_lo从哪来?就来自.rela.plt表里对应的一项。重定位器(ld-linux)在首次调用时,查.dynsym找到printf的真实地址,然后按 ARM64 编码规则,拆解成imm_hi(21 位)和imm_lo(12 位),写回 PLT stub 的这两条指令里。
这就是为什么R_AARCH64_ADR_PREL_LO21和R_AARCH64_ADD_ABS_LO12_NC这两个重定位类型必须成对出现——它们不是独立的,而是一个地址拆解的上下半场。
你可以在objdump -dr ./app输出里亲眼看到这个过程:
00000000000102e8 <printf@plt>: 102e8: 90000010 adrp x16, 110000 <__libc_start_main@plt-0x10> 102ec: 91000010 add x16, x16, #0x0这里的#0x0就是imm_lo,而110000是imm_hi的编码结果。readelf -r ./app会告诉你,102e8处的重定位类型是R_AARCH64_ADR_PREL_LO21,102ec处是R_AARCH64_ADD_ABS_LO12_NC。
如果这两个重定位没被正确应用(比如你手动mmap了 ELF 却忘了调用relocate_plt()),那么x16就永远指向一个错误地址,br x16之后,就是一片静默的崩溃。
你写的链接脚本,正在悄悄决定 MMU 页表的形状
最后,回到最源头:你用arm-linux-gnueabihf-gcc -T mylink.ld生成可执行文件时,那个mylink.ld里的一行:
.text : { *(.text) } > RAM它不只是告诉链接器“把代码放一起”,而是在直接雕刻 MMU 的页表结构。
因为.text段最终会成为一个PT_LOAD段。它的p_vaddr(虚拟地址)、p_memsz(内存长度)、p_flags(权限)全部来自链接脚本中.text的定义。而p_vaddr和p_memsz共同决定了这个段会占用多少个 4KB 页,p_flags则决定了每个页的 AP(Access Permission)位如何设置。
例如,如果你写:
.text ALIGN(16) : { *(.text) } > RAM链接器就会确保.text的起始地址是 16 字节对齐的,从而让p_vaddr满足 ARM64ADR指令的要求;而如果你漏掉ALIGN(16),在 ARM64 上,p_vaddr很可能变成0x400100(4KB 对齐但非 16B),那么adrp计算出的imm_hi就会失准。
更关键的是权限。.text段的p_flags必须包含PF_R|PF_X,不能有PF_W。否则,内核在mmap_region()里设置页表时,会把 AP 位设为AP=01(可读可写),而 ARM 架构要求代码页必须是AP=00(仅可读)或AP=10(仅可执行),否则触发Permission Fault。
这就是为什么-z,relro -z,now如此重要:它让.dynamic段在重定位完成后,立即将GOT所在页设为只读(mprotect(..., PROT_READ))。没有它,攻击者就能覆写GOT[printf]指向恶意函数——而 ARM 的PXN(Privileged Execute Never)位,正是靠这种只读页保护才真正生效。
现在,再打开终端,输入:
readelf -l ./app你看到的不再是一堆冰冷的十六进制数字。
你看到的是:
→ 内核正在为它配置的 MMU 页表项;
→ 动态链接器即将遍历的.dynamic键值对;
→ PLT stub 正等待填充的imm_hi和imm_lo;
→ 以及,你昨天在mylink.ld里随手写的ALIGN(16),此刻正决定着第一条BL指令能否成功跳转。
这才是 ELF 在 ARM 世界里的真实分量。
如果你在调试一个SIGBUS却始终找不到原因,不妨先readelf -l ./app | grep LOAD,看看p_vaddr和p_align是否真的对齐。
这比翻三天内核日志更快。
欢迎在评论区分享你遇到的最诡异的 ELF 相关 bug —— 是p_offset错位?还是.dynsym缺失?或是DT_FLAGS_1没设DF_1_PIE导致 ASLR 失效?我们一起来拆解。