diff --git a/.gitignore b/.gitignore index 61bae185..4501bbd8 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,6 @@ site/.vitepress/dist site/.vitepress/.build-tmp site/.vitepress/.build-cache site/.vitepress/cache + +# 误生成的嵌套 build 产物 +site/site/ diff --git a/document/tutorials/debugging/01-debug-printk.md b/document/tutorials/debugging/01-debug-printk.md new file mode 100644 index 00000000..bd9e1bba --- /dev/null +++ b/document/tutorials/debugging/01-debug-printk.md @@ -0,0 +1,281 @@ +--- +title: printk:内核调试的生命线 +slug: debug-printk +difficulty: intermediate +tags: [printk, 日志系统, 调试, 动态调试] +architectures: [arm64, x86_64, riscv] +kernel_version: "6.19" +maturity: drafting +prerequisites: + - /tutorials/foundations/07-kernel-module-hello +related: [] +sources: + - notes: document/notes/linux_kernel_debugging/ch03.md + - notes: document/notes/linux_kernel_debugging/ch03_2.md + - notes: document/notes/linux_kernel_debugging/ch03_3.md + - notes: document/notes/linux_kernel_debugging/ch03_4.md + - notes: document/notes/linux_kernel_debugging/ch03_5.md +--- + +# printk:内核调试的生命线 + +> 🔨 **整理中** · 本篇机制对照 Linux 6.19 源码讲解,关键函数 / 数据结构 / 字段名都已核对过(`kernel/printk/printk.c`、`include/linux/printk.h`、`kernel/printk/printk_ringbuffer.h`、`include/linux/kern_levels.h`);具体行号、命令输出和 dmesg 样例还没拿到 QEMU 上亲测,标了「待亲测」的就是这个意思。 + +## 为什么内核调试离不开 printk + +在用户空间,我们想看程序跑到哪了,随手一个 `printf`。但进了内核,这条路断了——**内核里没有 `printf`,也没有 libc**。内核不按用户空间那一套去链接库(不管动态还是静态),它的 `lib/` 目录里那些 API 是直接编译进内核镜像的。 + +那想确认一个变量、想看代码走到哪一步怎么办?唯一的笨办法就是**插桩**——在代码里埋打印,看输出。而内核里干这件事的瑞士军刀,就是 `printk()`。 + +它能在几乎所有上下文里安然工作:硬中断、软中断、tasklet、普通进程上下文,甚至持着自旋锁的临界区。这背后是一套精心设计的无锁环形缓冲区,待会深挖。先把它的签名看一眼,跟 `printf` 几乎一模一样: + +```c +// include/linux/printk.h +asmlinkage __printf(1, 2) __cold +int _printk(const char *fmt, ...); +``` + +`printk` 本身是个宏,`#define printk(fmt, ...) printk_index_wrap(_printk, fmt, ##__VA_ARGS__)`,真正干活的实体是 `_printk`(Linux 6.19)。它的实现躺在 `kernel/printk/printk.c`,最终经 `vprintk_default()` → `vprintk_emit()` → `vprintk_store()` 把消息塞进环形缓冲区。 + +## 八级 loglevel:给消息定个轻重缓急 + +`printk` 和 `printf` 最显眼的差别,是格式字符串开头要带一个**日志级别**前缀: + +```c +printk(KERN_INFO "Hello, kernel debug world\n"); +``` + +注意,`KERN_INFO` 不是第二个参数——C 的字符串拼接把它和后面的字面量合并成了同一个 `fmt`。它本质上是个标记,定义在 `include/linux/kern_levels.h`: + +```c +// include/linux/kern_levels.h +#define KERN_SOH "\001" /* ASCII Start Of Header */ +#define KERN_EMERG KERN_SOH "0" /* system is unusable */ +#define KERN_ALERT KERN_SOH "1" /* action must be taken immediately */ +#define KERN_CRIT KERN_SOH "2" /* critical conditions */ +#define KERN_ERR KERN_SOH "3" /* error conditions */ +#define KERN_WARNING KERN_SOH "4" /* warning conditions */ +#define KERN_NOTICE KERN_SOH "5" /* normal but significant condition */ +#define KERN_INFO KERN_SOH "6" /* informational */ +#define KERN_DEBUG KERN_SOH "7" /* debug-level messages */ +``` + +说白了,`KERN_` 就是字符串 `"0"`~`"7"` 前面拼一个值为 `\001` 的控制字符(SOH)。同文件里还有对应的整型宏 `LOGLEVEL_EMERG`(0) … `LOGLEVEL_DEBUG`(7),后面在模块里打印数字时会用到。 + +### console_loglevel 决定哪些上控制台 + +关键问题:一条消息会**同时**刷到屏幕上吗?取决于 **console_loglevel**。在 `kernel/printk/printk.c` 顶部有一个数组: + +```c +// kernel/printk/printk.c +int console_printk[4] = { + CONSOLE_LOGLEVEL_DEFAULT, /* console_loglevel */ + MESSAGE_LOGLEVEL_DEFAULT, /* default_message_loglevel*/ + CONSOLE_LOGLEVEL_MIN, /* minimum_console_loglevel*/ + CONSOLE_LOGLEVEL_DEFAULT, /* default_console_loglevel*/ +}; +``` + +然后 `include/linux/printk.h` 把这四个槽位起了别号,便于按名字访问: + +```c +// include/linux/printk.h +extern int console_printk[]; +#define console_loglevel (console_printk[0]) +#define default_message_loglevel (console_printk[1]) +#define minimum_console_loglevel (console_printk[2]) +#define default_console_loglevel (console_printk[3]) +``` + +判断一条消息要不要屏蔽出控制台,源码就一行(`kernel/printk/printk.c` 的 `suppress_message_printing()`): + +```c +return (level >= console_loglevel && !ignore_loglevel); +``` + +——级别数值**大于等于** `console_loglevel` 的(也就是更不紧急的)就被压在缓冲区里不上屏。`console_loglevel` 默认通常配成 7(`CONSOLE_LOGLEVEL_DEFAULT` 来自 `CONFIG_CONSOLE_LOGLEVEL_DEFAULT`),意思是默认连 `KERN_DEBUG` 都上控制台;但很多发行版会调低,实际只放 `<=` 某个值的。`/proc/sys/kernel/printk` 里那四个数字,就对应 `console_printk[]` 这四个槽位(待亲测核对): + +``` +$ cat /proc/sys/kernel/printk +4 4 1 7 +``` + +想临时全开调试,就 `echo 8 > /proc/sys/kernel/printk`(8 大于任何 0~7 的级别,于是全放行)。但这通常是坏主意——内核里 DEBUG 级日志浩如烟海,瞬间刷屏。 + +## 偷懒封装:pr_* 和 pr_fmt + +每次写 `printk(KERN_INFO "...")` 太啰嗦,内核给了一套封装宏(`include/linux/printk.h`),优先用它们。八级 loglevel 正好对应八个 `pr_` 档位,外加一个不换行的 `pr_cont`: + +```c +// include/linux/printk.h +#define pr_emerg(fmt, ...) printk(KERN_EMERG pr_fmt(fmt), ##__VA_ARGS__) +#define pr_alert(fmt, ...) printk(KERN_ALERT pr_fmt(fmt), ##__VA_ARGS__) +#define pr_crit(fmt, ...) printk(KERN_CRIT pr_fmt(fmt), ##__VA_ARGS__) +#define pr_err(fmt, ...) printk(KERN_ERR pr_fmt(fmt), ##__VA_ARGS__) +#define pr_warn(fmt, ...) printk(KERN_WARNING pr_fmt(fmt), ##__VA_ARGS__) +#define pr_notice(fmt, ...) printk(KERN_NOTICE pr_fmt(fmt), ##__VA_ARGS__) +#define pr_info(fmt, ...) printk(KERN_INFO pr_fmt(fmt), ##__VA_ARGS__) +#define pr_cont(fmt, ...) printk(KERN_CONT fmt, ##__VA_ARGS__) +``` + +`pr_debug` 和 `pr_devel` 另有讲究(一个挂动态调试、一个直接编译消除),留到后文「动态调试」一节细讲。注意每个宏里都套了一层 `pr_fmt(fmt)`。`pr_fmt` 是个"元宏",头文件里默认定义成 `#define pr_fmt(fmt) fmt`(透传)。但只要你在源文件**第一行非注释处**重新定义它,后续所有 `pr_*` 都会被自动套上前缀: + +```c +#define pr_fmt(fmt) "%s:%s():%d: " fmt, KBUILD_MODNAME, __func__, __LINE__ +``` + +这一招让每条日志自动带上 `模块名:函数名:行号:`,多模块同时刷日志时能一眼分清谁在说话,调试时救命。另外 `pr_cont()` 是个特殊家伙,它不带换行、把内容**追加**到上一条 `printk` 末尾,用来拼多段式日志。 + +### dev_dbg:写驱动的首选 + +如果写的是设备驱动,规则要升级——别用 `pr_debug`,用 `dev_dbg`。它的第一个参数是 `struct device *`,能自动把设备名、总线信息塞进日志,多个同类设备并存时是救命稻草。配套的有整套 `dev_emerg / dev_err / dev_warn / dev_notice / dev_info / dev_dbg`。更妙的是 `dev_dbg` 和 `pr_debug` 一样,背后挂着动态调试(见后文)。这条设备上下文元数据在 ringbuffer 里其实有专门位置承载,待会讲 `printk_info` 时会点名。 + +## 消息去向三处 + +发出去的字符到底流落何处?和用户空间 `printf` 直接到 `stdout` 不同,内核这套"下水道"分三层: + +| 去向 | 说明 | 怎么看 | +|:---|:---|:---| +| **内核环形缓冲区** | 所有 `printk` 的第一站,内存里固定大小的循环队列 | `dmesg`、`/proc/kmsg`、`/dev/kmsg` | +| **控制台** | 级别足够紧急(数值 ≤ `console_loglevel`)时直接刷屏 | 屏幕 / 串口直接看 | +| **持久化日志** | systemd-journald 等守护进程从缓冲区读走落盘 | `/var/log/syslog`(Debian 系)、`journalctl` | + +第一站永远是环形缓冲区——这意味着日志先暂存在内存里,**重启就丢**(除非 `pstore` 持久化)。`dmesg` 读的就是这个缓冲区。 + +## ring buffer 机制深挖:lockless 设计 + +这是 `printk` 之所以能在任何上下文安全工作的根。早期内核的环形缓冲区是带自旋锁的 `log_buf`,NMI / 持锁路径里打印有死锁风险;Linux 5.10 起换成了一套**无锁(lockless)环形缓冲区**,核心数据结构定义在 `kernel/printk/printk_ringbuffer.h`(Linux 6.19)。 + +它把"元数据"和"正文"分成两个独立的环: + +```c +// kernel/printk/printk_ringbuffer.h +struct printk_ringbuffer { + struct prb_desc_ring desc_ring; /* 描述符环 */ + struct prb_data_ring text_data_ring; /* 正文数据环 */ + atomic_long_t fail; +}; +``` + +每条记录的元数据用一个**描述符** `struct prb_desc` 表示,状态用原子变量 `state_var` 编码(同时塞进了描述符 ID 和状态:`desc_miss / desc_reserved / desc_committed / desc_finalized / desc_reusable`);正文指针存在 `text_blk_lpos` 里: + +```c +struct prb_desc { + atomic_long_t state_var; + struct prb_data_blk_lpos text_blk_lpos; +}; +``` + +描述符配套的 `struct printk_info` 存这条记录的"身份证"——序列号 `seq`、纳秒时间戳 `ts_nsec`、文本长度 `text_len`、`facility`、`level`(只有 3 bit!)、`caller_id`(线程或 CPU 标识),末尾还有一个 `dev_info`: + +```c +struct printk_info { + u64 seq; /* sequence number */ + u64 ts_nsec; /* timestamp in nanoseconds */ + u16 text_len; /* length of text message */ + u8 facility; /* syslog facility */ + u8 flags:5; /* internal record flags */ + u8 level:3; /* syslog level */ + u32 caller_id; /* thread id or processor id */ + + struct dev_printk_info dev_info; +}; +``` + +这里的 `dev_info`(`struct dev_printk_info`)专门装 `dev_dbg()` 那套设备上下文元数据——前面强调写驱动首选 `dev_dbg` 能自动带设备信息,底座就藏在这儿:`dev_*` 打印时把设备信息填进 `dev_info`,落进 ringbuffer 跟着记录一起存。两个数据环用 `head_lpos`/`tail_lpos`(正文)和 `head_id`/`tail_id`(描述符)做无锁推进,全是 `atomic_long_t`。写入流程在 `vprintk_store()` 里:先 `printk_enter_irqsave()` 防递归、`local_clock()` 取时间戳、`printk_parse_prefix()` 把字符串里的 `\001N` 解析成整数 `level`,然后 `prb_reserve()` 在环里**预留**一块槽位,写完正文再 `prb_commit()` 把描述符状态推进到 committed/finalized。 + +### 序列号:读者排序的锚 + +每条记录有一个单调递增的 `u64 seq`。读者(`dmesg`、journald)就靠 `seq` 排序、判断"我读到哪了""有没有丢消息"。由于是环形,旧记录会被覆盖——但 `seq` 一直涨,所以读者能精确知道中间被冲掉了哪几条,而不是被糊弄过去。描述符环的初始化有专门的 bootstrap 技巧(`last_finalized_seq`、`DESC0_ID` 等),保证第一时刻读者也看得见合法状态。 + +## 为什么中断/原子上下文也能用 printk + +答案就在上面这套无锁设计里。写入路径走的是原子操作(`atomic_long_t` 的 CAS)加上一把**可重入的 CPU 级自旋锁**(`printk_cpu_sync_get_irqsave()`,见 `include/linux/printk.h`),它在**同一个 CPU 上可重入**——所以 NMI 里再次进入 `printk` 不会死锁。真正可能阻塞的"刷到物理控制台"那一步,被**推迟**了:`vprintk_emit()` 只负责把消息存进 ringbuffer,然后把"有活要干"的信号(`wake_up_klogd()` / `defer_console_output()`)丢给专门的 kthread / irq work 去慢慢刷。于是中断里调 `printk` 只是往内存里写几个字节,绝不让 CPU 停下来等串口(旧内核那套 console_lock 直接打印会把系统拖死的坑,就是这么绕开的)。 + +> 顺带一提:还有 `_printk_deferred()`(`include/linux/printk.h` 里声明、`kernel/printk/printk.c` 实现)专给调度器等"连唤醒都不方便"的路径用,它连 console 唤醒都延后,由 irq work 兜底。 + +## 限速 ratelimit:防炸屏 + +在高频路径(中断、定时器回调)里裸跑 `printk` 会出三件事:缓冲区瞬间被冲满、旧日志被覆盖、CPU 全力以赴在吐串口导致**活锁**。内核的对策是限速。核心是 `__ratelimit()`,配套宏 `printk_ratelimited()`(`include/linux/printk.h`): + +```c +#define printk_ratelimited(fmt, ...) \ +({ \ + static DEFINE_RATELIMIT_STATE(_rs, \ + DEFAULT_RATELIMIT_INTERVAL, \ + DEFAULT_RATELIMIT_BURST); \ + if (__ratelimit(&_rs)) \ + printk(fmt, ##__VA_ARGS__); \ +}) +``` + +两个默认值(`include/linux/ratelimit_types.h`):`DEFAULT_RATELIMIT_INTERVAL` = `5 * HZ`(5 秒窗口),`DEFAULT_RATELIMIT_BURST` = 10(窗口内允许 10 条突发)。也就是**5 秒内最多放 10 条,多出来的全部丢弃**。 + +超量时 `__ratelimit` 会顺手打一句抑制提示,告诉你吞了多少。注意这句提示在 6.19 下的长相:`lib/ratelimit.c` 里是 `printk_deferred(KERN_WARNING "%s: %d callbacks suppressed\n", func, m)`,而 `func` 来自 `include/linux/ratelimit_types.h` 的 `#define __ratelimit(state) ___ratelimit(state, __func__)`——它解析成**调用者的函数名**。所以如果你的模块 init 函数叫 `ratelimit_test_init`,抑制信息就是 `ratelimit_test_init: 40 callbacks suppressed` 这样。笔记里那种 `kernel: __ratelimit: 40 callbacks suppressed` 是旧内核的写法(当时前缀固定成 `__ratelimit`),6.19 起改成了调用者函数名。 + +封装好的 `pr_info_ratelimited / pr_err_ratelimited / dev_err_ratelimited` 等都应该直接用;头文件里明确警告**别用共享状态的 `printk_ratelimit()`**(它所有调用点共用一个 ratelimit 状态,会互相干扰),每个 `_ratelimited` 宏各自 `static DEFINE_RATELIMIT_STATE`,互不干扰。 + +阈值还能在运行时调:`/proc/sys/kernel/printk_ratelimit`(窗口,秒)和 `/proc/sys/kernel/printk_ratelimit_burst`(突发条数)。真要更狠地高频打印,就该换 `trace_printk()`——它只写 trace buffer、不走 console,开销几乎为零(ftrace 篇再细讲)。 + +## 动态调试 dynamic-debug + +`pr_debug()` 平时有个尴尬:它要么受 `DEBUG` 宏控制(编译时开关,开了铺天盖地、关了彻底静默),要么……能不能**运行时**开关任意一行?能。这就是 Dynamic Debug。 + +前提是内核开了 `CONFIG_DYNAMIC_DEBUG`。看 `pr_debug` 的三态定义(`include/linux/printk.h`):开了 `CONFIG_DYNAMIC_DEBUG`,`pr_debug` 就被重定向成 `dynamic_pr_debug()`;否则看 `DEBUG`,再否则 `no_printk`(编译期消除): + +```c +#if defined(CONFIG_DYNAMIC_DEBUG) || \ + (defined(CONFIG_DYNAMIC_DEBUG_CORE) && defined(DYNAMIC_DEBUG_MODULE)) +#define pr_debug(fmt, ...) dynamic_pr_debug(fmt, ##__VA_ARGS__) +#elif defined(DEBUG) +#define pr_debug(fmt, ...) printk(KERN_DEBUG pr_fmt(fmt), ##__VA_ARGS__) +#else +#define pr_debug(fmt, ...) no_printk(KERN_DEBUG pr_fmt(fmt), ##__VA_ARGS__) +#endif +``` + +`dynamic_pr_debug` 背后每个打印点都被编译进一个 `struct _ddebug` 描述符,里头有个 `flags` 位域。决定要不要真打印的就一位 `_DPRINTK_FLAGS_PRINT (1<<0)`(`include/linux/dynamic_debug.h`),用 `DYNAMIC_DEBUG_BRANCH(descriptor)` 做分支预测: + +```c +#define _DPRINTK_FLAGS_PRINT (1<<0) /* printk() a message using the format */ +// ... +likely(descriptor.flags & _DPRINTK_FLAGS_PRINT) +``` + +控制文件内核会**同时**创建两份:一份在 debugfs(`/sys/kernel/debug/dynamic_debug/control`,需要 debugfs 已挂载才能访问),一份在 procfs(`/proc/dynamic_debug/control`,始终可用)——`lib/dynamic_debug.c` 的 `dynamic_debug_init_control()` 里两条路径是各自独立建的,procfs 那份并不是"debugfs 没挂才落到"的备胎。生产环境要是没挂 debugfs,直接用 `/proc/dynamic_debug/control` 即可。 + +控制文件列出**所有**动态调试打印点,每行格式 `filename:lineno [module]function flags format`,`flags` 默认是 `=_`(全关)。往里 echo 命令就能改: + +```bash +# 打开 miscdrv_rdwr 模块所有打印 +echo -n "module miscdrv_rdwr +p" > /proc/dynamic_debug/control +``` + +flags 字母表(日常调试用 `p/f/l/m/t` 这五个就够了,6.19 还多了 `s`、`d` 等更细的标记):`p`(print 开)、`f`(函数名)、`l`(行号)、`m`(模块名)、`t`(线程 ID)、`s`(源文件名,`_DPRINTK_FLAGS_INCL_SOURCENAME`,6.19 新增)、`d`(调用栈,`_DPRINTK_FLAGS_INCL_STACK`,6.19 新增);操作符 `=`(设)、`+`(加)、`-`(去)。match spec 还有 `file`/`func`/`line`/`format`,多个之间是与关系: + +```bash +# 只开 snd 驱动里、函数名含 ctl、行号 <600 的点 +echo -n "module snd func *ctl* line 1-600 +p" > /proc/dynamic_debug/control +``` + +调试启动早期阶段(initcall)则不能事后写控制文件,得在 cmdline 里预先塞 `dyndbg="file drivers/usb/* +pflmt"`,或 modprobe 配置里 `options mydriver dyndbg=+pmflt`。配套的救命 boot 参数还有 `ignore_loglevel`(无视级别全吐)、`initcall_debug`(打印每个 initcall 的耗时和返回值,查启动卡死神器)。 + +## 动手试试 + +> 以下是验证方案,等在 QEMU ARM64 上实跑后填真实输出。 + +- 写一个内核模块,init 函数里依次 `pr_emerg`…`pr_debug` 各打一条,配 `pr_fmt` 自动加 `模块名:函数名:行号` 前缀;`insmod` 后 `dmesg` 观察各级别,确认 `KERN_DEBUG` 默认是否上屏、改 `console_loglevel` 后是否变化(待亲测核对)。 +- `cat /proc/sys/kernel/printk` 记下四个数字,对照本文 `console_printk[4]` 的含义;`echo 8 > /proc/sys/kernel/printk` 后再 `insmod`,看 DEBUG 是否冒出来(待亲测)。 +- 写一个限速模块,循环里 `pr_info_ratelimited` 打 60 条,观察末尾的抑制信息(默认 5 秒 / 10 条突发)。在 6.19 下这条信息形如 `<调用者函数名>: N callbacks suppressed`,不是笔记里那种 `__ratelimit: ...` 前缀——具体函数名和被吞条数待亲测核对。 +- 若内核开了 `CONFIG_DYNAMIC_DEBUG`:`grep 自己模块 /proc/dynamic_debug/control` 看到打印点,`echo -n "module xxx +p"` 开启后触发设备操作,对比开关前后 `dmesg`(待亲测)。 + +## 小结 + +`printk` 之所以是内核调试的生命线,不是因为它"像 `printf`",而是因为它背后有一套**无锁环形缓冲区**(`printk_ringbuffer`:`desc_ring` 管元数据、`text_data_ring` 管正文,原子操作 + CPU 级可重入锁推进 `head/tail`),才让它在中断、NMI、持锁上下文都能安全地往内存里塞一条带 `seq` 序列号的记录。日志流向三层:环形缓冲区(`dmesg` 看)→ 控制台(`console_loglevel` 卡阈值,判定就一行 `level >= console_loglevel`)→ 持久化(journald 落盘)。 + +工程纪律就四条:用 `pr_*` / `dev_*` 封装别裸 `printk`;`pr_fmt` 统一前缀;高频路径用 `_ratelimited`(默认 5 秒 10 条,6.19 抑制信息前缀是调用者函数名)或干脆 `trace_printk`;要运行时按需开关就走 Dynamic Debug(`/proc/dynamic_debug/control` + `+p` flags)。 + +## 延伸阅读 + +- 源码(Linux 6.19):`kernel/printk/printk.c`(`printk` 主战场,`vprintk_store` / `vprintk_emit` / `console_printk[]`)、`kernel/printk/printk_ringbuffer.h`(lockless 环形缓冲数据结构、`struct printk_info` 含 `dev_info`)、`include/linux/printk.h`(`pr_*` 封装、`pr_fmt`、ratelimit 宏)、`include/linux/kern_levels.h`(八级 `KERN_*` / `LOGLEVEL_*`)、`lib/ratelimit.c`(`___ratelimit` 及 `%s: %d callbacks suppressed` 抑制提示)、`include/linux/ratelimit_types.h`(`__ratelimit` 宏解析 `__func__`、默认窗口/突发值)、`include/linux/dynamic_debug.h`(动态调试 flags,6.19 含 `s`/`d`)、`lib/dynamic_debug.c`(控制文件解析、debugfs+procfs 双创建)。 +- docs.kernel.org:[Dynamic Debug](https://docs.kernel.org/admin-guide/dynamic-debug-howto.html)、[printk formats(格式说明符全集)](https://docs.kernel.org/core-api/printk-formats.html)、[kernel parameters](https://docs.kernel.org/admin-guide/kernel-parameters.html)(`ignore_loglevel` / `dyndbg` / `initcall_debug`)。 \ No newline at end of file diff --git a/document/tutorials/debugging/02-debug-kprobes.md b/document/tutorials/debugging/02-debug-kprobes.md new file mode 100644 index 00000000..2672e209 --- /dev/null +++ b/document/tutorials/debugging/02-debug-kprobes.md @@ -0,0 +1,236 @@ +--- +title: Kprobes:在任意函数上插眼 +slug: debug-kprobes +difficulty: intermediate +tags: [动态追踪, kprobes, ftrace, 调试] +architectures: [arm64, x86_64, riscv] +kernel_version: "6.19" +maturity: drafting +prerequisites: + - /tutorials/foundations/07-kernel-module-hello +related: + - /tutorials/debugging/01-debug-printk +sources: + - notes: document/notes/linux_kernel_debugging/ch04.md +--- + +# Kprobes:在任意函数上插眼 + +> 🔨 **整理中** · 本篇机制对照 Linux 6.19 源码讲解(函数/数据结构已核对);具体行号与命令输出待 QEMU 亲测核对。 + +## 静态埋点不够,要的是运行时插任意函数 + +上一篇我们聊 `printk`——它是内核调试的祖传手艺,但它有个要命的硬伤:**得重新编译内核(或模块)**。在用户空间改个 `printf` 重编译几秒钟的事,在内核里这意味着停机、意味着那个转瞬即逝的并发 bug 早就溜了。再说,很多发行版内核你根本没源码也没权限重编。 + +内核其实还有一套更体面的静态机制叫 **tracepoint**:开发者预先在代码里埋好 `trace_*()` 钩子,编译进内核,运行时通过 ftrace 开关。它比 printk 高级,但仍是"开发商配好的家具"——只在开发者**主动埋点**的函数上才有用。你想看的那个冷门函数要是没埋点,tracepoint 也帮不上忙。 + +我们真正想要的是这种能力:**不动源码、不重编内核、运行时在任意函数入口(甚至任意指令偏移)插一个"眼",函数流经这里时把寄存器、参数、返回值全抓下来,然后让程序像没事一样继续跑。** 这就是 **kprobes**——内核调试界的瑞士军刀。 + +## 原理黑盒揭秘:把指令首字节换成断点 + +很多人觉得"运行时给正在跑的内核插桩"是黑魔法,其实底层朴素得很:**改指令**。把目标函数第一条指令替换成一条 CPU 一看就触发异常的"断点指令",CPU 一执行就掉进陷阱,kprobes 的异常处理器接管现场。 + +不同架构的断点指令不同(架构相关层,`arch//kernel/.../kprobes.c`): + +- **x86**:`INT3`(`0xCC`),CPU 触发 `#BP` 异常,走 `int3` 处理路径(`arch/x86/kernel/kprobes/core.c`)。 +- **ARM64**:`BRK64_OPCODE_KPROBES`,一条 `BRK #imm`,触发同步异常,被 `kprobe_brk_handler()` 接走(`arch/arm64/kernel/probes/kprobes.c`)。 +- **RISC-V**:`ebreak` 指令,同样走陷阱。 + +具体怎么"改指令"?以 ARM64 为例(Linux 6.19),插桩函数 `arch_arm_kprobe()`(`arch/arm64/kernel/probes/kprobes.c:148`)干的事就这么几行: + +```c +void __kprobes arch_arm_kprobe(struct kprobe *p) +{ + void *addr = p->addr; + u32 insn = BRK64_OPCODE_KPROBES; + aarch64_insn_patch_text(&addr, &insn, 1); // 把首条指令原地改成 BRK +} +``` + +`aarch64_insn_patch_text()` 负责"边改边跑"——它要在所有 CPU 上安全地替换一条正在被执行的指令,这就是 kprobes 真正的硬骨头(涉及 IPI、停机补丁、`text_mutex`),但那是另一篇的故事。 + +### 一条断点指令触发后的完整生命 + +当一个倒霉进程执行到被改写的那条 `BRK`,CPU 立刻跳进异常向量,最终调用 ARM64 的 `kprobe_brk_handler()`(同文件 `:311`)。这里就是 kprobes 的"前台": + +```c +int __kprobes +kprobe_brk_handler(struct pt_regs *regs, unsigned long esr) +{ + ... + p = get_kprobe((kprobe_opcode_t *) addr); // 按 PC 查探针哈希表 + if (cur_kprobe) { + if (!reenter_kprobe(p, regs, kcb)) // kprobe 套 kprobe:重入处理 + return DBG_HOOK_ERROR; + } else { + set_current_kprobe(p); + kcb->kprobe_status = KPROBE_HIT_ACTIVE; + if (!p->pre_handler || !p->pre_handler(p, regs)) // ① 调你的 pre_handler + setup_singlestep(p, regs, kcb, 0); // ② 单步执行原指令 + else + reset_current_kprobe(); + } + return DBG_HOOK_HANDLED; +} +``` + +完整生命是四步: + +1. **断点命中** → 查 `kprobe_table` 哈希表拿到你的 `struct kprobe`。 +2. **`pre_handler`** → 在原指令执行**之前**调用你注册的回调,把 `pt_regs`(CPU 寄存器快照)递给你。 +3. **单步原指令** → `setup_singlestep()` 把原指令拷贝到一块专门的"执行槽"(insn slot,避免在原位执行又触发自己)单步跑一次,跑完再插一个"二次 BRK"。 +4. **`post_handler`** → 单步收尾触发 `kprobe_ss_brk_handler()`(`:355`),它调 `post_kprobe_handler()` 执行你的 post 回调,然后放程序走。 + +这就是黑盒的全部:**断点替换 → pre → 单步 → post**。注意 `pre_handler` 返回非 0 会跳过单步("我自己改了执行流,不用单步了"),这是少数高级用法。 + +### `fault_handler`:handler 出事时的安全网 + +> ⚠️ **接口已变(Linux 6.19 已删除)**:下面的叙述是给老内核的迁移说明,**6.19 上 `struct kprobe` 已不再有 `fault_handler` 字段**,别再往结构体里填它。 + +如果你的 handler 代码踩了非法内存(比如解引用了坏指针),会触发 page fault。入口是架构无关的 `kprobe_page_fault()`(`include/linux/kprobes.h:576`)——它先排除用户态、可抢占、当前没在跑 kprobe 这几种情况,确认无误后才调架构相关的 `kprobe_fault_handler()`。 + +**关键变化**:在较早的内核(commit `ec6aba3d2be1` 之前,约 6.6 之前)里,`struct kprobe` 有一个 `fault_handler` 字段,`kprobe_fault_handler()` 会回调你注册的 handler,由它决定"修好了继续"还是"交给内核默认机制"。但那套"用户态兜底"接口连同 `kprobe_fault_handler_t` typedef 已被移除。现在 6.19 里的 `kprobe_fault_handler()`(如 `arch/arm64/.../kprobes.c:280`、`arch/x86/kernel/kprobes/core.c:1033`)只干一件事:**遇到单步执行期间的 page fault,把指令指针拨回探针地址,让这次 fault 当作普通 page fault 继续走内核默认处理**——它不再回调任何用户态 hook。 + +也就是说:**6.19 上你的 `pre_handler`/`post_handler` 要是自己访问了坏内存,没有任何用户态兜底可挂,直接走内核默认 page fault 路径**(八成是 oops)。所以 handler 代码必须自己保证安全:别解引用来路不明的指针、别在没校验的情况下读用户态地址。 + +## `struct kprobe`:那张手术清单 + +整个机制的"配置单"就一个结构体 `struct kprobe`(`include/linux/kprobes.h:59`)。挑关键字段记(6.19 实有字段,对照源码核对过): + +| 字段 | 作用 | +|:---|:---| +| `symbol_name` | 要"开刀"的函数名,如 `"do_sys_open"`;底层 `_kprobe_addr()` 用 kallsyms 解析成内核虚拟地址,回填进 `addr` | +| `addr` | 解析后的探针地址(你也可以直接填地址) | +| `offset` | 函数内偏移,支持插到函数**中间**的任意指令(CISC 上偏移到指令中间会直接崩,慎用) | +| `pre_handler` | 函数执行**前**的回调,签名 `int (*)(struct kprobe *, struct pt_regs *)` | +| `post_handler` | 函数执行**后**的回调,签名 `void (*)(struct kprobe *, struct pt_regs *, unsigned long)` | +| `opcode` | 被替换掉的原指令(disarm 时写回去) | +| `ainsn` | 架构相关的"指令副本 + 单步信息",单步执行就靠它 | +| `nmissed` | 被临时 disarm 而漏抓的次数(高频函数会涨) | +| `flags` | 状态位:`KPROBE_FLAG_DISABLED`、`KPROBE_FLAG_GONE`、`KPROBE_FLAG_FTRACE` 等 | + +> 老笔记里常见的 `fault_handler` 字段**6.19 已删除**(见上一节),别再写。要兜底就在 handler 里自己写防御性代码。 + +注册/反注册 API:`register_kprobe(&p)` / `unregister_kprobe(&p)`,还有批量版 `register_kprobes`、临时开关 `disable_kprobe`/`enable_kprobe`。注册主流程在 `register_kprobe()`(`kernel/kprobes.c:1634`)里:先 `_kprobe_addr()`(`:1642`)算地址 → `check_kprobe_address_safe()`(`:1658`)查黑名单 → `__register_kprobe()`(`:1597`)把探针挂进 `kprobe_table` 哈希表并 `arm_kprobe()` 写断点。 + +> **铁律**:模块卸载必须 `unregister_kprobe()`。忘了的话,下次任何代码流经那个地址,内核会去触发一个已经失效的探针回调——直接内核 bug 甚至死机。泄漏的不是内存,是"控制流劫持点"。 + +### 黑名单:有些函数不能碰 + +不是所有函数都能探测。kprobes 自己的内部函数(`get_kprobe`、handler 们)要是被探,会无限递归死锁。内核用两道防线:源码里标 `__kprobes` / `nokprobe_inline` 注解,或用宏 `NOKPROBE_SYMBOL(handler_xxx)` 显式把某函数拉黑。查名单:`cat /sys/kernel/debug/kprobes/blacklist`。你写 kprobe 模块时,自己的所有 handler 都该用 `NOKPROBE_SYMBOL()` 保护起来。 + +## kprobe vs kretprobe:入口眼 vs 出口眼 + +普通 kprobe 只能看"函数进去时"的样子。但调试时我们常想问:**这函数到底返回了什么?** 这就是 **kretprobe(返回探针)**。 + +难点在于:函数返回时指令指针已经回到调用者那儿了,普通 post-handler 这会儿想拿返回值得深挖栈,又脏又跟架构强相关。kretprobe 的解法很巧:**在函数入口偷换返回地址。** + +`register_kretprobe()`(`kernel/kprobes.c:2178`)做的事——它其实**先在函数入口注册一个普通 kprobe**,把这个 kprobe 的 `pre_handler` 偷偷设成内部函数 `pre_handler_kretprobe`(`:2103`): + +```c +rp->kp.pre_handler = pre_handler_kretprobe; // 入口 kprobe 的回调 +rp->kp.post_handler = NULL; +... +rp->rh = rethook_alloc((void *)rp, kretprobe_rethook_handler, ...); // 返回钩子 +ret = register_kprobe(&rp->kp); +``` + +函数被调用时,`pre_handler_kretprobe` 调 `rethook_hook()`(`:2120`)——这一步**把栈上的真实返回地址换成 trampoline 地址**并记下原件(具体改地址的脏活在 rethook 机制里,不在 kprobes 核心)。函数真返回时 CPU 跳进 trampoline(ARM64 走 `kretprobe_brk_handler`,`:374`),它最终回到你注册的 `rp->handler`,这时 `pt_regs` 里的返回值寄存器(x86 的 `ax`、ARM64 的 `regs[0]`)还热乎着。你注册的返回回调签名: + +```c +int handler(struct kretprobe_instance *ri, struct pt_regs *regs); +``` + +拿返回值别手抠寄存器,用架构无关宏 `regs_return_value(regs)`。`struct kretprobe` 里 `kp`(内嵌 kprobe)、`handler`(返回回调)、`entry_handler`(入口回调,可选,返回非 0 表示"这次不探了")、`maxactive`(最多同时探多少个并发实例,默认 `max(10, 2*num_possible_cpus())`,设小了会漏抓、`nmissed` 涨)、`data_size`(per-instance 私有空间大小)。 + +> **jprobe(跳转探针)已废弃,别用。** 它当年是专门偷函数参数的接口——**4.15 起标记弃用(commit `590c84593045`,加警告但仍可用),4.19 正式删除 API 实现**(commit `4de58696de07` 等一批)。原因很简单:偷参数直接靠 ABI 知识从 `pt_regs` 抠就行(下篇细讲),没必要维护一套复杂接口。维护老内核(<4.19)才可能碰见。 + +## kprobe events:不写模块,写一行就插桩 + +写模块、填 `struct kprobe`、编译、insmod——这套"静态 kprobe"每次改个函数名都得重来。现代内核有更优雅的:**kprobe events**,ftrace 的动态事件源。前提是内核开了 `CONFIG_KPROBE_EVENTS=y`(绝大多数发行版默认开)。 + +核心思想:把"探针"抽象成"事件"。你往 tracefs 的 `kprobe_events` 文件写一行配置,内核就帮你建一个动态 kprobe,输出进统一的 trace buffer。看它的源码(`kernel/trace/trace_kprobe.c`)就懂了——所谓"动态事件"底层就是一个 `struct trace_kprobe`(`:59`),里面套了个 `struct kretprobe rp`(`rp.kp` 当普通 kprobe 用): + +```c +struct trace_kprobe { + struct dyn_event devent; + struct kretprobe rp; /* Use rp.kp for kprobe use */ + unsigned long __percpu *nhit; + const char *symbol; + struct trace_probe tp; +}; +``` + +创建逻辑(`:294` 附近)根据你是 kprobe 还是 kretprobe,把回调设成 `kprobe_dispatcher` 或 `kretprobe_dispatcher`(这俩负责把现场写进 trace buffer)。也就是说:**kprobe events 不是新机制,它就是把你本来要手写的"注册 + 回调填 buffer"这套活儿,换成写一行字符串、由内核代办。** + +> **别误以为所有 kprobe 使用者都往 `kprobe_events` 写。** 三条路径要分清:① `perf probe`(`tools/perf/util/probe-file.c`)确实会写 `kprobe_events` 这个用户态文件;② eBPF / perf 的 **单点** kprobe attach 走 `perf_event_open()`(`PERF_TYPE_PROBE`),内核侧 `perf_kprobe_init()` 最终调 `create_local_trace_kprobe()`(`kernel/trace/trace_kprobe.c:1914`)——这是**内核内路径,复用了 trace_kprobe 的构建逻辑,但不经过用户态 `kprobe_events` 文件**;③ eBPF 更新的 **kprobe_multi link**(`BPF_LINK_TYPE_KPROBE_MULTI`)基于 **fprobe** 机制(`struct bpf_kprobe_multi_link` 里直接内嵌 `struct fprobe fp`,见 `kernel/trace/bpf_trace.c`),**连 trace_kprobe 都不碰**。一句话:能往文件里写的是 perf probe,eBPF 另有两条更直接的内核路径。 + +一行插桩的语法: + +``` +p:<事件名> <函数> [参数抓取...] +``` + +`p:` 是 kprobe,`r:` 是 kretprobe。建、开、看、关、删五步走: + +```bash +cd /sys/kernel/tracing +echo 'p:myopen do_sys_open' >> kprobe_events # 建 +echo 1 > events/kprobes/myopen/enable # 开 +cat trace_pipe # 看(实时流) +echo 0 > events/kprobes/myopen/enable # 关 +echo '-:myopen' >> kprobe_events # 删(减号 = 删除) +``` + +抓参数是它的杀手锏。`do_sys_open(int dfd, const char __user *filename, int flags, umode_t mode)` 第二个参数是文件名,x86_64 上在 `%si`,ARM64 上在 `regs[1]`,ARM-32 在 `%r1`(ABI 不同,见下表)。x86_64 抓文件名: + +```bash +echo 'p:myopen do_sys_open file=+0(%si):string' >> kprobe_events +``` + +> ⚠️ **待亲测**:上面这串在 x86_64 上 OK,但搬到 ARM 上会报 `write error: Invalid argument`——因为 ARM 没 `%si`。ARM-32 得写 `+0(%r1):string`。这是 ABI(应用二进制接口)的差异,参数传递规则是架构定的: + +| 架构 | 前 N 个参数寄存器 | 返回值 | +|:---|:---|:---| +| x86_64 | RDI, RSI, RDX, RCX, R8, R9 | RAX | +| ARM-32 | R0, R1, R2, R3 | R0 | +| ARM64 | X0~X7 | X0 | + +这条"跨架构的坑"我们打算在 QEMU(arm64 + x86_64)上各跑一遍亲测,记下真实输出再补进来。 + +## 与 ftrace/trace_event 的关系 + +理清这三者的层级很关键: + +- **kprobes** 是最底层的能力——改指令、陷异常、调回调,纯机制。 +- **trace_event / ftrace events** 是上面那层"框架"——它定义了"事件"这个统一抽象(预置的 tracepoint + 动态的 kprobe/uprobe events),所有事件共享同一套 trace buffer、`enable`/`filter`/`format` 接口。这也是为什么你 `echo` 进 `kprobe_events` 后,新出现的 `events/kprobes/myopen/` 目录跟预置 tracepoint 的目录结构一模一样。 +- **perf / eBPF** 是更上层的"使用者"——它们各自有 attach 路径(见上一节的三条路径区分),不一定都落到 `kprobe_events` 文件上。 + +所以"kprobe events 是 ftrace 的动态事件源"这句话,源码上的证据就是 `trace_kprobe.c` 把 `struct kretprobe` 包进 `trace_kprobe` 并注册成 `dyn_event`。 + +## 动手待亲测(占位) + +本篇聚焦讲机制,完整的 `example/mini` 代码留到配套篇。这里先给两个验证方案,等 QEMU 亲测后补真实输出。 + +**方案 A:kprobe 模块插桩 `do_sys_open`。** 写一个最小模块:`static struct kprobe kp`,填 `symbol_name="do_sys_open"` + `pre_handler`(打印 `regs` 里第二个参数寄存器),`init` 里 `register_kprobe`、`exit` 里 `unregister_kprobe`。insmod 后在系统里 `cat` 一个文件触发,看 dmesg。 + +**方案 B:kprobe events 动态插 `kernel_clone`。** 不写代码,直接: + +```bash +echo 'p:myclone kernel_clone' >> /sys/kernel/tracing/kprobe_events +echo 1 > /sys/kernel/tracing/events/kprobes/myclone/enable +cat /sys/kernel/tracing/trace_pipe # 然后随便起个进程 +``` + +> ⚠️ **待亲测**:以上命令与输出待在 QEMU ARM64/x86_64 上亲测核对后填入真实结果。 + +## 小结 + +kprobes 让我们在不重编内核的前提下,运行时在任意函数上插眼。它的黑盒就一句:**把首条指令换成断点(x86 `INT3`/ARM64 `BRK`/RISC-V `ebreak`),CPU 陷异常 → `pre_handler` → 单步原指令 → `post_handler`**。普通 kprobe 看入口、kretprobe 靠偷换返回地址看出口(trampoline 机制)。嫌写模块麻烦就用 kprobe events——往 `kprobe_events` 写一行,底层照样是 `struct trace_kprobe` 注册 kprobe。记住几条红线:**反注册不能忘**;**黑名单函数(含你自己的 handler,用 `NOKPROBE_SYMBOL` 保护)不能探**;还有一条容易踩的——**6.19 已删掉 `fault_handler` 这个用户态兜底,handler 自己访问坏内存没有 hook 可挂,会直接走默认 page fault**,所以 handler 必须自己写防御性代码。 + +## 延伸阅读 + +- 源码(Linux 6.19):`kernel/kprobes.c`(核心:`register_kprobe`、`__register_kprobe`、`pre_handler_kretprobe`、`register_kretprobe`、`kretprobe_rethook_handler`)、`include/linux/kprobes.h`(`struct kprobe`/`struct kretprobe`/`kprobe_page_fault`;注意 6.19 已无 `fault_handler` 字段与 `kprobe_fault_handler_t` typedef)。 +- 架构层:`arch/arm64/kernel/probes/kprobes.c`(`arch_arm_kprobe`、`kprobe_brk_handler`、`kprobe_ss_brk_handler`、`kretprobe_brk_handler`、`kprobe_fault_handler`)、`arch/x86/kernel/kprobes/core.c`(`INT3` 单步路径、`kprobe_fault_handler`)、RISC-V 在 `arch/riscv/kernel/probes/`。 +- 事件层:`kernel/trace/trace_kprobe.c`(kprobe events 的创建与 dispatcher、`create_local_trace_kprobe`)、`kernel/trace/fprobe.c`(eBPF kprobe_multi 底层的 fprobe 机制)。 +- 内核文档:[Kprobes concepts](https://docs.kernel.org/trace/kprobes.html)、[Kprobe-based Event Tracing](https://docs.kernel.org/trace/kprobetrace.html)、[Ftrace 索引页](https://docs.kernel.org/trace/index.html)。 \ No newline at end of file diff --git a/document/tutorials/debugging/03-debug-kasan.md b/document/tutorials/debugging/03-debug-kasan.md new file mode 100644 index 00000000..cbfaa5b9 --- /dev/null +++ b/document/tutorials/debugging/03-debug-kasan.md @@ -0,0 +1,197 @@ +--- +title: KASAN:影子内存抓内存破坏 +slug: debug-kasan +difficulty: intermediate +tags: [KASAN, 影子内存, 内存调试, UAF] +architectures: [arm64, x86_64, riscv] +kernel_version: "6.19" +maturity: drafting +prerequisites: + - /tutorials/foundations/07-kernel-module-hello +related: + - /tutorials/debugging/01-debug-printk + - /tutorials/kernel/mm/02-mm-slab +sources: + - notes: document/notes/linux_kernel_debugging/ch05.md +--- + +# KASAN:影子内存抓内存破坏 + +> 🔨 **整理中** · 本篇机制对照 Linux 6.19 源码讲解(函数/数据结构已核对);具体行号与命令输出待 QEMU 亲测核对。 + +## 内存 bug 三大恶 + +写 C 的人大概都有过这种经历:代码明明逻辑没问题,跑得好好的,结果某天凌晨两点给生产环境加了一行无关痛痒的代码,整个系统炸了。罪魁祸首往往是那三类幽灵一样的内存错误: + +- **use-after-free(UAF)**:内存都 `kfree` 还回去了,你还攥着老指针往里写——而 Slab 分配器最喜欢把刚释放的对象立刻复用给下一次分配,于是你的"旧指针"正中别人新拿到的内存,数据悄无声息地被串改。 +- **buffer-overflow(OOB)**:往数组屁股后面多写一个字节,踩进了 Slab 对象之间的红区,或者更狠的,踩进了下一个对象。 +- **wild-pointer**:指针压根没初始化就解引用,指到哪算哪。 + +这三类 bug 的共性是**隐蔽、难复现**——UAF 往往要等那个对象被重新分配出去才显形,OOB 踩个红区短期内根本不崩。在内核里它们不只是 bug,更是提权漏洞的温床(越界写改了页表就是一次特权提升)。所以内核社区造了一把重炮:**KASAN(Kernel AddressSanitizer)**。 + +## 核心思路:影子内存 + +KASAN 的核心机制可以一句话概括:**给每 8 字节真实内存配 1 字节"影子",记录这 8 字节能不能访问。** + +这个映射关系在 `include/linux/kasan.h`(Linux 6.19)里就是这么算的: + +```c +static inline void *kasan_mem_to_shadow(const void *addr) +{ + return (void *)((unsigned long)addr >> KASAN_SHADOW_SCALE_SHIFT) + + KASAN_SHADOW_OFFSET; +} +``` + +`KASAN_SHADOW_SCALE_SHIFT` 是 3(因为 2³=8),所以一个地址右移 3 位再加上一个固定的基地址 `KASAN_SHADOW_OFFSET`,就得到它在影子区里对应的那 1 字节。影子区的"配额"比例由此固定:每 8 字节真实内存 → 1 字节影子。 + +那 1 字节影子到底怎么编码内存的可访问性?答案在 `mm/kasan/kasan.h`: + +- 影子值 **0**:这 8 字节全部可访问。 +- 影子值 **1~7**:前 N 个字节可访问,剩下 `8-N` 个不可访问(处理 `kmalloc(123)` 这种不是 8 的整数倍的尾巴)。 +- 影子值是个**负数**(0xFF 之类):整块都不可访问,具体值还区分了"为什么不可访问"。 + +这套魔数全在 `kasan.h` 里钉死(Generic 模式): + +```c +#define KASAN_PAGE_FREE 0xFF /* freed page */ +#define KASAN_PAGE_REDZONE 0xFE /* redzone for kmalloc_large allocation */ +#define KASAN_SLAB_REDZONE 0xFC /* redzone for slab object */ +#define KASAN_SLAB_FREE 0xFB /* freed slab object */ +#define KASAN_SLAB_FREE_META 0xFA /* freed slab object with free meta */ +#define KASAN_GLOBAL_REDZONE 0xF9 /* redzone for global variable */ +``` + +把这套魔数当成一本"罪状词典":`0xFB` 是"这块 slab 对象已被释放",`0xFC` 是"这是 slab 对象的红区"。踩进红区就是 OOB,摸了 `0xFB` 就是 UAF——报告怎么定性,全靠查这本词典。 + +## 编译器插桩:每次访问都查一遍影子 + +光有影子内存没用,得有人在每次内存访问时去翻这本词典。这件事编译器替你做——开 `CONFIG_KASAN` 后,编译内核会带上 `-fsanitize=kernel-address`,编译器在你**每一条** load/store 指令前面塞一段检查代码。 + +具体怎么塞?看 `mm/kasan/generic.c`(Linux 6.19)这段宏,它定义了 `__asan_load1/2/4/8/16` 和 `__asan_store1/2/4/8/16` 这一整套函数,正是编译器插桩时调用的入口: + +```c +#define DEFINE_ASAN_LOAD_STORE(size) \ + void __asan_load##size(void *addr) \ + { \ + check_region_inline(addr, size, false, _RET_IP_); \ + } \ + ... +DEFINE_ASAN_LOAD_STORE(1); +DEFINE_ASAN_LOAD_STORE(2); +DEFINE_ASAN_LOAD_STORE(4); +DEFINE_ASAN_LOAD_STORE(8); +DEFINE_ASAN_LOAD_STORE(16); +``` + +也就是说,你写一行 `*p = 1`,编译器把它改写成"先调用 `__asan_store1(p)` 检查,再真正写"。检查的核心是 `check_region_inline()`(`generic.c`):它先做两个早退守卫——`kasan_enabled()` 没打开就直接放行、`size == 0` 的零长度访问也直接放行;过了守卫再依次检查三件事:地址有没有回绕(`addr + size < addr`)、有没有对应的影子元数据(`addr_has_metadata()`)、以及 `memory_is_poisoned()` 返回什么。任一项报红就调 `kasan_report()` 当场翻车。 + +插桩有两种风味(`CONFIG_KASAN_OUTLINE` 默认 vs `CONFIG_KASAN_INLINE`):Outline 是插一个真正的函数调用(上面那些 `__asan_load*`),内核镜像小、稍慢;Inline 是把检查逻辑直接内联展开,镜像膨胀但快 1.1~2 倍。典型的镜像换速度权衡。 + +## 三种模式:Generic / SW_TAGS / HW_TAGS + +KASAN 不是铁板一块,它有三档火力,区别在于"怎么给内存打可访问性标记": + +| 模式 | 昵称 | 内存/CPU 开销 | 架构限制 | +|:---|:---|:---|:---| +| Generic | 通用版 | 高 / 中 | x86_64、ARM64、RISC-V、甚至 32 位 ARM | +| SW_TAGS | 软件标签版 | 中 / 低 | 仅 ARM64 | +| HW_TAGS | 硬件标签版(MTE) | 低 / 极低 | 仅 ARM64(v8.5+ MTE) | + +- **Generic**:就是上面讲的"软件影子内存 + 插桩查影子",最重也最狠,调试首选,所有 64 位架构通吃。 +- **SW_TAGS**:不用整块影子区,改用指针高位塞个 tag、内存里也塞 tag,访问时比较两 tag 是否匹配。轻量很多,但只 ARM64。 +- **HW_TAGS**:依赖 ARM64 的 MTE 硬件,把 tag 检查交给 CPU 硬件做,开销低到敢在生产环境开。 + +为什么 tag 系只认 ARM64?因为 Android。几亿台手机没法都开着 Generic KASAN 跑,Google 急需"线上也能低开销抓内存错误"的能力,于是把 MTE 推上了标准。这是商业驱动,不是技术偏心。 + +Generic 的代价在配置菜单 help 里写得很直白:**吃掉约 1/8 物理内存,分配开销约 ×1.5,整体性能慢约 ×3**。所以 KASAN 只用于调试内核,绝不进发行版默认内核。 + +## 报告怎么读 + +KASAN 抓到 bug 时会甩出一份报告(`kasan_report()` 触发),开头长这样(待亲测核对): + +``` +BUG: KASAN: slab-out-of-bounds in kmalloc_oob_right+0x159/0x260 [test_kasan] +Write of size 1 at addr ffff8880316a45fb by task kunit_try_catch/1206 +``` + +关键看三点: + +1. **bug 类型**(`slab-out-of-bounds` / `slab-use-after-free` / `double-free` 等)——这是怎么定性的?答案在 `mm/kasan/report_generic.c` 的 `get_shadow_bug_type()`:它读出第一个出问题的影子字节,对着那张魔数词典查——影子是 `KASAN_SLAB_REDZONE`(0xFC) 就判 `slab-out-of-bounds`,是 `KASAN_SLAB_FREE`(0xFB) 就判 `slab-use-after-free`。**报告怎么定性,完全由踩中的那个影子魔数决定。** +2. **访问类型 + 地址 + 大小**:是读还是写、写了几个字节、写在哪个地址。 +3. **调用栈**:当前访问栈,以及(如果开了 `CONFIG_STACKTRACE`)这块内存**何时分配、何时释放**的栈。UAF 定位全靠这两个历史栈。 + +报告里还会打印一段影子内存 dump,行首的 `>` 指着出问题那个字节,`^` 在下面标出来: + +``` +>ffff8880318ad980: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 03 + ^ +``` + +这里 `03` 就是"前 3 字节可访问,后 5 字节不可访问"——正是 `kmalloc(123)` 那块内存的尾巴(123 = 15×8 + 3),测试用例故意写第 123 字节踩出去了。报告里还顺带告诉你这块内存"allocated / freed"状态、归属哪个 cache、buggy 地址在对象左边还是右边多少字节(`describe_object_addr()`),把现场交代得明明白白。 + +## quarantine:隔离区,UAF 的命门 + +KASAN 抓 UAF 的命门在于一件事:**释放的对象不能马上被复用。** 想想就懂——如果 `kfree` 之后对象立刻还回 freelist 被别人拿走,UAF 触发时影子值可能已经被新分配"擦干净"重写成 0 了,KASAN 就抓瞎。 + +所以 Generic KASAN 搞了个**隔离区(quarantine)**:`kfree` 时不立刻把对象还回 slab freelist,而是先扣在隔离区里一段时间,让"释放"这个状态(影子值 `KASAN_SLAB_FREE`)多活一会儿,给后续的越界访问一个被抓现行的时间窗口。 + +代码在 `mm/kasan/quarantine.c`(Linux 6.19)。释放流程里(`mm/kasan/common.c` 的 `__kasan_slab_free()`)先把对象毒化(`poison_slab_object()` 把影子写成 `KASAN_SLAB_FREE`),再调 `kasan_quarantine_put()` 把它塞进隔离区: + +```c +bool kasan_quarantine_put(struct kmem_cache *cache, void *object) +{ + ... + q = this_cpu_ptr(&cpu_quarantine); /* 先进每 CPU 队列 */ + qlist_put(q, &meta->quarantine_link, cache->size); + if (unlikely(q->bytes > QUARANTINE_PERCPU_SIZE)) { + qlist_move_all(q, &temp); + raw_spin_lock(&quarantine_lock); + qlist_move_all(&temp, &global_quarantine[quarantine_tail]); /* 攒够再倒进全局批次数组 */ + ... +``` + +隔离区是两层结构:每 CPU 一个本地队列(`cpu_quarantine`),攒够 1MB(`QUARANTINE_PERCPU_SIZE`)再搬到全局的批次数组(`global_quarantine[]`,FIFO 轮转)。对象在里面以 `kasan_free_meta.quarantine_link` 串成单链表(`struct qlist_node`)。当隔离区总大小超过上限(物理内存的 1/32,`QUARANTINE_FRACTION`),`kasan_quarantine_reduce()` 就从最老那一批开始,调 `qlink_free()` 把对象真正还回 slab(`___cache_free()`)。 + +> ⚠️ **待亲测**:这套"延迟回收"机制到底让 UAF 窗口撑多久,需要在开了 KASAN 的内核上跑 UAF 用例、观察影子值从 `KASAN_SLAB_FREE` 翻回 0 的时机来验证。注意 tag 系模式(SW_TAGS/HW_TAGS)**不用隔离区**——它们靠 tag 不匹配直接抓,`kasan_quarantine_put()` 在那俩模式下是个空壳。 + +## UBSAN:抓未定义行为 + +KASAN 抓的是内存访问越界/UAF,但有一类 bug 它抓不到:**C 语言的未定义行为(UB)**——整数溢出、除零、位移越界、静态数组下标越界。这是 UBSAN(Undefined Behavior Sanitizer)的地盘。 + +UBSAN 同样是编译时插桩(`-fsanitize=undefined` 等),但插桩位置和检查逻辑不同。它最擅长的是**静态数组索引边界**——内核里 `CONFIG_UBSAN_BOUNDS` 负责这块,编译器能根据数组声明大小生成下标检查代码。越界时报告长这样(待亲测核对): + +``` +array-index-out-of-bounds in .c: +index 13 is out of range for type 'char [10]' +``` + +UBSAN 的盲区也明确:**纯指针运算**它看不见(因为它靠的是数组声明的类型信息,指针算术丢了这层信息),而这恰恰是 KASAN 的强项——KASAN 对所有基于指针的访问一视同仁。所以**KASAN 和 UBSAN 是互补的**,调试内核通常两个都开。 + +额外提醒:内核和模块必须用**同一个编译器**编(别拿 GCC 编内核、Clang 编模块),ABI 不一致会让 KASAN 直接失效。而有些全局变量的"左越界"只有 **Clang 11+** 才抓得到(GCC 在全局红区处理上有历史坑),所以调试关键路径时 Clang 是更稳的选择。 + +## 动手验证方案(待亲测) + +> ⚠️ 这部分我们还没在 QEMU 上亲手跑过,先给方案,跑过就升级成已锤炼。 + +1. **配调试内核**:`make ARCH=arm64 menuconfig`,在 `Kernel hacking → Memory Debugging` 开 `CONFIG_KASAN=y`(Generic 模式)、`CONFIG_STACKTRACE=y`;顺手开 `CONFIG_KASAN_KUNIT_TEST=m`(内核自带测试模块,**6.19 里测试源码已和 KASAN 核心同目录**,就在 `mm/kasan/kasan_test_c.c`,实测 76 个故意写坏的用例)。 +2. **编出来烧进 QEMU**,启动时留意 `KernelAddressSanitizer initialized (generic)` 这行(`kasan_init_generic()` 打印),看到它就说明影子区已就绪、运行时检查已由内部的 `kasan_enable()` 启用——这是初始化时自动打开的,没有需要你手动拨的开关。 +3. **跑自带测试**:6.19 的测试模块已经改名叫 `kasan_test`(不再是 5.10 时代的 `test_kasan`),直接 `modprobe kasan_test` 加载。它是一个 KUnit suite,注册名是 `kasan`,所以也可以不靠 modprobe、用内置 KUnit 触发:启动参数加 `kunit.filter=suite=kasan`,或加载模块后 `echo "suite=kasan" > /sys/kernel/debug/kunit/run`。然后 `dmesg` 看每条 `BUG: KASAN: ...` 报告,逐条对照上面讲的"影子魔数 → bug 类型"词典核验。 +4. **自己写 UAF/OOB 模块**:在 `example/mini/` 下写一个 `kmalloc` 一块内存、释放后故意再写、或故意写越界的模块,观察 KASAN 报告格式,验证隔离区是否让"释放"状态多撑了一会儿。 + +## 小结 + +KASAN 的全部魔法就是一句话:**影子内存 + 编译器插桩**。每 8 字节真实内存配 1 字节影子,影子值编码"能不能访问、为什么不能";编译器给每条 load/store 插入检查(Generic 模式下就是 `__asan_load*`/`__asan_store*` → `check_region_inline()` → `memory_is_poisoned()`),影子说有毒就 `kasan_report()`。三种模式里 Generic 最重最狠,SW_TAGS/HW_TAGS 走 tag 路线只认 ARM64;quarantine 隔离区靠延迟回收让 UAF 的"释放"状态多活一会儿,是 Generic 抓 UAF 的命门。UBSAN 补 KASAN 的盲区(纯指针运算抓不到的整数溢出、静态数组越界),两者互补。代价是约 1/8 内存 + ~3 倍减速,只用于调试内核。 + +## 延伸阅读 + +- 源码(Linux 6.19): + - `mm/kasan/generic.c` —— Generic 模式的影子检查 `memory_is_poisoned*()`、`check_region_inline()`(含 `kasan_enabled()`/零长度早退守卫)、插桩入口 `__asan_load*`/`__asan_store*`,以及 `kasan_init_generic()` 打印初始化日志。 + - `mm/kasan/common.c` —— 分配/释放插桩 `__kasan_slab_alloc()`/`__kasan_slab_free()`、毒化 `poison_slab_object()`。 + - `mm/kasan/quarantine.c` —— 隔离区两层队列 `kasan_quarantine_put()`/`kasan_quarantine_reduce()`、`qlink_free()` → `___cache_free()` 真正还回 slab。 + - `mm/kasan/report_generic.c` —— bug 类型定性 `get_shadow_bug_type()`(影子魔数词典,`slab-out-of-bounds`/`slab-use-after-free`/`wild-memory-access`)。 + - `mm/kasan/kasan.h` —— 影子魔数定义 `KASAN_*`、`struct kasan_track` / `kasan_alloc_meta` / `kasan_free_meta`。 + - `mm/kasan/kasan_test_c.c` —— 6.19 的 KASAN KUnit 测试(76 个故意写坏的用例),随 `kasan_test.ko` 构建,suite 名 `kasan`。 + - `include/linux/kasan.h` —— 影子地址映射 `kasan_mem_to_shadow()`。 +- kernel.org:[KASAN 文档索引](https://docs.kernel.org/dev-tools/kasan/index.html)。 +- 笔记:`document/notes/linux_kernel_debugging/ch05.md` 及其子章 ch05_3~ch05_7(笔记基于较早内核,模块名/测试位置/用例数以本文 6.19 核对为准)。 \ No newline at end of file diff --git a/document/tutorials/debugging/04-debug-slub.md b/document/tutorials/debugging/04-debug-slub.md new file mode 100644 index 00000000..6f1ecac9 --- /dev/null +++ b/document/tutorials/debugging/04-debug-slub.md @@ -0,0 +1,283 @@ +--- + +title: SLUB 调试:红区、毒药与追踪 +slug: debug-slub +prerequisites: + - /tutorials/kernel/mm/02-mm-slab +next: + - /tutorials/debugging/05-debug-kasan +difficulty: intermediate +tags: [调试, 内存, SLUB, KASAN] +architectures: [arm64, x86_64, riscv] +kernel_version: "6.19" +maturity: drafting +sources: + - notes: document/notes/linux_kernel_programming/ch06_2.md + - notes: document/notes/linux_kernel_programming/ch06_4.md + - notes: document/notes/linux_kernel_programming/ch06_5.md +--- + +# SLUB 调试:红区、毒药与追踪 + +> 🔨 **整理中** · 本篇机制对照 Linux 6.19 源码讲解(函数/数据结构/数值已逐条核对 `mm/slub.c`、`include/linux/poison.h`、`mm/slab.h`);具体行号与命令输出待 QEMU 亲测核对。 + +## slab 出了问题最难查 + +承接 [SLAB/SLUB 分配器](/tutorials/kernel/mm/02-mm-slab) 那篇,我们知道 `kmem_cache` 把固定大小的对象攒成一池反复用,省得走 buddy 那套大动干戈的分配。但这个"反复用"恰恰是它最坑的地方——一个对象这秒是结构体 A,下秒被归还,再下秒可能就分给了别人当结构体 B。 + +于是内核里最阴间的两类 bug 全砸在 slab 上: + +- **越界写(buffer overrun)**:对象只有 64 字节,你写了 80 字节,多出来的 16 字节糊到了隔壁对象头上。隔壁对象的内容莫名其妙变了,但**两个对象本身都"正常活着"**,谁也不 panic,bug 要等很久以后才以八竿子打不着的方式爆出来。 +- **释放后使用(UAF, use-after-free)**:`kfree(p)` 之后你又去读 `p`,如果那块内存还没被别人拿走,你读到的还是旧数据,程序"看起来对"——直到某天别人分配走了那块内存,你读到的全是垃圾。 + +这类 bug 的可怕之处在于:**错误现场会消失**。等你发现隔壁对象坏了,早就过了案发时间,栈都没了。这就是 SLUB 调试要解决的核心矛盾——**在分配/释放的瞬间布下哨兵,让破坏在发生时就报警,而不是等后果蔓延**。 + +## slub_debug:四个开关一把抓 + +SLUB 把调试能力拆成四类,用一个启动参数 `slub_debug=` 统一控制,每个能力对应一个字母: + +| 字母 | 能力 | 逮什么 bug | +|------|------|-----------| +| `F` | **F**ree sanity(一致性检查) | 释放时基本健全性检查(重复释放、错误指针) | +| `Z` | Red **Z**one(红区) | 越界写 | +| `P` | **P**oison(毒药) | 未初始化读 / UAF 读 | +| `U` | Alloc/free tracking(**U**se trace) | 内存泄漏 | + +启动时一行搞定: + +```bash +# 给所有 slab cache 开全部四类调试 +slub_debug=FZPU + +# 只给 kmalloc-64 开,其余不动 +slub_debug=FZP,kmalloc-64 + +# 关掉所有(调试开太大想回退时用) +slub_debug=-,kmalloc-64 +``` + +这四个字母背后对应的不是四个独立子系统,而是 `struct kmem_cache` 上一组 debug 标志位。内核在 `mm/slub.c` 里把 `slub_debug` 字符串解析进全局位掩码,然后每个 cache 创建时根据它来决定 `flags` 里要不要置上 `SLAB_CONSISTENCY_CHECKS`、`SLAB_RED_ZONE`、`SLAB_POISON`、`SLAB_STORE_USER` 这几个标志。**这些标志位会真实改变对象在内存里的布局**——下面逐个讲。 + +## Red Zone:对象两侧站两个哨兵 + +`Z` 开的是 Red Zone(红区)。思路特别直白:既然越界写会糊到隔壁对象,那我就在**每个对象的有效区域之后塞一段哨兵字节**,分配器知道这段字节应该是什么值,一旦被改了,就是有人越界了。 + +在 SLUB 里,Red Zone 不是你想的"红色",它就是一段固定填充字节,而且**空闲对象和已分配对象填的值不一样**。权威定义在 `include/linux/poison.h:41-42`: + +```c +/* include/linux/poison.h */ +#define SLUB_RED_INACTIVE 0xbb /* when obj is inactive */ +#define SLUB_RED_ACTIVE 0xcc /* when obj is active */ +``` + +红区逻辑是**双向分值**的:空闲对象的红区是 `0xbb`(`SLUB_RED_INACTIVE`),对象一旦被分配出去、变成"使用中",它的红区就被改写成 `0xcc`(`SLUB_RED_ACTIVE`)。`mm/slub.c` 顶部的 Object layout 注释(`mm/slub.c:1350-1387`)把这套布局钉死了: + +``` +object address + Bytes of the object to be managed. + ... 对象体 ... +object + s->object_size + Padding to reach word boundary. This is also used for Redzoning. + We fill with 0xbb (SLUB_RED_INACTIVE) for inactive objects and with + 0xcc (SLUB_RED_ACTIVE) for objects in use. +object + s->inuse + Meta data starts here. (free pointer / tracking / ...) +object + s->size + Nothing is used beyond s->size. +``` + +注意红区塞在 `object_size` 到 `inuse` 这段缝隙里(SLUB 为了对齐,对象实际占的格子往往比你申请的大一点)。这段额外开销在 cache 创建时被算进每个对象的 `inuse`/`size`,所以**开了 Red Zone 之后内存占用会涨**。 + +检查的时机很关键,而且**两个方向各查各的**: + +- **分配时**:对象被领走前,`alloc_debug_processing()`(Linux 6.19, `mm/slub.c:1717`) 调 `check_object()` 校验它此刻还应该是空闲态(`SLUB_RED_INACTIVE=0xbb`),确认没人偷偷写过这块即将交给你的内存。 +- **释放时**:对象被还回时,`free_debug_processing()`(Linux 6.19, `mm/slub.c:4309`,内部走 `free_consistency_checks`) 调 `check_object()` 校验它此刻还应该是使用态(`SLUB_RED_ACTIVE=0xcc`),看你在用它的期间有没有越界糊到红区。 + +`check_object()`(`mm/slub.c:1448`) 逐字节比对的活儿最终落在 `check_bytes_and_report()`(`mm/slub.c:1318`): + +```c +/* mm/slub.c, 简化自 check_bytes_and_report() */ +static u8 *check_bytes_and_report(struct kmem_cache *s, struct slab *slab, + u8 *object, const char *what, + u8 *start, unsigned int value, + unsigned int bytes, bool must_fail) +{ + /* 从 start 开始,期望每个字节 == value,逐字节比对; + 发现不一致就记录 fault 字节、打印详尽报告 */ + ... +} +``` + +一旦对不上,`check_bytes_and_report()` 立刻打印一份详尽报告:哪个 cache、哪个 slab、对象地址、**哪个字节被改成了什么值**、调用栈。这份报告长这样(待亲测核对): + +``` +============================================================================= +BUG kmalloc-64 (Not tainted): Redzone overwritten +INFO: 0xffff...: 6b 6b 6b 6b cc cc cc cc <- 应该是 cc(RED_ACTIVE),被写成了 6b +INFO: Slab 0xffff... objects=16 used=1 +INFO: Object 0xffff... @offset=64 +Call trace: + [<...>] my_buggy_write+0x20/0x40 +``` + +这个 `cc cc cc cc` 才是案发现场(使用中对象的红区)——你能精确看到越界写从第几个字节开始、被改成了什么。**Red Zone 的精髓在于"延后报警":你越界的当下未必报,要等释放(或下次分配)那个瞬间才一并清算。** 所以如果你有个对象从来不释放(比如全局静态分配后又越界),Red Zone 抓不到它,得靠下面的 Poison 或 KASAN。 + +## Poison:往内存里下毒 + +`P` 开的是 Poison(毒药)。Red Zone 只盯对象边缘,Poison 把整个对象的内部都盯上,而且它管的是更阴间的问题:**用了没初始化的内存,或用了已经释放的内存**。 + +Poison 的玩法是"填特定值,读出来要是这个值就说明有问题"。权威定义在 `include/linux/poison.h:45-47`,三个值各司其职: + +```c +/* include/linux/poison.h */ +#define POISON_INUSE 0x5a /* for use-uninitialised poisoning */ +#define POISON_FREE 0x6b /* for use-after-free poisoning */ +#define POISON_END 0xa5 /* end-byte of poisoning */ +``` + +注意别被名字带偏——这套命名讲的是"填在哪个区域",对照 `mm/slub.c:1358-1379` 的 Object layout 注释: + +- **对象体内部**(`object address` 到 `object_size` 之间):用 **`0x6b`(`POISON_FREE`)** 投毒,并且**末尾一个字节**单独填 **`0xa5`(`POISON_END`)** 当哨兵。对象体整块填 0x6b、收尾加一个 0xa5,是 free 之后的标准姿态。 +- **对象之外的 padding/对齐缝隙**:用 **`0x5a`(`POISON_INUSE`)** 填充。 + +所以**对象内部的毒是 `0x6b`(`POISON_FREE`),不是 `0x5a`**;`0x5a` 是给对象之外的对齐缝隙用的。这点老资料里经常混。 + +投毒和校验是**因果分两步**的,方向要记牢: + +- **释放时投毒**:`slab_free()` → `free_debug_processing()` → `init_object()`(Linux 6.19, `mm/slub.c:1270`)。`init_object()` 在释放方向拿 `SLUB_RED_INACTIVE` 把红区/对齐缝隙抹回去、把对象体填成 `POISON_FREE(0x6b)`、末字节填 `POISON_END(0xa5)`(`mm/slub.c:1295-1296`)。free 的那一刻,这块内存就被"下了毒"。 +- **分配时校验**:`slab_alloc()` 走到 `alloc_debug_processing()` → `check_object()`(`mm/slub.c:1448`)。它把上次 free 时投的毒逐字节比对,**如果毒药值被动过**,就说明上一个使用者在 free 之后还偷偷写过(UAF 的"写后释放"变种),立刻报 `Poison overwritten`。 + +> 顺带一提:`set_orig_size()`(`mm/slub.c:860`) 跟投毒没关系,它只干一件事——把这次 `kmalloc` 的**原始请求大小**存进对象元数据(配合 `SLAB_KMALLOC` 做 kmalloc 红区用)。别把它混进投毒叙事。 + +所以 Poison 能逮两种问题: + +1. **UMR(使用未初始化内存)**:你分配了对象却没初始化就拿来用,读到一堆 `0x6b`,逻辑可能就错了。分配器没法直接知道你读没读(所以不直接 oops),但 `0x6b 6b 6b 6b` 这种特征值出现在你的数据里时,基本就是没初始化的铁证。 +2. **UAF 读**:free 之后对象体被填了 `0x6b` 毒药,你再读,读到的就是毒药值,典型征兆是看到一个指针字段是 `0x6b6b6b6b6b6b6b6b`,直接解引用就是 oops。 + +Poison 比 Red Zone 贵——它每次 alloc/free 都要扫整个对象填值/比对,对象越大越慢。这就是为什么默认不开。 + +## alloc/free track:给每个对象挂一份案底 + +`U` 开的是 tracking,SLUB 里对应的标志叫 `SLAB_STORE_USER`。它的活儿是在每个对象的元数据里**存下分配它和释放它的那次调用信息**。这样一旦对象出问题,你能直接看到"谁分配的、谁释放的",追凶链路完整。 + +数据结构是 `mm/slub.c:340` 定义的 `struct track`(Linux 6.19): + +```c +/* mm/slub.c:339-348 */ +#define TRACK_ADDRS_COUNT 16 + +struct track { + unsigned long addr; /* 触发分配/释放的那一帧 IP */ +#ifdef CONFIG_STACKDEPOT + depot_stack_handle_t handle; /* 指向 stack_depot 里的完整调用栈 */ +#endif + int cpu; /* 哪个 CPU 分配/释放的 */ + int pid; /* 哪个进程 */ + unsigned long when; /* jiffies 时间戳 */ +}; + +enum track_item { TRACK_ALLOC, TRACK_FREE }; +``` + +这里有个关键点容易看走眼:`struct track` 里**只存一个 `addr`(单帧),不是一整条调用栈**。完整的调用栈是 `set_track_prepare()`(`mm/slub.c:1041`) 用一个长度为 `TRACK_ADDRS_COUNT(16)` 的**局部数组** `entries[16]` 抓下来,再 `stack_depot_save()` 进栈仓库(stack depot),返回的 `handle` 句柄存在 `track->handle` 里。要看完整栈,得拿这个 `handle` 去 stack_depot 反查。换句话说,**完整栈存在 stack_depot,track 上只挂句柄**——别误以为 track 里直接挂着 16 帧数组。 + +每个对象旁边(`get_info_end()` 之后)挂着 `ALLOC`/`FREE` 两份 `struct track`(各一份,共 `2 * sizeof(struct track)`)。读取它们的入口是 `get_track()`(`mm/slub.c:1030`),打印栈的入口是 `print_track()`(`mm/slub.c:1093`) / `print_trailer()`(`mm/slub.c:1172`)。 + +track 最香的用法不是逐个查对象,而是**逮内存泄漏**:在 `kmem_cache_destroy()` 销毁整个 cache 时,SLUB 会扫一遍所有 slab,对每个**还没被释放的对象**调 `print_tracking()`(`mm/slub.c:1111`),把它分配时的栈打印出来(`mm/slub.c:8148` 就是这个路径)。于是一个模块卸载、cache 销毁,控制台上哗啦啦列出所有"分配了却没归还"的对象及其分配栈——泄漏点一目了然: + +``` +INFO: Slab 0xffff... objects=16 used=3 +INFO: Object 0xffff... @offset=0, inuse=64 +Allocated in my_module_init+0x2c/0x80 age=X cpu=0 pid=123 + [<...>] my_module_init+0x2c/0x80 + [<...>] do_one_initcall+0x... + [<...>] ... +``` + +这就是为什么调试内存泄漏时,**先把模块拆出独立 cache(`kmem_cache_create`)再开 `SLAB_STORE_USER`,然后反复 modprobe/rmmod** 是经典套路——rmmod 销毁 cache 那一下,泄漏栈自动浮出来。 + +## 运行时开关:能查、但不能随便改 + +启动参数是"全局默认",但很多时候你已经跑起来了,想看看当前状态。SLUB 给了 sysfs 接口,每个 cache 都有自己的目录: + +``` +/sys/kernel/slab// +``` + +比如 `kmalloc-64` 就是 `/sys/kernel/slab/kmalloc-64/`。里面有一堆文件,关键的几个: + +- **`red_zone` / `poison` / `store_user` / `sanity_checks`**:这几个**都是只读的**(`mm/slub.c:9440/9447/9454/9427`,全是 `SLAB_ATTR_RO`),只能 `cat` 看"当前这个 cache 开了哪些 debug 标志",**不能 `echo 1 >` 动态开**。一旦写了会失败。SLUB 的设计是:cache 创建时(`slub_debug=` 解析落位)flag 就定死了,运行中不易改。 +- **`validate`**:这是唯一一个能写的调试触发(`mm/slub.c:9461-9473`,`SLAB_ATTR`)。`echo 1 > /sys/kernel/slab//validate` 会触发一次 `validate_slab_cache()` 全盘扫描——但前提是 `kmem_cache_debug(s)` 已经为真(即这个 cache 本来就开着 debug);对没开 debug 的 cache 写 1,`validate_store()` 会返回 `-EINVAL` 静默拒绝。这是"主动扫一遍、把潜伏问题逼出来"的开关,不是"开 debug"的开关。 +- **`slabs` / `objects` / `object_size`**:看这个 cache 的基本账(也都是只读)。 + +还有一个老牌工具 `slabinfo`(内核源码 `tools/mm/slabinfo.c`),它就是读 `/proc/slabinfo` 和 `/sys/kernel/slab/` 把信息整理成人话。最常用的几个参数(`tools/mm/slabinfo.c:111-145` 的 usage):`slabinfo -t`(或 `--tracking`)吐出各 cache 的分配/释放统计、`slabinfo -v`(或 `--validate`)对开了 debug 的 cache 做一次校验、`slabinfo -r` 看单个 cache 的详细报告。**想在用户态"按调用栈聚合看分配/释放"是用 `slabinfo -t`,不是去 `cat` 某个 sysfs 文件**——6.19 的 `/sys/kernel/slab//` 下并没有 `alloc_calls`/`free_calls` 这种文件,那种聚合统计是 slabinfo 工具读 `/proc/slabinfo` + debug 数据后算出来的。 + +至于"运行时动态开 debug"这件事:6.19 里**没有**给单个 cache 动态打开 red_zone/poison 的 sysfs 写接口。要切 debug 配置,基本就是重启改 `slub_debug=` 启动参数这一条正路。slabinfo 工具自带的 `--debug=FZPU`(对应 `-da`)那套是**给 slabinfo 工具自己看的输出开关**,不会真去改内核里 cache 的 flag——别误以为它能在运行中给 cache 开 debug。 + +内核侧判断要不要走带检查的慢路径,靠的是 `kmem_cache_debug(s)`(`mm/slub.c:252`,是个 `static inline` 内部封装,背后是 `mm/slab.h:490` 的 `kmem_cache_debug_flags()`)。它检查的是 `SLAB_DEBUG_FLAGS`(`mm/slab.h:434`,=`SLAB_RED_ZONE | SLAB_POISON | SLAB_STORE_USER | SLAB_TRACE | SLAB_CONSISTENCY_CHECKS`)。**只要 cache 开着这组标志里的任何一个,alloc/free 就绕过 cmpxchg 快路径,改走 `alloc_debug_processing()`/`free_debug_processing()` 带检查的慢路径**——这就是开 debug 有性能损失的根因。(顺带澄清一个常见误传:6.19 内核里**并没有 `SLAB_MEMCPY` 这个标志**,快慢路径分流的判据就是上面这条 `kmem_cache_debug(s)`,不是某个 memcpy 标志。) + +## 和 KASAN 怎么分工 + +读到这儿你可能会问:这玩意和 KASAN(Address Sanitizer)不都是查内存破坏的吗? + +分工是这样的: + +- **SLUB debug 轻量、针对性强**:它只管 slab 分配出来的内存,机制简单(填字节、存栈),开销主要在 alloc/free 路径。适合"我知道大概是 slab 出问题,想低成本长期开着盯"。 +- **KASAN 通用、覆盖广但重**:它给**所有**内存(包括 buddy 直接给的 page、栈变量、全局变量)都罩上一层影子内存(shadow memory),越界/UAF 都能逮,精度更高(能逮单字节越界、读到已释放后又被重用的数据)。代价是内存翻倍影子、每次访存都有插桩检查,性能开销大(典型 2-3 倍 slowdown)。 + +实战上:**复现阶段用 KASAN** 一把锁死范围(它报警最及时,不依赖 free 时机);**定位到是 slab cache 后切到 SLUB debug** 长期盯,顺便拿 alloc/free track 查泄漏栈。两者不互斥,可以同时开,只是都开会很慢。 + +## 动手验证方案(待 QEMU 亲测) + +按下面的套路走一遍,把上面讲的机制亲自跑出报告。具体命令输出待亲测后回填。 + +**1. 启动带 slub_debug 的内核** + +在 QEMU 启动参数里给内核传 `slub_debug=FZPU`(或先只开 `ZP` 试水),观察 dmesg 确认调试已启用: + +```bash +# 引导后确认 +dmesg | grep -i slub # 期待看到 SLUB debug 相关行,待亲测核对 +cat /sys/kernel/slab/kmalloc-64/red_zone # 应为 1(只读,确认当前开了 Red Zone) +cat /sys/kernel/slab/kmalloc-64/sanity_checks +``` + +**2. 写一个故意越界的内核模块** + +在 `example/mini/` 下放一个小模块,`kmem_cache_alloc` 一个小对象,然后故意往对象尾巴后面多写几个字节,再 `kmem_cache_free`。预期:free 的瞬间(或下次分配扫描时),dmesg 喷出 `Redzone overwritten` 报告,带上越界的具体字节(应该是 `0xcc` 那段被改了)和写入栈。 + +**3. 写一个 UAF 模块** + +分配 → 释放 → 把保存的指针再读一次(或写一次)。预期:读到的值是毒药 `0x6b` 系列(对象体 `POISON_FREE`),或在后续分配扫描时报 `Poison overwritten`(检查末尾的 `0xa5`/`POISON_END` 哨兵是否还在)。 + +**4. 写一个泄漏模块** + +`kmem_cache_create` 一个独立 cache → `kmem_cache_alloc` 几个对象**故意不释放** → `kmem_cache_destroy`。预期:destroy 时 `print_tracking()` 打印每个未释放对象的分配栈,泄漏点直接指向你的 alloc 调用。 + +每个模块都走 `example/common/Makefile.arch` 多架构编译(arm64/x86_64/riscv),验证报告在三套架构上行为一致。这部分的模块代码留给你按 mm-slab 篇的套路自己搭,本篇只给验证目标和预期现象——代码归你,机制讲解归我。 + +## 小结 + +我们这一篇把 SLUB 调试的四类能力拆到了内核源码层面: + +- **Red Zone(Z)**:对象红区填**两个不同值**——空闲对象填 `0xbb`(`SLUB_RED_INACTIVE`)、使用中对象填 `0xcc`(`SLUB_RED_ACTIVE`),`check_object()`/`check_bytes_and_report()` 在 alloc/free 两个方向分别校验,逮越界写。 +- **Poison(P)**:对象体投 `0x6b`(`POISON_FREE`)、末字节加 `0xa5`(`POISON_END`)哨兵、对象外的对齐缝隙填 `0x5a`(`POISON_INUSE`)。投毒在 `init_object()` 的 free 路径、校验在 `check_object()` 的 alloc 路径,逮 UMR/UAF。 +- **Tracking(U)**:`struct track`(`addr` + `stack_depot handle` + `cpu`/`pid`/`when`),完整栈存 stack_depot、track 上只挂句柄。`SLAB_STORE_USER` 标志开启,`kmem_cache_destroy` 时 `print_tracking()` 打印泄漏栈。 +- **Sanity(F)**:`SLAB_CONSISTENCY_CHECKS`,释放时基本健全性检查(重复释放等)。 + +它们都挂在 `struct kmem_cache` 的 flags 上,由 cache 创建路径根据 `slub_debug` 解析结果落位,运行时通过 `/sys/kernel/slab//`(只读查标志 + `validate` 主动扫描)和 `slabinfo` 工具观测;快慢路径分流的判据是 `kmem_cache_debug(s)`(`SLAB_DEBUG_FLAGS`)。和 KASAN 一个轻量针对、一个通用沉重,搭配着用。 + +## 延伸阅读 + +- 内核源码(Linux 6.19): + - `mm/slub.c` —— SLUB 主实现。`Object layout` 注释块(`mm/slub.c:1350-1387`)讲清了红区/毒药/padding 各填什么值;`init_object()`(`mm/slub.c:1270`,投毒)、`check_object()`(`mm/slub.c:1448`)/`check_bytes_and_report()`(`mm/slub.c:1318`,红区与毒药校验)、`alloc_debug_processing()`(`mm/slub.c:1717`)/`free_debug_processing()`(`mm/slub.c:4309`,慢路径)、`get_track()`(`mm/slub.c:1030`)/`set_track()`(`mm/slub.c:1074`)/`print_track()`(`mm/slub.c:1093`)/`print_tracking()`(`mm/slub.c:1111`,追踪)都在这里;`struct track` 定义在 `mm/slub.c:340`。快慢路径判据 `kmem_cache_debug(s)` 在 `mm/slub.c:252`。 + - `include/linux/poison.h` —— 毒药常量权威出处:`SLUB_RED_INACTIVE(0xbb)`/`SLUB_RED_ACTIVE(0xcc)`(`poison.h:41-42`)、`POISON_INUSE(0x5a)`/`POISON_FREE(0x6b)`/`POISON_END(0xa5)`(`poison.h:45-47`)。 + - `include/linux/slab.h` —— `SLAB_RED_ZONE`/`SLAB_POISON`/`SLAB_STORE_USER`/`SLAB_CONSISTENCY_CHECKS` 标志位定义(这些标志确实在此头文件);`slub_debug=` 字符串到 flag 的解析在 `mm/slub.c`。 + - `mm/slab.h` —— `kmem_cache_debug_flags()`(`mm/slab.h:490`)、`SLAB_DEBUG_FLAGS`(`mm/slab.h:434`)。注意:`kmem_cache_debug(s)` 是 `mm/slub.c` 的 `static inline` 内部封装,**不是** `include/linux/slab.h` 的导出 API,别去头文件里找。 + - `tools/mm/slabinfo.c` —— slabinfo 工具源码(`-t` 看分配/释放统计、`-v` 校验、`-r` 详细报告)。6.x 起 slabinfo.c 从 `tools/vm/` 迁到了 `tools/mm/`,老资料里写的 `tools/vm/slabinfo.c` 已过时。 +- 官方文档: + - [kernel.org 开发工具总索引](https://docs.kernel.org/dev-tools/index.html) —— 含 SLUB debug、KASAN、kmemleak 入口。 + - [kernel.org KASAN 文档](https://docs.kernel.org/dev-tools/kasan.html) —— 对照 SLUB debug 理解分工。 +- 站内: + - [SLAB/SLUB 分配器](/tutorials/kernel/mm/02-mm-slab) —— 本篇前置,看 `kmem_cache` 基本运作。 + - [printk 调试输出](/tutorials/debugging/01-debug-printk) —— 报告怎么读、怎么控制日志级别。" \ No newline at end of file diff --git a/document/tutorials/debugging/05-debug-oops.md b/document/tutorials/debugging/05-debug-oops.md new file mode 100644 index 00000000..b8b125f1 --- /dev/null +++ b/document/tutorials/debugging/05-debug-oops.md @@ -0,0 +1,198 @@ +--- +title: Oops:内核崩溃现场解读 +slug: debug-oops +difficulty: intermediate +tags: [内核调试, Oops, panic, 栈回溯] +architectures: [arm64, x86_64, riscv] +kernel_version: "6.19" +maturity: drafting +prerequisites: + - /tutorials/foundations/06-gdb-debug-setup +related: + - /tutorials/debugging/01-debug-printk +sources: + - notes: document/notes/linux_kernel_debugging/ch07.md +--- + +# Oops:内核崩溃现场解读 + +> 🔨 **整理中** · 本篇机制对照 Linux 6.19 源码讲解(函数/数据结构已核对);具体行号与命令输出待 QEMU 亲测核对。 + +## Oops 到底是什么 + +写用户态程序,指针乱指顶多 `Segmentation fault`——内核给进程发个 `SIGSEGV`,进程自己死,系统活得好好的。可一旦错误发生在内核代码里——空指针解引用、写只读页、非法指令——事情性质就变了:**这是内核自己在犯错**。内核没有"上层"能给它善后,它要么硬扛着把肇事进程做掉继续跑,要么干脆承认自己活不下去了。这个"承认错误并打印现场"的瞬间,就是 **Oops**。 + +触发 Oops 的硬件源头通常是一条访存指令撞上了 MMU 的墙。在 ARM64 上,MMU 翻译失败或权限不对,CPU 抛出同步异常(Synchronous Exception),异常向量把控制权交给 `do_mem_abort()`(`arch/arm64/mm/fault.c`)。它根据 ESR(异常综合寄存器)查一张 `fault_info` 表,分派到具体的 fault 处理函数——比如翻译失败走 `do_translation_fault()`、访问标志/权限失败走 `do_page_fault()`。这些处理函数发现"这是内核态、又没有上下文可救"时,才会走到 `__do_kernel_fault()`:它先判断这错能不能救(比如 `copy_from_user` 这种带异常修复表的场景,`fixup_exception()` 直接改寄存器跳走),救不了才打印诊断并调用 `die("Oops", regs, esr)`。所以中间隔了那一层 fault fn,不是 `do_mem_abort` 直接调 `__do_kernel_fault`。 + +```c +// arch/arm64/mm/fault.c(Linux 6.19) +static void __do_kernel_fault(unsigned long addr, unsigned long esr, + struct pt_regs *regs) +{ + if (!is_el1_instruction_abort(esr) && fixup_exception(regs, esr)) + return; /* 能修就修,悄悄跳过 */ + /* ... 判定 msg(权限错 / NULL / paging request) ... */ + else if (addr < PAGE_SIZE) + msg = "NULL pointer dereference"; + /* ... */ + die_kernel_fault(msg, addr, esr, regs); +} +``` + +注意那个 `addr < PAGE_SIZE` 的判定——这就是为什么报错地址常常是个小于 4096 的怪数字。你写 `oopsie->data = 'x'`,`oopsie` 是 NULL,`data` 在结构体里偏移 `0x30`(48 字节),CPU 真正访问的是 `NULL + 0x30 = 0x30`。**看到一个几十几百的小地址,反射弧就该接上:八成是 NULL 指针访问结构体成员,那数字就是成员的偏移量。** + +## Oops vs panic:一念生,一念死 + +关键区别在 `die()` 收尾怎么走(`arch/arm64/kernel/traps.c`,Linux 6.19): + +```c +void die(const char *str, struct pt_regs *regs, long err) +{ + raw_spin_lock_irqsave(&die_lock, flags); + oops_enter(); + console_verbose(); + bust_spinlocks(1); + ret = __die(str, err, regs); /* 打印现场 */ + if (regs && kexec_should_crash(current)) + crash_kexec(regs); + bust_spinlocks(0); + add_taint(TAINT_DIE, LOCKDEP_NOW_UNRELIABLE); + oops_exit(); + + if (in_interrupt()) + panic("%s: Fatal exception in interrupt", str); /* 中断里崩,必死 */ + if (panic_on_oops) + panic("%s: Fatal exception", str); /* 开了开关,必死 */ + + raw_spin_unlock_irqrestore(&die_lock, flags); + if (ret != NOTIFY_STOP) + make_task_dead(SIGSEGV); /* 否则杀进程了事 */ +} +``` + +这里藏着两条命:**Oops 默认能活**——它把肇事进程(`make_task_dead`)做掉,然后系统继续跑。但有两条路会升级成 panic(内核彻底停摆):一是**在中断上下文里崩了**(`in_interrupt()` 为真,没有进程可背锅,只能死);二是 `panic_on_oops` 这个开关被打开。 + +`panic_on_oops` 定义在 `kernel/panic.c` 第 56 行(`int panic_on_oops = IS_ENABLED(CONFIG_PANIC_ON_OOPS);`),初始值跟 `CONFIG_PANIC_ON_OOPS` 走,但运行时可以通过 `/proc/sys/kernel/panic_on_oops` 临时改。想逼内核一崩就死(方便抓第一现场的完整 dump),`echo 1 > /proc/sys/kernel/panic_on_oops`;想让它苟着让你看现场又不重启,就保持 0。 + +为什么中断里必死?因为中断没有进程上下文,`current` 指向的只是那个倒霉蛋——当时恰好被中断抢占的进程,它对这次崩溃一无所知,杀它毫无意义,整个内核状态已经不可信,只能 panic 保平安。 + +## Oops 输出逐段解读 + +以 ARM64 为例,Oops 大致长这样(待亲测核对): + +``` +Internal error: Oops: 96000046 [#1] SMP +CPU: 0 PID: 16 Comm: kworker/0:1 Tainted: G OE +pc : do_the_work+0x68/0x94 [oops_tryv2] +lr : process_one_work+0x1a7/0x360 +sp : ffff8000800dbe90 +pstate: 60400009 (nZCv ... +PAN +UAO ...) +x29: ffff8000800dbe90 x28: ... +... +Call trace: + do_the_work+0x68/0x94 [oops_tryv2] + process_one_work+0x1a7/0x360 + worker_thread+0x4d/0x3f0 + ... +Code: a9bf7bfd 910003fd f9000f80 (e5c3201c) +---[ end trace 0000000000000000 ]--- +``` + +**第一行**(`Internal error: ...`)来自 `__die()`:`pr_emerg("Internal error: %s: %016lx [#%d] " S_SMP "\n", str, err, ++die_counter)`。那个 `[#1]` 是 `die_counter`——本次开机第几次 Oops。`err` 是 ESR 值,要查 ARM 架构手册(ARM ARM)才能解出来。 + +**`pc`/`lr`/`sp`/`pstate`** 来自 `__show_regs()`(`arch/arm64/kernel/process.c`):内核态下 `pc` 用 `%pS` 格式直接解出"函数名+偏移",这就是我们定位源码的钥匙。`pc` 是崩在哪儿,`lr`(x30)是返回地址(谁调进来的),`sp` 是栈顶,`pstate` 是处理器状态(`print_pstate` 把它的位拆成 `nPAN +UAO` 这种人类可读字母)。 + +**`Tainted`** 一栏是"内核污染"状态,由 `add_taint()` 累积。这里有个反直觉的点:最前面那个 `G` **不是一个污染标志**,恰恰相反——它是"干净基线字符"。`kernel/panic.c:646` 里 `TAINT_FLAG(PROPRIETARY_MODULE, 'P', 'G')` 的语义是:`c_true`(这一位置位时打印)是 `P`,`c_false`(未置位时打印)是 `G`。所以 `G` 表示第 0 位(`TAINT_PROPRIETARY_MODULE`)没置位——你这内核没加载私有闭源模块,还算干净。真正能被打出来的污染三件套是 `O`(`TAINT_OOT_MODULE`,树外模块)、`E`(`TAINT_UNSIGNED_MODULE`,未签名模块),再加上 Oops 自己触发的 `D`(`TAINT_DIE`)。要是看到 `P`(私有闭源)或 `F`(`TAINT_FORCED_MODULE`,强制加载),上游开发者基本会拒收 bug 报告。 + +**`Code:`** 那行是崩点附近的机器码字节,由 `dump_kernel_instr()`(`arch/arm64/kernel/traps.c`)打印。ARM64 的格式是把崩点那条指令**用圆括号**括起来:`dump_kernel_instr` 在 `arch/arm64/kernel/traps.c:166` 用的是 `i == 0 ? "(%08x) " : "%08x "`,所以你看到的是 `(e5c3201c)` 这种被圆括号包着的字,旁边是它前后各几条不带括号的指令。**注意这跟 x86 Oops 里常见的尖括号 `<...>` 不是一回事**——那是 x86 `Code:` dump 的格式(把触发指令包成 `<48>`),架构之间别张冠李戴。人眼看不懂这些字节没关系,内核自带 `scripts/decodecode` 能把它反汇编出来。 + +**`Call trace`** 是栈回溯,最底下是最早调用的函数。行首带 `?` 的是回溯算法觉得"不太可靠、可能是栈上残留数据"。**末尾那行 `---[ end trace ...]---`** 来自 `oops_exit()` 里的 `print_oops_end_marker()`(`kernel/panic.c:852`,`pr_warn("---[ end trace %016llx ]---\n", 0ULL)`)——看到它说明整个 Oops 打印流程走完了。 + +## `dump_stack()` 怎么把栈走出来的 + +整个 `Call trace` 的幕后功臣是栈回溯。ARM64 的函数调用约定里,每个函数进来都会执行 prologue:把返回地址 `lr` 存进当前栈帧、把上一帧的 `fp`(x29)压栈,然后把新的 `fp` 指向本帧。这样所有栈帧就串成了一条**以 fp 为指针的链表**。 + +回溯的起点在 `arch/arm64/kernel/stacktrace.c`:对于当前崩溃的任务,`state->common.fp = regs->regs[29]`、`state->common.pc = regs->pc`(`stacktrace.c:86-87`)——也就是拿崩溃那一瞬的 x29 和 pc 作种子。然后顺着 `fp` 链一帧一帧往上爬:每读一个 `fp`,就拿到上一层 `fp` 和那条 `lr`(返回地址),`lr` 就是调用者里"调用点下一条指令"的地址,于是 `Call trace` 里就多出一行。 + +`dump_stack()` 本体(`lib/dump_stack.c`)做的事很简单:先 `dump_stack_print_info` 打个头,然后 `show_stack(NULL, NULL, log_lvl)`(`dump_stack.c:93-94`)——后者最终走到架构的回溯器把整条链吐出来。所以你手动 `dump_stack()` 和 Oops 里看到的 `Call trace`,走的是**同一套机制**。 + +**这有个大坑**:fp 链要成立,编译时必须保留帧指针。在 ARM64 上,解栈靠的是 `arch/arm64/kernel/stacktrace.c` 这套帧指针解栈器,编译期靠顶层 `Makefile:925` 全局开的 `-fno-omit-frame-pointer` 保留 fp(开了 `CONFIG_FUNCTION_TRACER` 时那行会被 `-fomit-frame-pointer` 覆盖回去,但那是另一个话题)。注意 `CONFIG_UNWINDER_FRAME_POINTER` 和基于 ORC/dwarf 的解栈器**都是 x86 的概念**——`arch/arm64/Kconfig` 里压根没有 `UNWINDER_*` 选项,别在 ARM64 语境下搬这些配置名。要是某些函数(比如内联、汇编、或被 `-O2` 优化掉帧指针的)没乖乖建栈帧,链就断了,`Call trace` 在那处就会断档或出现一堆 `?`。这也是为什么内核调试构建会强开 `-fno-omit-frame-pointer -Og`。 + +## 把地址钉回源码行 + +有了 `pc : do_the_work+0x68/0x94 [oops_tryv2]`,那个 `+0x68` 就是命门。三把刀,挑顺手的用: + +**`addr2line`(最轻)**:直接地址转文件:行号。模块崩了喂 `.ko` 或 `.o`,内核崩了喂带调试符号的 `vmlinux`。 + +```bash +# 待亲测核对 +addr2line -e ./oops_tryv2.ko -p -f 0x68 +# 输出形如: do_the_work at oops_tryv2.c:62 +``` + +**`gdb list *符号+偏移`(最直观)**:GDB 懂 ELF/DWARF,会顺便把上下文源码列出来,`=>` 指着那行。 + +```bash +# 待亲测核对 +$ gdb -q ./oops_tryv2.ko +(gdb) list *do_the_work+0x68 +``` + +**`objdump -dS`(最底层)**:把整段反汇编连同 C 源码混排出来,适合看汇编层面的把戏。如果模块还活在内存里,`sudo grep oops_tryv2 /proc/modules` 拿到加载基址,配 `--adjust-vma=基址` 让左侧地址和运行时对齐,再拿崩点的绝对地址去 grep。 + +> ⚠️ **待亲测**:下面的汇编示意是整理时的占位,偏移口径全文统一用 `0x30`(48)这套(对应 NULL 指针 + 结构体成员偏移 48 字节)。QEMU ARM64 跑出来的真实字节、真实指令会替换这一段,不会混 x86/ARM32 两套笔记数据。 + +``` +; 假设崩点指令把 'x' 写进结构体成员(偏移 0x30=48) +; 具体寄存器分配与字节码待亲测核对 +strb w2, [x3, #48] ; x3=0(NULL) -> 写 0+48,炸 +``` + +这条 `strb`(待亲测核对真实指令)就是把 `'x'` 存进 `[x3, #0x30]`,而 x3 在上一条被置成 0——汇编当场破案。 + +**KASLR 的坑**:开了内核地址随机化(`CONFIG_RANDOMIZE_BASE`)时,Oops 里的地址是"随机化后的绝对地址",而 `vmlinux` 符号是"编译时的相对地址",`addr2line` 直接喂会对不上号。两条路:启动参数加 `nokaslr` 关掉,或者用内核源码树里的 `scripts/faddr2line`(它处理"符号+偏移"形式,绕开绝对地址问题)。前提永远是**二进制带调试符号**——模块 Makefile 开 `-g`、内核开 `CONFIG_DEBUG_INFO=y`,否则上面全是空谈。 + +## 中断上下文:Oops 打不出来怎么办 + +前面说过,中断里崩必然 panic。但真正的坑是:panic 时控制台可能已经锁死,**屏幕黑掉、键盘失灵,`dmesg` 根本敲不出来**——内核的遗言打印了,却没人收得到。 + +这要靠一条不依赖显卡的备用通道把日志"走私"出来: + +- **串口控制台**:QEMU/真机加一根虚拟或物理串口,启动参数 `console=ttyS0 console=tty0 ignore_loglevel`。内核 `printk` 会把日志同时往串口灌,宿主机用文件接住。`ignore_loglevel` 是关键——别让日志级别过滤把救命信息挡了。 +- **`netconsole`**:把 `printk` 内容封装成 UDP 包轰到局域网另一台机器,靠 `netcat -u -l 6666` 接。网卡还活着就能收,连物理串口都省了。配置时目标 MAC 地址最好硬编码(panic 时 ARP 路由可能已乱)。 +- **`pstore`/`ramoops`**:把 panic 日志写进一块保留内存,重启后从 `/sys/fs/pstore/` 读出来。这是"死机重启后还想看现场"的正解。 + +捕获后看中断 Oops,会发现 `Call trace` 被 ` ... ` 分成两截——上半截是中断栈上的路径,下半截是被打断的进程原本的栈。而 `Comm` 字段显示的进程(比如 `insmod` 甚至 `swapper/0`)多半是个**背锅侠**:它只是中断发生时 `current` 碰巧指向的那个倒霉进程,跟崩溃的真凶无关。别被它误导。 + +## die → panic 的完整生命线 + +把这条链串起来,从一条非法访存指令到屏幕上红字,内核内部走过的路是: + +1. **MMU 翻译失败** → CPU 抛同步异常 → 异常向量进 `do_mem_abort()`(`arch/arm64/mm/fault.c`)。 +2. `do_mem_abort` 按 ESR 查 `fault_info` 表分派 → 翻译失败走 `do_translation_fault`、权限/访问标志走 `do_page_fault` → 后者判定"内核态无上下文可救"走到 `__do_kernel_fault()` → 判定 NULL/只读/缺页等具体 msg。 +3. `__do_kernel_fault` 调 `die_kernel_fault()`:打印 `Unable to handle kernel ... at virtual address`、`show_pte`、再调 `die("Oops", regs, esr)`。 +4. `die()`(`arch/arm64/kernel/traps.c:206`):加锁、`oops_enter()`(关掉锁调试、标记不可信)、`console_verbose()`(强制把日志级别调到最详)、`bust_spinlocks(1)`(让 printk 在 panic 途中也能硬输出)、`__die()` 打印 ESR/模块/寄存器/`Code`、`oops_exit()`(打 `---[ end trace ]---`、触发 `kmsg_dump(KMSG_DUMP_OOPS)`)。 +5. 收尾分叉:中断上下文 → `panic("Fatal exception in interrupt")`;`panic_on_oops` 开 → `panic("Fatal exception")`;否则 `make_task_dead(SIGSEGV)` 杀进程了事。 + +`panic()` 本体在 `kernel/panic.c`(实现在 `vpanic()`,第 429 行起;`panic()` 第 622 行只是 `va_start`/`vpanic` 的薄包装):它先抢 `panic_cpu`(`panic_try_start()` 只允许一个 CPU 跑 panic 代码,其他 `panic_smp_self_stop` 自停)、`local_irq_disable`、`pr_emerg("Kernel panic - not syncing: ...")`(第 483 行)、视情况 `dump_stack()`、尝试 `crash_kexec`(kdump)、跑 `panic_notifier`、`kmsg_dump(KMSG_DUMP_PANIC)`、最后死循环。中间那条 `if (test_taint(TAINT_DIE) || oops_in_progress > 1)`(第 487 行)是为了**避免 panic 嵌套在 Oops 里时重复打栈**——源码注释原话就是"Avoid nested stack-dumping if a panic occurs during oops processing",已经打过一次了,别再打。 + +## 动手试试 + +> ⚠️ **待亲测**:下面是验证方案,具体输出我们会在 QEMU ARM64 上跑一遍记下真值再补。模块示例代码不在此展开,动手部分保持"方案 + 待亲测"占位,真跑通后再把命令输出补进来。 + +1. **触发一次进程上下文 Oops**:写个模块,`init` 里给一个 NULL 指针偏移成员赋值(比如 `struct oopsie *p = NULL; p->data = 'x';`,`data` 偏移固定设计成 0x30)。注意:光读不用会被优化掉,要么写、要么读后 `pr_info` 用掉结果。`insmod` 后 `dmesg` 看完整 Oops,对照上面逐段解读对号入座。 +2. **`addr2line` 定位**:拿 Oops 里 `pc` 的偏移,`addr2line -e oops.ko -p -f <偏移>`,确认它指回你写的那行。 +3. **`panic_on_oops` 开关对比**:`echo 1 > /proc/sys/kernel/panic_on_oops` 再触发,观察系统直接死;改回 0 再触发,进程被杀但系统继续。 +4. **中断上下文 + 串口捕获**:用 `irq_work` 把崩溃函数塞进硬中断上下文,确认屏幕黑死;然后配串口控制台(QEMU `-serial file:...` + `console=ttyS0 ignore_loglevel`),从宿主机文件里捞出完整 Oops,重点看 `...` 分隔的中断栈。 +5. **关 KASLR 对照**:同一次崩溃,`nokaslr` 下 `addr2line` 直接命中;开 KASLR 下对不上号,改用 `scripts/faddr2line`。 + +## 小结 + +Oops 是内核"承认自己犯错"的瞬间:MMU 拦下非法访存 → `do_mem_abort` 按 ESR 分派 fault fn → `__do_kernel_fault` → `die()` 打印现场 → 根据"是否在中断/`panic_on_oops`"决定是杀进程苟活还是 panic 死透。学会读那段日志——`pc` 偏移、`Code` 字节(ARM64 是圆括号、x86 是尖括号)、`Call trace`、`Tainted` 标记(记住 `G` 是干净基线不是污染位)——再配 `addr2line`/`gdb`/`objdump` 三板斧,冷冰冰的十六进制就能还原成具体源码行。中断上下文是最大坑,备好串口/`netconsole`/`pstore` 三条走私通道,才不会让内核的遗言石沉大海。 + +## 延伸阅读 + +- 源码(Linux 6.19):`kernel/panic.c`(`panic_on_oops` 第 56 行、`vpanic()` 第 429 行起、`oops_enter/exit`、`print_oops_end_marker` 第 850 行、`taint_flags` 表第 645 行)、`arch/arm64/kernel/traps.c`(`die()`/`__die()`/`dump_kernel_instr`)、`arch/arm64/mm/fault.c`(`do_mem_abort`/`__do_kernel_fault`/`die_kernel_fault`)、`arch/arm64/kernel/stacktrace.c`(fp 链解栈)、`arch/arm64/kernel/process.c`(`__show_regs`/`print_pstate`)、`lib/dump_stack.c`。 +- ARM ESR 解码(正文反复说"err 是 ESR 值要查架构手册"的入口):内核源码侧速查 `arch/arm64/include/asm/esr.h`(`ESR_ELx_*` 位定义与 `esr_to_fault_info`);权威定义见 ARM Architecture Reference Manual 的 ESR_ELx 描述。 +- kernel.org 文档:[kernel-parameters.txt](https://docs.kernel.org/admin-guide/kernel-parameters.html)(查 `panic_on_oops`、`nokaslr`、`ignore_loglevel`)、[ramoops / pstore](https://docs.kernel.org/admin-guide/ramoops.html)、[netconsole](https://docs.kernel.org/networking/netconsole.html)。 +- 内核自带脚本:`scripts/decodecode`(反汇编 `Code:` 字节)、`scripts/faddr2line`(KASLR 友好的符号+偏移定位)、`scripts/checkstack.pl`(静态栈深度检查)。 \ No newline at end of file diff --git a/document/tutorials/debugging/06-debug-ftrace.md b/document/tutorials/debugging/06-debug-ftrace.md new file mode 100644 index 00000000..7165f966 --- /dev/null +++ b/document/tutorials/debugging/06-debug-ftrace.md @@ -0,0 +1,163 @@ +--- +title: ftrace:内核的瑞士军刀追踪器 +slug: debug-ftrace +difficulty: intermediate +tags: [ftrace, 动态追踪, ring buffer, tracepoint] +architectures: [arm64, x86_64, riscv] +kernel_version: "6.19" +maturity: drafting +prerequisites: + - /tutorials/foundations/07-kernel-module-hello +related: + - /tutorials/debugging/02-debug-kprobes +sources: + - notes: document/notes/linux_kernel_debugging/ch09.md +--- + +# ftrace:内核的瑞士军刀追踪器 + +> 🔨 **整理中** · 本篇机制对照 Linux 6.19 源码讲解(函数/数据结构已核对);具体行号与命令输出待 QEMU 亲测核对。 + +## 为什么需要 ftrace:静态插桩扛不住 + +写内核模块的人第一反应都是 `printk`。它能干活,但干不了精细活——你把日志埋进代码、重新编译、重启内核,结果发现"现在想看的是另一个函数",又得重来一轮。更要命的是 `printk` 走控制台,慢,慢到会**扰动你正在观测的时序**,海森堡 Bug 就这么来了:你看它,它就变样。 + +我们真正想要的是一台**不重新编译、想看哪看哪、几乎零开销**的内核内窥镜。ftrace 就是这台内窥镜。名字里的 `f` 当年代表 `function`(它最早就是为追踪函数调用图生的),今天它已经长成了一个通用追踪引擎,既能看函数调用,也能看内核预埋的事件,还能测延迟、抓栈深、给 panic 留遗嘱。 + +## 两大支柱:function tracer + trace events + +先把骨架立起来。ftrace 有两根承重柱,看到的内核是两个不同的切面: + +- **function tracer**:盯着**函数**。普通 `function` 档位只记**入口**——每个内核函数被调用的瞬间记一行,带上"是谁调的我"(`parent_ip`),平铺成一张列表。视角像"路边盯梢,谁路过记一笔,但只记进门、不记出门,也不画谁叫谁的关系"。想要看出口、画缩进调用图,那是 `function_graph` 档位独门的活,下面单独说。 +- **trace events**:盯着**事件**。内核开发者已经在关键节点埋了 tracepoint(比如调度切换、中断进出、系统调用),ftrace 把这些点暴露成事件供你开关。视角像"高速公路上的感应线圈,车一过就抓拍,还带参数值"。 + +两根柱子共享同一套基础设施:`tracefs`(挂在 `/sys/kernel/tracing`,4.1 起的标准路径,独立于 `debugfs`,生产环境禁了 debugfs 也能用)和它自己的 ring buffer。 + +> 顺带提一句 `function_graph`:它比普通 `function` 多挂了一对 **entry + return** 钩子(`register_ftrace_graph()`,`kernel/trace/trace_functions_graph.c`),所以能在函数进入时缩进、退出时反缩进,画出真正的调用关系树。普通 `function`(`kernel/trace/trace_functions.c` 的 `function_trace`,`.name = "function"`)只有一个入口回调 `function_trace_call()`,不挂 return,**看不到出口、没有缩进**。把这两者搞混是最常见的入门坑。 + +## function tracer 原理:编译器埋点 + 运行时打补丁 + +这是 ftrace 最骚的设计,值得展开讲。 + +编译内核时,编译器(gcc/clang 的 `-pg`,或 `-mfentry`)会给**每个**函数入口插一条调用指令——历史上叫 `mcount`,现代叫 `fentry`。如果就这么留着,每次函数调用都跳进追踪代码,内核直接慢成蜗牛。所以内核干了一件相当疯狂的事:**运行时改写自己的机器码**,这就是 `CONFIG_DYNAMIC_FTRACE`(动态 ftrace)。 + +启动早期,`ftrace_init()`(`kernel/trace/ftrace.c`,Linux 6.19)拿到链接器收集好的所有插桩地址表 `__start_mcount_loc[]` ~ `__stop_mcount_loc[]`,交给 `ftrace_process_locs()` 给每个地址建一条记录 `struct dyn_ftrace { unsigned long ip; unsigned long flags; struct dyn_arch_ftrace arch; }`(`include/linux/ftrace.h:757`,`ip` 就是那个 mcount 调用点的地址)。然后关键的一步:把这些调用点**全部改成 `nop`**。从此不追踪时,每个函数入口多一条空指令,开销接近零。 + +当你 `echo function > current_tracer`,内核要反向把"被选中"的函数入口从 `nop` 改回"跳到 tracer"。这条流水线在 `ftrace_modify_all_code()` → `ftrace_replace_code()`(`kernel/trace/ftrace.c`)里,对每条 `dyn_ftrace` 记录调 `__ftrace_replace_code()`(`ftrace.c:2719`): + +```c +case FTRACE_UPDATE_MAKE_CALL: + ftrace_bug_type = FTRACE_BUG_CALL; + return ftrace_make_call(rec, ftrace_addr); +case FTRACE_UPDATE_MAKE_NOP: + ftrace_bug_type = FTRACE_BUG_NOP; + return ftrace_make_nop(NULL, rec, ftrace_old_addr); +``` + +`ftrace_make_call/make_nop` 是**架构相关**的(x86 在 `arch/x86/kernel/ftrace.c`,arm64 在 `arch/arm64/kernel/ftrace.c`),干的就是往 `rec->ip` 那几个字节写指令。判断"这个函数要不要改"靠的是过滤哈希:`struct ftrace_ops` 里挂着 `struct ftrace_ops_hash { filter_hash; notrace_hash; }`(`include/linux/ftrace.h`),`set_ftrace_filter` 写进去的函数名最终落到 `filter_hash` 里,`__ftrace_hash_rec_update()` 据此更新每条记录的引用计数。 + +> 选 tracer 的入口是 `tracing_set_tracer()`(`kernel/trace/trace.c:215`),写 `current_tracer` 时触发。所以 `echo function_graph > current_tracer` 不是"换了个开关",而是触发了全系统范围的机器码改写——这就是为什么它"听话且急躁":一写就开始。 + +## trace events:tracepoint 是地基 + +function tracer 看的是"函数被调了",但你不知道**传入的参数是多少**。要看参数,得用事件。 + +事件的地基是 **tracepoint**——开发者在内核源码里用 `TRACE_EVENT()` 宏静态埋下的点(比如 `include/trace/events/sched.h` 里的 `sched_switch`)。tracepoint 本身只是基础设施,默认几乎零开销(基于 jump_label)。ftrace 把它"激活"成一个可记录的事件:你在 `/sys/kernel/tracing/events/` 下能看到所有事件,按子系统分目录(`sched/`、`net/`、`irq/`……)。 + +这里要分清两根柱子各自的"开关"和"过滤",别把它们混成一回事: + +- **`set_ftrace_filter` 是 function tracer 的过滤**:配合 `current_tracer=function` / `function_graph` 用,控制"盯哪些函数"。可以 Glob 匹配(`echo 'tcp_*' > set_ftrace_filter`),也可以写索引号(`grep -n tcp available_filter_functions`)省掉字符串匹配开销。它**不会开启事件**——它只在 function tracer 已经生效时,缩小被记录的函数范围。 +- **`set_event` 是 tracepoint 事件的开关**:控制"记录哪些事件"。`echo 'net:* sock:* syscalls:*' > set_event` 一次开一整个子系统的事件。这跟 `set_ftrace_filter` 服务的是完全不同的两根柱子(函数 vs 事件),别把两者并称为"开启事件的两种方式"。 + +事件视角的代价和收益在 ch09 笔记里讲得很直白:**失去** function_graph 那种缩进调用图(调用栈被拍扁),**得到**每一行的参数值(buffer 指针、标志位、PID)。调试"为什么参数错了导致丢包"时,参数值比调用图更致命。 + +> 补一句呼应 `02-debug-kprobes`:kprobe/uprobe 动态打出来的事件也能挂进同一个 `events/` 框架(`events/kprobes/`、`events/uprobes/`),和静态 tracepoint 事件走同一套 ring buffer 和 trigger 机制。所以 kprobes 不只是断点调试工具,它还是 ftrace 事件的数据源之一。 + +## tracer 家族:不止 function + +`available_tracers` 文件列出当前内核能用的档位。几个代表的源码落点(Linux 6.19,`.name` 字段已核对): + +- `function` / `function_graph`:`kernel/trace/trace_functions.c`(`function_trace`,`.name = "function"`)、`kernel/trace/trace_functions_graph.c`(`function_graph`,`.name = "function_graph"`)。 +- `wakeup` / `wakeup_rt` / `wakeup_dl`:`kernel/trace/trace_sched_wakeup.c`,测"从被唤醒到真正跑起来"的调度延迟。 +- `irqsoff` / `preemptoff` / `preemptirqsoff`:`kernel/trace/trace_irqsoff.c`,抓"谁把中断/抢占关太久",对实时性敏感的嵌入式驱动是抓鬼利器。 +- `hwlat`、`blk`、`mmiotrace`、`nop`:硬件延迟、块设备、内存映射 IO、空操作(默认)。 + +切换 tracer 和开头讲的 patching 是一套机制:选了 `irqsoff`,内核就只关心"中断开关"那几个状态位的变化,记录每段中断关闭的时长,`max_latency` 字段(`struct trace_array`,`kernel/trace/trace.h`)记下历史最坏值。 + +## ring buffer:每 CPU 一个的环形账本 + +function tracer 和 trace events 记下来的东西,都写进 ftrace 自己的 ring buffer。**注意它和 printk 的 ringbuffer 不是同一个东西**——printk 那套是给控制台/log 用的,ftrace 这套是为高速并发写优化的。 + +核心数据结构是 `struct trace_array`(`kernel/trace/trace.h:331`),里面嵌着一个 `struct array_buffer array_buffer` 字段(`trace.h:334`);`struct array_buffer`(`trace.h:217`)内部用一个 `struct trace_buffer *buffer` 指针(`trace.h:219`)指向底层每 CPU 一个的 ring buffer 子 buffer。这样不同核写自己那块,不用全局锁,只在读 `trace` 文件时才合并。(另一个字段 `max_buffer`,`trace.h:347`,是给快照 trigger 用的,下面会讲。) + +写入的快路径是 `__trace_buffer_lock_reserve()`(`kernel/trace/trace.c:1072`):先 `ring_buffer_lock_reserve()` 预留一段,填事件头,再 `__buffer_unlock_commit()`(`trace.c:1115`)提交。读完 `trace` 文件里的内容**不会清空** buffer(那是快照),要清空 `echo > trace`;想边跑边看用 `trace_pipe`(流式,读走即清)。 + +## trigger:事件之间互相串通 + +这是 ftrace 最巧妙的组合技:**一个点发生时,去开/关另一个东西**。但要分清两套 trigger,别张冠李戴: + +**第一套是 function trigger**(写到 `set_ftrace_filter`)。比如你只想看某个 bug 出现前那一段,可以让 ftrace 平时开着跑,一碰到某个函数就自动 `traceoff` 刹车。语法是 filter command: + +```bash +echo '::' > set_ftrace_filter +# 例:碰到 my_buggy_func 就关跟踪 +echo 'my_buggy_func:traceoff' >> set_ftrace_filter +``` + +底层实现走的是 **`struct ftrace_func_command` + `struct ftrace_probe_ops` + `register_ftrace_command()`**:`traceon/traceoff/stacktrace/dump/cpudump` 这几个命令实现在 `kernel/trace/trace_functions.c`(`ftrace_traceon_cmd`/`ftrace_traceoff_cmd`/`ftrace_stacktrace_cmd` 等,`ftrace_traceon`/`ftrace_traceoff`/`ftrace_stacktrace` 是对应的 probe 回调),而 `mod`(只跟某模块)命令实现在 `kernel/trace/ftrace.c`(`ftrace_mod_cmd`,`ftrace.c:5221`)。所以你到 `trace_events_trigger.c` 里去找 `traceoff` 会扑空——它根本不在那。 + +**第二套是 event trigger**(写到 `events///trigger`)。这是挂在**具体事件**上的 trigger,命令是 `enable_event`/`disable_event`/`snapshot`/`stacktrace` 等,作用对象是事件而非函数。这套的底层实现才是 **`struct event_trigger_data` + `event_triggers_call()`**(`kernel/trace/trace_events_trigger.c:124`)——事件触发时,内核沿着挂在它身上的 trigger 链逐个调用 ops。 + +> **注意坑**:function trigger 的 filter command 只控制运行时开关(traceon/traceoff 之类),**不改变"哪些函数被跟踪"这个集合**——过滤函数名还是 `set_ftrace_filter` 本职的活。而"碰到某事件就 `snapshot`(把 buffer 快照存到 `max_buffer`)"这种,得用 event trigger 写到 `events/.../trigger`,而不是写到 `set_ftrace_filter`。 + +## 前端工具:trace-cmd / KernelShark / perf-tools + +裸 ftrace 配置起来繁琐得让人想骂街(追踪一个 ping 要写同步握手脚本抢 PID)。所以原作者 Steven Rostedt 写了 `trace-cmd`,设计风格像 git——一堆子命令: + +```bash +sudo trace-cmd reset # 清场 +sudo trace-cmd record -p function_graph -F sleep 1 # 录制 +sudo trace-cmd report -l > sleep1.txt # 出报告 +sudo trace-cmd record -e net -e sock -F ping -c1 host # 只录网络事件 +``` + +它底层还是操作 ftrace,但把"同步、过滤、抓取、格式化"全包了,产物是二进制 `trace.dat`。`KernelShark` 是 `trace.dat` 的 GUI 前端,上下双栏(图形 + 列表)、双 Marker 量时差、按 CPU/Task/Event 过滤,看唤醒延迟的"空心绿条"一目了然。Brendan Gregg 的 `perf-tools` 则是一堆 bash 脚本,`opensnoop`、`funcslower`、`execsnoop` 之类,本质就是 raw ftrace 的封装(其源码值得读,`funcgraph` 就是一段包装 `function_graph` 的 shell)。 + +## 动手试试(待亲测) + +> ⚠️ 以下命令均在 QEMU ARM64/x86_64 上**待亲测核对**输出,先给方案。 + +**实验一:function_graph 看一秒内核** + +```bash +cd /sys/kernel/tracing +echo 0 > tracing_on +echo function_graph > current_tracer +echo 1 > tracing_on ; sleep 1 ; echo 0 > tracing_on +cp trace /tmp/trc.txt # trace 文件大小故意显示为 0,是伪文件 +wc -l /tmp/trc.txt # 待亲测:预计几万行 +``` + +function_graph 的输出每行前头有一串"上下文密码"(像 `d.h2`),用来标记这一笔发生在什么上下文:大致是 `.`=进程上下文、`h`=硬中断、`s`=软中断之类,`d` 之类的字母表示调度相关标志。具体每个位置的字母含义,以及要开启哪个 option 才能让这列完整出现,**待 QEMU 亲测核对**(笔记 ch09_4 提到需要额外开某个 latency 相关 option 才出现完整上下文列,开关名待亲测确认,不在这里凭记忆写死)。 + +**实验二:trace-cmd 录一次系统调用** + +```bash +sudo trace-cmd record -e syscalls -F ls +sudo trace-cmd report -l > syscall.txt +# 待亲测:确认能看到 openat/read/write 等事件的参数值 +``` + +实验二是**事件视角**(不是 function_graph),所以验证清单就聚焦 syscalls 事件参数值本身:能不能看到 `openat` 的 filename、`read` 的 fd 和字节数、`write` 的内容指针。别在这一节里去找 `d.h2` 那一列——那是 function_graph 才有的东西。 + +验证清单(跨两个实验):tracefs 是否挂载(`mount | grep tracefs`)、`CONFIG_FTRACE=y`(`zcat /proc/config.gz | grep FTRACE`,具体配置项待亲测核对)、实验一能读出 function_graph 的上下文密码列、实验二能看到 syscall 事件的参数值。 + +## 小结 + +ftrace 的精髓是**动态**:编译器埋点 + 运行时打补丁,让"想看哪看哪、不看零开销"成为可能。两根柱子要分清:function tracer 看**函数入口**(平铺列表,普通 `function` 不画调用图、不看出口;`function_graph` 才缩进出调用树),trace events 看**事件参数**(靠 tracepoint + `set_event`)。各自的开关也别混——`set_ftrace_filter` 是函数过滤,`set_event` 是事件开关。数据都进每 CPU 独立的 ring buffer(`trace_array.array_buffer.buffer`),trigger 分两套(`set_ftrace_filter` 里的 function trigger 走 `ftrace_func_command`/`ftrace_probe_ops`,`events/.../trigger` 里的 event trigger 走 `event_trigger_data`/`event_triggers_call`),trace-cmd/KernelShark 把繁琐手工活自动化。记住:`current_tracer` 一改就是全系统机器码改写,所以它"听话且急躁"。 + +## 延伸阅读 + +- 源码(Linux 6.19):`kernel/trace/ftrace.c`(动态 patching、`ftrace_init`、`ftrace_replace_code`、`__ftrace_replace_code`、`ftrace_mod_cmd`);`kernel/trace/trace.c`(ring buffer、`tracing_set_tracer`、`__trace_buffer_lock_reserve`);`include/linux/ftrace.h`(`ftrace_ops`、`dyn_ftrace`、`ftrace_make_call/make_nop` 签名);`kernel/trace/trace.h`(`trace_array`、`array_buffer`、`max_buffer`);`kernel/trace/trace_functions.c`(`function` tracer + `traceon/traceoff/stacktrace` 等 function trigger);`kernel/trace/trace_events_trigger.c`(`event_trigger_data` + `event_triggers_call`,event trigger);`kernel/trace/trace_functions_graph.c`、`trace_irqsoff.c`、`trace_sched_wakeup.c`(各 tracer)。 +- docs.kernel.org:[ftrace — Function Tracer](https://docs.kernel.org/trace/ftrace.html)、[Linux Tracing Technologies](https://docs.kernel.org/trace/index.html)、[Tracepoints](https://docs.kernel.org/trace/tracepoints.html)。 +- trace-cmd / KernelShark:。 \ No newline at end of file diff --git a/document/tutorials/debugging/07-debug-kcsan.md b/document/tutorials/debugging/07-debug-kcsan.md new file mode 100644 index 00000000..d27e273a --- /dev/null +++ b/document/tutorials/debugging/07-debug-kcsan.md @@ -0,0 +1,189 @@ +--- +title: KCSAN:抓并发里的数据竞争 +slug: debug-kcsan +difficulty: intermediate +tags: [并发调试, 数据竞争, KCSAN, 编译器插桩] +architectures: [arm64, x86_64, riscv] +kernel_version: "6.19" +maturity: drafting +prerequisites: + - /tutorials/foundations/07-kernel-module-hello +related: + - /tutorials/debugging/03-debug-kasan +sources: + - notes: document/notes/linux_kernel_debugging/ch08.md + - notes: document/notes/linux_kernel_debugging/ch08_3.md +--- + +# KCSAN:抓并发里的数据竞争 + +> 🔨 **整理中** · 本篇机制对照 Linux 6.19 源码讲解(函数名 / 数据结构 / Kconfig 默认值均已核对);具体行号、menuconfig 截图与 `dmesg` 输出待 QEMU 亲测核对。 + +## 并发 bug 的痛:会隐身的"海森堡 bug" + +先说最折磨人的一类 bug:**数据竞争**。两个执行流(两个线程、或者线程和中断)不加任何同步,就往同一块共享可写内存上招呼——一个在写前 32 位,另一个冲进来读,读到的是"既不是旧值也不是新值"的撕裂脏数据(torn read)。按 LKMM(Linux Kernel Memory Model)的严格定义,只要满足"同地址 + 并发 + 至少一个写 + 至少一个是普通 C 访问",这就是数据竞争。 + +这类 bug 的恶心之处在于它**会隐身**:你盯着代码看一切正常,加一行 `printk` 想抓它,结果因为输出带来的时序变化,bug 消失了;上 GDB 单步,时序一变又复现不了。这就是所谓的海森堡 bug(Heisenbug)——观察行为本身改变了行为。进了生产环境,复现一次得烧香,定位一次得掉头发。 + +人类大脑天生不擅长模拟多核并发时序,所以我们需要一个工具,在内核**运行时**替我们盯着这些稍纵即逝的竞争。这就是 KCSAN(Kernel Concurrency Sanitizer),2020 年 8 月随 5.8 进主线,专治数据竞争。 + +## KCSAN 的思路:编译器插桩 + 软观察点 + +KCSAN 的总策略一句话能讲清:**靠编译器插桩,给每次普通内存访问套上一个"软观察点",再用一点人为延时把竞争窗口撑大,撞上了就报告。** + +插桩是怎么进来的?打开 `CONFIG_KCSAN` 后,编译器对被插桩的编译单元开启 `-fsanitize=thread`,于是它把每条普通的 load/store 都改写成对 `__tsan_*` 运行时函数的调用。KCSAN 的 `kernel/kcsan/core.c`(Linux 6.19)里就用一个宏 `DEFINE_TSAN_READ_WRITE(size)` 批量定义了这些桩函数,比如 1/2/4/8/16 字节的版本,它们全都最终落到同一个核心入口 `check_access()`: + +```c +// kernel/kcsan/core.c(Linux 6.19) +void __tsan_write##size(void *ptr) { + check_access(ptr, size, KCSAN_ACCESS_WRITE, _RET_IP_); +} +``` + +`__tsan_*` 这套名字不是 KCSAN 凭空造的——它复用了编译器为 ThreadSanitizer(TSAN)已经会生成的桩,所以不用自己写 pass,编译器帮你把活干了。 + +**软观察点**才是 KCSAN 的灵魂。硬件断点资源稀缺,不可能给每个地址都上硬观察点,KCSAN 的做法是开一个全局数组当"软观察点池": + +```c +// kernel/kcsan/core.c(Linux 6.19) +static atomic_long_t watchpoints[CONFIG_KCSAN_NUM_WATCHPOINTS + NUM_SLOTS-1]; +``` + +每个槽位是一个 `atomic_long_t`,里面把"被监视的地址 + 访问大小 + 是不是写"用 `encode_watchpoint()` 打包编码进一个长整数(编码细节在 `kernel/kcsan/encoding.h`)。用 `atomic_long` 而不是带锁的结构体,是为了让快速路径(每次内存访问都要走)零锁开销。`CONFIG_KCSAN_NUM_WATCHPOINTS` 默认 64(见 `lib/Kconfig.kcsan`)。 + +整套设点—拖延—收网逻辑集中在 `kcsan_setup_watchpoint()`,挨着读一遍就能看明白它干的三件事: + +1. **设点**:`insert_watchpoint()` 用 `atomic_long_try_cmpxchg_relaxed` 抢一个空槽,把编码后的观察点塞进去(抢失败就放弃,记一个 `KCSAN_COUNTER_NO_CAPACITY`)。 +2. **拖延**:`delay_access()` 里 `udelay(delay)`,任务上下文默认 `kcsan_udelay_task = 80` 微秒,中断上下文 `kcsan_udelay_interrupt = 20` 微秒(见 `core.c` 顶上的全局变量和 Kconfig 默认值)。这一下故意停顿,就是为了把竞争窗口撑大,让另一个执行流有机会在这 80µs 里撞进来。 +3. **收网**:拖延期间如果有人来碰这个地址,那次的访问会在快速路径的 `find_watchpoint()` 里命中并走 `kcsan_found_watchpoint()`,把自己的调用栈塞进 `other_info`;设点线程拖完之后 `read_instrumented_memory()` 重读一次,靠 `old ^ new` 判断值有没有被改(值变化检测,只对 ≤8 字节的访问做)。撞上、值又变了,就调 `kcsan_report_known_origin()` 出报告。 + +快速路径的入口是 `check_access()`——它先 `find_watchpoint()` 看当前访问有没有命中别人设的点;没命中再 `should_watch()` 决定自己要不要设点。`should_watch()` 里有两道关卡,正是 KCSAN 抽样 + 免检的设计核心,下一节细说。 + +## 抽样与免检:`should_watch()` 的两道关卡 + +KCSAN 不能每次访问都设点,那系统直接卡死。它的节流靠两个机制,都在 `should_watch()`(`core.c`,Linux 6.19)里: + +**第一关:原子访问直接免检。** `should_watch()` 第一行就是 `if (is_atomic(ctx, ptr, size, type)) return false;`。`is_atomic()` 判断这个访问是不是原子的——是,就绝不给它设点。这覆盖三种情况:访问带了 `KCSAN_ACCESS_ATOMIC` 标记(也就是 `READ_ONCE`/`WRITE_ONCE`/`atomic_*` 这些"标记访问");满足"对齐且不超过字长的普通写被假设为原子"(这条是 `CONFIG_KCSAN_ASSUME_PLAIN_WRITES_ATOMIC` 的魔法,后面踩坑专门讲);或者当前处于原子区(`atomic_nest_count`/`in_flat_atomic`/`atomic_next`,靠 `kcsan_nestable_atomic_begin()` 等设置,存在 `struct kcsan_ctx` 里,见 `include/linux/kcsan.h`)。 + +**第二关:抽样计数。** 免检过了,再看每 CPU 的跳过计数器 `kcsan_skip`:`if (this_cpu_dec_return(kcsan_skip) >= 0) return false;`。每访问一次就减一,减到负数才肯设点,然后 `reset_kcsan_skip()` 重新装填成 `kcsan_skip_watch`(默认 4000,还带 `KCSAN_SKIP_WATCH_RANDOMIZE` 随机化抖动)。也就是说每 4000 次内存访问才抽检一次——这就是 KCSAN 抓 bug 带点运气、要靠密集循环喂它的根因。 + +## 与 KASAN 的分工 + +很多人会把 KASAN 和 KCSAN 搞混,它俩名字像、都是 sanitizer、都用编译器插桩,但查的东西完全不一样: + +- **KASAN**(Kernel Address Sanitizer)查的是**内存正确性**——越界访问(out-of-bounds)、释放后使用(UAF)、双重释放。它靠给每块内存配"影子内存"(shadow memory)记状态,访问时查影子。 +- **KCSAN** 查的是**并发正确性**——同一块内存在多个执行流之间有没有没加保护的竞争访问。 + +它俩水火不容,`lib/Kconfig.kcsan` 里写死了互斥(都做大量插桩,叠一起会炸),menuconfig 里只能二选一。用 Clang 编译时 KCSAN 还和 KCOV(覆盖率工具)冲突。所以选哪个看你抓什么 bug:怀疑内存越界/UAF 开 KASAN,怀疑时序竞争开 KCSAN。 + +## KCSAN 的独门绝技:Advisory Lock 检测 + +这是 KCSAN 比 Lockdep 强的地方,也是它真正"读并发语义"的部分。 + +Lockdep(锁依赖检测器)盯的是**加锁顺序**——它看你拿锁、放锁的序列,抓死锁、抓锁依赖成环。但它有个盲区:**你拿了一把锁,却忘了用它去保护某个共享变量**。这种"持了锁、却没锁该锁的变量"的访问,Lockdep 一无所知,因为它根本不关心你拿锁之后访问了哪些内存。 + +KCSAN 通过一组 `ASSERT_EXCLUSIVE*()` 宏(定义在 `include/linux/kcsan-checks.h`,Linux 6.19)来补这个洞。比如 `ASSERT_EXCLUSIVE_WRITER(var)`: + +```c +// include/linux/kcsan-checks.h(Linux 6.19) +#define ASSERT_EXCLUSIVE_WRITER(var) \ + __kcsan_check_access(&(var), sizeof(var), KCSAN_ACCESS_ASSERT) +``` + +它发起一次带 `KCSAN_ACCESS_ASSERT` 标记的访问。在 `core.c` 里,`is_atomic()` 对 assert 访问特意返回 false(注释写得很直白:"never consider an assertion access as atomic"),于是 KCSAN 会在它身上设点;而报告侧 `report.c` 的 `get_bug_type()` 看到 assert 标记就把 bug 类型从 `data-race` 换成 `assert: race`。意思就是:**我断言这个变量在这段范围内只能被当前线程写,谁敢并发写就是 bug**——哪怕那个并发写本身是"标记访问"(按严格 LKMM 不算 data-race),KCSAN 照样抓。这正是 Lockdep 够不着的角落。 + +还有 `ASSERT_EXCLUSIVE_BITS(var, mask)` 这种位粒度断言,用 `kcsan_set_access_mask(mask)` 设掩码,只检查指定那几位有没有被并发改——适合"某些位只读、某些位可并发改"的 flags 变量。 + +## CONFIG_KCSAN:编译开销与 runtime 控制 + +启用 KCSAN 有不少硬性前置(`lib/Kconfig.kcsan`):架构要支持(x86_64 自 5.8、arm64 自 5.17),编译器要 GCC/Clang ≥11(由 `CONFIG_HAVE_KCSAN_COMPILER` 检查),要开 `CONFIG_DEBUG_KERNEL`,且和 KASAN/KCOV 互斥。menuconfig 路径是 `Kernel hacking → Generic Kernel Debugging Instruments → KCSAN: dynamic data race detector`。勾上 `CONFIG_KCSAN` 会自动 `select CONFIG_STACKTRACE`(报告要打调用栈)。 + +编译开销很重——全内核插桩 + 每访问都过一遍快速路径,所以 KCSAN 内核只适合调试环境跑,不能上生产。几个关键 Kconfig 旋钮(默认值已在 `lib/Kconfig.kcsan` 核对): + +| 配置项 | 默认 | 作用 | +|:---|:---:|:---| +| `KCSAN_ASSUME_PLAIN_WRITES_ATOMIC` | y | 假设对齐、≤字长的普通写是原子的(初学者最大困惑源) | +| `KCSAN_REPORT_VALUE_CHANGE_ONLY` | y | 只有竞争真的改了值才报,过滤无害竞争 | +| `KCSAN_SKIP_WATCH` | 4000 | 每 4000 次访问抽检一次(越小越准越卡) | +| `KCSAN_NUM_WATCHPOINTS` | 64 | 软观察点池大小 | +| `KCSAN_UDELAY_TASK` / `UDELAY_INTERRUPT` | 80 / 20 | 设点后拖延的微秒数 | +| `KCSAN_REPORT_ONCE_IN_MS` | 3000 | 3 秒内同一场竞争只报一次 | + +运行时还能通过 debugfs 现场开关(需 root):`/sys/kernel/debug/kcsan`。`echo on/off > /sys/kernel/debug/kcsan` 切开关,`cat` 看统计(查了多少、抓了多少 race)。 + +## 报告解读:两个调用栈 + 读写类型 + +报告生成逻辑在 `kernel/kcsan/report.c`(Linux 6.19)。设点线程抓到竞争后调 `kcsan_report_known_origin()`,另一个线程的栈通过 `other_info` 结构体跨线程传过来,最后在 `print_report()` 里拼成一份完整报告。`struct other_info`(`report.c`)里装着对方的 `struct access_info`(含 ptr/size/access_type/task_pid/cpu_id)和一组 `stack_entries[]`,靠 `raw_spin_lock` 保护的 `report_lock` 串行化,避免两线程同时往 `printk` 写乱。 + +报告长这样(结合 `print_report()` 的 `pr_err` 拼出来的格式,输出待亲测核对): + +``` +================================================================== +BUG: KCSAN: data-race in do_the_work1 / do_the_work2 + +write to 0xffff...3238 of 8 bytes by task ... on cpu 0: + do_the_work1+0x... + process_one_work+0x... + ... + +write to 0xffff...3238 of 8 bytes by task ... on cpu 1: + do_the_work2+0x... + process_one_work+0x... + ... + +value changed: 0x0000000000007d00 -> 0x00000000000017d0 + +Reported by Kernel Concurrency Sanitizer on: +... +================================================================== +``` + +读报告分三块:第一行 `BUG: KCSAN: data-race in A / B` 直接点名两个打架的函数(`report.c` 的 `sym_strcmp` 把两函数按字典序排,保证 bug 标题稳定);接着是每个访问一行的"访问信息"(读/写、是否 marked、内核虚地址、谁在哪个 CPU 干的,由 `get_access_type()` 拼字符串,比如 `write`、`read (marked)`、`assert no writes`),紧跟各自的调用栈;最后若有值变化会打印 `value changed: 旧值 -> 新值`(`old ^ new` 的 diff)。顺着调用栈往上爬就能定位到具体代码行。 + +## 一个必踩的坑:两个普通写却不报警 + +笔记里记的真实现象:写个模块,两个工作队列线程疯狂地不加锁写同一个全局变量,满心欢喜等 KCSAN 报错,结果**一声不吭**。 + +根因就是 `CONFIG_KCSAN_ASSUME_PLAIN_WRITES_ATOMIC` 默认 y。在 `is_atomic()` 里这段: + +```c +// kernel/kcsan/core.c(Linux 6.19) +if (IS_ENABLED(CONFIG_KCSAN_ASSUME_PLAIN_WRITES_ATOMIC) && + (type & KCSAN_ACCESS_WRITE) && size <= sizeof(long) && + !(type & KCSAN_ACCESS_COMPOUND) && IS_ALIGNED((unsigned long)ptr, size)) + return true; /* Assume aligned writes up to word size are atomic. */ +``` + +对齐、不超过字长、非复合的普通写,被当成原子的——既然原子,`should_watch()` 第一关就 return false,连点都不设。再加 `KCSAN_REPORT_VALUE_CHANGE_ONLY` 默认 y,竞争若没明显改值也不报。所以两个普通写互撞,默认配置下 KCSAN 睁一只眼闭一只眼。 + +想让它报,得开 `CONFIG_KCSAN_STRICT=y`(`kcsan_init()` 里会打印 `strict mode configured`),或手动关掉 `KCSAN_ASSUME_PLAIN_WRITES_ATOMIC`——告诉 KCSAN 别做任何假设,凡是未标记的并发写撞上就是 bug。重新编译内核,再插模块,报告立刻炸出来。 + +> 还有一条正告:**看到报告别急着甩个 `READ_ONCE`/`WRITE_ONCE` 消警告**。那只是让 KCSAN 闭嘴,数据不一致的风险还在。正确姿势是加锁、用原子操作、或上无锁技术。只有确定是良性竞争(统计计数器那种读 100 还是 101 无所谓),才用 `data_race()` 宏明确告诉工具"我知道,别管"。 + +## 动手待亲测 + +> ⚠️ **待亲测**:以下方案还没在 QEMU 上跑过,只列步骤,实测后补真实输出。 + +验证目标:在 QEMU(x86_64 或 arm64)里复现一次 KCSAN 报告。方案: + +1. 编译内核:menuconfig 开 `CONFIG_KCSAN=y`(注意关掉 KASAN),建议再开 `CONFIG_KCSAN_STRICT=y`,重编内核、做 rootfs、启动 QEMU。 +2. 写一个内核模块(放 `example/mini/` 下,遵循 `Makefile.arch`):起两个 `kthread`,循环里不加锁、直接 `ptr->data = ...` 写同一个全局 `u64`,跑几万次。 +3. `insmod` 后 `dmesg | grep KCSAN`,对照上面的报告格式读两个调用栈。 +4. 对照实验:把写改成 `WRITE_ONCE`,重跑,看报告是否消失(体会 `KCSAN_ACCESS_ATOMIC` 的免检);再试一次关掉 `KCSAN_ASSUME_PLAIN_WRITES_ATOMIC`、用普通写,看报告是否复现(体会那个坑)。 +5. 进阶:在写之前加一行 `ASSERT_EXCLUSIVE_WRITER(ptr->data)`,再让另一处并发写,观察 bug 类型从 `data-race` 变成 `assert: race`,亲手验证 Advisory Lock 检测。 + +## 小结 + +KCSAN 用编译器插桩(`__tsan_*` 桩)+ 软观察点(`watchpoints[]` 数组)+ 抽样(`should_watch` 的 `kcsan_skip`)+ 人为延时(`udelay`),在运行时动态抓并发数据竞争。记住它的设计取舍:默认配置(`ASSUME_PLAIN_WRITES_ATOMIC` + `REPORT_VALUE_CHANGE_ONLY`)为降误报做了妥协,想看全部竞争要开 strict 模式。它和 KASAN 分工明确(内存正确性 vs 并发正确性、二选一),又靠 `ASSERT_EXCLUSIVE*()` 弥补 Lockdep 在"持锁却没保护对应变量"上的盲区。最后一句忠告:报告是症状不是处方,别用 `READ_ONCE` 当创可贴。 + +## 延伸阅读 + +- 源码(Linux 6.19): + - `kernel/kcsan/core.c` —— KCSAN 运行时核心,`check_access` / `should_watch` / `kcsan_setup_watchpoint` / `__tsan_*` 桩都在这。 + - `kernel/kcsan/report.c` —— 报告生成,`print_report` / `struct other_info` / 限流 `rate_limit_report`。 + - `kernel/kcsan/encoding.h` —— 观察点的编码/解码、`matching_access`、slot 映射。 + - `include/linux/kcsan-checks.h` —— `ASSERT_EXCLUSIVE*` 宏、`KCSAN_ACCESS_*` 标记位、`__kcsan_check_access`。 + - `include/linux/kcsan.h` —— `struct kcsan_ctx`(`atomic_nest_count` / `in_flat_atomic` / `access_mask` 等上下文字段)。 + - `lib/Kconfig.kcsan` —— 所有 KCSAN 配置项与默认值。 +- 文档:[docs.kernel.org 内核调试工具索引](https://docs.kernel.org/dev-tools/index.html)、[KCSAN 官方说明](https://docs.kernel.org/dev-tools/kcsan.html)、[LKMM 与 access marking](https://docs.kernel.org/overview.html)(`tools/memory-model/Documentation/access-marking.txt`)。 +- 战果追踪:[Syzbot KCSAN 上游实例](https://syzkaller.appspot.com/upstream?manager=ci2-upstream-kcsan-gce)。 \ No newline at end of file diff --git a/document/tutorials/debugging/08-debug-panic.md b/document/tutorials/debugging/08-debug-panic.md new file mode 100644 index 00000000..19bbce1c --- /dev/null +++ b/document/tutorials/debugging/08-debug-panic.md @@ -0,0 +1,185 @@ +--- +title: panic、Hung Task 与死锁检测 +slug: debug-panic +difficulty: intermediate +tags: [panic, 死锁检测, hung_task, lockup] +architectures: [arm64, x86_64, riscv] +kernel_version: "6.19" +maturity: drafting +prerequisites: + - /tutorials/debugging/05-debug-oops +related: + - /tutorials/debugging/05-debug-oops +sources: + - notes: document/notes/linux_kernel_debugging/ch10.md + - notes: document/notes/linux_kernel_debugging/ch10_2.md + - notes: document/notes/linux_kernel_debugging/ch10_3.md + - notes: document/notes/linux_kernel_debugging/ch10_4.md + - notes: document/notes/linux_kernel_debugging/ch10_5.md +--- + +# panic、Hung Task 与死锁检测 + +> 🔨 **整理中** · 本篇机制对照 Linux 6.19 源码讲解(函数/数据结构已核对);具体行号与命令输出待 QEMU 亲测核对。 + +上一篇我们拆了 oops——内核崩了但还苟着喘气。这一篇讲更狠的两种死法:一种是**干脆不活了**的 panic,另一种是**没死透、但跟死了没两样**的假死(hung task、lockup)。oops 和 panic 的关系是前者可能升级成后者,而升级的开关就藏在本篇里。 + +## panic:内核的最后遗言 + +`panic()` 是内核的"放弃治疗"按钮。它的签名在 `include/linux/panic.h`(Linux 6.19)里标得清清楚楚: + +```c +void panic(const char *fmt, ...) __noreturn __cold; +``` + +注意 `__noreturn`——这个函数进去就别想出来。它收一个 printf 风格字符串,把死因吼到日志里,然后让系统停摆。我们模块里能直接调它(导出符号),但更常见的是内核自己在遇到无法恢复的错误时内部触发。 + +### `vpanic()` 的临终流程 + +`panic()` 只是个壳,真正的活都在 `vpanic()` 里(`kernel/panic.c`,Linux 6.19)。我们顺着源码走一遍它咽气前的每一步: + +1. **`local_irq_disable(); preempt_disable_notrace();`**(`kernel/panic.c`)——先把本 CPU 中断关掉、抢占禁掉。为什么?因为一旦 `panic_cpu` 被设上,后续任何中断处理函数都可能再次调 `panic()`,自己把自己卡死。 + +2. **抢"第一个到达 panic 的 CPU"名额**:`if (panic_try_start())`。`panic_try_start()` 用 `atomic_try_cmpxchg(&panic_cpu, &old_cpu, this_cpu)` 抢锁,`panic_cpu` 初值是 `PANIC_CPU_INVALID`(`-1`,定义在 `include/linux/panic.h`)。SMP 系统上多核可能同时 panic,只有抢到的那个继续走完临终流程,其他 CPU 调 `panic_smp_self_stop()` 进死循环空转。 + +3. **`pr_emerg("Kernel panic - not syncing: %s\n", buf);`**(`kernel/panic.c`)——那句经典的"not syncing"就来自这里。意思是:**内存里有一堆脏数据没刷盘,但故意不刷了**。系统状态已经乱了,强行写磁盘反而可能把文件系统搞挂,两害相权取其轻。 + +4. **`dump_stack()`**(若开了 `CONFIG_DEBUG_BUGVERBOSE`)吐调用栈,这是查死因最重要的线索。 + +5. **`kgdb_panic(buf)`**——如果使能了 kgdb,先给它一次机会:在停掉其他 CPU 之前让 gdbstub 接进来调试,否则那些 CPU 一停就抓不到现场了(`kernel/panic.c`,源码注释明说了这点)。 + +6. **`__crash_kexec(NULL)`**——若配置了 kdump 崩溃内核,**默认在 notifier 链和 kmsg_dump 之前**就先切过去让它 dump 内存,这是服务器标准方案。这里有条岔路:若开了 `crash_kexec_post_notifiers`,则改为延后到 notifier 链与 `kmsg_dump` **之后**再调一次 `__crash_kexec(NULL)`——给那些怀疑 kdump 不稳的人留个"先跑通知器、先 dump 日志、再崩"的选项(`kernel/panic.c`,两处 `__crash_kexec` 由该开关二选一)。 + +7. **`panic_other_cpus_shutdown()`**——若 `panic_print` 里设了 `SYS_INFO_ALL_BT` 位,先调 `panic_trigger_all_cpu_backtrace()`(内部再触发 `trigger_all_cpu_backtrace()`)把所有 CPU 栈拍下来,**再** `smp_send_stop()`(或崩溃路径下的 `crash_smp_send_stop()`)停掉其他 CPU。顺序很关键:CPU 一停就拍不到栈了。 + +8. **`atomic_notifier_call_chain(&panic_notifier_list, 0, buf);`**——跑 panic 通知器链,给注册的回调最后一次机会做事(见下节)。 + +9. **`sys_info(panic_print); kmsg_dump_desc(KMSG_DUMP_PANIC, buf);`**——按位掩码打印系统信息、dump 内核日志。 + +10. **结尾的死循环**:`pr_emerg("---[ end Kernel panic ...]---"); suppress_printk = 1;`,然后 `for (i = 0; ; i += PANIC_TIMER_STEP)` 永远空转,里面周期性调 `panic_blink()`——告诉你"是内核死了,不是显示器坏了"。`suppress_printk = 1` 是为了锁死屏幕画面,防止后面的日志把关键诊断滚没。 + +> 关于 `panic_blink()`:它默认是 `no_blink`(啥也不干),x86 上 i8042 键盘驱动(`drivers/input/serio/i8042.c`)在 init 时会把它换成 `i8042_panic_blink()`,靠拨键盘 LED 来"闪烁"。所以"键盘灯闪"这个体感是 x86 + i8042 才有的,别的架构不一定。 + +## panic 通知器链:挂钩到内核的死亡瞬间 + +`kernel/panic.c` 里有一行关键定义: + +```c +ATOMIC_NOTIFIER_HEAD(panic_notifier_list); +EXPORT_SYMBOL(panic_notifier_list); +``` + +这是一条**原子通知器链**——回调跑在原子上下文,**绝对不能睡眠**。Panic 时系统已经极度脆弱,中断可能关了、调度停了,你要是在回调里 `kmalloc(GFP_KERNEL)` 或拿信号量,系统会卡死在你手里,连 kdump 都生成不了。这个纪律内核源码注释里都标了:某些 panic_notifier 可能让崩溃内核更不稳定,增加 kdump 失败风险。 + +### `notifier_block`:挂钩用的"身份证" + +定义在 `include/linux/notifier.h`(Linux 6.19): + +```c +struct notifier_block { + notifier_fn_t notifier_call; + struct notifier_block __rcu *next; + int priority; // 数字越大越早被调 +}; +``` + +注册用 `atomic_notifier_chain_register(&panic_notifier_list, &my_nb)`(注意:`atomic_notifier_chain_register` 是 **GPL-only** 符号,模块 LICENSE 必须是 `GPL` 或 `Dual MIT/GPL`,否则符号未定义编不过)。回调签名是 `int (*notifier_fn_t)(struct notifier_block *nb, unsigned long action, void *data)`,返回 `NOTIFY_OK` / `NOTIFY_DONE` / `NOTIFY_STOP` / `NOTIFY_BAD`。 + +**一个活生生的例子就挂在 `kernel/hung_task.c` 里**——`hung_task_init()` 用 `subsys_initcall` 跑起来时,第一件事就是: + +```c +atomic_notifier_chain_register(&panic_notifier_list, &panic_block); +``` + +对应的 `panic_block.notifier_call = hung_task_panic`,它只做一件事:`did_panic = 1;`。这样 hung task 检测器一旦发现系统已 panic,就不再多嘴报新的卡死任务,避免在尸体上做多余的动作。这就是"挂钩做最后一点必要的事"的范本——极简、不睡眠。 + +## Hung Task:抓 D 状态睡死的任务 + +CPU 还在转、没 panic,但有个进程在 `TASK_UNINTERRUPTIBLE`(`ps` 里的 `D` 状态)赖着不醒,超过 120 秒——这就是 Hung Task。怎么抓?内核养了一条叫 `khungtaskd` 的看门狗线程。 + +### 线程主体:`watchdog()` + +`kernel/hung_task.c` 的 `hung_task_init()` 用 `kthread_run(watchdog, NULL, "khungtaskd")` 起了这个线程。线程函数 `watchdog()` 是个无限循环:算出下次该醒的时间 `t = hung_timeout_jiffies(...)`,`schedule_timeout_interruptible(t)` 睡过去,醒来后调 `check_hung_uninterruptible_tasks(timeout)` 巡视。默认间隔(`hung_task_check_interval_secs` 为 0 时)就等于超时时间 120 秒。 + +### 怎么判定一个任务"卡住":`task_is_hung()` + +核心判断在 `task_is_hung()`(`kernel/hung_task.c`,Linux 6.19): + +```c +unsigned long switch_count = t->nvcsw + t->nivcsw; // 自愿+非自愿切换次数 +unsigned int state = READ_ONCE(t->__state); + +if (!(state & TASK_UNINTERRUPTIBLE) || + (state & (TASK_WAKEKILL | TASK_NOLOAD | TASK_FROZEN))) + return false; // 只管真正的 D 状态 +... +if (switch_count != t->last_switch_count) { // 期间发生过调度→还活着 + t->last_switch_count = switch_count; + t->last_switch_time = jiffies; + return false; +} +if (time_is_after_jiffies(t->last_switch_time + timeout * HZ)) + return false; // 还没超时 +return true; +``` + +关键思路:**看上下文切换计数 `last_switch_count` 有没有变**。一个 D 状态任务如果在这段时间内一次都没被调度过(`switch_count` 没变),且超过 `timeout * HZ` 个 jiffies,就算卡死。注意它特意跳过 `TASK_KILLABLE`(带 `TASK_WAKEKILL`/`TASK_NOLOAD`)和 `TASK_FROZEN`——这些状态本来就该长时间睡,不是故障。 + +### 报告与升级:`check_hung_task()` + +判定卡死后,`check_hung_task()` 打出那条标志性的 `INFO: task %s:%d blocked for more than %ld seconds.`,调 `sched_show_task(t)` 吐栈。`sysctl_hung_task_warnings` 默认 10,报满 10 次就闭嘴(设 `-1` 可无限报,避免持续性死锁后期日志被吞)。若 `sysctl_hung_task_panic` 被设上,`check_hung_uninterruptible_tasks()` 末尾直接 `panic("hung_task: blocked tasks")`——这就是"警告升级为处决",HA 集群常用。 + +### 关键 sysctl(都在 `hung_task_sysctls` 表里注册到 `/proc/sys/kernel/`) + +- `hung_task_timeout_secs`:判定阈值,默认 120,设 0 关闭。 +- `hung_task_warnings`:最多报几次,默认 10,`-1` 无限。 +- `hung_task_panic`:是否升级成 panic,默认 0。 +- `hung_task_check_count`:一次最多扫几个任务(性能优化),初值取自 `PID_MAX_LIMIT`——64 位上约 420 万(`4 * 1024 * 1024`),`CONFIG_BASE_SMALL` 或 32 位平台上这个值会小得多(量级到几万)。 +- `hung_task_all_cpu_backtrace`:设 1 则向所有 CPU 发 NMI 拍栈,帮你找出谁占着锁不放。 + +## Lockup:CPU 还在转但逻辑卡死 + +Hung Task 是"任务睡死",lockup 是"CPU 疯跑不调度"。分两种: + +**Soft Lockup**:任务在内核态死循环,霸占 CPU 不让调度器插手,但**中断还开着**。官方文档把阈值定义为内核态连续跑超过 20 秒——正好是 `watchdog_thresh`(默认 10)的两倍。检测代码在 `kernel/watchdog.c` 的 `watchdog_timer_fn()`,靠一个 hrtimer(周期 `2*watchdog_thresh/5`,默认 4 秒)驱动计时。 + +**Hard Lockup**:任务不仅死循环,还**关了中断**(典型场景:持着 `spin_lock_irqsave()` 死循环)。此时普通时钟中断都进不来,hrtimer 那套失灵。怎么检测?靠 **NMI(不可屏蔽中断)**——NMI 的定义就是"中断关了我也照样进来",它利用硬件性能计数器周期性检查 CPU 是否还活着。这就是为什么 hard lockup 检测依赖 NMI watchdog,且**虚拟机通常没这东西**(`kernel.nmi_watchdog = 0`)。没有 NMI perf event 的平台还有个备选的 **buddy 检测器**:每个 CPU 让另一个 CPU 当"伙伴"代为盯梢,连续 3 个 hrtimer 周期没等到心跳就算死锁——代价是若所有 CPU 一起锁住它也发现不了。默认阈值 10 秒。 + +相关 sysctl:`watchdog_thresh`(阈值)、`softlockup_panic` / `hardlockup_panic`(是否升级 panic)、`softlockup_all_cpu_backtrace` / `hardlockup_all_cpu_backtrace`(全场拍栈)。 + +RCU 也有类似的 **RCU CPU Stall**——一个宽限期迟迟过不去就报警。这里有个常被笔记记错的点:单次检查周期默认 `CONFIG_RCU_CPU_STALL_TIMEOUT` 是 **21 秒**(`kernel/rcu/Kconfig.debug`,range 3..300),不是 60 秒;首个 stall 警告大约在宽限期超过 21 秒后就吐出来(`record_gp_stall_check_time()` 设的 `jiffies_stall`),而**后续**每轮重复警告的间隔才是 `3 * rcu_jiffies_till_stall_check()`(约 63 秒,`kernel/rcu/tree_stall.h`)。别把"60 秒"挂在 CONFIG 名下。 + +## 升级开关:oops/warn 什么时候变 panic + +`panic.c` 里几个全局变量就是这些开关,都通过 `kern_panic_table` 注册成 sysctl,也可走启动参数: + +- **`panic_on_oops`**(默认取 `CONFIG_PANIC_ON_OOPS`):设 1 则任何 oops 直接升级 panic。关键业务系统常用(宁可死也不许带病跑)。 +- **`panic_on_warn`**:设 1 则任何 `WARN_ON()` 都变 panic。开发期抓隐患利器,但慎用。`check_panic_on_warn()` 里还会看 `warn_limit`——累计警告次数(`warn_count`)超过 `warn_limit` 也 panic。 +- **`panic_timeout`**(`/proc/sys/kernel/panic`,启动参数 `panic=N`):panic 后 N 秒自动重启,`0` 表示永远挂起。嵌入式无人值守设备常用。 +- **`panic_print`**(位掩码):控制 panic 时额外打印什么。注意 6.19 里它已标记 **deprecated**,设它会吐一行 `pr_info_once` 提示改用 `panic_sys_info` 和 `panic_console_replay`。 + +一行魔法的 SysRq 崩溃触发(配合 `panic_on_oops`),笔记里实测过: + +```bash +echo 1 > /proc/sys/kernel/panic_on_oops +echo 1 > /proc/sys/kernel/sysrq +echo c > /proc/sysrq-trigger # c = 强制崩溃(crashdump,若配置了的话) +``` + +## 动手验证(待亲测) + +QEMU 上我们要验三件事,命令输出都待亲测核对: + +1. **制造 panic 看 `vpanic()` 流程**:写个 `letspanic` 模块,`init` 里直接 `panic("...")`。配合 `netconsole` 把日志发到宿主机(系统僵死时本地控制台刷不出来),核对 `Kernel panic - not syncing:` / `---[ end Kernel panic ]---` 两行,以及 `dump_stack` 输出。 +2. **自定义 panic handler**:写模块定义 `struct notifier_block`,`init` 里 `atomic_notifier_chain_register(&panic_notifier_list, &nb)`,回调里 `pr_emerg` 打一条标记。先 insmod handler 模块,再触发 SysRq 崩溃,在 netconsole 里核对我们的回调确实在 `panic_notifier_list` 链上被调到了。注意 LICENSE 必须 `GPL`。 +3. **自旋锁死锁看 hung task 报告**:写内核线程持 `spin_lock_irqsave()` 死循环(先留至少一个核维持响应)。把 `hung_task_timeout_secs` 调到 10(缩短等待),核对 `INFO: task ... blocked for more than ... seconds.` 和 `sched_show_task` 的栈。再试 `echo 1 > /proc/sys/kernel/hung_task_all_cpu_backtrace`,看所有 CPU 的 NMI 栈回溯。 + +## 小结 + +内核的"死法"分两类:**真死**(panic,`panic()` 走完临终流程进死循环)和**假死**(lockup / hung task,CPU 或任务卡住但系统还在)。整套检测体系的核心数据结构和函数都钉在源码里:`panic_notifier_list` 通知器链 + `notifier_block` 挂钩、`khungtaskd` 线程 + `task_is_hung()` 的 `last_switch_count` 判定、`watchdog_timer_fn()` 的 hrtimer + NMI 双保险。`panic_on_oops` / `panic_on_warn` / `*_panic` 这组开关决定"警告升不升级成处决",`*_timeout_secs` 决定"等多久才报警"。把这些机制读穿,你下次看到黑屏或假死,就知道是该翻 `khungtaskd` 的报告、还是 NMI 的栈、还是 panic 的遗言。 + +## 延伸阅读 + +- 源码:`kernel/panic.c`(Linux 6.19),`vpanic()` / `panic_notifier_list` / `kern_panic_table`;`kernel/hung_task.c`,`watchdog()` / `task_is_hung()` / `check_hung_task()`;`kernel/watchdog.c`,`watchdog_timer_fn()`(soft/hard lockup,hrtimer 周期 `2*watchdog_thresh/5`);`kernel/rcu/tree_stall.h`,`rcu_jiffies_till_stall_check()`(默认 21 秒、后续 3×≈63 秒);`include/linux/panic.h`、`include/linux/notifier.h`(`notifier_block`)。 +- kernel.org 文档:[kernel.org admin-guide sysctl/kernel](https://docs.kernel.org/admin-guide/sysctl/kernel.html)(`panic` / `hung_*` / `watchdog_*` 各项)、[Magic SysRq key](https://docs.kernel.org/admin-guide/sysrq.html)、[Softlockup / hardlockup detector (aka nmi_watchdog)](https://docs.kernel.org/admin-guide/lockup-watchdogs.html)。 +- 笔记:`document/notes/linux_kernel_debugging/ch10_*.md`(panic 流程、自定义 handler、lockup、hung task 四节)。 diff --git a/document/tutorials/debugging/09-debug-kgdb.md b/document/tutorials/debugging/09-debug-kgdb.md new file mode 100644 index 00000000..d31e018b --- /dev/null +++ b/document/tutorials/debugging/09-debug-kgdb.md @@ -0,0 +1,154 @@ +--- +title: KGDB:让 GDB 停下整个内核 +slug: debug-kgdb +difficulty: intermediate +tags: [调试, KGDB, GDB, 断点] +architectures: [arm64, x86_64, riscv] +kernel_version: "6.19" +maturity: drafting +prerequisites: + - /tutorials/foundations/06-gdb-debug-setup +related: + - /tutorials/debugging/05-debug-oops +sources: + - notes: document/notes/linux_kernel_debugging/ch11.md +--- + +# KGDB:让 GDB 停下整个内核 + +> 🔨 **整理中** · 机制对照 Linux 6.19(v6.19.9)源码核对过(函数名、数据结构、BRK 立即数、调用链都已 grep 确认);但具体行号、menuconfig 实际菜单层级、QEMU 串口分配和 GDB 命令输出还没亲手跑过。等我们在 QEMU 上验完,升级成 ✅ 已锤炼。 + +## 为什么 printk 不够用 + +我们调试用户态程序时,加个 `printf` 永远只是「事后看尸体」——你能看到它经过这一行,却没法在那一刻停下来翻口袋。`printk`、`ftrace`、`kprobes` 在内核里扮演的就是这个角色:它们是高速摄像机,能记录内核做了什么,但内核照常全速往前跑。等你看完日志想问「那个 `task_struct` 里 `mm` 指针到底指哪」时,现场早就没了。 + +GDB 给的是另一种交互:**断点 → 停下 → 单步 → 查变量 → 改寄存器 → 继续**。问题在于内核自己占着 CPU,谁来响应 GDB?答案就是 KGDB——把一个极简的 GDB 服务端(stub)塞进内核,让内核在断点处「自我冻结」,通过串口或网络和外部 GDB 对话。 + +## 原理:内核里长出一个 gdb stub + +KGDB 的核心是两层分工,源码就分在几个文件里: + +- **`kernel/debug/debug_core.c`**:与架构无关的「调试核心」,管断点表、总线锁、CPU 集结(让所有核一起停下来)、和 GDB 的命令调度。 +- **`kernel/debug/gdbstub.c`**:实现 GDB Remote Serial Protocol(RSP)的收发包、寄存器读写、内存读写,把 GDB 发来的 `$g`/`$G`/`$m`/`$M` 报文翻译成对内核内存的操作。 +- **`arch//kernel/kgdb.c`**:架构相关层,负责异常接管、寄存器布局、单步指令注入。ARM64 对应 `arch/arm64/kernel/kgdb.c`。 + +打个比方:`debug_core` 是「停机调度员」,`gdbstub` 是「翻译官」,arch 层是「按 CPU 型号定制的手」。三者合起来才构成一个能被 GDB 认作远程目标的 stub。 + +内部维护的核心数据结构在 `include/linux/kgdb.h`:断点表是个定长数组 `static struct kgdb_bkpt kgdb_break[KGDB_MAX_BREAKPOINTS]`(`debug_core.c:101`),每项记录四个字段——`bpt_addr`(断点地址)、`saved_instr`(被替换走的原始指令字节,`BREAK_INSTR_SIZE` 长度)、`type`(断点类型)、`state`(断点状态)。`kgdb_breakpoint()`(`debug_core.c:1209`)是那个能在任何地方下断的入口——**一个 `noinline` 函数**,不是宏。它在代码里主动陷进 KGDB 的「钩子」:你在自己的代码里写一行 `kgdb_breakpoint();`,内核执行到这就立刻自我冻结,等外部 GDB 连进来。 + +## 两条路:kgdb 与 kdb + +进了 stub 之后有两种「前台」: + +- **kgdb**:纯走 GDB 远程协议,外部那个 `aarch64-linux-gnu-gdb` 才是真正的命令解释器。能力最强(条件断点、Python 脚本、漂亮的 `struct` 展开),代价是必须开两个终端、靠串口通信。 +- **kdb**:内核内置的简易行调试器,不需要外部 GDB,直接在串口控制台敲 `bp`、`go`、`md`(内存 dump)。轻量、能在没网络只有串口的板子上救命,但语法朴素、没有符号类型推断。kdb 由 `CONFIG_KDB_KDB` 单独提供。 + +两者共用同一个 `debug_core`,区别只是 `gdbstub.c` 走对外协议、`kdb` 在内核态自己解析命令。生产线上几乎不会上 KGDB(一停全停),它天生属于「开发/调试机」场景。 + +## 搭建:CONFIG_KGDB + QEMU + kgdbwait + +把内核配出来(`make menuconfig`),注意 `KGDB` 在 6.19 是个顶层 `menuconfig`,下面挂的子项都是真实存在的(别找那个不存在的「kernel debugger interface」子项): + +``` +Kernel hacking ---> + [*] Kernel debugging # DEBUG_KERNEL + [*] Compile the kernel with debug info # CONFIG_DEBUG_INFO,GDB 要符号 + [*] KGDB: kernel debugger # CONFIG_KGDB(顶层 menuconfig) + <*> KGDB: use kgdb over the serial console # CONFIG_KGDB_SERIAL_CONSOLE + [*] KGDB: use kprobe blocklist to prohibit unsafe breakpoints # CONFIG_KGDB_HONOUR_BLOCKLIST(默认 y) + [*] KGDB: internal test suite # CONFIG_KGDB_TESTS(可选) + [*] KGDB_KDB: include kdb frontend for kgdb # CONFIG_KGDB_KDB(要用 kdb 才开) +``` + +> ⚠️ **务必开着 `KGDB_HONOUR_BLOCKLIST`**(`lib/Kconfig.kgdb`,默认 `y`)。它让 KGDB 借 kprobe 黑名单识别出那些「下断会自己把自己搞死」的函数(比如调试 trap 处理路径上被调用的函数),否则你在不安全的函数下断会触发递归 trap,整台机当场死透。 + +QEMU 启动时给一条专用串口并告诉内核「开机就在 KGDB 处停住等连接」,关键是 `kgdbwait` 这个内核参数: + +```bash +qemu-system-aarch64 -M virt -cpu cortex-a57 -m 1G \ + -kernel arch/arm64/boot/Image \ + -append "console=ttyAMA0 kgdboc=ttyAMA0 kgdbwait nokasnr" \ + -serial mon:stdio -nographic +``` + +`kgdboc=ttyAMA0` 把 KGDB 绑到这个串口(`kgdb` over `console`),`kgdbwait` 让内核初始化到某处就主动 `kgdb_breakpoint()` 挂起自己,`nokaslr` 是为了让 GDB 的符号地址和实际运行地址对得上(KASLR 一开,断点地址全得靠重定位)。 + +外部另开终端连进去: + +```bash +aarch64-linux-gnu-gdb vmlinux +(gdb) target remote /dev/pts/3 # 视 QEMU 的串口分配而定,待亲测核对 +(gdb) b do_sys_openat2 +(gdb) c +``` + +> 命令输出样例(待亲测核对): +> ``` +> Remote debugging using /dev/pts/3 +> 0xffff800080010000 in cpu_resume () +> (gdb) +> ``` + +## debug_core 的断点机制:怎么让 CPU 真的停下来 + +光有数据结构不顶用,得让 CPU 执行到某地址时触发异常。`debug_core.c:1209` 的 `kgdb_breakpoint()` 是入口:它是 `noinline void kgdb_breakpoint(void)`,体内调用架构相关的 `arch_kgdb_breakpoint()`——在 ARM64 上(`arch/arm64/include/asm/kgdb.h:19`)是个 `static inline` 函数,体内就一句汇编 `asm ("brk %0" : : "I" (KGDB_COMPILED_DBG_BRK_IMM))`,一执行就陷进异常。注意它**是函数不是宏**,别去源码里找 `#define kgdb_breakpoint`。 + +异常处理路径在 arch 层分两步。ARM64 上 BRK 指令陷进 EL1 异常后,由 `arch/arm64/kernel/debug-monitors.c` 的 `call_el1_break_hook()`(`debug-monitors.c:210`)统一分发:它从 ESR 的 ISS comment 字段里用 `esr_brk_comment(esr)` 提取出 BRK 指令编码的立即数,命中 KGDB 的专属立即数就调对应的 handler——动态断点立即数 `KGDB_DYN_DBG_BRK_IMM`(`0x400`)调 `kgdb_brk_handler()`,编译期断点立即数 `KGDB_COMPILED_DBG_BRK_IMM`(`0x401`)调 `kgdb_compiled_brk_handler()`(两个 handler 都在 `arch/arm64/kernel/kgdb.c:237/244`)。这些 handler 再调 `kgdb_handle_exception()`,后者进到 `debug_core.c` 的核心 `kgdb_cpu_enter()`(`debug_core.c:571`),它干三件事: + +1. **停机**:通过 IPI(核间中断)通知其它 CPU 进入集结,全部冻结,避免一边调试一边别的核还在改内存。ARM64 的 `kgdb_roundup_cpus()`(`arch/arm64/kernel/smp.c:940`)给每个在线 CPU 发一条**专用核间中断 `IPI_KGDB_ROUNDUP`**(`smp.c:824` 的 ipi_types 表里登记为「KGDB roundup interrupts」)把它们喊停。 +2. **快照现场**:寄存器现场经 `pt_regs_to_gdb_regs()`(`gdbstub.c:340`)快照进 `gdb_regs[]` 缓冲;硬件断点的临时关闭/恢复走 `arch_kgdb_ops`(`struct kgdb_arch`,`include/linux/kgdb.h:261`)里的 `disable_hw_break` / `remove_all_hw_break` / `correct_hw_break` 等回调。 +3. **进命令循环**:调用 `gdb_serial_stub()`(在 `gdbstub.c:955`)等待 GDB 报文,解析 `$?`/`$g`/`$m` 并回包,直到收到 `$c`(continue)才退出循环、恢复其它 CPU、返回被断点打断的现场。 + +恢复时 stub 把原指令字节写回去、单步执行原指令、再把断点指令重新填上——这套「填—单步—重填」就是软件断点跨越断点处的标准手法。 + +## 实战:透视一个内核数据结构 + +连上之后,KGDB 和调用户态程序几乎一样,只是你在读内核地址。我们在 `do_sys_openat2` 下断——注意 6.19 的签名是 `static int do_sys_openat2(int dfd, const char __user *filename, struct open_how *how)`(`fs/open.c:1415`),这里 `filename` 是个**用户态裸字符串指针**(`const char __user *`),不是 `struct filename *`,所以别敲 `p filename->name`(会报错): + +```gdb +(gdb) b do_sys_openat2 +(gdb) c +Breakpoint 1, do_sys_openat2 (dfd=-100, filename=..., how=...) at fs/open.c:1415 +(gdb) p filename # 看用户态传进来的路径字符串(__user 指针,需 GDB 主动读) +(gdb) p how->flags # 看用户到底想用什么 flag 打开文件 +(gdb) p *current # 当前进程的 task_struct,整张摊开 +(gdb) bt # 谁调的它 +(gdb) stepi # 单步一条机器指令 +``` + +> 想看 `struct filename *`(内核内部那个封装了路径字符串、引用计数、审计信息的结构体),得把断点挪到 `do_sys_openat2` 内部 `getname()` 拿到 `tmp` 之后的位置——`struct filename *tmp` 是 `do_sys_openat2` 里的局部变量(`fs/open.c:1417`)。在 `do_filp_open` 或 `getname_flags` 上断也行,那里能直接 `p tmp->name`。 + +最爽的是 `p *current`:GDB 借 `DEBUG_INFO` 把 `task_struct` 的字段全展开,`mm`、`pid`、`comm`、`fs` 一览无余。这是 `printk` 永远给不了的「现场快照」。 + +## 和 kprobes 的边界 + +很多人会把 KGDB 和 kprobes/eBPF 搞混,它们的本质区别是「停不停机」: + +- **kprobes / ftrace / eBPF**:探针命中后跑一段处理函数就放行,内核继续全速跑,**生产环境可用**,开销在纳秒到微秒级。 +- **KGDB**:命中就冻结整个系统,**生产慎用**(一停可能几十秒,watchdog、网络心跳全超时),属于开发机的专属工具。 + +需要「持续观测、低打扰」用 kprobes;需要「这一刻我得翻遍所有寄存器和数据结构」才上 KGDB。 + +## 小结 + +KGDB 的价值不在「比 printk 强一点」,而在于它把 GDB 那套交互式调试能力**整体搬进了内核**:靠 `debug_core.c` 做停机调度、`gdbstub.c` 说 RSP 协议、arch 层接管 BRK 异常,三者合起来让内核在断点处自我冻结,把现场完整交给外部 GDB。代价是全局停机,所以它和 kprobes 各占一个生态位——一个管「交互深挖」,一个管「线上观测」。 + +## 动手验证方案(待亲测) + +1. 按 QEMU + `kgdbwait` 把 6.19 内核拉起来,确认开机后卡在等待连接状态(命令输出待亲测)。 +2. 用 `aarch64-linux-gnu-gdb vmlinux` 连上,`info threads` 应能看到多个 CPU 的栈(验证 `IPI_KGDB_ROUNDUP` 集结成功)。 +3. 在 `do_sys_openat2` 下断点,触发一次 `cat /etc/hostname`,命中后 `p *current` 摊开 `task_struct`,记录 `comm`、`pid`、`mm->pgd` 字段(输出待亲测核对)。 +4. 单步几条 `stepi`,观察 PC 移动,再 `c` 恢复,确认系统正常继续启动。 + +## 延伸阅读 + +- 源码(Linux 6.19): + - `kernel/debug/debug_core.c` — 停机调度、断点表 `kgdb_break`、`kgdb_cpu_enter()`、`kgdb_breakpoint()` + - `kernel/debug/gdbstub.c` — GDB Remote Serial Protocol 实现、`gdb_serial_stub()` + - `arch/arm64/kernel/kgdb.c` — ARM64 BRK handler(`kgdb_brk_handler`/`kgdb_compiled_brk_handler`)、`kgdb_arch_init()` 注册 die notifier + - `arch/arm64/kernel/debug-monitors.c` — `call_el1_break_hook()` 按 BRK 立即数分流 + - `include/linux/kgdb.h` — `struct kgdb_bkpt`、`struct kgdb_arch`/`arch_kgdb_ops`、`kgdb_breakpoint()` 接口 +- 官方文档(6.19 KGDB 文档已归入 process/debugging,不再在 dev-tools 下): + - [docs.kernel.org/process/debugging/kgdb.html](https://docs.kernel.org/process/debugging/kgdb.html) — KGDB/kdb 使用手册(配置参数、kdb 命令表) + - [docs.kernel.org/process/debugging/index.html](https://docs.kernel.org/process/debugging/index.html) — 内核调试建议总览(KGDB 在此 toctree 下) + - [docs.kernel.org/dev-tools/index.html](https://docs.kernel.org/dev-tools/index.html) — dev-tools 总览(KGDB/kdb 现归 process/debugging)" \ No newline at end of file diff --git a/document/tutorials/debugging/index.md b/document/tutorials/debugging/index.md index 0f6c3a7a..acfd90c0 100644 --- a/document/tutorials/debugging/index.md +++ b/document/tutorials/debugging/index.md @@ -7,11 +7,22 @@ description: printk、ftrace、perf、eBPF——内核调试与性能分析全 > printk → ftrace → perf → eBPF,内核调试和性能分析全栈。 -📚 **规划中**,还没开写。通识基础里的 [GDB + QEMU 调试](../foundations/06-gdb-debug-setup)已经打底,这里会往运行时调试和性能分析展开。 +🔨 **整理中**。通识基础里的 [GDB + QEMU 调试](../foundations/06-gdb-debug-setup)打底,这里往运行时调试展开。 -## 计划 +## 调试工具 🔨 整理中 + +- 🔨 [printk:内核调试的生命线](./01-debug-printk) +- 🔨 [Kprobes:在任意函数上插眼](./02-debug-kprobes) +- 🔨 [KASAN:影子内存抓内存破坏](./03-debug-kasan) +- 🔨 [SLUB 调试:红区、毒药与追踪](./04-debug-slub) +- 🔨 [Oops:内核崩溃现场解读](./05-debug-oops) +- 🔨 [ftrace:内核的瑞士军刀追踪器](./06-debug-ftrace) +- 🔨 [KCSAN:抓并发里的数据竞争](./07-debug-kcsan) +- 🔨 [panic、Hung Task 与死锁检测](./08-debug-panic) +- 🔨 [KGDB:让 GDB 停下整个内核](./09-debug-kgdb) + +## 持续铺开 -- **printk 与日志**:日志级别、`log_buf`、动态调试 -- **ftrace**:tracepoint、function tracer、事件触发 - **perf**:采样、火焰图、cache 分析 - **eBPF**:bcc/bpftrace、内核可观测性 +- lockdep、trace-cmd、crash 工具 diff --git a/document/tutorials/drivers/01-drv-chardev.md b/document/tutorials/drivers/01-drv-chardev.md new file mode 100644 index 00000000..9aa6a586 --- /dev/null +++ b/document/tutorials/drivers/01-drv-chardev.md @@ -0,0 +1,148 @@ +--- +title: 字符设备驱动:用户态通往内核的门 +slug: drv-chardev +difficulty: intermediate +tags: [字符设备, file_operations, cdev, misc 设备] +architectures: [arm64, x86_64, riscv] +kernel_version: "6.19" +maturity: drafting +prerequisites: + - /tutorials/foundations/07-kernel-module-hello +related: + - /tutorials/foundations/07-kernel-module-hello +sources: + - notes: document/notes/linux_kernel_device_drivers/ch01.md + - notes: document/notes/linux_kernel_device_drivers/ch01_1.md + - notes: document/notes/linux_kernel_device_drivers/ch01_2.md +--- + +# 字符设备驱动:用户态通往内核的门 + +> 🔨 **整理中** · 本篇机制对照 Linux 6.19 源码讲解(函数/数据结构已核对);具体行号与命令输出待 QEMU 亲测核对。讲解里有 `chrdev_open`、`cdev_add`、`file_operations` 这套真实实现,动手那节留了个 misc 设备的验证方案,等我们上 QEMU 跑通了就升级成 ✅ 已锤炼。 + +## 一切皆文件,那硬件算哪门子文件 + +我们写用户态程序,`read()`/`write()` 张口就来,内核帮你兜底,虚拟内存护着你,崩了也只是进程退场。可一旦写驱动,身份就变了——你住进内核空间,特权级 Ring 0,一个野指针直接把整台机器搞蒸发。听起来吓人,但内核没那么神秘:它就是个跑在特权级、攥着全部硬件访问权的巨型程序,而驱动只是它身上的一块"插件"。 + +这块插件的核心任务很朴素——**建一条通道**:让不敢乱碰硬件的普通程序,能安全地经过内核,把数据发给硬件或从硬件拿回来。Linux 想了个统一的法子贯彻"一切皆文件":把设备也抽象成一种特殊文件,叫**设备节点**,住在 `/dev` 下。于是你 `open("/dev/xxx")` 跟 `open` 一个普通文本文件,走的系统调用接口是一模一样的。 + +内核怎么区分 `/dev` 下成千上万的设备?发两张身份证:**类型**(字符/块/网络)+ **设备号**(32 位 `dev_t`,高 12 位主号、低 20 位次号)。`ls -l /dev/sda1` 看到的 `8, 1` 就是这个——8 是主号,1 是次号。主号回答"我归哪个驱动管",次号回答"我是这个驱动手底下的第几号实例"。 + +## 设备三分类,本文只盯字符 + +Linux 把设备粗分成三类,理解它们只需抓一个核心差异: + +- **字符设备**:流式、按顺序读写,一般不能随机跳转,也挂不成文件系统。键盘、鼠标、传感器、串口都是它——它就是一根水管,水(数据)只能从一头进另一头出,没法跳到中间舀一勺。 +- **块设备**:数据按块存,支持随机访问,长得像磁盘所以能挂载文件系统。硬盘、U 盘、SD 卡。 +- **网络设备**:`/dev` 下根本找不到它,走的是 socket 那套接口,内核里对应 `struct net_device`,这套要另开一篇讲。 + +本文聚焦字符设备——它是驱动入门的标配,因为模型最直白:`open` 拿到 `fd`,`read`/`write` 跟硬件打交道,`close` 收尾。 + +## 注册三步走:从裸内核对象到"上线" + +一个字符设备要让用户态能用,得经过三步注册。这三步在内核源码里清清楚楚,我们顺着 `fs/char_dev.c`(Linux 6.19)走一遍。 + +**第一步:申请主设备号**。内核用一个哈希表 `chrdevs[CHRDEV_MAJOR_HASH_SIZE]`(255 槽)记下所有已注册的主号区间,每个槽挂一串 `struct char_device_struct`(含 `major`/`baseminor`/`minorct`/`name` 字段)。`register_chrdev_region()` 是"我指定主号"的写法,`alloc_chrdev_region()` 是"内核你帮我挑个没用的"——后者内部调 `__register_chrdev_region(0, baseminor, count, name)`,第一个参数 major 传 0 触发 `find_dynamic_major()`,在 `chrdevs[]` 里从高到低扫出一个空闲主号。约定俗成:写新驱动就用 `alloc_chrdev_region` 动态分配,别去抢硬编码主号,免得撞号。 + +**第二步:填 `file_operations` 并 `cdev_init`**。`cdev` 是字符设备在内核里的核心对象,定义在 `include/linux/cdev.h:14`: + +```c +struct cdev { + struct kobject kobj; /* 挂进设备模型的"户口" */ + struct module *owner; /* 防止模块被卸载时还有人 open */ + const struct file_operations *ops; /* 功能菜单 */ + struct list_head list; + dev_t dev; + unsigned int count; +} __randomize_layout; +``` + +`cdev_init()` 做的事:`memset` 清零 → 初始化 `list` → `kobject_init` → 把你传进来的 `fops` 指针塞进 `cdev->ops`。这一步是"把驱动的功能菜单装订好"。 + +**第三步:`cdev_add()` 上线**。`cdev_add()` 把设备号写进 `cdev->dev`/`->count`,然后调 `kobj_map(cdev_map, dev, count, ...)`——`cdev_map` 是个全局 `struct kobj_map`,它就是那张"设备号 → cdev"的反查表。调完这一行,你的设备就立刻"活"了:用户态 `open` 对应设备号时能被找到。`cdev_add` 自己的文档注释只声明一句"A negative error code is returned on failure"(失败返回负值);而它的高层封装 `cdev_device_add`(`fs/char_dev.c` 的注释里,NOTE 段)有个值得记住的警告——即便 add 失败,用户态也可能已经能把 cdev open 并调用 fops 回调了。所以我们别假设"add 失败就万事大吉",失败路径同样得把状态清干净。 + +## file_operations:驱动的功能菜单 + +`struct file_operations`(`include/linux/fs.h:1918`,Linux 6.19)是整个字符设备框架的"灵魂"。它是一张函数指针表,驱动把自己实现的 C 函数地址填进对应槽位,用户态一发系统调用,VFS 就跳到这些函数里。一个最小但够用的字符设备通常实现这几个回调: + +| 回调 | 触发系统调用 | 干什么 | +|:---|:---|:---| +| `.open` | `open()` | 初始化资源、做权限检查、`nonseekable_open()` | +| `.read` | `read()` | 把内核数据搬给用户(配 `copy_to_user`) | +| `.write` | `write()` | 收用户数据进内核(配 `copy_from_user`) | +| `.release` | `close()` | 释放 `open` 申请的资源 | +| `.llseek` | `lseek()` | 调整文件偏移,不支持就显式设 `no_llseek` | +| `.unlocked_ioctl` | `ioctl()` | 设备专用的"自定义命令通道" | +| `.mmap` | `mmap()` | 把内核/设备内存映射进用户地址空间 | + +签名都是固定的,比如 `.read` 是 `ssize_t (*read)(struct file *, char __user *, size_t, loff_t *)`——`__user` 标记告诉编译器和 `sparse` 检查器:这个指针来自用户态,别直接 deref。某个回调不实现就让对应指针为 `NULL`,VFS 会返回默认错误。但有个坑:`.llseek` 设 `NULL` 不是"不支持",而是走默认逻辑可能返回随机正值糊弄用户;正确做法是显式赋 `no_llseek` 并在 `.open` 里调 `nonseekable_open()`,这样用户态 `lseek` 会得到明明白白的 `-ESPIPE`。 + +## 用户态怎么连上:open() → VFS → chrdev_open → 你的 .open + +这是全篇最该讲透的一段,因为它串起了"设备节点"和"驱动回调"。当一个字符设备节点被 `open()` 时,真正干活的不是驱动,而是内核 `fs/char_dev.c` 里的 `chrdev_open()`: + +1. `open("/dev/xxx")` 进 VFS,VFS 根据 inode 的设备号知道这是个字符设备,于是用内核默认的 `def_chr_fops` 打开——`def_chr_fops` 只设了两个回调:`.open = chrdev_open` 和 `.llseek = noop_llseek`,其余全是 `NULL`,它的唯一使命就是用 `chrdev_open` 把真正的驱动 `fops` 接进来。 +2. `chrdev_open()` 拿 `inode->i_rdev`(设备号)去 `kobj_lookup(cdev_map, inode->i_rdev, &idx)`——就是查第三步建的那张反查表,拿到对应的 `cdev`。 +3. 把 `cdev` 挂到 inode 上(`inode->i_cdev = p`)方便下次复用,`try_module_get(owner)` 给模块引用计数加一(防止有人趁你 open 着卸载模块)。 +4. 关键一句:`fops = fops_get(p->ops)` → `replace_fops(filp, fops)`——**把驱动的 `file_operations` 替换进 `file->f_op`**。 +5. 最后才调 `filp->f_op->open(inode, filp)`,也就是**你驱动里写的 `.open`**。 + +从这之后,`read`/`write` 等调用 VFS 都直接走 `filp->f_op->read`——也就是你填的函数。这套机制把"系统调用"和"驱动代码"用一张函数指针表优雅地接上了。 + +那设备节点 `/dev/xxx` 哪来的?老办法是 `mknod /dev/xxx c <主> <次>` 手敲;现代系统靠 **udev**(或 systemd 的 systemd-udevd)盯着内核 uevent,驱动一注册、设备一出现在 sysfs,udev 就自动 `mknod` 出节点。misc 框架更进一步,连这一步都帮你包了。 + +## misc 设备:字符设备的"快捷键" + +主设备号是稀缺资源,内核为收编一堆"杂牌军"(鼠标、传感器、看门狗)搞了个 **misc 类**——所有 misc 设备共享主设备号 **10**,靠次设备号区分彼此。次设备号的取用规则在 `include/linux/miscdevice.h` 里写得明明白白,分三段:`<255` 是**固定次号**(像看门狗、hwmon 这些"老住户"各占一个写死的号);`==255`(`MISC_DYNAMIC_MINOR`)是个**指示值**,意思是"我不想挑号,内核你给我动态分一个";`>255` 才是真正动态分到的次号池,容量大得离谱——`1048320` 个。它像一座拥有无限分机的电话总机:大家拨打同一个总机号 10,再靠分机号(次设备号)找到具体房间。 + +对驱动作者来说,misc 是字符设备的"快捷键":不用手动 `alloc_chrdev_region`+`cdev_init`+`cdev_add` 三步走,只要填一个 `struct miscdevice`(设 `minor = MISC_DYNAMIC_MINOR`、`name`、`fops`)然后 `misc_register()` 一次性搞定——内部其实还是走那三步,只是 misc 框架替你做了,并且自动在 `/dev` 下创建同名节点。本篇从 misc 起步,因为样板最小,机制又没丢。 + +## 安全红线:为什么 memcpy 是禁区 + +驱动最容易翻船、也最致命的地方,就是用户态和内核态之间的数据搬运。你也许想:拷内存嘛,`memcpy` 不就完了?**绝对不行。** 内核空间和用户空间页表是隔离的,用户传进来的指针在内核里可能根本没映射、或是只读的;直接 `memcpy` 轻则触发缺页 panic,重则恰好是合法地址——那就是越界写,是安全漏洞。 + +内核给了两条专用摆渡船: + +- `copy_to_user(void __user *to, const void *from, unsigned long n)` —— 内核 → 用户 +- `copy_from_user(void *to, const void __user *from, unsigned long n)` —— 用户 → 内核 + +它们会先检查用户地址合法性(历史上 `access_ok()`,现代已并入函数内部),拷不完就返回未拷字节数(驱动据此返回 `-EFAULT`),过程中可能触发缺页让进程睡眠,所以**绝不能在中断上下文或持自旋锁时调用**。但注意:`copy_from_user` 只保证"这个用户指针可写",**它不替你检查 n 有没有超过你内核缓冲区的大小**——这个边界检查是驱动作者的责任。 + +漏了这个检查就是经典提权路径:假设 `dev->secret` 只有 64 字节,你 `copy_from_user(dev->secret, buf, len)` 而 `len` 是用户给的 1000,内核内存就被一路覆盖下去。Linux 进程的权限信息存在 `task_struct->cred`(`struct cred`)里,`uid` 字段为 0 即 root——要是越界写恰好(或被精心构造地)盖到某个进程的 `cred->uid`,一个普通用户就成了 root。历史上无数 CVE 就是这种"边界检查缺失"酿的。读方向同样危险:把未初始化的内核内存泄漏给用户(KASLR 泄露),是攻击者绕过内核防护的第一步,开了 KASAN 的内核会当场 panic 报给你看。**所以每个 `copy_*_user` 前先想清楚 `len` 的上界,这是内核安全的生死线。** + +## 动手待亲测:写个 misc 设备,cat/echo 读写 + +动手部分留到亲测阶段,这里先给验证方案占位,落地代码归后续 `example/mini/` 目录。 + +**目标**:一个 misc 字符设备,内核里存一句"秘密",`cat /dev/xxx` 读出来,`echo "新秘密" > /dev/xxx` 写进去。 + +**验证方案(待 QEMU 亲测核对)**: + +1. 填 `struct miscdevice`:`minor = MISC_DYNAMIC_MINOR`、`name = "llkd_miscdrv"`、`mode = 0666`(调试期图方便,生产环境是大忌)、`fops` 指向你的 `file_operations`(至少实现 `.open/.read/.write/.release`,`.llseek = no_llseek`)。 +2. `init` 里 `misc_register()`,预期 `dmesg` 看到 `major # 10, minor# = N`。 +3. 读写用 `copy_to_user`/`copy_from_user`,**写时先判 `count > MAXBYTES` 返回 `-EFBIG`**,严守边界。 +4. `exit` 里 `misc_deregister()` 配对。 + +预期命令输出(待亲测): + +``` +$ ls -l /dev/llkd_miscdrv +crw-rw-rw- 1 root root 10, 56 ... /dev/llkd_miscdrv # 10 主号,56 动态次号 +$ echo "hello kernel" > /dev/llkd_miscdrv +$ cat /dev/llkd_miscdrv +hello kernel +``` + +> ⚠️ **待亲测**:上面命令输出是参考样例,次设备号会变——`minor` 设了 `MISC_DYNAMIC_MINOR` 时,内核分到的是 `>255` 池子里的号,而非示例里随手写的 56(56 其实落在 `<255` 的固定号区间,这里只是示意格式)。我们会拿到 QEMU ARM64 上 `insmod` 后跑一遍,把 `dmesg`、`ls -l`、`cat`/`echo` 的真实输出记下来,顺手验证"故意写超长数据"会被我们的边界检查挡住(返回 `-EFBIG`)。 + +## 小结 + +字符设备是用户态通往内核的门:主次设备号定位驱动,`struct cdev` 是内核里的设备对象,`file_operations` 是驱动的功能菜单,`chrdev_open` 在 `open()` 时把驱动的 `fops` 装进 `file->f_op`,之后所有读写系统调用都跳进驱动代码。misc 框架把"申请主号 + cdev 注册 + 建节点"打包成一次 `misc_register()`,是入门最省事的姿势。而 `copy_to_user`/`copy_from_user` 加上你自己写的边界检查,是这扇门的门栓——漏一根就是提权后门。 + +记住三件事:**主号定位驱动、次号定位实例**;**`file_operations` 是驱动和 VFS 的唯一接口**;**`copy_*_user` 不替你查缓冲区大小,边界检查是驱动作者的命**。 + +## 延伸阅读 + +- 源码:`fs/char_dev.c`(Linux 6.19),字符设备核心——`chrdev_open`、`cdev_add`、`cdev_init`、`__register_chrdev_region`、`find_dynamic_major` 都在这;`include/linux/cdev.h:14` 看 `struct cdev`;`include/linux/fs.h:1918` 看 `struct file_operations`;`def_chr_fops` 也在 `fs/char_dev.c`(只有 `.open = chrdev_open` 与 `.llseek = noop_llseek`);misc 框架看 `drivers/char/misc.c` 与 `include/linux/miscdevice.h`(次设备号三分规则在同一头文件注释里)。 +- kernel.org 稳定文档索引:[Driver implementer's API guide](https://docs.kernel.org/driver-api/index.html) 下有 Driver Basics、ioctl based interfaces 等字符设备相关小节;用户侧设备号官方登记表见 [Linux allocated devices (4.x+ version)](https://docs.kernel.org/admin-guide/devices.html)。 +- 进一步(持续铺开):`ioctl` 的 `_IO/_IOR/_IOW/_IOWR` 命令编码、`mmap` 与 `remap_pfn_range`、阻塞 I/O 与 wait queue、platform 总线与 `probe/remove`。 \ No newline at end of file diff --git a/document/tutorials/drivers/02-drv-ioctl.md b/document/tutorials/drivers/02-drv-ioctl.md new file mode 100644 index 00000000..438f9c7c --- /dev/null +++ b/document/tutorials/drivers/02-drv-ioctl.md @@ -0,0 +1,140 @@ +--- +title: ioctl:结构化的内核-用户命令通道 +slug: drv-ioctl +difficulty: intermediate +tags: [字符设备, ioctl, 用户内核通信, 安全] +architectures: [arm64, x86_64, riscv] +kernel_version: "6.19" +maturity: drafting +prerequisites: + - /tutorials/drivers/01-drv-chardev +related: + - /tutorials/drivers/01-drv-chardev +sources: + - notes: document/notes/linux_kernel_device_drivers/ch02_2.md + - notes: document/notes/linux_kernel_device_drivers/ch02_3.md +--- + +# ioctl:结构化的内核-用户命令通道 + +> 🔨 **整理中** · 本篇机制对照 Linux 6.19 源码讲解(`fs/ioctl.c`、`include/uapi/asm-generic/ioctl.h` 的函数/数据结构已逐行核对)。需要诚实说明:读书笔记里 ioctl 的正文章节是缺失的(ch02 只在通信全景里一笔带过,真正的机制正文没写),所以这篇以**源码为权威来源**,练习 2.5/2.6 的素材来自笔记 ch02_3。具体行号与命令输出待 QEMU 亲测核对。 + +上一篇我们用字符设备的 `read`/`write` 把数据在用户态和内核态之间搬来搬去。但很快就撞墙了:`read`/`write` 是一条**无类型的数据流水线**——它只认字节流,不认"命令"。你想对设备说"复位"、"换波特率"、"查一下当前状态结构体",全靠约定俗成的字节序去解析,这就把驱动逼成了一个臃肿的协议解析器。 + +`ioctl`(I/O Control)就是给这条无类型流水线加上**结构化命令语义**的口子:一次调用 = 一个命令码 + 一个参数。它是最老牌的设备控制通道,也是最容易写成一团魔数黑盒的那个——所以我们不光讲怎么写,要把内核里这套命令通道的实现掰开看。 + +## ioctl 的接口形态 + +用户态的入口是 `ioctl(2)` 系统调用,原型 `int ioctl(int fd, unsigned long request, ...)`,第三个参数在内核侧统一收成一个 `unsigned long arg`。落到驱动这边,挂的是 `struct file_operations` 里的 `unlocked_ioctl`(Linux 6.19,`include/linux/fs.h:1930`): + +```c +long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long); +long (*compat_ioctl) (struct file *, unsigned int, unsigned long); +``` + +那个 `arg` 是个**双面人**:它可能是一个标量值(比如要把某个寄存器设成几),也可能是一个**用户空间指针**(指向一个结构体,驱动再 `copy_from_user` 拷进来)。到底是哪种,完全由 `cmd` 的语义决定——这就是为什么 `cmd` 必须自带"参数怎么传"的信息。 + +## cmd 的编码魔法:四个宏 + +`ioctl` 最容易被滥用成黑盒的根源,是早年大家随便挑个数字当命令码。Linux 后来钉死了一套编码方案,在 `include/uapi/asm-generic/ioctl.h`(Linux 6.19)里,把一个 32 位的 `cmd` 拆成四段: + +| 字段 | 位宽 | 含义 | +|:---|:---:|:---| +| `_IOC_DIR`(方向) | 2 | NONE/READ/WRITE | +| `_IOC_SIZE`(参数大小) | 14 | 参数结构体字节数 | +| `_IOC_TYPE`(魔数) | 8 | 区分驱动家族的"姓氏" | +| `_IOC_NR`(序号) | 8 | 该家族下的命令编号 | + +关键是位宽注释里那句大实话:参数大小塞进命令码,上限约 **16KB - 1**,"有用——能抓住用旧版头文件编译的程序,也能防止写越界用户缓冲"(`include/uapi/asm-generic/ioctl.h:12`)。也就是说,内核**从命令码本身**就能知道要拷多少字节、方向是哪边,这对后面做边界检查是免费的保险。 + +四个构造宏(同文件 `:85-88`)把上面四段打包: + +```c +#define _IO(type,nr) _IOC(_IOC_NONE,(type),(nr),0) +#define _IOR(type,nr,argtype) _IOC(_IOC_READ, (type),(nr),(_IOC_TYPECHECK(argtype))) +#define _IOW(type,nr,argtype) _IOC(_IOC_WRITE,(type),(nr),(_IOC_TYPECHECK(argtype))) +#define _IOWR(type,nr,argtype) _IOC(_IOC_READ|_IOC_WRITE,(type),(nr),(_IOC_TYPECHECK(argtype))) +``` + +方向命名是个**坑**,源码注释专门强调(`include/uapi/asm-generic/ioctl.h:53-54` 与 `:82-83`):`_IOW` 是"用户在写、内核在读",`_IOR` 反过来。第一次接触必踩,记住"站在用户视角命名"就对了。 + +`_IOC_TYPECHECK(argtype)` 在用户态(`include/uapi/asm-generic/ioctl.h:75-77`,`#ifndef __KERNEL__` 块里)展开成 `sizeof(argtype)`,所以一旦你改了参数结构体大小,命令码自动变——用旧头文件的程序拿老码来调,驱动一眼就能识别不匹配(这正是练习 2.6 `ioctl_undoc` 那种"未文档化命令"要小心防护的场景)。 + +内核侧这道保险更硬:`include/asm-generic/ioctl.h`(内核专用副本,`:12-15`)把 `_IOC_TYPECHECK` 套进一个编译期检查——`sizeof(t) < (1 << _IOC_SIZEBITS)`,否则让符号解析成未定义的 `extern __invalid_size_argument_for_IOC`,直接**编译失败**。所以参数结构体超过 14 位 size 上限(>16383 字节)时,内核这侧连编都编不过——这是"塞 size 字段"在编码方案之外的又一道硬保险,和上面的 16KB-1 上限呼应。 + +**铁律:用户态和内核态共用同一份命令定义头**。把 `_IO*` 宏放进一个既能被用户程序 `#include`、又能被内核 `#include` 的头里(用 `#ifdef __KERNEL__` 分隔内核专用部分),保证两边算出来的 `cmd` 位级一致。否则你靠"手抄数字",早晚抄错。 + +## VFS 层流程:do_vfs_ioctl → vfs_ioctl + +用户态 `ioctl(2)` 一进来,先走 `SYSCALL_DEFINE3(ioctl, ...)`(`fs/ioctl.c:583`,Linux 6.19)。这条路径分两步,顺序很讲究: + +```c +error = do_vfs_ioctl(fd_file(f), fd, cmd, arg); // 内核"公共命令"先拦截 +if (error == -ENOIOCTLCMD) + error = vfs_ioctl(fd_file(f), cmd, arg); // 没人认领,才转交驱动 +``` + +`do_vfs_ioctl`(`fs/ioctl.c:492`)是个**公共命令总机**,它先用 `switch(cmd)` 截胡一批面向所有文件描述符的通用命令——`FIOCLEX`/`FIONCLEX`(设 close-on-exec)、`FIONBIO`(非阻塞)、`FIOASYNC`、`FIFREEZE`/`FITHAW`(冻结/解冻文件系统)、`FS_IOC_GETFLAGS`/`FS_IOC_SETFLAGS` 等等,这些命令**驱动不需要自己实现**。注意进总机前还有一道 `security_file_ioctl()`(`fs/ioctl.c:591`)——LSM(比如 SELinux)有权在这里把整次 ioctl 直接毙掉。 + +措辞要精确一点:`switch` 里那批是**面向所有 fd 的通用命令**,但行为并不对每种 fd 完全一致。`do_vfs_ioctl` 的 default 分支(`fs/ioctl.c:574-577`)在普通文件(`S_ISREG` 且非匿名文件)上还会把命令转交给 `file_ioctl()`(`:322`),后者处理 `FIBMAP`、`FIONREAD` 这类**受文件类型门控**的命令——换句话说,不是所有"公共命令"对任意 fd 行为都一样。 + +只有 `do_vfs_ioctl` 返回 `-ENOIOCTLCMD`(意思是"我不认识这个命令"),才轮到 `vfs_ioctl`(`:44`)把命令真正交给你驱动的 `.unlocked_ioctl`: + +```c +static int vfs_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) +{ + int error = -ENOTTY; + if (!filp->f_op->unlocked_ioctl) + goto out; + error = filp->f_op->unlocked_ioctl(filp, cmd, arg); + if (error == -ENOIOCTLCMD) + error = -ENOTTY; // 驱动说不认识,统一翻译成 ENOTTY + ... +} +``` + +这套两级分发的好处:公共功能内核替你兜了,驱动只管自己的私货;不认识的命令也别返回乱七八糟的码,`-ENOTTY`("非终端设备")是 ioctl"不认识此命令"的统一暗号。 + +## 参数传递:指针就得 copy_from_user + +当 `arg` 实际是个用户指针时,驱动必须用 `copy_from_user`/`copy_to_user` 跨边界拷贝——这点和 `read`/`write` 一模一样,绝对不能直接解引用用户传来的指针(会 Oops,甚至被打穿成安全漏洞)。`ioctl_fiemap`(`fs/ioctl.c:199`)就是个教科书范例:先 `copy_from_user(&fiemap, ufiemap, sizeof(fiemap))` 把用户结构体搬进来,处理完再 `copy_to_user` 搬回去,两步都检查返回值,失败返回 `-EFAULT`。 + +对于单个标量,内核给了轻量包装:`get_user(x, ptr)` / `put_user(x, ptr)`,`ioctl_fibmap`(`fs/ioctl.c:58`,函数体内 `:68` 行 `get_user(ur_block, p)`)就是这么用的。 + +### 32 位进程跑 64 位内核:compat_ioctl + +真正折磨人的是 32 位用户程序跑在 64 位内核上。指针大小、结构体对齐都对不上,原始 `unlocked_ioctl` 直接收到的 `arg` 是个被零扩展的 32 位指针,`copy_from_user` 会读到鬼地方去。内核为此准备了 `compat_ioctl`(`include/linux/fs.h:1931`)和一整套 `COMPAT_SYSCALL_DEFINE3(ioctl, ...)`(`fs/ioctl.c:638`)路径:它的 default 分支(`:688-690`)先把 `arg` 用 `compat_ptr()` 规整成正确的内核指针(在 s390 等架构上还会清最高位),再决定是直接转交 `do_vfs_ioctl`,还是调驱动的 `.compat_ioctl`(`:694`)。 + +内核还贴心提供了 `compat_ptr_ioctl`(`fs/ioctl.c:629`)这个通用实现——如果你的 ioctl 参数要么是无指针标量、要么是 32/64 位布局兼容的结构体,直接 `.compat_ioctl = compat_ptr_ioctl` 就够了,它会规整指针后转给你的 `unlocked_ioctl`。但凡有 `long`/指针/64 位字段混在结构体里,就必须手写 `compat_ioctl` 单独处理对齐。 + +## 安全:cmd 校验与边界检查 + +`ioctl` 的危险在于它太自由——一个不校验的 ioctl 就是个后门。踩坑笔记里反复强调的"未文档化命令"(练习 2.6 `ioctl_undoc`,见 `document/notes/linux_kernel_device_drivers/ch02_3.md`)正是攻击面:用户可以塞任意 `cmd` 进来,驱动必须对**每一个不认识的 cmd 返回 `-ENOTTY`**,绝不能让 default 分支悄悄放行。 + +其次,`arg` 指向的用户缓冲区得做**边界检查**。`ioctl_file_dedupe_range`(`fs/ioctl.c:415`)的做法值得抄:先 `get_user` 读出 `count`,用 `struct_size` 算总大小,超 `PAGE_SIZE` 直接 `-ENOMEM` 拒绝,再用 `memdup_user` 一次性拷进来。涉及特权操作的(如 `ioctl_fsfreeze`)必须查 `capable(CAP_SYS_ADMIN)` / `ns_capable`,否则普通用户一句 ioctl 就把文件系统冻住了。 + +还有一道容易被忽略的保险:**命令编码里的 `_IOC_SIZE`**。驱动可以用 `_IOC_SIZE(cmd)` 取出"声明的大小",和它实际要拷的结构体大小比对,不匹配就拒——这正是内核在编码方案里塞 size 字段的本意。 + +## 动手待亲测 + +我们会在 `example/mini/` 下落一个 ioctl demo,目标清单: + +- 用 `_IOWR` 编码一个命令,比如 `#define IOC_GETSTATUS _IOWR('k', 1, struct drv_status)`,魔数 `'k'`,参数结构体含几个字段。 +- 驱动 `unlocked_ioctl` 里 `switch(cmd)`,命中时 `copy_from_user` 收参数、处理、`copy_to_user` 回填;default 返 `-ENOTTY`。 +- 用户态 C 程序 `ioctl(fd, IOC_GETSTATUS, &st)` 调用,打印结构体。 +- 故意用 32 位编译的用户程序(`gcc -m32`)跑在 64 位内核上,验证不写 `compat_ioctl` 会怎样,再补上。 + +> ⚠️ **待亲测**:上面命令输出、`dmesg` 现象、32/64 位兼容的实测结果,都要拿到 QEMU ARM64/x86_64 上跑一遍记下来,回头把这一节从占位升级成真实记录。 + +## 小结 + +`ioctl` 给无类型的 `read`/`write` 流水线接上了结构化命令通道:一个 `cmd` 用四段编码(方向 + 大小 + 魔数 + 序号)自带"怎么传参数"的元数据,内核从 `SYSCALL_DEFINE3(ioctl)` 经 `do_vfs_ioctl`(公共命令总机)两级分发到驱动的 `unlocked_ioctl`。参数是指针时老老实实 `copy_from_user`/`copy_to_user`;32/64 位混跑要靠 `compat_ioctl`(或 `compat_ptr_ioctl`)规整指针;安全上每个 cmd 都得校验、不认识的返 `-ENOTTY`、特权操作查 capability、用户缓冲区做边界检查。 + +记住一句话:ioctl 的自由度是它的力量,也是它的债务——编码方案和校验纪律,就是还债的账本。 + +## 延伸阅读 + +- 源码:`fs/ioctl.c`(Linux 6.19),`SYSCALL_DEFINE3(ioctl`(`:583`)/ `do_vfs_ioctl`(`:492`)/ `vfs_ioctl`(`:44`)/ `compat_ptr_ioctl`(`:629`)全在这;`include/uapi/asm-generic/ioctl.h` 看 `_IO*` 宏与编码位布局;`include/asm-generic/ioctl.h`(内核副本,`:12-15`)看 `_IOC_TYPECHECK` 的编译期 size 上限断言;`include/linux/fs.h:1930` 看 `struct file_operations` 的 `unlocked_ioctl`/`compat_ioctl` 字段。 +- kernel.org 文档(均经树内核 `Documentation/` 核实存在):[ioctl based interfaces](https://docs.kernel.org/driver-api/ioctl.html)(`Documentation/driver-api/ioctl.rst`,讲命令编号约定、错误码、`_IOC_SIZE` 用法)、[(How to avoid) Botching up ioctls](https://docs.kernel.org/process/botching-up-ioctls.html)(`Documentation/process/botching-up-ioctls.rst`,Daniel Vetter 写的 ioctl 设计避坑经典)、[Decoding ioctl numbers](https://docs.kernel.org/userspace-api/ioctl/ioctl-decoding.html)(`Documentation/userspace-api/ioctl/ioctl-decoding.rst`)、[Linux Filesystems API summary](https://docs.kernel.org/filesystems/api-summary.html)(`Documentation/filesystems/api-summary.rst`)。 +- man page:`ioctl(2)`、`ioctl_list(2)`——用户态接口语义与已知命令码清单。 +- 进一步(持续铺开):sysfs/debugfs/netlink 这几条兄弟通道的取舍,以及 64 位兼容的完整 `compat` 框架。 \ No newline at end of file diff --git a/document/tutorials/drivers/03-drv-poll.md b/document/tutorials/drivers/03-drv-poll.md new file mode 100644 index 00000000..5efc6ce9 --- /dev/null +++ b/document/tutorials/drivers/03-drv-poll.md @@ -0,0 +1,148 @@ +--- +title: poll/select:驱动怎么告诉用户"数据来了" +slug: drv-poll +difficulty: intermediate +tags: [字符设备, 等待队列, poll机制, select, epoll] +architectures: [arm64, x86_64, riscv] +kernel_version: "6.19" +maturity: drafting +prerequisites: + - /tutorials/drivers/01-drv-chardev +sources: + - notes: document/notes/linux_kernel_device_drivers/ch02.md +--- + +# poll/select:驱动怎么告诉用户"数据来了" + +> 🔨 **整理中** · 本篇机制对照 Linux 6.19 源码讲解(函数/数据结构已核对);具体行号与命令输出待 QEMU 亲测核对。 +> +> 诚实交代一句:本站读书笔记里**没有**专讲 poll/等待队列的章节(现有的 ch02 笔记讲的是 procfs/sysfs/debugfs/netlink/ioctl 这类通信手段,跟本篇主题对不上),所以这篇的素材是直接从 Linux 6.19 源码扒出来的,不走笔记路线。 + +## 阻塞 read 的死穴 + +上一篇我们写了字符设备驱动的 `read`。默认情况下它有个让人抓狂的特性:**数据没来,`read` 就卡死**。用户进程调一次 `read(fd, buf, N)`,驱动一看缓冲区空,就把它丢进等待队列睡大觉——直到数据来被唤醒。这种"阻塞 I/O"是 Unix 的默认脾气。 + +单看一个设备这没毛病。可现实是,一个程序经常要同时伺候好几个数据源:键盘敲一下要响应、串口来一帧要收、网络来包要处理。如果每个 fd 都阻塞 read,那第一个没数据的设备就把整个进程焊死了——后面的设备再忙也没人理。 + +这就是 poll/select/epoll 三兄弟要解决的问题:**让一个进程同时盯一堆 fd,谁先有数据就先告诉它,它再去 read 那个有货的**。从"轮流死等"变成"有货叫你"。 + +## 用户态三兄弟:同时盯多个 fd + +用户态有三个长得像但进化程度不同的系统调用: + +- **`select(fd_set, timeout)`**:最古老。用三个位图(读/写/异常)标关心哪些 fd,返回时改写位图标谁就绪。坑是 fd 用位图编号,默认上限 `FD_SETSIZE`=1024;而且每次都要把整个位图在用户态和内核态之间拷来拷去。 +- **`poll(struct pollfd[], timeout)`**:进化版。传一个 `pollfd` 数组,每个元素写"关心哪个 fd、关心哪些事件",返回时往 `revents` 里填实际发生的事件。没有 1024 上限,但还是要每次全量拷数组。 +- **`epoll`**:终极形态。`epoll_create` 建一个内核里的红黑树,`epoll_ctl` 注册关心的 fd(只注册一次),`epoll_wait` 只返回就绪的那些——O(就绪数)而非 O(总 fd 数)。高并发服务器几乎只用它。 + +这篇我们重点讲前两个的内核实现,因为它们的驱动侧接口完全一样:都是那个 `.poll` 回调。epoll 的用户态接口不同,但内核侧一样走 `vfs_poll()` → 驱动 `.poll`,所以把 `.poll` 写对,三个全照顾到了。 + +## 驱动 `.poll` 回调:两件事缺一不可 + +用户调 `poll()`,内核最终会回调驱动 `file_operations` 里那个 `.poll` 方法。签名长这样(Linux 6.19): + +```c +__poll_t my_poll(struct file *filp, struct poll_table_struct *wait); +``` + +这个回调要干**两件缺一不可**的事: + +**第一件:`poll_wait()` 把当前进程登记到等待队列上。** + +```c +poll_wait(filp, &my_wq, wait); +``` + +`poll_wait` 是个 inline 函数,定义在 `include/linux/poll.h`(Linux 6.19)。它干的事其实就一句:如果 `wait->_qproc` 不为空,就调 `wait->_qproc(filp, wait_address, wait)`,把进程"预先挂"到你驱动的等待队列 `my_wq` 上。注意——是"登记",不是"立刻睡"。登记完内核还能继续跑,先把所有 fd 都问一遍再说。 + +**第二件:返回当前这个 fd 能不能读/写。** + +驱动看一眼自己的状态:缓冲区有数据就返回 `EPOLLIN | EPOLLRDNORM`(可读),能写就返回 `EPOLLOUT | EPOLLWRNORM`(可写),没货就返回 0。这个掩码就是内核拿去判断"这个 fd 现在就绪没"的依据。 + +为什么两件都要?因为有个经典的竞态:如果只返回掩码不登记等待队列,那么驱动在 `.poll` 返回后、内核真正决定睡之前,如果数据恰好来了,没人会唤醒这个进程——它就睡死或等超时。登记等待队列是为了"数据来时能叫醒我",返回掩码是为了"现在就有货就别睡了"。两个一起,才堵住竞态。 + +## 等待队列:数据就绪谁来叫醒 + +`poll_wait` 登记的等待队列,本质是 `wait_queue_head_t`,对应的结构体定义在 `include/linux/wait.h`(Linux 6.19): + +```c +struct wait_queue_head { + spinlock_t lock; + struct list_head head; +}; +``` + +就是个带自旋锁的链表头。`init_waitqueue_head(&my_wq)` 初始化它。当数据真的来了(比如中断里收完一帧),驱动要主动喊一嗓子: + +```c +wake_up_interruptible(&my_wq); +``` + +`wake_up_interruptible` 是 `include/linux/wait.h` 里的宏,展开成 `__wake_up(x, TASK_INTERRUPTIBLE, 1, NULL)`——只唤醒一个可被信号打断的睡眠者。`wake_up` 则是 `__wake_up(x, TASK_NORMAL, 1, NULL)`,唤醒 `TASK_NORMAL` 的。区别很重要:`TASK_INTERRUPTIBLE` 状态的进程收到信号会被叫醒,适合可中断的等待。 + +那么 `.poll` 登记的等待项,内核是怎么塞进队列的?看 `fs/select.c`(Linux 6.19)的 `__pollwait()`: + +```c +static void __pollwait(struct file *filp, wait_queue_head_t *wait_address, + poll_table *p) +{ + struct poll_wqueues *pwq = container_of(p, struct poll_wqueues, pt); + struct poll_table_entry *entry = poll_get_entry(pwq); + ... + entry->filp = get_file(filp); + entry->wait_address = wait_address; + entry->key = p->_key; + init_waitqueue_func_entry(&entry->wait, pollwake); + entry->wait.private = pwq; + add_wait_queue(wait_address, &entry->wait); +} +``` + +关键点:它创建一个 `poll_table_entry`,里面塞个 `wait_queue_entry`,唤醒回调函数设成 `pollwake`(不是默认的 `default_wake_function`),然后 `add_wait_queue` 挂到你驱动的等待队列上。所以你 `wake_up_interruptible` 一喊,最终走 `pollwake` → `__pollwake`,它把 `pwq->triggered` 置 1,再 `default_wake_function` 把进程真叫醒。 + +## 内核流程:`do_sys_poll` → 驱动 `.poll` → 没货就睡 → 被唤醒重查 + +把整条链路串起来,以 `poll()` 系统调用为例(`fs/select.c`,Linux 6.19): + +1. **`SYSCALL_DEFINE3(poll, ...)`** 进内核,算好超时,调 `do_sys_poll()`。 +2. **`do_sys_poll()`** 在栈上开个 `struct poll_wqueues table`,调 **`poll_initwait(&table)`**——它把 `table.pt._qproc` 设成 `__pollwait`(就是上面那个塞等待项的函数),`polling_task = current`(记下是哪个进程在 poll),`triggered = 0`。 +3. **`do_poll()`** 是核心循环。它遍历每个关心的 fd,对每个调 **`do_pollfd()`**: + ```c + filter = demangle_poll(pollfd->events) | EPOLLERR | EPOLLHUP; + pwait->_key = filter | busy_flag; + mask = vfs_poll(fd_file(f), pwait); // 这一句回调你的 .poll + return mask & filter; + ``` + `vfs_poll()`(在 `include/linux/poll.h`)就是 `file->f_op->poll(file, pt)`——直接打到你驱动的 `.poll` 回调。你的 `.poll` 里 `poll_wait()` 登记队列、返回掩码,全在这一步发生。 +4. **第一轮全扫一遍**:如果某个 fd 返回了非零掩码(`mask`),`count++`,并把 `pt->_qproc = NULL`(找到就绪的了,后面的 fd 就不必再登记等待项,省事)。 +5. **没找到任何就绪 fd**:如果 `count` 为 0 且没信号、没超时,就调 **`poll_schedule_timeout(wait, TASK_INTERRUPTIBLE, ...)`**——把进程设成 `TASK_INTERRUPTIBLE` 睡下去,等定时器或唤醒。 +6. **被唤醒**:驱动的 `wake_up_interruptible(&my_wq)` 触发 `pollwake` → `triggered=1` → 进程被唤醒。 +7. **醒来重查**:`for(;;)` 循环回去再扫一遍所有 fd,这次某个 fd 就会返回 `POLLIN`,`count>0`,`break` 出循环,返回到用户态。 + +`select` 走的是 `do_select()`(同文件),逻辑几乎一样,只是用位图而非 `pollfd` 数组、用 `select_poll_one()` 而非 `do_pollfd()`。两套入口,一套精神。 + +## 和阻塞 read 的配合:同一个等待队列 + +这里有个新手最容易踩的坑:`.poll` 用的等待队列,和 `.read` 阻塞用的等待队列,**必须是同一个**。 + +为什么?因为 `.poll` 只是"登记+查状态",真正读数据还是 `read` 干。如果数据来时 `wake_up_interruptible` 喊的是 A 队列,而阻塞 `read` 把进程挂在 B 队列上,那 poll 能被叫醒,read 却睡死——两个机制各干各的,数据对不齐。 + +所以驱动的标准写法是:**一个设备一个等待队列**,`poll` 和阻塞 `read` 共用它: + +- `.poll`:`poll_wait(filp, &dev->wq, wait);` 然后返回掩码。 +- `.read`:缓冲区空时用 `wait_event_interruptible(&dev->wq, 有数据了)`(或手写 `prepare_to_wait` + `schedule`)把自己挂上去;数据来时中断里 `wake_up_interruptible(&dev->wq)`。 + +这样数据一来,喊一嗓子,poll 的等待者和阻塞 read 的等待者都被叫醒,各自重查状态——机制统一,不重复造轮子。 + +还有个搭配:`read` 要尊重 `O_NONBLOCK`。用户以非阻塞模式打开设备时,`read` 在没数据时应立刻返回 `-EAGAIN`,而不是傻睡。`filp->f_flags & O_NONBLOCK` 一测便知。poll 和非阻塞 read 是天生一对:poll 负责"等",read 负责"拿",互不阻塞。 + +## 小结 + +poll/select 的内核实现,核心就一条主线:**用户态一次盯多个 fd → 内核回调每个驱动的 `.poll` → `.poll` 里 `poll_wait` 把进程登记到驱动等待队列,并返回当前就绪掩码 → 全都没就绪就睡 → 驱动数据来时 `wake_up_interruptible` 叫醒 → 醒来重扫一遍 → 返回就绪列表**。 + +记住三个源码锚点:`poll_wait`(`include/linux/poll.h`,登记)、`__pollwait`/`pollwake`(`fs/select.c`,塞等待项与唤醒)、`do_poll` 的 `for(;;)` 循环(扫-睡-重扫)。再加一条纪律:`.poll` 和阻塞 `.read` 共用同一个 `wait_queue_head`,否则两边叫不齐。epoll 用户态接口虽不同,内核侧同样走 `vfs_poll` → 驱动 `.poll`,所以把 `.poll` 写对,三兄弟全受益。 + +## 延伸阅读 + +- 源码:`fs/select.c`(Linux 6.19),`do_sys_poll`/`do_poll`/`do_pollfd`/`__pollwait`/`pollwake` 全在这;`include/linux/poll.h` 看 `poll_wait`/`vfs_poll`/`poll_wqueues`;`include/linux/wait.h` 看等待队列与 `wait_event_*` 宏。 +- 内核文档:等待队列 API(`include/linux/wait.h` 与 `kernel/sched/wait.c` 的 kernel-doc)见 [Linux Driver Implementer's API Guide — Wait queues and events](https://docs.kernel.org/driver-api/basics.html);poll 相关数据结构对照源码 `include/linux/poll.h`。 +- 进一步(待亲测铺开):epoll 的红黑树+就绪链表实现(`fs/eventpoll.c`)、`fasync` 异步信号通知、驱动的中断顶半部与 `wake_up` 配合。 \ No newline at end of file diff --git a/document/tutorials/drivers/04-drv-mmap.md b/document/tutorials/drivers/04-drv-mmap.md new file mode 100644 index 00000000..9b240f78 --- /dev/null +++ b/document/tutorials/drivers/04-drv-mmap.md @@ -0,0 +1,204 @@ +--- +title: mmap:把设备内存搬进用户进程 +slug: drv-mmap +difficulty: intermediate +tags: [字符设备驱动, mmap, 设备内存映射, 页表] +architectures: [arm64, x86_64, riscv] +kernel_version: "6.19" +maturity: drafting +prerequisites: + - /tutorials/drivers/01-drv-chardev +related: + - /tutorials/drivers/01-drv-chardev +sources: + - notes: document/notes/linux_kernel_device_drivers/ch03_2.md +--- + +# mmap:把设备内存搬进用户进程 + +> 🔨 **整理中** · 本篇机制对照 Linux 6.19(v6.19.9)源码讲解(函数/数据结构已核对);具体行号与命令输出待 QEMU 亲测核对。 + +## 关于来源,先说句实话 + +本篇的核心——`remap_pfn_range`、`VM_REMAP_FLAGS`、`.mmap` 回调骨架——是对照 Linux 6.19 源码逐行研读补出来的,不是从某本现成笔记里抄的。能挂上钩的笔记只有 `linux_kernel_device_drivers/ch03_2.md`:它第 115 行拿 `mmap()` 系统调用打过一个比方("就像 `mmap()` 把内核内存映射给用户空间一样,`ioremap` 把外设 I/O 内存映射给内核空间"),提供了 `ioremap`/MMIO 的上下文,给本篇"内核映射 vs 用户映射"那节做了衔接。但**笔记里没有驱动 `.mmap` 回调的实现素材**,`remap_pfn_range` 那一整套是源码研读补全的。所以读完本篇,建议照着源码自己再核一遍,别把这篇当二手结论吞。 + +## 用户态要访问设备内存,凭什么还得搭一座桥 + +上一篇我们走完了字符设备的 `read`/`write`:用户进程发系统调用,内核拷一段缓冲区过去。这套机制对"传个数"完全够用,但一旦你想在用户态**直接啃设备的内存**——比如一帧帧的显示缓冲、一张网卡的环形接收队列——逐字节 `read` 就成了灾难:每取一个字节都得用户态陷进内核再弹回来,还要在内核缓冲区倒一次手,开销大得离谱。 + +真正高性能的做法是**别让内核搬运数据**:直接把设备的物理内存,原封不动地映射进用户进程的虚拟地址空间。之后用户态拿一个普通指针读写,CPU 自己走 MMU、走页表、直奔硬件,全程不用内核插手。这就是 `mmap` 干的事。 + +这里有个容易绕晕的点:`mmap` 这个词在两个地方出现。一个是用户态的 `mmap(2)` 系统调用,一个是驱动里实现的 `file_operations.mmap` 回调。用户态调 `mmap`,内核里最终会**调进驱动的 `.mmap`**——驱动负责告诉内核"这块虚拟地址该接哪段物理内存"。本篇讲的,就是驱动这个回调内部那几行代码背后,内核到底替你做了什么。 + +## 思路:把物理内存接进用户页表 + +`mmap` 的核心是**改页表**。进程地址空间由一堆 `vm_area_struct`(VMA)描述,每个 VMA 就是一段连续的虚拟地址区间。当用户态对这段地址发读写,MMU 查不到页表项,就缺页异常,正常路径下内核去分配物理页、填进页表。但设备内存不一样——它**本来就存在于物理总线上**(帧缓冲芯片、寄存器组),不需要内核去"分配",只需要把它的**物理地址**写进页表项,告诉 MMU"这块虚拟地址直接指过去"。 + +驱动的 `.mmap` 回调签名(Linux 6.19,`include/linux/fs.h:1932`): + +```c +int (*mmap) (struct file *, struct vm_area_struct *); +``` + +回调拿到的 `vma` 是内核已经建好的空壳——`vm_start`/`vm_end` 是用户态那段虚拟地址的边界,`vm_pgoff` 是用户态 `mmap(2)` 第六个参数(偏移,以页为单位)。驱动要做的就一件事:把 `vma` 这段虚拟地址,跟设备内存的物理页,用页表项连起来。 + +## remap_pfn_range:往用户页表里塞物理页号 + +连接虚拟地址和物理页的那个内核函数,叫 `remap_pfn_range()`,定义在 `mm/memory.c:3089`(Linux 6.19,行号待亲测核对): + +```c +int remap_pfn_range(struct vm_area_struct *vma, unsigned long addr, + unsigned long pfn, unsigned long size, pgprot_t prot); +``` + +- `vma`:用户传进来的那段虚拟区。 +- `addr`:要开始建映射的虚拟地址,通常是 `vma->vm_start`。 +- `pfn`:**物理页帧号**(page frame number)——这才是关键,见下一节。 +- `size`:映射多大(字节)。 +- `prot`:页保护位(可读、可写、可执行、是否共享),通常直接用 `vma->vm_page_prot`。 + +返回 0 成功,负数失败。它内部走的是标准的页表遍历——`remap_pfn_range_internal()`(`memory.c:2920`)从 `pgd` 一路 `remap_p4d_range → remap_pud_range → remap_pmd_range`,最底层落在 `remap_pte_range()`(`memory.c:2808`)。这个最底层的函数干的事简单粗暴,一个循环把每一页填进页表: + +```c +do { + BUG_ON(!pte_none(ptep_get(pte))); /* 这格页表必须是空的 */ + if (!pfn_modify_allowed(pfn, prot)) { err = -EACCES; break; } + set_pte_at(mm, addr, pte, pte_mkspecial(pfn_pte(pfn, prot))); /* 填页表项 */ + pfn++; +} while (pte++, addr += PAGE_SIZE, addr != end); +``` + +`pfn_pte(pfn, prot)` 把"页帧号 + 保护位"组装成一个 PTE,`pte_mkspecial()` 给它打个"special"标记——意思是这页**没有对应的 `struct page`**(设备内存本来就没有 page 描述符),内核的内存管理子系统见到这个标记就知道"别管我,我是设备内存"。`set_pte_at()` 真正把它写进进程页表。这一套干完,用户态那段虚拟地址就跟设备内存连上了。 + +## pfn vs 物理地址:差一个 PAGE_SHIFT,还要分清内存来源 + +注意 `remap_pfn_range` 要的不是物理地址,是 **pfn(页帧号)**。两者差一个页内偏移:`pfn = 物理地址 >> PAGE_SHIFT`(`PAGE_SHIFT` 在 4KB 页上是 12)。内核给了换算宏 `phys_to_pfn()`,反过来 `pfn_to_phys()`。 + +场景分两种,**来源不一样,算法完全不一样**,新手最爱在这里翻车: + +1. **映射内核里 `kmalloc`/`__get_free_page` 出来的普通内存**:你手里是内核虚拟地址,先 `virt_to_phys()`(arm64 定义在 `arch/arm64/include/asm/memory.h:362`,第 361 行那个 `#define virt_to_phys virt_to_phys` 只是个别名宏,函数体在下一行)拿物理地址,再 `>> PAGE_SHIFT` 拿页帧号,驱动里常写成 `virt_to_phys(kaddr) >> PAGE_SHIFT`。 +2. **映射真正的设备 I/O 内存**(寄存器、帧缓冲,衔接 ch03):你手里是 `ioremap` 之前的那个**物理/总线地址**,直接 `>> PAGE_SHIFT` 就是 pfn。注意 `ioremap` 返回的是**内核虚拟地址**,不是 pfn 的来源——别拿 `ioremap` 的返回值去算 pfn,那是南辕北辙。 + +场景 1 那个 `virt_to_phys(kmalloc_buf) >> PAGE_SHIFT` 看着顺手,但有个**可移植性大坑**:`virt_to_phys()` 只对**线性映射(lowmem)地址**成立。`kmalloc`/`__get_free_page` 在 arm64/x86_64 上返回的是线性映射地址,能用;但**如果你拿 `vmalloc` 分配大块内存**,返回的是 vmalloc 区地址,`virt_to_phys()` 公式根本不成立,算出来的 pfn 是错的,映射上去就是野指针。而且 arm64 默认常开 `CONFIG_DEBUG_VIRTUAL`,对 `kmalloc` 内存调 `__virt_to_phys` 会做 bounds 检查,用法不对直接告警甚至 panic。 + +所以这条老路能走,但要记牢:**`virt_to_phys()` 只认线性映射地址,vmalloc 区/高端内存地址一律不能这么算。** + +更关键的是——映射**一页内核 RAM** 时,内核现在更推荐用 `vm_insert_page()`(`memory.c:2470`)。它直接吃一个 `struct page *`,不用你手算 pfn,自动处理 page 描述符。源码里 `vm_insert_page` 的注释自己就说得很直白(`memory.c:2452`): + +```c + * NOTE! Traditionally this was done with "remap_pfn_range()" which + * took an arbitrary page protection parameter. This doesn't allow + * that. Your vma protection will have to be set up correctly ... +``` + +也就是说 `remap_pfn_range` 是"传统做法",`vm_insert_page` 是现代替代——它唯一的代价是不能像 `remap_pfn_range` 那样塞一个任意的 page protection,VMA 保护位得提前设好。归纳一下怎么选: + +| 映射什么 | 推荐函数 | +|:---|:---| +| 一页内核 RAM(有 `struct page`) | `vm_insert_page()`(吃 page,别手算 pfn) | +| 多页连续内核 RAM | `vm_insert_pages()` 或 `vm_insert_page` 循环 | +| 设备 I/O 内存(寄存器、帧缓冲,无 page) | `remap_pfn_range()`(只能走这条,带 `pgprot_noncached`) | + +驱动 `.mmap` 里映射**设备内存**的常见骨架(机制示意,非完整可跑代码): + +```c +static int my_mmap(struct file *file, struct vm_area_struct *vma) +{ + /* 设备 I/O 内存:拿到的是物理地址,>> PAGE_SHIFT 得 pfn;待亲测:用真实寄存器物理地址 */ + unsigned long pfn = dev_phys_addr >> PAGE_SHIFT; + vma->vm_page_prot = pgprot_noncached(vma->vm_page_prot); /* 设备内存要关缓存 */ + return remap_pfn_range(vma, vma->vm_start, pfn, + vma->vm_end - vma->vm_start, vma->vm_page_prot); +} +``` + +映射内核 RAM 的话就把 `remap_pfn_range` 那行换成 `vm_insert_page(vma, vma->vm_start, page)`,别再算 pfn。 + +## VM_IO / VM_PFNMAP:告诉内核"这是设备内存" + +`remap_pfn_range` 最关键的一步,藏在它的 prepare 阶段。`memory.c:3061` 的 `remap_pfn_range_prepare_vma()` 里有一行(`memory.c:3073`): + +```c +vm_flags_set(vma, VM_REMAP_FLAGS); +``` + +而 `VM_REMAP_FLAGS` 在 `include/linux/mm.h:561` 定义成一坨标志的合集: + +```c +#define VM_REMAP_FLAGS (VM_IO | VM_PFNMAP | VM_DONTEXPAND | VM_DONTDUMP) +``` + +也就是说,只要你调了 `remap_pfn_range`,内核**自动**给这段 VMA 打上这四个标志(定义在 `mm.h:414-430`)。每个标志都是一行潜台词: + +- **`VM_PFNMAP`**(`mm.h:414`):这段映射是"纯页帧号映射",**没有 `struct page`**。缺页处理、内存回收、`get_user_pages()` 看到 `VM_PFNMAP` 就走特殊路径——它不会去给这些"页"分配 page、不会换出、不会参与 LRU。`is_cow_mapping()`(`mm.h:1730`,判断 `(VM_SHARED|VM_MAYWRITE)==VM_MAYWRITE`)相关的写时复制逻辑也直接绕开。 +- **`VM_IO`**(`mm.h:418`):这是设备 I/O 内存。内核拿它当三道护身符——**直接拒绝 GUP**(`get_user_pages` 长期 pin 设备内存没意义,也容易出事);**不进 core dump**(`VM_IO` 的常规后果,避免 dump 时去读设备寄存器触发副作用);**不参与 swap**(设备内存换出去毫无意义)。 +- **`VM_DONTEXPAND`**(`mm.h:422`):禁止 `mremap` 扩张这段映射。 +- **`VM_DONTDUMP`**(`mm.h:430`):core dump 时跳过这段。 + +这里要说清楚 `VM_IO` 拒绝 GUP 这件事,**到底发生在哪**。真正动手拒绝的,是 `mm/gup.c:1207` 的 `check_vma_flags()`: + +```c +if (vm_flags & (VM_IO | VM_PFNMAP)) + return -EFAULT; +``` + +这是 `get_user_pages` 走的入口校验,`VM_IO` 和 `VM_PFNMAP` 任一命中就 `return -EFAULT`。`mm/memory.c:2249-2253` 那段**不是拒绝动作本身**——它只是 `vmf_can_call_write_fault()` 附近的一条注释,在 FSDAX/`VM_IO` 与 GUP 不兼容的上下文里点了一句"VM_IO is incompatible to GUP completely (see check_vma_flags)"。读者要找 GUP 的拒绝逻辑,得去 `gup.c` 的 `check_vma_flags`,别按行号在 `memory.c` 里翻。 + +一句话:这四个标志把"普通可换页的匿名内存"和"必须原样直通的设备内存"彻底隔开,内核看到它们就走旁路,绝不用那套针对 RAM 的常规套路去折腾设备。`remap_pfn_range` 之所以"安全",正是因为它顺手把这些标志钉死了,驱动**不需要**、也**不该**自己去 `vm_flags_set` 这些位。 + +### 题外话:vmf_insert_pfn 那条路上的另一道防线 + +上面说 `VM_PFNMAP` 让 COW 逻辑绕开,要补一句——`mm/memory.c:2663` 确实有一行兜底: + +```c +BUG_ON((vma->vm_flags & VM_PFNMAP) && is_cow_mapping(vma->vm_flags)); +``` + +但这行**不在 `remap_pfn_range` 这条路上**,它在 `vmf_insert_pfn_prot()`(`memory.c:2651`,底层走 `insert_pfn()`,`memory.c:2567`)里——那是**按需建页表**(page fault 时填一页)的另一套机制。两条路各自把关:`remap_pfn_range` 一次性建表,靠 `VM_REMAP_FLAGS`(含 `VM_IO`,让 `is_cow_mapping` 判为非 COW)规避 COW;`vmf_insert_pfn*` 按需建表,靠这条 `BUG_ON` 兜底。别把它们混成一处。 + +## 内核映射 vs 用户映射:ioremap 和 mmap 不是一回事 + +这是最常被新手搞混的一对,把它们摆在一起说清楚(衔接 ch03 笔记): + +| | `ioremap()` | `.mmap` + `remap_pfn_range` | +|:---|:---|:---| +| 谁能用 | **内核态**(驱动代码) | **用户态**(进程) | +| 映射到哪 | 内核虚拟地址空间(vmalloc 区,`0xFFFF...` 开头) | 进程自己的虚拟地址空间 | +| 目标 | 设备 I/O 内存的物理地址 | 设备 I/O 内存的 pfn,或内核 RAM 的 page(`vm_insert_page` 更推荐) | +| 怎么读写 | `ioread32()`/`iowrite32()`(不能解引用) | 普通 C 指针解引用(`*p = ...`) | + +ch03 里 `ioremap` 是**把设备内存映射给内核自己**用——驱动拿着那个 `void __iomem *`,用带屏障的 `ioread`/`iowrite` 谨慎访问。而 `mmap` 是**把设备内存映射给用户进程**——这回用户态拿到的是普通指针,可以直接 `*p` 读写,因为映射建立时页表项已经标好了"直通物理地址"。 + +注意表里那栏"目标":`mmap` 两类都能映射——设备 I/O 内存的 pfn(这时它和 `ioremap` 接的是同一段物理内存,这就是两者的衔接点),以及内核 RAM 的 page(这时优先用 `vm_insert_page`)。新手别看完表误以为 `mmap` 只能映射 kmalloc 内存。 + +典型链路:驱动先 `ioremap`(内核态用 `ioread32` 配置寄存器、初始化硬件),同时 `mmap` 把同一段设备内存的 pfn 暴露给用户态(用户态直接读写帧缓冲的像素数据)。两者映射的是**同一段物理内存**,只是接到了两个不同的虚拟地址空间。这层关系理顺了,ch03 和本篇就接上了。 + +这里有个真实但没讲透的坑:把 `ioremap` 那段设备物理地址直接给 `mmap` 用时,**用户态拿到的页保护必须和设备要求一致**,否则映射即使成功,读到的也是脏数据。寄存器要用 `pgprot_noncached()`(强序、禁缓存),某些帧缓冲可能要 write-combine(`pgprot_writecombine()`)。这套保护位得在 `remap_pfn_range` **之前**设到 `vma->vm_page_prot`——因为 `remap_pfn_range` 默认用 `vma->vm_page_prot` 去填 PTE,你不在它前面把缓存属性改好,它就把带缓存的默认值填进去了,结果就是用户态写进去的值没真正到硬件、读回来的还是缓存里的旧值。 + +## 动手验证方案(待亲测) + +> ⚠️ **待亲测**:下面是验证思路,命令输出和最终代码待 QEMU 亲测后填实。 + +最小验证目标:写一个字符设备驱动,在 `init` 里 `__get_free_page`(或 `alloc_page`)一页内核内存并填上特征值;实现 `.mmap`,用 `vm_insert_page`(RAM 页,更现代)或 `remap_pfn_range`(I/O 内存)把它映射出去;用户态 `mmap(2)` 后用指针读,应看到内核填的值,再写回一个值、内核读出来确认双向通。 + +验证点 checklist: + +- [ ] 用户态读到内核预设的魔数 → 映射建立成功。 +- [ ] 用户态写入后,内核侧读到 → 双向连通。 +- [ ] `cat /proc//smaps` 看这段 VMA,确认打上了 `io`/`pfnmap` 等标志(印证 `VM_IO`/`VM_PFNMAP`)。 +- [ ] 多架构编译:参照 `example/common/Makefile.arch`,arm64/x86_64/riscv 三套都过。 + +踩坑预警:映射设备寄存器时**务必用 `pgprot_noncached()` 关掉缓存**(普通 RAM 不用),而且要在调 `remap_pfn_range` **之前**设好 `vma->vm_page_prot`——否则 CPU 缓存会让你的写操作"消失":写进去的值没真正到硬件,读回来的还是缓存里的旧值。这块等亲测时重点记。 + +## 小结 + +`mmap` 把设备内存直接搬进用户进程,绕开了 `read`/`write` 的逐字节搬运。驱动的 `.mmap` 回调调 `remap_pfn_range()`(`mm/memory.c:3089`),它把一串物理页帧号逐页 `set_pte_at` 进用户页表,并自动给 VMA 打上 `VM_REMAP_FLAGS`(`VM_IO|VM_PFNMAP|VM_DONTEXPAND|VM_DONTDUMP`)——这套标志把设备内存和普通 RAM 彻底隔开,禁止换页、禁止 core dump、拒绝 GUP(拒绝动作在 `mm/gup.c:1207` 的 `check_vfa_flags`,不是 `memory.c` 里那条注释)。映射内核 RAM 单页时优先用 `vm_insert_page()`(吃 page、不用算 pfn),`remap_pfn_range` 留给设备 I/O 内存。要分清:`ioremap` 是内核态映射设备内存(配 `ioread`/`iowrite`),`mmap` 是把同样的物理内存暴露给用户态(用普通指针)——两者常在同一个驱动里配合出现,但映射设备寄存器时务必先 `pgprot_noncached()` 设好 `vma->vm_page_prot`。 + +## 延伸阅读 + +- 源码:`mm/memory.c`(Linux 6.19)——`remap_pfn_range()`(`3089`)及其内部 `remap_pfn_range_internal`(`2920`)/`remap_pte_range`(`2808`)、`remap_pfn_range_prepare_vma`(`3061`)、`vm_insert_page()`(`2470`,注释 `2452` 点名它是 `remap_pfn_range` 的现代替代)。 +- 源码:`mm/gup.c:1207`——`check_vma_flags()` 里 `VM_IO|VM_PFNMAP` 拒绝 GUP 的真实发生地(`mm/memory.c:2249-2253` 只是条点名的注释,不是拒绝动作)。 +- 源码:`include/linux/mm.h`——`VM_REMAP_FLAGS`(`561`)、`VM_IO`/`VM_PFNMAP`/`VM_DONTEXPAND`/`VM_DONTDUMP`(`414-430`)、`is_cow_mapping()`(`1730`)。 +- 源码:`arch/arm64/include/asm/memory.h:362`——`virt_to_phys()` 函数体(361 是别名宏)。 +- 内核源码注释:`mm/memory.c` 中 `vmf_insert_pfn_prot()`(`2651`)及其 `BUG_ON(... VM_PFNMAP && is_cow_mapping ...)`(`2663`),按需建表那条路上对 COW 的兜底,与 `remap_pfn_range` 一次性建表分属两条路。 +- 进一步(持续铺开):`vmf_insert_pfn` 按需建页表 vs `remap_pfn_range` 一次性建表;`fault` 回调与写时复制;DMA 一致性与 `pgprot_noncached`/`pgprot_writecombine`。 \ No newline at end of file diff --git a/document/tutorials/drivers/05-drv-irq.md b/document/tutorials/drivers/05-drv-irq.md new file mode 100644 index 00000000..9ff7aa13 --- /dev/null +++ b/document/tutorials/drivers/05-drv-irq.md @@ -0,0 +1,204 @@ +--- +title: 硬件中断:设备怎么打断 CPU +slug: drv-irq +difficulty: intermediate +tags: [中断, 硬件中断, 线程化中断, 驱动框架] +architectures: [arm64, x86_64, riscv] +kernel_version: "6.19" +maturity: drafting +prerequisites: + - /tutorials/foundations/07-kernel-module-hello +related: + - /tutorials/drivers/01-drv-chardev +sources: + - notes: document/notes/linux_kernel_device_drivers/ch04.md + - notes: document/notes/linux_kernel_device_drivers/ch04_1.md + - notes: document/notes/linux_kernel_device_drivers/ch04_2.md + - notes: document/notes/linux_kernel_device_drivers/ch04_3.md +--- + +# 硬件中断:设备怎么打断 CPU + +> 🔨 **整理中** · 这篇把硬件中断从「电信号 → CPU → 内核 → 驱动 ISR」这条链路,对着 Linux 6.19 源码讲透了(函数 / 数据结构已核对);具体行号与 `/proc/interrupts` 命令输出待 QEMU 亲测核对。 + +## 中断到底是怎么打断 CPU 的 + +写用户态程序时,CPU 仿佛永远在老老实实跑我们的代码。其实它每执行完一条指令,硬件都会偷偷瞄一眼中断引脚——一旦有信号,立刻保存现场、跳到内核预设的入口。这股「最高优先级」的力量,就是硬件中断。 + +先记下这条物理路径,它是后面一切机制的根: + +1. **外设拉线**:网卡收到一个包,在它连接到中断控制器的物理线上拉高(或拉低)电压。 +2. **中断控制器汇总**:x86 上是 IO-APIC,ARM 上是 GIC——叫它 PIC(可编程中断控制器)。它把信号暂存进寄存器,再拉高通往 CPU 的中断引脚。 +3. **CPU 捕获**:CPU 检测到引脚信号,硬件自动保存现场、跳进内核的低级入口(ARM 上常是 `asm_do_IRQ` 之类)。CPU 不知道「网卡」「键盘」是什么,它只知道「第 24 号中断线触发了」——这个号叫 **IRQ**(Interrupt ReQuest),是硬件中断的身份证号。 +4. **通用 IRQ 层分发**:内核拿到 IRQ 号,查中断描述符数组里这个号挂的处理函数链,逐个调用。 +5. **驱动 ISR 执行**:我们注册的处理函数被叫醒。 + +为了屏蔽「PIC 各家操作方式天差地别」这件事,内核专门有 **Generic IRQ 处理层**。它就像一个适配器:上层驱动只管调标准 API「给我分配 24 号中断」,这层负责翻译成给具体 PIC 的指令,还顺手处理共享中断、屏蔽重入这些麻烦事。这样我们写的驱动代码能在 x86、ARM、RISC-V 上编译运行,一行不用改。 + +## 注册中断:把自己的函数挂到 IRQ 上 + +要收中断,得先向内核「预订」这个号。这条路最终都收敛到一个函数上——`request_threaded_irq()`(Linux 6.19,`kernel/irq/manage.c:2100`): + +```c +int request_threaded_irq(unsigned int irq, irq_handler_t handler, + irq_handler_t thread_fn, unsigned long irqflags, + const char *devname, void *dev_id); +``` + +我们平时更熟的 `request_irq()` 其实只是它的一层薄包装,定义在 `include/linux/interrupt.h:173`:把 `thread_fn` 填 `NULL`,自动补一个 `IRQF_COND_ONESHOT` 标志,然后原样转发: + +```c +static inline int __must_check +request_irq(unsigned int irq, irq_handler_t handler, unsigned long flags, + const char *name, void *dev) +{ + return request_threaded_irq(irq, handler, NULL, flags | IRQF_COND_ONESHOT, name, dev); +} +``` + +参数逐个看(`manage.c:2073` 的注释讲得很直白): + +- **`irq`**:IRQ 号。嵌入式上来自设备树或 `platform_get_irq()`,PCI 设备来自 `pci_dev->irq`,别硬编码。 +- **`handler`**:主处理函数,跑在硬中断上下文。传 `NULL` 的话,内核给你装个默认的(`manage.c:988` 的 `irq_default_primary_handler`),它就干一件事——返回 `IRQ_WAKE_THREAD`。 +- **`thread_fn`**:线程化处理函数,跑在内核线程上下文(见后文)。传 `NULL` 表示不线程化。 +- **`irqflags`**:行为标志位,待会儿细讲。 +- **`devname`**:字符串名字,会出现在 `/proc/interrupts` 里,调试时你会感谢起个好名字的自己。 +- **`dev_id`**:私有数据指针,中断发生时原样传回 handler。**共享中断(`IRQF_SHARED`)时必须非空**——否则释放时内核分不清该摘掉链表上哪个节点。注释里 `manage.c:2087` 原话:"@dev_id must be globally unique"。 + +返回值加 `__must_check`:0 成功,负数失败(`-EBUSY` 表示线被人占了且不让共享,`-EINVAL` 多半是标志组合不合法)。 + +内核注册时做了一道硬性校验(`manage.c:2124`):`IRQF_SHARED` 配了却没给 `dev_id`、或者共享中断还设了 `IRQF_NO_AUTOEN`,直接 `-EINVAL` 打回。然后 `kzalloc` 一个 `struct irqaction`,把我们的 handler/flags/name/dev_id 填进去,交给 `__setup_irq()` 挂到 `irq_desc` 的 action 链表上。 + +## irqaction 与共享中断的链表 + +为什么是「链表」?因为一根 IRQ 线可能被多个设备共享(PCI 的老传统)。每个设备的注册信息是一个 `struct irqaction`(`include/linux/interrupt.h:123`),关键字段: + +```c +struct irqaction { + irq_handler_t handler; // 主处理函数 + void *dev_id; // 私有数据 cookie + struct irqaction *next; // 链到下一个 action(共享时) + irq_handler_t thread_fn; // 线程化处理函数 + struct task_struct *thread; // 线程化时对应的内核线程 + unsigned int irq; + unsigned int flags; // IRQF_* 标志 + const char *name; + ... +}; +``` + +那个 `next` 指针就是共享中断的关键:共享同一根 IRQ 线的多个 action 串成链。当中断真的发生,内核不知道是哪个设备拉的线,就把链上每个 handler 都调一遍——**轮到每个驱动自己读硬件寄存器,判断「是不是我的设备」**,不是就返回 `IRQ_NONE`。 + +## ISR 的铁律:不能睡,要快 + +handler 跑在**中断上下文**——一个比进程上下文严苛得多的环境。核心铁律只有一条,但要刻进骨头里: + +> **在中断处理程序里,绝不能睡眠。** + +不能调 `mutex_lock()`,不能 `kmalloc(GFP_KERNEL)`(必须 `GFP_ATOMIC`),不能 `copy_to_user()`(可能触发缺页睡眠)。理由很简单:中断上下文**不属于任何进程**,调度器根本没法把你「挂起再唤醒」——你一旦睡,系统就死在那儿。内核配了 `CONFIG_DEBUG_ATOMIC_SLEEP` 的话,`might_sleep()` 会吐一条 `WARNING` 并 `dump_stack()`(底层走 `__might_sleep()`,`kernel/sched/core.c:8751`)——只是警告加打印调用栈,不会 oops、也不会 panic,但看到这栈你该知道自己踩雷了。 + +handler 的标准长相: + +```c +static irqreturn_t my_isr(int irq, void *dev_id) +{ + /* 1. 读寄存器,确认是不是本设备触发 */ + /* 2. 清掉硬件的 interrupt pending 位(否则电平触发会风暴) */ + /* 3. 只做最紧急的活,重活推给下半部 */ + return IRQ_HANDLED; /* 或 IRQ_NONE / IRQ_WAKE_THREAD */ +} +``` + +返回值 `irqreturn_t`:`IRQ_HANDLED`(处理了)、`IRQ_NONE`(不是我的,共享中断里常见)、`IRQ_WAKE_THREAD`(唤醒线程化 handler,见下)。注意 `IRQ_NONE` 不能滥用——持续返回它会被内核的虚假中断逻辑(`note_interrupt()`,在 `kernel/irq/spurious.c`)记成 spurious,超过阈值就可能自动把这条 IRQ 线禁掉,得排查清楚再返回。 + +## 标志位:IRQF_ 家族 + +`irqflags` 控制中断行为,定义在 `include/linux/interrupt.h`: + +- **`IRQF_SHARED`**(`0x00000080`):允许多个设备共用一根 IRQ 线。配它就必须给非空 `dev_id`。PCI 设备的标配。 +- **`IRQF_TRIGGER_RISING/FALLING/HIGH/LOW`**(`0x1/0x2/0x4/0x8`):电气触发方式。上升沿/下降沿是边沿触发,高/低电平是电平触发。 +- **`IRQF_ONESHOT`**(`0x00002000`):线程化中断专用。hardirq 跑完**不立即重开这条 IRQ 线**,保持屏蔽直到 thread_fn 跑完。电平触发中断不加它就完蛋——电平一直高着,thread 还没跑完就被新中断淹死。 +- **`IRQF_NO_THREAD`**、`IRQF_NO_AUTOEN`、`IRQF_TIMER` 等:按需翻头文件。 + +边沿 vs 电平触发的坑:电平触发必须 handler 里把电平拉下去(ack 硬件),否则 handler 一返回立刻又被触发,形成中断风暴;边沿触发只在跳变瞬间响一次,相对省心但高负载下可能漏脉冲。设备树或 BSP 通常已经配好触发方式,驱动里很少手动设。 + +## 上半部 / 下半部:拆开「急活」和「重活」 + +ISR 有两个天敌:**太慢会丢后续中断,太原子干不了重活**。Linux 的解法是分治——**上半部(Top Half)** 抢时间、关中断里干最紧急的确认和 ack;**下半部(Bottom Half)** 把不急的重活延后到开中断的环境里慢慢做。 + +下半部的实现有好几代,按「能干多重的活」排: + +- **softirq**:编译期静态注册的向量(`HI_SOFTIRQ`、`NET_RX_SOFTIRQ` 等,见 `interrupt.h:562` 的枚举)。驱动不能动态注册新类型。最底层、最快,但没并发保护,容易踩 SMP 竞态。 +- **tasklet**:建在 softirq 之上的动态机制,保证「同一个 tasklet 不会同时在两个 CPU 上跑」,简化了锁。**注意它已被标记 deprecated**(`interrupt.h:680` 头注),官方建议改用线程化中断。 +- **workqueue**:把活儿丢给内核线程跑,**可以睡眠**,适合要分配大内存、要持 mutex 的重活。 +- **线程化中断**:见下节,本质是把下半部这件事做到极致。 + +softirq 疯起来会饿死用户进程——于是内核搞了 `ksoftirqd` 内核线程,软中断过载时把活儿甩给它当普通进程调度。`/proc/softirqs` 能看到各类软中断的计数。 + +## 线程化中断:让 ISR 也能睡觉 + +「不能睡眠」这条规矩对某些硬件太憋屈——比如要走 I2C 慢总线读数据的传感器,或者要持 mutex 访问临界区的设备。`request_threaded_irq()` 把处理拆成两段: + +1. **Primary handler(hardirq)**:跑在硬中断上下文,只做最紧急的确认/屏蔽,然后返回 `IRQ_WAKE_THREAD`。 +2. **Threaded handler(`thread_fn`)**:跑在专门的内核线程里,**可以睡眠、持 mutex、做所有进程上下文能做的事**。 + +线程怎么来的?`__setup_irq()` 里调 `setup_irq_thread()`(`manage.c:1391`),用 `kthread_create(irq_thread, new, "irq/%d-%s", irq, name)` 造一个内核线程,名字就是 `irq/24-eth0` 这种(见 `manage.c:1396`)。这线程平时睡在 `irq_wait_for_interrupt()`(`manage.c:1043`)里等被叫醒。顺带一提,如果驱动还配了 secondary 线程,第二个线程名字会带个 `-s-`(`irq/%d-s-%s`,`manage.c:1399`),用来跑 force-threaded 场景下的「被强制线程化的原 hardirq」。 + +线程的调度策略在线程一启动就定了:`irq_thread()` 里对主线程调 `sched_set_fifo(current)`、对 secondary 线程调 `sched_set_fifo_secondary(current)`(都在 `manage.c:1244` 附近)。这两个函数都在 `kernel/sched/syscalls.c:812`,把线程设成 `SCHED_FIFO` 实时策略,主线程优先级 `MAX_RT_PRIO / 2`(即 50,`MAX_RT_PRIO` 在 `include/linux/sched/prio.h:16` 定义为 100),secondary 线程低一档 `MAX_RT_PRIO / 2 - 1`(49)。 + +唤醒的链路在 `kernel/irq/handle.c:185` 的 `__handle_irq_event_percpu()`——遍历 action 链调每个 handler,拿到返回值后 `switch`: + +```c +res = action->handler(irq, action->dev_id); +... +switch (res) { +case IRQ_WAKE_THREAD: + if (unlikely(!action->thread_fn)) { warn_no_thread(irq, action); break; } + __irq_wake_thread(desc, action); /* 唤醒对应内核线程跑 thread_fn */ + break; +default: + break; +} +``` + +这就是「handler 返回 `IRQ_WAKE_THREAD` → 内核唤醒 irq 线程 → thread_fn 跑」的源码真相。`handle.c:216` 还有个贴心检查:handler 跑完发现中断居然被开了(`!irqs_disabled()`),直接 `WARN_ONCE` 并帮你关回去——别让你的 ISR 偷偷开中断。 + +线程化中断另一个深层好处是**优先级可控**:普通硬中断优先级压倒一切用户进程,但线程化后中断变成了一个 `SCHED_FIFO` 实时调度实体(默认 prio ≈ 50)。于是一个优先级更高的实时任务(比如 `SCHED_FIFO` prio 60)能抢占它,系统设计者得以把「中断处理」和「关键实时任务」排出明确的高低。这正是 PREEMPT_RT 的核心逻辑之一——在 RT 内核里,绝大多数硬中断被强制线程化,可控的优先级就是它「可预测延迟」的根基。 + +## 释放、启用与禁用 IRQ + +用完要还:`free_irq(irq, dev_id)`(`manage.c:1989`,签名是 `const void *free_irq(...)`)。它调 `__free_irq()` 从 action 链上摘掉匹配 `dev_id` 的节点、`kfree(action)`,并**等待当前正在跑的 handler 跑完**才返回(内部正是调 `__synchronize_irq()`,`manage.c:108`)。返回值是被摘下那个 action 的 name(即当初注册时的 `devname`),无可释放则返回 `NULL`——释放后判个空,能确认自己确实摘对了节点。共享中断务必先在硬件层禁用这条线再 `free_irq`,否则释放途中又来中断、handler 却已解绑,系统会懵。 + +光释放不够时,还得会「临时掐断再恢复」。常用的几个 API 都在 `kernel/irq/manage.c`,作用域差很多,别混用: + +- **`disable_irq(irq)`**(`manage.c:710`):同步禁用——标记这条线不响应,**并等待当前正在跑的 handler 结束**才返回。所以它本身可能睡眠,**绝不能在持有自旋锁或中断上下文里调**,否则死锁。 +- **`disable_irq_nosync(irq)`**(`manage.c:690`):异步禁用——只标记,不等 handler。中断上下文里要禁用就用它,但代价是你返回后 handler 可能还在别的 CPU 上跑完。 +- **`enable_irq(irq)`**(`manage.c:800`):和上面配对,重新开线。开之前必须保证 `disable` 调了几次、`enable` 就配几次,内核对这对操作有计数。 +- **`synchronize_irq(irq)`**(`manage.c:133`):不改变使能状态,纯粹「等到这条 IRQ 上所有 pending 的 handler 跑完」。换 buffer、卸 handler 前调它最稳。 +- **`local_irq_disable()` / `local_irq_enable()`**(`include/linux/irqflags.h`):这是宏,关/开**当前 CPU 的全部中断**,粒度最粗。只能极短临界区用,关太久直接拖垮实时性。 + +这套 API 跟 `free_irq` 的「等 handler 跑完」是同一条逻辑线:只要你想动一条正在服务的中断,都得先确保没人还在它的 handler 里。 + +现代驱动更推荐托管版 `devm_request_irq()` / `devm_request_threaded_irq()`:多传一个 `struct device *`,内核记录归属,设备移除时自动 `free_irq`,省去手动配对的麻烦。 + +怎么验证中断真的来了?看 `/proc/interrupts`——每行一个 IRQ,列依次是 IRQ 号、各 CPU 核上的触发计数、中断控制器类型、触发方式、设备名。怀疑中断没触发,先来这儿看计数是不是 0:是 0 就说明硬件压根没拉线,或者 IRQ 号/触发方式没配对。 + +## 动手验证方案(待 QEMU 亲测) + +> ⚠️ 以下为验证方案,具体代码与命令输出待我们在 QEMU 上跑通后回填。 + +1. **环境**:用 `scripts/qemu-run.sh` 起一个带 virtio-gpio 或简易 platform 设备的 ARM64 虚拟机;内核开 `CONFIG_DEBUG_ATOMIC_SLEEP`、`CONFIG_GENERIC_IRQ_DEBUGFS`。 +2. **注册一个虚拟中断**:写一个 platform 驱动,`probe` 里用 `devm_request_irq()` 注册一段 IRQ,handler 只做计数自增 + 返回 `IRQ_HANDLED`,再挂一个 tasklet 或 workqueue 打印一行日志当下半部对比。 +3. **触发与观察**:通过 sysfs/gpio 或 `irq_inject_interrupt()`(`interrupt.h:262`)注入一次中断,`cat /proc/interrupts` 看对应 IRQ 计数是否 +1,`dmesg` 看 ISR 与下半部的执行先后与上下文(用 `in_irq()`/`in_task()` 打印)。 +4. **线程化对比**:把同一套逻辑换成 `devm_request_threaded_irq()` + `IRQF_ONESHOT`,在 thread_fn 里 `msleep(10)`——观察它居然不 panic,且 thread_fn 跑在进程上下文(`in_task()` 为真)。这正是线程化中断能睡眠的铁证。 + +## 小结 + +硬件中断是 CPU 能被打断的根:电信号 → PIC → CPU 跳异常向量 → 通用 IRQ 层查 `irq_desc` → 调我们挂的 `irqaction`。注册走 `request_irq()`(实为 `request_threaded_irq` 的薄包装),handler 跑在中断上下文里**绝不能睡眠、要尽快返回**,重活交给下半部(softirq/tasklet/workqueue)。慢活、要睡眠的活就用 `request_threaded_irq()` 丢给专门的 irq 内核线程(默认 `SCHED_FIFO` prio 50),配 `IRQF_ONESHOT` 防电平风暴。要临时掐断用 `disable_irq(_nosync)`/`enable_irq`,要彻底收摊用 `free_irq`。`/proc/interrupts` 是验证中断到没到的第一现场。 + +## 延伸阅读 + +- 源码:`kernel/irq/manage.c`(Linux 6.19),`request_threaded_irq` / `free_irq` / `disable_irq` / `enable_irq` / `__setup_irq`;`kernel/irq/handle.c` 的 `__handle_irq_event_percpu` 看分发链路;`kernel/irq/spurious.c` 的 `note_interrupt` 看虚假中断判定。 +- 头文件:`include/linux/interrupt.h`,`struct irqaction`、`IRQF_*` 标志、softirq/tasklet 定义全在这儿;`include/linux/sched/prio.h` 的 `MAX_RT_PRIO`;`kernel/sched/syscalls.c:812` 的 `sched_set_fifo` 看 IRQ 线程优先级。 +- kernel.org:[核心 API 文档索引](https://docs.kernel.org/core-api/index.html)、[驱动 API 文档索引](https://docs.kernel.org/driver-api/index.html)。 +- 进一步(持续铺开):softirq/tasklet 工作流、workqueue、IRQ affinity 与 smp_affinity、PREEMPT_RT 的强制线程化。 \ No newline at end of file diff --git a/document/tutorials/drivers/06-drv-clk.md b/document/tutorials/drivers/06-drv-clk.md new file mode 100644 index 00000000..7f633f25 --- /dev/null +++ b/document/tutorials/drivers/06-drv-clk.md @@ -0,0 +1,121 @@ +--- +title: 时间与延迟:内核怎么"等" +slug: drv-clk +difficulty: intermediate +tags: [时间管理, 延迟, hrtimer, jiffies] +architectures: [arm64, x86_64, riscv] +kernel_version: "6.19" +maturity: drafting +prerequisites: + - /tutorials/foundations/07-kernel-module-hello +related: + - /tutorials/drivers/05-drv-irq +sources: + - notes: document/notes/linux_kernel_device_drivers/ch05.md + - notes: document/notes/linux_kernel_device_drivers/ch05_1.md + - notes: document/notes/linux_kernel_device_drivers/ch05_2.md +--- + +# 时间与延迟:内核怎么"等" + +> 🔨 **整理中** · 本篇机制对照 Linux 6.19 源码讲解(函数/数据结构已核对);具体行号与命令输出待 QEMU 亲测核对。 + +## 驱动为什么要"等" + +写驱动时,"等一会儿"是高频需求,归纳起来就三类:**等硬件就绪**(写完命令寄存器,手册要求至少等 5µs 才能读状态)、**周期任务**(每 200ms 采一次传感器)、**超时检测**(发出去的请求 100ms 内没回包就算失败)。用户空间这些事一个 `sleep(1)` 搞定,进程一睡 CPU 就让给别人;可一进内核,"等"立刻分裂成两种截然不同的姿势,选错了不是性能差,是直接死锁。 + +## 两种延迟的本质:忙等待 vs 休眠 + +内核对"等"的 API 是按**你能不能让 CPU 调度出去**来分家的,这是生死分界线,不是风格问题。 + +- **忙等待(busy-wait)**:CPU 原地空转数 cycle,**不发生 `schedule()`**。`*delay()` 系列(`udelay`/`ndelay`/`mdelay`)。打个比方,就像你在 ATM 前死盯着屏幕,后面的人谁都别想动——CPU 被你一个人霸占。 +- **休眠(sleep)**:把当前进程状态改成 `TASK_INTERRUPTIBLE`/`TASK_UNINTERRUPTIBLE`,调 `schedule()` 让出 CPU,丢进等待队列,到点再被唤醒。`*sleep()` 系列(`msleep`/`usleep_range`/`ssleep`)。这回是拿号坐椅子上玩手机,柜台让给别人,叫号了再回去。 + +## 为什么不能任意睡:原子上下文的死结 + +休眠的代价是**必须能调度**,而调度的前提是当前在**进程上下文**。一旦你处在这几种"原子上下文"里——硬中断、软中断(含 `TIMER_SOFTIRQ`、tasklet)、或者**手里攥着自旋锁**的临界区——`schedule()` 就是禁区。 + +为什么自旋锁里睡会死锁?自旋锁的语义是"别人想拿这把锁就原地自旋等我放"。你拿着锁睡着了,那个等锁的家伙大概率在别的 CPU 上空转——如果它恰好是个内核核心调度路径上的线程,整个系统就僵住了。所以铁律:**原子上下文只能 `*delay()`,进程上下文才许 `*sleep()`**。内核贴心地埋了 `might_sleep()` 钩子,一旦你在原子上下文踩进会睡眠的代码,会甩一脸堆栈帮你抓虫。 + +## 忙等待 API:udelay / ndelay / mdelay + +三个精度档,核心实现在 `` 与 ``(Linux 6.19): + +| API | 单位 | 备注 | +|:---|:---|:---| +| `ndelay(nsecs)` | 纳秒 | 内部 `DIV_ROUND_UP(x,1000)` 后转 `udelay` | +| `udelay(usecs)` | 微秒 | 这一族的核心 | +| `mdelay(msecs)` | 毫秒 | 宏,大延迟时循环调 `udelay(1000)` | + +`udelay` 是这一族的**核心**:`ndelay` 内部 `DIV_ROUND_UP` 后转 `udelay`,`mdelay` 是个宏循环调 `udelay`;真正**架构相关**的是 `udelay` 背后的 `__const_udelay()` / `__delay()`——后者就是个紧凑的空循环(x86 在 `arch/x86/lib/delay.c`)。换句话说,被别人复用的底层入口是 `udelay`,而不是说它有独立的汇编实现而另两个没有。 + +关键是它们**怎么算准时间**。CPU 频率会变,空循环跑多少圈才等于 1µs?答案是启动时校准出来的 `loops_per_jiffy`(`init/calibrate.c` 的 `calibrate_delay()`,换算成 BogoMIPS)。`udelay()` 的通用实现(`include/asm-generic/delay.h`)本质:把常数 µs 折算成 xloops,交给架构相关的 `__const_udelay()` / `__delay()`。 + +两个坑:**别用 `udelay(30*1000)` 代替 `mdelay(30)`**——`delay.h` 注释明说 `loops_per_jiffy` 高的机器上几毫秒的 `udelay` 可能溢出,`mdelay` 的宏(`MAX_UDELAY_MS` 通常 5)就是为了防这个;**别在原子上下文里 `mdelay` 秒级等待**,那是把 CPU 拴死空转,纯烧电。 + +## 休眠 API:msleep / usleep_range / schedule_timeout + +进程上下文专用,核心是"设个闹钟 + `schedule()`"。源码在 `kernel/time/sleep_timeout.c`,一目了然。 + +`msleep(unsigned int msecs)`(第 313 行)的真身就三行: + +```c +void msleep(unsigned int msecs) +{ + unsigned long timeout = msecs_to_jiffies(msecs); + while (timeout) + timeout = schedule_timeout_uninterruptible(timeout); +} +``` + +而 `schedule_timeout_uninterruptible()` 干的事是 `__set_current_state(TASK_UNINTERRUPTIBLE)` 然后 `schedule_timeout()`。`schedule_timeout()`(第 61 行)的机理值得逐字看:它在**栈上**建一个 `struct process_timer`(内嵌 `timer_list`),把过期时间设成 `timeout + jiffies`,`add_timer()` 挂上,然后 `schedule()` 让出 CPU;闹钟回调 `process_timeout()` 调 `wake_up_process()` 把你摇醒,醒来再 `timer_delete_sync()` 收拾掉栈上定时器。所以 `msleep` 是**基于 jiffies/timer wheel** 的,精度受 `HZ` 限制(timer wheel 还允许最多 12.5% 的 slack,文档里写得很清楚)。 + +`msleep_interruptible()`(第 334 行)状态改成 `TASK_INTERRUPTIBLE`,可被信号打断,返回**剩余毫秒数**——符合 UNIX"提供机制不给策略"的哲学,需要响应 `Ctrl+C` 的驱动该用它。 + +`usleep_range(min, max)` 走的是另一条路——**hrtimer**。它的实现 `usleep_range_state()`(第 362 行)算出绝对过期时间 `exp = ktime_get() + min`,设 `delta = (max-min)` 纳秒的 slack,然后 `schedule_hrtimeout_range(&exp, delta, HRTIMER_MODE_ABS)`。**给个范围**不是矫情:这让内核能把多个 hrtimer 合并到同一个中断里唤醒,少打扰 CPU 的深度省电状态(C-states)。`checkpatch` 见到 `usleep_range(x, x)`(min==max)甚至会发 WARNING,让你留点余量。 + +经验法则(与笔记 ch05_1 对齐):`≤10µs` 用 `udelay`;`10µs–20ms` 用 `usleep_range`;`>20ms` 用 `msleep`;`>1s` 用 `ssleep`(`msleep(s*1000)` 的薄封装)。注意这条分界随 `HZ` 变化(典型 `HZ=250/1000` 下的经验值),不是硬切线。 + +**懒得记这一串阈值?** 6.19 给了个 `fsleep(usecs)`(`include/linux/delay.h:127`),它内部按 **25% slack 上限**自动选最佳机制:`≤10µs` 走 `udelay`、中等延迟走 `usleep_range`、长延迟走 `msleep`(`delay.h:110-135` 注释即其分支逻辑)。也就是说,上面那条经验法则的本质,就是 `fsleep` 的内部分支。非精确时序场景直接用 `fsleep` 最省心——文档里也把它列为"拿不准就上它"的首选。 + +## 高精度定时器 hrtimer:纳秒级闹钟 + +`usleep_range` 底层就是 hrtimer。当你需要**自己设周期闹钟**(不是睡一觉),就用 `struct hrtimer`(`include/linux/hrtimer.h`)。它取代了老 `timer_list` 的精度痛点:`timer_list` 基于 jiffies(`HZ=1000` 时精度才 1ms),hrtimer 是**纳秒级**,在 `CONFIG_HIGH_RES_TIMERS` 下脱离 tick 真正高精度。 + +核心模式 `enum hrtimer_mode`(第 35 行):`HRTIMER_MODE_ABS`(绝对时间)/ `HRTIMER_MODE_REL`(相对现在),还能 `| _SOFT`(软中断回调)或 `| _HARD`(**即便在 `PREEMPT_RT` 上也强制硬中断**,这是它的语义,见 `hrtimer.h:32` 注释)。 + +典型用法四步: + +1. `hrtimer_setup(&timer, callback, CLOCK_MONOTONIC, HRTIMER_MODE_REL)`——绑定回调,签名 `enum hrtimer_restart (*function)(struct hrtimer *)`。 +2. `hrtimer_start(&timer, ns_to_ktime(200*1000000ULL), HRTIMER_MODE_REL)`——启动,传 `ktime_t`。 +3. 回调里想周期触发就返回 `HRTIMER_RESTART` 并 `hrtimer_forward(timer, now, interval)` 推进过期点;一次性就返回 `HRTIMER_NORESTART`。 +4. 收尾 `hrtimer_cancel(&timer)`。 + +回调上下文要警惕——这里要把"显式 flag"和"默认行为"分开看。**默认**(不或 `_SOFT` 也不或 `_HARD`)时,在**非 RT 内核**上回调跑在**硬中断**上下文:`__hrtimer_setup()` 里 `softtimer = !!(mode & HRTIMER_MODE_SOFT)`,不带 `_SOFT` 就是 `is_soft=false`,从而选中硬中断的 `clock_base`(`kernel/time/hrtimer.c:1607-1650`);而 `PREEMPT_RT` 内核则**除非显式 `_HARD`,一律降级到软中断**(`hrtimer.c:1621`,注释明说"RT 上回调可能调 `spin_lock` 等会睡的函数")。所以上面示例用的 `HRTIMER_MODE_REL`,在非 RT 内核上此刻就是硬中断上下文。结论不变:**默认回调绝对不能睡**;要干可能阻塞的活,要么或上 `_SOFT` 走软中断,要么干脆丢工作队列。 + +## 时间来源:jiffies 与 ktime_get_* + +打表测延迟需要一把尺子。两套时间源: + +- **jiffies**:全局变量,每个时钟中断(tick)加 1,**全局粗粒度**(`HZ=250` 时一格 4ms)。`msecs_to_jiffies()` / `jiffies_to_msecs()` 做单位换算,`timer_list.expires` 就用它。 +- **ktime_get_\* 系列**(`include/linux/timekeeping.h`):**纳秒级**。`ktime_get_ns()` 单调时钟、`ktime_get_real_ns()` 墙钟时间(自 Epoch,会随 NTP 跳)、`ktime_get_boottime_ns()` 含挂机睡眠时间。打表就用 `ktime_get_ns()` 包前后相减。 + +## 动手验证方案(待亲测) + +写个内核模块,在 `init` 里依次打表,看真实延迟和标称值的偏差: + +- **忙等待精度**:`ktime_get_ns()` 包住 `udelay(10)`、`mdelay(2)`,对比预期——`*delay()` 常常**偏短**(`asm-generic/delay.h` 注释列了三大原因:`loops_per_jiffy` 算低了、cache 影响、CPU 变频)。 +- **休眠精度**:包住 `msleep(20)`、`usleep_range(5000,5500)`,预期会**偏长**——唤醒要调度延迟,醒了还得排队等 CPU。 +- **hrtimer 周期回调**:起一个 `HRTIMER_MODE_REL` 的 hrtimer,回调里 `hrtimer_forward` + 返回 `HRTIMER_RESTART`,`ktime_get_ns()` 打每次回调间隔,对照 200ms 标称。 + +> ⚠️ **待亲测**:以上为验证方案与预期,命令输出(`dmesg` 时间戳、实际 ns 数)会在 QEMU ARM64 上跑过后回填真实数据。落地代码放 `example/mini/{descriptive-name}/`,include `../../common/Makefile.arch` 走多架构编译。 + +## 小结 + +内核里"等"的纪律一句话:**上下文决定一切**。原子上下文(中断/持自旋锁)只能 `udelay` 类忙等待,进程上下文才许 `msleep`/`usleep_range` 休眠让出 CPU。底层两条腿:忙等待靠启动时校准的 `loops_per_jiffy`(BogoMIPS),休眠靠 `schedule_timeout()`(栈上 `timer_list` + `schedule()`)或 `schedule_hrtimeout_range()`(hrtimer)。需要周期闹钟用 `struct hrtimer`,纳秒级;打表测延迟用 `ktime_get_ns()`。记不住阈值就用 `fsleep()` 让内核替你挑。记住内核延迟永远是"至少"这么久而非"精确"这么久——硬实时不是标准 Linux 的活。 + +## 延伸阅读 + +- 源码(Linux 6.19):`include/linux/delay.h`(`mdelay` 宏、`ndelay`、`usleep_range`/`fsleep`/`ssleep` 的内联实现与注释)、`include/asm-generic/delay.h`(`udelay`/`ndelay` 的 `__always_inline` 实现、`__const_udelay`/`__delay` 声明、偏短三因注释)、`kernel/time/sleep_timeout.c`(`schedule_timeout`/`msleep`/`msleep_interruptible`/`usleep_range_state`)、`include/linux/hrtimer.h`(`struct hrtimer`、`enum hrtimer_mode`/`hrtimer_restart`)、`kernel/time/hrtimer.c`(`__hrtimer_setup` 的 soft/hard 与 RT 降级逻辑)、`include/linux/timekeeping.h`(`ktime_get_*`)、`init/calibrate.c`(`loops_per_jiffy` 校准)。 +- 文档:[Timers 子系统总索引](https://docs.kernel.org/timers/index.html)(6.19 下含 highres/hpet/hrtimers/no_hz/timekeeping/delay_sleep_functions 六篇)、[delay/sleep 函数选型(delay_sleep_functions.rst)](https://docs.kernel.org/timers/delay_sleep_functions.rst)(讲怎么选 `*delay`/`usleep_range`/`*sleep`/`fsleep`,含 `fsleep` 文档建议)、[高精度定时器 hrtimers.rst](https://docs.kernel.org/timers/hrtimers.html)。 +- 进一步(后续铺开):`timer_list` 软中断定时器、内核线程(`kthread_run`)、工作队列(`schedule_work`)——把"稍后做"的活推迟到进程上下文。 \ No newline at end of file diff --git a/document/tutorials/drivers/07-drv-sync.md b/document/tutorials/drivers/07-drv-sync.md new file mode 100644 index 00000000..7f69dc14 --- /dev/null +++ b/document/tutorials/drivers/07-drv-sync.md @@ -0,0 +1,181 @@ +--- +title: mutex 与 spinlock:保护临界区的两把锁 +slug: drv-sync +difficulty: intermediate +tags: [同步, 自旋锁, 互斥锁, 并发] +architectures: [arm64, x86_64, riscv] +kernel_version: "6.19" +maturity: drafting +prerequisites: + - /tutorials/foundations/07-kernel-module-hello +related: + - /tutorials/drivers/05-drv-irq +sources: + - notes: document/notes/linux_kernel_device_drivers/ch06.md +--- + +# mutex 与 spinlock:保护临界区的两把锁 + +> 🔨 **整理中** · 本篇机制对照 Linux 6.19 源码讲解(函数/数据结构已核对);具体行号与命令输出待 QEMU 亲测核对。 + +## 并发从哪来:不只是多核,还有中断和抢占 + +我们写用户态程序时,一个进程的代码流通常是单线的——除非你显式开多线程。可一旦进了内核,这个"单线"的幻觉瞬间破灭:**同一时刻,真的有多个执行流在物理并行地跑,还会随时被打断。** 并发不是我们设计的,是被硬件硬塞进来的麻烦。 + +并发来源主要有三个: + +1. **SMP 多核**:现在的芯片——哪怕树莓派——都是多核的。CPU 0 在执行你的驱动 `write`,CPU 1 同时在执行另一个进程的 `read`,两条流物理并行,都去碰同一个共享数据。 +2. **中断**:进程上下文正在改一个计数器,硬件中断来了,同一个 CPU 上跳去跑中断处理程序,中断处理程序也想改这个计数器。 +3. **内核抢占**(`CONFIG_PREEMPTION`):进程 A 持有共享数据正在算,被高优先级进程 B 抢占,B 也来碰这份数据。 + +把这三个凑齐,就得到了"数据竞争"的温床。 + +## 临界区与数据竞争 + +不是所有代码都需要保护,只有**访问共享可写数据**的那段才危险。这种代码段叫**临界区**(critical section)。 + +数据竞争长什么样?看一行最朴素的 `count++`。C 代码看起来是一条语句,汇编层面却是"读 `count` → 加 1 → 写回 `count`"三步。两个 CPU 同时读到旧值(比如 100),各自加 1 写回 101——本该是 102,结果少了 1。这就是经典的"更新丢失"。 + +要根治它,得让这段"读-改-写"变成**原子**的——要么做完,要么没做,中间谁也插不进来。这就是**原子性**。而要保证原子性,最直接的工具就是**互斥**(mutual exclusion):进厕所锁门,别人在外面排队。 + +内核里实现互斥,主要靠两把脾气截然不同的锁:**mutex** 和 **spinlock**。 + +## 两把锁的分野:能不能睡眠 + +mutex 和 spinlock 的根本区别只有一句话:**抢不到锁时,你是去睡觉,还是原地打转?** + +- **mutex(互斥锁)**:抢不到就**睡觉**(schedule 出去,把 CPU 让给别人),等锁主人释放了再把你唤醒。代价是上下文切换开销,好处是不烧 CPU。它要求持有者在**进程上下文**——因为睡觉是进程才能干的事。 +- **spinlock(自旋锁)**:抢不到就**原地打转**(一个紧凑循环反复试探锁有没有释放),CPU 一直在那空转。代价是烧 CPU cycles,好处是不用上下文切换、可以在任何上下文(包括中断)用。但要求临界区**极短**,且**绝对不能睡眠**。 + +这就引出一句选择口诀,背下来就够用八成场景: + +> **临界区能不能睡眠?能睡 → mutex;不能睡(中断里、或持锁路径)→ spinlock。** + +## mutex:竞争时睡觉排队 + +mutex 在内核里是 `struct mutex`(`include/linux/mutex_types.h`,Linux 6.19)。它的核心字段长这样: + +```c +struct mutex { + atomic_long_t owner; // 持锁 task 指针 + 低几位标志 + raw_spinlock_t wait_lock; // 保护 wait_list 的自旋锁 + struct list_head wait_list; // 等待者队列 + struct optimistic_spin_queue osq; // 乐观自旋的 MCS 排队锁 + ... +}; +``` + +注意它把"谁持有锁"和"有没有人等"压进了一个 `atomic_long_t owner`——低位复用成标志位(`MUTEX_FLAG_WAITERS`、`HANDOFF`、`PICKUP`),高位存持锁者 `task_struct *`。这是为了快速路径能靠一条原子 `cmpxchg` 拿锁。 + +### 快速路径:无竞争时一条原子指令 + +`mutex_lock()`(`kernel/locking/mutex.c:285`)的实现分快慢两路。第一行就是 `might_sleep()`——这就是 mutex 的"自我宣告":它会在调度器面前喊一嗓子"我可能要睡",配合 `CONFIG_DEBUG_ATOMIC_SLEEP` 把在原子上下文误用 mutex 的情况当场揪出来。 + +```c +void __sched mutex_lock(struct mutex *lock) +{ + might_sleep(); + if (!__mutex_trylock_fast(lock)) + __mutex_lock_slowpath(lock); +} +``` + +`__mutex_trylock_fast()`(mutex.c:152)是乐观尝试:用 `atomic_long_try_cmpxchg_acquire(&lock->owner, &zero, curr)`,把 owner 从 0 原子地换成"当前 task 指针"。没人竞争时这一条指令就拿到锁了,开销极小。 + +### 慢速路径:先乐观自旋,不行再睡 + +竞争来了怎么办?内核不会立刻让你睡觉——先尝试**乐观自旋**(`mutex_optimistic_spin`,mutex.c:444):如果锁主人此刻正在另一个 CPU 上跑,它八成马上就放,那我也跟着转几圈 `cpu_relax()`,省一次上下文切换。多个自旋者用 `osq`(MCS 排队锁)排成一队,避免一堆人挤着抢。要是主人也被抢占了、或调度器提示该让 CPU 了,就老老实实走慢速路径。 + +真正的睡觉发生在 `__mutex_lock_common()`(mutex.c:577)里:把自己塞进 `wait_list`(FIFO 排队),设状态 `set_current_state(TASK_UNINTERRUPTIBLE)`,然后调 `schedule_preempt_disabled()`(mutex.c:692)——**这一句就是"把 CPU 让出去睡觉"**。等锁主人 `mutex_unlock()` 唤醒它,它再被调度回来重新尝试拿锁。 + +### 解锁 + +`mutex_unlock()`(mutex.c:546)同样先试快速路径 `__mutex_unlock_fast()`——用 `cmpxchg_release` 把 owner 清零。但要是 `wait_list` 里有人排队(owner 带 `WAITERS` 标志),就得走慢速路径 `__mutex_unlock_slowpath()`(mutex.c:931):从 `wait_list` 取出第一个等待者,塞进 `wake_q` 唤醒队列,最后通过 `wake_q` 把它叫醒。 + +```c +mutex_lock(&m); /* 抢不到就睡 */ +/* 临界区:可改共享数据、可调用会阻塞的函数 */ +mutex_unlock(&m); +``` + +mutex 还派生出几个变体:`mutex_lock_interruptible()`(被信号打断时返回 `-EINTR`)、`mutex_lock_killable()`(只被致命信号打断)、`mutex_trylock()`(拿不到立刻返回 0,不睡)。中断处理程序里**不能用** mutex——ISR 不能睡觉。 + +## spinlock:竞争时 CPU 空转 + +spinlock 的核心是 `spinlock_t`(在非 RT 内核里它就包了个 `raw_spinlock_t`,`include/linux/spinlock.h:349` 的 `spin_lock` 直接转调 `raw_spin_lock(&lock->rlock)`)。 + +竞争时的"原地打转"长什么样?看 `kernel/locking/spinlock.c:67` 的 `BUILD_LOCK_OPS` 宏生成的 `__raw_spin_lock`: + +```c +for (;;) { + preempt_disable(); /* 拿锁必先关抢占 */ + if (likely(do_raw_spin_trylock(lock))) /* 试原子拿锁 */ + break; + preempt_enable(); /* 没拿到,放掉抢占计数 */ + arch_spin_relax(&lock->raw_lock); /* cpu_relax() 空转一下 */ +} +``` + +关键就这几步:`preempt_disable()` → 试拿锁(`do_raw_spin_trylock` 最终调架构的 `arch_spin_lock`,比如 x86 的 `lock cmpxchg`、ARM64 的 `ldaxr/stxr` 原子指令)→ 拿到就 `break`,没拿到就 `preempt_enable()` 让一下、`cpu_relax()` 省点功耗,再来一轮。 + +注意一个细节:**自旋循环里每轮都 `preempt_disable`/`preempt_enable`**。为什么?因为持有自旋锁时不能被抢占——被抢走了,等锁的别的 CPU 只能干转到地老天荒。所以一旦真正拿到锁,`preempt_disable` 就一直生效到 `spin_unlock`。这也解释了下面那条铁律的根源。 + +### spinlock 的铁律:临界区绝对不能睡眠 + +这条比 mutex 严苛得多。持着 spinlock 时,你**不能**做任何可能引发调度的事:不能 `msleep`、不能 `kmalloc(GFP_KERNEL)`、不能 `copy_from_user`(可能缺页换页)、不能 `mutex_lock`(mutex 会睡觉)。 + +原因就在上面那个 `preempt_disable()`——拿锁时抢占被关了,进程当前所在 CPU 不会切走,别的 CPU 上等这把锁的人还能靠"空转"等到你放锁。可你要是在锁里睡了,调度器要把你换出去——但你 `preempt_count` 还是非零、还处于"原子上下文",调度器一检测到这种矛盾,就会炸出内核最著名的告警之一:**"scheduling while atomic"**,轻则 dump 栈,重则直接 panic。 + +> 比喻:mutex 像去银行取号排队,你可以坐着刷手机(睡觉),叫到号再上。spinlock 像在高速收费站的人工通道,你踩着刹车原地怠速等前面那辆走——你不能熄火下车吃饭(睡觉),不然后面整条队都卡死,你的车还堵在窗口。 + +## 中断里的锁:spin_lock_irqsave 防重入死锁 + +最让人头疼的场景:进程上下文拿着 spinlock 改数据,**同一 CPU** 上一个中断打进来,中断处理程序也要改这数据,也去拿这把锁——**死锁**。中断处理程序会一直空转等锁,可锁的主人(被中断的进程)根本没机会运行放锁,因为它被中断抢占了。 + +解决办法:拿锁的同时**关掉本地 CPU 的中断**,保证临界区执行期间不会被本 CPU 的中断打断。内核给了一族带 `irq` 后缀的 API,最推荐通用写法是 `spin_lock_irqsave`(`include/linux/spinlock.h:379` 的宏 → `kernel/locking/spinlock.c:160` 的 `_raw_spin_lock_irqsave`): + +```c +unsigned long flags; +spin_lock_irqsave(&lock, flags); /* 关中断 + 存旧中断状态到 flags + 拿锁 */ +/* 临界区 */ +spin_unlock_irqrestore(&lock, flags); /* 还原中断状态 + 放锁 */ +``` + +为什么用 `_irqsave` 而不是更简单的 `_irq`?因为 `_irq` 版本解锁时**无条件开中断**——要是你这段代码本来就是在"中断本就关着"的环境里被调用的(比如某层嵌套中断处理),解锁时把中断强行打开,就破坏了外层的约定。`_irqsave` 把进入前的中断状态存进 `flags`,解锁时原样还原,无副作用。**不确定就用 `_irqsave`,永远安全。** + +还有个 `_bh` 变体:只防软中断/底半部(`local_bh_disable()`),不防硬件中断,用于跟 tasklet/softirq 共享数据时。 + +## 单核 + 抢占:spin_lock 本质是关抢占 + +有人会问:单核(UP)系统上,spinlock 还"自旋"个什么劲?只有一个 CPU,锁主人没放锁,等待者根本跑不起来,转给谁看? + +答案是:**在非抢占的 UP 上,自旋那部分逻辑被编译器优化掉了**,`spin_lock` 基本退化成 `preempt_disable()`。但开了抢占的 UP 上,关抢占是实打实有用的——防止被抢占。而 `spin_lock_irqsave` 里的关中断逻辑,在 UP 上依然有意义(防中断重入)。 + +所以工程铁律是:**作为驱动开发者,别管 UP 还是 SMP,一律按 SMP 的逻辑写、一律用标准 API。** 内核会替你处理单核细节。 + +### Local locks(5.8+) + +到了 5.8,内核引入了 `local_lock_t`(`include/linux/local_lock_internal.h`),给"关抢占 + 关中断"这套组合一个有名字、可被 lockdep 追踪的封装。它在非 debug 构建里基本是空的(就是 `preempt_disable`/`local_irq` 的马甲),但在 `CONFIG_DEBUG_LOCK_ALLOC` 下会记录 owner 和 dep_map,让 lockdep 能查出"在原子上下文睡觉"这类隐蔽 bug。普通驱动暂时用不上,知道有这么个东西、知道它和 PREEMPT_RT 实时内核关系密切即可。 + +## 动手待亲测(占位,QEMU 上验过再补真实输出) + +两个最小验证方案,等我们拿到 QEMU ARM64 上跑一遍记下真实输出: + +1. **mutex vs spinlock 对比模块**:开一个内核线程持 `mutex_lock` 然后 `msleep(100)`——能正常睡醒,证明 mutex 临界区可睡眠。换成 `spin_lock` + `msleep`——触发 `scheduling while atomic` 报错/dump,证明 spinlock 临界区不能睡。观察 `dmesg`。 +2. **故意持锁睡眠触发死锁**:写一个 ISR 用普通 `spin_lock` 拿一把进程上下文正持有的锁,确认死锁/挂起现象,再改成 `spin_lock_irqsave` 复现"正常工作"。 + +> ⚠️ 上面两段是计划方案,真实命令输出和 `dmesg` 报错栈待 QEMU 亲测后回填,届时升级为 ✅ 已锤炼。 + +## 小结 + +并发的根是 SMP 多核 + 中断 + 抢占,它们让"读-改-写"不再是原子的,数据竞争就这么来。保护临界区有两把锁:**mutex** 抢不到就睡觉(靠 `owner` 原子量快速路径 + `wait_list` 排队睡眠 + 乐观自旋优化),只能在进程上下文用;**spinlock** 抢不到就 CPU 空转(`preempt_disable` + 原子试锁 + `cpu_relax`),任何上下文都能用,但临界区**绝对不能睡眠**,否则 "scheduling while atomic"。 + +选择口诀一句话:**能睡用 mutex,不能睡(中断/持锁路径)用 spinlock**。当中断和进程上下文共享数据时,spinlock 必须配 `spin_lock_irqsave`/`spin_unlock_irqrestore`——拿锁同时关中断并保存状态,防中断重入死锁。 + +## 延伸阅读 + +- 源码(Linux 6.19): + - `kernel/locking/mutex.c` — mutex 的快慢路径、乐观自旋、等待队列(`mutex_lock` at mutex.c:285,`__mutex_lock_common` at mutex.c:577)。 + - `kernel/locking/spinlock.c` — `BUILD_LOCK_OPS` 生成的自旋循环(spinlock.c:67),`_raw_spin_lock_irqsave`(spinlock.c:160)。 + - `include/linux/mutex_types.h` — `struct mutex` 定义;`include/linux/spinlock.h` — `spin_lock` 等内联封装;`include/linux/local_lock_internal.h` — local_lock 实现。 +- kernel.org 文档:[Locking types and docs](https://docs.kernel.org/locking/index.html)、[Kernel API / locking](https://docs.kernel.org/core-api/kernel-api.html)。 \ No newline at end of file diff --git a/document/tutorials/drivers/08-drv-atomic.md b/document/tutorials/drivers/08-drv-atomic.md new file mode 100644 index 00000000..1202c28c --- /dev/null +++ b/document/tutorials/drivers/08-drv-atomic.md @@ -0,0 +1,287 @@ +--- +title: 原子操作、refcount 与内存屏障 +slug: drv-atomic +difficulty: intermediate +tags: [原子操作, 引用计数, 内存屏障, 并发同步] +architectures: [arm64, x86_64, riscv] +kernel_version: "6.19" +maturity: drafting +prerequisites: + - /tutorials/drivers/07-drv-sync +related: + - /tutorials/drivers/07-drv-sync +sources: + - notes: document/notes/linux_kernel_device_drivers/ch07.md + - notes: document/notes/linux_kernel_device_drivers/ch07_1.md + - notes: document/notes/linux_kernel_device_drivers/ch07_2.md +--- + +# 原子操作、refcount 与内存屏障 + +> 🔨 **整理中** · 本篇机制对照 Linux 6.19 源码讲解(函数/数据结构已核对);具体行号与命令输出待 QEMU 亲测核对。 + +## 一条 `x++` 为什么会丢更新 + +写驱动的迟早撞上并发。最朴素的直觉是「共享变量加个锁不就行了」,但锁贵——自旋锁要空转、互斥锁要切上下文。于是内核另有一条路:原子操作。可在这之前,得先看明白一个朴素到容易被忽略的事实——**`x++` 根本不是一条指令**。 + +```c +int counter = 0; +/* 两个 CPU 同时执行 */ +counter++; +``` + +`counter++` 在汇编里是三步(RMW,Read-Modify-Write):先把 `counter` 从内存**读**进寄存器,在寄存器里**改**(加 1),再**写**回内存。两个 CPU 同时干这事,时间线可能是这样:CPU A 读到 0、CPU B 也读到 0,各自加成 1,各自写回 1——结果 `counter` 是 1,不是预期的 2。一次更新凭空蒸发了。这就是经典**丢失更新(lost update)**。 + +加锁当然能解,但代价是把整个 `++` 包进临界区。内核想要的,是一种让「读-改-写」三步像一条指令一样**不可被打断**的东西。这就是 `atomic_t` 存在的理由——靠 CPU 提供的硬件原子指令(x86 的 `lock` 前缀、ARM 的 `ldxr/stxr` 独占加载/存储、RISC-V 的 `lr/sc` 原子预留)把 RMW 拍进一条原子的指令序列。 + +## `atomic_t`:带原子语义的封装,不是普通 int + +先看它长什么样(`include/linux/types.h`,Linux 6.19): + +```c +typedef struct { + int counter; +} atomic_t; + +#define ATOMIC_INIT(i) { (i) } +``` + +注意它是个**结构体包着一个 int**,而不是裸 `int`。这个封装有两层心思:一是让编译器**阻止你直接写 `v.counter++`**——那一脚下去原子性就没了;二是为架构层按需加调试位、强制缓存行对齐留出空间(具体对齐在各架构 `` 实现里做,不是这个 typedef 本身的固有属性)。64 位版本是 `atomic64_t`,里头是 `s64 counter`——这个简洁结构体定义在 `#ifdef CONFIG_64BIT` 下(`types.h`),32 位内核另有 `atomic64_t` 实现。 + +用法是清一色的函数族,**绝不直接碰 `.counter` 字段**: + +```c +static atomic_t v = ATOMIC_INIT(0); /* 静态定义并初始化 */ + +atomic_set(&v, 4); /* 设置为 4 */ +atomic_add(1, &v); /* v += 1 */ +atomic_inc(&v); /* v++ */ +int val = atomic_read(&v); /* 读当前值 */ +``` + +真正让 `atomic_t` 强大的,是**条件判断与修改合一**的接口。最经典的是 `atomic_dec_and_test()`——原子地减 1,并判断结果是否为 0,整个动作没人能插一脚: + +```c +if (atomic_dec_and_test(&v)) { + /* 减完正好是 0:我是最后一个引用者,可以 kfree 了 */ + kfree(obj); +} +``` + +同族还有 `atomic_inc_and_test()`(加完为 0、通常是下溢信号)、`atomic_sub_and_test()`、`atomic_add_negative()`。这些是手写引用计数的老搭档——但下面会讲,现代内核更推荐 `refcount_t`,因为 `atomic_t` 有个致命软肋。 + +**踩坑提醒**:`atomic_t` 只保证**这一个变量**的操作原子。如果 `struct { atomic_t a; int b; }`,你想让 `a` 和 `b` 保持一致,`atomic_t` 帮不了你,得用自旋锁/互斥锁把这对操作框成一个临界区。它不替代序列化,只是单变量的并发安全计数器。 + +## 为什么不用 `volatile` + +很多人第一反应:既然怕编译器优化、怕并发,那 `volatile` 修饰一下不就行了?这是内核并发里最经典的误解。 + +`volatile` 的本意是告诉编译器「这个变量可能被硬件/中断/别的线程莫名其妙改掉,别把它缓存进寄存器,每次老老实实去内存读」。它确实**只防编译器优化这一层**。但它有两个硬伤,挡不住 RMW 丢失更新: + +1. **不保证原子性**。`volatile int i; i++;` 仍是三步指令,`volatile` 让你每次都回内存取值,却拦不住两个 CPU 在三条指令的缝隙里互相踩。`counter++` 该丢还是丢。 +2. **不充当内存屏障**。C 标准只保证 `volatile` 变量**之间**的访问不被编译器重排,管不了非 `volatile` 变量,更管不了 **CPU 硬件层面的乱序执行**。 + +内核文档 `Documentation/process/volatile-considered-harmful.rst` 把这点钉得很死:内核里要并发安全,要么用锁,要么用原子操作/`refcount`,要么显式加内存屏障——`volatile` 不是同步原语,它只在 MMIO 寄存器访问那种「每次都得真打在硬件上」的场景才合理。 + +## `refcount_t`:带溢出/下溢检测的安全计数器 + +回到那个软肋。拿 `atomic_t` 手搓引用计数,多核疯狂并发下计数可能被 `dec` 成负数(重复释放)或被 `inc` 到回绕(`INT_MAX` → `INT_MIN`)。一旦回绕,`atomic_dec_and_test()` 可能误判为 0 而 `kfree` 一个还有人用的对象——**Use-After-Free(UAF)**,内核安全漏洞的一大温床。 + +内核为此造了 `refcount_t`(`include/linux/refcount_types.h`): + +```c +typedef struct refcount_struct { + atomic_t refs; +} refcount_t; +``` + +里头还是个 `atomic_t`,但**外层包装加了溢出/下溢检测**。核心机制是**饱和(saturation)**。看 `include/linux/refcount.h` 的定义: + +```c +#define REFCOUNT_SATURATED (INT_MIN / 2) /* 0xc000_0000 */ +``` + +一旦检测到非法状态(下溢、溢出、对 0 加引用),计数被钉死在 `REFCOUNT_SATURATED`,并通过 `refcount_warn_saturate()`(`lib/refcount.c`)打 `WARN_ONCE` 提示具体毛病。枚举 `enum refcount_saturation_type` 把故障分得很细: + +```c +enum refcount_saturation_type { + REFCOUNT_ADD_NOT_ZERO_OVF, /* add_not_zero 溢出 */ + REFCOUNT_ADD_OVF, /* 溢出 */ + REFCOUNT_ADD_UAF, /* 对 0 加引用 */ + REFCOUNT_SUB_UAF, /* 下溢 */ + REFCOUNT_DEC_LEAK, /* 减到 0 仍调用 dec,泄露 */ +}; +``` + +对应的 `WARN` 文案也很直白:`"underflow; use-after-free"`、`"addition on 0; use-after-free"`、`"decrement hit 0; leaking memory"`。饱和值的位置源码头文件有一张 ASCII 图说得很清楚(`refcount.h`): + +``` + INT_MAX REFCOUNT_SATURATED UINT_MAX +0 (0x7fff_ffff) (0xc000_0000) (0xffff_ffff) ++--------------------------------+----------------+----------------+ + <---------- bad value! ----------> + +(in a signed view of the world, the "bad value" range corresponds to +a negative counter value). +``` + +也就是说,`REFCOUNT_SATURATED` 故意落在 **`INT_MAX` 与 `UINT_MAX` 之间**(有符号视角看就是负值区),离 0 隔着整整一个 `INT_MAX`——正常计数(`0..INT_MAX`)怎么加减都够不到它,攻击者也难在饱和区里反复腾挪骗过 `dec_and_test`。 + +接口和 `atomic_t` 几乎一一对应,但语义更安全: + +```c +static refcount_t r = REFCOUNT_INIT(1); + +refcount_set(&r, 1); + +/* 拿引用:原值非 0 才成功加 1,否则对象正在销毁,不能再用 */ +if (refcount_inc_not_zero(&r)) { /* ... 拿到引用 ... */ } + +/* 放引用:减完为 0 表示自己是最后一个,可释放 */ +if (refcount_dec_and_test(&r)) { kfree(obj); } + +/* 单纯加/减,违规时会 WARN(inc 遇 0、dec 减到 0 仍继续) */ +refcount_inc(&r); +refcount_dec(&r); +``` + +**代价是性能**。看 `refcount.h` 里 `__refcount_sub_and_test()` 的实现(Linux 6.19,这是 `refcount_dec_and_test` 一路追到底的真实函数体;`__refcount_dec_and_test()` 只是 `i=1` 的一行特化包装): + +```c +bool __refcount_sub_and_test(int i, refcount_t *r, int *oldp) +{ + int old = atomic_fetch_sub_release(i, &r->refs); + + if (oldp) + *oldp = old; + + if (old > 0 && old == i) { + smp_acquire__after_ctrl_dep(); + return true; /* 减完正好是 0,可以 free */ + } + + if (unlikely(old <= 0 || old - i < 0)) + refcount_warn_saturate(r, REFCOUNT_SUB_UAF); /* 下溢报警 */ + + return false; +} +``` + +`atomic_fetch_sub_release` 拿到旧值后还得做范围检查、必要时调 `refcount_warn_saturate`——比裸 `atomic_dec` 多一串判断。所以结论很明确:**只做纯统计标志位,用 `atomic_t` 就够;涉及对象生命周期管理,必须 `refcount_t`。** + +## 内存屏障:挡住 CPU 和编译器的「手快」 + +讲完「值的并发安全」,还有一个更阴险的问题——**顺序**。先灌一个反直觉的事实:**你在 C 代码里写的顺序,不一定是内存里实际发生的顺序。** + +两个元凶:编译器为了性能会**重排指令**,CPU 为了流水线效率会**乱序执行**。单核无伤大雅,可一旦跨核通信——尤其是跟 DMA 控制器、网卡这类「死脑筋」的硬件打交道——顺序错了就是灾难。 + +经典模式是 **flag + data**:CPU A 先写数据,再写一个标志位通知 CPU B「数据好了」;CPU B 轮询标志位,看到 1 就去读数据。 + +```c +/* CPU A */ +data = 42; +flag = 1; + +/* CPU B */ +while (flag != 1) ; +printk("%d\n", data); /* 期望读到 42 */ +``` + +逻辑上无懈可击。可 CPU B 可能先看到 `flag == 1`、再去读 `data` 时却读到旧值——因为 CPU A 那两条 `store` 在硬件层面被重排了,或者两条 `load` 在 B 这边被重排了。**没有屏障,"先写 data 后写 flag" 只是你的美好愿望。** + +内核给的武器是内存屏障宏。看 `include/asm-generic/barrier.h`(Linux 6.19): + +```c +#define mb() do { kcsan_mb(); __mb(); } while (0) /* 全屏障 */ +#define rmb() do { kcsan_rmb(); __rmb(); } while (0) /* 读屏障 */ +#define wmb() do { kcsan_wmb(); __wmb(); } while (0) /* 写屏障 */ +``` + +- **`wmb()`**(Write Memory Barrier):屏障**之前**的所有写,必须全部落地、对其他观察者可见,之后才允许屏障之后的写发生。填 DMA 描述符时用它——先把地址、选项这些铺垫写完,再让标志位「拍板」生效。 +- **`rmb()`**(Read Memory Barrier):屏障之前的读必须先完成,才能执行后面的读。读标志位后、读数据前插一道。 +- **`mb()`**:读写都挡,最重。 + +回到 Realtek 8139 网卡驱动的真实例子(`drivers/net/ethernet/realtek/8139cp.c`,`cp_start_xmit`)。发一个包要先填 DMA 描述符 `struct cp_desc { opts1; opts2; addr; }`,再置位 `opts1` 的「有效」位让硬件开干: + +```c +txd->opts2 = opts2; +txd->addr = cpu_to_le64(mapping); /* 货架号 */ + +wmb(); /* 钉子:铺垫必须先落地 */ + +opts1 |= eor | len | FirstFrag | LastFrag; +txd->opts1 = cpu_to_le32(opts1); /* 拍板:让硬件开干 */ + +wmb(); /* 再一道:有效位立刻对硬件可见 */ +``` + +两道 `wmb()` 各司其职:第一道保住数据依赖(地址不能被重排到标志位之后),第二道保证命令立刻生效。少了它们,x86 上可能「运气好」不出事(x86 内存模型强,硬件本来就有不少顺序保证),但代码一旦移植到 ARM 或 RISC-V,或换块更挑剔的网卡,就会收获凌晨三点负载高峰才复现的灵异 Bug。**这种跨内存模型的坑,屏障是你唯一的保险。** + +## `smp_*` 屏障 vs 非 smp 屏障 + +你会注意到屏障分两套:`wmb()/rmb()/mb()` 和 `smp_wmb()/smp_rmb()/smp_mb()`。区别在**作用域**: + +- **`wmb()/rmb()/mb()`**:**总是生效**,连单核(UP)也挡,主要给**与硬件设备/DMA 通信**用——因为设备根本不关心你几核,它只按内存里字面顺序读。 +- **`smp_wmb()/smp_rmb()/smp_mb()`**:**只在 SMP(多核)编译时才插真屏障**。看 `barrier.h` 的真实结构——它俩由 `CONFIG_SMP` 二选一,不是背靠背连续两个 `#ifndef`: + +```c +#ifdef CONFIG_SMP + +#ifndef smp_mb +#define smp_mb() do { kcsan_mb(); __smp_mb(); } while (0) +#endif +#ifndef smp_rmb +#define smp_rmb() do { kcsan_rmb(); __smp_rmb(); } while (0) +#endif +#ifndef smp_wmb +#define smp_wmb() do { kcsan_wmb(); __smp_wmb(); } while (0) +#endif + +#else /* !CONFIG_SMP */ + +#ifndef smp_mb +#define smp_mb() barrier() +#endif +#ifndef smp_rmb +#define smp_rmb() barrier() +#endif +#ifndef smp_wmb +#define smp_wmb() barrier() /* UP 上退化成编译器屏障,不挡 CPU */ +#endif + +#endif /* CONFIG_SMP */ +``` + +因为单核上 CPU 乱序只会被中断看见,而中断返回和单核执行流之间的顺序约束,靠编译器屏障(`barrier()`,即 `asm volatile("" ::: "memory")`)就够。多核才需要真正插硬件屏障指令。 + +**规则**:纯软件的多核通信用 `smp_*`;跟硬件/DMA 打交道用不带 `smp_` 的那套。 + +## 动手验证方案(待亲测) + +> ⚠️ **待亲测**:以下方案我们会在 QEMU(arm64 / x86_64)上跑模块验证,记下真实命令与 `dmesg` 输出后再补。 + +1. **`atomic_inc` 不丢更新**:起 N 个内核线程(`kthread_run`)各自对同一个 `atomic_t` 做 M 次 `atomic_inc`,结束读 `atomic_read`,应严格等于 `N * M`。换成裸 `int` 的 `counter++` 作对照,看更新丢失。 +2. **`refcount_t` 溢出/下溢报警**:构造重复 `refcount_dec` 或对 0 `refcount_inc`,`dmesg` 应出现 `"refcount_t: underflow; use-after-free"` 之类 `WARN`,且计数被钉在 `REFCOUNT_SATURATED`。 +3. **屏障保 flag/data 顺序**:起生产者/消费者线程,不带屏障跑大量迭代观察乱序导致的脏读;加 `smp_wmb()/smp_rmb()` 后消失。 + +模块源码与 `Makefile`(多架构,参考 `example/common/Makefile.arch`)验证通过后,落到 `example/mini/atomic-refcount-barrier/`。 + +## 小结 + +这一篇串起了一条线:**原子操作保「值」,内存屏障保「顺序」,`refcount_t` 在原子之上再加一层「生命周期安全」。** + +- `atomic_t` 是带原子语义的封装(`include/linux/types.h`),靠硬件 RMW 指令让 `inc/dec/add` 不可打断,但只保护单个变量、不替代序列化;`atomic64_t` 的简洁结构体定义在 `#ifdef CONFIG_64BIT` 下。 +- `volatile` 只防编译器优化,**既不保证原子性也不充当内存屏障**,不是同步原语。 +- `refcount_t`(`include/linux/refcount_types.h` + `include/linux/refcount.h`)针对引用计数加饱和检测,下溢/溢出会 `WARN` 并钉死在 `REFCOUNT_SATURATED`(落在 `INT_MAX` 与 `UINT_MAX` 之间、有符号视角的负值区),代价是比 `atomic_t` 略慢——做生命周期管理必须用它。 +- 内存屏障(`include/asm-generic/barrier.h`):`wmb()`/`rmb()`/`mb()` 总生效(给硬件/DMA 用),`smp_*` 系列由 `CONFIG_SMP` 决定——SMP 编译插真屏障,UP 退化成 `barrier()`(只挡编译器);这套机制挡住编译器重排和 CPU 乱序,保住 flag/data 模式的顺序。 + +## 延伸阅读 + +- 源码(Linux 6.19): + - `include/linux/types.h`、`include/linux/refcount_types.h` —— `atomic_t` / `atomic64_t` / `refcount_t` 类型定义。 + - `include/linux/atomic.h`、`include/linux/atomic/atomic-instrumented.h` —— 原子操作接口与 acquire/release/relaxed 变体。 + - `include/linux/refcount.h`、`lib/refcount.c` —— `refcount_*` 实现、饱和与 `refcount_warn_saturate()`。 + - `include/asm-generic/barrier.h` —— `mb/rmb/wmb` 与 `smp_*` 屏障宏。 +- kernel.org 文档: + - [Core API — Memory Barriers](https://docs.kernel.org/core-api/wrappers/memory-barriers.html):内存屏障权威长文(Howells/McKenney/Deacon/Zijlstra 合著)。这个在线页面就是源码树 `Documentation/memory-barriers.txt` 的全文渲染(`.rst` 只是 `.. include::` 薄包装),看哪个都一样。 + - `Documentation/process/volatile-considered-harmful.rst`(为什么不要拿 `volatile` 当同步手段)。 \ No newline at end of file diff --git a/document/tutorials/drivers/09-drv-rcu.md b/document/tutorials/drivers/09-drv-rcu.md new file mode 100644 index 00000000..f066fca8 --- /dev/null +++ b/document/tutorials/drivers/09-drv-rcu.md @@ -0,0 +1,203 @@ +--- +title: RCU:读多写少的无锁魔法 +slug: drv-rcu +difficulty: intermediate +tags: [RCU, 并发同步, 无锁编程, 内存屏障] +architectures: [arm64, x86_64, riscv] +kernel_version: "6.19" +maturity: drafting +prerequisites: + - /tutorials/drivers/08-drv-atomic +related: + - /tutorials/drivers/08-drv-atomic + - /tutorials/drivers/07-drv-sync +sources: + - notes: document/notes/linux_kernel_device_drivers/ch07.md + - notes: document/notes/linux_kernel_device_drivers/ch07_3.md +--- + +# RCU:读多写少的无锁魔法 + +> 🔨 **整理中** · 本篇机制对照 Linux 6.19 源码讲解(函数/数据结构已核对);具体行号与命令输出待 QEMU 亲测核对。 + +## 锁的尽头是另一种思路 + +写到这一篇,我们已经攒了一抽屉的锁:自旋锁原地打转、互斥锁睡过去等。可内核里有一类场景特别折磨这些锁——**读极多、写极少**。典型的就是路由表、VFS 的 `dentry` 缓存、网络协议族的 `struct net_proto_family` 链表。热点路径上几百个 CPU 同时在读,可要更新它?几秒钟都不见得有一次。 + +如果给这种结构套一把自旋锁,那画面太美不敢看:每个读者进临界区都要 `spin_lock`,在多核 SMP 上这等于所有 CPU 排队抢同一把锁——锁总线、刷缓存行、CAS 自旋,读得越多锁竞争越惨,典型的"用锁保护读"是把性能往火坑里推。 + +RCU(Read-Copy-Update)就是为这种"读多写少"量身打造的。它的核心承诺听起来像作弊:**读者完全不加锁、不做原子操作、不写共享变量**,所以读者之间零竞争,速度跟单线程裸读几乎一样。代价全部转移给了写者。这不是黑魔法,是靠精巧的设计把"快"和"正确"分开买单。 + +## RCU 的核心三步 + +RCU 名字里 Read-Copy-Update 就把套路交代了,我们一行行拆: + +1. **读者无锁读旧副本**:读者进临界区只标记一下"我在读"(具体怎么标记待会儿用源码说),然后直接顺着指针读当前数据,全程不碰任何锁、不做原子操作。 +2. **写者复制一份再改**:写者不原地修改共享数据,而是 `kmalloc` 一份新副本,在新副本上改。这期间老读者还在读旧数据,互不干扰。 +3. **等合适时机回收旧版**:旧数据不能马上 `kfree`,因为可能有读者正引用着它。写者登记一个回收动作,等所有"老读者"都退出临界区后,才真正释放旧数据。 + +关键在第 3 步——"等所有老读者退出"这件事,RCU 有个专门的名词:**宽限期(grace period)**。理解了宽限期,就理解了 RCU 一半。 + +## 宽限期:等老读者安全下车 + +写者改完、把指针切到新副本之后,旧副本里还可能有"在改之前就已经进入临界区"的读者在引用它。这种读者叫**老读者**。RCU 不去精确追踪每一个读者(那等于又加锁了),而是用一个粗粒度但高效的判定:**宽限期**。 + +宽限期的定义很朴素:从写者发起回收那一刻起,等**所有 CPU 都经历一次"静止状态"(quiescent state)**。什么叫静止状态?对一个非抢占内核来说,就是 CPU 发生了一次上下文切换、或经历了一次时钟中断、或跑进了用户态——任何能让 RCU 确认"这个 CPU 上当前没有卡在 `rcu_read_lock` 临界区里"的时刻。一个宽限期意味着:所有在回收发起前进入临界区的老读者,到宽限期结束时一定已经退出了。为什么?因为非抢占内核里,一个 CPU 要退出 `rcu_read_lock` 临界区,必然伴随着被抢占/调度出去,而那就是静止状态。宽限期扫过所有 CPU 的静止状态,就等于"老读者全清"。 + +一旦宽限期结束,旧副本就彻底没人引用了,写者登记的释放回调安全执行。这就是 RCU "延迟回收"的本质——**不是不释放,是等安全了再释放**。 + +## 读者 API:`rcu_read_lock` / `rcu_read_unlock` + +读者的全套家当就两个宏: + +```c +rcu_read_lock(); /* 进临界区 */ +/* 这里读 RCU 保护的数据,随便读,不加锁 */ +rcu_read_unlock(); /* 出临界区 */ +``` + +这两个宏到底干了什么?我们直接看 6.19 源码。`rcu_read_lock()`(`include/linux/rcupdate.h:863`)本体是个 inline 函数: + +```c +static __always_inline void rcu_read_lock(void) +{ + __rcu_read_lock(); + __acquire(RCU); + rcu_lock_acquire(&rcu_lock_map); + ... +} +``` + +真正干活的是 `__rcu_read_lock()`。在非抢占内核(`TREE_RCU`)配置下,它长这样(`include/linux/rcupdate.h:91`): + +```c +static inline void __rcu_read_lock(void) +{ + preempt_disable(); /* 就这一句!禁掉本 CPU 抢占 */ +} + +static inline void __rcu_read_unlock(void) +{ + preempt_enable(); +} +``` + +看到没?读者进临界区,RCU 做的全部事情就是 `preempt_disable()`——**禁掉本 CPU 的抢占**。没有自旋、没有 CAS、没有原子读改写、没有写共享变量。所以读者快到飞起:它付出的代价仅仅是"告诉调度器:这一小段别把我换走"。而这点代价正是宽限期判定的依据——只要本 CPU 还没发生上下文切换,RCU 就知道这个读者可能还在临界区里。 + +(抢占内核 `PREEMPT_RCU` 下 `__rcu_read_lock` 会真正计数 `current->rcu_read_lock_nesting`,允许读者被抢占,判定逻辑更复杂,但对外 API 一模一样。) + +> 注释里还有一句很硬的话(rcupdate.h:872):`So where is rcu_write_lock()? It does not exist`——**RCU 根本没有写者锁**,因为没有任何机制能"挡住"读者,这正是 RCU 快的根源。 + +## 写者 API:`synchronize_rcu` 与 `call_rcu` + +写者改完数据后,要把旧副本的安全回收托付给 RCU。有两条路: + +**同步等宽限期——`synchronize_rcu()`**。调用者会**阻塞**,直到当前宽限期结束、旧数据确认安全才返回。看源码(`kernel/rcu/tree.c:3337`): + +```c +void synchronize_rcu(void) +{ + ... + if (!rcu_blocking_is_gp()) { + if (rcu_gp_is_expedited()) + synchronize_rcu_expedited(); /* 强制快速宽限期,代价是 IPI 打扰所有 CPU */ + else + synchronize_rcu_normal(); /* 正常等宽限期,友好 */ + return; + } + ... +} +``` + +`synchronize_rcu` 内部调用 `synchronize_rcu_normal()`(tree.c:3265),它注册一个回调然后睡死等宽限期完成。注意它要求**进程上下文**——中断里绝对不能调,因为它会睡眠。典型写法是写者持一把普通自旋锁(**只用来隔绝写者之间**,不是隔绝读者),改完指针、`spin_unlock`,然后 `synchronize_rcu()` + `kfree(old)`。 + +**异步回收——`call_rcu()`**。不想阻塞?把释放动作包成回调挂上去,宽限期结束后 RCU 自己调它: + +```c +void call_rcu(struct rcu_head *head, rcu_callback_t func) +{ + __call_rcu_common(head, func, enable_rcu_lazy); +} +``` + +(tree.c:3237)`call_rcu` 不阻塞,立即返回。代价是回调延迟执行、且写者要保证传入的 `old` 指针在这期间不会被二次释放。更新极频繁的场景(比如路由表)几乎只用 `call_rcu`,把 `kfree` 推迟到宽限期之后批量做。 + +## 为什么读者快、写者贵 + +把账算清楚: + +**读者快,是因为它什么都不做**。`preempt_disable` 一条指令级别的事,没有跨 CPU 的总线同步、没有缓存行乒乓。N 个 CPU 同时读一个 RCU 链表,彼此完全无感,扩展性近乎线性。这正是 RCU 在网络/调度热路径上铺天盖地的原因。 + +**写者贵,贵在三处**:(1) 要 `kmalloc` 复制一份新数据并改它;(2) 要等一个完整宽限期才能释放旧数据,宽限期可能长达几毫秒到几十毫秒;(3) 在宽限期结束前,**新旧的内存同时存在**,内存占用短暂翻倍。所以 RCU 是"读者爽、写者扛"的交换——只有当读频率远远高于写频率时,这笔买卖才划算。若读写都频繁,RCU 反而比锁更糟。 + +## 链表 RCU:`list_for_each_entry_rcu` / `list_add_rcu` + +实战中 RCU 最常见的载体是双向链表。读者用 RCU 版本遍历,写者用 RCU 版本增删,二者可安全并发: + +```c +/* 读者:在 rcu_read_lock() 保护下 */ +struct foo *entry; +list_for_each_entry_rcu(entry, head, list) { + /* 读 entry,绝不能 free,也绝不能改它 */ +} + +/* 写者:加一把普通锁隔绝其它写者 */ +spin_lock(&writers_lock); +list_add_rcu(&new->list, head); /* 原子地插入,读者要么看到要么看不到,不会看到半个节点 */ +spin_unlock(&writers_lock); +``` + +读者那个 `list_for_each_entry_rcu`(`include/linux/rculist.h:446`)展开后核心是:用 `list_entry_rcu` 取节点,而它底层是 `READ_ONCE` 取 `next` 指针——一个普通加载,不带锁。它能和 `list_add_rcu` 安全并发,靠的是 RCU 写者**先建好新节点的全部内容,最后再用一次原子指针更新把它挂进链表**,以及宽限期保证被摘除的旧节点在读者退出前不会被释放。读者可能"绕过"刚加的新节点(看到旧的 `next`),也可能看到,但绝不会看到半个写了一半的脏节点。 + +## 与内存屏障衔接:`rcu_assign_pointer` / `rcu_dereference` + +链表 API 帮你把指针更新封装好了,但如果你自己手搓 RCU 保护的结构体(比如一个全局指针 `gp` 指向某结构),就必须用这一对宏来发布/订阅,**不能裸赋值**: + +```c +struct foo __rcu *gp; /* __rcu 是给 sparse 看的标注 */ + +/* 写者:发布 */ +p = kmalloc(...); +p->a = 1; p->b = 2; /* 先填好字段 */ +rcu_assign_pointer(gp, p); /* 再发布指针 */ + +/* 读者:订阅 */ +rcu_read_lock(); +local = rcu_dereference(gp); /* 取到指针 */ +do_something(local->a); /* 安全读 */ +rcu_read_unlock(); +``` + +为什么要这俩宏?因为现代 CPU 和编译器都会**乱序**。写者写完 `p->a`、`p->b` 后,如果裸 `gp = p`,CPU 可能把指针更新排到字段写入之前——读者拿到指针时,字段可能还没落内存。`rcu_assign_pointer`(`rcupdate.h:588`)用 `smp_store_release` 解决:它是一条**release 语义的存储**,保证屏障之前的所有写(字段赋值)全部对其它 CPU 可见之后,才让指针更新可见: + +```c +#define rcu_assign_pointer(p, v) \ +do { \ + ... \ + smp_store_release(&p, RCU_INITIALIZER(...)); \ +} while (0) +``` + +对称地,读者端 `rcu_dereference`(rcupdate.h:770,包到 `rcu_dereference_check`)用 `READ_ONCE` + 依赖屏障保证:先读到正确的指针,再去读它指向的字段。**发布用 release、订阅用 deref**,这一对配合就是 RCU 版的"内存屏障契约"——上一篇讲的手写 `wmb()`/`rmb()`,RCU 替你压进了这两个宏里。 + +## 动手验证(待亲测) + +本篇不贴完整示例,给两个验证方案,留到 QEMU 上跑: + +**方案 A:体会宽限期。** 写一个模块,`rcu_read_lock` 里 `udelay` 卡住一会儿模拟长读者,另一个 CPU/线程 `synchronize_rcu()` 并在前后打 `ktime_get` 截时间——你会看到 `synchronize_rcu` 的返回被读者卡住的时长拖长,直观感受"宽限期等老读者"。 + +**方案 B:RCU 链表并发。** 一个内核线程 `list_add_rcu` 猛加节点 + 旧节点 `call_rcu` 回收,几个线程 `list_for_each_entry_rcu` 遍历只读。开 `CONFIG_PROVE_RCU` 和 lockdep,故意写错(比如读者不加 `rcu_read_lock`)观察 splat,体会 RCU 的纪律。 + +> ⚠️ **待亲测**:上面两个方案的代码、`dmesg` 输出、宽限期耗时实测,都要在 QEMU ARM64 上跑一遍落实,再把数字填回这里。 + +## 小结 + +RCU 把"读多写少"的并发做到了极致:读者只 `preempt_disable`、不加锁、不做原子操作,所以读侧近零开销、扩展性近乎线性;代价全部转给写者——复制副本、等宽限期、延迟回收。三步走(读者读旧副本 → 写者复制改 → 等宽限期回收)加上一对发布/订阅宏(`rcu_assign_pointer`/`rcu_dereference`)和链表 RCU 家族,就构成了内核里路由、VFS、网络协议等热路径的并发骨架。 + +记住一句话:**RCU 不是"不释放",是"等安全了再释放"**;它适合读远多于写的场景,读写都频繁时它比锁更糟。 + +## 延伸阅读 + +- 源码:`include/linux/rcupdate.h`(RCU 核心 API 与宏)、`kernel/rcu/tree.c`(Tree RCU 宽限期引擎,`synchronize_rcu`/`call_rcu`)、`include/linux/rculist.h`(RCU 链表)。 +- kernel.org 文档:[RCU documentation index](https://docs.kernel.org/RCU/index.html)(RCU 设计、内存序、 stall 诊断全套)、[What is RCU?](https://docs.kernel.org/RCU/rcu.html)。 +- 关联篇:原子与内存屏障(`/tutorials/drivers/08-drv-atomic`)、同步原语总览(`/tutorials/drivers/07-drv-sync`)。 \ No newline at end of file diff --git a/document/tutorials/drivers/index.md b/document/tutorials/drivers/index.md index 2757b20b..d49fbd7f 100644 --- a/document/tutorials/drivers/index.md +++ b/document/tutorials/drivers/index.md @@ -7,11 +7,21 @@ description: 字符设备、平台驱动、设备树、中断——主线内核 > 字符设备 → 平台驱动 → 设备树 → 中断,掌握主线内核驱动开发的完整链条。 -📚 **规划中**,还没开写。建议先把[通识基础](../foundations/)和[内核子系统](../kernel/)走通,这块会在那之后铺开。 +🔨 **整理中**。建议先把[通识基础](../foundations/)和[内核子系统](../kernel/)走通。 -## 计划 +## 字符设备与中断 🔨 整理中 + +- 🔨 [字符设备驱动:用户态通往内核的门](./01-drv-chardev) +- 🔨 [ioctl:结构化的内核-用户命令通道](./02-drv-ioctl) +- 🔨 [poll/select:驱动怎么告诉用户“数据来了”](./03-drv-poll) +- 🔨 [mmap:把设备内存搬进用户进程](./04-drv-mmap) +- 🔨 [硬件中断:设备怎么打断 CPU](./05-drv-irq) +- 🔨 [时间与延迟:内核怎么“等”](./06-drv-clk) +- 🔨 [mutex 与 spinlock:保护临界区的两把锁](./07-drv-sync) +- 🔨 [原子操作、refcount 与内存屏障](./08-drv-atomic) +- 🔨 [RCU:读多写少的无锁魔法](./09-drv-rcu) + +## 持续铺开 -- **字符设备**:`file_operations`、`ioctl`、`cdev` 生命周期 - **平台驱动**:probe/remove、总线-设备-驱动模型 - **设备树**:`compatible`、binding 文档、`of_*` 接口 -- **中断**:`request_irq`、threaded IRQ、上下半部 diff --git a/document/tutorials/kernel/index.md b/document/tutorials/kernel/index.md index 30244272..97afd246 100644 --- a/document/tutorials/kernel/index.md +++ b/document/tutorials/kernel/index.md @@ -18,14 +18,33 @@ maturity: drafting ## 内存管理 🔨 整理中 -- 🔨 [伙伴系统:内核怎么管物理页](./mm/mm-buddy) - -Slab/Slub、vmalloc、页面回收、OOM 持续铺开。 +- 🔨 [伙伴系统:内核怎么管物理页](./mm/01-mm-buddy) +- 🔨 [Slab 分配器:内核怎么管小对象](./mm/02-mm-slab) +- 🔨 [vmalloc:只要虚拟连续就行](./mm/03-mm-vmalloc) +- 🔨 [页面回收与 kswapd:内存紧张时怎么办](./mm/04-mm-page-reclaim) +- 🔨 [OOM Killer:回收也扛不住时的最后防线](./mm/05-mm-oom) ## 文件系统 📚 规划中 VFS、Ext4、页缓存、写时复制。 -## 网络栈 📚 规划中 - -socket 层、TCP/IP 协议栈、Netfilter、XDP。 +## 网络栈 🔨 整理中 + +- 🔨 [网络栈全景:一个包的内核漂流](./net/01-net-overview) +- 🔨 [sk_buff:贯穿网络栈的快递盒](./net/02-net-sk-buff) +- 🔨 [邻居子系统与 ARP:IP 怎么找到 MAC](./net/03-net-neighbor) +- 🔨 [IPv4 协议层:包的接收与发送](./net/04-net-ipv4) +- 🔨 [IPv4 路由子系统:包该往哪走](./net/05-net-routing) +- 🔨 [TCP 传输层:三次握手与收发内核视角](./net/06-net-tcp) +- 🔨 [UDP:无连接的轻量传输](./net/07-net-udp) +- 🔨 [Netfilter:网络栈的钩子框架](./net/08-net-netfilter) +- 🔨 [Netlink:用户态与内核的双向 socket](./net/09-net-netlink) +- 🔨 [ICMP:网络的诊断与控制协议](./net/10-net-icmp) +- 🔨 [组播路由:一对多的高效投递](./net/11-net-multicast) +- 🔨 [IPv6:不只是更长的地址](./net/12-net-ipv6) +- 🔨 [IPsec 与 XFRM:内核怎么给数据包穿装甲](./net/13-net-ipsec) +- 🔨 [mac80211:内核怎么管一块无线网卡](./net/14-net-wireless) +- 🔨 [RDMA 与 InfiniBand:绕过内核的零拷贝传输](./net/15-net-rdma) +- 🔨 [网络命名空间:容器网络的根基](./net/16-net-namespace) + +XDP 持续铺开。 diff --git a/document/tutorials/kernel/mm/mm-buddy.md b/document/tutorials/kernel/mm/01-mm-buddy.md similarity index 100% rename from document/tutorials/kernel/mm/mm-buddy.md rename to document/tutorials/kernel/mm/01-mm-buddy.md diff --git a/document/tutorials/kernel/mm/02-mm-slab.md b/document/tutorials/kernel/mm/02-mm-slab.md new file mode 100644 index 00000000..9476f876 --- /dev/null +++ b/document/tutorials/kernel/mm/02-mm-slab.md @@ -0,0 +1,133 @@ +--- +title: Slab 分配器:内核怎么管小对象 +slug: mm-slab +difficulty: intermediate +tags: [内存管理, Slab 分配器, kmalloc, 对象池] +architectures: [arm64, x86_64, riscv] +kernel_version: "6.19" +maturity: drafting +prerequisites: + - /tutorials/kernel/mm/01-mm-buddy +related: + - /tutorials/kernel/mm/01-mm-buddy +sources: + - notes: document/notes/linux_kernel_programming/ch08.md + - notes: document/notes/linux_kernel_programming/ch09.md +--- + +# Slab 分配器:内核怎么管小对象 + +> 🔨 **整理中** · 这篇是从读书笔记(ch08 §8.5/§8.6、ch09 §9.1)整理出来的骨架,核心机制讲透了;但动手部分(QEMU 上 `cat /proc/slabinfo` 看缓存清单、写模块对比 `kmalloc` 与 `kmem_cache_alloc` 的实际开销和槽位)还没亲手跑过。等我们在 QEMU 里验过,就升级成 ✅ 已锤炼。 + +## 伙伴系统的盲区:要几十字节却被塞一整页 + +上一篇我们陪伙伴系统锯了一上午木头,结论很扎心:它只认 2 的幂,最小交易单位是 4KB 一页,非 2 的幂请求会被向上取整浪费。可这还不是它最尴尬的地方——它真正管不了的是**零碎小对象**。 + +想象一下,内核里到处都是几十字节到几百字节的小结构:一个网络包的 `sk_buff`、一个文件的 `inode`、一个目录项的 `dentry`。这些家伙生命周期短、分配释放极其频繁。如果每次都找伙伴系统要,会是什么光景?你要 192 字节,伙伴系统甩给你 4KB 一整页——**剩下 3904 字节就这么空着**,内部碎片率高得离谱,光是初始化和回收一整页的开销都够这个小对象分配几十次了。 + +所以内核在伙伴系统上面又搭了一层楼,这就是 **Slab 分配器**:它专门干"把一整页木头锯成小木条零售"的活。承接 buddy 篇,这篇讲上层。 + +## Slab 的核心思想:对象池化 + 复用 + +Slab 的设计初衷其实就两条,说穿了都很朴素: + +1. **对象池化**:内核里有些结构(`task_struct`、`inode`、`dentry`)被成千上万次地分配释放。与其每次都现造一个、用完拆一个,不如**预先批量造好一池子放着**,要的时候直接拿一个现成的,用完扔回池子。省掉的不是一点内存,是反复构造/析构那套开销。 +2. **按规格切页**:把一整页 4KB 提前切成若干个固定大小的"槽位"(比如 192 字节一槽),小对象按需领槽,一个页能塞下二十个 `sk_buff` 头,内部碎片瞬间从 ~95% 压到几个百分点。 + +还有个常被忽略的红利:**Slab 分配出来的内存是物理连续、且按 CPU 缓存行对齐的**。这对高频网络/存储路径很要命——对齐意味着少踩伪共享(False Sharing),缓存命中率高出一截。 + +## 实现沿革:SLAB/SLOB 已退场,今天只剩 SLUB + +Slab 这个概念在内核历史上先后有过三套具体实现,了解这段沿革能帮你读懂老资料——但别误以为今天还有得选: + +- **SLAB**:最早的那套(从 Solaris 借鉴来的),设计精巧但**数据结构复杂**、对 NUMA 多节点管理开销大,元数据占内存不少。**已在 6.5 从主线移除**。 +- **SLUB**:**今天唯一的实现**。它把 SLAB 那套复杂的 per-CPU 队列和元数据大幅精简,代码更少、性能更好、碎片更少。你编 6.19 用的就是它,没有别的选项。 +- **SLOB**:曾经给**嵌入式、内存极小**设备用的精简版,连 SLUB 都嫌重时上它,代价是分配效率低。**已在 6.4 从主线移除**。 + +所以现在 `mm/` 下只剩一个 `slub.c`(外加面向极小内存的 `SLUB_TINY` 配置)。早年资料里"通过 `CONFIG_SLAB`/`CONFIG_SLUB`/`CONFIG_SLOB` 三选一"的说法已经彻底过时——今天只剩 SLUB 一条路。好在三者从来共用同一套上层 API(`kmalloc`、`kmem_cache_create` 等),不管底下跑哪个,你写代码的方式都一样。 + +## 核心数据对象:kmem_cache 与 slab 页 + +Slab 的世界有两个关键角色: + +- **`struct kmem_cache`**:代表"**一种类型**的专用缓存"。你可以理解为一个池子只装一种货——比如 `task_struct` 有自己的 `kmem_cache`,`inode` 有自己的,互不串。每个 `kmem_cache` 记着这种对象的大小、对齐、构造函数、还有底下挂着的一堆 slab 页。对外 API 集中在 `include/linux/slab.h`,而 `struct kmem_cache` 的定义本身在 `mm/slab.h`(内核内部头,6.19,行号待亲测核对)。 +- **slab 页**:`kmem_cache` 真正存货的物理载体——它向伙伴系统要来的整页,被切成一个个等大的槽位。对象就躺在槽位里。 + +一个 `kmem_cache` 底下挂很多 slab 页,每个页切成 N 个对象槽,分配就是在某个有空位的页里找个空槽,释放就是把槽标记为空。 + +## 两层 API:通用 kmalloc vs 专用 kmem_cache + +内核对外暴露两层 Slab 接口,选哪层看你的需求: + +**通用层——`kmalloc` 家族**(按大小临时挑坑位): + +```c +void *kmalloc(size_t size, gfp_t flags); /* 不清零,内容是垃圾 */ +void *kzalloc(size_t size, gfp_t flags); /* 推荐用这个,清零版 */ +void kfree(const void *objp); /* kfree(NULL) 安全 */ +``` + +内核预造了一组通用缓存:`kmalloc-8`、`kmalloc-16`、`kmalloc-32`……一直到 `kmalloc-8192`(6.x 下还会再有更大的)。你调 `kmalloc(20, ...)`,它挑一个能装下 20 字节的最小坑(32 字节槽)给你。**`kzalloc` 推荐**:省心,又防未初始化内存泄漏。 + +**专用层——`kmem_cache` 系列**(为高频结构自建缓存): + +```c +struct kmem_cache *kmem_cache_create(const char *name, + unsigned int size, + unsigned int align, + slab_flags_t flags, + void (*ctor)(void *)); +void *kmem_cache_alloc(struct kmem_cache *s, gfp_t gfpflags); +void kmem_cache_free(struct kmem_cache *s, void *objp); +void kmem_cache_destroy(struct kmem_cache *s); +``` + +这是一套"建厂→生产→关停"的流程,三步缺一不可,下一节展开。 + +## 为什么 task_struct、inode 要自建缓存 + +你可能会问:通用 `kmalloc` 不是挺好用的吗,为啥内核还要给 `task_struct` 这些结构单独开缓存?因为"**通用的往往是低效的**"——三个理由: + +1. **减少碎片**:通用缓存按固定档位(32/64/96...)给坑,你要 328 字节它可能塞你进 512 的坑,白浪费 184 字节。自建缓存按你结构体实际大小切页,碎片压到最低。 +2. **对齐可控**:`SLAB_HWCACHE_ALIGN` 能保证对象起始落在缓存行边界,热门字段还能凑到同一个缓存行里,多核性能更稳。 +3. **构造回调**:`kmem_cache_create` 可以挂个 `ctor` 构造函数。内核预分配对象时自动调它初始化,省得每次 `alloc` 完还得手动 `memset`/填字段——透着一股 C++ 面向对象的味道。 + +所以 `task_struct`、`inode`、`dentry`、`sk_buff`、`mm_struct` 这些高频家伙,内核启动时都给它们各自建好了专用 `kmem_cache`,你可以 `/proc/slabinfo` 里亲眼看到这份清单。 + +> 还有个真实的"内碎片"坑:你 `kmem_cache_create` 指定 `size=328`,但内核为了对齐和元数据,**实际给的槽位可能是 448 字节**。`kmem_cache_size()` 会告诉你实际开了多大。嵌入式抠字节到极致的场景,这笔账必须算进去。 + +## 动手:亲测 /proc/slabinfo 与 kmalloc vs kmem_cache_alloc + +这是本篇唯一还没在 QEMU 上跑通的部分,列个验证方案,等亲测后回填真实输出。 + +**方案一:看缓存清单** + +```bash +cat /proc/slabinfo | head +slabtop -o | head # 按占用排序看,更直观 +``` + +> ⚠️ **待亲测核对**:上面命令在 6.19 + QEMU ARM64 上的真实输出还没记。预期会看到 `task_struct`、`inode_cache`、`dentry`、`kmalloc-192` 等一长串条目,每条带 `active_objs / num_objs / objsize` 几列。我们会把真实输出贴进来,再解释每列含义。 + +**方案二:写模块对比 kmalloc 与 kmem_cache_alloc** + +骨架目标(完整实战代码留到亲测阶段,不在本篇铺): + +- 定义一个 ~328 字节的 `struct myctx`,模块 init 里用 `kmem_cache_create` 建专用缓存,`SLAB_POISON | SLAB_RED_ZONE | SLAB_HWCACHE_ALIGN` 全开做调试。 +- 对比两路分配:一路 `kmalloc(sizeof(myctx))`,一路 `kmem_cache_alloc`,各自用 `ksize()`/`kmem_cache_size()` 打印实际槽位大小,观察"内碎片"差距。 +- 挂个 `ctor`,打印它被调的次数——你只 `alloc` 一次,`ctor` 可能被调 **18 次**(内核预填充批次),这是 Slab 池化的直接证据。 +- 用完 `kmem_cache_free` + `kmem_cache_destroy` 配对,验证"还有对象没还回来就销毁"会失败。 + +> ⚠️ **待亲测**:实际模块代码、QEMU 上的 `dmesg` 输出、`ctor` 18 次的批次现象,都留到亲测阶段补齐并沉淀到 `example/mini/`。本篇只立方案。 + +## 小结 + +Slab 是伙伴系统之上的小对象零售层,核心就两招:**对象池化复用**(省构造/析构开销)和**按规格切页**(压内部碎片)。6.x 只剩 **SLUB** 一种实现,对外两层 API——通用 `kmalloc`/`kzalloc` 按大小挑坑,专用 `kmem_cache_create`/`alloc`/`free`/`destroy` 给高频结构自建缓存。 + +记住三件事:**自建缓存能减碎片、控对齐、挂构造回调**;**实际槽位常比指定 size 大**(内碎片代价);**`ctor` 会被预分配批量调用**。下一篇我们继续往大块内存走——`vmalloc` 和那个让人手心出汗的 OOM Killer。 + +## 延伸阅读 + +- 源码:`mm/slub.c`(Linux 6.19 唯一实现);`mm/slab_common.c` 看 `kmem_cache_create`;`mm/slab.h` 看 `struct kmem_cache` 的定义;`include/linux/slab.h` 是对外 API 头。 +- kernel.org:[Memory Management guide](https://docs.kernel.org/admin-guide/mm/index.html)、[Slab allocators 文档](https://docs.kernel.org/core-api/mm-api.html)。 +- 进一步(持续铺开):`vmalloc`、`kvmalloc`、页面回收与 OOM Killer。 \ No newline at end of file diff --git a/document/tutorials/kernel/mm/03-mm-vmalloc.md b/document/tutorials/kernel/mm/03-mm-vmalloc.md new file mode 100644 index 00000000..7bce9507 --- /dev/null +++ b/document/tutorials/kernel/mm/03-mm-vmalloc.md @@ -0,0 +1,111 @@ +--- +title: vmalloc:只要虚拟连续就行 +slug: mm-vmalloc +difficulty: intermediate +tags: [内存管理, vmalloc, 虚拟连续, 大块分配] +architectures: [arm64, x86_64, riscv] +kernel_version: "6.19" +maturity: drafting +prerequisites: + - /tutorials/kernel/mm/01-mm-buddy +related: + - /tutorials/kernel/mm/01-mm-buddy + - /tutorials/kernel/mm/02-mm-slab +sources: + - notes: document/notes/linux_kernel_programming/ch09.md +--- + +# vmalloc:只要虚拟连续就行 + +> 🔨 **整理中** · 这篇是从读书笔记(ch09 §9.2 / §9.3)提炼的骨架,vmalloc 的机制和取舍讲透了;但动手部分(写模块 `vmalloc` 1MB、`cat /proc/vmallocinfo` 看映射)还没在 QEMU 上亲手跑过。等我们验过真实命令输出、行号核对完,就升级成 ✅ 已锤炼。 + +## 先把两种连续性分清楚 + +上一篇我们跟着伙伴系统走了一圈,它的招牌是一条铁律:**给出去的内存物理上必须连成一片**。`kmalloc`、`alloc_pages` 全是这条线上的——你要 128KB,它就在物理 RAM 里给你找一块完整连续的 128KB,代价是这条内存贵、稀缺,还容易被外部碎片卡死。 + +但很多时候我们根本不在乎物理连不连续。比如你要存一个大数组、要加载一个内核模块的映像、要给某个软件逻辑开一大块缓冲——CPU 只需要一个**连续的虚拟地址**能顺序读写就行,至于这一段虚拟地址背后映射到哪几片散落的物理页,软件层面完全感知不到。 + +这就是 `vmalloc` 的定位:**物理可以散,虚拟必须连**。把 `kmalloc` 想象成买地皮,得是物理上挨着的一整块,适合盖楼(还能拿去给硬件做 DMA);`vmalloc` 像搞虚拟办公,给你一串连续的门牌号,背后真正的办公室可能散在城市各处——只要快递员(CPU)按门牌号能挨个找到就行。 + +## vmalloc 怎么做到"虚拟连续、物理散" + +秘密在页表。`vmalloc` 在内核的 `vmalloc` 区域(一段专门留出来的虚拟地址空间)里划出一块连续的虚拟地址,然后**一页一页**地从伙伴系统那儿零散地讨物理页(可能 order 0 的散页,东一页西一页),再通过修改页表把这些散落的物理页**映射**到那段连续虚拟地址上。 + +从 CPU 视角看:虚拟地址是连续递增的,顺着指针走毫无障碍。从物理视角看:真实 RAM 可能这儿一页那儿一页,完全不挨着。代价全摊在页表建立和 TLB 上。 + +> 核心实现在 `mm/vmalloc.c`(Linux 6.19),入口是 `vmalloc()` 系列,底层靠 `__vmalloc_node_range()` 在 `VMALLOC_START`/`VMALLOC_END` 区间里找洞、再逐页映射。行号待亲测核对。 + +## 代价:为什么不能随便用 vmalloc + +`vmalloc` 不是免费午餐,它有三笔账要算: + +1. **TLB 失效多**。因为物理页散落,虚拟地址到物理地址的映射在页表里东跳西跳,TLB(页表缓存)命中率比物理连续的 `kmalloc` 差一截,访问起来更慢。这是它性能上最大的硬伤。 +2. **不能直接给 DMA 用**。硬件 DMA 引擎大多只认物理地址(除非有 IOMMU 给你做地址翻译),`vmalloc` 出来的虚拟地址硬件不认。在 x86 上想把 `vmalloc` 内存做 DMA 映射还得 `kmap` 一下,更是慢上加重。 +3. **多数情况会睡眠**。`vmalloc` 内部要分配页表、可能要做内存回收,会触发调度,所以**绝不能在中断上下文或持自旋锁时调用**——睡了就死锁。这点和 `GFP_KERNEL` 的纪律一脉相承。 + +一句话:`vmalloc` 是个"大而慢"的工具,省了连续物理内存的稀缺性,搭上了 TLB 性能和 DMA 能力。 + +## 什么时候非 vmalloc 不可 + +那什么场景值得吃这三笔代价?答案是**块够大,而且只要虚拟连续**: + +- **模块加载映像**:内核模块 `.ko` 文件加载进内核时,映像放在 `vmalloc` 区域,因为它大、且软件按顺序读,不需要物理连续。 +- **超大数组 / 软件缓冲区**:几 MB 到上百 MB 的纯软件缓冲,只要 CPU 能顺序访问,物理散不散无所谓。 +- **需要 `vmalloc_to_page()` 的场景**:有些子系统(比如 `vmap`、percpu)就是建立在 vmalloc 区域之上的,天然用 `vmalloc`。 +- **大宗内存、允许离散**:当你估算 `kmalloc`(受 `KMALLOC_MAX_SIZE` 和碎片限制)八成要失败,又不需要 DMA,`vmalloc` 是退路。 + +反过来,如果块不大(几 KB),老老实实 `kmalloc`——快、物理连续、省心。 + +## vmalloc 全家桶 API + +`vmalloc` 有一串变体,挑对工具能少踩坑: + +| API | 功能 | 返回 | +|:---|:---|:---| +| `void *vmalloc(unsigned long size)` | 分配虚拟连续内存 | 虚拟地址 | +| `void *vzalloc(unsigned long size)` | 同上,但**清零**(`z` = zero) | 虚拟地址 | +| `void vfree(const void *addr)` | 释放,可睡眠,**别在原子上下文调** | — | +| `void *vmalloc_32(unsigned long size)` | 只从 32 位可寻址的物理页分配 | 虚拟地址 | +| `void *vmalloc_user(unsigned long size)` | 分配可映射到用户空间的(`VM_USERMAP`) | 虚拟地址 | + +还有个"偷懒但聪明"的混合体值得单独说:**`kvmalloc(size, flags)`**。它的逻辑是先试着 `kmalloc`(快且物理连续),失败了自动回退到 `vmalloc`。对那些"我不想纠结到底该用哪个"的中等大小请求,这就是福音。配套释放用 **`kvfree()`**,它会自己判断当初走的是哪条路。 + +> API 声明见 `include/linux/vmalloc.h`(Linux 6.19)。签名以源码头文件为准,行号待亲测核对。 + +## 决策树:kmalloc vs vmalloc vs alloc_pages + +脑子里的分配器多了就容易卡壳,贴一张决策图在显示器旁: + +1. **给 DMA 硬件用?** → 别用这些,走 DMA 专用 API(`dma_alloc_coherent`)。 +2. **给软件逻辑用,很小(几 KB 内)?** → 首选 `kmalloc()` / `kzalloc()`,最快最省事。 +3. **中等(1MB~4MB)且不在乎物理连续?** → `kvmalloc()`,让它自己选。 +4. **中等且必须物理连续?** → 硬上 `kmalloc`(小心失败)或底层 `__get_free_pages()`。 +5. **巨大(超过 4MB)?** → 基本只能 `vmalloc()`。 +6. **频繁分配释放同一结构体?** → 自定义 Slab 缓存(`kmem_cache_create`)。 + +**性能陷阱提醒**:别因为"`vmalloc` 能给大内存"就把小内存也全换成它。`kmalloc` 是从内存池直接拿,飞快;`vmalloc` 要改页表、处理 TLB,慢得多。默认永远首选 `kmalloc`,只有它真的给不出来时才退一步求 `vmalloc` / `kvmalloc`。 + +## 动手待亲测:模块里 vmalloc 1MB + +我们计划在 `example/mini/` 下开一个模块,`vmalloc(1MB)` 一块、再 `vzalloc(1MB)` 一块对比清零效果,然后 `cat /proc/vmallocinfo` 看映射——`vmallocinfo` 是 vmalloc 区域的账本,每一行对应一段 vmalloc 映射,会列出地址范围、大小、调用者(caller),能让我们亲眼看到"虚拟连续、物理散"这件事落在哪里。 + +验证方案(**待亲测核对**,输出是占位样例): + +```bash +# 加载模块后看 vmalloc 区域的映射 +cat /proc/vmallocinfo | grep +# 期望看到类似这样一行(数字/调用者为占位,待亲测替换): +# 0xffff000010000000-0xffff000010100000 1048576 +0x.../0x... pages=256 vmalloc +``` + +观察点有三:一是 `pages=256` 印证 1MB = 256 个 4KB 页;二是地址范围是连续的虚拟区间;三是对照 `print_hex_dump_bytes` 打出来的内容,`vmalloc` 出来的是脏数据(不清零)、`vzalloc` 出来全 0。等 QEMU 跑完,把真实输出和 `mm/vmalloc.c` 的关键行号补进来,这块就从"待亲测"变"已验证"。 + +## 小结 + +`vmalloc` 是内核给"大块、只要虚拟连续"场景准备的退路:靠改页表把散落的物理页缝成一段连续虚拟地址,省下了物理连续的稀缺性,代价是 TLB 性能、DMA 能力和原子上下文的禁忌。记住决策的优先级——**默认 `kmalloc`,中等块用 `kvmalloc` 省心,巨大且不需 DMA 才退守 `vmalloc`**——你就不会在四个分配器之间犯选择困难症了。 + +## 延伸阅读 + +- 源码:`mm/vmalloc.c`(Linux 6.19),vmalloc 核心实现;`include/linux/vmalloc.h` 看 API 声明。 +- kernel.org 文档索引:[Memory Management guide](https://docs.kernel.org/admin-guide/mm/index.html)、[Memory Allocation APIs](https://docs.kernel.org/core-api/memory-allocation.html)。 +- 进一步(持续铺开):Slab 分配器(上一篇)、`kvmalloc` 的回退逻辑、DMA 一致性内存分配。 diff --git a/document/tutorials/kernel/mm/04-mm-page-reclaim.md b/document/tutorials/kernel/mm/04-mm-page-reclaim.md new file mode 100644 index 00000000..82e52738 --- /dev/null +++ b/document/tutorials/kernel/mm/04-mm-page-reclaim.md @@ -0,0 +1,122 @@ +--- +title: 页面回收与 kswapd:内存紧张时怎么办 +slug: mm-page-reclaim +difficulty: intermediate +tags: [内存管理, 页面回收, kswapd, 水位线] +architectures: [arm64, x86_64, riscv] +kernel_version: "6.19" +maturity: drafting +prerequisites: + - /tutorials/kernel/mm/01-mm-buddy +related: + - /tutorials/kernel/mm/01-mm-buddy + - /tutorials/kernel/mm/05-mm-oom +sources: + - notes: document/notes/linux_kernel_programming/ch09.md +--- + +# 页面回收与 kswapd:内存紧张时怎么办 + +> 🔨 **整理中** · 这篇是从读书笔记(ch09 §9.4)整理出来的骨架,把 kswapd 的水位线机制和后台回收的脉络讲透了;但 `/proc/zoneinfo` 看水位、压测触发 kswapd、抓 vmstat 这几个动手部分还没在 QEMU 里亲测过,命令输出样例都标了"待亲测核对"。等我们跑完,升级成 ✅ 已锤炼。 + +## 内核内存不能换出,那什么能换 + +上一篇我们讲过一条铁律:**内核自己用的内存常驻 RAM,绝不会被换到磁盘上**——管理内存的数据结构要是被换出去,想读回来还得用内存,这就是"找眼镜得先戴眼镜"的死锁。那系统内存吃紧的时候,内核总得有点东西能腾出来吧? + +能腾的就是**用户侧的两大类页**: + +1. **文件页(file-backed pages)**——page cache。你 `read` 一个文件,内核顺手把内容缓在内存里,下次读就不用再跑磁盘。这些页的"原件"就在磁盘文件里,内存里的只是副本。 +2. **匿名页(anonymous pages)**——进程的堆、栈这些,磁盘上没有对应文件,属于"无家可归"的内存。要换出它们,得给它们在磁盘上临时安个窝——这就是 **swap**。 + +这两类页是内核内存回收的"存货",平时占着 RAM 充缓存,紧张了就得让位。 + +## 两类页,两种回收手法 + +回收不是一刀切,得看页"脏不脏": + +- **干净的文件页**:内存里的副本跟磁盘上一模一样(比如只读没改过的 page cache)。**直接丢弃**就完事——下次要用,重新从文件读一遍就行。成本最低。 +- **脏的文件页**:被写过、还没回写磁盘的 page cache。**先回写(writeback)到文件,再丢弃**。多一次磁盘 I/O。 +- **匿名页**:磁盘上压根没有原件。**写进 swap 分区/文件,再丢弃**。最贵,因为 swap 一般也是磁盘。 + +所以内核回收时的偏好顺序很自然:**先扔干净文件页(白嫖),再扔脏文件页(得写磁盘),最后才动匿名页(还得走 swap)**。代价从低到高,能省则省。`swappiness` 这个 sysctl 就是调这个偏好的——值越小越不愿意碰匿名页,留给后面亲测时细看。 + +## zone 的三档水位线 + +回收不是随性而为,得有规矩触发。规矩就是**每个 zone 都有三档水位线**(注意:是每个 zone 独立一套,不是全局): + +| 水位 | 含义 | +|:---|:---| +| **`WMARK_HIGH`** | 充裕,舒服区 | +| **`WMARK_LOW`** | 有点紧了,该打扫了 | +| **`WMARK_MIN`** | 最低警戒线,不能再低 | + +> 这套水位定义在 `include/linux/mmzone.h`(Linux 6.19)的 `enum zone_watermarks`,挂在 `struct zone` 的 `_watermark[NR_WMARK]` 数组里。具体值由 `mm/page_alloc.c` 里的 `setup_per_zone_wmarks()` 根据 zone 大小和 `min_free_kbytes` 算出来。行号待亲测核对。 + +三档水位把 zone 的空闲内存划成几段,下面两节就是"谁在什么水位被触发"。 + +## kswapd:后台清洁工 + +内核里有个专门的内核线程叫 **`kswapd`**,每个 Node 一个,平时睡大觉,干的是后台异步回收的活——**不打扰正在分配内存的人**。它的唤醒与睡眠完全跟着水位走: + +1. **空闲跌破 `WMARK_LOW`**:kswapd 被唤醒,开始后台回收——扔 page cache、回写脏页、必要时换出匿名页。 +2. **一路回收到 `WMARK_HIGH`**:够了,kswapd 回去睡觉。 + +注意这里的不对称:**从 low 醒来,回收到 high 才睡**。中间留了 `high - low` 这一段缓冲,免得它刚睡下又被叫醒、来回折腾(这叫 kswapd 的滞回,hysteresis)。这种后台异步回收的好处是:分配内存的进程几乎无感,kswapd 在另一个 CPU 上默默收拾,**不阻塞**你。 + +> kswapd 的主循环在 `mm/vmscan.c`(Linux 6.19)的 `balance_pgdat()`,由 `kswapd()` 线程函数驱动。行号待亲测核对。 + +## direct reclaim:分配者自己上手 + +但要是内存掉得太快,kswapd 来不及打扫呢?比如某个进程一口气要一大块,空闲内存直接跌破 `WMARK_MIN`——这时候就顾不上后台了。**正在分配内存的那个进程自己被拉去干回收的活**,这叫 **direct reclaim(直接回收)**。 + +区别一目了然: + +- **kswapd**:后台、异步、别的线程干、不阻塞你。 +- **direct reclaim**:前台、同步、你自己干、**分配被卡住直到回收出内存**。 + +direct reclaim 是慢路径,直接影响延迟——你的 `malloc` / `alloc_pages` 会突然变慢,因为它得停下来先扫一轮 LRU、回写、换出,才能拿到页。所以系统调优的一个核心目标就是**尽量别让空闲内存跌破 low**,把回收的活都甩给 kswapd,别逼分配者亲自上阵。 + +## PG_reclaim 与 shrink:机制概览 + +那"回收"具体在扫什么?这里只点一下骨架,LRU 的细节留给亲测篇: + +- 内核给每个 zone 维护 **LRU 链表**(活跃/非活跃,文件页/匿名页各一组),靠 `PG_active`、`PG_referenced` 这些页标志位判断"这页最近还用不用"。 +- 回收的核心入口是 `mm/vmscan.c` 的 `shrink_lruvec()` 一族函数,它们遍历 LRU、按代价挑页、该丢的丢、该回写的回写、该换出的换出。 +- 页被打上 **`PG_reclaim`** 标志表示"这页被选中要走 writeback"。具体怎么打分、怎么换出,等亲测篇配合 `/proc/vmstat` 的 `pgscan_kswapd_*` / `pgsteal_*` 计数器展开。 + +这篇的目标是把"为什么要回收、谁触发、什么时候阻塞"这层心智模型立起来,LRU 深水区不在这篇的射程内。 + +## 动手待亲测(验证方案占位) + +下面这几步是我们打算在 QEMU 上验的,现在只列方案,输出都标了"待亲测核对": + +1. **看水位线**:`cat /proc/zoneinfo`,找每个 zone 的 `min` / `low` / `high` 三行,核对它们跟 `min_free_kbytes` 的关系。 +2. **压测触发 kswapd**:用一个吃内存的进程(比如 `stress` 或手写小程序)把 Normal zone 的空闲压到 low 以下,同时开第二个终端盯 `vmstat 1`,看 `si`/`so`(swap in/out)和 kswapd 相关计数有没有动。 +3. **抓 direct reclaim**:更激进地压到 min 以下,观察分配延迟的变化(`/proc/vmstat` 里的 `allocstall_*` 计数器,正是 direct reclaim 拖慢分配的痕迹)。 + +``` +# 待亲测核对 —— 下面是参考样例,QEMU ARM64 上真实输出待补 +$ cat /proc/zoneinfo | grep -E "Node|zone|min|low|high" +Node 0, zone DMA + min 16 + low 20 + high 24 +Node 0, zone Normal + min 4096 + low 5120 + high 6144 +``` + +> ⚠️ **待亲测**:上面的数字是整理时的参考样例。我们会拿到 QEMU ARM64 上跑一遍,把真实的水位值、`vmstat` 输出、压测前后的对比记下来,再决定要不要配一个 `example/mini` 验证模块。 + +## 小结 + +页面回收是内核内存管理的"续命机制":内核内存不换出,但**文件页和匿名页**可以——干净文件页直接丢、脏文件页先回写、匿名页写 swap,代价从低到高。触发靠**每个 zone 的三档水位线**(high / low / min):**kswapd** 在跌破 low 时后台异步回收、回到 high 睡觉,不打扰分配者;一旦跌破 min,分配者就得亲自上阵做 **direct reclaim**,付出延迟代价。 + +记住一个调优直觉:**尽量把回收的活留给 kswapd**——别让系统闲到跌破 min,否则你的分配请求会被同步回收拖慢。至于 LRU 怎么挑页、`swappiness` 怎么调,那是亲测篇的活。 + +## 延伸阅读 + +- 源码:`mm/vmscan.c`(Linux 6.19),kswapd 主循环与 `shrink_*` 回收族;`mm/page_alloc.c` 的 `setup_per_zone_wmarks()` 看水位怎么算;`include/linux/mmzone.h` 看 `enum zone_watermarks` 与 `struct zone`。 +- kernel.org:[Memory Management guide](https://docs.kernel.org/admin-guide/mm/index.html)(管理员视角,含 `/proc/zoneinfo`、kswapd、`swappiness` 条目)。 +- 进一步(持续铺开):OOM Killer(`/proc//oom_score_adj`)、LRU 与 `PG_reclaim` 深挖。 \ No newline at end of file diff --git a/document/tutorials/kernel/mm/05-mm-oom.md b/document/tutorials/kernel/mm/05-mm-oom.md new file mode 100644 index 00000000..f3daf075 --- /dev/null +++ b/document/tutorials/kernel/mm/05-mm-oom.md @@ -0,0 +1,111 @@ +--- +title: OOM Killer:回收也扛不住时的最后防线 +slug: mm-oom +difficulty: intermediate +tags: [内存管理, OOM Killer, 进程管理, 内存压力] +architectures: [arm64, x86_64, riscv] +kernel_version: "6.19" +maturity: drafting +prerequisites: + - /tutorials/kernel/mm/04-mm-page-reclaim +related: + - /tutorials/kernel/mm/04-mm-page-reclaim +sources: + - notes: document/notes/linux_kernel_programming/ch09.md +--- + +# OOM Killer:回收也扛不住时的最后防线 + +> 🔨 **整理中** · 这篇是从读书笔记(ch09 §9.4.2/§9.4.3)整理出来的骨架,OOM 的判定逻辑、`oom_score_adj` 调参讲清了;但动手部分(QEMU 上造内存压力真触发一次 OOM、用 `oom_score_adj` 保住指定进程存活、抓 `dmesg` 看 killed 行)还没亲手跑过。等我们在 QEMU 里验过,就升级成 ✅ 已锤炼。 + +## 回收都来不及了:与其全崩,不如砍一个 + +上一篇我们聊了内存回收:`kswapd` 在后台默默扫地,水位跌破 `min` 了就触发 direct reclaim,让申请内存的进程阻塞着等回收。这些手段本质都是"挤海绵"——把没人用的 page cache、能丢的 Slab 对象挤出去,换回一点空闲页。 + +可海绵总有挤干的时候。回收线程拼了老命,连 direct reclaim 都上了,内存还是不够——这时候内核面对的是一个二选一的绝境:**要么整个系统一起死,要么牺牲一个进程换大家活。** 内核选了后者,请出那位冷血杀手——**OOM Killer**(Out-Of-Memory Killer)。 + +它的逻辑粗暴到让人想哭:**算个分,谁分高谁死。** 发个 `SIGKILL`,那个进程连求饶的机会都没有(`SIGKILL` 无法被捕获或忽略),它占的内存全部释放出来,系统续命。 + +> 这是 reclaim(含 direct reclaim)全部失败之后的兜底。回收是"挤海绵",OOM 是"砍人止血"——两个完全不同量级的手段。下一篇我们回头看回收篇就能把这条链路串起来:分配失败 → kswapd → direct reclaim → 还是失败 → OOM。 + +## oom_score:怎么挑"该牺牲的进程" + +杀手不瞎砍,它有一套评分系统,叫 **`oom_score`**。每个进程都有一个分数,**分越高越该死**。判定的核心思路就两条: + +1. **占用内存多的优先砍**——这是最实在的释放收益,砍掉一个吃了几个 G 的进程,比砍十个吃几 MB 的小喽啰划算得多。 +2. **相对不重要的优先砍**——这是个相对概念,后面 `oom_score_adj` 就是用来微调这一项的。 + +粗略理解:`oom_score` ≈ 占用内存大小,再做点归一化(按总内存的千分比表达),所以一个吃掉系统一半内存的进程,分数会接近 1000。 + +具体怎么算、`oom_badness()` 怎么给每个进程打分,核心在 `mm/oom_kill.c`(Linux 6.19),行号待亲测核对。我们这里抓主线:**它综合进程的 RSS(常驻内存)、页表、swap 占用,给出一个分数,然后挑分数最高的那个动手。** + +## 执行流程:out_of_memory → select_bad_process → oom_kill_process + +把整条调用链画出来,就知道杀手是怎么一步步动手的: + +1. **`out_of_memory()`**:OOM 的总入口。回收彻底失败、申请内存的进程已经快被饿死时,内核走到这里。它负责决定"要不要真的开杀"(有些情况会先重试回收)。 +2. **`select_bad_process()`**:遍历所有进程,调用 `oom_badness()` 给每个进程打分,挑出分数最高的那个"倒霉蛋"。 +3. **`oom_kill_process()`**:拿到受害者后,给它发 `SIGKILL`。进程被杀,它持有的内存(匿名页、页表、Slab 等)被回收。 + +这三步都在 `mm/oom_kill.c`(Linux 6.19),行号待亲测核对。整个过程是同步的——杀手动手、回收内存,让那个原本卡在分配上的进程终于拿到页,继续跑下去。 + +> 这也解释了为什么线上服务"莫名其妙"被杀:你的进程吃内存最多,`oom_badness()` 给它打了最高分,杀手毫不留情。日志里常常只剩一行 `Killed`,就是它干的。 + +## 保护重要进程:oom_score_adj + +杀手无情,但不是没法管。关键服务(sshd、数据库主进程、监控 agent)被杀一场灾难。内核给了我们一个手动加权旋钮:**`oom_score_adj`**。 + +- **范围**:`-1000` 到 `1000`。 +- **`-1000`**:**绝对免疫**,这个进程永不被 OOM 砍。生产环境保命首选。 +- **`1000`**:反过来——优先砍它(想自杀或者搞"自爆替死鬼"时用)。 +- 中间值则是在 `oom_badness()` 算出的原始分基础上做偏移。 + +```bash +# 保护 SSH 守护进程,让 OOM 永远别动它 +echo -1000 > /proc/$(pidof sshd)/oom_score_adj +``` + +> ⚠️ **待亲测核对**:上面这条是整理时的参考用法。我们会拿到 QEMU ARM64 上:先把某个吃内存的测试进程 `oom_score_adj` 设成 `-1000`,再造内存压力触发 OOM,看它是不是真活下来了——把 `-1000` 的"免死金牌"亲眼验一遍。 + +**旧接口 `oom_adj`**:早期内核用的是 `/proc//oom_adj`,范围 `-17` 到 `15`,`-17` 等价于现在的 `-1000`。现在它被 `oom_score_adj` 取代了,内核为了兼容还留着,但官方文档明确建议用新的。新代码别碰 `oom_adj`。 + +## 查看与调参:/proc 下的两个文件 + +跟 OOM 打交道,就这两个文件: + +| 文件 | 作用 | 可读/可写 | +|:---|:---|:---| +| `/proc//oom_score` | 当前进程的 OOM 分数(越高越该死) | 只读 | +| `/proc//oom_score_adj` | 手动加权,`-1000` 免死 / `1000` 优先砍 | 可读写 | + +排查"为什么偏偏杀了我的进程"的标准动作:`cat /proc//oom_score` 看它分有多高;想保住它就往 `oom_score_adj` 写 `-1000`。 + +## 动手待亲测 + +> 这部分是验证方案占位,还没在 QEMU 上跑过,跑通后会补真实输出、真实 dmesg、真实存活截图,升级成正式实战。 + +**验证目标一:造内存压力,真触发一次 OOM** +- 在 QEMU ARM64 里起一个故意吃内存的进程(比如一段不断 `malloc` 不 `free` 的死循环,或用 stress 工具)。 +- 也可以直接用 SysRq 强制走一遍 OOM 评估路径(命令待亲测核对): + ```bash + echo f > /proc/sysrq-trigger + ``` +- 观察:`dmesg` 里应该出现 OOM Killer 的判决日志(被打分的进程列表、最终被 `SIGKILL` 的受害者),系统是否恢复。 + +**验证目标二:用 oom_score_adj 保住指定进程** +- 起两个吃内存进程 A、B,A 比较重要。 +- `echo -1000 > /proc/$(pidof A)/oom_score_adj`。 +- 再造压力触发 OOM,期望看到:B 被杀、A 活着。 +- 把 A 的 `oom_score_adj` 改回 0,重复一次,这次该轮到分数更高的那个被杀——验证旋钮真的有效。 + +## 小结 + +OOM Killer 是内存管理的最后防线:回收(kswapd + direct reclaim)全部失败后,内核宁杀一进程也不让全系统崩。它靠 `oom_badness()` 给每个进程算 `oom_score`(吃内存越多分越高),挑最高分那个发 `SIGKILL`,`out_of_memory → select_bad_process → oom_kill_process` 三步走完。 + +想保住关键进程,就往 `/proc//oom_score_adj` 写 `-1000`,那是张免死金牌(旧接口 `oom_adj` 已废弃,别用)。下一篇回头看回收篇,就能把"分配失败 → 回收 → OOM"这条保命链路彻底串起来。 + +## 延伸阅读 + +- 源码:`mm/oom_kill.c`(Linux 6.19),OOM Killer 核心(`out_of_memory` / `select_bad_process` / `oom_kill_process` / `oom_badness`);`include/linux/oom.h` 看相关数据结构与接口。 +- kernel.org:[Memory Management guide](https://docs.kernel.org/admin-guide/mm/index.html)(稳定文档索引页,OOM 相关条目在其中)。 +- 进一步(持续铺开):页面回收与水位线(`kswapd` / direct reclaim)、swap 与 OOM 的联动、cgroup 内存控制器下的 OOM(memory cgroup OOM killer)。 \ No newline at end of file diff --git a/document/tutorials/kernel/net/01-net-overview.md b/document/tutorials/kernel/net/01-net-overview.md new file mode 100644 index 00000000..6d7ab9d8 --- /dev/null +++ b/document/tutorials/kernel/net/01-net-overview.md @@ -0,0 +1,131 @@ +--- +title: 网络栈全景:一个包的内核漂流 +slug: net-overview +difficulty: intermediate +tags: [网络栈, net_device, NAPI, sk_buff] +architectures: [arm64, x86_64, riscv] +kernel_version: "6.19" +maturity: drafting +prerequisites: + - /tutorials/foundations/07-kernel-module-hello +related: + - /tutorials/foundations/07-kernel-module-hello +sources: + - notes: document/notes/linux_kernel_networking/ch01_1.md + - notes: document/notes/linux_kernel_networking/ch01_2.md + - notes: document/notes/linux_kernel_networking/ch01_3.md +--- + +# 网络栈全景:一个包的内核漂流 + +> 🔨 **整理中** · 本篇函数签名/字段/数值已对照 Linux 6.19 源码校订(读书笔记基于较早内核版本,部分接口已演进);具体行号仍待 QEMU 亲测核对。这篇是从读书笔记(`linux_kernel_networking` ch01 系列,主章只算目录页,实质内容在 ch01_1/1_2/1_3 三个子章)提炼的全景骨架,L2-L4 铁三角、`net_device`/NAPI/`sk_buff` 的"为什么"已经讲透;但收发旅程里那些函数名的真实落点、`/proc/net/dev` 的样例输出,还没在 QEMU 里亲手验过。等我们在 QEMU 上 `tcpdump` + 内核模块 trace 跑过一遍真实收发,再升级成 ✅ 已锤炼。 + +## 网络栈为什么是"黑盒" + +写用户态网络程序的时候,我们大多数人脑子里只有两个洞:一个叫 `socket()`,一个叫 `read()/write()`。TCP 客户端写得再漂亮,并发模型再优雅,高吞吐下就是跑不满带宽;`iptables` 规则配了,包却像长了翅膀一样飞过去——这时候继续在用户态打转是没用的,**答案藏在内核源码的深处**。 + +我们这章要做的,就是把这个黑盒子撬开。 + +但撬开之前先建立坐标系。教科书的 OSI 七层模型像贴在墙上的旧海报,挂着没人看:物理层、数据链路层、网络层、传输层、会话层、表示层、应用层,七层齐齐整整。可是当你真去翻内核代码,会发现现实根本分不这么清楚。 + +内核真正操心的只有三层——**L2(链路层)、L3(网络层)、L4(传输层)**。它是一个夹心饼干:上面的 L5-L7(会话/表示/应用)交给用户态程序,下面的 L1(物理层)交给硬件和驱动工程师,内核只夹在中间这层"软肋"里做 L2 到 L4 的博弈。会话层、表示层在真实实现里基本被合并或忽略,应用协议自己管自己。 + +记住这张图:教科书七层是抽象坐标,内核铁三角才是代码现实。 + +## Linux 网络栈分层全景 + +从上往下,内核网络栈大致是这么几层(这是地图,后面的篇章逐层下钻): + +1. **VFS socket 层**:用户态程序调 `socket()/send()/recv()` 的入口,把"文件描述符"和"网络连接"接起来。这一层是用户态和内核态的边界。 +2. **协议族 / 传输层**:`net_protocol`(如 IPv4、IPv6 注册的 `ip_rcv()`)管 L3 入口分发,TCP/UDP 管 L4 的端到端可靠/不可靠传输。 +3. **IP 层**:做路由决策、分片重组、TTL 递减、Netfilter 防火墙钩子,是整个网络子系统的核心战场。 +4. **网络设备驱动**:通过 `net_device` 把物理网卡抽象成软件对象,用 NAPI 收发真实帧。 + +每一层之间都不是"直线通行"。包在 L2→L4 之间穿梭时会被反复安检和整形:被 NAT 改写 IP 地址、被 IPsec 加密、被防火墙丢掉、过大被分片、每层都要算一遍 checksum——内核本质上是一个"在协议栈各层间对数据包反复安检和修饰的精密工厂"。 + +## 一个包的内核漂流(接收方向) + +先把接收路径从下到上走一遍(笔记里最详细的一条线,函数名已对照 6.19 源码,行号待亲测核对): + +1. **网卡中断**:包从网线进来,网卡触发硬件中断,CPU 跑到驱动的中断处理函数。在 NAPI 模型下,驱动会暂时关中断,告诉内核"我现在有一堆包,你定期来轮询我拿"。 +2. **NAPI 轮询收包**:驱动用 `netdev_alloc_skb()`(老代码里叫 `dev_alloc_skb()`,6.19 里它退化成包着 `netdev_alloc_skb` 的 legacy helper)分配一个 `sk_buff`,把 DMA 搬进来的帧数据塞进去。 +3. **L2 处理 `eth_type_trans()`**:驱动调它判定包类型、剥掉以太网头。在 6.19 里它的实现是分两步走的(`net/ethernet/eth.c:155`):先调 `eth_skb_pull_mac(skb)`——这个 inline helper(`include/linux/etherdevice.h:639`)内部就是 `skb_pull_inline(skb, ETH_HLEN)`,把 `skb->data` 往后挪 14 字节(`ETH_HLEN = 14`)跳过以太网头,这就是"剥洋葱",剥掉 L2 露出 L3;再调 `eth_skb_pkt_type(skb, dev)`(`etherdevice.h:622`)依据目的 MAC 判定 `pkt_type`——组播 `PACKET_MULTICAST`、广播 `PACKET_BROADCAST`、别的主机 `PACKET_OTHERHOST`(目的 MAC 命中本机时不改写,`pkt_type` 在收包早期就预置成默认的 `PACKET_HOST = 0`,代表"是给我的")。至于以太网头 Type 字段(`0x0800` 是 IPv4,`0x86DD` 是 IPv6)填进 `skb->protocol`,是 `eth_type_trans` 的返回值干的事。 +4. **`netif_receive_skb`**:包交给网络核心,按 `skb->protocol` 分发。IPv4 的包会被扔给 `ip_rcv()`,IPv6 扔给 `ipv6_rcv()`——这两个协议处理函数是**协议模块**(如 IPv4 的 inet 初始化在 `net/ipv4/af_inet.c` 里)通过 `dev_add_pack()` 注册进 `ptype_all`/`ptype_base` 链表的(`net/ipv4/af_inet.c:2013` 那行 `dev_add_pack(&ip_packet_type)`,IPv6 同理在 `net/ipv6/af_inet6.c`)。注意挂 `ip_rcv` 进 ptype 链表的不是网卡驱动,驱动只负责 NAPI 收包与 `eth_type_trans`,把 L3 入口分发函数挂上去是协议栈自己的初始化职责。 +5. **`ip_rcv()` → `ip_rcv_finish()`**:先做一堆 sanity checks(健康检查),如果没被 Netfilter 的 `NF_INET_PRE_ROUTING` 钩子拦下,就进入 finish。在这里查路由子系统,构建一个 `dst_entry`(目标缓存项),决定这个包下一步往哪走——是留给本机继续往上,还是转发。 +6. **`tcp_v4_rcv`(传输层)→ socket 接收队列**:本机接收的包继续往上,TCP 头被剥掉,最终塞进对应 Socket 的接收队列,等用户态程序 `read()` 来取。 + +转发路径在 L3 就分叉了:查完路由表后不往上走,直接回头塞回 L2 发送队列,从另一张网卡发出去——转发包的 `skb->sk` 是 **NULL**,因为它是"过路客",不归任何本地 socket 管。 + +> ⚠️ **待亲测**:上面这条收发链路的函数名已经对照 6.19 源码核对过,但每一步的具体行号、`tcp_v4_rcv` 与 socket 入队之间的真实调用顺序,要我们在 QEMU 上挂 `kprobe` 逐个跑一遍才算锤炼落地。 + +## 一个包的内核漂流(发送方向) + +发送就是接收的镜像,从上往下走: + +1. **socket write**:用户态 `send()/write()` 下发数据到 socket 层。 +2. **传输层封装**:L4(TCP/UDP)给它加 TCP/UDP 头。 +3. **IP 层封装 + 路由**:L3 加 IP 头,查路由决定从哪张网卡出,过大就分片,过 Netfilter 的 `POST_ROUTING` 钩子。 +4. **邻居子系统填 MAC**:靠 ARP(IPv4)或 NDISC(IPv6)把"下一跳 IP"翻译成目标 MAC 地址,补上以太网头。 +5. **驱动发送**:最终通过 `net_device_ops` 里的发送回调(`ndo_start_xmit`)交给网卡驱动,驱动把帧 DMA 出去(老代码里这个回调曾叫 `hard_start_xmit`,现统一为 `net_device_ops->ndo_start_xmit`,`hard_start_xmit` 在 6.19 里只剩注释里的历史名字残留)。 + +笔记对发送方向的函数级落点讲得不如接收方向细,这里只给方向、不给具体函数名(比如 `tcp_sendmsg`/`ip_queue_xmit` 这类名字的真实对应关系),等我们读 `net/ipv4/tcp_output.c` 等源码亲测核对后再补上。**拿不准的宁可写"详见 X",也不编造数据通路。** + +## net_device:网卡的"身份证" + +内核眼里没有"网卡硬件"这个概念,一张网卡就是一个巨大的 `struct net_device` 实例。它装着这张网卡的全部"身家性命": + +- **硬件 IRQ 号**:CPU 靠它知道网卡有活干。 +- **MTU**:以太网默认 1500 字节,超过就得分片。 +- **MAC 地址**(`dev_addr`,48 位)、**设备名**(`eth0`/`wlan0`)、**标志位**(UP/DOWN/RUNNING)。 +- **`net_device_ops` 回调集**:网卡的操作手册,含打开/停止/发送/改 MTU 的函数指针——发送就是这里的 `ndo_start_xmit`。 +- **硬件特性**:是否支持 GSO/GRO 卸载、多队列(现代万兆卡有多 Tx/Rx 队列)、时间戳。 +- **ethtool 回调**:这就是 `ethtool eth0` 能读出一堆寄存器信息的原因。 + +有个特别容易忽略的细节:**混杂模式计数器 `promiscuity` 为什么是个计数器(`unsigned int`)而不是 `bool`?** 在 6.19 里 `include/linux/netdevice.h` 把它声明成 `unsigned int promiscuity`,配套的 `dev_set_promiscuity(dev, int inc)` / `netif_set_promiscuity(dev, int inc)` 入参才是带符号 `int`。想象两个抓包工具同时开:`tcpdump` 启动 `+1`(变 1)→ `wireshark` 启动 `+1`(变 2)→ `tcpdump` 退出 `-1`(变 1,网卡仍混杂)→ `wireshark` 退出 `-1`(变 0,才退出混杂)。用布尔值的话,第二个工具一关就把第一个也带没了。这是"多用户共享资源状态"的经典设计,抓包工具能并发跑全靠它。 + +## NAPI:为什么不能每个包一个中断 + +旧时代的网卡驱动简单粗暴:来一个包,发一个中断。包来了 → 中断 → CPU 保存上下文 → 跑中断处理 → 拿包 → 恢复上下文。平时上网没事,可一旦碰上 DDoS 或海量小包流量,CPU 就崩了——每秒几十万个中断,光"进场退场"(保存/恢复寄存器)就把算力耗光,正事根本干不动。这在操作系统里叫**中断活锁**。 + +NAPI(New API)的解法是**根据负载动态切换策略**: + +- **低负载**:还是用中断,没包就不打扰 CPU,省电且响应快。 +- **高负载**:切换到**轮询**。中断触发一次后驱动关掉该中断,告诉内核"我这有一堆包,你自己定期来轮询我拿"。 + +效果就是把"N 个包 = N 次中断上下文切换"变成"N 个包 = 1 次中断 + 轮询"。代价是延迟会涨一点(要等轮询周期)。对延迟极致敏感、愿意挥霍 CPU 的场景(高频交易),内核还有 Busy Polling on Sockets(应用通过 `SO_BUSY_POLL` 把套接字切到主动轮询,6.19 文档里归在 NAPI busy polling 一类),那是更偏门的优化,留到后面专章。 + +## sk_buff:贯穿全栈的"快递盒" + +收发旅程里那个从网卡一路被传到 socket 的东西,就是 `sk_buff`(简称 SKB)——内核网络栈里最核心、最复杂、也最令人头秃的数据结构。无论包刚被驱动捞上来,还是正要从 TCP 发出去,它的"肉身"都是一个 SKB。 + +SKB 靠 `head`/`data`/`tail`/`end` 一组指针,加上 L2/L3/L4 三个 header 偏移,灵活地处理协议头的剥除与添加。新手最容易犯的错是手动 `skb->data++`——千万别。内核有一套严格的 API:剥头用 `skb_pull()`,预留头用 `skb_push()`,取各层头用 `skb_transport_header()`/`skb_network_header()`/`skb_mac_header()`。遵守它才能管好 SKB 内部那个线性区 + 分页结构。 + +这篇只点到为止。SKB 的指针布局、零拷贝、clone/clone-with-fragments,下一篇 `02-net-sk-buff` 会掰开揉碎讲。 + +## 本藤地图 + +这篇是全景鸟瞰,把铁三角的形状、`net_device`/NAPI/`sk_buff` 是干嘛的、一个包怎么漂的,先在脑子里建立起来。后面是一条逐层下钻的藤蔓: + +- **`02-net-sk-buff`**:SKB 指针布局与零拷贝 API。 +- **邻居子系统**:ARP/NDISC 怎么把 IP 翻译成 MAC。 +- **IPv4 / 路由**:`ip_rcv` 之后的路由决策、`dst_entry`、FIB。 +- **TCP**:可靠传输、拥塞控制、`tcp_v4_rcv` 之后的那些事。 + +## 小结 + +Linux 网络栈不是一块铁板,而是一条由无数挂钩组成的流水线:从 NAPI 的中断/轮询混合收包开始,包被抬起 → 经 Netfilter 防火墙过滤 → 穿路由岔路口 → 经邻居子系统找下一跳 MAC → 最后落进 Socket 被用户态接住。内核只管 L2-L4,上面是应用,下面是硬件,它夹在中间做高速流动、反复校验与转发的精密加工。 + +记住三个主角:`net_device`(网卡的身份证,`promiscuity` 是 `unsigned int` 计数器,是共享状态经典)、NAPI(负载自适应的中断+轮询,解掉中断风暴)、`sk_buff`(贯穿全栈的快递盒,操作必须走 `skb_pull/push` API 不能乱改指针)。还有一条认知:**内核网络开发是双轨制江湖**(`net` 管修复、`net-next` 管新特性,`netdev` 邮件列表 + `checkpatch.pl`/`get_maintainer.pl` 是入场券),但那是写代码给主线的事,读懂栈先用不到,先存着。 + +## 延伸阅读 + +- 源码(Linux 6.19,行号待亲测核对): + - `net/core/dev.c`——`netif_receive_skb`、`dev_add_pack`、设备注册核心。 + - `net/ipv4/ip_input.c`——`ip_rcv`/`ip_rcv_finish`。 + - `net/ipv4/tcp_ipv4.c`——`tcp_v4_rcv` 入口。 + - `include/linux/netdevice.h`——`struct net_device`(`unsigned int promiscuity`)、`net_device_ops`、`ndo_start_xmit`。 + - `include/linux/skbuff.h`——`struct sk_buff`、`skb_pull`/`skb_push` 等操作 API。 + - `net/ethernet/eth.c` + `include/linux/etherdevice.h`——`eth_type_trans` 及 `eth_skb_pull_mac`/`eth_skb_pkt_type` 两个 helper。 + - `Documentation/networking/napi.rst`——NAPI 与 busy polling 官方说明。 +- kernel.org 稳定文档索引:[Networking documentation](https://docs.kernel.org/networking/index.html)、[Kernel networking — core API](https://docs.kernel.org/networking/kernel.html)。 +- 进一步(持续铺开):`02-net-sk-buff` 详讲 SKB,邻居子系统(ARP/NDISC),IPv4 与路由子系统,TCP 收发。 \ No newline at end of file diff --git a/document/tutorials/kernel/net/02-net-sk-buff.md b/document/tutorials/kernel/net/02-net-sk-buff.md new file mode 100644 index 00000000..b8a79fae --- /dev/null +++ b/document/tutorials/kernel/net/02-net-sk-buff.md @@ -0,0 +1,192 @@ +--- +title: sk_buff:贯穿网络栈的快递盒 +slug: net-sk-buff +difficulty: intermediate +tags: [网络栈, sk_buff, 数据结构, 缓冲区管理] +architectures: [arm64, x86_64, riscv] +kernel_version: "6.19" +maturity: drafting +prerequisites: + - /tutorials/kernel/net/01-net-overview +related: + - /tutorials/kernel/net/01-net-overview +sources: + - notes: document/notes/linux_kernel_networking/ch01_2.md + - notes: document/notes/linux_kernel_networking/ch01_3.md + - notes: document/notes/linux_kernel_networking/ch04_2.md + - notes: document/notes/linux_kernel_networking/ch04_5.md + - notes: document/notes/linux_kernel_networking/ch04_6.md + - notes: document/notes/linux_kernel_networking/ch04_9.md +--- + +# sk_buff:贯穿网络栈的快递盒 + +> 🔨 **整理中** · 这篇是从《Linux 内核网络》ch01(Socket Buffer 节)和 ch04(IPv4 收发/分片)读书笔记整理出来的骨架,sk_buff 的四指针模型、收包剥头/发包加头那套机制已经讲透了。**本篇函数签名/字段/数值已对照 Linux 6.19 源码校订(读书笔记基于较早内核版本,部分接口已演进);具体行号仍待 QEMU 亲测核对。** 但动手部分还没在 QEMU 里亲跑过——下一步要写个内核模块 `alloc_skb` 出来,把 `head/data/tail/end` 四个指针在 `reserve/put/push/pull` 一步步移动的真实数值打出来核对。验过就升级成 ✅ 已锤炼。 + +## sk_buff 不是包,是包的「快递盒 + 运单」 + +刚翻进网络栈,第一个撞上的就是 `sk_buff`(社区里都叫它 **SKB**)。很多人第一反应是:「这不就是装数据包的那块内存吗?」——错。**SKB 不是包本身,它是包的「快递盒 + 运单」。** 数据包字节躺在盒子里那块叫线性区的内存里(有时还搭几页零散的 frags),而 SKB 这个结构体本身,是一堆指针和元数据,记录着「这货从哪来、往哪去、现在头部指向第几层、归谁所有」。 + +笔记里 `ch01_2` 直接把话挑明了:SKB 是「数据包在内核里的肉身」——不管这包刚被网卡驱动捞上来、还是正准备从 TCP 层发出去,它在内核里都是一个 SKB。盒子里装的字节可以一层一层剥、一层一层加,但盒子始终是同一个,这就是它能在协议栈各层之间高速传递而不需要反复拷贝的关键。 + +## 为什么不直接用裸 buffer + +如果只是装字节,一个 `char *` 数组不就够了吗?干嘛要套这么复杂一层结构。三个理由: + +1. **分层元数据**:网络栈是 L2/L3/L4 三层流水线。每一层都要知道自己关心的头部在哪、协议类型是什么、关联哪张网卡。这些信息塞进裸 buffer 里没法分层管理,所以单独拎出来做成 SKB 的字段(`dev`、`protocol`、那一串 `*_header` 偏移)。 +2. **跨层零拷贝传递**:收包是从 L2 往 L4 剥头、发包是从 L4 往 L2 加头。如果每过一层都拷贝整个包,千兆网卡下 CPU 早被搬数据的活儿压垮了。SKB 靠挪指针(不动数据)实现「层层蜕变」,数据本身始终待在原处。 +3. **引用计数共享**:组播要一份包同时发往多个目的地,或者 netfilter/抓包工具想顺手看一眼包——总不能每次都深拷贝。SKB 自带引用计数(`users` 字段,6.19 里是 `refcount_t` 类型),可以克隆共享。 + +> ch01_3 的「要点提炼」里那句话很到位:SKB 通过维护 `head`、`data`、`tail` 等指针灵活处理协议头部的剥除与添加,让收包时能层层「剥皮」,实现了协议层级间零拷贝的高性能。 + +## 关键字段巡礼 + +照着 `include/linux/skbuff.h`(Linux 6.19)里的 `struct sk_buff` 定义,挑核心成员点一遍(行号待亲测核对): + +```c +struct sk_buff { + /* ... */ + struct sock *sk; /* 拥有这个包的套接字 */ + struct net_device *dev; /* 关联的网卡设备 */ + /* ... */ + __u8 pkt_type:3; /* 包类型(单播/组播/广播)*/ + /* ... */ + __be16 protocol; /* 协议类型 */ + /* ... */ + sk_buff_data_t tail; /* 尾部指针 */ + sk_buff_data_t end; /* 结束指针 */ + unsigned char *head, + *data; /* 头部和数据指针 */ + __u16 transport_header; /* L4 头部偏移 */ + __u16 network_header; /* L3 头部偏移 */ + __u16 mac_header; /* L2 头部偏移 */ + /* ... */ +}; +``` + +几个最常打交道的: + +- **`next` / `prev`**:SKB 自带链表指针,可以串成队列(比如 socket 的接收队列、发送队列 `sk_write_queue`)。一个 IP 包分片就是一串挂在 `frag_list` 上的 SKB 链。 +- **`dev`**:这个包归哪张网卡。收上来的包记输入网卡,发出去的包记输出网卡——内核要根据这张网卡的 MTU 决定要不要切片。 +- **`sk`**:拥有这个包的 socket。**转发的包 `sk` 是 `NULL`**,因为它不是本地生的,只是个「过路客」(ch01_2 原话)。 +- **`pkt_type` / `protocol`**:收包时由 `eth_type_trans()` 填,前者区分单播/组播/广播(`PACKET_HOST`/`PACKET_MULTICAST`/`PACKET_BROADCAST`),后者记以太网 Type(`0x0800` 是 IPv4,`0x86DD` 是 IPv6)。 +- **`transport_header` / `network_header` / `mac_header`**:三层头部各自在缓冲区里的偏移位置。要拿对应层头部用配套的取值宏,不直接读指针。 + +> 字段类型对齐 6.19:三层 `*_header` 偏移字段都是 `__u16`(偏移量足够小,省内存);`head`/`data` 是 `unsigned char *` 指针;`tail`/`end` 是 `sk_buff_data_t`(64 位内核下就是 `unsigned int`,同样是偏移量)。完整定义在 `include/linux/skbuff.h`,远不止这些——笔记建议遇到卡壳就回附录 A 翻「字典」。 + +## 房间四指针:head / data / tail / end + +整个 SKB 内存区想象成一间「房间」,四面墙各立一根标尺: + +- **`head`**:房间最左边的墙,缓冲区起始。分配后就固定不动。 +- **`data`**:当前有效数据的起点。这根线是「活动」的——收包剥头往后挪、发包加头往前挪。 +- **`tail`**:当前有效数据的终点。往里塞数据就往后挪。 +- **`end`**:房间最右边的墙,缓冲区终止。 + +`head` 和 `end` 围出整个缓冲区;`data` 和 `tail` 之间是**当前装着的数据**;`head` 到 `data` 之间那块叫 **headroom**(预留的头空间),`tail` 到 `end` 之间那块叫 **tailroom**(预留的尾空间)。这两块预留是后面 `push`/`put` 操作能成立的物理基础。 + +收包时 L2 头在最前面,`data` 指着 L2 头;发包时是反着来的,先把数据放中间,头从前往后 push。同一块缓冲区,两种方向都能玩,全靠这四根线配合。 + +## 房间伸缩四件套 + +这四个操作是 SKB 的「黄金法则」——笔记 ch01_2 反复警告:**千万别手动 `skb->data++`**,一切走配套 API。因为它们除了挪指针,还得维护 SKB 内部那个线性区和分页结构的账。 + +| 操作 | 干什么 | 形象 | 典型场景 | +|:---|:---|:---|:---| +| `skb_reserve(skb, len)` | `data` 和 `tail` 同时往后挪 len 字节 | 在房间前部空出预留区 | 分配后立刻预留头空间 | +| `skb_put(skb, len)` | `tail` 往后挪 len,扩展数据区 | 尾部放大装数据 | 往包里 append 数据 | +| `skb_push(skb, len)` | `data` 往前挪 len,吃掉一段 headroom | 前推加一层协议头 | 发包层层加头(L4→L3→L2)| +| `skb_pull(skb, len)` | `data` 往后挪 len,剥掉一段头部 | 收缩剥头 | 收包层层剥头(L2→L3→L4)| + +**收包:层层剥头。** 最经典的例子是驱动把包交给 L3 那一刻。笔记 ch01_2 写得很细:以太网帧进内存时 `skb->data` 指着 L2 头,但交给 L3 时内核希望 `data` 指着 L3(IP)头。**在 6.19 里这一跳经了一层包装**:`eth_type_trans()`(`net/ethernet/eth.c`)自己负责 `skb_reset_mac_header()`、`eth_skb_pkt_type()`(填 `pkt_type`)、判断协议填 `protocol`;真正剥 L2 头的活儿它转手交给了内联函数 `eth_skb_pull_mac()`(`include/linux/etherdevice.h`),后者就一句 `skb_pull_inline(skb, ETH_HLEN)`——正好是 14 字节——指针往后一跳,跳过 L2 头。 + +```c +/* include/linux/etherdevice.h:eth_skb_pull_mac,剥掉 14 字节以太网头 */ +static inline struct ethhdr *eth_skb_pull_mac(struct sk_buff *skb) +{ + struct ethhdr *eth = (struct ethhdr *)skb->data; + skb_pull_inline(skb, ETH_HLEN); /* ETH_HLEN == 14 */ + return eth; +} +``` + +笔记的比喻依然成立:「你在剥洋葱,剥掉一层(L2),手里剩下的刚好是下一层(L3)。」只是别去 `eth_type_trans()` 函数体里找 `skb_pull_inline` 这行——它在 `etherdevice.h` 里。 + +ch04_2 的 `ip_rcv()` 拿到包时,L2 头已经剥掉了,`skb->data` 正指着 IPv4 头,`ip_hdr(skb)` 取出来的就是 IP 头——这是收包剥头的接力。(顺带一提:6.19 里 `ip_rcv()` 把实际处理塞进了 `ip_rcv_core()` 辅助函数,但「L2 已剥、data 指 IP 头」这个接力关系不变。) + +**发包:层层加头,顺序相反。** `__ip_queue_xmit()`(`net/ipv4/ip_output.c`,对外导出包装是 `ip_queue_xmit()`,ch04_5 笔记记的就是这条 TCP 路径)里写得明明白白:SKB 从传输层下来时 `data` 指着 TCP 头,要给 IP 头腾位置就得 `skb_push()`: + +```c +/* net/ipv4/ip_output.c:TCP 层下来的 skb,data 指着传输层头,往前推腾出 IP 头 */ +skb_push(skb, sizeof(struct iphdr) + (inet_opt ? inet_opt->opt.optlen : 0)); +skb_reset_network_header(skb); +iph = ip_hdr(skb); +``` + +push 完,`data` 往前挪到了 IP 头的位置,正好把 TCP 头「盖」在前头。到了 L2 还会再 push 一次以太网头。整条发包路径是「数据放中间,头部从外向内一层层 push 包上去」。 + +> 想拿各层头还有配套取值宏:`skb_transport_header()` 拿 L4、`skb_network_header()` 拿 L3、`skb_mac_header()` 拿 L2;还有 `skb_reset_*` 系列重置对应偏移。 + +## 分配与释放 + +**分配**收包路径上,驱动用 `netdev_alloc_skb()`(老代码里还常能见到薄包装 `dev_alloc_skb()`,6.19 里它仍以 legacy helper 形式保留)分配 SKB,见 ch01_2。分片慢路径里能看到最底层的 `alloc_skb()` 用法——下面这段忠实还原笔记 ch04_6 贴的较早内核 `ip_fragment()` 写法,把四件套里的两个串起来了: + +```c +/* 分片慢路径:为每个碎片新分配 SKB(结构对应较早内核;6.19 等价代码见下注) */ +if ((skb2 = alloc_skb(len + hlen + ll_rs, GFP_ATOMIC)) == NULL) { + err = -ENOMEM; + goto fail; +} +ip_copy_metadata(skb2, skb); +skb_reserve(skb2, ll_rs); // 先预留链路层头空间 +skb_put(skb2, len + hlen); // 尾部放大,装 IP 头 + 数据 +skb_reset_network_header(skb2); +skb2->transport_header = skb2->network_header + hlen; +``` + +先 `skb_reserve` 在前部留出 L2 头空间(发包时要 push 以太网头进去),再 `skb_put` 把数据区撑开。注意这里用的 `GFP_ATOMIC`——分片可能持着锁,不能睡眠。 + +> **6.19 对齐**:这段对应的是较早期内核的 `ip_fragment()`。在 6.19 里,分片主入口拆得更细了:`ip_fragment()`(`ip_output.c`)先判断 DF 位,把真正的切包活儿转交给 `ip_do_fragment()`;而上面这段 alloc/reserve/put 的代码已经被抽进辅助函数 `ip_frag_next()`(`ip_output.c`),`ip_do_fragment()` 的 `while` 循环里就一句 `skb2 = ip_frag_next(skb, &state)`。但 `alloc_skb + skb_reserve + skb_put + skb_reset_network_header` 这套四件套用法**一字未改**——`ip_frag_next()` 里逐行对得上。所以学四件套看这段老代码反而更直白。 + +**释放**有两套。普通丢弃用 `kfree_skb()`(DF 位置位拒绝分片那条路径里直接 `kfree_skb(skb)` 扔包);如果是正常消费完(数据已发出去、引用该回收了)用 `consume_skb()`。分片收尾就是 `consume_skb(skb)` 释放原始大包(`ip_do_fragment()` 成功收尾处)。两者区别在语义和统计计数——`kfree_skb` 通常意味着「异常丢弃」,`consume_skb` 意味着「正常用完」。 + +> 更精确地说,6.19 里 `kfree_skb()` 是 `kfree_skb_reason(skb, SKB_DROP_REASON_NOT_SPECIFIED)` 的内联包装,**会走 skb drop reason 子系统、记进丢包统计**;而 `consume_skb()` 是成功路径的引用回收,**不记丢包**。所以这俩不止是命名差异,连计数器都分得清清楚楚。 + +**headroom / tailroom 预留的意义**:分配时故意多留一段头空间(`netdev_alloc_skb` 这类会预留 `NET_SKB_PAD`——6.19 里定义为 `max(32, L1_CACHE_BYTES)`),就是为了后面发包时 `skb_push` 加各层头不用重新分配内存。如果头空间不够 push,会触发代价昂贵的 `__pskb_pull_tail` / `pskb_expand_head` 重新分配——高性能路径要极力避免。 + +## 共享与克隆 + +同一个包想被多方同时看一眼(组播、netfilter、抓包),就得能共享。SKB 有两套拷贝机制,笔记里能拼出来: + +- **`skb_clone(skb, gfp)`**:浅拷贝。**只复制 SKB 这个结构体本身,底层数据共享同一块缓冲区**。两个 SKB 各自有独立的元数据(指针、头部偏移可以各挪各的),但指向的数据是同一份、只读。引用计数管理,谁都不许改共享数据。适合「我只想偷看一眼这个包」。 +- **`pskb_copy(skb, gfp)`** / **`skb_copy(skb, gfp)`**:深拷贝。连数据区一起复制一份,两个 SKB 彻底独立。代价大,只有真要改数据时才用。 + +引用计数是这套共享的命根子——`skb->users`(6.19 里是 `refcount_t`)。`kfree_skb` / `consume_skb` 不是直接释放,而是先把引用计数减一,减到 0 才真释放底层数据。所以「持有 SKB 的各方各自管理自己的引用」是铁律。 + +## 小结 + +`sk_buff` 是网络栈里数据包的唯一肉身:一块缓冲区(head/data/tail/end 四根线围出来),外加一堆元数据(dev、sk、三层 header 偏移、protocol、pkt_type)。它的精妙全在那四根线上——`skb_reserve` 预留、`skb_put` 装数据、`skb_push` 加头、`skb_pull` 剥头,让收包层层剥、发包层层加而数据不动,实现了跨层零拷贝。配合 `alloc_skb`/`kfree_skb`/`consume_skb` 的生命周期和 `skb_clone`/`pskb_copy` 的共享语义,整个网络栈的高数据通路才转得起来。 + +记住两件事:**一切指针操作走配套 API、绝不手动 `data++`**;以及 **发包是数据在中间头往外 push、收包是头往里 pull 剥掉**——方向相反,但用的是同一块缓冲区、同一套四指针。 + +## 动手试试 + +> ⚠️ **待亲测**:下面的方案还没在 QEMU 上跑过,先把骨架立在这。 +> +> **目标**:写一个最小内核模块,`alloc_skb` 一个 SKB,把 `reserve/put/push/pull` 四步各执行一次,每步打印 `head/data/tail/end` 四个指针(以及 `skb_headlen`/`skb_tailroom`/`skb_headroom`),肉眼核对指针移动方向是否和正文那张表一致。 +> +> **验证清单(待填真实数值)**: +> - [ ] `alloc_skb` 之后 `data == tail`(数据区为空),`headroom == NET_SKB_PAD`、`tailroom == sizeof(skb_shared_info)` 量级 +> - [ ] `skb_reserve(skb, 16)` 后 `data`、`tail` 同时 +16,`headroom` 缩 16、数据区仍为空 +> - [ ] `skb_put(skb, 20)` 后 `tail` +20,数据区长度变 20,`tailroom` 缩 20 +> - [ ] `skb_push(skb, 10)` 后 `data` -10,数据区长度变 30(10+20),`headroom` 缩 10 +> - [ ] `skb_pull(skb, 10)` 后 `data` +10,数据区长度回到 20 +> +> **踩坑预警(待亲测验证)**:`skb_push`/`skb_pull` 越界会触发 `BUG()`(不是返回错误),所以 reserve 的量必须够 push 用——这正好印证正文那句「headroom 不够就触发 `pskb_expand_head` 重分配」。具体宏的真实行为、`SKB_DATA_ALIGN` 的对齐填充,以及 `dmesg` 里打出来的指针差值,都以 QEMU 亲跑为准,回头补进正文。 + +## 延伸阅读 + +- 源码:`include/linux/skbuff.h`(Linux 6.19),`struct sk_buff` 定义 + 全套 `skb_*` 内联函数;`net/core/skbuff.c`,`alloc_skb` / `kfree_skb` / `consume_skb` / `skb_clone` / `pskb_copy` 实现;`include/linux/etherdevice.h`,`eth_skb_pull_mac` / `eth_skb_pkt_type`;`net/ethernet/eth.c`,`eth_type_trans`;`net/ipv4/ip_output.c`,`__ip_queue_xmit` / `ip_do_fragment` / `ip_frag_next`。 +- 笔记:`document/notes/linux_kernel_networking/ch01_2.md`(SKB 诞生与黄金法则)、`ch01_3.md`(要点提炼里的四指针总结)、`ch04_2.md`(`ip_rcv` 收包剥头接力)、`ch04_5.md`(发包 `skb_push` 加 IP 头)、`ch04_6.md`(分片慢路径的 `alloc_skb`/`reserve`/`put`)、`ch04_9.md`(IPv4 方法速查,含 `skb_has_frag_list` 改名典故)。 +- kernel.org:[Networking documentation](https://docs.kernel.org/networking/index.html)、[Core API](https://docs.kernel.org/core-api/index.html)(持续铺开,skb 详细文档以官方稳定索引页为准)。 +- 进一步(待铺开):`frag_list` / `frags[]` 两种分片方式、`skb_shared_info`、NAPI 收包与 SKB 批量回收。 \ No newline at end of file diff --git a/document/tutorials/kernel/net/03-net-neighbor.md b/document/tutorials/kernel/net/03-net-neighbor.md new file mode 100644 index 00000000..0ed945d4 --- /dev/null +++ b/document/tutorials/kernel/net/03-net-neighbor.md @@ -0,0 +1,226 @@ +--- +title: 邻居子系统与 ARP:IP 怎么找到 MAC +slug: net-neighbor +difficulty: intermediate +tags: [邻居子系统, ARP, NDISC, NUD 状态机] +architectures: [arm64, x86_64, riscv] +kernel_version: "6.19" +maturity: drafting +prerequisites: + - /tutorials/kernel/net/01-net-overview +related: + - /tutorials/kernel/net/01-net-overview +sources: + - notes: document/notes/linux_kernel_networking/ch07.md + - notes: document/notes/linux_kernel_networking/ch07_1.md + - notes: document/notes/linux_kernel_networking/ch07_2.md + - notes: document/notes/linux_kernel_networking/ch07_3.md + - notes: document/notes/linux_kernel_networking/ch07_4.md + - notes: document/notes/linux_kernel_networking/ch07_5.md +--- + +# 邻居子系统与 ARP:IP 怎么找到 MAC + +> 🔨 **整理中** · 这篇是从读书笔记(`ch07` 全章 + 四个子章)整理出来的骨架,邻居表、NUD 状态机、ARP/NDISC 数据通路都讲透了;但动手部分(QEMU 上 `ip neigh show` 看表、`arping` 抓请求应答、故意改 MAC 触发 STALE)还没亲手跑过。等我们在 QEMU 里验过,就升级成 ✅ 已锤炼。 +> +> 本篇函数签名/字段/数值已对照 Linux 6.19 源码校订(读书笔记基于较早内核版本,部分接口已演进);具体行号仍待 QEMU 亲测核对。 + +## 网络课没讲透的那一步:IP 撞上 MAC + +我们 ping 一个 IP,路由表查完了,下一跳清清楚楚,可网卡偏偏就是不发包。它在等什么?等一个答案——那个 IP 对应的 MAC 地址到底是什么。 + +这就是邻居子系统存在的全部理由。**IP 是网络层(L3)的逻辑地址,方便我们规划网络;但网卡只认 MAC(L2),它根本不知道 IP 是什么东西。** 在最后那一跳,IP 地址其实毫无用处。内核必须把"下一跳 IP"翻译成"对端 MAC",数据包才出得去网卡。 + +这道翻译由两个协议干:IPv4 用 **ARP**(1982 年的 RFC 826),IPv6 用 **NDISC**(RFC 4861)。它们名字不同、报文格式不同、严谨程度差着好几个量级,但内核里被同一套框架收编——**邻居子系统(neighbouring subsystem)**。这篇我们就拆这个黑盒子。 + +## 邻居表 `neigh_table`:IP→MAC 的缓存仓库 + +每个协议族一张表:IPv4 的是 `arp_tbl`,IPv6 的是 `nd_tbl`。两者结构几乎一模一样,核心定义分别在 `net/ipv4/arp.c` 和 `net/ipv6/ndisc.c`(Linux 6.19)。 + +每张表的关键成员: + +- **哈希表**:邻居条目(`struct neighbour`)挂在哈希桶里,按 L3 地址(IPv4 是 4 字节 IP、IPv6 是 `struct in6_addr`)做 key 查找。条目多了会自动 `neigh_hash_grow()` 扩容。 +- **`gc_thresh1/2/3`**:垃圾回收的三道闸,6.19 里 ARP 和 NDISC 默认都是 128 / 512 / 1024(`arp.c:181-183`、`ndisc.c:140-142`)。条目到 `gc_thresh3`(硬上限)还想新建,直接拒绝。 +- **`constructor` 回调**:协议特定的构造函数,ARP 是 `arp_constructor()`、NDISC 是 `ndisc_constructor()`,负责填协议专属字段、挑 `neigh_ops` 操作集。 + +核心数据结构定义在 `include/net/neighbour.h`,三套源码分别躲在 `net/core/neighbour.c`(通用框架)、`net/ipv4/arp.c`、`net/ipv6/ndisc.c`。 + +## 创建一个邻居:先跟 GC 搏斗 + +入口是 `__neigh_create()`(签名见 `include/net/neighbour.h:346`):拿到表 `tbl`、L3 关键字 `pkey`、出站网卡 `dev`、是否要引用计数 `want_ref`。它第一步就调 `neigh_alloc()`,而 `neigh_alloc()`(`neighbour.c:497`)一上来不是分内存,是先把**用于 GC 门控的条目计数** `atomic_inc_return(&tbl->gc_entries)` 拿在手里(`neighbour.c:508`),然后盯着阈值脸色行事: + +``` +gc_entries >= gc_thresh3 + OR (gc_entries >= gc_thresh2 且 距上次清理 > 5*HZ) + → 触发同步 GC neigh_forced_gc() + → 清完还 >= gc_thresh3 → 直接拒绝分配(out_entries) +``` + +这里有个 6.x 起拆出来的细节:**门控判定用的是 `gc_entries`,而 `tbl->entries`(条目总数)要等分配成功后才在 `neighbour.c:544` 递增**。两者不是一回事——GC 看的是"算上门槛预占"的那本账。 + +`neigh_forced_gc()` 是个暴力拆迁队:所有非 `NUD_PERMANENT`、引用计数为 1 的条目,统统标 `dead=1` 释放掉。分配完内存,还要调协议的 `constructor`(`neighbour.c:668`),它顺手处理两类**不需要 ARP** 的特殊地址: + +- **多播**:`RTN_MULTICAST` 类型的地址(往 `224.0.0.1` 发包不问"你是谁"),直接 `arp_mc_map()` 算出多播 MAC 填进 `ha`,状态标 `NUD_NOARP`(`arp.c:269-271`)。 +- **广播**:`RTN_BROADCAST`(`255.255.255.255` 这种)或点对点设备,把网卡广播地址 `dev->broadcast` 抄进 `ha`,同样 `NUD_NOARP`(`arp.c:275-278`)。 + +> IPv6 没有传统广播概念(`ff02::1` 等被归为多播),所以 `ndisc_constructor()` 里没有这段广播逻辑。 + +最后还有个反直觉的小细节:新条目的 `confirmed` 字段被设成**过去的时间**(`neighbour.c:688`,`jiffies - base_reachable_time*2`)。意思是"我虽然是新生的,但我现在就需要你验证我"——别让一个空壳子被当成可信的长期缓存。 + +## NUD 状态机:疑神疑鬼的守门人 + +邻居条目的核心是 `nud_state` 字段,这是一套状态机,内核靠它时刻怀疑"邻居是不是还活着": + +| 状态 | 含义 | +|:---|:---| +| `NUD_INCOMPLETE` | 刚建,MAC 还没解析出来 | +| `NUD_REACHABLE` | 最近确认过可达,直接发 | +| `NUD_STALE` | 有一阵没用过了,缓存可能过期 | +| `NUD_DELAY` | 用到了,先延迟一会儿 | +| `NUD_PROBE` | 真的开始发探测包验证 | +| `NUD_FAILED` | 验证失败,准备清掉 | +| `NUD_NOARP` | 多播/广播,不需要解析 | +| `NUD_PERMANENT` | 用户手动加的静态条目 | + +典型流转:建表时 `INCOMPLETE` → 收到回应变 `REACHABLE` → 一段时间不用变 `STALE` → 下次发包触发 `DELAY` → 计时器到点进 `PROBE` → 探测成功回 `REACHABLE`,失败进 `FAILED`。 + +这种"信任但验证"是必须的——局域网里拔网线不需要打招呼,内核只能靠不停试探维持现实一致。每个邻居自带定时器,闹铃响了由 `neigh_timer_handler()` 推动状态流转。 + +## ARP 流程:内核不知 MAC 时怎么喊话 + +发包走 `ip_finish_output2()`(`net/ipv4/ip_output.c:200`)时,手里只有下一跳 IP。6.19 的代码已经不像老内核那样直接 `__ipv4_neigh_lookup_noref`/`__neigh_create` 一把梭,而是收敛进一个助手 `ip_neigh_for_gw()`,一把把邻居拿过来再交给 `neigh_output()`: + +```c +/* net/ipv4/ip_output.c:230 */ +rcu_read_lock(); +neigh = ip_neigh_for_gw(rt, skb, &is_v6gw); /* 网关邻居(IPv4/IPv6 网关都走这里) */ +if (!IS_ERR(neigh)) { + sock_confirm_neigh(skb, neigh); + res = neigh_output(neigh, skb, is_v6gw); /* 跨协议网关时不走 hh 缓存 */ + rcu_read_unlock(); + return res; +} +``` + +`ip_neigh_for_gw()`(`include/net/route.h:412`)根据路由里的网关族(`rt_gw_family` 是 IPv4 还是 IPv6)分别调 `ip_neigh_gw4()`/`ip_neigh_gw6()`,没设网关就把目标地址当直连。`is_v6gw` 这个布尔会一路传给 `neigh_output()` 当 `skip_cache`——跨协议(比如 IPv4 over IPv6 隧道网关)时不能复用缓存的 L2 头。 + +`neigh_output()`(`include/net/neighbour.h:543`)做关键判断:只有 `nud_state & NUD_CONNECTED` 且有缓存的 L2 头(`hh->hh_len` 非零)时才走快路径 `neigh_hh_output()`;否则调 `n->output`,ARP 这边指向 `neigh_resolve_output()`。 + +```c +/* include/net/neighbour.h:543 */ +static inline int neigh_output(struct neighbour *n, struct sk_buff *skb, + bool skip_cache) +{ + const struct hh_cache *hh = &n->hh; + if (!skip_cache && + (READ_ONCE(n->nud_state) & NUD_CONNECTED) && + READ_ONCE(hh->hh_len)) + return neigh_hh_output(hh, skb); + return READ_ONCE(n->output)(n, skb); +} +``` + +> 老内核里这块用的是 `dst_neigh_output()`、声明在 `include/net/dst.h`,6.x 早已经迁走、全树搜不到 `dst_neigh_output`。如果你看的是更早的笔记/书(包括本站的 ch07_3),那里写的 `dst_neigh_output` 在 6.19 已是历史名字。 + +`neigh_resolve_output()` 干一件容易被忽略的事——**把数据包暂存到 `neigh->arp_queue` 队列里**。它调 `neigh_event_send()`,后者实际进 `__neigh_event_send()`(`neighbour.c:1200`)。这一次调用里**同时**做两件事:把 skb `__skb_queue_tail()` 进 `arp_queue`(`neighbour.c:1264`),并在状态允许时触发 `neigh_probe()`(`neighbour.c:1271`)去解析。也就是说"入队 + 触发解析"是同一拍完成的,但**解析结果回来、状态机往前推**是另一拍——后续由 `neigh_timer_handler()` 在定时器里推进,不是同步等来的。 + +`arp_queue` 有长度上限(按 `QUEUE_LEN_BYTES` 字节限流,`neighbour.c:1252`),解析一直不出来就持续往里塞,满了就 `__skb_dequeue()` 丢老的(`SKB_DROP_REASON_NEIGH_QUEUEFULL`)——表现为 ping 不通、但没报错,包在黑洞里消失。 + +`neigh_probe()` 调协议的 `solicit` 回调,ARP 就是 `arp_solicit()`。它干三件事: + +1. **选源 IP**:受 sysctl `arp_announce`(`IN_DEV_ARP_ANNOUNCE`)控制——0 用任意本地地址、1 尽量同子网、2 只用主地址(`arp.c:349-370` 的 switch 注释原文如此)。 +2. **单播 vs 广播**:若旧条目里还有 MAC 记录,先省着用单播探测(`UCAST_PROBES` 次),减少广播风暴;用完了才广播(`arp.c:376` 起的 `probes -= UCAST_PROBES` 判断)。 +3. 调 `arp_send()` 把请求扔出去。 + +收包端 `arp_rcv()` 拦下以太网类型 `ETH_P_ARP`(`0x0806`)的帧,合法性检查后交给 `arp_process()`(`arp.c:702`)。这是 ARP 的大脑,要处理三种情况:发给本机的请求(要回 Reply)、发给本机的响应(更新表)、需要转发的请求(Proxy ARP)。 + +`arp_process()` 里有个很实用的机制叫**被动学习**:只要收到 ARP 包(不管请求还是响应),顺手把发送者(SHA+SIP)记进邻居表。好比有人敲门问路,你答他的同时把他的长相也记下了。还有一个 `locktime`(默认 `1*HZ`,`arp.c:177`)防飘移:短时间内(`jiffies - n->updated < LOCKTIME`)收到多个不同 Reply,只认第一个(`override` 标志的判定见 `arp.c:925-928`),免得被一串 proxy agent 的应答来回刷。 + +## ARP 包结构:八个小字段 + +ARP 报文头部是 `struct arphdr`(`include/uapi/linux/if_arp.h`): + +```c +struct arphdr { + __be16 ar_hrd; /* 硬件类型,以太网 0x01 */ + __be16 ar_pro; /* 协议类型,IPv4 是 0x0800 */ + unsigned char ar_hln; /* 硬件地址长度,MAC 是 6 */ + unsigned char ar_pln; /* 协议地址长度,IPv4 是 4 */ + __be16 ar_op; /* 操作码:1=请求, 2=应答 */ +}; +/* 紧跟在后面的变长字段(不属结构体,手动算偏移读): + SHA 发送方 MAC / SIP 发送方 IP / THA 目标 MAC / TIP 目标 IP */ +``` + +关键就 `ar_op`:`ARPOP_REQUEST`(1) 是"谁有这个 IP"的喊话,`ARPOP_REPLY`(2) 是"我有,MAC 是 X"的举手。`arp_process()` 里因为 SHA/SIP/THA/TIP 不在结构体里,得用 `arp_ptr = (unsigned char *)(arp + 1)` 手动逐字段抠出来。 + +## 缓存老化与 GC:为什么不永久保留 + +如果邻居表永久保留,设备离线、网卡换 MAC、机器搬家之后,内核还死抱着一个失效的 MAC 不放,结果就是发包发出去没人收——网络黑洞。所以邻居条目必须能老化、能回收。 + +两条 GC 路径:同步暴力的 `neigh_forced_gc()`(`neighbour.c:254`,分配时 `gc_entries` 满了触发,踢掉非永久条目);异步温和的 `neigh_periodic_work()`(`neighbour.c:976`,由 `tbl->gc_work` 周期性调度,清过期条目)。配合 `NUD` 状态机,内核做到了"最近用过的留、太久没用的过期、空间紧张时优先牺牲陈旧的"。统计可以看 `/proc/net/stat/arp_cache` 和 `/proc/net/stat/ndisc_cache`。 + +## NDISC:IPv6 用 NS/NA 替掉广播 + +ARP 太糙——没有验证,谁都能喊"我是网关"(ARP 欺骗)。IPv6 换成 NDISC,走 ICMPv6(类型 133-137),其中跟地址解析对应的是 **NS(邻居请求,135)** 和 **NA(邻居通告,136)**,RS/RA/Redirect 留给路由那章讲。 + +最大的区别:**NDISC 不广播,改用组播**。问"谁有 IP X",不是全局域网喊,而是发到 X 对应的 Solicited-Node 组播地址(`addrconf_addr_solict_mult()` 算出来),只有 X 的主人会被叫醒。 + +发送路径 `ip6_finish_output2()`(`net/ipv6/ip6_output.c:120` 起)和 IPv4 不太一样,6.19 里它**仍然直接调** `__ipv6_neigh_lookup_noref()`/`__neigh_create(&nd_tbl,...)`/`neigh_output()`(没有像 IPv4 那样收进一个 `ip_neigh_for_gw`): + +```c +/* net/ipv6/ip6_output.c:124-136 */ +neigh = __ipv6_neigh_lookup_noref(dev, nexthop); +if (IS_ERR_OR_NULL(neigh)) { + if (unlikely(!neigh)) + neigh = __neigh_create(&nd_tbl, nexthop, dev, false); + ... +} +sock_confirm_neigh(skb, neigh); +ret = neigh_output(neigh, skb, false); /* IPv6 这里固定 skip_cache=false */ +``` + +`ndisc_solicit()` 也是先试单播(有旧记录)、用完再组播。接收走 `icmpv6_rcv()` → `ndisc_rcv()`(`ndisc.c:1801`)→ 分发 `ndisc_recv_ns()` / `ndisc_recv_na()`。 + +NDISC 比 ARP 严谨的地方,全在细节里: + +- **Hop Limit 必须 255**:`ndisc_rcv()` 开头就查(`ndisc.c:1816`,`ipv6_hdr(skb)->hop_limit != 255` 直接丢)。255 意味着包没经任何路由器转发、来自同链路,挡住了远程伪造。 +- **NA 三个 flag**(在 `icmp6hdr` 联合体里,定义见 `include/uapi/linux/icmpv6.h:72-74`):`Router`(我是路由器)、`Solicited`(我是应你请求而来,收到方置 `NUD_REACHABLE`)、`Override`(不管你缓存是啥,以我为准)。 +- **强制 DAD**:配 IPv6 地址前必须问"这地址有人用了吗"(发源地址为 `::` 的 NS),没人回才从 `IFA_F_TENTATIVE` 转 `IFA_F_PERMANENT`。`arping -D` 那套在 IPv4 只是可选,IPv6 是强制的。 +- **Optimistic DAD**(`CONFIG_IPV6_OPTIMISTIC_DAD`):DAD 要等,怕卡?RFC 4429 允许先标 `IFA_F_OPTIMISTIC` 顶着用,事后冲突再撤——"先用后付"。 + +## 动手:等 QEMU 亲测 + +验证方案(待亲测核对真实输出): + +``` +# 1. 看邻居表(新派,带 NUD 状态) +ip neigh show +ip -6 neigh show + +# 2. 看邻居表(老派,只 IPv4) +arp -n +cat /proc/net/arp + +# 3. 抓 ARP 请求/应答的来回 +arping <对端 IP> + +# 4. 手动加一条静态邻居,观察 NUD_PERMANENT 不被 GC +ip neigh add 192.168.0.121 dev eth0 lladdr 00:30:48:5b:cc:45 nud permanent + +# 5. 故意改对端 MAC,观察条目从 REACHABLE → STALE → 重新探测 +``` + +> ⚠️ **待亲测**:上面命令是整理时的方案清单。我们会在 QEMU 两节点网络里实跑一遍,重点记下 `ip neigh show` 输出里 `REACHABLE/STALE/DELAY` 状态随时间的真实变化,以及 `arping` 抓到的请求/应答报文——把 NUD 状态机亲眼看到。 + +## 小结 + +邻居子系统是 L3 到 L2 的翻译层:每协议一张 `neigh_table`(`arp_tbl` / `nd_tbl`)缓存 IP→MAC,靠 `NUD` 状态机(INCOMPLETE→REACHABLE→STALE→DELAY→PROBE)做"信任但验证",靠两套 GC(同步 `neigh_forced_gc` + 异步 `neigh_periodic_work`)控制表规模。6.19 的 IPv4 出口路径把"查/建邻居"收进了 `ip_neigh_for_gw()`,出口统一过 `neigh_output()`;IPv6 路径仍直接调 `__ipv6_neigh_lookup_noref`/`__neigh_create`/`neigh_output`。IPv4 用 ARP 广播喊话+被动学习填表,IPv6 用 NDISC 组播 NS/NA,外加 Hop Limit 255、三个 NA flag、强制 DAD,把 ARP 那套简单粗暴升级成严密得多的协议。 + +记三件事:**`arp_queue` 满了会静默丢包**(ping 不通无报错)、**`gc_thresh3` 是硬上限**(满了直接拒新连接)、**ARP 是喊话、NDISC 是点名+验证**。 + +## 延伸阅读 + +- 源码(Linux 6.19):通用框架 `net/core/neighbour.c` + `include/net/neighbour.h`;IPv4 ARP `net/ipv4/arp.c` + `include/net/arp.h` + `include/uapi/linux/if_arp.h`;IPv4 出口路径 `net/ipv4/ip_output.c` + `ip_neigh_for_gw()` 在 `include/net/route.h`;IPv6 NDISC `net/ipv6/ndisc.c` + `include/net/ndisc.h`,IPv6 出口 `net/ipv6/ip6_output.c`。 +- 内核文档:[Networking — kernel.org core index](https://docs.kernel.org/networking/index.html)(找 ARP / Neighbor / IPv6 相关条目)。 +- 进一步(持续铺开):路由子系统(下一跳决策)、ICMPv6、IPv6 自动配置与 RA。 \ No newline at end of file diff --git a/document/tutorials/kernel/net/04-net-ipv4.md b/document/tutorials/kernel/net/04-net-ipv4.md new file mode 100644 index 00000000..a647fa04 --- /dev/null +++ b/document/tutorials/kernel/net/04-net-ipv4.md @@ -0,0 +1,138 @@ +--- +title: IPv4 协议层:包的接收与发送 +slug: net-ipv4 +difficulty: intermediate +tags: [网络栈, IPv4, 协议注册, 分片重组] +architectures: [arm64, x86_64, riscv] +kernel_version: "6.19" +maturity: drafting +prerequisites: + - /tutorials/kernel/net/01-net-overview +related: + - /tutorials/kernel/net/01-net-overview + - /tutorials/kernel/net/02-net-sk-buff +sources: + - notes: document/notes/linux_kernel_networking/ch04.md +--- + +# IPv4 协议层:包的接收与发送 + +> 🔨 **整理中** · 这篇是从读书笔记 `ch04`(4.1 头部与协议注册、4.2 接收 `ip_rcv`、4.5 发送 `ip_queue_xmit`、4.6/4.7 分片重组、4.8 转发)提炼出来的骨架,IPv4 包怎么进、怎么发、怎么切的机制讲透了。本篇函数签名/字段/数值已对照 Linux 6.19 源码校订(读书笔记基于较早内核版本,部分接口已演进);具体行号仍待 QEMU 亲测核对。但动手部分——`tcpdump` 抓 IP 头逐字段、改 TTL 看转发与 ICMP、在 QEMU 上 `cat /proc/net/snmp` 看计数器跳动——还没亲手跑过。等我们在 QEMU 双机环境里验过,就升级成 ✅ 已锤炼。 + +## IP 层到底是干嘛的 + +上一篇我们站在了网络栈的全景上。现在要钻进其中一层——**IPv4 协议层**,把这一层彻底拆开看。 + +先说 IP 层的定位,方便脑子里有个"最终样子":把链路层(以太网那些)和传输层(TCP/UDP)连起来的中间人。它干三件事:**接收**(收上来的包,校验、判定是给我还是让我转发)、**发送**(传输层要往外发,给它套上 IP 头、查好路由送出去)、**分片重组**(包太大就切,到了对面再拼)。转发(当路由器)可以理解为"接收 + 发送"的合体,只不过目的地不是本机。 + +这一层所有操作,在内核眼里就是折腾一个东西——`struct iphdr`,也就是 IPv4 头部。我们从这张"脸"开始解剖。 + +## IPv4 头部逐字段:那张最熟悉的脸 + +IPv4 头部是网络层最核心的数据结构,内核里抽象成 `struct iphdr`,定义在 `include/uapi/linux/ip.h`(Linux 6.19)。最小 20 字节、最大 60 字节(带选项),按 4 字节为单位计数。逐个字段过一遍: + +- **version / ihl**:挤在一个字节里,还得看字节序(大端小端排布不同)。`version` 必须是 4,不是 4 直接扔。`ihl`(Internet Header Length)是头部长度,但**单位是 4 字节不是字节**——所以 20 字节头部 `ihl=5`,最大 `ihl=15`(60 字节)。这个"单位是 4 字节"的坑后面在 `ip_queue_xmit` 里还会再踩一次。 +- **tos**:8 位,历史上被反复"再利用"。最初(RFC 791)是 QoS"加急章",后来前 6 位重定义为 **DSCP**(差分服务),最后 2 位拿来做 **ECN**(显式拥塞通知)——路由器拥塞时不丢包而是标这一位,告诉收方"慢点发"。 +- **tot_len**:整个 IP 包(头+数据)长度,16 位,最大 64KB。注意以太网 MTU 通常 1500,超了就得切,但 `tot_len` 记的是切片前的总长,接收端靠它判断重组是否完成。 +- **id**:16 位标识。一个包被切成多片时,所有片共享同一个 `id`,对面重组就靠它认亲。 +- **frag_off**:16 位里塞了两样东西——**高 3 位是标志**,**低 13 位是偏移量**(单位是 8 字节,不是字节)。这里口径容易绕晕,两套说法并列记最稳: + - **逻辑位**(按 `frag_off` 高 3 位排):MF(还有片)= `0b001`、DF(别切我)= `0b010`、CE(拥塞)= `0b100`; + - **网络字节序字段值**(`include/net/ip.h`,6.19 line 142-145):`IP_MF=0x2000`、`IP_DF=0x4000`、`IP_CE=0x8000`、`IP_OFFSET=0x1FFF`。 + - 抓包看到 `frag_off=8192` 别以为偏移很大,`8192=0x2000=IP_MF`,表示"后面还有分片";要看 DF 得认 `0x4000=16384`。看分片偏移必须做掩码剥离高 3 位(`& htons(IP_OFFSET)`)。 +- **ttl**:生存时间,每过一跳减 1,归零销毁,防路由环路死包。`traceroute` 就靠故意递增 TTL 触发 `Time Exceeded` 来探路径。 +- **protocol**:告诉内核肚子里装啥——`IPPROTO_TCP`(6)、`IPPROTO_UDP`(17)、`IPPROTO_ICMP`(1) 等,定义在 `include/uapi/linux/in.h`(注意是 `uapi` 那份,`include/linux/in.h` 里只是几个 inline case,不含 `#define`)。 +- **check**:**只校验头部**的校验和,错一个比特就丢。因为 TTL 每跳都变,路由器转发必须重算校验和(后面会讲内核用增量技巧,不用遍历整头)。 +- **saddr / daddr**:32 位源/目的地址,路由的核心依据。 + +> 源码引用:`include/uapi/linux/ip.h` 看 `struct iphdr`;标志位字段值宏 `IP_DF/IP_MF/IP_CE/IP_OFFSET` 在 `include/net/ip.h`(6.19 line 142-145)。行号待亲测核对。 + +## 协议注册:内核怎么认领 IP 包 + +回到一个更基础的问题:网卡收上来一个帧,内核怎么知道它是 IPv4 而不是 ARP 或 IPv6? + +答案在以太网头的 `type` 字段——IPv4 是 `0x0800`。内核需要把"0x0800"和"IPv4 处理函数"绑起来,这就是 `ip_packet_type` 干的事,定义在 `net/ipv4/af_inet.c`(Linux 6.19): + +```c +static struct packet_type ip_packet_type __read_mostly = { + .type = cpu_to_be16(ETH_P_IP), // 0x0800 + .func = ip_rcv, // 处理函数指针 +}; +``` + +在 IPv4 协议栈初始化 `inet_init()` 里,`dev_add_pack(&ip_packet_type)` 把它挂到内核全局的协议处理哈希表(`ptype_base`)上。从此每个进来的包,内核瞄一眼以太网类型,是 `0x0800` 就调 `.func`——也就是 `ip_rcv()`。这就是 IPv4 故事的起点:**`ip_rcv` 是 IPv4 王国的"海关"**。 + +至于肚子里装的是 TCP 还是 UDP,那要等后面到了传输层,靠 `protocol` 字段查 `inet_protos` 表再分发——这是另一张注册表,本篇先不展开,详见后续 TCP/UDP 章节。 + +## 接收路径 ip_rcv:看门人 + 路由判定 + +进了 `ip_rcv`,直觉以为它负责拆包送上层,**恰恰相反**——它更像**看门人**,只关心"这是不是合法 IPv4 包",真正的活交给下一棒。函数本身在 6.19 里瘦得只剩骨架:先调 `ip_rcv_core()` 做 sanity check,再过一个 Netfilter 钩子。两个函数中间夹着 `NF_INET_PRE_ROUTING`(源码在 `net/ipv4/ip_input.c`,行号待亲测核对): + +1. **头部格式**(在 `ip_rcv_core` 里):`iph = ip_hdr(skb)`,查 `iph->ihl < 5 || iph->version != 4` 直接 `goto drop`,计 `IPSTATS_MIB_INHDRERRORS`。 +2. **校验和**(同在 `ip_rcv_core`):`ip_fast_csum()` 只算头部,失败同样丢弃(RFC 1122 要求默默丢,不发错误包)。 +3. **放行关卡**(`ip_rcv` 里):`NF_HOOK(NFPROTO_IPV4, NF_INET_PRE_ROUTING, ...)`——这是 `NF_INET_PRE_ROUTING` 钩子点(包刚进栈、还没路由判定前)。iptables/nf_conntrack 就插在这里。返回 `NF_DROP` 包就没了,`NF_STOLEN` 被钩子"偷走",`NF_ACCEPT` 才继续调 `ip_rcv_finish`。 + +过了海关,`ip_rcv_finish`(6.19 里是层薄壳)把真正的路由查找活儿派给 `ip_rcv_finish_core()`:若 SKB 上还没挂路由结果,调 `ip_route_input_noref()` 拿目的地址、源地址、**DSCP(由 `tos` 高 6 位派生,6.19 传的是 `ip4h_dscp(iph)` 而非裸 `tos`)** 去查表。查完给 SKB 绑一个 `dst` 对象,关键是 `dst->input` 这个回调函数指针——**路由表查的是"数据",返回的却是"代码"**,C 语言多态的经典用法: + +- 发给本机 → `dst->input = ip_local_deliver`(送上去给传输层,顺带处理分片重组); +- 要转发 → `dst->input = ip_forward`(帮它送去隔壁); +- 组播 → `ip_mr_input`。 + +最后 `dst_input(skb)` 就是执行那个函数指针。中途还有个 **RPF(反向路径过滤)**:进来和回去的接口不一致,怀疑伪造源地址,丢掉——在 6.19 里这表现为路由层返回 `SKB_DROP_REASON_IP_RPFILTER` 这个 drop reason(早期内核用错误码 `-EXDEV`,6.19 已改成 drop reason 体系),命中就计 `LINUX_MIB_IPRPFILTER`。顺带一提,`ihl > 5` 时还会调 `ip_rcv_options()` 处理 IP 选项。 + +## 发送路径 ip_queue_xmit:查路由 → 套头 → 送出 + +把角色反过来——传输层要往外发包,IP 层怎么打包。发送主要两条路(源码在 `net/ipv4/ip_output.c`,行号待亲测核对): + +- **操心型的 TCP** 走 `ip_queue_xmit()`:TCP 自己管分段,不希望 IP 插手。 +- **甩手掌柜型的 UDP/ICMP** 走 `ip_append_data()` + `ip_push_pending_frames()`:把数据塞 `sk_write_queue` 队列,再触发发送(2.6.39 后 UDP 又有 `ip_make_skb()` 的无锁快速通道)。Raw Socket 带 `IP_HDRINCL` 时连这条路都不走,直接 `raw_send_hdrinc()` 丢给 `LOCAL_OUT` 钩子——这就是 `ping -t 128` 能手改 TTL 的原因,头根本不是内核造的。 + +重点看 `ip_queue_xmit`(TCP 主场)。它一上来先解决"发往哪":`__sk_dst_check()` 查路由缓存,没缓存就构造 `flowi4`、调 `ip_route_output_flow()` 查表(6.19 实际调的是 `_flow` 这层;`ip_route_output_ports()` 是 route.h 里再包一层的 inline,最终还是走 `ip_route_output_flow`);失败 `goto no_route` 返回 `-EHOSTUNREACH`,靠 TCP 重传。有个隐蔽坑——同时开严格源路由(SSRR)和网关会自相矛盾,直接拒绝。 + +路由搞定后装箱:`skb_push()` 往前腾 IP 头位置,填字段。有一行看着晕的位运算 `htons((4 << 12) | (5 << 8) | (tos & 0xff))` 一次性把 version+ihl+tos 塞进前 16 位。DF 标志靠 `ip_dont_fragment()` 判断写进 `frag_off`。**关键的 ihl 坑又来了**:有 IP 选项时 `iph->ihl += inet_opt->opt.optlen >> 2`——因为 ihl 单位是 4 字节,选项 20 字节右移 2 位得 5,加基础 5 成 10(头部 40 字节)。最后 `ip_select_ident_segs()` 选包 ID(6.19;旧内核笔记里写的 `ip_select_ident_more` 已不存在,重命名为 `ip_select_ident_segs`,按 GSO 段数 `gso_segs ?: 1` 选)、`ip_local_out()` 送出门。 + +## 分片与重组:路太窄怎么办 + +以太网 MTU 通常 1500,但 IP 包能到 64KB。超了怎么办?两条路:要么发 ICMP "Fragmentation Needed" 劝对方切小(PMTU Discovery),要么自己 `ip_fragment()` 切碎(`net/ipv4/ip_output.c`,行号待亲测核对)。注意 6.19 的 `ip_fragment()` 一进来先看 DF:没设 DF 才走 `ip_do_fragment()` 切;设了 DF 又超 MTU,内核**不切**,直接 `icmp_send()` 扔回 `ICMP_FRAG_NEEDED` 然后 `kfree_skb`。这解释了为啥防火墙禁掉 ICMP 大包就发不出去:内核想告诉你"路太窄",你把它嘴堵了。 + +真正切分有快慢两条路。**快路径**:SKB 的 `frag_list` 已挂好预切片(GSO/UDP 来的),只需给每节贴新 IP 头、设偏移(`offset>>3`,单位 8 字节)、打 `IP_MF` 标志、重算校验和,**不拷数据**。**慢路径**:手里一个大块 SKB,得 `alloc_skb`+`skb_copy_bits` 一片片割,`len &= ~7` 强制 8 字节对齐。慢路径里 `GFP_ATOMIC` 分配(可能持锁不能睡)、`skb_set_owner_w` 把内存算在 Socket 头上防 DoS,都是工程细节。 + +重组是 `ip_fragment` 的逆运算,在 `ip_local_deliver()` 里触发(`net/ipv4/ip_fragment.c`,行号待亲测核对)。`ip_is_fragment()` 判断是不是碎片——只要 MF 或偏移量任一非零就是。重组靠**四维坐标**(id、saddr、daddr、protocol,外加 user/vif 辅助位)算哈希找归属队列。在 6.19 里这套坐标被收进一个 key 结构 `frag_v4_compare_key`(`include/net/inet_frag.h`,含 saddr/daddr/user/vif/id/protocol),挂在 `struct ipq` 内嵌的 `inet_frag_queue.q.key.v4` 上——所以别再按旧内核去 `struct ipq` 里找 saddr/daddr 这些成员了,6.19 的 `ipq` 只剩 `ecn`/`max_df_size`/`iif`/`rid`/`peer` 几个重组状态字段(这套 `inet_frag_queue` 框架 IPv6 也在共用)。`ip_defrag()` 先 `ip_evictor()` 扫地(内存紧了踢老队列),再 `ip_find()` 找/建队列,`ip_frag_queue()` 处理乱序和重叠插入,最后 `meat == len` 且收到最后一片就 `ip_frag_reasm()` 拼回整包(超 65535 直接丢)。每个队列默认 30 秒超时(`IP_FRAG_TIME = 30 * HZ`,`/proc/sys/net/ipv4/ipfrag_time` 可调),防 Teardrop 那种恶意重叠碎片的资源耗尽攻击。 + +## 转发 ip_forward:接收+发送的合体 + +当路由判定"这货不是给我的",`dst->input` 就是 `ip_forward()`(`net/ipv4/ip_forward.c`,行号待亲测核对)。转发路径检查一长串,**顺序以 6.19 源码为准**: + +- **pkt_type 检查**(第一关):不是 `PACKET_HOST` 直接 `goto drop`——本来就不该交给我转发。 +- **本地生成包拦截**:`unlikely(skb->sk)` 非空就丢——本机自己生成往外发的包不走转发路径,这是笔记里漏掉的一道。 +- **拦 LRO**:`skb_warn_if_lro(skb)` goto drop——LRO 合并的大包转发时出口 MTU 装不下又拆不干净,GRO 才考虑了转发。 +- **xfrm4 策略检查**:`xfrm4_policy_check(NULL, XFRM_POLICY_FWD, skb)`——IPsec 策略过滤,不通过就丢(笔记也没提)。 +- **Router Alert**:`IPCB(skb)->opt.router_alert` 且 `ip_call_ra_chain()` 把带 `IPOPT_RA` 的包喂给挂在 `ip_ra_chain` 上的 Raw Socket。 +- **TTL 审判**:`ttl <= 1` goto `too_many_hops`,发 `ICMP_TIME_EXCEEDED`。 +- **严格源路由 vs 网关**:`is_strictroute && rt_uses_gateway` 冲突 goto `sr_failed`,发 `ICMP_SR_FAILED`。 +- **MTU + DF 进退两难**:`ip_exceeds_mtu()` 命中发 `ICMP_FRAG_NEEDED`(PMTUD 核心);但 `skb_is_gso()` 且 GSO 段长度能过 MTU 的包放过(还没真正分片)。 + +挺过检查后 `skb_cow()` 做 COW 副本(要改头了),`ip_decrease_ttl()` 减 1 并用 RFC 1624 增量技巧更新校验和(只改一字节不必遍历整头);若 `IPCB(skb)->flags & IPSKB_DOREDIRECT` 且非源路由、非 IPsec 路径,就 `ip_rt_send_redirect()` 发 ICMP Redirect;`sysctl_ip_fwd_update_priority` 打开时 `skb->priority = rt_tos2priority(iph->tos)`(转发的包没 Socket,按 tos 查表定优先级),最后过 `NF_INET_FORWARD` 钩子(防火墙最常拦的点)进 `ip_forward_finish()`,`dst_output()` 送入发送路径。 + +## 与邻居/路由的衔接 + +这一篇反复出现"查路由""下一跳",得把 IP 层和邻居子系统的衔接点钉死:**IP 层只决定"下一跳的 IP 是谁"**(查路由表拿到 `rt->dst`),但光有下一跳 IP 没法封装以太网帧——还得知道这个 IP 对应哪个 MAC。把下一跳 IP 翻译成 MAC 是**邻居子系统(ARP/邻居表)**的活,这一步发生在 IP 层把包往下送、进链路层之前。换句话说:IP 层管"逻辑路径"(下一跳 IP),邻居子系统管"物理寻址"(MAC)。这块单独成篇(邻居/ARP),这里先埋个伏笔。 + +## 动手待亲测(QEMU 双机环境) + +> ⚠️ 以下方案还没在 QEMU 上亲手跑过,等亲测后再把真实输出补进正文。 + +1. **抓 IP 头逐字段**:QEMU 双机间 ping,主机上 `tcpdump -i tap0 -x -nn icmp` 抓一个包,逐字节对照 `struct iphdr`——验证 `ihl=5`、`protocol=1`(ICMP)、TTL、校验和。 +2. **改 TTL 看转发与 ICMP**:把其中一台 QEMU 当路由器(`echo 1 > /proc/sys/net/ipv4/ip_forward`),另一台发 `ping -t 1`,应触发 `ICMP_TIME_EXCEEDED`;用 `tcpdump` 同时看 ICMP 报错。对照 `/proc/net/snmp` 里 `InHdrErrors`、`OutForwDatagrams`、`FragFails` 等计数器跳动。 +3. **分片观察**:`ping -s 3000` 打超 MTU 的大包,`tcpdump` 应看到多个带相同 `id`、`IP_MF` 标志(注意是 `0x2000` 不是 `0x4000`)、偏移量递增的分片;改抓 DF 行为可对照 `FragCreates`/`FragOks`。 + +真实命令输出待亲测核对。 + +## 小结 + +IPv4 层是链路层和传输层之间的中间人,核心就是折腾 `struct iphdr`。接收上 `ip_rcv` 是看门人(`ip_rcv_core` 三步 sanity check + PRE_ROUTING 钩子),`ip_rcv_finish`/`ip_rcv_finish_core` 查路由决定本机收(`ip_local_deliver`)还是转发(`ip_forward`);发送上 TCP 走 `ip_queue_xmit`(查路由→套头→`ip_local_out`),UDP 走 `ip_append_data` 攒包路径。包超 MTU 时 `ip_fragment`/`ip_do_fragment` 快慢两路切、`ip_defrag` 靠四维坐标拼回,转发路径则要在 TTL、MTU、DF、LRO、xfrm 之间做一堆生死判断。记住一条主线:**IP 层只决定下一跳 IP,MAC 交给邻居子系统**。 + +## 延伸阅读 + +- 源码:`net/ipv4/ip_input.c`(`ip_rcv`/`ip_rcv_core`/`ip_rcv_finish`/`ip_rcv_finish_core`/`ip_local_deliver`)、`net/ipv4/ip_output.c`(`ip_queue_xmit`/`ip_fragment`/`ip_do_fragment`/`ip_local_out`)、`net/ipv4/ip_fragment.c`(`ip_defrag`/`ip_frag_queue`/`ip_find`)、`net/ipv4/ip_forward.c`(`ip_forward`)、`net/ipv4/af_inet.c`(`ip_packet_type` 注册)、`include/uapi/linux/ip.h`(`struct iphdr`)、`include/net/ip.h`(`IP_DF/IP_MF/IP_CE/IP_OFFSET` 字段值、`IP_FRAG_TIME`)、`include/net/inet_frag.h`(`frag_v4_compare_key`)——均 Linux 6.19,行号待亲测核对。 +- kernel.org 文档:[Networking documentation index](https://docs.kernel.org/networking/index.html)(找 IPv4/分片/路由相关稳定索引页)。 +- 进一步(持续铺开):IPv4 路由子系统(`fib`/路由缓存)、邻居与 ARP、TCP/UDP 传输层发送路径、Netfilter 与 `nf_hook` 机制。 \ No newline at end of file diff --git a/document/tutorials/kernel/net/05-net-routing.md b/document/tutorials/kernel/net/05-net-routing.md new file mode 100644 index 00000000..fa892745 --- /dev/null +++ b/document/tutorials/kernel/net/05-net-routing.md @@ -0,0 +1,172 @@ +--- +title: IPv4 路由子系统:包该往哪走 +slug: net-routing +difficulty: intermediate +tags: [网络栈, 路由, FIB, 策略路由] +architectures: [arm64, x86_64, riscv] +kernel_version: "6.19" +maturity: drafting +prerequisites: + - /tutorials/kernel/net/04-net-ipv4 +related: + - /tutorials/kernel/net/04-net-ipv4 +sources: + - notes: document/notes/linux_kernel_networking/ch05_1.md + - notes: document/notes/linux_kernel_networking/ch05_2.md + - notes: document/notes/linux_kernel_networking/ch05_3.md + - notes: document/notes/linux_kernel_networking/ch05_4.md + - notes: document/notes/linux_kernel_networking/ch05_5.md + - notes: document/notes/linux_kernel_networking/ch05_6.md +--- + +# IPv4 路由子系统:包该往哪走 + +> 🔨 **整理中** · 这篇是从《Linux 内核网络》第 5 章读书笔记整理出来的骨架,转发/FIB/`fib_lookup`/`fib_info`/`fib_nh`/policy routing 这条主线已经讲透了;但动手部分(QEMU 上 `ip route`、`ip rule`、strace 发送路径)还没亲手跑过核对。等我们在 QEMU 双网卡拓扑里验过、把真实输出贴进来,就升级成 ✅ 已锤炼。下面凡是命令输出样例,都标注「待亲测核对」。 +> +> 本篇函数签名/字段/数值已对照 Linux 6.19 源码校订(读书笔记基于较早内核版本,部分接口已演进);具体行号仍待 QEMU 亲测核对。 + +## 路由到底要解决什么 + +一个 IP 包到达 IP 层,内核要在三种命运里挑一个: + +1. **本机处理**——目标地址就是我,把包往上传给传输层(socket 那头有人在等)。 +2. **转发**——目标不是我,我是过路的路由器,要选一个出口网卡把它扔给下一站。 +3. **丢弃**——既不是我的、也无处可转,直接进垃圾桶。 + +判定走哪条,靠的就是**路由查找**。如果判定是转发,内核还得回答两个问题:从哪块网卡出?下一跳(next hop)是谁?这就是路由子系统(routing subsystem)的活儿——它手里攥着一张决定数据包生死的「藏宝图」,叫 **FIB(Forwarding Information Base,转发信息库)**。 + +先刻一个直觉:路由器眼里**转发流量根本不会爬到 Layer 4**。没人会在 socket 那头等一个过路包,把它送到传输层是纯浪费 CPU。过路流量在 Layer 3(网络层)就被截停,查完路由直接从另一块网卡甩出去——高效、冷酷,不带一丝情感。 + +## FIB:路由表在内核里长什么样 + +FIB 不是一个扁平数组,而是几层结构叠出来的: + +- **`fib_table`**:一整本路由册子。没有策略路由时内核只有两张表——**Local 表**(ID 255,内核私有领地,只放本机 IP 的路由,管理员塞不进去)和 **Main 表**(ID 254,你 `ip route add` 配的路由大多在这)。开启了策略路由能扩到最多 255 张表。这两个 ID 不是凭空定的,`include/uapi/linux/rtnetlink.h` 里写死了 `RT_TABLE_LOCAL=255`、`RT_TABLE_MAIN=254`、`RT_TABLE_DEFAULT=253`。 +- **`fib_info`**:册子里某一条具体路由的「身份证」,记录这条路怎么走——从哪个设备出、优先级、协议来源、作用域、性能度量。它不存「目的地」,目的地是 `fib_alias` 的活。 +- **`fib_alias`**:一个轻量级挂钩。当好几条路由**除了 TOS/优先级/类型等少量可挂 alias 的属性不同、其余路径参数(网关、出口、metrics)完全相同时**,它们共享一份胖大的 `fib_info`,各自挂一个小 `fib_alias` 记自己的差异化属性。这是典型的「提取公因式」省内存设计。 + +为什么这么分?因为 BGP 场景下一张表能有几万条路由,很多只是优先级不同,要是每条都复制一份完整的 nexthop+metrics,内存早炸了。共享一份 `fib_info`、用引用计数 `fib_treeref` 守住它的命,是工程上的优雅解。而这个「共享」的判据不是我们拍脑袋想的——`fib_find_info()`(`net/ipv4/fib_semantics.c`)逐项比对 protocol、scope、prefsrc、priority、**type**、tb_id、metrics、flags 和整套 nexthop,全相等才复用。也就是说,连 `RTN_UNICAST` 和 `RTN_PROHIBIT` 这种同网关但 type 不同的路由,只要路径参数一致也能共享 `fib_info`。 + +## 路由查找 fib_lookup:最长前缀匹配 + +查表的大脑是 `fib_lookup()`: + +```c +static inline int fib_lookup(struct net *net, const struct flowi4 *flp, + struct fib_result *res, unsigned int flags); +``` + +它吃四个参数:**线索** `flowi4`(一张查表申请单,关键字段是目标地址、源地址、TOS)、**结果容器** `fib_result`,以及一个 **flags**(常见的如 `FIB_LOOKUP_NOREF`,表示查找时不给 `fib_info` 的引用计数加一)。找到就返回 0,把结果填进 `res`。读书笔记里那个 3 参的 `fib_lookup` 是早年形态——6.19 内核里末尾那个 `flags` 已经是标配,`include/net/ip_fib.h` 里单表 inline 版和多表版(走 `__fib_lookup`)签名都是四参。 + +查找过程走**最长前缀匹配(LPM)**,但「先翻 Local、没命中再翻 Main」这个说法得拆开讲,单表和多表完全是两套机制: + +1. **不开 `CONFIG_IP_MULTIPLE_TABLES`**:inline 版 `fib_lookup()`(`include/net/ip_fib.h`)**只查 Main 表**(`fib_get_table(net, RT_TABLE_MAIN)`),并不显式先翻 Local。本机地址的命中是靠 `fib_add_ifaddr()`(`net/ipv4/fib_frontend.c`)在配置 IP 时就把 local 项提前注入到 Main 表里——所以我们「感觉到」的 Local 优先,其实是注入时就已经摆好了。 +2. **开了 `CONFIG_IP_MULTIPLE_TABLES`**:真正的顺序由 `fib_rules` 按优先级决定。`fib_default_rules_init()`(`net/ipv4/fib_rules.c`)登记三条默认规则——Local 优先级 **0**、Main **0x7FFE**、Default **0x7FFF**,查找时按优先级从小到大走,自然是先查 Local 表、再查 Main 表。 + +底层那棵高效的树叫 **LC-trie**(在 `net/ipv4/fib_trie.c`),查找复杂度是 O(key length),不随路由表规模线性增长——这是 3.6 之后能砍掉路由缓存的底气。 + +`fib_result` 里最关键的字段是 `type`,它直接定包的生死: + +| type | 含义 | +|:---|:---| +| `RTN_LOCAL` | 发往本机,往上传 | +| `RTN_UNICAST` | 普通单播,转发或直连 | +| `RTN_BROADCAST` / `RTN_MULTICAST` | 广播 / 组播 | +| `RTN_UNREACHABLE` | 不可达,回 ICMP 目标不可达 | +| `RTN_PROHIBIT` | 禁止,回 ICMP "Packet Filtered" | + +`type` 不是靠一堆 `if` 判出来的——内核查一张 `fib_props[]` 配置表(在 `net/ipv4/fib_semantics.c`),把每种 type 映射到对应的 error 码和 scope。比如 `fib_props[RTN_PROHIBIT].error` 是 `-EACCES`、`fib_props[RTN_UNREACHABLE].error` 是 `-EHOSTUNREACH`。这种「数据驱动」设计内核里到处都是:不写死逻辑,去查配置数组。 + +查完 `fib_result`,内核把它加工成一个 `dst_entry`(嵌在更大的 `rtable` 里)挂在 SKB 身上。`dst_entry` 最值钱的是两个函数指针 `input` 和 `output`——**在代码里,路由选择本质上就是「选函数」**:目标是本机就把 `input` 挂成 `ip_local_deliver()`;要转发就挂 `ip_forward()`;本机发出就 `output` 挂 `ip_output()`。包拿着这张「路条」,直接调函数,剩下的路自动走完。 + +## 路由缓存那点旧历史 + +3.6 之前,路由查找分两步:先翻**路由缓存(route cache)**,没命中再翻 FIB。缓存是一张哈希表,能极大加速热路径查找。 + +3.6 起这块缓存被**整个移除**,每次直接查 FIB TRIE。两个原因: + +- **性能**:互联网核心路由表条目极多,维护庞大哈希缓存及其一致性(失效、更新)的开销越来越大;而 LC-trie 本身够快,缓存层变得多余。 +- **安全**:路由缓存容易吃 **「Shadow Master」类 DoS**——攻击者狂发随机目标 IP,逼内核不断 cache miss + 填缓存,耗光内存和 CPU。直接查 FIB TRIE 消除了这个攻击面。 + +注意,现在内核里**还有缓存,但不是那个被移除的旧 route cache**,而是基于 nexthop 的细粒度缓存(见下节),不能混为一谈。 + +## nexthop fib_nh:最后一公里 + +`fib_info` 是路由的「母亲」,`fib_nh`(next hop)是她牵着的「孩子」。决定真正发包时,内核那一纳秒只关心两件事:**从哪个设备出、发给谁**。这就是 `fib_nh` 的全部意义。 + +但要小心:6.19 的 `struct fib_nh`(`include/net/ip_fib.h`)内核字段其实长这样: + +```c +struct fib_nh { + struct fib_nh_common nh_common; /* 真正的家当都在这 */ + struct hlist_node nh_hash; + struct fib_info *nh_parent; + /* ... 其余是源地址相关 */ +}; +``` + +真正承载 dev/oif/gw 这些「出口信息」的是 `fib_nh_common`(IPv4/IPv6 共用的那一层)。我们平时写代码念叨的 `fib_nh_dev`、`fib_nh_oif`、`fib_nh_gw4` 其实都是**宏**,转发到 `nh_common.nhc_*`: + +- **`nhc_dev`**(宏 `fib_nh_dev`):出口设备的 `net_device *`,内核抓着它才能调驱动把包扔出去。 +- **`nhc_oif`**(宏 `fib_nh_oif`):出口接口的索引(Interface Index),手里只有 ID 时用它反查设备。 +- **`nhc_gw.ipv4`**(宏 `fib_nh_gw4`):下一跳网关 IP。直连路由这里填 0;要经过路由器跳一下,这里就是路由器的 IP。 +- **`nh_parent`**:回指 `fib_info` 的指针,双向链接方便反查(这个是真字段,不是宏)。 + +旧笔记里那种 `nh_dev`/`nh_oif`/`nh_gw` 直接挂在 `fib_nh` 上的写法,是重构成 `fib_nh_common` 之前的形态,现在源码里已经找不到了——读老资料时心里要有这个版本差。 + +普通路由一个 `fib_info` 只牵一个 `fib_nh`;开了多路径路由(`CONFIG_IP_ROUTE_MULTIPATH`),`fib_info` 末尾是个柔性数组 `fib_nh[]`,内核按权重/哈希把包分到不同出口上。 + +还有两个现代缓存,它们挂在 `fib_nh_common` 上(注意是 `nhc_` 前缀,不是旧笔记的 `nh_`):收包路径结果缓在 **`nhc_rth_input`**,发包路径缓在 **`nhc_pcpu_rth_output`**——注意那个 `pcpu`,是**每 CPU 一份**缓存,多核并发发包不抢锁,这是 Linux 高吞吐转发的性能魔法之一。 + +设备掉线(`ip link set eth0 down`)时,通过**通知链(notifier chain)**机制,FIB 的 `fib_netdev_event()` 回调收到 `NETDEV_DOWN`,最终调到 `fib_disable_ip()`(`net/ipv4/fib_frontend.c`)。这里**不是**线性的三连操作,而是一个条件分支外加一步必经的清理: + +```c +static void fib_disable_ip(struct net_device *dev, unsigned long event, bool force) +{ + if (fib_sync_down_dev(dev, event, force)) /* 给用这台设备的 fib_nh 打 RTNH_F_DEAD */ + fib_flush(dev_net(dev)); /* 有路由因此死亡 → 彻底清 FIB */ + else + rt_cache_flush(dev_net(dev)); /* 否则只刷缓存 */ + arp_ifdown(dev); /* 无论如何都清 ARP 邻居 */ +} +``` + +也就是说,`fib_sync_down_dev()` 先把相关 `fib_nh` 打上 `RTNH_F_DEAD`,然后**二选一**——它的返回值告诉你「有没有路由因此彻底死亡」,有就走重口味的 `fib_flush`,没有就只 `rt_cache_flush` 刷缓存。最后无论哪条分支,`arp_ifdown(dev)` 都得跑一遍,把这台设备的 ARP 邻居也清掉——人走茶凉,路由表和邻居表一气呵成。 + +## policy routing:不止一张默认表 + +前面说默认只有 Local + Main 两张表。开了 `CONFIG_IP_MULTIPLE_TABLES`,世界变了——支持最多 255 张表,启动默认初始化 Local(255)、Main(254)、Default(253) 三张(`fib_default_rules_init`)。历史包袱:2.6.25 之前这两张表还是全局变量,后来重构成统一用 `fib_get_table(net, id)` 取表指针,给多表铺了路。 + +但光有表还不够——**该在什么时候查哪张表?** 这套规则才是策略路由的灵魂,由 `ip rule` 管理(`fib_rules`,下一章细聊)。典型场景:双网卡机器,目标地址一样,但备份流量想走贵的 `eth1`、普通浏览走 `eth0`——只看目的地的传统路由表无能为力,得靠规则按源地址/协议来分流。 + +顺带一提:`ip route add` 背后其实是 **Netlink** 的 `RTM_NEWROUTE` 消息,内核由 `inet_rtm_newroute()`(`net/ipv4/fib_frontend.c`)接手;老派的 `route` 命令走的是另一条 **IOCTRL**(`SIOCADDRT`)路径,由 `ip_rt_ioctl()` 处理。而路由守护进程(BGP/OSPF,如 Bird/Quagga)也是狂发 `RTM_NEWROUTE`——对内核来说,管理员手敲的和协议算出来的,最终都是一样的 `fib_info` 挂在同一张表里。 + +## 动手待亲测 + +> ⚠️ **以下输出均为整理时的参考样例,待 QEMU 亲测核对后替换为真实输出。** + +验证方案(QEMU 双网卡拓扑,ARM64 优先): + +1. **看路由表**:`ip route show`(默认只看 Main 表);要看 Local 表得 `ip route show table local`。注意 iproute2 的输出格式是 `default via ... dev eth0 proto ... metric ...` 这种键值字段,**没有** `U/G/H` 那套缩写字母——那套 `U` 激活、`G` 走网关、`H` 主机路由的 Flags 缩写是老派 `route -n` / `netstat -r` 的列格式。亲测时想看那套字母,得敲 `route -n`;用 iproute2 就照 `proto`/`scope`/`metric` 字段描述。 +2. **看策略规则**:`ip rule show`——列出现在有几条规则、分别查哪张表(默认会看到 priority 0/32766/32767 三条,对应 Local/Main/Default)。 +3. **抓一次发送路径**:`strace -e trace=network ping <对端>`,看 socket 发送那一瞬有没有触发路由查找(发送路径里 `dst.output` 回调)。 +4. **构造一个 prohibit**:`ip route add prohibit `,再 `ping` 它,观察回的 ICMP "Packet Filtered"(对应 `fib_props[RTN_PROHIBIT]` 的 `-EACCES`)。 + +亲测阶段还会在 `example/mini/` 落一个配套小模块,把这条主线串成可跑的代码——那篇是亲测完的事,本骨架不展开。 + +## 小结 + +IPv4 路由子系统是一条清晰的主线:包到 IP 层 → `fib_lookup()` 拿 `flowi4` 在 FIB 里做最长前缀匹配 → 填出 `fib_result`(`type` 定生死)→ 加工成 `dst_entry`/`rtable`,挂上 `input`/`output` 回调 → 回调指向 `ip_local_deliver`/`ip_forward`/`ip_output`,路由选择本质是「选函数」。 + +至于「在 FIB 里怎么查」要分清两种模式:单表只查 Main(本机地址靠 `fib_add_ifaddr` 提前注入);多表由 `fib_rules` 按优先级先查 Local(0) 再查 Main(0x7FFE)。 + +往下钻:`fib_info` 是路由身份证(含 `fib_metrics` 性能参数 + 引用计数生命周期),`fib_nh` 是最后一公里——出口设备/网关信息实际都在 `fib_nh_common` 里(`fib_nh_dev`/`fib_nh_oif`/`fib_nh_gw4` 都是转发宏),收包缓存在 `nhc_rth_input`、每 CPU 发包缓存在 `nhc_pcpu_rth_output`。3.6 移除的旧 route cache 别和现在基于 nexthop 的缓存混为一谈;`fib_props[]` 数据驱动的 type→行为映射、`fib_nh_common` 上的 nexthop exception 便签本(记 ICMP Redirect 改的网关、PMTU 改的 MTU)都是值得回味的内核设计美学。 + +记住一件事:**路由决策不是一次简单的匹配,而是「查找 → 缓存 → 动态修正(Redirect/FNHE)」的完整闭环。** + +## 延伸阅读 + +- 源码(Linux 6.19,行号待亲测核对):`net/ipv4/fib_frontend.c`(FIB 前台,处理 Netlink 的 `ip route add`,含 `inet_rtm_newroute`、`ip_rt_ioctl`、`fib_disable_ip`、`fib_add_ifaddr`);`net/ipv4/fib_trie.c`(LC-trie 核心查找);`net/ipv4/fib_semantics.c`(`fib_info` 管理 + `fib_props[]` 映射表 + `fib_find_info` 去重);`net/ipv4/route.c`(`dst_entry`/`rtable`、per-CPU 缓存);`net/ipv4/fib_rules.c`(策略路由 + `fib_default_rules_init`,需 `CONFIG_IP_MULTIPLE_TABLES`)。 +- 头文件:`include/net/ip_fib.h`(`fib_lookup`/`fib_info`/`fib_nh`/`fib_nh_common`)、`include/net/route.h`、`include/net/flow.h`(`flowi4`)、`include/net/dst.h`(`dst_entry`)、`include/uapi/linux/rtnetlink.h`(`RT_TABLE_*` 表 ID 常量)。 +- docs.kernel.org 索引:[Networking](https://docs.kernel.org/networking/index.html)(含 IPv4 路由相关文档入口)。 +- 进一步(持续铺开):邻居子系统(ARP/ND,下一章)、ICMPv4 Redirect、PMTU Discovery 与 FIB nexthop exception 的实战。 \ No newline at end of file diff --git a/document/tutorials/kernel/net/06-net-tcp.md b/document/tutorials/kernel/net/06-net-tcp.md new file mode 100644 index 00000000..0855ee1f --- /dev/null +++ b/document/tutorials/kernel/net/06-net-tcp.md @@ -0,0 +1,147 @@ +--- +title: TCP 传输层:三次握手与收发内核视角 +slug: net-tcp +difficulty: intermediate +tags: [TCP, 传输层, 三次握手, 网络栈] +architectures: [arm64, x86_64, riscv] +kernel_version: "6.19" +maturity: drafting +prerequisites: + - /tutorials/kernel/net/04-net-ipv4 +related: + - /tutorials/kernel/net/04-net-ipv4 +sources: + - notes: document/notes/linux_kernel_networking/ch11.md +--- + +# TCP 传输层:三次握手与收发内核视角 + +> 🔨 **整理中** · 这篇是从读书笔记(ch11 的 socket/TCP 子章)整理出来的骨架,连接建立、收发包路径、定时器的脉络已经讲透;但动手部分(QEMU 里 `ss -ti` 看 TCP 内部状态、`tcpdump` 抓三次握手、`cat /proc/net/tcp` 看连接表)还没亲手跑过。等我们在 QEMU 里验过真实输出,就升级成 ✅ 已锤炼。**本篇函数签名/字段/数值已对照 Linux 6.19 源码校订(读书笔记基于较早内核版本,部分接口已演进);具体行号仍待 QEMU 亲测核对。** + +## socket vs sock:一个端点为什么长着两张脸 + +UDP 那篇我们说过它是“发完即忘”的乐天派,TCP 则恰好相反——它是网络协议世界里最严重的强迫症患者。但在钻进 TCP 的复杂之前,得先把一个横亘在整个网络栈门口的谜题解开:用户态一个 `socket()` 调用,内核里到底造出了什么。 + +内核的哲学是“一切皆文件”,所以网络通信得能 `read()`/`write()`,得有个文件描述符。可一旦你真正 `socket(AF_INET, SOCK_STREAM, 0)`,内核并没有只建一个 inode,而是策划了一场“分裂”——它同时造了两样东西:**`struct socket`** 和 **`struct sock`**。这俩名字只差一个字母,却是两个物种,让无数初学者晕头转向。 + +为什么要拆成两个?因为一个套接字得同时扮演两个截然相反的角色: + +- **`struct socket`** 是面向**用户空间**的“门面”(`net/socket.c`、`include/linux/net.h`)。它带着 `state`(`SS_UNCONNECTED` / `SS_CONNECTED`)、`type`(`SOCK_STREAM` 之类)、一个 `file *` 指针(这就是它能被 `read`/`write` 的原因),还有一张回调表 `ops`(`proto_ops`,装着 `connect`/`listen`/`sendmsg`/`recvmsg`)。注意 TCP 的 `ops` 里有真正的 `inet_listen()`/`inet_accept()`,而 UDP 这俩回调被设成 `sock_no_listen()`——唯一动作就是返回 `-EOPNOTSUPP`,因为明信片根本不需要接电话。 +- **`struct sock`** 是面向**网络层(L3)** 的“引擎房”(`include/net/sock.h`),协议无关。它才是承载连接状态的实体:`sk_receive_queue`(收到的包先挂这儿等用户读)、`sk_write_queue`(准备发出去的包排这儿)、`sk_rcvbuf`/`sk_sndbuf`(收发缓冲区大小)、`sk_protocol`、`sk_type`,还有回调 `sk_data_ready`(“有货了”)和 `sk_write_space`(“能继续写了”)。 + +`struct socket` 里有个 `sk` 指针,把这俩绑在一起。在 IPv4 里,`inet_create()`(`net/ipv4/af_inet.c`)负责分配 `socket` 的同时把那个 `sock` 也建好。所以用户手里那个 `sockfd` 是文件凭证,凭证背后是 `struct socket`,`struct socket` 再指向真正干活的 `struct sock`——三层套娃,才维持住“socket 就是个文件”的体面假象。 + +## TCP 头:比 UDP 重得多的行囊 + +认清 TCP 之前,先认清它的脸。UDP 头只有 8 字节,短小精悍;TCP 头不含选项就 20 字节,带上选项最多 60 字节。每一比特都有用武之地(`include/uapi/linux/tcp.h` 的 `struct tcphdr`): + +- **source / dest**:源/目的端口,传输层的多路复用钥匙,决定数据归哪个进程。 +- **seq / ack_seq**:序号与确认号,各 32 位,是可靠性的基石。注意 `ack_seq` 只有在 ACK 标志为 1 时才有效,它告诉对方“这之前的我都收到了,接下来我期待这个序号”。 +- **doff**:数据偏移,4 位,单位是 4 字节——其实就是头部长度。因为 TCP 头变长(有选项),得靠它告诉内核“真正的数据从哪开始”,最小 5(20 字节),最大 15(60 字节)。 +- **标志位**(每个 1 比特,每个都能改写状态机走向):`SYN`(握手同步序号)、`ACK`(确认号有效,几乎除第一个包外都带)、`FIN`(“我发完了,准备关门”)、`RST`(“连接出错,立刻重启”,紧急刹车)、`PSH`(“别缓存,立刻推给应用层”)、`URG`(紧急指针有效),外加 `ECE`/`CWR` 这俩显式拥塞通知(ECN,RFC 3168)标志——网络拥堵时不用靠丢包就能互相提醒,比以前野蛮丢包文明多了。(`include/uapi/linux/tcp.h` 里这一组 bitfield 最前面还塞了个 `ae` 位,是 TCP-AO/AccECN 之类的新活儿,咱们先不展开。) +- **window**:接收窗口(16 位),流量控制的阀门——“我的接收缓冲区还剩这么多空位,你别超发”。 +- **check / urg_ptr**:校验和(覆盖头部和数据)、紧急指针(仅 URG 置位时有意义)。 + +复杂性意味着开销,但也意味着控制力。UDP 用速度换了放弃控制,TCP 则紧紧抓住每一个比特,不让你的包迷失在网络荒原里。 + +## 注册与初始化:把一个复杂灵魂塞进内核 + +TCP 这么复杂,初始化自然不能像 UDP 那样随便。两步走: + +第一步,定义并注册一个 `net_protocol` 对象(`net/ipv4/af_inet.c`),用 `inet_add_protocol()` 在 `inet_init()` 里挂上协议链表。注意 6.19 里它已经不是当年那个挂满回调的大胖子了——`struct net_protocol`(`include/net/protocol.h`)瘦得只剩 `.handler`/`.err_handler` 和两个 bit 位(`.no_policy`/`.icmp_strict_tag_validation`),而且整体塞进了 per-netns 的 `net_hotdata`: + +```c +net_hotdata.tcp_protocol = (struct net_protocol) { + .handler = tcp_v4_rcv, /* 收包入口 */ + .err_handler = tcp_v4_err, + .no_policy = 1, + .icmp_strict_tag_validation = 1, +}; +if (inet_add_protocol(&net_hotdata.tcp_protocol, IPPROTO_TCP) < 0) + pr_crit("%s: Cannot add TCP protocol\n", __func__); +``` + +那些年笔记里爱写的 `.early_demux = tcp_v4_early_demux`、`.netns_ok = 1` 已经不复存在了。`tcp_v4_early_demux()` 这个函数本身还在(`net/ipv4/tcp_ipv4.c`),但调用点**上移到了 IP 层**——`net/ipv4/ip_input.c` 在 `ip_rcv_finish` 那段会按 `sysctl_tcp_early_demux` 决定要不要提前做一次 socket 预查找。换句话说,早分流不再是 L4 协议结构体的事,而是 IP 层抢着干了。 + +第二步,注册 socket 层操作的 `proto` 对象 `tcp_prot`(`net/ipv4/tcp_ipv4.c`),用 `proto_register()`。注意 `.init = tcp_v4_init_sock` 这一回调——UDP 那节没展开类似的 `.init`,但 UDP 其实也有(`udp_prot.init = udp_init_sock`),只是它做的事很轻(端口查找表、destruct 钩子),TCP 不一样,`tcp_v4_init_sock` 要初始化定时器、缓冲区、拥塞窗口一整套,重得多——这才是对比点。 + +当你 `socket(AF_INET, SOCK_STREAM, 0)` 时,内核最终调到 `tcp_v4_init_sock()` → `tcp_init_sock()`(`net/ipv4/tcp.c`),把一个空壳 `struct sock` 变成一个有状态的 TCP 实体: + +1. 状态这时是 `TCP_CLOSE`(这是 `sk_alloc()` 给每个新 `sock` 的默认初值,`net/core/sock.c`,`tcp_init_sock` 依赖这个起点)。 +2. **初始化定时器**(`tcp_init_xmit_timers()`)——TCP 极度依赖定时器,没了它们就不知道该重传还是该放弃。 +3. 设置收发缓冲区:默认发送缓冲 16KB(`sysctl_tcp_wmem[1]`,`net/ipv4/tcp.c`)、接收 128KB(`sysctl_tcp_rmem[1]`,即 `131072` 字节),可经 `/proc/sys/net/ipv4/tcp_wmem`、`tcp_rmem` 调优。(笔记里那个“接收 87KB”的数字在 6.19 源码里查无实据,是早年版本的旧值,已弃用。) +4. 初始化乱序队列(`tp->out_of_order_queue = RB_ROOT`)与重传队列(`sk->tcp_rtx_queue = RB_ROOT`)。**注意:曾经笔记爱提的 prequeue 在现代内核已经退场**——6.19 的 `tcp_init_sock` 里没有任何 prequeue 初始化,收包路径也没有,别去源码里找它了。 +5. 把初始拥塞窗口设为 10 个段(`tcp_snd_cwnd_set(tp, TCP_INIT_CWND)`,`TCP_INIT_CWND` 定义为 10,对齐 RFC 6928)。 + +没有这一步,后面的 `connect()`/`listen()` 都无从谈起。这步对 IPv6 同理(走 `tcp_v6_init_sock`)。 + +## 三次握手内核视角:状态机的流转 + +教科书里三次握手是“交换三个包”,但在内核里它更是状态和内存结构的转换。socket 任意时刻都处在一个状态(`TCP_LISTEN`、`TCP_SYN_SENT` 等),存在 `struct sock` 的 `sk_state` 里。 + +1. **客户端发 SYN**:`connect()` 发出 SYN,客户端 `TCP_CLOSE` → `TCP_SYN_SENT`。 +2. **服务端收 SYN,回 SYN-ACK**:服务端在 `TCP_LISTEN`(`listen()` 进入)。这里有个关键设计——**内核不会把监听 socket 本身变成已连接**,因为监听 socket 得服务所有客户端。它转而创建一个新的 `request_sock`(请求 sock)代表这个半成品连接,状态设为 `TCP_SYN_RECV`,然后回送 SYN-ACK。这批 `request_sock` 排在**半连接队列(SYN queue)**里。 +3. **客户端收 SYN-ACK,发 ACK**:客户端 `TCP_SYN_SENT` → `TCP_ESTABLISHED`,发出最后的 ACK。 +4. **服务端收 ACK**:`request_sock` 完成使命,内核基于它创建一个完整的子 socket(child socket),状态置 `TCP_ESTABLISHED`,放进**全连接队列(accept queue)**,等应用层 `accept()` 取走。 + +整个状态机流转的总控是 `tcp_rcv_state_process()`(`net/ipv4/tcp_input.c`)——除了 `ESTABLISHED` 状态的快路径,绝大部分状态变迁都经它手。行号待亲测核对。 + +## 收包 tcp_v4_rcv:从 IP 层上来后 + +连接建好了,数据开始流。当 IP 层的 `struct sk_buff` 到达,TCP 的入口是 `tcp_v4_rcv()`(`net/ipv4/tcp_ipv4.c`): + +**第一步:sanity 检查 + 找 socket。** 包是不是发给我们的、长度够不够一个 TCP 头,然后最关键——调 `__inet_lookup_skb(&tcp_hashinfo, ...)` 在 hash 表里找归属。先查 established 表找已连接 socket,找不到再查 listening 表找监听 socket;都找不到就是瞎发的,丢弃。 + +**第二步:socket 被用户占着吗?** 用 `sock_owned_by_user()` 判断。 + +- **情况 A:没人用** → 直接 `tcp_v4_do_rcv()` 走正常流程。这里得专门提一句:早年内核(约 3.x/4.x)会在这一步先把包塞进 **prequeue** 缓存队列、等用户进程下次碰 socket 时批量处理,但**这套 prequeue 优化在 6.19 已经基本移除**(源码里 `grep prequeue` 只剩注释),现在没人占着就直接进处理函数,不再有那个中间层。 +- **情况 B:被用户进程锁住** → 不能乱动它的数据结构,调 `tcp_add_backlog()` 把包暂时塞进 **backlog** 队列;backlog 都满了就只能丢包,并统计 `LINUX_MIB_TCPBACKLOGDROP`。 + +不管哪条路,最终都在 `tcp_v4_do_rcv()` 里分拣:`TCP_ESTABLISHED`(快路径)走 `tcp_rcv_established()`;`TCP_LISTEN` 先调 `tcp_v4_cookie_check()`(处理 SYN cookie / `request_sock`),再喂给 `tcp_child_process()` 处理子 socket(**注意:笔记里常写的 `tcp_v4_hnd_req()` 在 6.19 已彻底删除**,`grep` 全树无命中,别再去找它);其他状态走大管家 `tcp_rcv_state_process()`。 + +## 发包:socket write 到 ip_queue_xmit + +用户态 `send()`/`sendmsg()` 最终落到 `tcp_sendmsg()`(`net/ipv4/tcp.c`),比 UDP 复杂得多——它不是把指针指过去就完事:从用户空间拷数据到 `skb`、处理 Nagle 算法(立刻发还是攒一攒)、按 MSS 拆段、检查 `sk_sndbuf`。组装好放 `skb` 后,一路走 `tcp_push_one()` → `tcp_write_xmit()` → **`tcp_transmit_skb()`**(`net/ipv4/tcp_output.c`)。 + +最后一跃交给 IP 层的那行(6.19 实际形态,带 `INDIRECT_CALL_INET` 优化包装): + +```c +err = INDIRECT_CALL_INET(icsk->icsk_af_ops->queue_xmit, + inet6_csk_xmit, ip_queue_xmit, + sk, skb, &inet->cork.fl); +``` + +注意签名里 `queue_xmit` 现在是三参的(`sk, skb, fl`),不是早年笔记里那个只剩 `skb, fl` 的两参版本——别照抄老行号。`icsk_af_ops` 是面向地址族的操作对象,IPv4 TCP 指向 `ipv4_specific`(`net/ipv4/tcp_ipv4.c`),其 `queue_xmit` 回调就是通用的 `ip_queue_xmit()`。至此 TCP 层交差,数据包正式移交 IP 层。 + +## 定时器:TCP 是有记忆、有时间的协议 + +TCP 的可靠性很大一部分建立在“等待”和“重试”上,这些都由 `net/ipv4/tcp_timer.c` 里的定时器管,每个针对一种“焦虑症”: + +1. **重传定时器**:最焦虑的一个。每发一段就启动,超时没收到 ACK 就重发——包丢了它是最后救命稻草。 +2. **延迟 ACK 定时器**:较佛系。收到数据不必立刻回 ACK,可以稍等(比如 200ms)看有没有数据能捎带回去,减少小包。 +3. **保活定时器(keepalive)**:防“僵尸连接”。两端长期无数据,中间路由器可能断了、对端可能断电,谁也不知道对方还活着没——keepalive 定期探测,发现没反应就调 `tcp_send_active_reset()` 干掉连接。 +4. **零窗口探测定时器(persistent)**:经典死锁防止。接收方缓冲满了告诉发送方“窗口为 0 别发了”,发送方就停。可万一接收方腾出空间后发的“窗口更新”包半路丢了?发送方以为还是 0 继续等、接收方以为通知过了继续等数据——死锁。于是发送方不干等,启动这个定时器,时不时发个小包戳一下“喂,窗口开没?”,收到非零响应再继续传。 + +## 小结 + +TCP 是内核里最复杂的协议之一:它把一个套接字拆成对上的 `struct socket` 和对下的 `struct sock`;头部带着序号/确认号/窗口/一排标志位,换来可靠与可控;初始化时注册 `net_hotdata.tcp_protocol`/`tcp_prot`(注意 6.19 的 `net_protocol` 已精简、`early_demux` 上移 IP 层),靠 `tcp_v4_init_sock` 把空壳变成有状态的实体;连接建立是一场状态机流转,半连接队列存 `TCP_SYN_RECV` 的 `request_sock`、全连接队列存就绪的 child socket;收包从 `tcp_v4_rcv` 入口、按四元组查 socket、按占用情况分流 backlog/`tcp_v4_do_rcv`(prequeue 已退场);发包从 `tcp_sendmsg` 一路走到 `tcp_transmit_skb` 再交 `ip_queue_xmit`;而贯穿全程的是重传/延迟 ACK/keepalive/零窗口这四个定时器——TCP 之所以可靠,是因为它有记忆、有时间。 + +记住三件事:**socket/sock 双胞胎**(门面 vs 引擎房)、**两次队列分流**(半连接 vs 全连接;正常路径 vs backlog)、**定时器撑起可靠性**(TCP 没时间感就不叫 TCP)。 + +## 动手待亲测(验证方案) + +这部分还没在 QEMU 里跑过,下面是验证清单,等亲测后填真实输出: + +- `ss -ti`:看 TCP 内部状态机字段(cwnd、rtt、重传计数),核对与笔记里“状态保存在 `sk_state`”的对应。 +- `tcpdump -i any -n 'tcp port '`:抓三次握手,核对 SYN → SYN-ACK → ACK 三个包与状态 `TCP_SYN_SENT`/`TCP_SYN_RECV`/`TCP_ESTABLISHED` 的对应。 +- `cat /proc/net/tcp`:看连接表,关注第 2 列状态码(`01`=ESTABLISHED、`06`=TIME_WAIT、`0A`=LISTEN 等),核对半连接与全连接。 +- 调 `/proc/sys/net/ipv4/tcp_wmem`、`tcp_rmem` 观察缓冲区默认值(应看到第二列是 16KB / 128KB,验证源码里的 `sysctl_tcp_wmem[1]`/`sysctl_tcp_rmem[1]`)。 + +> ⚠️ **待亲测**:上面 `ss`/`tcpdump`/`/proc/net/tcp` 的输出我们会在 QEMU ARM64 上跑一遍记下真实结果,再把“状态机/队列分流”亲眼看到,然后升级到 ✅ 已锤炼。 + +## 延伸阅读 + +- 源码:`net/ipv4/tcp_ipv4.c`(Linux 6.19,`tcp_v4_rcv`/`tcp_v4_do_rcv`/`tcp_v4_init_sock`/`tcp_v4_cookie_check`/`tcp_prot`)、`net/ipv4/tcp_input.c`(`tcp_rcv_state_process` 状态机)、`net/ipv4/tcp_output.c`(`tcp_transmit_skb` 发包)、`net/ipv4/tcp.c`(`tcp_sendmsg`/`tcp_init_sock`/`sysctl_tcp_wmem`/`sysctl_tcp_rmem`)、`net/ipv4/tcp_timer.c`(四种定时器)、`net/ipv4/af_inet.c`(`net_hotdata.tcp_protocol` 注册与 `inet_create`)、`net/ipv4/ip_input.c`(`early_demux` 上移后的调用点)、`include/net/protocol.h`(精简后的 `struct net_protocol`)、`include/net/sock.h`(`struct sock`)、`include/uapi/linux/tcp.h`(`struct tcphdr`)。 +- kernel.org:[Linux networking subsystem](https://docs.kernel.org/networking/index.html)。 +- RFC 793(TCP)、RFC 6928(初始拥塞窗口 10 段)、RFC 3168(ECN)。 +- 进一步(持续铺开):SCTP/DCCP 这种 TCP/UDP 之间的“混血儿”、TCP 拥塞控制算法(Cubic/BBR)、四挥与 TIME_WAIT/2MSL 的关闭流程。 \ No newline at end of file diff --git a/document/tutorials/kernel/net/07-net-udp.md b/document/tutorials/kernel/net/07-net-udp.md new file mode 100644 index 00000000..a154b9a3 --- /dev/null +++ b/document/tutorials/kernel/net/07-net-udp.md @@ -0,0 +1,236 @@ +--- +title: UDP:无连接的轻量传输 +slug: net-udp +difficulty: intermediate +tags: [网络栈, UDP, 传输层, socket] +architectures: [arm64, x86_64, riscv] +kernel_version: "6.19" +maturity: drafting +prerequisites: + - /tutorials/kernel/net/04-net-ipv4 +related: + - /tutorials/kernel/net/04-net-ipv4 + - /tutorials/kernel/net/06-net-tcp +sources: + - notes: document/notes/linux_kernel_networking/ch11.md + - notes: document/notes/linux_kernel_networking/ch11_1.md + - notes: document/notes/linux_kernel_networking/ch11_2.md + - notes: document/notes/linux_kernel_networking/ch11_3.md +--- + +# UDP:无连接的轻量传输 + +> 🔨 **整理中** · 本篇机制对照 Linux 6.19 源码讲解(函数/数据结构已核对);具体行号与命令输出待 QEMU 亲测核对。 + +## UDP 在内核里扮演什么角色 + +我们顺着上一篇 IPv4 的路子往上爬一层,到了传输层。先挑 UDP 这个「最直来直去」的家伙开刀——它没什么花哨状态机,几乎就是 IP 层上面裹的一层薄纸,只多做了一件事:**加端口号**。 + +UDP 的设计哲学是「尽力而为」:不保证送达、不保证顺序、甚至不保证连接还在。它把可靠性整个甩给了应用层,自己只管把数据报怼出去。这种"不负责任"反而让它成了对延迟敏感场景的宠儿——DNS 一问一答、DHCP 开局找地址、视频流丢两帧无所谓、QUIC 干脆在 UDP 上重建了一套可靠的传输。VoIP 里的 RTP 也是 UDP 跑的:实时音频丢几个包顶多声音卡一下,为了重传而延迟两秒那才叫灾难。 + +RFC 768 把它在 1980 年就钉死了,头部就那么点东西。接下来我们从头部开始,一路追到内核怎么发、怎么收。 + +## 头部:8 字节,四个字段 + +UDP 头部固定 8 字节,比 TCP 那个 20 字节起步、还能带一堆选项的头部寒酸得多。内核里就一个结构(`include/uapi/linux/udp.h`,Linux 6.19): + +```c +struct udphdr { + __be16 source; // 源端口 + __be16 dest; // 目的端口 + __be16 len; // 长度(含头部) + __sum16 check; // 校验和 +}; +``` + +四个 16 位字段:源端口、目的端口、长度、校验和。注意 `len` 只有 16 位,所以单个 UDP 包(含头)最大 65535 字节——这点会直接变成后面 `udp_sendmsg` 里的长度检查。`check` 字段是校验和,IPv4 里**理论上可以置 0 表示不算**(UDP-Lite 变体还能只校验一部分,详见 RFC 3828,内核里它复用 UDP 代码,主要在 `net/ipv4/udplite.c`)。 + +内核解析 UDP 头部靠 `udp_hdr()`(`include/linux/udp.h`),本质就是从 SKB 的传输层头部位置强转出来: + +```c +static inline struct udphdr *udp_hdr(const struct sk_buff *skb) +{ + return (struct udphdr *)skb_transport_header(skb); +} +``` + +而每个 UDP socket 在内核里还挂着一个更大的 `struct udp_sock`(同样在 `include/linux/udp.h`),它把 `inet_sock` 包在第一个成员里,再多出 UDP 特有的状态:`len`(cork 时攒包的总长度)、`pending`(有没有挂着的待发包)、`encap_type`(隧道封装用,比如 VXLAN/QUIC 都靠它把 UDP 当集装箱)、`reader_queue`(接收快队列)等等。这是 socket 侧的状态仓,头部那 8 字节是线上的真实报文。 + +## 注册到内核:两张表,两条入口 + +UDP 要干活,得在内核的两张表里登记。这两张表对应它的两张「脸」——一张面向网络层(收包),一张面向 socket 系统调用(发包/收包)。 + +**第一张表:网络层协议表。** 内核在 `inet_init()` 里(`net/ipv4/af_inet.c`)注册一个 `net_protocol` 结构,告诉 IP 层:「收到协议号 `IPPROTO_UDP` 的包,调我这个 handler」(Linux 6.19): + +```c +net_hotdata.udp_protocol = (struct net_protocol) { + .handler = udp_rcv, + .err_handler = udp_err, + .no_policy = 1, +}; +if (inet_add_protocol(&net_hotdata.udp_protocol, IPPROTO_UDP) < 0) + pr_crit("%s: Cannot add UDP protocol\n", __func__); +``` + +注意 6.19 这里它被收进了 `net_hotdata.udp_protocol`(热数据结构,省一次缓存行跳转),handler 就是接收入口 `udp_rcv`,`err_handler` 是 `udp_err`(处理 ICMP 报错)。`no_policy = 1` 表示不做 XFRM 策略检查,省点开销。 + +**第二张表:socket 操作表。** `struct proto udp_prot`(`net/ipv4/udp.c`)把 socket 系统调用映射到 UDP 的具体实现(Linux 6.19): + +```c +struct proto udp_prot = { + .name = "UDP", + .close = udp_lib_close, + .connect = udp_connect, + .disconnect = udp_disconnect, + .ioctl = udp_ioctl, + .sendmsg = udp_sendmsg, // 发包入口 + .recvmsg = udp_recvmsg, // 收包入口 + .get_port = udp_v4_get_port, + .obj_size = sizeof(struct udp_sock), + ... +}; +``` + +用户态调 `send()`/`sendto()`/`sendmsg()` 最终都汇到 `.sendmsg = udp_sendmsg`;收包同理落到 `udp_recvmsg`。UDP 的 `.connect` 不是握手——它只是给 socket 写死一个默认对端(给明信片提前写好收件人),后续 `send()` 不用每次填地址。 + +登记完,收发两条路就通了。先看发包。 + +## 发送:`udp_sendmsg` 的快路与慢路 + +`udp_sendmsg()` 是 UDP 发包的总指挥(`net/ipv4/udp.c`,Linux 6.19)。注意现在的签名已经比老书上的干净了——不再有 `kiocb`: + +```c +int udp_sendmsg(struct sock *sk, struct msghdr *msg, size_t len) +{ + struct inet_sock *inet = inet_sk(sk); + struct udp_sock *up = udp_sk(sk); + ... + int corkreq = udp_test_bit(CORK, sk) || msg->msg_flags & MSG_MORE; + ... + if (len > 0xFFFF) + return -EMSGSIZE; +``` + +第一件事是算 `corkreq`——要不要「软木塞」。UDP 默认即发即走:给 10 字节就立刻发一个 10 字节的小包。但有时你想把多次小写攒成一个大包再发(应用层自己拼数据时常见),这就靠 `UDP_CORK` socket 选项或 `MSG_MORE` flag。`corkreq` 决定了走快路还是慢路。 + +紧接着是长度检查 `len > 0xFFFF → -EMSGSIZE`,这就是头部 `len` 只有 16 位的硬约束——超 64KB 直接拒,原因就在头部那 4 个字段的宽度上。 + +接下来确定「发给谁」。两种情况:用户在 `msg->msg_name` 里直接塞了 `sockaddr_in`(带目的 IP/端口,端口不能为 0),或者没塞——那这个 socket 必须之前 `connect()` 过,状态被标成 `TCP_ESTABLISHED`(UDP 借这个名字只表示「已指定默认对端」,跟 TCP 那种真握手没关系),否则报 `-EDESTADDRREQ`。 + +地址搞定后,解析辅助数据(`msg_controllen` 非空就 `ip_cmsg_send()`,比如用 `IP_PKTINFO` 指定源地址),再做路由查找(`ip_route_output_flow()`,构造 `flowi4` 四元组去查路由表)。路由结果 `rt` 是后面构建 SKB 的依据。 + +**快路(无锁,Kernel 2.6.39 引入)**:没开 cork,就没必要拿那把沉重的 socket 锁,直接构建并发出(Linux 6.19): + +```c +/* Lockless fast path for the non-corking case. */ +if (!corkreq) { + struct inet_cork cork; + skb = ip_make_skb(sk, fl4, getfrag, msg, ulen, + sizeof(struct udphdr), &ipc, &rt, + &cork, msg->msg_flags); + ... + if (!IS_ERR_OR_NULL(skb)) + err = udp_send_skb(skb, fl4, &cork); + goto out; +} +``` + +`ip_make_skb()` 把数据从用户态拷进 SKB、贴上 IP+UDP 头,组装好但不发;`udp_send_skb()` 填 UDP 校验和(`udp_csum`,覆盖伪首部+UDP 头+数据)后交给 `ip_send_skb` 进 IP 层发送队列。一气呵成,全程不持锁——这就是「寄一封扔邮筒一封」。 + +**慢路(cork,上锁)**:开了软木塞就得维护状态,必须上锁(Linux 6.19): + +```c +lock_sock(sk); +... +WRITE_ONCE(up->pending, AF_INET); +do_append_data: + up->len += ulen; + err = ip_append_data(sk, fl4, getfrag, msg, ulen, ...); + if (err) + udp_flush_pending_frames(sk); // 出错就冲掉攒的包,防内存泄漏 + else if (!corkreq) + err = udp_push_pending_frames(sk); // 真正触发发送+分片 +``` + +`ip_append_data()` 不发,只把数据拷到 `sk->sk_write_queue` 队列里攒着;等攒够了或取消 cork,`udp_push_pending_frames()` 一次性触发发送。这是「攒一摞信打成一个包裹再叫快递」。 + +## 接收:`udp_rcv` → 查 socket → 入队列 + +接收是发包的反向,但多了个关键动作:**根据四元组找 socket**。 + +入口 `udp_rcv()` 极简,就是个二传手(`net/ipv4/udp.c`,Linux 6.19): + +```c +int udp_rcv(struct sk_buff *skb) +{ + return __udp4_lib_rcv(skb, dev_net(skb->dev)->ipv4.udp_table, IPPROTO_UDP); +} +``` + +真正干活的是 `__udp4_lib_rcv()`。它先做校验:`pskb_may_pull` 确认 SKB 装得下 UDP 头、`ulen = ntohs(uh->len)` 取长度、`udp4_csum_init()` 初始化校验和验证。然后是广播/组播的分支(`__udp4_lib_mcast_deliver`),单播则走核心逻辑——查 socket(Linux 6.19): + +```c +sk = inet_steal_sock(net, skb, sizeof(struct udphdr), saddr, uh->source, + daddr, uh->dest, &refcounted, udp_ehashfn); +... +if (sk) { + ... + ret = udp_unicast_rcv_skb(sk, skb, uh); + return ret; +} +if (rt->rt_flags & (RTCF_BROADCAST|RTCF_MULTICAST)) + return __udp4_lib_mcast_deliver(net, skb, uh, saddr, daddr, udptable, proto); + +sk = __udp4_lib_lookup_skb(skb, uh->source, uh->dest, udptable); +if (sk) + return udp_unicast_rcv_skb(sk, skb, uh); +``` + +`__udp4_lib_lookup_skb()`(底层是 `__udp4_lib_lookup`)在 UDP 哈希表 `udp_table` 里用四元组(源 IP、源端口、目的 IP、目的端口)匹配 socket。哈希表本身是 `struct udp_table`(`udp.c` 里的全局 `udp_table`,每 netns 一份 `net->ipv4.udp_table`),由 `udp_hashfn()` 这类函数算槽位。 + +**找到了 socket**:说明有应用在这个端口监听。链路是 `udp_unicast_rcv_skb` → `udp_queue_rcv_skb`(带 BPF filter/封装检查)→ `__udp_queue_rcv_skb` → `__udp_enqueue_schedule_skb`,最终把 SKB 挂到 `sk->sk_receive_queue`(以及接收快队列 `reader_queue`)的尾巴上,等用户态 `recvmsg` 来取。 + +**没找到 socket**:地址对、端口没人收。这时不能悄无声息地丢。内核先 `udp_lib_checksum_complete()` 复查校验和——错了直接丢;没错就礼貌地给发信方回一个 ICMP Destination Unreachable(Code 3: Port Unreachable),并递增 `UDP_MIB_NOPORTS` 计数器(`netstat -su` 能看到)(Linux 6.19): + +```c +__UDP_INC_STATS(net, UDP_MIB_NOPORTS, proto == IPPROTO_UDPLITE); +icmp_send(skb, ICMP_DEST_UNREACH, ICMP_PORT_UNREACH, 0); +``` + +所以你拿 UDP 探一个没人监听的端口,是会收到 ICMP 回声的——这就是「端口不可达」的来源。 + +## 校验和:伪首部那点事 + +UDP 校验和覆盖三段:**伪首部(源 IP + 目的 IP + 协议号 + UDP 长度)+ UDP 头 + 数据**。伪首部不是真实报文的一部分,纯粹是为了让校验和能验证「这个包确实送到了正确的 IP 和协议」。发送侧在 `udp_send_skb` 里通过 `udp_csum()` 算出来填进 `uh->check`;接收侧在 `__udp4_lib_rcv` 开头用 `udp4_csum_init()` 起算、`udp_lib_checksum_complete()` 复查。IPv4 里 UDP 校验和**可选**(可置 0 跳过),IPv6 里则强制——这是两套协议对 UDP 可靠性的取舍差异。 + +## 跟 TCP 比一比 + +| | UDP | TCP | +|:---|:---|:---| +| 连接 | 无(`connect` 只设默认对端) | 三次握手建连接 | +| 可靠性 | 不保证送达/顺序 | 重传、序号、ACK | +| 流控 | 没有 | 滑动窗口、拥塞控制 | +| 头部 | 8 字节固定 | 20 字节起步,可带选项 | +| 内核状态 | 几乎无(cork 算一点) | 庞大状态机 | + +UDP 什么承诺都不给,换来的是低开销、低延迟、实现简单。代价是「出了事自己兜」——应用层要可靠就得自己加序号、重传、拥塞控制(QUIC 就是这么干的)。所以选 UDP 不是因为它「更好」,而是你**需要自己掌控这些机制**,或者根本不在乎那点丢包。 + +## 动手验证(待 QEMU 亲测) + +> ⚠️ **待亲测**:下面是验证方案占位,等拿到 QEMU 环境实跑后补真实输出。 + +1. **看 UDP socket**:`ss -u -a` 列出所有 UDP socket,对照 `udp_prot` 的注册,理解每个 socket 背后的 `struct udp_sock`。 +2. **写 UDP echo**:用户态 `socket(AF_INET, SOCK_DGRAM, 0)` + `bind` + `recvfrom`/`sendto`,对照 `udp_sendmsg` 快路和 `udp_recvmsg` 路径。 +3. **抓包看头**:`tcpdump -i any udp -X` 抓 UDP 包,肉眼数那 8 字节头部(src port/dst port/len/check)。 +4. **探空端口**:往一个没人监听的端口发 UDP,`tcpdump` 同步抓,对照 `icmp_send(ICMP_PORT_UNREACH)` 看回的 ICMP。 +5. **压限长**:发一个超 65535 字节的 UDP,验证 `-EMSGSIZE`(对应 `len > 0xFFFF` 检查)。 + +## 小结 + +UDP 是传输层最薄的一层:头部 8 字节四个字段,靠 `udp_prot`(socket 操作表)和 `net_hotdata.udp_protocol`(IP 层收包入口)两张表注册到内核。发送 `udp_sendmsg` 走无锁快路(`ip_make_skb` + `udp_send_skb`)或有锁 cork 慢路(`ip_append_data` + `udp_push_pending_frames`);接收 `udp_rcv` → `__udp4_lib_rcv` → 四元组查 `udp_table` → 找到就入 `sk_receive_queue`,找不到就回 ICMP Port Unreachable。它的「轻」换来的是「不可靠」,可靠性得应用层自己补。 + +## 延伸阅读 + +- 源码(Linux 6.19):`net/ipv4/udp.c`(收发主逻辑、`udp_prot`/`udp_table`)、`net/ipv4/udplite.c`(UDP-Lite 变体)、`net/ipv4/af_inet.c`(`inet_init` 里注册 `udp_protocol`)、`include/linux/udp.h`(`struct udp_sock`、`udp_hdr`)、`include/uapi/linux/udp.h`(`struct udphdr`)。 +- kernel.org 稳定文档索引:[Networking — kernel documentation](https://docs.kernel.org/networking/index.html)、[Linux Networking and Network Devices APIs](https://docs.kernel.org/networking/netdev-features.html)。 +- 进一步(持续铺开):下一篇 TCP 状态机与重传、UDP GSO/GRO、QUIC 在 UDP 上的封装。 \ No newline at end of file diff --git a/document/tutorials/kernel/net/08-net-netfilter.md b/document/tutorials/kernel/net/08-net-netfilter.md new file mode 100644 index 00000000..3c4be8ce --- /dev/null +++ b/document/tutorials/kernel/net/08-net-netfilter.md @@ -0,0 +1,248 @@ +--- +title: Netfilter:网络栈的钩子框架 +slug: net-netfilter +difficulty: intermediate +tags: [Netfilter, 网络栈, 连接跟踪, NAT] +architectures: [arm64, x86_64, riscv] +kernel_version: "6.19" +maturity: drafting +prerequisites: + - /tutorials/kernel/net/04-net-ipv4 +related: + - /tutorials/kernel/net/04-net-ipv4 +sources: + - notes: document/notes/linux_kernel_networking/ch09.md + - notes: document/notes/linux_kernel_networking/ch09_2.md + - notes: document/notes/linux_kernel_networking/ch09_4.md + - notes: document/notes/linux_kernel_networking/ch09_6.md + - notes: document/notes/linux_kernel_networking/ch09_7.md +--- + +# Netfilter:网络栈的钩子框架 + +> 🔨 **整理中** · 本篇机制对照 Linux 6.19 源码讲解(函数名 / 超时值 / 挂载点均已逐条 grep 核对);具体行号与命令输出待 QEMU 亲测核对。 + +## Netfilter 是网络栈的「检查站体系」 + +上一篇我们追着包从 `ip_rcv` 一路走到 `ip_output`,把 IPv4 收发路径的骨架摸了一遍。但那条路径上其实埋着一整套「海关系统」——每个包进站、出站、转发时,都要被一排检查员拦下来过一遍:查身份、改地址、记流水、决定放行还是扔掉。这套系统就是 **Netfilter**。 + +它是 Linux 防火墙、NAT、流量整形的共同地基。你在用户空间敲的 `iptables`、`nft`、`conntrack`,底下全是它。但 Netfilter 本身**不做任何具体策略**——它只提供「在协议栈关键路口插钩子」的能力,把活儿派给注册进来的模块。理解了这层「框架 vs 客户」的关系,后面看 conntrack、NAT、iptables 都会顺理成章。 + +## 五个挂载点:包一生要过的五个检查站 + +Netfilter 在 IPv4/IPv6 协议栈里钉了五个统一的钩子点,定义在 `include/uapi/linux/netfilter.h`(Linux 6.19)的 `enum nf_inet_hooks`: + +```c +enum nf_inet_hooks { + NF_INET_PRE_ROUTING, + NF_INET_LOCAL_IN, + NF_INET_FORWARD, + NF_INET_LOCAL_OUT, + NF_INET_POST_ROUTING, + NF_INET_NUMHOOKS, + NF_INET_INGRESS = NF_INET_NUMHOOKS, +}; +``` + +把包想象成一列火车,这五个就是铁轨上的检查站,顺序严格由「铁轨物理连接」决定: + +- **`PRE_ROUTING`**:所有入站包的**第一站**,嵌在 `ip_rcv()` 里。此刻内核还没查路由表,连包是发给本机还是要转发都不知道——所以是「通用捕包」的最佳位置。 +- **`LOCAL_IN`**:嵌在 `ip_local_deliver()` 里。只有路由判决后确认「目的地是本机」的包才走这里。 +- **`FORWARD`**:嵌在 `ip_forward()` 里,专给「过路车」——路由判决要转发的包走这条专用线。这是 Linux 当路由器的核心路径。 +- **`LOCAL_OUT`**:嵌在 `__ip_local_out()` 里,本机进程发出的包的**始发站**。 +- **`POST_ROUTING`**:嵌在 `ip_output()` 里,所有出站包的**最后一站**。转发包(刚过 FORWARD)和本机生成的包(刚过 LOCAL_OUT)在这里汇合。 + +于是三条包的旅行路线就清楚了:发给本机走 `PRE_ROUTING → LOCAL_IN`;本机发出走 `LOCAL_OUT → POST_ROUTING`;转发走 `PRE_ROUTING → FORWARD → POST_ROUTING`。你在 `LOCAL_IN` 里等一个转发的包,永远等不到——物理上不通。 + +## `nf_hook_ops`:注册一个钩子,靠它拿到通行证 + +光有检查站概念不够,内核得有一套机制把代码真正挂上去。这就是 `struct nf_hook_ops`——你的「派工单」。它定义在 `include/linux/netfilter.h`: + +```c +struct nf_hook_ops { + struct list_head list; + struct rcu_head rcu; + /* User fills in from here down. */ + nf_hookfn *hook; + struct net_device *dev; + void *priv; + u8 pf; + enum nf_hook_ops_type hook_ops_type:8; + unsigned int hooknum; + /* Hooks are ordered in ascending priority. */ + int priority; +}; +``` + +关键字段三件套:`pf`(协议族,`NFPROTO_IPV4`/`NFPROTO_IPV6`)+ `hooknum`(五个点之一)+ `priority`(优先级)一起决定了「把 `hook` 这个回调函数派到哪个检查站的哪个位置」。 + +`priority` 是个很容易翻车的点:**数值越小越先调用**。一个检查站上可能同时有查毒品、查关税、查违禁品的几拨人在执勤,谁先查由它定。内核给了标准常量(`NF_IP_PRI_FIRST`、`NF_IP_PRI_CONNTRACK`、`NF_IP_PRI_NAT_SRC` 等,定义在 `include/uapi/linux/netfilter_ipv4.h`)。要是你把过滤规则的优先级排得比连接跟踪还高,conntrack 可能直接失效——包还没被记录就被你 DROP 或改写了。 + +注册 API 在 `net/netfilter/core.c`: + +```c +int nf_register_net_hook(struct net *net, const struct nf_hook_ops *reg); +int nf_register_net_hooks(struct net *net, const struct nf_hook_ops *reg, unsigned int n); +``` + +后者注册一组(数组),要么全成功要么全失败回滚,原子性好,多个点一起挂时用它。 + +### 钩子按优先级排序:`nf_hook_entries` 的 grow 逻辑 + +注册不是简单地往链表尾巴上塞。`nf_register_net_hook` 会调 `__nf_register_net_hook` → `nf_hook_entries_grow`,按 `priority` 把新回调**插到有序位置**,生成一份全新的 `struct nf_hook_entries`(柔性数组,`num_hook_entries` + `hooks[]`),再用 RCU 原子替换旧表(`rcu_assign_pointer`),旧表通过 `call_rcu` 延迟释放。整个替换是**无锁读、互斥写**——`nf_hook_mutex` 只保护注册/注销,包路径全程走 RCU 读锁。 + +一个细节:注销时不能直接删条目(怕有读者正在遍历),于是用 `WRITE_ONCE` 把该位回调换成 `accept_all`、`ops` 指针换成 `&dummy_ops`(`dummy_ops` 是那个永远返回 `NF_ACCEPT` 的占位,`net/netfilter/core.c`),**数组长度当场不变**。真正的「压缩」要等下一次有新钩子注册、`nf_hook_entries_grow` 重建这张表时,跳过 `dummy_ops` 条目,顺带把空位挤掉(`core.c` 里多次 `if (orig_ops[i] == &dummy_ops)` 跳过)。并没有一条独立的 shrink 路径——这是源码里那句注释「Hook unregistration must always succeed」的来由。 + +## 包过检查站:`NF_HOOK` 宏 → `nf_hook_slow` + +协议栈在每个检查站都硬编码了 `NF_HOOK` 宏(`include/linux/netfilter.h`)。比如 `ip_local_deliver` 里: + +```c +NF_HOOK(NFPROTO_IPV4, NF_INET_LOCAL_IN, net, sk, skb, in, out, okfn); +``` + +`NF_HOOK` 展开后先调 `nf_hook()`:它在 RCU 读锁下,按 `pf` 找到本网络命名空间 `net->nf.hooks_ipv4[hook]`(或 `hooks_ipv6`)这张表,初始化一个 `struct nf_hook_state`(装着 hook 号、入/出网卡、`okfn` 等),然后调 `nf_hook_slow(skb, &state, hook_head, 0)`。 + +> 一个性能优化很巧:开了 `CONFIG_JUMP_LABEL` 时,`nf_hook()` 开头先用静态键 `nf_hooks_needed[pf][hook]` 判断——如果这个检查站压根没注册任何钩子,直接返回 1,连 RCU 锁都不上。注册钩子时 `nf_static_key_inc` 才把静态键打开。零钩子的检查站近乎零开销。 + +`nf_hook_slow`(`net/netfilter/core.c`)就是遍历这张表,按优先级顺序逐个调回调,根据返回值决定走不走下一个: + +```c +int nf_hook_slow(struct sk_buff *skb, struct nf_hook_state *state, + const struct nf_hook_entries *e, unsigned int s) +{ + unsigned int verdict; + for (; s < e->num_hook_entries; s++) { + verdict = nf_hook_entry_hookfn(&e->hooks[s], skb, state); + switch (verdict & NF_VERDICT_MASK) { + case NF_ACCEPT: + break; /* 继续下一个回调 */ + case NF_DROP: + kfree_skb_reason(skb, SKB_DROP_REASON_NETFILTER_DROP); + ... + return ret; /* 死刑,走人 */ + case NF_QUEUE: + ret = nf_queue(skb, state, s, verdict); + ... + case NF_STOLEN: + return NF_DROP_GETERR(verdict); /* 被劫持,本模块接管 */ + ... + } + } + return 1; /* 全放行,调 okfn 继续原路 */ +} +``` + +返回 `1` 是「全部放行」,`NF_HOOK` 宏据此再调 `okfn`(比如 `ip_local_deliver_finish`)让包继续原路旅程。 + +## 回调的裁决权:五种 verdict + +回调函数原型 `nf_hookfn(void *priv, struct sk_buff *skb, const struct nf_hook_state *state)` 必须返回一个裁决值,定义在 `include/uapi/linux/netfilter.h`: + +```c +#define NF_DROP 0 /* 丢弃,黑洞,对方啥也收不到 */ +#define NF_ACCEPT 1 /* 放行,交给下一个 */ +#define NF_STOLEN 2 /* 劫持,本模块全权接管(自己发或自己释放) */ +#define NF_QUEUE 3 /* 送用户态队列(nfqueue 机制基础) */ +#define NF_REPEAT 4 /* 再审一次 */ +``` + +两个易踩的坑:`NF_STOLEN` 意味着后续协议栈代码再也看不到这个包,**偷了不释放就内存泄漏**;`NF_DROP` 的高 16 位被复用编码 errno(`NF_DROP_ERR` / `NF_DROP_GETERR`),所以 verdict 不是单纯的枚举,是个「编码后的复合值」,`nf_hook_slow` 里用 `verdict & NF_VERDICT_MASK` 取低 8 位判断动作。 + +## 连接跟踪 conntrack:给包打状态 + +到这里框架就讲完了——但防火墙光能逐包过滤不够。现实里我们常说「放行已建立连接的回包」,这要求内核知道「这条连接之前来过吗、握手完成没」。这就是 **conntrack(连接跟踪)**,有状态防火墙的基础。 + +核心数据结构是 `struct nf_conn`(`include/net/netfilter/nf_conntrack.h`),关键字段: + +- **`tuplehash[IP_CT_DIR_MAX]`**:双向指纹。一个连接有两个方向——去程和回程,各算一个五元组 tuple,都插进全局哈希表。无论包从哪头来,算哈希都能命中同一个 `nf_conn`。 +- **`status`**:状态位图。`IPS_SEEN_REPLY_BIT` 标记「见过回包没」,对应我们熟悉的 `NEW`/`ESTABLISHED`/`RELATED`/`INVALID` 状态。 +- **`master`**:主从指针。FTP 这种协议控制连接在 21 端口、数据连接另开端口,conntrack 靠它把「小弟」数据连接挂到「老大」控制连接上。 +- **`timeout`**:倒计时定时器。一段时间没流量就销毁回收,UDP 单向(unreplied)30 秒、双向(replied)120 秒这种差异都靠它。 + +> UDP 超时值核对自 Linux 6.19 源码 `net/netfilter/nf_conntrack_proto_udp.c` 的 `udp_timeouts[]`:`[UDP_CT_UNREPLIED] = 30*HZ`、`[UDP_CT_REPLIED] = 120*HZ`。**双向是 120 秒**(不是 180 秒——180 这个数来自旧笔记里的推测,别被带偏)。这些是内核编译期默认值,实际可经 `/proc/sys/net/netfilter/nf_conntrack_udp_timeout*` 调。TCP 的各状态超时同理在 `nf_conntrack_proto_tcp.c`,行号待亲测核对。 + +入口函数是 `nf_conntrack_in()`(`net/netfilter/nf_conntrack_core.c`),挂在 `PRE_ROUTING` 和 `LOCAL_OUT`,优先级 `NF_IP_PRI_CONNTRACK`。它干六件事:看 SKB 的 `nfct` 字段有没有已挂的连接(loopback/untracked 跳过)→ 确认 L3/L4 协议号 → L4 协议的 `error()` 合法性检查 → `resolve_normal_ct()` 算哈希查表,查不到就 `init_conntrack()` 建一个(**先进未确认列表**)→ 调协议的 `packet()` 处理函数刷新状态和超时 → 若是首个回包,置 `IPS_SEEN_REPLY_BIT`。 + +关键的「**两阶段确认**」:新建的 `nf_conn` 不敢直接进哈希表——万一包后面被某条规则 DROP 了,这条记录就不该存在。所以先挂「未确认列表」,等包一路过到 `POST_ROUTING`/`LOCAL_IN` 上的确认钩子,才正式入表。6.19 里这个确认回调叫 **`nf_confirm`**(`net/netfilter/nf_conntrack_proto.c`),分别挂在 `NF_INET_POST_ROUTING` 和 `NF_INET_LOCAL_IN`、优先级 `NF_IP_PRI_CONNTRACK_CONFIRM`;它内部调 `__nf_conntrack_confirm()`(`net/netfilter/nf_conntrack_core.c`)真正完成入表。中途被 DROP 就不 confirm,未确认条目最终被销毁。这就保证了哈希表里只存「真正活着」的连接。 + +> 版本提示:旧内核里确认函数叫 `ipv4_confirm`,6.19 起统一改名 `nf_confirm`(IPv4/IPv6 共用同一套实现)。读老书 / 老笔记看到 `ipv4_confirm` 时心里换算一下即可。 + +## iptables 前端:规则怎么变成钩子回调 + +iptables 在内核里**没有任何魔法**,它就是 Netfilter 的一个客户。核心代码在 `net/ipv4/netfilter/ip_tables.c`。 + +每个「表」(filter/nat/mangle)是一个 `struct xt_table`,表定义里用位图 `valid_hooks` 声明自己只在哪些检查站生效。以 filter 表(`net/ipv4/netfilter/iptable_filter.c`)为例: + +```c +#define FILTER_VALID_HOOKS ((1 << NF_INET_LOCAL_IN) | \ + (1 << NF_INET_FORWARD) | \ + (1 << NF_INET_LOCAL_OUT)) +static const struct xt_table packet_filter = { + .name = "filter", + .valid_hooks = FILTER_VALID_HOOKS, + .af = NFPROTO_IPV4, + .priority = NF_IP_PRI_FILTER, +}; +``` + +初始化时(`iptable_filter.c:86`)用 `xt_hook_ops_alloc(&packet_filter, ipt_do_table)` **一次性为三个 hook 点生成 ops 数组**,回调直接就是 `ipt_do_table` 本身(作为 `nf_hookfn` 传进去)。换句话说,filter / security / raw 这类表根本不套中间包装函数,**直接拿规则引擎当回调**——包走到 `LOCAL_IN` 时被调的正是 `ipt_do_table`,它遍历表里规则逐条匹配。只有 mangle 表会套一层 `iptable_mangle_hook`(因为 mangle 要在 hook 里改包再决定走不走表)。 + +> 版本提示:旧资料里常提的 `xt_hook_link()` / `iptable_filter_hook()` 在 6.19 源码里已经不存在——`grep xt_hook_link` 全内核无定义(只剩 `x_tables.c` 注释里的历史痕迹)。filter 表现在统一走 `xt_hook_ops_alloc`。 + +匹配靠 **`xt_match`**(如 `-m conntrack --ctstate`、`-p tcp --dport`),动作靠 **`xt_target`**(如 `-j DROP`、`-j LOG`、`-j SNAT`)。这些扩展各自注册到内核,`ipt_do_table` 把它们串成流水线:match 是「质检传感器」判断成色,target 是「机械臂」做最终处理,返回 verdict。 + +一条 `iptables -A INPUT -p udp --dport=5001 -j LOG` 的旅行:包过 `PRE_ROUTING`(filter 表没挂这里,无感)→ 路由判决「目的地是本机」→ 进 `LOCAL_IN` → `ipt_do_table` 匹配命中 → LOG target 打 syslog → 返回 `NF_ACCEPT` → `okfn`=`ip_local_deliver_finish` 继续上交 L4。 + +## NAT:靠 conntrack 记账的地址改写 + +NAT 干的事就是改写 IP 头的源/目地址(顺带 L4 端口)。改目地址(DNAT)和改源地址(SNAT)是两种基本动作,但它们**不止各挂一个点**——6.19 源码 `net/ipv4/netfilter/iptable_nat.c` 里 NAT 表的 `valid_hooks` 挂的是四个点: + +```c +static const struct xt_table nf_nat_ipv4_table = { + .name = "nat", + .valid_hooks = (1 << NF_INET_PRE_ROUTING) | + (1 << NF_INET_POST_ROUTING) | + (1 << NF_INET_LOCAL_OUT) | + (1 << NF_INET_LOCAL_IN), + ... +}; +``` + +对应的 `nf_nat_ipv4_ops[]` 四条回调(回调同样是直接指向 `ipt_do_table`): + +| hook 点 | 优先级 | 干的活 | +|:---|:---|:---| +| `PRE_ROUTING` | `NF_IP_PRI_NAT_DST` | DNAT:路由前改目地址 | +| `POST_ROUTING` | `NF_IP_PRI_NAT_SRC` | SNAT:出站前改源地址 | +| `LOCAL_OUT` | `NF_IP_PRI_NAT_DST` | DNAT:本机发出包改目地址 | +| `LOCAL_IN` | `NF_IP_PRI_NAT_SRC` | SNAT:送本机包改源地址 | + +一句话归纳:**改目(DNAT)在 `PRE_ROUTING` 和 `LOCAL_OUT`,都在路由判决之前;改源(SNAT)在 `POST_ROUTING` 和 `LOCAL_IN`,都在路由判决之后、即将上交 / 离开之际。** 唯独 `FORWARD` 被 NAT 表排除——转发节点上路由已决,NAT 在这个中间地带没活干(这点跟原草稿一致)。 + +> 之前若记成「SNAT 只在 POST_ROUTING、DNAT 只在 PRE_ROUTING」,那是漏了一半:本机发出的包要 DNAT 得在 `LOCAL_OUT`,送到本机的包要 SNAT 得在 `LOCAL_IN`。把这四个点补齐才完整。 + +NAT 改完地址,**必须同步更新对应的 conntrack 条目**:`tuplehash` 两个方向的 tuple(改了地址 tuple 就得重算),以及挂在 `nf_conn` 扩展区(`nf_conn_nat`)里的 NAT 映射信息。改了地址忘了更新 conntrack,回包就找不到原连接——那就是灾难。这也是 SNAT/DNAT 强依赖 conntrack 的根本原因:NAT 表本质是「在 conntrack 记录上做地址映射」。 + +> 注:`nf_nat_hook`(`include/linux/netfilter.h`)是**单个** `const struct nf_nat_hook __rcu *` 指针,不是链表——它只是一组 NAT 回调函数的挂钩点;真正的 NAT 映射存在 conntrack 扩展里,别把「映射记录」和这个 hook 指针搞混。 + +## 小结 + +Netfilter 是「协议栈钩子框架 + 注册机制」的地基:五个 `nf_inet_hooks` 检查站、`nf_hook_ops` 派工单(`pf`+`hooknum`+`priority`)、`nf_hook_slow` 按优先级遍历回调、五种 verdict 裁决包命运。它本身不做策略,策略由注册进来的模块提供:conntrack 给包打状态(`nf_conn` 双向 tuple + 两阶段确认,6.19 确认回调统一叫 `nf_confirm`),iptables 用 `xt_table`+`xt_match`+`xt_target` 把用户规则编译成钩子回调(filter 表直接拿 `ipt_do_table` 当回调,`xt_hook_ops_alloc` 生成 ops),NAT 挂在 `PRE_ROUTING`/`POST_ROUTING`/`LOCAL_OUT`/`LOCAL_IN` 四个点改地址并同步更新 conntrack 记账。 + +记住三件事:**优先级纪律**(数值小先调,过滤别抢在 conntrack 前面)、**FORWARD 上 NAT 无活**(NAT 表的 `valid_hooks` 明确排除 FORWARD)、**UDP conntrack 双向 120 秒**(不是 180,源自 6.19 `nf_conntrack_proto_udp.c`)。 + +## 延伸阅读 + +- 源码(Linux 6.19): + - `net/netfilter/core.c`(`nf_register_net_hook` / `nf_hook_slow` / 钩子表 grow 与 RCU 替换 / `dummy_ops`+`accept_all` 占位); + - `include/linux/netfilter.h`(`struct nf_hook_ops` / `NF_HOOK` 宏 / `nf_hook_state` / `nf_nat_hook`); + - `include/uapi/linux/netfilter.h`(`enum nf_inet_hooks` / verdict 常量); + - `net/netfilter/nf_conntrack_core.c`(`nf_conntrack_in` / `__nf_conntrack_confirm`); + - `net/netfilter/nf_conntrack_proto.c`(确认回调 `nf_confirm`,优先级 `NF_IP_PRI_CONNTRACK_CONFIRM`); + - `net/netfilter/nf_conntrack_proto_udp.c`(`udp_timeouts`:unreplied `30*HZ` / replied `120*HZ`); + - `net/ipv4/netfilter/ip_tables.c`(`ipt_do_table`); + - `net/ipv4/netfilter/iptable_filter.c`(`xt_hook_ops_alloc(&packet_filter, ipt_do_table)`); + - `net/ipv4/netfilter/iptable_nat.c`(NAT 表 `valid_hooks` 四点 + `nf_nat_ipv4_ops`)。 +- docs.kernel.org:[Netfilter sysctl](https://docs.kernel.org/networking/netfilter-sysctl.html)、[nf_conntrack sysctl](https://docs.kernel.org/networking/nf_conntrack-sysctl.html)、[Networking 子系统文档索引](https://docs.kernel.org/networking/index.html)。 +- 进一步(持续铺开):nftables(iptables 的后继,`nf_tables` 引擎)、conntrack 的 helper/expectation(FTP/IRC 之类 RELATED 连接怎么来)、NAT 回调 `nf_nat_fn` 内部怎么重算校验和。 \ No newline at end of file diff --git a/document/tutorials/kernel/net/09-net-netlink.md b/document/tutorials/kernel/net/09-net-netlink.md new file mode 100644 index 00000000..4808ea53 --- /dev/null +++ b/document/tutorials/kernel/net/09-net-netlink.md @@ -0,0 +1,148 @@ +--- +title: Netlink:用户态与内核的双向 socket +slug: net-netlink +difficulty: intermediate +tags: [Netlink, 网络栈, 通用 netlink, 内核通信] +architectures: [arm64, x86_64, riscv] +kernel_version: "6.19" +maturity: drafting +prerequisites: + - /tutorials/kernel/net/01-net-overview +related: + - /tutorials/kernel/net/01-net-overview +sources: + - notes: document/notes/linux_kernel_networking/ch02.md + - notes: document/notes/linux_kernel_networking/ch02_2.md + - notes: document/notes/linux_kernel_networking/ch02_3.md + - notes: document/notes/linux_kernel_networking/ch02_4.md + - notes: document/notes/linux_kernel_networking/ch02_5.md + - notes: document/notes/linux_kernel_networking/ch02_6.md +--- + +# Netlink:用户态与内核的双向 socket + +> 🔨 **整理中** · 本篇机制对照 Linux 6.19 源码讲解(函数/数据结构/协议号已逐条 grep 核对,行号归为「待 QEMU 亲测核对」口径);命令输出待亲测。 + +## 内核为什么不用 ioctl 了 + +写网络相关的东西时,用户态迟早要跟内核「商量」:加一条路由、把网卡 up 起来、问内核「现在 socket 都长什么样」。20 年前干这活的利器是 ioctl——你 `open("/dev/whatever")`,然后一个 `ioctl(fd, CMD, arg)`,内核回一句,你接着问下一句。 + +ioctl 这套传声筒的毛病在于:**它是单向、一次性、硬编码的**。你想加一条路由,得为「加路由」专门编一个 ioctl 号;你想知道网卡状态变了,没门,ioctl 不会主动喊你,你只能不停轮询。更要命的是,ioctl 号是个全局稀缺资源,每个新功能都要往里挤一个魔数,几版内核下来就乱成一锅粥。 + +Netlink 就是为治这些毛病生的。它本身是个**正经的 socket**(`AF_NETLINK`),所以天然支持双向、支持多播、支持异步——内核干完活可以**主动广播**「我刚加了一条路由,关心的人都听好了」,用户态的守护进程(NetworkManager、路由守护进程 bird)只管竖着耳朵听,不用再傻乎乎地去轮询 `/proc` 或 `/sys`。这一篇我们就钻进源码,看这套双向通道在内核里到底是怎么搭起来的。 + +## Netlink socket:AF_NETLINK 与 netlink_create + +用户态这边发起一个 Netlink 通道,就是一句普通的 `socket()`: + +```c +int fd = socket(AF_NETLINK, SOCK_RAW, NETLINK_ROUTE); +``` + +第三个参数是**协议号**——它决定这条 socket 跟内核里哪个子系统对话。`NETLINK_ROUTE` 管网络配置,`NETLINK_GENERIC` 是后面要讲的「万能插座」,还有 `NETLINK_AUDIT`(审计)、`NETLINK_KOBJECT_UEVENT`(热插拔事件)等二十来个。这套协议号在用户态头文件里写死(`include/uapi/linux/netlink.h`),`NETLINK_GENERIC` 是 16,而总量被一个 `MAX_LINKS` 卡死——只有 **32 个**(`include/uapi/linux/netlink.h:35`)。这 32 个坑位就是后面「通用 netlink」要解决的根源。 + +用户态 `socket()` 进内核后,最终落在 `net/netlink/af_netlink.c:644` 的 `netlink_create()`。它干的头一件事是查这张 socket 要的协议合不合法: + +```c +if (protocol < 0 || protocol >= MAX_LINKS) + return -EPROTONOSUPPORT; /* af_netlink.c:659 */ +``` + +合法之后,它会去 `nl_table` 这个全局表里找这个协议有没有被内核注册过(`nl_table[protocol].registered`,af_netlink.c:665)。没注册的话还会触发一次 `request_module("net-pf-%d-proto-%d", ...)`,按需把对应模块拉起来——所以 Netlink 协议族是**可以做成模块按需加载**的。注册过了,就调 `__netlink_create()`(af_netlink.c:618)真正 `sk_alloc` 出一个 `struct sock`,把 `sock->ops` 挂成 `netlink_ops`,协议号塞进 `sk->sk_protocol`。 + +到这一步,用户态手里有了一个 fd,内核里多了一个 `struct sock`——双向管道的两头都接好了。 + +## Netlink 消息结构:nlmsghdr + TLV 载荷 + +socket 是管道,管道里流的是「消息」。Netlink 的消息有严格的封装格式,最外层是固定的 16 字节头部 `struct nlmsghdr`(`include/uapi/linux/netlink.h:52`): + +```c +struct nlmsghdr { + __u32 nlmsg_len; /* 整条消息总长,含本头部 */ + __u16 nlmsg_type; /* 消息类型 */ + __u16 nlmsg_flags; /* 标志位 */ + __u32 nlmsg_seq; /* 序列号,用于匹配请求/应答 */ + __u32 nlmsg_pid; /* 发送方的 port ID */ +}; +``` + +挨个看这五个字段为什么这么设计。`nlmsg_len` 是**整条消息**的长度(含头部),解析器靠它知道读多少字节该停、下一条从哪开始——所以**一个 buffer 里可以塞多条消息**,这是 Netlink 区别于「一问一答」的小心思。`nlmsg_type` 决定这条消息干啥:小于 `NLMSG_MIN_TYPE`(0x10)的是通用控制类型,比如 `NLMSG_ERROR`(出错/ACK)、`NLMSG_DONE`(多段转储结束标记);≥ 0x10 的则是各协议族自己的「方言」,`NETLINK_ROUTE` 在这儿定义 `RTM_NEWLINK`、`RTM_NEWROUTE` 一大堆。 + +`nlmsg_flags` 是行为指令:`NLM_F_REQUEST`(这是请求)、`NLM_F_ACK`(请回我个确认)、`NLM_F_DUMP`(把整张表倒给我)、`NLM_F_MULTI`(这是多段消息中的一段)、`NLM_F_CREATE`/`NLM_F_EXCL`/`NLM_F_REPLACE`(增改时的语义)。`nlmsg_seq` 给用户态做请求-应答配对用,内核层面不强制连续。`nlmsg_pid` 是发送方「端口」:**内核发的消息这个字段恒为 0**,用户态发的通常是进程 PID,内核回包时直接抄这个值当目标地址——所以内核天生知道该把回复塞给谁。 + +头部之后是**载荷**,但 Netlink 不让你把数据硬塞进去,而是规定了一套自描述的 **TLV(Type-Length-Value)** 编码:每个属性前面有个小头 `struct nlattr`(`nla_len` + `nla_type`),值可以是 `NLA_U32`、`NLA_STRING`,甚至 `NLA_NESTED`——属性里再嵌一套 TLV,能搭出树状结构。**每个属性必须按 `NLA_ALIGNTO = 4` 字节对齐**(netlink.h:248),手动拼包忘了补 padding,内核解析时会错位、丢包或读出乱码。内核收到消息后,会用一张 `struct nla_policy` 数组逐个验证属性的类型和长度(`nla_policy.type` / `.len`),验证不过直接拒收——这就是内核那道「海关」。 + +## NETLINK_ROUTE:iproute2 的底层通道 + +协议号里最重量级的是 `NETLINK_ROUTE`(rtnetlink)。别被名字骗了,它管的不止路由表,还攥着网卡(LINK)、IP 地址(ADDR)、邻居表/ARP(NEIGH)、策略路由规则(RULE)、QoS 排队(QDISC/TCLASS)一大家子。消息类型遵循 CRUD 套路:`RTM_NEWXXX`(建)、`RTM_DELXXX`(删)、`RTM_GETXXX`(查);LINK 家族因为常常要「只改一个 MTU」而不是删了重建,额外多了一个 `RTM_SETLINK`(改)。 + +你每天敲的 `ip` 命令,底层就是 iproute2 打开一个 `NETLINK_ROUTE` socket、拼一条 `RTM_NEWROUTE` 消息扔进内核。内核侧这个 socket 是在网络命名空间初始化时建好的,`net/core/rtnetlink.c:7032` 的 `rtnetlink_net_init()`: + +```c +struct netlink_kernel_cfg cfg = { + .groups = RTNLGRP_MAX, + .input = rtnetlink_rcv, + .flags = NL_CFG_F_NONROOT_RECV, + .bind = rtnetlink_bind, +}; +sk = netlink_kernel_create(net, NETLINK_ROUTE, &cfg); +net->rtnl = sk; +``` + +注意几点:`input` 回调是 `rtnetlink_rcv`——所有从用户态上来的 `NETLINK_ROUTE` 消息都进它;`net->rtnl` 把这个 sock 指针存进**网络命名空间对象**,所以容器里配网卡只动容器自己的 `struct net`,宿主机不受影响,Netlink 从设计上就是命名空间感知的。`NL_CFG_F_NONROOT_RECV` 让普通用户也能 bind 多播组收事件(uevent、ss 也靠这个)。 + +`rtnetlink_rcv`(rtnetlink.c:6983)把活外包给通用的 `netlink_rcv_skb()`(af_netlink.c:2524)——它在一个 `while` 循环里按 `nlmsg_len` 切 buffer,对每条消息调你给的回调 `cb`,出错就 `netlink_ack` 回报错包。rtnetlink 的回调 `rtnetlink_rcv_msg` 会查 `rtnl_msg_handlers` 这张「协议号 × 消息类型」二维表,把活派给具体函数——`RTM_NEWROUTE` 派给 `net/ipv4/fib_frontend.c` 的 `inet_rtm_newroute()`,真正往 FIB 路由表里插记录。表是子系统们用 `rtnl_register()` 早早填好的函数指针格子。 + +## 错误与 ACK:那张退件单 + +Netlink 的报错机制很体贴,封装在 `struct nlmsgerr` 里(`error` + 触发错误的原始 `nlmsghdr`)。内核回包时类型设成 `NLMSG_ERROR`,实现在 `af_netlink.c:2463` 的 `netlink_ack()`: + +```c +if (err && !test_bit(NETLINK_F_CAP_ACK, &nlk->flags)) + payload += nlmsg_len(nlh); /* 出错时把原始请求头也贴回来 */ +errmsg->error = err; +errmsg->msg = *nlh; +``` + +**反直觉的点**:成功(ACK)和失败,回包**类型都是 `NLMSG_ERROR`**,区分只看 `error` 字段——为 0 就是「签收单」(成功),非 0(如 `-EINVAL`)才是「退件单」,而且只有退件单才把你的原始请求头贴回来,方便你按 `nlmsg_seq` 对号入座查是哪条炸了。 + +## 通用 netlink:用名字换 ID 的多路复用 + +标准协议号只有 32 个坑,早就被 `NETLINK_ROUTE` 这些大个子占光了。如果你写个驱动想给自己加个 Netlink 控制接口,没坑位给你。**通用 netlink(genetlink)**就是来解决这个「插座荒」的:它只占 `NETLINK_GENERIC` 这一个标准坑位,但在上面挂了无数个自定义「家族」,本质是个**多路复用器**。 + +核心思路是把「硬编码的协议号」换成「运行时动态分配的 ID」。你给家族起个**名字**(如 `"nl80211"`、`"nlctrl"`),内核注册时用 `idr_alloc_cyclic()`(net/netlink/genetlink.c:816)在 `GENL_START_ALLOC`(19)到 `GENL_MAX_ID`(1023)之间循环分配一个唯一数字 ID(这套 `idr` 机制见 `include/uapi/linux/genetlink.h`:`GENL_MIN_ID`/`GENL_MAX_ID`/`GENL_START_ALLOC`)。开头三个号是预留的——`GENL_ID_CTRL`(16,总服务台)、`GENL_ID_VFS_DQUOT`(17)、`GENL_ID_PMCRAID`(18),所以普通家族从 19 起。**6.19 里没有「填 0 让内核分配」的老套路了**——族 ID 一律由 `idr_alloc_cyclic` 动态决定,不存在静态 ID 的家族(那三个预留除外)。(注:另有个 `find_first_zero_bit()` 只用在**多播组**号的分配上,genetlink.c:408,跟族 ID 是两码事,旧笔记容易把两者混为一谈。) + +用户态不知道数字是多少没关系——先问那个固定 ID 的「总服务台」Controller(`nlctrl`,`GENL_ID_CTRL = 0x10`):「`nl80211` 的 ID 是几?」Controller 查 `genl_fam_idr` 表回一个动态分配的数字(历史上常见是 21,但具体值取决于注册顺序,不是写死的常量),用户态再拿这个 ID 发真正的命令。 + +内核侧 genetlink 的入口 socket 在 `net/netlink/genetlink.c:1878` 的 `genl_pernet_init()` 里建,`input` 回调是 `genl_rcv`,锁用专属的 `genl_mutex`(genetlink.c:27)。Controller 家族本身在 genetlink.c:1799 定义,`.id = GENL_ID_CTRL`、`.name = "nlctrl"`——**它是唯一硬编码 ID 的家族**,没有它整个查找流程就转不起来(6.19 里它改用 `split_ops`,机制不变)。 + +家族之上挂的是操作 `struct genl_ops`(`include/net/genetlink.h:213`),每个 op 有 `.cmd`(命令号)、`.doit`(单体操作,如「设 SSID」)、`.dumpit`(列表转储,如「列出所有扫描到的 AP」)、`.policy`(属性校验)。**doit 和 dumpit 至少填一个**,否则注册时 `-EINVAL` 拒绝。消息格式是俄罗斯套娃:外层标准 `nlmsghdr`,往里一层是 genetlink 特有的 `struct genlmsghdr`(`cmd` + `version` + `reserved`,uapi/genetlink.h:13),再往里才是 TLV 载荷。谁走这套?`iw`(无线工具,走 `NETLINK_GENERIC` 的 `nl80211` 家族)是典型。**注意 `ss` 不走 genetlink**——它走的是另一个专用协议族 `NETLINK_SOCK_DIAG`(协议号 4,`net/core/sock_diag.c` 里直接 `netlink_kernel_create(net, NETLINK_SOCK_DIAG, &cfg)` 建的),不是 genetlink 家族、不挂 `genl_ops`,但「注册 handler 表 + 按协议族分发」的设计思路(`sock_diag_handler`)跟 genetlink 一脉相承。 + +## 内核侧发消息:netlink_unicast 与广播 + +内核主动通知用户态,靠的是 `netlink_unicast()`(af_netlink.c:1327)和 `netlink_broadcast()`(af_netlink.c:1554)。`netlink_unicast` 拿目标 portid 找到对端 sock,如果对端是内核 sock 就走 `netlink_unicast_kernel()` 调它的 `netlink_rcv` 回调,否则塞进用户态 socket 的接收队列。广播则遍历多播组成员逐个投递。 + +典型例子:网卡被 `__dev_open()` 拉起来时(net/core/dev.c),内核调 `rtmsg_ifinfo(RTM_NEWLINK, ...)`,它 `nlmsg_new` 分个 skb、填 `nlmsghdr` + `ifinfomsg`、`rtnl_fill_ifinfo`(rtnetlink.c:2027)灌数据,最后 `rtnl_notify()`(rtnetlink.c:953)→ `nlmsg_notify()` 广播给 `RTNLGRP_LINK` 组。加了路由则广播 `RTNLGRP_IPV4_ROUTE`。这就是 NetworkManager 们能秒级响应网卡/路由变化的根源——**不用轮询,内核直接推**。 + +## 小结 + +Netlink 是 Linux 用户态↔内核态网络通信的基石:它用 `AF_NETLINK` socket 取代了单向的 ioctl,用 `nlmsghdr` + TLV 规范了消息格式,靠 `netlink_kernel_create` 在命名空间里建内核侧 socket,靠 `nl_table` 分发协议、`netlink_rcv_skb` 切包分发、`netlink_ack` 报错/ACK。标准协议号只有 32 个,于是有了通用 netlink 这层多路复用——用一个名字换一个 `idr_alloc_cyclic` 动态分配的 ID,让任意驱动都能挂上自己的命令族。记住三件事:**消息可一条 buffer 塞多条**(靠 `nlmsg_len` 切)、**内核主动广播**(这才是它比 ioctl 强的核心)、**genetlink 解决坑位荒**。 + +## 动手验证(待 QEMU 亲测) + +- **抓 rtnetlink 广播**:两个终端,一个 `ip monitor route`(订阅 `RTNLGRP_IPV4_ROUTE`),另一个 `ip route add 192.168.1.10 via 192.168.2.200`,看监听端秒出通知;再 `ip route del` 看带 `Deleted` 前缀的广播。 +- **抓 genetlink**:`ip link set eth0 up` 时用 `strace -e socket,sendto,recvfrom ip ...` 观察 `AF_NETLINK`/`NETLINK_ROUTE` 的 syscall 序列;`iw dev wlan0 scan`(若有无线)观察 genetlink 的 `CTRL_CMD_GETFAMILY` 名字解析过程。 +- **自发自收**:用 libmnl 或 libnl 写个小程序,`socket(AF_NETLINK, SOCK_RAW, NETLINK_ROUTE)` → `RTM_GETLINK`(带 `NLM_F_DUMP`)→ 收多段消息直到 `NLMSG_DONE`,打印每个网卡的 ifinfomsg。输出与命令行号待亲测记录。 + +## 延伸阅读 + +- 源码(Linux 6.19): + - `net/netlink/af_netlink.c` — Netlink socket 核心:`netlink_create`、`netlink_kernel_create`(`__netlink_kernel_create`)、`netlink_unicast`、`netlink_broadcast`、`netlink_ack`、`netlink_rcv_skb`。 + - `net/netlink/genetlink.c` — 通用 netlink:`genl_pernet_init`、`genl_rcv`、`genl_ctrl`、`idr_alloc_cyclic`(族 ID 分配)。 + - `net/core/rtnetlink.c` — `rtnetlink_net_init`、`rtnetlink_rcv`、`rtnetlink_rcv_msg`、`rtnl_register`。 + - `include/uapi/linux/netlink.h`、`include/uapi/linux/genetlink.h`、`include/linux/netlink.h`、`include/net/genetlink.h` — 结构体、协议号、`GENL_START_ALLOC`/`GENL_MAX_ID` 定义。 +- kernel.org 文档: + - [Networking — Generic Netlink](https://docs.kernel.org/networking/generic_netlink.html)(对应源码树 `Documentation/networking/generic_netlink.rst`,正文本身很薄,基本只指向下面这篇 howto) + - [Generic Netlink HOWTO (Linux Foundation Wiki)](https://wiki.linuxfoundation.org/networking/generic_netlink_howto)(更具体的编程指引) + - [Networking subsystem documentation index](https://docs.kernel.org/networking/index.html)(搜 "netlink" 找各子系统文档) +- 进一步(持续铺开):`netlink_diag`/`ss` 的实现(`NETLINK_SOCK_DIAG`,独立协议族)、libnl/libmnl 编程、`nl80211` 无线家族走读。 \ No newline at end of file diff --git a/document/tutorials/kernel/net/10-net-icmp.md b/document/tutorials/kernel/net/10-net-icmp.md new file mode 100644 index 00000000..fff594b1 --- /dev/null +++ b/document/tutorials/kernel/net/10-net-icmp.md @@ -0,0 +1,232 @@ +--- +title: ICMP:网络的诊断与控制协议 +slug: net-icmp +difficulty: intermediate +tags: [ICMP, 网络栈, 邻居发现, PMTU] +architectures: [arm64, x86_64, riscv] +kernel_version: "6.19" +maturity: drafting +prerequisites: + - /tutorials/kernel/net/04-net-ipv4 +related: + - /tutorials/kernel/net/04-net-ipv4 +sources: + - notes: document/notes/linux_kernel_networking/ch03.md + - notes: document/notes/linux_kernel_networking/ch03_1.md + - notes: document/notes/linux_kernel_networking/ch03_2.md + - notes: document/notes/linux_kernel_networking/ch03_3.md +--- + +# ICMP:网络的诊断与控制协议 + +> 🔨 **整理中** · 这篇是从读书笔记(linux_kernel_networking/ch03)整理出来的骨架,本篇机制对照 Linux 6.19 源码讲解(函数/数据结构已核对);具体行号与命令输出待 QEMU 亲测核对。等我们在 QEMU 里 `tcpdump` 抓过 echo、跑过 traceroute,就升级成 ✅ 已锤炼。 + +## ICMP 到底是干嘛的:IP 层的维保工 + +IP 协议是"尽力而为"——它只管把包扔出去,不管有没有人收、走的路对不对。如果没有任何反馈机制,整个互联网就是一个丢包了也没人知道的黑盒。ICMP(Internet Control Message Protocol)就是给这个黑盒装的"神经系统",专门传错误报告和诊断信息。 + +我们天天用的 `ping` 和 `traceroute` 底层全是它。但记住一点:这篇讲的不是这两个工具怎么用,而是**内核收到一个 ICMP 包之后到底干了什么**——`icmp_rcv` 怎么收、`__icmp_send` 怎么发、查哪张表分发到哪个 handler。每个机制都对着源码讲。 + +## ICMPv4:收发两条主干道 + +ICMP 是 IP 层的协议,协议号是 1(`IPPROTO_ICMP`)。它和 TCP/UDP 一样要往内核协议分发表里注册一个处理器: + +```c +static const struct net_protocol icmp_protocol = { + .handler = icmp_rcv, + .err_handler = icmp_err, + .no_policy = 1, +}; +``` + +(结构体定义在 `net/ipv4/af_inet.c`,Linux 6.19)。当 IP 层剥完 IP 头发现协议字段是 1,就跳到 `icmp_rcv`。`no_policy = 1` 是个优化——在 `ip_local_deliver_finish()` 里看到这标志会跳过 IPsec 策略检查,因为对 ICMP 这种控制消息,安全策略通常不是首要矛盾。 + +发方向上有个反直觉的设计:**内核给每个 CPU 单独建了一个 Raw Socket 用来发 ICMP**。看 `net/ipv4/icmp.c` 的 `icmp_init()`: + +```c +for_each_possible_cpu(i) { + err = inet_ctl_sock_create(&sk, PF_INET, + SOCK_RAW, IPPROTO_ICMP, &init_net); + ... + per_cpu(ipv4_icmp_sk, i) = sk; + ... + inet_sk(sk)->pmtudisc = IP_PMTUDISC_DONT; +} +``` + +6.19 里这块 socket 数组是 `static DEFINE_PER_CPU(struct sock *, ipv4_icmp_sk)`,不再是老的 `net->ipv4.icmp_sk[i]`(那是基于老旧书籍笔记的说法,源码已经变了,咱们以源码为准)。为什么要 per-CPU?因为多核下所有 CPU 抢一个 socket 发包,锁竞争会爆炸。每个 CPU 用自己的 socket,谁发的谁排队,互不干扰。`pmtudisc = IP_PMTUDISC_DONT` 关掉 PMTU 发现——错误报告要尽量送达,不能因为 MTU 问题被分片或丢弃。 + +> 注意名字坑:6.19 里**创建 socket 的是 `icmp_init()`**,而 `icmp_sk_init()` 只负责初始化一堆 sysctl 默认值(`icmp_ratelimit`、`icmp_ratemask` 等)。老笔记把这俩混着讲,我们这次照源码拆开了。 + +## 报文头部:`struct icmphdr` + +每个 ICMP 包头部是同一副骨架(`include/uapi/linux/icmp.h`):8 位 type、8 位 code、16 位 checksum,外加一个 32 位"可变部分"——内容随类型变: + +```c +struct icmphdr { + __u8 type; + __u8 code; + __sum16 checksum; + union { + struct { __be16 id; __be16 sequence; } echo; + __be32 gateway; + struct { __be16 __unused; __be16 mtu; } frag; + } un; +}; +``` + +错误消息后面通常还跟一截"罪魁祸首"原始包的 IP 头和载荷。RFC 1812 要求整个 ICMP 错误报文总长不超过 576 字节(IPv4 最小 MTU),保证任何设备都处理得了。代码里这截体现在 `__icmp_send` 里的 `room` 计算(见后文)。 + +`include/linux/icmp.h` 还有个好用的辅助 `icmp_is_err(int type)`,它用一个 `switch` 把五种错误类型(`ICMP_DEST_UNREACH`/`ICMP_SOURCE_QUENCH`/`ICMP_REDIRECT`/`ICMP_TIME_EXCEEDED`/`ICMP_PARAMETERPROB`)判出来——ICMPv4 没有"最高位区分错误/信息"这种简单规则,得逐个枚举。 + +## 一个 ping 的旅程:echo request → echo reply + +ping 程序做的事很简单:发一个 `ICMP_ECHO`(type 8)请求,等对端回 `ICMP_ECHOREPLY`(type 0)。对端内核收到后走 `icmp_rcv`,最终命中 `icmp_echo` handler,它把 type 从 ECHO 翻成 ECHOREPLY 再 `icmp_reply` 发回去: + +```c +static enum skb_drop_reason icmp_echo(struct sk_buff *skb) +{ + ... + if (READ_ONCE(net->ipv4.sysctl_icmp_echo_ignore_all)) + return SKB_NOT_DROPPED_YET; /* "隐身模式":直接不理 */ + ... + if (icmp_param.data.icmph.type == ICMP_ECHO) + icmp_param.data.icmph.type = ICMP_ECHOREPLY; /* 翻转 type */ + ... + icmp_reply(&icmp_param, skb); + return SKB_NOT_DROPPED_YET; +} +``` + +`sysctl_icmp_echo_ignore_all` 写 1 就是"隐身"——收到 ping 也不回,但这只代表不响应 echo,不代表主机真的不可达。`icmp_rcv` 在分发前还有几道安检:先 `__ICMP_INC_STATS(net, ICMP_MIB_INMSGS)` 计数,再 `skb_checksum_simple_validate(skb)` 校验和,错了就跳到 `csum_error` 分支静默丢弃(`icmp_rcv` 永远不返回负值——返回负值会让 `ip_local_deliver_finish` 尝试重处理,对一个坏掉的 ICMP 包纯属浪费)。还有一个广播/组播抑制:`sysctl_icmp_echo_ignore_broadcasts`(默认 1),防止有人 ping 广播地址触发全网响应的风暴。 + +有意思的是 echo reply 的处理:6.19 里 `icmp_rcv` 没走 `icmp_pointers` 分发表,而是单独拎出来直接调 `ping_rcv(skb)`(`net/ipv4/ping.c`,双栈文件,IPv6 的 echo reply 也走这里)。这是因为 ICMP Sockets 机制让非 root 用户也能发 ping,回来的 reply 没法匹配到传统 Raw Socket,得专门处理。 + +## `icmp_pointers`:一张分发表 + +收到的包该给谁处理?查表。`net/ipv4/icmp.c` 里定义了: + +```c +struct icmp_control { + enum skb_drop_reason (*handler)(struct sk_buff *skb); + short error; /* 该类型是否归类为错误消息 */ +}; + +static const struct icmp_control icmp_pointers[NR_ICMP_TYPES + 1] = { + [ICMP_ECHOREPLY] = { .handler = ping_rcv }, + [ICMP_DEST_UNREACH] = { .handler = icmp_unreach, .error = 1 }, + [ICMP_REDIRECT] = { .handler = icmp_redirect, .error = 1 }, + [ICMP_ECHO] = { .handler = icmp_echo }, + [ICMP_TIME_EXCEEDED]= { .handler = icmp_unreach, .error = 1 }, + [ICMP_PARAMETERPROB]= { .handler = icmp_unreach, .error = 1 }, + [ICMP_TIMESTAMPREPLY] = { .handler = icmp_discard }, /* 历史遗留,NTP 顶替了 */ + ... +}; +``` + +以类型为索引,`error` 字段标记是不是错误消息(防"错误报告套错误报告"死循环,后面讲)。`icmp_discard` 就是装样子的成功——现代网络用 NTP 取代了 ICMP 时间戳,用 DHCP 取代了 ICMP 问子网掩码,这些类型内核收到直接扔。 + +`icmp_rcv` 的核心一行就是 `reason = icmp_pointers[icmph->type].handler(skb);`——查表分派。超过 `NR_ICMP_TYPES`(18)的未知类型按 RFC 1122 静默丢弃。 + +## `__icmp_send`:内核什么时候主动发错误 + +发 ICMP 错误靠 `__icmp_send`(6.19 里 `icmp_send` 是个宏,包一层 `__icmp_send`,Netfilter 场景还有个 `icmp_ndo_send` 会做 NAT 反向翻译)。原型: + +```c +void __icmp_send(struct sk_buff *skb_in, int type, int code, __be32 info, + const struct inet_skb_parm *parm); +``` + +`skb_in` 是"罪魁祸首"原始包,内核从里面剥 IP 头做诊断、再把它嵌进新错误包的数据部分;`info` 常用来传 MTU 值。几个经典触发场景(都在协议层调 `__icmp_send`): + +- **协议不可达(Code 2)**:`ip_local_deliver_finish()` 查 `inet_protos[protocol]` 查不到(比如 IP 头写了个内核没注册的协议号),回 `ICMP_DEST_UNREACH`/`ICMP_PROT_UNREACH`。 +- **端口不可达(Code 3)**:UDP 包发到一个没人监听的端口,`__udp4_lib_rcv()` 查 socket 查不到,回 `ICMP_PORT_UNREACH`——这是日常最常见的错误包。 +- **需要分片(Code 4)**:`ip_forward()` 里发现 `skb->len > dst_mtu()` 且 DF 置位,不能分片只能丢,回 `ICMP_FRAG_NEEDED`,并把正确的 MTU 塞进 `info`(`htonl(dst_mtu(&rt->dst))`)。这就是 **PMTU 发现的核心**。 +- **TTL 超时(Type 11)**:转发时 TTL 减到 0,`ip_forward()` 回 `ICMP_TIME_EXCEEDED`/`ICMP_EXC_TTL`——**traceroute 就靠这个**:发 TTL=1 的包,第一跳回超时;发 TTL=2,第二跳回……一路拼出路由图。 + +`__icmp_send` 里几道关键防雪崩检查(`net/ipv4/icmp.c`):收到的是不是广播/组播(`pkt_type != PACKET_HOST` 直接走人)、是不是非首片(`iph->frag_off & htons(IP_OFFSET)`)、以及"错误报告套错误报告"——`if (icmp_pointers[type].error)` 判断要发的是错误,且原始包本身就是 ICMP,那就再扒一层看内层是不是又是错误包,是就放弃(`*itp > NR_ICMP_TYPES || icmp_pointers[*itp].error` → `goto out`),避免错误风暴。最后还有 `room` 限制:`if (room > 576) room = 576;`,给原始包留的载荷最多 576 减去头,符合 RFC。 + +## 速率限制:别让错误报告变成错误炸弹 + +ICMP 不限速会雪崩——某根线断了,路由器对每个丢包都回不可达,回包本身又压垮网络。`__icmp_send` 走两级限流:全局令牌桶 `icmp_global_allow()`(`sysctl_icmp_msgs_per_sec`,默认 1000/s、burst 50)和按目标的 `icmpv4_xrlim_allow()`(`sysctl_icmp_ratelimit`,默认 `1*HZ`)。但三种情况**跳过限流**(`icmpv4_mask_allow` 里写死): + +1. **PMTU 发现消息**(`ICMP_DEST_UNREACH` + `ICMP_FRAG_NEEDED`)——被限流丢了 TCP 连接就彻底断了,必须及时发。 +2. **loopback 设备**——本机自己转,无所谓拥塞。 +3. **`icmp_ratemask` 没置位的类型**(默认 `0x1818`,主要限错误消息)。 + +调这些旋钮不用重编内核,`/proc/sys/net/ipv4/icmp_*` 实时改。`icmp_ratemask` 是位掩码,每一位对应一个 type;`icmp_echo_ignore_broadcasts` 务必保持默认 1,否则一条 ping 广播的病毒能把整个二层网搞瘫。 + +## ICMPv6:IPv6 世界的瑞士军刀 + +到 IPv6 这边,ICMP 角色彻底变了。IPv4 里 ARP 管地址解析、IGMP 管组播、ICMP 管报错,分工明确;IPv6 把 ARP 和 IGMP 全砍了,统一收编进 ICMPv6。没有 ICMPv6,IPv6 连邻居都找不到,一步都迈不出去。 + +实现主要在 `net/ipv6/icmp.c` 和 `net/ipv6/ip6_icmp.c`,同样编进内核不能做成模块。注册方式跟 v4 如出一辙,协议号 `IPPROTO_ICMPV6 = 58`: + +```c +static const struct inet6_protocol icmpv6_protocol = { + .handler = icmpv6_rcv, + .err_handler = icmpv6_err, + .flags = INET6_PROTO_NOPOLICY | INET6_PROTO_FINAL, +}; +``` + +`INET6_PROTO_NOPOLICY` 同样跳过 IPsec——处理网络层错误报告时,不能因为 IPsec 验证失败把错误报告本身丢了,否则永远不知道网络出什么事。 + +### 类型编号的聪明分界线 + +ICMPv6 报头 `struct icmp6hdr`(`include/uapi/linux/icmpv6.h`)字段一样:type、code、checksum。但 type 的解释有条 RFC 4443 定的聪明线——**最高位为 0(0~127)是错误消息,最高位为 1(128~255)是信息消息**。内核用掩码 `ICMPV6_INFOMSG_MASK`(0x80)一次位与就判出来,比 v4 那个枚举优雅多了。常见类型: + +| Type | 宏 | 类别 | 含义 | +|:---:|:---|:---:|:---| +| 1 | `ICMPV6_DEST_UNREACH` | Error | 目的不可达 | +| 2 | `ICMPV6_PKT_TOOBIG` | Error | 包太大(**独立 type**,不是 code) | +| 3 | `ICMPV6_TIME_EXCEED` | Error | 超时(Hop Limit 用完) | +| 4 | `ICMPV6_PARAMPROB` | Error | 参数问题 | +| 128/129 | `ICMPV6_ECHO_REQUEST`/`REPLY` | Info | ping | +| 133-137 | `NDISC_ROUTER_SOLICIT`... | Info | **邻居发现 ND**(替代 ARP) | + +后半截全是 ND 协议消息(路由器请求/通告、邻居请求/通告),印证那句"在 IPv6 里 ICMP 就是邻居发现的载体"。 + +### 接收分发:`switch` 而非查表 + +和 v4 的 `icmp_pointers` 查表不同,ICMPv6 用一个巨大的 `switch(type)` 分发(`net/ipv6/icmp.c` 的 `icmpv6_rcv`)。关键分支:echo request 走 `icmpv6_echo_reply`、echo reply 走 `ping_rcv`、ND 消息(133-137)全交给 `ndisc_rcv`(`net/ipv6/ndisc.c`,IPv6 地址解析核心)、MLD 组播消息走 `igmp6_event_query/report`。`default` 分支有个精巧逻辑:未知**信息类**消息(最高位 1)静默 `break`(多点噪音无所谓),未知**错误类**消息必须 `icmpv6_notify()` 往上报给上层(Raw Socket),因为 RFC 4443 要求未知错误也得让上层有机会处理。 + +### PMTU:v4 和 v6 的关键分野 + +这是 v4/v6 最大区别之一。IPv4 路由器包太大可以分片(除非 DF=1);**IPv6 路由器禁止分片,分片是发送端自己的事**。所以 IPv6 路由器发现包比出口 MTU 大,唯一选择就是丢包并回 `ICMPV6_PKT_TOOBIG`,把正确 MTU 塞进消息(`ip6_forward()` 里 `icmpv6_send(skb, ICMPV6_PKT_TOOBIG, 0, mtu)`)。对比 v4 发的是 `ICMP_DEST_UNREACH`+`ICMP_FRAG_NEEDED`,v6 直接给独立 type——因为这事太常发生了。 + +发送限流逻辑同样跳过三类:信息类消息、`ICMPV6_PKT_TOOBIG`、loopback。错误报文长度硬性不超过 1280 字节(IPv6 最小 MTU `IPV6_MIN_MTU`),原始包太长就截断。 + +## ICMP Sockets:让普通用户也能 ping + +以前 ping 要 root 权限——创建 Raw Socket(`SOCK_RAW`)需要 `CAP_NET_RAW`,所以 `/bin/ping` 传统上带 `setuid root` 位。2011 年左右内核引入 **ICMP Sockets(Ping Sockets)**:`socket(PF_INET, SOCK_DGRAM, IPPROTO_ICMP)`,特殊 Datagram Socket,不一定需要 root。代码在 `net/ipv4/ping.c`(双栈,IPv6 也调这里)。 + +内核检查调用方的 GID 是否落在 `/proc/sys/net/ipv4/ping_group_range`(默认 `1 0` 意为"没人能用")。想放开:`echo 1000 1000 > .../ping_group_range`。`ping_supported()` 还卡一道:这种 socket 只能发标准 echo(`type == ICMP_ECHO && code == 0`),不能用来发 redirect 或 dest unreach——给了普通用户诊断权,但不给 DoS 武器。这就是现代发行版"无 root ping"的秘密。 + +## 动手验证方案(待亲测) + +下面这几条等我们在 QEMU 跑一遍记下真实输出再填实,先列方案: + +- **ping 抓 echo**:QEMU 双机/双 netns 之间 `ping <对端>`,对端 `tcpdump -ni any icmp` 抓 type 8 request 和 type 0 reply;顺便 `cat /proc/net/snmp | grep -A1 '^Icmp:'` 看 `InEchos`/`OutEchoReplies` 计数跳动。 +- **隐身模式**:对端 `echo 1 > /proc/sys/net/ipv4/icmp_echo_ignore_all`,再 ping,观察请求仍在、应答消失、`InErrors`/`OutMsgs` 变化。 +- **traceroute 看 time exceed**:本机 `traceroute <多跳目标>`(或自建中转路由器),同时 `tcpdump 'icmp[icmptype] == icmp-timexceed'`,核对每一跳返回的就是 Type 11。 +- **端口不可达**:对端不开任何服务,本机 `nc -u <对端> 8888` 发个 UDP 包,抓 `icmp[icmptype] == icmp-unreach`(Type 3 Code 3)。 +- **PMTU**:中间路由器接口 MTU 压到 1400,本机 `ping -M do -s 1472 <对端>` 强制不分片,抓 `ICMP_FRAG_NEEDED` 并看 `info` 字段里的 MTU 值。 + +> ⚠️ **待亲测**:以上命令与计数器输出是整理时的方案设计,尚未在本机 QEMU 验证。验完会补真实 tcpdump 片段和 `/proc/net/snmp` 计数。 + +## 小结 + +ICMP 是 IP 层的维保工:`icmp_rcv` 收、`__icmp_send` 发,靠 `icmp_pointers` 表分发到各 handler。错误报告带 per-CPU socket 防锁竞争、带双重速率限制防雪崩、带"错误不套错误"防风暴。ICMPv6 比 v4 重得多——它把 ARP(邻居发现 ND)和 IGMP(组播 MLD)全收编了,用"最高位判错误/信息"的简洁规则和 `switch` 分发取代了 v4 的查表。记住两个工程教训:**PMTU 消息必须放行**(否则大包静默黑洞),以及**防火墙优先 REJECT 而非 DROP**——礼貌回个不可达,客户端立刻知道此路不通,比让它干等到超时强得多。 + +## 延伸阅读 + +- 源码(Linux 6.19): + - `net/ipv4/icmp.c` — `icmp_rcv`、`__icmp_send`、`icmp_unreach`、`icmp_echo`、`icmp_pointers[]`、`icmp_init` + - `net/ipv6/icmp.c` — `icmpv6_rcv`、`icmpv6_send`、`icmpv6_echo_reply` + - `include/uapi/linux/icmp.h` / `include/uapi/linux/icmpv6.h` — `struct icmphdr`、`struct icmp6hdr` + - `include/linux/icmp.h` — `icmp_is_err()`、`icmp_hdr()` + - `net/ipv4/ping.c` — `ping_rcv`、`ping_supported`(ICMP Sockets 双栈实现) +- kernel.org 稳定文档:[Networking — IP-Sysctl](https://docs.kernel.org/networking/ip-sysctl.html)(`icmp_*` 旋钮的权威说明)、[Linux Networking and Network Devices](https://docs.kernel.org/networking/index.html)(网络子系统总入口)。 +- RFC:792(ICMPv4)、4443(ICMPv6)、1812(路由器要求,含 ICMP 速率限制)。 +- 进一步(持续铺开):`ip_forward` 路由转发、IPv6 邻居发现(`ndisc.c`)、PMTU 发现的传输层联动。 \ No newline at end of file diff --git a/document/tutorials/kernel/net/11-net-multicast.md b/document/tutorials/kernel/net/11-net-multicast.md new file mode 100644 index 00000000..9264dc09 --- /dev/null +++ b/document/tutorials/kernel/net/11-net-multicast.md @@ -0,0 +1,227 @@ +--- +title: 组播路由:一对多的高效投递 +slug: net-multicast +difficulty: intermediate +tags: [网络栈, 组播, IGMP, 多路径路由] +architectures: [arm64, x86_64, riscv] +kernel_version: "6.19" +maturity: drafting +prerequisites: + - /tutorials/kernel/net/04-net-ipv4 +related: + - /tutorials/kernel/net/04-net-ipv4 +sources: + - notes: document/notes/linux_kernel_networking/ch06.md + - notes: document/notes/linux_kernel_networking/ch06_1.md + - notes: document/notes/linux_kernel_networking/ch06_2.md + - notes: document/notes/linux_kernel_networking/ch06_3.md + - notes: document/notes/linux_kernel_networking/ch06_4.md + - notes: document/notes/linux_kernel_networking/ch06_5.md + - notes: document/notes/linux_kernel_networking/ch06_6.md + - notes: document/notes/linux_kernel_networking/ch06_7.md + - notes: document/notes/linux_kernel_networking/ch06_8.md +--- + +# 组播路由:一对多的高效投递 + +> 🔨 **整理中** · 本篇机制对照 Linux 6.19 源码讲解(函数名/数据结构/CONFIG/行号已逐条核对过 `net/ipv4/ipmr.c`、`net/ipv4/igmp.c`、`net/ipv4/route.c`、`net/ipv4/fib_semantics.c`);动手部分(加组、tcpdump 抓 IGMP、多路径配置)还没在 QEMU 上亲手跑过,输出仍是参考样例。 + +## 一场直播流,凭什么不把光纤烧干 + +单播的世界观很朴素:要给一百个人发同一封邮件,就发一百次。逻辑上没毛病,可一旦换成 NFL 直播流——服务器得为每个在线观众单独拉一根网线,带宽直接爆炸。我们需要让网络理解「这封信属于某一群人」,然后只在必要的分叉口才复制。 + +这就是**组播(multicast)**。IPv4 用 D 类地址 `224.0.0.0/4`(首段 224~239)专门承载这种「一对多」流量,OSPF 的 `224.0.0.5`、视频流的 `239.x.x.x` 都住在这片地界。 + +但组播比单播难得多:路由器得时刻追踪「谁想听」「谁不想听了」「谁在发」。管不好,组播包就像洪水漫灌,把交换机活活撑死。这一篇我们就拆 Linux 内核这套系统怎么转——IGMP 怎么管「成员名单」、内核那张特殊的组播路由表长啥样、一个组播包进来后怎么被分发到成千上万个出口。IPv6 那套叫 MLD(基于 ICMPv6),留到以后。 + +## IGMP:主机举手报名 + +要在 IPv4 玩组播,主机和路由器都绕不开 **IGMP(Internet Group Management Protocol)**。它的活很纯:建立并维护组播成员关系。协议迭代了三版,每版都在补上一版的漏洞。 + +- **IGMPv1(RFC 1112)**:只有两种消息——主机喊「我要加入」的 Membership Report、路由器问「这局域网还有人听吗」的 Membership Query。Query 发给 `224.0.0.1`(`IGMP_ALL_HOSTS`,所有人),TTL 锁死为 1,永远出不了本地网段——防噪声。 +- **IGMPv2(RFC 2236)**:v1 最大的坑是「沉默就是离开」——主机关机拔网线时没法说再见,路由器只能干等超时。v2 加了 **Leave Group(0x17)**,主机礼貌退群,路由器立刻停止转发,不必傻等。同时 Query 拆出 General Query 和 Group-Specific Query 两个子类。 +- **IGMPv3(RFC 3376)**:引入**源过滤**——不光能说「我要听 239.1.1.1」,还能说「我只信 10.0.0.1 这个源发的」(Include)/「除了 10.0.0.2 谁都行」(Exclude)。代价是 Socket API 也得扩展(`IP_ADD_SOURCE_MEMBERSHIP` 等)。 + +内核视角下,IGMP 报文进栈后落到 `igmp_rcv()`(`net/ipv4/igmp.c:1075`,Linux 6.19),它按消息类型分发。路由器定期发 Query,主机收到 `IGMP_HOST_MEMBERSHIP_QUERY` 后调 `igmp_heard_query()`(`igmp.c:947`)——这个方法就是主机说「我在听」的触发器,它会重置本机定时器、准备回 Report,保证网段里只要有活人,路由器就知道这个组还在。 + +主机自己想加入一个组,是应用层发起的:`setsockopt(fd, IPPROTO_IP, IP_ADD_MEMBERSHIP, ...)`,最终进 `ip_mc_join_group()`(`igmp.c:2313`),把组地址挂到网卡 `in_device` 的成员链表上,同时回发一个 IGMP Report。**有个历史遗留限额**:同一 socket 最多加 20 个组,硬编码在 `sysctl_igmp_max_memberships`(`igmp.c:2294` 用 `count >= READ_ONCE(net->ipv4.sysctl_igmp_max_memberships)` 判断),超了直接 `-ENOBUFS`——20 个对普通应用够用,做组播网关就得多开 socket 或改 `/proc/sys/net/ipv4/igmp_max_memberships`。 + +## 组播路由表:`mr_table` 调度中心 + +IGMP 是「举手报名」,组播路由表就是那个拿着花名册的「点名员」。内核里这张表是 `struct mr_table`(`include/linux/mroute_base.h:246`,Linux 6.19): + +```c +struct mr_table { + struct list_head list; + possible_net_t net; // 网络命名空间,容器隔离用 + u32 id; // 表身份证号,单表模式常是 RT_TABLE_DEFAULT(253) + struct sock __rcu *mroute_sk; // 用户空间路由守护进程的 socket 引用 + struct timer_list ipmr_expire_timer; // 定时清垃圾 + struct list_head mfc_unres_queue; // 未解析条目队列 + struct vif_device vif_table[MAXVIFS]; // 虚拟接口表(最多 32 个) + struct rhltable mfc_hash; // 组播转发缓存(哈希表) + struct list_head mfc_cache_list; // 缓存条目链表 + int maxvif; + atomic_t cache_resolve_queue_len; + bool mroute_do_assert; // 是否在入接口错误时通知用户空间 + bool mroute_do_pim; // 是否收 PIMv1 + int mroute_reg_vif_num; // PIM register vif 索引 +}; +``` + +> ⚠️ 这里有个版本坑:老书和老笔记写的是 `mfc_cache_array[MFC_LINES]`(64 槽位数组)。Linux 6.19 早已换成 `rhltable mfc_hash`(可扩缩的哈希表)+ `mfc_cache_list` 链表,不再是固定数组。老笔记这块没跟上,我们按现网代码讲。 + +每个字段都埋着机制。**`net`** 是网络命名空间指针(容器化网络隔离的关键),**`id`** 是表身份证。**`mroute_sk`** 最有意思——它指向内核保留的一个用户空间 socket 引用。这里有个关键交互:用户空间的组播路由守护进程(`mrouted`/`pimd`)启动时调 `setsockopt(..., MRT_INIT, ...)`,内核就把当前 socket 存进 `mroute_sk`,认定它是「总指挥」;守护进程退出调 `MRT_DONE`,内核清空这个指针。 + +为什么这么设计?**内核自己不跑路由协议**,只管转发;策略决策(怎么转、建什么树)由用户空间守护进程算好,通过 `setsockopt`/ioctl 喂给内核。反过来,内核遇到不会转的包,也通过这个 socket 把消息(`sock_queue_rcv_skb`)塞回守护进程。 + +**独占性**是硬规矩:同一时间只能有一个组播路由守护进程。`MRT_INIT` 时第一件事就是查 `mroute_sk` 占没占坑,占了直接 `-EADDRINUSE`(`ipmr.c:1413`:`if (rtnl_dereference(mrt->mroute_sk)) ret = -EADDRINUSE`)。同时内核会把 `IPV4_DEVCONF_ALL(net, MC_FORWARDING)` 自增 1(`ipmr.c:1421`)——这文件只读,因为它是**状态不是配置**,只有 `MRT_INIT` 发生时才翻转,防止没守护进程时强行开转发。 + +守护进程还要把物理网卡注册成组播接口,靠 `MRT_ADD_VIF` 命令填一张 `struct vifctl` 表,内核调 `vif_add()` 把设备挂进 `vif_table`。每个 VIF 可以是真实网卡,也可以是 IPIP 隧道(`VIFF_TUNNEL`),跨不支持组播的公网时靠它封装。`vif_add()` 还会调 `dev_set_allmulti(dev, 1)`——告诉网卡驱动「别光收单播,把路过的组播包都递上来」,否则硬件层直接丢,内核根本没机会转发。 + +## MFC:组播转发缓存 + +`mr_table` 是调度中心,真正的转发决策在 **MFC(Multicast Forwarding Cache)**。每个组播包进来都要在这里问路。条目最小单位是 `struct mfc_cache`(`include/linux/mroute.h:80`,Linux 6.19),它本身只装两个键值字段 + 一个内嵌的通用基底: + +```c +struct mfc_cache { // mroute.h:80,键值字段(mfc_cache 自有) + struct mr_mfc _c; // ← 通用基底,继承自它 + union { // cmparg 用于哈希比较 + struct { + __be32 mfc_mcastgrp; // 组地址 + __be32 mfc_origin; // 源地址 + }; + struct mfc_cache_cmp_arg cmparg; + }; +}; +``` + +而 `mfc_parent`、`mfc_un`(unres/res)这些干活字段都不在 `mfc_cache` 本体,而在它内嵌的 `struct mr_mfc _c` 里(`include/linux/mroute_base.h:135`): + +```c +struct mr_mfc { // mroute_base.h:135,通用基底 + // ...(list/hash 链表、refcount 等) + unsigned short mfc_parent; // 入接口(VIF 索引),访问写成 c->_c.mfc_parent + union { + struct { ... } unres; // 未解析态:expires + unresolved 包队列 + struct { ... } res; // 已解析态:bytes/pkt/wrong_if 统计 + ttls[MAXVIFS] + } mfc_un; +}; +``` + +所以真实代码里访问转发字段一律走 `_c` 间接:`c->_c.mfc_parent`、`c->_c.mfc_un.res.ttls[ct]`、`c->_c.mfc_un.res.maxvif`。哈希键只吃两个值:**源地址 + 组地址**——一条组播流由 `(S, G)` 唯一确定。`mfc_parent` 是入接口:组播路由关心源(后面讲 RPF 会用),必须知道包最初从哪个 VIF 进来,防环路防重复。`mfc_un` 那个 union 是个薛定谔盒子——**`unres`(未解析)**态挂着过期时间 `expires` 和一个 `unresolved` 包队列;**`res`(已解析)**态塞满干活数据:`bytes`/`pkt`/`wrong_if` 统计、还有关键的 `ttls[MAXVIFS]`——记录每个虚拟接口的 TTL 阈值,包能不能从某接口出,全看这个数组里值和包的 TTL 谁大。 + +理论上流程是「包来 → 查 MFC → 命中 → 转发」,现实往往是「没命中」。这时 `ipmr_cache_unresolved()` 登场,务实得甚至卑微:建/找一个未解析条目、把包挂进 `unresolved` 队列、通过 `mroute_sk` 给守护进程发 `IGMPMSG_NOCACHE`——「大哥,这有个包不知去哪,快来看看」。 + +> **⚠️ 踩坑预警:只有 3 个名额。** `ipmr.c:1176` 那行硬逻辑:`if (c->_c.mfc_un.unres.unresolved.qlen > 3)`,同一流的迷路包队列里蹲了 3 个,第 4 个直接 `kfree_skb` 丢、返回 `-ENOBUFS`。同时未解析条目有个 **10 秒最后通牒**——`ipmr_cache_alloc_unres()` 里 `c->_c.mfc_un.unres.expires = jiffies + 10 * HZ`(`ipmr.c:992`),10 秒内守护进程不回填路由,条目就被 `ipmr_expire_timer` 清掉。这是保护机制:守护进程挂了或太慢,内核不能让未解析队列无限膨胀吃光内存。 + +## 组播接收路径:从网卡到转发队列 + +视角切到路由器。组播包抵达网卡后,在 `ip_route_input_mc()`(`net/ipv4/route.c:1742`,Linux 6.19)里初始化 `rtable` 时,有个关键微调:**开了 `CONFIG_IP_MROUTE` 且目标不是本地链路组播(`!ipv4_is_local_multicast`,排除 `224.0.0.x` 这类)且入接口 `IN_DEV_MFORWARD` 开启时,才把 `rth->dst.input` 改指向 `ip_mr_input`**(`route.c:1775`): + +```c +#ifdef CONFIG_IP_MROUTE + if (!ipv4_is_local_multicast(daddr) && IN_DEV_MFORWARD(in_dev)) + rth->dst.input = ip_mr_input; +#endif +``` + +这「三个条件缺一不可」也顺带解释了为什么 IGMP(`224.0.0.1`)这类本地组播根本不进 ipmr 转发——它们被 `ipv4_is_local_multicast` 直接挡掉,留给 `igmp_rcv` 自己处理。 + +`ip_mr_input()`(`ipmr.c:2144`)身兼两职——既转发又本地投递(路由器自己可能也是组成员)。它先做几道关卡: + +1. **防重复转发**:`if (IPCB(skb)->flags & IPSKB_FORWARDED) goto dont_forward;`——这包已被我转过一次了(可能因网桥/VLAN 绕回来),再转就是死循环。 +2. **查表**:`mrt = ipmr_rt_fib_lookup(net, skb)`,普通配置下直接返回 `net->ipv4.mrt`。 +3. **Router Alert 特快通道**:IGMPv2/v3 在 JOIN/LEAVE 报文 IPv4 头里盖个 Router Alert 印章(`IPCB(skb)->opt.router_alert`),`ip_call_ra_chain()` 直接把包塞给守护进程的 raw socket。代码里有段精彩注释吐槽 Cisco IOS ≤11.2(8) 这种不守规矩的老设备不设 RA,内核只能「夹带私货」——直接拿 `mrt->mroute_sk` 强行 `raw_rcv()` 发给守护进程,保证**无论设备多老、守护进程一定要收到 IGMP**。 +4. **查 MFC**:`ipmr_cache_find(mrt, saddr, daddr)`,键是 `(S,G)`。命中就转给 `ip_mr_forward()`;没命中先查通配源 `ipmr_cache_find_any`,还找不到就走 `ipmr_cache_unresolved()` 收留+报警。 + +命中后直接 `ip_mr_forward(net, mrt, dev, skb, cache, local)`(`ipmr.c:2227`)——此时外层 `ip_mr_input` 已持有 `rcu_read_lock()`,函数注释里写明 `/* Called with mrt_lock or rcu_read_lock() */`,所以这里**不再额外加 `mrt_lock`**;若包也要本地投递(`local` 为真),再走 `ip_local_deliver(skb)`。 + +## `ip_mr_forward()`:分发肌肉 + +`ip_mr_input` 是决策大脑,`ip_mr_forward`(`ipmr.c:1996`)是干脏活的肌肉——按 MFC 指示把包复制并转发到所有该去的 VIF。这里最著名的是**入接口验明正身(Wrong VIF)**:`if (rcu_access_pointer(mrt->vif_table[vif].dev) != dev)`——路由条目说你该从 eth0 进来,结果你从 eth1 冒头。这分两种情况:本机发出包绕回来的「回环噩梦」直接 `dont_forward`(注释里直呼 "Very complicated situation...");真走错门则统计 `wrong_if`,凑齐「接口有效 + 允许 assert +(PIM 或 TTL 合理)+ 距上次吵架超 `MFC_ASSERT_THRESH`」四连条件,就发 `IGMPMSG_WRONGVIF` 让守护进程吵一架(PIM Assert)。 + +通过入接口检查后进 `forward` 标签,核心是个**遍历 VIF 表的转发循环**(`ipmr.c:2082` 起,按 6.19 真实代码): + +```c +for (ct = c->_c.mfc_un.res.maxvif - 1; ct >= c->_c.mfc_un.res.minvif; ct--) { + /* For (*,G) entry, don't forward to the incoming interface */ + if ((c->mfc_origin != htonl(INADDR_ANY) || ct != true_vifi) && + ip_hdr(skb)->ttl > c->_c.mfc_un.res.ttls[ct]) { + if (psend != -1) { // 之前攒了个待发接口 + struct sk_buff *skb2 = skb_clone(skb, GFP_ATOMIC); + if (skb2) + ipmr_queue_fwd_xmit(net, mrt, true_vifi, skb2, psend); + } + psend = ct; + } +} +``` + +注意两点:(1) 转发字段一律经 `_c` 间接访问(`c->_c.mfc_un.res.maxvif` / `c->_c.mfc_un.res.ttls[ct]`),键值字段才直接挂在 `mfc_cache` 上(`c->mfc_origin`);(2) 真正发货的函数叫 `ipmr_queue_fwd_xmit()`,比很多老笔记写的 `ipmr_queue_xmit` 多一个 `in_vifi`(入接口索引)参数——那个老函数名在 6.19 里**根本不存在**,别照着老资料找。每个潜在出口 `ct` 过两道生死关:**是不是回头路**(`(*,G)` 不能发回进来的接口 `true_vifi`)和 **TTL 够不够**——每接口一个阈值 `ttls[ct]`,只有包 TTL **大于**它才允许出。这就是组播控范围、防泛滥的最有效手段。 + +循环结束后还有个 `last_forward:` 标签处理最后一个攒在 `psend` 里的待发口(`ipmr.c:2098`):若也要本地投递(`local` 为真),就 `skb_clone()` 一份发出、原 skb 留给本地;否则直接拿原 skb 发出后 `return`。 + +### 发货其实分两步:`ipmr_prepare_xmit` 备货,`ipmr_queue_fwd_xmit` 发货 + +老资料把它说成一个 `ipmr_queue_xmit` 全包圆,其实 6.19 拆成了**备货**和**发货**两环。`ipmr_queue_fwd_xmit()`(`ipmr.c:1935`)本身很瘦:先试硬件卸载(`ipmr_forward_offloaded`),不成再调 `ipmr_prepare_xmit()` 备货,备好了打 `IPSKB_FORWARDED` 标记、过 `NF_HOOK(NF_INET_FORWARD)`,放行后交给 `ipmr_forward_finish()` → `dst_output()` 出网卡。真正干「查路由、判 MTU、套隧道头」重活的是 `ipmr_prepare_xmit()`(`ipmr.c:1857`): + +- **隧道 vs 物理口**:VIF 是隧道(`VIFF_TUNNEL`)就走 `IPPROTO_IPIP` 查到隧道对端(`vif->remote`)的单播路由,并预留 `encap = sizeof(struct iphdr)`(`ipmr.c:1888`)给新 IP 头腾地方;物理接口就查到组地址(`iph->daddr`)的路由。 +- **反直觉的 MTU 处理**:MTU 不够且带 DF 位时——**什么都不做,直接丢,不发 ICMP**(`ipmr.c:1898`,注释直白:"Do not fragment multicasts. Alas, IPv4 does not allow to send ICMP, so that packets will disappear to blackhole.")。组播接收者成千上万,一条路径 MTU 变小就向源头灌 ICMP 是灾难(ICMP 风暴 + 没法满足所有人)。所以「组播不切片,包消失进黑洞」——RFC 规定,沉默是金。 +- 随后 `ip_decrease_ttl()`、隧道则 `ip_encap()` 套新头,备货就算齐活。 + +**TTL 在组播里有两层含义**:第一层是跳数限制(防环路),第二层是范围阈值——Steve Deering 定的「行政边界」:0=本机、1=同子网、32=同站点、64=同地区、128=同大洲、255=全球。应用层用 `IP_MULTICAST_TTL` 控制包飞多远,设 1 就在局域网晃悠。 + +## 多路径路由(ECMP):流量摊开 + +组播讲完了,把视角拉回单播查表。传统路由只看目的地,但现实常要「把流量摊开」:两条等带宽宽带想都用上、两块网卡接同一交换机想跑满带宽。这就是**多路径路由(multipath / ECMP)**——为一个目的配多个下一跳,加权分担: + +```bash +ip route add 192.168.1.10 \ + nexthop via 192.168.2.1 weight 3 \ + nexthop via 192.168.2.10 weight 5 +``` + +内核里路由信息载体是 `struct fib_info`。要分清新老两套载体:**现代主流是 `struct nexthop` 对象**(`fib_info->nh`,`include/net/ip_fib.h:160`),用 `ip nexthop` 命令族管理,可被多条路由共享;**旧式兼容路径**才是 `fib_nh[]` 数组(`fib_info:162`,长度 `fib_nhs`)。两套并存,`fib_select_multipath` 开头先判 `if (unlikely(res->fi->nh))`,有 `nexthop` 对象就走 `nexthop_path_fib_result()`、直接 return;没有才回落到遍历 `fib_nh[]` 数组的老路径。权重字段是个宏 `fib_nh_weight`(`ip_fib.h:126`,展开成 `nh_common.nhc_weight`),不是裸的 `nh_weight`。 + +路由表只决定「有哪些路可选」,数据包真到了得挑**一条**走,这个黑盒是 `fib_select_multipath()`(`net/ipv4/fib_semantics.c:2165`,Linux 6.19)。它不是简单的「轮流坐庄」——先由 `fib_multipath_hash()` 算一个哈希 `h`,再按 `nh_upper_bound`(累加权重上限)和哈希值匹配选路。**关键修正:默认只哈希三层**。`sysctl_fib_multipath_hash_policy` 默认是 `0`(三层),`fib_multipath_hash()` 在 `case 0` 里只取源 IP + 目的 IP(`route.c:2073` 的 `switch`、`:2080` 用 `fl4->saddr`/`fl4->daddr`)算哈希——对流粘性已经够,且不依赖端口;要端口级分散才设 `fib_multipath_hash_policy=1`(四层,含源/目的端口+协议)或更高。设计目标是**同一条流走同一条路**(否则 TCP 乱序性能暴跌)、**不同流按权重比例分配**。调用点是 `fib_select_path()`(`fib_semantics.c:2209`):它先看 `if (fl4->flowi4_oif)`——应用若 `bind()`/`sendmsg()` 硬性指定了出口设备,多路径就没意义,直接跳过走指定路;否则在多路径(`fib_info_num_path > 1`)时调 `fib_multipath_hash` + `fib_select_multipath`。 + +> **版本澄清**:老笔记说多路径用 `jiffies` 做随机种子,那是 2007 年前的老实现。现代内核用 `fib_multipath_hash` 的确定性哈希,保证流内一致性。另外别混淆两个历史删除:2007 年(2.6.23)删的是「多路径路由缓存」(实验性、效果差),2012 年(3.6)删的是「单播路由缓存」(多核同步开销)。现在多路径在 FIB 查找阶段直接完成,没缓存层,逻辑更清爽。多路径代码不像 ipmr.c 那样独立成文件,而是散在通用路由代码里、被大量 `#ifdef CONFIG_IP_ROUTE_MULTIPATH` 包着——它是路由查找的增强特性,不是独立子系统。 + +## 组播 vs 单播:关键区别 + +组播路由和单播路由最根本的差异在「建树」逻辑: + +- **单播路由按目的建表**——给个目的地,查一个确定的下一跳。 +- **组播路由按「组」建转发树**——同一组的不同源可能走不同树,所以 MFC 键是 `(S, G)` 或 `(*, G)`,且必须记 `mfc_parent`(入接口)。 + +这个「入接口」是 **RPF(Reverse Path Forwarding,反向路径转发)**的核心:组播包只在「从源到本机的最短路径」对应的接口上才被接受转发,从别的接口冒出来的直接判 Wrong VIF 丢掉。这就是 `ip_mr_forward()` 里 `vif_table[vif].dev != skb->dev` 那道关卡的本质——防环路、防重复泛滥。单播转发从不关心包从哪个接口进来,只关心去哪;组播必须双查(从哪来 + 去哪)。 + +## 动手验证(待亲测) + +> ⚠️ 以下方案待 QEMU 亲测,输出是参考样例,跑过再替换成真实数据。 + +1. **加组播组、看本机成员表**: + ```bash + ip maddr add 239.1.1.1 dev eth0 + ip maddr show dev eth0 + ``` +2. **tcpdump 抓 IGMP**:在另一终端开抓包,加组后应能看到 Membership Report: + ```bash + tcpdump -ni eth0 'igmp' -vv + # 期望: IP 0.0.0.0 > 239.1.1.1: igmp report v2 + ``` +3. **看内核参数**:`cat /proc/sys/net/ipv4/conf/all/mc_forwarding`(普通主机应为 0;跑起组播守护进程 `MRT_INIT` 后才翻 1)、`cat /proc/sys/net/ipv4/igmp_max_memberships`(默认 20)。 +4. **多路径路由配置 + 哈希策略**:`ip route add default nexthop dev eth0 nexthop dev eth1` 后 `ip route show` 看是否生效(需内核 `CONFIG_IP_ROUTE_MULTIPATH=y`);`cat /proc/sys/net/ipv4/fib_multipath_hash_policy` 看哈希策略(默认 `0`=三层,端口级分散要设成 `1`)。 + +## 小结 + +组播是「一对多」的高效投递:IGMP(`igmp.c`)管主机举手报名,`mr_table`(`mroute_base.h:246`)是调度中心——`mroute_sk` 串起用户空间守护进程、`vif_table` 管出口、`mfc_hash` 存转发决策。每个组播包经 `ip_route_input_mc`(在 `CONFIG_IP_MROUTE` + 非本地组播 + `IN_DEV_MFORWARD` 三条件齐备时)把 `dst.input` 改指向 `ip_mr_input`,查 MFC `(S,G)` 命中就 `ip_mr_forward` 按 `ttls[]` 阈值复制分发到各 VIF;发货分两步,`ipmr_prepare_xmit` 备货(查路由、隧道 IPIP 封装预留 `encap`、MTU+DF 沉默丢弃),`ipmr_queue_fwd_xmit` 打标过 Netfilter 发出(注意:6.19 里没有老资料说的 `ipmr_queue_xmit`,别照着找)。cache miss 有 3 包缓冲 + 10 秒超时的保护机制。组播按「组」建树 + RPF 入接口校验,是与单播「按目的建表」的根本区别。多路径路由(ECMP)则是单播侧的流量摊开——`fib_select_multipath` 按 `fib_multipath_hash_policy`(默认 `0` 只哈希源/目的 IP)算哈希、加权选路,同流同路、异流按权重分;现代实现优先走 `nexthop` 对象(`fib_info->nh`),老式 `fib_nh[]` 数组是兼容路径。 + +## 延伸阅读 + +- 源码(Linux 6.19):`net/ipv4/ipmr.c`(组播路由核心,`ip_mr_input`/`ip_mr_forward`/`ipmr_prepare_xmit`/`ipmr_queue_fwd_xmit`/`ipmr_cache_unresolved`)、`net/ipv4/igmp.c`(IGMP,`igmp_rcv`/`igmp_heard_query`/`ip_mc_join_group`)、`include/linux/mroute_base.h`(`struct mr_table`、`struct mr_mfc`)、`include/linux/mroute.h`(`struct mfc_cache`)、`net/ipv4/fib_semantics.c`(`fib_select_multipath`/`fib_select_path`,多路径)、`net/ipv4/route.c:1742`(`ip_route_input_mc` 把 input 指向 `ip_mr_input`)、`net/ipv4/route.c:2073`(`fib_multipath_hash` 的 `hash_policy` 分支)。 +- kernel.org 文档:[Networking — index](https://docs.kernel.org/networking/index.html)(网络子系统文档总索引,本站死链纪律下用它做入口)、[IP Sysctl](https://docs.kernel.org/networking/ip-sysctl.html)(含 `fib_multipath_hash_policy`/`fib_multipath_use_neigh` 等多路径参数,正好支撑正文)。 +- 进一步(持续铺开):策略路由(`ip rule`/`fib_rules.c`)、IPv6 MLD、PIM-SM 协议细节。 + +> 注:组播专用的 `docs.kernel.org/networking/multicast.html` 和 `routing.html` 在本核对时点(Linux 6.19.9 的 `Documentation/networking/` 下)**并不存在**(目录里既无 `multicast.*` 也无 `routing.*` rst 源),故未引用,改用真实存在的 index.html 和 ip-sysctl.html。发布前建议对每个外链再跑一次 HTTP 200 校验。 \ No newline at end of file diff --git a/document/tutorials/kernel/net/12-net-ipv6.md b/document/tutorials/kernel/net/12-net-ipv6.md new file mode 100644 index 00000000..24b29223 --- /dev/null +++ b/document/tutorials/kernel/net/12-net-ipv6.md @@ -0,0 +1,196 @@ +--- +title: IPv6:不只是更长的地址 +slug: net-ipv6 +difficulty: intermediate +tags: [IPv6, 邻居发现, 组播, 网络协议栈] +architectures: [arm64, x86_64, riscv] +kernel_version: "6.19" +maturity: drafting +prerequisites: + - /tutorials/kernel/net/04-net-ipv4 + - /tutorials/kernel/net/03-net-neighbor +related: + - /tutorials/kernel/net/04-net-ipv4 + - /tutorials/kernel/net/03-net-neighbor +sources: + - notes: document/notes/linux_kernel_networking/ch08.md + - notes: document/notes/linux_kernel_networking/ch08_1.md + - notes: document/notes/linux_kernel_networking/ch08_2.md + - notes: document/notes/linux_kernel_networking/ch08_3.md + - notes: document/notes/linux_kernel_networking/ch08_4.md + - notes: document/notes/linux_kernel_networking/ch08_5.md + - notes: document/notes/linux_kernel_networking/ch08_6.md + - notes: document/notes/linux_kernel_networking/ch08_7.md + - notes: document/notes/linux_kernel_networking/ch08_8.md +--- + +# IPv6:不只是更长的地址 + +> 🔨 **整理中** · 本篇机制对照 Linux 6.19 源码讲解(函数名/数据结构/CONFIG/sysctl 已核对);具体行号与命令输出待 QEMU 亲测核对。等我们在 QEMU 里跑通 `ip -6 addr` / `ping6` / `ip -6 neigh`,把 NDISC 和 SLAAC 的真实报文抓下来,就升级成 ✅ 已锤炼。 + +## 从一个幽灵数字说起 + +有一个数字像幽灵一样盘旋在网络工程的上空:$2^{32}$——IPv4 地址的理论总量。2011 年地址池正式宣告耗尽,从那以后整个互联网靠 NAT 这台透析机勉强吊着命。但 NAT 本质是个 Hack,它砸碎了互联网端到端的设计哲学,让 P2P 和协议设计都变得别扭。 + +IPv6 看起来只是把地址从 32 位拉到 128 位,但真正进了内核代码你会发现——它不是在 IPv4 后面打补丁,而是站在老兵肩膀上做的一次重构。变量名往往只多一个 `6`,函数名换个前缀,这种相似性有欺骗性。剥开外壳,IPv6 把 IPv4 几十年的历史包袱砍掉了:路由器不再分片、扩展头代替 Options、主机不需要 DHCP 也能拿到全球地址。这篇我们就钻进 `net/ipv6/`,看看这些机制在内核里到底怎么实现。 + +## 地址:从门牌号到坐标 + +先看内核怎么装这个 128 位的怪兽。在 `include/uapi/linux/in6.h` 里,`struct in6_addr` 是一个 union,把同一块 16 字节切成三种视角: + +```c +struct in6_addr { + union { + __u8 u6_addr8[16]; // 按字节 memcpy + __be16 u6_addr16[8]; // 16 位分段 + __be32 u6_addr32[4]; // 按位/掩码运算 + } in6_u; +}; +``` + +这是内核网络代码的常见手法——为了性能,直接按字长操作内存,而不是在那儿移位。`__be` 前缀提醒你这些是网络字节序(大端),跨架构移植时别填本地整数。 + +> ⚠️ **读头文件别被骗**:上面是**内核内部视图**,三个成员都可见。但在 uapi 头里,`u6_addr16`/`u6_addr32` 被包在 `#if __UAPI_DEF_IN6_ADDR_ALT` 里——用户态默认这个宏为 0,所以 glibc 程序 `#include` 后**只能看到 `u6_addr8`**(配合 `s6_addr` 这类宏访问)。只有进了内核态(`__UAPI_DEF_IN6_ADDR_ALT` 为 1)三视图才全露出来。你拿 uapi 头自己编译用户态程序只看到 `u6_addr8`,不是头文件坏了。 + +IPv6 地址分三类,而且**取消了广播**。单播一对一;任播一对最近的一个(像连锁店,你找的是"麦当劳"这个品牌,但只走进最近的那家);组播一对多。为什么砍掉广播?因为 IPv4 里 ARP 一次广播喊醒整个网段太吵了,IPv6 干脆用组播代替——你只朝自己关心的那个组喊,不是组成员的设备可以继续睡觉。 + +几个必须刻进肌肉记忆的特殊地址:链路本地 `fe80::/64`(只能在本链路用,路由器绝不转发,是邻居发现和自动配置的根基);全球单播(公网身份证,`全局路由前缀 + 子网 ID + 接口 ID` 三段千层饼);环回 `::1`;未指定 `::`(DAD 时当源地址,意思是"我还没地址,正在占坑")。 + +## 源码里的核心结构:`struct ipv6hdr` + +来看内核眼里的 IPv6 包长什么样,`include/uapi/linux/ipv6.h`(Linux 6.19): + +```c +struct ipv6hdr { +#if defined(__LITTLE_ENDIAN_BITFIELD) + __u8 priority:4, version:4; +#elif defined(__BIG_ENDIAN_BITFIELD) + __u8 version:4, priority:4; +#endif + __u8 flow_lbl[3]; + __be16 payload_len; + __u8 nexthdr; + __u8 hop_limit; + __struct_group(, addrs, , struct in6_addr saddr; struct in6_addr daddr;); +}; +``` + +**固定 40 字节,雷打不动。** 这是 IPv6 第一刀——IPv4 头部长度可变(有 Options),所以必须有 `IHL` 字段告诉内核头有多长;IPv6 直接砍掉这个字段,40 字节写死。 + +第二刀更狠:**IPv6 头部没有校验和**。IPv4 每经过一个路由器 TTL 减 1 就得重算整个头部校验和,在老式软件路由器上是不小的开销。IPv6 把这活儿甩给了二层(以太网 CRC)和四层(TCP/UDP 校验和)。后果是路由器改 `hop_limit` 不用重算校验和——纯软件转发实打实的提速。副作用:IPv6 里 UDP 校验和强制开启(除极少数隧道场景),因为没人再给你兜底了。 + +那个被切分的字节很有意思。RFC 2460 标准说 4 位 Version + 8 位 Traffic Class + 20 位 Flow Label。但 Linux 实现把第一个字节拆成 `priority:4` + `version:4`,剩下的 4 位 Traffic Class 塞进了 `flow_lbl[0]` 的高 4 位。`priority` 加 `flow_lbl[0]` 高 4 位才拼出完整的 8 位 Traffic Class(留给 DiffServ 做 QoS)。 + +字段一一过:`version` 必须 6;`flow_lbl` 是流标签,让路由器按标签快转(RFC 6437,通用互联网上很少大规模用);`payload_len` 只算载荷不含头部,16 位最大 65535,再大靠 Hop-by-Hop 的 Jumbo Payload 选项;`hop_limit` 就是改了名字的 TTL;`nexthdr` 是全篇的核心,下面单讲。 + +## `nexthdr` 与扩展头链:链接式扩展 + +`nexthdr` 取代了 IPv4 的 Protocol 字段,但它更灵活——它像链表节点的 `next` 指针,指向紧跟其后的下一个头部类型。没有扩展头时,它就是上层协议号(`IPPROTO_TCP`=6、`IPPROTO_UDP`=17);有扩展头时,它指向第一个扩展头。 + +``` +[ IPv6 Header (nexthdr=Routing) ] + -> [ Routing Header (nexthdr=TCP) ] + -> [ TCP Segment ] +``` + +每个扩展头的第一个字节都是自己的 `Next Header` 字段,一路串到底,最后一个才指向真正的上层协议。这种设计带来的好处很实在:中间路由器除了极个别的 Hop-by-Hop 头,根本不解析这些中间头,直接跳过去转发——比 IPv4 那个让硬件加速痛苦万分的变长 Options 强太多。 + +几种核心扩展头(类型号定义在 `include/net/ipv6.h`,不是 uapi 头):**Hop-by-Hop**(`NEXTHDR_HOP`=0)是唯一特权阶级,必须紧挨 IPv6 头、强迫路径上每个路由器处理,常用于 Router Alert 和 Jumbo Payload,滥用会拖垮转发效率;**Routing**(`NEXTHDR_ROUTING`=43)是 IPv4 源站选路的继任者,Type 0 因反射攻击风险被 RFC 5095 废弃;**Fragment**(`NEXTHDR_FRAGMENT`=44)是分片机制核心;**Destination Options**(`NEXTHDR_DEST`=60)是唯一允许出现两次的头(Routing 前给中转路由器看,Routing 后给最终目标看)。 + +> ⚠️ **踩坑**:MTU 是 IPv6 故障头号杀手。IPv6 规定**中间路由器绝对不分片**。包比 MTU 大?路由器不切碎,直接扔掉并回一个 ICMPv6 "Packet Too Big"。源主机收到后才缩小包重发,这就是强制的 Path MTU Discovery。所以**千万别封 ICMPv6**——一旦 "Packet Too Big" 回不来,源主机一直发大包全在半路被无声丢弃,表现就是小包通大包丢,典型 PMTU 黑洞。 + +## 进入内核:`ipv6_rcv()` 的入口 + +讲了这么多结构,来看系统怎么启动。`net/ipv6/af_inet6.c`(Linux 6.19)里,IPv6 子系统的总指挥是 `inet6_init()`,它注册 TCPv6/UDPv6 协议处理器、启动邻居发现和路由子系统。最关键一步是告诉网络核心:"收到以太网类型 `0x86DD` 的帧,交给我"——通过 `dev_add_pack()` 完成,和 IPv4 一模一样: + +```c +static struct packet_type ipv6_packet_type __read_mostly = { + .type = cpu_to_be16(ETH_P_IPV6), /* 0x86DD */ + .func = ipv6_rcv, + .list_func = ipv6_list_rcv, +}; + +static int __init ipv6_packet_init(void) +{ + dev_add_pack(&ipv6_packet_type); + return 0; +} +``` + +从此只要网卡收到 EtherType 是 `0x86DD` 的帧,内核就跳进 `ipv6_rcv()`。这是所有 IPv6 包(单播和组播,IPv6 没有广播)的必经之路。 + +`ipv6_rcv()` 的活儿是**第一道安检**(实现在 `net/ipv6/ip6_input.c`):版本号必须 6(`hdr->version != 6` 直接丢);从外面进来的包不能带环回地址(`ipv6_addr_loopback(&hdr->saddr/daddr)`);源地址不能是组播(`ipv6_addr_is_multicast`)。过检后,如果 `nexthdr == NEXTHDR_HOP` 立刻调用 `ipv6_parse_hopopts()` 解析逐跳选项,失败就统计 `IPSTATS_MIB_INHDRERRORS` 丢包。最后甩给 Netfilter 钩子 `NF_HOOK(NFPROTO_IPV6, NF_INET_PRE_ROUTING, ..., ip6_rcv_finish)`——你的 iptables/nftables raw 表 PREROUTING 链就在这里触发。 + +放行后进 `ip6_rcv_finish()`,这才是决定包**命运**的岔路口:还没绑目的缓存就调 `ip6_route_input()` 查路由表(底层走 `ip6_route_input_lookup()` → `fib6_rule_lookup()`,开了多路由表时先过 policy rule 再查 FIB6),拿到结果后调 `dst_input(skb)`。`dst_input` 是个神奇的小函数,它直接调用路由结果里预设的 `input` 回调:本地的扔进 `ip6_input`(本地投递)、给别人的扔进 `ip6_forward`(转发)、给一群人的扔进 `ip6_mc_input`(组播)、找不到路的扔进 `ip6_pkt_discard` 顺便回个 ICMPv6 不可达。本地投递路径里 `ip6_input_finish()` 会像剥洋葱一样顺着 `nexthdr` 链解扩展头,最后交给上层(`tcp_v6_rcv`/`udpv6_rcv`)。 + +## SLAAC:无状态自动配置的魔法 + +最让人困惑的反直觉现象:你根本没 `ip addr add`,`ip addr show` 里却已经躺着一个长得吓人的 128 位地址。没人配,地址哪来的?这就是 IPv6 的 **SLAAC(无状态地址自动配置)**,四步仪式。 + +**第一步,本地低调起步。** 系统启动时 IPv6 协议栈先给自己造个临时身份证——链路本地地址,前缀 `fe80::/64` 接上自己的 EUI-64 接口 ID。此时地址被打上 `IFA_F_TENTATIVE`(试探性)标记,只能处理邻居发现消息,不能收发普通流量。为什么?因为你得先确认屋里没有另一个同名者——这就是 DAD(重复地址检测),承接 03-net-neighbor 那一篇讲过的机制。DAD 通过、标志移除,地址才上岗。 + +**第二步,寻找指路人。** 链路本地地址确立后,主机(如果它不是路由器)主动调 `ndisc_send_rs()` 发 **Router Solicitation(RS)**,目标 `ff02::2`(所有路由器组播),ICMPv6 Type 133。像在走廊喊一嗓子"这儿有台机器要上网,路由器谁在?" + +**第三步,路由器布道。** 路由器回 **Router Advertisement(RA)**,源是路由器的链路本地地址,目标 `ff02::1`(所有节点),ICMPv6 Type 134。Linux 里这个角色通常由用户空间的 `radvd` 守护进程扮演,配置文件里写前缀(如 `2001:db8:abcd::/64`),它定期广播并响应 RS。RA 手里攥着主机最想要的两样东西:前缀信息,以及标志位(告诉你能用 SLAAC 无状态配,还是必须找 DHCPv6)。 + +**第四步,地址合成。** 主机收到 RA 拿到前缀,简单拼装:`IPv6 地址 = Prefix (from RA) + Interface ID`。前缀必须 64 位,Interface ID 通常是 MAC 算出来的 EUI-64。 + +但这里有个隐患:Interface ID 直接用 MAC,意味着你走到哪儿地址后 64 位都不变,Google、广告商换个 Wi-Fi 也能通过这串尾巴追踪到你。Linux 的解法是 **Privacy Extensions**(RFC 4941),靠 sysctl `net.ipv6.conf..use_tempaddr` 开启——在前缀后面接一个**随机**的 Interface ID 而不是 MAC,而且这个临时地址定期过期换新的。 + +> ⚠️ **别去 `make menuconfig` 翻 `CONFIG_IPV6_PRIVACY`**:这个 Kconfig 选项在 6.19 源码里**不存在**(`grep net/ipv6/Kconfig` 全空)。Privacy Extensions 是**运行期**开关,由 sysctl `use_tempaddr` 控制——置为 0 表示禁用,>0 才生成临时地址;`ipv6_create_tempaddr()` 在 `use_tempaddr <= 0` 时会直接打日志 `"use_tempaddr is disabled"` 退出(`net/ipv6/addrconf.c`,6.19 已核对)。顺带一提,6.19 里网卡创建时的默认 `addr_gen_mode` 已经是 `IN6_ADDR_GEN_MODE_STABLE_PRIVACY`(稳定隐私地址,靠 HMAC 算接口 ID),跟 `use_tempaddr` 的 RFC 4941 临时地址是两套机制,别混了。 + +地址不是永久的,`struct inet6_ifaddr`(`include/net/if_inet6.h`,第 39-40 行,已核对)里的 `valid_lft`/`prefered_lft` 字段对应 RA 里的两个生命周期:`valid_lft` 到期地址直接消失,`prefered_lft` 更短、一到点进入 deprecated 不再主动发起新连接但还能收。这套机制还能让网管只改 `radvd` 配置就平滑全网重编号(换 ISP 前缀),主机自动让旧地址过期、配上新前缀。 + +## NDISC 与 MLD:组播代替了广播 + +**NDISC(邻居发现)** 承接 03-net-neighbor,基于 ICMPv6 承载,彻底替代了 IPv4 的 ARP。它最精巧的设计是 **Solicited-Node 组播地址**:当接口配了一个单播/任播地址,内核必须算出一个对应的组播组加进去。算法在 `include/net/addrconf.h` 的 `addrconf_addr_solict_mult()` 里——保留单播地址的**低 24 位**,拼上固定前缀 `ff02::1:ff00::/104`。这样你找 `2001:db8::1234:5678` 的 MAC 时,不用广播吵醒全网,只往 `ff02::1:ff34:5678` 喊一声 NS 消息即可,碰撞概率 1/2²⁴ 可忽略。加入这个组的动作由 `addrconf_join_solict()` 完成(`net/ipv6/addrconf.c`)。NDP、自动配置的"悄悄话"全在链路本地范围内进行。 + +> ⚠️ **踩坑**:配置防火墙时很多人只盯着 Global 地址,把 `fe80::/10` 流量给封了,结果邻居发现挂了 ping6 不通——相当于在家把电话线掐了还奇怪快递员打不通电话。 + +**MLD(组播监听发现)** 是 IGMP 的 IPv6 版本,但塞进了 ICMPv6 口袋里(抓包看到的 MLD 上层协议永远写 ICMPv6,控制平面统一简化)。MLDv1(RFC 2710)只支持 ASM(任意源,大锅饭照单全收);MLDv2(RFC 3810)引入 SSM(源特定组播),允许主机用 INCLUDE/EXCLUDE 精确指定只听谁或屏蔽谁,这才是现在的标准。 + +内核两条加入路径。**路径 A 内核自动加入**:网卡一活过来,`ipv6_add_dev()` 立刻 `ipv6_dev_mc_inc()` 加入 `ff01::1`(接口本地所有节点)和 `ff02::1`(链路本地所有节点)这两个大喇叭频道,这是强制的,否则连 NDP 都做不了。若开了 `forwarding`,`dev_forward_change()` 会再加 `ff02::2`/`ff01::2`/`ff05::2` 三个所有路由器组。**路径 B 用户态请求**:`setsockopt(..., IPV6_JOIN_GROUP, ...)` 进内核的 `ipv6_sock_mc_join()`,既更新硬件过滤又把 socket 挂到成员列表上,同时发一个 MLDv2 Report——注意它的目标不是你加入的那个组,而是 `ff02::16`(所有 MLDv2 路由器),还带 Hop-by-Hop 的 Router Alert 选项(沿途路由器"别光转发,停下来看看"),ICMPv6 Type 143。调试时 `cat /proc/net/mcfilter6` 是最好用的账本,INCLUDE/EXCLUDE 源列表一目了然。 + +## 动手验证(待亲测) + +不写完整 `example/mini` 代码,先把验证方案钉死,等 QEMU 跑通再填真实输出。 + +**目标**:亲眼看到 SLAAC 全过程 + NDISC 表项 + MLD 组成员。 + +**步骤(待亲测输出)**: +1. `ip -6 addr show eth0` —— 看链路本地 `fe80::` 是否在 SLAAC 跑通前就出现(DAD 后 `tentative` 标志消失)。 +2. 在另一端起 `radvd` 广播前缀,观察主机自动合成全球单播地址。 +3. `ping6 ff02::1%eth0` —— 向所有节点喊话,看链路上 IPv6 邻居响应。 +4. `ip -6 neigh show` —— 看 NDISC 维护的邻居表(替代 `arp -n`),注意地址解析走的是 Solicited-Node 组播而非广播。 +5. `tcpdump -ni eth0 'icmp6'` —— 抓 RS(133)/RA(134)/NS(135)/NA(136),验证"IPv6 控制平面全是 ICMPv6"这件事。 +6. `cat /proc/net/mcfilter6` —— 看主机默认加入了哪些组播组(至少有 `ff02::1`)。 +7. `sysctl net.ipv6.conf.eth0.use_tempaddr` —— 验证 Privacy Extensions 开关(默认 0,设 >0 后看临时地址生成)。 + +> ⚠️ **待亲测**:以上输出全是占位。我们会拿到 QEMU ARM64 上把每条命令的真实输出记下来,重点验证 `ipv6_rcv` 那几道 sanity check 在抓包里的体现,以及关掉 ICMPv6 后 SLAAC 是否真的"静默失败"。 + +## 小结 + +IPv6 绝不是"IPv4 加长版"。它的设计哲学是**精简骨架、把复杂性交给扩展**:固定 40 字节头部去掉校验和,用 `nexthdr` 串起扩展头链让路由器轻松转发;取消广播,用 Solicited-Node 组播把 ARP 的以太网噪音压到极小;禁止中间路由器分片,强制 PMTUD;SLAAC 让主机即插即用拿到全球地址,Privacy Extensions 防追踪;NDISC 和 MLD 统统收编进 ICMPv6,控制平面大一统。 + +记住三件最容易翻车的事:**封 ICMPv6 必死**(PMTU 黑洞)、**关 forwarding 才是纯主机**(开了 forwarding 进路由器模式后,即使 `accept_ra` 还开着,RA 带来的地址/默认路由接纳行为也会受限——抓包里 RA 帧还在,但主机不再照单全收)、**链路本地地址是 NDISC 的命根子**(防火墙别封 `fe80::/10`)。 + +## 延伸阅读 + +- 源码:`net/ipv6/ip6_input.c`(`ipv6_rcv`/`ip6_rcv_finish` 接收路径)、`net/ipv6/ip6_output.c`(转发与输出)、`net/ipv6/route.c`(`ip6_route_input` → `ip6_route_input_lookup` → `fib6_rule_lookup` 收包查路由)、`net/ipv6/addrconf.c`(地址自动配置、`ipv6_add_dev`、MLD 组加入、`ipv6_create_tempaddr` 与 `use_tempaddr`)、`net/ipv6/ndisc.c`(邻居发现)、`net/ipv6/mcast.c`(MLDv2 Report 收发、`ipv6_sock_mc_join`)、`include/uapi/linux/ipv6.h`(`struct ipv6hdr`)、`include/net/ipv6.h`(`NEXTHDR_*` 扩展头类型常量)、`include/net/if_inet6.h`(`struct inet6_ifaddr` 的 `valid_lft`/`prefered_lft`)、`include/net/addrconf.h`(`addrconf_addr_solict_mult`/`addrconf_join_solict`)。 +- kernel.org 稳定索引页:[Networking 文档总入口](https://docs.kernel.org/networking/index.html)(在右侧索引找 IPv6 / multicast / neighbor 相关章节)、[IP Sysctl(`/proc/sys/net/ipv6/*` 变量语义)](https://docs.kernel.org/networking/ip-sysctl.html)(含 `forwarding`、`accept_ra`、`use_tempaddr` 各 sysctl)。 +- RFC:2460(IPv6 规范,现已被 8200 取代)、8200(IPv6)、4861(NDP)、4862(SLAAC)、3810(MLDv2)、4941(Privacy Extensions)、4291(IPv6 地址架构)。 + +--- +应用了 7 项修改,已与 Linux 6.19 源码核对: + +1. **[HIGH] 隐私扩展 / CONFIG** — 移除了错误的 `CONFIG_IPV6_PRIVACY` Kconfig 声明(在 `net/ipv6/Kconfig` 中不存在)。已替换为正确的运行时 sysctl `use_tempaddr`(当 `<=0` 时 `ipv6_create_tempaddr()` 会记录 `"use_tempaddr is_disabled"` — 已在 `net/ipv6/addrconf.c:1378` 核实),并添加了 `⚠️ callout`,以免读者去 `make menuconfig` 中寻找。同时澄清了 6.19 默认的 `addr_gen_mode = IN6_ADDR_GEN_MODE_STABLE_PRIVACY`(`addrconf.c:399`)与 RFC 4941 临时地址是不同的机制。 +2. **[MEDIUM] NEXTHDR_* 头文件位置** — 从误导性的 "内核头" / `include/uapi/linux/ipv6.h` 更改为正确的 `include/net/ipv6.h`(`NEXTHDR_HOP`=0 / `NEXTHDR_ROUTING`=43 / `NEXTHDR_FRAGMENT`=44 / `NEXTHDR_DEST`=60,已核实)。扩展阅读部分现在也列出了 `include/net/ipv6.h`。 +3. **[MEDIUM] ip6_route_input 后端** — 修正了输入路径的调用链:`ip6_route_input()` → `ip6_route_input_lookup()` → `fib6_rule_lookup()`(`route.c:2627/2341-2350`,已核实)。移除了不正确的 `fib6_lookup()`,该函数实际上属于配置路径(`route.c:3464`)。 +4. **[LOW] in6_addr union** — 添加了 `⚠️ callout`,解释了这是仅限内核的视图:`u6_addr16`/`u6_addr32` 在 uapi 头文件中受 `#if __UAPI_DEF_IN_ID_ADDR_ALT` 保护(用户空间默认为 0 → 仅 `u6_addr8` 可见)。已核实。 +5. **[LOW] 小结中的转发** — 从绝对化的 "进路由器模式收不到 RA" 放宽为:当 `forwarding=1` 时 RA 帧仍会到达(`ipv6_rcv` 不会丢弃它们),但 RA 地址/默认路由的接纳策略会改变。 +6. **[LOW] frontmatter 前置知识** — 在前置知识中添加了 `/tutorials/kernel/net/03-net-neighbor`(本页面在 DAD / Solicited-Node / NDISC 方面明确依赖于它);相关项保持不变。 +7. **[LOW] 延伸阅读链接** — 将 ip-sysctl 页面具有误导性的标题 "Linux IPv6 HOWTO(规范文档列表)" 改为正确的 "IP Sysctl(`/proc/sys/net/ipv6/*` 变量语义)"。Networking 索引和 ip-sysctl URL 均已核实有效。 + +风格保持不变:保留了 frontmatter 中的英文键,使用半角冒号,`sources:` 使用了 `notes:`,`🔨 整理中` 的 callout 和 6.19 版本提示,折腾博主腔调,章节结构,以及待亲测的 callout。添加了一个验证步骤 (`sysctl use_tempaddr`),以替换 Kconfig-驱动的框架,替换为实际启用该功能的 sysctl。 + +相关文件(未修改 — 输出是返回给父级的文本):`/home/charliechen/PenguinLab/document/tutorials/kernel/net/12-net-ipv6.md` \ No newline at end of file diff --git a/document/tutorials/kernel/net/13-net-ipsec.md b/document/tutorials/kernel/net/13-net-ipsec.md new file mode 100644 index 00000000..fd50169b --- /dev/null +++ b/document/tutorials/kernel/net/13-net-ipsec.md @@ -0,0 +1,181 @@ +--- +title: "IPsec 与 XFRM:内核怎么给数据包穿装甲" +slug: net-ipsec-xfrm +difficulty: intermediate +tags: [网络栈, IPsec, XFRM, ESP, NAT-T, VPN] +architectures: [arm64, x86_64, riscv] +kernel_version: "6.19" +maturity: drafting +prerequisites: + - /tutorials/kernel/net/04-net-ipv4 +related: + - /tutorials/kernel/net/04-net-ipv4 +sources: + - notes: document/notes/linux_kernel_networking/ch10.md + - notes: document/notes/linux_kernel_networking/ch10_1.md + - notes: document/notes/linux_kernel_networking/ch10_3.md + - notes: document/notes/linux_kernel_networking/ch10_4.md + - notes: document/notes/linux_kernel_networking/ch10_5.md + - notes: document/notes/linux_kernel_networking/ch10_6.md + - notes: document/notes/linux_kernel_networking/ch10_7.md + - notes: document/notes/linux_kernel_networking/ch10_8.md +--- + +# IPsec 与 XFRM:内核怎么给数据包穿装甲 + +> 🔨 **整理中** · 本篇机制对照 **Linux 6.19** 源码讲解,函数名 / 数据结构 / 字段已经逐一在 `net/xfrm/`、`net/ipv4/esp4.c`、`net/ipv4/xfrm4_input.c` 里核对过;但具体行号、`ip xfrm` 命令输出、strongSwan 跑隧道这些动手环节,等我们在 QEMU 里亲测过再升级成 ✅ 已锤炼。 + +## IPsec 到底在防谁 + +先回答一个老问题:**有了 HTTPS(TLS),为啥还要 IPsec?** + +TLS 保护的是"某一条连接"——它长在应用层和传输层之间,应用得主动配合才行。但如果你想让两台机器之间**所有**流量都加密(SSH、ICMP、甚至某个不走 TLS 的老旧服务),靠应用自己加 TLS 是不现实的。IPsec 干的就是这件事:它长在 **IP 层(L3)**,把"加密"这件事下沉到内核,应用完全无感——你的 TCP、UDP、ICMP 透明地被套上了装甲。 + +所以 IPsec 是企业 VPN 的事实标准。这一篇我们钻进内核,看一台 Linux 机器是怎么把一个明文 IP 包变成密文、又怎么在收到时还原的。整个引擎有个名字:**XFRM**(读作 transform)。 + +## 两种模式:传输 vs 隧道 + +IPsec 加密 IP 包,但"加密到哪一层"有两种选择,这就是传输模式和隧道模式的区别。 + +**传输模式(transport)**:原 IP 头不动,只加密 IP 载荷(也就是 TCP/UDP 头之后的内容)。像给快递员穿防弹衣——人还是那个人,路线不变,只是身体被保护了。适合端到端加密,IP 头必须能正常路由用,所以不能用私有地址。 + +**隧道模式(tunnel)**:把**整个原始 IP 包**(连同原 IP 头)当载荷加密,再套一个全新的 IP 头出去。新 IP 头是网关地址。像把装甲车再装进火车车厢——外面只看到一列火车从 A 地开往 B 地,车厢里装了什么谁也不知道。VPN 几乎都走隧道模式,因为它能把私有地址(`192.168.x.x`)藏在公网传输的新头后面。 + +模式信息存在 SA 的属性里:`struct xfrm_state` 的 `props.mode` 字段(`XFRM_MODE_TRANSPORT` / `XFRM_MODE_TUNNEL`)。这个字段后面在接收路径里会决定内核怎么"拆封装"。 + +## AH 和 ESP:ESP 才是主角 + +IPsec 协议族有两个成员: + +- **AH(Authentication Header)**:只做完整性 + 认证,不加密。像透明封套——能看但改不了。因为 AH 会校验 IP 头本身,NAT 一改地址就完蛋,所以实际几乎没人用。 +- **ESP(Encapsulating Security Payload)**:既加密又认证,协议号 **50**(`IPPROTO_ESP`)。这是真正的重头戏,也是本篇主角。 + +ESP 包是个"夹心饼干",结构长这样(RFC 4303): + +``` +[ SPI(4B) | Seq | 加密载荷 | Padding | PadLen | NextHdr | ICV ] +``` + +- **SPI**(Security Parameter Index):32 位,配合目的地址 + 协议号唯一标识一个 SA,是查表的钥匙。 +- **Seq**(序列号):抗重放攻击,收端维护滑动窗口,重复或太旧的包直接丢。 +- **ICV**(Integrity Check Value):防篡改指纹,改一个字节就对不上。 + +性能上有条演进线:老式做法是"先 AES-CBC 加密、再 HMAC-SHA 算 ICV",数据要扫两遍。现代内核偏好 **AEAD** 算法(如 AES-GCM),加密和认证一次搞定,配合 AES-NI 指令集,跑好几 Gbit/s 轻轻松松。 + +## XFRM 框架:策略当老板,状态当员工 + +XFRM 的设计哲学是"协议无关"——通用逻辑(策略管理、状态维护、垃圾回收)复用在 `net/xfrm/` 下,具体协议实现(ESP4/ESP6/AH4)单独放。它维护两本账: + +**SPD(Security Policy Database,安全策略库)**——这是"法律",决定哪些包该被 IPsec 处理。一条策略是 `struct xfrm_policy`,靠 **selector**(`struct xfrm_selector`,含源/目的地址、端口、协议、前缀长度)来匹配流量,`xfrm_selector_match()` 做比对。策略有个 `action` 字段:`XFRM_POLICY_ALLOW` 放行、`XFRM_POLICY_BLOCK` 拦截。策略不直接指定密钥,它只挂"模板"(`xfrm_vec[]`,最多 `XFRM_MAX_DEPTH` 个),把"先 ESP 再封装"这种复合动作描述出来。 + +**SAD(Security Association Database,安全关联库)**——这是"武器",真正干活的家伙。一个 SA 是 `struct xfrm_state`,里面装满敏感信息:算法指针(`aalg`/`ealg`/`aead`)、重放窗口(`replay_window`)、模式(`props.mode`)、身份证 `id`(目的地址 + SPI + 协议号三元组)。**SA 是单向的**,双向通信要两个 SA(一进一出)。 + +SA 在内核里挂三张哈希表(在 `struct netns_xfrm` 里):`state_bydst`、`state_bysrc`、`state_byspi`——三把钥匙开同一扇门,根据手头线索选入口。`xfrm_state_lookup()` 走 `state_byspi`,是接收路径最常用的。 + +用户空间怎么跟内核对话?靠 **Netlink**(`NETLINK_XFRM`)。你在命令行敲 `ip xfrm state add ...`,会变成 `XFRM_MSG_NEWSA` 消息飞进内核,被 `xfrm_netlink_rcv()` 接住。密钥协商(IKE)那部分是用户空间守护进程(strongSwan / Charon)的活,谈完结果通过 Netlink 下发给内核,内核只管执行。 + +## SA 的生命周期:状态机长什么样 + +SA 不是凭空冒出来的。`xfrm_state_alloc()`(`net/xfrm/xfrm_state.c:731`,Linux 6.19)负责分配:从 slab 缓存 `xfrm_state_cache` 拿一块内存,引用计数 `refcnt` 初始化为 1,给三张哈希表的节点和两个定时器(`mtimer` 管寿命、`rtimer` 管重放)挂上回调,生命周期限制 `lft` 默认填 `XFRM_INF`(无限)。 + +协商好的 SA 通过 `xfrm_state_add()`(`xfrm_state.c:1887`)插入 SAD,内部走 `__xfrm_state_insert()`(`xfrm_state.c:1721`),把节点同时挂进三张哈希表。SA 有个状态字段 `km.state`:`XFRM_STATE_VALID`(可用)、`XFRM_STATE_ACQ`(正在协商的"幼虫"态)、`XFRM_STATE_DEAD`(已死)等。接收路径里如果查到 SA 但状态不是 `VALID`,直接记一笔 `XFRMINSTATEINVALID` 然后丢包。 + +删除不直接释放内存。`__xfrm_state_delete()`(`xfrm_state.c:811`)先把状态置成 `XFRM_STATE_DEAD`、从哈希表摘下来;真正的内存回收走延迟路径——`__xfrm_state_destroy()`(`xfrm_state.c:800`)把对象塞进 GC 链表 `xfrm_state_gc_list`,再 `schedule_work()` 异步清理。这是内核老套路:热路径里只摘链表,把昂贵的 `kfree` 推到工作队列,避免抖动。 + +## ESP 怎么挂钩进协议栈 + +ESP 协议处理器要"双向注册"——既告诉 XFRM 自己的处理函数,又告诉 IP 协议栈"协议号 50 的包交给我"。6.19 里这步在 `esp4_init()`(`net/ipv4/esp4.c:1184`): + +```c +// net/ipv4/esp4.c:1165 —— ESP 的"功能说明书" +static const struct xfrm_type esp_type = { + .owner = THIS_MODULE, + .proto = IPPROTO_ESP, + .flags = XFRM_TYPE_REPLAY_PROT, // 我支持抗重放 + .init_state = esp_init_state, + .destructor = esp_destroy, + .input = esp_input, // 接收时的解密回调 + .output = esp_output, +}; + +// net/ipv4/esp4.c:1176 —— 注册给 IP 协议栈的入口 +static struct xfrm4_protocol esp4_protocol = { + .handler = xfrm4_rcv, // 收到 proto=50 的包调它 + .input_handler = xfrm_input, // 通用 XFRM 接收中心 + .cb_handler = esp4_rcv_cb, + .err_handler = esp4_err, + .priority = 0, +}; +``` + +`esp4_init()` 先 `xfrm_register_type(&esp_type, AF_INET)` 把类型挂进 XFRM 的 `type_map`,再 `xfrm4_protocol_register(&esp4_protocol, IPPROTO_ESP)` 注册给 IP 栈。注意后半步要是失败了,会 `xfrm_unregister_type()` 回滚前一步——这种"一来一回干干净净"是内核模块注册的范本。(这里得提醒一句:老的 IPsec 书里写的是 `inet_add_protocol()` + `struct net_protocol`,6.19 已经统一成 `xfrm4_protocol` + `xfrm4_protocol_register()` 了,看老书要对齐一下。) + +AH、IPCOMP 也都把 `.handler` 指向同一个 `xfrm4_rcv`,因为进门后查表、验证重放这些逻辑是通用的,没必要写三遍。 + +## 接收路径:xfrm_input 的拆壳之旅 + +现在真的有一个 ESP 传输模式包到达本机。它在 `ip_local_deliver_finish()` 被发现协议号是 50,于是调用 `xfrm4_rcv()`,转手扔给通用中心 `xfrm_input()`(`net/xfrm/xfrm_input.c:463`)。这是拆壳的核心,走一遍: + +**第一步:查 SAD 找钥匙。** 拿 SPI、目的地址、协议号去 `state_byspi` 查: + +```c +// xfrm_input.c:590(Linux 6.19) +x = xfrm_input_state_lookup(net, mark, daddr, spi, nexthdr, family); +if (x == NULL) { + XFRM_INC_STATS(net, LINUX_MIB_XFRMINNOSTATES); // 静默丢包 + goto drop; +} +``` + +(老书里这步叫 `xfrm_state_lookup()`,6.19 收敛成了 `xfrm_input_state_lookup()`。)查不到就丢——加密包没法发 ICMP 错误,因为你根本不知道它是谁发的。 + +**第二步:调协议的解密回调。** 拿着 SA 锁 `spin_lock(&x->lock)`,校验状态 `VALID`、校验 `encap_type` 匹配、`xfrm_replay_check()` 查重放窗口、`xfrm_state_check_expire()` 看寿命,全过了才解密: + +```c +// xfrm_input.c:658 +nexthdr = x->type->input(x, skb); // 对 ESP 就是 esp_input() +``` + +`esp_input()` 调 Crypto API 解密 + 验 ICV。返回的 `nexthdr` 是剥掉 ESP 头尾后内层载荷的协议号(TCP=6、UDP=17)。校验通过就 `xfrm_replay_advance(x, seq)` 推进重放窗口,统计 `x->curlft.bytes += skb->len`、`x->curlft.packets++`。 + +**第三步:按模式重整包结构。** 关键一句 `XFRM_MODE_SKB_CB(skb)->protocol = nexthdr`(`xfrm_input.c:692`)把内层协议号存进 skb 控制块,然后 `xfrm_inner_mode_input()` 分模式处理。隧道模式(`xfrm4_remove_tunnel_encap`)直接把外层新 IP 头拆掉、露出内层完整 IP 包,`gro_cells_receive()` 重新进栈;传输模式(`xfrm4_transport_input`,`xfrm_input.c:390`)做更精细的活——`memmove` 把 IP 头移回原位、重算 `tot_len`,相当于把快递单上的"协议=ESP"改回"协议=TCP"。 + +**第四步:重新注入协议栈。** 传输模式最后调 `afinfo->transport_finish()`(`xfrm_input.c:745`),通过 Netfilter `PRE_ROUTING` hook 再扔回 `ip_local_deliver()`。此刻 IP 头的协议号已是 TCP,内核像处理刚从网卡收到的普通包一样交给上层。一次加密包的接收之旅就此结束。 + +> 关于这部分,老书(包括我们参考的笔记 ch10_5)讲的是 `xfrm4_transport_finish()` 手动改 `iph->protocol`、`ip_send_check()` 重算校验和、再走 `NF_HOOK`。6.19 这套被重构成了上面 `xfrm_inner_mode_input()` + `transport_finish` 的统一接口,思想一样(改头、重注栈),代码组织变了。读到老描述别慌,对照 6.19 源码就是。 + +## NAT-T:给加密包套个 UDP 信封 + +现实总爱打脸:你在家 NAT 后面起 IPsec VPN,隧道死活起不来。因为 NAT 设备改 IP 地址后必须同步改 TCP/UDP 校验和(校验和覆盖伪首部),可 ESP 把 TCP/UDP 头全加密了,NAT 看不懂也算不了校验和——结果只能丢包。 + +IETF 的解法是 RFC 3948(**NAT-T,NAT Traversal**):既然你不认 ESP,那我在 IP 头和 ESP 头之间硬塞一个 **UDP 头**,伪装成你最爱的 UDP。NAT 只看 IP 和 UDP 端口改写,大家皆大欢喜。几个硬规矩:只救 ESP(AH 校验 IP 头,NAT 一改就废);必须靠 IKEv2 协商开启(不能手动密钥);UDP 端口固定 **4500**;还要每 20 秒发一次 keepalive 刷存在——保活包载荷就一个字节 `0xFF`。 + +内核收到这种"穿了马甲"的包,处理在 `xfrm4_udp_encap_rcv()`(`net/ipv4/xfrm4_input.c:161`),核心是 `__xfrm4_udp_encap_rcv()`(`xfrm4_input.c:81`): + +```c +case UDP_ENCAP_ESPINUDP: + if (len == 1 && udpdata[0] == 0xff) + return -EINVAL; // keepalive,吃掉 + else if (len > sizeof(struct ip_esp_hdr) && udpdata32[0] != 0) + len = sizeof(struct udphdr); // 真 ESP 包,剥掉 UDP 头 + else + return 1; // IKE 包,放行给 UDP +``` + +剥掉 UDP 头后调 `xfrm4_rcv_encap(skb, IPPROTO_ESP, 0, encap_type)`,剩下就和普通 ESP 包一模一样——查 SA、解密、验证、重组,全复用前面的 `xfrm_input()` 路径。NAT-T 本质就是一层"骗 NAT 的信封",到了目的地内核撕开信封,照常处理里面的加密文件。 + +## 小结 + +IPsec 是长在 IP 层的加密引擎,让应用无感地获得全流量保护。它把"法律"和"武器"分离:**策略(`xfrm_policy`)**定规矩不干活,**状态(`xfrm_state`)**拿密钥和算法真正加解密;两者通过模板 `xfrm_vec` 衔接。ESP(协议号 50)是主角,既加密又认证,现代用 AEAD(AES-GCM)一把梭。 + +接收路径 `xfrm_input()` 是拆壳核心:查 SA(`state_byspi`)→ 协议解密回调(`esp_input`)→ 按模式重整(传输模式改头、隧道模式剥头)→ 重注协议栈。SA 生命周期靠 slab 分配 + 引用计数 + 异步 GC 管理。NAT-T 给 ESP 套 UDP 信封(端口 4500),骗过 NAT,到了内核 `xfrm4_udp_encap_rcv` 撕掉信封复用解密路径。 + +记住三个抓手:**策略与状态分离**(老板与员工)、**SA 三张哈希表**(三把钥匙开一门)、**NAT-T 的 UDP 信封**(曲线救国的妥协)。 + +## 延伸阅读 + +- 源码:`net/xfrm/xfrm_input.c`(`xfrm_input` 接收主循环)、`net/xfrm/xfrm_state.c`(SA 状态机与 GC)、`net/xfrm/xfrm_policy.c`(策略匹配);`net/ipv4/esp4.c`(IPv4 ESP 实现,`esp_type` / `esp4_init`)、`net/ipv4/xfrm4_input.c`(NAT-T 的 `xfrm4_udp_encap_rcv`);头文件 `include/net/xfrm.h`、`include/uapi/linux/xfrm.h`。 +- kernel.org 文档:[Networking — IPsec (XFRM) subsystem](https://docs.kernel.org/networking/index.html)(在 networking 总索引里定位 XFRM/IPsec 章节)、[IPsec 通用的 admin-guide 入口](https://docs.kernel.org/admin-guide/index.html);协议规范 RFC 4303(ESP)、RFC 3948(NAT-T)。 +- 用户态实践:strongSwan 官方文档(IKEv2 + NAT-T 默认开启),`iproute2` 的 `ip xfrm state` / `ip xfrm policy` 子命令(`man ip-xfrm`)。 + +> ⚠️ **动手待亲测**:本篇的命令输出全部留空。计划在 QEMU 里跑两条:① `ip xfrm state add` / `ip xfrm policy add` 手动建一条 SA,`ip xfrm state show` / `cat /proc/net/xfrm_stat`(看 XFRM MIB 计数器对应 `XfrmInNoStates` 等);② 用 strongSwan 在两台 QEMU 之间起一条隧道,`tcpdump` 抓 ESP 包(隧道模式)和 UDP 4500(NAT-T),把真实抓包贴进来再升级。 \ No newline at end of file diff --git a/document/tutorials/kernel/net/14-net-wireless.md b/document/tutorials/kernel/net/14-net-wireless.md new file mode 100644 index 00000000..96938225 --- /dev/null +++ b/document/tutorials/kernel/net/14-net-wireless.md @@ -0,0 +1,220 @@ +--- +title: mac80211:内核怎么管一块无线网卡 +slug: net-mac80211 +difficulty: intermediate +tags: [无线网络, mac80211, cfg80211, 802.11n, MLME, 节电] +architectures: [arm64, x86_64, riscv] +kernel_version: "6.19" +maturity: drafting +prerequisites: + - /tutorials/kernel/net/01-net-overview +related: + - /tutorials/kernel/net/01-net-overview +sources: + - notes: document/notes/linux_kernel_networking/ch12.md + - notes: document/notes/linux_kernel_networking/ch12_1.md + - notes: document/notes/linux_kernel_networking/ch12_2.md + - notes: document/notes/linux_kernel_networking/ch12_3.md + - notes: document/notes/linux_kernel_networking/ch12_4.md + - notes: document/notes/linux_kernel_networking/ch12_5.md + - notes: document/notes/linux_kernel_networking/ch12_6.md + - notes: document/notes/linux_kernel_networking/ch12_7.md +--- + +# mac80211:内核怎么管一块无线网卡 + +> 🔨 **整理中** · 这篇是从读书笔记整理出来的骨架,**函数与数据结构已对照 Linux 6.19 源码核对**(cfg80211/mac80211 的 API、省电常量、聚合流程都已逐条验过);但**具体行号与命令输出待 QEMU 亲测核对**——行号会随版本漂,等我们在 `mac80211_hwsim` 上跑通再固化。笔记里有些老版本结论(如省电缓冲 128 包、A-MSDU 只在 RX 支持)在 6.19 已经变了,下文按 6.19 实际情况讲。 + +## 无线不是以太网的一个变种 + +很多人第一反应是:无线网卡嘛,不就是个插上驱动、起个 `wlan0`、能发包的以太网卡?真这么想,写驱动会死得很惨。 + +无线和有线在 `/etc/network/interfaces` 里长得像,但内核眼里是两个物种。两个根本差异决定了它必须有自己的一套子系统: + +1. **共享介质,没法边说边听**。以太网用 CSMA/CD——我说话时撞了车我立刻知道(Collision Detection)。无线不行:设备一发射,自己的发射功率把整个信道盖住,根本听不见别人。而且还有"隐藏节点"——你离 A 近、离 B 远,A、B 同时说,你只听见 A,以为空闲就开口,结果在 B 那里成了干扰。所以 802.11 改用 **CSMA/CA(Collision Avoidance,冲突避免)**:发之前先听,发了之后等对方回 ACK,没回就默认撞了、重传。这一下,内核就得替每张网卡维护**重传队列、定时器、状态机**——以太网里完全不用操心的事。 +2. **链路天生不可靠**。空气里微波炉、蓝牙、隔壁路由器都在抢通道,丢包是常态。802.11 强制(除广播/组播外)每个收到的帧都回 ACK。于是无线驱动不是在搬运数据,是在不停握手。 + +再加上**移动性**(手机端着走要换 AP)和**省电**(电池设备要时不时关接收机),无线复杂度直接起飞。Linux 给它单开了一层:**mac80211**(软 MAC 框架),上面还有 **cfg80211**(配置框架)。这篇就顺着这两个子系统,把机制扒到底。 + +## cfg80211:配置框架(net/wireless/) + +cfg80211 是无线世界的"户籍处 + 规矩办":它定义了**用户态和内核沟通无线事务的统一 API**(nl80211 这个 netlink 家族),管着**监管域(Regulatory,哪些频段/功率在哪个国家合法)**,还维护着所有无线设备(`wiphy`)的全局列表。 + +核心数据结构是 `struct wiphy`(`include/net/cfg80211.h`),你可以理解成"一块无线硬件的身份证"——它装着这张卡能干什么(支持的接口模式、频段、加密能力)。每注册一个 `wiphy` 都对应一个内部包装结构 `struct cfg80211_registered_device`(`net/wireless/core.c`,用 `LIST_HEAD(cfg80211_rdev_list)` 在 `core.c:46` 串成全局链表)。 + +驱动(包括 mac80211)要接入 cfg80211 的标准三步: + +1. `wiphy_new_nm(&ops, sizeof_priv, name)` —— 申领一个 `wiphy`,绑一组 `struct cfg80211_ops` 回调(`net/wireless/core.c`,`wiphy_new_nm` 在第 446 行附近)。 +2. 填好 `wiphy` 的能力字段(bands、cipher_suites、interface_modes 等)。 +3. `wiphy_register(wiphy)` —— 上户口(第 732 行附近),从此对用户态可见。 + +mac80211 自己就是 cfg80211 的一个"客户端驱动"——`net/mac80211/main.c` 里 `ieee80211_alloc_hw_nm()` 调的就是 `wiphy_new_nm(&mac80211_config_ops, ...)`。 + +> 顺便钉个概念:上层常说的"Channel 6",硬件只认"Frequency 2.437 GHz",二者靠 `ieee80211_channel_to_frequency()`(`net/wireless/util.c`)翻译。给寄存器塞 Channel 6 它一脸懵。 + +## mac80211:MAC 层驱动框架(net/mac80211/) + +cfg80211 只管"配置"。真正干协议脏活的是 mac80211——它是个**软 MAC 框架**:把扫描、认证、关联、加密、重传、聚合这些通用逻辑全搬进内核,驱动只负责和硬件打交道(收发原始帧、切信道、设密钥)。 + +这背后是话语权的转移。早年 FullMAC 驱动把 MLME(MAC 层管理)全丢给闭源固件,结果 802.11 修正案(a/b/g/e/i/n...)铺天盖地而来,固件跟不上。mac80211(2007 年并进 2.6.22,前身 Devicescape 的 d80211)把控制权夺回开源内核。现在主流无线驱动(Intel iwlwifi、ath9k/ath10k/ath11k...)都是 mac80211 的 SoftMAC 驱动。 + +### 入口:ieee80211_alloc_hw_nm() 与 ieee80211_hw + +一切始于 `struct ieee80211_hw`(`include/net/mac80211.h`)——硬件设备的身份证。它有个 `void *priv` 指针,是驱动的私房钱,内核完全不看里面是什么,只有驱动自己知道(Intel 的 `iwl_priv`、Atheros 的私有结构都挂这儿)。通用框架和私有实现被切得干干净净。 + +驱动初始化标准三步(`net/mac80211/main.c`): + +1. `ieee80211_alloc_hw(priv_data_len, &ops)` → 实际调 `ieee80211_alloc_hw_nm()`(第 791 行)。 +2. `ieee80211_register_hw(hw)`(第 1120 行)—— 上户口。 +3. 收到包时调 `ieee80211_rx_irqsafe(hw, skb)`(中断上下文安全版,`net/mac80211/rx.c:5584`,内部转调 `ieee80211_rx_list()`)。 + +`ieee80211_alloc_hw_nm()` 里有个漂亮设计:它把 `struct wiphy` + `struct ieee80211_local` + 驱动私有数据**三件套打包成一块连续内存**,靠 `ALIGN(sizeof(*local), NETDEV_ALIGN)` 卡出对齐边界(`main.c:834` 那段注释画了那张内存图)。`local->hw.priv = (char *)local + ALIGN(...)`(第 928 行)——一句指针算术,零拷贝拿到私有区。 + +进去第一件事是个硬性体检(`main.c:800`): + +```c +if (WARN_ON(!ops->tx || !ops->start || !ops->stop || !ops->config || + !ops->add_interface || !ops->remove_interface || + !ops->configure_filter || !ops->wake_tx_queue)) + return NULL; +``` + +这 8 个回调是 mac80211 的命根子,少一个直接拒绝注册。 + +### 驱动的承诺书:struct ieee80211_ops + +`struct ieee80211_ops`(`include/net/mac80211.h:4515`)是一堆函数指针,是驱动写给内核的承诺书。核心几个: + +- **`tx()`**(结构体首个成员,`mac80211.h:4516`):每次内核要发包都调它,正常返回 `NETDEV_TX_OK`。 +- **`start()`(`mac80211.h:4519`)/ `stop()`(`mac80211.h:4520`)**:电源开关。`start()` 激活硬件开接收,`stop()` 关机断电。 +- **`add_interface()` / `remove_interface()`**:`ifconfig wlan0 up` 时触发——"我要把虚拟网卡绑上来了,硬件那边准备好接客没?" +- **`config()`**:调参旋钮。切信道(CH6→CH11)就是内核调它通知硬件改频率。 +- **`configure_filter()`**:看门狗设置——"我只要这几种包,其余别吵我"。 + +## 网络拓扑:STA / IBSS / AP / Mesh / WDS + +帧不是在真空中飞的,是在某种"社会关系"里产生消费的。802.11 定义了几种身份,内核里对应 `enum nl80211_iftype`(`include/uapi/linux/nl80211.h`:注释从 3617 起,枚举本体在 3645 行): + +| 模式 | 宏 | 干什么 | +|:---|:---|:---| +| Managed(STA) | `NL80211_IFTYPE_STATION` | 客户端,你手机连 WiFi 就是它 | +| AP | `NL80211_IFTYPE_AP` | 接入点,网卡变路由器 | +| Ad Hoc(IBSS) | `NL80211_IFTYPE_ADHOC` | 无政府,没 AP 大家平等 | +| Mesh | `NL80211_IFTYPE_MESH_POINT` | 网状网,路由和传输纠缠 | +| WDS | `NL80211_IFTYPE_WDS` | 无线分发系统,做桥接 | +| Monitor | `NL80211_IFTYPE_MONITOR` | 嗅探者,空中所有包都收 | + +**Infrastructure BSS(基础架构模式)** 是绝大多数人的日常:一个 AP(中心节点)围着一圈 client station,构成一个 BSS。关联是排他的——一个客户端同时只能绑一个 AP。AP 会发一个 **AID(Association ID,1~2007,上限 `IEEE80211_MAX_AID`)** 在 BSS 内唯一标识你。多 AP 用网线连起来覆盖大区域叫 **ESS**。注意 BSS A 的广播飘到 BSS B,BSS B 的站点会因 BSSID 对不上而**丢弃**——"隔壁教室点名,不是叫我"。 + +**IBSS(Ad Hoc)** 没人管事,BSSID 用 `get_random_bytes()` 随机生成 48 位地址。命令行 `iw wlan0 ibss join 名字 2412` 一敲,内核走 `ieee80211_ibss_join()` → `ieee80211_sta_create_ibss()`(`net/mac80211/ibss.c:1287`),网络就诞生了。麻烦的省电协调(ATIM 机制)mac80211 **不支持**,这个坑别指望。 + +AP 本质就是块加了以太网口/LED 的无线网卡,真正让它变 AP 的是用户态的 **hostapd**——它通过 nl80211 注册自己,专门收管理帧,是那个"窗口后盖章的人"。 + +## MLME:扫描/认证/关联/漫游 + +MAC 层管理实体(MLME)像个接线员,处理连网那些琐事。 + +**扫描**有两种:**被动扫描**(闭嘴听 Beacon)和**主动扫描**(跳信道、发 Probe Request、等 Probe Response)。决定走哪条的是**信道(channel)上的 flag**,不是模式开关——5GHz 高端频段、DFS 信道这类法律禁止乱喊的,会带 `IEEE80211_CHAN_NO_IR`(No Initiating Radiation,不主动辐射,`include/net/cfg80211.h:132`),DFS 频段还会同时带 `IEEE80211_CHAN_RADAR`;`net/mac80211/scan.c` 里多处就是判 `(flags & (IEEE80211_CHAN_NO_IR | IEEE80211_CHAN_RADAR))` 直接走被动路径(scan.c:856、918、1047)。主动扫描由 `ieee80211_request_scan()` 触发,"喊一嗓子"是 `ieee80211_send_probe_req()`,切信道调 `ieee80211_hw_config(... IEEE80211_CONF_CHANGE_CHANNEL)`。 + +> ⚠️ **别认错标志名**:网上老资料(和旧版代码)里见过 `IEEE80211_CHAN_PASSIVE_SCAN` 这个名字,但它在当前 mac80211/cfg80211 的 channel flag 里**已经不存在**了——`grep` 整个 `net/mac80211/`、`net/wireless/`、`include/net/cfg80211.h` 全是空。它只剩个废弃别名残留在 nl80211 用户态 ABI 里(`NL80211_FREQUENCY_ATTR_PASSIVE_SCAN = NL80211_FREQUENCY_ATTR_NO_IR`,`nl80211.h:4503`)。现役机制认 `IEEE80211_CHAN_NO_IR`。 + +**认证**——注意**认证≠加密**。WPA2/3 的密钥协商在四步握手阶段,这里的 MLME 认证更像"敲门问好"。`ieee80211_send_auth()`(`net/mac80211/util.c:1076`)发 `IEEE80211_STYPE_AUTH` 帧。最古老的 **Open System(`WLAN_AUTH_OPEN`)** 是世上最虚伪的安全机制:客户端说"我想认证",AP 说"通过"。零安全性,但协议必经——哪怕后面 WPA2 加密,第一步也得走它把状态机推过去。 + +**关联**是正式入座登记。`ieee80211_send_assoc()`(`net/mac80211/mlme.c:2141`)发 `IEEE80211_STYPE_ASSOC_REQ`,帧里带速率集、11n/ac/ax 能力、要不要省电等。AP 回 Response 带状态码 0 表示成功,给你分配 AID。 + +**漫游**本质是同一 ESS 内换 AP,走 **Reassociation**,复用 `ieee80211_send_assoc()`,只是子类型换成 `IEEE80211_STYPE_REASSOC_REQ`,帧里多带一个"我上一个连的 AP 的 BSSID"——新 AP 拿着它去老 AP 那里把缓存数据同步过来,实现无缝切换。 + +## 节电模式(Power Save):AP 当保姆 + +电池设备得时不时关接收机睡觉。问题来了:**你睡觉时别人发给你的微信怎么办?** 有线网那头永远有电,无线这是个大问题。 + +**进入节电**:客户端发一个 **Null Data**(空数据包,`IEEE80211_STYPE_NULLFUNC`,只有头没载荷),把帧控制里的 **PM(Power Management)位置 1**,AP 就懂"这哥们睡了"。AP 在内核里为每个关联站点维护一个**省电缓冲队列 `ps_tx_buf`**,把发给睡眠站点的单播包存起来。 + +⚠️ **注意版本差异**:笔记写"每站 128 包",但 **6.19 实际是 `STA_MAX_TX_BUFFER = 64`**(`net/mac80211/sta_info.h:860`),而且是**每 AC 一个队列** `ps_tx_buf[IEEE80211_NUM_ACS]`(sta_info.h:739)——QoS 优先级分开放,不是单链表。组播/广播走共享的 `bc_buf`,上限 `AP_MAX_BC_BUFFER = 128`(`net/mac80211/ieee80211_i.h:46`);还有个全局总量 `TOTAL_MAX_TX_BUFFER = 512`(ieee80211_i.h:51)。超了就 FIFO 丢老的——睡死过去的手机醒来可能丢最早几条消息,永远找不回。 + +**醒来取货**:AP 周期性(通常 100ms)广播 **Beacon**,里头带 **TIM(Traffic Indication Map)**——一个 2008 位的位图,对应 AID 0~2007,你的队列有货就把你的位置 1。客户端醒来抓 Beacon,用 `ieee80211_check_tim()`(`include/linux/ieee80211.h:2836`)查自己那一位,是 1 就发 **PS-Poll** 控制帧去提货。AP 发货时每个包帧控制里带 `IEEE80211_FCTL_MOREDATA`:1 表示"还有货接着 Poll",0 表示"最后一个拿完睡吧"。组播包的 AID=0,配 **DTIM**(每几个 Beacon 出现一次)周期性批量倒 `bc_buf`,极大省电。 + +> 别把 **Power Save Mode**(协议层,运行时睡几毫秒、AP 缓存)和 **Power Management**(系统层,合盖 suspend、`net/mac80211/pm.c` 的 suspend/resume 回调)搞混——在 suspend 回调里处理 TIM 位图是走错房间。 + +## 802.11n High Throughput:MIMO + 帧聚合 + +802.11n(2009 定稿)把无线拖进高速时代,物理层速率理论上飙到 600 Mbps。两大杀器: + +**MIMO(多输入多输出)**:AP 和客户端两边都装多根天线,"你三四张嘴一起喊,我三四只耳朵同时听",距离更远、速度更快。同时霸占 2.4G 和 5G 两个频段。 + +**Packet Aggregation(帧聚合)**:以前一封信一封信寄,信封本身有成本;现在塞一本厚书一次发走。配 **Block Ack(BA)**——发一堆包,对方回一个 BA 说"那十个我都收到了",省掉中间傻等的间隔。两种聚合别搞混: + +- **A-MSDU**:多个上层包捏一起、外面只包一个 MAC 头。mac80211 里**接收方向支持**;发送方向在 6.19 也有条件支持——`net/mac80211/tx.c` 的 fast-xmit 路径里有 `drv_can_aggregate_in_amsdu()`(tx.c:3489)+ `ieee80211_amsdu_prepare_head()`(tx.c:3333),能把多个上层包在 TX 侧聚成 A-MSDU,但依赖驱动/硬件支持。**不依赖 Block Ack**。 +- **A-MPDU**:多个已包好 MAC 头的完整 MPDU 打包,**必须配 Block Ack**——本节主角。 + +> 💡 **旧结论要更新**:不少老资料(含我们这份笔记)写"A-MSDU 只在 RX 方向支持"——这在老版本对,但 6.19 已经变了,TX 侧的 A-MSDU 聚合是现役代码。读源码别照搬过时结论。 + +**建立 BA 会话**绑定在某个 **TID**(流量标识符)上,分**发起侧**和**响应侧**两半,代码分别在两个文件里(别当成一锅炖): + +- **发起侧(`net/mac80211/agg-tx.c`)**:发起者 `ieee80211_start_tx_ba_session()`(agg-tx.c:600)置状态位 `HT_AGG_STATE_WANT_START`,调 `ieee80211_send_addba_request()`(agg-tx.c:61)发 **ADDBA Request**(带 Buffer Size、TID)。发完一波 A-MPDU 后发 **BAR**(`ieee80211_send_bar()`,agg-tx.c:103)催确认,BAR 里带 **SSN(起始序列号)**,结构体是 `struct ieee80211_bar`(`include/linux/ieee80211-ht.h:47`,不在 `ieee80211.h` 里)。 +- **响应侧(`net/mac80211/agg-rx.c`)**:本机作为响应者收到对方的 ADDBA Request 时,`ieee80211_process_addba_request()`(agg-rx.c:471)处理,置 `HT_AGG_STATE_OPERATIONAL`,`ieee80211_send_addba_resp()`(agg-rx.c:233)发响应成交。 + +BA 有 Immediate(立即回,性能好)和 Delayed(先 ACK 缓会再回)两种。结束时 `ieee80211_send_delba()` 收场。**1 秒**没回音定时器触发、掐死会话。边界:A-MPDU 最大 65535 字节;聚合只支持 **AP 和 Managed 模式**,IBSS 规范不支持。 + +## RX/TX 流水线与责任链 + +收包主角是 `ieee80211_rx_list()`(`net/mac80211/rx.c:5417`)——6.19 早不是老的单一 `ieee80211_rx()`,而是 list 化批量处理,`ieee80211_rx_irqsafe()`(rx.c:5584)内部转调它。驱动把 SKB 递上来时,在 SKB 控制缓冲区塞了张小纸条 `ieee80211_rx_status`(用 `IEEE80211_SKB_RXCB()` 抽出来),写着 FCS 校验、信号强度等。flag 带 `RX_FLAG_FAILED_FCS_CRC` 就是废包。 + +收发都用**责任链模式**:一串处理器各看一眼包,返回三类结果(`net/mac80211/drop.h:86` 附近): + +- `RX_CONTINUE` —— 没我的事,下一个继续。 +- `RX_QUEUED` —— 我接管了。 +- `RX_DROP` —— 垃圾,丢。 + +比如 `ieee80211_rx_h_mgmt_check()`(`rx.c:3490`)检查"号称管理帧但连 24 字节都没有",直接返回 `RX_DROP`——层层过滤保证非法包跑不满 CPU。6.19 起 `RX_DROP` 不是光秃秃一个值了,底层带**细分 drop reason code**(`drop.h` 里 2023 年重构过的一套,`R(RX_DROP_U_RUNT_ACTION)` 这种就是它),把"为什么丢"记进 SKB 给监控/统计用。TX 侧 `ieee80211_tx()` → `__ieee80211_tx_prepare()` → `invoke_tx_handlers()`(`CALL_TXH` 串起来),绿灯后 `__ieee80211_tx()` 真正推向驱动 `tx()` 回调。 + +## 动手验证方案(待亲测) + +下面这些命令等我们在 QEMU + `mac80211_hwsim`(虚拟无线网卡模块,内核自带)上亲测后填真实输出: + +- **加载虚拟网卡**:`modprobe mac80211_hwsim radios=2`,看是否冒出 `phy0`/`phy1`、`wlan0`/`wlan1`。 +- **看能力**:`iw list`(看 `wiphy` 的 bands、interface_modes、cipher_suites)。 +- **扫描**:`iw dev wlan0 scan`(主动扫描触发 `ieee80211_send_probe_req`)。 +- **看 mac80211 日志**:`echo 0x7fff > /sys/module/mac80211/parameters/debug`,开 debugfs `/sys/kernel/debug/ieee80211/phy0/`,挖 `total_ps_buffered`、`statistics/`、`rc/name`。 +- **待亲测**:在 `example/mini/` 下落一个最小 mac80211 SoftMAC 驱动骨架(填 `ieee80211_ops` 那 8 个命根子回调 + `ieee80211_alloc_hw`/`register_hw`),跑通 QEMU 收发——验证"alloc_hw 三件套内存布局"和"责任链收包"。 + +## 小结 + +无线不是以太网的变种:共享介质逼出 CSMA/CA 和 ACK 重传状态机,移动性和省电又叠了 MLME 和 Power Save。Linux 用两层子系统扛这套复杂度——**cfg80211**(`net/wireless/`)管配置和监管,核心是 `wiphy` + `wiphy_new_nm`/`wiphy_register`;**mac80211**(`net/mac80211/`)是软 MAC 框架,靠 `ieee80211_alloc_hw_nm` 把 wiphy + local + 私有数据三件套打包、`ieee80211_ops` 那 8 个命根子回调挂驱动。省电靠 AP 缓存(`ps_tx_buf` 每站 64/AC、`bc_buf` 128、全局 512),802.11n 靠 MIMO + A-MPDU/Block Ack 提速、A-MSDU 在 RX 和(有条件的)TX 都支持。被动扫描认 `IEEE80211_CHAN_NO_IR`(不是已消失的 `IEEE80211_CHAN_PASSIVE_SCAN`)。看源码时记住一条主线:**驱动只碰硬件,所有协议逻辑都在 mac80211 的责任链里。** + +## 延伸阅读 + +- 源码(Linux 6.19): + - `net/mac80211/main.c` —— `ieee80211_alloc_hw_nm`、`ieee80211_register_hw`、`ieee80211_ops` 体检。 + - `net/wireless/core.c` —— cfg80211 核心,`wiphy_new_nm`、`wiphy_register`、`cfg80211_rdev_list`。 + - `include/net/mac80211.h` —— `struct ieee80211_ops`(4515 行)、`struct ieee80211_hw`。 + - `include/net/cfg80211.h` —— `struct wiphy`、`IEEE80211_CHAN_NO_IR`(132 行)、监管 API。 + - `net/mac80211/mlme.c` —— MLME(`ieee80211_send_assoc`、`ieee80211_send_auth`)。 + - `net/mac80211/agg-tx.c` —— A-MPDU/Block Ack 发起侧(`start_tx_ba_session`、`send_addba_request`、`send_bar`)。 + - `net/mac80211/agg-rx.c` —— A-MPDU/Block Ack 响应侧(`process_addba_request`、`send_addba_resp`)。 + - `net/mac80211/tx.c` —— TX A-MSDU 聚合(`drv_can_aggregate_in_amsdu`、`ieee80211_amsdu_prepare_head`)。 + - `net/mac80211/rx.c` —— RX 责任链(`ieee80211_rx_list`、`ieee80211_rx_irqsafe`、`ieee80211_rx_h_mgmt_check`)。 + - `net/mac80211/drop.h` —— 6.19 drop-reason 重构(`RX_CONTINUE`/`RX_QUEUED`/`RX_DROP` + 细分 reason code)。 + - `net/mac80211/ibss.c` —— IBSS(`ieee80211_sta_create_ibss`)。 + - `include/linux/ieee80211-ht.h` —— `struct ieee80211_bar`。 +- kernel.org 文档索引(已核对页面真实存在): + - [Linux 802.11 Driver Developer's Guide](https://docs.kernel.org/driver-api/80211/index.html)(含 cfg80211、mac80211、mac80211-advanced 子页)。 + - [mac80211_hwsim](https://docs.kernel.org/networking/mac80211_hwsim/mac80211_hwsim.html) —— 虚拟无线网卡,QEMU 验证用。 +- 进一步(持续铺开):802.11s Mesh(`net/mac80211/mesh_*`)、速率控制算法(`rc80211_*`)、WPA 加密密钥路径。 + +--- + +以上为修订版全文。逐条修正对照(均已对照 6.19 源码 `third_party/linux` 核实): + +1. **Finding 1(被动扫描标志,HIGH)**:`IEEE80211_CHAN_PASSIVE_SCAN` 全仓库 channel flag 不存在,改为现役 `IEEE80211_CHAN_NO_IR`(cfg80211.h:132)+ `IEEE80211_CHAN_RADAR`,补 scan.c 行号,并加"别认错标志名"callout 指出旧名仅存于 nl80211 废弃别名(nl80211.h:4503)。 +2. **Finding 2(A-MSDU,MEDIUM)**:改为"RX 支持 + TX 在 6.19 有 fast-xmit 条件支持",引 tx.c:3489 `drv_can_aggregate_in_amsdu`、tx.c:3333 `ieee80211_amsdu_prepare_head`,加版本更新 callout。 +3. **Finding 3(责任链返回值,MEDIUM)**:删掉 `RX_DROP_MONITOR`/`RX_DROP_UNUSABLE`,改为 `RX_CONTINUE`/`RX_QUEUED`/`RX_DROP`(drop.h:86-88),mgmt_check 改为返回 `RX_DROP`(rx.c:3490→3512 `RX_DROP_U_RUNT_ACTION`),并提 drop.h 2023 年 drop-reason 重构。 +4. **Finding 4(ADDBA 文件归属,MEDIUM)**:拆成发起侧(agg-tx.c:600/61)和响应侧(agg-rx.c:471/233),不再打包进 agg-tx.c。 +5. **Finding 5(`struct ieee80211_bar` 路径,LOW)**:`include/linux/ieee80211.h` → `include/linux/ieee80211-ht.h:47`。 +6. **Finding 6(tx 回调行号,LOW)**:tx 是结构体首个成员(mac80211.h:4516),start 4519/stop 4520,删掉"tx 在其后"。 +7. **Finding 7(nl80211_iftype 行号,LOW)**:改为"注释 3617 起,枚举本体 3645",AID 补 `IEEE80211_MAX_AID` 来源。 +8. **Finding 8(callout 收敛)**:callout 从"函数/数据结构已核对"收敛为"函数与数据结构已核对,行号与命令输出待亲测核验"。 + +其余经源码核对无误、保留原样:省电三常量(STA_MAX_TX_BUFFER=64/STA_MAX_BC_BUFFER=128/TOTAL_MAX_TX_BUFFER=512,已正确)、alloc_hw_nm:791、register_hw:1120、rx_list:5417、rx_irqsafe:5584、send_auth util.c:1076、send_assoc mlme.c:2141、ibss_create:1287、send_bar agg-tx.c:103、WARN_ON main.c:800、wiphy_new_nm:446、wiphy_register:732、三件套内存布局 main.c:834、priv 指针 928、check_tim:2836、AID 2007、RX_FLAG_FAILED_FCS_CRC、IEEE80211_FCTL_MOREDATA、docs.kernel.org 两条链接(`driver-api/80211/index.rst` 与 `networking/mac80211_hwsim/mac80211_hwsim.rst` 均真实存在,原草稿无 netns.html 死链)。frontmatter 英文键名半角冒号、sources 用 `notes:`、折腾博主风格、章节结构均保留。 + +修订文件路径:`/home/charliechen/PenguinLab/document/tutorials/kernel/net/14-net-wireless.md` \ No newline at end of file diff --git a/document/tutorials/kernel/net/15-net-rdma.md b/document/tutorials/kernel/net/15-net-rdma.md new file mode 100644 index 00000000..0477bb67 --- /dev/null +++ b/document/tutorials/kernel/net/15-net-rdma.md @@ -0,0 +1,127 @@ +--- +title: RDMA 与 InfiniBand:绕过内核的零拷贝传输 +slug: net-rdma-infiniband +difficulty: intermediate +tags: [网络栈, RDMA, InfiniBand, RoCE, 零拷贝] +architectures: [arm64, x86_64, riscv] +kernel_version: "6.19" +maturity: drafting +prerequisites: + - /tutorials/kernel/net/01-net-overview +related: + - /tutorials/kernel/net/01-net-overview +sources: + - notes: document/notes/linux_kernel_networking/ch13.md + - notes: document/notes/linux_kernel_networking/ch13_1.md + - notes: document/notes/linux_kernel_networking/ch13_2.md + - notes: document/notes/linux_kernel_networking/ch13_3.md + - notes: document/notes/linux_kernel_networking/ch13_5.md + - notes: document/notes/linux_kernel_networking/ch13_6.md + - notes: document/notes/linux_kernel_networking/ch13_7.md +--- + +# RDMA 与 InfiniBand:绕过内核的零拷贝传输 + +> 🔨 **整理中** · 本篇机制对照 Linux 6.19 源码讲解(函数/数据结构已核对);具体行号与命令输出待 QEMU 亲测核对。 + +## RDMA 到底绕过了什么 + +先用一句大白话把 RDMA(Remote Direct Memory Access,远程直接内存访问)钉死:**它让一块网卡不经过 CPU、不经过内核协议栈,直接把这台机器的用户内存 DMA 读写到另一台机器的用户内存里。** 传统 socket 发一个包要交多少"税"?三次握手、用户态/内核态上下文切换、数据在内核缓冲区之间来回拷贝、网卡驱动每个包都中断一次 CPU。对普通业务这点税忍忍就过去了,但高频交易、大规模分布式存储、百万节点的 HPC 集群——这点税就是不可接受的死罪。 + +RDMA 的承诺听起来像在作弊:数据只在"本地用户内存"和"远程用户内存"之间搬一次,中间没有任何副本;CPU 卸载给网卡的专用处理器去算校验、重传、原子操作;延迟可以低到几百纳秒,带宽轻松上 100 Gbps。 + +但自由是有代价的。RDMA 抛弃了内核协议栈的"保姆式服务",把连接管理、内存注册、错误处理这些脏活全甩给了开发者——内核退居二线,只负责**建路不管运货**。 + +## 三种网络,一套 Verbs + +支持 RDMA 的网络协议主流三种,底层物理链路天差地别,但对外暴露的 API 是统一的: + +1. **InfiniBand(IB)**:从头设计的全新高性能网络架构,规范由 IBTA 维护。交换机很"笨"——不学 MAC、不跑生成树,转发表由一个叫 SM(Subnet Manager)的上帝视角实体远程配好,只负责查表转发,转发延迟极低。 +2. **RoCE(RDMA over Converged Ethernet,读作 "Rocky")**:既然 IB 交换机太贵,那就在现有以太网/IP 链路层之上跑 RDMA,属于混血方案。 +3. **iWARP(Internet Wide Area RDMA Protocol)**:直接在标准 TCP/IP 协议栈之上实现 RDMA,适合不想动底层网络、只想在广域网上玩 RDMA 的场景。 + +这套统一 API 叫 **Verbs**,可以理解成 RDMA 世界里的"系统调用表"。无论哪种网络,客户端代码都只通过 `ib_*` 系列函数操作硬件,底层差异由内核模块屏蔽。 + +> ⚠️ **历史包袱**:API 虽统称 RDMA,但大量内核函数/结构体/文件名仍以 `ib_`(InfiniBand)开头。比如 `ib_register_client` 一样能注册 RoCE 设备。别被名字误导,把它们当通用 RDMA 接口就行——`include/rdma/ib_verbs.h`(Linux 6.19)里的 `ib` 是通用的。 + +## 绕过内核的代价:内核只管建路 + +内核里的 RDMA 栈几乎全藏在 `drivers/infiniband/` 下(名字同样误导,里头也有 RoCE/iWARP 的逻辑)。拆开看像一栋大厦: + +- **`core/`** 是地基和管道:`core/cm.c`(Communication Manager,建连接时协商参数、交换密钥的"媒人")、`core/verbs.c`(核心 Verbs 实现)、`core/uverbs_*.c`(用户态 Verbs,让用户程序通过 `ioctl` 直接跟硬件打交道,彻底绕过内核)、`core/mad.c`(管理数据报,处理配置交换机、查端口状态这类管理包)。 +- **`hw/`** 是各厂商(Mellanox、Intel 等)的 HCA(Host Channel Adapter,"超级网卡")硬件驱动,挂在 PCIe 上,自带 DMA 引擎和内存管理单元。 +- **`ulp/`** 是住客:`ipoib`(IP over InfiniBand,让 IB 网卡看起来像普通网卡跑 TCP/IP)、`iser`(iSCSI over RDMA)、`srp`(SCSI RDMA Protocol)。 + +关键认知:**数据路径上内核基本不介入**——用户态应用直接往映射进用户空间的网卡寄存器下达指令,网卡自己去 DMA 内存。内核只在控制路径上做事:分配 QP 号、建立连接、注册内存。这就是为什么大量 RDMA 应用是纯用户态的,内核只负责"建路"。 + +## RDMA Device:谁接管这台机器 + +写第一行 RDMA 代码,第一个问题不是"怎么发数据",而是"我怎么知道这板子上有 RDMA 网卡,怎么接管它"。这把钥匙就是 **RDMA Device** 对象 `struct ib_device`。 + +在 `include/rdma/ib_verbs.h:2785`(Linux 6.19)能看到它的全貌——核心字段:`name[IB_DEVICE_NAME_MAX]`(设备名)、`node_guid`(节点全局唯一 ID)、`phys_port_cnt`(物理端口数)、`attrs`(`struct ib_device_attr`,设备的出厂能力)、`ops`(`struct ib_device_ops`,驱动填的函数指针表)、`local_dma_lkey`(设备级 DMA 本地密钥)。每个 HCA 驱动探测到硬件后,填充一个 `struct ib_device` 并调用 `ib_register_device()`(`:2969`)注册进核心层。 + +客户端(上层消费者模块)想接管设备,要先注册成"客户端":`ib_register_client()`(`drivers/infiniband/core/device.c:1854`)。它的实现里有一段关键循环——拿到 `devices_rwsem` 写锁后,`xa_for_each_marked(&devices, ...)` 遍历所有已注册设备,挨个调用 `add_client_context()` 触发你注册的 `add` 回调。这意味着**不管你的模块先于还是后于硬件驱动加载,都不会漏掉任何一张网卡**;之后热插拔新网卡,回调照样触发。 + +`struct ib_client`(`:2895`)就是回调契约:`name`(起个名)、`add(struct ib_device *)`(设备出现)、`remove(struct ib_device *, void *client_data)`(设备消失)。模块卸载必须配对调 `ib_unregister_client()`(`device.c:1901`),它会遍历所有设备逐个触发 `remove`——忘调就是 kernel panic。挂私有上下文用 `ib_set_client_data()`/`ib_get_client_data()`,像给衣服缝口袋。 + +## Queue Pair:RDMA 的通信端点 + +设备拿到了,路也认了,但数据到底从哪飞出去?答案是 **Queue Pair(QP,队列对)**。名字里的"对"非常精准——它由两条完全独立的工作队列组成: + +- **Send Queue(SQ,发送队列)**:投递请求,告诉网卡"把数据发出去"。 +- **Receive Queue(RQ,接收队列)**:投递请求,告诉网卡"我有空地了,把收到的数据放这儿"。 + +这是最容易踩坑的认知点:**发送和接收完全解耦**。同一队列内部严格保序(SQ 里先投 WR1 再投 WR2,网卡一定先处理 WR1),但 SQ 和 RQ 之间毫无关系,像两条平行的单行道。`struct ib_qp`(`ib_verbs.h:1800`)把这些都钉死了:`qp_num`(`:1825`,设备内唯一的 QP 号,别人找你通信靠它)、`qp_type`(`:1828`,传输类型)、`send_cq`/`recv_cq`(绑定的完成队列)、`srq`(绑定的共享接收队列)、`pd`(所属保护域)。 + +**传输类型不是随便选的**。`enum ib_qp_type`(`:1128`)列了一桌菜:`IB_QPT_RC`(Reliable Connected,可靠连接,一对一,包丢自动重传、乱序自动重排,支持 SEND/RDMA READ/RDMA WRITE/Atomic 全餐)、`IB_QPT_UC`(不可靠连接,砍掉重传,只支持 SEND/RDMA WRITE)、`IB_QPT_UD`(不可靠数据报,一对多甚至组播,只能 SEND,消息不能分片)、`IB_QPT_XRC_INI/TGT`(扩展可靠连接,配合 SRQ 用,多对一省 QP)。存储和数据库要强一致就选 RC;连接建立前的控制面信息交换用 UD。还有两个端口自带的特殊 QP:`IB_QPT_SMI`(QP0,子网管理)和 `IB_QPT_GSI`(QP1,通用服务)。 + +创建 QP 用 `ib_create_qp(pd, init_attr)`,`init_attr` 是 `struct ib_qp_init_attr`(`:1190`):`qp_type`、`sq_sig_type`(`IB_SIGNAL_ALL_WR` 每个包都通知,调试用;`IB_SIGNAL_REQ_WR` 手动控制通知,生产环境省 CQ 中断)、`cap`(`struct ib_qp_cap`,`:1108`,决定 `max_send_wr`/`max_recv_wr`/`max_send_sge`/`max_recv_sge`)、绑定的 `send_cq`/`recv_cq`/`srq`。 + +建出来的 QP 是空壳,必须走状态机才能收发。`enum ib_qp_state`(`:1285`)画了那条著名的"俄罗斯方块"路:`RESET`(刚建,啥都不能干,进来的包全丢)→ `INIT`(还不能 SEND,但可以预投 RECV,防止一到 RTR 远端数据就到、RQ 空着触发 RNR 错误)→ `RTR`(Ready To Receive,能处理接收)→ `RTS`(Ready To Send,全速战斗状态),中间还有 `SQD`(发送队列排空,改属性时的过渡态)、`SQE`(UC/UD 发送出错)、`ERR`(不可恢复,必须 RESET 重来)。状态转换不是自动的,靠 `ib_modify_qp()` 一档一档推,每推一档顺便配该档必须的参数(RTR 要配对方的 `dest_qp_num`/`rq_psn`/路径 MTU,RTS 要配超时/重试次数/自己的 PSN)。合法性检查有现成的 `ib_modify_qp_is_ok()`(`:3119`)。 + +## Memory Region:网卡能 DMA 哪些内存 + +QP 通了路,但"车"(数据)还得先有地方装——而 RDMA 的内存不是随便扔个指针就行的。你手里的虚拟地址在物理内存里到底在哪?分页机制可能随时把它换到 swap,网卡正 DMA 读着就读到垃圾了。 + +**Memory Registration** 就是给内存上"双头锁":一头锁住虚拟到物理地址的映射(Pin 住,禁止换出),另一头生成两把钥匙。这块内存注册成功才变成一个 **MR(Memory Region)**,`struct ib_mr`(`ib_verbs.h:1872`)的核心字段正是那两把钥匙:`lkey`(`:1875`,本地密钥,自己 CPU 填 Work Request 时出示)、`rkey`(`:1876`,远程密钥,交给对方,对方发 RDMA READ/WRITE 时必须带上,否则你的网卡直接拒收),外加 `iova`(IO 虚拟地址)、`length`、`pd`。 + +注册是"重"操作(拆页、翻译地址、查权限、Pin 住),可能睡眠——所以中断/原子上下文里不能随便注册,那得用 FMR(Fast MR)池预先注册好再快速取用。临时映射一段 `kmalloc` 出来的地址给网卡,用 `ib_dma_map_single()` 拿到网卡能懂的 DMA 地址,用完必须 `ib_dma_unmap_single()` 解映射,否则 DMA 映射表泄露。嫌映射+同步(`ib_dma_sync_single_for_cpu/device`)麻烦,直接 `ib_dma_alloc_coherent()` 分配 CPU 和网卡都能直接访问的一致性内存。 + +想动态授权又不想反复注销 MR?用 **Memory Window(MW)**:MR 保持注册不动,往 QP 发个特殊的 Bind WR 把 MW 绑到 MR 上生成新 rkey,解绑后 rkey 立即失效——轻量级权限控制。 + +## SEND/RECV vs READ/WRITE:对端 CPU 在不在场 + +这是 RDMA 区别于普通网卡的根本,必须讲透。`enum ib_wr_opcode`(`:1336`)把操作码列清楚了: + +**带 CPU 的操作**——`IB_WR_SEND`/`SEND_WITH_IMM`:像 socket 的 send,但前提是**远端必须提前摆好接收请求**(往 RQ 投 RECV WR,指定数据落在哪个缓冲区)。远端没准备?要么丢包要么 RNR 错误。`SEND_WITH_IMM` 还能附 32 位带外立即数,直接出现在接收方的 Work Completion 里,不进数据缓冲区——发简短指令/元数据的巧妙机制。 + +**不带 CPU 的操作**——`IB_WR_RDMA_WRITE`/`RDMA_READ`:这是 RDMA 的灵魂。`RDMA WRITE` 直接把数据写到远端指定内存地址,**远端 CPU 完全不参与**,没有中断、没有上下文切换,只要对方给了 rkey 和地址权限;`RDMA READ` 则是你主动指定远端地址,把数据"拉"回本地。配合 `RDMA WRITE_WITH_IMM`,数据像 WRITE 那样进远端内存,立即数又像 SEND 那样进远端 CQ(但要远端有 RECV 在排队)。还有硬件级原子操作 `IB_WR_ATOMIC_CMP_AND_SWP`(CAS)、`IB_WR_ATOMIC_FETCH_AND_ADD`——分布式系统绕过锁直接操作远端内存。 + +投递 Work Request 走 `ib_post_send(qp, wr, &bad_wr)` 或 `ib_post_recv()`,`bad_wr` 在批量投递时告诉你"挂在链上哪个环节断了"。RC 模式下硬件有自动重试:通用重试(超时没收 ACK 就重发)和 RNR 重试(远端没摆盘子回 RNR NACK,发送方等一会儿再重发)。一个 WR 投出去就是 Outstanding,直到在关联 CQ 里 poll 到对应 Work Completion(`struct ib_wc`,`:1032`)才算寿终正寝——这期间它用到的 buffer 绝对不能碰。 + +## Shared Receive Queue:多 QP 共享接收省内存 + +服务端要扛上万并发连接时,"每个 QP 配独立接收队列"的规矩就不可理喻了。一万客户端,每个都可能突发,99% 时候沉默——按旧规矩你得为每个 QP 预备满汉全席,结果内存被 9900 桌凉掉的菜耗光。 + +**SRQ(Shared Receive Queue,共享接收队列)** 的核心思想:接收资源从"私有"变"公有"。所有 QP 连到同一个大池子,谁收到数据谁从池里拿一个缓冲区装。`struct ib_srq`(`:1643`)里 `srq_type`、`event_handler`、`ext.xrc.srq_num` 都备好了。 + +代价是池化的通病:你不再确切知道是哪个 QP 会拿走缓冲区,所以**所有投到 SRQ 的接收缓冲必须大到能装下所有关联 QP 里最大的消息**(64B 心跳和 4MB 数据块混用就只能全上 4MB,解决办法是分级——小包 QP 挂一个 SRQ、大包 QP 挂另一个)。更棘手的是池子空了所有 QP 都饿死,所以 SRQ 有独门绝技**水位线**:`ib_modify_srq(srq, attr, IB_SRQ_LIMIT)`(`IB_SRQ_LIMIT` 见 `:1079`)设个 `srq_limit` 阈值,剩余请求数跌破就触发异步事件提醒"快没水了赶紧补水"。投递用 `ib_post_srq_recv()`,别等 0 才设水位,给自己留 5%–10% 余量。 + +## 小结 + +把这条越狱之路连起来:你先 `ib_register_client()` 接管 `struct ib_device`(建路),建 `struct ib_pd` 当隔离沙箱,注册 `struct ib_mr` 把内存 Pin 住拿 lkey/rkey(装货),创建 `struct ib_qp` 走 RESET→INIT→RTR→RTS 状态机(修管道),最后用 `ib_post_send`/`ib_post_recv` 投 Work Request,硬件绕过内核直接 DMA。理解它分三层:物理结构(SQ/RQ + PD + CQ)、传输类型(RC/UC/UD/XRC,决定能做什么操作)、生命周期(状态机少一步不行快一步也不行)。SRQ 是高并发场景下省接收内存的终极答案。 + +记住最关键的一点:**SEND/RECV 要对端 CPU 配合摆盘子,RDMA READ/WRITE 直接读写远端内存、对端 CPU 纹丝不动**——这就是 RDMA 性能魔法的根。 + +## 延伸阅读 + +- 源码(Linux 6.19): + - `include/rdma/ib_verbs.h`——所有核心数据结构与 API 声明(`struct ib_device`/`ib_qp`/`ib_mr`/`ib_pd`/`ib_srq`、`enum ib_qp_state`/`ib_qp_type`/`ib_wr_opcode`)。 + - `drivers/infiniband/core/device.c`——设备/客户端注册(`ib_register_device`、`ib_register_client`、`ib_unregister_client`)。 + - `drivers/infiniband/core/`——`verbs.c`(核心 Verbs)、`cm.c`(连接管理)、`uverbs_*.c`(用户态 Verbs)、`mad.c`(管理数据报)。 + - `drivers/infiniband/hw/`——各厂商 HCA 硬件驱动;`drivers/infiniband/ulp/`——IPoIB/iSER/SRP 等上层协议。 +- kernel.org 文档: + - [InfiniBand 子系统总览](https://docs.kernel.org/infiniband/index.html) + - [User-space Verbs(用户态如何绕过内核直接操作网卡)](https://docs.kernel.org/infiniband/user_verbs.html) + - [InfiniBand 驱动 API](https://docs.kernel.org/driver-api/infiniband.html) +- 下一步铺开:CQ(Completion Queue,完成队列与轮询)、RDMA CM(连接管理器用户态 API)、FMR/MW 进阶内存管理。 \ No newline at end of file diff --git a/document/tutorials/kernel/net/16-net-namespace.md b/document/tutorials/kernel/net/16-net-namespace.md new file mode 100644 index 00000000..1af69532 --- /dev/null +++ b/document/tutorials/kernel/net/16-net-namespace.md @@ -0,0 +1,315 @@ +--- +title: 网络命名空间:容器网络的根基,内核怎么造一个独立网络栈 +slug: net-netns +difficulty: intermediate +tags: [网络栈, network namespace, netns, 容器网络, cgroups, 通知链] +architectures: [arm64, x86_64, riscv] +kernel_version: "6.19" +maturity: drafting +prerequisites: + - /tutorials/kernel/net/01-net-overview +related: + - /tutorials/kernel/net/01-net-overview + - /tutorials/kernel/net/09-net-netlink +sources: + - notes: document/notes/linux_kernel_networking/ch14.md + - notes: document/notes/linux_kernel_networking/ch14_3.md + - notes: document/notes/linux_kernel_networking/ch14_4.md + - notes: document/notes/linux_kernel_networking/ch14_5.md + - notes: document/notes/linux_kernel_networking/ch14_10.md +--- + +# 网络命名空间:容器网络的根基,内核怎么造一个独立网络栈 + +> 🔨 **整理中** · 本篇机制对照 Linux 6.19 源码讲解(函数/数据结构已核对);具体行号与命令输出待 QEMU 亲测核对。核心数据结构 `struct net`、`copy_net_ns()`/`setup_net()`/`cleanup_net()` 生命周期、pernet 回调、`netdev_chain` 通知链都已对齐 6.19 的 `net/core/net_namespace.c` 与 `include/net/net_namespace.h`。一处提醒:旧笔记里写「lo 钉死靠 `NETIF_F_NETNS_LOCAL` 特性位」——这个 feature 位在 6.19 已删,改成了 `net_device->netns_immutable` 字段(见下文「搬设备的规矩」)。 + +## 为什么网络栈需要被「复制」 + +我们前面几篇把网络栈当成一台机器上独此一份的东西在讲——一张网卡、一张路由表、一套 iptables。这在传统服务器时代没问题:一台物理机就跑几个服务,网络栈确实是全局唯一的。 + +可一旦进了容器和虚拟化的世界,这套假设就崩了。一台宿主机上可能同时跑着一两百个容器,每个容器都觉得自己是网络的主人:它要有自己的 `eth0`、自己的默认路由、自己的防火墙规则,容器 A 里跑的 80 端口和容器 B 里的 80 端口绝对不能打架。要是所有容器共用一套网络栈,光端口号冲突就够你喝一壶的。 + +内核解这个题的路子很暴力也很优雅:**别共享了,给每个隔离环境拷一份完整的网络栈出来。** 这就是网络命名空间(network namespace,netns)。它不是改改配置文件那种浅隔离,而是把网卡、路由表、邻居表(ARP/NDISC)、Netfilter 规则、socket、`/proc/net` 视图整一套都深拷贝一遍——你可以把它理解成「凭空变出一台独立的虚拟路由器」。 + +这篇我们就钻进 `net/core/net_namespace.c`,看看内核到底是怎么把这份「独立世界」造出来、又怎么管它的生死。 + +## `struct net`:一个 netns 就是一个上帝对象 + +万丈高楼从 `struct net` 起。每创建一个网络命名空间,内核就分配一个 `struct net`,它就是那个「独立世界」的总账本——所有和网络相关的状态都挂在它身上。定义在 `include/net/net_namespace.h`(Linux 6.19): + +```c +struct net { + refcount_t passive; /* 决定 netns 何时真正释放 */ + /* ... */ + unsigned int dev_base_seq; + u32 ifindex; /* 本 netns 内分配设备索引的计数器 */ + + struct list_head list; /* 串起所有 netns 的全局链表 */ + struct list_head exit_list; /* 销毁时挂上 pernet exit 列表 */ + + struct user_namespace *user_ns; /* 拥有它的 user namespace */ + struct ucounts *ucounts; + struct idr netns_ids; /* 给其它 netns 起的本地编号 */ + struct ns_common ns; /* 通用 namespace 句柄 */ + + struct list_head dev_base_head; /* 本 netns 所有网设备的链表头 */ + struct hlist_head *dev_name_head; /* 按名字哈希查设备 */ + struct hlist_head *dev_index_head;/* 按 ifindex 哈希查设备 */ + struct raw_notifier_head netdev_chain; /* 本 netns 的设备通知链 */ + + struct net_device *loopback_dev; /* 回环设备,钉死不能搬 */ + + struct netns_ipv4 ipv4; /* IPv4 私有世界:路由表/iptables/sysctl */ +#if IS_ENABLED(CONFIG_IPV6) + struct netns_ipv6 ipv6; +#endif + /* ... netfilter / sctp / xfrm / bpf ... */ + struct net_generic __rcu *gen; /* 可选子系统的私有数据兜底 */ +}; +``` + +我们把关键字段拆开看,它们正好把「隔离」这件事一层层落地: + +- **`dev_base_head` / `dev_name_head` / `dev_index_head`**:本 netns 的设备库房。`dev_base_head` 是全设备链表头,另两个是哈希表,分别按名字和 ifindex 快速查设备。注意 `ifindex` 是**虚拟化**的——netns A 和 netns B 里的 lo 都可以是 1,各自的 `eth0` 也可以重号,互不干扰。 +- **`loopback_dev`**:回环设备,每个新 netns 里**唯一**默认存在的设备,在 `loopback_net_init()`(`drivers/net/loopback.c`)里挂上去。它有个铁律:**lo 设备禁止跨 netns 搬运**,是钉死在户籍里的(6.19 靠的是 `net_device->netns_immutable` 字段,下面细讲)。 +- **`ipv4` / `ipv6` / `nf` / `ct` / `xfrm`**:各大协议栈的「私人地盘」。光一个 `struct netns_ipv4`(`include/net/netns/ipv4.h`)里就装着 FIB 路由表、Netfilter 表(`iptable_filter`/`nat_table`)、一堆 `sysctl_tcp_*` 调节旋钮。回到「虚拟路由器」那个比喻:这就是那台虚拟路由器的路由面板、防火墙面板和 TCP 参数旋钮。 +- **`gen`(`struct net_generic`)**:工程上的妥协。要是每个可选子系统都往 `struct net` 塞字段,这结构体早膨胀成垃圾场了。于是内核搞了个通用指针数组,那些非核心子系统(比如 sit、pppoe)在这里申请一个 ID,存自己的私有数据,不污染 `struct net` 本体。分配逻辑在 `net_namespace.c` 的 `net_alloc_generic()` / `net_assign_generic()`。 + +> 笔记里写的 `atomic_t count` 在 6.19 已经换成 `refcount_t passive` 了——这是这些年 refcount 加固的成果,机制不变:`get_net()` 增、`put_net()` 减,归零触发清理。读源码时拿笔记的旧字段名对照新字段,别犯愣。 + +## 一个 netns 的生死:`copy_net_ns` → `setup_net` → `cleanup_net` + +光有数据结构不够,得看它怎么生、怎么死。netns 的生命周期全在 `net/core/net_namespace.c` 里。 + +**生**:当你 `unshare(CLONE_NEWNET)` 或 `ip netns add`,系统调用最终走到 `copy_net_ns()`(`net_namespace.c`,Linux 6.19)。它的骨架很清楚: + +```c +struct net *copy_net_ns(u64 flags, struct user_namespace *user_ns, + struct net *old_net) +{ + struct ucounts *ucounts; + struct net *net; + + if (!(flags & CLONE_NEWNET)) + return get_net(old_net); /* 没要新 netns,复用旧的 */ + + ucounts = inc_net_namespaces(user_ns); /* 用户能建的 netns 数有上限 */ + if (!ucounts) + return ERR_PTR(-ENOSPC); + + net = net_alloc(); /* 从 net_cachep slab 分配 */ + /* ... preinit_net(): 初始化 user_ns/passive/idr/nsid_lock ... */ + rv = setup_net(net); /* 挨个跑 pernet_list 的 init */ + /* ... 失败就走 put_userns/dec_ucounts 回滚 ... */ + return net; +} +``` + +第一个 `if` 是关键:不带 `CLONE_NEWNET` 标志位就只是给旧 netns 加个引用计数走人。真要造新世界,得先过 `ucounts` 这一关——每个 user namespace 能创建的 netns 数量是有限额的(防恶意进程无限造命名空间耗资源)。 + +造的核心在 `setup_net()`:它持着 `pernet_ops_rwsem` 读锁,遍历全局的 `pernet_list`,挨个调用每个 `pernet_operations` 的 `init` 回调,最后把新 netns 挂进全局 `net_namespace_list`: + +```c +static __net_init int setup_net(struct net *net) +{ + const struct pernet_operations *ops; + /* ... */ + net->net_cookie = ns_tree_gen_id(net); + + list_for_each_entry(ops, &pernet_list, list) { + error = ops_init(ops, net); /* 跑每个子系统的 init */ + if (error < 0) + goto out_undo; /* 哪步失败就逆序回滚 */ + } + down_write(&net_rwsem); + list_add_tail_rcu(&net->list, &net_namespace_list); /* 上户口 */ + up_write(&net_rwsem); + /* ... */ +} +``` + +这套「遍历 pernet_list 跑 init」的机制是 netns 可扩展性的根基——任何网络子系统只要注册一个 `pernet_operations`,就能在「每个新 netns 创建时」拿到通知做自己的初始化(建 `/proc/net/xxx`、分配私有数据等)。 + +**死**:netns 销毁是异步的。当引用计数 `passive` 归零,`__put_net()` 把它挂到全局 `cleanup_list` 上,调度 workqueue 上的 `net_cleanup_work`,最终跑 `cleanup_net()`。这函数先把待死的 netns 从全局列表摘掉、清掉它们对别的 netns 的编号引用(`unhash_nsid()`),再**逆序**(和注册顺序相反)跑每个 pernet ops 的 `exit` 回调,最后 `rcu_barrier()` 等所有 RCU 回调跑完才真正释放内存。逆序是规矩:你后注册的子系统可能依赖先注册的,拆的时候得反过来拆,先拆依赖方。 + +> 为什么销毁要异步、要 RCU?因为 netns 在数据包收发的热路径上被无数处 RCU 读侧引用着(`skb`、`sock` 都攥着 netns 指针)。直接在 `put_net` 里同步释放,会踩到还在用它的 CPU,所以甩给 workqueue,再配 `rcu_barrier` 兜底。 + +## pernet 回调:子系统怎么搭上 netns 这趟车 + +上面反复提到的 `pernet_operations`,是子系统接入 netns 的统一接口。定义在 `include/net/net_namespace.h`: + +```c +struct pernet_operations { + struct list_head list; + int (*init)(struct net *net); + void (*exit)(struct net *net); + void (*exit_batch)(struct list_head *net_exit_list); + void (*pre_exit)(struct net *net); + /* ... */ + int *id; + size_t size; +}; +``` + +子系统填好回调,再选注册方式: + +- `register_pernet_subsys(&ops)`:注册「子系统」类回调,netns 创建时跑 `init`、销毁时跑 `exit`,**不**涉及网络设备本身。 +- `register_pernet_device(&ops)`:注册「设备」类回调,插在 `pernet_list` 末尾(通过 `first_device` 指针分割前后段),保证设备相关的 init 晚于普通子系统跑、exit 早于它们跑。 + +看个笔记里的经典例子——PPPoE 模块要往每个 netns 的 `/proc/net` 下导出 `pppoe` 文件,于是定义: + +```c +static struct pernet_operations pppoe_net_ops = { + .init = pppoe_init_net, + .exit = pppoe_exit_net, + .id = &pppoe_net_id, + .size = sizeof(struct pppoe_net), +}; +``` + +`init` 里 `proc_create("pppoe", ..., net->proc_net, ...)` 建文件,`exit` 里 `remove_proc_entry` 删文件。`.id`/`.size` 配合 `net_generic`:`size` 是该子系统在每个 netns 里要的私有数据大小,内核给它在 `net->gen` 数组里预留一个槽位(`ops_init()` 里 `kzalloc(ops->size)` + `net_assign_generic()`)。 + +netns 模块自己也走这套——`net_ns_ops` 在 `net_ns_init()` 里 `register_pernet_subsys(&net_ns_ops)`,每个新 netns 创建时跑 `net_ns_net_init()`(6.19 里主要是建 debugfs ref-tracker 符号链接)。 + +## 用户态怎么玩:`ip netns` 背后的三件事 + +理论够了,回到命令行。99% 的时候我们用 `iproute2` 的 `ip netns`,不直接 `unshare()`。 + +```bash +ip netns add ns1 +``` + +这行命令背后干三件事:① 在 `/var/run/netns/` 下建 `ns1` 文件;② `unshare(CLONE_NEWNET)` 让内核造新 netns(最终就是上面的 `copy_net_ns`);③ **把 `/proc/self/ns/net` 绑定挂载(bind mount)到那个文件上**。 + +第③步是点睛之笔。netns 在内核里是漂浮在内存的对象,创建它的进程一退出,引用归零它就没了。bind mount 那个文件相当于在文件系统里留了个「传送门锚点」——文件系统对它持有引用,netns 就赖着不走,之后随时能 `ip netns exec` 找回去。所以 `ip netns list` 本质就是 `ls /var/run/netns/`;你用 `unshare --net bash` 硬造的「隐形」netns 不会出现在列表里,因为它没留锚点。 + +进 netns 跑命令是 `ip netns exec ns1 bash`:打开锚点文件拿 fd → `setns()` 关联当前进程到这个 fd 指向的 netns → `fork()`+`execve()` 你要的命令。`setns` 走的是 `netns_operations.install`(`net_namespace.c`),它会检查 `CAP_SYS_ADMIN` 能力,然后 `nsproxy->net_ns = get_net(net)` 换掉进程的网络栈归属。 + +> 进去后 `ip addr` 通常只看到孤零零一个 lo(还是 DOWN 状态)。这就是个刚拆封的空网络世界。 + +## veth pair:连两个 netns 的「跨世界网线」 + +空 netns 没法通信,得拉根「网线」。这就是 **veth(Virtual Ethernet)**——它**永远成对**出现,一头塞数据,另一头立刻收到,像一根管子贯穿两个世界。 + +典型搭桥流程(两 netns 互通的基础): + +```bash +ip netns add ns1 +ip netns add ns2 +# 在主空间建一对 veth +ip link add if_one type veth peer name if_one_peer +# 把一头扔进 ns1,另一头扔进 ns2 +ip link set if_one netns ns1 +ip link set if_one_peer netns ns2 +# 各自配 IP、起来 +ip netns exec ns1 ip addr add 10.0.0.1/24 dev if_one +ip netns exec ns1 ip link set if_one up +ip netns exec ns2 ip addr add 10.0.0.2/24 dev if_one_peer +ip netns exec ns2 ip link set if_one_peer up +# 互通 +ip netns exec ns1 ping 10.0.0.2 +``` + +这就是 Docker、Kubernetes 那套容器网络(CNI)的地基:每个容器一个 netns,靠 veth 把容器 netns 和宿主机网桥(bridge)连起来,再由网桥/路由/iptables 转发出去。搞懂 veth pair,你就懂了容器网络一半的「线缆」。 + +## 搬设备的规矩:谁也不能带走 lo + +网卡能在 netns 之间搬:`ip link set eth0 netns ns1`。但有个硬限制——被标记为「本地户籍不可搬运」的设备(lo、bridge、bond、vrf、hsr、各种 fb_tunnel 等)**禁止搬运**,内核直接返回 `-EINVAL`。 + +这里有个版本变化要特别注意:旧笔记和老资料里都写「靠 `NETIF_F_NETNS_LOCAL` 这个 feature 位判定」。但 **这个 feature 位在 Linux 6.19 已经被彻底删除**(`grep -rn NETIF_F_NETNS_LOCAL` 在整个源码树里零匹配)。6.19 改成了 `struct net_device` 里的一个 1-bit 字段 `netns_immutable`(`include/linux/netdevice.h`,注释写着 `interface can't change network namespaces`)。判定的闸门在 `__dev_change_net_namespace()`(`net/core/dev.c:12495`,Linux 6.19): + +```c +int __dev_change_net_namespace(struct net_device *dev, struct net *net, + const char *pat, int new_ifindex, + struct netlink_ext_ack *extack) +{ + /* ... */ + /* Don't allow namespace local devices to be moved. */ + err = -EINVAL; + if (dev->netns_immutable) { + NL_SET_ERR_MSG(extack, "The interface netns is immutable"); + goto out; + } + /* ... 真正切换:dev_net_set(dev, net) ... */ +} +``` + +> 注:对外的 wrapper 叫 `dev_change_net_namespace()`(`include/linux/netdevice.h:4299`),它是上面 `__dev_change_net_namespace()` 的一层封装,参数更简单(只传 `dev/net/pat`)。真带闸门、带全套切换逻辑的是那个双下划线的内部函数。 + +那些「本地户籍」设备在各自初始化时主动把自己钉死。比如 lo 在 `loopback_net_init()` 里 `dev->netns_immutable = true`(`drivers/net/loopback.c:176`)、网桥在 `br_dev_setup()` 里同样置位(`net/bridge/br_device.c:493`),bond/team/vrf/hsr、以及 ipmr/ip6mr 的 fb_tunnel、sit/ip6_gre 的 fb_tunnel、amt、batman-adv 的 mesh 接口、OVS 内部端口也都这么做。读源码时拿 `grep -rn 'netns_immutable = true'` 就能拉出 6.19 的完整「钉死名单」。 + +反过来,当一个 netns 被销毁时,里面那些**可搬运**的设备会被强制「搬家」回 `init_net`(宿主机默认 netns),不会跟着陪葬;只有本地户籍设备才随 netns 一起销毁。搬家的逻辑在 `default_device_exit_batch()`(`net/core/dev.c`)里,同样是靠这个字段放行——遇到不可搬运的直接略过: + +```c +for_each_netdev_safe(net, dev, aux) { + /* Ignore unmoveable devices (i.e. loopback) */ + if (dev->netns_immutable) + continue; + /* ... 其余设备 push 回 init_net ... */ +} +``` + +这就是为什么你 `ip netns del` 一个里面有容器的 netns,物理网卡不会凭空消失的原因。 + +> ⚠️ **待亲测核验**:上面这份「6.19 钉死名单」是我照着 `grep netns_immutable` 在 6.19 源码里拉的,veth/vxlan/macvlan 这类**可搬运**的虚拟设备没出现在名单里(vxlan 在老 `NETIF_F_NETNS_LOCAL` 时代被算作本地设备,6.19 起不再置 `netns_immutable`,所以现在能搬了)。具体每个设备能不能搬,以你 QEMU 上 `ip link set netns ` 的实际返回为准。 + +## netns + cgroups:隔离的墙 + 限流的闸 + +netns 解决的是「视线隔离」(各看各的网络栈),但它管不住资源争夺——容器里一个进程把带宽/CPU 吃满,宿主机照样卡。补上这块短板的是 **cgroups**。 + +这里要刻一条铁律:**netns 和 cgroups 是正交的。** 你可以只有 netns 没 cgroups(光隔离不限制),也可以反过来。历史上内核试过搞个 `ns` cgroup 把两者捏一起,后来代码删了——没必要。现代容器(Docker/K8s)是「两者都用」:netns 切网络栈,cgroup 切 CPU/内存/带宽。 + +cgroups 的设计很 Unix:**不引入新系统调用,把资源管理做成一个可挂载的虚拟文件系统**(`cgroup` fs)。创建分组、限制资源、统计用量,全是 `mkdir`/`echo`/`cat` 文件操作。挂载点通常在 `/sys/fs/cgroup/`。 + +跟网络直接相关的两个控制器: + +- **net_prio**:不改应用代码就能给某个 cgroup 里进程发出的包打优先级。它在每个 `net_device` 上挂一张 `priomap`(按 cgroup id 索引),发包路径 `dev_queue_xmit()` 查表填进 `skb->priority`。 +- **net_cls**:给 cgroup 里的包打 `classid`(如 `10:1`),配合 `tc`(Traffic Control)做基于「应用分组」而非「IP/端口」的流量整形。 + +> netns + cgroups 这对组合,本质上就是容器网络的两条腿:netns 管「能不能看见」,cgroup 管「能吃多少」。后面我们讲 netfilter、netlink 时还会反复回到这套隔离模型上。 + +## 通知链:netns 里的「神经系统」 + +最后一个机制,它贯穿整个网络栈而不只是 netns,但在 netns 上下文里尤其关键——**通知链(notifier chain)**。 + +网络世界是动态的:网线拔了、MTU 改了、设备注销了、netns 被销毁了……这些事件发生时,相关子系统必须立刻知情,否则路由表还在发往死设备、ARP 缓存还在查不存在的端口。通知链就是内核里的「发布-订阅」系统。 + +每个订阅者填一张 `notifier_block`(`include/linux/notifier.h`):回调函数指针 `notifier_call`、串链的 `next`、`priority`(数字大先被通知)。事件来了,内核顺着链表挨个拨电话。 + +网络子系统主要用 **`raw_notifier_chain`**——最宽松、不加锁的那种。因为网络代码路径太复杂,有些场景已经在锁里、有些不能睡眠,raw 链让网络子系统自己决定怎么加锁。前面 `struct net` 里那个 `netdev_chain` 字段(`raw_notifier_head`),就是**每个 netns 自己的一条设备通知链**。 + +事件码是一张大表(`NETDEV_UP`/`DOWN`、`REGISTER`/`UNREGISTER`、`CHANGEMTU`、`CHANGEADDR`、`BONDING_FAILOVER`…)。比如网桥模块想跟着从口网卡一起改 MTU,就注册一个 `notifier_block` 到 `netdev_chain`(`register_netdevice_notifier()`,本质是 `raw_notifier_chain_register` 的包装),回调里 `switch(event)` 挑关心的处理。6.19 真实代码(`net/bridge/br.c:74`): + +```c +static int br_device_event(struct notifier_block *unused, + unsigned long event, void *ptr) +{ + struct net_device *dev = ptr; + /* ... 找到 dev 所属的桥 br ... */ + switch (event) { + case NETDEV_CHANGEMTU: + br_mtu_auto_adjust(br); /* 按所有从口最小 MTU 重算桥的 MTU */ + break; + /* ... */ + } +} +``` + +`br_mtu_auto_adjust()` 定义在 `net/bridge/br_if.c:514`,干的就是「取所有从口里最小的 MTU,把桥设备的 MTU 调成这个值」——语义和直觉一致,6.19 只是把它收进了一个专门函数,没散在回调里。 + +为什么在 netns 篇提它?因为 netns 的**销毁**本身就伴随着大量通知——所有设备要 `NETDEV_UNREGISTER`、邻居表要清、路由要撤、各子系统的 `exit` 回调要跑。`cleanup_net()` 里那一串 `exit` 回调和 `unhash_nsid` 的通知广播,全靠这套通知机制把「netns 要没了」这件事扩散给每个关心它的子系统。netns 不是孤岛,它靠通知链和整个网络栈保持着神经联系。 + +## 小结 + +网络命名空间是容器网络的根基。一个 netns 就是一个 `struct net`——它挂着独立的设备链表(`dev_base_head`)、路由表和 iptables(藏在 `ipv4`/`nf` 里)、独立的 socket、独立的 `/proc/net`。生命周期走 `copy_net_ns` → `setup_net`(跑 pernet init)→ `cleanup_net`(逆序跑 exit + RCU 兜底);子系统靠 `pernet_operations` 搭车,netns 之间靠 veth pair 连「网线」,带宽/CPU 靠 cgroups 限流,事件靠 `netdev_chain` 通知链广播。 + +记住三件事:**lo 钉死不能搬**(6.19 靠 `dev->netns_immutable`,老的 `NETIF_F_NETNS_LOCAL` 已删)、**netns 与 cgroups 正交**(隔离的墙 vs 限流的闸)、**销毁是异步 + RCU**(热路径上有无数读侧引用)。 + +## 延伸阅读 + +- 源码:`net/core/net_namespace.c`(Linux 6.19),`copy_net_ns`/`setup_net`/`cleanup_net`/`register_pernet_subsys` 全在这;`include/net/net_namespace.h` 看 `struct net` / `pernet_operations`;`include/net/netns/ipv4.h` 看一个 netns 的 IPv4 世界有多大;`net/core/dev.c`(`__dev_change_net_namespace` / `default_device_exit_batch`)看搬设备的闸门。 +- docs.kernel.org:[Namespaces admin guide](https://docs.kernel.org/admin-guide/namespaces/index.html)(用户态视角,含 netns 的能力/限制清单)、[Network management cgroup](https://docs.kernel.org/admin-guide/cgroup-v1/net_cls.html)(net_cls 控制器)、[Cgroup v2](https://docs.kernel.org/admin-guide/cgroup-v2.html)。注:内核文档里**没有**独立的 netns 机制专页(`Documentation/networking/` 下无 `netns.rst`),netns 的内核侧机制请直接读上面的源码。 +- 命令手册:`ip-netns(8)`、`ip-link(8)`(veth 类型)、`unshare(1)`/`setns(2)`——netns 用户态用法的权威来源。 +- 待铺开:veth/bridge 内部实现、netfilter 在 netns 里的规则隔离、netlink(本站 [/tutorials/kernel/net/09-net-netlink](/tutorials/kernel/net/09-net-netlink))如何驱动这套 netns 管理。 + +> ⚠️ **待亲测**:上面的命令流程会在 QEMU 上跑一遍——`ip netns add ns1/ns2`、建 veth pair 连两个 netns、互 ping 验证、`cat /proc//ns/net` 对比 inode、`ip netns identify` 找名字;顺手验证 `ip link set lo netns ` 返回 `-EINVAL`(`netns_immutable` 生效)。跑完记下真实输出,把这篇从 🔨 升级成 ✅。 \ No newline at end of file diff --git a/site/site/.vitepress/cache/deps_temp_3106e562/chunk-ZWXT3AT4.js b/site/site/.vitepress/cache/deps_temp_3106e562/chunk-ZWXT3AT4.js deleted file mode 100644 index 1f8f2f03..00000000 --- a/site/site/.vitepress/cache/deps_temp_3106e562/chunk-ZWXT3AT4.js +++ /dev/null @@ -1,13050 +0,0 @@ -// node_modules/.pnpm/@vue+shared@3.5.35/node_modules/@vue/shared/dist/shared.esm-bundler.js -function makeMap(str) { - const map2 = /* @__PURE__ */ Object.create(null); - for (const key of str.split(",")) map2[key] = 1; - return (val) => val in map2; -} -var EMPTY_OBJ = true ? Object.freeze({}) : {}; -var EMPTY_ARR = true ? Object.freeze([]) : []; -var NOOP = () => { -}; -var NO = () => false; -var isOn = (key) => key.charCodeAt(0) === 111 && key.charCodeAt(1) === 110 && // uppercase letter -(key.charCodeAt(2) > 122 || key.charCodeAt(2) < 97); -var isModelListener = (key) => key.startsWith("onUpdate:"); -var extend = Object.assign; -var remove = (arr, el) => { - const i = arr.indexOf(el); - if (i > -1) { - arr.splice(i, 1); - } -}; -var hasOwnProperty = Object.prototype.hasOwnProperty; -var hasOwn = (val, key) => hasOwnProperty.call(val, key); -var isArray = Array.isArray; -var isMap = (val) => toTypeString(val) === "[object Map]"; -var isSet = (val) => toTypeString(val) === "[object Set]"; -var isDate = (val) => toTypeString(val) === "[object Date]"; -var isRegExp = (val) => toTypeString(val) === "[object RegExp]"; -var isFunction = (val) => typeof val === "function"; -var isString = (val) => typeof val === "string"; -var isSymbol = (val) => typeof val === "symbol"; -var isObject = (val) => val !== null && typeof val === "object"; -var isPromise = (val) => { - return (isObject(val) || isFunction(val)) && isFunction(val.then) && isFunction(val.catch); -}; -var objectToString = Object.prototype.toString; -var toTypeString = (value) => objectToString.call(value); -var toRawType = (value) => { - return toTypeString(value).slice(8, -1); -}; -var isPlainObject = (val) => toTypeString(val) === "[object Object]"; -var isIntegerKey = (key) => isString(key) && key !== "NaN" && key[0] !== "-" && "" + parseInt(key, 10) === key; -var isReservedProp = makeMap( - // the leading comma is intentional so empty string "" is also included - ",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted" -); -var isBuiltInDirective = makeMap( - "bind,cloak,else-if,else,for,html,if,model,on,once,pre,show,slot,text,memo" -); -var cacheStringFunction = (fn) => { - const cache = /* @__PURE__ */ Object.create(null); - return (str) => { - const hit = cache[str]; - return hit || (cache[str] = fn(str)); - }; -}; -var camelizeRE = /-\w/g; -var camelize = cacheStringFunction( - (str) => { - return str.replace(camelizeRE, (c) => c.slice(1).toUpperCase()); - } -); -var hyphenateRE = /\B([A-Z])/g; -var hyphenate = cacheStringFunction( - (str) => str.replace(hyphenateRE, "-$1").toLowerCase() -); -var capitalize = cacheStringFunction((str) => { - return str.charAt(0).toUpperCase() + str.slice(1); -}); -var toHandlerKey = cacheStringFunction( - (str) => { - const s = str ? `on${capitalize(str)}` : ``; - return s; - } -); -var hasChanged = (value, oldValue) => !Object.is(value, oldValue); -var invokeArrayFns = (fns, ...arg) => { - for (let i = 0; i < fns.length; i++) { - fns[i](...arg); - } -}; -var def = (obj, key, value, writable = false) => { - Object.defineProperty(obj, key, { - configurable: true, - enumerable: false, - writable, - value - }); -}; -var looseToNumber = (val) => { - const n = parseFloat(val); - return isNaN(n) ? val : n; -}; -var toNumber = (val) => { - const n = isString(val) ? Number(val) : NaN; - return isNaN(n) ? val : n; -}; -var _globalThis; -var getGlobalThis = () => { - return _globalThis || (_globalThis = typeof globalThis !== "undefined" ? globalThis : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : typeof global !== "undefined" ? global : {}); -}; -var GLOBALS_ALLOWED = "Infinity,undefined,NaN,isFinite,isNaN,parseFloat,parseInt,decodeURI,decodeURIComponent,encodeURI,encodeURIComponent,Math,Number,Date,Array,Object,Boolean,String,RegExp,Map,Set,JSON,Intl,BigInt,console,Error,Symbol"; -var isGloballyAllowed = makeMap(GLOBALS_ALLOWED); -function normalizeStyle(value) { - if (isArray(value)) { - const res = {}; - for (let i = 0; i < value.length; i++) { - const item = value[i]; - const normalized = isString(item) ? parseStringStyle(item) : normalizeStyle(item); - if (normalized) { - for (const key in normalized) { - res[key] = normalized[key]; - } - } - } - return res; - } else if (isString(value) || isObject(value)) { - return value; - } -} -var listDelimiterRE = /;(?![^(]*\))/g; -var propertyDelimiterRE = /:([^]+)/; -var styleCommentRE = /\/\*[^]*?\*\//g; -function parseStringStyle(cssText) { - const ret = {}; - cssText.replace(styleCommentRE, "").split(listDelimiterRE).forEach((item) => { - if (item) { - const tmp = item.split(propertyDelimiterRE); - tmp.length > 1 && (ret[tmp[0].trim()] = tmp[1].trim()); - } - }); - return ret; -} -function stringifyStyle(styles) { - if (!styles) return ""; - if (isString(styles)) return styles; - let ret = ""; - for (const key in styles) { - const value = styles[key]; - if (isString(value) || typeof value === "number") { - const normalizedKey = key.startsWith(`--`) ? key : hyphenate(key); - ret += `${normalizedKey}:${value};`; - } - } - return ret; -} -function normalizeClass(value) { - let res = ""; - if (isString(value)) { - res = value; - } else if (isArray(value)) { - for (let i = 0; i < value.length; i++) { - const normalized = normalizeClass(value[i]); - if (normalized) { - res += normalized + " "; - } - } - } else if (isObject(value)) { - for (const name in value) { - if (value[name]) { - res += name + " "; - } - } - } - return res.trim(); -} -function normalizeProps(props) { - if (!props) return null; - let { class: klass, style } = props; - if (klass && !isString(klass)) { - props.class = normalizeClass(klass); - } - if (style) { - props.style = normalizeStyle(style); - } - return props; -} -var HTML_TAGS = "html,body,base,head,link,meta,style,title,address,article,aside,footer,header,hgroup,h1,h2,h3,h4,h5,h6,nav,section,div,dd,dl,dt,figcaption,figure,picture,hr,img,li,main,ol,p,pre,ul,a,b,abbr,bdi,bdo,br,cite,code,data,dfn,em,i,kbd,mark,q,rp,rt,ruby,s,samp,small,span,strong,sub,sup,time,u,var,wbr,area,audio,map,track,video,embed,object,param,source,canvas,script,noscript,del,ins,caption,col,colgroup,table,thead,tbody,td,th,tr,button,datalist,fieldset,form,input,label,legend,meter,optgroup,option,output,progress,select,textarea,details,dialog,menu,summary,template,blockquote,iframe,tfoot"; -var SVG_TAGS = "svg,animate,animateMotion,animateTransform,circle,clipPath,color-profile,defs,desc,discard,ellipse,feBlend,feColorMatrix,feComponentTransfer,feComposite,feConvolveMatrix,feDiffuseLighting,feDisplacementMap,feDistantLight,feDropShadow,feFlood,feFuncA,feFuncB,feFuncG,feFuncR,feGaussianBlur,feImage,feMerge,feMergeNode,feMorphology,feOffset,fePointLight,feSpecularLighting,feSpotLight,feTile,feTurbulence,filter,foreignObject,g,hatch,hatchpath,image,line,linearGradient,marker,mask,mesh,meshgradient,meshpatch,meshrow,metadata,mpath,path,pattern,polygon,polyline,radialGradient,rect,set,solidcolor,stop,switch,symbol,text,textPath,title,tspan,unknown,use,view"; -var MATH_TAGS = "annotation,annotation-xml,maction,maligngroup,malignmark,math,menclose,merror,mfenced,mfrac,mfraction,mglyph,mi,mlabeledtr,mlongdiv,mmultiscripts,mn,mo,mover,mpadded,mphantom,mprescripts,mroot,mrow,ms,mscarries,mscarry,msgroup,msline,mspace,msqrt,msrow,mstack,mstyle,msub,msubsup,msup,mtable,mtd,mtext,mtr,munder,munderover,none,semantics"; -var VOID_TAGS = "area,base,br,col,embed,hr,img,input,link,meta,param,source,track,wbr"; -var isHTMLTag = makeMap(HTML_TAGS); -var isSVGTag = makeMap(SVG_TAGS); -var isMathMLTag = makeMap(MATH_TAGS); -var isVoidTag = makeMap(VOID_TAGS); -var specialBooleanAttrs = `itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly`; -var isSpecialBooleanAttr = makeMap(specialBooleanAttrs); -var isBooleanAttr = makeMap( - specialBooleanAttrs + `,async,autofocus,autoplay,controls,default,defer,disabled,hidden,inert,loop,open,required,reversed,scoped,seamless,checked,muted,multiple,selected` -); -function includeBooleanAttr(value) { - return !!value || value === ""; -} -var isKnownHtmlAttr = makeMap( - `accept,accept-charset,accesskey,action,align,allow,alt,async,autocapitalize,autocomplete,autofocus,autoplay,background,bgcolor,border,buffered,capture,challenge,charset,checked,cite,class,code,codebase,color,cols,colspan,content,contenteditable,contextmenu,controls,coords,crossorigin,csp,data,datetime,decoding,default,defer,dir,dirname,disabled,download,draggable,dropzone,enctype,enterkeyhint,for,form,formaction,formenctype,formmethod,formnovalidate,formtarget,headers,height,hidden,high,href,hreflang,http-equiv,icon,id,importance,inert,integrity,ismap,itemprop,keytype,kind,label,lang,language,loading,list,loop,low,manifest,max,maxlength,minlength,media,min,multiple,muted,name,novalidate,open,optimum,pattern,ping,placeholder,poster,preload,radiogroup,readonly,referrerpolicy,rel,required,reversed,rows,rowspan,sandbox,scope,scoped,selected,shape,size,sizes,slot,span,spellcheck,src,srcdoc,srclang,srcset,start,step,style,summary,tabindex,target,title,translate,type,usemap,value,width,wrap` -); -var isKnownSvgAttr = makeMap( - `xmlns,accent-height,accumulate,additive,alignment-baseline,alphabetic,amplitude,arabic-form,ascent,attributeName,attributeType,azimuth,baseFrequency,baseline-shift,baseProfile,bbox,begin,bias,by,calcMode,cap-height,class,clip,clipPathUnits,clip-path,clip-rule,color,color-interpolation,color-interpolation-filters,color-profile,color-rendering,contentScriptType,contentStyleType,crossorigin,cursor,cx,cy,d,decelerate,descent,diffuseConstant,direction,display,divisor,dominant-baseline,dur,dx,dy,edgeMode,elevation,enable-background,end,exponent,fill,fill-opacity,fill-rule,filter,filterRes,filterUnits,flood-color,flood-opacity,font-family,font-size,font-size-adjust,font-stretch,font-style,font-variant,font-weight,format,from,fr,fx,fy,g1,g2,glyph-name,glyph-orientation-horizontal,glyph-orientation-vertical,glyphRef,gradientTransform,gradientUnits,hanging,height,href,hreflang,horiz-adv-x,horiz-origin-x,id,ideographic,image-rendering,in,in2,intercept,k,k1,k2,k3,k4,kernelMatrix,kernelUnitLength,kerning,keyPoints,keySplines,keyTimes,lang,lengthAdjust,letter-spacing,lighting-color,limitingConeAngle,local,marker-end,marker-mid,marker-start,markerHeight,markerUnits,markerWidth,mask,maskContentUnits,maskUnits,mathematical,max,media,method,min,mode,name,numOctaves,offset,opacity,operator,order,orient,orientation,origin,overflow,overline-position,overline-thickness,panose-1,paint-order,path,pathLength,patternContentUnits,patternTransform,patternUnits,ping,pointer-events,points,pointsAtX,pointsAtY,pointsAtZ,preserveAlpha,preserveAspectRatio,primitiveUnits,r,radius,referrerPolicy,refX,refY,rel,rendering-intent,repeatCount,repeatDur,requiredExtensions,requiredFeatures,restart,result,rotate,rx,ry,scale,seed,shape-rendering,slope,spacing,specularConstant,specularExponent,speed,spreadMethod,startOffset,stdDeviation,stemh,stemv,stitchTiles,stop-color,stop-opacity,strikethrough-position,strikethrough-thickness,string,stroke,stroke-dasharray,stroke-dashoffset,stroke-linecap,stroke-linejoin,stroke-miterlimit,stroke-opacity,stroke-width,style,surfaceScale,systemLanguage,tabindex,tableValues,target,targetX,targetY,text-anchor,text-decoration,text-rendering,textLength,to,transform,transform-origin,type,u1,u2,underline-position,underline-thickness,unicode,unicode-bidi,unicode-range,units-per-em,v-alphabetic,v-hanging,v-ideographic,v-mathematical,values,vector-effect,version,vert-adv-y,vert-origin-x,vert-origin-y,viewBox,viewTarget,visibility,width,widths,word-spacing,writing-mode,x,x-height,x1,x2,xChannelSelector,xlink:actuate,xlink:arcrole,xlink:href,xlink:role,xlink:show,xlink:title,xlink:type,xmlns:xlink,xml:base,xml:lang,xml:space,y,y1,y2,yChannelSelector,z,zoomAndPan` -); -var isKnownMathMLAttr = makeMap( - `accent,accentunder,actiontype,align,alignmentscope,altimg,altimg-height,altimg-valign,altimg-width,alttext,bevelled,close,columnsalign,columnlines,columnspan,denomalign,depth,dir,display,displaystyle,encoding,equalcolumns,equalrows,fence,fontstyle,fontweight,form,frame,framespacing,groupalign,height,href,id,indentalign,indentalignfirst,indentalignlast,indentshift,indentshiftfirst,indentshiftlast,indextype,justify,largetop,largeop,lquote,lspace,mathbackground,mathcolor,mathsize,mathvariant,maxsize,minlabelspacing,mode,other,overflow,position,rowalign,rowlines,rowspan,rquote,rspace,scriptlevel,scriptminsize,scriptsizemultiplier,selection,separator,separators,shift,side,src,stackalign,stretchy,subscriptshift,superscriptshift,symmetric,voffset,width,widths,xlink:href,xlink:show,xlink:type,xmlns` -); -function isRenderableAttrValue(value) { - if (value == null) { - return false; - } - const type = typeof value; - return type === "string" || type === "number" || type === "boolean"; -} -var cssVarNameEscapeSymbolsRE = /[ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~]/g; -function getEscapedCssVarName(key, doubleEscape) { - return key.replace( - cssVarNameEscapeSymbolsRE, - (s) => doubleEscape ? s === '"' ? '\\\\\\"' : `\\\\${s}` : `\\${s}` - ); -} -function looseCompareArrays(a, b) { - if (a.length !== b.length) return false; - let equal = true; - for (let i = 0; equal && i < a.length; i++) { - equal = looseEqual(a[i], b[i]); - } - return equal; -} -function looseEqual(a, b) { - if (a === b) return true; - let aValidType = isDate(a); - let bValidType = isDate(b); - if (aValidType || bValidType) { - return aValidType && bValidType ? a.getTime() === b.getTime() : false; - } - aValidType = isSymbol(a); - bValidType = isSymbol(b); - if (aValidType || bValidType) { - return a === b; - } - aValidType = isArray(a); - bValidType = isArray(b); - if (aValidType || bValidType) { - return aValidType && bValidType ? looseCompareArrays(a, b) : false; - } - aValidType = isObject(a); - bValidType = isObject(b); - if (aValidType || bValidType) { - if (!aValidType || !bValidType) { - return false; - } - const aKeysCount = Object.keys(a).length; - const bKeysCount = Object.keys(b).length; - if (aKeysCount !== bKeysCount) { - return false; - } - for (const key in a) { - const aHasKey = a.hasOwnProperty(key); - const bHasKey = b.hasOwnProperty(key); - if (aHasKey && !bHasKey || !aHasKey && bHasKey || !looseEqual(a[key], b[key])) { - return false; - } - } - } - return String(a) === String(b); -} -function looseIndexOf(arr, val) { - return arr.findIndex((item) => looseEqual(item, val)); -} -var isRef = (val) => { - return !!(val && val["__v_isRef"] === true); -}; -var toDisplayString = (val) => { - return isString(val) ? val : val == null ? "" : isArray(val) || isObject(val) && (val.toString === objectToString || !isFunction(val.toString)) ? isRef(val) ? toDisplayString(val.value) : JSON.stringify(val, replacer, 2) : String(val); -}; -var replacer = (_key, val) => { - if (isRef(val)) { - return replacer(_key, val.value); - } else if (isMap(val)) { - return { - [`Map(${val.size})`]: [...val.entries()].reduce( - (entries, [key, val2], i) => { - entries[stringifySymbol(key, i) + " =>"] = val2; - return entries; - }, - {} - ) - }; - } else if (isSet(val)) { - return { - [`Set(${val.size})`]: [...val.values()].map((v) => stringifySymbol(v)) - }; - } else if (isSymbol(val)) { - return stringifySymbol(val); - } else if (isObject(val) && !isArray(val) && !isPlainObject(val)) { - return String(val); - } - return val; -}; -var stringifySymbol = (v, i = "") => { - var _a; - return ( - // Symbol.description in es2019+ so we need to cast here to pass - // the lib: es2016 check - isSymbol(v) ? `Symbol(${(_a = v.description) != null ? _a : i})` : v - ); -}; -function normalizeCssVarValue(value) { - if (value == null) { - return "initial"; - } - if (typeof value === "string") { - return value === "" ? " " : value; - } - if (typeof value !== "number" || !Number.isFinite(value)) { - if (true) { - console.warn( - "[Vue warn] Invalid value used for CSS binding. Expected a string or a finite number but received:", - value - ); - } - } - return String(value); -} - -// node_modules/.pnpm/@vue+reactivity@3.5.35/node_modules/@vue/reactivity/dist/reactivity.esm-bundler.js -function warn(msg, ...args) { - console.warn(`[Vue warn] ${msg}`, ...args); -} -var activeEffectScope; -var EffectScope = class { - // TODO isolatedDeclarations "__v_skip" - constructor(detached = false) { - this.detached = detached; - this._active = true; - this._on = 0; - this.effects = []; - this.cleanups = []; - this._isPaused = false; - this._warnOnRun = true; - this.__v_skip = true; - if (!detached && activeEffectScope) { - if (activeEffectScope.active) { - this.parent = activeEffectScope; - this.index = (activeEffectScope.scopes || (activeEffectScope.scopes = [])).push( - this - ) - 1; - } else { - this._active = false; - this._warnOnRun = false; - } - } - } - get active() { - return this._active; - } - pause() { - if (this._active) { - this._isPaused = true; - let i, l; - if (this.scopes) { - for (i = 0, l = this.scopes.length; i < l; i++) { - this.scopes[i].pause(); - } - } - for (i = 0, l = this.effects.length; i < l; i++) { - this.effects[i].pause(); - } - } - } - /** - * Resumes the effect scope, including all child scopes and effects. - */ - resume() { - if (this._active) { - if (this._isPaused) { - this._isPaused = false; - let i, l; - if (this.scopes) { - for (i = 0, l = this.scopes.length; i < l; i++) { - this.scopes[i].resume(); - } - } - for (i = 0, l = this.effects.length; i < l; i++) { - this.effects[i].resume(); - } - } - } - } - run(fn) { - if (this._active) { - const currentEffectScope = activeEffectScope; - try { - activeEffectScope = this; - return fn(); - } finally { - activeEffectScope = currentEffectScope; - } - } else if (this._warnOnRun) { - warn(`cannot run an inactive effect scope.`); - } - } - /** - * This should only be called on non-detached scopes - * @internal - */ - on() { - if (++this._on === 1) { - this.prevScope = activeEffectScope; - activeEffectScope = this; - } - } - /** - * This should only be called on non-detached scopes - * @internal - */ - off() { - if (this._on > 0 && --this._on === 0) { - if (activeEffectScope === this) { - activeEffectScope = this.prevScope; - } else { - let current = activeEffectScope; - while (current) { - if (current.prevScope === this) { - current.prevScope = this.prevScope; - break; - } - current = current.prevScope; - } - } - this.prevScope = void 0; - } - } - stop(fromParent) { - if (this._active) { - this._active = false; - let i, l; - for (i = 0, l = this.effects.length; i < l; i++) { - this.effects[i].stop(); - } - this.effects.length = 0; - for (i = 0, l = this.cleanups.length; i < l; i++) { - this.cleanups[i](); - } - this.cleanups.length = 0; - if (this.scopes) { - for (i = 0, l = this.scopes.length; i < l; i++) { - this.scopes[i].stop(true); - } - this.scopes.length = 0; - } - if (!this.detached && this.parent && !fromParent) { - const last = this.parent.scopes.pop(); - if (last && last !== this) { - this.parent.scopes[this.index] = last; - last.index = this.index; - } - } - this.parent = void 0; - } - } -}; -function effectScope(detached) { - return new EffectScope(detached); -} -function getCurrentScope() { - return activeEffectScope; -} -function onScopeDispose(fn, failSilently = false) { - if (activeEffectScope) { - activeEffectScope.cleanups.push(fn); - } else if (!failSilently) { - warn( - `onScopeDispose() is called when there is no active effect scope to be associated with.` - ); - } -} -var activeSub; -var pausedQueueEffects = /* @__PURE__ */ new WeakSet(); -var ReactiveEffect = class { - constructor(fn) { - this.fn = fn; - this.deps = void 0; - this.depsTail = void 0; - this.flags = 1 | 4; - this.next = void 0; - this.cleanup = void 0; - this.scheduler = void 0; - if (activeEffectScope) { - if (activeEffectScope.active) { - activeEffectScope.effects.push(this); - } else { - this.flags &= -2; - } - } - } - pause() { - this.flags |= 64; - } - resume() { - if (this.flags & 64) { - this.flags &= -65; - if (pausedQueueEffects.has(this)) { - pausedQueueEffects.delete(this); - this.trigger(); - } - } - } - /** - * @internal - */ - notify() { - if (this.flags & 2 && !(this.flags & 32)) { - return; - } - if (!(this.flags & 8)) { - batch(this); - } - } - run() { - if (!(this.flags & 1)) { - return this.fn(); - } - this.flags |= 2; - cleanupEffect(this); - prepareDeps(this); - const prevEffect = activeSub; - const prevShouldTrack = shouldTrack; - activeSub = this; - shouldTrack = true; - try { - return this.fn(); - } finally { - if (activeSub !== this) { - warn( - "Active effect was not restored correctly - this is likely a Vue internal bug." - ); - } - cleanupDeps(this); - activeSub = prevEffect; - shouldTrack = prevShouldTrack; - this.flags &= -3; - } - } - stop() { - if (this.flags & 1) { - for (let link = this.deps; link; link = link.nextDep) { - removeSub(link); - } - this.deps = this.depsTail = void 0; - cleanupEffect(this); - this.onStop && this.onStop(); - this.flags &= -2; - } - } - trigger() { - if (this.flags & 64) { - pausedQueueEffects.add(this); - } else if (this.scheduler) { - this.scheduler(); - } else { - this.runIfDirty(); - } - } - /** - * @internal - */ - runIfDirty() { - if (isDirty(this)) { - this.run(); - } - } - get dirty() { - return isDirty(this); - } -}; -var batchDepth = 0; -var batchedSub; -var batchedComputed; -function batch(sub, isComputed = false) { - sub.flags |= 8; - if (isComputed) { - sub.next = batchedComputed; - batchedComputed = sub; - return; - } - sub.next = batchedSub; - batchedSub = sub; -} -function startBatch() { - batchDepth++; -} -function endBatch() { - if (--batchDepth > 0) { - return; - } - if (batchedComputed) { - let e = batchedComputed; - batchedComputed = void 0; - while (e) { - const next = e.next; - e.next = void 0; - e.flags &= -9; - e = next; - } - } - let error; - while (batchedSub) { - let e = batchedSub; - batchedSub = void 0; - while (e) { - const next = e.next; - e.next = void 0; - e.flags &= -9; - if (e.flags & 1) { - try { - ; - e.trigger(); - } catch (err) { - if (!error) error = err; - } - } - e = next; - } - } - if (error) throw error; -} -function prepareDeps(sub) { - for (let link = sub.deps; link; link = link.nextDep) { - link.version = -1; - link.prevActiveLink = link.dep.activeLink; - link.dep.activeLink = link; - } -} -function cleanupDeps(sub) { - let head; - let tail = sub.depsTail; - let link = tail; - while (link) { - const prev = link.prevDep; - if (link.version === -1) { - if (link === tail) tail = prev; - removeSub(link); - removeDep(link); - } else { - head = link; - } - link.dep.activeLink = link.prevActiveLink; - link.prevActiveLink = void 0; - link = prev; - } - sub.deps = head; - sub.depsTail = tail; -} -function isDirty(sub) { - for (let link = sub.deps; link; link = link.nextDep) { - if (link.dep.version !== link.version || link.dep.computed && (refreshComputed(link.dep.computed) || link.dep.version !== link.version)) { - return true; - } - } - if (sub._dirty) { - return true; - } - return false; -} -function refreshComputed(computed3) { - if (computed3.flags & 4 && !(computed3.flags & 16)) { - return; - } - computed3.flags &= -17; - if (computed3.globalVersion === globalVersion) { - return; - } - computed3.globalVersion = globalVersion; - if (!computed3.isSSR && computed3.flags & 128 && (!computed3.deps && !computed3._dirty || !isDirty(computed3))) { - return; - } - computed3.flags |= 2; - const dep = computed3.dep; - const prevSub = activeSub; - const prevShouldTrack = shouldTrack; - activeSub = computed3; - shouldTrack = true; - try { - prepareDeps(computed3); - const value = computed3.fn(computed3._value); - if (dep.version === 0 || hasChanged(value, computed3._value)) { - computed3.flags |= 128; - computed3._value = value; - dep.version++; - } - } catch (err) { - dep.version++; - throw err; - } finally { - activeSub = prevSub; - shouldTrack = prevShouldTrack; - cleanupDeps(computed3); - computed3.flags &= -3; - } -} -function removeSub(link, soft = false) { - const { dep, prevSub, nextSub } = link; - if (prevSub) { - prevSub.nextSub = nextSub; - link.prevSub = void 0; - } - if (nextSub) { - nextSub.prevSub = prevSub; - link.nextSub = void 0; - } - if (dep.subsHead === link) { - dep.subsHead = nextSub; - } - if (dep.subs === link) { - dep.subs = prevSub; - if (!prevSub && dep.computed) { - dep.computed.flags &= -5; - for (let l = dep.computed.deps; l; l = l.nextDep) { - removeSub(l, true); - } - } - } - if (!soft && !--dep.sc && dep.map) { - dep.map.delete(dep.key); - } -} -function removeDep(link) { - const { prevDep, nextDep } = link; - if (prevDep) { - prevDep.nextDep = nextDep; - link.prevDep = void 0; - } - if (nextDep) { - nextDep.prevDep = prevDep; - link.nextDep = void 0; - } -} -function effect(fn, options) { - if (fn.effect instanceof ReactiveEffect) { - fn = fn.effect.fn; - } - const e = new ReactiveEffect(fn); - if (options) { - extend(e, options); - } - try { - e.run(); - } catch (err) { - e.stop(); - throw err; - } - const runner = e.run.bind(e); - runner.effect = e; - return runner; -} -function stop(runner) { - runner.effect.stop(); -} -var shouldTrack = true; -var trackStack = []; -function pauseTracking() { - trackStack.push(shouldTrack); - shouldTrack = false; -} -function resetTracking() { - const last = trackStack.pop(); - shouldTrack = last === void 0 ? true : last; -} -function cleanupEffect(e) { - const { cleanup } = e; - e.cleanup = void 0; - if (cleanup) { - const prevSub = activeSub; - activeSub = void 0; - try { - cleanup(); - } finally { - activeSub = prevSub; - } - } -} -var globalVersion = 0; -var Link = class { - constructor(sub, dep) { - this.sub = sub; - this.dep = dep; - this.version = dep.version; - this.nextDep = this.prevDep = this.nextSub = this.prevSub = this.prevActiveLink = void 0; - } -}; -var Dep = class { - // TODO isolatedDeclarations "__v_skip" - constructor(computed3) { - this.computed = computed3; - this.version = 0; - this.activeLink = void 0; - this.subs = void 0; - this.map = void 0; - this.key = void 0; - this.sc = 0; - this.__v_skip = true; - if (true) { - this.subsHead = void 0; - } - } - track(debugInfo) { - if (!activeSub || !shouldTrack || activeSub === this.computed) { - return; - } - let link = this.activeLink; - if (link === void 0 || link.sub !== activeSub) { - link = this.activeLink = new Link(activeSub, this); - if (!activeSub.deps) { - activeSub.deps = activeSub.depsTail = link; - } else { - link.prevDep = activeSub.depsTail; - activeSub.depsTail.nextDep = link; - activeSub.depsTail = link; - } - addSub(link); - } else if (link.version === -1) { - link.version = this.version; - if (link.nextDep) { - const next = link.nextDep; - next.prevDep = link.prevDep; - if (link.prevDep) { - link.prevDep.nextDep = next; - } - link.prevDep = activeSub.depsTail; - link.nextDep = void 0; - activeSub.depsTail.nextDep = link; - activeSub.depsTail = link; - if (activeSub.deps === link) { - activeSub.deps = next; - } - } - } - if (activeSub.onTrack) { - activeSub.onTrack( - extend( - { - effect: activeSub - }, - debugInfo - ) - ); - } - return link; - } - trigger(debugInfo) { - this.version++; - globalVersion++; - this.notify(debugInfo); - } - notify(debugInfo) { - startBatch(); - try { - if (true) { - for (let head = this.subsHead; head; head = head.nextSub) { - if (head.sub.onTrigger && !(head.sub.flags & 8)) { - head.sub.onTrigger( - extend( - { - effect: head.sub - }, - debugInfo - ) - ); - } - } - } - for (let link = this.subs; link; link = link.prevSub) { - if (link.sub.notify()) { - ; - link.sub.dep.notify(); - } - } - } finally { - endBatch(); - } - } -}; -function addSub(link) { - link.dep.sc++; - if (link.sub.flags & 4) { - const computed3 = link.dep.computed; - if (computed3 && !link.dep.subs) { - computed3.flags |= 4 | 16; - for (let l = computed3.deps; l; l = l.nextDep) { - addSub(l); - } - } - const currentTail = link.dep.subs; - if (currentTail !== link) { - link.prevSub = currentTail; - if (currentTail) currentTail.nextSub = link; - } - if (link.dep.subsHead === void 0) { - link.dep.subsHead = link; - } - link.dep.subs = link; - } -} -var targetMap = /* @__PURE__ */ new WeakMap(); -var ITERATE_KEY = Symbol( - true ? "Object iterate" : "" -); -var MAP_KEY_ITERATE_KEY = Symbol( - true ? "Map keys iterate" : "" -); -var ARRAY_ITERATE_KEY = Symbol( - true ? "Array iterate" : "" -); -function track(target, type, key) { - if (shouldTrack && activeSub) { - let depsMap = targetMap.get(target); - if (!depsMap) { - targetMap.set(target, depsMap = /* @__PURE__ */ new Map()); - } - let dep = depsMap.get(key); - if (!dep) { - depsMap.set(key, dep = new Dep()); - dep.map = depsMap; - dep.key = key; - } - if (true) { - dep.track({ - target, - type, - key - }); - } else { - dep.track(); - } - } -} -function trigger(target, type, key, newValue, oldValue, oldTarget) { - const depsMap = targetMap.get(target); - if (!depsMap) { - globalVersion++; - return; - } - const run = (dep) => { - if (dep) { - if (true) { - dep.trigger({ - target, - type, - key, - newValue, - oldValue, - oldTarget - }); - } else { - dep.trigger(); - } - } - }; - startBatch(); - if (type === "clear") { - depsMap.forEach(run); - } else { - const targetIsArray = isArray(target); - const isArrayIndex = targetIsArray && isIntegerKey(key); - if (targetIsArray && key === "length") { - const newLength = Number(newValue); - depsMap.forEach((dep, key2) => { - if (key2 === "length" || key2 === ARRAY_ITERATE_KEY || !isSymbol(key2) && key2 >= newLength) { - run(dep); - } - }); - } else { - if (key !== void 0 || depsMap.has(void 0)) { - run(depsMap.get(key)); - } - if (isArrayIndex) { - run(depsMap.get(ARRAY_ITERATE_KEY)); - } - switch (type) { - case "add": - if (!targetIsArray) { - run(depsMap.get(ITERATE_KEY)); - if (isMap(target)) { - run(depsMap.get(MAP_KEY_ITERATE_KEY)); - } - } else if (isArrayIndex) { - run(depsMap.get("length")); - } - break; - case "delete": - if (!targetIsArray) { - run(depsMap.get(ITERATE_KEY)); - if (isMap(target)) { - run(depsMap.get(MAP_KEY_ITERATE_KEY)); - } - } - break; - case "set": - if (isMap(target)) { - run(depsMap.get(ITERATE_KEY)); - } - break; - } - } - } - endBatch(); -} -function getDepFromReactive(object, key) { - const depMap = targetMap.get(object); - return depMap && depMap.get(key); -} -function reactiveReadArray(array) { - const raw = toRaw(array); - if (raw === array) return raw; - track(raw, "iterate", ARRAY_ITERATE_KEY); - return isShallow(array) ? raw : raw.map(toReactive); -} -function shallowReadArray(arr) { - track(arr = toRaw(arr), "iterate", ARRAY_ITERATE_KEY); - return arr; -} -function toWrapped(target, item) { - if (isReadonly(target)) { - return isReactive(target) ? toReadonly(toReactive(item)) : toReadonly(item); - } - return toReactive(item); -} -var arrayInstrumentations = { - __proto__: null, - [Symbol.iterator]() { - return iterator(this, Symbol.iterator, (item) => toWrapped(this, item)); - }, - concat(...args) { - return reactiveReadArray(this).concat( - ...args.map((x) => isArray(x) ? reactiveReadArray(x) : x) - ); - }, - entries() { - return iterator(this, "entries", (value) => { - value[1] = toWrapped(this, value[1]); - return value; - }); - }, - every(fn, thisArg) { - return apply(this, "every", fn, thisArg, void 0, arguments); - }, - filter(fn, thisArg) { - return apply( - this, - "filter", - fn, - thisArg, - (v) => v.map((item) => toWrapped(this, item)), - arguments - ); - }, - find(fn, thisArg) { - return apply( - this, - "find", - fn, - thisArg, - (item) => toWrapped(this, item), - arguments - ); - }, - findIndex(fn, thisArg) { - return apply(this, "findIndex", fn, thisArg, void 0, arguments); - }, - findLast(fn, thisArg) { - return apply( - this, - "findLast", - fn, - thisArg, - (item) => toWrapped(this, item), - arguments - ); - }, - findLastIndex(fn, thisArg) { - return apply(this, "findLastIndex", fn, thisArg, void 0, arguments); - }, - // flat, flatMap could benefit from ARRAY_ITERATE but are not straight-forward to implement - forEach(fn, thisArg) { - return apply(this, "forEach", fn, thisArg, void 0, arguments); - }, - includes(...args) { - return searchProxy(this, "includes", args); - }, - indexOf(...args) { - return searchProxy(this, "indexOf", args); - }, - join(separator) { - return reactiveReadArray(this).join(separator); - }, - // keys() iterator only reads `length`, no optimization required - lastIndexOf(...args) { - return searchProxy(this, "lastIndexOf", args); - }, - map(fn, thisArg) { - return apply(this, "map", fn, thisArg, void 0, arguments); - }, - pop() { - return noTracking(this, "pop"); - }, - push(...args) { - return noTracking(this, "push", args); - }, - reduce(fn, ...args) { - return reduce(this, "reduce", fn, args); - }, - reduceRight(fn, ...args) { - return reduce(this, "reduceRight", fn, args); - }, - shift() { - return noTracking(this, "shift"); - }, - // slice could use ARRAY_ITERATE but also seems to beg for range tracking - some(fn, thisArg) { - return apply(this, "some", fn, thisArg, void 0, arguments); - }, - splice(...args) { - return noTracking(this, "splice", args); - }, - toReversed() { - return reactiveReadArray(this).toReversed(); - }, - toSorted(comparer) { - return reactiveReadArray(this).toSorted(comparer); - }, - toSpliced(...args) { - return reactiveReadArray(this).toSpliced(...args); - }, - unshift(...args) { - return noTracking(this, "unshift", args); - }, - values() { - return iterator(this, "values", (item) => toWrapped(this, item)); - } -}; -function iterator(self2, method, wrapValue) { - const arr = shallowReadArray(self2); - const iter = arr[method](); - if (arr !== self2 && !isShallow(self2)) { - iter._next = iter.next; - iter.next = () => { - const result = iter._next(); - if (!result.done) { - result.value = wrapValue(result.value); - } - return result; - }; - } - return iter; -} -var arrayProto = Array.prototype; -function apply(self2, method, fn, thisArg, wrappedRetFn, args) { - const arr = shallowReadArray(self2); - const needsWrap = arr !== self2 && !isShallow(self2); - const methodFn = arr[method]; - if (methodFn !== arrayProto[method]) { - const result2 = methodFn.apply(self2, args); - return needsWrap ? toReactive(result2) : result2; - } - let wrappedFn = fn; - if (arr !== self2) { - if (needsWrap) { - wrappedFn = function(item, index) { - return fn.call(this, toWrapped(self2, item), index, self2); - }; - } else if (fn.length > 2) { - wrappedFn = function(item, index) { - return fn.call(this, item, index, self2); - }; - } - } - const result = methodFn.call(arr, wrappedFn, thisArg); - return needsWrap && wrappedRetFn ? wrappedRetFn(result) : result; -} -function reduce(self2, method, fn, args) { - const arr = shallowReadArray(self2); - const needsWrap = arr !== self2 && !isShallow(self2); - let wrappedFn = fn; - let wrapInitialAccumulator = false; - if (arr !== self2) { - if (needsWrap) { - wrapInitialAccumulator = args.length === 0; - wrappedFn = function(acc, item, index) { - if (wrapInitialAccumulator) { - wrapInitialAccumulator = false; - acc = toWrapped(self2, acc); - } - return fn.call(this, acc, toWrapped(self2, item), index, self2); - }; - } else if (fn.length > 3) { - wrappedFn = function(acc, item, index) { - return fn.call(this, acc, item, index, self2); - }; - } - } - const result = arr[method](wrappedFn, ...args); - return wrapInitialAccumulator ? toWrapped(self2, result) : result; -} -function searchProxy(self2, method, args) { - const arr = toRaw(self2); - track(arr, "iterate", ARRAY_ITERATE_KEY); - const res = arr[method](...args); - if ((res === -1 || res === false) && isProxy(args[0])) { - args[0] = toRaw(args[0]); - return arr[method](...args); - } - return res; -} -function noTracking(self2, method, args = []) { - pauseTracking(); - startBatch(); - const res = toRaw(self2)[method].apply(self2, args); - endBatch(); - resetTracking(); - return res; -} -var isNonTrackableKeys = makeMap(`__proto__,__v_isRef,__isVue`); -var builtInSymbols = new Set( - Object.getOwnPropertyNames(Symbol).filter((key) => key !== "arguments" && key !== "caller").map((key) => Symbol[key]).filter(isSymbol) -); -function hasOwnProperty2(key) { - if (!isSymbol(key)) key = String(key); - const obj = toRaw(this); - track(obj, "has", key); - return obj.hasOwnProperty(key); -} -var BaseReactiveHandler = class { - constructor(_isReadonly = false, _isShallow = false) { - this._isReadonly = _isReadonly; - this._isShallow = _isShallow; - } - get(target, key, receiver) { - if (key === "__v_skip") return target["__v_skip"]; - const isReadonly2 = this._isReadonly, isShallow2 = this._isShallow; - if (key === "__v_isReactive") { - return !isReadonly2; - } else if (key === "__v_isReadonly") { - return isReadonly2; - } else if (key === "__v_isShallow") { - return isShallow2; - } else if (key === "__v_raw") { - if (receiver === (isReadonly2 ? isShallow2 ? shallowReadonlyMap : readonlyMap : isShallow2 ? shallowReactiveMap : reactiveMap).get(target) || // receiver is not the reactive proxy, but has the same prototype - // this means the receiver is a user proxy of the reactive proxy - Object.getPrototypeOf(target) === Object.getPrototypeOf(receiver)) { - return target; - } - return; - } - const targetIsArray = isArray(target); - if (!isReadonly2) { - let fn; - if (targetIsArray && (fn = arrayInstrumentations[key])) { - return fn; - } - if (key === "hasOwnProperty") { - return hasOwnProperty2; - } - } - const res = Reflect.get( - target, - key, - // if this is a proxy wrapping a ref, return methods using the raw ref - // as receiver so that we don't have to call `toRaw` on the ref in all - // its class methods - isRef2(target) ? target : receiver - ); - if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) { - return res; - } - if (!isReadonly2) { - track(target, "get", key); - } - if (isShallow2) { - return res; - } - if (isRef2(res)) { - const value = targetIsArray && isIntegerKey(key) ? res : res.value; - return isReadonly2 && isObject(value) ? readonly(value) : value; - } - if (isObject(res)) { - return isReadonly2 ? readonly(res) : reactive(res); - } - return res; - } -}; -var MutableReactiveHandler = class extends BaseReactiveHandler { - constructor(isShallow2 = false) { - super(false, isShallow2); - } - set(target, key, value, receiver) { - let oldValue = target[key]; - const isArrayWithIntegerKey = isArray(target) && isIntegerKey(key); - if (!this._isShallow) { - const isOldValueReadonly = isReadonly(oldValue); - if (!isShallow(value) && !isReadonly(value)) { - oldValue = toRaw(oldValue); - value = toRaw(value); - } - if (!isArrayWithIntegerKey && isRef2(oldValue) && !isRef2(value)) { - if (isOldValueReadonly) { - if (true) { - warn( - `Set operation on key "${String(key)}" failed: target is readonly.`, - target[key] - ); - } - return true; - } else { - oldValue.value = value; - return true; - } - } - } - const hadKey = isArrayWithIntegerKey ? Number(key) < target.length : hasOwn(target, key); - const result = Reflect.set( - target, - key, - value, - isRef2(target) ? target : receiver - ); - if (target === toRaw(receiver)) { - if (!hadKey) { - trigger(target, "add", key, value); - } else if (hasChanged(value, oldValue)) { - trigger(target, "set", key, value, oldValue); - } - } - return result; - } - deleteProperty(target, key) { - const hadKey = hasOwn(target, key); - const oldValue = target[key]; - const result = Reflect.deleteProperty(target, key); - if (result && hadKey) { - trigger(target, "delete", key, void 0, oldValue); - } - return result; - } - has(target, key) { - const result = Reflect.has(target, key); - if (!isSymbol(key) || !builtInSymbols.has(key)) { - track(target, "has", key); - } - return result; - } - ownKeys(target) { - track( - target, - "iterate", - isArray(target) ? "length" : ITERATE_KEY - ); - return Reflect.ownKeys(target); - } -}; -var ReadonlyReactiveHandler = class extends BaseReactiveHandler { - constructor(isShallow2 = false) { - super(true, isShallow2); - } - set(target, key) { - if (true) { - warn( - `Set operation on key "${String(key)}" failed: target is readonly.`, - target - ); - } - return true; - } - deleteProperty(target, key) { - if (true) { - warn( - `Delete operation on key "${String(key)}" failed: target is readonly.`, - target - ); - } - return true; - } -}; -var mutableHandlers = new MutableReactiveHandler(); -var readonlyHandlers = new ReadonlyReactiveHandler(); -var shallowReactiveHandlers = new MutableReactiveHandler(true); -var shallowReadonlyHandlers = new ReadonlyReactiveHandler(true); -var toShallow = (value) => value; -var getProto = (v) => Reflect.getPrototypeOf(v); -function createIterableMethod(method, isReadonly2, isShallow2) { - return function(...args) { - const target = this["__v_raw"]; - const rawTarget = toRaw(target); - const targetIsMap = isMap(rawTarget); - const isPair = method === "entries" || method === Symbol.iterator && targetIsMap; - const isKeyOnly = method === "keys" && targetIsMap; - const innerIterator = target[method](...args); - const wrap = isShallow2 ? toShallow : isReadonly2 ? toReadonly : toReactive; - !isReadonly2 && track( - rawTarget, - "iterate", - isKeyOnly ? MAP_KEY_ITERATE_KEY : ITERATE_KEY - ); - return extend( - // inheriting all iterator properties - Object.create(innerIterator), - { - // iterator protocol - next() { - const { value, done } = innerIterator.next(); - return done ? { value, done } : { - value: isPair ? [wrap(value[0]), wrap(value[1])] : wrap(value), - done - }; - } - } - ); - }; -} -function createReadonlyMethod(type) { - return function(...args) { - if (true) { - const key = args[0] ? `on key "${args[0]}" ` : ``; - warn( - `${capitalize(type)} operation ${key}failed: target is readonly.`, - toRaw(this) - ); - } - return type === "delete" ? false : type === "clear" ? void 0 : this; - }; -} -function createInstrumentations(readonly2, shallow) { - const instrumentations = { - get(key) { - const target = this["__v_raw"]; - const rawTarget = toRaw(target); - const rawKey = toRaw(key); - if (!readonly2) { - if (hasChanged(key, rawKey)) { - track(rawTarget, "get", key); - } - track(rawTarget, "get", rawKey); - } - const { has } = getProto(rawTarget); - const wrap = shallow ? toShallow : readonly2 ? toReadonly : toReactive; - if (has.call(rawTarget, key)) { - return wrap(target.get(key)); - } else if (has.call(rawTarget, rawKey)) { - return wrap(target.get(rawKey)); - } else if (target !== rawTarget) { - target.get(key); - } - }, - get size() { - const target = this["__v_raw"]; - !readonly2 && track(toRaw(target), "iterate", ITERATE_KEY); - return target.size; - }, - has(key) { - const target = this["__v_raw"]; - const rawTarget = toRaw(target); - const rawKey = toRaw(key); - if (!readonly2) { - if (hasChanged(key, rawKey)) { - track(rawTarget, "has", key); - } - track(rawTarget, "has", rawKey); - } - return key === rawKey ? target.has(key) : target.has(key) || target.has(rawKey); - }, - forEach(callback, thisArg) { - const observed = this; - const target = observed["__v_raw"]; - const rawTarget = toRaw(target); - const wrap = shallow ? toShallow : readonly2 ? toReadonly : toReactive; - !readonly2 && track(rawTarget, "iterate", ITERATE_KEY); - return target.forEach((value, key) => { - return callback.call(thisArg, wrap(value), wrap(key), observed); - }); - } - }; - extend( - instrumentations, - readonly2 ? { - add: createReadonlyMethod("add"), - set: createReadonlyMethod("set"), - delete: createReadonlyMethod("delete"), - clear: createReadonlyMethod("clear") - } : { - add(value) { - const target = toRaw(this); - const proto = getProto(target); - const rawValue = toRaw(value); - const valueToAdd = !shallow && !isShallow(value) && !isReadonly(value) ? rawValue : value; - const hadKey = proto.has.call(target, valueToAdd) || hasChanged(value, valueToAdd) && proto.has.call(target, value) || hasChanged(rawValue, valueToAdd) && proto.has.call(target, rawValue); - if (!hadKey) { - target.add(valueToAdd); - trigger(target, "add", valueToAdd, valueToAdd); - } - return this; - }, - set(key, value) { - if (!shallow && !isShallow(value) && !isReadonly(value)) { - value = toRaw(value); - } - const target = toRaw(this); - const { has, get } = getProto(target); - let hadKey = has.call(target, key); - if (!hadKey) { - key = toRaw(key); - hadKey = has.call(target, key); - } else if (true) { - checkIdentityKeys(target, has, key); - } - const oldValue = get.call(target, key); - target.set(key, value); - if (!hadKey) { - trigger(target, "add", key, value); - } else if (hasChanged(value, oldValue)) { - trigger(target, "set", key, value, oldValue); - } - return this; - }, - delete(key) { - const target = toRaw(this); - const { has, get } = getProto(target); - let hadKey = has.call(target, key); - if (!hadKey) { - key = toRaw(key); - hadKey = has.call(target, key); - } else if (true) { - checkIdentityKeys(target, has, key); - } - const oldValue = get ? get.call(target, key) : void 0; - const result = target.delete(key); - if (hadKey) { - trigger(target, "delete", key, void 0, oldValue); - } - return result; - }, - clear() { - const target = toRaw(this); - const hadItems = target.size !== 0; - const oldTarget = true ? isMap(target) ? new Map(target) : new Set(target) : void 0; - const result = target.clear(); - if (hadItems) { - trigger( - target, - "clear", - void 0, - void 0, - oldTarget - ); - } - return result; - } - } - ); - const iteratorMethods = [ - "keys", - "values", - "entries", - Symbol.iterator - ]; - iteratorMethods.forEach((method) => { - instrumentations[method] = createIterableMethod(method, readonly2, shallow); - }); - return instrumentations; -} -function createInstrumentationGetter(isReadonly2, shallow) { - const instrumentations = createInstrumentations(isReadonly2, shallow); - return (target, key, receiver) => { - if (key === "__v_isReactive") { - return !isReadonly2; - } else if (key === "__v_isReadonly") { - return isReadonly2; - } else if (key === "__v_raw") { - return target; - } - return Reflect.get( - hasOwn(instrumentations, key) && key in target ? instrumentations : target, - key, - receiver - ); - }; -} -var mutableCollectionHandlers = { - get: createInstrumentationGetter(false, false) -}; -var shallowCollectionHandlers = { - get: createInstrumentationGetter(false, true) -}; -var readonlyCollectionHandlers = { - get: createInstrumentationGetter(true, false) -}; -var shallowReadonlyCollectionHandlers = { - get: createInstrumentationGetter(true, true) -}; -function checkIdentityKeys(target, has, key) { - const rawKey = toRaw(key); - if (rawKey !== key && has.call(target, rawKey)) { - const type = toRawType(target); - warn( - `Reactive ${type} contains both the raw and reactive versions of the same object${type === `Map` ? ` as keys` : ``}, which can lead to inconsistencies. Avoid differentiating between the raw and reactive versions of an object and only use the reactive version if possible.` - ); - } -} -var reactiveMap = /* @__PURE__ */ new WeakMap(); -var shallowReactiveMap = /* @__PURE__ */ new WeakMap(); -var readonlyMap = /* @__PURE__ */ new WeakMap(); -var shallowReadonlyMap = /* @__PURE__ */ new WeakMap(); -function targetTypeMap(rawType) { - switch (rawType) { - case "Object": - case "Array": - return 1; - case "Map": - case "Set": - case "WeakMap": - case "WeakSet": - return 2; - default: - return 0; - } -} -function reactive(target) { - if (isReadonly(target)) { - return target; - } - return createReactiveObject( - target, - false, - mutableHandlers, - mutableCollectionHandlers, - reactiveMap - ); -} -function shallowReactive(target) { - return createReactiveObject( - target, - false, - shallowReactiveHandlers, - shallowCollectionHandlers, - shallowReactiveMap - ); -} -function readonly(target) { - return createReactiveObject( - target, - true, - readonlyHandlers, - readonlyCollectionHandlers, - readonlyMap - ); -} -function shallowReadonly(target) { - return createReactiveObject( - target, - true, - shallowReadonlyHandlers, - shallowReadonlyCollectionHandlers, - shallowReadonlyMap - ); -} -function createReactiveObject(target, isReadonly2, baseHandlers, collectionHandlers, proxyMap) { - if (!isObject(target)) { - if (true) { - warn( - `value cannot be made ${isReadonly2 ? "readonly" : "reactive"}: ${String( - target - )}` - ); - } - return target; - } - if (target["__v_raw"] && !(isReadonly2 && target["__v_isReactive"])) { - return target; - } - if (target["__v_skip"] || !Object.isExtensible(target)) { - return target; - } - const existingProxy = proxyMap.get(target); - if (existingProxy) { - return existingProxy; - } - const targetType = targetTypeMap(toRawType(target)); - if (targetType === 0) { - return target; - } - const proxy = new Proxy( - target, - targetType === 2 ? collectionHandlers : baseHandlers - ); - proxyMap.set(target, proxy); - return proxy; -} -function isReactive(value) { - if (isReadonly(value)) { - return isReactive(value["__v_raw"]); - } - return !!(value && value["__v_isReactive"]); -} -function isReadonly(value) { - return !!(value && value["__v_isReadonly"]); -} -function isShallow(value) { - return !!(value && value["__v_isShallow"]); -} -function isProxy(value) { - return value ? !!value["__v_raw"] : false; -} -function toRaw(observed) { - const raw = observed && observed["__v_raw"]; - return raw ? toRaw(raw) : observed; -} -function markRaw(value) { - if (!hasOwn(value, "__v_skip") && Object.isExtensible(value)) { - def(value, "__v_skip", true); - } - return value; -} -var toReactive = (value) => isObject(value) ? reactive(value) : value; -var toReadonly = (value) => isObject(value) ? readonly(value) : value; -function isRef2(r) { - return r ? r["__v_isRef"] === true : false; -} -function ref(value) { - return createRef(value, false); -} -function shallowRef(value) { - return createRef(value, true); -} -function createRef(rawValue, shallow) { - if (isRef2(rawValue)) { - return rawValue; - } - return new RefImpl(rawValue, shallow); -} -var RefImpl = class { - constructor(value, isShallow2) { - this.dep = new Dep(); - this["__v_isRef"] = true; - this["__v_isShallow"] = false; - this._rawValue = isShallow2 ? value : toRaw(value); - this._value = isShallow2 ? value : toReactive(value); - this["__v_isShallow"] = isShallow2; - } - get value() { - if (true) { - this.dep.track({ - target: this, - type: "get", - key: "value" - }); - } else { - this.dep.track(); - } - return this._value; - } - set value(newValue) { - const oldValue = this._rawValue; - const useDirectValue = this["__v_isShallow"] || isShallow(newValue) || isReadonly(newValue); - newValue = useDirectValue ? newValue : toRaw(newValue); - if (hasChanged(newValue, oldValue)) { - this._rawValue = newValue; - this._value = useDirectValue ? newValue : toReactive(newValue); - if (true) { - this.dep.trigger({ - target: this, - type: "set", - key: "value", - newValue, - oldValue - }); - } else { - this.dep.trigger(); - } - } - } -}; -function triggerRef(ref2) { - if (ref2.dep) { - if (true) { - ref2.dep.trigger({ - target: ref2, - type: "set", - key: "value", - newValue: ref2._value - }); - } else { - ref2.dep.trigger(); - } - } -} -function unref(ref2) { - return isRef2(ref2) ? ref2.value : ref2; -} -function toValue(source) { - return isFunction(source) ? source() : unref(source); -} -var shallowUnwrapHandlers = { - get: (target, key, receiver) => key === "__v_raw" ? target : unref(Reflect.get(target, key, receiver)), - set: (target, key, value, receiver) => { - const oldValue = target[key]; - if (isRef2(oldValue) && !isRef2(value)) { - oldValue.value = value; - return true; - } else { - return Reflect.set(target, key, value, receiver); - } - } -}; -function proxyRefs(objectWithRefs) { - return isReactive(objectWithRefs) ? objectWithRefs : new Proxy(objectWithRefs, shallowUnwrapHandlers); -} -var CustomRefImpl = class { - constructor(factory) { - this["__v_isRef"] = true; - this._value = void 0; - const dep = this.dep = new Dep(); - const { get, set } = factory(dep.track.bind(dep), dep.trigger.bind(dep)); - this._get = get; - this._set = set; - } - get value() { - return this._value = this._get(); - } - set value(newVal) { - this._set(newVal); - } -}; -function customRef(factory) { - return new CustomRefImpl(factory); -} -function toRefs(object) { - if (!isProxy(object)) { - warn(`toRefs() expects a reactive object but received a plain one.`); - } - const ret = isArray(object) ? new Array(object.length) : {}; - for (const key in object) { - ret[key] = propertyToRef(object, key); - } - return ret; -} -var ObjectRefImpl = class { - constructor(_object, key, _defaultValue) { - this._object = _object; - this._defaultValue = _defaultValue; - this["__v_isRef"] = true; - this._value = void 0; - this._key = isSymbol(key) ? key : String(key); - this._raw = toRaw(_object); - let shallow = true; - let obj = _object; - if (!isArray(_object) || isSymbol(this._key) || !isIntegerKey(this._key)) { - do { - shallow = !isProxy(obj) || isShallow(obj); - } while (shallow && (obj = obj["__v_raw"])); - } - this._shallow = shallow; - } - get value() { - let val = this._object[this._key]; - if (this._shallow) { - val = unref(val); - } - return this._value = val === void 0 ? this._defaultValue : val; - } - set value(newVal) { - if (this._shallow && isRef2(this._raw[this._key])) { - const nestedRef = this._object[this._key]; - if (isRef2(nestedRef)) { - nestedRef.value = newVal; - return; - } - } - this._object[this._key] = newVal; - } - get dep() { - return getDepFromReactive(this._raw, this._key); - } -}; -var GetterRefImpl = class { - constructor(_getter) { - this._getter = _getter; - this["__v_isRef"] = true; - this["__v_isReadonly"] = true; - this._value = void 0; - } - get value() { - return this._value = this._getter(); - } -}; -function toRef(source, key, defaultValue) { - if (isRef2(source)) { - return source; - } else if (isFunction(source)) { - return new GetterRefImpl(source); - } else if (isObject(source) && arguments.length > 1) { - return propertyToRef(source, key, defaultValue); - } else { - return ref(source); - } -} -function propertyToRef(source, key, defaultValue) { - return new ObjectRefImpl(source, key, defaultValue); -} -var ComputedRefImpl = class { - constructor(fn, setter, isSSR) { - this.fn = fn; - this.setter = setter; - this._value = void 0; - this.dep = new Dep(this); - this.__v_isRef = true; - this.deps = void 0; - this.depsTail = void 0; - this.flags = 16; - this.globalVersion = globalVersion - 1; - this.next = void 0; - this.effect = this; - this["__v_isReadonly"] = !setter; - this.isSSR = isSSR; - } - /** - * @internal - */ - notify() { - this.flags |= 16; - if (!(this.flags & 8) && // avoid infinite self recursion - activeSub !== this) { - batch(this, true); - return true; - } else if (true) ; - } - get value() { - const link = true ? this.dep.track({ - target: this, - type: "get", - key: "value" - }) : this.dep.track(); - refreshComputed(this); - if (link) { - link.version = this.dep.version; - } - return this._value; - } - set value(newValue) { - if (this.setter) { - this.setter(newValue); - } else if (true) { - warn("Write operation failed: computed value is readonly"); - } - } -}; -function computed(getterOrOptions, debugOptions, isSSR = false) { - let getter; - let setter; - if (isFunction(getterOrOptions)) { - getter = getterOrOptions; - } else { - getter = getterOrOptions.get; - setter = getterOrOptions.set; - } - const cRef = new ComputedRefImpl(getter, setter, isSSR); - if (debugOptions && !isSSR) { - cRef.onTrack = debugOptions.onTrack; - cRef.onTrigger = debugOptions.onTrigger; - } - return cRef; -} -var TrackOpTypes = { - "GET": "get", - "HAS": "has", - "ITERATE": "iterate" -}; -var TriggerOpTypes = { - "SET": "set", - "ADD": "add", - "DELETE": "delete", - "CLEAR": "clear" -}; -var INITIAL_WATCHER_VALUE = {}; -var cleanupMap = /* @__PURE__ */ new WeakMap(); -var activeWatcher = void 0; -function getCurrentWatcher() { - return activeWatcher; -} -function onWatcherCleanup(cleanupFn, failSilently = false, owner = activeWatcher) { - if (owner) { - let cleanups = cleanupMap.get(owner); - if (!cleanups) cleanupMap.set(owner, cleanups = []); - cleanups.push(cleanupFn); - } else if (!failSilently) { - warn( - `onWatcherCleanup() was called when there was no active watcher to associate with.` - ); - } -} -function watch(source, cb, options = EMPTY_OBJ) { - const { immediate, deep, once, scheduler, augmentJob, call } = options; - const warnInvalidSource = (s) => { - (options.onWarn || warn)( - `Invalid watch source: `, - s, - `A watch source can only be a getter/effect function, a ref, a reactive object, or an array of these types.` - ); - }; - const reactiveGetter = (source2) => { - if (deep) return source2; - if (isShallow(source2) || deep === false || deep === 0) - return traverse(source2, 1); - return traverse(source2); - }; - let effect2; - let getter; - let cleanup; - let boundCleanup; - let forceTrigger = false; - let isMultiSource = false; - if (isRef2(source)) { - getter = () => source.value; - forceTrigger = isShallow(source); - } else if (isReactive(source)) { - getter = () => reactiveGetter(source); - forceTrigger = true; - } else if (isArray(source)) { - isMultiSource = true; - forceTrigger = source.some((s) => isReactive(s) || isShallow(s)); - getter = () => source.map((s) => { - if (isRef2(s)) { - return s.value; - } else if (isReactive(s)) { - return reactiveGetter(s); - } else if (isFunction(s)) { - return call ? call(s, 2) : s(); - } else { - warnInvalidSource(s); - } - }); - } else if (isFunction(source)) { - if (cb) { - getter = call ? () => call(source, 2) : source; - } else { - getter = () => { - if (cleanup) { - pauseTracking(); - try { - cleanup(); - } finally { - resetTracking(); - } - } - const currentEffect = activeWatcher; - activeWatcher = effect2; - try { - return call ? call(source, 3, [boundCleanup]) : source(boundCleanup); - } finally { - activeWatcher = currentEffect; - } - }; - } - } else { - getter = NOOP; - warnInvalidSource(source); - } - if (cb && deep) { - const baseGetter = getter; - const depth = deep === true ? Infinity : deep; - getter = () => traverse(baseGetter(), depth); - } - const scope = getCurrentScope(); - const watchHandle = () => { - effect2.stop(); - if (scope && scope.active) { - remove(scope.effects, effect2); - } - }; - if (once && cb) { - const _cb = cb; - cb = (...args) => { - _cb(...args); - watchHandle(); - }; - } - let oldValue = isMultiSource ? new Array(source.length).fill(INITIAL_WATCHER_VALUE) : INITIAL_WATCHER_VALUE; - const job = (immediateFirstRun) => { - if (!(effect2.flags & 1) || !effect2.dirty && !immediateFirstRun) { - return; - } - if (cb) { - const newValue = effect2.run(); - if (deep || forceTrigger || (isMultiSource ? newValue.some((v, i) => hasChanged(v, oldValue[i])) : hasChanged(newValue, oldValue))) { - if (cleanup) { - cleanup(); - } - const currentWatcher = activeWatcher; - activeWatcher = effect2; - try { - const args = [ - newValue, - // pass undefined as the old value when it's changed for the first time - oldValue === INITIAL_WATCHER_VALUE ? void 0 : isMultiSource && oldValue[0] === INITIAL_WATCHER_VALUE ? [] : oldValue, - boundCleanup - ]; - oldValue = newValue; - call ? call(cb, 3, args) : ( - // @ts-expect-error - cb(...args) - ); - } finally { - activeWatcher = currentWatcher; - } - } - } else { - effect2.run(); - } - }; - if (augmentJob) { - augmentJob(job); - } - effect2 = new ReactiveEffect(getter); - effect2.scheduler = scheduler ? () => scheduler(job, false) : job; - boundCleanup = (fn) => onWatcherCleanup(fn, false, effect2); - cleanup = effect2.onStop = () => { - const cleanups = cleanupMap.get(effect2); - if (cleanups) { - if (call) { - call(cleanups, 4); - } else { - for (const cleanup2 of cleanups) cleanup2(); - } - cleanupMap.delete(effect2); - } - }; - if (true) { - effect2.onTrack = options.onTrack; - effect2.onTrigger = options.onTrigger; - } - if (cb) { - if (immediate) { - job(true); - } else { - oldValue = effect2.run(); - } - } else if (scheduler) { - scheduler(job.bind(null, true), true); - } else { - effect2.run(); - } - watchHandle.pause = effect2.pause.bind(effect2); - watchHandle.resume = effect2.resume.bind(effect2); - watchHandle.stop = watchHandle; - return watchHandle; -} -function traverse(value, depth = Infinity, seen) { - if (depth <= 0 || !isObject(value) || value["__v_skip"]) { - return value; - } - seen = seen || /* @__PURE__ */ new Map(); - if ((seen.get(value) || 0) >= depth) { - return value; - } - seen.set(value, depth); - depth--; - if (isRef2(value)) { - traverse(value.value, depth, seen); - } else if (isArray(value)) { - for (let i = 0; i < value.length; i++) { - traverse(value[i], depth, seen); - } - } else if (isSet(value) || isMap(value)) { - value.forEach((v) => { - traverse(v, depth, seen); - }); - } else if (isPlainObject(value)) { - for (const key in value) { - traverse(value[key], depth, seen); - } - for (const key of Object.getOwnPropertySymbols(value)) { - if (Object.prototype.propertyIsEnumerable.call(value, key)) { - traverse(value[key], depth, seen); - } - } - } - return value; -} - -// node_modules/.pnpm/@vue+runtime-core@3.5.35/node_modules/@vue/runtime-core/dist/runtime-core.esm-bundler.js -var stack = []; -function pushWarningContext(vnode) { - stack.push(vnode); -} -function popWarningContext() { - stack.pop(); -} -var isWarning = false; -function warn$1(msg, ...args) { - if (isWarning) return; - isWarning = true; - pauseTracking(); - const instance = stack.length ? stack[stack.length - 1].component : null; - const appWarnHandler = instance && instance.appContext.config.warnHandler; - const trace = getComponentTrace(); - if (appWarnHandler) { - callWithErrorHandling( - appWarnHandler, - instance, - 11, - [ - // eslint-disable-next-line no-restricted-syntax - msg + args.map((a) => { - var _a, _b; - return (_b = (_a = a.toString) == null ? void 0 : _a.call(a)) != null ? _b : JSON.stringify(a); - }).join(""), - instance && instance.proxy, - trace.map( - ({ vnode }) => `at <${formatComponentName(instance, vnode.type)}>` - ).join("\n"), - trace - ] - ); - } else { - const warnArgs = [`[Vue warn]: ${msg}`, ...args]; - if (trace.length && // avoid spamming console during tests - true) { - warnArgs.push(` -`, ...formatTrace(trace)); - } - console.warn(...warnArgs); - } - resetTracking(); - isWarning = false; -} -function getComponentTrace() { - let currentVNode = stack[stack.length - 1]; - if (!currentVNode) { - return []; - } - const normalizedStack = []; - while (currentVNode) { - const last = normalizedStack[0]; - if (last && last.vnode === currentVNode) { - last.recurseCount++; - } else { - normalizedStack.push({ - vnode: currentVNode, - recurseCount: 0 - }); - } - const parentInstance = currentVNode.component && currentVNode.component.parent; - currentVNode = parentInstance && parentInstance.vnode; - } - return normalizedStack; -} -function formatTrace(trace) { - const logs = []; - trace.forEach((entry, i) => { - logs.push(...i === 0 ? [] : [` -`], ...formatTraceEntry(entry)); - }); - return logs; -} -function formatTraceEntry({ vnode, recurseCount }) { - const postfix = recurseCount > 0 ? `... (${recurseCount} recursive calls)` : ``; - const isRoot = vnode.component ? vnode.component.parent == null : false; - const open = ` at <${formatComponentName( - vnode.component, - vnode.type, - isRoot - )}`; - const close = `>` + postfix; - return vnode.props ? [open, ...formatProps(vnode.props), close] : [open + close]; -} -function formatProps(props) { - const res = []; - const keys = Object.keys(props); - keys.slice(0, 3).forEach((key) => { - res.push(...formatProp(key, props[key])); - }); - if (keys.length > 3) { - res.push(` ...`); - } - return res; -} -function formatProp(key, value, raw) { - if (isString(value)) { - value = JSON.stringify(value); - return raw ? value : [`${key}=${value}`]; - } else if (typeof value === "number" || typeof value === "boolean" || value == null) { - return raw ? value : [`${key}=${value}`]; - } else if (isRef2(value)) { - value = formatProp(key, toRaw(value.value), true); - return raw ? value : [`${key}=Ref<`, value, `>`]; - } else if (isFunction(value)) { - return [`${key}=fn${value.name ? `<${value.name}>` : ``}`]; - } else { - value = toRaw(value); - return raw ? value : [`${key}=`, value]; - } -} -function assertNumber(val, type) { - if (false) return; - if (val === void 0) { - return; - } else if (typeof val !== "number") { - warn$1(`${type} is not a valid number - got ${JSON.stringify(val)}.`); - } else if (isNaN(val)) { - warn$1(`${type} is NaN - the duration expression might be incorrect.`); - } -} -var ErrorCodes = { - "SETUP_FUNCTION": 0, - "0": "SETUP_FUNCTION", - "RENDER_FUNCTION": 1, - "1": "RENDER_FUNCTION", - "NATIVE_EVENT_HANDLER": 5, - "5": "NATIVE_EVENT_HANDLER", - "COMPONENT_EVENT_HANDLER": 6, - "6": "COMPONENT_EVENT_HANDLER", - "VNODE_HOOK": 7, - "7": "VNODE_HOOK", - "DIRECTIVE_HOOK": 8, - "8": "DIRECTIVE_HOOK", - "TRANSITION_HOOK": 9, - "9": "TRANSITION_HOOK", - "APP_ERROR_HANDLER": 10, - "10": "APP_ERROR_HANDLER", - "APP_WARN_HANDLER": 11, - "11": "APP_WARN_HANDLER", - "FUNCTION_REF": 12, - "12": "FUNCTION_REF", - "ASYNC_COMPONENT_LOADER": 13, - "13": "ASYNC_COMPONENT_LOADER", - "SCHEDULER": 14, - "14": "SCHEDULER", - "COMPONENT_UPDATE": 15, - "15": "COMPONENT_UPDATE", - "APP_UNMOUNT_CLEANUP": 16, - "16": "APP_UNMOUNT_CLEANUP" -}; -var ErrorTypeStrings$1 = { - ["sp"]: "serverPrefetch hook", - ["bc"]: "beforeCreate hook", - ["c"]: "created hook", - ["bm"]: "beforeMount hook", - ["m"]: "mounted hook", - ["bu"]: "beforeUpdate hook", - ["u"]: "updated", - ["bum"]: "beforeUnmount hook", - ["um"]: "unmounted hook", - ["a"]: "activated hook", - ["da"]: "deactivated hook", - ["ec"]: "errorCaptured hook", - ["rtc"]: "renderTracked hook", - ["rtg"]: "renderTriggered hook", - [0]: "setup function", - [1]: "render function", - [2]: "watcher getter", - [3]: "watcher callback", - [4]: "watcher cleanup function", - [5]: "native event handler", - [6]: "component event handler", - [7]: "vnode hook", - [8]: "directive hook", - [9]: "transition hook", - [10]: "app errorHandler", - [11]: "app warnHandler", - [12]: "ref function", - [13]: "async component loader", - [14]: "scheduler flush", - [15]: "component update", - [16]: "app unmount cleanup function" -}; -function callWithErrorHandling(fn, instance, type, args) { - try { - return args ? fn(...args) : fn(); - } catch (err) { - handleError(err, instance, type); - } -} -function callWithAsyncErrorHandling(fn, instance, type, args) { - if (isFunction(fn)) { - const res = callWithErrorHandling(fn, instance, type, args); - if (res && isPromise(res)) { - res.catch((err) => { - handleError(err, instance, type); - }); - } - return res; - } - if (isArray(fn)) { - const values = []; - for (let i = 0; i < fn.length; i++) { - values.push(callWithAsyncErrorHandling(fn[i], instance, type, args)); - } - return values; - } else if (true) { - warn$1( - `Invalid value type passed to callWithAsyncErrorHandling(): ${typeof fn}` - ); - } -} -function handleError(err, instance, type, throwInDev = true) { - const contextVNode = instance ? instance.vnode : null; - const { errorHandler, throwUnhandledErrorInProduction } = instance && instance.appContext.config || EMPTY_OBJ; - if (instance) { - let cur = instance.parent; - const exposedInstance = instance.proxy; - const errorInfo = true ? ErrorTypeStrings$1[type] : `https://vuejs.org/error-reference/#runtime-${type}`; - while (cur) { - const errorCapturedHooks = cur.ec; - if (errorCapturedHooks) { - for (let i = 0; i < errorCapturedHooks.length; i++) { - if (errorCapturedHooks[i](err, exposedInstance, errorInfo) === false) { - return; - } - } - } - cur = cur.parent; - } - if (errorHandler) { - pauseTracking(); - callWithErrorHandling(errorHandler, null, 10, [ - err, - exposedInstance, - errorInfo - ]); - resetTracking(); - return; - } - } - logError(err, type, contextVNode, throwInDev, throwUnhandledErrorInProduction); -} -function logError(err, type, contextVNode, throwInDev = true, throwInProd = false) { - if (true) { - const info = ErrorTypeStrings$1[type]; - if (contextVNode) { - pushWarningContext(contextVNode); - } - warn$1(`Unhandled error${info ? ` during execution of ${info}` : ``}`); - if (contextVNode) { - popWarningContext(); - } - if (throwInDev) { - throw err; - } else { - console.error(err); - } - } else if (throwInProd) { - throw err; - } else { - console.error(err); - } -} -var queue = []; -var flushIndex = -1; -var pendingPostFlushCbs = []; -var activePostFlushCbs = null; -var postFlushIndex = 0; -var resolvedPromise = Promise.resolve(); -var currentFlushPromise = null; -var RECURSION_LIMIT = 100; -function nextTick(fn) { - const p2 = currentFlushPromise || resolvedPromise; - return fn ? p2.then(this ? fn.bind(this) : fn) : p2; -} -function findInsertionIndex(id) { - let start = flushIndex + 1; - let end = queue.length; - while (start < end) { - const middle = start + end >>> 1; - const middleJob = queue[middle]; - const middleJobId = getId(middleJob); - if (middleJobId < id || middleJobId === id && middleJob.flags & 2) { - start = middle + 1; - } else { - end = middle; - } - } - return start; -} -function queueJob(job) { - if (!(job.flags & 1)) { - const jobId = getId(job); - const lastJob = queue[queue.length - 1]; - if (!lastJob || // fast path when the job id is larger than the tail - !(job.flags & 2) && jobId >= getId(lastJob)) { - queue.push(job); - } else { - queue.splice(findInsertionIndex(jobId), 0, job); - } - job.flags |= 1; - queueFlush(); - } -} -function queueFlush() { - if (!currentFlushPromise) { - currentFlushPromise = resolvedPromise.then(flushJobs); - } -} -function queuePostFlushCb(cb) { - if (!isArray(cb)) { - if (activePostFlushCbs && cb.id === -1) { - activePostFlushCbs.splice(postFlushIndex + 1, 0, cb); - } else if (!(cb.flags & 1)) { - pendingPostFlushCbs.push(cb); - cb.flags |= 1; - } - } else { - pendingPostFlushCbs.push(...cb); - } - queueFlush(); -} -function flushPreFlushCbs(instance, seen, i = flushIndex + 1) { - if (true) { - seen = seen || /* @__PURE__ */ new Map(); - } - for (; i < queue.length; i++) { - const cb = queue[i]; - if (cb && cb.flags & 2) { - if (instance && cb.id !== instance.uid) { - continue; - } - if (checkRecursiveUpdates(seen, cb)) { - continue; - } - queue.splice(i, 1); - i--; - if (cb.flags & 4) { - cb.flags &= -2; - } - cb(); - if (!(cb.flags & 4)) { - cb.flags &= -2; - } - } - } -} -function flushPostFlushCbs(seen) { - if (pendingPostFlushCbs.length) { - const deduped = [...new Set(pendingPostFlushCbs)].sort( - (a, b) => getId(a) - getId(b) - ); - pendingPostFlushCbs.length = 0; - if (activePostFlushCbs) { - activePostFlushCbs.push(...deduped); - return; - } - activePostFlushCbs = deduped; - if (true) { - seen = seen || /* @__PURE__ */ new Map(); - } - for (postFlushIndex = 0; postFlushIndex < activePostFlushCbs.length; postFlushIndex++) { - const cb = activePostFlushCbs[postFlushIndex]; - if (checkRecursiveUpdates(seen, cb)) { - continue; - } - if (cb.flags & 4) { - cb.flags &= -2; - } - if (!(cb.flags & 8)) cb(); - cb.flags &= -2; - } - activePostFlushCbs = null; - postFlushIndex = 0; - } -} -var getId = (job) => job.id == null ? job.flags & 2 ? -1 : Infinity : job.id; -function flushJobs(seen) { - if (true) { - seen = seen || /* @__PURE__ */ new Map(); - } - const check = true ? (job) => checkRecursiveUpdates(seen, job) : NOOP; - try { - for (flushIndex = 0; flushIndex < queue.length; flushIndex++) { - const job = queue[flushIndex]; - if (job && !(job.flags & 8)) { - if (check(job)) { - continue; - } - if (job.flags & 4) { - job.flags &= ~1; - } - callWithErrorHandling( - job, - job.i, - job.i ? 15 : 14 - ); - if (!(job.flags & 4)) { - job.flags &= ~1; - } - } - } - } finally { - for (; flushIndex < queue.length; flushIndex++) { - const job = queue[flushIndex]; - if (job) { - job.flags &= -2; - } - } - flushIndex = -1; - queue.length = 0; - flushPostFlushCbs(seen); - currentFlushPromise = null; - if (queue.length || pendingPostFlushCbs.length) { - flushJobs(seen); - } - } -} -function checkRecursiveUpdates(seen, fn) { - const count = seen.get(fn) || 0; - if (count > RECURSION_LIMIT) { - const instance = fn.i; - const componentName = instance && getComponentName(instance.type); - handleError( - `Maximum recursive updates exceeded${componentName ? ` in component <${componentName}>` : ``}. This means you have a reactive effect that is mutating its own dependencies and thus recursively triggering itself. Possible sources include component template, render function, updated hook or watcher source function.`, - null, - 10 - ); - return true; - } - seen.set(fn, count + 1); - return false; -} -var isHmrUpdating = false; -var setHmrUpdating = (v) => { - try { - return isHmrUpdating; - } finally { - isHmrUpdating = v; - } -}; -var hmrDirtyComponents = /* @__PURE__ */ new Map(); -if (true) { - getGlobalThis().__VUE_HMR_RUNTIME__ = { - createRecord: tryWrap(createRecord), - rerender: tryWrap(rerender), - reload: tryWrap(reload) - }; -} -var map = /* @__PURE__ */ new Map(); -function registerHMR(instance) { - const id = instance.type.__hmrId; - let record = map.get(id); - if (!record) { - createRecord(id, instance.type); - record = map.get(id); - } - record.instances.add(instance); -} -function unregisterHMR(instance) { - map.get(instance.type.__hmrId).instances.delete(instance); -} -function createRecord(id, initialDef) { - if (map.has(id)) { - return false; - } - map.set(id, { - initialDef: normalizeClassComponent(initialDef), - instances: /* @__PURE__ */ new Set() - }); - return true; -} -function normalizeClassComponent(component) { - return isClassComponent(component) ? component.__vccOpts : component; -} -function rerender(id, newRender) { - const record = map.get(id); - if (!record) { - return; - } - record.initialDef.render = newRender; - [...record.instances].forEach((instance) => { - if (newRender) { - instance.render = newRender; - normalizeClassComponent(instance.type).render = newRender; - } - instance.renderCache = []; - isHmrUpdating = true; - if (!(instance.job.flags & 8)) { - instance.update(); - } - isHmrUpdating = false; - }); -} -function reload(id, newComp) { - const record = map.get(id); - if (!record) return; - newComp = normalizeClassComponent(newComp); - updateComponentDef(record.initialDef, newComp); - const instances = [...record.instances]; - for (let i = 0; i < instances.length; i++) { - const instance = instances[i]; - const oldComp = normalizeClassComponent(instance.type); - let dirtyInstances = hmrDirtyComponents.get(oldComp); - if (!dirtyInstances) { - if (oldComp !== record.initialDef) { - updateComponentDef(oldComp, newComp); - } - hmrDirtyComponents.set(oldComp, dirtyInstances = /* @__PURE__ */ new Set()); - } - dirtyInstances.add(instance); - instance.appContext.propsCache.delete(instance.type); - instance.appContext.emitsCache.delete(instance.type); - instance.appContext.optionsCache.delete(instance.type); - if (instance.ceReload) { - dirtyInstances.add(instance); - instance.ceReload(newComp.styles); - dirtyInstances.delete(instance); - } else if (instance.parent) { - queueJob(() => { - if (!(instance.job.flags & 8)) { - isHmrUpdating = true; - instance.parent.update(); - isHmrUpdating = false; - dirtyInstances.delete(instance); - } - }); - } else if (instance.appContext.reload) { - instance.appContext.reload(); - } else if (typeof window !== "undefined") { - window.location.reload(); - } else { - console.warn( - "[HMR] Root or manually mounted instance modified. Full reload required." - ); - } - if (instance.root.ce && instance !== instance.root) { - instance.root.ce._removeChildStyle(oldComp); - } - } - queuePostFlushCb(() => { - hmrDirtyComponents.clear(); - }); -} -function updateComponentDef(oldComp, newComp) { - extend(oldComp, newComp); - for (const key in oldComp) { - if (key !== "__file" && !(key in newComp)) { - delete oldComp[key]; - } - } -} -function tryWrap(fn) { - return (id, arg) => { - try { - return fn(id, arg); - } catch (e) { - console.error(e); - console.warn( - `[HMR] Something went wrong during Vue component hot-reload. Full reload required.` - ); - } - }; -} -var devtools$1; -var buffer = []; -var devtoolsNotInstalled = false; -function emit$1(event, ...args) { - if (devtools$1) { - devtools$1.emit(event, ...args); - } else if (!devtoolsNotInstalled) { - buffer.push({ event, args }); - } -} -function setDevtoolsHook$1(hook, target) { - var _a, _b; - devtools$1 = hook; - if (devtools$1) { - devtools$1.enabled = true; - buffer.forEach(({ event, args }) => devtools$1.emit(event, ...args)); - buffer = []; - } else if ( - // handle late devtools injection - only do this if we are in an actual - // browser environment to avoid the timer handle stalling test runner exit - // (#4815) - typeof window !== "undefined" && // some envs mock window but not fully - window.HTMLElement && // also exclude jsdom - // eslint-disable-next-line no-restricted-syntax - !((_b = (_a = window.navigator) == null ? void 0 : _a.userAgent) == null ? void 0 : _b.includes("jsdom")) - ) { - const replay = target.__VUE_DEVTOOLS_HOOK_REPLAY__ = target.__VUE_DEVTOOLS_HOOK_REPLAY__ || []; - replay.push((newHook) => { - setDevtoolsHook$1(newHook, target); - }); - setTimeout(() => { - if (!devtools$1) { - target.__VUE_DEVTOOLS_HOOK_REPLAY__ = null; - devtoolsNotInstalled = true; - buffer = []; - } - }, 3e3); - } else { - devtoolsNotInstalled = true; - buffer = []; - } -} -function devtoolsInitApp(app, version2) { - emit$1("app:init", app, version2, { - Fragment, - Text, - Comment, - Static - }); -} -function devtoolsUnmountApp(app) { - emit$1("app:unmount", app); -} -var devtoolsComponentAdded = createDevtoolsComponentHook( - "component:added" - /* COMPONENT_ADDED */ -); -var devtoolsComponentUpdated = createDevtoolsComponentHook( - "component:updated" - /* COMPONENT_UPDATED */ -); -var _devtoolsComponentRemoved = createDevtoolsComponentHook( - "component:removed" - /* COMPONENT_REMOVED */ -); -var devtoolsComponentRemoved = (component) => { - if (devtools$1 && typeof devtools$1.cleanupBuffer === "function" && // remove the component if it wasn't buffered - !devtools$1.cleanupBuffer(component)) { - _devtoolsComponentRemoved(component); - } -}; -function createDevtoolsComponentHook(hook) { - return (component) => { - emit$1( - hook, - component.appContext.app, - component.uid, - component.parent ? component.parent.uid : void 0, - component - ); - }; -} -var devtoolsPerfStart = createDevtoolsPerformanceHook( - "perf:start" - /* PERFORMANCE_START */ -); -var devtoolsPerfEnd = createDevtoolsPerformanceHook( - "perf:end" - /* PERFORMANCE_END */ -); -function createDevtoolsPerformanceHook(hook) { - return (component, type, time) => { - emit$1(hook, component.appContext.app, component.uid, component, type, time); - }; -} -function devtoolsComponentEmit(component, event, params) { - emit$1( - "component:emit", - component.appContext.app, - component, - event, - params - ); -} -var currentRenderingInstance = null; -var currentScopeId = null; -function setCurrentRenderingInstance(instance) { - const prev = currentRenderingInstance; - currentRenderingInstance = instance; - currentScopeId = instance && instance.type.__scopeId || null; - return prev; -} -function pushScopeId(id) { - currentScopeId = id; -} -function popScopeId() { - currentScopeId = null; -} -var withScopeId = (_id) => withCtx; -function withCtx(fn, ctx = currentRenderingInstance, isNonScopedSlot) { - if (!ctx) return fn; - if (fn._n) { - return fn; - } - const renderFnWithContext = (...args) => { - if (renderFnWithContext._d) { - setBlockTracking(-1); - } - const prevInstance = setCurrentRenderingInstance(ctx); - let res; - try { - res = fn(...args); - } finally { - setCurrentRenderingInstance(prevInstance); - if (renderFnWithContext._d) { - setBlockTracking(1); - } - } - if (true) { - devtoolsComponentUpdated(ctx); - } - return res; - }; - renderFnWithContext._n = true; - renderFnWithContext._c = true; - renderFnWithContext._d = true; - return renderFnWithContext; -} -function validateDirectiveName(name) { - if (isBuiltInDirective(name)) { - warn$1("Do not use built-in directive ids as custom directive id: " + name); - } -} -function withDirectives(vnode, directives) { - if (currentRenderingInstance === null) { - warn$1(`withDirectives can only be used inside render functions.`); - return vnode; - } - const instance = getComponentPublicInstance(currentRenderingInstance); - const bindings = vnode.dirs || (vnode.dirs = []); - for (let i = 0; i < directives.length; i++) { - let [dir, value, arg, modifiers = EMPTY_OBJ] = directives[i]; - if (dir) { - if (isFunction(dir)) { - dir = { - mounted: dir, - updated: dir - }; - } - if (dir.deep) { - traverse(value); - } - bindings.push({ - dir, - instance, - value, - oldValue: void 0, - arg, - modifiers - }); - } - } - return vnode; -} -function invokeDirectiveHook(vnode, prevVNode, instance, name) { - const bindings = vnode.dirs; - const oldBindings = prevVNode && prevVNode.dirs; - for (let i = 0; i < bindings.length; i++) { - const binding = bindings[i]; - if (oldBindings) { - binding.oldValue = oldBindings[i].value; - } - let hook = binding.dir[name]; - if (hook) { - pauseTracking(); - callWithAsyncErrorHandling(hook, instance, 8, [ - vnode.el, - binding, - vnode, - prevVNode - ]); - resetTracking(); - } - } -} -function provide(key, value) { - if (true) { - if (!currentInstance || currentInstance.isMounted) { - warn$1(`provide() can only be used inside setup().`); - } - } - if (currentInstance) { - let provides = currentInstance.provides; - const parentProvides = currentInstance.parent && currentInstance.parent.provides; - if (parentProvides === provides) { - provides = currentInstance.provides = Object.create(parentProvides); - } - provides[key] = value; - } -} -function inject(key, defaultValue, treatDefaultAsFactory = false) { - const instance = getCurrentInstance(); - if (instance || currentApp) { - let provides = currentApp ? currentApp._context.provides : instance ? instance.parent == null || instance.ce ? instance.vnode.appContext && instance.vnode.appContext.provides : instance.parent.provides : void 0; - if (provides && key in provides) { - return provides[key]; - } else if (arguments.length > 1) { - return treatDefaultAsFactory && isFunction(defaultValue) ? defaultValue.call(instance && instance.proxy) : defaultValue; - } else if (true) { - warn$1(`injection "${String(key)}" not found.`); - } - } else if (true) { - warn$1(`inject() can only be used inside setup() or functional components.`); - } -} -function hasInjectionContext() { - return !!(getCurrentInstance() || currentApp); -} -var ssrContextKey = Symbol.for("v-scx"); -var useSSRContext = () => { - { - const ctx = inject(ssrContextKey); - if (!ctx) { - warn$1( - `Server rendering context not provided. Make sure to only call useSSRContext() conditionally in the server build.` - ); - } - return ctx; - } -}; -function watchEffect(effect2, options) { - return doWatch(effect2, null, options); -} -function watchPostEffect(effect2, options) { - return doWatch( - effect2, - null, - true ? extend({}, options, { flush: "post" }) : { flush: "post" } - ); -} -function watchSyncEffect(effect2, options) { - return doWatch( - effect2, - null, - true ? extend({}, options, { flush: "sync" }) : { flush: "sync" } - ); -} -function watch2(source, cb, options) { - if (!isFunction(cb)) { - warn$1( - `\`watch(fn, options?)\` signature has been moved to a separate API. Use \`watchEffect(fn, options?)\` instead. \`watch\` now only supports \`watch(source, cb, options?) signature.` - ); - } - return doWatch(source, cb, options); -} -function doWatch(source, cb, options = EMPTY_OBJ) { - const { immediate, deep, flush, once } = options; - if (!cb) { - if (immediate !== void 0) { - warn$1( - `watch() "immediate" option is only respected when using the watch(source, callback, options?) signature.` - ); - } - if (deep !== void 0) { - warn$1( - `watch() "deep" option is only respected when using the watch(source, callback, options?) signature.` - ); - } - if (once !== void 0) { - warn$1( - `watch() "once" option is only respected when using the watch(source, callback, options?) signature.` - ); - } - } - const baseWatchOptions = extend({}, options); - if (true) baseWatchOptions.onWarn = warn$1; - const runsImmediately = cb && immediate || !cb && flush !== "post"; - let ssrCleanup; - if (isInSSRComponentSetup) { - if (flush === "sync") { - const ctx = useSSRContext(); - ssrCleanup = ctx.__watcherHandles || (ctx.__watcherHandles = []); - } else if (!runsImmediately) { - const watchStopHandle = () => { - }; - watchStopHandle.stop = NOOP; - watchStopHandle.resume = NOOP; - watchStopHandle.pause = NOOP; - return watchStopHandle; - } - } - const instance = currentInstance; - baseWatchOptions.call = (fn, type, args) => callWithAsyncErrorHandling(fn, instance, type, args); - let isPre = false; - if (flush === "post") { - baseWatchOptions.scheduler = (job) => { - queuePostRenderEffect(job, instance && instance.suspense); - }; - } else if (flush !== "sync") { - isPre = true; - baseWatchOptions.scheduler = (job, isFirstRun) => { - if (isFirstRun) { - job(); - } else { - queueJob(job); - } - }; - } - baseWatchOptions.augmentJob = (job) => { - if (cb) { - job.flags |= 4; - } - if (isPre) { - job.flags |= 2; - if (instance) { - job.id = instance.uid; - job.i = instance; - } - } - }; - const watchHandle = watch(source, cb, baseWatchOptions); - if (isInSSRComponentSetup) { - if (ssrCleanup) { - ssrCleanup.push(watchHandle); - } else if (runsImmediately) { - watchHandle(); - } - } - return watchHandle; -} -function instanceWatch(source, value, options) { - const publicThis = this.proxy; - const getter = isString(source) ? source.includes(".") ? createPathGetter(publicThis, source) : () => publicThis[source] : source.bind(publicThis, publicThis); - let cb; - if (isFunction(value)) { - cb = value; - } else { - cb = value.handler; - options = value; - } - const reset = setCurrentInstance(this); - const res = doWatch(getter, cb.bind(publicThis), options); - reset(); - return res; -} -function createPathGetter(ctx, path) { - const segments = path.split("."); - return () => { - let cur = ctx; - for (let i = 0; i < segments.length && cur; i++) { - cur = cur[segments[i]]; - } - return cur; - }; -} -var pendingMounts = /* @__PURE__ */ new WeakMap(); -var TeleportEndKey = Symbol("_vte"); -var isTeleport = (type) => type.__isTeleport; -var isTeleportDisabled = (props) => props && (props.disabled || props.disabled === ""); -var isTeleportDeferred = (props) => props && (props.defer || props.defer === ""); -var isTargetSVG = (target) => typeof SVGElement !== "undefined" && target instanceof SVGElement; -var isTargetMathML = (target) => typeof MathMLElement === "function" && target instanceof MathMLElement; -var resolveTarget = (props, select) => { - const targetSelector = props && props.to; - if (isString(targetSelector)) { - if (!select) { - warn$1( - `Current renderer does not support string target for Teleports. (missing querySelector renderer option)` - ); - return null; - } else { - const target = select(targetSelector); - if (!target && !isTeleportDisabled(props)) { - warn$1( - `Failed to locate Teleport target with selector "${targetSelector}". Note the target element must exist before the component is mounted - i.e. the target cannot be rendered by the component itself, and ideally should be outside of the entire Vue component tree.` - ); - } - return target; - } - } else { - if (!targetSelector && !isTeleportDisabled(props)) { - warn$1(`Invalid Teleport target: ${targetSelector}`); - } - return targetSelector; - } -}; -var TeleportImpl = { - name: "Teleport", - __isTeleport: true, - process(n1, n2, container, anchor, parentComponent, parentSuspense, namespace, slotScopeIds, optimized, internals) { - const { - mc: mountChildren, - pc: patchChildren, - pbc: patchBlockChildren, - o: { insert, querySelector, createText, createComment, parentNode } - } = internals; - const disabled = isTeleportDisabled(n2.props); - let { dynamicChildren } = n2; - if (isHmrUpdating) { - optimized = false; - dynamicChildren = null; - } - const mount = (vnode, container2, anchor2) => { - if (vnode.shapeFlag & 16) { - mountChildren( - vnode.children, - container2, - anchor2, - parentComponent, - parentSuspense, - namespace, - slotScopeIds, - optimized - ); - } - }; - const mountToTarget = (vnode = n2) => { - const disabled2 = isTeleportDisabled(vnode.props); - const target = vnode.target = resolveTarget(vnode.props, querySelector); - const targetAnchor = prepareAnchor(target, vnode, createText, insert); - if (target) { - if (namespace !== "svg" && isTargetSVG(target)) { - namespace = "svg"; - } else if (namespace !== "mathml" && isTargetMathML(target)) { - namespace = "mathml"; - } - if (parentComponent && parentComponent.isCE) { - (parentComponent.ce._teleportTargets || (parentComponent.ce._teleportTargets = /* @__PURE__ */ new Set())).add(target); - } - if (!disabled2) { - mount(vnode, target, targetAnchor); - updateCssVars(vnode, false); - } - } else if (!disabled2) { - warn$1("Invalid Teleport target on mount:", target, `(${typeof target})`); - } - }; - const queuePendingMount = (vnode) => { - const mountJob = () => { - if (pendingMounts.get(vnode) !== mountJob) return; - pendingMounts.delete(vnode); - if (isTeleportDisabled(vnode.props)) { - const mountContainer = parentNode(vnode.el) || container; - mount(vnode, mountContainer, vnode.anchor); - updateCssVars(vnode, true); - } - mountToTarget(vnode); - }; - pendingMounts.set(vnode, mountJob); - queuePostRenderEffect(mountJob, parentSuspense); - }; - if (n1 == null) { - const placeholder = n2.el = true ? createComment("teleport start") : createText(""); - const mainAnchor = n2.anchor = true ? createComment("teleport end") : createText(""); - insert(placeholder, container, anchor); - insert(mainAnchor, container, anchor); - if (isTeleportDeferred(n2.props) || parentSuspense && parentSuspense.pendingBranch) { - queuePendingMount(n2); - return; - } - if (disabled) { - mount(n2, container, mainAnchor); - updateCssVars(n2, true); - } - mountToTarget(); - } else { - n2.el = n1.el; - const mainAnchor = n2.anchor = n1.anchor; - const pendingMount = pendingMounts.get(n1); - if (pendingMount) { - pendingMount.flags |= 8; - pendingMounts.delete(n1); - queuePendingMount(n2); - return; - } - n2.targetStart = n1.targetStart; - const target = n2.target = n1.target; - const targetAnchor = n2.targetAnchor = n1.targetAnchor; - const wasDisabled = isTeleportDisabled(n1.props); - const currentContainer = wasDisabled ? container : target; - const currentAnchor = wasDisabled ? mainAnchor : targetAnchor; - if (namespace === "svg" || isTargetSVG(target)) { - namespace = "svg"; - } else if (namespace === "mathml" || isTargetMathML(target)) { - namespace = "mathml"; - } - if (dynamicChildren) { - patchBlockChildren( - n1.dynamicChildren, - dynamicChildren, - currentContainer, - parentComponent, - parentSuspense, - namespace, - slotScopeIds - ); - traverseStaticChildren(n1, n2, false); - } else if (!optimized) { - patchChildren( - n1, - n2, - currentContainer, - currentAnchor, - parentComponent, - parentSuspense, - namespace, - slotScopeIds, - false - ); - } - if (disabled) { - if (!wasDisabled) { - moveTeleport( - n2, - container, - mainAnchor, - internals, - 1 - ); - } else { - if (n2.props && n1.props && n2.props.to !== n1.props.to) { - n2.props.to = n1.props.to; - } - } - } else { - if ((n2.props && n2.props.to) !== (n1.props && n1.props.to)) { - const nextTarget = n2.target = resolveTarget( - n2.props, - querySelector - ); - if (nextTarget) { - moveTeleport( - n2, - nextTarget, - null, - internals, - 0 - ); - } else if (true) { - warn$1( - "Invalid Teleport target on update:", - target, - `(${typeof target})` - ); - } - } else if (wasDisabled) { - moveTeleport( - n2, - target, - targetAnchor, - internals, - 1 - ); - } - } - updateCssVars(n2, disabled); - } - }, - remove(vnode, parentComponent, parentSuspense, { um: unmount, o: { remove: hostRemove } }, doRemove) { - const { - shapeFlag, - children, - anchor, - targetStart, - targetAnchor, - target, - props - } = vnode; - const shouldRemove = doRemove || !isTeleportDisabled(props); - const pendingMount = pendingMounts.get(vnode); - if (pendingMount) { - pendingMount.flags |= 8; - pendingMounts.delete(vnode); - } - if (target) { - hostRemove(targetStart); - hostRemove(targetAnchor); - } - doRemove && hostRemove(anchor); - if (!pendingMount && shapeFlag & 16) { - for (let i = 0; i < children.length; i++) { - const child = children[i]; - unmount( - child, - parentComponent, - parentSuspense, - shouldRemove, - !!child.dynamicChildren - ); - } - } - }, - move: moveTeleport, - hydrate: hydrateTeleport -}; -function moveTeleport(vnode, container, parentAnchor, { o: { insert }, m: move }, moveType = 2) { - if (moveType === 0) { - insert(vnode.targetAnchor, container, parentAnchor); - } - const { el, anchor, shapeFlag, children, props } = vnode; - const isReorder = moveType === 2; - if (isReorder) { - insert(el, container, parentAnchor); - } - if (!pendingMounts.has(vnode) && (!isReorder || isTeleportDisabled(props))) { - if (shapeFlag & 16) { - for (let i = 0; i < children.length; i++) { - move( - children[i], - container, - parentAnchor, - 2 - ); - } - } - } - if (isReorder) { - insert(anchor, container, parentAnchor); - } -} -function hydrateTeleport(node, vnode, parentComponent, parentSuspense, slotScopeIds, optimized, { - o: { nextSibling, parentNode, querySelector, insert, createText } -}, hydrateChildren) { - function hydrateAnchor(target2, targetNode) { - let targetAnchor = targetNode; - while (targetAnchor) { - if (targetAnchor && targetAnchor.nodeType === 8) { - if (targetAnchor.data === "teleport start anchor") { - vnode.targetStart = targetAnchor; - } else if (targetAnchor.data === "teleport anchor") { - vnode.targetAnchor = targetAnchor; - target2._lpa = vnode.targetAnchor && nextSibling(vnode.targetAnchor); - break; - } - } - targetAnchor = nextSibling(targetAnchor); - } - } - function hydrateDisabledTeleport(node2, vnode2) { - vnode2.anchor = hydrateChildren( - nextSibling(node2), - vnode2, - parentNode(node2), - parentComponent, - parentSuspense, - slotScopeIds, - optimized - ); - } - const target = vnode.target = resolveTarget( - vnode.props, - querySelector - ); - const disabled = isTeleportDisabled(vnode.props); - if (target) { - const targetNode = target._lpa || target.firstChild; - if (vnode.shapeFlag & 16) { - if (disabled) { - hydrateDisabledTeleport(node, vnode); - hydrateAnchor(target, targetNode); - if (!vnode.targetAnchor) { - prepareAnchor( - target, - vnode, - createText, - insert, - // if target is the same as the main view, insert anchors before current node - // to avoid hydrating mismatch - parentNode(node) === target ? node : null - ); - } - } else { - vnode.anchor = nextSibling(node); - hydrateAnchor(target, targetNode); - if (!vnode.targetAnchor) { - prepareAnchor(target, vnode, createText, insert); - } - hydrateChildren( - targetNode && nextSibling(targetNode), - vnode, - target, - parentComponent, - parentSuspense, - slotScopeIds, - optimized - ); - } - } - updateCssVars(vnode, disabled); - } else if (disabled) { - if (vnode.shapeFlag & 16) { - hydrateDisabledTeleport(node, vnode); - vnode.targetStart = node; - vnode.targetAnchor = nextSibling(node); - } - } - return vnode.anchor && nextSibling(vnode.anchor); -} -var Teleport = TeleportImpl; -function updateCssVars(vnode, isDisabled) { - const ctx = vnode.ctx; - if (ctx && ctx.ut) { - let node, anchor; - if (isDisabled) { - node = vnode.el; - anchor = vnode.anchor; - } else { - node = vnode.targetStart; - anchor = vnode.targetAnchor; - } - while (node && node !== anchor) { - if (node.nodeType === 1) node.setAttribute("data-v-owner", ctx.uid); - node = node.nextSibling; - } - ctx.ut(); - } -} -function prepareAnchor(target, vnode, createText, insert, anchor = null) { - const targetStart = vnode.targetStart = createText(""); - const targetAnchor = vnode.targetAnchor = createText(""); - targetStart[TeleportEndKey] = targetAnchor; - if (target) { - insert(targetStart, target, anchor); - insert(targetAnchor, target, anchor); - } - return targetAnchor; -} -var leaveCbKey = Symbol("_leaveCb"); -var enterCbKey = Symbol("_enterCb"); -function useTransitionState() { - const state = { - isMounted: false, - isLeaving: false, - isUnmounting: false, - leavingVNodes: /* @__PURE__ */ new Map() - }; - onMounted(() => { - state.isMounted = true; - }); - onBeforeUnmount(() => { - state.isUnmounting = true; - }); - return state; -} -var TransitionHookValidator = [Function, Array]; -var BaseTransitionPropsValidators = { - mode: String, - appear: Boolean, - persisted: Boolean, - // enter - onBeforeEnter: TransitionHookValidator, - onEnter: TransitionHookValidator, - onAfterEnter: TransitionHookValidator, - onEnterCancelled: TransitionHookValidator, - // leave - onBeforeLeave: TransitionHookValidator, - onLeave: TransitionHookValidator, - onAfterLeave: TransitionHookValidator, - onLeaveCancelled: TransitionHookValidator, - // appear - onBeforeAppear: TransitionHookValidator, - onAppear: TransitionHookValidator, - onAfterAppear: TransitionHookValidator, - onAppearCancelled: TransitionHookValidator -}; -var recursiveGetSubtree = (instance) => { - const subTree = instance.subTree; - return subTree.component ? recursiveGetSubtree(subTree.component) : subTree; -}; -var BaseTransitionImpl = { - name: `BaseTransition`, - props: BaseTransitionPropsValidators, - setup(props, { slots }) { - const instance = getCurrentInstance(); - const state = useTransitionState(); - return () => { - const children = slots.default && getTransitionRawChildren(slots.default(), true); - const child = children && children.length ? findNonCommentChild(children) : ( - // Keep explicit default-slot conditionals on the same transition path - // as regular v-if branches, which render a comment placeholder. - instance.subTree ? createCommentVNode() : void 0 - ); - if (!child) { - return; - } - const rawProps = toRaw(props); - const { mode } = rawProps; - if (mode && mode !== "in-out" && mode !== "out-in" && mode !== "default") { - warn$1(`invalid mode: ${mode}`); - } - if (state.isLeaving) { - return emptyPlaceholder(child); - } - const innerChild = getInnerChild$1(child); - if (!innerChild) { - return emptyPlaceholder(child); - } - let enterHooks = resolveTransitionHooks( - innerChild, - rawProps, - state, - instance, - // #11061, ensure enterHooks is fresh after clone - (hooks) => enterHooks = hooks - ); - if (innerChild.type !== Comment) { - setTransitionHooks(innerChild, enterHooks); - } - let oldInnerChild = instance.subTree && getInnerChild$1(instance.subTree); - if (oldInnerChild && oldInnerChild.type !== Comment && !isSameVNodeType(oldInnerChild, innerChild) && recursiveGetSubtree(instance).type !== Comment) { - let leavingHooks = resolveTransitionHooks( - oldInnerChild, - rawProps, - state, - instance - ); - setTransitionHooks(oldInnerChild, leavingHooks); - if (mode === "out-in" && innerChild.type !== Comment) { - state.isLeaving = true; - leavingHooks.afterLeave = () => { - state.isLeaving = false; - if (!(instance.job.flags & 8)) { - instance.update(); - } - delete leavingHooks.afterLeave; - oldInnerChild = void 0; - }; - return emptyPlaceholder(child); - } else if (mode === "in-out" && innerChild.type !== Comment) { - leavingHooks.delayLeave = (el, earlyRemove, delayedLeave) => { - const leavingVNodesCache = getLeavingNodesForType( - state, - oldInnerChild - ); - leavingVNodesCache[String(oldInnerChild.key)] = oldInnerChild; - el[leaveCbKey] = () => { - earlyRemove(); - el[leaveCbKey] = void 0; - delete enterHooks.delayedLeave; - oldInnerChild = void 0; - }; - enterHooks.delayedLeave = () => { - delayedLeave(); - delete enterHooks.delayedLeave; - oldInnerChild = void 0; - }; - }; - } else { - oldInnerChild = void 0; - } - } else if (oldInnerChild) { - oldInnerChild = void 0; - } - return child; - }; - } -}; -function findNonCommentChild(children) { - let child = children[0]; - if (children.length > 1) { - let hasFound = false; - for (const c of children) { - if (c.type !== Comment) { - if (hasFound) { - warn$1( - " can only be used on a single element or component. Use for lists." - ); - break; - } - child = c; - hasFound = true; - if (false) break; - } - } - } - return child; -} -var BaseTransition = BaseTransitionImpl; -function getLeavingNodesForType(state, vnode) { - const { leavingVNodes } = state; - let leavingVNodesCache = leavingVNodes.get(vnode.type); - if (!leavingVNodesCache) { - leavingVNodesCache = /* @__PURE__ */ Object.create(null); - leavingVNodes.set(vnode.type, leavingVNodesCache); - } - return leavingVNodesCache; -} -function resolveTransitionHooks(vnode, props, state, instance, postClone) { - const { - appear, - mode, - persisted = false, - onBeforeEnter, - onEnter, - onAfterEnter, - onEnterCancelled, - onBeforeLeave, - onLeave, - onAfterLeave, - onLeaveCancelled, - onBeforeAppear, - onAppear, - onAfterAppear, - onAppearCancelled - } = props; - const key = String(vnode.key); - const leavingVNodesCache = getLeavingNodesForType(state, vnode); - const callHook3 = (hook, args) => { - hook && callWithAsyncErrorHandling( - hook, - instance, - 9, - args - ); - }; - const callAsyncHook = (hook, args) => { - const done = args[1]; - callHook3(hook, args); - if (isArray(hook)) { - if (hook.every((hook2) => hook2.length <= 1)) done(); - } else if (hook.length <= 1) { - done(); - } - }; - const hooks = { - mode, - persisted, - beforeEnter(el) { - let hook = onBeforeEnter; - if (!state.isMounted) { - if (appear) { - hook = onBeforeAppear || onBeforeEnter; - } else { - return; - } - } - if (el[leaveCbKey]) { - el[leaveCbKey]( - true - /* cancelled */ - ); - } - const leavingVNode = leavingVNodesCache[key]; - if (leavingVNode && isSameVNodeType(vnode, leavingVNode) && leavingVNode.el[leaveCbKey]) { - leavingVNode.el[leaveCbKey](); - } - callHook3(hook, [el]); - }, - enter(el) { - if (!isHmrUpdating && leavingVNodesCache[key] === vnode) return; - let hook = onEnter; - let afterHook = onAfterEnter; - let cancelHook = onEnterCancelled; - if (!state.isMounted) { - if (appear) { - hook = onAppear || onEnter; - afterHook = onAfterAppear || onAfterEnter; - cancelHook = onAppearCancelled || onEnterCancelled; - } else { - return; - } - } - let called = false; - el[enterCbKey] = (cancelled) => { - if (called) return; - called = true; - if (cancelled) { - callHook3(cancelHook, [el]); - } else { - callHook3(afterHook, [el]); - } - if (hooks.delayedLeave) { - hooks.delayedLeave(); - } - el[enterCbKey] = void 0; - }; - const done = el[enterCbKey].bind(null, false); - if (hook) { - callAsyncHook(hook, [el, done]); - } else { - done(); - } - }, - leave(el, remove2) { - const key2 = String(vnode.key); - if (el[enterCbKey]) { - el[enterCbKey]( - true - /* cancelled */ - ); - } - if (state.isUnmounting) { - return remove2(); - } - callHook3(onBeforeLeave, [el]); - let called = false; - el[leaveCbKey] = (cancelled) => { - if (called) return; - called = true; - remove2(); - if (cancelled) { - callHook3(onLeaveCancelled, [el]); - } else { - callHook3(onAfterLeave, [el]); - } - el[leaveCbKey] = void 0; - if (leavingVNodesCache[key2] === vnode) { - delete leavingVNodesCache[key2]; - } - }; - const done = el[leaveCbKey].bind(null, false); - leavingVNodesCache[key2] = vnode; - if (onLeave) { - callAsyncHook(onLeave, [el, done]); - } else { - done(); - } - }, - clone(vnode2) { - const hooks2 = resolveTransitionHooks( - vnode2, - props, - state, - instance, - postClone - ); - if (postClone) postClone(hooks2); - return hooks2; - } - }; - return hooks; -} -function emptyPlaceholder(vnode) { - if (isKeepAlive(vnode)) { - vnode = cloneVNode(vnode); - vnode.children = null; - return vnode; - } -} -function getInnerChild$1(vnode) { - if (!isKeepAlive(vnode)) { - if (isTeleport(vnode.type) && vnode.children) { - return findNonCommentChild(vnode.children); - } - return vnode; - } - if (vnode.component) { - return vnode.component.subTree; - } - const { shapeFlag, children } = vnode; - if (children) { - if (shapeFlag & 16) { - return children[0]; - } - if (shapeFlag & 32 && isFunction(children.default)) { - return children.default(); - } - } -} -function setTransitionHooks(vnode, hooks) { - if (vnode.shapeFlag & 6 && vnode.component) { - vnode.transition = hooks; - setTransitionHooks(vnode.component.subTree, hooks); - } else if (vnode.shapeFlag & 128) { - vnode.ssContent.transition = hooks.clone(vnode.ssContent); - vnode.ssFallback.transition = hooks.clone(vnode.ssFallback); - } else { - vnode.transition = hooks; - } -} -function getTransitionRawChildren(children, keepComment = false, parentKey) { - let ret = []; - let keyedFragmentCount = 0; - for (let i = 0; i < children.length; i++) { - let child = children[i]; - const key = parentKey == null ? child.key : String(parentKey) + String(child.key != null ? child.key : i); - if (child.type === Fragment) { - if (child.patchFlag & 128) keyedFragmentCount++; - ret = ret.concat( - getTransitionRawChildren(child.children, keepComment, key) - ); - } else if (keepComment || child.type !== Comment) { - ret.push(key != null ? cloneVNode(child, { key }) : child); - } - } - if (keyedFragmentCount > 1) { - for (let i = 0; i < ret.length; i++) { - ret[i].patchFlag = -2; - } - } - return ret; -} -function defineComponent(options, extraOptions) { - return isFunction(options) ? ( - // #8236: extend call and options.name access are considered side-effects - // by Rollup, so we have to wrap it in a pure-annotated IIFE. - (() => extend({ name: options.name }, extraOptions, { setup: options }))() - ) : options; -} -function useId() { - const i = getCurrentInstance(); - if (i) { - return (i.appContext.config.idPrefix || "v") + "-" + i.ids[0] + i.ids[1]++; - } else if (true) { - warn$1( - `useId() is called when there is no active component instance to be associated with.` - ); - } - return ""; -} -function markAsyncBoundary(instance) { - instance.ids = [instance.ids[0] + instance.ids[2]++ + "-", 0, 0]; -} -var knownTemplateRefs = /* @__PURE__ */ new WeakSet(); -function useTemplateRef(key) { - const i = getCurrentInstance(); - const r = shallowRef(null); - if (i) { - const refs = i.refs === EMPTY_OBJ ? i.refs = {} : i.refs; - if (isTemplateRefKey(refs, key)) { - warn$1(`useTemplateRef('${key}') already exists.`); - } else { - Object.defineProperty(refs, key, { - enumerable: true, - get: () => r.value, - set: (val) => r.value = val - }); - } - } else if (true) { - warn$1( - `useTemplateRef() is called when there is no active component instance to be associated with.` - ); - } - const ret = true ? readonly(r) : r; - if (true) { - knownTemplateRefs.add(ret); - } - return ret; -} -function isTemplateRefKey(refs, key) { - let desc; - return !!((desc = Object.getOwnPropertyDescriptor(refs, key)) && !desc.configurable); -} -var pendingSetRefMap = /* @__PURE__ */ new WeakMap(); -function setRef(rawRef, oldRawRef, parentSuspense, vnode, isUnmount = false) { - if (isArray(rawRef)) { - rawRef.forEach( - (r, i) => setRef( - r, - oldRawRef && (isArray(oldRawRef) ? oldRawRef[i] : oldRawRef), - parentSuspense, - vnode, - isUnmount - ) - ); - return; - } - if (isAsyncWrapper(vnode) && !isUnmount) { - if (vnode.shapeFlag & 512 && vnode.type.__asyncResolved && vnode.component.subTree.component) { - setRef(rawRef, oldRawRef, parentSuspense, vnode.component.subTree); - } - return; - } - const refValue = vnode.shapeFlag & 4 ? getComponentPublicInstance(vnode.component) : vnode.el; - const value = isUnmount ? null : refValue; - const { i: owner, r: ref2 } = rawRef; - if (!owner) { - warn$1( - `Missing ref owner context. ref cannot be used on hoisted vnodes. A vnode with ref must be created inside the render function.` - ); - return; - } - const oldRef = oldRawRef && oldRawRef.r; - const refs = owner.refs === EMPTY_OBJ ? owner.refs = {} : owner.refs; - const setupState = owner.setupState; - const rawSetupState = toRaw(setupState); - const canSetSetupRef = setupState === EMPTY_OBJ ? NO : (key) => { - if (true) { - if (hasOwn(rawSetupState, key) && !isRef2(rawSetupState[key])) { - warn$1( - `Template ref "${key}" used on a non-ref value. It will not work in the production build.` - ); - } - if (knownTemplateRefs.has(rawSetupState[key])) { - return false; - } - } - if (isTemplateRefKey(refs, key)) { - return false; - } - return hasOwn(rawSetupState, key); - }; - const canSetRef = (ref22, key) => { - if (knownTemplateRefs.has(ref22)) { - return false; - } - if (key && isTemplateRefKey(refs, key)) { - return false; - } - return true; - }; - if (oldRef != null && oldRef !== ref2) { - invalidatePendingSetRef(oldRawRef); - if (isString(oldRef)) { - refs[oldRef] = null; - if (canSetSetupRef(oldRef)) { - setupState[oldRef] = null; - } - } else if (isRef2(oldRef)) { - const oldRawRefAtom = oldRawRef; - if (canSetRef(oldRef, oldRawRefAtom.k)) { - oldRef.value = null; - } - if (oldRawRefAtom.k) refs[oldRawRefAtom.k] = null; - } - } - if (isFunction(ref2)) { - callWithErrorHandling(ref2, owner, 12, [value, refs]); - } else { - const _isString = isString(ref2); - const _isRef = isRef2(ref2); - if (_isString || _isRef) { - const doSet = () => { - if (rawRef.f) { - const existing = _isString ? canSetSetupRef(ref2) ? setupState[ref2] : refs[ref2] : canSetRef(ref2) || !rawRef.k ? ref2.value : refs[rawRef.k]; - if (isUnmount) { - isArray(existing) && remove(existing, refValue); - } else { - if (!isArray(existing)) { - if (_isString) { - refs[ref2] = [refValue]; - if (canSetSetupRef(ref2)) { - setupState[ref2] = refs[ref2]; - } - } else { - const newVal = [refValue]; - if (canSetRef(ref2, rawRef.k)) { - ref2.value = newVal; - } - if (rawRef.k) refs[rawRef.k] = newVal; - } - } else if (!existing.includes(refValue)) { - existing.push(refValue); - } - } - } else if (_isString) { - refs[ref2] = value; - if (canSetSetupRef(ref2)) { - setupState[ref2] = value; - } - } else if (_isRef) { - if (canSetRef(ref2, rawRef.k)) { - ref2.value = value; - } - if (rawRef.k) refs[rawRef.k] = value; - } else if (true) { - warn$1("Invalid template ref type:", ref2, `(${typeof ref2})`); - } - }; - if (value) { - const job = () => { - doSet(); - pendingSetRefMap.delete(rawRef); - }; - job.id = -1; - pendingSetRefMap.set(rawRef, job); - queuePostRenderEffect(job, parentSuspense); - } else { - invalidatePendingSetRef(rawRef); - doSet(); - } - } else if (true) { - warn$1("Invalid template ref type:", ref2, `(${typeof ref2})`); - } - } -} -function invalidatePendingSetRef(rawRef) { - const pendingSetRef = pendingSetRefMap.get(rawRef); - if (pendingSetRef) { - pendingSetRef.flags |= 8; - pendingSetRefMap.delete(rawRef); - } -} -var hasLoggedMismatchError = false; -var logMismatchError = () => { - if (hasLoggedMismatchError) { - return; - } - console.error("Hydration completed but contains mismatches."); - hasLoggedMismatchError = true; -}; -var isSVGContainer = (container) => container.namespaceURI.includes("svg") && container.tagName !== "foreignObject"; -var isMathMLContainer = (container) => container.namespaceURI.includes("MathML"); -var getContainerType = (container) => { - if (container.nodeType !== 1) return void 0; - if (isSVGContainer(container)) return "svg"; - if (isMathMLContainer(container)) return "mathml"; - return void 0; -}; -var isComment = (node) => node.nodeType === 8; -function createHydrationFunctions(rendererInternals) { - const { - mt: mountComponent, - p: patch, - o: { - patchProp: patchProp2, - createText, - nextSibling, - parentNode, - remove: remove2, - insert, - createComment - } - } = rendererInternals; - const hydrate2 = (vnode, container) => { - if (!container.hasChildNodes()) { - warn$1( - `Attempting to hydrate existing markup but container is empty. Performing full mount instead.` - ); - patch(null, vnode, container); - flushPostFlushCbs(); - container._vnode = vnode; - return; - } - hydrateNode(container.firstChild, vnode, null, null, null); - flushPostFlushCbs(); - container._vnode = vnode; - }; - const hydrateNode = (node, vnode, parentComponent, parentSuspense, slotScopeIds, optimized = false) => { - optimized = optimized || !!vnode.dynamicChildren; - const isFragmentStart = isComment(node) && node.data === "["; - const onMismatch = () => handleMismatch( - node, - vnode, - parentComponent, - parentSuspense, - slotScopeIds, - isFragmentStart - ); - const { type, ref: ref2, shapeFlag, patchFlag } = vnode; - let domType = node.nodeType; - vnode.el = node; - if (true) { - def(node, "__vnode", vnode, true); - def(node, "__vueParentComponent", parentComponent, true); - } - if (patchFlag === -2) { - optimized = false; - vnode.dynamicChildren = null; - } - let nextNode = null; - switch (type) { - case Text: - if (domType !== 3) { - if (vnode.children === "") { - insert(vnode.el = createText(""), parentNode(node), node); - nextNode = node; - } else { - nextNode = onMismatch(); - } - } else { - if (node.data !== vnode.children) { - warn$1( - `Hydration text mismatch in`, - node.parentNode, - ` - - rendered on server: ${JSON.stringify( - node.data - )} - - expected on client: ${JSON.stringify(vnode.children)}` - ); - logMismatchError(); - node.data = vnode.children; - } - nextNode = nextSibling(node); - } - break; - case Comment: - if (isTemplateNode(node)) { - nextNode = nextSibling(node); - replaceNode( - vnode.el = node.content.firstChild, - node, - parentComponent - ); - } else if (domType !== 8 || isFragmentStart) { - nextNode = onMismatch(); - } else { - nextNode = nextSibling(node); - } - break; - case Static: - if (isFragmentStart) { - node = nextSibling(node); - domType = node.nodeType; - } - if (domType === 1 || domType === 3) { - nextNode = node; - const needToAdoptContent = !vnode.children.length; - for (let i = 0; i < vnode.staticCount; i++) { - if (needToAdoptContent) - vnode.children += nextNode.nodeType === 1 ? nextNode.outerHTML : nextNode.data; - if (i === vnode.staticCount - 1) { - vnode.anchor = nextNode; - } - nextNode = nextSibling(nextNode); - } - return isFragmentStart ? nextSibling(nextNode) : nextNode; - } else { - onMismatch(); - } - break; - case Fragment: - if (!isFragmentStart) { - nextNode = onMismatch(); - } else { - nextNode = hydrateFragment( - node, - vnode, - parentComponent, - parentSuspense, - slotScopeIds, - optimized - ); - } - break; - default: - if (shapeFlag & 1) { - if ((domType !== 1 || vnode.type.toLowerCase() !== node.tagName.toLowerCase()) && !isTemplateNode(node)) { - nextNode = onMismatch(); - } else { - nextNode = hydrateElement( - node, - vnode, - parentComponent, - parentSuspense, - slotScopeIds, - optimized - ); - } - } else if (shapeFlag & 6) { - vnode.slotScopeIds = slotScopeIds; - const container = parentNode(node); - if (isFragmentStart) { - nextNode = locateClosingAnchor(node); - } else if (isComment(node) && node.data === "teleport start") { - nextNode = locateClosingAnchor(node, node.data, "teleport end"); - } else { - nextNode = nextSibling(node); - } - mountComponent( - vnode, - container, - null, - parentComponent, - parentSuspense, - getContainerType(container), - optimized - ); - if (isAsyncWrapper(vnode) && !vnode.type.__asyncResolved) { - let subTree; - if (isFragmentStart) { - subTree = createVNode(Fragment); - subTree.anchor = nextNode ? nextNode.previousSibling : container.lastChild; - } else { - subTree = node.nodeType === 3 ? createTextVNode("") : createVNode("div"); - } - subTree.el = node; - vnode.component.subTree = subTree; - } - } else if (shapeFlag & 64) { - if (domType !== 8) { - nextNode = onMismatch(); - } else { - nextNode = vnode.type.hydrate( - node, - vnode, - parentComponent, - parentSuspense, - slotScopeIds, - optimized, - rendererInternals, - hydrateChildren - ); - } - } else if (shapeFlag & 128) { - nextNode = vnode.type.hydrate( - node, - vnode, - parentComponent, - parentSuspense, - getContainerType(parentNode(node)), - slotScopeIds, - optimized, - rendererInternals, - hydrateNode - ); - } else if (true) { - warn$1("Invalid HostVNode type:", type, `(${typeof type})`); - } - } - if (ref2 != null) { - setRef(ref2, null, parentSuspense, vnode); - } - return nextNode; - }; - const hydrateElement = (el, vnode, parentComponent, parentSuspense, slotScopeIds, optimized) => { - optimized = optimized || !!vnode.dynamicChildren; - const { type, props, patchFlag, shapeFlag, dirs, transition } = vnode; - const forcePatch = type === "input" || type === "option"; - if (true) { - if (dirs) { - invokeDirectiveHook(vnode, null, parentComponent, "created"); - } - let needCallTransitionHooks = false; - if (isTemplateNode(el)) { - needCallTransitionHooks = needTransition( - null, - // no need check parentSuspense in hydration - transition - ) && parentComponent && parentComponent.vnode.props && parentComponent.vnode.props.appear; - const content = el.content.firstChild; - if (needCallTransitionHooks) { - const cls = content.getAttribute("class"); - if (cls) content.$cls = cls; - transition.beforeEnter(content); - } - replaceNode(content, el, parentComponent); - vnode.el = el = content; - } - if (shapeFlag & 16 && // skip if element has innerHTML / textContent - !(props && (props.innerHTML || props.textContent))) { - let next = hydrateChildren( - el.firstChild, - vnode, - el, - parentComponent, - parentSuspense, - slotScopeIds, - optimized - ); - if (next && !isMismatchAllowed( - el, - 1 - /* CHILDREN */ - )) { - warn$1( - `Hydration children mismatch on`, - el, - ` -Server rendered element contains more child nodes than client vdom.` - ); - logMismatchError(); - } - while (next) { - const cur = next; - next = next.nextSibling; - remove2(cur); - } - } else if (shapeFlag & 8) { - let clientText = vnode.children; - if (clientText[0] === "\n" && (el.tagName === "PRE" || el.tagName === "TEXTAREA")) { - clientText = clientText.slice(1); - } - const { textContent } = el; - if (textContent !== clientText && // innerHTML normalize \r\n or \r into a single \n in the DOM - textContent !== clientText.replace(/\r\n|\r/g, "\n")) { - if (!isMismatchAllowed( - el, - 0 - /* TEXT */ - )) { - warn$1( - `Hydration text content mismatch on`, - el, - ` - - rendered on server: ${textContent} - - expected on client: ${clientText}` - ); - logMismatchError(); - } - el.textContent = vnode.children; - } - } - if (props) { - if (true) { - const isCustomElement = el.tagName.includes("-"); - for (const key in props) { - if (// #11189 skip if this node has directives that have created hooks - // as it could have mutated the DOM in any possible way - !(dirs && dirs.some((d) => d.dir.created)) && propHasMismatch(el, key, props[key], vnode, parentComponent)) { - logMismatchError(); - } - if (forcePatch && (key.endsWith("value") || key === "indeterminate") || isOn(key) && !isReservedProp(key) || // force hydrate v-bind with .prop modifiers - key[0] === "." || isCustomElement && !isReservedProp(key)) { - patchProp2(el, key, null, props[key], void 0, parentComponent); - } - } - } else if (props.onClick) { - patchProp2( - el, - "onClick", - null, - props.onClick, - void 0, - parentComponent - ); - } else if (patchFlag & 4 && isReactive(props.style)) { - for (const key in props.style) props.style[key]; - } - } - let vnodeHooks; - if (vnodeHooks = props && props.onVnodeBeforeMount) { - invokeVNodeHook(vnodeHooks, parentComponent, vnode); - } - if (dirs) { - invokeDirectiveHook(vnode, null, parentComponent, "beforeMount"); - } - if ((vnodeHooks = props && props.onVnodeMounted) || dirs || needCallTransitionHooks) { - queueEffectWithSuspense(() => { - vnodeHooks && invokeVNodeHook(vnodeHooks, parentComponent, vnode); - needCallTransitionHooks && transition.enter(el); - dirs && invokeDirectiveHook(vnode, null, parentComponent, "mounted"); - }, parentSuspense); - } - } - return el.nextSibling; - }; - const hydrateChildren = (node, parentVNode, container, parentComponent, parentSuspense, slotScopeIds, optimized) => { - optimized = optimized || !!parentVNode.dynamicChildren; - const children = parentVNode.children; - const l = children.length; - let hasCheckedMismatch = false; - for (let i = 0; i < l; i++) { - const vnode = optimized ? children[i] : children[i] = normalizeVNode(children[i]); - const isText = vnode.type === Text; - if (node) { - if (isText && !optimized) { - if (i + 1 < l && normalizeVNode(children[i + 1]).type === Text) { - insert( - createText( - node.data.slice(vnode.children.length) - ), - container, - nextSibling(node) - ); - node.data = vnode.children; - } - } - node = hydrateNode( - node, - vnode, - parentComponent, - parentSuspense, - slotScopeIds, - optimized - ); - } else if (isText && !vnode.children) { - insert(vnode.el = createText(""), container); - } else { - if (!hasCheckedMismatch) { - hasCheckedMismatch = true; - if (!isMismatchAllowed( - container, - 1 - /* CHILDREN */ - )) { - warn$1( - `Hydration children mismatch on`, - container, - ` -Server rendered element contains fewer child nodes than client vdom.` - ); - logMismatchError(); - } - } - patch( - null, - vnode, - container, - null, - parentComponent, - parentSuspense, - getContainerType(container), - slotScopeIds - ); - } - } - return node; - }; - const hydrateFragment = (node, vnode, parentComponent, parentSuspense, slotScopeIds, optimized) => { - const { slotScopeIds: fragmentSlotScopeIds } = vnode; - if (fragmentSlotScopeIds) { - slotScopeIds = slotScopeIds ? slotScopeIds.concat(fragmentSlotScopeIds) : fragmentSlotScopeIds; - } - const container = parentNode(node); - const next = hydrateChildren( - nextSibling(node), - vnode, - container, - parentComponent, - parentSuspense, - slotScopeIds, - optimized - ); - if (next && isComment(next) && next.data === "]") { - return nextSibling(vnode.anchor = next); - } else { - logMismatchError(); - insert(vnode.anchor = createComment(`]`), container, next); - return next; - } - }; - const handleMismatch = (node, vnode, parentComponent, parentSuspense, slotScopeIds, isFragment) => { - if (!isMismatchAllowed( - node.parentElement, - 1 - /* CHILDREN */ - )) { - warn$1( - `Hydration node mismatch: -- rendered on server:`, - node, - node.nodeType === 3 ? `(text)` : isComment(node) && node.data === "[" ? `(start of fragment)` : ``, - ` -- expected on client:`, - vnode.type - ); - logMismatchError(); - } - vnode.el = null; - if (isFragment) { - const end = locateClosingAnchor(node); - while (true) { - const next2 = nextSibling(node); - if (next2 && next2 !== end) { - remove2(next2); - } else { - break; - } - } - } - const next = nextSibling(node); - const container = parentNode(node); - remove2(node); - patch( - null, - vnode, - container, - next, - parentComponent, - parentSuspense, - getContainerType(container), - slotScopeIds - ); - if (parentComponent) { - parentComponent.vnode.el = vnode.el; - updateHOCHostEl(parentComponent, vnode.el); - } - return next; - }; - const locateClosingAnchor = (node, open = "[", close = "]") => { - let match = 0; - while (node) { - node = nextSibling(node); - if (node && isComment(node)) { - if (node.data === open) match++; - if (node.data === close) { - if (match === 0) { - return nextSibling(node); - } else { - match--; - } - } - } - } - return node; - }; - const replaceNode = (newNode, oldNode, parentComponent) => { - const parentNode2 = oldNode.parentNode; - if (parentNode2) { - parentNode2.replaceChild(newNode, oldNode); - } - let parent = parentComponent; - while (parent) { - if (parent.vnode.el === oldNode) { - parent.vnode.el = parent.subTree.el = newNode; - } - parent = parent.parent; - } - }; - const isTemplateNode = (node) => { - return node.nodeType === 1 && node.tagName === "TEMPLATE"; - }; - return [hydrate2, hydrateNode]; -} -function propHasMismatch(el, key, clientValue, vnode, instance) { - let mismatchType; - let mismatchKey; - let actual; - let expected; - if (key === "class") { - if (el.$cls) { - actual = el.$cls; - delete el.$cls; - } else { - actual = el.getAttribute("class"); - } - expected = normalizeClass(clientValue); - if (!isSetEqual(toClassSet(actual || ""), toClassSet(expected))) { - mismatchType = 2; - mismatchKey = `class`; - } - } else if (key === "style") { - actual = el.getAttribute("style") || ""; - expected = isString(clientValue) ? clientValue : stringifyStyle(normalizeStyle(clientValue)); - const actualMap = toStyleMap(actual); - const expectedMap = toStyleMap(expected); - if (vnode.dirs) { - for (const { dir, value } of vnode.dirs) { - if (dir.name === "show" && !value) { - expectedMap.set("display", "none"); - } - } - } - if (instance) { - resolveCssVars(instance, vnode, expectedMap); - } - if (!isMapEqual(actualMap, expectedMap)) { - mismatchType = 3; - mismatchKey = "style"; - } - } else if (el instanceof SVGElement && isKnownSvgAttr(key) || el instanceof HTMLElement && (isBooleanAttr(key) || isKnownHtmlAttr(key))) { - if (isBooleanAttr(key)) { - actual = el.hasAttribute(key); - expected = includeBooleanAttr(clientValue); - } else if (clientValue == null) { - actual = el.hasAttribute(key); - expected = false; - } else { - if (el.hasAttribute(key)) { - actual = el.getAttribute(key); - } else if (key === "value" && el.tagName === "TEXTAREA") { - actual = el.value; - } else { - actual = false; - } - expected = isRenderableAttrValue(clientValue) ? String(clientValue) : false; - } - if (actual !== expected) { - mismatchType = 4; - mismatchKey = key; - } - } - if (mismatchType != null && !isMismatchAllowed(el, mismatchType)) { - const format = (v) => v === false ? `(not rendered)` : `${mismatchKey}="${v}"`; - const preSegment = `Hydration ${MismatchTypeString[mismatchType]} mismatch on`; - const postSegment = ` - - rendered on server: ${format(actual)} - - expected on client: ${format(expected)} - Note: this mismatch is check-only. The DOM will not be rectified in production due to performance overhead. - You should fix the source of the mismatch.`; - { - warn$1(preSegment, el, postSegment); - } - return true; - } - return false; -} -function toClassSet(str) { - return new Set(str.trim().split(/\s+/)); -} -function isSetEqual(a, b) { - if (a.size !== b.size) { - return false; - } - for (const s of a) { - if (!b.has(s)) { - return false; - } - } - return true; -} -function toStyleMap(str) { - const styleMap = /* @__PURE__ */ new Map(); - for (const item of str.split(";")) { - let [key, value] = item.split(":"); - key = key.trim(); - value = value && value.trim(); - if (key && value) { - styleMap.set(key, value); - } - } - return styleMap; -} -function isMapEqual(a, b) { - if (a.size !== b.size) { - return false; - } - for (const [key, value] of a) { - if (value !== b.get(key)) { - return false; - } - } - return true; -} -function resolveCssVars(instance, vnode, expectedMap) { - const root = instance.subTree; - if (instance.getCssVars && (vnode === root || root && root.type === Fragment && root.children.includes(vnode))) { - const cssVars = instance.getCssVars(); - for (const key in cssVars) { - const value = normalizeCssVarValue(cssVars[key]); - expectedMap.set(`--${getEscapedCssVarName(key, false)}`, value); - } - } - if (vnode === root && instance.parent) { - resolveCssVars(instance.parent, instance.vnode, expectedMap); - } -} -var allowMismatchAttr = "data-allow-mismatch"; -var MismatchTypeString = { - [ - 0 - /* TEXT */ - ]: "text", - [ - 1 - /* CHILDREN */ - ]: "children", - [ - 2 - /* CLASS */ - ]: "class", - [ - 3 - /* STYLE */ - ]: "style", - [ - 4 - /* ATTRIBUTE */ - ]: "attribute" -}; -function isMismatchAllowed(el, allowedType) { - if (allowedType === 0 || allowedType === 1) { - while (el && !el.hasAttribute(allowMismatchAttr)) { - el = el.parentElement; - } - } - const allowedAttr = el && el.getAttribute(allowMismatchAttr); - if (allowedAttr == null) { - return false; - } else if (allowedAttr === "") { - return true; - } else { - const list = allowedAttr.split(","); - if (allowedType === 0 && list.includes("children")) { - return true; - } - return list.includes(MismatchTypeString[allowedType]); - } -} -var requestIdleCallback = getGlobalThis().requestIdleCallback || ((cb) => setTimeout(cb, 1)); -var cancelIdleCallback = getGlobalThis().cancelIdleCallback || ((id) => clearTimeout(id)); -var hydrateOnIdle = (timeout = 1e4) => (hydrate2) => { - const id = requestIdleCallback(hydrate2, { timeout }); - return () => cancelIdleCallback(id); -}; -function elementIsVisibleInViewport(el) { - const { top, left, bottom, right } = el.getBoundingClientRect(); - const { innerHeight, innerWidth } = window; - return (top > 0 && top < innerHeight || bottom > 0 && bottom < innerHeight) && (left > 0 && left < innerWidth || right > 0 && right < innerWidth); -} -var hydrateOnVisible = (opts) => (hydrate2, forEach) => { - const ob = new IntersectionObserver((entries) => { - for (const e of entries) { - if (!e.isIntersecting) continue; - ob.disconnect(); - hydrate2(); - break; - } - }, opts); - forEach((el) => { - if (!(el instanceof Element)) return; - if (elementIsVisibleInViewport(el)) { - hydrate2(); - ob.disconnect(); - return false; - } - ob.observe(el); - }); - return () => ob.disconnect(); -}; -var hydrateOnMediaQuery = (query) => (hydrate2) => { - if (query) { - const mql = matchMedia(query); - if (mql.matches) { - hydrate2(); - } else { - mql.addEventListener("change", hydrate2, { once: true }); - return () => mql.removeEventListener("change", hydrate2); - } - } -}; -var hydrateOnInteraction = (interactions = []) => (hydrate2, forEach) => { - if (isString(interactions)) interactions = [interactions]; - let hasHydrated = false; - const doHydrate = (e) => { - if (!hasHydrated) { - hasHydrated = true; - teardown(); - hydrate2(); - e.target.dispatchEvent(new e.constructor(e.type, e)); - } - }; - const teardown = () => { - forEach((el) => { - for (const i of interactions) { - el.removeEventListener(i, doHydrate); - } - }); - }; - forEach((el) => { - for (const i of interactions) { - el.addEventListener(i, doHydrate, { once: true }); - } - }); - return teardown; -}; -function forEachElement(node, cb) { - if (isComment(node) && node.data === "[") { - let depth = 1; - let next = node.nextSibling; - while (next) { - if (next.nodeType === 1) { - const result = cb(next); - if (result === false) { - break; - } - } else if (isComment(next)) { - if (next.data === "]") { - if (--depth === 0) break; - } else if (next.data === "[") { - depth++; - } - } - next = next.nextSibling; - } - } else { - cb(node); - } -} -var isAsyncWrapper = (i) => !!i.type.__asyncLoader; -function defineAsyncComponent(source) { - if (isFunction(source)) { - source = { loader: source }; - } - const { - loader, - loadingComponent, - errorComponent, - delay = 200, - hydrate: hydrateStrategy, - timeout, - // undefined = never times out - suspensible = true, - onError: userOnError - } = source; - let pendingRequest = null; - let resolvedComp; - let retries = 0; - const retry = () => { - retries++; - pendingRequest = null; - return load(); - }; - const load = () => { - let thisRequest; - return pendingRequest || (thisRequest = pendingRequest = loader().catch((err) => { - err = err instanceof Error ? err : new Error(String(err)); - if (userOnError) { - return new Promise((resolve2, reject) => { - const userRetry = () => resolve2(retry()); - const userFail = () => reject(err); - userOnError(err, userRetry, userFail, retries + 1); - }); - } else { - throw err; - } - }).then((comp) => { - if (thisRequest !== pendingRequest && pendingRequest) { - return pendingRequest; - } - if (!comp) { - warn$1( - `Async component loader resolved to undefined. If you are using retry(), make sure to return its return value.` - ); - } - if (comp && (comp.__esModule || comp[Symbol.toStringTag] === "Module")) { - comp = comp.default; - } - if (comp && !isObject(comp) && !isFunction(comp)) { - throw new Error(`Invalid async component load result: ${comp}`); - } - resolvedComp = comp; - return comp; - })); - }; - return defineComponent({ - name: "AsyncComponentWrapper", - __asyncLoader: load, - __asyncHydrate(el, instance, hydrate2) { - let patched = false; - (instance.bu || (instance.bu = [])).push(() => patched = true); - const performHydrate = () => { - if (patched) { - if (true) { - warn$1( - `Skipping lazy hydration for component '${getComponentName(resolvedComp) || resolvedComp.__file}': it was updated before lazy hydration performed.` - ); - } - return; - } - hydrate2(); - }; - const doHydrate = hydrateStrategy ? () => { - const teardown = hydrateStrategy( - performHydrate, - (cb) => forEachElement(el, cb) - ); - if (teardown) { - (instance.bum || (instance.bum = [])).push(teardown); - } - } : performHydrate; - if (resolvedComp) { - doHydrate(); - } else { - load().then(() => !instance.isUnmounted && doHydrate()); - } - }, - get __asyncResolved() { - return resolvedComp; - }, - setup() { - const instance = currentInstance; - markAsyncBoundary(instance); - if (resolvedComp) { - return () => createInnerComp(resolvedComp, instance); - } - const onError = (err) => { - pendingRequest = null; - handleError( - err, - instance, - 13, - !errorComponent - ); - }; - if (suspensible && instance.suspense || isInSSRComponentSetup) { - return load().then((comp) => { - return () => createInnerComp(comp, instance); - }).catch((err) => { - onError(err); - return () => errorComponent ? createVNode(errorComponent, { - error: err - }) : null; - }); - } - const loaded = ref(false); - const error = ref(); - const delayed = ref(!!delay); - if (delay) { - setTimeout(() => { - delayed.value = false; - }, delay); - } - if (timeout != null) { - setTimeout(() => { - if (!loaded.value && !error.value) { - const err = new Error( - `Async component timed out after ${timeout}ms.` - ); - onError(err); - error.value = err; - } - }, timeout); - } - load().then(() => { - loaded.value = true; - if (instance.parent && isKeepAlive(instance.parent.vnode)) { - instance.parent.update(); - } - }).catch((err) => { - onError(err); - error.value = err; - }); - return () => { - if (loaded.value && resolvedComp) { - return createInnerComp(resolvedComp, instance); - } else if (error.value && errorComponent) { - return createVNode(errorComponent, { - error: error.value - }); - } else if (loadingComponent && !delayed.value) { - return createInnerComp( - loadingComponent, - instance - ); - } - }; - } - }); -} -function createInnerComp(comp, parent) { - const { ref: ref2, props, children, ce } = parent.vnode; - const vnode = createVNode(comp, props, children); - vnode.ref = ref2; - vnode.ce = ce; - delete parent.vnode.ce; - return vnode; -} -var isKeepAlive = (vnode) => vnode.type.__isKeepAlive; -var KeepAliveImpl = { - name: `KeepAlive`, - // Marker for special handling inside the renderer. We are not using a === - // check directly on KeepAlive in the renderer, because importing it directly - // would prevent it from being tree-shaken. - __isKeepAlive: true, - props: { - include: [String, RegExp, Array], - exclude: [String, RegExp, Array], - max: [String, Number] - }, - setup(props, { slots }) { - const instance = getCurrentInstance(); - const sharedContext = instance.ctx; - if (!sharedContext.renderer) { - return () => { - const children = slots.default && slots.default(); - return children && children.length === 1 ? children[0] : children; - }; - } - const cache = /* @__PURE__ */ new Map(); - const keys = /* @__PURE__ */ new Set(); - let current = null; - if (true) { - instance.__v_cache = cache; - } - const parentSuspense = instance.suspense; - const { - renderer: { - p: patch, - m: move, - um: _unmount, - o: { createElement } - } - } = sharedContext; - const storageContainer = createElement("div"); - sharedContext.activate = (vnode, container, anchor, namespace, optimized) => { - const instance2 = vnode.component; - move(vnode, container, anchor, 0, parentSuspense); - patch( - instance2.vnode, - vnode, - container, - anchor, - instance2, - parentSuspense, - namespace, - vnode.slotScopeIds, - optimized - ); - queuePostRenderEffect(() => { - instance2.isDeactivated = false; - if (instance2.a) { - invokeArrayFns(instance2.a); - } - const vnodeHook = vnode.props && vnode.props.onVnodeMounted; - if (vnodeHook) { - invokeVNodeHook(vnodeHook, instance2.parent, vnode); - } - }, parentSuspense); - if (true) { - devtoolsComponentAdded(instance2); - } - }; - sharedContext.deactivate = (vnode) => { - const instance2 = vnode.component; - invalidateMount(instance2.m); - invalidateMount(instance2.a); - move(vnode, storageContainer, null, 1, parentSuspense); - queuePostRenderEffect(() => { - if (instance2.da) { - invokeArrayFns(instance2.da); - } - const vnodeHook = vnode.props && vnode.props.onVnodeUnmounted; - if (vnodeHook) { - invokeVNodeHook(vnodeHook, instance2.parent, vnode); - } - instance2.isDeactivated = true; - }, parentSuspense); - if (true) { - devtoolsComponentAdded(instance2); - } - if (true) { - instance2.__keepAliveStorageContainer = storageContainer; - } - }; - function unmount(vnode) { - resetShapeFlag(vnode); - _unmount(vnode, instance, parentSuspense, true); - } - function pruneCache(filter) { - cache.forEach((vnode, key) => { - const name = getComponentName( - isAsyncWrapper(vnode) ? vnode.type.__asyncResolved || {} : vnode.type - ); - if (name && !filter(name)) { - pruneCacheEntry(key); - } - }); - } - function pruneCacheEntry(key) { - const cached = cache.get(key); - if (cached && (!current || !isSameVNodeType(cached, current))) { - unmount(cached); - } else if (current) { - resetShapeFlag(current); - } - cache.delete(key); - keys.delete(key); - } - watch2( - () => [props.include, props.exclude], - ([include, exclude]) => { - include && pruneCache((name) => matches(include, name)); - exclude && pruneCache((name) => !matches(exclude, name)); - }, - // prune post-render after `current` has been updated - { flush: "post", deep: true } - ); - let pendingCacheKey = null; - const cacheSubtree = () => { - if (pendingCacheKey != null) { - if (isSuspense(instance.subTree.type)) { - queuePostRenderEffect(() => { - cache.set(pendingCacheKey, getInnerChild(instance.subTree)); - }, instance.subTree.suspense); - } else { - cache.set(pendingCacheKey, getInnerChild(instance.subTree)); - } - } - }; - onMounted(cacheSubtree); - onUpdated(cacheSubtree); - onBeforeUnmount(() => { - cache.forEach((cached) => { - const { subTree, suspense } = instance; - const vnode = getInnerChild(subTree); - if (cached.type === vnode.type && cached.key === vnode.key) { - resetShapeFlag(vnode); - const da = vnode.component.da; - da && queuePostRenderEffect(da, suspense); - return; - } - unmount(cached); - }); - }); - return () => { - pendingCacheKey = null; - if (!slots.default) { - return current = null; - } - const children = slots.default(); - const rawVNode = children[0]; - if (children.length > 1) { - if (true) { - warn$1(`KeepAlive should contain exactly one component child.`); - } - current = null; - return children; - } else if (!isVNode(rawVNode) || !(rawVNode.shapeFlag & 4) && !(rawVNode.shapeFlag & 128)) { - current = null; - return rawVNode; - } - let vnode = getInnerChild(rawVNode); - if (vnode.type === Comment) { - current = null; - return vnode; - } - const comp = vnode.type; - const name = getComponentName( - isAsyncWrapper(vnode) ? vnode.type.__asyncResolved || {} : comp - ); - const { include, exclude, max } = props; - if (include && (!name || !matches(include, name)) || exclude && name && matches(exclude, name)) { - vnode.shapeFlag &= -257; - current = vnode; - return rawVNode; - } - const key = vnode.key == null ? comp : vnode.key; - const cachedVNode = cache.get(key); - if (vnode.el) { - vnode = cloneVNode(vnode); - if (rawVNode.shapeFlag & 128) { - rawVNode.ssContent = vnode; - } - } - pendingCacheKey = key; - if (cachedVNode) { - vnode.el = cachedVNode.el; - vnode.component = cachedVNode.component; - if (vnode.transition) { - setTransitionHooks(vnode, vnode.transition); - } - vnode.shapeFlag |= 512; - keys.delete(key); - keys.add(key); - } else { - keys.add(key); - if (max && keys.size > parseInt(max, 10)) { - pruneCacheEntry(keys.values().next().value); - } - } - vnode.shapeFlag |= 256; - current = vnode; - return isSuspense(rawVNode.type) ? rawVNode : vnode; - }; - } -}; -var KeepAlive = KeepAliveImpl; -function matches(pattern, name) { - if (isArray(pattern)) { - return pattern.some((p2) => matches(p2, name)); - } else if (isString(pattern)) { - return pattern.split(",").includes(name); - } else if (isRegExp(pattern)) { - pattern.lastIndex = 0; - return pattern.test(name); - } - return false; -} -function onActivated(hook, target) { - registerKeepAliveHook(hook, "a", target); -} -function onDeactivated(hook, target) { - registerKeepAliveHook(hook, "da", target); -} -function registerKeepAliveHook(hook, type, target = currentInstance) { - const wrappedHook = hook.__wdc || (hook.__wdc = () => { - let current = target; - while (current) { - if (current.isDeactivated) { - return; - } - current = current.parent; - } - return hook(); - }); - injectHook(type, wrappedHook, target); - if (target) { - let current = target.parent; - while (current && current.parent) { - if (isKeepAlive(current.parent.vnode)) { - injectToKeepAliveRoot(wrappedHook, type, target, current); - } - current = current.parent; - } - } -} -function injectToKeepAliveRoot(hook, type, target, keepAliveRoot) { - const injected = injectHook( - type, - hook, - keepAliveRoot, - true - /* prepend */ - ); - onUnmounted(() => { - remove(keepAliveRoot[type], injected); - }, target); -} -function resetShapeFlag(vnode) { - vnode.shapeFlag &= -257; - vnode.shapeFlag &= -513; -} -function getInnerChild(vnode) { - return vnode.shapeFlag & 128 ? vnode.ssContent : vnode; -} -function injectHook(type, hook, target = currentInstance, prepend = false) { - if (target) { - const hooks = target[type] || (target[type] = []); - const wrappedHook = hook.__weh || (hook.__weh = (...args) => { - pauseTracking(); - const reset = setCurrentInstance(target); - const res = callWithAsyncErrorHandling(hook, target, type, args); - reset(); - resetTracking(); - return res; - }); - if (prepend) { - hooks.unshift(wrappedHook); - } else { - hooks.push(wrappedHook); - } - return wrappedHook; - } else if (true) { - const apiName = toHandlerKey(ErrorTypeStrings$1[type].replace(/ hook$/, "")); - warn$1( - `${apiName} is called when there is no active component instance to be associated with. Lifecycle injection APIs can only be used during execution of setup(). If you are using async setup(), make sure to register lifecycle hooks before the first await statement.` - ); - } -} -var createHook = (lifecycle) => (hook, target = currentInstance) => { - if (!isInSSRComponentSetup || lifecycle === "sp") { - injectHook(lifecycle, (...args) => hook(...args), target); - } -}; -var onBeforeMount = createHook("bm"); -var onMounted = createHook("m"); -var onBeforeUpdate = createHook( - "bu" -); -var onUpdated = createHook("u"); -var onBeforeUnmount = createHook( - "bum" -); -var onUnmounted = createHook("um"); -var onServerPrefetch = createHook( - "sp" -); -var onRenderTriggered = createHook("rtg"); -var onRenderTracked = createHook("rtc"); -function onErrorCaptured(hook, target = currentInstance) { - injectHook("ec", hook, target); -} -var COMPONENTS = "components"; -var DIRECTIVES = "directives"; -function resolveComponent(name, maybeSelfReference) { - return resolveAsset(COMPONENTS, name, true, maybeSelfReference) || name; -} -var NULL_DYNAMIC_COMPONENT = Symbol.for("v-ndc"); -function resolveDynamicComponent(component) { - if (isString(component)) { - return resolveAsset(COMPONENTS, component, false) || component; - } else { - return component || NULL_DYNAMIC_COMPONENT; - } -} -function resolveDirective(name) { - return resolveAsset(DIRECTIVES, name); -} -function resolveAsset(type, name, warnMissing = true, maybeSelfReference = false) { - const instance = currentRenderingInstance || currentInstance; - if (instance) { - const Component = instance.type; - if (type === COMPONENTS) { - const selfName = getComponentName( - Component, - false - ); - if (selfName && (selfName === name || selfName === camelize(name) || selfName === capitalize(camelize(name)))) { - return Component; - } - } - const res = ( - // local registration - // check instance[type] first which is resolved for options API - resolve(instance[type] || Component[type], name) || // global registration - resolve(instance.appContext[type], name) - ); - if (!res && maybeSelfReference) { - return Component; - } - if (warnMissing && !res) { - const extra = type === COMPONENTS ? ` -If this is a native custom element, make sure to exclude it from component resolution via compilerOptions.isCustomElement.` : ``; - warn$1(`Failed to resolve ${type.slice(0, -1)}: ${name}${extra}`); - } - return res; - } else if (true) { - warn$1( - `resolve${capitalize(type.slice(0, -1))} can only be used in render() or setup().` - ); - } -} -function resolve(registry, name) { - return registry && (registry[name] || registry[camelize(name)] || registry[capitalize(camelize(name))]); -} -function renderList(source, renderItem, cache, index) { - let ret; - const cached = cache && cache[index]; - const sourceIsArray = isArray(source); - if (sourceIsArray || isString(source)) { - const sourceIsReactiveArray = sourceIsArray && isReactive(source); - let needsWrap = false; - let isReadonlySource = false; - if (sourceIsReactiveArray) { - needsWrap = !isShallow(source); - isReadonlySource = isReadonly(source); - source = shallowReadArray(source); - } - ret = new Array(source.length); - for (let i = 0, l = source.length; i < l; i++) { - ret[i] = renderItem( - needsWrap ? isReadonlySource ? toReadonly(toReactive(source[i])) : toReactive(source[i]) : source[i], - i, - void 0, - cached && cached[i] - ); - } - } else if (typeof source === "number") { - if (!Number.isInteger(source) || source < 0) { - warn$1( - `The v-for range expects a positive integer value but got ${source}.` - ); - ret = []; - } else { - ret = new Array(source); - for (let i = 0; i < source; i++) { - ret[i] = renderItem(i + 1, i, void 0, cached && cached[i]); - } - } - } else if (isObject(source)) { - if (source[Symbol.iterator]) { - ret = Array.from( - source, - (item, i) => renderItem(item, i, void 0, cached && cached[i]) - ); - } else { - const keys = Object.keys(source); - ret = new Array(keys.length); - for (let i = 0, l = keys.length; i < l; i++) { - const key = keys[i]; - ret[i] = renderItem(source[key], key, i, cached && cached[i]); - } - } - } else { - ret = []; - } - if (cache) { - cache[index] = ret; - } - return ret; -} -function createSlots(slots, dynamicSlots) { - for (let i = 0; i < dynamicSlots.length; i++) { - const slot = dynamicSlots[i]; - if (isArray(slot)) { - for (let j = 0; j < slot.length; j++) { - slots[slot[j].name] = slot[j].fn; - } - } else if (slot) { - slots[slot.name] = slot.key ? (...args) => { - const res = slot.fn(...args); - if (res) res.key = slot.key; - return res; - } : slot.fn; - } - } - return slots; -} -function renderSlot(slots, name, props = {}, fallback, noSlotted) { - if (currentRenderingInstance.ce || currentRenderingInstance.parent && isAsyncWrapper(currentRenderingInstance.parent) && currentRenderingInstance.parent.ce) { - const hasProps = Object.keys(props).length > 0; - if (name !== "default") props.name = name; - return openBlock(), createBlock( - Fragment, - null, - [createVNode("slot", props, fallback && fallback())], - hasProps ? -2 : 64 - ); - } - let slot = slots[name]; - if (slot && slot.length > 1) { - warn$1( - `SSR-optimized slot function detected in a non-SSR-optimized render function. You need to mark this component with $dynamic-slots in the parent template.` - ); - slot = () => []; - } - if (slot && slot._c) { - slot._d = false; - } - openBlock(); - const validSlotContent = slot && ensureValidVNode(slot(props)); - const slotKey = props.key || // slot content array of a dynamic conditional slot may have a branch - // key attached in the `createSlots` helper, respect that - validSlotContent && validSlotContent.key; - const rendered = createBlock( - Fragment, - { - key: (slotKey && !isSymbol(slotKey) ? slotKey : `_${name}`) + // #7256 force differentiate fallback content from actual content - (!validSlotContent && fallback ? "_fb" : "") - }, - validSlotContent || (fallback ? fallback() : []), - validSlotContent && slots._ === 1 ? 64 : -2 - ); - if (!noSlotted && rendered.scopeId) { - rendered.slotScopeIds = [rendered.scopeId + "-s"]; - } - if (slot && slot._c) { - slot._d = true; - } - return rendered; -} -function ensureValidVNode(vnodes) { - return vnodes.some((child) => { - if (!isVNode(child)) return true; - if (child.type === Comment) return false; - if (child.type === Fragment && !ensureValidVNode(child.children)) - return false; - return true; - }) ? vnodes : null; -} -function toHandlers(obj, preserveCaseIfNecessary) { - const ret = {}; - if (!isObject(obj)) { - warn$1(`v-on with no argument expects an object value.`); - return ret; - } - for (const key in obj) { - ret[preserveCaseIfNecessary && /[A-Z]/.test(key) ? `on:${key}` : toHandlerKey(key)] = obj[key]; - } - return ret; -} -var getPublicInstance = (i) => { - if (!i) return null; - if (isStatefulComponent(i)) return getComponentPublicInstance(i); - return getPublicInstance(i.parent); -}; -var publicPropertiesMap = ( - // Move PURE marker to new line to workaround compiler discarding it - // due to type annotation - extend(/* @__PURE__ */ Object.create(null), { - $: (i) => i, - $el: (i) => i.vnode.el, - $data: (i) => i.data, - $props: (i) => true ? shallowReadonly(i.props) : i.props, - $attrs: (i) => true ? shallowReadonly(i.attrs) : i.attrs, - $slots: (i) => true ? shallowReadonly(i.slots) : i.slots, - $refs: (i) => true ? shallowReadonly(i.refs) : i.refs, - $parent: (i) => getPublicInstance(i.parent), - $root: (i) => getPublicInstance(i.root), - $host: (i) => i.ce, - $emit: (i) => i.emit, - $options: (i) => __VUE_OPTIONS_API__ ? resolveMergedOptions(i) : i.type, - $forceUpdate: (i) => i.f || (i.f = () => { - queueJob(i.update); - }), - $nextTick: (i) => i.n || (i.n = nextTick.bind(i.proxy)), - $watch: (i) => __VUE_OPTIONS_API__ ? instanceWatch.bind(i) : NOOP - }) -); -var isReservedPrefix = (key) => key === "_" || key === "$"; -var hasSetupBinding = (state, key) => state !== EMPTY_OBJ && !state.__isScriptSetup && hasOwn(state, key); -var PublicInstanceProxyHandlers = { - get({ _: instance }, key) { - if (key === "__v_skip") { - return true; - } - const { ctx, setupState, data, props, accessCache, type, appContext } = instance; - if (key === "__isVue") { - return true; - } - if (key[0] !== "$") { - const n = accessCache[key]; - if (n !== void 0) { - switch (n) { - case 1: - return setupState[key]; - case 2: - return data[key]; - case 4: - return ctx[key]; - case 3: - return props[key]; - } - } else if (hasSetupBinding(setupState, key)) { - accessCache[key] = 1; - return setupState[key]; - } else if (__VUE_OPTIONS_API__ && data !== EMPTY_OBJ && hasOwn(data, key)) { - accessCache[key] = 2; - return data[key]; - } else if (hasOwn(props, key)) { - accessCache[key] = 3; - return props[key]; - } else if (ctx !== EMPTY_OBJ && hasOwn(ctx, key)) { - accessCache[key] = 4; - return ctx[key]; - } else if (!__VUE_OPTIONS_API__ || shouldCacheAccess) { - accessCache[key] = 0; - } - } - const publicGetter = publicPropertiesMap[key]; - let cssModule, globalProperties; - if (publicGetter) { - if (key === "$attrs") { - track(instance.attrs, "get", ""); - markAttrsAccessed(); - } else if (key === "$slots") { - track(instance, "get", key); - } - return publicGetter(instance); - } else if ( - // css module (injected by vue-loader) - (cssModule = type.__cssModules) && (cssModule = cssModule[key]) - ) { - return cssModule; - } else if (ctx !== EMPTY_OBJ && hasOwn(ctx, key)) { - accessCache[key] = 4; - return ctx[key]; - } else if ( - // global properties - globalProperties = appContext.config.globalProperties, hasOwn(globalProperties, key) - ) { - { - return globalProperties[key]; - } - } else if (currentRenderingInstance && (!isString(key) || // #1091 avoid internal isRef/isVNode checks on component instance leading - // to infinite warning loop - key.indexOf("__v") !== 0)) { - if (data !== EMPTY_OBJ && isReservedPrefix(key[0]) && hasOwn(data, key)) { - warn$1( - `Property ${JSON.stringify( - key - )} must be accessed via $data because it starts with a reserved character ("$" or "_") and is not proxied on the render context.` - ); - } else if (instance === currentRenderingInstance) { - warn$1( - `Property ${JSON.stringify(key)} was accessed during render but is not defined on instance.` - ); - } - } - }, - set({ _: instance }, key, value) { - const { data, setupState, ctx } = instance; - if (hasSetupBinding(setupState, key)) { - setupState[key] = value; - return true; - } else if (setupState.__isScriptSetup && hasOwn(setupState, key)) { - warn$1(`Cannot mutate