66

linux 内核开发指南 - 4 让代码正确

 4 years ago
source link: https://www.tuicool.com/articles/aUrUnua
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

4. 让代码正确

虽然对于一个坚实的、面向社区的设计过程有很多话要说,但是任何内核开发项目的 证明都在生成的代码中。它是将由其他开发人员检查并合并(或不合并)到主线树中 的代码。所以这段代码的质量决定了项目的最终成功。

本节将检查编码过程。我们将从内核开发人员出错的几种方式开始。然后重点将转移 到正确的事情和可以帮助这个任务的工具上。

4.1. 陷阱

4.1.1. 编码风格

内核长期以来都有一种标准的编码风格,如 Documentation/translations/zh_CN/process/coding-style.rst 中所述。在大部分时间里,该文件中描述的政策被认为至多是建议性的。因此,内核 中存在大量不符合编码风格准则的代码。代码的存在会给内核开发人员带来两个独立 的危害。

首先,要相信内核编码标准并不重要,也不强制执行。事实上,如果没有按照标准对代 码进行编码,那么向内核添加新代码是非常困难的;许多开发人员甚至会在审查代码之 前要求对代码进行重新格式化。一个与内核一样大的代码库需要一些统一的代码,以使 开发人员能够快速理解其中的任何部分。所以已经没有空间来存放奇怪的格式化代码了。

偶尔,内核的编码风格会与雇主的强制风格发生冲突。在这种情况下,内核的风格必须 在代码合并之前获胜。将代码放入内核意味着以多种方式放弃一定程度的控制权——包括 控制代码的格式化方式。

另一个陷阱是假定已经在内核中的代码迫切需要编码样式的修复。开发人员可能会开始 生成重新格式化补丁,作为熟悉过程的一种方式,或者作为将其名称写入内核变更日志 的一种方式,或者两者兼而有之。但是纯编码风格的修复被开发社区视为噪音;它们往 往受到冷遇。因此,最好避免使用这种类型的补丁。由于其他原因,在处理一段代码的 同时修复它的样式是很自然的,但是编码样式的更改不应该仅为了更改而进行。

编码风格的文档也不应该被视为绝对的法律,这是永远不会被违反的。如果有一个很好 的理由反对这种样式(例如,如果拆分为适合80列限制的行,那么它的可读性就会大大 降低),那么就这样做。

请注意,您还可以使用 clang-format 工具来帮助您处理这些规则,自动重新格式 化部分代码,并查看完整的文件,以发现编码样式错误、拼写错误和可能的改进。它还 可以方便地进行排序,包括对齐变量/宏、回流文本和其他类似任务。有关详细信息,请 参阅文件 Documentation/process/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 EXTRA_CFLAGS=-W”构建内核以 获得完整集合。

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

  • 启用 ENABLE_MUST_CHECK and FRAME_WARN 以获得一组额外的警告,以解决使用不 推荐使用的接口或忽略函数的重要返回值等问题。这些警告生成的输出可能是冗长 的,但您不必担心来自内核其他部分的警告。

  • DEBUG_OBJECTS 将添加代码,以跟踪内核创建的各种对象的生存期,并在出现问题时 发出警告。如果要添加创建(和导出)自己的复杂对象的子系统,请考虑添加对对象 调试基础结构的支持。

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

  • DEBUG_SPINLOCK, DEBUG_ATOMIC_SLEEP and DEBUG_MUTEXES 会发现许多常见的 锁定错误.

还有很多其他调试选项,其中一些将在下面讨论。其中一些具有显著的性能影响,不应 一直使用。但是,在学习可用选项上花费的一些时间可能会在短期内得到多次回报。

其中一个较重的调试工具是锁定检查器或“lockdep”。该工具将跟踪系统中每个锁 (spinlock或mutex)的获取和释放、获取锁的相对顺序、当前中断环境等等。然后, 它可以确保总是以相同的顺序获取锁,相同的中断假设适用于所有情况,等等。换句话 说,lockdep可以找到许多场景,在这些场景中,系统很少会死锁。在部署的系统中, 这种问题可能会很痛苦(对于开发人员和用户而言);LockDep允许提前以自动方式 发现问题。具有任何类型的非普通锁定的代码在提交包含前应在启用lockdep的情况 下运行。

作为一个勤奋的内核程序员,毫无疑问,您将检查任何可能失败的操作(如内存分配) 的返回状态。然而,事实上,最终的故障恢复路径可能完全没有经过测试。未测试的 代码往往会被破坏;如果所有这些错误处理路径都被执行了几次,那么您可能对代码 更有信心。

内核提供了一个可以做到这一点的错误注入框架,特别是在涉及内存分配的情况下。启用故障注入后,内存分配的可配置百分比将失败;这些失败可以限制在特定的代码 范围内。在启用了故障注入的情况下运行,程序员可以看到当情况恶化时代码如何响 应。有关如何使用此工具的详细信息,请参阅 Documentation/fault-injection/fault-injection.txt。

使用“sparse”静态分析工具可以发现其他类型的错误。对于sparse,可以警告程序员 用户空间和内核空间地址之间的混淆、big endian和small endian数量的混合、在需 要一组位标志的地方传递整数值等等。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系统的 大型交叉编译器:

http://www.kernel.org/pub/tools/crosstool/

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

4.3. 文档

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

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

任何添加新用户空间界面的代码(包括新的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更改时,应尽可能确保编译器捕获未更新的代码。这将帮助您确保找 到该接口的树内用处。它还将警告开发人员树外代码存在他们需要响应的更改。支持树外 代码不是内核开发人员需要担心的事情,但是我们也不必使树外开发人员的生活有不必要 的困难。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK