我先交代下背景:这个项目启动的动机特别朴素,就是团队要基于自研编译器后端对接 ARM 生态,结果光“ABI 规范”四个字就把我们绕晕了——网上资料一堆,讲 AAPCS64 的、讲 EHABI 的、讲 CPPABI 的,但没人告诉你这些规范到底存在哪个仓库、怎么从源码层面去核实编译器行为。所以后来我就干脆把arm-abi-aa仓库从头到尾捋了一遍,一边读规范一边对着 LLVM 后端代码做审计,再落到一套可复现的编译器开发验证流程上。
这篇文章就是那段时间工作的整理,面向的人群很明确:准备做 ARM 后端开发、交叉编译工具链适配、或者想搞明白“编译器究竟怎么遵守 ABI”的工程师。里面没有任何玄学,全是能直接抄作业的仓库路径、审计方法、编译参数和排查命令。
1. 项目全景:Arm-abi-a仓库到底在审计什么
先把最容易混淆的概念说清楚。ARM 的官方 ABI 规范并不只有一个文档,而是一个文档集合,涵盖了过程调用标准、ELF 文件格式、异常处理、C++ ABI、运行时 ABI 等等。这个集合在 GitHub 上的统一仓库就是ARM-software/abi-aa,也就是标题里那个“Arm-abi-aa”的由来。
很多人以为读 ABI 规范就是去官网下载 PDF,实际不是。abi-aa仓库把所有规范以rst源码形式托管,每个版本都有清晰的提交记录和 release 标签。这意味着你可以像审计代码一样去审计“规范的变更历史”,精确找出某个 ABI 规则是从哪个版本开始变的、为什么变、影响面有多大——这一点在做编译器兼容性时价值极大。
1.1 为什么做源码级审计而不是直接读文档
abi-aa仓库当前的目录结构大概是这样的:
abi-aa/ ├── aadwarf32.rst # 32位DWARF调试格式 ├── aadwarf64.rst # 64位DWARF调试格式 ├── aaelf32.rst # 32位ELF文件格式 ├── aaelf64.rst # 64位ELF文件格式 ├── aapcs32.rst # 32位过程调用标准 ├── aapcs64.rst # 64位过程调用标准 ├── bfabi.rst # 裸机ABI ├── cppabi.rst # C++ ABI(含异常处理) ├── rtabi.rst # C库运行时ABI ├── ...直接对着浏览器里的 HTML 版规范读,最大问题是“你是在被动接收信息”。做源码级审计就不一样:你会先建立一份“必须验证的 ABI 功能点清单”,然后逐个去仓库源码里找实现证据,比如:
- AAPCS64 规定参数寄存器为
x0-x7,那么 LLVM 后端AArch64ISelLowering.cpp里的CC_AArch64调用约定是否真的只用了这 8 个寄存器? - 结构体如果超过 16 字节就放入内存,这是
AArch64CallingConvention.td的哪条规则实现的? - 浮点参数使用
v0-v7,但复数类型、HFAs(Homogeneous Floating-point Aggregates)又怎么处理?
规范负责告诉你“应该怎样”,源码审计负责回答“编译器实际做没做、做到什么程度”。两者的差距,就是兼容性 bug 的藏身之处。
1.2 仓库目录结构与源码分布状况
先说最重要的一点:在abi-aa仓库里不要指望能直接找到任何 C 代码。它全是文档源码,通常是rst格式。真正的“仓库源码审计”其实是两条线并行:
- 第一条线:审计
abi-aa仓库本身的版本变更、文档结构和规范表述 - 第二条线:拿规范去审计 LLVM/GCC 的编译器后端源码,确认实现和行为一致
我建议你两条线都要做。只读文档不做实现核对,等于纸上谈兵;只读编译器源码不看规范,遇到“编译器行为与规范不一致”时,你都不知道该信谁。
以aapcs64.rst为例,规范里最核心的几节包括:
| 章节 | 内容 | 影响面 |
|---|---|---|
| 2.0 | 通用机器寄存器布局 | 参数传递、返回值、栈回溯 |
| 3.0 | 参数传递规则 | 整型、浮点、结构体、HFA |
| 4.0 | 子程序返回规则 | 返回值位置、大小 |
| 5.0 | 栈布局与对齐 | 栈帧、16字节对齐、叶子函数 |
审计时我会对每个章节建立一张“检查表”,再对应到 LLVM 的源码位置。比如参数传递对应AArch64CallingConvention.td,栈布局对应AArch64FrameLowering.cpp,返回值对应AArch64ISelLowering.cpp。
1.3 搭建审计清单:从规范到源码的映射方法
我自己的做法是:先建一个简单的电子表格,列四列——规范条款号、规范摘录、对应源码文件、源码行号/函数名。审计过程中每发现一条映射,就填一行,最后汇总出一张“ABI 规范实现映射表”。
这一步不能省,因为编译器的 ABI 相关代码往往散落在多个文件中,没有映射表,你查起来会极其痛苦。比如“参数在栈上如何排布”这一条,就涉及AArch64CallingConvention.td中的CCIfType、CCAssignToRegister、CCAssignToStack好几个规则,还和AArch64ISelLowering.cpp里的LowerFormalArguments与LowerCall函数相互配合。再加上AArch64TargetMachine.cpp里computeAddressingMode的影响,情况就变得复杂起来,表就派上用场了。
2. 核心ABI规则解读与源码细节
这一节把审计过程中最核心的几个 ABI 规则挑出来,结合源码细节拆开讲。这些都是编译器开发中真正会踩坑的地方,也是在abi-aa仓库里反复变更的高频区。
2.1 AAPCS64:过程调用标准拆解
AAPCS64 是 64 位 ARM 的过程调用标准,整个规范的核心思想就一句话:用寄存器传参,传不下再上栈。但“传不下”的判断条件远比你想象的复杂。
整数和指针参数的传递规则相对简单:按顺序使用x0到x7,超出部分用栈传递。例如一个函数参数为(int a, int b, int c),会被依次放置到w0、w1、w2寄存器中。虽然实际使用 32 位寄存器w0,但寄存器编号对应x0。
浮点参数则使用v0到v7。但如果你混用整型和浮点参数,比如func(int a, double b),a放到x0,b放到v0,互不干扰。但要注意:这并不意味着x0和v0是独立的两套“编号空间”,v0寄存器的低 64 位与d0是同一个物理寄存器。
在AArch64CallingConvention.td源码中,可以看到类似这样的规则定义:
def CC_AArch64_DarwinPCS : CallingConv<[ CCIfType<[i1, i8, i16, i32, i64], CCIfNotConsecutiveRegs<CCAssignToReg<[X0, X1, X2, X3, X4, X5, X6, X7]>>>> ]>;这段代码核心意思是:如果参数类型是整型或指针,并且寄存器没有被占用,就分配到X0到X7这 8 个 64 位寄存器中。注意i32会被分配在w0上,但寄存器表示的仍是X0的低 32 位。
对编译器开发来说,真正的难点是:LLVM 的调用约定描述不是简单的一对一规则,而是需要处理各种约束的组合。比如有些参数可能已经占用了某个寄存器,那你得靠CCIfNotConsecutiveRegs这样的约束条件去跳过已经被占用的寄存器,再找下一个可用的。
2.2 结构体传递与HFA/HVA规则
结构体参数的传递是 ABI 里最容易被搞错的部分,也是我做源码审计时花时间最多的地方。AAPCS64 对结构体有一套完整的分类系统:将结构体按成员的布局和类型分为Integer、Float、HFA(Homogeneous Float Aggregate)、HVA(Homogeneous Vector Aggregate)和Composite等类别,然后按类别决定如何传参。
HFA 是指一个结构体最多包含 4 个成员,且所有成员都是同一种浮点类型(float或double)。比如:
struct Point { float x; float y; }; // HFA,两个 float struct Line { double a; double b; }; // HFA,两个 doubleHFA 类型的结构体在传参时不是被复制到栈上,而是展开成多个浮点寄存器传递。struct Point会占用v0和v1,而不是被看作一个 8 字节的整数去占用x0。
这个规则在 LLVM 的AArch64CallingConvention.td中对应CCIfType<[f32, f64], CCIfConsecutiveRegs<CCAssignToReg<[V0, V1, V2, V3, V4, V5, V6, V7]>>>>,在源码中可以看到对f32、f64类型采用CCIfConsecutiveRegs约束,要求这些浮点寄存器是连续的。
实战中一个常见的坑是:ARM 32 位没有 HFA 规则。同样的结构体,在 ARMv7 上可能通过整数寄存器传递,到了 ARMv8-A 上就变成浮点寄存器传递,跨架构的二进制接口必然不兼容。这种差异在审计时一定要单独列出来,写进映射表。
2.3 大端与小端、数据布局的坑
ARM 架构同时支持大端和小端,但 ABI 默认使用小端。如果你做的是嵌入式或特定行业定制,可能会遇到大端环境。源码审计时需要注意:
abi-aa仓库中aaelf64.rst明确规定了EF_ARM_BE8和EF_ARM_LE8这样的标志位。编译器在生成 ELF 文件时,会在e_flags字段中写入这些标志,链接器会检查这些标志来保证所有输入文件的字节序一致。
在 LLVM 后端,字节序相关的逻辑散落在各个地方:AArch64MCTargetDesc.cpp里有setEndianness;AArch64AsmBackend.cpp中要根据目标字节序选择重定位的写入方式。审计时最容易疏忽的是调试信息部分——DWARF 调试信息也分大端小端,如果编译单元是小端、调试信息里却按大端写了某个偏移量,调试器加载时就会出现诡异的“地址差 4 个字节”问题。
3. 编译器开发落地指南:从源码审计到工具链
源码审计和规范对照只是第一步,真正见真章的是把规范落地到编译器开发里。这个阶段我总结为:搭建工具链环境 → 用编译参数验证结构体分类 → 用反汇编核对参数寄存器 → 修复后端代码 → 回归测试。
先提醒一句:如果你是第一次做 ARM 编译器开发,不要直接用系统自带的交叉编译器去验证 ABI 行为。系统包管理器里的aarch64-linux-gnu-gcc版本可能很老,对最新规范的支持不完整;而且有些发行版会对编译器打本地补丁,行为可能与上游略有差异。我都是自己构建一个干净的、带调试信息的 LLVM/Clang 来做测试。
3.1 工具链选型与ARM编译器版本选择
ARM 编译器有两大阵营:ARM 自家商用编译器(armclang,也就是 Arm Compiler)和开源 GCC/LLVM。armclang 的许可和下载方式相对封闭,需要注册、审批,而且版本发布节奏较慢,不适合做 ABI 行为验证。我推荐使用LLVM/Clang,因为它有独立的AArch64后端,源码结构清晰,而且对 ABI 的实现集中、可读性好。
如果你一定要用 ARM 官方编译器做对照,要注意版本。Arm Compiler 5 系列(ARMCC)和 Arm Compiler 6 系列(armclang)在 ABI 上存在差异,ARMCC 5 自己定义了一些__asm和关键字行为,容易干扰 ABI 测试结果。做 ABI 源码审计时,最稳妥的组合是:
| 用途 | 编译器 | 说明 |
|---|---|---|
| 主测试编译器 | Clang/LLVM 18+ | 源码开放,可加日志断点 |
| 对照编译器 | GCC 13+ | 成熟稳定,验证交叉编译结果 |
| ABI基准 | armclang 6.16+ | 官方实现,商业项目兼容性参考 |
3.2 搭建交叉编译环境与ABI测试用例
环境搭建的核心需求是:能够生成带有 debug info 的 AArch64 ELF,并能反汇编查看寄存器分配。最简单的方案是使用 CMake + LLVM 的交叉编译能力,而不是手动指定一堆-target参数。
为某个空函数编写 CMakeLists.txt 是现代交叉编译的推荐方式:
set(CMAKE_SYSTEM_NAME Linux) set(CMAKE_SYSTEM_PROCESSOR aarch64) set(CMAKE_C_COMPILER clang) set(CMAKE_C_COMPILER_TARGET aarch64-linux-gnu) set(CMAKE_C_FLAGS "-O0 -g")然后写一个测试文件test_abi.c:
struct Point { float x, y; }; float sum(struct Point p) { return p.x + p.y; }编译产物直接用llvm-objdump反汇编,就能看到结构体是如何被传递的。如果编译器正确处理了 HFA,你会看到类似于这样的反汇编:
sum: fadd s0, s0, s1 ret这里s0是v0的低 32 位,s1是v1的低 32 位。结构体的两个 float 成员确实通过连续两个浮点寄存器传入,而不是通过整数寄存器。
对照测试:如果把结构体的类型改成int x; int y;,就应该看到参数通过w0、w1传入:
sum: add w0, w0, w1 ret3.3 落地时的高频修复点
在实际修改编译器后端时,有几个位置是 ABI 问题的高发区,值得你在源码审计时重点标注:
AArch64CallingConvention.td:调用约定的规则描述基本都在这,改动前最好先跑一遍llvm-tblgen生成 Debug 文件,肉眼检查生成结果AArch64ISelLowering.cpp:函数参数下降(LowerFormalArguments)和调用生成(LowerCall)的核心逻辑,HFA 的分类函数CC_AArch64_Custom_Block就在这里AArch64FrameLowering.cpp:栈帧布局、红区(Red Zone)、叶子函数优化。苹果的 Darwin 平台红区大小与 Linux 不同,如果你做的是 macOS 版 AArch64 后端,记得专门处理AArch64AsmBackend.cpp:重定位与字节序,尤其是做-mbig-endian目标时要检查
大多数兼容性 bug 都源自:规范里某一个“条件”没有在 TableGen 规则里落实。比如 AAPCS64 规定 HFA 必须是“同类型”的成员,但如果你在 TableGen 里只写了CCIfType<[f32, f64]>而没检查成员类型是否相同,就会出现一个struct { float a; double b; }被错误当成 HFA 传递的 bug。
4. 常见问题与排查技巧实录
源码审计和编译器开发过程中,我遇到了不少问题,也整理了一套排查方法。这里挑几个典型的分享出来,希望能帮你跳过这些坑。
4.1 常见问题速查表
| 症状 | 可能原因 | 排查命令/方法 |
|---|---|---|
| 结构体参数被分成多个寄存器 | TableGen规则没启用HFA | llvm-objdump -d检查fadd指令是否接收连续浮点寄存器 |
| 传参寄存器错位 | 某个调用约定规则提前消耗了寄存器 | llc -debug-only=isel加打印,观察x0-x7分配过程 |
| 栈上参数位置不对 | 栈帧对齐计算有误 | llvm-readelf -s查看符号地址,核对偏移 |
| 大端环境重定位错误 | AsmBackend 未处理字节序 | readelf -r检查重定位类型 |
| 调试器显示地址错乱 | DWARF 字节序与目标不符 | readelf --debug-dump=info检查DW_AT_low_pc大小端 |
链接时出现relocation truncated | 重定位类型选择错误 | llvm-objdump -r对比 GNU objdump 输出 |
4.2 排查思路与工具集
做 ABI 排查时,我始终推荐一套固定的三层排查链:
第一层是反汇编,用llvm-objdump -d --no-show-raw-insn看编译产物。这一层解决 80% 的问题,因为 ABI 错误最终都会表现为“寄存器用错了”或“栈上偏移算错了”。
第二层是加 LLVM 的调试输出。llc -debug-only=isel -O0 test.ll能看到指令选择和调用约定降级(lowering)的完整过程。如果你想追踪某个参数到底是怎么被分配到寄存器的,也可以给AArch64ISelLowering.cpp里的LowerFormalArguments函数临时加dbgs()打印,重新编译 LLVM 后运行。
第三层是 ELF 层面检查。用readelf -A test.o看.ARM.attributes段,确认架构版本、浮点 ABI 是否匹配;用readelf -h看e_flags是否写入了正确的 ABI 标志。这层主要排查链接器和汇编器的问题。
4.3 一个典型的HFA排查过程实录
有一次我写了一个struct { float x, y, z, w; },期望它作为 HFA 通过v0-v3传递,但反汇编出来的结果让我傻眼了:结构体被拆成了整数寄存器x0、x1。
第一反应是看编译产物。llc -debug-only=isel的输出显示参数类型被降级成了<4 x float>,但调用约定规则里没有“vector type”对应的 HFA 分支,所以走了默认的 integer 分支。
问题定位得很清楚:我的 TableGen 规则只处理了若干个单独的float成员,没有处理编译器在中间表示阶段已经把结构体转换为向量类型<4 x float>的情况。解决办法是在AArch64CallingConvention.td里增加CCIfType<[v4f32], ...>的分支,或者在LowerFormalArguments阶段把向量类型先拆分为标量类型。
这类问题的核心经验是:规范看的是源码语言层,编译器处理的是中间表示层,中间会做各种形态转换,审计时必须时刻盯住 IR 层的真实类型。
4.4 排查技巧:善用readelf/objdump
如果你在排查 ARM 相关问题时不知道用什么工具,那我推荐先记住这三个:
readelf -A:查看.ARM.attributes段,能告诉你这个目标文件的 ABI 版本、架构特性、浮点 ABI 类型llvm-objdump -r:查看重定位条目,用它判断编译器生成的重定位类型是否与abi-aa规范一致readelf --debug-dump=info:查看 DWARF 调试信息,排查找 Debugger 端问题时特别有用
一个快速验证 ABI 版本是否一致的方法:
readelf -A test.o输出中会包含类似Tag_ABI_VFP_args: VFP registers的字段。如果你在一个 armhf 系统上链接一个使用软浮点 ABI 编译的目标文件,这里就会暴露出来,链接阶段可能不会报错,但运行时就是各种行为异常。
5. 实操心得与后续扩展
整个项目走下来,我有几个比较深的体会,最后分享给你。
5.1 做源码审计容易犯的三个错误
第一个错误是“只看文档不碰代码”。读aapcs64.rst很容易,但真到编译器里排查一个问题时,你才知道规范里那句话说得多模糊。文档只是起点,审计的核心工作量都在源码里。
第二个错误是“把所有 ABI 问题都往调用约定里塞”。调用约定是 ABI 的一部分,但异常处理(EHABI)、CFI(Call Frame Information)、DWARF、.ARM.attributes、枚举类型布局这些都属于 ABI 范畴。很多看起来像“传参错误”的 bug,根因却在栈展开信息生成上。
第三个错误是“不记录映射表”。我一开始审计时嫌麻烦,不建映射表,结果过了两周再回头看,已经想不起来某条规范对应哪个源码文件了。强制自己填表之后,整个审计过程的速度和质量都提升了一个档次。
5.2 源码审计的扩展玩法
abi-aa仓库的价值不止在于审计,它也能拿来当编译器开发的“测试用例来源”。比如你想验证自己新写的后端指令选择逻辑是否符合 ABI,可以直接从aapcs64.rst的描述中提取边界情况,生成成百上千个测试用例,用llvm-mca或FileCheck来跑回归测试。
另外,该仓库的历史 commit 也值得精读。ABI 规范的变更往往伴随着架构演进或者生态兼容性修复,从这些变更中能学到很多架构端的设计思路。比如某个版本修改了 HFA 的成员数量上限,虽然看起来只是把 4 改成了 8,但背后涉及的是硬件寄存器堆的容量约束,以及编译器后端在寄存器分配阶段能否承受更大的分配压力。
还有一种玩法是跨平台对比。把abi-aa和 RISC-V 的 psABI 文档放在一起读,你会发现两类架构的思考方式差异非常大:RISC-V 更强调“极简与可扩展”,而 ARM 在兼容性上花了大量精力。理解这些差异,对做多后端编译器开发的人来说是眼界上的提升。
5.3 关于项目后续方向的一点想法
这个项目做完后,我最想做的事是把它沉淀成一个开源工具:输入一段 C/C++ 代码,自动生成 AArch64 的 ABI 符合性检查报告。拿abi-aa仓库的规范条款做规则库,用 Clang 的 AST Matcher 和 LLVM 的 SelectionDAG 调试接口提取编译过程信息,再比对得到结果。目前市面上很多静态分析工具专注于安全或代码质量,但“ABI 符合性检查”这个点还是个相对空白的区域。
如果你也在做编译器开发或 ARM 工具链适配,我的建议是:别把 ABI 当文档看,把它当代码看,带着“审计”的心态去读abi-aa仓库,配合readelf、objdump、llc -debug-only这些趁手工具,很多问题都会迎刃而解。