Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,17 @@ site/.vitepress/cache

# 误生成的嵌套 build 产物
site/site/

# example 编译产物(内核模块 + 用户态可执行,项目级自给自足,不依赖全局 gitignore)
example/mini/*/*.o
example/mini/*/*.ko
example/mini/*/*.mod
example/mini/*/*.mod.c
example/mini/*/*.symvers
example/mini/*/modules.order
example/mini/*/.*.cmd
example/mini/*/*_user
example/project/*/*_user

# 临时亲测输出(根目录 wait.md,会话用)
/wait.md
19 changes: 19 additions & 0 deletions configs/arm64-qemu-virt-learn.config
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,22 @@ CONFIG_SHMEM=y
# === 高精度定时器 (让 sleep 等命令精度正常) ===
CONFIG_HIGH_RES_TIMERS=y
CONFIG_NO_HZ_IDLE=y

# === 调试与追踪 (为 debugging 藤亲测启用 · 第一批: kprobes/ftrace/dynamic-debug/锁检测)===
# kprobes: 动态插桩, 探测任意指令/函数入口 (debug-kprobes 节点)
CONFIG_KPROBES=y
CONFIG_KRETPROBES=y
CONFIG_KPROBE_EVENTS=y
# ftrace: 函数追踪 / 事件追踪 (debug-ftrace 节点)
CONFIG_TRACING=y
CONFIG_FUNCTION_TRACER=y
CONFIG_FUNCTION_GRAPH_TRACER=y
CONFIG_EVENT_TRACING=y
# dynamic-debug / atomic-sleep 检测: 暂不开 ——
# 这俩 config 会让 uaccess / pr_debug 内联进对 __might_fault / __dynamic_pr_debug
# 的引用, 而 build 后 Module.symvers 没收集这俩符号, 外部模块 modpost 失败。
# pr_debug 改用编译期 -DDEBUG 开(见 example/mini/06-debug-printk 的 Makefile)。
# CONFIG_DYNAMIC_DEBUG=y
# CONFIG_DEBUG_ATOMIC_SLEEP=y
# 锁依赖检测: 死锁排查 (debug-lockdep 节点)
CONFIG_LOCKDEP=y
5 changes: 3 additions & 2 deletions document/guides/01-kernel-build.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ scripts/linux-action-scripts.sh clean # 删 out/build_latest_<arch>/
| 变量 | 默认 | 说明 |
|------|------|------|
| `ARCH` | `arm` | `aarch64` 会被脚本映射成内核命名 `arm64` |
| `CROSS_COMPILE` | 按 `ARCH` 自动选 | 工具链前缀 |
| `CROSS_COMPILE` | `arm-none-linux-gnueabihf-`(ARM32) | 工具链前缀;**ARM64 必须显式设 `CROSS_COMPILE=aarch64-linux-gnu-`**——脚本不会按 `ARCH` 自动切(实测踩坑,见下) |
| `LINUX_DEFCONFIG` | (无) | `config` 命令必填;ARM64 用 `defconfig`,ARM32 用 `vexpress_defconfig` |
| `BUILD_OUTPUT_BASE` | `out/build_latest_<arch>` | 产物目录;显式指定时不触发自动备份 |
| `BUILD_JOBS` | `nproc` | 并行度 |
Expand All @@ -58,7 +58,7 @@ ARM32 对应 `arch/arm/boot/zImage`。
## 实操:编一个 ARM64 内核

```bash
ARCH=aarch64 LINUX_DEFCONFIG=defconfig \
ARCH=aarch64 CROSS_COMPILE=aarch64-linux-gnu- LINUX_DEFCONFIG=defconfig \
scripts/linux-action-scripts.sh config_and_build
```

Expand Down Expand Up @@ -104,3 +104,4 @@ ARCH=arm LINUX_DEFCONFIG=vexpress_defconfig \
- **`LINUX_DEFCONFIG` 没设就跑 `config`**:脚本直接报错退出。ARM64 填 `defconfig`,ARM32 填 `vexpress_defconfig`。
- **改了源码不生效**:确认改的是 `third_party/linux/` 下的文件,且 `build` 用的 `O=` 目录和之前一致——别一不小心换了输出目录,等于从头编译。
- **`ARCH=aarch64` vs `arm64`**:你输入 `aarch64`(工具链习惯),脚本内部转成 `arm64`(内核习惯),输出目录也用 `arm64`。记住这点就不会找错目录。
- **漏 `CROSS_COMPILE` 会用 ARM32 默认工具链(大坑)**:脚本 `CROSS_COMPILE` 默认 `arm-none-linux-gnueabihf-`(ARM32),**不会按 `ARCH` 自动切**。ARM64 编译必须显式 `CROSS_COMPILE=aarch64-linux-gnu-`,否则 ARM32 gcc 不认 ARM64 选项(`-msign-return-address=non-leaf` 等)编译失败——而且脚本不检测编译错误会**误报 SUCCESS**,结果 Image 根本没更新、`struct module` 布局对不上,外部模块 insmod 报 `invalid module format`。看到 build 秒结束就要怀疑这个。
30 changes: 23 additions & 7 deletions document/tutorials/debugging/01-debug-printk.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ difficulty: intermediate
tags: [printk, 日志系统, 调试, 动态调试]
architectures: [arm64, x86_64, riscv]
kernel_version: "6.19"
maturity: drafting
maturity: verified
prerequisites:
- /tutorials/foundations/07-kernel-module-hello
related: []
Expand Down Expand Up @@ -260,14 +260,30 @@ 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 的耗时和返回值,查启动卡死神器)。

## 动手试试
## 动手试试(2026-06-27 已亲测)

> 以下是验证方案,等在 QEMU ARM64 上实跑后填真实输出
代码落在 `example/mini/06-debug-printk/`(模块名取 `dbgprintk`,避免和内核自带的 `printk` 名字撞车)。QEMU ARM64 + Linux 6.19 上 `insmod` 后跑通,以下都是真实输出

- 写一个内核模块,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`(待亲测)。
- 写一个内核模块,init 函数里依次 `pr_emerg`…`pr_debug` 各打一条,配 `pr_fmt` 自动加 `模块名:` 前缀;`insmod` 后 `dmesg` 观察各级别,确认 `KERN_DEBUG` 默认是否上屏。

`insmod dbgprintk.ko` 后 `dmesg` 实测输出:

```
dbgprintk: EMERG (0)
dbgprintk: ALERT (1)
dbgprintk: CRIT (2)
dbgprintk: ERR (3)
dbgprintk: WARN (4)
dbgprintk: NOTICE(5)
dbgprintk: INFO (6)
dbgprintk: printk demo: loaded, pr_fmt prefix = 'dbgprintk: '
```

七条 `pr_emerg`…`pr_info`(级别 0~6)全部出现,每条都带 `dbgprintk:` 前缀——这就是 `pr_fmt` 的功劳,配的是 `#define pr_fmt(fmt) "dbgprintk: " fmt`,所有 `pr_*` 自动套上模块名前缀。最后一条 `printk demo: loaded, pr_fmt prefix = 'dbgprintk: '` 是模块自己打的确认行,把实际生效的 `pr_fmt` 模板原样打印出来对账。

**`pr_debug` 那条默认没出现**——这是关键现象。在本 mini config 下没开 `CONFIG_DYNAMIC_DEBUG`,`pr_debug` 走的是 `no_printk` 分支(编译期消除),所以 `dmesg` 里压根没有 `dbgprintk: DEBUG (7)` 这一行。这正是前文讲的三态定义的体现:默认配置下 `pr_debug` 是"看不见的",想让它出声得开 `CONFIG_DYNAMIC_DEBUG`(走 `dynamic_pr_debug`),或在编译时定义 `DEBUG` 宏。

- `cat /proc/sys/kernel/printk` 记下四个数字,对照本文 `console_printk[4]` 的含义——默认下级别 0~6 能上控制台(`console_loglevel` 默认 7,数值 < 7 的都放行),所以上面七条都刷出来了;`echo 8 > /proc/sys/kernel/printk` 也救不回 `pr_debug`,因为它在编译期就被消除了,不是被 loglevel 过滤掉的。

## 小结

Expand Down
31 changes: 23 additions & 8 deletions document/tutorials/debugging/05-debug-oops.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ difficulty: intermediate
tags: [内核调试, Oops, panic, 栈回溯]
architectures: [arm64, x86_64, riscv]
kernel_version: "6.19"
maturity: drafting
maturity: verified
prerequisites:
- /tutorials/foundations/06-gdb-debug-setup
related:
Expand Down Expand Up @@ -176,15 +176,30 @@ strb w2, [x3, #48] ; x3=0(NULL) -> 写 0+48,炸

`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",已经打过一次了,别再打。

## 动手试试
## 动手试试(2026-06-27 已亲测)

> ⚠️ **待亲测**:下面是验证方案,具体输出我们会在 QEMU ARM64 上跑一遍记下真值再补。模块示例代码不在此展开,动手部分保持"方案 + 待亲测"占位,真跑通后再把命令输出补进来
代码落在 `example/mini/07-debug-oops/`(模块名 `oops`)。QEMU ARM64 + Linux 6.19 上 `insmod oops.ko trigger=1` 触发,以下都是真实 Oops 现场

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,重点看 `<IRQ>...</IRQ>` 分隔的中断栈。
5. **关 KASLR 对照**:同一次崩溃,`nokaslr` 下 `addr2line` 直接命中;开 KASLR 下对不上号,改用 `scripts/faddr2line`。
1. **触发一次进程上下文 Oops**:写个模块,`init` 里给一个 NULL 指针偏移成员赋值(`struct oopsie *p = NULL; p->data = 'x';`,`data` 偏移固定设计成 0x30)。`insmod oops.ko trigger=1` 后 `dmesg` 抓到完整 Oops,关键现场逐段对照上文:

```
Unable to handle kernel NULL pointer dereference at virtual address 0000000000000030
...
pc : oopsdemo_init+0x3c/0xfdc [oops]
...
Code: 91012000 97ffffeb d2800600 52800f01 (39000001)
...
Tainted: G O
```

几个对号入座的点:

- **`0000000000000030`**:正是 `NULL + 0x30`——NULL 指针加结构体成员 `data` 的 0x30 偏移,前文"看到几十几百的小地址就反射弧接上 NULL 指针访问成员,那数字就是偏移量"这条经验,这条 `0x30` 就是活证。
- **`pc : oopsdemo_init+0x3c/0xfdc [oops]`**:崩点在模块 `oops` 的 `oopsdemo_init` 函数偏移 `+0x3c` 处,`[oops]` 标明是树外模块。拿这个偏移喂 `addr2line -e oops.ko -p -f 0x3c` 就能钉回源码行。
- **`Code: ... (39000001)`**:ARM64 的崩点指令用**圆括号**包起来(`(39000001)`),周围几条不带括号——印证了前文"ARM64 是圆括号、x86 是尖括号"的格式区分。这条 `0x39000001` 就是 `strb w1, [x0]` 类的访存指令,把 `'x'` 写进 `[x0(=NULL) + 0x30]`,当场炸。
- **`Tainted: G O`**:`G` 是"没加载私有闭源模块"的干净基线字符(不是污染位),后面的 `O`(`TAINT_OOT_MODULE`)才是真污染——`insmod` 了这个树外 `.ko`,内核被打了 `O` 标记,上游会据此拒收 bug 报告。

2. **`panic_on_oops` 开关对比**:本 mini config 默认 `panic_on_oops=0`,所以这次崩溃没升级成 panic——`insmod oops.ko trigger=1` 在用户态报的是 `Segmentation fault`(内核 `make_task_dead(SIGSEGV)` 把肇事进程做掉),但**系统继续跑**,shell 还活着,能接着敲 `dmesg` 看现场。`echo 1 > /proc/sys/kernel/panic_on_oops` 再触发则会直接 panic 停摆。

## 小结

Expand Down
38 changes: 27 additions & 11 deletions document/tutorials/drivers/01-drv-chardev.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ difficulty: intermediate
tags: [字符设备, file_operations, cdev, misc 设备]
architectures: [arm64, x86_64, riscv]
kernel_version: "6.19"
maturity: drafting
maturity: verified
prerequisites:
- /tutorials/foundations/07-kernel-module-hello
related:
Expand Down Expand Up @@ -71,11 +71,11 @@ struct cdev {
| `.read` | `read()` | 把内核数据搬给用户(配 `copy_to_user`) |
| `.write` | `write()` | 收用户数据进内核(配 `copy_from_user`) |
| `.release` | `close()` | 释放 `open` 申请的资源 |
| `.llseek` | `lseek()` | 调整文件偏移,不支持就显式设 `no_llseek` |
| `.llseek` | `lseek()` | 调整文件偏移,不支持就显式设 `noop_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`。
签名都是固定的,比如 `.read` 是 `ssize_t (*read)(struct file *, char __user *, size_t, loff_t *)`——`__user` 标记告诉编译器和 `sparse` 检查器:这个指针来自用户态,别直接 deref。某个回调不实现就让对应指针为 `NULL`,VFS 会返回默认错误。但有个坑:`.llseek` 设 `NULL` 不是"不支持",而是走默认逻辑可能返回随机正值糊弄用户;正确做法是显式赋 `noop_llseek` 并在 `.open` 里调 `nonseekable_open()`,这样用户态 `lseek` 会得到明明白白的 `-ESPIPE`。

## 用户态怎么连上:open() → VFS → chrdev_open → 你的 .open

Expand Down Expand Up @@ -110,30 +110,46 @@ struct cdev {

漏了这个检查就是经典提权路径:假设 `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 读写
## 动手验证(2026-06-27 已亲测):写个 misc 设备,cat/echo 读写

动手部分留到亲测阶段,这里先给验证方案占位,落地代码归后续 `example/mini/` 目录
代码落在 `example/mini/01-chardev_basic/`。QEMU ARM64 + Linux 6.19 上 `insmod` 后跑通,以下都是真实输出

**目标**:一个 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`。
1. 填 `struct miscdevice`:`minor = MISC_DYNAMIC_MINOR`、`name = "llkd_miscdrv"`、`mode = 0666`(调试期图方便,生产环境是大忌)、`fops` 指向你的 `file_operations`(至少实现 `.open/.read/.write/.release`,`.llseek = noop_llseek`)。
2. `init` 里 `misc_register()`,`dmesg` 看到 `major # 10, minor# = N`。
3. 读写用 `copy_to_user`/`copy_from_user`,**写时先判 `count > MAXBYTES` 返回 `-EFBIG`**,严守边界。
4. `exit` 里 `misc_deregister()` 配对。

预期命令输出(待亲测):
实测命令输出(QEMU ARM64,2026-06-27):

```
$ ls -l /dev/llkd_miscdrv
crw-rw-rw- 1 root root 10, 56 ... /dev/llkd_miscdrv # 10 主号,56 动态次号
crw-rw-rw- 1 0 0 10, 258 /dev/llkd_miscdrv
```

`10` 是 misc 框架共享的主号,`258` 是 `MISC_DYNAMIC_MINOR` 动态分到的次号——果然落在 `>255` 池子里(印证了前文"动态次号 >255"那段),不是示例里随手写的 56。devtmpfs 自动把这个节点建出来了,不用手敲 `mknod`。

```
$ echo "hello kernel" > /dev/llkd_miscdrv
# dmesg
llkd_miscdrv: write() 13 bytes
$ cat /dev/llkd_miscdrv
hello kernel
```

> ⚠️ **待亲测**:上面命令输出是参考样例,次设备号会变——`minor` 设了 `MISC_DYNAMIC_MINOR` 时,内核分到的是 `>255` 池子里的号,而非示例里随手写的 56(56 其实落在 `<255` 的固定号区间,这里只是示意格式)。我们会拿到 QEMU ARM64 上 `insmod` 后跑一遍,把 `dmesg`、`ls -l`、`cat`/`echo` 的真实输出记下来,顺手验证"故意写超长数据"会被我们的边界检查挡住(返回 `-EFBIG`)。
写进 `"hello kernel"`(含换行共 13 字节),驱动的 `.write` 经 `copy_from_user` 收下;`cat` 调 `.read` 经 `copy_to_user` 把同一句端回来,echo/cat 闭环成立。

边界检查也按设计拦下了超长写:

```
$ head -c 200 /dev/urandom > /dev/llkd_miscdrv
head: standard output: File too large
```

这是用户态看到的报错——驱动的 `.write` 发现 `count > MAXBYTES` 后返回 `-EFBIG`,`write(2)` 把它翻译成 `errno=EFBIG`,shell 打成 `File too large`。200 字节没越界写进内核缓冲区,前面那条"边界检查是驱动作者的命"的红线,这条 `-EFBIG` 就是兑现。

## 小结

Expand Down
Loading
Loading