ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

arm-abi-aa 源码审计:AAPCS64 调用约定与编译器后端实现解析

arm-abi-aa 源码审计:AAPCS64 调用约定与编译器后端实现解析 第一次把arm-abi-aa这个仓库完整拉下来做审计的时候我其实没太当回事。名字看起来很单纯就是 ARM 官方的 ABI 规范仓库里面躺着一堆 Markdown 和 RST 文件。但等到真正对着 LLVM 后端和编译器生成的汇编去逐条核对时我才意识到这份仓储的含金量远不止“文档”这么简单。它实际上是 AArch32 / AArch64 两套指令集在编译器、汇编器、链接器、调试器之间达成共识的“宪法”你写的每一行 C 代码最后怎么传参数、怎么分配寄存器、怎么对齐栈帧根源都在这里。这篇文章我会站在一个编译器开发者和底层软件工程师的角度把arm-abi-aa架构全景、核心规范内容、源码审计方法论以及它如何直接落地到编译器开发中这件事讲透。适合三类人看一是正在做交叉编译工具链适配的工程师二是对 ARM 汇编和调用约定感兴趣的底层爱好者三是想真正读懂 LLVM / GCC 后端里那些晦涩 Calling Convention 代码的人。看完你至少能回答这几个问题AAPCS64 里那套寄存器编号规则从哪来为什么一个结构体有时用寄存器传、有时又塞进栈编译器后端里“ABI lowering”到底在干什么。1. 仓库全景arm-abi-aa 到底藏了什么值得一页页审计1.1 仓库定位与核心价值arm-abi-aa全称大概是“ARM ABI Application-Specific”是 ARM 官方维护的一套 ABI 规范合集。它定义的内容非常具体过程调用标准Procedure Call Standard、ELF 目标文件格式的架构扩展、DWARF 调试信息的 ARM 绑定、运行时模型等等。可以把它理解为“ARM 生态里所有二进制文件共同遵守的交通规则”。为什么这个词值得拿出来深度研究因为编译器后端开发中尤其是做目标平台适配的时候最难的不是指令选择而是“接口契约”。指令选择可以对着手册一条条来但参数怎么塞寄存器、结构体怎么拆分、异常展开信息怎么生成这些规则零散地散布在规范文档里。GCC 和 LLVM 的实现各有取舍但最终都必须回溯到这个仓库的规则上。如果读者需要自己写一个简化版编译器、或给非主流操作系统适配 ARM 后端arm-abi-aa就是起点。用 git clone 拉下来之后你会看到一份相当工整的仓库结构核心内容包括 AAPCS3232 位过程调用标准、AAPCS6464 位过程调用标准、AAELFELF 格式的 ARM 扩展、AADWARFDWARF 的 ARM 扩展、rtmodel 等。每个目录下基本都是.rst或.md的规范文本配合少量 Makefile 和辅助脚本。没有一堆花哨的示例代码但它很可能会成为你手边翻得最勤的一本“底层字典”。1.2 版本演进与技术趋势从 git 历史可以看到这份仓库一直在跟随架构演进更新。早期它更多是为了 Armv7-A、Cortex-A 系列处理器提供一个稳定的 ABI 基准。到 AArch64 出现之后AAPCS64 逐渐成为主线很多公司从 32 位迁移到 64 位时踩过的坑最终都沉淀成了规范文档里的一条条修正案。再往后BFloat16、SME可扩展矩阵扩展、PAC指针认证、MTE内存标记扩展这些新特性每一个都要求在 ABI 层面给出新的参数传递规则或栈帧布局约定。所以审计这份仓库时不要只把它当成静态文档。我习惯的做法是git log --oneline --all扫一遍提交历史看每个版本更新了哪一节这能帮你建立起“架构演进如何倒逼 ABI 升级”的全局观。比如 ARMv8.1-M 引入 MVEM 系列向量扩展后 AAPCS32 就多了对应的参数传递补充ARMv8.5-A 引入 PAC 后AAPCS64 就对返回地址签名、LR 的使用增加了约束。这些细节在写安全相关代码或者做逆向分析时特别关键。2. 四份核心规范逐层拆解从 AAPCS64 到 AAELF 与 AADWARF2.1 AAPCS64AArch64 过程调用标准的骨架AAPCS64 是整个arm-abi-aa仓库里最核心、也最常被引用的部分。它定义了 AArch64 执行状态下的寄存器用途、参数传递规则、栈帧布局、异常返回等。先说寄存器分配这一点是任何想读懂编译器汇编输出的人都绕不开的。AArch64 有 31 个通用寄存器 x0-x30外加 SP 和 PC。AAPCS64 把 x0-x7 定义为参数寄存器函数调用时前 8 个整型或指针参数依次放入 x0-x7第 9 个及以后的参数压栈。x8 是 indirect result register用于返回大型结构体时指向调用方预留的内存。x9-x15 是临时寄存器调用方保存caller-saved。x16 和 x17 被用作 IP0 和 IP1供链接器 veneer 和过程入口使用理论上调用者不用保证它们的值跨调用存活。x18 是平台寄存器在 Linux 内核里通常用作 CPU 的 per-CPU 变量基址应用层一般不用。x19-x28 是 callee-saved被调用函数必须保证这些寄存器在返回时与进入时一致。x29 是帧指针 FPx30 是链接寄存器 LR。SP 始终要保持 16 字节对齐这是 AAPCS64 特别强调的约束违反它会在调用标准库时出现诡异崩溃。浮点和 SIMD 寄存器方面v0-v7 用于传参和返回值v8-v15 的低 64 位是 callee-saved其余都是 caller-saved。参数传递时整型和浮点类型分别用 NCRNNext Core Register Number和 NSRNNext SIMD and Floating-point Register Number计数。把这两个计数器记在心里读编译器生成的汇编时会顺很多。2.2 AAPCS32AArch32 过程调用标准补充尽管 64 位是主流但嵌入式领域里 ARM32 依旧大量存在所以 AAPCS32 并没有过时。AAPCS32 的寄存器规则相对简单r0-r3 传参r4-r11 是 callee-savedr12 是 IPr13 是 SPr14 是 LRr15 是 PC。浮点参数使用 s0-s15 或 d0-d7具体取决于硬浮点还是软浮点 ABI。结构体传递是 AAPCS32 里比较微妙的点。一个结构体如果大小不超过 4 字节且全部由整数成员组成可以直接在寄存器中传递如果含有浮点成员则可能按“浮点成员在浮点寄存器、整数成员在整数寄存器”的规则混着传如果结构体过大就会退回内存复制 指针传递。看 GCC 的arm后端源码时你会看到大量针对TARGET_AAPCS_BASED的条件分支根子就在这份规范里。2.3 AAELF 与 AADWARF链接与调试的暗线AAELF 定义了 ELF 文件格式在 ARM 架构下的具体扩展。比如.ARM.attributes段它记录了目标文件依赖的架构特性、对齐规则、浮点模型等。链接器读取这个段来检查不同目标文件之间是否兼容。做交叉编译时如果发现链接器报错说uses VFP register arguments就是.ARM.attributes段里的 Tag_ABI_VFP_args 不一致导致的。AAELF 还详细规定了 AArch64 的重定位类型比如R_AARCH64_CALL26、R_AARCH64_ADR_PREL_PG_HI21、R_AARCH64_LDST64_ABS_LO12_NC等。理解这些重定位项是手写链接脚本或排查“relocation truncated to fit”类错误的必经之路。AADWARF 则定义了 DWARF 调试信息在 ARM 上的绑定点。包括寄存器编号映射比如 DWARF 里 x0 对应 0 号寄存器SP 对应 31 号v0 对应 64 号、CFI 规则、以及.debug_frame/.eh_frame的组织方式。别看它偏门实际调试时经常会遇到 “gdb 显示栈回溯坏了” 的情况根源往往就是 AADWARF 里某个 ARM 特定规则没处理好。3. 源码审计实践从规范文本到可验证的证据链3.1 审计方法不只看还要编译验证审计规范文档最大的误区是“从头读到尾”。我建议反过来先挑一个你已经知道答案的简单问题比如“一个两个 long 成员的结构体当参数时怎么传”然后去规范里找到对应章节再去编译器里验证。这个方法在审计中效率极高因为你会带着具体问题去读文本读到的内容很快就能和机器码对上号。我实际验证过的一个典型例子是struct pair { long x; long y; }; long add_pair(struct pair p) { return p.x p.y; }用 clang 交叉编译并输出汇编clang --targetaarch64-linux-gnu -O2 -S add_pair.c -o add_pair.s生成的汇编核心部分大概是add_pair: add x0, x0, x1 ret这个结果直接对应 AAPCS64 的规则结构体pair的大小是 16 字节完全被拆成两个 64 位整数x0 接收第一个 longx1 接收第二个 long。函数体只需要一条加法指令。如果换成 32 位 ARM 编译器传参方式就会变成 r0 和 r1 传两个 32 位整数因为小端模式下 long long 会被拆分。同样的 C 代码因为 ABI 不同生成的汇编差异极大这就是为什么源码审计必须落到具体架构上。3.2 寄存器与栈帧的实例验证再复杂一点看一个返回结构体的例子struct big { long data[4]; }; struct big make_big(void) { struct big b {1, 2, 3, 4}; return b; }AAPCS64 规定大于 16 字节的结构体不能直接通过 x0 返回调用方必须预先分配一块内存并把内存地址放在 x8 寄存器里传给被调函数。被调函数把返回值写入[x8]指向的缓冲区最后再把 x8 原样返回。所以你会看到类似这样的汇编make_big: adrp x9, .LC0 ... stp x1, x2, [x8] stp x3, x4, [x8, #16] mov x0, x8 ret这个例子特别适合拿来验证自己对 ABI 的理解。如果你在写编译器后端返回值布局阶段必须处理“要不要用 sretstruct return指针”的问题如果做二进制逆向看到 x8 被写入地址基本就能判断这是一个大结构体返回。规范里这一节值得反复读因为它同时牵涉寄存器传参和对齐规则。审计过程中我习惯搭配这几个工具clang -S -emit-llvm看中端 IRllvm-objdump -d看最终机器码readelf -A看.ARM.attributesgdb加断点看寄存器实时值。规范文本是死的但工具链组合是活的用证据链去反推规范条款才能真正把知识内化。4. 编译器开发落地把文字规则变成机器码的关键路径4.1 调用约定在编译器中是怎么实现的编译器后端处理 ABI 的核心过程叫“ABI lowering”简单说就是把源语言里的函数参数、返回值按照目标平台的 ABI 规则翻译成具体的寄存器或栈位置。在 LLVM 里这个流程主要落在三处AArch64CallingConvention.td定义调用约定的 TableGen 描述AArch64ABIInfo负责分析参数类型AArch64ISelLowering.cpp里的LowerFormalArguments和LowerCall负责生成实际指令。如果你要为一个新的操作系统或自定义 ABI 适配 ARM 后端第一件事就是改.td文件里的规则。比如想把前 4 个参数放进寄存器的规则改成前 8 个并不需要去写多少 C 代码多数情况下调整 TableGen 描述就能实现。关键规则通常长这样CCIfType[i64], CCAssignToReg[X0, X1, X2, X3]这段描述的意思是“64 位整型参数按顺序分配到 X0 到 X3”。不理解.td语法没关系重点是理解背后的逻辑——LLVM 把 ABI 规则抽象成了一张“匹配-分配”表参数类型是匹配条件寄存器列表是分配结果。看懂这张表就相当于看懂了后端如何落实 AAPCS64。GCC 的实现路径不太一样它集中在config/aarch64/aarch64.cc里的aarch64_function_arg和aarch64_layout_arg等函数中逻辑更显式但也更绕。两者对比来看LLVM 的 TableGen 方式对规则改动更友好但调试时反而更难追踪GCC 的方式啰嗦但每一步都有 C 可读逻辑。不同团队选型时的考量在这里体现得淋漓尽致。4.2 参数分类与聚合体的底层逻辑编译器把 C/C 类型归类为 Integer、Float、Vector、Aggregate 这几种大类。AArch64 的 AAPCS64 对聚合体结构体、联合体、数组有一套专门的分类算法先递归拆解每个成员按成员类型把它归类到整数类或浮点类然后合并成最终分类。如果成员同时包含整型和浮点就变成 “Composite” 类型再用特定的规则决定放入通用寄存器还是 SIMD 寄存器簇。一个很经典的坑是位域和未完全初始化结构体。AAPCS64 基于“成员的自然类型”做分类但 C 语言里位域成员的类型可能被编译器提升成int或unsigned int。如果一个结构体里既有位域又有 double最终的传参方式会和直觉预期不一致。我实际见过一次某个团队的通信协议结构体里塞了一个uint64_t flags : 1结果跨平台传输时参数布局完全错位。排查到 ABI 层才明白位域和普通成员在 AAPCS64 中按不同规则归类。规范里有一张表格专门解释“fundamental data types”的类别和大小写网络协议栈或跨进程通信模块时最好对照着来。可变参数varargs也是落地时的重灾区。AAPCS64 要求可变参数部分统一按“寄存器保存区”处理调用方把可变参数可能占用的寄存器全部复制到栈上的连续区域被调函数通过va_list去这块区域取参数。这个设计的本质是为了让va_arg的实现简单且一致因为它在编译期不知道实际参数个数。如果自己写调试器或做函数跟踪遇到printf这类函数时必须知道它的参数区在栈底的位置否则抓取到的寄存器快照完全对不上。栈帧布局则涉及 SP 对齐、LR 压栈、局部变量偏移等多个方面。AAPCS64 强制 SP 16 字节对齐所以每个函数入口处通常会有stp x29, x30, [sp, #-16]!这样的指令把 FP 和 LR 一起压栈同时保持对齐。写汇编起步的人经常忽略这个格式导致调用外部函数时栈指针未对齐出现 SIGBUS 或 glibc 断言失败。别问我为什么知道都是泪。5. 常见问题与排查实录编译器和 ABI 结合部的那些坑5.1 编译期/链接期问题速查表下面这张表覆盖了我实际开发中遇到的高频问题以及对应的排查方向。每个问题背后几乎都能在arm-abi-aa的一节或几节里找到依据。现象可能根因排查方向uses VFP register arguments链接错误目标文件之间硬浮点/软浮点 ABI 不一致检查编译选项-mfloat-abi对照 AAPCS32 的浮点规则传结构体时寄存器错位函数拿到错误值聚合体分类和预期不符位域或填充字节影响查看 AAPCS64 6.4 组合体传参规则用-fdump-rtl-expand看 IR调用外部函数时崩溃或 SIGBUS栈指针 16 字节对齐被破坏检查函数入口是否用stp x29, x30, [sp, #-16]!保持对齐relocation truncated to fit: R_AARCH64_CALL26函数跳转距离超出 /-128MB检查链接脚本是否缺少-Ttext-segment或需要插入 veneergdb 栈回溯信息错乱.eh_frame/.debug_frame里 CFI 规则不对对照 AADWARF 的寄存器号和表达式规则用readelf --debug-dumpframes检查va_arg取到错误参数va_list栈保存区的布局理解错误对照 AAPCS64 7.2 可变参数部分确认寄存器保存区偏移5.2 我踩过的几个具体坑第一个坑是早期做 ARM64 后端自定义 ABI 时为省一点点栈空间手动在函数入口只压了 8 字节的 LR破坏了 16 字节对齐。结果单独跑裸机代码一直正常一调用标准库的memcpy就随机崩溃。后来用 gdb 查寄存器发现 SP 低四位不为 0查找 AAPCS64 的栈约束后才明白根因。规范里用“must”级别的词汇强调 SP 对齐真不是开玩笑。第二个坑是结构体返回的隐藏指针 x8。自己在写汇编函数时如果忘了“大于 16 字节结构体返回需要 x8”返回值就会莫名变成垃圾。更隐蔽的是有些优化选项下编译器可能把 struct return 优化成 sret 参数在 LLVM IR 里表现为第一个参数是sret属性。如果只盯着最终机器码看很容易误解成“突然多了个隐藏参数”。第三个坑和 DWARF 相关。给自定义调试器做栈回溯时我一度搞混了 AAPCS64 里 x29 和 DWARF 寄存器编号的关系结果 backtrace 错位到完全不可用。后来老老实实打开 AADWARF对照寄存器编号映射表一点点修才解决。所以我的建议是调试器开发和编译器开发不要分开看ABI 文档里 DWARF 那节往往决定你能不能睡个好觉。第四个坑来自软硬浮点混用。整套代码用 GCC 默认-mfloat-abisoftfp编译但其中一个第三方库是用硬浮点 ABI 编的。运行时只要一调用这个库的函数浮点参数就全乱。最后用readelf -A查看两个目标文件的.ARM.attributes对比 Tag_ABI_VFP_args 才发现问题。从那以后我把 readelf 检查.ARM.attributes列为了每次交叉编译后的必备步骤。6. 编译并验证一个最小的 AArch64 程序这一节给一个可以直接照做的完整小实验帮你把上面的规则串起来。第一步写一个简单的 C 程序// test_abi.c struct small { int a; int b; }; int sum(struct small s) { return s.a s.b; } int main(void) { struct small s {10, 20}; return sum(s); }第二步用交叉编译器编译并输出汇编clang --targetaarch64-linux-gnu -O2 -S test_abi.c -o test_abi.s第三步查看sum函数的汇编。按照 AAPCS64struct small因为大小正好 8 字节且全是整数成员会被放进一个 64 位寄存器也就是说参数只占一个 x0低 32 位是a高 32 位是b。汇编大概长这样sum: add w0, w0, w1 ret注意实际 clang 可能因为其内部参数分类规则把两个 int 分别放入 w0 和 w1然后再相加。这里的关键不是看它具体放 w0/w1 还是合并成一个 x0而是理解AAPCS64 给了编译器一定的自由度只要符合“整数参数优先使用通用寄存器、结构体按成员拆分”的规则即可。这也是源码审计时容易被忽略的点——规范里有些地方明确约束有些地方则允许实现选择。想在编译器开发时做出正确决策必须分清这两类条款。第四步用-S -emit-llvm看优化前 IR再用llc手动指到 AArch64 后端clang --targetaarch64-linux-gnu -O2 -S -emit-llvm test_abi.c -o test_abi.ll llc -marchaarch64 test_abi.ll -o test_abi.s这样你能更清晰地看到 ABI lowering 发生在哪个阶段。LLVM 在前端生成 IR 时函数参数还保持 C 语言的“复合结构体”形态到后端LegalizeTypes阶段才会根据 AAPCS64 把结构体拆成多个虚拟寄存器或栈槽。理解这个分层对定位传参问题是至关重要的。7. 后续扩展方向与个人建议arm-abi-aa这份源码仓库的价值不会止步于文档阅读。如果你的工作涉及 RISC-V 之外的第二架构支持或者要给某个 RTOS 编写 AArch64 后端建议从 AAPCS64 的“过程调用标准”章节入手并配合 LLVM 的AArch64CallingConvention.td逐条对照。之后再用一个真实的小内核或 bootloader 验证栈回溯和中断上下文切换整个过程会迫使你把规范里的每一个约束都过一遍。另外如果你对 ARM32 的嵌入式生态感兴趣AAPCS32 和 AAELF 里的.ARM.attributes这两块值得优先读。很多嵌入式团队把不同编译器的目标文件混在一起链接只要架构特性标签对不上链接器就会抛出迷惑性错误。学会用 readelf 去解读这些段能节省大量联调时间。根据我个人经验最好的学习路径是“以问题驱动”先设定一个小目标比如“写一个可以调用printf的裸机 AArch64 程序”然后从汇编开始一步步实现遇到任何异常就去arm-abi-aa找对应规则。比起单纯通读规范这种方式带来的记忆深度完全不同。最后再分享一个小技巧把 AAPCS64 里的寄存器用途表打印出来贴在显示器边上写汇编和调试时会发现效率提升非常明显。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进