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() 函数里干了三件大事:
- 创建
kernel_init内核线程 - 创建
kthreadd内核线程(负责管理其他内核线程) - 调用
cpu_idle(),让当前进程变成空闲进程
其中,kernel_init 线程最终会去执行用户空间的 /init 程序。这个 /init 程序,就是所有用户进程的祖先——进程ID为1。
代码路径在 init/main.c 的 kernel_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() 系统调用完成的。
我简化一下流程:
run_init_process()调用do_execve()do_execve()加载ELF格式的/init程序- 设置用户态栈、代码段、数据段
- 调用
start_thread()修改CPU的寄存器 - 执行
ret_fast_syscall或ret_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等核心服务。不过那是下一章的内容了。
我个人建议,如果你有时间,可以自己编译一个最小内核,加上 earlyprintk 和 initcall_debug 启动参数,亲眼看看这些初始化函数的调用顺序。纸上得来终觉浅,对吧?