@[toc]
嵌入式工程中,栈对齐通常不会被单独拿出来讨论。很多 Cortex-M 工程长期使用 4 字节对齐的内存访问,也能正常启动、跑任务、处理中断,因此“栈必须 8 字节对齐”看起来不像一个硬性问题。真正容易暴露问题的场景,往往出现在变参函数、64 位数据、浮点参数、启动阶段 C 代码、异常入口,以及手写汇编与编译器生成代码混用的边界上。
这篇文章从规定开始,解释为什么 Cortex-M 工程需要关心栈的 8 字节对齐;再说明不满足时可能出现什么现象;最后给出不同工具链脚本的查看方法和修改方法。
一、规定:公开接口处 SP 必须 8 字节对齐
AAPCS32 是 Arm 32 位架构的过程调用标准。它规定函数之间如何传参、如何返回、哪些寄存器由调用者保存、哪些寄存器由被调用者保存,以及栈指针需要满足什么约束。
在 AAPCS32 中,栈有两层约束:
| 位置 | 要求 | 含义 |
|---|---|---|
| 任意时刻 | SP mod 4 = 0 |
栈指针至少按字对齐 |
| 函数公开调用边界 | SP mod 8 = 0 |
栈指针必须按双字对齐 |
这里的“函数公开调用边界”可以理解为:编译器、库函数、汇编代码、启动代码、异常入口或运行时环境之间按照 ABI 互相调用的位置。只要进入的是符合 AAPCS32 的 C/C++ 函数,就应保证此时 SP 是 8 字节对齐的。
这条规定不是 Thumb 指令集规定的。Thumb / Thumb-2 是 Cortex-M 执行的指令编码;AAPCS32 是 C/C++ 函数调用的 ABI。Cortex-M 运行 Thumb 指令,但函数调用仍然按照 AAPCS32 组织。
二、为什么这样规定
2.1 8 字节数据需要可靠的栈上位置
C 代码中存在需要 8 字节对齐或 8 字节宽度的数据,例如:
1 | double |
这些类型不一定都会放到栈上。小参数可能放在寄存器里,硬浮点 ABI 下的非变参浮点参数还可能走浮点寄存器。但是一旦 8 字节对齐的数据被放到栈上,编译器就会按 ABI 假设它位于合适的双字边界。
因此,规则的核心不是“所有 double 都一定有问题”,而是:
当 8 字节对齐的数据实际位于栈上时,入口 SP 的 8 字节对齐状态会影响后续栈上参数和局部对象的位置判断。
2.2 变参函数更容易暴露问题
变参函数是典型场景。例如:
1 |
|
变参函数和普通函数不同。AAPCS32 规定,变参函数始终按 base standard 进行参数组织。即使工程使用硬浮点 ABI,变参函数也不会像普通非变参浮点函数那样简单依赖浮点寄存器传参。
当入口 SP 未满足 8 字节对齐时,编译器在编译期对“是否需要插入填充字”的判断,可能与 va_arg 在运行期对实际地址对齐状态的判断不一致。结果不是“所有 double 都错”,而是在满足特定条件时,va_arg 可能从错误地址取 8 字节数据。
2.3 操作系统、RTOS 和运行时需要提供正确入口
Arm 的 ABI advisory note 对这点说得更直接:操作系统、RTOS 和运行时环境在调用符合 AAPCS 的代码前,应保证 SP 是 8 字节对齐的。也就是说,不能只依赖 C 编译器生成正确的函数序言;启动代码、链接脚本、异常入口、上下文切换代码同样属于 ABI 正确性的组成部分。
三、Cortex-M 上的 MSP 和 PSP
Cortex-M 有两个常见栈指针:
| 名称 | 中文含义 | 常见用途 |
|---|---|---|
| MSP | 主栈指针 | 复位后启动、早期初始化、异常和中断处理 |
| PSP | 进程栈指针 | RTOS 任务或线程运行时常用 |
复位后,处理器通常从向量表第 0 项取初始栈顶并装入 MSP。启动代码执行期间,清零 BSS、复制 DATA、调用系统初始化和 main() 等流程,往往还在使用 MSP。RTOS 调度器启动后,普通任务常切换到 PSP,每个任务使用自己的栈空间。
因此,链接脚本里的初始栈符号通常主要影响 MSP;任务栈是否安全,还要看任务栈分配和上下文初始化函数是否在把地址装入 PSP 前做了 8 字节对齐。
四、不遵守 8 字节对齐可能出现什么后果
SP 未满足 8 字节对齐时,不一定立刻 HardFault。很多场景下,程序仍然可以继续运行,但数据已经不可靠。
常见现象包括:
va_arg(ap, double)读取到异常值;printf、sprintf、日志函数中的浮点或 64 位变参输出异常;- 多个变参后续参数错位;
- 64 位局部对象或栈上传参在特定编译选项下表现异常;
- 少数场景下进入 UsageFault、BusFault、HardFault;
- 手写汇编、异常入口、上下文切换代码与 C 代码混用时出现偶发跑飞。
这些现象具有隐蔽性。原因是 4 字节对齐已经满足很多 32 位访问要求,简单整数参数、普通函数调用、短路径启动代码可能完全看不出问题。只有当代码路径涉及 8 字节对齐的栈上数据,或者变参读取逻辑依赖实际对齐状态时,问题才更容易出现。
五、为什么不是所有代码都会受影响
5.1 参数可能没有进入栈
AAPCS32 base standard 优先使用 r0 到 r3 传递参数。参数较少时,很多值不会进入栈。没有进入栈,就不会触发“栈上 8 字节对齐数据”的问题。
5.2 非变参浮点参数可能走浮点寄存器
在带 FPU 且启用硬浮点 ABI 的工程中,普通非变参函数的浮点参数可能使用浮点寄存器传递。此时不能简单推断“函数里出现 double 就会错”。
但变参函数不同。AAPCS32 明确规定变参函数按 base standard 处理,且变参过程不使用 VFP 协处理器寄存器候选参数规则。因此,printf 类接口、日志封装、va_arg 读取 double 或 64 位数据,更容易成为触发点。
5.3 RTOS 任务栈通常还有一次对齐
RTOS 启动后,任务栈通常来自堆或静态数组,但“来自堆”不等于“运行时使用堆作为栈”。堆只是分配内存的来源;任务真正运行时使用的是一段 stack buffer。
成熟的 Cortex-M RTOS 移植层通常会在任务栈初始化时,把将要装入 PSP 的栈顶调整到 8 字节边界。这样一来,普通任务栈的风险会低很多。早期 MSP、异常入口、自定义汇编入口、手写上下文切换代码,仍需要单独确认。
六、怎么查看工程是否遵守
6.1 先找最终装入 SP 的符号
不同工具链写法不同,常见符号包括:
| 工具链 | 常见符号或区域 |
|---|---|
| GCC/ld | _estack、_sstack、.stack、._user_heap_stack |
| Keil/ARMCC/Armclang | __initial_sp、Stack_Mem、startup 汇编中的 AREA STACK |
| IAR | CSTACK、__ICFEDIT_size_cstack__ |
| RTOS 任务栈 | 任务栈初始化函数中最终写入 PSP 的地址 |
仅看源码中的 ALIGN(4) 或 ALIGN(8) 还不够。最终是否满足要求,要看 map 文件里的实际符号地址:
1 | _estack = 0x20020000 |
判断方式很简单:
1 | 地址 % 8 == 0 合格 |
6.2 GCC/ld:关注 _estack 的来源
一种存在风险的写法是把 .stack 放在 .data 后面,并且只做 4 字节对齐:
1 | .stack : |
这类写法只能保证 _estack % 4 == 0,不能保证 _estack % 8 == 0。如果 .data 结束地址刚好是 4 mod 8,最终 _estack 也可能是 4 mod 8。
另一种常见写法是让 _estack 直接等于 RAM 末尾:
1 | _estack = ORIGIN(RAM) + LENGTH(RAM); |
只要 ORIGIN(RAM) 和 LENGTH(RAM) 都是 8 字节对齐的,_estack 通常天然满足 8 字节对齐。
6.3 Keil:不能只看 .sct
Keil 工程的 scatter 文件可能只定义 ROM 和 RAM 区域,不直接定义栈。例如:
1 | LR_IROM1 0x08000000 0x00100000 { |
这种情况下,栈可能在 startup 汇编里定义。常见写法是:
1 | AREA STACK, NOINIT, READWRITE, ALIGN=3 |
ALIGN=3 表示按 2^3 对齐,也就是 8 字节对齐。因此,Keil 工程要同时查看 startup 文件和 map 文件,确认 __initial_sp 的最终地址。
6.4 IAR:关注 CSTACK
IAR .icf 中常见写法是:
1 | define block CSTACK with alignment = 8, size = __ICFEDIT_size_cstack__ { }; |
如果 CSTACK 显式设置 alignment = 8,并且 RAM 区域末尾也满足 8 字节对齐,则初始栈通常没有同类问题。
七、怎么修改
7.1 修 GCC/ld 中的 .stack 边界
如果初始栈来自 .stack 段,并且当前只使用 ALIGN(4),可以把栈段边界改为 8 字节对齐:
1 | .stack : |
如果 STACK_SIZE 本身是 8 的倍数,那么 _sstack 8 对齐后,_estack 通常也能保持 8 对齐。末尾继续保留 ALIGN(8) 更直观,也能抵抗后续栈大小改动带来的风险。
7.2 把初始栈顶放在 8 对齐的 RAM 末尾
另一种写法是直接使用 RAM 末尾作为初始栈顶:
1 | _estack = ORIGIN(RAM) + LENGTH(RAM); |
使用这种写法时,需要确认 RAM 起始地址和长度都按 8 字节对齐。STM32 这类常见 SRAM 区域通常满足这一点,但外部 RAM、分区 RAM、自定义 bootloader 偏移场景仍需检查。
7.3 修任务栈初始化
任务栈不一定由链接脚本中的初始栈符号决定。如果任务栈来自堆或静态数组,关键是把最终写入 PSP 的栈顶地址对齐到 8 字节边界。
常见原则是:
1 | stack_top = ALIGN_DOWN(stack_top, 8); |
或等价处理。具体使用向上还是向下对齐,取决于栈增长方向和移植层约定。Cortex-M 常见任务栈向低地址增长,因此通常对栈顶做向下对齐。
八、为什么修改栈边界就能解决问题
初始 SP 的值来自某个地址来源:向量表、链接符号、startup 汇编标签或 RTOS 栈初始化结果。C 编译器在生成函数调用和栈上对象访问代码时,会假设进入符合 AAPCS32 的函数时 SP 已经满足 8 字节对齐。也就是说,编译器不会在每个普通函数入口都无条件修复错误的 SP。
因此,正确做法是在源头修复:
1 | 链接脚本 / startup / 栈初始化函数 |
如果只修改某个使用 double 的函数,问题仍然可能在其他变参函数、日志函数、异常入口或库函数中出现。修栈边界不是为了某一个类型服务,而是为了让整个调用链恢复 ABI 前提。
九、一个更准确的理解方式
这类问题可以按以下链路理解:
1 | AAPCS32 要求函数公开调用边界 SP 8 字节对齐 |
所以,现象上可能是 double、uint64_t 或日志格式化函数异常;根因上则是进入 C ABI 边界时 SP 没有满足 8 字节对齐。
十、收束
Cortex-M 栈 8 字节对齐问题,本质上不是某个 RTOS、某个芯片或某个 double 变量的局部问题,而是 ABI、链接脚本、启动代码和运行时上下文之间的配合问题。
Thumb 指令集解决“CPU 如何执行指令”;AAPCS32 解决“函数之间如何互相调用”。只要工程使用符合 Arm EABI 的 C/C++ 工具链,函数公开调用边界处的 SP 8 字节对齐要求就需要被满足。
排查时,重点不在于搜索所有 double,而在于确认这些地址是否 8 字节对齐:
1 | 初始 MSP |
满足这些前提后,double、64 位整数、变参函数和库函数才能处在编译器预期的 ABI 环境中。栈对齐是一条底层约束;一旦在源头破坏,表现出来的可能只是某个上层函数偶发读错。
参考资料
- Arm, Procedure Call Standard for the Arm Architecture (AAPCS32): https://github.com/ARM-software/abi-aa/blob/main/aapcs32/aapcs32.rst
- Arm, ABI Advisory Note – SP must be 8-byte aligned on entry to AAPCS-conforming functions: https://github.com/ARM-software/abi-aa/blob/main/advnote132/advnote132.rst
- GNU Binutils, LD Builtin Functions / ALIGN: https://sourceware.org/binutils/docs/ld/Builtin-Functions.html
- FreeRTOS Kernel, portable/GCC/ARM_CM4F/portmacro.h: https://github.com/FreeRTOS/FreeRTOS-Kernel/blob/main/portable/GCC/ARM_CM4F/portmacro.h
















