Kernel 启动埋点与 dmesg:解析内核环形缓冲区

说实话,很多做 Android 系统开发的朋友,一听到「内核启动」就觉得那是驱动工程师的事。但做埋点,你就绕不开内核——尤其是 boot 阶段,用户空间还没起来,所有日志都靠内核环形缓冲区存着。我自己的习惯是:拿到一台出问题的设备,先敲 dmesg,看看内核在启动过程中到底干了什么。

环形缓冲区,说白了就是一个固定大小、循环覆盖的内存区域。内核把 printk 吐出来的日志往里写,用户空间再用 dmesg 或者其他方式读出来。为什么要环形?因为启动阶段没有文件系统,没法写到文件,环形缓冲区是最省内存、最可靠的办法。

1. 内核环形缓冲区原理

记得我刚接触 Android 内核时,有个困惑:为什么有时候 dmesg 只显示最后几行?后来才明白,环形缓冲区大小是有限的,默认 __LOG_BUF_LEN 通常只有 128KB 或 256KB。如果你打印太多,旧日志就会被新日志覆盖——这就叫「冲掉」。

下面这张表总结了几个关键参数,我建议你在做埋点前先确认一下设备的内核配置:

参数典型值说明
__LOG_BUF_LEN128KB / 256KB环形缓冲区总大小,可通过内核配置 CONFIG_LOG_BUF_SHIFT 调整
LOG_BUF_MAGIC0x4c6f6742魔数,用于校验缓冲区完整性
每个 log entry 头部16 bytes包含时间戳、进程号、日志等级等元信息
访问接口/dev/kmsg + dmesg用户空间读取的两种方式,/dev/kmsg 是字符设备,支持阻塞读
写入方式printk / pr_xxx内核代码里调用,支持格式化字符串

一个很常见的坑:我在某款手机上看 dmesg,发现 kernel 启动完成后的日志全没了,只剩下最后几百个字节。后来检查发现,是因为init进程在用户空间疯狂打印 log,而/dev/kmsg默认允许任何人写,导致 kernel ring buffer 被污染了。所以,做埋点时一定要控制写入量。

注意:如果 dmesg 输出不全,先检查是否有人或进程在大量往 /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_EMERG0紧急,系统不可用内核 panic
KERN_ALERT1必须立即处理内存耗尽
KERN_CRIT2严重条件磁盘 I/O 错误
KERN_ERR3错误条件驱动加载失败
KERN_WARNING4警告硬件检测异常
KERN_NOTICE5正常但重要挂载文件系统
KERN_INFO6信息内核版本打印
KERN_DEBUG7调试信息驱动内部跟踪

比如我只想抓内核启动期间的错误,可以这样:

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. 埋点实战:如何将内核日志注入到埋点数据中

好,前面都是基础。真正到埋点这一步,要解决两个问题:什么时候抓?抓哪些?

我推荐的做法是分阶段抓取:

  1. 第一阶段:bootconfig 或 init 脚本里打印关键节点。 比如在 init.rc 里通过 write /dev/kmsg "BOOT_STAGE: start_early_hal\n" 打标记。这样你在 dmesg 里就能用 grep BOOT_STAGE 快速定位每个阶段。
  2. 第二阶段:用户空间服务启动后,执行一次 dmesg -s 65536 -T | grep "BOOT_STAGE\|ENABLED\|INIT"。 把结果存到 DropBoxManager 或者直接写到 statsd 的 log source 里。
  3. 第三阶段:清理。 我建议在抓取完毕后,用 dmesg -c 清空环形缓冲区(当然,要确认没有别的进程还在读)。如果不清理,后续的非启动日志会混进来。

曾经有一个项目,内核启动时有个驱动加载失败,但用户空间启动后发现功能正常。我们查了好久,才发现是 init 进程重新加载了驱动。如果当时没抓第一阶段的 dmesg,根本找不到根因。从那以后,我坚持在 boot 完成后的 5 秒内抓一次完整 dmesg,存成事件日志。

下面这张图梳理了从内核日志产生到埋点上报的流程:

内核环形缓冲区与dmesg埋点流程 内核 printk / pr_xxx 环形缓冲区 (__LOG_BUF_LEN) /dev/kmsg 或 syslog dmesg 命令/接口 日志过滤 (等级/关键字) 埋点数据收集 (DropBox/Statsd) 注意 1. 多进程读/dev/kmsg,每条日志只发一次 2. 环形缓冲区可被冲掉,尽早抓取 3. 埋点前做等级过滤,减小数据量

最后总结一下:内核环形缓冲区是 Android 启动埋点中最底层、最可靠的日志来源。dmesg 命令是调试利器,而通过 /dev/kmsg 或 dmesg -c 配合过滤,你可以在系统服务里高效地采集内核启动信息。我个人的经验是把抓取时机定在 BOOT_COMPLETED 广播之前、UserSystemReady 之后,这样既能拿到完整的启动日志,又不会太晚导致被冲掉。嗯,记住一点——环形缓冲区不是无限大的,所以该读就读,读完该清就清。