第二章 嵌入式系统启动流程:MCU启动流程、ROM与RAM布局、向量表与中断处理

各位同学,咱们今天聊聊嵌入式系统启动这件事。说实话,我见过不少工程师,写了好几年代码,对启动流程还是一知半解。结果呢?板子跑不起来,连问题出在哪都找不到。这章内容,说白了就是帮你把底层的「地基」打牢。

2.1 MCU启动流程:从复位到main()

MCU上电后,到底干了些什么?我习惯把启动流程拆成三个阶段:硬件初始化、引导加载、应用启动。

2.1.1 硬件初始化阶段

复位信号释放后,MCU内部硬件会自动完成几件事:

  • 时钟系统初始化:内部振荡器开始工作,通常先跑一个低速时钟(比如8MHz),等后面软件再切到高速时钟。
  • 关键外设复位:看门狗、中断控制器、存储器接口等核心模块被复位到默认状态。
  • 程序计数器(PC)加载:从复位向量地址(通常是0x00000000或0x08000000)读取栈指针和复位向量。

嗯,这里要注意:不同MCU的复位向量地址可能不一样。我在做NXP S32K项目时,就遇到过因为复位向量配置错误,导致芯片一直重启的坑。

2.1.2 引导加载阶段

硬件初始化完成后,CPU开始执行复位向量指向的代码。这段代码通常由启动文件(startup.s)完成:

; 典型的ARM Cortex-M启动代码片段
Reset_Handler   PROC
                EXPORT  Reset_Handler
                LDR     R0, =__main
                BX      R0
                ENDP

这段代码做了三件关键事:

  1. 设置栈指针(SP)—— 从向量表第一个字读取
  2. 初始化全局变量(BSS段清零、DATA段从ROM拷贝到RAM)
  3. 调用系统初始化函数(SystemInit),配置时钟、PLL等

我个人习惯在SystemInit里就把看门狗关掉,免得后面初始化时间太长导致复位。你想想看,如果看门狗没关,调试时动不动就复位,那得多崩溃。

2.1.3 应用启动阶段

最后,跳转到main()函数。但注意,main()之前其实还有一层——C运行时库的初始化。我记得有一次调试,发现全局变量值不对,查了半天,原来是__main里的变量初始化部分被优化掉了。

2.2 ROM与RAM布局:代码和数据怎么放

搞懂启动流程后,咱们得看看代码和数据在存储器里是怎么安排的。说白了,就是ROM(Flash)和RAM的分工问题。

2.2.1 典型存储器映射

以STM32F4为例,它的存储器映射大致如下:

地址范围 存储器类型 用途
0x0800 0000 - 0x080F FFFF Flash (ROM) 代码、常量、初始化的全局变量
0x2000 0000 - 0x2001 FFFF SRAM (RAM) 栈、堆、未初始化全局变量
0x4000 0000 - 0x4002 3FFF 外设寄存器 GPIO、UART、TIM等外设

这里有个关键点:向量表必须放在Flash的起始地址。为什么?因为复位后CPU就是去那里找入口的。

2.2.2 链接脚本中的段布局

实际工程中,我们通过链接脚本来控制布局。我拿一个典型的GCC链接脚本举例:

MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 1024K
    RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}

SECTIONS
{
    .text : {
        *(.isr_vector)    /* 向量表 */
        *(.text*)         /* 代码 */
        *(.rodata*)       /* 只读数据 */
    } > FLASH

    .data : {
        _sdata = .;
        *(.data*)
        _edata = .;
    } > RAM AT > FLASH   /* 初始值存在Flash,运行时拷贝到RAM */

    .bss : {
        _sbss = .;
        *(.bss*)
        _ebss = .;
    } > RAM
}

看到没?.data段有个AT > FLASH的标记。这意味着:变量的初始值存在Flash里,但运行时会被拷贝到RAM中。我曾经犯过一个错误:忘记在启动代码里做这个拷贝动作,结果全局变量全是0,程序跑得莫名其妙。

2.2.3 避坑指南:RAM不够用怎么办?

我曾经在一个ADAS项目中,因为图像处理缓冲区太大,RAM爆了。后来用了两种方法解决:

  • 压缩常量数据:把查表用的数组放到Flash里,用const关键字声明
  • 动态分配:只在需要时才申请大块内存,用完就释放

但注意,ADAS系统对实时性要求高,动态分配要慎用,最好在初始化阶段一次性分配好。

2.3 向量表与中断处理:CPU如何响应事件

向量表,说白了就是一张「事件处理函数地址表」。CPU遇到中断或异常时,就查这张表,找到对应的处理函数去执行。

2.3.1 向量表的结构

以ARM Cortex-M为例,向量表的前16个是系统异常,后面是外设中断:

偏移地址 异常类型 优先级 说明
0x00 栈顶地址 - 复位后SP的初始值
0x04 复位 -3 (最高) 系统上电或复位后执行
0x08 NMI -2 不可屏蔽中断
0x0C 硬错误 -1 未处理的异常都会进这里
0x10 - 0x3C 其他系统异常 可配置 MemManage、BusFault等
0x40 开始 外设中断 可配置 UART、TIM、DMA等

嗯,这里有个细节:向量表的第一项不是函数地址,而是栈顶地址。为什么?因为复位后CPU要立刻有一个可用的栈,才能调用C函数。我见过有人把向量表第一项写成了函数地址,结果复位后SP指向了奇怪的地方,程序直接跑飞。

2.3.2 中断处理流程

当中断发生时,CPU自动完成以下步骤:

  1. 压栈:自动把R0-R3、R12、LR、PC、xPSR这8个寄存器压入当前栈
  2. 查表:根据中断号,从向量表中取出对应的中断服务函数地址
  3. 跳转:将PC设置为该地址,开始执行中断服务函数
  4. 出栈:中断服务函数执行完毕后,执行BX LR指令,CPU自动出栈恢复现场

我个人习惯在中断服务函数里尽量少做事。为什么?因为中断是抢占式的,如果处理时间太长,会影响到其他实时任务。我在ADAS项目中,通常只在中断里设置一个标志位,真正的处理放到主循环或RTOS任务里。

2.3.3 向量表重定向

在Bootloader设计中,有一个非常重要的概念——向量表重定向。当你的应用代码不在Flash起始地址运行时,需要告诉CPU:向量表搬家了!

// 以STM32为例,重定向向量表到0x08010000
#define APPLICATION_ADDRESS 0x08010000

void JumpToApplication(void)
{
    // 关闭所有中断
    __disable_irq();
    
    // 设置向量表偏移
    SCB->VTOR = APPLICATION_ADDRESS;
    
    // 读取应用代码的栈指针和复位向量
    uint32_t stack_ptr = *(uint32_t*)APPLICATION_ADDRESS;
    uint32_t reset_addr = *(uint32_t*)(APPLICATION_ADDRESS + 4);
    
    // 设置主栈指针
    __set_MSP(stack_ptr);
    
    // 跳转到应用代码
    void (*app_entry)(void) = (void (*)(void))reset_addr;
    app_entry();
}

提示:跳转前一定要关闭所有中断,否则应用代码的向量表还没准备好,中断来了就找不到处理函数,直接进硬错误。我曾经因为这个原因,调试了整整两天才找到问题。

2.3.4 中断优先级与嵌套

ARM Cortex-M支持中断优先级嵌套。高优先级的中断可以打断低优先级的中断。但要注意:

  • 优先级分组:可以配置抢占优先级和子优先级,抢占优先级决定能否嵌套
  • 临界区保护:在访问共享资源时,需要关中断或使用互斥锁
  • 中断延迟:嵌套越深,中断延迟越大,实时性越差

我建议在ADAS系统中,把时间关键的中断(比如CAN通信、定时器)设为最高优先级,非关键的中断(比如按键扫描)设为低优先级。这样既能保证实时性,又不会让系统过于复杂。

2.4 本章小结

好了,这一章的内容就这些。咱们从MCU启动流程讲到了存储器布局,再到向量表和中断处理。说白了,这些都是嵌入式系统最底层的知识,但也是最容易出问题的地方。

我个人觉得,理解这些内容最好的方法就是动手调试。拿一块开发板,单步执行启动代码,看看寄存器是怎么变化的。相信我,这样学一遍,比看十遍书都管用。

下一章,咱们聊聊Bootloader的具体设计,包括分区策略、固件校验和升级流程。到时候我会分享一些实际项目中的踩坑经验,敬请期待。