3. 内核启动流程概览:从引导加载程序到内核入口、早期启动阶段划分、串口初始化时机
在嵌入式 Linux 系统调试中,串口输出是开发者在黑暗森林中唯一的火把。然而,串口并非系统上电后立即可用。理解从引导加载程序(Bootloader)到内核入口的完整启动流程,以及串口初始化在其中的精确位置,是捕获早期调试信息的关键前提。
本章将系统性地拆解内核启动的各个阶段,明确每个阶段串口硬件的状态,并指出开发者必须在哪个时间窗口内完成串口配置,才能不错失最宝贵的早期日志。
3.1 从 Bootloader 到内核入口:完整的启动链
嵌入式系统的启动是一个接力过程,每个阶段都负责初始化下一阶段所需的硬件环境。下图展示了从 SoC 复位到内核入口的完整启动链:
从上图可以看出,串口在 U-Boot 阶段 已经可以正常工作。但内核启动早期(从入口到串口驱动初始化完成)存在一个 “静默窗口”,这是调试信息最容易丢失的阶段。
3.2 内核早期启动阶段划分
从内核入口到用户空间 init 进程启动,内核内部经历了多个精心设计的阶段。每个阶段对串口的支持能力不同。下表总结了各阶段的关键特征:
| 阶段编号 | 阶段名称 | 主要执行代码 | 串口状态 | 典型耗时 |
|---|---|---|---|---|
| 1 | 汇编级入口 | arch/arm/kernel/head.S (或类似) | 不可用(无 C 环境) | ~1-5 ms |
| 2 | 早期 C 初始化 | start_kernel() 前半段 | 不可用(MMU 未开启) | ~10-50 ms |
| 3 | MMU 与中断初始化 | setup_arch(), trap_init() | 不可用(外设未映射) | ~20-100 ms |
| 4 | 控制台框架初始化 | console_init() | 框架就绪,驱动未绑定 | ~1-5 ms |
| 5 | 串口驱动初始化 | uart_register_driver() / 设备 probe | 可用(输出开始) | ~5-20 ms |
| 6 | init 进程启动 | kernel_init() | 完全可用 | ~100-500 ms |
调试要点: 阶段 1-3 是串口输出的“真空地带”。如果内核在此阶段发生崩溃(如 MMU 配置错误、未映射的访问),开发者将看不到任何输出。这正是 early console 机制需要介入的地方。
3.3 串口初始化时机:精确到函数调用链
为了更精确地理解串口何时“开口说话”,我们需要深入到 start_kernel() 函数的调用链中。下图展示了与串口初始化相关的关键函数调用顺序:
3.4 早期串口输出的关键窗口分析
基于上述启动流程,我们可以总结出串口输出的三个关键时间窗口:
- 窗口 1:Bootloader 阶段(U-Boot) — 串口已初始化,可输出大量调试信息。这是开发者最熟悉的阶段。
- 窗口 2:内核早期(console_init 之前) — 串口硬件未初始化,
printk()的输出被缓冲在内存中,无法输出。这是调试的“黑洞”。 - 窗口 3:console_init 之后,串口驱动 probe 之前 — 如果启用了
early console,可以通过简单的轮询方式输出字符,但功能受限(无格式化输出、无中断支持)。
常见陷阱: 许多开发者认为在
start_kernel() 中调用 printk() 就能看到输出。实际上,在 console_init() 执行之前,printk() 只是将日志写入一个循环缓冲区(__log_buf),并不会驱动串口硬件。如果系统在 console_init() 之前崩溃,这些日志将永远无法输出。
3.5 实践:如何验证串口初始化时机
在目标板上,可以通过以下方法验证串口初始化的实际时机:
- 在 U-Boot 中设置 bootargs: 添加
earlyprintk或earlycon参数,启用早期控制台。 - 在
start_kernel()开头添加自定义输出: 使用early_printk()(如果架构支持)或直接操作串口寄存器。 - 观察串口输出时间戳: 使用逻辑分析仪或示波器捕获串口 TX 引脚的电平变化,与内核日志的时间戳对比。
// 示例:在 start_kernel() 中尝试早期输出(ARM64 架构)
// 需要提前在汇编阶段映射串口寄存器地址
void __init start_kernel(void)
{
char *p = "Kernel entry at start_kernel()\n";
// 直接操作串口寄存器(假设 UART 基址已映射)
volatile u32 *uart = (u32 *)0x1c090000; // 示例地址
while (*p) {
while (!(uart[5] & (1 << 5))); // 等待 TX FIFO 空
uart[0] = *p++; // 发送字符
}
// ... 后续初始化
}
调试建议: 在开发早期,强烈建议在 U-Boot 和内核中都启用
earlycon。这样,即使内核在 MMU 初始化阶段崩溃,你仍然能看到最后一条输出信息,从而定位崩溃点。