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

这段代码的逻辑很清楚:

  1. 修改 CPSR 寄存器,切换到目标模式
  2. 把该模式的 SP 指向预分配的栈顶地址
  3. 重复操作,直到所有模式都设置完毕
  4. 最后切回 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 图,把整个流程串起来了。你可以对照着代码看,会更清楚。

启动代码栈初始化流程 启动代码栈初始化流程 系统上电 / 复位 切换到 SVC 模式 切换到 IRQ 模式 切换到 FIQ 模式 切回 SVC 模式 → 跳转 main() 关键操作 1. 修改 CPSR 模式位 2. 加载 SP 到栈顶地址 3. 每个模式独立操作 4. 最后统一切回 SVC 5. 栈为满递减模式 图:启动代码中 SVC / IRQ / FIQ 栈初始化流程

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,一气呵成。

记住一句话:栈空间独立,系统才稳定。下次你的嵌入式系统出现随机死机,不妨先检查一下栈指针初始化对不对。