1.9 调试的禅意与直觉
上一节我们花了不少时间在「磨刀」上——编译生产内核和调试内核。现在,你的工具箱里已经有了两把不同的刀。
但在你挥刀之前,我们需要先谈谈心法。
这一节不会涉及具体的命令或配置文件。我们要聊的是——当你面对一个莫名其妙的 Bug 时,该如何思考。
1.9.1 科学与艺术的交界
我必须先泼一盆冷水:调试(Debugging)不仅是技术活,更是一门艺术。
这话听起来很虚,但只要你经历过那种盯着屏幕看代码、直到怀疑人生的时刻,你就明白我在说什么。它是科学,因为需要严密的逻辑去复现和定位问题;它也是艺术,因为很多时候解决问题的那个「灵光一现」,来自于经验和直觉,而不是文档。
接下来的这些建议,你可能觉得「老生常谈」。
确实,这些道理并不新鲜。但诡异的是,我们在压力之下,往往会把这些最简单的原则忘得一干二净,然后一头撞进死胡同里。
所以,就把这当作是一次中场休息,调整一下呼吸,然后再出发。
1.9.2 第一戒:别做任何假设
丘吉尔有句名言:「永远,永远,永远不要放弃」。
我们的版本是:「永远,永远,永远不要做假设」。
假设,是无数 Bug 的根源。回想一下我们在本章开头讲的那些「翻车」案例——哪一个不是因为设计者或程序员潜意识里做了一个错误的假设?
这里有个不太文雅但极度精准的文字游戏(开个玩笑,但这很真实):
看看 assume(假设)这个词——它由三个部分组成:ASS、U、ME。换句话说,做假设就是把 U 和 ME 变成傻 X(ASS)。
如果你不想让自己(和队友)变成傻 X,就停止假设「这里应该没问题」。
怎么对抗假设?用代码说话。
在你的代码里使用断言是抓取假设的最佳手段。
- 在用户空间,你用
assert()宏(查一下 man page,它很强大)。 - 在内核里,我们有专门的宏来干这事(我们在第 12 章会深入讲内核里的
BUG()、WARN()和VM_BUG_ON(),先留个伏笔)。
1.9.3 别在树丛里迷路,要看森林
有时候代码路径复杂得像一碗意大利面。当你深陷在 if 和 else 的迷宫里时,很容易忘记「这坨代码到底是想干嘛」。
这就叫「见树不见林」。
一旦发现自己陷进去了,强行 Zoom Out。
停下来,问自己:这段代码的宏观目标是什么?它的输入和输出应该是什么?这种「跳出画面看画」的思维方式,往往能帮你发现那个导致错误的错误假设。
在这个时候,一份写得好的文档就是救命稻草。这也是为什么我总强调:别偷懒,写文档。
1.9.4 让问题变小
遇到一个棘手的 Bug 时,试试这招:
构建一个最小的、可复现问题的场景。
把所有无关的代码、配置、依赖统统砍掉,只保留最核心的那几行,能让 Bug 稳定复现即可。这个过程本身往往就是在追踪根本原因。
更有趣的是(这在我的经验里发生过很多次),当你试图把问题「精简」并写下来的时候——甚至在写文档描述问题的过程中——你的大脑会突然「叮」的一声:
「等等,既然这里是这样,那那里……」
然后你就在还没运行代码之前,已经找到了答案。
1.9.5 调试比写代码更费脑子
Brian Kernighan 在《The Elements of Programming Style》里说过一句(被无数人引用的)话:
「调试一段代码所需的脑力,是写这段代码的两倍。」
如果你在写代码时没有用尽全力,那你在调试时就会双倍奉还。
这里的核心建议是:别急着写代码,先做地基。
- 写一个简要的高层设计文档。
- 写下你期望代码做的事情(高层抽象)。
- 然后再去管细节(所谓的低层设计文档)。
好的文档是为你自己省时间。相信我,未来的你会跪下来感谢现在的你。
这让我想起了另一句名言:
「一盎司的设计,抵得上一磅的重构。」 —— Karl Wiegers
1.9.6 禅意与初心
有时候,代码烂得像一坨意大利面,闻着都不对劲。
如果你还能推倒重来,那么直接删掉重写,可能反而是最高效的路。这就是一种「初心」。
但「初心」还有另一层含义:暂时放下你的自负。
「这代码是我写的,怎么可能错?」 「这逻辑太完美了,肯定是编译器的问题。」
这种念头是调试的大敌。你需要试着用一个完全陌生的新人的眼光来看待这段代码和这个环境。
这也是为什么 Code Review(同事代码审查)如此有效——同事没有你的心理盲区,一眼就能看到你视而不见的 Bug。
当然,还有一招屡试不爽的神技:去睡一觉。
真的,别硬撑。很多时候,熬夜两小时不如睡醒后那专注的十分钟。
1.9.7 命名的艺术与注释的分寸
我在 Quora 上看过一个讨论:程序员最难的事情是什么?
最高赞的答案居然是——给变量起名。
这听起来好笑,但越想越对。变量名是有粘性的,一旦定下来,它会跟随你很久。
int i作为循环索引?很好。int theloopindex?这就有点矫情了,看得人眼睛疼。
怎么把握度?
- 名字:要清晰表达意图,但也别过分冗长。
- 注释:是用来解释「为什么要这样设计」,以及「代码背后的逻辑是什么」,而不是解释「这行代码怎么运作」。
任何合格的程序员都能看懂 a = b + c 是在做什么,不需要你注释。但没人知道为什么这里要加 1,这时候注释就是救命稻草。
1.9.8 别无视日志
这听起来像废话,但在高压之下,我们经常忽略最明显的东西。
仔细检查内核日志(甚至应用层日志)。日志通常支持按时间倒序排列(dmesg 可以做到,或者用 journalctl),这能让你一眼看到灾难发生前的那一刻到底发生了什么。
Linux 的 systemd 提供的 journalctl(1) 是个神器。如果你还没熟练掌握它,现在就去学。它会回报你的。
1.9.9 测试的残酷真相
这里有一个残酷的真理:
测试只能证明 Bug 的存在,而不能证明 Bug 的不存在。
—— Edsger W. Dijkstra
但这不代表我们要放弃测试。相反,测试和 QA 是软件流程里最关键的部分,忽视它的代价是巨大的。
花时间写详尽的测试用例——包括正向和负向的。这在长期看来回报丰厚。
- 负向测试 和 Fuzzing(模糊测试) 对于暴露安全漏洞至关重要。
- 代码覆盖率分析:别只凭感觉说「测过了」。用工具说话。100% 的覆盖率(结合运行时测试)才是目标。
我们会在第 12 章深入讲内核的代码覆盖率工具和测试框架。现在记住结论:别偷懒。
1.9.10 技术债务
有时候,你看着自己写的代码,心里隐隐作痛:
「它是能跑,但写得不够好……有些边界情况没处理……但这只是个小 Case,应该没事吧?」
截止日期就在眼前,把它 Check in 的诱惑很大。
请忍住。
这世界上有一种东西叫「技术债务」。它就像信用卡:你可以现在透支(写烂代码),但利息(未来的维护成本)会高得让你破产。这笔债,迟早要还,而且会有讨债人上门找你的。
1.9.11 那些愚蠢的错误
如果因为我犯低级错误而每次得到一分钱,我现在已经是富翁了。
举个真事:我曾经花了大半天时间头秃地调试一个 C 程序,死活不 work。逻辑没问题,代码也没问题……
直到我发现,我在编辑正确的代码,但编译的是旧版本——因为我跑 make 的目录不对。
(我相信你也有过这种想砸键盘的时刻。)
这种时候,最好的办法就是离开电脑,去喝杯水,或者睡一觉。你的大脑已经累了。
1.9.12 实证主义模型:别信书,去信实验
图 1.6 – 要实证!
Empirical(实证)这个词的意思是:通过观察和经验去验证事物,而不是靠理论。
所以,别相信书本(当然,本书除外),别相信教程,别相信博客,也别相信所谓的专家——包括我。
去试一下。
用你自己的眼睛看结果。
多年前,我刚入职的第一天,一位同事发给我一份文档,我至今珍藏:《C 程序员十诫》,作者 Henry Spencer。
虽然有些笨拙,但我受此启发,为你整理了一份速查表。
程序员的七条军规
非常重要!每次提交代码前,过一遍这个清单:
-
检查所有 API 的失败情况。 别只写成功路径,所有的系统调用、库函数都可能失败。处理它。
-
开启所有警告编译。 起码要加
-Wall。理想情况下加-Wextra,甚至-Werror(把警告当错误处理——内核代码就是这么干的)。消除所有警告。 -
永远不要信任输入。 尤其是用户输入。验证它。再验证一遍。
-
立即消除死代码。 没用的代码、被注释掉的代码,删掉它。留着它是隐患。
-
彻底测试。 100% 代码覆盖率是目标。花时间学习那些强大的工具:内存检查器、静态/动态分析器、安全检查器、Fuzzer、代码覆盖率工具……别忽视安全。
-
硬件也会撒谎。 对于内核和驱动开发者,排除了软件问题后,别忘了外围硬件故障也可能是元凶。别轻易排除这个可能!(等你吃过亏就懂了)。
-
不做任何假设。 记住
ASSUME那个词。用断言抓假设,从而抓 Bug。
我们接下来的章节里,会反复回到这些规则上。
(本章总结段略——由其他 Agent 处理)