4、Linux内核启动:内核解压与初始化,进程1(init进程)的诞生,内核态与用户态的切换点

好,我们接着往下走。上一章我们聊完了Bootloader,它把内核镜像从存储介质里读出来,放到了内存里。现在,轮到Linux内核登场了。

说实话,内核启动这部分,我当年刚接触时也觉得挺神秘的。一堆汇编代码,一堆C函数,怎么就突然变成一个能跑app的系统了?今天我们就把它拆开看看。

4.1 内核解压:从压缩包到可执行体

你想想看,Bootloader加载到内存里的内核镜像,其实是个压缩包。为什么?因为内核太大了,不压缩的话,光是读盘就要花不少时间。我见过一些低端设备,光解压这一步就能占掉整个启动时间的10%。

内核镜像通常有两种格式:zImage(老式,小于512KB)和uImage(带U-Boot头)。但不管哪种,开头都有一段自解压代码。

解压的入口点,一般在 arch/arm/boot/compressed/head.S(ARM架构为例)。这段汇编代码干的事很纯粹:

// 伪代码示意,非完整指令
1. 设置栈指针,为C语言环境做准备
2. 调用 decompress_kernel() 函数
3. 跳转到解压后的内核入口

这里有个细节我提一下。解压时,内核会把自身从内存的低地址搬到高地址,避免解压过程中把自己覆盖掉。嗯,这个坑我踩过——有一次调试一个自定义内核,解压地址算错了,结果解压到一半,代码把自己给踩了,直接死机。

关键点:解压完成后,控制权从自解压代码交到真正的内核入口 start_kernel()。这是从汇编到C语言的第一次切换。

4.2 内核初始化:start_kernel() 的使命

start_kernel()init/main.c 里,它是内核初始化的C语言入口。我建议你把这个函数当成一张地图,它调用的每个子函数,都对应着内核的一个子系统。

它的执行顺序大致如下:

顺序 函数 作用
1 setup_arch() 解析机器类型、设置页表、初始化内存布局
2 trap_init() 设置中断向量表
3 mm_init() 内存管理子系统初始化
4 sched_init() 进程调度器初始化
5 init_IRQ() 中断控制器初始化
6 console_init() 控制台初始化,从此能看到打印信息
7 fork_init() 创建进程所需的数据结构
8 rest_init() 创建内核线程,启动init进程

你看,这个顺序是有讲究的。先有内存,再有调度器,然后才能创建进程。我曾经在调试一个内存碎片问题时,就是卡在 mm_init() 之后,发现伙伴系统分配器没初始化好,导致后面所有内存申请都失败。

个人经验:如果你在启动日志里看到 Kernel panic - not syncing,别慌。先看它是在哪个函数之后挂掉的。比如在 console_init() 之前挂掉,你连日志都看不到,那才叫真头疼。

4.3 进程1(init进程)的诞生

好,重点来了。rest_init() 函数里干了三件大事:

  1. 创建 kernel_init 内核线程
  2. 创建 kthreadd 内核线程(负责管理其他内核线程)
  3. 调用 cpu_idle(),让当前进程变成空闲进程

其中,kernel_init 线程最终会去执行用户空间的 /init 程序。这个 /init 程序,就是所有用户进程的祖先——进程ID为1。

代码路径在 init/main.ckernel_init() 函数里:

static int __ref kernel_init(void *unused)
{
    // ... 省略一些准备工作 ...
    
    // 尝试执行用户空间的 init 程序
    if (ramdisk_execute_command) {
        ret = run_init_process(ramdisk_execute_command);
        if (!ret)
            return 0;
        pr_err("Failed to execute %s (error %d)\n",
               ramdisk_execute_command, ret);
    }

    // 默认路径
    if (!try_to_run_init_process("/sbin/init") ||
        !try_to_run_init_process("/etc/init")  ||
        !try_to_run_init_process("/bin/init")  ||
        !try_to_run_init_process("/bin/sh"))
        return 0;

    panic("No working init found.  Try passing init= option to kernel.");
}

看到了吗?内核会依次尝试 /sbin/init/etc/init/bin/init,最后实在不行就尝试 /bin/sh。如果都找不到,就 panic 了。

注意:在Android系统里,这个 /init 程序不是传统的SysV init,而是Android自己写的 /init 守护进程。它负责解析 init.rc 文件,启动各种服务。如果你在Android启动时看到 init: starting service 'adbd' 之类的日志,那就是它在工作。

4.4 内核态与用户态的切换点

这是整个启动流程里最微妙的一个环节。说白了,就是CPU从「最高权限模式」切换到「受限模式」。

在ARM架构里,CPU上电后默认运行在SVC模式(超级管理员模式)。内核初始化完成后,需要切换到用户模式(USR模式)去执行 /init 程序。

这个切换点发生在 kernel_init() 调用 run_init_process() 时。具体来说,是通过 do_execve() 系统调用完成的。

我简化一下流程:

  1. run_init_process() 调用 do_execve()
  2. do_execve() 加载ELF格式的 /init 程序
  3. 设置用户态栈、代码段、数据段
  4. 调用 start_thread() 修改CPU的寄存器
  5. 执行 ret_fast_syscallret_to_user 返回用户空间

关键就在第4步。start_thread() 会把程序计数器(PC)设置为 /init 程序的入口地址,同时把CPU模式位从SVC改为USR。当CPU执行完异常返回指令后,它就已经在用户态了。

切换的本质:内核态到用户态的切换,其实就是修改了CPU的 CPSR(当前程序状态寄存器)中的模式位。从0x13(SVC模式)变成0x10(USR模式)。同时,栈指针也从内核栈切换到了用户栈。

嗯,这里有个容易混淆的地方。很多人以为「切换」是像开关一样啪的一下就变了。其实不是。它是通过一个「异常返回」的机制完成的。CPU执行了一条类似 movs pc, lr 的指令,这条指令会同时恢复PC和CPSR。说白了,就是假装我们刚从一次系统调用中返回到用户空间。

我当初调试一个自定义init程序时,就遇到过一个问题:init程序一启动就段错误。后来发现是用户栈指针没设置对,导致 /init 程序一运行就压栈压到了非法地址。这个坑,我记了好几年。

4.5 小结

我们来捋一下这条线:

  • 内核解压:从压缩镜像中释放出原始内核
  • 内核初始化:start_kernel() 依次初始化内存、调度器、中断等子系统
  • 进程1诞生:rest_init() 创建 kernel_init 线程,最终执行 /init
  • 切换点:通过 do_execve() 和异常返回机制,从内核态进入用户态

到了这一步,Linux内核的启动就基本完成了。接下来,就是用户空间的 /init 程序接管一切。在Android里,这个 /init 会解析 init.rc,启动Zygote、SurfaceFlinger等核心服务。不过那是下一章的内容了。

我个人建议,如果你有时间,可以自己编译一个最小内核,加上 earlyprintkinitcall_debug 启动参数,亲眼看看这些初始化函数的调用顺序。纸上得来终觉浅,对吧?