启动时间分析:启动阶段划分、时间测量方法、性能基准建立

做 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 是优化重点。

Bootloader 启动时间分布图 Bootloader 启动时间分布 Stage 0: ROM 启动 (2.7%) Stage 1: 硬件初始化 (77.0%) Stage 2: Bootloader 主体 (19.9%) Stage 3: 跳转执行 (0.4%) 12ms 345.6 ms (77.0%) 89ms 2ms 优化优先级 Stage 1(硬件初始化)占比 77%,是优化主战场。 Stage 2 虽然占比不高,但优化空间大(算法替换、DMA 加速)。 Stage 0 和 Stage 3 占比极小,除非有特殊要求,否则不值得投入精力。

2.4 实战:建立你的第一个基准

好了,理论讲完了。我们来走一遍实际操作流程:

  1. 选一个稳定的硬件平台——同一块板子,同一版 PCB,同一批物料。不同批次的 DDR 训练时间可能有差异。
  2. 在代码里插入测量点——用硬件定时器法,在 5 个基准点记录时间戳。
  3. 连续运行 10 次——每次冷启动(断电再上电),记录数据。
  4. 剔除异常值——如果某次启动时间明显偏离(比如多了 100ms),检查是不是 DDR 训练重试了。
  5. 取中位数,写入文档——这就是你的基线。以后每次优化后,都用同样的方法测量,对比基线。

我的习惯:我会把基线数据直接写在代码注释里,这样任何人拿到代码,都知道「正常启动时间应该是多少」。如果某次编译后的启动时间偏离了基线,那一定是改出了 bug。

2.5 常见陷阱

最后,分享几个我踩过的坑:

  • 陷阱一:测量点放在中断里——中断延迟会导致时间戳不准。测量点一定要放在主流程里,关中断执行。
  • 陷阱二:用调试器影响时序——JTAG 调试器本身会插入等待周期。测量时,尽量用「运行到断点」而不是「单步执行」。
  • 陷阱三:忽略 Cache 预热——第一次执行和第二次执行,Cache 命中率完全不同。测量启动时间时,一定是冷启动(Cache 未预热)。
  • 陷阱四:串口输出影响时间——printf 很慢。测量阶段不要加调试打印,等测量完再输出结果。

嗯,这一章的内容就到这里。启动时间分析是 Bootloader 优化的第一步,也是最重要的一步。把这一步做扎实了,后面的优化才有方向。记住:没有数据,就没有优化;没有基准,就没有进步。