第四章:Linux内核启动 — 解压内核、初始化MMU、调度器、中断子系统,挂载根文件系统
好,咱们继续往下走。上一章我们聊到bootloader把内核镜像加载到了内存里,然后跳过去执行。但这时候的内核,其实还是压缩过的。你想想看,一个几十兆的内核镜像,里面塞满了驱动、调度器、内存管理代码,不压缩的话,烧录都费劲。
所以内核做的第一件事,就是把自己解压开。
4.1 内核自解压:从压缩包到可执行体
内核镜像的开头,其实是一段自解压代码。这段代码是用汇编写的,非常精简。它的任务就一个:把后面的压缩数据解压到内存的另一个位置,然后跳过去。
我记得第一次看这段代码时,心里想的是:「就这么几行汇编,就能把gzip解压了?」后来仔细一读,发现它用的是decompress_kernel()这个函数,实际解压算法是C语言实现的,汇编只是搭了个架子。
关键点:自解压阶段,MMU还没开启。CPU跑在物理地址上,所有地址都是实打实的物理地址。这时候如果访问了不存在的内存,那就是真正的crash,没有任何保护。
解压完成后,内核会跳转到解压后的入口点,通常是start_kernel()。嗯,从这里开始,才是真正的内核初始化。
4.2 初始化MMU:从物理到虚拟的跨越
start_kernel()一上来,第一件大事就是初始化MMU。为什么这么急?因为内核后续的代码,几乎全部依赖虚拟地址。没有MMU,你连内存管理的基本数据结构都建不起来。
MMU初始化的核心,是建立页表。在ARM64上,通常使用4级页表(PGD、PUD、PMD、PTE)。内核会把整个物理内存映射到虚拟地址空间的高位区域,也就是我们常说的内核空间。
// 页表项的结构(ARM64为例)
// 每个页表项64位,包含物理地址和权限位
typedef struct {
unsigned long pgd; // 页全局目录项
} pgd_t;
// 创建恒等映射(identity mapping)
// 这是MMU开启前的临时映射
void __init create_idmap(void) {
// 映射内核代码段、数据段
// 保证开启MMU后,PC指针还能继续执行
}
这里有个坑,我踩过一次。MMU开启的瞬间,CPU的PC指针还在物理地址上跑。但开启后,所有地址都变成了虚拟地址。如果页表里没有把当前执行位置映射好,CPU直接就飞了。所以内核会先建立一个恒等映射,把物理地址和虚拟地址映射到同一个位置,等切换完成后再删掉。
注意:MMU初始化失败,系统直接hang住,没有任何日志输出。因为串口驱动这时候还没初始化。所以这一步的代码,必须经过严格验证。我在项目里见过一次,因为页表对齐问题,导致MMU开启后系统崩溃,查了两天才找到原因。
4.3 调度器初始化:让多任务跑起来
MMU搞定后,内存管理的基本框架就有了。接下来是调度器。调度器是操作系统的核心,它决定了哪个进程能使用CPU。
Linux的调度器经历过好几个版本。早期是O(1)调度器,后来是CFS(完全公平调度器),现在用的是CFS + EEVDF的混合体。但不管怎么变,初始化流程都差不多。
// 调度器初始化核心函数
void __init sched_init(void) {
// 初始化每个CPU的运行队列
for_each_possible_cpu(cpu) {
struct rq *rq = cpu_rq(cpu);
// 初始化CFS就绪队列
init_cfs_rq(&rq->cfs);
// 初始化实时任务队列
init_rt_rq(&rq->rt);
// 初始化DL(Deadline)任务队列
init_dl_rq(&rq->dl);
}
// 设置空闲线程(idle task)
// 每个CPU都有一个空闲线程,没事干时就跑它
init_idle(current, smp_processor_id());
}
我个人习惯把调度器初始化分成三步理解:
- 数据结构准备 — 每个CPU一个运行队列,里面分CFS、RT、DL三个子队列
- 空闲线程创建 — 这是系统第一个线程,PID为0
- 时钟中断绑定 — 调度器依赖时钟中断来触发时间片切换
你可能会问:「这时候还没有进程,调度器初始化了有什么用?」其实调度器初始化后,系统就有了最基本的任务管理能力。后续创建的内核线程,比如init进程(PID 1),就是通过调度器来管理的。
4.4 中断子系统:让CPU学会响应外部事件
中断子系统,说白了就是CPU对外部事件的响应机制。没有中断,CPU只能轮询,效率极低。
Linux的中断子系统分三层:
| 层级 | 作用 | 我遇到过的坑 |
|---|---|---|
| 中断控制器层 | 管理硬件中断源,如GIC(通用中断控制器) | GIC版本不匹配,导致中断无法触发 |
| 通用中断层 | 提供统一的API,如request_irq() | 中断号映射错误,设备驱动申请不到中断 |
| 中断处理层 | 执行具体的中断处理函数 | 处理函数耗时太长,导致中断丢失 |
初始化时,内核会先检测当前平台用的是哪种中断控制器。ARM平台最常见的是GIC(Generic Interrupt Controller),有GICv2、GICv3等版本。
// GIC初始化示例(简化版)
void __init gic_init(void) {
// 读取GIC的版本号
u32 gic_version = readl(gic_base + GIC_DIST_CTRL);
// 根据版本选择不同的初始化路径
switch (gic_version) {
case 2:
gicv2_init();
break;
case 3:
gicv3_init();
break;
default:
// 不支持的版本,直接panic
panic("Unsupported GIC version");
}
}
小技巧:调试中断问题时,可以在/proc/interrupts里看到每个中断的触发次数。如果某个中断次数不增长,说明要么没触发,要么被屏蔽了。我曾经靠这个文件,定位到一个GPIO中断没配置好的问题。
4.5 挂载根文件系统:从内核态到用户态的桥梁
内核初始化到最后一步,就是挂载根文件系统。这一步完成后,内核才能执行用户空间的程序,比如init、shell、Launcher。
根文件系统的挂载,涉及几个关键参数:
- root= — 指定根设备,比如
/dev/mmcblk0p2 - rootfstype= — 文件系统类型,比如ext4、squashfs
- rootflags= — 挂载选项,比如ro(只读)
内核会按照以下顺序尝试挂载:
- 如果内核编译时指定了initramfs,会先挂载内存中的临时根文件系统
- 然后根据cmdline参数,挂载真正的根文件系统
- 如果挂载失败,内核会panic,打印
VFS: Unable to mount root fs
// 根文件系统挂载的核心逻辑
int __init mount_root(void) {
// 1. 尝试挂载initramfs
if (initramfs_is_setup()) {
populate_rootfs();
return 0;
}
// 2. 根据root=参数挂载
dev_t root_dev = name_to_dev_t(root_device_name);
if (root_dev) {
return mount_block_root(root_dev, root_mountflags);
}
// 3. 如果都失败,打印错误
printk("VFS: Unable to mount root fs on %s\n", root_device_name);
return -EINVAL;
}
避坑指南:我曾经遇到一个问题,内核启动后一直卡在Waiting for root device。查了半天,发现是设备树里指定的根设备路径,和实际分区对不上。后来用cat /proc/partitions对比了一下,才发现是mmcblk0p2和mmcblk1p2搞混了。嗯,这种问题,说白了就是设备树写错了。
根文件系统挂载成功后,内核会执行/init程序(或者/sbin/init)。这时候,控制权就从内核态移交到了用户态。接下来,init进程会启动各种服务,最终把Launcher拉起来。
好,这一章的内容就到这里。总结一下:内核启动经历了自解压、MMU初始化、调度器初始化、中断子系统初始化,最后挂载根文件系统。每一步都环环相扣,缺一不可。下一章,我们会聊聊init进程是如何启动的,以及它是怎么把Android系统拉起来的。