
第一次把内核源码拉下来ls一下七万多个文件、三千万行代码铺在终端里绝大多数人的第一反应是打开init/main.c从start_kernel开始往下啃。我当年就是这么干的坚持了三天看到setup_arch()就彻底断了线索因为那是一堆与具体体系结构绑定的初始化跳进去之后再也没能回到主线。后来才想明白一件事内核源码不是一本可以从前言读到后记的书它更像一座城市的市政系统你得先知道自己要找的是水务、电力还是交通再顺着管道走而不是站在市中心广场上试图认全每一栋楼。这篇内容想解决的问题很具体给准备或正在读 Linux 内核源代码的人一条能走通的路径。它不是内核原理科普也不会把进程调度、内存管理这些大块头重新讲一遍——那些东西随便一本教材都比一篇文章讲得细。我要分享的是怎么读这件事本身从哪个版本切进去、目录结构背后的责任划分、怎么追一条完整的调用链、用什么工具让静态代码变成可验证的实验以及我在这个过程中实实在在踩过的几个坑每个坑的排查链路都完整还原。适合已经写过简单驱动、能看懂 C 语言和基本汇编、但面对整棵源码树不知道该从哪下手的人完全零基础也能看懂思路只是实操部分需要一点 Linux 使用经验打底。1. 动手之前先认命内核源码不是用来通读的1.1 通读路线为什么必然失败先把这个念想掐掉。内核代码量摆在那里而且它的组织方式不是线性的——kernel/目录里放的是与体系结构无关的核心逻辑arch/里放的是同一套逻辑在不同 CPU 上的实现include/里散落着几千个头文件供所有人引用。你想按顺序读但代码之间是网状的引用关系读到第三个函数就会跳进另一个目录再跳一次就迷路了。更麻烦的是内核里有大量代码是条件编译的。同一个函数在CONFIG_SMP打开和关闭的情况下走的是完全不同的分支同一份源码在 x86 和 ARM 上编译出来的东西差别巨大。你读的这段代码很可能在你机器上跑的这版内核里压根没被编译进去。所以通读这个动作连读到的就是运行时的真相这个前提都保证不了。我后来给自己定的规矩是每读一段代码必须能回答它什么时候被调用和我的机器上它会不会被编译这两个问题。答不上来就先别往下读先去把这两个问题弄清楚。1.2 三条可行的切入路径及其适用人群不同目的对应完全不同的读法我把常见的分成三类切入路径入口适合的人典型产出驱动/子系统路线drivers/下某个具体设备想做嵌入式、写驱动、调硬件能定位一个设备从 probe 到读写数据的完整路径系统调用路线fs/、kernel/中的 syscall 实现想做性能优化、排查应用层诡异问题能把一个用户态 API 追到内核深处的落点机制原理路线mm/、kernel/sched/想搞懂某个机制的设计取舍能说清一个数据结构为什么这么设计三条路线里我个人最推荐系统调用路线作为第一条。原因很实在它有明确的起点用户态调用、有明确的终点返回值和 errno中间经过的层次多但每一层职责清晰而且你能在用户态写几行代码就触发它随时可以拿运行时行为反推静态代码。驱动路线也不错但受硬件限制很多人手上没有合适的设备读完没法验证。1.3 版本锚定选错分支等于白读这一条是我吃过亏之后才重视的。网上流传的大量内核分析文章写于五六年前甚至更早里面的函数名、参数列表、文件位置早就变了。你拿着新版本源码去对照旧文章会发现函数签名对不上、文件被挪了位置、有的函数干脆被合并或删掉了然后开始怀疑自己是不是找错了地方。我的做法是选定一个长期维护的稳定版本整个学习周期内不换。选版本的时候看两个东西——是不是还在维护窗口内以及你手上的开发环境编译器版本、工具链能不能顺利编过。选定之后把版本号记在笔记最上面后面所有查到的资料、看到的文章只要版本差得远一律当作参考思路而不是逐行对照。另外源码树里有个Documentation/目录很多人从来没点开过。这个目录的价值极高里面有针对各个子系统的说明文档尤其是Documentation/process/下的开发流程说明和Documentation/core-api/下的核心 API 文档。这些文档跟着源码一起维护时效性比网上大部分文章好得多。2. 目录结构不是分类是责任边界2.1 arch 与 kernel 的界面在哪里arch/和kernel/的关系是理解整棵源码树的第一把钥匙。kernel/里放的是体系结构无关的通用逻辑比如进程创建、信号处理、定时器框架arch/里放的是这些逻辑在具体 CPU 上的落地实现。两者之间存在一层明确的接口通常以一组arch_xxx()函数或者weak符号的形式存在。举个例子通用代码里调用arch_irq_work_raise()来通知当前 CPU 有紧急工作要处理具体怎么通知x86 和 ARM 的实现完全不同。你在kernel/里读通用逻辑读到这类函数就应该意识到这里是一个断层往下要换到arch/目录去读了。理解这个界面之后读代码时就不会有为什么这里调用了一个没实现的函数的困惑。我的笔记里专门有一页记录这种跨目录的接口点读到一个就记一个慢慢就形成了整棵树的关节图。2.2 mm、fs、net 这些子系统的共同骨架mm/内存管理、fs/文件系统、net/网络这三个目录体量都很大但它们的组织方式有共同套路抓住套路就能快速建立索引面向对象的模拟内核里没有类但用struct xxx_operations这种函数指针表模拟了虚函数。file_operations、address_space_operations、super_operations都是这个套路。找到这张表就找到了这一层的全部能力清单。注册与查找每个子系统几乎都有一个注册函数register_xxx和一个全局链表或哈希表驱动程序先注册自己核心层再按规则查表找到对应的实现。上下两层分离fs/下的 VFS 是抽象层fs/ext4/、fs/xfs/是具体实现net/下的 socket 层是抽象层net/ipv4/是具体协议栈。我建议的做法是先读抽象层VFS、socket 层只搞清楚请求是怎么被转交下去的不要急着进具体实现。抽象层代码量小、注释多、逻辑清晰是整棵树里性价比最高的阅读区域。2.3 drivers 那半棵树该怎么看drivers/常年占据整棵源码树一半以上的体量很多人一看就头大。但它其实是最好切入的区域因为驱动的结构高度模板化。一个典型的字符设备驱动无非是这几件事注册设备号、实现file_operations、在probe里申请资源、在remove里释放。看懂一个其余的大同小异。关键在于选对第一个驱动。我的建议是选一个你手上有对应硬件、或者能在虚拟环境里模拟的设备比如虚拟串口、回环网卡、内存块设备。看得见结果的代码才读得下去。纯粹看一个你永远用不上的驱动读到一半就会失去动力。2.4 Kconfig 与 Makefile你看到的代码不一定被编译这是最容易被忽略的一节。内核的编译规则由两级文件控制Kconfig决定某个配置项在make menuconfig里出不出现、默认值是什么、依赖哪些其他配置Makefile决定这个配置项打开时哪些.o文件会被链接进去。一个典型片段长这样obj-$(CONFIG_EXT4_FS) ext4.o ext4-y : balloc.o bitmap.o block_validity.o dir.o意思是只有CONFIG_EXT4_FS为 y 或 m 时ext4.o才会被编译而ext4.o是由后面列出的那一串目标文件链接起来的。这两级规则组合起来就是一张精确的代码是否参与编译的判定表。我养成了一个习惯每次准备深入读某个文件之前先回到它所在目录的Makefile里找到它确认它挂在哪个CONFIG_下面再回Kconfig里确认这个配置项的依赖关系最后去自己的.config里 grep 一下确认它是开着的。这四步走完才能保证我读的代码确实会跑。3. 追一条系统调用链从 read() 到块设备3.1 SYSCALL_DEFINE 宏背后藏着什么系统调用在源码里长得不像函数而是用宏定义出来的。以read为例在fs/read_write.c里能找到SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count) { return ksys_read(fd, buf, count); }SYSCALL_DEFINE3这个名字里的 3 表示三个参数。这个宏展开之后会生成真正的入口函数__x64_sys_read在 x86_64 上以及配套的系统调用表项。如果你直接搜read会搜到无数同名或包含 read 的符号搜SYSCALL_DEFINE加上参数类型才是准确的做法。继续往下ksys_read里做了两件关键的事从文件描述符表里取出struct file *然后调用vfs_read。这一步是用户态到内核态切换之后的第一层分流后面的所有路径都从这里开始分叉。3.2 VFS 如何把请求转交给具体文件系统vfs_read的逻辑不复杂但有两个关键分支值得细看if (file-f_op-read) ret file-f_op-read(file, buf, count, pos); else ret do_iter_read(file, ...);老式的read接口和新的read_iter接口在这里分流。新代码基本都走read_iter因为它是基于 iov_iter 的能更好地支持零拷贝和异步 IO。看到这里就应该明白具体文件系统的实现不在 VFS 层而在file-f_op指向的那张表里。顺着f_op找下去就进入了真正干活的文件系统代码。这一步是我认为整条链路里最有教学价值的地方。它把抽象层定义接口、具体层提供实现这个设计模式演示得非常干净VFS 从不关心底下是 ext4 还是 xfs它只认那张函数指针表具体文件系统也不关心调用方是谁它只负责把数据搬出来。3.3 页缓存这一层为什么经常被跳过很多人在追 read 链路的时候直接从 VFS 跳到文件系统然后困惑于数据到底是从哪来的。中间的页缓存page cache这一层被跳过了。实际的路径大致是vfs_read→filemap_read通用文件映射读取→ 查页缓存 → 命中则直接copy_to_user未命中则触发回读 → 回读走readahead或read_folio→ 最终进入块设备层。这条路径在mm/filemap.c和fs/read_write.c里交织非常容易跟丢。我的经验是这一层不要一次追到底先把命中缓存这条路走通。命中路径短、逻辑清晰能帮你建立数据在内存里怎么组织的这个概念。回读路径涉及块设备层、IO 调度器、驱动可以留到第二次再深入。3.4 什么时候该停止往下追这是很多人忽略的问题。追调用链很容易上瘾一路追到汇编、追到硬件寄存器但这时候你已经离最初的问题很远很远了。我给自己设的止损点是当这条链路已经能解释我最初提出的那个问题时立刻停手。比如我最初想知道为什么我的程序读这个文件比读另一个慢追到页缓存命中与否、是否触发了预读就足够解释了再往下追块设备调度策略对回答这个问题已经没有增量价值。没有止损点的人往往在追了两天之后既没解决问题也没记住路径。4. 让静态代码跑起来索引、宏展开与运行时对照4.1 clangd compile_commands.json 的实际配置纯靠 grep 和编辑器跳转读内核代码会非常痛苦因为符号重名太严重。clangd是目前体验最好的方案但前提是要有compile_commands.json。内核提供了生成脚本前提是你已经配置并至少编译过一次# 配置一个最小配置 make defconfig # 生成编译数据库需要编译一次否则部分信息缺失 make -j$(nproc) # 生成 compile_commands.json ./scripts/clang-tools/gen_compile_commands.py生成的compile_commands.json放在源码根目录编辑器里的 clangd 插件会自动识别。有了它跳转、补全、查看类型定义都变得准确——clangd 是根据真实的编译参数来解析的宏定义和 include 路径都是对的这一点比 ctags 强太多。要注意的是这套东西占内存不小编译数据库本身也可能有好几百 MB。机器配置一般的话可以只对你重点关注的子系统做索引不一定要全树。4.2 宏展开看不到怎么办内核里宏用得极多有些关键逻辑藏在一层层宏里编辑器跳进去只能看到#define得自己在脑子里展开。这时候有两个办法第一个是让编译器帮你展开。预处理阶段输出就可以看到宏展开后的样子make fs/read_write.i生成的.i文件是预处理后的结果宏都已经替换掉了。缺点是文件巨大、行号信息混乱但用来确认某个宏到底展开成什么样非常直接。第二个是看编译出来的目标文件。反汇编某个函数能看到最终生成的指令比源码更能反映真实行为objdump -d --no-show-raw-insn vmlinux | less # 或者针对单个目标文件 objdump -dr fs/read_write.o.o文件里的重定位信息还能告诉你它引用了哪些外部符号这是静态阅读时很容易漏掉的信息。4.3 ftrace 与 perf 把函数调用变成时间线静态代码告诉你可能走哪些路径ftrace告诉你实际走了哪条路径。这两个东西结合起来才算真正看懂了代码。ftrace的function_graph追踪器能把函数调用画成嵌套的缩进图最直观cd /sys/kernel/debug/tracing echo function_graph current_tracer echo ksys_read set_graph_function echo 1 tracing_on # 此时在另一个终端执行 cat /etc/hostname echo 0 tracing_on cat trace输出会显示ksys_read底下每一层调用的耗时能非常直观地看出哪一层慢、哪一层被跳过。我第一次用这个追 read 链路时发现实际路径跟我读代码时以为的路径差了三个函数原因就是页缓存命中时根本没走到我以为的那个分支。perf则适合做统计层面的观察比如perf top看热点函数perf trace看系统调用层面的耗时分布。它和 ftrace 不是替代关系一个是抽样统计一个是精确追踪配合着用。4.4 QEMU gdb 单步的成本与收益再往下就是单步调试内核了。用 QEMU 跑一个最小系统配合 gdb 连上去可以在任意函数下断点、单步、看寄存器qemu-system-x86_64 \ -kernel arch/x86/boot/bzImage \ -append consolettyS0 nokaslr \ -nographic -s -S # 另一个终端 gdb vmlinux (gdb) target remote :1234 (gdb) b ksys_read (gdb) cnokaslr必须加否则内核地址随机化会让断点地址对不上。-s是开启 gdbstub-S是启动时暂停等待连接。这套组合的收益在于你能看到每一个变量的真实值、每一层调用的真实参数这比任何静态分析都可靠。成本也很明显——启动慢、调试慢、断点打多了几乎没法用。我的用法是只在静态分析卡住的时候用它而且一次只回答一个具体问题。比如这个file指针在这一层的f_op到底指向哪张表用 gdb 打一个断点看一眼问题就解决了比在源码里猜半天强。如果只是想验证某段逻辑还有一种更轻的办法写一个内核模块调用你想验证的函数用printk打出结果加载上去看 dmesg。模块编译成本低、加载卸载快适合做小范围验证。不过要注意有些内核符号因为EXPORT_SYMBOL的限制模块里用不了这时候还是得回到 QEMU。5. 我踩过的四个坑以及每个坑的排查链路5.1 坑一读的代码根本没编进内核症状是我在drivers/下仔细读了一个驱动的初始化流程用 ftrace 追踪时却完全看不到相关函数被调用。第一反应是设备没插检查后发现设备在第二反应是代码路径判断错了于是重新读了一遍还是找不到。排查链路是这样的先回到这个驱动所在目录的Makefile找到它挂的CONFIG_项名称然后去本地.config里 grep发现是# CONFIG_XXX is not set。也就是说这行代码压根没被编译设备是由另一个驱动接管了。这个坑的教训是读任何代码之前先确认它在当前配置下是否参与编译。后来我把这个检查固化成了习惯每次打开一个新文件读之前都先花两分钟看Makefile和.config省下的返工时间远大于这两分钟。5.2 坑二版本漂移导致文章对不上号我早期读代码非常依赖网上搜到的分析文章经常出现文章里说这个函数在fs/xxx.c我在源码里怎么都找不到的情况。一开始以为是自己环境有问题折腾了半天才发现文章写的是好几年前的内核版本那个函数已经被拆到别的文件里了。排查链路比较简单但也费时间先用git log --follow -- 文件名看这个文件的演变历史再用git log -S 函数名 --oneline搜出引入和删除这个函数的提交。这两条命令一跑大概就能知道函数是什么时候被挪走的、挪到哪去了。后来我改成先看git log再搜文章顺序反过来之后被旧文章误导的情况基本没有了。另外Documentation/目录里的文档跟着源码走遇到拿不准的地方优先翻那里。5.3 坑三把 inline 和宏当成普通函数追丢了有次追一个调用链在某个位置看到一个函数调用grep 遍全树都找不到它的定义只有一堆声明。查了很久才明白那是个static inline函数定义在头文件里而且被#ifdef包着。我在.c文件里搜当然搜不到。另一次更隐蔽一个看似普通的函数调用实际上是个宏展开之后是三条语句其中一条改了局部变量的值导致后面的逻辑跟我预想的完全不同。这个是通过看预处理产物才发现gcc -E -Iinclude -Iarch/x86/include ... 某个文件.c | grep -A5 那段代码或者更省事直接make 目标文件.i然后在生成的.i里搜。现在的做法是读到一个不熟悉的函数调用先在include/目录下搜再在预处理产物里确认它到底是不是宏。这两步做完基本不会再追丢。5.4 坑四在数据结构上打转却不知道谁在改它内核里全是复杂的数据结构红黑树、链表、各种嵌套的 struct。我一度陷入把结构体每个字段都搞明白的状态花了好几天读一个调度相关的结构结果看完还是不知道它什么时候被改、被谁改。转折点是有人提醒我数据结构不是静态的它的意义在于被谁修改。于是我换了方法先用git grep 字段名找出所有可能写这个字段的位置再看这些位置分别属于哪些函数、被谁调用。这样一圈下来字段的作用域和生命周期就清楚了比盯着结构体定义看有效得多。这个方法后来成了我的标准动作遇到复杂结构体先找写者再找读者最后才回来看定义。6. 时间怎么分配一份可执行的推进节奏6.1 第一周只做环境与索引别急着读代码。第一周全部用来搭环境拉源码、编译一次、生成compile_commands.json、配好 clangd、跑通一个 QEMU 启动、确认 ftrace 能用。这些准备工作看着枯燥但后面每一天都要用到越早做完越省事。同时做另一件事把源码树的顶层目录过一遍每个目录写一句话说明它管什么。不用深入知道crypto/是密码学算法、security/是安全模块框架、lib/是通用库函数就够了。这一页笔记后面会反复用到。6.2 第二到第四周锁死一个子系统整个学习周期里最容易犯的错是这周看内存下周看调度再下周看网络结果每块都只看了个开头。三周时间锁死一个子系统把它从入口到出口走通一遍收获远大于到处撒网。具体节奏可以这样安排第一周把抽象层读完理清接口和分流逻辑第二周往下追一个具体实现追到底第三周用 ftrace 和 gdb 验证前面两周的结论把读错的地方修正过来。第三周往往最痛苦因为会发现自己前两周理解的路径有好几处是错的但这正是学习真正发生的地方。6.3 长期把改动提交当成阅读笔记读到一定程度就会发现单纯看代码记不住。我的办法是给自己找点小改动做改一个日志输出、加一个参数的合法性检查、修一个明显的边界条件问题。改动不用发给上游自己本地跑通、测试过就行。这个过程的价值在于它逼着你把代码真正读懂——你得知道变量类型、知道锁在哪加、知道改动会影响哪些调用方。读十遍代码不如改一次这话在阅读内核源码这件事上完全成立。另外git log本身就是极好的学习材料。挑一个你熟悉的函数看它历史上被改过多少次、每次为什么改、提交信息怎么写的能学到大量设计层面的东西。很多设计取舍在代码注释里看不到但在提交信息里写得清清楚楚。最后分享一个我自己一直在用的小技巧每读完一条完整的调用链就用几句话把它复述出来写在自己看得见的地方比如用户态 read → ksys_read → vfs_read → 判断 f_op → 进具体文件系统 → 查页缓存 → 命中则拷贝返回。写不出来说明还没读透回去重读。这个复述动作看着简单但它是检验是不是真懂了最便宜的标尺。