Warning
此文件的目的是为让中文读者更容易阅读和理解,而不是作为一个分支。 因此, 如果您对此文件有任何意见或更新,请先尝试更新原始英文文件。
Note
如果您发现本文档与原始文件有任何不同或者有翻译问题,请联系该文件的译者, 或者请求时奎亮的帮助:<alexs@kernel.org>。
- Original:
- Translator:
时奎亮 Alex Shi <alex.shi@linux.alibaba.com>
- 校译:
吴想成 Wu XiangCheng <bobwxc@email.cn>
4. 使代码正确¶
虽然一个坚实的、面向社区的设计过程有很多值得说道的,但是任何内核开发项目工作 的证明都反映在代码中。它是将由其他开发人员检查并合并(或不合并)到主线树中 的代码。所以这段代码的质量决定了项目的最终成功。
本节将检查编码过程。我们将从内核开发人员常犯的几种错误开始。然后重点将转移 到正确的做法和相关有用的工具上。
4.1. 陷阱¶
4.1.1. 代码风格¶
内核长期以来都有其标准的代码风格,如 Documentation/translations/zh_CN/process/coding-style.rst 中所述。在多数时候,该文档中描述的准则至多被认为是建议性的。因此,内核中存在 大量不符合代码风格准则的代码。这种代码的存在会给内核开发人员带来两方面的危害。
首先,相信内核代码标准并不重要,也不强制执行。但事实上,如果没有按照标准 编写代码,那么新代码将很难加入到内核中;许多开发人员甚至会在审查代码之前要求 对代码进行重新格式化。一个像内核这么大的代码库需要一些统一格式的代码,以使 开发人员能够快速理解其中的任何部分。所以再也经不起奇怪格式的代码的折腾了。
内核的代码风格偶尔会与雇主的强制风格发生冲突。在这种情况下,必须在代码合并 之前遵从内核代码风格。将代码放入内核意味着以多种方式放弃一定程度的控制权—— 包括控制代码样式。
另一个危害是认为已经在内核中的代码迫切需要修复代码样式。开发者可能会开始编写 重新格式化补丁,作为熟悉开发过程的一种方式,或者作为将其名字写入内核变更日志 的一种方式,或者两者兼而有之。但是纯代码风格的修复被开发社区视为噪音,它们往 往受到冷遇。因此,最好避免编写这种类型的补丁。在由于其他原因处理一段代码的 同时顺带修复其样式是很自然的,但是不应该仅为了更改代码样式而更改之。
代码风格文档也不应该被视为绝对不可违反的规则。如果有一个足够的理由反对这种 样式(例如为了80列限制拆分行会导致可读性大大降低),那么就这样做吧。
注意您还可以使用 clang-format
工具来帮助您处理这些规则,快速自动重新格式
化部分代码,和审阅完整的文件以发现代码样式错误、拼写错误和可能的改进。它还
可以方便地排序 #includes
、对齐变量/宏、重排文本和其他类似任务。有关详细
信息,请参阅文档 Documentation/dev-tools/clang-format.rst
4.1.2. 抽象层¶
计算机科学教授教学生以灵活性和信息隐藏的名义广泛使用抽象层。当然,内核广泛 地使用了抽象;任何涉及数百万行代码的项目都必须做到这一点以存续下来。但经验 表明,过度或过早的抽象可能和过早的优化一样有害。抽象应用在适当层级, 不要过度。
简单点,先考虑一个调用时始终只有一个参数且总为零的函数。我们可以保留这个参数, 以在需要使用它时提供的额外灵活性。不过,在那时实现了这个额外参数的代码很有 可能以某种从未被注意到的微妙方式被破坏——因为它从未被使用过。或者当需要额外 的灵活性时,它并未以符合程序员当初期望的方式来实现。内核开发人员通常会提交 补丁来删除未使用的参数;一般来说,一开始就不应该添加这些参数。
隐藏硬件访问的抽象层——通常为了允许大量的驱动程序兼容多个操作系统——尤其不受 欢迎。这样的层使代码变得模糊,可能会造成性能损失;它们不属于Linux内核。
另一方面,如果您发现自己从另一个内核子系统复制了大量的代码,那么是时候 了解一下:是否需要将这些代码中的部分提取到单独的库中,或者在更高的层次上 实现这些功能。在整个内核中复制相同的代码没有价值。
4.1.3. #ifdef 和预处理¶
C预处理器似乎给一些C程序员带来了强大的诱惑,他们认为它是一种将大量灵活性加入 源代码中的方法。但是预处理器不是C,大量使用它会导致代码对其他人来说更难阅读, 对编译器来说更难检查正确性。使用了大量预处理器几乎总是代码需要一些 清理工作的标志。
使用#ifdef的条件编译实际上是一个强大的功能,它在内核中使用。但是很少有人希望 看到代码被铺满#ifdef块。一般规定,ifdef的使用应尽可能限制在头文件中。条件 编译代码可以限制函数,如果代码不存在,这些函数就直接变成空的。然后编译器将 悄悄地优化对空函数的调用。使得代码更加清晰,更容易理解。
C预处理器宏存在许多危险性,包括可能对具有副作用且没有类型安全的表达式进行多 重评估。如果您试图定义宏,请考虑创建一个内联函数替代。结果相同的代码,内联 函数更容易阅读,不会多次计算其参数,并且允许编译器对参数和返回值执行类型检查。
4.1.4. 内联函数¶
不过,内联函数本身也存在风险。程序员可以倾心于避免函数调用和用内联函数填充源 文件所固有的效率。然而,这些功能实际上会降低性能。因为它们的代码在每个调用站 点都被复制一遍,所以最终会增加编译内核的大小。此外,这也对处理器的内存缓存 造成压力,从而大大降低执行速度。通常内联函数应该非常小,而且相对较少。毕竟 函数调用的成本并不高;大量创建内联函数是过早优化的典型例子。
一般来说,内核程序员会自冒风险忽略缓存效果。在数据结构课程开头中的经典 时间/空间权衡通常不适用于当代硬件。空间 就是 时间,因为一个大的程序比一个 更紧凑的程序运行得慢。
较新的编译器越来越激进地决定一个给定函数是否应该内联。因此,随意放置使用 “inline”关键字可能不仅仅是过度的,也可能是无用的。
4.1.5. 锁¶
2006年5月,“deviceescape”网络堆栈在前呼后拥下以GPL发布,并被纳入主线内核。 这是一个受欢迎的消息;Linux中对无线网络的支持充其量被认为是不合格的,而 Deviceescape堆栈承诺修复这种情况。然而直到2007年6月(2.6.22),这段代码才真 正进入主线。发生了什么?
这段代码出现了许多闭门造车的迹象。但一个大麻烦是,它并不是为多处理器系统而 设计。在合并这个网络堆栈(现在称为mac80211)之前,需要对其进行一个锁方案的 改造。
曾经,Linux内核代码可以在不考虑多处理器系统所带来的并发性问题的情况下进行 开发。然而现在,这个文档就是在双核笔记本电脑上写的。即使在单处理器系统上, 为提高响应能力所做的工作也会提高内核内的并发性水平。编写内核代码而不考虑锁 的日子早已远去。
可以由多个线程并发访问的任何资源(数据结构、硬件寄存器等)必须由锁保护。新 的代码应该谨记这一要求;事后修改锁是一项相当困难的任务。内核开发人员应该花 时间充分了解可用的锁原语,以便为工作选择正确的工具。对并发性缺乏关注的代码 很难进入主线。
4.1.6. 回归¶
最后一个值得一提的危险是回归:它可能会引起导致现有用户的某些东西中断的改变 (这也可能会带来很大的改进)。这种变化被称为“回归”,回归已经成为主线内核 最不受欢迎的问题。除了少数例外情况,如果回归不能及时修正,会导致回归的修改 将被取消。最好首先避免回归发生。
人们常常争论,如果回归带来的功能远超过产生的问题,那么回归是否为可接受的。 如果它破坏了一个系统却为十个系统带来新的功能,为何不改改态度呢?2007年7月, Linus对这个问题给出了最佳答案:
所以我们不会通过引入新问题来修复错误。这种方式是靠不住的,没人知道
是否真的有进展。是前进两步、后退一步,还是前进一步、后退两步?
(http://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”。该工具将跟踪系统中每个锁 (spinlock或mutex)的获取和释放、获取锁的相对顺序、当前中断环境等等。然后, 它可以确保总是以相同的顺序获取锁,相同的中断假设适用于所有情况等等。换句话 说,lockdep可以找到许多导致系统死锁的场景。在部署的系统中,这种问题可能会 很痛苦(对于开发人员和用户而言);LockDep允许提前以自动方式发现问题。具有 任何类型的非普通锁的代码在提交合并前应在启用lockdep的情况下运行测试。
作为一个勤奋的内核程序员,毫无疑问,您将检查任何可能失败的操作(如内存分配) 的返回状态。然而,事实上,最终的故障复现路径可能完全没有经过测试。未测试的 代码往往会出问题;如果所有这些错误处理路径都被执行了几次,那么您可能对代码 更有信心。
内核提供了一个可以做到这一点的错误注入框架,特别是在涉及内存分配的情况下。 启用故障注入后,内存分配的可配置失败的百分比;这些失败可以限定在特定的代码 范围内。在启用了故障注入的情况下运行,程序员可以看到当情况恶化时代码如何响 应。有关如何使用此工具的详细信息,请参阅 Fault injection capabilities infrastructure。
“sparse”静态分析工具可以发现其他类型的错误。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更改时,应尽可能确保编译器捕获未更新的代码。这将帮助您确保找 到该接口的树内用处。它还将警告开发人员树外代码存在他们需要响应的更改。支持树外 代码不是内核开发人员需要担心的事情,但是我们也不必使树外开发人员的生活有不必要 的困难。