Linux 内核启动全链路深度实战:从 UEFI 到第一个用户进程

不了解内核启动过程,就无法真正理解设备模型、内存子系统、中断系统和驱动程序之间的依赖关系。本文将沿着 CPU 加电后一条完整的执行链路,深入剖析 Linux 内核从固件接手控制权到用户态 init 进程创建的全过程。我们将结合 QEMU 源码调试、固件日志和内核源码追踪,构建一条清晰的启动路径。

一、为什么要研究生启动全过程?

一个典型的 Linux 开发者可能熟悉以下内容:知道 sys_write 如何进入 VFS 层,理解 schedule() 如何选择下一个进程,甚至能分析缺页异常的完整路径。但如果追问"这些子系统在被调用之前是如何初始化的?"很多问题就会暴露出来:

  • 内核为何能访问 __init 段内的初始化函数?这背后是 initcall 机制。
  • 页表是在哪个时刻建立的?在 arch/x86/boot/ 阶段就已经有了初步映射。
  • kmalloc 最早什么时候可用?答案是 mm_init() 之后,但 slab 分配器的初始化本身依赖调度器和中断。
  • initramfs 里的文件如何挂载为根文件系统?prepare_namespace() 中完成。

这些问题的答案全部藏在内核启动的全链路中。掌握它不仅能帮助你在启动阶段排错(比如 init 迟迟无法启动,或 early params 解析失败),更能让你理解各个子系统之间的冷热依赖关系。


二、阶段一:固件层(UEFI)— 平台初始化

2.1 UEFI 启动流程概述

现代 x86 服务器统一采用 UEFI 固件,其启动分为若干阶段:

阶段 名称 运行方式 关键任务
SEC Security Phase CPU 缓存即内存(CAR) 验证固件完整性,建立临时内存
PEI Pre-EFI Initialization 无 DRAM,用缓存模拟内存 初始化内存控制器、training DDR
DXE Driver eXecution Environment 已识别完整 DRAM 加载 UEFI 驱动、枚举 PCIe 设备
BDS Boot Device Selection 运行时 解析 BootOrder 项,加载 OS Loader

SEC 和 PEI 阶段对软件开发者基本不可见,它们把一个"什么都没有"的平台变成一个有完整内存映射的系统调用环境。DXE 阶段则加载 .efi 驱动,其中就包含了我们要用到的 EFI System Partition (ESP) 文件系统驱动和 NVRAM 变量读写驱动。

2.2 UEFI 如何找到 Bootloader

UEFI 规范要求固件在 ESP 分区上查找 \EFI\BOOT\BOOTX64.EFI(x86-64 架构)作为默认启动项。实际生产中,这个路径往往是一个指向具体 OS loader(如 GRUB)的 symlink 或链式加载器。

UEFI 会把内核映像和可选的 initrd 映像读取进来,然后调用 ExitBootServices() 放弃对硬件的控制。在这之后所有 Boot Services 函数都不能再使用,内核获得对硬件的完全控制权。

// UEFI 加载完成后,系统进入 "运行时" 状态。
// 关键数据结构:EFI_SYSTEM_TABLE 和 EFI_BOOT_SERVICES
struct boot_params {
    // ...
    u64 efi_systab;           // EFI 系统表物理地址
    u64 efi_memmap;           // EFI 内存映射表
    u64 efi_memmap_size;
    u64 efi_memdesc_size;
    u32 efi_memdesc_version;
    // ...
};

早期内核启动代码(arch/x86/boot/compressed/eboot.c)会通过 efi 子结构体获取这些信息,并将其转换为内核自己的 e820 内存映射表,这是理解后续内存管理的关键基础。


三、阶段二:Bootloader(GRUB2)— 内核加载与参数传递

3.1 GRUB2 的执行模型

GRUB2 是一个 mini-OS,它有自己的文件系统驱动、LBA 磁盘访问层和命令行分析器。其启动分三步:

  1. boot.img(512 字节)被 BIOS/UEFI 加载到 0x7C00,负责跳转到 diskboot.img
  2. diskboot.img 加载 core.img(包含文件系统驱动)
  3. core.img 解析 grub.cfg,呈现菜单并执行用户选择的条目

对于 UEFI 系统,GRUB 会编译为 \EFI\BOOT\BOOTX64.EFI,由 UEFI 固件直接执行,因此 LBA 模式的前两步可以跳过。

3.2 GRUB2 加载 Linux 内核的关键步骤

当用户选择 GRUB 菜单中的 Linux 条目时,GRUB 执行:

# 伪代码:GRUB 加载 vmlinuz 和 initrd
linux /vmlinuz-6.6.8 root=/dev/nvme0n1p2 console=ttyS0,115200
initrd /initramfs-6.6.8.img
boot

GRUB 会完成以下关键操作:

  • 读取 vmlinuz 文件到内存(地址通常低于 0x100000)
  • 解析 ELF 或 bzImage 格式(对于 bzImage 需要跳转到 0x100006 处的 setup 代码)
  • 填充 struct boot_params(即所谓的 "zero page"),包括:

- 命令行参数(struct boot_params + 0x1E4 偏移处的 cmd_line_ptr)

- initrd 地址与大小(ramdisk_image / ramdisk_size)

- 视频模式参数

- EFI 运行时服务地址(对于 UEFI 启动)

最后跳转到入口地址(对于 bzImage 是 0x200 处的 start_of_setup),开始执行内核 setup 代码。

3.3 bzImage 的内存布局

+---------------------------+ 0x000000
|   实模式中断向量表 IVT    |  1 KB
+---------------------------+ 0x000400
|   BIOS数据区 BDA          |  256 B
+---------------------------+ 0x010000
|  boot_params (zero page)  |  4 KB  <-- GRUB 填充
+---------------------------+ 0x011000
|  command_line             |  512 B
+---------------------------+ 0x011800
|  real-mode kernel code    |        <-- 16-bit setup_sects
+---------------------------+ 0x100000 (1 MB)
|  32-bit protected mode    |
|  startup_32               |
+---------------------------+
|  compressed kernel        |
|  (vmlinux.bin.bz2)        |
+---------------------------+ (最终由 32-bit 解压到 0x1000000 或高端地址)

四、阶段三:内核的早期启动:从实模式到 64 位

这是内核启动最复杂的部分,也是 bzImage "混搭"风格代码的集中体现。

4.1 16-bit 实模式入口:start_of_setup

; arch/x86/boot/header.S
.globl start_of_setup
start_of_setup:
    ;此时 CS:IP 指向这里,仍在 16-bit 实模式
    ; GRUB 已把 boot_params 填好,bootsect_helper 地址也在

    ; 第一步:设置 DS=SS 指向 boot_params
    movw    %cs, %ax
    movw    %ax, %ds
    ; GDT 已经由 header.S 中固定的 boot_gdt 提供
    ; 但实模式下用不到,只为了之后的 far jump

    ; 第二步:检查加载位置和 loader 类型
    movb    $0x1, %cs:loader_type  ; 非 legacy BIOS 则为 0x3

    ; 第三步:启用 A20(现代系统由 GRUB 已处理)
    call    enable_a20

    ; 第四步:探测内存大小
    ; E820 调用:int 15h, ax=E820
    ; 结果存入 e820_entry[] 表
    call    detect_memory

    ; 第五步:设置初始视频模式
    call    set_video

    ; 第六步:关中断,切换到保护模式
    cli
    movl    %cr0, %eax
    orl     $0x1, %eax
    movl    %eax, %cr0
    ljmp    $CODE32_SEG, $boot_offset  ; 跳到 32-bit 入口

关键过渡:CPU 从 16-bit 实模式跳转到 32-bit 保护模式时,GDT 已临时设置好平坦模型(flat model),以便直接访问 4GB 物理空间。

4.2 32-bit 保护模式:startup_32

进入 arch/x86/boot/compressed/head_32.S 的 startup_32 后:

  1. 初始化段寄存器指向保护模式数据段
  2. 开启 PAE(物理地址扩展)并设置初始页表
  3. 准备解压器的 GDT/IDT
  4. 进入 64 位 long mode(如果目标是 64 位内核),或通过 EFER.LME 使能
  5. 跳转到解压器 extract_kernel()
; PAE + long mode 切换
movl    %cr4, %eax
orl     $(X86_CR4_PAE), %eax
movl    %eax, %cr4

; 加载 PML4 物理地址到 CR3
movl    $early_top_pgt, %eax
movl    %eax, %cr3

; 使能 long mode (EFER.LME)
movl    $MSR_EFER, %ecx
rdmsr
orl     $EFER_LME, %eax
wrmsr

; 使能分页
movl    %cr0, %eax
orl     $(X86_CR0_PG|X86_CR0_PE), %eax
movl    %eax, %cr0

; 通过 far jump 进入 64-bit 代码段
ljmp    $CS_L, $startup_64

4.3 64-bit 模式:startup_64 与解压

现在我们在 long mode 下运行,使用的是恒等映射的 1GB 大页表(early_top_pgt),把 [0, MAXMEM) 一一映射到物理地址。

; arch/x86/boot/compressed/head_64.S
.globl startup_64
startup_64:
    ; 重定位 GDT
    ; 初始化 SSE/AVX
    ; 验证解压器
    ; 调用 extract_kernel() C 函数

extract_kernel() 是 arch/x86/boot/compressed/misc.c 中的函数,它做三件事:

  1. 分配解压器输出缓冲区(通常在高物理地址,如 0x1000000)
  2. 解压 vmlinux.bin(使用 zlib 或 zstd,取决于编译时的配置)
  3. 重定位(如果 KASLR 开启,还需要移动内核基址到随机位置)

解压完成后通过 jmp *%rax 跳转到 x86_64_start_kernel()(即 64-bit 内核的入口)。


五、阶段四:内核 start_kernel() — 子系统级联初始化

start_kernel() 位于 init/main.c,它是整个启动流程中最重要的函数,也是子系统初始化的统一入口。

asmlinkage __visible void __init start_kernel(void)
{
    char *command_line;
    char *after_dashes;

    set_task_stack_end_magic(&init_task);
    smp_setup_processor_id();
    cgroup_init_early();
    local_irq_disable();
    early_boot_irqs_off();

    /*
     * 注意顺序:在调度器可用之前,大部分初始化代码不能阻塞。
     * 以下调用顺序是经过数十年演进形成的,几乎不可更改。
     */

    boot_cpu_init();
    setup_arch(&command_line);          // 架构相关:内存布局、fixmap、IOMMU 早期初始化
    mm_init_cpumask(&init_mm);
    setup_per_cpu_areas();              // per-cpu 数据区设置
    smp_prepare_boot_cpu();             // APIC 早期初始化

    build_all_zonelists(NULL);          // 构建内存区域(DMA/normal/highmem)
    page_alloc_init();                  // 伙伴系统(buddy system)就绪
    parse_early_param();
    after_dashes = parse_args(...);

    jump_label_init();
    setup_log_buf(0);
    pidhash_init();
    vfs_caches_init_early();
    sort_main_extable();
    trap_init();                        // 中断描述符表(IDT)安装,缺页/GPF handler
    mm_init();                          // slab 分配器初始化 -> kmalloc 可用!

    sched_init();                       // 调度器初始化,idle 进程创建
    rcu_init();                         // RCU 子系统
    init_IRQ();                         // 中断控制器(IO-APIC/Local APIC/iommu-irq)
    init_timers();                      // 时间子系统(jiffies, wall time)
    hrtimers_init();
    softirq_init();
    timekeeping_init();
    time_init();                        // clocksource, clockevent 注册
    perf_event_init();
    profile_init();
    call_function_init();
    WARN(!irqs_disabled(), "IRQs not disabled");

    kmem_cache_init_late();
    console_init();
    lockdep_init();
    lockdepoff();
    locking_selftest();
    numa_policy_init();
    calib_delay();
    pidmap_init();
    anon_vma_init();
    acpi_early_init();
    #ifdef CONFIG_EFI
    efi_enter_virtual_mode();          // EFI 进入虚拟寻址模式
    #endif
    #ifdef CONFIG_THREAD_INFO_IN_TASK
    thread_info_cache_init();
    #endif
    cred_init();
    fork_init();                        // task_struct 相关
    proc_caches_init();
    uts_ns_init();
    key_init();
    security_init();
    dbg_late_init();
    vfs_caches_init();                  // dentry cache, inode cache
    signals_init();
    page_writeback_init();
    proc_root_init();
    nsfs_init();
    cpuset_init();
    cgroup_init();
    taskstats_init_early();
    delayacct_init();

    check_bugs();
    acpi_subsystem_init();
    arch_post_acpi_subsys_init();
    sfi_init_late();
    efi_enabled(EFI_LOADER) -> efi_vmalloc_setup();

    ftrace_init();
    rest_init();                        // 创建 kernel_init 和 kthreadd
}

关键依赖关系可视化

start_kernel()
  ├─ setup_arch()
  │    ├─ x86_init.resources.reserve_resources()
  │    ├─ init_mem_mapping()      # 建立 full direct mapping
  │    ├─ init_mem_mapping_ext()
  │    └─ early_ioremap_setup()
  │
  ├─ page_alloc_init()            # kmem_cache_init 前不可用
  │    └─ set_pageblock_order()
  │
  ├─ mm_init()                    # kmalloc 才真正可用
  │    ├─ mem_init()               # 统计可用内存,释放 bootmem
  │    ├─ kmem_cache_init()        # slab/slub
  │    ├─ pgtable_cache_init()
  │    ├─ vmalloc_init()
  │    └─ kmap_init()
  │
  ├─ sched_init()
  │    ├─ init_idle()             # 创建 idle 进程 (PID 0)
  │    └─ 初始化 root 负载均衡
  │
  ├─ init_IRQ()                   # 中断子系统里程碑
  │    ├─ apic_intr_init()
  │    ├─ x86_init.irqs.intr_init()    # APIC 模式选择
  │    └─ 最终覆盖 static_branch_enable(irqs_disabled)
  │
  ├─ rest_init()
  │    ├─ kernel_thread(kthreadd, ...)      # PID 2: kthreadd
  │    └─ kernel_thread(kernel_init, ...)  # PID 1 前身: kernel_init (最终执行 /init)
  │
  └─ *idle*                        # start_kernel() 自身进入 idle 循环 (PID 0)

六、阶段五:kernel_init — 从内核线程到用户态 PID 1

6.1 挂载根文件系统

start_kernel() 末尾的 rest_init() 创建 kernel_init 内核线程(PID 1 的前身),它的主要责任是:

  1. 等待所有其他空闲 CPU 完成初始化
  2. 释放 bootmem / 释放 init 段代码所在的内存区域
  3. 尝试执行 initramfs 中的 /init 程序
  4. 如果失败,挂载真实的根文件系统再尝试
  5. 最终执行用户态的 init
// init/main.c: kernel_init()
static int __ref kernel_init(void *unused)
{
    int ret;

    kernel_init_freeable();
    /* 此时 init 段代码已被释放,不能再调用 __init 函数 */

    async_synchronize_full();
    ftrace_free_init_mem();
    jump_label_invalidate_init();
    mark_readonly();

    system_state = SYSTEM_RUNNING;
    num_default_serial_console();

    rcu_end_inkernel_boot();

    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 (execute_command) {
        ret = run_init_process(execute_command);
        if (!ret)
            return 0;
        panic("Requested init %s failed (error %d).",
              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.  Use=bin/init, "
          "linuxrc\".  See Linux Documentation/admin-guide/init.rst.");
}

6.2 prepare_namespace() — 解析 root= 参数

在真正挂载根文件系统之前,kernel_init_freeable() 中会调用 prepare_namespace(),它负责:

  • 解析 root= 参数(可以是 /dev/xxx, PARTUUID=xxx, LABEL=xxx, major:minor 等)
  • 使用 name_to_dev_t() 将名称转换为主/次设备号
  • 调用 mount_root() 通过指定的文件系统类型挂载

6.3 initramfs 的生存之道

initramfs 的关键优势在于:它不是一个模块,而是一个嵌入内核的 cpio 文件。解压过程和根文件系统切换由内核直接完成,无需额外文件系统驱动。

# 查看 initramfs 中的内容(以 CentOS/Fedora 为例)
lsinitrd /boot/initramfs-$(uname -r).img | head -30

# 输出示例:
# ========================================
# early_cpio
# kernel/
# kernel/x86/
# kernel/x86/microcode/
# kernel/x86/microcode/AuthenticAMD.bin
# kernel/x86/microcode/GenuineIntel.bin
# usr/
# usr/lib/
# usr/lib/modules/
# usr/lib/modules/6.6.8/
# usr/lib/modules/6.6.8/modules.dep
# usr/lib/firmware/
# ...
sbin
bin  -> usr/bin
lib  -> usr/lib
usr/
init     <-- 这是第一个执行的脚本

microcode 早期加载就是放在 early_cpio 段中,由最早的解压代码处理,远在文件系统驱动加载之前完成。


七、阶段六:用户态 init — PID 1 的世界

一旦 run_init_process() 成功返回(实际上是永不返回的),CPU 就从内核态切换到用户态,执行 init 程序。现代发行版大多使用 systemd。

$ cat /proc/1/comm
systemd

$ ls -la /proc/1/exe
lrwxrwxrwx 1 root root 0  ... /proc/1/exe -> /usr/lib/systemd/systemd

systemd 接管后执行的任务包括:

  1. 读取 /etc/inittab(兼容 SysV init)
  2. 加载 unit 文件(.service, .target, .mount, .socket 等)
  3. 并行启动依赖链上的服务
  4. 进入 default.target(通常是 multi-user.target 或 graphical.target)
  5. 触发 [email protected],显示登录提示

至此,用户已经可以看到登录提示符,整个启动流程完成。


八、实战中的调试技巧

8.1 通过 dmesg 查看启动时间戳

$ dmesg -H --color=always | less -R
[    0.000000] Linux version 6.6.8 (root@build) ...
[    0.000000] Command line: BOOT_IMAGE=/vmlinuz-6.6.8 root=UUID=xxx ...
[    0.000000] BIOS-provided physical RAM map:
[    0.004000] tsc: Detected 3000.000 MHz processor
[    0.004169] Calibrating delay loop (skipped)...
[    0.452328] smp: Bringing up secondary CPUs ...
[    4.182966] VFS: Mounted root (ext4 filesystem) readonly on device 8:2.
[    4.199011] devtmpfs: mounted
[    4.215734] Freeing unused kernel memory: 2048K
[    4.249586] Run /sbin/init as init process
[    5.873417] systemd[1]: Detected architecture x86-64.

通过 systemd-analyze 可以获取更详细的分析:

$ systemd-analyze
Startup finished in 3.456s (firmware) + 1.234s (loader) +
2.567s (kernel) + 5.678s (userspace) = 12.935s
graphical.target reached after 5.623s in userspace

8.2 使用 ftrace/perf 追踪启动过程

# 使用 initcall_debug 参数追踪每个 initcall 的执行时间
$ cat /proc/cmdline
... initcall_debug
$ dmesg | grep initcall
[    0.123456] initcall calibrate_delay_jiffies+0x0/0x20 returned 0 after 34567 usecs
[    0.156789] initcall usbcore_init+0x0/0x80 returned 0 after 8765 usecs

8.3 使用 QEMU 调试内核启动

# 启动 QEMU 并等待 gdb 连接
qemu-system-x86_64 \
  -kernel arch/x86/boot/bzImage \
  -initrd initramfs.cpio.gz \
  -append "console=ttyS0 nokaslr" \
  -nographic \
  -s -S

# 另一个终端:gdb
(gdb) target remote :1234
(gdb) break *0x100000       # startup_64
(gdb) break start_kernel
(gdb) break x86_64_start_kernel
(gdb) continue

8.4 用 ftrace 的早期事件追踪 boot

# 通过 bootconfig 在启动阶段即启用 ftrace
# /etc/bootconfig
ftrace {
    tracer=function_graph;
    initcall;
}

九、常见启动故障排查路径

故障现象 可能原因 排查命令
Kernel panic: VFS unable to mount root root= 参数错误 / 缺少文件系统驱动 dmesg 搜索 VFS:
Initramfs unpacking failed initramfs 损坏 / 格式不支持 提取 initrd 对比 size
"No working init found" initramfs 里没有 /init lsinitrd 检查
Emergency mode fstab 有错 / fsck 失败 journalctl -xb -1
启动慢于预期 某个 initcall 卡住 initcall_debug
kexec 崩溃 EFI runtime services 未正确重定位 dmesg 搜索 kexec

十、总结

Linux 内核启动是一个严密的序列化过程,每一步都建立在前一步的基础上:

  1. 固件(UEFI SEC/PEI/DXE)初始化硬件,发现启动设备
  2. Bootloader(GRUB2)加载内核、填充 boot_params、跳转到 start_of_setup
  3. 内核 early boot 从 16-bit 实模式到 64-bit long mode,解压 vmlinux
  4. start_kernel() 串行初始化所有子系统(mm/sched/irq/timer/vfs/cgroup)
  5. kernel_init 准备根文件系统,尝试 initramfs 中的 /init
  6. systemd 接管并行启动服务,用户可见的启动完成

关键认知:内核启动中最晚被初始化的东西往往就是程序员最早调用的东西。这就是为什么内核代码设计对顺序如此敏感——一次错误的 initcall 顺序,可能导致整个系统静默崩溃数小时才被发现。


作者注:本文基于 Linux 6.6 系列内核源码分析。启动代码随版本演进而调整(如 KASLR 引入随机化、EFI stub 替代部分 bootloader 功能),但整体框架自 2.6 以来保持稳定。建议结合 scripts/bootgraph.pl 和 systemd-analyze plot 生成 SVG 可视化启动火焰图,对照本文的时间线理解。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部
0.356920s