Kernel 启动埋点与 dmesg:解析内核环形缓冲区
说实话,很多做 Android 系统开发的朋友,一听到「内核启动」就觉得那是驱动工程师的事。但做埋点,你就绕不开内核——尤其是 boot 阶段,用户空间还没起来,所有日志都靠内核环形缓冲区存着。我自己的习惯是:拿到一台出问题的设备,先敲 dmesg,看看内核在启动过程中到底干了什么。
环形缓冲区,说白了就是一个固定大小、循环覆盖的内存区域。内核把 printk 吐出来的日志往里写,用户空间再用 dmesg 或者其他方式读出来。为什么要环形?因为启动阶段没有文件系统,没法写到文件,环形缓冲区是最省内存、最可靠的办法。
1. 内核环形缓冲区原理
记得我刚接触 Android 内核时,有个困惑:为什么有时候 dmesg 只显示最后几行?后来才明白,环形缓冲区大小是有限的,默认 __LOG_BUF_LEN 通常只有 128KB 或 256KB。如果你打印太多,旧日志就会被新日志覆盖——这就叫「冲掉」。
下面这张表总结了几个关键参数,我建议你在做埋点前先确认一下设备的内核配置:
| 参数 | 典型值 | 说明 |
|---|---|---|
__LOG_BUF_LEN | 128KB / 256KB | 环形缓冲区总大小,可通过内核配置 CONFIG_LOG_BUF_SHIFT 调整 |
LOG_BUF_MAGIC | 0x4c6f6742 | 魔数,用于校验缓冲区完整性 |
| 每个 log entry 头部 | 16 bytes | 包含时间戳、进程号、日志等级等元信息 |
| 访问接口 | /dev/kmsg + dmesg | 用户空间读取的两种方式,/dev/kmsg 是字符设备,支持阻塞读 |
| 写入方式 | printk / pr_xxx | 内核代码里调用,支持格式化字符串 |
一个很常见的坑:我在某款手机上看 dmesg,发现 kernel 启动完成后的日志全没了,只剩下最后几百个字节。后来检查发现,是因为init进程在用户空间疯狂打印 log,而/dev/kmsg默认允许任何人写,导致 kernel ring buffer 被污染了。所以,做埋点时一定要控制写入量。
/dev/kmsg 写数据。另外,dmesg -c 会清空环形缓冲区,千万别在线上随便用!
2. dmesg 命令实战
dmesg 是读取环形缓冲区最常用的工具。我个人在调试阶段几乎离不开它。来几个最实用的用法:
# 查看所有内核日志(默认)
dmesg
# 只显示最后 10 行,用于快速检查启动末尾
dmesg | tail -10
# 按日志等级过滤,比如只看错误和警告
dmesg -l err,warn
# 持续监控新日志(类似 tail -f)
dmesg -w
# 显示可读的时间戳(从系统启动开始)
dmesg -T
# 清空缓冲区(慎用!)
dmesg -c
我以前踩过一个坑:用 dmesg -T 查看时间戳,却发现某些设备的时钟在启动初期是不准确的。内核刚起来时,xtime 还没完成初始化,时间戳会显示为 1970-01-01。嗯,那时候我就改用 dmesg --ctime(如果内核支持实时时钟)或者直接用 dmesg 看启动时间 delta。
还有一个技巧:如果你在 AOSP 的 logcat 里看不到 kernel log,可以试试 adb shell dmesg -T | grep -i "your_keyword"。很多系统服务会把内核日志吞掉,直接读 dmesg 是最原始的提取方式。
3. 内核日志级别与过滤
内核日志有 8 个级别,定义在 <linux/kernel.h> 里。我经常会用 dmesg -l 只抓出真正要紧的东西,避免被海量 info 淹没。
| 宏名称 | 数值 | 含义 | 举例场景 |
|---|---|---|---|
KERN_EMERG | 0 | 紧急,系统不可用 | 内核 panic |
KERN_ALERT | 1 | 必须立即处理 | 内存耗尽 |
KERN_CRIT | 2 | 严重条件 | 磁盘 I/O 错误 |
KERN_ERR | 3 | 错误条件 | 驱动加载失败 |
KERN_WARNING | 4 | 警告 | 硬件检测异常 |
KERN_NOTICE | 5 | 正常但重要 | 挂载文件系统 |
KERN_INFO | 6 | 信息 | 内核版本打印 |
KERN_DEBUG | 7 | 调试信息 | 驱动内部跟踪 |
比如我只想抓内核启动期间的错误,可以这样:
dmesg -l err,crit,alert,emerg -T | grep -E "\[[ ]*[0-9]+\.[0-9]+\]"
# 加上时间戳过滤,只看早期启动阶段(比如前 30 秒)
做埋点的时候,我不建议一股脑把全部 dmesg 都丢到上报数据里,一是太大,二是有可能包含敏感信息。我一般会只收集几个关键等级,再配合关键字匹配。
4. 读取内核日志的代码方式(Android 层)
有时候你需要在 App 或系统服务里直接抓内核日志,而不是靠 adb 命令。举个例子,我在做启动耗时埋点时,会在 SystemServer 初始化之后读取 /dev/kmsg 中关于硬件初始化耗时的那几行。
最直接的方法:用 C 或 C++ 读取 /dev/kmsg,这是一个只读(写入需要特权)的字符设备。伪代码如下:
// 打开 /dev/kmsg
int fd = open("/dev/kmsg", O_RDONLY);
if (fd < 0) {
// 权限不足,尝试其他方式
return;
}
char buf[4096];
while (1) {
ssize_t n = read(fd, buf, sizeof(buf) - 1);
if (n <= 0) break;
buf[n] = '\0';
// 处理每一行,比如提取 "init..." 等关键信息
process_kernel_log_line(buf);
}
close(fd);
但要注意:/dev/kmsg 在多进程同时读取时,每条日志只会被其中一个进程拿到(类似生产者-消费者模式)。如果你希望所有进程都能读到,就老老实实用 dmesg 或内核的 syslog 接口。
在 Java 层,Android 提供了 android.os.KernelMemoryUtil 之类的辅助类吗?其实没有。我通常通过 ProcessBuilder 执行一个本地可执行文件,或者直接调用 Runtime.exec("dmesg") 抓取。但这么做效率低,建议在开机完成后一次性读。
/dev/kmsg 需要 CAP_SYSLOG 权限(即 root 或 system uid)。如果你的系统服务有 root 权限,优先用这个;否则只有用 dmesg -r 通过 proc 文件系统解析了。
5. 埋点实战:如何将内核日志注入到埋点数据中
好,前面都是基础。真正到埋点这一步,要解决两个问题:什么时候抓?抓哪些?
我推荐的做法是分阶段抓取:
- 第一阶段:bootconfig 或 init 脚本里打印关键节点。 比如在
init.rc里通过write /dev/kmsg "BOOT_STAGE: start_early_hal\n"打标记。这样你在 dmesg 里就能用grep BOOT_STAGE快速定位每个阶段。 - 第二阶段:用户空间服务启动后,执行一次
dmesg -s 65536 -T | grep "BOOT_STAGE\|ENABLED\|INIT"。 把结果存到DropBoxManager或者直接写到statsd的 log source 里。 - 第三阶段:清理。 我建议在抓取完毕后,用
dmesg -c清空环形缓冲区(当然,要确认没有别的进程还在读)。如果不清理,后续的非启动日志会混进来。
曾经有一个项目,内核启动时有个驱动加载失败,但用户空间启动后发现功能正常。我们查了好久,才发现是 init 进程重新加载了驱动。如果当时没抓第一阶段的 dmesg,根本找不到根因。从那以后,我坚持在 boot 完成后的 5 秒内抓一次完整 dmesg,存成事件日志。
下面这张图梳理了从内核日志产生到埋点上报的流程:
最后总结一下:内核环形缓冲区是 Android 启动埋点中最底层、最可靠的日志来源。dmesg 命令是调试利器,而通过 /dev/kmsg 或 dmesg -c 配合过滤,你可以在系统服务里高效地采集内核启动信息。我个人的经验是把抓取时机定在 BOOT_COMPLETED 广播之前、UserSystemReady 之后,这样既能拿到完整的启动日志,又不会太晚导致被冲掉。嗯,记住一点——环形缓冲区不是无限大的,所以该读就读,读完该清就清。