1. 先想明白:ABI管住了什么,为什么它比ISA更影响你的日常开发
1.1 从一桩“玄学崩溃”说起
有段时间我经常被问到一个问题:同一份代码,在Keil MDK里把编译器从AC5换成AC6,或者把芯片工程从GCC换到Clang,明明逻辑一行没改,结果程序要么结构体大小变了,要么函数参数传着传着就错位,要么一调用浮点函数就打印出天文数字。很多人的第一反应是“编译器有bug”,再去论坛搜一圈,发现最常背锅的是“优化选项”、是“内存对齐”、是“启动文件有问题”。
这些说法不能说完全错,但都没点到根上。真正的根,绝大多数时候出在ABI上。ABI全称Application Binary Interface,中文通常叫“应用二进制接口”。它规范的是:编译器生成的机器码应该如何传递参数、如何保存寄存器、如何在栈上布局数据、如何编码重定位信息、如何标识符号。如果两个模块不遵循同一个ABI,它们编译出来的二进制连在一起跑,就像两个人用两套暗号对接头,偶尔能对上几句,一旦涉及复杂交互立刻露馅。
所以我一直觉得,不搞懂ABI的ARM开发者,迟早会被ABI上一课。这一课可能是线上设备随机复位,可能是库调用的返回值永远不对,也可能是交叉编译出来的程序一运行就Segmentation Fault。与其等到出事再去翻文档,不如趁着这个标题,把ARM ABI规范仓库的源码审计方法和编译器落地思路一起梳理清楚,这篇文章的目标就是把这三件事做透。
1.2 ISA、ABI、API三者的分工,别混为一谈
聊ABI之前,先把三个很容易混的概念拆开。
ISA(Instruction Set Architecture)是CPU硬件理解的接口,例如ARMv8-A规定的AArch64指令集。它规定了有哪些寄存器、哪些指令、异常模型、内存模型。ISA是硬件设计者和汇编程序员之间的契约,编译器的目标就是生成符合ISA定义的机器指令。
API(Application Programming Interface)是源代码层面的接口,例如C标准库里的printf、malloc,你在源码里调用它们,由编译器负责把它们翻译成对某个库符号的引用。
ABI介于两者之间,它规定的是二进制层面的接口。举几个ABI管的具体事情:int占几个字节、结构体如何对齐、函数参数是用寄存器传还是用栈传、用哪几个寄存器传、返回值放在哪里、哪些寄存器在函数调用之后仍然保持原来的值、共享库的符号怎么命名、动态链接时重定位怎么表达。
如果说ISA是CPU这张“考试卷子”,ABI就是全体考生约定好的“答题格式”:写在哪里、用什么颜色、密封线内能不能答题。API是“题目内容”,ABI是“答题规则”,ISA是“阅卷机的工作语言”。
对普通应用开发者来说,API和ABI基本不需要关注,因为编译器全包了。但如果你在写编译器、移植工具链、做固件对不同工具链产物的互操作,或者在做汇编——那ABI就避不开。尤其是做ARM平台,因为ARM芯片几乎跑在所有地方:手机、路由器、嵌入式MCU、服务器,每个行业都会遇到多工具链协作的场景。
1.3 一个普通函数背后的ABI旅程
看一段最简单的AArch64汇编级的函数调用,你就知道ABI在背后干了多少活。
int add3(int a, int b, int c) { return a + b + c; }用aarch64交叉编译器编译,不带优化,得到类似这样的汇编:
add3: add w0, w0, w1 add w0, w0, w2 ret这里没有栈操作、没有压栈出栈。为什么参数直接出现在w0、w1、w2里?因为AAPCS64(AArch64过程调用标准)规定:前8个整数或指针参数用x0到x7传递,前8个浮点参数用v0到v7传递,返回值放在x0或v0里。
如果你在一个函数里用了这3个参数,又在另一个函数里按“所有参数都走栈”的约定写了汇编,两边一对接,结果就是a拿到的是b的值,语义彻底错乱。而这类问题极难排查,因为编译器单独看每一边都是“正确”的。
所以,ABI规范到的每一项内容,都实实在在影响机器码。理解了这层关系,再去看ARM官方沉淀下来的规范仓库,才能真正读出价值来。
2. 全景扫描:abi-aa规范仓库里到底有什么
2.1 ARM官方ABI文档的在线形态
如果你去翻ARM的开源组织,会发现一个仓库叫abi-aa,这个仓库的全称差不多是“ABI for Arm Architecture”,里面维护的就是ARM架构ABI规范的源文件。注意,不是PDF,是源文件。
以前查找ARM ABI规范,大家的习惯是去ARM官网下载PDF文档,或者去developer.arm.com一页页点。这些文档当然是权威的,但它们是发布态产物。而abi-aa仓库保存的是规范的“源码形态”,用AsciiDoc之类的标记语言组织,通过CI流程生成HTML或PDF。对做源码审计的人来说,这个仓库比PDF有价值得多,因为你可以看git历史、看每次提交的差异、看issue区里的讨论、清楚知道某一条规则是在哪个版本引入的。
仓库结构大致是每个子规范一个目录,各自独立维护版本。主干子规范有这些:
| 子规范 | 全称 | 覆盖范围 |
|---|---|---|
| AAPCS32 | AArch32过程调用标准 | 32位ARM指令集和Thumb指令集的参数传递、寄存器用法、栈规则 |
| AAPCS64 | AArch64过程调用标准 | 64位AArch64指令集的调用约定、寄存器角色、栈规则 |
| AAELF32 | AArch32 ELF规范 | 32位ELF文件格式、符号表、重定位类型 |
| AAELF64 | AArch64 ELF规范 | 64位ELF文件格式、重定位类型、动态链接相关定义 |
| CPPABI | C++ ABI | 名称修饰(name mangling)、异常处理、运行时类型信息等C++相关约定 |
| CLIBABI | C库ABI | C标准库函数层面的二进制接口约定 |
我见过有初学者把ABI规范直接等同于AAPCS64,这是不够完整的。调用约定只是ABI的一部分,ELF的重定位、异常展开、C++符号修饰,都属于ABI规范的管辖范围。只不过日常开发里,调用约定最容易感知,也最容易被自己的代码“踩到”,所以大家印象最深。
2.2 用审计源码的方式去读规范仓库
读这类规范仓库,我的方法是当成一个软件项目来审计,而不只是查手册。
第一步,clone仓库到本地,先看README和主目录的文档索引,搞清楚每个子规范的适用范围和当前版本号。第二步,看最近一段时间的提交记录和标签,了解你关心的架构版本对应的文档基线。第三步,针对你要落地的特性(比如新增了一种函数调用约定、要支持新的重定位类型),找到对应子规范的对应章节,把原文从头到尾过一遍。
这种读法的好处很明显:规范仓库里的版本号不是摆设,AAPCS64文档本身就有版本演化。例如在支持ARMv8.5-A的某些新特性后,涉及内存标记、分支目标识别等机制,调用约定的某些约定也会被修订。你要是不关注版本,随便拿一版规范去实现,很可能和当前主流的GCC/LLVM行为不一致。
另外,仓库的issue区是宝库。很多工具链实现者、内核开发者会在里面讨论某条规则在极端场景下如何理解。比如说“这个结构体含位域,到底按什么规则传递”,在规范文本里可能只写了一句,但在issue里会有多个编译器开发者给出各自工具链的实际行为,这些信息在实际做兼容时价值极高。
2.3 从ATPCS到AAPCS再到AAPCS64,规范演进说明了什么
ARM过程调用标准不是一开始就叫AAPCS。早年32位ARM时代有ATPCS,ARM-Thumb Procedure Call Standard。那时候的调用约定和现在有很多差别,最明显的是寄存器角色的划分没有现在这么严格,栈对齐要求也没有AArch64那么苛刻。
后来演进到AAPCS,把参数传递、寄存器保存责任、栈布局的规则进一步明确化,例如引入了“基础调用标准”和“扩展调用标准”的分层。到64位时代,AAPCS64做了非常大胆的简化设计:整数参数统一走x0-x7,浮点走v0-v7,栈对齐固定16字节,叶子函数可以不用栈帧。
看这段历史,你会发现ARM的ABI设计并不是一成不变的,它是随着处理器架构的复杂度、编译器实现的能力、软件生态的需求在调整。如果你在维护一个老项目,老工具链基于ATPCS的很多习惯行为在AArch64时代已经完全失效,这就是为什么从ARM32迁移到ARM64时,直接重编经常出现奇怪问题——根本不是CPU指令集的问题,而是ABI规则变了,编译器按新规则生成,但你手写的汇编还是老规则。
3. 源码级审计:我是怎么对着规范仓库做交叉验证的
3.1 审计前准备:工具链与材料清单
如果你要验证一个工具链是否真正遵循了ABI规范,或者要在自己的编译器实现里核对行为,我建议先准备好这四样东西:
- 规范仓库源码(
abi-aa的本地clone) - 目标架构的交叉编译器,至少一个GCC一个Clang,方便做行为对照
- 反汇编和ELF查看工具:
aarch64-linux-gnu-objdump、readelf、nm、llvm-objdump - 一个能快速生成测试用例的脚本环境(Python就够用)
审计的核心思想很简单:从规范文本推导出“编译器应该生成什么样的机器码”,然后实际操作编译器,看它生成的机器码与推导是否一致。两边互相印证,比空读文档有效得多。
3.2 审计案例一:超过8个整数参数时,参数怎么放
AAPCS64规定得很清楚:第1到第8个整数或指针参数使用x0到x7;第9个及以后的参数,按顺序压到栈上。注意,这里说的“栈上”不是简单地压栈,而是按参数列表的顺序从低地址到高地址排列,第一个栈参数放在调用者栈帧里SP的位置。
我写了这样一个测试函数:
int sum9(int a0, int a1, int a2, int a3, int a4, int a5, int a6, int a7, int a8) { return a0 + a1 + a2 + a3 + a4 + a5 + a6 + a7 + a8; }用GCC编译到汇编,核心片段是这样的:
sum9: add w0, w0, w1 add w0, w0, w2 add w0, w0, w3 add w0, w0, w4 add w0, w0, w5 add w0, w0, w6 add w0, w0, w7 ldr w1, [sp] add w0, w0, w1 ret看到了吗,前8个参数全在寄存器里,第9个参数a8是从[sp]加载的。这说明AAPCS64关于栈传参的规定在GCC里确实被执行了。
这里有个容易忽略的点:参数入栈的顺序与C源码声明顺序一致,而且编译器不负责在调用返回后清理栈参数,由调用者负责平衡栈。这是与x86-32时代的cdecl约定完全不同的地方,做底层移植的老手也会偶尔在这上面栽跟头。
3.3 审计案例二:SP的16字节对齐约束
AAPCS64里有一条让很多人初看觉得“强迫症”的规则:在任何对外调用发生的位置,栈指针SP必须是16字节对齐的;函数入口时,SP的值应当是16字节的整数倍。背后的原因是AArch64的原子操作、某些加载存储指令要求更高的对齐,同时ABI希望通过统一的栈对齐规则简化编译器实现和在栈上使用SIMD类型(比如128位的float32x4_t)时的布局。
看一个简单的非叶子函数最直观:
int outer(int n) { int local = n + 1; return inner(local) + local; }GCC生成的汇编大体是:
outer: stp x29, x30, [sp, -16]! mov x29, sp add w0, w0, #1 str w0, [sp, #12] bl inner ...stp x29, x30, [sp, -16]!这条指令将栈指针先减16,然后把帧指针和返回地址一并存入新栈顶。为什么一次存两个寄存器而不是分别压栈?因为压栈时如果只压一个64位寄存器,SP会变成8字节偏移,破坏16字节对齐;用stp一次压两个寄存器,正好保持对齐。这是ABI规则直接塑造指令选择的一个经典例子。
如果你审计的编译器在某个路径上没有生成这样的对齐方式,结果就是调用一个外部函数时SP不是16字节对齐,被调函数里一旦涉及需要使用对齐栈的操作(比如ldp或SIMD加载),就会触发总线错误或性能异常。这种bug极其隐蔽,因为它不崩溃则已,一崩溃就是全局性的。
3.4 审计案例三:重定位类型与长跳转
调用约定之外,ELF重定位是ABI审计的另一大重点。AArch64的重定位类型用R_AARCH64_*前缀,常见的有R_AARCH64_CALL26、R_AARCH64_JUMP26、R_AARCH64_ADR_PREL_PG_HI21、R_AARCH64_ADDABS_LO12_NC等等。
R_AARCH64_CALL26专门用于BL指令。AArch64的BL指令编码中立即数只有26位,按4字节对齐计算,最大跳转范围是±128MB。如果链接器在解析一个函数调用时发现目标超出了这个范围,就不能直接使用BL了,需要编译器生成跳转桩或者改用ADRP+ADD+BR间接跳转序列。但这个转换不是普通重定位能自动解决的,它依赖编译器和链接器的配合。
实际操作中,你可以用readelf -r去查看一个目标文件的重定位表,对照规范文档验证每一个重定位类型的使用场景。我曾在自研工具链的调试中发现过一个案例:编译器对全局变量的访问使用了R_AARCH64_ADR_PREL_PG_HI21加R_AARCH64_ADDABS_LO12_NC的组合,但目标变量的对齐要求超过页面大小,结果加载出来的地址低位被错误清零。这种问题如果不查AAELF64规范,根本不知道是重定位选择错了。
3.5 审计过程中容易踩的坑
第一个坑:用-O0的汇编输出做唯一依据。-O0下编译器会生成大量栈访问指令,很多寄存器的使用看起来和-O2完全不一样。ABI审计应以优化后但尚未链接的中间文件为准,因为那才反映工具链对调用约定的真实处理。
第二个坑:只测编译器生成的代码,不测手写汇编的边界。很多嵌入式项目里有关键函数是纯汇编写的,如果汇编源文件用的是另一套约定,ABI一致性立刻破功。审计时要有意识构造“C调汇编、汇编调C”的用例。
第三个坑:忽略重定位和动态链接的审计。调用约定对了不代表ELF合规,很多质量差的工具链在静态调用上没问题,一开PIC或者动态加载就出问题,根源往往是AAELF规范里的重定位要求没有实现完整。
4. 编译器开发落地:从规范条款到CodeGen的翻译过程
4.1 调用约定在编译器后端的着陆点
如果你拿到一份ABI规范,要去修改一个真实存在的编译器,第一步不是急着改代码,而是先找到编译器后端中处理调用约定的那一层。
以LLVM为例,调用约定分散在几个层面:CallingConv枚举标识有哪些约定;TargetLowering类里有LowerFormalArguments(处理函数入口接收参数)和LowerCall(处理函数调用时传参);再往下是CCState和CCAssignFn,负责真正决定每个参数分到寄存器还是栈。AArch64后端的这些逻辑在AArch64CallingConv.td里,以TableGen描述文件的形式体现。
GCC的结构不一样,它通过target hook机制实现,比如TARGET_FUNCTION_ARG就是决定某个参数如何传递的核心钩子。ARM后端在config/arm/arm.c里做了大量实现。思路是一样的:规范里写的“整型参数依次放入x0-x7”,翻译成代码就是“遍历形式参数列表,对每个参数,如果是整型或指针且尚未用满8个寄存器,就分配给定寄存器”。
最开始做这件事容易陷入一种误区:想从头到尾读完整个后端代码再动手。没必要。你只需要锁定调用约定相关的几个文件,先把参数分配逻辑读通,然后对着规范一条一条改就行。有个前提是你要看得懂TableGen和td文件的基本语法,这个门槛不高,花个半天就能上手。
4.2 一个最小实践:在LLVM后端定制简化AAPCS64约定
拿LLVM的AArch64后端举例,如果你想新增一个自定义调用约定,比如“所有参数都通过栈传递,不用寄存器”,大致的路径是这样:
第一步,新增一个CallingConv枚举值,定义在include/llvm/IR/CallingConv.h里,并同步更新LLVM的文本表示。
第二步,在AArch64CallingConv.td里新增一个CCIfCC规则,例如:
def CC_AArch64_MyCall : CallingConv<[ CCIfType<[i32, i64, v4f32, v2f64], CCAssignToStack<8, 8>> ]> { }这只是示意,真正的实现比这个复杂,但核心逻辑就是这样:通过CCIfType做类型匹配,通过CCAssignToStack把参数分配到栈上。
第三步,在AArch64ISelLowering.cpp中,把自定义的低级调用约定和这个CC规则关联起来,让LowerFormalArguments和LowerCall走到新分支。
这里最需要理解的是:调用约定的实现不只是一个“分配函数”,它还牵扯到栈帧布局、参数在入口如何被保存和复用、返回地址如何管理、以及被调用者保存寄存器的维护。你把参数改成全部走栈,就必须同步调整栈帧的大小计算和访问偏移,不然就算参数到了栈上,函数体内访问的位置也可能对不上。
我不建议在真实产品里去搞一个完全自定义的调用约定,因为一旦打破与C库、操作系统、其他模块的兼容,代价非常大。但作为学习编译器后端的实验项目,这个练习极其有价值,它强迫你把LLVM后端里最关键的几个环节都过一遍。
4.3 寄存器分配时最容易被漏掉的:Callee-Saved寄存器
AAPCS64规定了一组“被调用者保存”的寄存器,也就是函数被调用的过程中,如果你要用这些寄存器,必须先保存原来的值,在返回前恢复。这组寄存器包括x19到x28、v8到v15的一部分,以及帧指针x29和返回地址x30。
对普通编译器使用者来说,这条规则是透明的;但对编译器开发者来说,这是ABI里最容易被漏掉的部分。我做自研工具链时踩过一个非常经典的坑:后端的寄存器分配器在实现时只想着用寄存器提升性能,把一个循环变量分配到了x19,却没在函数的进出处保存和恢复x19。结果函数跑完后,调用方的循环变量被默默改掉了。表现为程序不崩溃,但行为完全随机——有时候循环次数变多,有时候变量从某个奇怪的值重新开始,排查了很久才发现罪魁祸首。
所以当你拿到一个ABI规范,看到“寄存器角色表”那一章时,不要只关注参数寄存器,callee-saved寄存器列表同样关键。我建议在落地调用约定的同时,把你的后端的getCalleeSavedRegs实现和规范逐行对照检查,一个都不能少。
4.4 不只是传参:从ABI到ELF、异常和调试信息的联动
很多人觉得“我写个编译器,只要能生成正确的机器码就行了”。在玩具编译器阶段确实如此,但要在真实操作系统上落地一个能用的工具链,ABI涉及的远不止传参。
第一块是符号修饰与ELF语义。C++程序里每个重载函数的符号都不相同,依赖的是Name Mangling规则,例如_Z3addii代表add(int, int)。这块如果不按CPPABI规范走,链接时就会出现“定义存在但找不到符号”的诡异问题。
第二块是异常展开。AArch64的异常处理依赖.eh_frame和.ARM.exidx这类展开表,调试器、backtrace函数、C++异常机制都依赖它们。如果ABI里关于栈布局的记录和实际代码不一致,抛异常时栈回溯直接罢工。
第三块是调试信息。DWARF调试信息中的栈帧基址、寄存器位置表达式都必须和编译器生成的真实栈布局一致。也就是说,ABI不只约束运行时代码,还约束调试器怎么还原现场。
所以做编译器落地时,我建议把规范文档分成两块看:运行时可见的部分(调用约定、寄存器角色)和编译产物可见的部分(ELF格式、重定位、异常表)。前者决定程序能不能跑,后者决定程序跑挂了之后你能不能查。
5. 一套可用的验证方案和一个经典排障案例
5.1 可复现的ABI交叉验证步骤
审计和落地的最后一步都是验证。我自己的做法是维护一个小型ABI测试集,每次改动工具链后跑一遍,基本能覆盖ABI的绝大部分要点。
测试用例分成5组:
- 基础调用:不同数量、不同类型(int、long long、double、指针)参数的函数,编译后互相调用,检查返回值。
- 结构体传参:包含1个、4个、8个、16个字节结构体的传参,注意寄存器分配和栈拷回行为。
- 浮点与SIMD:单精度、双精度、向量类型参数的分配,以及大小端下的数据排列。
- C与汇编互操作:每个用例都同时准备C版和汇编版实现,验证双向调用。
- 跨编译器互操作:GCC编的C代码调用Clang编的静态库,或反过来。这一组最容易暴露规范理解不一致的问题。
执行时有一个技巧:用__attribute__((noinline))避免编译器把测试函数直接内联掉,否则很多调用约定细节根本不会在机器码中体现。另外,优化级别至少要测-O0和-O2两种,因为两个级别下栈帧结构、参数保存方式差异很大,很多ABI兼容问题只在特定优化级别下出现。
5.2 一个经典事故排查链路
我把一个真实排查过的问题简化后分享给你,它很能说明ABI错误的长相。
故障现象:一个RTOS系统里,任务A调用一个由汇编实现的信号处理函数后,任务B的某个全局变量值随机变化,整个系统最终看门狗复位。
排查过程:
第一步,从崩溃现场的反汇编入手,发现信号处理函数返回后,调用方的x19值比调用前小了一个固定数值。
第二步,对照AAPCS64,确认x19属于被调用者保存寄存器,调用方期待被调函数不修改它。
第三步,检查汇编函数实现,发现它确实用了x19做临时变量,但函数末尾没有恢复原值。
第四步,修复:在汇编函数入口压栈保存x19,返回前弹出恢复。
这个问题的根源是不是ABI规范写得不够清楚?不是,规范写得非常清楚。问题在于手写汇编的人跳过了规范,或者根本不知道x19是callee-saved。如果你自己写编译器,后端生成的代码也会犯同样的错误,而排查这个错误的路径,就是从崩溃点反推寄存器状态、再拿ABI规范比对。这套功夫就是ABI审计的核心能力。
5.3 关于CI和ABI兼容的一些建议
工具链团队或者嵌入式团队如果有条件,我强烈建议把ABI测试集接入持续集成。每次升级GCC、Clang、AC5换AC6,或者改了自己的编译器,都自动跑一遍。别看这些用例写起来简单,它们能挡住大多数换工具链后的“玄学故障”。
另外一个实用建议:跨工具链合作时,尽量保留产物信息。每当有人报告一个可疑的ABI问题,第一时间让他提供编译器版本、架构选项、优化级别、反汇编片段和ELF文件哈希。没有这些信息,ABI问题排查等于蒙着眼睛找路。
如果对照规范后仍然觉得某个行为有歧义,或者怀疑规范本身存在漏洞,可以直接去abi-aa仓库的issue区提问题。ARM开源团队对这类问题的响应是认真的,但提交时务必带上最小复现用例、相关章节编号和你测试过的工具链列表,这样才可能得到有效回应。
我自己在持续做ABI相关开发的这段时间里,最大的感受是:规范里每一个看似偏执的规定,背后几乎都能找到一段真实的事故。16字节对齐是为了SIMD和原子操作稳定,Callee-saved寄存器的划分是为了让函数边界清晰,重定位类型的细化是为了让大地址空间下的代码生成更高效。你越早把这些底层约定吃透,在做ARM开发和编译器适配时就越不容易被那些“看起来无解的崩溃”困住。想深入这一块,不必等什么大项目,从自己手头的工具链开始,拿规范和反汇编互相验证,走一遍,收获远超预期。