Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

Linux 启动流程:固件、引导、内核、initramfs、systemd 七个阶段与两个关键分界:内核交棒给用户态、systemd 达到 target。 从按下电源到登录提示符UEFI/BIOSPOST → 找 ESPbootloaderGRUB / systemd-bootkernel解压 / 初始化 / initcallinitramfs加载驱动 / 挂根initsystemd PID 1targetmulti-user / graphicallogingetty / sshd内核阶段的关键节点start_kernel → rest_init → kernel_init → run_init_process用户态阶段的关键节点systemd → 并行启动 unit → 达到 default.target观测:dmesg -T(内核)、journalctl -b(systemd)、systemd-analyze blame / critical-chain故障:initramfs 缺驱动 → 卡在「Waiting for root device」;unit 依赖环 → 启动超时
读图:从左到右读。前两段是固件与引导器,第三段内核初始化,第四段 initramfs 负责「挂上真正的根」,第五段起是用户态。排启动问题先确定卡在哪一段:dmesg 有输出说明已进内核;journalctl -b 有记录说明 systemd 已接管。

读图:七个方框里,只有第三个是内核。前面两段(固件、引导器)属于平台,后面两段(initramfs、init)已经是内核交棒之后的事。排启动故障时,先确定卡在哪一段——这比背流程有用。

三段的分工:

阶段运行环境干的活交棒方式
固件(UEFI/BIOS)16/32 位实模式起步,UEFI 下是 64 位POST、枚举内存与设备、找启动项加载 ESP 上的 .efi,或读 MBR
引导器(GRUB/systemd-boot)固件提供的执行环境读配置、选内核、准备 initrd、填 boot_paramsExitBootServices() 后跳内核入口
内核自带的 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_32arch/x86/boot/compressed/head_64.S32 位入口,建临时页表,进长模式
startup_64arch/x86/kernel/head_64.S64 位入口,设置 init_top_pgt,跳到 C
x86_64_start_kernelarch/x86/kernel/head64.c清 BSS、复制 boot_params、调 x86_64_start_reservations
start_kernelinit/main.c第一个「与架构无关」的 C 函数
定义 3.2.1 · boot protocol

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() 有上百行,按职责可压成八步:

顺序调用建立什么失败会怎样
1setup_arch(&command_line)内存映射、e820、ACPI、页表后面全部崩,通常无声重启
2setup_command_line() / setup_nr_cpu_ids()保存 command_line、算 CPU 数参数丢失、per-CPU 分配错
3setup_per_cpu_areas()per-CPU 变量区this_cpu_ptr 全错
4build_all_zonelists() / mm_core_init()伙伴系统、slab、vmalloc第一次 kmalloc 就 panic
5sched_init()运行队列、调度类、init_task 入队第一次 schedule() 崩
6rcu_init() / trap_init() / irq_init()RCU、IDT、中断控制器中断来即崩
7time_init() / console_init()时钟源、printk 输出没有任何日志
8arch_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 有问题。

定义 3.2.2 · 内核线程与用户进程的分界

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 出来。

6.8 与旧版的一处差异

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() 是启动过程中最长的一个函数,做四类事:

  1. 内存:e820__memory_setup() 把固件的内存地图复制成 e820_table,memblock_add() 把可用区登记进 memblock(伙伴系统之前的早期分配器),init_mem_mapping() 建立直接映射,paging_init() 初始化 zone 与 struct page 数组。
  2. 平台:解析 ACPI 表(acpi_boot_table_init())、SMBIOS/DMI、PCI 根桥、setup_ioapic_ids() 等。
  3. 命令行:parse_early_param() 先处理 early_param() 注册的参数(如 earlyprintk、mem=、nokaslr),其余留给后面的 parse_args()。
  4. 资源:把内核自身占用的区间(_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 顺序的入口。

关于 `setup_arch` 的四个误解
  1. 「此时可以用 kmalloc」:不行。slab 还没初始化,只能用 memblock_alloc() 或静态数组。6.8 里 memblock_alloc() 之后的 memblock_free() 也很常见。
  2. 「e820 就是全部物理内存」:e820 只描述固件眼里的 RAM/ACPI/保留区;内核还会用 memblock 叠加 mem=、crashkernel=、ELF core header 等约束。
  3. 「setup_arch 与架构无关」:正好相反,它是全内核最架构相关的一段,x86 与 arm64 的同名函数几乎没有共同代码。
  4. 「命令行参数随时可读」: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_init1user_mode_thread()跑 kernel_init_freeable(),然后 execve 用户态 init
kthreadd2kernel_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.");
}

三件事值得记:

  1. rdinit= 优先于 init=,两者都优先于默认列表。rdinit 只在 initramfs 里找,init 在最终根文件系统里找。
  2. 默认列表的顺序是固定的:/sbin/init → /etc/init → /bin/init → /bin/sh。最后那个 /bin/sh 是「实在没有 init 就给你一个 shell」,容器镜像常靠它。
  3. 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:内核的「构造函数表」

定义 3.2.3 · 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 枚举,不在内核本体。

例 3.2.1 · 用 initcall 顺序解释一个真实故障

某板子上的触摸屏驱动在 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 就是那只「先有」的鸡。

定义 3.2.4 · initramfs 与 initrd

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= 的解析与最初的挂载尝试。

做一个能挂真实根文件系统的 initramfs
# 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 提供)做了三步:

  1. 把新根 mount --move 到 /,让旧根变成空目录;
  2. chdir("/") 后 exec 新根的 init;
  3. 旧根的 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 做四件事:

  1. 建 manager:manager_new() 读出 /etc/systemd/system.conf,建 cgroup 层级,挂 /sys/fs/cgroup。
  2. 加载 unit:扫描 /usr/lib/systemd/system、/etc/systemd/system、/run/systemd/system,解析 .service/.target/.socket/.mount。
  3. 算依赖并并行启动:transaction 从 default.target 反向展开依赖,能并行的全部并行。After=/Before= 只定顺序,Requires=/Wants= 定依赖。
  4. 收尾:达到 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/ 下建一个软链——这是「开机自启」的全部机制,没有魔法。

与鸟哥第 19 章的对照

鸟哥讲的是 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 启动失败怎么定位

启动故障的分段定位法
  1. 一行日志都没有:卡在固件/引导器,或 console= 指错。加 earlyprintk=serial,ttyS0,115200 与 keep_bootcon,确认串口是 ttyS0 而不是 ttyS1。
  2. 有 Linux version 但停在 setup_arch 附近:多半是内存地图或页表问题。加 memblock=debug mem=512M 缩小范围;nokaslr 排除 KASLR。
  3. 停在 Run /init as init process:initramfs 里的 /init 不可执行或缺解释器。改用 rdinit=/bin/sh 验证;用 cpio -t 检查归档顶层。
  4. VFS: Cannot open root device:root= 拼错、驱动是模块但没进 initramfs、或 UUID 变了(换盘/重装后常见)。用 root=PARTUUID=... 比 root=/dev/sda1 稳。
  5. 内核起来了但卡在 systemd:systemd.unit=rescue.target 或 rd.break 进维护模式,journalctl -b -p err 看红字。
  6. 随机卡住、无日志:panic=10 让它自动重启并保留现场;sysrq 的 t(任务列表)、w(阻塞任务)能 dump 出卡在哪。
启动阶段的五个常见误解
  1. 「init=/bin/sh 就能修一切」:/bin/sh 存在且动态库齐全才行;只读根要先 mount -o remount,rw /。
  2. 「initramfs 是内核的一部分」:只有 CONFIG_INITRAMFS_SOURCE 嵌入时才是;发行版的 initramfs 是独立的 /boot/initrd.img-*,由引导器加载。
  3. 「dmesg 能看到全部启动日志」:环形缓冲区会覆盖,早期日志可能已被冲掉。要完整日志用 journalctl -k -b,或加大 log_buf_len=。
  4. 「改了 /etc/default/grub 就生效」:还要 update-grub(Debian/Ubuntu)或 grub2-mkconfig(RHEL),否则改的是模板不是 grub.cfg。
  5. 「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),因为:

  1. 大页映射少一层,建表代码短;
  2. 内核代码段按 2 MiB 对齐,mark_rodata_ro() 之前也只能按大页改权限;
  3. 早期内存分配器(memblock)还没有页表管理能力。
表符号映射
PGDearly_top_pgt / init_top_pgt内核空间的 512 项
PUDlevel3_kernel_pgt内核代码/数据区
PMDlevel2_kernel_pgt_text 到 _end,2 MiB 大页
PMDlevel2_fixmap_pgtfixmap 区(早期 ioremap)
PMDlevel2_ident_pgt恒等映射(低地址,供实模式切换用)

x86_64_start_kernel() 之后,paging_init() 才建立完整的直接映射(page_offset_base 起,按 struct page 大小算 vmemmap),并把 init_top_pgt 切换为正式页表。

解压与早期页表的五个坑
  1. 「vmlinux 的入口是 start_kernel」:ELF 入口是 startup_64(arch/x86/kernel/head_64.S),start_kernel 是 C 层第一个函数。
  2. 「KASLR 只随机化虚拟地址」:它随机化物理加载地址;虚拟地址由 __START_KERNEL_map 固定,kaslr_offset() 反映的是物理偏移在虚拟空间的投影。
  3. 「nokaslr 能关掉所有随机化」:只关内核镜像的物理随机化;直接映射、vmalloc、vmemmap 的随机化由 CONFIG_RANDOMIZE_MEMORY 控制,另有 nokaslr 之外的开关。
  4. 「解压后的内核就是 vmlinux 原样」:解压后还经过 parse_elf 按段布局、handle_relocations 修正、__startup_64 重定位,最终内存布局与 ELF 文件布局不同。
  5. 「早期代码可以用 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_pageszone 覆盖的页数(含洞)
lock保护 free_area 的自旋锁

水线在启动时由 init_per_zone_wmark_min() 按 min_free_kbytes 计算,运行期可由 watermark_scale_factor 调整。3.10 会讲 kswapd 怎么用水线;这里只需记住:首次 alloc_pages() 之前,水线必须已经算好,否则直接回收会在启动期触发。

6.8 的 `MAX_ORDER`

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
initcall 的五个坑
  1. 「module_init 有等级」:模块加载时只调用 module_init 注册的函数,等级宏在模块里被 __initcall 展开成普通段,不影响加载顺序。
  2. 「同一等级按文件名排序」:按链接顺序,链接顺序由 Makefile 决定;不要依赖字母序。
  3. 「initcall 返回非 0 会回滚」:不会。内核只记录返回值,资源不会自动释放;init 函数必须自己清理。
  4. 「late_initcall 能保证依赖就绪」:不能,只是更晚;依赖应该用 -EPROBE_DEFER 或 device_link。
  5. 「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:

偏移长度字段含义
06c_magic"070701"
68c_inoinode 号
148c_mode类型 + 权限(0100644 普通文件)
228c_uid所有者
308c_gid所属组
388c_nlink链接数
468c_mtime修改时间
548c_filesize数据长度
628c_devmajor主设备号
708c_devminor次设备号
788c_rdevmajor特殊文件的主设备号
868c_rdevminor特殊文件的次设备号
948c_namesize文件名长度(含结尾 \0)
1028c_check校验和,newc 固定 0
110namesize文件名含 \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 大多走这条路。

initramfs 的六个坑
  1. 「cpio 默认格式就是 newc」:GNU cpio 默认可能是 bin;必须显式 --format=newc,否则内核不认。
  2. 「find . 与 find . -print0 一样」:文件名带空格/换行时,-print0 + cpio --null 才安全;否则归档截断。
  3. 「归档里可以有绝对路径」:cpio 里的名字必须是相对路径(./init),以 / 开头会被内核拒绝或产生奇怪目录。
  4. 「initramfs 会自动挂 /proc」:不会,/init 自己挂;很多「ps 不工作」的救援环境就是漏了 mount -t proc。
  5. 「initramfs 占的内存会释放」:rootfs 是 ramfs,文件删除才释放;switch_root 后旧根必须靠 mount --move + 删除内容才能回收。
  6. 「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);

它做三件事:

  1. 把当前进程的根从旧根换成 new_root;
  2. 把旧根挂到 put_old(必须是 new_root 下的一个目录);
  3. 让旧根不再是我的根,但仍是挂载树的一部分,可以随后卸载。

约束(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

容器运行时的 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-chainunit 依赖链关键路径上的等待
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 太小)或时钟源切换导致时间跳变。

启动慢的二分法
  1. 先看 systemd-analyze time:firmware 长 → BIOS/UEFI 设置;loader 长 → GRUB 菜单超时;kernel 长 → 3.2.16 的 initcall;userspace 长 → blame。
  2. 内核段再二分:initcall_debug 找最慢的等级,bpftrace 找最慢的函数。
  3. 单个驱动慢:看它的 probe 是否在等硬件(usleep_range、I2C 重试),而不是内核调度。
  4. 时间对不上:查 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),而不是「后台初始化」。

启动实验的五个坑
  1. 「gen_init_cpio 在 PATH 里」:它是内核构建产物,路径是 usr/gen_init_cpio,要先 make usr/ 或完整构建。
  2. 「静态链接的 glibc 程序一定能跑」:getpwnam() 等 NSS 调用会去 /etc/nsswitch.conf,最小 initramfs 里没有;要么避免 NSS,要么用 musl/BusyBox。
  3. 「reboot(RB_POWER_OFF) 在 QEMU 里一定退出」:需要 ACPI 或 isa-debug-exit;老 QEMU 可能只打印 Power down 不退出,用 -no-reboot + Ctrl-A X 兜底。
  4. 「initcall_blacklist 对模块有效」:只对内置 initcall 生效;模块加载走 load_module(),不受它影响。
  5. 「initcall_debug 的耗时包含中断」:它用 jiffies/ktime 包住函数调用,包含被中断的时间;要看纯 CPU 时间用 perf。

3.2.18 陷阱清单:启动与 initramfs 篇

#现象原因修复
1卡在 Run /init as init process/init 不可执行、解释器缺失、rdinit 拼错chmod +x;静态链接;检查 rdinit=
2No working init foundinitramfs 顶层多了一层目录cpio -t 确认 init 在根
3VFS: Cannot open root device根设备驱动是模块且 initramfs 没加载编进内核,或在 /init 里 modprobe
4Initramfs unpacking failed: invalid magic用了 cpio -H bin 或非 gzip 压缩--format=newc,确认 CONFIG_RD_GZIP
5启动后没有 eth0initcall_blacklist/驱动没编dmesg 查 eth;lspci -k
6gdb 断点打不中KASLR 或用了 bzImage 而非 vmlinuxnokaslr;gdb vmlinux
7switch_root 后旧根占满内存没删旧根内容/没卸载旧挂载mount --move 后删除,或 umount -l
8pivot_root: Invalid argumentnew_root 不是挂载点,或 put_old 不在其下先 mount --bind;put_old 用子目录
9pivot_root: Device or resource busy挂载是 MS_SHAREDmount --make-rprivate / 再切
10启动时间戳跳变clocksource 切换或 log_buf_len 溢出dmesg 查 clocksource;加大 log_buf_len
11initcall ... preemption imbalanceinit 函数关了抢占没恢复检查 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-chain3.2.7
说出解压器与 __startup_64 的分工物理重定位发生在哪3.2.11
画出 memblock→伙伴→slab 顺序首次 kmalloc 何时可用3.2.12
解释 -EPROBE_DEFER为什么不靠等级抢顺序3.2.13
手写一个 cpio initramfs头是 0707013.2.14
说出 pivot_root 三条约束挂载点/子目录/非 shared3.2.15
即时自测 · 3.2

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–713.2.3、3.2.12完整(e820、memblock、paging_init)
奔跑吧 Linux 内核第 2 章 2.4 分配物理页面(伙伴系统)p93–1103.2.12精选(zone/水线在 3.9 展开)
奔跑吧 Linux 内核第 6 章 6.1 QEMU 调试 Linux 内核p705–7143.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–623.2.4背景(内核态/用户态切换的起点)
Kerrisk第 25 章 进程的终止章级3.2.4背景(PID 1 与 _exit 的特殊性)

下一站:3.3 中断、异常与系统调用 接着讲「启动完成后,CPU 怎么从用户态回到内核」:IDT 里装了什么、int 0x80 与 syscall 的差别、为什么中断要分上半部与下半部。