内核解压与入口:vmlinuz解压、start_kernel函数入口,早期串口调试环境的搭建
说实话,很多工程师第一次接触内核启动时,都被vmlinuz这个文件搞晕过。它到底是个什么格式?解压过程发生在哪里?早期调试又该怎么搞?
我个人习惯把这一阶段称为「黎明前的黑暗」。为什么?因为此时内存管理还没初始化,中断还没打开,连个打印函数都不可靠。你想想看,这时候出问题,基本就是两眼一抹黑。
好,咱们一步步来拆解。
vmlinuz:压缩的内核镜像
vmlinuz本质上是一个自解压的压缩包。它包含了两部分:
- 解压代码(decompressor):一段很小的汇编 + C代码,负责解压真正的内核
- 压缩后的内核(vmlinux.bin.gz / .xz / .lz4):真正的内核二进制,被压缩了
为什么非要压缩?我见过一个实际案例:某嵌入式设备Flash只有8MB,未压缩的内核6MB,压缩后2.5MB。不压缩根本放不下。所以压缩不是炫技,是刚需。
关键点:vmlinuz ≠ vmlinux。vmlinux是未压缩的ELF格式,vmlinuz是压缩后的自解压镜像。
解压流程:从汇编到C
解压过程大致分三步:
- arch/x86/boot/header.S:引导加载程序(GRUB/U-Boot)跳到这里,执行实模式代码
- 切换到保护模式:开启A20地址线,设置GDT,跳入32位模式
- 调用decompress_kernel():真正的解压函数,位于lib/decompress_*.c
代码路径大致是这样:
// 实模式入口
arch/x86/boot/header.S -> _start
// 切换到保护模式后
arch/x86/boot/pmjump.S -> protected_mode_jump
// 解压入口
arch/x86/boot/compressed/head_64.S -> decompress_kernel()
// 解压完成后跳转到
arch/x86/kernel/head_64.S -> startup_64
嗯,这里要注意:解压阶段是没有内存管理的。所有内存访问都是直接物理地址。所以一旦解压代码有bug,系统直接挂掉,连个错误信息都看不到。
start_kernel:C语言的起点
解压完成后,内核跳转到start_kernel()。这个函数在init/main.c里,是内核初始化C代码的入口。
我刚开始看这个函数时,被它的长度吓到了——好几百行,调用了上百个初始化函数。但别慌,它的逻辑其实很清晰:
| 顺序 | 函数 | 作用 |
|---|---|---|
| 1 | setup_arch() | 架构相关初始化,解析启动参数 |
| 2 | trap_init() | 设置中断向量表 |
| 3 | mm_init() | 内存管理初始化 |
| 4 | sched_init() | 调度器初始化 |
| 5 | console_init() | 控制台初始化,终于能打印了 |
| ... | ... | ... |
| 最后 | rest_init() | 创建init进程,进入用户态 |
注意console_init()的位置。在此之前,printk的输出是看不到的。它们被暂存在一个环形缓冲区里,等控制台初始化完成后才一起输出。
我曾经踩过的坑:在console_init()之前调用printk调试,结果什么也看不到。折腾了半天才发现输出被缓存了。后来我学乖了,早期调试一律用串口直接写寄存器。
早期串口调试环境的搭建
说到串口调试,这是内核启动早期最可靠的调试手段。没有之一。
为什么可靠?因为串口驱动极其简单——说白了就是操作几个寄存器。不需要中断,不需要DMA,不需要内存管理。只要CPU能执行指令,串口就能工作。
搭建方法分两步:
1. 内核配置开启早期串口
Kernel hacking --->
[*] Early printk
[*] Early printk via serial port
(0x3f8) I/O port for early serial (0x3f8=COM1)
(115200) Baud rate for early serial
这里0x3f8是标准COM1的IO端口地址。如果你用虚拟机,通常映射到宿主机的/dev/ttyS0或COM1。
2. 内核启动参数添加
earlyprintk=serial,ttyS0,115200 console=ttyS0,115200
earlyprintk负责早期输出,console负责后续标准输出。两者可以共存。
我的调试小技巧:如果板子没有物理串口,可以用USB转串口线。但要注意——USB转串口在早期启动时不可用!因为USB控制器需要初始化。所以真正的早期调试,必须用板载的UART。
实战:手写一个早期串口输出函数
有时候内核自带的early printk不够用,比如你想在解压阶段加调试信息。这时候就得自己写一个。
以x86为例,串口寄存器操作就几个:
// 直接操作IO端口
#define COM1_PORT 0x3f8
// 检查发送缓冲区是否为空
static int serial_tx_empty(void)
{
return inb(COM1_PORT + 5) & 0x20;
}
// 发送一个字符
static void serial_putc(char c)
{
// 等待发送缓冲区空
while (!serial_tx_empty())
cpu_relax();
// 发送字符
outb(c, COM1_PORT);
}
// 发送字符串
void early_serial_puts(const char *s)
{
while (*s) {
if (*s == '\n')
serial_putc('\r'); // 串口需要\r\n
serial_putc(*s++);
}
}
这段代码可以在任何地方调用——包括解压阶段、start_kernel之前。我曾在head_64.S里插入过这个函数,用来调试一个内存映射的bug。
注意:这段代码没有锁,没有中断保护。因为早期是单核执行,中断还没开。所以没问题。但一旦进入多核阶段,就不能这么干了。
知识体系总览
下面这张图总结了本章的核心逻辑:
说白了,这一章的核心就三件事:解压、入口、调试。解压搞不定,内核都看不到;入口进不去,初始化全白搭;调试搭不好,出问题只能干瞪眼。
我个人建议,做内核移植或调试时,先把早期串口搞定。哪怕其他功能都没跑起来,只要串口能输出,你就有了一双「眼睛」。我见过太多工程师,花了两天时间调一个bug,结果发现只是早期串口没配好,输出被吞了——这种冤枉路,咱们不走。
避坑指南:我曾经在调试某ARM板时,发现earlyprintk死活不输出。查了两天才发现——那个板子的UART时钟没初始化,串口根本不在工作频率上。所以,早期串口调试的第一步,永远是确认UART时钟和波特率设置正确。
公众号:蓝海资料掘金营,微信deep3321