ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

ARM ABI规范源码审计:从AAPCS到编译器落地实践

2026/9/9 11:44:52 拓冰建站 浏览量
ARM ABI规范源码审计:从AAPCS到编译器落地实践 做了快十年的编译器和底层工具链我越来越觉得“ABI 规范”这个东西被低估得离谱。很多人写 ARM 程序跑个交叉编译链链接能过、板子能跑就以为万事大吉。直到某天把两个不同编译器编出来的库混在一起或者换了一个工具链版本程序开始莫名其妙崩溃你才会意识到原来 ABI 才是那个看不见的合同。最近我把 ARM 的 arm-abi-aa 规范仓库从头到尾过了一遍从源码审计的角度把 AAPCS、ELF 布局、异常处理这些约定重新读了一遍顺手也整理了一套编译器开发落地时可以直接抄作业的方法。这篇就把整个过程拆开讲arm-abi-aa 仓库到底放了什么、怎么去审这些规范源码、以及拿到了规范之后怎么在你自己的编译器里真正实现它。这篇东西适合三类人一类是做交叉编译、工具链适配的嵌入式开发者一类是正在读 LLVM/GCC 后端、准备给自有 CPU 核做编译器支持的人还有一类是自己写脚本语言解释器、需要生成 ARM 机器码的“野生编译器作者”。不管你是哪类只要能理解“ABI 不是文档摆设而是可执行契约”这句话这篇文章就能帮你省掉大量试错时间。1. 全景坐标先把 ARM、ABI、arm-abi-aa 三个词放对位置1.1 三个词背后的层级关系先从最基础的说起。ARM 是处理器架构x86 的指令集是 CISCARM 是 RISC这点大家都知道。但很多人没意识到ARM 不是一个单一架构而是一族架构Cortex-A 系列跑 Linux/AndroidCortex-R 系列做实时控制Cortex-M 系列做单片机。不同系列之间寄存器和指令集都有差异。你写编译器第一件事就是要明确目标到底是 ARMv7-A、ARMv8-A 还是 Cortex-M 专属的 Thumb-2。ABIApplication Binary Interface则是比“指令集”更高一层的约定。指令集只规定“这条指令的机器码是什么”ABI 规定的是“函数参数怎么传、返回值放哪里、栈怎么对齐、全局变量怎么寻址、异常怎么展开”。换句话说指令集是语言本身ABI 是你们俩对话时的礼仪规范。两个编译器编出来的二进制能互相调用靠的不是指令集相同而是 ABI 相同。arm-abi-aa 是 ARM 官方在 GitHub 上的一个仓库全称是 ARM ABI for Arm Architecture存放的正是这套“礼仪规范”的源文件。它不是一行可执行代码而是一堆 Markdown、reStructuredText、LaTeX 文档以及自动化构建脚本。但我在审计这个仓库的时候完全是按“源码审计”的方式去做的看目录结构、看提交历史、看 issue、看每个文档里用词的变化。因为规范这种“源码”比真正的代码更值得抠字眼。1.2 为什么“ABI 源码审计”是编译器开发的入场券写编译器的人普遍有个误区我先实现指令选择、寄存器分配最后有空再看 ABI。这个顺序几乎必然翻车。因为 ABI 渗透在编译器后端的每一层函数调用要按 ABI 生成 prologue/epilogue结构体传参要按 ABI 决定是放寄存器还是压栈DWARF 调试信息要按 ABI 规定的规则去描述栈帧甚至连memcpy的优化都可能依赖 ABI 里“某些寄存器是调用者保存”的约定。所以我历来主张拿到一个新的 CPU 架构第一步不是写指令选择而是先把官方 ABI 文档从源码仓库里拉下来认认真真审计一遍。审计至少有三个层次第一层读懂字面意思。知道哪些寄存器是 caller-saved哪些是 callee-saved参数最多能用几个寄存器传。第二层读懂边界情况。结构体大于 16 字节怎么传可变参数函数怎么处理浮点寄存器栈对齐遇到 SIMD 指令怎么办第三层读懂演进历史。去看 git log看哪个版本改了“栈对齐要求从 8 字节变成 16 字节”看为什么要改这能帮你预判将来可能踩到的兼容性坑。arm-abi-aa 这个仓库做得好的地方就是它是“活”的。文档有版本、有修订记录、有 CI 检查拼写和格式。你把仓库 clone 下来git log --follow一遍等于看了一部 ARM ABI 的演进史。2. 仓库源码审计从提交记录里读出的规范演进2.1 仓库结构arm-abi-aa 里到底放了什么第一次 clone 这个仓库的人看到目录结构可能会有点懵一堆名字像乱码的文件夹里面是.rst、.tex、.pdf混着放。别急先看 README 和根目录的Makefile这个仓库本质上是“规范文档的源工程”后面有一套 Sphinx/LaTeX 的构建流程把它们渲染成正式发布版。按我的审计经验仓库里可以把内容分成四组对应四类不同的 ABI 主题过程调用标准这就是大家常说的 AAPCS32 位 ARM和 AAPCS64AArch64。里面规定了寄存器用途、参数传递规则、结构体返回规则、栈帧布局。编译器后端做调用约定基本就看这部分。文件格式与动态链接对应 AAELF32/AAELF64讲 ELF 文件在 ARM 上怎么组织重定位类型怎么定义GOT/PLT 条目怎么布局。链接器和动态加载器的作者重点看这里。C 与异常处理对应 CPPABI 和 EHABI讲 C 名字修饰name mangling、虚表布局、异常展开unwinding规则。你要是自己实现 C 前端这部分跑不掉。运行时 ABI对应 RTABI32/RTABI64讲__aeabi_*这类运行时辅助函数的约定比如整数除法、memcpy、double运算的辅助实现。嵌入式开发里经常遇到的undefined reference to __aeabi_uidiv就是拜这套规范所赐。审计时建议按这四组逐个过否则容易漏掉关键信息。2.2 审计方法像看代码一样看规范我审计这个仓库时用了一套自己沉淀下来的方法跟面上“读文档”完全不是一回事。拿 AAPCS64 举例我打开文档后不急着看正文先干三件事第一搜“must”和“shall”。ABI 规范里强制要求和推荐建议的用词是严格区分的。凡出现must的地方编译器不遵守就是生成非法二进制凡出现should属于建议项能兼容最好但实在做不了也不会导致 crash。我把must筛选出来列成一张“硬性约束表”后面实现时逐条对照。第二盯住寄存器表看三遍。AAPCS64 那张寄存器用途表第一遍看哪个寄存器是参数第二遍看哪个是 callee-saved第三遍看哪些寄存器有特殊用途比如 X18 在有些平台是 platform register不能随便用。我见过不止一个编译器后端寄存器分配器把 X16/X17 当成普通临时寄存器用结果和链接器 veneer 冲突程序在长跳转时神秘崩溃。第三对比 HTML/PDF 版和源码版。仓库里有些目录会同时放源文件和编译产物偶尔源文件更新了PDF 没重新构建。如果你拿到的 PDF 和.rst对不上以源码版为准。这也算审计仓库时的一个小冷知识。总之规范文档要当成“代码”来审有注释正文说明、有断言must/shall、有测试示例代码你逐条验证而不是当成“小说”来读。2.3 从版本历史中反推设计意图arm-abi-aa 的 git 历史是这个仓库最值钱的部分之一因为很多设计决策的上下文在最新版文档里已经被压缩成冷冰冰的一句话只有回到提交信息里才能看到当初的来龙去脉。我印象很深的一条是 AAPCS64 里关于栈对齐的修订。早期版本对栈对齐的要求是“在调用边界栈指针保持 16 字节对齐”这是为了高效支持 SIMD 的ldp/stp指令。后来有人提 issue说叶子函数内部想用栈存局部变量如果把栈帧大小凑成 16 的倍数会浪费不少内存。ARM 的回应是叶子函数内部可以稍微放宽但必须保证所有sp相对访问的地址仍然满足目标数据类型对齐。这个细节后来演变成许多编译器优化 pass 的理论依据。你看提交历史能更透彻地理解“为什么编译器要把某个局部变量放到sp8而不是sp0”。另一个例子是重定位类型的扩展。AArch64 的R_AARCH64_JUMP26和R_AARCH64_CALL26的作用范围都是 ±128MB。如果代码段太大链接器要插 veneer跳板来做长跳转。这个限制在文档里只是一行字但在 git 历史里你能看到 ARM 跟 GCC/LLVM 社区讨论“要不要允许使用绝对跳转作为逃生门”的完整争论。了解这些背景你在设计编译器的跳转指令选择策略时就能做出更合理的取舍。3. 编译器开发落地把 ABI 翻译成可运行的机器码3.1 工具链选型LLVM、GCC 还是自研拿到规范接下来就是动手写编译器。一个绕不开的问题是完全自研还是在 LLVM/GCC 基础上改我的建议很直白除非你的目标是研究型项目否则不要从零开始写指令选择和寄存器分配成本和收益完全不成正比。更现实的路线是直接基于 LLVM 的AArch64后端做二次开发把精力集中在 ABI 相关的定制点上。选择 LLVM 的主要理由是它的后端分层清晰CallingConv.td定义调用约定AArch64ISelLowering.cpp处理参数 loweringAArch64FrameLowering.cpp负责栈帧布局AArch64Subtarget.cpp控制系统特性开关。你改了 ABI 规则影响范围能精确控制不容易牵连到指令选择的核心逻辑。选择 GCC 的理由则相反GCC 对 ARM 架构的历史支持更久arm-abi-aa里的很多规范条目本来就是 GCC 社区推动制定的。但 GCC 的中间表示GIMPLE/RTL抽象层次比较绕新人上手周期长而且要改调用约定得在config/arm/arm.cc这种几千行的大文件里动刀心理压力不小。如果你坚持自研比如写一个只支持 Cortex-M3 的小型编译器那也 OK但一定要先把RTABI里__aeabi_*辅助函数的调用规则定好。否则你生成的代码调用一个除法函数参数放哪个寄存器没约定清楚出来的二进制就是“薛定谔的可用”。3.2 调用约定的实现细节AAPCS/AAPCS64真正写代码的时候调用约定是第一个硬骨头。这里我拿 AAPCS64 的几条核心规则展开因为我的主力平台是 AArch64而且 64 位的规则比 32 位更规整适合当教学模板。AAPCS64 规定函数参数优先使用寄存器传递整数和指针参数依次放到 X0-X7浮点参数放到 V0-V7浮点和整数参数可以并行占用各自的寄存器序列。这意味着一个函数float f(int a, float b, int c, double d)a进 X0b进 V0c进 X1d进 V1。如果你按“所有参数统一从 X0 开始按顺序排”那实现出来的 ABI 就是错的混用两个编译器编译的模块就会发生参数错位。结构体返回值的处理更是重灾区。AAPCS64 规定大小超过 16 字节的结构体调用者要在栈上预留一个内存区域并把地址写入 X8函数内部把结果写入这个地址返回后再由调用者访问。这个“隐藏指针参数”的规则是很多自研编译器作者第一次实现时最容易漏掉的。漏掉的后果是函数返回一个struct { double x; double y; double z; }时寄存器根本放不下编译器却试图用 X0/X1 硬塞最后数据被截断程序输出一堆垃圾值。至于 32 位 ARM 的 AAPCS规则稍有不同整数参数放 R0-R3浮点参数按精度放在 S0-S15 或者 D0-D7 中结构体超过 4 字节或者大小不是 4 的整数倍时也走隐藏指针路线。32 位下栈对齐要求是 8 字节但在使用某些 NEON 指令时编译器会临时把栈对齐到 16 字节。我在给 ARM 32 位后端加-mvectorize-with-neon-quad支持时就踩过这个“双重对齐”的坑。3.3 数据结构布局对齐、大小端、位域的坑ABI 不只是管“函数怎么调用”还管“结构体在内存里怎么摆”。AAPCS32 和 AAPCS64 都规定了基本类型的大小和对齐以及结构体成员的布局规则。大多数时候你的前端比如 Clang已经替你按照目标 ABI 布局好了llvm::StructType但一旦你自己写代码生成器或者需要直接操作结构体偏移时这些规则就是你必须手算的东西。拿对齐来说AAPCS64 下int64_t的对齐是 8 字节但 AArch64 允许非对齐访问的代价比 32 位 ARM 低很多。这不代表你可以在结构体里乱排布成员。标准建议仍然是把成员按对齐大小从大到小排列减少 padding。我在分析一个图形库的崩溃问题时就是利用pahole对比不同 ABI 下的结构体布局定位到某个成员在 32 位和 64 位下偏移差了 4 字节导致跨 ABI 共享内存时字段全部错位。大小端的问题在 ARM 上尤其阴险。ARM 的指令集是双端可配的但绝大多数 Linux 系统跑的是 little-endian。如果你在交叉编译时忘了指定-mbig-endian或者链接器脚本里.data段的字节序跟代码段不一致运行时会得到超乎想象的结果变量值被整体翻转字符串直接反序。我曾经在一个项目里把endianness配置错了三层编译器参数、汇编器参数、链接器脚本排查了整整两天。位域的布局规则不同 ABI 差异更大。AAPCS32 里位域成员的分配顺序跟字节序相关little-endian 下从 LSB 开始big-endian 下从 MSB 开始。同一个struct { unsigned a: 3; unsigned b: 5; }在两种字节序下布局完全镜像。你的编译器如果同时支持两种字节序位域代码生成逻辑必须写得跟查表一样精确。3.4 用最小用例验证代码生成器写完调用约定和结构体布局怎么能确定没写错我从来不相信“看代码”能发现问题尤其是编译器这种每层都有隐藏状态机的系统。我的做法是设计一小组“ABI 探针”函数用目标编译器编译再用反汇编验证生成的代码是否符合规范。探针函数要覆盖几类典型情况整数参数传递int add(int a, int b)验证 a、b 进 X0、X1。浮点参数传递double scale(double x, double y)验证进 V0、V1。混合参数传递float mix(int a, float b, double c)验证不同类型各进各的寄存器。大结构体返回struct Big make(void)验证 X8 被设置为返回缓冲区的地址。可变参数函数int printf_like(const char *fmt, ...)验证浮点参数在可变参数区改用通用寄存器传递。叶子函数栈对齐一个只有几个局部变量的函数看sp是否在入口处被修正到 16 的倍数。每写一个探针我都会做这样几个验证动作用-S生成汇编看参数是不是放进了预期寄存器。用objdump -d反汇编生成的目标文件再次核对机器码层面的行为。写一个对应的 C 文件跟一个用 GCC 编译的库做“跨编译器互调”确保两边能互相调用。其中第 3 步最有价值。现代 ABI 规范本质上是“互操作性契约”你写的编译器如果能跟 GCC/Clang 编译的库正确互调说明你的 ABI 实现至少有 90% 是正确的。4. 常见问题与排错速查4.1 热搜关键词里藏着的典型问题每次有热点事件搜索引擎里就会冒出一堆 ARM 工具链相关问题。我在博客后台看到后台的关键词几乎每个都能对应到一类真实的坑。“arm compiler 5.06 下载”是常年热门。Arm Compiler 5AC5是 ARM 官方旧的编译工具链基于 ARMCC支持 ARMv7-A 和更老的架构。AC5 用的 ABI 是 AAPCS 的一个旧修订版本跟现在 LLVM 的默认行为有一些细微差别。很多人把 AC5 编译的库跟 AC6基于 Clang编译的应用混用遇到结构体传参对不上其实根因往往是新旧 ABI 修订里某个“未指定行为”的差异被触发。解决办法只有一个除非你能确认所有模块都用同一个 ABI 修订版否则别急着混用 AC5 和 AC6 的二进制。“arm和x86的区别”这类提问本质上是把架构差异跟 ABI 差异混为一谈。x86 的参数主要压栈ARM 的参数主要放寄存器x86 是可变长指令ARM 是固定 4 字节Thumb-2 是混合长度。但这只是“指令集差异”真正让你写的 C 代码在两种架构上表现不同的是 ABI 规定的对齐和调用规则。这个区别厘清了很多困惑会瞬间消失。“银河麒麟 ssh rpm 升级包 arm”和“arm 系统增加常用命令工具”这类关键词反映的是国产化替换过程中常见的现实需求你会发现很多 x86 下现成的二进制包在 ARM 下没有对应的 rpm 或 deb。解决办法倒不复杂配置好交叉编译环境自己编一份就是了。但编译之前一定要检查目标机的 ARM 版本aarch64 还是 armv7hl以及 glibc 版本防止“编译时用的 ABI 比运行时新”。4.2 参数速查表与排查建议我把常用命令和排查建议整理成一张速查表方便你在遇到问题时快速定位。场景命令 / 检查项说明查看目标文件架构file libfoo.so确认是 aarch64 还是 armv7避免 ABI 不匹配查看 ELF 中的 ABI 标识readelf -h libfoo.so检查 Flags 字段是否有 EABI 版本查看函数符号的调用约定objdump -d foo.o对照 AAPCS 寄存器规则人工核查检查结构体布局pahole foo.o输出成员偏移适合跨架构对比查看动态库依赖readelf -d foo.sogrep NEEDED检查栈对齐objdump -d找sub sp, sp, #N计算 N 是否为 16 的倍数排查 undefined referencenm foo.ogrep __aeabi这些工具大部分来自 binutils 和 elfutils在交叉编译环境里一定要记得用aarch64-linux-gnu-前缀的版本不要直接调宿主机的readelf否则会读错格式。5. 落地建议从“看懂”到“真的能写”5.1 个人学习的路线图如果你是一个之前没接触过编译器后端的开发者想把这个方向学起来我给一条封闭式的路线按这个顺序走大概率不会卡壳。第一站先把某个目标嵌入式板卡的交叉编译环境搭起来。无论你是用arm-linux-gnueabihf-gcc编译一个 hello world还是用aarch64-linux-gnu-gcc编一个带浮点运算的矩阵乘法都行。重要的是亲手把“编译-链接-拷贝-运行-调试”这个闭环跑通。第二站把 arm-abi-aa 仓库 clone 下来找到 AAPCS64 文档只读前 20 页不需要全部读完。重点看寄存器用途表和参数传递规则。然后拿你手边的交叉编译器用-S编译几个小函数对照着看汇编里函数调用和返回的部分确认你从文档里读到的规则跟真实编译器生成的一致。第三站去看 LLVM 后端的CallingConv.td和AArch64ISelLowering.cpp。你不用看懂所有代码只要搜CC_AArch64和LowerFormalArguments建立“文档规定 - 后端实现”的对应关系即可。这一步能让你体会到“规范落地成代码”的真实手感。第四站如果你真的想自己写点什么可以尝试用 TableGen 定义一套自定义调用约定在 LLVM 里加一个-myabi开关把普通函数的入参规则改成“只用 X0-X3 传递参数其余压栈”。这样一个看似任性的改动能让你把 ABI 的各个角落都摸一遍。5.2 工程落地需要注意的事如果是团队项目编译器落地要额外注意三件事。第一ABI 版本锁定。一个项目一旦发布ABI 就不能随意改。改 ABI 等于把所有用户的二进制全部废掉成本极高。arm-abi-aa 仓库里的文档都带修订号和日期你在自己的编译器里也要建立同样的机制把 ABI 修订版本编码进工具链的--version输出并在发布时附上兼容性矩阵。第二自动化 ABI 兼容测试。我建议在 CI 里加一个abi-compliance-checker任务每次 push 后自动比对最新构建的共享库和上一版本的导出符号、结构体大小和类型布局。如果没有这种自动化检查等你发现 ABI 被破坏时往往已经发布了三天用户已经踩了几个月的坑。第三跟社区保持同步。arm-abi-aa 仓库不是静止的ARM 会不定期更新规范细节LLVM 和 GCC 也会根据新规范调整实现。就算你现阶段只维护一个封闭工具链也建议定期git pull一下规范仓库看看和自己相关的部分有没有变化。我在实际项目里就遇到过ARM 把 AAPCS64 里某个关于half类型参数传递的规则修订了而我们的编译器还停留在旧规则编译出来的二进制在最新的 ARM 固件上行为异常。最后再分享一个小技巧无论你用哪一层做开发都养成把 ABI 文档和反汇编放在一起对照的习惯。写编译器的时候每次生成一个函数的汇编就用objdump再审一眼参数寄存器用的对不对结构体偏移算的对不对栈对齐够不够。这套“文档-源码-反汇编”三层对照法我用了十年几乎每一次都能在看似毫无问题的代码里抓出新的细节问题。ABI 规范本身并不难难的是你把它当作一个“活”的约束在每一行代码里都记得住它。