3. 内核启动流程概览:从引导加载程序到内核入口、早期启动阶段划分、串口初始化时机

在嵌入式 Linux 系统调试中,串口输出是开发者在黑暗森林中唯一的火把。然而,串口并非系统上电后立即可用。理解从引导加载程序(Bootloader)到内核入口的完整启动流程,以及串口初始化在其中的精确位置,是捕获早期调试信息的关键前提。

本章将系统性地拆解内核启动的各个阶段,明确每个阶段串口硬件的状态,并指出开发者必须在哪个时间窗口内完成串口配置,才能不错失最宝贵的早期日志。

3.1 从 Bootloader 到内核入口:完整的启动链

嵌入式系统的启动是一个接力过程,每个阶段都负责初始化下一阶段所需的硬件环境。下图展示了从 SoC 复位到内核入口的完整启动链:

嵌入式 Linux 启动链概览 嵌入式 Linux 启动链:从复位到内核入口 ROM Bootloader (固化在 SoC 内部) SPL / Pre-loader (初始化 DDR & 时钟) U-Boot (主 Bootloader) (加载内核到 DDR) Linux 内核入口 (stext / start_kernel) 关键说明: • ROM Bootloader:SoC 上电后第一条指令执行处,通常不支持串口输出或仅输出极简字符。 • SPL/Pre-loader:初始化 DDR 控制器和 PLL,此时串口控制器可能尚未配置。 • U-Boot:完整的 Bootloader,串口驱动已就绪,可输出大量调试信息。 • 内核入口:跳转到内核映像的起始地址,开始执行汇编级初始化。

从上图可以看出,串口在 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() 函数的调用链中。下图展示了与串口初始化相关的关键函数调用顺序:

start_kernel() 中串口初始化调用链 start_kernel() 串口初始化关键路径 start_kernel() setup_arch() console_init() do_initcalls() 解析 DTB / ATAG 初始化内存 & 外设映射 注册 console 驱动框架 绑定 early console 执行所有 initcall 函数 串口驱动 probe 在此触发 串口初始化时机详解: 1. setup_arch():解析设备树,获取串口寄存器地址和中断号,但尚未初始化硬件。 2. console_init():注册 console 框架,如果配置了 early console,此时会绑定一个简单的 输出函数(如 printch),允许在 MMU 开启前输出字符。 3. do_initcalls():执行所有模块的初始化函数。串口驱动(如 8250、PL011)的 probe 函数在此阶段被调用,完成硬件寄存器配置,之后 printk 输出才能正常显示。

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 实践:如何验证串口初始化时机

在目标板上,可以通过以下方法验证串口初始化的实际时机:

  1. 在 U-Boot 中设置 bootargs: 添加 earlyprintk 或 earlycon 参数,启用早期控制台。
  2. 在 start_kernel() 开头添加自定义输出: 使用 early_printk()(如果架构支持)或直接操作串口寄存器。
  3. 观察串口输出时间戳: 使用逻辑分析仪或示波器捕获串口 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 初始化阶段崩溃,你仍然能看到最后一条输出信息,从而定位崩溃点。