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. 回归¶
最后一个值得一提的危险是:进行一项(可能带来巨大改进的)更改,但却导致现有用户的功能损坏,这很诱人。这种更改称为“回归”,而回归在主线内核中是最不受欢迎的。除少数例外,如果回归问题无法及时修复,导致回归的更改将被回滚。最好从一开始就避免回归。
人们常争辩说,如果回归能让更多人受益而非带来问题,那么它就是合理的。如果一项更改能为十个系统带来新功能而只破坏一个系统,为什么不做呢?
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?
对此问题的最佳回答由 Linus 于 2007 年 7 月给出:(https://lwn.net/Articles/243460/)。
一种特别不受欢迎的回归是用户空间 ABI 的任何更改。一旦接口被导出到用户空间,它就必须被无限期地支持。这一事实使得用户空间接口的创建尤其具有挑战性:因为它们不能以不兼容的方式更改,所以必须一次性做对。因此,用户空间接口总是需要大量的思考、清晰的文档和广泛的审查。
4.2. 代码检查工具¶
至少目前,编写无错误代码仍是我们少数人才能达到的理想。然而,我们希望能做的是,在我们的代码进入主线内核之前,尽可能多地发现并修复这些错误。为此,内核开发者汇集了一系列令人印象深刻的工具,能够以自动化方式捕获各种难以发现的问题。任何由计算机发现的问题,都不会在以后困扰用户,因此理所当然地,应尽可能使用自动化工具。
第一步是简单地注意编译器产生的警告。现代版本的 gcc 可以检测(并警告)大量潜在错误。这些警告常常指向真正的问题。提交审查的代码,原则上不应产生任何编译器警告。在消除警告时,请务必理解其真正原因,并尽量避免那些未能解决根本原因而只是让警告消失的“修复”。
请注意,并非所有编译器警告都默认启用。使用“make KCFLAGS=-W”构建内核以启用所有警告。
内核提供了几个用于启用调试功能的配置选项;大部分可以在“kernel hacking”子菜单中找到。任何用于开发或测试目的的内核都应启用其中几个选项。特别地,您应该启用:
`FRAME_WARN` 以获取大于给定大小的堆栈帧警告。生成的输出可能会很详细,但无需担心来自内核其他部分的警告。
`DEBUG_OBJECTS` 将添加代码来追踪内核创建的各种对象的生命周期,并在操作顺序不正确时发出警告。如果您正在添加一个创建(并导出)自身复杂对象的子系统,请考虑为对象调试基础设施添加支持。
`DEBUG_SLAB` 可以发现各种内存分配和使用错误;它应该在大多数开发内核上使用。
`DEBUG_SPINLOCK`、`DEBUG_ATOMIC_SLEEP` 和 `DEBUG_MUTEXES` 将发现许多常见的锁错误。
还有许多其他调试选项,其中一些将在下面讨论。其中一些对性能有显著影响,不应一直使用。但花时间学习可用的选项很可能会在短时间内获得多倍的回报。
最重要的调试工具之一是锁检查器,或称“lockdep”。该工具将跟踪系统中每个锁(自旋锁或互斥锁)的获取和释放,锁之间获取的相对顺序,当前的中断环境等等。然后,它可以确保锁总是以相同的顺序获取,相同的中断假设适用于所有情况等等。换句话说,lockdep 可以发现一些系统在极少数情况下可能发生死锁的场景。这种问题在已部署的系统中可能会很痛苦(对开发者和用户都是);lockdep 允许提前以自动化方式发现它们。包含任何非平凡锁的代码在提交合入之前都应在启用 lockdep 的情况下运行。
作为一名勤奋的内核程序员,您无疑会检查任何可能失败的操作(例如内存分配)的返回状态。然而,事实是,由此产生的故障恢复路径很可能完全未经测试。未经测试的代码往往是损坏的代码;如果所有这些错误处理路径都曾被执行过几次,您就能对自己的代码更加自信。
内核提供了一个故障注入框架,能够做到这一点,尤其是在涉及内存分配的地方。启用故障注入后,可配置百分比的内存分配将失败;这些失败可以限制在特定的代码范围内。启用故障注入运行允许程序员查看代码在出现问题时的响应方式。有关如何使用此功能的更多信息,请参阅 故障注入能力基础设施。
其他类型的可移植性错误最好通过为其他架构编译代码来发现。使用 sparse,可以警告程序员关于用户空间和内核空间地址之间的混淆、大端和小端量混合、在期望位标志集的地方传递整数值等问题。Sparse 必须单独安装(如果您的发行版未提供,可以在 https://sparse.wiki.kernel.org/index.php/Main_Page 找到);然后可以通过在 make 命令中添加“C=1”来对代码运行它。
“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 注释也没有坏处;事实上,这对于初级内核开发者来说是一项有益的活动。这些注释的格式以及如何创建 kerneldoc 模板的一些信息可以在 Documentation/doc-guide/ 找到。
任何阅读大量现有内核代码的人都会注意到,通常,注释的显著特点是其缺失。再次强调,对新代码的期望比过去更高;合并未注释的代码将更加困难。尽管如此,我们也不希望看到过度注释的代码。代码本身应该可读,注释则用于解释更微妙的方面。
某些内容应始终添加注释。内存屏障的使用应附带一行注释,解释为何需要该屏障。数据结构的锁规则通常需要某处进行解释。主要数据结构通常需要全面的文档。应指出独立代码片段之间不明显的依赖关系。任何可能诱使代码清理者进行不正确“清理”的地方都需要注释说明其原因。诸如此类。
4.4. 内部 API 变更¶
内核提供给用户空间的二进制接口,除了在最严峻的情况下,都不能被破坏。相反,内核的内部编程接口具有高度的流动性,可以在需要时进行更改。如果您发现自己不得不绕过某个内核 API,或者仅仅因为某个特定功能不满足您的需求而没有使用它,这可能表明该 API 需要更改。作为一名内核开发者,您有权进行此类更改。
当然,也有一些注意事项。API 可以更改,但需要充分的理由。因此,任何进行内部 API 更改的补丁都应附带更改内容和必要性的描述。这种更改也应该拆分成单独的补丁,而不是埋藏在一个更大的补丁中。
另一个注意事项是,更改内部 API 的开发者通常负责修复内核树中因该更改而损坏的任何代码。对于一个广泛使用的函数,这项任务可能导致字面上的数百甚至数千处更改——其中许多可能与正在由其他开发者进行的工作发生冲突。不言而喻,这可能是一项巨大的工作,因此最好确保理由充分。请注意,Coccinelle 工具可以帮助处理范围广泛的 API 更改。
当进行不兼容的 API 更改时,应尽可能确保未更新的代码能被编译器捕获。这将帮助您确定已找到该接口的所有内核树内使用。它还将提醒内核树外代码的开发者,存在需要他们响应的更改。支持内核树外代码并非内核开发者需要担心的事情,但我们也没有必要让内核树外开发者面临不必要的困难。