第四章: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());
}

我个人习惯把调度器初始化分成三步理解:

  1. 数据结构准备 — 每个CPU一个运行队列,里面分CFS、RT、DL三个子队列
  2. 空闲线程创建 — 这是系统第一个线程,PID为0
  3. 时钟中断绑定 — 调度器依赖时钟中断来触发时间片切换

你可能会问:「这时候还没有进程,调度器初始化了有什么用?」其实调度器初始化后,系统就有了最基本的任务管理能力。后续创建的内核线程,比如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(只读)

内核会按照以下顺序尝试挂载:

  1. 如果内核编译时指定了initramfs,会先挂载内存中的临时根文件系统
  2. 然后根据cmdline参数,挂载真正的根文件系统
  3. 如果挂载失败,内核会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系统拉起来的。