2. Bootloader基础
做 Bootloader 优化,第一件事不是动手改代码,而是搞清楚时间都花在哪了。我见过太多人上来就对着汇编一顿猛改,结果优化了半天,启动时间只快了 5 毫秒——其实瓶颈根本不在那里。
这一章,我们就来聊聊怎么把启动过程拆开、量准、建好基准。说白了,就是给你的 Bootloader 做一次「体检」。
2.1 启动阶段划分:把黑盒子拆开
一个典型的 Bootloader 启动流程,可以分成四个大阶段。每个阶段的特征和优化空间都不一样。
| 阶段 | 起始事件 | 结束事件 | 典型耗时占比 |
|---|---|---|---|
| Stage 0:ROM 启动 | CPU 复位 | 跳转到 Flash 入口 | 5% - 10% |
| Stage 1:硬件初始化 | Flash 入口 | DDR 初始化完成 | 30% - 50% |
| Stage 2:Bootloader 主体 | DDR 可用 | 加载 Kernel 完成 | 20% - 30% |
| Stage 3:跳转执行 | Kernel 加载完成 | Kernel 入口执行 | 5% - 10% |
你可能会问,为什么 Stage 1 占比这么高?嗯,这里有个坑。DDR 初始化通常需要等待 PLL 锁定、DDR PHY 训练,这些硬件操作是「硬等」,软件再快也快不了。我曾在某个项目中,DDR 初始化占了 400 毫秒,总启动时间才 800 毫秒——一半时间都耗在这了。
核心原则:优化前先分阶段。每个阶段的优化手段完全不同。Stage 0 拼的是代码密度,Stage 1 拼的是硬件配置,Stage 2 拼的是算法效率。
2.2 时间测量方法:你得有把好尺子
分好阶段了,怎么量?我见过有人用秒表掐,也有人用示波器看 GPIO 电平。这些方法不是不能用,但精度和可重复性都差了点。
我个人习惯用三种方法,按场景选:
2.2.1 硬件定时器法(最推荐)
在 Bootloader 早期就初始化一个通用定时器,频率越高越好。然后在每个阶段开始和结束时读取计数值。
/* 伪代码示例:使用 Cortex-M 的 SysTick 测量 */
void boot_timer_init(void) {
SysTick->LOAD = 0xFFFFFF; /* 最大计数值 */
SysTick->VAL = 0;
SysTick->CTRL = 0x5; /* 使能,使用内核时钟 */
}
uint32_t boot_timer_get(void) {
return SysTick->VAL; /* 递减计数器 */
}
/* 在阶段边界调用 */
uint32_t t0 = boot_timer_get();
/* ... 执行阶段代码 ... */
uint32_t t1 = boot_timer_get();
uint32_t elapsed = t0 - t1; /* 注意递减方向 */
小技巧:如果定时器频率是 1MHz,那么计数值直接就是微秒。我习惯在调试串口输出时,把时间戳打在每行前面,这样一眼就能看出哪段代码慢。
2.2.2 GPIO 示波器法(硬件调试用)
当你的 Bootloader 还没有串口时,这个方法最靠谱。在阶段开始拉高 GPIO,结束拉低。用示波器看脉宽。
/* 示波器法示例 */
#define DEBUG_GPIO_PORT GPIOA
#define DEBUG_GPIO_PIN GPIO_PIN_0
void stage_start(int stage_id) {
HAL_GPIO_WritePin(DEBUG_GPIO_PORT, DEBUG_GPIO_PIN, GPIO_PIN_SET);
}
void stage_end(int stage_id) {
HAL_GPIO_WritePin(DEBUG_GPIO_PORT, DEBUG_GPIO_PIN, GPIO_PIN_RESET);
}
我曾经用这个方法抓到一个问题:某个外设的初始化函数里,有个 while 循环在等一个永远不会来的中断。示波器上一看,那个 GPIO 脉宽直接「焊死」在高电平了——代码卡死了。
2.2.3 JTAG/SWD 跟踪法(最精确)
如果你手上有 J-Trace 或者 Lauterbach,直接用 ETM/ETB 跟踪指令执行。这个方法能精确到单个指令周期,但配置起来比较麻烦。我一般只在定位「毫秒级以下」的抖动时才用。
注意:测量本身会引入开销。硬件定时器法大约增加 10-20 个指令周期,GPIO 法大约 50-100 个周期。对于微秒级的测量,这个开销可以忽略。但如果你的阶段只有几十个周期,那就要考虑用 JTAG 跟踪了。
2.3 性能基准建立:没有基准,就没有优化
测量完时间,接下来要建基准。我见过最糟糕的情况是:团队里每个人对「启动时间」的定义都不一样。有人说从按下电源键开始算,有人说从 Bootloader 入口开始算。基准不统一,优化结果就没法对比。
2.3.1 定义统一的测量点
我建议在代码里定义一组宏,把测量点固定下来:
/* 基准测量点定义 */
#define BENCHMARK_POWER_ON 0 /* CPU 复位 */
#define BENCHMARK_ROM_EXIT 1 /* 离开 ROM 代码 */
#define BENCHMARK_DDR_READY 2 /* DDR 初始化完成 */
#define BENCHMARK_BOOT_DONE 3 /* Bootloader 主体完成 */
#define BENCHMARK_KERNEL_ENTRY 4 /* Kernel 入口 */
每个测量点对应一个全局变量,记录时间戳。最后统一输出。
2.3.2 建立基线数据
拿到时间戳后,我习惯用表格记录三次测量的结果,取中位数作为基线:
| 测量点 | 运行 1 (ms) | 运行 2 (ms) | 运行 3 (ms) | 中位数 (ms) |
|---|---|---|---|---|
| POWER_ON → ROM_EXIT | 12.3 | 12.1 | 12.4 | 12.3 |
| ROM_EXIT → DDR_READY | 345.6 | 346.2 | 344.9 | 345.6 |
| DDR_READY → BOOT_DONE | 89.2 | 88.7 | 89.5 | 89.2 |
| BOOT_DONE → KERNEL_ENTRY | 2.1 | 2.0 | 2.1 | 2.1 |
| 总启动时间 | 449.2 | 449.0 | 448.9 | 449.2 |
为什么要取中位数而不是平均值?因为启动时间受硬件状态影响,偶尔会有异常值(比如 DDR 训练失败重试)。中位数能更好地反映「典型情况」。
2.3.3 可视化基线
数据有了,画成图更直观。下面这张图展示了各阶段的时间占比,一眼就能看出 Stage 1 是优化重点。
2.4 实战:建立你的第一个基准
好了,理论讲完了。我们来走一遍实际操作流程:
- 选一个稳定的硬件平台——同一块板子,同一版 PCB,同一批物料。不同批次的 DDR 训练时间可能有差异。
- 在代码里插入测量点——用硬件定时器法,在 5 个基准点记录时间戳。
- 连续运行 10 次——每次冷启动(断电再上电),记录数据。
- 剔除异常值——如果某次启动时间明显偏离(比如多了 100ms),检查是不是 DDR 训练重试了。
- 取中位数,写入文档——这就是你的基线。以后每次优化后,都用同样的方法测量,对比基线。
我的习惯:我会把基线数据直接写在代码注释里,这样任何人拿到代码,都知道「正常启动时间应该是多少」。如果某次编译后的启动时间偏离了基线,那一定是改出了 bug。
2.5 常见陷阱
最后,分享几个我踩过的坑:
- 陷阱一:测量点放在中断里——中断延迟会导致时间戳不准。测量点一定要放在主流程里,关中断执行。
- 陷阱二:用调试器影响时序——JTAG 调试器本身会插入等待周期。测量时,尽量用「运行到断点」而不是「单步执行」。
- 陷阱三:忽略 Cache 预热——第一次执行和第二次执行,Cache 命中率完全不同。测量启动时间时,一定是冷启动(Cache 未预热)。
- 陷阱四:串口输出影响时间——printf 很慢。测量阶段不要加调试打印,等测量完再输出结果。
嗯,这一章的内容就到这里。启动时间分析是 Bootloader 优化的第一步,也是最重要的一步。把这一步做扎实了,后面的优化才有方向。记住:没有数据,就没有优化;没有基准,就没有进步。