2. 嵌入式启动流程:从复位到C语言
做嵌入式开发,尤其是搞Bootloader,第一件事就是搞清楚芯片上电后到底干了什么。很多人一上来就写代码,结果板子不跑,连问题出在哪都不知道。我刚开始带项目时也犯过这种错——焊好板子,烧完程序,按复位键,啥反应没有。后来老老实实把启动流程捋了一遍,才发现是Flash映射配错了。
说白了,启动流程就是CPU从“出生”到“会跑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的物理地址,中间可能有一层映射。
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那个地址根本没人管。所以啊,别信手册上的“典型映射”,一定要看芯片复位后的实际映射表。
2.3 第一阶段启动(汇编):给C语言铺路
从BootROM跳转到用户程序后,CPU开始执行你的代码。但这时候还不能跑C语言——因为C语言需要栈、需要全局变量初始化、需要BSS段清零。这些事,得用汇编干。
第一阶段启动代码通常放在一个叫 startup_xxx.s 的文件里。它的核心任务就这几条:
- 设置栈指针(SP)——C语言调用函数全靠栈,没栈就崩
- 初始化异常向量表——至少把复位、NMI、HardFault这几个填上
- 清零BSS段——未初始化的全局变量必须归零
- 拷贝数据段——把Flash里的初始值搬到RAM里
- 使能FPU/MPU/MMU(如果硬件支持)
- 跳转到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 是链接脚本里定义的栈顶地址。如果这个地址算错了,函数调用一深就栈溢出,程序直接跑飞。
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之前就被调用了——输出全是乱码。
2.5 整体启动流程图
说了这么多,咱们用一张图把整个流程串起来。这张图我画了好几个版本,最后觉得这个最清楚:
这张图我建议你存下来。每次调试启动问题,就对照着看——卡在哪个阶段,就去查哪个阶段的代码。我这些年修过的启动Bug,90%都能在这张图上找到对应位置。