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 内核启动流程图
下面这张图,是我自己画的内核启动核心路径。你可以把它当成一个“导航图”,遇到启动问题,先看看卡在哪一步。
4.4 init 进程启动:从内核态到用户态
start_kernel() 的最后,会调用 arch_call_rest_init(),进而创建第一个内核线程 kernel_init。这个线程会:
- 尝试执行
ramdisk_execute_command(通常是/init) - 如果失败,依次尝试
/sbin/init、/etc/init、/bin/init、/bin/sh - 如果都失败,内核 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 一看,一目了然。