Linux 0.11阅读笔记
本文以 Linux 0.11 的 i386 源码为起点,对比现代 Linux 的引导、进程创建、进程调度、睡眠唤醒和退出回收机制,并补充 ARM64/QEMU 平台的对应关系。
1. 学习目标
阅读本笔记后,应能够回答以下问题:
Linux 0.11 的 bootsect.s、setup.s 和 head.s 分别负责什么?
为什么 Linux 0.11 要在 0x7C00、0x90000、0x10000 和 0x00000 之间搬移代码?
Linux 0.11 如何从 16 位实模式进入 32 位保护模式并开启分页?
现代 Linux 为什么通常不再由内核自己的第一个扇区读取磁盘?
GRUB、UEFI Stub、bzImage、boot_params 和 start_kernel() 分别处于哪一层?
ARM64 启动过程与 Linux 0.11 的 x86 启动过程如何对应?
Linux 0.11 中“进程、任务和调度对象”是什么关系?
fork() 如何通过 copy_process() 创建进程,并实现写时复制?
schedule() 如何依据 counter 和 priority 选择进程?
sleep_on()、wake_up() 如何完成睡眠与唤醒?
do_exit() 为什么不会立即彻底删除进程?
上述机制与现代 Linux 的线程模型、每 CPU 运行队列和 EEVDF 调度器有什么联系?
2. 首先区分两个概念
“Linux 引导程序”经常包含两类程序。
2.1 外部引导加载器
它运行在 Linux 内核之前,主要负责:
-
找到内核映像;
-
将内核装入内存;
-
加载 initramfs;
-
准备内核命令行和硬件信息;
-
跳转到内核规定的入口地址。
现代系统中的典型例子包括:
-
GRUB;
-
systemd-boot;
-
U-Boot;
-
UEFI 固件及 Linux EFI Stub。
2.2 内核早期启动代码
这部分属于 Linux 内核自身,主要负责:
-
建立最初的栈;
-
设置 CPU 工作模式;
-
建立临时页表;
-
解压和重定位内核;
-
准备进入通用 C 语言内核初始化函数。
Linux 0.11 把这两部分结合得比较紧密。bootsect.s 自己读取磁盘,而 setup.s、head.s 又继续完成内核早期初始化。现代 Linux 则把外部加载器和内核自身启动代码明确分开。
3. Linux 0.11 的完整启动链路
flowchart TD
A["BIOS"] –> B["bootsect.s:加载映像"]
B –> C["setup.s:硬件信息与保护模式"]
C –> D["head.s:页表与分页"]
D –> E["init/main.c:main()"]
可以简化为:
BIOS
→ bootsect.s
→ setup.s
→ head.s
→ init/main.c 中的 main()
→ 初始化内核子系统
→ 创建进程 1
3.1 第一阶段:BIOS 加载 bootsect.s
传统 PC BIOS 完成最基本的硬件自检后,将启动设备的第一个扇区读入:
物理地址 0x7C00。大小512字节。
然后从 0x7C00 开始执行。此时CPU仍处于16位实模式。Linux 0.11的 bootsect.s 首先将自身复制到 0x90000:
0x07C00 ──复制512字节──> 0x90000
这样做是为了避开低地址区域,防止后续加载内核时覆盖仍在运行的引导代码。随后bootsect.s 使用BIOS磁盘服务int 0x13继续加载:
setup.s → 0x90200
system → 0x10000
其中 system 是由 head.s 和内核主体构成的映像。
3.2 Linux 0.11 早期内存布局
| 0x00000 | 内核最终运行位置、后来的页目录 | setup.s 会将 system 搬到这里 |
| 0x07C00 | BIOS 最初加载的引导扇区 | 共 512 字节 |
| 0x10000 | system 临时加载位置 | 避免直接覆盖 BIOS 低地址区域 |
| 0x90000 | 搬移后的 bootsect、启动参数区 | setup.s 后续也在这里保存硬件参数 |
| 0x90200 | setup 程序 | 紧跟在引导扇区之后 |
| 0x9FF00 | bootsect 使用的临时栈附近 | 栈向低地址增长 |
该布局体现了早期 x86 的限制:BIOS 运行在实模式下,使用“段地址 × 16 + 偏移地址”形成物理地址,并且早期加载过程主要活动在 1 MiB 以下。
3.3 bootsect.s 的主要任务
bootsect.s 主要完成:
将自身从 0x7C00 搬到 0x90000;
设置临时段寄存器和栈;
使用 BIOS 中断加载 setup.s;
获取软盘的磁道参数;
将 system 映像加载到 0x10000;
根据配置或软盘格式确定根设备;
关闭软盘电机;
跳转到 setup.s。
需要注意:它直接按照磁道、磁头、扇区读取数据,不认识 ext4、FAT 等文件系统,也没有现代引导菜单。
3.4 第二阶段:setup.s 获取硬件信息
setup.s 仍然运行在 16 位实模式,可以继续使用 BIOS 服务。它通过 BIOS 获取并保存:
| 光标位置和显示模式 | int 0x10 |
| 扩展内存大小 | int 0x15 |
| 硬盘参数 | BIOS 中断向量表及 int 0x13 |
这些数据被放在 0x90000 附近,后面的 C 语言内核代码再从固定地址读取。
3.5 setup.s 将内核搬到地址 0
system 映像最初位于 0x10000,setup.s 将其向低地址搬移:
0x10000~内核末尾
↓
0x00000~对应位置
原因是 Linux 0.11 的 head.s 按绝对地址 0x00000000 组织,并且最初的页目录也放在地址 0。这种设计非常依赖固定内存布局。现代 Linux 通常不会把内核整体搬到物理地址 0。
3.6 从实模式进入保护模式
在切换模式之前,setup.s 依次完成:
cli,关闭中断
↓
搬移 system 映像
↓
加载临时 IDT 和 GDT
↓
打开 A20 地址线
↓
重新编程 8259A 中断控制器
↓
设置 CR0.PE
↓
远跳转到代码段选择子 8、偏移 0
其中:
-
A20 地址线:打开后 CPU 才能正常访问 1 MiB 以上的地址;
-
GDT:为保护模式建立代码段和数据段描述符;
-
IDT:此时只建立最简单的临时中断描述表;
-
8259A 重映射:避免硬件中断向量与 CPU 异常向量冲突;
-
远跳转:装载新的 CS,真正进入 32 位保护模式代码。
3.7 第三阶段:head.s 建立分页环境
进入 head.s 时,CPU 已经处于 32 位保护模式,但分页尚未开启。head.s 的主要工作是:
设置数据段寄存器和内核栈;
建立正式的临时 GDT、IDT;
检查 A20 是否真正打开;
检查 x87 数学协处理器;
在物理地址 0 建立页目录;
在 0x1000~0x4000 建立 4 张页表;
恒等映射前 16 MiB 物理内存;
将页目录地址写入 CR3;
设置 CR0.PG,开启分页;
进入 init/main.c 中的 main()。
恒等映射表示:
线性地址 0x00100000 → 物理地址 0x00100000
线性地址 0x00200000 → 物理地址 0x00200000
地址转换关系暂时保持不变,可以降低刚开启分页时的复杂度。一个很有代表性的细节是head.s 最初就在地址 0 运行,而建立页目录时又会清空地址 0 开始的内存,因此早期启动代码会被页目录覆盖。此时相关代码已经执行完毕,不再需要返回。
3.8 进入 main() 后
此时引导汇编阶段基本完成,内核开始初始化:
-
内存管理;
-
中断和陷阱;
-
块设备、字符设备;
-
时钟和调度器;
-
缓冲区和文件系统;
-
进程 0 和进程 1。
因此可以把 main() 看作:
体系结构相关的早期引导
↓
通用内核初始化
4. 现代x86-64 Linux的典型启动过程
现代 Linux 没有唯一固定的外部启动方式,常见的是传统 BIOS 路径和 UEFI 路径。
4.1 传统 BIOS 路径
BIOS
→ MBR 中的少量代码
→ GRUB 等完整引导加载器
→ 加载 bzImage、initramfs 和命令行
→ Linux 实模式兼容代码
→ 进入保护模式
→ 建立临时页表并进入 64 位模式
→ 解压、重定位内核
→ start_kernel()
传统 BIOS 仍然只能首先加载一个很小的启动扇区,但这个扇区通常只负责找到更完整的 GRUB 阶段,不再像 Linux 0.11 的 bootsect.s 一样直接承担整个内核加载过程。
4.2 UEFI 路径
flowchart TD
A["UEFI 固件"] –> B["GRUB、systemd-boot 或 EFI Stub"]
B –> C["内核、initramfs、启动参数"]
C –> D["解压与重定位"]
D –> E["64位内核入口与 start_kernel()"]
UEFI 能识别 EFI 系统分区中的文件,并执行 PE/COFF 格式程序,不再依赖 BIOS 将第一个 512 字节扇区装入 0x7C00。启用 EFI Stub 后,Linux 内核映像可以表现为 EFI 可执行文件,因此固件或 systemd-boot 可以直接加载它,不一定需要 GRUB。
4.3 现代内核内部的启动链路
现代 x86-64 内核内部可以概括为:
arch/x86/boot/header.S
↓
arch/x86/boot/main.c
↓
go_to_protected_mode()
↓
arch/x86/boot/compressed/head_64.S
↓
decompress_kernel()
↓
arch/x86/kernel/head_64.S
↓
x86_64_start_kernel()
↓
start_kernel()
不同启动协议可能跳过其中部分传统实模式路径,但最终都要建立内核需要的 CPU、内存和参数环境。
4.4 bzImage 与内核解压
现代 x86 内核通常以压缩的 bzImage 形式启动。引导加载器负责将它放到符合协议的位置,压缩内核代码随后负责:
-
建立解压阶段使用的临时内存环境;
-
选择合适的目标地址;
-
根据需要实施 KASLR 地址随机化;
-
解压真正的 ELF 内核;
-
跳转到解压后的内核入口。
因此现代加载过程更接近:
加载压缩映像
↓
选择或随机化目标地址
↓
解压内核
↓
进入解压后的内核入口
而不是 Linux 0.11 的“加载到 0x10000 后整体搬到地址 0”。
4.5 标准化启动参数
现代 x86 启动加载器通过 Linux Boot Protocol 向内核传递 struct boot_params,内容可包括:
-
物理内存布局;
-
内核命令行;
-
initramfs 地址和大小;
-
显示信息;
-
ACPI RSDP 地址;
-
引导加载器类型;
-
内核装载地址;
-
扩展 setup_data。
Linux 0.11 使用的是固定地址、固定偏移;现代 Linux 使用了版本化的启动协议。这使内核可以与 GRUB、UEFI、QEMU、kexec 等多种加载环境配合。
4.6 进入 start_kernel()
start_kernel() 相当于现代 Linux 的通用内核初始化总入口,功能上对应 Linux 0.11 的 main()。后续大致为:
start_kernel()
→ 内存、异常、中断、时钟、调度器等初始化
→ rest_init()
→ 创建 PID 1:kernel_init
→ 创建 PID 2:kthreadd
→ 启动任务变为 CPU0 idle
PID 1 完成剩余初始化后,通过 kernel_execve() 执行 /init、/sbin/init 等用户空间程序。
5. 两者的相似之处
虽然实现规模相差巨大,但核心阶段仍然一致。
| 固件先运行 | BIOS | BIOS 或 UEFI,也可能存在其他平台固件 |
| 将内核装入内存 | bootsect.s 直接读扇区 | GRUB、UEFI、U-Boot 等加载 |
| 传递硬件信息 | 固定保存在 0x90000 附近 | boot_params、EFI、ACPI 或设备树 |
| 准备 CPU 状态 | 实模式转保护模式 | 进入保护模式、长模式或相应架构模式 |
| 建立初始页表 | 映射前 16 MiB | 建立多级临时页表和正式页表 |
| 汇编进入 C | head.s → main() | head_64.S → start_kernel() |
| 初始化操作系统 | 初始化内存、设备、文件系统和调度器 | 同样的总体目标,但子系统更多 |
| 创建第一个用户空间进程 | 创建 PID 1 并运行 init | 创建 PID 1,最终执行用户空间 init |
共同的抽象过程为:
加载内核
→ 准备启动参数
→ 建立最小执行环境
→ 开启地址转换
→ 进入通用 C 代码
→ 初始化完整内核
6. 两者的主要区别
| 外部加载器 | 内核自带 bootsect.s 承担大量加载工作 | 通常使用独立的 GRUB、UEFI、systemd-boot 或 U-Boot |
| 固件接口 | 传统 PC BIOS | UEFI 为主,同时保留 BIOS 兼容路径 |
| 磁盘访问 | BIOS int 0x13,按磁道、磁头、扇区读取 | 加载器可支持 FAT、ext4、NVMe、USB、网络等 |
| 内核装载位置 | 固定装入 0x10000,再搬到 0x00000 | 通常装入 1 MiB 以上,可重定位 |
| 内核映像 | 简单 system 映像 | 通常为压缩的 bzImage,解压后为 ELF 内核 |
| 地址随机化 | 不支持 | 可支持 KASLR |
| 启动参数 | 固定地址和固定偏移 | 标准化、版本化的 Boot Protocol |
| 根文件系统 | 通常直接挂载软盘或硬盘根设备 | 经常先进入 initramfs,再挂载真正根文件系统 |
| CPU模式 | 16 位实模式转 32 位保护模式 | 最终进入 x86-64 长模式 |
| 分页 | 两级页表,恒等映射前 16 MiB | 多级页表、超大内存和高地址内核映射 |
| 处理器数量 | 单处理器 | 引导处理器启动后继续唤醒其他 CPU |
| 硬件描述 | BIOS 参数与大量固定假设 | ACPI、EFI 内存映射、设备树等 |
| 安全能力 | 基本没有 | 可配合 Secure Boot、签名验证和 TPM 测量启动 |
| 支持架构 | 早期 i386 PC | x86-64、ARM64、RISC-V 等多种架构 |
7. 功能对应关系
不能把 Linux 0.11 文件与现代文件完全一一对应,但可以从功能上理解:
| bootsect.s | BIOS 启动扇区、GRUB、UEFI 或 U-Boot 的一部分加载功能 |
| setup.s 获取 BIOS 参数 | Boot Protocol、EFI、ACPI、设备树 |
| setup.s 切换保护模式 | arch/x86/boot/pm.c 等体系结构早期代码 |
| head.s 建立页表 | compressed/head_64.S、kernel/head_64.S |
| main() | start_kernel() |
| 固定的 0x90000 参数区 | struct boot_params |
| 直接选择根设备 | 内核命令行 root=、initramfs 和用户空间挂载流程 |
最核心的变化是:
Linux 0.11:加载器与内核早期代码紧密结合
现代 Linux:固件 → 通用加载器 → 标准启动协议 → 内核早期代码
8. 从内核引导进入进程管理
完成 head.s → main() 后,Linux 0.11 开始初始化内存、异常、中断、设备、文件系统和调度器。进程管理由此接入启动流程。
Linux 0.11 的关键路径为:
head.s
→ main()
→ sched_init()
→ sti()
→ move_to_user_mode()
→ fork()
├─ 子进程:执行 init(),成为 PID 1
└─ 父进程:反复调用 pause(),成为任务 0
sched_init() 主要完成:
-
初始化任务 0 的 TSS 和 LDT;
-
清空其他任务槽;
-
初始化 8253/8254 定时器;
-
安装 0x20 时钟中断;
-
安装 0x80 系统调用入口。
现代 Linux 的对应路径大致为:
start_kernel()
→ sched_init()
→ rest_init()
├─ kernel_clone(kernel_init) → PID 1
├─ kernel_thread(kthreadd) → PID 2
└─ cpu_startup_entry() → CPU0 idle
两者都先有一个静态建立的启动任务,再创建 PID 1,最后让原启动任务承担 idle 角色。区别在于 Linux 0.11 没有成熟的内核线程框架,需要任务 0 先进入用户态再调用 fork();现代 Linux 可以直接在内核态创建 kernel_init 和 kthreadd。
9. 进程、任务和线程模型
9.1 Linux 0.11:进程就是任务
Linux 0.11 使用:
struct task_struct *task[NR_TASKS];
其中 NR_TASKS = 64,包括任务 0,因此整个系统只有固定的 64 个任务槽。每个任务由一个 task_struct 描述,保存:
-
运行状态;
-
counter 和 priority;
-
PID、父进程 PID;
-
信号和阻塞信号;
-
文件描述符;
-
当前目录、根目录和可执行文件;
-
TSS、LDT;
-
用户时间和内核时间;
-
地址空间相关信息。
task_struct 与内核栈共同占用一个 4 KiB 页面:
一个4 KiB页面
┌──────────────────────────────┐ 低地址
│ task_struct │
├──────────────────────────────┤
│ │
│ 内核栈空闲区域 │
│ │
├──────────────────────────────┤
│ 内核栈顶 │ 高地址
└──────────────────────────────┘
Linux 0.11 尚未支持现代意义上的多线程和线程组。因此基本可以认为:
一个进程
= 一个 task_struct
= 一个 PID
= 一个调度对象
= 一个执行流
9.2 现代 Linux:线程是直接调度对象
现代 Linux 仍然使用 task_struct,但每一个线程都有自己的 task_struct:
一个进程(线程组)
├─ 主线程 task_struct,TID 1000
├─ 线程1 task_struct,TID 1001
└─ 线程2 task_struct,TID 1002
三者共享 TGID 1000
现代 Linux 内核并没有为“线程”和“进程”分别设计两套完全不同的调度对象。它通过 clone 标志决定不同任务共享哪些资源:
-
CLONE_VM:共享地址空间;
-
CLONE_FILES:共享文件描述符表;
-
CLONE_SIGHAND:共享信号处理方式;
-
CLONE_THREAD:加入同一线程组。
所以现代 Linux 中更准确的关系是:
一个 task_struct = 一个可调度线程
多个共享资源的 task_struct = 一个用户可见进程
10. Linux 0.11 的进程创建
10.1 创建调用链
Linux 0.11 创建进程的主要调用链为:
用户调用 fork()
↓
int 0x80
↓
system_call.s 中的 sys_fork
↓
find_empty_process()
↓
copy_process()
↓
copy_mem()
↓
copy_page_tables()
↓
子进程变为 TASK_RUNNING
10.2 find_empty_process()
它完成两件事:
产生一个当前系统中未使用的 PID;
在 task[1]~task[63] 中寻找空槽。
如果固定任务表已经没有空槽,就返回 -EAGAIN。这说明 Linux 0.11 的进程数量由编译时数组大小直接限制。
10.3 copy_process()
copy_process() 的核心过程为:
申请一个 4 KiB 页面;
将页面起始地址作为新 task_struct;
先整体复制父进程的 task_struct;
将子进程暂时设置为 TASK_UNINTERRUPTIBLE;
设置新 PID、父 PID、启动时间和统计信息;
设置 counter = priority;
构造子进程的 TSS 寄存器现场;
将子进程的 EAX 设置为 0;
调用 copy_mem() 建立子进程地址空间;
增加共享文件、目录和可执行文件 inode 的引用计数;
在 GDT 中建立该任务的 TSS、LDT 描述符;
最后设置为 TASK_RUNNING。
“最后才设置为可运行”非常重要:只有初始化全部成功后,调度器才能看到并运行新进程。
10.4 为什么父子进程的 fork() 返回值不同
父进程继续执行原来的系统调用路径,copy_process() 向父进程返回子进程 PID。子进程的 TSS 中被预先设置:
子进程 eax = 0;
所以从用户程序角度看:
pid = fork();
if (pid == 0) {
/* 子进程 */
} else {
/* 父进程,pid为子进程PID */
}
并不是 fork() 真正执行了两次,而是父子进程从相同指令位置继续运行时,寄存器返回值不同。
10.5 地址空间与写时复制
Linux 0.11 为不同任务分配固定的 64 MiB 线性地址区间:
任务n的线性地址基址 = n × 64 MiB
copy_mem() 修改子进程 LDT 中代码段和数据段的基址,然后通过 copy_page_tables() 复制页表关系。父子进程不会立即复制全部物理内存,而是:
父页表 ─┐
├─→ 同一个只读物理页
子页表 ─┘
某一方尝试写入
↓
触发缺页异常
↓
复制物理页
↓
修改者获得自己的可写副本
这就是写时复制 COW。它减少了 fork() 的内存复制量,尤其适合子进程很快调用 exec() 的情况。
10.6 与现代 Linux 进程创建的联系和区别
现代 Linux 的公共创建路径大致为:
fork / vfork / clone / clone3
↓
kernel_clone()
↓
copy_process()
↓
wake_up_new_task()
两者的共同点包括:
-
都分配新的 task_struct;
-
都复制新任务的寄存器现场;
-
都复制或引用父任务资源;
-
普通 fork() 都继续使用写时复制;
-
初始化完成后才让新任务进入可运行状态。
主要差别包括:
| 创建接口 | 主要是 fork() | fork、vfork、clone、clone3、内核线程 |
| 任务存储 | 固定 task[64] | 动态分配并使用多个索引结构 |
| 线程支持 | 没有线程组 | 可通过 CLONE_* 共享资源并组成线程组 |
| 地址空间 | 每任务固定 64 MiB 线性区间 | 独立 mm_struct,支持庞大虚拟地址空间 |
| 内核栈 | 与 task_struct 位于同一 4 KiB 页面 | 独立分配,可配置更大栈或虚拟映射栈 |
| 隔离机制 | 基本没有 | PID、用户、挂载、网络等命名空间 |
| 管理机制 | 简单引用计数 | cgroup、LSM、seccomp、审计、RCU 等 |
11. Linux 0.11 的进程调度
11.1 调度器要解决的问题
调度器需要从可运行任务中选择一个任务占用 CPU:
当前任务主动阻塞
或时间片耗尽
或系统调用返回前发现需要调度
↓
schedule()
↓
选择下一个任务
↓
switch_to(next)
11.2 counter 与 priority
Linux 0.11 中:
-
priority:任务的基础优先级;
-
counter:当前剩余时间片,同时也作为动态选择值。
新进程创建时:
counter = priority;
时钟中断到来时,当前任务的 counter 递减。counter 越大的可运行任务越容易被选中。
11.3 schedule() 的选取过程
调度逻辑可以写成以下等价伪代码:
for (;;) {
next = 0; /* 默认选择任务0 */
max_counter = -1;
扫描 task[1] 到 task[63];
在 TASK_RUNNING 中选择 counter 最大者;
if (max_counter != 0)
break;
对所有已存在任务重新计算 counter;
}
switch_to(next);
存在三种情况:
找到 counter > 0 的可运行任务:直接选择最大者;
有可运行任务,但它们最大的 counter == 0:重新计算时间片;
没有任何普通任务可运行:max_counter 保持为 -1,选择任务 0。
11.4 什么时候重新计算 counter
需要准确理解为当所有可运行的普通任务都没有剩余时间片时,调度器重新计算所有已存在任务的 counter。公式为:
counter = (counter >> 1) + priority;
重算对象不仅包括当前可运行任务,也包括正在睡眠的任务。例如:
| A | 可运行,时间片耗尽 | 0 | 5 | 5 |
| B | 睡眠,保留时间片 | 6 | 5 | 8 |
任务 B 睡眠期间没有消耗完旧时间片,唤醒后可能拥有更大的 counter。这使 I/O 密集型、经常睡眠的任务通常能较快得到响应。所以它不是简单地“所有任务统一重新获得相同时间片”,而是将旧动态值衰减一半,再叠加静态优先级。
11.5 时钟中断和内核不可抢占
do_timer(cpl) 每个时钟滴答都会减少当前任务的 counter:
counter仍大于0
→ 继续运行
counter减到0
├─ 当前在用户态 → 调用schedule()
└─ 当前在内核态 → 暂不抢占
因此 Linux 0.11 属于“用户态可被时钟抢占、内核态基本不可抢占”的模型。内核代码通常要等到:
-
主动调用 schedule();
-
因等待资源而睡眠;
-
系统调用返回用户态前;
-
其他明确的调度点;
才会切换任务。
11.6 上下文切换
Linux 0.11 的 switch_to(n) 使用每任务 TSS 和 x86 硬件任务切换:
计算下一个任务的TSS选择子
↓
更新current
↓
通过ljmp触发硬件任务切换
↓
CPU保存旧TSS、装载新TSS
因此每个任务都需要自己的 TSS 和 LDT 描述符。
11.7 现代 Linux 调度器
现代 Linux 已不再扫描一个全局 task[64] 数组。主要特征包括:
-
每个 CPU 有自己的运行队列 struct rq;
-
调度器直接调度线程对应的 task_struct;
-
支持普通、公平、实时、截止时间、idle 等调度类别;
-
支持 CPU 亲和性、负载均衡、NUMA 和跨 CPU 唤醒;
-
支持可配置内核抢占;
-
通过软件代码保存和恢复寄存器,不再使用 TSS 硬件任务切换。
当前主线 Linux 的公平调度类以 EEVDF 思想选择普通任务:
-
虚拟运行时间用于衡量已获得的 CPU 服务;
-
lag 表示任务相对公平份额是“欠运行”还是“超额运行”;
-
在符合运行资格的任务中,优先选择虚拟截止时间较早者;
-
nice 值通过权重影响虚拟时间推进速度。
网上很多资料只写“现代 Linux 使用 CFS”。更准确地说,公平调度类沿用了 CFS 的大量基础设施,但当前主线的任务选择逻辑已经采用 EEVDF。
11.8 调度机制对比
| CPU模型 | 单 CPU | SMP、多核、NUMA |
| 运行任务保存 | 全局固定 task[64] | 每 CPU struct rq |
| 普通任务选择 | 扫描并选最大 counter | 公平类采用虚拟时间、lag、虚拟截止时间 |
| 时间片更新 | 全局周期式重算 counter | 不存在同样的全任务统一重算周期 |
| 调度类型 | 一套简单规则 | 多个调度类和策略 |
| 内核抢占 | 内核态基本不可抢占 | 由配置决定,可支持完全抢占和 PREEMPT_RT |
| 多核均衡 | 不存在 | 负载均衡、迁移和 CPU 亲和性 |
| 上下文切换 | x86 TSS 硬件任务切换 | context_switch() 加体系结构软件切换 |
| idle任务 | 只有任务 0 | 每个 CPU 都有自己的 idle 任务 |
12. 睡眠与唤醒机制
12.1 为什么进程需要睡眠
进程等待磁盘、键盘、管道数据或其他资源时,不应该一直占用 CPU:
资源尚未就绪
↓
当前任务设置为睡眠
↓
schedule()运行其他任务
↓
资源就绪后wake_up()
↓
任务重新变为TASK_RUNNING
12.2 Linux 0.11 的 sleep_on()
核心逻辑可以概括为:
tmp = *wait_head;
*wait_head = current;
current->state = TASK_UNINTERRUPTIBLE;
schedule();
if (tmp != NULL)
tmp->state = TASK_RUNNING;
等待头保存最后进入睡眠的任务,而前一个等待任务保存在后一个任务内核栈中的局部变量 tmp 里,形成一种隐式的后进先出等待链。wake_up() 首先唤醒等待头。该任务恢复执行后,再唤醒它保存的前一个任务,从而形成级联唤醒。这不是现代意义上的显式链表等待队列,也不适合多 CPU 并发环境。
12.3 两种睡眠状态
| TASK_INTERRUPTIBLE | 可以因为信号到来而被唤醒 |
| TASK_UNINTERRUPTIBLE | 一般只能等待明确的资源事件 |
任务 0 是特殊 idle 任务,不允许调用 sleep_on() 真正睡眠。
12.4 现代等待队列
现代 Linux 使用 wait_queue_head 和 wait_queue_entry:
wait_queue_head
├─ wait_queue_entry → task A
├─ wait_queue_entry → task B
└─ wait_queue_entry → task C
现代等待机制具有:
-
显式双向链表;
-
自旋锁和内存屏障;
-
SMP 并发安全;
-
条件等待宏 wait_event*;
-
独占唤醒和全部唤醒;
-
与 mutex、semaphore、completion、futex 等机制配合。
两者的基本思想相同:改变任务状态、让出 CPU、等待事件、重新进入运行队列;区别在于现代实现具有完整的并发和竞态保护。
13. 进程退出、僵尸与最终删除
13.1 “退出”不等于“立即删除”
进程退出后,父进程仍需要获得:
-
子进程 PID;
-
退出码;
-
是否被信号终止;
-
CPU 时间等统计信息。
因此内核不能在 exit() 时立即删除全部信息,而是先保留一个僵尸记录。
13.2 Linux 0.11 的退出路径
用户调用exit()
↓
sys_exit()
↓
do_exit()
↓
释放页表、文件和目录等资源
↓
子进程重新托管给PID 1
↓
设置TASK_ZOMBIE和exit_code
↓
向父进程发送SIGCHLD
↓
schedule(),退出者不再运行
do_exit() 会释放或处理大部分资源,但仍保留 task_struct、PID 和退出状态。
13.3 父进程通过 waitpid() 回收
父进程调用 waitpid() 后:
扫描自己的子进程;
如果子进程仍在运行,父进程可以睡眠等待;
如果发现 TASK_ZOMBIE,读取退出码和运行时间;
调用 release();
将该任务从 task[] 中移除;
释放保存 task_struct 和内核栈的页面。
完整状态变化为:
flowchart TD
A["创建"] –> B["TASK_RUNNING"]
B –> C["正在运行"]
C –>|等待资源| D["睡眠"]
D –>|wake_up| B
C –>|exit| E["TASK_ZOMBIE"]
E –>|父进程wait| F["release并最终释放"]
如果父进程一直不调用 wait(),僵尸记录就会继续占据任务槽。
13.4 现代 Linux 的退出与回收
现代 Linux 仍保留相同的两阶段思想:
do_exit()
→ 释放mm、files等资源
→ exit_notify()
→ EXIT_ZOMBIE或EXIT_DEAD
→ 父进程wait4()/waitid()
→ release_task()
→ 引用计数归零后最终释放task_struct
现代实现还需要处理:
-
多线程和线程组退出;
-
exit_group();
-
PID 命名空间;
-
子收割器和重新托管;
-
ptrace;
-
cgroup;
-
性能事件和审计;
-
RCU 延迟释放;
-
多 CPU 上仍在引用该任务的情况。
因此现代 do_exit() 和 release_task() 很复杂,但“先退出成为可等待状态,再由父进程或内核回收”的基本语义仍继承自早期 Linux。
13.5 退出机制对比
| 退出入口 | sys_exit() → do_exit() | exit/exit_group → do_exit() |
| 资源释放 | 页表、文件、inode、终端等 | mm、文件、命名空间、cgroup等大量资源 |
| 僵尸状态 | TASK_ZOMBIE | EXIT_ZOMBIE,也可能自动回收为 EXIT_DEAD |
| 通知父进程 | SIGCHLD | SIGCHLD、pidfd、等待队列等 |
| 父进程等待 | sys_waitpid() | wait4()、waitid() 等 |
| 最终释放 | release() 释放任务页 | release_task()、引用计数和 RCU 延迟释放 |
| 多线程处理 | 不存在 | 线程组、组退出和最后线程语义 |
14. 进程完整生命周期
把创建、调度、睡眠和销毁串起来,可以得到:
fork()
↓
分配并初始化task_struct
↓
TASK_RUNNING
↓
schedule()选择并切换到该任务
↓
运行
┌────────┴────────┐
↓ ↓
等待资源/睡眠 exit()
↓ ↓
wake_up() TASK_ZOMBIE
↓ ↓
返回可运行状态 父进程wait()
└────────┐ ↓
└──→ release()
最终释放
其中必须区分三件事:
创建完成:任务结构初始化完成并变为可运行;
退出完成:任务不再执行,但可能仍是僵尸;
删除完成:父进程读取退出信息后,内核最终释放任务结构。
15. Linux 0.11 与现代 Linux 的函数映射
| 启动任务 | init_task、任务 0 | init_task、swapper/0 |
| 创建 PID 1 | 任务 0 进入用户态后 fork() | kernel_clone(kernel_init) |
| 普通进程创建 | sys_fork → copy_process | kernel_clone → copy_process |
| 线程创建 | 不支持现代线程模型 | clone/clone3 加 CLONE_* |
| 地址空间复制 | copy_mem → copy_page_tables | copy_mm → dup_mm/dup_mmap |
| 写时复制 | 页表只读加缺页复制 | 仍然使用 COW,机制更完整 |
| 任务集合 | task[64] | 动态任务结构和每 CPU 运行队列 |
| 调度入口 | schedule() | schedule() → __schedule() |
| 选择任务 | 最大 counter | 调度类的 pick_next_task 逻辑 |
| 上下文切换 | switch_to 加硬件 TSS | context_switch 加体系结构软件切换 |
| 睡眠 | sleep_on、interruptible_sleep_on | wait_event*、mutex、completion等 |
| 唤醒 | wake_up | wake_up*、try_to_wake_up |
| 退出 | do_exit | do_exit |
| 等待子进程 | sys_waitpid | wait4、waitid |
| 最终回收 | release | release_task 加引用计数、RCU |
可以看到,很多函数名和核心抽象一直保留至今,例如 task_struct、schedule()、copy_process() 和 do_exit()。真正发生根本变化的是它们内部的数据结构、并发模型和可扩展能力。
16. 总体联系与本质区别
16.1 一直保留下来的思想
-
使用 task_struct 描述可调度任务;
-
任务具有运行、睡眠、停止和退出等状态;
-
fork() 复制执行上下文和资源关系;
-
使用写时复制降低创建成本;
-
阻塞任务主动让出 CPU;
-
事件到来后将任务重新变为可运行;
-
schedule() 选择下一个任务;
-
退出后保留僵尸信息供父进程读取;
-
父进程通过 wait() 完成最终回收;
-
没有普通任务可运行时进入 idle。
16.2 已经根本改变的实现
-
从“一个进程就是一个任务”变成线程组模型;
-
从固定 64 个任务槽变成动态任务管理;
-
从单 CPU 变成 SMP、NUMA;
-
从扫描 task[] 变成每 CPU 运行队列和多调度类;
-
从 counter 算法变成 EEVDF、实时和截止时间调度并存;
-
从不可抢占内核变成可配置抢占和实时抢占;
-
从 TSS 硬件切换变成软件上下文切换;
-
从简单指针睡眠链变成 SMP 安全等待队列;
-
从立即释放一个任务页变成引用计数和 RCU 协同回收;
-
增加命名空间、cgroup、LSM、审计、pidfd 等机制。
17. 源码与官方文档
17.1 Linux 0.11 引导
-
boot/bootsect.s
-
boot/setup.s
-
boot/head.s
-
init/main.c
17.2 Linux 0.11 进程管理
-
include/linux/sched.h
-
kernel/system_call.s
-
kernel/fork.c
-
mm/memory.c
-
kernel/sched.c
-
kernel/exit.c
17.3 现代 Linux 引导
-
Linux/x86 Boot Protocol
-
Linux EFI Boot Stub
-
arch/x86/boot/header.S
-
arch/x86/boot/main.c
-
arch/x86/boot/pm.c
-
arch/x86/boot/compressed/head_64.S
-
arch/x86/kernel/head_64.S
-
init/main.c
17.4 现代 Linux 进程管理
-
kernel/fork.c
-
kernel/sched/core.c
-
kernel/sched/fair.c
-
EEVDF Scheduler
-
include/linux/wait.h
-
kernel/exit.c
-
arch/x86/include/asm/switch_to.h
17.5 ARM64
-
Booting AArch64 Linux
-
arch/arm64/kernel/head.S



