Learning Linux Kernel (Part 1)
I started learning the Linux kernel, and this is the first part of my notes.
前言
最近要开始学 Linux kernel 了。已经看了几节南京大学蒋老师的操作系统课,他在课上提到的最多的一句话就是“现在的 AI 真的非常厉害,你们在学习系统的时候比我当时的条件好多了”。于是我也想要改变之前学习的流程,不再是先看书、看视频、写笔记,而是直接上手,让 AI 带我学习。
AI 更像是一个量身定制的老师。学习 kernel 这种东西是非常众口难调的。对于计算机系统的基础理解不同会导致完全不同的学习路径和重点。AI,则相比之下更能适配不同背景的学习者,提供个性化的引导和解释。它可以根据我的提问和理解水平,调整讲解的深度和角度,帮助我更有效地构建知识体系。
为了避免 AI 讲得太过于碎片化,我也想要在学习过程中搭建一个清晰的骨架。这个骨架既不是某本书的目录,也不是某个课程的章节,而是我让 AI 知道让他带我学完之后我希望掌握的技能:
- Linux kernel 有哪些核心子系统,它们怎么连起来
- 看任何子系统时,知道该问什么问题
- 打开源码、文档、trace、论文,不会迷路
为了避免 AI 胡说,我也采取了以下策略:
- 提供了 browser 工具,让 AI 能够直接查阅官方文档和源码;
- 我使用(笔者写文章时当下最好的模型)GPT-5.4-xhigh 以及 Claude Opus 4.6 来教我学 Linux kernel,利用它们的强大能力来理解复杂的概念和代码
- 我直接让 Agent 跑在本地的 kernel 的代码库上,这样它就能直接结合代码来讲解,而不是泛泛而谈。
- 我会在学习过程中不断地提问,并且总结成 blogpost 发出来,这样各位读者也可以跟着我一起学,或者提出可能的疑问和建议。
当然,这毕竟是我根据自己的理解和学习路径搭建的骨架,并且是通过 AI 来填充细节的,所以难免会有不够准确或者不够全面的地方。欢迎大家批评指正,也欢迎大家跟着我一起学,一起提问,一起总结。
我希望我的学习笔记是精炼的,但又不失细节的。但是许多具体的 implementation 细节我可能不会展开讲解,因为那可能会让文章变得过于冗长和难以理解。我会尽量把重点放在核心概念和机制上,帮助大家建立一个清晰的知识框架。(或者我可能在后续的文章里再展开讲解一些细节?不过鉴于我现在也没有搞懂,我也不好承诺)
Linux Kernel 的总定义
如果要给 Linux kernel 一个最粗粒度的定位,可以把它看作资源管理器 + 事件处理器。它管 CPU——决定谁现在运行、谁排队等待;管内存——决定谁能看到哪些地址,物理页怎么分配、回收和共享;管设备与 I/O——包括磁盘、网卡、定时器、中断、文件和 socket。
我们来借用一些比喻辅助理解:
- 用户态程序像业务对象
- kernel 像一个全局唯一的 runtime,集合了 scheduler、memory manager 和 driver framework 的职责。
- Syscall 是受控的系统 API 调用
- 中断是异步的硬件回调
- 内核线程是 runtime 自己的后台工作线程
从什么开始?
我们会始终围绕这几个全局对象反复展开:
- task
- mm
- page
- file/inode/dentry
- socket/sk_buff
- device/driver
因为几乎所有 kernel 机制,最后都绕回这些对象的状态变化和交互。所以作为 intro 的章节, 我们先把这些对象的基本定义和它们之间的关系搞清楚。
执行上下文
很多初学者学乱 kernel,不是因为函数难,而是没有先区分执行上下文。Linux 内核里最常见的四种执行现场是 process context(例如用户态通过 read()、write()、fork() 进入内核)、interrupt context(例如网卡收包、磁盘完成 I/O、定时器触发)、延后处理(例如 softirq 和 workqueue),以及内核线程(例如 kworker、kswapd、ksoftirqd)。
面对任何一段内核代码,最重要的问题往往不是”这个函数做什么”,而是:它现在处于什么上下文?能不能睡眠?持有什么锁?是不是中断上下文?是不是可以被抢占?这些问题经常比”功能是什么”还重要。
内核里”这个函数能不能睡眠”之所以关键,是因为 sleep 意味着当前执行现场会主动阻塞,把 CPU 让出去,等某个条件满足后再被唤醒。这件事直接受执行上下文约束:在 process context 里通常可以睡眠,在 interrupt context 里不能,持有某些自旋锁时也不能。由此衍生出一对常见选择——mutex 可以睡眠,适合较长的临界区,但不能在中断上下文里用;spinlock 不会睡眠,适合极短的临界区和不能睡眠的上下文。
Syscall、Interrupt 和 Exception
这三个概念必须尽早分清。Syscall 是用户程序主动请求内核服务。Interrupt 是硬件异步打断 CPU。Exception 是当前执行某条指令时,CPU 同步发现某种异常条件。
从学习路径上,一个很实用的心智模型是:
- syscall 回答的是“进程主动要内核做什么”;
- interrupt 回答的是“外设异步告诉内核发生了什么”;
- exception(包括 page fault)回答的是“CPU 在执行当前指令时发现了什么异常条件”。
Syscall:受控进入内核的入口
用户态代码不能直接调用内核内部函数,也不能直接操作设备和页表。它必须通过 syscall 这扇门进入内核,比如 read()、write()、open()、mmap()、fork()。
可以把 syscall 理解成一次受控的上下文切换:
userspace call libc wrapper
-> CPU 执行 syscall 指令陷入内核
-> 内核按 syscall number 分发到对应处理函数
-> 参数检查 / 权限检查 / 对象查找 / 具体子系统逻辑
-> 返回值写回寄存器
-> 回到用户态继续执行text这里最重要的不是“跳进内核”这件事本身,而是“受控”二字:
- 用户态只能通过定义好的 syscall ABI 传参,不能随便访问内核数据结构;
- 内核必须校验用户指针和权限,避免把用户输入当成可信数据;
- syscall 运行在 process context,通常允许睡眠,所以可能触发调度。
这也是为什么看内核路径时,copy_from_user() / copy_to_user()、权限检查和错误码传播总是高频出现:内核在处理的不是“内部调用”,而是“来自用户态的请求”。
Interrupt:硬件驱动的异步事件
和 syscall 相反,interrupt 不是进程主动调用,而是设备在“自己准备好了”时通知 CPU。常见例子是网卡收到包、磁盘 I/O 完成、定时器到期。
粗粒度路径可以先记成:
device raises interrupt
-> CPU 暂停当前执行流并进入中断入口
-> 内核执行中断处理程序(ISR)
-> 必要时唤醒等待该事件的 task
-> 返回被打断的执行流(或随后触发调度)textinterrupt 的关键特征是异步和上下文约束:
- 它和当前正在跑的用户指令没有直接因果关系;
- 它发生在 interrupt context,不是 process context;
- 中断处理通常要求“快进快出”,不能做可能睡眠的操作。
Exception:CPU 执行发现的异常
Page fault 就属于第三类:exception。它不是程序主动发起的 syscall,也不是外部设备异步打断,而是 CPU 在执行当前指令时,发现地址翻译或权限条件不满足,于是同步陷入内核。我们在这里用 page fault 这个例子来说明 exception 的概念,同时我们引入对于内存管理非常核心的一个概念:虚拟内存。
Page Fault:不只是”出错”
Page fault 常见的触发原因有三类:页还没有真正映射到物理内存、权限不满足、或者 COW 需要兑现。所以它不一定代表程序出了错,很多时候它是虚拟内存正常工作的组成部分。
什么是 COW?不是奶牛,Copy-on-Write 是一种优化策略,通常在
fork()后使用。父子进程先共享同一批物理页,并把这些页标成只读。当某一方写入时触发 page fault,内核这时才复制这一页。这样做的好处是:如果父子进程都不修改这部分内存,就完全避免了不必要的复制,节省了内存和时间。之所以要先把共享页改成只读,是因为如果不改成只读,写入就不会触发 page fault,内核也就失去了”第一次写时再复制”的机会。
看一段简单的例子:
#include <sys/mman.h>
#include <unistd.h>
#include <stdio.h>
int main() {
size_t n = 4096 * 10;
char *p = mmap(NULL, n, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
p[0] = 'A';
p[4096] = 'B';
printf("%c %c\n", p[0], p[4096]);
}c这里
mmap()在做什么?mmap()是在当前进程的虚拟地址空间里,建立一段地址范围和某种”后端对象”之间的映射关系。这个后端对象可以是匿名内存、文件、或设备内存。关键在于mmap()通常先登记规则,不急着把所有物理页准备好。所以它更像”申请一段虚拟地址规则”,而不只是”立刻拿到一堆已经就位的物理内存”。
这里 mmap() 成功返回并不代表所有物理页都已就位。更常见的情况是 mmap() 先登记一段虚拟地址范围,第一次访问某页时触发 page fault,内核再去分配物理页、建立映射或检查权限。
为什么操作系统故意延迟分配? 为什么不在 mmap() 或 malloc() 的时候就把物理页一次性全分完?原因包括:程序不一定会用到每一页,延迟分配避免了白白浪费;支持稀疏使用场景;降低创建开销;以及支持 fork() 后的 COW。虚拟内存把”我想拥有这段地址空间”和”我现在真的需要物理内存”分开了。 这个观点相信大家在学习计算机系统课时候已经体会到了。
如果你写过一个 RISCV 的裸机 CPU,可能从未碰过”CPU 发现地址翻译不成立于是同步陷入”的概念。原因很简单——page fault 不是基础整数指令集天然就有的能力,它依赖更完整的体系结构环境:特权级、虚拟内存/MMU、页表/TLB、trap 机制,以及操作系统内核。一个直接访问物理内存的裸机处理器模型不会有 Linux 这种虚拟内存意义上的 page fault。
Kernel 里几个核心对象
这组对象是理解 kernel 的基本词汇表。
task 是执行单位。调度器关心的不是抽象课本里的”进程”或”线程”,而是 task。
mm 是一个进程的地址空间描述,对应核心结构 struct mm_struct。可以把它理解成:这个进程有哪些虚拟地址范围、每段权限是什么、哪些地方是代码段/堆/栈/mmap 区、页表根在哪。多个 task 共享同一个 mm 时,看起来就很像同一进程内的多个线程。
inode、dentry 和 file 是文件系统里最核心的一组对象。inode 是文件本身的元数据和身份,包括大小、权限、时间戳和数据块位置。dentry 是路径名到 inode 的目录项关系,更偏”名字解析”这一层。file 是一次 open() 得到的”打开实例”,包括当前偏移、打开标志和操作方法表。
举个例子:
int fd1 = open("a.txt", O_RDONLY);
int fd2 = open("a.txt", O_RDONLY);c通常意味着可能是同一个 inode,可能共享相关 dentry,但有两个不同的 file 打开实例——因为两个 fd 的偏移可以独立变化。
socket 和 sk_buff 是网络子系统里最核心的一组对象。sk_buff 是内核里表示网络数据包的结构,包含数据内容和各种元信息。它们是网络协议栈里数据流动的核心载体。至于 socket 是什么,你不妨可以看看我之前写的这篇文章。
这一层最值得记住的对象列表是 task、mm、page、inode、dentry、file、socket 和 sk_buff。学 kernel,本质上就是在不断回答两个问题:这个子系统的核心对象是什么?这些对象会在什么事件下发生状态变化?我们在接下来的章节里会反复围绕这些对象来展开,理解它们在不同子系统里的角色和交互。
线程、进程和 task
Linux 里”线程”和”进程”在内核层面没有高层语言里想象的那么不同。更底层的事实是:调度器调度的是 task。如果多个 task 共享 mm、files、signal handlers 等资源,用户态会把它们叫作线程;如果这些资源不共享,用户态通常把它们叫作独立进程。共享同一个 mm 就是在共享同一个虚拟地址空间,所以它更像线程。
用 read(fd, buf, n) 串起 syscall、VFS、文件和内存
read(fd, buf, n) 是一个特别好的串联例子。表面上只是一行调用,但从内核视角看,它在同时处理两件事:
- 找到
fd背后的内核对象,确定“数据从哪里来”; - 验证并访问
buf这段用户地址,确定“数据往哪里去”。
VFS 到底是什么
VFS(Virtual File System)不是某一种具体文件系统(比如 ext4、xfs),而是 Linux 内核里的一层统一抽象。
它的价值是:用户态只要调用统一接口(open/read/write/...),内核就能在 VFS 层把请求分发到不同后端实现(普通文件系统、pipe、socket、字符设备、块设备等),而不是让每种后端都暴露一套完全不同的 syscall 语义。
你可以把 VFS 理解成:
- 对上:提供统一的“文件语义”接口给 syscall;
- 对下:通过
struct file里的操作方法表,把请求交给具体实现。
所以我们常说“Linux 里很多东西都可以 file-like 地操作”,背后核心就是这层统一抽象。
read 路径
粗粒度路径先记成:
read(fd, buf, n)
-> syscall entry
-> fd table lookup
-> struct file
-> VFS
-> filesystem / pipe / socket / device implementation
-> page cache or lower I/O path
-> copy_to_user(buf)
-> return to user modetext展开后大概是这样:
- 用户态调用
read(fd, buf, n),通过 syscall 进入内核。 - 内核在当前 task 的 fd table 里查
fd,拿到对应struct file。 - 进入 VFS 读路径。VFS 根据
file的类型和操作方法,把请求转发给具体后端。 - 具体后端产出数据:
- 普通文件:可能先命中 page cache,未命中再走更底层 I/O;
- pipe/socket:从对应缓冲区或协议栈路径取数据;
- 设备文件:进入这个设备的具体文件读取实现。
- 内核把得到的数据通过
copy_to_user()拷贝到用户给的buf。 - 返回实际读取字节数,或者错误码。
read 不是只处理“文件”这么简单。它至少同时碰到两类对象:I/O 侧对象:fd -> file -> VFS -> 具体后端;以及内存侧对象:当前进程 mm 里的用户地址 buf。
所以内核必须做两套校验:
fd是否有效,是否可读,当前偏移和对象状态是否合理;buf是否是当前进程可访问且可写的用户地址。
为什么这里也会触发 page fault
很多人刚学时会误以为“page fault 只会在用户代码里 *p = ... 时发生”。其实在 read 里,内核执行 copy_to_user(buf) 时同样可能触发 page fault。
原因很直接:buf 对应的用户页可能尚未分配或尚未建立有效映射。此时 CPU 在内核代表用户访问该地址时发现条件不满足,就会进入缺页异常处理,补齐映射后再继续拷贝。
换句话说,page fault 不只会发生在用户代码直接访存时,也会发生在内核代表用户访问用户地址时。 内核也不会随心所欲地访问用户地址,它也可能会遇到”这个地址还没有准备好”的情况。
调度器
刚刚我们上面所提到的内容是 Linux kernel 的“事件处理器”部分,我们知道了 Linux 是如何处理 syscall、interrupt 和 exception 的。接下来我们要进入 Linux kernel 的另一个核心部分:资源管理器。我们刚在上面已经部分介绍了 Linux 是如何管理内存的,接下来我们要介绍 Linux 是如何管理 CPU 资源的,也就是调度器。
调度器最粗暴但很准确的定义是:它在决定”下一小段时间里,哪个 task 占哪个 CPU”。 所以调度不是静态分配,而是持续的动态决策。它要平衡的目标包括吞吐量、延迟、公平性、实时性、多核负载均衡、cache locality 和功耗——这些目标本身常常互相冲突。
Task 的粗粒度状态
我们先了解三个状态就够:running(正在跑)、runnable(可以跑但还没被选中)、blocked/sleeping(在等某个事件)。一个 task 发起磁盘 I/O 后从 running 变成 blocked,I/O 完成后被唤醒变成 runnable,被 scheduler 选中后重新变成 running。
等 I/O 时不一直占着 CPU 的原因很直接:磁盘 I/O 相对 CPU 指令执行是极高延迟的事件,原地忙等白白浪费 CPU。更合理的做法是 task 主动阻塞,让调度器去运行别的 runnable task,等设备完成后由中断和后续处理唤醒原 task。本质上就是:调度器把 CPU 时间从”当前没法继续推进的 task”手里拿走,交给”现在能推进的 task”。
每 CPU 一个 runqueue
Linux 倾向于每 CPU 一个本地 runqueue,而不是把所有 runnable task 都塞进全局唯一队列。直觉上可以把它想成“每个 CPU 维护自己的待运行队列”,这样大部分调度决策都能就近完成。
为什么这件事这么重要?核心有三点:
- 降低全局锁竞争:如果所有 CPU 都去抢一个全局队列,多核越多,锁争用越明显。
- 提高 cache locality:一个 task 如果持续在同一个 CPU 跑,CPU cache、TLB 和分支预测状态更容易复用。
- 让唤醒路径更本地化:很多时候一个 task 被某个 CPU 上的事件唤醒,优先放本地队列通常更快。
当然,“每 CPU 一个队列”不等于永远不跨 CPU。现实里负载并不平均,所以内核还需要做负载均衡:
- 某些 CPU 很忙,另一些 CPU 很闲时,会迁移一部分 runnable task;
- 唤醒一个 task 时,也可能根据策略把它放到更合适的目标 CPU;
- NUMA 和拓扑信息也会影响“迁不迁、迁到哪”。
所以更准确的总结是:默认本地化运行,必要时全局协调。
Context Switch
Context switch 的本质是保存当前 task 的执行现场、恢复下一个 task 的执行现场,让 CPU 从“继续执行 A”变成“继续执行 B”。
我们可以把它拆成两步:
- 保存 A 的现场:寄存器、栈相关状态、调度状态等;
- 恢复 B 的现场:把 B 上次停下时的现场重新装回 CPU。
切换完成后,CPU 看起来就像“从来都在执行 B”,这就是操作系统实现并发的关键机制之一。
为什么大家总说 context switch 有成本?因为它不只是“换个函数调用”。大家都写过 CPU, 我们知道程序的执行状态不仅仅是代码行号,还包括寄存器值、栈内容、内存映射、调度状态等。切换时需要保存和恢复这些状态,尤其是寄存器和地址空间相关状态,这些操作都需要 CPU 指令来完成,可能还会涉及 TLB shootdown 和 cache invalidation。
- 寄存器保存与恢复开销;
- 调度器路径和队列操作开销;
- cache/TLB 局部性被打断(尤其是跨 CPU 迁移时)。
如果切换到另一个 mm(也就是地址空间变了),额外成本通常更高,因为地址空间相关状态也要切换。反过来说,多个 task 共享同一个 mm(我们常说的线程)时,这部分代价通常会小一些。
这也是为什么“切换次数”经常是性能分析的重点指标:切换不是坏事,但过多、无效、抖动式切换会吞掉可观 CPU 时间。
调度视角下的线程和进程
从调度器视角看,最底层被调度的单位是 task,而不是“高级语言概念里的线程/进程标签”。 调度器真正关心的是这类信息:
- 这个 task 现在是 runnable 还是 blocked;
- 优先级/权重是多少;
- 放在哪个 CPU 的 runqueue;
- 最近跑了多久,是否该被抢占。
至于“它是线程还是进程”,在内核里更多是资源共享关系的差异:我们上面说了,如果多个 task 共享同一个 mm、files、signal handlers 等资源,用户态通常把它们叫线程;如果这些资源不共享,用户态通常把它们叫独立进程。这也解释了一个常见误区:并不是“线程切换”和“进程切换”有两套完全不同机制;而应该说,是同一套 task 切换机制,在共享资源多少不同的情况下,表现出不同开销特征。
CFS:普通任务的主力调度器
Linux 不只有一种调度类,但普通任务的主力调度器是 CFS(Completely Fair Scheduler)。
CFS 不是简单 round robin,而是尽量让 runnable 的普通任务公平分享 CPU 时间。这里的”公平”不是”每个人连续跑完全一样长的小片段”,而是长期来看,每个任务获得与其权重相称的 CPU 份额。我们利用 vruntime 这个核心概念来理解 CFS 的公平性。
vruntime 可以理解成调度器内部的公平账本。谁最近跑得多,账本涨得多;谁跑得少,涨得少。vruntime 越小,表示它在公平意义上越”亏” CPU,越应该先被运行。所以两个权重相同的 task,vruntime 更小的那个会被优先选择。
CPU-bound vs I/O-bound
这是一个很有用的二分法。CPU-bound 的 task 基本一直 runnable,持续吃 CPU;I/O-bound 的 task 经常睡眠,只有在事件到来时短暂运行。经常睡眠的 task 反而常常更容易被优先调度——不是因为调度器抽象地知道它是交互任务,而是因为它过去一段时间实际没怎么占 CPU,所以在公平账本上更”亏”,一旦被唤醒就更可能先跑。这也是为什么交互型任务往往感觉响应更快。
举个例子,大家都很熟悉的 shell,事实上是一个典型的 I/O-bound 任务。它大部分时间在等待用户从终端输入命令,只有在用户输入时才短暂地占用 CPU 来解析和执行命令。正因为它经常睡眠,所以一旦用户输入,它的 vruntime 就相对较小,更容易被调度器优先选择,从而尽管你后台在跑着其他 CPU-bound 的任务,shell 依然能快速响应用户的输入。
CFS 用红黑树
CFS 反复需要做三件事:插入 runnable task、删除阻塞 task、找到 vruntime 最小的 task。红黑树恰好适合:插入和删除都是 O(log n),最左边节点就是当前最该跑的 task。直觉上可以类比为 std::set<Task, CompareByVruntime>,当然真实实现是内核自己的红黑树。
调度类:CFS 不是全部
Linux 的调度框架里不只有 CFS。至少还有 stop、deadline、rt、fair(CFS)和 idle 这些调度类。不同调度类不是简单地放在一起按同一套 vruntime 竞争——RT 任务和普通 CFS 任务完全不在同一层竞争逻辑里,更高优先级调度类有活时,普通 task 要靠边。
结语
作为对于内核的第一个 intro, 我认为读完这篇文章应该能让大家或多或少有一个 Linux kernel 的全局视角,知道它的核心对象和机制是什么。接下来我们会在这个基础上,逐步展开讲解 Linux kernel 的各个子系统和工具链。
如果文章中你发现了错误,请务必指出来!