4、Bootloader阶段与埋点:引导加载程序的流程(AB分区、fastboot),如何在bootloader中添加时间戳埋点
Bootloader是整个Android启动链的第一段可执行代码。说白了,它负责把硬件从「一块砖」变成「一台能跑Linux的机器」。我记得刚开始做启动优化时,总觉得Bootloader跑得快慢无所谓,后来才发现——如果Bootloader阶段多耗100ms,用户就能明显感觉到开机慢了。埋点,就得从这里开始。
4.1 Bootloader到底干了哪些事?
简单来说,Bootloader的工作分四步:
- 硬件初始化——关看门狗、设置时钟、初始化DDR。这些事必须在进入C世界之前搞定。
- 加载引导镜像——从Flash/eMMC/UFS里把boot.img读进内存。这里就涉及AB分区选哪个槽位。
- fastboot检测——检查是否有按键组合被按下,决定是进fastboot模式还是正常启动。
- 跳转内核——准备好DTB和cmdline,最后一条指令跳到内核入口。
你想想看,每一步都可能成为性能瓶颈。不加埋点,你只能靠猜。我见过一个项目,开机慢在DDR training上,但团队一直以为是文件系统的问题——就是因为没在Bootloader里打时间戳。
4.2 AB分区机制——选错槽位,启动就慢半拍
AB分区的设计初衷是「无缝更新」。系统同时维护两套分区槽(slot A / slot B),当前在用一套,升级包写到另一套。下次启动时Bootloader根据标志位决定从哪个槽位启动。
我在项目中遇到过一个问题:某款设备升级后重启特别慢,抓了埋点数据才发现——Bootloader在AB分区选择阶段花了300ms去校验两个槽位的版本号。原因?分区表里混了旧格式的metadata,导致校验逻辑走了两次全量哈希。你看,没有埋点根本定位不到这种「慢」是从哪来的。
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_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 就能看到完整链路。
.noinit 段里。
好了,从埋点位置的选择,到代码插桩,再到数据读取,Bootloader阶段的埋点就是这么一套打法。别小看这几行时间戳代码——没有它们,你优化启动性能就像闭着眼开车。有了它们,每一步的耗时都清清楚楚摆在那,优化起来目标明确得多。