4. 内核编译流程与产物分析

内核启动这部分,说实话,是很多驱动开发者的盲区。大家往往只关注驱动本身的代码逻辑,却忽略了内核是怎么“活”起来的。我个人觉得,搞懂启动流程,对定位那些“莫名其妙”的驱动加载失败问题,特别有帮助。

咱们今天就把内核从压缩包到用户态 init 进程的整个路径,捋一遍。

4.1 内核解压:第一行代码之前

你想想看,BIOS/UEFI 把内核镜像加载到内存后,内核其实还是一坨压缩数据。谁把它解开的?

答案是 解压桩代码(decompression stub)。

对于 x86 架构,入口在 arch/x86/boot/compressed/head_64.S。这段汇编代码干了几件事:

  • 设置基本的段寄存器和栈
  • 调用 decompress_kernel() 函数
  • 跳转到解压后的内核入口 startup_64

关键点:解压阶段还没有虚拟内存,所有操作都在物理地址空间。如果这时候出问题,你连 dmesg 都看不到,只能靠串口或硬件调试器。

我记得有一次,客户报告说内核在某个定制板卡上启动到一半就挂了。我排查了半天,最后发现是解压阶段的栈指针没对齐,导致解压时数据错乱。嗯,这种问题,光看日志是看不出来的。

4.2 start_kernel:内核的“构造函数”

解压完成后,控制权交给 start_kernel()。这个函数在 init/main.c 里,可以说是内核的 main 函数。它负责把内核从“裸机状态”初始化成一个可以运行进程的操作系统。

它的执行流程,我习惯分成三个阶段:

阶段 关键函数 干了什么
早期初始化 setup_arch(), trap_init(), mm_init() 解析 DTB/ACPI,设置中断向量表,建立页表
核心子系统 sched_init(), vfs_caches_init() 初始化调度器、虚拟文件系统、网络协议栈
驱动与设备 do_initcalls() 按优先级调用所有模块的初始化函数

这里有个细节,do_initcalls() 会按照 initcall_levels 的顺序执行:

// include/linux/init.h
#define pure_initcall(fn)       __define_initcall(fn, 0)
#define core_initcall(fn)       __define_initcall(fn, 1)
#define postcore_initcall(fn)   __define_initcall(fn, 2)
#define arch_initcall(fn)       __define_initcall(fn, 3)
#define subsys_initcall(fn)     __define_initcall(fn, 4)
#define fs_initcall(fn)         __define_initcall(fn, 5)
#define device_initcall(fn)     __define_initcall(fn, 6)
#define late_initcall(fn)       __define_initcall(fn, 7)

调试技巧:如果你的驱动在 module_init() 里注册,它属于 device_initcall 级别。如果依赖某个子系统(比如 GPIO),而那个子系统是 postcore_initcall,那就没问题。反过来,如果你的驱动在 arch_initcall 里调用了 kmalloc(),而内存管理还没初始化好,那就等着崩溃吧。

我曾经在调试一个 SPI 驱动时,发现它总是在 spi_register_master() 里卡死。后来加了一堆 printk 才定位到,是 SPI 核心层本身还没初始化完成。说白了,就是 initcall 的优先级没搞对。

4.3 内核启动流程图

下面这张图,是我自己画的内核启动核心路径。你可以把它当成一个“导航图”,遇到启动问题,先看看卡在哪一步。

Linux 内核启动核心流程 BIOS/UEFI → 引导加载器 内核解压 (decompress_kernel) start_kernel() setup_arch() trap_init() / mm_init() sched_init() do_initcalls() vfs_caches_init() kernel_init() → init 进程 用户态 /sbin/init 说明:红色节点为软件入口,绿色为硬件/内存初始化,紫色为子系统,橙色为用户态过渡

4.4 init 进程启动:从内核态到用户态

start_kernel() 的最后,会调用 arch_call_rest_init(),进而创建第一个内核线程 kernel_init。这个线程会:

  1. 尝试执行 ramdisk_execute_command(通常是 /init)
  2. 如果失败,依次尝试 /sbin/init、/etc/init、/bin/init、/bin/sh
  3. 如果都失败,内核 panic

注意:如果你的根文件系统里没有 /sbin/init,或者 init 二进制文件依赖的共享库缺失,内核会卡在 Kernel panic - not syncing: No init found。这时候别慌,检查一下 root= 启动参数和文件系统内容。

我建议你在调试早期启动时,在 kernel_init() 函数里加几个 pr_info,看看它到底执行到哪一步了。比如:

static int __ref kernel_init(void *unused)
{
    pr_info("kernel_init: starting init process\n");
    // ... 省略中间代码 ...
    if (ramdisk_execute_command) {
        pr_info("kernel_init: trying /init\n");
        ret = run_init_process(ramdisk_execute_command);
    }
    pr_info("kernel_init: trying /sbin/init\n");
    ret = run_init_process("/sbin/init");
    // ...
}

4.5 内核日志分析:dmesg 的正确打开方式

启动完成后,dmesg 是你最好的朋友。但很多人只会 dmesg | grep,其实有很多技巧。

4.5.1 日志等级

内核日志有 8 个等级,从 KERN_EMERG 到 KERN_DEBUG。默认情况下,控制台只显示 KERN_INFO 及以上级别的日志。如果你想看到更详细的信息:

# 调整控制台日志级别,显示所有日志
echo 8 > /proc/sys/kernel/printk

# 或者启动时加参数
loglevel=8

4.5.2 时间戳分析

我习惯用 dmesg -T 显示人类可读的时间戳,然后看每条日志之间的时间间隔。如果某两条日志之间间隔了 5 秒,那大概率是某个驱动初始化卡住了。

# 显示时间戳并高亮关键信息
dmesg -T | grep -E "initcall|driver|error|fail"

4.5.3 常见启动问题定位

日志特征 可能原因 排查方向
卡在 Uncompressing Linux... 解压阶段崩溃 检查内存布局、解压桩代码
Kernel panic - not syncing VFS 无法挂载根文件系统 检查 root= 参数、文件系统类型
initcall xxxx returned 0 后无输出 某个 initcall 死循环 加 initcall_debug 参数定位
Failed to execute /init init 程序不存在或格式错误 检查文件系统内容、动态库依赖

我的习惯:在开发板上调试时,我会在 /proc/cmdline 里加上 ignore_loglevel 和 earlyprintk。这样即使串口驱动还没初始化,也能看到早期启动日志。嗯,这招帮我省了不少时间。

4.6 实战:用 initcall_debug 定位驱动加载顺序

假设你的驱动在 module_init 里注册了一个平台驱动,但 probe 函数从来没被调用过。你怀疑是某个依赖的子系统还没准备好。

加启动参数 initcall_debug,然后看 dmesg:

# dmesg | grep initcall
[    0.123456] initcall my_driver_init+0x0/0x100 returned 0 after 123 usecs
[    0.123789] initcall gpio_init+0x0/0x200 returned 0 after 456 usecs

你看,my_driver_init 在 gpio_init 之前就执行了。如果你的驱动在 init 函数里就调用了 GPIO 子系统,那肯定失败。解决方案?把驱动改成 late_initcall,或者用 deferred_probe 机制。

我曾经在一个项目里,就是因为没注意 initcall 的顺序,导致驱动 probe 总是返回 -EPROBE_DEFER。后来加上 initcall_debug 一看,一目了然。