深入剖析 Arm ABI 规范:AArch64 结构体传参与编译器实现

📅 发布时间:2026/9/8 22:46:25
深入剖析 Arm ABI 规范:AArch64 结构体传参与编译器实现
做编译器后端的人迟早会跟 ABI 这个硬骨头撞个满怀。前段时间我接到一个任务排查某个基于 AArch64 的定制平台上结构体传参异常现象很怪小结构体返回结果正确稍微带点浮点就乱。连着查了几天汇编最后问题指向了 ABI 实现于是我把 ARM 官方维护的 Arm-abi-aa 规范仓库完整拉下来做了一遍源码审计又对照 Clang 和 GCC 的源码逐条验证才算把整个链路理清楚。这篇文章就是这次审计和后续编译器开发落地的完整记录覆盖规范仓库的架构全景、关键规则拆解、审计实操路径以及从规范到编译器代码的落地指南。适合正在做编译器后端、OS 移植或者写汇编库和二进制兼容层的人参考。1. 为什么要深挖 Arm-abi-aa 仓库先说一个很多人容易忽略的事实Arm-abi-aa 不是一个编译器项目也不是某个内核驱动而是 ARM 官方维护的 ABI 规范仓库。它的价值在于所有 Arm 生态里的二进制互操作规则最终都要落到这一堆文档和示例代码上。搞清楚它的结构和读法比直接翻编译器源码更接近问题的本质。1.1 这个仓库到底装了什么很多人一听源码审计第一反应是去看编译器源码其实规范仓库本身也值得审。Arm-abi-aa 实际上是 GitHub 上ARM-software/abi-aa这个仓库的简写仓库里不包含编译器的业务代码而是 Arm 官方发布的 ABI 规范集合。每个主目录对应一份规范基本覆盖了 32 位 Arm 和 64 位 AArch64 从调用约定、ELF 文件格式、DWARF 调试信息到运行时 ABI 的全部内容。仓库里比较核心的文档包括AAPCS64 (IHI0055)AArch64 过程调用标准也就是 64 位 Arm 的调用约定编译器后端最常翻的一份。AAPCS32 (IHI0042)32 位 Arm 的过程调用标准老旧的 arm32 工具链还会用到。ELF for AArch64 (IHI0056)定义 64 位 Arm 的 ELF 文件格式、重定位、程序加载方式。Runtime ABI for Arm Architecture (IHI0043)定义运行时库、栈展开、异常处理的 ABI。C Library ABI for Arm Architecture (IHI0039)定义va_list、setjmp、longjmp等基础库的二进制接口。这些规范不是单纯丢一个 PDF 出来仓库内直接用 reStructuredText 维护了规范文本提供 PDF 构建脚本并且有不少用 C 和汇编写的示例代码片段。这些示例代码是审计时最需要的因为它们直接反映了规范作者期望的代码生成形态比看大段文字描述直观得多。1.2 源码审计的目标和收益审计这个仓库目标不是把每个字都过一遍而是带着问题读。我做这次审计的目标很明确搞清楚 AArch64 调用约定中结构体的完整分类流程因为前面定位到的传参异常十有八九出在这里。另一个目标是建立规范到编译器实现的映射方便后续在 LLVM 和 GCC 上做补丁。实际收益也很直接。审计完之后我能快速说出一个结构体在什么时候走x0-x7和v0-v7、什么时候被整体丢到栈上能解释va_list为什么是结构体而不是简单指针也能看懂 Clang 的AArch64ABIInfo.cpp里那一堆注释到底在说什么。更重要的是在给编译器提交 bug 修复合入时我有了底气去和评审人讨论规范原文是什么而不是凭感觉改代码。2. ABI 规范核心概念拆解ABI 规范内容很多但真正和日常编译器开发强相关的就几块寄存器分配规则、结构体分类算法、va_list和复合类型传递方式。这几个点只要拆清楚大部分 AArch64 下的传参问题都能定位到根因。2.1 AArch64 调用约定寄存器分配与参数传递先从寄存器分配开始。AArch64 过程调用标准把通用寄存器x0-x7分配给整数、指针和枚举参数把 SIMD/FP 寄存器v0-v7分配给浮点参数、向量参数以及部分结构体。前 8 个整形参数和 8 个浮点参数分别编号互不抢占。调用者负责保存x0-x7和v0-v7被调用者则必须保存x19-x28等寄存器。有一个容易忽略的点当参数是浮点但函数是变参时AAPCS64 要求调用者必须把浮点参数同时放到对应的通用寄存器中这样va_start才有统一的保存区可找。这一点直接决定了变参函数里浮点参数的读取方式也是很多自写汇编库翻车的地方。如果你在实现一个自定义格式的打印函数不遵循这条规则从va_arg里拿出来的一定是一堆乱值。寄存器还分角色x29是帧指针x30是链接寄存器x16/x17是 intra-procedure-call 临时寄存器x18在有些平台上是平台寄存器不能随意用。这些都写死在规范里编译器后端和汇编器只要按规则出牌就行。真正容易出问题的反而是开发者在手写汇编时在函数中间随意使用x16结果把链接器私有代码的临时寄存器踩了。2.2 结构体分类与内存布局最容易出错的环节结构体传递的分类逻辑是 AArch64 ABI 里最大的难点也是这次审计的核心。AAPCS64 对按值传递的结构体分类并不是一步到位的而是分两层判断。第一层是判断同质聚合类型Homogeneous Floating-point Aggregate简称 HFA向量类型则叫 HVA。如果一个复合类型的所有非空成员都是完全相同的浮点类型或者完全相同的向量类型并且成员数不超过 4那它就是 HFA/HVA。HFA 可以直接展开每个成员占一个 SIMD/FP 寄存器例如struct HFASample { float a; float b; };这样的参数会映射到s0和s1而不是打包成一个 64 位寄存器。这一点和很多人直觉不同也经常被初学 ABI 的人弄错。同样struct { double a; double b; }会映射到d0和d1因为它们属于同质 double 聚合。第二层才是通用分类。不是 HFA/HVA 且总大小不超过 16 字节的结构体会按地址切成一个或两个 8 字节块块内成员的类别必须一致类别分为 Integer、Float、Pointer 和 Memory。如果某个块内出现混合类别或者两个块类别不一致整个参数就退化为 Memory不能再使用寄存器传递。举个例子结构体定义分类过程传递方式struct { double a; double b; }同质 double 聚合d0,d1struct { float a; float b; }同质 float 聚合s0,s1struct { int a; float b; }HFA 不成立8 字节块内 IntegerFloat 混合整体按 Memory 走栈struct { double a; int b; int c; }两个 8 字节块类别分别是 Float 和 Integer整体退化为 Memory超过 16 字节的结构体也没有机会进寄存器规范要求调用者分配临时内存按引用方式传递给被调用者。这类结构体返回值通常通过x8指向的调用者内存来传递被调用方写完后会在x0里把地址带回来。如果你在调试一个返回大结构体时频繁出现栈错误先检查调用方是否真的为返回值分配了足够空间。2.3 va_list 与复杂类型传递的实现细节va_list是另一个绕不开的点。在 AArch64 上va_list不是一个简单指针而是一个结构体官方 C Library ABI 文档给的定义大概是这样typedef struct __va_list { void *__stack; void *__gr_top; void *__vr_top; int __gr_offs; int __vr_offs; } va_list;__stack指向栈上传参区域__gr_top和__vr_top分别指向通用寄存器和 SIMD 寄存器保存区的顶部__gr_offs和__vr_offs记录当前已消费到的偏移。为什么要设计成结构体因为变参函数的参数来源有两个寄存器保存区和栈。va_start必须能把两者都记录下来再用va_arg依次消费。编译器在函数 prologue 里会生成代码把x0-x7/v0-v7拷贝到保存区然后把保存区地址写进va_list。这也是为什么变参函数性能比普通函数差额外搬寄存器不可避免。复杂类型传递也值得多写两句。_Complex在 AArch64 上可以看作包含两个同构浮点元素的聚合比如double _Complex按两个double处理会走 HFA 规则使用d0/d1传递。向量类型则更依赖 target featureNEON 的float32x2_t和 128 位向量可能在传参路径上完全不同读取规范时一定要看当时文档对应的向量长度和 feature 版本否则容易把 SVE 的规则套到 NEON 上。3. 仓库源码审计的实操路径读规范最难的是从文字跳到实现。下面这部分是实际操作过程我把从拉取仓库到交叉验证编译器源码的完整路径写出来内容足够落地不需要你有 Arm 板子也能做。3.1 环境准备与仓库拉取我是在 x86 Linux 上做的审计没有专门的 Arm 开发板绝大部分工作可以静态完成。需要的工具不多Git、Python3、Sphinx如果要从 rst 构建 PDF、grep、Clang/LLVM 工具链、aarch64 交叉编译器以及一个能跑 aarch64 程序的模拟器比如 QEMU user-mode。拉取仓库很简单git clone https://github.com/ARM-software/abi-aa.git cd abi-aa ls -F git log --oneline -5 git tag仓库顶层目录非常多每个目录就是一份规范。建议先看 README里面解释了各文档版本和命名规则。规范文件名形如aapcs64.rst和aapcs64.pdf另外还有 addenda 目录存放对主文档的补充。要注意的是这份仓库的 commit 记录和版本 tag 并不一定和编译器发布的版本完全对齐你在拿规范对照旧编译器时必须先确认规范版本和编译器版本的对应关系。3.2 规范文档与代码示例的交叉审计开始审计前我先确定一个审计矩阵把要研究的规则编号、原始文本、对应编译器源码文件、测试用例四列列出来。比如 AArch64 结构体传参主规则在 AAPCS64 的 C.1-C.17 小节Clang 端代码在clang/lib/CodeGen/AArch64ABIInfo.cppGCC 端在gcc/config/aarch64/aarch64.cc。这样整个审计过程不会走散。具体操作时我习惯用几个 grep 把关键片段抓出来grep -n Composite aapcs64.rst | head -20 grep -n va_list va_list.rst | head -20 grep -rn classifyArgumentType ../llvm-project/clang/lib/CodeGen/AArch64ABIInfo.cpp然后逐个规则对照代码注释构造测试用例给编译器喂数据观察输出汇编是否符合规则。举个例子规范里说每个 8 字节块内所有成员必须是相同类别否则 Memory我就在代码里找classifyArgumentType里对 Memory 的分支再构造struct { double d; long long i; }这种混合结构体用下面的命令编译clang -S -target aarch64-none-elf -O2 -o - test.c看它是走了寄存器还是栈。这种交叉验证的过程能非常快地把规范文档里的抽象语言变成你对代码生成的具体认知。3.3 与编译器源码对照验证审计规范仓库只是第一步更重要的是拿规范和两条主流编译器实现做三方对照。我先拉了 LLVM 主干主要在AArch64ABIInfo.cpp里读分类逻辑同时拉了 GCC 的gcc/config/aarch64/aarch64.cc做对比。两个实现思路类似但也有一些差异特别是处理向量扩展、联合体、位域时。虽然两者都能通过互操作测试但代码路径不一样不能拿一个编译器的行为去推断另一个。对照之后我最大的感受是规范文本描述的是意图真正实现时每个编译器都有自己的历史包袱。比如 Clang 会把一些合法但低效的情况刻意降级为 Memory而 GCC 在某些版本里对 empty struct 有额外处理。所以如果你要自己实现 ABI不能只抄一个编译器必须回到规范仓库把规则当成唯一事实来源。这也解释了为什么 ARM 一直把 abi-aa 仓库作为官方参考而不是简单指向某一套编译器代码。4. 编译器开发落地指南看懂规范、查完源码之后最终还是要落到改代码这件事上。这一部分我说一下从 ABI 规范到实际编译器补丁的落地流程分别覆盖 Clang、GCC以及测试验证。4.1 在 Clang 中落地 AArch64 ABI 修改的步骤假设你要在 Clang 里修复一个 AArch64 结构体分类的 bug或者实现一个新的 ABI 优化建议按下面的流程走。第一步永远是先建 lit 测试把目标场景固定下来。比如在test/CodeGen/AArch64/abi-struct-mixed.c里写struct Mixed { double d; long long x; }; struct Mixed make(void) { struct Mixed m {1.0, 2}; return m; }先跑一遍确认当前输出是什么样。如果你的修改目标是让这个结构体由寄存器改为 Memory那么当前测试应该失败这样才有意义。没有失败测试就直接改代码是最容易留下回归隐患的做法。第二步是定位实现代码。去AArch64ABIInfo.cpp里找到classifyArgumentType定位 Memory 分支。通常这个函数很长按数据类型分了很多 if。在改动之前用注释把当前行为的适用规则标出来避免后续回归时看不明白。第三步是修改分类逻辑。注意所有 AArch64 变体都可能受影响包括aarch64_be、带 SVE 的 target、带 SME 的 target所以尽量用 triple 和 target feature 做联合判断不要写死架构版本。改完代码后跑测试ninja check-clang-codegen如果涉及函数返回值的 ABI 调整还要跑check-clang-analysis和check-clang-semantics避免前端 AST 相关测试被波及。4.2 在 GCC 侧实现的差异与协同思路GCC 的 AArch64 后端在gcc/config/aarch64/aarch64.cc里实现参数分类核心函数包括aarch64_classify_argument、aarch64_composite_type_p等。GCC 的源码组织方式比 LLVM 更依赖 target hook参数分类的结果会用机器模式machine mode和 rtx 表示理解门槛稍高但整体逻辑和 Clang 是对应的。如果你只是修一个编译 bug两边可以分开修但如果是为了给定制 ABI 引入新行为建议两边同步并且要联合跑互操作测试一边用 Clang 编译另一边用 GCC 编译让两个目标文件链在一起执行。ABI 不一致经常会延迟到链接或者运行时才暴露用跨编译器测试能在早期发现。我个人的做法是维护一张表把每个结构体类型在 Clang 和 GCC 下的分类结果都记录下来对比差异。比如struct { float a; float b; }在两边是否都走s0/s1struct { int a; float b; }是否都走 Memory。这张表也能作为提交 patch 时的说明附件评审人会很快理解你的意图。4.3 测试用例设计与回归验证测试用例必须覆盖正常、边界、异常三类场景。下面是我常用的用例清单普通整数、指针、浮点标量参数大小 8、8~16、16 的结构体混合 integer/float 结构体内嵌数组、联合体、位域_Complex double/_Complex float可变形参函数printf 风格NEON 向量类型空的 struct/unionGNU extension对齐属性aligned(16)、packed对传参的影响。这些用例要同时生成汇编和运行程序因为汇编符合规则不代表运行结果正确。建议用 QEMU user-mode 跑 aarch64 可执行文件把返回值通过echo $?或printf检查。LLVM 也提供了llvm-exegesis之类的工具但对 ABI 审计来说一套精简的 lit 测试加一个 shell 脚本就够了。真正关键的是把用例固化下来进 CI 跑不然下一个版本的编译器可能又改回去。5. 常见问题与避坑实录操作过程中我踩了不少坑这里整理成速查表和排查方法给后面做同样事情的人少走一点弯路。5.1 高频踩坑点速查表现象根本原因排查方向小 float 结构体参数结果不对分类逻辑误判为 Integer 块检查是否识别为 HFA确认每个成员是否独立分配 FP 寄存器返回结构体时莫名加栈指针结构体超过 16 字节或混合类别走 Memory查看x8间接返回确认调用方是否分配内存va_arg拿到的浮点参数是乱值调用方没有把浮点参数复制到通用寄存器保存区检查函数序言的 save area 与va_start偏移同一工具链编出的库无法和 GCC 库互调双方 ABI 实现版本不一致用规范仓库生成对照矩阵避免依赖单边行为位域结构体在调试器里布局偏差位域容器类型选择差异读取 DWARF 对齐信息检查-mabi相关选项5.2 排查工具与调试技巧排查 ABI 问题时我最常用的三个工具是clang -S加-emit-llvm看前端生成的内存布局llvm-objdump/objdump看最终汇编以及readelf -S/-A检查目标文件的属性。遇到运行期段错误再用 gdb 在函数入口停下来用info registers看寄存器内容是否符合预期。一个小技巧在 clang 里用-fdump-record-layouts能直接看到记录布局这对结构体对齐、位域位置的判断很有用。GCC 对应的是-fdump-lang-class或-fdump-record-layouts。如果涉及 QEMU 运行用qemu-aarch64 -L /usr/aarch64-linux-gnu ./test_prog跑再配合 strace 能很快判断是不是系统调用接口的 ABI 问题。不要一上来就怀疑编译器先把参数寄存器打印出来往往比猜快得多。5.3 几条独家实操心得审计规范仓库这种事情最忌讳的是把文档从头到尾念一遍。正确做法是先有自己的问题再带着问题去定位文档。规范文档里全是表格和修订说明直接全部阅读很容易失去焦点所以我建议先写一个预期行为清单再逐个去文档里找对应章节。第二个心得是维护一个规范版本与编译器版本的匹配关系。ARM 会发布新版本 ABI 规范但编译器往往落后一拍。如果碰到新版规范里的规则先看有没有对应的编译器版本支持否则你改的代码可能在老工具链下无人认领。我在这次审计中就是因为没注意这一点拿新规范的某条规则去套老版本的 Clang折腾了大半天才发现是版本错位。第三个心得能用脚本自动化对比就用脚本。我最后写了一个很小的 Python 脚本批量把几十个结构体定义编译成汇编然后用正则提取寄存器/栈的分配结果和规范预期比对。这个过程帮我发现了至少两个平时很难注意到的边界问题其中一个就是 32 位浮点结构体在 HFA 判断和 64 位块分类之间被重复匹配的现象。这类边界问题靠人眼一个个看很容易漏。