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 内核的职责边界:它做什么,不做什么
内核是常驻内存、运行在 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 | 普通用户能 mount | 3.4 |
| 设备管理 | drivers/ | 显卡、网卡无法工作 | 3.14 |
| 网络协议栈 | net/ | 只能自己拼以太网帧 | 第 4 篇 |
反过来,内核不做的事同样重要:它不认识 printf 的格式串(那是 libc),不解析 JSON,不做字符编码转换,不理解「用户」这个概念之外的业务语义。内核只提供机制(mechanism),策略(policy)尽量留给用户态。调度器给你 nice 值和调度类,但不判断「哪个进程更重要」;cgroup 给你配额,但配额定多少是运维的事。
微内核把文件系统、驱动都挪到用户态,靠 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
读这张图有三个常用入口,按问题类型选:
- 从系统调用进入。想知道
write怎么走,从SYSCALL_DEFINE3(write, ...)开始(在fs/read_write.c),跟着vfs_write()走。 - 从数据结构进入。想知道一个进程有哪些状态,直接读
include/linux/sched.h的struct task_struct;想知道一个物理页怎么描述,读include/linux/mm_types.h的struct page。 - 从文档进入。
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_* 变量连接。
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 |
- 改了
.config却没重新make:Kbuild 会检测.config时间戳并自动跑syncconfig,但如果你手改了include/generated/autoconf.h,它不会救你。 localmodconfig裁太狠:它按「当前已加载模块」推断需求,会关掉还没用到的文件系统、USB HID、虚拟化驱动,结果新内核起不来。裁完至少保留ext4、virtio、8139cp或e1000。select造成的隐式依赖:menuconfig里看到某选项自动变y,多半是别人select了它。查rg -n 'select FOO'。CONFIG_FOO=m时用#ifdef CONFIG_FOO:预处理看不到CONFIG_FOO,分支被静默删掉。用IS_ENABLED()或#if IS_MODULE()。
3.1.4 构建:从源码到 bzImage
一次完整构建产出四类东西,先认清它们:
| 产物 | 路径 | 是什么 | 谁用 |
|---|---|---|---|
vmlinux | 源码根目录 | 未压缩的 ELF,带符号与调试信息 | gdb、objdump、crash |
bzImage | arch/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 | 全部物理内存的线性映射 |
0xffffc90000000000 | vmalloc / ioremap | 32 TiB | 非连续映射,见 3.9 |
0xffffea0000000000 | vmemmap | 1 TiB | struct page 数组 |
0xffffffff80000000 | 内核代码映射 | 512 MiB | _text 从这里起,链接基址 0xffffffff81000000 |
这张表(简化自 Documentation/arch/x86/x86_64/mm.rst)解释了三件事:
vmlinux里的符号地址是链接地址,不是物理地址。start_kernel在System.map里通常是0xffffffff81xxxxxx;panic 打印的RIP: 0010:start_kernel+0x...也是这个空间的值,加nokaslr后可以直接对照。__pa()/virt_to_phys()只对直接映射区成立。它们做的是减去page_offset_base;对vmalloc()返回的地址,这个减法毫无意义,必须走vmalloc_to_page()再取page_to_phys()。这是驱动里最常见的一类地址错误。- 内核代码与只读数据在同一个大页映射里。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 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。
- QEMU 直接退出、没有一行
Linux version:-kernel指错文件,或bzImage不是 x86-64。用file arch/x86/boot/bzImage确认。 - 停在
Run /init as init process之后没有 shell:/init没有可执行位、shebang 写的解释器不存在,或rdinit=路径拼错。把rdinit=/bin/sh试一次可快速区分。 - 出现
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(但模块代码要自己处理并发读)。
- 「
insmod失败是权限问题」:先看dmesg。常见原因是 vermagic 不匹配、签名强制(CONFIG_MODULE_SIG_FORCE=y且模块未签名)、符号未导出。 - 「
printk一定要加\n」:可以不加,但日志会黏在下一行。更严重的是printk在原子上下文可用,printf不行——内核里没有printf。 - 「
__init只是注释」:它把函数放进.init.text段,启动完成后这段内存被释放。对模块而言__init只在加载阶段有效,别把函数指针存下来以后再用。 - 「模块卸载后内存一定干净」:
rmmod只调用module_exit,你注册的 timer、workqueue、debugfs 文件都得自己撤。忘了撤就是 use-after-free。 - 「在宿主上试一下没关系」:宿主内核版本、配置、符号都可能不同,最坏情况是 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/slice | 3.5 的算法完全不同 |
| VMA 索引 | 红黑树 mm->mmap + 链表 | maple tree mm->mm_mt(6.1 起) | find_vma() 内部换了容器 |
| 页描述 | struct page 为主 | struct folio 逐步铺开(5.16 起) | 大量函数签名改成 folio |
| 内存锁 | mmap_sem | mmap_lock(5.8 重命名) | 名字变了,语义没变 |
| 最大分配阶 | MAX_ORDER=11 | MAX_ORDER=10(6.4 起) | 伙伴系统最大块 4 MiB |
| 模块签名 | 可选 | CONFIG_MODULE_SIG 成发行版默认 | 未签名模块可能被拒 |
内核不保证内部函数、结构体布局、宏的稳定,跨版本编译模块必须重新编译。它保证的是用户态 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 | 导出符号名的字符串池 | 同上 |
__param | module_param 描述符 | 模块加载时建 sysfs 参数 |
.modinfo | MODULE_* 宏写入的键值对 | modinfo、内核校验 license/vermagic |
.BTF | BPF 类型格式(类型信息的紧凑编码) | bpftrace、bpftool、CO-RE |
.BTF_ids | BTF ID 集合(kfunc 等) | BPF 子系统 |
__mcount_loc | -pg 插桩点表 | ftrace 初始化时替换成 nop |
.orc_unwind / .orc_unwind_ip | ORC 展开表 | 内核栈回溯(dump_stack()) |
.altinstr_replacement | 运行时指令替换(CPU 特性) | apply_alternatives() |
.data..percpu | per-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
换算步骤:
RIP的ext4_file_write_iter+0x1a3已经给出「函数 + 偏移」,不需要自己减;- 在
vmlinux上objdump -d --start-address=$(nm vmlinux | awk '/ ext4_file_write_iter$/{print "0x"$1}')找到该函数,再加0x1a3; - 用
addr2line -e vmlinux -f -i <地址>得到文件:行号; - 若开了 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_64 | 64 位绝对地址 | 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 代码里出现。
- 「
make成功就说明配置对了」:make只证明能编译;olddefconfig可能把你依赖的选项关掉,启动时才 panic。 - 「
vmlinux和bzImage是同一份代码」:bzImage是vmlinux压缩后再套引导头,地址与段都变了;gdb 必须用vmlinux。 - 「
System.map可以删」:运行中的/proc/kallsyms够用,但crash/gdb离线分析需要System.map或带符号的vmlinux。 - 「BTF 等于调试信息」:BTF 只有类型与函数原型,没有行号、没有变量值;
addr2line仍要 DWARF。 - 「
modpost警告可以忽略」:Section mismatch与undefined symbol是加载期/运行期故障的前兆;KBUILD_MODPOST_WARN=1只是把错误降级,不是修复。 - 「模块可以在任意内核上加载」:
vermagic、MODVERSIONSCRC、签名三道关卡,任何一道不过都拒绝;跨版本必须重编。
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) | 含义 |
|---|---|---|
state | enum module_state | MODULE_STATE_LIVE/COMING/GOING/UNLOADING |
list | struct list_head | 全局 modules 链表节点 |
name | char[MODULE_NAME_LEN] | 模块名(最长 56 字节) |
mkobj | struct module_kobject | sysfs 的 /sys/module/<name> |
syms / num_syms | kernel_symbol * / unsigned int | 本模块导出的普通符号 |
gpl_syms / num_gpl_syms | 同上 | EXPORT_SYMBOL_GPL 导出的符号 |
crcs / gpl_crcs | const s32 * | 每个导出符号的 CRC(MODVERSIONS) |
kp / num_kp | kernel_param * | module_param 描述符数组 |
core_layout | struct module_layout | 常驻区:base/size/text_size/ro_size |
init_layout | struct module_layout | __init 区,init 成功后释放 |
init / exit | 函数指针 | module_init/module_exit 展开后的入口 |
refcnt | atomic_t | 引用计数;rmmod 时非 0 就 EBUSY |
source_list / target_list | struct list_head | 模块间依赖关系(谁用我、我用谁) |
taints | unsigned long | 本模块给内核加的 taint 位 |
kallsyms | const unsigned long * | 模块内符号地址表(/proc/kallsyms 用) |
sig_ok | bool | 签名校验是否通过 |
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_MODULE | EPERM | Operation not permitted |
| ELF 校验 | 不是 ET_REL、段表越界 | ENOEXEC | Invalid module format |
| vermagic | 版本/SMP/抢占不符 | ENOEXEC | version magic ... should be ... |
| 签名 | 强制签名但无有效签名 | ENOKEY/EKEYREJECTED | Loading of unsigned module is rejected |
| 符号 | 引用了未导出符号 | ENOENT | Unknown symbol foo (err -2) |
| 重定位 | 地址超出范围 | ENOEXEC | relocation out of range |
| 已加载 | 同名模块存在 | EEXIST | File exists |
| 内存 | 模块区域不足 | ENOMEM | Cannot 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)。
/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 起始):
| 偏移 | 字段 | 谁填 | 含义 |
|---|---|---|---|
0x1f1 | setup_sects | 构建 | setup 部分占多少个 512 字节扇区 |
0x1fe | boot_flag | 构建 | 0xAA55,可引导标志 |
0x202 | header | 构建 | "HdrS"(0x53726448) |
0x206 | version | 构建 | boot protocol 版本,如 0x020f |
0x210 | type_of_loader | 引导器 | 0=未知,0xE=GRUB,0xFF=QEMU |
0x211 | loadflags | 双方 | bit0 LOADED_HIGH、bit1 KASLR_FLAG、bit5 QUIET |
0x214 | code32_start | 构建 | 保护模式入口(通常 0x100000) |
0x218 | ramdisk_image | 引导器 | initrd 加载地址 |
0x21c | ramdisk_size | 引导器 | initrd 字节数 |
0x228 | cmd_line_ptr | 引导器 | 命令行字符串地址 |
0x230 | kernel_alignment | 构建 | 内核需要的对齐(通常 2 MiB) |
0x234 | relocatable_kernel | 构建 | 是否支持重定位(KASLR 前提) |
0x236 | xloadflags | 构建 | 64 位入口、EFI handover 能力 |
0x248 | payload_offset | 构建 | 压缩内核在 bzImage 内的偏移 |
0x24c | payload_length | 构建 | 压缩内核长度 |
0x260 | init_size | 构建 | 解压器 + 内核需要的连续内存 |
0x264 | handover_offset | 构建 | EFI stub 的 handover 入口偏移 |
xloadflags 的位决定引导器能不能直接进 64 位:
| 位 | 名称 | 含义 |
|---|---|---|
| 0 | XLF_KERNEL_64 | 内核是 64 位 |
| 1 | XLF_CAN_BE_LOADED_ABOVE_4G | 可加载到 4 GiB 以上 |
| 2/3 | XLF_EFI_HANDOVER_32/64 | 支持 EFI handover 协议 |
| 4 | XLF_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 + GRUB | setup 实模式 | GRUB | 老机器、QEMU 默认 |
| EFI stub + handover | efi_main → handover_offset | stub 自己 | 现代 UEFI、systemd-boot |
| EFI stub 无 handover | efi_main → startup_32 | stub | 老固件 |
QEMU -kernel | setup/startup_64 | QEMU 的 load_linux() | 本书实验 |
QEMU -kernel 走的是「QEMU 内置的 Linux 引导器」:它解析 bzImage 头、把 boot_params 填好、直接跳 code32_start 或 64 位入口。它不经过 GRUB,所以 grub.cfg 里的参数要全部写进 -append。
- 「
bzImage是压缩的vmlinux」:只对了一半;它前面还有 setup 与引导头,payload_offset才指向压缩内核。 - 「
boot_params是内核自己填的」:大部分字段由引导器填;内核只读,并在setup_arch()里解析。 - 「KASLR 只影响内核代码」:它还随机化直接映射基址、vmalloc 区、vmemmap;
/proc/kallsyms的_text会变,模块区域也会跟着变。 - 「EFI stub 会替代
start_kernel」:stub 只负责交接,最后仍跳startup_64→start_kernel。 - 「
-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 解释「观测工具为什么能读字段」。
dmesg | tail -30:内核一定打印了原因(vermagic、Unknown symbol、signature)。modinfo -F vermagic X.ko与uname -r对比。nm -u X.ko列出未定义符号,逐个grep /proc/kallsyms。readelf -h X.ko | grep Type确认是REL。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,适合容器运行时「模块已在内存里」的场景。
- 「
insmod报 Unknown symbol,说明符号没导出」:也可能是依赖模块没加载、GPL-only 符号被非 GPL 模块引用、或 CRC 不匹配。先看dmesg的完整一行。 - 「
rmmod失败就是内核 bug」:先看/proc/modules的 refcnt 与Used by;符号依赖、打开的字符设备、未停的 workqueue 都会占引用。 - 「模块名可以随便起」:
KBUILD_MODNAME来自文件名,/sys/module/<name>、modprobe的依赖表、rmmod的参数都用它;改名后旧名字的引用会失效。 - 「
__init函数可以存到全局变量」:init 成功后.init.text被释放,函数指针变野指针。跨生命周期要用常驻段。 - 「
module_param的 0644 表示线程安全」:只表示 sysfs 可写;多个写者并发调用param_set_int仍需自己加锁。 - 「
modprobe会从当前目录找.ko」:它只查/lib/modules/$(uname -r)/下的索引;实验模块要么insmod绝对路径,要么先depmod再放进该目录。
3.1.16 陷阱清单:构建、模块与启动篇
| # | 现象 | 原因 | 修复 |
|---|---|---|---|
| 1 | make 成功,启动 panic No working init found | olddefconfig 关掉了 initramfs/驱动 | 用 defconfig 起步,逐项加;保留 ext4、virtio、DEVTMPFS |
| 2 | gdb 断 start_kernel 失败 | KASLR 打开,符号地址与运行时不一致 | 启动加 nokaslr,或关 CONFIG_RANDOMIZE_BASE |
| 3 | 模块 invalid module format | vermagic 与目标内核不符(用了宿主 headers) | KDIR 指向目标源码树,重新 make modules |
| 4 | 加载后立刻 Oops,栈在已释放地址 | 存了 __init 函数指针 | 去掉 __init,或只在启动期调用 |
| 5 | rmmod 后定时器又触发 | 没 del_timer_sync()/cancel_work_sync() | exit 里先停所有异步源,再释放内存 |
| 6 | Unknown symbol,但 grep /proc/kallsyms 有 | 符号未 EXPORT_SYMBOL,或 GPL-only 被非 GPL 引用 | 加导出宏;检查 MODULE_LICENSE |
| 7 | modprobe 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(仅实验) |
| 11 | rmmod 永远 EBUSY | modules_disabled=1 或 refcnt 泄漏 | 检查 try_module_get 配对;重启恢复 |
| 12 | initramfs 里 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 与 vmlinux | make -j 成功,file 显示 x86-64 | 3.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 -n | 3.1.10 |
解释 vmlinux.o 与 vmlinux | modpost 与最终链接的分工 | 3.1.11 |
| 画出模块加载流水线 | ELF 校验 → 重定位 → init | 3.1.12 |
说出 boot_params.hdr 三个字段 | 偏移与填写者 | 3.1.13 |
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–52 | 3.1.10、3.1.11 | 背景(构建产物与体系结构解耦;屏障在 3.11) |
| 奔跑吧 Linux 内核 | 第 6 章 6.1 QEMU 调试 Linux 内核 | p705–714 | 3.1.5、3.1.7、3.1.13 | 改写(原书 ARM vexpress,本书改 x86-64 + 6.8) |
| 奔跑吧 Linux 内核 | 第 6 章 6.2 ftrace | p715–730 | 3.1.7、3.1.14 | 覆盖(函数图 + initcall 观测) |
| 奔跑吧 Linux 内核 | 第 6 章 6.3 SystemTap、6.4 内存检测、6.5 死锁检测、6.6 调试秘籍 | p731–758 | 3.15(另章) | 合并(本书用 bpftrace/perf/KASAN/lockdep 替代) |
| 李治军 | 第 1 章 系统启动(实践项目 1 控制操作系统启动) | 章级 | 3.1.5 | 背景(引导与最小系统的心智模型) |
| OSTEP | 第 2 章 操作系统介绍 | p3–12 | 3.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进程本身。