3. 内存管理核心概念
嵌入式系统上电后,第一件事就是跑启动代码。而启动代码里,栈指针初始化是重中之重。为什么?因为 C 语言环境需要栈才能工作——局部变量、函数调用、中断现场保存,全都依赖栈。
我个人习惯把栈初始化看作「给 CPU 搭好舞台」。舞台没搭好,演员(C 代码)根本没法上场。今天我们就来拆解一下,汇编级启动代码里,如何为 SVC、IRQ、FIQ 这些异常模式分别准备独立的栈空间。
3.1 为什么需要多个栈?
ARM 处理器有七种工作模式,每种模式都有自己的栈指针寄存器(SP)。你想想看,如果所有模式共用同一个栈,会发生什么?
- IRQ 中断来了,现场压栈,SP 往下走
- FIQ 快速中断又来了,继续压栈
- SVC 系统调用再插一脚,栈可能就溢出了
我在项目中遇到过这种情况:一个产品在跑压力测试时随机死机,查了三天,最后发现是 IRQ 和 SVC 共用栈空间,中断嵌套把返回地址给覆盖了。从那以后,我对独立栈空间这件事就特别较真。
核心原则:每种异常模式拥有独立的栈区域,互不干扰。这是嵌入式系统稳定性的第一道防线。
3.2 栈空间分配策略
栈空间分配,说白了就是「给每种模式划一块内存」。这块内存从哪里来?通常是链接脚本里定义的全局数组,或者直接在汇编里预留一段空间。
我一般这样分配:
| 异常模式 | 栈大小(典型值) | 用途说明 |
|---|---|---|
| SVC(管理模式) | 4KB | 主程序运行、系统调用 |
| IRQ(普通中断) | 1KB | 外设中断处理 |
| FIQ(快速中断) | 512B | 高速数据搬运、DMA 辅助 |
| ABT(中止模式) | 256B | 数据/指令中止处理 |
| UND(未定义模式) | 256B | 未定义指令陷阱 |
我的经验:IRQ 栈别给太少。有些工程师觉得中断处理很快,给 256B 就够了。但一旦有中断嵌套,或者中断里调用了 printf 这类吃栈的函数,立马翻车。我一般至少给 1KB,保险起见 2KB。
3.3 汇编级实现:一步一步来
好,我们直接看代码。以 ARM Cortex-M 系列为例(虽然它简化了异常模型,但原理相通),或者更经典的 ARM7/ARM9 架构。下面这段汇编,就是启动代码里初始化栈指针的典型写法。
; 定义栈空间起始地址(由链接脚本提供)
SVC_STACK_TOP EQU 0x20001000
IRQ_STACK_TOP EQU 0x20000C00
FIQ_STACK_TOP EQU 0x20000A00
; 启动入口
Reset_Handler:
; 1. 切换到 SVC 模式,设置 SVC 栈
MRS R0, CPSR
BIC R0, R0, #0x1F ; 清除模式位
ORR R0, R0, #0x13 ; 设置为 SVC 模式
MSR CPSR_c, R0
LDR SP, =SVC_STACK_TOP
; 2. 切换到 IRQ 模式,设置 IRQ 栈
MRS R0, CPSR
BIC R0, R0, #0x1F
ORR R0, R0, #0x12 ; IRQ 模式
MSR CPSR_c, R0
LDR SP, =IRQ_STACK_TOP
; 3. 切换到 FIQ 模式,设置 FIQ 栈
MRS R0, CPSR
BIC R0, R0, #0x1F
ORR R0, R0, #0x11 ; FIQ 模式
MSR CPSR_c, R0
LDR SP, =FIQ_STACK_TOP
; 4. 切回 SVC 模式,准备进入 C 语言 main
MRS R0, CPSR
BIC R0, R0, #0x1F
ORR R0, R0, #0x13
MSR CPSR_c, R0
; 跳转到 C 语言入口
BL main
这段代码的逻辑很清楚:
- 修改 CPSR 寄存器,切换到目标模式
- 把该模式的 SP 指向预分配的栈顶地址
- 重复操作,直到所有模式都设置完毕
- 最后切回 SVC 模式,进入 C 语言世界
注意:切换模式时,一定要先清除 CPSR 的模式位(低 5 位),再设置新模式。直接 ORR 可能会残留旧模式的位,导致模式切换失败。我曾经见过一个同事因为这个 bug 调了一整天。
3.4 栈生长方向与 SP 初始值
ARM 处理器的栈是满递减的。什么意思?
- SP 初始指向栈区的最高地址
- 压栈时 SP 先减,再存数据
- 出栈时先取数据,SP 再加
所以代码里写的 LDR SP, =SVC_STACK_TOP,这个 SVC_STACK_TOP 必须是栈区的末尾地址 + 1。比如你分配了 4KB 的栈空间,起始地址是 0x20000000,那么栈顶就是 0x20001000。
嗯,这里要注意:有些工程师会把栈顶算错,少算 4 个字节,结果第一次压栈就把数据写到别人的地盘去了。
3.5 一张图看懂栈初始化流程
下面这张 SVG 图,把整个流程串起来了。你可以对照着代码看,会更清楚。
3.6 避坑指南:我踩过的几个坑
做嵌入式这么多年,栈初始化这块的坑,我基本都踩了一遍。分享几个典型的:
- 栈大小估算不足:我曾经在一个项目中,IRQ 栈只给了 256 字节。结果中断处理函数里调了一个库函数,栈溢出,程序跑飞。后来用
__builtin_frame_address一查,发现 IRQ 栈实际用了 700 多字节。嗯,从那以后我都是先给大一点,稳定后再优化。 - 栈顶地址算错:满递减栈的 SP 初始值应该是栈区最高地址 + 1。我见过有人直接拿栈区起始地址当 SP,结果第一次压栈就把数据写到栈外面去了。
- 忘记关中断:在设置栈指针的过程中,如果中断开着,IRQ 或 FIQ 可能在你设置到一半时触发,而此时栈还没准备好。我习惯在启动代码最开头就关掉所有中断,等所有模式栈都设置完毕,再统一开启。
小技巧:调试时可以在每个栈区域的末尾填充一个特殊标记(比如 0xDEADBEEF),然后在运行时定期检查这些标记是否被覆盖。如果被覆盖,说明栈溢出了。这个方法帮我抓到了好几个隐蔽的栈溢出 bug。
3.7 总结一下
栈指针初始化,说白了就是给 CPU 的每种工作模式准备一个独立的「工作台」。这件事看起来简单,但做不好,整个系统的稳定性就无从谈起。
我个人习惯在启动代码里,把 SVC、IRQ、FIQ 这三个主要模式的栈先配好,ABT 和 UND 模式也顺手配了,反正不费事。然后关中断、配栈、开中断、跳 main,一气呵成。
记住一句话:栈空间独立,系统才稳定。下次你的嵌入式系统出现随机死机,不妨先检查一下栈指针初始化对不对。