2. 嵌入式系统启动流程:从复位向量到操作系统启动

嵌入式系统上电那一刻,芯片内部到底发生了什么?

很多人觉得启动就是“跑个main函数”,其实没那么简单。从复位引脚拉高,到操作系统开始调度,中间要经历CPU模式切换、内存初始化、设备树传递等一系列步骤。任何一个环节出错,系统都起不来。

我刚开始做Bootloader时,就吃过这个亏——以为只要把代码烧进去就行,结果板子死活不跑。后来才发现,是复位向量表没放对位置。嗯,咱们今天就把这整个流程捋清楚。

2.1 复位向量:系统的第一行代码

CPU上电后,PC指针会被硬件强制指向一个固定地址。这个地址就是复位向量。

对于ARM Cortex-M系列,复位向量在0x00000000;对于Cortex-A系列,通常在0x00000000或0xFFFF0000;RISC-V则根据具体实现,一般在0x80000000附近。

核心要点:复位向量里存的不是代码,而是栈指针和复位入口地址。CPU先加载SP,再跳转到复位入口执行。

举个例子,ARM Cortex-M的向量表结构是这样的:

; 向量表起始地址:0x00000000
0x00000000: 0x20010000    ; 初始栈指针 (SP)
0x00000004: 0x08000145    ; 复位入口地址 (PC)
0x00000008: 0x08000149    ; NMI异常入口
0x0000000C: 0x0800014D    ; HardFault异常入口
; ... 后续中断向量

注意看,复位入口地址0x08000145,最低位是1。这是因为ARM Cortex-M使用Thumb指令集,地址最低位必须为1表示Thumb状态。如果写成0x08000144,CPU会直接进HardFault。

我曾经踩过的坑:有一次移植Bootloader到新板子,复位向量表里入口地址忘了加1。结果芯片上电后直接卡死,调试器都连不上。折腾了半天才发现是这个问题。所以,向量表地址对齐和Thumb位检查,一定要放在自检清单里。

2.2 CPU模式切换:从特权级到用户态

复位后,CPU默认运行在最高特权级。ARM里叫SVC模式(Supervisor Mode),RISC-V里叫M模式(Machine Mode)。

为什么要分这么多模式?说白了,是为了安全。操作系统内核跑在特权级,应用程序跑在用户级。应用程序崩了,不能把整个系统拖下水。

典型的模式切换流程如下:

  1. 复位后:CPU处于SVC/M模式,可以访问所有寄存器、内存和外设。
  2. Bootloader执行:在SVC模式下完成时钟、内存、外设初始化。
  3. 跳转前:切换到SVC模式(如果还没切换),准备跳转到操作系统。
  4. 操作系统接管:内核初始化完成后,通过异常返回切换到用户模式。

我个人习惯在Bootloader里显式设置CPSR寄存器,确保模式正确:

@ ARM汇编:切换到SVC模式并关中断
MRS     r0, CPSR
BIC     r0, r0, #0x1F       @ 清除模式位
ORR     r0, r0, #0x13       @ 设置为SVC模式 (0x13)
ORR     r0, r0, #0xC0       @ 关中断 (IRQ+FIQ)
MSR     CPSR_c, r0

小技巧:在切换模式前,一定要先设置好对应模式的栈指针。每个模式都有自己的SP,忘了设置的话,一进异常就崩。我一般会在启动汇编里给SVC、IRQ、FIQ模式都分配独立的栈空间。

2.3 内存初始化:让CPU认识你的DDR

这是整个启动流程里最关键的环节。没有内存,代码就没地方跑。

内存初始化通常分两步:

  • 片内SRAM初始化:上电即可用,不需要配置。但容量小,一般只有几十KB到几百KB。
  • 片外DDR初始化:需要配置控制器、时序参数、PHY校准。这是Bootloader的主要工作之一。

DDR初始化的典型步骤:

  1. 配置DDR控制器基地址和时钟。
  2. 设置时序参数(tRCD、tCL、tRP等)。
  3. 执行PHY校准(ZQ校准、DQS门控校准)。
  4. 发送MRS命令配置DDR芯片模式寄存器。
  5. 执行写/读测试,验证内存可用。

以某款ARM SoC为例,DDR初始化代码片段:

/* DDR控制器寄存器配置 */
#define DDR_BASE        0x40000000
#define DDR_TIMING      0x40000010
#define DDR_PHY_CTRL    0x40000020

void ddr_init(void)
{
    /* 1. 使能DDR时钟 */
    *(volatile uint32_t *)0x20000004 |= (1 << 3);

    /* 2. 配置时序参数 */
    *(volatile uint32_t *)DDR_TIMING = 
        (3 << 24) |  /* tRCD = 3 */
        (5 << 16) |  /* tCL  = 5 */
        (3 << 8)  |  /* tRP  = 3 */
        (7 << 0);    /* tRAS = 7 */

    /* 3. 启动PHY校准 */
    *(volatile uint32_t *)DDR_PHY_CTRL = 0x1;
    while (*(volatile uint32_t *)DDR_PHY_CTRL & 0x1);

    /* 4. 内存测试:写0x55AA55AA并回读验证 */
    *(volatile uint32_t *)DDR_BASE = 0x55AA55AA;
    if (*(volatile uint32_t *)DDR_BASE != 0x55AA55AA) {
        /* 内存初始化失败,进入错误处理 */
        while (1);
    }
}

注意:DDR时序参数跟硬件设计强相关。不同频率、不同PCB走线长度,参数都不一样。我遇到过一块板子,DDR初始化在常温下没问题,到了-20°C就频繁报错。后来发现是tRCD参数余量不够。所以,量产前一定要做温度循环测试。

2.4 设备树传递:让操作系统认识硬件

操作系统启动时,需要知道板子上有哪些外设、地址是多少、中断号怎么分配。早期做法是把这些信息硬编码在代码里,换一块板子就得重新编译内核。

设备树(Device Tree)解决了这个问题。它用文本文件描述硬件拓扑,Bootloader把它加载到内存,然后传递给操作系统。

设备树传递的典型流程:

  • Bootloader从存储介质(Flash、SD卡)加载设备树二进制文件(.dtb)。
  • 将dtb放到内存中的特定地址(比如ARM Linux约定在0x100000偏移处)。
  • 在跳转到操作系统前,把dtb地址通过寄存器传递(ARM用r2,RISC-V用a1)。

一个简单的设备树节点示例:

/dts-v1/;

/ {
    model = "MyBoard V1.0";
    compatible = "mycompany,myboard";

    memory@80000000 {
        device_type = "memory";
        reg = <0x80000000 0x10000000>;  /* 256MB DDR */
    };

    uart0: serial@10000000 {
        compatible = "ns16550";
        reg = <0x10000000 0x1000>;
        interrupts = <0 5 4>;
    };
};

在Bootloader里,跳转到操作系统前的代码通常是这样:

/* ARM Linux启动约定:
 * r0 = 0
 * r1 = 机器类型号 (设备树时设为0)
 * r2 = 设备树基地址
 */
void boot_linux(void *dtb_addr)
{
    void (*kernel_entry)(int, int, void *);

    /* 关闭MMU和Cache */
    asm volatile("mrc p15, 0, r0, c1, c0, 0");
    asm volatile("bic r0, r0, #0x1");  /* 关闭MMU */
    asm volatile("mcr p15, 0, r0, c1, c0, 0");

    kernel_entry = (void (*)(int, int, void *))0x80008000;
    kernel_entry(0, 0, dtb_addr);
}

个人经验:设备树地址的选择要避开内核解压地址和initrd地址。我习惯把dtb放在内存末尾附近,比如0x8FF00000,这样不容易跟内核镜像重叠。另外,dtb文件大小不要超过1MB,否则可能覆盖其他数据。

2.5 整体启动流程框架

把上面几个环节串起来,整个启动流程就清晰了。我画了一张图,方便你理解:

嵌入式系统启动流程框架 1. 上电复位 2. 复位向量表加载 3. CPU模式/栈初始化 4. DDR内存初始化 5. 加载Bootloader主程序 6. 加载并传递设备树 7. 跳转到操作系统 关键说明 • 步骤1-4在片内SRAM执行 • 步骤5-7在DDR中执行 • 设备树地址通过寄存器传递 • 跳转前需关闭MMU/Cache 模式切换 复位 → SVC/M模式 Bootloader → 保持SVC OS内核 → 用户模式

从这张图可以看出来,整个启动流程是串行且不可逆的。前一步没做好,后面全白搭。我见过不少工程师在DDR初始化上翻车,结果反复查操作系统代码,其实问题出在更早的阶段。

2.6 常见问题与避坑指南

问题 现象 原因 解决方案
复位后无任何输出 板子完全没反应 复位向量表地址错误或内容不对 检查链接脚本,确认向量表放在正确段
进入HardFault 调试器显示异常 栈指针未初始化或模式切换错误 确保每个模式都有独立栈空间
DDR读写不稳定 随机崩溃、数据错误 时序参数配置不当 参考DDR芯片手册,留足时序余量
操作系统不识别外设 驱动加载失败 设备树地址传递错误或dtb损坏 检查dtb地址对齐,用fdt命令验证

我曾经踩过的坑:有一次移植U-Boot到新平台,DDR初始化通过了自检,但一跑Linux就随机死机。查了三天,最后发现是DDR控制器的一个时序参数在数据手册里写错了——手册推荐值是5,实际需要7。从那以后,我养成了一个习惯:DDR初始化后,跑一遍完整的memtest,而不是只写一个模式就完事。

好了,这一章的内容就到这里。启动流程是Bootloader的基础,理解了它,后面讲U-Boot移植、分区布局、安全启动时,你就能更快上手。记住:从复位向量到操作系统启动,每一步都有它的道理,也都有它的坑。多动手、多调试,慢慢就熟了。