30、总结与实战:手写一个最小系统的启动代码

好,终于到了最后一章。

前面29章,我们把C语言运行时初始化、链接脚本、启动流程、堆栈、BSS段清零、数据段搬运、中断向量表……这些概念翻来覆去讲了个遍。说实话,我自己当年学这些的时候,也是看了好几遍才真正串起来。

这一章,咱们不聊理论了。直接动手,手写一个最小系统的启动代码。你跟着我走一遍,写完了,你就能真正理解——芯片上电后,到底发生了什么

31.1 最小系统需要什么?

先想清楚,一个最小系统,至少要有这几样东西:

  • 一个CPU核(比如ARM Cortex-M0/M3/M4)
  • 一小段SRAM(放堆栈和变量)
  • 一小段Flash(放代码)
  • 一个复位信号

嗯,说白了,就是能跑起来、能跳转到main()、能处理中断。就这么简单。

核心目标:从复位向量 → 初始化C运行时 → 跳转到main()。中间不能死机,不能跑飞。

31.2 启动代码的整体结构

我个人习惯把启动代码分成四个阶段:

  1. 定义中断向量表——告诉CPU,复位后去哪里执行
  2. 复位处理函数——真正的入口
  3. C运行时初始化——搬数据、清BSS、设堆栈
  4. 跳转到main()——把控制权交给应用层

你想想看,这四步缺一不可。我在项目中遇到过,有人忘了清BSS段,结果全局变量初始值全是乱的,查了两天才找到原因……

31.3 手写启动代码(完整示例)

下面我给出一个基于ARM Cortex-M3的最小启动代码。你可以直接拿去用,但建议你一行一行敲一遍。

/* startup_minimal.c - 最小系统启动代码 */

/* 外部符号声明 */
extern int main(void);
extern unsigned int _sdata, _edata, _sbss, _ebss, _sstack, _estack;

/* 中断向量表 */
__attribute__((section(".isr_vector")))
void (* const g_pfnVectors[])(void) = {
    (void (*)(void))(&_estack),   /* 栈顶地址 */
    Reset_Handler,                /* 复位处理函数 */
    NMI_Handler,                  /* NMI */
    HardFault_Handler,            /* 硬错误 */
    /* 其他中断可以继续添加,这里省略 */
};

/* 复位处理函数 */
void Reset_Handler(void) {
    unsigned int *src, *dst;

    /* 1. 初始化栈指针(有些芯片硬件自动做,这里显式做一次) */
    __asm volatile ("ldr sp, =_estack");

    /* 2. 搬运.data段:从Flash复制到SRAM */
    src = &_sdata;
    dst = &_sdata;
    while (dst < &_edata) {
        *dst++ = *src++;
    }

    /* 3. 清零.bss段 */
    dst = &_sbss;
    while (dst < &_ebss) {
        *dst++ = 0;
    }

    /* 4. 跳转到main() */
    main();

    /* 如果main()返回,死循环 */
    while (1);
}

/* 弱定义的默认中断处理函数 */
void __attribute__((weak)) NMI_Handler(void) {
    while (1);
}
void __attribute__((weak)) HardFault_Handler(void) {
    while (1);
}

我的习惯:在Reset_Handler里显式设置一次SP,虽然有些芯片硬件会自动加载,但多写一行不亏。我曾经在某个国产MCU上踩过坑——硬件没做SP初始化,结果一进中断就崩了。

31.4 配套链接脚本(最小版)

启动代码离不开链接脚本。下面这个脚本,跟上面的启动代码是配套的。

/* link_minimal.ld */

MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 64K
    SRAM  (rwx) : ORIGIN = 0x20000000, LENGTH = 20K
}

SECTIONS
{
    .isr_vector :
    {
        . = ALIGN(4);
        KEEP(*(.isr_vector))
        . = ALIGN(4);
    } > FLASH

    .text :
    {
        . = ALIGN(4);
        *(.text*)
        *(.rodata*)
        . = ALIGN(4);
    } > FLASH

    _sdata = .;
    .data : AT(_sdata)
    {
        . = ALIGN(4);
        _sdata = .;
        *(.data*)
        . = ALIGN(4);
        _edata = .;
    } > SRAM

    .bss :
    {
        . = ALIGN(4);
        _sbss = .;
        *(.bss*)
        *(COMMON)
        . = ALIGN(4);
        _ebss = .;
    } > SRAM

    _estack = ORIGIN(SRAM) + LENGTH(SRAM);
}

注意:链接脚本里的符号名(_sdata、_edata等)必须和启动代码里的一致。我见过有人把_sdata写成了_sdata_start,结果链接报错,查了半天才发现是名字没对上。

31.5 启动流程全景图

下面这张图,把整个启动流程串起来了。你对照着看,会更清楚。

最小系统启动流程 1. 芯片上电复位 2. 硬件加载栈指针 & PC 3. 跳转到 Reset_Handler 4a. 搬运 .data 段 4b. 清零 .bss 段 4c. 初始化堆栈 5. 跳转到 main()

31.6 常见坑与避坑指南

写启动代码,有几个地方特别容易出问题。我把自己踩过的坑列出来:

  • 中断向量表对齐问题——ARM要求向量表必须4字节对齐,否则硬件可能加载错误地址。我建议在链接脚本里加 . = ALIGN(4);
  • 栈顶地址计算错误——很多人直接把SRAM末尾地址当栈顶,但忘了留一点空间给中断嵌套。我习惯在链接脚本里减掉16字节。
  • 弱定义处理函数——所有中断处理函数最好都加上 __attribute__((weak)),这样你没实现的中断会默认进入死循环,不会跑飞。
  • 搬运.data段时源地址搞错——记住,.data段在Flash里存了一份,运行时搬到SRAM。源地址是 LOADADDR(.data),目标地址是 ADDR(.data)。这两个不一样!

一个小技巧:调试启动代码时,可以在Reset_Handler开头加一个GPIO翻转,用示波器看。如果波形出来了,说明至少跑到这里了。我每次移植新平台都这么干。

31.7 总结

写启动代码,说白了就是三件事:

  1. 告诉CPU从哪里开始
  2. 准备好C语言运行环境(内存、堆栈、变量)
  3. 把控制权交给main()

你只要把这三件事理清楚,不管换什么芯片、什么架构,核心逻辑都是一样的。嗯,这就是底层开发的魅力——万变不离其宗。

好了,这一章就到这里。代码你可以直接拿去用,但建议你亲手敲一遍,跑一跑,改一改。只有自己踩过坑,才能真正记住。


公众号:蓝海资料掘金营,微信 deep3321