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 预处理器宏存在许多危险,包括可能多次评估具有副作用的表达式和无类型安全性。如果您想定义一个宏,请考虑创建一个内联函数。结果代码将是相同的,但内联函数更容易阅读,不会多次评估其参数,并允许编译器对参数和返回值执行类型检查。

4.1.4. 内联函数

但是,内联函数本身也存在危险。程序员可能会沉迷于避免函数调用所带来的感知效率,并在源文件中填充内联函数。然而,这些函数实际上会降低性能。由于它们的代码在每个调用站点都被复制,它们最终会膨胀已编译内核的大小。反过来,这会给处理器的内存缓存带来压力,从而显着降低执行速度。一般来说,内联函数应该非常小且相对罕见。毕竟,函数调用的成本并不高;创建大量内联函数是过早优化的一个典型例子。

一般来说,内核程序员会忽略缓存影响,后果自负。在初级数据结构课程中讲授的经典时间/空间权衡通常不适用于当代硬件。空间就是时间,因为较大的程序会比更紧凑的程序运行得慢。

较新的编译器在决定是否应实际内联给定函数方面发挥着越来越积极的作用。因此,自由放置“inline”关键字可能不仅仅是过度;也可能是不相关的。

4.1.5.

在 2006 年 5 月,“Devicescape”网络堆栈在盛大的宣传下以 GPL 许可发布,并可用于包含在主线内核中。这一捐赠是一个可喜的消息;Linux 中的无线网络支持充其量被认为是次标准的,Devicescape 堆栈有望解决这种情况。然而,此代码实际上直到 2007 年 6 月 (2.6.22) 才进入主线。发生了什么?

这段代码显示出许多在公司内部开发的迹象。但特别大的问题之一是它没有被设计为在多处理器系统上工作。在合并此网络堆栈(现在称为 mac80211)之前,需要在其上进行改造加锁方案。

曾经,Linux 内核代码可以在不考虑多处理器系统带来的并发问题的情况下进行开发。然而,现在,本文档是在双核笔记本电脑上编写的。即使在单处理器系统上,为提高响应速度所做的工作也会提高内核内的并发级别。内核代码可以在不考虑锁定的情况下编写的日子早已过去。

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

4.1.6. 回归

最后值得一提的一个风险是:可能会有人试图进行一些更改(可能会带来很大的改进),但会导致现有用户的一些功能失效。这种类型的更改称为“回归”,而回归在主线内核中非常不受欢迎。除了少数例外,如果回归问题无法及时修复,则会导致回归的更改将被撤销。最好从一开始就避免回归。

经常有人认为,如果回归能让更多的人受益,而不是给少数人造成问题,那么回归就是合理的。如果一项更改给十个系统带来了新功能,而只破坏了一个系统,为什么不做呢?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”构建内核以获取完整集合。

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

  • FRAME_WARN 以获取大于给定数量的堆栈帧的警告。生成的输出可能很冗长,但不必担心来自内核其他部分的警告。

  • DEBUG_OBJECTS 将添加代码以跟踪内核创建的各种对象的生命周期,并在事情完成顺序错误时发出警告。如果您正在添加一个子系统,该子系统创建(并导出)自己的复杂对象,请考虑添加对对象调试基础结构的支持。

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

  • DEBUG_SPINLOCK、DEBUG_ATOMIC_SLEEP 和 DEBUG_MUTEXES 将找到许多常见的锁定错误。

还有很多其他的调试选项,其中一些将在下面讨论。其中一些会对性能产生重大影响,不应一直使用。但是,花一些时间学习可用的选项可能会很快得到回报。

较重的调试工具之一是锁定检查器,即“lockdep”。此工具将跟踪系统中每个锁(自旋锁或互斥锁)的获取和释放、锁相互获取的顺序、当前的 IRQ 环境等等。然后,它可以确保锁始终以相同的顺序获取,在所有情况下都应用相同的 IRQ 假设,等等。换句话说,lockdep 可以找到许多系统可能在极少数情况下死锁的情况。这种类型的问题在已部署的系统中(对于开发人员和用户而言)可能是痛苦的;lockdep 允许提前以自动化的方式发现它们。在提交包含任何不平凡锁定的代码之前,应启用 lockdep 运行。

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

内核提供了一个故障注入框架,可以做到这一点,尤其是在涉及内存分配的情况下。启用故障注入后,将使可配置百分比的内存分配失败;这些失败可以限制在特定的代码范围内。启用故障注入运行可让程序员看到当情况不妙时代码如何响应。有关如何使用此功能的更多信息,请参见 故障注入功能基础架构

其他类型的错误可以使用“sparse”静态分析工具找到。使用 sparse,可以警告程序员用户空间和内核空间地址之间的混淆、大端和小端数量的混合、传递整数值(其中预期设置位标志)等等。sparse 必须单独安装(如果您的分发版未打包,则可以在 https://sparse.wiki.kernel.org/index.php/Main_Page 找到);然后可以通过将“C=1”添加到您的 make 命令来在代码上运行它。

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

通过为其他架构编译代码,可以最好地发现其他类型的可移植性错误。如果您恰好没有 S/390 系统或 Blackfin 开发板,您仍然可以执行编译步骤。可以在以下位置找到用于 x86 系统的大量交叉编译器

花一些时间安装和使用这些编译器将有助于避免以后的尴尬。

4.3. 文档

在内核开发中,文档通常更多的是例外而不是规则。即便如此,足够的文档将有助于简化将新代码合并到内核中,使其他开发人员的生活更轻松,并且对您的用户有所帮助。在许多情况下,添加文档已基本成为强制性的。

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

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

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

任何新的配置选项都必须附有帮助文本,清楚地解释这些选项以及用户可能希望选择它们的时间。

许多子系统的内部 API 信息通过特殊格式的注释记录;这些注释可以通过“kernel-doc”脚本以多种方式提取和格式化。如果您正在使用包含 kerneldoc 注释的子系统,则应维护它们并为外部可用的函数适当地添加它们。即使在没有此类文档的区域中,添加 kerneldoc 注释以备将来之用也没有什么坏处;实际上,这对于初学者内核开发人员来说可能是一个有用的活动。可以在 Documentation/doc-guide/ 中找到这些注释的格式以及有关如何创建 kerneldoc 模板的一些信息。

任何阅读大量现有内核代码的人都会注意到,通常,注释最显著的特征是缺少。再一次,对新代码的期望比过去更高;合并未注释的代码将更加困难。也就是说,人们不希望出现冗长的注释代码。代码本身应该是可读的,注释应解释更微妙的方面。

某些内容应始终注释。内存屏障的使用应附有一行,说明为什么需要该屏障。通常需要在某处解释数据结构的锁定规则。一般而言,主要数据结构需要全面的文档。应指出单独的代码段之间不明显的依赖关系。任何可能诱使代码清理器进行不正确的“清理”的地方都需要注释,说明为什么这样做。等等。

4.4. 内部 API 更改

内核提供给用户空间的二进制接口不能被破坏,除非在最严重的情况下。相反,内核的内部编程接口是高度流动的,可以在需要时进行更改。如果您发现自己不得不解决内核 API 的问题,或者仅仅因为特定功能不符合您的需求而没有使用它,这可能表明该 API 需要更改。作为内核开发人员,您有权进行此类更改。

当然,这里有一些需要注意的地方。API 可以进行更改,但需要有充分的理由。因此,任何对内部 API 进行更改的补丁都应该附带一份说明,解释更改的内容以及为什么需要进行更改。这种类型的更改也应该拆分成单独的补丁,而不是埋藏在更大的补丁中。

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

在进行不兼容的 API 更改时,应尽可能确保未更新的代码会被编译器捕获。这将帮助您确保已找到该接口的所有树内使用。它还将提醒树外代码的开发者,需要响应更改。支持树外代码不是内核开发者需要担心的事情,但我们也不必让树外开发者的生活比实际需要的更艰难。