ARMv7-A小系统实践(三):AAPCS32、栈与上下文汇编
本章记录什么
这一章讨论 C 函数与汇编函数如何彼此信任。处理器不会自动知道哪几个寄存器应该由调用者保存,也不会自动保证栈满足编译器要求;这些规则来自 AAPCS32。只有把参数、返回值、caller-saved、callee-saved、栈对齐和 lr 的含义连起来,才能安全地从启动汇编调用 C、从 IRQ 入口调用分发器,或者实现一个与编译器兼容的上下文切换函数。
在这一章之前做了什么
上一章已经说明模式切换如何改变 sp、lr 和 SPSR 的可见版本。那属于处理器架构。普通函数调用则通常不改变处理器模式,它依赖的是软件合同。两者都在操作寄存器,但保存集合、返回方式和错误症状完全不同。本章先把这条边界画清楚,再进入多寄存器访存和实际函数边界。
文章中的示例会从一个普通五参数函数开始,逐步走到汇编调用 C、C 调用汇编以及结构体偏移校验。读完后,即使没有工程源码,也应能独立判断一段汇编是否破坏了 ABI。
系列目录 · 上一篇:处理器模式、寄存器组与 CPSR
本章的阅读顺序
先读基础概念,确认每个术语解决什么问题;再看设计推导,理解实现为什么采用这种保存集合、初始化顺序或状态机;随后逐段阅读代码和执行轨迹;最后用验证方法与错误案例检查理解。文中保留寄存器名、指令名和标准缩写,叙述部分统一使用简体中文。
动手前的基础:AAPCS32 的完整调用合同
适用平台:Zynq-7000 / Cortex-A9 / AArch32 /
-mfloat-abi=soft
学习目的:能在 C、汇编、异常入口、调度器之间安全传参和保存现场
阅读方式:正文替代本节 AAPCS32 英文阅读;本章没有必须对照的原图
编写日期:2026-07-30
0. AAPCS 不是“编译器习惯”,而是模块之间的合同
AAPCS32 是 Arm 32 位过程调用标准。只要两个模块都遵守同一 ABI,它们不需要知道对方内部怎样实现,也能正确协作:
1 | |
它主要约定:
- 参数放在哪些寄存器或栈位置;
- 返回值放在哪里;
- 哪些寄存器由调用者保护,哪些由被调用者保护;
- 栈向哪里增长、在公共接口必须怎样对齐;
BL/LR如何表达调用和返回;- ARM/Thumb 代码怎样互操作。
如果双方对这份合同理解不同,代码可能在 -O0 正常、-O2 崩溃,也可能只在增加一个日志函数后才暴露。这种“玄学”通常不是编译器随机出错,而是手写汇编破坏了 ABI。
1. 本文覆盖的原始资料和准确范围
| 原始资料 | 准确范围 | 页数 | 本文用途 |
|---|---|---|---|
本地 42_ARM_C过程调用标准_AAPCS32.pdf 第 6.1–6.6 节 |
PDF 页 17–25 | 9 页 | Base PCS 的寄存器、栈、调用、返回、传参和互操作 |
| Programmer’s Guide 第 5 章 | PDF/手册页 57–68 | 12 页 | 汇编文件、GNU/Arm 语法和 interworking 背景 |
| Programmer’s Guide 第 6.4 节 | 第 82 页附近 | 选读 | B/BL/BX/BLX 与函数边界 |
本地 AAPCS32 文档的相关小节:
1 | |
原文没有需要单独打开查看的架构图。若想核对寄存器表,直接看:
- AAPCS32 第 17 页,
Core registers and AAPCS usage表。 - AAPCS32 第 20–21 页,
The Stack。 - AAPCS32 第 23–24 页,参数分配 Stage A/B/C。
2. 先建立一个函数调用的全流程
假设:
1 | |
调用点附近的逻辑可理解为:
1 | |
这里已经包含 AAPCS 的三类规则:
- 前四个整型参数优先使用
r0-r3。 BL把返回地址放入LR。- 一个 32 位返回值使用
r0。
但调用者不能假定调用后 r1-r3/r12/LR 仍保持原值,因为它们属于可破坏寄存器。
3. 核心寄存器的 ABI 角色
3.1 一张真正应该记住的表
| 寄存器 | AAPCS 角色 | 谁负责跨函数调用保持 |
|---|---|---|
r0-r3 |
参数、结果、过程内临时量 | caller |
r4-r8 |
局部变量寄存器 | callee |
r9 |
平台寄存器或额外 callee-saved | 由平台 ABI 明确 |
r10 |
局部变量寄存器 | callee |
r11 |
局部变量/常用帧指针 | callee |
r12 / ip |
intra-procedure-call scratch,链接器也可使用 | caller |
r13 / sp |
栈指针 | callee 必须恢复 |
r14 / lr |
链接寄存器 | caller 不能假定调用后保留 |
r15 / pc |
程序计数器 | 控制流 |
压缩记忆:
1 | |
r9 必须看本平台约定。它可能是静态基址、线程寄存器,也可能被当作 callee-saved。写可移植汇编时不要擅自拿它当普通 scratch。
3.2 caller-saved 的真正含义
不是说“caller 每次都必须保存 r0-r3”,而是:
如果调用者在
BL之后还需要某个 caller-saved 寄存器里的旧值,调用者必须在调用前把这个活跃值移到安全位置。
例如:
1 | |
当前函数既然改写了 r4,它自己作为 callee 又必须在入口保存调用者原来的 r4:
1 | |
保存责任是相对调用边界而言的,不是某个寄存器永远只能由固定一方使用。
3.3 callee-saved 的真正含义
被调用函数可以使用 r4-r11,但返回时必须让调用者看到进入前的值。典型非叶函数:
1 | |
若函数根本没改 r5-r11,就没有必要机械保存全部寄存器。编译器会根据实际活跃集合生成最小保存集。
4. BL、LR 与叶函数/非叶函数
4.1 BL target
BL 完成:
1 | |
被调用函数通常通过:
1 | |
返回。BX 还会根据目标地址最低位处理 ARM/Thumb 指令状态。
4.2 为什么非叶函数通常保存 LR
叶函数不再调用其他函数,可以直接保留入口时的 LR:
1 | |
非叶函数执行第二次 BL 时,自己的 LR 会被新的返回地址覆盖:
1 | |
因此“LR 是返回地址”还不够准确。更准确的是:
LR是最近一次链接操作写入的返回信息;需要跨下一次BL保留时,必须先保存。
4.3 B、BL、BX、BLX
| 指令 | 主要用途 |
|---|---|
B target |
跳转,不建立新返回链 |
BL target |
调用,写 LR |
BX Rm |
跳到寄存器地址,可切换 ARM/Thumb 状态 |
BLX target/Rm |
调用并支持状态切换 |
函数指针调用通常不能简单替换成固定标签 BL,要考虑地址最低位携带的指令状态信息。
5. 栈模型与对齐
5.1 满递减栈
AAPCS32 通常使用向低地址增长的 full-descending stack:
1 | |
典型压栈:
1 | |
是 STMDB sp!, {r4, lr} 的常用别名。先降低 SP,再保存。
5.2 两级对齐要求
AAPCS 的核心约束:
1 | |
“公共接口”包括正常的 ABI 函数调用边界。手写汇编在执行 BL c_function 前,必须确保 SP 8 字节对齐。
错误例:
1 | |
可采用:
1 | |
或者显式补齐,但保存与恢复布局必须一致。
5.3 为什么“现在没用 64 位值”也要对齐
被调用 C 函数内部可能:
- 保存双字数据;
- 调用另一个使用双字对齐的函数;
- 使用编译器生成的
LDRD/STRD; - 在优化后改变栈布局。
调用者不能根据当前反汇编碰巧没出问题,就违反 ABI。
5.4 异常栈也要履行 AAPCS
硬件进入 IRQ 模式后并不会自动把 SP_irq 调整为 C ABI 所需布局。入口汇编若要调用 C dispatcher,必须主动保证:
1 | |
“异常入口不属于普通 C 调用”不能成为调用 C 时破坏 AAPCS 的理由。
6. 参数分配:先寄存器,后栈
6.1 最常用规则
对 32 位整数、指针等一个字大小的参数:
1 | |
例:
1 | |
入口时:
1 | |
被调函数若先 push 了寄存器,e 相对于当前 SP 的偏移会变化。汇编作者必须根据自己的序言重新计算,不能永远写 [sp]。
6.2 64 位参数的偶数寄存器对齐
需要双字对齐的参数进入核心寄存器时,起始寄存器号要为偶数。例如:
1 | |
典型分配:
1 | |
不能把 b 想当然放在 r1/r2。
6.3 参数可能被寄存器和栈拆分
如果核心寄存器还有空间、而某个多字参数装不完,标准在满足条件时允许前半放剩余寄存器,后半放栈。这就是为什么复杂原型最好:
- 先用 C 写一个最小函数;
- 用目标编译选项生成
.s或反汇编; - 再写手工汇编边界;
- 最后用 AAPCS 规则裁决,而不是凭印象猜。
6.4 结构体按值传参
复合类型传参时,标准按其内存内容复制,大小向 4 字节倍数处理;需要时调用者先生成临时副本。小结构体可能占多个核心寄存器,也可能部分或全部放栈。
接口设计上,内核汇编边界优先使用:
1 | |
而不是把大型结构体按值传递。一个指针只占 r0,布局也更容易稳定和检查。
6.5 soft-float 对当前项目的意义
当前项目使用 -mfloat-abi=soft,基础理解是:
- 浮点参数不采用 hard-float 的 VFP 参数寄存器 ABI。
- 它们按 Base PCS 的核心寄存器/栈规则传递。
- 所有目标文件和库必须使用兼容浮点 ABI。
若一部分按 soft-float、一部分按 hard-float 编译,即使函数名和 C 原型相同,调用边界也可能完全不兼容。
7. 返回值规则
常见返回位置:
| 返回类型 | 位置 |
|---|---|
| 小于等于 32 位的基础类型 | r0 |
| 64 位基础类型 | r0-r1 |
| 可按四个字表达的基础结果 | 最多 r0-r3 |
| 不大于 4 字节的小复合类型 | r0 中按内存装载效果返回 |
| 大于 4 字节或大小不能静态确定的复合类型 | 调用者提供结果内存 |
7.1 隐藏的结构体返回指针
例如:
1 | |
Pair 大于 4 字节。Base PCS 通常让调用者分配结果空间,并把其地址作为隐藏参数放入 r0。于是显式参数向后顺延:
1 | |
这就是为什么只看 C 源码会误以为 a 在 r0。写汇编实现复合返回函数时必须知道隐藏参数。
7.2 返回后哪些寄存器可信
若函数返回 32 位结果,只有约定的 r0 承载结果。不能因为某次反汇编中 r1 恰好还有中间值,就把它当第二返回值使用。
多返回信息更适合:
- 用结构体指针输出;
- 明确返回一个 ABI 支持的复合类型;
- 或将次要信息放调用者提供的对象中。
8. AAPCS Stage A/B/C 的工程化理解
原文第 23–24 页用 Stage A、B、C 严格定义参数分配。第一次不需要背规则编号,但要理解算法顺序。
Stage A:初始化分配状态
1 | |
如果函数用内存返回大结构体:
1 | |
Stage B:预处理参数
对每个参数先确定:
- 实际传递大小;
- 是否需要符号/零扩展到一个字;
- 复合对象是否需要调用者副本;
- 大小是否向 4 字节倍数取整;
- 是否要求 8 字节对齐。
Stage C:分配到寄存器或栈
依次尝试:
- 满足双字对齐要求时,把 NCRN 调到偶数寄存器。
- 参数能完全放入剩余核心寄存器,就放寄存器。
- 满足标准条件时,可在寄存器和栈之间拆分。
- 核心寄存器耗尽后,后续参数放栈。
- 栈地址按参数需要对齐,再复制参数。
对移植者而言,最重要的不是手算任意复杂结构体,而是知道:
参数位置由完整原型、类型大小、对齐、返回方式和 ABI 共同决定,不能只按“第几个参数”机械判断。
9. 汇编函数怎样正确调用 C
假设 IRQ 汇编已经建立:
1 | |
调用边界至少应满足:
1 | |
关键点:
r0是地址,不是 frame 的第一项内容。- C handler 可以合法改写
r0-r3/r12/LR。 - 被中断任务的完整现场必须在调用 C 之前保存。
SPSR不属于 AAPCS 普通寄存器保存合同,异常入口必须自己管理。- 从 C 返回后,异常恢复代码依赖的是内存中的 frame。
10. 普通任务上下文为什么常保存 r4-r11
如果上下文切换函数在一个正常 ABI 调用边界发生:
1 | |
调用者本来就必须认为:
1 | |
调用者若有跨调用活跃值,编译器已经把它们放到 callee-saved 寄存器或栈。切换函数要让任务未来“像这次调用正常返回一样继续”,核心是保持:
1 | |
这就是普通上下文可利用 AAPCS 缩小保存集的原因。
但异常可发生在任意指令之间,不能假定当前正处于函数调用边界。因此异常现场通常必须保护更完整的 r0-r12、返回 PC 和 CPSR。
1 | |
11. 反汇编阅读方法:从 C 原型向寄存器映射
看到一个汇编调用时,按固定顺序分析:
第一步:找到完整 C 原型
包括:
- 返回类型;
- 所有参数的精确类型;
- 是否变参;
- 结构体是否按值;
- 编译单元的 float ABI。
第二步:判断有没有隐藏结果指针
返回大型复合类型时,先把 r0 留给结果地址。
第三步:按类型大小和对齐分配 r0-r3
遇到 64 位或复合类型,不要只数参数个数。
第四步:在调用前向上追踪寄存器定义
例如:
1 | |
结合原型解释为:
1 | |
r0 与 [r0] 必须区分:前者是寄存器中的值,后者才是访问该值指向的内存。
第五步:调用后只信 ABI 保证
检查调用者是否错误地继续使用未保存的 r0-r3/r12/LR。
12. 最常见的 ABI Bug 与定位方式
Bug 1:只在优化版本崩溃
检查:
- 汇编函数是否改写
r4-r11却没有恢复; - 是否把 caller-saved 值跨
BL使用; - inline asm 是否缺少 clobber 声明;
- C 与汇编声明是否一致。
Bug 2:加一条日志后异常返回就坏
日志函数是一次新的 BL,会改写 LR 和 caller-saved 寄存器。检查:
- 异常返回地址是否在调用日志前存入内存;
SPSR是否安全保存;- 栈是否仍 8 字节对齐;
- 是否发生 IRQ 栈溢出。
Bug 3:第五个参数值错误
检查:
- 被调函数序言压栈后,栈参数偏移是否重算;
- 调用点 SP 是否对齐;
- 前面是否存在 64 位参数造成寄存器跳位;
- 是否存在隐藏结构体返回指针。
Bug 4:某函数返回后 r4 被污染
这是典型 callee 违约。对照函数入口/出口的保存集合,确认所有返回路径都恢复了相同寄存器和 SP。
Bug 5:链接成功但浮点参数全错
检查所有目标文件与库的:
1 | |
函数名匹配不代表 ABI 匹配。
13. 为 Zynq 移植制定统一 ABI 基线
第一版应把 ABI 固定到构建系统,而不是靠个人记忆:
1 | |
汇编文件要使用与 C 一致的选项,并明确:
- 函数符号类型与可见性;
- ARM/Thumb 状态;
- 必要的
.align; - 不可执行栈等元数据(按工具链需要);
- 保存集合和栈帧布局注释。
每个 C/汇编边界建议写一小段“ABI 合同”:
1 | |
14. 本目标的掌握标准
完成本章后,应能不看资料回答:
r0-r3与r4-r11的保存责任分别是什么。- 为什么 caller-saved 不等于每次都保存。
- 为什么非叶函数通常要保护 LR。
- 5 个 32 位参数放在哪里。
- 64 位参数为什么可能跳过奇数号寄存器。
- 大结构体返回为什么会占用隐藏的
r0。 - 为什么汇编调用 C 前要让 SP 8 字节对齐。
- 为什么异常入口调用 C 前必须先把完整现场放入内存。
- 为什么普通任务切换可以主要保存 callee-saved 状态,而 IRQ 现场不行。
- 为什么当前
soft-float必须在整个链接单元中保持一致。
30 秒复述版
AAPCS32 是 C 与汇编之间的调用合同。r0-r3 用于参数、返回和临时量,由调用者保护活跃值;r4-r11 由被调用者保持,r12 是 scratch,BL 会改 LR。普通 32 位参数先放 r0-r3,再放栈;64 位参数注意偶数寄存器和 8 字节对齐;大结构体返回会通过 r0 传入隐藏结果指针。SP 平时至少 4 字节对齐,在公共函数接口必须 8 字节对齐。异常汇编若要调用 C,也必须先完整保存异常现场并满足这份合同。
15. 下一主题的接口
有了模式 bank 和 AAPCS,下一步可以真正解释上下文:
1 | |
同时还要学习 LDR/STR、STM/LDM、MRS/MSR、SRS/RFE 等指令怎样把这套合同落实到内存。
用常见错误和函数边界检验 ABI
四、第一张必须掌握的表:寄存器调用角色
| 寄存器 | AAPCS 名称 | 普通用途 | 谁负责保证调用后仍有效 |
|---|---|---|---|
r0 |
a1 |
参数1、返回值、临时值 | 调用者 |
r1 |
a2 |
参数2、返回值一部分、临时值 | 调用者 |
r2 |
a3 |
参数3、临时值 | 调用者 |
r3 |
a4 |
参数4、临时值 | 调用者 |
r4-r8 |
v1-v5 |
局部变量寄存器 | 被调用者 |
r9 |
v6/SB/TR |
平台寄存器或局部变量寄存器 | 由平台 ABI 决定 |
r10 |
v7 |
局部变量寄存器 | 被调用者 |
r11 |
v8/FP |
局部变量或帧指针 | 被调用者 |
r12 |
IP |
函数内部/链接器临时寄存器 | 调用者 |
r13 |
SP |
栈指针 | 被调用者必须维护 |
r14 |
LR |
返回地址 | 调用者不能假设调用后仍保留原值 |
r15 |
PC |
程序计数器 | 控制流寄存器 |
把它压缩成两组:
1 | |
Caller-saved 到底是什么意思
Caller-saved 不是“调用者每次必须保存”。
准确含义是:
被调用函数可以破坏这些寄存器。如果调用者在函数返回后还需要原值,调用者必须在调用前自己保存。
例如:
1 | |
执行完 BL 后,不能假设 r0 仍然等于 123,因为 r0 是 caller-saved。
Callee-saved 到底是什么意思
准确含义是:
如果被调用函数需要使用这些寄存器,就必须在返回前恢复成进入函数时的值。
例如:
1 | |
my_function 使用了 r4,因此进入时保存、返回时恢复。它使用 r4 跨越 another_function 的调用,是因为 another_function 也必须保持 r4。
五、为什么 r12 不能用来跨函数保存重要数据
r12 又叫 IP,是 Intra-Procedure-call scratch register。
除了被调用函数可以修改它,链接器还可能在调用点插入 veneer:
1 | |
veneer 用于:
- 目标地址超过
BL的直接跳转范围。 - ARM/Thumb interworking。
- 某些动态链接场景。
AAPCS 允许 veneer 修改:
r12- CPSR 条件标志
因此:
即使读者查看当前反汇编发现一次
BL没有修改 r12,也不能把需要跨调用保存的值放在 r12 中。重新链接、地址变化或加入新代码后,链接器可能插入 veneer。
六、r9 为什么不能简单死记
r9 的角色由平台 ABI 决定,常见选择:
SB:Static Base,静态基址。TR:Thread Register,线程寄存器。v6:普通 callee-saved 变量寄存器。
所以通用 AAPCS32 不能无条件说“r9 永远是普通 callee-saved 寄存器”。
对一套现有 ARMv7-A 工程进行核对时,正确做法是:
- 检查工具链、平台 ABI 和 ELF attributes。
- 搜索项目是否固定使用 r9。
- 分析编译器实际生成的代码。
在没有完成这三步前,手写汇编应保守处理 r9,不要擅自把它作为随意破坏的临时寄存器。
七、栈规则
AAPCS32 使用 full-descending stack:
- 栈向低地址增长。
- SP 指向当前已分配栈区域的最低地址。
PUSH通常等价于STMDB sp!, {...}。POP通常等价于LDMIA sp!, {...}。
任何时刻的基本约束
1 | |
即 SP 至少保持 4 字节对齐。
公共调用边界的额外约束
1 | |
也就是当代码进入一个符合 AAPCS 的公开函数接口时,SP 必须为 8 字节对齐。
这里最实用的判断方法是:
1 | |
常见错误
假设进入汇编函数时 SP 已经 8 字节对齐:
1 | |
可以改为:
1 | |
八、子程序调用与返回
BL
BL target 完成:
1 | |
在 ARM 状态下,LR[0] == 0;从 Thumb 状态调用时,返回信息中 LR[0] == 1,用于指示返回后的指令集状态。
BLX
BLX 可以调用寄存器中的目标地址,并正确处理 ARM/Thumb 状态切换和返回状态。
BX LR
叶子函数没有再次调用其他函数、也没有把 LR 用作临时寄存器时,通常可以:
1 | |
如果函数内部还要执行 BL,新的 BL 会覆盖 LR,因此通常先保存 LR:
1 | |
九、参数传递:第一轮先掌握常用规则
1~4 个 32 位参数
1 | |
进入 f 时:
1 | |
第 5 个及后续参数
1 | |
进入函数时:
1 | |
这里的 [sp] 是函数入口时的 SP。函数自己执行 prologue 改变 SP 后,参数相对当前 SP 的偏移也会变化。
小于 32 位的整数
char、short 等整数参数在传递前扩展到 32 位:
- 有符号类型进行符号扩展。
- 无符号类型进行零扩展。
64 位参数
64 位参数占用两个连续核心寄存器,并且必须从偶数编号寄存器开始:
1 | |
例如:
1 | |
概念分配结果:
1 | |
64 位值在两个寄存器中的具体字顺序按它从内存中通过一次 LDM 加载后的表现确定,并受数据端序影响。
结构体参数
结构体属于 Composite Type。第一轮只需知道:
- 小结构体可能拆分到多个 r0-r3。
- 寄存器不够时,可能一部分在寄存器、一部分在栈。
- 需要 8 字节对齐的结构体会触发偶数寄存器/栈对齐规则。
- 尺寸不能由调用双方静态确定时,通常改为传递指向副本的指针。
结构体的完整 Stage A/B/C 分配算法先做到“会查”,暂时不要求背诵。
十、返回值
base PCS 常用返回规则:
| C 返回类型 | 返回位置 |
|---|---|
char/short/int/enum/pointer |
r0 |
long long、64 位基础类型 |
r0-r1 |
| 128 位 containerized vector | r0-r3 |
| 不超过 4 字节的小结构体 | r0 |
| 较大结构体 | 调用者提供内存,隐藏地址通常通过 r0 传入 |
较大结构体返回可以理解为编译器在概念上改写:
1 | |
类似:
1 | |
此时:
1 | |
十一、CPSR 在普通调用边界上的规则
普通函数返回后,调用者不能依赖以下 CPSR 标志仍保持调用前的值:
1 | |
也就是说,不能写出依赖跨函数调用条件标志的逻辑:
1 | |
应该在调用后重新比较:
1 | |
A/I/F/M 属于特权状态,不是普通应用函数随意修改的临时状态。特权系统代码如果确实修改这些位,必须根据自己的接口合同和异常/并发要求进行恢复。
十二、现有 ARMv7-A 工程中最值得看的 C/汇编边界
边界 1:C 调用汇编上下文切换
C 文件:
1 | |
调用:
1 | |
根据 AAPCS32:
1 | |
汇编入口:
1 | |
这说明 C 与汇编在入口参数上的合同完全可由 AAPCS32解释。
但是它不是普通“调用后回到同一执行流”的函数:它切换 SP 并恢复另一个任务,最终通过 RFE 进入新上下文。因此:
- 进入汇编函数的参数边界遵循 AAPCS。
- 内部上下文帧和离开方式由操作系统自己定义。
这正是 AAPCS 与上下文切换的边界。
边界 2:IRQ 汇编调用 C
文件:
1 | |
IRQ 入口先分配 64 字节:
1 | |
64 是 8 的倍数。如果异常栈初始值按 8 字节对齐,这些 BL 的 SP 仍满足公共调用边界要求:
1 | |
其中:
1 | |
解释:
- C 函数把返回值放在
r0。 - 下一次 C 调用可以破坏
r0。 - 汇编把这个需要跨调用保存的值移到
r4。 r4是 callee-saved,因此sched_check_is_need_isr_tail必须保持它。
源代码注释“r4 不是 caller-saved”表达的就是这一规则,更标准的说法是:
r4 是 callee-saved / call-preserved register。
边界 3:IRQ tail 主动对齐 SP
同一文件中:
1 | |
它把 SP 向下对齐到 8 字节边界,然后再调用 C:
1 | |
源码注释已经明确指出原因:被中断线程的 SP 在任意时刻只保证 word alignment,而执行 BL 进入 C 的公共调用边界要求 8-byte alignment。
这段代码是项目中学习 AAPCS 栈对齐的最佳实例。
边界 4:启动汇编调用 C
arm_boot.S 中:
1 | |
链接脚本把临时启动栈顶按 16 字节对齐,因此也满足 AAPCS 的 8 字节调用边界要求。
arm_startup.S 中:
1 | |
最后的 b srh_kernel_init 是尾调用式转移:它不需要返回到 kernel_entry。进入 C 函数前仍然需要准备合法 SP 和正确参数。
十三、第一轮阅读范围
不要从头读完整个 AAPCS32。第一轮只看第 6 章以下部分:
| 章节 | 目的 | 第一轮要求 |
|---|---|---|
| 6.1.1 Core registers | 寄存器角色 | 必须掌握 |
| 6.1.1.1 Values larger than 32 bits | 64/128 位寄存器占用 | 理解 |
| 6.2.1 The Stack | 栈模型 | 必须掌握 |
| 6.2.1.1 Universal constraints | 4 字节对齐 | 必须掌握 |
| 6.2.1.2 Public interface | 8 字节对齐 | 必须掌握 |
| 6.3 Subroutine Calls | BL/LR/返回 | 必须掌握 |
| 6.3.1 Use of IP | r12/veneer | 理解 |
| 6.4 Result Return | 返回值 | 掌握常用类型 |
| 6.5 Parameter Passing | 参数分配 | 掌握常用规则,复杂结构会查即可 |
当前项目是 soft-float,第一轮跳过:
- 6.1.2.1 VFP register usage conventions 的细节。
- 第 7 章 VFP 参数传递变体。
- 语言专用的复杂接口。
从规则走到实际实现
下面回到本章对应的小系统实现。这里不再假定读者已经打开源码,而是把关键地址、数据结构、指令序列和返回路径直接放在文章中解释。
调用规范解决的是模块之间的约定
编译器可以把局部变量放在寄存器,也可以放在栈里。调用另一个函数时,如果双方没有一致规则,就无法知道哪些值仍然有效。
AAPCS32 规定这种边界约定。本工程采用普通 32 位参数传递、ARM 状态和 -mfloat-abi=soft。先限定这组条件,再讨论最小寄存器现场;不能一边不保存浮点寄存器,一边允许任务任意执行 VFP/NEON。
| 寄存器 | 常见作用 | 跨普通调用的责任 |
|---|---|---|
| r0-r3 | 参数、结果、临时值 | 调用者需要时自行保存 |
| r4-r8 | 局部值 | 被调用者保持 |
| r9 | 平台寄存器或额外保存寄存器 | 必须按平台 ABI 处理 |
| r10-r11 | 局部值、可能的帧指针 | 被调用者保持 |
| r12/ip | 临时值,链接器 veneer 也可能使用 | 不保证保持 |
| r13/sp | 当前栈位置 | 函数返回时恢复约定值 |
| r14/lr | 链接返回地址 | 发起嵌套调用时必须保护原返回点 |
| r15/pc | 当前执行位置 | 由分支与返回改变 |
本工程直接保存 r4-r11,包含 r9,避免遗漏平台保留寄存器。不能把“r9 一定是普通局部变量”写成通用规则。具体定义见 AAPCS32 的 Machine Registers 与 The Stack。
参数并不总是“第一个到第四个进寄存器”
对四个普通 32 位整数,规则很直观:
1 | |
入口时 a/b/c/d 分别位于 r0/r1/r2/r3,32 位整数结果通常放 r0。增加第五个 32 位参数,在这个简单例子中就需要从调用者提供的栈参数区取。
64 位参数还涉及寄存器配对和对齐。以如下原型为例:
1 | |
在这里讨论的基本 PCS 中,a 使用 r0;b 需要双字对齐的寄存器对,放 r2:r3,r1 可能空出来;c 落到栈上。不能按“还有一个空闲 r1,所以 c 就放 r1”理解。
结构体返回更不能一概按 r0 处理:某些结果需要调用者提供内存,并通过隐藏参数传入结果地址。浮点变体、复合类型、可变参数有额外规则。调试陌生汇编时先看完整函数原型与编译选项,再推断寄存器。
BL 保存的是返回地址,不是整个函数现场
下面是解释调用关系的完整小例子:
1 | |
BL 会改 LR。因此 call_and_add_one 在执行 BL 前必须把来自其调用者的 LR 保存起来;否则最后只能找到内部调用之后的位置。
叶子函数 add_two 没有继续发起调用,又没有修改需要保持的寄存器,可以直接 BX LR 返回。r0 作为结果寄存器被改变是正常的。
r12 也不能被当成跨 BL 的保险箱:链接器为远跳转或 ARM/Thumb 互操作插入的 veneer 可能使用它。只有检查源代码而不看最终反汇编,很容易漏掉这种情况。
栈为什么有两个对齐要求
AAPCS 的基本栈约束要求 SP 至少按 4 字节对齐;在公共调用接口处要求 8 字节对齐。栈通常向低地址增长,SP 指向当前已分配栈区的边界。
对齐要求的区别会直接影响 IRQ:
1 | |
因此不能把“异常发生时任务 SP 不是 8 字节对齐”直接判成损坏。基础版把 C handler 放到链接脚本保证 8 字节对齐的独立栈上,解决的是 C 调用边界对齐;它不要求被打断任务的 SP 在任意时刻都 8 字节对齐。
在普通汇编函数中,单独 push {lr} 会减去 4 字节。如果紧接着 BL 到 C,而原 SP 为 8 字节对齐,那么这个调用就破坏了规则。可以成对保存,或显式补齐,再在返回前精确恢复。
地址和值要分开读
1 | |
三条指令依次表示:
- 把 task_a_context 的地址装入 r0;
- 从该地址加 32 的内存读取一个 32 位值;
- 把当前 SP 的值写入该地址加 32 的内存。
第一条的 = 是汇编伪指令语法,最终可能变成 literal load 或其他适合的装常量方式;它不是“取 task_a_context 的第一个字段”。
C 中对应的思路是:
1 | |
一个上下文对象保存的是地址和数值;真正把这些数装回寄存器的动作仍由汇编完成。
STM/LDM 怎样决定内存布局
STMIA 是从基址向高地址存储多个寄存器,LDMIA 反向执行加载。寄存器按编号递增排列,不按花括号里的视觉书写顺序任意排序。
1 | |
对应:
1 | |
没有 !,r0 自身不做写回。若写成 stmia r0!, {...},还会更新基址。上下文保存通常不希望无意改变保存对象指针,必须看清这个符号。
栈常用的是另一对:
1 | |
假设原 SP 为 0x2000,第一条完成后 SP 为 0x1ff8,r4 位于 0x1ff8,LR 位于 0x1ffc。恢复后 SP 回到 0x2000。FD 是 full descending 的栈命名方式,必须结合存储或加载操作理解,不能把不同指令中的后缀机械替换。
为什么普通 context 不保存 r0-r3
基础版切换函数本身就是一次正常调用:
1 | |
对编译器来说,这个调用可以破坏 r0-r3、r12 和条件标志。若任务在调用后还需要某个值,编译器会提前把它放进 callee-saved 寄存器或栈里。
于是底层只需要维持普通调用所承诺的继续执行状态:
1 | |
r0/r1 此时已经是两个 context 指针,早已不再是任务在函数调用前随意使用的临时值。保存它们并不能还原“调用前的一切”,也没有必要。
IRQ 没有这个编译器配合过程。它可以打断一个尚未执行完的表达式,r0 中可能就有不可丢的中间结果。因此异步现场必须另外处理这些寄存器。
C 结构体与汇编偏移必须共同维护
基础版的布局为:
1 | |
对应的关键偏移:
1 | |
44 字节大小并不违背 SP 8 字节对齐要求:结构体不是函数栈指针。真正需要向 8 字节对齐的是任务入口和调用边界的 SP。
工程使用 _Static_assert(offsetof(...)) 检查布局。这个检查比注释重要,因为给结构体插入字段以后,手写汇编不会自动知道所有偏移已移动。
少数指令就能读通主路径
| 指令 | 在工程里的职责 |
|---|---|
| MOV、ADD、SUB | 搬移指针,分配栈,修正返回 PC |
| LDR、STR | 读写 context 字段、设备寄存器 |
| LDM、STM | 批量保存和恢复寄存器 |
| CMP 与条件执行 | 分 CPU、比较状态、循环清 BSS |
| B、BL、BX、BLX | 跳转、调用与指令状态互操作 |
| MRS、MSR、CPS | 状态寄存器和模式控制 |
| MRC、MCR | 访问 CP15 系统控制接口 |
| SRS、RFE | 异常返回状态转存与恢复 |
| DMB、DSB、ISB | 数据顺序、完成与指令上下文同步 |
| WFI、WFE、SEV | 等待中断、等待事件、发送事件 |
理解这些指令时最好总带一个具体地址、一组寄存器值和一个栈图。只记助记符的中文翻译,遇到写回、模式切换或 LR 的两种含义时仍容易混淆。
下一篇:启动、链接与 UART。
重点回看
AAPCS32 是 C 与汇编共享的合同。调用者保护易失寄存器,被调用者保护 r4-r11 和 sp,公共调用边界保持 8 字节对齐。普通 context 的最小保存集合由这个合同推导,而 IRQ 现场不能照搬它。结构体偏移必须由静态断言与汇编常量共同约束。
本章总结
AAPCS32 是 C 与汇编共享的合同。调用者保护易失寄存器,被调用者保护 r4-r11 和 sp,公共调用边界保持 8 字节对齐。普通 context 的最小保存集合由这个合同推导,而 IRQ 现场不能照搬它。结构体偏移必须由静态断言与汇编常量共同约束。
下一章把模式与 ABI 放进启动路径,用链接脚本和启动汇编建立第一个可观察的 C 环境。
ARMv7-A小系统实践(三):AAPCS32、栈与上下文汇编
https://goko-son626.github.io/post/armv7a-02-aapcs-assembly.html

