Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

3.1 内核架构、源码、构建与调试环境

本章回答

内核到底替你做了哪几件事,又有哪些事它不做?一千多万行源码该从哪个目录读起?make defconfig && make -j 之后生成的那几个文件各是什么?怎样在 QEMU 里跑一个自己编译的内核、加载自己的模块、并在 start_kernel 上停下来?

读完你会

画出内核的职责边界与源码目录地图;用 Kconfig/Kbuild 增删一个配置项并解释 y/m/n 的差别;用 BusyBox 做一个 initramfs 并在 QEMU 中启动 6.8 内核;写一个带参数的模块,用 dmesg、gdb、ftrace 各取到一条证据。

从这一章起,视角从「使用 Linux」切到「读 Linux」。前两篇你关心的是接口行为:read 返回什么、fork 之后谁先跑、mmap 的标志位怎么组合。进入内核篇,问题变成另一类:这些行为由哪个函数、哪张数据结构、哪把锁实现,代价落在哪。

第一件要建立的能力不是读代码,而是搭一个能反复重来的实验环境。内核学习最贵的成本是「改一行、编一次、重启一次机器」,所以本章把环境一次性搭好:源码树、构建系统、QEMU 虚机、initramfs、模块加载、gdb 断点。后面 14 章的实验都复用这套环境。

统一版本:Linux 6.8(2024 年 3 月发布),x86-64,GCC 13,QEMU 8.x。6.8 不是 LTS(6.6 与 6.12 才是),选它是因为它处在「EEVDF 已稳定、folio 已铺开、maple tree 已替换 VMA 红黑树」的时点,读到的代码与今天的上游差距最小。遇到 4.x/5.x 的写法差异,文中会明确标注。

3.1.1 内核的职责边界:它做什么,不做什么

定义 3.1.1 · 内核(kernel)

内核是常驻内存、运行在 CPU 最高特权级(x86-64 的 ring 0)上的那部分代码。它独占三样东西:特权指令(改页表、关中断、访问 I/O 端口)、全部物理内存的映射能力、设备的中断向量。用户态程序只能通过系统调用、异常、中断三条路径进入内核。

把「内核做什么」压缩成一张表,比背定义有用:

职责内核里的落点不管会怎样本书位置
虚拟化 CPU调度器 kernel/sched/一个死循环占满机器3.5、3.6
虚拟化内存mm/、页表进程能读别人的密码3.7–3.10
持久化VFS、文件系统、块层掉电后数据成谜3.12、3.13
隔离与权限kernel/cred.c、LSM普通用户能 mount3.4
设备管理drivers/显卡、网卡无法工作3.14
网络协议栈net/只能自己拼以太网帧第 4 篇

反过来,内核不做的事同样重要:它不认识 printf 的格式串(那是 libc),不解析 JSON,不做字符编码转换,不理解「用户」这个概念之外的业务语义。内核只提供机制(mechanism),策略(policy)尽量留给用户态。调度器给你 nice 值和调度类,但不判断「哪个进程更重要」;cgroup 给你配额,但配额定多少是运维的事。

为什么 Linux 是宏内核却还能装驱动

微内核把文件系统、驱动都挪到用户态,靠 IPC 通信,理论上隔离性更好。Linux 走另一条路:所有东西都编进内核地址空间(宏内核),但用可加载模块解决「不能重新编译整个内核」的问题。代价是一个驱动里的空指针能拖垮整台机器——这也是为什么驱动代码 review 比用户态代码严得多。

这条边界有一个正在移动的口子:eBPF。它允许用户态把经过验证的字节码挂到内核的钩子上(kprobe、tracepoint、TC、cgroup),在不改内核、不写模块的前提下扩展行为。6.8 的内核里 eBPF 已经是一个完整的子系统(kernel/bpf/),第 3.15 章会用它做观测。

3.1.2 源码目录地图:先记 12 个目录

6.8 的源码树约 7 万个文件、3000 万行(含驱动与架构代码)。不需要背,但要知道「一个问题该去哪个目录找答案」。

linux-6.8/
├── arch/            体系结构相关:arch/x86 下才有真正的页表、入口、中断门
│   └── x86/
│       ├── entry/       系统调用与中断入口(entry_64.S、syscall_64.c)
│       ├── mm/          页表、缺页、ioremap(pgtable_64.c、fault.c)
│       └── kernel/      CPU 初始化、SMP、APIC(smpboot.c、apic/)
├── kernel/          与架构无关的核心:调度、fork、exit、时间、cgroup
│   ├── sched/       fair.c(CFS/EEVDF)、rt.c、deadline.c、core.c
│   ├── fork.c       kernel_clone()、copy_process()
│   ├── exit.c       do_exit()、wait4()
│   └── locking/     mutex.c、spinlock.c、lockdep.c
├── mm/              内存管理:page_alloc.c、slub.c、vmscan.c、memory.c
├── fs/              文件系统:VFS(namei.c、open.c、read_write.c)+ 各具体 fs
├── block/           块层:blk-mq.c、bio.c、elevator.c、mq-deadline.c
├── include/linux/   内核内部接口的头文件(sched.h、mm.h、fs.h、slab.h)
├── include/uapi/    用户态可见的 ABI 常量与结构(ioctl 号、syscall 号)
├── drivers/         驱动:char/、block/、net/、pci/、base/(总线与设备模型)
├── net/             协议栈:core/、ipv4/、ipv6/、netfilter/、unix/
├── ipc/             System V IPC 与 POSIX mqueue
├── security/        LSM 框架(SELinux、AppArmor、capability)
├── lib/             内核自己用的库:rbtree.c、xarray.c、string.c、vsprintf.c
├── init/            main.c(start_kernel)、initramfs 与 initcall
├── Documentation/   ReST 文档,比多数博客可靠
└── scripts/         Kconfig 解析、gdb 脚本、checkpatch.pl、dtc

读这张图有三个常用入口,按问题类型选:

  1. 从系统调用进入。想知道 write 怎么走,从 SYSCALL_DEFINE3(write, ...) 开始(在 fs/read_write.c),跟着 vfs_write() 走。
  2. 从数据结构进入。想知道一个进程有哪些状态,直接读 include/linux/sched.h 的 struct task_struct;想知道一个物理页怎么描述,读 include/linux/mm_types.h 的 struct page。
  3. 从文档进入。Documentation/admin-guide/、Documentation/mm/、Documentation/filesystems/ 里的 ReST 是维护者写的,版本对得上。
定位一个符号的定义
# 1. 先确认源码版本与工作区干净
git -C linux-6.8 describe --tags          # v6.8
# 2. 找函数/结构定义(不要用 grep -r,太慢)
rg -n '^struct task_struct \{' include/linux/sched.h
rg -n 'kernel_clone\(' kernel/fork.c | head
# 3. 找调用点
rg -n 'copy_process\(' kernel/ | head
# 4. 看这个函数最近被谁改过
git -C linux-6.8 log --oneline -5 -- kernel/fork.c

rg(ripgrep)默认尊重 .gitignore,在源码树里比 grep -r 快一个数量级;查宏定义用 rg -n '#define\s+GFP_KERNEL'。

3.1.3 Kconfig 与 Kbuild:配置与编译是两套语言

新手最容易混淆的一点:Kconfig 决定编译什么,Makefile 决定怎么编译。两者通过 .config 里的 CONFIG_* 变量连接。

定义 3.1.2 · tristate 与三种取值

Kconfig 里的 tristate 有三个值:y(编进 vmlinux)、m(编成独立 .ko 模块)、n(不编译)。bool 只有 y/n。CONFIG_FOO=y 在编译命令行里表现为 -DCONFIG_FOO=1;CONFIG_FOO=m 表现为 -DCONFIG_FOO_MODULE=1,没有 CONFIG_FOO=1。所以代码里判断「功能是否可用」要写 IS_ENABLED(CONFIG_FOO),它同时覆盖 y 与 m。

一个最小 Kconfig 条目长这样(风格取自 mm/Kconfig):

config DEMO_PAGE_TRACK
	bool "track page allocation for demo"
	depends on DEBUG_KERNEL && PROC_FS
	default n
	help
	  打开后在 /proc/demo_pages 暴露一个只读计数器,
	  用于观察 alloc_pages() 的调用次数。仅用于实验。

三个关键字要分清:

  • depends on:必要条件。不满足时这个选项在 menuconfig 里根本不出现。
  • select:强制打开另一个符号。危险在于它绕过对方的 depends on,容易造出「A 选中 B,但 B 的前置条件没满足」的坏配置。上游对新 select 极谨慎。
  • default:默认值,可以带条件,如 default y if SMP。

配置完 .config,编译系统靠 Makefile 里的写法决定目标文件:

# 写法一:无条件编进内核
obj-y += demo.o

# 写法二:跟随配置,y 编进内核、m 编成模块
obj-$(CONFIG_DEMO_PAGE_TRACK) += demo_page_track.o

# 写法三:一个模块由多个目标文件链接而成
demo_page_track-y := track.o report.o

# 写法四:只在该配置下加的编译选项
ccflags-$(CONFIG_DEMO_DEBUG) += -DDEBUG

obj-m 与 obj-y 的差别不只是「是否独立成文件」:obj-y 的代码在链接时就固定进 vmlinux,启动即存在;obj-m 生成 .ko,运行时由 init_module() 装载,还要通过 vermagic 与签名检查。

常用配置命令:

命令作用什么时候用
make defconfig用 arch/x86/configs/x86_64_defconfig 生成 .config第一次构建
make menuconfig终端菜单界面手工增删选项
make savedefconfig把当前配置压成最小 defconfig提交配置改动
make localmodconfig只保留当前机器已加载模块对应的选项给本机裁内核
make olddefconfig旧 .config 补上新选项的默认值升级内核版本后
scripts/config -e FOO -d BAR命令行改单个选项脚本化、CI
配置阶段的四个坑
  1. 改了 .config 却没重新 make:Kbuild 会检测 .config 时间戳并自动跑 syncconfig,但如果你手改了 include/generated/autoconf.h,它不会救你。
  2. localmodconfig 裁太狠:它按「当前已加载模块」推断需求,会关掉还没用到的文件系统、USB HID、虚拟化驱动,结果新内核起不来。裁完至少保留 ext4、virtio、8139cp 或 e1000。
  3. select 造成的隐式依赖:menuconfig 里看到某选项自动变 y,多半是别人 select 了它。查 rg -n 'select FOO'。
  4. CONFIG_FOO=m 时用 #ifdef CONFIG_FOO:预处理看不到 CONFIG_FOO,分支被静默删掉。用 IS_ENABLED() 或 #if IS_MODULE()。

3.1.4 构建:从源码到 bzImage

一次完整构建产出四类东西,先认清它们:

产物路径是什么谁用
vmlinux源码根目录未压缩的 ELF,带符号与调试信息gdb、objdump、crash
bzImagearch/x86/boot/bzImage压缩后的可引导镜像QEMU、GRUB
System.map源码根目录符号 → 地址的文本表看 panic 地址、对照 /proc/kallsyms
.ko + modules.order各目录可加载模块insmod/modprobe

最小构建流程(Ubuntu 24.04 宿主,6.8):

sudo apt install build-essential flex bison libelf-dev libssl-dev \
                 libncurses-dev bc dwarves cpio qemu-system-x86
tar xf linux-6.8.tar.xz && cd linux-6.8

make x86_64_defconfig
# 打开调试信息、gdb 脚本、debugfs、kprobe/ftrace,关掉模块签名强制
scripts/config -e DEBUG_INFO -e DEBUG_INFO_DWARF5 -e GDB_SCRIPTS \
               -e DEBUG_FS -e KPROBES -e FTRACE -e FUNCTION_TRACER \
               -e FUNCTION_GRAPH_TRACER -e DYNAMIC_FTRACE \
               -e KPROBE_EVENTS -e BPF_SYSCALL -e BPF_JIT \
               -d MODULE_SIG_FORCE
make olddefconfig

make -j"$(nproc)"            # 首次约 5–20 分钟,视机器而定
ls -lh vmlinux arch/x86/boot/bzImage
make kernelrelease           # 6.8.0

几个容易踩的点:

  • KASLR:6.8 的 defconfig 打开 CONFIG_RANDOMIZE_BASE,内核基址每次启动都变,gdb 在 start_kernel 上下断点会失败。启动参数加 nokaslr 即可,不必改配置。
  • DEBUG_INFO_DWARF5 与 pahole:6.8 可能生成 DWARF5,老版本 pahole 读不了 BTF。dwarves 包要够新。
  • 磁盘:完整构建的中间文件约 2–4 GiB,O= 可以把输出放到别的目录:make O=/build/linux defconfig。
  • make modules_install 装到 /lib/modules/6.8.0/:只在真机安装时需要;QEMU + initramfs 实验不需要装到宿主。

内核映像落在虚拟地址的哪里

构建产物里最容易被忽略的是「地址」。x86-64 的内核虚拟地址空间划分是固定的(4 级页表、47 位有效地址),与物理内存大小无关:

起始地址区域大小用途
0x0000000000000000用户空间128 TiB每个进程各自的地址空间
0xffff888000000000直接映射(direct map)64 TiB全部物理内存的线性映射
0xffffc90000000000vmalloc / ioremap32 TiB非连续映射,见 3.9
0xffffea0000000000vmemmap1 TiBstruct page 数组
0xffffffff80000000内核代码映射512 MiB_text 从这里起,链接基址 0xffffffff81000000

这张表(简化自 Documentation/arch/x86/x86_64/mm.rst)解释了三件事:

  1. vmlinux 里的符号地址是链接地址,不是物理地址。start_kernel 在 System.map 里通常是 0xffffffff81xxxxxx;panic 打印的 RIP: 0010:start_kernel+0x... 也是这个空间的值,加 nokaslr 后可以直接对照。
  2. __pa()/virt_to_phys() 只对直接映射区成立。它们做的是减去 page_offset_base;对 vmalloc() 返回的地址,这个减法毫无意义,必须走 vmalloc_to_page() 再取 page_to_phys()。这是驱动里最常见的一类地址错误。
  3. 内核代码与只读数据在同一个大页映射里。6.8 把内核代码按 2 MiB 页对齐映射(kernel_physical_mapping_init()),减少 TLB 压力;这也意味着 W^X 只能做到「大页粒度不可写」,rodata 的严格只读要靠 mark_rodata_ro() 在启动末尾改页表权限。

.init 段是另一块特殊区域:__init 标注的函数和初始化数据在启动完成后由 free_initmem() 整段释放,/proc/meminfo 的 MemFree 会因此跳一下。模块的 __init 段同理,在 do_init_module() 成功后由 module_memfree() 释放——这也是 3.1.6 那条「别存 __init 函数指针」的底层原因。

`make` 之外的三条命令

make cscope/make tags 生成代码索引,配合 vim/emacs 跳转;make bindeb-pkg 直接产出 .deb(Debian/Ubuntu);make LLVM=1 用 clang/lld 构建,是近年上游 CI 的主力工具链。它们都不改变代码,只改变开发体验。

3.1.5 QEMU + BusyBox:十分钟搭出可重复的实验机

真机装内核的风险是「起不来就失去这台机器」。QEMU 把风险降到零:内核 panic 只会让 QEMU 退出,宿主毫发无损。更重要的好处是快——-no-reboot 加脚本,一次重启两秒。

整体结构:

 宿主机                               QEMU 虚机(x86-64, 2 vCPU, 512 MiB)
┌──────────────────┐               ┌─────────────────────────────────────┐
│ linux-6.8/       │  -kernel      │  bzImage 解压 → start_kernel        │
│   arch/x86/boot/ ├──────────────►│    → rest_init → kernel_init(PID 1) │
│     bzImage      │               │      → 执行 initramfs 的 /init      │
│ initramfs.cpio.gz│  -initrd      │        → mount proc/sys/dev         │
│   /init /bin/busybox              │        → exec /bin/sh               │
│   /dev/console   ├──────────────►│  串口 ttyS0 ←→ 宿主机终端           │
└──────────────────┘  -append      └─────────────────────────────────────┘
                       console=ttyS0 rdinit=/init

第一步,编一个静态 BusyBox(动态链接的 BusyBox 需要把 ld-linux 和 libc 一起塞进 initramfs,没必要):

cd busybox-1.36.1
make defconfig
sed -i 's/^# CONFIG_STATIC is not set/CONFIG_STATIC=y/' .config
make -j"$(nproc)" && make install       # 产物在 _install/

第二步,做根文件系统骨架。内核只要求一件事:/init 存在且可执行。剩下的挂载由这个脚本自己做:

#!/bin/sh
# initramfs 的 /init —— 作为 PID 1 运行
mount -t proc     none /proc
mount -t sysfs    none /sys
mount -t devtmpfs none /dev        # 内核会自动建 /dev/console
mkdir -p /tmp /root
echo "=== initramfs up, kernel $(uname -r) ==="
exec /bin/sh -i

第三步,打包成 newc 格式的 cpio 并用 gzip 压缩。注意 find . 必须在 _install 目录里执行,路径不能带前缀,否则内核找不到 /init:

cd _install
mkdir -p proc sys dev tmp root
cp /path/to/init .
chmod +x init
find . -print0 | cpio --null -ov --format=newc 2>/dev/null | gzip -9 > ../initramfs.cpio.gz

第四步,启动:

qemu-system-x86_64 \
  -m 512M -smp 2 \
  -kernel arch/x86/boot/bzImage \
  -initrd initramfs.cpio.gz \
  -append "console=ttyS0 rdinit=/init loglevel=7 nokaslr" \
  -nographic -no-reboot

进入后应看到类似输出(节选):

[    0.000000] Linux version 6.8.0 (you@host) (gcc 13.2.0) #1 SMP PREEMPT_DYNAMIC
[    0.000000] Command line: console=ttyS0 rdinit=/init loglevel=7 nokaslr
[    0.000000] BIOS-provided physical RAM map:
[    0.000000] BIOS-e820: [mem 0x0000000000000000-0x000000000009fbff] usable
...
[    2.113450] Run /init as init process
=== initramfs up, kernel 6.8.0 ===
/ #

退出 QEMU 的按键是 Ctrl-A 然后 X。想把宿主目录共享进虚机,加 -virtfs local,path=/tmp/share,mount_tag=host0,security_model=none,虚机里 mount -t 9p -o trans=virtio host0 /mnt。

例 3.1.1 · 内核起不来时按顺序看三件事
  1. QEMU 直接退出、没有一行 Linux version:-kernel 指错文件,或 bzImage 不是 x86-64。用 file arch/x86/boot/bzImage 确认。
  2. 停在 Run /init as init process 之后没有 shell:/init 没有可执行位、shebang 写的解释器不存在,或 rdinit= 路径拼错。把 rdinit=/bin/sh 试一次可快速区分。
  3. 出现 Kernel panic - not syncing: No working init found:initramfs 打包时多了一层目录(./_install/init 而不是 ./init)。用 zcat initramfs.cpio.gz | cpio -t | head 检查顶层。

3.1.6 第一个模块:hello, kernel

模块是「不重启就进内核」的唯一正路。下面的 hellomod.c 演示四件事:入口/出口、日志、模块参数、许可证。

// hellomod.c — 最小可加载模块(Linux 6.8)
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/smp.h>

static int repeat = 1;
module_param(repeat, int, 0444);
MODULE_PARM_DESC(repeat, "how many times to greet (default 1)");

static char *who = "kernel";
module_param(who, charp, 0444);
MODULE_PARM_DESC(who, "name to greet");

static int __init hellomod_init(void)
{
	int i;

	for (i = 0; i < repeat; i++)
		pr_info("hellomod: hello, %s (cpu %d)\n", who, raw_smp_processor_id());
	return 0;              /* 非 0 表示加载失败,模块会被自动卸载 */
}

static void __exit hellomod_exit(void)
{
	pr_info("hellomod: bye\n");
}

module_init(hellomod_init);
module_exit(hellomod_exit);

MODULE_LICENSE("GPL");                 /* 非 GPL 拿不到 EXPORT_SYMBOL_GPL 的符号 */
MODULE_AUTHOR("you");
MODULE_DESCRIPTION("minimal demo module");
MODULE_VERSION("0.1");

配套的 Makefile 必须用 Kbuild 的写法,不能直接调 gcc:

# 用法:make -C /path/to/linux-6.8 M=$PWD modules
obj-m += hellomod.o

KDIR ?= /lib/modules/$(shell uname -r)/build
PWD  := $(shell pwd)

all:
	$(MAKE) -C $(KDIR) M=$(PWD) modules

clean:
	$(MAKE) -C $(KDIR) M=$(PWD) clean

在宿主机上针对刚编好的源码树编译(不要 insmod 到宿主):

make -C /path/to/linux-6.8 M=$PWD modules
modinfo hellomod.ko | head -8

modinfo 会显示 vermagic: 6.8.0 SMP preempt mod_unload。这串字符串是模块与内核的「合同」:内核版本、SMP、抢占模型、架构任何一项不匹配,insmod 直接拒绝并报 invalid module format。

把 .ko 放进 initramfs 再启动,在虚机里加载:

# 虚机内
insmod /hellomod.ko repeat=3 who="qemu"
dmesg | tail -4
lsmod | head -3
cat /sys/module/hellomod/parameters/repeat
rmmod hellomod

预期 dmesg 里出现三行 hellomod: hello, qemu (cpu 0) 和一行 hellomod: bye。/sys/module/hellomod/parameters/ 下的文件是 module_param 自动生成的,权限 0444 意味着只读;改成 0644 就能在运行时 echo 5 > repeat(但模块代码要自己处理并发读)。

模块相关的五个误解
  1. 「insmod 失败是权限问题」:先看 dmesg。常见原因是 vermagic 不匹配、签名强制(CONFIG_MODULE_SIG_FORCE=y 且模块未签名)、符号未导出。
  2. 「printk 一定要加 \n」:可以不加,但日志会黏在下一行。更严重的是 printk 在原子上下文可用,printf 不行——内核里没有 printf。
  3. 「__init 只是注释」:它把函数放进 .init.text 段,启动完成后这段内存被释放。对模块而言 __init 只在加载阶段有效,别把函数指针存下来以后再用。
  4. 「模块卸载后内存一定干净」:rmmod 只调用 module_exit,你注册的 timer、workqueue、debugfs 文件都得自己撤。忘了撤就是 use-after-free。
  5. 「在宿主上试一下没关系」:宿主内核版本、配置、符号都可能不同,最坏情况是 panic 掉你正在工作的机器。所有实验都在 QEMU 里做。

3.1.7 断点与第一手证据:gdb + ftrace

有了 vmlinux(带符号)和 QEMU 的 gdb stub,就能在内核启动的第一行停下来。启动时加 -s -S:-s 等价 -gdb tcp::1234,-S 让 CPU 在第一条指令前冻结。

# 终端 1:启动并冻结
qemu-system-x86_64 -m 512M -smp 2 -kernel arch/x86/boot/bzImage \
  -initrd initramfs.cpio.gz -append "console=ttyS0 rdinit=/init nokaslr" \
  -nographic -no-reboot -s -S

# 终端 2:连上去
gdb vmlinux
(gdb) target remote :1234
(gdb) hbreak start_kernel          # 硬件断点,不依赖改写内存
(gdb) continue
(gdb) bt
(gdb) p init_task.pid
(gdb) info registers cr3

hbreak 比 break 稳:内核早期还没建立可写映射时,软件断点(int3 替换指令)可能写不进去。nokaslr 必须加,否则 start_kernel 的实际地址与 vmlinux 里的符号地址对不上。

6.8 自带一套 gdb 脚本(CONFIG_GDB_SCRIPTS=y),加载后可以按内核视角看数据结构:

(gdb) source scripts/gdb/vmlinux-gdb.py
(gdb) lx-dmesg                 # 打印内核日志缓冲区
(gdb) lx-ps                    # 遍历 task_struct 链表,等价于内核态 ps
(gdb) lx-lsmod                 # 已加载模块
(gdb) lx-symbols               # 加载模块符号(在虚机 insmod 之后执行)

ftrace 是另一条证据链:不用暂停机器,直接看「谁调用了谁、花了多久」。它在本章只做一次亮相,3.15 展开。

# 虚机内(需 CONFIG_FUNCTION_TRACER / FUNCTION_GRAPH_TRACER)
cd /sys/kernel/debug/tracing
echo function_graph > current_tracer
echo 'do_sys_openat2' > set_graph_function
echo 1 > tracing_on
cat /etc/hostname > /dev/null
echo 0 > tracing_on
cat trace | head -30

你会看到 do_sys_openat2 下面挂着 getname_flags、do_filp_open、path_openat 的调用树和每段耗时(单位 µs)。这类输出的价值在于:它给出的是运行中内核的真实调用关系,比读代码猜路径可靠。

同一个问题也可以反过来问「谁在频繁打开文件」。bpftrace 一行就能按进程聚合(需要 CONFIG_BPF_SYSCALL、CONFIG_DEBUG_INFO_BTF,并在虚机里装上 bpftrace 静态版):

bpftrace -e 'kprobe:vfs_open { @opens[comm] = count(); }'
# Ctrl-C 后打印,例如:
# @opens[sh]: 3
# @opens[busybox]: 11

kprobe:vfs_open 是动态插桩:内核在 vfs_open() 的第一条指令处放一个断点指令,命中时执行 BPF 程序并返回。它不需要重编内核,也不需要模块,是 6.x 上定位「谁在做什么」的默认工具。第 3.15 章会把它和 tracepoint、perf 放在一起比较。

3.1.8 版本、git 与「读哪一版」

内核每 9–10 周一个版本,每个版本约 1.2–1.5 万个提交。逐版通读不可能,也不需要。可行的策略是:钉住一个版本读结构,用 git 看演化。

git clone --depth 1 --branch v6.8 https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git
cd linux
git log --oneline -10 -- mm/memory.c        # 这个文件最近的改动
git log -S'kernel_clone' --oneline -- kernel/fork.c | head   # 某符号何时出现
git blame -L '/^struct task_struct/,+5' include/linux/sched.h

git log -S(pickaxe)回答「这行代码是什么时候进来的」,是读内核最有用的单条命令。想知道一个函数为什么长这样,先看引入它的提交说明——维护者通常写清了取舍。

4.x/5.x/6.x 之间,下面这些差异会直接影响你读代码:

变化旧写法(4.x/5.x 早期)6.8 写法影响
创建进程do_fork()kernel_clone()(5.10 起)搜 do_fork 只能找到历史
调度器CFS,vruntime + 红黑树EEVDF(6.6 起),增加 deadline/slice3.5 的算法完全不同
VMA 索引红黑树 mm->mmap + 链表maple tree mm->mm_mt(6.1 起)find_vma() 内部换了容器
页描述struct page 为主struct folio 逐步铺开(5.16 起)大量函数签名改成 folio
内存锁mmap_semmmap_lock(5.8 重命名)名字变了,语义没变
最大分配阶MAX_ORDER=11MAX_ORDER=10(6.4 起)伙伴系统最大块 4 MiB
模块签名可选CONFIG_MODULE_SIG 成发行版默认未签名模块可能被拒
定义 3.1.3 · 内核 ABI 的稳定面

内核不保证内部函数、结构体布局、宏的稳定,跨版本编译模块必须重新编译。它保证的是用户态 ABI:系统调用号与语义、ioctl 编号、/proc 与 /sys 的既有字段、ELF 加载规则。这条界线是 Linux 能做发行版生态的基础,也是「驱动必须进上游」的根本原因。

读源码的顺序建议

第一次读某个子系统,不要从 init 开始逐行。先读 Documentation/ 里对应的 ReST,再读头文件里的结构体与注释,最后才进 .c。头文件是作者写给未来的自己的摘要,信息密度最高。

3.1.10 构建产物解剖:vmlinux 的段、符号与 BTF

这一节回答:vmlinux 里到底有哪些段、panic 地址怎么换算成源码行、bpftrace 依赖的 BTF 从哪来、为什么 pahole 版本会影响构建。

3.1.4 列了四类产物。真正要会用的是 vmlinux:它不是「一个内核可执行文件」这么简单,而是一个带几十个自定义段的 ELF,每个段都有内核自己的用途。

关键段

段内容谁生成 / 谁用
.text常驻代码链接器;/sys/kernel/debug/tracing 的 kprobe 在此插桩
.rodata只读数据、字符串、const 表启动末尾 mark_rodata_ro() 改成只读
.data可写全局变量—
.bss零初始化数据不占文件空间,启动时清零
.init.text / .init.data只在启动期用的代码/数据free_initmem() 整段释放
.exit.text模块卸载代码(内置代码里被丢弃)__exit
__ksymtab / __ksymtab_gpl导出符号表(EXPORT_SYMBOL)modpost 检查、模块加载时解析
__ksymtab_strings导出符号名的字符串池同上
__parammodule_param 描述符模块加载时建 sysfs 参数
.modinfoMODULE_* 宏写入的键值对modinfo、内核校验 license/vermagic
.BTFBPF 类型格式(类型信息的紧凑编码)bpftrace、bpftool、CO-RE
.BTF_idsBTF ID 集合(kfunc 等)BPF 子系统
__mcount_loc-pg 插桩点表ftrace 初始化时替换成 nop
.orc_unwind / .orc_unwind_ipORC 展开表内核栈回溯(dump_stack())
.altinstr_replacement运行时指令替换(CPU 特性)apply_alternatives()
.data..percpuper-CPU 变量模板setup_per_cpu_areas() 按 CPU 复制

用标准 binutils 验证:

readelf -S vmlinux | grep -E '\.(text|rodata|data|bss|BTF)|ksymtab|modinfo'
readelf -h vmlinux | grep -E 'Class|Machine|Entry'
# 符号按地址排序,panic 里的 RIP 可以直接对照
nm -n vmlinux | grep -E ' (start_kernel|do_syscall_64|schedule)$'
# 反汇编一个函数
objdump -d --no-show-raw-insn vmlinux | sed -n '/<do_syscall_64>:/,+12p'

System.map 就是 nm -n 的文本版;启动时内核还会把符号表压进 __kallsyms,所以运行中的 /proc/kallsyms 不依赖 System.map 文件。两者可能不一致——/proc/kallsyms 是当前运行内核的地址(KASLR 后是运行时地址),System.map 是链接地址。

panic 地址怎么变成源码行

一次 Oops 的关键三行:

RIP: 0010:ext4_file_write_iter+0x1a3/0x4f0
Call Trace:
 <TASK>
 vfs_write+0x2d1/0x3e0

换算步骤:

  1. RIP 的 ext4_file_write_iter+0x1a3 已经给出「函数 + 偏移」,不需要自己减;
  2. 在 vmlinux 上 objdump -d --start-address=$(nm vmlinux | awk '/ ext4_file_write_iter$/{print "0x"$1}') 找到该函数,再加 0x1a3;
  3. 用 addr2line -e vmlinux -f -i <地址> 得到 文件:行号;
  4. 若开了 KASLR,RIP 是运行时地址,addr2line 前要减去 kaslr_offset(从 /proc/kallsyms 的 _text 减去 System.map 的 _text),或者直接看 +0x1a3 偏移。

CONFIG_DEBUG_INFO=y 才有 DWARF;CONFIG_DEBUG_INFO_DWARF5=y 生成 DWARF5,gdb 12+、addr2line(binutils 2.39+)才完整支持。发行版内核常把调试信息单独打成 vmlinux-debuginfo 包,/usr/lib/debug/boot/vmlinux-$(uname -r)。

BTF:不靠 DWARF 的类型信息

bpftrace 的 args->field、struct task_struct 的字段偏移、bpftool btf dump 都依赖 BTF。它由 pahole -J 在链接后从 DWARF 提取,塞进 .BTF 段:

# 确认内核带 BTF
readelf -S vmlinux | grep BTF
ls -l /sys/kernel/btf/vmlinux

# 导出某个结构体的 BTF 定义
bpftool btf dump file /sys/kernel/btf/vmlinux format c | \
  awk '/struct task_struct \{/,/^\};/' | head -40

# 看模块的 BTF(6.8 支持模块 BTF)
bpftool btf dump file /sys/kernel/btf/ext4 format raw | head

构建侧的两个坑:

  • CONFIG_DEBUG_INFO_BTF=y 需要 pahole 足够新(DWARF5 要 1.24+),否则报 BTF: .tmp_vmlinux.btf: pahole (pahole) is not available;
  • CONFIG_DEBUG_INFO_BTF_MODULES=y 会为每个 .ko 生成 BTF,构建时间与体积都增加;只做观测时可以不开了。

vmlinux.o 与 vmlinux 不是一回事

文件是什么用途
vmlinux.o所有内置目标文件的可重定位链接(ld -r)modpost 检查符号、生成 BTF、最终链接的输入
vmlinux最终 ELF,地址已定gdb、crash、objdump
vmlinux.a所有内置对象的静态库(6.x 新布局)归档,减小中间文件
arch/x86/boot/bzImage压缩 + 引导头QEMU/GRUB

vmlinux.o 保留重定位信息,所以 modpost 能在链接前发现「模块引用了未导出符号」「__init 函数被 __exit 调用」这类问题。最终 vmlinux 链接后重定位已解析,反汇编里的地址就是链接地址。

一次构建的中间产物排查

构建失败时按顺序看:

ls -l vmlinux.o vmlinux System.map            # 卡在哪一步
tail -50 .vmlinux.cmd 2>/dev/null             # 最终链接命令
grep -n 'undefined' .tmp_vmlinux1.log 2>/dev/null
make V=1 drivers/char/ | head -40             # 看某个目录的实际编译命令

V=1 打印完整命令行,V=2 还会打印 Kbuild 的变量展开;make -j1 能把并行输出整理成可读顺序。定位「某个文件用了错的头文件」时,make V=1 加上 grep 比读 Makefile 快。

3.1.11 Kbuild 编译链路:从 .config 到 .ko

这一节回答:make 之后 Kbuild 到底跑了哪些步骤、modpost 在检查什么、.ko 里的 vermagic 与 __this_module 是谁写进去的。

五个阶段

make
 ├─ 1. syncconfig
 │    .config → include/config/auto.conf
 │             include/generated/autoconf.h
 │             include/config/*.h(每个 CONFIG 一个空头文件,供 Makefile 判断)
 ├─ 2. 递归编译各目录
 │    obj-y  → built-in.a(ar 归档)
 │    obj-m  → foo.ko(ld -r + modpost 生成的 foo.mod.o)
 ├─ 3. 链接 vmlinux.o
 │    ld -r -o vmlinux.o --whole-archive built-in.a ...
 ├─ 4. modpost
 │    读 vmlinux.o 与 Module.symvers
 │    检查模块引用的符号是否导出、CRC 是否匹配、段是否错配
 │    为每个模块生成 foo.mod.c → foo.mod.o
 ├─ 5. 最终链接
 │    vmlinux、arch/x86/boot/bzImage
 │    每个模块 ld -r foo.o foo.mod.o → foo.ko

Module.symvers 是阶段 4 的账本:每行是「CRC、符号名、所属模块、导出类型(EXPORT_SYMBOL / GPL)」。模块 B 引用模块 A 的符号时,modpost 从 Module.symvers 查到 CRC,写进 B 的 ____versions 段;加载时内核比对,不匹配就拒绝(CONFIG_MODVERSIONS)。

# 看某个符号的导出记录
grep -w demo_add Module.symvers
# 输出形如:0x1234abcd\tdemo_add\tsym_a\tEXPORT_SYMBOL

.mod.c 里有什么

modpost 为每个模块生成的 foo.mod.c 包含四类东西:

/* 由 modpost 生成,不要手改;6.8 风格,已简化 */
MODULE_INFO(vermagic, VERMAGIC_STRING);
MODULE_INFO(name, KBUILD_MODNAME);
__visible struct module __this_module
__section(".gnu.linkonce.this_module") = {
	.name = KBUILD_MODNAME,
	.init = init_module,
#ifdef CONFIG_MODULE_UNLOAD
	.exit = cleanup_module,
#endif
	.arch = MODULE_ARCH_INIT,
};
static const struct modversion_info ____versions[]
__used __section("__versions") = {
	{ 0x1234abcd, "demo_add" },
};
  • __this_module:内核 load_module() 通过 .gnu.linkonce.this_module 段找到它,填入 state、core_layout、list 等字段;
  • vermagic:版本、SMP、抢占、MODULE_UNLOAD、架构、RANDSTRUCT 的拼接串;
  • ____versions:CONFIG_MODVERSIONS 下每个外部符号的 CRC;
  • __param_*:module_param 的参数描述符,加载时由 parse_args() 解析。

vermagic 的组成(include/linux/vermagic.h):

6.8.0 SMP preempt mod_unload modversions x86_64
│       │   │       │          │           └─ 架构
│       │   │       │          └─ CONFIG_MODVERSIONS
│       │   │       └─ CONFIG_MODULE_UNLOAD
│       │   └─ 抢占模型(PREEMPT_NONE/VOLUNTARY/FULL/RT)
│       └─ CONFIG_SMP
└─ UTS_RELEASE(含 localversion)

任何一项不同,check_modinfo() 就返回 -ENOEXEC,dmesg 打印 module: version magic '...' should be '...'。modprobe --force-vermagic(需要 CONFIG_MODULE_FORCE_LOAD)能绕过,但会让内核 taint。

段错配检查

modpost 最有用的一类警告是 Section mismatch:

WARNING: vmlinux.o(.text+0x1234): Section mismatch in reference from the function
foo_probe() to the function .init.text:bar_init()
The function foo_probe() references the function __init bar_init().
This is often because foo_probe lacks a __init annotation or the annotation of bar_init is wrong.

含义:常驻代码引用了启动后会被释放的 .init.text。运行到那行就是跳到已释放内存。修法只有两种:去掉被引用函数的 __init,或让引用者也在 .init.text(且保证启动期调用)。CONFIG_DEBUG_SECTION_MISMATCH=y 会把这类警告升级为错误。

模块的链接与加载视图

一个 .ko 是可重定位 ELF(ET_REL),不是可执行文件:

readelf -h hellomod.ko | grep Type          # Type: REL
readelf -S hellomod.ko | grep -E 'text|data|modinfo|this_module|versions'
modinfo hellomod.ko

加载时内核做的第一件「重活」是 layout_and_allocate():按段属性把 .text、.rodata、.data 分别放到模块区域的三个子区间,.text 可执行、.rodata 只读、.data 可写。之后 apply_relocations() 逐条处理 R_X86_64_PC32、R_X86_64_PLT32、R_X86_64_32S 等重定位,把「符号 + 加数」写成实际地址。

重定位类型用途出错时的典型报错
R_X86_64_6464 位绝对地址relocation truncated to fit
R_X86_64_PC32相对寻址(call/jmp/数据引用)relocation out of range
R_X86_64_PLT32经 PLT 的调用(编译器默认)unknown relocation: 4
R_X86_64_32S符号扩展的 32 位绝对地址模块地址超出 ±2 GiB

6.8 的模块区域在 MODULES_VADDR(x86-64 为 0xffffffffa0000000)附近,离内核 .text 只有几百 MiB,所以 PC32 够用;R_X86_64_32S 只在老式 -mcmodel=kernel 代码里出现。

构建与产物的六个坑
  1. 「make 成功就说明配置对了」:make 只证明能编译;olddefconfig 可能把你依赖的选项关掉,启动时才 panic。
  2. 「vmlinux 和 bzImage 是同一份代码」:bzImage 是 vmlinux 压缩后再套引导头,地址与段都变了;gdb 必须用 vmlinux。
  3. 「System.map 可以删」:运行中的 /proc/kallsyms 够用,但 crash/gdb 离线分析需要 System.map 或带符号的 vmlinux。
  4. 「BTF 等于调试信息」:BTF 只有类型与函数原型,没有行号、没有变量值;addr2line 仍要 DWARF。
  5. 「modpost 警告可以忽略」:Section mismatch 与 undefined symbol 是加载期/运行期故障的前兆;KBUILD_MODPOST_WARN=1 只是把错误降级,不是修复。
  6. 「模块可以在任意内核上加载」:vermagic、MODVERSIONS CRC、签名三道关卡,任何一道不过都拒绝;跨版本必须重编。

3.1.12 模块加载:ELF、重定位与 struct module

这一节回答:insmod 到内核里发生了什么、struct module 记了哪些账、为什么两个模块会互相「占用」、加载失败时内核怎么把半成品收回去。

两个系统调用

用户态装模块只有两个入口:

/* 把内存里的 ELF 交给内核 */
int init_module(void *module_image, unsigned long len, const char *param_values);
/* 从 fd 读 ELF(modprobe 用的就是它) */
int finit_module(int fd, const char *param_values, int flags);

finit_module 的 flags 只有两位:MODULE_INIT_IGNORE_MODVERSIONS=1、MODULE_INIT_IGNORE_VERMAGIC=2。两者都需要 CONFIG_MODULE_FORCE_LOAD,且会让内核 taint。param_values 是 "repeat=3 who=qemu" 这样的字符串,由模块的 __param 描述符解析;解析失败返回 -EINVAL,且模块不会加载。

modprobe 不是系统调用,它是用户态程序:读 /lib/modules/$(uname -r)/modules.dep.bin 解决依赖,按 modules.alias.bin 匹配别名,最后调 finit_module。insmod 只加载指定文件、不解决依赖,所以「insmod 报 Unknown symbol」时换 modprobe 往往就好了——前提是依赖模块已安装。

加载流水线

init_module / finit_module
  → load_module()
      → copy_module_from_user() / copy_module_from_fd()
      → elf_validity_check()          校验 ELF 头、段表、.gnu.linkonce.this_module
      → setup_load_info()             找到 __this_module、__ksymtab、__param
      → layout_and_allocate()         分配模块区域,按段属性分 text/rodata/data
      → rewrite_section_headers()     把 sh_addr 改成模块内实际地址
      → check_modinfo()               vermagic、license、intree、签名
      → simplify_symbols()            解析每个未定义符号(resolve_symbol)
      → apply_relocations()           处理 R_X86_64_* 重定位
      → post_relocation()             排序 extable、解析 modinfo、建立 kallsyms
      → complete_formation()          state=LIVE、挂全局链表、建 sysfs
      → do_init_module()              调 module->init(),释放 init 段

任何一步失败都会走 free_module():撤销 sysfs、从链表摘除、释放模块区域。失败路径比成功路径更值得读,因为这里最容易出 use-after-free——比如 complete_formation() 已经把模块挂进全局链表,do_init_module() 里 init() 返回错误,必须确保其他 CPU 不会在链表中看到半初始化的模块。

struct module:一个模块的全部账本

字段类型(6.8)含义
stateenum module_stateMODULE_STATE_LIVE/COMING/GOING/UNLOADING
liststruct list_head全局 modules 链表节点
namechar[MODULE_NAME_LEN]模块名(最长 56 字节)
mkobjstruct module_kobjectsysfs 的 /sys/module/<name>
syms / num_symskernel_symbol * / unsigned int本模块导出的普通符号
gpl_syms / num_gpl_syms同上EXPORT_SYMBOL_GPL 导出的符号
crcs / gpl_crcsconst s32 *每个导出符号的 CRC(MODVERSIONS)
kp / num_kpkernel_param *module_param 描述符数组
core_layoutstruct module_layout常驻区:base/size/text_size/ro_size
init_layoutstruct module_layout__init 区,init 成功后释放
init / exit函数指针module_init/module_exit 展开后的入口
refcntatomic_t引用计数;rmmod 时非 0 就 EBUSY
source_list / target_liststruct list_head模块间依赖关系(谁用我、我用谁)
taintsunsigned long本模块给内核加的 taint 位
kallsymsconst unsigned long *模块内符号地址表(/proc/kallsyms 用)
sig_okbool签名校验是否通过

module_layout 把模块地址空间切成三段:

/* include/linux/module.h —— 6.8 节选 */
struct module_layout {
	void *base;                 /* 起始地址 */
	unsigned int size;          /* 总大小 */
	unsigned int text_size;     /* 可执行段大小 */
	unsigned int ro_size;       /* 只读段大小 */
	unsigned int ro_after_init_size;
};

layout_and_allocate() 先按 text_size、ro_size、size 做三次对齐分配,再把各段 memcpy 进去;module_enable_ro() 在 init 之后把 rodata 段改成只读,module_enable_nx() 把 data 段标成不可执行。这就是「模块代码也能 W^X」的实现。

符号解析与依赖

simplify_symbols() 对每个未定义符号调用 resolve_symbol():

/* kernel/module/main.c: resolve_symbol() —— 6.8 节选,已简化 */
static const struct kernel_symbol *resolve_symbol(struct module *mod,
						  const struct load_info *info,
						  const char *name,
						  char ownername[])
{
	struct module *owner;
	const struct kernel_symbol *sym;
	int err;

	sym = find_symbol(name, &owner, &err);
	if (!sym)
		return ERR_PTR(err);
	if (!use_symbol(mod, owner, sym))       /* GPL-only 检查 */
		return ERR_PTR(-ENOENT);
	if (!try_module_get(owner))             /* 锁住提供者,防止被卸载 */
		return ERR_PTR(-ENOENT);
	...
	return sym;
}

关键在 try_module_get(owner):模块 B 引用了模块 A 的符号,加载 B 时内核给 A 的 refcnt 加一,并记录到 B 的 target_list、A 的 source_list。所以:

insmod sym_a.ko          # A 的 refcnt = 0
insmod sym_b.ko          # B 用 A 的符号,A 的 refcnt = 1
rmmod sym_a              # 失败:Module sym_a is in use
rmmod sym_b              # 先卸 B,A 的 refcnt 回到 0
rmmod sym_a              # 成功

/proc/modules 的第三列就是 refcnt,第四列是依赖模块列表(Used by)。lsmod 只是把这两列格式化。

失败路径与 errno

阶段错误errno用户看到
权限没有 CAP_SYS_MODULEEPERMOperation not permitted
ELF 校验不是 ET_REL、段表越界ENOEXECInvalid module format
vermagic版本/SMP/抢占不符ENOEXECversion magic ... should be ...
签名强制签名但无有效签名ENOKEY/EKEYREJECTEDLoading of unsigned module is rejected
符号引用了未导出符号ENOENTUnknown symbol foo (err -2)
重定位地址超出范围ENOEXECrelocation out of range
已加载同名模块存在EEXISTFile exists
内存模块区域不足ENOMEMCannot allocate memory
init 返回模块自己的 init() 失败模块返回值init_module: ... failed

最后一行值得强调:module_init() 的函数返回非 0,load_module() 会把整个模块卸载掉,用户态看到的就是那个 errno。init 失败后不要假设资源已释放——内核只释放模块内存,你申请的内存、注册的设备、创建的线程都要在错误分支里自己撤。

delete_module() 的 errno 则是另一组:EBUSY(refcnt 非 0)、EWOULDBLOCK(O_NONBLOCK 且正在使用)、ENOENT(模块不存在)、EPERM(无权限或 modules_disabled=1)。

`modules_disabled`:一次性的保险

/proc/sys/kernel/modules_disabled 默认 0。写 1 之后,内核永久拒绝加载与卸载模块(不可逆,只能重启)。它的用途是「启动完成后锁死内核」:容器宿主、加固服务器在 init 末尾置 1,攻击者即使拿到 root 也不能插模块。代价是 rmmod 也不可用,热修复只能走 livepatch。

3.1.13 bzImage 与 boot protocol:引导器交给内核什么

这一节回答:bzImage 的前 512 字节为什么是引导扇区、boot_params 里的字段谁填、KASLR 在解压前还是解压后选地址、EFI stub 与 startup_64 是什么关系。

bzImage 的三层结构

bzImage
├── setup 部分(前 32 KiB 左右)
│   ├── boot sector(512 B):0x1FE 处 0xAA55,0x202 处 "HdrS"
│   └── setup code:16 位实模式代码,收集 e820、设置视频、准备保护模式
└── compressed kernel
    ├── vmlinux.bin.gz(或 zstd/xz)
    ├── head_32.S / head_64.S(解压器的入口)
    └── misc.c(extract_kernel)

引导器(GRUB、UEFI stub、QEMU -kernel)只做三件事:把 setup 部分放到 0x90000、把 boot_params 放到 0x10000、跳到 setup 的入口。剩下的由内核自己完成。

验证头部:

# 引导标志 0xAA55(小端:55 aa)
xxd -s 0x1fe -l 2 arch/x86/boot/bzImage
# "HdrS"(小端:48 64 72 53)
xxd -s 0x202 -l 4 arch/x86/boot/bzImage
# 版本号:0x0206 表示 boot protocol 2.06+
xxd -s 0x206 -l 2 arch/x86/boot/bzImage

boot_params.hdr:引导器与内核的契约

struct boot_params 在 0x10000,其中 hdr(struct setup_header)从偏移 0x1f1 开始。常用字段(偏移相对 boot_params 起始):

偏移字段谁填含义
0x1f1setup_sects构建setup 部分占多少个 512 字节扇区
0x1feboot_flag构建0xAA55,可引导标志
0x202header构建"HdrS"(0x53726448)
0x206version构建boot protocol 版本,如 0x020f
0x210type_of_loader引导器0=未知,0xE=GRUB,0xFF=QEMU
0x211loadflags双方bit0 LOADED_HIGH、bit1 KASLR_FLAG、bit5 QUIET
0x214code32_start构建保护模式入口(通常 0x100000)
0x218ramdisk_image引导器initrd 加载地址
0x21cramdisk_size引导器initrd 字节数
0x228cmd_line_ptr引导器命令行字符串地址
0x230kernel_alignment构建内核需要的对齐(通常 2 MiB)
0x234relocatable_kernel构建是否支持重定位(KASLR 前提)
0x236xloadflags构建64 位入口、EFI handover 能力
0x248payload_offset构建压缩内核在 bzImage 内的偏移
0x24cpayload_length构建压缩内核长度
0x260init_size构建解压器 + 内核需要的连续内存
0x264handover_offset构建EFI stub 的 handover 入口偏移

xloadflags 的位决定引导器能不能直接进 64 位:

位名称含义
0XLF_KERNEL_64内核是 64 位
1XLF_CAN_BE_LOADED_ABOVE_4G可加载到 4 GiB 以上
2/3XLF_EFI_HANDOVER_32/64支持 EFI handover 协议
4XLF_EFI_KEXEC支持 EFI kexec

从 setup 到 start_kernel

bootloader
  → 实模式 setup(arch/x86/boot/header.S / main.c)
      → 收集 e820、键盘、视频、EDID
      → 若 loadflags & KASLR_FLAG:在 boot_params 里留随机种子
      → 进入保护模式,跳 code32_start
  → arch/x86/boot/compressed/head_32.S 或 head_64.S
      → extract_kernel()(misc.c)
          → choose_random_location()      选解压目标地址(KASLR)
          → 解压 vmlinux.bin
          → 跳转到解压后的 startup_64
  → arch/x86/kernel/head_64.S: startup_64
      → __startup_64()(head64.c)建立早期页表、修正地址
      → x86_64_start_kernel() → x86_64_start_reservations()
      → start_kernel()

KASLR 发生在 extract_kernel() 里,解压之前:choose_random_location() 在可用物理内存里随机选一个满足 kernel_alignment 与 init_size 的地址,解压到那里,再用 __startup_64() 修正所有绝对地址的偏移。所以 nokaslr 不是「关掉页表随机化」,而是「让 choose_random_location() 返回首选地址」。

EFI stub:一条不同的入口

开了 CONFIG_EFI_STUB=y 后,bzImage 里多了一个 PE/COFF 头,UEFI 固件可以直接把内核当 EFI 应用加载:

UEFI firmware
  → efi_main()(drivers/firmware/efi/libstub/x86-stub.c)
      → 读 boot_params、设置 efi 系统表
      → 若支持 handover:按 handover_offset 跳入内核
      → 否则:调用 ExitBootServices,再跳 startup_32/startup_64

它解决了「没有 GRUB 的机器怎么启动」:UEFI 直接 \EFI\BOOT\BOOTX64.EFI(内核改名)即可。代价是 bzImage 不再是纯 Linux 格式,file 会显示 PE32+ executable (EFI application)。

引导方式入口谁准备 boot_params典型场景
传统 BIOS + GRUBsetup 实模式GRUB老机器、QEMU 默认
EFI stub + handoverefi_main → handover_offsetstub 自己现代 UEFI、systemd-boot
EFI stub 无 handoverefi_main → startup_32stub老固件
QEMU -kernelsetup/startup_64QEMU 的 load_linux()本书实验

QEMU -kernel 走的是「QEMU 内置的 Linux 引导器」:它解析 bzImage 头、把 boot_params 填好、直接跳 code32_start 或 64 位入口。它不经过 GRUB,所以 grub.cfg 里的参数要全部写进 -append。

引导与 boot protocol 的五个坑
  1. 「bzImage 是压缩的 vmlinux」:只对了一半;它前面还有 setup 与引导头,payload_offset 才指向压缩内核。
  2. 「boot_params 是内核自己填的」:大部分字段由引导器填;内核只读,并在 setup_arch() 里解析。
  3. 「KASLR 只影响内核代码」:它还随机化直接映射基址、vmalloc 区、vmemmap;/proc/kallsyms 的 _text 会变,模块区域也会跟着变。
  4. 「EFI stub 会替代 start_kernel」:stub 只负责交接,最后仍跳 startup_64 → start_kernel。
  5. 「-kernel 与 -bios 可以随便组合」:-kernel 走 QEMU 内置引导,-bios 走固件;同时用时以 -kernel 为准,容易误判「为什么没进 GRUB」。

3.1.14 观测:模块加载、initcall 与构建产物的证据链

这一节回答:怎么在不改内核的前提下量出「模块加载花了多久」「哪个 initcall 最慢」「加载失败卡在哪一步」。

模块加载的 tracepoint

内核在 include/trace/events/module.h 里定义了五个模块事件:module_load、module_free、module_get、module_put、module_request。它们比 kprobe 稳定,参数是结构化的:

# 列出所有模块事件
bpftrace -l 'tracepoint:module:*'

# 谁在加载模块、是否带 taint
sudo bpftrace -e '
tracepoint:module:module_load {
	printf("%-16s taints=0x%x comm=%s\n", str(args->name), args->taints, comm);
}'

预期输出:

hellomod         taints=0x0 comm=insmod
sym_b            taints=0x0 comm=insmod

taints 非 0 就要查:0x1 专有模块、0x1000 树外模块(OOT)、0x2000 未签名模块。cat /proc/sys/kernel/tainted 是全局累积值。

加载耗时与失败点

# 模块 init 的耗时直方图(微秒)
sudo bpftrace -e '
kprobe:do_init_module { @s[tid] = nsecs; }
kretprobe:do_init_module /@s[tid]/ {
	@us = hist((nsecs - @s[tid]) / 1000);
	delete(@s[tid]);
}'

如果 do_init_module 在内核里被内联(bpftrace -l 'kprobe:do_init_module' 查不到),改用 kprobe:load_module 或 tracepoint:module:module_load 到 module_free 的时间差。加载失败时 do_init_module 不会执行,要看 load_module 的返回值。

# 加载失败时,谁返回了错误
sudo bpftrace -e '
kretprobe:load_module {
	if (retval < 0)
		printf("load_module failed: %d comm=%s\n", retval, comm);
}'

initcall:启动期最慢的那一段

initcall 在 3.2 展开;这里只给观测方法。6.8 的 initcall_debug 启动参数会为每个 initcall 打印耗时:

# QEMU 启动参数追加 initcall_debug
-append "console=ttyS0 rdinit=/init nokaslr initcall_debug"

输出形如:

calling  e1000_init_module+0x0/0x70 @ 1
initcall e1000_init_module+0x0/0x70 returned 0 after 12453 usecs

after ... usecs 就是该 initcall 的耗时。启动慢时按这个数排序,比盲猜快。运行期还可以用 ftrace 看 do_one_initcall 的调用树:

cd /sys/kernel/debug/tracing
echo function_graph > current_tracer
echo do_one_initcall > set_graph_function
echo 1 > tracing_on
# 触发一次模块加载(initcall 也会跑)
insmod /hellomod.ko
echo 0 > tracing_on
cat trace | head -40

构建产物的证据

# 确认 vermagic 与当前内核一致
modinfo -F vermagic hellomod.ko
uname -r

# 确认模块确实引用了目标符号
nm -u hellomod.ko | head
readelf -r hellomod.ko | grep demo_add

# 确认 BTF 可用(bpftrace 的 args-> 依赖它)
ls -l /sys/kernel/btf/vmlinux
bpftool btf dump file /sys/kernel/btf/vmlinux format c | grep -A3 'struct module {' | head

这四条命令构成一条完整证据链:vermagic 解释「能不能加载」,nm -u 解释「缺什么符号」,readelf -r 解释「重定位到哪」,BTF 解释「观测工具为什么能读字段」。

一次加载失败的五步定位
  1. dmesg | tail -30:内核一定打印了原因(vermagic、Unknown symbol、signature)。
  2. modinfo -F vermagic X.ko 与 uname -r 对比。
  3. nm -u X.ko 列出未定义符号,逐个 grep /proc/kallsyms。
  4. readelf -h X.ko | grep Type 确认是 REL。
  5. journalctl -k 或串口日志确认没有更早的 Oops——有时模块加载失败只是前一个 bug 的后果。

3.1.15 实验:符号依赖与从内存加载模块

这一节给两个可运行实验:一个演示模块间依赖与 rmmod EBUSY,一个用 finit_module 绕过文件路径直接从 fd 加载。均在 QEMU(Linux 6.8,x86-64)中运行,未在真机实测。

实验一:两个模块的符号依赖

sym_a.c 导出 demo_add:

// sym_a.c — 导出符号(Linux 6.8)
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>

int demo_add(int a, int b)
{
	return a + b;
}
EXPORT_SYMBOL_GPL(demo_add);          /* GPL-only:非 GPL 模块拿不到 */

static int __init sym_a_init(void)
{
	pr_info("sym_a: loaded, demo_add exported\n");
	return 0;
}

static void __exit sym_a_exit(void)
{
	pr_info("sym_a: unloaded\n");
}

module_init(sym_a_init);
module_exit(sym_a_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("export demo_add for dependency demo");

sym_b.c 使用它:

// sym_b.c — 依赖 sym_a 的符号(Linux 6.8)
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>

extern int demo_add(int a, int b);

static int __init sym_b_init(void)
{
	pr_info("sym_b: demo_add(1, 2) = %d\n", demo_add(1, 2));
	return 0;
}

static void __exit sym_b_exit(void)
{
	pr_info("sym_b: unloaded\n");
}

module_init(sym_b_init);
module_exit(sym_b_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("use demo_add from sym_a");

Makefile:

obj-m += sym_a.o sym_b.o
KDIR ?= /lib/modules/$(shell uname -r)/build

all:
	$(MAKE) -C $(KDIR) M=$(PWD) modules
clean:
	$(MAKE) -C $(KDIR) M=$(PWD) clean

构建后把两个 .ko 放进 initramfs,在虚机里按顺序验证:

# 1. 先装 B:应该失败,Unknown symbol
insmod /sym_b.ko
dmesg | tail -3
# sym_b: Unknown symbol demo_add (err -2)

# 2. 先装 A
insmod /sym_a.ko
grep demo_add /proc/kallsyms
# ffffffffc0001000 T demo_add	[sym_a]

# 3. 再装 B:成功
insmod /sym_b.ko
dmesg | tail -2
# sym_b: demo_add(1, 2) = 3

# 4. A 的引用计数变成 1,卸载失败
cat /proc/modules | grep sym_
# sym_b 16384 1 - Live 0xffffffffc0003000
# sym_a 16384 1 sym_b, Live 0xffffffffc0000000
rmmod sym_a
# rmmod: ERROR: Module sym_a is in use

# 5. 先卸 B,再卸 A
rmmod sym_b
rmmod sym_a

关键观察:/proc/modules 第三列 1 是 sym_a 的引用计数,第四列 sym_b, 是「谁在用我」。这个计数不是「加载次数」,而是 try_module_get() 的次数——符号依赖、try_module_get() 调用、文件打开(模块实现的 chrdev)都会增加它。

实验二:finit_module 从 fd 加载

insmod 内部就是 finit_module。下面的程序直接调系统调用,跳过 modprobe 的依赖解析与路径查找:

/* finit_demo.c — 从 fd 加载模块
 * 编译: cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -o finit_demo finit_demo.c
 * 用法: sudo ./finit_demo hellomod.ko "repeat=2 who=fd"
 */
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <sys/syscall.h>
#include <unistd.h>

int main(int argc, char **argv)
{
	const char *path = argc > 1 ? argv[1] : "hellomod.ko";
	const char *args = argc > 2 ? argv[2] : "";
	int fd, err;

	fd = open(path, O_RDONLY | O_CLOEXEC);
	if (fd < 0) {
		fprintf(stderr, "open %s: %s\n", path, strerror(errno));
		return 1;
	}
	err = (int)syscall(SYS_finit_module, fd, args, 0);
	if (err < 0) {
		fprintf(stderr, "finit_module(%s): %s\n", path, strerror(errno));
		close(fd);
		return 1;
	}
	close(fd);
	printf("loaded %s with args \"%s\"\n", path, args);
	return 0;
}

运行与错误路径:

# 成功:dmesg 里应出现两行 "hello, fd"
sudo ./finit_demo /hellomod.ko "repeat=2 who=fd"

# 非 root:EPERM
./finit_demo /hellomod.ko
# finit_module(...): Operation not permitted

# 文件不是模块:ENOEXEC
sudo ./finit_demo /etc/hostname
# finit_module(...): Exec format error

# 参数不合法:EINVAL(模块的 parse_args 拒绝)
sudo ./finit_demo /hellomod.ko "repeat=notanint"

finit_module 与 init_module 的差别只在「ELF 从哪来」:前者从 fd 读(内核 kernel_read_file_from_fd()),后者从用户缓冲区拷贝。两者都要经过同一套 load_module(),所以 vermagic、签名、符号检查完全一样。finit_module 的好处是不用把整个 .ko 读进用户内存,且 fd 可以来自 memfd_create() 或 O_TMPFILE,适合容器运行时「模块已在内存里」的场景。

模块实验的六个坑
  1. 「insmod 报 Unknown symbol,说明符号没导出」:也可能是依赖模块没加载、GPL-only 符号被非 GPL 模块引用、或 CRC 不匹配。先看 dmesg 的完整一行。
  2. 「rmmod 失败就是内核 bug」:先看 /proc/modules 的 refcnt 与 Used by;符号依赖、打开的字符设备、未停的 workqueue 都会占引用。
  3. 「模块名可以随便起」:KBUILD_MODNAME 来自文件名,/sys/module/<name>、modprobe 的依赖表、rmmod 的参数都用它;改名后旧名字的引用会失效。
  4. 「__init 函数可以存到全局变量」:init 成功后 .init.text 被释放,函数指针变野指针。跨生命周期要用常驻段。
  5. 「module_param 的 0644 表示线程安全」:只表示 sysfs 可写;多个写者并发调用 param_set_int 仍需自己加锁。
  6. 「modprobe 会从当前目录找 .ko」:它只查 /lib/modules/$(uname -r)/ 下的索引;实验模块要么 insmod 绝对路径,要么先 depmod 再放进该目录。

3.1.16 陷阱清单:构建、模块与启动篇

#现象原因修复
1make 成功,启动 panic No working init foundolddefconfig 关掉了 initramfs/驱动用 defconfig 起步,逐项加;保留 ext4、virtio、DEVTMPFS
2gdb 断 start_kernel 失败KASLR 打开,符号地址与运行时不一致启动加 nokaslr,或关 CONFIG_RANDOMIZE_BASE
3模块 invalid module formatvermagic 与目标内核不符(用了宿主 headers)KDIR 指向目标源码树,重新 make modules
4加载后立刻 Oops,栈在已释放地址存了 __init 函数指针去掉 __init,或只在启动期调用
5rmmod 后定时器又触发没 del_timer_sync()/cancel_work_sync()exit 里先停所有异步源,再释放内存
6Unknown symbol,但 grep /proc/kallsyms 有符号未 EXPORT_SYMBOL,或 GPL-only 被非 GPL 引用加导出宏;检查 MODULE_LICENSE
7modprobe foo 找不到没 depmod,或 .ko 不在 /lib/modules/$(uname -r)make modules_install && depmod -a
8模块参数在 sysfs 写入无效权限 0444,或 param_set_int 返回值被忽略改 0644;检查 module_param 的 setter
9两个模块同名符号冲突都用了 EXPORT_SYMBOL 且同名加前缀;或改成 static + 显式接口
10内核 taint 0x2000加载了未签名模块签名或关 MODULE_SIG_FORCE(仅实验)
11rmmod 永远 EBUSYmodules_disabled=1 或 refcnt 泄漏检查 try_module_get 配对;重启恢复
12initramfs 里 modprobe 失败initramfs 没有 /lib/modules 与 modules.dep打包时带上依赖与 depmod 产物,或改用内置 y

最后一条常被忽略:initramfs 是一个独立的小根文件系统,modprobe 在里面同样需要 /lib/modules/$(uname -r)/modules.dep。很多「根文件系统能挂、但加密盘打不开」的故障,根因是 initramfs 里少了某个存储驱动或它的依赖。

3.1.17 本章出口检查

检查项达标线没达标去哪
说出内核的三条进入路径系统调用、异常、中断,各举一例3.1.1
在源码树里定位 copy_process()30 秒内给出文件与行号3.1.2
解释 y/m/n 与 IS_ENABLED说清 CONFIG_FOO_MODULE 的差别3.1.3
独立构建出 bzImage 与 vmlinuxmake -j 成功,file 显示 x86-643.1.4
用 BusyBox 启动到 shell看到 Run /init as init process 后有提示符3.1.5
加载一个带参数的模块dmesg 出现自定义日志,/sys/module 有参数3.1.6
在 start_kernel 下断点gdb 能 bt 出调用栈3.1.7
读出 vmlinux 的段与符号readelf -S、nm -n3.1.10
解释 vmlinux.o 与 vmlinuxmodpost 与最终链接的分工3.1.11
画出模块加载流水线ELF 校验 → 重定位 → init3.1.12
说出 boot_params.hdr 三个字段偏移与填写者3.1.13
即时自测 · 3.1

Q1 为什么内核模块的 Makefile 不能直接写 gcc -c hellomod.c?它少了哪几样东西?

Q2 CONFIG_DEBUG_FS=m 时,#ifdef CONFIG_DEBUG_FS 会怎样?正确写法是什么?

Q3 QEMU 启动参数里 nokaslr 去掉后,gdb 断 start_kernel 为什么失败?两种补救方式各是什么?

Q4 insmod 报 invalid module format,按可能性从高到低列出三个原因与验证命令。

Q5 为什么本书坚持在 QEMU 里做模块实验,而不是让读者在宿主机上 insmod?

答案

A1 因为模块不是普通目标文件:它需要内核头文件搜索路径(-I$(KDIR)/include 与 include/generated)、-D__KERNEL__ -DMODULE、正确的编译选项(-mcmodel=kernel -mno-red-zone -fno-pic 等),还要生成 .mod.c(存放 __this_module、参数表、vermagic)并做 modpost 符号检查。这些都由 Kbuild 完成。

A2 #ifdef 只看宏是否定义,m 时定义的是 CONFIG_DEBUG_FS_MODULE,所以整段代码被静默删掉。正确写法是 #if IS_ENABLED(CONFIG_DEBUG_FS),它在 y 和 m 下都为真;要区分两者用 IS_MODULE()。

A3 KASLR 打开时内核被随机加载到某个偏移,vmlinux 里的符号地址是链接时的基址,断点落在错误位置。补救:① 启动参数加 nokaslr;② 配置里关掉 CONFIG_RANDOMIZE_BASE;③ 在 gdb 里按运行时基址 add-symbol-file 重定位(麻烦,不推荐)。

A4 ① vermagic 不匹配(内核版本/SMP/抢占模型不同)——modinfo hellomod.ko | grep vermagic 与 uname -r 对比;② 模块签名强制但模块未签名——看 dmesg 的 Loading of unsigned module is rejected,检查 CONFIG_MODULE_SIG_FORCE;③ 依赖符号未导出——dmesg 会打印 Unknown symbol xxx,用 grep xxx /proc/kallsyms 确认。

A5 宿主是你正在工作的机器:模块里的空指针、错误的锁、越界的写都可能直接 panic 或损坏文件系统。QEMU 里 panic 只影响虚机,-no-reboot 加脚本还能两秒重启一次,实验成本低两个数量级。

母本对应

母本章节/小节原书页(PDF)本书落点程度
奔跑吧 Linux 内核第 1 章 处理器体系结构(全章,含 cache/MESI/屏障思考题)p25–523.1.10、3.1.11背景(构建产物与体系结构解耦;屏障在 3.11)
奔跑吧 Linux 内核第 6 章 6.1 QEMU 调试 Linux 内核p705–7143.1.5、3.1.7、3.1.13改写(原书 ARM vexpress,本书改 x86-64 + 6.8)
奔跑吧 Linux 内核第 6 章 6.2 ftracep715–7303.1.7、3.1.14覆盖(函数图 + initcall 观测)
奔跑吧 Linux 内核第 6 章 6.3 SystemTap、6.4 内存检测、6.5 死锁检测、6.6 调试秘籍p731–7583.15(另章)合并(本书用 bpftrace/perf/KASAN/lockdep 替代)
李治军第 1 章 系统启动(实践项目 1 控制操作系统启动)章级3.1.5背景(引导与最小系统的心智模型)
OSTEP第 2 章 操作系统介绍p3–123.1.1精选(机制与策略之分)
Kerrisk第 2 章 2.1 操作系统的核心—内核章级3.1.1精选(内核职责边界)
鸟哥第 19 章 开机流程与主要服务章级3.1.4背景(发行版视角的安装流程)

下一站:3.2 启动与初始化 把这台虚机从加电到 init 的每一步拆开:start_kernel 之前固件做了什么、setup_arch 建立了什么、rest_init 之后 PID 1 为什么不是 init 进程本身。