做编译器后端开发这几年我越来越觉得ARM生态里最容易被低估的东西不是指令集而是ABI。指令集告诉我们一条指令怎么写ABI却决定了你的编译产物能不能和系统里其它代码顺利协作。ARM官方把ABI相关规范全部托管在 arm-abi-aa 仓库里这个仓库是ARM架构上编译器、汇编器、链接器、运行时库开发者的共同参考基准。这篇文章记录了我对 arm-abi-aa 仓库做的一次完整源码审计过程包含仓库结构全景、AAPCS64核心规则拆解、LLVM后端如何落地这些规则以及一次真实的结构体传参错位问题的完整排查。如果你正在做编译器开发、工具链移植或者需要在ARM平台上解决跨语言、跨编译器互调问题这篇内容能帮你省下不少弯路的试错成本。1. Arm-abi-aa 仓库全景官方ABI规范到底管住了什么1.1 仓库结构它远不止一份AAPCS先说一个很多人理解上的盲区一提到ARM ABI大家下意识就想到AAPCS过程调用标准。但打开 arm-abi-aa 仓库你会发现AAPCS只是其中一份文档旁边还躺着一整套互相咬合的规范。仓库里的核心文档大致分为这样几块aapcs64 和 aapcs32 对应AArch64/AArch32的过程调用标准aaelf64 和 aaelf32 对应两个架构的ELF psABIehabi64 和 ehabi32 是异常处理ABIcppabi32 是C ABI在ARM上的适配rtabi32 是运行时ABI。每一份都不是摆设后端的各个阶段都会踩到它们。1.2 文档矩阵与编译器后端开发的对应关系为了把文档和工程实现对应起来我整理了一张表这也是我进行源码审计时最重要的索引文档管什么编译器开发中的直接落点aapcs64 / aapcs32参数传递、寄存器分类、栈布局、返回值规则调用约定、指令选择、Frame Loweringaaelf64 / aaelf32ELF文件布局、重定位类型、e_flags、属性段汇编器、链接器、目标文件生成、MC层ehabi64 / ehabi32栈展开、CFI指令、异常处理行为生成.eh_frame / .ARM.exidx异常表cppabi32Itanium C ABI的ARM调整名字修饰、类型信息、虚表布局rtabi32运行时辅助函数规则_aeabi* 系列辅助函数软浮点支持如果你只实现了一个“能用”的调用约定就宣布后端完成那大概率会在汇编器生成的重定位段、异常处理表或者C类型信息这些地方炸出问题。ABI不是一个文档是一整套规则族。1.3 版本演进审计前务必锁定基线arm-abi-aa 仓库是持续更新的不是一成不变的“终极规范”。某个版本可能调整了一条CFI指令的编码下一个版本又可能对重定位的修饰符做澄清。如果不锁定版本就开始读后端的代码注释、测试用例和规范对不上排查问题时会把“规范变了”误判成“后端写错了”。我在审计前固定做两件事第一clone时记录commit号并在审计报告开头写明“本次审计基于 commit xxxx日期 xxxx”第二如果有正式 release 标签优先对标 release 版本而不是 main 分支。这样后续代码里不管哪里出了问题都可以回溯到这版规范。2. ABI规范源码审计精读方法与输出物2.1 审计准备工具链与实验环境我的审计环境不算复杂一台普通Linux机器就够了。核心工具是交叉编译链和反汇编工具链clang/gcc 的 aarch64-linux-gnu 目标支持、llvm-objdump、llvm-readelf、objdump、readelf再加上 qemu-user 用来在本地直接跑AArch64测试程序。没有开发板之前qemu-user 是性价比最高的验证方式。我习惯把审计工作区建成这样的结构一个目录放规范原文的标注笔记一个目录放实验C文件一个目录放反汇编结果文件命名统一带规则编号。举个例子验证 composite type 分类规则时我会写abi_aapcs64_composite_01.c对应反汇编abi_aapcs64_composite_01.s。这套命名规范看起来啰嗦但等后端开始改代码、需要查阅某个规则对应的实测结果时效率非常高。2.2 精读路径从AAPCS64到EHABI再到C ABI我建议的精读顺序是从aapcs64 开始然后 aaelf64 中与重定位、属性段相关的章节再往后是ehabi64最后才是cppabi32。顺序是有讲究的调用约定是最高频规则先啃下来随后是目标文件格式这决定了汇编器输出异常处理影响的是代码生成的“边界条件”C ABI则影响的对象更偏上层。读规范文本时不要光学字面意思要多做“翻译”工作。规范里有大量绕口的定义比如 composite type 的分类条件一句话反复出现约束我通常会把它改成伪代码再消化。例如复合类型分类我读完后整理成这样的形式if 是 HFA 或 HVA: 按浮点/向量寄存器处理 else if 大小 16 字节: 按双字块拆分逐块分配到通用寄存器 else: 按引用传递传递指针这个步骤很关键因为后端代码就是这类逻辑的直接翻译伪代码先理清楚C实现就不容易跑偏。2.3 审计输出物一份可执行的ABI备忘录审计不能只停留在“读懂了”最终要沉淀成输出物。我做每一轮ABI审计后都会产出一份 ABI 备忘录包含寄存器分类表、参数传递流程伪代码、结构体分类决策规则、栈布局与对齐要求以及“规则→C示例→期望汇编”的三段式用例。这份备忘录不只是在审计期间有用它后来直接成了后端实现时的需求文档也是代码审查时的核对清单。还有一个额外收益审计过程会发现规范之间的交叉引用关系。比如 aaelf64 中对 e_flags 的要求会影响汇编器如何编码浮点参数使用方式ehabi64 中的CFI规则又和AAPCS64中的被调用者保存寄存器列表直接挂钩。这些东西在单一文档里看不出威力放在一起才有全景视角。3. AAPCS64核心规则逐条拆解寄存器、参数与结构体分类3.1 寄存器分工Caller-saved与Callee-saved的边界AAPCS64 的寄存器角色划分是整个调用约定的地基。我把最核心的分配规则整理成一张表寄存器组角色保存责任x0 - x7参数传递、返回值调用者保存x8间接结果地址寄存器 (IP0)调用者保存x9临时寄存器 (IP1)调用者保存x10 - x17临时寄存器调用者保存x18平台寄存器具体平台定义按平台规则x19 - x28被调用者保存寄存器被调用者保存x29帧指针 (FP)被调用者保存x30返回地址 (LR)被调用者保存v0 - v7FP/SIMD参数传递、返回值调用者保存v8 - v15FP/SIMD临时寄存器仅低64位由被调用者保存v16 - v31FP/SIMD临时寄存器调用者保存有个非常容易忽略的细节v8-v15 只有低64位是被调用者保存的整个128位并不是全保存的。这意味着编译器在做寄存器分配时如果往 v8 的高64位塞了数据就必须假设它会跨调用被破坏。这也是NEON向量运算在函数边界处出现随机数据丢失的常见原因。3.2 参数分配机制NGRN、NSRN与NSAA三个游标AAPCS64 描述参数分配时用了三个游标这是理解整个参数传递过程的钥匙。NGRNNext General-purpose Register Number记录下一个可用的通用寄存器NSRNNext SIMD and Floating-point Register Number记录下一个可用的浮点/向量寄存器NSAANext Stacked Argument Address记录下一个栈上参数的位置。参数按从左到右的顺序处理。整数、指针类参数放进 x[NGRN] 并递增NGRN浮点标量放进 v[NSRN] 并递增NSRN。一个参数占用的寄存器宽度按64位计小于64位的基本整数类型虽然在寄存器的最低有效位存放但编译时通常会做扩展避免高位谜之数据。当寄存器游标超过7、后续仍有参数要传时参数进入栈区NSAA 按8字节对齐递增。值得注意的是寄存器类和栈上的参数是可以同时存在的。例如一个函数的前两个整型参数用了 x0、x1第三个浮点参数可能还在 v0而第四个整型参数已经落到栈里了。编译器实现时要是把参数通道“一刀切”这种混合场景就会错位。3.3 结构体与联合体分类HFA/HVA判定和复合类型规则复合类型结构体、联合体是AAPCS64里最容易出 bug 的重灾区。规范给出的规则一层套一层我把它拆成了决策树第一步判断是否为 HFAHomogeneous Floating-point Aggregate或 HVAHomogeneous Short-vector Aggregate。条件是成员数量不超过4、所有成员都是同一种浮点类型或同一种short vector类型且允许嵌套扁平化。第二步如果是 HFA/HVA参数通过连续的 v 寄存器传递例如包含两个 double 的结构体用 d0、d1 传递。第三步如果不是 HFA/HVA但大小不超过16字节则把结构体拆成多个双字每个双字按顺序分配到通用寄存器。第四步如果大小超过16字节按引用传递实际参数是数据块地址的指针。我见过一个非常典型的例子。看这两个结构体struct Pair { double x; double y; }; // HFA走 v0/v1 struct Mixed { long long a; double b; }; // 非HFA走 x0/x1把struct Pair传到函数里两个 double 都在浮点寄存器里但把struct Mixed传进去即使里面有一个 double 字段整个结构体也按整数链表处理字段 a 占 x0字段 b 占 x1。如果编译器把第二个结构体的 b 放进了 v 寄存器那调用方和被调方就能当场“失联”。3.4 返回规则、栈对齐与隐含寄存器x8返回值的规则相比参数传递要简洁但也有几个关键约束。整型和指针类型返回值放 x0浮点标量返回值放 v0HFA/HVA 返回值按连续寄存器处理。复合类型超过16字节时由调用方提供返回数据的内存区域这块内存的地址放进 x8被调方将数据写入该地址。因此 x8 被称为 indirect result location register在编译器的 sretstruct return实现中地位特殊。栈对齐规则同样在ABI层级被明文固化函数调用前后SP需要保持16字节对齐。如果手写汇编函数在进入函数后分配的栈帧大小不是16的倍数或者调用前SP因为某些调整错位了绝大多数情况下都会在第一次调用 printf 或任何标准库函数时以 SIGBUS 或随机崩溃的形式爆发。这一点对编译器后端、汇编开发、以及手写启动代码都是同一套规则。4. 编译器开发落地把ABI写进代码生成器4.1 LLVM后端ABI实现的三个核心入口如果基于LLVM做ARM/AArch64后端ABI实现最关键的位置集中在 TargetLowering 的几个方法里。LowerFormalArguments 负责函数入口处收集参数把传入寄存器和栈上的数据整理成后续指令能用的虚拟寄存器LowerCall 负责调用方侧生成函数调用序列包括参数装载、bl/branch 指令、栈参数区处理LowerReturn 负责返回值处理把返回值按规则放到指定寄存器或内存。对应文件上AArch64 后端主要是 AArch64CallingConv.td 和 AArch64ISelLowering.cppARM 32位后端则是 ARMCallingConv.td 和 ARMISelLowering.cpp。初期定位规则时我都是直接在这两个文件里搜 AAPCS、CC_AAPCS 之类的关键字把默认调用约定相关的路径全部拉出来看。4.2 TableGen调用约定描述与手工Lowering的分工LLVM 把调用约定的一部分描述放在了 CallingConv.td 里用 TableGen 描述规则例如简单的标量类型如何提升宽度、如何分配到寄存器。下面是一段教学用的简化示意真实文件里会比这复杂得多但结构上就是这个套路def CC_AAPCS64 : CallingConv[ CCIfType[i8, i16, i32], CCPromoteToTypei64, CCIfType[f32, f64], CCIfFixedCCAssignToReg[V0, V1, V2, V3, V4, V5, V6, V7], CCIfType[i64], CCAssignToReg[X0, X1, X2, X3, X4, X5, X6, X7] ];但 TableGen 适合描述相对规整的规则遇到 HFA/HVA 判定、非HFA复合类型拆分、sret 返回地址传递这类需要类型上下文判断的逻辑还是要回到 C 里手写 LowerFormalArguments 和 LowerCall。分工原则简单说就是能用声明式描述就声明式描述复杂的分类逻辑就用代码硬编码并把规范编号写进注释。4.3 一个结构体传参的完整执行链路我举个例子说明 ABI 规则最终如何变成指令。假设这样一个函数struct Pair { double x; double y; }; double add_pair(struct Pair p) { return p.x p.y; }因为 struct Pair 是 HFA两个 double 成员分别进入 d0、d1函数体里只需要一条浮点加法add_pair: fadd d0, d0, d1 ret再看另一个结构体struct Mixed { long long a; double b; }; double add_mixed(struct Mixed m) { return m.a m.b; }第一个字段 a 在 x0第二个字段 b 在 x1。函数里要先把 x0 转成浮点再用 x1 里的位模式参与加法add_mixed: scvtf d1, x0 fadd d0, d1, d0 ret这两个例子就是 AAPCS64 第6.4.3节关于 composite type 分类规则最直观的实证。如果你从原理上理解了这条链后面排查问题就有了一根定海神针。4.4 ABI合规自测矩阵落到工程实践上只修通一小撮用例远远不够。我建议在测试套件里建立一张“ABI合规自测矩阵”每个规则对应一个独立测试用例。下面是我常用的骨架场景示例类型预期寄存器/行为验证方式HFA传参struct { double x, y, z, w; }v0 - v3反汇编检查非HFA复合类型struct { long long a; double b; }x0, x1反汇编检查大于16字节复合类型struct { long long a[3]; }传引用指针反汇编检查返回大结构体同上结构体作为返回类型x8指向的缓冲区运行期验证可变参数printf类函数va_list解析正确运行期验证栈对齐递归大量栈参数16字节对齐运行时检查SP除了反汇编检查我还会做一层“ABI互操作测试”用 clang/gcc 编译调用方用自制编译器编译被调方然后交叉编译链接成一个程序跑起来反过来再把角色对调。只有当双向互调都稳定通过时ABI才算真正落地。5. 实测案例一次非HFA结构体传参错位的完整排查5.1 问题现场与最小复现有一次在给AArch64后端扩展浮点类型支持时出现了一个诡异的现象一个返回结构体的库函数返回值里的第二个字段永远是随机值。现场用 clang 编译调用方用我们自研编译器编译库函数任何程序只要调用了这个返回struct Mixed的函数就会出问题。最小复现缩成这样struct Mixed { long long a; double b; }; struct Mixed make_mixed(long long a, double b) { struct Mixed m {a, b}; return m; }问题出现时调用方拿到的 m.a 是正确的m.b 却完全不是传入的 b。当时第一反应是上层优化有bug怀疑某个pass把返回结构体的存储错位了。5.2 排查链路反汇编、规范对照、寄存器验证排查的第一步永远是看反汇编。我把make_mixed的反汇编拉出来对比“我们编译的版本”和“clang编译的版本”。结果一目了然clang 版本把返回值结构体的两个字段都按双字拆分到 x0 和 x1调用方也按这个规则读取而我们编译的版本却把 b 放进了 v0调用方按 x1 去取自然拿到的是别的东西。随后我写了一个直接调用该函数的测试程序在函数入口用调试器看寄存器确认了 v0 里确实有输入 b 的位模式而 x1 是某个不相干的残留值。到这里范围已经很清楚不是优化pass的问题是调用约定里参数/返回值的分类规则执行错了。5.3 根因分析与修复方案继续往下挖根因很快浮出水面。在 classify 复合类型时代码里只判断了“是不是HFA”但没有在非HFA分支里强制走通用寄存器而是错误地复用了浮点标量的分配逻辑。结果遇到含 double 字段的非HFA结构体就把 double 字段单独拎出来塞给了FP寄存器。修复方案按规范来伪代码如下function classifyComposite(T): if isHFA(T) or isHVA(T): return ClassFP if size(T) 16: return ClassGPR64xN return ClassByReference修复后重新生成库函数反汇编已经是 x0/x1 的布局和 clang 完全对齐跨编译器互调恢复正常。那次之后我深刻体会到一件事ABI的每个分支都不是“理论”它会在互操作场景里以最直接的方式回报你。5.4 防回归ABI互操作测试修复完不是终点。我把这个案例固化成两个测试一是lit测试锁定make_mixed的反汇编输出防止以后某个commit把分类逻辑改回老路二是互操作测试每次修改调用约定相关代码都会跑一遍“clang编译调方 自研编译被调方”的套件。有了这两个测试后续做寄存器分配优化、指令选择调整时心里会踏实很多。6. 面向实战的避坑清单与工作流建议6.1 高频ABI问题速查表最后把这几年在ARM ABI上踩过的典型问题整理成一张速查表排查时可以直接对照症状可能原因排查手段修复方向结构体参数第2个字段读错非HFA复合类型被误放FP寄存器反汇编对比参数布局修正classify规则调用标准库函数时崩溃SP未保持16字节对齐检查调用点SP值调整栈帧分配链接时报VFP寄存器参数冲突softfp/hardfp混用readelf查看属性段统一sysroot的ABI大结构体返回值错乱x8返回地址寄存器处理不对反汇编检查sret路径按规则传递返回缓冲区指针varargs解析乱掉va_list实现不匹配打印va_arg结果按ABI实现va_list结构NEON数据跨调用损坏v8-v15只保存了低64位规则忽略检查保存代码宽度按64位保存v8-v156.2 三个我印象最深的坑第一个坑是v8-v15的保存宽度。有一版优化里我为了方便直接把整个 v8 的128位都当作被调用者保存来对待功能上没毛病但函数序言/尾声的长度凭空多出一截。反过来的另一个极端是只保存低64位却误以为高64位也安全结果NEON计算跨函数调用丢数据。规则就一条v8-v15 只有低64位是callee-saved其余属于caller-saved。第二个坑是软浮点与硬浮点混用。32位 ARM 上这个问题尤其尖锐sysroot里只要混进来一个按 softfp 编译的库调用链上所有浮点参数传递都会乱套最麻烦的是这种问题常常只在特定版本组合下出现。应对方式不是“哪个好”的问题而是整个sysroot必须保持一致的ABI选择并在构建描述里写死。第三个坑是规范版本漂移。某个系统自带的旧工具链按旧版ABI实现生成代码遇到新版规范里的修复反而表现为“实现bug”。这种东西耗时间但没技术含量处理方式就是版本隔离和替换工具链不要试图在自制后端里去兼容老工具链的偏差行为。6.3 日常工作流建议经验沉淀到最后我给自己定了几条固定工作流也推荐给做编译器/工具链的朋友。第一每次新项目启动先写一份ABI基线报告记录仓库commit、规范版本、目标平台、使用的sysroot和ABI选项。第二把规范条款编号和代码注释互相关联比如实现某个规则时注释里直接写aapcs64:6.4.3后面审查和排障时有迹可循。第三所有和ABI相关的改动都必须跑通双向互操作测试不能只跑同一编译器的“自我验证”。第四每隔一段时间跟踪 arm-abi-aa 仓库的提交记录把变化点单独归档和自家代码的兼容性做一次评估。这些工作看上去琐碎却是我实测下来最省心的方式。ARM生态的坑大多不是出不出现的问题而是会在你最不想出问题的时刻出现提前把规范吃透、把测试网织密才是真正稳妥的做法。
