30、总结与实战:手写一个最小系统的启动代码
好,终于到了最后一章。
前面29章,我们把C语言运行时初始化、链接脚本、启动流程、堆栈、BSS段清零、数据段搬运、中断向量表……这些概念翻来覆去讲了个遍。说实话,我自己当年学这些的时候,也是看了好几遍才真正串起来。
这一章,咱们不聊理论了。直接动手,手写一个最小系统的启动代码。你跟着我走一遍,写完了,你就能真正理解——芯片上电后,到底发生了什么。
31.1 最小系统需要什么?
先想清楚,一个最小系统,至少要有这几样东西:
- 一个CPU核(比如ARM Cortex-M0/M3/M4)
- 一小段SRAM(放堆栈和变量)
- 一小段Flash(放代码)
- 一个复位信号
嗯,说白了,就是能跑起来、能跳转到main()、能处理中断。就这么简单。
核心目标:从复位向量 → 初始化C运行时 → 跳转到main()。中间不能死机,不能跑飞。
31.2 启动代码的整体结构
我个人习惯把启动代码分成四个阶段:
- 定义中断向量表——告诉CPU,复位后去哪里执行
- 复位处理函数——真正的入口
- C运行时初始化——搬数据、清BSS、设堆栈
- 跳转到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 启动流程全景图
下面这张图,把整个启动流程串起来了。你对照着看,会更清楚。
31.6 常见坑与避坑指南
写启动代码,有几个地方特别容易出问题。我把自己踩过的坑列出来:
- 中断向量表对齐问题——ARM要求向量表必须4字节对齐,否则硬件可能加载错误地址。我建议在链接脚本里加
. = ALIGN(4);。 - 栈顶地址计算错误——很多人直接把SRAM末尾地址当栈顶,但忘了留一点空间给中断嵌套。我习惯在链接脚本里减掉16字节。
- 弱定义处理函数——所有中断处理函数最好都加上
__attribute__((weak)),这样你没实现的中断会默认进入死循环,不会跑飞。 - 搬运.data段时源地址搞错——记住,.data段在Flash里存了一份,运行时搬到SRAM。源地址是
LOADADDR(.data),目标地址是ADDR(.data)。这两个不一样!
一个小技巧:调试启动代码时,可以在Reset_Handler开头加一个GPIO翻转,用示波器看。如果波形出来了,说明至少跑到这里了。我每次移植新平台都这么干。
31.7 总结
写启动代码,说白了就是三件事:
- 告诉CPU从哪里开始
- 准备好C语言运行环境(内存、堆栈、变量)
- 把控制权交给main()
你只要把这三件事理清楚,不管换什么芯片、什么架构,核心逻辑都是一样的。嗯,这就是底层开发的魅力——万变不离其宗。
好了,这一章就到这里。代码你可以直接拿去用,但建议你亲手敲一遍,跑一跑,改一改。只有自己踩过坑,才能真正记住。