Linux 内核学习笔记
- Linux
- 内核
- 源码
目录
- 开始之前:这份文档怎么用
- 约定
- 读源码的四分法
- 学习进度怎么衡量
- 开机:从按下电源到第一个进程
- 全景图
- 内核最开头:一段汇编
- start_kernel():内核主函数
- rest_init():从内核走向多任务世界的门口
- kernel_init():第一个用户态进程的诞生
- 系统调用:用户程序进入内核的门
- 为什么要有这扇门
- 一次 read() 的完整旅程
- 第 0 步:libc 包装
- 第 1 步:syscall 指令——墙上的门缝
- 第 2 步:入口分发
- 第 3 步:进入通用层
- 第 4 步:VFS 通用层
- 第 5 步:返回
- 把整条链串起来(背下来)
- 用 strace 现场验尸
- 进程:fork、exec 与线程
- 进程的”户口本”:task_struct
- fork 的总思路:先整体照抄,再按需改
- copy_process() 逐段拆解
- 第 1 步:领户口本
- 第 2 步:改身份
- 第 3 步:按 clone_flags 决定资源”复制”还是”共享”
- 第 4 步:登记身份
- 第 5 步:上户口
- exec:不造新人,只换灵魂
- 退出与收尸:exit、wait 和”僵尸”
- 本章小结(背下来)
- 虚拟内存:页表、缺页与写时复制
- 为什么要虚拟内存
- 翻译层:MMU 与页表
- 内核里的三个核心结构
- mmap:登记一块区域
- page fault:账上没这页?现场补
- 本章小结(背下来)
- 文件系统:VFS 四层结构
- VFS 是什么
- 四层结构:一个 fd 的族谱
- open 的旅程:字符串 → inode
- read 的旅程:回到第 2 章那条链
- 一图收束(背下来)
- 调度器:CPU 时间怎么分
- 要解决什么问题
- 位置与入口
- schedule():核心只有十几个字
- 挑人:公平怎么定义
- 唤醒:把睡觉的人捞回队列
- 本章小结(背下来)
- 动手实验与下一步
- 不装环境的实验
- 进阶:内核里的断点(有 QEMU 时)
- 学完这份笔记之后的地图
开始之前:这份文档怎么用
约定
- 本文以 Linux 6.x 为主线。不同版本函数名会略有出入,但主干逻辑十几年没变。
- 强烈建议配合在线源码浏览器 Bootlin Elixir 阅读:https://elixir.bootlin.com/linux/latest/source,可以点任何函数名直接跳转到定义,比本地 grep 顺手得多。
- 文中代码都做了删减和简化:只保留主干,去掉了错误处理、锁、统计、各种兼容分支。真实源码比这里长得多,但骨架就是这些。
读源码的四分法
拿到任何一个内核函数,先把它拆成四类,精力分配完全不同:
| 类别 | 例子 | 怎么处理 |
|---|---|---|
| 主干逻辑 | copy_process() 主体 | 必须逐行看 |
| 数据结构操作 | 链表挂载、引用计数 | 看懂意图即可 |
| 错误处理 | goto out; if (ret) ... | 第一遍跳过 |
| 兼容/优化/架构差异 | #ifdef CONFIG_X86_64 | 第一遍跳过 |
第一遍读源码,跳过后两类不看是正确操作,不是偷懒。
学习进度怎么衡量
不要用”看了多少行”衡量,用”能讲清楚几条路径”衡量。全部学完后,你应该能不查资料回答:
- 按下电源后到出现登录界面,内核做了什么?
- 敲
./hello回车后,这个程序是怎么跑起来的? fork()之后父子进程为什么返回值不同?malloc()之后为什么可能还没有占用物理内存?- 文件描述符(一个整数)怎么一路找到磁盘上的数据?
- CPU 上同时只有一个进程在跑,为什么你感觉几十个程序”同时”在动?
开机:从按下电源到第一个进程
全景图
一句话版本:固件 → 引导程序 → 内核自举 → start_kernel() → rest_init() → 第一个用户态进程。
按下电源
↓
CPU 从固定地址取第一条指令(跑的是主板上的固件)
↓
固件找到硬盘上的引导程序(GRUB),把它加载进内存
↓
GRUB 把内核镜像(vmlinuz 这个文件)加载进内存,跳过去执行
↓
内核最开头的汇编:搭建 C 语言能跑的环境
↓
start_kernel() ← init/main.c,内核的"主函数"
↓
rest_init() ← 关键分岔点
↓
kernel_init() ← 它最终变成 1 号进程(systemd)
↓
systemd fork 出所有服务 → 登录界面
先解释几个词:
- 固件(Firmware):焊在主板上的小程序。老机器叫 BIOS(Basic Input/Output System,基本输入输出系统),新机器叫 UEFI(Unified Extensible Firmware Interface,统一可扩展固件接口)。它的工作只有一个:硬件自检,然后从硬盘加载下一棒。
- GRUB(GRand Unified Bootloader,统一引导加载器):Linux 世界最常用的”下一棒”,它的全部工作是:找到内核文件、加载进内存、把 CPU 交给内核。
- vmlinuz:压缩过的内核镜像文件,
/boot/目录下能看到它。名字里的vm指 virtual memory,z指压缩。
第一遍学习,固件和 GRUB 不用深挖,从”内核被加载进内存”开始看即可。
内核最开头:一段汇编
位置:arch/x86/kernel/head_64.S(x86 是最常见的 PC 架构;ARM 机器对应 arch/arm64/kernel/head.S)。
为什么开头必须是汇编?因为此时 C 语言的环境还不存在:没有栈、没有全局变量地址、内存管理单元还关着。这段汇编只做三件事:
- 开启分页(虚拟内存的硬件基础,见第 4 章);
- 给自己搭一个栈;
- **跳进第一个 C 函数 **
start_kernel(),从此不再回头。
读源码建议:这一段第一遍跳过。只要记住结论——“汇编的使命就是让 C 能跑起来,然后光荣退场”。
start_kernel():内核主函数
位置:init/main.c。这个函数很长,几百行,几乎全是函数调用,像一份总目录。内核在这里按依赖顺序给各个子系统上电:没有内存管理就没有分配器,没有分配器什么都建不起来,所以顺序是固定的。
简化后的骨架:
asmlinkage __visible void __init start_kernel(void)
{
setup_arch(); // 架构初始化:解析内存布局、建早期页表
setup_command_line(); // 保存内核启动参数(就是 GRUB 菜单里 "quiet splash" 那些)
mm_core_init(); // ★ 内存管理上线:伙伴分配器(分配物理页的部门)
sched_init(); // ★ 调度器上线(第 6 章)
init_IRQ(); // 中断系统上线
time_init(); // 时钟上线:调度、sleep 全靠它计时
console_init(); // 控制台上线:这之后你才能看到 dmesg 打印的内容
softirq_init(); // 软中断(中断处理的"下半场")上线
...
/* 一路把各种子系统插上电…… */
rest_init(); // ★★★ 全剧最重要的分岔点,见下一节
}
几个缩写顺便解释:
- IRQ(Interrupt ReQuest,中断请求):硬件”拍桌子”叫 CPU 的信号线。鼠标动了、网卡收到包了、磁盘读完了,都靠中断通知 CPU。
- dmesg:一个命令,查看内核自己打印的日志。
console_init()之前的日志你是看不到的。
读法:不要逐个展开这几百个调用。把 start_kernel() 当目录读一遍,能说出”哦,内存比调度器先上电”就够了。
rest_init():从内核走向多任务世界的门口
位置:init/main.c。这是整个启动流程里最值得逐行读的函数,因为只有几十行,却是分水岭。
noinline void __init_refok rest_init(void)
{
struct task_struct *tsk;
// 1. 创建第一个内核线程,它去跑 kernel_init 函数
pid = user_mode_thread(kernel_init, NULL, CLONE_FS);
// 2. 当前的这个执行流(idle 的雏形)转去执行 cpu_startup_entry()
// 即"没有任务可跑时 CPU 待的地方",无限循环省电
schedule_preempt_disabled(); // 3. ★ 第一次调用调度器
cpu_startup_entry(CPUHP_AP_ONLINE_IDLE); // 它正常情况不会返回
}
用工厂比喻理解这三步:
- 此刻”工厂”里只有厂长一个人(当前执行流)。
- 厂长招了第一个工人
kernel_init(第 1 步),让他待命。 - 厂长宣布”开工”(第 3 步,第一次调度),然后自己走进传达室打瞌睡(idle 循环)——从此之后,只要没有别的活,CPU 就在 idle 里休息,省电。
一个重要认知:到这里为止,世界上没有任何普通进程,只有内核自己。
上述存在一些误解,所以第一次学习时候有点没懂user_mode_thread 创建的不是“内核里第一个线程”,下面详细解释一下:
- PID 0(init_task / swapper):第一个任务,但它是静态定义的,不是创建出来的。
- PID 1(kernel_init → systemd):第一个被动态创建的任务,就是
user_mode_thread(kernel_init, ...)造的那个。
所以user_mode_thread 创建的不是“内核里第一个线程”,而是“第一个被 fork 类机制创建出来的线程”。PID 0 比它资格老,但没经过创建过程。
顺带解释
user_mode_thread和kernel_thread的区别:内核里有两个造线程的函数,kernel_thread()造的线程只准待在内核态;user_mode_thread()造的线程将来允许切换到用户态去执行。kernel_init之后要execve("/sbin/init")变成用户态进程,所以必须用user_mode_thread。
那 rest_init 最后去哪了
rest_init() 是函数不是任务,它执行完(其实永远不返回)后,执行它的这条执行流——PID 0——的身份就变成 idle:
cpu_startup_entry(CPUHP_AP_ONLINE_IDLE); ↓do_idle() // 死循环:反复检查"有没有活干" 没活 → 执行 mwait/hlt 指令让 CPU 睡眠省电 来活了 → 被调度器叫醒 → 干活 → 再回来睡
所以之前那个厂长比喻可以再精确一步:
- PID 0(init_task):厂长本人,开机全程是他在干活(跑汇编、start_kernel、rest_init),最后自己走进传达室常驻打瞌睡(idle),从此他的正式职务就是“0 号员工 = 本核的待机员”。
- PID 1(kernel_init):他招的第一个工人,后来去用户态当了 systemd。
- 多核机器上其他 CPU 核后来上线时,各自会 fork 一个自己的 idle(
fork_idle()),但 0 号核的 idle 永远是最初那个 init_task,PID 0 只有一个。
补充术语:
- 内核线程(kernel thread):只跑在内核态的执行流,没有自己的用户程序、没有用户地址空间。它和普通进程用同一套
task_struct管理(第 3 章)。 - idle:每个 CPU 核一个的待机执行流。调度器发现没任务时就把 CPU 分给它,它执行特殊指令让 CPU 降频等待,不空转烧电。
kernel_init():第一个用户态进程的诞生
位置:还在 init/main.c。kernel_init() 作为内核线程先跑腿:
kernel_init()
├─ 完成剩余初始化:根据启动参数挂载真正的根文件系统
├─ (如果配了 initrd/initramfs,先用它过渡)
└─ ★ run_init_process("/sbin/init")
└─ 本质是 kernel_execve():加载磁盘上的 ELF 文件并开始执行
- initrd / initramfs(boot 时挂的临时小文件系统):现实中的鸡生蛋问题——内核要挂载磁盘上的根文件系统,可能需要磁盘驱动,而驱动文件在根文件系统里。解决:GRUB 先塞给内核一个小型内存文件系统,里面有临时驱动和挂载脚本,用它过渡到真根。
- ELF(Executable and Linkable Format,可执行与可链接格式):Linux 上
.out可执行文件的标准格式,ls -l /sbin/init看到的那个文件就是 ELF。 - execve:见第 3 章。一句话:把当前执行流的用户态程序整个换掉,“PID 不变,灵魂换了”。
关键的戏剧性时刻:kernel_execve() 执行 /sbin/init(在现代发行版上,它是软链接,指向 systemd——1 号进程、所有服务的管理者)。此时 kernel_init 这个 task_struct 被复用:PID 依然是 1,但它从”内核线程”变成了”用户态进程”。
开机到此结束。此后你机器上的一切进程,全是 1 号进程直接或间接 fork 出来的。
系统调用:用户程序进入内核的门
为什么要有这扇门
先立地基,两个概念:
- 用户态(user mode):普通程序运行的 CPU 模式。权限受限:不能直接碰硬件、不能直接访问内核内存。你的浏览器、你的 hello world 都在这。
- 内核态(kernel mode):内核运行的 CPU 模式。权限全开:能碰硬件、能访问所有内存。
为什么这么设计?如果任何程序都能直接读写磁盘、改别人的内存,一个 bug 就能毁掉整台机器。所以 CPU 硬件层面划了这道墙,而系统调用(system call,常缩写 syscall)就是墙上唯一的门:用户程序想用内核的服务(读文件、分配内存、创建进程),必须排队敲门,让内核代劳。
一个类比:用户程序是客户,内核是柜台后面的职员,syscall 是取号机。客户不能翻柜台自己翻档案,只能取号(发起系统调用),职员(内核)验号后替你去办。
一次 read() 的完整旅程
需求:C 代码里 read(fd, buf, 100),从文件读 100 字节。它要穿过几道门。
第 0 步:libc 包装
你调用的 read() 其实是 libc(C standard library,C 标准库,glibc 最常见)里的包装函数。它做的事:把参数装进寄存器、把系统调用号装进寄存器、执行特殊指令。
第 1 步:syscall 指令——墙上的门缝
x86-64 上就是一条指令:syscall(ARM64 上叫 svc)。它做两件事,硬件自动完成:
- CPU 从用户态切到内核态(权限升级);
- 跳到内核事先登记好的固定入口(门的位置是内核定的,用户程序无法伪造)。
同时 libc 按约定(ABI,Application Binary Interface,二进制接口约定)把参数放好:系统调用号放 rax,三个参数放 rdi、rsi、rdx。
第 2 步:入口分发
位置:arch/x86/entry/entry_64.S → arch/x86/entry/common.c。
syscall 指令
↓ entry_SYSCALL_64 (汇编入口:保存现场、切换栈)
↓ do_syscall_64 (查系统调用表)
↓ x64_sys_call() (根据 rax 里的编号分发)
系统调用表:一个函数指针数组,编号即下标。read 的编号是 0,write 是 1,open 是 2……(unistd.h 里那些 __NR_read 宏)。所以”门后面的房间”就是一张内核预先建好的大表,按号取房。
第 3 步:进入通用层
位置:fs/read_write.c。
SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count)
{
return ksys_read(fd, buf, count);
}
ssize_t ksys_read(unsigned int fd, char __user *buf, size_t count)
{
struct fd f = fdget_pos(fd); // ★ 拿到 struct file
...
ret = vfs_read(f.file, buf, count, &f.file->f_pos);
...
}
先解释宏:SYSCALL_DEFINE3(read, ...) 的意思是”定义一个 3 参数的系统调用,名字叫 read”。数字就是参数个数。展开后就是你熟悉的函数定义,这是内核用来统一生成元数据的写法。
注意一个重要细节:buf 的类型是 char __user *——用户态指针。内核绝不直接信任用户给的指针(它可能为 NULL、可能越界、进程可能骗你)。所有跨墙拷贝必须用 copy_to_user() 这类专用函数,先验证再拷贝。内核里每一个安全漏洞的祖师爷,几乎都是”直接解引用了用户指针”。
第 4 步:VFS 通用层
vfs_read()(同文件)做权限检查,然后是本节最重要的一行:
ret = file->f_op->read_iter(...) // 或走新版封装 new_sync_read
f_op 是 struct file_operations(文件操作表)指针。这是什么?内核版的多态:
struct file_operations {
ssize_t (*read_iter)(...);
ssize_t (*write_iter)(...);
int (*open)(...);
int (*release)(...);
...
};
(*read_iter)(...)语法含义:函数指针——结构体里存的不是数据而是”函数的地址”,调用时才决定执行哪段代码。
设计思想:具体文件系统负责填表,通用层负责查表。
普通文件:ext4 填的表 → 读写走页缓存,最终到磁盘
终端:tty 填的表 → 读写走到键盘/显示器
管道:pipe 填的表 → 读写走到内存里的环形缓冲区
这就是为什么 read() 对文件、终端、管道都能用:通用层只认接口,不问出身。这与 OOP(面向对象编程)里的”接口 + 实现”完全同构,只是用 C 的函数指针手工实现。
第 5 步:返回
结果写回寄存器,sysret 指令降回用户态,libc 拿到返回值,你的 read() 返回。全程微秒级。
把整条链串起来(背下来)
用户 read()
→ libc 装参数 → syscall 指令
→ entry_SYSCALL_64(保存现场、升权限)
→ 按调用号查表分发 → SYSCALL_DEFINE3(read)
→ fdget_pos(fd) 拿到 struct file(整数 fd 的真身)
→ vfs_read()
→ file->f_op->read_iter(...) 函数指针分发到具体文件系统
→ 数据经 copy_to_user() 交回用户 buf
→ sysret 降回用户态
用 strace 现场验尸
strace(system call trace,系统调用追踪器)能打印进程的每次敲门:
strace cat /etc/hostname
你会亲眼看到:openat(...) = 3(fd 3 被分配)、read(3, "...", 8192)、write(1, ...)(fd 1 是标准输出)、close(3)。纸上看到的链,用 strace 都能对上号——这是检验你理解的最快手段。
进程:fork、exec 与线程
进程的”户口本”:task_struct
内核里进程不叫 object、不叫 Process,管理单位叫任务(task),核心结构是 struct task_struct,定义在 include/linux/sched.h,有几百个字段。第一遍不要试图全懂,先抓这张关系图:
task_struct(一个执行流的户口本)
├── mm → 该进程的整个地址空间(第 4 章主角)
├── files → 打开的文件表(fd 0/1/2 和你 open 的所有文件)
├── fs → 当前目录、根目录
├── signal → 信号相关(Ctrl+C 怎么杀死进程就靠它)
├── parent → 父进程是谁
├── pid / tgid → 编号
├── cred → 身份:哪个用户、什么权限
└── 调度字段 → 优先级、虚拟运行时间等(第 6 章)
两个编号顺便讲清:
- PID(Process IDentifier,进程编号):进程的唯一编号。
- TGID(Thread Group ID,线程组编号):线程出现后引入的。进程的 PID = 它自己线程组的 TGID;同组内每个线程有自己的 PID,但共享一个 TGID。用户态看到的”进程号”其实是 TGID。
内核里所有任务串在一条全局链表(task_struct->tasks)上,ps 命令就是遍历它(实际读 /proc,/proc 是它的投影)。
fork 的总思路:先整体照抄,再按需改
破除一个常见误解:Linux 创建进程不是照图纸从头造一个,而是把父进程整个复制一份,然后按参数决定哪些资源”真复制”、哪些”共享”。这就是 fork 极快的根本原因。
调用链:
用户 fork()
→ kernel_clone() ← kernel/fork.c
→ copy_process() ← fork 的核心,90% 逻辑在这
→ wake_up_new_task() ← 新任务放进调度队列
→ 两个进程各回用户态
fork() 只是 libc 里的薄包装,真实实现是 kernel_clone()(以前叫 _do_fork,新版本改名了,内核喜欢改这种名字,老博客别较真)。
copy_process() 逐段拆解
位置:kernel/fork.c。按执行顺序,共六步。
第 1 步:领户口本
static __latent_entropy struct task_struct *copy_process(...)
{
p = dup_task_struct(current); // ★ current 是谁?
- current:内核里最著名的宏,指”当前正在 CPU 上跑的这个任务自己”。
dup_task_struct():从 slab 缓存里领一块现成内存(slab:内核的对象池机制,常用尺寸的对象预先批量造好,领用归还都快,相当于”成品零件柜”),得到新的task_struct和配套的内核栈(进程陷入内核态时用的独立栈,和用户栈是两回事),然后:
// dup_task_struct 内部(简化)
*p = *orig; // ★★ 整块 memcpy 照抄父进程
fork 的”儿子像爹”,物理来源就是这一行:先整个照抄。
第 2 步:改身份
copy_creds(p, clone_flags); // 拷贝用户 ID、权限(cred = credentials,凭证)
第 3 步:按 clone_flags 决定资源”复制”还是”共享”
这是整个 fork 的灵魂。 clone_flags(克隆标志位)是一组二进制开关,每个开关管一项资源:
| 开关 | 效果 |
|---|---|
不设 CLONE_VM | 复制地址空间 → 表现为进程 |
设 CLONE_VM | 共享地址空间 → 表现为线程 |
设 CLONE_FILES | 共享打开文件表(线程常用) |
设 CLONE_SIGHAND | 共享信号处理表 |
| 都不设 | 全套复制 → 经典 fork |
对不上号的直觉:进程和线程在内核里没有本质区别,都是 task_struct,区别只在这组开关。
逐个看三个关键函数:
copy_mm()——Copy-on-Write(COW,写时复制)的诞生地:
static int copy_mm(unsigned long clone_flags, struct task_struct *tsk)
{
if (clone_flags & CLONE_VM) {
// 线程:地址空间直接共享,引用计数 +1
mmget(oldmm);
tsk->mm = oldmm;
return 0;
}
// 进程:dup_mm → 复制的是"页表"这层簿记,不是物理内存!
retval = dup_mm(tsk);
}
关键:dup_mm() 复制的是页表(地址翻译的”账本”,见第 4 章),让父子两本账都指向同一批物理页,并把两本账上的所有条目都标记为只读。物理内存一个字节都没多花。
之后谁先写,谁的 CPU 就发现”这页标了只读但你却在写”——触发缺页异常(page fault),内核在异常处理里(mm/memory.c 的 do_wp_page())才当页复制那一页,再各自改各自的。这就是”写时复制”四个字的全部含义:抄账本不抄货,谁动谁付钱。
推论:一个 malloc 了几个 GB 的进程,fork 瞬间只多付一本页表的钱(每 4KB 页一条账目,页表本身也压缩得很好),这就是 fork 快的真正原因。
copy_files()——文件表的规则:
fd 表整体复制,父子各持一份表。但表里每项指向的 struct file(打开文件对象)不复制,只把引用计数 +1。所以 fork 后父进程再 read() 会推进同一个文件偏移量——父子读同一本书,共用一个书签。
copy_thread()——一场精妙的”伪造现场”(本节高潮):
int copy_thread(struct task_struct *p, ...)
{
// 取出内核栈顶的异常帧(保存用户态现场的结构)
struct pt_regs *regs = task_pt_regs(p);
// 让子进程的"用户态返回值" = 0
regs->ax = 0; // ★★★
// 子进程内核栈里放一个假的中断返回帧
...
p->thread.ip = (unsigned long) ret_from_fork; // 子进程第一次被调度时的起跑点
}
理解它,就能回答初学者三大经典问题之一:“fork 为什么返回两次、值还不一样?”
真相是:fork 只被真正执行了一次(父进程)。子进程从未执行过 fork——它是被伪造了一个”刚要返回”的现场:内核往子进程的内核栈里塞了一个假的寄存器现场,指令指针指向”从 fork 返回”的位置,返回值寄存器 ax 被写成 0。等调度器第一次切到子进程、恢复这个现场,它便”从 fork 处醒来”,拿到 0。
而父进程是真实跑完 fork 的,返回值是新任务的 PID。两个返回值,来自两个不同的现场恢复,不是函数被调了两次。
第 4 步:登记身份
copy_process() 内:
pid = alloc_pid(...) // 领编号
p->pid = pid_nr(pid);
p->real_parent = current; // 认爹
// 并挂进若干哈希表和全局链表
list_add_tail_rcu(&p->tasks, ...);
RCU(Read-Copy-Update,读时复制更新):内核核心的并发技巧——改链表时不打断读者,先造新版再原子切换指针。第一遍知道名字即可。
第 5 步:上户口
回到 kernel_clone():
wake_up_new_task(p); // 选个 CPU 的运行队列,挂进去
见第 6 章。然后父进程正常返回——此刻子进程还没跑过一行代码,只是被登记进调度队列等待发车。
exec:不造新人,只换灵魂
fork 单独用没意义,经典组合是 fork + exec:fork 出子进程,子进程立刻 execve() 装入新程序(这就是 shell 执行命令、posix_spawn、所有进程诞生的标准姿势)。
位置:fs/exec.c。
execve("ls") 干的事:
- 找到磁盘上的 ELF(可执行文件格式);
- 销毁当前 mm:解除所有旧映射(页表、映射区域全扔);
- 读 ELF 的段(segment),建新映射:代码段、数据段、堆、栈;
- 把
argv、envp(参数表、环境变量表)压到新栈顶; - PID 不变、files 不变(所以 fd 能传给新程序!)、parent 不变;
- 设好返回用户态的现场:指令指针 = ELF 的入口地址。
一句话:户口本不动,灵魂整个换掉。exec 不是”创建”。
这个特性 + COW 是 Unix 的传家宝:fork 极便宜(抄账本)+ exec 干净(换灵魂)+ fd 继承,组合出 shell 管道(ls | grep:fork 两个子进程,用 pipe() 连起来,各自 exec)。
退出与收尸:exit、wait 和”僵尸”
exit()(内核态do_exit(),kernel/exit.c):释放 mm、fd 表、关闭文件……最后task_struct里只剩一个死亡证明。为什么不干脆连户口本一起注销? 因为要留证据给父进程:退出码还没人取。- 僵尸(zombie)状态:已死、资源已释放、只剩登记信息等父进程来查。
wait()/waitpid()(do_wait()):父进程读死亡证明,此后 task_struct 才真正注销,僵尸升天。- 孤儿(orphan):父进程先死,子进程被过继给谁?systemd(PID 1)——它有个内置循环专门收尸。这就是 PID 1 众多职责中最底层的。
学到这里顺手自测一个高频面试题:为什么大量产生子进程却不 wait,会撑爆系统?——每个僵尸占一条不可回收的 task_struct。
本章小结(背下来)
fork = 整体照抄(dup_task_struct 的 memcpy)
+ 按 clone_flags 决定每项资源复制还是共享(copy_mm / copy_files / ...)
+ copy_thread 伪造"刚从 fork 返回"的现场(ax = 0)
+ wake_up_new_task 挂进调度队列
exec = 不造新人换灵魂:旧 mm 全销毁,装 ELF 新映射,PID/fd/爹都不变
线程 = clone(CLONE_VM|CLONE_FILES|...):只换开关,别无不同
退出 = do_exit 释放一切 → 僵尸等父 wait 收尸 → 孤儿归 systemd
虚拟内存:页表、缺页与写时复制
为什么要虚拟内存
假如程序直接用物理地址:两个程序都想住 0x1000 这间房怎么办?一个 bug 把别人内存写了怎么办?
解法:给每个进程一套独立编号的”虚拟地址”,真实住在哪由一个翻译层决定。这就是虚拟内存(virtual memory):
进程眼里的地址(虚拟地址)
↓ 翻译(硬件自动做)
真实的物理地址(哪个内存条、哪个格子)
好处三合一:隔离(各住各的编号,互不干扰)、省内存(用多少分多少)、灵活(可以搬家、可以映射文件、可以换出到磁盘)。
翻译层:MMU 与页表
- MMU(Memory Management Unit,内存管理单元):CPU 里负责翻译地址的硬件部门。程序每访问一次内存,MMU 都要现查一次”账本”。
- 页(page):翻译不是逐字节翻的,而是按4KB 一块翻,一块叫一页。整页整页翻,账本才能薄。
- 页表(page table):账本本身。多级结构(x86-64 是四级:PGD → P4D → PUD → PMD → PTE,名字不用背,思想是”账本套账本”——像省→市→区→街道→门牌,一次查询走四跳)。
- TLB(Translation Lookaside Buffer,地址翻译缓存):MMU 的随身小抄。四级查找太慢,最近翻过的结果记在小抄上,下次直接命中。TLB 未命中才走完整四级——这就是换地址空间(比如切换进程)要刷 TLB、因此很贵的原因。
一个关键认知:账本是按进程一本一本建的。两个进程虚拟地址相同(比如都从 0x400000 开始),物理上毫无冲突,因为账本不同。
内核里的三个核心结构
都在 include/linux/mm_types.h:
struct mm_struct {
pgd_t * pgd; // ★ 账本第一级的地址(此结构 = 进程的整个地址空间)
...
struct maple_tree mm_mt; // ★ 该进程所有"已登记区域"的索引
};
struct vm_area_struct { // 缩写 VMA(Virtual Memory Area,虚拟内存区域)
unsigned long vm_start; // 这块区域的起始虚拟地址
unsigned long vm_end; // 结束
unsigned long vm_flags; // 读/写/执行权限
struct file *vm_file; // 若非空:这块区域映射的是哪个文件
const struct vm_operations_struct *vm_ops; // 这块区域自己的"操作表"(又是函数指针!)
};
struct page { // ★ 物理内存的格子:每个物理页一个,全局一份
unsigned long flags; // 状态标记:是否脏(被写过)、是否在内存里……
atomic_t _refcount; // ★ 几人在共享(引用计数)
...
};
三者的关系,一张图:
进程的虚拟地址空间(mm_struct,一本账)
┌────────────────────────────────────────┐
│ VMA: 代码段(只读,vm_file=hello 可执行文件) │
│ VMA: 数据段(读写) │
│ VMA: 堆(malloc 用,匿名=vm_file 为空) │
│ 未登记的空洞 ← 访问这里直接 SIGSEGV │
│ VMA: 共享库映射(只读,vm_file=libc.so) │
│ VMA: 栈(读写) │
└────────────────────────────────────────┘
↓ 账本:页表(pgd 起头的四级结构)
↓ 每条账目指向……
struct page(物理格子,几号内存条、第几格)
- SIGSEGV(Segmentation Violation,段错误信号):访问了未登记地址时,MMU 翻不出来,内核检查后发现该地址不在任何 VMA 里,或权限不符(写只读页),于是发这个信号——默认行为是”进程崩溃”。段错误的本质是:地址要么查无此人,要么权限不符。
一个高频面试点的答案就藏在这里:malloc 不报错但一写就崩,为什么? 因为 malloc 只在堆上”登记”了更大的 VMA(账面动作),没分物理页。真正写入时 MMU 查账:条目不存在 → 触发 page fault → 内核现场分页 → 若发现连 VMA 都没有 → SIGSEGV。延迟分配(lazy allocation)是贯穿整个内存子系统的设计哲学:能拖到最后一刻的分配就拖。
mmap:登记一块区域
mmap() 系统调用(mm/mmap.c)就是把上面图中”新增一个 VMA”:
// 用户 mmap(NULL, len, PROT_READ|PROT_WRITE, MAP_ANONYMOUS|MAP_PRIVATE, -1, 0)
SYSCALL_DEFINE6(mmap, ...)
↓
map = mmap_region(...)
// 核心:造一个 vm_area_struct,填好起止地址、权限,
// 挂进 mm_struct 的索引树。结束。
// ★ 没分配任何物理页。
两种模式的分岔,务必分清:
- 匿名映射(anonymous,
MAP_ANONYMOUS):vm_file为空,不对应任何文件,纯粹”要一块内存”。malloc 大块、线程栈都是它。 - 文件映射(file-backed):
vm_file指向某文件,内容 = 文件对应区段。读写文件就像读写内存,进程加载 ELF 用的就是它——execve装代码段时就是mmap了hello这个文件。
page fault:账上没这页?现场补
page fault(缺页异常):MMU 查账没查到(或权限冲突),CPU 临时停下当前指令,跳进内核预先登记的处理函数,处理完再回来重试那条指令。它 99% 不是错误,是内核”现场办事”的机制。
处理链(mm/memory.c):
MMU 翻译失败 → CPU 跳异常入口(汇编, arch/x86/entry/entry_64.S)
→ exc_page_fault() (x86 入口)
→ handle_mm_fault(vma, address, flags)
├─ 先查:这个地址落在哪个 VMA?
│ ├─ 不在任何 VMA → 发 SIGSEGV(真正的错误)
│ └─ 在 VMA → 接着看是哪种缺页 ↓
├─ ① 读匿名页缺页(第一次碰 malloc 的内存)
│ → do_anonymous_page():领一个物理页,清零,记账,返回
│ → CPU 重试指令 → 成功
├─ ② 读文件页缺页
│ → 从磁盘把对应页读进来(走页缓存,第 5 章),记账
└─ ③ 写"只读标记的 COW 页"(第 3 章埋的伏笔在此引爆)
→ do_wp_page():★ 真正复制这一页(写时复制的"付钱时刻")
检查:真的只有我在用?→ 独占,只改回可写权限
还有别人共住? → 复制一份给自己
把 ③ 讲透,因为它回收了第 3 章的伏笔:fork 时 dup_mm 抄了账本、所有页标只读。此后:
- 两边都只读 → 一直共享一份物理内存,分文未付。
- 子进程写其中一页 → MMU:
0x1234000标记只读但你写 → 异常 →do_wp_page→ 复制这 4KB 给子进程独用,两边各自改回可写 → 重试写 → 成功。
COW 的省钱账:fork 后子进程往往立刻 execve,旧地址空间全部销毁,99% 的页从未被写——那些复制就永远不必发生。
顺带两个词,常见但不该陌生:
- swap(换页/交换):物理内存吃紧时,内核把不活跃的匿名页写进磁盘的 swap 分区/文件,腾出物理页。要用时再从盘上换回——本质也是一次特殊 page fault。
- 颠簸(thrashing):内存太紧,刚换出去又要用,CPU 大部分时间耗在换页上——系统”假死”的常见原因。
本章小结(背下来)
mm_struct = 一本账(pgd 指向四级页表)
VMA = 账上登记的一块区域(mmap 只登记、不分配)
page = 物理格子(全局一份,引用计数管共享)
MMU/TLB = 硬件翻译员 + 小抄
page fault= 账上没有就现场补:匿名页→清零给页
文件页→从盘读入
COW页→当场复制
SIGSEGV = 查无此人或权限不符
文件系统:VFS 四层结构
VFS 是什么
一台机器上往往同时有 ext4(Linux 常用本地盘格式)、tmpfs(纯内存文件系统,/tmp)、procfs(内核信息投影,/proc)、NFS(网络文件系统)……为什么 open/read/write 一套 API 通吃?
答案是 VFS(Virtual File System,虚拟文件系统):内核在”通用 API”和”各家具体文件系统”之间加了一层统一抽象。思想与第 2 章 file_operations 一脉相承——通用层定接口,各家填函数指针。VFS 是 Linux 内核最成功的一次抽象设计,没有它就没有”一切皆文件”的 Unix 哲学。
四层结构:一个 fd 的族谱
核心是分清四个概念。所有初学者混的都是它们:
fd(整数,进程私有的表格下标)
└─→ struct file("这次打开"的会话状态:偏移量、权限、引用计数)
└─→ dentry("路径名 → inode"的翻译件,目录项缓存)
└─→ inode(文件本体元数据:大小、权限、修改时间……)
└─→ 具体文件系统/设备里的真实数据
- fd(file descriptor,文件描述符):就是个小整数,进程私有表格的下标。
ls -l看到的 0/1/2 就是标准输入/输出/错误的 fd。fd 相对、file 绝对:fork 后父子各自 fd 表里都有 3 号,指向同一个 file(第 3 章copy_files的伏笔)。 - struct file:一次”打开会话”的全部状态——读写的偏移量(这次读到哪了)、打开的模式。两个进程分别
open同一文件 = 两个 file、两本书签,各读各的;fork 共享 = 一份 file、一个书签(第 3 章讲过)。 - dentry(directory entry,目录项):路径名和 inode 的绑定关系,带全局缓存(dcache,dentry cache)。
/home/a.txt里”a.txt”这个名字就是一层 dentry,dentry 指到 inode 才找到本体。 - inode(index node,索引节点):文件本体的元数据(注意:inode 里没有文件名——名字在 dentry 里,这是最反直觉也最重要的一句)。一个 inode 可以被多个 dentry 指向(硬链接:两个名字同一个本体;
ls -i可见两个名字 inode 号相同)。文件本体删不删,由 inode 的引用计数决定,跟 dentry 无关。
一道经典的分概念题,此刻你应该能秒答:rm 删文件名后文件为什么还可能在?(fd 还开着,struct file 引用着 inode,数据仍在——这正是 lsof +L1 能找回”已删除但仍被进程持有”文件的原理。)
open 的旅程:字符串 → inode
open("/home/a.txt", O_RDONLY) 的链(fs/open.c、fs/namei.c):
SYSCALL_DEFINE(openat 系列入口)
→ do_sys_openat2()
├─ get_unused_fd_flags() // ★ 第一步:领一个空闲 fd 号
├─ do_filp_open()
│ └─ path_openat() → filename_lookup() ← 逐级查路径,重头戏在这
│ 走到 fs/namei.c 的 link_path_walk():
│ 从根("/")或当前目录的 dentry 出发
│ 取出路径分量"home"→ 在父目录 inode 里查"home"这个 dentry
│ → 下一段"a.txt" → 查到目标 inode
│ (dcache 缓存命中就不真查盘)
│ └─ 造一个新的 struct file,填好:指向目标 dentry/inode、
│ 调用 inode->i_fop->open(...)【又是函数指针,各家文件系统的入口】
└─ fd_install(fd, f) // ★ 最后一步:fd 号放进本进程的表格
→ 返回用户态
两个值得咀嚼的细节:
- 顺序反直觉:fd 号是先领的,最后才装进表——领号排队在前,办事在后,fail 早退原则。
- 权限检查在每个分量上做:
/home/a.txt要有权限穿过home、进到a.txt,逐级查(execute 权限在目录上的含义就是”允许穿过这个目录找东西”)。
read 的旅程:回到第 2 章那条链
read(fd, buf, len)
→ fd → struct file → dentry → inode → 具体 file_operations → ……
唯一新知识:页缓存(page cache)。本地文件系统几乎不直接让 read 走盘:读文件时按页缓存进内存(address_space 结构管理的”文件 → 物理页”缓存树),第二次读同一段就不再碰盘。写也是同理——先写页缓存、标记”脏”(dirty,已改未刷盘),内核攒一批再异步入盘。Linux 文件 I/O 的性能引擎就是页缓存。
read 命中页缓存 → 微秒级,纯内存
read 未命中 → page fault(第 4 章 ②)→ 从盘读入页缓存 → 下次命中
第 4 章的”文件映射缺页”与此完全打通:mmap** 一个文件后读它 = 用缺页异常按需从页缓存取页**——两条看似无关的机制,在页缓存处合流。这是内核设计中”一个缓存服务所有路径”的典范。
一图收束(背下来)
fd(下标)
→ file(会话:书签/偏移量,fork 共享)
→ dentry(名字,全局缓存)
→ inode(本体元数据+数据指针,引用计数管生死)
→ file_operations(函数指针表,各家文件系统填)
→ 页缓存(读写性能引擎,mmap 与 read 在此合流)
调度器:CPU 时间怎么分
要解决什么问题
每个 CPU 核同一时刻只能跑一个任务。但你有浏览器、音乐、终端几十个进程”同时在动”。原理是:把 CPU 时间切成毫秒级的小片,轮着给——快到人感觉不出切换。
- 时间片(time slice):每次分给任务的连续时长,几毫秒量级。
- 上下文切换(context switch):换人时保存旧任务现场(寄存器)、恢复新任务现场。就是
task_struct里的thread字段和内核栈里那套 pt_regs 的存取——第 3 章”伪造现场”用的是同一个机制,只是这次是真存真取。
任务三种典型状态:
运行中(正占着 CPU)
↓ 时间片耗尽或被更高优先级抢走
就绪(万事俱备,只欠 CPU —— 挂在运行队列里排队)
↓ 被调度器选中 ↑ 被唤醒(事件到达,如数据可读)
睡眠(等事件:等磁盘、等键盘、等锁、sleep)──┘
- 运行队列(run queue,新代码叫
struct cfs_rq等按策略分队列):每个 CPU 一份的”就绪任务集合”,本质是一棵按”紧急程度”排序的树。 - 主动调度:任务自己睡(
read没数据等、sleep、等锁)→ 主动调schedule()让位。 - 被动调度(抢占,preemption):时间片用完或高优先级任务醒来,内核强行换人。
直觉校准:大部分切换是任务自己睡的(主动),“抢来抢去”是少数——所以几十个任务并不真的在”争抢”,多数在睡觉排队等事件。
位置与入口
调度器在 kernel/sched/:
kernel/sched/core.c ← 总入口:schedule()、context_switch()(第一遍主读这个)
kernel/sched/fair.c ← 普通任务的"完全公平"策略(新版本叫 EEVDF)
kernel/sched/rt.c ← 实时任务策略(rt = real time,实时,必须尽快响应的任务)
include/linux/sched.h ← task_struct 定义
schedule():核心只有十几个字
core.c 里的主干(简化到骨架):
static void __sched __schedule(void)
{
prev = current; // 当前任务
next = pick_next_task(rq, prev); // ① 从运行队列挑下一个
if (prev != next) {
context_switch(rq, next); // ② 换人
}
}
context_switch 内部:
① 切地址空间:换 mm(刷 TLB —— 第 4 章讲过为什么贵);
内核线程不切(它没有用户 mm)。
② switch_to(prev, next):最后一刀,汇编级换栈+换寄存器。
执行完这行,CPU 已经在跑 next 了。
★ prev 之后的代码要等 prev 下次被切回来才继续执行
—— 这就是"被切换者视角":
每个任务眼里的世界都是"睡了一觉/被人切走过,
从 schedule() 调用点后面继续跑"。
switch_to() 有一层微妙的理解:它不是”函数调用后回来”,而是从此以后 CPU 的执行流属于 next;prev 挂在 switch_to 后面那几行,要等下次 prev 被别人选中才轮到执行——“下一段代码”是一张期货。把第 3 章 copy_thread 的”伪造现场”和这里对上,你就完全打通了”任务切换”的全部原理。
挑人:公平怎么定义
普通任务用公平调度(fair.c):核心直觉是**“记账”**——每个任务记一笔 vruntime(virtual runtime,虚拟运行时间,= 已占的 CPU 时间按权重折算)。
挑人规则:永远挑 vruntime 最小的 —— 谁最久没被 CPU 照顾,谁先来
睡眠任务醒来 → vruntime 很小 → 很快被挑到(等了很久,欠的该还)
CPU 密集任务 → vruntime 一直涨 → 让位给交互任务
一句话:排队时比的是”你多久没跑过”,不是”你多重要”(重要程度用 nice 值加权,nice 值越低越优先)。实时任务(rt.c)另立山头:严格优先级压倒公平,调度器永远先喂实时队列——所以实时任务要是失控占满 CPU,普通任务会饿死,内核为此留了保底机制。
唤醒:把睡觉的人捞回队列
第 4~5 章埋的伏笔在此收尾——“睡眠→唤醒”是调度器与所有等待场景共用的骨架:
read(fd, buf, len) 无数据可读
→ 标记自己睡眠(挂到该 socket/管道/文件的"等待队列" waiter 上)
→ 主动 schedule() 让出 CPU
...
事件到达(网卡中断收到包 / 对端写入管道)
→ wake_up(该等待队列)
→ 把任务改回就绪态,放回运行队列
→ 被 pick_next_task 选中 → 回到 read() 调用点后面继续执行
这一段就是 read() 在没有数据时会”卡住”的全部原理——它没卡住,它睡了。现在你应该能同时从三条链(第 2 章系统调用、第 3 章进程状态、本章调度)讲清同一个现象了。
本章小结(背下来)
schedule() = pick_next_task + context_switch
公平策略 = 比 vruntime(谁久没跑谁先跑,睡眠者有欠账优势)
实时策略 = 严格优先级,先于公平
换人成本 = 换 mm + 刷 TLB + 存取寄存器现场
睡眠/唤醒 = 挂等待队列 + 事件 wake_up —— read 卡住的全部原理
动手实验与下一步
不装环境的实验
# ① 看开机主线(第 1 章):所有 [ 0.000000] 开头的都是 start_kernel 的打印
dmesg | head -50
# ② 看系统调用(第 2 章)
strace cat /etc/hostname
# ③ 看 fork 链(第 3 章):数一数启动一个 shell 要 clone/execve 多少次
strace -c -e trace=clone,execve sh -c 'echo hi'
# ④ 看内存布局(第 4 章):找 VMA、找堆、找栈
cat /proc/self/maps
# 按 4KB 探测 malloc 何时真正拿页(观察 Rss/缺页):
grep -E 'VmSize|VmRSS' /proc/self/status
# ⑤ 看文件结构(第 5 章):两个硬链接同一个 inode 号
touch a b && ln a b2 && ls -li a b2
# 已删但被进程持有(fd 指着 inode):
tail -f /var/log/syslog & # 占住文件后另开终端 rm,lsof 还能看到
# ⑥ 看调度(第 6 章):观察睡眠为主的进程
ps -eo state,comm | sort | uniq -c | sort -rn
进阶:内核里的断点(有 QEMU 时)
QEMU(Quick EMUlator,开源虚拟机)+ GDB(GNU Debugger,调试器)里起一个测试内核,断点直接下在这份笔记讲过的函数上,眼见为实:
start_kernel ← 第 1 章:开机主函数
rest_init ← 第 1 章:分岔点
copy_process ← 第 3 章:fork 核心(观察参数、clone_flags)
do_execveat_common ← 第 3 章:exec
handle_mm_fault ← 第 4 章:所有缺页的总入口
do_wp_page ← 第 4 章:COW 付钱时刻
path_openat ← 第 5 章:open 找路
schedule ← 第 6 章:换人入口
学完这份笔记之后的地图
七条主干已覆盖内核 70% 的日常认知。再往外走,按需选:
中断与下半场(softirq/tasklet/workqueue):事件的"接收→延后处理"机制,驱动和网络的地基
块设备与 I/O 栈:第 5 章页缓存之下的故事
网络栈(net/):socket → TCP → IP → 网卡驱动,复杂度≈以上全部之和,放最后
同步(include/linux/spinlock.h 等):锁的原理,读 COW/调度时已经反复撞见
命名空间与 cgroup(kernel/cgroup/):容器(Docker)的内核地基——fork/进程语法的延伸