TheUnknownBlog

Back

前言

最近要开始学 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、定时器触发)、延后处理(例如 softirqworkqueue),以及内核线程(例如 kworkerkswapdksoftirqd)。

面对任何一段内核代码,最重要的问题往往不是”这个函数做什么”,而是:它现在处于什么上下文?能不能睡眠?持有什么锁?是不是中断上下文?是不是可以被抢占?这些问题经常比”功能是什么”还重要。

内核里”这个函数能不能睡眠”之所以关键,是因为 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
 -> 返回被打断的执行流(或随后触发调度)
text

interrupt 的关键特征是异步和上下文约束:

  • 它和当前正在跑的用户指令没有直接因果关系;
  • 它发生在 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 是什么,你不妨可以看看我之前写的这篇文章

这一层最值得记住的对象列表是 taskmmpageinodedentryfilesocketsk_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 mode
text

展开后大概是这样:

  1. 用户态调用 read(fd, buf, n),通过 syscall 进入内核。
  2. 内核在当前 task 的 fd table 里查 fd,拿到对应 struct file
  3. 进入 VFS 读路径。VFS 根据 file 的类型和操作方法,把请求转发给具体后端。
  4. 具体后端产出数据:
  • 普通文件:可能先命中 page cache,未命中再走更底层 I/O;
  • pipe/socket:从对应缓冲区或协议栈路径取数据;
  • 设备文件:进入这个设备的具体文件读取实现。
  1. 内核把得到的数据通过 copy_to_user() 拷贝到用户给的 buf
  2. 返回实际读取字节数,或者错误码。

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 维护自己的待运行队列”,这样大部分调度决策都能就近完成。

为什么这件事这么重要?核心有三点:

  1. 降低全局锁竞争:如果所有 CPU 都去抢一个全局队列,多核越多,锁争用越明显。
  2. 提高 cache locality:一个 task 如果持续在同一个 CPU 跑,CPU cache、TLB 和分支预测状态更容易复用。
  3. 让唤醒路径更本地化:很多时候一个 task 被某个 CPU 上的事件唤醒,优先放本地队列通常更快。

当然,“每 CPU 一个队列”不等于永远不跨 CPU。现实里负载并不平均,所以内核还需要做负载均衡:

  • 某些 CPU 很忙,另一些 CPU 很闲时,会迁移一部分 runnable task;
  • 唤醒一个 task 时,也可能根据策略把它放到更合适的目标 CPU;
  • NUMA 和拓扑信息也会影响“迁不迁、迁到哪”。

所以更准确的总结是:默认本地化运行,必要时全局协调。

Context Switch

Context switch 的本质是保存当前 task 的执行现场、恢复下一个 task 的执行现场,让 CPU 从“继续执行 A”变成“继续执行 B”。

我们可以把它拆成两步:

  1. 保存 A 的现场:寄存器、栈相关状态、调度状态等;
  2. 恢复 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。至少还有 stopdeadlinertfair(CFS)和 idle 这些调度类。不同调度类不是简单地放在一起按同一套 vruntime 竞争——RT 任务和普通 CFS 任务完全不在同一层竞争逻辑里,更高优先级调度类有活时,普通 task 要靠边。

结语

作为对于内核的第一个 intro, 我认为读完这篇文章应该能让大家或多或少有一个 Linux kernel 的全局视角,知道它的核心对象和机制是什么。接下来我们会在这个基础上,逐步展开讲解 Linux kernel 的各个子系统和工具链。

如果文章中你发现了错误,请务必指出来!

Learning Linux Kernel (Part 1)
https://20051110.xyz/blog/linux-kernel-1
Author TheUnknownThing
Published at March 25, 2026
Comment seems to stuck. Try to refresh?✨
浙ICP备2025146421号-1 浙公网安备33010502012185号