2. 嵌入式启动流程:从复位到C语言

做嵌入式开发,尤其是搞Bootloader,第一件事就是搞清楚芯片上电后到底干了什么。很多人一上来就写代码,结果板子不跑,连问题出在哪都不知道。我刚开始带项目时也犯过这种错——焊好板子,烧完程序,按复位键,啥反应没有。后来老老实实把启动流程捋了一遍,才发现是Flash映射配错了。

说白了,启动流程就是CPU从“出生”到“会跑C语言”的全过程。今天咱们就把这四步拆开揉碎了讲清楚。

核心要点: 嵌入式启动 = 复位向量定位 → ROM/Flash映射 → 汇编第一阶段 → C语言第二阶段

2.1 CPU复位向量:芯片的第一口气

CPU上电或复位后,第一件事是去固定地址拿指令。这个固定地址就叫复位向量。不同的架构,复位向量位置不一样:

架构 复位向量地址 说明
ARM Cortex-M 0x00000000(向量表基址) 栈顶指针 + 复位中断入口
ARM Cortex-A 0x00000000 或 0xFFFF0000 可配置,通常由硬件决定
RISC-V 0x00000000 或 0x80000000 由mtvec寄存器指定
x86 0xFFFFFFF0 复位后进入实模式

我做过一个Cortex-M3的项目,当时芯片手册上写复位向量在0x00000000,结果板子死活不跑。查了两天才发现——芯片内部BootROM默认映射到了0x00000000,用户Flash被挤到了0x08000000。嗯,这里要注意:复位向量指向的地址,不一定就是Flash的物理地址,中间可能有一层映射。

我的习惯: 拿到新芯片,第一件事就是看复位向量地址和芯片默认的存储器映射图。别急着写代码,先把“CPU上电后去哪找第一条指令”搞清楚。

2.2 ROM代码与Flash映射:谁在管启动

现代MCU内部通常有两块只读存储:一块是BootROM(出厂固化的启动代码),另一块是用户Flash(你写的程序)。

芯片上电后,CPU先执行BootROM里的代码。这段代码是芯片厂商写死的,负责:

  • 检查启动引脚电平(决定从Flash、串口还是USB启动)
  • 初始化基本时钟和内存控制器
  • 把用户Flash映射到CPU能访问的地址空间
  • 跳转到用户程序的复位向量

举个例子,STM32的System Memory(BootROM)里有一段出厂固件,支持通过USART1下载程序。你拉高BOOT0引脚,上电后CPU就跑这段ROM代码,等着你通过串口发数据。这就是所谓的“系统存储器启动模式”。

Flash映射这块,我踩过一个坑。当时做一款国产MCU,芯片手册说Flash基址是0x08000000,但我把程序烧进去后,CPU死活不跳转。后来用调试器一看——BootROM把Flash映射到了0x10000000,0x08000000那个地址根本没人管。所以啊,别信手册上的“典型映射”,一定要看芯片复位后的实际映射表。

我曾经遇到: 某款芯片的BootROM里有一段代码会检查Flash前4个字节是否为有效栈顶指针,如果不是,就卡死在BootROM里不跳转。这意味着你的程序必须从正确的栈顶指针开始布局,否则连启动都启动不了。

2.3 第一阶段启动(汇编):给C语言铺路

从BootROM跳转到用户程序后,CPU开始执行你的代码。但这时候还不能跑C语言——因为C语言需要栈、需要全局变量初始化、需要BSS段清零。这些事,得用汇编干。

第一阶段启动代码通常放在一个叫 startup_xxx.s 的文件里。它的核心任务就这几条:

  1. 设置栈指针(SP)——C语言调用函数全靠栈,没栈就崩
  2. 初始化异常向量表——至少把复位、NMI、HardFault这几个填上
  3. 清零BSS段——未初始化的全局变量必须归零
  4. 拷贝数据段——把Flash里的初始值搬到RAM里
  5. 使能FPU/MPU/MMU(如果硬件支持)
  6. 跳转到main函数——终于可以写C了

我贴一段典型的Cortex-M启动汇编,你感受一下:

; 文件: startup_stm32f4xx.s
; 复位中断入口
Reset_Handler   PROC
                EXPORT  Reset_Handler

                ; 1. 设置栈指针
                LDR     SP, =_estack

                ; 2. 清零BSS段
                LDR     R0, =_sbss
                LDR     R1, =_ebss
                MOVS    R2, #0
bss_loop        CMP     R0, R1
                ITT     LT
                STRLT   R2, [R0], #4
                BLT     bss_loop

                ; 3. 拷贝数据段
                LDR     R0, =_sdata
                LDR     R1, =_edata
                LDR     R2, =_sidata
data_loop       CMP     R0, R1
                ITT     LT
                LDRLT   R3, [R2], #4
                STRLT   R3, [R0], #4
                BLT     data_loop

                ; 4. 跳转到C语言的main
                BL      main
                B       .
                ENDP

这段代码看着简单,但每行都有讲究。比如 LDR SP, =_estack 里的 _estack 是链接脚本里定义的栈顶地址。如果这个地址算错了,函数调用一深就栈溢出,程序直接跑飞。

我的经验: 调试启动阶段时,别急着看C代码。先用调试器单步跑汇编,确认SP设置正确、BSS清零成功、数据拷贝完整。这三步只要错一步,后面全白搭。

2.4 第二阶段启动(C语言):硬件初始化

进入C语言后,Bootloader的第二阶段启动正式开始。这时候你有了栈、有了全局变量,可以放心地调用函数了。但别高兴太早——硬件还没初始化呢。

第二阶段启动通常做这些事:

  • 时钟系统初始化——配置PLL、分频器,把CPU频率从默认的慢速时钟提上来
  • 外设时钟使能——GPIO、UART、SPI、DMA等模块的时钟门控打开
  • 内存控制器初始化——如果外挂了SDRAM或NOR Flash,得配时序
  • 中断控制器初始化——NVIC(Cortex-M)或GIC(Cortex-A)的基本配置
  • 板级外设初始化——LED、按键、调试串口等

我习惯把第二阶段启动写成一个 SystemInit() 函数,在main函数的第一行调用。这样结构清晰,也方便移植。

// 文件: main.c
void SystemInit(void)
{
    // 1. 配置系统时钟为168MHz(以STM32F4为例)
    RCC_PLLConfig(RCC_PLLSource_HSE, 8, 336, 2, 7);
    RCC_PLLCmd(ENABLE);
    while(RCC_GetFlagStatus(RCC_FLAG_PLLRDY) == RESET);

    // 2. 使能GPIO时钟
    RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOA, ENABLE);
    RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOB, ENABLE);

    // 3. 配置调试串口(USART1)
    USART_InitTypeDef usart;
    usart.USART_BaudRate = 115200;
    usart.USART_WordLength = USART_WordLength_8b;
    usart.USART_StopBits = USART_StopBits_1;
    usart.USART_Parity = USART_Parity_No;
    USART_Init(USART1, &usart);
    USART_Cmd(USART1, ENABLE);
}

int main(void)
{
    SystemInit();

    // 打印启动信息
    printf("Bootloader started...\n");

    // 进入Bootloader主循环
    Bootloader_MainLoop();
}

这里有个容易忽略的点:printf依赖的串口必须在SystemInit里配好。我见过有人把串口初始化放在main函数里,结果printf在SystemInit之前就被调用了——输出全是乱码。

避坑指南: 第二阶段启动时,中断默认是关闭的。如果你在SystemInit里使能了某个外设中断,记得先清掉挂起标志位再开全局中断。否则一开中断就进异常,你连错在哪都查不到。

2.5 整体启动流程图

说了这么多,咱们用一张图把整个流程串起来。这张图我画了好几个版本,最后觉得这个最清楚:

嵌入式启动流程总图 嵌入式启动流程总图 1. CPU复位/上电 2. BootROM执行 检查启动引脚 → 映射Flash → 跳转 3. 第一阶段启动(汇编) 设SP → 清BSS → 拷数据 → 使能FPU → 跳转main 4. 第二阶段启动(C语言) SystemInit() → 时钟/外设/中断初始化 → Bootloader主循环 5. Bootloader运行 注意:每个阶段都可能卡住 调试时逐阶段确认 常见问题 • 复位向量地址配错 • Flash映射与预期不符 • 栈指针设置错误 • BSS段未清零 • 时钟配置导致死锁 • 中断标志位未清除

这张图我建议你存下来。每次调试启动问题,就对照着看——卡在哪个阶段,就去查哪个阶段的代码。我这些年修过的启动Bug,90%都能在这张图上找到对应位置。

总结一下: 嵌入式启动不是玄学,是明明白白的五步走。复位向量定方向,BootROM做映射,汇编铺路,C语言初始化,最后才轮到你的Bootloader主逻辑。每一步都有固定的代码和固定的检查点。把这些搞透了,以后换任何芯片都能快速上手。