4、Bootloader阶段与埋点:引导加载程序的流程(AB分区、fastboot),如何在bootloader中添加时间戳埋点

Bootloader是整个Android启动链的第一段可执行代码。说白了,它负责把硬件从「一块砖」变成「一台能跑Linux的机器」。我记得刚开始做启动优化时,总觉得Bootloader跑得快慢无所谓,后来才发现——如果Bootloader阶段多耗100ms,用户就能明显感觉到开机慢了。埋点,就得从这里开始。

核心认知:Bootloader阶段的埋点数据,是分析「冷启动时间」的第一手资料。没有这部分数据,你永远不知道开机慢在哪。

4.1 Bootloader到底干了哪些事?

简单来说,Bootloader的工作分四步:

  1. 硬件初始化——关看门狗、设置时钟、初始化DDR。这些事必须在进入C世界之前搞定。
  2. 加载引导镜像——从Flash/eMMC/UFS里把boot.img读进内存。这里就涉及AB分区选哪个槽位。
  3. fastboot检测——检查是否有按键组合被按下,决定是进fastboot模式还是正常启动。
  4. 跳转内核——准备好DTB和cmdline,最后一条指令跳到内核入口。

你想想看,每一步都可能成为性能瓶颈。不加埋点,你只能靠猜。我见过一个项目,开机慢在DDR training上,但团队一直以为是文件系统的问题——就是因为没在Bootloader里打时间戳。

Bootloader 启动流程与埋点位置 Bootloader 阶段 上电 / PBL 启动 SBL 加载 AB 分区选择 fastboot 检测 内核加载并跳转 power_on_ts:上电瞬间时间戳 sbl_entry_ts:SBL 入口时间戳 ab_slot_select_ts:分区选定时间戳 fastboot_check_ts:fastboot 判定时间戳 kernel_entry_ts:跳转内核前的时间戳 在每个关键阶段的入口/出口插入时间戳,串联起完整的 Bootloader 启动链路

4.2 AB分区机制——选错槽位,启动就慢半拍

AB分区的设计初衷是「无缝更新」。系统同时维护两套分区槽(slot A / slot B),当前在用一套,升级包写到另一套。下次启动时Bootloader根据标志位决定从哪个槽位启动。

我在项目中遇到过一个问题:某款设备升级后重启特别慢,抓了埋点数据才发现——Bootloader在AB分区选择阶段花了300ms去校验两个槽位的版本号。原因?分区表里混了旧格式的metadata,导致校验逻辑走了两次全量哈希。你看,没有埋点根本定位不到这种「慢」是从哪来的。

埋点建议:在AB分区选择阶段打两个时间戳——ab_slot_select_start 和 ab_slot_select_end。差值就是分区决策耗时。如果超过50ms,就该看看是不是校验逻辑太重了。

4.3 fastboot模式——一个被忽视的埋点盲区

fastboot是Bootloader中的调试模式。用户通过按键组合触发,用于刷机、解锁等操作。正常启动时,fastboot检测只是「看一眼按键电平」,花不了几微秒。但问题出在——如果gpio驱动在检测前被反复resume/suspend,耗时可能飙升到几十甚至上百毫秒。

我记得有一次客户反馈「开机偶尔慢2秒」,查了所有地方都没头绪。最后我在fastboot检测前后加了时间戳,发现每次慢的时候,fastboot检测都花了1.8秒——原因是那颗gpio的pin controller在检测前被意外触发了电源状态切换。嗯,不加这个埋点,鬼都想不到是按键检测惹的祸。

避坑指南:fastboot检测埋点一定要区分「正常启动」和「进入fastboot」两条路径。我曾经在同一份日志里把两种路径的时间戳混在一起,结果算出来的平均值完全失真。建议用两个不同的变量名来记录,比如 fastboot_check_normal_ts 和 fastboot_entry_ts。

4.4 在Bootloader中添加时间戳埋点——具体怎么做?

原理其实很简单:读硬件计时器 → 存到共享内存 → 内核启动后从内存里取出来。关键是怎么读、存哪、怎么保证数据不被覆盖。

下面这张表列出了我推荐的埋点位置和对应的变量命名:

埋点位置 变量名 说明
上电瞬间(PBL入口) power_on_ts 整个启动链的零点
SBL入口 sbl_entry_ts PBL→SBL的交接时刻
AB分区选择完成 ab_slot_select_ts 选好槽位的时刻
fastboot检测完成 fastboot_check_ts 是否进入fastboot的判定点
跳转内核前 kernel_entry_ts Bootloader使命结束

4.5 代码示例:实际埋点怎么插?

以我去年在qcom平台上的实践为例——用ARM Generic Timer作为时间源,在共享内存(IMEM)里维护一个时间戳数组。代码大概长这样:

/* bootloader_timestamp.h */
#define MAX_TIMESTAMPS 16

enum ts_id {
    TS_POWER_ON      = 0,
    TS_SBL_ENTRY     = 1,
    TS_AB_SLOT_SEL   = 2,
    TS_FASTBOOT_CHK  = 3,
    TS_KERNEL_ENTRY  = 4,
};

/* 共享内存地址(由平台spec定义) */
#define TS_SMEM_BASE  0x146A0000

/* bootloader_timestamp.c */
#include "bootloader_timestamp.h"

static uint64_t read_timestamp(void) {
    uint64_t cnt;
    /* 读取ARM Generic Timer的Counter Register */
    asm volatile("mrs %0, cntpct_el0" : "=r"(cnt));
    return cnt;
}

void ts_record(enum ts_id id) {
    if (id >= MAX_TIMESTAMPS) return;
    uint64_t *ts_buf = (uint64_t *)TS_SMEM_BASE;
    ts_buf[id] = read_timestamp();
}

void ts_init(void) {
    /* 清零共享内存区域 */
    memset((void *)TS_SMEM_BASE, 0, 
           MAX_TIMESTAMPS * sizeof(uint64_t));
    /* 记录上电时间戳 */
    ts_record(TS_POWER_ON);
}

然后在Bootloader的各阶段入口调用:

/* main.c */
void bootloader_main(void) {
    ts_init();                          /* TS_POWER_ON */
    
    /* SBL阶段 */
    sbl_init();
    ts_record(TS_SBL_ENTRY);            /* TS_SBL_ENTRY */
    
    /* AB分区选择 */
    int slot = ab_select_slot();
    ts_record(TS_AB_SLOT_SEL);          /* TS_AB_SLOT_SEL */
    
    /* fastboot检测 */
    int fb_mode = check_fastboot();
    ts_record(TS_FASTBOOT_CHK);         /* TS_FASTBOOT_CHK */
    
    if (fb_mode) {
        enter_fastboot();
    } else {
        /* 加载内核并跳转 */
        load_kernel(slot);
        ts_record(TS_KERNEL_ENTRY);     /* TS_KERNEL_ENTRY */
        jump_kernel();
    }
}
一个小技巧:我习惯在 ts_record 函数里加一个「写后读回校验」。共享内存区域可能被Cache策略坑到——你写进去了,但读出来是旧的。加一行 if (ts_buf[id] != read_timestamp()) { retry; } 能省掉很多调试时间。

4.6 埋点数据怎么取出来?

时间戳写进了共享内存,内核起来后怎么拿到?两种常见方式:

  • 方式一:把共享内存地址通过DTB传递给内核,内核驱动直接映射读取。
  • 方式二:Bootloader把时间戳数组拼接成一段ATAG,放在内核cmdline之后。内核解析ATAG时顺手存下来。

我比较推荐方式一,原因是DTB传递更规范,且不依赖cmdline长度限制。实际项目中我在内核里写了一个很小的平台驱动,probe的时候从DTB拿到物理地址,ioremap之后把时间戳dump到debugfs里。用户空间通过 cat /sys/kernel/debug/bootloader_ts 就能看到完整链路。

注意:共享内存区域在Bootloader阶段必须保证不被二次初始化。我曾经踩过一个坑——SBL跑完后,PBL往共享内存里写的数据被SBL的BSS清零操作覆盖了。解决办法是把共享内存区域单独配置在链接脚本的 .noinit 段里。

好了,从埋点位置的选择,到代码插桩,再到数据读取,Bootloader阶段的埋点就是这么一套打法。别小看这几行时间戳代码——没有它们,你优化启动性能就像闭着眼开车。有了它们,每一步的耗时都清清楚楚摆在那,优化起来目标明确得多。