很多人学Linux内核,资料看了一堆,最后卡死的往往是同一个坎:用VSCode打开源码目录,满屏红色波浪线,F12跳转不到函数定义,搜一个结构体要手动翻半天;好不容易编译出了内核镜像,想用GDB看看运行状态,结果target remote一敲不是直接失败,就是断点打上去内核根本不停。这篇文章就把“阅读”和“调试”两条线的准备工作一次性说清楚:VSCode侧怎么把索引做对,GDB侧怎么连上QEMU里的虚拟机内核,以及我在反复搭建这套环境过程中踩过的坑。适合刚拿到内核源码想认真读一遍的初学者,也适合已经编译过内核、但还没试过在线调试的开发者。
1. 内核对工具链的特殊要求:为什么要先“编译”再“阅读”
1.1 源码树里有大量编译期生成文件
很多人拿到linux-x.y.z.tar.xz解压之后直接用VSCode打开/linux目录,然后会看到一堆报错,比如include/generated/autoconf.h找不到、arch/x86/include/generated/asm/unistd_64.h打不开。这不是VSCode坏了,而是内核源码树本身就不是一个“完整”的C工程。
Linux内核的构建系统会在配置和编译阶段生成一大批头文件、汇编文件和链接脚本。比如include/generated/autoconf.h是根据.config里的选项自动生成的,include/generated/utsrelease.h记录版本号,arch/x86/include/generated/asm/下面很多头文件也是动态生成的。也就是说,你只把tarball解开,索引工具拿到的是一棵残缺的目录树,再牛的IDE也没办法凭空知道这些文件该长什么样。
所以准备工作第一步是先把内核“prepare”一下。在源码根目录执行:
make x86_64_defconfig make -j$(nproc) prepare跑完之后,include/generated、arch/x86/include/generated这些目录就出现了,很多红色波浪线会立刻消失。这个动作对后续所有工作都是地基,不管你是用VSCode、clangd还是直接GDB调试,都建议先执行一次。
1.2 宏开关和体系结构分支,让“静态阅读”变得不可靠
内核代码里充斥着#ifdef CONFIG_XXX、#ifdef __x86_64__这类分支,同一个函数在不同配置下编译出来的内容可能完全不同。比如task_struct这个核心结构体,在开启真实时间抢占、关闭CPU调度域、支持cgroup等不同选项时,成员变量差异巨大。
普通编辑器的智能提示如果不了解编译参数,就会把所有分支里的代码都当成“有效代码”来处理。结果就是:报一大堆重复定义、未知成员,或者干脆把一个实际上不存在的分支里的函数高亮成可用。这会让阅读体验非常糟糕。
换句话说,内核源码需要“基于某个具体配置”去解释,而不是当成一份静态文本来读。这也是为什么后面一定要把编译参数导出给索引工具——compile_commands.json的价值就在这里,它记录了每个文件编译时用到的所有参数、宏定义、头文件路径,索引工具拿到它才能真正理解代码。
1.3 调试侧同理:没有符号表的镜像就是一堆汇编
阅读侧的问题是不理解编译配置,调试侧的问题则更基础:没有调试信息,GDB连函数名都看不到。
用make编译出来的bzImage是压缩过的可引导镜像,里面几乎不带符号;真正带完整调试信息的是链接产物vmlinux。如果编译时没开CONFIG_DEBUG_INFO,vmlinux里也没有DWARF信息,你断下来的每一处都只能看到裸地址,全凭汇编硬猜。
所以这篇要做的“准备”,本质上是两件事:让编辑器拿到正确的编译上下文,让调试器拿到正确的符号和连接方式。下面两章分别展开。
2. VSCode阅读侧准备:把索引工程化
2.1 三条路线:C/C++扩展、ctags/cscope、clangd
VSCode里阅读内核源码,主流方案有三条,先做个对比:
| 方案 | 索引精度 | 配置成本 | 对内核适配 | 适合场景 |
|---|---|---|---|---|
| Microsoft C/C++扩展 | 中等,依赖includePath和defines | 低 | 一般,手动维护头文件路径 | 快速浏览、临时看代码 |
| ctags / cscope | 低,按符号名匹配 | 低 | 通用,但不理解宏 | 全文搜索、看调用关系 |
| clangd + compile_commands.json | 高,能识别真实编译参数 | 中等 | 高,完全按编译参数解析 | 长期阅读、精确定位跳转 |
我的选择是clangd作为主力,cscope作为辅助检索手段,C/C++扩展只在应急时用一下。
原因很简单:clangd是直接读取编译参数来解析代码的,它看到的就是编译器看到的内容。内核里那些宏分支、__attribute__、内联汇编扩展,clangd都能按实际配置展开,跳转和诊断的准确度明显高于手工维护includePath的方案。
2.2 推荐路线:compile_commands.json + clangd
要让clangd工作,核心是让它在源码目录下找到compile_commands.json。这个文件记录了每个.c文件编译时用的完整命令,clangd读取后就知道该用什么头文件路径、什么宏、什么语言标准去解析。
生成方式有两种:
第一种,新版内核自带目标:
make -j$(nproc) make compile_commands.json如果你用的是clang工具链编译内核,可以写成:
make LLVM=1 compile_commands.json这个目标会调用scripts/clang-tools/gen_compile_commands.py,从构建过程中残留的.cmd文件里提取命令信息。你需要先执行过一次比较完整的编译,否则没有中间产物可以提取。
第二种,老版本内核或外置构建时,用bear:
bear -- make -j$(nproc)bear做的事情是拦截整个构建过程里的execve调用,把每个编译动作记录下来,生成compile_commands.json。这种方式和编译器无关,gcc、clang都能用。
我个人的习惯是:尽量用make compile_commands.json,失败时才上bear。因为bear偶尔会漏掉一些在子shell里执行的编译命令,导致生成的索引不全。如果你用的是O=/path/to/build外置构建,compile_commands.json里记录的路径也是相对于构建目录的,这时可以把json放在构建目录,然后在.clangd里或VSCode的clangd扩展设置中指定编译数据库路径。
生成之后,重启VSCode里的clangd扩展,等右下角的索引进度跑完,跳转定义就有反应了。
2.3 让clangd适配内核的特殊配置
内核代码用了不少GCC特有语法,clangd内部的Clang前端解析时偶尔会报警告,甚至因为个别flag直接报错。我通常会在内核源码根目录放一个.clangd文件:
CompileFlags: Add: - "-Wno-error" - "-Wno-unknown-warning-option" - "--target=x86_64-linux-gnu" Remove: - "-mno-sse" - "-mindirect-branch=thunk-extern" - "-mno-avx"Add里的-Wno-error和-Wno-unknown-warning-option防止Clang把警告升级成错误,避免索引中断;Remove里清理掉一些Clang不认识的汇编相关flag。不同内核版本、不同编译器组合下需要调整的flag不一样,出现索引崩溃时优先改这个文件。
还有个小细节:如果之前装过Microsoft的C/C++扩展,建议把它对当前工作区的“IntelliSense引擎”关掉,或者直接把C_Cpp.intelliSenseEngine设置为disabled,避免两个索引工具同时工作导致波浪线忽好忽坏。
2.4 兜底方案:c_cpp_properties.json手写includePath
如果你不想折腾clangd,或者某个老版本内核实在生成不了compile_commands.json,还有一个相对省事的办法:在.vscode/c_cpp_properties.json里手动指定内核的头文件搜索路径。
{ "configurations": [ { "name": "Kernel", "includePath": [ "${workspaceFolder}/**", "${workspaceFolder}/include", "${workspaceFolder}/arch/x86/include", "${workspaceFolder}/arch/x86/include/generated", "${workspaceFolder}/arch/x86/include/uapi", "${workspaceFolder}/arch/x86/include/generated/uapi", "${workspaceFolder}/include/uapi", "${workspaceFolder}/include/generated/uapi" ], "defines": ["__KERNEL__", "__x86_64__", "CONFIG_X86_64"], "compilerPath": "/usr/bin/gcc", "cStandard": "c11", "intelliSenseMode": "linux-gcc-x64" } ], "version": 4 }我的建议是:“${workspaceFolder}/**”这个写法能快速覆盖整个目录,但索引量很大,VSCode会明显卡顿。真要长期用,还是把目录精确列出来。这个方案的问题是defines里的宏手动列不全,CONFIG_*分支的判断仍然不准确,所以只适合作为临时兜底。
3. GDB调试侧准备:QEMU当作可停下来的硬件
3.1 为什么不能直接gdb attach内核
我在网上看到不少人问“能不能直接gdb attach自己的内核”,答案是不行。普通用户态程序是进程,可以被暂停、单步、读写内存,是因为操作系统提供了对应的ptrace机制;内核自己就运行在最高特权级,负责管理整个系统,没有任何外部机制能暂停它。
你调试内核,实际上是让内核跑在一个虚拟机上,由虚拟机监控程序提供一个调试后门。QEMU的gdbstub就是这么个东西:QEMU启动客户机时开启一个TCP端口,把虚拟CPU的寄存器、内存、中断状态全部暴露给GDB。GDB连上去之后,可以让整个虚拟CPU停下来,设置断点,单步执行。
你可以粗暴地把它理解成“给硬件焊了一个仿真器接口”。这个模式决定了调试内核时GDB看到的不是一个进程,而是整个CPU和设备状态,很多概念和普通调试不一样。
3.2 编译内核时打开调试相关选项
为了让内核可以被GDB调试,编译配置里至少有这几项:
make x86_64_defconfig scripts/config --enable CONFIG_DEBUG_INFO \ --enable CONFIG_GDB_SCRIPTS \ --disable CONFIG_RANDOMIZE_BASE make olddefconfig make -j$(nproc)逐个解释一下:
CONFIG_DEBUG_INFO:让内核编译产物包含DWARF调试信息,这是GDB能识别符号、行号、结构体定义的前提。CONFIG_GDB_SCRIPTS:编译生成内核官方提供的GDB扩展脚本,也就是前面提到过的scripts/gdb/vmlinux-gdb.py,后面会用到lx-symbols、lx-dmesg这些命令。CONFIG_RANDOMIZE_BASE:这是KASLR开关,内核启动时会把自身映射到随机地址。调试学习阶段强烈建议先关掉,否则你根据vmlinux设置的软件断点地址,和实际运行地址对不上,断点根本不会命中。如果你不想重新编译,也可以在QEMU启动参数里加nokaslr,效果一样。
编译完成后,确认一下:
grep CONFIG_DEBUG_INFO .config看到CONFIG_DEBUG_INFO=y再继续。
3.3 QEMU启动命令与gdbstub的接线方式
调试内核最省事的启动方式是用initramfs,不需要准备磁盘镜像。QEMU命令长这样:
qemu-system-x86_64 \ -kernel arch/x86/boot/bzImage \ -initrd /tmp/rootfs.cpio.gz \ -nographic \ -append "console=ttyS0 nokaslr rdinit=/bin/sh" \ -s -S逐个参数拆开讲:
-kernel:指定要启动的内核镜像,就是编译出来的arch/x86/boot/bzImage。-initrd:指定initramfs压缩包。它是虚拟机的根文件系统,里面有一个最小化的shell环境。-nographic:把串口重定向到当前终端。没有图形界面也能看到内核日志。-append:传给内核的命令行参数。console=ttyS0让printk输出到串口;nokaslr关闭地址随机化;rdinit=/bin/sh让initramfs启动后直接进入shell。-s:等价于-gdb tcp::1234,打开1234端口。-S:让QEMU在启动后先停下来,等待GDB连接。这个组合非常关键,否则内核可能在你连上之前已经跑完整个启动流程了。
关于initramfs怎么做,最简单的办法是用静态编译的busybox:
mkdir -p /tmp/rootfs/{bin,sbin,etc,proc,sys,dev} cp /usr/bin/busybox /tmp/rootfs/bin/ cd /tmp/rootfs && ln -s busybox bin/sh find . | cpio -H newc -o | gzip > /tmp/rootfs.cpio.gz这样生成的rootfs很小,能进shell、能跑常用命令,用来触发系统调用做断点验证足够用了。
3.4 把符号和官方脚本加载进GDB
QEMU启动之后,另开一个终端,进入内核源码目录启动GDB:
cd /path/to/linux gdb vmlinux然后依次执行:
(gdb) target remote :1234 (gdb) source scripts/gdb/vmlinux-gdb.py (gdb) lx-symbols (gdb) break start_kernel (gdb) continue这里有个概念要分清:vmlinux是编译链接出来的未压缩ELF,带完整调试信息;bzImage是最终的启动镜像,经过压缩,调试信息几乎不保留。所以GDB一定要加载vmlinux,不是加载bzImage。
source scripts/gdb/vmlinux-gdb.py的作用是加载内核官方的GDB扩展,lx-symbols会动态加载调试过程中遇到的内核模块符号。如果没有这个脚本,后面调试模块时要手动算地址、手动add-symbol-file,非常难受。
4. 实操:从复位到system call的断点之旅
4.1 第一个断点:start_kernel
GDB连上之后,因为QEMU用了-S参数,虚拟CPU还停在启动前的状态,这时可以先下断点再继续执行:
(gdb) break start_kernel (gdb) continuestart_kernel是体系结构无关的C语言入口,几乎所有内核教科书都会从这里讲起。断在这里之后,执行bt:
(gdb) bt你能看到从汇编启动代码跳转到start_kernel的完整调用栈。我第一跑通这个流程的时候,是真切感觉到“代码从实模式到保护模式再到长模式,最后进入C世界”的整个链路,比看任何书都直观。
如果断点打上之后一直不命中,优先检查两件事:QEMU启动参数里有没有nokaslr;CONFIG_RANDOMIZE_BASE是不是还开着。
4.2 用lx-*系列命令观测内核内部
内核官方的GDB脚本提供了一批lx-开头的辅助命令,常用这几个:
lx-symbols:自动加载内核模块的符号表。lx-dmesg:直接读取内存里的log_buf,把内核日志打印出来。这个比看串口输出舒服,因为不会丢数据,还能按级别过滤。lx-current:拿到当前CPU正在运行的task_struct *。后面会解释为什么不能用current宏。lx-list:按类型遍历各种内核链表,学习list_head、hlist这种数据结构时特别有用。
这些命令的实现都在scripts/gdb/linux/目录里,用Python写的。我强烈建议你打开这些脚本看一下,既能学到内核数据结构的实际布局,也能了解GDB Python扩展API怎么用。
4.3 在系统调用上下断点:以read为例
读代码时最常遇到的问题就是“这个函数到底被谁调用了”“参数实际传的什么”。用GDB验证非常容易。比如我们想看read系统调用的实现,先设置断点:
(gdb) break __x64_sys_read (gdb) continue然后在QEMU的shell里执行一个会读文件的命令,比如:
cat /proc/versionGDB会命中在__x64_sys_read入口。这时候查看参数:
(gdb) info registers rdi rsi rdxx86_64系统调用ABI下,前三个参数分别在rdi、rsi、rdx:第一个是文件描述符fd,第二个是用户态缓冲区地址buf,第三个是长度count。再执行:
(gdb) bt从entry_SYSCALL_64到do_syscall_64再到__x64_sys_read,整条系统调用路径一目了然。这比单纯盯着fs/read_write.c猜流程要快得多。
有一个坑是函数名的版本差异:老内核里叫sys_read,新内核里通常叫__x64_sys_read。打不上断点时可以用rbreak sys_read,它会把所有包含sys_read的函数都列出来,再手动continue看停在哪。
4.4 早期启动阶段和percpu的一些注意点
调试启动早期代码时,有一些情况和你平时调试普通程序不太一样。
首先,start_kernel之前的汇编阶段,一些符号的地址还没有最终重定位,GDB里下断点可能断不到你预期的地方。这个阶段适合用stepi单步汇编,不适合依赖C符号。
其次,内核里current不是一个普通全局变量,它通常指向percpu区域里的一个入口,通过GS段基址加上固定偏移来访问。直接执行:
(gdb) p current得到的地址往往是不对的,因为GDB不知道percpu的规则。用官方脚本提供的lx-current能拿到正确结果:
(gdb) p (struct task_struct *)$lx_current()多核情况下也有问题。QEMU默认单核启动,如果用-smp 2,GDB的info threads能看到多个虚拟CPU线程,但调试启动阶段强烈建议先用单核,否则断点命中后上下文频繁切换,行为会很乱。
5. 可复制的完整清单与我的使用习惯
5.1 最小命令流
把前面所有步骤整合到一起,形成一份可以直接“抄作业”的命令流:
准备rootfs和索引:
# 1. 准备initramfs mkdir -p /tmp/rootfs/{bin,sbin,etc,proc,sys,dev} cp /usr/bin/busybox /tmp/rootfs/bin/ cd /tmp/rootfs && ln -s busybox bin/sh find . | cpio -H newc -o | gzip > /tmp/rootfs.cpio.gz # 2. 编译内核(带上调试选项) cd /path/to/linux make x86_64_defconfig scripts/config --enable CONFIG_DEBUG_INFO \ --enable CONFIG_GDB_SCRIPTS \ --disable CONFIG_RANDOMIZE_BASE make olddefconfig make -j$(nproc) # 3. 生成compile_commands.json,供VSCode/clangd使用 make compile_commands.json启动QEMU和GDB:
# 终端A:启动QEMU qemu-system-x86_64 \ -kernel arch/x86/boot/bzImage \ -initrd /tmp/rootfs.cpio.gz \ -nographic \ -append "console=ttyS0 nokaslr rdinit=/bin/sh" \ -s -S # 终端B:启动GDB cd /path/to/linux gdb vmlinux (gdb) target remote :1234 (gdb) source scripts/gdb/vmlinux-gdb.py (gdb) lx-symbols (gdb) break start_kernel (gdb) continue5.2 常见问题快速对照表
我在这套环境上踩过的坑比较典型,列出来供参考:
| 现象 | 原因 | 解决办法 |
|---|---|---|
| VSCode里跳转不了定义,没有符号 | 没生成compile_commands.json,或索引未完成 | 生成索引后重启clangd,等索引结束 |
| 报“cannot open source file××.h” | includePath不完整,或没用compile_commands | 改用clangd方案,检查.clangd文件 |
GDB执行target remote失败 | QEMU没加-s,或端口被占用 | 确认QEMU命令含-s,用netstat查1234端口 |
| 断点打了但一直不命中 | KASLR开启,漏了nokaslr | 启动参数加nokaslr,或编译时关掉随机化 |
bt出来全是??,没有函数名 | GDB没加载vmlinux符号 | 启动GDB时指定gdb vmlinux |
lx-*命令不存在,提示找不到 | 没加载vmlinux-gdb.py | 执行source scripts/gdb/vmlinux-gdb.py |
p current得到奇怪的地址 | percpu变量,普通方式读不对 | 用lx-current读取 |
5.3 我习惯的“先猜后验”工作流
环境准备好之后,真正高效的学习方式不是从头到尾翻源码,而是“先猜后验”。
具体做法是:先用VSCode读一个函数的实现,猜它大概会走哪条调用路径,然后写一个小模块或直接在内核shell里跑一个命令去触发这条路径,再用GDB设置断点验证。
举个例子,想理解read(2)系统调用怎么从VFS层到达具体驱动,就先在fs/read_write.c里顺着__x64_sys_read往下一层层看,找到你猜测的关键函数,记下名字。然后在GDB里对那个函数下断点,触发一次读操作,看它是否真的被调用了,参数是什么,调用栈长什么样。
这种循环验证比单纯读代码有效得多。你读的是“可能执行”的路径,GDB告诉你的是“实际执行”的路径,两者一比对,内核里很多抽象的概念立刻就有了具体的锚点。
实际上,VSCode的调试面板也可以作为GDB前端来连QEMU,配置好program为vmlinux、端口为1234之后,能在图形界面里看断点和变量。但我个人更喜欢命令行GDB,原因有两个:内核官方的lx-*脚本都是命令行工具,调试过程中写脚本、批量执行命令都更方便;命令行里的操作可以直接复制到笔记里,作为学习记录保存下来。
最后分享一点个人体会。这套环境我重建过很多次,每次换电脑都要从零来一遍。最初总想找一个“一键脚本”直接配好,后来发现准备过程本身就是理解内核构建系统最好的机会——配置、编译、加载符号、连接gdbstub,每一步都会逼你搞清楚vmlinux、bzImage、System.map、initramfs这些概念之间的关系。所以别嫌麻烦,一步一步来。当你能在GDB里自如地断下start_kernel,看到调用栈里那串从汇编到C的路径时,之前踩的所有坑都会变成你的直觉。