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 磁盘访问层和命令行分析器。其启动分三步:
- boot.img(512 字节)被 BIOS/UEFI 加载到
0x7C00,负责跳转到 diskboot.img - diskboot.img 加载 core.img(包含文件系统驱动)
- 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 后:
- 初始化段寄存器指向保护模式数据段
- 开启 PAE(物理地址扩展)并设置初始页表
- 准备解压器的 GDT/IDT
- 进入 64 位 long mode(如果目标是 64 位内核),或通过
EFER.LME使能 - 跳转到解压器
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 中的函数,它做三件事:
- 分配解压器输出缓冲区(通常在高物理地址,如
0x1000000) - 解压 vmlinux.bin(使用 zlib 或 zstd,取决于编译时的配置)
- 重定位(如果 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 的前身),它的主要责任是:
- 等待所有其他空闲 CPU 完成初始化
- 释放
bootmem/ 释放init段代码所在的内存区域 - 尝试执行 initramfs 中的
/init程序 - 如果失败,挂载真实的根文件系统再尝试
- 最终执行用户态的
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 接管后执行的任务包括:
- 读取
/etc/inittab(兼容 SysV init) - 加载 unit 文件(.service, .target, .mount, .socket 等)
- 并行启动依赖链上的服务
- 进入
default.target(通常是multi-user.target或graphical.target) - 触发
[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 内核启动是一个严密的序列化过程,每一步都建立在前一步的基础上:
- 固件(UEFI SEC/PEI/DXE)初始化硬件,发现启动设备
- Bootloader(GRUB2)加载内核、填充
boot_params、跳转到start_of_setup - 内核 early boot 从 16-bit 实模式到 64-bit long mode,解压 vmlinux
start_kernel()串行初始化所有子系统(mm/sched/irq/timer/vfs/cgroup)kernel_init准备根文件系统,尝试 initramfs 中的/init- systemd 接管并行启动服务,用户可见的启动完成
关键认知:内核启动中最晚被初始化的东西往往就是程序员最早调用的东西。这就是为什么内核代码设计对顺序如此敏感——一次错误的 initcall 顺序,可能导致整个系统静默崩溃数小时才被发现。
作者注:本文基于 Linux 6.6 系列内核源码分析。启动代码随版本演进而调整(如 KASLR 引入随机化、EFI stub 替代部分 bootloader 功能),但整体框架自 2.6 以来保持稳定。建议结合scripts/bootgraph.pl和systemd-analyze plot生成 SVG 可视化启动火焰图,对照本文的时间线理解。

发表评论 取消回复