ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Linux内核源码阅读与GDB调试环境搭建:VSCode+clangd+QEMU完整指南

2026/9/21 3:18:57 拓冰建站 浏览量
Linux内核源码阅读与GDB调试环境搭建:VSCode+clangd+QEMU完整指南 很多人学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_INFOvmlinux里也没有DWARF信息你断下来的每一处都只能看到裸地址全凭汇编硬猜。所以这篇要做的“准备”本质上是两件事让编辑器拿到正确的编译上下文让调试器拿到正确的符号和连接方式。下面两章分别展开。2. VSCode阅读侧准备把索引工程化2.1 三条路线C/C扩展、ctags/cscope、clangdVSCode里阅读内核源码主流方案有三条先做个对比方案索引精度配置成本对内核适配适合场景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 LLVM1 compile_commands.json这个目标会调用scripts/clang-tools/gen_compile_commands.py从构建过程中残留的.cmd文件里提取命令信息。你需要先执行过一次比较完整的编译否则没有中间产物可以提取。第二种老版本内核或外置构建时用bearbear -- 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 - --targetx86_64-linux-gnu Remove: - -mno-sse - -mindirect-branchthunk-extern - -mno-avxAdd里的-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_INFOy再继续。3.3 QEMU启动命令与gdbstub的接线方式调试内核最省事的启动方式是用initramfs不需要准备磁盘镜像。QEMU命令长这样qemu-system-x86_64 \ -kernel arch/x86/boot/bzImage \ -initrd /tmp/rootfs.cpio.gz \ -nographic \ -append consolettyS0 nokaslr rdinit/bin/sh \ -s -S逐个参数拆开讲-kernel指定要启动的内核镜像就是编译出来的arch/x86/boot/bzImage。-initrd指定initramfs压缩包。它是虚拟机的根文件系统里面有一个最小化的shell环境。-nographic把串口重定向到当前终端。没有图形界面也能看到内核日志。-append传给内核的命令行参数。consolettyS0让printk输出到串口nokaslr关闭地址随机化rdinit/bin/sh让initramfs启动后直接进入shell。-s等价于-gdb tcp::1234打开1234端口。-S让QEMU在启动后先停下来等待GDB连接。这个组合非常关键否则内核可能在你连上之前已经跑完整个启动流程了。关于initramfs怎么做最简单的办法是用静态编译的busyboxmkdir -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 把符号和官方脚本加载进GDBQEMU启动之后另开一个终端进入内核源码目录启动GDBcd /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_kernelGDB连上之后因为QEMU用了-S参数虚拟CPU还停在启动前的状态这时可以先下断点再继续执行(gdb) break start_kernel (gdb) continuestart_kernel是体系结构无关的C语言入口几乎所有内核教科书都会从这里讲起。断在这里之后执行bt(gdb) bt你能看到从汇编启动代码跳转到start_kernel的完整调用栈。我第一跑通这个流程的时候是真切感觉到“代码从实模式到保护模式再到长模式最后进入C世界”的整个链路比看任何书都直观。如果断点打上之后一直不命中优先检查两件事QEMU启动参数里有没有nokaslrCONFIG_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 2GDB的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 consolettyS0 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 vmlinuxlx-*命令不存在提示找不到没加载vmlinux-gdb.py执行source scripts/gdb/vmlinux-gdb.pyp 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的路径时之前踩的所有坑都会变成你的直觉。