资讯动态

深入ARM ABI规范:从arm-abi-aa分支到编译器开发实践

发布时间:2026/9/9 9:08:35 来源:尧图企业网站定制
上个月我连续熬了三个晚上把 ARM 官方那个abi-aa规范仓库从头翻到尾又顺手把llvm-project里一个叫arm-abi-aa的分支拉下来做了源码审计。起因倒不是什么宏大的项目规划就是帮朋友定位一个产品在 aarch64 平台上的段错误排查到最后发现是编译器生成的代码对栈对齐的理解和 ABI 规范要求差了几个 bit。这种问题文档里翻不到搜索引擎也救不了只能回到源头去啃规范。这篇东西不是教科书也不是官方手册的翻译版。我想把这一轮查资料、读源码、做验证的过程原原本本记录下来Arm-abi-aa 分支到底解决了什么问题ARM 的 ABI 规范仓库里有哪些关键文档值得逐字读以及如果你打算基于这套东西做编译器开发或交叉编译真正落地的路线该怎么走。无论你是被一个问题逼到源码面前还是想系统地理解 ARM64 平台的二进制接口这篇内容应该都能少走点弯路。1. 从 Arm-abi-aa 这个名字开始一个分支解决什么先说结论arm-abi-aa不是一个独立项目也不是新编程语言更不是某种硬件开发板型号。它是 LLVM 项目里 ARM 官方长期维护的一个分支通常以arm-abi-aa命名常见 tag 形如llvmorg-10.0.1-arm-abi-aa。这名字拆开看就是“ARM ABI AA”AA 在这里是 ARM Architecture 的缩写整条线其实就是 ARM 工程师在 LLVM 上游维护的一套工具链分支。我当初第一次看到这个分支名时下意识以为只是个临时工作分支毕竟 LLVM 上游每天都有几十个分支在并排跑。但拉下来看 git log 之后发现这个分支的提交记录跨度很大而且相当多改动是针对 aarch64/aarch32 后端、运行时库、编译器内置函数这些和 ABI 高度相关的模块做的。后来我去查 ARM 社区的资料才明白这个分支承载了一个很实际的任务把 ABI 规范的改动同步到开源编译器里让下游厂商能基于它做验证和二次开发。很多人在做 ARM Linux 交叉编译时会拿系统自带的 gcc-aarch64-linux-gnu 编译完就跑根本不会接触到 ABI 这一层。但一旦你开始做编译器定制、安全特性适配、或者跑一些对调用约定极其敏感的系统级软件比如 u-boot、TF-A、内核模块AArch64 的 ABI 细节就会从“看不见的规则”变成“无比具体的坑”。1.1 ABIs不止是 aapcs64很多人以为 ARM 的 ABI 就是“前 8 个参数放 x0-x7返回值放 x0”这只是叫 AAPCS64Procedure Call Standard for the Arm 64-bit Architecture的一小部分。完整的 ABI 体系要比这大得多单是 ARM 官方的abi-aa仓库里就有二十多个规范文档覆盖了 ELF 文件格式、DWARF 调试信息、C ABI、CFI 展开规则、安全调用约定PAC、异常处理、独特的向量寄存器使用规则、以及运行时 ABI 等等。对做嵌入式系统的人来说最要命的通常不是“参数放哪个寄存器”而是几个看似冷门的方向参数是浮点数、复合体、复数、或者超过寄存器数量的超长参数列表时规则怎么变栈帧的对齐规则、红区red zone是否存在AArch64 用户态没有红区但内核态在某些场合有特殊性结构体返回时到底是 x8 传地址还是按寄存器拆分可变参数varargs的寄存器保存区和栈传递规则和 .cfi 展开规则相关的栈回溯信息这直接决定崩溃日志能不能还原出调用栈。这些细节全部集中在 AAPCS64 和 AAELF64 两篇规范里。如果只背结论不理解设计动机遇到跨编译器场景比如你的动态库是 Arm Clang 编的App 是 GCC 编的就会很被动。1.2 用户态和内核态的 ABI 也有些“微妙”差异还有个常见误解是“ABI 规范是给编译器看的内核反正自己定义接口”。这话对一半内核和用户态的接口syscall ABI确实由内核定义不归 AAPCS64 管例如系统调用号放 x8函数调用参数用 x0-x7内核对每个 syscall 都有自己的一整套约束但内核编译出的模块、vDSO、以及内核与固件比如 TF-A 通过 SMC 调用之间的数据交换还是要遵守 ELF 重定位和寄存器使用的基本约定。我在实际排查飞腾平台上的一个 IO 问题时就发现同样是 ARM64 架构厂家固件对 SMC 调用里的参数寄存器和返回码定义跟 ARM 官方推荐的标准有出入如果用编译器自带的头文件和内联汇编裸调一不小心就会传错寄存器。这类问题[规范仓库里其实有迹可循]只是很少有人顺着“源码-规范-反汇编”这条线去查。1.3 为什么这件事和编译器开发强相关编译器不是魔法。从 C 语言到汇编再到机器码每一层都受 ABI 规则约束。LLVM/Clang 的后端AArch64 backend里几乎每个指令选择、寄存器分配、栈帧布局的决策都必须对齐 AAPCS64 和 AAELF64。如果你只是把编译器当成“黑盒来用”那没问题但如果你想做下面这些事情就必须吃透 ABI自研或者裁剪编译器例如只保留某种 armv8.x 微架构特性给自家硬件平台定制函数调用约定例如某些厂商希望保留 x19-x28 给特殊用途移植新语言运行时Rust、Go、Swift 的 AArch64 移植都绕不开这套规则编写和校验汇编器/链接器/反汇编器审计第三方闭源库的兼容性判断它到底能跑在哪个指令集版本上。所以开发一款针对 ARM64 的编译器本质上是实现一套 ABI 规范的过程这话毫不夸张。2. 把 ARM 官方 ABI 规范仓库拉下来我做了什么源码审计不能空口说白话。我直接把 GitHub 上ARM-software/abi-aa仓库拉了下来。这个仓库用 Sphinx 组织的文档比较规范也有原始 reST 源文件方便 diff 和自动化处理。2.1 审计清单我翻了哪些关键文档整个仓库里有多份规范文档不同时期版本不同。我按自己实际需求列了一个优先级清单如果你时间有限建议也从上往下看优先级文档你需要知道的重点1AAPCS64过程调用标准、寄存器职责、参数传递、返回规则、栈布局2AAELF64ELF 格式、节区、符号表、重定位类型、线程局部存储3CPPABIC 的虚表布局、RTTI、异常处理、构造函数/析构函数约定4CFI调用帧信息、异常展开与栈回溯规则5Runtime ABI各种运行时辅助函数的调用方式、栈探测与错误处理6ACLEARM C Language Extensions内建函数与类型定义比如arm_acle.h我做审计时主要关心两个问题一是这些规范有没有互相矛盾的地方二是规范和 LLVM 当前实现的偏差有多大。前者靠通读后者靠读代码和做实验。比如 AAPCS64 里会明确说x0-x7 是参数寄存器也是 volatile调用者保存x8 是间接结果寄存器也可以作为临时寄存器使用x9-x15 是临时寄存器调用者保存x19-x28 是被调用者保存的寄存器x29 是帧指针如果使用x30 是链接寄存器SP 必须保持 16 字节对齐某些场合是 8 字节但用户态基本按 16 字节处理此外还有特殊用途寄存器如 x16/x17IP0/IP1用于 linker 蹦床、x18平台寄存器通常不用。这些规则编译器后端在生成函数序言/尾声时都必须精确落实。比如 LLVM 生成的函数开头通常有stp x29, x30, [sp, #-16]!就是把帧指针和返回地址成对保存到栈上同时调整 SP。如果换成str单独保存栈对齐就乱套了。2.2 比较有意思的发现GNU 和 ARM 的“ABI 版本差异”我读仓库时发现ARM 官方规范本身也分两个分支线一个面向 GNU 工具链生态一个面向 ARM Compiler 6 (基于 Clang) 生态。它们大部分时候一致但细节上有些差异比如某些重定位类型的支持状态、TLSDESC 的使用要求、以及-mno-outline-atomics这类原子操作辅助函数的调用约定。真实世界里最常见的冲突场景是你用一个老版本 ARM Compiler 5AC5编的核心库换到 ARM Compiler 6AC6或者 GCC 去链接结果链接器报出一堆 “relocation truncated to fit” 或者 “requires unsupported dynamic reloc”这大概率不是代码逻辑错了而是不同工具链对某些重定位类型和 ABI 辅助符号的理解不同。我一直建议新项目直接用 AAPCS64 对应的现代工具链AC6 / Arm Clang / aarch64 GCC老项目要升级时务必做 ABI 兼容性检查不要想当然地认为“都是 ARM64必然兼容”。2.3 源码审计的三个隐蔽陷阱提几个审计规范时容易忽略的点都是我自己踩过的第一规范版本和补丁状态。GitHub 上的main分支是最新的但你的编译器可能只实现了某个特定版本比如 ABI r2023Q4需要盯着 git tag 看。你拿main上的新约束去要求旧编译器只会徒增困惑。第二AAELF64 和重定位表。许多做底层编程的人以为重定位是链接器的事和编译器无关。但实际上编译器生成的R_AARCH64_CALL26、R_AARCH64_ADR_PREL_PG_HI21、R_AARCH64_LDST64_ABS_LO12_NC等等重定位类型全在 AAELF64 里定义。审计代码生成时必须对着重定位表逐一核对。第三C ABI 容易被忽略。如果你只做 C 代码CPPABI 可能不关心。但项目里只要涉及 C哪怕只是一个构造函数vtable、typeinfo、异常展开表的布局就要跟编译器实现的规则一致。Linux 发行版之间碰到的 “libstdc and libc ABI 不兼容” 问题根子大多在这份规范。3. 编译器开发落地的几条主线从 AC5 到 Arm Clang再到自研后端明确规范之后真正的活儿来了把这些规范落到编译器里。当前实际项目里最常见的一条路径是从老掉牙的 ARM Compiler 5 迁移到现代 Clang/LLVM 体系。顺便说一句很多人到处找arm compiler 5.06u7 下载我非常理解——一些老芯片原厂 SDK 还绑着 AC5。但 AC5 本身早已停止更新新的硬件特性比如 armv8.6 之后的若干扩展它根本管不了。建议把 AC5 当作“遗留兼容工具”而不是长期依赖的开发工具。3.1 AC5 到 AC6 的迁移不只是换了个编译器AC5 的编译器核心是 ARMCC而 AC6 的核心是 Clang/LLVM二者对 C 语言标准和内联汇编的处理有本质差异。我在帮一个客户迁移一个物联网网关产品时遇到过几个典型问题__force_strict_alignment这类 AC5 专有关键字AC6 不认识得改成__attribute__((aligned))或重新设计数据结构__irq、__fiq这类中断函数修饰符在 AC6 里推荐用__attribute__((interrupt(IRQ)))或者直接在汇编里包装内联汇编语法AC5 用__asm { ... }的写法在 AC6 里会报一堆错要改成 GCC 风格asm volatile(...)代码大小和性能的平衡Clang 的优化策略跟 ARMCC 不同有些原本在 AC5 上通过特定姿势“压”出来的体积到 AC6 上需要重新做 PGO 才能追回来。迁移时建议先把编译选项梳理一遍--cpu换成-mcpu--apcs换成-mabi--fpu换成-mfpu。尤其要注意-march和-mcpu必须是目标平台实际支持的指令集版本很多问题不是编译器错了是你把 armv8.2 的代码跑在了只支持 armv8.0 的板子上。更底层地讲AC5 的代码生成策略仍然遵循 ARM ABI但 AC6 的实现更贴近规范里“现代用法”的那套推荐路径。比如 AC6 默认使用x18作为平台寄存器时更谨慎、对不同重定位类型的生成本身也更符合 LLD 的预期。3.2 调用约定与寄存器分配规范如何落进 LLVM 代码如果自己维护 LLVM 的 AArch64 后端最常改的文件大概是AArch64ISelLowering.cpp、AArch64CallingConvention.td、AArch64FrameLowering.cpp、以及AArch64RegisterInfo.td。它们分别负责指令选择、调用约定、栈帧布局、寄存器分配。举个例子AAPCS64 规定当返回一个比较大的结构体时用 x8 传返回缓冲区地址函数返回时 x8 保持不变。LLVM 在LowerFormalArguments和LowerCall里对这个行为有非常具体的实现。如果你改坏了一个返回值路径编译出的函数可能出现“返回值拿到一堆垃圾”的诡异现象。另一个高频坑是尾调用优化。AAPCS64 对尾调用要求被调用者保存寄存器集合保持一致如果调用方和被调用方的参数布局不匹配强行br会搞乱寄存器状态。LLVM 有个专门的isEligibleForTailCallOptimization逻辑来判断能否做优化。我在审计一个项目时发现它的某些函数因为参数太多根本走不了尾调用但 Clang 仍然生成了b指令结果每次返回都跳到错误地址。后来我去对规范才发现参数不超过 x0-x7 且栈布局相同才能安全尾调用。3.3 LTO、内联汇编与定位问题真实开发里反复出现的场景编译器落地不是“能编过就算赢”。在实际项目中最影响交付节奏的其实是 LTO链接时代码生成和手写内联汇编。LTO 场景里函数会被跨编译单元地内联和重排个别情况下会把原本正确的浮点运算顺序改到不符合规范约束导致调试输出和发布版行为不一致。要控制这类问题我有几个建议对性能关键且行为敏感的模块先单独关掉 LTO确认是不是优化引起的用-fno-strict-aliasing避免严格的别名假设做 LTO 前先固定-mabilp64、-marcharmv8.2-a...这类指令集和 ABI 参数确保所有单元保持一致审计时重点看.ll中间产物里的tail call和musttail标记很多崩在这里。至于内联汇编AArch64 上最容易踩坑的是忘记memoryclobber导致访存指令被重排64 位系统里用r约束传一个 32 位寄存器数据被截断在内联汇编里改了 x19-x28 却不在 clobber 列表里声明调用者保存的寄存器被破坏汇编指令用到了输出操作数但把它标成了早期快穿earlyclobber导致编译器优化出一个错误寄存器分配。这些说来都是“细节”可真到了调硬件 GPIO、配置 MMU、读系统计数器的时候任何一个错都会让人抓狂。所以工具链开发或定制过程中的测试必须覆盖手写汇编用例。4. 把理论落地一份最小可复现的 ABI 验证清单纸上谈兵到这里为止。下面分享我基于arm-abi-aa分支和相关工具构建的一套最小验证流程。如果你正在评估编译器行为、做交叉编译或者准备开发自己的后端可以直接跑起来。4.1 首先准备好环境和源码我是用一个干净的 Ubuntu 环境做的交叉编译目标为 aarch64。大致步骤# 1. 克隆 LLVM 的 arm-abi-aa 分支 git clone --branch arm-abi-aa https://github.com/llvm/llvm-project.git llvm-arm-abi-aa # 2. 构建仅包含 clang 和 lld 的工具链 cd llvm-arm-abi-aa mkdir build cd build cmake -G Ninja ../llvm \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDAArch64;ARM \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_ASSERTIONSON ninja clang lld如果只做验证不必构建全部 LLVMclang lld 已经够用。-DLLVM_ENABLE_ASSERTIONSON建议开着编译器内部断言能帮你提前发现 ABI 相关的不变量被破坏。4.2 验证调用约定最简单也最关键写一个最简单的 C 函数然后用 clang 生成汇编并对照 AAPCS64// test_abi.c long add_and_return(long a, long b) { return a b; } struct Pair { long x; long y; }; struct Pair make_pair(long x, long y) { struct Pair p {x, y}; return p; }用下面的命令编译并查看汇编./build/bin/clang --targetaarch64-linux-gnu -S -O2 test_abi.c -o -对照规范你应该看到如下特征add_and_return的参数在 x0/x1返回值在 x0make_pair这种结构体返回通常被优化成直接构造在调用者分配的缓冲区x8 指向里。如果编译器选择根据参数直接在寄存器里构造结构体sret 之外的小结构体也可以打散传回也必须符合 AAPCS64 的 “Composite Type” 规则。把这个测试扩展到浮点参数、可变参数、超多参数的场景基本能排查掉大部分调用约定层面的兼容问题。4.3 验证栈对齐一个很难用眼睛发现的问题栈对齐错误最诡异的表现是“单线程好好的加上中断或者多线程一跑就挂”。AAPCS64 规定 SP 在函数入口处必须 16 字节对齐。写一个故意反例// stack_test.c __attribute__((noinline)) void touch_stack(volatile char *p) { *p 1; } void caller(void) { char buf[1]; touch_stack(buf); }查看caller的汇编看编译器是否用sub sp, sp, #16这类方式保持对齐。正常情况下LLVM 会为局部变量预留至少 16 字节的栈空间并保证 16 字节对齐。如果你改了后端这地方是最容易松的。4.4 用 objdump 和 readelf 反查 ELF ABI 细节编译出.o后用llvm-readelf或者aarch64-linux-gnu-readelf查看./build/bin/llvm-readelf -h test_abi.o重点看Flags字段AArch64 ELF 文件的e_flags里会标注 ABI 版本、是否使用软浮点、是否使用硬浮点、是否为纯代码等。如果这个字段和链接器、运行环境的预期不一致动态链接时就会报错。这类问题在交叉编译时最容易被忽视。4.5 跑一个完整的动态链接验证ABI 不只涉及编译和链接还涉及动态装载器、TLS、以及 C 运行库的辅助函数。一个最基本的完整验证是./build/bin/clang --targetaarch64-linux-gnu test_abi.c -c -o test_abi.o aarch64-linux-gnu-gcc test_abi.o -o test_abi_static -static然后放到真机或 qemu-aarch64 里执行。如果连最简单的汇编级 ABI 测试都不通过后面的复杂功能就不要指望了。5. 我个人的建议与体会跑完这套验证流程之后我的整体体会是ARM64 生态的碎片化比想象中严重但规范仓库和arm-abi-aa分支的存在至少在“编译器层”提供了一个可以校准的锚点。如果你只是想用现成工具链做交叉编译我的建议是新项目优先选 Arm ClangAC6或者较新的 aarch64 GCC不要再用 AC5 开新坑每次升级工具链都要重新做一遍 ABI 验证至少跑一下寄存器调用约定和栈对齐测试遇到莫名其妙的链接或运行时崩溃先去看汇编再去看 ABI 规范不要急着改代码如果项目涉及时序敏感、指令集敏感、或者硬浮点 ABI 切换一定要在时间表里预留审计 ABI 的工期。最后再分享一个很实用的小技巧在 LLVM 后端做 ABI 相关修改时除了看单测还可以用llvm-mca做指令时序分析配合llvm-exegesis验证某些指令的延迟和吞吐防止你为了满足 ABI 约束把一个高频路径改成性能地雷。毕竟编译器开发的终点不是“能编译”而是“编译得对、跑得快、可维护”。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价