读内核源码从哪儿下手:别从开头的第一行开始

难度 ★★★★★ · 前置:把 socket 封成类:一开始觉得多余,后来知道它能省两千行 | 一句话结论:读内核源码必须带一个具体问题进去,然后从「系统调用表 → 具体实现 → 它调用的下一层」这条路走。Documentation/ 有 15 万行文档,比代码有用;git log 比注释有用;scripts/get_maintainer.pl 比搜索结果有用。

十分钟之后停在一个函数指针上

入职第一年我想搞清楚一件事,write() 到底什么时候才真的返回,于是 cd /usr/src/linux,打开 fs/read_write.c,找到 SYSCALL_DEFINE3(write, ...),看了一眼觉得不过是个两行的壳子,就往里跳了,当时我天真地以为顺着调用链走下去,最多半小时就能把整条路摸清楚,完全没意识到自己正走进一个有上千个实现的目录结构里。那种感觉有点像拆一台从没见过的机器,螺丝一颗颗拧下来都很顺利,等你抬头想装回去,才发现地上摊着的东西已经超出了你的记忆范围。

1
2
3
4
5
SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf,
size_t, count)
{
return ksys_write(fd, buf, count);
}

跟着跳进 ksys_write,再跳进 vfs_write,再跳进 file->f_op->write_iter,十分钟之后我停在一个函数指针上,既不知道它指向哪,也不知道自己站在第几层。

那天晚上我折腾到一点多,最后在 vfs_write 那里彻底迷路,关掉终端去睡觉,第二天上班还在想这件事,一度怀疑是自己 C 语言功底不行。后来才明白这跟功底一点关系都没有,是我从第一行开始就没想好要找什么。其实内核里每个函数都长得规规矩矩,真正难的是那些不在代码里的东西,谁调的它、什么条件下调的、失败了会怎样,这些信息全散在别的文件和提交记录里,光盯着一个 .c 看是看不出来的。(后来我才知道,几乎每个刚入职的人都在同一个函数指针面前停过,区别只在于有人停完就走了,有人停下来问了句为什么。)

后来才明白问题出在问法上。

正确的问题不是 write 怎么实现的,而是我写了 1KB,write 返回 1024,机器断电之后这个文件还在不在,这个问题一问出来,要查的东西立刻变得具体,vfs_write 之后有没有调 sync、generic_write_sync 的触发条件是什么、O_SYNC 又是怎么传到 iocb->ki_flags 里的,五个文件就能回答,不用读完全部。

六个目录,一个该先看

include/ 排在第六层,但它才是该先看的,所有结构体的定义都在那里,看懂了结构体,函数在做什么就猜得到一半。我后来读任何新子系统都是先翻 include/linux/ 下对应的头文件,看完再回头看 .c,速度能快一倍不止,因为 .c 里那些长得吓人的函数签名,一半的信息其实在头文件的注释里,另一半在结构体成员的名字里。这个习惯是被人骂出来的,有次我在代码评审里把 struct iov_iter 的字段搞错了顺序,被同事在群里贴了头文件原文,从那以后我再也不敢跳过 include/。

其余四个目录在排查具体问题时很少用到,drivers/ 算半个例外,你要查的如果是网卡固件或者磁盘阵列,那就得进去。

一个请求穿过内核的完整路径

以 read(fd, buf, 100) 为例。

层 文件 干什么
系统调用入口 arch/x86/entry/syscalls/syscall_64.tbl 第 0 号是 read
分发 fs/read_write.c 的 ksys_read 参数转换
VFS 层 fs/read_write.c 的 vfs_read 权限检查、调 f_op
具体文件系统 fs/ext4/file.c 或 fs/overlayfs/file.c 决定走页缓存还是直读
页缓存 mm/filemap.c 的 filemap_read 查缓存,未命中就预读
块层 fs/ext4/inode.c 的 ext4_map_blocks 逻辑块号 → 物理块号
驱动 drivers/block/... 提交 IO

这张表就是读源码的地图,遇到任何文件操作从 fs/read_write.c 开始,遇到任何网络操作从 net/socket.c 开始,遇到任何内存问题从 mm/mmap.c 或 mm/memory.c 开始,这三个入口我抄在便利贴上贴在显示器边上贴了小半年,撕下来的时候胶印还在。表里最右边那列看着像凑数的说明,其实是整张表里最值钱的部分,因为它把每一层的职责压缩成了一个动词,读源码卡住的时候回头看一眼那一列,往往就知道自己跳到别的层去了。

这三个入口文件覆盖了 80% 的排查场景。

Documentation 被严重低估了

1
ls /usr/src/linux/Documentation/ | head -20
1
2
ABI  admin-guide  arch  block  bpf  cgroup-v2.rst  core-api
crypto dev-tools driver-api filesystems gpu hid

Documentation/admin-guide/ 里有大量 sysctl 的真实语义,比网上搜到的文章准确得多。我最初以为这层文档是给发行版打包的人看的,跟日常排查没什么关系,直到有次照着一篇博客改参数改出问题,翻回原文才发现博客里少写了最关键的一句话,而那句话就在文档的第三段里,一个字都没被引用过。(那篇博客的阅读量还不低,评论区一堆人说照着改就好了,我猜他们只是没跑到出问题的那条路径上。)

最该读的几份。

文档 讲什么
admin-guide/sysctl/net.rst 每个 net.* 参数的准确含义
admin-guide/sysctl/vm.rst 内存相关参数
admin-guide/mm/transhuge.rst 透明大页
filesystems/proc.rst /proc 每个文件的格式
networking/ip-sysctl.rst 最详细的一份,几百个 TCP 参数
bpf/ eBPF 的官方文档

networking/ip-sysctl.rst 三千多行,每个参数都有这个值怎么影响行为的说明,想搞懂 tcp_tw_reuse 到底做什么,看这份比看博客准,博客会告诉你怎么调,文档会告诉你为什么,两者差的那一部分通常就是你三个月后踩的坑。我把这份文件拷到本地建了个 ripgrep 索引,排查网络问题的时候先搜它再动配置,一年下来省掉的回滚次数大概比省掉的查询时间更值钱,毕竟改错一个 TCP 参数要等下一个连接周期才看得出来,而那时候你早就去干别的了。

我最早是被一个 tcp_max_syn_backlog 的默认值坑过一次,之后才开始习惯先翻 Documentation/ 的,那一次改完压测完全通过,线上却还是丢连接,原因就藏在文档里没人转述的第二段。

git log 比注释有用

内核的注释经常过时,注释写的是当时的样子,代码早就换了两代,但提交信息通常写清了为什么这么改,而理由这种东西不会随时间失效。

1
git log --oneline --no-merges -- fs/read_write.c | head -20

找某个函数的来历。

1
git log -L :ksys_write:fs/read_write.c

-L 会显示这个函数的完整变更历史,包括它最初叫什么、为什么改名、修过什么 bug,我第一次拉出 ksys_write 的完整历史时有点意外,这个看起来平平无奇的函数前后被改过十几次,每一次都跟着一个具体的线上故障,注释里一个字都没提。

1
git log -S "tcp_tw_reuse" --oneline -- net/ipv4/

-S 是字符串出现次数变化,能找到引入和删除某个标识符的提交,比 grep 稳,因为它找的是变化量,不是当前状态。

这两个命令比 grep 代码有用得多,它们能告诉你为什么,而不只是是什么,读内核读到最后你会发现,知道为什么的那部分才值钱,剩下的部分照着调用链抄一遍就有了。-S 还有一个不那么显眼的用法,把关键词换成一个错误码或者一条日志字符串,就能找到当时引入这个判断的提交,很多线上疑难的最后一条线索都是从日志原文倒推回提交的,我试过两次,两次都省下了原本要花一整天的大范围二分。

第一步:定位系统调用号

所有系统调用都在一张表里。

1
2
grep -n '^[0-9]*\s\+\(read\|write\|openat\|epoll_wait\)\s' \
/usr/src/linux/arch/x86/entry/syscalls/syscall_64.tbl
1
2
3
4
0   common  read            sys_read
1 common write sys_write
257 common openat sys_openat
232 common epoll_wait sys_epoll_wait

这张表就是编号到函数名的映射,一行一个,长得像配置文件,但它是整个 syscall 世界的户口本,你想知道任何一个 syscall 从哪进来,都只能从这一行开始。表里第三列和第四列还藏着一个细节,common 表示这条在所有 ABI 下都有效,而 64 只对 x86_64 生效,x32 是给那套带高位标记的 32 位兼容层用的,同一行往上翻几十行你就会看见这三种前缀混着出现,我刚开始读的时候拿不准一个调用到底走哪一条,就是靠这一列对出来的。

注意是 openat(257)而不是 open,现代代码几乎都用 openat,open 只在极老的代码里出现,这就是为什么 strace 输出里全是 openat,也是为什么你在源码里搜 SYSCALL_DEFINE3(open 什么都搜不到,白白浪费掉半小时。

第二步:SYSCALL_DEFINE 宏展开

1
2
3
4
SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count)
{
return ksys_read(fd, buf, count);
}

这个宏展开之后是 __x64_sys_read(const struct pt_regs *regs),负责三件事。

  1. 从 regs 里取参数(依次是 di/si/dx/r10/r8/r9,rax 是系统调用号,不是参数);
  2. 调 __se_sys_read,也就是带符号扩展的那一层;
  3. 再往下才是 ksys_read。

SYSCALL_DEFINE0 到 SYSCALL_DEFINE6 对应 0~6 个参数,看到 SYSCALL_DEFINEn 就知道这是系统调用的入口,不用再找了。这个规律我一开始以为是编译器约定,后来对着 syscall_64.tbl 数了一遍才发现是表和入口宏必须严格对齐,两边差一个参数,编译能过,运行必崩。

第三步:跟着函数指针走

vfs_read 里最关键的一行。

1
ret = file->f_op->read_iter(kio, iter);

这就是 VFS 抽象的实质,f_op 是 struct file_operations,由具体文件系统在 open 时填好,userspace 看到的 read 走到这里就变成了一个纯函数指针调用,后面发生什么完全取决于你打开的是哪个文件。这一行是我理解整个 VFS 的分水岭,想通之后就明白内核为什么能同时支持几十种文件系统却不被它们拖垮,因为它只认这张表,不认实现。

怎么找它指向哪,静态的路子是三步。

  1. grep -rn "ext4_file_operations" fs/ext4/ 找到定义;
  2. 里面 .read_iter = ext4_file_read_iter;
  3. 跳到 fs/ext4/file.c。

或者用动态方式,更可靠,因为它不依赖你猜对了哪个符号。

1
2
3
sudo bpftrace -e 'kprobe:vfs_read {
printf("%s %s\n", comm, kstack);
}'

但看函数指针目标最快的办法还是 grep,内核里每个 xxx_file_operations 都只定义一次,搜到定义就等于搜到了所有可能的实现,这种唯一性比任何索引工具都好用。

第四步:__user 与 copy_from_user

这是内核和用户态之间的边界。

1
2
static inline unsigned long __must_check
copy_from_user(void *to, const void __user *from, unsigned long n)

__user 是个标记,告诉 sparse 静态检查器这个指针来自用户态,直接解引用它会导致任意地址读写,这是内核漏洞最常见的来源,每年 CVE 里都有一批补丁长得几乎一样,改的就是少写了一个 __user。

所以内核代码里所有碰用户数据的地方都要 copy_from_user、copy_to_user、get_user、put_user。

看到哪个系统调用忘了 copy_from_user,那就是漏洞。

一个真实的漏洞模式

1
copy_from_user(kbuf, buf, n);            // 正确

对比。

1
memcpy(kbuf, buf, n);                    // 错误:直接解引用用户指针

前者不是一次简单的内存拷贝,它走的是这条路,先 might_fault() 确认当前上下文能不能缺页,再 access_ok() 判断地址范围(只看用户空间上限,不查页表),过 check_copy_size() 这道对象大小检查,最后 raw_copy_from_user() 在 SMAP 保护下完成拷贝,短拷剩下的字节会被 memset 补零。这四步里的每一步都能单独出问题,我见过有人把 access_ok 的参数顺序写反,编译器一句警告都不给,因为两个参数都是指针,类型一样,跑起来也能过,只有在特定的地址上才会被拦下来。

SMAP(Supervisor Mode Access Prevention)在硬件层面禁止内核直接访问用户页,忘了 copy_from_user 的代码在开了 SMAP 的机器上会直接崩溃,而崩溃的位置往往离 bug 很远,栈上看不出跟用户指针有什么关系。

这就是为什么这类 bug 在测试机上偶尔复现、在生产机必现,因为压测机器的内核配置不同,SMAP 一开一关,同一段代码就是两种命运。(我们那批测试机到现在还关着 SMAP,理由是开了之后一批老用例全挂,没人愿意去改用例,于是大家都假装这个开关不存在。)

怎么知道某个功能是哪个版本引入的

1
git log --all --oneline -S "tcp_tw_recycle" -- net/ipv4/tcp_ipv4.c

或者查 Documentation/ 里的版本标记。

更实用的办法是查 changelog。

1
curl -s https://cdn.kernel.org/pub/linux/kernel/v6.x/ChangeLog-6.1 | grep -i 'tw_recycle'

cdn.kernel.org 上有每个版本的完整 changelog,一行一个提交,能直接搜关键词,比翻 git 历史快得多。

这是判断我这台机器的内核有没有这个功能或这个修复最快的办法,尤其适合你手上只有一台生产机、又不能随便重启的时候。

用 get_maintainer.pl 找对的人

1
./scripts/get_maintainer.pl fs/read_write.c
1
2
3
Alexander Viro <viro@zeniv.linux.org.uk> (maintainer:VFS)
...
linux-fsdevel@vger.kernel.org (open list)

如果你想提 patch,或者只是想找某个子系统的权威文档,这个脚本直接给出维护者和邮件列表,比搜 Linux VFS 维护者是谁准得多,也省掉了在邮件列表里发错板块被人提醒一次的尴尬。

拿到一份内核源码

1
2
3
4
5
6
7
8
9
# 方法一:用发行版的源码包(快)
apt-get source linux # Debian/Ubuntu,需要 deb-src
sudo apt install linux-source-$(uname -r | cut -d- -f1)

# 方法二:github(全,慢)
git clone --depth 1 https://github.com/torvalds/linux.git

# 方法三:只抓某个版本
wget https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.1.tar.xz

方法二的 --depth 1 很重要,完整仓库有 5GB 以上历史,--depth 1 只有 200MB,差了一个数量级。

但注意,--depth 1 之后不能用 git log -L 和 git log -S,那是查历史用的,要读历史就完整克隆,要读代码就浅克隆,这个取舍没有中间路线。

我第一次克隆就是用 --depth 1,后来想查一个函数的来历,只能删了重来。(那次之后我学乖了,代码和历史的目录分开存,硬盘不够就删别的。)

先看版本号

1
uname -r
1
6.1.0-18-amd64

这里的 6.1 是 upstream 版本号,0-18-amd64 是发行版自己的,读源码时用 upstream 的 6.1,发行版的补丁通常不影响理解主干逻辑,但会影响你复现 bug 的成功率,这一点我吃过亏,同一个调用在两个版本里的行为能差出一次缺页。

从系统调用号找到实现

1
grep -rn 'SYSCALL_DEFINE3(read' /usr/src/linux/fs/
1
fs/read_write.c:  SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf,

一行定位。这就是从入口进的效率。

用 cscope 或 ctags 跳转

1
2
sudo apt install cscope universal-ctags   # 包名是 universal-ctags,ctags 只是它提供的虚拟包
cd /usr/src/linux && ctags -R --c++-kinds=+p --fields=+iaS --extras=+q .

vim 里 Ctrl-] 跳到定义,Ctrl-t 跳回,三千多万行的代码库,没有索引工具是没法读的,光靠 grep 你会把时间全花在等磁盘 IO 上。

或者用 clangd 加 VS Code,索引更准,能处理宏展开。

用 git log -L 看一个函数的完整历史

1
git log -L :ksys_write:fs/read_write.c --oneline | head -40

会看到这个函数每一次修改的 diff,里面通常有关键的提交信息。

1
2
commit 0e5b1f1c  fs: remove ksys_write
...

这条途径能回答为什么这里这么写,而这往往是代码里根本没注释的。

看一个 sysctl 参数的准确定义

1
grep -rn 'tcp_tw_reuse' /usr/src/linux/net/ipv4/sysctl_net_ipv4.c

会找到它的注册项,包括默认值和处理函数。

要理解它的语义,再看 Documentation/networking/ip-sysctl.rst。

1
grep -A 20 'tcp_tw_reuse' /usr/src/linux/Documentation/networking/ip-sysctl.rst

代码加文档对着看,比只看代码准。

用 bpftrace 验证理解

读完 vfs_read 想验证。

1
2
3
4
5
6
7
8
9
# arg0 要当 struct file * 用,前提是内核开了 CONFIG_DEBUG_INFO_BTF;
# 现代发行版基本都开,自制内核没开的话 bpftrace 会直接报「unknown struct」
sudo bpftrace -e '
kprobe:vfs_read
{
printf("pid=%d comm=%s file=%s count=%lu\n",
pid, comm, ((struct file *)arg0)->f_path.dentry->d_name.name, arg2);
}
'

没开 BTF 也不是完全没法验,把结构体那部分去掉,只打指针地址,再用 crash 或者 /proc/kallsyms 那边去对,麻烦但能走通:

1
sudo bpftrace -e 'kprobe:vfs_read { printf("file=%p count=%lu\n", arg0, arg2); }'

这段代码我没在自己机器上跑过,因为这台机器上没有内核头文件,BTF 也没开。

这条我得说清楚。

写在这儿是给你一个可以照着改的起点,arg0 到底指向 struct file 还是别的,取决于内核版本和这个符号是不是内联过,先 sudo bpftrace -l 'kprobe:vfs_read' 确认符号在,比直接抄稳。

这是读完源码立刻验证的闭环,只读不验证,理解很可能是错的,因为你在纸上推演的时候永远不会发现自己漏掉了一个分支,而那个分支恰好就是线上出问题的那个。(我第一次挂这个探针的时候 arg2 打出来是个天文数字,愣了半天才想起来 read 的第三个参数是 count,而 kprobe 看到的寄存器顺序跟 syscall 表里的顺序不是一回事。)

用 make 看编译了哪些文件

1
2
3
4
cd /usr/src/linux
make defconfig
make prepare modules_prepare # 没有 generated/autoconf.h 直接编 .o 会报错
make fs/read_write.o

单独编一个 .o 文件很快,能看到它依赖哪些头文件,make V=1 会显示完整命令行。

这比读 Makefile 快得多。

三千多万行,不能从头读

读源码要从头开始,这个说法我试过一次,三千多万行,从头读到第五章你就放弃了,而且放弃的时候会有一种自己不适合做这行的错觉。正确的做法是带问题进入,从入口往下,读到你那个问题的答案为止,然后停手,剩下的部分留给下一个问题。

源码里的注释可信,这句也不成立。内核注释经常过时,写的是当时的样子,代码早就换了两代,git log -L 和 Documentation/ 更可信,前者给理由,后者给现状,两个对一下基本不会有偏差。

内核代码很优雅,大部分是,但有大量为了历史兼容保留的丑代码,sys_olduname、__ARCH_WANT_SYS_* 这些宏就是活化石。看到奇怪的代码先查 git log,别自己推理解释,我干过一次,写了一千多字的错误分析,最后发现那三行是给某个还在跑 2.6 内核的老设备留的后门。

必须从头编译内核才能读,不用。读只需要源码加索引工具,只有要验证改动或者抓崩溃现场时才需要编译,而完整编一次内核的时间够你把这一篇从头到尾读三遍。

Documentation/ 很旧,有些是,sched-design-CFS.rst 之类还停在 CFS 时代,但 admin-guide/ 和 networking/ 维护得很勤,几乎每个版本都在改,所以别一棍子打死,按目录看。

sysctl 参数的默认值在源码里能查到,能,但发行版会在 /etc/sysctl.d/ 里覆盖,实际值永远看 /proc/sys/,源码里的默认值只对你自己编译的内核有效。

看源码能解决性能问题,通常不能。性能问题先看 perf 和 bpftrace 的数据,源码只是用来解释数据,顺序反过来就是拿放大镜去找一根根本没插好的线。

内核版本越新越好读,代码确实更清晰了,但每版都在重构,mm/ 和 io_uring 变化尤其大。读源码前先确定版本,别拿 5.x 的博客对着 6.6 的代码看,那会白白烧掉你一整周的晚上。

带着问题读,别带着目录读

  • 读内核源码必须带一个具体问题,问题越窄,需要的文件越少。
  • 路径是:系统调用表(arch/x86/entry/syscalls/syscall_64.tbl)→ 入口(SYSCALL_DEFINEn)→ ksys_* → VFS 或具体实现 → 下一层。
  • 三个万能入口:fs/read_write.c(文件)、net/socket.c(网络)、mm/mmap.c 与 mm/memory.c(内存)。
  • Documentation/ 有 15 万行,networking/ip-sysctl.rst 是最详细的参数文档。
  • git log -L :func:file 看函数的完整变更史,git log -S "字符串" 找引入和删除点。它们回答「为什么」,grep 只能回答「是什么」。
  • __user 标记和 copy_from_user 是内核与用户态的安全边界,绕过它就是漏洞(开 SMAP 的机器直接崩)。
  • 用 ctags、cscope、clangd 建索引,三千多万行没有跳转工具是读不动的。
  • 读完要验证:bpftrace 挂 kprobe 打一行,比在纸上推演可靠一百倍。

源码读到这里,还是有个过不去的坎,想观察内核里某一刻究竟发生了什么,要么改代码重新编译,要么挂 strace 把进程拖慢十倍,这两条路其实都不算路。接下来讲 eBPF,它解决的正是这个问题。