Linux内核开发流程指南 - 4. 编写正确的代码【ChatGPT】

发布时间 2023-12-08 20:14:56作者: 摩斯电码

4. 编写正确的代码

虽然坚实且以社区为导向的设计过程有很多值得说的地方,但任何内核开发项目的证明都在于最终的代码。其他开发人员将审查这些代码,并将其合并(或不合并)到主线树中。因此,代码的质量将决定项目的最终成功与否。

本节将讨论编码过程。我们将首先看一下内核开发人员可能犯的一些错误。然后,重点将转向正确的做法以及可以帮助实现这一目标的工具。

4.1. 陷阱

4.1.1. 编码风格

内核长期以来一直有一种标准的编码风格,描述在《Documentation/process/coding-style.rst》中。在大部分时间里,该文件中描述的策略被视为至多是建议性的。因此,内核中存在大量不符合编码风格指南的代码。这些代码的存在为内核开发人员带来了两个独立的风险。

首先,有人可能会认为内核编码标准并不重要,也不会被执行。事实上,如果代码不符合标准,将很难将其添加到内核中;许多开发人员会要求在审查代码之前对其进行重新格式化。内核这样庞大的代码库需要一定的代码统一性,以便开发人员能够快速理解其中的任何部分。因此,不再容许存在格式奇异的代码。

偶尔,内核的编码风格可能会与雇主规定的风格发生冲突。在这种情况下,内核的风格必须优先,才能将代码合并。将代码放入内核意味着在许多方面放弃了一定程度的控制,包括代码格式的控制。

另一个陷阱是假设已经在内核中的代码迫切需要进行编码风格修复。开发人员可能会开始生成重新格式化的补丁,以便熟悉这一过程,或者为了让自己的名字出现在内核的变更日志中——或者两者兼而有之。但纯粹的编码风格修复在开发社区中被视为噪音;它们往往会受到冷淡的对待。因此,最好避免这种类型的补丁。在处理其他问题时自然而然地修复代码的风格是可以的,但不应为了修复编码风格而进行更改。

编码风格文档也不应被视为绝对不可违背的法律。如果有充分的理由违反这种风格(例如,如果将一行拆分以适应80列的限制会使其变得不太可读),那就违反吧。

请注意,您还可以使用 clang-format 工具来帮助您遵循这些规则,以便自动重新格式化代码的部分,并审查完整的文件,以发现编码风格错误、拼写错误和可能的改进。它还可以方便地对 #includes 进行排序,对齐变量/宏,对文本进行重新排列以及执行其他类似的任务。有关更多详细信息,请参阅《Documentation/process/clang-format.rst》文件。

4.1.2. 抽象层

计算机科学教授教导学生要充分利用抽象层,以实现灵活性和信息隐藏。当然,内核充分利用了抽象;涉及数百万行代码的任何项目都不可能做到相反并生存下来。但经验表明,过度或过早的抽象可能会像过早的优化一样有害。抽象应该被使用到所需的程度,而不应过度使用。

在简单的层面上,考虑一个函数,其参数始终由所有调用者传递为零。可以保留该参数,以防以后有人需要使用它提供的额外灵活性。然而,到那个时候,实现这个额外参数的代码很可能已经以某种从未被注意到的微妙方式被破坏——因为它从未被使用过。或者,当需要额外的灵活性时,它并不是以程序员早期预期的方式出现。内核开发人员通常会提交补丁以删除未使用的参数;一般情况下,不应该首先添加这些参数。

隐藏对硬件的访问的抽象层——通常是为了允许驱动程序的大部分部分在多个操作系统中使用——特别不受欢迎。这样的层会使代码变得模糊,并可能带来性能损失;它们不属于 Linux 内核。

另一方面,如果您发现自己从另一个内核子系统复制了大量代码,那么是时候考虑是否将其中一些代码提取到一个单独的库中,或者在更高的层次上实现该功能。在整个内核中复制相同的代码是没有价值的。

4.1.3. #ifdef 和预处理器的一般使用

C 预处理器似乎对一些 C 程序员构成了强大的诱惑,他们认为这是一种有效地将大量灵活性编码到源文件中的方法。但预处理器不是 C,过度使用它会导致其他人难以阅读代码,也会使编译器难以检查其正确性。过度使用预处理器几乎总是需要进行一些清理工作的迹象。

使用 #ifdef 进行条件编译的确是一种强大的功能,并且在内核中被使用。但是,我们不希望看到代码中到处都是大量的 #ifdef 块。一般来说,应该尽可能将 #ifdef 的使用限制在头文件中。条件编译的代码可以限制在函数中,如果代码不需要存在,这些函数就会变为空。编译器将悄悄地优化掉对空函数的调用。结果是更清晰的代码,更容易理解。

C 预处理宏存在许多危害,包括可能多次评估具有副作用的表达式,以及没有类型安全性。如果您想要定义一个宏,考虑创建一个内联函数。生成的代码将是相同的,但内联函数更容易阅读,不会多次评估其参数,并且允许编译器对参数和返回值进行类型检查。

4.1.4. 内联函数

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

一般来说,内核程序员忽视缓存效应会有危险。在初学数据结构课程中教授的经典的时间/空间权衡通常不适用于当代硬件。空间就是时间,一个更大的程序将比一个更紧凑的程序运行得更慢。

更近期的编译器在决定是否实际内联给定函数时扮演了越来越积极的角色。因此,“内联”关键字的过度使用可能不仅仅是过度的,而且可能是无关紧要的。

4.1.5. 锁定

2006 年 5 月,“Devicescape”网络堆栈以极大的喧闹声发布,并以 GPL 许可证的形式提供给主线内核。这一捐赠是个好消息;当时 Linux 中的无线网络支持被认为是次标准的,而 Devicescape 堆栈提供了解决这一情况的希望。然而,这段代码直到 2007 年 6 月(2.6.22)才真正进入主线。发生了什么?

这段代码显示出一些迹象,表明它是在公司内部开发的。但特别大的问题之一是,它并不是设计用于多处理器系统的。在这个网络堆栈(现在称为 mac80211)合并之前,需要为其添加一个锁定方案。

曾经,Linux 内核代码可以在不考虑多处理器系统所带来的并发问题的情况下开发。然而,现在,这份文档是在一台双核笔记本上编写的。即使在单处理器系统上,为提高响应性而进行的工作也会增加内核中的并发水平。编写内核代码而不考虑锁定的日子已经一去不复返。

任何可能被多个线程同时访问的资源(数据结构、硬件寄存器等)都必须受到锁的保护。新的代码应该在考虑到这一要求的情况下编写;事后再添加锁定是一项更加困难的任务。内核开发人员应该花时间充分了解可用的锁定原语,以选择适合当前任务的正确工具。显示出对并发性注意不足的代码将很难进入主线。

4.1.6. 回归

最后一个值得一提的危险是:很容易做出一项改变(可能会带来很大的改进),但却导致现有用户遇到问题。这种改变被称为“回归”,而回归在主线内核中变得极为不受欢迎。除非有极少数的例外情况,否则导致回归的改变如果无法及时修复,将会被撤销。最好的做法是尽量避免出现回归。

人们经常会争论,如果一项改变让更多的人受益,而只对少数人造成问题,那么回归就是合理的。但对于这个问题,Linus 在 2007 年 7 月做出了最好的回答:

所以我们不会通过引入新问题来修复 bug。这样做只会导致疯狂,而且没有人知道你到底是否取得了真正的进步。是两步前进,一步后退,还是一步前进,两步后退?

(来源: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 找到 sparse,并通过在 make 命令中添加“C=1”来运行它。

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

其他类型的可移植性错误最好通过为其他架构编译你的代码来发现。如果你手头没有 S/390 系统或 Blackfin 开发板,你仍然可以执行编译步骤。可以在 https://www.kernel.org/pub/tools/crosstool/ 找到大量用于 x86 系统的交叉编译器。花一些时间安装和使用这些编译器将有助于避免以后的尴尬。

4.3. 文档

文档在内核开发中通常更多地是例外而不是规则。即便如此,充分的文档将有助于简化新代码合并到内核中,为其他开发人员提供便利,并对用户有所帮助。在许多情况下,添加文档已经成为基本上是强制性的。

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

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

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

任何新的配置选项必须附带清晰解释选项及用户何时可能需要选择它们的帮助文本。

许多子系统的内部 API 信息是通过特殊格式的注释进行文档化的;这些注释可以通过“kernel-doc”脚本以多种方式提取和格式化。如果你正在一个具有内核文档注释的子系统中工作,你应该维护它们,并根据需要为外部可用的函数添加它们。即使在尚未进行此类文档化的领域,添加内核文档注释也是没有坏处的;事实上,这对于初学内核开发者来说可能是一个有用的活动。有关这些注释的格式,以及如何创建内核文档模板的一些信息,可以在 Documentation/doc-guide/ 中找到。

任何阅读大量现有内核代码的人都会注意到,往往缺少注释。再次强调,对于新代码的期望要高于过去;合并未注释的代码将更加困难。但是,也没有人希望代码被过分注释。代码本身应该是可读的,注释应该解释其中更微妙的方面。

某些事情应该始终加上注释。内存屏障的使用应该附带一行解释为什么需要该屏障。数据结构的锁定规则通常需要在某处进行解释。一般来说,主要数据结构需要全面的文档。代码之间的非明显依赖关系应该指出。任何可能引诱代码清理人员进行不正确“清理”的东西都需要一条注释,说明为什么要这样做。等等。

4.4. 内部 API 更改

内核向用户空间提供的二进制接口除非在最严重的情况下,否则不能被破坏。相反,内核的内部编程接口是非常灵活的,可以在需要时进行更改。如果你发现自己不得不绕过内核 API,或者仅仅是因为它不符合你的需求而不使用特定功能,那可能是 API 需要更改的迹象。作为内核开发人员,你有权进行这样的更改。

当然,也有一些限制。可以进行 API 更改,但需要有充分的理由。因此,任何进行内部 API 更改的补丁都应该附有对更改内容及其必要性的描述。这种更改应该被拆分成一个单独的补丁,而不是埋在一个更大的补丁中。

另一个限制是,更改内部 API 的开发人员通常负责修复由更改导致的内核树中的任何代码。对于广泛使用的函数,这项任务可能会导致数百甚至数千个更改 - 其中许多可能会与其他开发人员正在进行的工作发生冲突。不用说,这可能是一项艰巨的工作,因此最好确保有充分的理由。请注意,Coccinelle 工具可以帮助处理广泛的 API 更改。

在进行不兼容的 API 更改时,应尽可能确保未更新的代码被编译器捕获。这将帮助你确保你已经找到了所有内核中使用该接口的地方。它还将提醒外部代码的开发人员,有一个他们需要做出响应的更改。支持外部代码并不是内核开发人员需要担心的事情,但我们也不必让外部开发人员的生活变得更加困难。