4. 编写正确的代码

尽管一个坚实且以社区为导向的设计过程有诸多优点,但任何内核开发项目的成败最终都取决于生成代码本身。正是这段代码将接受其他开发者的审查,并被合并(或拒绝合并)到主线树中。因此,正是这段代码的质量决定了项目的最终成败。

本节将探讨编码过程。我们首先来看看内核开发人员常犯的一些错误。然后,重点将转向如何正确做事以及能够在此过程中提供帮助的工具。

4.1. 陷阱

4.1.1. 编码风格

内核长期以来一直拥有一套标准的编码风格,详见 Documentation/process/coding-style.rst。在很长一段时间里,该文件中描述的政策最多只能被视为建议性的。因此,内核中存在大量不符合编码风格指南的代码。这些代码的存在给内核开发者带来了两个独立的问题隐患。

其中第一个隐患是认为内核编码标准无关紧要且并未得到强制执行。事实是,如果新代码未按照标准编写,将其添加到内核中将非常困难;许多开发者在甚至不愿审查代码之前,就会要求先将代码重新格式化。像内核这样庞大的代码库需要一定程度的代码统一性,以便开发人员能够快速理解其中的任何部分。因此,怪异格式的代码已经没有立足之地了。

有时,内核的编码风格会与雇主强制要求的风格发生冲突。在这种情况下,在代码能够被合并之前,内核的风格必须占上风。将代码放入内核意味着在多个方面放弃一定程度的控制权——包括对代码格式化方式的控制。

另一个陷阱是假设内核中现有的代码迫切需要进行编码风格修复。开发人员可能会开始生成重新格式化的补丁,以此来熟悉开发流程,或者作为将自己的名字写进内核变更日志的一种方式——或者两者兼而有之。但是,纯粹的编码风格修复在开发社区看来属于噪音;它们往往会受到冷遇。因此,最好避免这种类型的补丁。在因其他原因对某段代码进行修改时顺便修复其风格是很自然的,但不应该为了改风格而改风格。

编码风格文档也不应该被解读为永不可违反的绝对法律。如果有充分的理由违背该风格(例如,如果为了符合 80 列限制而拆分某行代码会导致可读性大大降低),那就尽管去做。

请注意,你还可以使用 clang-format 工具来帮助你遵守这些规则,快速自动重新格式化部分代码,并审查整个文件以发现编码风格错误、拼写错误以及可能的改进之处。它对于排序 #includes、对齐变量/宏、重排文本以及其他类似任务也非常方便。有关更多详细信息,请参阅文件 Documentation/dev-tools/clang-format.rst

如果你使用的是与 EditorConfig 兼容的编辑器,一些基本的编辑器设置(如缩进和行尾)将会自动配置。有关更多信息,请参阅官方 EditorConfig 网站:https://editorconfig.org/

4.1.2. 抽象层

计算机科学教授教导学生为了灵活性和信息隐藏而广泛使用抽象层。当然,内核也广泛使用了抽象;任何涉及数百万行代码的项目若不这样做便无法生存。但经验表明,过度或过早的抽象与过早优化一样有害。抽象应当使用到所需的程度,而不要过头。

简单来说,考虑这样一个函数:它的某个参数在所有调用者中总是被传为零。人们可能会保留该参数,以防将来有人需要它所提供的额外灵活性。然而,到那时,实现这个额外参数的代码很可能已经在某些未曾察觉的微妙地方损坏了——因为从未有人使用过它。或者,当对额外灵活性的需求出现时,其实现方式并不符合程序员早期的预期。内核开发者会例行公事般地提交补丁来删除未使用的参数;一般来说,这些参数从一开始就不应该被添加。

隐藏对硬件访问的抽象层——通常是为了让驱动程序的大部分代码能够在多个操作系统上使用——尤其不受欢迎。这些抽象层使代码变得晦涩难懂,并可能带来性能损失;它们不属于 Linux 内核。

另一方面,如果你发现自己从另一个内核子系统中复制了大量的代码,那就该扪心自问了:实际上,将其中一部分代码提取到一个独立的库中,或者在更高层面上实现该功能是否更有意义?在整个内核中复制相同的代码毫无价值。

4.1.3. #ifdef 与预处理器的总体使用

C 预处理器似乎对某些 C 程序员有着巨大的诱惑力,他们将其视为在源文件中高效地编码大量灵活性的途径。但预处理器并非 C 语言,大量使用它会导致代码对其他人来说极难阅读,也更难让编译器检查其正确性。大量使用预处理器几乎总是代码需要进行清理工作的标志。

使用 #ifdef 进行条件编译确实是一个强大的特性,内核中也确实使用了它。但大家都不希望看到代码中到处充斥着 #ifdef 块。作为一个通用规则,#ifdef 的使用应尽可能局限于头文件中。条件编译的代码可以局限于某些函数中,如果不需要这些代码存在,这些函数只需变为空函数即可。编译器随后会默默地将对空函数的调用优化掉。这样产生的代码要清爽得多,也更容易阅读。

C 预处理器 macros 存在许多隐患,包括可能对带有副作用的表达式进行多次求值,且缺乏类型安全。如果你忍不住想要定义一个宏,请考虑改用内联函数(inline function)。由此产生的代码将是相同的,但内联函数更容易阅读,不会对其参数进行多次求值,并允许编译器对参数和返回值执行类型检查。

4.1.4. 内联函数

不过,内联函数本身也存在隐患。程序员可能会迷恋于避免函数调用所带来的所谓效率,从而在源文件中充斥着内联函数。然而,这些函数实际上可能会降低性能。由于它们的代码在每个调用点都会被复制,最终会导致编译后的内核体积膨胀。这反过来又会给处理器的内存缓存带来压力,从而大大降低执行速度。通常情况下,内联函数应该非常小且相对罕见。毕竟,函数调用的开销并没有那么高;创建大量的内联函数是过早优化的一个典型例子。

总的来说,内核程序员如果忽视缓存效应,将自食其果。初级数据结构课程中所教的经典“时间/空间权衡”法则往往不适用于当代硬件。空间就是时间,因为较大的程序会比更紧凑的程序运行得更慢。

较新的编译器在决定某个给定函数是否真正应该被内联方面发挥着越来越积极的作用。因此,随意放置“inline”关键字不仅可能是过度的,也可能是无关紧要的。

4.1.5. 锁机制

2006年5月,“Devicescape”网络协议栈在大张旗鼓下以 GPL 许可证发布,并准备并入主线内核。这一贡献是个好消息;当时 Linux 对无线网络的支持充其量只能算是不标准,而 Devicescape 协议栈有望改变这种状况。然而,这段代码直到 2007年6月(2.6.22版本)才真正进入主线。发生了什么?

这段代码显示出许多在企业大门后(闭源开发)开发的迹象。但其中一个特别大的问题是,它并非为在多处理器系统上运行而设计的。在该网络协议栈(现称为 mac80211)能够合并之前,必须为其补上一套锁机制方案。

曾几何时,开发 Linux 内核代码时无需考虑多处理器系统带来的并发问题。然而现在,本文档正是在一台双核笔记本电脑上编写的。即使在单处理器系统上,为提高响应能力而进行的工作也会提高内核内部的并发级别。无需考虑锁机制就能编写内核代码的日子早已一去不复返了。

任何可能被多个线程并发访问的资源(数据结构、硬件寄存器等)都必须受到锁的保护。编写新代码时应牢记这一要求;事后补救式地加锁是一项相当困难的任务。内核开发人员应花时间充分理解可用的锁原语,以便为任务挑选正确的工具。对并发性缺乏关注的代码将很难进入主线。

4.1.6. 回归(Regressions)

最后值得一提的一个隐患是:人们很容易做出某种更改(可能会带来巨大的改进),但这会导致现有用户的某些功能损坏。这种类型的更改被称为“回归(regression)”,而回归在主线内核中变得极不受欢迎。除少数例外情况外,如果回归无法及时修复,导致回归的更改将会被撤销。最好从一开始就避免回归。

人们经常争辩说,如果一个回归带来的好处(让更多的人正常使用)大于它带来的问题,那么它是合情合理的。如果某个更改破坏了一个系统,却为另外十个系统带来了新功能,为什么不做呢?对这个问题最好的回答是 Linus 在 2007年7月 表达的

So we don't fix bugs by introducing new problems.  That way lies
madness, and nobody ever knows if you actually make any real
progress at all. Is it two steps forwards, one step back, or one
step forward and two steps back?

(https://lwn.net/Articles/243460/).

一种特别不受欢迎的回归是对用户空间 ABI 的任何形式的更改。一旦某个接口被导出到用户空间,它就必须被无限期地支持下去。这一事实使得用户空间接口的创建极具挑战性:既然它们不能以不兼容的方式更改,就必须在第一次就做到正确。因此,用户空间接口总是需要大量的思考、清晰的文档以及广泛的审查。

4.2. 代码检查工具

至少目前,编写无错误的代码仍然是我们中极少数人能够达到的理想境界。不过,我们能做的是在代码进入主线内核之前,尽可能多地捕获并修复这些错误。为此,内核开发者汇集了一系列令人瞩目的工具,能够以自动化方式捕获各种隐蔽的问题。计算机捕获的任何问题都是日后不会困扰用户的问题,因此理所当然地应该尽可能使用自动化工具。

第一步很简单,就是听取编译器产生的警告。当代版本的 gcc 可以检测(并警告)大量潜在错误。通常情况下,这些警告都指向真实的问题。提交审查的代码按规定不应产生任何编译器警告。在消除警告时,务必理解其真正原因,并尽量避免那些只是让警告消失而没有解决其根本原因的“修复”。

请注意,并非所有编译器警告默认都是启用的。使用 “make KCFLAGS=-W” 构建内核以获取完整的警告集。

内核提供了几个用于开启调试功能的配置选项;其中大多数可以在“内核黑客(kernel hacking)”子菜单中找到。对于用于开发或测试目的的任何内核,都应开启其中的几个选项。特别是,你应该开启

  • FRAME_WARN 以便在栈帧(stack frame)大于指定大小时获得警告。生成的输出可能很详细,但人们无需担心来自内核其他部分的警告。

  • DEBUG_OBJECTS 将添加代码以跟踪内核创建的各种对象的生命周期,并在操作顺序颠倒时发出警告。如果你正在添加一个创建(并导出)自身复杂对象的子系统,请考虑为对象调试基础架构添加支持。

  • DEBUG_SLAB 可以发现各种内存分配和使用错误;它应该在大多数开发内核上使用。

  • DEBUG_SPINLOCK、DEBUG_ATOMIC_SLEEP 和 DEBUG_MUTEXES 将发现一些常见的加锁错误。

还有相当多的其他调试选项,其中一些将在下文讨论。其中一些对性能有显着影响,不应一直使用。但花点时间学习可用选项很可能会在短时间内带来数倍的回报。

较重的调试工具之一是锁检查器,即“lockdep”。该工具将跟踪系统中每个锁(自旋锁或互斥锁)的获取和释放、各个锁相对获取的顺序、当前的中断环境等。然后它可以确保锁总是以相同的顺序获取,相同的中断假设在所有情况下都适用,等等。换句话说,lockdep 可以发现系统在极少数情况下可能发生死锁的许多场景。在已部署的系统中,此类问题可能会令人痛苦(对开发人员和用户都是如此);lockdep 允许提前以自动化方式发现它们。包含任何形式的非常规锁机制的代码在提交收录之前,应在启用 lockdep 的情况下运行。

作为一个勤勉的内核程序员,你毫无疑问会检查任何可能失败的操作(例如内存分配)的返回状态。然而事实是,由此产生的失败恢复路径可能完全未经测试。未经测试的代码往往是损坏的代码;如果所有这些错误处理路径都经过了几次演练,你对自己的代码就会更有信心。

内核提供了一个故障注入框架(fault injection framework),可以做到这一点,特别是在涉及内存分配的地方。启用故障注入后,可配置百分比的内存分配将会失败;这些失败可以限制在特定的代码范围内。在启用故障注入的情况下运行,可以让程序员观察当事情变糟时代码如何响应。有关如何使用该设施的更多信息,请参阅 Fault injection capabilities infrastructure

使用“sparse”静态分析工具可以发现其他类型的错误。借助 sparse,程序员可以收到关于用户空间与内核空间地址混淆、大端和小端数量混合、在期望位标志集的地方传递整数值等方面的警告。Sparse 必须单独安装(如果你的发行版没有打包它,可以在 https://sparse.wiki.kernel.org/index.php/Main_Page 找到);然后通过在 make 命令中添加 “C=1” 即可对代码运行它。

“Coccinelle”工具(http://coccinelle.lip6.fr/)能够发现各种潜在的编码问题;它还可以针对这些问题提出修复建议。相当多的内核“语义补丁(semantic patches)”已被打包在 scripts/coccinelle 目录下;运行 “make coccicheck” 将会遍历这些语义补丁并报告发现的任何问题。有关更多信息,请参阅 Documentation/dev-tools/coccinelle.rst

其他类型的可移植性错误最好通过针对其他架构编译代码来发现。如果你恰好没有 S/390 系统或 Blackfin 开发板在手,你仍然可以执行编译步骤。用于 x86 系统的庞大交叉编译器集合可以在以下网址找到

花点时间安装和使用这些编译器将有助于避免日后产生尴尬。

4.3. 文档

在内核开发中,有文档往往是特例而非规律。即便如此,充分的文档将有助于简化新代码并入内核的过程,让其他开发者的生活更轻松,并且对你的用户也会有所帮助。在许多情况下,添加文档实际上已成为强制要求。

任何补丁的第一份文档是与其关联的变更日志(changelog)。日志条目应当描述正在解决的问题、解决方案的形式、参与该补丁开发的人员、对性能的任何相关影响,以及理解该补丁可能需要的其他任何信息。请确保变更日志说明了该补丁为什么值得应用;令人惊奇的是,许多开发者未能提供该信息。

任何添加新用户空间接口的代码——包括新的 sysfs 或 /proc 文件——都应包含该接口的文档,以便用户空间开发人员了解他们正在处理的内容。有关该文档应如何格式化以及需要提供哪些信息的说明,请参阅 Documentation/ABI/README。

文件 Documentation/admin-guide/kernel-parameters.rst 描述了内核所有的启动时参数。任何添加新参数的补丁都应该在该文件中添加相应的条目。

任何新的配置选项都必须附带帮助文本,清楚地解释这些选项以及用户在什么情况下可能想要选择它们。

许多子系统的内部 API 信息是通过特殊格式的注释记录的;这些注释可以通过 “kernel-doc” 脚本以多种方式提取和格式化。如果你在带有 kerneldoc 注释的子系统中工作,你应该维护它们,并在适当时为对外公开的函数添加此类注释。即使在尚未这样记录文档的区域,为将来添加 kerneldoc 注释也毫无坏处;事实上,这对于初学内核开发的开发者来说是一项有用的活动。这些注释的格式以及有关如何创建 kerneldoc 模板的一些信息可以在 Documentation/doc-guide/ 中找到。

任何读过大量现有内核代码的人都会注意到,通常情况下,注释最显著的特点就是它们的缺失。再次强调,对新代码的期望比过去更高;合并没有注释的代码将更加困难。话虽如此,大家也不希望看到充斥着冗长注释的代码。代码本身应当是可读的,注释则用于解释那些更微妙的方面。

某些事物必须始终添加注释。内存屏障(memory barriers)的使用必须附带一行说明为什么该屏障是必要的。数据结构的加锁规则通常需要在某处进行解释。大型数据结构总体上需要全面的文档。应当指出不同代码片段之间不明显的依赖关系。任何可能诱使代码维护清洁工(code janitor)做出错误“清理”的事情,都需要一条注释说明为什么按照当前的方式来做。诸如此类。

4.4. 内部 API 变更

除非在最严苛的情况下,内核提供给用户空间的二进制接口是绝对不能破坏的。相比之下,内核的内部编程接口高度易变(fluid),在需要时可以进行更改。如果你发现自己不得不绕过某个内核 API,或者仅仅因为某个特定功能不满足你的需求而不用它,这可能是一个信号,说明该 API 需要更改。作为内核开发者,你有权进行此类更改。

当然,也有一些需要注意的地方。API 可以更改,但它们需要有充分的理由。因此,任何进行内部 API 变更的补丁都应附带一份说明,阐述该变更是什​​么以及为什么它是必要的。此类变更也应当拆分为一个独立的补丁,而不是埋藏在一个更大的补丁之中。

另一个需要注意的地方是,更改内部 API 的开发人员通常肩负着修复内核树中因该变更而损坏的所有代码的任务。对于一个广泛使用的函数,这项职责可能会导致数以百计甚至数以千计的修改——其中许多修改很可能会与其他开发人员正在进行的工作发生冲突。不用说,这是一项巨大的工作,因此最好确保理由十分充分。请注意,Coccinelle 工具可以帮助处理范围广泛的 API 变更。

在进行不兼容的 API 变更时,应尽可能确保未更新的代码能被编译器捕获。这将有助于你确认自己已找到该接口在树内的所有用法。它还将提醒树外(out-of-tree)代码的开发者,有一个他们需要应对的变更。支持树外代码并不是内核开发人员需要担心的事,但我们也不必故意让树外开发者的日子比实际需要的更难过。