解析arm-abi-aa:从ABI契约到编译器落地的实战指南
2026/9/8 19:40:52 网站建设 项目流程

这篇内容不是给你念 ARM 的 Datasheet,也不是整理一份“AArch64 指令大全”。我想聊的是arm-abi-aa这套源码仓库背后的设计逻辑,以及一个做编译器落地的人,该怎么去读它、审它,并且真正把它用到自己的工具链开发里。

先说清楚这是什么。arm-abi-aa是 ARM 官方维护的 ABI 规范仓库,里面存的不只是 AAPCS64 那一份 PDF,而是一整套与 AArch64/AArch32 调用约定、数据类型、重定位模型、异常展开、DWARF 调试信息相关的文档和定义。它决定了你的 C 函数怎么传参、结构体怎么对齐、汇编器怎么生成重定位、链接器怎么解析符号,甚至连调试器怎么恢复栈帧都跟它有关系。

作为一个常年跟编译器后端打交道的人,我的建议是:不要把它当成“参考手册”去翻,要当成“源码”去审。这个仓库里的每一条规范背后,都有对应的 LLVM/GCC 实现、binutils 处理逻辑和实际硬件行为。这篇文章我会从整体架构、审计路线、核心细节、编译器落地几个维度拆开讲,最后再给你一份我平时排查 ABI 相关问题时的实操笔记。

1. Arm-abi-aa 全景拆解:这套仓库到底管了哪些事

1.1 从 AAPCS 到 arm-abi-aa:为什么说“ABI 仓库”不等于“一份 PDF”

很多人对 AAPCS(Procedure Call Standard for the ARM Architecture)的印象停留在“32 位时代的传参规则”,但arm-abi-aa仓库里的内容远不止这些。它把 ARM 架构的 ABI 拆成了多个维度,分别用独立的文档或源码模块维护。我列一下核心组成,你在仓库根目录基本都能找到对应目录或文件:

  • AAPCS64:AArch64 架构的过程调用标准,这是最核心的一份,定义了参数寄存器、栈帧布局、结构体传递规则。
  • AAPCS32:AArch32(也就是 ARMv7 及之前)的过程调用标准,和 AAPCS64 有不少差异。
  • ABI for the C Language:C 语言层面的 ABI,包括基本数据类型的大小、对齐方式、枚举类型映射等。
  • ELF ABI for the ARM Architecture:ELF 文件格式在 ARM 上的具体要求,包含重定位类型定义、节区命名规则、属性节区等。
  • ABI for DWARF:调试信息格式在 ARM 上的约束,涉及 DWARF 寄存器编号映射、CFI 规则等。
  • Exception Handling ABI:异常展开(unwinding)相关的 ABI 规范,AArch64 上用的.eh_frame和旧式 ARM 的EXIDX都在这里定义。

这个仓库存在的意义是:它不光是给读书人看的规范,更是给工具链实现者对照的“契约”。LLVM 后端在实现调用约定时,不是自己拍脑袋设计,而是直接对照 AAPCS64 的条款来实现。同样,binutils 里readelf解析重定位条目时,也是按照 ELF ABI 文档里的定义去解码的。

1.2 版本演进与仓库里的“时间线”

我在审计这个仓库时注意到一个很关键的问题:ABI 是分版本演进的,而且不同版本的 GCC/LLVM 可能实现了不同版本的 ABI。仓库里会保留历史版本的规范文档,或者在一个文档里通过修订记录标注变化点。比如 AArch64 的 AAPCS 就有过关于 SVE(可扩展向量扩展)和 SME(可扩展矩阵扩展)的补充规范,这些后来都被合入了主文档或新增了独立章节。

用实际例子说明:早期 AAPCS64 对va_list的定义是一个指向参数栈区域的指针,但后来 ARM 引入了“参数在寄存器中传递时,va_list也要能访问到寄存器中的值”的规则,于是va_list变成了一个结构体,里面包含了__stack__gr_top__vr_top__gr_offs__vr_offs等字段。如果你用的编译器版本和库文件版本不一致,解析可变参数时就可能出现完全不符合预期的地址,这正是 ABI 版本错配的典型坑。

注意:读这种仓库,第一步不是去背条款,而是先看清这个仓库的“版本-硬件特性-编译器实现”三者的对应关系。我一般会在仓库里先搜SVESMEPAuth(指针认证)、MTE(内存标记扩展)这些关键词,确认目标平台开启的特性,再决定用哪套 ABI 规则去约束自己的代码生成。

2. 源码审计路线图:先建“ABI→实现”映射,再逐层下钻

2.1 审计前需要建立的全局视图

如果你直接打开 arm-abi-aa 仓库一篇篇读,大概率会陷入细节出不来。我建议你先把整个仓库当成一份源码项目的根目录,然后建立一张映射表,把“规范文档的哪个小节”对应到“LLVM/GCC 的哪个文件哪个函数”。这样审计时才有抓手,而不是漫无目的地浏览。

我自己的映射思路大概是这样的:

ABI 主题规范文档位置LLVM 侧关键文件binutils 侧关键工具
整型/指针参数传递AAPCS64Procedure Call StandardAArch64ISelLowering.cppLowerFormalArgumentsLowerCallllvm-readobj查看生成的 reloc
浮点/向量参数传递AAPCS64 中VFP相关小节AArch64ISelLowering.cppCC_AArch64相关代码readelf -A查看属性
结构体返回AAPCS64 里Composite Types小节AArch64TargetLoweringCC_AArch64处理sret的逻辑objdump -d观察调用点
栈对齐AAPCS64 的Stack ConstraintsAArch64FrameLowering.cppemitPrologueemitEpilogueobjdump -d检查sub sp, sp, #N
va_listAAPCS64Variadic实现AArch64ISelLowering.cppLowerVAARGLowerVASTART观察va_arg展开前后的地址计算
重定位ELF ABI 章节ELFObjectWriter.cpp中的重定位类型发射readelf -r查看.rela.*条目

这张表的作用不是让你背下来,而是给你一个审计起点。你读规范时遇到一条规则,就要问自己:这条规则在 LLVM 的哪个函数里体现?我能不能用readelf或者objdump造一个反例来验证?

2.2 审计的四个核心模块

我一般把审计分成四条线,每条线都对应一个独立的 ABI 关注点:

第一条线:调用约定(Calling Convention)。这是最高频的 ABI 场景。目标很明确:AArch64 下,整数、指针、浮点、向量参数各走哪些寄存器,超出部分怎么压栈,结构体怎么拆分。审计重点是x0-x7v0-v7的分配规则,以及x8这个“间接结果寄存器”的处理。

第二条线:内存布局与数据模型。C 语言的charshortintlong、指针在 AArch64 下分别是多少位、多少字节对齐?long double到底是 16 字节 IEEE 754 还是 128 位扩展精度?这些直接决定结构体大小和偏移,一旦和库文件不一致,轻则数据错位,重则直接越界。

第三条线:重定位模型。AArch64 有大量与位置无关代码(PIC)相关的重定位类型,比如R_AARCH64_ADR_PREL_PG_HI21负责计算某个符号所在页的地址,配合R_AARCH64_ADD_ABS_LO12_NC形成页内偏移。审计时要注意不同重定位类型的语义,以及编译器在什么场景下会使用GOT(全局偏移表)。

第四条线:异常展开与调试信息。AArch64 使用.eh_frame来记录栈展开规则,DWARF CFI 指令会被编码到.eh_frame里。审计时重点看栈指针、帧指针的恢复规则,以及RA_SIGN_STATE这种和指针认证相关的伪寄存器怎么处理。

2.3 审什么、怎么审:具体操作步骤

我这里给出一个可复制的审计流程,如果你没有太多经验,直接按这个流程走就行:

  1. 拉取仓库:把arm-abi-aa仓库克隆到本地,同时准备一份 LLVM 源码和一个 AArch64 的交叉编译工具链。
  2. 选择切入点:从一个简单的 C 函数开始,比如int add(int a, int b) { return a + b; },编译成汇编。
  3. 翻阅对应规范:打开 AAPCS64 文档,找到整数参数传递部分,确认aw0bw1、返回值放w0
  4. 对照实现:在 LLVM 源码里搜索AArch64ISelLowering.cpp,找到LowerFormalArguments,看它是怎么根据CC_AArch64分配寄存器的。
  5. 造反例验证:改掉某个参数的类型或顺序,比如把int改成double,观察寄存器分配的变化,再回到规范里找对应条款。

这套流程走完,你对一个模块的 ABI 理解会比单纯读十遍文档都深。因为你在“规范→实现→结果”三者之间建立了一条完整的验证链路。

3. 核心 ABI 细节深挖:调用约定、数据模型与重定位模型

3.1 调用约定里最容易被忽略的“规则优先级”

AAPCS64 对参数传递的规则写得比较细,但初学者很容易只记住“前 8 个整型参数用 x0-x7”,却忽略了两个关键点:

一是参数类型和数量共同决定寄存器分配。比如函数void f(double a, int b, float c),在 AArch64 里ad0bw1cs2,它们各自有独立的编号空间。这里并不是把整型和浮点混在一起统编的,浮点用浮点寄存器序列,整型用整型寄存器序列。审计时要特别留意,因为写后端时最容易在这类“双轨分配”上出错。

二是结构体的“奇异”传递规则。AAPCS64 规定,如果一个结构体的大小是 1、2、4、8、16 字节,且成员都是“单一基本数据类型组合”,它可能被当作一个“基本数据类型”来传递。比如struct { int a; float b; }这个 8 字节结构体,在double寄存器里按一个 64 位值传递;但如果结构体里有超过 16 字节的数组或者成员对齐要求变高,就会退化为“内存传递”——通过隐式指针传参。

这里有一个典型的坑:我在审计时见过一个案例,两个团队分别编译同一个函数接口,一边用 GCC 编译,一边用 Clang 编译,双方对同一个结构体的传递方式产生了分歧。原因是该结构体大小是 24 字节,按照 AAPCS64 应该走内存传递,但其中一边的头文件里结构体定义不对,导致编译器认为它只有 16 字节,于是按寄存器传。结果就是两边函数调用约定不一致,运行起来直接拿到垃圾参数。

3.2 AArch64 的数据模型和栈布局到底怎么算

AArch64 下常用数据模型是 LP64,也就是long和指针都是 64 位,int是 32 位。这看起来没什么好讲的,但配合栈对齐规则就有意思了。

AAPCS64 要求:在执行函数调用之前,栈指针必须 16 字节对齐。也就是说,在一个函数入口,sp是 16 字节对齐的;调用下一个函数之前,当前函数需要保证sp减去参数区和局部变量区后仍然 16 字节对齐。这听起来简单,但要做对,编译器必须综合考虑参数压栈数量、局部变量大小、帧指针是否使用等多种因素。

我来算一个实际例子:假设一个函数有两个局部变量,一个int(4 字节)、一个double(8 字节),同时它还要调用子函数,子函数有 2 个栈传参数(各 8 字节),那么当前函数可能会在序言里执行类似sub sp, sp, #32的操作。这 32 字节怎么来的?参数区 16 字节、局部变量可能要 16 字节(为了对齐,8 字节变量往往要额外垫 8 字节来满足 16 对齐)。如果你手写汇编,算错一位,调用子函数时栈不对齐,碰到用对齐指令(如ldp/stp)的场景就会崩溃。

实操心得:碰到栈对齐问题,不要去猜,直接用objdump -d看生成的指令,或者用readelf -wf.eh_frame里的 CFI 规则,里面记录了函数出入口栈指针的变化量。这个比看多少文档都直观。

3.3 重定位模型:PIC、GOT 与页寻址的联动

AArch64 的指令长度固定 32 位,所以一条adrp指令只能编码一个 21 位的页偏移(page offset),配合 12 位的页内立即数偏移,总共可以寻址 4GB 范围。这个设计决定了它在生成位置无关代码时,经常需要两条指令配合来引用一个全局变量:

adrp x0, symbol@PAGE add x0, x0, symbol@PAGEOFF

@PAGE@PAGEOFF对应的重定位类型分别是R_AARCH64_ADR_PREL_PG_HI21(有的实现也叫ADR_PREL_PG_HI21)和R_AARCH64_ADD_ABS_LO12_NC。在审计 ELF ABI 文档时,你要特别关注这些重定位类型的计算方式:它们算的是符号所在页和当前指令所在页的偏移,而不是符号本身的绝对地址。

再往深一层,如果要支持动态链接,全局变量可能要走 GOT。这时编译器生成的是adrp x0, :got:variable,然后ldr x0, [x0, #:got_lo12:variable],最终通过 GOT 表项拿到变量的真实地址。AArch64 的:got:重定位类型在 LLVM 和 binutils 里都有实现,但细节差别要注意:GOT 条目大小、对齐方式、是否使用LD64_GOT_LO12_NC等都会影响最终链接结果。

我在审计 binutils 的readelf -r输出时,会特别关注.rela.plt.rela.dyn里的条目,观察哪些是JUMP_SLOT、哪些是GLOB_DAT、哪些是RELATIVE。因为这几个类型的处理方式在动态链接器里不同:JUMP_SLOT用于函数地址解析,GLOB_DAT用于数据地址解析,RELATIVE用于基于加载基址的重定位。这类问题在交叉编译场景下特别容易踩坑,尤其是你自己写链接脚本或者做裸机/RTOS 启动代码时,处理错一个重定位类型,程序加载后访问的地址就是错的。

提示:如果你在排查一个“在可执行文件里正常,在共享库里崩溃”的问题,第一反应应该是去看重定位是否发生了变化,而不是去怀疑代码逻辑。把.so里符号的地址分布和.rela.dyn对齐,往往能快速锁定问题范围。

4. 从 ABI 规范到编译器后端:在 LLVM 里落地的完整路径

4.1 找对入口文件:AArch64 后端的关键代码地图

如果说 arm-abi-aa 是“契约”,那么 LLVM 的 AArch64 后端就是“实现”。你要做编译器开发落地,必须清楚地知道契约的每一条在代码里的形状。我给出几个你一定会用到的文件:

  • AArch64ISelLowering.cpp:这是指令选择层里最核心的文件,LowerFormalArguments负责处理函数入口的参数,LowerCall负责处理调用点的参数准备,LowerVAARG处理va_arg
  • AArch64CallingConvention.td:这个 TableGen 文件定义了多个调用约定,比如CC_AArch64_AAPCSCC_AArch64_DarwinCC_AArch64_Apple等。改调用约定,通常从这里开始。
  • AArch64FrameLowering.cpp:栈帧布局和序言/尾声指令的生成在这里,栈对齐、帧指针、叶函数优化都在这处理。
  • AArch64Subtarget.cpp:特性开关在这里定义,比如是否支持 SVE、是否支持 PAuth,这些特性会直接影响 ABI 的某些分支处理。

有一个比较实用的经验:如果你想快速验证“某个参数类型在 AAPCS64 里到底怎么传”,不用去翻文档熬时间,直接在 LLVM 源码里跑一个小实验。写一个 C 函数编译成 LLVM IR,然后用llc指定 AArch64 后端强制输出汇编,观察x0-x7v0-v7和栈的使用,同时和AArch64CallingConvention.td里的规则对照。

4.2 调试后端时的常用手段:从 IR 到汇编的逐步下钻

LLVM 后端调试有一个很顺手的流程,我基本每天都会用:

  1. 先写好 C 源文件,用clang --target=aarch64-linux-gnu -S直接出汇编,快速确认 ABI 层面没问题。
  2. 再用clang --target=aarch64-linux-gnu -O0 -emit-llvm生成 IR,手动或自动进行指令选择。
  3. 想看 SelectionDAG 阶段的信息,用llc -view-dag-combine1-dags之类的选项,可以图形化观察 DAG 变化,但如果没有图形界面,就用-debug-only=isel打印日志。
  4. 想看最终指令和寄存器分配,用llc -stop-after=machine-cp之类的参数控制编译阶段,逐步观察。

这套流程对我来说最大的价值是:它把“ABI 规范”和“编译器生成结果”真正串起来。比如你想知道 AAPCS64 对 16 字节结构体传参的规则,直接写一个 16 字节结构体的函数,从 IR 往下跟,看到CC_AArch64_AAPCS如何把它拆到两个 64 位寄存器里,比读条文要直观得多。

4.3 落地时最容易出错的三个点:寄存器保存、可变参数、尾调用

寄存器保存策略。AArch64 下,x19-x28是被调用者保存的,x9-x15是临时寄存器,x16x17是 intra-procedure-call 临时寄存器。后端在生成函数序言时要决定哪些被调用者保存的寄存器需要压栈,分析每个函数实际用到了哪些。如果分析错了,比如少保存了一个寄存器,函数返回后调用者的局部变量被破坏,这类 bug 极其隐蔽,因为它只在特定代码路径下才出现。

可变参数。C 数va_list在 AArch64 上是一个结构体,里面记录了通用寄存器区、浮点寄存器区、栈区的当前状态。LLVM 后端在LowerVASTART里要生成代码把这个状态保存到va_list中;在LowerVAARG里要根据参数类型决定从哪个区域取值。这个过程中最麻烦的是处理“参数类型在寄存器区和栈区之间切换”的场景,比如printf这种格式字符串里%d%f混合出现的调用。

尾调用优化。AAPCS64 对尾调用有特殊要求:尾调用时被调用者的栈帧必须已经释放,并且参数尽可能复用当前函数的寄存器/栈位置,避免额外压栈。如果后端在尾调用时重新分配了参数寄存器而又没有处理好生命周期,就会把仍在使用中的参数覆盖掉。我在调试过程中见过一个案例:尾调用优化开启后,某个浮点参数的精度偶尔出错,最后定位发现是编译器在尾调用的参数重分配过程中,把一个v0里的值搬到了v1,但原来的v0又被用于传一个整数指针,导致双方互相覆盖。

4.4 如何验证你的改法符合 ABI:工具链测试矩阵

编译器后端的改动不能只靠眼力,必须有一套验证矩阵。我的做法分三层:

  1. 单元级:针对每个函数原型,写一个简单的测试 C 文件,编译后检查汇编是否符合预期。重点覆盖基本类型、结构体、联合体、长整数、双精度浮点、向量类型和可变参数。
  2. 交互级:把新后端编译出来的目标文件和旧工具链编译的目标文件链接在一起,跑一个包含大量 API 调用的测试程序。这样能检查调用约定是否真的双方一致。
  3. 系统级:在真实 ARM 设备或 QEMU 用户态模拟器上跑完整的测试套件。对于 AArch64 来说,QEMU 用户态模式已经能覆盖绝大部分指令和系统调用场景,如果涉及 SVE 等特性,需要开启对应的cpu参数。

实际经验:交互级测试是三个里面最容易被忽视但最能发现问题的。因为单元级测试里编译器和链接器“自己配自己”,就算 ABI 实现有误也可能碰巧一致;系统级测试又往往被工具链环境差异干扰。只有交互级,一边用新后端、一边用老工具链,强制两边“谈判”,才能暴露真实契约偏差。

5. 常见问题与排查技巧实录

5.1 ABI 不一致导致的典型崩溃与快速定位

我整理了一下自己在实际项目中遇到过的 ABI 相关崩溃,基本都逃不出这几类:

现象可能原因快速排查方法
函数返回后栈指针不对,函数指针跳飞调用约定中参数寄存器布局不一致编译后用readelf --debug-dump=frames查看 CFI;或objdump -d对比两个编译单元的序言/尾声
结构体字段错位结构体传递规则版本不一致ptypepahole查看结构体布局;检查#pragma pack-mabi选项
可变参数读到垃圾值va_list的栈/寄存器偏移算法不一致反汇编调用点和va_start的展开代码,观察gr_offs/vr_offs的赋值
全局变量访问地址异常重定位类型或 GOT 条目生成错误readelf -r检查.rela.dyn类型;用LD_DEBUG=reloc运行时观察动态链接器行为
NATO/NEON 向量寄存器被破坏被调用者保存寄存器规则未正确遵守观察-fomit-frame-pointer模式下,被调用者是否保存了<v8-v15>等寄存器

定位这些问题的通用思路是:找一个最小复现用例,编译成汇编,再对照 arm-abi-aa 里的文档逐步排除。不要一上来就到大型项目里翻日志,效率太低。

5.2 工具链之间的“ABI 版本错配”问题

我遇到过最魔幻的现场是:两边用的都是aarch64-linux-gnu-gcc,但一个用的 8.x,一个用的 13.x,结果同一个函数传struct { long a; long b; }都能传错。查下来发现问题不在寄存器分配,而在编译选项:一边用了-mgeneral-regs-only,另一边默认开了 VFP。这就导致一边认为浮点和向量参数走通用寄存器,另一边认为走向量寄存器。

这是 ABI 审计里非常现实的问题:纸面上的 AAPCS64 只有一套,但实际工具链因为配置差异、特性开关、版本演进,会产生各种“方言”。所以我在做编译器落地时,一定会先把目标平台的编译选项固定下来,整理成一份“构建配置基线”。这份基线和 arm-abi-aa 仓库里的规范一起,才是一个完整的、可审计的 ABI 契约。

5.3 交叉编译与链接时最容易被忽略的默认行为

交叉编译时,很多人只顾着指定--target=aarch64-linux-gnu,却忽略了链接器的默认脚本和库路径。AArch64 的 ELF 加载模型里,有一个ld默认生成的_DYNAMIC链接信息,它依赖.dynamic段里各项的正确性,任何一项不对,动态链接器都会罢工。

另外,-Wl,-z,now-Wl,-z,lazy的选择也会影响 GOT 表项的填充时机,这在排查“一启动就崩”和“运行到某个函数才崩”的区别时非常关键。如果你用readelf -d看到FLAGS里有NOW,就知道所有符号都绑定了,启动时就会解析完所有 GOT 项。

5.4 写一个最小的 ABI 一致性验证工具

除了已有的工具链,我建议你在自己的项目里维护一个很小的“ABI 探针”程序。它不需要多复杂,只要能做这几件事:

  1. 定义一组包含各种基本类型和结构体的函数原型。
  2. 编译成静态库或动态库,提供get_version之类的导出函数。
  3. 在宿主程序里以相同原型调用这些函数,检查返回值和缓冲区内容。

当工具链升级或者更换交叉编译目标时,先编译这个探针并跑通,再上大项目。我在团队里把这个叫做“先签合同再打仗”,能省下大量排查 ABI 错配的时间。

5.5 审计过程中的“高频记忆点”速查

最后分享几个我审计时会特别圈出来的关键条款,它们分布在 arm-abi-aa 不同文档里,但又互相影响:

  • AAPCS64 中x8是间接结果寄存器。如果一个函数返回大型结构体,x8会持有返回值的地址,函数内部要往x8指向的内存写结果。这个规则对于手写汇编尤其重要,容易忽略。
  • 函数入口的sp必须 16 字节对齐,但 AAPCS64 并没有强制要求栈向下增长,所以编译器在序言里的常见动作是sub sp, sp, #N,其中 N 通常是 16 的倍数。
  • AArch64 的重定位枚举值按类型分组,数据重定位、指令重定位、GOT 相关、PLT 相关各有区间。你可以用readelf -r连续看几个条目,就能快速判断目标文件的链接需求。
  • 异常展开时,AArch64 的.eh_frame里记录的是叫“状态”的规则,CFI 指令可以精确描述栈指针偏移和寄存器恢复位置。调试崩溃时,gdbbt能不能正确打印调用栈,基本就取决于这一块。

写在最后

坦白说,arm-abi-aa 这套内容本身不会直接让你的程序跑得更快,但它是你在做编译器开发、工具链移植、固件安全审计时绕不开的“底层法律”。我个人的习惯是:不把它当一次性读物,而是当成一个不断回来对齐的基准。每次在 LLVM 后端改了调度策略、调整了调用约定、甚至只是升级了 Clang 版本,我都会重新去仓库里对照相应章节,确认自己的实现没有被某条细节条款打脸。

如果你也在做编译器后端或者交叉工具链相关的事,建议先从一个小函数开始,走一遍“规范→汇编→readelf”的验证链路。等你把这条链路跑顺了,再面对那些复杂特性(SVE、PAuth、MTE)时,就不会觉得 ABI 是玄学,而是一套可以被审计、被实现、被验证的工程契约。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询