3.2 启动与初始化
按下电源到出现登录提示符,CPU 换了几次执行模式、跳了几次入口?start_kernel() 那一长串调用里,哪几步倒了整台机器就起不来?为什么 PID 1 的 init 不是内核线程,而 kthreadd 是?initramfs 和 root= 到底谁先谁后?
画出从 UEFI 到 systemd 的完整链路并指出每段的证据;按顺序说出 start_kernel() 的关键步骤与失败现象;用 initcall_debug 找出某个驱动为什么没加载;自己做一个能挂真实根文件系统的 initramfs。
3.1 搭好了环境:QEMU 能跑起你编的内核,/init 能给你一个 shell。但那条命令里藏着一串问题:bzImage 被谁解压?start_kernel() 之前 CPU 在什么模式?/init 是被谁执行的,为什么它的 PID 是 1?root=/dev/vda 和设备驱动谁先就绪?
启动是内核里顺序性最强的一段代码:几乎所有初始化都只跑一次,顺序错了就是空指针或死锁。也正因为顺序固定,它最适合作为读内核的第一站——你能沿着一条线,把内存、调度、中断、驱动、文件系统各子系统的入口都见一遍。
本章的路线与母本一致:从固件与引导器开始(奔跑吧 6.1、鸟哥 19),进 start_kernel()(李治军 1 的 0.11 对照),到 rest_init() 分叉出 PID 1 与 kthreadd,再到 initramfs、根切换、systemd。设备树与 ACPI 只作为「硬件描述从哪来」的一句带过,展开在第 4 篇。
3.2.1 加电之后:固件、引导器与 boot protocol
dmesg 有输出说明已进内核;journalctl -b 有记录说明 systemd 已接管。读图:七个方框里,只有第三个是内核。前面两段(固件、引导器)属于平台,后面两段(initramfs、init)已经是内核交棒之后的事。排启动故障时,先确定卡在哪一段——这比背流程有用。
三段的分工:
| 阶段 | 运行环境 | 干的活 | 交棒方式 |
|---|---|---|---|
| 固件(UEFI/BIOS) | 16/32 位实模式起步,UEFI 下是 64 位 | POST、枚举内存与设备、找启动项 | 加载 ESP 上的 .efi,或读 MBR |
| 引导器(GRUB/systemd-boot) | 固件提供的执行环境 | 读配置、选内核、准备 initrd、填 boot_params | ExitBootServices() 后跳内核入口 |
| 内核 | 自带的 32/64 位环境 | 解压、建页表、初始化子系统 | 执行 /init 或 /sbin/init |
x86 上这条链路有两种走法:
- BIOS + GRUB:GRUB 的
linux命令把bzImage与initrd装进内存,填好boot_params(struct boot_params,定义在arch/x86/include/uapi/asm/bootparam.h),然后跳startup_32。 - UEFI + EFI stub:6.8 的内核自带 EFI stub(
CONFIG_EFI_STUB=y),bzImage本身就是一个合法的 PE/COFF EFI 应用,可以被固件直接启动,也可以被efibootmgr注册。GRUB 在 UEFI 下同样能加载它。
bzImage 其实是一个「自解压压缩包」:arch/x86/boot/header.S 提供 16 位入口与 setup_header,arch/x86/boot/compressed/ 里的 misc.c 负责解压,然后再跳进真正的 vmlinux 入口 startup_64(arch/x86/kernel/head_64.S)。这条路上有几个必须记住的名字:
| 符号 | 位置 | 作用 |
|---|---|---|
startup_32 | arch/x86/boot/compressed/head_64.S | 32 位入口,建临时页表,进长模式 |
startup_64 | arch/x86/kernel/head_64.S | 64 位入口,设置 init_top_pgt,跳到 C |
x86_64_start_kernel | arch/x86/kernel/head64.c | 清 BSS、复制 boot_params、调 x86_64_start_reservations |
start_kernel | init/main.c | 第一个「与架构无关」的 C 函数 |
boot_params 是引导器与内核之间的唯一契约:一个固定布局的结构体,放在 0x10000(或由 setup_header.code32_start 指定),里面包含内存映射(e820_table)、命令行指针、initrd 地址与大小、屏幕参数、ACPI 表地址等。内核不调用固件,只读这个结构体——这就是为什么换 GRUB 或换 systemd-boot 都不影响内核。
vmlinux 动辄几百 MB(带调试信息更大),而固件与引导器的加载能力有限,磁盘上的 /boot 也希望能小。压缩成 bzImage(通常 10–15 MB)后,解压的代价(约 100 ms)远小于读盘和加载的收益。解压器本身必须是位置无关的,因为此时还没有真正的页表。
3.2.2 start_kernel:单核上的第一段 C 代码
x86_64_start_kernel() 做完最底层的准备(清 BSS、复制 boot_params、建立初始页表)后调用 start_kernel()。此刻机器只有 boot CPU 在跑,中断关着,调度器还没初始化,current 指向静态的 init_task。
init/main.c: start_kernel() 有上百行,按职责可压成八步:
| 顺序 | 调用 | 建立什么 | 失败会怎样 |
|---|---|---|---|
| 1 | setup_arch(&command_line) | 内存映射、e820、ACPI、页表 | 后面全部崩,通常无声重启 |
| 2 | setup_command_line() / setup_nr_cpu_ids() | 保存 command_line、算 CPU 数 | 参数丢失、per-CPU 分配错 |
| 3 | setup_per_cpu_areas() | per-CPU 变量区 | this_cpu_ptr 全错 |
| 4 | build_all_zonelists() / mm_core_init() | 伙伴系统、slab、vmalloc | 第一次 kmalloc 就 panic |
| 5 | sched_init() | 运行队列、调度类、init_task 入队 | 第一次 schedule() 崩 |
| 6 | rcu_init() / trap_init() / irq_init() | RCU、IDT、中断控制器 | 中断来即崩 |
| 7 | time_init() / console_init() | 时钟源、printk 输出 | 没有任何日志 |
| 8 | arch_call_rest_init() → rest_init() | 创建 PID 1 与 kthreadd | 启动停在 start_kernel 末尾 |
读这段代码时抓住一条主线:每一步都在为下一步准备前提。setup_arch() 必须先于 mm_core_init(),因为没有页表就无法管理内存;mm_core_init() 必须先于 sched_init(),因为调度器初始化自己要分配内存;console_init() 放在后面,是因为串口驱动要等设备模型起来——这就是为什么早期的 printk 会先存进 log_buf,等 console 注册后一次性刷出。
把 start_kernel() 的头尾各截 10 行,能看清这条主线怎么落地:
/* init/main.c: start_kernel() —— 6.8 节选 */
set_task_stack_end_magic(&init_task); /* 栈底魔数,供栈溢出检测 */
smp_setup_processor_id(); /* 确定 boot CPU 的编号 */
cgroup_init_early();
local_irq_disable(); /* 此后到 local_irq_enable 之前不能睡眠 */
boot_cpu_init();
page_address_init();
pr_notice("%s", linux_banner);
early_security_init();
setup_arch(&command_line); /* 架构相关的一切:e820、页表、ACPI */
setup_command_line(command_line);
setup_nr_cpu_ids();
setup_per_cpu_areas(); /* 之后 this_cpu_ptr 才可用 */
...
mm_core_init(); /* 伙伴系统 + slab + vmalloc */
sched_init(); /* 运行队列与调度类 */
rcu_init();
trap_init(); /* IDT */
...
console_init(); /* 到这里 printk 才有输出端 */
...
arch_call_rest_init(); /* → rest_init(),不返回 */
三个细节:
local_irq_disable()之后有一大段代码不允许睡眠,因此这段路上只能memblock_alloc(),不能kmalloc(GFP_KERNEL)。set_task_stack_end_magic(&init_task)在栈底写一个魔数,CONFIG_DEBUG_STACK_OVERFLOW打开时,调度器每次切换会检查它是否被踩坏——这是排查内核栈溢出的低成本手段。pr_notice("%s", linux_banner)是你在串口看到的第一行,它出现时setup_arch()还没跑,说明earlyprintk或 boot console 已经能用。
内核启动日志开头那几行,就是这条链的证据:
[ 0.000000] Linux version 6.8.0 (you@host) (gcc 13.2.0) #1 SMP PREEMPT_DYNAMIC
[ 0.000000] Command line: console=ttyS0 rdinit=/init nokaslr
[ 0.000000] BIOS-provided physical RAM map:
[ 0.000000] BIOS-e820: [mem 0x0000000000000000-0x000000000009fbff] usable
[ 0.000000] BIOS-e820: [mem 0x0000000000100000-0x000000001ffdffff] usable
[ 0.000000] NX (Execute Disable) protection: active
[ 0.000000] DMI: QEMU Standard PC (i440FX + PIIX, 1996), BIOS 1.16.3
[ 0.000000] last_pfn = 0x1ffe0 max_arch_pfn = 0x400000000
[ 0.000000] Memory: 493000K/523896K available (14336K kernel code, 2048K rwdata)
BIOS-e820 是固件给的内存地图,last_pfn 是内核算出的最大页帧号,Memory: 是伙伴系统接管后的可用量。三者对不上,通常意味着 mem= 参数或 CONFIG_PHYSICAL_START 有问题。
init_task 是静态分配的 0 号进程(swapper/idle),每个 CPU 一个 idle 线程,没有用户地址空间。PID 1(kernel_init)由 rest_init() 用 user_mode_thread() 创建——它有用户地址空间,只是启动时先跑内核态的 kernel_init() 函数,最后用 run_init_process() 执行 /sbin/init。kthreadd(PID 2)才是纯内核线程,所有 kthread_create() 最终都由它 fork 出来。
start_kernel() 里的内存初始化在 6.4 之前叫 mm_init(),6.8 叫 mm_core_init();PID 1 的创建在 6.3 之前用 kernel_thread(kernel_init, ...),6.8 用 user_mode_thread()。读 4.x 的分析文章时,这两个名字对不上是正常的。
3.2.3 setup_arch:把固件的信息翻译成内核的数据
arch/x86/kernel/setup.c: setup_arch() 是启动过程中最长的一个函数,做四类事:
- 内存:
e820__memory_setup()把固件的内存地图复制成e820_table,memblock_add()把可用区登记进 memblock(伙伴系统之前的早期分配器),init_mem_mapping()建立直接映射,paging_init()初始化zone与struct page数组。 - 平台:解析 ACPI 表(
acpi_boot_table_init())、SMBIOS/DMI、PCI 根桥、setup_ioapic_ids()等。 - 命令行:
parse_early_param()先处理early_param()注册的参数(如earlyprintk、mem=、nokaslr),其余留给后面的parse_args()。 - 资源:把内核自身占用的区间(
_text.._end、initrd、brk)从 memblock 里memblock_reserve()掉,防止被分配出去。
memblock 是这里的关键中间层:它在伙伴系统可用之前提供 memblock_alloc(),伙伴系统起来后大部分内存转交 memblock_free_all()。启动早期的日志里 memblock=debug 会打印每次分配,是排查「内存去哪了」的第一手材料。
参数解析有两个入口,区别很重要:
/* 只能是 built-in:early_param 在 setup_arch() 期间生效,此时模块还不可能存在 */
static int __init demo_early_setup(char *str)
{
pr_info("demo: early param = %s\n", str);
return 1;
}
early_param("demo_early", demo_early_setup);
/* __setup 在 start_kernel() 后段解析,同样只对 built-in 有效 */
static int __init demo_setup(char *str)
{
pr_info("demo: param = %s\n", str);
return 1;
}
__setup("demo=", demo_setup);
把这两段加进一个 built-in 文件(例如 init/main.c 末尾),重新编译,在 QEMU 的 -append 里加 demo_early=abc demo=xyz,就能在 dmesg 里看到两行。注意它们不能做成模块:early_param 依赖链接时生成的 .init.setup 段,模块加载时早已过了这个阶段。这条限制本身就是理解 initcall 顺序的入口。
- 「此时可以用
kmalloc」:不行。slab 还没初始化,只能用memblock_alloc()或静态数组。6.8 里memblock_alloc()之后的memblock_free()也很常见。 - 「
e820就是全部物理内存」:e820只描述固件眼里的 RAM/ACPI/保留区;内核还会用memblock叠加mem=、crashkernel=、ELF core header等约束。 - 「
setup_arch与架构无关」:正好相反,它是全内核最架构相关的一段,x86 与 arm64 的同名函数几乎没有共同代码。 - 「命令行参数随时可读」:
saved_command_line在setup_command_line()之后才稳定;更早只能用boot_command_line。
3.2.4 rest_init:一个函数分出三个终身角色
start_kernel() 的最后一行是 arch_call_rest_init(),它调用 rest_init()。这个不到 40 行的函数决定了系统的三个长期角色:
/* init/main.c: rest_init() —— 6.8 节选 */
static noinline void __init rest_init(void)
{
struct task_struct *tsk;
int pid;
rcu_scheduler_starting();
pid = user_mode_thread(kernel_init, NULL, CLONE_FS);
rcu_read_lock();
tsk = find_task_by_pid_ns(pid, &init_pid_ns);
tsk->flags |= PF_NO_SETAFFINITY;
set_cpus_allowed_ptr(tsk, cpumask_of(smp_processor_id()));
rcu_read_unlock();
numa_default_policy();
pid = kernel_thread(kthreadd, NULL, NULL, CLONE_FS | CLONE_FILES);
rcu_read_lock();
kthreadd_task = find_task_by_pid_ns(pid, &init_pid_ns);
rcu_read_unlock();
system_state = SYSTEM_SCHEDULING;
complete(&kthreadd_done);
/* 0 号进程在这里变成每个 CPU 的 idle 线程 */
cpu_startup_entry(CPUHP_ONLINE);
}
三个角色:
| 角色 | PID | 怎么来的 | 之后做什么 |
|---|---|---|---|
| idle(swapper) | 0 | 静态 init_task,每个 CPU 一份 | cpu_startup_entry() 里循环 do_idle(),没活干时执行 hlt/mwait |
kernel_init | 1 | user_mode_thread() | 跑 kernel_init_freeable(),然后 execve 用户态 init |
kthreadd | 2 | kernel_thread() | 循环 create_kthread(),响应 kthread_create() 请求 |
idle 线程值得多看一眼:它没有用户地址空间,mm 为 NULL,但 active_mm 会借用上一个用户进程的页表(这就是 lazy TLB)。cpu_startup_entry() 在 kernel/sched/idle.c 里循环调用 do_idle():有任务就 schedule(),没任务就选一个 idle 回调(cpuidle 驱动提供的 mwait/hlt/WFI)把 CPU 送进低功耗状态。你在 top 里看到的 id 百分比,就是 CPU 停在这段循环里的时间比例。
kernel_init() 是真正的「启动总装线」:
/* init/main.c: kernel_init() —— 6.8 节选,已简化 */
static int __ref kernel_init(void *unused)
{
int ret;
kernel_init_freeable(); /* smp_init、do_basic_setup、initcall */
async_synchronize_full();
...
if (ramdisk_execute_command) { /* rdinit= 指定的程序 */
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) { /* init= 指定的程序 */
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. Try passing init= option to kernel.");
}
三件事值得记:
rdinit=优先于init=,两者都优先于默认列表。rdinit只在 initramfs 里找,init在最终根文件系统里找。- 默认列表的顺序是固定的:
/sbin/init→/etc/init→/bin/init→/bin/sh。最后那个/bin/sh是「实在没有 init 就给你一个 shell」,容器镜像常靠它。 kernel_init自己不变成 init 进程。它在run_init_process()里调用kernel_execve(),成功后 PID 1 的地址空间被/sbin/init替换——所以ps看到的 PID 1 是 systemd,不是内核线程。
kernel_init_freeable() 里还有两件大事:smp_init() 唤醒其他 CPU(它们从 start_secondary() 进入调度循环),do_basic_setup() 调用 do_initcalls() 跑完所有初始化函数。
3.2.5 initcall:内核的「构造函数表」
include/linux/init.h 定义了 8 个初始化等级,链接时按等级把函数指针放进不同的 .initcallN.init 段:
| 等级 | 宏 | 典型用途 |
|---|---|---|
| 0 | early_initcall |
极早期,依赖最少 |
| 1 | core_initcall |
核心子系统(如 init/main.c 的部分逻辑) |
| 2 | postcore_initcall |
总线、平台设备 |
| 3 | arch_initcall |
架构相关设备 |
| 4 | subsys_initcall |
子系统(如 net、block) |
| 5 | fs_initcall |
文件系统注册 |
| 6 | device_initcall |
绝大多数驱动;module_init 对 built-in 等价于此 |
| 7 | late_initcall |
最后收尾 |
同一等级内的顺序由链接顺序决定,也就是 Makefile 里的 obj-y 顺序。跨等级严格按数字递增。
do_initcalls() 遍历这些段,逐个调用并记录返回值。打开 initcall_debug 后,每次调用都会打印「谁、花了多久」:
# 虚机内核命令行加 initcall_debug
qemu-system-x86_64 ... -append "console=ttyS0 rdinit=/init initcall_debug"
# 虚机内看最慢的 10 个初始化函数
dmesg | grep 'initcall' | sort -t' ' -k6 -n | tail -10
输出形如 initcall e1000_init_module+0x0/0x... returned 0 after 12345 usecs。这是定位「启动为什么慢」最直接的手段:如果 usbcore 花了 2 秒,问题在 USB 枚举,不在内核本体。
某板子上的触摸屏驱动在 device_initcall 阶段读 I2C,但 I2C 控制器驱动是 subsys_initcall,两者都依赖 postcore_initcall 阶段的 pinctrl。现象是「偶尔 probe 失败,冷启动必失败,热重启偶尔成功」。
把触摸屏驱动改成 late_initcall 后问题消失,但这只是掩盖:真正的原因是 pinctrl 的 probe 被 -EPROBE_DEFER 推迟后没有重新触发。正确修法是让驱动返回 -EPROBE_DEFER,由驱动核心在依赖就绪后重试,而不是靠调整 initcall 等级。这个例子说明:initcall 等级是全局排序,不是依赖声明;跨子系统的依赖应该用 -EPROBE_DEFER,而不是抢顺序。
模块没有 initcall 等级:module_init() 在模块里只表示「加载时调用这个函数」,insmod 的时机完全由用户决定。所以「为什么我的驱动在启动时没加载」的答案通常是「它编译成了模块」,lsmod 一看便知。
在用户态读启动参数
内核把启动信息投影到 /proc 与 /sys,写一个 30 行的 C 程序就能把「这次启动是谁、用什么参数、从哪启动」读出来:
/* bootinfo.c — 读取本次启动的命令行与 PID 1 信息;在虚机内编译运行 */
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
static char *slurp(const char *path)
{
int fd = open(path, O_RDONLY | O_CLOEXEC);
if (fd == -1) return NULL;
size_t cap = 4096, len = 0;
char *buf = malloc(cap);
if (!buf) { close(fd); return NULL; }
for (;;) {
if (len + 1 == cap) {
char *nb = realloc(buf, cap * 2);
if (!nb) { free(buf); close(fd); return NULL; }
buf = nb; cap *= 2;
}
ssize_t n = read(fd, buf + len, cap - len - 1);
if (n == -1) {
if (errno == EINTR) continue;
free(buf); close(fd); return NULL;
}
if (n == 0) break;
len += (size_t)n;
}
buf[len] = '\0';
close(fd);
return buf;
}
int main(void)
{
char *cmdline = slurp("/proc/cmdline");
char *init = slurp("/proc/1/cmdline");
printf("cmdline : %s", cmdline ? cmdline : "(unreadable)\n");
if (init) {
/* /proc/PID/cmdline 用 '\0' 分隔,这里只取第一个参数 */
printf("pid 1 : %s\n", init);
}
printf("boot_id : ");
fflush(stdout);
char *id = slurp("/proc/sys/kernel/random/boot_id");
printf("%s", id ? id : "(unreadable)\n");
free(cmdline); free(init); free(id);
return 0;
}
编译运行:cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -o bootinfo bootinfo.c && ./bootinfo。三次输出分别来自 fs/proc/cmdline.c、fs/proc/base.c 与 kernel/random.c,都是只读投影——/proc 不存储状态,它在你 read() 的那一刻现算。boot_id 每次启动都变,是判断「机器有没有重启过」的可靠依据(比 uptime 可靠,后者会被调整)。
3.2.6 initramfs:先给你一个能用的根
内核要执行 /sbin/init,前提是根文件系统已经挂上。但挂根需要块设备驱动,而驱动可能是模块——鸡生蛋。initramfs 就是那只「先有」的鸡。
initramfs 是一个 cpio 归档(通常 gzip 压缩),链接进内核或被引导器加载,解压到 rootfs(ramfs/tmpfs)。它是内存文件系统,不是块设备。initrd 是老方案:一个带文件系统的块设备镜像,挂成 /dev/ram0。2.6 之后 initramfs 取代 initrd;今天 /boot/initrd.img-* 这个名字里装的其实是 initramfs。
内核里的落点是 init/initramfs.c:
populate_rootfs()注册为rootfs_initcall,负责解压与填充;unpack_to_rootfs()解析 cpio 的newc格式,do_name()建目录/文件,do_copy()写数据;- 如果内核配置了
CONFIG_INITRAMFS_SOURCE,归档在编译时就被嵌进vmlinux的.init.ramfs段;否则由引导器通过-initrd传入。
启动时的日志会明确告诉你走的是哪条:
[ 0.512345] Unpacking initramfs...
[ 0.678901] Freeing initrd memory: 8192K
[ 0.679012] Run /init as init process
第二行的 Freeing initrd memory 说明归档已被解压进 rootfs,原始内存被释放。第三行来自 kernel_init(),说明 rdinit=/init 找到了。
如果根不在内存里,内核还要做一次「根切换」:initramfs 里的 /init 负责加载块设备驱动、找到真实根、mount 它、switch_root(或 pivot_root)过去,再 exec 真正的 init。这套逻辑在用户态(dracut/initramfs-tools/mkinitcpio 生成的脚本),不在内核里——内核只提供 root= 的解析与最初的挂载尝试。
# 1. 造一个 64 MiB 的 ext4 根镜像,塞进静态 busybox
dd if=/dev/zero of=rootfs.img bs=1M count=64
mkfs.ext4 -q rootfs.img
mkdir -p mnt && sudo mount -o loop rootfs.img mnt
sudo mkdir -p mnt/{bin,sbin,proc,sys,dev,tmp,etc}
sudo cp busybox mnt/bin/ && sudo chroot mnt /bin/busybox --install -s
sudo sh -c 'echo "root:x:0:0:root:/root:/bin/sh" > mnt/etc/passwd'
sudo mkdir -p mnt/etc/init.d
sudo sh -c 'cat > mnt/etc/init.d/rcS <<EOF
#!/bin/sh
mount -t proc none /proc
mount -t sysfs none /sys
mount -t devtmpfs none /dev
exec /bin/sh
EOF'
sudo chmod +x mnt/etc/init.d/rcS
sudo sh -c 'echo "::sysinit:/etc/init.d/rcS" > mnt/etc/inittab'
sudo umount mnt
# 2. 内核命令行:根在 virtio 磁盘上,init 用 busybox 的 init
qemu-system-x86_64 -m 512M -smp 2 \
-kernel arch/x86/boot/bzImage \
-append "console=ttyS0 root=/dev/vda rw init=/sbin/init" \
-drive file=rootfs.img,format=raw,if=virtio \
-nographic -no-reboot
关键点:root=/dev/vda 要求内核有 CONFIG_VIRTIO_BLK 与 CONFIG_EXT4_FS(built-in 或由 initramfs 里的 /init 先加载)。如果没有 initramfs 且这两个是模块,内核会停在 VFS: Cannot open root device "vda"。
根切换:switch_root 为什么不能直接 chroot
发行版的 initramfs 里,/init 挂上真实根之后要做一次根切换,而不是简单地 chroot。原因是:chroot 只改当前进程的根目录,不改变挂载点的归属,旧根的挂载仍然占着目录,也无法卸载;而 initramfs 是 ramfs,占用的内存永远不释放。
switch_root(util-linux 提供)做了三步:
- 把新根
mount --move到/,让旧根变成空目录; chdir("/")后exec新根的 init;- 旧根的 ramfs 在最后一个引用消失后自动回收。
内核侧对应的系统调用是 pivot_root(2) 与 chroot(2),但 switch_root 还贴心地检查了「新根必须是挂载点」「不能有别的进程在用旧根」,避免把系统搞成半吊子状态。容器运行时里的 pivot_root 用法(第 5 篇)与这里同源。
3.2.7 systemd 接管:PID 1 之后的用户态
kernel_init() 执行 /sbin/init(通常是 systemd 的软链)后,内核的启动职责就结束了。systemd 作为 PID 1 做四件事:
- 建 manager:
manager_new()读出/etc/systemd/system.conf,建 cgroup 层级,挂/sys/fs/cgroup。 - 加载 unit:扫描
/usr/lib/systemd/system、/etc/systemd/system、/run/systemd/system,解析.service/.target/.socket/.mount。 - 算依赖并并行启动:
transaction从default.target反向展开依赖,能并行的全部并行。After=/Before=只定顺序,Requires=/Wants=定依赖。 - 收尾:达到
default.target后systemd-analyze的时间点就固定了,之后进入事件循环,处理 socket、timer、D-Bus 请求。
target 的常见链条:
sysinit.target ──► basic.target ──► multi-user.target ──► graphical.target
▲ ▲ ▲ ▲
local-fs.target sockets.target network.target display-manager.service
swap.target timers.target getty@tty1.service
paths.target
观测命令与它们回答的问题:
| 命令 | 回答什么 | 典型输出 |
|---|---|---|
systemd-analyze | 内核/initrd/用户态各花了多久 | = 1.2s (kernel) + 2.3s (initrd) + 4.5s (userspace) |
systemd-analyze blame | 哪个 unit 最慢 | 按耗时倒序的 unit 列表 |
systemd-analyze critical-chain | 关键路径上谁卡住 | graphical.target @8.0s └─multi-user.target @7.9s ... |
systemctl list-units --failed | 谁起失败了 | ● 开头的 unit |
journalctl -b -k | 本次启动的内核日志 | 与 dmesg 同源,带时间戳 |
journalctl -b -u sshd | 某个 unit 的日志 | 含 unit 的输出与退出码 |
一个最小 unit 能说明依赖是怎么表达的:
# /etc/systemd/system/demo.service
[Unit]
Description=demo service
After=network-online.target
Wants=network-online.target
ConditionPathExists=/etc/demo.conf
[Service]
Type=simple
ExecStart=/usr/local/bin/demo --foreground
Restart=on-failure
RestartSec=2s
[Install]
WantedBy=multi-user.target
After= 只影响顺序,不产生依赖:network-online.target 没起来,demo 照样启动。Wants= 才产生「想让它起来」的依赖,但失败不阻塞本 unit;Requires= 才会在对方失败时连带失败。WantedBy=multi-user.target 决定 systemctl enable 时在 /etc/systemd/system/multi-user.target.wants/ 下建一个软链——这是「开机自启」的全部机制,没有魔法。
鸟哥讲的是 SysV 时代的流程:BIOS → MBR → boot loader → kernel → init → runlevel(0–6)→ /etc/rc.d/rcN.d/ 里的 S/K 脚本。systemd 把 runlevel 映射成 target(runlevel3.target → multi-user.target),把「顺序执行脚本」换成「依赖图 + 并行」。读旧资料时看到 /etc/inittab,要知道 6.8 的发行版里它已经不存在了。
3.2.8 设备树与 ACPI:硬件描述从哪来
内核不能硬编码「这块板子的串口在 0x3f8」。硬件描述有两条来源:
- x86:ACPI。固件提供 RSDP → XSDT → 一系列表,内核解析
MADT(APIC/IOAPIC 拓扑)、MCFG(PCIe ECAM 基址)、DSDT/SSDT(AML 字节码,描述设备与电源管理)。acpi_table_parse()、acpi_bus_scan()是入口。 - ARM/ARM64:设备树(device tree)。一个
.dtb二进制,节点带compatible = "vendor,device",驱动用of_match_table匹配。CONFIG_OF打开时,of_platform_default_populate()把 DT 节点变成platform_device。
一个 DT 节点与它对应的驱动匹配表长这样:
/* demo-uart.dtsi —— 只是示意,不是真实板级文件 */
demo_uart0: serial@10000000 {
compatible = "demo,uart-v2";
reg = <0x10000000 0x1000>;
interrupts = <10>;
clocks = <&clk_uart>;
status = "okay";
};
/* drivers/tty/serial/demo_uart.c —— 匹配表节选 */
static const struct of_device_id demo_uart_of_match[] = {
{ .compatible = "demo,uart-v2" },
{ /* sentinel */ }
};
MODULE_DEVICE_TABLE(of, demo_uart_of_match);
static struct platform_driver demo_uart_driver = {
.probe = demo_uart_probe,
.remove = demo_uart_remove,
.driver = {
.name = "demo-uart",
.of_match_table = demo_uart_of_match,
},
};
module_platform_driver(demo_uart_driver);
reg 描述寄存器基址与长度,interrupts 是中断号(在 DT 里通常是 specifier,要经过 interrupt controller 翻译),clocks 指向时钟树节点。这些属性由 platform_get_resource()、platform_get_irq()、devm_clk_get() 读出,驱动代码里没有一处硬编码地址。
两者在驱动模型里汇合:无论来源如何,最终都变成 struct device 挂到某条总线上,驱动用 probe() 接手。差异只在「属性怎么读」:ACPI 用 device_property_read_u32(),DT 用 of_property_read_u32(),而 6.x 推荐统一用 device_property_*(),两种后端都支持。第 3.14 章会展开。
3.2.9 启动失败怎么定位
- 一行日志都没有:卡在固件/引导器,或
console=指错。加earlyprintk=serial,ttyS0,115200与keep_bootcon,确认串口是ttyS0而不是ttyS1。 - 有
Linux version但停在setup_arch附近:多半是内存地图或页表问题。加memblock=debug mem=512M缩小范围;nokaslr排除 KASLR。 - 停在
Run /init as init process:initramfs 里的/init不可执行或缺解释器。改用rdinit=/bin/sh验证;用cpio -t检查归档顶层。 VFS: Cannot open root device:root=拼错、驱动是模块但没进 initramfs、或 UUID 变了(换盘/重装后常见)。用root=PARTUUID=...比root=/dev/sda1稳。- 内核起来了但卡在 systemd:
systemd.unit=rescue.target或rd.break进维护模式,journalctl -b -p err看红字。 - 随机卡住、无日志:
panic=10让它自动重启并保留现场;sysrq的t(任务列表)、w(阻塞任务)能 dump 出卡在哪。
- 「
init=/bin/sh就能修一切」:/bin/sh存在且动态库齐全才行;只读根要先mount -o remount,rw /。 - 「initramfs 是内核的一部分」:只有
CONFIG_INITRAMFS_SOURCE嵌入时才是;发行版的 initramfs 是独立的/boot/initrd.img-*,由引导器加载。 - 「
dmesg能看到全部启动日志」:环形缓冲区会覆盖,早期日志可能已被冲掉。要完整日志用journalctl -k -b,或加大log_buf_len=。 - 「改了
/etc/default/grub就生效」:还要update-grub(Debian/Ubuntu)或grub2-mkconfig(RHEL),否则改的是模板不是grub.cfg。 - 「PID 1 挂了系统会重启」:默认
panic();容器里 PID 1 退出则容器结束,语义不同。
3.2.11 解压器与早期页表:从 16 位到 start_kernel 的最后一百行
这一节回答:bzImage 里的解压器在什么模式下跑、解压到哪个物理地址、__startup_64 为什么要「修正自己」、KASLR 的随机偏移存在哪。
3.2.1 列了 startup_32 → startup_64 → x86_64_start_kernel 三个符号。它们之间还有一层很少被展开的细节:解压器和被解压的内核是两份代码,各自有一套页表与入口。
三段执行环境
实模式(16 位)
setup code:收集 e820、设置视频、准备跳转
↓ 设置 CR0.PE,加载临时 GDT
保护模式(32 位,未分页或恒等映射)
compressed/head_32.S: startup_32
↓ 建 4 级页表(或 5 级),设置 CR4.PAE、EFER.LME、CR0.PG
长模式(64 位)
compressed/head_64.S: startup_64
→ extract_kernel() 解压
→ 跳被解压内核的 startup_64(arch/x86/kernel/head_64.S)
→ __startup_64()(head64.c)
→ x86_64_start_kernel() → start_kernel()
解压器自己的页表在 arch/x86/boot/compressed/head_64.S 里静态定义,通常只有几项:一个 2 MiB 大页映射解压器自身、一个映射输出缓冲区。它不做完整的内存管理,因为此时 memblock 还不存在。
extract_kernel() 做的四件事
/* arch/x86/boot/compressed/misc.c: extract_kernel() —— 6.8 节选,已简化 */
asmlinkage __visible void *extract_kernel(void *rmode, unsigned char *output)
{
...
/* 1. 选输出地址:KASLR 或 pref_address */
output = choose_random_location((unsigned long)input_data,
(unsigned long)input_len,
(void *)output);
...
/* 2. 解析被压缩内核的 ELF,按段放到 output */
parse_elf(output);
...
/* 3. 处理重定位(ET_EXEC 也要修正物理地址) */
handle_relocations(output, output_len, virt_addr);
...
/* 4. 返回入口地址,由汇编跳过去 */
return (void *)virt_addr;
}
choose_random_location() 的输入是 boot_params->hdr.pref_address(首选物理地址)与 init_size(需要的连续内存)。它从 e820 的可用区里随机挑一个满足对齐的洞;随机种子来自 RDRAND、RDTSC、引导器塞进 boot_params 的 kaslr_seed。选完把偏移写进 boot_params->hdr.kernel_alignment 之外的内部变量,解压后再由 __startup_64() 读取。
nokaslr 只是让 choose_random_location() 直接返回 pref_address,并不会关掉页表隔离或 SMEP/SMAP。
__startup_64:为什么要「修正自己」
被解压的内核是按链接地址(0xffffffff81000000 起)编译的,但物理上它可能被放在任意位置。__startup_64() 在开启正式页表前做一次「自我重定位」:
/* arch/x86/kernel/head64.c: __startup_64() —— 6.8 节选,已简化 */
unsigned long __head __startup_64(unsigned long physaddr,
struct boot_params *bp)
{
unsigned long load_delta, *p;
...
/* 1. 算出「运行时物理地址 - 链接地址」的差 */
load_delta = physaddr - (unsigned long)(_text - __START_KERNEL_map);
if (load_delta & ~PMD_MASK)
for (;;)
; /* 未按 2 MiB 对齐,死循环等重启 */
...
/* 2. 用差值修正页表项与关键指针 */
for (p = &early_top_pgt; p < &early_top_pgt + PTRS_PER_PGD; p++)
*p = (*p & ~PTE_MASK) | ...;
...
/* 3. 建立直接映射与内核映射 */
...
}
三个细节:
- 对齐要求:
load_delta必须按 2 MiB(PMD_MASK)对齐,否则死循环。KASLR 选地址时已经保证;手工kexec或自己写引导器时容易踩。 early_top_pgt是静态数组,在.bss里;__startup_64之前不能调用任何依赖current或memblock的代码。- 5 级页表:
CONFIG_X86_5LEVEL=y时,__startup_64还要检测 CPU 是否支持 LA57,并决定用 4 级还是 5 级;pgtable_l5_enabled()之后才可用。
早期页表的四层
x86-64 四级页表:PGD → PUD → PMD → PTE。启动早期只用 2 MiB 大页(PMD 直接指向物理页,不走 PTE),因为:
- 大页映射少一层,建表代码短;
- 内核代码段按 2 MiB 对齐,
mark_rodata_ro()之前也只能按大页改权限; - 早期内存分配器(memblock)还没有页表管理能力。
| 表 | 符号 | 映射 |
|---|---|---|
| PGD | early_top_pgt / init_top_pgt | 内核空间的 512 项 |
| PUD | level3_kernel_pgt | 内核代码/数据区 |
| PMD | level2_kernel_pgt | _text 到 _end,2 MiB 大页 |
| PMD | level2_fixmap_pgt | fixmap 区(早期 ioremap) |
| PMD | level2_ident_pgt | 恒等映射(低地址,供实模式切换用) |
x86_64_start_kernel() 之后,paging_init() 才建立完整的直接映射(page_offset_base 起,按 struct page 大小算 vmemmap),并把 init_top_pgt 切换为正式页表。
- 「
vmlinux的入口是start_kernel」:ELF 入口是startup_64(arch/x86/kernel/head_64.S),start_kernel是 C 层第一个函数。 - 「KASLR 只随机化虚拟地址」:它随机化物理加载地址;虚拟地址由
__START_KERNEL_map固定,kaslr_offset()反映的是物理偏移在虚拟空间的投影。 - 「
nokaslr能关掉所有随机化」:只关内核镜像的物理随机化;直接映射、vmalloc、vmemmap 的随机化由CONFIG_RANDOMIZE_MEMORY控制,另有nokaslr之外的开关。 - 「解压后的内核就是
vmlinux原样」:解压后还经过parse_elf按段布局、handle_relocations修正、__startup_64重定位,最终内存布局与 ELF 文件布局不同。 - 「早期代码可以用
kmalloc」:到mm_core_init()之前只有memblock;kmalloc在kmem_cache_init()之后才可用。
3.2.12 内存初始化的顺序:memblock、伙伴系统与 slab
这一节回答:start_kernel() 里内存管理分了哪几步、为什么 memblock 不能直接换成伙伴系统、struct page 数组什么时候才有。
启动期的内存管理是「三代同堂」:memblock(早期,按区段管理)、伙伴系统(按页管理)、slab(按对象管理)。它们不是替换关系,而是逐层接管:
setup_arch()
→ e820__memory_setup() 固件内存地图 → e820_table
→ memblock_add() 可用区登记进 memblock
→ memblock_reserve() 保留内核、initrd、设备区
→ init_mem_mapping() 建直接映射(此时还没有 struct page)
→ paging_init()
→ sparse_init() / vmemmap 分配 struct page 数组
→ free_area_init() 初始化 zone、伙伴系统
→ memblock_free_all() 把 memblock 的可用页交给伙伴系统
mm_core_init()
→ kmem_cache_init() 第一个 slab cache(kmem_cache 自己)
→ 各子系统 cache 建立
为什么需要 memblock
memblock 是「数组 + 区间」的极简分配器:memblock.memory 记录所有可用物理区间,memblock.reserved 记录已占用区间,分配就是找洞。它不依赖页表、不依赖 struct page、不依赖锁,所以在 start_kernel() 最早就能用。
| API | 用途 | 何时可用 |
|---|---|---|
memblock_add(base, size) | 登记可用内存 | setup_arch() 早期 |
memblock_reserve(base, size) | 保留(内核、initrd、ACPI) | 同上 |
memblock_alloc(size, align) | 分配物理内存(返回物理地址) | 伙伴系统之前 |
memblock_alloc_try_nid | 指定 NUMA 节点 | NUMA 初始化 |
memblock_free_all() | 把未保留内存交给伙伴系统 | paging_init() 末尾 |
memblock=debug 启动参数会打印每次 add/reserve/alloc,是排查「内核占了哪块内存」的利器。
paging_init() 之后
paging_init() 建立 struct page 数组(vmemmap)与 zone 结构,然后 free_area_init() 按 MAX_ORDER(6.4 起为 10,即最大 4 MiB 块)初始化每个 zone 的空闲链表。此后 alloc_pages() 可用,但 kmalloc() 还不行——slab 还没初始化。
mm_core_init() 的顺序(6.8,简化):
mm_core_init()
→ page_ext_init()
→ init_debug_pagealloc()
→ report_meminit()
→ mem_init() 打印 Memory: ... available
→ kmem_cache_init() 建立 kmem_cache 自身、kmalloc 系列 cache
→ kmemleak_init()
→ setup_per_cpu_pageset() per-CPU 页统计
→ page_alloc_sysctl_init()
→ vmalloc_init() vmalloc 区、vmap_area
第一条 kmalloc() 只能发生在 kmem_cache_init() 之后。这也是为什么 start_kernel() 里 setup_arch() 与 mm_core_init() 之间的代码只能用 memblock_alloc() 和静态数组。
zone 与水线
struct zone 是伙伴系统的顶层容器:
| 字段 | 含义 |
|---|---|
free_area[MAX_ORDER] | 每个阶的空闲链表 |
watermark[NR_WMARK] | WMARK_MIN/LOW/HIGH 三条水线 |
lowmem_reserve[] | 低端内存保留,防止高区耗尽低区 |
nr_free_pages | 当前空闲页数(per-CPU 批量计数汇总) |
managed_pages | 受伙伴系统管理的页数(不含保留区) |
spanned_pages | zone 覆盖的页数(含洞) |
lock | 保护 free_area 的自旋锁 |
水线在启动时由 init_per_zone_wmark_min() 按 min_free_kbytes 计算,运行期可由 watermark_scale_factor 调整。3.10 会讲 kswapd 怎么用水线;这里只需记住:首次 alloc_pages() 之前,水线必须已经算好,否则直接回收会在启动期触发。
6.4 起 MAX_ORDER=10,最大连续块 4 MiB(4 KiB 页)。这不是「内核不能分配更大连续内存」,而是「伙伴系统不直接管理更大块」;需要 2 MiB 大页时用 alloc_pages(GFP_KERNEL, 9),需要更大连续物理内存只能靠 CMA 或启动期 memblock_alloc()。/proc/buddyinfo 的列数就是 MAX_ORDER。
3.2.13 initcall 深入:等级、黑名单与延迟探测
这一节回答:do_initcalls() 怎么遍历、同一等级内谁先谁后、initcall_blacklist 怎么用、-EPROBE_DEFER 与 initcall 等级是什么关系。
段与遍历
include/linux/init.h 把初始化函数指针按等级放进 .initcallN.init 段:
/* include/linux/init.h —— 6.8 节选 */
#define __define_initcall(fn, id) \
static initcall_t __initcall_##fn##id __used \
__attribute__((__section__(".initcall" #id ".init"))) = fn;
#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 device_initcall(fn) __define_initcall(fn, 6)
#define late_initcall(fn) __define_initcall(fn, 7)
#define module_init(x) __initcall(x) /* 模块里:加载时调用 */
do_initcalls() 在 init/main.c:
/* init/main.c: do_initcalls() —— 6.8 节选,已简化 */
static void __init do_initcalls(void)
{
int level;
for (level = 0; level < ARRAY_SIZE(initcall_levels) - 1; level++)
do_initcall_level(level, initcall_level_names[level]);
...
}
do_initcall_level() 先 parse_args() 解析该等级的参数(如 initcall_blacklist=),再遍历 initcall_levels[level] 到 initcall_levels[level+1] 之间的函数指针,逐个 do_one_initcall()。同一等级内的顺序由链接顺序决定:链接器按 vmlinux.lds.h 里 .initcall6.init 段的输入顺序排列,而输入顺序由 Makefile 的 obj-y 顺序决定。改动一个目录的 obj-y 顺序,可能改变两个驱动谁先 probe。
do_one_initcall() 检查什么
/* init/main.c: do_one_initcall() —— 6.8 节选,已简化 */
int __init_or_module do_one_initcall(initcall_t fn)
{
int count = preempt_count();
char msgbuf[64];
int ret;
if (initcall_blacklisted(fn))
return -EPERM;
do_trace_initcall_start(fn);
ret = fn();
do_trace_initcall_finish(fn, ret);
if (preempt_count() != count) {
sprintf(msgbuf, "preemption imbalance");
...
}
if (irqs_disabled()) {
sprintf(msgbuf, "disabled interrupts");
...
}
...
return ret;
}
两个检查针对的是「initcall 把状态搞坏了」:preempt_count 不平衡说明某处 preempt_disable() 没配对,irqs_disabled() 说明关了中断没开。它们不会让启动失败,但会在日志里留下 initcall ... returned with preemption imbalance。
initcall_blacklist 与 initcall_debug
# 禁用某个内置驱动的初始化(函数名来自 System.map 或 dmesg)
-append "... initcall_blacklist=e1000_init_module"
# 打印每个 initcall 的耗时与返回值
-append "... initcall_debug"
# 只调试某个等级(6.8 支持 initcall_debug= 的 level 过滤?)
# 实际可用的是:tracepoint:initcall:initcall_start/finish
initcall_blacklist 在 initcall_blacklisted() 里按符号名匹配,返回 -EPERM,效果与「这个驱动没编进内核」类似,但模块仍然可以后来 insmod。它适合做「二分定位」:启动卡死时,逐个禁用可疑驱动的 initcall,看哪一个消失后能起来。
-EPROBE_DEFER:不要用 initcall 等级解决依赖
很多驱动在 probe 时需要另一个驱动提供的资源(clock、regulator、GPIO、PHY)。如果提供者还没 probe,驱动返回 -EPROBE_DEFER,驱动核心把它放进 deferred_probe_pending_list,等所有 initcall 跑完后由 deferred_probe_work 重试:
driver_probe_device()
→ really_probe()
→ drv->probe(dev)
→ 返回 -EPROBE_DEFER
→ driver_deferred_probe_add(dev)
→ 挂到 deferred_probe_pending_list
do_initcalls() 结束
→ driver_deferred_probe_trigger()
→ 调度 deferred_probe_work
→ 逐个重试,直到成功或列表为空
这正是 3.2.5 那个触摸屏例子的正确修法:依赖关系用 -EPROBE_DEFER 表达,不要靠 late_initcall 抢顺序。等级只能保证「我比你晚」,不能保证「你的依赖已经就绪」;而且等级是全局的,调高一个驱动会拖慢所有平台的启动。
| 机制 | 表达什么 | 失败后果 |
|---|---|---|
| initcall 等级 | 全局阶段顺序 | 同等级顺序不确定;跨等级过度延迟 |
-EPROBE_DEFER | 「我的依赖还没好」 | 无限 defer 会留下 deferred probe pending 日志 |
device_link | 设备间强制依赖(consumer/supplier) | 循环依赖会被检测 |
fw_devlink | 从固件(DT/ACPI)推导依赖 | 描述错误会推迟 probe |
- 「
module_init有等级」:模块加载时只调用module_init注册的函数,等级宏在模块里被__initcall展开成普通段,不影响加载顺序。 - 「同一等级按文件名排序」:按链接顺序,链接顺序由 Makefile 决定;不要依赖字母序。
- 「initcall 返回非 0 会回滚」:不会。内核只记录返回值,资源不会自动释放;init 函数必须自己清理。
- 「
late_initcall能保证依赖就绪」:不能,只是更晚;依赖应该用-EPROBE_DEFER或device_link。 - 「
initcall_debug没有开销」:每次 initcall 都打印一行并读jiffies,启动日志会显著变长;生产内核不要默认开。
3.2.14 initramfs 的字节格式:cpio newc 与嵌入方式
这一节回答:initramfs 归档里每个文件长什么样、CONFIG_INITRAMFS_SOURCE 怎么把它嵌进内核、解包失败时内核怎么回退。
3.2.6 讲了 initramfs 的作用与打包流程。真正排错时需要看字节:Initramfs unpacking failed 这类错误,只有对着 cpio 头才能定位。
newc 头:110 字节的 ASCII
newc(SVR4 无 CRC 的 cpio)每个条目由「110 字节头 + 文件名 + 填充 + 数据 + 填充」组成,所有字段是 8 位十六进制 ASCII:
| 偏移 | 长度 | 字段 | 含义 |
|---|---|---|---|
| 0 | 6 | c_magic | "070701" |
| 6 | 8 | c_ino | inode 号 |
| 14 | 8 | c_mode | 类型 + 权限(0100644 普通文件) |
| 22 | 8 | c_uid | 所有者 |
| 30 | 8 | c_gid | 所属组 |
| 38 | 8 | c_nlink | 链接数 |
| 46 | 8 | c_mtime | 修改时间 |
| 54 | 8 | c_filesize | 数据长度 |
| 62 | 8 | c_devmajor | 主设备号 |
| 70 | 8 | c_devminor | 次设备号 |
| 78 | 8 | c_rdevmajor | 特殊文件的主设备号 |
| 86 | 8 | c_rdevminor | 特殊文件的次设备号 |
| 94 | 8 | c_namesize | 文件名长度(含结尾 \0) |
| 102 | 8 | c_check | 校验和,newc 固定 0 |
| 110 | namesize | 文件名 | 含 \0,之后补零到 4 字节边界 |
| — | filesize | 数据 | 之后补零到 4 字节边界 |
归档以 "TRAILER!!!" 条目结束(c_namesize=11,c_filesize=0)。内核的 unpack_to_rootfs() 只认 070701(newc)与 070702(crc),不认老式 070707(odc)。
# 看 initramfs 的前 128 字节
xxd -l 128 initramfs.cpio.gz | head
# 若是 gzip,先解压
zcat initramfs.cpio.gz | xxd -l 128
第一个条目通常是 .(目录),头里 c_mode=040755、c_namesize=2。如果第一个字节不是 070701,说明打包时用了 --format=bin 或 odc,内核会报 invalid magic。
三种嵌入方式
| 方式 | 配置/命令 | 归档在哪 | 适用 |
|---|---|---|---|
| 编译进内核 | CONFIG_INITRAMFS_SOURCE="path" | .init.ramfs 段 | 嵌入式、单文件内核 |
| 引导器加载 | -initrd file / GRUB initrd | 内存,initrd_start | 发行版、实验 |
| 外部 initramfs | 无(根就是 initramfs) | rootfs | 容器、救援 |
编译进内核时,usr/initramfs_data.S 用 .incbin 把归档塞进段:
/* usr/initramfs_data.S —— 6.8 */
.section .init.ramfs,"a"
__irf_start:
.incbin "usr/initramfs_data.cpio"
__irf_end:
链接脚本把 .init.ramfs 放在 .init.data 旁边,populate_rootfs() 通过 __initramfs_start/__initramfs_size 找到它。CONFIG_INITRAMFS_SOURCE 可以指向目录、cpio 文件,或一个「文件列表」文本(每行 file <name> <location> <mode> <uid> <gid>),构建时由 usr/gen_init_cpio 生成归档。
解包流程与失败回退
populate_rootfs() (rootfs_initcall)
→ unpack_to_rootfs(__initramfs_start, __initramfs_size)
→ 解析 cpio,逐条 do_name()/do_copy()
→ 失败:打印 "Initramfs unpacking failed: ..."
→ 若 initrd_start 存在(引导器传的 initrd)
→ unpack_to_rootfs(initrd_start, initrd_end - initrd_start)
→ free_initrd()
两种失败要区分:
invalid magic at start of compressed archive:归档不是 cpio,或压缩格式内核不支持(如 zstd 但没开CONFIG_RD_ZSTD)。junk in compressed archive:cpio 条目长度/填充不对,通常是打包工具用了非 4 字节对齐。
失败后内核不会 panic,而是继续尝试 initrd_start;两者都没有,就等真实根设备。所以「initramfs 明明打了却没生效」时,先看这两行日志。
root= 的解析在谁手里
root= 不改变 initramfs 的解包,它只影响 prepare_namespace()(在 kernel_init_freeable() 里)尝试挂载的根设备:
| 参数 | 作用 |
|---|---|
root=/dev/vda1 | 按设备名挂载(需要 devtmpfs 或已存在的 /dev 节点) |
root=PARTUUID=... | 按分区 UUID 挂载(GPT) |
root=UUID=... | 按文件系统 UUID 挂载(需要 initramfs 里的 blkid) |
rootfstype=ext4 | 指定文件系统类型,跳过探测 |
rootflags=... | 传给文件系统的挂载选项 |
rootwait | 无限等根设备出现(USB/MMC 常用) |
rootdelay=N | 等 N 秒再挂 |
如果 initramfs 里的 /init 自己挂根并 switch_root,内核的 root= 只作为「提示」传给 /init(通过 /proc/cmdline),真正的挂载由用户态脚本完成。发行版的 initramfs 大多走这条路。
- 「
cpio默认格式就是 newc」:GNU cpio 默认可能是bin;必须显式--format=newc,否则内核不认。 - 「
find .与find . -print0一样」:文件名带空格/换行时,-print0+cpio --null才安全;否则归档截断。 - 「归档里可以有绝对路径」:cpio 里的名字必须是相对路径(
./init),以/开头会被内核拒绝或产生奇怪目录。 - 「initramfs 会自动挂
/proc」:不会,/init自己挂;很多「ps不工作」的救援环境就是漏了mount -t proc。 - 「initramfs 占的内存会释放」:rootfs 是 ramfs,文件删除才释放;
switch_root后旧根必须靠mount --move+ 删除内容才能回收。 - 「
CONFIG_INITRAMFS_SOURCE改了会自动重编」:Kbuild 有依赖,但增量构建偶尔不触发;改完先make clean或删usr/initramfs_data.cpio。
3.2.15 根切换:pivot_root 与 switch_root 的语义
这一节回答:pivot_root 与 chroot 差在哪、pivot_root(".", ".") 这个怪调用为什么合法、switch_root 删旧根时为什么不会误删新根。
pivot_root 的系统调用语义
int pivot_root(const char *new_root, const char *put_old);
它做三件事:
- 把当前进程的根从旧根换成
new_root; - 把旧根挂到
put_old(必须是new_root下的一个目录); - 让旧根不再是我的根,但仍是挂载树的一部分,可以随后卸载。
约束(man 2 pivot_root):
new_root必须是一个挂载点,且不能是当前根;put_old必须在new_root之下,且不能与new_root同一目录;new_root与put_old不能位于MS_SHARED的挂载上(否则EBUSY);- 调用者需要
CAP_SYS_ADMIN。
常见错误:pivot_root("/mnt", "/mnt/old") 之后忘了 chdir("/"),进程的 cwd 还指着旧根,旧根永远卸不掉。正确顺序是 chdir(new_root) → pivot_root(".", ".") → chdir("/"),其中 pivot_root(".", ".") 是标准技巧:new_root 与 put_old 都传当前目录,内核把旧根叠在自身上,之后旧根通过 /proc/1/root 或 umount 处理。
switch_root 为什么不是简单 chroot
switch_root(util-linux/busybox)是给 initramfs 用的封装:
switch_root(newroot, init)
1. chdir(newroot)
2. mount(".", "/", NULL, MS_MOVE, NULL) 把新根移到 /
3. chroot(".")
4. 删除旧根下的所有文件(保留挂载点)
5. execv(init, ...)
第 2 步的 MS_MOVE 是关键:它把 newroot 这棵挂载树移动到 /,旧根被覆盖但仍在挂载树里;第 3 步 chroot(".") 之后,进程的根就是新根。第 4 步删除旧根内容是为了释放 ramfs 内存——如果旧根里还有别的挂载(如 /proc),要先 umount 或跳过。
| 操作 | 改变 fs_struct->root | 改变挂载树 | 旧根内存可回收 |
|---|---|---|---|
chroot(dir) | 是 | 否 | 否 |
pivot_root(new, old) | 是 | 是(旧根挂到 old) | 卸载后可以 |
mount --move new / + chroot | 是 | 是 | 删除内容后可以 |
initramfs 的 /init 最小实现
发行版的 /init 是 shell 脚本,核心逻辑可以压成 30 行:
#!/bin/sh
# 最小 init:挂伪文件系统,找根,切换,执行真 init
mount -t proc none /proc
mount -t sysfs none /sys
mount -t devtmpfs none /dev
# 等根设备出现(简单重试)
i=0
while [ ! -b /dev/vda1 ] && [ $i -lt 50 ]; do
sleep 0.1; i=$((i+1))
done
mount -t ext4 -o rw /dev/vda1 /newroot || exec sh
# 把 /proc /sys /dev 移到新根
for d in proc sys dev; do
mount --move /$d /newroot/$d
done
exec switch_root /newroot /sbin/init
真实的 dracut 还要处理 LVM、加密盘、网络根、resume=,但骨架就是这五步。读不懂发行版 initramfs 时,先在自己的 30 行版本上跑通,再对照它的 hook 目录。
容器运行时的 pivot_root 与这里同源,但多一层:新根通常是 overlayfs 的挂载点,旧根要 umount 掉,且整个操作在 mount namespace 里完成。runc 的 prepareRootfs() 会先 mount --make-rprivate /,再 pivot_root,最后 umount 旧根——顺序错了就会把宿主的挂载树带歪。
3.2.16 观测启动:initcall tracepoint、bootgraph 与 systemd-analyze
这一节回答:不打开 initcall_debug 也能量 initcall 吗、启动时间怎么拆成「内核 / initramfs / 用户态」、systemd-analyze 的数字从哪来。
内核侧:initcall tracepoint
include/trace/events/initcall.h 定义了三个事件:initcall_start、initcall_finish、initcall_level。它们比 initcall_debug 轻,因为不打印到 console,只写 ring buffer:
# 统计每个 initcall 的耗时(微秒),只列超过 1 ms 的
sudo bpftrace -e '
tracepoint:initcall:initcall_start { @s[tid] = nsecs; }
tracepoint:initcall:initcall_finish /@s[tid]/ {
$us = (nsecs - @s[tid]) / 1000;
if ($us > 1000)
printf("%s %d us\n", str(args->func), $us);
delete(@s[tid]);
}'
args->func 是函数名字符串(tracepoint 里存了 __builtin_return_address 解析后的符号)。输出按耗时排序后,就是「启动慢在哪」的直接答案。
# 按等级统计 initcall 数量
sudo bpftrace -e '
tracepoint:initcall:initcall_level { @[str(args->level)] = count(); }'
内核侧:bootgraph.pl
内核源码自带 scripts/bootgraph.pl,把 dmesg 的 initcall_debug 输出画成 SVG:
# 启动参数加 initcall_debug,启动后
dmesg | perl scripts/bootgraph.pl > /tmp/boot.svg
它按 initcall X+0x0/0x... returned 0 after N usecs 的行解析,横轴是时间,纵轴是调用深度。适合看「哪一段整体变慢」,不适合看单个函数。
用户态:systemd-analyze
systemd-analyze 的数字来自三个来源:
| 命令 | 数据来源 | 含义 |
|---|---|---|
systemd-analyze time | /proc/uptime + manager 时间戳 | firmware / loader / kernel / userspace 四段 |
systemd-analyze blame | 每个 unit 的 ExecStart 前后时间 | 哪个服务最慢 |
systemd-analyze critical-chain | unit 依赖链 | 关键路径上的等待 |
systemd-analyze plot | 上述数据 | SVG 时间线 |
systemd-analyze time 的 kernel 段是「内核启动到 init 开始」,userspace 段是「init 开始到 default.target」。如果 kernel 段很长,用 3.2.16 的 initcall 探针;如果 userspace 长,用 blame 与 critical-chain。
三段证据对齐
# 1. 内核日志时间戳(相对 boot)
dmesg -t | head -5
# 2. 内核命令行与总耗时
cat /proc/cmdline
cat /proc/uptime
# 3. systemd 的分段
systemd-analyze time
# 4. 最慢的 10 个 initcall(需 initcall_debug)
dmesg | grep 'initcall' | sed 's/.*after //' | sort -n | tail
四组数字应该能对上:dmesg 最后一行的时间 ≈ systemd-analyze 的 kernel 结束点;/proc/uptime 是总时间。对不上说明有日志丢失(log_buf_len 太小)或时钟源切换导致时间跳变。
- 先看
systemd-analyze time:firmware长 → BIOS/UEFI 设置;loader长 → GRUB 菜单超时;kernel长 → 3.2.16 的 initcall;userspace长 →blame。 - 内核段再二分:
initcall_debug找最慢的等级,bpftrace找最慢的函数。 - 单个驱动慢:看它的 probe 是否在等硬件(
usleep_range、I2C 重试),而不是内核调度。 - 时间对不上:查
clocksource切换(dmesg | grep clocksource)与log_buf_len。
3.2.17 实验:手写 cpio initramfs 与 initcall 黑名单
这一节给两个可运行实验:一个不依赖发行版工具、用 gen_init_cpio 手搓最小 initramfs;一个用 initcall_blacklist 做启动期二分开机。均在 QEMU(Linux 6.8,x86-64)验证流程,未在真机实测。
实验一:30 行 C 程序当 PID 1
mini_init.c 只做四件事:挂 /proc、/sys、/dev,打印自己的 PID 与命令行,然后关机。
/* mini_init.c — initramfs 的 PID 1(Linux 6.8)
* 编译: cc -static -O2 -Wall -Wextra -Werror -o init mini_init.c
*/
#define _GNU_SOURCE
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <sys/mount.h>
#include <sys/reboot.h>
#include <sys/stat.h>
#include <unistd.h>
static void cat(const char *path)
{
char buf[512];
ssize_t n;
int fd = open(path, O_RDONLY | O_CLOEXEC);
if (fd < 0) {
dprintf(STDERR_FILENO, "open %s failed\n", path);
return;
}
while ((n = read(fd, buf, sizeof buf)) > 0)
write(STDOUT_FILENO, buf, (size_t)n);
close(fd);
}
int main(void)
{
mkdir("/proc", 0555);
mkdir("/sys", 0555);
mkdir("/dev", 0755);
if (mount("proc", "/proc", "proc", 0, NULL) < 0)
perror("mount proc");
if (mount("sysfs", "/sys", "sysfs", 0, NULL) < 0)
perror("mount sysfs");
if (mount("devtmpfs", "/dev", "devtmpfs", 0, NULL) < 0)
perror("mount devtmpfs");
printf("mini_init: pid=%d\n", getpid());
cat("/proc/cmdline");
cat("/proc/mounts");
fflush(stdout);
sync();
reboot(RB_POWER_OFF); /* 需要 CAP_SYS_BOOT;QEMU -no-reboot 会退出 */
for (;;)
pause();
return 0;
}
用内核源码里的 gen_init_cpio 打包,而不是 cpio 命令——它不需要 root,也不会把宿主文件的 uid/gid 带进去:
# 1. 编译 init(静态链接,initramfs 里没有 libc)
cc -static -O2 -Wall -Wextra -Werror -o init mini_init.c
# 2. 写文件列表:路径 权限 uid gid
cat > initramfs.list <<'EOF'
dir /dev 0755 0 0
nod /dev/console 0600 0 0 c 5 1
nod /dev/null 0666 0 0 c 1 3
file /init ./init 0755 0 0
EOF
# 3. 生成 cpio + gzip
/path/to/linux-6.8/usr/gen_init_cpio initramfs.list | gzip -9 > initramfs.cpio.gz
# 4. 验证头
xxd -l 120 initramfs.cpio 2>/dev/null || zcat initramfs.cpio.gz | xxd -l 120
# 00000000: 3037 3037 3031 ... ← "070701"
# 5. 启动
qemu-system-x86_64 -m 512M -smp 2 \
-kernel arch/x86/boot/bzImage \
-initrd initramfs.cpio.gz \
-append "console=ttyS0 rdinit=/init nokaslr" \
-nographic -no-reboot
预期输出(节选):
[ 0.512345] Unpacking initramfs...
[ 0.678901] Run /init as init process
mini_init: pid=1
console=ttyS0 rdinit=/init nokaslr
proc /proc proc rw,relatime 0 0
sysfs /sys sysfs rw,relatime 0 0
devtmpfs /dev devtmpfs rw,relatime,size=...
[ 1.234567] reboot: Power down
三个可直接观察的点:
pid=1证明rdinit=/init被kernel_init()执行,且 exec 后 PID 不变;/proc/mounts里proc/sysfs/devtmpfs的顺序就是你代码里 mount 的顺序;reboot: Power down说明 PID 1 的reboot(2)直接生效,不需要 init 脚本。
把 rdinit=/init 改成 rdinit=/nope,会看到内核回退到 init= 与默认列表,最后 panic No working init found。这就是 3.2.4 那条优先级链的实证。
实验二:用 initcall_blacklist 做启动二分
假设某个内置驱动让启动卡住,先确认它的 initcall 名字:
grep -E 'e1000_init_module|virtio_blk' System.map
# ffffffff82a12340 t e1000_init_module
启动参数禁用它:
qemu-system-x86_64 ... -append "console=ttyS0 root=/dev/vda rw \
initcall_blacklist=e1000_init_module initcall_debug"
预期:
initcall e1000_init_module+0x0/0x... blacklisted
...
[ 2.345678] e1000: ...(没有这行)
进系统后 ip link 看不到 eth0,但内核仍能启动——这证明「驱动没初始化」和「内核起不来」是两件事。用同样的方法逐个禁用可疑驱动,能在几次重启内定位到具体 initcall。
实验三:量一次 initcall 耗时
# 虚机内,bpftrace 可用时
sudo bpftrace -e '
tracepoint:initcall:initcall_start { @s[tid] = nsecs; }
tracepoint:initcall:initcall_finish /@s[tid]/ {
$us = (nsecs - @s[tid]) / 1000;
if ($us > 500)
printf("%-40s %6d us\n", str(args->func), $us);
delete(@s[tid]);
}'
加载一个模块(模块的 init 也会走 do_one_initcall):
sudo insmod /hellomod.ko repeat=1000
若 hellomod_init 出现在输出里且耗时明显,说明 do_one_initcall 的计时覆盖了模块 init;这也解释了为什么模块 init 里不要做长时间睡眠——它会拖住调用它的进程(insmod),而不是「后台初始化」。
- 「
gen_init_cpio在 PATH 里」:它是内核构建产物,路径是usr/gen_init_cpio,要先make usr/或完整构建。 - 「静态链接的 glibc 程序一定能跑」:
getpwnam()等 NSS 调用会去/etc/nsswitch.conf,最小 initramfs 里没有;要么避免 NSS,要么用 musl/BusyBox。 - 「
reboot(RB_POWER_OFF)在 QEMU 里一定退出」:需要 ACPI 或isa-debug-exit;老 QEMU 可能只打印Power down不退出,用-no-reboot+Ctrl-A X兜底。 - 「
initcall_blacklist对模块有效」:只对内置 initcall 生效;模块加载走load_module(),不受它影响。 - 「
initcall_debug的耗时包含中断」:它用jiffies/ktime包住函数调用,包含被中断的时间;要看纯 CPU 时间用perf。
3.2.18 陷阱清单:启动与 initramfs 篇
| # | 现象 | 原因 | 修复 |
|---|---|---|---|
| 1 | 卡在 Run /init as init process | /init 不可执行、解释器缺失、rdinit 拼错 | chmod +x;静态链接;检查 rdinit= |
| 2 | No working init found | initramfs 顶层多了一层目录 | cpio -t 确认 init 在根 |
| 3 | VFS: Cannot open root device | 根设备驱动是模块且 initramfs 没加载 | 编进内核,或在 /init 里 modprobe |
| 4 | Initramfs unpacking failed: invalid magic | 用了 cpio -H bin 或非 gzip 压缩 | --format=newc,确认 CONFIG_RD_GZIP |
| 5 | 启动后没有 eth0 | initcall_blacklist/驱动没编 | dmesg 查 eth;lspci -k |
| 6 | gdb 断点打不中 | KASLR 或用了 bzImage 而非 vmlinux | nokaslr;gdb vmlinux |
| 7 | switch_root 后旧根占满内存 | 没删旧根内容/没卸载旧挂载 | mount --move 后删除,或 umount -l |
| 8 | pivot_root: Invalid argument | new_root 不是挂载点,或 put_old 不在其下 | 先 mount --bind;put_old 用子目录 |
| 9 | pivot_root: Device or resource busy | 挂载是 MS_SHARED | mount --make-rprivate / 再切 |
| 10 | 启动时间戳跳变 | clocksource 切换或 log_buf_len 溢出 | dmesg 查 clocksource;加大 log_buf_len |
| 11 | initcall ... preemption imbalance | init 函数关了抢占没恢复 | 检查 preempt_disable/enable 配对 |
| 12 | 模块在内核启动时没加载 | 编成了 m,但没人 modprobe | 改 y,或在 initramfs 里显式加载 |
3.2.19 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 画出固件→引导器→内核→init 的链路 | 能说出每段的交棒函数或协议 | 3.2.1 |
按顺序说出 start_kernel 的八步 | 至少说出内存、调度、中断、console 的位置 | 3.2.2 |
解释 setup_arch 为什么不能用 kmalloc | 说出 memblock 与 slab 的先后 | 3.2.3 |
| 说清 PID 0/1/2 的区别 | idle、init、kthreadd 各是什么 | 3.2.4 |
用 initcall_debug 找慢驱动 | 能给出 dmesg 过滤命令 | 3.2.5 |
| 区分 initramfs 与 initrd | 说出 cpio 与块设备的差别 | 3.2.6 |
用 systemd-analyze 定位慢 unit | 能读 critical-chain | 3.2.7 |
说出解压器与 __startup_64 的分工 | 物理重定位发生在哪 | 3.2.11 |
| 画出 memblock→伙伴→slab 顺序 | 首次 kmalloc 何时可用 | 3.2.12 |
解释 -EPROBE_DEFER | 为什么不靠等级抢顺序 | 3.2.13 |
| 手写一个 cpio initramfs | 头是 070701 | 3.2.14 |
说出 pivot_root 三条约束 | 挂载点/子目录/非 shared | 3.2.15 |
Q1 bzImage 里的 startup_32 与 vmlinux 里的 startup_64 各在什么模式下运行?中间谁建立了长模式所需的页表?
Q2 start_kernel() 里 console_init() 之前调用的 pr_notice() 输出去了哪里?为什么最终还能看到?
Q3 kernel_init 是 PID 1,但它最后变成了 /sbin/init。ps 里看到的 PID 1 是谁?kernel_init 这个函数还在吗?
Q4 一个驱动用 module_init() 注册,编译成 y 和编译成 m 时,初始化时机分别是什么?
Q5 root=/dev/vda 启动失败,报 VFS: Cannot open root device "vda"。列出三种可能原因与对应的验证方法。
答案
A1 startup_32 运行在 32 位保护模式(由引导器或 EFI stub 准备),它建立临时页表并打开 EFER.LME/CR0.PG 进入长模式,然后跳到 startup_64。真正的内核页表 init_top_pgt 在 startup_64(arch/x86/kernel/head_64.S)里建立,之后才调用 x86_64_start_kernel()。
A2 进了 log_buf(__log_buf 静态缓冲区)。console_init() 之后注册的 console 会把缓冲区里未打印的记录一次性刷出,所以最终能在串口看到,但时间戳是记录时刻,不是打印时刻。
A3 ps 看到的是 /sbin/init(systemd),因为 kernel_execve() 用新程序替换了 PID 1 的地址空间与 comm。kernel_init 这个函数仍在 vmlinux 里,但它的代码段属于 .init.text,在 free_initmem() 后被释放——它不会再被执行。
A4 编成 y 时,module_init 展开成 device_initcall,在 do_basic_setup() 的 initcall 阶段(等级 6)执行;编成 m 时,module_init 只记录入口地址,实际执行发生在 insmod 调用 do_init_module() 时,与启动顺序无关。
A5 ① 内核没有 virtio_blk 驱动(CONFIG_VIRTIO_BLK 未开或是模块且没加载)——ls /sys/bus/virtio/devices、dmesg | grep virtio;② root= 设备名错,QEMU 用 if=virtio 时是 /dev/vda,用 if=ide 时是 /dev/sda——看 dmesg 里 virtio_blk 的盘符;③ 根文件系统类型未编入(如缺 ext4)——cat /proc/filesystems,或临时加 rootfstype=ext4 看报错变化。
母本对应
| 母本 | 章节/小节 | 原书页(PDF) | 本书落点 | 程度 |
|---|---|---|---|---|
| 奔跑吧 Linux 内核 | 第 2 章 2.1 物理内存初始化 | p57–71 | 3.2.3、3.2.12 | 完整(e820、memblock、paging_init) |
| 奔跑吧 Linux 内核 | 第 2 章 2.4 分配物理页面(伙伴系统) | p93–110 | 3.2.12 | 精选(zone/水线在 3.9 展开) |
| 奔跑吧 Linux 内核 | 第 6 章 6.1 QEMU 调试 Linux 内核 | p705–714 | 3.2.16、3.2.17 | 改写(x86-64 + 6.8) |
| 李治军 | 第 1 章 系统启动(实践项目 1) | 章级 | 3.2.1–3.2.2、3.2.11 | 对照(0.11 的 bootsect/setup/head 与 6.8 的对应关系) |
| 鸟哥 | 第 19 章 开机流程与主要服务 | 章级 | 3.2.1、3.2.7 | 精选(BIOS/MBR/runlevel 到 systemd target 的映射) |
| OSTEP | 第 6 章 机制:受限直接执行 | p51–62 | 3.2.4 | 背景(内核态/用户态切换的起点) |
| Kerrisk | 第 25 章 进程的终止 | 章级 | 3.2.4 | 背景(PID 1 与 _exit 的特殊性) |
下一站:3.3 中断、异常与系统调用 接着讲「启动完成后,CPU 怎么从用户态回到内核」:IDT 里装了什么、
int 0x80与syscall的差别、为什么中断要分上半部与下半部。