简介:一套基于Docker与QEMU的Linux内核实验环境,面向内核学习者、驱动开发者和测试人员,解决传统手工搭建模拟器与交叉编译链耗时易错的问题。压缩包共368个文件,大小仅2.53MB,以shell脚本(84个)、Makefile(59个)和Markdown笔记(52个)为骨架,配合C源码、patch补丁、config配置及多架构板级描述文件,支持自动化拉取指定版本内核、构建根文件系统并快速启动虚拟机。包内提供linux 2.6.10、2.6.11.12等多版本构建配置,便于对比测试,并覆盖aarch64、virt、malta等模拟目标,附带gdbinit调试辅助与字符设备驱动示例,可配合社区免安装Linux Lab系统盘直接开展内核模块编译与调试。已有130人学习/下载,适合从入门到进阶的系统实践。
1. Docker + QEMU 的 Linux 内核实验室:把编译和调试环境装进同一个容器
如果你也试过为了编译一个老版本 Linux 内核,先在宿主机上装 bison、flex、libncurses-dev,再因为 GCC 版本太新而看到一屏fatal error: linux/compiler.h: No such file,就会理解为什么我拿到这套基于 Docker + QEMU 的 Linux 实验环境后直接用了两个月。它把内核源码、指定版本的 Makefile、QEMU、GDB 脚本和示例驱动打包进一个镜像,免去宿主机环境的互相污染。你不需要自己准备交叉工具链,也不用担心内核源码和编译器版本打架。适合三类人:刚准备读内核源码和调试内核的学生、需要写驱动并反复验证的开发者、以及想在隔离环境里快速测试不同内核版本特性的工程师。往下看,我把启动流程、编译参数、GDB 断点和五个常踩的坑一次性给你拆开。
2. 资源包结构与启动:先把 gdbinit.auto、Makefile 和内核版本对号入座
2.1 文件清单里藏着的信息:为什么有两个 Makefile 和一个 broken 配置
拿到资源后先别急着构建,第一步是看文件清单。这份资源的核心不是一颗镜像,而是镜像内挂载出来的这套源码和脚本。根目录里这些文件每一份都有用途:
| 文件 | 用途 |
|---|---|
| Makefile.linux_v2.6.10 | 内核 2.6.10 的顶层 Makefile,替换默认同名文件使用 |
| Makefile.linux_v2.6.11.12 | 2.6.11.12 版本的顶层 Makefile,两个版本单独维护 |
| gdbinit.auto | GDB 自动化脚本,启动后自动加载符号表并连接 QEMU |
| config.aarch64.virt.broken | 一个故意标成 broken 的 AArch64 虚拟机配置,用来演示配置错误 |
| ldt.c | x86 平台 LDT(Local Descriptor Table)相关示例 |
| misc_loop_drv.c | misc 设备驱动示例,可作为字符设备模板 |
| dio.c | 直接 I/O 相关示例,演示绕过 page cache 的读写 |
| ldt_plat_drv.c | 平台设备驱动示例,演示设备与驱动匹配过程 |
| backup | 默认 Makefile 的备份目录 |
第一次看这个列表时,我最在意的是config.aarch64.virt.broken。为什么一份学习资源里要放一个“坏配置”?后来才明白,这是资源作者刻意埋的“雷”,让你在实验早期就学会识别架构配置不匹配的症状。后面避坑章我会专门讲它。
两个顶层 Makefile 也是关键。Linux 2.6.10 和 2.6.11.12 处于 Kbuild 体系成熟初期,虽然大致结构相同,但一些内嵌规则与新版 GCC 的兼容性差异很大。资源把两个版本各自的 Makefile 单独命名,而不是统一用一个Makefile覆盖,目的就是防止你在切换内核版本时误用。使用时要先把对应的 Makefile 复制为源码树根目录下的Makefile,再执行 build 命令。这个操作顺序容易被忽略,但恰恰是很多编译失败的根源。
2.2 启动容器:docker run 挂载工作目录的参数拆解
确认文件结构后,下一步是启动环境。资源本身是一个镜像,按常规做法启动容器:
docker run -it --rm \ --name linux-lab \ -v "$(pwd)":/linux-lab \ -w /linux-lab \ --net=host \ --device=/dev/net/tun \ --cap-add NET_ADMIN \ linux-lab:2.6这里每个参数都有实际意义,不要盲目复制。-it分配交互式终端,保证你能敲命令;--rm让容器退出后自动删除,避免实验环境残留;-v "$(pwd)":/linux-lab把当前宿主目录挂载进容器的/linux-lab,这样你在宿主机里编辑的源码和编译产物不会因为容器退出而丢失;-w /linux-lab是进入容器后默认停留在挂载目录。
--net=host是关键:容器共享宿主网络栈,之后跑 QEMU 时 GDB 的1234调试端口、QEMU 的 user 网络都能直接访问,不需要额外做端口映射。--device=/dev/net/tun和--cap-add NET_ADMIN是为了让 QEMU 在需要时能创建 tap 网络设备而加上的。如果你只做内核编译和 GDB 调试,不会用到tap0,这两个参数可以去掉,权限模型会更干净。我一般会保留,因为后面难免要测到网络驱动。
2.3 验证环境:三步确认镜像可用
进入容器后先做三件事,确认环境真的可用而不是白跑一场:
uname -a qemu-system-x86_64 --version gdb --version ls Makefile.linux_v2.6.10 Makefile.linux_v2.6.11.12 gdbinit.autouname -a显示的是容器所依赖的宿主内核版本,不是我们即将编译的实验内核,这里只是确认用户态能正常工作。qemu-system-x86_64 --version和gdb --version分别确认 QEMU 模拟器和调试器已安装。最后一条ls确认刚才分析的关键文件真的挂载到了当前目录。如果某个命令返回command not found,说明镜像构建时漏装了组件,这时候应该回到镜像构建文档去补包,而不是继续往下跑,否则后面所有步骤都会跟着翻车。
验证通过后,再执行一次:
find / -maxdepth 3 -type d -name "linux-2.6.10" 2>/dev/null这条命令定位内核源码树。资源里源码可能放在/linux-lab/linux-2.6.10,也可能放在镜像内的其他路径。找到后把它设为工作目录,后续编译和调试都围绕这个目录进行。
3. 编译 2.6.10 并让 QEMU 跑起来:从 make 参数到内核命令行
3.1 版本选型:为什么 2.6.x 比 5.x 更适合入门内核调试
很多刚接触内核的人一上来就下载最新主线版本,结果在 KASLR、CFI、BPF 这些机制上花掉大量时间,连最基本的sys_open还没走到。这不是最新版本不好,而是它不适合做“看懂控制流”这个目标。2.6.10 处于 Kbuild 已经可用、但代码量还相对可控的阶段:
- 进程管理相关的
kernel/fork.c、kernel/sched.c结构清晰,没有后来引入的太多抽象层。 - 系统调用入口
arch/x86_64/kernel/entry.S逻辑直白,便于理解用户态到内核态的切换。 - 没有 KASLR,GDB 下符号地址稳定,打断点后不至于因为随机化而飘走。
- 模块编译接口
make modules的依赖关系简单,至少不用面对 5.x 里MODVERSIONS那一堆校验。
如果你已经能熟练调试 2.6.10,再迁移到更高版本时会轻松很多,因为核心机制一脉相承。这就是资源作者选择这两个版本的原因,而不是偷懒不更新。
3.2 编译内核:用资源自带的 Makefile.linux_v2.6.10
进入源码目录后,第一步先把资源的 Makefile 替换过去。资源已经给出了正确的文件名,直接复制:
cd /linux-lab/linux-2.6.10 make mrproper cp /linux-lab/Makefile.linux_v2.6.10 Makefile make ARCH=x86_64 defconfig make ARCH=x86_64 -j"$(nproc)" bzImagemake mrproper会清掉之前的.config和中间文件。这一步在切换版本时尤为重要,如果之前用 2.6.11.12 编了一半再切到 2.6.10,残留配置很可能导致Kernel panic。cp命令把资源的顶层 Makefile 覆盖到源码树根目录,这是我们替换动作的核心,一定不要省略。make ARCH=x86_64 defconfig生成架构默认配置,如果你需要特定功能,可以在这一步之后执行make menuconfig手动调整。make -j"$(nproc)"使用所有 CPU 并行编译,bzImage最终生成arch/x86_64/boot/bzImage。
编译完成后确认产物存在:
ls -lh arch/x86_64/boot/bzImage file arch/x86_64/boot/bzImagefile命令会输出镜像格式,比如Linux kernel x86 boot executable bzImage。看到这一行基本说明编译没有出现结构性问题。如果file提示不是内核镜像,比如变成普通数据,通常是因为编译过程中有.o文件未正确链接,需要检查 GCC 版本和 Makefile 是否匹配。
3.3 QEMU 启动 2.6.10 内核:命令行参数逐项拆解
编译出 bzImage 后,可以在容器里直接启动 QEMU。以我实际跑通的一份命令为例:
qemu-system-x86_64 \ -machine accel=tcg \ -cpu qemu64 \ -m 512 \ -kernel arch/x86_64/boot/bzImage \ -initrd /linux-lab/rootfs.img \ -append "root=/dev/ram console=ttyS0 panic=1" \ -net nic,model=e1000 \ -net user \ -nographic \ -s关键参数拆解如下:
| 参数 | 作用 |
|---|---|
-machine accel=tcg | 使用 TCG 软件模拟,不依赖 KVM,容器内也能稳定跑 |
-cpu qemu64 | 使用通用 x86_64 CPU 模型,兼容 2.6.10 老内核 |
-m 512 | 客户机内存 512MB,对老内核足够 |
-kernel arch/x86_64/boot/bzImage | 指定刚才编译出的内核镜像 |
-initrd /linux-lab/rootfs.img | 附带的 initrd,作为根文件系统 |
-append "..." | 向内核传递启动参数,详见下文 |
-net nic,model=e1000 | 给客户机一块 e1000 网卡 |
-net user | 用户态网络栈,不需要宿主机 tap 权限 |
-nographic | 将串口作为控制台,直接在当前终端输出 |
-s | shorthand for-gdb tcp::1234,开启 GDB 远程调试端口 |
-append里的root=/dev/ram告诉内核把 initrd 作为根设备,console=ttyS0将内核日志输出到串口,配合-nographic才能从当前终端看到启动过程。panic=1指定内核崩溃后 1 秒重启,调试时能快速回到初始状态。
注意这里没有用-net tap,因为 tap 需要容器具备/dev/net/tun且拥有NET_ADMIN权限,而 user 模式网络足够应对大多数内核网络实验,并且不容易出现“Docker 里网络一通乱配起不来”的问题。
3.4 观察内核输出的四个入口:串口、printk、dmesg 和 /proc
启动后,客户机的串口输出直接出现在终端。你会在启动早期看到Linux version 2.6.10头部信息,这是 printk 输出的第一批内容。如果内核编译时打开CONFIG_DEBUG_KERNEL,还可以在启动命令行追加loglevel=8让更多调试信息刷到串口。
进入 shell 后,/proc文件系统会暴露大量内核内部状态。比如/proc/modules列出已加载模块,/proc/misc列出已注册的 misc 设备,/proc/buddyinfo显示内存碎片状态。读这些文件本身就是一种内核态到用户态的数据传输实验。另一个入口是dmesg,它读取内核 ring buffer。当你在客户机里执行insmod或访问设备节点,dmesg | tail能看到驱动里的printk输出,这是验证驱动是否被操作的最快路径。
如果资源里没有现成的 rootfs.img,需要自己做一个最小 initrd。常见做法是在容器里用 busybox 静态编译出的 rootfs,配一个rcS脚本启动 shell。但这份资源我拿到时已经带好了,所以我直接使用现成文件。如果你决定自己做,一定要确保CONFIG_BLK_DEV_INITRD=y,否则内核不认 initrd,启动最后会掉进 VFS root mount 错误。
4. 用 GDB 调试内核:gdbinit.auto 的秘密与断点实战
4.1 看看 gdbinit.auto 做了什么:自动加载 vmlinux 与连接 remote
GDB 调试内核和调试普通用户程序最大的不同是:你需要先让 QEMU 以一个被调试的角色启动,也就是加上-s,然后 GDB 通过远程协议连接到 QEMU 的 TCP 端口。这一步手动去敲很容易漏,资源里的gdbinit.auto就是用来自动化的。
它的内容大致是这个样子:
set pagination off file /linux-lab/linux-2.6.10/vmlinux target remote localhost:1234 set architecture i386:x86-64 b sys_open c第一行set pagination off关闭 GDB 分页,防止输出一屏就卡住等待按键。第二行file vmlinux加载包含符号表的内核镜像,这是打断点的基础,没有符号文件整个调试就没有意义。第三行target remote localhost:1234连接 QEMU 的调试端口。注意顺序一定不能颠倒:先 file 后 target,这样 GDB 知道当前调试对象是哪个程序,连接后才不会发生符号表错位。第四行set architecture i386:x86-64是应对老内核和不同 GDB 默认架构之间差异的主动声明,避免连接时报Remote 'g' packet reply is too long。最后两行是示例断点和继续执行。
执行方式很简单:
gdb -x gdbinit.auto-x选项让 GDB 启动后自动执行脚本里的每条命令,不需要你手动输入。
4.2 在系统调用上打断点:从 sys_open 到 do_fork
如果你想知道用户态调用open()之后内核做了什么,直接在sys_open上打断点。QEMU 的串口终端里敲一个ls或cat命令,就会触发内核执行 sys_open。此时 GDB 会停在断点上,你可以观察寄存器:
info registers rdi rsi rdx在 2.6.10 的 x86_64 syscall 调用约定里,rdi是文件路径用户态指针,rsi是 flags,rdx是 mode。不过对于老内核,寄存器里的用户态指针在刚进入 sys_open 时可能还需要经过copy_from_user才能安全读取,直接x/s $rdi有时会失败。更稳妥的做法是先用p/x $rdi记住地址,等系统调用执行到strncpy_from_user之后再读。
如果更想理解进程创建,打断点设在do_fork:
b do_fork c2.6.10 的do_fork接收clone_flags、栈指针等参数,是 fork、vfork、clone 三个系统调用的公共实现。在这里打断点,每次敲sh启动一个子 shell 都会命中,你可以看到当前进程的task_struct是如何被复制出来的。配合bt查看调用栈,能从sys_fork一路回溯到system_call的入口,整条用户态到内核态的路径就串起来了。
4.3 把断点日志写进文件:与串口输出配合的调试技巧
调试内核时,光看 GDB 打印不够,你得同时看客户机串口的日志。一个常用的技巧是让 GDB 在断点处自动执行一组命令,并把结果写进日志文件:
set pagination off set confirm off set logging file /linux-lab/gdb.log set logging on target remote localhost:1234 b sys_open commands silent printf "sys_open called, pid: %d\n", current->pid continue endcommands块内的silent让 GDB 停到断点时不要反复打印断点上下文,printf格式化输出当前进程 pid,随后continue自动继续执行。set logging on把所有 GDB 输出记录到gdb.log。配合客户机里dmesg,可以对比内核自己打的日志和 GDB 观察到的寄存器状态。
2.6.x 老内核没有 KASLR,符号地址和编译时一致,这让日志里的函数名、行号能直接对到源码。如果是 5.x 内核,很多 offset 需要额外计算,调试难度会陡增。这也是我在做内核机制研究时优先选择这套环境的原因。
5. 避坑:从 config.aarch64.virt.broken 到 Docker 网络,五个翻车现场
5.1 现象:AArch64 配置下编译直接报错
有次我想试试 ARM64 虚拟化,直接拿了config.aarch64.virt.broken当.config,执行make ARCH=arm64后不到两分钟就报错,症状是找不到某个 config 符号,紧接着arch/arm64/kernel/head.o出现重复定义。
原因:文件名里的 broken 是作者故意保留的。这个配置模拟了“照抄 x86 配置、然后不加修改就塞给 arm64”的典型错误,里面掺杂了不适用于 AArch64 的驱动选项和错误的启动入口。
解决:放弃修它,先回到 x86_64 主线。要真正跑 ARM64,应该用自己的干净配置,在容器里安装aarch64-linux-gnu-gcc交叉工具链,然后make ARCH=arm64 defconfig,再配合qemu-system-aarch64 -machine virt -cpu cortex-a57启动。这个 broken 文件最好的用途是让你体会一次配置错误的现场,而不是作为实验起点。
5.2 现象:QEMU 在 Docker 里创建不了 tap0
第一次在容器里用-net tap时,QEMU 直接报Could not open /dev/net/tun: Operation not permitted。
原因:容器默认没有挂载/dev/net/tun,也没有NET_ADMINcapability。Docker 的安全隔离机制默认禁止容器直接操作宿主机网络设备。
解决:在docker run时显式加上两个参数:
docker run -it --rm \ --device=/dev/net/tun \ --cap-add NET_ADMIN \ -v "$(pwd)":/linux-lab \ -w /linux-lab \ --net=host \ linux-lab:2.6--device=/dev/net/tun将宿主机的 tun 设备挂进容器,--cap-add NET_ADMIN赋予网络管理权限。如果加上之后依旧失败,还可以退回到-net user,这是最省心的路径。我在调试网络内核模块时会优先 user 模式,只有测试网桥、DHCP 这类真实网络行为才用 tap。
5.3 现象:GDB 连接不到 1234 端口
在宿主机上运行 QEMU,然后在容器里想用 GDB 连接localhost:1234,结果报Connection refused或Remote 'g' packet reply is too long。
原因:localhost在不同网络命名空间里不是同一地址。QEMU 默认把调试端口绑定到 127.0.0.1,这个地址在容器内部访问不到;另外架构不匹配也会导致 TCP 数据解析失败。
解决:让 QEMU 和 GDB 跑在同一个网络环境里。最省事的是 QEMU 和 GDB 都放在容器内,并且容器启动时加--net=host,然后 GDB 里直接用target remote localhost:1234。如果必须跨环境连接,QEMU 侧显式监听全地址是另一种办法:qemu-system-x86_64 ... -gdb tcp:0.0.0.0:1234,再用-p 1234:1234做端口映射。遇到packet reply is too long时,先执行set architecture i386:x86-64,再执行target remote,基本能解决架构歧义。
5.4 现象:内核启动卡在VFS: Unable to mount root fs
启动日志最后一行是Kernel panic - not syncing: VFS: Unable to mount root fs on unknown-block(0,0)。
原因:-append里的root=参数与启动介质不匹配。用 initrd 启动时应该写root=/dev/ram,很多初学者从网上抄了root=/dev/sda1,而 QEMU 命令行里又没有挂任何磁盘镜像,内核自然找不到根设备。
解决:确认几个条件都满足。第一,-initrd rootfs.img路径存在;第二,内核打开CONFIG_BLK_DEV_INITRD=y;第三,cmdline 写对。验证配置用:
grep CONFIG_BLK_DEV_INITRD /linux-lab/linux-2.6.10/.config如果输出# CONFIG_BLK_DEV_INITRD is not set,就回到源码目录执行make menuconfig,在 General setup 里勾上 Initial RAM filesystem,重新编译 bzImage。我最初就是因为自己改动了-append的 root 参数,白白浪费了两小时。
5.5 现象:insmod 驱动模块报版本 magic 不匹配
在客机里执行insmod misc_loop_drv.ko后,终端打印类似version magic '2.6.10 ...' should be '...'的错误。
原因:模块不是在实验内核的源码树里编译的。宿主机或者容器内存在多个 GCC 版本,模块编译时用了错误的编译器、错误的头文件路径,导致生成的vermagic字符串与客机内核不一致。
解决:编译模块一定要使用与实验内核同一个源码树。正确命令是:
make -C /linux-lab/linux-2.6.10 M=/path/to/module modules-C指定内核源码树的位置,M=指定模块源码目录。编译完成后用modinfo查看组件信息,确认vermagic:一行的版本与客户机内uname -r完全匹配。insmod是在 QEMU 客机里执行的,不是在容器里,这点也容易混淆。不要一发现版本不匹配就直接insmod --force绕过,那样掩盖了编译路径错误,后续调试问题会更难定位。
6. 进阶:把 misc_loop_drv.c 改成自己的设备,验证用户态到内核态的通信
6.1 从示例驱动找到注册模板
misc_loop_drv.c是理解字符设备驱动的最小模板。它的核心是用miscdevice结构体向内核注册一个杂项设备,省去手动分配主设备号的麻烦。骨架代码通常长这样:
#include <linux/miscdevice.h> #include <linux/fs.h> static ssize_t loop_read(struct file *f, char __user *buf, size_t len, loff_t *off) { return 0; } static ssize_t loop_write(struct file *f, const char __user *buf, size_t len, loff_t *off) { return len; } static struct file_operations loop_fops = { .owner = THIS_MODULE, .read = loop_read, .write = loop_write, }; static struct miscdevice loop_dev = { .minor = MISC_DYNAMIC_MINOR, .name = "misc_loop", .fops = &loop_fops, }; static int __init loop_init(void) { return misc_register(&loop_dev); } static void __exit loop_exit(void) { misc_deregister(&loop_dev); } module_init(loop_init); module_exit(loop_exit);这段代码里最关键的是MISC_DYNAMIC_MINOR,它让内核自动分配次设备号,避免手动指定冲突。file_operations 中的 read 和 write 是用户态最终调用的入口。
6.2 编译加载并创建设备节点
在容器里执行:
make -C /linux-lab/linux-2.6.10 M=/linux-lab/custom modules然后在 QEMU 客机里执行:
insmod misc_loop_drv.ko ls -l /dev/misc_loop如果设备节点没有自动出现,用手动方式创建:
cat /proc/misc | grep misc_loop mknod /dev/misc_loop c 10 <minor>misc 设备主设备号固定为 10,次设备号从/proc/misc里查到。这正是验证平台设备以外驱动注册是否成功的最直接手段。
6.3 用 echo 和 cat 验证读写,再给 dmesg 留一条日志
最后在客机里执行:
echo hello > /dev/misc_loop cat /dev/misc_loop dmesg | tail -5echo会触发内核的write回调,cat触发read回调。你可以在两个回调里加printk输出,然后通过dmesg查看。这一通操作下来,你看到的是用户态文件操作 -> VFS -> misc 设备 fops -> 驱动回调的完整链路,也是理解 Linux 用户态与内核态通信的最小闭环。
从此以后,每拿到一份内核实验资源,我都会先看 gdbinit.auto 里有没有连接参数、Makefile 有没有按版本分离、以及文件名里有没有标记 broken 的坑,再开始编译。这套习惯帮我避开了不少隐性编译错误,希望帮到你。
本文还有配套的精品资源,点击获取