<?xml version="1.0" encoding="UTF-8"?><?xml-stylesheet href="/scripts/pretty-feed-v3.xsl" type="text/xsl"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:h="http://www.w3.org/TR/html4/"><channel><title>TheUnknownBlog</title><description>Stay Hungry, Stay Foolish</description><link>https://theunknownth.ing</link><item><title>Learning Linux Kernel (Part 6) - Device Model</title><link>https://theunknownth.ing/blog/linux-kernel-6</link><guid isPermaLink="true">https://theunknownth.ing/blog/linux-kernel-6</guid><description>In this chapter, we will explore the device model of the Linux kernel, including MMIO, interrupts, DMA, and IOMMU.</description><pubDate>Fri, 24 Apr 2026 22:21:00 GMT</pubDate><content:encoded>&lt;h2&gt;前言&lt;/h2&gt;
&lt;p&gt;前面我们已经从内存管理、虚拟内存、页表、调度器等方面对 Linux 内核有了一个初步的了解。现在我们来看看 Linux 内核里另一个非常重要的部分：&lt;strong&gt;设备模型（device model）&lt;/strong&gt;。设备模型是 Linux 内核里管理硬件设备和驱动的核心框架。它定义了设备、驱动和总线之间的关系，提供了 MMIO、interrupt、DMA、IOMMU 等机制，让驱动能够高效、安全地与硬件交互。&lt;/p&gt;
&lt;p&gt;如果你还没有阅读上一章节，建议先阅读 &lt;a href=&quot;/blog/linux-kernel-5&quot;&gt;Linux Kernel (Part 5) - Virtual Filesystem&lt;/a&gt;。&lt;/p&gt;
&lt;h2&gt;Disclaimer&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;我本次使用了 AI 来带我学习 Linux 内核。所以无论我后续怎么整理润色，本文的 first draft 肯定是 AI 告诉我的内容&lt;/strong&gt;。如果你对此感到无法接受，或者觉得 AI 讲得不够好，你可以随时退出阅读，或者自己去看 Linux 内核的源码和文档。我的目标是通过 AI 来帮我梳理和总结内核的知识体系，而不是替代你自己去接触原始资料。&lt;/p&gt;
&lt;p&gt;如果你能接受这个前提，那么我们就继续往下走了。&lt;/p&gt;
&lt;h2&gt;为什么需要设备模型&lt;/h2&gt;
&lt;p&gt;一块网卡插进系统，内核需要回答的问题远不止“它能不能用”：它挂在哪条总线上（PCIe？USB？platform bus）？哪个驱动能管理它？它的寄存器在哪里，用哪个中断，能不能 DMA？它应该暴露成什么用户接口——&lt;code&gt;eth0&lt;/code&gt;、&lt;code&gt;/dev/xxx&lt;/code&gt;，还是一个 block device？suspend/resume 时该怎么处理？&lt;/p&gt;
&lt;p&gt;这就是为什么驱动不是简单地调几个 &lt;code&gt;write_register()&lt;/code&gt; 就能了事的。驱动必须嵌入 Linux 的设备模型，围绕三个核心对象展开：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;struct device;         // 一个具体设备实例
struct device_driver;  // 能管理某类设备的驱动
struct bus_type;       // 一类总线/匹配规则
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;骨架层面，这三个结构体的核心字段大致如下：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;struct device {
    const char         *name;
    struct bus_type    *bus;
    struct device_driver *driver;
    struct device      *parent;
    void               *driver_data;
};

struct device_driver {
    const char   *name;
    struct bus_type *bus;
    int  (*probe)(struct device *dev);
    void (*remove)(struct device *dev);
};

struct bus_type {
    const char *name;
    int (*match)(struct device *dev, struct device_driver *drv);
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;真实内核结构体更复杂，但骨架就是这个。三者的关系用一句话说就是：&lt;strong&gt;bus 负责把设备和驱动配对，配对成功后调用驱动的 &lt;code&gt;probe()&lt;/code&gt; 函数，让驱动接管该设备。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;Bus 是什么&lt;/h2&gt;
&lt;p&gt;在 Linux 里，“bus”不仅仅是一根物理总线，更是一套匹配和管理规则。常见的有：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;PCI bus&lt;/strong&gt;：设备从 PCI config space 枚举出来，内核自动发现；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;USB bus&lt;/strong&gt;：支持热插拔，设备带有 vendor/product id；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;platform bus&lt;/strong&gt;：主要用于 SoC/ARM/RISC-V 上那些不能自己枚举的板载设备，依赖 ACPI 或 Device Tree 描述；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;virtual bus&lt;/strong&gt;：有些“设备”本身只是内核抽象出来的逻辑对象。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Bus 最核心的工作是调用 &lt;code&gt;bus-&gt;match(device, driver)&lt;/code&gt;。一旦匹配成功，内核便调用 &lt;code&gt;driver-&gt;probe(device)&lt;/code&gt;，这就是驱动开发中无处不在的 &lt;code&gt;probe()&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;每种设备最终对用户呈现的接口，取决于它注册到了哪个内核子系统：&lt;/p&gt;
&lt;p&gt;| 设备类型 | kernel subsystem | 用户看到 |
|---|---|---|
| 网卡 | netdev | ip link, eth0, socket |
| 磁盘 | block layer | /dev/sda, filesystem |
| 键盘鼠标 | input subsystem | /dev/input/eventX |
| 串口 | tty | /dev/ttyS0 |
| 简单字符设备 | char device | /dev/mydev |
| GPU | DRM | /dev/dri/card0 |&lt;/p&gt;
&lt;h2&gt;Probe() 到底是什么&lt;/h2&gt;
&lt;p&gt;很多初学者会误解 &lt;code&gt;probe()&lt;/code&gt; 是“探测有没有这个硬件”。更准确的理解是：内核已经有了一个 device 对象，现在发现某个 driver 声称能处理它，于是调用这个 driver 的 probe，让它初始化并接管该设备。&lt;/p&gt;
&lt;p&gt;一个典型的 &lt;code&gt;probe()&lt;/code&gt; 大致做这些事：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;static int my_probe(struct device *dev)
{
    // 1. 分配驱动自己的私有状态
    struct my_dev *priv = kzalloc(sizeof(*priv), GFP_KERNEL);

    // 2. 找到硬件资源：寄存器地址、中断号、DMA 能力
    // 3. ioremap MMIO 寄存器
    // 4. request_irq() 申请中断
    // 5. 初始化 DMA buffer / ring buffer
    // 6. 注册到某个子系统：net/block/input/char/tty...
    // 7. 保存私有数据
    dev_set_drvdata(dev, priv);
    return 0;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里有一个重要认知：&lt;code&gt;probe()&lt;/code&gt; 不是用户程序调用的，也不是硬件直接触发的，而是 device model 在 driver 和 device 匹配成功后主动调用的。以 PCI 网卡为例，整条链是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;PCI device
  -&gt; PCI driver probe()
     -&gt; 初始化硬件
     -&gt; 注册 struct net_device
     -&gt; 用户通过 socket 使用它
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;设备是怎么“出现”的&lt;/h2&gt;
&lt;p&gt;设备对象的来源大致分两类。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;可枚举的总线&lt;/strong&gt;（如 PCI/PCIe、USB）：内核可以主动扫描总线，读取 vendor id / device id，据此创建 &lt;code&gt;struct device&lt;/code&gt;，再去匹配 driver。整个流程是：先有硬件被枚举出来，再有 kernel 中的设备对象，再去匹配驱动。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;不能自枚举的设备&lt;/strong&gt;：很多 ARM/RISC-V SoC 上的 UART、GPIO、I2C controller、SPI controller 不挂在 PCI 上，没有自我报告的能力。内核的解决方案是依靠硬件描述——x86 通常用 ACPI，ARM/RISC-V 通常用 Device Tree。这些描述告诉内核：“这里有个 UART，寄存器基址是 XXX，中断号是 YYY。”内核据此创建 platform device，再 match platform driver，再调 probe()。&lt;/p&gt;
&lt;p&gt;如果用一张图来总结设备的“出生”过程：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Device Tree / PCIe 枚举
  → bus match → probe()
  → MMIO（ioremap → readl/writel）     ← CPU 给设备下命令
  → DMA API（分配、映射、barrier）      ← 设备高效搬运数据
  → interrupt / IRQ handler             ← 设备异步通知 CPU
  → bottom half / NAPI / workqueue      ← 延后的重活
  → subsystem 注册（net_device 等）     ← 用户态可见接口
  → scheduler: wakeup → runnable → running
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;MMIO&lt;/h2&gt;
&lt;h3&gt;Memory-Mapped I/O 的本质&lt;/h3&gt;
&lt;p&gt;MMIO 的核心思想是：设备寄存器不是普通 RAM，但 CPU 用“像访问内存一样的 load/store 指令”去访问它。这就是为什么驱动里会出现这样的代码：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;val = readl(dev-&gt;mmio + REG_STATUS);
writel(cmd, dev-&gt;mmio + REG_CMD);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;看起来像在读写内存，实际上是在和设备寄存器对话。&lt;/p&gt;
&lt;p&gt;CPU 在发出一次地址访问时，并不知道这个地址后面一定是 DRAM。总线/互联会把这个地址路由到对应的目标——可能是普通内存、PCIe BAR、SoC 外设寄存器、ROM、framebuffer 等等。从 CPU 视角，访问地址 &lt;code&gt;0xffff000012340000&lt;/code&gt; 也许指向的不是“内存条里的某个字节”，而是 UART controller 的 status 寄存器、网卡的 TX doorbell，或 I2C controller 的 control 寄存器。这就叫 memory-mapped I/O。&lt;/p&gt;
&lt;h3&gt;MMIO 与普通内存的根本区别&lt;/h3&gt;
&lt;p&gt;虽然都“长得像地址”，但语义完全不同。&lt;/p&gt;
&lt;p&gt;普通内存可以 cache、prefetch、合并写，读写基本没有副作用——读出来的就是之前存进去的数据。MMIO 寄存器则完全不同：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;读一次可能是“读设备当前状态”&lt;/li&gt;
&lt;li&gt;写一次可能会“启动 DMA、清中断或发命令”&lt;/li&gt;
&lt;li&gt;不能随便 cache，不能随便乱序&lt;/li&gt;
&lt;li&gt;有些寄存器的读或写本身就带有副作用&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;例如 &lt;code&gt;writel(1, mmio + DOORBELL)&lt;/code&gt; 不是“把数字 1 存起来”，而是“告诉网卡：去处理 TX ring”。因此 MMIO 本质上是&lt;strong&gt;地址形式的设备命令接口&lt;/strong&gt;——你每一次读写，都是在和设备对话，而不是在操作一块普通内存。&lt;/p&gt;
&lt;h3&gt;为什么用 readl/writel&lt;/h3&gt;
&lt;p&gt;内核不鼓励用 &lt;code&gt;*(u32 *)(base + REG)&lt;/code&gt; 这种写法，而是统一用 &lt;code&gt;readl()&lt;/code&gt;/&lt;code&gt;writel()&lt;/code&gt;，原因有三：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;可移植性&lt;/strong&gt;：不同架构对设备内存的访问规则不同，&lt;code&gt;readl/writel&lt;/code&gt; 封装了这些差异；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;顺序保证&lt;/strong&gt;：设备寄存器访问常常需要特定顺序，普通 C 指针访问可能被编译器或 CPU 以你不希望的方式优化，而 &lt;code&gt;readl/writel&lt;/code&gt; 通常带有合适的 barrier 语义；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;宽度与总线语义&lt;/strong&gt;：某些设备要求 32 位对齐访问，不能拆成 4 个 byte，专用 accessor 语义更明确。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;code&gt;void __iomem *base&lt;/code&gt; 中的 &lt;code&gt;__iomem&lt;/code&gt; 是给静态检查工具（sparse）用的注解，意思是这是 I/O memory 指针，不是普通内存指针——不要拿它去 memcpy，不要随便解引用，应该配套 &lt;code&gt;readl/writel&lt;/code&gt; 使用。这属于内核“类型约束靠约定加工具辅助”的典型体现。&lt;/p&gt;
&lt;h3&gt;寄存器地址从哪来&lt;/h3&gt;
&lt;p&gt;对 platform device，通常来自 Device Tree 或 ACPI 里的 &lt;code&gt;reg&lt;/code&gt; 属性：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dts&quot;&gt;uart@10000000 {
    reg = &amp;#x3C;0x0 0x10000000 0x0 0x1000&gt;;
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;上面表示这个设备有一段寄存器窗口，物理地址范围是 &lt;code&gt;[0x10000000, 0x10000fff]&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;对 PCI 设备，则来自 BAR（Base Address Register）——设备向系统声明自己需要一段 MMIO 空间，内核在枚举时为其分配地址，驱动再拿到这个 BAR。&lt;/p&gt;
&lt;h3&gt;ioremap 做了什么&lt;/h3&gt;
&lt;p&gt;拿到设备的物理地址后，内核代码不能直接把它当指针用，需要先将这段物理设备地址映射到内核虚拟地址空间：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;base = ioremap(0x10000000, 0x1000);
// 或者更常见的写法：
base = devm_ioremap_resource(&amp;#x26;pdev-&gt;dev, res);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;得到的 &lt;code&gt;base&lt;/code&gt; 是一个内核虚拟地址，但它经过页表映射指向的不是 DRAM，而是设备寄存器。整条链是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Device Tree / PCI BAR
  → resource（物理地址范围）
  → ioremap
  → kernel virtual address
  → readl/writel 访问
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这和我们前面讲页表/虚拟内存的知识是连起来的：CPU 最终仍然在访问一个虚拟地址，只不过这段虚拟地址被页表映射到了“设备寄存器物理地址”，而不是 DRAM。&lt;/p&gt;
&lt;h3&gt;寄存器偏移是哪来的&lt;/h3&gt;
&lt;p&gt;你经常会在驱动里看到这样的宏定义：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;#define REG_DESC_BASE  0x1000
#define REG_DOORBELL   0x1008
#define REG_STATUS     0x1010
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这些不是 Linux 统一规定的名字，而是某个具体硬件设备手册里定义的一组寄存器偏移，驱动作者把它们写成宏。比如某块网卡手册可能规定：offset 0x1000 是描述符环基址寄存器，offset 0x1008 是 doorbell 寄存器。于是：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;writel(desc_addr, mmio + REG_DESC_BASE);  // 告诉设备描述符 ring 在哪
writel(kick,      mmio + REG_DOORBELL);   // 按门铃，通知设备开始处理
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;REG_DESC_BASE&lt;/code&gt; 和 &lt;code&gt;REG_DOORBELL&lt;/code&gt; 里的 offset 来自硬件规格书，不是 Linux 随便发明的。驱动开发者的工作，就是把硬件手册里的这些定义翻译成内核可以管理的对象。&lt;/p&gt;
&lt;h3&gt;MMIO 访问的顺序问题&lt;/h3&gt;
&lt;p&gt;MMIO 里有一个驱动新手很容易踩坑的地方：写操作的顺序问题。&lt;/p&gt;
&lt;p&gt;比如你写了：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;writel(desc_addr, mmio + REG_DESC_BASE);
writel(kick,      mmio + REG_DOORBELL);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;语义上是：先告诉设备描述符在哪，再按门铃让它开始。但真实硬件世界里，可能出现：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;CPU 的 store buffer 让写操作延迟可见&lt;/li&gt;
&lt;li&gt;总线/桥把写操作 posted 了&lt;/li&gt;
&lt;li&gt;某些架构上普通内存写和 MMIO 写顺序不一致&lt;/li&gt;
&lt;li&gt;DMA 描述符还没对设备可见，你就先 doorbell 了&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以驱动常需要显式使用 barrier：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;/* 先把 ring/descriptor 写到内存 */
...
dma_wmb();
/* 再通知设备 */
writel(kick, mmio + DOORBELL);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;dma_wmb()&lt;/code&gt; 保证前面对描述符的写入已对设备可见，然后才按门铃。这不是“为了编译器好看”，而是为了 CPU 和设备之间的时序正确。&lt;/p&gt;
&lt;h2&gt;MMIO 与中断&lt;/h2&gt;
&lt;p&gt;MMIO 和中断经常成对出现，不是巧合，而是因为它们分别承担了两个方向的通信：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;MMIO&lt;/strong&gt;：CPU → device（发命令，像“我给你发任务”）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;interrupt&lt;/strong&gt;：device → CPU（报结果，像“我做完了，来处理一下”）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果没有中断，驱动就得 busy wait：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;writel(START, base + REG_CMD);
while (!(readl(base + REG_STATUS) &amp;#x26; DONE))
    ;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这对慢速设备（磁盘、网卡、触摸屏）极其低效：浪费 CPU，延迟高，无法并发处理别的事。典型的异步模式是：CPU 通过 MMIO 写寄存器下命令，返回去干别的；设备完成后发中断；驱动在中断 handler 里用 MMIO 读状态、取结果、清中断：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;writel(START, base + REG_CMD);
/* 返回去干别的 */
...

/* 设备完成后发 interrupt */
irq_handler() {
    stat = readl(base + REG_STATUS);
    ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以 MMIO 很像“命令（control plane）”，interrupt 很像“事件通知（event plane）”。你会发现，中断处理最后又回到了 MMIO——这正是“MMIO 和 interrupt 经常绑在一起”的根本原因。&lt;/p&gt;
&lt;h2&gt;Interrupt&lt;/h2&gt;
&lt;p&gt;上面我们提过，设备通过 interrupt 向 CPU 报告事件。Linux 里对 interrupt 的抽象叫 IRQ（Interrupt Request）。&lt;/p&gt;
&lt;h3&gt;IRQ 是什么&lt;/h3&gt;
&lt;p&gt;IRQ 原始上是 Interrupt Request，但在 Linux 语境里它有三层含义：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;最朴素的意思&lt;/strong&gt;：一次中断请求本身，比如网卡做完收包，向 CPU 这边发出“请处理我”的通知；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;硬件视角&lt;/strong&gt;：某条中断源/中断线，例如某个 GPIO 中断输入、PCI MSI vector 等；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Linux 视角&lt;/strong&gt;：内核分配并管理的一个虚拟编号（virq）。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;驱动平时接触的是第三层——内核 IRQ 号（virq），而不是硬件中断号（hwirq）。两者之间通过 &lt;strong&gt;irq domain&lt;/strong&gt; 做映射解耦：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;硬件中断源（如 GIC line 57）
  → irq domain 映射
  → Linux IRQ number（virq）
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这对 Device Tree/ACPI 和级联中断控制器非常关键。不同平台的中断硬件长得完全不一样——x86 常见 APIC，ARM/RISC-V 常见 GIC/PLIC，PCI 设备可能用 MSI/MSI-X。Linux 不想让每个驱动都直接面对这些细节，所以它做了抽象：驱动只需要知道“我有一个 IRQ 资源，注册 handler 即可”，底层到底是 GIC 第几号、MSI 第几向量，由 IRQ 子系统处理。&lt;/p&gt;
&lt;h3&gt;一次中断的完整控制流&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;设备内部事件发生
  → 设备向 interrupt controller 报告
  → controller 决定把中断送给某个 CPU
  → CPU 暂停当前执行流，进入中断入口
  → 内核保存现场，进入 generic IRQ 层
  → generic IRQ 层找到 Linux IRQ 对应的 handler
  → 调用驱动 handler
  → handler 快速处理、确认、清中断
  → 如有重活，推迟到 bottom half / threaded irq
  → 从中断返回，可能触发调度
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里最关键的认知：中断不是“新开了一个 task”，而是当前 CPU 上的执行流被硬件异步打断了。这和 syscall 很像，都是 trap 进内核，但来源不同——syscall 是软件主动请求，中断是外部异步事件。&lt;/p&gt;
&lt;p&gt;这也带来两个天然约束：现场必须保存好，否则原来被打断的代码回不去；中断现场不能拖太久，否则整个 CPU 都被你卡住。&lt;/p&gt;
&lt;h3&gt;为什么驱动还要自己读状态寄存器&lt;/h3&gt;
&lt;p&gt;你可能会想：既然 Linux 已经知道是 IRQ 42 了，为什么驱动还要 &lt;code&gt;readl(REG_INT_STAT)&lt;/code&gt;？&lt;/p&gt;
&lt;p&gt;因为 IRQ 42 往往只说明“这个设备有事了”，但不一定说明是 RX 完成、TX 完成、DMA error 还是 link change——这些通常要靠设备自己的状态寄存器判断。所以中断处理经常是两层判断：第一层是系统层（哪个 IRQ 来了？），第二层是设备层（这个设备内部到底哪件事发生了？）。这就是为什么 handler 里经常第一句就是 &lt;code&gt;stat = readl(base + REG_INT_STAT)&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;上半部与下半部&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;上半部（top half）&lt;/strong&gt; 就是 &lt;code&gt;request_irq()&lt;/code&gt; 注册的那个函数，它的职责是：确认是不是本设备的中断、读状态寄存器、清中断/mask 中断、把少量关键数据摘出来、安排后续处理。例如：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;irqreturn_t my_irq_handler(int irq, void *data)
{
    u32 stat = readl(base + REG_INT_STAT);
    if (!(stat &amp;#x26; MY_INT))
        return IRQ_NONE;
    writel(stat, base + REG_INT_STAT);    /* ack/clear */
    schedule_work(&amp;#x26;priv-&gt;work);           /* 或 raise softirq / NAPI */
    return IRQ_HANDLED;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;下半部（bottom half）&lt;/strong&gt; 负责真正的重活，主要机制有：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;softirq&lt;/strong&gt;：偏底层、偏性能导向，仍然不能随便睡眠；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;workqueue&lt;/strong&gt;：在内核线程上下文中执行，可以睡眠，适合逻辑比较重的场景；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;threaded irq&lt;/strong&gt;：把中断处理的主体放在一个内核线程里执行，代码更像普通内核路径，对某些慢速外设（挂在 I2C/GPIO 上的设备）很友好，但延迟比纯 hardirq 大；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;NAPI&lt;/strong&gt;：网络收包的高效批处理机制，低流量时靠 interrupt 提醒，高流量时切换到 polling 批量收包。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;中断 handler 里不能随便睡眠，因为这段代码是“插队进来”执行的，不能像普通进程那样阻塞——否则被打断的那段代码就永远回不来了。&lt;/p&gt;
&lt;h3&gt;为什么一定要 ack/clear 中断&lt;/h3&gt;
&lt;p&gt;仅仅是 handler 被调用，并不代表中断就自动消停。很多设备内部有 pending bit，不手动清掉就会一直认为“我还在中断中”，导致 CPU 一返回就被重新打断，产生中断风暴，CPU 被打满。&lt;/p&gt;
&lt;p&gt;清中断的方式由设备手册规定，不是 Linux 统一规定，常见的语义有：write-1-to-clear、read-to-clear、写某个专门 ACK 寄存器、先 mask 处理完再 unmask。所以驱动里常见的 bug 之一就是：状态寄存器读了，但清中断的顺序错了或者根本没清干净。&lt;/p&gt;
&lt;h3&gt;共享中断&lt;/h3&gt;
&lt;p&gt;有些系统里一个 IRQ 线上可能挂了多个设备。这时内核会把多个 handler 都挂在这个 IRQ 上，某次中断来时每个 handler 都可能被问一遍：“这次是不是你家的事？”所以共享中断 handler 里很常见：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;stat = readl(dev-&gt;base + REG_INT_STATUS);
if (!(stat &amp;#x26; EXPECTED))
    return IRQ_NONE;
/* 是我的，处理完返回 IRQ_HANDLED */
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这也解释了为什么“IRQ 到了”不等于“驱动已经知道发生了什么”——IRQ 只是一个粗粒度入口，具体语义还得设备寄存器自己说。&lt;/p&gt;
&lt;h3&gt;中断与调度器的连接&lt;/h3&gt;
&lt;p&gt;当 interrupt 到来，driver 处理完成后，如果某个正在等待这个 I/O 事件的 task 可以醒来，它会被设为 runnable，然后交给调度器决定何时真正运行。完整链条是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;用户 task 发起 I/O，然后睡眠
  → 设备完成 I/O，发 interrupt
  → IRQ handler / bottom half 标记 I/O 完成
  → wake_up(waitqueue)
  → task 变成 runnable
  → 调度器决定什么时候让它真正 running
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;也就是说，设备 I/O 完成并不意味着等待进程&lt;strong&gt;立刻&lt;/strong&gt;恢复执行，而是先 wakeup，再走调度流程。这把我们前面学过的调度器也连了进来。&lt;/p&gt;
&lt;h2&gt;DMA&lt;/h2&gt;
&lt;p&gt;DMA（Direct Memory Access）是设备直接访问内存的能力。它让设备可以绕过 CPU，直接读写内存数据，极大提升了性能和效率。&lt;/p&gt;
&lt;h3&gt;为什么需要 DMA&lt;/h3&gt;
&lt;p&gt;如果没有 DMA，CPU 要亲自搬运每一个字节：磁盘读 1MB，CPU 得反复从设备寄存器读再写内存；网卡收包，CPU 得一字节一字节取。对 GPU、NVMe、高速网卡这样的高性能设备，这种做法根本不现实。&lt;/p&gt;
&lt;p&gt;DMA（Direct Memory Access）让设备在合适条件下直接读写内存，CPU 只负责“安排”和“通知”，不亲自搬运数据。驱动的典型分工是：&lt;strong&gt;CPU 控制，设备搬运，interrupt 通知完成&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;以网卡发包为例，有了 DMA 之后：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;驱动在内存里准备 packet buffer 和 descriptor&lt;/li&gt;
&lt;li&gt;descriptor 里写好 buffer 地址、长度、flags&lt;/li&gt;
&lt;li&gt;&lt;code&gt;writel(desc_base, REG_DESC_BASE)&lt;/code&gt; 告诉设备“工作单放哪”&lt;/li&gt;
&lt;li&gt;&lt;code&gt;writel(kick, REG_DOORBELL)&lt;/code&gt; 告诉设备“去看工作单”&lt;/li&gt;
&lt;li&gt;网卡自己 DMA 读 descriptor 和 packet data，发出去&lt;/li&gt;
&lt;li&gt;发包完成，interrupt 通知 CPU&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这就把前面讲的 &lt;code&gt;REG_DESC_BASE&lt;/code&gt; 和 &lt;code&gt;DOORBELL&lt;/code&gt; 完整串起来了。&lt;/p&gt;
&lt;h3&gt;虚拟地址、物理地址、DMA 地址&lt;/h3&gt;
&lt;p&gt;DMA 里最容易混淆的是地址。驱动里至少要分清三类：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;CPU 虚拟地址&lt;/strong&gt;：内核代码里看到的指针，如 &lt;code&gt;buf&lt;/code&gt;、&lt;code&gt;priv-&gt;ring&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;物理地址&lt;/strong&gt;：真实物理内存地址；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DMA 地址&lt;/strong&gt;：设备眼里要用的地址。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;三者不一定相等。中间可能隔着 IOMMU 的地址重映射、bounce buffer、cache 一致性约束。这就是为什么&lt;strong&gt;驱动不能直接把 &lt;code&gt;kmalloc()&lt;/code&gt; 返回的指针写进设备寄存器&lt;/strong&gt;——设备不走 CPU 页表，它需要经过 DMA 映射后的地址：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;void *buf = kmalloc(4096, GFP_KERNEL);
// 错误！不能直接写给设备
writel((u64)buf, dev-&gt;base + REG_BUF_ADDR);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;正确做法是使用 DMA API，让它帮你把一块 CPU 可用内存变成设备也能访问的 DMA 地址。&lt;/p&gt;
&lt;h3&gt;Linux DMA API 的两大类&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Coherent DMA&lt;/strong&gt;：&lt;code&gt;dma_alloc_coherent()&lt;/code&gt; 同时给你一个 CPU 用的地址和设备用的 DMA handle，两者指向同一块内存：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;cpu_addr = dma_alloc_coherent(dev, size, &amp;#x26;dma_handle, GFP_KERNEL);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;比较适合 descriptor ring、completion queue、device control block 这类 CPU 和设备都会频繁读写的长期共享结构。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Streaming DMA&lt;/strong&gt;：&lt;code&gt;dma_map_single()&lt;/code&gt; / &lt;code&gt;dma_unmap_single()&lt;/code&gt;，把一块已有内存临时映射给设备做一次 DMA，用完再 unmap：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;dma_addr = dma_map_single(dev, buf, len, DMA_TO_DEVICE);
// ... DMA 完成 ...
dma_unmap_single(dev, dma_addr, len, DMA_TO_DEVICE);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;更适合 packet buffer、一次性 I/O payload 这类临时场景。高性能驱动常见的组合是：ring/queue 用 coherent，数据 payload 用 streaming。&lt;/p&gt;
&lt;h3&gt;Cache 一致性与 Barrier&lt;/h3&gt;
&lt;p&gt;CPU 有 cache、store buffer 和乱序执行；设备的 DMA 可能绕过 CPU cache 直接读写内存。如果不做同步，可能出现 CPU 以为 descriptor 已写好但设备还看不到，或设备已写完数据但 CPU 还在 cache 里看到旧内容等问题。&lt;/p&gt;
&lt;p&gt;这就是为什么在 doorbell 前需要 &lt;code&gt;dma_wmb()&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;desc-&gt;addr  = dma_addr;
desc-&gt;len   = len;
desc-&gt;flags = OWNED_BY_DEV;
dma_wmb();                               // 保证 descriptor 对设备可见
writel(1, dev-&gt;base + REG_DOORBELL);     // 再按门铃
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果省略 barrier，设备可能在 descriptor 还没写完时就去读，导致极其难复现的数据错误。这个 bug 很真实——不是假设性的。&lt;/p&gt;
&lt;h3&gt;Bounce Buffer&lt;/h3&gt;
&lt;p&gt;有些设备有 DMA 限制，比如只能访问低 32-bit 地址，或者对齐要求特殊。这时内核可能引入 bounce buffer——一块设备够得着的中间缓冲区：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;发包时先把数据拷到 bounce buffer，再让设备 DMA；&lt;/li&gt;
&lt;li&gt;收包时设备 DMA 到 bounce buffer，再拷回原 buffer。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这显然有额外的拷贝开销，但能兼容受限设备。有了 IOMMU 之后情况会好很多，因为地址重映射更灵活，但并非绝对不需要 bounce buffer，仍然取决于设备能力和平台实现。&lt;/p&gt;
&lt;h2&gt;IOMMU：给设备做地址翻译和隔离&lt;/h2&gt;
&lt;p&gt;如果你还记得的话，我在去年这个时候写过一篇文章 &lt;a href=&quot;/blog/gpu-passthrough&quot;&gt;Enabling KVM GPU Passthrough&lt;/a&gt; 讲过 IOMMU 的概念和它在 GPU 直通里的作用。现在我们来更系统地看看 IOMMU 是什么、为什么需要它、它解决了哪些问题，以及它的代价。&lt;/p&gt;
&lt;h3&gt;类比 CPU 的 MMU&lt;/h3&gt;
&lt;p&gt;CPU 访问内存时，虚拟地址经过 MMU/页表翻译到物理地址，进程因此互相隔离。对设备做 DMA 时，若没有 IOMMU，设备拿到地址后可能直接碰物理内存——坏了或写错的驱动可能 DMA 到不该写的地方，虚拟化环境更难以安全隔离。&lt;/p&gt;
&lt;p&gt;有了 IOMMU，流程变成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;device DMA address (IOVA)
  → IOMMU page table
  → physical memory
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;IOVA（I/O Virtual Address）是设备眼里的地址，IOMMU 负责翻译到物理页。你可以把它类比成：CPU 用虚拟地址，设备用 IOVA，IOMMU 负责把 IOVA 翻译到物理页。&lt;/p&gt;
&lt;h3&gt;IOMMU 解决的三个问题&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;安全隔离&lt;/strong&gt;：IOMMU 允许内核规定“这个设备只能 DMA 到这几页”，即使驱动有 bug，破坏面也会大大减小。这在现代系统里很重要，尤其是外设复杂、PCIe 设备强大、有安全要求的场景。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;虚拟化与设备直通&lt;/strong&gt;：这正是你在玩 QEMU 显卡直通时反复看到它的原因。Guest 里的驱动会让 GPU 去 DMA “guest 物理地址”，但这些地址对 host 来说并非真实物理地址，需要 host 的 IOMMU 帮忙做映射，把 GPU 的 DMA 限制在这台 VM 分配到的内存范围内。没有 IOMMU，安全可靠的 PCI passthrough 基本无法实现——否则 guest 里的设备可以乱 DMA 到 host 甚至别的 VM 的内存。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;地址空间灵活性&lt;/strong&gt;：IOMMU 可以把一串不连续的物理页映射成设备眼里连续的 IOVA，减少对物理连续内存的苛刻要求，方便 scatter-gather，更方便内存管理。&lt;/p&gt;
&lt;h3&gt;BIOS 里会有这个选项&lt;/h3&gt;
&lt;p&gt;因为 IOMMU 是硬件能力，不只是操作系统软件功能。在 BIOS 里打开它，系统/内核才能真正使用 DMA remapping 这些功能。不同平台名字不同：Intel 叫 VT-d，AMD 叫 AMD-Vi 或 AMD IOMMU。注意不要和 CPU 虚拟化（VT-x / SVM）混淆——VT-x 是 CPU 级别的虚拟化，VT-d 是设备 DMA 隔离与直通。&lt;/p&gt;
&lt;h3&gt;IOMMU Group&lt;/h3&gt;
&lt;p&gt;现实中设备的隔离边界不一定是单个 PCI 设备。几个设备可能在 PCIe 拓扑上共享上游资源，IOMMU 没法把它们完全隔开，于是 Linux 把它们放进同一个 IOMMU group。这就是做显卡直通时“这个 GPU 和它的 HDMI audio 在同一个 group”的原因——同一个 group 只能作为整体被安全隔离，group 不干净，就很难安全直通。&lt;/p&gt;
&lt;h3&gt;IOMMU 的代价&lt;/h3&gt;
&lt;p&gt;IOMMU 不是白来的，也有成本：DMA 访问要多过一层地址翻译（虽然 IOMMU 也有自己的 TLB 级别缓存，但仍有开销）；映射、解除映射、失效、同步的管理更复杂；做 VFIO、SR-IOV、PASID、ATS 这些高级特性时，复杂度会上来很多。所以有时候 BIOS 里有人会关掉 IOMMU 追求某些极端性能，但那会牺牲隔离和直通能力。&lt;/p&gt;
&lt;h2&gt;Platform Device / Device Tree&lt;/h2&gt;
&lt;p&gt;上面我们提到，对于 PCI/USB 这类可枚举总线，内核可以自己扫描枚举出设备对象；但对于 ARM/RISC-V 上的板载设备，内核需要外部描述来告诉它“这里有个设备，寄存器地址是 XXX，中断号是 YYY”。Device Tree 就是这个描述的格式和机制。&lt;/p&gt;
&lt;h3&gt;Device Tree 提供了什么&lt;/h3&gt;
&lt;p&gt;对于一个板载设备，kernel 至少需要知道：寄存器基址（MMIO reg）、中断号（interrupts）、时钟（clocks）、reset 线（resets）、GPIO、电源（regulators）、pin multiplexing（pinctrl），有时还有 DMA 通道和 endpoint 拓扑。&lt;/p&gt;
&lt;p&gt;一个典型的 Device Tree 节点：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dts&quot;&gt;i2c2: i2c@ff150000 {
    compatible = “vendor,my-i2c”;
    reg = &amp;#x3C;0x0 0xff150000 0x0 0x1000&gt;;
    interrupts = &amp;#x3C;42&gt;;
    clocks = &amp;#x3C;&amp;#x26;cru 17&gt;;
    status = “okay”;
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;驱动的 &lt;code&gt;probe()&lt;/code&gt; 本质上就是把这些“描述”翻译成内核里真正可用的资源对象：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;static int my_i2c_probe(struct platform_device *pdev)
{
    struct resource *res;
    void __iomem *base;
    int irq;

    res  = platform_get_resource(pdev, IORESOURCE_MEM, 0);
    base = devm_ioremap_resource(&amp;#x26;pdev-&gt;dev, res);
    irq  = platform_get_irq(pdev, 0);
    devm_request_irq(&amp;#x26;pdev-&gt;dev, irq, my_irq_handler, 0, “my-i2c”, data);
    return 0;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果 DT 配错了，常见结果不是“驱动代码有 bug”，而是：根本没创建设备对象、驱动没 match 上、probe 缺资源失败、时钟/regulator/pinctrl 没准备好。这条依赖链里一环错，整个设备就可能完全不工作。&lt;/p&gt;
&lt;h3&gt;Compatible 是 Device Tree 里的匹配键&lt;/h3&gt;
&lt;p&gt;DTS 里的 &lt;code&gt;compatible = “goodix,gt911”&lt;/code&gt; 和驱动里的 of_device_id 表里的同名字符串是配对关系：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;static const struct of_device_id goodix_of_match[] = {
    { .compatible = “goodix,gt911” },
    { }
};
MODULE_DEVICE_TABLE(of, goodix_of_match);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;DT 说“这是个 goodix,gt911 设备”，驱动说“我支持 goodix,gt911”，两边 match，于是 probe。&lt;code&gt;compatible&lt;/code&gt; 不是注释，它是 match 的核心键——字符串写错一个字母，设备可能就永远不会 probe。&lt;/p&gt;
&lt;h3&gt;-EPROBE_DEFER：依赖链的优雅处理&lt;/h3&gt;
&lt;p&gt;复杂板子上，设备 A 的 probe 可能需要设备 B（比如 regulator/clock provider）已经就绪。如果 B 还没准备好，A 的 &lt;code&gt;probe()&lt;/code&gt; 返回 &lt;code&gt;-EPROBE_DEFER&lt;/code&gt;，内核会在 B 就绪后重新尝试调用 A 的 probe。所以在 dmesg 里看到 deferred probe 不一定是坏事——这是内核依赖顺序机制的一部分，设备不是严格线性初始化的，很多驱动会先 defer，等依赖 ready 以后再回来 probe。&lt;/p&gt;
&lt;h3&gt;devm_* 系列 API&lt;/h3&gt;
&lt;p&gt;驱动里大量出现的 &lt;code&gt;devm_ioremap_resource()&lt;/code&gt;、&lt;code&gt;devm_request_irq()&lt;/code&gt;、&lt;code&gt;devm_clk_get()&lt;/code&gt;、&lt;code&gt;devm_kzalloc()&lt;/code&gt; 等，含义是：将资源的生命周期绑定到 device 上，在 remove 或 probe 失败时由内核自动回收。&lt;/p&gt;
&lt;p&gt;这在驱动里很有价值，因为 probe 失败路径通常很长，remove 路径也麻烦，手写清理很容易漏。所以现代驱动里 &lt;code&gt;devm_*&lt;/code&gt; 很常见——它不是魔法，但能显著减少资源管理错误。&lt;/p&gt;
&lt;h2&gt;第一个开发者怎么调试&lt;/h2&gt;
&lt;p&gt;读到这里，你可能会想：我有了这些知识，拿到一块新板子，是不是就可以直接写驱动了？又或者说，第一个开发者是怎么写驱动以及调试的？我去哪里找资料，怎么验证每一步？&lt;/p&gt;
&lt;p&gt;对于一块全新的 ARM 板子，驱动开发者手里通常有：SoC TRM（Technical Reference Manual）、board schematic、PMIC 文档、参考 Device Tree / BSP、厂商 downstream kernel。他不是从纯黑盒开始的，首要工作是建立“硬件事实表”（这个 panel reset GPIO 接哪根 pin？背光是 PWM 还是专用芯片？触摸芯片挂在哪个 I2C 地址？），而不是马上写驱动代码。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;最早的观察能力是串口，不是屏幕。&lt;/strong&gt; UART 初始化最简单，只要 pinmux 和时钟配对了就能打印。屏幕路径太长（display controller → bridge → panel → backlight → regulator → reset → timing → MIPI DSI/eDP/LVDS），很多板子 bring-up 的前几天乃至前几周，全靠串口日志活着。所以真正的板级 bring-up，工具链优先级是：USB-UART → JTAG → logic analyzer → oscilloscope，而不是桌面、Wayland 或 framebuffer。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;按最小启动链逐层验证：&lt;/strong&gt; boot ROM → bootloader → UART 打印正常 → DRAM 正常 → kernel 能解压启动 → earlycon/printk 正常 → timer/interrupt 正常 → storage/rootfs 正常 → 基础总线（pinctrl/GPIO/I2C/SPI）→ 再往上调 panel/touchscreen/wifi/audio。第一个开发者并不是一上来就调 touchscreen 或 LCD，而是先把能提供观察能力的东西拉起来。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;每一层都做最小测试：&lt;/strong&gt; 调 I2C touchscreen，不是一上来就在桌面里等触摸事件，而是分层验证：I2C controller 是否 probe 成功 → &lt;code&gt;i2cdetect&lt;/code&gt; 能否看到设备地址 → interrupt GPIO 是否会跳变 → reset GPIO 是否控制正确 → regulator 是否上电 → 驱动是否产生 input event → &lt;code&gt;/dev/input/eventX&lt;/code&gt; 是否有数据。显示调试同理：DRM 驱动是否 probe → panel driver 是否 probe → regulator 是否 enable → reset 时序是否正确 → DSI link 是否训练成功 → backlight 是否真被拉高。&lt;/p&gt;
&lt;p&gt;软件侧的调试工具：&lt;code&gt;dmesg&lt;/code&gt;、&lt;code&gt;/proc/interrupts&lt;/code&gt;、&lt;code&gt;/sys/firmware/devicetree/base&lt;/code&gt;、&lt;code&gt;/sys/kernel/debug/*&lt;/code&gt;、&lt;code&gt;i2cdetect/i2cget/i2cset&lt;/code&gt;、&lt;code&gt;evtest&lt;/code&gt;、&lt;code&gt;modetest&lt;/code&gt;、&lt;code&gt;ethtool&lt;/code&gt;、&lt;code&gt;trace-cmd/ftrace&lt;/code&gt;。硬件侧的调试工具：示波器（看 reset/clock 信号时序），逻辑分析仪（抓 I2C/SPI 波形），JTAG（单步调试），USB-UART（串口日志）。对于新板子，前两周的 bring-up 可能完全离不开示波器和逻辑分析仪。&lt;/p&gt;
&lt;h2&gt;PCIe + 网卡&lt;/h2&gt;
&lt;h3&gt;PCIe 设备是怎么被发现的&lt;/h3&gt;
&lt;p&gt;与 platform device 最大的区别在于：PCIe 设备会自己“报到”。内核扫描 PCI bus，读到每个设备的 vendor id / device id / class code，创建 &lt;code&gt;struct pci_dev&lt;/code&gt;；某个 pci_driver 声明支持这些 id，match 成功，调用 &lt;code&gt;probe()&lt;/code&gt;。识别机制不是 DT 的 compatible，而是 PCI ID table：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;static const struct pci_device_id my_ids[] = {
    { PCI_DEVICE(0x8086, 0x100e) },
    { 0, }
};
MODULE_DEVICE_TABLE(pci, my_ids);
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;PCI probe() 的关键步骤&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;static int mynic_probe(struct pci_dev *pdev, const struct pci_device_id *id)
{
    pci_enable_device(pdev);            // 启用这个设备，让它能真正工作
    pci_request_regions(pdev, “mynic”); // 申请 BAR 对应的资源区间，不让别人乱占
    pci_set_master(pdev);              // 允许设备发起 DMA（bus mastering）
    dma_set_mask_and_coherent(&amp;#x26;pdev-&gt;dev, DMA_BIT_MASK(64)); // 声明 DMA 地址宽度
    base = pci_iomap(pdev, 0, 0);      // 把 BAR 映射成内核可访问的 MMIO 地址
    // 分配 rings, net_device, irq 等
    return 0;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;pci_set_master()&lt;/code&gt; 很关键，因为 PCI 设备不是天然就允许 bus mastering 的——没有这一步，设备根本无法发起 DMA。&lt;code&gt;dma_set_mask_and_coherent()&lt;/code&gt; 则是告诉内核这个设备支持多宽的 DMA 地址空间，比如 64-bit 或 32-bit，内核据此决定分配策略。&lt;/p&gt;
&lt;p&gt;BAR（Base Address Register）是 PCIe 设备暴露自身寄存器/内存的窗口。设备向系统声明“我需要一段地址空间”，系统分配后驱动通过 &lt;code&gt;pci_iomap()&lt;/code&gt; 拿到 base，之后就是熟悉的 &lt;code&gt;readl/writel(base + REG_XXX)&lt;/code&gt;。你可以把 BAR 理解成“这个 PCI 设备的 MMIO 窗口入口”。&lt;/p&gt;
&lt;p&gt;真正的网卡驱动在 probe 成功后，通常还会继续：分配 &lt;code&gt;net_device&lt;/code&gt;（Linux 网络子系统对“一个网络接口”的核心抽象对象）、初始化 TX/RX ring（DMA descriptor queue）、申请 interrupt（现代网卡常用 MSI-X，多队列时每队列一个 IRQ）、最后注册到网络子系统：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;register_netdev(ndev);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样用户态才会看到它变成 &lt;code&gt;eth0&lt;/code&gt; 或 &lt;code&gt;enp3s0&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;网卡驱动的两条数据路径&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;发包（TX）&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;socket/send → 协议栈 → 网卡驱动 ndo_start_xmit()
  → 把包放进 TX ring
  → DMA 地址写进 descriptor
  → doorbell 通知设备
  → 设备 DMA 取包并发出去
  → interrupt/completion
  → 驱动回收 descriptor
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;收包（RX）&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;驱动预先准备 RX buffer ring（把空桶摆好）
  → 网卡收到包
  → DMA 将包写进 RX buffer（设备自己倒进空桶）
  → interrupt 或 NAPI poll
  → 驱动取到包
  → 封装成 skb
  → 交给内核协议栈
  → IP/TCP/UDP → socket → 用户进程 recv/read
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;网卡驱动的本质不是“处理 socket API”，而是&lt;strong&gt;在硬件 ring / DMA buffer 和 Linux 网络栈之间做翻译&lt;/strong&gt;。&lt;/p&gt;
&lt;h3&gt;为什么一定要讲 NAPI&lt;/h3&gt;
&lt;p&gt;纯 interrupt 模式下，每来一个包就中断一次，在高流量时会让 CPU 被 interrupt 打爆，开销太大，吞吐很差。NAPI 的策略是：低流量时靠 interrupt 提醒，一旦流量大，先关掉 RX interrupt，进入 polling 批量收包，收得差不多了再重新开 interrupt。这是延迟与吞吐之间的经典折中，是 Linux 网络驱动里的核心机制。后面讲网络主线时，NAPI 会是重点。&lt;/p&gt;
&lt;h2&gt;整条主线&lt;/h2&gt;
&lt;p&gt;我们可以把上面讲的内容串成一条主线，看看一个设备从被发现、配置、搬运数据到通知 CPU 的完整流程：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Device Tree / PCIe 枚举
  → bus match → probe()
  → MMIO（ioremap → readl/writel）    ← CPU 给设备下命令
  → DMA API（分配、映射、barrier）     ← 设备高效搬运数据
  → interrupt / IRQ handler            ← 设备异步通知 CPU
  → bottom half / NAPI / workqueue     ← 延后的重活
  → subsystem 注册（net_device 等）    ← 用户态可见接口
  → scheduler: wakeup → runnable → running
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;驱动通过 MMIO 配置和命令设备，设备通过 interrupt 异步通知完成/错误，内核在 IRQ 上半部快速响应，在下半部做较重处理，最终可能唤醒 task 并触发调度。这就是大量现代驱动（网卡、NVMe、GPU 队列、高速存储控制器）的基本骨架。&lt;/p&gt;
&lt;p&gt;你现在已经掌握了驱动/设备模型里最重要的主干——device/driver/bus/probe、MMIO、interrupt/IRQ、DMA、Device Tree/platform device/依赖链。这足以支撑后面的很多内核主题，这条主线已经够清晰了。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>Learning Linux Kernel (Part 5) - Virtual Filesystem</title><link>https://theunknownth.ing/blog/linux-kernel-5</link><guid isPermaLink="true">https://theunknownth.ing/blog/linux-kernel-5</guid><description>One of the most important and complex subsystems in Linux kernel, which provides a unified abstraction layer for all file systems.</description><pubDate>Tue, 31 Mar 2026 20:22:00 GMT</pubDate><content:encoded>&lt;h2&gt;前言&lt;/h2&gt;
&lt;p&gt;我们终于来到了 Linux 内核的&quot;文件系统&quot;部分了。这个章节非常重要，因为它是 Linux 内核里最庞大、最复杂、也是最核心的子系统之一。理解了 VFS，你就真正理解了 Linux 的&quot;万物皆文件接口&quot;是怎么实现的。&lt;/p&gt;
&lt;p&gt;如果你还没有阅读上一章节，建议先阅读 &lt;a href=&quot;/blog/linux-kernel-4&quot;&gt;Linux Kernel (Part 4) - Memory Management&lt;/a&gt;。&lt;/p&gt;
&lt;h2&gt;Disclaimer&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;我本次使用了 AI 来带我学习 Linux 内核。所以无论我后续怎么整理润色，本文的 first draft 肯定是 AI 告诉我的内容&lt;/strong&gt;。如果你对此感到无法接受，或者觉得 AI 讲得不够好，你可以随时退出阅读，或者自己去看 Linux 内核的源码和文档。我的目标是通过 AI 来帮我梳理和总结内核的知识体系，而不是替代你自己去接触原始资料。&lt;/p&gt;
&lt;p&gt;如果你能接受这个前提，那么我们就继续往下走了。&lt;/p&gt;
&lt;h2&gt;为什么 Linux 一定要有 VFS&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;VFS = Virtual Filesystem Switch。&lt;/strong&gt; 它不是一个具体的文件系统，而是 Linux 为整个&quot;文件世界&quot;所构建的&lt;strong&gt;统一抽象层&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;Linux 上同时存在着大量截然不同的文件系统实现：ext4、xfs、btrfs、tmpfs、procfs、sysfs、NFS……它们的底层数据结构和存储方式各有不同。但用户态程序从来不需要为此操心——不论底层是哪种文件系统，你都可以统一地写：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;open()
read()
write()
close()
mmap()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这件事之所以成立，就是因为有 VFS。
如果没有 VFS，会怎样？那每种文件系统都得自己定义一套完整的接口：打开文件的方法、查找路径的方法、读写的方法、权限语义、目录操作……用户态和内核其它子系统都会被文件系统的实现细节死死耦合住，整个系统将变得不可维护。&lt;/p&gt;
&lt;p&gt;VFS 的解法非常清晰：&lt;strong&gt;先把&quot;文件、目录、路径、打开实例&quot;这些概念统一出来，形成一套标准的对象模型和接口；具体文件系统再去实现这些统一抽象背后的操作。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;和调度器一样，VFS 大量依赖 ops table：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;inode_operations&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;file_operations&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;address_space_operations&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;super_block_operations&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;你不需要把它们全记住，只需抓住核心思想：&lt;strong&gt;VFS 先定义统一的对象和操作接口，具体文件系统去实现这些接口。&lt;/strong&gt; 这意味着 ext4、xfs、tmpfs 都可以挂在同一套 VFS 框架下，用户态不需要为每种文件系统学一套新的系统调用。这就是 Linux 的&quot;万物皆文件接口&quot;能够成立的关键工程基础。&lt;/p&gt;
&lt;p&gt;VFS 统一的不只是名字解析，还统一了一部分&quot;文件内容访问模型&quot;。如果你仔细看过之前讲的内存管理，应该知道 &lt;code&gt;read()&lt;/code&gt; 和 &lt;code&gt;mmap(file)&lt;/code&gt; 常常在 page cache 汇合。现在把它放回 VFS 的视角：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;inode / file-backed object
  -&gt; address_space
  -&gt; address_space 管理 page cache
  -&gt; VFS 和具体 fs 通过这层衔接数据访问
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以文件系统这一章不会离开你刚学的 mm，反而会不断地把 inode、file、page cache、mmap、writeback 绑在一起。&lt;/p&gt;
&lt;h2&gt;文件系统主线最关键的三个对象&lt;/h2&gt;
&lt;p&gt;学习 VFS，第一步也是最重要的一步，就是把下面三个对象彻底分清：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;inode&lt;/strong&gt;：文件本体&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;dentry&lt;/strong&gt;：名字和路径关系&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;file&lt;/strong&gt;：一次打开实例&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这三者是 VFS 的核心骨架。&lt;/p&gt;
&lt;h3&gt;inode&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;inode&lt;/code&gt; 表示&quot;文件对象本身&quot;。它关注的是文件最本质的属性：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;文件类型（普通文件、目录、设备、symlink……）&lt;/li&gt;
&lt;li&gt;权限&lt;/li&gt;
&lt;li&gt;大小&lt;/li&gt;
&lt;li&gt;时间戳&lt;/li&gt;
&lt;li&gt;数据块映射关系&lt;/li&gt;
&lt;li&gt;inode 编号&lt;/li&gt;
&lt;li&gt;文件系统相关的元数据&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;关键点在于：inode 不关心你是通过哪个路径名找到它的。&lt;/strong&gt; 一个文件可以有多个 hard link，也就是多个名字指向同一个 inode。这意味着&quot;名字&quot;不是文件本体——&lt;strong&gt;inode 才是文件的核心抽象&lt;/strong&gt;。&lt;/p&gt;
&lt;h3&gt;dentry&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;dentry&lt;/code&gt; 全称 directory entry，它表示的是&quot;某个目录下面的某个名字&quot;。所以 dentry 关心的主要是三件事：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;这个名字叫什么&lt;/li&gt;
&lt;li&gt;它在哪个父目录下面&lt;/li&gt;
&lt;li&gt;它对应的是哪个 inode&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这就回答了路径名世界的问题。比如 &lt;code&gt;/tmp/a.txt&lt;/code&gt; 不是一个单一对象，而是一层层目录项解析出来的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/ -&gt; tmp -&gt; a.txt
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;每走一步名字解析，都会碰到 dentry。因此，如果要用一句话区分两者：&lt;strong&gt;inode 是&quot;文件是什么&quot;，dentry 是&quot;这个名字指向什么&quot;。&lt;/strong&gt; 这两个概念必须分清，否则后面所有关于路径解析和文件打开的理解都会出问题。&lt;/p&gt;
&lt;h3&gt;file&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;file&lt;/code&gt; 表示一次打开文件得到的&quot;打开实例&quot;。这是三个对象中最容易被误解的。很多人会觉得&quot;文件就是文件&quot;，&lt;code&gt;open()&lt;/code&gt; 之后得到的 fd 就是文件对象了。其实不是。fd 事实上只是一个整数索引，指向当前进程文件描述符表里的一个 &lt;code&gt;struct file&lt;/code&gt; 指针；而 &lt;code&gt;struct file&lt;/code&gt; 是一个独立的内核对象，代表了这次打开的状态。&lt;/p&gt;
&lt;p&gt;考虑下面的代码：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;int fd1 = open(&quot;a.txt&quot;, O_RDONLY);
int fd2 = open(&quot;a.txt&quot;, O_RDONLY);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;虽然路径名相同、底层 inode 也相同，但通常会产生&lt;strong&gt;两个不同的 &lt;code&gt;struct file&lt;/code&gt;&lt;/strong&gt;。因为每次打开都有自己独立的状态：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;当前文件偏移 &lt;code&gt;f_pos&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;打开标志&lt;/li&gt;
&lt;li&gt;指向哪套 &lt;code&gt;file_operations&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;一些运行期上下文&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以这三层的关系是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;inode&lt;/strong&gt;：文件本体&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;file&lt;/strong&gt;：这次打开所得到的句柄对象&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;fd&lt;/strong&gt;：进程文件表中的整数索引，指向 &lt;code&gt;file&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我在之前（大概是 Part 1 还是 Part 3 中？）提到过 &lt;code&gt;task_struct&lt;/code&gt; 的构成，其中 &lt;code&gt;files_struct&lt;/code&gt;，现在就能把它接回来了——&lt;code&gt;files_struct&lt;/code&gt; 中的 fd table 里存的就是指向 &lt;code&gt;struct file&lt;/code&gt; 的指针。&lt;/p&gt;
&lt;h3&gt;拼成一张图&lt;/h3&gt;
&lt;p&gt;理解文件操作，应该始终用这张图：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pathname -&gt; dentry -&gt; inode
open()   -&gt; file
fd table -&gt; fd -&gt; file
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;更精确地说：&lt;strong&gt;路径解析&lt;/strong&gt;主要在 dentry / inode 世界里发生; &lt;code&gt;open()&lt;/code&gt; 成功后，才得到 &lt;code&gt;file&lt;/code&gt;. 进程里真正拿着的是 &lt;code&gt;fd&lt;/code&gt;, &lt;code&gt;fd&lt;/code&gt; 再指向 &lt;code&gt;file&lt;/code&gt;. 所以 pathname lookup、open file instance、file descriptor table——这是三层不同的概念。&lt;/p&gt;
&lt;p&gt;在这里，我也要提一个非常容易混淆的点：&lt;strong&gt;fd 不是文件&lt;/strong&gt;.
这一点必须特别清楚。fd 只是当前进程文件描述符表里的一个整数编号——比如 0 是 stdin、1 是 stdout、2 是 stderr。它本身不包含&quot;文件内容&quot;，只是一个查表入口。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;task -&gt; files_struct -&gt; fd table -&gt; struct file -&gt; dentry/inode
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以当你在用户态写下 &lt;code&gt;read(fd, buf, n)&lt;/code&gt; 时，真正的底层含义是：&quot;拿当前 task 的 fd table 里的这个打开实例，从它背后的文件对象读数据。&quot; 这就把 task、files、VFS 三者连在了一起。&lt;/p&gt;
&lt;h2&gt;Pathname Lookup&lt;/h2&gt;
&lt;h3&gt;路径是逐段解析&lt;/h3&gt;
&lt;p&gt;路径不是&quot;一个字符串直接指向文件&quot;，而是一个逐段解析的过程。
比如 &lt;code&gt;/a/b/c&lt;/code&gt;，内核不会把它当成一个整体黑盒。它会像这样一步步走：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/ -&gt; a -&gt; b -&gt; c
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;每走一步，都会涉及：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;当前目录是谁&lt;/li&gt;
&lt;li&gt;当前名字是什么&lt;/li&gt;
&lt;li&gt;这个名字对应哪个 dentry / inode&lt;/li&gt;
&lt;li&gt;有没有 mount 点需要切换&lt;/li&gt;
&lt;li&gt;有没有符号链接需要重写路径&lt;/li&gt;
&lt;li&gt;权限是否允许&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;路径解析的起点&lt;/h3&gt;
&lt;p&gt;起点取决于路径类型：&lt;/p&gt;
&lt;p&gt;如果是&lt;strong&gt;绝对路径&lt;/strong&gt;（如 &lt;code&gt;/etc/passwd&lt;/code&gt;）：从当前进程看到的根目录开始。注意是&quot;当前进程看到的根目录&quot;——当我们后面讲到 mount namespace 时，你就会明白为什么这个限定词很重要。&lt;/p&gt;
&lt;p&gt;如果是&lt;strong&gt;相对路径&lt;/strong&gt;（如 &lt;code&gt;tmp/a.txt&lt;/code&gt;）：从当前工作目录 cwd 开始。这就和 &lt;code&gt;fs_struct&lt;/code&gt; 接上了——&lt;code&gt;fs_struct&lt;/code&gt; 里就包括 cwd 和 root 这些&quot;路径视角状态&quot;。&lt;/p&gt;
&lt;p&gt;所以 pathname lookup 不是纯字符串解析，&lt;strong&gt;它还依赖当前 task 的路径视角上下文。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;每一段到底查什么&lt;/h3&gt;
&lt;p&gt;假设要解析 &lt;code&gt;/home/user/test.txt&lt;/code&gt;，流程大致是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;从起点目录开始&lt;/li&gt;
&lt;li&gt;查找名字 &lt;code&gt;home&lt;/code&gt;，得到对应的 dentry / inode&lt;/li&gt;
&lt;li&gt;确认它是目录，进入&lt;/li&gt;
&lt;li&gt;再查名字 &lt;code&gt;user&lt;/code&gt;，重复相同过程&lt;/li&gt;
&lt;li&gt;最后查 &lt;code&gt;test.txt&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;每一步都像在做：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;(当前目录 inode, 名字) -&gt; 下一个 dentry -&gt; 下一个 inode
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这就是 dentry 在 pathname lookup 中真正的意义：&lt;strong&gt;目录世界里，名字解析的中间对象。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;为什么需要 dentry cache&lt;/h3&gt;
&lt;p&gt;如果每次解析路径都要去磁盘或底层文件系统查目录项，代价会非常高。所以 Linux 会缓存大量 pathname lookup 的结果——这就是 &lt;strong&gt;dentry cache&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;它缓存的是目录项的名字关系和名字到 inode 的解析结果。所以当你多次访问 &lt;code&gt;/usr/lib/libc.so&lt;/code&gt; 时，后续的 lookup 不一定每次都完整地走底层文件系统，而是尽量命中 dentry cache。这使得路径解析可以非常快。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;你可以把 dentry cache 理解成&quot;路径世界的高速缓存&quot;。&lt;/strong&gt; 它和你已经学过的其他缓存形成了一个非常漂亮的平行：&lt;/p&gt;
&lt;p&gt;| 缓存类型 | 缓存什么 |
|--|--|
| dcache | 名字解析结果 |
| page cache | 文件内容页 |
| TLB | 地址翻译条目 |&lt;/p&gt;
&lt;p&gt;这三个缓存各自面向不同的层次，但设计思想是相通的。&lt;/p&gt;
&lt;h3&gt;dentry 和 inode 在 lookup 中怎么配合&lt;/h3&gt;
&lt;p&gt;我们需要区分 &lt;code&gt;dentry&lt;/code&gt; 和 &lt;code&gt;inode&lt;/code&gt; 分别的作用：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;dentry 解决的是&lt;/strong&gt;：&quot;这个目录下面有没有这个名字？&quot;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;inode 解决的是&lt;/strong&gt;：&quot;这个名字指向的对象本体是什么？&quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以 lookup 的逻辑是在父目录里找名字 -&gt; 得到 dentry -&gt; dentry 指向 inode -&gt; inode 告诉你它是文件、目录、symlink 还是别的对象。
如果 inode 表示的是目录，那就可以继续向下走；如果最终 inode 表示的是普通文件，那 lookup 成功结束。&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;.&lt;/code&gt; 和 &lt;code&gt;..&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;它们也是路径解析的一部分——&lt;code&gt;.&lt;/code&gt; 表示当前目录，&lt;code&gt;..&lt;/code&gt; 表示父目录。只不过内核在 lookup 过程中对这些名字有专门的语义处理。这说明 pathname lookup 不是简单的哈希字符串，而是有路径语义规则的。&lt;strong&gt;lookup 是&quot;有状态的路径遍历&quot;，不是纯粹的字符串到 inode 的哈希表查询。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;符号链接&lt;/h3&gt;
&lt;p&gt;符号链接（symlink）的本质是：&lt;strong&gt;一个文件，其内容是另一个路径字符串。&lt;/strong&gt;
所以如果解析过程中遇到 symlink，内核可能需要：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;停下当前的路径解析&lt;/li&gt;
&lt;li&gt;取出 symlink 指向的新路径&lt;/li&gt;
&lt;li&gt;把它拼回剩余路径&lt;/li&gt;
&lt;li&gt;继续重新走 lookup&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这意味着 pathname lookup 不总是&quot;单纯在树上往下走&quot;，还可能出现路径替换、重新起点、以及递归/循环保护。&lt;strong&gt;symlink 让路径解析从&quot;目录树遍历&quot;变成了&quot;树遍历 + 路径重写&quot;。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;mount point&lt;/h3&gt;
&lt;p&gt;在路径解析过程中，某个目录位置不一定真的&quot;继续在同一个文件系统里往下走&quot;——它可能是一个 &lt;strong&gt;mount point&lt;/strong&gt;。
举个具体例子。假设一开始 root 文件系统是 ext4，里面有：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/
└── mnt
    └── data
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;此时 &lt;code&gt;/mnt/data&lt;/code&gt; 只是 ext4 上的一个普通目录。然后你执行：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;mount /dev/sdb1 /mnt/data
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这件事做完之后，路径世界发生了变化：&lt;/p&gt;
&lt;p&gt;&lt;code&gt;/mnt/data&lt;/code&gt; 这个&quot;位置&quot;还在。但这个位置下面显示出来的内容，不再是原 ext4 里那个 &lt;code&gt;data&lt;/code&gt; 目录的原本内容，而是 &lt;code&gt;/dev/sdb1&lt;/code&gt; 这个文件系统的根目录内容。&lt;/p&gt;
&lt;p&gt;所以 &lt;code&gt;/mnt/data/foo.txt&lt;/code&gt; 现在实际上是在访问 &lt;code&gt;/dev/sdb1&lt;/code&gt; 文件系统根下的 &lt;code&gt;foo.txt&lt;/code&gt;，而不是原来 ext4 里的 &lt;code&gt;data/foo.txt&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;原来的 &lt;code&gt;/mnt/data&lt;/code&gt; 目录并没有消失，只是被覆盖/遮住了。&lt;/strong&gt; 只要 mount 在，你通过 &lt;code&gt;/mnt/data/...&lt;/code&gt; 看到的就是新挂上来的那棵树，原来底下那棵树暂时&quot;藏在后面&quot;。这也是为什么 umount 之后一切又恢复原样——内核本来就没有修改过原始的 dentry 或磁盘数据，mount 本质上是在内存的 VFS 层做了一次路径解析的重定向。&lt;/p&gt;
&lt;p&gt;所以 pathname lookup 里，每走一步除了查名字，还要检查当前位置是不是 mount point。如果是，就切到另一个 superblock 的根目录继续走。对于 mount，我们稍后还会提到，如果你对“底下那棵树暂时藏在后面”这个表述还感到困惑，可以先放一放。&lt;/p&gt;
&lt;h3&gt;小小总结&lt;/h3&gt;
&lt;p&gt;这张图非常重要。因为以后 &lt;code&gt;open&lt;/code&gt;、&lt;code&gt;stat&lt;/code&gt;、&lt;code&gt;unlink&lt;/code&gt;、&lt;code&gt;rename&lt;/code&gt;、&lt;code&gt;mkdir&lt;/code&gt;、&lt;code&gt;execve&lt;/code&gt;——全都要先经历某种形式的 pathname lookup。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;task
  -&gt; fs_struct (cwd/root)
  -&gt; pathname string
  -&gt; start from root or cwd
  -&gt; for each path component:
       lookup dentry in current directory
       get inode
       if directory: continue
       if symlink: rewrite path / continue
       if mountpoint: switch filesystem tree
  -&gt; final dentry/inode
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;open() 到底干了什么&lt;/h2&gt;
&lt;h3&gt;open() 不是&quot;读文件&quot;&lt;/h3&gt;
&lt;p&gt;先纠正一个常见的直觉误区：&lt;strong&gt;&lt;code&gt;open()&lt;/code&gt; 做的不是&quot;把文件内容读进来&quot;，而是&quot;根据一个路径，创建一个打开实例，并把它挂到当前进程的 fd 表里&quot;。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;很多人会下意识以为 &lt;code&gt;open()&lt;/code&gt; = &quot;把文件打开到内存里&quot;。其实不是。&lt;code&gt;open()&lt;/code&gt; 的核心工作是以下这四点：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;路径解析&lt;/li&gt;
&lt;li&gt;权限检查&lt;/li&gt;
&lt;li&gt;创建 &lt;code&gt;struct file&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;分配 fd&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;真正读内容，通常要等 &lt;code&gt;read()&lt;/code&gt; 或 &lt;code&gt;mmap()&lt;/code&gt; 等后续操作。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;第一步：pathname lookup&lt;/h3&gt;
&lt;p&gt;当你执行：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;int fd = open(&quot;/tmp/a.txt&quot;, O_RDONLY);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;内核第一件事不是&quot;找个整数给你&quot;，而是先去解析路径 &lt;code&gt;/tmp/a.txt&lt;/code&gt;——从 root 开始，走 &lt;code&gt;tmp&lt;/code&gt;，再走 &lt;code&gt;a.txt&lt;/code&gt;，得到最终的 dentry / inode。&lt;strong&gt;&lt;code&gt;open()&lt;/code&gt; 的前半段其实就是在走 pathname lookup。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;第二步：权限与语义检查&lt;/h3&gt;
&lt;p&gt;路径找到了，不代表一定能成功 open。内核还要检查：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;目标对象是否存在&lt;/li&gt;
&lt;li&gt;是普通文件、目录、设备、symlink 还是其他类型&lt;/li&gt;
&lt;li&gt;当前进程有没有权限&lt;/li&gt;
&lt;li&gt;打开标志是否合法&lt;/li&gt;
&lt;li&gt;如果带了 &lt;code&gt;O_CREAT&lt;/code&gt;，是否需要创建新 inode&lt;/li&gt;
&lt;li&gt;如果带了 &lt;code&gt;O_TRUNC&lt;/code&gt;，是否允许截断&lt;/li&gt;
&lt;li&gt;如果带了 &lt;code&gt;O_DIRECTORY&lt;/code&gt;，目标是不是真目录&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;open()&lt;/code&gt; 是路径语义 + 权限语义 + 打开语义，三者共同决定结果。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;第三步：创建 struct file&lt;/h3&gt;
&lt;p&gt;这一步是 &lt;code&gt;open()&lt;/code&gt; 真正最关键的动作。路径解析最终给你的是 dentry 和 inode，但进程真正要拿来做后续 &lt;code&gt;read&lt;/code&gt; / &lt;code&gt;write&lt;/code&gt; / &lt;code&gt;lseek&lt;/code&gt; / &lt;code&gt;mmap&lt;/code&gt; / &lt;code&gt;close&lt;/code&gt; 的不是 inode 本身，而是 &lt;strong&gt;&lt;code&gt;struct file&lt;/code&gt;——&quot;打开实例&quot;&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这个 file 对象里会存放：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;打开标志&lt;/li&gt;
&lt;li&gt;当前文件偏移 &lt;code&gt;f_pos&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;指向目标 dentry / path / inode&lt;/li&gt;
&lt;li&gt;指向哪套 &lt;code&gt;file_operations&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;一些运行时状态&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;open()&lt;/code&gt; 成功，本质上是创建了一个新的 file 实例。&lt;/strong&gt; 你可以把 inode 理解为文件本体，file 理解为&quot;这次会话对象&quot;。&lt;/p&gt;
&lt;h3&gt;为什么同一个文件多次 open() 会得到不同 file&lt;/h3&gt;
&lt;p&gt;因为每次打开都应该有自己的一份&quot;打开状态&quot;：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;fd1 = open(&quot;a.txt&quot;, O_RDONLY);
fd2 = open(&quot;a.txt&quot;, O_RDONLY);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这通常意味着：同一个 inode、可能同一路径 dentry，但&lt;strong&gt;两个不同的 &lt;code&gt;struct file&lt;/code&gt;&lt;/strong&gt;。因为 &lt;code&gt;fd1&lt;/code&gt; 的 offset 可以走到 100，&lt;code&gt;fd2&lt;/code&gt; 仍然可以停在 0。file 必须是&quot;打开实例&quot;，而非文件本体。一旦想清楚这一点，很多 Unix API 的语义都会变得自然。&lt;/p&gt;
&lt;h3&gt;第四步：分配 fd&lt;/h3&gt;
&lt;p&gt;现在终于轮到 fd 了。当前 task 的 &lt;code&gt;files_struct&lt;/code&gt; 里有一张 fd table，内核会找一个空闲 fd 编号，把 &lt;code&gt;fd -&gt; struct file *&lt;/code&gt; 的关系填进去，然后把整数 fd 返回给用户态。&lt;/p&gt;
&lt;p&gt;所以 fd 本质上就是：当前进程文件描述符表里，对某个打开实例的索引。以后你看到 &lt;code&gt;read(fd, ...)&lt;/code&gt;，内核并不是&quot;拿着这个数字直接找文件&quot;，而是先从当前 task 的 fd table 里把 &lt;code&gt;file *&lt;/code&gt; 找出来，再往后走。&lt;/p&gt;
&lt;h3&gt;dup() 为什么是一个很有代表性的例子&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;dup()&lt;/code&gt; 能非常好地帮你区分&quot;fd != file&quot;。当你调用 &lt;code&gt;fd2 = dup(fd1)&lt;/code&gt; 时，通常不会新建一个 &lt;code&gt;struct file&lt;/code&gt;，而是新建一个 fd table entry，让 &lt;code&gt;fd2&lt;/code&gt; 也指向同一个 &lt;code&gt;struct file&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;所以 &lt;code&gt;fd1&lt;/code&gt; 和 &lt;code&gt;fd2&lt;/code&gt; &lt;strong&gt;共享同一个打开实例&lt;/strong&gt;——共享 offset、共享打开状态。多个 fd 可以指向同一个 file，这个区别非常重要。&lt;/p&gt;
&lt;h3&gt;fork() 为什么会把文件语义带进来&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;fork()&lt;/code&gt; 后，子进程通常继承父进程的 fd table 语义。很多情况下，父子会持有指向同一批 &lt;code&gt;struct file&lt;/code&gt; 的引用，这意味着某些 fd 是共享打开实例的，offset 也可能联动。&lt;/p&gt;
&lt;p&gt;这就是为什么 shell 重定向、pipe、父子进程文件共享语义会表现得那么自然——因为底层就是多个进程/线程指向同一个打开实例。&lt;/p&gt;
&lt;h3&gt;open() 和 create 的关系&lt;/h3&gt;
&lt;p&gt;如果 &lt;code&gt;open()&lt;/code&gt; 带了 &lt;code&gt;O_CREAT&lt;/code&gt; 标志，它可能还要额外做一件事：如果路径的最后一项不存在，就在父目录里创建一个新的目录项和 inode。所以 &lt;code&gt;open()&lt;/code&gt; 实际上有两种主要模式：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;仅打开已有对象：&lt;/strong&gt; lookup → 检查 → 创建 file → 分配 fd&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;打开时创建：&lt;/strong&gt; 先找到父目录 → 创建新 dentry / inode → 再创建 file → 再分配 fd&lt;/p&gt;
&lt;p&gt;&lt;code&gt;open()&lt;/code&gt; 常常不只是&quot;打开&quot;，而是路径语义的一大入口。&lt;/p&gt;
&lt;h3&gt;open() 成功不等于文件内容已在 page cache 里&lt;/h3&gt;
&lt;p&gt;这个点必须钉死。&lt;code&gt;open()&lt;/code&gt; 完成时，意味着你已经有了 file 对象、可以后续操作。但它&lt;strong&gt;不&lt;/strong&gt;意味着文件数据已经读进内存、page cache 已经准备好、甚至不意味着将来的 &lt;code&gt;read()&lt;/code&gt; 不会阻塞。&lt;/p&gt;
&lt;p&gt;真正的内容访问通常要等 &lt;code&gt;read()&lt;/code&gt;、&lt;code&gt;write()&lt;/code&gt;、&lt;code&gt;mmap()&lt;/code&gt;、readahead 或其他后续动作。&lt;strong&gt;&lt;code&gt;open()&lt;/code&gt; 主要是名字和对象语义，不是内容搬运语义。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;对于 open()，我们把途经的步骤画成一张图：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;open(path, flags)
  -&gt; pathname lookup
     -&gt; dentry / inode
  -&gt; permission + semantic checks
  -&gt; maybe create / truncate / etc
  -&gt; allocate struct file
  -&gt; install into current task&apos;s fd table
  -&gt; return integer fd
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Dentry Cache vs. Inode Cache&lt;/h2&gt;
&lt;p&gt;理解了 dentry cache 加速路径解析之后，一个自然的问题是：&lt;strong&gt;为什么还需要 inode cache？有 dentry cache 不就够了吗？我存这个 inode 到底有什么用？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;答案是不够。因为这两层缓存解决的是完全不同的问题。&lt;/p&gt;
&lt;h3&gt;dentry cache 解决什么&lt;/h3&gt;
&lt;p&gt;它解决的是：&quot;这个目录下面有没有这个名字？这个名字指向谁？&quot;&lt;/p&gt;
&lt;p&gt;比如解析 &lt;code&gt;/usr/bin/python&lt;/code&gt;，你会反复查 &lt;code&gt;/&lt;/code&gt; 下有没有 &lt;code&gt;usr&lt;/code&gt;、&lt;code&gt;/usr&lt;/code&gt; 下有没有 &lt;code&gt;bin&lt;/code&gt;、&lt;code&gt;/usr/bin&lt;/code&gt; 下有没有 &lt;code&gt;python&lt;/code&gt;。&lt;strong&gt;dentry cache 加速的是 pathname lookup——名字解析。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;inode cache 解决什么&lt;/h3&gt;
&lt;p&gt;inode cache 缓存的是&quot;文件对象本体的 in-core 表示&quot;——文件类型、权限、大小、时间戳、block mapping、&lt;code&gt;i_op&lt;/code&gt; / &lt;code&gt;f_op&lt;/code&gt;、&lt;code&gt;address_space&lt;/code&gt;、引用计数、锁、状态位等等。这些东西不是 dentry 能替代的。&lt;/p&gt;
&lt;p&gt;即使你已经知道名字 &lt;code&gt;a.txt&lt;/code&gt; 对应 inode number 12345，你后面仍然需要这个 inode 对象本身在内存里的结构体——文件大小是多少？权限是什么？它对应哪套 inode / file operations？page cache 挂在哪个 address_space 上？&lt;strong&gt;dentry 只是告诉你&quot;去找哪个 inode&quot;，并不能代替 inode 本体。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;很多操作根本不经过 pathname lookup&lt;/h3&gt;
&lt;p&gt;这是一个非常重要的观察：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;read(fd, buf, n);
fstat(fd, ...);
mmap(fd, ...);
write(fd, ...);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这些操作拿到的链条是 &lt;code&gt;fd -&gt; file -&gt; inode&lt;/code&gt;，它们根本不会重新走路径解析。也就是说，这些场景下 dentry cache 的作用已经很弱了，&lt;strong&gt;但 inode 仍然是核心对象&lt;/strong&gt;。如果没有 inode cache，每次 &lt;code&gt;read()&lt;/code&gt;、&lt;code&gt;write()&lt;/code&gt;、&lt;code&gt;mmap()&lt;/code&gt; 都可能需要重新把 inode 元数据从底层文件系统或磁盘读一遍——这显然不可接受。&lt;/p&gt;
&lt;h3&gt;多个 dentry 可以指向同一个 inode&lt;/h3&gt;
&lt;p&gt;Hard link 的存在意味着同一个 inode 可以有多个目录项名字。如果只有 dentry cache 没有 inode cache，名字层的缓存可能大量重复，而文件本体的状态却没有统一的对象来承载。&lt;strong&gt;inode cache 正好解决了这个问题：把&quot;同一个文件本体&quot;统一成一个内存对象。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;三层缓存体系&lt;/h3&gt;
&lt;p&gt;把所有缓存放在一起看，VFS 的缓存体系是这样的：&lt;/p&gt;
&lt;p&gt;| 缓存 | 缓存什么 | 偏向 |
|--|--|--|
| dentry cache | 名字解析结果 | 名字 |
| inode cache | 文件本体元数据对象 | 对象 |
| page cache | 文件内容页 | 内容 |&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;名字、对象、内容——三层分离，各司其职。&lt;/strong&gt; Linux VFS 的缓存的优美设计非常值得学习。&lt;/p&gt;
&lt;h2&gt;Hard Link 与 Soft Link&lt;/h2&gt;
&lt;p&gt;在理解了 inode 和 dentry 的区别之后，hard link 和 soft link 的差异就变得非常清晰了。&lt;/p&gt;
&lt;h3&gt;hard link&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ln a.txt b.txt
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这意味着 &lt;code&gt;a.txt&lt;/code&gt; 和 &lt;code&gt;b.txt&lt;/code&gt; 是两个不同的目录项名字（不同 dentry），但它们指向&lt;strong&gt;同一个 inode&lt;/strong&gt;。文件内容是同一份，inode 编号相同，link count 增加。修改 &lt;code&gt;a.txt&lt;/code&gt; 就等于修改 &lt;code&gt;b.txt&lt;/code&gt;，因为从内核视角看，它们根本就是同一个文件本体，只是有两个名字。&lt;/p&gt;
&lt;p&gt;所以 &lt;strong&gt;hard link 更像是：给同一个 inode 多挂一个名字。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;但是他也有约束条件：一般不能跨文件系统（因为 inode number 是 per-filesystem 的）；通常不能对目录做 hard link（为了避免目录图变成难以处理的一般图）。&lt;/p&gt;
&lt;h3&gt;soft link&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ln -s a.txt b.txt
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这时 &lt;code&gt;b.txt&lt;/code&gt; 本身是一个&lt;strong&gt;新的 inode&lt;/strong&gt;，但这个 inode 的类型是 symlink，它的数据内容不是普通文件数据，而是 &lt;code&gt;&quot;a.txt&quot;&lt;/code&gt; 这条路径字符串。&lt;/p&gt;
&lt;p&gt;所以访问 &lt;code&gt;b.txt&lt;/code&gt; 时，pathname lookup 会看到这是个 symlink，取出里面的路径字符串，再按那个路径继续解析。&lt;strong&gt;soft link 更像一个&quot;路径跳转器&quot;文件。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;soft link 不共享 inode，可以跨文件系统，可以指向目录，但可以变成 &lt;strong&gt;dangling link&lt;/strong&gt;（目标路径被删除后，symlink 自己还在，但解析失败）。而 hard link 永远不会&quot;悬空&quot;——因为它根本不是&quot;指向另一个名字&quot;，而是直接就是那个 inode 的另一个名字。&lt;/p&gt;
&lt;h2&gt;read() / write() / mmap()&lt;/h2&gt;
&lt;h3&gt;共同的起点&lt;/h3&gt;
&lt;p&gt;这三种最核心的文件内容访问方式，都从同一个起点出发：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;当前 task -&gt; files_struct -&gt; fd table -&gt; struct file
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;用户态传进来的 &lt;code&gt;read(fd, ...)&lt;/code&gt;、&lt;code&gt;write(fd, ...)&lt;/code&gt;、&lt;code&gt;mmap(fd, ...)&lt;/code&gt; 第一步都不是&quot;直接操作磁盘&quot;，而是先查 fd table、找到 &lt;code&gt;struct file&lt;/code&gt;、再沿着 &lt;code&gt;file -&gt; dentry -&gt; inode&lt;/code&gt; 走到对象本体，然后进入 VFS 和具体 fs 的内容访问路径。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;三者的共同起点是&quot;一个打开实例 struct file&quot;。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;read()：copy model&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;read(fd, buf, n)&lt;/code&gt; 的典型路径：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;fd -&gt; file&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;VFS 层调用该对象的 read 逻辑&lt;/li&gt;
&lt;li&gt;先看 page cache——如果命中，直接从缓存读&lt;/li&gt;
&lt;li&gt;如果 page cache miss，发 I/O，把文件页装入 page cache&lt;/li&gt;
&lt;li&gt;从 page cache 把数据 &lt;strong&gt;&lt;code&gt;copy_to_user&lt;/code&gt;&lt;/strong&gt; 到用户 buffer&lt;/li&gt;
&lt;li&gt;更新 &lt;code&gt;file-&gt;f_pos&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;read()&lt;/code&gt; 的本质是：&quot;从文件对象中取数据，并复制到用户提供的缓冲区。&quot;&lt;/strong&gt; 关键特点：经过 page cache，经过一次 &lt;code&gt;copy_to_user&lt;/code&gt;，与 &lt;code&gt;file-&gt;f_pos&lt;/code&gt; 强相关。&lt;/p&gt;
&lt;h3&gt;write()：先缓存，后落盘&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;write(fd, buf, n)&lt;/code&gt; 看起来像&quot;把数据写进文件&quot;，但从内核视角更精确地说：&lt;strong&gt;先把用户数据写进内核管理的文件页/缓存层，再由 writeback 机制决定何时真正落到磁盘。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;典型主线：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;fd -&gt; file&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;VFS / fs 路径&lt;/li&gt;
&lt;li&gt;把用户 buffer 数据 &lt;strong&gt;&lt;code&gt;copy_from_user&lt;/code&gt;&lt;/strong&gt; 进对应文件页（通常就是 page cache 页）&lt;/li&gt;
&lt;li&gt;把页标成 &lt;strong&gt;dirty&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;更新 inode 大小/时间戳等元数据&lt;/li&gt;
&lt;li&gt;更新 &lt;code&gt;file-&gt;f_pos&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;将来某个时机再 writeback 到磁盘&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;所以 &lt;strong&gt;&lt;code&gt;write()&lt;/code&gt; 返回成功，常常只表示&quot;数据已经进入内核可控的缓存/页层&quot;，不一定表示&quot;磁盘此刻已经写完&quot;。&lt;/strong&gt; 这就是为什么脏页、writeback、&lt;code&gt;fsync()&lt;/code&gt; 这些概念会变得重要。&lt;/p&gt;
&lt;h3&gt;mmap()：mapping model&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;mmap(fd, ...)&lt;/code&gt; 的语义和 read / write 完全不同——它不直接搬内容：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;fd -&gt; file&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;在当前 mm 里创建 &lt;strong&gt;file-backed VMA&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;记录映射关系：这段虚拟地址对应哪个文件、从哪个文件 offset 开始、映射权限是什么&lt;/li&gt;
&lt;li&gt;通常&lt;strong&gt;并不立刻&lt;/strong&gt;把每页都建立好 PTE&lt;/li&gt;
&lt;li&gt;后续访问时，通过 &lt;strong&gt;page fault&lt;/strong&gt; 把 page cache 页映射进来&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;mmap()&lt;/code&gt; 的本质不是&quot;帮你读文件内容&quot;，而是&quot;把文件对象的一段内容，变成你地址空间中的一段映射规则&quot;。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;统一图&lt;/h3&gt;
&lt;p&gt;现在用一张图来统一理解：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;fd -&gt; struct file -&gt; inode / address_space / page cache

read():
    page cache -&gt; copy_to_user(buf)

write():
    copy_from_user(buf) -&gt; page cache (dirty) -&gt; later writeback

mmap():
    file-backed VMA -&gt; page fault -&gt; page cache page mapped into user page table
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这张图是 VFS、mm、page cache 三者交汇处的总图。&lt;/p&gt;
&lt;h3&gt;f_pos 为什么重要&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;f_pos&lt;/code&gt; 是 &lt;code&gt;struct file&lt;/code&gt; 作为&quot;打开实例&quot;的最好例子之一。&lt;code&gt;read()&lt;/code&gt; / &lt;code&gt;write()&lt;/code&gt; 常常依赖 &lt;code&gt;file-&gt;f_pos&lt;/code&gt;——当前读写偏移是打开实例状态的一部分，不是 inode 属性，也不是路径属性。&lt;/p&gt;
&lt;p&gt;这就解释了为什么两次独立 &lt;code&gt;open()&lt;/code&gt; 得到两个 file、它们偏移互不影响；而 &lt;code&gt;dup()&lt;/code&gt; 出来的两个 fd 指向同一个 file，偏移会联动。&lt;/p&gt;
&lt;p&gt;而 &lt;code&gt;mmap()&lt;/code&gt; 则不同——映射一旦建立，访问发生在用户虚拟地址 + 页表 + page fault 的世界里，不需要每次都拿 &lt;code&gt;f_pos&lt;/code&gt; 去推进。&lt;strong&gt;read / write 更像&quot;流式 I/O&quot;，mmap 更像&quot;把文件内容投影进地址空间&quot;。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;mmap() 这么好，为什么不直接用 mmap()？&lt;/h3&gt;
&lt;p&gt;既然 &lt;code&gt;mmap()&lt;/code&gt; 能做到&lt;strong&gt;零拷贝（免去了内核态到用户态的复制数据）&lt;strong&gt;并且&lt;/strong&gt;消除了系统调用开销&lt;/strong&gt;，在处理大数据时甚至自带&lt;strong&gt;原生的随机访问能力&lt;/strong&gt;，那为什么我们不干脆抛弃传统的 &lt;code&gt;read()&lt;/code&gt; 和 &lt;code&gt;write()&lt;/code&gt;？&lt;/p&gt;
&lt;p&gt;答案是：mmap 不是在所有场景下都碾压常规 I/O，它是一把双刃剑。&lt;/p&gt;
&lt;p&gt;如果你无脑使用 mmap，可能会遇到下面这些问题：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;mmap&lt;/code&gt; 刚调用成功时只是分配了虚拟地址，并没有真正把文件读进内存。只有当你真正访问它时，CPU 才会触发 Page Fault 陷入内核去拉数据。如果你的访问极其离散，频繁的缺页中断开销可能会抹平零拷贝带来的收益。而传统的 &lt;code&gt;read()&lt;/code&gt; 配合操作系统的预读（Read-ahead）机制，在&lt;strong&gt;严格顺序读取&lt;/strong&gt;时，顺滑程度往往比不断触发 Page Fault 的 mmap 还要快。&lt;/li&gt;
&lt;li&gt;如果你 mmap 了一个文件，在读取过程中另一个进程把这个文件截断（Truncate）变小了，当你访问超出新文件结尾的内存时，内核不会像 &lt;code&gt;read&lt;/code&gt; 那样优雅地返回 EOF 或 -1，而是直接给你发一个 SIGBUS 信号，导致程序瞬间崩溃。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;mmap&lt;/code&gt; 分配的地址空间大小是固定的，&lt;strong&gt;它不能自动扩容&lt;/strong&gt;。如果你想往文件末尾追加数据，你不能直接接着内存地址往下写。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;那如果我用 &lt;code&gt;write()&lt;/code&gt; 来扩容文件，用 &lt;code&gt;mmap()&lt;/code&gt; 来随机读，是不是就完美了？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;直觉上很美，但实际上这套“组合拳”会引入极大的复杂度和“心智负担”：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;因为映射的地址空间是固定的，&lt;code&gt;write&lt;/code&gt; 虽然扩大了底层物理文件的大小，但它&lt;strong&gt;无法自动扩大你已经建立好的“内存窗口”&lt;/strong&gt;。试图越界访问就会 Segmentation Fault。&lt;/li&gt;
&lt;li&gt;要想让 mmap 窗口读到新数据，你必须先把旧地址 &lt;code&gt;munmap&lt;/code&gt; 掉，再重新 &lt;code&gt;mmap&lt;/code&gt; 或者使用 &lt;code&gt;mremap&lt;/code&gt;。这涉及修改内核页表，是一个非常昂贵的系统调用。&lt;/li&gt;
&lt;li&gt;更致命的是，重新映射可能会导致文件的起始地址改变。这意味着你代码里所有指向这块内存的 C++ 指针、&lt;code&gt;std::string_view&lt;/code&gt; 或迭代器会&lt;strong&gt;瞬间全部失效&lt;/strong&gt;。在多线程环境下，这简直是灾难。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果你追求极致的随机读性能（比如 RocksDB 读索引文件），mmap 确实是神器。但在需要频繁更新和引发扩容的场景下，成熟的系统通常有这几种解法：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;预分配&lt;/strong&gt;：不要写几百字节就扩容一次。通常的做法是，文件不够用时，用 &lt;code&gt;ftruncate&lt;/code&gt; 一次性给文件增加大块空间（如 128MB 或 1GB），然后 &lt;code&gt;mremap&lt;/code&gt;，以此大幅减少重映射的次数。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只读 MMap + O_DIRECT Write&lt;/strong&gt;：一些数据库（如 LMDB）读操作全部通过 mmap 指针进行，极度高效；而写操作则绕过 Page Cache 自己控制落盘。这需要应用层维护一个非常复杂的“元数据页面”来告诉读线程当前的有效边界在哪里。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;老老实实手写 Buffer Pool&lt;/strong&gt;：这也是为什么大多数成熟的关系型数据库（如 PostgreSQL, MySQL/InnoDB）不迷信 mmap 的主要原因。虽然自己管理内存缓冲区很累，但彻底避开了内核页表映射的坑和 SIGBUS 风险，同时也获得了绝对的话语权：可以精确控制脏页什么时候落盘（fsync），而不是看 OS 的心情。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Mount、Superblock 与 Filesystem Type&lt;/h2&gt;
&lt;p&gt;&quot;文件系统类型&quot;告诉你这是什么品种；&quot;superblock&quot;表示一次具体挂载出来的文件系统实例；&quot;mount&quot;表示把这个实例接到路径树的某个位置上。有了这个大局观，我们接下来看具体的结构。&lt;/p&gt;
&lt;h3&gt;file_system_type&lt;/h3&gt;
&lt;p&gt;这是&quot;类型&quot;层。ext4、xfs、btrfs、tmpfs、proc、sysfs——它回答的是&quot;这是什么文件系统类型？&quot;你可以把它理解成一种&quot;驱动/实现类&quot;。&lt;/p&gt;
&lt;p&gt;如果你一直只接触 &lt;code&gt;ext4&lt;/code&gt; 或 Windows 的 &lt;code&gt;NTFS&lt;/code&gt;，可能觉得文件系统等同于“用于给硬盘存文件格式”。但在万物皆文件的 Unix 哲学下，文件系统早已演化出了三头六臂：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;物理磁盘文件系统&lt;/strong&gt;
比如 &lt;code&gt;xfs&lt;/code&gt; 被设计为一种极度成熟、高性能的 64 位日志文件系统。它尤其擅长应对极大容量（如 PB 级）系统，对高并发和大规模并行 I/O 极其友好，以至于现在许多企业级发行版都默认选择了 &lt;code&gt;xfs&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;内存虚拟文件系统&lt;/strong&gt;
&lt;code&gt;tmpfs&lt;/code&gt; 不写入硬盘，只占用 RAM 或 Swap，读取速度奇快。但它具有易失性，一断电东西全灰飞烟灭。很多 Linux 系统会把 &lt;code&gt;/tmp&lt;/code&gt; 或进程共享内存挂载为 tmpfs 来追求极致临时数据访问性能。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;内核监视器/接口伪文件系统&lt;/strong&gt;
它们挂载在 &lt;code&gt;/proc&lt;/code&gt; 和 &lt;code&gt;/sys&lt;/code&gt;，本质上它们占用大小是 0 字节，在磁盘上并不存在。每当有进程去读取里面的文件，内核就实时捕获当前状态吐出来。&lt;code&gt;proc&lt;/code&gt; 面向进程与系统指标信息，而 &lt;code&gt;sysfs&lt;/code&gt; 则为了解决 &lt;code&gt;/proc&lt;/code&gt; 过度膨胀，被专门整理出来展现内核极其复杂的“硬件设备树和驱动参数”。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;路由器上的联合挂载 (OverlayFS)&lt;/strong&gt;
在嵌入式系统或 Docker 中常常大放异彩的 &lt;code&gt;OverlayFS&lt;/code&gt;，被称为 Union Mount（联合挂载）。
它犹如把底下一张不能涂改的白纸（Lowerdir，只读固件压缩文件系统）和上面一张透明塑料纸（Upperdir，可写区域）叠压在一起让你看。如果是读取，看到哪一层最新就展示什么；如果是修改文件，内核把白纸上的旧文件先 Copy 一份到透明薄膜层上给你改（Copy-up）；要删除底层文件，就建立一个白点（Whiteout）来把它遮盖掉。如此既保证了底层骨架的不可破坏（一键恢复出厂设置的原理），又实现了上层高度的写自由。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这就凸显出 VFS 模型抽象层最精妙的能力：&lt;strong&gt;制定协议，让全世界万物，甚至内存、设备和硬件信号，都可以披上“文件”的外衣被我们优雅访问。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;super_block&lt;/h3&gt;
&lt;p&gt;这是&quot;实例&quot;层。同样都是 ext4，你可以同时挂两个分区：&lt;code&gt;/dev/sda1&lt;/code&gt; 到 &lt;code&gt;/&lt;/code&gt;，&lt;code&gt;/dev/sdb1&lt;/code&gt; 到 &lt;code&gt;/data&lt;/code&gt;。它们类型都叫 ext4，但显然不是同一个文件系统实例。内核里，每个挂起来的文件系统实例，都会有一个 &lt;code&gt;struct super_block&lt;/code&gt;，它大致包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;这个文件系统实例的全局元数据&lt;/li&gt;
&lt;li&gt;根 inode&lt;/li&gt;
&lt;li&gt;block size&lt;/li&gt;
&lt;li&gt;superblock ops&lt;/li&gt;
&lt;li&gt;设备/后端信息&lt;/li&gt;
&lt;li&gt;与这个实例关联的全局状态&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;历史上磁盘文件系统里就有&quot;superblock&quot;这个概念——存储整个文件系统的全局元数据。但在 VFS 中，不是所有文件系统都必须有真实的磁盘 superblock：procfs、tmpfs、sysfs 也都有 VFS 的 super_block 对象，因为它们也需要一个&quot;文件系统实例级&quot;的总管对象。所以更准确地理解，&lt;strong&gt;super_block 就是 VFS 中的 filesystem-instance object&lt;/strong&gt;，而不只是&quot;磁盘 superblock 的镜像&quot;。&lt;/p&gt;
&lt;h3&gt;mount&lt;/h3&gt;
&lt;p&gt;即使你已经有了一个文件系统实例（superblock），它还得被接到当前路径树的某个点上，用户才能通过路径走到它。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;mount /dev/sdb1 /mnt/data
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这件事的本质是：有一个文件系统实例（superblock），把它的根目录接到当前路径树中的 &lt;code&gt;/mnt/data&lt;/code&gt; 这个位置。&lt;strong&gt;mount 回答的是：&quot;这个文件系统实例现在挂在路径树的哪里？&quot;&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;一个具体例子&lt;/h3&gt;
&lt;p&gt;假设你有一个 root ext4（在 &lt;code&gt;/dev/sda1&lt;/code&gt;）、一个 data ext4（在 &lt;code&gt;/dev/sdb1&lt;/code&gt;）、一个 procfs（在 &lt;code&gt;/proc&lt;/code&gt;）。内核看到的大致是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;file_system_type:
  ext4
  proc

superblock instances:
  ext4 instance for /dev/sda1
  ext4 instance for /dev/sdb1
  proc instance

mount tree:
  [ext4:/dev/sda1 root]  mounted at /
  [ext4:/dev/sdb1 root]  mounted at /mnt/data
  [proc root]            mounted at /proc
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;ext4 不是 mount，&lt;code&gt;/dev/sdb1&lt;/code&gt; 不是 mount point，&lt;code&gt;/mnt/data&lt;/code&gt; 也不是文件系统实例本身——它们分别属于类型、实例、接入点。&lt;/p&gt;
&lt;h3&gt;bind mount&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;mount --bind /var/log /tmp/x
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里没有新磁盘、没有新 ext4 实例被创建。它做的是把现有路径树中的某个子树再挂到另一个位置。这说明 &lt;strong&gt;mount 的本质真的不是&quot;打开磁盘分区&quot;，而是&quot;把一个文件系统树里的某个入口，接到当前命名空间的路径树某处&quot;。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;mount 是 Dentry 上的任意门&lt;/h3&gt;
&lt;p&gt;回忆前面的路径解析，如果在 &lt;code&gt;/mnt/data&lt;/code&gt; 挂载了一个新磁盘（新文件系统），发生了什么？&lt;/p&gt;
&lt;p&gt;原始的 &lt;code&gt;/mnt/data&lt;/code&gt; 目录的数据和 dentry 并没有被删除。当你在它上面挂载新系统时，内核实例化了新的文件系统并生成了一个 &lt;code&gt;vfsmount&lt;/code&gt; 对象。内核悄悄在这个原本的 &lt;code&gt;/mnt/data&lt;/code&gt; dentry 上打了一个特殊标记：&lt;code&gt;DCACHE_MOUNTED&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Traverse（路径遍历）的大变法：&lt;/strong&gt;
之后，当路径检索（Path Walk）顺藤摸瓜来到 &lt;code&gt;/mnt/data&lt;/code&gt; 时，内核看到这个 &lt;code&gt;DCACHE_MOUNTED&lt;/code&gt; 标记。它立刻停止读取原先的 inode！取而代之的是，它去查全局 &lt;code&gt;vfsmount&lt;/code&gt; 表，然后&lt;strong&gt;就像把当前路径指针推进了一个“任意门”，瞬间跳转到了新文件系统 &lt;code&gt;super_block&lt;/code&gt; 的根 dentry 继续往下走。&lt;/strong&gt;
你以为你还在原来的房间探索，其实 VFS 早已通过这扇任意门，不露声色地把你传送到另外一个世界了。&lt;/p&gt;
&lt;p&gt;当我们执行 &lt;code&gt;umount&lt;/code&gt; 时，内核就把这扇门（和挂载标记）拆除，你再次访问 &lt;code&gt;/mnt/data&lt;/code&gt;，便又能看到原来的数据了。&lt;/p&gt;
&lt;p&gt;这种设计通过 &lt;code&gt;mount namespace&lt;/code&gt; 还能做到隔离——它可以限制“任意进程只能看到某一套挂载关系”。mount namespace 可以理解成&quot;每个进程看到的挂载树视图&quot;。不同进程可以拥有不同的根目录、不同的 &lt;code&gt;/proc&lt;/code&gt; 挂载、不同的 &lt;code&gt;/mnt/data&lt;/code&gt; 内容。&lt;/p&gt;
&lt;p&gt;所以同一个路径字符串 &lt;code&gt;/etc/passwd&lt;/code&gt;，在不同 mount namespace 里可能走到不同的 inode。这也是容器技术非常关键的一层能力——路径字符串可以一样，但看到的挂载世界完全不同。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;mount namespace 决定&quot;路径解析过程中，哪些 mount 会发生、会切到哪棵树&quot;。&lt;/strong&gt; lookup 穿越 mount point 时，会查当前 namespace 的挂载关系，这不是抽象意义上的影响，而是非常直接的。&lt;/p&gt;
&lt;h3&gt;path = (vfsmount, dentry)&lt;/h3&gt;
&lt;p&gt;在内核中，一个路径位置经常不是单独一个 dentry 就能完整表达的。因为 mount crossing 会改变你所处的文件系统实例，所以内核里经常用这种组合来表示一个路径位置：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;path = (mount context, dentry)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;一个路径位置不只是某个 dentry，还要知道它属于哪次挂载视图/哪棵挂载子树。这也是 pathname lookup 天然和 mount namespace 绑在一起的原因。&lt;/p&gt;
&lt;h3&gt;层次总图&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;file_system_type
  -&gt; ext4 / xfs / tmpfs / proc ...

super_block
  -&gt; 某个具体文件系统实例

mount
  -&gt; 把这个实例的根接到路径树某个位置

namespace
  -&gt; 决定一个进程看到哪棵挂载树

within a mounted filesystem:
  -&gt; dentry / inode / file
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;类型、实例、接入点、视图、对象模型——五层分得清清楚楚。&lt;/p&gt;
&lt;h2&gt;VFS 回顾&lt;/h2&gt;
&lt;p&gt;我们把所有关键认知汇总一下：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;VFS 不是具体文件系统，而是统一抽象层。&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;inode 是文件本体&lt;/strong&gt;，关心元数据和文件对象身份。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;dentry 是目录项&lt;/strong&gt;，解决&quot;这个目录下这个名字指向谁&quot;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;file 是一次打开实例&lt;/strong&gt;，带 &lt;code&gt;f_pos&lt;/code&gt; 和打开状态。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;fd 只是当前进程 fd table 的整数索引&lt;/strong&gt;，不是文件本身。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;pathname lookup 是逐段解析&lt;/strong&gt;，不是把整个字符串一次性映射成对象。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;三层缓存各司其职&lt;/strong&gt;：dentry cache 缓存名字解析结果，inode cache 缓存文件本体对象，page cache 缓存文件内容页。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;open()&lt;/code&gt; 的核心不是读内容&lt;/strong&gt;，而是路径解析 → 检查 → 创建 struct file → 安装进 fd table。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;read()&lt;/code&gt; / &lt;code&gt;write()&lt;/code&gt; / &lt;code&gt;mmap()&lt;/code&gt; 都从 fd -&gt; file 出发&lt;/strong&gt;，但内容访问模型不同：read 是 copy model，write 先写入页缓存再写回，mmap 是 mapping model。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;file-backed VMA 和 page cache 把 mm 和 VFS 接到了一起。&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;mount point 是 pathname lookup 中的&quot;切树点&quot;。&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;file_system_type 是类型，super_block 是实例，mount 是接入路径树的动作。&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;mount namespace 决定一个进程看到的挂载树视图。&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;配合着前面所讲的，&quot;进程 + 内存 + 文件&quot;——Linux 内核三大核心骨架就基本齐了。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>Learning Linux Kernel (Part 4) - Memory Management</title><link>https://theunknownth.ing/blog/linux-kernel-4</link><guid isPermaLink="true">https://theunknownth.ing/blog/linux-kernel-4</guid><description>In this chapter, we will continue to explore the memory management mechanism of the Linux kernel.</description><pubDate>Sat, 28 Mar 2026 17:57:00 GMT</pubDate><content:encoded>&lt;h2&gt;前言&lt;/h2&gt;
&lt;p&gt;在前面的章节中，我们已经对 Linux 内核的核心对象和机制有了初步的了解。我们从内核的启动过程讲起，介绍了内核是如何从一个简单的引导程序逐步构建出一个功能完整的操作系统的。在接下来的章节里，我们将继续深入探讨 Linux 内核的内存管理机制。如果你还没有阅读上一章节，建议先阅读 &lt;a href=&quot;/blog/linux-kernel-3&quot;&gt;Linux Kernel (Part 3) - Task and Scheduler&lt;/a&gt;。&lt;/p&gt;
&lt;h2&gt;Disclaimer&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;我本次使用了 AI 来带我学习 Linux 内核。所以无论我后续怎么整理润色，本文的 first draft 肯定是 AI 告诉我的内容&lt;/strong&gt;。如果你对此感到无法接受，或者觉得 AI 讲得不够好，你可以随时退出阅读，或者自己去看 Linux 内核的源码和文档。我的目标是通过 AI 来帮我梳理和总结内核的知识体系，而不是替代你自己去接触原始资料。&lt;/p&gt;
&lt;p&gt;如果你能接受这个前提，那么我们就继续往下走了。&lt;/p&gt;
&lt;h2&gt;内存管理到底在解决什么问题？&lt;/h2&gt;
&lt;p&gt;学 Linux 内存管理，切忌一上来就陷入页表细节或者 &lt;code&gt;malloc&lt;/code&gt;/&lt;code&gt;free&lt;/code&gt; 的实现。更合适的方式是先按三层模型来理解它的全局结构：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;第一层：进程看到的虚拟地址空间&lt;/strong&gt; —— 每个进程“以为”自己在用什么内存？&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;第二层：页表 + MMU/TLB&lt;/strong&gt; —— CPU 怎么把虚拟地址翻译成真实的物理地址？&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;第三层：内核管理的物理页&lt;/strong&gt; —— 内核到底怎么分配、回收、缓存、搬运这些物理页？&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这三层就是 mm 的主骨架。理解了它们各自的职责以及彼此的连接方式，后面学到的 &lt;code&gt;mmap&lt;/code&gt;、&lt;code&gt;fork&lt;/code&gt;、page fault、page cache、reclaim 等等，就都有了坐标可循。&lt;/p&gt;
&lt;p&gt;从职责的角度看，mm 子系统本质上同时在做三件事：&lt;strong&gt;虚拟化&lt;/strong&gt;（给每个进程一个看起来独立、连续、巨大的地址空间）、&lt;strong&gt;保护&lt;/strong&gt;（让进程彼此隔离，限制读/写/执行权限）、&lt;strong&gt;复用&lt;/strong&gt;（在有限的物理内存上按需分配、回收、缓存文件内容、swap、NUMA 放置）。所以 mm 不是“管 &lt;code&gt;malloc&lt;/code&gt; 的模块”，而是一整套“地址空间 + 物理页 + 缓存 + 回收”系统。&lt;/p&gt;
&lt;h2&gt;虚拟内存&lt;/h2&gt;
&lt;p&gt;每个进程看到的是虚拟地址。当用户程序执行 &lt;code&gt;char *p = malloc(4096); p[0] = &apos;A&apos;;&lt;/code&gt; 时，它以为自己在操作某个内存地址——但这个地址不是物理 RAM 地址，而是当前进程地址空间里的一个虚拟地址。CPU 真正访问内存时，会通过 page table、MMU 和 TLB 把它翻译成某个物理页。&lt;/p&gt;
&lt;p&gt;所以你要把内存访问理解成两步：&lt;code&gt;虚拟地址 -&gt; 页表翻译 -&gt; 物理地址&lt;/code&gt;。这就是为什么两个进程都可以有地址 &lt;code&gt;0x400000&lt;/code&gt;，但它们背后可以是完全不同的物理页——虚拟内存给了每个进程一个独立的地址空间幻觉，而翻译机制负责把幻觉“兑现”成真实的物理位置。&lt;/p&gt;
&lt;h2&gt;mm_struct 和 VMA&lt;/h2&gt;
&lt;h3&gt;mm_struct&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;mm_struct&lt;/code&gt; 是一个进程的地址空间对象。它关心的是：这个进程有哪些虚拟内存区域、页表根在哪里、堆/栈/mmap 区的大致布局，以及各种和地址空间相关的元数据和统计。如果说 &lt;code&gt;task_struct&lt;/code&gt; 代表执行流，那么 &lt;code&gt;mm_struct&lt;/code&gt; 就是它看到的“虚拟内存宇宙”。如果多个 task 共享同一个 &lt;code&gt;mm_struct&lt;/code&gt;，那就是线程语义——共享地址空间。&lt;/p&gt;
&lt;h3&gt;VMA&lt;/h3&gt;
&lt;p&gt;你不能把 &lt;code&gt;mm_struct&lt;/code&gt; 理解成“一整块平坦大内存”。一个进程的地址空间其实是由很多段区域组成的，每段由一个 &lt;code&gt;vm_area_struct&lt;/code&gt;（通常简称 VMA）来描述。你可以把一个 VMA 理解成：地址空间里一段连续的、具有统一属性的虚拟内存区域。&lt;/p&gt;
&lt;p&gt;一个典型进程里常见的 VMA 包括：代码段、数据段、堆、栈、共享库映射、&lt;code&gt;mmap()&lt;/code&gt; 映射的文件或匿名区。每个 VMA 会带上起止虚拟地址、权限（R/W/X）、是匿名内存还是文件映射、对应哪个 file/offset 等属性。所以一个进程的地址空间更像这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;mm_struct
  -&gt; VMA #1: text/code
  -&gt; VMA #2: data/bss
  -&gt; VMA #3: heap
  -&gt; VMA #4: mmap file
  -&gt; VMA #5: stack
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;理解 VMA 非常关键，因为以后你会发现 &lt;code&gt;mmap&lt;/code&gt;、&lt;code&gt;fork&lt;/code&gt;、page fault、文件映射、&lt;code&gt;munmap&lt;/code&gt; 全都围绕 VMA 打转。&lt;/p&gt;
&lt;h3&gt;两者的关系&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;mm_struct&lt;/code&gt; 不是“页表本身”，而是地址空间的总控对象。它下面挂着 VMA 组织、页表根、各类统计/锁/引用计数。而 VMA 则在说：哪些虚拟地址范围存在，以及范围的属性是什么。你可以把它们记成：&lt;strong&gt;mm_struct = 地址空间总管，VMA = 地址空间里的具体分区。&lt;/strong&gt; 后面你一旦搞清 &lt;code&gt;fork&lt;/code&gt; 改了什么、&lt;code&gt;mmap&lt;/code&gt; 加了什么、&lt;code&gt;munmap&lt;/code&gt; 删了什么、page fault 查了什么，就会发现几乎都绕不开这两个对象。&lt;/p&gt;
&lt;h2&gt;页表、MMU 与 TLB&lt;/h2&gt;
&lt;p&gt;这是 mm 里最有“硬件接口感”的一层。最核心的一句话是：CPU 并不直接拿虚拟地址去访问 RAM，它会先通过 MMU 查 page table，命中或未命中 TLB，把虚拟地址翻译成物理地址，然后才真正访问内存。这三者你必须作为一组来理解。&lt;/p&gt;
&lt;h3&gt;Page Table&lt;/h3&gt;
&lt;p&gt;Page table 是“虚拟页 -&gt; 物理页”映射规则的表。注意粒度是“页”级别，不是每个字节都单独映射。以 4KB 页为例，CPU 访问虚拟地址时会先把它拆成虚拟页号（VPN）和页内偏移（offset），然后查页表回答：这个虚拟页号映射到哪个物理页框（PFN）？权限是什么？这页在不在？能不能读/写/执行？页表项不只是“地址映射”，还带 present/valid、R/W/X 权限、user/supervisor、dirty/accessed 等保护和状态信息。&lt;/p&gt;
&lt;p&gt;因为虚拟地址空间很大，平铺一张大表会浪费得离谱，所以现实中通常用多级页表——用树状结构按需展开。不需要的地址范围就不建那些页表页，本质上在解决大地址空间下页表空间开销太大的问题。&lt;/p&gt;
&lt;h3&gt;MMU&lt;/h3&gt;
&lt;p&gt;MMU（Memory Management Unit）是 CPU 里负责地址翻译和权限检查的那块硬件。CPU 执行 load/store/fetch 指令时，MMU 会根据当前页表根做地址翻译，同时检查权限。如果失败就触发 page fault 或 permission fault。所以页表是数据结构，MMU 是使用这套数据结构的硬件——地址翻译是 CPU 执行内存访问路径上的硬件能力，跟“软件查哈希表”完全不是一个层级的事情。&lt;/p&gt;
&lt;h3&gt;TLB&lt;/h3&gt;
&lt;p&gt;TLB（Translation Lookaside Buffer）是最关键也最容易被低估的东西。你可以把它理解成页表翻译结果的高速缓存。因为 CPU 不可能每次访存都一层层查多级页表——那会太慢——所以它把最近用过的“虚拟页号 -&gt; 物理页号 + 权限”缓存到 TLB 里。TLB hit 时很快就拿到翻译结果，无需完整 page walk；TLB miss 时就要走页表查找，把结果再填回 TLB，成本更高。&lt;/p&gt;
&lt;p&gt;很多内存访问性能问题，不只是 RAM 快不快，而是 TLB locality 好不好。TLB 对调度器也很重要：context switch 如果切换了地址空间（&lt;code&gt;prev-&gt;mm != next-&gt;mm&lt;/code&gt;），当前 CPU 上缓存的旧地址翻译就不再有用，直接影响 TLB 命中率和地址空间切换成本。这也是为什么同进程线程切换通常比跨进程切换更轻——scheduler 和 mm 在 CPU 上其实通过 TLB 紧紧耦合。&lt;/p&gt;
&lt;h3&gt;CR3 / satp&lt;/h3&gt;
&lt;p&gt;CPU 必须知道当前该用哪套页表。这个“页表根”的位置通常由架构特定寄存器给出——x86 上是 CR3，RISC-V 上是 satp。切换地址空间时，内核本质上就是把当前页表根寄存器切到另一个 &lt;code&gt;mm&lt;/code&gt; 的页表，这是进程切换比线程切换更重的一大原因。&lt;/p&gt;
&lt;h3&gt;权限检查发生在哪里&lt;/h3&gt;
&lt;p&gt;在 MMU 翻译过程中。不是“软件事后再看看有没有越权”。用户态访问某页时，如果页表项不允许 user 访问、或不允许写、或 NX 不允许执行，MMU 在翻译/访问时就会发现问题并产生 fault。&lt;strong&gt;虚拟内存保护不是“程序员自觉”，也不是“内核事后审计”，而是 CPU 硬件在执行访存时实时强制的。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;不过这里需要一个重要的限定。更严谨的说法应该是：&lt;strong&gt;在架构语义上&lt;/strong&gt;，CPU 会在访存时强制执行页表权限和地址空间保护。之所以要加这个限定，是因为现代 CPU 有 speculative execution、out-of-order、aggressive prefetch 等微架构行为。从“架构结果”上看，非法访问不会被正式提交；但在“微架构副作用”上，某些本不该暴露的信息可能通过 cache/predictor/buffer 状态泄漏出来——Spectre 和 Meltdown 类攻击的核心正在于此。它们不是说“权限机制在架构上完全失效了”，而是在权限检查真正收敛并提交前，CPU 的瞬时/speculative 行为已经留下了可侧信道观测的痕迹。&lt;/p&gt;
&lt;p&gt;更具体地说，Spectre 更像是“欺骗分支预测器/speculation，让 CPU 瞬时执行本不该走的路径，再通过 cache side channel 泄漏数据”；Meltdown 则更像是“某些 CPU 上权限检查和数据取回的时序处理有缺陷，瞬时能把本不该读到的内核数据带进微架构状态——虽然架构上最终会 fault，但侧信道已经泄漏了信息”。所以可以这样记：架构上保护仍然成立，微架构上可能先泄漏、再被回滚。这也是后来 KPTI、retpoline、IBRS/IBPB、speculation barrier 等一系列软硬件修复存在的原因。&lt;/p&gt;
&lt;h3&gt;VMA 和 page table 的区别&lt;/h3&gt;
&lt;p&gt;这是初学者最容易混淆的点。VMA 说的是“这段虚拟地址范围是合法区域吗？语义是什么？”，page table 说的是“这个具体虚拟页当前映射到哪里？”。所以可能出现这种情况：VMA 已经存在，但某个具体页表项还没建立；第一次访问时触发 fault，内核再补上映射。这就是按需分配、懒加载、文件映射、COW 能成立的基础。&lt;strong&gt;VMA 是 zoning plan，page table 是当前具体住户表。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;Page Walk&lt;/h3&gt;
&lt;p&gt;当 TLB miss 时，MMU 需要执行 page walk——按虚拟地址的若干段索引，一层层查多级页表，最终找到叶子页表项，取得物理页框号（PFN）和权限信息，再把翻译结果填进 TLB。如果在某一级就发现没有有效项或者权限不允许，那就触发 fault。所以 page walk 是页表查找的硬件化过程，它的频率和深度直接影响内存访问性能。&lt;/p&gt;
&lt;h3&gt;一次内存访问的完整路径&lt;/h3&gt;
&lt;p&gt;现在你可以把一次普通 load 想成这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CPU 执行 load [virtual address]
 -&gt; 先查 TLB
    -&gt; hit: 直接拿到物理页翻译
    -&gt; miss: MMU page walk 查页表
        -&gt; 若成功：填 TLB，再访问物理内存
        -&gt; 若失败：触发 page fault，进内核
 -&gt; 真正访问 cache / RAM
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这张图把 TLB、page table、MMU、page fault、内核介入时机全串起来了。真正的内存访问路径不是“CPU 直接问内存”，而是 &lt;code&gt;CPU -&gt; TLB/MMU -&gt; cache/memory -&gt; maybe kernel on fault&lt;/code&gt;。这就是为什么系统调优里常常会关心 TLB miss rate、page fault rate、NUMA locality、huge pages。&lt;/p&gt;
&lt;h2&gt;Huge Page&lt;/h2&gt;
&lt;p&gt;在讲 page fault 之前，值得先岔开说说 huge page，因为它和 TLB 的关系非常直接。&lt;/p&gt;
&lt;p&gt;如果普通页是 4KB，那 huge page 通常是 2MB 甚至 1GB。核心动机就一个：降低 TLB 压力。TLB entry 数量很有限，如果全用 4KB 页，映射 1GB 内存需要大量页表项和 TLB entry；如果用 2MB 页，同样大小的内存只需要少得多的 TLB entry。因此 huge page 的收益通常是更少的 TLB miss、更少的 page walk、更少的页表内存开销，对数据库、JVM、大型 in-memory 服务、HPC、虚拟化等大内存连续访问的 workload 尤其明显。&lt;/p&gt;
&lt;p&gt;Linux 里有两种常见的 huge page 路线。第一种是 &lt;strong&gt;hugetlb/hugetlbfs&lt;/strong&gt;，属于“手动挡”——通常需要提前预留，更可控，适合你非常明确地知道自己要用大页且想要确定性的场景。第二种是 &lt;strong&gt;THP（Transparent Huge Pages）&lt;/strong&gt;，属于“自动挡”——内核尽量自动把合适的匿名内存折叠成大页，应用不用改代码。&lt;/p&gt;
&lt;p&gt;但 huge page 不是白来的。它的代价包括：&lt;strong&gt;内存碎片更敏感&lt;/strong&gt;（2MB 大页需要足够连续且对齐的物理内存，系统跑久了碎片化后分配更难）；&lt;strong&gt;内部碎片&lt;/strong&gt;（如果只用到其中一小部分，大页会浪费更多空间）；&lt;strong&gt;fault/reclaim/migration 成本更大&lt;/strong&gt;（一旦按大页做，fault 成本更高，回收更重，NUMA 迁移更重，COW 粒度也变粗）；&lt;strong&gt;延迟可能更抖&lt;/strong&gt;（尤其 THP 有时为了折叠、分裂、整理内存会带来 latency spike，所以很多 latency-sensitive 服务会谨慎看待 THP）。&lt;/p&gt;
&lt;p&gt;特别值得记住的是 huge page 和 COW/fork 的关系：如果一个大页参与了 fork + COW，写时复制粒度变大，内核可能需要 split huge page，否则复制成本和浪费都会很夸张。一句话总结：&lt;strong&gt;huge page 用更粗的页粒度换更好的 TLB 覆盖和更低的翻译开销，但代价是更高的内存管理复杂性和潜在的碎片/延迟问题。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;Page Fault&lt;/h2&gt;
&lt;p&gt;Page fault 在 mm 里如此核心，因为它是虚拟内存延迟分配/延迟映射的核心执行点。当 CPU 访问某虚拟地址时，如果页表里没有有效映射、权限不允许、需要处理 COW、或者需要把文件页真正拉进内存，就会产生 page fault，然后内核来决定这是合法但尚未兑现的访问（修复并继续），还是非法访问（SIGSEGV）。&lt;/p&gt;
&lt;h3&gt;Page fault 的四大类别&lt;/h3&gt;
&lt;p&gt;对于 page fault 的处理，内核大体上有四种不同的路径：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A. 匿名页按需分配（demand-zero fault）。&lt;/strong&gt; 最常见的一类。典型来源是 &lt;code&gt;malloc()&lt;/code&gt;、&lt;code&gt;mmap(MAP_ANONYMOUS)&lt;/code&gt;、栈增长。地址空间先被“声明出来”，但物理页不立刻分配。VMA 已经存在，但 PTE 还没真正指向物理页，第一次访问时才 fault。比如 &lt;code&gt;char *p = malloc(4096 * 1000); p[0] = 1;&lt;/code&gt;，&lt;code&gt;malloc()&lt;/code&gt; 往往只是让用户态拿到一段可用虚拟地址，真正第一次写 &lt;code&gt;p[0]&lt;/code&gt; 时才触发 fault。内核的处理大致是：找到对应的匿名 VMA，分配一个物理页，清零，建立 PTE，设置权限位，返回用户态让原指令重试。因为新分配给用户的匿名页语义上必须像全 0 一样开始（安全原因：不能把别的进程旧数据泄漏给你），所以这类页常被叫做 demand-zero page。栈增长本质上也属于这一路——栈 VMA 在那儿，真正往下踩到新页时 fault，内核确认是合法的栈增长，给它补页。&lt;/p&gt;
&lt;p&gt;这里有一个值得一提的优化：在某些场景下，第一次“只读”访问匿名页时，内核可能先用共享的 zero page 做映射；等第一次写入时再真正分配私有物理页。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;B. 文件页按需装入。&lt;/strong&gt; 例如 &lt;code&gt;mmap&lt;/code&gt; 文件后第一次访问、page cache 还没准备好时。内核需要把对应文件内容装进内存，再建立映射。这个我们在 page cache 那一节会详细展开。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;C. COW fault（Copy-on-Write）。&lt;/strong&gt; 例如 &lt;code&gt;fork()&lt;/code&gt; 后父子共享页，页先被设成只读，某一方第一次写时 fault，内核复制页并给写入方新的可写页。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;D. 非法访问 / 权限 fault。&lt;/strong&gt; 访问不存在的地址、用户态碰内核地址、写只读页、执行 NX 页——这类 fault 不能被正常修复，通常以 SIGSEGV 或类似异常告终。&lt;/p&gt;
&lt;h3&gt;Fault 路径的关键步骤&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;第一步：先看 VMA。&lt;/strong&gt; 内核一拿到 fault 地址，先回答：这个地址在当前 &lt;code&gt;mm&lt;/code&gt; 里是否落在某个合法 VMA 内？不在的话，大概率是非法访问，直接走 bad fault 路径。在的话，再看这个 VMA 的权限和类型——允许读吗？允许写吗？是匿名页还是文件映射？是不是 COW 场景？VMA 是 fault 处理的第一道软件语义边界：硬件只知道“翻译失败/权限失败”，内核靠 VMA 判断这是合法的还是非法的、如果合法该怎么补救。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第二步：根据类型走不同修复路径。&lt;/strong&gt; 匿名页第一次访问就分配物理页、清零、建页表项；文件映射就找到对应 file/offset、把页装进 page cache、建页表项；COW 就分配新页、拷旧页内容、改成新页可写映射；权限不允许就直接 bad fault。所以 page fault 不是一个单一路径，而是一个统一入口加多种后端处理逻辑。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第三步：修好页表，返回，重新执行那条指令。&lt;/strong&gt; 这是 page fault 最优美的地方。如果 fault 可修复，内核修好映射/权限，返回用户态，CPU 重新执行原来那条失败的指令——这次成功。对用户程序来说，很多 fault 是完全“透明”的，它根本感觉不到，除了性能上可能慢一下。&lt;strong&gt;Page fault 不等于错误，很多 fault 是虚拟内存正常工作的一部分。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;匿名页 Fault 与 COW Fault&lt;/h2&gt;
&lt;h3&gt;COW 的机制&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;fork()&lt;/code&gt; 之后，最理想的实现不是立刻把所有物理页复制一遍——那太贵了。Linux 会做的是：父子先共享同一批物理页，对应页表项先改成只读，页的引用计数增加。逻辑上它们“看起来各自有一份”，真到某一方写时再复制。这就是 Copy-on-Write。&lt;/p&gt;
&lt;p&gt;为什么要改成只读？因为内核必须“截获第一次写”。如果不改成只读，用户进程写下去时 CPU 不会 fault，内核就没机会知道“该复制了”。所以这里的只读不是因为语义上真的不允许写，而是内核故意用页保护制造一个可拦截的写 fault。&lt;/p&gt;
&lt;p&gt;假设父子共享某页，某一方执行 &lt;code&gt;p[0] = 42;&lt;/code&gt;。硬件看到当前 PTE 不允许写，于是 page fault 进内核。内核查 VMA 后发现：这段地址从进程语义上是可写的，只是当前 PTE 被设成只读，且属于 COW 场景。于是内核分配新页、复制旧页内容、让当前写入方的 PTE 指向新页并设成可写、旧共享页引用计数减一、返回用户态重试。父子从这一页开始分家，只有真正被写到的页才复制——这就是 &lt;code&gt;fork()&lt;/code&gt; 可以便宜的原因。&lt;/p&gt;
&lt;p&gt;COW 不只发生在 &lt;code&gt;fork()&lt;/code&gt; 后的匿名页上。&lt;code&gt;MAP_PRIVATE&lt;/code&gt; 文件映射本质上也有类似语义：读可以共享底层文件页，写入时不能直接改共享文件页，需要给写入方私有副本。所以 COW 是一种更通用的策略：先共享只读底层页，写时再私有化。&lt;/p&gt;
&lt;h3&gt;区别是什么？&lt;/h3&gt;
&lt;p&gt;这两个很容易混淆但本质不同。匿名页首次 fault：之前根本没有这页的私有物理页，现在第一次真正分配——是“首次兑现”。而 COW fault：之前已经有物理页，只是和别人共享，现在因为写入需要“拆分共享关系”——是“共享后分家”。&lt;/p&gt;
&lt;h3&gt;TLB 与 COW 的关系&lt;/h3&gt;
&lt;p&gt;这里有一个细节值得展开。TLB 里通常不只缓存 &lt;code&gt;VPN -&gt; PPN&lt;/code&gt; 的翻译，还会缓存权限信息（readable/writable/executable 等）。所以当 CPU 执行写操作时，如果页在 TLB 里已有 entry，CPU 可以直接根据 TLB 里的权限判断这是只读页、当前写入不允许，触发 protection fault。&lt;/p&gt;
&lt;p&gt;那操作系统怎么“改 TLB”？最重要的结论是：操作系统通常不是直接去改某个 TLB entry 的内容，而是&lt;strong&gt;修改页表，让旧 TLB entry 失效，之后由硬件重新按新页表填 TLB&lt;/strong&gt;。cache 改不过来的经典做法就是 invalidate/flush。x86 上有 &lt;code&gt;invlpg&lt;/code&gt; 或 reload CR3，RISC-V 上有 &lt;code&gt;SFENCE.VMA&lt;/code&gt;，ARM 上有对应的 TLBI 指令族。在 SMP 上还更麻烦，因为同一个进程可能在多个 CPU 上跑过，那些 CPU 的 TLB 里都可能缓存了旧权限，所以内核有时要做 TLB shootdown——通知别的 CPU 把对应地址空间/页的旧 TLB 项作废。这也是页表权限修改在&lt;strong&gt;多核&lt;/strong&gt;上不便宜的原因之一。&lt;/p&gt;
&lt;h3&gt;和 NUMA / First-Touch 的关系&lt;/h3&gt;
&lt;p&gt;匿名页首次 fault 时，分配发生在 fault 当下。所以哪个线程、在哪个 CPU、在哪个 NUMA node 第一次碰这页，会直接影响这页最终落在哪个 node。这就是 NUMA 和 mm 的真正连接点之一——很多并行程序会让“将来谁用这块数据，谁先初始化它”，背后不仅是 cache locality，也是 NUMA first-touch。&lt;/p&gt;
&lt;h3&gt;为什么我们单独讲这两个 Fault&lt;/h3&gt;
&lt;p&gt;因为它们直接决定了几个常见的性能现象：大内存程序启动时不一定立刻吃满物理内存（因为大部分页还没被 first-touch）；&lt;code&gt;fork()&lt;/code&gt; 表面上很快（因为不立刻复制）；某些工作负载在“第一次触摸内存”时有明显延迟（demand-zero fault 的开销）；写放大可能按“页粒度”发生（COW 每次至少复制一整页）。例如一个进程 &lt;code&gt;fork()&lt;/code&gt; 出很多子进程但几乎不写，共享成本很低；一旦每个子进程都大量写共享页，就会触发密集的 COW fault，性能开销随之上升。&lt;/p&gt;
&lt;h2&gt;你可能会问&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;“我写 C++ 的时候，所有人都告诉我说 ‘未初始化变量是 UB’，不能读它；但 Linux 内核里匿名页第一次分配时必须清零，感觉好像是‘未初始化变量默认从 0 开始’？这两者不是矛盾吗？”&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这个问题值得单独拎出来说一下，因为它涉及两个不同层次的事情。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;内核/硬件层：&lt;/strong&gt; Linux 给用户进程一个全新的匿名物理页时，这页必须表现得像全 0 开始。原因主要是安全——不能把别的进程的旧数据泄漏给你——以及语义要求（BSS、新栈页、&lt;code&gt;mmap(MAP_ANONYMOUS)&lt;/code&gt; 页都应从零开始）。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;C/C++ 语言层：&lt;/strong&gt; “未初始化变量是 UB” 说的是这个对象在语言语义上没有被初始化，编译器不保证你读它是合法的——即使底层那几个字节碰巧是 0，也不改变语言规则。物理字节碰巧是 0，不等于这个 C++ 对象在语言层被初始化了。&lt;/p&gt;
&lt;p&gt;更关键的一点是：你平时遇到的大多数“未初始化变量”根本不是“新页第一次分配”。栈上的局部变量所在位置可能早就被之前的函数调用反复用过了，读到的不是“OS 刚给的新页”而是同一进程栈上的旧内容。&lt;code&gt;malloc&lt;/code&gt;/&lt;code&gt;new&lt;/code&gt; 得到的往往是进程自己以前 &lt;code&gt;free&lt;/code&gt; 掉并被用户态 allocator 复用的堆块，不会自动清零（这就是为什么 &lt;code&gt;calloc()&lt;/code&gt; 才保证零初始化）。就算字节真是 0，语言语义仍可能认为你在读 indeterminate value。&lt;/p&gt;
&lt;p&gt;所以说如果用一句话总结的话，那就是：&lt;strong&gt;OS 保证新匿名页的字节语义通常从 0 开始；C/C++ 只在对象被正确初始化后才保证读取有定义。&lt;/strong&gt; 两句话同时都对。&lt;/p&gt;
&lt;h2&gt;文件映射 Fault 与 Page Cache&lt;/h2&gt;
&lt;h3&gt;Page Cache&lt;/h3&gt;
&lt;p&gt;Linux 里，文件内容常常先进入 page cache，再以不同方式暴露给进程。Page cache 是内核用物理内存缓存文件内容的地方——文件的某些页内容被读进 RAM 后，以后再次访问同一部分时就不必重新从磁盘读。这些缓存页按“文件 + offset -&gt; page”的方式组织。Linux 内存管理不只服务匿名页（栈、堆），还大量参与文件缓存——内存里很多页不是“某个进程 &lt;code&gt;malloc&lt;/code&gt; 出来的匿名页”，而是某个文件内容的缓存页。&lt;/p&gt;
&lt;h3&gt;read() 和 mmap()&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;read()&lt;/code&gt; 路径中，内核从 fd 找到 file，走 VFS/文件系统，看这段文件对应的页在不在 page cache。在的话直接从 page cache 拿数据，不在的话发起 I/O 把文件页装进 page cache，最后再把数据从 page cache 拷到用户 buffer。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;mmap(file)&lt;/code&gt; 路径中，&lt;code&gt;mmap()&lt;/code&gt; 本身通常只是在当前 &lt;code&gt;mm&lt;/code&gt; 里建一个 file-backed VMA，记录这段地址对应哪个 file、对应文件哪个 offset、权限是什么。此时往往还没真正把文件内容都搬进内存。真正第一次访问时，CPU 发现对应页表项还没准备好，page fault 进内核；内核查 VMA 确认是合法的 file mapping，根据 file + offset 找对应 page cache 页（如果没有则发 I/O 读进来），然后把 page cache 页映射进进程页表，返回用户态重试原指令。&lt;/p&gt;
&lt;p&gt;我们总结一下它们的路径：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;file on disk
 -&gt; page cache page in RAM
    -&gt; read():  copy page cache data -&gt; user buffer
    -&gt; mmap():  map page cache page  -&gt; user page table
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它们的共同点是常常都依赖 page cache，都可能触发从磁盘读文件页。它们的区别是：&lt;strong&gt;read() 是 copy model&lt;/strong&gt;——需要一次显式 copy 到用户空间；&lt;strong&gt;mmap() 是 mapping model&lt;/strong&gt;——page cache 页直接映射进用户地址空间，少了一次 copy，但 fault 和页映射更重要。不要误解成“mmap() 永远 zero-copy 万能更快”——它只是把问题转成 fault、映射、页粒度行为、一致性/回写/失效等。&lt;/p&gt;
&lt;h3&gt;文件页 fault 为什么可能很慢&lt;/h3&gt;
&lt;p&gt;因为它可能意味着真正的磁盘 I/O。匿名页首次 fault 常常只需要分配和清零一个页；文件映射 fault 可能还要包含从磁盘读取文件内容的延迟。整条路径是：fault -&gt; 进内核 -&gt; 找 page cache -&gt; miss -&gt; 发磁盘 I/O -&gt; 等 I/O 完成 -&gt; 页进 page cache -&gt; 建立页表映射 -&gt; 返回用户态。&lt;/p&gt;
&lt;p&gt;我们可以大致把文件页 fault 的成本分成两部分：&lt;strong&gt;内核处理 fault 的 CPU 成本&lt;/strong&gt;（查 VMA、查 page cache、建页表项等）和&lt;strong&gt;磁盘 I/O 的等待成本&lt;/strong&gt;（如果 page cache miss）。前者通常是几微秒级别，后者可能是毫秒级别甚至更高。所以文件映射的 page fault 性能可能非常不稳定，取决于 page cache 命中率和底层存储性能。&lt;/p&gt;
&lt;h3&gt;脏页与写回&lt;/h3&gt;
&lt;p&gt;如果是可写的 file-backed mapping，你改的是内存里的 page cache 页，不代表磁盘已经立刻写了。系统还需要标记页为 dirty，以后异步写回到磁盘。文件映射不是“我一写内存，磁盘就同步改了”，中间还有页缓存一致性、回写时机、&lt;code&gt;msync()&lt;/code&gt; 等机制。先记住主线：&lt;strong&gt;page cache 页可能变脏，脏页以后要写回文件。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;MAP_SHARED 与 MAP_PRIVATE&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;MAP_SHARED&lt;/code&gt;：你对映射页的修改语义上是共享的，最终应反映回底层文件，其它映射同一文件区域的人也可能看到变化。&lt;code&gt;MAP_PRIVATE&lt;/code&gt;：你看到的是“私有视图”，写时触发 COW，修改不会直接改到底层共享文件页。这和前面讲的 COW 正好接上——file-backed mapping fault 不只是“从文件进内存”，还要看它是 shared 还是 private、写时是否需要私有化。&lt;/p&gt;
&lt;h3&gt;Page Cache 是 mm 和 VFS 的交界&lt;/h3&gt;
&lt;p&gt;从文件系统看，page cache 缓存的是文件内容。从 mm 看，page cache 页是物理页，可以被映射到用户页表、参与 reclaim、脏页回写、受 NUMA/huge page/page fault 路径影响。它两边都算，这也是为什么 &lt;strong&gt;mm 和 VFS 不是两章彼此独立的知识，而是中间有 page cache 这条大桥。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;VMA 的统一抽象&lt;/h2&gt;
&lt;p&gt;理解匿名 VMA 通常更直觉——背后对应的就是“真实的内存”。但 file-backed VMA 可能让人“心里有点膈应”：操作系统是怎么表示“背后对应什么文件、从哪里开始读”的？&lt;/p&gt;
&lt;p&gt;答案是：VMA 本身只表示“这段虚拟地址范围的规则”。不管哪种 VMA，至少都会有虚拟地址起点（&lt;code&gt;vm_start&lt;/code&gt;）、终点（&lt;code&gt;vm_end&lt;/code&gt;）、权限/标志（&lt;code&gt;vm_flags&lt;/code&gt;）、属于哪个 &lt;code&gt;mm&lt;/code&gt;。如果它是 file-backed，还会额外带上指向 &lt;code&gt;struct file&lt;/code&gt; 的指针（&lt;code&gt;vm_file&lt;/code&gt;）和文件页偏移（&lt;code&gt;vm_pgoff&lt;/code&gt;）。所以 file-backed VMA 不是“背后直接塞了一堆文件内容”，而是一条地址区间到文件区间的映射规则。如果某个 fault 地址是 &lt;code&gt;addr&lt;/code&gt;，对应的文件偏移大致就是 &lt;code&gt;(addr - vm_start) + (vm_pgoff &amp;#x3C;&amp;#x3C; PAGE_SHIFT)&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;匿名 VMA 通常没有 &lt;code&gt;vm_file&lt;/code&gt;，缺页时按匿名页逻辑补。它们之所以能统一成一个 VMA 抽象，是因为 &lt;strong&gt;VMA 本质上描述的是“这段地址范围应该怎么被解释和处理”，而不是“这段范围当前已经装了什么物理页”&lt;/strong&gt;。当 page fault 发生时，内核先拿 fault 地址去找 VMA，找到后再看它是匿名还是 file-backed、是 shared 还是 private、权限是否允许，然后走不同的 fault handler。VMA 是“统一前台接口”，后面再分匿名和文件两套后端。&lt;/p&gt;
&lt;p&gt;更底层一点，Linux 里文件缓存真正的核心对象不是单纯的 &lt;code&gt;file&lt;/code&gt;，而是 &lt;code&gt;address_space&lt;/code&gt;——某个文件内容在页缓存层的表示。Page cache 按 &lt;code&gt;address_space + page offset&lt;/code&gt; 来组织页面。所以 file-backed fault 更准确的路径是：VMA 里有 &lt;code&gt;vm_file&lt;/code&gt; -&gt; &lt;code&gt;vm_file&lt;/code&gt; 指向对应文件 -&gt; 文件关联到它的 &lt;code&gt;address_space&lt;/code&gt; -&gt; &lt;code&gt;address_space&lt;/code&gt; 里按 offset 找 page cache 页。&lt;/p&gt;
&lt;p&gt;至于“page cache 是不是只给 file-backed 用”，大体上可以这么理解。但“file-backed”不要只狭义理解成“磁盘上的普通文件”——tmpfs/shmem 这类内存文件系统对象虽然“感觉像纯内存”，但在内核抽象上有 inode、有 &lt;code&gt;address_space&lt;/code&gt;，能走 page cache 那套机制。匿名页本身不属于 page cache，但如果被换出到 swap，会进入另一个相关但不同的概念——swap cache。&lt;/p&gt;
&lt;h2&gt;物理页分配器&lt;/h2&gt;
&lt;p&gt;到这里你已经知道 page fault 最后经常要“分物理页”，page cache 也需要物理页。但内核不是随手从 RAM 里拿一块字节数组就完事的，它需要不同粒度的分配器。&lt;/p&gt;
&lt;h3&gt;Buddy Allocator&lt;/h3&gt;
&lt;p&gt;Buddy 分配器是 Linux 里最基础的物理页分配器，按 2 的幂次、按页为单位分配连续物理内存块（1 页、2 页、4 页、8 页……）。page fault 需要新页、page cache 需要新页，底层通常都会落到 buddy 这一层。&lt;/p&gt;
&lt;p&gt;它之所以叫“buddy”，是因为它把空闲块按大小分层组织。需要更小块时就把一个大块一分为二，这两个一分为二出来的块互为 buddy；将来两个 buddy 都空闲了还可以合并回更大的块。核心思想是方便拆分、方便合并、适合页级别的大粒度分配。代价则是可能有外部碎片，且不适合频繁分配小对象。&lt;/p&gt;
&lt;h3&gt;Slab/SLUB&lt;/h3&gt;
&lt;p&gt;内核里有大量对象根本不是“整页整页”用的——&lt;code&gt;task_struct&lt;/code&gt;、&lt;code&gt;mm_struct&lt;/code&gt;、&lt;code&gt;dentry&lt;/code&gt;、&lt;code&gt;inode&lt;/code&gt; 等等。如果这些都直接向 buddy 要整页，会浪费很大。所以 Linux 在 buddy 之上又有 slab/slub 对象分配器（现代 Linux 主流通常是 SLUB），本质就是在从 buddy 拿来的页上再切小块给内核对象用。&lt;/p&gt;
&lt;p&gt;Slab/SLUB 不是简单 &lt;code&gt;malloc&lt;/code&gt;，而是按对象类型做 cache——&lt;code&gt;task_struct&lt;/code&gt; 一种 cache、&lt;code&gt;dentry&lt;/code&gt; 一种 cache、&lt;code&gt;inode&lt;/code&gt; 一种 cache。好处是对象大小固定、分配/释放很快、布局更适合 CPU cache、可以复用已初始化过一部分的对象槽位。所以 slab/slub 不只是“切小块”，还是 typed object cache。&lt;/p&gt;
&lt;p&gt;一个经典的配合路径：你要一个 &lt;code&gt;task_struct&lt;/code&gt; -&gt; &lt;code&gt;kmem_cache_alloc(task_struct_cachep)&lt;/code&gt; -&gt; 如果 slab cache 里有空对象槽直接拿，没有就去向底层申请新页 -&gt; 底层落到 buddy allocator。而如果你本来就要的是页（fault 分匿名页、给 page cache 加页），往往直接走 buddy。&lt;/p&gt;
&lt;p&gt;所以说，buddy 管页块，slab/slub 管小对象，slub 背后最终靠 buddy 提供页。&lt;/p&gt;
&lt;h2&gt;Reclaim、Swap 与 Page Cache 的竞争&lt;/h2&gt;
&lt;p&gt;到现在为止你看到的主要是“怎么建地址空间、怎么翻译、怎么 fault 补页、怎么分配新页”。但真实系统里最难的地方往往不是“分配”，而是&lt;strong&gt;内存不够时，谁该留下，谁该走&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;一台 64GB RAM 的机器，这些内存可以被拿去放进程堆/栈/匿名页、文件缓存 page cache、slab/slub 内核对象等等。所以从系统角度看，问题不是“内存给进程用还是给文件系统用”，而是“哪些页现在最值得留在 RAM 里？”这就是 reclaim 的本质。&lt;/p&gt;
&lt;h3&gt;Reclaim&lt;/h3&gt;
&lt;p&gt;Reclaim 的最短定义：在内存压力下，内核回收一些页，把它们重新变成可分配的空闲页。它不是简单 &lt;code&gt;free()&lt;/code&gt; 某个进程的内存，可能包括丢弃干净的 page cache 页、回写脏页后再回收、把匿名页换出到 swap、回收 slab cache，最后实在不行触发 OOM。&lt;/p&gt;
&lt;p&gt;当分配路径发现可用空闲页太少时，会触发 reclaim。这里有两个关键角色：&lt;strong&gt;kswapd&lt;/strong&gt; 是后台回收线程——内存刚开始紧了，后台先去回收一点，尽量别让前台分配线程卡住。&lt;strong&gt;Direct reclaim&lt;/strong&gt; 则是前台自己上场——某个线程想分配页但真不够了，它自己被迫进入 reclaim 路径回收完再继续分配。后者对 latency 很伤，所以系统总希望更多工作由 kswapd 提前做好。&lt;/p&gt;
&lt;p&gt;系统内存压力来时，内核不是乱扔页，而是在做某种排序：这个页最近常用吗？是 file-backed 还是 anon？是 clean 还是 dirty？回收代价高不高？将来再用到的概率高不高？心智模型就是“冷页先走，热页尽量留”，目标是近似保留 working set，把冷页赶出去。&lt;/p&gt;
&lt;p&gt;值得注意的是，reclaim 不只盯着 page cache 和匿名页。内核对象缓存——dentry cache、inode cache、各类 slab cache——在内存压力下也可能被 shrink。所以内存竞争的完整图景是 &lt;strong&gt;anonymous pages vs file cache vs slab/kernel caches&lt;/strong&gt;，只不过前两者通常是回收的主要目标。&lt;/p&gt;
&lt;h3&gt;页类型不同，代价不同&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;干净的 file-backed page cache&lt;/strong&gt; 是最容易回收的。背后本来就有文件，如果这一页没有被修改（clean），直接丢掉内存里的缓存页就行，将来再访问时重新从文件读回来。这也是为什么 Linux 倾向于“把空闲内存尽量拿来做 page cache”——因为它有用，而且在需要时相对容易丢弃。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;脏的 file-backed 页&lt;/strong&gt; 不能直接丢，因为内存里的内容已经比磁盘新，直接扔会丢数据。需要先 writeback 到文件/磁盘，写回完成后才变成可回收的 clean 页。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;匿名页&lt;/strong&gt; 背后没有普通文件作后备存储，如果要从 RAM 里赶出去通常需要 swap out——把页内容写到 swap 空间，以后再需要时从 swap 读回。这就是 page cache 和匿名页竞争时一个非常核心的不对称性：&lt;strong&gt;clean file page 可以直接扔，anonymous page 通常要 swap 才能扔。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;Linux 怎么把内存吃光了？&lt;/h3&gt;
&lt;p&gt;很多新手看到 &lt;code&gt;buff/cache&lt;/code&gt; 很大就以为“Linux 怎么把内存吃光了”。其实内核的策略是：RAM 空着也是空着，不如拿来缓存文件内容，一旦应用真要内存，reclaim 时可以优先把缓存页让出来。Linux 的哲学不是“尽量保持空闲内存多”，而是&lt;strong&gt;尽量让空闲内存有用途，同时保证需要时能回收&lt;/strong&gt;。上面我们说到，clean file page 是最容易回收的，内核就倾向于把空闲内存用来做 page cache，这样既有性能提升又不妨碍应用需要内存时 reclaim。&lt;code&gt;buff/cache&lt;/code&gt; 大不代表“内存被吃光了”，而是“内核在积极利用空闲内存做缓存”，真正吃光了会看到 &lt;code&gt;available&lt;/code&gt; 也变成 0。&lt;/p&gt;
&lt;h3&gt;Swap&lt;/h3&gt;
&lt;p&gt;Swap 就是匿名页被逐出 RAM 后的落脚地。当匿名页暂时不值得留在 RAM 但又不能像 clean file page 那样直接丢时，就把它写到 swap 区域（swap partition 或 swap file），以后再 fault 回来。Swap out 时把匿名页内容写到 swap、页表里改成“这页现在在 swap 上”、物理页释放回系统。以后再次访问时，CPU 发现页不在内存、对应的是 swap entry，触发 fault，内核从 swap 读回内存、重建映射。Swap 让“匿名页不在 RAM 里”这件事成为可恢复状态。&lt;/p&gt;
&lt;h3&gt;OOM&lt;/h3&gt;
&lt;p&gt;如果 reclaim 已经很努力但还是回收不出足够内存，最后可能进入 OOM killer——内核决定杀掉某些进程来释放内存，让系统从完全卡死边缘回来。完整链条是：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;memory pressure -&gt; kswapd/direct reclaim -&gt; writeback/swap/page dropping -&gt; still insufficient -&gt; OOM
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Reclaim 为什么会影响性能&lt;/h3&gt;
&lt;p&gt;因为它是慢路径，可能包含扫描页、检查引用/冷热、写回脏页、swap I/O、slab shrink、锁竞争。一旦进入强 reclaim / swap 抖动，典型症状就是：延迟飙升、throughput 掉下来、磁盘忙但业务没推进、CPU 花很多时间在内核回收路径、反复 fault in/reclaim out 形成 thrashing。这才是“内存压力”真正可怕的地方，不是单纯少了几页。&lt;/p&gt;
&lt;p&gt;在 NUMA 下 reclaim 还更复杂——哪个 node 缺页、从哪个 node 回收、远程内存是否还能撑、local reclaim 和跨 node fallback 怎么平衡——所以真正服务器上的 mm 调优往往离不开 NUMA placement、page cache 行为、reclaim 统计和 swap 策略。&lt;/p&gt;
&lt;h2&gt;Page Fault 与 Reclaim&lt;/h2&gt;
&lt;p&gt;Page Fault 和 Reclaim 是 mm 里两个最核心的机制，前者负责把“需要的页”拉进来，后者负责把“不值得留的页”赶出去。它们在某种程度上是“相互对称”的：Page fault 是“缺什么补什么”，Reclaim 是“留什么丢什么”。&lt;/p&gt;
&lt;p&gt;这个视角非常关键。Page fault 把“还没在 RAM 的页”拉进来，reclaim 把“现在不值得在 RAM 的页”赶出去。所以 mm 不是单向“越分越多”，而是一个循环系统：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;fault in -&gt; use -&gt; become cold -&gt; reclaim out -&gt; future access -&gt; fault in again
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这就是工作集管理的本质。两者围绕同一个核心问题：&lt;strong&gt;这页现在值不值得驻留？&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;一页内存的一生&lt;/h2&gt;
&lt;p&gt;把整个 mm 主线串成一条动态故事来走一遍，是建立完整心智模型最有效的方式之一。我们用一个最普通但足够真实的场景：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;char *p = malloc(4096);
p[0] = 1;
// ... 很久不访问 ...
p[0] = 2;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;第一步，malloc() 当下发生了什么。&lt;/strong&gt; 很多人下意识以为 &lt;code&gt;malloc(4096)&lt;/code&gt; 就是“向 OS 申请一页物理内存”。其实通常不是。用户态 allocator（如 glibc malloc）先做一层自己的管理——可能复用已有 heap 块，可能扩 brk，可能走 mmap，不一定立刻触发新的物理页分配。内核视角更常见的是当前进程的 &lt;code&gt;mm&lt;/code&gt; 里多了一段合法虚拟地址空间，但具体那一页未必已经有物理页。&lt;code&gt;malloc()&lt;/code&gt; 常常先分的是“地址空间使用权”，不是立刻兑现的物理页。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第二步，第一次访问：匿名页 fault。&lt;/strong&gt; 执行 &lt;code&gt;p[0] = 1;&lt;/code&gt; 时，CPU 做地址翻译发现这地址在合法 VMA 里但当前页表项还没指向物理页，触发 page fault。内核找到对应匿名 VMA，从 buddy 拿一个物理页，清零，建立页表映射，返回用户态重试。到这一步，这页真正“出生”了。&lt;code&gt;mm_struct&lt;/code&gt;、VMA、page table、page fault、buddy allocator 第一次串起来。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第三步，后续反复访问：TLB/cache 主导。&lt;/strong&gt; 页表建好后，后面很多访问不再进内核。典型路径是 TLB hit 或 miss、cache hit 或 miss、直接访问 RAM——全是 CPU/MMU/TLB/cache 的硬件路径。真正健康运行时，内核不应该反复参与每次访问。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第四步，如果 fork()：进入 COW 世界。&lt;/strong&gt; 这时进程调 &lt;code&gt;fork()&lt;/code&gt;，这页不会被立刻复制两份——父子暂时共享同一物理页，PTE 改成只读，引用计数增加。某一方一写 &lt;code&gt;p[0] = 42;&lt;/code&gt;，CPU 看到 PTE 不允许写，fault 进内核，识别出 COW 场景，分配新页、复制旧页内容、PTE 指到新页并设可写。父子从这一页开始分家。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第五步，很久不访问：变“冷”。&lt;/strong&gt; 随着时间推移，如果这页很久没被访问且内存压力上来了，它在内核眼里就可能变成冷页。Reclaim 开始考虑它值不值得继续留在 RAM。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第六步，内存压力下：swap out。&lt;/strong&gt; 系统真缺内存时，这页可能被 swap out——匿名页内容写到 swap、页表改成“在 swap 上”的编码、物理页释放回系统。这页的“逻辑存在”还在，但暂时不驻留在物理内存里了——从 resident 变成 non-resident，但仍属于这个进程的地址空间。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第七步，再次访问：swap-in fault。&lt;/strong&gt; 后来程序执行 &lt;code&gt;p[0] = 2;&lt;/code&gt;，CPU 发现页不在 RAM、对应的是 swap entry，page fault 再次发生。这次内核走的不是“匿名页首次 fault”，而是从 swap 读回一页、重新分配物理页、重建映射。同样叫 page fault，但来源完全不同——这就是为什么 page fault 只是“统一入口”，后端分叉很多。&lt;/p&gt;
&lt;p&gt;如果换成文件映射场景（&lt;code&gt;p = mmap(file, ...)&lt;/code&gt;），这页的旅程会有所不同：VMA 建立时记录 file + offset -&gt; 第一次访问触发 file-backed fault -&gt; 文件页进入 page cache -&gt; 映射进用户页表 -&gt; 很久不用后如果是 clean 可以直接从 RAM 丢掉 -&gt; 将来再访问时如果页已不在 page cache 就重新从文件读回。文件页和匿名页的大差别正在于此：文件页很多时候“可丢弃，因为文件本体就是 backing store”；匿名页很多时候“必须 swap，除非整个进程退出”。&lt;/p&gt;
&lt;h2&gt;内存管理的框架&lt;/h2&gt;
&lt;p&gt;走完这一轮，你现在应该有一个完整的 mm 骨架了。每个角色各司其职：&lt;/p&gt;
&lt;p&gt;| 角色 | 职责 |
|------|------|
| &lt;code&gt;mm_struct&lt;/code&gt; | 管理一个进程的地址空间 |
| VMA | 告诉内核每段地址合不合法、是匿名还是文件映射 |
| Page Table | 记录当前具体页是否映射、映射到哪 |
| MMU / TLB | 做硬件翻译和权限检查 |
| Page Fault Handler | 在映射缺失/权限需修复时补救 |
| Buddy Allocator | 给匿名页 fault、page cache 等提供物理页 |
| Slab / SLUB | 给内核小对象提供分配 |
| Page Cache | 缓存文件页 |
| Reclaim | 在内存压力下回收冷页 |
| Swap | 为匿名页提供后备存储 |
| Writeback | 为脏文件页回写磁盘 |&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>Learning Linux Kernel (Part 3) - Task &amp; Scheduler</title><link>https://theunknownth.ing/blog/linux-kernel-3</link><guid isPermaLink="true">https://theunknownth.ing/blog/linux-kernel-3</guid><description>How the task is managed and scheduled in Linux kernel?</description><pubDate>Sat, 28 Mar 2026 17:42:00 GMT</pubDate><content:encoded>&lt;h2&gt;前言&lt;/h2&gt;
&lt;p&gt;上一章节：&lt;a href=&quot;/blog/linux-kernel-2&quot;&gt;Linux Kernel (Part 2) - Bootstrapping&lt;/a&gt; 如果你没有阅读过，建议先阅读。本章节我们将引入 Linux kernel 的核心对象之一：task，以及调度器。&lt;/p&gt;
&lt;h2&gt;Disclaimer&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;我本次使用了 AI 来带我学习 Linux 内核。所以无论我后续怎么整理润色，本文的 first draft 肯定是 AI 告诉我的内容&lt;/strong&gt;。如果你对此感到无法接受，或者觉得 AI 讲得不够好，你可以随时退出阅读，或者自己去看 Linux 内核的源码和文档。我的目标是通过 AI 来帮我梳理和总结内核的知识体系，而不是替代你自己去接触原始资料。&lt;/p&gt;
&lt;p&gt;如果你能接受这个前提，那么我们就继续往下走了。&lt;/p&gt;
&lt;h2&gt;内核眼中的“进程”&lt;/h2&gt;
&lt;p&gt;在用户态编程时，我们常常把“进程”想象成一个封装好的黑盒——它有自己的内存空间、打开的文件、运行的代码。这种直觉在日常开发中没什么问题，但一旦你走进 Linux 内核的世界，就会发现事情远比这复杂，也远比这优雅。&lt;/p&gt;
&lt;p&gt;Linux 内核并不像教科书那样维护一个叫“Process”的超级对象。相反，它把我们通常称为“进程”或“线程”的东西，拆分成了&lt;strong&gt;一组可以独立管理、灵活组合的内核对象&lt;/strong&gt;。理解这套拆分方式，是理解后面所有内容的基础。&lt;/p&gt;
&lt;h3&gt;task_struct&lt;/h3&gt;
&lt;p&gt;在这组对象中，&lt;code&gt;task_struct&lt;/code&gt; 是当之无愧的核心。你可以把它理解为“一个可被调度的执行流在内核中的描述符”。调度器最关心的就是它——每当内核需要决定“接下来哪段代码应该在 CPU 上跑”时，它打交道的对象就是 &lt;code&gt;task_struct&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;一个 &lt;code&gt;task_struct&lt;/code&gt; 里包含了大量信息：调度状态、CPU 寄存器上下文、指向地址空间的指针、打开文件表的引用、信号处理状态、进程号与线程号、父子关系、安全凭据……如果你只需要记住一个内核数据结构的名字，那就是它。&lt;/p&gt;
&lt;p&gt;但 &lt;code&gt;task_struct&lt;/code&gt; 并不能独自描述一个完整的“进程”。原因很简单：一个执行流所关联的资源种类繁多，而其中有些资源是每个 task 独有的（比如寄存器上下文和调度状态），有些则可以在同一进程内的多个线程之间共享（比如地址空间和打开文件表）。为了优雅地支持这种“有些共享、有些独有”的语义，Linux 把这些资源拆成了独立的内核对象，再通过指针挂接到 &lt;code&gt;task_struct&lt;/code&gt; 上。&lt;/p&gt;
&lt;h3&gt;围绕 task_struct 的资源对象&lt;/h3&gt;
&lt;p&gt;让我们逐一看看这些被拆出来的关键对象：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;mm_struct&lt;/code&gt;——地址空间。&lt;/strong&gt; 如果说 &lt;code&gt;task_struct&lt;/code&gt; 回答的是“谁在执行”，那么 &lt;code&gt;mm_struct&lt;/code&gt; 回答的就是“它看到的虚拟内存世界长什么样”。它描述了进程拥有的虚拟内存区域（VMA）、页表根地址、代码段和数据段的布局、堆和栈的位置等信息。当两个 task 共享同一个 &lt;code&gt;mm_struct&lt;/code&gt; 时，它们看到的就是完全相同的用户地址空间——这正是“线程”最核心的特征之一。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;files_struct&lt;/code&gt;——打开文件描述符表。&lt;/strong&gt; 这就是 &lt;code&gt;fd = 0, 1, 2, 3, …&lt;/code&gt; 背后那整套映射。它维护着从文件描述符到内核 &lt;code&gt;struct file&lt;/code&gt; 对象的映射关系。如果多个 task 共享同一个 &lt;code&gt;files_struct&lt;/code&gt;，它们操作的就是同一组文件描述符。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;fs_struct&lt;/code&gt;——文件系统视角。&lt;/strong&gt; 这个名字很容易让人联想到文件系统的实现，但它描述的其实是“这个进程怎么看待路径世界”——具体来说就是当前工作目录（cwd）和根目录（root）。相对路径的解析就依赖于它。一个简单的区分方式是：&lt;code&gt;files_struct&lt;/code&gt; 关心的是“我打开了哪些文件”，&lt;code&gt;fs_struct&lt;/code&gt; 关心的是“我站在文件系统的哪个位置”。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;signal_struct&lt;/code&gt; 与 &lt;code&gt;sighand_struct&lt;/code&gt;——信号状态。&lt;/strong&gt; Linux 的信号模型本身就比较复杂，所以信号相关的状态也被拆成了多个对象。粗略来说，&lt;code&gt;signal_struct&lt;/code&gt; 偏向整个线程组（进程）层面的信号状态，而 &lt;code&gt;sighand_struct&lt;/code&gt; 则偏向信号处理动作（比如 handler 表）。此外，每个 task 还有自己独立的 pending signal 集合。核心认知是：信号相关的状态远不是一个简单的整数能描述的，而是由一组对象协作完成。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;cred&lt;/code&gt;——安全凭据。&lt;/strong&gt; 它回答的是“这个 task 以什么身份在系统里行动”，包含 &lt;code&gt;uid&lt;/code&gt;、&lt;code&gt;gid&lt;/code&gt;、capability 集合等安全相关属性。内核在做权限检查时，查看的就是这个对象。&lt;/p&gt;
&lt;h3&gt;把拼图组合起来&lt;/h3&gt;
&lt;p&gt;如果我们把上面这些对象画成一张图，一个“进程/线程”在内核里的全貌大致如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;task_struct
  -&gt; mm_struct        // 地址空间
  -&gt; files_struct     // 打开的文件描述符表
  -&gt; fs_struct        // cwd/root 等路径视角
  -&gt; signal/sighand   // 信号相关状态
  -&gt; cred             // 身份和权限
  -&gt; sched info       // 调度信息
  -&gt; parent/children  // 进程关系
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这张图传达的关键信息是：&lt;strong&gt;进程并不是一个封闭的黑盒，而是一组可以灵活组合的内核对象。&lt;/strong&gt; 理解了这一点，很多后续的概念就会自然变得清晰。&lt;/p&gt;
&lt;h3&gt;线程与进程&lt;/h3&gt;
&lt;p&gt;从内核的角度看，线程和进程并不是两种截然不同的东西。它们的底层表示都是 &lt;code&gt;task_struct&lt;/code&gt;，区别仅仅在于这些 task 之间&lt;strong&gt;共享了多少资源对象&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;如果两个 task 共享同一个 &lt;code&gt;mm_struct&lt;/code&gt;、同一个 &lt;code&gt;files_struct&lt;/code&gt;、同一套信号处理状态——那我们通常称它们为同一进程内的“线程”。如果它们各自拥有独立的地址空间和文件表，各有各的资源边界——那它们更像是两个独立的“进程”。&lt;/p&gt;
&lt;p&gt;这种设计背后的哲学非常 Unix：&lt;strong&gt;用灵活的底层原语加上不同的组合方式，来表达高层概念。&lt;/strong&gt; 而不是在内核里硬编码两套完全不同的实体。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;fork()&lt;/code&gt; 和 &lt;code&gt;clone()&lt;/code&gt; 就是实现这种组合的接口。&lt;code&gt;fork()&lt;/code&gt; 更像传统的“创建新进程”，默认会为子进程复制或逻辑独立很多资源；而 &lt;code&gt;clone()&lt;/code&gt; 则要底层得多，它允许调用者精确指定新 task 与父 task 之间共享哪些资源。线程的创建，本质上就是通过 &lt;code&gt;clone()&lt;/code&gt; 来指定“共享地址空间、共享文件表、共享信号处理”的。&lt;/p&gt;
&lt;h3&gt;为什么这套拆分如此重要？&lt;/h3&gt;
&lt;p&gt;你可能会觉得“了解内核对象拆分”只是一个冷知识，但实际上，未来你在分析内核问题时，几乎所有关键问题都可以归结为对这张对象图的追问：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;这个属性是 per-task 的，还是属于某个共享对象？&lt;/li&gt;
&lt;li&gt;一个线程修改了某个状态，同进程的其他线程会不会看到变化？&lt;/li&gt;
&lt;li&gt;上下文切换的开销到底花在哪里——是切 task 贵？还是换 &lt;code&gt;mm&lt;/code&gt; 贵？还是文件表共享导致的锁竞争？&lt;/li&gt;
&lt;li&gt;某个 bug 到底是生命周期管理出了问题，还是共享关系搞错了？&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;把“进程”当成一个黑盒，这些问题都会显得模糊。但把它拆成对象图，很多答案就会变得清晰。&lt;/p&gt;
&lt;h2&gt;fork() 和 exec()&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;fork()&lt;/code&gt; 和 &lt;code&gt;exec()&lt;/code&gt; 是 Unix/Linux 世界中最经典的一对系统调用。几乎所有新进程的诞生，都要经过它们的接力。但许多人对它们的理解停留在“fork 创建进程，exec 执行程序”这种粗略的描述上——而实际的设计要巧妙得多。&lt;/p&gt;
&lt;p&gt;用最短的话概括：&lt;strong&gt;&lt;code&gt;fork()&lt;/code&gt; 解决的是“谁来跑”，&lt;code&gt;exec()&lt;/code&gt; 解决的是“跑什么”。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;fork() 不是“启动一个程序”&lt;/h3&gt;
&lt;p&gt;这大概是关于 &lt;code&gt;fork()&lt;/code&gt; 最常见的误解了。&lt;code&gt;fork()&lt;/code&gt; 做的事情并不是去启动一个新程序，而是&lt;strong&gt;复制出一份当前执行环境的副本&lt;/strong&gt;。调用 &lt;code&gt;fork()&lt;/code&gt; 之后，系统中会多出一个新的 task，它几乎是父进程的翻版——拥有看起来一样的地址空间、相似的打开文件表、从同一段代码的同一位置继续执行。父子之间唯一的区别体现在 &lt;code&gt;fork()&lt;/code&gt; 的返回值上：父进程拿到子进程的 pid，子进程拿到 0。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;pid_t pid = fork();
if (pid == 0) {
    // 子进程的代码路径
} else {
    // 父进程的代码路径
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同一个系统调用，靠返回值分流父子逻辑——这是一种非常简洁且优雅的 API 设计。子进程并不会“自动去运行另一个程序”，它只是从当前代码的当前位置继续执行。至于“它最终变成什么程序”，那是 &lt;code&gt;exec()&lt;/code&gt; 的工作。&lt;/p&gt;
&lt;h3&gt;exec() 不是“创建新进程”&lt;/h3&gt;
&lt;p&gt;与 &lt;code&gt;fork()&lt;/code&gt; 恰恰相反，&lt;code&gt;exec()&lt;/code&gt; 不会创建任何新的 task。它做的事情是&lt;strong&gt;把当前 task 的用户态程序映像整个替换掉&lt;/strong&gt;。调用前后，&lt;code&gt;task_struct&lt;/code&gt; 还是同一个，pid 通常也不变，但用户地址空间被彻底换了——代码段、数据段、栈、程序入口，全都变成了新程序的。&lt;/p&gt;
&lt;p&gt;如果用一个比喻来说：&lt;code&gt;exec()&lt;/code&gt; 更像是同一个容器，把里面原来的程序倒掉，装进去一个全新的程序。不是“再开一个容器”，而是“换了容器里的内容”。所以 &lt;code&gt;exec()&lt;/code&gt; 成功后，原来的那段代码就不复存在了——它不会像普通函数那样“返回”到旧程序，只有失败时才会返回错误。&lt;/p&gt;
&lt;h4&gt;execve() 和 exec() 到底是什么关系？&lt;/h4&gt;
&lt;p&gt;这里需要澄清一个很容易让人混淆的概念——当我们说 &lt;code&gt;exec()&lt;/code&gt; 的时候，在 C 语言的标准库里其实根本不存在一个叫 &lt;code&gt;exec()&lt;/code&gt; 的函数。它只是一个“家族”的统称。&lt;/p&gt;
&lt;p&gt;最核心的区别在于：&lt;strong&gt;&lt;code&gt;execve()&lt;/code&gt; 是真正的底层老板（内核系统调用），而 &lt;code&gt;exec()&lt;/code&gt; 是一群为了方便你点单的服务员（用户态的库函数家族）。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;execve()&lt;/code&gt; 是真正的内核系统调用。&lt;/strong&gt; 它是 Linux 内核提供的&lt;strong&gt;唯一一个&lt;/strong&gt;用于执行新程序的系统调用接口。当你去看内核源码时，最终真正执行清空当前进程内存、加载新程序代码、设置栈和环境变量这些硬核操作的，是内核里的 &lt;code&gt;sys_execve&lt;/code&gt;。它的传参方式非常严格，必须提供三个精确的参数——程序路径、参数数组（&lt;code&gt;argv&lt;/code&gt;）、环境变量数组（&lt;code&gt;envp&lt;/code&gt;）：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;int execve(const char *pathname, char *const argv[], char *const envp[]);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;exec()&lt;/code&gt; 家族是用户态的包装函数。&lt;/strong&gt; 它们是 C 标准库（如 glibc）提供的包装函数，运行在用户态。这个家族包括 6 个常用的成员，名字都以 &lt;code&gt;exec&lt;/code&gt; 开头：&lt;code&gt;execl&lt;/code&gt;、&lt;code&gt;execlp&lt;/code&gt;、&lt;code&gt;execle&lt;/code&gt;、&lt;code&gt;execv&lt;/code&gt;、&lt;code&gt;execvp&lt;/code&gt;、&lt;code&gt;execvpe&lt;/code&gt;。这些函数的存在仅仅是为了让程序员写代码更方便——不管你调用哪一个，它们在底层都会把你的参数按照 &lt;code&gt;execve()&lt;/code&gt; 需要的格式打包好，然后统一去调用 &lt;code&gt;execve()&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;它们各自提供了不同的“贴心服务”：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;带 &lt;code&gt;p&lt;/code&gt; 的（如 &lt;code&gt;execlp&lt;/code&gt;）：你不需要写程序的绝对路径，它会自动去 &lt;code&gt;PATH&lt;/code&gt; 环境变量里帮你找（比如直接写 &lt;code&gt;“ls”&lt;/code&gt; 而不是 &lt;code&gt;“/bin/ls”&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;带 &lt;code&gt;l&lt;/code&gt; 的（如 &lt;code&gt;execl&lt;/code&gt;）：允许你把参数像列表一样一个个写出来，不用自己去构建一个数组。&lt;/li&gt;
&lt;li&gt;带 &lt;code&gt;e&lt;/code&gt; 的（如 &lt;code&gt;execle&lt;/code&gt;）：允许你显式地传一份自定义的环境变量数组。&lt;/li&gt;
&lt;li&gt;带 &lt;code&gt;v&lt;/code&gt; 的（如 &lt;code&gt;execv&lt;/code&gt;）：参数以数组形式传入，适合参数个数在运行时才确定的场景。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以当我们在这篇文章里说 &lt;code&gt;exec()&lt;/code&gt; 时，指的是这整个家族的行为——而它们最终干的活，全部都是委托给 &lt;code&gt;execve()&lt;/code&gt; 这个唯一的内核系统调用来完成的。&lt;/p&gt;
&lt;h3&gt;Unix 的经典美学&lt;/h3&gt;
&lt;p&gt;那么，为什么不干脆设计一个 &lt;code&gt;spawn(program, args)&lt;/code&gt; 一步到位呢？好问题，答案正是 Unix 设计哲学的精髓所在。&lt;/p&gt;
&lt;p&gt;将进程创建拆成 &lt;code&gt;fork()&lt;/code&gt; 和 &lt;code&gt;exec()&lt;/code&gt; 两步，带来了一个非常强大的组合性优势：&lt;strong&gt;在 fork 和 exec 之间，存在一个可以自由定制子进程环境的窗口期。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;以 shell 执行 &lt;code&gt;ls -l&lt;/code&gt; 为例，整个过程大致如下：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Shell 先调用 &lt;code&gt;fork()&lt;/code&gt;，得到一个几乎一样的子进程&lt;/li&gt;
&lt;li&gt;子进程在 &lt;code&gt;fork()&lt;/code&gt; 返回后走自己的代码分支&lt;/li&gt;
&lt;li&gt;在调用 &lt;code&gt;exec()&lt;/code&gt; 之前，子进程可以自由地做各种准备工作：重定向 stdin/stdout/stderr、关闭或保留特定的文件描述符、设置环境变量、改变工作目录、设置用户身份、建立管道、配置信号处理……&lt;/li&gt;
&lt;li&gt;准备完毕后，子进程调用 &lt;code&gt;exec(“ls”)&lt;/code&gt;，把自己替换成 &lt;code&gt;ls&lt;/code&gt; 程序&lt;/li&gt;
&lt;li&gt;与此同时，父进程（shell）继续管理前台和后台任务&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;如果把所有这些定制选项都塞进一个巨大的 &lt;code&gt;spawn&lt;/code&gt; 参数包里，接口会变得极其笨重。而 fork + exec 的两步流程让一切都非常自然——先复制出一份环境，按需调整，最后再换成目标程序。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;这就是 Unix API 设计的经典美学：&lt;strong&gt;小原语组合成强表达力。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;从内核对象角度看这两个调用&lt;/h3&gt;
&lt;p&gt;从前面介绍的对象模型来看，&lt;code&gt;fork()&lt;/code&gt; 和 &lt;code&gt;exec()&lt;/code&gt; 的内部动作截然不同：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;fork() 的核心工作&lt;/strong&gt;是创建一个新的 &lt;code&gt;task_struct&lt;/code&gt;，复制父 task 的大量元数据，为子 task 建立调度上下文，并处理地址空间、打开文件表、信号状态、凭据、父子关系等一系列对象的复制或共享。其中有一个至关重要的优化：&lt;strong&gt;地址空间不会被立刻全量复制&lt;/strong&gt;。这就是大名鼎鼎的 Copy-on-Write（COW）——逻辑上子进程获得了父进程地址空间的完整副本，但物理上它们先共享同一批页面，只有在某一方真正写入时才会触发复制。这是 &lt;code&gt;fork()&lt;/code&gt; 在现代系统里依然高效可用的根本原因——否则每次 fork 都全量拷贝内存，开销会大到难以接受。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;exec() 的核心工作&lt;/strong&gt;则完全不同：通过 VFS 找到目标程序文件、判断可执行格式（如 ELF）、销毁或替换旧的用户态内存映像、建立新的 &lt;code&gt;mm_struct&lt;/code&gt; 布局、映射新的代码段/数据段/栈、设置 &lt;code&gt;argv&lt;/code&gt;/&lt;code&gt;envp&lt;/code&gt;、配置初始寄存器状态、把程序计数器指向新程序的入口。注意，在整个过程中，&lt;code&gt;task_struct&lt;/code&gt; 本身和 pid 都没有变——变的只是这个 task 所承载的程序内容。&lt;/p&gt;
&lt;p&gt;用一个更生动的类比来说：&lt;strong&gt;&lt;code&gt;fork()&lt;/code&gt; 更像是“让一个新人出生”，&lt;code&gt;exec()&lt;/code&gt; 更像是“给这个人换一整套大脑和身体”。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;值得留意的源码入口&lt;/h3&gt;
&lt;p&gt;如果你以后想深入源码，以下几个文件是很好的起点：&lt;/p&gt;
&lt;p&gt;| 文件 | 内容 |
|------|------|
| &lt;code&gt;kernel/fork.c&lt;/code&gt; | task 创建与复制路径 |
| &lt;code&gt;fs/exec.c&lt;/code&gt; | exec 路径 |
| &lt;code&gt;fs/binfmt_elf.c&lt;/code&gt; | ELF 格式加载器 |
| &lt;code&gt;include/linux/sched*.h&lt;/code&gt; | task 相关核心数据结构 |&lt;/p&gt;
&lt;h2&gt;Task 的一生&lt;/h2&gt;
&lt;p&gt;理解了 task 是什么、怎么被创建之后，下一个自然的问题就是：&lt;strong&gt;一个 task 会经历怎样的一生？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Linux 里的 task 不是一个静态的“进程盒子”，而更应该被理解为一个&lt;strong&gt;有完整生命周期的动态对象&lt;/strong&gt;——它出生、排队、运行、阻塞、被唤醒、可能分裂出后代、可能更换程序身份、最终退出、变成残骸、等待回收。&lt;/p&gt;
&lt;h3&gt;出生&lt;/h3&gt;
&lt;p&gt;一个新 task 的诞生通常源于 &lt;code&gt;fork()&lt;/code&gt;、&lt;code&gt;clone()&lt;/code&gt; 或内核自行创建的内核线程。无论走哪条路径，底层都会汇聚到同一个核心流程：分配一个新的 &lt;code&gt;task_struct&lt;/code&gt;，建立调度上下文，挂接上 &lt;code&gt;mm&lt;/code&gt;、&lt;code&gt;files&lt;/code&gt;、&lt;code&gt;fs&lt;/code&gt;、&lt;code&gt;signal&lt;/code&gt;、&lt;code&gt;cred&lt;/code&gt; 等资源对象，然后设定初始的执行位置。对于用户态进程或线程，这个初始位置通常是 &lt;code&gt;fork()&lt;/code&gt; 的返回点；对于内核线程，则是某个内核函数的入口。&lt;/p&gt;
&lt;h3&gt;出生 ≠ 立刻运行&lt;/h3&gt;
&lt;p&gt;一个很重要的认知是：&lt;strong&gt;task 被创建出来，不代表它此刻就在 CPU 上跑。&lt;/strong&gt; 新 task 通常先进入 &lt;strong&gt;runnable&lt;/strong&gt; 状态——它满足了运行的所有前提条件，但还没有真正拿到 CPU 时间片。只有当调度器选中它、把它放上某个 CPU 之后，它才进入 &lt;strong&gt;running&lt;/strong&gt; 状态。&lt;/p&gt;
&lt;p&gt;这个区分看似细微，但在分析并发行为和性能问题时至关重要。&lt;/p&gt;
&lt;h3&gt;三态循环：running、runnable、blocked&lt;/h3&gt;
&lt;p&gt;在 task 的运行期间，它会在三种核心状态之间反复切换，形成一个循环：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;running → runnable&lt;/strong&gt;：时间片用完或被更高优先级的 task 抢占，暂时让出 CPU，但随时准备再次运行。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;running → blocked&lt;/strong&gt;：需要等待某个外部事件（磁盘 I/O 完成、网络数据到达、锁可用、子进程退出、定时器到期……），继续占着 CPU 毫无意义，于是主动睡下去，让调度器把 CPU 交给其他有事可做的 task。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;blocked → runnable&lt;/strong&gt;：等待的条件终于满足了，内核将其标记回 runnable 并放回调度队列——但注意，&lt;strong&gt;唤醒不等于立刻运行&lt;/strong&gt;，唤醒只意味着“你重新有资格参与竞争 CPU 了”，至于什么时候真正跑起来，还得看调度器的安排。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;分析任何内核控制流时，有一个非常实用的思维框架：先问两个问题——“它现在在哪个状态？”和“是什么事件让它跳到了另一个状态？”这就是&lt;strong&gt;状态机视角&lt;/strong&gt;，它能帮你把复杂的并发场景分解成清晰的状态转移。&lt;/p&gt;
&lt;h3&gt;生命线上的两个特殊事件&lt;/h3&gt;
&lt;p&gt;在 task 的运行过程中，可能发生两个改变生命线形态的重要事件：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;fork()——生命线分叉。&lt;/strong&gt; 当一个正在运行的 task 调用 &lt;code&gt;fork()&lt;/code&gt; 时，生命周期出现了“分叉”——原来的 task 继续存在，同时一个全新的 task 也诞生了。此后父子各自独立运行，谁先跑、跑多久，完全由调度器决定。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;exec()——换一副躯壳。&lt;/strong&gt; 当 task 调用 &lt;code&gt;exec()&lt;/code&gt; 时，没有新的 task 出生，也没有 pid 的变化，但程序映像被整个替换了。如果把 task 看成一条生命线，那么 &lt;code&gt;fork()&lt;/code&gt; 是这条线分出了岔路，而 &lt;code&gt;exec()&lt;/code&gt; 则是同一条线上的生命换了一个全新的身份。&lt;/p&gt;
&lt;h3&gt;退出与善后&lt;/h3&gt;
&lt;p&gt;当进程完成了它的工作（从 &lt;code&gt;main&lt;/code&gt; 函数返回、调用 &lt;code&gt;exit()&lt;/code&gt;）或收到致命信号时，就会走上退出路径。内核需要做大量的清理工作：释放资源、关闭文件描述符、通知父进程、处理线程组关系、确保这个 task 不再被调度。&lt;/p&gt;
&lt;p&gt;但 task 并不会在退出的瞬间就从系统中彻底消失。它通常会短暂进入一个特殊状态——&lt;strong&gt;zombie&lt;/strong&gt;。&lt;/p&gt;
&lt;h3&gt;Zombie：已死未葬&lt;/h3&gt;
&lt;p&gt;Zombie 这个词听起来很恐怖，但它的本质其实很简单：&lt;strong&gt;task 已经执行结束了，但还保留着最基本的退出信息，等待父进程来回收。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;为什么不能在 &lt;code&gt;exit()&lt;/code&gt; 的那一刻就把一切都删干净？因为父进程可能还需要获取子进程的退出码、pid 和资源使用统计。在父进程调用 &lt;code&gt;wait()&lt;/code&gt; / &lt;code&gt;waitpid()&lt;/code&gt; 之前，这些信息必须被保留。所以 zombie 状态的 task 不再运行、不再真正占用用户态资源，但 &lt;code&gt;task_struct&lt;/code&gt; 等最小的元数据还留在那里。&lt;/p&gt;
&lt;p&gt;记住：&lt;strong&gt;&lt;code&gt;exit()&lt;/code&gt; 不是生命周期的最后一步，&lt;code&gt;wait()&lt;/code&gt; 才是。&lt;/strong&gt; 子进程的退出和父进程的回收，是两个独立的阶段。&lt;/p&gt;
&lt;h3&gt;完整生命周期一览&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;new task created
  -&gt; runnable（排队等 CPU）
  -&gt; running（获得 CPU，开始执行）
  -&gt; blocked &amp;#x3C;-&gt; runnable &amp;#x3C;-&gt; running（在三态间反复切换）
  -&gt; maybe fork()（生命线分叉，新 task 诞生）
  -&gt; maybe exec()（程序映像被替换）
  -&gt; exit（退出，开始清理）
  -&gt; zombie（等待父进程回收）
  -&gt; parent wait()（父进程领取退出状态）
  -&gt; fully reaped（彻底从系统中消失）
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;线程的本质&lt;/h2&gt;
&lt;h3&gt;别在内核里找“Thread”对象&lt;/h3&gt;
&lt;p&gt;如果你在 Linux 内核源码里搜索一个叫 &lt;code&gt;thread&lt;/code&gt; 或 &lt;code&gt;process&lt;/code&gt; 的专有数据结构，你不会找到。这不是因为内核不支持线程，而是因为 Linux 从根本上就没有把“线程”和“进程”设计成两种不同的实体。它们在内核里的底层表示是完全一样的——都是 &lt;code&gt;task_struct&lt;/code&gt;。区别仅仅在于：&lt;strong&gt;多个 task 之间共享了哪些资源。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;用户态的直觉是对的：进程有独立的地址空间和资源边界，线程是同一进程内的多个执行流。但到了内核层面，这段直觉会被翻译成一种更具体、更精确的表述——多个 task 共享同一个 &lt;code&gt;mm_struct&lt;/code&gt;，往往还共享 &lt;code&gt;files_struct&lt;/code&gt;、信号处理状态等。&lt;/p&gt;
&lt;p&gt;| 线程间共享的资源 | 每个线程独有的资源 |
|:---|:---|
| &lt;code&gt;mm_struct&lt;/code&gt;（地址空间） | &lt;code&gt;task_struct&lt;/code&gt;（调度实体） |
| &lt;code&gt;files_struct&lt;/code&gt;（文件描述符表） | CPU 寄存器上下文 |
| &lt;code&gt;fs_struct&lt;/code&gt;（cwd/root） | 内核栈 |
| &lt;code&gt;sighand_struct&lt;/code&gt;（信号处理表） | thread-local 状态 |
| &lt;code&gt;signal_struct&lt;/code&gt;（组信号状态） | 每线程 pending signal |&lt;/p&gt;
&lt;h3&gt;共享 mm 是线程最核心的标志&lt;/h3&gt;
&lt;p&gt;在所有共享关系中，&lt;strong&gt;共享 &lt;code&gt;mm_struct&lt;/code&gt;&lt;/strong&gt; 是最关键的。它意味着：同样的虚拟地址在多个线程里指向同一份内存内容，一个线程写入全局变量另一个线程立刻可见，一个线程 &lt;code&gt;malloc&lt;/code&gt; 出来的对象另一个线程可以直接访问。&lt;/p&gt;
&lt;p&gt;判断“这更像线程还是进程”，最有力的一个问题就是：&lt;strong&gt;它们是不是共享同一个 &lt;code&gt;mm_struct&lt;/code&gt;？&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;线程也需要独立的 task_struct&lt;/h3&gt;
&lt;p&gt;即使线程共享了大量资源，它仍然是一个独立的执行流。每个线程都需要自己的 CPU 寄存器现场、调度状态、内核栈、blocked/runnable/running 状态和线程级 pending signal。没有这些，调度器就无法单独地暂停和恢复每个线程。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;调度器调度的是 task，不是抽象的“进程盒子”。&lt;/strong&gt; 这是理解 Linux 线程性能行为很重要的一点。&lt;/p&gt;
&lt;h3&gt;clone()：灵活的任务构造器&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;clone()&lt;/code&gt; 系统调用的本质是：&lt;strong&gt;创建一个新的 task，同时精确指定它和父 task 共享哪些资源。&lt;/strong&gt; 它不是一个简单的二选一开关——不是只有“完全独立进程”和“标准线程”两种选项。&lt;code&gt;clone()&lt;/code&gt; 提供了一组细粒度的共享标志（mm、files、fs、信号、线程组等），你可以按需组合。这使得 &lt;code&gt;clone()&lt;/code&gt; 更像是一个“任务构造器”，而 &lt;code&gt;fork()&lt;/code&gt; 只是它的一种特定组合（“什么都不共享”），&lt;code&gt;pthread_create()&lt;/code&gt; 是另一种特定组合（“尽量都共享”）。&lt;/p&gt;
&lt;h3&gt;线程组与 tgid&lt;/h3&gt;
&lt;p&gt;在多线程场景中，用户态所说的“进程”通常对应内核里的一个 &lt;strong&gt;thread group&lt;/strong&gt;——一组共享资源的 task。其中有一个 &lt;strong&gt;group leader&lt;/strong&gt;，它的 &lt;code&gt;pid&lt;/code&gt; 同时也作为这个组的 &lt;code&gt;tgid&lt;/code&gt;（Thread Group ID）。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;task A: pid = 100, tgid = 100  (leader)
task B: pid = 101, tgid = 100
task C: pid = 102, tgid = 100
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;三个 task 都是独立的调度单位，但在用户态它们统称“进程 100”。单线程进程里 &lt;code&gt;pid == tgid&lt;/code&gt;，差别不明显；一旦进入多线程世界，这个区分就变得非常重要。&lt;/p&gt;
&lt;h3&gt;线程带来的复杂性&lt;/h3&gt;
&lt;p&gt;线程共享地址空间带来了效率上的好处——创建更快、切换更轻（不需要切换 &lt;code&gt;mm&lt;/code&gt;），但也带来了一系列棘手的问题。最典型的就是信号处理：某个信号是发给整个进程还是某个特定线程？handler 是共享的吗？一个线程收到致命信号后整个进程怎么办？&lt;/p&gt;
&lt;p&gt;更深层的问题来自并发：当多个执行流在同一个地址空间里同时读写同一批对象时，就需要 mutex、spinlock、condition variable、memory ordering、futex 等一整套同步机制来保证正确性。理解了“线程就是共享 mm 的多个 task”，你就会自然理解为什么这些工具是必不可少的。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;线程不只带来“更快的并发”，也带来了更复杂的共享语义、同步问题和生命周期管理。它本质上是拿&lt;strong&gt;共享带来的效率&lt;/strong&gt;换&lt;strong&gt;共享带来的复杂性&lt;/strong&gt;。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;wait()、zombie、orphan 与 PID 1&lt;/h2&gt;
&lt;p&gt;这几个概念经常被混为一谈，但它们其实对应着 task 生命周期中截然不同的阶段，以及父子关系中截然不同的问题。让我们逐一厘清。&lt;/p&gt;
&lt;h3&gt;wait()：父进程的回收义务&lt;/h3&gt;
&lt;p&gt;当子进程退出时，父进程通常还需要知道它的退出码、它是否被信号杀死、它的资源使用情况。&lt;code&gt;wait()&lt;/code&gt; / &lt;code&gt;waitpid()&lt;/code&gt; 就是父进程向内核“领取”这些信息的接口。调用 &lt;code&gt;wait()&lt;/code&gt; 时，内核会在父进程的子进程集合中寻找已退出的子进程，把退出状态交给父进程，然后彻底回收残留的内核对象。&lt;/p&gt;
&lt;p&gt;如果没有子进程已退出但还有活着的子进程，父进程可以选择阻塞等待——这又和前面讲的 task 状态机无缝衔接了。&lt;/p&gt;
&lt;h3&gt;Zombie：已死但未收尸&lt;/h3&gt;
&lt;p&gt;Zombie 不是卡住的程序，不是挂起的程序，不是占满 CPU 的程序。它的准确定义是：&lt;strong&gt;已经结束执行、用户态资源大多已释放，但退出状态还保留着等待父进程回收的 task。&lt;/strong&gt; 它已经死了，只是还没有被“正式登记注销”。&lt;/p&gt;
&lt;h3&gt;Orphan：还活着但没了父亲&lt;/h3&gt;
&lt;p&gt;Orphan 和 zombie 完全不是一回事，千万不要混淆：&lt;/p&gt;
&lt;p&gt;| 概念 | 含义 |
|:---|:---|
| zombie | 子进程&lt;strong&gt;已经死了&lt;/strong&gt;，但还没被 &lt;code&gt;wait()&lt;/code&gt; 回收 |
| orphan | 子进程&lt;strong&gt;还活着&lt;/strong&gt;，但父进程先死了 |&lt;/p&gt;
&lt;p&gt;一个是“已死未收尸”，一个是“还活着但没爹了”。而且，一个 orphan 在将来退出后也可能短暂变成 zombie——直到它的新父进程把它回收。这两个概念可以先后发生在同一个进程身上，但它们描述的是完全不同的问题。&lt;/p&gt;
&lt;h3&gt;PID 1：系统进程树的最终兜底家长&lt;/h3&gt;
&lt;p&gt;当一个进程的父进程先退出时，这个变成 orphan 的进程需要有人接管。传统上，这个角色落在 PID 1（&lt;code&gt;init&lt;/code&gt; / &lt;code&gt;systemd&lt;/code&gt;）身上。所以 PID 1 的职责远不止“启动服务”那么简单——它还肩负着领养孤儿进程、负责最终的 &lt;code&gt;wait()&lt;/code&gt; 回收的重任。&lt;/p&gt;
&lt;p&gt;现代 Linux 还支持 &lt;strong&gt;subreaper&lt;/strong&gt; 机制——允许某个进程充当“小号 PID 1”，在局部进程树中接管 orphan 子孙。这在服务管理器、容器运行时等场景中非常实用。&lt;/p&gt;
&lt;p&gt;举一个具体的例子：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sleep 100 &amp;#x26;
# 然后 shell 退出
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;此时 &lt;code&gt;sleep&lt;/code&gt; 还活着，变成 orphan，被 PID 1 接管。100 秒后 &lt;code&gt;sleep&lt;/code&gt; 退出，短暂变成 zombie，然后被 PID 1 回收。orphan 是活着时的父子关系问题，zombie 是死后等待回收的问题。&lt;/p&gt;
&lt;h3&gt;SIGCHLD 与 wait() 的关系&lt;/h3&gt;
&lt;p&gt;子进程退出时，父进程通常会收到 &lt;code&gt;SIGCHLD&lt;/code&gt; 信号。但 &lt;code&gt;SIGCHLD&lt;/code&gt; 只是一个通知——“嘿，你的某个孩子退出了”——真正的回收动作还是得靠 &lt;code&gt;wait()&lt;/code&gt;。不调用 &lt;code&gt;wait()&lt;/code&gt;，zombie 就不会被清理。&lt;/p&gt;
&lt;p&gt;这也解释了为什么编写不当的 daemon 或服务管理器会产生大量僵尸进程：它们的父进程收到了 &lt;code&gt;SIGCHLD&lt;/code&gt; 但没有正确调用 &lt;code&gt;wait()&lt;/code&gt; 来回收。Zombie 虽然不占 CPU、不占地址空间，但仍然会占用 pid 号位和内核表项，大量积累会造成 pid 空间紧张和系统管理混乱。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;编写 daemon、服务管理器、进程 supervisor 时，&lt;strong&gt;正确处理 &lt;code&gt;SIGCHLD&lt;/code&gt; 和 &lt;code&gt;wait()&lt;/code&gt; 是基本功&lt;/strong&gt;。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;调度器何时介入&lt;/h2&gt;
&lt;p&gt;很多人在初学操作系统时，会下意识地把调度器想象成“一个独立的后台线程，不断扫描系统中的所有 task，挑一个最该跑的放上 CPU”。但 Linux 的调度器并不是这样工作的。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;调度器更像是嵌在各种事件路径里的一段逻辑&lt;/strong&gt;——它在 syscall 路径、wakeup 路径、tick 路径、中断返回路径、主动阻塞路径中被触发，在合适的时机重新做“谁该跑”的决策。它介入的核心原因只有一个：&lt;strong&gt;当前 CPU 上这个 task 不该继续独占 CPU 了，或者有别的 task 更应该跑。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;六类触发时机&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;时机一：当前 task 主动阻塞。&lt;/strong&gt; 这是最经典的一种场景。比如用户进程调用 &lt;code&gt;read()&lt;/code&gt; 但数据还没从磁盘到达、等待 mutex 释放、等待子进程退出——当前 task 继续占着 CPU 没有任何意义。于是 task 把自己挂到等待队列、状态变成 blocked，然后显式调用 &lt;code&gt;schedule()&lt;/code&gt; 让调度器选别人来跑。这里的 &lt;code&gt;schedule()&lt;/code&gt; 是一个非常关键的函数——它是“现在真的需要选下一个 task 了”的核心调度入口。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;时机二：当前 task 跑了太久。&lt;/strong&gt; 一个 CPU-bound 的 task 一直占着 CPU 不放，但 runqueue 里还有其他 runnable task 在排队。调度器在记账时发现这一点，就会给当前 task 设置一个标记：&lt;code&gt;need_resched&lt;/code&gt;。这个标记的含义是——“当前 task 先别闷头跑了，尽快找机会重新做一次调度决策”。但要注意，&lt;strong&gt;&lt;code&gt;need_resched&lt;/code&gt; 不等于立刻切走&lt;/strong&gt;，它只是一个“该找时机换人了”的请求。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;时机三：更值得跑的 task 被唤醒了。&lt;/strong&gt; 某个 I/O 完成、某个高优先级 task 变成 runnable——如果新唤醒的 task 比当前正在跑的更应该先执行，内核就给当前 task 打上 &lt;code&gt;need_resched&lt;/code&gt;，等到合适时机发生抢占。这是 &lt;strong&gt;wakeup path 和 preemption 的交汇点&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;时机四：时钟 tick 到来。&lt;/strong&gt; 系统时钟中断会周期性触发。在时钟中断处理中，调度器可以做记账工作（当前 task 又跑了多久）、检查是否应该触发重新调度、推进各种时间相关的逻辑。但时钟 tick 不等于“每来一次就一定切换 task”——它只是提供了一个自然的检查时机。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;时机五：从内核返回用户态前。&lt;/strong&gt; 很多时候，内核在处理 syscall、中断或 fault 的过程中不一定会立刻做调度切换。但在“准备返回用户态”这个边界点上，内核会检查当前 task 有没有被标记了 &lt;code&gt;need_resched&lt;/code&gt;。如果有，就先做一次调度，再返回。这个时机之所以特别好，是因为此时 trap 已经处理完毕，当前 task 的内核态工作通常处于一致状态，不会有半个 syscall 逻辑悬在半空中。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;时机六：内核抢占点。&lt;/strong&gt; 如果内核配置为可抢占内核（preemptible kernel），那么即使当前 task 正在内核态执行，也不一定能一直跑到 syscall 结束。只要当前上下文允许抢占、没有持有禁止抢占的锁、且 &lt;code&gt;need_resched&lt;/code&gt; 已经被标记，内核就可以在某些点上被抢占。这里涉及 &lt;code&gt;preempt_count&lt;/code&gt; 的概念——它跟踪“当前这段代码是不是暂时不允许被抢占”。&lt;/p&gt;
&lt;h3&gt;need_resched 与 schedule()：标记 vs 动作&lt;/h3&gt;
&lt;p&gt;这两个概念必须区分清楚：&lt;/p&gt;
&lt;p&gt;| 概念 | 含义 |
|:---|:---|
| &lt;code&gt;need_resched&lt;/code&gt; | “该重新调度了”的请求标记。&lt;strong&gt;还没真的切换。&lt;/strong&gt; |
| &lt;code&gt;schedule()&lt;/code&gt; | “真的进入调度器，选下一个 task”的执行动作。 |&lt;/p&gt;
&lt;p&gt;一个是决策信号，一个是决策执行。&lt;code&gt;need_resched&lt;/code&gt; 只是说“该换人了”，而 &lt;code&gt;schedule()&lt;/code&gt; 才是真正动手换人。&lt;/p&gt;
&lt;h3&gt;为什么不在进入内核时立刻切换？&lt;/h3&gt;
&lt;p&gt;你可能会问：既然 syscall 要进入内核，为什么不在入口就做调度？原因有三：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;手上的活还没干完。&lt;/strong&gt; 刚进入内核时，参数还没检查、trap frame 还没整理好、可能还持有锁——在这个时候做调度切换容易导致状态不一致。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;进入内核不代表当前 task 不该跑。&lt;/strong&gt; 比如 &lt;code&gt;getpid()&lt;/code&gt; 这种极短的 syscall，每次都考虑切换反而会带来不必要的开销。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;返回用户态前才是天然边界。&lt;/strong&gt; 在这个点上，trap 处理已经完成，当前 task 的内核态工作处于一致状态，切换不会把半完成的逻辑悬在半空中。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;syscall 返回值不会丢&lt;/h3&gt;
&lt;p&gt;你可能还会担心：如果 syscall 处理完毕后，调度器决定先让别的 task 跑，那当前 task 的返回值会不会丢？答案是不会。内核在进入 syscall 时就已经保存了完整的用户态现场（寄存器、返回地址等），syscall 的返回值也会被放到约定的寄存器中。即使 task 被调度器暂时换走，等它将来再次被选中时，内核会从保存的现场恢复，一切如同无事发生。&lt;/p&gt;
&lt;p&gt;用一种更优雅的方式来理解：&lt;strong&gt;Syscall 进入内核时，本质上创建了一个“将来返回用户态”的 continuation。调度器要做的只是决定——这个 continuation 现在执行，还是以后再执行。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;一个完整的例子&lt;/h3&gt;
&lt;p&gt;让我们用一个具体场景把上面所有概念串起来：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;用户态程序正在运行
  -&gt; 时钟中断到来，CPU 进入内核态
  -&gt; 中断处理程序进行调度记账
  -&gt; 发现当前 task 已经跑了相当长的时间
  -&gt; 设置 need_resched 标记
  -&gt; 中断处理结束
  -&gt; 准备返回用户态前，检查 need_resched
  -&gt; 发现标记已设置，调用 schedule()
  -&gt; 调度器选择另一个 task 运行
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;注意：中断发生的那一瞬间并没有立刻切换 task，而是先完成中断本身的处理逻辑，然后在返回用户态这个安全的边界点上做出调度决策。&lt;/p&gt;
&lt;h2&gt;阶段性总结&lt;/h2&gt;
&lt;p&gt;走到这里，我们已经完整梳理了两条互相交织的主线：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;启动与控制流主线：&lt;/strong&gt; 从 firmware 到 bootloader，到 kernel early boot，到 &lt;code&gt;start_kernel()&lt;/code&gt;，再到 PID 1 和用户空间的启动。在稳态运行阶段，用户程序通过 syscall/exception/interrupt 进入内核，内核处理完毕后可能做一次调度决策，然后返回用户态。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;执行体与生命周期主线：&lt;/strong&gt; task 从出生（runnable）到运行（running），在 running/runnable/blocked 的三态间反复切换，可能通过 fork 分裂出后代、通过 exec 更换程序身份，最终退出变成 zombie，等待父进程 wait 回收。&lt;/p&gt;
&lt;p&gt;把这两条线编织在一起，就构成了 Linux 内核运行时行为的核心图景。让我们用一份清单来概览所有核心认知：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Linux 里执行的基本单位是 &lt;strong&gt;task&lt;/strong&gt;，不是教科书上那种抽象的“进程”&lt;/li&gt;
&lt;li&gt;线程和进程不是两种不同的实体，而是&lt;strong&gt;同一种底层对象（task）加上不同的资源共享策略&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;task_struct&lt;/code&gt; 是执行体的中心对象，&lt;code&gt;mm_struct&lt;/code&gt; 是地址空间对象，&lt;code&gt;files_struct&lt;/code&gt; 是打开文件表对象&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;fork()&lt;/code&gt; 创建新 task，&lt;code&gt;exec()&lt;/code&gt; 替换当前 task 的程序映像&lt;/strong&gt;——两步分离带来了强大的组合性&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;zombie&lt;/strong&gt; 是已退出但还没被 &lt;code&gt;wait()&lt;/code&gt; 回收的进程；&lt;strong&gt;orphan&lt;/strong&gt; 是父进程先死了但子进程还活着的进程&lt;/li&gt;
&lt;li&gt;PID 1（或 subreaper）负责接管 orphan 并承担最终的回收义务&lt;/li&gt;
&lt;li&gt;调度器不是一个独立的后台线程，而是&lt;strong&gt;嵌在各种内核路径里的决策逻辑&lt;/strong&gt;，在阻塞、唤醒、tick、返回用户态前等时机介入&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;理解了这些，你就拥有了一个分析并发行为、调试生命周期 bug、理解性能开销来源的坚实基础。内核的世界还有很多更深层的话题等待探索——锁与同步原语、内存管理、cache coherence、调度器策略——但无论走向哪个方向，今天建立起来的这套“task 对象图 + 生命周期状态机 + 调度触发时机”的思维框架，都会是你最可靠的地图。&lt;/p&gt;
&lt;h2&gt;调度类&lt;/h2&gt;
&lt;p&gt;之所以刚刚说是“阶段性总结”那是因为本章还有第二个重要介绍的内容——调度类。它非常复杂，所以后续的介绍甚至也还只是 cover 了调度类中的一部分。我们在 &lt;a href=&quot;/blog/linux-kernel-1&quot;&gt;Part 1&lt;/a&gt; 中介绍的 CFS 调度器，其实只是调度类中的一个。接下来，我们就来详细介绍一下调度类。&lt;/p&gt;
&lt;h3&gt;为什么需要调度类？&lt;/h3&gt;
&lt;p&gt;如果你对 Linux 调度器的全部印象只有“CFS 红黑树”，那第一件需要更新的认知就是：&lt;strong&gt;Linux 从不会把所有 task 一视同仁地扔进同一棵树里。&lt;/strong&gt; 在决定“谁该运行”之前，内核会先回答一个更基本的问题——这个 task 属于哪种&lt;strong&gt;调度类（sched_class）&lt;/strong&gt;？&lt;/p&gt;
&lt;p&gt;为什么要有这一层分类？原因在于，系统中运行着目标截然不同的 task。有的 task 只是普通的用户程序，追求公平地共享 CPU 时间；有的是音频处理线程，对延迟极度敏感，必须尽快响应；还有的甚至携带着明确的截止时间——“必须在 5ms 内完成这一轮计算”。&lt;/p&gt;
&lt;p&gt;如果用一套“所有人都按公平性排队”的规则来统一管理，问题就来了：音频实时线程可能被普通的 CPU 密集型程序拖慢，deadline 任务无法表达它的时限要求，而 idle 任务根本不应该和正常任务在同一个层面上竞争。&lt;/p&gt;
&lt;p&gt;所以 Linux 选择了&lt;strong&gt;分层&lt;/strong&gt;。你可以这样理解：&lt;strong&gt;先确定你属于哪个“世界”，再在那个世界内部决定你和其他人怎么比较。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;五大调度类速览&lt;/h3&gt;
&lt;p&gt;Linux 内核定义了五种调度类，按优先级从高到低排列：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;stop&lt;/strong&gt;：内核内部使用的最高优先级类，用于 CPU 热插拔、migration 等极特殊的控制任务。普通开发者几乎不会直接接触它。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;deadline&lt;/strong&gt;：面向有严格时间约束的任务。每个 task 携带运行预算（runtime）、周期（period）和截止时间（deadline），更接近调度理论中的实时调度模型。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;rt（real-time）&lt;/strong&gt;：实时任务类。比普通任务优先级高，强调及时响应和严格的优先级保证。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;fair&lt;/strong&gt;：普通任务的默认归宿，也就是 CFS 的主场。目标是让所有普通任务公平地分享 CPU 时间，用 vruntime 来记账。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;idle&lt;/strong&gt;：当一个 CPU 上真的没有任何其他 task 可以运行时，idle task 作为“保底”出场。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;一个关键认知：Linux scheduler 不是 CFS 的一言堂，而是多个调度类并存的联合体。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;调度类之间的层次关系&lt;/h3&gt;
&lt;p&gt;理解调度类之间的关系，最核心的直觉是：&lt;strong&gt;调度器在选择下一个要运行的 task 时，不是把所有 runnable task 混在一起比较，而是先按调度类的优先级逐层检查。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这个检查顺序是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;stop &gt; deadline &gt; rt &gt; fair &gt; idle
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;具体来说，调度器会先看当前 CPU 上有没有 stop 类的 runnable task，有就选它；没有，再看 deadline 类；还没有，看 rt 类；再没有，才轮到 fair 类。如果连 fair 类都是空的，那就只剩 idle task 了。&lt;/p&gt;
&lt;p&gt;这意味着什么呢？如果一个 rt task 处于 runnable 状态，那不管有多少普通 fair task 在排队，它们都得先让路。这就是为什么在实际系统中，一旦有实时线程在运行，普通程序往往会显得“被压制”——它们确实被压制了，因为它们根本不在同一个竞争层级上。&lt;/p&gt;
&lt;h3&gt;sched_class 的设计哲学&lt;/h3&gt;
&lt;p&gt;从代码设计的角度看，&lt;code&gt;sched_class&lt;/code&gt; 本质上是一种用 C 语言实现的&lt;strong&gt;多态接口&lt;/strong&gt;（polymorphic interface）。每种调度类需要提供一组标准操作：如何把 task 加入队列（enqueue）、如何移出队列（dequeue）、如何选出下一个要运行的 task（pick next）、时钟 tick 到达时该做什么（task tick）、有新 task 被唤醒时是否需要抢占当前 task（wakeup preempt check）。&lt;/p&gt;
&lt;p&gt;如果你熟悉 Linux 的 VFS 子系统，会觉得这种设计味道非常熟悉——VFS 用 &lt;code&gt;file_operations&lt;/code&gt; 加上 &lt;code&gt;inode/file/dentry&lt;/code&gt; 等抽象来实现文件系统的多态；调度器用 &lt;code&gt;sched_class&lt;/code&gt; 加上各类自己的数据结构来实现调度策略的多态。&lt;strong&gt;公共框架 + ops table + 各实现自治&lt;/strong&gt;，这是 Linux 内核中反复出现的经典设计模式。&lt;/p&gt;
&lt;h3&gt;分析调度行为的正确顺序&lt;/h3&gt;
&lt;p&gt;建立了调度类的概念后，以后看到一个 task，不应该立刻跳到“它在 CFS 红黑树里什么位置”，而应该先问一个更基本的问题：&lt;strong&gt;“它属于哪个调度类？”&lt;/strong&gt; 因为如果它是 rt 或 deadline task，那它压根不在 CFS 那套公平规则下竞争。&lt;/p&gt;
&lt;p&gt;正确的分析顺序应该是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;先看调度类&lt;/strong&gt;：这个 task 是 fair、rt 还是 deadline？&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;再看类内部规则&lt;/strong&gt;：在它所属的类里，它和同类的其他 task 怎么竞争？&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;再看外部因素&lt;/strong&gt;：它和其他 CPU、wakeup 路径、CPU affinity 之间的关系是什么？&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;per-CPU Runqueue 的真实面貌&lt;/h2&gt;
&lt;h3&gt;struct rq：本地调度状态的总控对象&lt;/h3&gt;
&lt;p&gt;在理解了调度类的分层之后，下一个需要建立的核心概念是 &lt;strong&gt;per-CPU runqueue&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;在 Linux 内核中，每个 CPU 都拥有自己独立的 runqueue，具体表现为一个 &lt;code&gt;struct rq&lt;/code&gt; 实例。但要注意，&lt;code&gt;rq&lt;/code&gt; 的名字虽然叫“runqueue”（运行队列），它实际上远不止一条简单的链表或队列。更准确地说，&lt;strong&gt;&lt;code&gt;rq&lt;/code&gt; 是这个 CPU 上所有本地调度状态的总控对象&lt;/strong&gt;——它包含了当前正在运行的 task、各个调度类的候选人集合、时间记账信息，以及大量的统计数据。&lt;/p&gt;
&lt;p&gt;让我们看看 &lt;code&gt;rq&lt;/code&gt; 中几个最重要的成员：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;curr&lt;/code&gt;&lt;/strong&gt;：指向当前这个 CPU 正在执行的 task。这是最重要的一个指针——调度器每次介入时，最先需要评估的就是 &lt;code&gt;curr&lt;/code&gt; 和其他候选人之间的关系。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;idle&lt;/code&gt;&lt;/strong&gt;：这个 CPU 的 idle task。它不是普通任务，而是当 CPU 上没有任何 runnable task 时运行的“保底”执行体。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;stop&lt;/code&gt;&lt;/strong&gt;：指向 stop class 的特殊高优先级 task，用于 CPU 控制等内核内部场景。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;nr_running&lt;/code&gt;&lt;/strong&gt;：当前 CPU 上处于 runnable 状态的 task 总数。这个计数器能帮助调度器和负载均衡逻辑快速判断这个 CPU 是否繁忙、是否存在任务积压。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;clock&lt;/code&gt;&lt;/strong&gt;：这个 runqueue 自己维护的时间基准。调度器在做公平性计算、运行时间统计、延迟判断时，都依赖这个本地时钟。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;各调度类的候选人不会混在一起&lt;/h3&gt;
&lt;p&gt;这是理解 &lt;code&gt;rq&lt;/code&gt; 结构最关键的一点。&lt;code&gt;rq&lt;/code&gt; 并不会把所有 runnable task 倒进一个大容器里，而是&lt;strong&gt;按调度类分开维护&lt;/strong&gt;各自的候选人集合：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;rq
  -&gt; current task: curr
  -&gt; idle task: idle
  -&gt; dl runnable set     // deadline 类的候选集
  -&gt; rt runnable set     // rt 类的候选集
  -&gt; fair runnable set   // fair 类的候选集（CFS 红黑树）
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以 per-CPU runqueue 的真实模样，更像是&lt;strong&gt;一个总控对象加上多套分层的 runnable 集合&lt;/strong&gt;，而不是一条“所有 task 平铺排队”的线性队列。当调度器需要选出下一个要运行的 task 时，逻辑更像是：先检查 deadline 集合里有没有人，再检查 rt 集合，再检查 fair 集合，如果全都是空的，就运行 idle task。&lt;/p&gt;
&lt;h3&gt;为什么要 per-CPU 各一个 rq？&lt;/h3&gt;
&lt;p&gt;你可能会问：为什么不用一个全局的 runqueue 来统一管理所有 CPU 的调度呢？&lt;/p&gt;
&lt;p&gt;答案和性能息息相关。采用 per-CPU 的设计，大多数 enqueue 和 dequeue 操作都可以在本地 CPU 上完成，不需要获取全局锁、不需要和其他 CPU 竞争。这样做的好处包括：减少锁竞争、利用 cache locality（本地 CPU 频繁访问自己的 &lt;code&gt;rq&lt;/code&gt;，数据更可能留在缓存中）、让调度决策尽量保持本地化。&lt;/p&gt;
&lt;p&gt;所以当一个 task 被唤醒时，内核不会说“把它放到全局大队列里吧”，而更像是在回答两个问题：&lt;strong&gt;“它该去哪个 CPU 的 rq？”&lt;/strong&gt; 以及 &lt;strong&gt;“放进那个 CPU 的哪个 class 集合里？”&lt;/strong&gt; 这一步就已经把调度、wakeup 路径和 CPU affinity 联系在一起了。&lt;/p&gt;
&lt;h3&gt;一次真正的调度决策是什么样的&lt;/h3&gt;
&lt;p&gt;假设当前 CPU 上触发了调度（不论是因为 task 阻塞、时钟 tick 到达、还是有新 task 被唤醒），一次完整的调度决策大致如下：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;内核进入调度路径（&lt;code&gt;schedule()&lt;/code&gt; 被调用）&lt;/li&gt;
&lt;li&gt;查看当前 CPU 的 &lt;code&gt;rq&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;更新 &lt;code&gt;rq&lt;/code&gt; 的时钟和记账信息&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;处理 &lt;code&gt;prev&lt;/code&gt;（当前正在运行的 task）&lt;/strong&gt;：它是继续保持 runnable？还是已经变成 blocked？还是有其他特殊状态？&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;按调度类的优先级逐层挑选 &lt;code&gt;next&lt;/code&gt;&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;找到 &lt;code&gt;next&lt;/code&gt; 后，执行 context switch&lt;/li&gt;
&lt;li&gt;更新 &lt;code&gt;rq-&gt;curr = next&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这里需要特别注意的是，&lt;strong&gt;调度决策的核心角色只有两个：&lt;code&gt;prev&lt;/code&gt;（刚才正在运行的 task）和 &lt;code&gt;next&lt;/code&gt;（即将要运行的 task）。&lt;/strong&gt; 正确的心智模型不应该是“从树里找最小 vruntime”，而是更完整的：&lt;strong&gt;处理 prev → 挑选 next → 切换 curr&lt;/strong&gt;。&lt;/p&gt;
&lt;h3&gt;prev 的三种命运&lt;/h3&gt;
&lt;p&gt;当调度器介入时，&lt;code&gt;prev&lt;/code&gt;（当前 task）并不总是面对同一种命运：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;情况 A：它阻塞了。&lt;/strong&gt; 比如它在等待 I/O 完成或等待一把锁。这种情况下，它会被从 runnable 集合中移除，暂时退出竞争 CPU 的世界。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;情况 B：它仍然是 runnable 的，只是时间片用完了或者有更高优先级的人来了。&lt;/strong&gt; 这时它会被放回自己所属 class 的 runnable 集合里，以后继续参与竞争。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;情况 C：它是 idle 或其他特殊 task。&lt;/strong&gt; 对此有各自的特殊处理逻辑。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;换句话说，一次调度不仅仅是“选谁上来”的问题，还包括**“把当前运行的人妥善安置到正确的位置”**。&lt;/p&gt;
&lt;h3&gt;next 是怎么被选出来的&lt;/h3&gt;
&lt;p&gt;选出下一个 task 的过程，概念上可以用伪代码表示：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pick next:
  if deadline class 有 runnable task:
      从 deadline 里选
  else if rt class 有 runnable task:
      从 rt 里选
  else if fair class 有 runnable task:
      在 fair 内部按 vruntime/EEVDF 规则选
  else:
      选 idle task
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;整个 pick 过程本质上是两层：&lt;strong&gt;第一层按调度类的优先级逐层淘汰，第二层在被选中的类内部用该类自己的规则做最终决策。&lt;/strong&gt; 这就是 Linux 调度器最基本的骨架。&lt;/p&gt;
&lt;h3&gt;curr 和 runnable 集合的关系&lt;/h3&gt;
&lt;p&gt;还有一个值得单独强调的直觉：&lt;code&gt;curr&lt;/code&gt; 是“此刻已经占据 CPU 的人”，而各调度类的 runnable 集合是“候选人池”。&lt;code&gt;curr&lt;/code&gt; 不是一个虚拟的抽象概念，它就是当前这个 CPU 上真正在执行指令的那个 task。而 runqueue 里其他 runnable task 则是随时准备接班的候补选手。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;调度问题的本质，就是在回答这样一个问题：当前占着 CPU 的人还能不能继续占下去？如果不能，候选池里谁来接班？&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;Wakeup Path&lt;/h2&gt;
&lt;h3&gt;wakeup 的本质：获得参赛资格，而非直接冲线&lt;/h3&gt;
&lt;p&gt;在理解了 runqueue 的结构后，我们来看一个调度器中特别容易产生误解的环节——&lt;strong&gt;唤醒路径&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;先给出一个最核心的判断：&lt;strong&gt;wakeup 的本质不是“让 task 马上执行”，而是“让 task 重新具备竞争 CPU 的资格”。&quot;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;很多对调度行为的直觉性误判，都源于把 wakeup 和 running 混淆成了同一件事。一个 task 被唤醒，仅仅意味着它从“沉睡”状态回到了“可以参与竞争”的状态——至于它能不能真的跑起来，还得看当时的竞争环境。&lt;/p&gt;
&lt;h3&gt;task 是怎么“睡下去”的&lt;/h3&gt;
&lt;p&gt;要理解 wakeup，首先要理解一个 task 是如何进入睡眠的。一个 task 进入睡眠，通常会经历以下步骤：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;发现条件不满足&lt;/strong&gt;：比如要读的数据还没到、要获取的锁被别人持有、要等待的子进程还没退出。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把自己挂到 wait queue 上&lt;/strong&gt;：wait queue（等待队列）是内核中一种常见的数据结构，你可以把它理解为“一群正在等待同一个条件成立的 task 的列表”。等某个文件描述符变得可读、等某把锁被释放、等某次 I/O 完成——这些等待都不是抽象的“空想”，内核会将等待者挂到一个具体的数据结构上。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;将自己的状态改为 sleeping / blocked&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;调用 &lt;code&gt;schedule()&lt;/code&gt;&lt;/strong&gt;，主动让出 CPU&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;经过这个过程后，task 就退出了当前 CPU 的竞争。在被唤醒之前，它不在任何 CPU 的 runnable 集合里，而是静静地挂在某个 wait queue 上。&lt;/p&gt;
&lt;p&gt;这里有一个重要的认知需要建立：&lt;strong&gt;调度器本身并不负责“记住每个 task 在等什么”。&lt;/strong&gt; 那是由各个具体子系统的 wait queue 来表达的——I/O 子系统有它的等待队列，锁机制有它的等待队列，各种同步原语也各自维护着等待队列。调度器只关心 task 的状态：runnable 还是 blocked。&lt;/p&gt;
&lt;h3&gt;条件满足时，wakeup 路径到底做了什么&lt;/h3&gt;
&lt;p&gt;当等待的条件终于满足（I/O 完成、锁释放、数据到达、超时触发），内核会沿着 wait queue 找到等待的 task，然后执行一整条唤醒路径：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;找到等待的 task&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;将它从 wait queue 上摘下来&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;将它的状态改回 runnable&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;选择一个目标 CPU&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;将它 enqueue 到那个 CPU 的 runqueue 中相应调度类的集合里&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;判断是否需要触发抢占&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;注意，wakeup 不是一个点状的动作，而是一条完整的路径：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;wait queue
-&gt; 状态改为 runnable
-&gt; 选择目标 CPU
-&gt; enqueue 到目标 rq
-&gt; 可能触发对当前 task 的抢占
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;为什么被唤醒不等于立刻运行&lt;/h3&gt;
&lt;p&gt;因为 runqueue 是一个&lt;strong&gt;竞争池&lt;/strong&gt;，不是一个执行保证。被唤醒之后，task 只是从 blocked 变成了 runnable，回到了“可以抢 CPU”的世界。但它能不能立刻变成 running，还取决于很多因素：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;目标 CPU 上是否已经有人在运行&lt;/li&gt;
&lt;li&gt;它属于哪个调度类（如果它是 fair task，而当前 CPU 上有一个 rt task 在跑，它就得等）&lt;/li&gt;
&lt;li&gt;它的优先级、vruntime、截止时间如何&lt;/li&gt;
&lt;li&gt;它被放到了哪个 CPU&lt;/li&gt;
&lt;li&gt;当前上下文是否允许立刻进行抢占（比如是否还在中断处理中）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;用一个比喻来说：&lt;strong&gt;wakeup 相当于拿到了比赛的入场资格，而 running 才是真正站上赛场。&lt;/strong&gt; 这是两个不同的阶段。&lt;/p&gt;
&lt;h3&gt;一个具体的例子&lt;/h3&gt;
&lt;p&gt;假设 task A 正在运行，它发起了一次磁盘读取：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;A 正在运行
-&gt; 调用 read()
-&gt; 数据尚未到达
-&gt; A 变为 blocked，被挂到 I/O 子系统的 wait queue 上
-&gt; 调用 schedule()
-&gt; CPU 切换去运行 task B

... 时间过去 ...

磁盘 I/O 完成：
-&gt; 中断到达
-&gt; I/O 完成处理逻辑执行
-&gt; 将 A 从 wait queue 上摘下
-&gt; A 的状态变为 runnable
-&gt; A 被 enqueue 到某个 CPU 的 rq 上
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但此时 A 还不一定马上运行。因为当前 CPU 可能还在处理中断、B 可能仍在运行并且时间片还没用完、调度器也可能判断让 B 跑完当前这一小段更合适、或者 A 被放到了另一个 CPU 上而那个 CPU 也有事在做。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;wakeup 只是“把 A 重新送回赛道”，不是“让 A 立刻冲过终点”。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;目标 CPU 是怎么选择的&lt;/h3&gt;
&lt;p&gt;wakeup 路径中有一个非常关键的步骤值得展开——&lt;strong&gt;CPU 选择&lt;/strong&gt;。一个 task 醒来后，内核需要决定把它放到哪个 CPU 的 &lt;code&gt;rq&lt;/code&gt; 上。这个决策会考虑很多因素：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;它上次运行在哪个 CPU&lt;/strong&gt;：如果还放在那个 CPU 上，之前的 L1/L2 cache 内容可能还是热的，性能更好。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;cache locality 是否值得保留&lt;/strong&gt;：如果 task 最近才在某个 CPU 上运行过，缓存数据可能仍然有效。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CPU affinity 约束&lt;/strong&gt;：task 可能通过 &lt;code&gt;sched_setaffinity()&lt;/code&gt; 限制了只能在某些 CPU 上运行。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;目标 CPU 的负载情况&lt;/strong&gt;：如果某个 CPU 已经很忙了，把 task 放过去只会增加排队时间。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;唤醒者在哪个 CPU&lt;/strong&gt;：有时候把被唤醒的 task 放在唤醒者所在的 CPU 附近，可以获得更好的局部性。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;调度类的特殊要求&lt;/strong&gt;：不同调度类可能有不同的 CPU 选择偏好。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以 wakeup path 不仅仅是一个状态转换（从 blocked 到 runnable），而是：&lt;strong&gt;从睡眠状态，进入某个具体 CPU 上的具体调度竞争环境。&lt;/strong&gt; 调度策略、缓存局部性和负载均衡在这一步就已经开始交织了。&lt;/p&gt;
&lt;h3&gt;什么情况下 task 醒了能很快抢占当前 task&lt;/h3&gt;
&lt;p&gt;虽然说 wakeup 不等于立刻运行，但确实存在一些情况下，被唤醒的 task 能够很快就抢占当前正在运行的 task：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;高优先级任务醒来&lt;/strong&gt;：如果一个 rt task 被唤醒了，而当前 CPU 上跑的是普通 fair task，那 fair task 通常很快就会被切走。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;交互型 task 有很大的“公平欠账”&lt;/strong&gt;：比如一个交互型 task 睡了很久，它的 vruntime 相对于其他人来说非常小（意味着它“欠”了很多 CPU 时间），那它醒来后会非常有竞争力，很可能很快就能上 CPU。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;当前 task 已经被标记为“该让出了”&lt;/strong&gt;：如果当前 task 已经跑了很长时间，而刚醒来的 task 又很有竞争力，抢占就很容易被触发。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;但即使在这些情况下，也不一定是“中断发生的那一纳秒就立刻切换”。很多时候，内核仍然会选择在一个合适的、安全的边界上完成切换。&lt;/p&gt;
&lt;h3&gt;wakeup 和 preemption 的关系&lt;/h3&gt;
&lt;p&gt;这两个概念联系紧密，但它们不是一回事：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;wakeup&lt;/strong&gt;：让一个 blocked task 重新变成 runnable——“有一个新的候选人回来了”&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;preemption&lt;/strong&gt;：把当前正在 running 的 task 切走，让别的 task 上 CPU——“当前占位者被换下了”&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;wakeup 可能导致 preemption（新来的候选人太强了，当前选手被换下），但不是一定会导致。正确的理解是：&lt;strong&gt;wakeup 是“候选人回到赛场”，preemption 是“当前选手被替换下场”。前者是后者的一种触发条件，但不是充分条件。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;wakeup latency 为什么重要&lt;/h3&gt;
&lt;p&gt;wakeup latency 指的是：从“条件满足、task 被唤醒”到“task 真正开始在 CPU 上执行”之间经过的时间。&lt;/p&gt;
&lt;p&gt;这个延迟之所以重要，是因为很多用户可感知的交互体验问题，以及很多实时性场景的性能瓶颈，都出在这里。想象一下：键盘输入已经到达了、处理键盘事件的线程也已经被唤醒了，但它在 8ms 之后才真正获得 CPU 开始处理——用户就会感觉到明显的输入延迟或卡顿。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;调度器不仅需要关心吞吐量和公平性，还必须关心 wakeup 之后能不能尽快让等待的 task 得到响应。&lt;/strong&gt; 这正是 wakeup path 在 scheduler 设计中占据重要地位的原因。&lt;/p&gt;
&lt;h2&gt;Preemption&lt;/h2&gt;
&lt;h3&gt;preemption 的最短定义&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;抢占（preemption）= 当前 task 还没有主动放弃 CPU，但内核决定暂停它的执行，把 CPU 交给另一个更值得运行的 task。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这和 task 自己主动阻塞是完全不同的两件事：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;阻塞（blocking）&lt;/strong&gt;：当前 task 自己说“我现在无法继续推进了（在等 I/O / 等锁），先去睡一会儿”——这是 task 的主动选择。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;抢占（preemption）&lt;/strong&gt;：内核说“你虽然还能继续运行，但现在有人比你更需要 CPU，先停一下”——这是调度器的强制干预。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;用户态抢占：最直观的场景&lt;/h3&gt;
&lt;p&gt;考虑最简单的情况：task A 正在用户态执行一个无限循环 &lt;code&gt;while (1) { compute(); }&lt;/code&gt;。它没有发起任何系统调用、没有主动阻塞、也没有调用任何“让出 CPU”的函数。如果没有抢占机制，A 就会永远占着 CPU，系统中的其他 task 全部饿死。&lt;/p&gt;
&lt;p&gt;显然，操作系统不能允许这种事情发生。&lt;/p&gt;
&lt;p&gt;实际会发生的是：当时钟中断到达、或者有更高优先级的 task 被唤醒时，内核会评估当前的调度状况。如果判断 A 已经运行了足够长的时间、或者有人比 A 更应该获得 CPU，内核就会在 A 的 &lt;code&gt;thread_info&lt;/code&gt; 中设置一个 &lt;code&gt;need_resched&lt;/code&gt; 标志。然后，当控制流到达一个合适的边界——最典型的就是&lt;strong&gt;从中断返回用户态之前&lt;/strong&gt;——内核会检查这个标志，发现它已经被设置，于是调用 &lt;code&gt;schedule()&lt;/code&gt; 完成 task 切换。&lt;/p&gt;
&lt;h3&gt;内核态抢占：为什么更复杂&lt;/h3&gt;
&lt;p&gt;用户态抢占相对简单——用户态代码就是普通的计算逻辑，在任何指令边界上打断它通常都不会造成问题。但内核态就不一样了。&lt;/p&gt;
&lt;p&gt;内核态代码不只是在做“普通计算”。它可能正在修改关键的内核数据结构、持有某把保护共享资源的锁、处理中断相关的状态、访问必须保持一致性的对象。如果在这些操作进行到一半的时候随便抢占，系统就可能陷入不一致的状态，导致各种难以调试的问题。&lt;/p&gt;
&lt;p&gt;所以内核态不是“任意时刻都能被抢占”的，而是需要检查当前上下文是否安全。这里有一个核心概念——&lt;strong&gt;&lt;code&gt;preempt_count&lt;/code&gt;&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;当 &lt;code&gt;preempt_count == 0&lt;/code&gt; 时：说明当前处于普通上下文中，原则上允许内核抢占。&lt;/li&gt;
&lt;li&gt;当 &lt;code&gt;preempt_count != 0&lt;/code&gt; 时：说明当前处于某种不适合被抢占的上下文中（比如持有自旋锁、处于中断处理中、或者显式关闭了抢占）。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;哪些情况下通常不能抢占&lt;/h3&gt;
&lt;p&gt;最重要的几类场景：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;持有自旋锁（spinlock）时。&lt;/strong&gt; 自旋锁保护的通常是非常短但非常敏感的临界区。如果一个 task 拿着自旋锁被抢占切走了，那另一个 CPU 上试图获取同一把锁的 task 就会一直自旋等待（spin）。被切走的 task 因为没有在运行，无法释放锁，而等待的 task 又在空耗 CPU 不停自旋——这会造成严重的性能问题，甚至在某些情况下导致死锁。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;处于中断或原子上下文时。&lt;/strong&gt; 这类上下文本来就不允许随意睡眠或切换，因为它们通常有严格的执行约束。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;显式关闭抢占时。&lt;/strong&gt; 某些内核代码会主动调用 &lt;code&gt;preempt_disable()&lt;/code&gt; 说“这段代码必须不被打断地执行完”，比如操作 per-CPU 数据时。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;核心结论：“内核可抢占”从来不是绝对的，它始终依赖于当前的执行上下文。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;need_resched：意图与实施的分离&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;need_resched&lt;/code&gt; 是抢占机制中一个精妙的中介信号。它的含义不是“立刻硬切走当前 task”，而是**“当前 task 应该在最近的合适时机让出 CPU”**。&lt;/p&gt;
&lt;p&gt;一次典型的抢占触发路径是这样的：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;时钟 tick 到达、或者有新 task 被唤醒&lt;/li&gt;
&lt;li&gt;scheduler 评估后判断当前 task 应该被切换&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;设置 &lt;code&gt;need_resched&lt;/code&gt; 标志&lt;/strong&gt;（标记意图）&lt;/li&gt;
&lt;li&gt;等到达安全边界：返回用户态前、内核重新允许抢占的位置、或显式调度点&lt;/li&gt;
&lt;li&gt;在安全边界上调用 &lt;code&gt;schedule()&lt;/code&gt;（执行实施）&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这里的设计哲学是：&lt;strong&gt;&lt;code&gt;need_resched&lt;/code&gt; 是“意图”，&lt;code&gt;schedule()&lt;/code&gt; 是“实施”。&lt;/strong&gt; 意图可以在任何时候产生（比如在中断处理程序中），但实施必须等到安全的时刻。&lt;/p&gt;
&lt;h3&gt;为什么“安全边界”如此重要&lt;/h3&gt;
&lt;p&gt;因为内核需要维持其数据结构的一致性。想象一下，如果在以下操作进行到一半时被切走：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;一个链表正在被修改——已经修改了 &lt;code&gt;next&lt;/code&gt; 指针，但还没来得及更新 &lt;code&gt;prev&lt;/code&gt; 指针&lt;/li&gt;
&lt;li&gt;一个引用计数刚刚递增了，但相关的状态还没有更新&lt;/li&gt;
&lt;li&gt;某个对象正处于中间态，既不是旧状态也不是新状态&lt;/li&gt;
&lt;li&gt;一把锁已经获取了但还没释放&lt;/li&gt;
&lt;li&gt;中断控制器的状态还没有完全恢复&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;在任何这些情况下被切走，都可能导致其他 task 看到不一致的状态，进而引发各种问题。&lt;/p&gt;
&lt;p&gt;所以内核调度和抢占的一个核心原则是：&lt;strong&gt;尽量在数据结构处于一致状态的边界点进行切换。&lt;/strong&gt; 典型的安全边界包括：task 主动阻塞时、从中断/异常/syscall 返回用户态之前、重新开启抢占之后、以及代码中显式的调度点。&lt;/p&gt;
&lt;h3&gt;抢占与延迟的权衡&lt;/h3&gt;
&lt;p&gt;调度器在抢占机制上需要平衡两个彼此矛盾的目标：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;尽快响应更重要的 task&lt;/strong&gt;：如果一个高优先级的实时 task 被唤醒了，我们希望它尽快获得 CPU。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;不要在不安全的位置打断当前内核执行路径&lt;/strong&gt;：在关键操作中途切走会导致一致性问题。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;太保守（很少抢占）：当前 task 会占 CPU 太久，调度延迟变差，用户感觉系统响应迟钝。太激进（频繁抢占）：内核一致性维护更加困难，锁的复杂度增加，并发问题激增。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;preemption 本质上是一种平衡机制：在“尽快响应”和“保证内核执行安全”之间寻找最佳折中点。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;一个典型的抢占例子&lt;/h3&gt;
&lt;p&gt;假设 task A 是一个普通的 fair task，正在用户态做计算。task B 是一个刚被唤醒的交互型任务（比如用户刚刚按下了键盘）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;A 在用户态运行
-&gt; 时钟中断到达 / B 被唤醒
-&gt; scheduler 判断 B 比 A 更值得先运行
-&gt; 在 A 上设置 need_resched
-&gt; 到达安全边界（比如从中断返回用户态前）
-&gt; 调用 schedule()
-&gt; 从 A 切换到 B
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;注意：A 并没有自己主动要求阻塞或让出 CPU，是调度器基于公平性或优先级的判断决定让它暂停。这就是抢占。&lt;/p&gt;
&lt;h2&gt;Load Balancing 和 CPU Affinity&lt;/h2&gt;
&lt;h3&gt;多核环境下的核心问题&lt;/h3&gt;
&lt;p&gt;到目前为止，我们已经建立了这些概念：调度的基本单位是 task；每个 CPU 有自己的 &lt;code&gt;rq&lt;/code&gt;；&lt;code&gt;rq-&gt;curr&lt;/code&gt; 是当前正在运行的 task；runnable task 会进入各自调度类的本地集合；wakeup 会把 task enqueue 到某个 CPU 的队列上；preemption 决定是否需要在当前 CPU 上立刻换人。&lt;/p&gt;
&lt;p&gt;但在多核环境下，有一个我们还没有展开讨论的核心问题：&lt;strong&gt;task 应该放在哪个 CPU 上运行？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这个问题之所以关键，是因为“放在哪”会同时影响吞吐量、延迟、cache locality、NUMA locality、锁竞争和负载均衡等多个维度。这就是 CPU affinity、load balancing 和 migration 登场的地方。&lt;/p&gt;
&lt;h3&gt;CPU affinity：task 可以跑在哪些 CPU 上&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;CPU affinity = 一个 task 被允许或倾向在哪些 CPU 上运行。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这个概念可以从两个层面来理解：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;硬亲和性（hard affinity）&lt;/strong&gt;：通过 &lt;code&gt;sched_setaffinity()&lt;/code&gt; 等接口设置的 cpumask，明确限制 task 只能在某些 CPU 上运行。如果一个 task 的 affinity mask 只包含 CPU 0 和 CPU 1，那它永远不会被调度到 CPU 2 上。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;软亲和性（soft affinity）&lt;/strong&gt;：没有硬性限制，但调度器会倾向于让 task 留在它最近运行过的 CPU 上，以保留 cache 和其他硬件状态的局部性。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以 affinity 不仅关乎“能不能在某个 CPU 上跑”，还关乎“更适合在哪个 CPU 上跑”。在内核实现中，这通常体现为 cpumask、per-task 的 allowed CPUs 集合，以及 scheduler domain 和 CPU topology 的约束。&lt;/p&gt;
&lt;h3&gt;为什么不能随便迁移 task&lt;/h3&gt;
&lt;p&gt;既然多个 CPU 都有各自的 runqueue，一个自然的想法就是：如果某个 CPU 太忙了，把一些 task 搬到空闲的 CPU 上不就好了？&lt;/p&gt;
&lt;p&gt;思路是对的，但搬迁（migration）并不是免费的午餐。一个 task 留在原来的 CPU 上运行，通常有以下好处：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;L1/L2 cache 中可能还保留着它之前访问过的数据&lt;/strong&gt;，避免了冷启动&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TLB 中可能还有它的地址翻译条目&lt;/strong&gt;，避免了 TLB miss&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;分支预测器可能已经学习了它的分支模式&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;per-CPU 数据的访问更高效&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果频繁地把 task 从 CPU 0 搬到 CPU 7，所有这些“热”状态都会丢失，task 到了新 CPU 后相当于冷启动，实际性能可能反而下降。如果涉及跨 NUMA 节点的迁移，代价还会更大。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;调度器的目标从来不是“让所有 CPU 的负载绝对均匀”，而是在“平衡负载”和“保留局部性”之间寻找折中。&lt;/strong&gt; 这一点非常关键。&lt;/p&gt;
&lt;h3&gt;负载均衡（Load Balancing）&lt;/h3&gt;
&lt;p&gt;负载均衡的目标用一句话概括就是：&lt;strong&gt;当某些 CPU 太忙、另一些 CPU 太闲时，把 runnable task 重新分布得更合理。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;举个例子：如果 CPU 0 上堆积了 10 个 runnable task，而 CPU 1 上几乎空着——不做任何干预的话，CPU 0 上的 task 排队严重，响应变慢，而 CPU 1 上的计算能力被白白浪费。这时调度器会尝试通过 pull work（把其他 CPU 的 task 拉过来）或 push work（把自己的 task 推出去）来重新分配负载。&lt;/p&gt;
&lt;p&gt;但请注意，balancing 的目标不是追求数学上的完美均衡，而是避免明显的失衡、减少不必要的排队和 CPU 空闲。&lt;/p&gt;
&lt;h3&gt;两种不同的均衡手段&lt;/h3&gt;
&lt;p&gt;多核调度中的负载均衡，实际上有两种主要的触发时机和方式：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Wakeup placement（唤醒时放置）。&lt;/strong&gt; 当一个 task 从 blocked 变回 runnable 时，调度器在 wakeup path 中当场决定把它放到哪个 CPU。如果这一步做得好——比如选择了一个既不太忙、又对 cache 友好的 CPU——后续就不需要频繁迁移。这是一种“在入口处做决定”的策略。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Periodic / idle balancing（周期性和空闲时均衡）。&lt;/strong&gt; 即使 wakeup 时的初始放置不是最优的，调度器后续仍然有机会修正。它会在周期性检查、某个 CPU 变得空闲、或某个 scheduler domain 触发均衡时，重新评估是否有必要把某些 CPU 上的 runnable task 迁移到其他 CPU 上。&lt;/p&gt;
&lt;p&gt;所以多核调度不是一次性的决策，而是&lt;strong&gt;初始放置 + 持续修正&lt;/strong&gt;的动态过程。&lt;/p&gt;
&lt;h3&gt;migration 的代价&lt;/h3&gt;
&lt;p&gt;migration 的表面看起来很简单——不过是把一个 runnable task 从一个 CPU 的 &lt;code&gt;rq&lt;/code&gt; 搬到另一个 CPU 的 &lt;code&gt;rq&lt;/code&gt;。但在实现层面，这涉及的操作远不止“移动一个指针”：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;需要从源 CPU 的对应调度类集合中 dequeue&lt;/li&gt;
&lt;li&gt;需要更新目标 CPU 的各种 bookkeeping 信息&lt;/li&gt;
&lt;li&gt;需要 enqueue 到目标 CPU 的对应调度类集合中&lt;/li&gt;
&lt;li&gt;如果必要，还要在目标 CPU 上触发抢占&lt;/li&gt;
&lt;li&gt;整个过程涉及双方 &lt;code&gt;rq&lt;/code&gt; 的锁、负载统计更新、affinity 合法性检查和调度类特定的 dequeue/enqueue 逻辑&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;migration 绝不是一个轻量的“数组元素搬位置”操作。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;Scheduler Domains 和硬件拓扑&lt;/h3&gt;
&lt;p&gt;Linux 不会天真地把所有 CPU 视为完全等价的平面结构。它能够感知硬件的层次结构，并据此组织调度逻辑。典型的层次包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;SMT siblings&lt;/strong&gt;：超线程共享同一个物理核心&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;同 core / 同 cluster&lt;/strong&gt;：共享某一级缓存（比如 L2）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;同 socket&lt;/strong&gt;：共享最后一级缓存（LLC）和内存控制器&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;同 NUMA node&lt;/strong&gt;：共享本地内存&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;跨 NUMA node&lt;/strong&gt;：跨互连访问远程内存&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些层次会直接影响负载均衡的策略——在同一个物理核心或同一个 LLC 范围内迁移 task，代价相对较小（缓存共享程度高）；而跨 socket 或跨 NUMA 节点迁移，代价可能大得多（缓存完全冷启动 + 内存访问延迟增加）。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Linux scheduler 基于这种硬件拓扑构建了 scheduler domain 和 sched group 的层次结构，让 balancing 决策能够感知迁移的“距离”和“代价”。&lt;/strong&gt; 这就是 Linux 调度器多核部分复杂性的根源之一。&lt;/p&gt;
&lt;h3&gt;Idle balancing：空闲 CPU 主动找活干&lt;/h3&gt;
&lt;p&gt;当一个 CPU 变成空闲状态时（当前 runqueue 上没有 runnable task 了），它不会坐等任务到来。相反，它通常会主动尝试：&lt;strong&gt;“我自己没活干了，不如从别的 CPU 那里拉一些 runnable task 过来？”&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这就是典型的 &lt;strong&gt;pull model&lt;/strong&gt;。这种做法的好处是：不需要等全局统一的调度决策，空闲的 CPU 主动拉取工作负载，在多核系统上比较自然高效。&lt;/p&gt;
&lt;h3&gt;调度器的启发式决策&lt;/h3&gt;
&lt;p&gt;在实际的 wakeup path 和 balancing 决策中，调度器不会仅仅看“哪个 CPU 最空”就把 task 搬过去。它还会综合考虑：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;这个 task 最近是不是就在某个 CPU 上运行过（cache 热度）&lt;/li&gt;
&lt;li&gt;唤醒它的任务在哪个 CPU（放在同一个 CPU 可以提高局部性）&lt;/li&gt;
&lt;li&gt;如果放在另一个空闲 CPU 上，迁移带来的收益是否大于局部性损失&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;调度器做的很多时候不是“求最优解”，而是基于启发式规则做出“足够好”的决策——locality-aware、load-aware、latency-aware 的综合 placement。&lt;/strong&gt; 这也是为什么阅读 scheduler 源码时，你会发现大量的启发式判断（heuristic），而不是纯粹的教科书式公式。&lt;/p&gt;
&lt;h3&gt;affinity 的约束来源不只是用户设置&lt;/h3&gt;
&lt;p&gt;一个 task 最终能运行在哪些 CPU 上，不仅取决于用户通过 &lt;code&gt;sched_setaffinity()&lt;/code&gt; 手动设置的绑核策略，还可能受到以下因素的约束：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;cpuset cgroup&lt;/strong&gt;：容器或 cgroup 对 CPU 集合的限制&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CPU 配额和隔离配置&lt;/strong&gt;：如 &lt;code&gt;isolcpus&lt;/code&gt;、&lt;code&gt;nohz_full&lt;/code&gt; 等内核启动参数&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;实时任务策略的要求&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;内核线程的绑定需求&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;调度器最终看到的 allowed CPUs 集合，往往是多种约束综合作用后的结果。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;NUMA&lt;/h2&gt;
&lt;h3&gt;NUMA 是什么？&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;NUMA，全称 Non-Uniform Memory Access（非一致内存访问）。&lt;/strong&gt; 它描述的是这样一种硬件现实：一台机器上的所有物理内存，对所有 CPU 来说并不是以同样的代价可达的——有的内存“离这个 CPU 近”，访问速度更快、延迟更低；有的内存“离这个 CPU 远”，需要经过额外的硬件互连才能到达，访问代价更高。&lt;/p&gt;
&lt;h3&gt;为什么会出现 NUMA？&lt;/h3&gt;
&lt;p&gt;在只有少量 CPU 核心的小型系统上，所有核心共享同一个内存控制器，访问任意内存地址的代价基本相同——这种架构被称为 &lt;strong&gt;UMA（Uniform Memory Access）&lt;/strong&gt;。你的笔记本电脑大概率就是这种架构。&lt;/p&gt;
&lt;p&gt;但到了多路 CPU 的服务器上，情况就不一样了。每个 CPU socket 通常自带内存控制器和一组 DIMM 插槽：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Socket 0                      Socket 1
  -&gt; CPU cores                  -&gt; CPU cores
  -&gt; 本地内存控制器                -&gt; 本地内存控制器
  -&gt; 本地 DIMMs                  -&gt; 本地 DIMMs
        |                            |
        +---------- 互连总线 ----------+
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在这种架构下，Socket 0 上的 CPU 核心访问 Socket 0 本地的内存很快（直接通过本地内存控制器），但访问 Socket 1 的内存就需要经过芯片间的互连总线（比如 Intel 的 UPI 或 AMD 的 Infinity Fabric），延迟会显著增加。&lt;/p&gt;
&lt;p&gt;关键在于：这仍然是一台“共享内存”的机器——所有 CPU 理论上都能访问所有 RAM，只不过代价不一致。&lt;strong&gt;一句话总结：NUMA 不是“内存不能共享”，而是“共享，但远近有别”。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;理解 NUMA 的核心抽象&lt;/h3&gt;
&lt;p&gt;理解 NUMA 最好的方式是把它想象成：&lt;strong&gt;一台机器内部有多个“小岛”，每个小岛都有自己的 CPU 核心和一块本地内存。这些小岛之间可以互相访问对方的内存，但跨岛访问的代价比岛内访问要高。&lt;/strong&gt; 每个小岛就是一个 &lt;strong&gt;NUMA node&lt;/strong&gt;。&lt;/p&gt;
&lt;h3&gt;NUMA 对程序的影响&lt;/h3&gt;
&lt;p&gt;假设一个线程运行在 Node 0 的 CPU 上，但它频繁访问的内存页大部分分配在 Node 1——这意味着每次内存访问都要“跨岛取东西”。直接后果可能包括：吞吐量下降、延迟升高、内存带宽利用变差、程序的可扩展性变差。&lt;/p&gt;
&lt;p&gt;所以在 NUMA 系统上，一个核心的优化原则是：&lt;strong&gt;尽量让计算和数据待在同一个 node 上。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;Linux 如何感知和管理 NUMA&lt;/h3&gt;
&lt;p&gt;Linux 内核完整地感知了 NUMA 的拓扑结构。它知道每个物理页属于哪个 node、每个 CPU 属于哪个 node。基于这些信息，调度器和内存管理子系统可以做出更明智的决策：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;task 应该运行在哪个 CPU 上？&lt;/li&gt;
&lt;li&gt;它的内存页在哪个 node 上？&lt;/li&gt;
&lt;li&gt;如果 task 和它的内存不在同一个 node，应该搬 task 还是搬内存？&lt;/li&gt;
&lt;li&gt;是否需要自动进行 NUMA 平衡？&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;NUMA 与调度器的交织&lt;/h3&gt;
&lt;p&gt;如果调度器只看“哪个 CPU 最空”就决定把 task 搬过去，在 NUMA 系统上可能会做出糟糕的决策。考虑这个场景：CPU 7 很空闲，但它在 Node 1 上，而 task 的所有热数据都在 Node 0 的内存中。把 task 搬到 CPU 7 上虽然减轻了 Node 0 的 CPU 负载，但每次内存访问都变成了远程访问，实际性能可能反而下降。&lt;/p&gt;
&lt;p&gt;所以调度器需要在 &lt;strong&gt;CPU 负载均衡&lt;/strong&gt;和&lt;strong&gt;内存局部性&lt;/strong&gt;之间做权衡。&lt;strong&gt;在 NUMA 机器上，“把 task 放在哪个 CPU 上跑”和“task 的数据在哪个 node 上”同样重要。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;内存分配的 NUMA 感知&lt;/h3&gt;
&lt;p&gt;内核在分配内存页时，不仅仅是“找一页空闲内存”那么简单。它还会考虑“从哪个 node 分”。理想的情况是：task 在 Node 0 上运行，分配给它的内存页也来自 Node 0。&lt;/p&gt;
&lt;p&gt;这就引出了一个重要的策略——&lt;strong&gt;first-touch policy（首次触碰策略）&lt;/strong&gt;。很多时候，页不是在 &lt;code&gt;malloc()&lt;/code&gt; 调用时就真正分配的（那只是虚拟地址空间的预留），而是在第一次被实际访问时通过 page fault 触发真正的物理页分配。这时 Linux 通常倾向于：&lt;strong&gt;从发生 page fault 的那个 CPU 所在的 node 分配物理页。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这个策略对并行程序有一个重要的实践启示：让将来要使用某块数据的线程自己去做初始化。比如在一个并行数组计算中，不要用一个主线程预先初始化所有数据（这会导致所有页都分配在主线程所在的 node），而应该让每个工作线程初始化属于自己的那一部分。这样做不仅是“写一遍数组”那么简单——它还决定了物理页最终落在哪个 NUMA node 上。这个技巧在 HPC 和服务器调优中非常常见。&lt;/p&gt;
&lt;h3&gt;Automatic NUMA Balancing&lt;/h3&gt;
&lt;p&gt;Linux 不仅仅被动接受内存的初始分布，还会尝试动态修正。内核可以观察 task 实际在访问哪些内存页，如果发现 task 和它频繁访问的内存长期分离在不同 node 上，它可能会：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;把 task 迁移到更靠近其内存的 CPU 上（让人靠近数据）&lt;/li&gt;
&lt;li&gt;或者把内存页迁移到 task 所在的 node 上（让数据靠近人）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;核心权衡是：&lt;strong&gt;是让人去找数据，还是让数据来找人？&lt;/strong&gt; Linux 的 automatic NUMA balancing 试图自动化这个决策。&lt;/p&gt;
&lt;h3&gt;NUMA 在哪些场景下影响最明显&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;多路服务器（2 路、4 路甚至更多 socket）&lt;/li&gt;
&lt;li&gt;大内存数据库（如 PostgreSQL、MySQL 在大内存机器上）&lt;/li&gt;
&lt;li&gt;内存带宽敏感的工作负载&lt;/li&gt;
&lt;li&gt;多线程高并发服务（如 web 服务器、消息队列）&lt;/li&gt;
&lt;li&gt;大规模 in-memory cache（如 Redis 集群）&lt;/li&gt;
&lt;li&gt;高性能计算（HPC）和科学计算&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果是单路的笔记本或小型桌面机、且负载不重，你实际上很难感受到 NUMA 的存在。&lt;/p&gt;
&lt;h3&gt;观察 NUMA 的工具&lt;/h3&gt;
&lt;p&gt;几个最常用的命令：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;lscpu&lt;/code&gt;：查看 CPU 拓扑和 NUMA node 信息&lt;/li&gt;
&lt;li&gt;&lt;code&gt;numactl --hardware&lt;/code&gt;：查看有哪些 node、每个 node 包含哪些 CPU 和多少内存&lt;/li&gt;
&lt;li&gt;&lt;code&gt;numastat&lt;/code&gt;：查看 NUMA 相关的统计数据&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/sys/devices/system/node/&lt;/code&gt;：内核通过 sysfs 暴露的 node 信息&lt;/li&gt;
&lt;li&gt;&lt;code&gt;numactl&lt;/code&gt;：控制进程的 CPU 和内存策略（&lt;code&gt;--cpunodebind&lt;/code&gt;、&lt;code&gt;--membind&lt;/code&gt;、&lt;code&gt;--interleave&lt;/code&gt;）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;NUMA 就是在一台共享内存的机器内部，CPU 访问不同物理内存的代价不一样，因此“算力放哪”和“数据放哪”必须被联合考虑。&lt;/p&gt;
&lt;h2&gt;Fair Class 的对象体系&lt;/h2&gt;
&lt;h3&gt;fair class 内部的对象关系&lt;/h3&gt;
&lt;p&gt;在前面的章节中，我们反复提到调度类是 scheduler 的第一层抽象。现在让我们深入到最常见的调度类——&lt;strong&gt;fair class&lt;/strong&gt;——内部，看看它的对象体系是如何组织的。&lt;/p&gt;
&lt;p&gt;先从 &lt;code&gt;task_struct&lt;/code&gt; 出发。Linux 中每个 task 的描述结构里，不仅有一个调度类指针，还同时携带了&lt;strong&gt;多种调度类的调度实体&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;task_struct
  -&gt; sched_entity se    // fair class 的调度实体
  -&gt; sched_rt_entity rt // rt class 的调度实体
  -&gt; sched_dl_entity dl // deadline class 的调度实体
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对于普通任务来说，最重要的是 &lt;code&gt;task_struct::se&lt;/code&gt;——一个 &lt;code&gt;struct sched_entity&lt;/code&gt; 类型的成员。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;sched_entity&lt;/code&gt;&lt;/strong&gt; 是 fair class 视角下的“可调度实体”。这个名字中的“entity”而非“task”是有深意的——因为在支持 group scheduling 的配置下，一个 &lt;code&gt;sched_entity&lt;/code&gt; 不一定只代表单个 task，它还可能代表一个 task group 或一个 cgroup 下的公平调度实体。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;cfs_rq&lt;/code&gt;&lt;/strong&gt; 则是 fair class 自己维护的 runqueue。在 per-CPU 的总 &lt;code&gt;rq&lt;/code&gt; 对象下面，fair class 有自己独立的子队列。如果系统开启了 group scheduling，这些 &lt;code&gt;cfs_rq&lt;/code&gt; 甚至会形成层次结构。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;核心认知：fair class 内部的调度不是简单的“一群 task 在一棵树里排队”，而是“一群 sched_entity 在 cfs_rq 上竞争”。entity 既可能代表 task，也可能代表 group。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;经典 CFS 的核心概念：vruntime、min_vruntime、rb-tree&lt;/h3&gt;
&lt;p&gt;CFS（Completely Fair Scheduler）的核心思想可以用几个关键概念来理解：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;vruntime（virtual runtime）&lt;/strong&gt;：每个 entity 的“公平运行账本”。它记录的不是真实的墙钟时间，而是经过权重修正后的虚拟运行时间。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;min_vruntime&lt;/strong&gt;：当前这棵 fair 树中最小的 vruntime 基准值。你可以把它理解为“这个 runqueue 的公平时钟推进到哪了”。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;rb-tree（红黑树）&lt;/strong&gt;：所有 runnable entity 按 vruntime 的大小排列在红黑树中。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;weight&lt;/strong&gt;：由 nice 值映射而来的权重，决定了 entity 获得 CPU 时间的比例。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;经典 CFS 的选择逻辑非常简洁：&lt;strong&gt;vruntime 最小的那个 entity，最值得被调度。&lt;/strong&gt; enqueue 把 entity 插入红黑树，dequeue 从树中移除，pick_next 选择最左边（vruntime 最小）的节点。&lt;/p&gt;
&lt;p&gt;官方文档明确指出：&lt;code&gt;p-&gt;se.vruntime&lt;/code&gt; 是 per-task 的虚拟运行时间，CFS 总是试图运行 vruntime 最小的实体，而 &lt;code&gt;rq-&gt;cfs.min_vruntime&lt;/code&gt; 单调递增，记录 runqueue 的最小基准。（来源：&lt;a href=&quot;https://docs.kernel.org/6.14/scheduler/sched-design-CFS.html&quot;&gt;CFS Scheduler&lt;/a&gt;）&lt;/p&gt;
&lt;h3&gt;权重（weight）和 nice 值如何影响调度&lt;/h3&gt;
&lt;p&gt;fair class 不是直接用“实际运行了多少毫秒”来记账，而是&lt;strong&gt;按权重修正后&lt;/strong&gt;记账：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;权重更大的 entity&lt;/strong&gt;（nice 值更低/优先级更高）：实际运行同样的 1ms，vruntime 增长得更慢——意味着它不容易“跑到红黑树的右边”。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;权重更小的 entity&lt;/strong&gt;（nice 值更高/优先级更低）：实际运行同样的 1ms，vruntime 增长得更快——意味着它很快就会“偏右”。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;长期来看，权重大的 entity 更容易保持较小的 vruntime，因此能获得更多的 CPU 份额。权重小的 entity 则相反。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;nice 值不是直接决定“谁立刻跑”的开关，而是通过影响权重来改变公平账本的增长速度。&lt;/strong&gt; 最终效果是：nice 值低的 task 获得更大比例的 CPU 时间，但不会完全饿死 nice 值高的 task——因为 CFS 本质上是公平的，只是“公平的比例”不同。&lt;/p&gt;
&lt;h3&gt;sched_entity 为什么如此重要&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;sched_entity&lt;/code&gt; 的存在把 fair class 的调度逻辑和 &lt;code&gt;task_struct&lt;/code&gt; 做了解耦。如果你去阅读 &lt;code&gt;sched/fair.c&lt;/code&gt; 的源码，会发现很多函数操作的不是 &lt;code&gt;task_struct *p&lt;/code&gt;，而是 &lt;code&gt;struct sched_entity *se&lt;/code&gt;——因为 fair class 不仅仅调度 task，还可能调度 group entity。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;sched_entity&lt;/code&gt; 是 fair 调度世界里的统一抽象层。&lt;/strong&gt; 这和 VFS 中不直接只看 ext4 的 inode 而是先操作统一的 &lt;code&gt;struct inode&lt;/code&gt; 抽象非常相似——同样的设计思想在 Linux 内核中反复出现。&lt;/p&gt;
&lt;h3&gt;Group scheduling：为什么 cfs_rq 会形成层次&lt;/h3&gt;
&lt;p&gt;如果系统开启了 cgroup 的 CPU fair group scheduling（&lt;code&gt;CONFIG_FAIR_GROUP_SCHED&lt;/code&gt;），调度器可以实现&lt;strong&gt;先在组之间公平，再在组内部公平&lt;/strong&gt;的效果。&lt;/p&gt;
&lt;p&gt;举个例子：假设系统中有一个 browser cgroup 和一个 multimedia cgroup，每个 cgroup 各自被视为 root cfs_rq 上的一个 sched_entity。调度器先让这两个组按各自的 shares 分配 CPU 时间，然后在每个组内部，各自的 task 再继续按 fair 规则竞争：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CPU rq
  -&gt; root cfs_rq
      -&gt; se(browser group)     // 先在组间公平
      -&gt; se(multimedia group)  // 先在组间公平

browser group se
  -&gt; browser&apos;s own cfs_rq
      -&gt; task A  // 组内再公平
      -&gt; task B  // 组内再公平
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种层次化的公平性在容器化环境中特别有用——它让 Kubernetes 等平台能够在不同 Pod 之间按比例分配 CPU 资源。&lt;/p&gt;
&lt;h3&gt;EEVDF：fair class 的现代演进&lt;/h3&gt;
&lt;p&gt;从 Linux 6.6 开始，fair class 的 pick_next 逻辑正在从经典 CFS 向 &lt;strong&gt;EEVDF（Earliest Eligible Virtual Deadline First）&lt;/strong&gt; 过渡。这是 fair class 最重要的现代演进之一。（来源：&lt;a href=&quot;https://docs.kernel.org/scheduler/sched-eevdf.html&quot;&gt;EEVDF Scheduler&lt;/a&gt;）&lt;/p&gt;
&lt;p&gt;EEVDF 并没有推翻 fair class 的整个对象体系——&lt;code&gt;sched_entity&lt;/code&gt;、&lt;code&gt;cfs_rq&lt;/code&gt;、&lt;code&gt;vruntime&lt;/code&gt;、红黑树这些地基仍然存在。但它改进了**“从候选人中选出谁来运行”这个核心决策的逻辑**。它引入了两个新的关键概念：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;lag（滞后量）&lt;/strong&gt;：&lt;code&gt;lag &gt; 0&lt;/code&gt; 表示这个 task 还“欠着”CPU 时间，应该优先被服务；&lt;code&gt;lag &amp;#x3C; 0&lt;/code&gt; 表示它最近跑得偏多了，可以稍微往后排。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;virtual deadline（虚拟截止时间，VD）&lt;/strong&gt;：在所有“有资格被调度”的 entity 中，EEVDF 选择 virtual deadline 最早的那个。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果用一句话对比：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;经典 CFS&lt;/strong&gt;：选 vruntime 最小的&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;EEVDF&lt;/strong&gt;：先看谁是 eligible（有资格的），再在 eligible 的人中选 virtual deadline 最早的&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;EEVDF 的好处在于它对延迟敏感型 task 更友好。同时，它还引入了机制来防止“一个 task 故意频繁短暂睡眠来洗刷自己的 lag”这种投机行为。&lt;/p&gt;
&lt;h3&gt;学习 fair class 的推荐顺序&lt;/h3&gt;
&lt;p&gt;如果你正在学习 fair class，推荐的路径是：&lt;strong&gt;先用经典 CFS 的模型建立起对象关系和记账方式的直觉，然后再补上“pick next 的现代实现正在向 EEVDF 迁移”这个认知。&lt;/strong&gt; &lt;code&gt;sched_entity&lt;/code&gt;、&lt;code&gt;cfs_rq&lt;/code&gt;、&lt;code&gt;vruntime&lt;/code&gt;、&lt;code&gt;rb-tree&lt;/code&gt;、&lt;code&gt;min_vruntime&lt;/code&gt;、&lt;code&gt;weight/nice&lt;/code&gt;、group scheduling 这些概念是不变的地基。在这个地基之上，pick_next 的策略在新内核中更像 EEVDF。&lt;/p&gt;
&lt;h3&gt;sched_class 的 hooks 在 fair class 中的体现&lt;/h3&gt;
&lt;p&gt;回到 &lt;code&gt;sched_class&lt;/code&gt; 的多态接口设计，fair class 需要实现的几个关键 hook 包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;enqueue_task(...)&lt;/code&gt; — task 从 blocked 变回 runnable 时，将其加入 cfs_rq&lt;/li&gt;
&lt;li&gt;&lt;code&gt;dequeue_task(...)&lt;/code&gt; — task 不再 runnable 时，从 cfs_rq 中移除&lt;/li&gt;
&lt;li&gt;&lt;code&gt;wakeup_preempt(...)&lt;/code&gt; — 有 task 醒来时，判断是否需要抢占当前 task&lt;/li&gt;
&lt;li&gt;&lt;code&gt;pick_next_task(...)&lt;/code&gt; — 真正选出下一个要运行的 entity&lt;/li&gt;
&lt;li&gt;&lt;code&gt;set_next_task(...)&lt;/code&gt; — 设置即将运行的 task&lt;/li&gt;
&lt;li&gt;&lt;code&gt;task_tick(...)&lt;/code&gt; — 时钟 tick 到达时，更新记账并判断是否需要重调度&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;理解这些 hook 的名字和语义，会让你在阅读 scheduler 源码时不至于迷失在一堆看似随机的函数名中。（来源：&lt;a href=&quot;https://docs.kernel.org/6.14/scheduler/sched-design-CFS.html&quot;&gt;CFS Scheduler&lt;/a&gt;）&lt;/p&gt;
&lt;h2&gt;RT 和 Deadline&lt;/h2&gt;
&lt;p&gt;调度类的优先级顺序再回顾一次：&lt;code&gt;stop &gt; deadline &gt; rt &gt; fair &gt; idle&lt;/code&gt;。deadline 和 rt 都能压过普通 fair 任务。&lt;/p&gt;
&lt;h3&gt;rt class&lt;/h3&gt;
&lt;p&gt;Linux 的实时调度策略主要包含两种：&lt;strong&gt;SCHED_FIFO&lt;/strong&gt; 和 &lt;strong&gt;SCHED_RR&lt;/strong&gt;。
理解 rt class 的心智模型非常直接：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;首先看静态优先级&lt;/strong&gt;：高优先级的 runnable task 永远先于低优先级 task 获得 CPU。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;同优先级内部&lt;/strong&gt;：
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;SCHED_FIFO&lt;/strong&gt;：一个 task 会一直运行，直到它主动阻塞、显式让出、或者被更高优先级的 task 抢占。同优先级之间没有时间片轮转。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SCHED_RR&lt;/strong&gt;：同优先级之间会轮转——每个 task 运行一个时间片后让给下一个同优先级 task。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;和 fair class 最本质的区别在于：&lt;strong&gt;rt class 不追求“公平分享 CPU”，它实行的是严格的固定优先级抢占式调度。&lt;/strong&gt; 优先级高就先跑，没有任何公平性的考量。&lt;/p&gt;
&lt;p&gt;这也是为什么滥用 rt 调度策略非常危险——一个高优先级的 rt task 如果一直处于 runnable 状态，普通 fair task 会被完全饿死，连内核的管理操作都可能受影响。为了防止完全失控，系统提供了 &lt;code&gt;sched_rt_runtime_us&lt;/code&gt; / &lt;code&gt;sched_rt_period_us&lt;/code&gt; 这类参数来限制 rt task 的最大 CPU 占用比例。（来源：&lt;a href=&quot;https://docs.kernel.org/scheduler/sched-rt-group.html&quot;&gt;RT Group Scheduling&lt;/a&gt;）&lt;/p&gt;
&lt;h3&gt;deadline class&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;SCHED_DEADLINE&lt;/code&gt; 代表着一种更精细的实时调度模型。它的核心概念不是 nice 值或静态优先级，而是三个参数：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;runtime&lt;/strong&gt;：在一个周期内，task 被保证获得的 CPU 时间预算&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;deadline&lt;/strong&gt;：task 希望在这个时间点之前完成一轮计算&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;period&lt;/strong&gt;：重复调度的周期&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;直觉上：每个 period 内，你保证给这个 task 最多 runtime 的 CPU 时间，并期望它在 deadline 到达之前完成工作。选择谁运行时使用的是 &lt;strong&gt;EDF（Earliest Deadline First）&lt;/strong&gt; 策略——截止时间最近的那个 task 最先被调度。&lt;/p&gt;
&lt;p&gt;但 Linux 不是裸用 EDF。它在此基础上加入了 &lt;strong&gt;CBS（Constant Bandwidth Server）&lt;/strong&gt; 机制，其作用是做 bandwidth isolation——防止一个 deadline task 因为 bug 或设计失误而耗费过多 CPU，把其他所有人都拖垮。（来源：&lt;a href=&quot;https://docs.kernel.org/scheduler/sched-deadline.html&quot;&gt;SCHED_DEADLINE&lt;/a&gt;）&lt;/p&gt;
&lt;h2&gt;Context Switch&lt;/h2&gt;
&lt;h3&gt;两层不同的“保存现场”&lt;/h3&gt;
&lt;p&gt;理解 context switch，最重要的是&lt;strong&gt;分清两件不同但衔接的事情&lt;/strong&gt;：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第一层：trap 进入内核时保存的现场。&lt;/strong&gt; 当一个 task 通过 syscall、中断或异常从用户态进入内核态时，内核首先要保存一份用户态的执行现场——包括用户态的 PC（程序计数器）、SP（栈指针）、通用寄存器、状态寄存器、以及 trap 原因相关的信息。这份数据通常保存在当前 task 的内核栈上，被称为 &lt;strong&gt;trap frame&lt;/strong&gt;。它的作用是：&lt;strong&gt;确保将来能够正确地返回到用户态继续执行。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第二层：scheduler 做 context switch 时切换的现场。&lt;/strong&gt; 这不是“再把所有用户寄存器完整地保存一遍”（trap frame 已经做过了），而是在两个 task 的&lt;strong&gt;内核执行现场&lt;/strong&gt;之间做切换。调度路径中真正关键的操作是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;prev = rq-&gt;curr        // 当前正在运行的 task
next = pick_next_task() // 选出下一个要运行的 task
context_switch(rq, prev, next)  // 执行切换
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;而 &lt;code&gt;context_switch&lt;/code&gt; 内部主要做两件大事：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第一件：地址空间切换。&lt;/strong&gt; 如果 &lt;code&gt;prev&lt;/code&gt; 和 &lt;code&gt;next&lt;/code&gt; 不属于同一个 &lt;code&gt;mm_struct&lt;/code&gt;（即它们是不同进程的线程），就需要切换页表上下文——在 x86 上是切换 CR3 寄存器，在 RISC-V 上是切换 satp 寄存器。这会带来 TLB 和地址空间相关的成本。&lt;/p&gt;
&lt;p&gt;这也解释了一个常见的说法：&lt;strong&gt;同一进程内的线程切换通常比不同进程之间的切换更轻量&lt;/strong&gt;——因为同一进程的线程共享 &lt;code&gt;mm&lt;/code&gt;，可以跳过地址空间切换。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第二件：内核执行上下文切换。&lt;/strong&gt; 这是 architecture-specific 的 &lt;code&gt;switch_to(...)&lt;/code&gt; 函数，它切换内核栈指针、callee-saved 寄存器和返回地址等少量架构相关的上下文。&lt;/p&gt;
&lt;h3&gt;context switch 的本质&lt;/h3&gt;
&lt;p&gt;把上述过程精炼成一句话：&lt;strong&gt;context switch 的本质是从 prev 的内核栈和内核执行现场，切换到 next 的内核栈和内核执行现场。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这也是为什么 Linux 内核中每个 task 都必须有自己的内核栈——没有独立的内核栈，就无法保存各自的内核执行状态，也就无法在 task 之间切换。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;一个非常重要的细节值得反复强调：&lt;/strong&gt; “trap 保存用户态现场”和“scheduler 切换 task”是两件不同但紧密衔接的事。trap frame 负责的是“将来怎么回到用户态”，而 &lt;code&gt;switch_to&lt;/code&gt; 负责的是“现在 CPU 改跳到哪个 task 的内核代码继续执行”。前者保证了用户态的连续性，后者实现了内核态的 task 切换。&lt;/p&gt;
&lt;h3&gt;kernel thread 的特殊情况&lt;/h3&gt;
&lt;p&gt;内核线程（kernel thread）通常 &lt;code&gt;mm == NULL&lt;/code&gt;——它们不运行在普通的用户地址空间中。但页表硬件要求总有一个有效的地址空间，所以内核线程会借用上一个用户进程的 &lt;code&gt;active_mm&lt;/code&gt;。这也从侧面说明了为什么 &lt;code&gt;task_struct&lt;/code&gt; 和 &lt;code&gt;mm_struct&lt;/code&gt; 在 Linux 中是分离的对象而不是绑死的——因为并非所有 task 都有自己的用户地址空间。&lt;/p&gt;
&lt;h3&gt;完整的切换链路&lt;/h3&gt;
&lt;p&gt;把所有环节串起来，一次完整的 task 切换链路如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;syscall / interrupt / fault
-&gt; trap frame 保存用户态现场
-&gt; 当前 task 在内核态中执行
-&gt; 某个时刻调用 schedule()
-&gt; 选出 next task
-&gt; 如有必要，切换 mm（页表）
-&gt; switch_to(prev, next) —— 切换内核栈和执行现场
-&gt; CPU 继续在 next 的内核栈上执行
-&gt; 以后某个时刻，next 从自己的 trap frame 返回用户态
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;调度观测手段&lt;/h2&gt;
&lt;p&gt;理论知识再完善，如果不能在实际系统上观察调度行为，就永远无法验证你的理解是否正确。Linux 提供了多种强大的工具来观测调度器的行为。&lt;/p&gt;
&lt;h3&gt;A. Tracepoints：最稳定的调度事件入口&lt;/h3&gt;
&lt;p&gt;Tracepoints 是内核中预定义的静态插桩点，语义稳定、开销可控。对于调度观测，最值得记住的几个是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;sched:sched_switch&lt;/code&gt; — 记录了 task 切换事件：谁 → 谁&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sched:sched_wakeup&lt;/code&gt; — 记录了 task 被唤醒的事件&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sched:sched_wakeup_new&lt;/code&gt; — 记录了新创建的 task 首次被唤醒&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sched:sched_migrate_task&lt;/code&gt; — 记录了 task 被迁移到另一个 CPU&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果你想直接通过 tracefs/ftrace 来使用它们：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;echo 1 &gt; /sys/kernel/tracing/events/sched/sched_switch/enable
echo 1 &gt; /sys/kernel/tracing/events/sched/sched_wakeup/enable
cat /sys/kernel/tracing/trace_pipe
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;（来源：&lt;a href=&quot;https://docs.kernel.org/trace/tracepoints.html&quot;&gt;Tracepoints&lt;/a&gt;）&lt;/p&gt;
&lt;h3&gt;B. perf sched：从用户工具视角看调度&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;perf sched&lt;/code&gt; 非常适合做调度行为的“全景观察”：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;perf sched record&lt;/code&gt;：录制调度事件&lt;/li&gt;
&lt;li&gt;&lt;code&gt;perf sched timehist&lt;/code&gt;：以时间线的形式查看每个 task 何时运行、何时等待、runqueue delay 有多长&lt;/li&gt;
&lt;li&gt;&lt;code&gt;perf sched map&lt;/code&gt;：可视化各 CPU 上 task 的流动情况&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;它特别擅长回答这类问题：某个线程从 wakeup 到真正运行经过了多久？某个 CPU 是否长期过载？调度延迟的瓶颈在哪里？&lt;/p&gt;
&lt;h3&gt;C. ftrace：追踪函数级的调度路径&lt;/h3&gt;
&lt;p&gt;如果你想看到 scheduler 内部更底层的执行路径——比如 &lt;code&gt;try_to_wake_up&lt;/code&gt; 函数内部做了什么、&lt;code&gt;enqueue_task&lt;/code&gt; 是怎么调的、&lt;code&gt;pick_next_task&lt;/code&gt; 的决策过程——那 ftrace 是最强大的工具。两个最常用的模式：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;function&lt;/code&gt;：记录哪些函数被调用了&lt;/li&gt;
&lt;li&gt;&lt;code&gt;function_graph&lt;/code&gt;：以缩进的调用图形式展示函数的调用层次和耗时&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;尤其是 &lt;code&gt;function_graph&lt;/code&gt; 对理解调度路径的内部结构非常有帮助：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;echo function_graph &gt; /sys/kernel/tracing/current_tracer
echo __schedule &gt; /sys/kernel/tracing/set_graph_function
cat /sys/kernel/tracing/trace
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;（来源：&lt;a href=&quot;https://docs.kernel.org/trace/ftrace.html&quot;&gt;ftrace&lt;/a&gt;）&lt;/p&gt;
&lt;h3&gt;D. BPF tracing：在调度事件上做自定义统计&lt;/h3&gt;
&lt;p&gt;BPF tracing 是观测能力的最强一层。它的独特优势在于：你不只是被动地“看日志”，而是可以在内核中直接运行自定义的统计和分析逻辑。&lt;/p&gt;
&lt;p&gt;一个最经典的用例是计算 &lt;strong&gt;wakeup latency&lt;/strong&gt;——在 &lt;code&gt;sched_wakeup&lt;/code&gt; 事件发生时记录时间戳，在 &lt;code&gt;sched_switch&lt;/code&gt; 事件中看到对应 task 真正开始运行时计算时间差：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;tracepoint:sched:sched_wakeup
{
    start[pid] = nsecs;
}

tracepoint:sched:sched_switch
/ start[args-&gt;next_pid] /
{
    @lat_us = hist((nsecs - start[args-&gt;next_pid]) / 1000);
    delete(start[args-&gt;next_pid]);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这类 tracing 非常适合研究 wakeup latency 的分布、runqueue delay 的统计特征、CPU migration 的频率、以及某类 task 被哪种 workload 压制住了。&lt;/p&gt;
&lt;h2&gt;Scheduler 的本质&lt;/h2&gt;
&lt;h3&gt;scheduler 到底是什么&lt;/h3&gt;
&lt;p&gt;到这里，我们有必要退后一步，审视一个根本性的问题：&lt;strong&gt;scheduler 到底是什么？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;一个常见的误解是把 scheduler 想象成一个独立运行的后台进程——像一个“调度守护进程”一样常驻后台，不断地检查各 CPU 的状况然后做出决策。但实际上并非如此。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;scheduler 不是一个 task。&lt;/strong&gt; 它是内核中的一组代码、一组数据结构、以及一套决策规则的组合：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;代码&lt;/strong&gt;：&lt;code&gt;schedule()&lt;/code&gt;、&lt;code&gt;__schedule()&lt;/code&gt;、&lt;code&gt;pick_next_task()&lt;/code&gt;、各种 enqueue/dequeue/wakeup 路径&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;数据&lt;/strong&gt;：每 CPU 的 &lt;code&gt;rq&lt;/code&gt;、&lt;code&gt;cfs_rq&lt;/code&gt;/&lt;code&gt;rt_rq&lt;/code&gt;/&lt;code&gt;dl_rq&lt;/code&gt;、&lt;code&gt;sched_entity&lt;/code&gt;、各种统计和记账信息&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;规则&lt;/strong&gt;：fair/rt/deadline 的选择逻辑、preemption 规则、load balancing 策略&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;scheduler 的执行方式是：&lt;strong&gt;当某个事件发生时（task 阻塞、中断返回、wakeup 触发），当前 CPU 上正在执行的代码流会“走进”scheduler 的逻辑，完成一次“选出下一个要运行的 task”的决策，然后切走。&lt;/strong&gt; 它更像是一个被动响应事件的机制，而不是一个主动巡逻的 daemon。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;更准确的定义：scheduler 是事件驱动的内核机制，不是常驻后台的 task。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;“返回用户态前调度” 和 “内核态中途也能切换” 矛盾吗？&lt;/h3&gt;
&lt;p&gt;这是一个很好的问题，答案是&lt;strong&gt;不矛盾&lt;/strong&gt;。只要把调度可能发生的位置分成三类，就能清晰地理解：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第一类：显式阻塞点。&lt;/strong&gt; 内核代码在执行过程中遇到了无法立刻满足的条件——比如 &lt;code&gt;read()&lt;/code&gt; 时数据还没到、&lt;code&gt;mutex_lock()&lt;/code&gt; 时锁被别人持有、&lt;code&gt;wait_event()&lt;/code&gt; 时条件未满足。这时当前 task 会将自己的状态改为 blocked，挂到 wait queue 上，然后显式调用 &lt;code&gt;schedule()&lt;/code&gt;。这当然发生在内核态执行过程中——task 根本还没到“返回用户态”那一步就已经要调度了。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第二类：返回用户态前的调度点。&lt;/strong&gt; syscall、中断或异常已经处理完毕（或接近完毕），在真正切回用户态之前，内核检查 &lt;code&gt;need_resched&lt;/code&gt; 标志。如果它已被设置，就在这个边界上调用 &lt;code&gt;schedule()&lt;/code&gt;。这个时机特别自然——当前的 trap 处理已经接近完成，所有内核数据结构都处于一致的状态，不会把做了一半的敏感操作悬在中间。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第三类：内核抢占点。&lt;/strong&gt; 在配置了内核可抢占（&lt;code&gt;CONFIG_PREEMPT&lt;/code&gt;）的系统上，即使 task 正在内核态正常执行（既没有阻塞，也没到返回用户态的边界），也可能被切走。但前提条件很严格：&lt;code&gt;need_resched&lt;/code&gt; 已被设置、&lt;code&gt;preempt_count == 0&lt;/code&gt;、当前不在持有自旋锁、原子上下文或禁抢占区中。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;所以更准确的表述是：&lt;/strong&gt; “返回用户态前”只是最常见、最自然的重调度检查点之一，但内核态中间也完全可能发生调度——只是需要满足更严格的上下文条件。&lt;/p&gt;
&lt;h3&gt;一张完整的调度器地图&lt;/h3&gt;
&lt;p&gt;你应该已经拥有了 Linux 调度器的完整骨架：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;调度类分层&lt;/strong&gt;：Linux 调度器不是一个 CFS 独裁国。它先按 &lt;code&gt;sched_class&lt;/code&gt; 分层，再在每一层内部用各自的规则做决策。&lt;code&gt;stop &gt; deadline &gt; rt &gt; fair &gt; idle&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;对象体系&lt;/strong&gt;：fair class 内部的核心概念是 &lt;code&gt;sched_entity&lt;/code&gt;、&lt;code&gt;cfs_rq&lt;/code&gt;、&lt;code&gt;vruntime&lt;/code&gt;、&lt;code&gt;rb-tree&lt;/code&gt;。新内核正在向 EEVDF 迁移。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;RT 和 Deadline&lt;/strong&gt;：rt 是固定优先级抢占式调度；deadline 基于 EDF + CBS，提供截止时间和带宽保证。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;per-CPU runqueue&lt;/strong&gt;：每个 CPU 有自己的 &lt;code&gt;struct rq&lt;/code&gt;，包含当前 task、各类的 runnable 集合和本地记账信息。调度决策的核心是“处理 prev → 挑选 next → 切换 curr”。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Wakeup path&lt;/strong&gt;：wakeup 只是让 task 从 blocked 变回 runnable，给它重新参与竞争的资格——不等于立刻运行。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Preemption&lt;/strong&gt;：抢占是“当前 task 还能跑，但被调度器判定应该先让别人上”。它和主动阻塞是完全不同的机制。&lt;code&gt;need_resched&lt;/code&gt; 是意图，&lt;code&gt;schedule()&lt;/code&gt; 是实施。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;多核调度&lt;/strong&gt;：负载均衡在防止 CPU 闲置和保留局部性之间做权衡。NUMA 让“算力放哪”和“数据放哪”必须联合考虑。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Context switch&lt;/strong&gt;：同时涉及内核执行现场切换和可能的地址空间（mm/页表）切换。trap frame 和 switch_to 是两件不同但衔接的事。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;观测手段&lt;/strong&gt;：&lt;code&gt;sched_switch&lt;/code&gt;、&lt;code&gt;sched_wakeup&lt;/code&gt;、&lt;code&gt;perf sched&lt;/code&gt;、&lt;code&gt;ftrace&lt;/code&gt;、BPF tracing 是理解调度行为的核心工具链。&lt;/li&gt;
&lt;/ul&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>Learning Linux Kernel (Part 2) - Bootstrapping</title><link>https://theunknownth.ing/blog/linux-kernel-2</link><guid isPermaLink="true">https://theunknownth.ing/blog/linux-kernel-2</guid><description>Let&apos;s continue our journey to learn Linux kernel. In this part, we will talk about how Linux kernel is bootstrapped.</description><pubDate>Fri, 27 Mar 2026 20:48:00 GMT</pubDate><content:encoded>&lt;h2&gt;前言&lt;/h2&gt;
&lt;p&gt;在阅读本文之前，建议先看 &lt;a href=&quot;/blog/linux-kernel-1&quot;&gt;Part 1&lt;/a&gt;，因为我在那篇文章里介绍了 Linux kernel 的核心对象和机制，这些都是理解后续内容的基础。&lt;/p&gt;
&lt;h2&gt;Disclaimer&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;我本次使用了 AI 来带我学习 Linux 内核。所以无论我后续怎么整理润色，本文的 first draft 肯定是 AI 告诉我的内容&lt;/strong&gt;。如果你对此感到无法接受，或者觉得 AI 讲得不够好，你可以随时退出阅读，或者自己去看 Linux 内核的源码和文档。我的目标是通过 AI 来帮我梳理和总结内核的知识体系，而不是替代你自己去接触原始资料。&lt;/p&gt;
&lt;p&gt;如果你能接受这个前提，那么我们就继续往下走了。&lt;/p&gt;
&lt;h2&gt;Linux 内核的启动&lt;/h2&gt;
&lt;p&gt;如果只能用一个最简洁的方式来理解 Linux 内核的整个生命周期，那就是把它分成两幕：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;第一幕：Bring-up&lt;/strong&gt; —— 把内核自己“点亮”&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;第二幕：Steady-state&lt;/strong&gt; —— 进入长期运行的事件驱动系统&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这个划分看似简单，却是理解内核最根本的分界线。我们后续会学到的内存管理（&lt;code&gt;mm&lt;/code&gt;）、文件系统（&lt;code&gt;fs&lt;/code&gt;）、网络协议栈（&lt;code&gt;net&lt;/code&gt;）、设备驱动（&lt;code&gt;driver&lt;/code&gt;）、进程调度（&lt;code&gt;sched&lt;/code&gt;）——这些令人眼花缭乱的子系统，其实全部都属于第二幕里的不同分支。换句话说，内核的绝大部分复杂性，都发生在系统“活过来”之后。而第一幕的任务，仅仅是“让内核活过来”这件事本身。&lt;/p&gt;
&lt;p&gt;从最粗的全景来看，整条启动链是这样展开的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;上电
 -&gt; 固件（Firmware）
 -&gt; 引导加载器（Bootloader）
 -&gt; 内核入口（arch-specific）
 -&gt; start_kernel()
 -&gt; rest_init()
 -&gt; kernel_init (PID 1) + kthreadd (PID 2)
 -&gt; 第一个用户态 init 进程
 -&gt; steady-state：syscall / exception / interrupt / kthread / workqueue / softirq
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;其中最重要的分界线是 &lt;code&gt;start_kernel()&lt;/code&gt;：在它之前，系统还处于“早期引导世界”——很多我们习以为常的内核设施根本还不存在；在它之后，内核才逐步搭建起完整的运行环境，走向“正常内核世界”。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;启动链：谁把控制权交给了谁&lt;/h2&gt;
&lt;h3&gt;Firmware → Bootloader → Kernel&lt;/h3&gt;
&lt;p&gt;很多人一提到“开机”，脑子里就只有 bootloader 这一个概念。但实际上，启动是一条链，而不是一个孤立的节点。理解这条链上的每一环如何交接控制权，是理解内核启动的第一步。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;上电后，CPU 会从一个硬件预设的固定地址开始取指。&lt;/strong&gt; 那个地址上存放着固件（firmware）的代码——它早就被烧录在主板上的 Flash ROM 里。这里有一个非常朴素但重要的事实：CPU 并不会“理解 Linux 然后自动运行它”，它只会做一件事——从当前程序计数器（PC）指向的地址取指令、执行指令。所以启动的第一步，不是 Linux 自己跑起来，而是固件先跑起来。&lt;/p&gt;
&lt;p&gt;固件做的事情非常底层：最基础的硬件初始化、内存控制器初始化、寻找启动设备……你可以把它想成机器出厂时就自带的“最底层系统软件”。它不像 Linux 那样是用户安装的操作系统本体，但它也不是纯硬件——它是介于硬件和操作系统之间的一层。在 PC/x86 的世界里，老一点的机器你会听到 BIOS 这个词，而现代机器更常见的则是 UEFI。不管叫什么名字，它们都属于 firmware 这一层。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Bootloader 则运行在 firmware 之后，它的职责是把 Linux 内核拉起来。&lt;/strong&gt; GRUB 就是一个广为人知的 bootloader，在现在的默认安装中，你也可以看到诸如 systemd-boot 的身影。在现代 PC 上，常见的启动链条是这样的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;UEFI firmware -&gt; GRUB EFI program -&gt; Linux kernel
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;也可能是更精简的链条：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;UEFI firmware -&gt; Linux EFI stub -&gt; Linux kernel proper
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里顺便提一个容易混淆的点：你可能接触过 systemd-boot。它虽然和 systemd 同属一个大项目，但二者的职责完全不同——systemd-boot 是一个 UEFI bootloader，负责“把内核拉起来”；而 systemd 是用户态的 init 系统，负责“内核起来之后，把用户态世界拉起来”（这个我们稍后会提到）。名字相似，角色截然不同。&lt;/p&gt;
&lt;p&gt;说回 bootloader 本身。它做的两件最重要的事，概括起来就是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;把内核镜像放进内存&lt;/strong&gt; —— 内核原本存储在磁盘、Flash、甚至网络上，CPU 无法直接执行那些地方的代码，必须先把它搬到 RAM 里。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;跳转到内核入口地址&lt;/strong&gt; —— 把 PC 指向 Linux 的起点，让 CPU 开始执行内核的第一条指令。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这两步的顺序不能反——如果还没把内核代码加载到内存，就跳转过去，CPU 只会遇到空内容或垃圾数据，系统立刻就会崩溃。从 bootloader 完成跳转的那一刻起，它的历史使命就结束了，执行流正式属于 Linux 内核。&lt;/p&gt;
&lt;p&gt;这里有一个常见但值得思考的问题：&lt;strong&gt;为什么 bootloader 不能跳过内核，直接启动一个 shell？&lt;/strong&gt; 答案在于，bootloader 不是操作系统。Shell 之所以能运行，是因为 Linux 为它提供了地址空间、文件系统、系统调用、进程调度、标准输入输出、设备驱动等一整套能力。这些能力不是 bootloader 能提供的。Bootloader 的职责始终只有一个：“把 Linux 请上台”，而不是“代替 Linux 运行应用”。&lt;/p&gt;
&lt;h3&gt;硬件描述：内核怎么知道机器上有什么&lt;/h3&gt;
&lt;p&gt;内核并不是天生就知道这台机器有多少内存、UART 在哪个地址、磁盘控制器用哪个中断号。它需要拿到一份“硬件清单和位置表”——没有这个，内核就像一个人走进了陌生的仓库，四面都是墙壁，却没有一张地图告诉它门、灯、工具箱分别在哪里。&lt;/p&gt;
&lt;p&gt;不同平台提供这份清单的方式不同：x86 世界里常见的是由 firmware 提供的 ACPI 表，而在 ARM 和 RISC-V 的世界里，更常见的方式是使用 device tree。Device tree 本质上就是一份数据文件——注意，它不是代码——它以结构化的方式描述了这台机器上有哪些设备、每个设备的地址和连接关系。Bootloader 通常会在启动时将 device tree 和内核镜像一起准备好，一并交给内核。这样，内核从被唤醒的第一刻起，就已经知道了“这台机器长什么样”。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Early Boot：先让内核自己活下来&lt;/h2&gt;
&lt;p&gt;内核接管 CPU 之后，并不会直接运行你的 shell 或者启动 systemd。在那之前，它有一项更紧迫的任务：&lt;strong&gt;先把“自己能正常工作”这件事搞定。&lt;/strong&gt; 这个过程分为两层，理解这个分层，对后续阅读内核源码至关重要。&lt;/p&gt;
&lt;h3&gt;汇编入口&lt;/h3&gt;
&lt;p&gt;这一层的代码通常在 &lt;code&gt;arch/&amp;#x3C;arch&gt;/kernel/head*.S&lt;/code&gt; 文件里——注意后缀 &lt;code&gt;.S&lt;/code&gt; 指的是汇编源文件。之所以这里用汇编而不是 C，不是因为内核开发者偏爱汇编，大家如果有 C 写，干嘛写汇编，而是因为一个更根本的原因：&lt;strong&gt;C 代码运行本身就依赖一部分运行环境，而 early boot 的任务恰恰是要把这部分环境先搭建出来。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;换句话说，在系统启动的最初几步，C 语言还“站不稳”。举一个最朴素的例子：你写一个 C 函数 &lt;code&gt;void foo() { int x = 1; bar(x); }&lt;/code&gt;，这短短两行代码至少需要栈来存放局部变量、返回地址和需要保存的寄存器。如果连栈指针（比如我们在 x86 中熟知的 rsp）都还没有设置好，函数调用的基本机制就完全不靠谱。所以必须先用更接近机器底层的汇编代码，把 C 语言运行所需的那些前提条件一个个搭出来。&lt;/p&gt;
&lt;p&gt;具体来说，head.S 最核心要完成的几件事是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;建立一个最早期可用的栈（stack）&lt;/strong&gt; —— 没有栈，复杂的函数调用几乎都无法稳定进行。这是让内核“先站稳”的第一步。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;清空 BSS 段&lt;/strong&gt; —— BSS 段存放的是那些“默认初始值为 0 的全局和静态变量”，而内存里原本的内容可能是脏数据。如果不清零，内核里的很多全局状态从一开始就是不可预测的垃圾值，后续运行中会出现各种诡异的行为。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;建立最小可用的页表（page table）&lt;/strong&gt; —— 内核通常不会一直在裸物理地址模式下工作，它需要逐步进入自己正常的地址空间布局。但此刻只需要一个“最小可用”的页表——保证当前正在执行的代码和数据能被正确访问、能继续往后初始化即可。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;设置最基本的 CPU 模式和特权级&lt;/strong&gt;，然后跳入 C 的世界。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;在多核机器上，先只让 boot CPU 往前走&lt;/strong&gt; —— 其他 CPU（secondary CPUs）在这个阶段还没有被唤醒，它们会在后续的初始化阶段才被逐个 bring-up。所以整个 early boot 过程，本质上是单核在运行的。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;需要强调的是，head.S 并不是要把 early boot 的所有事情都包办——它只完成第一棒：“让 C 代码终于能稳定跑起来”。后面的 C 代码会接手真正搭建整个系统的工作。不过，即便进入了 C 阶段，仍然会偶尔插入少量汇编代码，用于读写特权寄存器、切换页表、开关中断、处理 trap 入口和返回等特别贴近硬件的步骤——这些事情不适合用 C 来做，因为 C 编译器无法精确控制到那种程度。&lt;/p&gt;
&lt;p&gt;这里还有一个常见的误解值得纠正：很多人看到 &lt;code&gt;start_kernel()&lt;/code&gt; 这个函数名，就以为“这是内核执行的第一行代码”。其实不是。在 &lt;code&gt;start_kernel()&lt;/code&gt; 被调用之前，已经有一段更底层、更脆弱、更依赖具体硬件架构的 early boot 代码跑过了。&lt;code&gt;start_kernel()&lt;/code&gt; 更像是宣告：&lt;strong&gt;“终于，我们进入了相对正常的内核初始化阶段。”&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;start_kernel() —— 把系统真正搭起来&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;start_kernel()&lt;/code&gt; 位于 &lt;code&gt;init/main.c&lt;/code&gt;，是内核通用 C 初始化的“大入口”。你可以把它想成内核的“总装配流程”——它按照严格的依赖关系，一层一层地把系统的各项能力搭建出来。以下是它做的最重要的五件事：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第一，建立内存基础。&lt;/strong&gt; 内核得先搞明白这台机器上有哪些 RAM 可用、哪些区域是保留的不能触碰，然后建立起基本的内存分配能力。这是整个初始化过程的基石——没有内存管理，后面的数据结构、任务对象、缓存、驱动对象统统都建不起来，一切都无从谈起。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第二，建立事件入口。&lt;/strong&gt; 系统跑起来之后，内核会不断地被系统调用、异常、中断拉回来——这是它日常运行的基本模式。所以在正式开门营业之前，必须先把 trap、syscall、interrupt 的入口处理路径都准备好。可以用一个比喻来理解：先把所有的“门”都装好，再把“门后面接待的人和处理流程”安排到位。如果门都没有，用户程序发了系统调用没人接，设备来了中断没人接，访问内存出了错也没人接——那整个系统根本不可能稳定运行。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第三，建立任务与调度基础。&lt;/strong&gt; Linux 不是一个顺着一条代码从头跑到尾的系统，它本质上是一个多执行流系统。所以内核需要在这个阶段建立 task 的基本表示形式、scheduler 的基础调度能力、每个 CPU 的调度环境，以及 idle task。这一步的本质，是让系统从“只有一条启动路径在往前走”的状态，转变为“能够管理和切换多个执行流”的状态。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第四，建立公共基础设施。&lt;/strong&gt; 这包括 timer 和 timekeeping（时间子系统）、workqueue（工作队列）、中断子系统更完整的部分、VFS 基础（虚拟文件系统）、驱动模型的基础框架等等。这些基础设施就像城市的水电煤气管网——它们本身不是最终产品，但没有它们，上层的文件系统、驱动、网络协议栈都无法正常运转。没有时间系统，调度和超时机制都难以工作；没有 workqueue，很多需要延后处理的工作没法优雅完成；没有 VFS 基础，后面就没办法统一处理“文件”这件事。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第五，切换到长期运行状态。&lt;/strong&gt; &lt;code&gt;start_kernel()&lt;/code&gt; 不会永远停留在初始化阶段。当各项基础设施就绪后，它要完成最后的身份转换——从“施工阶段”切到“营业阶段”：创建后续的关键执行流、启动第一个用户程序、让 boot CPU 进入正常的调度与 idle 角色。从这一刻起，Linux 才真正开始像我们熟悉的操作系统那样长期运行。&lt;/p&gt;
&lt;p&gt;如果你仔细观察这五步，会发现其中有一种有趣的“先有鸡还是先有蛋”的味道：想动态分配复杂对象，先得有最基础的内存管理；想运行多个执行流，先得有任务和调度能力；想处理中断，先得把入口装好。所以整个启动过程的本质，是从一个极简的初始状态出发，逐步搭建出更强大的状态——后一步总是依赖前一步已经建好的能力。这也是为什么启动代码看起来和普通内核代码有些不同：它不是“正常工作态的代码”，而是“自举代码”——你会看到一堆 &lt;code&gt;__init&lt;/code&gt; 标记、一堆架构特殊入口、一堆 early allocator、一堆按严格顺序依次解锁系统能力的初始化调用。不是代码写得丑，而是这个问题本身就特殊——它在解决的是“操作系统如何从无到有把自己搭建起来”。&lt;/p&gt;
&lt;p&gt;最后，用一句话总结这两层的分工：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;early boot / head.S&lt;/strong&gt; 解决的是“内核怎么先活下来”&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;start_kernel()&lt;/strong&gt; 解决的是“内核怎么变成一个完整的系统”&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;rest_init()：从初始化走向长期运行&lt;/h2&gt;
&lt;p&gt;当 &lt;code&gt;start_kernel()&lt;/code&gt; 执行到后期，系统已经具备了基本的内存管理、trap/syscall/interrupt 入口、scheduler 的基本形态等关键能力。但启动并没有结束——接下来要引入三个在内核世界里具有特殊地位的角色：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;PID 0：idle / swapper&lt;/strong&gt; —— 每个 CPU 在“没有其他 task 可运行”时所对应的 idle 执行体。在 boot CPU 上，它同时也承担着“启动路径当前执行体”的角色——也就是说，一直在执行 &lt;code&gt;start_kernel()&lt;/code&gt; 的那个执行流，其身份就是 PID 0。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PID 1：init&lt;/strong&gt; —— 未来的第一个用户态进程，整个用户态世界的起源。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PID 2：kthreadd&lt;/strong&gt; —— 内核线程体系里的核心管理者，后续几乎所有内核线程的创建都要经过它。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;rest_init()&lt;/code&gt; 正是把系统从“初始化过程”推进到“长期运行过程”的关键函数。它会创建两个新的 task——未来的 PID 1（kernel_init）和 PID 2（kthreadd），然后 boot CPU 自身通常进入 idle loop。从这一刻起，系统不再只是“一条启动路径在孤独地往前走”，而是开始具备真正的多执行流结构——有任务在运行，有调度器在调度，系统正式“活”了起来。&lt;/p&gt;
&lt;p&gt;你可能会好奇：为什么不在 &lt;code&gt;start_kernel()&lt;/code&gt; 里一路把所有事情初始化完，然后直接跳到用户态？答案在于，后续的初始化工作——比如设备探测、驱动加载、文件系统准备等——本身就需要在一个具备调度和多执行流能力的环境中进行。有些初始化操作需要等待 I/O 完成，有些需要阻塞等待其他子系统就绪，有些还需要和其他内核线程协作。在一个没有调度器的单线程环境里，这些操作根本无法正常完成。所以，必须先把多执行流结构建立起来，让 boot CPU 释放出来进入正常的调度角色，后续的初始化才能在“正常的内核运行时环境”中顺利完成。&lt;/p&gt;
&lt;p&gt;另一个细节值得注意：&lt;code&gt;kernel_init&lt;/code&gt; 通常不会在 kthreadd 还没有准备好之前就一股脑地往前冲——因为很多后续内核线程的创建和管理都依赖 kthreadd，它们之间存在明确的协调和同步关系。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;第一个用户程序是怎么来的？&lt;/h2&gt;
&lt;p&gt;这里有一个很反直觉但非常重要的事实：&lt;strong&gt;未来的 PID 1 一开始并不是用户程序。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;它诞生时只是一个由内核创建的 task，先在内核态执行 &lt;code&gt;kernel_init()&lt;/code&gt; 函数。只有在 &lt;code&gt;kernel_init()&lt;/code&gt; 完成了一系列后续初始化工作之后，它才会去加载并执行真正的用户态 init 程序（如 &lt;code&gt;/sbin/init&lt;/code&gt; 或 &lt;code&gt;/init&lt;/code&gt;）。这不是内核凭空变出了一个正在运行用户代码的进程，而是一个清晰的两步过程：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;内核创建一个 task
 -&gt; 这个 task 先跑 kernel_init()
 -&gt; kernel_init() 再通过 exec 切换到真正的用户态 init 程序
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在执行 exec 之前，&lt;code&gt;kernel_init()&lt;/code&gt; 还有不少事情要忙。这个阶段的工作已经不属于 very early boot，但也还没有正式进入用户态世界——它处于二者之间的过渡地带：等关键的内核线程基础设施就绪、继续完成设备探测和驱动加载、准备文件系统、挂载 root filesystem……在源码中你会看到 &lt;code&gt;kernel_init_freeable()&lt;/code&gt;、&lt;code&gt;prepare_namespace()&lt;/code&gt;、&lt;code&gt;run_init_process()&lt;/code&gt;、&lt;code&gt;try_to_run_init_process()&lt;/code&gt; 这些函数名，它们就是这个过渡阶段的主要参与者。&lt;/p&gt;
&lt;h3&gt;内核会去运行哪个 init 程序？&lt;/h3&gt;
&lt;p&gt;这取决于启动配置，最经典的两种情况如下：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;有 initramfs 的情况：&lt;/strong&gt; 内核直接运行 initramfs 中的 &lt;code&gt;/init&lt;/code&gt;。initramfs 是一份临时的根文件系统，在真正的根文件系统还没有挂载好之前，先给内核一个能运行早期用户空间程序的环境。这些程序可以帮忙加载内核模块、发现磁盘设备、解密加密分区、组装 RAID/LVM，等一切准备就绪之后，再通过 &lt;code&gt;switch_root&lt;/code&gt; 或 &lt;code&gt;pivot_root&lt;/code&gt; 切换到真正的根文件系统。内核启动时先把 initramfs 解包到 rootfs；如果 rootfs 里存在 /init，就以 PID 1 执行它。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;没有 initramfs 的情况：&lt;/strong&gt; 内核在准备好真正的 root filesystem 之后，会逐个尝试一些常见路径——&lt;code&gt;/sbin/init&lt;/code&gt;、&lt;code&gt;/etc/init&lt;/code&gt;、&lt;code&gt;/bin/init&lt;/code&gt;、&lt;code&gt;/bin/sh&lt;/code&gt;，或者由 boot 参数 &lt;code&gt;init=&lt;/code&gt; 指定的程序。如果所有路径都找不到可执行的 init 程序，内核就会报出那个经典的 panic 信息：“No working init found”。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;现代 Linux 发行版几乎都走 initramfs 路径。一条典型的链条是这样的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kernel -&gt; initramfs 中的 /init -&gt; 准备真正 root fs -&gt; switch_root -&gt; 真正系统上的 init (如 systemd)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;从原理上说，initramfs 并不是绝对必须的。如果内核已经内建了访问根文件系统所需的所有驱动，并且能够直接识别和挂载 rootfs，那就可以跳过这一步。但在现代通用发行版的复杂硬件环境下，initramfs 几乎是标配。&lt;/p&gt;
&lt;h3&gt;“运行第一个用户程序”在底层到底发生了什么？&lt;/h3&gt;
&lt;p&gt;当内核决定运行某个 init 程序时，它本质上要执行一次 &lt;code&gt;execve&lt;/code&gt; 类的操作。这个过程涉及多个子系统的紧密配合：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;通过 VFS 的路径解析机制找到目标文件&lt;/li&gt;
&lt;li&gt;判断文件的格式——是 ELF 可执行文件？还是脚本？需要哪个 binary handler 来处理？&lt;/li&gt;
&lt;li&gt;创建或替换进程的地址空间（&lt;code&gt;mm&lt;/code&gt;），建立代码段、数据段、栈等内存映射&lt;/li&gt;
&lt;li&gt;ELF loader 解析程序头信息，将各个 segment 映射进用户地址空间，准备初始的用户栈，并把 argv 和 envp 放到正确位置&lt;/li&gt;
&lt;li&gt;设置初始寄存器状态：让用户态的程序计数器指向 ELF 的 entry point，让栈指针指向新建的用户栈&lt;/li&gt;
&lt;li&gt;从内核态返回到用户态——从这一刻起，这个 task 不再执行 &lt;code&gt;kernel_init()&lt;/code&gt; 里的下一行 C 代码，而是开始在用户态执行 init 程序的第一条用户指令&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;可以这样概括这个过程：同一个 task 的控制流身份，从内核初始化线程，变身成了用户态 init 进程。所以从内核到第一个用户进程的过渡，本质上是 &lt;strong&gt;task + mm + exec&lt;/strong&gt; 三件事的配合。在这里你也可以了解了一个 process 究竟是什么：从内核对象视角看，用户平时说的‘进程’更像是以 task_struct 为中心、再加上一组相关对象及共享关系形成的抽象。&lt;/p&gt;
&lt;h3&gt;用户态的 init 又是干什么的？&lt;/h3&gt;
&lt;p&gt;到这里，一个自然的问题是：内核已经起来了，为什么还需要用户态的 init 程序？&lt;/p&gt;
&lt;p&gt;答案涉及操作系统设计中一个经典的分离原则。内核提供的是 &lt;strong&gt;mechanism&lt;/strong&gt;（机制）——进程管理、地址空间、文件系统接口、调度、网络协议栈等底层能力。而用户态的 init 负责的是 &lt;strong&gt;policy&lt;/strong&gt;（策略）和 &lt;strong&gt;orchestration&lt;/strong&gt;（编排）——系统启动后具体要做什么、挂载哪些文件系统、启动哪些服务、要不要图形界面、关机和重启怎么组织……这些决策属于用户态的策略层面。&lt;code&gt;/sbin/init&lt;/code&gt; 或 systemd 的职责，就是把内核提供的基础能力组织起来，编排成一个对用户可用的完整 Linux 系统。&lt;/p&gt;
&lt;p&gt;简单来说：&lt;strong&gt;内核搭建的是“操作系统的引擎”，用户态 init 搭建的是“操作系统的环境”。&lt;/strong&gt; 两层缺一不可——没有引擎，环境无处安放；没有环境，引擎的能力无从发挥。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;User Mode 与 Kernel Mode 的来回切换&lt;/h2&gt;
&lt;p&gt;当第一个用户程序成功跑起来之后，Linux 就正式进入了 steady-state——长期运行的稳态。从此以后，系统日复一日做的事情，归结起来只有一个模式：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;用户态程序运行
 -&gt; 因为 syscall / exception / interrupt 进入内核
 -&gt; 内核处理
 -&gt; 返回用户态
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个简单的循环，就是 Linux 日常运行的核心节奏。&lt;/p&gt;
&lt;h3&gt;为什么需要 user mode 和 kernel mode？&lt;/h3&gt;
&lt;p&gt;User mode 和 kernel mode 的划分，是现代操作系统最根本的隔离机制。试想，如果系统里所有代码都拥有和内核一样的权限，后果会怎样？任何一个普通应用程序都可以随意修改页表、直接控制硬件设备、覆盖其他进程的内存、甚至把整个系统搞崩溃。这显然是不可接受的。&lt;/p&gt;
&lt;p&gt;所以，CPU 在硬件层面就提供了权限级别的划分：user mode 权限较低，普通用户程序运行于此；kernel mode 权限最高，内核运行于此。用户程序不能直接执行敏感操作（比如修改页表或访问硬件寄存器），必须通过受控的入口请求内核代劳——而这，正是系统调用（syscall）存在的根本原因。&lt;/p&gt;
&lt;h3&gt;三种最重要的“进入内核”方式&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Syscall（系统调用）&lt;/strong&gt; 是用户程序主动对内核说“我需要你帮我做一件事”。比如 &lt;code&gt;read&lt;/code&gt;、&lt;code&gt;write&lt;/code&gt;、&lt;code&gt;fork&lt;/code&gt;、&lt;code&gt;mmap&lt;/code&gt; 等操作。这是一种主动的、有意识的进入方式——程序知道自己在请求内核服务。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Exception / Fault（异常）&lt;/strong&gt; 则不同，它发生在当前指令执行时同步出了问题，CPU 发现必须交给内核来处理。最典型的例子就是 page fault。假设用户程序在执行 &lt;code&gt;x = *p&lt;/code&gt; 这条语句，CPU 试图访问指针 &lt;code&gt;p&lt;/code&gt; 指向的虚拟地址时，发现页表里没有有效的映射，或者当前的权限不允许这次访问，于是 CPU 产生一个 fault，自动切换到内核态。内核接手之后，会判断这个 fault 是否可以修复：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;可修复的情况（很常见）：&lt;/strong&gt; 可能需要分配一个物理页面并建立映射，也可能需要处理 Copy-on-Write（COW）。内核修好之后，会让 &lt;strong&gt;触发 fault 的那条指令重新执行&lt;/strong&gt;——注意，是重试那条原始指令，而不是跳过它。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;不可修复的情况：&lt;/strong&gt; 比如程序访问了一个完全非法的地址，内核会给进程发送 SIGSEGV 信号，进程通常就此崩溃终止。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这里最关键的认知是：fault 是和“当前这条指令”绑定的同步事件——它不是外面突然来了一条消息，而是当前执行流自己“绊倒”了。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Interrupt（中断）&lt;/strong&gt; 是第三种方式，来自外部设备的异步通知——网卡收到了数据包、磁盘完成了一次 I/O 操作、定时器到了预设时间。中断和 exception 的关键区别在于它的异步性质：它可以在任意时刻打断当前正在执行的代码，不管那段代码正在做什么。&lt;/p&gt;
&lt;p&gt;同样，和我在 Part 1 中就提到的，这三种方式是理解内核运行时行为的基础框架：&lt;strong&gt;syscall 是程序主动请求服务，exception 是当前指令同步出事，interrupt 是外部设备异步发消息。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;从 CPU 视角看进入内核的过程&lt;/h3&gt;
&lt;p&gt;不管是通过哪种方式进入内核，从 CPU 硬件的视角来看，过程在抽象层面上都是相似的：CPU 发现需要离开当前正在执行的用户代码 → 保存最基本的返回信息 → 切换到更高的权限模式 → 跳转到内核预设的入口地址 → 内核入口代码保存更多的寄存器和上下文信息 → 根据进入的原因，分发到正确的处理函数。&lt;/p&gt;
&lt;p&gt;这里需要引入一个重要的概念：&lt;strong&gt;trap frame（陷入帧）&lt;/strong&gt;。它就是“这次陷入内核时保存下来的执行现场”——包括程序计数器、栈指针、通用寄存器、状态寄存器、trap 原因等信息。内核处理完毕后需要回到原来的用户程序继续执行，就必须依靠这份保存下来的现场信息来恢复。所以“进入内核”不是随便跳进来就行，而是一次精心编排的 &lt;strong&gt;保存现场 → 处理 → 恢复现场&lt;/strong&gt; 的完整过程。&lt;/p&gt;
&lt;p&gt;以 RISC-V 架构为例来说明：用户态程序运行在 U-mode（用户模式），当触发 trap 时，CPU 硬件自动将返回地址记录到 &lt;code&gt;sepc&lt;/code&gt; 寄存器，将 trap 原因写入 &lt;code&gt;scause&lt;/code&gt; 寄存器，与 fault 相关的地址或值记入 &lt;code&gt;stval&lt;/code&gt; 寄存器，然后切换到 S-mode（监管模式），跳转到 &lt;code&gt;stvec&lt;/code&gt; 寄存器所指定的入口地址。随后内核的入口代码保存通用寄存器、构造 trap frame，C 代码根据 &lt;code&gt;scause&lt;/code&gt; 的值进行分发处理，处理完毕后用 &lt;code&gt;sret&lt;/code&gt; 指令返回用户态。具体的寄存器名称因架构而异（例如 x86 有一套完全不同的 IDT/trapframe 机制），但背后的思想完全一致：&lt;strong&gt;CPU 把“出事了”或“有请求了”的最小状态信息交给内核，内核接管控制权、处理事件、然后恢复现场并返回。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;返回用户态不一定回到“原来那个进程”&lt;/h3&gt;
&lt;p&gt;内核处理完之后，如果是一个简单的 syscall 完成或者中断处理完毕，通常就恢复 trap frame、切回用户态，让原来的程序继续执行。但事情并不总是这么直截了当。如果内核在处理过程中发现，当前这个 task 应该被抢占、有其他更高优先级的 task 需要运行，那么内核会先切换到那个更该运行的 task。原来的 task 会在未来某个时刻才再被调度回来。所以 &lt;strong&gt;trap 路径和调度器经常是紧密联系在一起的&lt;/strong&gt;。一个典型的流程可能是这样的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;用户程序调用 read()
 -&gt; trap 进内核
 -&gt; 发现需要等待磁盘 I/O
 -&gt; 当前 task 进入 blocked 状态
 -&gt; scheduler 选择另一个 task 运行
 -&gt; （时间流逝……）
 -&gt; 磁盘 I/O 完成，中断到来
 -&gt; 原来的 task 被唤醒
 -&gt; 在某个合适的时刻再被调度到 CPU 上运行
 -&gt; 从系统调用返回到用户态
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;还有一点值得补充：&lt;strong&gt;内核并不是“只在用户程序 trap 进来时才运行”的。&lt;/strong&gt; 内核自己也有长期存在的执行流——kthreadd、kworker、kswapd、各种 rcu_* 内核线程等。它们不是因为某个用户程序触发 trap 才临时存在的，而是内核自己创建的、由调度器正常调度的 task。所以在 steady-state 下，CPU 在任意时刻可能正在运行三种东西中的一种：用户线程、内核线程、或者 idle task。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;系统调用与函数调用的区别&lt;/h2&gt;
&lt;p&gt;在你写用户代码时，&lt;code&gt;read(fd, buf, n)&lt;/code&gt; 看起来和调用一个普通函数没什么两样——传几个参数，拿个返回值，就完事了。但这只是 C 标准库（libc）给你的精心包装。在这层包装之下，真正发生的事情远比一次函数调用复杂：用户态代码执行一条特殊的 CPU 指令（x86 上是 &lt;code&gt;syscall&lt;/code&gt;，RISC-V 上是 &lt;code&gt;ecall&lt;/code&gt;，ARM 上是 &lt;code&gt;svc&lt;/code&gt;）→ CPU 切换权限级 → 跳到内核预设的入口地址 → 保存执行现场 → 识别系统调用编号和参数 → 检查参数的合法性 → 执行真正的内核逻辑 → 可能需要在用户态和内核态之间复制数据 → 可能会阻塞 → 可能会触发调度 → 最后恢复现场，返回用户态。&lt;/p&gt;
&lt;p&gt;和普通函数调用相比，系统调用跨越了两个关键的边界：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;权限边界&lt;/strong&gt; —— 从 user mode 切换到 kernel mode&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;可信边界&lt;/strong&gt; —— 内核不能直接信任用户传进来的任何东西：指针可能非法、长度可能越界、文件描述符可能无效、标志位可能含有恶意值&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;正是因为这两个边界的存在，syscall 天然就带着 trap 进入和返回的成本、上下文的保存与恢复、安全检查、&lt;code&gt;copy_to_user&lt;/code&gt; / &lt;code&gt;copy_from_user&lt;/code&gt; 的数据搬运、以及可能的阻塞与调度等开销。对于像 &lt;code&gt;getpid()&lt;/code&gt; 这样非常轻量的 syscall，真正的工作几乎可以忽略不计，进入和退出内核本身的固定开销反而占据了绝大部分时间；而对于像 &lt;code&gt;read()&lt;/code&gt; 这样可能涉及 VFS 路径解析、page cache 查找、磁盘 I/O 等操作的 syscall，真正的工作量才是大头。&lt;/p&gt;
&lt;p&gt;这就是为什么在高性能场景下，大家总是在强调“&lt;strong&gt;减少系统调用的次数&lt;/strong&gt;”。批量 I/O（&lt;code&gt;readv&lt;/code&gt;/&lt;code&gt;writev&lt;/code&gt;/&lt;code&gt;preadv2&lt;/code&gt;）、&lt;code&gt;mmap&lt;/code&gt;、&lt;code&gt;sendmmsg&lt;/code&gt;/&lt;code&gt;recvmmsg&lt;/code&gt;、&lt;code&gt;epoll&lt;/code&gt;、&lt;code&gt;io_uring&lt;/code&gt;、zero-copy techniques……这些看起来五花八门的优化手段，本质上都在解决同一个核心问题：不要因为用户态和内核态之间的边界太昂贵，就让系统把大量时间浪费在频繁的来回切换上。&lt;/p&gt;
&lt;h3&gt;绕开 syscall 的例外：vDSO&lt;/h3&gt;
&lt;p&gt;有一些被调用频率极高、又可以安全优化的操作，Linux 选择用一种巧妙的方式来绕开 syscall 的开销。这就是 vDSO（virtual Dynamic Shared Object）机制——内核会将一小段特殊的代码和数据页映射到每个用户进程的地址空间里，让某些操作（最典型的如 &lt;code&gt;clock_gettime&lt;/code&gt;）不必真的 trap 进内核就能完成。这个机制的存在本身就是一种反证：它证明了 syscall 的边界确实昂贵到值得专门设计一套机制来绕开它。&lt;/p&gt;
&lt;h3&gt;I/O 性能优化的几种武器&lt;/h3&gt;
&lt;p&gt;当你理解了 syscall 的边界成本之后，这些 I/O 优化手段的存在就顺理成章了。它们大多在解决三类成本中的一种或多种：syscall 太频繁导致的切换开销、数据拷贝太多导致的内存带宽浪费、等待 I/O 的方式太笨导致的 CPU 空转。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;批量 I/O&lt;/strong&gt; 不是某个单一的 API，而是一类设计思想：既然每次进内核都有固定开销，那就一次进去多做几件事。&lt;code&gt;readv&lt;/code&gt;/&lt;code&gt;writev&lt;/code&gt; 在一次调用中处理多个不连续的缓冲区，&lt;code&gt;sendmmsg&lt;/code&gt;/&lt;code&gt;recvmmsg&lt;/code&gt; 在一次调用中发送或接收多个消息，&lt;code&gt;preadv2&lt;/code&gt;/&lt;code&gt;pwritev2&lt;/code&gt; 支持批量的偏移量 I/O，io_uring 更是可以一次提交多个异步请求，都是这个思路的不同实例。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;mmap&lt;/strong&gt; 把文件或匿名内存映射进进程的地址空间，让你可以像访问一个大数组一样直接访问文件内容，背后由 page fault 机制按需加载对应的数据页。它不是“一个更快版本的 read”，而是一种思路的根本转换——把“文件 I/O 操作”改写成“内存地址访问”，横跨了内存管理和 VFS 两个子系统。不过 mmap 也有它的代价：page fault 的触发时机不可预测，导致性能抖动比显式 read 更难排查；延迟不一定平滑；写回和一致性语义更加复杂；而且并非所有 I/O 场景都适合用 mmap。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;sendmmsg/recvmmsg&lt;/strong&gt; 是专门针对消息型 I/O 的批量优化。它允许在一次 syscall 里发送或接收多个 datagram，在高包率的 UDP 网络场景中，可以显著减少 syscall 次数带来的开销。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;epoll&lt;/strong&gt; 帮你高效地监视大量文件描述符，告诉你哪些 fd 当前已经 ready——意思是“现在对这个 fd 做 read/write 操作大概率不会阻塞”。它提供的是一种 readiness notification 模型：内核告诉你谁准备好了，你再自己去做实际的 I/O 操作。epoll 特别适合高并发网络服务器的场景，where 你可能同时维护着数万个连接。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;io_uring&lt;/strong&gt; 是近年来 Linux I/O 领域最重要的创新之一。它在用户态和内核之间共享两组 ring buffer：一个 Submission Queue（提交队列）和一个 Completion Queue（完成队列）。用户态程序往 SQ 里填入 I/O 请求，内核处理完后把结果放进 CQ。这是一种 completion notification 模型——和 epoll 的 readiness notification 不同，它的语义是“你把请求交给我，做完了我告诉你结果”。io_uring 不是“更快的 epoll”，而是一种全新的 I/O 提交与完成模型，二者解决的问题和使用场景都有所不同。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;zero-copy&lt;/strong&gt; 同样不是单一的 API，而是一类优化目标：尽量避免在数据传输路径上做无谓的内存拷贝。常见的实现机制包括 &lt;code&gt;sendfile&lt;/code&gt;、&lt;code&gt;splice&lt;/code&gt;、&lt;code&gt;mmap&lt;/code&gt;、&lt;code&gt;MSG_ZEROCOPY&lt;/code&gt; 等。不过 zero-copy 通常会带来更复杂的 buffer 生命周期管理和 completion 语义——你省了拷贝的成本，但要付出管理复杂度的代价。&lt;/p&gt;
&lt;p&gt;如果你尝试把这些接口按照内核子系统来归位，会发现它们各有所属：mmap 主要涉及内存管理和 VFS；epoll 属于事件通知模型和 VFS/socket 层；sendmmsg/recvmmsg 属于 socket/network 的系统调用优化；io_uring 属于 I/O 提交模型和异步 I/O 框架；而 zero-copy 则横跨了内存管理、VFS、网络和 DMA 等多个子系统。建立起这个映射关系，后续分模块深入学习时就更容易定位“这个东西在内核的哪一层”。&lt;/p&gt;
&lt;h2&gt;启动全景图&lt;/h2&gt;
&lt;p&gt;最后，用一张完整的图来为这篇文章收尾：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;硬件上电
 -&gt; Firmware (BIOS/UEFI)
 -&gt; Bootloader (GRUB/systemd-boot)
 -&gt; Kernel entry (head.S): setup stack, clear BSS, minimal page table
 -&gt; start_kernel(): memory, trap, scheduler, timer, VFS, driver core
 -&gt; rest_init(): create PID 1 (kernel_init) + PID 2 (kthreadd)
 -&gt; kernel_init(): late init, prepare rootfs, exec /init or /sbin/init
 -&gt; PID 1 becomes first userspace process (often systemd)
 -&gt; Steady-state: user &amp;#x3C;-&gt; kernel via syscall / exception / interrupt
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这条从上电到 steady-state 的主线一旦通了，后面再去学调度、内存管理、文件系统、网络协议栈，就都有了一个稳固的坐标系。你不会再迷失在某个子系统的细节里，因为你总是能回到这条主线上，知道自己正在研究的东西处于整个系统生命周期的哪个位置。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>Learning Linux Kernel (Part 1) - Intro</title><link>https://theunknownth.ing/blog/linux-kernel-1</link><guid isPermaLink="true">https://theunknownth.ing/blog/linux-kernel-1</guid><description>I started learning the Linux kernel, and this is the first part of my notes.</description><pubDate>Wed, 25 Mar 2026 20:48:00 GMT</pubDate><content:encoded>&lt;h2&gt;前言&lt;/h2&gt;
&lt;p&gt;最近要开始学 Linux kernel 了。已经看了几节南京大学蒋老师的操作系统课，他在课上提到的最多的一句话就是“现在的 AI 真的非常厉害，你们在学习系统的时候比我当时的条件好多了”。于是我也想要改变之前学习的流程，不再是先看书、看视频、写笔记，而是直接上手，让 AI 带我学习。&lt;/p&gt;
&lt;p&gt;AI 更像是一个量身定制的老师。学习 kernel 这种东西是非常众口难调的。对于计算机系统的基础理解不同会导致完全不同的学习路径和重点。AI，则相比之下更能适配不同背景的学习者，提供个性化的引导和解释。它可以根据我的提问和理解水平，调整讲解的深度和角度，帮助我更有效地构建知识体系。&lt;/p&gt;
&lt;p&gt;为了避免 AI 讲得太过于碎片化，我也想要在学习过程中搭建一个清晰的骨架。这个骨架既不是某本书的目录，也不是某个课程的章节，而是我让 AI 知道让他带我学完之后我希望掌握的技能：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Linux kernel 有哪些核心子系统，它们怎么连起来&lt;/li&gt;
&lt;li&gt;看任何子系统时，知道该问什么问题&lt;/li&gt;
&lt;li&gt;打开源码、文档、trace、论文，不会迷路&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;为了避免 AI 胡说，我也采取了以下策略：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;提供了 browser 工具，让 AI 能够直接查阅官方文档和源码；&lt;/li&gt;
&lt;li&gt;我使用（笔者写文章时当下最好的模型）GPT-5.4-xhigh 以及 Claude Opus 4.6 来教我学 Linux kernel，利用它们的强大能力来理解复杂的概念和代码&lt;/li&gt;
&lt;li&gt;我直接让 Agent 跑在本地的 kernel 的代码库上，这样它就能直接结合代码来讲解，而不是泛泛而谈。&lt;/li&gt;
&lt;li&gt;我会在学习过程中不断地提问，并且总结成 blogpost 发出来，这样各位读者也可以跟着我一起学，或者提出可能的疑问和建议。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;当然，这毕竟是我根据自己的理解和学习路径搭建的骨架，并且是通过 AI 来填充细节的，所以难免会有不够准确或者不够全面的地方。欢迎大家批评指正，也欢迎大家跟着我一起学，一起提问，一起总结。&lt;/p&gt;
&lt;p&gt;我希望我的学习笔记是精炼的，但又不失细节的。但是许多具体的 implementation 细节我可能不会展开讲解，因为那可能会让文章变得过于冗长和难以理解。我会尽量把重点放在核心概念和机制上，帮助大家建立一个清晰的知识框架。（或者我可能在后续的文章里再展开讲解一些细节？不过鉴于我现在也没有搞懂，我也不好承诺）&lt;/p&gt;
&lt;h2&gt;Linux Kernel 的总定义&lt;/h2&gt;
&lt;p&gt;如果要给 Linux kernel 一个最粗粒度的定位，可以把它看作&lt;strong&gt;资源管理器 + 事件处理器&lt;/strong&gt;。它管 CPU——决定谁现在运行、谁排队等待；管内存——决定谁能看到哪些地址，物理页怎么分配、回收和共享；管设备与 I/O——包括磁盘、网卡、定时器、中断、文件和 socket。&lt;/p&gt;
&lt;p&gt;我们来借用一些比喻辅助理解：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;用户态程序像业务对象&lt;/li&gt;
&lt;li&gt;kernel 像一个全局唯一的 runtime，集合了 scheduler、memory manager 和 driver framework 的职责。&lt;/li&gt;
&lt;li&gt;Syscall 是受控的系统 API 调用&lt;/li&gt;
&lt;li&gt;中断是异步的硬件回调&lt;/li&gt;
&lt;li&gt;内核线程是 runtime 自己的后台工作线程&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;从什么开始？&lt;/h2&gt;
&lt;p&gt;我们会始终围绕这几个全局对象反复展开：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;task&lt;/li&gt;
&lt;li&gt;mm&lt;/li&gt;
&lt;li&gt;page&lt;/li&gt;
&lt;li&gt;file/inode/dentry&lt;/li&gt;
&lt;li&gt;socket/sk_buff&lt;/li&gt;
&lt;li&gt;device/driver&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因为几乎所有 kernel 机制，最后都绕回这些对象的状态变化和交互。所以作为 intro 的章节， 我们先把这些对象的基本定义和它们之间的关系搞清楚。&lt;/p&gt;
&lt;h2&gt;执行上下文&lt;/h2&gt;
&lt;p&gt;很多初学者学乱 kernel，不是因为函数难，而是没有先区分&lt;strong&gt;执行上下文&lt;/strong&gt;。Linux 内核里最常见的四种执行现场是 process context（例如用户态通过 &lt;code&gt;read()&lt;/code&gt;、&lt;code&gt;write()&lt;/code&gt;、&lt;code&gt;fork()&lt;/code&gt; 进入内核）、interrupt context（例如网卡收包、磁盘完成 I/O、定时器触发）、延后处理（例如 &lt;code&gt;softirq&lt;/code&gt; 和 &lt;code&gt;workqueue&lt;/code&gt;），以及内核线程（例如 &lt;code&gt;kworker&lt;/code&gt;、&lt;code&gt;kswapd&lt;/code&gt;、&lt;code&gt;ksoftirqd&lt;/code&gt;）。&lt;/p&gt;
&lt;p&gt;面对任何一段内核代码，最重要的问题往往不是“这个函数做什么”，而是：它现在处于什么上下文？能不能睡眠？持有什么锁？是不是中断上下文？是不是可以被抢占？这些问题经常比“功能是什么”还重要。&lt;/p&gt;
&lt;p&gt;内核里“这个函数能不能睡眠”之所以关键，是因为 sleep 意味着当前执行现场会主动阻塞，把 CPU 让出去，等某个条件满足后再被唤醒。这件事直接受执行上下文约束：在 process context 里通常可以睡眠，在 interrupt context 里不能，持有某些自旋锁时也不能。由此衍生出一对常见选择——&lt;code&gt;mutex&lt;/code&gt; 可以睡眠，适合较长的临界区，但不能在中断上下文里用；&lt;code&gt;spinlock&lt;/code&gt; 不会睡眠，适合极短的临界区和不能睡眠的上下文。&lt;/p&gt;
&lt;h2&gt;Syscall、Interrupt 和 Exception&lt;/h2&gt;
&lt;p&gt;这三个概念必须尽早分清。Syscall 是用户程序主动请求内核服务。Interrupt 是硬件异步打断 CPU。Exception 是当前执行某条指令时，CPU 同步发现某种异常条件。&lt;/p&gt;
&lt;p&gt;从学习路径上，一个很实用的心智模型是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;syscall 回答的是“进程主动要内核做什么”；&lt;/li&gt;
&lt;li&gt;interrupt 回答的是“外设异步告诉内核发生了什么”；&lt;/li&gt;
&lt;li&gt;exception（包括 page fault）回答的是“CPU 在执行当前指令时发现了什么异常条件”。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Syscall：受控进入内核的入口&lt;/h3&gt;
&lt;p&gt;用户态代码不能直接调用内核内部函数，也不能直接操作设备和页表。它必须通过 syscall 这扇门进入内核，比如 &lt;code&gt;read()&lt;/code&gt;、&lt;code&gt;write()&lt;/code&gt;、&lt;code&gt;open()&lt;/code&gt;、&lt;code&gt;mmap()&lt;/code&gt;、&lt;code&gt;fork()&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;可以把 syscall 理解成一次受控的上下文切换：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;userspace call libc wrapper
 -&gt; CPU 执行 syscall 指令陷入内核
 -&gt; 内核按 syscall number 分发到对应处理函数
 -&gt; 参数检查 / 权限检查 / 对象查找 / 具体子系统逻辑
 -&gt; 返回值写回寄存器
 -&gt; 回到用户态继续执行
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里最重要的不是“跳进内核”这件事本身，而是“受控”二字：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;用户态只能通过定义好的 syscall ABI 传参，不能随便访问内核数据结构；&lt;/li&gt;
&lt;li&gt;内核必须校验用户指针和权限，避免把用户输入当成可信数据；&lt;/li&gt;
&lt;li&gt;syscall 运行在 process context，通常允许睡眠，所以可能触发调度。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这也是为什么看内核路径时，&lt;code&gt;copy_from_user()&lt;/code&gt; / &lt;code&gt;copy_to_user()&lt;/code&gt;、权限检查和错误码传播总是高频出现：内核在处理的不是“内部调用”，而是“来自用户态的请求”。&lt;/p&gt;
&lt;h3&gt;Interrupt：硬件驱动的异步事件&lt;/h3&gt;
&lt;p&gt;和 syscall 相反，interrupt 不是进程主动调用，而是设备在“自己准备好了”时通知 CPU。常见例子是网卡收到包、磁盘 I/O 完成、定时器到期。&lt;/p&gt;
&lt;p&gt;粗粒度路径可以先记成：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;device raises interrupt
 -&gt; CPU 暂停当前执行流并进入中断入口
 -&gt; 内核执行中断处理程序（ISR）
 -&gt; 必要时唤醒等待该事件的 task
 -&gt; 返回被打断的执行流（或随后触发调度）
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;interrupt 的关键特征是异步和上下文约束：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;它和当前正在跑的用户指令没有直接因果关系；&lt;/li&gt;
&lt;li&gt;它发生在 interrupt context，不是 process context；&lt;/li&gt;
&lt;li&gt;中断处理通常要求“快进快出”，不能做可能睡眠的操作。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Exception：CPU 执行发现的异常&lt;/h3&gt;
&lt;p&gt;Page fault 就属于第三类：exception。它不是程序主动发起的 syscall，也不是外部设备异步打断，而是 CPU 在执行当前指令时，发现地址翻译或权限条件不满足，于是同步陷入内核。我们在这里用 page fault 这个例子来说明 exception 的概念，同时我们引入对于内存管理非常核心的一个概念：&lt;strong&gt;虚拟内存&lt;/strong&gt;。&lt;/p&gt;
&lt;h4&gt;Page Fault：不只是“出错”&lt;/h4&gt;
&lt;p&gt;Page fault 常见的触发原因有三类：&lt;strong&gt;页还没有真正映射到物理内存、权限不满足、或者 COW 需要兑现&lt;/strong&gt;。所以它不一定代表程序出了错，很多时候它是虚拟内存正常工作的组成部分。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;什么是 COW？不是奶牛，Copy-on-Write 是一种优化策略，通常在 &lt;code&gt;fork()&lt;/code&gt; 后使用。父子进程先共享同一批物理页，并把这些页标成只读。当某一方写入时触发 page fault，内核这时才复制这一页。这样做的好处是：如果父子进程都不修改这部分内存，就完全避免了不必要的复制，节省了内存和时间。之所以要先把共享页改成只读，是因为&lt;strong&gt;如果不改成只读，写入就不会触发 page fault，内核也就失去了“第一次写时再复制”的机会。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;看一段简单的例子：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;#include &amp;#x3C;sys/mman.h&gt;
#include &amp;#x3C;unistd.h&gt;
#include &amp;#x3C;stdio.h&gt;

int main() {
    size_t n = 4096 * 10;
    char *p = mmap(NULL, n, PROT_READ | PROT_WRITE,
                   MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);

    p[0] = &apos;A&apos;;
    p[4096] = &apos;B&apos;;
    printf(“%c %c\n”, p[0], p[4096]);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;这里 &lt;code&gt;mmap()&lt;/code&gt; 在做什么？&lt;/strong&gt;&lt;code&gt;mmap()&lt;/code&gt; 是在当前进程的虚拟地址空间里，建立一段地址范围和某种“后端对象”之间的映射关系。这个后端对象可以是匿名内存、文件、或设备内存。关键在于 &lt;strong&gt;&lt;code&gt;mmap()&lt;/code&gt; 通常先登记规则，不急着把所有物理页准备好&lt;/strong&gt;。所以它更像“申请一段虚拟地址规则”，而不只是“立刻拿到一堆已经就位的物理内存”。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这里 &lt;code&gt;mmap()&lt;/code&gt; 成功返回并不代表所有物理页都已就位。更常见的情况是 &lt;code&gt;mmap()&lt;/code&gt; 先登记一段虚拟地址范围，第一次访问某页时触发 page fault，内核再去分配物理页、建立映射或检查权限。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;为什么操作系统故意延迟分配&lt;/strong&gt;? 为什么不在 &lt;code&gt;mmap()&lt;/code&gt; 或 &lt;code&gt;malloc()&lt;/code&gt; 的时候就把物理页一次性全分完？原因包括：程序不一定会用到每一页，延迟分配避免了白白浪费；支持稀疏使用场景；降低创建开销；以及支持 &lt;code&gt;fork()&lt;/code&gt; 后的 COW。&lt;strong&gt;虚拟内存把“我想拥有这段地址空间”和“我现在真的需要物理内存”分开了。&lt;/strong&gt; 这个观点相信大家在学习计算机系统课时候已经体会到了。&lt;/p&gt;
&lt;p&gt;如果你写过一个 RISCV 的裸机 CPU，可能从未碰过“CPU 发现地址翻译不成立于是同步陷入”的概念。原因很简单——page fault 不是基础整数指令集天然就有的能力，它依赖更完整的体系结构环境：特权级、虚拟内存/MMU、页表/TLB、trap 机制，以及操作系统内核。一个直接访问物理内存的裸机处理器模型不会有 Linux 这种虚拟内存意义上的 page fault。&lt;/p&gt;
&lt;h2&gt;Kernel 里几个核心对象&lt;/h2&gt;
&lt;p&gt;这组对象是理解 kernel 的基本词汇表。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;task&lt;/strong&gt; 是执行单位。调度器关心的不是抽象课本里的“进程”或“线程”，而是 &lt;code&gt;task&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;mm&lt;/strong&gt; 是一个进程的地址空间描述，对应核心结构 &lt;code&gt;struct mm_struct&lt;/code&gt;。可以把它理解成：这个进程有哪些虚拟地址范围、每段权限是什么、哪些地方是代码段/堆/栈/mmap 区、页表根在哪。多个 task 共享同一个 &lt;code&gt;mm&lt;/code&gt; 时，看起来就很像同一进程内的多个线程。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;inode、dentry 和 file&lt;/strong&gt; 是文件系统里最核心的一组对象。&lt;code&gt;inode&lt;/code&gt; 是文件本身的元数据和身份，包括大小、权限、时间戳和数据块位置。&lt;code&gt;dentry&lt;/code&gt; 是路径名到 inode 的目录项关系，更偏“名字解析”这一层。&lt;code&gt;file&lt;/code&gt; 是一次 &lt;code&gt;open()&lt;/code&gt; 得到的“打开实例”，包括当前偏移、打开标志和操作方法表。&lt;/p&gt;
&lt;p&gt;举个例子：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;int fd1 = open(“a.txt”, O_RDONLY);
int fd2 = open(“a.txt”, O_RDONLY);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;通常意味着可能是同一个 &lt;code&gt;inode&lt;/code&gt;，可能共享相关 &lt;code&gt;dentry&lt;/code&gt;，但有两个不同的 &lt;code&gt;file&lt;/code&gt; 打开实例——因为两个 fd 的偏移可以独立变化。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;socket 和 sk_buff&lt;/strong&gt; 是网络子系统里最核心的一组对象。&lt;code&gt;sk_buff&lt;/code&gt; 是内核里表示网络数据包的结构，包含数据内容和各种元信息。它们是网络协议栈里数据流动的核心载体。至于 &lt;code&gt;socket&lt;/code&gt; 是什么，你不妨可以看看我之前写的&lt;a href=&quot;/blog/socket&quot;&gt;这篇文章&lt;/a&gt;。&lt;/p&gt;
&lt;p&gt;这一层最值得记住的对象列表是 &lt;code&gt;task&lt;/code&gt;、&lt;code&gt;mm&lt;/code&gt;、&lt;code&gt;page&lt;/code&gt;、&lt;code&gt;inode&lt;/code&gt;、&lt;code&gt;dentry&lt;/code&gt;、&lt;code&gt;file&lt;/code&gt;、&lt;code&gt;socket&lt;/code&gt; 和 &lt;code&gt;sk_buff&lt;/code&gt;。学 kernel，本质上就是在不断回答两个问题：这个子系统的核心对象是什么？这些对象会在什么事件下发生状态变化？我们在接下来的章节里会反复围绕这些对象来展开，理解它们在不同子系统里的角色和交互。&lt;/p&gt;
&lt;h3&gt;线程、进程和 task&lt;/h3&gt;
&lt;p&gt;Linux 里“线程”和“进程”在内核层面没有高层语言里想象的那么不同。更底层的事实是：调度器调度的是 &lt;code&gt;task&lt;/code&gt;。如果多个 task 共享 &lt;code&gt;mm&lt;/code&gt;、files、signal handlers 等资源，用户态会把它们叫作线程；如果这些资源不共享，用户态通常把它们叫作独立进程。共享同一个 &lt;code&gt;mm&lt;/code&gt; 就是在共享同一个虚拟地址空间，所以它更像线程。&lt;/p&gt;
&lt;h3&gt;用 read(fd, buf, n) 串起 syscall、VFS、文件和内存&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;read(fd, buf, n)&lt;/code&gt; 是一个特别好的串联例子。表面上只是一行调用，但从内核视角看，它在同时处理两件事：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;找到 &lt;code&gt;fd&lt;/code&gt; 背后的内核对象，确定“数据从哪里来”；&lt;/li&gt;
&lt;li&gt;验证并访问 &lt;code&gt;buf&lt;/code&gt; 这段用户地址，确定“数据往哪里去”。&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;VFS 到底是什么&lt;/h4&gt;
&lt;p&gt;VFS（Virtual File System）不是某一种具体文件系统（比如 ext4、xfs），而是 Linux 内核里的一层统一抽象。&lt;/p&gt;
&lt;p&gt;它的价值是：用户态只要调用统一接口（&lt;code&gt;open/read/write/...&lt;/code&gt;），内核就能在 VFS 层把请求分发到不同后端实现（普通文件系统、pipe、socket、字符设备、块设备等），而不是让每种后端都暴露一套完全不同的 syscall 语义。&lt;/p&gt;
&lt;p&gt;你可以把 VFS 理解成：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;对上：提供统一的“文件语义”接口给 syscall；&lt;/li&gt;
&lt;li&gt;对下：通过 &lt;code&gt;struct file&lt;/code&gt; 里的操作方法表，把请求交给具体实现。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以我们常说“Linux 里很多东西都可以 file-like 地操作”，背后核心就是这层统一抽象。&lt;/p&gt;
&lt;h4&gt;read 路径&lt;/h4&gt;
&lt;p&gt;粗粒度路径先记成：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;read(fd, buf, n)
 -&gt; syscall entry
 -&gt; fd table lookup
 -&gt; struct file
 -&gt; VFS
 -&gt; filesystem / pipe / socket / device implementation
 -&gt; page cache or lower I/O path
 -&gt; copy_to_user(buf)
 -&gt; return to user mode
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;展开后大概是这样：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;用户态调用 &lt;code&gt;read(fd, buf, n)&lt;/code&gt;，通过 syscall 进入内核。&lt;/li&gt;
&lt;li&gt;内核在当前 task 的 fd table 里查 &lt;code&gt;fd&lt;/code&gt;，拿到对应 &lt;code&gt;struct file&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;进入 VFS 读路径。VFS 根据 &lt;code&gt;file&lt;/code&gt; 的类型和操作方法，把请求转发给具体后端。&lt;/li&gt;
&lt;li&gt;具体后端产出数据：&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;普通文件：可能先命中 page cache，未命中再走更底层 I/O；&lt;/li&gt;
&lt;li&gt;pipe/socket：从对应缓冲区或协议栈路径取数据；&lt;/li&gt;
&lt;li&gt;设备文件：进入这个设备的具体文件读取实现。&lt;/li&gt;
&lt;/ul&gt;
&lt;ol start=&quot;5&quot;&gt;
&lt;li&gt;内核把得到的数据通过 &lt;code&gt;copy_to_user()&lt;/code&gt; 拷贝到用户给的 &lt;code&gt;buf&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;返回实际读取字节数，或者错误码。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;code&gt;read&lt;/code&gt; 不是只处理“文件”这么简单。它至少同时碰到两类对象：I/O 侧对象：&lt;code&gt;fd&lt;/code&gt; -&gt; &lt;code&gt;file&lt;/code&gt; -&gt; VFS -&gt; 具体后端；以及内存侧对象：当前进程 &lt;code&gt;mm&lt;/code&gt; 里的用户地址 &lt;code&gt;buf&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;所以内核必须做两套校验：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;fd&lt;/code&gt; 是否有效，是否可读，当前偏移和对象状态是否合理；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;buf&lt;/code&gt; 是否是当前进程可访问且可写的用户地址。&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;为什么这里也会触发 page fault&lt;/h4&gt;
&lt;p&gt;很多人刚学时会误以为“page fault 只会在用户代码里 &lt;code&gt;*p = ...&lt;/code&gt; 时发生”。其实在 &lt;code&gt;read&lt;/code&gt; 里，内核执行 &lt;code&gt;copy_to_user(buf)&lt;/code&gt; 时同样可能触发 page fault。&lt;/p&gt;
&lt;p&gt;原因很直接：&lt;code&gt;buf&lt;/code&gt; 对应的用户页可能尚未分配或尚未建立有效映射。此时 CPU 在内核代表用户访问该地址时发现条件不满足，就会进入缺页异常处理，补齐映射后再继续拷贝。&lt;/p&gt;
&lt;p&gt;换句话说，&lt;strong&gt;page fault 不只会发生在用户代码直接访存时，也会发生在内核代表用户访问用户地址时。&lt;/strong&gt; 内核也不会随心所欲地访问用户地址，它也可能会遇到“这个地址还没有准备好”的情况。&lt;/p&gt;
&lt;h2&gt;调度器&lt;/h2&gt;
&lt;p&gt;刚刚我们上面所提到的内容是 Linux kernel 的“事件处理器”部分，我们知道了 Linux 是如何处理 syscall、interrupt 和 exception 的。接下来我们要进入 Linux kernel 的另一个核心部分：&lt;strong&gt;资源管理器&lt;/strong&gt;。我们刚在上面已经部分介绍了 Linux 是如何管理内存的，接下来我们要介绍 Linux 是如何管理 CPU 资源的，也就是调度器。&lt;/p&gt;
&lt;p&gt;调度器最粗暴但很准确的定义是：&lt;strong&gt;它在决定“下一小段时间里，哪个 task 占哪个 CPU”。&lt;/strong&gt; 所以调度不是静态分配，而是持续的动态决策。它要平衡的目标包括吞吐量、延迟、公平性、实时性、多核负载均衡、cache locality 和功耗——这些目标本身常常互相冲突。&lt;/p&gt;
&lt;h3&gt;Task 的粗粒度状态&lt;/h3&gt;
&lt;p&gt;我们先了解三个状态就够：&lt;code&gt;running&lt;/code&gt;（正在跑）、&lt;code&gt;runnable&lt;/code&gt;（可以跑但还没被选中）、&lt;code&gt;blocked/sleeping&lt;/code&gt;（在等某个事件）。一个 task 发起磁盘 I/O 后从 running 变成 blocked，I/O 完成后被唤醒变成 runnable，被 scheduler 选中后重新变成 running。&lt;/p&gt;
&lt;p&gt;等 I/O 时不一直占着 CPU 的原因很直接：磁盘 I/O 相对 CPU 指令执行是极高延迟的事件，原地忙等白白浪费 CPU。更合理的做法是 task 主动阻塞，让调度器去运行别的 runnable task，等设备完成后由中断和后续处理唤醒原 task。本质上就是：&lt;strong&gt;调度器把 CPU 时间从“当前没法继续推进的 task”手里拿走，交给“现在能推进的 task”。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;每 CPU 一个 runqueue&lt;/h3&gt;
&lt;p&gt;Linux 倾向于每 CPU 一个本地 runqueue，而不是把所有 runnable task 都塞进全局唯一队列。直觉上可以把它想成“每个 CPU 维护自己的待运行队列”，这样大部分调度决策都能就近完成。&lt;/p&gt;
&lt;p&gt;为什么这件事这么重要？核心有三点：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;降低全局锁竞争：如果所有 CPU 都去抢一个全局队列，多核越多，锁争用越明显。&lt;/li&gt;
&lt;li&gt;提高 cache locality：一个 task 如果持续在同一个 CPU 跑，CPU cache、TLB 和分支预测状态更容易复用。&lt;/li&gt;
&lt;li&gt;让唤醒路径更本地化：很多时候一个 task 被某个 CPU 上的事件唤醒，优先放本地队列通常更快。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;当然，“每 CPU 一个队列”不等于永远不跨 CPU。现实里负载并不平均，所以内核还需要做负载均衡：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;某些 CPU 很忙，另一些 CPU 很闲时，会迁移一部分 runnable task；&lt;/li&gt;
&lt;li&gt;唤醒一个 task 时，也可能根据策略把它放到更合适的目标 CPU；&lt;/li&gt;
&lt;li&gt;NUMA 和拓扑信息也会影响“迁不迁、迁到哪”。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以更准确的总结是：&lt;strong&gt;默认本地化运行，必要时全局协调。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;Context Switch&lt;/h3&gt;
&lt;p&gt;Context switch 的本质是保存当前 task 的执行现场、恢复下一个 task 的执行现场，让 CPU 从“继续执行 A”变成“继续执行 B”。&lt;/p&gt;
&lt;p&gt;我们可以把它拆成两步：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;保存 A 的现场：寄存器、栈相关状态、调度状态等；&lt;/li&gt;
&lt;li&gt;恢复 B 的现场：把 B 上次停下时的现场重新装回 CPU。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;切换完成后，CPU 看起来就像“从来都在执行 B”，这就是操作系统实现并发的关键机制之一。&lt;/p&gt;
&lt;p&gt;为什么大家总说 context switch 有成本？因为它不只是“换个函数调用”。大家都写过 CPU, 我们知道程序的执行状态不仅仅是代码行号，还包括寄存器值、栈内容、内存映射、调度状态等。切换时需要保存和恢复这些状态，尤其是寄存器和地址空间相关状态，这些操作都需要 CPU 指令来完成，可能还会涉及 TLB shootdown 和 cache invalidation。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;寄存器保存与恢复开销；&lt;/li&gt;
&lt;li&gt;调度器路径和队列操作开销；&lt;/li&gt;
&lt;li&gt;cache/TLB 局部性被打断（尤其是跨 CPU 迁移时）。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果切换到另一个 &lt;code&gt;mm&lt;/code&gt;（也就是地址空间变了），额外成本通常更高，因为地址空间相关状态也要切换。反过来说，多个 task 共享同一个 &lt;code&gt;mm&lt;/code&gt;（我们常说的线程）时，这部分代价通常会小一些。
这也是为什么“切换次数”经常是性能分析的重点指标：切换不是坏事，但过多、无效、抖动式切换会吞掉可观 CPU 时间。&lt;/p&gt;
&lt;h3&gt;调度视角下的线程和进程&lt;/h3&gt;
&lt;p&gt;从调度器视角看，最底层被调度的单位是 task，而不是“高级语言概念里的线程/进程标签”。
调度器真正关心的是这类信息：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;这个 task 现在是 runnable 还是 blocked；&lt;/li&gt;
&lt;li&gt;优先级/权重是多少；&lt;/li&gt;
&lt;li&gt;放在哪个 CPU 的 runqueue；&lt;/li&gt;
&lt;li&gt;最近跑了多久，是否该被抢占。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;至于“它是线程还是进程”，在内核里更多是资源共享关系的差异：我们上面说了，如果多个 task 共享同一个 &lt;code&gt;mm&lt;/code&gt;、files、signal handlers 等资源，用户态通常把它们叫线程；如果这些资源不共享，用户态通常把它们叫独立进程。这也解释了一个常见误区：并不是“线程切换”和“进程切换”有两套完全不同机制；而应该说，是同一套 task 切换机制，在共享资源多少不同的情况下，表现出不同开销特征。&lt;/p&gt;
&lt;h3&gt;CFS：普通任务的主力调度器&lt;/h3&gt;
&lt;p&gt;Linux 不只有一种调度类，但普通任务的主力调度器是 CFS（Completely Fair Scheduler）。&lt;/p&gt;
&lt;p&gt;CFS 不是简单 round robin，而是&lt;strong&gt;尽量让 runnable 的普通任务公平分享 CPU 时间&lt;/strong&gt;。这里的“公平”不是“每个人连续跑完全一样长的小片段”，而是长期来看，每个任务获得与其权重相称的 CPU 份额。我们利用 &lt;code&gt;vruntime&lt;/code&gt; 这个核心概念来理解 CFS 的公平性。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;vruntime&lt;/code&gt; 可以理解成调度器内部的公平账本。谁最近跑得多，账本涨得多；谁跑得少，涨得少。&lt;code&gt;vruntime&lt;/code&gt; 越小，表示它在公平意义上越“亏” CPU，越应该先被运行。所以两个权重相同的 task，&lt;code&gt;vruntime&lt;/code&gt; 更小的那个会被优先选择。&lt;/p&gt;
&lt;h4&gt;CPU-bound vs I/O-bound&lt;/h4&gt;
&lt;p&gt;这是一个很有用的二分法。CPU-bound 的 task 基本一直 runnable，持续吃 CPU；I/O-bound 的 task 经常睡眠，只有在事件到来时短暂运行。经常睡眠的 task 反而常常更容易被优先调度——不是因为调度器抽象地知道它是交互任务，而是因为它过去一段时间实际没怎么占 CPU，所以在公平账本上更“亏”，一旦被唤醒就更可能先跑。这也是为什么交互型任务往往感觉响应更快。&lt;/p&gt;
&lt;p&gt;举个例子，大家都很熟悉的 shell，事实上是一个典型的 I/O-bound 任务。它大部分时间在等待用户从终端输入命令，只有在用户输入时才短暂地占用 CPU 来解析和执行命令。正因为它经常睡眠，所以一旦用户输入，它的 &lt;code&gt;vruntime&lt;/code&gt; 就相对较小，更容易被调度器优先选择，从而尽管你后台在跑着其他 CPU-bound 的任务，shell 依然能快速响应用户的输入。&lt;/p&gt;
&lt;h4&gt;CFS 用红黑树&lt;/h4&gt;
&lt;p&gt;CFS 反复需要做三件事：插入 runnable task、删除阻塞 task、找到 &lt;code&gt;vruntime&lt;/code&gt; 最小的 task。红黑树恰好适合：插入和删除都是 &lt;code&gt;O(log n)&lt;/code&gt;，最左边节点就是当前最该跑的 task。直觉上可以类比为 &lt;code&gt;std::set&amp;#x3C;Task, CompareByVruntime&gt;&lt;/code&gt;，当然真实实现是内核自己的红黑树。&lt;/p&gt;
&lt;h2&gt;调度类：CFS 不是全部&lt;/h2&gt;
&lt;p&gt;Linux 的调度框架里不只有 CFS。至少还有 &lt;code&gt;stop&lt;/code&gt;、&lt;code&gt;deadline&lt;/code&gt;、&lt;code&gt;rt&lt;/code&gt;、&lt;code&gt;fair&lt;/code&gt;（CFS）和 &lt;code&gt;idle&lt;/code&gt; 这些调度类。不同调度类不是简单地放在一起按同一套 &lt;code&gt;vruntime&lt;/code&gt; 竞争——RT 任务和普通 CFS 任务完全不在同一层竞争逻辑里，更高优先级调度类有活时，普通 task 要靠边。&lt;/p&gt;
&lt;h2&gt;结语&lt;/h2&gt;
&lt;p&gt;作为对于内核的第一个 intro, 我认为读完这篇文章应该能让大家或多或少有一个 Linux kernel 的全局视角，知道它的核心对象和机制是什么。接下来我们会在这个基础上，逐步展开讲解 Linux kernel 的各个子系统和工具链。&lt;/p&gt;
&lt;p&gt;如果文章中你发现了错误，请务必指出来！&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>装一台新电脑</title><link>https://theunknownth.ing/blog/arch-install</link><guid isPermaLink="true">https://theunknownth.ing/blog/arch-install</guid><description>I use arch btw.</description><pubDate>Sat, 21 Feb 2026 21:57:00 GMT</pubDate><content:encoded>&lt;p&gt;本来以为装一台新电脑没什么好写的，后来仔细一想，发现其实还是有不少坑的。从选硬件，到装系统，然后我又折腾了好久才把 Headless mode 的 game streaming 搞定，再是桌面美化。索性就把整个过程写下来，给以后自己和大家做个参考。&lt;/p&gt;
&lt;h2&gt;选硬件&lt;/h2&gt;
&lt;p&gt;先上配置单：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;CPU: Intel Core i5-13490F&lt;/li&gt;
&lt;li&gt;主板: 七彩虹 B760M-T WIFI DDR4&lt;/li&gt;
&lt;li&gt;内存: 海盗船 Vengeance Pro DDR4 3200MHz 16GB + 威刚万紫千红 DDR4 2400MHz 8GB&lt;/li&gt;
&lt;li&gt;显卡: 华擎 RX 9070 GRE Steel Legend&lt;/li&gt;
&lt;li&gt;存储: 西数 SN570 1TB NVMe SSD&lt;/li&gt;
&lt;li&gt;电源: 利民 SP750 750W 80PLUS 白金全模组&lt;/li&gt;
&lt;li&gt;机箱: 随便买的&lt;/li&gt;
&lt;li&gt;散热器: 随便买的&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;对于上述所有硬件，我花出的成本价格为 4900 元左右。注意：这个价格已经减去了“我卖掉了家里两根内存条”的收入。如果不算的话，成本在 5450 元左右。&lt;/p&gt;
&lt;h3&gt;解答一些问题&lt;/h3&gt;
&lt;p&gt;为了防止杠精，让我先来把一些可能会被问到的问题回答一下。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;为什么要装一台新电脑？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;在笔者写这篇文章的时候内存的价格是疯狂的。16GB DDR5 6400MHz 的内存条一根要 1300 元，就算是 16GB DDR4 3200MHz 的内存条在京东上也要 600 元一根。那么为什么现在还要装一台新电脑呢？众所周知我现在用的是一台 MacBook Air M3，我平时一直是连接 SSH 来做开发的，老的开发机是 i5-4440S，已经有 13 个年头了，虽然说不上没法用，但是确实体验不太好。~~其实我主要是想要一台可以玩游戏的电脑，上面的只是借口。~~&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;为什么不直接买一台整机？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;傻瓜才买整机。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;为什么内存条混插？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;因为便宜啊！那条 8GB 的内存条是我从家里老电脑上拆下来的，反正也没什么用处了。诶那你可能会说“混插频率不就被限制了吗？”好问题！但是 somehow 我家里那条威刚的体质特别好。BIOS 里我直接傻瓜 XMP 一开，内存频率就直接跑到了 3200MHz，时序 18-20-20-40 没有任何问题。（时序有点拉，但是总比跑 2400MHz 好）过了 MemTest86 的测试之后我就放心用了。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;你硬盘哪里来的&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;那块 NVMe SSD 是我之前给 MacBook Air M3 买的扩容硬盘（暑假买的，花费 330 元），哈哈哈哈 现在同款硬盘要 899 元了。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;13 代酷睿会缩肛你还买？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;好问题！但是 guess what？i5-13490F 作为英特尔特供中国市场的版本，当时为了节省成本，使用的是跟 12 代 Alder Lake 一样的架构设计，没有缩肛🙂‍↔️ 它的核心规模和 i5-12600KF 是几乎一致的。（当时 i5-13400F 是混用 Alder Lake 核心和 Raptor Lake 核心，然后大家还想抽奖抽到 Raptor Lake 核心，结果反倒会缩肛。想不到这也有反转！）&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;为什么不买 AMD？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;AMD 如果要上 DDR4 内存的话只有 Zen 3 架构能上。Zen 3 感觉还是太老了一些。并且还考虑到 AMD 平台的性能和内存频率非常挂钩（他的 Infinity Band 频率和内存频率是挂钩的）而我根本上不起 DDR5 内存条。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;我感觉解答完上述问题之后，大家对我选硬件的思路应该有了一定的了解。在这里我再提几个我选硬件时候注意到的点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;电源最好要选支持 ATX 3.1 标准的，毕竟现在显卡功耗越来越高了，ATX 3.1 规定了 12VHPWR 接口的供电要求以及峰值功率的要求，能更好地保护硬件。&lt;/li&gt;
&lt;li&gt;主板最好要带 WiFi 和蓝牙模块，因为这张显卡算是中高端型号，体积大（它占用了 2.9 槽位），相当于已经把主板最下方的一个 PCIe x1 插槽给挡住了，如果主板没有自带 WiFi 和蓝牙模块的话，就没法再插一张无线网卡了。（只能插 USB 无线网卡，但我个人不太喜欢用 USB 无线网卡）&lt;/li&gt;
&lt;li&gt;显卡保修最好是要支持个人送保的，现在显卡厂商中支持个人送保的并不多，华硕、技嘉、微星、七彩虹等品牌都支持个人送保，华擎不支持，那么你就得找代售点送修（所以我选择了从京东自营购买，这样京东会帮我送修，不会扯皮）。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;装电脑&lt;/h2&gt;
&lt;p&gt;装电脑其实并不难，网上有很多装机视频和教程，但是我在 CPU 安装被卡了一下，因为和我之前装过的电脑不太一样。&lt;/p&gt;
&lt;p&gt;这次我用的是 Intel 第 13 代 CPU，使用的是 LGA 1700 插槽。我之前只装过 LGA 115x（六代酷睿）和 LGA 3124（至强第一代可扩展）的 CPU。这里主要带来的不同是：（1）CPU 在安装时候并不只是直接放进去然后扣上固定杆，在按下固定杆的同时还需要按住扣具的左上角，否则扣具是扣不上的，个人猜测这可能是因为 CPU 不再是方形，而是长方形的缘故；（2）在安装散热器扣具的时候，散热器背板的四个角要向外“拉一下”，让螺柱间距变大，才能卡到主板的孔位上。&lt;/p&gt;
&lt;p&gt;我们直接来欣赏一下装好后的效果吧 🤗
&lt;img src=&quot;https://theunknownth.ing/_astro/finish.BiooLjn8_Z1Uxr7X.webp&quot; alt=&quot;装好后的效果&quot;&gt;&lt;/p&gt;
&lt;p&gt;btw 我确实不喜欢 RGB 吧，但是行业的共识是没有 RGB 的显卡大约是同级中最丐的了，我也不想买丐显卡。我回头再看看怎么把 CPU 散热器和显卡的 RGB 灯关掉吧。不然我这台电脑就成了一个行走的迪斯科了。&lt;/p&gt;
&lt;p&gt;当然 玻璃盖板还没盖（因为我还准备装三个风扇），所以看起来有点乱。等我装好风扇之后再放一张照片。当然我理线肯定也要再理一下，不过不是现在，因为我在家里装的电脑，搬到学校时候我肯定要把显卡和 CPU 散热器拆下来，所以我就先不理线了。&lt;/p&gt;
&lt;h2&gt;装系统&lt;/h2&gt;
&lt;p&gt;我这次装的是 Arch Linux。这次我尝试了点不一样的：PXE Boot + Arch Linux Netboot 镜像安装系统。全程没有使用任何 U 盘。非常爽。这里我顺便分享一下我的 PXE Boot 环境搭建过程。&lt;/p&gt;
&lt;h3&gt;搭建 PXE Boot 环境&lt;/h3&gt;
&lt;p&gt;PXE Boot basically 就是通过网络来启动电脑。我在这里不是很想赘述他的工作原理（如果你好奇的话，他是基于 DHCP 和 TFTP 工作的，关于什么是 DHCP 我在上一篇 blogpost 中有讲 感兴趣可以看看？）。操作的方法是：你首先需要在主板里开启又一个叫做“Network Stack”的选项（听起来跟网络启动没有半毛钱关系），然后重启 BIOS，你大概率就能看到启动选项卡里有一个叫做“PXE Boot”的选项了。&lt;/p&gt;
&lt;p&gt;有了这个选项还是没用，我们还需要把一个特定的 Bootloader 文件喂给这个电脑，总之我们需要搭建一个“让把 Bootloader 喂给计算机”的 server。&lt;/p&gt;
&lt;p&gt;这里参考了&lt;a href=&quot;https://jah.io/easy-mode-pxe-boot&quot;&gt;这篇文章&lt;/a&gt;。但是稍作了一下修改：文章中提到&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Go Version 1.18 changed the way that go get works. As of that version it now manages module dependencies and no longer fetches, compiles and installs tools/binaries built in Go. You want go install for that as of 1.18, however, pixiecore won&apos;t build in 1.18, so you need to run go install using go 1.17, no newer. This is because they project is basically abandonware now for whatever reason.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;其实大可不必大费周章去装一个 Go 1.17 的环境，consider that 大家的 go 环境是新的版本（比如我是 1.24），我们可以自己 clone 源代码，然后用新的 go 版本来编译它。具体步骤如下：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git clone https://github.com/danderson/netboot.git
cd netboot
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后编译：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;go build -o pixiecore-bin ./cmd/pixiecore
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;编译完成后，我们可以使用 &lt;code&gt;netboot.xyz&lt;/code&gt; 这个非常好用的 PXE Boot。我们先下载 UEFI Bootloader：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;wget https://boot.netboot.xyz/ipxe/netboot.xyz.efi
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后运行 pixiecore：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ./pixiecore-bin boot &quot;netboot.xyz.efi&quot; --bootmsg &quot;booting from pxe&quot; -d --ipxe-efi64 &quot;netboot.xyz.efi&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样我们的 PXE Boot server 就搭建完成了。接下来我们只需要在目标电脑上选择 PXE Boot 启动（注意要连接同一个局域网，并且待启动的电脑需要接上网线），然后你在 pixiecore 运行的终端上能看到这样的内容：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theunknownth.ing/_astro/netboot.D_rfV1B3_299HnA.webp&quot; alt=&quot;PXE Boot 日志&quot;&gt;&lt;/p&gt;
&lt;p&gt;你在目标电脑上就能看到类似这样的界面：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theunknownth.ing/_astro/server.DpRktS2s_ZX1SQK.webp&quot; alt=&quot;PXE Boot 界面&quot;&gt;&lt;/p&gt;
&lt;p&gt;在跑完进度条后，你就能进入 netboot.xyz 的主界面了：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theunknownth.ing/_astro/netboot.Bspj4UvV_Z21g2Ug.webp&quot; alt=&quot;netboot.xyz 主界面&quot;&gt;&lt;/p&gt;
&lt;p&gt;在这里我们选择“Linux Network Installs -&gt; “Arch Linux”，然后他会下载 Arch Linux Netboot 镜像并启动它，接下来就可以按照 Arch Wiki 上的安装步骤来安装 Arch Linux 了。同样，我们也可以选择其他的 Linux 发行版来安装，比如 Ubuntu、Fedora 等等。我没试过用它装 Windows（虽然它也提供了 Windows 的安装选项），试过的朋友可以在评论区告诉我体验怎么样。&lt;/p&gt;
&lt;p&gt;如果你在下载 Arch Linux Netboot 镜像的时候遇到了问题（比如下载速度慢，或者下载失败），那是因为你没有换源。我没有找到 netboot.xyz 把 archlinux 的 netboot 镜像换成国内源的方法。如果你网络环境通畅，那就直接下载吧，反正也不大（大约 700MB）。如果你网络环境不太好，同样推荐你使用 Arch 官方的 pxe 来启动。这里面首页进去就会让你选区域，选 China 后可选阿里云，清华大学，南京大学等国内的镜像源，下载速度就会快很多了。具体详情，可以参考 &lt;a href=&quot;https://wiki.archlinux.org/title/Netboot&quot;&gt;Arch Wiki 上的 PXE Boot 章节&lt;/a&gt;。逻辑是完全一样的，你把命令中 &lt;code&gt;netboot.xyz.efi&lt;/code&gt; 换成 Arch 官方下载的 .efi 文件就好了。&lt;/p&gt;
&lt;p&gt;在这里面 Utilities 选项卡下还有一个叫做“memtest86+ (v8.0.0)”的选项，可以用来测试内存是否有问题，非常方便。我就是用它测试通过了我那根混插的内存条。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theunknownth.ing/_astro/memtest.6hp9_tVl_ZTtGv.webp&quot; alt=&quot;Memtest Pass&quot;&gt;&lt;/p&gt;
&lt;p&gt;来放一张我装好 Arch Linux 之后的截图（桌面美化稍后会有，这不是最终效果）：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theunknownth.ing/_astro/fastfetch.BMF_lr-d_Z1l4MOl.webp&quot; alt=&quot;Arch Linux&quot;&gt;&lt;/p&gt;
&lt;h2&gt;玩游戏&lt;/h2&gt;
&lt;p&gt;买了这么好的显卡显然是要玩游戏的，而且为了用 Linux 特意选了一块 AMD 显卡（So NVIDIA, Fuck You）。AMD 显卡有非常好的社区驱动支持，在 archinstall 的时候就会提示让你选择 AMD 的 proprietary 的驱动或是社区驱动，社区驱动往往 perform 更好，proprietary 的驱动是给那些大客户用的，（或许）更稳定。&lt;/p&gt;
&lt;h3&gt;降压、解锁功耗墙&lt;/h3&gt;
&lt;p&gt;在这里我们既要降压也要解锁功耗墙。至于我为什么不说“超频”，且听我解释：&lt;/p&gt;
&lt;p&gt;当代 AMD 的 GPU 的 base clock 往往都标的非常保守（拿我的 9070 GRE 举例，他的基础频率最高只有 2350MHz），在这个频率下是根本跑不满功耗的（同样拿我的举例子，只能跑 160~200W 左右，而 TDP 为 240W）。此时 GPU 如果 Performance Level 在 “Automatic” 上（见下图），GPU 会尽可能尝试 boost 到高的频率来&lt;strong&gt;吃满功耗&lt;/strong&gt;。对于高负载的游戏下（比如赛博朋克2077），GPU 是跑不到板卡厂商标定的最高频率的。因此，真正限制 GPU 频率发挥的往往不是 “你超了多少频” 而是 “GPU 到底能不能跑到这个频率”，也即“有没有这个功耗允许他跑到这个频率”。&lt;/p&gt;
&lt;p&gt;With that in mind，我们需要做的只是降压+解锁功耗墙即可。降压能让相同频率下显卡功耗更低，于是显卡能跑更高频。解锁功耗墙同样也是为了让其跑到更高频率。&lt;/p&gt;
&lt;p&gt;对于具体配置，每张显卡各不相同。不过你应该先在B站上做做调研。搜索“你的显卡型号+超频”，看个五六个视频以及评论区，你就对你的显卡的“average的体质”有了了解。可以从那个配置的基础上改。&lt;/p&gt;
&lt;p&gt;我在 Arch 上使用 LACT 程序。我并不推荐你手动去改 GPU 的 profile。使用现成的超频工具能带来两个好处：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;它自带 daemon，开启能自动生效，并且能防止配置不被 override&lt;/li&gt;
&lt;li&gt;它能 List available 的配置。而你直接改配置文件可能会改坏，并不是所有配置都适用于所有年代的显卡。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;对于我的显卡，最终稳定游戏的是以下配置：显存 +100MHz，GPU 电压 -85mV，功耗墙 240W -&gt; 264W。其实在显存 +150MHz，电压 -100mV 的情况下也能过测，但是我并不推荐这样日常使用。某个测试稳定（比如 superposition，或者 3dmark）并不代表所有游戏都稳定。在 Windows 下，不稳定的现象表现为 “AMD 显卡掉驱动”，在 Linux 下，则表现为桌面环境卡死，然后过一会儿恢复，提示你“GPU 已经恢复”。如果你在游戏中遇到了这种情况，那就说明你的配置不稳定了。我在之前几天亦遇到过几次这种情况，后来把降压幅度调小了一点就稳定了。总之，稳定性才是第一位的，能过测只是一个参考。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theunknownth.ing/_astro/lact.eIpXKMzD_mIo8g.webp&quot; alt=&quot;降压超频&quot;&gt;&lt;/p&gt;
&lt;h3&gt;开启 FSR4 Support&lt;/h3&gt;
&lt;p&gt;首次游玩赛博朋克2077 的时候，我发现对于 FSR 的支持最高只有 3.1 版本。在网上搜索了一下，发现 AMD 官方早就说赛博朋克2077 的 FSR 版本已经支持 4.0 了（2025 年 4 月份就有相关报道了），但是为什么我这边最高只能选 3.1 呢？&lt;/p&gt;
&lt;p&gt;后来发现是 Steam 的 Proton 版本过旧了。Steam 的 Proton 是一个兼容层，能让 Windows 游戏在 Linux 上运行。Proton 的更新频率尽管已经较快了，但有时候还是会落后于 Windows 上游戏的更新。根据社区提示，我们需要换用 CachyOS 的 Proton 版本（CachyOS 是一个基于 Arch Linux 的发行版，专门针对游戏进行了优化）。换了 CachyOS 的 Proton 版本之后，FSR4 就能选了。&lt;/p&gt;
&lt;p&gt;需要安装 &lt;code&gt;proton-cachyos&lt;/code&gt; 包：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;yay -S proton-cachyos
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;安装需要大约 1 个小时，，，，建议不要在睡前安装，否则会被硬控。装完后在 Steam 关于游戏的“Properties”里把 Proton 版本切换到 CachyOS 的版本就好了。&lt;/p&gt;
&lt;h3&gt;无头模式下的 Game Streaming&lt;/h3&gt;
&lt;p&gt;装好系统之后我就开始折腾 Headless Mode 下的 Game Streaming 了。这里我选择了 Sunshine + Moonlight 的组合。Sunshine 是一个开源的自托管的游戏流媒体服务器，Moonlight 则是一个开源的 NVIDIA GameStream 客户端，可以在多种设备上运行。&lt;/p&gt;
&lt;p&gt;Sunshine 的安装非常简单，Arch Linux 的用户可以直接从 AUR 上安装：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;yay -S sunshine-git
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;安装完成后，运行 Sunshine，在网页端（注意要用https访问）设置好你的密码和编解码器。然后在你的客户端设备上安装 Moonlight，连接到你的 Sunshine 服务器（配对码也是在网页端显示），就可以开始游戏了。&lt;/p&gt;
&lt;p&gt;但是这样的话你在玩游戏的时候显示器一直得开着。如果你把显示器关掉，Sunshine 会罢工，告诉你 “Streaming Error,... Is the host display turned on?” 一直把显示器开着也不是长久之计，我们要节约能源！所以我们需要让 Sunshine 在无头模式下也能正常工作。这里有两种方法，都可以解决这个问题：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;方法一：你喜欢的购物网站上买一个叫做 HDMI 诱骗器的东西（HDMI Dummy Plug），插在显示器的 HDMI 接口上，这样电脑就会认为有一个显示器连接着了，Sunshine 就不会罢工了。&lt;/li&gt;
&lt;li&gt;方法二：通过内核参数强制“伪造”一个显示器。这个稍微更有技术含量一些，我们详细讲一下：&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;它会让 Linux 内核在启动阶段就确信“有一个显示器插在 HDMI 接口上”。首先准备一个 EDID 文件。你需要一个文件来告诉显卡“我连接的是一个什么显示器”。通常你不需要大费周章去生成一个 EDID 文件，最简单的办法是直接把你现在显示器的 EDID 文件 dump 出来就好了。&lt;/p&gt;
&lt;p&gt;如果没有安装 &lt;code&gt;edid-decode&lt;/code&gt; 的话可以先安装：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;yay -S edid-decode
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后去这个目录下看看：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ls /sys/class/drm
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在这里每个人的配置都会不一样。比如我的输出是这样的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;card1  card1-DP-1  card1-DP-2  card1-DP-3  card1-HDMI-A-1  card1-Writeback-1  renderD128  version
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;其中 &lt;code&gt;card1&lt;/code&gt; 中包含了显卡的一些配置，我们不管。其余的 &lt;code&gt;card1-DP-1&lt;/code&gt;，&lt;code&gt;card1-DP-2&lt;/code&gt;，&lt;code&gt;card1-DP-3&lt;/code&gt; 是三个 DisplayPort 接口，&lt;code&gt;card1-HDMI-A-1&lt;/code&gt; 是 HDMI 接口。对于我来说我的真显示器是插在 HDMI 接口上的，所以我就去 &lt;code&gt;card1-HDMI-A-1&lt;/code&gt; 这个目录下 dump 出 EDID：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cat /sys/class/drm/card1-HDMI-A-1/edid | edid-decode
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果你看到了显示器的相关信息（比如分辨率啊，刷新率啊，厂商信息啊等等），那就说明你成功了。接下来我们把这个 EDID 信息保存到一个文件里：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo cat /sys/class/drm/card1-HDMI-A-1/edid &gt; /usr/lib/firmware/edid/my_monitor.bin
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;接下来我们找一下显卡空闲的接口。你当然可以通过上面 &lt;code&gt;cat edid&lt;/code&gt; 的方法来一个个试，如果 &lt;code&gt;edid-decode&lt;/code&gt; 提示 empty stdin，那么就是空的，但是更简单的方法是运行一下：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;for p in /sys/class/drm/*/status; do con=${p%/status}; echo -n &quot;${con#*/card?-}: &quot;; cat $p; done
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我们只需要找到那个 status 是 disconnected 的接口就好了。比如我的输出是这样的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;DP-1: disconnected
DP-2: disconnected
DP-3: disconnected
HDMI-A-1: connected
Writeback-1: unknown
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;那么我就选 DP-3 这个接口来伪造显示器。注意：这个借口你最好也别接显示器了，不然可能会有冲突。接下来我们需要把这个 EDID 文件的路径告诉内核。&lt;/p&gt;
&lt;p&gt;你需要修改 Bootloader 配置（GRUB 或 systemd-boot），加入强制启用显示器的参数。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;如果你用 GRUB:
编辑 /etc/default/grub，在 GRUB_CMDLINE_LINUX_DEFAULT 引号里追加：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;drm.edid_firmware=DP-3:edid/my_monitor.bin video=DP-3:e
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;记得把这个 DP-3: 换成你上面查到的接口名。同时，&lt;code&gt;edid/my_monitor.bin&lt;/code&gt;: 换成你第一步里放的文件名（路径默认相对于 /usr/lib/firmware/）。&lt;code&gt;video=DP-3:e&lt;/code&gt;: 这个 :e 最关键，意思是 &quot;Enable&quot;（强制启用），告诉内核忽略物理连接状态。&lt;/p&gt;
&lt;p&gt;然后更新 GRUB：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo grub-mkconfig -o /boot/grub/grub.cfg
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;如果你用 systemd-boot:
直接编辑 &lt;code&gt;/boot/loader/entries/&lt;/code&gt; 下对应的 .conf 文件，在 options 行尾追加同样的内容。注意这里每个人每个系统的 .conf 文件以及目录结构可能都不太一样，你需要自己找一下。比如我这里是 /boot/loader/entries/2026-01-20_14-25-25_linux-zen.conf，我就在这个文件的 options 行尾追加：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;drm.edid_firmware=DP-3:edid/my_monitor.bin video=DP-3:e
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;然后注意 ⚠️ 需要更新 initramfs！因为 EDID 文件需要被打包进启动镜像里，显卡驱动在加载时才能读到它。
编辑 &lt;code&gt;/etc/mkinitcpio.conf&lt;/code&gt;，找到 FILES=() 这一行，把你的 EDID 文件加进去：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;FILES=(/usr/lib/firmware/edid/my_monitor.bin)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后重新生成：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo mkinitcpio -P
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;重启电脑后，你就会发现即使显示器关掉了，Sunshine 也不会罢工了！你就可以愉快地在无头模式下玩游戏了！Better yet，像 KDE 之类非常智能的桌面环境还会自动检测到显示器状态的变化，你在接上正常显示器的时候，你可以去显示设置里 disable 掉那个虚拟显示器，这样你就可以正常使用显示器了；当你把显示器关掉的时候，KDE 会发现当前显示器不可用了，就自动切换到那个虚拟显示器上了，完全不需要你手动干预，非常智能。&lt;/p&gt;
&lt;h2&gt;换桌面&lt;/h2&gt;
&lt;p&gt;说实话当时我 archinstall 的时候是直接选的 niri 作为桌面环境。（我用 Linux 其实很大一部分原因也是馋 niri！macOS 下有一个类似物叫做 paperspoonwm 但是那玩意动画不好看 然后也受限于 macOS 的 WM 总之就是做不好）但是刚一进去这个 niri 也太简陋了😅 所有的 navigation 又是完全依赖于键盘 不配置的情况下完全是不可用的状态。于是又重新安装了 KDE Plasma 😅。但是经过一番美化，我觉得当下 niri 已经是相当可用，我已经将其作为我的主力桌面环境使用，请看现在的效果图：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theunknownth.ing/_astro/niri.By4jVghK_ZjfaP6.webp&quot; alt=&quot;niri&quot;&gt;&lt;/p&gt;
&lt;p&gt;Linux 社区来 show 桌面的时候总是会打开一个 btop，打开一个 fastfetch 然后右下角再开点别的东西。遵循这个传统，我也是这样排布的。右下角是我和同学开的 Minecraft Server 😁。&lt;/p&gt;
&lt;p&gt;对于审美，每个人各不相同，但是我在这里可以抛砖引玉一下我个人觉得较好的美化方案：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;背景模糊：我个人觉得背景模糊是非常重要的一个美化元素了。它能让界面看起来更有层次感，减少视觉疲劳。However, by the end of 2026.2，niri 是不支持背景模糊的，上图终端的背景模糊是在 niri 中配置 “draw-border-with-background false”，然后让终端自己来画背景模糊的。好消息是在笔者写这篇文章的时候， niri 的开发者已经在他的 development branch 上实现了背景模糊的功能了，可以看这个 commit：&lt;a href=&quot;https://github.com/niri-wm/niri/commit/1d92c18aac07dc83e08e470ed315a6d36da3c19e&quot;&gt;Link to GitHub commit&lt;/a&gt;。想要尝试的话也可以编译 https://github.com/niri-wm/niri/tree/wip/branch 这个分支上的代码。所以不久的将来 niri 就会原生支持背景模糊了，敬请期待！&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Waybar：Waybar 是一个非常好用的状态栏工具，支持高度自定义。你可以通过编辑配置文件来调整它的外观和功能，比如添加系统信息、网络状态、电池状态等等。Waybar 的主题也非常丰富，你可以在网上找到很多现成的主题，或者自己动手设计一个。他的主题就是通过一大堆 CSS 来实现的，所以理论上你只要能写 CSS 就能设计出你想要的状态栏了。一个很不错的基底是 &lt;a href=&quot;https://github.com/catppuccin/waybar&quot;&gt;这个主题&lt;/a&gt;，他能给你那种“药丸圆角”的那种感觉，口说无凭，这是他官方给的效果图：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;preview.webp&quot; alt=&quot;Waybar&quot;&gt;&lt;/p&gt;
&lt;p&gt;你可以向其中加入很多你自己喜欢的模块，比如我就加入了一个显示当前 CPU 占用率，内存占用率，网络状态的模块。你也可以加入当前正在播放的音乐信息，或者是一个显示当前时间和日期的模块，甚至是一个显示当前天气的模块。总之，Waybar 的自定义性非常强，你可以根据自己的需求来设计你的状态栏。这是我现在的 Waybar：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theunknownth.ing/_astro/my_waybar.CXyaqSH__Z1auyxd.webp&quot; alt=&quot;My Waybar&quot;&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Fuzzel：默认的 niri 应用启动器就是 fuzzel，但是默认状态实在是太丑了。好在 fuzzel 的主题配置较为简单，就是一个标准的 .ini 文件，里面定义了一些颜色和字体的配置项。我就选择了 Tokyo Night 这个配色方案，稍微改了一下字体和边框的配置，辅以背景模糊，效果如下：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theunknownth.ing/_astro/fuzzel.CUlOvdfb_Z18Gzqx.webp&quot; alt=&quot;Fuzzel&quot;&gt;&lt;/p&gt;
&lt;p&gt;你可以 argue 说你觉得还是不好看，但是我觉得已经是非常不错了。毕竟也就是这么几行配置文件的事情，如果你需要的话可以抄我的配置文件，或者自己改一改来适合你的审美。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ini&quot;&gt;[main]
font=JetBrainsMono Nerd Font:size=13
prompt=&quot;❯   &quot;
icon-theme=Papirus-Dark
icons-enabled=yes
width=45
lines=10
horizontal-pad=20
vertical-pad=20
inner-pad=10
layer=overlay

[colors]
background=1a1b26e6
text=c0caf5ff
match=f7768eff
selection=414868ff
selection-text=c0caf5ff
selection-match=ff899dff
border=7aa2f7ff

[border]
width=2
radius=10
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Alacritty：默认的 niri 终端是 Alacritty。Alacritty 是一个非常快的 GPU 加速终端模拟器，支持高度自定义。Alacritty 默认那套“黑底白字无边距”的样式非常简陋。我们可以通过编辑配置文件来美化它。Alacritty 在较新的版本中已经彻底废弃了原有的 YAML 格式，全面转向了 TOML 格式。网上的很多老教程可能已经失效了。如果你需要我的配置文件的话，我同样贴在这里：&lt;/p&gt;
&lt;p&gt;Alacritty 会自动读取 &lt;code&gt;~/.config/alacritty/alacritty.toml&lt;/code&gt;。另外，记得安装好字体（我用的是 JetBrains Mono Nerd Font），不然可能会出现乱码或者图标显示不出来的情况。用&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo pacman -S ttf-jetbrains-mono-nerd
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;来安装这个字体。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-toml&quot;&gt;[window]
padding = { x = 16, y = 16 }
opacity = 0.90
decorations = &quot;None&quot;
dynamic_title = true

[font]
normal = { family = &quot;JetBrainsMono Nerd Font&quot;, style = &quot;Regular&quot; }
bold = { family = &quot;JetBrainsMono Nerd Font&quot;, style = &quot;Bold&quot; }
italic = { family = &quot;JetBrainsMono Nerd Font&quot;, style = &quot;Italic&quot; }
size = 12.0

[cursor]
style = { shape = &quot;Beam&quot;, blinking = &quot;On&quot; }

[colors.primary]
background = &quot;#1a1b26&quot;
foreground = &quot;#c0caf5&quot;

[colors.normal]
black   = &quot;#15161e&quot;
red     = &quot;#f7768e&quot;
green   = &quot;#9ece6a&quot;
yellow  = &quot;#e0af68&quot;
blue    = &quot;#7aa2f7&quot;
magenta = &quot;#bb9af7&quot;
cyan    = &quot;#7dcfff&quot;
white   = &quot;#a9b1d6&quot;

[colors.bright]
black   = &quot;#414868&quot;
red     = &quot;#ff899d&quot;
green   = &quot;#b1e37b&quot;
yellow  = &quot;#f3c27b&quot;
blue    = &quot;#8cb6ff&quot;
magenta = &quot;#ceafff&quot;
cyan    = &quot;#8fe2ff&quot;
white   = &quot;#c0caf5&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;又或者说 你看到这里的时候可能已经厌倦了，觉得“反正我也不 care 美化”，或者“怎么有这么多配置文件要写？”。那么我还有一个大招。你直接用 &lt;code&gt;dankmaterialshell&lt;/code&gt;。这个项目把桌面美化做成了一个一键安装的脚本，能让你的桌面瞬间变得非常好看。它的效果图非常好看（采用了 consistent 的谷歌 Material 3 Design Style）。可以去 &lt;a href=&quot;https://github.com/AvengeMedia/DankMaterialShell&quot;&gt;项目地址&lt;/a&gt; 看看效果图。安装也非常简单，直接运行下面的命令就好了：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;curl -fsSL https://install.danklinux.com | sh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;他能让你自由选择窗口管理器（支持 niri、hyprland,...），让你自由选择终端（ghostty, alacritty, ...）总之你如果不是 artist 或者你只需要一个“开箱即用”的美化方案的话，这个项目绝对是非常好的选择了。我的桌面就是安装了这个项目之后稍微改了一点配置的结果了。&lt;/p&gt;
&lt;p&gt;桌面美化的折腾是永无止境的，等 niri 的原生背景模糊功能出来了，我还想再把桌面美化一下，敬请期待下一篇 blogpost 吧！&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>What is the Internet Protocol?</title><link>https://theunknownth.ing/blog/internet-protocol</link><guid isPermaLink="true">https://theunknownth.ing/blog/internet-protocol</guid><description>Understanding the Invisible Envelope that Delivers Data Across the Globe</description><pubDate>Tue, 27 Jan 2026 18:32:00 GMT</pubDate><content:encoded>&lt;p&gt;Before you read further, take a moment to ask yourself: &lt;strong&gt;What is the Internet Protocol (IP)?&lt;/strong&gt; Can you explain its purpose &amp;#x26; what it is consisting of beyond just &quot;it&apos;s how devices get IP addresses&quot;?&lt;/p&gt;
&lt;p&gt;Like many developers, I had a functional understanding of the stack: I knew TCP provides reliable connections, UDP is for fast messages, and IP addresses are where packets go. But when I really stopped to ask, &lt;strong&gt;&quot;What &lt;em&gt;is&lt;/em&gt; the Internet Protocol?&quot;&lt;/strong&gt;, I realized I was stuck.&lt;/p&gt;
&lt;p&gt;For IP, the only thing I could picture was an IP Address. This led to a fundamental confusion: &lt;strong&gt;Is the Internet Protocol just a standard for addresses?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The answer, of course, is no. After digging into the architecture, I finally built a mental model that clicks. Here is what I learned.&lt;/p&gt;
&lt;h2&gt;The Protocol is Not the Address&lt;/h2&gt;
&lt;p&gt;First let&apos;s see the well-known OSI Model:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;+---------------------+
| Application Layer   |  (HTTP, FTP, DNS)
+---------------------+
| Transport Layer     |  (TCP, UDP)
+---------------------+
| Network Layer       |  (IP)
+---------------------+
| Data Link Layer     |  (Ethernet, Wi-Fi)
+---------------------+
| Physical Layer      |  (Cables, Radio Waves)
+---------------------+
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you are confused why the above graph only contains 5 layers instead of the usual 7, check out &lt;a href=&quot;/channel/osi&quot;&gt;my channel post&lt;/a&gt;. TL;DR: people often use a simplified “Internet stack” view where OSI’s Session/Presentation layers are folded into the Application layer, and Physical + Data Link are commonly treated together as “the link” (even though they’re distinct in the strict OSI model).&lt;/p&gt;
&lt;p&gt;From this model, it&apos;s easy to see the functionality of IP: it operates at the &lt;strong&gt;Network Layer&lt;/strong&gt;, responsible for routing packets from one host to another across different networks.&lt;/p&gt;
&lt;p&gt;The biggest mental block was separating the &lt;em&gt;address name&lt;/em&gt; (the IP address) from the &lt;em&gt;logic&lt;/em&gt; (the IP protocol). Once you start understanding IP as &lt;strong&gt;a set of rules and procedures&lt;/strong&gt; rather than just a label, the rest falls into place.&lt;/p&gt;
&lt;p&gt;The internet is a series of wires, &lt;strong&gt;IP is the logic that navigates the wires.&lt;/strong&gt; The IP address, on the other hand, is part of the protocol (like a street address in a mailing system) but not the whole story. I gave a short talk about &lt;a href=&quot;/src/assets/teaching/slides/Route.pdf&quot;&gt;How Computer Networks Route Your Packets&lt;/a&gt;, and actually the logic of &lt;code&gt;Routing&lt;/code&gt; is also part of the Internet Protocol. What&apos;s more, IP also defines how packets are structured (the IP Header), how fragmentation works (note that large packets may need to be broken down; on Ethernet MTU is often 1500 bytes), and the delegation of IP addresses (CIDR, DHCP, SLAAC, etc).&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;postal office&lt;/code&gt; analogy is one of the best mental models in networking (from my perspective). When you send a letter, the postal system functions similarly to how IP works:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Envelope: The IP protocol defines how to package data into packets with headers containing source and destination addresses.&lt;/li&gt;
&lt;li&gt;Addressing: The national addressing system ensures each location has a unique identifier, just like IP addresses.&lt;/li&gt;
&lt;li&gt;Routing: The postal system routes letters through various post offices; similarly, IP routes packets through routers across networks.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And let&apos;s talk about routing in more detail with the above 3 components in mind.&lt;/p&gt;
&lt;h2&gt;The IP Header&lt;/h2&gt;
&lt;p&gt;The Internet Protocol has a single focus: getting a packet from Computer A to Computer B. It is completely agnostic about what is inside that packet. It doesn&apos;t care if it&apos;s a fragment of a 4K video, a move in a competitive game, or a simple text file.&lt;/p&gt;
&lt;p&gt;Let&apos;s first see the IP Header for IPv4 (credit: UC Berkeley CS168):&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theunknownth.ing/_astro/ipv4_header.5l95QlQQ_ZYaTk0.webp&quot; alt=&quot;IPv4 Header&quot;&gt;&lt;/p&gt;
&lt;p&gt;There are so many fields in the header, but when you take a closer look at it, you&apos;ll find many of them are pretty useful. For example:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Version:&lt;/strong&gt; Indicates whether it&apos;s IPv4 or IPv6.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Source &amp;#x26; Destination IP:&lt;/strong&gt; The &quot;From&quot; and &quot;To&quot; addresses. We need these to know where to send the packet &amp;#x26; where to send back the response.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TTL (Time To Live):&lt;/strong&gt; Prevents packets from circulating forever.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Protocol:&lt;/strong&gt; Indicates whether the payload is TCP, UDP, ICMP, etc. This is for demultiplexing the Layer 4 protocol at the destination. Otherwise the payload would be gibberish.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Header Checksum:&lt;/strong&gt; Ensures the integrity of the header data. &lt;strong&gt;NOTE:&lt;/strong&gt; This checksum only covers the header, not the payload. This is due to the &lt;em&gt;end-to-end principle&lt;/em&gt;: payload integrity is handled by the end hosts, not the routers.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fragmentation Fields:&lt;/strong&gt; Allow large packets to be broken down into smaller fragments for transmission and reassembled at the destination.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The IPv6 header is simpler and more efficient (credit: UC Berkeley CS168):&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theunknownth.ing/_astro/ipv6_header.DlbAZf7m_jD0kI.webp&quot; alt=&quot;IPv6 Header&quot;&gt;&lt;/p&gt;
&lt;p&gt;You can view it as an evolution of the IPv4 header and pushing the &lt;code&gt;End-to-End Principle&lt;/code&gt; even further by:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Removing the Header Checksum (relying entirely on upper-layer protocols for error checking).&lt;/li&gt;
&lt;li&gt;Simplifying fragmentation (only the source handles fragmentation; intermediate routers don’t fragment).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It also ensures scalability with the &lt;code&gt;next header&lt;/code&gt; field, allowing extension headers without complicating the base header.&lt;/p&gt;
&lt;h2&gt;Addressing&lt;/h2&gt;
&lt;p&gt;Before we talk about routing, we must ask: how do devices get these addresses in the first place?&lt;/p&gt;
&lt;p&gt;We have moved away from the rigid &quot;Class A/B/C&quot; system of the 1980s to Classless Inter-Domain Routing (CIDR). If you want to learn about the old-school Class A/B/C system, you could view this &lt;a href=&quot;/channel/classful_addressing&quot;&gt;channel post&lt;/a&gt;. CIDR allows us to slice IP space at any bit boundary, creating subnets of any size to fit the need perfectly.&lt;/p&gt;
&lt;p&gt;The CIDR notation is nothing fancy; if you are not familiar with it, here is a quick refresher:&lt;/p&gt;
&lt;p&gt;An IP address is a 32-bit number (IPv4) or a 128-bit number (IPv6). For simplicity, we take IPv4 as example. What if we want to represent a block of addresses (e.g. 10.0.0.0 to 10.0.0.255)? We could write it as 10.0.0.*, of course, but this fails at examples such as 10.0.0.0 to 10.0.0.127. Instead, we use CIDR notation: &lt;code&gt;10.0.0.0/24&lt;/code&gt; to represent the first example and &lt;code&gt;10.0.0.0/25&lt;/code&gt; for the second. The &lt;code&gt;/24&lt;/code&gt; or &lt;code&gt;/25&lt;/code&gt; indicates how many bits are fixed for the network portion of the address. If you are still confused, let&apos;s write it in binary:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;10.0.0.0/24 means:
00001010.00000000.00000000.00000000
|----- 24 bits fixed -----| Hosts |

The first 24 bits define the network, fixed.
The last 8 bits are free for host addresses.
This gives us 2^8 = 256 addresses (10.0.0.0 to 10.0.0.255).
We write `/24` because 24 bits are fixed for the network.

10.0.0.0/25 means:
00001010.00000000.00000000.0|0000000
|------ 25 bits fixed ------| Hosts |

The first 25 bits define the network, fixed.
The last 7 bits are free for host addresses.
This gives us 2^7 = 128 addresses (10.0.0.0 to 10.0.0.127).
We write `/25` because 25 bits are fixed for the network.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The beauty of CIDR is its flexibility. You can carve up address space at any bit boundary, creating subnets of exactly the size you need. A &lt;code&gt;/30&lt;/code&gt; gives you 4 addresses, while a &lt;code&gt;/22&lt;/code&gt; gives you $2^{32-22}=1024$ addresses. This efficiency is what allows the internet to scale beyond the rigid Class A/B/C system. Similarly, for IPv6, the total bit length is 128 bits, and a very common LAN subnet size is &lt;code&gt;/64&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Now the practical question: &lt;strong&gt;who hands out addresses to hosts?&lt;/strong&gt; This is where DHCP and SLAAC come in, and they solve &lt;em&gt;slightly different problems&lt;/em&gt;.&lt;/p&gt;
&lt;h3&gt;DHCP (IPv4)&lt;/h3&gt;
&lt;p&gt;In IPv4, the default model is &lt;strong&gt;DHCP (Dynamic Host Configuration Protocol)&lt;/strong&gt;: a server “leases” configuration to clients for a limited time.&lt;/p&gt;
&lt;p&gt;What the client gets is usually more than just an IP:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;IPv4 address&lt;/li&gt;
&lt;li&gt;Subnet mask&lt;/li&gt;
&lt;li&gt;Default gateway&lt;/li&gt;
&lt;li&gt;DNS servers&lt;/li&gt;
&lt;li&gt;Lease time (and other options)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The handshake you’ll often see described is &lt;strong&gt;DORA&lt;/strong&gt;:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Discover&lt;/strong&gt;: client broadcasts “is there a DHCP server?”&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Offer&lt;/strong&gt;: server offers an address + options&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Request&lt;/strong&gt;: client requests that offer&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Ack&lt;/strong&gt;: server confirms (lease is now active)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Two important operational details are easy to miss:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Leases expire and renew.&lt;/strong&gt; Clients try to renew partway through the lease (often around 50%), and if renewal fails they can “rebind” by broadcasting to any DHCP server.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DHCP works across subnets using relays.&lt;/strong&gt; Broadcasts don’t cross routers, so networks commonly deploy a &lt;strong&gt;DHCP relay&lt;/strong&gt; (often on the router) that forwards DHCP requests to a centralized server.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This “stateful” model is simple for admins: one place to audit, reserve static leases, and manage options.&lt;/p&gt;
&lt;h3&gt;SLAAC (IPv6)&lt;/h3&gt;
&lt;p&gt;IPv6 introduced &lt;strong&gt;SLAAC (Stateless Address Autoconfiguration)&lt;/strong&gt; because the world needed to number &lt;em&gt;billions&lt;/em&gt; of devices without a central server tracking every single assignment.&lt;/p&gt;
&lt;p&gt;In a SLAAC environment, the router doesn’t hand out individual addresses. Instead, it periodically sends &lt;strong&gt;Router Advertisements (RA)&lt;/strong&gt; (part of NDP/ICMPv6) that say:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;“Here is the &lt;strong&gt;prefix&lt;/strong&gt; for this link (often &lt;code&gt;/64&lt;/code&gt;).”&lt;/li&gt;
&lt;li&gt;“Here is the default gateway.”&lt;/li&gt;
&lt;li&gt;“Here are timing parameters (valid lifetime, preferred lifetime).”&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each host then creates its own address by combining:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the advertised &lt;strong&gt;prefix&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;a self-generated &lt;strong&gt;Interface ID&lt;/strong&gt; (lower 64 bits)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That Interface ID is &lt;em&gt;not&lt;/em&gt; always derived from MAC due to privacy concerns. Modern OSes commonly use temporary, randomized addresses; it rotates over time to reduce tracking. Before a host starts using the address, it runs &lt;strong&gt;Duplicate Address Detection (DAD)&lt;/strong&gt; to ensure no one else on the link is already using it.&lt;/p&gt;
&lt;p&gt;So SLAAC is “stateless” in the sense that &lt;em&gt;no server maintains a lease database of host addresses&lt;/em&gt;. The network advertises the prefix; hosts pick their own. (It’s also common to mix SLAAC + DHCPv6: SLAAC for the address, DHCPv6 for DNS/search domains, depending on network policy.)&lt;/p&gt;
&lt;h3&gt;DHCPv6 Prefix Delegation (PD)&lt;/h3&gt;
&lt;p&gt;Here’s the missing piece that makes home IPv6 feel different from home IPv4: With IPv4, home networks often relied on &lt;strong&gt;NAT&lt;/strong&gt;: your router got &lt;em&gt;one&lt;/em&gt; public IP and hid everyone else behind it. In IPv6, NAT is discouraged, while it IS possible for you to setup NAT66 on OpenWRT or similar router, it is not the common practice (I mean, IPv6 has enough addresses, why bother?).&lt;/p&gt;
&lt;p&gt;IPv6 aims to restore &lt;strong&gt;end-to-end addressing&lt;/strong&gt;, so your home router needs not just one address, but a &lt;strong&gt;block&lt;/strong&gt; large enough to carve into multiple &lt;code&gt;/64&lt;/code&gt; LANs (guest Wi‑Fi, IoT VLAN, etc). That’s what PD is for.&lt;/p&gt;
&lt;p&gt;Think of it as a two-level process:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;ISP -&gt; Router (DHCPv6-PD):&lt;/strong&gt; your router requests a prefix using an &lt;strong&gt;IA_PD&lt;/strong&gt; (Identity Association for Prefix Delegation). The ISP “delegates” something like a &lt;code&gt;/56&lt;/code&gt; or &lt;code&gt;/60&lt;/code&gt; to your router.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Router -&gt; LAN Hosts (SLAAC via RA):&lt;/strong&gt; your router takes that delegated block, selects one &lt;code&gt;/64&lt;/code&gt; per LAN segment, and advertises each &lt;code&gt;/64&lt;/code&gt; via &lt;strong&gt;Router Advertisements&lt;/strong&gt;. Hosts then self-assign addresses using SLAAC.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Routing&lt;/h2&gt;
&lt;p&gt;So how does a packet find its way across the ocean? Routers make decisions based on a logic called Longest Prefix Match (LPM). A router doesn&apos;t memorize every single IP address on earth. Instead, it memorizes &quot;prefixes&quot; (recall what we discussed above, the CIDR). If a packet matches multiple entries in the routing table, the router always picks the most specific one (e.g. If a /24 and /22 both match, it picks /24 routing).&lt;/p&gt;
&lt;p&gt;But &lt;strong&gt;where do these tables come from&lt;/strong&gt;? This is the domain of Routing Protocols, which are divided into two distinct families based on their scope, IGP and BGP.&lt;/p&gt;
&lt;h3&gt;IGP&lt;/h3&gt;
&lt;p&gt;Interior Gateway Protocols (IGP) operate within a single AS (Autonomous System).&lt;/p&gt;
&lt;p&gt;Remember: we view the whole Internet as a network of networks. It is not a centralized system, but rather a federated system. Within each small network we may have our own routing policies. To manage this complexity, the concept of an Autonomous System (AS) is introduced.&lt;/p&gt;
&lt;p&gt;An Autonomous System (AS) is a collection of IP networks and routers under the control of a single organization that presents a common routing policy to the internet. Each AS is assigned a unique AS number (ASN) for identification.&lt;/p&gt;
&lt;p&gt;There are 2 major IGP protocols:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Distance-Vector Protocols&lt;/li&gt;
&lt;li&gt;Link-State Protocols&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It is pretty common that you have no idea about what the above 2 protocols is about. But before I dive deeper into them, let me give you a quick summary:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Distance-Vector Protocols get the full view of the network by getting their neighbors&apos; routing tables periodically.&lt;/li&gt;
&lt;li&gt;Link-State Protocols get the full view of the network by local computation and flooding the network with link-state advertisements.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Maybe ths above explanation is awful 😢, maybe you still don&apos;t grasp the point. Don&apos;t worry, hope you can grasp the details below.&lt;/p&gt;
&lt;h4&gt;Distance-Vector Protocols&lt;/h4&gt;
&lt;p&gt;Let’s first look at a picture to understand how distance-vector protocols work:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theunknownth.ing/_astro/distance_vector.DjxJHieC_14mT77.webp&quot; alt=&quot;Distance Vector&quot;&gt;&lt;/p&gt;
&lt;p&gt;In distance-vector protocols, each router maintains a table (vector) that lists the best known distance to each destination and the next hop to reach that destination. Periodically, each router shares its table with its immediate neighbors. Formally, we can state the update process as:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;If you hear about a path to some destination, update the table if:
&lt;ul&gt;
&lt;li&gt;You don&apos;t have a path to that destination yet, or&lt;/li&gt;
&lt;li&gt;The new path is shorter than your current known path.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Tell your neighbors about your updated table.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This process is exactly what we state above &quot;getting their neighbors&apos; routing tables periodically&quot;. Routers get &lt;code&gt;How far is each destination&lt;/code&gt; by &quot;neighbor&apos;s result&quot; + &quot;cost to reach that neighbor&quot;.&lt;/p&gt;
&lt;p&gt;But as you might notice, this process has some problems. The major problem is: if a link goes down, it can take a long time for all routers to realize that the path is no longer valid and find the new optimal path.&lt;/p&gt;
&lt;p&gt;One simple optimization to address the above problem is to add the &lt;code&gt;poison packets&lt;/code&gt; mechanism. When a router detects that a link is down, it immediately informs its neighbors that the distance to the affected destination is infinite (or some very large number). But adding the packet would also cause some problem we haven&apos;t seen before (what I do not want to cover here). If you want to see the final algorithm when adding the poison packets, here it is:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;If you hear an advertisement for that destination, update the table and reset the timer if:
&lt;ul&gt;
&lt;li&gt;The destination isn&apos;t in the table, or&lt;/li&gt;
&lt;li&gt;The advertised cost + link cost is better than the best-known cost, or&lt;/li&gt;
&lt;li&gt;The advertisement is from the current next-hop (includes poison advertisements).&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Advertise updates to neighbors when the table changes, and periodically.
&lt;ul&gt;
&lt;li&gt;Don’t advertise back to the next-hop (split horizon), &lt;strong&gt;or&lt;/strong&gt; advertise poison back (poison reverse).&lt;/li&gt;
&lt;li&gt;Any cost ≥ a threshold (e.g., 16 in RIP) is treated as infinity.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;If a table entry expires, mark it poison and advertise it.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;Link-State Protocols&lt;/h4&gt;
&lt;p&gt;Distance-vector spreads &lt;em&gt;results&lt;/em&gt; (“here’s my best distance”). Link-state spreads &lt;em&gt;facts&lt;/em&gt; (“here’s what I’m directly connected to”). See the picture below:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theunknownth.ing/_astro/link_state.DOb10hgh_Zi6Kcf.webp&quot; alt=&quot;Link State&quot;&gt;&lt;/p&gt;
&lt;p&gt;In a link-state protocol (OSPF / IS-IS), each router does three big things:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Discover neighbors&lt;/strong&gt;&lt;br&gt;
Routers exchange hello messages to find adjacent routers and form neighbor relationships.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Flood link-state advertisements&lt;/strong&gt;&lt;br&gt;
Each router advertises the state and cost of its &lt;em&gt;local links&lt;/em&gt; (e.g., “I have a link to R2 with cost 10”).&lt;br&gt;
These LSAs are &lt;strong&gt;flooded&lt;/strong&gt; (forwarded onward) until convergence.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Run Dijkstra&lt;/strong&gt;&lt;br&gt;
Once everyone has the same topology database, each router independently runs &lt;strong&gt;Dijkstra&lt;/strong&gt; to compute a shortest-path tree rooted at itself.&lt;br&gt;
The result becomes the router’s forwarding entries (“to reach prefix X, next hop is Y”).&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This approach has several advantages over distance-vector:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Faster convergence:&lt;/strong&gt; link-state can react to changes more quickly since routers have a full view of the topology.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Routing Logic is consistent:&lt;/strong&gt; all routers compute the same shortest-path tree, it is less likely to have routing loops.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;However, there are tradeoffs, one major problem is it consumes &lt;strong&gt;More CPU/RAM:&lt;/strong&gt; maintaining a database and running Dijkstra is heavier than basic distance-vector.&lt;/p&gt;
&lt;h3&gt;BGP&lt;/h3&gt;
&lt;p&gt;IGPs are about “best path by metric” inside one organization. &lt;strong&gt;BGP is about policy&lt;/strong&gt; across organizations. Note that the network is federated: no one entity controls the whole Internet. Each AS has its own policies about which routes to accept, prefer, or advertise. Trasferring packets across AS boundaries requires it to obey these policies.&lt;/p&gt;
&lt;p&gt;BGP (Border Gateway Protocol) is the Internet’s inter-domain routing protocol. It’s often described as a &lt;strong&gt;path-vector&lt;/strong&gt; protocol:&lt;/p&gt;
&lt;p&gt;Remember that in the link-state protocol, each router floods the network with link-state advertisements to build a complete topology map. From the privacy perspective, this is not acceptable for BGP. Each AS wants to keep its internal topology &amp;#x26; customers private. Instead, BGP only shares reachability information (which prefixes can be reached) along with path attributes, without revealing the internal structure of the AS.&lt;/p&gt;
&lt;p&gt;At a high level, the global Internet is many ASes (ISPs, cloud providers, enterprises, universities). Each AS can run its own IGP internally (OSPF/IS-IS/etc.). At the edges, ASes use &lt;strong&gt;BGP&lt;/strong&gt; to exchange which prefixes they can reach.&lt;/p&gt;
&lt;p&gt;For more details about &lt;code&gt;peering &amp;#x26; transit&lt;/code&gt;, &lt;code&gt;iBGP &amp;#x26; eBGP&lt;/code&gt;, &lt;code&gt;hot-potato routing&lt;/code&gt;, please check my short talk about &lt;a href=&quot;/src/assets/teaching/slides/Route.pdf&quot;&gt;How Computer Networks Route Your Packets&lt;/a&gt;. Here I&apos;ll just omit these details.&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>如何在寝室制作热巧克力（WIP）</title><link>https://theunknownth.ing/blog/hot-chocolate</link><guid isPermaLink="true">https://theunknownth.ing/blog/hot-chocolate</guid><description>在寒冷的夜晚，享受一杯自制热巧克力的温暖与甜蜜。</description><pubDate>Mon, 29 Dec 2025 21:27:55 GMT</pubDate><content:encoded>&lt;p&gt;接续这一条 Channel 发的内容：&lt;a href=&quot;/channel/hot_chocolate&quot;&gt;如何在寝室制作热巧克力&lt;/a&gt;。我在这里更新一下制作热巧克力的完整步骤（完整吗？现在我还在探索中）和一些小贴士。&lt;/p&gt;
&lt;h2&gt;选择容器&lt;/h2&gt;
&lt;p&gt;寝室里制作热巧克力需要一个微波炉安全的杯子或碗。确保它足够大，可以容纳热巧克力和搅拌空间。&lt;/p&gt;
&lt;h3&gt;注意事项&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;使用微波炉安全的容器，避免塑料杯，千万不要使用金属容器！这会炸微波炉！&lt;/li&gt;
&lt;li&gt;那些一次性的咖啡杯 / 关东煮杯子能不能用？绝大多数纸杯内壁都有一层塑料淋膜。如果是PE淋膜（最常见）：耐热只有90-100℃左右。微波炉加热牛奶极易产生局部高温，导致涂层融化，让你可以喝到“塑料味”的热巧，甚至摄入有害物质。如果是PP淋膜：可以耐受120℃左右，相对安全，但这种杯子成本高，便利店不一定用。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;鉴于以上几点，我仍然最推荐的是陶瓷杯或玻璃杯（商品详情页面里面最好提到了说“可以进微波炉”，因为这类杯子往往有更厚的壁，不会炸）。我在 PDD 上买了个杯子，12.1 元包邮，并且还附带一个搅拌勺。所以不要贪这点钱，花点钱让自己喝的安心最重要。&lt;/p&gt;
&lt;h3&gt;如何清洗容器？&lt;/h3&gt;
&lt;p&gt;寝室里很少有人会买洗洁精（毕竟大家都不做饭），也不太可能会有小苏打或者白醋（毕竟不是在化学实验室里🤣）。对于刚买来的杯子，如果实在不放心上面的细菌 / 残留的灰尘，可以考虑用一下“牙膏”来清洁。&lt;/p&gt;
&lt;p&gt;在知道“牙膏清洁法”之前我也使用的是热水泡的方法。仅靠“开水泡”可以做到90%的放心（主要是杀菌和去味），但想要彻底洗干净（去油和微小粉尘），还是需要用到牙膏。为什么？&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;开水烫泡确实能杀灭绝大多数附着的细菌，这点没问题。
但是新杯子表面可能有一层薄薄的保护蜡或工业油膜。开水虽然能融化它们，但如果只是泡着不动，油膜漂浮在水面，水倒掉时油膜又会挂在杯壁上，等于没洗掉。
另外，静电吸附的微小粉尘，光靠水泡很难彻底脱落，需要物理摩擦。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;鉴于以上两点，使用牙膏清洁法是最简单有效的。使用我们高中化学的知识，我们知道它含有研磨剂（碳酸钙或二氧化硅）和表面活性剂，去污能力强，且带有清香。而且 better yet，牙膏毕竟要入口，还是比较安全的，哪怕你真的没有洗干净，残留的量也不会对人体造成伤害。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;先把杯子用清水冲湿。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;挤大概黄豆大小的牙膏涂在杯壁和杯底。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;用手指（或者干净的牙刷/洗脸巾）在杯子内部反复摩擦，特别是杯口接触嘴唇的地方。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;你会感觉到摩擦力，这是牙膏在带走污垢。结束后，用清水彻底冲洗干净即可。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这样清洁出来的杯子亲测很干净好吧。建议将牙膏清洁法和热水泡法结合使用，先用牙膏清洁，再用开水泡一会儿（我泡了两次开水，每次泡开水都是加开水至快溢出的情况，然后等待 90 摄氏度多的开水冷却至 40 摄氏度左右，再倒掉）。&lt;/p&gt;
&lt;p&gt;经过这样两步走的清洁，杯子就非常干净了，已经可以放心使用。&lt;/p&gt;
&lt;h2&gt;选择原料&lt;/h2&gt;
&lt;p&gt;在本次实验中，我使用了以下原料：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;纯牛奶（全脂更佳，口感更好）。每罐 200ml 的小包装牛奶。&lt;/li&gt;
&lt;li&gt;巧克力（我使用了 85% 可可含量的黑巧克力）。巧克力可以替换为牛奶巧克力（需要你相应减少糖的用量）又或者说者可可粉（需要你额外添加糖和脂肪）。&lt;/li&gt;
&lt;li&gt;糖（根据个人口味调整）。我们稍后会说到这点。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;当然，AI还给我建议了很多提升风味的原料，比如肉桂粉 / 香草精 / 棉花糖 / 鲜奶油 / 薄荷糖浆等等。但鉴于寝室条件有限，我这次就先用最基础的原料来制作。&lt;/p&gt;
&lt;h3&gt;糖如何选择？&lt;/h3&gt;
&lt;p&gt;一般来说寝室里不太可能有白砂糖这种东西，就算有的话也是红糖之类（但我觉得红糖跟热可可不搭）。建议重新选购一下糖。大家可以直接去电商平台上买糖，一般来说大家有四个象限的选择：&lt;/p&gt;
&lt;p&gt;|        | 颗粒状糖        | 方糖     |
| ------ | --------------- | -------- |
| 白色糖 | 白砂糖 / 细砂糖 | 咖啡方糖 |
| 棕色糖 | 红糖 / 黄糖     | 黄糖方糖 |&lt;/p&gt;
&lt;p&gt;以下给出我的一些选购建议：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;如果用量少（比如你就准备做一杯 hot chocolate），建议不要买，直接去麦当劳白嫖就可以了。你厚着脸皮问店员要几包糖包，完全没问题的。我试了两次（校内校外的麦当劳都试过）店员都很乐意给我糖🍬。关于糖的用量，spoiler：200mL牛奶 + 85% 黑巧克力的话，加 12g 糖就够甜（加 4 包绝对会太多！我试过）。如果使用牛奶巧克力，还需要相应减少。麦当劳糖包是 4g / 袋，所以 3 袋即可。&lt;/li&gt;
&lt;li&gt;如果用量多的话，建议买颗粒状糖更划算。颗粒状糖更容易溶解在牛奶中（方糖需要时间融化）。尽管价格不一定比方糖实惠，但颗粒状糖更好用。按照我买的价格对比：我买了 25 包 5g / 袋 “太古金黄咖啡调糖”，每包 0.22 元，非常实惠，总价 5.5 元包邮，~~他们这样快递费都不够赚吧~~。By contrast，方糖三盒 * 250g / 盒，价格 20.0 元包邮，实惠不少，但是你做不出来那么多热巧克力吧🤣。&lt;/li&gt;
&lt;li&gt;选择白色糖还是棕色糖？
&lt;ul&gt;
&lt;li&gt;白砂糖 / 细砂糖：甜味纯净，不会影响巧克力的风味。&lt;/li&gt;
&lt;li&gt;黄糖：带有焦糖风味，会增加热巧克力的复杂度。如果你喜欢这种风味，可以尝试。我觉得很不错好吧，毕竟白砂糖就是“纯甜”，为什么不试试有风味的糖呢？&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;巧克力如何选择？&lt;/h3&gt;
&lt;p&gt;如果你喜欢浓郁的巧克力风味，建议选择 70% 及以上的黑巧克力。如果你喜欢甜一些的口感，可以选择牛奶巧克力（大约 30%-40% 可可含量）。注意，牛奶巧克力含糖量较高，制作时需要相应减少糖的用量。&lt;/p&gt;
&lt;p&gt;同时注意：因为牛奶巧克力可可少了，所以后续如果你希望“更浓郁的风味”的话，需要相应稍微增加巧克力的用量。&lt;/p&gt;
&lt;h2&gt;实验 Attempt 1&lt;/h2&gt;
&lt;h3&gt;期望步骤&lt;/h3&gt;
&lt;h4&gt;制作“巧克力酱”&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;掰碎：把板状巧克力掰得越碎越好。建议用量：200mL牛奶大约搭配 20g 巧克力。&lt;/li&gt;
&lt;li&gt;加少许奶。只倒一点点牛奶，刚刚没过巧克力碎即可。此时也加入糖。&lt;/li&gt;
&lt;li&gt;初次加热：放入微波炉，中高火加热 30-40秒。&lt;/li&gt;
&lt;li&gt;搅拌乳化：拿出来。这时候巧克力可能看起来还没化，但牛奶是烫的。用勺子不断搅拌，利用余温把巧克力彻底化开，变成一杯浓稠的巧克力酱。&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;如果还有硬块化不开，就再加热10秒。一定要搅拌到顺滑无颗粒。&lt;/li&gt;
&lt;li&gt;为什么糖在这一步加入？糖在加热过程中会更好地融入牛奶分子，产生一种“熟奶香”。如果在热好了之后再放糖，虽然也能化，但风味融合度不如一起加热来得好。&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;融合牛奶&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;倒满牛奶：在巧克力酱里倒入剩下的牛奶（留一点空间防溢出）。&lt;/li&gt;
&lt;li&gt;混合：用勺子稍微搅拌一下，让酱和奶初步混合。&lt;/li&gt;
&lt;li&gt;二次加热：放入微波炉，高火加热 1分 - 1分30秒。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;实际实验&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://theunknownth.ing/_astro/0.B1LVnnTd_q0h5N.webp&quot; alt=&quot;巧克力用量&quot;&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;如图所示，一整版是 100g 巧克力，我掰了大约 20g 出来，放入杯子中。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;https://theunknownth.ing/_astro/0.5.D9lRomDl_ZDX0L5.webp&quot; alt=&quot;加牛奶&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;注意：这里出现了第一个问题。巧克力还是没有掰太碎，实在是太大块了，导致后续没有融化的特别好！&lt;/strong&gt;&lt;/p&gt;
&lt;ol start=&quot;2&quot;&gt;
&lt;li&gt;
&lt;p&gt;加少许奶。不要倒满牛奶！ 只倒一点点牛奶，刚刚没过巧克力碎即可（大约30-50mL）。在这里我只倒了我杯子的 1/4 高。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;放入微波炉，高火加热 1 分钟。（&lt;strong&gt;这是我的操作！不要学！1 分钟太多太多了！&lt;/strong&gt;）牛奶溢出来了！（当然，我把微波炉擦过了，但是还是要注意不要溢出来！）&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;拿出来搅拌。巧克力还是没完全化开！（&lt;strong&gt;第二个问题出现了！&lt;/strong&gt;）我觉得是因为巧克力块太大块了，导致没法完全融化。注意：尽管巧克力看起来像下图这样“看似融化”，但是还是记得多搅拌！因为巧克力块还是有的！（见下面两张图）&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;看似搅拌好了的巧克力酱：
&lt;img src=&quot;https://theunknownth.ing/_astro/1.QdXpLtJ0_Z2osEsW.webp&quot; alt=&quot;看似搅拌好了&quot;&gt;&lt;/p&gt;
&lt;p&gt;但事实上还有细细碎碎的巧克力块（这是我喝到最后才发现的）：
&lt;img src=&quot;https://theunknownth.ing/_astro/2.Cs3Ljb54_4yFnY.webp&quot; alt=&quot;还有细细碎碎的巧克力块&quot;&gt;&lt;/p&gt;
&lt;p&gt;请忽略上图杯子边沿超级超级脏的样子🤣，如果它让你想到了一些不好的画面，请你不要想它，而是相信这杯热巧克力是很美味的😋。&lt;/p&gt;
&lt;h3&gt;实验反思&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;巧克力块要掰得越碎越好！建议掰到大约豌豆大小，甚至更小。这样才能确保它们能完全融化。&lt;/li&gt;
&lt;li&gt;初次加热时间要短！我下次准备减少火力到中高火，加热时间减少到 20-30 秒。&lt;/li&gt;
&lt;li&gt;搅拌要彻底！确保巧克力完全融化，没有颗粒感。&lt;/li&gt;
&lt;li&gt;糖的用量要适中！本次我加入了 4 包麦当劳糖包（16g）（我使用了 200mL 牛奶），结果太甜了！下次准备减少到 3 包（12g）。糖和奶的比例应该在 6% 左右比较合适。&lt;/li&gt;
&lt;li&gt;本次因为我在清理微波炉，所以我没有进行“二次加热融合牛奶”的步骤。我准备下次实验时再进行这个步骤，看看效果如何。当然，我会注意不要让牛奶溢出来😅。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;洗杯子&lt;/h3&gt;
&lt;p&gt;同样，我非常推荐使用“牙膏清洁法”来清洗杯子。经过热巧克力的浸泡，杯子内部可能会有一些巧克力残留物。使用牙膏+ 手搓，30秒钟就能洗得干干净净，比单纯用水冲洗效果好很多。请看效果图：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theunknownth.ing/_astro/3.D5YnxTu0_1QME5W.webp&quot; alt=&quot;洗杯子前后对比&quot;&gt;&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>What is a Socket?</title><link>https://theunknownth.ing/blog/socket</link><guid isPermaLink="true">https://theunknownth.ing/blog/socket</guid><description>Understanding Sockets in Go Networking and the Underlying OS Mechanics</description><pubDate>Sat, 06 Dec 2025 21:57:00 GMT</pubDate><content:encoded>&lt;p&gt;If you’ve spent any time learning network programming in Go, you’ve likely marveled at how simple the &lt;code&gt;net&lt;/code&gt; package is. With just three lines of code, you can create a performant TCP server. I mean&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;ln, _ := net.Listen(&quot;tcp&quot;, &quot;:8080&quot;)
for {
    conn, _ := ln.Accept()
    go handle(conn)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But this simplicity often hides the mechanics. You know &lt;em&gt;how&lt;/em&gt; to use &lt;code&gt;net.Dial&lt;/code&gt; or &lt;code&gt;net.Listen&lt;/code&gt;, but do you know what a &quot;socket&quot; actually is?&lt;/p&gt;
&lt;p&gt;Here is what I learned about file descriptors, the OS kernel, and why your listener socket is &quot;blind.&quot;&lt;/p&gt;
&lt;h2&gt;The Socket is Just a File&lt;/h2&gt;
&lt;p&gt;In Unix-like operating systems (Linux, macOS), there is a golden rule: &lt;strong&gt;Everything is a file.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;When you write this in Go:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;conn, _ := net.Dial(&quot;tcp&quot;, &quot;google.com:80&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You aren&apos;t magically holding a physical wire connected to Google. You are asking the Operating System to create a network endpoint. The OS sets up the heavy lifting in memory and hands you back a simple integer, known as a &lt;strong&gt;File Descriptor&lt;/strong&gt;. Yeah, just like anything else you read or write on your computer, a file on a disk, or stdin/stdout.&lt;/p&gt;
&lt;p&gt;Your &lt;code&gt;net.Conn&lt;/code&gt; object is essentially a wrapper around that number.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;When you call &lt;code&gt;conn.Write()&lt;/code&gt;, you are writing bytes to a file buffer.&lt;/li&gt;
&lt;li&gt;When you call &lt;code&gt;conn.Read()&lt;/code&gt;, you are reading bytes from a file buffer.&lt;/li&gt;
&lt;li&gt;The OS kernel takes care of actually pushing that data across the physical wires.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Why Listeners Create New Sockets?&lt;/h2&gt;
&lt;p&gt;When I first try to write my SOCKS5 server with Go, the below case is the most confusing part. Look at this standard Go pattern:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;ln, _ := net.Listen(&quot;tcp&quot;, &quot;:8080&quot;)

for {
    conn, _ := ln.Accept()
    go handle(conn)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So why does &lt;code&gt;ln.Accept()&lt;/code&gt; return a &lt;em&gt;new&lt;/em&gt; connection (&lt;code&gt;conn&lt;/code&gt;)? Why doesn&apos;t it just use the &lt;code&gt;ln&lt;/code&gt; object to talk to the client?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Answer: Concurrency.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Think of your server as a busy hotel. If the Receptionist had to personally escort every guest to their room and stay there to chat, the front desk would be empty. No new guests could check in.&lt;/p&gt;
&lt;p&gt;By design, the OS separates these roles. The Listener stays bound to Port 8080. When a Client arrives, the Listener performs the handshake, creates a &lt;em&gt;new&lt;/em&gt; file descriptor for that specific conversation, and immediately goes back to watching the door for the next guest.&lt;/p&gt;
&lt;p&gt;Same thing happens if you use C to create sockets. If your implement your server in C, you would go through slightly more steps: &lt;code&gt;socket()&lt;/code&gt;, &lt;code&gt;bind()&lt;/code&gt;, &lt;code&gt;listen()&lt;/code&gt;, and then &lt;code&gt;accept()&lt;/code&gt;. The &lt;code&gt;accept()&lt;/code&gt; call is what creates a new socket file descriptor for the specific client connection.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;int server_fd = socket(AF_INET, SOCK_STREAM, 0);
bind(server_fd, (struct sockaddr *)&amp;#x26;address, sizeof(address));
listen(server_fd, 3);
int new_socket = accept(server_fd, (struct sockaddr *)&amp;#x26;address, (socklen_t*)&amp;#x26;addrlen);
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;The Listener is Blind&lt;/h2&gt;
&lt;p&gt;A common misconception is that because the Listener creates the connection, it must &quot;see&quot; all the traffic. But is it true? e.g. in the above C code, can you read data from &lt;code&gt;server_fd&lt;/code&gt;? Is it possible to do something like this?&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;char buffer[1024] = {0};
read(server_fd, buffer, 1024); // Is this valid?
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you tried to read data from the listening socket, what would happen? &lt;strong&gt;It would fail.&lt;/strong&gt; The listening socket is essentially &lt;strong&gt;blind&lt;/strong&gt; to data payloads. It only understands one thing: &lt;strong&gt;The Handshake&lt;/strong&gt;  (SYN packets).&lt;/p&gt;
&lt;p&gt;When a packet of data arrives at your server&apos;s IP, the Operating System does the following:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Is this a new handshake?&lt;/strong&gt; Send it to the &lt;strong&gt;Listener&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Is this data for an active conversation?&lt;/strong&gt; Look up the specific &lt;strong&gt;Child Socket&lt;/strong&gt; (that &lt;code&gt;conn&lt;/code&gt; object you got earlier) and send the data there.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The Listener is just a forwarder for new connections. It doesn&apos;t handle data itself.&lt;/p&gt;
&lt;h2&gt;Why This Matters?&lt;/h2&gt;
&lt;p&gt;Understanding that &lt;code&gt;Accept()&lt;/code&gt; generates a new, independent file descriptor is exactly why Go is so good at networking.&lt;/p&gt;
&lt;p&gt;Because the new connection (&lt;code&gt;conn&lt;/code&gt;) is completely decoupled from the listener (&lt;code&gt;ln&lt;/code&gt;), we can immediately hand &lt;code&gt;conn&lt;/code&gt; over to a Goroutine.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;go handle(conn) // This runs in the background
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The main loop stays unblocked, the Listener stays at the front desk, and Go&apos;s runtime manages thousands of these &quot;Room Keys&quot; concurrently.&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>What Stops L1 Cache from Being Larger?</title><link>https://theunknownth.ing/blog/l1cache</link><guid isPermaLink="true">https://theunknownth.ing/blog/l1cache</guid><description>Have you ever wondered why L1 cache sizes are relatively small compared to L2 and L3 caches? Actually, it has stopped being larger for a long time.</description><pubDate>Thu, 27 Nov 2025 14:55:00 GMT</pubDate><content:encoded>&lt;p&gt;Let&apos;s take a look at a modern CPU, the AMD Ryzen 7950X: (all the below CPU-Z images are from &lt;a href=&quot;https://valid.x86.fr&quot;&gt;valid.x86.fr&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theunknownth.ing/_astro/955htt.Cc7QM509_2bDzj.webp&quot; alt=&quot;Ryzen 7950X&quot;&gt;&lt;/p&gt;
&lt;p&gt;And you might wonder: huh, this looks normal. L1 cache is 32 + 32KB per core, L2 is 1MB per core, and L3 is 64MB shared. This seems reasonable, with each level being larger than the previous one.&lt;/p&gt;
&lt;p&gt;But what if I show you an older CPU, the Intel Core 2 Duo E8400 from 2008?&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theunknownth.ing/_astro/7s2acr.CEbABV53_Cx75I.webp&quot; alt=&quot;Core 2 Duo E8400&quot;&gt;&lt;/p&gt;
&lt;p&gt;Surprisingly, it has the same L1 cache size: 32 + 32KB per core, L2 is 6MB shared, and there is no L3 cache. This means that for over 15 years, L1 cache sizes have not increased at all! Why is that?&lt;/p&gt;
&lt;p&gt;We all want larger caches to reduce memory latency and improve performance. AMD even come with their 3D V-Cache technology to stack more cache on top of existing cache dies. So, what stops L1 cache from being larger?&lt;/p&gt;
&lt;p&gt;This question emerged in my mind when I was learning CS:APP (Computer Systems: A Programmer&apos;s Perspective) about Virtual Memory System. As you can see here in the course slide, the modern CPU utilizes a &quot;cute trick&quot; for speeding up L1 cache access (credit: &lt;a href=&quot;http://csapp.cs.cmu.edu/&quot;&gt;CS:APP3e Slide&lt;/a&gt;):&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theunknownth.ing/_astro/csapp.CWxw_0ph_c4mDO.webp&quot; alt=&quot;L1 Cache Cute Trick&quot;&gt;&lt;/p&gt;
&lt;p&gt;TL;DR of the trick is that L1 cache is &quot;&lt;strong&gt;Virtually Indexed, Physically Tagged&lt;/strong&gt;&quot;. It allows the processor to start looking for data in the L1 Cache before it has even finished translating the address from Virtual to Physical.&lt;/p&gt;
&lt;p&gt;Normally, if CPU is built without this trick, accessing memory works in a strict sequence:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;The CPU translates the Virtual Address (VA) to a Physical Address (PA) using the TLB.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;The CPU uses the Physical Address to check the L1 Cache.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is safe, but slow because step 2 cannot start until step 1 is finished. However, if we utilize the VIPT trick, the CPU can do two things simultaneously. By the time the L1 Cache has found the potential data row using the Index, the Address Translation finishes, and the hardware compares this physical tag against the tag stored in the cache slot we just looked up. Really smart!&lt;/p&gt;
&lt;p&gt;But wait... For this trick to work, the bits required for the &lt;strong&gt;Cache Index (CI) plus the Cache Offset (CO) must fit inside the Page Offset&lt;/strong&gt;. If the cache were larger, the CI bits would spill over into the VPN. If that happened, we couldn&apos;t use the virtual bits to index the cache because that part of the address does change during translation.&lt;/p&gt;
&lt;p&gt;Note that the cache size is calculated as:&lt;/p&gt;
&lt;p&gt;$$
\text{Cache Size} = 2^{CI + CO} \times \text{Associativity}
$$&lt;/p&gt;
&lt;p&gt;where $2^{CI}$ denotes the number of cache sets, $2^{CO}$ denotes the block size, and Associativity is how many blocks are in each set. As Page Offset size is fixed by the system architecture (e.g., 12 bits for 4KB pages), this actually &lt;em&gt;limits&lt;/em&gt; how large the L1 cache can be.&lt;/p&gt;
&lt;p&gt;The standard way to cheat this limit is to increase Associativity. If we double the associativity, we can double the cache size without increasing CI. However, increasing associativity is not free. Remember that you want better performance (access speed) from L1 cache? Actually, the more associative a cache is, the longer it takes to look up data.&lt;/p&gt;
&lt;p&gt;To select one entry from a cache, we need:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$N$ comparators for an $N$-way set associative cache, running in parallel. So 16-way associative cache needs 2x comparator power, and 2x area compared to 8-way.&lt;/li&gt;
&lt;li&gt;A multiplexer to select the right data output from the $N$ entries. But note: This mux or its control logic is &lt;strong&gt;often on the critical path&lt;/strong&gt; for L1 hit latency. This mux consumes area and power, and more importantly, &lt;strong&gt;increases the access time&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Hardware is physical. You need to consider about fan-out. The input address tag must be fanned-out to 16 locations instead of 8.&lt;/li&gt;
&lt;li&gt;You also need to deal with replacement policy! You need to track usage for 16 blocks instead of 8 to determine which one to evict. 16-way caches almost never use true LRU. They use approximations (Pseudo-LRU) or random replacement often.&lt;/li&gt;
&lt;li&gt;Once you have to physically stretch the cache across more of the core, the wire delays and clock tree load start dominating. This is one big reason designers would rather keep L1 small and very close to the pipelines.&lt;/li&gt;
&lt;li&gt;...&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;All these overheads add up. So back to our question: why L1 cache has not increased in size for 15 years? The answer is clear now: CPU designers have to make a trade-off between cache size and access speed. A 32 KB L1 is full of parallel tag comparators and big mux trees and hit on nearly every cycle, so it’s a power hotspot. Still, it is a very reasonable sweet spot for many modern CPUs, and its size has stayed flat mostly because other things scaled instead (L2/L3, prefetching, OoO machinery, etc.). Modern CPUs leaned into this idea:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Keep L1 tiny but extremely fast.&lt;/li&gt;
&lt;li&gt;Grow L2/L3 aggressively for capacity and hit-rate.&lt;/li&gt;
&lt;li&gt;Use prefetchers, better branch prediction, bigger OoO windows, etc. to hide latency.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So instead of &quot;make L1 bigger&quot;, architects made the rest of the machine smarter. They bring things into L1 just in time with prefetch.&lt;/p&gt;
&lt;p&gt;But wait, before you close this article, let me show you one more thing. I only show you part of the story. Let&apos;s look at another CPUs:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theunknownth.ing/_astro/69c8c6.BdurLi2k_1jicc2.webp&quot; alt=&quot;Intel Ultra 9 285K&quot;&gt;&lt;/p&gt;
&lt;p&gt;It got 48KB L1 Data Cache and 64KB L1 Instruction Cache per performance core!&lt;/p&gt;
&lt;p&gt;The Apple M5 CPU (I do not have the CPU-Z picture), is even more interesting: it has &lt;strong&gt;192KB L1 Instruction Cache&lt;/strong&gt; 🤯 and &lt;strong&gt;128KB L1 Data Cache&lt;/strong&gt; 🤯 per performance core! How? Well... let&apos;s break them down.&lt;/p&gt;
&lt;p&gt;Intel decided 32KB wasn&apos;t enough for their Data Cache. But remember the VIPT Limit (Page Size = 4KB)? To get 48KB without breaking VIPT, Intel had to pay the &quot;associativity tax&quot; we discussed earlier. They made the L1 Data Cache 12-way set associative (instead of the common 8-way).&lt;/p&gt;
&lt;p&gt;But what about Apple? Apple&apos;s M-series chips have L1 caches that are &lt;strong&gt;3x–6x larger than Intel or AMD&lt;/strong&gt;. How???? Dude, there are no magic here.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Apple runs its CPUs at lower frequencies (~4.0 GHz) compared to AMD/Intel (~5.7 GHz and they are going even further!). Lower frequency makes it easier to access a large cache in 3 cycles.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Apple uses ARM, which has a fixed instruction length (mostly). This makes indexing and decoding slightly more predictable than x86&apos;s variable-length chaos, allowing them to optimize large cache access differently.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Most importantly: While x86 is stuck with standard 4KB memory pages, Apple Silicon is optimized for &lt;strong&gt;16KB pages&lt;/strong&gt;. By using a 16KB page size, the &apos;Page Offset&apos; becomes larger, effectively quadrupling the VIPT limit. This allows Apple to build massive L1 caches without needing complex hardware tricks or excessive associativity.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So what about AMD then? ~~Would increasing L1 cache size to 48KB make Ryzen CPUs even more better than Intel?~~ Well, it turns out that in their latest chip, the Ryzen 9 9950X, they increased the L1 Dcache to 48KB.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theunknownth.ing/_astro/x5e28p.d7vVVWVn_aVEl4.webp&quot; alt=&quot;AMD Ryzen 9 9950X&quot;&gt;&lt;/p&gt;
&lt;p&gt;Is that the complete story? Not really. Remember we said &quot;Apple uses ARM, which has a fixed instruction length (mostly). This makes indexing and decoding slightly more predictable than x86&apos;s variable-length chaos&quot;? So what about x86? Modern x86 instructions are complex and &quot;ugly.&quot; Before the CPU can execute them, it must decode them into simpler internal commands called &quot;micro-ops.&quot; It turns out that the Micro-op cache is a critical component in modern x86 CPUs.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Both Zen 4 and Zen 5 architectures feature an Op Cache, but Zen 5 has upgraded the design by utilizing two 6-wide Op Caches, as opposed to Zen 4’s single 9-wide Op Cache. The Op Cache is crucial because it stores pre-decoded micro-operations (uOps). When instructions are fetched repeatedly (such as in loops), the CPU can pull these uOps directly from the Op Cache instead of decoding the instructions again, which saves time and power.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The above text are from &lt;a href=&quot;https://medium.com/@jason890418123/exploring-zen-5-and-zen-4-microarchitectures-dive-into-op-cache-branch-prediction-and-more-f9da2469fb5e&quot;&gt;here&lt;/a&gt;. Because this exists, the Icache doesn&apos;t need to be huge; it just serves as a backup for the Op-Cache.&lt;/p&gt;
&lt;p&gt;Hope you enjoyed this article! If you have any questions or something to correct, feel free to comment below.&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>A Simple Hack to Use Touch ID for sudo on macOS</title><link>https://theunknownth.ing/blog/touchid-sudo</link><guid isPermaLink="true">https://theunknownth.ing/blog/touchid-sudo</guid><description>Tired of typing your sudo password on macOS? Learn how to enable Touch ID for sudo commands in just a few simple steps.</description><pubDate>Sat, 06 Sep 2025 14:24:00 GMT</pubDate><content:encoded>&lt;p&gt;If you spend any amount of time in the macOS Terminal, you know the drill. You type a command with &lt;code&gt;sudo&lt;/code&gt;, press Enter, you type your long, secure password for the tenth time today, and you think, &quot;There has to be a better way.&quot;&lt;/p&gt;
&lt;p&gt;There is. And it&apos;s right at your fingertips.
It&apos;s a simple, reversible, and game-changing tweak that you&apos;ll appreciate every single day.&lt;/p&gt;
&lt;h2&gt;How It Works (The Quick Version)&lt;/h2&gt;
&lt;p&gt;macOS uses a flexible system called PAM (Pluggable Authentication Modules) to handle authentication. All we&apos;re going to do is edit the configuration file for &lt;code&gt;sudo&lt;/code&gt; to tell it: &quot;Hey, before you ask for a password, just check for a valid fingerprint from Touch ID first. If that works, we&apos;re good to go.&quot;&lt;/p&gt;
&lt;h2&gt;The 2-Minute Setup Guide&lt;/h2&gt;
&lt;h3&gt;Open the Terminal&lt;/h3&gt;
&lt;p&gt;You can find it in &lt;code&gt;Applications/Utilities&lt;/code&gt; or just search for it with Spotlight (&lt;code&gt;⌘ + Space&lt;/code&gt;).&lt;/p&gt;
&lt;h3&gt;Open the PAM Configuration File&lt;/h3&gt;
&lt;p&gt;We need to edit a protected system file, so we&apos;ll use the simple command-line editor &lt;code&gt;nano&lt;/code&gt; with &lt;code&gt;sudo&lt;/code&gt; privileges. Copy and paste the following command and press Enter. It will ask for your password (likely for the last time!).&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo nano /etc/pam.d/sudo
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;nano&lt;/code&gt; editor will open inside your Terminal window. You&apos;ll see a few lines of configuration text.&lt;/p&gt;
&lt;p&gt;The most important part is getting this next step right. On a &lt;strong&gt;new line right after the first commented line&lt;/strong&gt; (the one starting with &lt;code&gt;#&lt;/code&gt;), add the following:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;auth       sufficient     pam_tid.so
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Make sure it is the &lt;strong&gt;very first active rule&lt;/strong&gt;. For my system, the file looks like this after the edit:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# sudo: auth account password session

auth       sufficient     pam_tid.so   # &amp;#x3C;-- This is the line we added
auth       include        sudo_local
auth       sufficient     pam_smartcard.so
auth       required       pam_opendirectory.so
account    required       pam_permit.so
password   required       pam_deny.so
session    required       pam_permit.so
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The keyword &lt;code&gt;sufficient&lt;/code&gt; is what makes this work. It tells the system that if Touch ID authentication succeeds, it&apos;s enough to grant permission, and no other authentication methods (like your password) are needed.&lt;/p&gt;
&lt;h3&gt;Save and Exit&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Press &lt;code&gt;Control + O&lt;/code&gt; to Write Out (save) the file.&lt;/li&gt;
&lt;li&gt;Press &lt;code&gt;Enter&lt;/code&gt; to confirm the filename.&lt;/li&gt;
&lt;li&gt;Press &lt;code&gt;Control + X&lt;/code&gt; to exit &lt;code&gt;nano&lt;/code&gt; and return to your prompt.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Time to Test It!&lt;/h2&gt;
&lt;p&gt;For the change to take effect, you &lt;strong&gt;must open a new Terminal window or tab&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;In your new Terminal session, type a simple &lt;code&gt;sudo&lt;/code&gt; command, like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ls
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Instead of a password prompt, you should be greeted by a Touch ID verification pop-up. Place your finger on the sensor, and your command will run. Welcome to the good life.&lt;/p&gt;
&lt;h2&gt;Good to Know&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;How do I undo this?&lt;/strong&gt; Simply edit the &lt;code&gt;/etc/pam.d/sudo&lt;/code&gt; file again and delete the &lt;code&gt;auth sufficient pam_tid.so&lt;/code&gt; line you added.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What if it still asks for my password?&lt;/strong&gt; You likely put the new line in the wrong place. Go back to Step 3 and make absolutely sure it&apos;s the first non-commented line in the file.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What about macOS updates?&lt;/strong&gt; Major system updates can sometimes overwrite this file, reverting it to the default. If Touch ID suddenly stops working for &lt;code&gt;sudo&lt;/code&gt; after an update, just repeat these steps.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That’s it! Enjoy the precious seconds you’ve reclaimed. Happy coding! 👍&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>Overview of the RISC-V Design with Tomasulo&apos;s Algorithm</title><link>https://theunknownth.ing/blog/tomasulo-cpu</link><guid isPermaLink="true">https://theunknownth.ing/blog/tomasulo-cpu</guid><description>An introduction to the RISC-V ISA, Verilog, and Tomasulo’s algorithm.</description><pubDate>Sun, 24 Aug 2025 18:09:00 GMT</pubDate><content:encoded>&lt;h2&gt;Disclaimer&lt;/h2&gt;
&lt;p&gt;The content of this blog post has a large portion of &lt;strong&gt;AI-generated text&lt;/strong&gt; (Google Gemini 2.5 Pro Deep Research). Although I have reviewed, edited the text, and did fact check, I cannot guarantee that it is 100% accurate or free of errors. Please use this content as a starting point for your own research and understanding, and verify any critical information independently.&lt;/p&gt;
&lt;p&gt;With that said, I believe this post is &lt;strong&gt;super well-written and informative&lt;/strong&gt;, and what really fascinates me is the &quot;problem-solving&quot; learning curve, which highlights the flaws and problems in every design choice, and segues into the components that solves the problem.&lt;/p&gt;
&lt;h2&gt;Part I: The Language of Hardware - Verilog Fundamentals&lt;/h2&gt;
&lt;p&gt;The study of processor design requires a fundamental shift in perspective. The tools and languages used to design hardware, such as Verilog, represent a different paradigm of computation. Understanding this paradigm is the first and most crucial step toward grasping the intricate workings of a modern CPU.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;Section 1.1: Thinking in Parallel&lt;/h3&gt;
&lt;p&gt;The most significant conceptual leap from software to hardware is the transition from sequential to concurrent execution. A typical software program, written in a language like C++, is a sequence of instructions executed one after another by a processor. The program&apos;s state changes in a predictable, linear progression. In contrast, a physical hardware circuit is a collection of components—gates, flip-flops, memory blocks—that, once powered on, operate continuously and in parallel. A thousand logic gates do not wait their turn; they all compute their output based on their current inputs simultaneously, every moment in time.&lt;/p&gt;
&lt;p&gt;It is for this reason that Verilog is classified as a &lt;strong&gt;Hardware Description Language (HDL)&lt;/strong&gt;, not a programming language in the traditional sense. Its primary purpose is not to provide a list of commands for a processor to execute, but to &lt;strong&gt;describe&lt;/strong&gt; the physical structure and behavior of a digital electronic circuit. This description serves two main purposes: it can be fed into a simulation tool to model how the described circuit will behave over time, or it can be used by a synthesis tool to generate a netlist, which is a detailed blueprint for manufacturing an Application-Specific Integrated Circuit (ASIC) or configuring a Field-Programmable Gate Array (FPGA).&lt;/p&gt;
&lt;p&gt;The fundamental unit of design in Verilog is the &lt;strong&gt;module&lt;/strong&gt;. A module is a self-contained block of hardware logic, analogous to a class in C++ or a physical integrated circuit (IC) chip. It encapsulates internal logic and defines a clear interface to the outside world through a set of ports, which are declared as &lt;strong&gt;input&lt;/strong&gt;, &lt;strong&gt;output&lt;/strong&gt;, or &lt;strong&gt;inout&lt;/strong&gt;. This modularity is essential for hierarchical design, allowing complex systems like an entire CPU to be built by connecting smaller, well-defined modules such as an Arithmetic Logic Unit (ALU), a register file, and a control unit.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;Section 1.2: Describing Behavior - &lt;code&gt;initial&lt;/code&gt; and &lt;code&gt;always&lt;/code&gt; Blocks&lt;/h3&gt;
&lt;p&gt;Within a Verilog module, the behavior of the circuit is described primarily within two types of procedural blocks: &lt;code&gt;initial&lt;/code&gt; and &lt;code&gt;always&lt;/code&gt;. These blocks contain statements that define how the outputs and internal state of the module should change in response to inputs and time.&lt;/p&gt;
&lt;h4&gt;The &lt;code&gt;initial&lt;/code&gt; Block&lt;/h4&gt;
&lt;p&gt;The &lt;code&gt;initial&lt;/code&gt; block is the simpler of the two. As its name suggests, it contains a block of code that begins execution only once, at the very start of a simulation, at time zero. If multiple &lt;code&gt;initial&lt;/code&gt; blocks are defined within a module, they all start concurrently at time zero.&lt;/p&gt;
&lt;p&gt;This &quot;run-once&quot; behavior has a critical implication: &lt;code&gt;initial&lt;/code&gt; blocks are generally &lt;strong&gt;not synthesizable&lt;/strong&gt;. Real hardware does not have a concept of a &quot;beginning of time&quot; in the same way a simulation does; once powered on, it operates continuously. Therefore, an &lt;code&gt;initial&lt;/code&gt; block cannot be translated into a physical circuit that performs an action only at power-on. Its primary role is within the realm of simulation, specifically in the construction of a &lt;strong&gt;testbench&lt;/strong&gt;. A testbench is a separate Verilog module written to test the design (often called the &quot;Design Under Test&quot; or DUT). Within a testbench, &lt;code&gt;initial&lt;/code&gt; blocks are indispensable for generating clock signals, providing a sequence of input stimuli to the DUT, and setting up initial memory states to verify the design&apos;s correctness.&lt;/p&gt;
&lt;h4&gt;The &lt;code&gt;always&lt;/code&gt; Block&lt;/h4&gt;
&lt;p&gt;The &lt;code&gt;always&lt;/code&gt; block is the cornerstone of synthesizable Verilog code. It contains a block of statements that execute repeatedly throughout the simulation. The execution of an &lt;code&gt;always&lt;/code&gt; block is triggered by events specified in its &lt;strong&gt;sensitivity list&lt;/strong&gt;, denoted by &lt;code&gt;@(...)&lt;/code&gt;. This behavior directly models the nature of real hardware, which continuously reacts to changes in its input signals or to clock edges.&lt;/p&gt;
&lt;p&gt;The sensitivity list dictates what kind of hardware the &lt;code&gt;always&lt;/code&gt; block describes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;always @(posedge clk)&lt;/code&gt;: This syntax specifies that the block should execute only on the positive (rising) edge of the signal named &lt;code&gt;clk&lt;/code&gt;. This is the standard way to describe &lt;strong&gt;sequential logic&lt;/strong&gt;, such as flip-flops and registers, which are memory elements that capture and store a value at a specific moment defined by a clock signal.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;always @(*)&lt;/code&gt;: The asterisk is a shorthand that tells the simulator to execute the block whenever &lt;strong&gt;any&lt;/strong&gt; of the signals read on the right-hand side of assignments within the block changes its value. This describes &lt;strong&gt;combinational logic&lt;/strong&gt;—circuits like adders, multiplexers, or decoders whose outputs depend solely on their current inputs, with no memory of past states.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Because these constructs map directly to physical hardware components (clocked registers and logic gates), &lt;code&gt;always&lt;/code&gt; blocks are the primary tool for describing the synthesizable behavior of a digital design.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;Section 1.3: The Heart of Synthesis - Blocking vs. Non-blocking Assignments&lt;/h3&gt;
&lt;p&gt;Perhaps the most frequent and critical point of confusion for those transitioning from software to Verilog is the distinction between the two types of assignment operators: &lt;strong&gt;blocking (&lt;code&gt;=&lt;/code&gt;)&lt;/strong&gt; and &lt;strong&gt;non-blocking (&lt;code&gt;&amp;#x3C;=&lt;/code&gt;)&lt;/strong&gt;. This is not a matter of stylistic preference; the choice of operator is a direct instruction to the synthesis tool about the type of hardware circuit to create. Misunderstanding this distinction is the leading cause of simulation-synthesis mismatches, where a design works perfectly in simulation but fails when implemented in actual hardware.&lt;/p&gt;
&lt;h4&gt;Blocking Assignments (&lt;code&gt;=&lt;/code&gt;)&lt;/h4&gt;
&lt;p&gt;A blocking assignment is executed in the order it appears within a procedural block, much like in a C program. The execution of the current statement &quot;blocks&quot; the execution of any subsequent statements in the same &lt;code&gt;begin...end&lt;/code&gt; block until it is complete. The variable on the left-hand side is updated immediately, and this new value is used by all subsequent statements in the block.&lt;/p&gt;
&lt;p&gt;This immediate-update behavior models a chain of &lt;strong&gt;combinational logic&lt;/strong&gt;. Imagine a series of logic gates connected by wires. The output of the first gate is instantaneously available as the input to the second gate. Blocking assignments are therefore the correct choice for describing this type of logic, typically within an &lt;code&gt;always @(*)&lt;/code&gt; block.&lt;/p&gt;
&lt;h4&gt;Non-blocking Assignments (&lt;code&gt;&amp;#x3C;=&lt;/code&gt;)&lt;/h4&gt;
&lt;p&gt;A non-blocking assignment operates in a two-phase manner that is fundamentally different from any software assignment. Within a block triggered by an event (like a clock edge), all the right-hand side (RHS) expressions of the non-blocking assignments are evaluated and stored in temporary variables &lt;strong&gt;first&lt;/strong&gt;. Only after all RHS expressions have been evaluated does the second phase begin, where the left-hand side (LHS) variables are all updated &lt;strong&gt;simultaneously&lt;/strong&gt; with their corresponding temporary values. The execution of one non-blocking assignment does not block the evaluation of the next.&lt;/p&gt;
&lt;p&gt;This two-phase mechanism perfectly models the behavior of a bank of &lt;strong&gt;sequential logic&lt;/strong&gt; elements, such as D-type flip-flops, that share a common clock. On a clock edge, all the flip-flops simultaneously sample the data at their D inputs. A short time later (the clock-to-Q delay), all their Q outputs change to reflect the newly captured values. The value of one flip-flop&apos;s output at the beginning of the clock cycle determines the input of the next flip-flop, but the update doesn&apos;t happen until the end of the cycle. Non-blocking assignments are therefore the correct and safe way to model state changes in sequential logic, and they should be used exclusively for assignments within a clocked &lt;code&gt;always @(posedge clk)&lt;/code&gt; block.&lt;/p&gt;
&lt;h4&gt;Example: The Shift Register&lt;/h4&gt;
&lt;p&gt;The difference becomes crystal clear with a simple 3-bit shift register example. The goal is to have a value at &lt;code&gt;data_in&lt;/code&gt; shift one position to the right on each clock cycle: &lt;code&gt;data_in -&gt; q1 -&gt; q2 -&gt; q3&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Incorrect Version (using Blocking &lt;code&gt;=&lt;/code&gt;)&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-verilog&quot;&gt;always @(posedge clk) begin
  q1 = data_in;
  q2 = q1;
  q3 = q2;
end
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In simulation, on a single rising clock edge, &lt;code&gt;q1&lt;/code&gt; is immediately updated with &lt;code&gt;data_in&lt;/code&gt;. Because this is a blocking assignment, that new value of &lt;code&gt;q1&lt;/code&gt; is then immediately used to update &lt;code&gt;q2&lt;/code&gt;. And that new value of &lt;code&gt;q2&lt;/code&gt; is immediately used to update &lt;code&gt;q3&lt;/code&gt;. The result is that the value from &lt;code&gt;data_in&lt;/code&gt; propagates all the way to &lt;code&gt;q3&lt;/code&gt; within a single clock cycle. The synthesis tool will interpret this as a direct wire from &lt;code&gt;data_in&lt;/code&gt; to &lt;code&gt;q3&lt;/code&gt;, not a series of registers. This is not a shift register.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Correct Version (using Non-blocking &lt;code&gt;&amp;#x3C;=&lt;/code&gt;)&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-verilog&quot;&gt;always @(posedge clk) begin
  q1 &amp;#x3C;= data_in;
  q2 &amp;#x3C;= q1;
  q3 &amp;#x3C;= q2;
end
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On a rising clock edge, the simulator evaluates all RHS expressions first: &lt;code&gt;data_in&lt;/code&gt;, the &lt;strong&gt;current&lt;/strong&gt; value of &lt;code&gt;q1&lt;/code&gt;, and the &lt;strong&gt;current&lt;/strong&gt; value of &lt;code&gt;q2&lt;/code&gt;. Then, at the end of the simulation time step, it updates the LHS variables simultaneously. &lt;code&gt;q1&lt;/code&gt; gets the value of &lt;code&gt;data_in&lt;/code&gt;, &lt;code&gt;q2&lt;/code&gt; gets the &lt;strong&gt;old&lt;/strong&gt; value of &lt;code&gt;q1&lt;/code&gt;, and &lt;code&gt;q3&lt;/code&gt; gets the &lt;strong&gt;old&lt;/strong&gt; value of &lt;code&gt;q2&lt;/code&gt;. This correctly models three separate flip-flops, and it takes three clock cycles for a value to propagate from &lt;code&gt;data_in&lt;/code&gt; to &lt;code&gt;q3&lt;/code&gt;. This is a true shift register.&lt;/p&gt;
&lt;h4&gt;Pitfalls and Best Practices&lt;/h4&gt;
&lt;p&gt;Mixing blocking and non-blocking assignments in the same &lt;code&gt;always&lt;/code&gt; block, or using the wrong type for the logic intended, can lead to indeterminate behavior known as a &lt;strong&gt;race condition&lt;/strong&gt;. This occurs when the final state of a variable depends on the unpredictable order in which a simulator evaluates concurrent events. To avoid these issues and ensure a design that is both simulatable and synthesizable, designers adhere to strict rules of thumb:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;When modeling sequential logic (clocked &lt;code&gt;always&lt;/code&gt; blocks), use &lt;strong&gt;non-blocking&lt;/strong&gt; assignments (&lt;code&gt;&amp;#x3C;=&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;When modeling combinational logic (&lt;code&gt;always @(*)&lt;/code&gt; blocks), use &lt;strong&gt;blocking&lt;/strong&gt; assignments (&lt;code&gt;=&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Do not mix blocking and non-blocking assignments in the same &lt;code&gt;always&lt;/code&gt; block.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The underlying reason for these rules is to bridge the gap between the discrete event-scheduling model of a simulator and the continuous, physical reality of hardware. A non-blocking assignment is a directive to the simulator to &lt;strong&gt;schedule&lt;/strong&gt; an update for the end of the current time step, which is how a synthesis tool understands the need for a memory element (a flip-flop) that holds a value across clock cycles. A blocking assignment directs the simulator to update a value &lt;strong&gt;immediately&lt;/strong&gt;, which is how a synthesis tool understands a direct connection of logic gates whose output changes as soon as the input changes. Using the wrong operator creates a mismatch between what is simulated and what is built, which is the root cause of many hardware design bugs.&lt;/p&gt;
&lt;p&gt;| Feature                    | Blocking Assignment (&lt;code&gt;=&lt;/code&gt;)                                             | Non-blocking Assignment (&lt;code&gt;&amp;#x3C;=&lt;/code&gt;)                                      |
| :------------------------- | :-------------------------------------------------------------------- | :------------------------------------------------------------------ |
| &lt;strong&gt;Operator&lt;/strong&gt;               | &lt;code&gt;=&lt;/code&gt;                                                                   | &lt;code&gt;&amp;#x3C;=&lt;/code&gt;                                                                |
| &lt;strong&gt;Execution Model&lt;/strong&gt;        | Sequential, in-order execution within a block. Updates are immediate. | Parallel evaluation of RHS, followed by simultaneous update of LHS. |
| &lt;strong&gt;Hardware Inference&lt;/strong&gt;     | Combinational logic (wires, gates).                                   | Sequential logic (flip-flops, registers).                           |
| &lt;strong&gt;Typical &lt;code&gt;always&lt;/code&gt; Block&lt;/strong&gt; | &lt;code&gt;always @(*)&lt;/code&gt;                                                         | &lt;code&gt;always @(posedge clk)&lt;/code&gt;                                             |
| &lt;strong&gt;Use Case Example&lt;/strong&gt;       | &lt;code&gt;assign y = sel ? b : a;&lt;/code&gt;                                             | &lt;code&gt;always @(posedge clk) begin q &amp;#x3C;= d; end&lt;/code&gt;                           |&lt;/p&gt;
&lt;h2&gt;Part II: The Blueprint of a CPU - The RISC-V ISA&lt;/h2&gt;
&lt;p&gt;Having established the language for describing hardware, the next step is to understand the vocabulary that a processor speaks. This vocabulary is its Instruction Set Architecture (ISA), the fundamental interface between software and hardware. For this exploration, the RISC-V ISA provides an ideal foundation due to its modern, clean, and extensible design.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;Section 2.1: An Introduction to Instruction Set Architectures (ISA)&lt;/h3&gt;
&lt;p&gt;An ISA is the abstract model of a computer that is visible to a machine-language programmer or compiler. It is the definitive contract between the software that runs on a processor and the hardware that executes it. This contract specifies a set of critical elements, including:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The set of available instructions (the &quot;opcodes&quot;).&lt;/li&gt;
&lt;li&gt;The native data types.&lt;/li&gt;
&lt;li&gt;The programmer-visible registers.&lt;/li&gt;
&lt;li&gt;The memory addressing modes.&lt;/li&gt;
&lt;li&gt;The handling of events like interrupts and exceptions.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Any processor that correctly implements a given ISA will execute the same machine code and produce the same results, regardless of its internal microarchitectural design. An Intel Core i9 and an AMD Ryzen processor, for example, have vastly different internal designs but can both run Windows because they both implement the x86-64 ISA.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;Section 2.2: The RISC-V Revolution - Openness and Modularity&lt;/h3&gt;
&lt;p&gt;RISC-V (pronounced &quot;risk-five&quot;) is not just another ISA; it represents a paradigm shift in how ISAs are developed and used. It was born at the University of California, Berkeley, in 2010 with the goal of creating a practical, high-quality ISA that was open, free, and suitable for a wide range of computing applications, from academic research to industrial deployment.&lt;/p&gt;
&lt;h4&gt;The RISC Philosophy&lt;/h4&gt;
&lt;p&gt;At its core, RISC-V is a pure embodiment of the &lt;strong&gt;Reduced Instruction Set Computer (RISC)&lt;/strong&gt; philosophy. This design approach contrasts with the Complex Instruction Set Computer (CISC) paradigm of architectures like x86. The core tenets of RISC, and by extension RISC-V, are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A small number of simple instructions:&lt;/strong&gt; The instruction set is kept minimal, focusing on fundamental operations. More complex operations are built by combining these simple instructions.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fixed-length instruction encoding:&lt;/strong&gt; All base instructions are the same length (32 bits), which dramatically simplifies the hardware required for instruction fetching and decoding.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Load/Store architecture:&lt;/strong&gt; The only instructions that access memory are explicit &lt;strong&gt;load&lt;/strong&gt; and &lt;strong&gt;store&lt;/strong&gt; operations. All arithmetic and logical operations are performed on operands held in processor registers. This simplifies the control logic and encourages efficient register usage by compilers.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;One instruction per cycle:&lt;/strong&gt; The simplicity of the instructions is designed to allow for execution in a single clock cycle in a basic pipeline, which is key to achieving high performance.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This adherence to simplicity results in a more streamlined processor design, leading to improved performance, lower power consumption, and reduced design complexity.&lt;/p&gt;
&lt;h4&gt;Open and Free&lt;/h4&gt;
&lt;p&gt;Unlike proprietary ISAs such as x86 and ARM, the RISC-V specification is developed and maintained by the non-profit RISC-V International and is available under open-source licenses. This means anyone can design, manufacture, and sell RISC-V chips and software without paying royalties. This openness has catalyzed a global wave of innovation, enabling startups, academic institutions, and even large corporations to develop custom processors tailored for specific applications without the barrier of licensing fees or vendor lock-in.&lt;/p&gt;
&lt;h4&gt;Modular Design&lt;/h4&gt;
&lt;p&gt;A defining feature of RISC-V is its inherent modularity. The ISA is not a monolithic entity but is structured as a small, mandatory &lt;strong&gt;base integer ISA&lt;/strong&gt; with a rich set of optional &lt;strong&gt;standard extensions&lt;/strong&gt;. A processor&apos;s full ISA is specified by its base and the extensions it implements. For instance, a common configuration for a 64-bit general-purpose processor is denoted &lt;strong&gt;RV64GC&lt;/strong&gt;, which stands for &lt;strong&gt;RV64IMAFDC&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The base integer ISAs are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;RV32I&lt;/strong&gt;: The base 32-bit integer instruction set with 32 integer registers (x0-x31).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;RV64I&lt;/strong&gt;: The base 64-bit integer instruction set, extending the registers and operations to 64 bits.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;RV32E&lt;/strong&gt;: An embedded variant of &lt;code&gt;RV32I&lt;/code&gt; with only 16 integer registers, designed for the smallest microcontrollers.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The most common standard extensions, often grouped under the letter &apos;G&apos; for &quot;General-Purpose,&quot; are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;M&lt;/strong&gt;: Standard Extension for Integer Multiplication and Division. Adds instructions like &lt;code&gt;mul&lt;/code&gt;, &lt;code&gt;div&lt;/code&gt;, and &lt;code&gt;rem&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A&lt;/strong&gt;: Standard Extension for Atomic Instructions. Provides instructions for atomic memory operations (e.g., &lt;code&gt;amoswap&lt;/code&gt;), essential for synchronization in multi-core systems.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;F&lt;/strong&gt;: Standard Extension for Single-Precision Floating-Point. Adds a separate floating-point register file (f0-f31) and instructions for 32-bit floating-point arithmetic.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;D&lt;/strong&gt;: Standard Extension for Double-Precision Floating-Point. Extends the &lt;code&gt;F&lt;/code&gt; extension with support for 64-bit floating-point operations.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;C&lt;/strong&gt;: Standard Extension for Compressed Instructions. Defines 16-bit versions of the most common 32-bit instructions. This can significantly reduce code size and improve instruction fetch bandwidth, which is critical in memory-constrained embedded systems and for performance in high-end cores.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This modularity allows designers to create highly optimized processors. A tiny microcontroller for an IoT sensor might only implement &lt;code&gt;RV32EMC&lt;/code&gt;, while a high-performance application processor in a data center might implement &lt;code&gt;RV64G&lt;/code&gt; plus extensions for vector processing (V) and bit manipulation (B).&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;Section 2.3: Anatomy of a RISC-V Instruction&lt;/h3&gt;
&lt;p&gt;All base RISC-V instructions are 32 bits long and fall into one of a few well-defined formats. The regularity of these formats is a key design feature that enables the simple, high-performance pipelines for which RISC architectures are known. The primary formats are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;R-type (Register):&lt;/strong&gt; Used for register-to-register operations like &lt;code&gt;add&lt;/code&gt;, &lt;code&gt;sub&lt;/code&gt;, &lt;code&gt;and&lt;/code&gt;, &lt;code&gt;or&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;| funct7 (7) | rs2 (5) | rs1 (5) | funct3 (3) | rd (5) | opcode (7) |
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;opcode&lt;/code&gt;: Defines the instruction type (e.g., &lt;code&gt;OP&lt;/code&gt; for register-register arithmetic).&lt;/li&gt;
&lt;li&gt;&lt;code&gt;rd&lt;/code&gt;: The destination register.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;funct3&lt;/code&gt;: Further specifies the operation (e.g., &lt;code&gt;ADD&lt;/code&gt;/&lt;code&gt;SUB&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;code&gt;rs1&lt;/code&gt;, &lt;code&gt;rs2&lt;/code&gt;: The two source registers.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;funct7&lt;/code&gt;: An additional field to differentiate operations (e.g., &lt;code&gt;ADD&lt;/code&gt; from &lt;code&gt;SUB&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;I-type (Immediate):&lt;/strong&gt; Used for operations with an immediate value, including &lt;code&gt;addi&lt;/code&gt;, and for load instructions like &lt;code&gt;lw&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;| imm[11:0] (12) | rs1 (5) | funct3 (3) | rd (5) | opcode (7) |
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;imm[11:0]&lt;/code&gt;: A 12-bit signed immediate value.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;rs1&lt;/code&gt;: The source register.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;rd&lt;/code&gt;: The destination register.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;S-type (Store):&lt;/strong&gt; Used for store instructions like &lt;code&gt;sw&lt;/code&gt; (store word).&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;| imm[11:5] (7) | rs2 (5) | rs1 (5) | funct3 (3) | imm[4:0] (5) | opcode (7) |
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;The 12-bit immediate is split to accommodate the two source register fields.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;rs1&lt;/code&gt;: The base address register.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;rs2&lt;/code&gt;: The register containing the data to be stored.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;B-type (Branch):&lt;/strong&gt; Used for conditional branch instructions like &lt;code&gt;beq&lt;/code&gt; (branch if equal). Similar to S-type, the immediate is split.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;| imm[12|10:5] (7) | rs2 (5) | rs1 (5) | funct3 (3) | imm[4:1|11] (5) | opcode (7) |
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;rs1&lt;/code&gt;, &lt;code&gt;rs2&lt;/code&gt;: The registers to be compared.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;imm&lt;/code&gt;: The signed branch offset, which is multiplied by 2 and added to the PC.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;U-type (Upper Immediate):&lt;/strong&gt; Used for loading a 20-bit upper immediate value, as in &lt;code&gt;lui&lt;/code&gt; (load upper immediate).&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;| imm[31:12] (20) | rd (5) | opcode (7) |
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;J-type (Jump):&lt;/strong&gt; Used for unconditional jumps like &lt;code&gt;jal&lt;/code&gt; (jump and link).&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;| imm[20|10:1|11|19:12] (20) | rd (5) | opcode (7) |
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The deliberate and consistent placement of the &lt;code&gt;opcode&lt;/code&gt;, &lt;code&gt;rs1&lt;/code&gt;, &lt;code&gt;rs2&lt;/code&gt;, and &lt;code&gt;rd&lt;/code&gt; fields across these formats is not an accident. It is a cornerstone of efficient RISC design. In a pipelined processor, the Instruction Decode (ID) stage must identify the source registers and read their values from the register file. Because &lt;code&gt;rs1&lt;/code&gt; and &lt;code&gt;rs2&lt;/code&gt; are always in the same bit positions for all instruction formats that use them (R, I, S, B), the decoder hardware is greatly simplified. It can begin reading from the register file before it has even finished fully decoding the instruction to determine the exact operation. This parallelism within the ID stage is a crucial enabler of the classic 5-stage RISC pipeline, a concept that forms the foundation of modern processor execution.&lt;/p&gt;
&lt;h2&gt;Part III: The Assembly Line - Pipelined Execution and Its Perils&lt;/h2&gt;
&lt;p&gt;To achieve high performance, modern processors do not execute instructions one at a time, waiting for each to complete before starting the next. Instead, they use a technique called &lt;strong&gt;pipelining&lt;/strong&gt;, which overlaps the execution of multiple instructions, much like an assembly line in a factory. This approach is fundamental to all high-performance CPUs, and the RISC-V ISA is explicitly designed to facilitate it.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;Section 3.1: The Classic 5-Stage RISC Pipeline&lt;/h3&gt;
&lt;p&gt;Pipelining increases the &lt;strong&gt;instruction throughput&lt;/strong&gt;—the number of instructions completed per unit of time—without necessarily decreasing the &lt;strong&gt;latency&lt;/strong&gt; of any single instruction. The concept is best understood through the analogy of doing laundry. A sequential approach would be to wash, dry, fold, and put away one load of laundry completely before starting the next. A pipelined approach starts the washer on the second load as soon as the first load moves to the dryer. By keeping all stages (washer, dryer, folding table) busy, the total time to complete many loads is significantly reduced.&lt;/p&gt;
&lt;p&gt;Similarly, the execution of a RISC instruction can be broken down into a series of uniform steps. The classic RISC pipeline consists of five stages:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;IF (Instruction Fetch):&lt;/strong&gt; The processor fetches the 32-bit instruction from the instruction memory (or cache) at the address currently held by the Program Counter (PC). Concurrently, the PC is updated to point to the next instruction, which is typically at address $PC+4$ since each instruction is 4 bytes long.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;ID (Instruction Decode and Register Fetch):&lt;/strong&gt; The fetched instruction is decoded by the control unit to determine what operation to perform. The format of the instruction is identified, and the required control signals for subsequent stages are generated. Simultaneously, the source register identifiers (&lt;code&gt;rs1&lt;/code&gt; and &lt;code&gt;rs2&lt;/code&gt;) are used to read their corresponding values from the processor&apos;s register file. Any immediate value in the instruction is also sign-extended and prepared for use.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;EX (Execute):&lt;/strong&gt; This is where the actual computation occurs. The Arithmetic Logic Unit (ALU) performs the operation specified by the instruction. This could be an arithmetic operation (add, &lt;code&gt;sub&lt;/code&gt;), a logical operation (and, &lt;code&gt;or&lt;/code&gt;), a memory address calculation for a load or store (by adding the base register and the immediate offset), or a comparison for a branch instruction.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;MEM (Memory Access):&lt;/strong&gt; This stage is active only for load and store instructions. For a &lt;strong&gt;load&lt;/strong&gt; instruction (&lt;code&gt;lw&lt;/code&gt;), the address calculated in the EX stage is used to read data from the data memory (or cache). For a &lt;strong&gt;store&lt;/strong&gt; instruction (&lt;code&gt;sw&lt;/code&gt;), the address and data are used to write to the data memory. For all other instructions (e.g., arithmetic or branch), this stage performs no operation.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;WB (Write-Back):&lt;/strong&gt; The final stage writes the result of the operation back into the register file. For an arithmetic instruction, the result comes from the ALU. For a &lt;strong&gt;load&lt;/strong&gt; instruction, the result is the data read from memory. The destination register identifier (&lt;code&gt;rd&lt;/code&gt;) from the instruction determines which register is written.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In an ideal scenario, a new instruction enters the IF stage every clock cycle. After five cycles, the pipeline is full, and one instruction completes every cycle, achieving an ideal throughput of one instruction per cycle (IPC).&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;Section 3.2: When the Assembly Line Breaks - Pipeline Hazards&lt;/h3&gt;
&lt;p&gt;The simple, elegant model of the 5-stage pipeline breaks down when dependencies between instructions conflict with the overlapped execution model. These conflicts are known as &lt;strong&gt;pipeline hazards&lt;/strong&gt;, and they are the primary challenge in processor design. Hazards force the pipeline to stall, inserting &quot;bubbles&quot; where no useful work is done, thereby degrading performance. There are three main types of hazards.&lt;/p&gt;
&lt;h4&gt;Structural Hazards&lt;/h4&gt;
&lt;p&gt;A &lt;strong&gt;structural hazard&lt;/strong&gt; occurs when two or more instructions in the pipeline require the same hardware resource at the same time. A classic example is a processor with a single, unified memory for both instructions and data. In such a design, a &lt;strong&gt;load&lt;/strong&gt; instruction in its MEM stage would need to access memory simultaneously with a later instruction in its IF stage, which also needs to access memory to be fetched. This resource conflict would force one of the instructions to wait. The standard solution in RISC processors is to use a &lt;strong&gt;Harvard architecture&lt;/strong&gt;, which employs separate, independent memories or caches for instructions and data, thus eliminating this specific hazard. Another potential structural hazard is in the register file, which is accessed for reads in the ID stage and for writes in the WB stage. This is typically resolved by designing the register file with separate read and write ports, or by performing writes in the first half of the clock cycle and reads in the second half.&lt;/p&gt;
&lt;h4&gt;Data Hazards&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;Data hazards&lt;/strong&gt; arise from data dependencies between instructions. They occur when an instruction&apos;s execution depends on the result of a preceding instruction that is still in the pipeline.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Read-After-Write (RAW):&lt;/strong&gt; This is the most common and intuitive data hazard. An instruction attempts to read a source register before a previous instruction has written its result back to that register. Consider the sequence:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;add x5, x1, x2  // Instruction 1
sub x6, x5, x3  // Instruction 2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;sub&lt;/code&gt; instruction needs the value of &lt;code&gt;x5&lt;/code&gt;, but the &lt;code&gt;add&lt;/code&gt; instruction only calculates it in its EX stage and writes it back in its WB stage. By the time the &lt;code&gt;sub&lt;/code&gt; instruction is in its ID stage ready to read &lt;code&gt;x5&lt;/code&gt;, the &lt;code&gt;add&lt;/code&gt; instruction has not yet completed its WB stage, so the register file contains an old, stale value for &lt;code&gt;x5&lt;/code&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Write-After-Read (WAR):&lt;/strong&gt; An instruction tries to write to a destination register before a preceding instruction has finished reading that register&apos;s original value. This is not a problem in the simple 5-stage pipeline because reads always happen in an earlier stage (ID) than writes (WB). However, it becomes a major issue in processors with out-of-order execution.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Write-After-Write (WAW):&lt;/strong&gt; Two instructions in the pipeline are scheduled to write to the same destination register. Similar to WAR, this is not an issue in a simple in-order pipeline where writes happen in program order, but it is a critical hazard that must be managed in more complex designs.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;Control Hazards&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;Control hazards&lt;/strong&gt;, also known as branch hazards, are caused by branch and jump instructions that change the normal flow of program execution. The processor does not know the outcome of a conditional branch (whether it is taken or not taken) until the comparison is performed in the EX stage. By that time, the processor has already fetched and started decoding the instructions that sequentially follow the branch (at $PC+4$). If the branch is taken, these fetched instructions are incorrect and must be flushed from the pipeline, and the fetch must restart from the branch target address. This flushing process introduces stalls, or bubbles, into the pipeline, reducing performance.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;Section 3.3: Basic Hazard Resolution - Stalling and Forwarding&lt;/h3&gt;
&lt;p&gt;To ensure correct program execution, hazards must be detected and resolved by the processor&apos;s control logic.&lt;/p&gt;
&lt;h4&gt;Stalling (Pipeline Bubbles)&lt;/h4&gt;
&lt;p&gt;The most straightforward solution to a hazard is to &lt;strong&gt;stall&lt;/strong&gt; the pipeline. When the hazard detection logic in the ID stage identifies a dependency (e.g., a RAW hazard), it can freeze the early stages of the pipeline and insert no-operation instructions, or &quot;bubbles,&quot; into the later stages. For the &lt;code&gt;add&lt;/code&gt;/&lt;code&gt;sub&lt;/code&gt; example above, the &lt;code&gt;sub&lt;/code&gt; instruction would be held in the ID stage for several cycles until the &lt;code&gt;add&lt;/code&gt; instruction completes its WB stage and the new value of &lt;code&gt;x5&lt;/code&gt; is available in the register file. While simple and effective, stalling is inefficient as it directly reduces the pipeline&apos;s throughput.&lt;/p&gt;
&lt;h4&gt;Forwarding (Bypassing)&lt;/h4&gt;
&lt;p&gt;A much more efficient solution for most data hazards is &lt;strong&gt;forwarding&lt;/strong&gt;, also known as &lt;strong&gt;bypassing&lt;/strong&gt;. The key observation is that the result of an operation is often available within the pipeline long before it is written back to the register file. For example, the result of the &lt;code&gt;add&lt;/code&gt; instruction is available at the output of the ALU at the end of the EX stage. Forwarding logic adds extra data paths to send this result directly from the output of a later stage (like EX or MEM) back to the input of an earlier stage (like EX) for a subsequent, dependent instruction. This bypasses the need to wait for the result to be written to and then read from the register file. In the &lt;code&gt;add&lt;/code&gt;/&lt;code&gt;sub&lt;/code&gt; example, the result from the &lt;code&gt;add&lt;/code&gt; instruction&apos;s EX stage can be forwarded directly to the input of the &lt;code&gt;sub&lt;/code&gt; instruction&apos;s EX stage, completely eliminating the stall.&lt;/p&gt;
&lt;p&gt;However, forwarding cannot solve all data hazards. A classic case is the &lt;strong&gt;load-use hazard&lt;/strong&gt;. Consider this sequence:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;lw  x5, 0(x1)   // Instruction 1
add x6, x5, x2  // Instruction 2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;lw&lt;/code&gt; instruction only has the data from memory available at the end of its MEM stage. The &lt;code&gt;add&lt;/code&gt; instruction needs this data at the beginning of its EX stage. Even with a forwarding path from the MEM stage back to the EX stage, the data arrives one cycle too late. The &lt;code&gt;add&lt;/code&gt; instruction must be stalled for one cycle. This limitation, along with the performance penalty from control hazards and the inefficiency of handling long-latency operations like floating-point division, reveals the inherent performance ceiling of a rigid, in-order pipeline. It is this ceiling that motivates the development of more sophisticated, dynamic execution techniques that can look further ahead in the instruction stream to find independent work to do.&lt;/p&gt;
&lt;p&gt;| Hazard Type | Description                                                            | Example RISC-V Sequence                                              | Simple Pipeline Effect                                                 | Solution(s)                                                           |
| :---------- | :--------------------------------------------------------------------- | :------------------------------------------------------------------- | :--------------------------------------------------------------------- | :-------------------------------------------------------------------- |
| Structural  | Two instructions need the same resource in the same cycle.             | &lt;code&gt;lw&lt;/code&gt; in MEM stage, &lt;code&gt;add&lt;/code&gt; in IF stage, both needing a unified memory. | One instruction must stall.                                            | Separate Instruction/Data Memories (Harvard Architecture).            |
| Data (RAW)  | An instruction needs the result of a previous, unfinished instruction. | &lt;code&gt;add x5, x1, x2&lt;/code&gt; followed by &lt;code&gt;sub x6, x5, x3&lt;/code&gt;                        | &lt;code&gt;sub&lt;/code&gt; reads a stale value of &lt;code&gt;x5&lt;/code&gt; from the register file.              | Stalling, Forwarding (Bypassing).                                     |
| Control     | The address of the next instruction is unknown due to a branch.        | &lt;code&gt;beq x1, x2, L1&lt;/code&gt; followed by &lt;code&gt;add x3, x4, x5&lt;/code&gt;                        | Processor fetches &lt;code&gt;add&lt;/code&gt; before knowing if the branch to &lt;code&gt;L1&lt;/code&gt; is taken. | Stall until branch resolves, Branch Prediction, Flush incorrect path. |&lt;/p&gt;
&lt;h2&gt;Part IV: The Brains of the Operation - Dynamic Scheduling with Tomasulo&apos;s Algorithm&lt;/h2&gt;
&lt;p&gt;The limitations of in-order pipelining become severe in the presence of long-latency operations (like floating-point arithmetic or cache misses) and frequent data dependencies. Stalls can quickly dominate the execution time, leaving valuable functional units idle. To overcome this, high-performance processors employ &lt;strong&gt;dynamic scheduling&lt;/strong&gt;, a technique that allows instructions to execute out of their original program order. The seminal hardware algorithm for this is Tomasulo&apos;s algorithm, first implemented in the IBM System/360 Model 91.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;Section 4.1: Beyond In-Order Execution&lt;/h3&gt;
&lt;p&gt;The core idea behind dynamic scheduling is to shift from a control-flow-driven execution model to a &lt;strong&gt;dataflow-driven&lt;/strong&gt; one. In a simple pipeline, an instruction executes when it reaches the front of the line. In a dynamically scheduled machine, an instruction is allowed to execute as soon as all of its required operands are available, regardless of its position in the original program sequence. This decoupling of instruction issue (fetching and decoding) from execution allows the processor to look ahead in the instruction stream, find independent instructions, and execute them while a prior, dependent instruction is stalled waiting for its data. This significantly increases the utilization of the processor&apos;s multiple execution units and improves overall performance.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;Section 4.2: Core Components of the Tomasulo Machine&lt;/h3&gt;
&lt;p&gt;Tomasulo&apos;s algorithm achieves this dataflow execution through three key hardware components that work in concert.&lt;/p&gt;
&lt;h4&gt;Reservation Stations (RS)&lt;/h4&gt;
&lt;p&gt;Instead of a single pipeline, a Tomasulo-based processor has a set of functional units (e.g., one or more adders, multipliers, load/store units), each equipped with its own set of buffers called &lt;strong&gt;Reservation Stations (RS)&lt;/strong&gt;. When an instruction is decoded, it is issued to a free reservation station associated with the required functional unit. The RS acts as a waiting area, holding the instruction until it is ready to execute.&lt;/p&gt;
&lt;p&gt;Each entry in a reservation station contains the following fields:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Busy:&lt;/strong&gt; A bit indicating whether the station is in use.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Op:&lt;/strong&gt; The operation to be performed (e.g., &lt;code&gt;ADD&lt;/code&gt;, &lt;code&gt;MUL&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Vj, Vk:&lt;/strong&gt; The actual values of the two source operands. These fields are filled if the operand values are already available in the register file when the instruction is issued.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Qj, Qk:&lt;/strong&gt; The source operand &lt;strong&gt;tags&lt;/strong&gt;. If an operand is not yet available because it is being produced by another instruction currently in-flight, these fields will hold a tag that identifies which reservation station will produce the required result. A value of zero or null in these fields indicates that the corresponding &lt;code&gt;V&lt;/code&gt; field holds a valid operand.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dest:&lt;/strong&gt; A tag identifying the destination of the result (in modern implementations, this is a pointer to a Reorder Buffer entry).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The RS continuously monitors for its required operands. Once both &lt;code&gt;Qj&lt;/code&gt; and &lt;code&gt;Qk&lt;/code&gt; are zero (meaning &lt;code&gt;Vj&lt;/code&gt; and &lt;code&gt;Vk&lt;/code&gt; are both valid), the instruction is ready to be dispatched to its functional unit for execution.&lt;/p&gt;
&lt;h4&gt;The Common Data Bus (CDB)&lt;/h4&gt;
&lt;p&gt;The &lt;strong&gt;Common Data Bus (CDB)&lt;/strong&gt; is a broadcast bus that connects the outputs of all functional units to the inputs of all reservation stations and the register file. When a functional unit finishes its computation, it does not just write the result to a register. Instead, it places both the computed &lt;strong&gt;value&lt;/strong&gt; and its unique &lt;strong&gt;tag&lt;/strong&gt; (the name of the reservation station that produced it) onto the CDB.&lt;/p&gt;
&lt;p&gt;All reservation stations are &quot;snooping&quot; (monitoring) the CDB in every cycle. If an RS sees a tag on the CDB that matches a tag in its &lt;code&gt;Qj&lt;/code&gt; or &lt;code&gt;Qk&lt;/code&gt; field, it knows its long-awaited operand is now available. It grabs the value from the CDB, places it into the corresponding &lt;code&gt;Vj&lt;/code&gt; or &lt;code&gt;Vk&lt;/code&gt; field, and clears the &lt;code&gt;Qj&lt;/code&gt; or &lt;code&gt;Qk&lt;/code&gt; field to zero. This mechanism allows results to be forwarded directly from producer to consumer without ever needing to pass through the register file, dramatically reducing stalls from RAW dependencies.&lt;/p&gt;
&lt;h4&gt;Hardware Register Renaming&lt;/h4&gt;
&lt;p&gt;Out-of-order execution introduces the possibility of WAR and WAW hazards, which were not a problem in the simple in-order pipeline. Tomasulo&apos;s algorithm elegantly eliminates these hazards through a mechanism called &lt;strong&gt;hardware register renaming&lt;/strong&gt;.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Why WAR and WAW hazards are a problem in out-of-order execution? You can think about it yourself, or read &lt;a href=&quot;#appendix-a&quot;&gt;Appendix A&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The key is to decouple the architectural registers (the names visible to the programmer, e.g., F0, F2, F4) from the physical storage locations (the reservation stations). A mapping table, often called the &lt;strong&gt;Register Alias Table (RAT)&lt;/strong&gt; or Register Result Status, maintains the current mapping. For each architectural register, this table stores the tag of the reservation station that will produce the next value for that register.&lt;/p&gt;
&lt;p&gt;The process works as follows:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Issue:&lt;/strong&gt; When an instruction like &lt;code&gt;ADD.D F6, F8, F2&lt;/code&gt; is issued, the control logic looks up &lt;code&gt;F8&lt;/code&gt; and &lt;code&gt;F2&lt;/code&gt; in the RAT.
&lt;ul&gt;
&lt;li&gt;If the RAT entry for a source register is empty, the value is ready in the main register file. This value is copied to the &lt;code&gt;V&lt;/code&gt; field of the reservation station.&lt;/li&gt;
&lt;li&gt;If the RAT entry contains a tag (e.g., &lt;code&gt;Add1&lt;/code&gt;), it means another instruction is currently computing the value. This tag is copied into the &lt;code&gt;Q&lt;/code&gt; field of the new reservation station.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rename:&lt;/strong&gt; After reading the source tags, the logic updates the RAT entry for the destination register, &lt;code&gt;F6&lt;/code&gt;, with the tag of the newly allocated reservation station (e.g., &lt;code&gt;Add2&lt;/code&gt;). Now, any subsequent instruction that needs &lt;code&gt;F6&lt;/code&gt; will be directed to get its value from &lt;code&gt;Add2&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This renaming process breaks false dependencies. If a later instruction also writes to &lt;code&gt;F6&lt;/code&gt; (a WAW hazard), it will simply be allocated a new reservation station (&lt;code&gt;Add3&lt;/code&gt;), and the RAT will be updated to point to &lt;code&gt;Add3&lt;/code&gt;. The original &lt;code&gt;ADD.D&lt;/code&gt; instruction is unaffected because it is already linked to &lt;code&gt;Add2&lt;/code&gt;. Similarly, WAR hazards are eliminated because source operands either get their value immediately or are linked to a specific producer via a tag; a subsequent write to that source register will be renamed to a new physical location and will not affect the original value needed by the earlier instruction.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;Section 4.3: A Cycle-by-Cycle Walkthrough of Tomasulo&apos;s Algorithm&lt;/h3&gt;
&lt;p&gt;To solidify these concepts, a detailed, cycle-by-cycle trace of a sequence of dependent instructions is invaluable. This walkthrough will demonstrate the dynamic interplay between the reservation stations, the RAT, and the CDB.&lt;/p&gt;
&lt;h4&gt;Simulation Setup:&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Functional Units:&lt;/strong&gt;
1 Integer Unit (for effective address calculation): 1 cycle latency.
2 FP Adders (for &lt;code&gt;ADD.D&lt;/code&gt;, &lt;code&gt;SUB.D&lt;/code&gt;): 2 cycles latency.
2 FP Multipliers (for &lt;code&gt;MUL.D&lt;/code&gt;): 10 cycles latency.
1 FP Divider (for &lt;code&gt;DIV.D&lt;/code&gt;): 40 cycles latency.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Instruction Issue:&lt;/strong&gt; 1 instruction per cycle.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CDB:&lt;/strong&gt; 1 result can be broadcast per cycle.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reservation Stations:&lt;/strong&gt; 3 for Add/Sub, 2 for Mult/Div.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;Example Instruction Sequence:&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;L.D F6, 34(R2)
L.D F2, 45(R3)
MUL.D F0, F2, F4
SUB.D F8, F6, F2
DIV.D F10, F0, F6
ADD.D F6, F8, F2
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;Initial State:&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Register File:&lt;/strong&gt; R2=100, R3=200, F4=2.0. All other FP registers have some initial value.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Memory:&lt;/strong&gt; Mem[134]=10.0, Mem[245]=5.0.&lt;/li&gt;
&lt;li&gt;All Reservation Stations are empty.&lt;/li&gt;
&lt;li&gt;Register Result Status is empty (all values are in the Register File).&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h4&gt;Cycle 1:&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Events:&lt;/strong&gt; &lt;code&gt;L.D F6, 34(R2)&lt;/code&gt; is issued.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Actions:&lt;/strong&gt;
A Load buffer (Load1) is allocated.
The value of &lt;code&gt;R2&lt;/code&gt; (100) is read from the integer register file.
The effective address is calculated immediately: $100 + 34 = 134$.
The Register Result Status for &lt;code&gt;F6&lt;/code&gt; is updated to point to &lt;code&gt;Load1&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;| Instruction      | Issue | Execute | Write Result |
| :--------------- | :---- | :------ | :----------- |
| &lt;code&gt;L.D F6, 34(R2)&lt;/code&gt; | 1     |         |              |&lt;/p&gt;
&lt;p&gt;| Reservation Stations | Busy | Op   | Vj  | Vk  | Qj  | Qk  | Address |
| :------------------- | :--- | :--- | :-- | :-- | :-- | :-- | :------ |
| Load1                | Yes  | Load | 100 |     |     |     | 34      |
| Load2                | No   |      |     |     |     |     |         |&lt;/p&gt;
&lt;p&gt;| Register Result Status | F0  | F2  | F4  | F6    | F8  | F10 |
| :--------------------- | :-- | :-- | :-- | :---- | :-- | :-- |
| Qi                     |     |     |     | Load1 |     |     |&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;CDB Activity:&lt;/strong&gt; None.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h4&gt;Cycle 2:&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Events:&lt;/strong&gt; &lt;code&gt;L.D F2, 45(R3)&lt;/code&gt; is issued. &lt;code&gt;Load1&lt;/code&gt; begins memory access.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Actions:&lt;/strong&gt;
Load2 buffer is allocated.
Value of &lt;code&gt;R3&lt;/code&gt; (200) is read. Effective address $200 + 45 = 245$ is calculated.
Register Result Status for &lt;code&gt;F2&lt;/code&gt; is updated to &lt;code&gt;Load2&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;| Instruction      | Issue | Execute | Write Result |
| :--------------- | :---- | :------ | :----------- |
| &lt;code&gt;L.D F6, 34(R2)&lt;/code&gt; | 1     | 2       |              |
| &lt;code&gt;L.D F2, 45(R3)&lt;/code&gt; | 2     |         |              |&lt;/p&gt;
&lt;p&gt;| Reservation Stations | Busy | Op   | Vj  | Vk  | Qj  | Qk  | Address |
| :------------------- | :--- | :--- | :-- | :-- | :-- | :-- | :------ |
| Load1                | Yes  | Load | 100 |     |     |     | 34      |
| Load2                | Yes  | Load | 200 |     |     |     | 45      |&lt;/p&gt;
&lt;p&gt;| Register Result Status | F0  | F2    | F4  | F6    | F8  | F10 |
| :--------------------- | :-- | :---- | :-- | :---- | :-- | :-- |
| Qi                     |     | Load2 |     | Load1 |     |     |&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;CDB Activity:&lt;/strong&gt; None.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h4&gt;Cycle 3:&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Events:&lt;/strong&gt; &lt;code&gt;MUL.D F0, F2, F4&lt;/code&gt; is issued. &lt;code&gt;Load1&lt;/code&gt; completes memory access. &lt;code&gt;Load2&lt;/code&gt; begins memory access.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Actions:&lt;/strong&gt;
A multiplier RS (Mult1) is allocated.
RAT is checked for sources &lt;code&gt;F2&lt;/code&gt; and &lt;code&gt;F4&lt;/code&gt;. &lt;code&gt;F2&lt;/code&gt; is being produced by &lt;code&gt;Load2&lt;/code&gt;, so &lt;code&gt;Qj&lt;/code&gt; of &lt;code&gt;Mult1&lt;/code&gt; gets tag &lt;code&gt;Load2&lt;/code&gt;. &lt;code&gt;F4&lt;/code&gt; is ready in the register file, so its value (2.0) is copied to &lt;code&gt;Vk&lt;/code&gt;.
RAT for destination &lt;code&gt;F0&lt;/code&gt; is updated to &lt;code&gt;Mult1&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;| Instruction        | Issue | Execute | Write Result |
| :----------------- | :---- | :------ | :----------- |
| &lt;code&gt;L.D F6, 34(R2)&lt;/code&gt;   | 1     | 2       | 3            |
| &lt;code&gt;L.D F2, 45(R3)&lt;/code&gt;   | 2     | 3       |              |
| &lt;code&gt;MUL.D F0, F2, F4&lt;/code&gt; | 3     |         |              |&lt;/p&gt;
&lt;p&gt;| Reservation Stations | Busy | Op    | Vj  | Vk  | Qj    | Qk  | Address |
| :------------------- | :--- | :---- | :-- | :-- | :---- | :-- | :------ |
| Load1                | Yes  | Load  | ... |     |       |     |         |
| Load2                | Yes  | Load  | ... |     |       |     |         |
| Mult1                | Yes  | MUL.D |     | 2.0 | Load2 |     |         |
| Add1                 | No   |       |     |     |       |     |         |&lt;/p&gt;
&lt;p&gt;| Register Result Status | F0    | F2    | F4  | F6    | F8  | F10 |
| :--------------------- | :---- | :---- | :-- | :---- | :-- | :-- |
| Qi                     | Mult1 | Load2 |     | Load1 |     |     |&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;CDB Activity:&lt;/strong&gt; &lt;code&gt;Load1&lt;/code&gt; broadcasts result Mem[134] (value 10.0) with tag &lt;code&gt;Load1&lt;/code&gt;.
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Snooping:&lt;/strong&gt; No waiting RS needs &lt;code&gt;Load1&lt;/code&gt; yet. The RAT entry for &lt;code&gt;F6&lt;/code&gt; is updated with the value and the tag is cleared.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h4&gt;Cycle 4:&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Events:&lt;/strong&gt; &lt;code&gt;SUB.D F8, F6, F2&lt;/code&gt; is issued. &lt;code&gt;Load2&lt;/code&gt; completes memory access.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Actions:&lt;/strong&gt;
An adder RS (Add1) is allocated.
RAT is checked for &lt;code&gt;F6&lt;/code&gt; and &lt;code&gt;F2&lt;/code&gt;. &lt;code&gt;F6&lt;/code&gt; is now ready (value 10.0 from &lt;code&gt;Load1&lt;/code&gt;&apos;s broadcast), so &lt;code&gt;Vj&lt;/code&gt; gets 10.0. &lt;code&gt;F2&lt;/code&gt; is still being produced by &lt;code&gt;Load2&lt;/code&gt;, so &lt;code&gt;Qk&lt;/code&gt; gets tag &lt;code&gt;Load2&lt;/code&gt;.
RAT for &lt;code&gt;F8&lt;/code&gt; is updated to &lt;code&gt;Add1&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;| Instruction        | Issue | Execute | Write Result |
| :----------------- | :---- | :------ | :----------- |
| &lt;code&gt;L.D F6, 34(R2)&lt;/code&gt;   | 1     | 2       | 3            |
| &lt;code&gt;L.D F2, 45(R3)&lt;/code&gt;   | 2     | 3       | 4            |
| &lt;code&gt;MUL.D F0, F2, F4&lt;/code&gt; | 3     |         |              |
| &lt;code&gt;SUB.D F8, F6, F2&lt;/code&gt; | 4     |         |              |&lt;/p&gt;
&lt;p&gt;| Reservation Stations | Busy | Op    | Vj   | Vk  | Qj    | Qk    |
| :------------------- | :--- | :---- | :--- | :-- | :---- | :---- |
| Mult1                | Yes  | MUL.D |      | 2.0 | Load2 |       |
| Add1                 | Yes  | SUB.D | 10.0 |     |       | Load2 |&lt;/p&gt;
&lt;p&gt;| Register Result Status | F0    | F2    | F4  | F6  | F8   | F10 |
| :--------------------- | :---- | :---- | :-- | :-- | :--- | :-- |
| Qi                     | Mult1 | Load2 |     |     | Add1 |     |&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;CDB Activity:&lt;/strong&gt; &lt;code&gt;Load2&lt;/code&gt; broadcasts result Mem[245] (value 5.0) with tag &lt;code&gt;Load2&lt;/code&gt;.
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Snooping:&lt;/strong&gt; Both &lt;code&gt;Mult1&lt;/code&gt; and &lt;code&gt;Add1&lt;/code&gt; are waiting for &lt;code&gt;Load2&lt;/code&gt;. They both snoop the CDB, capture the value 5.0, and clear their &lt;code&gt;Q&lt;/code&gt; fields. &lt;code&gt;Mult1&lt;/code&gt;&apos;s &lt;code&gt;Vj&lt;/code&gt; becomes 5.0. &lt;code&gt;Add1&lt;/code&gt;&apos;s &lt;code&gt;Vk&lt;/code&gt; becomes 5.0.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h4&gt;Cycle 5:&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Events:&lt;/strong&gt; &lt;code&gt;DIV.D F10, F0, F6&lt;/code&gt; is issued. Both &lt;code&gt;Mult1&lt;/code&gt; and &lt;code&gt;Add1&lt;/code&gt; are now ready to execute.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Actions:&lt;/strong&gt;
A divider RS (Div1) is allocated.
RAT is checked for &lt;code&gt;F0&lt;/code&gt; and &lt;code&gt;F6&lt;/code&gt;. &lt;code&gt;F0&lt;/code&gt; is being produced by &lt;code&gt;Mult1&lt;/code&gt;. &lt;code&gt;F6&lt;/code&gt; is ready. &lt;code&gt;Div1&lt;/code&gt; gets tag &lt;code&gt;Mult1&lt;/code&gt; in &lt;code&gt;Qj&lt;/code&gt; and value 10.0 in &lt;code&gt;Vk&lt;/code&gt;.
RAT for &lt;code&gt;F10&lt;/code&gt; is updated to &lt;code&gt;Div1&lt;/code&gt;.
&lt;code&gt;Mult1&lt;/code&gt; begins its 10-cycle execution (5.0 * 2.0).
&lt;code&gt;Add1&lt;/code&gt; begins its 2-cycle execution (10.0 - 5.0). Note the out-of-order execution: &lt;code&gt;SUB.D&lt;/code&gt; starts before &lt;code&gt;MUL.D&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;| Instruction         | Issue | Execute | Write Result |
| :------------------ | :---- | :------ | :----------- |
| ...                 | ...   | ...     | ...          |
| &lt;code&gt;MUL.D F0, F2, F4&lt;/code&gt;  | 3     | 5       |              |
| &lt;code&gt;SUB.D F8, F6, F2&lt;/code&gt;  | 4     | 5       |              |
| &lt;code&gt;DIV.D F10, F0, F6&lt;/code&gt; | 5     |         |              |&lt;/p&gt;
&lt;p&gt;| Reservation Stations | Busy | Op    | Vj   | Vk   | Qj    | Qk  |
| :------------------- | :--- | :---- | :--- | :--- | :---- | :-- |
| Mult1                | Yes  | MUL.D | 5.0  | 2.0  |       |     |
| Add1                 | Yes  | SUB.D | 10.0 | 5.0  |       |     |
| Div1                 | Yes  | DIV.D |      | 10.0 | Mult1 |     |&lt;/p&gt;
&lt;p&gt;| Register Result Status | F0    | F2  | F4  | F6  | F8   | F10  |
| :--------------------- | :---- | :-- | :-- | :-- | :--- | :--- |
| Qi                     | Mult1 |     |     |     | Add1 | Div1 |&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;CDB Activity:&lt;/strong&gt; None.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;...This process continues. The &lt;code&gt;SUB.D&lt;/code&gt; will finish in cycle 6 and broadcast its result. The &lt;code&gt;ADD.D&lt;/code&gt; (instruction 6) will issue and wait for results from &lt;code&gt;Add1&lt;/code&gt; and &lt;code&gt;Load2&lt;/code&gt;. The &lt;code&gt;MUL.D&lt;/code&gt; will finish in cycle 14 and broadcast, allowing the &lt;code&gt;DIV.D&lt;/code&gt; to start its long 40-cycle execution. This detailed trace reveals how the hardware dynamically resolves dependencies and executes instructions as soon as their data is ready, maximizing parallelism.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;Section 4.4: Taming the Chaos with the Reorder Buffer (ROB)&lt;/h3&gt;
&lt;p&gt;While Tomasulo&apos;s algorithm is brilliant at extracting instruction-level parallelism, its out-of-order completion creates a significant problem: it makes handling exceptions and branch mispredictions incredibly difficult. If &lt;code&gt;MUL.D&lt;/code&gt; completes after &lt;code&gt;SUB.D&lt;/code&gt;, but &lt;code&gt;SUB.D&lt;/code&gt; causes an arithmetic exception, the machine state is inconsistent. The processor has modified state (F8) from an instruction that is logically &lt;strong&gt;after&lt;/strong&gt; the faulting instruction. This is called an &lt;strong&gt;imprecise exception&lt;/strong&gt;, and it makes operating systems and recovery mechanisms nearly impossible to implement correctly.&lt;/p&gt;
&lt;p&gt;The solution is to add a new hardware structure, the &lt;strong&gt;Reorder Buffer (ROB)&lt;/strong&gt;, which extends the original algorithm to ensure that while instructions may &lt;strong&gt;execute&lt;/strong&gt; out of order, they &lt;strong&gt;commit&lt;/strong&gt; their results to the architectural state (the main register file and memory) in strict program order.&lt;/p&gt;
&lt;h4&gt;ROB Mechanism&lt;/h4&gt;
&lt;p&gt;The ROB is a circular buffer that operates on a First-In, First-Out (FIFO) basis. It bridges the gap between out-of-order execution completion and in-order architectural update.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Issue:&lt;/strong&gt; When an instruction is decoded, it is allocated an entry at the tail of the ROB. This ROB entry number becomes the instruction&apos;s new tag. The register renaming table (RAT) now points to ROB entries, not reservation stations.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Execute:&lt;/strong&gt; Instructions are still sent to reservation stations and execute out-of-order as before.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Write Result:&lt;/strong&gt; When a functional unit completes, it broadcasts its result and its ROB tag on the CDB. The result is written into the corresponding entry in the &lt;strong&gt;ROB&lt;/strong&gt;, not the register file. The ROB entry is marked as &quot;ready&quot;. Any waiting reservation stations also snoop the CDB and grab the result.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Commit:&lt;/strong&gt; The processor examines the instruction at the &lt;strong&gt;head&lt;/strong&gt; of the ROB. If its entry is marked &quot;ready,&quot; the instruction is committed. This means its result is finally written from the ROB to the architectural register file or memory. The instruction is then removed from the ROB (the head pointer advances). If the instruction at the head is not yet ready, the commit stage stalls, and no subsequent instructions can be committed, thus enforcing in-order retirement.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each entry in the ROB typically contains these fields:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Busy:&lt;/strong&gt; Indicates if the entry is valid.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Instruction Type:&lt;/strong&gt; Specifies if it&apos;s a branch, store, or register operation.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;State:&lt;/strong&gt; Tracks the instruction&apos;s progress (e.g., &lt;code&gt;Issue&lt;/code&gt;, &lt;code&gt;Execute&lt;/code&gt;, &lt;code&gt;WriteResult&lt;/code&gt;, &lt;code&gt;Commit&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Destination:&lt;/strong&gt; The architectural register number or memory address to be written.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Value:&lt;/strong&gt; The computed result, held here until commit.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Ready:&lt;/strong&gt; A bit indicating the result is valid in the &lt;code&gt;Value&lt;/code&gt; field.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Exception:&lt;/strong&gt; Stores any exception information generated during execution.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3&gt;Section 4.5: Achieving Precise Exceptions and Speculation&lt;/h3&gt;
&lt;p&gt;The addition of the ROB is the key that unlocks two of the most powerful features of modern high-performance processors: precise exceptions and speculative execution.&lt;/p&gt;
&lt;h4&gt;Precise Exceptions&lt;/h4&gt;
&lt;p&gt;The ROB provides a simple and elegant mechanism for handling exceptions precisely. When an instruction (e.g., a &lt;code&gt;DIV.D&lt;/code&gt; by zero) encounters an exception during its execution, the exception is not acted upon immediately. Instead, the exception status is simply recorded in the &lt;code&gt;Exception&lt;/code&gt; field of the instruction&apos;s entry in the ROB. The processor continues to execute and complete other instructions out of order. The exception is only handled when the faulting instruction reaches the head of the ROB and is ready to be committed. At that point, the processor knows the exception is not speculative and is the next one to occur in the program&apos;s sequential order. It can then flush the entire pipeline and ROB, save a precise state, and jump to the operating system&apos;s exception handler.&lt;/p&gt;
&lt;h4&gt;Branch Speculation&lt;/h4&gt;
&lt;p&gt;The ROB is also the enabler of efficient &lt;strong&gt;branch speculation&lt;/strong&gt;. When the processor encounters a branch, a branch predictor guesses the outcome. The processor then &lt;strong&gt;speculatively&lt;/strong&gt; fetches, issues, and executes instructions from the predicted path, filling the ROB with these speculative instructions.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;If the prediction is correct:&lt;/strong&gt; The branch instruction eventually reaches the head of the ROB and is committed. The speculative instructions that follow it then commit normally as they reach the head. No time was lost.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;If the prediction is incorrect:&lt;/strong&gt; When the branch instruction is finally executed and the misprediction is discovered, the processor performs a recovery. It flushes all speculative instructions from the pipeline, reservation stations, and the ROB (this is as simple as resetting the ROB&apos;s tail pointer to its head pointer). No architectural state was corrupted because none of the speculative instructions were ever committed. The processor then begins fetching from the correct path.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The combination of a Tomasulo-style dataflow execution core with a Reorder Buffer for in-order commit forms the foundation of virtually all modern high-performance, out-of-order (OOO) processors. This two-part architecture elegantly solves the problems of data dependencies, false dependencies, and imprecise state, allowing for a massive increase in instruction-level parallelism.&lt;/p&gt;
&lt;h2&gt;Appendix A&lt;/h2&gt;
&lt;h3&gt;WAR Hazard&lt;/h3&gt;
&lt;p&gt;A WAR hazard, or &quot;anti-dependence,&quot; happens when an instruction wants to write to a register before an earlier instruction has finished reading that register&apos;s original value.&lt;/p&gt;
&lt;p&gt;Here is a simple example:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Instruction 1 w/ long latency
1.  FMUL.D  F2, F4, F6   // Multiplies F4 and F6, result goes to F2

# Instruction 2 w/ short latency, independent operands
2.  FADD.D  F4, F8, F10  // Adds F8 and F10, result goes to F4
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A Tomasulo processor&apos;s goal is to maximize performance by executing instructions as soon as their operands are ready.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Instruction 1 (FMUL.D) is issued. Let&apos;s say it&apos;s a long operation that will take 10 cycles. It needs to read F4.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Instruction 2 (FADD.D) is issued right after. The processor sees its source operands (F8 and F10) are ready and that the addition functional unit is free. It&apos;s a short 2-cycle operation.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The processor executes FADD.D immediately, without waiting for the FMUL.D to finish.&lt;/p&gt;
&lt;p&gt;So here is the &lt;strong&gt;WAR&lt;/strong&gt; hazard: The FADD.D finishes in 2 cycles and wants to write its result to register F4. But the FMUL.D instruction hasn&apos;t even started its long execution yet and still needs the original value from F4! If the FADD.D were allowed to write to the actual architectural register F4, it would corrupt the input for the FMUL.D, leading to an incorrect program result.&lt;/p&gt;
&lt;h3&gt;WAW Hazard&lt;/h3&gt;
&lt;p&gt;A WAW hazard, or &quot;output dependence,&quot; happens when two different instructions want to write to the same destination register, and the instruction that came later in the program finishes execution first.&lt;/p&gt;
&lt;p&gt;Here is a simple example:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1.  FMUL.D  F2, F4, F6    // Writes to F2

2.  FADD.D  F2, F8, F10   // Also writes to F2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The correct final value in F2 should be the result of the FADD.D instruction, since it comes later in the program.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;The FADD.D (Instruction 2) is short and finishes in 2 cycles. It&apos;s ready to write its result.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;The FMUL.D (Instruction 1) is long and finishes 10 cycles later.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So here is the &lt;strong&gt;WAW&lt;/strong&gt; hazard: If the FADD.D writes its result to F2, and then 8 cycles later the FMUL.D also writes its result to F2, the final value in the register will be from the FMUL.D. This is incorrect! The result from the instruction that was supposed to happen first has overwritten the result from the instruction that was supposed to happen last.&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>Enabling KVM GPU Passthrough</title><link>https://theunknownth.ing/blog/gpu-passthrough</link><guid isPermaLink="true">https://theunknownth.ing/blog/gpu-passthrough</guid><description>How to enable GPU passthrough for KVM on Linux</description><pubDate>Sun, 27 Apr 2025 10:55:00 GMT</pubDate><content:encoded>&lt;h2&gt;Credits&lt;/h2&gt;
&lt;p&gt;In this article, the &quot;Enabling IOMMU&quot; and the &quot;GPU Passthrough&quot; sections are adapted from &lt;a href=&quot;https://drakeor.com/2022/02/16/kvm-gpu-passthrough-tutorial/&quot;&gt;Drakeor&apos;s Blog&lt;/a&gt; with some clarifications and modifications. The original article is very well written and I highly recommend reading it.&lt;/p&gt;
&lt;p&gt;If this article is helpful, make sure to check out Drakeor&apos;s blog and support him. Thanks to Drakeor for the great work!&lt;/p&gt;
&lt;h2&gt;Enabling IOMMU&lt;/h2&gt;
&lt;h3&gt;Setup&lt;/h3&gt;
&lt;p&gt;In my setup, I have a host machine with an NVIDIA GeForce RTX 4090 GPU and a guest machine running Ubuntu 24.04 Server for AI training. The host machine is running Ubuntu 24.04 LTS with 6.11 kernel. The host machine has a integrated Intel UHD Graphics 770 GPU, which is used for the host display. The NVIDIA GPU is passed through to the guest machine.&lt;/p&gt;
&lt;p&gt;The host machine has the following hardware:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;CPU: Intel Core i9-14900K&lt;/li&gt;
&lt;li&gt;Motherboard: Gigabyte Z790 AORUS XTREME&lt;/li&gt;
&lt;li&gt;GPU: ZOTAC GeForce RTX 4090&lt;/li&gt;
&lt;li&gt;RAM: 64GB DDR5&lt;/li&gt;
&lt;li&gt;Storage: 2TB NVMe SSD&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Enabling IOMMU is a crucial step for GPU passthrough. It allows the host machine to access the GPU directly. It takes two steps to enable IOMMU: enabling it in the BIOS and enabling it in linux.&lt;/p&gt;
&lt;h3&gt;BIOS Settings&lt;/h3&gt;
&lt;p&gt;This tutorial assumes that you have IOMMU support for both your motherboard and CPU. Most modern server motherboards should support it, but your mileage may vary with desktop motherboards. Here are the options in BIOS corresponding to IOMMU related features:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Intel Based: Enable &quot;Intel VT-d&quot;. May also be called &quot;Intel Virtualization Technology&quot; or simply &quot;VT-d&quot; on some motherboards.&lt;/li&gt;
&lt;li&gt;AMD Based: Enable &quot;SVM&quot;. May also be called &quot;AMD Virtualization&quot; or simply &quot;AMD-V&quot;.
Note: I&apos;ve seen &quot;IOMMU&quot; as it&apos;s own separate option on one of my motherboards, but not on any of my other motherboards. Make sure it&apos;s enabled if you do see it. If you don&apos;t see it, it&apos;s likely rolled into one of the former VT-d or AMD-V options listed above.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;Some modern computers may have IOMMU enabled by default, so you may first verify whether it is enabled or not. If you are not sure, you can check the BIOS settings.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4&gt;Checking for IOMMU Support on your CPU&lt;/h4&gt;
&lt;p&gt;On Ubuntu/Debian for my Intel processor, it&apos;s as easy as this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cat /proc/cpuinfo | grep --color vmx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you see colored &lt;code&gt;vmx&lt;/code&gt; in the output, you have IOMMU support. If you see nothing, your CPU does not support IOMMU.&lt;/p&gt;
&lt;p&gt;The AMD equivalent is this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cat /proc/cpuinfo | grep --color svm
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There are one other BIOS settings that I recommend enabling before you move on to the next section.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Make sure the &lt;strong&gt;Primary GPU is set to integrated and not using your passthrough graphics card&lt;/strong&gt;. This is called &quot;Boot GPU&quot; and &quot;Primary Graphics&quot; in my BIOS. Also remember to plug your monitor into the integrated graphics port on your motherboard. This is important because the host machine will use the integrated graphics for display and the passthrough graphics card will be used by the guest machine.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;It is also worth notice that some motherboards have a setting called &quot;Above 4G Decoding&quot; or &quot;Resizable Bar Support&quot;. This is not the same as IOMMU. It is used for PCIe devices that require more than 4GB of address space. It is not required for IOMMU to work, but it is recommended to enable it if you have a GPU with more than 4GB of VRAM.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Once you&apos;ve enabled the above settings, save and exit the BIOS. This is a one-time operation. You will not need to do this again unless you reset your BIOS settings.&lt;/p&gt;
&lt;h3&gt;Linux GRUB Settings&lt;/h3&gt;
&lt;p&gt;Add the following options to your GRUB_CMDLINE_LINUX option in the &lt;code&gt;/etc/default/grub&lt;/code&gt; file:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;nano /etc/default/grub
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For Intel CPUs, add the following options:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;GRUB_CMDLINE_LINUX=&quot;... intel_iommu=on iommu=pt video=efifb:off&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The ... in the above line is the existing options. Make sure to keep them.&lt;/p&gt;
&lt;p&gt;For AMD CPUs, add the following options:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;GRUB_CMDLINE_LINUX=&quot;... amd_iommu=on iommu=pt video=efifb:off&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And then update GRUB:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo grub-mkconfig -o /boot/grub/grub.cfg
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Make sure to reboot your system.&lt;/p&gt;
&lt;p&gt;Then, to check that IOMMU is enabled, we can run the following command&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo dmesg | grep -i -e DMAR -e IOMMU
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You should see at least a message or two about it loading like below:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;Feb 10 17:55:23.119993 opaleye kernel: pci 0000:00:00.2: AMD-Vi: IOMMU performance counters supported
Feb 10 17:55:23.123622 opaleye kernel: pci 0000:00:00.2: AMD-Vi: Found IOMMU cap 0x40
Feb 10 17:55:23.123691 opaleye kernel: perf/amd_1ommu: Detected AMD IOMMU #0 (2 banks, 4 counters/bank) •
Feb 10 17:55:23.124108 opaleye kernel: AMD-Vi: AMD IOMMUv2 loaded and initialized
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;GPU Passthrough&lt;/h2&gt;
&lt;h3&gt;Find IOMMU Groups&lt;/h3&gt;
&lt;p&gt;Finding IOMMU Groups&lt;/p&gt;
&lt;p&gt;Before looking at the IOMMU Groups, I want to make sure that my graphics card is visible to the OS. I run the following command:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;lspci -nnk | grep VGA
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For me, this results in 2 graphics controllers being shown:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;00:02.0 VGA compatible controller [0300]: Intel Corporation Raptor Lake-S GT1 [UHD Graphics 770] [8086:a780] (rev 04)
01:00.0 VGA compatible controller [0300]: NVIDIA Corporation AD102 [GeForce RTX 4090] [10de:2684] (rev a1)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The first one is the integrated graphics card and the second one is the NVIDIA GPU. To list all the IOMMU groups they are part of, I&apos;ll run the following command (TheUnknownThing notes: I&apos;ve modified the command because the original one from drakeor&apos;s blog was not working for me):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;for d in /sys/kernel/iommu_groups/*/devices/*; do
  n=${d#*/iommu_groups/*}; n=${n%%/*}
  printf &apos;IOMMU Group %s &apos; &quot;$n&quot;
  lspci -nns &quot;${d##*/}&quot;
done | sort -V
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://theunknownth.ing/_astro/IOMMU.B9WXifC2_ZI6v4s.webp&quot; alt=&quot;IOMMU Groups&quot;&gt;&lt;/p&gt;
&lt;p&gt;As is shown in the figure, my RTX 4090 is in IOMMU group 12.&lt;/p&gt;
&lt;h3&gt;Loading the Correct Kernel Modules&lt;/h3&gt;
&lt;p&gt;Okay, so now that we have IOMMU all set, we need to make sure to load the correct modules for our passthrough graphics card. By default, nouveau will try to grab the graphics card when we boot.&lt;/p&gt;
&lt;p&gt;I created a new file called /etc/modprobe.d/vfio.conf and added the following lines:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;blacklist nouveau
options vfio_pci ids=10de:2684,10de:22ba
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://theunknownth.ing/_astro/ID.CzJ56Oe7_226Akw.webp&quot; alt=&quot;ID&quot;&gt;&lt;/p&gt;
&lt;p&gt;Note that I got the IDs from the IOMMU Group above. I need to pass in EVERY device in that IOMMU group or it won&apos;t work! Even though I&apos;m not using audio, I still need to pass in the audio device in that group.&lt;/p&gt;
&lt;p&gt;Side note: why we need to block nouveau? Because it will try to grab the graphics card and we don&apos;t want that. We want vfio-pci to grab it instead.&lt;/p&gt;
&lt;p&gt;In &lt;code&gt;/etc/modules-load.d/modules.conf&lt;/code&gt;, we&apos;ll ensure vfio_pci is loaded at boot:&lt;/p&gt;
&lt;p&gt;Add &lt;code&gt;vfio_pci&lt;/code&gt; to the file:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;echo &quot;vfio_pci&quot; | sudo tee -a /etc/modules-load.d/modules.conf
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Now reboot your system.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Now run the following to make sure the correct module is being used:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;lspci -nnk
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Make sure you see &lt;code&gt;vfio-pci&lt;/code&gt; in the driver column for your graphics card.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theunknownth.ing/_astro/VFIO.--QXy68v_1e9DcJ.webp&quot; alt=&quot;VFIO&quot;&gt;&lt;/p&gt;
&lt;h3&gt;Passing the GPU to the Guest VM&lt;/h3&gt;
&lt;p&gt;If you haven&apos;t installed the virt-manager or created your VM yet, please move on to the &lt;a href=&quot;#creating-a-vm&quot;&gt;Creating a VM&lt;/a&gt; section.&lt;/p&gt;
&lt;p&gt;So recall that the PCI address is on the left-side of when I ran lspci -Dnn earlier:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;0000:01:00.0 VGA compatible controller [0300]: NVIDIA Corporation AD102 [GeForce RTX 4090] [10de:2684] (rev a1)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We want to take that value (0000:01:00.0) and convert all the colons and dots into underscores. So for 0000:01:00.0, it will be 0000_01_00_0.&lt;/p&gt;
&lt;p&gt;Now we need to detach the PCI device from the host machine. We can do this with the following virsh command:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;virsh nodedev-detach pci_0000_01_00_0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then we&apos;ll edit the VM we want to attach the GPU to with the following virsh command:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;virsh edit &amp;#x3C;vm_name&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Under the devices tag, we&apos;ll add the GPU. Note that address, bus, slot, and function matches the PCI address we saw earlier. You could add the following to wherever you want in the devices section, but I like to put it at the end.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;..
&amp;#x3C;devices&gt;
...
    &amp;#x3C;hostdev mode=&apos;subsystem&apos; type=&apos;pci&apos; managed=&apos;yes&apos;&gt;
        &amp;#x3C;driver name=&apos;vfio&apos;/&gt;
        &amp;#x3C;source&gt;
        &amp;#x3C;address domain=&apos;0x0000&apos; bus=&apos;0x01&apos; slot=&apos;0x00&apos; function=&apos;0x0&apos;/&gt;
        &amp;#x3C;/source&gt;
    &amp;#x3C;/hostdev&gt;
...
&amp;#x3C;/devices&gt;
...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now save the file and reboot your VM, and you should see the NVIDIA GPU in the VM. Remember to install the NVIDIA drivers in the guest machine. For a quick test, I will run the following command in the guest machine:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt update
sudo ubuntu-drivers autoinstall
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And testthe following command to check if the NVIDIA drivers are installed correctly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo nvidia-smi
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Creating a VM&lt;/h2&gt;
&lt;h3&gt;Prerequisites: Check Hardware Virtualization Support&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;KVM requires hardware virtualization extensions (Intel VT-x or AMD-V) to be enabled in your system&apos;s BIOS/UEFI.  As we discussed earlier, I&apos;ll assume you have this enabled.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Check if the KVM modules are loaded (after installation step below):&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;lsmod | grep kvm
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You should see kvm_intel or kvm_amd listed.&lt;/p&gt;
&lt;h3&gt;Install Libvirt&lt;/h3&gt;
&lt;p&gt;Ensure your package list is up-to-date:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt update
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You&apos;ll need the Libvirt daemon, the QEMU/KVM hypervisor, and management tools.&lt;/p&gt;
&lt;p&gt;The Libvirt package installation includes several components:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;qemu-kvm&lt;/code&gt;: The KVM hypervisor backend.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;libvirt-daemon-system&lt;/code&gt;: The main Libvirt daemon that runs as a system service.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;libvirt-clients&lt;/code&gt;: Command-line tools for managing Libvirt (like &lt;code&gt;virsh&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;code&gt;bridge-utils&lt;/code&gt;: Utilities for creating and managing network bridges (often needed for VM networking).&lt;/li&gt;
&lt;li&gt;&lt;code&gt;virtinst&lt;/code&gt;: Tools to create virtual machines (like &lt;code&gt;virt-install&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;code&gt;virt-manager&lt;/code&gt;: (Optional, but Recommended) A graphical user interface for managing VMs.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt install qemu-kvm libvirt-daemon-system libvirt-clients bridge-utils virtinst virt-manager
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This command installs all the essential components, including the graphical &lt;code&gt;virt-manager&lt;/code&gt;. If you are setting up a headless server, you can omit &lt;code&gt;virt-manager&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;Add Your User to the &lt;code&gt;libvirt&lt;/code&gt; Group&lt;/h3&gt;
&lt;p&gt;By default, only the &lt;code&gt;root&lt;/code&gt; user can manage system-wide Libvirt virtual machines. To allow your regular user account to manage VMs without using &lt;code&gt;sudo&lt;/code&gt; for every command, add it to the &lt;code&gt;libvirt&lt;/code&gt; group.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo adduser &amp;#x3C;your_username&gt; libvirt
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Replace &lt;code&gt;&amp;#x3C;your_username&gt;&lt;/code&gt; with your actual username.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; You need to &lt;strong&gt;log out and log back in&lt;/strong&gt; for this group change to take effect. Alternatively, you can activate the group membership for your current shell session using &lt;code&gt;newgrp libvirt&lt;/code&gt; (but logging out/in is generally recommended).&lt;/p&gt;
&lt;h3&gt;Verify the Installation&lt;/h3&gt;
&lt;p&gt;Check the Libvirt daemon status by executing the following command:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo systemctl status libvirtd
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It should show as &lt;code&gt;active (running)&lt;/code&gt;. If not, try starting and enabling it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo systemctl start libvirtd
sudo systemctl enable libvirtd
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And check Libvirt connection (as your user, after logging back in):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;virsh list --all
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This command should run without errors (even if it shows an empty list of VMs). If you get a permission error, double-check that you&apos;ve logged out and back in after adding your user to the &lt;code&gt;libvirt&lt;/code&gt; group.&lt;/p&gt;
&lt;h3&gt;Create a Virtual Machine&lt;/h3&gt;
&lt;p&gt;First, download the ISO image for the OS you want to install. For this tutorial, I will use Ubuntu 24.04 Server. You can download it from the &lt;a href=&quot;https://ubuntu.com/download/server&quot;&gt;official Ubuntu website&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I will recommend using the &lt;code&gt;virt-manager&lt;/code&gt; GUI for creating and managing VMs, as it simplifies the process significantly. However, if you prefer command-line tools, you can use &lt;code&gt;virt-install&lt;/code&gt;.
To simplify the process, I will use &lt;code&gt;virt-manager&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;Launching &lt;code&gt;virt-manager&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;To launch &lt;code&gt;virt-manager&lt;/code&gt;, run the following command in your terminal:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;virt-manager
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This will open the graphical interface for managing virtual machines. And the experience is quite straightforward, so I won&apos;t go into detail here. Just follow the prompts to create a new VM.&lt;/p&gt;
&lt;h2&gt;Accessing VM through &lt;code&gt;virsh console&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;The &lt;code&gt;virsh console&lt;/code&gt; command connects you to a &lt;em&gt;serial console&lt;/em&gt; device that libvirt exposes to the virtual machine. For this to work bidirectional (input and output), two things need to be properly configured:&lt;/p&gt;
&lt;h3&gt;Virsh console&lt;/h3&gt;
&lt;p&gt;In the Virtual Machine&apos;s Libvirt XML, tt needs to have a &lt;code&gt;&amp;#x3C;console type=&apos;pty&apos;&gt;&lt;/code&gt; or similar device defined, connected to a serial port (like &lt;code&gt;target port=&apos;0&apos;&lt;/code&gt;). You can double-check this by running &lt;code&gt;virsh dumpxml ubuntu24.04&lt;/code&gt; and looking within the &lt;code&gt;&amp;#x3C;devices&gt;&lt;/code&gt; section for a &lt;code&gt;&amp;#x3C;console&gt;&lt;/code&gt; or &lt;code&gt;&amp;#x3C;serial&gt;&lt;/code&gt; entry.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;&amp;#x3C;serial type=&apos;pty&apos;&gt;
  &amp;#x3C;source path=&apos;/dev/pts/3&apos;/&gt;
  &amp;#x3C;target type=&apos;isa-serial&apos; port=&apos;0&apos;&gt;
    &amp;#x3C;model name=&apos;isa-serial&apos;/&gt;
  &amp;#x3C;/target&gt;
  &amp;#x3C;alias name=&apos;serial0&apos;/&gt;
&amp;#x3C;/serial&gt;
&amp;#x3C;console type=&apos;pty&apos; tty=&apos;/dev/pts/3&apos;&gt;
  &amp;#x3C;source path=&apos;/dev/pts/3&apos;/&gt;
  &amp;#x3C;target type=&apos;serial&apos; port=&apos;0&apos;/&gt;
  &amp;#x3C;alias name=&apos;serial0&apos;/&gt;
&amp;#x3C;/console&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If this is missing, you&apos;ll need to add it using &lt;code&gt;virsh edit ubuntu24.04&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;Inside the Guest VM&lt;/h3&gt;
&lt;p&gt;Edit the GRUB configuration:&lt;/p&gt;
&lt;p&gt;Open the GRUB default file in a text editor:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo nano /etc/default/grub
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Find the line that starts with &lt;code&gt;GRUB_CMDLINE_LINUX_DEFAULT&lt;/code&gt;. It might look something like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;GRUB_CMDLINE_LINUX_DEFAULT=&quot;quiet splash&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You need to add console redirection parameters. Add &lt;code&gt;console=tty0 console=ttyS0,115200&lt;/code&gt;.
_ &lt;code&gt;console=tty0&lt;/code&gt;: Ensures output also goes to the primary virtual console (if you still have one, which you likely do for initial setup).
_ &lt;code&gt;console=ttyS0,115200&lt;/code&gt;: Directs kernel and boot messages to the first serial port (&lt;code&gt;ttyS0&lt;/code&gt;) at a baud rate of 115200. This corresponds to the &lt;code&gt;port=&apos;0&apos;&lt;/code&gt; in the libvirt XML.&lt;/p&gt;
&lt;p&gt;The line should become something like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;GRUB_CMDLINE_LINUX_DEFAULT=&quot;quiet splash console=tty0 console=ttyS0,115200&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you already have other parameters in this line, just add the &lt;code&gt;console=...&lt;/code&gt; parts inside the quotes, separated by spaces.&lt;/p&gt;
&lt;p&gt;Enable a Serial Getty Service:&lt;/p&gt;
&lt;p&gt;Ubuntu uses &lt;code&gt;systemd&lt;/code&gt; to manage services. You need to enable the service that provides a login prompt on the serial port.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo systemctl enable serial-getty@ttyS0.service
sudo systemctl start serial-getty@ttyS0.service
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;enable&lt;/code&gt; command ensures it starts on boot, and &lt;code&gt;start&lt;/code&gt; attempts to start it immediately.&lt;/p&gt;
&lt;p&gt;After editing the GRUB configuration file, you must update the GRUB bootloader:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo update-grub
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Also update Initramfs (necessary for console changes to take full effect early in boot):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo update-initramfs -u
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And remember to reboot your VM, and it should now be accessible via the &lt;code&gt;virsh console&lt;/code&gt; command.&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>CS188 Notes 4 - Reinforcement Learning</title><link>https://theunknownth.ing/blog/cs188-notes-4</link><guid isPermaLink="true">https://theunknownth.ing/blog/cs188-notes-4</guid><description>Notes from UC Berkeley&apos;s CS188 course on Artificial Intelligence.</description><pubDate>Sun, 20 Apr 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;Note:&lt;/h2&gt;
&lt;p&gt;You could view previous notes on &lt;a href=&quot;/blog/cs188-notes-3&quot;&gt;CS188: Lecture 9 - Markov Decision Processes (MDPs)&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Also note that my notes are based on the &lt;strong&gt;Spring 2025&lt;/strong&gt; version of the course, and my understanding of the material. So they MAY NOT be 100% accurate or complete. Also, THIS IS NOT A SUBSTITUTE FOR THE COURSE MATERIAL. I would only take notes on parts of the lecture that I find interesting or confusing. I will NOT be taking notes on every single detail of the lecture.&lt;/p&gt;
&lt;h2&gt;Reinforcement Learning&lt;/h2&gt;
&lt;p&gt;In this note I will go through the key concepts in the Reinforcement Learning (RL) lecture. I will also try to clarify my understanding of the Q-learning algorithm, which is a key concept in RL.&lt;/p&gt;
&lt;p&gt;First let&apos;s categorize the topics. I&apos;ll use the same categories as in the lecture slides also adding some of my own notes.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Passive Learning
&lt;ul&gt;
&lt;li&gt;Model-based&lt;/li&gt;
&lt;li&gt;Model-free&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Active Learning&lt;/li&gt;
&lt;li&gt;Approximate Q-learning&lt;/li&gt;
&lt;li&gt;Policy Gradient&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;ONE SENTENCE SUMMARY:
Passive learning involves evaluating a fixed policy (likely human will control it), while active learning seeks to improve the policy through exploration (likely model itself would operate); model-based methods use environment models, model-free methods learn directly from experience, approximate Q-learning generalizes learning to large state spaces, and policy gradient methods optimize policies directly using gradient ascent.&lt;/p&gt;
&lt;p&gt;I believe this is a good summary of the key concepts in RL. I will go through each of these categories in detail below. Also, I will use the structure of &quot;HOW? -&gt; WHY? -&gt; PROBLEM&quot; to explain each concept.&lt;/p&gt;
&lt;h2&gt;Passive RL&lt;/h2&gt;
&lt;h3&gt;Model-based&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;How?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The agent learns a model of the environment (e.g., transition probabilities, rewards) and uses this model to evaluate the policy. This is done by estimating the expected value of each action in each state based on the model.&lt;/p&gt;
&lt;p&gt;Then Solve for values as if the learned model were correct. (Trust the model)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Answering &quot;why&quot; in this section is basically answering &quot;why do we need a model?&quot; The answer is that we do not have a model of the environment, so we need to learn it. This is done by estimating the transition probabilities and rewards based on the observed data.
This is a key concept in RL, as it allows the agent to learn from its experiences and improve its policy over time.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The problem with this approach is that it requires a lot of data to learn the model accurately. If the model is not accurate, the agent may make suboptimal decisions based on the learned model.&lt;/p&gt;
&lt;h3&gt;Model-free&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;How?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;In this case there are no models to guide us &quot;what to do&quot;. We need to learn the value function directly from the data.&lt;/p&gt;
&lt;p&gt;The simplest thought is to &lt;strong&gt;Average together observed sample values&lt;/strong&gt;. Every time you visit a state, write down what the sum of discounted rewards turned out to be, and average it out. But what&apos;s bad about this is that it do not take account of state connections. For example, there is a graph A -&gt; B -&gt; C (end). How to calculate $V$ for state $A$ and $B$? We would evaluate every single starting state separately, for example, when evaluating A, we would NOT take the previous evaluation of B in to account, it only cares the final outcome and to average it. This is not a good idea, because we are wasting a lot of data. We could use the data from state B to help us evaluate state A. So we need to take into account the connections between states.&lt;/p&gt;
&lt;p&gt;So an evolution of this is to use the &lt;strong&gt;Bellman equation&lt;/strong&gt;. The idea is to use the value of the next state to help us evaluate the current state. This is done by using the Bellman equation similar to the one we used in the MDP lecture. However, we need modifications for this.&lt;/p&gt;
&lt;p&gt;The ORIGINAL Bellman equation is:&lt;/p&gt;
&lt;p&gt;$$
V(s) = \sum_{s&apos;} T(s, a, s&apos;)[R(s, a, s&apos;) + \gamma V(s&apos;)]
$$&lt;/p&gt;
&lt;p&gt;And its ADAPTED version is:&lt;/p&gt;
&lt;p&gt;$$
V(s) = \frac{1}{n}\sum \mathrm{sample}&lt;em&gt;{s&apos;} \quad \text{where} \ \mathrm{sample}&lt;/em&gt;{s&apos;} = R(s, a, s&apos;) + \gamma V(s&apos;)
$$&lt;/p&gt;
&lt;p&gt;What&apos;s improved from the naive version is that we are utilizing the existing data to evaluate. However, as we notice that there are problems with this:
We are waiting until the end of an episode to update values as we are using the average of all samples. We could &lt;strong&gt;update values more frequently&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;So this is where the &lt;strong&gt;Temporal Difference (TD) Learning&lt;/strong&gt; comes in. The idea is to update the value of the current state based on the value of the next state, without waiting for the end of the episode.
Because updates happen after every transition, states and transitions that are experienced more frequently will have a greater influence on the learned values over time.&lt;/p&gt;
&lt;p&gt;The specific type of TD learning shown here is for &lt;strong&gt;policy evaluation&lt;/strong&gt;. This means we have a &lt;em&gt;fixed policy&lt;/em&gt; &lt;code&gt;π&lt;/code&gt; (a fixed way of choosing actions in each state), and we want to figure out the value function &lt;code&gt;Vπ(s)&lt;/code&gt; for that policy. We are &lt;em&gt;not&lt;/em&gt; trying to find the &lt;em&gt;best&lt;/em&gt; policy yet, just evaluating the current one.&lt;/p&gt;
&lt;p&gt;In TD, we have samples, and the update rule.&lt;/p&gt;
&lt;p&gt;$$
\mathrm{sample} = R(s, \pi(s), s&apos;) + γV^\pi(s&apos;)
$$&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;sample&lt;/code&gt; (or TD Target) is: &quot;the reward I just got, plus the discounted value of where I landed (according to my current beliefs)&quot;.&lt;/p&gt;
&lt;p&gt;The update rule is:&lt;/p&gt;
&lt;p&gt;$$
V^\pi(s) \leftarrow (1 - α)V^\pi(s) + α \ \mathrm{sample}
$$&lt;/p&gt;
&lt;p&gt;We calculate the &lt;strong&gt;TD Error&lt;/strong&gt;: &lt;code&gt;sample - Vπ(s)&lt;/code&gt;. This error represents the difference between our target (&lt;code&gt;sample&lt;/code&gt;) and our current estimate (&lt;code&gt;Vπ(s)&lt;/code&gt;). We then adjust our current estimate &lt;code&gt;Vπ(s)&lt;/code&gt; by moving it a small step (&lt;code&gt;α&lt;/code&gt;) in the direction of that error.&lt;/p&gt;
&lt;p&gt;It shows that TD learning is essentially maintaining a running average of the TD targets it observes for each state.
It gradually &quot;forgets&quot; older, potentially less accurate, information because initial value estimates might be far off.
Using a decreasing learning rate &lt;code&gt;α&lt;/code&gt; over time can help the value estimates converge more stably.&lt;/p&gt;
&lt;p&gt;However, there are still problems. Mentioned in the previous lecture, what really GUIDES the agent is the $Q$-values. So we need to learn the $Q$-values instead of the $V$-values.&lt;/p&gt;
&lt;h2&gt;Q-Learning (Active RL)&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;How?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Q-Learning is a model-free reinforcement learning algorithm used to learn the optimal action-value function (Q-values). Unlike TD learning which focuses on state values, Q-learning focuses on (state, action) pairs.&lt;/p&gt;
&lt;p&gt;The Q-learning update rule is:&lt;/p&gt;
&lt;p&gt;$$
Q(s, a) \leftarrow Q(s, a) + \alpha \left[ r + \gamma \max_{a&apos;} Q(s&apos;, a&apos;) - Q(s, a) \right]
$$&lt;/p&gt;
&lt;p&gt;Similar to above, where: $Q(s, a)$ is the current estimate of the Q-value for state $s$ and action $a$, $\alpha$ is the learning rate, and the term $r + \gamma \max_{a&apos;} Q(s&apos;, a&apos;) - Q(s, a)$ is the TD error. You might wonder &quot;why do we need to use the max operator here?&quot; The answer is that we are trying to learn the optimal Q-value for each state-action pair. The max operator allows us to select the best action in the next state $s&apos;$ based on the current Q-values.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Q-learning allows us to select the best action in each state, unlike TD learning which only evaluates a fixed policy. It&apos;s called &quot;off-policy&quot; because it learns the optimal policy regardless of how the agent is currently behaving (exploration). The agent can follow any exploratory policy during training while still learning the greedy optimal policy.&lt;/p&gt;
&lt;p&gt;With Q-values, we can derive our policy directly:&lt;/p&gt;
&lt;p&gt;$$
\pi(s) = \arg\max_a Q(s, a)
$$&lt;/p&gt;
&lt;p&gt;This means choosing the action that maximizes the expected future rewards for each state.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The main challenge in Q-learning is balancing exploration and exploitation, i.e., balancing the behaviour that &quot;Trying new actions to discover potentially better rewards&quot; and &quot;Using known Q-values to maximize rewards based on past experience&quot;&lt;/p&gt;
&lt;p&gt;This is typically addressed using an &lt;strong&gt;$\epsilon$-greedy policy&lt;/strong&gt; or &lt;strong&gt;exploration functions&lt;/strong&gt;:&lt;/p&gt;
&lt;p&gt;The $\epsilon$-greedy policy works as follows:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;With probability $1-\epsilon$, choose the best action (exploit)&lt;/li&gt;
&lt;li&gt;With probability $\epsilon$, choose a random action (explore)&lt;/li&gt;
&lt;li&gt;Gradually decrease $\epsilon$ over time to favor exploitation as learning progresses&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And the exploration function works as follows:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Define &quot;exploration bonus&quot; based on the uncertainty of Q-values. Let $n$ be the number of times action $a$ has been taken in state $s$. The exploration bonus can be defined as $\frac{1}{n(s, a)}$.&lt;/li&gt;
&lt;li&gt;When choosing actions, add the exploration bonus to the Q-value: $Q(s, a) + \frac{1}{n(s, a)}$.&lt;/li&gt;
&lt;li&gt;This encourages the agent to explore less frequently visited actions, balancing exploration and exploitation.&lt;/li&gt;
&lt;li&gt;Gradually decrease the exploration bonus over time to favor exploitation as learning progresses&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This approach can be more efficient than $\epsilon$-greedy, as it focuses exploration on less certain actions rather than uniformly random actions. So it IS used in practice.&lt;/p&gt;
&lt;h3&gt;Experience Replay&lt;/h3&gt;
&lt;p&gt;Experience replay is a optimization technique used in reinforcement learning, particularly in deep Q-learning. So I&apos;ll add it as a subtopic here.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Experience replay enhances Q-learning by storing the agent&apos;s experiences (transitions) in a replay buffer. Instead of updating Q-values using only the most recent experience, the agent &lt;strong&gt;stores the recent experience to buffer, and randomly samples batches of past experiences&lt;/strong&gt; from this buffer for training.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;In Q-learning, the agent learns from its experiences sequentially. This can lead to correlations between consecutive experiences, making learning inefficient. By using experience replay, the agent can break these correlations and learn from a more diverse set of experiences. Consecutive experiences are often similar, making learning inefficient. Random sampling creates more independent training examples.&lt;/p&gt;
&lt;p&gt;This is especially important in deep reinforcement learning where neural networks are used to approximate Q-values.&lt;/p&gt;
&lt;p&gt;With all the problems addressed above, we still could not put the Q-learning algorithm into practice. The problem is that the state space is too large. We cannot store the Q-values for every single state-action pair. So we need to use function approximation to generalize across similar states, and this is where &lt;strong&gt;Approximate Q-Learning&lt;/strong&gt; comes in.&lt;/p&gt;
&lt;h2&gt;Hold on a second&lt;/h2&gt;
&lt;p&gt;But before that, I do believe I need to clarify some points here.&lt;/p&gt;
&lt;p&gt;You might think: &quot;Why Q-Learning is discussed in active learning? Q-learning could be used in passive learning, while TD could also be used in active learning, is that correct?&quot;&lt;/p&gt;
&lt;p&gt;Yes, you are right. In CS188 (and many RL courses), the algorithms are typically presented in this order:&lt;/p&gt;
&lt;p&gt;TD Learning is introduced first as a way to learn value functions for passive learning&lt;/p&gt;
&lt;p&gt;Q-Learning is introduced next as a way to extend these ideas to active learning&lt;/p&gt;
&lt;p&gt;This pedagogical approach sometimes creates the impression that these algorithms are strictly tied to their respective learning categories, but they&apos;re more flexible than that.&lt;/p&gt;
&lt;p&gt;The main difference is that TD learning (as typically presented) learns state values V(s) while Q-learning learns state-action values Q(s,a). Q-values naturally lend themselves to policy improvement (just take argmax), which is why Q-learning is often presented in the active learning context.&lt;/p&gt;
&lt;h2&gt;Approximate Q-Learning&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;How?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;In environments with large or continuous state spaces, it&apos;s impractical to maintain a separate Q-value for each state-action pair. Approximate Q-learning uses function approximation to generalize across similar states.&lt;/p&gt;
&lt;p&gt;Simple solution is that recall the &quot;feature function&quot; that we discussed in the game tree lecture. We describe a state using a vector of features (properties) $f_1, f_2, \ldots, f_n$ and learn a linear function of these features:&lt;/p&gt;
&lt;p&gt;$$
Q(s, a) = w_1 f_1(s, a) + w_2 f_2(s, a) + \ldots + w_n f_n(s, a)
$$&lt;/p&gt;
&lt;p&gt;Where $w_1, w_2, \ldots, w_n$ are weights that we learn through experience.
This is a linear function approximation. We can also use non-linear function approximators like neural networks, but the basic idea is the same: learn a function that maps states (and actions) to Q-values.&lt;/p&gt;
&lt;p&gt;And you might wonder: &quot;How to learn the weights?&quot; The answer is that we can use the same Q-learning update rule, but instead of updating the Q-value directly, we update the weights using some tricks. This tricks is a simple notion of &quot;if something unexpectedly bad happens, blame the features that were on: disprefer all states with that state’s features&quot;.&lt;/p&gt;
&lt;p&gt;So the update rule becomes:&lt;/p&gt;
&lt;p&gt;$$
w_i \leftarrow w_i + \alpha \left[ r + \gamma \max_{a&apos;} Q(s&apos;, a&apos;) - Q(s, a) \right] f_i(s, a)
$$&lt;/p&gt;
&lt;p&gt;Where $f_i(s, a)$ is the value of the $i$-th feature for state $s$ and action $a$. This means we are updating the weights based on the features that were present in the current state-action pair.&lt;/p&gt;
&lt;p&gt;The update rule of $Q$ is still the same:&lt;/p&gt;
&lt;p&gt;$$
Q(s, a) \leftarrow Q(s, a) + \alpha \ \mathrm{Difference}
$$&lt;/p&gt;
&lt;p&gt;Where $\mathrm{Difference} = r + \gamma \max_{a&apos;} Q(s&apos;, a&apos;) - Q(s, a)$&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Approximate Q-learning allows reinforcement learning to scale to complex environments with huge state spaces (like Atari games, robotics, etc.) where tabular methods would be impossible.&lt;/p&gt;
&lt;p&gt;It enables generalization across similar states, so learning in one state can improve performance in similar states, even those the agent hasn&apos;t encountered yet.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Approximate Q-learning faces these two challenges:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Forgetting - learning in one region of the state space can undo learning in another region&lt;/li&gt;
&lt;li&gt;Feature selection - choosing the right representation for states is critical for good generalization&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Policy Gradient Methods&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;How?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Instead of learning a value function and deriving a policy from it, policy gradient methods directly parameterize the policy itself. That is, the agent&apos;s behavior is described by a function $\pi(a|s; \theta)$, where $\theta$ are the parameters (often the weights of a neural network). The goal is to adjust $\theta$ so that the expected return (the sum of rewards) is maximized.&lt;/p&gt;
&lt;p&gt;The core idea is to use gradient ascent: we estimate how changing the parameters would affect the expected return, and then nudge the parameters in that direction. The update rule looks like this:&lt;/p&gt;
&lt;p&gt;$$
\theta \leftarrow \theta + \alpha \nabla_\theta J(\theta)
$$&lt;/p&gt;
&lt;p&gt;where $J(\theta)$ is the expected return under the current policy.&lt;/p&gt;
&lt;p&gt;But how do we compute this gradient? The answer is the &lt;strong&gt;policy gradient theorem&lt;/strong&gt;, which tells us that the gradient of the expected return can be estimated using samples from the environment:&lt;/p&gt;
&lt;p&gt;$$
\nabla_\theta J(\theta) \approx \mathbb{E}&lt;em&gt;{\pi&lt;/em&gt;\theta} \left[ \nabla_\theta \log \pi_\theta(a|s) \cdot G \right]
$$&lt;/p&gt;
&lt;p&gt;Here, $G$ is the return (sum of discounted rewards) following the action $a$ in state $s$. In practice, we run episodes, collect rewards, and use these samples to estimate the gradient.&lt;/p&gt;
&lt;p&gt;This approach is called &lt;strong&gt;REINFORCE&lt;/strong&gt;, the simplest policy gradient algorithm. Each time the agent takes an action, it computes the gradient of the log-probability of that action, multiplies it by the return, and uses that as the update direction.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Policy gradient methods are powerful for several reasons. First, they allow us to optimize the policy directly, which is what we ultimately care about. This is especially useful in environments with continuous or high-dimensional action spaces, where value-based methods struggle. Policy gradients can also learn stochastic policies, which can be optimal in environments with inherent randomness or partial observability.&lt;/p&gt;
&lt;p&gt;Another advantage is that policy gradient methods can be combined with function approximation (e.g., neural networks) to handle very large or continuous state spaces. This is the foundation of modern deep reinforcement learning algorithms.&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>CS188 Notes 2 - Markov Decision Processes (MDPs)</title><link>https://theunknownth.ing/blog/cs188-notes-2</link><guid isPermaLink="true">https://theunknownth.ing/blog/cs188-notes-2</guid><description>Notes from UC Berkeley&apos;s CS188 course on Artificial Intelligence.</description><pubDate>Sat, 19 Apr 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;Note:&lt;/h2&gt;
&lt;p&gt;You could view previous notes on &lt;a href=&quot;/blog/cs188-notes-1&quot;&gt;CS188: Lecture 4 - Constraint Satisfaction Problems (CSPs)&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Also note that my notes are based on the &lt;strong&gt;Spring 2025&lt;/strong&gt; version of the course, and my understanding of the material. So they MAY NOT be 100% accurate or complete. Also, THIS IS NOT A SUBSTITUTE FOR THE COURSE MATERIAL. I would only take notes on parts of the lecture that I find interesting or confusing. I will NOT be taking notes on every single detail of the lecture.&lt;/p&gt;
&lt;h2&gt;CS188: Lecture 8 - Markov Decision Processes (MDPs)&lt;/h2&gt;
&lt;h3&gt;Markov Decision Processes (MDPs)&lt;/h3&gt;
&lt;p&gt;A Markov Decision Process (MDP) represents sequential decision-making in environments where actions produce stochastic (random) outcomes, and an agent&apos;s goal is to maximize its cumulative reward over time. In an MDP, the agent faces uncertainty: it cannot always predict the result of its actions, but it must still try to act optimally.&lt;/p&gt;
&lt;p&gt;The key components of an MDP are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;States&lt;/strong&gt; $S$: Possible situations the agent can find itself in.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Actions&lt;/strong&gt; $A$: The set of possible moves or decisions the agent can make in each state.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Transition Function&lt;/strong&gt; $T(s, a, s&apos;)$: The probability that action $a$ in state $s$ leads to state $s&apos;$.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reward Function&lt;/strong&gt; $R(s, a, s&apos;)$: The reward received after transitioning from $s$ to $s&apos;$ via action $a$.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Discount Factor&lt;/strong&gt; $\gamma$: How much the agent values future rewards compared to immediate rewards.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;We yield the value function $V(s)$ for each &lt;strong&gt;state&lt;/strong&gt; $s$, which represents the expected cumulative reward starting from state $s$ and following the optimal policy thereafter. And the action-value function $Q(s, a)$ for each &lt;strong&gt;action state&lt;/strong&gt; $(s,a)$, which represents the expected cumulative reward starting from state $s$, taking action $a$, and then following the optimal policy thereafter.&lt;/p&gt;
&lt;p&gt;You might think &quot;why not just use the value function $V(s)$?&quot; The reason is actions are easier to select from $Q$-values than values! You will see this in the following part of this lecture.&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;goal&lt;/strong&gt; is to find an optimal &lt;strong&gt;policy&lt;/strong&gt; $\pi^*$, which is a mapping from states to actions ($\pi(s) = a$), maximizing the expected cumulative (usually discounted) reward from any state. In this sense, an MDP defines both the &quot;game rules&quot; and what it means to &quot;play well&quot; in that environment.&lt;/p&gt;
&lt;h4&gt;Stationary Preferences&lt;/h4&gt;
&lt;p&gt;The assumption of &lt;strong&gt;stationary preferences&lt;/strong&gt; means that your relative preference between two future sequences of rewards doesn&apos;t change just because you receive the same immediate reward before both. This property imposes a recursive structure on the utility function for reward sequences.&lt;/p&gt;
&lt;p&gt;Formally, the utility $U$ of a sequence $[r_0, r_1, r_2, ...]$ must satisfy:&lt;/p&gt;
&lt;p&gt;$$
U([r_0, r_1, r_2, ...]) = f(r_0, U([r_1, r_2, ...]))
$$&lt;/p&gt;
&lt;p&gt;where $f$ is some consistent function. If we assume $f$ is linear, this recursion unrolls to only two possible forms for the utility function (after appropriate normalization):&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Additive Utility:&lt;/strong&gt; $U = r_0 + r_1 + r_2 + \cdots$ (corresponds to $\gamma = 1$)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Discounted Utility:&lt;/strong&gt; $U = r_0 + \gamma r_1 + \gamma^2 r_2 + \cdots$ (where $0 \leq \gamma &amp;#x3C; 1$)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The discounted utility is the standard in MDPs, as it ensures convergence for infinite horizons and reflects the diminishing importance of rewards further in the future.&lt;/p&gt;
&lt;h4&gt;Why MDPs?&lt;/h4&gt;
&lt;p&gt;MDPs are particularly suitable when:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The environment is &lt;strong&gt;stochastic&lt;/strong&gt;: the same action in the same state can yield different results.&lt;/li&gt;
&lt;li&gt;Rewards may be &lt;strong&gt;delayed&lt;/strong&gt;: the value of an action now may be realized only after multiple future steps.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Unlike simple search algorithms (e.g., greedy or expectimax), MDPs explicitly model both uncertainty (via $T$) and the accumulation of rewards over time (via $R$ and $\gamma$). While solving an MDP requires knowledge of $T$ and $R$, reinforcement learning (RL) methods learn optimal policies directly from experience, using the MDP framework as a theoretical foundation.&lt;/p&gt;
&lt;h4&gt;MDPs vs Expectimax&lt;/h4&gt;
&lt;p&gt;Both MDPs and expectimax handle uncertainty and aim for maximum expected utility. Expectimax, however, is typically used to compute the expected value of actions from a specific starting point, often with a tree structure and a finite horizon. MDPs, in contrast, compute a &lt;strong&gt;policy&lt;/strong&gt;—the best action for every possible state—naturally handling cycles and infinite (discounted) horizons.&lt;/p&gt;
&lt;p&gt;In short: expectimax is a limited lookahead from the current state; solving an MDP finds a full strategy for all states.&lt;/p&gt;
&lt;h4&gt;MDPs and Multi-Agent Games&lt;/h4&gt;
&lt;p&gt;Standard MDPs are designed for a &lt;strong&gt;single agent&lt;/strong&gt; interacting with a stochastic environment. They do not directly accommodate multiple strategic agents whose actions affect each other&apos;s outcomes. Multi-agent situations typically require other formalisms, such as stochastic games or Markov games.&lt;/p&gt;
&lt;h4&gt;MDPs vs Greedy Search&lt;/h4&gt;
&lt;p&gt;Greedy algorithms make decisions based solely on immediate rewards, without considering long-term consequences. MDPs, by calculating the expected sum of (possibly discounted) future rewards, are inherently long-sighted. Optimizing for the value function $V^&lt;em&gt;(s)$ or the action-value function $Q^&lt;/em&gt;(s,a)$, MDPs look ahead through the space of future possibilities, not just the next step.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;ONE SENTENCE SUMMARY:&lt;/strong&gt;&lt;br&gt;
Markov Decision Processes are mathematical models for sequential decision-making under uncertainty, aiming to find policies that maximize expected (possibly discounted) cumulative reward, and forming the theoretical foundation for reinforcement learning.&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>CS188 Notes 3 - Markov Decision Processes (MDPs) II</title><link>https://theunknownth.ing/blog/cs188-notes-3</link><guid isPermaLink="true">https://theunknownth.ing/blog/cs188-notes-3</guid><description>Notes from UC Berkeley&apos;s CS188 course on Artificial Intelligence.</description><pubDate>Sat, 19 Apr 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;Note:&lt;/h2&gt;
&lt;p&gt;You could view previous notes on &lt;a href=&quot;/blog/cs188-notes-2&quot;&gt;CS188: Lecture 8 - Markov Decision Processes (MDPs)&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Also note that my notes are based on the &lt;strong&gt;Spring 2025&lt;/strong&gt; version of the course, and my understanding of the material. So they MAY NOT be 100% accurate or complete. Also, THIS IS NOT A SUBSTITUTE FOR THE COURSE MATERIAL. I would only take notes on parts of the lecture that I find interesting or confusing. I will NOT be taking notes on every single detail of the lecture.&lt;/p&gt;
&lt;h2&gt;Markov Decision Processes (MDPs)&lt;/h2&gt;
&lt;p&gt;After the previous lecture, I realized I had some misunderstandings about the Policy Iteration algorithm, especially when compared to Value Iteration. So here, I&apos;ll clarify my understanding of these two core approaches for solving MDPs.&lt;/p&gt;
&lt;h3&gt;Why use a &quot;fixed policy&quot; in Policy Iteration?&lt;/h3&gt;
&lt;p&gt;It can be confusing at first that Policy Iteration evaluates a fixed policy. You might ask: does using a fixed, possibly non-optimal policy ever lead to the optimal one?&lt;/p&gt;
&lt;p&gt;The answer is that evaluating a fixed policy is an essential &lt;em&gt;intermediate&lt;/em&gt; step towards finding the optimal policy. We might &quot;evaluate&quot; a policy that is not optimal, but we it yields valuable information about the expected future rewards of that policy, so finnaly what we act on is the optimal policy.&lt;/p&gt;
&lt;p&gt;In Policy Iteration, we loop between two key phases:&lt;/p&gt;
&lt;h4&gt;&lt;strong&gt;Step 1: Policy Evaluation&lt;/strong&gt;&lt;/h4&gt;
&lt;p&gt;We begin with an initial policy $\pi$ (random, greedy, whatever). For this $\pi$, we compute the exact utility $V^{\pi}(s)$ for each state $s$ under the assumption that we &lt;em&gt;always&lt;/em&gt; follow $\pi$. The Bellman equation for this is:&lt;/p&gt;
&lt;p&gt;$$
V^{\pi}(s) = \sum_{s&apos;} T(s, \pi(s), s&apos;) [ R(s, \pi(s), s&apos;) + \gamma V^{\pi}(s&apos;) ]
$$&lt;/p&gt;
&lt;p&gt;This evaluates the policy&apos;s long-term value at every state, given that policy.&lt;/p&gt;
&lt;h4&gt;&lt;strong&gt;Step 2: Policy Improvement&lt;/strong&gt;&lt;/h4&gt;
&lt;p&gt;Now that we have $V^{\pi}$, we look at each state $s$ and ask: &quot;Is there an action $a$ that would improve my expected future rewards if I took it immediately, then continued with $\pi$?&quot;&lt;/p&gt;
&lt;p&gt;For each state, we consider:&lt;/p&gt;
&lt;p&gt;$$
Q^{\pi}(s, a) = \sum_{s&apos;} T(s, a, s&apos;) [ R(s, a, s&apos;) + \gamma V^{\pi}(s&apos;) ]
$$&lt;/p&gt;
&lt;p&gt;We then build a new policy by setting:&lt;/p&gt;
&lt;p&gt;$$
\pi_{\text{new}}(s) = \arg\max_a Q^{\pi}(s, a)
$$&lt;/p&gt;
&lt;p&gt;That is, for each state, choose the action that looks best based on the values under the old policy. This is the &lt;em&gt;policy improvement&lt;/em&gt; step.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Repeat:&lt;/strong&gt; We now re-evaluate the new policy $\pi_{\text{new}}$, and the process continues until the policy stops changing. This guarantees convergence to the optimal policy $\pi^&lt;em&gt;$ and optimal value function $V^&lt;/em&gt;$. Evaluating a fixed policy at each stage is essential for knowing both how good our current strategy is and how to improve it.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;What is the difference between Policy Iteration and Value Iteration?&lt;/h2&gt;
&lt;p&gt;In short:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Value Iteration&lt;/strong&gt; is always searching for the best action at each step, directly refining the estimate of the &lt;em&gt;optimal&lt;/em&gt; value function.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Policy Evaluation&lt;/strong&gt; (as used in Policy Iteration) simply calculates the consequences of following a &lt;em&gt;predefined&lt;/em&gt; plan $\pi$, without improvement during evaluation itself. Policy improvement occurs as a separate step.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Let&apos;s break down the differences in detail.&lt;/p&gt;
&lt;h3&gt;&lt;strong&gt;Value Iteration Equation:&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;$$
V_{k+1}(s) = \max_{a} \sum_{s&apos;} T(s, a, s&apos;) [ R(s, a, s&apos;) + \gamma V_{k}(s&apos;) ]
$$&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Goal:&lt;/strong&gt; Directly compute the optimal value function $V^*(s)$.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;How:&lt;/strong&gt; Each iteration, for each state $s$, considers all possible actions $a$. For each action, it calculates the expected value (reward + discounted future value), then takes the maximum over all actions.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Policy:&lt;/strong&gt; Implicit. The $\max$ operation is finding the best action, and the final optimal policy $\pi^*$ is extracted after $V_k$ converges.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What it computes:&lt;/strong&gt; Iteratively refines the best possible long-term value from each state.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;&lt;strong&gt;Policy Evaluation Equation (for a fixed policy $\pi$):&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;$$
V^{\pi}&lt;em&gt;{k+1}(s) = \sum&lt;/em&gt;{s&apos;} T(s, \pi(s), s&apos;) [ R(s, \pi(s), s&apos;) + \gamma V^{\pi}_k(s&apos;) ]
$$&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Goal:&lt;/strong&gt; Compute the value function $V^\pi(s)$ for the given, fixed policy $\pi$ (which may not be optimal).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;How:&lt;/strong&gt; Each iteration, for each state $s$, uses only the action prescribed by $\pi$: $a = \pi(s)$. Calculates the expected value (reward + discounted future value) following this fixed action. There is no $\max$ because the action is predetermined by $\pi$.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Policy:&lt;/strong&gt; Explicit and fixed throughout evaluation.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3&gt;&lt;strong&gt;Comparison Table&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;| Feature           | Value Iteration (VI)                        | Policy Evaluation (PE for fixed $\pi$)                    |
| :---------------- | :------------------------------------------ | :-------------------------------------------------------- |
| Equation Core     | $\max_a \sum T(s,a,s&apos;)[R + \gamma V_k(s&apos;)]$ | $\sum T(s, \pi(s), s&apos;)[R + \gamma V^\pi_k(s&apos;)]$           |
| $\max_a$ Present? | &lt;strong&gt;Yes&lt;/strong&gt;                                     | &lt;strong&gt;No&lt;/strong&gt;                                                    |
| Action Choice     | Considers all $a$, picks the best           | Only the action $\pi(s)$ given by policy                  |
| Policy Role       | Policy is implicit (via $\max$)             | Policy is explicit and fixed                              |
| Goal              | Compute optimal value function $V^&lt;em&gt;$        | Compute value function $V^\pi$ for the given policy $\pi$ |
| Used Where?       | Standalone algorithm to find $V^&lt;/em&gt;$          | Subroutine within Policy Iteration                        |
| Convergence       | $V_k$ converges to $V^*$                    | $V^\pi_k$ converges to $V^\pi$                            |&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;Does Policy Evaluation converge after more iterations than Value Iteration?&lt;/h3&gt;
&lt;p&gt;It&apos;s tempting to think that Policy Evaluation takes more iterations to converge, since it does not optimize at every step, but in practice, Policy Iteration often &lt;strong&gt;converges in fewer&lt;/strong&gt; outer iterations (policy updates) than Value Iteration, though the work per iteration can differ.&lt;/p&gt;
&lt;p&gt;The real power of Policy Iteration comes after Policy Evaluation. Once we have $V^\pi$ for our current policy, we can often make a large jump to a better policy by improving all states at once:&lt;/p&gt;
&lt;p&gt;$$
\pi_{\text{new}}(s) = \arg\max_a \sum_{s&apos;} T(s, a, s&apos;) [ R(s, a, s&apos;) + \gamma V^\pi(s&apos;) ]
$$&lt;/p&gt;
&lt;p&gt;We only repeat this process until the policy stops changing, which often happens quickly and requires fewer overall iterations than Value Iteration.&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>CS188 Notes 1 - Constraint Satisfaction Problems (CSPs)</title><link>https://theunknownth.ing/blog/cs188-notes-1</link><guid isPermaLink="true">https://theunknownth.ing/blog/cs188-notes-1</guid><description>Notes from UC Berkeley&apos;s CS188 course on Artificial Intelligence.</description><pubDate>Fri, 18 Apr 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;Note:&lt;/h2&gt;
&lt;p&gt;This is a work in progress. I will be adding more notes and examples as I go through the course. The course is available on the &lt;a href=&quot;https://inst.eecs.berkeley.edu/~cs188/sp25/&quot;&gt;Berkeley website&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Note that my notes are based on the &lt;strong&gt;Spring 2025&lt;/strong&gt; version of the course, and my understanding of the material. So they MAY NOT be 100% accurate or complete. Also, THIS IS NOT A SUBSTITUTE FOR THE COURSE MATERIAL. I would only take notes on parts of the lecture that I find interesting or confusing. I will NOT be taking notes on every single detail of the lecture.&lt;/p&gt;
&lt;p&gt;I will begin my notes with Lec.4 (CSPs I) and continue from there.&lt;/p&gt;
&lt;h2&gt;CS188: Lecture 4 - Constraint Satisfaction Problems (CSPs)&lt;/h2&gt;
&lt;p&gt;The &lt;strong&gt;goal&lt;/strong&gt; is to find a &lt;strong&gt;complete assignment&lt;/strong&gt; (every variable has a value from its domain) such that &lt;strong&gt;all constraints&lt;/strong&gt; are satisfied. CSPs are a special kind of search problem where the path to the goal doesn&apos;t matter, only the final state.&lt;/p&gt;
&lt;h3&gt;Backtracking Search&lt;/h3&gt;
&lt;p&gt;The fundamental algorithm for solving CSPs systematically is &lt;strong&gt;Backtracking Search&lt;/strong&gt;. It works as follows:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Start with an empty assignment.&lt;/li&gt;
&lt;li&gt;Select an unassigned variable.&lt;/li&gt;
&lt;li&gt;Try assigning a value from its domain.&lt;/li&gt;
&lt;li&gt;Check if this assignment violates any constraints with already assigned variables.
&lt;ul&gt;
&lt;li&gt;If &lt;strong&gt;no violation&lt;/strong&gt;, recursively call backtracking for the next variable. If the recursive call succeeds, we&apos;re done (or continue if finding all solutions).&lt;/li&gt;
&lt;li&gt;If &lt;strong&gt;violation&lt;/strong&gt;, or if the recursive call returns failure, try the next value for the current variable.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;If all values for the current variable have been tried and failed, &lt;strong&gt;backtrack&lt;/strong&gt;: return failure to the previous call, forcing it to try a different value.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This explores the space of partial assignments in a depth-first manner. While complete (guaranteed to find a solution if one exists), basic backtracking can be very slow.&lt;/p&gt;
&lt;h3&gt;Filtering (Constraint Propagation)&lt;/h3&gt;
&lt;p&gt;Filtering techniques aim to prune the search space &lt;em&gt;before&lt;/em&gt; or &lt;em&gt;during&lt;/em&gt; backtracking by removing values from domains that cannot possibly lead to a solution.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Forward Checking:&lt;/strong&gt;
When a variable &lt;code&gt;X&lt;/code&gt; is assigned a value &lt;code&gt;v&lt;/code&gt;, look at all unassigned neighboring variables &lt;code&gt;Y&lt;/code&gt; connected to &lt;code&gt;X&lt;/code&gt; by a constraint. Remove any value &lt;code&gt;y&lt;/code&gt; from &lt;code&gt;Y&lt;/code&gt;&apos;s domain that is inconsistent with &lt;code&gt;X=v&lt;/code&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Why:&lt;/strong&gt; Simple, relatively cheap check that prevents immediate failures down the line.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Limitation:&lt;/strong&gt; It only checks constraints between the &lt;em&gt;newly assigned&lt;/em&gt; variable and its &lt;em&gt;future&lt;/em&gt; neighbors. It doesn&apos;t detect inconsistencies &lt;em&gt;between two unassigned variables&lt;/em&gt;, even if their domains have been reduced (e.g., if both NT and SA are reduced to only {Blue}, Forward Checking won&apos;t notice the NT-SA conflict until one of them is assigned).&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Arc Consistency (2-Consistency):&lt;/strong&gt;
An arc &lt;code&gt;X -&gt; Y&lt;/code&gt; is consistent if &lt;em&gt;for every&lt;/em&gt; value &lt;code&gt;x&lt;/code&gt; remaining in &lt;code&gt;X&lt;/code&gt;&apos;s domain, there exists &lt;em&gt;at least one&lt;/em&gt; value &lt;code&gt;y&lt;/code&gt; remaining in &lt;code&gt;Y&lt;/code&gt;&apos;s domain such that &lt;code&gt;(x, y)&lt;/code&gt; satisfies the constraint between &lt;code&gt;X&lt;/code&gt; and &lt;code&gt;Y&lt;/code&gt;. So you could think of it as a &quot;two-way&quot; check, a update of the previous mentioned Forward Checking.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;How (AC-3 Algorithm Idea):&lt;/strong&gt; Maintain a queue of all arcs. While the queue is not empty, pop an arc &lt;code&gt;X -&gt; Y&lt;/code&gt;. Check if it&apos;s consistent. If not, remove the inconsistent value(s) &lt;code&gt;x&lt;/code&gt; from &lt;code&gt;X&lt;/code&gt;&apos;s domain (&quot;delete from the tail&quot;). &lt;strong&gt;Crucially:&lt;/strong&gt; If any value was removed from &lt;code&gt;X&lt;/code&gt;, add all arcs &lt;code&gt;Z -&gt; X&lt;/code&gt; (where &lt;code&gt;Z&lt;/code&gt; is a neighbor of &lt;code&gt;X&lt;/code&gt;, other than &lt;code&gt;Y&lt;/code&gt;) back into the queue, because the removal might make some values in &lt;code&gt;Z&lt;/code&gt; inconsistent. Repeat until the queue is empty (no more values can be removed).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Why:&lt;/strong&gt; More powerful than Forward Checking. It propagates constraints between variables, potentially detecting failures much earlier (like the NT-SA {Blue} conflict). Can be used as preprocessing or maintained during search. However, it is more computationally expensive than Forward Checking, but it is often worth it.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;K-Consistency &amp;#x26; Strong K-Consistency:&lt;/strong&gt;
Generalizes consistency checks to &lt;code&gt;k&lt;/code&gt; variables. K-Consistency means any consistent assignment to &lt;code&gt;k-1&lt;/code&gt; variables can be extended to a &lt;code&gt;k&lt;/code&gt;-th variable.
1-Consistency = Node Consistency (unary constraints).
2-Consistency = Arc Consistency.
3-Consistency = Path Consistency.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Strong K-Consistency:&lt;/strong&gt; Means the CSP is J-Consistent for all &lt;code&gt;J&lt;/code&gt; from 1 to &lt;code&gt;K&lt;/code&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Fact&lt;/strong&gt;: &lt;strong&gt;Strong&lt;/strong&gt; n-Consistency (where n is the number of variables) guarantees a solution can be found &lt;strong&gt;without backtracking&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;My misunderstanding&lt;/strong&gt;: Why &quot;&lt;strong&gt;Strong&lt;/strong&gt;&quot;? Because the backtrack-free construction process requires the guarantee at &lt;em&gt;every&lt;/em&gt; step &lt;code&gt;k&lt;/code&gt;. Step &lt;code&gt;k&lt;/code&gt; requires k-Consistency &lt;em&gt;assuming&lt;/em&gt; the first &lt;code&gt;k-1&lt;/code&gt; assignments were consistent. Plain n-Consistency only guarantees the &lt;em&gt;last&lt;/em&gt; step (n-1 to n) works, but doesn&apos;t guarantee the intermediate steps (like 2 to 3) are possible if the problem isn&apos;t also 3-Consistent, etc. A problem could be n-Consistent (vacuously, if no consistent n-1 assignments exist) but fail lower levels of consistency, requiring backtracking or even having no solution. Strong n-Consistency ensures all necessary intermediate guarantees hold.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Speeding Up Backtracking&lt;/h3&gt;
&lt;p&gt;These heuristics don&apos;t prune the search space but guide the backtracking search to potentially find solutions faster or detect failures earlier.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Variable Ordering: Minimum Remaining Values (MRV):&lt;/strong&gt;
Choose the &lt;em&gt;next unassigned variable&lt;/em&gt; that has the &lt;strong&gt;fewest&lt;/strong&gt; legal values left in its domain.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Why (&quot;Fail-Fast&quot;):&lt;/strong&gt; If a variable has 0 values, failure is detected immediately. If it has 1 value, it&apos;s forced, simplifying the problem. Variables with few values are often bottlenecks; dealing with them early is likely to prune large parts of the search tree quickly if they lead to failure. Also called &quot;most constrained variable&quot;.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Value Ordering: Least Constraining Value (LCV):&lt;/strong&gt;
Once a variable is selected (e.g., by MRV), try assigning values from its domain in an order. Choose the value that &lt;strong&gt;rules out the fewest&lt;/strong&gt; values in the domains of &lt;em&gt;neighboring unassigned variables&lt;/em&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Why (&quot;Succeed-First&quot;):&lt;/strong&gt; Tries to keep options open for the future, increasing the chance that the current path leads to a solution without immediate backtracking. It prioritizes choices that seem less likely to cause conflicts later.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;MRV and LCV often work very well together.&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>The Hidden Cost of try-catch</title><link>https://theunknownth.ing/blog/try-catch</link><guid isPermaLink="true">https://theunknownth.ing/blog/try-catch</guid><description>Profiling revealed that using exceptions in C++ for expected control flow can lead to significant performance degradation.</description><pubDate>Sat, 12 Apr 2025 14:44:00 GMT</pubDate><content:encoded>&lt;h2&gt;The Problem&lt;/h2&gt;
&lt;p&gt;So I was implementing my own version of standard library containers like &lt;code&gt;std::map&lt;/code&gt;. It&apos;s a fantastic learning exercise! I get to the &lt;code&gt;operator[]&lt;/code&gt;, the &lt;code&gt;access-or-insert&lt;/code&gt; function. And I was looking at the existing &lt;code&gt;at()&lt;/code&gt; method (which provides bounds-checked access) and think, &quot;Aha! I can reuse &lt;code&gt;at()&lt;/code&gt; and just catch the exception if the key isn&apos;t there!&quot;&lt;/p&gt;
&lt;p&gt;It seems elegant, right? I wrote something like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-cpp&quot;&gt;T &amp;#x26;at(const Key &amp;#x26;key) {
    if (root == nullptr) {
        throw index_out_of_bound();
    }
    return find(key, root);
}

T &amp;#x26;operator[](const Key &amp;#x26;key) {
    try {
        return at(key);
    } catch (index_out_of_bound &amp;#x26;) {
        // insert
        value_type value(key, T());
        pair&amp;#x3C;iterator, bool&gt; result = insert(value);
        return result.first-&gt;second;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I compile it, feeling pretty good about the code reuse. Then, I run your benchmarks, comparing my &lt;code&gt;sjtu::operator[]&lt;/code&gt; against &lt;code&gt;std::map::operator[]&lt;/code&gt;, especially focusing on scenarios involving insertions (where the key doesn&apos;t initially exist), and boom - Time Limit Exceeded. Why? So I looked at the benchmark script, and it got something like&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-cpp&quot;&gt;	//	test: erase()
	while (map.begin() != map.end()) {
		map.erase(map.begin());
	}
	assert(map.empty() &amp;#x26;&amp;#x26; map.size() == 0);
	//	test: operator[]
	for (int i = 0; i &amp;#x3C; 100000; ++i) {
		std::cout &amp;#x3C;&amp;#x3C; map[Integer(i)];
	}
	std::cout &amp;#x3C;&amp;#x3C; map.size() &amp;#x3C;&amp;#x3C; std::endl;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So probably you have already identified the problem now, but not so lucky for me. I was just thinking, &quot;Oh, maybe the &lt;code&gt;insert&lt;/code&gt; function is slow.&quot;&lt;/p&gt;
&lt;h2&gt;The Profiling&lt;/h2&gt;
&lt;p&gt;The benchmark results are shocking. This implementation is &lt;em&gt;dramatically&lt;/em&gt; slower – in my case, it is 88% slower – than &lt;code&gt;std::map&lt;/code&gt; specifically when &lt;code&gt;operator[]&lt;/code&gt; results in inserting a new element. Accessing existing elements might be fine, but the insert path is killing performance.&lt;/p&gt;
&lt;p&gt;What gives? Is your tree balancing algorithm inefficient? Is memory allocation slow? This is where debugging tools become essential. Simple code inspection doesn&apos;t immediately reveal &lt;em&gt;why&lt;/em&gt; it&apos;s so much slower as it DO ACHIEVE $O(\log N)$ Time complexity.&lt;/p&gt;
&lt;p&gt;Time to bring out the &lt;strong&gt;profilers&lt;/strong&gt;. Tools like &lt;code&gt;perf&lt;/code&gt; (on Linux) and &lt;code&gt;callgrind&lt;/code&gt; (part of the Valgrind suite) are designed to answer the question: &quot;Where is my program &lt;em&gt;actually&lt;/em&gt; spending its time?&quot;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Beginning with &lt;code&gt;perf record ./code&lt;/code&gt; followed by &lt;code&gt;perf report&lt;/code&gt; is a great start as it already provides simple CLI views to see which functions are &quot;hot&quot; – consuming the most CPU cycles. The &lt;code&gt;perf report&lt;/code&gt; points towards functions with names &lt;code&gt;_Unwind_Find_FDE&lt;/code&gt;, and various functions involved in stack unwinding and exception handling. This already reminded me to focus on some syntax issues (improper coding) instead of my code. However, I’m unfamiliar with something like _Unwind_Find_FDE, so I use callgrind to further view the instruction counts.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;https://theunknownth.ing/_astro/perf-report-1.CJQNxDZ7_g2E47.webp&quot; alt=&quot;Perf Report&quot;&gt;&lt;/p&gt;
&lt;ol start=&quot;2&quot;&gt;
&lt;li&gt;&lt;strong&gt;Running &lt;code&gt;callgrind&lt;/code&gt;:&lt;/strong&gt; I run &lt;code&gt;valgrind --tool=callgrind ./code&lt;/code&gt;. And I am using macOS, so I use &lt;code&gt;qcachegrind&lt;/code&gt; to visualize the results.
&lt;ul&gt;
&lt;li&gt;The visualization confirms &lt;code&gt;perf&lt;/code&gt;&apos;s findings but with more detail. I can see that when &lt;code&gt;sjtu::operator[]&lt;/code&gt; calls &lt;code&gt;sjtu::at&lt;/code&gt; and &lt;code&gt;at&lt;/code&gt; executes &lt;code&gt;throw&lt;/code&gt;, a massive cascade of function calls related to exception handling follows - costing 87% of execution time!!!!!&lt;/li&gt;
&lt;li&gt;Crucially, &lt;code&gt;callgrind&lt;/code&gt; shows the &lt;em&gt;cost&lt;/em&gt; associated not just with the &lt;code&gt;throw&lt;/code&gt; itself, but with the entire &lt;strong&gt;stack unwinding&lt;/strong&gt; process – the runtime searching for the &lt;code&gt;catch&lt;/code&gt; block and meticulously destroying any local objects created within the &lt;code&gt;try&lt;/code&gt; block and intervening function calls.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;https://theunknownth.ing/_astro/qcachegrind-0.Cffbs33F_Z24XwlU.webp&quot; alt=&quot;Callgrind Report&quot;&gt;&lt;/p&gt;
&lt;h3&gt;The &quot;Aha!&quot; Moment&lt;/h3&gt;
&lt;p&gt;The profilers leave no doubt. The performance bottleneck &lt;strong&gt;is&lt;/strong&gt; the deliberate, designed-in overhead of the C++ exception handling mechanism being triggered repeatedly for a &lt;em&gt;non-exceptional&lt;/em&gt; condition (key not found during an insertion).&lt;/p&gt;
&lt;h2&gt;What &lt;em&gt;Actually&lt;/em&gt; Happens When C++ Throws an Exception? (And Why Profilers Flag It)&lt;/h2&gt;
&lt;p&gt;After chatting with some AI Chatbots and doing some googling, I realize that throwing and catching an exception isn&apos;t just a fancy &lt;code&gt;goto&lt;/code&gt;. Instead, it involves a complex runtime process that the profilers pick up as costly operations:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Exception Object Creation:&lt;/strong&gt; &lt;code&gt;throw std::out_of_range(...)&lt;/code&gt; creates an object, often involving dynamic memory allocation (heap allocation shows up in profilers).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Stack Unwinding:&lt;/strong&gt; (The main cost flagged by profilers) The runtime walks backward up the call stack.
&lt;ul&gt;
&lt;li&gt;It destroys local objects (RAII cleanup). Profilers show time spent in destructors &lt;em&gt;during&lt;/em&gt; unwinding.&lt;/li&gt;
&lt;li&gt;It consults compiler-generated &quot;unwinding tables&quot;. Accessing and processing this data takes time/instructions.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Handler Matching:&lt;/strong&gt; The runtime checks &lt;code&gt;catch&lt;/code&gt; blocks using RTTI, adding overhead.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Control Transfer:&lt;/strong&gt; Jumping to the &lt;code&gt;catch&lt;/code&gt; block disrupts linear execution flow, potentially causing instruction cache misses and branch mispredictions (subtler effects seen in very low-level profiling).&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The profiling results, combined with understanding the mechanics, paint a clear picture:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Stack Unwinding Overhead:&lt;/strong&gt; As &lt;code&gt;callgrind&lt;/code&gt; showed, walking the stack, looking up cleanup actions, and calling destructors is expensive, especially compared to a simple &lt;code&gt;if&lt;/code&gt; check.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Runtime Machinery:&lt;/strong&gt; The hidden machinery (dynamic allocation, RTTI, table lookups) adds significant overhead absent in direct conditional logic.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Optimization Barriers:&lt;/strong&gt; Exception handling constructs can limit compiler optimizations compared to simpler control flow, contributing to higher instruction counts seen in &lt;code&gt;callgrind&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In our &lt;code&gt;operator[]&lt;/code&gt; example, the case where the key &lt;em&gt;doesn&apos;t&lt;/em&gt; exist is expected. By using exceptions here, we frequently trigger the heavyweight process the profilers flagged, leading to poor performance.&lt;/p&gt;
&lt;p&gt;So what does a normal &lt;code&gt;operator[]&lt;/code&gt; look like? It should be something like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-cpp&quot;&gt;    T &amp;#x26;operator[](const Key &amp;#x26;key) {
        Node *node = find_node(key, root);
        if (node != nullptr) {
            return node-&gt;data.second;
        } else {
            // Insert new element
            value_type value(key, T());
            pair&amp;#x3C;iterator, bool&gt; result = insert(value);
            return result.first-&gt;second;
        }
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and the profiler results should look like something like this:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theunknownth.ing/_astro/perf-report-0.Dl7yfGEs_2mxqnc.webp&quot; alt=&quot;Normal Report&quot;&gt;&lt;/p&gt;
&lt;p&gt;As you can see in the image, the top CPU-consuming functions are now actual function in the code, not the exception handling machinery. The &lt;code&gt;find_node&lt;/code&gt; function is now the most expensive operation, which is expected since it involves
$O(\log N)$ tree traversal.&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>My First VSCode Extension - ACMOJ Helper from Scratch</title><link>https://theunknownth.ing/blog/vscode-extension</link><guid isPermaLink="true">https://theunknownth.ing/blog/vscode-extension</guid><description>As a student frequently using ACMOJ, constantly switching between VSCode and browser was tedious. Could I complete all these operations within VSCode?</description><pubDate>Sun, 06 Apr 2025 06:25:00 GMT</pubDate><content:encoded>&lt;p&gt;Constantly switching between the editor (VS Code) and browser was incredibly tedious. Looking at problem descriptions, examples, and outputs in the browser, then comparing results, copying code to VS Code, writing and debugging, copying back to browser for submission, and finally switching back to browser to check results... Although I could use split screen, the Stage Manager experience on macOS wasn&apos;t great. This process not only interrupted my thought flow but was also inefficient.&lt;/p&gt;
&lt;p&gt;Could I complete all these operations within VSCode? Seeing classmates in my class developing plugins, it didn&apos;t seem that difficult. Having recently learned Golang, TypeScript didn&apos;t seem too hard to learn either 😋 With this idea in mind, I created my first VSCode extension development journey, aiming to create a convenient assistant for ACMOJ. This article documents the process from conception to implementation, through pitfalls to the final working product.&lt;/p&gt;
&lt;h2&gt;Getting Started&lt;/h2&gt;
&lt;p&gt;VS Code extensions are primarily written in TypeScript (or JavaScript) and run in a Node.js environment. Before starting, the essential tools are:&lt;/p&gt;
&lt;p&gt;Node.js &amp;#x26; npm/yarn serve as the basic runtime environment and package manager. Yeoman &amp;#x26; generator-code are the official scaffolding tools recommended by VS Code for quickly generating project structure. Simply run &lt;code&gt;npm install -g yo generator-code&lt;/code&gt; followed by &lt;code&gt;yo code&lt;/code&gt; and select TypeScript Extension. VS Code itself is needed for developing and debugging the plugin.&lt;/p&gt;
&lt;p&gt;The generated project structure is clear and straightforward. The &lt;code&gt;src/extension.ts&lt;/code&gt; file serves as the plugin&apos;s entry point, containing &lt;code&gt;activate&lt;/code&gt; (called when activated) and &lt;code&gt;deactivate&lt;/code&gt; (called when deactivated) functions. The &lt;code&gt;package.json&lt;/code&gt; file is the core manifest file, defining the plugin&apos;s metadata, &lt;strong&gt;contributions&lt;/strong&gt; (such as commands, views, configurations), and &lt;strong&gt;activation events&lt;/strong&gt; (determining when to load the plugin). The &lt;code&gt;tsconfig.json&lt;/code&gt; file contains TypeScript configuration.&lt;/p&gt;
&lt;p&gt;My initial blueprint was to implement these core features:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Authentication:&lt;/strong&gt; Connect to the ACMOJ API.
&lt;strong&gt;Problem/Assignment Browsing:&lt;/strong&gt; View problem lists in VS Code&apos;s sidebar.
&lt;strong&gt;Problem Details:&lt;/strong&gt; Display problem descriptions, examples, etc. in Webview.
&lt;strong&gt;Code Submission:&lt;/strong&gt; Quickly submit code from the current editor.
&lt;strong&gt;Result Viewing:&lt;/strong&gt; View submission status and results in sidebar or Webview.&lt;/p&gt;
&lt;h2&gt;API Interaction and Authentication&lt;/h2&gt;
&lt;p&gt;ACMOJ provides an OpenAPI-compliant API, which forms the foundation for implementing functionality.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;API Client Setup&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;I chose &lt;code&gt;axios&lt;/code&gt; as the HTTP request library and encapsulated an &lt;code&gt;ApiClient&lt;/code&gt; class to uniformly handle request sending, Base URL configuration, and error handling. The key was setting up request interceptors to automatically attach &lt;code&gt;Bearer &amp;#x3C;token&gt;&lt;/code&gt; in the &lt;code&gt;Authorization&lt;/code&gt; Header.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Authentication &quot;Episode&quot; - OAuth vs PAT&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The API documentation mentioned both OAuth2 (Authorization Code Flow) and Personal Access Token (PAT) authentication methods.&lt;/p&gt;
&lt;p&gt;Initially, I tried implementing the OAuth2 flow. This involved directing users to browser authorization, then starting a temporary HTTP server locally to listen for callback URIs to obtain the &lt;code&gt;code&lt;/code&gt;, then using the &lt;code&gt;code&lt;/code&gt; and &lt;code&gt;client_secret&lt;/code&gt; to exchange for an &lt;code&gt;access_token&lt;/code&gt;. While this flow is standard for applications requiring multi-user authorization, it&apos;s quite complex to implement, especially handling &lt;code&gt;client_secret&lt;/code&gt; and local callbacks securely in a VS Code extension environment. (Actually, what stopped me initially was needing a &lt;code&gt;client secret&lt;/code&gt; from the admin team. At that time, I didn&apos;t know anyone on the admin team, though they seem to know me now after developing this plugin XD)&lt;/p&gt;
&lt;p&gt;Considering that target users (mainly myself and classmates) could easily generate PATs on the ACMOJ website, I decided to switch to the simpler PAT authentication. This greatly simplified the flow: create an &lt;code&gt;AuthService&lt;/code&gt; (or &lt;code&gt;TokenManager&lt;/code&gt;), provide an &lt;code&gt;acmoj.setToken&lt;/code&gt; command using &lt;code&gt;vscode.window.showInputBox({ password: true })&lt;/code&gt; to prompt users for PAT input, use VS Code&apos;s &lt;code&gt;SecretStorage&lt;/code&gt; API (&lt;code&gt;context.secrets.store&lt;/code&gt; / &lt;code&gt;context.secrets.get&lt;/code&gt;) to securely store and read PATs, provide an &lt;code&gt;acmoj.clearToken&lt;/code&gt; command to clear stored PATs, directly get stored PATs from &lt;code&gt;AuthService&lt;/code&gt; in &lt;code&gt;ApiClient&lt;/code&gt;&apos;s request interceptor to add to request headers, and in response interceptor, if encountering 401 Unauthorized errors, call &lt;code&gt;AuthService&lt;/code&gt; methods to clear invalid tokens and prompt users to reset.&lt;/p&gt;
&lt;h2&gt;Building User Interface with TreeView and Webview&lt;/h2&gt;
&lt;p&gt;To display information and provide interaction in VS Code, I mainly used TreeView and Webview.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;TreeView (Sidebar)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;I used the &lt;code&gt;vscode.TreeDataProvider&lt;/code&gt; interface to create two views for the Activity Bar:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problemsets (Contests/Assignments):&lt;/strong&gt; Initially, I simply listed all problems but quickly found the information overwhelming. I improved it to display Problemsets that users joined. Further improvement involved categorizing Problemsets into &quot;Ongoing&quot;, &quot;Upcoming&quot;, and &quot;Passed&quot; top-level nodes based on their start/end times. This required fetching all Problemsets, then filtering and sorting them in the &lt;code&gt;getChildren&lt;/code&gt; method based on current time and category nodes. I used two custom &lt;code&gt;TreeItem&lt;/code&gt; types: &lt;code&gt;CategoryTreeItem&lt;/code&gt; and &lt;code&gt;ProblemsetTreeItem&lt;/code&gt;. Each Problemset node was set as expandable (&lt;code&gt;vscode.TreeItemCollapsibleState.Collapsed&lt;/code&gt;), loading its contained problem list (&lt;code&gt;ProblemBriefTreeItem&lt;/code&gt;) when clicked.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Submissions (Submission Records):&lt;/strong&gt; This displays the user&apos;s submission list, including ID, problem, status, language, time, etc. I set different icons (&lt;code&gt;ThemeIcon&lt;/code&gt;) for different submission statuses (AC, WA, TLE, RE...) to make them more intuitive.&lt;/p&gt;
&lt;p&gt;The key to implementing TreeView lies in the &lt;code&gt;getChildren&lt;/code&gt; (get child nodes) and &lt;code&gt;getTreeItem&lt;/code&gt; (define node appearance and behavior) methods. Through &lt;code&gt;EventEmitter&lt;/code&gt; and &lt;code&gt;onDidChangeTreeData&lt;/code&gt; events, you can notify VS Code to refresh the view.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Webview (Detail Display)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;When users click on problems or submission records in TreeView, I use &lt;code&gt;vscode.window.createWebviewPanel&lt;/code&gt; to create a Webview for displaying detailed information. Why use &lt;code&gt;webview&lt;/code&gt;? Because I needed to render TeX formulas, and JSON requests returned Markdown results.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Content Rendering:&lt;/strong&gt; Webview is essentially an embedded browser environment with HTML content. I used the &lt;code&gt;markdown-it&lt;/code&gt; library to convert Markdown-formatted problem descriptions, input/output formats, etc. obtained from the API into HTML.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Challenge: Mathematical Formula Rendering:&lt;/strong&gt; OJ problem descriptions often contain LaTeX formulas.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Attempt One (Failed):&lt;/strong&gt; Initially, I tried including KaTeX JS library and auto-render script in the Webview HTML for client-side rendering. However, this caused the strange issue of formulas being rendered twice (once as original text, once as KaTeX rendered result).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Attempt Two (Success):&lt;/strong&gt; I realized the problem was in the duplicate rendering flow. The final solution was using &lt;code&gt;markdown-it&lt;/code&gt;&apos;s KaTeX plugin (&lt;code&gt;@vscode/markdown-it-katex&lt;/code&gt; - this package had another developer&apos;s version when installing via npm, which was outdated and had security risks, but the good news is that VS Code officially noticed this project and made subsequent fixes, so I used this one). When using &lt;code&gt;md.render()&lt;/code&gt; on the &lt;strong&gt;extension side&lt;/strong&gt; (Node.js environment), this plugin directly converts LaTeX in Markdown (&lt;code&gt;$...$&lt;/code&gt;, &lt;code&gt;$$...$$&lt;/code&gt;) to final KaTeX HTML structure. This way, the HTML sent to Webview is already pre-rendered, and the Webview side &lt;strong&gt;only needs&lt;/strong&gt; to include KaTeX CSS (&lt;code&gt;katex.min.css&lt;/code&gt;) to display styles correctly, no longer needing KaTeX JS and auto-render scripts.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Commands and Status Bar&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;I used &lt;code&gt;vscode.commands.registerCommand&lt;/code&gt; to register various user operations (set Token, refresh views, submit code, view problems by ID, etc.). I used &lt;code&gt;vscode.window.createStatusBarItem&lt;/code&gt; to display current login status and username on the left side of the status bar, which can trigger corresponding commands (like showing user info or setting Token) when clicked.&lt;/p&gt;
&lt;h2&gt;Packaging and Publishing&lt;/h2&gt;
&lt;p&gt;Everything worked smoothly during development and debugging (&lt;code&gt;F5&lt;/code&gt;), but when I used &lt;code&gt;vsce package&lt;/code&gt; to package into a VSIX file and installed it on another computer, I encountered the classic problem: &lt;code&gt;Command &apos;acmoj.setToken&apos; not found&lt;/code&gt; or &lt;code&gt;Cannot find module &apos;axios&apos;&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Debugging Process&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;I checked the developer tools by opening VS Code developer tools (&lt;code&gt;Developer: Toggle Developer Tools&lt;/code&gt;) Console on the test computer. I found that activating the extension directly reported error &lt;code&gt;Cannot find module &apos;axios&apos;&lt;/code&gt;. I checked VSIX contents using &lt;code&gt;vsce ls&lt;/code&gt; command (or renaming &lt;code&gt;.vsix&lt;/code&gt; to &lt;code&gt;.zip&lt;/code&gt; and extracting) to view package contents. I discovered that the &lt;code&gt;node_modules&lt;/code&gt; folder wasn&apos;t packaged at all!&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Root Cause&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;I mistakenly placed runtime-required libraries (like &lt;code&gt;axios&lt;/code&gt;, &lt;code&gt;markdown-it&lt;/code&gt;, &lt;code&gt;katex&lt;/code&gt;, &lt;code&gt;@vscode/markdown-it-katex&lt;/code&gt;) under &lt;code&gt;devDependencies&lt;/code&gt; instead of &lt;code&gt;dependencies&lt;/code&gt; in &lt;code&gt;package.json&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Dependencies&lt;/strong&gt; are libraries required for extension &lt;strong&gt;runtime&lt;/strong&gt; and will be packaged by &lt;code&gt;vsce package&lt;/code&gt;. &lt;strong&gt;DevDependencies&lt;/strong&gt; are libraries used during &lt;strong&gt;development&lt;/strong&gt; (compilers, type definitions, linters, packaging tools, etc.) and will &lt;strong&gt;not&lt;/strong&gt; be packaged.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Solution&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;I carefully checked &lt;code&gt;package.json&lt;/code&gt; and moved all runtime dependencies (&lt;code&gt;axios&lt;/code&gt;, etc.) to the &lt;code&gt;dependencies&lt;/code&gt; section, while keeping development tools (&lt;code&gt;typescript&lt;/code&gt;, &lt;code&gt;@types/*&lt;/code&gt;, &lt;code&gt;eslint&lt;/code&gt;, &lt;code&gt;@vscode/vsce&lt;/code&gt;, etc.) in &lt;code&gt;devDependencies&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;dependencies&quot;: {
    &quot;@vscode/markdown-it-katex&quot;: &quot;...&quot;,
    &quot;axios&quot;: &quot;...&quot;,
    &quot;katex&quot;: &quot;...&quot;,
    &quot;markdown-it&quot;: &quot;...&quot;
  },
  &quot;devDependencies&quot;: {
    &quot;@types/vscode&quot;: &quot;...&quot;,
    &quot;@types/node&quot;: &quot;...&quot;,
    &quot;@types/markdown-it&quot;: &quot;...&quot;,
    &quot;@vscode/vsce&quot;: &quot;...&quot;, // The packaging tool itself is a dev dependency
    &quot;typescript&quot;: &quot;...&quot;,
    &quot;eslint&quot;: &quot;...&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Key Step:&lt;/strong&gt; After modifying &lt;code&gt;package.json&lt;/code&gt;, it&apos;s essential to perform &lt;strong&gt;&quot;clean &amp;#x26; reinstall&quot;&lt;/strong&gt; - I continued getting errors initially because I didn&apos;t clear node_modules and package-lock.json.&lt;/p&gt;
&lt;p&gt;This time, the generated VSIX file finally contained the correct &lt;code&gt;node_modules&lt;/code&gt;, and after installation, commands could be found normally and the extension activated successfully.&lt;/p&gt;
&lt;h2&gt;TypeScript Interlude&lt;/h2&gt;
&lt;p&gt;As a TypeScript project, I also encountered some typical type issues:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Module/Type Not Found:&lt;/strong&gt; &lt;code&gt;Cannot find module &apos;vscode&apos;&lt;/code&gt; or other &lt;code&gt;@types&lt;/code&gt; packages, usually resolved by &lt;code&gt;npm install --save-dev @types/vscode @types/node ...&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Implicit &lt;code&gt;any&lt;/code&gt;:&lt;/strong&gt; After enabling &lt;code&gt;strict&lt;/code&gt; mode, I needed to explicitly add types for callback function parameters (like &lt;code&gt;progress&lt;/code&gt; in &lt;code&gt;withProgress&lt;/code&gt;, &lt;code&gt;text&lt;/code&gt; in &lt;code&gt;validateInput&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;API Signature Mismatch:&lt;/strong&gt; When calling &lt;code&gt;vscode.window.showQuickPick&lt;/code&gt;, if providing option objects, you need to pass &lt;code&gt;QuickPickItem[]&lt;/code&gt; instead of &lt;code&gt;string[]&lt;/code&gt;, requiring mapping.&lt;/p&gt;
&lt;h2&gt;Is This the End?&lt;/h2&gt;
&lt;p&gt;While acmoj-helper can already run and has helped me considerably in daily use, during the development process, I gradually felt some &quot;growing pains.&quot; As features iterated (even with minor adjustments), I found the code becoming somewhat messy:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Unclear Responsibilities:&lt;/strong&gt; The &lt;code&gt;commands.ts&lt;/code&gt; file not only handled command registration but also contained substantial complex business logic implementations like &lt;code&gt;submitCurrentFile&lt;/code&gt;. This made the file abnormally bloated, making modifications affect the entire system.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;High Coupling:&lt;/strong&gt; Modifying one module (like &lt;code&gt;cache.ts&lt;/code&gt; handling API caching) might unexpectedly affect views (&lt;code&gt;submissionProvider.ts&lt;/code&gt;) or command handling. When I mentioned rewriting &lt;code&gt;submissionProvider&lt;/code&gt; earlier, that was a typical example - the view layer was too tightly coupled with data fetching and business logic.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Registration Chaos:&lt;/strong&gt; Command registration was scattered across &lt;code&gt;extension.ts&lt;/code&gt; and &lt;code&gt;commands.ts&lt;/code&gt;, lacking centralization and clarity.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Extension Difficulties:&lt;/strong&gt; If I wanted to add new features like &quot;Contest&quot; view or more complex problem filtering logic, it would be extremely painful under the existing structure, requiring careful navigation through various files to ensure existing functionality wasn&apos;t broken.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Testing Obstacles:&lt;/strong&gt; Code mixing UI logic, API calls, and business processing was very difficult to unit test.&lt;/p&gt;
&lt;p&gt;These issues made me realize that while the current architecture works, it&apos;s not &quot;elegant&quot; and lacks long-term viability. To ensure this project can develop healthily and to improve my own code design skills, I decided to conduct a thorough refactoring.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Refactoring Goals: Decoupling, Layering, Single Responsibility&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The new architecture I&apos;m currently working on is roughly divided into these layers:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;VS Code Integration Layer (&lt;code&gt;extension.ts&lt;/code&gt;, &lt;code&gt;src/commands/index.ts&lt;/code&gt;)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Service Layer (&lt;code&gt;src/services/&lt;/code&gt;)&lt;/strong&gt; - Responsible for encapsulating core business logic and interactions with external resources (like APIs, caching). Each service corresponds to a clear domain.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Command Handling Layer (&lt;code&gt;src/commands/&lt;/code&gt;)&lt;/strong&gt; - Command handlers receive calls from VS Code and then &lt;strong&gt;use the service layer&lt;/strong&gt; to complete specific tasks. They serve as bridges between VS Code commands and business logic. Complex logic (like &lt;code&gt;submitCurrentFile&lt;/code&gt;) is now clearly encapsulated in corresponding command handlers.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;UI Layer (&lt;code&gt;src/views/&lt;/code&gt;, &lt;code&gt;src/webviews/&lt;/code&gt;)&lt;/strong&gt; - Responsible for data display and UI interaction. The &lt;code&gt;views/&lt;/code&gt; directory contains TreeDataProviders (like &lt;code&gt;ProblemsetProvider&lt;/code&gt;, &lt;code&gt;SubmissionProvider&lt;/code&gt;) that get data from the &lt;strong&gt;service layer&lt;/strong&gt; and format it into structures needed by VS Code TreeView. The &lt;code&gt;webviews/&lt;/code&gt; directory contains Webview Panel logic. After refactoring, I created dedicated classes for problem details and submission details (&lt;code&gt;ProblemDetailPanel&lt;/code&gt;, &lt;code&gt;SubmissionDetailPanel&lt;/code&gt;), encapsulating their respective HTML generation, message handling, and lifecycle management. They also get data through the &lt;strong&gt;service layer&lt;/strong&gt;, and Webview operations (like &quot;copy code&quot;) now typically send messages to VS Code via &lt;code&gt;postMessage&lt;/code&gt;, responded to by corresponding &lt;strong&gt;command handlers&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Core/Data Layer (&lt;code&gt;src/core/&lt;/code&gt;, &lt;code&gt;src/types.ts&lt;/code&gt;)&lt;/strong&gt; - Provides the most basic components and definitions. A typical example during refactoring was &lt;strong&gt;&lt;code&gt;core/apiClient.ts&lt;/code&gt;&lt;/strong&gt;: a purer HTTP client only responsible for sending requests, handling authentication headers, retry logic, and basic error interpretation. It no longer contains specific business endpoint logic. Previously, getUserProfile, getSubmission, etc. were all in there.&lt;/p&gt;
&lt;p&gt;While the refactoring process was quite challenging and temporarily introduced new bugs, it laid a solid foundation for ACMOJ Helper&apos;s long-term development. Now I can more confidently implement those more comprehensive features I envisioned at the end of version 1.0.&lt;/p&gt;
&lt;p&gt;If you&apos;re also interested in VSCode extension development or want to build integrations for tools or platforms you frequently use, don&apos;t hesitate - just start doing it! Begin with &lt;code&gt;yo code&lt;/code&gt;, encounter problems, solve problems - this process itself is the best learning experience.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Project Repository:&lt;/strong&gt; &lt;a href=&quot;https://github.com/TheUnknownThing/vscode-acmoj&quot;&gt;TheUnknownThing/vscode-acmoj&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Thanks for reading! I hope my experience can be helpful to you.&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>I&apos;ll Never Use memset Again...</title><link>https://theunknownth.ing/blog/memset</link><guid isPermaLink="true">https://theunknownth.ing/blog/memset</guid><description>The Pitfalls of the memset function</description><pubDate>Mon, 10 Mar 2025 05:11:40 GMT</pubDate><content:encoded>&lt;h2&gt;0. Foreword&lt;/h2&gt;
&lt;p&gt;This problem originated from my first programming exam during my freshman year... It was a question involving block decomposition (data chunking), and in my program, I had an operation like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-cpp&quot;&gt;memset(mul_tag, 1, sizeof(mul_tag));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Unsurprisingly, the program resulted in a WA (Wrong Answer). I spent a very, very long time debugging. This line looked completely harmless, didn&apos;t it? But as it turned out, simply changing this line fixed the program! Why??? The answer becomes clear when we look at the &lt;code&gt;memset&lt;/code&gt; function prototype.&lt;/p&gt;
&lt;h2&gt;1. &lt;code&gt;memset&lt;/code&gt; Function Introduction&lt;/h2&gt;
&lt;p&gt;The prototype for the &lt;code&gt;memset&lt;/code&gt; function is as follows:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;void *memset(void *s, int c, size_t n);
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;s&lt;/code&gt;: A pointer to the block of memory to fill.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;c&lt;/code&gt;: The value to be set. &lt;strong&gt;Note:&lt;/strong&gt; Although &lt;code&gt;c&lt;/code&gt; is of type &lt;code&gt;int&lt;/code&gt;, &lt;code&gt;memset&lt;/code&gt; actually converts &lt;code&gt;c&lt;/code&gt; to an &lt;code&gt;unsigned char&lt;/code&gt; before filling.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;n&lt;/code&gt;: The number of bytes to be set to the value.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The purpose of &lt;code&gt;memset&lt;/code&gt; is to set the first &lt;code&gt;n&lt;/code&gt; bytes of the memory block pointed to by &lt;code&gt;s&lt;/code&gt; to the value specified by &lt;code&gt;c&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;2. The Trap&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;memset&lt;/code&gt; performs its filling operation &lt;strong&gt;byte by byte&lt;/strong&gt;. When &lt;code&gt;a&lt;/code&gt; is an &lt;code&gt;int&lt;/code&gt; array (assuming &lt;code&gt;int&lt;/code&gt; occupies 4 bytes), &lt;code&gt;memset(a, 1, sizeof(a))&lt;/code&gt; will set &lt;em&gt;each byte&lt;/em&gt; of &lt;em&gt;each &lt;code&gt;int&lt;/code&gt; element&lt;/em&gt; to &lt;code&gt;1&lt;/code&gt;. This results in each &lt;code&gt;int&lt;/code&gt; element having the value &lt;code&gt;0x01010101&lt;/code&gt;, which is &lt;code&gt;16843009&lt;/code&gt; in decimal, not the &lt;code&gt;1&lt;/code&gt; we were hoping for.&lt;/p&gt;
&lt;h2&gt;3. Exceptions&lt;/h2&gt;
&lt;p&gt;Using &lt;code&gt;memset(a, 1, sizeof(a))&lt;/code&gt; is dangerous in most scenarios. However, there are a few exceptional cases where it works as expected or is safe:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;If &lt;code&gt;a&lt;/code&gt; is a &lt;code&gt;char&lt;/code&gt; array, &lt;code&gt;memset(a, 1, sizeof(a))&lt;/code&gt; is correct because the &lt;code&gt;char&lt;/code&gt; type occupies only one byte.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;memset(a, 0, sizeof(a))&lt;/code&gt; can be safely used for arrays of any type to initialize the entire array to 0. (This is what we typically do! And it&apos;s precisely why I initially thought &lt;code&gt;memset(a, 1, sizeof(a))&lt;/code&gt; would be fine!)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;memset(a, -1, sizeof(a))&lt;/code&gt; is safe for &lt;code&gt;int&lt;/code&gt; arrays and will correctly initialize the elements to -1. Why? Hint: Computers store negative numbers using two&apos;s complement representation. The two&apos;s complement of -1 (for a 32-bit int) is &lt;code&gt;11111111 11111111 11111111 11111111&lt;/code&gt;, which means every byte is &lt;code&gt;0xFF&lt;/code&gt;. Therefore, &lt;code&gt;memset(a, -1, sizeof(a))&lt;/code&gt; fills every byte with &lt;code&gt;0xFF&lt;/code&gt;, effectively setting each &lt;code&gt;int&lt;/code&gt; element to -1.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;4. You Should Use &lt;code&gt;std::fill&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;Instead of &lt;code&gt;memset&lt;/code&gt; for non-zero/non-minus-one initializations (especially in C++), you should use &lt;code&gt;std::fill&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;std::fill&lt;/code&gt; Example (C++):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c++&quot;&gt;#include &amp;#x3C;algorithm&gt;
#include &amp;#x3C;array&gt; // Or use raw arrays

std::array&amp;#x3C;int, 10&gt; a;  // Or: int a[10];
std::fill(a.begin(), a.end(), 1); // Or: std::fill(a, a + 10, 1);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;std::fill&lt;/code&gt; operates on elements of the container or array, assigning the specified value (&lt;code&gt;1&lt;/code&gt; in this case) correctly to each element, regardless of its underlying byte representation.&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>Installing Windows on a IPv6 VPS</title><link>https://theunknownth.ing/blog/ipv6-vps-windows</link><guid isPermaLink="true">https://theunknownth.ing/blog/ipv6-vps-windows</guid><description>If you happen to have a cloud server that does not provide Windows images, you might want to try installing Windows yourself.</description><pubDate>Wed, 15 Jan 2025 14:59:00 GMT</pubDate><content:encoded>&lt;p&gt;If you happen to have a high-configuration cloud server (like my Afly Black Friday VPS) that doesn&apos;t provide Windows images, you might want to try installing Windows yourself using the DD method.&lt;/p&gt;
&lt;h2&gt;What is DD System Installation?&lt;/h2&gt;
&lt;p&gt;As the name suggests, DD system installation uses the dd command to transfer a vhd file to a specific partition, then configures boot files to make it bootable. As scripts have evolved, many features have been added (like installation from img or iso images, system rescue). However, this isn&apos;t the main focus - this tutorial aims to cover the pitfalls I encountered while using such scripts, and how to solve them.&lt;/p&gt;
&lt;h2&gt;My Environment Configuration&lt;/h2&gt;
&lt;p&gt;First, let me introduce my environment (these configurations might seem unusual, but these specific characteristics led to some interesting problems):&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;CPU: 3 Core AMD Ryzen 9 9950X&lt;/li&gt;
&lt;li&gt;RAM: 4.5GB&lt;/li&gt;
&lt;li&gt;SSD: 125GB&lt;/li&gt;
&lt;li&gt;Network: IPv6 /128 Only (Yes, pure IPv6 environment with no IPv4 access! And only a /128 IPv6 allocation, which becomes important later)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Preparation&lt;/h2&gt;
&lt;h3&gt;Script Used&lt;/h3&gt;
&lt;p&gt;I chose this script: &lt;a href=&quot;https://github.com/bin456789/reinstall&quot;&gt;https://github.com/bin456789/reinstall&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;I strongly recommend carefully reading the README first, as the repository contains detailed instructions on how to use the script.&lt;/p&gt;
&lt;h3&gt;System Image Selection&lt;/h3&gt;
&lt;p&gt;I used an image from TeddySun&apos;s collection, which you can find by searching &lt;a href=&quot;https://teddysun.com/?s=DD&quot;&gt;https://teddysun.com/?s=DD&lt;/a&gt; to find your preferred image. I selected Windows 10 LTSC because it&apos;s relatively clean.&lt;/p&gt;
&lt;h3&gt;Quick Installation Commands&lt;/h3&gt;
&lt;p&gt;If you&apos;re in a hurry, here are the basic installation commands:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Download the script
curl -O https://raw.githubusercontent.com/bin456789/reinstall/main/reinstall.sh || wget -O reinstall.sh

# Execute the installation
bash reinstall.sh dd --img https://dl.lamp.sh/vhd/zh-cn_windows10_ltsc.xz
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Remember to install curl beforehand (if your system doesn&apos;t have it)&lt;/p&gt;
&lt;h3&gt;First Issue: Incorrect DNS Configuration&lt;/h3&gt;
&lt;p&gt;This problem was mainly caused by my specific network environment. The DNS configuration in the script&apos;s Alpine environment was incorrect, preventing files from being downloaded. Here&apos;s my solution:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/bin/sh

# Modify /etc/resolv.conf file
echo &quot;nameserver 2001:4860:4860::8888&quot; &gt; /etc/resolv.conf
echo &quot;nameserver 2001:4860:4860::8844&quot; &gt;&gt; /etc/resolv.conf

if [ -f /etc/systemd/resolved.conf ]; then
    echo &quot;[Resolve]&quot; &gt;&gt; /etc/systemd/resolved.conf
    echo &quot;DNS=2001:4860:4860::8888&quot; &gt;&gt; /etc/systemd/resolved.conf
    echo &quot;DNS=2001:4860:4860::8844&quot; &gt;&gt; /etc/systemd/resolved.conf
    systemctl restart systemd-resolved
fi

echo &quot;DNS successfully changed to Google IPv6 DNS&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Of course, if you have a normal dual-stack environment, you probably won&apos;t encounter this issue.&lt;/p&gt;
&lt;h3&gt;Second Issue: Password Setup&lt;/h3&gt;
&lt;p&gt;I found this particularly interesting: when the script first runs, it prompts you to enter a password, but this password is not the one you&apos;ll use to log into Windows! Despite the script&apos;s README mentioning this, I missed it.&lt;/p&gt;
&lt;p&gt;In fact, the Windows login password is determined by the image. For TeddySun&apos;s image that I used:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Username: Administrator&lt;/li&gt;
&lt;li&gt;Password: Teddysun.com&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Third Issue: Windows IPv6 Privacy Protection&lt;/h3&gt;
&lt;p&gt;This problem puzzled me for a long time. If you run &lt;code&gt;ipconfig /all&lt;/code&gt; on a Windows computer, you might notice something called &quot;temporary address.&quot; This is because Windows &quot;protects your online privacy,&quot; but in my environment with only a /128 IPv6 allocation, this became a problem: external access to your machine is through that fixed IP address, but your machine accesses external websites using a temporary address. This means you can connect via Remote Desktop but can&apos;t access the internet.&lt;/p&gt;
&lt;p&gt;The solution is simple - open Command Prompt as administrator:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-cmd&quot;&gt;netsh interface ipv6 set privacy state=disable
# Then restart the network adapter
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Fourth Issue: Workarounds for Pure IPv6 Environment&lt;/h3&gt;
&lt;p&gt;This issue also stems from my special network environment. Not having IPv4 access is quite inconvenient, so I used Cloudflare WARP to provide IPv4 access. However, note that if you directly use the Windows version of WARP, after enabling it, your IPv6 address will also change to WARP&apos;s address, preventing you from connecting to Remote Desktop!&lt;/p&gt;
&lt;p&gt;I used a solution provided by a user on the Nodeseek forum (&lt;a href=&quot;https://www.nodeseek.com/post-128008-1&quot;&gt;original post&lt;/a&gt;):&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Download and install the official CloudFlare WARP client&lt;/li&gt;
&lt;li&gt;In WARP settings:
&lt;ul&gt;
&lt;li&gt;Click the gear icon in the bottom right → Preferences&lt;/li&gt;
&lt;li&gt;Advanced → Configure Proxy Mode&lt;/li&gt;
&lt;li&gt;Enable proxy mode and set a memorable port&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This effectively gives you a locally available Cloudflare-provided IPv4 exit socks proxy, which you can use however you like - with SwitchyOmega or other tools, configure as you prefer. This way, you can maintain Remote Desktop connections while gaining IPv4 access.&lt;/p&gt;
&lt;h3&gt;Fifth Issue: LTSC Minor Problem&lt;/h3&gt;
&lt;p&gt;If you chose the LTSC 2021 version of Windows like I did, you might notice that the &lt;code&gt;wsappx&lt;/code&gt; service is always running in the background. This issue has a solution on the PCbeta forum; if you&apos;re interested, check out this post: &lt;a href=&quot;https://bbs.pcbeta.com/viewthread-1912382-1-1.html&quot;&gt;LTSC Optimization Guide&lt;/a&gt;&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>Caddy Configuration for Typecho — Revisited</title><link>https://theunknownth.ing/blog/caddy-typecho</link><guid isPermaLink="true">https://theunknownth.ing/blog/caddy-typecho</guid><description>I decided to stop using pre-built Docker images and instead manually configure PHP + Caddy + Typecho.</description><pubDate>Tue, 14 Jan 2025 17:12:00 GMT</pubDate><content:encoded>&lt;p&gt;It seems the very first post on this blog showed how to set up Caddy, but at that time I used someone else&apos;s Docker image which bundled nginx, PHP, and Typecho, and I simply reverse-proxied Caddy to that port.&lt;/p&gt;
&lt;p&gt;Now I rented a server on Alibaba Cloud with only 512MB RAM, which is a bit tight. To avoid the extra memory overhead of nginx and Docker, I decided to hand-build the Typecho environment.&lt;/p&gt;
&lt;h2&gt;Install the world&apos;s best programming language&lt;/h2&gt;
&lt;h3&gt;Add the Sury PPA repository&lt;/h3&gt;
&lt;p&gt;First, add the PPA that contains the latest PHP packages. You need to install some prerequisite packages.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt update
sudo apt install lsb-release apt-transport-https ca-certificates software-properties-common -y
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After installing the tools, import the Sury GPG key. Sury provides almost every PHP version. Typecho requires PHP &gt; 7.4, so we&apos;ll install 8.2.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo wget -O /etc/apt/trusted.gpg.d/php.gpg https://packages.sury.org/php/apt.gpg
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then add the repository to your sources list.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo sh -c &apos;echo &quot;deb https://packages.sury.org/php/ $(lsb_release -sc) main&quot; &gt; /etc/apt/sources.list.d/php.list&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Update the package list to verify it&apos;s working.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt update
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Install PHP 8.2 packages&lt;/h3&gt;
&lt;p&gt;Install PHP 8.2 and common extensions.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt install php8.2 php8.2-cli php8.2-fpm php8.2-mysql php8.2-curl php8.2-gd php8.2-mbstring php8.2-xml php8.2-zip php8.2-opcache php8.2-sqlite3 -y
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Install Caddy v2&lt;/h2&gt;
&lt;p&gt;I found many guides using Caddy v1 plus special rewrite rules, but one important upgrade in Caddy v2 is that you don&apos;t need extra rewrite rules for typical setups — v2 is simply more convenient. Don&apos;t try to force old patterns.&lt;/p&gt;
&lt;p&gt;Caddy provides an official script; this is what I recommend:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf &apos;https://dl.cloudsmith.io/public/caddy/stable/gpg.key&apos; | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf &apos;https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt&apos; | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install caddy
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you need extra plugins (for example, DNS providers), you can use xcaddy to build your own binary, but that&apos;s out of scope for this post.&lt;/p&gt;
&lt;h2&gt;Configure the Caddyfile&lt;/h2&gt;
&lt;p&gt;Again: I prefer you learn to use the Caddyfile rather than the JSON config.&lt;/p&gt;
&lt;p&gt;The following Caddyfile has been tested — paste and use it as-is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;YOUR WEBSITE {
            encode gzip
            log
            tls YOUR EMAIL
            header Strict-Transport-Security max-age=31536000
            root * /var/www/YOUR WEBSITE
            php_fastcgi unix//run/php/php8.2-fpm.sock
            file_server
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Do you now appreciate Caddy v2&apos;s convenience? You don&apos;t need to configure php-fpm details or rewrite rules — it&apos;s basically ready out of the box.&lt;/p&gt;
&lt;p&gt;Everything is self-explanatory, but remember to replace &lt;code&gt;YOUR WEBSITE&lt;/code&gt; and &lt;code&gt;YOUR EMAIL&lt;/code&gt; with your actual domain and email address. If you want to read more about Caddyfile options, check the &lt;a href=&quot;https://caddyserver.com/docs/caddyfile&quot;&gt;official documentation&lt;/a&gt;, &quot;it is amazingly easy to read&quot; (my peer who studies Medical Sciences has said so).&lt;/p&gt;
&lt;h2&gt;Final step: add your Typecho site files&lt;/h2&gt;
&lt;p&gt;Download the latest Typecho release with wget:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;wget https://github.com/typecho/typecho/releases/latest/download/typecho.zip
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Unzip Typecho into /var/www:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;Make sure /var/www exists:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo mkdir -p /var/www
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then create your site directory; for example, if your site is 20051110.xyz, create:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo mkdir /var/www/20051110.xyz
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Unzip typecho.zip into /var/www/your-site-directory:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo cd /var/www/your-site-directory
sudo unzip /root/typecho.zip # remember to replace with the actual download location of Typecho
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Final step: change file ownership of /var/www and its subdirectories to the www-data user and group (the user typically used by web servers):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo chown -R www-data:www-data /var/www
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can check that your directory structure looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;/var/www/your-site
├── admin/
├── install/
├── usr/
├── var/
├── index.php
├── install.php
└── ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If everything is correct, start Caddy:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;caddy run --config=Caddyfile # I ran this because my Caddyfile is in the same directory
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Caddy will automatically obtain certificates for you. Once that&apos;s done, visit the site and proceed with the Typecho installation (don&apos;t worry — it&apos;s a GUI, just click through).&lt;/p&gt;
&lt;p&gt;Thanks for reading! Hope this post helps.&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>A Few Things About OpenWRT Compilation</title><link>https://theunknownth.ing/blog/openwrt-compile</link><guid isPermaLink="true">https://theunknownth.ing/blog/openwrt-compile</guid><description>I encountered some issues during OpenWRT compilation that are worth documenting.</description><pubDate>Sun, 20 Oct 2024 01:05:00 GMT</pubDate><content:encoded>&lt;p&gt;Let me answer a few questions first&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Why compile it myself? I&apos;m a mature computer science student (lol).&lt;/li&gt;
&lt;li&gt;Why use Github Actions? Because Github is really convenient ~~I originally wanted to use my dedicated AMD 9950X as the build machine, but it failed after 15 minutes and I was too lazy to troubleshoot~~&lt;/li&gt;
&lt;li&gt;Why can&apos;t I understand this? ~~If you don&apos;t understand, don&apos;t read it.~~ Just download pre-compiled packages from others.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The following is based on the latest OpenWRT (23.05) + Github Actions online compilation&lt;/p&gt;
&lt;h2&gt;1. Preparation&lt;/h2&gt;
&lt;h3&gt;Clone the repository locally&lt;/h3&gt;
&lt;p&gt;First, you need to Fork the LEDE source code. LEDE repository [Github][1]&lt;/p&gt;
&lt;p&gt;Clone the repository you just forked to your local machine:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git clone https://github.com/your-username/lede
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;Don&apos;t download ZIP! The ZIP file is not a Git repository and doesn&apos;t contain the &lt;code&gt;.git&lt;/code&gt; folder, so you can&apos;t use &lt;code&gt;git&lt;/code&gt; commands on it.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;Update Feeds&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;cd lede
./scripts/feeds update -a
./scripts/feeds install -a
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you don&apos;t update the feeds, you won&apos;t see Luci apps later! &lt;strong&gt;This step is mandatory!&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;Enter the configuration menu&lt;/h3&gt;
&lt;p&gt;Use the following command to enter the configuration menu:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;make menuconfig
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Configuration menu explanation&lt;/h3&gt;
&lt;p&gt;Generally, you only need to modify these:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Target System: Processor architecture&lt;/li&gt;
&lt;li&gt;Subtarget: Select processor&lt;/li&gt;
&lt;li&gt;Target Profile: Preconfigured profile&lt;/li&gt;
&lt;li&gt;LuCI: LuCI plugins
&lt;ul&gt;
&lt;li&gt;Applications: Applications&lt;/li&gt;
&lt;li&gt;Themes: Themes&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For example, I selected:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Target System: Mediatek-ARM&lt;/li&gt;
&lt;li&gt;Subtarget: Filogic&lt;/li&gt;
&lt;li&gt;Target Profile: ASR3000&lt;/li&gt;
&lt;li&gt;LuCI: LuCI plugins
&lt;ul&gt;
&lt;li&gt;Applications: Many fun plugins for you to explore!&lt;/li&gt;
&lt;li&gt;Themes: luci-theme-material&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;After making changes, select &lt;code&gt;Save&lt;/code&gt; to save as a &lt;code&gt;.config&lt;/code&gt; file.&lt;/p&gt;
&lt;p&gt;For Luci plugins, please refer to [this article on the Enshan forum][2]&lt;/p&gt;
&lt;h3&gt;Commit to your forked repository&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Delete the &lt;code&gt;/.config&lt;/code&gt; line in the &lt;code&gt;.gitignore&lt;/code&gt; file to stop ignoring the config file.&lt;/strong&gt; Very important!!! Otherwise, the &lt;code&gt;.config&lt;/code&gt; file won&apos;t be included when you &lt;code&gt;commit&lt;/code&gt;!!!&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Commit changes to GitHub:&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git add .
git commit -m &quot;upd: personal config&quot;
git push origin master
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;~~The branch is called master, has a master-servant flavor to it~~&lt;/p&gt;
&lt;h2&gt;Pitfalls:&lt;/h2&gt;
&lt;h3&gt;Enable WIFI before compilation&lt;/h3&gt;
&lt;p&gt;If you need to enable WIFI by default for easy management, I searched many tutorials online but they were useless, mostly from around 2015 with no reference value. I figured it out myself:
Go to the &lt;code&gt;package/lean/default-settings/files/&lt;/code&gt; directory, edit the file &lt;code&gt;zzz-default-settings&lt;/code&gt;
Comment out these two lines by adding &lt;code&gt;#&lt;/code&gt; at the beginning:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sh&quot;&gt;sed -i &apos;/option disabled/d&apos; /etc/config/wireless
sed -i &apos;/set wireless.radio${devidx}.disabled/d&apos; /lib/wifi/mac80211.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Github Actions Compilation&lt;/h3&gt;
&lt;p&gt;Online guides still say &quot;submit a Release and it will automatically trigger Github Actions&quot; but that didn&apos;t work for me, so I needed to make some changes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;When using Github Actions for compilation, remember to go to the Workflow page and enable the Workflow, and also enable OpenWrt-CI (because Workflows in forked repositories are disabled by default)&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Also modify the repository&apos;s &lt;code&gt;.github/workflows/openwrt-ci.yml&lt;/code&gt;, changing the &lt;code&gt;cron&lt;/code&gt; task at the beginning (line 10) to the following to allow manual workflow triggering:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;on:
  repository_dispatch:
  workflow_dispatch:
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It will take about two hours, ~~but what does that have to do with me since I&apos;m using Github&apos;s resources~~&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Modifying various miscellaneous settings&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Change the default theme
&lt;pre&gt;&lt;code&gt;sed -i &quot;s/luci-theme-bootstrap/luci-theme-material/g&quot; feeds/luci/collections/luci/Makefile
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt; (Nowadays people&apos;s aesthetics seem to prefer the argon theme, anyway this should match what you installed in the `luci-themes` section of your `.config`)
- Add compiler information
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;sed -i &quot;s/OpenWrt /TheUnknownThing build $(TZ=UTC-8 date &quot;+%Y.%m.%d&quot;) @ OpenWrt /g&quot; package/lean/default-settings/files/zzz-default-settings&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;You probably don&apos;t want to keep &quot;TheUnknownThing&quot; as your builder name, change it to something else.
- Modify the default management address
The default management address is `192.168.1.1`, if it conflicts with your upstream network segment, you can modify it
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;sed -i &apos;s/192.168.1.1/192.168.2.1/g&apos; package/base-files/files/bin/config_generate&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;This changes it to `192.168.2.1`



[1]: https://github.com/coolsnowwolf/lede
[2]: https://www.right.com.cn/forum/thread-3682029-1-1.html
&lt;/code&gt;&lt;/pre&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>How to Elegantly Annotate PDFs with LaTeX</title><link>https://theunknownth.ing/blog/latex-annotation-1</link><guid isPermaLink="true">https://theunknownth.ing/blog/latex-annotation-1</guid><description>My professor shared a PDF Beamer file, and I want to add mathematical annotations to it. How can I do this elegantly?</description><pubDate>Wed, 09 Oct 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Let me cut to the chase—it&apos;s getting late and I need some sleep!&lt;/p&gt;
&lt;p&gt;The inspiration for this solution comes from &lt;a href=&quot;https://tex.stackexchange.com/questions/85651/is-there-are-way-to-annotate-pdfs-with-latex#:~:text=Okular%20can%20annotate%20PDFs%20nicely,%20e.g.&quot;&gt;Stackexchange&lt;/a&gt;. I&apos;ve tried several annotation software options before, but I really want to bring only my iPad to class. Using VNC or xrdp to remotely access Linux just feels clunky and has terrible latency. So I&apos;ve settled on using Overleaf + LaTeX with PDF page inclusion for annotations.&lt;/p&gt;
&lt;p&gt;The Stackexchange author provided this solution:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;\documentclass{article}
%\url{http://tex.stackexchange.com/q/85651/86}
\usepackage[svgnames]{xcolor}
\usepackage{pdfpages}
\usepackage{tikz}

\tikzset{
  every node/.style={
    anchor=mid west,
  }
}

\makeatletter
\pgfkeys{/form field/.code 2 args={\expandafter\global\expandafter\def\csname field@#1\expandafter\endcsname\expandafter{#2}}}

\newcommand{\place}[3][]{\node[#1] at (#2) {\csname field@#3\endcsname};}
\makeatother
\newcommand{\xmark}[1]{\node at (#1) {X};}

\begin{document}

\foreach \mykey/\myvalue in {
  ctsfn/{Defined in Week 1},
  metsp/{Defined in Week 3},
} {
  \pgfkeys{/form field={\mykey}{\myvalue}}
}

\includepdf[
  pages=1,
  picturecommand={%
    \begin{tikzpicture}[remember picture,overlay]
%%% The next lines draw a useful grid - get rid of them (comment them out) on the final version
    \draw[gray] (current page.south west) grid (current page.north east);
\foreach \k in {1,...,28} {
      \path (current page.south east) ++(-2,\k) node {\k};
}
\foreach \k in {1,...,20} {
      \path (current page.south west) ++(\k,2) node {\k};
}
%%% grid code ends here
\tikzset{every node/.append style={fill=Honeydew,font=\large}}
\place[name=ctsfn]{14cm,17cm}{ctsfn}
\place[name=metsp]{11cm,9cm}{metsp}
\draw[ultra thick,blue,-&gt;] (ctsfn) to[out=135,in=90] (9cm,17.3cm);
\draw[ultra thick,blue,-&gt;] (metsp) to[out=155,in=70] (6cm,9cm);
    \end{tikzpicture}
  }
]{tikzmark_example.pdf}

\end{document}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The original author&apos;s result:
&lt;img src=&quot;https://theunknownth.ing/_astro/Z7cqd.DiFwxJ0H_Z4hInz.webp&quot; alt=&quot;Original author&amp;#x27;s result&quot;&gt;&lt;/p&gt;
&lt;p&gt;This immediately caught my eye because:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;It has a coordinate grid, making annotation placement super convenient&lt;/li&gt;
&lt;li&gt;It&apos;s highly extensible—you can mix text and graphics, insert TikZ diagrams, mathematical formulas, you name it!&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;However, there were several issues to address:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The professor&apos;s Beamer slides are in landscape format, but this code produces portrait output&lt;/li&gt;
&lt;li&gt;The macro definitions are somewhat messy, and I don&apos;t need fancy connecting lines. Plus, the &lt;code&gt;includepdf&lt;/code&gt; call is too verbose and inelegant for repeated use&lt;/li&gt;
&lt;li&gt;The coordinate grid looks pretty ugly&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So here&apos;s how I solved these problems:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Fix the orientation&lt;/strong&gt;: Use &lt;code&gt;\usepackage[paperwidth=12cm, paperheight=16cm, landscape]{geometry}&lt;/code&gt; to make it landscape format.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Create a clean macro&lt;/strong&gt; to simplify &lt;code&gt;includepdf&lt;/code&gt; usage and support multiple annotations:&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;\newcommand{\includePDFWithAnnotations}[2]{
\includepdf[
  pages=#1,
  picturecommand={%
    \begin{tikzpicture}[remember picture,overlay]
    %%% The next lines draw a useful grid - get rid of them (comment them out) on the final version
    \draw[very thin, lightgray] (current page.south west) grid (current page.north east);
    \foreach \k in {0,...,11} {
      \path (current page.south east) ++(-0.55,\k + 0.2) node[font=\tiny] {\k};
    }
    \foreach \k in {0,...,14} {
      \path (current page.south west) ++(\k,0.2) node[font=\tiny] {\k};
    }
    %%% grid code ends here
    \tikzset{every node/.append style={fill=Honeydew,font=\huge}}
    % Iterate through annotation list and place annotations
    #2
    \end{tikzpicture}
  }
]{YOUR PDF NAME.pdf}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;ol start=&quot;3&quot;&gt;
&lt;li&gt;&lt;strong&gt;Use the macro elegantly&lt;/strong&gt; to insert multiple annotations:&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;\includePDFWithAnnotations{1}{
\place{5, 4}{$123avd$}
\place{7, 8}{$456xyz$}
}

\includePDFWithAnnotations{7}{
\place{5, 4}{$123avd$}
\place{7, 8}{$456xyz$}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;ol start=&quot;4&quot;&gt;
&lt;li&gt;&lt;strong&gt;Improve the aesthetics&lt;/strong&gt;: Move the coordinate grid to the page edges, use tiny font size, make the lines thinner and lighter colored. Much more visually appealing!&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;https://theunknownth.ing/_astro/1.Bn-d4_uE_ZkrUiy.webp&quot; alt=&quot;Final result&quot;&gt;&lt;/p&gt;
&lt;p&gt;Isn&apos;t that satisfying?&lt;/p&gt;
&lt;p&gt;Here&apos;s the complete TeX example:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;\documentclass[UTF8]{ctexart}
\usepackage[svgnames]{xcolor}
\usepackage[paperwidth=12cm, paperheight=16cm, landscape]{geometry}
\usepackage{pdfpages}
\usepackage{tikz}
\usepackage{amsmath,amsfonts,amssymb,amsthm}

\tikzset{
  every node/.style={
    anchor=mid west,
  }
}

\makeatletter
\pgfkeys{/form field/.code 2 args={\expandafter\global\expandafter\def\csname field@#1\expandafter\endcsname\expandafter{#2}}}

\newcommand{\place}[2]{\node at (#1) {\large #2};}
\makeatother

\newcommand{\xmark}[1]{\node at (#1) {X};}

\newcommand{\NotePage}[2]{
  \includepdf[
    pages=#1,
    picturecommand={%
      \begin{tikzpicture}[remember picture,overlay]
      %%% The next lines draw a useful grid - get rid of them (comment them out) on the final version
      \draw[very thin, lightgray] (current page.south west) grid (current page.north east);
      \foreach \k in {0,...,11} {
        \path (current page.south east) ++(-0.45,\k + 0.2) node[font=\tiny] {\k};
      }
      \foreach \k in {0,...,14} {
        \path (current page.south west) ++(\k,0.2) node[font=\tiny] {\k};
      }
      \place{0,11.25}{Page #1}
      %%% grid code ends here
      \tikzset{every node/.append style={fill=Honeydew,font=\huge}}
      #2
      \end{tikzpicture}
    }
  ]{LA14.pdf}
}

\begin{document}

\NotePage{1}{
  \place{1,4.5}{That is because $\det{A} = \det{A^\top}$}
}
\NotePage{2}{}

\end{document}
&lt;/code&gt;&lt;/pre&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>Use lsyncd for Real-Time File Synchronization</title><link>https://theunknownth.ing/blog/rsync-lsyncd</link><guid isPermaLink="true">https://theunknownth.ing/blog/rsync-lsyncd</guid><description>The limitations of rsync + inotify forced us to look for better solutions.</description><pubDate>Sun, 06 Oct 2024 10:56:00 GMT</pubDate><content:encoded>&lt;p&gt;Ever since I became an mjj (server hoarder), I’ve accumulated a lot of VPSs—I just can’t resist buying more. But to keep my blog data synchronized across multiple servers, I’ve put in quite a bit of effort. I got tired of using cron to automatically package my blog directory and then manually back it up. Ultimately, it’s laziness—I want a fully automated solution. Since my Typecho blog is deployed via Docker images, I figured I’d go the extra mile and tackle multi-end data synchronization, so that any change on one site is reflected on all sites.&lt;/p&gt;
&lt;p&gt;When it comes to synchronizing files between multiple servers, rsync is a commonly used tool. It achieves efficient directory synchronization through incremental transfers, compression, and deletion operations. However, the classic working mode of rsync is “manual or scheduled trigger,” which falls short for scenarios requiring real-time synchronization.&lt;/p&gt;
&lt;h2&gt;How rsync Works&lt;/h2&gt;
&lt;p&gt;rsync compares the differences between the source and target directories and only transfers changed files or data blocks, reducing bandwidth usage. This method is ideal for backing up and synchronizing large amounts of data, especially in bandwidth-constrained environments. However, rsync usually needs to be triggered manually or via scheduled tasks (like cron). For applications that require real-time updates, this approach leads to data lag and resource waste.&lt;/p&gt;
&lt;h2&gt;The Shortcomings of rsync + inotify&lt;/h2&gt;
&lt;p&gt;To address real-time synchronization, you can use inotify to monitor file system changes and trigger rsync when changes occur. However, this approach has several obvious drawbacks:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;inotify requires additional scripts to work with rsync, increasing system complexity.&lt;/li&gt;
&lt;li&gt;This solution is usually one-way and cannot achieve multi-source real-time synchronization, which goes against my goals.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;The Advantages of lsyncd&lt;/h2&gt;
&lt;p&gt;To solve the above problems, lsyncd combines inotify’s real-time monitoring with rsync’s efficient transfer capabilities, providing a simple yet powerful solution for real-time synchronization. The advantages of lsyncd include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;lsyncd can handle complex real-time synchronization tasks with a simple configuration file, eliminating the need for extra scripts.&lt;/li&gt;
&lt;li&gt;It supports one-way synchronization between multiple servers, ensuring that data on every server is up to date. &lt;em&gt;Note: lsyncd does not natively support true bidirectional or multi-master sync with conflict resolution. But it doesn&apos;t matter in my case because I only need one-way sync.&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Step-by-Step Guide to Configuring lsyncd&lt;/h2&gt;
&lt;p&gt;Here’s how to use lsyncd for real-time synchronization:&lt;/p&gt;
&lt;h3&gt;Install lsyncd and rsync:&lt;/h3&gt;
&lt;p&gt;On all servers involved in synchronization, run the following command to install the necessary tools:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt-get install lsyncd rsync
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Configure lsyncd:&lt;/h3&gt;
&lt;p&gt;On each server, create the configuration file &lt;code&gt;/etc/lsyncd.conf&lt;/code&gt; with the following content:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-lua&quot;&gt;settings {
    logfile = &quot;/var/log/lsyncd/lsyncd.log&quot;,
    statusFile = &quot;/var/log/lsyncd/lsyncd.status&quot;,
    inotifyMode  = &quot;CloseWrite or Modify&quot;,
    maxProcesses = 1,
    -- nodaemon = true,
}

sync {
    default.rsyncssh,
    source = &quot;/var/www&quot;,
    targetdir = &quot;/var/www&quot;,
    host = &quot;45.*.*.*&quot;,
    delete = true,
    rsync = {
        binary = &quot;/usr/bin/rsync&quot;,
        archive = true,
        compress = true,
        verbose = true,
    },
    delay = 1,
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Explanation:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;source&lt;/code&gt;: The local directory to monitor, &lt;code&gt;/var/www/&lt;/code&gt; (replace with your own).&lt;/li&gt;
&lt;li&gt;&lt;code&gt;host&lt;/code&gt;: The remote target server (excluding itself).&lt;/li&gt;
&lt;li&gt;&lt;code&gt;targetdir&lt;/code&gt;: The remote target directory, &lt;code&gt;/var/www/&lt;/code&gt; (replace with your own).&lt;/li&gt;
&lt;li&gt;&lt;code&gt;delay&lt;/code&gt;: Sets the synchronization delay (in seconds) to prevent excessive syncing during frequent changes.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;delete&lt;/code&gt;: Deletes files on the target server that have been deleted on the source server.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Note: When using &lt;code&gt;rsyncssh&lt;/code&gt;, &lt;code&gt;maxProcesses&lt;/code&gt; must be 1. If using &lt;code&gt;rsync&lt;/code&gt;, you can set a higher value (e.g., 5).&lt;/p&gt;
&lt;p&gt;Tip: For troubleshooting, it’s recommended to start with &lt;code&gt;lsyncd /etc/lsyncd.conf&lt;/code&gt; to check for errors. Also, make sure to create the log directory first: &lt;code&gt;mkdir -p /var/log/lsyncd&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;One more thing: To allow servers to log in to each other without a password, you need to set up SSH key-based authentication.&lt;/p&gt;
&lt;p&gt;To automate real-time synchronization, ensure the source server can log in to the target server via SSH without a password.&lt;/p&gt;
&lt;p&gt;On the source server, generate an SSH key:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ssh-keygen -t ed25519
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Follow the prompts; usually, you don’t set a passphrase.&lt;/p&gt;
&lt;p&gt;Copy the public key to the target server:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ssh-copy-id user@target-server
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This copies the generated public key to the target server, enabling passwordless login. Note: You need to configure this on both servers if you want mutual access.&lt;/p&gt;
&lt;p&gt;Once configured, start verification.&lt;/p&gt;
&lt;h3&gt;Start lsyncd:&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;lsyncd /etc/lsyncd.conf
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Verify the Configuration:&lt;/h3&gt;
&lt;p&gt;Perform file operations in the &lt;code&gt;/var/www/&lt;/code&gt; directory on any server and check synchronization on the others.&lt;/p&gt;
&lt;h2&gt;Handling Conflicts&lt;/h2&gt;
&lt;p&gt;When multiple servers modify the same file at the same time, conflicts may occur. However, my use case probably won’t encounter conflicts, so I’m leaving it as is :D&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>Disabling Adobe Acrobat&apos;s OCR Feature</title><link>https://theunknownth.ing/blog/delete-acrobat-ocr</link><guid isPermaLink="true">https://theunknownth.ing/blog/delete-acrobat-ocr</guid><description>Acrobat’s OCR really annoys me. Every time I edit a PDF, it freezes for a moment. So I’m just going to disable it once and for all.</description><pubDate>Wed, 11 Sep 2024 19:11:00 GMT</pubDate><content:encoded>&lt;p&gt;Acrobat’s OCR really annoys me. Every time I edit a PDF, it freezes for a moment—I have to wait for the current page’s OCR to finish before I can turn off automatic text recognition. So now, I’m just going to disable it once and for all. Go to this directory:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;C:\Program Files (x86)\Adobe\Acrobat DC\Acrobat\plug_ins&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Do you see &quot;PaperCapture&quot; there? Just rename it to &quot;PaperCapture_disabled&quot; and you’re done.&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>方正书版10.0从安装到入门</title><link>https://theunknownth.ing/blog/founder-book-10</link><guid isPermaLink="true">https://theunknownth.ing/blog/founder-book-10</guid><description>方正书版10.0从安装到入门</description><pubDate>Sat, 16 Mar 2024 21:05:00 GMT</pubDate><content:encoded>&lt;p&gt;今儿花了一下午总算是把方正书版10.0搞定了。&lt;/p&gt;
&lt;h2&gt;1. 安装PDF Creator&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;方正PDFCreator 3.0&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;重要提示：请务必在系统装完后第一时间就安装字库和PDF Creator（虽然我不信这个邪，但是确实这样会少很多乱七八糟字体的干扰或者你从另外什么地方安装了字库的干扰）&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;安装顺序如下：&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;安装PDFCreator3108；&lt;/li&gt;
&lt;li&gt;把破解文件覆盖在安装文件的目录下C:\ProgramFiles\Founder\PDFCreator\Bin&lt;/li&gt;
&lt;li&gt;导入注册表〖根据你安装在哪个盘上要修改盘符和路径〗；(没安装RIP软件的，才导入这个注册表文件。目的是“欺骗”系统，让系统认为你安装了RIP软件)&lt;/li&gt;
&lt;li&gt;先安装CID5.01(748_GB)字库，“方正CID V5.00〖全套〗”安装密码：安装系列号：000000000 安装密码：42C2D35B4735036B; 字体密码：5918347506891A57（包括GBK、GB/748，都一样！） 再安装CID5.0(GBK)字库，安装序列号000000000安装密码：ce9d84241294e529;字体密码：2e4965af7e74ad68 ；安装字库时选择“方正世纪RIP”(我这里没有弹出选择这个选项，不过不要紧，还是顺利安装了)；&lt;/li&gt;
&lt;li&gt;字库路径为:C:\ProgramFiles\Founder\PDFCreator\Font，此时会在这个目录下生成一个fonts的目录C:\Program Files\Founder\PDFCreator\Fonts；（也可能不会生成！这个时候需要另外一个文件帮忙！安装完两个字库一半会生成一个FONTS文件夹，但很多人电脑偏偏没有生成，有些简单的后端字体识别不出，所以要借用PSPNT的FONTS文件夹字体来补充。）&lt;/li&gt;
&lt;li&gt;打开PDFCreator 3108；&lt;/li&gt;
&lt;li&gt;字体重置：字体路径为: C:\ProgramFiles\Founder\PDFCreator\Resource\CIDfont；TrueType字体路径为：C:\WINDOWS\Fonts&lt;/li&gt;
&lt;li&gt;PDFCreator重置字库时候千万不要去点它或者动它，不然会死机，只能重启。&lt;/li&gt;
&lt;li&gt;我这边提示安装成功了1100多个字体，最后可以正常输出PDF。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;2. 安装书版10.0&lt;/h2&gt;
&lt;p&gt;这个不用多说了，安装完成以后把修改的文件复制到安装目录下。&lt;/p&gt;
&lt;p&gt;注意：在这一步就安装书版10.0还有女娲补字就好了，别的一切都不要安装，包括这里面什么字体啊什么的，免得到时出问题。&lt;/p&gt;
&lt;h2&gt;3. PS输出设置&lt;/h2&gt;
&lt;p&gt;在FBD输出PS/EPS时候点开左下角的“选项”，我这里比较粗暴，把后端748字库和后端GBK字库都勾选了“全部已安装”。按照我的测试来看，只要你的PDFCreator配置良好，这样就可以正常输出PDF了。在输出之前不妨去网络上随便找个正常的PS文件试试看，避免是因为自己PS有问题错怪了PDFCreator。&lt;/p&gt;
&lt;h2&gt;4. Word文件转FBD小样的一些问题&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;已知书版10.0的doc文件转换是broken的，不要用。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;在使用这个网络上大神开发的软件时，小样的输出会有一些问题，这里总结如下：&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;如果你在用Word转FBD 6.0版本，那么最终doc文件里的所有MathType公式都保留其原样即可，不要跟着网上的教程转换啊什么的；如果你用的是5.6版本，那么需要跟着网上教程走。&lt;/p&gt;
&lt;p&gt;MathType转换过后sin, cos, &lt;code&gt;π&lt;/code&gt;, ln……这种数学符号会变斜，需要自己纠正。我写了个VSCode里用的正则表达式可以参考（Ctrl+F查找替换，选择正则表达式）（这里看的会是乱码，但是粘贴进去就是那个圈z）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;上面填这些：(cos|sin|tan|π|lim|ln|i)
下面填这些：$1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同样对于根号和字符贴在一起的情况，需要在&lt;code&gt;〖KF(〗&lt;/code&gt;前加圈1/2，同样可以使用替换来实现。&lt;/p&gt;
&lt;p&gt;对于选择题选项的排版，这里写了一个Python小程序，你只需要配置好第一个选择题的WB即可，它实现了以下功能：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;在开头和结尾加上〖ZK(〗和〖ZK)〗（ZK+换行符）&lt;/li&gt;
&lt;li&gt;替换（换段）符号（除了第一处）为〖DW1〗到〖DW3〗&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;import pyperclip

def transform_text(input_text):
    # 在开头和结尾加上特定标记
    transformed_text = &apos;〖ZK(〗&apos; + input_text + &apos;〖ZK)〗&apos;

    # 替换符号为〖DW1〗到〖DW3〗，跳过第一个符号
    parts = transformed_text.split(&apos;&apos;)
    transformed_text = &apos;&apos;.join(parts[:2])  # 保留第一个符号
    for i, part in enumerate(parts[2:], 1):  # 从第二个符号开始替换
        transformed_text += &apos;〖DW&apos; + str(i) + &apos;〗&apos; + part

    return transformed_text

# 原始文本
original_text = &apos;&apos;
original_text = pyperclip.paste()

transformed_text = transform_text(original_text)

# 将转换后的文本打印出来
print(transformed_text)
pyperclip.copy(transformed_text)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;至此，请愉快地开启你的排版生涯吧！&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>Starting to Write Again</title><link>https://theunknownth.ing/blog/hello-world</link><guid isPermaLink="true">https://theunknownth.ing/blog/hello-world</guid><description>It probably began with a sudden inspiration during the winter break, preparing to take care of my blog again.</description><pubDate>Sat, 09 Mar 2024 22:44:00 GMT</pubDate><content:encoded>&lt;p&gt;It probably began with a sudden inspiration during the winter break, preparing to take care of my blog again.&lt;/p&gt;
&lt;p&gt;The last time I seriously ran a blog was perhaps in middle school. Back then, I thought having a Blogger was cool, and having your own space for thoughts on the internet was great and trendy—I guess it&apos;s not like that anymore?&lt;/p&gt;
&lt;p&gt;CNBlogs urges everyone to turn off ad-blocking plugins, the atmosphere on CSDN in China keeps getting worse, and Bloggers have turned into Vloggers—why am I starting to take care of my blog again at this time?&lt;/p&gt;
&lt;p&gt;I didn&apos;t expect to encounter so many difficulties deploying Typecho... My previous setup was Nginx+MySQL+BaoTa, so you can understand that BaoTa had taken care of everything; all I needed to do was click the deployment button and it was ready to go.&lt;/p&gt;
&lt;p&gt;So maybe the first thing I tried to do to become a &lt;em&gt;True&lt;/em&gt; Blogger is to organize all the things myself.&lt;/p&gt;
&lt;p&gt;I dropped Nginx for Caddy2 (isn&apos;t this just asking for trouble? I couldn&apos;t find any working URL rewrite configurations online after searching for days!) Of course, I thought about giving up (php-fpm configuration was fine, mysql configuration was fine, Caddy rewrite was fine and the homepage was accessible, but articles were unreadable? Login page worked but couldn&apos;t log in?) In the end, the almighty Docker solved the problem, and I think it&apos;s worth pasting the setup here for backup:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker run -d \
--name=typecho-blog \
--restart always \
--mount type=tmpfs,destination=/tmp \
-v /root/Typecho-Files:/data \
-e PHP_TZ=Asia/Shanghai \
-e PHP_MAX_EXECUTION_TIME=600 \
-p 127.0.0.1:9080:80 \
80x86/typecho:latest
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here I didn&apos;t expose the host port because I planned to use Caddy as a reverse proxy. When using Caddy as a reverse proxy, be aware of these two pitfalls I encountered (initially I could only access the homepage, but clicking on any content would redirect me to localhost:9080 which was inaccessible—how to solve it? Turns out I didn&apos;t properly set X-Forwarded-Proto and X-Forwarded-Port):&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Check Typecho&apos;s config.inc.php file to ensure that &lt;strong&gt;TYPECHO_SITE_URL&lt;/strong&gt; is set to your public domain.&lt;/li&gt;
&lt;li&gt;In the Caddy configuration, make sure to set the correct X-Forwarded-For and X-Forwarded-Proto headers so Typecho knows the actual request protocol and client IP.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Your Caddy configuration should look like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;YOUR_DOMAIN_GOES_HERE {
  reverse_proxy http://localhost:9080 {
       header_up Host {host}
       header_up X-Forwarded-Host {host}
       header_up X-Forwarded-For {remote_host}
       header_up X-Forwarded-Proto {scheme}
    }
  tls YOUR_EMAIL_GOES_HERE
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Great, I finally have my own blog again. Hope I can write more in the future.&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item></channel></rss>