Read OSS

从上电到 PID 1:Linux 内核的启动过程

中级

前置知识

  • 第 1 篇:架构与目录结构导览
  • 具备 C 函数指针与链接器 section 的基本理解

从上电到 PID 1:Linux 内核的启动过程

每一个正在运行的 Linux 系统,都曾经是刚通电的 CPU 上执行的一条指令。从那条指令到 login: 提示符之间,是一段精密的初始化序列——约 80 个严格有序的函数调用、一套跨越八个优先级的自注册驱动机制,以及系统中最重要的两个进程的诞生。理解这条启动路径,不仅能让你知道 Linux 是怎么启动的,更能让你明白它的初始化架构为何能在没有中央注册表的情况下支撑数以千计的驱动程序。

正如第 1 篇所介绍的,内核源码分为架构相关层和可移植层。启动序列正是这两层的交汇点:架构相关的汇编代码完成硬件初始化,随后将控制权移交给可移植的 C 代码,逐一将每个子系统启动上线。

从架构入口到 start_kernel()

x86-64 系统启动时,bootloader(GRUB、systemd-boot 等)将压缩的内核镜像加载到内存并跳转到其入口点。内核随即完成自解压、建立初步的页表,最终进入架构相关的启动代码,为执行 C 代码做好环境准备。

关键的跳转目标是 start_kernel()——位于 init/main.c 的 C 语言入口函数。在此之前,汇编代码已经完成了以下工作:

  1. 建立有效的内核栈
  2. 启用基本分页
  3. 将 BSS 段清零
  4. 初始化 GDT(全局描述符表)
flowchart LR
    A["Bootloader<br/>(GRUB)"] --> B["Decompress<br/>kernel"]
    B --> C["Arch-specific<br/>assembly setup"]
    C --> D["start_kernel()<br/>in init/main.c"]
    D --> E["rest_init()<br/>creates PID 1 & 2"]

start_kernel() 的函数签名本身就传递了重要信息——它被多个属性修饰:

init/main.c#L1007-L1008

asmlinkage __visible __init __no_sanitize_address __noreturn __no_stack_protector
void start_kernel(void)

其中 __init 至关重要——它将该函数放入 .init.text section,启动完成后这段内存会被释放。asmlinkage 告诉编译器此函数由汇编调用,参数通过栈传递。__noreturn 则如字面意思:这个函数永远不会返回。

逐步解析 start_kernel()

start_kernel() 的函数体是一段约 80 个初始化调用的线性序列,顺序并非随意——每个调用都依赖于此前已初始化的子系统。

init/main.c#L1008-L1211

按逻辑阶段划分如下:

sequenceDiagram
    participant SK as start_kernel()
    participant HW as Hardware Setup
    participant MM as Memory Mgmt
    participant SCHED as Scheduler
    participant IRQ as Interrupts/Timers
    participant VFS as VFS & Processes

    SK->>HW: set_task_stack_end_magic()
    SK->>HW: setup_arch(&command_line)
    SK->>MM: mm_core_init()
    SK->>SCHED: sched_init()
    SK->>IRQ: init_IRQ(), timers_init()
    SK->>IRQ: hrtimers_init(), softirq_init()
    SK->>VFS: vfs_caches_init()
    SK->>VFS: fork_init(), signals_init()
    SK->>SK: rest_init()

阶段一:早期硬件初始化 —— setup_arch() 是最重要的架构相关调用。在 x86 上,它负责识别 CPU、解析 BIOS/UEFI 的内存映射,并建立初始内存布局。在此之前的所有代码都只使用最基础的架构无关逻辑。

阶段二:内存管理 —— mm_core_init() 启动页分配器、slab 分配器和 vmalloc。完成之后,内核便可以使用 kmalloc() 了。

阶段三:调度器 —— sched_init() 初始化每 CPU 的运行队列并创建 idle 任务。调度器在此之后即可工作,尽管 SMP 尚未启用。

阶段四:中断与定时器 —— init_IRQ()tick_init()timers_init()hrtimers_init()softirq_init() 将中断和定时器子系统全部上线。在第 1138 行调用 local_irq_enable() 之后,中断正式开始运行。

阶段五:核心子系统 —— vfs_caches_init() 创建 dentry 和 inode 缓存,fork_init() 完成进程创建机制的初始化,此外还有 signals_init()proc_root_init() 等数十个调用。

最后一个调用是 rest_init()——名字听起来平平无奇,但真正重要的事情恰恰在这里发生。

提示: 如果你在排查启动卡死的问题,可以在内核命令行中加上 initcall_debug。它会为每个初始化调用打上时间戳,一眼就能看出卡在哪里。

Initcall 机制

在了解 rest_init() 如何创建第一批进程之前,我们需要先理解这些进程用来初始化驱动的机制——initcall 系统

问题的根源在于:内核有数以千计的驱动、文件系统和子系统需要初始化。如果用一个集中式的列表来管理,将难以维护。为此,每个子系统只需声明自己的初始化函数,并通过宏将其自动注册到某个链接器 section 中。

include/linux/init.h#L293-L309

#define pure_initcall(fn)           __define_initcall(fn, 0)
#define core_initcall(fn)           __define_initcall(fn, 1)
#define postcore_initcall(fn)       __define_initcall(fn, 2)
#define arch_initcall(fn)           __define_initcall(fn, 3)
#define subsys_initcall(fn)         __define_initcall(fn, 4)
#define fs_initcall(fn)             __define_initcall(fn, 5)
#define rootfs_initcall(fn)         __define_initcall(fn, rootfs)
#define device_initcall(fn)         __define_initcall(fn, 6)
#define late_initcall(fn)           __define_initcall(fn, 7)

每个级别都有对应的 _sync 变体(如 core_initcall_sync),作为屏障使用——上一级别的所有 initcall 必须全部完成,sync 调用才会执行。

级别 名称 典型使用者
0 pure_initcall 仅用于静态变量初始化
1 core_initcall 核心内核基础设施(IRQ、DMA)
2 postcore_initcall 总线类型(PCI、USB 总线注册)
3 arch_initcall 架构相关初始化
4 subsys_initcall 子系统初始化(网络、块层)
5 fs_initcall 文件系统注册
rootfs rootfs_initcall 根文件系统设置
6 device_initcall 大多数设备驱动(即默认的 module_init
7 late_initcall 依赖所有其他子系统的初始化

__define_initcall 宏会将函数指针放入形如 .initcall1.init 的命名链接器 section。链接器脚本按数字顺序排列这些 section,从而形成一个按优先级排序的函数指针数组——整个过程无需任何驱动了解其他驱动的存在。

PID 1 与 PID 2 的诞生

rest_init() 创建了内核的前两个进程:

init/main.c#L714-L743

static noinline void __ref __noreturn rest_init(void)
{
    struct task_struct *tsk;
    int pid;

    rcu_scheduler_starting();
    /*
     * We need to spawn init first so that it obtains pid 1, however
     * the init task will end up wanting to create kthreads, which, if
     * we schedule it before we create kthreadd, will OOPS.
     */
    pid = user_mode_thread(kernel_init, NULL, CLONE_FS);
    ...
    pid = kernel_thread(kthreadd, NULL, NULL, CLONE_FS | CLONE_FILES);
    ...
}

PID 1kernel_init)是所有用户空间进程的祖先。PID 2kthreadd)是内核线程守护进程——系统中所有内核线程最终都由 kthreadd 创建。

注释中解释了一个微妙的顺序约束:PID 1 必须先创建(以确保获得 pid 1),但在 kthreadd(PID 2)存在之前,它不能被调度执行——因为 kernel_init 在初始化过程中需要创建内核线程。

flowchart TD
    rest_init["rest_init()"] --> pid1["PID 1: kernel_init<br/>(user_mode_thread)"]
    rest_init --> pid2["PID 2: kthreadd<br/>(kernel_thread)"]
    pid1 --> kif["kernel_init_freeable()"]
    kif --> dbs["do_basic_setup()"]
    dbs --> di["driver_init()"]
    dbs --> dic["do_initcalls()"]
    dic --> lvl0["Level 0: pure"]
    dic --> lvl1["Level 1: core"]
    dic --> lvl6["..."]
    dic --> lvl7["Level 7: late"]
    kif --> sinit["Search for /sbin/init"]
    pid2 --> kt["Manages all<br/>kernel threads"]

kernel_init 函数会调用 kernel_init_freeable(),后者再调用 do_basic_setup()

init/main.c#L1473-L1480

static void __init do_basic_setup(void)
{
    cpuset_init_smp();
    driver_init();
    init_irq_proc();
    do_ctors();
    do_initcalls();
}

do_initcalls() 则依次遍历全部八个优先级:

init/main.c#L1447-L1464

static void __init do_initcalls(void)
{
    int level;
    ...
    for (level = 0; level < ARRAY_SIZE(initcall_levels) - 1; level++) {
        strcpy(command_line, saved_command_line);
        do_initcall_level(level, command_line);
    }
    ...
}

就在这一刻,所有编译进内核的驱动、文件系统和子系统完成初始化——不依赖任何显式注册,而是通过遍历链接器 section 中的函数指针来实现。

寻找 /sbin/init

所有 initcall 执行完毕后,kernel_init 通过 exec() 一个 init 程序,完成从内核空间到用户空间的跨越:

init/main.c#L1573-L1646

查找顺序如下:

  1. ramdisk_execute_command —— 通常是 initramfs 中的 /init
  2. execute_command —— 内核命令行中通过 init= 指定的路径
  3. CONFIG_DEFAULT_INIT —— 编译时指定的默认值
  4. /sbin/init/etc/init/bin/init/bin/sh —— 依次回退查找

如果全部失败:panic("No working init found.")

这是内核初始化与用户空间之间的分界线。一旦 kernel_execve() 成功,PID 1 就变成了一个用户空间进程(systemd、OpenRC,或你使用的任何 init 系统)。内核此后的职责是处理系统调用、管理内存和调度进程。

__init 与内存回收

我们讨论过的所有内容——start_kernel()、initcall 函数、init 查找逻辑——都带有 __init 注解:

include/linux/init.h#L45-L48

#define __init      __section(".init.text") __cold __latent_entropy __no_kstack_erase
#define __initdata  __section(".init.data")
#define __initconst __section(".init.rodata")

__init 将函数放入 .init.text__initdata 将数据放入 .init.data。启动完成后,free_initmem() 会将这些页面归还给内存分配器。在典型系统上,这可以回收数兆字节的 RAM——这些内存只在启动期间才会用到。

init/main.c#L1568-L1571

void __weak free_initmem(void)
{
    free_initmem_default(POISON_FREE_INITMEM);
}

启动时你可能见过类似 Freeing unused kernel memory: 2048K 的日志,那正是 free_initmem() 在工作。__init 模式非常重要,构建系统会主动检查"section 不匹配"的问题——非 init 代码引用 init 代码,本质上是一种 use-after-free 漏洞。

提示: 如果你编写的内核模块中有只在初始化阶段使用的代码,请用 __init 标记。但要注意:可加载模块可以在任意时刻加载和卸载,因此模块中的 __init 代码在 module_init() 执行后即被释放,而不是等到系统启动完成。

下一篇

至此,我们已经完整追踪了内核从汇编入口到 PID 1 诞生的全过程。下一篇文章将深入调度器——也就是 start_kernel() 中那个 sched_init() 调用背后的子系统。我们将探索 sched_class 虚表模式如何支撑六种可插拔调度策略、__schedule() 如何选择下一个任务,以及全新的 sched_ext 框架如何让你用 BPF 编写调度策略。