ARTICLE DETAIL

建站实战干货

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

ARM ABI 全解析:从规范仓库审计到编译器落地实践

2026/9/8 15:04:35 拓冰建站 浏览量
ARM ABI 全解析:从规范仓库审计到编译器落地实践 我讲一个很多人可能都遇到过或者即将踩进去的坑交叉编译出来的库放到目标板上运行莫名其妙地崩溃、结构体解析错位、va_list 参数乱掉而且只发生在特定的编译器版本和特定的 ARM 内核上。查到最后十有八九是 ABI 不匹配。这篇文章就是从我的实际排查经历出发把 ARM 官方维护的 ABI 规范仓库arm-abi-aa完整梳理了一遍同时结合我对仓库源码的审计笔记给出从规范阅读到编译器落地的实操路线。不论你是做嵌入式开发、RTOS 移植、还是自己折腾编译器后端这篇文章都值得存下来。很多人一听“ABI 规范”就觉得是干巴巴的文档看不进去。但真到了多平台适配、第三方库集成、汇编与 C 互调的时候不懂 ABI 细节就是反复试错。我自己就经历过同一个静态库用 ARM Compiler 5 编译好端端的换到 ARM Compiler 6 就出现结构体大小对不上还有一次在 Cortex-M 上把double当参数传GCC 和 ARMCC 编出来的函数互相调用直接丢精度。这些问题的根源都在于对 ARM ABI 的理解不够深。如果你正准备在 ARM 生态里做工具链适配、跑开源编译器、或者只是想搞明白为什么不同编译器编出来的代码不能随便混用这篇文章会把整个脉络给你捋清楚并且把我审计 ABI 仓库源码的笔记、编译器开发的落地经验一并写出来保证是可以在实际工程里直接用的那种。1. Arm-abi-aa 架构全景先搞明白这套规范在管什么1.1 ABI 到底是什么为什么嵌入式开发者绕不开ABIApplication Binary Interface应用二进制接口跟 API 是两码事。API 是源代码层面的约定你调用一个函数传入什么参数、返回什么类型这是 APIABI 是二进制层面的约定机器码里的函数调用通过什么寄存器传参数、返回值放在哪里、结构体在内存里怎么排列、异常怎么展开这些都是 ABI 管的。我习惯用一个类比API 像你跟服务员说“我要一杯拿铁”双方听懂就行ABI 像咖啡店的标准化杯型不管你是在北京店还是上海店下单中杯就是 360ml杯盖卡口都一致这样杯子、杯盖、纸袋才能混着用。对应到嵌入式开发生态里编译器负责生成二进制链接器负责拼接二进制调试器负责解析二进制这三者如果对二进制格式的理解不一致整个工具链就是散的。ARM 平台之所以要专门搞一套 ABI 规范是因为 ARM 处理器用得实在太杂了。你可以在同一个 ARMv7-A 内核上跑 Linux、跑 RTOS、跑裸机程序也可以选硬浮点或软浮点编译还可以大小端切换甚至可以按 AAPCS32 或 AAPCS64 的规则来编译。如果没有统一的 ABI各家的编译器、汇编器、链接器产出的二进制根本无法互换更别提什么生态了。1.2 arm-abi-aa 仓库里到底装了哪些规范文档ARM 官方把所有 ABI 相关规范放在 GitHub 的ARM-software/abi-aa仓库里纯文档仓库但里面每一篇都是编译器后端、链接器实现、操作系统移植必须遵循的硬性标准。仓库里用的格式主要是 reStructuredText可以直接编译成 PDF 或 HTML也可以直接在网页上阅读。我审计的时候把仓库拉下来大致数了一下主要的规范文档包括aapcs32.rst、aapcs64.rst、aaelf32.rst、aaelf64.rst、cppabi.rst、rtabi.rst、ehabi.rst、bfabi.rst、bpabi.rst、pfabi.rst。这些文档分别规定了过程调用标准、ELF 文件格式、C ABI、运行时辅助函数 ABI、异常处理 ABI、裸机平台 ABI 等。这里先给一张我整理的对应关系表方便你按需查阅文档名称全称管什么aapcs32Procedure Call Standard for the ARM Architecture (32-bit)32 位 ARM 的函数调用约定、寄存器使用、参数传递、栈布局aapcs64Procedure Call Standard for the ARM 64-bit Architecture64 位 AArch64 的函数调用约定、寄存器使用、参数传递aaelf32ELF for the ARM 32-bit Architecture32 位 ARM 的 ELF 文件格式、重定位类型、节区命名规则aaelf64ELF for the ARM 64-bit Architecture64 位 AArch64 的 ELF 文件格式与重定位cppabiC ABI for the ARM ArchitectureC 名称修饰、异常规范、运行时类型信息在 ARM 上的具体布局rtabiRun-time ABI for the ARM Architecture编译器生成的辅助函数调用约定如__aeabi_memcpy、__aeabi_uidivehabiException Handling ABI for the ARM Architecture异常处理的二进制接口包括异常展开表格式bfabiABI for the ARM Architecture (base)所有 ARM 平台公共的基础 ABI 要求bpabiABI for the ARM Architecture (bare metal)裸机平台的 ABI主要给没有操作系统的环境用pfabiABI for the ARM Architecture (platform)平台相关 ABI涉及操作系统层面的接口约定1.3 ABI 的层次关系从过程调用标准到异常处理读这套规范的时候我建议你不要平铺着读而是按层次去看。最底层的是基础 ABIbfabi它定义了所有 ARM 平台共通的规则比如字节序的基本立场、基本数据类型的大小和对齐。往上一层是过程调用标准aapcs32/aapcs64它决定了一个函数怎么被调用这是绝大多数开发者直接面对的部分。再往上才是文件格式aaelf和 C 运行时cppabi、ehabi、rtabi。为什么强调这个层次因为实际工程里的 ABI 兼容问题绝大多数出在过程调用标准这一层也就是寄存器怎么分配、参数怎么传。如果你做的是编译器后端开发那你首先要实现的就是 AAPCS 这一层然后再去考虑 ELF 重定位、异常展开表这些。举一个最典型的例子。在 AAPCS32 里32 位模式下整数和指针参数通过r0到r3传递超过四个参数则压栈返回值放在r032 位或r0和r164 位里。而到了 AAPCS64参数寄存器扩展到了x0到x7整整翻了一倍。这个差异直接导致你在移植一个从 32 位到 64 位的汇编代码时很多关于参数位置的假设都要推翻重来。另外AAPCS 里规定r9在有些平台上是平台寄存器Platform Register可以用于特殊用途但在默认情况下它就是普通寄存器。实际做操作系统移植的时候有些 RTOS 会把r9用作线程局部存储指针这就属于对 ABI 的扩展必须在编译器和操作系统之间保持一致。这类细节如果不看规范原文光靠经验很难把握准确。2. 仓库源码审计我是怎么审 ARM 官方 ABI 仓库的2.1 审计范围与准备工作审计一个规范仓库跟审计代码仓库思路差不多但审计对象是文档所以更看重“版本的一致性”和“规则的完备性”。我先拉取仓库最新版本然后建立了一套审计清单按照下面几个维度逐项过结构体布局规则每个结构体成员的偏移量、对齐方式、位域划分是否定义清楚。参数传递规则整数、浮点数、复合类型结构体、联合体分别通过什么方式传递。寄存器保存规则哪些寄存器是调用者保存caller-saved哪些是被调用者保存callee-saved。异常处理与展开异常表条目怎么组织__aeabi_unwind_cpp_pr0之类的函数怎么实现。运行时辅助函数所有__aeabi_*函数的名称、参数、返回值约定。大小端支持大端模式下的数据布局规则以及 BE8、BE32 两种模式的区别。工具方面我没有用什么高级的静态分析工具Git 加文本检索就够用。我主要是先把所有.rst文件拖进本地然后用 ripgrep 按关键词扫了一遍比如搜索align、bit-field、register、parameter passing把相关段落全部标注出来再逐个细读。2.2 关键源码/文档的审计视角从寄存器规则到结构体传递审计 AAPCS 文档的时候我会特别关注寄存器规则。这里直接给大家看审计笔记里整理的关键信息。AAPCS32 中r0-r3是参数寄存器也是调用者保存的临时寄存器r4-r11是被调用者保存的寄存器进入函数后如果要用必须 push 保存返回前恢复r12是内部调用临时寄存器IPr13是栈指针SPr14是链接寄存器LRr15是程序计数器PC。AAPCS64 的逻辑类似但寄存器数量多了很多。x0-x7是参数寄存器x8是间接结果寄存器类似于 32 位下的整型返回值用于返回大型结构体时指向返回空间x9-x15是临时寄存器x19-x29是被调用者保存x30是链接寄存器sp是栈指针。文档里还有一个容易忽略的地方就是复合类型参数传递规则。在 AAPCS32 里一个其大小不超过 4 字节的结构体可以直接通过通用寄存器传递而超过 4 字节且大小是 4 的整数倍的结构体则通过多个通用寄存器传递不满足这些条件时按“展开为多个参数”的规则处理。在 AAPCS64 里规则更细还会区分“含基本数据类型的同构聚合”即所谓的 HFA/HVA比如只包含 float 或 double 的结构体这类复合类型可以直接用浮点寄存器传递。我当时审计的重点就是在 HFA 这一块因为很多自研编译器在实现的时候容易漏掉这个规则。如果你写的结构体是struct { float x; float y; };在 AArch64 上它会被当作 HFA通过s0、s1传递而不是打包进x0。这个规则不实现C 库里的complex float运算就会出问题但奇怪的是很多简单测试用例根本测不出来只有跑 FPU 密集型的第三方库时才会暴露。2.3 审计中发现的几个值得注意的设计细节审计完整个仓库有几个细节给我留下的印象很深也直接影响了我的编译器开发落地。第一个是位域的内存布局方向。在 ARM 的默认 ABI 中位域的内存分配方向取决于字节序小端模式下位域从 LSB 开始分配大端模式下从 MSB 开始分配。这一点在手动写寄存器映射结构体的时候特别致命。很多做单片机开发的人在头文件里定义一个struct去映射外设寄存器位域换一个大端编译选项后整个寄存器读写全乱就是这个原因。第二个是va_list 的结构差异。在 AAPCS32 中va_list是一个数组类型指向参数栈区在 AAPCS64 中va_list是一个结构体里面同时保存了通用寄存器区指针、浮点寄存器区指针、栈参数区指针等。这意味着你如果在 AArch64 上把va_list直接当指针用或者想当然地按 32 位的方式解析代码必然出事。我审计的时候看到这个差异又专门去翻了 GCC 和 LLVM 的实现确认两者对va_list的定义高度一致但确实有一些老的第三方库用 GNU C 的__builtin_va_list做扩展时链接阶段没有匹配上导致运行时崩溃。第三个是大小端模式下的重定位信息。aaelf32.rst明确规定了不同重定位类型在大小端下的字节布局比如R_ARM_ABS32在小端模式下按tgt A S计算重定位字段按小端存放。这个看起来很简单但当你要自己实现一个 ELF 重定位器的时候大端模式下每一条重定位都需要小心处理很容易把 4 字节的值按反序写入目标内存。3. 编译器开发落地从规范到工具链的完整链路3.1 工具链选型AC5、AC6、GCC、LLVM 的 ABI 实现差异做编译器开发落地第一步不是写代码而是选定工具链。ARM 生态里常见的选择有ARM Compiler 5AC5基于 ARMCC只支持 ARMv7 及更早的架构。它使用老的 ATPCS 演进过来的 ABI虽然也能兼容 AAPCS但因为年代久远对新的语言标准支持差官方推荐用 AC6 替代。ARM Compiler 6AC6基于 LLVM 后端完整支持 AAPCS32 和 AAPCS64同时支持 AC5 的遗留关键字比如__irq、__forceinline是目前 Keil MDK 的默认编译器。GCC开源工具链在 ARM Linux 生态里最常用。arm-linux-gnueabihf-gcc使用硬浮点 ABIarm-linux-gnueabi-gcc使用软浮点 ABI两者不能互换。Clang/LLVM现代编译器基础设施跨平台、支持以库的形式嵌入其他工具做自研编译器的起点。不同工具链对 ABI 的遵守程度有差别。AC6 和 Clang 几乎完全实现 AAPCSGCC 在某些嵌入式场景下会提供-mstructure-size-boundary之类的扩展选项改变结构体对齐规则这种选项用多了就很容易产生 ABI 兼容问题。所以我的建议是如果你做的是库或者 SDK 要给别人调用绝对不要碰这些扩展选项如果你自己掌控全部源码且不跟外部二进制交互那你可以为了减小 RAM 占用适当放宽对齐要求。3.2 关键一步确认目标平台和硬浮点/软浮点选项落地任何 ARM 编译配置之前第一件事是确认浮点 ABI。ARM 的浮点 ABI 分成三种soft不使用 FPU浮点运算通过编译器生成的辅助函数完成参数通过通用寄存器传递。这样编出来的二进制兼容性最好任何 ARM 核都能跑但性能最差。softfp可以使用 FPU 指令完成浮点运算但参数仍然通过通用寄存器传递保持与软浮点 ABI 的参数传递规则兼容。hard参数通过浮点寄存器传递函数调用过程中直接使用 FPU 指令性能最好但要求所有互相调用的模块都用相同的硬浮点 ABI 编译。这三者中softfp 和 hard 的库混用是必炸的雷区。比如你用-mfloat-abisoftfp编了自己的库又链接了一个用-mfloat-abihard编的第三方库两个库的函数参数传递规则不一致调用时参数被放到了不同的寄存器区域轻则参数错误重则直接崩溃。这种问题排查起来极难因为编译链接都正常运行表现也时好时坏直到你把它放到压力测试里才暴露。在 GCC 里这个组合是通过-mfloat-abi和-mfpu两个选项控制的。-mfpu决定生成什么浮点指令比如vfpv3-d16、neon-mfloat-abi决定参数怎么传。选参数的时候要明确一点-mfpu只管指令集-mfloat-abi才管 ABI 兼容性二者不能混淆。3.3 结构体布局、位域与内存对齐的实战验证在编译器落地过程中结构体布局是最容易出错也最容易验证的部分。我建议写一个专门的小程序编译后在目标板上打印出每个结构体成员的大小、偏移量和对齐方式用来验证编译器的行为是否符合预期。下面这段代码就是我当时调试用的模板把它编译后在目标板或 QEMU 里跑一下所有布局规则一眼就能看明白。#include stdio.h #include stddef.h #include stdint.h struct mixed { char a; double b; int16_t c; char d; }; struct packed_struct { char a; int32_t b; } __attribute__((packed)); int main(void) { printf(sizeof(double) %zu\n, sizeof(double)); printf(alignof(double) %zu\n, _Alignof(double)); printf(sizeof(struct mixed) %zu\n, sizeof(struct mixed)); printf(offsetof(a) %zu\n, offsetof(struct mixed, a)); printf(offsetof(b) %zu\n, offsetof(struct mixed, b)); printf(offsetof(c) %zu\n, offsetof(struct mixed, c)); printf(offsetof(d) %zu\n, offsetof(struct mixed, d)); printf(sizeof(struct packed_struct) %zu\n, sizeof(struct packed_struct)); printf(offsetof(packed b) %zu\n, offsetof(struct packed_struct, b)); return 0; }我在这段代码上踩过一个教训在 AAPCS32 上double的自然对齐是 8 字节但 ARM 架构早期版本在栈上对 8 字节类型没有强制 8 字节对齐要求所以 GCC 在-mstructure-size-boundary8和默认配置下布局都会不一样。后来 ARM 在 AAPCS 里明确要求 8 字节数据类型的栈对齐必须按规则来结构体成员也要遵循自然对齐。所以如果你在用旧版编译器建议升级后把结构体布局重新验证一遍。关于位域布局直接给一个常用技巧如果要在不同编译器之间保持位域映射完全一致尽量不用位域改用uint32_t加掩码的方式定义寄存器映射。如果非要用位域至少要在头文件顶部写好明确注释注明该文件仅支持小端模式并加上编译时检查。这个习惯能帮你省掉很多查 bug 的时间。3.4 一个完整的 ABI 兼容编译流程示例这里给出一套我在嵌入式 Linux 项目里常用的编译配置流程目标是保证产出的库和可执行文件可以安全地和系统库互调。第一步确认目标架构和操作系统。比如目标板是 Cortex-A53跑的是 64 位 Linux 环境那么用的编译器前缀应该是aarch64-linux-gnu-。第二步设置统一的编译参数。我的建议是建立一套公用的CMake工具链文件把 ABI 相关的选项固定下来避免不同模块用不同参数编译。核心参数包括架构类型、浮点 ABI 和链接参数set(CMAKE_SYSTEM_NAME Linux) set(CMAKE_SYSTEM_PROCESSOR aarch64) set(CMAKE_C_COMPILER aarch64-linux-gnu-gcc) set(CMAKE_CXX_COMPILER aarch64-linux-gnu-g) # AArch64 下浮点 ABI 不像 32 位那样区分 softfp/hard但有 ILP32/LP64 之分 set(CMAKE_C_FLAGS -marcharmv8-afpsimd -mabilp64 -O2) set(CMAKE_CXX_FLAGS -marcharmv8-afpsimd -mabilp64 -O2)如果是 32 位 ARM Linux工具链文件里要特别注意-mfloat-abi的设置set(CMAKE_C_FLAGS -marcharmv7-a -mfloat-abihard -mfpuvfpv3-d16 -O2)第三步编译完所有目标文件之后做一次 ABI 检查。最直接的方法是反汇编关键函数确认参数传递寄存器使用是否符合预期。以 AArch64 为例编译一个简单的加法函数然后反汇编aarch64-linux-gnu-objdump -d test.o如果函数参数是用w0、w1传递的说明参数走通用寄存器符合 LP64 默认 ABI没问题。如果你发现传参时出现了栈操作先检查是不是结构体参数太大触发了按栈传递的规则。第四步链接阶段统一指定链接脚本如果是裸机程序或使用系统默认链接脚本如果是 Linux 程序。裸机环境里链接脚本中的内存布局必须和编译器的__attribute__((section))约定一致否则数据段跑到只读区域运行必炸。4. 常见问题与排查技巧实录4.1 典型踩坑现场第三方库崩溃、结构体错位、va_list 乱掉我实际在工作中积累了几个高频问题每一个都是真实的血泪教训。第一个是第三方预编译库崩溃。现象项目里链接了一个芯片厂商提供的静态库用 GCC 编译主程序只要一调用库里的函数就崩溃。排查过程很痛苦后来把问题定位到浮点 ABI 不一致。厂商的库是用-mfloat-abisoftfp编的我的主程序用了-mfloat-abihard。解决办法很简单把主程序改成 softfp 重新编译问题彻底消失。所以拿到任何第三方库第一件事就是用readelf -A查看它的属性标签确认浮点 ABI。readelf -A libfoo.a输出里会有Tag_ABI_VFP_args: VFP registers之类的字段清晰标明了硬浮点还是软浮点参数传递。这个命令也可以在链接之前检查所有目标文件是否一致非常好用。第二个是结构体大小对不上。现象一个结构体在服务端程序里序列化后通过网络发给嵌入式设备设备端解析时数据全部错位。查下来发现服务端用 x86 编译默认结构体对齐方式是 4/8 字节对齐设备端用 ARM GCC某些结构体成员因为使用#pragma pack或__attribute__((packed))有了不同的对齐方式。解决这类问题只有一个王道跨端通信的数据结构一律使用显式的打包方式不要依赖任何编译器的默认对齐规则。最好在网络层就用专门的序列化库或者手动对每个字段进行逐字节读写。第三个是va_list在 AArch64 上的行为异常。现象一个可变参数函数在 x86 上运行正常交叉编译到 ARM64 之后第一次取参数就拿到错误值。原因就是 64 位平台va_list已经不再是简单的指针类型它是一个结构体你需要用va_start/va_arg宏来操作不能直接检查其内部表示。我当时看到一段比较老的代码直接写了va_list ap; char *p ap;这在 32 位上是巧合能跑的在 64 位上就是未定义行为。4.2 问题速查表为了方便排查我把高频问题整理成一张对照表你可以贴在工位旁边遇到问题直接对号入座现象最可能的原因快速验证方法解决办法模块间函数调用时参数错乱或返回值不对浮点 ABI 不匹配分别对所有 .o 执行readelf -A比较 ABI 标签统一-mfloat-abi重新编译结构体序列化/反序列化结果错位结构体对齐规则不一致打印sizeof和offsetof使用__attribute__((packed))或序列化库64 位系统上可变参数函数取值错误va_list被当作指针操作检查代码里有没有直接访问va_list内部改用va_start/va_arg/va_end宏位域寄存器读写异常大小端模式与位域分配方向不匹配构造一个位域赋值再读回改用掩码读写或明确限制只支持小端大端模式链接后程序起不来ELF 重定位或入口点不对检查反汇编入口处指令核对架构大小端配置与链接脚本函数返回结构体时崩溃结构体返回约定不一致反汇编调用者和被调者确认两边用同一编译器版本及 ABI 参数4.3 排查 ABI 问题的三个实用工具第一是readelf -A它可以直接显示 ELF 文件的 ARM 属性标签包括使用的架构、浮点 ABI、大小端等。拿到任何库的第一件事是跑这个命令确认对方是不是跟你用同一套规则编译的。第二是objdump -d反汇编是验证 ABI 规则最可靠的方式。你觉得编译器可能没按 AAPCS 传递结构体就直接去看汇编代码寄存器是否按规范使用一目了然。第三是abi-compliance-checker这是一个专门检查 C/C 库 ABI 兼容性的工具适合用在动态库升级后的兼容性验证上。它能比较新旧两个版本库的所有导出符号、结构体布局、函数签名生成一个兼容性报告。版本升级后跑一遍比手动找问题高效得多。5. 一些关于 ABI 审计和编译器的思考坦白讲ABI 这东西平时不惹事的时候你完全感觉不到它的存在但只要出一次问题定位过程就像大海捞针。我个人的体会是与其出了问题再痛苦排查不如在项目开局的工具链阶段就把 ABI 相关的约束定死。顺便分享一个我一直在用的小技巧所有参与编译的机器和模块都统一用同一份工具链配置文件放在项目仓库的cmake/或build/目录下用 git 管理起来。团队里任何人拿到项目直接按这份配置构建就不会出现“我机器上好好的你那儿就崩”这类玄学问题。还有所有对外发布的静态库和头文件必须在 README 里写清楚编译时用的 CPU 架构、浮点 ABI、大小端和编译器版本别偷懒。写这篇文章的时候我重新把 ARM 官方的abi-aa仓库翻了一遍仍然能从里面找到一些之前没太注意的细节比如新版本对 Armv9 的 FEAT 特性的 ABI 扩展。所以建议大家隔一段时间就去仓库看看有没有更新毕竟架构在演进ABI 规则也会跟着调整。编译器开发这条路没有捷径读规范、验证实现、排查二进制是绕不过去的基本功。