首页 / 资讯中心 / 文章详情

Linux内核学习:先建立心智模型,再读源码

Linux内核学习:先建立心智模型,再读源码 ★ FEATURED ARTICLE
我第一次认真读内核源码是在一个项目里被派去调一个串口驱动的偶发卡死问题。那会儿我自认为对Linux很熟日常命令、shell、网络排查都能玩可真到了打开drivers/tty/serial目录开始翻代码的时候整个人是懵的一个uart_port结构体牵扯出十几层调用读不到五层就开始忘前面在干嘛最后变成了这里搜一下、那里点一下的盲人摸象。后来带我的人一句话点醒了我你不是代码量不够你是脑子里没有一张内核地图。从那以后我才意识到学Linux内核最忌讳的就是一上来就盯源码。你得先在脑子里把内核是什么、它靠哪些抽象活着、各个子系统之间是什么关系这件事想清楚然后再去读代码每一行才会各就各位。这篇是Linux内核专栏的第一篇不打算直接扔源码先聊心智模型和设计哲学。所谓心智模型说白了就是你对一个复杂系统在大脑里建立的那个简化但正确的运作图景。它不需要覆盖每一行代码但必须让你拿到一个问题时能立刻判断出这个问题的入口在哪、它会牵扯到哪几个子系统、大概的处理思路是什么。1. 内核在你脑中应该长什么样先画地图再走迷宫1.1 没有地图时我看内核代码的真实状态很多人学内核的路径是买了一本经典书打开第一页从进程管理开始读读到进程调度就想追进kernel/sched/core.c然后一头扎进schedule()、pick_next_task()、各种调度类三五百行之后彻底迷失。我当年就是这样。读代码读得很认真笔记也记了一堆但合上书连这个调度器为什么要跟时钟中断配合工作都想不利索。原因很简单我看到的全是树的枝叶没有树的骨架。真实的内核源码有数千万行没有哪个人是靠顺序全部读懂的。老手读源码时本质是先带着一张主干地图按图索骥地往细了走走到哪一层需要深究就停在哪一层。所以学习Linux内核的第一步不是读源码是先让自己能闭上眼睛说出这个系统由哪几块构成、谁来衔接谁。1.2 一张可以放进脑子的系统分层图如果只允许用一张图来概括Linux那应该是用户态与内核态的分界以及内核里面几个核心子系统之间的协作关系。这里不再画那种动辄几十行的架构图用最朴素的方式描述用户态你写的C程序、Python脚本、shell命令都在这里跑。它们不能直接碰硬件只能通过系统调用请求内核帮忙。内核态进程调度、内存管理、文件系统、网络协议栈、设备驱动都在这里拥有直接操作硬件的特权。系统调用是两界之间的门卫比如open()、read()、write()、mmap()你的每个用户态操作都会经过它。中断是硬件反向通知内核的通道属于异步入口。内核内部则大致可以分成这几个互不绕开的角色子系统它回答什么问题核心文件目录主要对谁服务进程调度下一个该跑谁、什么时候切换kernel/sched/所有用户进程、内核线程内存管理每个进程怎么看待地址空间、物理页如何分配mm/所有要分配内存的组件VFS 虚拟文件系统如何统一地读写文件、设备和伪文件fs/用户态的每一个文件操作设备驱动怎么指挥具体的硬件干活drivers/硬件与内核其余部分网络协议栈数据包如何进出、如何路由net/用户态socket调用进程间通信进程之间怎么同步、传递数据kernel/ipc/、管道等用户态程序这张表不需要背需要用的时候能快速定位就行。真正的价值在于当你遇到一个实际问题你能先把它塞到某一格子里再顺着格子之间的连线去找根源。比如文件读写特别慢你至少要在VFS、页缓存mm/、块设备驱动三个格子里转一圈而不是直接打开某个设备驱动盲查。1.3 一次系统调用的完整穿行暴露了子系统的耦合真相这些子系统并不是孤岛。举一个最常见的例子一个C程序调用printf(hello)底层发生了什么printf()在用户态格式化字符串后会调用write()系统调用。进入内核后文件描述符会先路由到VFS层VFS根据你打开的文件类型去调用对应文件系统或者设备驱动的写方法。如果写的是普通磁盘文件数据会先进入页缓存由内存管理子系统负责在合适时机把脏页刷到块设备驱动最终由驱动程序发出磁盘I/O指令。就这么一句写一个文件前后穿过了系统调用层、VFS层、文件系统层、页缓存、块设备层。哪一环慢了都会表现为写文件慢。这就是我强调心智模型的直接原因一旦你脑中有了这条链路性能问题来了你会本能地从每一段的边界上去定位而不是漫无目的地抓瞎。没有地图时你在迷宫里的每一步都是运气运气差一点一个星期都绕不出来。2. 一切皆文件不是口号而是最结实的抽象墙2.1 从/proc和/sys认识文件这个万能比喻Linux设计哲学里最出名的一句话就是一切皆文件。很多初学者当段子听其实它是理解整个内核的一把钥匙。你看系统的运行状态用的不是专门定制的什么管理接口就是cat、echo、ls这几把板斧cat /proc/cpuinfo cat /proc/meminfo ls /sys/class/net/eth0/ echo 1 /sys/class/leds/led1/brightness/proc下的文件并不是磁盘上真实存在的文件它们的read操作最终会去内核里现算一段数据返回给你/sys下的文件则大多直接映射到设备驱动里的一个属性。也就是说内核把系统信息查询和设备参数调整这两类完全不同的工作统一伪装成了读文件和写文件。这个伪装有巨大的现实收益用户态不需要为每个硬件发明一套新APIshell里积累了几十年的管道、重定向、grep、awk工具链全部可以复用。你不需要装什么特殊软件一行cat /proc/zoneinfo就能拿到内存管理子系统内部的分区情况。同一个心智模型可以用在所有文件类操作上学习成本一下子被压得很低。2.2 设备驱动也长成文件的样子认识file_operations如果一切皆文件只停留在系统状态展示那还不够核心。真正的关键在于一个硬件设备在内核里也是通过文件的方式暴露给用户空间的。比如你自己写一个字符设备驱动注册好设备号、创建好设备节点以后用户态程序就可以对它执行open()、read()、write()、ioctl()。驱动一侧要做的事情就是填充一个file_operations结构体static struct file_operations my_fops { .owner THIS_MODULE, .open my_open, .read my_read, .write my_write, .release my_release, };然后把这个结构体挂到设备注册的接口上。内核不知道也不关心你背后是串口、是GPIO还是什么稀奇古怪的自研芯片用户态拿到的都是一个文件描述符操作它就跟操作普通文件的感觉几乎一样。我后来写嵌入式Linux驱动越来越觉得这套设计简直是为可维护性而生设备驱动作者不需要向用户态提供一套私有协议只需要把操作逻辑拆成打开、读、写、关闭这几件小事用户态的同学看一眼设备节点的读写约定就能上手。对比一下Windows和传统RTOS那套每个设备一套API的风格Linux的这一招确实高明。2.3 统一抽象也有让位的地方网络和文件之间的裂缝一切皆文件并不是铁板一块最典型的例外在网络。socket虽然也可以用文件描述符来操作但它不是一个普通的文件。read()一个socket和read()一个磁盘文件语义差异很大文件有位置指针可以lseek()来回读网络流是一串连续字节没有往回读这回事lseek()一个socket会直接报错。所以网络栈在内核里有自己的一套API和一套缓冲管理逻辑net/目录下的代码几乎不怎么依赖VFS层。这个例外是很重要的心智模型细节抽象统一是设计倾向不是为了统一而统一。内核真正追求的是在合理的地方用尽量少的机制完成尽量多的事因此当你看到某个子系统没有强行套进文件模型时不要惊讶那是为了性能和语义正确性故意留下的裂缝。理解到这一层你对内核那几千个接口函数就不会再有一种为什么到处不统一的别扭感了。3. 中断驱动的世界内核的大部分脾气都由它定3.1 用接待客人的餐厅理解上半部和下半部用户态的程序逻辑是线性的开了门再说一步接一步。但内核对硬件事件的处理不是这样的它必须随时准备被打断。想象你在后厨做菜炉子上的汤烧开了你当然可以选择关火、专心切完手里的菜、再回来管汤。但硬件不行网卡收到一个数据包、键盘被按了一下、串口进来一个字符这些事情都有时效性如果不尽快处理数据就会丢。所以内核必须允许硬件随时打断手头的工作这就叫硬件中断。中断处理是有讲究的。你不可能在中断里花很长时间做复杂的事因为中断打断的可能是任何一个进程的代码你占用的CPU时间都是从别人那里抢来的。于是内核把中断处理拆成了两段上半部hardirq动作快、只记录必要信息、把真正耗时的活儿登记到待办清单然后马上返回。下半部softirq/tasklet/workqueue等系统没那么紧张了再慢慢处理这些登记的任务。类比起来就是有人按门铃你先把门打开、快速看一眼是谁、把名片放进一个盒子、关门等有空了再翻名片逐一回访而不是堵在门口跟对方聊十五分钟。这个快速登记、迟延处理的节奏贯穿了整个内核的I/O路径。3.2 为什么内核规定中断上下文不能睡眠这是内核里最著名的一条纪律也劝退了无数刚入门的驱动学习者。要理解它得回到心智模型的层面中断上下文不是一个可以被调度器管理的过程。普通内核线程或用户进程可以睡眠是因为调度器知道当前是谁在跑它可以把CPU切给别人等事件完成了再把你叫醒。中断处理程序没有这个身份它不是调度器的合法成员。如果中断处理程序试图睡眠它连把CPU让出来之后将来怎么被找回来这条退路都没有更别说大量睡眠锁依赖进程上下文来维护唤醒队列。所以内核开发者写代码时养成了极强的边界意识一旦发现自己站在中断上下文里碰都不能碰会睡眠的东西。举一个我自己踩过的坑写一个按键输入驱动的中断处理函数图方便在里面调用了一个加锁的函数那个锁恰好是mutex一旦在中断里获取不到锁休眠等待整个系统开始出现诡异的卡顿和死锁。排查了一整天才定位到这句不起眼的调用。3.3 驱动设计里被中断塑造出来的习惯如果每天都和中断打交道你会自然形成几个条件反射比如中断处理函数里尽量只置标志位、唤醒等待队列、提交工作项。耗时的处理外包给workqueue或者内核线程。中断服务函数注册时能用线程化中断就用线程化中断request_threaded_irq把真正业务逻辑放进一个可以睡眠的线程化上下文里。一个简单的示例static irqreturn_t my_irq_handler(int irq, void *data) { schedule_work(my_work_struct); return IRQ_HANDLED; }schedule_work()只负责把活儿扔给系统工作队列然后立刻返回真正需要花时间的逻辑放在my_work_func()里执行。这个模式几乎是驱动书籍里的标准答案背后的原因也正是中断上下文那套不能睡、不宜久留的心智模型。记住这个模型你读内核里大量以_bh、irq、schedule_work为后缀的代码时就不会再对为什么这里不等一等、不直接干完产生疑问了。它们不是怂而是尊重内核的时间节奏。4. 并发与锁先问谁在同时跑再决定拿哪种锁4.1 四个并发来源让内核代码永远在打架边缘真正让内核代码难写的不是C语言本身而是并发。用户态写多线程程序时你只需要担心其他线程会不会同时访问同一份变量。内核里的情况复杂得多多核CPU并行、中断随时插入、调度器抢占、内核线程自说自话地后台运行这四路并发随时可能同时读改写一份全局数据。我经常跟团队里的新人说在做任何内核开发之前先问自己一个点名问题这一行代码执行的时候有哪些CPU、哪些路径、哪些中断会同时摸到这份数据如果能答上来再去决定要不要加锁答不上来就去用静态分析、lockdep、代码审查把这些并发路径揪出来。很多人设计的第一个内核模块就是在全局链表中自由地增删节点。单核环境下跑得好像还不错一放到多核环境偶发崩溃、数据错乱。病因基本就是两个CPU同时修改链表指针彼此覆盖了对方写的值。锁/机制能不能睡眠适合场景中断上下文可用spinlock不能临界区极短可以配合irq版本mutex可以临界区较长、会阻塞不可以rwlock不能读多写少可以RCU读侧不睡读极多、写极少读侧通常可以原子变量不涉及简单计数、标志位可以这张表不是一个背道填空题而是在告诉你每一种锁背后都是对我可以付出多少阻塞代价的取舍。需要快速互斥、不能睡就选自旋锁临界区可能要等I/O就选互斥锁极端的读多写少场景RCU能让你几乎无锁地读到旧值换来极端性能。4.2 从一次性能事故理解锁粒度的重要性我参与过一个网络数据处理模块最开始实现图省事所有的会话状态共用一把大锁任何一次会话查询和更新都要穿过它。功能是完全正确的但压测一上来吞吐量腰斩所有CPU都在等同一把锁多核优势彻底消失。后来把一把大锁拆成了按会话哈希桶分片的几十把锁每个桶一个自旋锁只在哈希命中的那一刻短暂持锁。改造完的吞吐量翻了一倍不止。我举这个例子是想说锁不光是有无的问题还是粒度的问题。你选的锁类型决定了并发窗口有多大你临界区写得多秀决定了并发窗口存在多久。这两件事是内核心智模型里比怎么用API更重要的修炼。4.3 让 lockdep 当你的并发老师如果你开始写内核代码我想建议第一件事就是打开CONFIG_PROVE_LOCKING也就是我们常说的lockdep。它会在内核运行过程中自动追踪每个锁的获取顺序一旦发现可能的死锁环形依赖立刻打印一大段包含调用栈的警告。这玩意儿对学习心智模型的价值在于它能把你脑子里的并发路径图自动补全。你某天写了两段代码一段在A锁下拿B锁另一段在B锁下拿A锁你运行一百次可能都触发不了死锁但lockdep会在第一次运行到交叉点时就报警。我后来做代码评审只要涉及锁第一句话就习惯性问lockdep有没有跑过这个习惯帮我躲掉了好几次线上事故。5. 虚拟内存心智模型每个进程都觉得自己独占一台机器5.1 没有虚拟内存世界会乱成什么样进程眼里看到的内存空间和物理内存的真实排布是两回事。每个用户态进程都觉得自己拥有一个从0开始、几乎连续且私有的地址空间但对内核来说那只是一张虚拟地址到物理页的映射关系。如果直接拿物理内存给进程用问题立刻扑面而来碎片化昨天内存散落成小块今天想给新进程分配一片连续的大块都不够互相踩踏两个进程没有隔离指针算错一位就可能改掉别的程序的数据安全性更是无从谈起任何程序都能扫描别人的数据。虚拟内存用一张页表把虚拟地址映射到任意物理页。进程以为自己在读写一块连续的内存实际上那些页可能分散在物理内存的各个角落。地址翻译由CPU的MMU硬件加速完成内核只负责维护页表和把缺失的页装载进来性能损耗被压缩到了很低。5.2malloc并不真正分配内存按需分配的懒汉哲学第一次听到malloc之后并没有真正拿到物理内存时很多人会觉得不可思议但这正是虚拟内存设计里最精妙的一点按需分配。进程调用malloc(1GB)内核只是在这个进程的虚拟地址空间里划分出一块1GB的虚拟区段登记一下属性完事。真正分配物理页是后续写内存时触发缺页异常才发生的CPU访问那个虚拟地址查页表发现没有对应物理页陷入内核内核此时才去分配一页物理内存填充页表然后返回用户态继续执行。这样一来很多进程申请了内存但只用了其中一小部分物理内存也不会被白白浪费。这跟JIT编译用到了才编译、数据库连接池慢启动是一个思路内核把资源分配延迟到了真正需要的时刻。fork()能这么快也是占了虚拟内存的便宜。传统fork()本要把父进程整个内存复制一份这代价极高。Linux默认让父子进程共享同一批物理页只在某一方要写入时才对那一页做拷贝这就是COW。你写一个调度小脚本时可能感觉不到但在大型服务里fork()完立刻exec()的场景下这个设计能省掉几GB的无效复制。5.3 写驱动时最该记住的安全边界虚拟内存模型还给内核开发者立了一条铁规矩不要直接解引用用户态传来的指针。用户进程的虚拟地址空间只有在其自身上下文里才有意义。内核拿到一个用户传过来的指针那只是那个进程地址空间里的一个数字。如果内核直接把它当普通内核指针去读写轻则访问到无效地址导致内核崩溃重则被恶意程序利用制造漏洞。正确的姿势是使用copy_from_user()、copy_to_user()或者get_user()、put_user()这一类接口char buf[64]; if (copy_from_user(buf, user_ptr, sizeof(buf))) return -EFAULT;别看只是一个小小的复制动作它背后做了两件事检查用户地址是否合法以及为缺页情况做好准备。我把这条边界看作虚拟内存心智模型在实际工程里最重要的一个落脚点任何跨权限边界的访问都要通过正式的搬运接口而不是抄一条近路。这个习惯能让你避免大量从系统崩溃中学习的机会。6. 从知道到内化我验证内核心智模型的三条路径6.1 自己从源码编译一次内核并跑起来哪怕你暂时没有具体的内核开发任务我也强烈建议自己完整编译一次内核。从下载源码到make再到启动新内核这一趟流程跑下来你对内核是软件、它需要被构建、它可以被定制这件事会有完全不同的体感。最小操作步骤大概是wget https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.x.tar.xz tar -xf linux-6.x.tar.xz cd linux-6.x make defconfig make -j$(nproc)编译好的内核镜像在arch/x86/boot/bzImage想快速验证可以用QEMU直接引导qemu-system-x86_64 -kernel arch/x86/boot/bzImage -append consolettyS0 -nographic不用等很久你就能在一个终端里看到内核打印启动日志甚至进入一个简单的shell。这个动作最大的收益不是你会编译了而是你在心智模型里给内核这一格补上了一块很实在的拼图它不是一个玄学黑盒就是一个可以被你亲手构建的普通软件。6.2 写一个最小的字符设备驱动把抽象的模型变成手感的经验读一百遍file_operations不如亲手写一个只有几十行的字符设备。你没有设备硬件也没关系注册一个miscdevice填充.read回调在回调里把内核里某个计数器拼成字符串返回给用户态。然后用户态用cat去读它你会发现原来一切皆文件真的是这么运作的驱动真的可以长成一个文件。我当年第一次看到自己写的驱动被cat /dev/mydev正常读出数据时有一种抽象概念突然落地的爽感。这一步的意义在于它把前面提到的file_operations、设备节点、系统调用路径全部串成了一条能实际碰到的链路。6.3 用追踪工具验证你脑中的调用链心智模型只有被反复验证才会越来越精确。验证的最好工具是各种追踪和观测手段。strace是最容易上手的strace -f -e traceopenat cat /proc/cpuinfo你会看到cat进程发起的一系列系统调用openat()、read()、close()这些调用会透过内核进入VFS层再走到/proc伪文件系统对应的实现函数。对照源码目录你就能一步步验证脑子里画的调用链到底对不对。进阶一点就是用perf或者tracepoint在内核里插桩观察函数级别的执行流程。实践几次之后你能明显感觉到自己的心智模型从大概是这样变成了确定就是这样因为我在运行现场看到了它。我个人走到今天最深的体会是内核心智模型不是某个顿悟的瞬间建成的它更像一座城市的地图先画出主干道再逐步补上支路然后通过一次次的故障定位和代码阅读把每条街都走出来。这套地图一旦成型再面对陌生的子系统你也会知道该站在哪里看、该沿着哪条路深入而不是再一次跌回盲人摸象的循环里。
阅读完成 · 觉得有帮助?
咨询建站