3. 早期内核启动日志(printk)机制:printk实现原理、日志缓冲区与console初始化
在嵌入式Linux内核启动的早期阶段,标准I/O子系统尚未就绪,此时内核通过printk机制向开发者输出启动日志。printk是内核中最基础的日志输出函数,其设计目标是在任意上下文(中断、原子操作、早期启动)中都能安全调用。理解printk的实现原理、日志缓冲区管理以及console的初始化流程,是分析和调试内核启动问题的关键。
3.1 printk 实现原理
printk 的核心是一个可变参数函数,它将格式化后的字符串写入一个全局的环形缓冲区(ring buffer)。其实现路径如下:
- 格式化与加锁: 调用
vprintk_store()将 fmt 和 args 格式化为字符串,并添加日志级别、时间戳、进程ID等元数据。整个过程使用logbuf_lock自旋锁保护,防止多核竞争。 - 写入环形缓冲区: 格式化后的记录被写入
__log_buf[]数组。该缓冲区是一个固定大小的环形结构,新记录会覆盖最旧的记录。 - 唤醒控制台线程: 写入完成后,如果当前进程可以调度,则唤醒
printk_kthread内核线程,由该线程负责将日志输出到所有注册的 console 设备。 - 紧急输出(早期/原子上下文): 在系统启动早期或中断上下文中,无法依赖内核线程。此时 printk 会直接调用
console_unlock()函数,尝试立即将日志推送到 console 设备。
3.2 日志缓冲区(__log_buf)结构与管理
日志缓冲区是 printk 机制的数据核心。它是一个静态分配的、大小可配置的环形字符数组。内核通过 struct printk_record 来管理缓冲区中的每一条日志记录。
| 组件 | 类型/宏 | 说明 |
|---|---|---|
| 缓冲区基址 | static char __log_buf[__LOG_BUF_LEN] |
静态分配的环形缓冲区,大小由 LOG_BUF_SHIFT 决定(通常为 16~18,即 64KB~256KB) |
| 写入索引 | atomic_long_t log_next_seq |
全局序列号计数器,每写入一条记录递增1,用于日志排序和丢失检测 |
| 读取索引 | u64 clear_seq |
用户空间(如 dmesg)已读取到的最大序列号,用于判断哪些记录是新的 |
| 记录描述符 | struct printk_info |
存储每条记录的元数据:序列号、时间戳(纳秒)、调用者ID、日志级别、facility 等 |
| 记录数据 | struct printk_record |
包含 struct printk_info 和实际文本数据的描述符,指向缓冲区中的具体位置 |
log_buf_len 内核参数设置为较大值(如 4MB),或启用 pstore 持久化存储。
3.3 console 初始化流程
console 的初始化是内核启动过程中一个分阶段的过程。早期 console(如 earlycon)在 MMU 和中断开启前就能工作,而标准 console 驱动则需要等到对应的设备初始化完成后才能注册。
3.3.1 关键数据结构:console_drivers 链表
所有注册的 console 驱动都挂在全局链表 console_drivers 上。每个 console 驱动由 struct console 结构体描述:
struct console {
char name[16]; /* 驱动名称,如 "ttyS", "ttyAMA" */
void (*write)(struct console *, const char *, unsigned);
int (*read)(struct console *, char *, unsigned);
struct tty_driver *(*device)(struct console *, int *);
void (*unblank)(void);
int (*setup)(struct console *, char *);
int (*exit)(struct console *);
short flags; /* CON_ENABLED, CON_CONSDEV, CON_BOOT 等 */
short index; /* 设备编号,如 ttyS0 的 index=0 */
int cflag;
void *data;
struct console *next;
};
3.3.2 初始化阶段划分
| 阶段 | 调用时机 | console 类型 | 特点 |
|---|---|---|---|
| 早期 console | start_kernel() 早期,setup_arch() 之后 | earlycon / early_printk |
不依赖设备模型,直接操作寄存器;用于调试早期崩溃 |
| 引导 console | console_init() 函数中 | CON_BOOT 标志的驱动 |
临时 console,在标准 console 就绪前提供输出;通常由架构代码注册 |
| 标准 console | 设备驱动初始化时(如 serial8250_console_init) | 串口、VGA、虚拟终端等 | 通过 register_console() 注册,成为最终输出设备 |
| 最终切换 | do_initcalls() 阶段 | 首选 console(console= 参数指定) | 非 CON_BOOT 的 console 注册后,引导 console 被自动注销 |
earlycon 和 console=ttyS0,115200 可以同时启用早期和标准 console。如果系统在 console_init() 之前崩溃,earlycon 是唯一能看到最后日志的方法。
3.4 日志级别与控制
每条 printk 记录都带有一个日志级别(loglevel),用于控制消息的严重程度和输出行为。日志级别定义在 <linux/kern_levels.h> 中:
| 宏 | 数值 | 含义 | 典型使用场景 |
|---|---|---|---|
KERN_EMERG |
0 | 紧急事件,系统不可用 | 内核 panic 前的最后消息 |
KERN_ALERT |
1 | 必须立即处理 | 严重硬件故障 |
KERN_CRIT |
2 | 临界条件 | 驱动初始化失败 |
KERN_ERR |
3 | 错误条件 | I/O 错误、内存分配失败 |
KERN_WARNING |
4 | 警告 | 配置不匹配、降级运行 |
KERN_NOTICE |
5 | 正常但重要 | 挂载文件系统、网络接口启动 |
KERN_INFO |
6 | 信息性消息 | 驱动版本、硬件探测结果 |
KERN_DEBUG |
7 | 调试消息 | 函数进入/退出、变量值(需开启 DEBUG) |
内核通过 console_loglevel 变量控制哪些级别的消息会输出到 console。只有日志级别数值 小于 console_loglevel 的消息才会被输出。默认值通常为 7(所有消息),但在生产系统中常设置为 4(仅显示错误及以上)。
loglevel=4 — 设置 console 日志级别为 4(仅显示 KERN_WARNING 及以上)ignore_loglevel — 忽略日志级别,输出所有消息(调试时使用)log_buf_len=4M — 将环形缓冲区扩大到 4MB,防止早期日志被覆盖
3.5 实战:调试早期启动日志丢失问题
在嵌入式开发中,经常遇到“内核启动到一半就挂死,但没有任何日志输出”的情况。以下是基于 printk 机制的排查思路:
- 检查 earlycon 是否启用: 在 bootargs 中添加
earlycon,确认串口驱动在setup_arch()阶段已初始化。对于 ARM64,使用earlycon=uart8250,mmio32,0xfe215000指定寄存器地址。 - 确认 console 参数匹配:
console=ttyS0,115200中的设备名和波特率必须与硬件实际配置一致。不匹配时,标准 console 不会输出任何内容。 - 检查 CONFIG 配置: 确保内核配置了
CONFIG_SERIAL_8250_CONSOLE=y(或对应串口驱动)以及CONFIG_PRINTK=y。 - 使用 JTAG 或硬件断点: 如果 earlycon 也无输出,说明系统在
setup_arch()之前就崩溃了。此时需要 JTAG 调试器直接读取__log_buf内存区域,或使用硬件断点定位崩溃点。 - 启用 pstore: 配置
CONFIG_PSTORE=y和CONFIG_PSTORE_CONSOLE=y,将内核日志保存到持久化存储(如 RAM 保留区),重启后通过pstore文件系统读取。