Linux-内核编程-全-

Linux 内核编程(全)

原文:zh.annas-archive.org/md5/86EBDE91266D2750084E0C4C5C494FF7

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

这本书明确地旨在帮助你以实际、动手的方式学习 Linux 内核开发,同时提供必要的理论背景,使你对这个广阔而有趣的主题有一个全面的了解。它有意地专注于通过强大的可加载内核模块LKM)框架进行内核开发;绝大多数的内核项目和产品,包括设备驱动程序开发,都是以这种方式完成的。

重点放在实际操作和对 Linux 操作系统内部的深入理解上。在这方面,我们涵盖了从源代码构建 Linux 内核到理解和处理内核中的同步等复杂主题的方方面面。

为了指导你进行这激动人心的旅程,我们将这本书分为三个部分。第一部分涵盖了基础知识-设置内核开发所需的工作空间,从源代码构建内核,以及编写你的第一个内核模块。

接下来的一部分,一个关键部分,将帮助你理解重要和必要的内核内部- Linux 内核架构、任务结构以及用户和内核模式堆栈。内存管理是一个重要且有趣的主题-我们专门撰写了三整章来涵盖它(充分涵盖了内部内容,以及如何准确分配任何空闲内核内存)。Linux 上的 CPU 调度的工作和更深入的细节结束了这一部分。

本书的最后一部分涉及更高级的内核同步主题-这是 Linux 内核专业设计和编码的必要内容。我们专门撰写了两整章来涵盖这些关键主题。

本书使用了截至撰写时最新的 5.4 长期支持LTS)Linux 内核。这是一个将从 2019 年 11 月一直维护(包括错误修复和安全修复)到 2025 年 12 月的内核!这是一个关键点,确保了本书的内容在未来多年仍然保持最新和有效!

我们非常相信实践:本书的 GitHub 仓库上有超过 20 个内核模块(以及几个用户应用程序和 shell 脚本),使学习变得生动、有趣和有用。

我们强烈建议你也使用本书的配套指南Linux Kernel Programming (Part 2)

这是一本与行业接轨的初学者指南,涵盖了编写misc字符驱动程序、在外围芯片内存上执行 I/O 以及处理硬件中断。你可以免费获取这本书,同时也可以在 GitHub 仓库中找到这本电子书:github.com/PacktPublishing/Linux-Kernel-Programming/tree/master/Linux-Kernel-Programming-(Part-2)

我们真诚地希望你能从这本书中学到东西并且享受阅读。祝阅读愉快!

这本书适合谁

这本书主要是为那些刚开始涉足 Linux 内核模块开发以及在一定程度上 Linux 设备驱动程序开发的人而写的。它也非常针对那些已经在 Linux 模块和/或驱动程序上工作的人,他们希望对 Linux 内核架构、内存管理和同步有更深入、结构良好的理解。这种对底层操作系统的了解,以适当的结构方式进行覆盖,将在面对难以调试的现实情况时帮助你无比。

本书涵盖的内容

第一章,“内核工作空间设置”,指导您设置一个完整的 Linux 内核开发工作空间(通常作为一个完全虚拟化的客户系统)。您将学习如何在其中安装所有必需的软件包,包括交叉工具链。您还将了解其他几个开源项目,这些项目对您成为专业内核/驱动程序开发人员的旅程将会有用。完成本章后,您将准备好构建 Linux 内核,以及开始编写和测试内核代码(通过可加载内核模块框架)。在我们看来,您实际上使用这本书进行动手操作,尝试和实验代码非常重要。学习某件事情的最好方法是通过经验主义 - 不是完全相信任何人的话,而是通过尝试和亲身体验来学习。

第二章,“从源代码构建 5.x Linux 内核 - 第一部分”,是解释如何从头开始使用源代码构建现代 Linux 内核的第一部分。在这一部分,您将获得必要的背景信息 - 版本命名、不同的源树、内核源代码的布局。接下来,您将详细了解如何将稳定的 vanilla Linux 内核源代码树下载到虚拟机上。然后,我们将学习一些关于内核源代码布局的知识,实际上是对内核代码库的“鸟瞰”。然后是提取和配置 Linux 内核的实际工作。还展示了创建和使用自定义菜单条目进行内核配置。

第三章,“从源代码构建 5.x Linux 内核 - 第二部分”,是关于从源代码执行内核构建的第二部分。在这一部分,您将继续上一章的内容,现在实际上构建内核,安装内核模块,了解initramfsinitrd)的确切含义以及如何生成它,以及设置引导加载程序(对于 x86)。此外,作为有价值的附加内容,本章还解释了如何为典型的嵌入式 ARM 目标(使用流行的树莓派作为目标设备)交叉编译内核。还提到了一些关于内核构建的技巧和窍门,甚至内核安全(加固)的内容。

第四章,“编写您的第一个内核模块 - LKMs 第一部分”,是涵盖 Linux 内核开发的一个基本方面的两个部分之一 - LKM 框架,以及模块用户(您 - 内核模块或设备驱动程序程序员)如何理解和使用它。它涵盖了 Linux 内核架构的基础知识,然后详细介绍了编写一个简单的“Hello, world”内核模块的每个步骤,包括编译、插入、检查和从内核空间中删除。我们还详细介绍了通过普遍的 printk API 进行内核日志记录。

第五章,“编写您的第一个内核模块 - LKMs 第二部分”,是涵盖 LKM 框架的第二部分。在这里,我们首先要学习如何使用“更好”的 Makefile,这将帮助您生成更健壮的代码(具有多个代码检查、纠正、静态分析目标等)。然后我们详细展示了成功交叉编译内核模块到另一个架构的步骤,以及如何在内核中模拟“类库”代码(通过“链接”和模块堆叠方法),定义和使用传递参数给内核模块。其他主题包括在启动时自动加载模块、重要的安全指南,以及有关内核文档的一些信息以及如何访问它。几个示例内核模块使学习更加有趣。

第六章,内核内部要点-进程和线程,深入探讨了一些基本的内核内部主题。我们首先介绍了进程和中断上下文中执行的含义,以及进程用户虚拟地址空间(VAS)布局的最小但必需的覆盖范围。这为您铺平了道路;然后您将更深入地了解 Linux 内核架构,重点关注进程/线程任务结构及其相应的堆栈(用户模式和内核模式)。然后我们向您展示了更多关于内核任务结构(一个“根”数据结构),如何从中实际获取信息,甚至遍历各种(任务)列表。几个内核模块使这个主题更加生动。

第七章,内存管理内部要点-基础知识,是一个关键章节,深入探讨了 Linux 内存管理子系统的基本内部结构,以满足典型模块作者或驱动程序开发人员所需的详细程度。因此,这种覆盖范围在本质上更加理论化;然而,在这里获得的知识对于您作为内核开发人员来说至关重要,无论是为了深入理解和使用适当的内核内存 API,还是为了在内核层面进行有意义的调试。我们涵盖了 VM 分割(以及它在各种实际架构上的情况),深入了解用户 VAS(我们的 procmap 实用程序将让您大开眼界),以及内核段(或内核 VAS)。然后我们简要地探讨了内存布局随机化([K]ASLR)的安全技术,并以讨论 Linux 内部的物理内存组织结束了本章。

第八章,模块作者的内核内存分配第一部分,让我们亲自动手使用内核内存分配(和显然的释放)API。您将首先了解 Linux 内部的两种分配“层”-位于内核内存分配“引擎”上方的 slab 分配器,以及页面分配器(或 BSA)。我们将简要了解页面分配器算法的基础和其“空闲列表”数据结构;在决定使用哪一层时,这些信息是有价值的。接下来,我们直接投入到学习这些关键 API 的实际工作中。我们将涵盖 slab 分配器(或缓存)的背后思想以及主要的内核分配器 API-kzalloc/kfree。重要的是,详细介绍了使用这些常见 API 时的大小限制、缺点和注意事项。此外,特别适用于驱动程序作者的是,我们涵盖了内核的现代资源管理内存分配 API(devm_*()例程)。

第九章,模块作者的内核内存分配第二部分,在逻辑上进一步发展了前一章。在这里,您将学习如何创建自定义 slab 缓存(对于高频(de)分配,例如自定义驱动程序非常有用),以及关于在 slab 层调试内存分配的一些帮助。接下来,您将了解并使用vmalloc() API(及其相关内容)。非常重要的是,在涵盖了许多内核内存(de)分配 API 之后,您现在将学习如何根据您所处的实际情况选择适当的 API。本章以对内核的内存不足(OOM)“killer”框架的重要覆盖结束。了解它也将导致您对用户空间内存分配的工作原理有更深入的理解,通过需求分页技术。

第十章,“CPU 调度器-第一部分”,是两章中的第一部分,涵盖了关于 Linux 操作系统上 CPU 调度的理论和实践的有用混合内容。首先介绍了关于线程作为 KSE 以及可用的内核调度策略的最低必要理论背景。接下来,介绍了足够的内核 CPU 调度的细节,以便让您了解现代 Linux 操作系统上的调度工作原理。在学习的过程中,您将学习如何使用强大的工具(如 perf)“可视化”PU 调度;还深入探讨了线程调度属性(策略和实时优先级)。

第十一章,“CPU 调度器-第二部分”,是关于 CPU 调度的第二部分,继续更深入地介绍了这个主题。在这里,我们介绍了更多用于 CPU 调度的可视化工具(利用强大的软件,如 LTTng 和 trace-cmd 实用程序)。接下来,深入探讨了 CPU 亲和性掩码以及如何查询/设置它,以及在每个线程基础上控制调度策略和优先级的功能。还概述了控制组(cgroups)的含义和重要性,以及通过 cgroups v2 进行 CPU 带宽分配的有趣示例。您可以将 Linux 作为 RTOS 运行吗?确实可以!然后展示了实际操作的详细信息。最后,我们讨论了(调度)延迟以及如何测量它们。

第十二章,“内核同步-第一部分”,首先介绍了关于临界区、原子性、锁概念的关键概念,以及所有这些的重要性。然后我们介绍了在 Linux 内核中工作时的并发问题;这自然地引出了重要的锁定准则,死锁的含义,以及预防死锁的关键方法。然后深入讨论了两种最流行的内核锁技术——互斥锁和自旋锁——以及几个(驱动程序)代码示例。

第十三章,“内核同步-第二部分”,继续介绍内核同步的内容。在这里,您将了解关键的锁定优化——使用轻量级原子和(更近期的)引用计数运算符来安全地操作整数,使用 RMW 位运算符来安全地执行位操作,以及使用读者-写者自旋锁而不是常规自旋锁。还讨论了缓存“虚假共享”等固有风险。然后概述了无锁编程技术(重点是每 CPU 变量及其用法,并提供示例)。然后介绍了关键主题——锁调试技术,包括使用内核强大的“lockdep”锁验证器。最后简要介绍了内存屏障(并提供了一个示例)。

为了充分利用本书

为了充分利用本书,我们希望您具有以下知识和经验:

  • 熟悉 Linux 系统的命令行(shell)。

  • C 编程语言。

  • 这不是强制性的,但具有 Linux 系统编程概念和技术的经验将大大有助于。

有关硬件和软件要求以及其安装的详细信息在第一章,“内核工作区设置”中完整而深入地介绍。您必须详细阅读并遵循其中的说明。

此外,我们还在这些平台上测试了本书中的所有代码(它还有自己的 GitHub 存储库):

  • x86_64 Ubuntu 18.04 LTS 客户操作系统(在 Oracle VirtualBox 6.1 上运行)

  • x86_64 Ubuntu 20.04.1 LTS 客户操作系统(在 Oracle VirtualBox 6.1 上运行)

  • x86_64 Ubuntu 20.04.1 LTS 本机操作系统

  • ARM Raspberry Pi 3B+(同时运行其“发行版”内核和我们的自定义 5.4 内核);轻度测试

  • x86_64 CentOS 8 客户操作系统(在 Oracle VirtualBox 6.1 上运行);轻度测试

我们假设在作为客户机(VM)运行 Linux 时,主机系统要么是 Windows 10 或更高版本(当然,甚至 Windows 7 也可以),要么是最新的 Linux 发行版(例如 Ubuntu 或 Fedora),甚至是 macOS。

**如果您使用本书的数字版本,我们建议您自己输入代码,或者更好的是通过 GitHub 存储库访问代码(链接在下一节中提供)。这样做将有助于避免与复制和粘贴代码相关的任何潜在错误。

我强烈建议您遵循经验主义方法:不要轻信任何人的话,而是亲自尝试并体验。因此,本书为您提供了许多实践实验和内核代码示例,您可以并且必须亲自尝试;这将极大地帮助您取得实质性进展,并深入学习和理解 Linux 内核开发的各个方面。

下载示例代码文件

您可以从 GitHub 上下载本书的示例代码文件,链接为github.com/PacktPublishing/Linux-Kernel-Programming。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

我们还有来自我们丰富书籍和视频目录的其他代码包,可在github.com/PacktPublishing/上找到。去看看吧!

下载彩色图像

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。您可以在这里下载:www.packtpub.com/sites/default/files/downloads/9781789953435_ColorImages.pdf

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码字词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名。这是一个例子:“ioremap() API 返回void *类型的 KVA(因为它是一个地址位置)”

代码块设置如下:

static int __init miscdrv_init(void)
{
    int ret;
    struct device *dev;

当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:

#define pr_fmt(fmt) "%s:%s(): " fmt, KBUILD_MODNAME, __func__
[...]
#include <linux/miscdevice.h>
#include <linux/fs.h>             
[...]

任何命令行输入或输出都是按照以下方式编写的:

pi@raspberrypi:~ $ sudo cat /proc/iomem

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种形式出现在文本中。这是一个例子:“从管理面板中选择系统信息”。

警告或重要说明会出现在这样的形式中。

提示和技巧会以这种形式出现。

联系我们

我们始终欢迎读者的反馈意见。

一般反馈:如果您对本书的任何方面有疑问,请在您的消息主题中提及书名,并通过电子邮件联系我们,邮箱为customercare@packtpub.com

勘误:尽管我们已经非常注意确保内容的准确性,但错误是难免的。如果您在本书中发现错误,我们将不胜感激,如果您能向我们报告。请访问www.packtpub.com/support/errata,选择您的书,点击勘误提交表单链接,并输入详细信息。

盗版:如果您在互联网上发现我们作品的任何形式的非法副本,我们将不胜感激,如果您能向我们提供位置地址或网站名称。请通过copyright@packt.com与我们联系,并提供材料链接。

如果您有兴趣成为作者:如果您在某个专业领域有专长,并且有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

评论

请留下评论。在阅读并使用本书后,为什么不在购买书籍的网站上留下评论呢?潜在的读者可以看到并使用您的客观意见来做出购买决定,我们在 Packt 可以了解您对我们产品的看法,我们的作者也可以看到您对他们书籍的反馈。谢谢!

有关 Packt 的更多信息,请访问 packt.com

第一部分:基础知识

在这里,您将学习如何执行基本的内核开发任务。您将设置一个内核开发工作空间,从源代码构建 Linux 内核,了解 LKM 框架,并编写一个“Hello, world”内核模块。

本部分包括以下章节:

  • 第一章,内核工作空间设置

  • 第二章,从源代码构建 5.x Linux 内核,第一部分

  • 第三章,从源代码构建 5.x Linux 内核,第二部分

  • 第四章,编写您的第一个内核模块 - LKMs 第一部分

  • 第五章,编写您的第一个内核模块 - LKMs 第二部分

我们强烈建议您还使用本书的配套指南,Linux Kernel Programming (Part 2)

这是一本与行业相关的优秀的初学者指南,介绍了编写misc字符驱动程序,对外围芯片内存进行 I/O 以及处理硬件中断。这本书主要是为了开始在设备驱动程序开发中找到自己方向的 Linux 程序员而写的。想要克服频繁和常见的内核/驱动程序开发问题,以及了解和学习执行常见驱动程序任务的 Linux 设备驱动程序开发人员 - 现代Linux 设备模型LDM)框架,用户-内核接口,执行外围 I/O,处理硬件中断,处理并发等 - 都将从这本书中受益。需要对 Linux 内核内部(和常见 API)、内核模块开发和 C 编程有基本的了解。

您可以免费获取这本书,以及您的副本,或者您也可以在 GitHub 存储库中找到这本电子书:github.com/PacktPublishing/Linux-Kernel-Programming/tree/master/Linux-Kernel-Programming-(Part-2)

第一章:内核工作区设置

你好,欢迎来到这本关于学习 Linux 内核开发的书。为了充分利用本书,非常重要的是您首先设置我们将在整本书中使用的工作区环境。本章将教您如何做到这一点并开始。

我们将安装最新的 Linux 发行版,最好作为虚拟机VM),并设置它以包括所有必需的软件包。我们还将在 GitHub 上克隆本书的代码库,并了解一些有用的项目,这些项目将在这个过程中帮助我们。

学习某事的最佳方法是经验主义-不要完全相信任何人的话,而是尝试并亲身体验。因此,本书为您提供了许多实践实验和内核代码示例,您可以并且必须自己尝试;这将极大地帮助您取得实质性进展,深入学习和理解 Linux 内核和驱动程序开发的各个方面。所以,让我们开始吧!

本章将带领我们通过以下主题,帮助我们设置我们的环境:

  • 作为客户 VM 运行 Linux

  • 设置软件-分发和软件包

  • 一些额外有用的项目

技术要求

您需要一台现代台式机或笔记本电脑。Ubuntu 桌面指定了以下作为“推荐系统要求”的分发安装和使用:

  • 2 GHz 双核处理器或更好。

  • 内存:

  • 在物理主机上运行:2 GB 或更多系统内存(更多肯定会有所帮助)。

  • 作为客户 VM 运行:主机系统应至少有 4 GB RAM(内存越多越好,体验越流畅)。

  • 25 GB 的可用硬盘空间(我建议更多,至少是这个的两倍)。

  • 安装介质的 DVD 驱动器或 USB 端口(在设置 Ubuntu 作为客户 VM 时不需要)。

  • 互联网访问绝对是有帮助的,有时是必需的。

由于从源代码构建 Linux 内核等任务是一个非常消耗内存和 CPU 的过程,我强烈建议您在具有充足内存和磁盘空间的强大 Linux 系统上尝试。很明显-主机系统的 RAM 和 CPU 功率越大,越好!

像任何经验丰富的内核贡献者一样,我会说在本地 Linux 系统上工作是最好的。但是,出于本书的目的,我们不能假设您总是有一个专用的本地 Linux 框可供使用。因此,我们将假设您正在使用 Linux 客户端。在客户 VM 中工作还增加了一个额外的隔离层,因此更安全。

克隆我们的代码库:本书的完整源代码可以在 GitHub 上免费获取,网址为github.com/PacktPublishing/Linux-Kernel-Programming. 您可以通过克隆git树来克隆并使用它。

git clone https://github.com/PacktPublishing/Linux-Kernel-Programming.git

源代码按章节组织。每个章节都表示为一个目录-例如,ch1/包含本章的源代码。源树的根目录有一些对所有章节都通用的代码,比如源文件convenient.hklib_llkd.c等等。

为了高效地浏览代码,我强烈建议您始终使用ctags(1)和/或cscope(1)对代码库进行索引。例如,要设置ctags索引,只需cd到源树的根目录,然后输入ctags -R

除非另有说明,我们在书中展示的代码输出是在 x86-64 Ubuntu 18.04.3 LTS 客户 VM 上看到的输出(在 Oracle VirtualBox 6.1 下运行)。您应该意识到,由于(通常是轻微的)分布-甚至在相同的发行版中但是不同的版本-差异,这里显示的输出可能不完全匹配您在 Linux 系统上看到的内容。

作为客户 VM 运行 Linux

正如之前讨论的,与使用本机 Linux 系统相比,一个实用和方便的替代方法是在虚拟机上安装和使用 Linux 发行版作为客户端操作系统。重要的是,您安装一个最近的 Linux 发行版,最好作为虚拟机,以确保安全并避免不愉快的数据丢失或其他意外。事实上,当在内核级别工作时,突然崩溃系统(以及由此产生的数据丢失风险)实际上是一个常见的情况。我建议使用Oracle VirtualBox 6.x(或最新的稳定版本)或其他虚拟化软件,如VMware Workstation.

这两者都是免费提供的。只是这本书的代码已经在VirtualBox 6.1上进行了测试。Oracle VirtualBox 被认为是开源软件OSS),并且根据 GPL v2 许可(与 Linux 内核相同)。您可以从www.virtualbox.org/wiki/Downloads下载它。其文档可以在这里找到:www.virtualbox.org/wiki/End-user_documentation

主机系统应该是 MS Windows 10 或更高版本(当然,甚至 Windows 7 也可以),最近的 Linux 发行版(例如 Ubuntu 或 Fedora)或 macOS。因此,让我们通过安装我们的 Linux 客户端来开始。

安装 64 位 Linux 客户端

在这里,我不会深入讨论在 Oracle VirtualBox 上安装 Linux 作为客户端的细节,原因是这种安装与 Linux 内核开发没有直接关联。有许多设置 Linux 虚拟机的方法;我们真的不想在这里讨论每种方法的细节和利弊。

但如果您对此不熟悉,不用担心。为了您的方便,这里有一些非常好的资源可以帮助您:

此外,您可以在本章末尾的进一步阅读部分查找有关在 VirtualBox 上安装 Linux 客户端的有用资源。

在安装 Linux 虚拟机时,请记住以下几点。

打开您的 x86 系统的虚拟化扩展支持

安装 64 位 Linux 客户端需要在主机系统的基本输入/输出系统BIOS)设置中打开 CPU 虚拟化扩展支持(Intel VT-x 或 AMD-SV)。让我们看看如何做到这一点:

  1. 我们的第一步是确保我们的 CPU 支持虚拟化:

  2. 在 Windows 主机上检查这一点有两种广泛的方法

  • 首先,运行任务管理器应用程序并切换到性能选项卡。在 CPU 图表下,您将看到,除其他几个选项外,有一个名为虚拟化的选项,后面跟着启用或禁用。

  • 在 Windows 系统上检查的第二种方法是打开命令窗口(cmd)。在命令提示符中,键入systeminfo并按Enter。在输出中将看到固件中启用了虚拟化一行。它将后面跟着

  1. 在 Linux 主机上检查这一点,从终端,输入以下命令(处理器虚拟化扩展支持:vmx是 Intel 处理器的检查,smv是 AMD 处理器的检查):
egrep --color "vmx|svm" /proc/cpuinfo 

对于 Intel CPU,如果支持虚拟化,vmx标志将显示出来(以颜色显示)。对于 AMD CPU,svm将显示出来(以颜色显示)。有了这个,我们知道我们的 CPU 支持虚拟化。但是为了使用它,我们需要在计算机 BIOS 中启用它。

  1. 通过按DelF12进入 BIOS(按键的确切按键因 BIOS 而异)。请参阅系统手册,了解要使用哪个键。搜索诸如虚拟化虚拟化技术(VT-x)之类的术语。以下是 Award BIOS 的示例:

图 1.1 - 将 BIOS 虚拟化选项设置为已启用状态

如果您使用的是 Asus EFI-BIOS,则如果默认情况下未设置该条目,则必须将该条目设置为[Enabled]。访问superuser.com/questions/367290/how-to-enable-hardware-virtualization-on-asus-motherboard/375351#375351

  1. 现在,选择在 VM 的 VirtualBox 设置菜单中使用硬件虚拟化。要做到这一点,请单击系统,然后加速。之后,检查框,如下面的屏幕截图所示:

图 1.2 - 在 VirtualBox VM 设置中启用硬件虚拟化选项

这就是我们启用主机处理器的硬件虚拟化功能以获得最佳性能的方法。

为磁盘分配足够的空间

对于大多数台式机/笔记本系统,为客户 VM 分配 1 GB 的 RAM 和两个 CPU 应该足够了。

但是,在为客户的磁盘分配空间时,请慷慨一些。我强烈建议您将其设置为 50 GB 甚至更多,而不是通常/默认的 8 GB 建议。当然,这意味着主机系统有更多的磁盘空间可用!此外,您可以将此金额指定为动态分配按需分配。虚拟机监视程序将以最佳方式“增长”虚拟磁盘,而不是一开始就给它整个空间。

安装 Oracle VirtualBox 客户附加组件

为了获得最佳性能,重要的是在客户 VM 中安装 Oracle VirtualBox 客户附加组件。这些本质上是用于优化性能的 para-virtualization 加速软件。让我们看看如何在 Ubuntu 客户会话中执行此操作:

  1. 首先,更新您的 Ubuntu 客户操作系统的软件包。您可以使用以下命令来执行此操作:
sudo apt update

sudo apt upgrade 
  1. 完成后,重新启动您的 Ubuntu 客户操作系统,然后使用以下命令安装所需的软件包:
sudo apt install build-essential dkms linux-headers-$(uname -r)
  1. 现在,从 VM 菜单栏,转到设备 | 插入客户附加 CD 映像.... 这将在 VM 内部挂载客户附加 ISO文件。以下屏幕截图显示了这样做的样子:

图 1.3 - VirtualBox | 设备 | 插入客户附加 CD 映像

  1. 现在,将弹出一个对话框窗口,提示您运行安装程序以启动它。选择运行。

  2. 客户添加安装现在将在显示的终端窗口中进行。完成后,按Enter键关闭窗口。然后,关闭 Ubuntu 客户操作系统,以便从 VirtualBox 管理器更改一些设置,如下所述。

  3. 现在,要在客户机和主机之间启用共享剪贴板和拖放功能,请转到常规 | 高级,并使用下拉菜单启用两个选项(共享剪贴板和拖放):

图 1.4 - VirtualBox:启用主机和客户之间的功能

  1. 然后,单击 OK 保存设置。现在启动到您的客户系统,登录并测试一切是否正常工作。

截至撰写本文时,Fedora 29 在安装所需的共享文件夹功能的vboxsf内核模块时存在问题。我建议您参考以下资源来尝试纠正这种情况:Bug 1576832 - virtualbox-guest-additions does not mount shared folder (*bugzilla.redhat.com/show_bug.cgi?id=1576832)。如果这种方法不起作用,您可以使用scp(1)通过 SSH 在主机和来宾 VM 之间传输文件;要这样做,请使用以下命令安装并启动 SSH 守护程序:

sudo yum install openssh-server

sudo systemctl start sshd

记得定期更新来宾 VM,当提示时。这是一个必要的安全要求。您可以通过以下方式手动执行:

sudo /usr/bin/update-manager 

最后,请不要在来宾 VM 上保存任何重要数据。我们将进行内核开发。崩溃来宾内核实际上是一个常见的情况。虽然这通常不会导致数据丢失,但你永远无法确定!为了安全起见,请始终备份任何重要数据。这也适用于 Fedora。要了解如何将 Fedora 安装为 VirtualBox 来宾,请访问fedoramagazine.org/install-fedora-virtualbox-guest/

有时,特别是当 X Window 系统(或 Wayland)GUI 的开销太高时,最好只是在控制台模式下工作。您可以通过在引导加载程序中的内核命令行中附加3(运行级别)来实现。但是,在 VirtualBox 中以控制台模式工作可能不是那么愉快的体验(例如,剪贴板不可用,屏幕大小和字体不太理想)。因此,只需从主机系统中进行远程登录(通过sshputty或等效工具)到 VM 中,这是一种很好的工作方式。

使用树莓派进行实验

树莓派是一种流行的信用卡大小的单板计算机SBC),就像一个具有 USB 端口,microSD 卡,HDMI,音频,以太网,GPIO 等的小型 PC。驱动它的SoC系统芯片)来自 Broadcom,其中包含 ARM 核心或核心集群。当然,这并非强制要求,但在本书中,我们还努力在树莓派 3 Model B+目标上测试和运行我们的代码。在不同的目标架构上运行代码始终是发现可能缺陷并有助于测试的好方法。我鼓励您也这样做:

图 1.5-连接到其 GPIO 引脚的树莓派的 USB 到串行适配器电缆

您可以使用数字监视器/电视通过 HDMI 作为输出设备和传统键盘/鼠标通过其 USB 端口,或者更常见的是通过ssh(1)远程 shell 在树莓派目标上工作。但是,在某些情况下,SSH 方法并不适用。在树莓派上有一个串行控制台有所帮助,特别是在进行内核调试时。

我建议您查看以下文章,该文章将帮助您建立 USB 到串行连接,从而可以从 PC /笔记本电脑登录到树莓派的控制台:使用树莓派在控制台上工作, kaiwanTECH:kaiwantech.wordpress.com/2018/12/16/working-on-the-console-with-the-raspberry-pi/

要设置您的树莓派,请参阅官方文档:www.raspberrypi.org/documentation/。我们的树莓派系统运行“官方”Raspbian(树莓派的 Debian)Linux 操作系统,带有最新(写作时)的 4.14 Linux 内核。在树莓派的控制台上,我们运行以下命令:

rpi $ lsb_release -a
No LSB modules are available.
Distributor ID: Raspbian
Description:    Raspbian GNU/Linux 9.6 (stretch)
Release:        9.6
Codename:       stretch
rpi $ uname -a
Linux raspberrypi 4.14.79-v7+ #1159 SMP Sun Nov 4 17:50:20 GMT 2018 armv7l GNU/Linux
rpi $ 

如果您没有树莓派,或者它不方便使用,那怎么办?嗯,总是有办法——模拟!虽然不如拥有真正的设备好,但用强大的自由开源软件FOSS)模拟器QEMUQuick Emulator模拟树莓派是一个不错的开始方式,至少是这样。

由于设置通过 QEMU 模拟树莓派的细节超出了本书的范围,我们将不予涵盖。但是,您可以查看以下链接以了解更多信息:在 Linux 上模拟树莓派embedonix.com/articles/linux/emulating-raspberry-pi-on-linux/qemu-rpi-kernel,GitHubgithub.com/dhruvvyas90/qemu-rpi-kernel/wiki

当然,您不必局限于树莓派家族;还有几个其他出色的原型板可供选择。其中一个让人印象深刻的是流行的BeagleBone BlackBBB)开发板。

实际上,对于专业开发和产品工作来说,树莓派并不是最佳选择,原因有几个……稍微搜索一下就能理解。话虽如此,作为学习和基本原型环境,它很难被超越,因为它拥有强大的社区(和技术爱好者)支持。

在这篇深度文章中,讨论并对比了几种嵌入式 Linux(以及更多)的现代微处理器选择:SO YOU WANT TO BUILD AN EMBEDDED LINUX SYSTEM?,Jay Carlson,2020 年 10 月:jaycarlson.net/embedded-linux/;请查看。

到目前为止,我希望您已经设置了 Linux 作为虚拟机(或者正在使用本地的“测试”Linux 框)并克隆了本书的 GitHub 代码库。到目前为止,我们已经涵盖了一些关于将 Linux 设置为虚拟机(以及可选地使用树莓派或 BeagleBone 等开发板)的信息。现在让我们继续进行一个关键步骤:在我们的 Linux 虚拟系统上实际安装软件组件,以便我们可以在系统上学习和编写 Linux 内核代码!

设置软件——发行版和软件包

建议使用以下或更高版本的稳定版 Linux 发行版。正如前一节中提到的,它们始终可以安装为 Windows 或 Linux 主机系统的虚拟操作系统,首选当然是 Ubuntu Linux 18.04 LTS 桌面。以下截图显示了推荐的版本和用户界面:

图 1.6 - Oracle VirtualBox 6.1 运行 Ubuntu 18.04.4 LTS 作为虚拟机

上一个版本——Ubuntu 18.04 LTS 桌面——至少对于本书来说是首选版本。选择这个版本的两个主要原因很简单:

  • Ubuntu Linux 是当今工业中最受欢迎的 Linux(内核)开发工作站环境之一,如果不是受欢迎的话。

  • 由于篇幅和清晰度的限制,我们无法在本书中展示多个环境的代码/构建输出。因此,我们选择以 Ubuntu 18.04 LTS 桌面上看到的输出来展示。

Ubuntu 16.04 LTS 桌面也是一个不错的选择(它也有长期支持LTS)),一切都应该可以正常工作。要下载它,请访问www.ubuntu.com/download/desktop

还可以考虑一些其他 Linux 发行版,包括以下内容:

  • CentOS 8 Linux(不是 CentOS Stream):CentOS Linux 是一个基本上是 RedHat 流行企业服务器发行版(在我们的案例中是 RHEL 8)的克隆。您可以从这里下载:www.centos.org/download/

  • Fedora Workstation:Fedora 也是一个非常知名的 FOSS Linux 发行版。您可以将其视为 RedHat 企业产品中最终会出现的项目和代码的测试平台。从getfedora.org/下载(下载 Fedora Workstation 镜像)。

  • Raspberry Pi 作为目标:最好参考官方文档来设置您的 Raspberry Pi(Raspberry Pi 文档www.raspberrypi.org/documentation/)。也许值得注意的是,广泛提供完全预安装的 Raspberry Pi“套件”,还配备了一些硬件配件。

如果您想学习如何在 SD 卡上安装 Raspberry Pi OS 映像,请访问www.raspberrypi.org/documentation/installation/installing-images/

  • BeagleBone Black 作为目标:BBB 与 Raspberry Pi 一样,是业余爱好者和专业人士非常受欢迎的嵌入式 ARM SBC。您可以从这里开始:beagleboard.org/black。BBB 的系统参考手册可以在这里找到:cdn.sparkfun.com/datasheets/Dev/Beagle/BBB_SRM_C.pdf。尽管我们没有在 BBB 上运行示例,但它仍然是一个有效的嵌入式 Linux 系统,一旦正确设置,您可以在上面运行本书的代码。

在我们结束对书中软件发行版的选择讨论之前,还有一些要注意的地方:

  • 这些发行版在其默认形式下是 FOSS 和非专有的,可以作为最终用户免费使用。

  • 尽管我们的目标是成为与 Linux 发行版无关,但代码只在 Ubuntu 18.04 LTS 上进行了测试,并在 CentOS 8 上进行了“轻微”测试,以及在运行基于 Debian 的 Raspbian GNU/Linux 9.9(stretch)的 Raspberry Pi 3 Model B+上进行了测试。

  • 我们将尽可能使用最新的(在撰写时)稳定的 LTS

Linux 内核版本 5.4用于我们的内核构建和代码运行。作为 LTS 内核,5.4 内核是一个非常好的选择来运行和学习。

有趣的是,5.4 LTS 内核的寿命将会很长;从 2019 年 11 月一直到 2025 年 12 月!这是个好消息:本书的内容将在未来几年内保持最新和有效!

  • 对于这本书,我们将以名为llkd的用户帐户登录。

要最大限度地提高安全性(使用最新的防御和修复),您必须运行最新的长期支持LTS)内核,以便用于您的项目或产品。

现在我们已经选择了我们的 Linux 发行版和/或硬件板和 VM,是时候安装必要的软件包了。

安装软件包

当您使用典型的 Linux 桌面发行版(如任何最近的 Ubuntu、CentOS 或 Fedora Linux 系统)时,默认安装的软件包将包括系统程序员所需的最小设置:本地工具链,其中包括gcc编译器和头文件,以及make实用程序/软件包。

在本书中,我们将学习如何使用 VM 和/或在外部处理器(ARM 或 AArch64 是典型情况)上运行的目标系统编写内核空间代码。为了有效地在这些系统上开发内核代码,我们需要安装一些软件包。继续阅读。

安装 Oracle VirtualBox 客户附加组件

确保您已安装了客户端 VM(如前所述)。然后,跟着做:

  1. 登录到您的 Linux 客户 VM,首先在终端窗口(shell 上)运行以下命令:
sudo apt update
sudo apt install gcc make perl
  1. 现在安装 Oracle VirtualBox 客户附加组件。参考如何在 Ubuntu 中安装 VirtualBox 客户附加组件:www.tecmint.com/install-virtualbox-guest-additions-in-ubuntu/

只有当您将 Ubuntu 作为使用 Oracle VirtualBox 作为 hypervisor 应用程序的 VM 运行时才适用。

安装所需的软件包

要安装这些软件包,请执行以下步骤:

  1. 在 Ubuntu VM 中,首先执行以下操作:
sudo apt update
  1. 现在,在一行中运行以下命令:
sudo apt install git fakeroot build-essential tar ncurses-dev tar xz-utils libssl-dev bc stress python3-distutils libelf-dev linux-headers-$(uname -r) bison flex libncurses5-dev util-linux net-tools linux-tools-$(uname -r) exuberant-ctags cscope sysfsutils gnome-system-monitor curl perf-tools-unstable gnuplot rt-tests indent tree pstree smem libnuma-dev numactl hwloc bpfcc-tools sparse flawfinder cppcheck tuna hexdump openjdk-14-jre trace-cmd virt-what

首先执行安装gccmakeperl的命令,以便可以直接安装 Oracle VirtualBox Guest Additions。这些(Guest Additions)本质上是 para-virtualization 加速软件。安装它们对于性能优化很重要。

这本书有时提到在另一个 CPU 架构上运行程序-通常是 ARM-可能是一个有用的练习。如果您想尝试(有趣!)这样的东西,请继续阅读;否则,可以随意跳到重要的安装注意事项部分。

安装交叉工具链和 QEMU

在 ARM 机器上尝试事物的一种方法是实际在物理 ARM-based SBC 上这样做;例如,树莓派是一个非常受欢迎的选择。在这种情况下,典型的开发工作流程是首先在 x86-64 主机系统上构建 ARM 代码。但为了这样做,我们需要安装一个交叉工具链-一组工具,允许您在一个设计为在不同目标CPU 上执行的主机 CPU 上构建软件。一个 x86-64 主机构建 ARM 目标的程序是一个非常常见的情况,确实是我们的用例。稍后将详细介绍安装交叉编译器的详细信息。

通常,尝试事物的另一种方法是模拟 ARM/Linux 系统-这样可以减轻对硬件的需求!为此,我们建议使用出色的QEMU项目(www.qemu.org/)。

要安装所需的 QEMU 软件包,请执行以下操作:

  • 对于 Ubuntu 的安装,请使用以下命令:
sudo apt install qemu-system-arm
  • 对于 Fedora 的安装,请使用以下命令:
sudo dnf install qemu-system-arm-<version#>

要在 Fedora 上获取版本号,只需输入前面的命令,然后在输入所需的软件包名称(这里是qemu-system-arm-)后,按两次Tab键。它将自动完成,提供一个选择列表。选择最新版本,然后按Enter

CentOS 8 似乎没有简单的方法来安装我们需要的 QEMU 软件包。(您可以通过源代码安装交叉工具链,但这很具有挑战性;或者,获取一个合适的二进制软件包。)由于这些困难,我们将跳过在 CentOS 上展示交叉编译。

安装交叉编译器

如果您打算编写一个在特定主机系统上编译但必须在另一个目标系统上执行的 C 程序,那么您需要使用所谓的交叉编译器或交叉工具链进行编译。例如,在我们的用例中,我们希望在一个 x86-64 主机上工作。甚至可以是 x86-64 虚拟机,没有问题,但在 ARM-32 目标上运行我们的代码:

  • 在 Ubuntu 上,您可以使用以下命令安装交叉工具链:
sudo apt install crossbuild-essential-armhf 

前面的命令安装了适用于 ARM-32“硬浮点”(armhf)系统(例如树莓派)的 x86_64 到 ARM-32 工具链(通常很好)。它会安装arm-linux-gnueabihf-<foo>一组工具;其中<foo>代表交叉工具,如addr2lineasg++gccgcovgprofldnmobjcopyobjdumpreadelfsizestrip等。 (在这种情况下,交叉编译器前缀是arm-linux-gnueabihf-)。此外,虽然不是强制的,您也可以这样安装arm-linux-gnueabi-<foo>交叉工具集:

sudo apt install gcc-arm-linux-gnueabi binutils-arm-linux-gnueabi
  • 在 Fedora 上,您可以使用以下命令安装交叉工具链:
sudo dnf install arm-none-eabi-binutils-cs-<ver#> arm-none-eabi-gcc-cs-<ver#>

对于 Fedora Linux,与之前相同的提示适用-使用Tab键来帮助自动完成命令。

安装和使用交叉工具链可能需要一些新手用户的阅读。您可以访问进一步阅读部分,我在那里放置了一些有用的链接,这些链接肯定会帮助很大。

重要的安装注意事项

我们现在将提到一些剩下的要点,其中大部分涉及软件安装或在特定发行版上工作时的其他问题:

  • 在 CentOS 8 上,您可以使用以下命令安装 Python:
sudo dnf install python3

然而,这实际上并没有创建(必需的)符号链接symlink),/usr/bin/python;为什么呢?查看此链接获取详细信息:developers.redhat.com/blog/2019/05/07/what-no-python-in-red-hat-enterprise-linux-8/

手动创建符号链接,例如 python3,请执行以下操作:

sudo alternatives --set python /usr/bin/python3
  • 如果未安装 OpenSSL 头文件,内核构建可能会失败。在 CentOS 8 上使用以下命令修复:
sudo dnf install openssl-devel
  • 在 CentOS 8 上,可以使用以下命令安装 lsb_release 实用程序:
sudo dnf install redhat-lsb-core
  • 在 Fedora 上,执行以下操作:

  • 安装这两个包,确保在 Fedora 系统上构建内核时满足依赖关系:

sudo dnf install openssl-devel-1:1.1.1d-2.fc31 elfutils-libelf-devel(前面的openssl-devel包后缀为相关的 Fedora 版本号(这里是.fc31;根据您的系统需要进行调整)。

  • 为了使用 lsb_release 命令,您必须安装 redhat-lsb-core 包。

恭喜!软件设置完成,您的内核之旅开始了!现在,让我们看看一些额外的有用项目,以完成本章。强烈建议您也阅读这些。

额外有用的项目

本节为您带来了一些额外的杂项项目的详细信息,您可能会发现它们非常有用。在本书的一些适当的地方,我们提到或直接使用了其中一些,因此理解它们非常重要。

让我们开始熟悉并重要的 Linux man 页面项目。

使用 Linux man 页面

您一定已经注意到了大多数 Linux/Unix 文献中遵循的惯例:

  • 用户命令 的后缀为 (1) – 例如, gcc(1) 或 gcc.1

  • 系统调用 带有 (2) – 例如, fork(2) 或 fork().2

  • 库 API 带有 (3) – 例如, pthread_create(3) 或 pthread_create().3

正如您无疑所知,括号中的数字(或句号后面的数字)表示命令/API 所属的手册man页面)的部分。通过 man(1) 快速检查,通过 man man 命令 (这就是我们喜欢 Unix/Linux 的原因!)可以查看 Unix/Linux 手册的部分:

$ man man
[...]
A section, if provided, will direct man to look only in that section of
the manual. [...]

       The table below shows the section numbers of the manual followed by the types of pages they contain.

       1   Executable programs or shell commands
       2   System calls (functions provided by the kernel)
       3   Library calls (functions within program libraries)
       4   Special files (usually found in /dev)
       5   File formats and conventions eg /etc/passwd
       6   Games
       7   Miscellaneous (including macro packages and conventions), e.g. 
           man(7), groff(7)
       8   System administration commands (usually only for root)
       9   Kernel routines [Non standard]
[...]

因此,例如,要查找 stat(2) 系统调用的 man 页面,您将使用以下命令:

man 2 stat # (or: man stat.2)

有时(实际上经常),man页面太详细了,不值得阅读,只需要一个快速答案。这就是 tldr 项目的用途 – 继续阅读!

tldr 变种

当我们讨论man页面时,一个常见的烦恼是命令的man页面有时太大了。以 ps(1) 实用程序为例。它有一个很大的man页面,因为它当然有大量的选项开关。不过,有一个简化和总结的“常见用法”页面会很好,对吧?这正是 tldr 页面项目的目标。

TL;DR 字面意思是 太长了;没读.

他们提供“简化和社区驱动的 man 页面。”因此,一旦安装,tldr ps 提供了一个简洁的摘要,介绍了最常用的ps 命令选项开关,以便做一些有用的事情:

图 1.7 – tldr 实用程序的截图:tldr ps

所有 Ubuntu 仓库都有 tldr 包。使用 sudo apt install tldr 进行安装。

确实值得一看。如果您想了解更多,请访问 tldr.sh/

早些时候,我们提到用户空间系统调用属于 man 页面的第二部分,库子例程属于第三部分,内核 API 属于第九部分。鉴于此,在本书中,为什么我们不将,比如,printk内核函数(或 API)指定为printk(9) - 因为man man向我们展示手册的第九部分是Kernel routines?嗯,实际上这是虚构的(至少在今天的 Linux 上):内核 API 实际上没有 man 页面!那么,你如何获取内核 API 的文档等?这正是我们将在下一节中简要探讨的内容。

查找和使用 Linux 内核文档

社区经过多年的努力,已经将 Linux 内核文档发展和演变到一个良好的状态。内核文档的最新版本以一种漂亮和现代的“web”风格呈现,可以在这里在线访问:www.kernel.org/doc/html/latest/

当然,正如我们将在下一章中提到的那样,内核文档始终可以在内核源树中的该内核版本中找到,位于名为Documentation/的目录中。

作为在线内核文档的一个例子,可以查看以下页面的部分截图Core Kernel Documentation/Basic C Library Functions (www.kernel.org/doc/html/latest/core-api/kernel-api.html#basic-c-library-functions):

图 1.8 - 部分截图显示现代在线 Linux 内核文档的一小部分

从截图中可以看出,现代文档非常全面。

从源代码生成内核文档

你可以从内核源树中以各种流行的格式(包括 PDF、HTML、LaTeX、EPUB 或 XML)生成完整的 Linux 内核文档,以JavadocDoxygen风格。内核内部使用的现代文档系统称为Sphinx。在内核源树中使用make help将显示几个文档目标,其中包括htmldocspdfdocs等。因此,例如,你可以cd到内核源树并运行make pdfdocs来构建完整的 Linux 内核文档作为 PDF 文档(PDF 文档以及其他一些元文档将放在Documentation/output/latex中)。至少在第一次,你可能会被提示安装几个软件包和实用程序(我们没有明确显示这一点)。

如果前面的细节还不是很清楚,不要担心。我建议你先阅读第二章,从源代码构建 5.x Linux 内核-第一部分,和第三章,从源代码构建 5.x Linux 内核-第二部分,然后再回顾这些细节。

Linux 内核的静态分析工具

静态分析工具是通过检查源代码来尝试识别其中潜在错误的工具。它们对开发人员非常有用,尽管你必须学会如何“驯服”它们 - 因为它们可能会产生误报。

存在一些有用的静态分析工具。其中,对于 Linux 内核代码分析更相关的工具包括以下内容:

例如,要安装并尝试 Sparse,请执行以下操作:

sudo apt install sparse
cd <kernel-src-tree>
make C=1 CHECK="/usr/bin/sparse" 

还有一些高质量的商业静态分析工具可用。其中包括以下内容:

clang是 GCC 的前端,即使用于内核构建也越来越受欢迎。您可以使用sudo apt install clang clang-tools在 Ubuntu 上安装它。

静态分析工具可以帮助解决问题。花时间学习如何有效使用它们是值得的!

Linux Trace Toolkit next generation

用于跟踪分析的绝佳工具是功能强大的Linux Tracing Toolkit next generation(LTTng)工具集,这是一个 Linux 基金会项目。LTTng 允许您详细跟踪用户空间(应用程序)和/或内核代码路径。这可以极大地帮助您了解性能瓶颈出现在哪里,以及帮助您了解整体代码流程,从而了解代码实际执行任务的方式。

为了学习如何安装和使用它,我建议您参考这里非常好的文档:lttng.org/docs(尝试lttng.org/download/ 安装常见的 Linux 发行版)。强烈建议您安装 Trace Compass GUI:www.eclipse.org/tracecompass/。它提供了一个优秀的 GUI 来检查和解释 LTTng 的输出。

Trace Compass 最低要求安装Java Runtime Environment(JRE)。我在我的 Ubuntu 20.04 LTS 系统上安装了一个,使用sudo apt install openjdk-14-jre

举个例子(我忍不住!),这是 LTTng 捕获的截图,由出色的 Trace Compass GUI“可视化”。在这里,我展示了一些硬件中断(IRQ 线 1 和 130,分别是我的本机 x86_64 系统上 i8042 和 Wi-Fi 芯片组的中断线。)

图 1.9 - Trace Compass GUI 的示例截图;由 LTTng 记录的显示 IRQ 线 1 和 130 的样本

前面截图上部的粉色表示硬件中断的发生。在下面,在 IRQ vs Time 标签(仅部分可见),可以看到中断分布。(在分布图中,y轴是所花费的时间;有趣的是,网络中断处理程序 - 以红色显示 - 似乎花费的时间很少,i8042 键盘/鼠标控制器芯片的处理程序 - 以蓝色显示 - 花费更多时间,甚至超过 200 微秒!)

procmap 实用程序

procmap实用程序的设计目的是可视化内核虚拟地址空间(VAS)的完整内存映射,以及任何给定进程的用户 VAS。

其 GitHub 页面上的描述总结如下:

它以垂直平铺的格式输出给定进程的完整内存映射的简单可视化,按降序虚拟地址排序。脚本具有智能功能,可以显示内核和用户空间映射,并计算并显示将出现的稀疏内存区域。此外,每个段或映射都按相对大小进行缩放(并以颜色编码以便阅读)。在 64 位系统上,它还显示所谓的非规范稀疏区域或“空洞”(通常接近 x86_64 上的 16,384 PB)。

该实用程序包括查看仅内核空间或用户空间、详细和调试模式、将输出以便于的 CSV 格式导出到指定文件以及其他选项。它还有一个内核组件,目前可以在 x86_64、AArch32 和 Aarch64 CPU 上工作(并自动检测)。

请注意,我仍在开发此实用程序...目前仍有一些注意事项。欢迎反馈和贡献!

github.com/kaiwan/procmap下载/克隆它:

图 1.10- procmap 实用程序输出的部分截图,仅显示 x86_64 内核 VAS 的顶部部分

我们在第七章中充分利用了这个实用程序,内存管理内部-基础

简单的嵌入式 ARM Linux 系统 FOSS 项目

SEALSSimple Embedded ARM Linux System是一个非常简单的“骨架”Linux 基本系统,运行在模拟的 ARM 机器上。它提供了一个主要的 Bash 脚本,通过菜单询问最终用户需要什么功能,然后相应地继续为 ARM 交叉编译 Linux 内核,然后创建和初始化一个简单的根文件系统。然后可以调用 QEMU(qemu-system-arm)来模拟和运行 ARM 平台(Versatile Express CA-9 是默认的模拟板)。有用的是,该脚本构建目标内核、根文件系统和根文件系统映像文件,并设置引导。它甚至有一个简单的 GUI(或控制台)前端,以使最终用户的使用变得更简单一些。该项目的 GitHub 页面在这里:github.com/kaiwan/seals/。克隆它并试试看...我们强烈建议您查看其 wiki 部分页面以获取帮助。

使用[e]BPF 进行现代跟踪和性能分析

作为众所周知的伯克利数据包过滤器BPF的扩展,eBPF扩展 BPF(顺便说一句,现代用法是简单地将其称为BPF,去掉前缀'e')。简而言之,BPF 用于在内核中提供支持基本上是为了有效地跟踪网络数据包。BPF 是非常近期的内核创新-仅从 Linux 4.0 内核开始可用。它扩展了 BPF 的概念,允许您跟踪的不仅仅是网络堆栈。此外,它适用于跟踪内核空间和用户空间应用程序。实际上,BPF 及其前端是在 Linux 系统上进行跟踪和性能分析的现代方法

要使用 BPF,您需要具有以下系统:

直接使用 BPF 内核功能非常困难,因此有几个更容易的前端可供使用。其中,BCC 和bpftrace被认为是有用的。查看以下链接,了解有多少强大的 BCC 工具可用于帮助跟踪不同的 Linux 子系统和硬件:github.com/iovisor/bcc/raw/master/images/bcc_tracing_tools_2019.png

重要提示:您可以通过阅读此处的安装说明在您的常规主机 Linux 发行版上安装 BCC 工具:github.com/iovisor/bcc/raw/master/INSTALL.md。为什么不能在我们的 Linux VM 上安装?当运行发行版内核(如 Ubuntu 或 Fedora 提供的内核)时,您可以。原因是:BCC 工具集的安装包括(并依赖于)linux-headers-$(uname -r)包的安装;这个linux-headers包仅适用于发行版内核(而不适用于我们经常在客人上运行的自定义 5.4 内核)。

BCC 的主要网站可以在github.com/iovisor/bcc找到。

LDV - Linux 驱动程序验证 - 项目

成立于 2005 年的俄罗斯 Linux 验证中心是一个开源项目;它拥有专家,并因此专门从事复杂软件项目的自动化测试。这包括在核心 Linux 内核以及主要的内核内设备驱动程序上执行的全面测试套件、框架和详细分析(静态和动态)。该项目还非常注重对内核模块的测试和验证,而许多类似的项目往往只是粗略地涉及。

我们特别感兴趣的是在线 Linux 驱动程序验证服务页面(linuxtesting.org/ldv/online?action=rules);它包含了一些经过验证的规则(图 1.11):

图 1.11 - Linux 驱动程序验证(LDV)项目网站的“规则”页面的屏幕截图

通过浏览这些规则,我们不仅能够看到规则,还能看到这些规则在主线内核中被驱动程序/内核代码违反的实际案例,从而引入了错误。LDV 项目已成功发现并修复(通过通常方式发送补丁)了几个驱动程序/内核错误。在接下来的几章中,我们将提到这些 LDV 规则违反的实例(例如,内存泄漏,使用后释放(UAF)错误和锁定违规)已经被发现,并(可能)已经修复。

以下是 LDV 网站上一些有用的链接:

总结

在本章中,我们详细介绍了设置适当的开发环境的硬件和软件要求,以便开始进行 Linux 内核开发。此外,我们提到了基础知识,并在适当的地方提供了设置树莓派设备、安装强大工具如 QEMU 和交叉工具链等的链接。我们还介绍了其他一些“杂项”工具和项目,作为一个新手内核和/或设备驱动程序开发人员,您可能会发现这些工具和如何开始查找内核文档的信息很有用。

在本书中,我们强烈建议并期望您以实际操作的方式尝试并开展内核代码的工作。为此,您必须设置一个适当的内核工作空间环境,我们在本章中已经成功完成了这一点。

现在我们的环境已经准备好,让我们继续探索 Linux 内核开发的广阔世界吧!接下来的两章将教您如何从源代码下载、提取、配置和构建 Linux 内核。

问题

最后,这里有一些问题供您测试对本章材料的了解:github.com/PacktPublishing/Linux-Kernel-Programming/tree/master/questions。您会发现一些问题的答案在书的 GitHub 存储库中:github.com/PacktPublishing/Linux-Kernel-Programming/tree/master/solutions_to_assgn

进一步阅读

为了帮助您深入了解这个主题并提供有用的材料,我们在本书的 GitHub 存储库中提供了一个相当详细的在线参考和链接列表(有时甚至包括书籍)。进一步阅读文档在这里可用:github.com/PacktPublishing/Linux-Kernel-Programming/raw/master/Further_Reading.md

第二章:从源代码构建 5.x Linux 内核 - 第一部分

从源代码构建 Linux 内核是开始内核开发之旅的有趣方式!请放心,这是一个漫长而艰巨的旅程,但这就是其中的乐趣,对吧?内核构建主题本身足够大,值得分成两章,本章和下一章。

本章和下一章的主要目的是详细描述如何从头开始、从源代码构建 Linux 内核。在本章中,您将首先学习如何将稳定的原始 Linux 内核源树下载到一个 Linux虚拟机VM)上(通过原始内核,我们指的是 Linux 内核社区在其存储库上发布的普通默认内核源代码,www.kernel.org)。接下来,我们将学习一些关于内核源代码布局的知识 - 实际上是对内核代码库的一个整体概览。然后是实际的内核构建过程。

在继续之前,一个关键信息:任何 Linux 系统,无论是超级计算机还是微型嵌入式设备,都有三个必需的组件:引导加载程序、操作系统OS)内核和根文件系统。在本章中,我们只关注从源代码构建 Linux 内核。我们不深入研究根文件系统的细节,并且(在下一章中)学习如何最小化配置(非常特定于 x86 的)GNU GRUB 引导加载程序。

在本章中,我们将涵盖以下主题:

  • 内核构建的前提条件

  • 构建内核的步骤

  • 第 1 步 - 获取 Linux 内核源树

  • 第 2 步 - 提取内核源树

  • 第 3 步 - 配置 Linux 内核

  • 自定义内核菜单 - 添加我们自己的菜单项

技术要求

我假设您已经阅读了第一章,内核工作空间设置,并已经适当地准备了一个运行 Ubuntu 18.04 LTS(或 CentOS 8,或这些发行版的后续稳定版本)的客户 VM,并安装了所有必需的软件包。如果没有,我强烈建议您首先这样做。

为了充分利用本书,我强烈建议您首先设置工作空间环境,包括克隆本书的 GitHub 存储库(github.com/PacktPublishing/Linux-Kernel-Programming)以获取代码,并进行实际操作。

内核构建的前提条件

从一开始就了解一些事情对我们在构建和使用 Linux 内核的旅程中会有所帮助。首先,Linux 内核及其姊妹项目是完全去中心化的 - 这是一个虚拟的、在线的开源社区!我们最接近办公室的地方是:Linux 内核(以及几十个相关项目)的管理权在 Linux 基金会(linuxfoundation.org/)的有力掌握之下;此外,它管理着 Linux 内核组织,这是一个私人基金会,向公众免费分发 Linux 内核(www.kernel.org/nonprofit.html)。

本节讨论的一些关键点包括以下内容:

  • 内核发布,或版本号命名法

  • 典型的内核开发工作流程

  • 存储库中不同类型的内核源树的存在

有了这些信息,您将更好地了解内核构建过程。好的,让我们逐个讨论前面提到的每一点。

内核发布命名法

要查看内核版本号,只需在 shell 上运行uname -r。如何准确解释uname -r的输出?在我们的 Ubuntu 18.04 LTS 客户 VM 上,我们运行uname(1),传递-r选项开关,只显示当前的内核发布或版本:

$ uname -r
5.0.0-36-generic 

当然,在您阅读本文时,Ubuntu 18.04 LTS 内核肯定已经升级到了更高的版本;这是完全正常的。在我写这一章节时,5.0.0-36-generic 内核是我在 Ubuntu 18.04.3 LTS 中遇到的版本。

现代 Linux 内核发布号命名规范如下:

major#.minor#[.patchlevel][-EXTRAVERSION]

这也经常被写成或描述为w.x[.y][-z]

方括号表示patchlevelEXTRAVERSION组件是可选的。以下表总结了发布号的各个组件的含义:

发布号组件 含义 示例号码
主要#(或w 主要号码;目前,我们在 5.x 内核系列上,因此主要号码是5 2345
次要#(或x 次要号码,在主要号码之下。 0及以上
[patchlevel](或y 在次要号码之下 - 也称为 ABI 或修订版 - 在需要时应用于稳定内核,以进行重要的错误/安全修复。 0及以上
[-EXTRAVERSION](或-z 也称为localversion;通常由发行版内核用于跟踪其内部更改。 变化;Ubuntu 使用w.x.y-'n'-generic

表 2.1 - Linux 内核发布命名规范

因此,我们现在可以解释我们 Ubuntu 18.04 LTS 发行版的内核发布号5.0.0-36-generic

  • 主要#(或 w)5

  • 次要#(或 x)0

  • [patchlevel](或 y)0

  • [-EXTRAVERSION](或-z)-36-generic

请注意,发行版内核可能会或可能不会严格遵循这些约定,这取决于他们自己。在www.kernel.org/发布的常规或原始内核确实遵循这些约定(至少在 Linus 决定更改它们之前)。

(a)作为一个有趣的练习配置内核的一部分,我们将稍后更改我们构建的内核的localversion(又名-EXTRAVERSION)组件。

(b)在 2.6 之前的内核中(也就是说,现在是古老的东西),次要号具有特殊的含义;如果是偶数,表示稳定的内核发布,如果是奇数,表示不稳定或测试版发布。现在不再是这样了。

内核开发工作流程 - 基础知识

在这里,我们简要概述了典型的内核开发工作流程。任何像您一样对内核开发感兴趣的人,至少应该对这个过程有基本的了解。

可以在内核文档中找到详细描述:www.kernel.org/doc/html/latest/process/2.Process.html#how-the-development-process-works

一个常见的误解,尤其是在它的初期,是 Linux 内核是以一种非常临时的方式开发的。这一点完全不正确!内核开发过程已经发展成为一个(大部分)良好运转的系统,有着详细的文件化流程和对内核贡献者应该了解的期望。我建议您查看前面的链接以获取完整的详细信息。

为了让我们一窥典型的开发周期,让我们假设我们在系统上克隆了最新的主线 Linux Git 内核树。

关于强大的git(1)源代码管理SCM)工具的使用细节超出了本书的范围。请参阅进一步阅读部分,了解如何使用 Git 的有用链接。显然,我强烈建议至少基本了解如何使用git(1)

如前所述,截至撰写本文时,5.4 内核是最新的长期稳定LTS)版本,因此我们将在接下来的材料中使用它。那么,它是如何产生的呢?显然,它是从发布候选rc)内核和之前的稳定内核发布演变而来的,在这种情况下,是v5.4-rc'n'内核和之前的稳定v5.3。我们使用以下git log命令按日期顺序获取内核 Git 树中标签的可读日志。在这里,我们只对导致 5.4 LTS 内核发布的工作感兴趣,因此我们故意截断了以下输出,只显示了那部分内容:

git log命令(我们在下面的代码块中使用,实际上任何其他git子命令)只能在git树上工作。我们纯粹使用以下内容来演示内核的演变。稍后,我们将展示如何克隆 Git 树。

$ git log --date-order --graph --tags --simplify-by-decoration --pretty=format:'%ai %h %d'
* 2019-11-24 16:32:01 -0800 219d54332a09  (tag: v5.4)
* 2019-11-17 14:47:30 -0800 af42d3466bdc  (tag: v5.4-rc8)
* 2019-11-10 16:17:15 -0800 31f4f5b495a6  (tag: v5.4-rc7)
* 2019-11-03 14:07:26 -0800 a99d8080aaf3  (tag: v5.4-rc6)
* 2019-10-27 13:19:19 -0400 d6d5df1db6e9  (tag: v5.4-rc5)
* 2019-10-20 15:56:22 -0400 7d194c2100ad  (tag: v5.4-rc4)
* 2019-10-13 16:37:36 -0700 4f5cafb5cb84  (tag: v5.4-rc3)
* 2019-10-06 14:27:30 -0700 da0c9ea146cb  (tag: v5.4-rc2)
* 2019-09-30 10:35:40 -0700 54ecb8f7028c  (tag: v5.4-rc1)
* 2019-09-15 14:19:32 -0700 4d856f72c10e  (tag: v5.3)
* 2019-09-08 13:33:15 -0700 f74c2bb98776  (tag: v5.3-rc8)
* 2019-09-02 09:57:40 -0700 089cf7f6ecb2  (tag: v5.3-rc7)
* 2019-08-25 12:01:23 -0700 a55aa89aab90  (tag: v5.3-rc6)
[...]

啊哈!在前面的代码块中,您可以清楚地看到稳定的 5.4 内核于 2019 年 11 月 24 日发布,5.3 树于 2019 年 9 月 15 日发布(您也可以通过查找其他有用的内核资源来验证,例如kernelnewbies.org/LinuxVersions)。

对于最终导致 5.4 内核的开发系列,后一个日期(2019 年 9 月 15 日)标志着所谓的合并窗口的开始,为期(大约)两周的下一个稳定内核。在此期间,开发人员被允许向内核树提交新代码(实际上,实际工作早在很早之前就已经进行了;这项工作的成果现在已经在此时合并到主线)。

两周后(2019 年 9 月 30 日),合并窗口关闭,rc内核工作开始,5.4-rc1rc版本的第一个版本,当然。-rc(也称为预补丁)树主要用于合并补丁和修复(回归)错误,最终导致由主要维护者(Linus Torvalds 和 Andrew Morton)确定为“稳定”的内核树。预补丁(-rc发布)的数量有所不同。通常,这个“错误修复”窗口需要 6 到 10 周的时间,之后新的稳定内核才会发布。在前面的代码块中,我们可以看到八个发布候选内核最终导致了 2019 年 11 月 24 日发布了 v5.4 树(共计 70 天)。

可以通过github.com/torvalds/linux/releases页面更直观地看到:

图 2.1 - 导致 5.4 LTS 内核的发布(自下而上阅读)

前面的截图是部分截图,显示了各种v5.4-rc'n'发布候选内核最终导致了 LTS 5.4 树的发布(2019 年 11 月 25 日,v5.4-rc8是最后一个rc发布)。工作从未真正停止:到 2019 年 12 月初,v5.5-rc1发布候选版本已经发布。

通常情况下,以 5.x 内核系列为例(对于任何其他最近的major内核系列也是如此),内核开发工作流程如下:

  1. 5.x 稳定版本已经发布。因此,5.x+1(主线)内核的合并窗口已经开始。

  2. 合并窗口保持开放约 2 周,新的补丁被合并到主线。

  3. 一旦(通常)过去了 2 周,合并窗口就会关闭。

  4. rc(也称为主线,预补丁)内核开始。5.x+1-rc1, 5.x+1-rc2, ..., 5.x+1-rcn被发布。这个过程需要 6 到 8 周的时间。

  5. 稳定版本已经发布:新的5.x+1稳定内核已经发布。

  6. 发布被移交给“稳定团队”:

  • 重大的错误或安全修复导致了5.x+1.y的发布:

5.x+1.1, 5**.x+1.2, ... , 5.x+1.n

  • 维护直到下一个稳定发布或生命周期结束(EOL)日期到达

...整个过程重复。

因此,当您看到 Linux 内核发布时,名称和涉及的过程将变得合乎情理。现在让我们继续看看不同类型的内核源树。

内核源树的类型

有几种类型的 Linux 内核源树。关键的是长期支持(LTS)内核。好吧,LTS 发布内核到底是什么?它只是一个“特殊”的发布,内核维护者将继续在其上进行重要的错误和安全修复的后移(嗯,安全问题通常只是错误),直到给定的 EOL 日期。

LTS 内核的“寿命”通常至少为 2 年,它可以延长多年(有时会延长)。我们将在本书中使用的5.4 LTS 内核是第 20 个 LTS 内核,其寿命超过 6 年-从 2019 年 11 月到 2025 年 12 月

存储库中有几种类型的发布内核。然而,在这里,我们提到一个不完整的列表,按稳定性从低到高排序(因此,它们的生命周期从最短到最长):

  • -next 树:这确实是最前沿的,子系统树中收集了新的补丁进行测试和审查。这是上游内核贡献者将要处理的内容。

  • 预补丁,也称为-rc 或主线:这些是在发布之前生成的候选版本内核。

  • 稳定内核:顾名思义,这是业务端。这些内核通常会被发行版和其他项目采用(至少起初是这样)。它们也被称为原始内核。

  • 发行版和 LTS 内核:发行版内核(显然)是发行版提供的内核。它们通常以基本的原始/稳定内核开始。LTS 内核是专门维护更长时间的内核,使它们特别适用于行业/生产项目和产品。

在本书中,我们将一直使用撰写时的最新 LTS 内核,即 5.4 LTS 内核。正如我在第一章中提到的,内核工作区设置,5.4 LTS 内核最初计划的 EOL 是“至少 2021 年 12 月”。最近(2020 年 6 月),它现在被推迟到2025 年 12 月,使本书的内容在未来几年仍然有效!

  • 超长期支持(SLTS)内核:更长时间维护的 LTS 内核(由民用基础设施平台www.cip-project.org/)提供支持,这是一个 Linux 基金会项目)。

这是相当直观的。尽管如此,我建议您访问 kernel.org 的 Releases 页面获取有关发布内核类型的详细信息:www.kernel.org/releases.html。同样,要获取更多详细信息,请访问开发过程如何工作www.kernel.org/doc/html/latest/process/2.Process.html#how-the-development-process-works)。

有趣的是,某些 LTS 内核是非常长期的发布,称为SLTS超长期支持内核。例如,4.4 Linux 内核(第 16 个 LTS 发布)被认为是一个 SLTS 内核。作为 SLTS 选择的第一个内核,民用基础设施平台将提供支持至少到 2026 年,可能一直到 2036 年。

以非交互式可脚本化的方式查询存储库www.kernel.org可以使用curl(1)(以下输出是截至 2021 年 1 月 5 日的 Linux 状态):

$ curl -L https://www.kernel.org/finger_banner The latest stable version of the Linux kernel is: 5.10.4
The latest mainline version of the Linux kernel is: 5.11-rc2
The latest stable 5.10 version of the Linux kernel is: 5.10.4
The latest stable 5.9 version of the Linux kernel is: 5.9.16 (EOL)
The latest longterm 5.4 version of the Linux kernel is: 5.4.86
The latest longterm 4.19 version of the Linux kernel is: 4.19.164
The latest longterm 4.14 version of the Linux kernel is: 4.14.213
The latest longterm 4.9 version of the Linux kernel is: 4.9.249
The latest longterm 4.4 version of the Linux kernel is: 4.4.249
The latest linux-next version of the Linux kernel is: next-20210105
$ 

当然,当您阅读本书时,内核极有可能(事实上是肯定的)已经进化,并且稍后的版本会出现。对于这样一本书,我能做的就是选择撰写时的最新 LTS 内核。

当然,这已经发生了!5.10 内核于 2020 年 12 月 13 日发布,截至撰写时(即将印刷之前),5.11 内核的工作正在进行中……

最后,另一种安全下载给定内核的方法是由内核维护者提供的,他们提供了一个脚本来安全地下载给定的 Linux 内核源树,并验证其 PGP 签名。该脚本在这里可用:git.kernel.org/pub/scm/linux/kernel/git/mricon/korg-helpers.git/tree/get-verified-tarball

好了,现在我们已经掌握了内核版本命名规则和内核源树类型的知识,是时候开始我们构建内核的旅程了。

从源码构建内核的步骤

作为一个方便和快速的参考,以下是构建 Linux 内核源码所需的关键步骤。由于每个步骤的解释都非常详细,您可以参考这个摘要来了解整体情况。步骤如下:

  1. 通过以下选项之一获取 Linux 内核源树:
  • 下载特定内核源作为压缩文件

  • 克隆(内核)Git 树

  1. 将内核源树提取到家目录中的某个位置(如果您通过克隆 Git 树获得内核,则跳过此步骤)。

  2. 配置:根据新内核的需要选择内核支持选项,

make [x|g|menu]config,其中make menuconfig是首选方式。

  1. 使用make [-j'n'] all构建内核的可加载模块和任何设备树块DTB)。这将构建压缩的内核映像(arch/<arch>/boot/[b|z|u]image)、未压缩的内核映像(vmlinux)、System.map、内核模块对象和任何已配置的 DTB(s)文件。

  2. 使用sudo make modules_install安装刚构建的内核模块。

此步骤默认将内核模块安装在/lib/modules/$(uname -r)/下。

  1. 设置 GRUB 引导加载程序和initramfs(之前称为initrd)映像(特定于 x86):

sudo make install

  • 这将在/boot下创建并安装initramfs(或initrd)映像。

  • 它更新引导加载程序配置文件以启动新内核(第一个条目)。

  1. 自定义 GRUB 引导加载程序菜单(可选)。

本章是关于这个主题的两章中的第一章,基本上涵盖了步骤 1 到 3,还包括了许多必需的背景材料。下一章将涵盖剩下的步骤,4 到 7。所以,让我们从第 1 步开始。

第 1 步——获取 Linux 内核源树

在这一部分,我们将看到两种获取 Linux 内核源树的广泛方法:

  • 通过从 Linux 内核公共存储库(www.kernel.org)下载和提取特定的内核源树

  • 通过克隆 Linus Torvalds 的源树(或其他人的)——例如,linux-next Git 树

但是你如何决定使用哪种方法?对于像您这样在项目或产品上工作的大多数开发人员来说,决定已经做出了——项目使用一个非常特定的 Linux 内核版本。因此,您将下载该特定的内核源树,如果需要,可能会对其应用特定于项目的补丁,并使用它。

对于那些打算向主线内核贡献或"上游"代码的人来说,第二种方法——克隆 Git 树——是您应该选择的方式。(当然,这还有更多内容;我们在内核源树类型部分中描述了一些细节)。

在接下来的部分中,我们将演示这两种方法。首先,我们描述了一种从内核存储库下载特定内核源树(而不是 Git 树)的方法。我们选择了截至撰写时的最新 LTS 5.4 Linux 内核来进行演示。在第二种方法中,我们克隆了一个 Git 树。

下载特定的内核树

首先,内核源代码在哪里?简短的答案是它在www.kernel.org上可见的公共内核存储库服务器上。该站点的主页显示了最新的稳定 Linux 内核版本,以及最新的longtermlinux-next发布(下面的截图显示了 2019 年 11 月 29 日的站点。它显示了以众所周知的yyyy-mm-dd格式的日期):

图 2.2 - kernel.org 网站(截至 2019 年 11 月 29 日)

快速提醒:我们还提供了一个 PDF 文件,其中包含本书中使用的截图/图表的全彩图像。您可以在这里下载:static.packt-cdn.com/downloads/9781789953435_ColorImages.pdf

有许多种方法可以下载(压缩的)内核源文件。让我们看看其中的两种:

  • 一个交互式,也许是最简单的方法,是访问上述网站,然后简单地点击适当的tarball链接。浏览器将会下载图像文件(以.tar.xz格式)到您的系统。

  • 或者,您可以使用wget(1)实用程序(我们也可以使用强大的curl(1)实用程序来做到这一点)从命令行(shell 或 CLI)下载它。例如,要下载稳定的 5.4.0 内核源代码压缩文件,我们可以这样做:

wget --https-only -O ~/Downloads/linux-5.4.0.tar.xz https://mirrors.edge.kernel.org/pub/linux/kernel/v5.x/linux-5.4.0.tar.xz

如果前面的wget(1)实用程序不起作用,很可能是因为内核(压缩的)tarball链接发生了变化。例如,如果对于5.4.0.tar.xz不起作用,尝试相同的wget实用程序,但将版本更改为5.4.1.tar.xz

这将安全地下载 5.4.0 压缩的内核源树到您计算机的~/Downloads文件夹中。当然,您可能不希望在存储库的主页上显示的内核版本。例如,如果对于我的特定项目,我需要最新的 4.19 稳定(LTS)内核,第 19 个 LTS 版本,怎么办?简单:通过浏览器,只需点击www.kernel.org/pub/(或镜像mirrors.edge.kernel.org/pub/)链接(在前几行显示的“HTTP”链接右侧)并导航到服务器上的linux/kernel/v4.x/目录(您可能会被引导到一个镜像站点)。或者,只需将wget(1)指向 URL(在撰写时,这里碰巧是mirrors.edge.kernel.org/pub/linux/kernel/v4.x/linux-4.19.164.tar.xz)。

克隆 Git 树

对于像您这样的开发人员,正在研究并寻求向上游贡献代码,您必须在 Linux 内核代码库的最新版本上工作。嗯,内核社区内有最新版本的微妙变化。如前所述,linux-next树以及其中的某个特定分支或标签,是为此目的而工作的树。

在这本书中,我们并不打算深入探讨建立linux-next树的血腥细节。这个过程已经有非常好的文档记录,我们更愿意不仅仅重复指令(详细链接请参见进一步阅读部分)。关于如何克隆linux-next树的详细页面在这里:使用 linux-nextwww.kernel.org/doc/man-pages/linux-next.html,正如在那里提到的,linux-next树*,git.kernel.org/cgit/linux/kernel/git/next/linux-next.git,是用于下一个内核合并窗口的补丁的存储区。如果你正在进行最前沿的内核开发,你可能希望从那个树上工作,而不是 Linus Torvalds 的主线树。

对于我们的目的,克隆mainlineLinux Git 存储库(Torvalds 的 Git 树)已经足够了。像这样做(在一行上输入):

git clone https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git

请注意,克隆完整的 Linux 内核树是一个耗时、耗网络和耗磁盘的操作!确保您有足够的磁盘空间可用(至少几个 GB)。

执行git clone --depth n <...>,其中n是一个整数值,非常有用,可以限制历史记录(提交)的深度,从而降低下载/磁盘使用量。正如git-clone(1)man页面中提到的--depth选项:“创建一个浅克隆,其历史记录被截断为指定数量的提交。”

根据前面的提示,为什么不执行以下操作(再次在一行上输入)?

git clone --depth=3 https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git

如果您打算在这个主线 Git 树上工作,请跳过步骤2 - 提取内核源树部分(因为git clone操作将在任何情况下提取源树),并继续进行下一部分(步骤 3 - 配置 Linux 内核)。

步骤 2 - 提取内核源树

如前所述,本节适用于那些从存储库www.kernel.org下载了特定的 Linux 内核并打算构建它的人。在本书中,我们使用 5.4 LTS 内核版本。另一方面,如果您已经在主线 Linux Git 树上执行了git clone,就像在前面的部分中所示的那样,您可以安全地跳过本节,继续进行下一节内核配置。

现在下载已经完成,让我们继续。下一步是提取内核源树 - 记住,它是一个经过 tar 和压缩的(通常是.tar.xz)文件。

我们假设,如本章前面详细介绍的那样,您现在已经将 Linux 内核版本 5.4 代码库下载为一个压缩文件(放入~/Downloads目录):

$ cd ~/Downloads ; ls -lh linux-5.4.tar.xz
-rw-rw-r-- 1 llkd llkd 105M Nov 26 08:04 linux-5.4.tar.xz

提取这个文件的简单方法是使用无处不在的tar(1)实用程序来完成:

tar xf ~/Downloads/linux-5.4.tar.xz

这将把内核源树提取到~/Downloads目录中名为linux-5.4的目录中。但是,如果我们想要将其提取到另一个文件夹,比如~/kernels中,那么可以这样做:

mkdir -p ~/kernels
tar xf ~/Downloads/linux-5.4.tar.xz --directory=${HOME}/kernels/

这将把内核源提取到~/kernels/linux-5.4/文件夹中。为了方便起见,也是一个良好的做法,让我们设置一个环境变量,指向我们内核源树根目录的位置:

export LLKD_KSRC=${HOME}/kernels/linux-5.4

请注意,从现在开始,我们将假设这个变量保存着内核源树的位置。

虽然您可以随时使用 GUI 文件管理器应用程序(如Nautilus(1))来提取压缩文件,但我强烈建议您熟悉使用 Linux CLI 来执行这些操作。

当您需要快速查找常用命令的最常用选项时,不要忘记tldr(1)!例如,对于tar(1),只需使用tldr tar来查找。

您注意到了吗?我们将内核源树提取到任何家目录下的任何目录中(甚至其他地方),不像以前那样总是提取到可写的根目录位置(通常是/usr/src/)。现在,只要说不(对于那个)。

如果您现在只想继续进行内核构建操作,请跳过以下部分并继续。如果感兴趣(我们当然希望如此!),下一节是一个简短但重要的偏离,看一下内核源树的结构和布局。

内核源树的简要介绍

内核源代码现在可以在您的系统上使用了!很酷,让我们快速看一下:

图 2.3 - 5.4 Linux 内核源树的根目录

太好了!它有多大?在内核源树的根目录中快速执行du -m .,可以看到这个特定的内核源树(记住,它是版本 5.4)的大小略大于 1,000 MB - 几乎是 1 GB!

值得一提的是,Linux 内核在代码行数(SLOCs)方面已经变得很大,并且正在变得越来越大。目前的估计是超过 2000 万行代码。当然,要意识到在构建内核时,并不是所有的代码都会被编译。

我们如何知道这段代码是哪个版本的 Linux 内核呢?很简单,一个快速的方法就是查看项目的 Makefile 的前几行。顺便说一句,内核在很多地方都使用 Makefile;大多数目录都有一个。我们将把这个 Makefile,也就是内核源代码树根目录下的 Makefile,称为顶层 Makefile

$ head Makefile
# SPDX-License-Identifier: GPL-2.0
VERSION = 5
PATCHLEVEL = 4
SUBLEVEL = 0
EXTRAVERSION =
NAME = Kleptomaniac Octopus

# *DOCUMENTATION*
# To see a list of typical targets execute "make help"
# More info can be located in ./README
$

显然,这是 5.4.0 内核的源代码。

让我们来看看内核源代码树的整体情况。以下表格总结了 Linux 内核源代码树根目录中(更)重要的文件和目录的广泛分类和目的:

文件或目录名称 目的
顶层文件
README 项目的 README 文件。它告诉我们内核文档存放在哪里 - 提示,它在名为 Documentation 的目录中 - 以及如何开始使用它。文档非常重要;它是由内核开发人员自己编写的真实内容。
COPYING 内核源代码发布的许可条款。绝大多数都是根据著名的 GNU GPL v2(写作 GPL-2.0)许可证发布的 [1]。
MAINTAINERS 常见问题: XYZ 出了问题,我应该联系谁获取支持? 这正是这个文件提供的 - 所有内核子系统的列表,甚至到个别组件(如特定驱动程序)的级别,它的状态,当前维护者,邮件列表,网站等等。非常有帮助!甚至有一个辅助脚本可以找到需要联系的人或团队:scripts/get_maintainer.pl [2]。
Makefile 这是内核的顶层 Makefile;kbuild 内核构建系统以及内核模块最初使用这个 Makefile 进行构建。
主要子系统目录
kernel/ 核心内核子系统:这里的代码涉及进程/线程生命周期,CPU 调度,锁定,cgroups,定时器,中断,信号,模块,跟踪等等。
mm/ 大部分内存管理mm)代码都在这里。我们将在第六章中涵盖一些内容,即内核内部要点 - 进程和线程,以及在第七章中涵盖一些相关内容,即内存管理内部要点,以及在第八章中涵盖一些内容,即模块作者的内核内存分配 - 第一部分
fs/ 这里的代码实现了两个关键的文件系统功能:抽象层 - 内核虚拟文件系统开关VFS),以及各个文件系统驱动程序(例如 `ext[2
block/ 底层(对于 VFS/FS)块 I/O 代码路径。它包括实现页面缓存、通用块 I/O 层、I/O 调度器等代码。
net/ 完整(按照请求评论RFC)的要求 - whatis.techtarget.com/definition/Request-for-Comments-RFC)实现了网络协议栈。包括高质量的 TCP、UDP、IP 等许多网络协议的实现。
ipc/ 进程间通信IPC)子系统代码;涵盖 IPC 机制,如(SysV 和 POSIX)消息队列,共享内存,信号量等。
sound/ 音频子系统代码,也称为高级 Linux 音频架构ALSA)。
virt/ 虚拟化(hypervisor)代码;流行且强大的内核虚拟机KVM)就是在这里实现的。
基础设施/其他
arch/ 这里存放着特定架构的代码(在这里,架构指的是 CPU)。Linux 最初是为 i386 架构的一个小型爱好项目。现在可能是最多移植的操作系统(请参见表后面的 步骤 3 中的架构移植)。
crypto/ 此目录包含密码(加密/解密算法,也称为转换)的内核级实现和内核 API,以为需要加密服务的消费者提供服务。
include/ 此目录包含与架构无关的内核头文件(还有一些特定架构的头文件在 arch/<cpu>/include/... 下)。
init/ 与架构无关的内核初始化代码;也许我们能接近内核的主要功能(记住,内核不是一个应用程序)就在这里:init/main.c:start_kernel(),其中的 start_kernel() 函数被认为是内核初始化期间的早期 C 入口点。
lib/ 这是内核最接近库的等价物。重要的是要理解,内核不支持像用户空间应用程序那样的共享库。这里的代码会自动链接到内核映像文件中,因此在运行时对内核可用(/lib 中存在各种有用的组件:[解]压缩、校验和、位图、数学、字符串例程、树算法等)。
scripts/ 这里存放着各种脚本,其中一些用于内核构建,许多用于其他目的(如静态/动态分析等),主要是 Bash 和 Perl。
security/ 包含内核的 Linux 安全模块LSM),这是一个旨在对用户应用程序对内核空间的访问控制施加更严格限制的 强制访问控制MAC)框架,比默认内核模型(称为 自由访问控制DAC))更严格。目前,Linux 支持几种 LSM;其中一些知名的是 SELinux、AppArmor、Smack、Tomoyo、Integrity 和 Yama(请注意,LSM 默认情况下是“关闭”的)。
tools/ 这里存放着各种工具,主要是与内核有“紧密耦合”的用户空间应用程序(或脚本),如现代性能分析工具 perf 就是一个很好的例子。

表 2.2 – Linux 内核源代码树的布局

表中以下是一些重要的解释:

  1. 内核许可证:不要陷入法律细节,这里是事物的实质:由于内核是根据 GNU GPL-2.0 许可证发布的(GNU GPLGNU 通用公共许可证),任何直接使用内核代码库的项目(即使只有一点点!)都自动属于这个许可证(GPL-2.0 的“衍生作品”属性)。这些项目或产品必须按照相同的许可条款发布其内核。实际上,实际情况要复杂得多;许多在 Linux 内核上运行的商业产品确实包含专有的用户空间和/或内核空间代码。它们通常通过重构内核(通常是设备驱动程序)工作为 可加载内核模块LKM)格式来实现。可以以 双重许可 模式发布内核模块(LKM)(例如,双重 BSD/GPL;LKM 是 第四章 和 第五章 的主题,我们在那里涵盖了一些关于内核模块许可的信息)。一些人更喜欢专有许可证,他们设法发布其内核代码,而不受 GPL-2.0 条款的约束;从技术上讲,这可能是可能的,但(至少)被认为是反社会的(甚至可能违法)。感兴趣的人可以在本章的 进一步阅读 文档中找到更多关于许可证的链接。

  2. MAINTAINERS:运行get_maintainer.pl Perl 脚本的示例(注意:它只能在 Git 树上运行):

$ scripts/get_maintainer.pl -f drivers/android/ Greg Kroah-Hartman <gregkh@linuxfoundation.org> (supporter:ANDROID DRIVERS)
"Arve Hjønnevåg" <arve@android.com> (supporter:ANDROID DRIVERS)
Todd Kjos <tkjos@android.com> (supporter:ANDROID DRIVERS)
Martijn Coenen <maco@android.com> (supporter:ANDROID DRIVERS)
Joel Fernandes <joel@joelfernandes.org> (supporter:ANDROID DRIVERS)
Christian Brauner <christian@brauner.io> (supporter:ANDROID DRIVERS)
devel@driverdev.osuosl.org (open list:ANDROID DRIVERS)
linux-kernel@vger.kernel.org (open list)
$ 
  1. Linux arch(CPU)端口:
$ cd ${LLKD_KSRC} ; ls arch/
alpha/ arm64/ h8300/   Kconfig     mips/  openrisc/ riscv/ sparc/ x86/
arc/   c6x/   hexagon/ m68k/       nds32/ parisc/   s390/  um/    xtensa/
arm/   csky/  ia64/    microblaze/ nios2/ powerpc/  sh/    unicore32/

作为内核或驱动程序开发人员,浏览内核源代码树是你必须要习惯(甚至喜欢!)的事情。当代码量接近 2000 万 SLOC 时,搜索特定函数或变量可能是一项艰巨的任务!要使用高效的代码浏览工具。我建议使用ctags(1)cscope(1)这些自由开源软件FOSS)工具。事实上,内核的顶层Makefile有针对这些工具的目标:

make tags ; make cscope

我们现在已经完成了步骤 2,提取内核源代码树!作为奖励,您还学会了有关内核源代码布局的基础知识。现在让我们继续进行步骤 3的过程,并学习如何在构建之前配置Linux 内核。

第 3 步-配置 Linux 内核

配置新内核可能是内核构建过程中关键的一步。Linux 备受好评的原因之一是其多功能性。普遍的误解是认为(企业级)服务器、数据中心、工作站和微型嵌入式 Linux 设备有各自独立的 Linux 内核代码库-不,它们都使用同一个统一的 Linux 内核源代码!因此,仔细配置内核以满足特定用例(服务器、桌面、嵌入式或混合/自定义)是一个强大的功能和要求。这正是我们在这里深入研究的内容。

无论如何都要执行内核配置步骤。即使您觉得不需要对现有(或默认)配置进行任何更改,至少在构建过程的一部分中运行此步骤非常重要。否则,这里自动生成的某些标头将丢失并引起问题。至少应执行make oldconfig。这将将内核配置设置为现有系统的配置,用户仅对任何新选项进行请求。

首先,让我们了解一下内核构建kbuild)系统的一些必要背景。

了解 kbuild 构建系统

Linux 内核用于配置和构建内核的基础设施被称为kbuild系统。不深入了解复杂的细节,kbuild 系统通过四个关键组件将复杂的内核配置和构建过程联系在一起:

  • CONFIG_FOO符号

  • 菜单规范文件,称为Kconfig

  • Makefile(s)

  • 总体内核配置文件

这些组件的目的总结如下:

Kbuild 组件 简要目的
配置符号:CONFIG_FOO 每个内核可配置的FOO都由CONFIG_FOO宏表示。根据用户的选择,该宏将解析为ymn中的一个:- y=yes:表示将该功能构建到内核映像本身中- m=module:表示将其构建为一个独立对象,即内核模块- n=no:表示不构建该功能请注意,CONFIG_FOO是一个字母数字字符串(很快我们将看到,您可以使用make menuconfig选项查找精确的配置选项名称,导航到配置选项,并选择<帮助>按钮)。
Kconfig文件 这是CONFIG_FOO符号定义的地方。kbuild 语法指定了它的类型(布尔值、三态值、[alpha]数字等)和依赖树。此外,对于基于菜单的配置 UI(通过make [menu&#124;g&#124;x]config之一调用),它指定了菜单条目本身。当然,我们稍后将使用此功能。
Makefile(s) kbuild 系统使用递归Makefile 方法。内核源代码树根文件夹下的 Makefile 称为顶层Makefile,在每个子文件夹中都有一个 Makefile 来构建那里的源代码。5.4 原始内核源代码中总共有 2500 多个 Makefile!
.config文件 最终,它的本质-实际的内核配置-以 ASCII 文本文件的形式生成并存储在内核源树根目录中的.config文件中。请保管好这个文件,它是产品的关键部分。

表 2.3 - Kbuild 构建系统的主要组件

关键是获得一个可用的.config文件。我们如何做到这一点?我们进行迭代。我们从“默认”配置开始-下一节的主题-并根据需要仔细地进行自定义配置。

到达默认配置

那么,您如何决定初始内核配置从哪里开始?存在几种技术;一些常见的技术如下:

  • 不指定任何内容;kbuild 系统将引入默认内核配置。

  • 使用现有发行版的内核配置。

  • 基于当前加载在内存中的内核模块构建自定义配置。

第一种方法的好处是简单性。内核将处理细节,为您提供默认配置。缺点是默认配置实际上相当大(在这里,我们指的是构建面向 x86 桌面或服务器类型系统的 Linux)-大量选项被打开,以防万一,这可能会使构建时间非常长,内核映像大小非常大。当然,您随后需要手动配置内核以获得所需的设置。

这带来了一个问题,默认内核配置存储在哪里?kbuild 系统使用优先级列表回退方案来检索默认配置。优先级列表及其顺序(第一个优先级最高)在init/Kconfig:DEFCONFIG_LIST中指定:

$ cat init/Kconfig
config DEFCONFIG_LIST
    string
    depends on !UML 
    option defconfig_list
    default "/lib/modules/$(shell,uname -r)/.config"
    default "/etc/kernel-config"
    default "/boot/config-$(shell,uname -r)"
    default ARCH_DEFCONFIG
    default "arch/$(ARCH)/defconfig"
config CC_IS_GCC
[...]

顺便说一句,关于Kconfig的内核文档(在此处找到:www.kernel.org/doc/Documentation/kbuild/kconfig-language.txt)记录了defconfig_list是什么:

"defconfig_list"
    This declares a list of default entries which can be used when
    looking for the default configuration (which is used when the main
    .config doesn't exists yet.)

从列表中可以看出,kbuild 系统首先检查/lib/modules/$(uname -r)文件夹中是否存在.config文件。如果找到,其中的值将被用作默认值。如果未找到,则接下来检查/etc/kernel-config文件是否存在。如果找到,其中的值将被用作默认值,如果未找到,则继续检查前面优先级列表中的下一个选项,依此类推。但请注意,内核源树根目录中存在.config文件将覆盖所有这些!

获取内核配置的良好起点

这带我们来到一个非常重要的观点:玩弄内核配置作为学习练习是可以的(就像我们在这里做的那样),但对于生产系统,使用已知、经过测试和工作的内核配置真的非常重要。

在这里,为了帮助您理解选择内核配置的有效起点的微妙之处,我们将看到三种获得内核配置起点的方法(我们希望)是典型的:

  • 首先,对于典型的小型嵌入式 Linux 系统要遵循的方法

  • 接下来,一种模拟发行版配置的方法

  • 最后,一种基于现有(或其他)系统的内核模块的内核配置的方法(localmodconfig方法)

让我们更详细地检查每种方法。

典型嵌入式 Linux 系统的内核配置

使用此方法的典型目标系统是小型嵌入式 Linux 系统。这里的目标是从已知、经过测试和工作的内核配置开始我们的嵌入式 Linux 项目。那么,我们究竟如何做到这一点呢?

有趣的是,内核代码库本身为各种硬件平台提供了已知、经过测试和工作的内核配置文件。我们只需选择与我们的嵌入式目标板匹配(或最接近匹配)的配置文件。这些内核配置文件位于内核源树中的arch/<arch>/configs/目录中。配置文件的格式为<platform-name>_defconfig。让我们快速看一下;看一下以下屏幕截图,显示了在 v5.4 Linux 内核代码库上执行ls arch/arm/configs命令:

图 2.4 - 5.4 Linux 内核中 arch/arm/configs 的内容

因此,例如,如果您发现自己为具有三星 Exynos 片上系统SoC)的硬件平台配置 Linux 内核,请不要从默认的 x86-64 内核配置文件开始(或者尝试使用它)。这样不会起作用。即使您成功了,内核也不会干净地构建/工作。选择适当的内核配置文件:对于我们的示例,arch/arm/configs/exynos_defconfig文件将是一个很好的起点。您可以将此文件复制到内核源树的根目录中的.config,然后继续对其进行微调以满足项目特定需求。

举个例子,树莓派(www.raspberrypi.org/)是一种流行的业余爱好者平台。内核配置文件 - 在其内核源树中 - 使用(作为基础)的是这个:arch/arm/configs/bcm2835_defconfig。文件名反映了树莓派板使用的是基于 Broadcom 2835 的 SoC。您可以在这里找到有关树莓派内核编译的详细信息:www.raspberrypi.org/documentation/linux/kernel/building.md。不过,我们将在第三章中至少涵盖其中的一些内容,从源代码构建 5.x Linux 内核 - 第二部分,在树莓派的内核构建部分。

查看哪个平台的配置文件适合哪个平台的简单方法是在目标平台上执行make help。输出的后半部分显示了特定架构目标标题下的配置文件(请注意,这是针对外部 CPU 的,不适用于 x86[-64])。

对产品进行内核配置的仔细调整和设置是平台板支持包BSP)团队工程师通常进行的重要工作的一部分。

使用发行版配置作为起点的内核配置

使用这种方法的典型目标系统是桌面或服务器 Linux 系统。

接下来,这第二种方法也很快:

cp /boot/config-5.0.0-36-generic ${LLKD_KSRC}/.config

在这里,我们只需将现有的 Linux 发行版(这里是我们的 Ubuntu 18.04.3 LTS 虚拟机)的配置文件复制到内核源树根目录中的.config文件中,从而使发行版配置成为起点,然后可以进一步编辑(更通用的命令:cp /boot/config-$(uname -r) ${LLKD_KSRC}/.config)。

通过 localmodconfig 方法调整内核配置

使用这种方法的典型目标系统是桌面或服务器 Linux 系统。

我们考虑的第三种方法是一个很好的方法,当目标是从基于现有系统的内核配置开始时,通常相对于桌面或服务器 Linux 系统的典型默认配置来说,它相对较小。在这里,我们通过简单地将lsmod(8)的输出重定向到临时文件,然后将该文件提供给构建,向 kbuild 系统提供了系统上当前运行的内核模块的快照。可以通过以下方式实现:

lsmod > /tmp/lsmod.now
cd ${LLKD_KSRC}
make LSMOD=/tmp/lsmod.now localmodconfig

lsmod(8)实用程序简单地列出当前驻留在系统(内核)内存中的所有内核模块。我们将在第四章中详细介绍这个(很多)。我们将其输出保存在一个临时文件中,并将其传递到 Makefile 的localmodconfig目标中的LSMOD环境变量中。此目标的工作是以一种只包括基本功能以及这些内核模块提供的功能的方式配置内核,并排除其余部分,从而实际上给我们提供了当前内核的合理外观(或lsmod输出所代表的任何内核)。我们将使用这种技术来配置我们的 5.4 内核,接下来是使用 localmodconfig 方法开始部分。

好了,这就结束了为内核配置设置起点的三种方法。事实上,我们只是触及了表面。许多更多的技术被编码到 kbuild 系统本身中,以明确地生成给定方式的内核配置!如何?通过make的配置目标。在Configuration targets标题下查看它们:

$ cd ${LKDC_KSRC}         *# root of the kernel source tree*
$ make help
Cleaning targets:
 clean             - Remove most generated files but keep the config and
 enough build support to build external modules
 mrproper          - Remove all generated files + config + various backup     
                     files
 distclean         - mrproper + remove editor backup and patch files

Configuration targets:
 config           - Update current config utilising a line-oriented  
                    program
 nconfig          - Update current config utilising a ncurses menu based 
                    program
 menuconfig       - Update current config utilising a menu based program
 xconfig          - Update current config utilising a Qt based front-end
 gconfig          - Update current config utilising a GTK+ based front-end
 oldconfig        - Update current config utilising a provided .config as 
                    base
 localmodconfig   - Update current config disabling modules not loaded
 localyesconfig   - Update current config converting local mods to core
 defconfig        - New config with default from ARCH supplied defconfig
 savedefconfig    - Save current config as ./defconfig (minimal config)
 allnoconfig      - New config where all options are answered with no
 allyesconfig     - New config where all options are accepted with yes
 allmodconfig     - New config selecting modules when possible
 alldefconfig     - New config with all symbols set to default
 randconfig       - New config with random answer to all options
 listnewconfig    - List new options
 olddefconfig     - Same as oldconfig but sets new symbols to their
                    default value without prompting
 kvmconfig        - Enable additional options for kvm guest kernel support
 xenconfig        - Enable additional options for xen dom0 and guest   
                    kernel support
 tinyconfig       - Configure the tiniest possible kernel
 testconfig       - Run Kconfig unit tests (requires python3 and pytest)

Other generic targets:
  all             - Build all targets marked with [*]
[...]
$

一个快速但非常有用的要点:为了确保一张干净的纸,首先使用mrproper目标。接下来我们将展示所有步骤的摘要,所以现在不要担心。

使用 localmodconfig 方法开始

现在,让我们快速开始使用我们之前讨论过的第三种方法 - localmodconfig技术为我们的新内核创建一个基本内核配置。如前所述,这种现有的仅内核模块方法是一个很好的方法,当目标是在基于 x86 的系统上获得内核配置的起点时,通过保持相对较小的内核配置,从而使构建速度更快。

不要忘记:当前正在执行的内核配置适用于您典型的基于 x86 的桌面/服务器系统。对于嵌入式目标,方法是不同的(如在典型嵌入式 Linux 系统的内核配置部分中所见)。我们将在第三章中进一步介绍这一点,从源代码构建 5.x Linux 内核 - 第二部分,在树莓派的内核构建部分。

如前所述,首先获取当前加载的内核模块的快照,然后通过指定localmodconfig目标让 kbuild 系统对其进行操作,如下所示:

lsmod > /tmp/lsmod.now
cd ${LLKD_KSRC} ; make LSMOD=/tmp/lsmod.now localmodconfig

现在,要理解的是:当我们执行实际的make [...] localmodconfig命令时,当前正在构建的内核(版本 5.4)与当前实际运行构建的内核($(uname -r) = 5.0.0-36-generic)之间的配置选项可能会有差异,甚至很可能会有差异。在这些情况下,kbuild 系统将在控制台(终端)窗口上显示每个新的配置选项以及您可以设置的可用值。然后,它将提示用户选择正在构建的内核中遇到的任何新的配置选项的值。您将看到这是一系列问题,并提示在命令行上回答它们。

提示将以(NEW)为后缀,实际上告诉您这是一个的内核配置选项,并希望您回答如何配置它。

在这里,至少,我们将采取简单的方法:只需按[Enter]键接受默认选择,如下所示:

$ uname -r5.0.0-36-generic $ make LSMOD=/tmp/lsmod.now localmodconfig 
using config: '/boot/config-5.0.0-36-generic'
vboxsf config not found!!
module vboxguest did not have configs CONFIG_VBOXGUEST
*
* Restart config...
*
*
* General setup
*
Compile also drivers which will not load (COMPILE_TEST) [N/y/?] n
Local version - append to kernel release (LOCALVERSION) [] 
Automatically append version information to the version string (LOCALVERSION_AUTO) [N/y/?] n
Build ID Salt (BUILD_SALT) [] (NEW) [Enter] Kernel compression mode
> 1\. Gzip (KERNEL_GZIP)
  2\. Bzip2 (KERNEL_BZIP2)
  3\. LZMA (KERNEL_LZMA)
  4\. XZ (KERNEL_XZ)
  5\. LZO (KERNEL_LZO)
  6\. LZ4 (KERNEL_LZ4)
choice[1-6?]: 1
Default hostname (DEFAULT_HOSTNAME) [(none)] (none)
Support for paging of anonymous memory (swap) (SWAP) [Y/n/?] y
System V IPC (SYSVIPC) [Y/n/?] y
[...]
Enable userfaultfd() system call (USERFAULTFD) [Y/n/?] y
Enable rseq() system call (RSEQ) [Y/n/?] (NEW)
[...]
  Test static keys (TEST_STATIC_KEYS) [N/m/?] n
  kmod stress tester (TEST_KMOD) [N/m/?] n
  Test memcat_p() helper function (TEST_MEMCAT_P) [N/m/y/?] (NEW)
#
# configuration written to .config
#
$ ls -la .config
-rw-r--r-- 1 llkd llkd  140764 Mar  7 17:31 .config
$ 

按下[Enter]键多次后,询问终于结束,kbuild 系统将新生成的配置写入当前工作目录中的.config文件中(我们截断了先前的输出,因为它太庞大,而且没有必要完全重现)。

前面两个步骤负责通过localmodconfig方法生成.config文件。在结束本节之前,这里有一些要注意的关键点:

  • 为了确保完全干净的状态,在内核源代码树的根目录中运行make mrpropermake distclean(当您想从头开始重新启动时很有用;请放心,总有一天会发生!请注意,这将删除内核配置文件)。

  • 在本章中,所有与内核配置步骤和相关截图都是在 Ubuntu 18.04.3 LTS x86-64 虚拟机上执行的,我们将其用作构建全新的 5.4 Linux 内核的主机。菜单项的名称、存在和内容,以及菜单系统(UI)的外观和感觉可能会根据(a)架构(CPU)和(b)内核版本而有所不同。

  • 正如前面提到的,在生产系统或项目中,平台或板支持包BSP)团队,或者如果您与嵌入式 Linux BSP 供应商合作,他们会提供一个已知的、可工作和经过测试的内核配置文件。请将其用作起点,将其复制到内核源代码树根目录中的.config文件中。

随着构建内核的经验增加,您会意识到第一次正确设置内核配置的工作量(至关重要!)更大;当然,第一次构建所需的时间也更长。不过,一旦正确完成,整个过程通常会变得简单得多 - 一个可以一遍又一遍运行的配方。

现在,让我们学习如何使用一个有用且直观的 UI 来调整我们的内核配置。

通过 make menuconfig UI 调整我们的内核配置

好的,很好,我们现在有一个通过localmodconfig Makefile 目标为我们生成的初始内核配置文件(.config),如前一节详细介绍的那样,这是一个很好的起点。现在,我们希望进一步检查和微调我们的内核配置。一种方法是通过menuconfig Makefile 目标 - 实际上,是推荐的方法。这个目标让 kbuild 系统生成一个相当复杂的(基于 C 的)程序可执行文件(scripts/kconfig/mconf),向最终用户呈现一个整洁的基于菜单的 UI。在下面的代码块中,当我们第一次调用该命令时,kbuild 系统会构建mconf可执行文件并调用它:

$ make menuconfig
 UPD scripts/kconfig/.mconf-cfg
 HOSTCC scripts/kconfig/mconf.o
 HOSTCC scripts/kconfig/lxdialog/checklist.o
 HOSTCC scripts/kconfig/lxdialog/inputbox.o
 HOSTCC scripts/kconfig/lxdialog/menubox.o
 HOSTCC scripts/kconfig/lxdialog/textbox.o
 HOSTCC scripts/kconfig/lxdialog/util.o
 HOSTCC scripts/kconfig/lxdialog/yesno.o
 HOSTLD scripts/kconfig/mconf
scripts/kconfig/mconf Kconfig
...

当然,一张图片无疑价值千言万语,这是menuconfig的 UI 外观:

图 2.5 - 通过 make menuconfig 进行内核配置的主菜单(在 x86-64 上)

作为经验丰富的开发人员,或者任何足够使用计算机的人都知道,事情可能会出错。例如,以下情景 - 在新安装的 Ubuntu 系统上第一次运行make menuconfig

$ make menuconfig
 UPD     scripts/kconfig/.mconf-cfg
 HOSTCC  scripts/kconfig/mconf.o
 YACC    scripts/kconfig/zconf.tab.c
/bin/sh: 1: bison: not found
scripts/Makefile.lib:196: recipe for target 'scripts/kconfig/zconf.tab.c' failed
make[1]: *** [scripts/kconfig/zconf.tab.c] Error 127
Makefile:539: recipe for target 'menuconfig' failed
make: *** [menuconfig] Error 2
$

等一下,不要慌(还)。仔细阅读失败消息。YACC [...]后的一行提供了线索:/bin/sh: 1: bison: not found。啊,所以用以下命令安装bison(1)

sudo apt install bison

现在,一切应该都好了。嗯,几乎;同样,在新安装的 Ubuntu 系统上,make menuconfig然后抱怨flex(1)未安装。所以,我们安装它(你猜对了:通过sudo apt install flex)。此外,在 Ubuntu 上,您需要安装libncurses5-dev包(在 Fedora 上,执行sudo dnf install ncurses-devel)。

如果您已经阅读并遵循了第一章,内核 工作空间设置,那么您应该已经安装了所有这些先决条件包。如果没有,请立即参考并安装所有所需的包。记住,种瓜得瓜,种豆得豆……

继续前进,kbuild 开源框架(顺便说一句,它在许多项目中被重复使用)通过其 UI 向用户提供了一些线索。菜单条目前缀的含义如下:

  • [.]: 内核功能,布尔选项(要么开启,要么关闭):

  • [*]: 开启,功能已编译并内置到内核镜像中(编译进内核)(y)

  • [ ]: 关闭,根本没有构建(n)

  • <.>:一个可以处于三种状态之一的特性(三态):

  • <*>:打开,特性已编译并内建(编译进)内核镜像(y)

  • <M>:模块,作为内核模块编译和内建(m)

  • < >:关闭,完全不构建(n)

  • {.}:此配置选项存在依赖关系;因此,它需要被构建(编译)为模块(m)或内建到内核镜像中(y)。

  • -*-:一个依赖需要将此项目编译进(y)。

  • (...):提示:需要输入字母数字(在此选项上按[Enter]键,然后会出现提示)。

  • <菜单项>  --->:后面有一个子菜单(在此项目上按[Enter]键导航到子菜单)。

再次,经验法则至关重要。让我们实际尝试使用make menuconfig UI 来看看它是如何工作的。这是下一节的主题。

使用 make menuconfig UI 的示例用法

通过方便的menuconfig目标来感受使用 kbuild 菜单系统的过程,让我们逐步进行导航到名为内核.config 支持的三态菜单项。它默认是关闭的,所以让我们打开它;也就是说,让我们把它设为y,内建到内核镜像中。我们可以在主屏幕上的常规设置主菜单项下找到它。

打开此功能到y会实现什么?当打开到y(或者当设置为M时,一个内核模块将可用,并且一旦加载,当前运行的内核配置设置可以通过两种方式随时查找:

  • 通过运行scripts/extract-ikconfig脚本

  • 直接读取/proc/config.gz伪文件的内容(当然,它是gzip(1)压缩的;首先解压缩,然后读取)

作为一个学习练习,我们现在将学习如何为 x86-64 架构的 5.4 Linux 内核配置内核配置选项,其值如下表所示。现在,不要担心每个选项的含义;这只是为了练习内核配置系统:

| 特性 | 在 make menuconfig UI 中的效果和位置 | 选择<帮助>按钮

查看精确的 CONFIG_选项 | 值:原始 -> 新值 |

本地版本 设置内核发布/版本的-EXTRAVERSION组件(使用uname -r查看);常规设置 / 附加到内核发布的本地版本 CONFIG_LOCALVERSION (none) -> -llkd01
内核配置文件支持 允许您查看当前内核配置详细信息;常规设置 / 内核.config 支持 CONFIG_IKCONFIG n -> y
与前面相同,还可以通过 procfs 访问 允许您通过proc 文件系统procfs)查看当前内核配置详细信息;常规设置 / 通过/proc/config.gz 启用对.config 的访问 CONFIG_IKCONFIG_PROC n -> y
内核分析 内核分析支持;常规设置 / 分析支持 CONFIG_PROFILING y -> n
HAM 无线电 HAM 无线电支持;网络支持 / 业余无线电支持 CONFIG_HAMRADIO y -> n
VirtualBox 支持 VirtualBox 的(Para)虚拟化支持;设备驱动程序 / 虚拟化驱动程序 / Virtual Box 客户端集成支持 CONFIG_VBOXGUEST n -> m
用户空间 IO 驱动程序UIO UIO 支持;设备驱动程序 / 用户空间 IO 驱动程序 CONFIG_UIO n -> m
前面加上具有通用中断处理的 UIO 平台驱动程序 具有通用中断处理的 UIO 平台驱动程序;设备驱动程序 / 用户空间 IO 驱动程序 / 具有通用中断处理的用户空间 IO 平台驱动程序 CONFIG_UIO_PDRV_GENIRQ n -> m
MS-DOS 文件系统支持 文件系统 / DOS/FAT/NT 文件系统 / MSDOS 文件系统支持 CONFIG_MSDOS_FS n -> m
安全性:LSMs 关闭内核 LSMs;安全选项 / 启用不同的安全模型 (注意:对于生产系统,通常最好保持此选项打开!) CONFIG_SECURITY y -> n
内核调试:堆栈利用信息 内核调试 / 内存调试 / 堆栈利用信息检测 CONFIG_DEBUG_STACK_USAGE n -> y

表 2.4 – 需要配置的项目

您如何解释这个表格?让我们以第一行为例;我们逐列地讨论它:

  • 第一列指定我们要修改(编辑/启用/禁用)的内核特性。在这里,它是内核版本字符串的最后部分(如在uname -r的输出中显示)。它被称为发布的-EXTRAVERSION组件(详细信息请参阅内核发布命名规范部分)。

  • 第二列指定了两件事:

  • 首先,我们要做什么。在这里,我们想要设置内核发布字符串的-EXTRAVERSION组件。

  • 第二,显示了此内核配置选项在menuconfig UI 中的位置。在这里,它在General Setup子菜单中,在其中是名为Local version - append to kernel release的菜单项。我们将其写为General Setup / Local version - append to kernel release

  • 第三列指定内核配置选项的名称为CONFIG_<FOO>。如果需要,您可以在菜单系统中搜索此选项。在这个例子中,它被称为CONFIG_LOCALVERSION

  • 第四列显示了此内核配置选项的原始以及我们希望您将其更改为的值(“新”值)。它以原始值 -> 新值的格式显示。在我们的示例中,它是(none) -> -llkd01,意味着-EXTRAVERSION字符串组件的原始值为空,我们希望您修改它,将其更改为值-llkd01

另一方面,对于我们展示的几个项目,可能不会立即显而易见——比如n -> m;这是什么意思?n -> m意味着您应该将原始值从n(未选择)更改为m(选择为内核模块进行构建)。同样,y -> n字符串表示将配置选项从打开更改为关闭。

您可以通过按下/键(就像 vi 一样;我们将在接下来的部分中展示更多内容)在menuconfig系统 UI 中搜索内核配置选项。

然后(实际上是在接下来的章节中),我们将使用这些新的配置选项构建内核(和模块),从中引导,并验证前面的内核配置选项是否按我们所需设置。

但是现在,您需要做您的部分:启动菜单 UI(通常使用make menuconfig),然后导航菜单系统,找到先前描述的相关内核配置选项,并根据需要进行编辑,以符合前表中第四列显示的内容。

请注意,根据您当前运行的 Linux 发行版及其内核模块(我们使用lsmod(8)生成了初始配置,记得吗?),在配置内核时看到的实际值和默认值可能与Ubuntu 18.04.3 LTS发行版(运行 5.0.0-36-generic 内核)的值不同,正如我们之前使用和展示的那样。

在这里,为了保持讨论的理智和紧凑,我们只会展示设置前表中显示的第二个和第三个内核配置选项的完整详细步骤(Kernel .config support)。剩下的编辑由您完成。让我们开始吧:

  1. 切换到内核源树的根目录(无论您在磁盘上的哪个位置提取了它):
cd ${LLKD_KSRC}
  1. 根据先前描述的第三种方法(在Tuned kernel config via the localmodconfig approach部分)设置初始内核配置文件:
lsmod > /tmp/lsmod.now
make LSMOD=/tmp/lsmod.now localmodconfig
  1. 运行 UI:
make menuconfig
  1. 一旦menuconfig UI 加载完成,转到General Setup菜单项。通常,在 x86-64 上,它是第二个项目。使用键盘箭头键导航到它,并按Enter键进入。

  2. 现在你在General Setup菜单项中。按下箭头键几次向下滚动菜单项。我们滚动到我们感兴趣的菜单——Kernel .config support——并将其突出显示;屏幕应该看起来(有点)像这样:

图 2.6 - 通过 make menuconfig 进行内核配置;通用设置/内核.config 支持

对于 x86-64 上的 5.4.0 原始 Linux 内核,通用设置/内核.config 支持是从通用设置菜单顶部开始的第 20 个菜单项。

  1. 一旦在Kernel .config support菜单项上,我们可以从其<M>前缀(在前面的屏幕截图中)看到,它是一个三态菜单项,最初设置为模块的选择<M>

  2. 保持这个项目(Kernel .config support)突出显示,使用右箭头键导航到底部工具栏上的< Help >按钮上,并在< Help >按钮上按Enter键。屏幕现在应该看起来(有点)像这样:

图 2.7 - 通过 make menuconfig 进行内核配置;一个示例帮助屏幕

帮助屏幕非常有信息量。事实上,一些内核配置帮助屏幕非常丰富并且实际上很有帮助。不幸的是,有些则不是。

  1. 好的,接下来,按Enter< Exit >按钮上,这样我们就回到了上一个屏幕。

  2. 然后,通过按空格键切换Kernel .config support菜单项(假设初始状态为<M>;也就是说,设置为模块)。按一次空格键会使 UI 项目显示如下:

<*> Kernel .config support
[ ]   Enable access to .config through /proc/config.gz (NEW)

注意它如何变成了<*>,这意味着这个功能将被构建到内核镜像本身中(实际上,它将始终处于打开状态)。现在,让我们这样做(当然,再次按空格键会将其切换到关闭状态< >,然后再回到原始的<M>状态)。

  1. 现在,项目处于<*>(是)状态,向下滚动到下一个菜单项,[*] Enable access to .config through /proc/config.gz,并启用它(再次按空格键);屏幕现在应该看起来(有点)像这样(我们只放大了相关部分):

图 2.8 - 通过 make menuconfig 进行内核配置:将布尔配置选项切换到打开状态

您可以随时使用右箭头键转到< Help >并查看此项目的帮助屏幕。

在这里,我们不会探索剩余的内核配置菜单;我会留给你去找到并按照前面的表格设置。

  1. 回到主菜单(主屏幕),使用右箭头键导航到< Exit >按钮上并按Enter。会弹出一个对话框:

图 2.9 - 通过 make menuconfig 进行内核配置:保存对话框

很简单,不是吗?在< Yes >按钮上按Enter保存并退出。如果选择< No >按钮,您将失去所有配置更改(在本次会话期间进行的更改)。或者,您可以按Esc两次来摆脱这个对话框并继续处理内核配置。

  1. 保存并退出。在< Yes >按钮上按Enter。菜单系统 UI 现在保存了新的内核配置并退出;我们回到控制台(一个 shell 或终端窗口)提示符。

但是新的内核配置保存在哪里?这很重要:内核配置被写入内核源树根目录中的一个简单的 ASCII 文本文件中,名为.config。也就是说,它保存在${LLKD_KSRC}/.config中。

如前所述,每个内核配置选项都与形式为CONFIG_<FOO>的配置变量相关联,其中<FOO>当然被适当的名称替换。在内部,这些变量成为构建系统和实际上内核源代码使用的。例如,考虑一下Kernel .config support选项:

$ grep IKCONFIG .config
CONFIG_IKCONFIG=y
CONFIG_IKCONFIG_PROC=y
$

啊哈!配置现在反映了我们已经完成的事实:

  • 打开了CONFIG_IKCONFIG内核功能(=y表示它已经打开,并将构建到内核镜像中)。

  • /proc/config.gz(伪)文件现在可用,作为CONFIG_IKCONFIG_PROC=y

注意*:最好不要尝试手动编辑.config文件(“手动”)。你可能不知道有几个相互依赖;始终使用 kbuild 菜单系统(我们建议通过make menuconfig)来编辑它。

实际上,在我们迄今为止与 kbuild 系统的快速冒险中,底层已经发生了很多事情。下一节将稍微探讨一下这个问题,在菜单系统中搜索以及清晰地可视化原始(或旧)和新的内核配置文件之间的差异。

关于 kbuild 的更多信息

通过make menuconfig或其他方法在内核源树的根目录中创建或编辑.config文件并不是 kbuild 系统处理配置的最后一步。不,它现在会内部调用一个名为syncconfig的目标,这个目标之前被(误)命名为silentoldconfig。这个目标让 kbuild 生成一些头文件,这些头文件进一步用于构建内核的设置。这些文件包括include/config下的一些元头文件,以及include/generated/autoconf.h头文件,它将内核配置存储为 C 宏,从而使内核的 Makefile(s)和内核代码能够根据内核功能是否可用来做出决策。

接下来,如果你正在寻找特定的内核配置选项,但很难找到它怎么办?没问题,menuconfig UI 系统有一个Search Configuration Parameter功能。就像著名的vi(1)编辑器一样,按下/(正斜杠)键会弹出一个搜索对话框,然后输入你的搜索词,带有或不带有CONFIG_前缀,然后选择< Ok >按钮让它继续进行。

以下几张截图显示了搜索对话框和结果对话框(例如,我们搜索了术语vbox):

图 2.10 - 通过make menuconfig进行内核配置:搜索配置参数的结果对话框

前面搜索的结果对话框很有趣。它揭示了关于配置选项的几条信息:

  • 配置指令(只需在Symbol:中加上CONFIG_前缀)

  • 配置的类型(布尔值、三态值、字母数字等)

  • 提示字符串

  • 重要的是,它在菜单系统中的位置(这样你就可以找到它)

  • 它的内部依赖,如果有的话

  • 它自动选择的任何配置选项(如果选择了它本身,则打开)

以下是结果对话框的截图:

图 2.11 - 通过make menuconfig进行内核配置:前面搜索的结果对话框

所有这些信息都包含在一个 ASCII 文本文件中,该文件由 kbuild 系统用于构建菜单系统 UI - 这个文件称为Kconfig(实际上有几个)。它的位置也显示出来了(在Defined at ...行)。

查找配置中的差异

一旦要写入.config内核配置文件,kbuild 系统会检查它是否已经存在,如果存在,它会备份为.config.old。知道这一点,我们总是可以区分这两个文件,看到我们所做的更改。然而,使用典型的diff(1)实用程序来做这件事使得差异很难解释。内核提供了一个更好的方法,一个专门用于做这件事的基于控制台的脚本。内核源树中的scripts/diffconfig脚本对此非常有用。为了看到原因,让我们首先运行它的帮助屏幕:

$ scripts/diffconfig --help
Usage: diffconfig [-h] [-m] [<config1> <config2>]

Diffconfig is a simple utility for comparing two .config files.
Using standard diff to compare .config files often includes extraneous and
distracting information. This utility produces sorted output with only the
changes in configuration values between the two files.

Added and removed items are shown with a leading plus or minus, respectively.
Changed items show the old and new values on a single line.
[...]

现在,我们来试一下:

$ scripts/diffconfig .config.old .config
-AX25 n
-DEFAULT_SECURITY_APPARMOR y
-DEFAULT_SECURITY_SELINUX n
-DEFAULT_SECURITY_SMACK n
[...]
-SIGNATURE y
 DEBUG_STACK_USAGE n -> y
 DEFAULT_SECURITY_DAC n -> y
 FS_DAX y -> n
 HAMRADIO y -> n
 IKCONFIG m -> y
 IKCONFIG_PROC n -> y
 LOCALVERSION "" -> "-llkd01"
 MSDOS_FS n -> m
 PROFILING y -> n
 SECURITY y -> n
 UIO n -> m
+UIO_AEC n
 VBOXGUEST n -> m
[...]
$ 

如果您修改了内核配置更改,如前表所示,您应该通过内核的diffconfig脚本看到类似于前面代码块中显示的输出。它清楚地向我们展示了我们改变了哪些内核配置选项以及如何改变的。

在我们结束之前,快速注意一些关键的事情:内核安全。虽然用户空间安全加固技术已经大大增长,但内核空间安全加固技术实际上正在追赶。仔细配置内核的配置选项在确定给定 Linux 内核的安全姿态方面起着关键作用;问题是,有太多的选项(实际上是意见),往往很难(交叉)检查哪些是从安全角度来看是一个好主意,哪些不是。Alexander Popov 编写了一个非常有用的 Python 脚本,名为kconfig-hardened-check;它可以运行以检查和比较给定的内核配置(通过通常的配置文件)与一组预定的加固偏好(来自各种 Linux 内核安全项目:著名的内核自我保护项目KSPP),最后一个公共 grsecurity 补丁,CLIP OS 和安全锁定 LSM)。查找kconfig-hardened-check GitHub 存储库,尝试一下!

好了!你现在已经完成了 Linux 内核构建的前三个步骤,相当了不起。(当然,我们将在下一章中完成构建过程的其余四个步骤。)我们将以一个关于学习有用技能的最后一节结束本章-如何自定义内核 UI 菜单。

自定义内核菜单-添加我们自己的菜单项

所以,假设你开发了一个设备驱动程序,一个实验性的新调度类,一个自定义的debugfs(调试文件系统)回调,或者其他一些很酷的内核特性。你将如何让团队中的其他人,或者说,你的客户,知道这个奇妙的新内核特性存在,并允许他们选择它(作为内置或内核模块)并因此构建和使用它?答案是在内核配置菜单的适当位置插入一个新的菜单项

为此,首先了解一下各种Kconfig*文件及其所在位置是很有用的。让我们找出来。

Kconfig*文件

内核源树根目录中的Kconfig文件用于填充menuconfig UI 的初始屏幕。如果你愿意,可以看一下它。它通过在内核源树的不同文件夹中源化各种其他Kconfig文件来工作。以下表总结了更重要的Kconfig*文件以及它们在 kbuild UI 中服务的菜单:

菜单 定义它的 Kconfig 文件位置
主菜单,初始屏幕 Kconfig
通用设置+启用可加载模块支持 init/Kconfig

| 处理器类型和特性+总线选项+二进制模拟

(特定于架构;上面的菜单标题是为 x86;一般来说,Kconfig 文件在这里:arch/<arch>/Kconfig)| arch/<arch>/Kconfig |

电源管理 kernel/power/Kconfig
固件驱动程序 drivers/firmware/Kconfig
虚拟化 arch/<arch>/kvm/Kconfig
通用架构相关选项 arch/Kconfig
启用块层+IO 调度程序 block/Kconfig
可执行文件格式 fs/Kconfig.binfmt
内存管理选项 mm/Kconfig
网络支持 net/Kconfig, net/*/Kconfig
设备驱动程序 drivers/Kconfig, drivers/*/Kconfig
文件系统 fs/Kconfig, fs/*/Kconfig
安全选项 security/Kconfig, security/*/Kconfig*
加密 API crypto/Kconfig, crypto/*/Kconfig
库例程 lib/Kconfig, lib/*/Kconfig
内核黑客 lib/Kconfig.debug, lib/Kconfig.*

表 2.5-内核配置菜单项及定义它们的相应 Kconfig*文件

通常,一个Kconfig文件驱动一个菜单。现在,让我们继续添加菜单项。

在 Kconfig 文件中创建一个新的菜单项

作为一个微不足道的例子,让我们在General Setup菜单中添加我们自己的布尔dummy配置选项。我们希望配置名称为CONFIG_LLKD_OPTION1。从前面的表中可以看出,要编辑的相关Kconfig文件是init/Kconfig,因为这是定义General Setup菜单的菜单元文件。

让我们开始吧:

  1. 为了安全起见,始终制作备份副本:
cp init/Kconfig init/Kconfig.orig
  1. 现在,编辑init/Kconfig文件:
vi init/Kconfig

在文件中找到适当的位置;在这里,我们选择在CONFIG_LOCALVERSION_AUTO之后插入我们的菜单项。以下截图显示了我们的新条目:

图 2.12 - 编辑 init/Kconfig 并插入我们自己的菜单项

我们已经将前面的文本作为补丁提供给了我们书籍的GitHub源代码树中的原始init/Kconfig文件。在ch2/Kconfig.patch下找到它。

新项目以config关键字开头,后跟您的新CONFIG_LLKD_OPTION1配置变量的FOO部分。现在,只需阅读我们在Kconfig文件中关于此条目的陈述。有关Kconfig语言/语法的更多细节在接下来的A few details on the Kconfig language部分中。

  1. 保存文件并退出编辑器。

  2. 重新配置内核。导航到我们的新菜单项并打开该功能(请注意,在下面的截图中,默认情况下它是高亮显示的并且关闭):

make menuconfig
[...]

这是输出:

图 2.13 - 通过 make menuconfig 进行内核配置,显示我们的新菜单项

  1. 打开它(使用空格键切换),然后保存并退出菜单系统。

在此期间,尝试按下< Help >按钮。您应该看到我们在Kconfig文件中提供的“帮助”。

  1. 检查我们的功能是否已被选择:
$ grep "LLKD_OPTION1" .config
CONFIG_LLKD_OPTION1=y
$ grep "LLKD_OPTION1" include/generated/autoconf.h 
$  

我们发现确实已经在我们的.config文件中设置为on,但是(还没有!)在内核的内部自动生成的头文件中。这将在构建内核时发生。

  1. 构建内核(不用担心;有关构建内核的完整细节在下一章中找到。您可以首先阅读第三章,从源代码构建 5.x Linux 内核-第二部分,然后再回到这一点,如果您愿意的话...):
make -j4
  1. 完成后,重新检查autoconf.h头文件,查看我们的新配置选项是否存在:
$ grep "LLKD_OPTION1" include/generated/autoconf.h 
#define CONFIG_LLKD_OPTION1 1

成功了!是的,但是在实际项目(或产品)中工作时,我们通常需要进一步设置,设置我们的配置项在使用此配置选项的代码相关的 Makefile 中。

这是一个快速示例,内核的顶层(或其他位置)Makefile 中,以下行将确保我们自己的代码(以下内容在llkd_option1.c源文件中)在构建时编译到内核中。将此行添加到相关的 Makefile 末尾:

obj-${CONFIG_LLKD_OPTION1}  +=  llkd_option1.o

现在不要担心内核Makefile语法相当奇怪。接下来的几章将对此进行一些解释。

此外,您应该意识到,同一个配置也可以作为内核代码片段中的普通 C 宏使用;例如,我们可以这样做:

#ifdef CONFIG_LLKD_OPTION1
    do_our_thing();
#endif

然而,非常值得注意的是,Linux 内核社区已经制定并严格遵守了某些严格的编码风格指南。在这种情况下,指南规定应尽量避免条件编译,如果需要使用Kconfig符号作为条件,则请按照以下方式进行:

if (IS_ENABLED(CONFIG_LLKD_OPTION1)) {
    do_our_thing();
}

Linux 内核编码风格指南可以在这里找到:www.kernel.org/doc/html/latest/process/coding-style.html。我建议您经常参考它们,并且当然要遵循它们!

关于 Kconfig 语言的一些细节

到目前为止,我们对Kconfig语言的使用只是冰山一角。事实上,kbuild 系统使用Kconfig语言(或语法)来使用简单的 ASCII 文本指令来表达和创建菜单。该语言包括菜单条目、属性、(反向)依赖项、可见性约束、帮助文本等等。

内核文档了Kconfig语言的构造和语法:www.kernel.org/doc/Documentation/kbuild/kconfig-language.txt。请参考此文档以获取完整的详细信息。

以下表格简要介绍了更常见的Kconfig构造(并不完整):

构造 含义
config <FOO> 在这里指定菜单条目名称(格式为CONFIG_FOO);只需放入FOO部分。
菜单属性
  bool ["<description>"] 将配置选项指定为布尔;在.config中的值将是Y(内建到内核映像中)或不存在(将显示为已注释的条目)。
  tristate ["description>"] 将配置选项指定为三态;在.config中的值将是YM(作为内核模块构建)或不存在(将显示为已注释的条目)。
  int ["<description>"] 将配置选项指定为整数值。
     range x-y 整数范围从xy
  default <value> 指定默认值;根据需要使用ymn或其他值。
 prompt "<description>" 描述内核配置的句子。
depends on "expr" 为菜单项定义一个依赖项;可以使用depends on FOO1 && FOO2 && (FOO3 &#124;&#124; FOO4)类型的语法来定义多个依赖项。
select <config> [if "expr"] 定义一个反向依赖项。
help "help-text" 在选择<帮助>按钮时显示的文本。

表 2.6 - Kconfig,一些构造

为了帮助理解语法,以下是来自lib/Kconfig.debug(描述 UI 的Kernel Hacking-内核调试,实际上-部分菜单项的文件)的一些示例:

  1. 我们将从一个简单的开始(CONFIG_DEBUG_INFO选项):
config DEBUG_INFO
    bool "Compile the kernel with debug info"
    depends on DEBUG_KERNEL && !COMPILE_TEST
    help
      If you say Y here the resulting kernel image will include
      debugging info resulting in a larger kernel image. [...]
  1. 接下来,让我们来看一下CONFIG_FRAME_WARN选项。注意range和条件默认值语法,如下所示:
config FRAME_WARN
    int "Warn for stack frames larger than (needs gcc 4.4)"
    range 0 8192
    default 3072 if KASAN_EXTRA
    default 2048 if GCC_PLUGIN_LATENT_ENTROPY
    default 1280 if (!64BIT && PARISC)
    default 1024 if (!64BIT && !PARISC)
    default 2048 if 64BIT
    help
      Tell gcc to warn at build time for stack frames larger than this.
      Setting this too low will cause a lot of warnings.
      Setting it to 0 disables the warning.
      Requires gcc 4.4
  1. 接下来,CONFIG_HAVE_DEBUG_STACKOVERFLOW选项是一个简单的布尔值;它要么开启,要么关闭。CONFIG_DEBUG_STACKOVERFLOW选项也是一个布尔值。请注意它如何依赖于另外两个选项,使用布尔 AND(&&)运算符分隔:
config HAVE_DEBUG_STACKOVERFLOW
        bool

config DEBUG_STACKOVERFLOW
        bool "Check for stack overflows"
        depends on DEBUG_KERNEL && HAVE_DEBUG_STACKOVERFLOW
        ---help---
          Say Y here if you want to check for overflows of kernel, IRQ
          and exception stacks (if your architecture uses them). This 
          option will show detailed messages if free stack space drops
          below a certain limit. [...]

好了!这完成了我们对在内核配置中创建(或编辑)自定义菜单条目的覆盖,也完成了本章。

总结

在本章中,您首先学习了如何获取 Linux 内核源代码树。然后,您了解了其发布(或版本)命名法,各种类型的 Linux 内核(-next树,-rc/主线树,稳定版,LTS,SLTS 和发行版),以及基本的内核开发工作流程。在这个过程中,您甚至快速浏览了内核源代码树,以便更清楚地了解其布局。接下来,您将看到如何将压缩的内核源代码树提取到磁盘上,并且关键的是如何配置内核-这是过程中的关键步骤。此外,您还学会了如何自定义内核菜单,向其中添加自己的条目,以及有关 kbuild 系统和相关的Kconfig文件的一些知识。

了解如何获取和配置 Linux 内核是一项有用的技能。我们刚刚开始了这段漫长而激动人心的旅程。您将意识到,随着对内核内部、驱动程序和目标系统硬件的更多经验和知识,您调整内核以适应项目目的的能力将会变得更好。

我们已经走了一半的路;我建议您首先消化这些材料,重要的是-尝试本章中的步骤,解决问题/练习,并浏览Further reading部分。然后,在下一章中,让我们实际构建 5.4.0 内核并进行验证!

问题

最后,这里有一些问题供您测试对本章材料的了解:github.com/PacktPublishing/Linux-Kernel-Programming/tree/master/questions。您会在本书的 GitHub 存储库中找到一些问题的答案:github.com/PacktPublishing/Linux-Kernel-Programming/tree/master/solutions_to_assgn

进一步阅读

为了帮助您深入了解有用的材料,我们在本书的 GitHub 存储库中提供了一个相当详细的在线参考和链接列表(有时甚至包括书籍)的 Further reading 文档。 Further reading 文档在这里可用:github.com/PacktPublishing/Linux-Kernel-Programming/raw/master/Further_Reading.md

第三章:从源代码构建 5.x Linux 内核 - 第二部分

本章继续上一章的内容。在上一章中,在“从源代码构建内核的步骤”部分,我们涵盖了构建内核的前三个步骤。在那里,您学会了如何下载和提取内核源树,甚至是git clone步骤 12)。然后,我们继续了解内核源树布局,以及正确到达配置内核起始点的各种方法(步骤 3)。我们甚至在内核配置菜单中添加了自定义菜单项。

在本章中,我们继续我们的内核构建任务,覆盖了剩下的四个步骤来实际构建它。首先,当然,我们要构建它(步骤 4)。然后您将看到如何正确安装作为构建的一部分生成的内核模块(步骤 5)。接下来,我们运行一个简单的命令来设置 GRUB 引导加载程序并生成initramfs(或initrd)镜像(步骤 6)。还讨论了使用initramfs镜像的动机以及它的使用方式。然后介绍了一些有关配置 GRUB 引导加载程序(对于 x86)的细节(步骤 7)。

在本章结束时,我们将使用新的内核镜像引导系统,并验证它是否按预期构建。然后,我们将学习如何为外部架构(即 ARM,所讨论的板子是著名的树莓派)交叉编译 Linux 内核。

简而言之,涵盖的领域如下:

  • 第 4 步 - 构建内核镜像和模块

  • 第 5 步 - 安装内核模块

  • 第 6 步 - 生成 initramfs 镜像和引导加载程序设置

  • 了解 initramfs 框架

  • 第 7 步 - 自定义 GRUB 引导加载程序

  • 验证我们新内核的配置

  • 树莓派的内核构建

  • 内核构建的其他提示

技术要求

在开始之前,我假设您已经下载、提取(如果需要)并配置了内核,因此有一个.config文件准备好了。如果您还没有,请参考上一章,了解如何确切地完成这些步骤。现在我们可以继续构建它了。

第 4 步 - 构建内核镜像和模块

从最终用户的角度来看,执行构建实际上非常简单。在最简单的形式中,只需确保您在配置的内核源树的根目录中,并键入make。就是这样 - 内核镜像和任何内核模块(在嵌入式系统上可能还有设备树二进制DTB))将被构建。喝杯咖啡吧!第一次可能需要一段时间。

当然,我们可以向make传递各种Makefile目标。在命令行上快速发出make help命令会显示相当多的信息。请记住,实际上我们之前就用过这个命令,事实上,以查看所有可能的配置目标。在这里,我们用它来查看all目标默认构建了什么:

$ cd ${LLKD_KSRC}     # the env var LLKD_KSRC holds the 'root' of our 
                      # 5.4 kernel source tree
$ make help
[...]
Other generic targets:
  all - Build all targets marked with [*]
* vmlinux - Build the bare kernel
* modules - Build all modules
[...]
Architecture specific targets (x86):
* bzImage - Compressed kernel image (arch/x86/boot/bzImage)
[...]
$ 

好的,执行make all将得到前面三个带有*前缀的目标;它们代表什么意思呢?

  • vmlinux实际上与未压缩的内核镜像的名称相匹配。

  • modules目标意味着所有标记为m(用于模块)的内核配置选项将作为内核模块(.ko文件)构建在内核源树中(有关内核模块的具体内容以及如何编程的细节将在接下来的两章中讨论)。

  • bzImage是特定于架构的。在 x86[-64]系统上,这是压缩内核镜像的名称 - 引导加载程序实际加载到 RAM 中并在内存中解压缩并引导的镜像文件。

那么,一个常见问题:如果bzImage是我们用来引导和初始化系统的实际内核,那么vmlinux是用来做什么的?请注意,vmlinux是未压缩的内核映像。它可能很大(甚至在调试构建期间生成的内核符号存在时非常大)。虽然我们从不通过vmlinux引导,但它仍然很重要。出于内核调试目的,请保留它(不幸的是,这超出了本书的范围)。

使用 kbuild 系统,只需运行make命令就相当于make all

内核代码库非常庞大。目前的估计在 2000 万源代码行SLOC)左右,因此,构建内核确实是一个非常占用内存和 CPU 的工作。事实上,有些人使用内核构建作为压力测试!现代的make(1)实用程序功能强大,能够处理多个进程。我们可以要求它生成多个进程来并行处理构建的不同(无关)部分,从而提高吞吐量,缩短构建时间。相关选项是-j'n',其中n是并行运行的任务数量的上限。用于确定这一点的启发式(经验法则)如下:

n = num-CPU-cores * factor;

在这里,factor是 2(或者在具有数百个 CPU 核心的高端系统上为 1.5)。从技术上讲,我们需要内部的核心是“线程化”的或者使用同时多线程SMT)-这是英特尔所称的超线程,这样启发式才有用。

有关并行化make及其工作原理的更多详细信息可以在make(1)的 man 页面中找到(使用man 1 make调用),在PARALLEL MAKE AND THE JOBSERVER部分。

另一个常见问题:您的系统上有多少 CPU 核心?有几种方法可以确定这一点,其中一种简单的方法是使用nproc(1)实用程序:

$ nproc
2 

关于nproc(1)和相关实用程序的一点说明:

a) 对nproc(1)执行strace(1)会发现它基本上是使用sched_getaffinity(2)系统调用。我们将在第九章 CPU 调度器-第一部分和第十章 CPU 调度器-第二部分中提到更多关于这个和相关系统调用的内容。

b) FYI,lscpu(1)实用程序提供核心数以及其他有用的 CPU 信息。例如,它显示是否在虚拟机VM)上运行(virt-what脚本也是如此)。在 Linux 系统上试一下。

显然,我们的客户机虚拟机已配置为具有两个 CPU 核心,因此让n=2*2=4。所以,我们开始构建内核。以下输出来自我们可靠的 x86_64 Ubuntu 18.04 LTS 客户机系统,配置为具有 2GB 的 RAM 和两个 CPU 核心。

请记住,内核必须首先配置。有关详细信息,请参阅第二章 从源代码构建 5.x Linux 内核-第一部分

再次,当您开始时,内核构建可能会发出警告,尽管在这种情况下不是致命的:

$ time make -j4
scripts/kconfig/conf --syncconfig Kconfig
 UPD include/config/kernel.release
warning: Cannot use CONFIG_STACK_VALIDATION=y, please install libelf-dev, libelf-devel or elfutils-libelf-devel
[...]

因此,为了解决这个问题,我们中断构建,使用Ctrl + C,然后按照输出的建议安装libelf-dev软件包。在我们的 Ubuntu 系统上,sudo apt install libelf-dev就足够了。如果您按照第一章 内核工作区设置中的详细设置进行操作,这种情况就不会发生。重试,现在它可以工作了!为了让您感受一下,我们展示了构建输出的一些小片段。但是,最好还是自己尝试一下:

正因为内核构建非常依赖 CPU 和 RAM,因此在虚拟机上进行这项工作要比在本机 Linux 系统上慢得多。通过至少将客户机引导到运行级别 3(多用户网络,无 GUI)来节省 RAM 是有帮助的:www.if-not-true-then-false.com/2012/howto-change-runlevel-on-grub2/

$ cd ${LLKD_KSRC} $ time make -j4 scripts/kconfig/conf --syncconfig Kconfig SYSHDR arch/x86/include/generated/asm/unistd_32_ia32.h
 SYSTBL arch/x86/include/generated/asm/syscalls_32.h
[...]
  DESCEND objtool
  HOSTCC /home/llkd/kernels/linux-5.4/tools/objtool/fixdep.o
  HOSTLD /home/llkd/kernels/linux-5.4/tools/objtool/fixdep-in.o
  LINK /home/llkd/kernels/linux-5.4/tools/objtool/fixdep
[...]

[...]
  LD      vmlinux.o
  MODPOST vmlinux.o
  MODINFO modules.builtin.modinfo
  LD      .tmp_vmlinux1
  KSYM    .tmp_kallsyms1.o
  LD      .tmp_vmlinux2
  KSYM    .tmp_kallsyms2.o
 LD      vmlinux
  SORTEX  vmlinux
  SYSMAP  System.map
  Building modules, stage 2.
 MODPOST 59 modules
  CC      arch/x86/boot/a20.o
[...]
  LD      arch/x86/boot/setup.elf
  OBJCOPY arch/x86/boot/setup.bin
  BUILD   arch/x86/boot/bzImage
Setup is 17724 bytes (padded to 17920 bytes).
System is 8385 kB
CRC 6f010e63
  CC [M]  drivers/hid/hid.mod.o
Kernel: arch/x86/boot/bzImage is ready  (#1)

好的,内核映像(在这里称为bzImage)和vmlinux文件已经成功地通过拼接生成的各种目标文件构建,正如在先前的输出中所见 - 先前块的最后一行确认了这一事实。但是,请稍等,构建还没有完成。kbuild 系统现在继续完成所有内核模块的构建;输出的最后部分如下所示:

[...]
  CC [M]  drivers/hid/usbhid/usbhid.mod.o
  CC [M]  drivers/i2c/algos/i2c-algo-bit.mod.o
[...]
  LD [M] sound/pci/snd-intel8x0.ko
  LD [M] sound/soundcore.ko

real     17m31.980s
user     23m58.451s
sys      3m22.280s
$

整个过程似乎总共花了大约 17.5 分钟。time(1)实用程序给出了一个(非常)粗略的时间概念,即后面的命令所花费的时间。

如果您想要准确的 CPU 分析,请学会使用强大的perf(1)实用程序。在这里,您可以尝试使用perf stat make -j4命令。我建议您在发行版内核上尝试此操作,否则,perf本身将必须为您的自定义内核手动构建。

此外,在先前的输出中,Kernel: arch/x86/boot/bzImage is ready (#1)#1意味着这是内核的第一个构建。此数字将在后续构建中自动递增,并在您引导到新内核然后执行uname -a时显示。

由于我们正在进行并行构建(通过make -j4,意味着四个进程并行执行构建),所有构建过程仍然写入相同的stdout位置 - 终端窗口。因此,输出可能是无序或混乱的。

构建应该干净地运行,没有任何错误或警告。嗯,有时会看到编译器警告,但我们将轻松地忽略它们。如果在此步骤中遇到编译器错误,从而导致构建失败,怎么办?我们怎么委婉地表达这?哦,好吧,我们不能 - 这很可能是您的问题,而不是内核社区的问题。请检查并重新检查每一步,如果一切都失败了,请使用make mrproper命令从头开始重做!很多时候,内核构建失败意味着内核配置错误(可能会冲突的随机选择的配置)、工具链的过时版本或不正确的打补丁,等等。

假设一切顺利,正如它应该的那样,在此步骤终止时,kbuild 系统已生成了三个关键文件(其中有许多)。

在内核源树的根目录中,我们有以下内容:

  • 未压缩的内核映像文件vmlinux(仅用于调试)

  • 符号地址映射文件System.map

  • 压缩的可引导内核映像文件bzImage(请参阅以下输出)

让我们来看看它们!通过向ls(1)传递-h选项,我们使输出(特别是文件大小)更易于阅读:

$ ls -lh vmlinux System.map
-rw-rw-r-- 1 llkd llkd 4.1M Jan 17 12:27 System.map
-rwxrwxr-x 1 llkd llkd 591M Jan 17 12:27 vmlinux
$ file ./vmlinux
./vmlinux: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, BuildID[sha1]=<...>, with debug_info, not stripped

如您所见,vmlinux文件非常庞大。这是因为它包含了所有内核符号以及额外的调试信息编码进去。(顺便说一句,vmlinuxSystem.map文件在内核调试上下文中使用;保留它们。)有用的file(1)实用程序向我们展示了有关此映像文件的更多细节。引导加载程序加载并引导的实际内核映像文件将始终位于arch/<arch>/boot/的通用位置;因此,对于 x86 架构,我们有以下内容:

$ ls -l arch/x86/boot/bzImage -rw-rw-r-- 1 llkd llkd 8604032 Jan 17 12:27 arch/x86/boot/bzImage$ file arch/x86/boot/bzImage
arch/x86/boot/bzImage: Linux kernel x86 boot executable bzImage, version 5.4.0-llkd01 (llkd@llkd-vbox) #1 SMP Thu [...], RO-rootFS, swap_dev 0x8, Normal VGA

x86_64 架构的压缩内核映像版本5.4.0-llkd01大小略大于 8MB。file(1)实用程序再次清楚地显示它确实是用于 x86 架构的 Linux 内核引导映像。

内核文档记录了在内核构建过程中可以通过设置各种环境变量执行的几个调整和开关。此文档可以在内核源树中的Documentation/kbuild/kbuild.rst找到。实际上,我们将在接下来的材料中使用INSTALL_MOD_PATHARCHCROSS_COMPILE环境变量。

太好了!我们的内核映像和模块已经准备就绪!继续阅读,因为我们将在下一步中安装内核模块。

第 5 步 - 安装内核模块

在上一步中,所有标记为m的内核配置选项实际上现在都已经构建完成。正如你将了解的那样,这还不够:它们现在必须被安装到系统上已知的位置。本节涵盖了这些细节。

在内核源代码中定位内核模块

要查看前一步生成的内核模块 - 内核构建 - 让我们在内核源文件夹中执行一个快速的find(1)命令。了解所使用的命名约定,其中内核模块文件名以.ko结尾。

$ cd ${LLKD_KSRC}
$ find . -name "*.ko"
./arch/x86/events/intel/intel-rapl-perf.ko
./arch/x86/crypto/crc32-pclmul.ko
./arch/x86/crypto/ghash-clmulni-intel.ko
[...]
./net/ipv4/netfilter/ip_tables.ko
./net/sched/sch_fq_codel.ko
$ find . -name "*.ko" | wc -l
59 

我们可以从前面的输出中看到,在这个特定的构建中,总共构建了 59 个内核模块(为了简洁起见,实际的find输出在前面的块中被截断)。

现在回想一下我在第二章中要求你进行的练习,从源代码构建 5.x Linux 内核 - 第一部分,在使用 make menuconfig UI 的示例部分。在那里,在表 2.4中,最后一列指定了我们所做更改的类型。寻找n -> m(或y -> m)的更改,这意味着我们正在配置该特定功能以构建为内核模块。在那里,我们可以看到这包括以下功能:

  • VirtualBox 支持,n -> m

  • 用户空间 I/OUIO)驱动程序,n -> m;以及具有通用中断处理的 UIO 平台驱动程序,n -> m

  • MS-DOS 文件系统支持,n -> m

由于这些功能被要求构建为模块,它们不会被编码到vmlinuxbzImage内核映像文件中。不,它们将作为独立的(嗯,有点)内核模块存在。让我们在内核源树中寻找前面功能的内核模块(显示它们的路径名和大小,使用一些脚本技巧):

$ find . -name "*.ko" -ls | egrep -i "vbox|msdos|uio" | awk '{printf "%-40s %9d\n", $11, $7}'
./fs/fat/msdos.ko                           361896
./drivers/virt/vboxguest/vboxguest.ko       948752
./drivers/gpu/drm/vboxvideo/vboxvideo.ko   3279528
./drivers/uio/uio.ko                        408136
./drivers/uio/uio_pdrv_genirq.ko            324568
$ 

好的,很好,二进制内核模块确实已经在内核源树中生成。但这还不够。为什么?它们需要被安装到根文件系统中的一个众所周知的位置,以便在引导时,系统实际上可以找到并加载它们到内核内存中。这就是为什么我们需要安装内核模块。根文件系统中的“众所周知的位置”是/lib/modules/$(uname -r)/,其中$(uname -r)产生内核版本号,当然。

安装内核模块

执行内核模块安装很简单;(在构建步骤之后)只需调用modules_install Makefile 目标。让我们这样做:

$ cd ${LLKD_KSRC} $ sudo make modules_install [sudo] password for llkd: 
  INSTALL arch/x86/crypto/aesni-intel.ko
  INSTALL arch/x86/crypto/crc32-pclmul.ko
  INSTALL arch/x86/crypto/crct10dif-pclmul.ko
[...]
  INSTALL sound/pci/snd-intel8x0.ko
  INSTALL sound/soundcore.ko
  DEPMOD 5.4.0-llkd01
$ 

请注意,我们使用sudo(8)root(超级用户)身份执行安装。这是因为默认的安装位置(在/lib/modules/下)只有 root 可写。一旦内核模块准备好并复制过去(在前面的输出块中显示为INSTALL的工作),kbuild 系统运行一个名为depmod(8)的实用程序。它的工作基本上是解决内核模块之间的依赖关系,并将它们(如果存在)编码到一些元文件中(有关depmod(8)的更多详细信息,请参阅linux.die.net/man/8/depmod上的 man 页面)。

现在让我们看看模块安装步骤的结果:

$ uname -r
5.0.0-36-generic        # this is the 'distro' kernel (for Ubuntu 18.04.3 LTS) we're running on
$ ls /lib/modules/
5.0.0-23-generic 5.0.0-36-generic 5.4.0-llkd01
$ 

在前面的代码中,我们可以看到对于每个(Linux)内核,我们可以将系统引导到的文件夹在/lib/modules/下,其名称是内核版本,正如预期的那样。让我们查看感兴趣的文件夹 - 我们新内核的(5.4.0-llkd01)。在那里,在kernel/子目录下 - 在各种目录中 - 存放着刚安装的内核模块:

$ ls /lib/modules/5.4.0-llkd01/kernel/
arch/  crypto/  drivers/  fs/  net/  sound/

顺便说一句,/lib/modules/<kernel-ver>/modules.builtin文件中列出了所有已安装的内核模块(在/lib/modules/<kernel-ver>/kernel/下)。

让我们在这里搜索我们之前提到的内核模块:

$ find /lib/modules/5.4.0-llkd01/kernel/ -name "*.ko" | egrep "vboxguest|msdos|uio"
/lib/modules/5.4.0-llkd01/kernel/fs/fat/msdos.ko
/lib/modules/5.4.0-llkd01/kernel/drivers/virt/vboxguest/vboxguest.ko
/lib/modules/5.4.0-llkd01/kernel/drivers/uio/uio.ko
/lib/modules/5.4.0-llkd01/kernel/drivers/uio/uio_pdrv_genirq.ko
$ 

它们都显示出来了。太棒了!

最后一个关键点:在内核构建过程中,我们可以将内核模块安装到我们指定的位置,覆盖(默认的)/lib/modules/<kernel-ver>位置。这是通过将环境变量INSTALL_MOD_PATH设置为所需的位置来完成的;例如,执行以下操作:

export STG_MYKMODS=../staging/rootfs/my_kernel_modules
make INSTALL_MOD_PATH=${STG_MYKMODS} modules_install

有了这个,我们所有的内核模块都安装到了${STG_MYKMODS}/文件夹中。请注意,如果INSTALL_MOD_PATH指向不需要root写入的位置,也许就不需要sudo

这种技术 - 覆盖内核模块的安装位置 - 在为嵌入式目标构建 Linux 内核和内核模块时特别有用。显然,我们绝对应该用嵌入式目标的内核模块覆盖主机系统的内核模块;那可能是灾难性的!

下一步是生成所谓的initramfs(或initrd)镜像并设置引导加载程序。我们还需要清楚地了解这个initramfs镜像到底是什么,以及使用它的动机。接下来的部分将深入探讨这些细节。

第 6 步 - 生成initramfs镜像和引导加载程序设置

首先,请注意,这个讨论非常偏向于 x86[_64]架构。对于典型的 x86 桌面或服务器内核构建过程,这一步被内部分成了两个不同的部分:

  • 生成initramfs(以前称为initrd)镜像

  • (GRUB)引导加载程序设置为新的内核镜像

在这里,将它封装成一个单一步骤的原因是,在 x86 架构上,方便的脚本执行这两个任务,看起来就像是一个单一步骤。

想知道这个initramfs(或initrd)镜像文件到底是什么?请参阅下面的了解 initramfs 框架部分以获取详细信息。我们很快就会到那里。

现在,让我们继续并生成initramfs(即初始 RAM 文件系统)镜像文件,并更新引导加载程序。在 x86[_64] Ubuntu 上执行这个操作非常简单,只需一步即可完成:

$ sudo make install sh ./arch/x86/boot/install.sh 5.4.0-llkd01 arch/x86/boot/bzImage \
  System.map "/boot"
run-parts: executing /etc/kernel/postinst.d/apt-auto-removal 5.4.0-llkd01 /boot/vmlinuz-5.4.0-llkd01
run-parts: executing /etc/kernel/postinst.d/initramfs-tools 5.4.0-llkd01 /boot/vmlinuz-5.4.0-llkd01
update-initramfs: Generating /boot/initrd.img-5.4.0-llkd01
[...]
run-parts: executing /etc/kernel/postinst.d/zz-update-grub 5.4.0-llkd01 /boot/vmlinuz-5.4.0-llkd01
Sourcing file `/etc/default/grub'
Generating grub configuration file ...
Found linux image: /boot/vmlinuz-5.4.0-llkd01
Found initrd image: /boot/initrd.img-5.4.0-llkd01
[...]
Found linux image: /boot/vmlinuz-5.0.0-36-generic
Found initrd image: /boot/initrd.img-5.0.0-36-generic
[...]
done
$

请注意,再次,我们在make install命令前加上了sudo(8)。显然,这是因为我们需要root权限来写入相关的文件和文件夹。

就是这样,我们完成了:一个全新的 5.4 内核,以及所有请求的内核模块和initramfs镜像,都已经生成,并且(GRUB)引导加载程序已经更新。剩下的就是重新启动系统,在引导加载程序菜单屏幕上选择新的内核镜像,启动,登录,并验证一切是否正常。

在 Fedora 30 及以上版本上生成initramfs镜像

不幸的是,在 Fedora 30 及以上版本中,生成initramfs镜像似乎并不像在前面的部分中使用 Ubuntu 那样容易。一些人建议通过ARCH环境变量明确指定架构。看一下:

$ sudo make ARCH=x86_64 install
sh ./arch/x86/boot/install.sh 5.4.0-llkd01 arch/x86/boot/bzImage \
System.map "/boot"
Cannot find LILO.
$

失败了!想知道为什么吗?我不会在这里详细介绍,但这个链接应该会帮到你:discussion.fedoraproject.org/t/installing-manually-builded-kernel-in-system-with-grub2/1895。为了解决这个问题,以下是我在我的 Fedora 31 VM 上所做的(是的,它成功了!):

  1. 手动创建initramfs镜像:
 sudo mkinitrd /boot/initramfs-5.4.0-llkd01.img 5.4.0-llkd01
  1. 确保安装了grubby软件包:
sudo dnf install grubby-deprecated-8.40-36.fc31.x86_64

在输入grubby-后按两次Tab键会自动完成完整的软件包名称。

  1. 重新运行make install命令:
$ sudo make ARCH=x86_64 install
 sh ./arch/x86/boot/install.sh 5.4.0-llkd01 arch/x86/boot/bzImage \
 System.map "/boot"
 grubby fatal error: unable to find a suitable template
 grubby fatal error: unable to find a suitable template
 grubby: doing this would leave no kernel entries. Not writing out new config.
 $

尽管make install命令似乎失败了,但它已经足够成功了。让我们偷看一下/boot目录的内容来验证一下:

 $ ls -lht /boot
 total 204M
 -rw-------. 1 root root  44M Mar 26 13:08 initramfs-5.4.0-llkd01.img
 lrwxrwxrwx. 1 root root   29 Mar 26 13:07 System.map -> /boot/System.map-5.4.0-llkd01
 lrwxrwxrwx. 1 root root   26 Mar 26 13:07 vmlinuz -> /boot/vmlinuz-5.4.0-llkd01
 -rw-r--r--. 1 root root 4.1M Mar 26 13:07 System.map-5.4.0-llkd01
 -rw-r--r--. 1 root root 9.0M Mar 26 13:07 vmlinuz-5.4.0-llkd01
[...]

的确,initramfs镜像、System.map文件和vmlinuz(以及所需的符号链接)似乎已经设置好了!重新启动,从 GRUB 菜单中选择新的内核,并验证它是否正常工作。

在这一步中,我们生成了initramfs镜像。问题是,在我们执行此操作时,kbuild系统在幕后执行了什么?继续阅读以了解详情。

生成 initramfs 镜像-在幕后

请回想一下前一节中,当sudo make install命令执行时,您将首先看到什么(以下是为了您的方便而重现的):

$ sudo make install sh ./arch/x86/boot/install.sh 5.4.0-llkd01 arch/x86/boot/bzImage \
 System.map "/boot"

显然,(install.sh)是一个正在执行的脚本。在其工作的一部分内部,它将以下文件复制到/boot文件夹中,名称格式通常为<filename>-$(uname -r)

System.map-5.4.0-llkd01, initrd.img-5.4.0-llkd01, vmlinuz-5.4.0-llkd01, config-5.4.0-llkd01

initramfs镜像也被构建。一个名为update-initramfs的 shell 脚本执行此任务(它本身是另一个名为mkinitramfs(8)的脚本的方便包装,该脚本执行实际工作)。构建后,initramfs镜像也被复制到/boot目录中,在前面的输出片段中被视为initrd.img-5.4.0-llkd01

如果要复制到/boot的文件已经存在,则将其备份为<filename>-$(uname -r).old。名为vmlinuz-<kernel-ver>的文件是arch/x86/boot/bzImage文件的副本。换句话说,它是压缩的内核镜像-引导加载程序将被配置为将其加载到 RAM 中,解压缩并跳转到其入口点,从而将控制权交给内核!

为什么叫vmlinux(回想一下,这是存储在内核源树根目录中的未压缩内核镜像文件)和vmlinuz?这是一个古老的 Unix 惯例,Linux OS 非常乐意遵循:在许多 Unix 版本中,内核被称为vmunix,因此 Linux 将其称为vmlinux,压缩的内核被称为vmlinuzvmlinuz中的z是为了暗示(默认情况下)gzip(1)压缩。

此外,位于/boot/grub/grub.cfg的 GRUB 引导加载程序配置文件将被更新,以反映新的内核现在可用于引导。

同样值得强调的是,所有这些都是非常特定于架构的。前面的讨论是关于在 Ubuntu Linux x86[-64]系统上构建内核的。虽然在概念上类似,但内核镜像文件名、它们的位置,特别是引导加载程序,在不同的架构上有所不同。

如果您愿意,可以直接跳到自定义 GRUB 引导加载程序部分。如果您感兴趣(我希望如此),请继续阅读。在下一节中,我们将更详细地描述initramfs/inird框架的如何为什么

理解 initramfs 框架

还有一个谜团!这个initramfsinitrd镜像到底是干什么的?它为什么存在?

首先,使用此功能是一个选择-配置指令称为CONFIG_BLK_DEV_INITRD。默认情况下为y。简而言之,对于那些事先不知道某些事情的系统,比如引导磁盘主机适配器或控制器类型(SCSI,RAID 等),根文件系统格式化为确切的文件系统类型(是ext2ext3ext4btrfsreiserfsf2fs还是其他?),或者对于那些这些功能总是作为内核模块构建的系统,我们需要initramfs功能。为什么确切的原因一会儿就会变得清楚。另外,正如前面提到的,initrd现在被认为是一个较旧的术语。如今,我们更经常使用initramfs这个术语。

为什么要使用 initramfs 框架?

initramfs框架本质上是早期内核引导和用户模式之间的一种中间人。它允许我们在实际根文件系统被挂载之前运行用户空间应用程序(或脚本)。这在许多情况下都很有用,以下列表详细说明了其中的一些情况。关键点是,initramfs允许我们在内核在引导时通常无法运行的用户模式应用程序。

实际上,在各种用途中,这个框架使我们能够做一些事情,包括以下内容:

  • 设置控制台字体。

  • 自定义键盘布局设置。

  • 在控制台设备上打印自定义欢迎消息。

  • 接受密码(用于加密磁盘)。

  • 根据需要加载内核模块。

  • 如果出现故障,生成“救援”shell。

  • 还有更多!

想象一下,你正在从事构建和维护新 Linux 发行版的业务。现在,在安装时,你的发行版的最终用户可能会决定用reiserfs文件系统格式化他们的 SCSI 磁盘(FYI,这是内核中最早的通用日志文件系统)。问题是,你无法预先知道最终用户会做出什么选择 - 它可能是任何一种文件系统。因此,你决定预先构建和提供大量的内核模块,几乎可以满足所有可能性。好吧,当安装完成并且用户的系统启动时,在这种情况下,内核将需要reiserfs.ko内核模块才能成功挂载根文件系统,从而继续系统启动。

图 3.1 - 磁盘上的根文件系统尚未挂载,内核映像在 RAM 中

但是,请等一下,想想这个,我们现在有一个经典的鸡和蛋问题:为了使内核挂载根文件系统,它需要将reiserfs.ko内核模块文件加载到 RAM 中(因为它包含必要的代码,能够与文件系统一起工作)。但是,该文件本身嵌入在reiserfs根文件系统中;准确地说,在/lib/modules/<kernel-ver>/kernel/fs/reiserfs/目录中!(见图 3.1)。initramfs框架的主要目的之一是解决这个鸡和蛋问题

initramfs镜像文件是一个压缩的cpio存档(cpiotar(1)使用的平面文件格式)。正如我们在前一节中看到的,update-initramfs脚本在内部调用mkinitramfs脚本(至少在 Ubuntu 上是这样)。这些脚本构建一个包含内核模块以及支持基础设施(如/etc/lib文件夹)的最小根文件系统,以简单的cpio文件格式,然后通常进行 gzip 压缩。现在形成了所谓的initramfs(或initrd)镜像文件,正如我们之前看到的,它将被放置在/boot/initrd.img-<kernel-ver>中。那么这有什么帮助呢?

在引导时,如果我们使用initramfs功能,引导加载程序将在其工作的一部分中将initramfs镜像文件加载到 RAM 中。接下来,当内核本身在系统上运行时,它会检测到initramfs镜像的存在,解压缩它,并使用其内容(通过脚本)将所需的内核模块加载到 RAM 中(图 3.2):

图 3.2 - initramfs 镜像充当早期内核和实际根文件系统可用性之间的中间人

关于 x86 引导过程和 initramfs 镜像的更多细节可以在以下部分找到。

了解 x86 上的引导过程的基础知识

在下面的列表中,我们提供了关于 x86[_64]桌面(或笔记本电脑)、工作站或服务器上典型引导过程的简要概述:

  1. 早期引导,POST,BIOS 初始化 - BIOS(即 x86 上的固件,简称基本输入输出系统)将第一个可引导磁盘的第一个扇区加载到 RAM 中,并跳转到其入口点。这形成了通常被称为第一阶段引导加载程序的东西,其主要工作是将第二阶段(更大)引导加载程序代码加载到内存并跳转到它。

  2. 现在第二阶段引导加载程序代码接管了控制。它的主要工作是将实际(第三阶段)GRUB 引导加载程序*加载到内存并跳转到其入口点(GRUB 通常是 x86[-64]系统上使用的引导加载程序)

  3. 引导加载程序将传递压缩的内核图像文件(/boot/vmlinuz-<kernel-ver>)以及压缩的initramfs图像文件(/boot/initrd.img-<kernel-ver>)作为参数。引导加载程序将(简单地)执行以下操作:

    • 执行低级硬件初始化。
  • 将这些图像加载到 RAM 中,对内核图像进行一定程度的解压缩。

  • 它将跳转到内核入口点

  1. Linux 内核现在控制着机器,将初始化硬件和软件环境。它不会对引导加载程序之前执行的工作做任何假设。

  2. 在完成大部分硬件和软件初始化后,它注意到initramfs功能已经打开(CONFIG_BLK_DEV_INITRD=y)。因此,它将在 RAM 中定位(并且如果需要,解压缩)initramfsinitrd)图像(参见图 3.2)。

  3. 然后,它将将其作为 RAM 中的临时根文件系统挂载

  4. 我们现在在内存中设置了一个基本的最小根文件系统。因此,initrd启动脚本现在运行,执行加载所需的内核模块到 RAM 中的任务(实际上是加载根文件系统驱动程序,包括在我们的场景中的reiserfs.ko内核模块;再次参见图 3.2)。

  5. 然后,内核执行pivot-root卸载临时的initrd根文件系统,释放其内存,并挂载真正的根文件系统;现在这是可能的,因为提供该文件系统支持的内核模块确实可用。

  6. 一旦(实际的)根文件系统成功挂载,系统初始化就可以继续进行。内核继续,最终调用第一个用户空间进程,通常是/sbin/init PID 1

  7. SysV **init框架现在继续初始化系统,按照配置的方式启动系统服务。

需要注意的几点:

(a) 在现代 Linux 系统上,传统的(即:旧的)SysV init框架已经大部分被一个名为systemd的现代优化框架所取代。因此,在许多(如果不是大多数)现代 Linux 系统上,包括嵌入式系统,传统的/sbin/init已被systemd取代(或者是其可执行文件的符号链接)。在本章末尾的进一步阅读部分了解更多关于systemd的信息。

(b) 顺便说一句,本书不涵盖根文件系统本身的生成;作为一个简单的例子,我建议您查看我在第一章中提到的 SEALS 项目的代码(在github.com/kaiwan/seals);它有一个脚本,可以从头开始生成一个非常简单或“骨架”根文件系统。

现在您了解了initrd/initramfs背后的动机,我们将在下一节中深入了解initramfs。请继续阅读!

关于 initramfs 框架的更多信息

initramfs框架帮助的另一个地方是启动磁盘加密的计算机。在引导过程的早期阶段,内核将不得不询问用户密码,如果正确,就会继续挂载磁盘等。但是,请考虑一下:如果没有建立 C 运行时环境,即包含库、加载程序、所需的内核模块(可能是加密支持的内核模块)等的根文件系统,我们如何运行一个请求密码的 C 程序可执行文件?

请记住,内核本身尚未完成初始化;用户空间应用程序如何运行?再次,initramfs框架通过确实在内存中设置一个临时用户空间运行环境来解决这个问题,其中包含所需的根文件系统,包含库、加载程序、内核模块等。

我们可以验证吗?可以!让我们来看看initramfs映像文件。在 Ubuntu 上,lsinitramfs(8)脚本正好用于此目的(在 Fedora 上,相当应的脚本称为lsinitrd):

$ lsinitramfs /boot/initrd.img-5.4.0-llkd01 | wc -l
334
$ lsinitramfs /boot/initrd.img-5.4.0-llkd01
.
kernel
kernel/x86
[...]
lib
lib/systemd
lib/systemd/network
lib/systemd/network/99-default.link
lib/systemd/systemd-udevd
[...]
lib/modules/5.4.0-llkd01/kernel/drivers/net/ethernet/intel/e1000/e1000.ko
lib/modules/5.4.0-llkd01/modules.dep
[...]
lib/x86_64-linux-gnu/libc-2.27.so
[...]
lib/x86_64-linux-gnu/libaudit.so.1
lib/x86_64-linux-gnu/ld-2.27.so
lib/x86_64-linux-gnu/libpthread.so.0
[...]
etc/udev/udev.conf
etc/fstab
etc/modprobe.d
[...]
bin/dmesg
bin/date
bin/udevadm
bin/reboot
[...]
sbin/fsck.ext4
sbin/dmsetup
sbin/blkid
sbin/modprobe
[...]
scripts/local-premount/resume
scripts/local-premount/ntfs_3g
$

里面有相当多的内容:我们截断输出以显示一些精选片段。显然,我们可以看到一个最小的根文件系统,支持所需的运行时库、内核模块、/etc/bin/sbin目录,以及它们的实用程序。

构建initramfs(或initrd)映像的细节超出了我们希望在这里涵盖的范围。我建议您查看这些脚本以揭示它们的内部工作(在 Ubuntu 上):/usr/sbin/update-initramfs,这是/usr/sbin/mkinitramfs shell 脚本的包装脚本。有关更多信息,请参阅进一步阅读部分。

此外,现代系统通常具有所谓的混合initramfs:一个由早期ramfs映像和常规或主ramfs映像组成的initramfs映像。事实上,我们需要特殊的工具来解包/打包(解压缩/压缩)这些映像。Ubuntu 分别提供了unmkinitramfs(8)mkinitramfs(8)脚本来执行这些操作。

作为一个快速实验,让我们将我们全新的initramfs映像(在上一节中生成的映像)解压到一个临时目录中。同样,这是在我们的 Ubuntu 18.04 LTS 虚拟机上执行的。使用tree(1)查看其输出以便阅读:

$ TMPDIR=$(mktemp -d)
$ unmkinitramfs /boot/initrd.img-5.4.0-llkd01 ${TMPDIR}
$ tree ${TMPDIR} | less
/tmp/tmp.T53zY3gR91
├── early
│   └── kernel
│       └── x86
│           └── microcode
│               └── AuthenticAMD.bin
└── main
    ├── bin
    │   ├── [
    │   ├── [[
    │   ├── acpid
    │   ├── ash
    │   ├── awk
[...]
  ├── etc
    │   ├── console-setup
    │   │   ├── cached_UTF-8_del.kmap.gz
[...]
   ├── init
   ├── lib
[...]
    │   ├── modules
    │   │   └── 5.4.0-llkd01
    │   │   ├── kernel
    │   │   │   └── drivers
[...]
    ├── scripts
    │   ├── functions
    │   ├── init-bottom
[...]
    └── var
        └── lib
            └── dhcp
$ 

这结束了我们对 x86 上initramfs框架和引导过程基础的(相当冗长的)讨论。好消息是,现在,掌握了这些知识,您可以通过根据需要调整initramfs映像来进一步定制产品-这是一项重要的技能!

例如(正如前面提到的),在现代系统中,安全性是一个关键因素,能够在块级别对磁盘进行加密是一个强大的安全功能;这在很大程度上涉及调整initramfs映像。 (再次强调,由于这超出了本书的范围,请参阅本章末尾的进一步阅读部分,以获取有关此内容和其他方面的有用链接。)

现在让我们通过对(x86)GRUB 引导加载程序的引导脚本进行一些简单的定制来完成内核构建。

第 7 步-定制 GRUB 引导加载程序

我们现在已经完成了第二章中概述的步骤 16从源代码构建 5.x Linux 内核-第一部分,在从源代码构建内核的步骤部分。我们可以重新启动系统;当然,首先关闭所有应用程序和文件。默认情况下,现代GRUBGRand Unified Bootloader)引导加载程序甚至在重新启动时都不会显示任何菜单;它将默认引导到新构建的内核(请记住,在这里,我们仅描述了 x86[_64]系统运行 Ubuntu 的这个过程)。

在 x86[_64]上,您可以在系统早期启动期间始终进入 GRUB 菜单。只需确保在启动过程中按住Shift键。

如果我们希望每次启动系统时都看到并定制 GRUB 菜单,从而可能选择要引导的备用内核/操作系统,该怎么办?在开发过程中,这通常非常有用,因此让我们看看如何做到这一点。

定制 GRUB-基础知识

定制 GRUB 非常容易。请注意以下内容:

  • 以下步骤是在“目标”系统本身上执行的(而不是在主机上);在我们的情况下,是 Ubuntu 18.04 虚拟机。

  • 这已在我们的 Ubuntu 18.04 LTS 客户系统上进行了测试和验证。

以下是我们定制的一系列快速步骤:

  1. 让我们安全起见,保留 GRUB 引导加载程序配置文件的备份副本:
sudo cp /etc/default/grub /etc/default/grub.orig

/etc/default/grub文件是涉及的用户配置文件。在编辑之前,为了安全起见,我们进行备份。这总是一个好主意。

  1. 编辑它。您可以使用vi(1)或您选择的编辑器:
sudo vi /etc/default/grub 
  1. 要始终在启动时显示 GRUB 提示符,请插入此行:
GRUB_HIDDEN_TIMEOUT_QUIET=false

在某些 Linux 发行版上,您可能会有GRUB_TIMEOUT_STYLE=hidden指令;只需将其更改为GRUB_TIMEOUT_STYLE=menu即可实现相同的效果。

  1. 根据需要设置启动默认操作系统的超时时间(以秒为单位);默认为10秒;请参阅以下示例:
GRUB_TIMEOUT=3

将上述超时值设置为以下值将产生以下结果:

  • 0:立即启动系统,不显示菜单。

  • -1:无限等待。

此外,如果存在GRUB_HIDDEN_TIMEOUT指令,只需将其注释掉:

#GRUB_HIDDEN_TIMEOUT=1
  1. 最后,以root身份运行update-grub(8)程序,使您的更改生效:
sudo update-grub

上述命令通常会导致initramfs镜像被刷新(重新生成)。完成后,您可以准备重新启动系统。不过等一下!接下来的部分将向您展示如何修改 GRUB 的配置,以便默认启动您选择的内核。

选择默认要启动的内核

GRUB 默认内核预设为数字零(通过GRUB_DEFAULT=0指令)。这将确保“第一个内核” - 最近添加的内核 - 默认启动(超时后)。这可能不是我们想要的;例如,在我们的 Ubuntu 18.04.3 LTS 虚拟机上,我们将其设置为默认的 Ubuntu 发行版内核,就像之前一样,通过编辑/etc/default/grub文件(当然是作为 root 用户):

GRUB_DEFAULT="Advanced options for Ubuntu>Ubuntu, with Linux 5.0.0-36-generic"

当然,这意味着如果您的发行版被更新或升级,您必须再次手动更改上述行,以反映您希望默认启动的新发行版内核,然后运行sudo update-grub

好了,我们新编辑的 GRUB 配置文件如下所示:

$ cat /etc/default/grub
[...]
#GRUB_DEFAULT=0
GRUB_DEFAULT="Advanced options for Ubuntu>Ubuntu, with Linux 5.0.0-36-generic"
#GRUB_TIMEOUT_STYLE=hidden
GRUB_HIDDEN_TIMEOUT_QUIET=false
GRUB_TIMEOUT=3
GRUB_DISTRIBUTOR=`lsb_release -i -s 2> /dev/null || echo Debian`
GRUB_CMDLINE_LINUX_DEFAULT="quiet splash"
GRUB_CMDLINE_LINUX=""
[...] 

与前一部分一样,不要忘记:如果您在这里进行任何更改,请运行sudo update-grub命令使更改生效。

需要注意的其他事项:

a) 此外,您可以添加“漂亮”的调整,比如通过BACKGROUND_IMAGE="<img_file>"指令来更改背景图片(或颜色)。

b) 在 Fedora 上,GRUB 引导程序配置文件有点不同;运行此命令以在每次启动时显示 GRUB 菜单:

sudo grub2-editenv - unset menu_auto_hide 详细信息可以在Fedora wiki: Changes/HiddenGrubMenu中找到:fedoraproject.org/wiki/Changes/HiddenGrubMenu

c) 不幸的是,GRUB2(最新版本现在是 2)似乎在几乎每个 Linux 发行版上都有不同的实现方式,导致在尝试以一种特定的方式进行调整时出现不兼容性。

现在让我们重新启动虚拟机系统,进入 GRUB 菜单,并启动我们的新内核。

全部完成!让我们(终于!)重新启动系统:

$ sudo reboot
[sudo] password for llkd: 

一旦系统完成关机程序并重新启动,您应该很快就会看到 GRUB 引导程序菜单(下一部分还显示了几个屏幕截图)。一定要通过按任意键来中断它!

虽然总是可能的,但我建议您不要删除原始的发行版内核镜像(以及相关的initrdSystem.map文件等)。如果您全新的内核无法启动呢?(如果泰坦尼克号都会发生...)通过保留我们的原始镜像,我们就有了备用选项:从原始发行版内核启动,解决我们的问题,并重试。

作为最坏的情况,如果所有其他内核/initrd镜像都已被删除,您的单个新内核无法成功启动呢?好吧,您总是可以通过 USB 闪存驱动器引导到恢复模式的 Linux;关于这方面的一些搜索将产生许多链接和视频教程。

通过 GNU GRUB 引导程序引导我们的虚拟机

现在我们的虚拟机客人(使用Oracle VirtualBox hypervisor)即将启动; 一旦它(模拟的)BIOS 例程完成,GNU GRUB 引导加载程序屏幕首先显示出来。 这是因为我们故意将GRUB_HIDDEN_TIMEOUT_QUIET GRUB 配置指令更改为false。 请参阅以下截图(图 3.3)。 截图中看到的特定样式是 Ubuntu 发行版自定义的样式:

图 3.3 - GRUB2 引导加载程序 - 在系统启动时暂停

现在让我们直接引导我们的虚拟机:

  1. 按下任何键盘键(除了Enter)以确保默认内核在超时(回想一下,我们将其设置为 3 秒)到期后不会引导。

  2. 如果还没有到达那里,请滚动到Ubuntu 的高级选项菜单,将其突出显示,然后按Enter

  3. 现在你会看到一个类似的菜单,但可能不完全相同,如下截图(图 3.4)。 对于 GRUB 检测到并可以引导的每个内核,都显示了两行 - 一个是内核本身,另一个是进入相同内核的特殊恢复模式引导选项:

图 3.4 - GRUB2 引导加载程序显示可引导的内核

注意默认引导的内核 - 在我们的情况下,默认高亮显示了5.0.0-36-generic内核,带有一个星号(*)。

前面的截图显示了一些“额外”的行项目。 这是因为在拍摄这张截图时,我已经更新了虚拟机,因此还安装了一些更新的内核。 我们可以看到5.0.0-37-generic5.3.0-26-generic内核。 没关系; 我们在这里忽略它们。

  1. 无论如何,只需滚动到感兴趣的条目,也就是5.4.0-llkd01内核条目。 在这里,它是 GRUB 菜单的第一行(因为它是可引导操作系统的 GRUB 菜单的最新添加):Ubuntu, with Linux 5.4.0-llkd01

  2. 一旦你突出显示了前面的菜单项,按Enter,完成! 引导加载程序将继续执行它的工作,将内核映像和initrd映像解压缩并加载到 RAM 中,并跳转到 Linux 内核的入口点,从而将控制权交给 Linux!

好了,如果一切顺利,就像应该的那样,你将成功引导到全新构建的 5.4.0 Linux 内核! 祝贺你完成了一项出色的任务。 再说一遍,你可以做得更多 - 以下部分将向你展示如何在运行时(引导时)进一步编辑和自定义 GRUB 的配置。 再次,这种技能偶尔会派上用场 - 例如,忘记了 root 密码? 是的,确实,你实际上可以使用这种技术绕过它! 继续阅读以了解详情。

尝试使用 GRUB 提示

你可以进一步进行实验; 而不仅仅是在Ubuntu, with Linux 5.4.0-llkd01内核的菜单条目上按Enter,确保突出显示此行并按e键(进行编辑)。 现在我们将进入 GRUB 的编辑屏幕,在这里我们可以自由更改任何值。 这是按下e键后的截图:

图 3.5 - GRUB2 引导加载程序 - 自定义 5.4.0-llkd01 内核的详细信息

这个截图是在向下滚动几行后拍摄的; 仔细看,你可以在编辑框底部的第三行的开头处看到光标(一个下划线状的, "_")。 这是关键的一行; 它以适当缩进的关键字linux开头。 它指定通过 GRUB 引导加载程序传递给 Linux 内核的内核参数列表。

尝试在这里做一些实验。 举个简单的例子,从这个条目中删除单词quietsplash,然后按Ctrl + XF10进行引导。 这一次,漂亮的 Ubuntu 启动画面不会出现; 你直接在控制台中看到所有内核消息闪过。

一个常见的问题:如果我们忘记了密码,因此无法登录怎么办?有几种方法可以解决这个问题。其中一种是通过引导加载程序:像我们一样进入 GRUB 菜单,转到相关的菜单项,按e进行编辑,滚动到以单词linux开头的行,并在此条目的末尾添加单词single(或只是数字1),使其看起来像这样:

               linux       /boot/vmlinuz-5.0.0-36-generic \ root=UUID=<...> ro quiet splash single

现在,当您启动时,内核将以单用户模式启动,并为您,永远感激的用户,提供具有 root 访问权限的 shell。只需运行passwd <username>命令来更改您的密码。

进入单用户模式的确切过程因发行版而异。在 Red Hat/Fedora/CentOS 上编辑 GRUB2 菜单的确切内容与其他发行版有些不同。请参阅进一步阅读部分,了解如何为这些系统设置的链接。

这教会了我们一些关于安全的东西,不是吗?当可以在没有密码的情况下访问引导加载程序菜单(甚至是 BIOS)时,系统被认为是不安全的!事实上,在高度安全的环境中,甚至必须限制对控制台设备的物理访问。

现在您已经学会了如何自定义 GRUB 引导加载程序,并且我期望您已经启动到了新的 5.4 Linux 内核!让我们不要假设,让我们验证内核是否确实按照我们的计划配置。

验证我们新内核的配置

好的,回到我们的讨论:我们现在已经启动到我们新构建的内核中。但是等等,让我们不要盲目地假设,让我们实际验证一下是否一切都按计划进行。经验主义方法总是最好的:

$ uname -r
5.4.0-llkd01

事实上,我们现在正在我们刚构建的5.4.0 Linux 内核上运行 Ubuntu 18.04.3 LTS!

回想一下我们在第二章中编辑的内核配置表,从源代码构建 5.x Linux 内核-第一部分,在表 2.4中。我们应该逐行检查我们已经更改的每个配置是否实际生效。让我们列出其中一些,从关注的CONFIG_'FOO'名称开始,如下所示:

  • CONFIG_LOCALVERSIONuname -r的前面输出清楚地显示了内核版本的localversion(或-EXTRAVERSION)部分已经设置为我们想要的-llkd01字符串。

  • CONFIG_IKCONFIG:允许我们查看当前内核配置的详细信息。让我们检查一下。请记住,您需要将LLKD_KSRC环境变量设置为您的 5.4 内核源代码树目录的根位置:

$ ${LLKD_KSRC}/scripts/extract-ikconfig /boot/vmlinuz-5.4.0-llkd01
#
# Automatically generated file; DO NOT EDIT.
# Linux/x86 5.4.0 Kernel Configuration
[...]
CONFIG_IRQ_WORK=y
[...]

它奏效了!我们可以通过scripts/extract-ikconfig脚本看到整个内核配置。我们将使用这个脚本来grep(1)我们在上述表 2.4中更改的其余配置指令:

$ scripts/extract-ikconfig /boot/vmlinuz-5.4.0-llkd01 | egrep "IKCONFIG|HAMRADIO|PROFILING|VBOXGUEST|UIO|MSDOS_FS|SECURITY|DEBUG_STACK_USAGE"
CONFIG_IKCONFIG=y
CONFIG_IKCONFIG_PROC=y
# CONFIG_PROFILING is not set
# CONFIG_HAMRADIO is not set
CONFIG_UIO=m
# CONFIG_UIO_CIF is not set
CONFIG_UIO_PDRV_GENIRQ=m
# CONFIG_UIO_DMEM_GENIRQ is not set
[...]
CONFIG_VBOXGUEST=m
CONFIG_EXT4_FS_SECURITY=y
CONFIG_MSDOS_FS=m
# CONFIG_SECURITY_DMESG_RESTRICT is not set
# CONFIG_SECURITY is not set
CONFIG_SECURITYFS=y
CONFIG_DEFAULT_SECURITY_DAC=y
CONFIG_DEBUG_STACK_USAGE=y
$ 

仔细查看前面的输出,我们可以看到我们确实得到了我们想要的结果。我们的新内核配置设置与第二章中从源代码构建 5.x Linux 内核-第一部分表 2.4中预期的设置完全匹配;完美。

或者,由于我们启用了CONFIG_IKCONFIG_PROC选项,我们可以通过查找(压缩的)proc文件系统条目/proc/config.gz来实现相同的验证,就像这样:

gunzip -c /proc/config.gz | egrep \ "IKCONFIG|HAMRADIO|PROFILING|VBOXGUEST|UIO|MSDOS_FS|SECURITY|DEBUG_STACK_USAGE"

所以,内核构建完成了!太棒了。我建议您回到第二章,从源代码构建 5.x Linux 内核-第一部分,在从源代码构建内核的步骤部分,再次查看整个过程的高级概述。我们将以树莓派设备内核的有趣交叉编译和一些剩余的提示结束本章。

树莓派的内核构建

一个受欢迎且相对便宜的单板计算机SBC)用于实验和原型设计是基于 ARM 的树莓派。爱好者和改装者发现它非常有用,可以尝试并学习如何使用嵌入式 Linux,特别是因为它有强大的社区支持(有许多问答论坛)和良好的支持:

图 3.6-树莓派 3 型 B+设备(请注意,照片中看到的 USB 转串口电缆不随设备一起提供)

有两种方式可以为目标设备构建内核:

  • 在功能强大的主机系统上构建内核,通常是运行 Linux 发行版的 Intel/AMD x86_64(或 Mac)台式机或笔记本电脑。

  • 在目标设备本身上进行构建。

我们将遵循第一种方法-它更快,被认为是执行嵌入式 Linux 开发的正确方法。

我们假设(像往常一样)我们正在运行我们的 Ubuntu 18.04 LTS 虚拟机。所以,想想看;现在,主机系统实际上是嵌入式 Linux 虚拟机!此外,我们的目标是为 ARM 32 位架构构建内核,而不是 64 位。

在虚拟机上执行大型下载和内核构建操作并不是理想的。根据主机和客户端的功率和 RAM,这将需要一段时间。它可能会比在本机 Linux 框上构建慢两倍。尽管如此,假设您在客户端设置了足够的磁盘空间(当然主机实际上有这个空间可用),这个过程是有效的。

我们将不得不使用x86_64 到 ARM(32 位)交叉编译器来构建内核,或者为树莓派目标构建任何组件。这意味着还需要安装适当的交叉工具链来执行构建。

在接下来的几个部分中,我们将工作分为三个离散的步骤:

  1. 为设备获取适当的内核源树

  2. 学习如何安装适当的交叉工具链

  3. 配置和构建内核

那么让我们开始吧!

第 1 步-克隆内核源树

我们任意选择一个暂存文件夹(构建发生的地方)用于内核源树和交叉工具链,并将其分配给一个环境变量(以避免硬编码):

  1. 设置您的工作空间。我们将一个环境变量设置为RPI_STG(不需要使用这个环境变量的确切名称;只需选择一个合理的名称并坚持使用)到暂存文件夹的位置-我们将在那里进行工作。随时使用适合您系统的值:
export RPI_STG=~/rpi_work
mkdir -p ${RPI_STG}/kernel_rpi ${RPI_STG}/rpi_tools

确保您有足够的磁盘空间可用:内核源树大约占用 900 MB,工具链大约占用 1.5 GB。您至少需要另外一千兆字节的工作空间。

  1. 下载树莓派内核源树(我们从官方源克隆,树莓派 GitHub 内核树库,链接:github.com/raspberrypi/linux/):
cd ${RPI_STG}/kernel_rpi
git clone --depth=1 --branch rpi-5.4.y https://github.com/raspberrypi/linux.git

内核源树被克隆到一个名为linux/的目录下(即${RPI_WORK}/kernel_rpi/linux)。请注意,在前面的代码中,我们有以下内容:

  • 我们选择的特定树莓派内核树分支不是最新的(在撰写本文时,最新的是 5.11 系列),它是 5.4 内核;这完全没问题(它是 LTS 内核,也与我们的 x86 内核匹配!)。

  • 我们将--depth参数设置为1传递给git clone以减少下载和解压负载。

现在树莓派内核源已安装。让我们简要验证一下:

$ cd ${RPI_STG}/kernel_rpi/linux ; head -n5 Makefile
# SPDX-License-Identifier: GPL-2.0
VERSION = 5
PATCHLEVEL = 4
SUBLEVEL = 51
EXTRAVERSION =

好的,这是 5.4.51 树莓派内核端口(我们在 x86_64 上使用的内核版本是 5.4.0;轻微的变化没问题)。

第 2 步-安装交叉工具链

现在是时候在您的主机系统上安装适用于执行实际构建的交叉工具链。事实上,有几个可用的工作工具链...在这里,我将展示两种获取和安装工具链的方法。第一种是最简单的,通常足够了,而第二种方法安装了一个更复杂的版本。

第一种方法-通过 apt 包安装

这非常简单且效果很好;通常使用这种方法:

sudo apt install ​crossbuild-essential-armhf

工具通常安装在/usr/bin/下,因此已经包含在您的PATH中;您可以直接使用它们。例如,检查 ARM-32 gcc编译器的位置和版本如下:

$ which arm-linux-gnueabihf-gcc
/usr/bin/arm-linux-gnueabihf-gcc
$ arm-linux-gnueabihf-gcc --version |head -n1
arm-linux-gnueabihf-gcc (Ubuntu 9.3.0-17ubuntu1~20.04) 9.3.0

此外,请记住:此工具链适用于构建 ARM 32 位架构的内核,而不适用于 64 位架构。如果您的意图是构建 64 位架构(这里我们不涉及),您将需要安装一个 x86_64 到 ARM64 的工具链,使用sudo apt install crossbuild-essential-arm64

第二种方法-通过源代码库安装

这是一种更复杂的方法。在这里,我们从树莓派的 GitHub 存储库克隆工具链:

  1. 下载工具链。让我们将其放在名为rpi_tools的文件夹中,放在我们的树莓派分期目录中:
cd ${RPI_STG}/rpi_tools
git clone https://github.com/raspberrypi/tools
  1. 更新PATH环境变量,使其包含工具链二进制文件:
export PATH=${PATH}:${RPI_STG}/rpi_tools/tools/arm-bcm2708/arm-linux-gnueabihf/bin/

设置PATH环境变量(如前面的代码所示)是必需的。但是,它只对当前的 shell 会话有效。通过将前面的行放入启动脚本(通常是您的${HOME}/.bashrc文件或等效文件)使其永久化。

如前所述,也可以使用其他工具链。例如,ARM 开发(A 型处理器)的几个工具链可在 ARM 开发者网站上找到。

第 3 步-配置和构建内核

让我们配置内核(适用于树莓派 2、3 和 3[B]+)。在开始之前,非常重要要记住以下内容:

  • ARCH环境变量应设置为要进行交叉编译的 CPU(架构),即编译后的代码将在该 CPU 上运行。要设置ARCH的值,是内核源树中arch/目录下的目录名称。例如,将ARCH设置为arm用于 ARM32,arm64用于 ARM64,powerpc用于 PowerPC,openrisc用于 OpenRISC 处理器。

  • CROSS_COMPILE环境变量应设置为交叉编译器(工具链)的前缀。基本上,它是在工具链中每个实用程序之前的前几个常见字母。在我们的下面的示例中,所有工具链实用程序(C 编译器gcc,链接器,C++,objdump等)都以arm-linux-gnueabihf-开头,因此我们将CROSS_COMPILE设置为这个。Makefile将始终调用实用程序为${CROSS_COMPILE}<utility>,因此调用正确的工具链可执行文件。这意味着工具链目录应该在PATH变量中(正如我们在前面的部分中提到的)。

好的,让我们构建内核:

cd ${RPI_STG}/kernel_rpi/linux
make mrproper
KERNEL=kernel7
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- bcm2709_defconfig

关于配置目标bcm2709_defconfig的简要说明:这一关键点在第二章中提到,从源代码构建 5.x Linux 内核-第一部分。我们必须确保使用适当的特定于板的内核配置文件作为起点。在这里,这是树莓派 2、树莓派 3、树莓派 3+和计算模块 3 设备上 Broadcom SoC 的正确内核配置文件。指定的bcm2709_defconfig配置目标会解析arch/arm/configs/bcm2709_defconfig文件的内容。(树莓派网站将其文档化为适用于树莓派 2、树莓派 3、树莓派 3+和计算模块 3 默认构建配置的bcm2709_defconfig。重要提示:如果您为其他类型的树莓派设备构建内核,请参阅www.raspberrypi.org/documentation/linux/kernel/building.md。)

顺便说一句,kernel7的值是这样的,因为处理器是基于 ARMv7 的(实际上,从树莓派 3 开始,SoC 是 64 位 ARMv8,兼容在 32 位 ARMv7 模式下运行;在这里,因为我们正在为 ARM32(AArch32)构建 32 位内核,我们指定KERNEL=kernel7)。

SoCs 的种类、它们的封装以及它们的命名方式造成了很多混乱;这个链接可能会有所帮助:raspberrypi.stackexchange.com/questions/840/why-is-the-cpu-sometimes-referred-to-as-bcm2708-sometimes-bcm2835

如果需要对内核配置进行任何进一步的定制,您可以使用以下方法:

make ARCH=arm menuconfig

如果不需要,可以跳过此步骤并继续。使用以下方法构建(交叉编译)内核、内核模块和 DTB:

make -j4 ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- zImage modules dtbs

(根据您的构建主机适当调整-jn)。一旦构建成功完成,我们可以看到生成了以下文件:

$ ls -lh vmlinux System.map arch/arm/boot/zImage
-rwxrwxr-x 1 llkd llkd  5.3M Jul 23 12:58 arch/arm/boot/zImage
-rw-rw-r-- 1 llkd llkd  2.5M Jul 23 12:58 System.map
-rwxrwxr-x 1 llkd llkd   16M Jul 23 12:58 vmlinux
$ 

在这里,我们的目的只是展示 Linux 内核如何配置和构建为不同于编译主机的架构,或者换句话说,进行交叉编译。关于将内核映像(和 DTB 文件)放在 microSD 卡上等细节不在此讨论范围内。我建议您查阅树莓派内核构建的完整文档,可以在这里找到:www.raspberrypi.org/documentation/linux/kernel/building.md

尽管如此,这里有一个快速提示,可以在树莓派 3[B+]上尝试新内核:

  1. 挂载 microSD 卡。通常会有一个 Raspbian 发行版和两个分区,bootrootfs,分别对应mmcblk0p1mmcblk0p2分区。

  2. 引导加载程序和相关二进制文件:关键是将低级启动二进制文件,包括引导加载程序本身,放到 SD 卡的引导分区上;这包括bootcode.bin(实际的引导加载程序)、fixup*.datstart*.elf二进制文件;/boot文件夹的内容在这里解释:www.raspberrypi.org/documentation/configuration/boot_folder.md。(如果您不确定如何获取这些二进制文件,最简单的方法可能是在 SD 卡上安装一个标准版本的树莓派 OS;这些二进制文件将被安装在其引导分区内。标准的树莓派 OS 镜像可以从www.raspberrypi.org/downloads/获取;另外,新的 Raspberry Pi Imager 应用程序(适用于 Windows、macOS、Linux)使得首次安装变得非常容易)。

  3. 如果存在,备份并用我们刚刚构建的zImage文件替换 microSD 卡上/boot分区内的kernel7.img文件,命名为kernel7.img

  4. 安装刚构建的内核模块;确保您将位置指定为 microSD 卡的根文件系统,使用INSTALL_MOD_PATH环境变量!(未这样做可能会覆盖主机的模块,这将是灾难性的!)在这里,我们假设 microSD 卡的第二个分区(其中包含根文件系统)被挂载在/media/${USER}/rootfs下;然后,执行以下操作(一行内全部执行):

sudo env PATH=$PATH make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf-  INSTALL_MOD_PATH=/media/${USER}/rootfs modules_install
  1. 在 SD 卡上安装刚生成的 DTB(和叠加):
sudo cp arch/arm/boot/dts/*.dtb /media/${USER}/boot
sudo cp arch/arm/boot/dts/overlays/*.dtb* arch/arm/boot/dts/overlays/README /media/${USER}/boot/overlays
sync 
  1. 卸载 SD 卡,重新插入设备,然后尝试。

再次,为了确保它能正常工作,请参考官方文档(可在www.raspberrypi.org/documentation/linux/kernel/building.md找到)。我们没有涵盖有关生成和复制内核模块和 DTB 到 microSD 卡的详细信息。

另外,值得一提的是,我们在第十一章中再次讨论了树莓派的内核配置和构建,CPU 调度器-第二部分

这完成了我们对树莓派内核交叉编译的简要介绍。我们将以一些杂项但仍然有用的提示结束本章。

内核构建的杂项提示

我们以一些提示结束了从源代码构建 Linux 内核的本章。以下各小节都包含了您需要注意的提示。

对于新手来说,经常会感到困惑的一点是:一旦我们配置、构建并从新的 Linux 内核引导,我们会注意到根文件系统和任何其他挂载的文件系统仍然与原始(发行版或自定义)系统上的相同。只有内核本身发生了变化。这是完全有意的,因为 Unix 范式要求内核和根文件系统之间松散耦合。由于根文件系统包含所有应用程序、系统工具和实用程序,包括库,实际上,我们可以为相同的基本系统拥有几个内核,以适应不同的产品风格。

最低版本要求

要成功构建内核,您必须确保您的构建系统具有工具链(和其他杂项工具和实用程序)的文档化的最低版本。这些信息清楚地在内核文档的编译内核的最低要求部分中,可在 github.com/torvalds/linux/raw/master/Documentation/process/changes.rst#minimal-requirements-to-compile-the-kernel找到。

例如,在撰写本文时,推荐的gcc最低版本为 4.9,make的最低版本为 3.81。

为其他站点构建内核

在本书的内核构建步骤中,我们在某个系统上(这里是一个 x86_64 客户机)构建了 Linux 内核,并从同一系统上引导了新构建的内核。如果情况不是这样,比如当您为另一个站点或客户现场构建内核时,经常会发生什么?虽然始终可以在远程系统上手动放置这些部件,但有一种更简单和更正确的方法——将内核和相关的元工作(initrd镜像、内核模块集合、内核头文件等)打包成一个众所周知的软件包格式(Debian 的deb、Red Hat 的rpm等)!在内核的顶层Makefile上快速输入help命令,就会显示这些软件包目标:

$ make help
[ ... ]
Kernel packaging:
 rpm-pkg - Build both source and binary RPM kernel packages
 binrpm-pkg - Build only the binary kernel RPM package
 deb-pkg - Build both source and binary deb kernel packages
 bindeb-pkg - Build only the binary kernel deb package
 snap-pkg - Build only the binary kernel snap package (will connect to external hosts)
 tar-pkg - Build the kernel as an uncompressed tarball
 targz-pkg - Build the kernel as a gzip compressed tarball
 tarbz2-pkg - Build the kernel as a bzip2 compressed tarball
 tarxz-pkg - Build the kernel as a xz compressed tarball
[ ... ]

因此,例如,要构建内核及其关联文件作为 Debian 软件包,只需执行以下操作:

$ make -j8 bindeb-pkg
scripts/kconfig/conf --syncconfig Kconfig
sh ./scripts/package/mkdebian
dpkg-buildpackage -r"fakeroot -u" -a$(cat debian/arch) -b -nc -uc
dpkg-buildpackage: info: source package linux-5.4.0-min1
dpkg-buildpackage: info: source version 5.4.0-min1-1
dpkg-buildpackage: info: source distribution bionic
[ ... ]

实际的软件包被写入到紧挨着内核源目录的目录中。例如,从我们刚刚运行的命令中,这里是生成的deb软件包:

$ ls -l ../*.deb
-rw-r--r-- 1 kaiwan kaiwan 11106860 Feb 19 17:05 ../linux-headers-5.4.0-min1_5.4.0-min1-1_amd64.deb
-rw-r--r-- 1 kaiwan kaiwan 8206880 Feb 19 17:05 ../linux-image-5.4.0-min1_5.4.0-min1-1_amd64.deb
-rw-r--r-- 1 kaiwan kaiwan 1066996 Feb 19 17:05 ../linux-libc-dev_5.4.0-min1-1_amd64.deb

这确实非常方便!现在,你可以在任何其他匹配的(在 CPU 和 Linux 版本方面)系统上直接安装软件包,只需使用简单的dpkg -i <package-name>命令。

观看内核构建运行

在内核构建运行时查看详细信息(gcc(1)编译器标志等),将V=1详细选项开关传递给make(1)。以下是在设置为on的详细开关下构建 Raspberry Pi 3 内核时的一些示例输出:

$ make V=1 ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- zImage modules dtbs
[...]
make -f ./scripts/Makefile.build obj=kernel/sched
arm-linux-gnueabihf-gcc -Wp,-MD,kernel/sched/.core.o.d 
 -nostdinc 
 -isystem <...>/gcc-linaro-7.3.1-2018.05-x86_64_arm-linux-gnueabihf/bin/../lib/gcc/arm-linux-gnueabihf/7.3.1/include 
 -I./arch/arm/include -I./arch/arm/include/generated/uapi 
 -I./arch/arm/include/generated -I./include 
 -I./arch/arm/include/uapi -I./include/uapi 
 -I./include/generated/uapi -include ./include/linux/kconfig.h 
 -D__KERNEL__ -mlittle-endian -Wall -Wundef -Wstrict-prototypes 
 -Wno-trigraphs -fno-strict-aliasing -fno-common 
 -Werror-implicit-function-declaration -Wno-format-security 
 -std=gnu89 -fno-PIE -fno-dwarf2-cfi-asm -fno-omit-frame-pointer 
 -mapcs -mno-sched-prolog -fno-ipa-sra -mabi=aapcs-linux 
 -mno-thumb-interwork -mfpu=vfp -funwind-tables -marm 
 -D__LINUX_ARM_ARCH__=7 -march=armv7-a -msoft-float -Uarm 
 -fno-delete-null-pointer-checks -Wno-frame-address 
 -Wno-format-truncation -Wno-format-overflow 
 -Wno-int-in-bool-context -O2 --param=allow-store-data-races=0 
 -DCC_HAVE_ASM_GOTO -Wframe-larger-than=1024 -fno-stack-protector 
 -Wno-unused-but-set-variable -Wno-unused-const-variable 
 -fno-omit-frame-pointer -fno-optimize-sibling-calls 
 -fno-var-tracking-assignments -pg -Wdeclaration-after-statement 
 -Wno-pointer-sign -fno-strict-overflow -fno-stack-check 
 -fconserve-stack -Werror=implicit-int -Werror=strict-prototypes 
 -Werror=date-time -Werror=incompatible-pointer-types 
 -fno-omit-frame-pointer -DKBUILD_BASENAME='"core"' 
 -DKBUILD_MODNAME='"core"' -c -o kernel/sched/.tmp_core.o  
 kernel/sched/core.c
[...]

请注意,我们通过插入新行和突出显示一些开关,使前面的输出更加易读。这种细节可以帮助调试构建失败的情况。

构建过程的快捷 shell 语法

一个快捷的 shell(通常是 Bash)语法到构建过程(假设内核配置步骤已完成)可能是以下示例,用于非交互式构建脚本:

time make -j4 [ARCH=<...> CROSS_COMPILE=<...>] all && sudo make modules_install && sudo make install

在上面的代码中,&&||元素是 shell(Bash)的便利条件列表语法:

  • cmd1 && cmd2意味着:只有在cmd1成功运行时才运行cmd2

  • cmd1 || cmd2意味着:只有在cmd1失败时才运行cmd2

处理编译器开关问题

很久以前,2016 年 10 月,当尝试为 x86_64 构建一个(较旧的 3.x)内核时,我遇到了以下错误:

$ make
[...]
CC scripts/mod/empty.o
scripts/mod/empty.c:1:0: error: code model kernel does not support PIC mode
/* empty file to figure out endianness / word size */
[...]

事实证明,这根本不是内核问题。相反,这是 Ubuntu 16.10 上的编译器开关问题:gcc(1)默认坚持使用-fPIE(其中PIE缩写为Position Independent Executable)标志。在较旧的内核的 Makefile 中,我们需要关闭这个选项。这个问题已经解决了。

这个关于AskUbuntu网站上的 Q&A,关于Kernel doesn't support PIC mode for compiling?,描述了如何做到这一点:askubuntu.com/questions/851433/kernel-doesnt-support-pic-mode-for-compiling

(有趣的是,在前面的Watching the kernel build run部分,使用最近的内核时,构建确实使用了-fno-PIE编译器开关。)

处理缺少的 OpenSSL 开发头文件

有一次,在 Ubuntu 上的 x86_64 内核构建失败,出现了以下错误:

[...] fatal error: openssl/opensslv.h: No such file or directory

这只是缺少 OpenSSL 开发头文件的情况;这在这里的Minimal requirements to compile the kernel文档中清楚地提到:github.com/torvalds/linux/raw/master/Documentation/process/changes.rst#openssl。具体来说,它提到从 v4.3 及更高版本开始,需要openssl开发包。

FYI,这个 Q&A 也展示了如何安装openssl-devel软件包(或等效的;例如,在 Raspberry Pi 上,需要安装libssl-dev软件包)来解决这个问题:OpenSSL missing during ./configure. How to fix?,可在superuser.com/questions/371901/openssl-missing-during-configure-how-to-fix找到。

实际上,在一个纯净的 x86_64 Fedora 29发行版上也发生了完全相同的错误:

make -j4
[...]
HOSTCC scripts/sign-file
scripts/sign-file.c:25:10: fatal error: openssl/opensslv.h: No such file or directory
 #include <openssl/opensslv.h>
 ^~~~~~~~~~~~~~~~~~~~
compilation terminated.
make[1]: *** [scripts/Makefile.host:90: scripts/sign-file] Error 1
make[1]: *** Waiting for unfinished jobs....
make: *** [Makefile:1067: scripts] Error 2
make: *** Waiting for unfinished jobs....

修复方法如下:

sudo dnf install openssl-devel-1:1.1.1-3.fc29

最后,请记住一个几乎可以保证成功的方法:当你遇到那些你无法解决的构建和/或引导错误时:将确切的错误消息复制到剪贴板中,转到 Google(或其他搜索引擎),并输入类似于linux kernel build <ver ...> fails with <paste-your-error-message-here>。你可能会惊讶地发现这有多么有帮助。如果没有,要认真地进行研究,如果你真的找不到任何相关/正确的答案,就在适当的论坛上发布你的(深思熟虑的)问题。

存在几个 Linux“构建器”项目,这些项目是用于构建整个 Linux 系统或发行版的复杂框架(通常用于嵌入式 Linux 项目)。截至撰写本文时,Yoctowww.yoctoproject.org/)被认为是行业标准的 Linux 构建器项目,而Buildrootbuildroot.org/)是一个更老但得到很好支持的项目;它们确实值得一看。

总结

本章以及前一章详细介绍了如何从源代码构建 Linux 内核。我们从实际的内核(和内核模块)构建过程开始。构建完成后,我们展示了如何将内核模块安装到系统上。然后我们继续讨论了生成initramfs(或initrd)镜像的实际操作,并解释了背后的动机。内核构建的最后一步是(简单的)自定义引导加载程序(这里,我们只关注 x86 GRUB)。然后我们展示了如何通过新构建的内核引导系统,并验证其配置是否符合预期。作为一个有用的附加功能,我们还展示了如何为另一个处理器(在这种情况下是 ARM)交叉编译 Linux 内核的基础知识。最后,我们分享了一些额外的提示,以帮助你进行内核构建。

再次强调,如果你还没有这样做,我们建议你仔细审查并尝试这里提到的程序,并构建自己的定制 Linux 内核。

因此,恭喜你成功地从头开始构建了一个 Linux 内核!你可能会发现,在实际项目(或产品)中,你可能不需要像我们努力地仔细展示的那样执行内核构建过程的每一步。为什么呢?原因之一是可能会有一个单独的 BSP 团队负责这个方面;另一个原因 - 在嵌入式 Linux 项目中尤其可能,是正在使用像Yocto(或Buildroot)这样的 Linux 构建框架。Yocto 通常会处理构建的机械方面。然而,你真的需要能够根据项目要求配置内核;这仍然需要在这里获得的知识和理解。

接下来的两章将带你深入了解 Linux 内核开发世界,向你展示如何编写你的第一个内核模块。

问题

最后,这里有一些问题供你测试对本章材料的了解:github.com/PacktPublishing/Linux-Kernel-Programming/tree/master/questions。你会发现其中一些问题的答案在本书的 GitHub 存储库中:github.com/PacktPublishing/Linux-Kernel-Programming/tree/master/solutions_to_assgn

进一步阅读

为了帮助你深入学习这个主题,我们在本书的 GitHub 存储库中提供了一个相当详细的在线参考和链接列表(有时甚至包括书籍)的 Further reading 文档。Further reading文档在这里可用:github.com/PacktPublishing/Linux-Kernel-Programming/raw/master/Further_Reading.md

第四章:编写您的第一个内核模块 - LKMs 第一部分

欢迎来到您学习 Linux 内核开发的基本方面-可加载内核模块LKM)框架以及如何被模块用户模块作者使用的旅程,他通常是内核或设备驱动程序员。这个主题相当广泛,因此分为两章-这一章和下一章。

在本章中,我们将首先快速了解 Linux 内核架构的基础知识,这将帮助我们理解 LKM 框架。然后,我们将探讨为什么内核模块有用,并编写我们自己的简单的Hello, world LKM,构建并运行它。我们将看到消息是如何写入内核日志的,并理解并利用 LKM Makefile。到本章结束时,您将已经学会了 Linux 内核架构和 LKM 框架的基础知识,并应用它来编写一个简单但完整的内核代码。

在本章中,我们涵盖了以下内容:

  • 理解内核架构-第 I 部分

  • 探索 LKMs

  • 编写我们的第一个内核模块

  • 内核模块的常见操作

  • 理解内核日志和 printk

  • 理解内核模块 Makefile 的基础知识

技术要求

如果您已经仔细遵循了第一章,内核工作空间设置,随后的技术先决条件已经得到了满足。(该章还提到了各种有用的开源工具和项目;我强烈建议您至少浏览一次。)为了您的方便,我们在这里总结了一些关键点。

要在 Linux 发行版(或自定义系统)上构建和使用内核模块,至少需要安装以下两个组件:

  • 工具链:这包括编译器、汇编器、链接器/加载器、C 库和各种其他部分。如果为本地系统构建,正如我们现在假设的那样,那么任何现代 Linux 发行版都会预先安装本地工具链。如果没有,只需安装适用于您发行版的gcc软件包即可;在基于 Ubuntu 或 Debian 的 Linux 系统上,使用以下命令:
sudo apt install gcc
  • 内核头文件:这些头文件将在编译过程中使用。实际上,您安装的软件包不仅安装内核头文件,还安装其他所需的部分(例如内核 Makefile)到系统上。再次强调,任何现代 Linux 发行版都应该预先安装内核头文件。如果没有(您可以使用dpkg(1)进行检查,如下所示),只需安装适用于您发行版的软件包;在基于 Ubuntu 或 Debian 的 Linux 系统上,使用以下命令:
$ sudo apt install linux-headers-generic $ dpkg -l | grep linux-headers | awk '{print $1, $2}'
ii linux-headers-5.3.0-28
ii linux-headers-5.3.0-28-generic
ii linux-headers-5.3.0-40
ii linux-headers-5.3.0-40-generic
ii linux-headers-generic-hwe-18.04
$ 

这里,使用dpkg(1)工具的第二个命令只是用来验证linux-headers软件包是否已经安装。

在某些发行版上,此软件包可能被命名为kernel-headers-<ver#>。此外,对于直接在树莓派上进行开发,安装名为raspberrypi-kernel-headers的相关内核头文件软件包。

本书的整个源代码树可在其 GitHub 存储库中找到,网址为github.com/PacktPublishing/Linux-Kernel-Programming,本章的代码位于ch4目录下。我们期望您进行克隆:

git clone https://github.com/PacktPublishing/Linux-Kernel-Programming.git

本章的代码位于其目录名称下,chn(其中n是章节编号;所以在这里,它位于ch4/下)。

理解内核架构-第一部分

在本节中,我们开始加深对内核的理解。更具体地说,在这里,我们深入探讨了用户空间和内核空间以及构成 Linux 内核的主要子系统和各种组件。目前,这些信息在更高的抽象级别上处理,并且故意保持简洁。我们将在第六章,内核内部基础知识-进程和线程**.中更深入地了解内核的结构。

用户空间和内核空间

现代微处理器支持至少两个特权级别。以英特尔/AMD x86[-64]家族为例,支持四个特权级别(它们称之为环级),而 ARM(32 位)微处理器家族支持多达七个(ARM 称之为执行模式;其中六个是特权的,一个是非特权的)。

这里的关键点是,为了平台的安全性和稳定性,所有运行在这些处理器上的现代操作系统都将使用(至少)两个特权级别(或模式):

  • 用户空间应用程序非特权用户模式下运行

  • 内核空间内核(及其所有组件)在特权模式下运行- 内核模式

以下图显示了这种基本架构:

图 4.1-基本架构-两个特权模式

接下来是有关 Linux 系统架构的一些细节;请继续阅读。

库和系统调用 API

用户空间应用程序通常依赖于应用程序编程接口APIs)来执行它们的工作。本质上是 API 的集合或存档,允许您使用标准化、编写良好且经过充分测试的接口(并利用通常的好处:无需重新发明轮子、可移植性、标准化等)。Linux 系统有几个库;即使在企业级系统上也不少见数百个。其中,所有用户模式 Linux 应用程序(可执行文件)都会被“自动链接”到一个重要的、始终使用的库中:glibc* - GNU 标准 C 库*,正如您将会了解的那样。然而,库只能在用户模式下使用;内核没有库(在接下来的章节中会详细介绍)。

库 API 的示例是众所周知的printf(3)(回想一下,来自第一章,内核工作空间设置,可以找到此 API 的 man 页面部分),scanf(3)strcmp(3)malloc(3)free(3)

现在,一个关键点:如果用户和内核是分开的地址空间,并且处于不同的特权级别,用户进程如何能够访问内核呢?简短的答案是通过系统调用系统调用是一种特殊的 API,因为它是用户空间进程访问内核的唯一合法(同步)方式。换句话说,系统调用是进入内核空间的唯一合法入口点。它们有能力从非特权用户模式切换到特权内核模式(更多关于这一点和单片设计的内容请参阅第六章,内核内部要点-进程和线程,在进程和中断上下文部分)。系统调用的示例包括fork(2)execve(2)open(2)read(2)write(2)socket(2)accept(2)chmod(2)等。

在线查看所有库和系统调用 API 的 man 页面:

这里强调的重点是,用户应用程序和内核之间实际上只能通过系统调用进行通信;这就是接口。在本书中,我们不会深入探讨这些细节。如果您对了解更多感兴趣,请参考 Packt 出版的书籍《Linux 系统编程实践》,特别是第一章,Linux 系统架构

内核空间组件

当然,本书完全专注于内核空间。今天的 Linux 内核是一个相当庞大和复杂的东西。在内部,它由几个主要子系统和几个组件组成。对内核子系统和组件的广泛枚举得到以下列表:

  • 核心内核:这段代码处理任何现代操作系统的典型核心工作,包括(用户和内核)进程和线程的创建/销毁,CPU 调度,同步原语,信号,定时器,中断处理,命名空间,cgroups,模块支持,加密等等。

  • 内存管理(MM):这处理所有与内存相关的工作,包括设置和维护内核和进程虚拟地址空间VASes)。

  • VFS(用于文件系统支持)虚拟文件系统开关VFS)是 Linux 内核中实际文件系统的抽象层(例如,ext[2|4]vfatreiserfsntfsmsdosiso9660,JFFS2 和 UFS)的实现。

  • 块 IO:实现实际文件 I/O 的代码路径,从 VFS 直到块设备驱动程序以及其中的所有内容(实际上,相当多!),都包含在这里。

  • 网络协议栈:Linux 以其对模型各层的众所周知(和不那么众所周知)的网络协议的精确、高质量实现而闻名,TCP/IP 可能是其中最著名的。

  • 进程间通信(IPC)支持:这里实现了 IPC 机制;Linux 支持消息队列,共享内存,信号量(旧的 SysV 和新的 POSIX),以及其他 IPC 机制。

  • 声音支持:这里包含了实现音频的所有代码,从固件到驱动程序和编解码器。

  • 虚拟化支持:Linux 已经成为大大小小的云提供商的极其受欢迎的选择,一个重要原因是其高质量、低占用的虚拟化引擎,基于内核的虚拟机KVM)。

所有这些构成了主要的内核子系统;此外,我们还有这些:

  • 特定于体系结构(即特定于 CPU)的代码

  • 内核初始化

  • 安全框架

  • 许多类型的设备驱动程序

回想一下,在第二章中,从源代码构建 5.x Linux 内核 - 第一部分内核源代码树简要介绍部分给出了与主要子系统和其他组件对应的内核源代码树(代码)布局。

众所周知,Linux 内核遵循单片内核架构。基本上,单片设计是指所有内核组件(我们在本节中提到的)都存在并共享内核地址空间(或内核)。这可以清楚地在下图中看到:

图 4.2 - Linux 内核空间 - 主要子系统和块

另一个你应该知道的事实是,这些地址空间当然是虚拟地址空间,而不是物理地址空间。内核将(利用硬件,如 MMU/TLB/高速缓存)映射,在页面粒度级别,虚拟页面到物理页面帧。它通过使用内核分页表将内核虚拟页面映射到物理帧,并且对于每个存活的进程,它通过为每个进程使用单独的分页表将进程的虚拟页面映射到物理页面帧。

在第六章中,内核内部要点 - 进程和线程(以及后续章节)中,等待您更深入地了解内核和内存管理架构和内部。

现在我们对用户空间和内核空间有了基本的了解,让我们继续并开始我们的 LKM 框架之旅。

探索 LKM

简而言之,内核模块是一种提供内核级功能而不必在内核源代码树中工作的方法。

想象一种情景,你必须向 Linux 内核添加支持功能 - 也许是为了使用某个硬件外围芯片而添加一个新的设备驱动程序,一个新的文件系统,或者一个新的 I/O 调度程序。一种明显的方法是:更新内核源代码树,构建并测试新代码。

尽管这看起来很简单,实际上需要大量工作 - 我们编写的代码的每一次更改,无论多么微小,都需要我们重新构建内核映像,然后重新启动系统以进行测试。必须有一种更清洁、更简单的方法;事实上是有的 - LKM 框架

LKM 框架

LKM 框架是一种在内核源树之外编译内核代码的方法,通常被称为“树外”代码,从某种程度上使其独立于内核,然后将其插入或插入到内核内存中,使其运行并执行其工作,然后将其(或拔出)从内核内存中移除。

内核模块的源代码通常由一个或多个 C 源文件、头文件和一个 Makefile 组成,通过make(1)构建成一个内核模块。内核模块本身只是一个二进制对象文件,而不是一个二进制可执行文件。在 Linux 2.4 及更早版本中,内核模块的文件名带有.o后缀;在现代的 2.6 Linux 及更高版本中,它的后缀是.kokernel object)。构建完成后,你可以将这个.ko文件 - 内核模块 - 插入到运行时的内核中,有效地使其成为内核的一部分。

请注意,并非所有内核功能都可以通过 LKM 框架提供。一些核心功能,如核心 CPU 调度器代码、内存管理、信号、定时器、中断管理代码路径等,只能在内核内部开发。同样,内核模块只允许访问完整内核 API 的子集;稍后会详细介绍。

你可能会问:我如何插入一个对象到内核中?让我们简单点 - 答案是:通过insmod(8)实用程序。现在,让我们跳过细节(这些将在即将到来的运行内核模块部分中解释)。以下图提供了首先构建,然后将内核模块插入内核内存的概述:

图 4.3 - 构建然后将内核模块插入内核内存

不用担心:内核模块的 C 源代码以及其 Makefile 的实际代码将在接下来的部分中详细介绍;现在,我们只想获得概念上的理解。

内核模块被加载到内核内存中,并驻留在内核 VAS(图 4.3的下半部分)中,由内核为其分配的空间中。毫无疑问,它是内核代码,并以内核特权运行。这样,你作为内核(或驱动程序)开发人员就不必每次都重新配置、重建和重新启动系统。你只需要编辑内核模块的代码,重新构建它,从内存中删除旧版本(如果存在),然后插入新版本。这样可以节省时间,提高生产效率。

内核模块有利的一个原因是它们适用于动态产品配置。例如,内核模块可以设计为在不同的价格点提供不同的功能;为嵌入式产品生成最终图像的脚本可以根据客户愿意支付的价格安装一组特定的内核模块。以下是另一个示例,说明了这项技术在调试或故障排除场景中的应用:内核模块可以用于在现有产品上动态生成诊断和调试日志。诸如 kprobes 之类的技术正是允许这样做的。

实际上,LKM 框架通过允许我们向内核内存中插入和移除实时代码的方式,为我们提供了一种动态扩展内核功能的手段。这种根据我们的意愿插入和拔出内核功能的能力使我们意识到 Linux 内核不仅是纯粹的单片式,它也是模块化的。

内核源树中的内核模块

事实上,内核模块对象对我们来说并不陌生。在第三章,从源代码构建 5.x Linux 内核-第二部分,我们在内核构建过程中构建了内核模块并将其安装。

请记住,这些内核模块是内核源代码的一部分,并且通过在 tristate 内核 menuconfig 提示中选择M来配置为模块。它们被安装在/lib/modules/$(uname -r)/目录下。因此,要查看一下我们当前运行的 Ubuntu 18.04.3 LTS 客户机内核下安装的内核模块,我们可以这样做:

$ lsb_release -a 2>/dev/null |grep Description
Description:    Ubuntu 18.04.3 LTS
$ uname -r
5.0.0-36-generic
$ find /lib/modules/$(uname -r)/ -name "*.ko" | wc -l
5359

好吧,Canonical 和其他地方的人很忙!超过五千个内核模块...想想看-这是有道理的:发行商无法预先知道用户最终会使用什么硬件外围设备(特别是在像 x86 架构系统这样的通用计算机上)。内核模块作为一种方便的手段,可以支持大量硬件而不会使内核镜像文件(例如bzImagezImage)变得非常臃肿。

我们 Ubuntu Linux 系统中安装的内核模块位于/lib/modules/$(uname -r)/kernel目录中,如下所示:

$ ls /lib/modules/5.0.0-36-generic/kernel/
arch/  block/  crypto/  drivers/  fs/  kernel/  lib/  mm/  net/  samples/  sound/  spl/  ubuntu/  virt/  zfs/
$ ls /lib/modules/5.4.0-llkd01/kernel/
arch/  crypto/  drivers/  fs/  net/  sound/
$ 

在这里,查看/lib/modules/$(uname -r)下的发行版内核(Ubuntu 18.04.3 LTS 运行5.0.0-36-generic内核)的kernel/目录的顶层,我们可以看到有许多子文件夹和成千上万的内核模块。相比之下,对于我们构建的内核(有关详细信息,请参阅第二章,从源代码构建 5.x Linux 内核-第一部分,和第三章,从源代码构建 5.x Linux 内核-第二部分),数量要少得多。您会回忆起我们在第二章中的讨论,从源代码构建 5.x Linux 内核-第一部分,我们故意使用了localmodconfig目标来保持构建的小巧和快速。因此,在这里,我们定制的 5.4.0 内核只构建了大约 60 个内核模块。

设备驱动程序是一个经常使用内核模块的领域。例如,让我们看一个作为内核模块架构的网络设备驱动程序。您可以在发行版内核的kernel/drivers/net/ethernet文件夹下找到几个(还有一些熟悉的品牌!):

图 4.4-我们发行版内核的以太网网络驱动程序(内核模块)的内容

许多基于 Intel 的笔记本电脑上都使用 Intel 1GbE 网络接口卡NIC)以太网适配器。驱动它的网络设备驱动程序称为e1000驱动程序。我们的 x86-64 Ubuntu 18.04.3 客户机(在 x86-64 主机笔记本电脑上运行)显示它确实使用了这个驱动程序:

$ lsmod | grep e1000
e1000                 139264  0

我们很快将更详细地介绍lsmod(8)('列出模块')实用程序。对我们来说更重要的是,我们可以看到它是一个内核模块!如何获取有关这个特定内核模块的更多信息?通过利用modinfo(8)实用程序很容易实现(为了可读性,我们在这里截断了它的详细输出):

$ ls -l /lib/modules/5.0.0-36-generic/kernel/drivers/net/ethernet/intel/e1000
total 220
-rw-r--r-- 1 root root 221729 Nov 12 16:16 e1000.ko
$ modinfo /lib/modules/5.0.0-36-generic/kernel/drivers/net/ethernet/intel/e1000/e1000.ko
filename:       /lib/modules/5.0.0-36-generic/kernel/drivers/net/ethernet/intel/e1000/e1000.ko
version:        7.3.21-k8-NAPI
license:        GPL v2
description:    Intel(R) PRO/1000 Network Driver
author:         Intel Corporation, <linux.nics@intel.com>
srcversion:     C521B82214E3F5A010A9383
alias:          pci:v00008086d00002E6Esv*sd*bc*sc*i*
[...]
name:           e1000
vermagic:       5.0.0-36-generic SMP mod_unload 
[...]
parm:           copybreak:Maximum size of packet that is copied to a new 
                buffer on receive (uint)
parm:           debug:Debug level (0=none,...,16=all) (int)
$  

modinfo(8)实用程序允许我们查看内核模块的二进制图像并提取有关它的一些详细信息;有关使用modinfo的更多信息将在下一节中介绍。

另一种获取系统有用信息的方法,包括有关当前加载的内核模块的信息,是通过systool(1)实用程序。对于已安装的内核模块(有关在下一章中自动加载系统引导时安装内核模块的详细信息),执行systool -m <module-name> -v可以显示有关它的信息。查阅systool(1)手册页以获取使用详细信息。

最重要的是,内核模块已成为构建和分发某些类型的内核组件的实用方法,设备驱动程序是它们最常见的用例。其他用途包括但不限于文件系统、网络防火墙、数据包嗅探器和自定义内核代码。

因此,如果您想学习如何编写 Linux 设备驱动程序、文件系统或防火墙,您必须首先学习如何编写内核模块,从而利用内核强大的 LKM 框架。这正是我们接下来要做的事情。

编写我们的第一个内核模块

在引入新的编程语言或主题时,模仿原始的K&R Hello, world程序作为第一段代码已经成为一种被广泛接受的计算机编程传统。我很高兴遵循这一受尊敬的传统来介绍强大的 LKM 框架。在本节中,您将学习编写简单 LKM 的步骤。我们会详细解释代码。

介绍我们的 Hello, world LKM C 代码

话不多说,这里是一些简单的Hello, world C 代码,实现了遵循 Linux 内核的 LKM 框架:

出于可读性和空间限制的原因,这里只显示了源代码的关键部分。要查看完整的源代码,构建并运行它,本书的整个源树都可以在 GitHub 仓库中找到:github.com/PacktPublishing/Linux-Kernel-Programming。我们期望您能够克隆它:

git clone https://github.com/PacktPublishing/Linux-Kernel-Programming.git

// ch4/helloworld_lkm/hellowworld_lkm.c
#include <linux/init.h>
#include <linux/kernel.h>
#include <linux/module.h>

MODULE_AUTHOR("<insert your name here>");
MODULE_DESCRIPTION("LLKD book:ch4/helloworld_lkm: hello, world, our first LKM");
MODULE_LICENSE("Dual MIT/GPL");
MODULE_VERSION("0.1");

static int __init helloworld_lkm_init(void)
{
    printk(KERN_INFO "Hello, world\n");
    return 0;     /* success */
}

static void __exit helloworld_lkm_exit(void)
{
    printk(KERN_INFO "Goodbye, world\n");
}

module_init(helloworld_lkm_init);
module_exit(helloworld_lkm_exit);

您可以立即尝试这个简单的Hello, world内核模块!只需像这里显示的那样cd到正确的源目录,并获取我们的辅助lkm脚本来构建和运行它:

$ cd <...>/ch4/helloworld_lkm
$ ../../lkm helloworld_lkm
Version info:
Distro:     Ubuntu 18.04.3 LTS
Kernel: 5.0.0-36-generic
[...]
dmesg[ 5399.230367] Hello, world
$ 

如何为什么很快会有详细的解释。尽管代码很小,但我们的第一个内核模块需要仔细阅读和理解。请继续阅读。

分解

以下小节解释了前面Hello, world C 代码的几乎每一行。请记住,尽管程序看起来非常小和琐碎,但对于它和周围的 LKM 框架,有很多需要理解的地方。本章的其余部分将重点介绍这一点,并进行详细讨论。我强烈建议您花时间阅读和理解这些基础知识。这将在以后可能出现的难以调试的情况下对您有很大帮助。

内核头文件

我们使用#include包含了一些头文件。与用户空间的'C'应用程序开发不同,这些是内核头文件(如技术要求部分所述)。请回顾第三章,从源代码构建 5.x Linux 内核 - 第二部分,内核模块安装在特定的根可写分支下。让我们再次检查一下(这里,我们正在运行我们的客户 x86_64 Ubuntu VM,使用的是 5.0.0-36-generic 发行版内核)。

$ ls -l /lib/modules/$(uname -r)/
total 5552
lrwxrwxrwx  1 root root      39 Nov 12 16:16 build -> /usr/src/linux-headers-5.0.0-36-generic/
drwxr-xr-x  2 root root    4096 Nov 28 08:49 initrd/
[...]

请注意名为build的符号链接或软链接。它指向系统上内核头文件的位置。在前面的代码中,它位于/usr/src/linux-headers-5.0.0-36-generic/下!正如您将看到的,我们将向用于构建内核模块的 Makefile 提供这些信息。(此外,一些系统有一个名为source的类似软链接)。

kernel-headerslinux-headers软件包将有限的内核源树解压到系统上,通常位于/usr/src/...下。然而,这段代码并不完整,因此我们使用了短语有限源树。这是因为构建模块并不需要完整的内核源树 - 只需要打包和提取所需的组件(头文件,Makefile 等)。

我们的Hello, world内核模块中的第一行代码是#include <linux/init.h>

编译器通过在/lib/modules/$(uname -r)/build/include/下搜索先前提到的内核头文件来解决这个问题。因此,通过跟随build软链接,我们可以看到它最终拾取了这个头文件:

$ ls -l /usr/src/linux-headers-5.0.0-36-generic/include/linux/init.h
-rw-r--r-- 1 root root 9704 Mar  4  2019 /usr/src/linux-headers-5.0.0-36-generic/include/linux/init.h

其他包含在内核模块源代码中的内核头文件也是如此。

模块宏

接下来,我们有一些形式为MODULE_FOO()的模块宏;大多数都很直观:

  • MODULE_AUTHOR(): 指定内核模块的作者

  • MODULE_DESCRIPTION(): 简要描述此 LKM 的功能

  • MODULE_LICENSE(): 指定内核模块发布的许可证

  • MODULE_VERSION(): 指定内核模块的(本地)版本

在没有源代码的情况下,如何将这些信息传达给最终用户(或客户)?啊,modinfo(8)实用程序正是这样做的!这些宏及其信息可能看起来微不足道,但在项目和产品中非常重要。例如,供应商通过在所有已安装的内核模块上使用grepmodinfo输出来确定代码正在运行的(开源)许可证。

入口和出口点

永远不要忘记,内核模块毕竟是以内核特权运行的内核代码。它不是一个应用程序,因此没有像我们熟悉和喜爱的main()函数那样的入口点。这当然引出了一个问题:内核模块的入口和出口点是什么?请注意,在我们简单的内核模块底部,以下行:

module_init(helloworld_lkm_init);
module_exit(helloworld_lkm_exit);

module_[init|exit]()代码是分别指定入口和出口点的宏。每个参数都是一个函数指针。使用现代 C 编译器,我们可以只指定函数的名称。因此,在我们的代码中,以下内容适用:

  • helloworld_lkm_init()函数是入口点。

  • helloworld_lkm_exit()函数是出口点。

这些入口和出口点几乎可以被认为是内核模块的构造函数/析构函数对。从技术上讲,当然不是这样,因为这不是面向对象的 C++代码,而是普通的 C。尽管如此,这是一个有用的类比。

返回值

注意initexit函数的签名如下:

static int  __init <modulename>_init(void);
static void __exit <modulename>_exit(void);

作为良好的编码实践,我们已经使用了函数的命名格式<modulename>__[init|exit](),其中<modulename>被替换为内核模块的名称。您会意识到这种命名约定只是这样 - 从技术上讲是不必要的,但它是直观的,因此有帮助。显然,这两个例程都不接收任何参数。

将这两个函数标记为static限定符意味着它们对这个内核模块是私有的。这正是我们想要的。

现在让我们继续讨论内核模块的init函数返回值所遵循的重要约定。

0/-E 返回约定

内核模块的init函数要返回一个类型为int的值;这是一个关键方面。Linux 内核已经形成了一种风格或约定,如果你愿意的话,关于从中返回值的方式(从内核空间到用户空间进程)。LKM 框架遵循了俗称的0/-E约定:

  • 成功时,返回整数值0

  • 失败时,返回用户空间全局未初始化整数errno的负值。

请注意,errno是一个全局变量,驻留在用户进程 VAS 中的未初始化数据段中。除了很少的例外情况,每当 Linux 系统调用失败时,都会返回-1,并且errno被设置为一个正值,表示失败代码;这项工作是由glibcsyscall返回路径上的“粘合”代码完成的。

此外,errno值实际上是全局英文错误消息表的索引(const char * const sys_errlist[]);这就是perror(3)strerror_r等函数如何打印出失败诊断信息的真正原因。

顺便说一句,您可以从这些(内核源树)头文件中查找可用的错误代码完整列表include/uapi/asm-generic/errno-base.hinclude/uapi/asm-generic/errno.h

一个快速的例子可以帮助我们清楚地了解如何从内核模块的init函数返回:假设我们的内核模块的init函数正在尝试动态分配一些内核内存(有关kmalloc()API 等的详细信息将在以后的章节中介绍;现在请忽略它)。然后,我们可以这样编写代码:

[...]
ptr = kmalloc(87, GFP_KERNEL);
if (!ptr) {
    pr_warning("%s:%s:%d: kmalloc failed!\n", __FILE__, __func__, __LINE__);
    return -ENOMEM;
}
[...]
return 0;   /* success */

如果内存分配失败(很少见,但嘿,这是可能的!),我们会执行以下操作:

  1. 首先,我们发出一个警告printk。实际上,在这种特殊情况下——"内存不足"——这是迂腐和不必要的。如果内核空间内存分配失败,内核肯定会发出足够的诊断信息!请参阅此链接以获取更多详细信息:lkml.org/lkml/2014/6/10/382;我们之所以在这里这样做,只是因为讨论刚开始,为了读者的连贯性。

  2. 返回-ENOMEM值:

  • 在用户空间返回此值的层实际上是glibc;它有一些"粘合"代码,将此值乘以-1并将全局整数errno设置为它。

  • 现在,[f]init_module(2)系统调用将返回-1,表示失败(这是因为insmod(8)实际上调用了这个系统调用,您很快就会看到)。

  • errno将被设置为ENOMEM,反映了由于内存分配失败而导致内核模块插入失败的事实。

相反,框架期望init函数在成功时返回值0。实际上,在旧的内核版本中,如果在成功时未返回0,内核模块将被突然从内核内存中卸载。如今,内核不会卸载内核模块,但会发出警告消息,指出已返回一个可疑的非零值。

清理例程没有太多可说的。它不接收任何参数,也不返回任何内容(void)。它的工作是在内核模块从内核内存中卸载之前执行所有必需的清理。

在您的内核模块中包括module_exit()宏将使其不可能卸载(当然,除非系统关闭或重新启动)。有趣...(我建议您尝试这个小练习!)。

当然,事情永远不会那么简单:只有在内核构建时将CONFIG_MODULE_FORCE_UNLOAD标志设置为Disabled(默认情况下)时,才能保证这种阻止卸载的行为。

ERR_PTR 和 PTR_ERR 宏

在返回值的讨论中,您现在了解到内核模块的init例程必须返回一个整数。如果您希望返回一个指针呢?ERR_PTR()内联函数来帮助我们,允许我们返回一个指针,只需将其强制转换为void *即可。事实上,情况会更好:您可以使用IS_ERR()内联函数来检查错误(它实际上只是确定值是否在[-1 到-4095]范围内),通过ERR_PTR()内联函数将负错误值编码为指针,并使用相反的例程PTR_ERR()从指针中检索此值。

作为一个简单的例子,看看这里给出的被调用者代码。这次,我们的(示例)函数myfunc()返回一个指针(指向一个名为mystruct的结构),而不是一个整数:

struct mystruct * myfunc(void)
{
    struct mystruct *mys = NULL;
    mys = kzalloc(sizeof(struct mystruct), GFP_KERNEL);
    if (!mys)
        return ERR_PTR(-ENOMEM);
    [...]
    return mys;
}

调用者代码如下:

[...]
gmys = myfunc();
if (IS_ERR(gmys)) {
    pr_warn("%s: myfunc alloc failed, aborting...\n", OURMODNAME);
    stat = PTR_ERR(gmys); /* sets 'stat' to the value -ENOMEM */
    goto out_fail_1;
}
[...]
return stat;
out_fail_1:
    return stat;
}

顺便说一句,内联函数ERR_PTR()PTR_ERR()IS_ERR()都在(内核头文件)include/linux/err.h文件中。内核文档(kernel.readthedocs.io/en/sphinx-samples/kernel-hacking.html#return-conventions)讨论了内核函数的返回约定。此外,你可以在内核源代码树中的crypto/api-samples代码下找到这些函数的用法示例:www.kernel.org/doc/html/v4.17/crypto/api-samples.html

__init 和 __exit 关键字

一个微小的遗留问题:在前面的函数签名中我们看到的__init__exit宏到底是什么?这些只是链接器插入的内存优化属性。

__init宏为代码定义了一个init.text部分。同样,任何声明了__initdata属性的数据都会进入init.data部分。这里的重点是init函数中的代码和数据在初始化期间只使用一次。一旦被调用,它就再也不会被调用;所以一旦被调用,它就会被释放掉(通过free_initmem())。

__exit宏的情况类似,当然,这只对内核模块有意义。一旦调用cleanup函数,所有内存都会被释放。如果代码是静态内核映像的一部分(或者模块支持被禁用),这个宏就没有效果了。

好了,但到目前为止,我们还没有解释一些实际问题:你到底如何将内核模块对象加载到内核内存中,让它执行,然后卸载它,以及你可能希望执行的其他一些操作。让我们在下一节讨论这些问题。

内核模块的常见操作

现在让我们深入讨论一下你到底如何构建、加载和卸载内核模块。除此之外,我们还将介绍关于非常有用的printk()内核 API、使用lsmod(8)列出当前加载的内核模块的基础知识,以及用于在内核模块开发过程中自动执行一些常见任务的便利脚本。所以,让我们开始吧!

构建内核模块

我们强烈建议你尝试一下我们简单的Hello, world内核模块练习(如果你还没有这样做的话)!为此,我们假设你已经克隆了本书的 GitHub 存储库(github.com/PacktPublishing/Linux-Kernel-Programming)。如果还没有,请现在克隆(参考技术要求部分获取详细信息)。

在这里,我们逐步展示了你到底如何构建并将我们的第一个内核模块插入到内核内存中。再次提醒一下:我们在运行 Ubuntu 18.04.3 LTS 发行版的 x86-64 Linux 虚拟机(在 Oracle VirtualBox 6.1 下)上执行了这些步骤。

  1. 切换到本书源代码章节目录和子目录。我们的第一个内核模块位于自己的文件夹中(应该是这样!)叫做helloworld_lkm
 cd <book-code-dir>/ch4/helloworld_lkm

<book-code-dir>当然是你克隆了本书的 GitHub 存储库的文件夹;在这里(见截图,图 4.5),你可以看到它是/home/llkd/book_llkd/Linux-Kernel-Programming/

  1. 现在验证代码库:
$ pwd
*<book-code-dir>*/ch4/helloworld_lkm
$ ls -l
total 8
-rw-rw-r-- 1 llkd llkd 1211 Jan 24 13:01 helloworld_lkm.c
-rw-rw-r-- 1 llkd llkd  333 Jan 24 13:01 Makefile
$ 
  1. 使用make进行构建:

图 4.5 - 列出并构建我们的第一个Hello, world内核模块

前面的截图显示内核模块已经成功构建。它是./helloworld_lkm.ko文件。(另外,注意我们是从我们之前章节中构建的自定义 5.4.0 内核引导的。)

运行内核模块

为了让内核模块运行,你需要首先将它加载到内核内存空间中。这被称为将模块插入到内核内存中。

将内核模块放入 Linux 内核段可以通过几种方式完成,最终都归结为调用[f]init_module(2)系统调用之一。为了方便起见,存在几个包装实用程序将这样做(或者您总是可以编写一个)。我们将在下面使用流行的insmod(8)(读作“insert module”)实用程序;insmod的参数是要插入的内核模块的路径名:

$ insmod ./helloworld_lkm.ko 
insmod: ERROR: could not insert module ./helloworld_lkm.ko: Operation not permitted
$ 

它失败了!实际上,失败的原因应该是非常明显的。想一想:将代码插入内核在很大程度上甚至优于在系统上成为root(超级用户)- 再次提醒您:它是内核代码,并且将以内核特权运行。如果任何用户都被允许插入或删除内核模块,黑客将有一天的乐趣!部署恶意代码将变得相当简单。因此,出于安全原因,只有具有 root 访问权限才能插入或删除内核模块

从技术上讲,作为root意味着进程(或线程)的真实和/或有效 UIDRUID/EUID)值是特殊值。不仅如此,而且现代内核通过现代和优越的 POSIX Capabilities 模型“看到”线程具有某些capabilities;只有具有CAP_SYS_MODULE能力的进程/线程才能(卸载)加载内核模块。我们建议读者查看capabilities(7)的手册页以获取更多详细信息。

所以,让我们再次尝试将我们的内核模块插入内存,这次使用sudo(8)root权限:

$ sudo insmod ./helloworld_lkm.ko
[sudo] password for llkd: 
$ echo $?
0

现在可以了!正如前面提到的,insmod(8)实用程序通过调用[f]init_module(2)系统调用来工作。insmod(8)实用程序(实际上是内部的[f]init_module(2)系统调用)失败的情况是什么时候?

有一些情况:

  • 权限:未以 root 身份运行或缺少CAP_SYS_MODULE能力(errno <- EPERM)。

  • proc文件系统中的内核可调参数,/proc/sys/kernel/modules_disabled,被设置为1(默认为0)。

  • 具有相同名称的内核模块已经在内核内存中(errno <- EEXISTS)。

好的,一切看起来都很好。$?的结果为0意味着上一个 shell 命令成功了。这很好,但是我们的Hello, world消息在哪里?继续阅读!

快速查看内核 printk()

为了发出消息,用户空间的 C 开发人员通常会使用可靠的printf(3) glibc API(或者在编写 C++代码时可能会使用cout)。但是,重要的是要理解,在内核空间中,没有库。因此,我们无法访问老式的printf() API*。相反,它在内核中基本上被重新实现为printk()内核 API(想知道它的代码在哪里吗?它在内核源树中的这里:kernel/printk/printk.c:printk())。

通过printk() API 发出消息非常简单,并且与使用printf(3)非常相似。在我们简单的内核模块中,这就是发生操作的地方:

printk(KERN_INFO "Hello, world\n");

虽然乍一看与printf非常相似,但printk实际上是非常不同的。在相似之处,API 接收一个格式字符串作为其参数。格式字符串几乎与printf的格式字符串完全相同。

但相似之处就到此为止。printfprintk之间的关键区别在于:用户空间的printf(3)库 API 通过根据请求格式化文本字符串并调用write(2)系统调用来工作,而后者实际上执行对stdout 设备的写入,默认情况下是终端窗口(或控制台设备)。内核printk API 也根据请求格式化其文本字符串,但其输出 目的地不同。它至少写入一个地方-以下列表中的第一个-可能还会写入几个地方:

  • RAM 中的内核日志缓冲区(易失性)

  • 一个日志文件,内核日志文件(非易失性)

  • 控制台设备

现在,我们将跳过关于printk工作原理的内部细节。另外,请忽略printk API 中的KERN_INFO标记;我们很快会涵盖所有这些内容。

当您通过printk发出消息时,可以保证输出进入内核内存(RAM)中的日志缓冲区。这实际上构成了内核日志。重要的是要注意,在图形模式下使用 X 服务器进程运行时(在典型的 Linux 发行版上工作时的默认环境),您永远不会直接看到printk输出。因此,这里显而易见的问题是:您如何查看内核日志缓冲区内容?有几种方法。现在,让我们简单快速地使用一种方法。

使用dmesg(1)实用程序!默认情况下,dmesg将将整个内核日志缓冲区内容转储到标准输出。在这里,我们使用它查找内核日志缓冲区的最后两行:

$ dmesg | tail -n2
[ 2912.880797] hello: loading out-of-tree module taints kernel.
[ 2912.881098] Hello, world
$ 

终于找到了:我们的Hello, world消息!

现在可以简单地忽略loading out-of-tree module taints kernel.的消息。出于安全原因,大多数现代 Linux 发行版将内核标记为污染(字面上是"污染"或"污染")如果插入了第三方"out-of-tree"(或非签名)内核模块。 (嗯,这实际上更像是伪法律掩盖,类似于:“如果从这一点开始出了问题,我们不负责任等等...”;你懂的)。

为了有点变化,这里是我们在运行 5.4 Linux LTS 内核的 x86-64 CentOS 8 虚拟机上插入和移除Hello, world内核模块的屏幕截图(详细信息如下):

图 4.6 - 屏幕截图显示我们在 CentOS 8 x86-64 虚拟机上使用Hello, world内核模块

在由dmesg(1)实用程序显示的内核日志中,最左边的列中的数字是一个简单的时间戳,格式为[秒.微秒],表示自系统启动以来经过的时间(尽管不建议将其视为完全准确)。顺便说一句,这个时间戳是一个Kconfig变量 - 一个内核配置选项 - 名为CONFIG_PRINTK_TIME;它可以被printk.time内核参数覆盖。

列出活动的内核模块

回到我们的内核模块:到目前为止,我们已经构建了它,将它加载到内核中,并验证了它的入口点helloworld_lkm_init()函数被调用,从而执行了printk API。那么,它现在做什么?嗯,实际上什么都不做;内核模块只是(愉快地?)坐在内核内存中什么都不做。实际上,我们可以很容易地使用lsmod(8)实用程序查找它。

$ lsmod | head
Module                  Size  Used by
helloworld_lkm         16384  0
isofs                  32768  0
fuse                  139264  3
tun                    57344  0
[...]
e1000                 155648  0
dm_mirror              28672  0
dm_region_hash         20480  1 dm_mirror
dm_log                 20480  2 dm_region_hash,dm_mirror
dm_mod                151552  11 dm_log,dm_mirror
$

lsmod显示当前驻留在内核内存中(或活动)的所有内核模块,按时间顺序排列。它的输出是列格式化的,有三列和一个可选的第四列。让我们分别看看每一列:

  • 第一列显示内核模块的名称

  • 第二列是内核中占用的(静态)大小(以字节为单位)。

  • 第三列是模块的使用计数

  • 可选的第四列(以及可能随后的更多内容)将在下一章中解释(在理解模块堆叠部分)。另外,在最近的 x86-64 Linux 内核上,似乎至少需要 16 KB 的内核内存来存储一个内核模块。

所以,很好:到目前为止,您已经成功构建、加载并运行了您的第一个内核模块到内核内存中,并且基本上可以工作:接下来呢?嗯,实际上并没有太多!我们只是在下一节学习如何卸载它。当然还有更多要学的...继续吧!

从内核内存中卸载模块

要卸载内核模块,我们使用方便的实用程序rmmod(8)删除模块):

$ rmmod 
rmmod: ERROR: missing module name.
$ rmmod helloworld_lkm
rmmod: ERROR: could not remove 'helloworld_lkm': Operation not permitted
rmmod: ERROR: could not remove module helloworld_lkm: Operation not permitted
$ sudo rmmod helloworld_lkm
[sudo] password for llkd: 
$ dmesg |tail -n2
[ 2912.881098] Hello, world
[ 5551.863410] Goodbye, world
$

rmmod(8) 的参数是内核模块的名称(如 lsmod(8) 的第一列中所示),而不是路径名。显然,就像 insmod(8) 一样,我们需要以 root 用户身份运行 rmmod(8) 实用程序才能成功。

在这里,我们还可以看到,由于我们的 rmmod,内核模块的退出例程(或 "析构函数")helloworld_lkm_exit() 函数被调用。它反过来调用了 printk,发出了 Goodbye, world 消息(我们用 dmesg 查找到)。

rmmod(请注意,在内部,它变成了 delete_module(2) 系统调用)失败 的情况是什么时候?以下是一些情况:

  • 权限:如果不以 root 用户身份运行,或者缺少 CAP_SYS_MODULE 能力(errno <- EPERM)。

  • 如果另一个模块正在使用内核模块的代码和/或数据(如果存在依赖关系;这在下一章的 模块堆叠 部分中有详细介绍),或者模块当前正在被进程(或线程)使用,则模块使用计数将为正,并且 rmmod 将失败(errno <- EBUSY)。

  • 内核模块没有使用 module_exit() 宏指定退出例程(或析构函数) CONFIG_MODULE_FORCE_UNLOAD 内核配置选项被禁用。

与模块管理相关的几个便利实用程序只是指向单个 kmod(8) 实用程序的符号(软)链接(类似于流行的 busybox 实用程序所做的)。这些包装器是 lsmod(8), rmmod(8), insmod(8), modinfo(8), modprobe(8), 和 depmod(8)。让我们看看其中的一些:

$ ls -l $(which insmod) ; ls -l $(which lsmod) ; ls -l $(which rmmod)
lrwxrwxrwx 1 root root 9 Oct 24 04:50 /sbin/insmod -> /bin/kmod
lrwxrwxrwx 1 root root 9 Oct 24 04:50 /sbin/lsmod -> /bin/kmod
lrwxrwxrwx 1 root root 9 Oct 24 04:50 /sbin/rmmod -> /bin/kmod
$ 

请注意,这些实用程序的确切位置(/bin/sbin/usr/sbin)可能会随着发行版的不同而有所变化。

我们的 lkm 便利脚本

让我们用一个名为 lkm 的简单而有用的自定义 Bash 脚本来结束这个 第一个内核模块 的讨论,它可以通过自动化内核模块的构建、加载、dmesg 和卸载工作流程来帮助你。这是它的内容(完整的代码在书籍源代码树的根目录中):

#!/bin/bash
# lkm : a silly kernel module dev - build, load, unload - helper wrapper script
[...]
unset ARCH
unset CROSS_COMPILE
name=$(basename "${0}")

# Display and run the provided command.
# Parameter(s) : the command to run
runcmd()
{
    local SEP="------------------------------"
    [ $# -eq 0 ] && return
    echo "${SEP}
$*
${SEP}"
    eval "$@"
    [ $? -ne 0 ] && echo " ^--[FAILED]"
}

### "main" here
[ $# -ne 1 ] && {
  echo "Usage: ${name} name-of-kernel-module-file (without the .c)"
  exit 1
}
[[ "${1}" = *"."* ]] && {
  echo "Usage: ${name} name-of-kernel-module-file ONLY (do NOT put any extension)."
  exit 1
}
echo "Version info:"
which lsb_release >/dev/null 2>&1 && {
  echo -n "Distro: "
  lsb_release -a 2>/dev/null |grep "Description" |awk -F':' '{print $2}'
}
echo -n "Kernel: " ; uname -r
runcmd "sudo rmmod $1 2> /dev/null"
runcmd "make clean"
runcmd "sudo dmesg -c > /dev/null"
runcmd "make || exit 1"
[ ! -f "$1".ko ] && {
  echo "[!] ${name}: $1.ko has not been built, aborting..."
  exit 1
}
runcmd "sudo insmod ./$1.ko && lsmod|grep $1"
runcmd dmesg
exit 0

给定内核模块的名称作为参数 - 没有任何扩展部分(例如 .c)- lkm 脚本执行一些有效性检查,显示一些版本信息,然后使用包装器 runcmd() bash 函数来显示并运行给定命令的名称,从而轻松完成 clean/build/load/lsmod/dmesg 工作流程。让我们在我们的第一个内核模块上试一试:

$ pwd
<...>/ch4/helloworld_lkm
$ ../../lkm
Usage: lkm name-of-kernel-module-file (without the .c)
$ ../../lkm helloworld_lkm
Version info:
Distro:          Ubuntu 18.04.3 LTS
Kernel: 5.0.0-36-generic
------------------------------
sudo rmmod helloworld_lkm 2> /dev/null
------------------------------
[sudo] password for llkd: 
------------------------------
sudo dmesg -C
------------------------------
------------------------------
make || exit 1
------------------------------
make -C /lib/modules/5.0.0-36-generic/build/ M=/home/llkd/book_llkd/Learn-Linux-Kernel-Development/ch4/helloworld_lkm modules
make[1]: Entering directory '/usr/src/linux-headers-5.0.0-36-generic'
  CC [M]  /home/llkd/book_llkd/Learn-Linux-Kernel-Development/ch4/helloworld_lkm/helloworld_lkm.o
  Building modules, stage 2.
  MODPOST 1 modules
  CC      /home/llkd/book_llkd/Learn-Linux-Kernel-Development/ch4/helloworld_lkm/helloworld_lkm.mod.o
  LD [M]  /home/llkd/book_llkd/Learn-Linux-Kernel-Development/ch4/helloworld_lkm/helloworld_lkm.ko
make[1]: Leaving directory '/usr/src/linux-headers-5.0.0-36-generic'
------------------------------
sudo insmod ./helloworld_lkm.ko && lsmod|grep helloworld_lkm
------------------------------
helloworld_lkm         16384  0
------------------------------
dmesg
------------------------------
[ 8132.596795] Hello, world
$ 

全部完成!记得使用 rmmod(8) 卸载内核模块。

恭喜!你现在已经学会了如何编写并尝试一个简单的 Hello, world 内核模块。不过,在你休息之前,还有很多工作要做;下一节将更详细地探讨有关内核日志记录和多功能 printk API 的关键细节。

理解内核日志和 printk

关于通过 printk 内核 API 记录内核消息仍有很多内容需要涵盖。本节深入探讨了一些细节。对于像你这样的新手内核开发人员来说,清楚地理解这些内容非常重要。

在本节中,我们将更详细地探讨内核日志记录。我们将了解到 printk 输出是如何处理的,以及其利弊。我们将讨论 printk 日志级别,现代系统如何通过 systemd 日志记录消息,以及如何将输出定向到控制台设备。我们将以限制 printk 和用户生成的打印输出,从用户空间生成 printk,并标准化 printk 输出格式的注意来结束本讨论。

我们之前在 快速查看内核 printk 部分看到了使用内核 printk API 功能的基本知识。在这里,我们将更详细地探讨关于 printk() API 的使用。在我们简单的内核模块中,这是发出 "Hello, world" 消息的代码行:

printk(KERN_INFO "Hello, world\n");

再次强调,printkprintf类似,都涉及格式字符串以及其工作原理 - 但相似之处就到此为止。值得强调的是,printf(3)是一个用户空间库API,通过调用write(2)系统调用来工作,该系统调用写入stdout 设备,默认情况下通常是终端窗口(或控制台设备)。而printk是一个内核空间API,其输出实际上会被发送到至少一个位置,如下列表中所示的第一个位置,可能还会发送到更多位置:

  • 内核日志缓冲区(在 RAM 中;易失性)

  • 内核日志文件(非易失性)

  • 控制台设备

让我们更详细地检查内核日志缓冲区。

使用内核内存环形缓冲区

内核日志缓冲区只是内核地址空间中的一个内存缓冲区,用于保存(记录)printk的输出。更具体地说,它是全局变量__log_buf[]。在内核源代码中的定义如下:

kernel/printk/printk.c:
#define __LOG_BUF_LEN (1 << CONFIG_LOG_BUF_SHIFT)
static char __log_buf[__LOG_BUF_LEN] __aligned(LOG_ALIGN);

它被设计为一个环形缓冲区;它有一个有限的大小(__LOG_BUF_LEN字节),一旦满了,就会从第一个字节开始覆盖。因此,它被称为“环形”或循环缓冲区)。在这里,我们可以看到大小是基于Kconfig变量CONFIG_LOG_BUF_SHIFT(C 中的1 << n表示2^n)。这个值是显示的,并且可以作为内核(菜单)配置的一部分被覆盖:常规设置 > 内核日志缓冲区大小

它是一个整数值,范围为12 - 25(我们可以随时搜索init/Kconfig并查看其规范),默认值为18。因此,日志缓冲区的大小=2¹⁸=256 KB。但是,实际运行时的大小也受其他配置指令的影响,特别是LOG_CPU_MAX_BUF_SHIFT,它使大小成为系统上 CPU 数量的函数。此外,相关的Kconfig文件中说,"当使用 log_buf_len 内核参数时,此选项将被忽略,因为它会强制使用环形缓冲区的确切(2 的幂)大小。"因此,这很有趣;我们经常可以通过传递内核参数(通过引导加载程序)来覆盖默认值!

内核参数非常有用,种类繁多,值得一看。请参阅官方文档:www.kernel.org/doc/html/latest/admin-guide/kernel-parameters.html。来自 Linux 内核文档关于log_buf_len内核参数的片段揭示了细节:

log_buf_len=n[KMG]   Sets the size of the printk ring buffer,
                     in bytes. n must be a power of two and greater                
                     than the minimal size. The minimal size is defined
                     by LOG_BUF_SHIFT kernel config parameter. There is
                     also CONFIG_LOG_CPU_MAX_BUF_SHIFT config parameter
                     that allows to increase the default size depending  
                     on the number of CPUs. See init/Kconfig for more 
                     details.

无论内核日志缓冲区的大小如何,处理 printk API 时会出现两个问题:

  • 它的消息被记录在易失性内存(RAM)中;如果系统崩溃或以任何方式断电,我们将丢失宝贵的内核日志(通常会影响我们的调试能力)。

  • 默认情况下,日志缓冲区并不是很大,通常只有 256 KB;大量的打印会使环形缓冲区不堪重负,导致信息丢失。

我们该如何解决这个问题?继续阅读...

内核日志和 systemd 的 journalctl

解决前面提到的问题的一个明显方法是将内核的printk写入(追加)到文件中。这正是大多数现代 Linux 发行版的设置方式。日志文件的位置因发行版而异:传统上,基于 Red Hat 的发行版会写入/var/log/messages文件,而基于 Debian 的发行版会写入/var/log/syslog。传统上,内核的printk会连接到用户空间的系统日志守护程序syslogd)以执行文件记录,因此自动获得更复杂功能的好处,如日志轮换、压缩和归档。

然而,在过去的几年里,系统日志已经完全被一个称为systemd的有用而强大的系统初始化新框架所取代(它取代了旧的 SysV init 框架,或者通常与其一起工作)。事实上,即使是嵌入式 Linux 设备也经常使用 systemd。在 systemd 框架内,日志记录由一个名为systemd-journal的守护进程执行,而journalctl(1)实用程序是其用户界面。

systemd 及其相关实用程序的详细覆盖范围超出了本书的范围。请参考本章的进一步阅读部分,了解更多相关内容。

使用日志记录来检索和解释日志的一个关键优势是,所有来自应用程序、库、系统守护进程、内核、驱动程序等的日志都会被写入(合并)在这里。这样,我们就可以看到一个(反向)时间线事件,而不必手动将不同的日志拼接成一个时间线。journalctl(1)实用程序的 man 页面详细介绍了它的各种选项。在这里,我们提供了一些(希望)基于这个实用程序的方便别名:

#--- a few journalctl(1) aliases
# jlog: current (from most recent) boot only, everything
alias jlog='/bin/journalctl -b --all --catalog --no-pager'
# jlogr: current (from most recent) boot only, everything,
#  in *reverse* chronological order
alias jlogr='/bin/journalctl -b --all --catalog --no-pager --reverse'
# jlogall: *everything*, all time; --merge => _all_ logs merged
alias jlogall='/bin/journalctl --all --catalog --merge --no-pager'
# jlogf: *watch* log, akin to 'tail -f' mode;
#  very useful to 'watch live' logs
alias jlogf='/bin/journalctl -f'
# jlogk: only kernel messages, this (from most recent) boot
alias jlogk='/bin/journalctl -b -k --no-pager'

注意-b选项current boot意味着日志是从当前系统启动日期显示的。可以使用journalctl --list-boots查看存储的系统(重新)启动的编号列表。

我们故意使用--no-pager选项,因为它允许我们进一步使用[e]grep(1)awk(1)sort(1)等来过滤输出,根据需要。以下是使用journalctl(1)的一个简单示例:

$ journalctl -k |tail -n2
Mar 17 17:33:16 llkd-vbox kernel: Hello, world
Mar 17 17:47:26 llkd-vbox kernel: Goodbye, world
$  

注意日志的默认格式:

[timestamp] [hostname] [source]: [... log message ...]

在这里[source]是内核消息的内核,或者写入消息的特定应用程序或服务的名称。

journalctl(1)的 man 页面中看一些用法示例是有用的:

Show all kernel logs from previous boot:
    journalctl -k -b -1

Show a live log display from a system service apache.service:
    journalctl -f -u apache

将内核消息非易失性地记录到文件中当然是非常有用的。但要注意,存在一些情况,通常由硬件限制所决定,可能会使这种记录变得不可能。例如,一个小型、高度资源受限的嵌入式 Linux 设备可能会使用小型内部闪存芯片作为存储介质。现在,它不仅很小,而且所有的空间几乎都被应用程序、库、内核和引导加载程序所使用,而且闪存芯片有一个有效的擦写周期限制,它们可以承受的擦写周期数量有限。因此,写入几百万次可能会使其报废!因此,有时系统设计人员故意和/或另外使用更便宜的外部闪存存储器,比如(微)SD/MMC 卡(用于非关键数据),以减轻这种影响,因为它们很容易更换。

让我们继续了解 printk 日志级别。

使用 printk 日志级别

为了理解和使用 printk 日志级别,让我们从我们的helloworld_lkm内核模块的第一个 printk 开始,重现那一行代码:

printk(KERN_INFO "Hello, world\n");

现在让我们来解决房间里的大象:KERN_INFO到底意味着什么?首先,现在要小心:它不是你的本能反应所说的参数。注意它和格式字符串之间没有逗号字符,只有空格。KERN_INFO只是内核 printk 记录的八个日志级别中的一个。立即要理解的一个关键点是,这个日志级别不是任何优先级;它的存在允许我们根据日志级别过滤消息。内核为 printk 定义了八个可能的日志级别;它们是:

// include/linux/kern_levels.h
#ifndef __KERN_LEVELS_H__
#define __KERN_LEVELS_H__

#define KERN_SOH       "\001"             /* ASCII Start Of Header */
#define KERN_SOH_ASCII '\001'

#define KERN_EMERG    KERN_SOH      "0"   /* system is unusable */
#define KERN_ALERT    KERN_SOH      "1"   /* action must be taken  
                                             immediately */
#define KERN_CRIT     KERN_SOH      "2"   /* critical conditions */
#define KERN_ERR      KERN_SOH      "3"   /* error conditions */
#define KERN_WARNING  KERN_SOH      "4"   /* warning conditions */
#define KERN_NOTICE   KERN_SOH      "5"   /* normal but significant 
                                             condition */
#define KERN_INFO     KERN_SOH      "6"   /* informational */
#define KERN_DEBUG    KERN_SOH      "7"   /* debug-level messages */

#define KERN_DEFAULT  KERN_SOH      "d"   /* the default kernel loglevel */

因此,现在我们看到KERN_<FOO>日志级别只是被添加到由 printk 发出的内核消息的字符串("0"、"1"、...、"7");没有更多。这使我们有了根据日志级别过滤消息的有用能力。它们右侧的注释清楚地向开发人员显示了何时使用哪个日志级别。

KERN_SOH是什么?那就是 ASCII 报头开始SOH)值\001。查看ascii(7)的 man 页面;ascii(1)实用程序以各种数字基数转储 ASCII 表。从这里,我们可以清楚地看到数字1(或\001)是SOH字符,这里遵循的是一个约定。

让我们快速看一下 Linux 内核源树中的一些实际示例。当内核的hangcheck-timer设备驱动程序(有点类似于软件看门狗)确定某个定时器到期(默认为 60 秒)被延迟超过一定阈值(默认为 180 秒)时,它会重新启动系统!在这里,我们展示了相关的内核代码 - hangcheck-timer驱动程序在这方面发出printk的地方:

// drivers/char/hangcheck-timer.c[...]if (hangcheck_reboot) {
  printk(KERN_CRIT "Hangcheck: hangcheck is restarting the machine.\n");
  emergency_restart();
} else {
[...]

查看printk API 是如何调用的,日志级别设置为KERN_CRIT

另一方面,发出信息消息可能正是医生所开的处方:在这里,我们看到通用并行打印机驱动程序礼貌地通知所有相关方打印机着火了(相当低调,是吧?)

// drivers/char/lp.c[...]
 if (last != LP_PERRORP) {
     last = LP_PERRORP;
     printk(KERN_INFO "lp%d on fire\n", minor);
 }

您可能会认为设备着火将使printk符合“紧急”日志级别...好吧,至少arch/x86/kernel/cpu/mce/p5.c:pentium_machine_check()函数遵循了这一点:

// arch/x86/kernel/cpu/mce/p5.c
[...]
 pr_emerg("CPU#%d: Machine Check Exception: 0x%8X (type 0x%8X).\n",
         smp_processor_id(), loaddr, lotype);

    if (lotype & (1<<5)) {
        pr_emerg("CPU#%d: Possible thermal failure (CPU on fire ?).\n",
             smp_processor_id());
    } 
[...]

pr_<foo>()方便宏将在下面介绍)。

常见问题解答如果在printk()中未指定日志级别,则打印将以什么日志级别发出?默认为4,即KERN_WARNING写入控制台部分详细说明了为什么)。请注意,您应始终在使用printk时指定适当的日志级别。

有一种简单的方法来指定内核消息日志级别。这是我们接下来要深入研究的内容。

pr_方便宏

这里提供的方便pr_<foo>()宏可以减轻编码痛苦。笨拙的

printk(KERN_FOO "<format-str>");被优雅地替换为

pr_foo("<format-str>");,其中<foo>是日志级别;鼓励使用它们:

// include/linux/printk.h:
[...]
/*
 * These can be used to print at the various log levels.
 * All of these will print unconditionally, although note that pr_debug()
 * and other debug macros are compiled out unless either DEBUG is defined
 * or CONFIG_DYNAMIC_DEBUG is set.
 */
#define pr_emerg(fmt, ...) \
        printk(KERN_EMERG pr_fmt(fmt), ##__VA_ARGS__)
#define pr_alert(fmt, ...) \
        printk(KERN_ALERT pr_fmt(fmt), ##__VA_ARGS__)
#define pr_crit(fmt, ...) \
        printk(KERN_CRIT pr_fmt(fmt), ##__VA_ARGS__)
#define pr_err(fmt, ...) \
        printk(KERN_ERR pr_fmt(fmt), ##__VA_ARGS__)
#define pr_warning(fmt, ...) \
        printk(KERN_WARNING pr_fmt(fmt), ##__VA_ARGS__)
#define pr_warn pr_warning
#define pr_notice(fmt, ...) \
        printk(KERN_NOTICE pr_fmt(fmt), ##__VA_ARGS__)
#define pr_info(fmt, ...) \
        printk(KERN_INFO pr_fmt(fmt), ##__VA_ARGS__)
[...]
/* pr_devel() should produce zero code unless DEBUG is defined */
#ifdef DEBUG
#define pr_devel(fmt, ...) \
    printk(KERN_DEBUG pr_fmt(fmt), ##__VA_ARGS__)
#else
#define pr_devel(fmt, ...) \
    no_printk(KERN_DEBUG pr_fmt(fmt), ##__VA_ARGS__)
#endif

内核允许我们将loglevel=n作为内核命令行参数传递,其中n是介于07之间的整数,对应于先前提到的八个日志级别。预期的是(很快您将会了解到),所有具有低于传递的日志级别的printk实例也将被定向到控制台设备。

直接将内核消息写入控制台设备有时非常有用;下一节将详细介绍如何实现这一点。

连接到控制台

回想一下,printk输出可能会到达三个位置:

  • 第一个是内核内存日志缓冲区(始终)

  • 第二个是非易失性日志文件

  • 最后一个(我们将在这里讨论):控制台设备

传统上,控制台设备是一个纯内核功能,超级用户登录的初始终端窗口(/dev/console)在非图形环境中。有趣的是,在 Linux 上,我们可以定义几个控制台 - 一个电传打字机终端tty)窗口(如/dev/console),文本模式 VGA,帧缓冲区,甚至是通过 USB 提供的串行端口(这在嵌入式系统开发中很常见;请参阅本章的进一步阅读部分中的有关 Linux 控制台的更多信息)。

例如,当我们通过 USB 到 RS232 TTL UART(USB 到串行)电缆将树莓派连接到 x86-64 笔记本电脑时(请参阅本章的进一步阅读部分,了解有关这个非常有用的附件以及如何在树莓派上设置它的博客文章!),然后使用minicom(1)(或screen(1))获取串行控制台时,这就是显示为tty设备的内容 - 它是串行端口:

rpi # tty
/dev/ttyS0

这里的重点是,控制台通常是足够重要的日志消息的目标,包括那些源自内核深处的消息。Linux 的printk使用基于proc的机制有条件地将其数据传递到控制台设备。为了更好地理解这一点,让我们首先查看相关的proc伪文件:

$ cat /proc/sys/kernel/printk
4    4    1    7
$ 

我们将前面的四个数字解释为 printk 日志级别(0为最高,“紧急”级别为7为最低)。前面的四个整数序列的含义是这样的:

  • 当前(控制台)日志级别

- 暗示着所有低于此值的消息将出现在控制台设备上!

  • 缺乏显式日志级别的消息的默认级别

  • 允许的最低日志级别

  • 启动时的默认日志级别

由此可见,日志级别4对应于KERN_WARNING。因此,第一个数字为4(实际上是 Linux 发行版的典型默认值),所有低于日志级别 4 的 printk 实例将出现在控制台设备上,当然也会被记录到文件中-实际上,所有以下日志级别的消息:KERN_EMERGKERN_ALERTKERN_CRITKERN_ERR

日志级别为0 [KERN_EMERG]的内核消息总是打印到控制台,确实打印到所有终端窗口和内核日志文件,而不受任何设置的影响。

值得注意的是,当在嵌入式 Linux 或任何内核开发中工作时,通常会在控制台设备上工作,就像刚才给出的树莓派示例一样。将proc printk伪文件的第一个整数值设置为8保证所有 printk 实例直接出现在控制台上从而使 printk 的行为类似于常规的 printf!在这里,我们展示了 root 用户如何轻松设置这一点:

# echo "8 4 1 7" > /proc/sys/kernel/printk

(当然,这必须以 root 身份完成。)这在开发和测试过程中非常方便。

在我的树莓派上,我保留了一个包含以下行的启动脚本:

[ $(id -u) -eq 0 ] && echo "8 4 1 7" > /proc/sys/kernel/printk

因此,以 root 身份运行时,这将生效,所有 printk 实例现在直接出现在minicom(1)控制台上,就像printf一样。

谈到多功能的树莓派,下一节演示了在树莓派上运行内核模块。

将输出写入树莓派控制台

接下来是我们的第二个内核模块!在这里,我们将发出九个 printk 实例,每个实例都在八个日志级别中的一个,另外一个通过pr_devel()宏(实际上只是KERN_DEBUG日志级别)。让我们来看看相关的代码:

// ch4/printk_loglvl/printk_loglvl.c
static int __init printk_loglvl_init(void)
{
    pr_emerg ("Hello, world @ log-level KERN_EMERG   [0]\n");
    pr_alert ("Hello, world @ log-level KERN_ALERT   [1]\n");
    pr_crit  ("Hello, world @ log-level KERN_CRIT    [2]\n");
    pr_err   ("Hello, world @ log-level KERN_ERR     [3]\n");
    pr_warn  ("Hello, world @ log-level KERN_WARNING [4]\n");
    pr_notice("Hello, world @ log-level KERN_NOTICE  [5]\n");
    pr_info  ("Hello, world @ log-level KERN_INFO    [6]\n");
    pr_debug ("Hello, world @ log-level KERN_DEBUG   [7]\n");
    pr_devel("Hello, world via the pr_devel() macro"
        " (eff @KERN_DEBUG) [7]\n");
    return 0; /* success */
}
static void __exit printk_loglvl_exit(void)
{
    pr_info("Goodbye, world @ log-level KERN_INFO [6]\n");
}
module_init(printk_loglvl_init);
module_exit(printk_loglvl_exit);

现在,我们将讨论在树莓派设备上运行前述printk_loglvl内核模块时的输出。如果您没有或者不方便使用树莓派,那没问题;请继续在 x86-64 虚拟机上尝试。

在树莓派设备上(我在这里使用的是运行默认树莓派 OS 的树莓派 3B+型号),我们登录并通过简单的sudo -s获取 root shell。然后我们构建内核模块。如果您在树莓派上安装了默认的树莓派镜像,所有必需的开发工具、内核头文件等都将预先安装!图 4.7 是在树莓派板上运行我们的printk_loglvl内核模块的截图。另外,重要的是要意识到我们正在控制台设备上运行,因为我们正在使用前面提到的 USB 转串口电缆通过minicom(1)终端仿真器应用程序(而不是简单地通过 SSH 连接):

图 4.7 - minicom 终端仿真器应用程序窗口-控制台-带有 printk_loglvl 内核模块输出

从 x86-64 环境中注意到一些与之有点不同:在这里,默认情况下,/proc/sys/kernel/printk输出的第一个整数-当前控制台日志级别-是 3(而不是 4)。好吧,这意味着所有内核 printk 实例的日志级别低于日志级别 3 将直接出现在控制台设备上。看一下截图:情况确实如此!此外,正如预期的那样,“紧急”日志级别(0)的 printk 实例始终出现在控制台上,确实出现在每个打开的终端窗口上。

现在是有趣的部分:让我们(当然是作为 root)将当前控制台日志级别(记住,它是/proc/sys/kernel/printk输出中的第一个整数)设置为值8。这样,所有的 printk实例应该直接出现在控制台上。我们在这里精确测试了这一点:

图 4.8 - minicom 终端 - 实际上是控制台 - 窗口,控制台日志级别设置为 8

确实,正如预期的那样,我们在控制台设备上看到了所有printk实例,无需使用dmesg

不过,等一下:pr_debug()pr_devel()宏发出的内核消息在日志级别KERN_DEBUG(即整数值7)上发生了什么?它在这里没有出现,也没有在接下来的dmesg输出中出现?我们马上解释这一点,请继续阅读。

当然,通过dmesg(1),所有内核消息(至少是 RAM 中内核日志缓冲区中的消息)都会显示出来。我们在这里看到了这种情况:

rpi # rmmod printk_loglvl
rpi # dmesg
[...]
[ 1408.603812] Hello, world @ log-level KERN_EMERG   [0]
[ 1408.611335] Hello, world @ log-level KERN_ALERT   [1]
[ 1408.618625] Hello, world @ log-level KERN_CRIT    [2]
[ 1408.625778] Hello, world @ log-level KERN_ERR     [3]
[ 1408.625781] Hello, world @ log-level KERN_WARNING [4]
[ 1408.625784] Hello, world @ log-level KERN_NOTICE  [5]
[ 1408.625787] Hello, world @ log-level KERN_INFO    [6]
[ 1762.985496] Goodbye, world @ log-level KERN_INFO    [6]
rpi # 

除了KERN_DEBUG之外的所有printk实例都可以通过dmesg实用程序查看内核日志来看到。那么,如何显示调试消息呢?接下来会介绍。

启用 pr_debug()内核消息

啊是的,pr_debug()原来是一个特殊情况:除非为内核模块定义DEBUG符号,否则在日志级别KERN_DEBUG下的printk实例不会显示出来。我们编辑内核模块的 Makefile 以启用这一功能。至少有两种设置方法:

  • 将这行插入到 Makefile 中:
CFLAGS_printk_loglvl.o := -DDEBUG

通用的是CFLAGS_<filename>.o := -DDEBUG

  • 我们也可以将这个语句插入到 Makefile 中:
EXTRA_CFLAGS += -DDEBUG

在我们的 Makefile 中,我们故意保持-DDEBUG注释掉,现在,为了尝试它,取消以下注释掉的行中的一个:

# Enable the pr_debug() as well (rm the comment from one of the lines below)
#EXTRA_CFLAGS += -DDEBUG
#CFLAGS_printk_loglvl.o := -DDEBUG

完成后,我们从内存中删除旧的过时内核模块,重新构建它,并使用我们的lkm脚本插入它。输出显示pr_debug()现在生效了:

# exit                      << exit from the previous root shell >>
$ ../../lkm printk_loglvl Version info:
Distro:     Ubuntu 18.04.3 LTS
Kernel: 5.4.0-llkd01
------------------------------
sudo rmmod printk_loglvl 2> /dev/null
------------------------------
[...]
sudo insmod ./printk_loglvl.ko && lsmod|grep printk_loglvl
------------------------------
printk_loglvl          16384  0
------------------------------
dmesg
------------------------------
[  975.271766] Hello, world @ log-level KERN_EMERG [0]
[  975.277729] Hello, world @ log-level KERN_ALERT [1]
[  975.283662] Hello, world @ log-level KERN_CRIT [2]
[  975.289561] Hello, world @ log-level KERN_ERR [3]
[  975.295394] Hello, world @ log-level KERN_WARNING [4]
[  975.301176] Hello, world @ log-level KERN_NOTICE [5]
[  975.306907] Hello, world @ log-level KERN_INFO [6]
[  975.312625] Hello, world @ log-level KERN_DEBUG [7]
[  975.312628] Hello, world via the pr_devel() macro (eff @KERN_DEBUG) [7]
$

lkm脚本输出的部分截图(图 4.9)清楚地显示了dmesg的颜色编码,KERN_ALERT / KERN_CRIT / KERN_ERR的背景以红色/粗体红色字体/红色前景颜色突出显示,KERN_WARNING以粗体黑色字体显示,帮助我们人类快速发现重要的内核消息。

图 4.9 - lkm 脚本输出的部分截图

请注意,当启用动态调试功能(CONFIG_DYNAMIC_DEBUG=y)时,pr_debug()的行为并不相同。

设备驱动程序作者应该注意,为了发出调试printk实例,他们应该避免使用pr_debug()。相反,建议设备驱动程序使用dev_dbg()宏(另外传递给相关设备的参数)。此外,pr_devel()是用于内核内部调试printk实例的,其输出在生产系统中永远不应该可见。

现在,回到控制台输出部分。因此,也许出于内核调试的目的(如果没有其他目的),有没有一种保证的方法可以确保所有的 printk 实例都被定向到控制台是的,确实 - 只需传递名为ignore_level的内核(启动时)参数。有关此更多详细信息,请查阅官方内核文档中的描述:www.kernel.org/doc/html/latest/admin-guide/kernel-parameters.html。忽略 printk 日志级别也是可能的:如上所述,您可以通过这样做打开忽略 printk 日志级别的功能,从而允许所有 printk 出现在控制台设备上(反之亦然,通过向同一伪文件中回显 N 来关闭它):

sudo bash -c "echo Y > /sys/module/printk/parameters/ignore_loglevel"

dmesg(1)实用程序也可以用于通过各种选项开关(特别是--console-level 选项)控制启用/禁用内核消息到控制台设备,以及控制台日志级别(即在该级别以下的消息将出现在控制台上)。我让你浏览一下 dmesg(1)的 man 页面以获取详细信息。

下一部分涉及另一个非常有用的日志记录功能:速率限制。

限制 printk 实例的速率

当我们从执行非常频繁的代码路径发出 printk 实例时,printk 实例的数量可能会迅速超出内核日志缓冲区(在 RAM 中;请记住它是一个循环缓冲区),从而覆盖可能是关键信息。此外,不断增长的非易失性日志文件然后几乎无限地重复相同的 printk 实例也不是一个好主意,会浪费磁盘空间,或者更糟糕的是,闪存空间。例如,想象一下在中断处理程序代码路径中有一个大的 printk。如果硬件中断以每秒 100 次的频率被调用,也就是每秒 100 次!

为了缓解这些问题,内核提供了一个有趣的替代方案:速率限制printk*。printk_ratelimited()宏的语法与常规 printk 相同;关键点是当满足某些条件时,它会有效地抑制常规打印。内核通过 proc 文件系统提供了两个控制文件,名为 printk_ratelimit 和 printk_ratelimit_burst,用于此目的。在这里,我们直接复制了 sysctl 文档(来自 https://www.kernel.org/doc/Documentation/sysctl/kernel.txt),该文档解释了这两个(伪)文件的确切含义:

printk_ratelimit:
Some warning messages are rate limited. printk_ratelimit specifies
the minimum length of time between these messages (in jiffies), by
default we allow one every 5 seconds.
A value of 0 will disable rate limiting.
==============================================================
printk_ratelimit_burst:
While long term we enforce one message per printk_ratelimit
seconds, we do allow a burst of messages to pass through.
printk_ratelimit_burst specifies the number of messages we can
send before ratelimiting kicks in.

在我们的 Ubuntu 18.04.3 LTS 客户系统上,我们发现它们(默认)的值如下:

$ cat /proc/sys/kernel/printk_ratelimit /proc/sys/kernel/printk_ratelimit_burst
5
10
$ 

这意味着默认情况下,在 5 秒的时间间隔内发生的相同消息最多可以通过 10 个实例,然后速率限制才会生效。

当 printk 速率限制器抑制内核 printk 实例时,会发出一条有用的消息,其中提到确切抑制了多少早期的 printk 回调。例如,我们有一个自定义内核模块,它利用 Kprobes 框架在每次调用 schedule()之前发出一个 printk 实例,这是内核的核心调度例程。

Kprobe 本质上是一个用于生产系统故障排除的仪器框架;使用它,您可以指定一个函数,该函数可以在给定内核例程之前或之后执行。细节超出了本书的范围。

现在,由于调度经常发生,常规的 printk 会导致内核日志缓冲区迅速溢出。正是这种情况需要使用速率限制的 printk。在这里,我们看到了我们示例内核模块的一些示例输出(我们这里不显示它的代码),它使用了 printk_ratelimited() API 通过设置一个称为 handle_pre_schedule()的预处理程序函数的 kprobe 来设置一个 printk 实例:

[ 1000.154763] kprobe schedule pre_handler: intr ctx = 0 :process systemd-journal:237
[ 1005.162183] handler_pre_schedule: 5860 callbacks suppressed
[ 1005.162185] kprobe schedule pre_handler: intr ctx = 0 :process dndX11:1071

在 Linux 内核的实时时钟(RTC)驱动程序的中断处理程序代码中,可以看到使用速率限制 printk 的代码级示例,位置在 drivers/char/rtc.c 中:

static void rtc_dropped_irq(struct timer_list *unused)
{ 
[...]
    spin_unlock_irq(&rtc_lock);
    printk_ratelimited(KERN_WARNING "rtc: lost some interrupts at         %ldHz.\n", freq);
    /* Now we have new data */
    wake_up_interruptible(&rtc_wait);
[...]
}

不要混淆 printk_ratelimited()宏和旧的(现在已弃用的)printk_ratelimit()宏。此外,实际的速率限制代码在 lib/ratelimit.c:___ratelimit()中。

此外,就像我们之前看到的 pr_宏一样,内核还提供了相应的 pr__ratelimited 宏,用于在启用速率限制时以日志级别生成内核 printk。以下是它们的快速列表:

pr_emerg_ratelimited(fmt, ...)
pr_alert_ratelimited(fmt, ...)
pr_crit_ratelimited(fmt, ...) 
pr_err_ratelimited(fmt, ...)  
pr_warn_ratelimited(fmt, ...) 
pr_notice_ratelimited(fmt, ...)
pr_info_ratelimited(fmt, ...)  

我们能否从用户空间生成内核级消息?听起来很有趣;这是我们的下一个子主题。

从用户空间生成内核消息

我们程序员经常使用的一种流行的调试技术是在代码的各个地方添加打印,这经常可以帮助我们缩小问题的来源。这确实是一种有用的调试技术,称为instrumenting代码。内核开发人员经常使用值得尊敬的 printk API 来实现这一目的。

因此,想象一下,您已经编写了一个内核模块,并且正在调试它(通过添加几个 printk)。您的内核代码现在发出了几个 printk 实例,当然,您可以在运行时通过dmesg或其他方式看到。这很好,但是,特别是因为您正在运行一些自动化的用户空间测试脚本,您可能希望通过打印某个特定消息来查看脚本在我们的内核模块中启动某个动作的位置。作为一个具体的例子,假设我们希望日志看起来像这样:

test_script: msg 1 ; kernel_module: msg n, msg n+1, ..., msg n+m ; test_script: msg 2 ; ...

我们的用户空间测试脚本可以像内核的 printk 一样,将消息写入内核日志缓冲区,通过写入特殊的/dev/kmsg设备文件:

echo "test_script: msg 1" > /dev/kmsg

嗯,等一下 - 这样做当然需要以 root 访问权限运行。但是,请注意,这里简单的在echo之前加上sudo(8)是行不通的:

$ sudo echo "test_script: msg 1" > /dev/kmsg
bash: /dev/kmsg: Permission denied
$ sudo bash -c "echo \"test_script: msg 1\" > /dev/kmsg"
[sudo] password for llkd:
$ dmesg |tail -n1
[55527.523756] test_script: msg 1
$ 

第二次尝试中使用的语法是有效的,但是更简单的方法是获取一个 root shell 并执行此类任务。

还有一件事:dmesg(1)实用程序有几个选项,旨在使输出更易读;我们通过我们的dmesg的示例别名显示了其中一些选项,之后我们使用它。

$ alias dmesg='/bin/dmesg --decode --nopager --color --ctime'
$ dmesg | tail -n1
user :warn : [Sat Dec 14 17:21:50 2019] test_script: msg 1
$ 

通过特殊的/dev/kmsg设备文件写入内核日志的消息将以当前默认的日志级别打印,通常是4 : KERN_WARNING。我们可以通过实际在消息前加上所需的日志级别(作为字符串格式的数字)来覆盖这一点。例如,要在用户空间中以日志级别6 : KERN_INFO写入内核日志,使用以下命令:

$ sudo bash -c "echo \"<6>test_script: test msg at KERN_INFO\"   \
   > /dev/kmsg"
$ dmesg | tail -n2
user :warn : [Fri Dec 14 17:21:50 2018] test_script: msg 1
user :info : [Fri Dec 14 17:31:48 2018] test_script: test msg at KERN_INFO

我们可以看到我们后来的消息是以日志级别6发出的,就像echo中指定的那样。

用户生成的内核消息和内核printk()生成的消息之间实际上没有办法区分;它们看起来是一样的。因此,当然,可以简单地在消息中插入一些特殊的签名字节或字符串,例如@user@,以帮助您区分这些用户生成的打印消息和内核消息。

通过 pr_fmt 宏标准化 printk 输出

关于内核 printk 的最后一个但重要的一点;经常,为了给您的printk()输出提供上下文(它到底发生在哪里?),您可能会像这样编写代码,利用各种 gcc 宏(如__FILE____func____LINE__)。

pr_warning("%s:%s():%d: kmalloc failed!\n", OURMODNAME,  __func__, __LINE__);

这很好;问题是,如果您的项目中有很多 printk,要保证标准的 printk 格式(例如,首先显示模块名称,然后是函数名称,可能还有行号,就像这里看到的那样)总是由项目中的每个人遵循,这可能会相当痛苦。

输入pr_fmt宏;在代码的开头定义这个宏(必须在第一个#include之前),可以保证代码中每个后续的 printk 都将以这个宏指定的格式为前缀。让我们举个例子(我们展示了下一章的代码片段;不用担心,它真的非常简单,可以作为您未来内核模块的模板)。

// ch5/lkm_template/lkm_template.c
[ ... ]
 */
#define pr_fmt(fmt) "%s:%s(): " fmt, KBUILD_MODNAME, __func__

#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
[ ... ]
static int __init lkm_template_init(void)
{
    pr_info("inserted\n");
    [ ... ]

pr_fmt()宏以粗体字突出显示;它使用预定义的KBUILD_MODNAME宏来替换您的内核模块的名称,并使用 gcc 的__func__指定符来显示我们当前运行的函数的名称!(您甚至可以添加一个%d,与相应的__LINE__宏匹配,以显示行号)。因此,最重要的是:我们在这个 LKM 的init函数中发出的pr_info()将在内核日志中显示如下:

[381534.391966] lkm_template:lkm_template_init(): inserted

注意 LKM 名称和函数名称是自动添加前缀的。这非常有用,而且非常常见;在内核中,成百上千的源文件以pr_fmt()开头。(在 5.4 内核代码库中快速搜索发现代码库中有超过 2000 个此宏的实例!我们也将遵循这个惯例,尽管并非所有的演示内核模块都是如此)。

pr_fmt()也影响了驱动程序作者推荐的 printk 使用方式 - 通过dev_<foo>()函数。

可移植性和 printk 格式说明符

关于多功能的 printk 内核 API,有一个问题需要考虑,那就是如何确保你的 printk 输出在任何 CPU 上看起来正确(格式正确)并且同样适用,无论位宽如何?这里涉及到可移植性问题;好消息是,熟悉提供的各种格式说明符将在这方面帮助你很多,实际上可以让你编写与体系结构无关的 printk。

重要的是要意识到size_t - 发音为size type - 是无符号整数的typedef;同样,ssize_tsigned size type)是有符号整数的typedef

以下是一些常见的printk格式说明符,当编写可移植代码时要记住:

  • 对于size_tssize_t(有符号和无符号)整数:分别使用%zd%zu

  • 内核指针:使用%pK进行安全处理(散列值),使用%px表示实际指针(在生产中不要使用!),另外,使用%pa表示物理地址(必须通过引用传递)

  • 原始缓冲区作为十六进制字符的字符串:%*ph(其中*被字符的数量替换;用于 64 个字符以内的缓冲区,使用print_hex_dump_bytes()例程进行更多操作);还有其他变体(请参阅内核文档,链接如下)

  • 使用%pI4表示 IPv4 地址,使用%pI6表示 IPv6 地址(也有变体)

printk 格式说明符的详尽列表,以及何时使用(附有示例)是官方内核文档的一部分:www.kernel.org/doc/Documentation/printk-formats.txt。内核还明确记录了在printk()语句中使用未装饰的%p可能会导致安全问题(链接:www.kernel.org/doc/html/latest/process/deprecated.html#p-format-specifier)。我建议你浏览一下!

好了!让我们通过学习内核模块的 Makefile 如何构建内核来完成本章的内容。

理解内核模块 Makefile 的基础知识。

你可能已经注意到,我们倾向于遵循一种每个目录一个内核模块的规则。是的,这确实有助于保持事情井然有序。因此,让我们来看看我们的第二个内核模块,ch4/printk_loglvl。要构建它,我们只需cd到它的文件夹,输入make,然后(祈祷!)完成。我们有了printk_loglevel.ko内核模块对象(然后我们可以使用insmod(8)/rmmod(8))。但是当我们输入make时,它究竟是如何构建的呢?啊,解释这一点正是本节的目的。

由于这是我们处理 LKM 框架及其相应 Makefile 的第一章,我们将保持事情简单,特别是在这里的 Makefile 方面。然而,在接下来的章节中,我们将介绍一个更复杂、更简单更好的 Makefile(仍然很容易理解)。然后我们将在所有后续的代码中使用这个更好的 Makefile;请留意并使用它!

正如你所知,make命令默认会在当前目录中查找名为Makefile的文件;如果存在,它将解析并执行其中指定的命令序列。这是我们的内核模块printk_loglevel项目的Makefile

// ch4/printk_loglvl/Makefile
PWD       := $(shell pwd)obj-m     += printk_loglvl.o

# Enable the pr_debug() as well (rm the comment from the line below)
#EXTRA_CFLAGS += -DDEBUG
#CFLAGS_printk_loglvl.o := -DDEBUG

all:
    make -C /lib/modules/$(shell uname -r)/build/ M=$(PWD) modules
install:
    make -C /lib/modules/$(shell uname -r)/build/ M=$(PWD) modules_install
clean:
    make -C /lib/modules/$(shell uname -r)/build/ M=$(PWD) clean

Unix 的Makefile语法基本上要求这样做:

target: [dependent-source-file(s)]
        rule(s)

rule(s)实例总是以[Tab]字符为前缀,而不是空格。

让我们了解一下这个 Makefile 的基本工作原理。首先,一个关键点是:内核的Kbuild系统(我们自第二章以来一直在提及和使用,从源代码构建 5.x Linux 内核-第一部分),主要使用两个软件变量字符串进行构建,这两个变量字符串在两个obj-yobj-m变量中链接起来。

obj-y字符串包含要构建并合并到最终内核镜像文件中的所有对象的连接列表-未压缩的vmlinux和压缩(可引导)[b]zImage镜像。想一想-这是有道理的:obj-y中的y代表Yes。所有内核内置和Kconfig选项在内核配置过程中设置为Y(或默认为Y)的都通过此项链接在一起,构建,并最终通过Kbuild构建系统编织到最终的内核镜像文件中。

另一方面,现在很容易看到obj-m字符串是所有内核对象的连接列表,要分别构建为内核模块!这正是为什么我们的 Makefile 有这一重要行:

obj-m += printk_loglvl.o

实际上,它告诉Kbuild系统包括我们的代码;更正确地说,它告诉它隐式地将printk_loglvl.c源代码编译成printk_loglvl.o二进制对象,然后将此对象添加到obj-m列表中。接下来,由于make的默认规则是all规则,它被处理:

all:
    make -C /lib/modules/$(shell uname -r)/build/ M=$(PWD) modules

这个单一语句的处理非常复杂;以下是发生的事情:

  1. -C选项开关到make使make进程更改目录(通过chdir(2)系统调用)到跟在-C后面的目录名。因此,它会更改到内核build文件夹(正如我们之前介绍的,这是通过kernel-headers包安装的有限内核源树的位置)。

  2. 一旦到达那里,它就会解析内核顶层Makefile 的内容-也就是说,位于这个有限内核源树根目录中的 Makefile。这是一个关键点。这样可以保证所有内核模块与它们正在构建的内核紧密耦合(稍后会详细介绍)。这也保证了内核模块使用与内核镜像本身完全相同的一组规则构建,即编译器/链接器配置(CFLAGS选项,编译器选项开关等)。所有这些都是二进制兼容性所必需的。

  3. 接下来,您可以看到变量M的初始化,指定的目标是modules;因此,make进程现在更改到由M变量指定的目录,您可以看到它设置为$(PWD) - 我们开始的文件夹(当前工作目录;Makefile 中的PWD := $(shell pwd)将其初始化为正确的值)!

有趣的是,这是一个递归构建:构建过程,非常重要的是,解析了内核顶层 Makefile 后,现在切换回内核模块的目录并构建其中的模块。

您是否注意到,构建内核模块时,还会生成相当多的中间工作文件?其中包括modules.order<file>.mod.c<file>.oModule.symvers<file>.mod.o.<file>.o.cmd.<file>.ko.cmd、一个名为.tmp_versions/的文件夹,当然还有内核模块二进制对象本身,<file>.ko-整个构建过程的重点。摆脱所有这些对象,包括内核模块对象本身,很容易:只需执行make cleanclean规则会将所有这些清理干净。(我们将在下一章中深入探讨install目标。)

您可以在这里查找modules.ordermodules.builtin文件(以及其他文件)的用途:Documentation/kbuild/kbuild.rst

另外,正如之前提到的,我们将在接下来的章节中介绍并使用一个更复杂的 Makefile 变体 - 一个更好的 Makefile;它旨在帮助您,内核模块/驱动程序开发人员,通过运行与内核编码风格检查、静态分析、简单打包以及(一个虚拟目标)相关的目标,提高代码质量。

随着这一章的结束,我们结束了。干得好 - 您现在已经在学习 Linux 内核开发的道路上取得了良好的进展!

摘要

在本章中,我们介绍了 Linux 内核架构和 LKM 框架的基础知识。您了解了什么是内核模块以及它的用途。然后,我们编写了一个简单但完整的内核模块,一个非常基本的Hello, world。然后,材料进一步深入探讨了它的工作原理,以及如何加载它,查看模块列表并卸载它。详细介绍了使用 printk 进行内核日志记录,以及限制 printk 的速率,从用户空间生成内核消息,标准化其输出格式,并了解内核模块 Makefile 的基础知识。

这结束了本章;我敦促你去研究示例代码(通过本书的 GitHub 存储库),完成问题/作业,然后继续下一章,继续我们的 Linux 内核模块编写覆盖范围。

问题

最后,这里有一些问题供您测试对本章材料的了解:github.com/PacktPublishing/Linux-Kernel-Programming/tree/master/questions。您会发现一些问题的答案在书的 GitHub 存储库中:github.com/PacktPublishing/Linux-Kernel-Programming/tree/master/solutions_to_assgn

进一步阅读

为了帮助您深入了解有用的材料,我们在本书的 GitHub 存储库中提供了一个相当详细的在线参考和链接列表(有时甚至包括书籍)的进一步阅读文档。进一步阅读文档在这里可用:github.com/PacktPublishing/Linux-Kernel-Programming/raw/master/Further_Reading.md

第五章:编写您的第一个内核模块-LKMs 第二部分

本章是关于可加载内核模块LKM)框架及如何使用它编写内核模块的覆盖的后半部分。为了充分利用它,我希望您在阅读本章之前完成上一章,并尝试那里的代码和问题。

在本章中,我们将继续上一章的内容。在这里,我们将介绍如何使用“更好”的 Makefile 来编写 LKMs,为 ARM 平台交叉编译内核模块(作为典型示例),模块堆叠是什么以及如何执行,以及如何设置和使用模块参数。在此过程中,除其他事项外,您还将了解内核 API/ABI 的稳定性(或者说,缺乏稳定性!),编写用户空间和内核代码之间的关键区别,系统启动时自动加载内核模块以及安全性问题以及如何解决它们。最后,我们将介绍内核文档(包括编码风格)和对主线的贡献。

简而言之,本章将涵盖以下主题:

  • 一个“更好”的内核模块 Makefile 模板

  • 交叉编译内核模块

  • 收集最小系统信息

  • 许可内核模块

  • 为内核模块模拟“类库”功能

  • 向内核模块传递参数

  • 内核中不允许浮点数

  • 系统启动时自动加载模块

  • 内核模块和安全性-概述

  • 内核开发人员的编码风格指南

  • 为主线内核做出贡献

技术要求

本章的技术要求——所需的软件包——与第四章中的技术要求部分所示的内容相同,请参考。您可以在本书的 GitHub 存储库中找到本章的源代码。使用以下命令进行克隆:

git clone https://github.com/PacktPublishing/Linux-Kernel-Programming

书中显示的代码通常只是相关片段。请跟随存储库中的完整源代码。对于本章(以及随后的章节),有关技术要求的更多信息请参阅以下部分。

一个“更好”的内核模块 Makefile 模板

上一章向您介绍了用于从源代码生成内核模块、安装和清理的 Makefile。然而,正如我们在那里简要提到的,我现在将介绍我认为更好的“更好”的 Makefile,并解释它为什么更好。

最终,我们都必须编写更好、更安全的代码——无论是用户空间还是内核空间。好消息是,有几种工具可以帮助改进代码的健壮性和安全性,其中包括静态和动态分析器(在第一章中已经提到了几种,内核工作空间设置,我就不在这里重复了)。

我设计了一个简单但有用的内核模块 Makefile“模板”,其中包括几个目标,可帮助您运行这些工具。这些目标使您可以非常轻松地执行有价值的检查和分析;可能是您会忘记、忽视或永远推迟的事情! 这些目标包括以下内容:

  • “通常”的目标——buildinstallclean

  • 内核编码风格生成和检查(通过indent(1)和内核的checkpatch.pl脚本,分别)。

  • 内核静态分析目标(sparsegccflawfinder),并提到Coccinelle

  • 一对“虚拟”的内核动态分析目标(KASANLOCKDEP / CONFIG_PROVE_LOCKING),鼓励您为所有测试用例配置、构建和使用“调试”内核。

  • 一个简单的tarxz-pkg目标,将源文件打包并压缩到前一个目录。这使您可以将压缩的tar-xz文件传输到任何其他 Linux 系统,并在那里提取和构建 LKM。

  • 一个“虚拟”的动态分析目标,指出您应该投入时间来配置和构建一个“调试”内核,并使用它来捕捉错误!(稍后将更多内容)

您可以在ch5/lkm_template目录中找到代码(以及README文件)。为了帮助您理解其用途和功能,并帮助您入门,以下图简单地显示了当运行其help目标时代码产生的输出的屏幕截图:

图 5.1 - 来自我们“更好”的 Makefile 的 helptarget 的输出

图 5.1中,我们首先执行make,然后按两次Tab键,这样它就会显示所有可用的目标。请仔细研究并使用它!例如,运行make sa将导致它在您的代码上运行所有静态分析sa)目标!

还需要注意的是,使用这个 Makefile 将需要您在系统上安装一些软件包/应用程序;这些包括(对于基本的 Ubuntu 系统)indent(1)linux-headers-$(uname -r)sparse(1)flawfinder(1)cppcheck(1)tar(1)。(第一章,内核工作区设置,已经指出这些应该被安装)。

另外,请注意,Makefile 中所谓的动态分析da)目标仅仅是不做任何事情,只是打印一条消息的虚拟目标。它们只是提醒您通过在适当配置的“调试”内核上运行代码来彻底测试您的代码!

说到“调试”内核,下一节将向您展示如何配置一个。

配置“调试”内核

(有关配置和构建内核的详细信息,请参阅第二章,从源代码构建 5.x Linux 内核-第一部分,和第三章,从源代码构建 5.x Linux 内核-第二部分)。

调试内核上运行代码可以帮助您发现难以发现的错误和问题。我强烈建议在开发和测试期间这样做!在这里,我至少希望您配置您的自定义 5.4 内核,使以下内核调试配置选项打开(在make menuconfig界面中,您会发现大多数选项在Kernel Hacking子菜单下;以下列表是针对 Linux 5.4.0 的):

  • CONFIG_DEBUG_INFO

  • CONFIG_DEBUG_FSdebugfs伪文件系统)

  • CONFIG_MAGIC_SYSRQ(Magic SysRq 热键功能)

  • CONFIG_DEBUG_KERNEL

  • CONFIG_DEBUG_MISC

  • 内存调试:

  • CONFIG_SLUB_DEBUG

  • CONFIG_DEBUG_MEMORY_INIT

  • CONFIG_KASAN:这是内核地址消毒剂端口;但是,截至撰写本文时,它仅适用于 64 位系统。

  • CONFIG_DEBUG_SHIRQ

  • CONFIG_SCHED_STACK_END_CHECK

  • 锁调试:

  • CONFIG_PROVE_LOCKING:非常强大的lockdep功能来捕获锁定错误!这将打开其他几个锁调试配置,详细说明在第十三章,内核同步-第二部分

  • CONFIG_LOCK_STAT

  • CONFIG_DEBUG_ATOMIC_SLEEP

  • CONFIG_STACKTRACE

  • CONFIG_DEBUG_BUGVERBOSE

  • CONFIG_FTRACEftrace:在其子菜单中,至少打开一些“跟踪器”)

  • CONFIG_BUG_ON_DATA_CORRUPTION

  • CONFIG_KGDB(内核 GDB;可选)

  • CONFIG_UBSAN

  • CONFIG_EARLY_PRINTK

  • CONFIG_DEBUG_BOOT_PARAMS

  • CONFIG_UNWINDER_FRAME_POINTER(选择FRAME_POINTERCONFIG_STACK_VALIDATION

需要注意的几件事:

a) 如果您现在不明白先前提到的所有内核调试配置选项的作用,也不要太担心;在您完成本书时,大多数选项都会变得清晰起来。

b) 打开一些Ftrace跟踪器(或插件),例如CONFIG_IRQSOFF_TRACER,这在我们的Linux 内核编程(第二部分)书中的处理硬件中断章节中实际上会有用;(请注意,尽管 Ftrace 本身可能默认启用,但并非所有跟踪器都是默认启用的)。

请注意,打开这些配置选项确实会带来性能损失,但没关系。我们正在运行这种“调试”内核,目的是捕捉错误和漏洞(尤其是难以发现的种类!)。它确实可以拯救生命!在你的项目中,你的工作流程应该涉及你的代码在以下两者上进行测试和运行

  • 调试内核系统,其中所有必需的内核调试配置选项都已打开(如先前所示的最小配置)

  • 生产内核系统(在其中所有或大部分先前的内核调试选项将被关闭)

毋庸置疑,我们将在本书中所有后续的 LKM 代码中使用先前的 Makefile 风格。

好了,现在你已经准备好了,让我们来探讨一个有趣且实际的场景-为另一个目标(通常是 ARM)编译你的内核模块。

交叉编译内核模块

在第三章中,从源代码构建 5.x Linux 内核-第二部分,在为树莓派构建内核部分,我们展示了如何为“外部”目标架构(如 ARM、PowerPC、MIPS 等)交叉编译 Linux 内核。基本上,对于内核模块也可以做同样的事情;通过适当设置“特殊”的ARCHCROSS_COMPILE环境变量,可以轻松地交叉编译内核模块。

例如,假设我们正在开发一个嵌入式 Linux 产品;我们的代码将在一个具有 AArch32(ARM-32)CPU 的目标设备上运行。为什么不举一个实际的例子。让我们为树莓派 3 单板计算机SBC)交叉编译我们的Hello, world内核模块!

这很有趣。你会发现,尽管看起来简单直接,但我们最终会进行四次迭代才成功。为什么?继续阅读以了解详情。

为交叉编译设置系统

交叉编译内核模块的先决条件非常明确:

  • 我们需要为目标系统安装内核源树,作为主机系统工作空间的一部分,通常是 x86_64 台式机(对于我们的示例,使用树莓派作为目标,请参考官方树莓派文档:www.raspberrypi.org/documentation/linux/kernel/building.md)。

  • 现在我们需要一个交叉工具链。通常,主机系统是 x86_64,而目标是 ARM-32,因此我们需要一个x86_64 到 ARM32 的交叉工具链。同样,正如在第三章中明确提到的,从源代码构建 5.x Linux 内核-第二部分为树莓派构建内核,你必须下载并安装 Raspberry Pi 特定的 x86_64 到 ARM 工具链作为主机系统工作空间的一部分(请参考第三章,从源代码构建 5.x Linux 内核-第二部分,了解如何安装工具链)。

好了,从这一点开始,我将假设你已经安装了 x86_64 到 ARM 交叉工具链。我还将假设工具链前缀arm-linux-gnueabihf-;我们可以通过尝试调用gcc交叉编译器来快速检查工具链是否已安装并将其二进制文件添加到路径中:

$ arm-linux-gnueabihf-gcc
arm-linux-gnueabihf-gcc: fatal error: no input files
compilation terminated.
$ 

它可以工作-只是我们没有传递任何 C 程序作为编译参数,因此它会报错。

你也可以使用arm-linux-gnueabihf-gcc --version命令查看编译器版本。

尝试 1-设置“特殊”的环境变量

实际上,交叉编译内核模块非常容易(或者我们认为是这样!)。只需确保适当设置“特殊”的ARCHCROSS_COMPILE环境变量。按照以下步骤进行:

  1. 让我们重新为树莓派目标构建我们的第一个Hello, world内核模块。以下是构建方法:

为了不破坏原始代码,我们创建一个名为cross的新文件夹,其中包含从第四章复制的(helloworld_lkm)代码,编写你的第一个内核模块 - LKMs 第一部分

cd <dest-dir>/ch5/cross

这里,<dest-dir>是书的 GitHub 源树的根目录。

  1. 现在,运行以下命令:
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf-

但它并不会立即起作用(或者可能会起作用;请参阅以下信息框)。我们会得到编译失败,如下所示:

$ make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- make -C /lib/modules/5.4.0-llkd01/build/ M=/home/llkd/book_llkd/Linux-Kernel-Programming/ch5/cross modules
make[1]: Entering directory '/home/llkd/kernels/linux-5.4'
  CC [M]  /home/llkd/book_llkd/Linux-Kernel-Programming/ch5/cross/helloworld_lkm.o
arm-linux-gnueabihf-gcc: error: unrecognized command line option ‘-fstack-protector-strong’
scripts/Makefile.build:265: recipe for target '/home/llkd/book_llkd/Linux-Kernel-Programming/ch5/cross/helloworld_lkm.o' failed
[...]
make: *** [all] Error 2
$ 

为什么失败了?

假设所有工具都按照之前讨论的技术要求设置好,交叉编译应该可以工作。这是因为书中提供的Makefile是一个正确工作的,树莓派内核已经正确配置和构建,设备已经引导到这个内核,并且内核模块已经针对它进行了编译。在这本书中,这里的目的是解释细节;因此,我们从没有假设开始,并引导您正确执行交叉编译的过程。

前面的交叉编译尝试失败的线索在于,它试图使用 - 构建对 - 当前主机系统的内核源,而不是目标的内核源树。因此,我们需要修改 Makefile 以指向目标的正确内核源树。这样做真的很容易。在下面的代码中,我们看到了(已更正的)Makefile 代码的典型写法:

# ch5/cross/Makefile:
# To support cross-compiling for kernel modules:
# For architecture (cpu) 'arch', invoke make as:
# make ARCH=<arch> CROSS_COMPILE=<cross-compiler-prefix> 
ifeq ($(ARCH),arm)
  # *UPDATE* 'KDIR' below to point to the ARM Linux kernel source tree on 
  # your box
  KDIR ?= ~/rpi_work/kernel_rpi/linux
else ifeq ($(ARCH),arm64)
  # *UPDATE* 'KDIR' below to point to the ARM64 (Aarch64) Linux kernel 
  # source tree on your box
  KDIR ?= ~/kernel/linux-4.14
else ifeq ($(ARCH),powerpc)
  # *UPDATE* 'KDIR' below to point to the PPC64 Linux kernel source tree  
  # on your box
  KDIR ?= ~/kernel/linux-4.9.1
else
  # 'KDIR' is the Linux 'kernel headers' package on your host system; this 
  # is usually an x86_64, but could be anything, really (f.e. building 
  # directly on a Raspberry Pi implies that it's the host)
  KDIR ?= /lib/modules/$(shell uname -r)/build
endif

PWD          := $(shell pwd)
obj-m        += helloworld_lkm.o
EXTRA_CFLAGS += -DDEBUG

all:
    @echo
    @echo '--- Building : KDIR=${KDIR} ARCH=${ARCH} CROSS_COMPILE=${CROSS_COMPILE} EXTRA_CFLAGS=${EXTRA_CFLAGS} ---'
    @echo
    make -C $(KDIR) M=$(PWD) modules
[...]

仔细查看(在前一节中解释的新的和“更好”的)Makefile,您将看到它是如何工作的:

  • 最重要的是,我们有条件地设置KDIR变量,根据ARCH环境变量的值指向正确的内核源树(当然,我已经用一些内核源树的路径名作为 ARM[64]和 PowerPC 的示例;请用实际的内核源树路径替换路径名)

  • 像往常一样,我们设置obj-m += <module-name>.o

  • 我们还设置CFLAGS_EXTRA以添加DEBUG符号(这样DEBUG符号就在我们的 LKM 中定义了,甚至pr_debug()/pr_devel()宏也可以工作)。

  • @echo '<...>'行等同于 shell 的echo命令;它只是在构建时发出一些有用的信息(@前缀隐藏了 echo 语句本身的显示)。

  • 最后,我们有“通常”的 Makefile 目标:allinstallclean - 这些与之前相同,除了这个重要的变化:我们让它改变目录(通过-C开关)到KDIR的值!

  • 尽管在上述代码中没有显示,但这个“更好”的 Makefile 有几个额外有用的目标。您应该花时间去探索和使用它们(如前一节所述;首先,只需输入make help,研究输出并尝试一些东西)。

完成所有这些后,让我们使用这个版本重试交叉编译并看看结果如何。

尝试 2 - 将 Makefile 指向目标的正确内核源树

现在,有了前一节中描述的增强Makefile,它应该可以工作。在我们将尝试这个的新目录中 - cross(因为我们是交叉编译,不是因为我们生气!) - 请按照以下步骤操作:

  1. 使用适用于交叉编译的make命令尝试构建(第二次)。
$ make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- 
--- Building : KDIR=~/rpi_work/kernel_rpi/linux ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- EXTRA_CFLAGS=-DDEBUG ---

make -C ~/rpi_work/kernel_rpi/linux M=/home/llkd/booksrc/ch5/cross modules
make[1]: Entering directory '/home/llkd/rpi_work/kernel_rpi/linux'

ERROR: Kernel configuration is invalid.
 include/generated/autoconf.h or include/config/auto.conf are missing.
 Run 'make oldconfig && make prepare' on kernel src to fix it.

 WARNING: Symbol version dump ./Module.symvers
 is missing; modules will have no dependencies and modversions.
[...]
make: *** [all] Error 2
$ 

实际失败的原因是,我们正在编译内核模块的树莓派内核仍处于“原始”状态。它甚至没有.config文件(以及其他所需的头文件,如前面的输出所告知的)存在于其根目录中,它需要(至少)被配置。

  1. 为了解决这个问题,请切换到您的树莓派内核源树的根目录,并按照以下步骤操作:
$ cd ~/rpi-work/kernel_rpi/linux $ make ARCH=arm bcmrpi_defconfig
#
# configuration written to .config
#
$ make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- oldconfig
scripts/kconfig/conf --oldconfig Kconfig
#
# configuration written to .config
#
$ make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- prepare
scripts/kconfig/conf --silentoldconfig Kconfig
 CHK include/config/kernel.release
 UPD include/config/kernel.release
 WRAP arch/arm/include/generated/asm/bitsperlong.h
 WRAP arch/arm/include/generated/asm/clkdev.h
 [...]
$ make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf-
 CHK include/config/kernel.release
 CHK include/generated/uapi/linux/version.h
 CHK include/generated/utsrelease.h
 [...]
 HOSTCC scripts/recordmcount
 HOSTCC scripts/sortextable
 [...]
$

请注意,这些步骤实际上与执行树莓派内核的部分构建非常相似!实际上,如果您已经按照第三章中所述的方式构建(交叉编译)了内核,从源代码构建 5.x Linux 内核 - 第二部分,那么内核模块的交叉编译应该可以在这里看到的中间步骤无需工作。

尝试 3 - 交叉编译我们的内核模块

现在我们在主机系统上有一个配置好的树莓派内核源树和增强的 Makefile(参见尝试 2 - 将 Makefile 指向目标的正确内核源树部分),它应该可以工作。让我们重试一下:

  1. 我们(再次)尝试构建(交叉编译)内核。像往常一样,发出make命令,同时传递ARCHCROSS_COMPILE环境变量:
$ ls -l
total 12
-rw-rw-r-- 1 llkd llkd 1456 Mar 18 17:48 helloworld_lkm.c
-rw-rw-r-- 1 llkd llkd 6470 Jul  6 17:30 Makefile
$ make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- --- Building : KDIR=~/rpi_work/kernel_rpi/linux ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- EXTRA_CFLAGS=-DDEBUG ---

make -C ~/rpi_work/kernel_rpi/linux M=/home/llkd/booksrc/ch5/cross modules
make[1]: Entering directory '/home/llkd/rpi_work/kernel_rpi/linux' 
 WARNING: Symbol version dump ./Module.symvers
 is missing; modules will have no dependencies and modversions.

Building for: ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- EXTRA_CFLAGS= -DDEBUG
 CC [M] /home/llkd/book_llkd/Linux-Kernel-Programming/ch5/cross/helloworld_lkm.o
​  Building modules, stage 2.
  MODPOST 1 modules
  CC /home/llkd/booksrc/ch5/cross/helloworld_lkm.mod.o
  LD [M] /home/llkd/booksrc/ch5/cross/helloworld_lkm.ko
make[1]: Leaving directory '/home/llkd/rpi_work/kernel_rpi/linux'
$ file ./helloworld_lkm.ko 
./helloworld_lkm.ko: ELF 32-bit LSB relocatable, ARM, EABI5 version 1 (SYSV), BuildID[sha1]=17...e, not stripped
$

构建成功!helloworld_lkm.ko内核模块确实已经针对 ARM 架构进行了交叉编译(使用树莓派交叉工具链和内核源树)。

我们现在可以忽略关于Module.symvers文件的前面警告。因为(在这里)整个树莓派内核尚未构建。

另外,值得一提的是,在运行 GCC 9.x 或更高版本和内核版本为 4.9 或更高版本的最近主机上,会发出一些编译器属性警告。当我尝试使用arm-linux-gnueabihf-gcc版本 9.3.0 和树莓派内核版本 4.14.114 交叉编译这个内核模块时,会发出诸如此类的警告:

./include/linux/module.h:131:6: 警告:'init_module'指定的属性比其目标'helloworld_lkm_init'更少限制:'cold' [-Wmissing-attributes]

Miguel Ojeda 指出了这一点(lore.kernel.org/lkml/CANiq72=T8nH3HHkYvWF+vPMscgwXki1Ugiq6C9PhVHJUHAwDYw@mail.gmail.com/),甚至生成了一个处理此问题的补丁(github.com/ojeda/linux/commits/compiler-attributes-backport)。截至撰写本文时,该补丁已应用于内核主线和最近的树莓派内核(因此,rpi-5.4.y分支可以正常工作,但较早的分支,如rpi-4.9.y分支似乎没有)!因此会出现编译器警告...实际上,如果您看到这些警告,请将树莓派分支更新到rpi-5.4.y或更高版本(或者暂时忽略它们)。

  1. 然而,实践出真知。因此,我们启动树莓派,通过scp(1)将交叉编译的内核模块对象文件传输到它,然后在树莓派上的ssh(1)会话中尝试它(以下输出直接来自设备):
$ sudo insmod ./helloworld_lkm.ko insmod: ERROR: could not insert module ./helloworld_lkm.ko: Invalid module format $ 

很明显,前面代码中的insmod(8)失败了!重要的是要理解为什么。

这实际上与我们试图加载模块的内核版本不匹配以及模块已编译的内核版本有关。

  1. 在树莓派上登录后,打印出我们正在运行的当前树莓派内核版本,并使用modinfo(8)实用程序打印出有关内核模块本身的详细信息:
rpi ~ $ cat /proc/version 
Linux version 4.19.75-v7+ (dom@buildbot) (gcc version 4.9.3 (crosstool-NG crosstool-ng-1.22.0-88-g8460611)) #1270 SMP Tue Sep 24 18:45:11 BST 2019
rpi ~ $ modinfo ./helloworld_lkm.ko 
filename: /home/pi/./helloworld_lkm.ko
version: 0.1
license: Dual MIT/GPL
description: LLKD book:ch5/cross: hello, world, our first Raspberry Pi LKM
author: Kaiwan N Billimoria
srcversion: 7DDCE78A55CF6EDEEE783FF
depends: 
name: helloworld_lkm
vermagic: 5.4.51-v7+ SMP mod_unload modversions ARMv7 p2v8 
rpi ~ $ 

从前面的输出中,很明显,我们在树莓派上运行4.19.75-v7+内核。实际上,这是我在设备的 microSD 卡上安装默认Raspbian OS 时继承的内核(这是一个故意引入的场景,最初使用我们为树莓派早期构建的 5.4 内核)。另一方面,内核模块显示它已经针对5.4.51-v7+ Linux 内核进行了编译(来自modinfo(8)vermagic字符串显示了这一点)。很明显,存在不匹配。那又怎样呢?

Linux 内核有一个规则,是内核 应用二进制接口ABI)的一部分:只有当内核模块构建在它上面时,它才会将内核模块插入内核内存 - 精确的内核版本,构建标志,甚至内核配置选项都很重要!

构建的内核是您在 Makefile 中指定的内核源位置(我们之前通过KDIR变量这样做)。

换句话说,内核模块与其构建的内核之外的内核不兼容。例如,如果我们在 Ubuntu 18.04 LTS 上构建一个内核模块,那么它将在运行这个精确环境的系统上工作(库,内核或工具链)!它将在 Fedora 29 或 RHEL 7.x,树莓派等上工作。现在 - 再次思考一下 - 这并不意味着内核模块完全不兼容。不,它们在不同架构之间是源代码兼容的(至少它们可以或者应该被编写成这样)。因此,假设你有源代码,你总是可以在给定的系统上重新构建一个内核模块,然后它将在该系统上工作。只是二进制映像.ko文件)与其构建的精确内核之外的内核不兼容。

放松,这个问题实际上很容易发现。查看内核日志:

$ dmesg |tail -n2 [ 296.130074] helloworld_lkm: no symbol version for module_layout
[ 296.130093] helloworld_lkm: version magic '5.4.51-v7+ mod_unload modversions ARMv6 p2v8 ' should be '4.19.75-v7+ SMP mod_unload modversions ARMv7 p2v8 ' $ 

在设备上,当前运行的内核是:4.19.75-v7+。内核直接告诉我们,我们的内核模块已经构建在5.4.51-v7+内核版本上(它还显示了一些预期的内核配置)以及它应该是什么。存在不匹配!因此无法插入内核模块。

虽然我们在这里不使用这种方法,但是有一种方法可以确保成功构建和部署第三方的内核模块(只要它们的源代码是可用的),通过一个名为DKMS动态内核模块支持)的框架。以下是直接从中引用的一句话:

动态内核模块支持(DKMS)是一个启用生成 Linux 内核模块的程序/框架其源代码通常驻留在内核源树之外。其概念是在安装新内核时自动重建 DKMS 模块。

作为 DKMS 使用的一个例子,Oracle VirtualBox hypervisor(在 Linux 主机上运行时)使用 DKMS 自动构建和保持其内核模块的最新状态。

尝试 4 - 交叉编译我们的内核模块

因此,现在我们了解了问题,有两种可能的解决方案:

  • 我们必须使用产品所需的自定义配置内核,并构建所有我们的内核模块。

  • 或者,我们可以重建内核模块以匹配当前运行的内核设备。

现在,在典型的嵌入式 Linux 项目中,您几乎肯定会为目标设备拥有一个自定义配置的内核,您必须与之一起工作。产品的所有内核模块将/必须构建在其上。因此,我们遵循第一种方法 - 我们必须使用我们自定义配置和构建的(5.4!)内核引导设备,因为我们的内核模块是构建在其上的,现在它应该肯定可以工作。

我们(简要地)在第三章中涵盖了树莓派的内核构建,从源代码构建 5.x Linux 内核 - 第二部分。如果需要,可以返回那里查看详细信息。

好的,我将假设您已经按照第三章中涵盖的步骤,并且现在已经为树莓派配置和构建了一个 5.4 内核。关于如何将我们的自定义zImage复制到设备的 microSD 卡等细节在这里没有涵盖。我建议您查看官方的树莓派文档:www.raspberrypi.org/documentation/linux/kernel/building.md

尽管如此,我们将指出一种方便的方法来在设备上切换内核(这里,我假设设备是运行 32 位内核的树莓派 3B+):

  1. 将您定制构建的zImage内核二进制文件复制到设备的 microSD 卡的/boot分区。将原始的 Raspberry Pi 内核映像 - Raspbian 内核映像 - 保存为kernel7.img.orig

  2. 从主机系统上复制(scp)刚刚交叉编译的内核模块(ARM 上的helloworld_lkm.ko,在上一节中完成)到 microSD 卡(通常是/home/pi)。

  3. 接下来,再次在设备的 microSD 卡上,编辑/boot/config.txt文件,通过kernel=xxx行设置内核引导。设备上的此文件片段显示了这一点:

rpi $ cat /boot/config.txt
[...]
# KNB: enable the UART (for the adapter cable: USB To RS232 TTL UART 
# PL2303HX Converter USB to COM)
enable_uart=1
# KNB: select the kernel to boot from via kernel=xxx
#kernel=kernel7.img.orig
kernel=zImage
rpi $ 
  1. 保存并重新启动后,我们登录到设备并重试我们的内核模块。图 5.2 是一个屏幕截图,显示了刚刚交叉编译的helloworld_lkm.ko内核模块在树莓派设备上的使用:

图 5.2 - 在树莓派上使用的交叉编译的 LKM

啊,成功了!请注意,这次当前内核版本(5.4.51-v7+)与模块构建时的内核版本完全匹配 - 在modinfo(8)输出中,我们可以看到vermagic字符串显示为5.4.51-v7+

如果您看到rmmod(8)出现非致命错误(尽管清理钩子仍然被调用),原因是您尚未完全在设备上设置新构建的内核。您将不得不复制所有内核模块(位于/lib/modules/<kernel-ver>下)并在那里运行depmod(8)实用程序。在这里,我们不会深入探讨这些细节 - 如前所述,树莓派的官方文档涵盖了所有这些步骤。

当然,树莓派是一个非常强大的系统;您可以在树莓派上安装(默认的)Raspbian 操作系统以及开发工具和内核头文件,从而在板上编译内核模块!(无需交叉编译。)然而,在这里,我们遵循了交叉编译的方法,因为这在嵌入式 Linux 项目中很典型。

LKM 框架是一个相当庞大的工作。还有很多需要探索的地方。让我们开始吧。在下一节中,我们将研究如何从内核模块中获取一些最小的系统信息。

收集最小的系统信息

在我们上一节的简单演示中(ch5/cross/helloworld_lkm.c),我们已经硬编码了一个printk()来发出一个"Hello/Goodbye, Raspberry Pi world\n"字符串,无论内核模块是否真的在树莓派设备上运行。为了更好地“检测”一些系统细节(如 CPU 或操作系统),我们建议您参考我们的样本ch5/min_sysinfo/min_sysinfo.c内核模块。在下面的代码片段中,我们只显示相关函数:

// ch5/min_sysinfo/min_sysinfo.c
[ ... ]
void llkd_sysinfo(void)
{
    char msg[128];

    memset(msg, 0, strlen(msg));
    snprintf(msg, 47, "%s(): minimal Platform Info:\nCPU: ", __func__);

    /* Strictly speaking, all this #if... is considered ugly and should be
     * isolated as far as is possible */
#ifdef CONFIG_X86
#if(BITS_PER_LONG == 32)
    strncat(msg, "x86-32, ", 9);
#else
    strncat(msg, "x86_64, ", 9);
#endif
#endif
#ifdef CONFIG_ARM
    strncat(msg, "ARM-32, ", 9);
#endif
#ifdef CONFIG_ARM64
    strncat(msg, "Aarch64, ", 10);
#endif
#ifdef CONFIG_MIPS
    strncat(msg, "MIPS, ", 7);
#endif
#ifdef CONFIG_PPC
    strncat(msg, "PowerPC, ", 10);
#endif
#ifdef CONFIG_S390
    strncat(msg, "IBM S390, ", 11);
#endif

#ifdef __BIG_ENDIAN
    strncat(msg, "big-endian; ", 13);
#else
    strncat(msg, "little-endian; ", 16);
#endif

#if(BITS_PER_LONG == 32)
    strncat(msg, "32-bit OS.\n", 12);
#elif(BITS_PER_LONG == 64)
    strncat(msg, "64-bit OS.\n", 12);
#endif
    pr_info("%s", msg);

  show_sizeof();
 /* Word ranges: min & max: defines are in include/linux/limits.h */
 [ ... ]
}
EXPORT_SYMBOL(lkdc_sysinfo);

(此 LKM 显示的其他细节 - 如各种原始数据类型的大小和字范围 - 这里没有显示;请参考我们的 GitHub 存储库中的源代码并自行尝试。)前面的内核模块代码是有益的,因为它有助于演示如何编写可移植的代码。请记住,内核模块本身是一个二进制的不可移植的目标文件,但它的源代码可能(也许应该,取决于您的项目)以这样一种方式编写,以便在各种架构上都是可移植的。然后在目标架构上进行简单的构建(或为目标架构构建)将使其准备好部署。

现在,请忽略此处使用的EXPORT_SYMBOL()宏。我们将很快介绍其用法。

在我们现在熟悉的 x86_64 Ubuntu 18.04 LTS 客户机上构建并运行它,我们得到了这个输出:

$ cd ch5/min_sysinfo
$ make
[...]
$ sudo insmod ./min_sysinfo.ko 
$ dmesg
[...]
[29626.257341] min_sysinfo: inserted
[29626.257352] llkd_sysinfo(): minimal Platform Info:
              CPU: x86_64, little-endian; 64-bit OS.
$ 

太棒了!类似地(如前面演示的),我们可以为 ARM-32(树莓派)交叉编译这个内核模块,然后将交叉编译的内核模块传输(scp(1))到我们的树莓派目标并在那里运行(以下输出来自运行 32 位 Raspbian OS 的树莓派 3B+):

$ sudo insmod ./min_sysinfo.ko
$ dmesg
[...]
[    80.428363] min_sysinfo: inserted
[    80.428370] llkd_sysinfo(): minimal Platform Info:
               CPU: ARM-32, little-endian; 32-bit OS.
$

事实上,这揭示了一些有趣的事情;树莓派 3B+拥有本地64 位 CPU,但默认情况下(截至撰写本文时)运行 32 位操作系统,因此出现了前面的输出。我们将留给你在树莓派(或其他设备)上安装 64 位 Linux 操作系统,并重新运行这个内核模块。

强大的Yocto 项目www.yoctoproject.org/)是一种(行业标准)生成树莓派 64 位操作系统的方法。另外(也更容易快速尝试),Ubuntu 为该设备提供了自定义的 Ubuntu 64 位内核和根文件系统(wiki.ubuntu.com/ARM/RaspberryPi)。

更加注重安全性

当然,安全性是当今的一个关键问题。专业开发人员被期望编写安全的代码。近年来,针对 Linux 内核已经有许多已知的漏洞利用(有关更多信息,请参阅进一步阅读部分)。与此同时,许多工作正在进行中,以改进 Linux 内核的安全性。

在我们之前的内核模块(ch5/min_sysinfo/min_sysinfo.c)中,要注意使用旧式的例程(比如sprintfstrlen等等;是的,在内核中存在这些)!静态分析器可以在捕获潜在的与安全相关的和其他错误方面大有裨益;我们强烈建议您使用它们。第一章,内核工作区设置,提到了内核的几种有用的静态分析工具。在下面的代码中,我们使用了我们“更好”的Makefile中的sa目标之一来运行一个相对简单的静态分析器:flawfinder(1)(由 David Wheeler 编写):

$ make [tab][tab] all        clean      help       install     sa_cppcheck    sa_gcc    
tarxz-pkg  checkpatch code-style indent      sa             sa_flawfinder sa_sparse $ make sa_flawfinder 
make clean
make[1]: Entering directory '/home/llkd/llkd_book/Linux-Kernel-Programming/ch5/min_sysinfo'

--- cleaning ---

[...]

--- static analysis with flawfinder ---

flawfinder *.c
Flawfinder version 1.31, (C) 2001-2014 David A. Wheeler.
Number of rules (primarily dangerous function names) in C/C++ ruleset: 169
Examining min_sysinfo.c

FINAL RESULTS:

min_sysinfo.c:60: [2] (buffer) char:
  Statically-sized arrays can be improperly restricted, leading to potential overflows or other issues (CWE-119:CWE-120). Perform bounds checking, use functions that limit length, or ensure that the size is larger than the maximum possible length.

[...]

min_sysinfo.c:138: [1] (buffer) strlen:
  Does not handle strings that are not \0-terminated; if given one it may
  perform an over-read (it could cause a crash if unprotected) (CWE-126).
[...]

仔细看一下flawfinder(1)发出的警告,特别是关于strlen()函数的警告(它生成了许多警告!)。在这里我们确实面临这种情况!记住,未初始化的局部变量(比如我们的msg缓冲区)在声明时具有随机内容。因此,strlen()函数可能会产生我们期望的值,也可能不会。

flawfinder的输出甚至提到了CWE编号(在这里是 CWE-126),表示在这里看到的一般类的安全问题;(搜索一下你会看到详细信息。在这种情况下,CWE-126 代表缓冲区过读问题:cwe.mitre.org/data/definitions/126.html)。

同样,我们避免使用strncat(),并用strlcat()函数替换它。因此,考虑到安全性问题,我们将llkd_sysinfo()函数的代码重写为llkd_sysinfo2()

我们还添加了一些代码行,以显示平台上无符号和有符号变量的范围(最小值、最大值)(以 10 进制和 16 进制表示)。我们留给你来阅读。作为一个简单的任务,运行这个内核模块在你的 Linux 设备上,并验证输出。

现在,让我们继续讨论一下 Linux 内核和内核模块代码的许可问题。

许可内核模块

众所周知,Linux 内核代码本身是根据 GNU GPL v2(也称为 GPL-2.0;GPL 代表通用公共许可证)许可的,就大多数人而言,将保持这种状态。如前所述,在第四章中,编写您的第一个内核模块 - LKMs 第一部分,许可您的内核代码是必需且重要的。基本上,至少对于我们的目的来说,讨论的核心是:如果您的意图是直接使用内核代码和/或向主线内核贡献您的代码(接下来会有一些说明),您必须以与 Linux 内核发布的相同许可证发布代码:GNU GPL-2.0。对于内核模块,情况仍然有点“灵活”,我们可以这么说。无论如何,为了与内核社区合作并得到他们的帮助(这是一个巨大的优势),您应该或者预期将代码发布为 GNU GPL-2.0 许可证(尽管双重许可证当然是可能和可接受的)。

使用MODULE_LICENSE()宏来指定许可证。从内核头文件include/linux/module.h中复制的以下注释清楚地显示了哪些许可证“标识”是可接受的(请注意双重许可)。显然,内核社区强烈建议将内核模块发布为 GPL-2.0(GPL v2)和/或其他许可证,如 BSD/MIT/MPL。如果您打算向内核主线贡献代码,毫无疑问,单独的 GPL-2.0 就是要发布的许可证:

// include/linux/module.h
[...]
/*
 * The following license idents are currently accepted as indicating free
 * software modules
 *
 * "GPL"                       [GNU Public License v2 or later]
 * "GPL v2"                    [GNU Public License v2]
 * "GPL and additional rights" [GNU Public License v2 rights and more]
 * "Dual BSD/GPL"              [GNU Public License v2
 *                              or BSD license choice]
 * "Dual MIT/GPL"              [GNU Public License v2
 *                              or MIT license choice]
 * "Dual MPL/GPL"              [GNU Public License v2
 *                              or Mozilla license choice]
 *
 * The following other idents are available
 *
 * "Proprietary" [Non free products]
 *
 * There are dual licensed components, but when running with Linux it is the GPL that is relevant so this is a non issue. Similarly LGPL linked with GPL is a GPL combined work.
 *
 * This exists for several reasons
 * 1\. So modinfo can show license info for users wanting to vet their setup is free
 * 2\. So the community can ignore bug reports including proprietary modules
 * 3\. So vendors can do likewise based on their own policies
 */
#define MODULE_LICENSE(_license) MODULE_INFO(license, _license)
[...]

顺便说一句,内核源代码树有一个LICENSES/目录,在其中您将找到有关许可证的详细信息;在这个文件夹上快速执行ls命令会显示其中的子文件夹:

$ ls <...>/linux-5.4/LICENSES/
deprecated/ dual/ exceptions/ preferred/

我们将留给您去查看,并且将讨论许可证的内容到此为止;现实情况是,这是一个需要法律知识的复杂话题。您最好咨询公司内的专业法律人员(律师)(或者雇佣他们)以确保您的产品或服务的法律角度正确。

在这个话题上,为了保持一致,最近的内核有一个规定:每个单独的源文件的第一行必须是一个 SPDX 许可证标识符(详见spdx.org/)。当然,脚本需要第一行指定解释器。此外,一些关于 GPL 许可证的常见问题的答案可以在这里找到:www.gnu.org/licenses/gpl-faq.html

有关许可模型、不滥用MODULE_LICENSE宏,特别是多许可证/双许可证的更多信息,请参阅本章“进一步阅读”部分提供的链接。现在,让我们回到技术方面。下一节将解释如何在内核空间有效地模拟类库功能。

在内核模块中模拟“类库”功能

用户模式和内核模式编程之间的一个主要区别是后者完全没有熟悉的“库”概念。库本质上是 API 的集合或存档,方便开发人员实现重要目标,通常包括:不要重复造轮子、软件重用、模块化等。但在 Linux 内核中,库根本不存在。

然而,好消息是,大体上说,有两种技术可以在内核空间为我们的内核模块实现“类库”功能:

  • 第一种技术:显式“链接”多个源文件(包括“库”代码)到您的内核模块对象中。

  • 第二个被称为模块堆叠。

请继续阅读,我们将更详细地讨论这些技术。也许有点剧透,但立即了解的话会很有用:前面的技术中的第一种通常优于第二种。不过,这取决于项目。请在下一节中阅读详细信息;我们将在进行时列出一些优缺点。

通过多个源文件执行库模拟

到目前为止,我们处理的内核模块都只有一个 C 源文件。那么对于(相当典型的)现实世界中存在多个 C 源文件的单个内核模块的情况呢?所有源文件都必须被编译,然后链接在一起成为一个.ko二进制对象。

例如,假设我们正在构建一个名为projx的内核模块项目。它由三个 C 源文件组成:prj1.c, prj2.cprj3.c。我们希望最终的内核模块被称为projx.ko。Makefile 是您指定这些关系的地方,如下所示:

obj-m      := projx.o
projx-objs := prj1.o prj2.o prj3.o

在上述代码中,请注意projx标签在obj-m指令之后和作为前缀使用的情况

-objs指令在下一行。当然,您可以使用任何标签。我们之前的示例将使内核构建系统将三个单独的 C 源文件编译为单独的目标(.o)文件,然后将它们链接在一起,形成最终的二进制内核模块对象文件,projx.ko,正如我们所期望的那样。

我们可以利用这种机制在我们书籍的源树中构建一个小的例程“库”(此处的“内核库”源文件位于源树的根目录中:klib_llkd.hklib_llkd.c)。其想法是其他内核模块可以通过链接到这里的函数来使用这里的函数!例如,在即将到来的第七章,内存管理内部 - 基本知识中,我们的ch7/lowlevel_mem/lowlevel_mem.c内核模块代码调用了我们库代码中的一个函数,../../klib_llkd.c。所谓的“链接到”我们所谓的“库”代码是通过将以下内容放入lowlevel_mem内核模块的 Makefile 中实现的:

obj-m                 += lowlevel_mem_lib.o
lowlevel_mem_lib-objs := lowlevel_mem.o ../../klib_llkd.o

第二行指定要构建的源文件(成为目标文件);它们是lowlevel_mem.c内核模块的代码和../../klib_llkd库代码。然后,将它们链接成一个单一的二进制内核模块,lowlevel_mem_lib.ko,实现我们的目标。(为什么不在本章末尾的问题部分中处理指定的作业 5.1。)

了解内核模块中的函数和变量作用域

在深入研究之前,快速回顾一些基础知识是个好主意。在使用 C 进行编程时,您应该了解以下内容:

  • 在函数内声明的变量显然只在函数内部可见,并且仅在该函数内部具有作用域。

  • 使用static限定符前缀的变量和函数仅在当前“单元”内具有作用域;实际上是在它们被声明的文件内。这很好,因为它有助于减少命名空间污染。静态(和全局)数据变量在该函数内保留其值。

在 2.6 Linux 之前(即<= 2.4.x,现在是古代历史),内核模块的静态和全局变量以及所有函数都会自动在整个内核中可见。回顾起来,这显然不是一个好主意。从 2.5 开始(因此 2.6 及以后的现代 Linux)决定反转:所有内核模块变量(静态和全局数据)和函数默认范围仅限于其内核模块,并且因此在外部不可见。因此,如果两个内核模块lkmAlkmB有一个名为maya的全局变量,它对每个模块都是唯一的;不会发生冲突。

要更改作用域,LKM 框架提供了EXPORT_SYMBOL()宏。使用它,您可以声明数据项或函数为全局作用域 - 实际上,对所有其他内核模块以及内核核心可见。

让我们举一个简单的例子。我们有一个名为prj_core的内核模块,其中包含一个全局变量和一个函数:

static int my_glob = 5;
static long my_foo(int key)
{ [...]
}

尽管两者都可以在这个内核模块内部使用,但在外部都看不到。这是有意为之的。为了使它们在这个内核模块外部可见,我们可以导出它们:

int my_glob = 5;
EXPORT_SYMBOL(my_glob);

long my_foo(int key)
{ [...]
}
EXPORT_SYMBOL(my_foo);

现在,这两者都在这个内核模块之外具有作用域(请注意,在前面的代码块中,static关键字已经被故意删除)。其他内核模块(以及核心内核)现在可以“看到”并使用它们。确切地说,这个想法以两种广泛的方式得到了利用:

  • 首先,内核导出了一个经过深思熟虑的全局变量和函数的子集,这些变量和函数构成了其核心功能的一部分,也是其他子系统的一部分。现在,这些全局变量和函数是可见的,因此可以从内核模块中使用!我们很快将看到一些示例用法。

  • 其次,内核模块作者(通常是设备驱动程序)使用这个概念来导出某些数据和/或功能,以便其他内核模块在更高的抽象级别上可以利用这个设计并使用这些数据和/或功能 - 这个概念被称为模块堆叠,我们将很快通过一个例子来深入探讨它。

例如,对于第一个用例,设备驱动程序的作者可能希望处理来自外围设备的硬件中断。通常的做法是通过request_irq()API 来实现,实际上,这个 API 只是对这个 API 的一个薄包装(内联):

// kernel/irq/manage.c
int request_threaded_irq(unsigned int irq, irq_handler_t handler,
                         irq_handler_t thread_fn, unsigned long irqflags,
                         const char *devname, void *dev_id)
{
    struct irqaction *action;
[...]
    return retval;
}
EXPORT_SYMBOL(request_threaded_irq);

正因为request_threaded_irq()函数是导出的,它可以从设备驱动程序中调用,而设备驱动程序往往是作为内核模块编写的。同样,开发人员经常需要一些“便利”例程 - 例如,字符串处理例程。Linux 内核在lib/string.c中提供了几个常见字符串处理函数的实现(您期望它们存在):str[n]casecmpstr[n|l|s]cpystr[n|l]catstr[n]cmpstrchr[nul]str[n|r]chrstr[n]len等等。当然,这些都是通过EXPORT_SYMBOL()导出的,以使它们可见,从而可供模块作者使用。

在这里,我们使用str[n|l|s]cpy表示内核提供了四个函数:strcpystrncpystrlcpystrscpy。请注意,一些接口可能已被弃用(strcpy()strncpy()strlcpy())。一般来说,始终避免使用此处记录的弃用内容:弃用接口、语言特性、属性和约定www.kernel.org/doc/html/latest/process/deprecated.html#deprecated-interfaces-language-features-attributes-and-conventions)。

另一方面,让我们来看一下内核核心深处的CFS完全公平调度器)调度代码的一小部分。在这里,当调度代码需要找到另一个任务进行上下文切换时,会调用pick_next_task_fair()函数:

// kernel/sched/fair.c
static struct task_struct *
pick_next_task_fair(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
{
        struct cfs_rq *cfs_rq = &rq->cfs;
[...]
        if (new_tasks > 0)
                goto again;
        return NULL;
}

我们这里并不真的想研究调度(第十章,CPU 调度器 - 第一部分,和第十一章,CPU 调度器 - 第二部分,已经涵盖了它),这里的重点是:由于前面的函数没有EXPORT_SYMBOL()宏标记,它永远不能被内核模块调用。它仍然是核心内核的私有

您还可以使用相同的宏将数据结构标记为已导出。此外,显而易见,只有全局范围的数据 - 而不是局部变量 - 可以被标记为已导出。

如果您想了解EXPORT_SYMBOL()宏的工作原理,请参考本章的进一步阅读部分,其中链接到了本书的 GitHub 存储库。

回想一下我们对内核模块许可的简要讨论。Linux 内核有一个,我们可以说,有趣的命题:还有一个名为EXPORT_SYMBOL_GPL()的宏。它就像它的表兄弟EXPORT_SYMBOL()宏一样,只是,是的,导出的数据项或函数只对那些在他们的MODULE_LICENSE()宏中包含GPL一词的内核模块可见!啊,内核社区的甜蜜复仇。它确实在内核代码库的几个地方使用。(我会把这留给你作为一个练习,在代码中找到这个宏的出现;在 5.4.0 内核上,使用cscope(1)进行快速搜索,发现“只有”14,000 多个使用实例!)

要查看所有导出的符号,请导航到内核源树的根目录,并发出make export_report命令。请注意,这仅适用于已配置和构建的内核树。

现在让我们看一下实现类似库的内核特性的另一个关键方法:模块堆叠。

理解模块堆叠

这里的第二个重要想法- 模块堆叠 - 是我们现在将进一步深入研究的。

模块堆叠是一个概念,为内核模块作者提供了类似“库”的功能。在这里,我们通常以这样的方式设计我们的项目或产品,有一个或多个“核心”内核模块,其工作是充当某种库。它将包括数据结构和功能(函数/API),这些将被导出到其他内核模块(前面的部分讨论了符号的导出)。

为了更好地理解这一点,让我们看一些真实的例子。首先,在我的主机系统上,一个 Ubuntu 18.04.3 LTS 本机 Linux 系统上,我在Oracle VirtualBox 6.1虚拟化应用程序上运行了一个或多个客户 VM。好的,在主机系统上执行快速的lsmod(8),同时过滤字符串vbox,会显示如下内容:

$ lsmod | grep vbox
vboxnetadp             28672  0
vboxnetflt             28672  1
vboxdrv               479232  3 vboxnetadp,vboxnetflt
$ 

回想一下我们之前的讨论,第三列是使用计数。在第一行中是0,但在第三行中是3。不仅如此,vboxdrv内核模块右侧列出了两个内核模块。如果任何内核模块出现在第三列之后,它们代表依赖关系;这样读:右侧显示的内核模块依赖于左侧的内核模块。

因此,在前面的例子中,vboxnetadpvboxnetflt内核模块依赖于vboxdrv内核模块。以什么方式依赖它?当然是使用vboxdrv核心内核模块中的数据结构和/或功能(API)!一般来说,出现在第三列右侧的内核模块意味着它们使用左侧内核模块的一个或多个数据结构和/或功能(导致使用计数的增加;这个使用计数是一个引用计数器的很好例子(这里,它实际上是一个 32 位原子变量),这是我们在最后一章中深入讨论的内容)。实际上,vboxdrv内核模块类似于一个“库”(在有限的意义上,与用户模式库相关的通常含义除外,除了提供模块化功能)。您可以看到,在这个快照中,它的使用计数是3,依赖它的内核模块堆叠在它的上面-字面上!(您可以在lsmod(1)输出的前两行中看到它们。)另外,请注意,vboxnetflt内核模块有一个正的使用计数(1),但在它的右侧没有内核模块显示;这仍然意味着某些东西目前在使用它,通常是一个进程或线程。

FYI,我们在这个示例中看到的Oracle VirtualBox内核模块实际上是VirtualBox Guest Additions的实现。它们本质上是一种半虚拟化构造,有助于加速客户 VM 的工作。Oracle VirtualBox 也为 Windows 和 macOS 主机提供类似的功能(所有主要的虚拟化供应商也是如此)。

作为承诺的模块堆叠的另一个例子:运行强大的LTTngLinux Tracing Toolkit next generation)框架使您能够执行详细的系统分析。LTTng 项目安装和使用了相当多的内核模块(通常是 40 个或更多)。其中一些内核模块是“堆叠”的,允许项目精确利用我们在这里讨论的“类似库”的功能。

在下图中(在 Ubuntu 18.04.4 LTS 系统上安装了 LTTng 后),查看lsmod | grep --color=auto "^lttng"输出的部分截图,涉及其内核模块:

图 5.3 - LTTng 产品中的大量模块堆叠

可以看到,lttng_tracer内核模块右侧有 35 个内核模块,表示它们“堆叠”在其上,使用它提供的功能(类似地,lttng_lib_ring_buffer内核模块有 23 个内核模块“依赖”它)。

这里有一些快速的脚本魔法,可以查看所有使用计数非零的内核模块(它们通常 - 但并不总是 - 有一些依赖的内核模块显示在它们的右侧):

lsmod | awk '$3 > 0 {print $0}'

模块堆叠的一个含义是:只有在使用计数为0时,才能成功地rmmod(8)一个内核模块;也就是说,它没有在使用中。因此,对于前面的第一个示例,我们只能在移除两个依赖它的内核模块之后(从而将使用计数减少到0)才能移除vboxdrv内核模块。

尝试模块堆叠

让我们为模块堆叠构建一个非常简单的概念验证代码。为此,我们将构建两个内核模块:

  • 第一个我们将称为core_lkm;它的工作是充当一种“库”,为内核和其他模块提供一些函数(API)。

  • 我们的第二个内核模块user_lkm是“用户”(或消费者)“库”的使用者;它将简单地调用第一个内核模块中的函数(并使用一些数据)。

为了做到这一点,我们的一对内核模块需要做到以下几点:

  • 核心内核模块必须使用EXPORT_SYMBOL()宏将一些数据和函数标记为导出

  • 用户内核模块必须声明其期望使用的数据和/或函数为外部数据,通过 C 的extern关键字(请记住,导出数据或功能只是设置适当的链接;编译器仍然需要知道被调用的数据和/或函数)。

  • 使用最近的工具链,允许将导出的函数和数据项标记为static。但会产生一个警告;我们不使用static关键字来导出符号。

  • 编辑自定义 Makefile 以构建两个内核模块。

代码如下;首先是核心或库内核模块。为了(希望)使其更有趣,我们将把之前一个模块的函数代码 - ch5/min_sysinfo/min_sysinfo.c:llkd_sysinfo2() - 复制到这个内核模块中,并导出它,从而使其对我们的第二个“用户”LKM 可见,后者将调用该函数:

这里我们不显示完整的代码;您可以参考本书的 GitHub 存储库。

// ch5/modstacking/core_lkm.c
#define pr_fmt(fmt) "%s:%s(): " fmt, KBUILD_MODNAME, __func__
#include <linux/init.h>
#include <linux/module.h>

#define MODNAME     "core_lkm"
#define THE_ONE     0xfedface
MODULE_LICENSE("Dual MIT/GPL");

int exp_int = 200;
EXPORT_SYMBOL_GPL(exp_int);

/* Functions to be called from other LKMs */
void llkd_sysinfo2(void)
{
[...]
}
EXPORT_SYMBOL(llkd_sysinfo2);

#if(BITS_PER_LONG == 32)
u32 get_skey(int p)
#else // 64-bit
u64 get_skey(int p)
#endif
{
#if(BITS_PER_LONG == 32)
    u32 secret = 0x567def;
#else // 64-bit
    u64 secret = 0x123abc567def;
#endif
    if (p == THE_ONE)
        return secret;
    return 0;
}
EXPORT_SYMBOL(get_skey);
[...]

接下来是user_lkm内核模块,它是“堆叠”在core_lkm内核模块之上的一个:

// ch5/modstacking/user_lkm.c
#define pr_fmt(fmt) "%s:%s(): " fmt, KBUILD_MODNAME, __func__
#define MODNAME "user_lkm"

#if 1
MODULE_LICENSE("Dual MIT/GPL");
#else
MODULE_LICENSE("MIT");
#endif

extern void llkd_sysinfo2(void);
extern long get_skey(int);
extern int exp_int;

/* Call some functions within the 'core' module */
static int __init user_lkm_init(void)
{
#define THE_ONE 0xfedface
     pr_info("%s: inserted\n", MODNAME);
     u64 sk = get_skey(THE_ONE);
     pr_debug("%s: Called get_skey(), ret = 0x%llx = %llu\n",
             MODNAME, sk, sk);
     pr_debug("%s: exp_int = %d\n", MODNAME, exp_int);
 llkd_sysinfo2();
     return 0;
}

static void __exit user_lkm_exit(void)
{
    pr_info("%s: bids you adieu\n", MODNAME);
}
module_init(user_lkm_init);
module_exit(user_lkm_exit);

Makefile 基本上与我们之前的内核模块相同,只是这次我们需要构建两个内核模块对象,如下所示:

obj-m     := core_lkm.o
obj-m     += user_lkm.o

好的,让我们试一下:

  1. 首先,构建内核模块:
$ make

--- Building : KDIR=/lib/modules/5.4.0-llkd02-kasan/build ARCH= CROSS_COMPILE= EXTRA_CFLAGS=-DDEBUG ---

make -C /lib/modules/5.4.0-llkd02-kasan/build M=/home/llkd/booksrc/ch5/modstacking modules
make[1]: Entering directory '/home/llkd/kernels/linux-5.4'
  CC [M] /home/llkd/booksrc/ch5/modstacking/core_lkm.o
  CC [M] /home/llkd/booksrc/ch5/modstacking/user_lkm.o
  [...]
  Building modules, stage 2.
  MODPOST 2 modules
  CC [M] /home/llkd/booksrc/ch5/modstacking/core_lkm.mod.o
  LD [M] /home/llkd/booksrc/ch5/modstacking/core_lkm.ko
  CC [M] /home/llkd/booksrc/ch5/modstacking/user_lkm.mod.o
  LD [M] /home/llkd/booksrc/ch5/modstacking/user_lkm.ko
make[1]: Leaving directory '/home/llkd/kernels/linux-5.4'
$ ls *.ko
core_lkm.ko  user_lkm.ko
$ 

请注意,我们正在针对我们自定义的 5.4.0 内核构建我们的内核模块。请注意其完整版本是5.4.0-llkd02-kasan;这是故意的。这是我构建并用作测试平台的“调试内核”!

  1. 现在,让我们进行一系列快速测试,以演示模块堆叠概念的证明。让我们首先错误地进行:我们将首先尝试在插入core_lkm模块之前插入user_lkm内核模块。

这将失败-为什么?您将意识到user_lkm内核模块依赖的导出功能(和数据)尚未(尚未)在内核中可用。更具体地说,符号将不会位于内核的符号表中,因为具有这些符号的core_lkm内核模块尚未插入:

$ sudo dmesg -C
$ sudo insmod ./user_lkm.ko 
insmod: ERROR: could not insert module ./user_lkm.ko: Unknown symbol in module
$ dmesg 
[13204.476455] user_lkm: Unknown symbol exp_int (err -2)
[13204.476493] user_lkm: Unknown symbol get_skey (err -2)
[13204.476531] user_lkm: Unknown symbol llkd_sysinfo2 (err -2)
$ 

正如预期的那样,由于所需的(要导出的)符号不可用,insmod(8)失败(您在内核日志中看到的精确错误消息可能会略有不同,这取决于内核版本和设置的调试配置选项)。

  1. 现在,让我们做对:
$ sudo insmod ./core_lkm.ko 
$ dmesg 
[...]
[19221.183494] core_lkm: inserted
$ sudo insmod ./user_lkm.ko 
$ dmesg 
[...]
[19221.183494] core_lkm:core_lkm_init(): inserted
[19242.669208] core_lkm:core_lkm_init(): /home/llkd/book_llkd/Linux-Kernel-Programming/ch5/modstacking/core_lkm.c:get_skey():100: I've been called
[19242.669212] user_lkm:user_lkm_init(): inserted
[19242.669217] user_lkm:user_lkm:user_lkm_init(): Called get_skey(), ret = 0x123abc567def = 20043477188079
[19242.669219] user_lkm:user_lkm_init(): exp_int = 200
[19242.669223] core_lkm:llkd_sysinfo2(): minimal Platform Info:
 CPU: x86_64, little-endian; 64-bit OS.
$ 
  1. 它按预期工作!使用lsmod(8)检查模块列表:
$ lsmod | egrep "core_lkm|user_lkm"
user_lkm               20480  0
core_lkm               16384  1 user_lkm
$ 

请注意,对于core_lkm内核模块,使用计数列已增加到1并且现在我们可以看到user_lkm内核模块依赖于core_lkm。回想一下,在lsmod输出的极右列中显示的内核模块依赖于极左列中的内核模块。

  1. 现在,让我们删除内核模块。删除内核模块也有顺序依赖性(就像插入一样)。首先尝试删除core_lkm失败,因为显然,仍然有另一个模块在内核内存中依赖其代码/数据;换句话说,它仍在使用中:
$ sudo rmmod core_lkm 
rmmod: ERROR: Module core_lkm is in use by: user_lkm
$ 

请注意,如果模块安装到系统上,那么您可以使用modprobe -r <modules...>命令来删除所有相关模块;我们将在系统引导时自动加载模块部分中介绍这个主题。

  1. 前面的rmmod(8)失败消息是不言自明的。因此,让我们做对:
$ sudo rmmod user_lkm core_lkm 
$ dmesg 
[...]
 CPU: x86_64, little-endian; 64-bit OS.
[19489.717265] user_lkm:user_lkm_exit(): bids you adieu
[19489.732018] core_lkm:core_lkm_exit(): bids you adieu
$ 

好了!

您将注意到在user_lkm内核模块的代码中,我们发布的许可是在条件#if语句中:

#if 1
MODULE_LICENSE("Dual MIT/GPL");
#else
MODULE_LICENSE("MIT");
#endif

我们可以看到它(默认)以双 MIT/GPL许可发布;那又怎样?想一想:在core_lkm内核模块的代码中,我们有以下内容:

int exp_int = 200;
EXPORT_SYMBOL_GPL(exp_int);

exp_int整数仅对在 GPL 许可下运行的内核模块可见。因此,请尝试更改core_lkm中的#if 1语句为#if 0,从而现在仅在 MIT 许可下发布它。现在,重新构建并重试。它在构建阶段本身失败

$ make
[...]
Building for: kver=5.4.0-llkd01 ARCH=x86 CROSS_COMPILE= EXTRA_CFLAGS=-DDEBUG
  Building modules, stage 2.
  MODPOST 2 modules
FATAL: modpost: GPL-incompatible module user_lkm.ko uses GPL-only symbol 'exp_int'
[...]
$ 

许可确实很重要!在结束本节之前,这里是模块堆叠可能出错的一些事项的快速清单;也就是说,要检查的事项:

  • 插入/删除时内核模块的错误顺序

  • 尝试插入已经在内核内存中的导出例程-名称空间冲突问题:

$ sudo insmod ./min_sysinfo.ko
[...]
$ cd ../modstacking ; sudo insmod ./core_lkm.ko
insmod: ERROR: could not insert module ./core_lkm.ko: Invalid module format
$ dmesg
[...]
[32077.823472] core_lkm: exports duplicate symbol llkd_sysinfo2 (owned by min_sysinfo)
$ sudo rmmod min_sysinfo
$ sudo insmod ./core_lkm.ko * # now it's ok*
  • 由于使用EXPORT_SYMBOL_GPL()宏引起的许可问题

始终查看内核日志(使用dmesg(1)journalctl(1))。它经常有助于显示实际出了什么问题。

因此,让我们总结一下:为了在内核模块空间中模拟类似库的功能,我们探索了两种技术:

  • 我们使用的第一种技术通过将多个源文件链接到单个内核模块中来工作。

  • 这与模块堆叠技术相反,后者实际上构建了多个内核模块并将它们“堆叠”在一起。

第一种技术不仅效果很好,而且还具有这些优点:

  • 我们不必明确标记(通过EXPORT_SYMBOL())我们使用的每个数据/函数符号作为已导出的。

  • 这些功能仅对实际链接到的内核模块可用(而不是整个内核,包括其他模块)。这是一件好事!所有这些都是以稍微调整 Makefile 的代价 - 绝对值得。

“链接”方法的一个缺点:在链接多个文件时,内核模块的大小可能会变得很大。

这就是您学习内核编程强大功能的结束——将多个源文件链接在一起形成一个内核模块,和/或利用模块堆叠设计,这两者都允许您开发更复杂的内核项目。

在接下来的部分中,我们将深入探讨如何向内核模块传递参数。

向内核模块传递参数

一种常见的调试技术是instrument您的代码;也就是说,在适当的位置插入打印,以便您可以跟踪代码的路径。当然,在内核模块中,我们会使用多功能的printk函数来实现这一目的。因此,让我们假设我们做了以下操作(伪代码):

#define pr_fmt(fmt) "%s:%s():%d: " fmt, KBUILD_MODNAME, __func__, __LINE__
[ ... ]
func_x() { 
    pr_debug("At 1\n");
    [...]
    while (<cond>) {
        pr_debug("At 2: j=0x%x\n", j); 
        [...] 
 }
 [...]
}

好的,很好。但是我们不希望调试打印出现在生产(或发布)版本中。这正是我们使用pr_debug()的原因:它只在定义了符号DEBUG时才发出一个 printk!确实,但是如果,有趣的是,我们的客户是一个工程客户,并希望动态打开或关闭这些调试打印呢?您可能会采取几种方法;其中一种如下伪代码所示:

static int debug_level;     /* will be init to zero */
func_x() { 
    if (debug_level >= 1) pr_debug("At 1\n");
    [...]
    while (<cond>) {
        if (debug_level >= 2) 
            pr_debug("At 2: j=0x%x\n", j); 
        [...] 
    }
 [...]
}

啊,这很好。那么,我们真正要说的是:如果我们可以将debug_level模块变量**作为我们的内核模块的参数,那将是一个强大的功能,内核模块的用户可以控制哪些调试消息出现或不出现。

声明和使用模块参数

模块参数作为name=value对在模块插入(insmod)时传递给内核模块。例如,假设我们有一个名为mp_debug_level模块参数,那么我们可以在insmod(8)时传递其值,如下所示:

sudo insmod modparams1.ko mp_debug_level=2

在这里,mp前缀代表模块参数。当然,不一定要这样命名,这有点迂腐,但可能会使其更直观一些。

这将是强大的。现在,最终用户可以决定verbosity 他们希望debug-level 消息。我们甚至可以轻松安排默认值为0

您可能会想:内核模块没有main()函数,因此没有常规的(argc, argv)参数列表,那么您究竟如何传递参数呢?事实上,这是一种链接器的技巧;只需这样做:将您打算的模块参数声明为全局(静态)变量,然后通过使用module_param()宏指定构建系统将其视为模块参数。

通过我们的第一个模块参数的演示内核模块,这一点很容易看出(通常情况下,完整的源代码和Makefile可以在本书的 GitHub 存储库中找到):

// ch5/modparams/modparams1/modparams1.c
[ ... ]
/* Module parameters */
static int mp_debug_level;
module_param(mp_debug_level, int, 0660);
MODULE_PARM_DESC(mp_debug_level,
"Debug level [0-2]; 0 => no debug messages, 2 => high verbosity");

static char *mp_strparam = "My string param";
module_param(mp_strparam, charp, 0660);
MODULE_PARM_DESC(mp_strparam, "A demo string parameter");

static int mp_debug_level;语句中,将其更改为static int mp_debug_level = 0;是没有害处的,这样明确地将变量初始化为 0,对吗?嗯,不是的:内核的scripts/checkpatch.pl脚本输出显示,内核社区并不认为这是良好的编码风格:

ERROR: do not initialise statics to 0

#28: FILE: modparams1.c:28:

+static int mp_debug_level = 0;

在上述代码块中,我们通过module_param()宏声明了两个模块参数。module_param()宏接受三个参数:

  • 第一个参数:变量名(我们希望将其视为模块参数)。这应该使用static限定符声明。

  • 第二个参数:其数据类型。

  • 第三个参数:权限(实际上,它通过sysfs的可见性;这将在下文中解释)。

MODULE_PARM_DESC()宏允许我们“描述”参数代表什么。想想看,这是如何通知内核模块(或驱动程序)的最终用户以及实际可用的参数。查找是通过modinfo(8)实用程序执行的。此外,您可以使用-p选项开关,仅将参数信息打印到模块,如下所示:

cd <booksrc>/ch5/modparams/modparams1
make
$ modinfo -p ./modparams1.ko 
parm:          mp_debug_level:Debug level [0-2]; 0 => no debug messages, 2 => high verbosity (int)
parm:          mp_strparam:A demo string parameter (charp)
$ 

modinfo(8)输出显示可用的模块参数(如果有的话)。在这里,我们可以看到我们的modparams1.ko内核模块有两个参数,它们的名称、描述和数据类型(在括号内;charp是字符指针,一个字符串)都显示出来了。好了,现在让我们快速运行一下我们的演示内核模块:

sudo dmesg -C
sudo insmod ./modparams1.ko 
dmesg 
[42724.936349] modparams1: inserted
[42724.936354] module parameters passed: mp_debug_level=0 mp_strparam=My string param

在这里,我们从dmesg(1)输出中看到,由于我们没有显式传递任何内核模块参数,模块变量显然保留了它们的默认(原始)值。让我们重新做一遍,这次传递显式值给模块参数:

sudo rmmod modparams1 
sudo insmod ./modparams1.ko mp_debug_level=2 mp_strparam=\"Hello modparams1\"
$ dmesg 
[...]
[42734.162840] modparams1: removed
[42766.146876] modparams1: inserted
[42766.146880] module parameters passed: mp_debug_level=2 mp_strparam=Hello modparams1
$ 

它按预期工作。既然我们已经看到了如何声明和传递一些参数给内核模块,现在让我们来看看如何在运行时检索甚至修改它们。

插入后获取/设置模块参数

让我们仔细看一下我们之前的modparams1.c源文件中module_param()宏的用法:

module_param(mp_debug_level, int, 0660);

注意第三个参数,权限(或模式):它是0660(当然,这是一个八进制数,意味着所有者和组有读写访问权限,其他人没有访问权限)。这有点令人困惑,直到你意识到如果指定了permissions参数为非零,伪文件将在sysfs文件系统下创建,表示内核模块参数,这里是:/sys/module/<module-name>/parameters/

sysfs通常挂载在/sys下。此外,默认情况下,所有伪文件的所有者和组都是 root。

  1. 因此,对于我们的modparams1内核模块(假设它加载到内核内存中),让我们查找它们:
$ ls /sys/module/modparams1/
coresize   holders/    initsize  initstate  notes/  parameters/  refcnt sections/  srcversion  taint     uevent     version
$ ls -l /sys/module/modparams1/parameters/
total 0
-rw-rw---- 1 root root 4096 Jan  1 17:39 mp_debug_level
-rw-rw---- 1 root root 4096 Jan  1 17:39 mp_strparam
$ 

确实,它们在那里!不仅如此,它的真正美妙之处在于这些“参数”现在可以随意读取和写入,任何时候(当然只有 root 权限)!

  1. 检查一下:
$ cat /sys/module/modparams1/parameters/mp_debug_level 
cat: /sys/module/modparams1/parameters/mp_debug_level: Permission denied
$ sudo cat /sys/module/modparams1/parameters/mp_debug_level
[sudo] password for llkd: 
2

是的,我们的mp_debug_level内核模块参数的当前值确实是2

  1. 让我们动态将其更改为0,表示modparams1内核模块不会发出“调试”消息:
$ sudo bash -c "echo 0 > /sys/module/modparams1/parameters/mp_debug_level"
$ sudo cat /sys/module/modparams1/parameters/mp_debug_level 
0

完成了。您可以类似地获取和/或设置mp_strparam参数;我们将留给您尝试这个作为一个简单的练习。这是强大的东西:您可以编写简单的脚本来通过内核模块参数控制设备(或其他内容)的行为,获取(或切断)调试信息等等;可能性是相当无限的。

实际上,将module_param()的第三个参数编码为字面八进制数(例如0660)在某些圈子里不被认为是最佳的编程实践。通过适当的宏(在include/uapi/linux/stat.h中指定)指定sysfs伪文件的权限,例如:

module_param(mp_debug_level, int, S_IRUSR|S_IWUSR|S_IRGRP|S_IWGRP);

然而,话虽如此,我们的“更好”的Makefilecheckpatch目标(当然,调用内核的scripts/checkpatch.pl“编码风格”Perl 脚本检查器)礼貌地告诉我们,简单地使用八进制权限更好:

$ make checkpatch
[ ... ]
checkpatch.pl: /lib/modules/<ver>/build//scripts/checkpatch.pl --no-tree -f *.[ch]
[ ... ]
WARNING: Symbolic permissions 'S_IRUSR|S_IWUSR|S_IRGRP|S_IWGRP' are not preferred. Consider using octal permissions '0660'.
 #29: FILE: modparams1.c:29:
 +module_param(mp_debug_level, int, S_IRUSR|S_IWUSR|S_IRGRP|S_IWGRP);

因此,内核社区并不同意。因此,我们将只使用“通常”的八进制数表示法0660

内核模块参数数据类型和验证

在我们之前的简单内核模块中,我们设置了整数和字符串数据类型(charp)的两个参数。还可以使用哪些数据类型?事实证明有几种,moduleparam.h包含文件中都有(在注释中重复显示):

// include/linux/moduleparam.h
[...]
 * Standard types are:
 * byte, short, ushort, int, uint, long, ulong
 * charp: a character pointer
 * bool: a bool, values 0/1, y/n, Y/N.
 * invbool: the above, only sense-reversed (N = true).

甚至可以根据需要定义自己的数据类型。通常,标准类型已经足够了。

验证内核模块参数

所有内核模块参数默认都是可选的;用户可以选择是否显式传递它们。但是如果我们的项目要求用户必须显式传递给定内核模块参数的值怎么办?我们在这里解决这个问题:让我们增强我们之前的内核模块,创建另一个(ch5/modparams/modparams2),关键区别在于我们设置了一个名为control_freak的额外参数。现在,我们要求用户在模块插入时必须传递这个参数:

  1. 在代码中设置新的模块参数:
static int control_freak;
module_param(control_freak, int, 0660);
MODULE_PARM_DESC(control_freak, "Set to the project's control level [1-5]. MANDATORY");
  1. 我们如何实现这种“强制传递”呢?嗯,这实际上有点小聪明:只需在插入时检查值是否为默认值(这里是0)。如果是,默认值,那么用适当的消息中止(我们还进行了一个简单的有效性检查,以确保传递的整数在给定范围内)。以下是ch5/modparams/modparams2/modparams2.c的初始化代码:
static int __init modparams2_init(void)
{
    pr_info("%s: inserted\n", OUR_MODNAME);
    if (mp_debug_level > 0)
        pr_info("module parameters passed: "
                "mp_debug_level=%d mp_strparam=%s\n control_freak=%d\n",
                mp_debug_level, mp_strparam, control_freak);

    /* param 'control_freak': if it hasn't been passed (implicit guess), 
     * or is the same old value, or isn't within the right range,
     * it's Unacceptable!  :-)
     */
    if ((control_freak < 1) || (control_freak > 5)) {
        pr_warn("%s: Must pass along module parameter"
              " 'control_freak', value in the range [1-5]; aborting...\n",
              OUR_MODNAME);
        return -EINVAL;
    }
    return 0; /* success */
}
  1. 另外,作为一个快速演示,注意我们如何发出一个printk,只有当mp_debug_level为正数时才显示模块参数值。

  2. 最后,在这个话题上,内核框架提供了一种更严格的方式来“获取/设置”内核(模块)参数并对其进行有效性检查,通过module_parm_cb()宏(cb代表回调)。我们不会在这里深入讨论这个问题;我建议你参考进一步阅读文档中提到的博客文章,了解如何使用它的详细信息。

现在,让我们继续讨论如何(以及为什么)覆盖模块参数的名称。

覆盖模块参数的名称

为了解释这个特性,让我们以(5.4.0)内核源代码树中的一个例子来说明:直接映射缓冲 I/O 库驱动程序drivers/md/dm-bufio.c需要使用dm_bufio_current_allocated变量作为模块参数。然而,这个名称实际上是一个内部变量,对于这个驱动程序的用户来说并不是非常直观的。这个驱动程序的作者更希望使用另一个名称——current_allocated_bytes——作为别名名称覆盖。可以通过module_param_named()宏来实现这一点,通过覆盖并完全等效于内部变量名称的方式,如下所示:

// drivers/md/dm-bufio.c
[...]
module_param_named(current_allocated_bytes, dm_bufio_current_allocated, ulong, S_IRUGO);
MODULE_PARM_DESC(current_allocated_bytes, "Memory currently used by the cache");

因此,当用户对这个驱动程序执行insmod时,他们可以做如下的事情:

sudo insmod <path/to/>dm-bufio.ko current_allocated_bytes=4096 ...

在内部,实际变量dm_bufio_current_allocated将被赋值为4096

与硬件相关的内核参数

出于安全原因,指定硬件特定值的模块或内核参数有一个单独的宏——module_param_hw[_named|array](). David Howells 于 2016 年 12 月 1 日提交了一系列补丁,用于支持这些新的硬件参数内核。补丁邮件[lwn.net/Articles/708274/]提到了以下内容:

Provided an annotation for module parameters that specify hardware
parameters (such as io ports, iomem addresses, irqs, dma channels, fixed
dma buffers and other types).

This will enable such parameters to be locked down in the core parameter
parser for secure boot support.  [...]

这就结束了我们对内核模块参数的讨论。让我们继续讨论一个特殊的方面——内核中的浮点使用。

内核中不允许浮点数

多年前,当我在温度传感器设备驱动程序上工作时,我有过一次有趣的经历(尽管当时并不那么有趣)。试图将毫摄氏度作为“常规”摄氏度值来表达温度值时,我做了类似以下的事情:

double temp;
[... processing ...]
temp = temp / 1000.0;
printk(KERN_INFO "temperature is %.3f degrees C\n", temp);

从那时起一切都变得糟糕了!

备受尊敬的 LDD(Linux 设备驱动程序,作者为Corbet, Rubini, and G-K-Hartman)书指出了我的错误——浮点(FP)算术在内核空间是不允许的!这是一个有意识的设计决定——保存处理器(FP)状态,打开 FP 单元,进行操作,然后关闭和恢复 FP 状态在内核中并不被认为是值得做的事情。内核(或驱动程序)开发人员最好不要在内核空间尝试执行 FP 工作。

那么,你会问,那你怎么做(以我的例子为例)温度转换呢?简单:将整数毫摄氏度值传递给用户空间,然后在那里执行 FP 工作!

话虽如此,显然有一种方法可以强制内核执行 FP:将你的浮点代码放在kernel_fpu_begin()kernel_fpu_end()宏之间。在内核代码库中有一些地方确实使用了这种技术(通常是一些涵盖加密/AES、CRC 等的代码路径)。不过,建议是典型的模块(或驱动程序)开发人员只在内核中执行整数算术

尽管如此,为了测试整个场景(永远记住,实证方法 - 实际尝试事物 - 是唯一现实的前进方式!),我们编写了一个简单的内核模块,试图执行一些 FP 工作。代码的关键部分在这里显示:

// ch5/fp_in_kernel/fp_in_kernel.c
static double num = 22.0, den = 7.0, mypi;
static int __init fp_in_lkm_init(void)
{
    [...]
    kernel_fpu_begin();
    mypi = num/den;
    kernel_fpu_end();
#if 1
    pr_info("%s: PI = %.4f = %.4f\n", OURMODNAME, mypi, num/den);
#endif
    return 0;     /* success */
}

它实际上是有效的,直到 我们尝试通过 printk() 显示 FP 值!在那一点上,它变得非常疯狂。请看下面的截图:

图 5.4 - 当我们尝试在内核空间中打印 FP 数字时,WARN_ONCE()的输出

关键行是Please remove unsupported %f in format string

这告诉我们一个故事。系统实际上并没有崩溃或恐慌,因为这只是一个通过WARN_ONCE()宏输出到内核日志的警告。但请注意,在生产系统上,/proc/sys/kernel/panic_on_warn伪文件很可能被设置为值1,导致内核(完全正确地)恐慌。

在前面截图(图 5.3)中的部分,从Call Trace:开始,当然是对进程或线程的内核模式堆栈的当前状态的一瞥,它是在前面的WARN_ONCE()代码路径中“捕获”的(稍等,你将在第六章中学到关于用户模式和内核模式堆栈等关键细节)。通过自下而上地阅读内核堆栈来解释内核堆栈;所以在这里,do_one_initcall函数调用了属于方括号中的内核模块的fp_in_lkm_init[fp_in_lkm_init]),然后调用了printk(),然后试图打印 FP(浮点)数量,结果导致了各种麻烦!

明显的道理是:避免在内核空间中使用浮点数运算。现在让我们继续讨论如何在系统启动时安装和自动加载内核模块。

在系统启动时自动加载模块

到目前为止,我们编写了简单的“外部”内核模块,它们驻留在自己的私有目录中,并且通常需要通过insmod(8)modprobe(8)实用程序手动加载。在大多数真实项目和产品中,你将需要在启动时自动加载你的外部内核模块。本节介绍了如何实现这一点。

假设我们有一个名为foo.ko的内核模块。我们假设我们可以访问源代码和 Makefile。为了在系统启动时自动加载它,你需要首先将内核模块安装到系统上已知的位置。为此,我们期望模块的 Makefile 包含一个install目标,通常是:

install:
 make -C $(KDIR) M=$(PWD) modules_install

这并不是什么新鲜事;我们一直在我们的演示内核模块的Makefile中放置install目标。

为了演示这个“自动加载”过程,我们展示了实际安装和自动加载我们的ch5/min_sysinfo内核模块的步骤:

  1. 首先,切换到模块的源目录:
cd <...>/ch5/min_sysinfo
  1. 接下来,首先重要的是构建内核模块(使用make),并且在成功后安装它(很快你会看到,我们的“更好”的 Makefile 通过保证先进行构建,然后进行安装和depmod来简化这个过程):
make && sudo make install   

假设它构建成功,sudo make install命令然后会在/lib/modules/<kernel-ver>/extra/安装内核模块,这是预期的(也请看下面的信息框和提示):

$ cd <...>/ch5/min_sysinfo
$ make                *<-- ensure it's first built 'locally'   
               generating the min_sysinfo.ko kernel module object*
[...]
$ sudo make install Building for: KREL= ARCH= CROSS_COMPILE= EXTRA_CFLAGS=-DDEBUG
make -C /lib/modules/5.4.0-llkd01/build M=<...>/ch5/min_sysinfo modules_install
make[1]: Entering directory '/home/llkd/kernels/linux-5.4'
 INSTALL <...>/ch5/min_sysinfo/min_sysinfo.ko
 DEPMOD  5.4.0-llkd01
make[1]: Leaving directory '/home/llkd/kernels/linux-5.4'
$ ls -l /lib/modules/5.4.0-llkd01/extra/
total 228
-rw-r--r-- 1 root root 232513 Dec 30 16:23 min_sysinfo.ko
$ 

sudo make install期间,可能会看到关于 SSL 的(非致命的)错误;它们可以安全地忽略。它们表明系统未能“签名”内核模块。关于这一点,稍后会有关于安全性的说明。

另外,如果你发现sudo make install失败,也可以尝试以下方法:

a) 切换到 root shell(sudo -s)并在其中运行make ; make install命令。

b) 一个有用的参考资料:Makefile: installing external Linux kernel module, StackOverflow, June 2016 (unix.stackexchange.com/questions/288540/makefile-installing-external-linux-kernel-module)。

  1. 然后通常会在sudo make install中默认调用另一个模块实用程序depmod(8)(可以从前面的输出中看到)。以防万一(无论出于什么原因),这没有发生,您总是可以手动调用depmod:它的工作基本上是解决模块依赖关系(有关详细信息,请参阅其手册页):sudo depmod。安装内核模块后,您可以使用其--dry-run选项开关查看depmod(8)的效果:
$ sudo depmod --dry-run | grep min_sysinfo
extra/min_sysinfo.ko:
alias symbol:lkdc_sysinfo2 min_sysinfo
alias symbol:lkdc_sysinfo min_sysinfo
$ 
  1. 在启动时自动加载内核模块:一种方法是创建/etc/modules-load.d/<foo>.conf配置文件(当然,您需要 root 访问权限来创建此文件);简单情况:只需在其中放入内核模块的foo名称,就是这样。任何以#字符开头的行都被视为注释并被忽略。对于我们的min_sysinfo示例,我们有以下内容:
$ cat /etc/modules-load.d/min_sysinfo.conf 
# Auto load kernel module for LLKD book: ch5/min_sysinfo
min_sysinfo
$

另外,通知 systemd 加载我们的内核模块的另一种(甚至更简单的)方法是将模块的名称输入到(现有的)/etc/modules-load.d/modules.conf文件中。

  1. 使用sync; sudo reboot重新启动系统。

系统启动后,使用lsmod(8)并查看内核日志(也许可以用dmesg(1))。您应该会看到与内核模块加载相关的相关信息(在我们的示例中是min_sysinfo)。

[... system boots up ...]

$ lsmod | grep min_sysinfo
min_sysinfo         16384  0
$ dmesg | grep -C2 min_sysinfo
[...]
[ 2.395649] min_sysinfo: loading out-of-tree module taints kernel.
[ 2.395667] min_sysinfo: module verification failed: signature and/or required key missing - tainting kernel
[ 2.395814] min_sysinfo: inserted
[ 2.395815] lkdc_sysinfo(): minimal Platform Info:
               CPU: x86_64, little-endian; 64-bit OS.
$

好了,完成了:我们的min_sysinfo内核模块确实已经在启动时自动加载到内核空间中!

正如您刚刚学到的,您必须首先构建您的内核模块,然后执行安装;为了帮助自动化这一过程,我们的“更好”的 Makefile 在其模块安装install目标中包含以下内容:

// ch5/min_sysinfo/Makefile
[ ... ]
install:
    @echo
    @echo "--- installing ---"
    @echo " [First, invoke the 'make' ]"
    make
    @echo
    @echo " [Now for the 'sudo make install' ]"
    sudo make -C $(KDIR) M=$(PWD) modules_install
 sudo depmod

它确保首先进行构建,然后进行安装,(显式地)进行depmod(8)

如果您的自动加载的内核模块在加载时需要传递一些(模块)参数,该怎么办?有两种方法可以确保这种情况发生:通过所谓的 modprobe 配置文件(在/etc/modprobe.d/下)或者,如果模块是内核内置的,通过内核命令行。

这里我们展示第一种方法:简单地设置您的 modprobe 配置文件(在这里作为示例,我们使用mykmod作为我们 LKM 的名称;同样,您需要 root 访问权限来创建此文件):/etc/modprobe.d/mykmod.conf;在其中,您可以像这样传递参数:

options <module-name> <parameter-name>=<value>

例如,我的 x86_64 Ubuntu 20.04 LTS 系统上的/etc/modprobe.d/alsa-base.conf modprobe 配置文件包含以下行(还有其他几行):

# Ubuntu #62691, enable MPU for snd-cmipci
options snd-cmipci mpu_port=0x330 fm_port=0x388

接下来是有关内核模块自动加载相关项目的一些要点。

模块自动加载-其他详细信息

一旦内核模块已经通过sudo make install安装到系统上(如前所示),您还可以通过一个“更智能”的insmod(8)实用程序的版本,称为modprobe(8),将其插入内核交互式地(或通过脚本)。对于我们的示例,我们可以首先rmmod(8)模块,然后执行以下操作:

sudo modprobe min_sysinfo

有趣的是,在有多个内核模块对象要加载的情况下(例如,模块堆叠设计),modprobe如何知道加载内核模块的顺序?在本地进行构建时,构建过程会生成一个名为modules.order的文件。它告诉诸如modprobe之类的实用程序加载内核模块的顺序,以便解决所有依赖关系。当内核模块被安装到内核中(即,到/lib/modules/$(uname -r)/extra/或类似位置),depmod(8)实用程序会生成一个/lib/modules/$(uname -r)/modules.dep文件。其中包含依赖信息 - 它指定一个内核模块是否依赖于另一个。使用这些信息,modprobe 然后按照所需的顺序加载它们。为了充实这一点,让我们安装我们的模块堆叠示例:

$ cd <...>/ch5/modstacking
$ make && sudo make install
[...]
$ ls -l /lib/modules/5.4.0-llkd01/extra/
total 668K
-rw-r--r-- 1 root root 218K Jan 31 08:41 core_lkm.ko
-rw-r--r-- 1 root root 228K Dec 30 16:23 min_sysinfo.ko
-rw-r--r-- 1 root root 217K Jan 31 08:41 user_lkm.ko
$ 

显然,我们模块堆叠示例中的两个内核模块(core_lkm.kouser_lkm.ko)现在安装在预期位置/lib/modules/$(uname -r)/extra/下。现在,来看一下这个:

$ grep user_lkm /lib/modules/5.4.0-llkd01/* 2>/dev/null
/lib/modules/5.4.0-llkd01/modules.dep:extra/user_lkm.ko: extra/core_lkm.ko
Binary file /lib/modules/5.4.0-llkd01/modules.dep.bin matches
$

grep后的第一行输出是相关的:depmod已经安排modules.dep文件显示extra/user_lkm.ko内核模块依赖于extra/core_lkm.ko内核模块(通过<k1.ko>: <k2.ko>...表示,意味着k1.ko模块依赖于k2.ko模块)。因此,modprobe 看到这一点,按照所需的顺序加载它们,避免任何问题。

(顺便说一句,谈到这个话题,生成的Module.symvers文件包含所有导出符号的信息。)

接下来,回想一下 Linux 上的新(ish)init框架,systemd。事实上,在现代 Linux 系统上,实际上是 systemd 负责在系统启动时自动加载内核模块,通过解析诸如/etc/modules-load.d/*之类的文件的内容(负责此操作的 systemd 服务是systemd-modules-load.service(8)。有关详细信息,请参阅modules-load.d(5)的 man 页面)。

相反,有时您可能会发现某个自动加载的内核模块表现不佳 - 导致死机或延迟,或者根本不起作用 - 因此您肯定想要禁用它的加载。这可以通过黑名单模块来实现。您可以在内核命令行上指定这一点(当其他方法都失败时很方便!)或者在(前面提到的)/etc/modules-load.d/<foo>.conf配置文件中指定。在内核命令行上,通过module_blacklist=mod1,mod2,...,内核文档向我们展示了语法/解释:

module_blacklist=  [KNL] Do not load a comma-separated list of
                        modules.  Useful for debugging problem modules.

您可以通过执行cat /proc/cmdline来查找当前的内核命令行。

谈到内核命令行,还存在一些其他有用的选项,使我们能够使用内核的帮助来调试与内核初始化有关的问题。例如,内核在这方面提供了以下参数之一(来源:www.kernel.org/doc/html/latest/admin-guide/kernel-parameters.html):

debug           [KNL] Enable kernel debugging (events log level).
[...]
initcall_debug  [KNL] Trace initcalls as they are executed. Useful
                      for working out where the kernel is dying during
                      startup.
[...]
ignore_loglevel [KNL] Ignore loglevel setting - this will print /all/
                      kernel messages to the console. Useful for  
                      debugging. We also add it as printk module 
                      parameter, so users could change it dynamically, 
                      usually by /sys/module/printk/parameters/ignore_loglevel.

顺便说一句,并且正如本章前面提到的,还有一个用于第三方内核模块自动重建的替代框架,称为动态内核模块支持DKMS)。

本章的进一步阅读文档还提供了一些有用的链接。总之,在系统启动时将内核模块自动加载到内存中是一个有用且经常需要的功能。构建高质量的产品需要对安全性有深刻的理解,并具有构建安全性的知识;这是下一节的主题。

内核模块和安全性 - 概述

讽刺的现实是,过去几年中,花费大量精力改进用户空间安全考虑已经取得了相当大的回报。几十年前,恶意用户进行有效的缓冲区溢出BoF)攻击是完全可能的,但今天却很难实现。为什么?因为有许多层加强的安全机制来防止许多这些攻击类别。

快速列举一些对策:编译器保护(-fstack-protector[...]

-Wformat-security, -D_FORTIFY_SOURCE=2, partial/full RELRO, better sanity and security checker tools (checksec.sh`, the address sanitizers, paxtest, static analysis tools, and so on), secure libraries, hardware-level protection mechanisms (NX, SMEP, SMAP, and so on), [K]ASLR, better testing (fuzzing), and so on.

讽刺的是,过去几年中内核空间攻击变得越来越常见!已经证明,即使是透露一个有效的内核(虚拟)地址(及其对应的符号)给一个聪明的攻击者,她也可以找到一些关键的内核结构的位置,从而为进行各种特权升级privesc)攻击铺平道路。因此,即使是透露一个看似无害的内核信息(如内核地址及其关联的符号)也可能是一个信息泄漏(或信息泄漏)并且必须在生产系统上予以防止。接下来,我们将列举并简要描述 Linux 内核提供的一些安全功能。然而,最终,内核开发人员-也就是您!-在其中扮演了重要角色:首先编写安全的代码!使用我们的“更好”的 Makefile 是一个很好的开始方式-其中的几个目标与安全有关(例如所有的静态分析目标)。

影响系统日志的 proc 文件系统可调整参数

我们直接参考proc(5)的手册页面-非常有价值!-以获取有关这两个与安全相关的可调整参数的信息:

  • dmesg_restrict

  • kptr_restrict

首先是dmesg_restrict

dmesg_restrict
/proc/sys/kernel/dmesg_restrict (since Linux 2.6.37)
 The value in this file determines who can see kernel syslog contents. A  value of 0 in this file imposes no restrictions. If the value is 1, only privileged users can read the kernel syslog. (See syslog(2) for more details.) Since Linux 3.4, only users with the CAP_SYS_ADMIN capability may change the value in this file.

默认值(在我们的 Ubuntu 和 Fedora 平台上)是0

$ cat /proc/sys/kernel/dmesg_restrict
0

Linux 内核使用强大的细粒度 POSIX capabilities模型。CAP_SYS_ADMIN能力本质上是传统root(超级用户/系统管理员)访问的一个捕捉所有。CAP_SYSLOG能力赋予进程(或线程)执行特权syslog(2)操作的能力。

如前所述,“泄漏”内核地址及其关联的符号可能导致基于信息泄漏的攻击。为了帮助防止这些情况,建议内核和模块的作者始终使用新的printf风格格式来打印内核地址:而不是使用熟悉的%p%px来打印内核地址,应该使用新的%pK格式来打印地址。(使用%px格式确保实际地址被打印出来;在生产中应避免使用这种格式)。这有什么帮助呢?请继续阅读...

kptr_restrict可调整参数(2.6.38 及以上版本)影响printk()输出时打印内核地址;使用printk("&var = **%pK**\n", &var);

而不是老旧的printk("&var = %p\n", &var);被认为是一种安全最佳实践。了解kptr_restrict可调整参数的工作原理对此至关重要:

kptr_restrict
/proc/sys/kernel/kptr_restrict (since Linux 2.6.38)
 The value in this file determines whether kernel addresses are exposed via /proc files and other interfaces. A value of 0 in this file imposes no restrictions. If the value is 1, kernel pointers printed using the %pK format specifier will be replaced with zeros unless the user has the CAP_SYSLOG capability. If the value is 2, kernel pointers printed using the %pK format specifier will be replaced with zeros regardless of the user's capabilities. The initial default value for this file was 1, but the default was changed to 0 in Linux 2.6.39\. Since Linux 3.4, only users with the CAP_SYS_ADMIN capability can change the value in this file.

默认值(在我们最近的 Ubuntu 和 Fedora 平台上)是1

$ cat /proc/sys/kernel/kptr_restrict 
1

在生产系统上,您可以-而且必须将这些可调整参数更改为安全值(1 或 2)以确保安全。当然,只有开发人员使用这些安全措施时,安全措施才能发挥作用;截至 Linux 内核 5.4.0 版本,整个 Linux 内核代码库中只有(仅有!)14 个使用%pK格式指定符,而使用%p的使用约为 5200 多次,显式使用%px格式指定符的使用约为 230 次。

a)由于procfs是一个易失性文件系统,您可以始终使用sysctl(8)实用程序和-w选项开关(或直接更新/etc/sysctl.conf文件)使更改永久生效。

b)为了调试的目的,如果必须打印实际的内核(未修改的)地址,建议您使用%px格式说明符;在生产系统上,请删除这些打印!

c)有关printk格式说明符的详细内核文档可以在www.kernel.org/doc/html/latest/core-api/printk-formats.html#how-to-get-printk-format-specifiers-right找到;请浏览一下。

随着 2018 年初硬件级缺陷的出现(现在众所周知的Meltdown,Spectre和其他处理器推测安全问题),人们对检测信息泄漏产生了一种新的紧迫感,从而使开发人员和管理员能够将其封锁。

一个有用的 Perl 脚本scripts/leaking_addresses.pl在 4.14 版中发布(2017 年 11 月;我很高兴能在这项重要工作中提供帮助:github.com/torvalds/linux/commit/1410fe4eea22959bd31c05e4c1846f1718300bde),并且正在进行更多的检查以检测泄漏的内核地址。

内核模块的加密签名

一旦恶意攻击者在系统上立足,他们通常会尝试某种特权升级向量,以获得 root 访问权限。一旦实现了这一点,典型的下一步是安装rootkit:基本上是一组脚本和内核模块,它们几乎会接管系统(通过“劫持”系统调用,设置后门和键盘记录器等)。

当然,这并不容易 - 现代生产质量的 Linux 系统的安全姿态,包括Linux 安全模块LSMs)等,意味着这并不是一件微不足道的事情,但对于一个技术娴熟且积极进取的攻击者来说,任何事情都有可能。假设他们安装了足够复杂的 rootkit,系统现在被认为是受到了威胁。

一个有趣的想法是:即使具有 root 访问权限,也不要允许insmod(8)(或modprobe(8),甚至底层的[f]init_module(2)系统调用)将内核模块插入内核地址空间除非它们使用安全密钥进行了加密签名,而该密钥在内核的密钥环中。这一强大的安全功能是在 3.7 内核中引入的(相关提交在这里:git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=106a4ee258d14818467829bf0e12aeae14c16cd7)。

有关对内核模块进行加密签名的详细信息超出了本书的范围;您可以在这里参考官方内核文档:www.kernel.org/doc/html/latest/admin-guide/module-signing.html

有关此功能的一些相关内核配置选项是CONFIG_MODULE_SIGCONFIG_MODULE_SIG_FORCECONFIG_MODULE_SIG_ALL等。要了解这究竟意味着什么,请参阅第一个选项的Kconfig 'help'部分,如下所示(来自init/Kconfig):

config MODULE_SIG
 bool "Module signature verification"
 depends on MODULES
 select SYSTEM_DATA_VERIFICATION
 help
  Check modules for valid signatures upon load: the signature is simply  
  appended to the module. For more information see  
  <file:Documentation/admin-guide/module-signing.rst>. Note that this  
  option adds the OpenSSL development packages as a kernel build   
  dependency so that the signing tool can use its crypto library.

 !!!WARNING!!! If you enable this option, you MUST make sure that the  
 module DOES NOT get stripped after being signed. This includes the
 debuginfo strip done by some packagers (such as rpmbuild) and
 inclusion into an initramfs that wants the module size reduced

内核配置MODULE_SIG_FORCE是一个布尔值(默认为n)。只有在打开MODULE_SIG时才会起作用。如果MODULE_SIG_FORCE设置为y,那么内核模块必须具有有效的签名才能加载。如果没有,加载将失败。如果其值保持为n,这意味着即使未签名的内核模块也将加载到内核中,但内核将被标记为有瑕疵。这往往是典型现代 Linux 发行版的默认设置。在以下代码块中,我们查找了我们的 x86_64 Ubuntu 20.04.1 LTS 客户 VM 上的这些内核配置:

$ grep MODULE_SIG /boot/config-5.4.0-58-generic 
CONFIG_MODULE_SIG_FORMAT=y
CONFIG_MODULE_SIG=y
# CONFIG_MODULE_SIG_FORCE is not set
CONFIG_MODULE_SIG_ALL=y
[ ... ] 

在生产系统上鼓励对内核模块进行加密签名(近年来,随着(I)IoT 边缘设备变得更加普遍,安全性是一个关键问题)。

完全禁用内核模块

偏执的人可能希望完全禁用内核模块的加载(和卸载)。这相当激烈,但嘿,这样你就可以完全锁定系统的内核空间(以及使任何 rootkit 基本上无害)。有两种广泛的方法可以实现这一点:

  • 首先,通过在构建之前的内核配置期间将CONFIG_MODULES内核配置设置为关闭(默认情况下是打开的)。这样做相当激烈 – 它使决定成为永久的!

  • 其次,假设CONFIG_MODULES已打开,模块加载可以通过modules_disabled sysctl可调参数在运行时动态关闭;看一下这个:

$ cat /proc/sys/kernel/modules_disabled
0 

当然,默认情况下是关闭0)。像往常一样,proc(5)的 man 页面告诉了我们这个故事:

/proc/sys/kernel/modules_disabled (since Linux 2.6.31)
 A toggle value indicating if modules are allowed to be loaded in an otherwise modular kernel. This toggle defaults to off (0), but can be set true (1). Once true, modules can be neither loaded nor unloaded, and the toggle cannot be set back to false. The file is present only if the kernel is built with the CONFIG_MODULES option enabled.

总之,当然,内核安全加固和恶意攻击是一场猫鼠游戏。例如,(K)ASLR(我们将在接下来的 Linux 内存管理章节中讨论(K)ASLR 的含义)经常被打败。另请参阅这篇文章 – 在 Android 上有效地绕过 kptr_restrictbits-please.blogspot.com/2015/08/effectively-bypassing-kptrrestrict-on.html。安全并不容易;它总是在不断地进步中。几乎可以说:开发人员 – 无论是用户空间还是内核空间 – 必须编写具有安全意识的代码,并且持续使用工具和测试

让我们通过关于 Linux 内核编码风格指南、访问内核文档以及如何进行对主线内核的贡献的主题来完成本章。

内核开发人员的编码风格指南

许多大型项目都规定了自己的一套编码准则;Linux 内核社区也是如此。遵循 Linux 内核编码风格指南是一个非常好的主意。您可以在这里找到官方文档:www.kernel.org/doc/html/latest/process/coding-style.html(请务必阅读!)。

此外,作为想要上游您的代码的开发人员的(相当详尽的)代码提交检查清单的一部分,您应该通过一个 Perl 脚本运行您的补丁,检查您的代码是否符合 Linux 内核编码风格:scripts/checkpatch.pl

默认情况下,此脚本仅在格式良好的git补丁上运行。可以对独立的 C 代码(如您的树外内核模块代码)运行它,方法如下(正如我们的“更好”的 Makefile 确实做到的):

<kernel-src>/scripts/checkpatch.pl --no-tree -f <filename>.c

在您的内核代码中养成这样的习惯是有帮助的,可以帮助您发现那些令人讨厌的小问题 – 以及更严重的问题! – 否则可能会阻碍您的补丁。再次提醒您:我们的“更好”的 Makefile 的indentcheckpatch目标是为此而设计的。

除了编码风格指南,您会发现,时不时地,您需要深入研究详细且有用的内核文档。温馨提示:我们在第一章 内核工作区设置查找和使用 Linux 内核文档部分中介绍了定位和使用内核文档。

我们现在将通过简要介绍如何开始一个崇高的目标来完成本章:为主线 Linux 内核项目贡献代码。

为主线内核做贡献

在本书中,我们通常通过 LKM 框架在内核源树之外进行内核开发。如果您正在内核树中编写代码,并明确目标是将您的代码上游到内核主线,该怎么办呢?这确实是一个值得赞扬的目标 - 开源的整个基础源自社区愿意付出努力并将其贡献到项目上游。

开始为内核做贡献

当然,最常见的问题是我该如何开始?为了帮助您准确地解决这个问题,内核文档中有一个非常详细的答案:如何进行 Linux 内核开发www.kernel.org/doc/html/latest/process/howto.html#howto-do-linux-kernel-development

实际上,您可以通过make pdfdocs命令在内核源树的根目录生成完整的 Linux 内核文档;一旦成功,您将在此找到 PDF 文档:<kernel-source-tree>/Documentation/output/latex/development-process.pdf

这是 Linux 内核开发过程的非常详细的指南,包括代码提交的指南。此处显示了该文档的裁剪截图:

图 5.5 - 生成的内核开发文档的(部分)截图

作为内核开发过程的一部分,为了保持质量标准,一个严格且必须遵循的清单 - 一种长长的配方! - 是内核补丁提交过程的重要部分。官方清单位于此处:Linux 内核补丁提交清单www.kernel.org/doc/html/latest/process/submit-checklist.html#linux-kernel-patch-submission-checklist

虽然对于内核新手来说可能看起来是一项繁重的任务,但仔细遵循这个清单会给您的工作带来严谨性和可信度,并最终产生优秀的代码。我强烈鼓励您阅读内核补丁提交清单并尝试其中提到的程序。

有没有一个真正实用的动手提示,一个几乎可以保证成为内核黑客的方法?当然,继续阅读本书!哈哈,是的,此外,参加简直太棒了的Eudyptula 挑战www.eudyptula-challenge.org/)哦,等等,很不幸,截至撰写本文时,它已经关闭了。

不要担心;这里有一个网站,上面发布了所有挑战(以及解决方案,但不要作弊!)。一定要去看看并尝试这些挑战。这将极大地提升您的内核编程技能:github.com/agelastic/eudyptula

总结

在本章中,我们涵盖了使用 LKM 框架编写内核模块的第二个章节,其中包括与这一重要主题相关的几个(剩余的)领域:其中包括使用“更好”的 Makefile 来为您的内核模块进行配置,配置调试内核的提示(这非常重要!),交叉编译内核模块,从内核模块中收集一些最小的平台信息,甚至涉及内核模块的许可证问题。我们还探讨了使用两种不同方法(一种是首选的链接方法,另一种是模块堆叠方法)来模拟类似库的特性,使用模块参数,避免浮点运算,内核模块的自动加载等等。安全问题及其解决方法也很重要。最后,我们通过介绍内核编码风格指南、内核文档以及如何开始为主线内核做出贡献来结束了本章。所以,恭喜!您现在知道如何开发内核模块,甚至可以开始迈向内核上游贡献的旅程。

在下一章中,我们将深入探讨一个有趣且必要的主题。我们将开始深入探讨 Linux 内核及其内存管理子系统的内部

问题

最后,这里有一些问题供您测试对本章材料的了解:github.com/PacktPublishing/Linux-Kernel-Programming/tree/master/questions。您会在书的 GitHub 存储库中找到一些问题的答案:github.com/PacktPublishing/Linux-Kernel-Programming/tree/master/solutions_to_assgn

进一步阅读

为了帮助您深入研究这个主题并提供有用的材料,我们在这本书的 GitHub 存储库中提供了一个相当详细的在线参考和链接(有时甚至包括书籍)的进一步阅读markdown 文档 - 按章节组织。进一步阅读文档在这里可用:github.com/PacktPublishing/Linux-Kernel-Programming/raw/master/Further_Reading.md

第二部分:理解和使用内核

许多人在内核开发中挣扎的一个关键原因是对其内部机制的理解不足。这里涵盖了一些内核架构、内存管理和调度的基本要点。

本节包括以下章节:

  • 第六章,内核内部要点-进程和线程

  • 第七章,内存管理内部要点-基础知识

  • 第八章,模块作者的内核内存分配,第一部分

  • 第九章,模块作者的内核内存分配,第二部分

第六章:内核内部基础知识-进程和线程

内核内部,特别是与内存管理有关的部分,是一个广阔而复杂的主题。在本书中,我们并不打算深入研究内核和内存内部的细节。同时,我希望为像你这样的新手内核或设备驱动程序开发人员提供足够的,绝对必要的背景知识,以成功地解决理解内核架构的关键主题,包括进程、线程及其堆栈的管理。您还将能够正确高效地管理动态内核内存(重点是使用可加载内核模块(LKM)框架编写内核或驱动程序代码)。作为一个附带的好处,掌握了这些知识,你会发现自己在调试用户空间和内核空间代码方面变得更加熟练。

我将基本内部讨论分为两章,这一章和下一章。本章涵盖了 Linux 内核内部架构的关键方面,特别是关于内核内部如何管理进程和线程。下一章将专注于内存管理内部,这是理解和使用 Linux 内核的另一个关键方面。当然,事实上,所有这些事情并不真正在一两章中涵盖,而是分布在本书中(例如,有关进程/线程的 CPU 调度的详细信息将在后面的章节中找到;内存内部,硬件中断,同步等等也是如此)。

简而言之,本章涵盖了以下主题:

  • 理解进程和中断上下文

  • 理解进程 VAS 的基础知识(虚拟地址空间)

  • 组织进程、线程及其堆栈-用户空间和内核空间

  • 理解和访问内核任务结构

  • 通过当前任务结构进行工作

  • 遍历内核的任务列表

技术要求

我假设你已经阅读了第一章,“内核工作空间设置”,并已经适当地准备了运行 Ubuntu 18.04 LTS(或更高版本)的虚拟机,并安装了所有必需的软件包。如果没有,我建议你先这样做。

为了充分利用本书,我强烈建议你首先设置好工作环境,包括克隆本书的 GitHub 代码库(在这里找到:github.com/PacktPublishing/Linux-Kernel-Programming),并且动手实践。

我假设你已经熟悉基本的虚拟内存概念,用户模式进程的虚拟地址空间(VAS)布局,堆栈等。尽管如此,我们会在接下来的“理解进程 VAS 的基础知识”部分中花几页来解释这些基础知识。

理解进程和中断上下文

在第四章,“编写你的第一个内核模块-LKMs,第一部分”,我们介绍了一个简短的名为“内核架构 I”的部分(如果你还没有阅读,我建议你在继续之前先阅读)。我们现在将扩展这个讨论。

重要的是要理解,大多数现代操作系统都是单片式设计。单片式字面上意味着单一的大块石头。我们稍后会详细讨论这如何适用于我们喜爱的操作系统!现在,我们将单片式理解为这样:当一个进程或线程发出系统调用时,它切换到(特权)内核模式并执行内核代码,并可能处理内核数据。是的,没有内核或内核线程代表它执行代码;进程(或线程)本身执行内核代码。因此,我们说内核代码在用户空间进程或线程的上下文中执行 - 我们称之为进程上下文。想想看,内核的重要部分正是以这种方式执行的,包括设备驱动程序的大部分代码。

好吧,你可能会问,既然你理解了这一点,除了进程上下文之外,内核代码还可以以什么其他方式执行?还有另一种方式:当硬件中断(来自外围设备 - 键盘、网络卡、磁盘等)触发时,CPU 的控制单元保存当前上下文,并立即重新定位 CPU 以运行中断处理程序的代码(中断服务例程ISR)。现在,这段代码也在内核(特权)模式下运行 - 实际上,这是另一种异步切换到内核模式的方式!许多设备驱动程序的中断代码路径就是这样执行的;我们说以这种方式执行的内核代码处于中断上下文中。

因此,任何一段内核代码都是在两种上下文中的一种中进入并执行的:

  • 进程上下文:内核从系统调用或处理器异常(如页面错误)中进入,并执行内核代码,处理内核数据;这是同步的(自上而下)。

  • 中断上下文:内核从外围芯片的硬件中断进入,并执行内核代码,处理内核数据;这是异步的(自下而上)。

图 6.1显示了概念视图:用户模式进程和线程在非特权用户上下文中执行;用户模式线程可以通过发出系统调用切换到特权内核模式。该图还显示了纯内核线程也存在于 Linux 中;它们与用户模式线程非常相似,关键区别在于它们只在内核空间中执行;它们甚至不能看到用户 VAS。通过系统调用(或处理器异常)同步切换到内核模式后,任务现在在进程上下文中运行内核代码。(内核线程也在进程上下文中运行内核代码。)然而,硬件中断是另一回事 - 它们导致执行异步进入内核;它们执行的代码(通常是设备驱动程序的中断处理程序)运行在所谓的中断上下文中。

图 6.1显示了更多细节 - 中断上下文的上半部分、下半部分、内核线程和工作队列;我们请求您耐心等待,我们将在后面的章节中涵盖所有这些内容以及更多内容:

图 6.1 - 概念图显示了非特权用户模式执行和特权内核模式执行,同时具有进程和中断上下文

在本书的后面,我们将向您展示如何准确检查您的内核代码当前正在运行的上下文。继续阅读!

理解进程虚拟地址空间的基础

虚拟内存的一个基本“规则”是:所有可寻址的内存都在一个盒子里;也就是说,它是沙盒的。我们把这个“盒子”看作进程镜像进程VAS。禁止看盒子外面的东西。

在这里,我们只提供了进程用户虚拟地址空间的快速概述。有关详细信息,请参阅本章末尾的进一步阅读部分。

用户虚拟地址空间被划分为称为或更专业的映射的同质内存区域。每个 Linux 进程至少有这些映射(或段):

图 6.2 - 进程 VAS

让我们快速了解一下这些段或映射的简要情况:

  • 文本段:这是存储机器代码的地方;静态(模式:r-x)。

  • 数据段:全局和静态数据变量存储在这里(模式:rw-)。它内部分为三个不同的段:

  • 初始化数据段:预初始化的变量存储在这里;静态。

  • 未初始化数据段:未初始化的变量存储在这里(在运行时自动初始化为0;这个区域有时被称为bss);静态。

  • 堆段:内存分配和释放的库 API(熟悉的malloc(3)系列例程)从这里获取内存。这也不完全正确。在现代系统上,只有低于MMAP_THRESHOLD(默认为 128 KB)的malloc()实例从堆中获取内存。任何更高的内存都将作为进程 VAS 中的一个单独的“映射”分配(通过强大的mmap(2)系统调用)。它是一个动态段(可以增长/缩小)。堆上的最后一个合法引用位置被称为程序断点

  • 库(文本,数据):所有进程动态链接的共享库都被映射到进程 VAS 中(在运行时,通过加载器)(模式:r-x/rw-)。

  • 堆栈:使用后进先出LIFO)语义的内存区域;堆栈用于实现高级语言的函数调用机制。它包括参数传递、局部变量实例化(和销毁)以及返回值传播。它是一个动态段。在所有现代处理器上(包括 x86 和 ARM 系列),堆栈向较低地址“增长”(称为完全降序堆栈)。每次调用函数时,都会分配并初始化一个堆栈帧;堆栈帧的精确布局非常依赖于 CPU(你必须参考相应的 CPU应用程序二进制接口ABI)文档;参见进一步阅读部分的参考资料)。SP 寄存器(或等效寄存器)始终指向当前帧,堆栈的顶部;由于堆栈向较低(虚拟)地址增长,堆栈的顶部实际上是最低(虚拟)地址!这是不直观但却是真实的(模式:rw-)。

当然,你会理解进程必须包含至少一个执行线程(线程是进程内的执行路径);那个线程通常是main()函数。在图 6.2中,我们举例展示了三个执行线程 - mainthrd2thrd3此外,如预期的那样,每个线程在 VAS 中共享一切,*除了堆栈;正如你所知,每个线程都有自己的私有堆栈。main的堆栈显示在进程(用户)VAS 的顶部;thrd2thrd3线程的堆栈显示在库映射和main的堆栈之间,并用两个(蓝色)方块表示。

我设计并实现了一个我认为非常有用的学习/教学和调试实用程序,名为procmapgithub.com/kaiwan/procmap);它是一个基于控制台的进程 VAS 可视化实用程序。它实际上可以向你展示进程 VAS(非常详细);我们将在下一章开始使用它。不过,这并不妨碍你立即尝试它;在你的 Linux 系统上克隆它并试用一下。

现在你已经了解了进程 VAS 的基础知识,是时候深入了解有关进程 VAS、用户和内核地址空间以及它们的线程和堆栈的内核内部了。

组织进程、线程及其堆栈 - 用户和内核空间

传统的UNIX 进程模型 - 一切都是进程;如果不是进程,就是文件 - 有很多优点。事实上,它仍然是在近五十年的时间跨度之后操作系统遵循的模型,这充分证明了这一点。当然,现在线程很重要;线程只是进程内的执行路径。线程共享所有进程资源,包括用户 VAS,除了堆栈。*每个线程都有自己的私有堆栈区域(这是完全合理的;否则,线程如何能够真正并行运行,因为堆栈保存了执行上下文)。

我们关注线程而不是进程的另一个原因在第十章中更清楚地阐明,CPU 调度器,第一部分**。现在,我们只能说:线程而不是进程是内核可调度实体(也称为 KSE)。这实际上是 Linux 操作系统架构的一个关键方面的结果。在 Linux 操作系统上,每个线程 - 包括内核线程 - 都映射到一个称为任务结构的内核元数据结构。任务结构(也称为进程描述符)本质上是一个大型的内核数据结构,内核将其用作属性结构。对于每个线程,内核维护一个相应的任务结构(见图 6.3*,不用担心,我们将在接下来的部分中更多地介绍任务结构)。

下一个真正关键的要点是:每个特权级别受 CPU 支持的线程都需要一个堆栈。在现代操作系统(如 Linux)中,我们支持两个特权级别 - 非特权用户模式(或用户空间)和特权内核模式(或内核空间)。因此,在 Linux 上,每个用户空间线程都有两个堆栈

  • 用户空间堆栈:当线程执行用户模式代码路径时,此堆栈处于活动状态。

  • 内核空间堆栈:当线程切换到内核模式(通过系统调用或处理器异常)并执行内核代码路径(在进程上下文中)时,此堆栈处于活动状态。

当然,每个好的规则都有例外:内核线程是纯粹存在于内核中的线程,因此只能“看到”内核(虚拟)地址空间;它们无法“看到”用户空间。因此,它们只会执行内核空间代码路径,因此它们只有一个堆栈 - 内核空间堆栈。

图 6.3将地址空间分为两部分 - 用户空间和内核空间。在图的上半部分 - 用户空间 - 您可以看到几个进程及其用户 VASes。在图的底部 - 内核空间 - 您可以看到,对于每个用户模式线程,都有一个内核元数据结构(struct task_struct,我们稍后将详细介绍)和该线程的内核模式堆栈。此外,我们还看到(在底部)三个内核线程(标记为kthrd1kthrd2kthrdn);如预期的那样,它们也有一个表示其内部(属性)的task_struct元数据结构和一个内核模式堆栈:

图 6.3 - 进程、线程、堆栈和任务结构 - 用户和内核 VAS

为了帮助使这个讨论更具实际意义,让我们执行一个简单的 Bash 脚本(ch6/countem.sh),它会计算当前存活的进程和线程的数量。我在我的本机 x86_64 Ubuntu 18.04 LTS 上执行了这个操作;请参阅以下结果输出:

$ cd <booksrc>/ch6
$ ./countem.sh
System release info:
Distributor ID: Ubuntu
Description:    Ubuntu 18.04.4 LTS
Release:        18.04
Codename:       bionic

Total # of processes alive               =       362
Total # of threads alive                 =      1234
Total # of kernel threads alive          =       181
Thus, total # of user-mode threads alive =      1053
$ 

我将让您查看此简单脚本的代码:ch6/countem.sh。研究前面的输出并理解它。当然,您会意识到这是某个时间点的快照。它可能会发生变化。

在接下来的部分中,我们将讨论分成两部分(对应于两个地址空间) - 用户空间中图 6.3 中所见的内容和内核空间中图 6.3 中所见的内容。让我们从用户空间组件开始。

用户空间组织

关于我们在前面部分运行的countem.sh Bash 脚本,我们现在将对其进行分解并讨论一些关键点,目前我们只限于 VAS 的用户空间部分。请注意仔细阅读和理解这一点(我们在下面的讨论中提到的数字是指我们在前面部分运行countem.sh脚本的示例)。为了更好地理解,我在这里放置了图表的用户空间部分:

图 6.4 - 图 6.3 中整体图片的用户空间部分

在这里(图 6.4)你可以看到三个单独的进程。每个进程至少有一个执行线程(main()线程)。在前面的示例中,我们展示了三个进程P1P2Pn,分别包含一个,三个和两个线程,包括main()。从我们之前在countem.sh脚本的示例运行中,Pn将有n=362。

请注意,这些图表纯粹是概念性的。实际上,具有 PID 2 的“进程”通常是一个名为kthreadd的单线程内核线程。

每个进程由几个段(技术上是映射)组成。广义上,用户模式段(映射)如下:

  • 文本:代码; r-x

  • 数据段rw-;包括三个不同的映射 - 初始化数据段,未初始化数据段(或bss),以及一个“向上增长”的heap

  • 库映射:对于进程动态链接到的每个共享库的文本和数据

  • 向下增长的堆栈

关于这些堆栈,我们从之前的示例运行中看到系统上目前有 1,053 个用户模式线程。这意味着也有 1,053 个用户空间堆栈,因为每个用户模式线程都会存在一个用户模式堆栈。关于这些用户空间线程堆栈,我们可以说以下内容:

  • 每个用户空间堆栈始终存在于main()线程,它将位于用户 VAS 的顶部 - 高端附近; 如果进程是单线程的(只有一个main()线程),那么它将只有一个用户模式堆栈; 图 6.4中的P1进程显示了这种情况。

  • 如果进程是多线程的,它将对每个活动的线程(包括main())有一个用户模式线程堆栈;图 6.4中的进程P2Pn说明了这种情况。这些堆栈要么在调用fork(2)(对于main())时分配,要么在进程内的pthread_create(3)(对于进程内的其他线程)时分配,这将导致内核中的进程上下文中执行这段代码路径:

sys_fork() --> do_fork() --> _do_fork()
  • 顺便说一下,在 Linux 上,pthread_create(3)库 API 调用(非常特定于 Linux)clone(2)系统调用;这个系统调用最终调用_do_fork();传递的clone_flags参数告诉内核如何创建“自定义进程”;换句话说,一个线程!

  • 这些用户空间堆栈当然是动态的;它们可以增长/缩小到堆栈大小资源限制(RLIMIT_STACK,通常为 8 MB;您可以使用prlimit(1)实用程序查找它)。

在看到并理解了用户空间部分之后,现在让我们深入了解内核空间的情况。

内核空间组织

继续我们关于在前面部分运行的countem.sh Bash 脚本的讨论,我们现在将对其进行分解并讨论一些关键点,目前我们只限于 VAS 的内核空间部分。请注意仔细阅读和理解这一点(在阅读我们在前面部分运行的countem.sh脚本时输出的数字)。为了更好地理解,我在这里放置了图表的内核空间部分(图 6.5):

图 6.5 - 图 6.3 中整体图片的内核空间部分

再次,从我们之前的样本运行中,您可以看到系统上目前有 1,053 个用户模式线程和 181 个内核线程。这导致了总共 1,234 个内核空间堆栈。为什么?如前所述,每个用户模式线程都有两个堆栈-一个用户模式堆栈和一个内核模式堆栈。因此,我们将为每个用户模式线程有 1,053 个内核模式堆栈,以及为(纯粹的)内核线程有 181 个内核模式堆栈(请记住,内核线程只有一个内核模式堆栈;它们根本无法“看到”用户空间)。让我们列出内核模式堆栈的一些特征:

  • 每个应用程序(用户模式)线程都将有一个内核模式堆栈,包括main()

  • 内核模式堆栈的大小是固定的(静态的)且非常小。从实际角度来看,它们在 32 位操作系统上的大小为 2 页,在 64 位操作系统上的大小为 4 页(每页通常为 4 KB)。

  • 它们在线程创建时分配(通常归结为_do_fork())。

再次,让我们对此非常清楚:每个用户模式线程都有两个堆栈-一个用户模式堆栈和一个内核模式堆栈。这一规则的例外是内核线程;它们只有一个内核模式堆栈(因为它们没有用户映射,因此没有用户空间“段”)。在图 6.5的下部,我们展示了三个内核线程- kthrd1kthrd2kthrdn(在我们之前的样本运行中,kthrdn将为n=181)。此外,每个内核线程在创建时都有一个任务结构和一个内核模式堆栈分配给它。

内核模式堆栈在大多数方面与用户模式堆栈相似-每次调用函数时,都会设置一个堆栈帧(帧布局特定于体系结构,并且是 CPU ABI 文档的一部分;有关这些细节的更多信息,请参见进一步阅读部分);CPU 有一个寄存器来跟踪堆栈的当前位置(通常称为堆栈指针SP)),堆栈“向较低虚拟地址增长”。但是,与动态用户模式堆栈不同,内核模式堆栈的大小是固定的且较小

对于内核/驱动程序开发人员来说,非常重要的一个含义是内核模式堆栈的大小相当小(两页或四页),因此要非常小心,不要通过执行堆栈密集型工作(如递归)来溢出内核堆栈。

存在一个内核可配置项,可以在编译时警告您关于高(内核)堆栈使用情况;以下是来自lib/Kconfig.debug文件的文本:

CONFIG_FRAME_WARN:

“告诉 gcc 在构建时警告堆栈帧大于此值。”

“设置得太低会导致很多警告。”

“将其设置为 0 会禁用警告。”

“需要 gcc 4.4”

总结当前情况

好的,现在让我们总结一下我们从countem.sh脚本的先前样本运行中学到的内容和发现的内容:

  • 任务结构

  • 每个活动的线程(用户或内核)在内核中都有一个相应的任务结构(struct task_struct);这是内核跟踪它的方式,所有属性都存储在这里(您将在理解和访问内核任务结构部分中了解更多)。

  • 关于我们ch6/countem.sh脚本的样本运行:

  • 由于系统上有总共 1,234 个线程(用户和内核),这意味着内核内存中有 1,234 个任务(元数据)结构(在代码中为struct task_struct),我们可以说以下内容:

  • 1,053 个这些任务结构代表用户线程。

  • 剩下的 181 个任务结构代表内核线程。

  • 堆栈

  • 每个用户空间线程都有两个堆栈:

  • 当线程执行用户模式代码路径时,会有一个用户模式堆栈。

  • 内核模式堆栈(在线程执行内核模式代码路径时发挥作用)

  • 纯内核线程只有一个堆栈-内核模式堆栈

  • 关于我们ch6/countem.sh脚本的样本运行:

  • 1,053 个用户空间堆栈(在用户空间)。

  • 1,053 个内核空间堆栈(在内核内存中)。

  • 181 个内核空间堆栈(对应活动的 181 个内核线程)。

  • 这总共有 1053+1053+181 = 2,287 个堆栈!

在讨论用户和内核模式堆栈时,我们还应该简要提到这一点:许多体系结构(包括 x86 和 ARM64)支持为中断处理支持单独的每 CPU 堆栈。当外部硬件中断发生时,CPU 的控制单元立即将控制重新定向到最终的中断处理代码(可能在设备驱动程序内)。单独的每 CPU 中断堆栈用于保存中断代码路径的堆栈帧;这有助于避免对被中断的进程/线程的现有(小)内核模式堆栈施加太大压力。

好的,现在你了解了进程/线程及其堆栈的用户空间和内核空间的整体组织,让我们继续看看你如何实际“查看”内核和用户空间堆栈的内容。除了用于学习目的外,这些知识还可以在调试情况下极大地帮助你。

查看用户和内核堆栈

堆栈通常是调试会话的关键。当然,堆栈保存了进程或线程的当前执行上下文 – 它现在在哪里 – 这使我们能够推断它在做什么。更重要的是,能够看到和解释线程的调用堆栈(或调用链/回溯)至关重要,这使我们能够准确理解我们是如何到达这里的。所有这些宝贵的信息都驻留在堆栈中。但等等,每个线程都有两个堆栈 – 用户空间和内核空间堆栈。我们如何查看它们呢?

在这里,我们将展示查看给定进程或线程的内核和用户模式堆栈的两种广泛方法,首先是通过“传统”方法,然后是更近代的方法(通过[e]BPF)。请继续阅读。

查看堆栈的传统方法

让我们首先学习使用我们将称之为“传统”方法来查看给定进程或线程的内核和用户模式堆栈。让我们从内核模式堆栈开始。

查看给定线程或进程的内核空间堆栈

好消息;这真的很容易。Linux 内核通过通常的机制使堆栈可见,以将内核内部暴露给用户空间 – 强大的 proc 文件系统接口。只需查看 /proc/<pid>/stack

所以,好吧,让我们查看一下我们 Bash 进程的内核模式堆栈。假设在我们的 x86_64 Ubuntu 客户机上(运行 5.4 内核),我们的 Bash 进程的 PID 是 3085

在现代内核上,为了避免信息泄漏,查看进程或线程的内核模式堆栈需要root访问权限作为安全要求。

$ sudo cat /proc/3085/stack
[<0>] do_wait+0x1cb/0x230
[<0>] kernel_wait4+0x89/0x130
[<0>] __do_sys_wait4+0x95/0xa0
[<0>] __x64_sys_wait4+0x1e/0x20
[<0>] do_syscall_64+0x5a/0x120
[<0>] entry_SYSCALL_64_after_hwframe+0x44/0xa9
$ 

在前面的输出中,每行代表堆栈上的一个调用帧。为了帮助解释内核堆栈回溯,了解以下几点是值得的:

  • 应该以自下而上的方式阅读(从底部到顶部)。

  • 每行输出代表一个 调用帧;实际上是调用链中的一个函数。

  • 出现为 ?? 的函数名意味着内核无法可靠地解释堆栈。忽略它,这是内核说这是一个无效的堆栈帧(留下的“闪烁”);内核回溯代码通常是正确的!

  • 在 Linux 上,任何 foo() 系统调用通常会成为内核中的 SyS_foo() 函数。而且,很多时候但并非总是,SyS_foo() 是一个调用“真正”代码 do_foo() 的包装器。一个细节:在内核代码中,你可能会看到 SYSCALL_DEFINEn(foo, ...) 这种类型的宏;这个宏会变成 SyS_foo() 例程;附加的数字 n 在 [0, 6] 范围内;它是从用户空间传递给内核的系统调用的参数数量。

现在再看一下前面的输出;应该很清楚:我们的 Bash 进程目前正在执行 do_wait() 函数;它是通过系统调用 wait4() 这个系统调用到达那里的!这是完全正确的;shell 通过 fork 出一个子进程,然后通过 wait4(2) 系统调用等待其终止。

好奇的读者(您!)应该注意,在前面片段中显示的每个堆栈帧的最左列中的[<0>]是该函数的文本(代码)地址的占位符。出于安全原因(防止信息泄漏),它在现代内核上被清零。 (与内核和进程布局相关的另一个安全措施在第七章中讨论,内存管理内部-基本知识,在KASLR用户模式 ASLR部分中讨论了随机化内存布局)。

查看给定线程或进程的用户空间堆栈

具有讽刺意味的是,在典型的 Linux 发行版上查看进程或线程的用户空间堆栈似乎更难(与我们刚刚在前一节中看到的查看内核模式堆栈相反)。有一个实用程序可以做到这一点:gstack(1)。实际上,它只是一个简单的包装器,通过批处理模式调用gdb(1),让gdb调用它的backtrace命令。

很遗憾,在 Ubuntu(至少是 18.04 LTS)上似乎存在一个问题;在任何本地软件包中都找不到gstack程序。(Ubuntu 确实有一个pstack(1)实用程序,但至少在我的测试 VM 上,它无法正常工作。)一个解决方法是直接使用gdb(您可以始终attach <PID>并发出[thread apply all] bt命令来查看用户模式堆栈)。

然而,在我的 x86_64 Fedora 29 客户系统上,gstack(1)实用程序安装和运行良好;一个示例如下(我们的 Bash 进程的 PID 恰好是12696):

$ gstack 12696
#0 0x00007fa6f60754eb in waitpid () from /lib64/libc.so.6
#1 0x0000556f26c03629 in ?? ()
#2 0x0000556f26c04cc3 in wait_for ()
#3 0x0000556f26bf375c in execute_command_internal ()
#4 0x0000556f26bf39b6 in execute_command ()
#5 0x0000556f26bdb389 in reader_loop ()
#6 0x0000556f26bd9b69 in main ()
$ 

同样,每行代表一个调用帧。从下到上阅读。显然,Bash执行一个命令,最终调用waitpid()系统调用(实际上,在现代 Linux 系统上,waitpid()只是对实际wait4(2)系统调用的glibc包装器!再次,简单地忽略任何标记为??的调用帧)。

能够窥视内核和用户空间堆栈(如前面的片段所示),并使用包括strace(1)ltrace(1)在内的实用程序分别跟踪进程/线程的系统和库调用,可以在调试时提供巨大的帮助!不要忽视它们。

现在,对于这个问题的“现代”方法。

[e]BPF-查看两个堆栈的现代方法

现在-更加令人兴奋!-让我们学习(基本知识)使用一种强大的现代方法,利用(在撰写本文时)非常新的技术-称为扩展伯克利数据包过滤器eBPF;或简称 BPF。我们在第一章中提到过[e]BPF 项目,内核工作空间设置,在其他有用的项目部分下。)旧的 BPF 已经存在很长时间,并且已经用于网络数据包跟踪;[e]BPF 是一个最近的创新,仅在 4.x Linux 内核中可用(这当然意味着您需要在 4.x 或更近的 Linux 系统上使用这种方法)。

直接使用底层内核级 BPF 字节码技术(极其)难以做到;因此,好消息是有几个易于使用的前端(工具和脚本)可以使用这项技术。(显示当前 BCC 性能分析工具的图表可以在www.brendangregg.com/BPF/bcc_tracing_tools_early2019.png找到;[e]BPF 前端的列表可以在www.brendangregg.com/ebpf.html#frontends找到;这些链接来自Brendan Gregg的博客。)在前端中,BCCbpftrace被认为非常有用。在这里,我们将简单地使用一个名为stackcount的 BCC 工具进行快速演示(至少在 Ubuntu 上它的名称是stackcount-bpfcc(8))。另一个优势是使用这个工具可以同时看到内核和用户模式堆栈;不需要单独的工具。

您可以通过阅读此处的安装说明在主机Linux 发行版上安装 BCC 工具:github.com/iovisor/bcc/raw/master/INSTALL.md。为什么不能在我们的 Linux 虚拟机上安装?您可以在运行发行版内核(例如 Ubuntu 或 Fedora 提供的内核)时安装。原因是:BCC 工具集的安装包括linux-headers-$(uname -r)包的安装;后者仅适用于发行版内核(而不适用于我们在虚拟机上运行的自定义 5.4 内核)。

在以下示例中,我们使用stackcount BCC 工具(在我的 x86_64 Ubuntu 18.04 LTS 主机系统上)来查找我们的 VirtualBox Fedora31 客户机进程的堆栈(毕竟,虚拟机是主机系统上的一个进程!)。对于这个工具,您必须指定一个感兴趣的函数(或函数)(有趣的是,您可以在这样做时指定用户空间或内核空间函数,并且还可以使用“通配符”或正则表达式!);只有在调用这些函数时,堆栈才会被跟踪和报告。例如,我们选择包含名称malloc的任何函数:

$ sudo stackcount-bpfcc -p 29819 -r ".*malloc.*" -v -d
Tracing 73 functions for ".*malloc.*"... Hit Ctrl-C to end.
^C
 ffffffff99a56811 __kmalloc_reserve.isra.43
 ffffffff99a59436 alloc_skb_with_frags
 ffffffff99a51f72 sock_alloc_send_pskb
 ffffffff99b2e986 unix_stream_sendmsg
 ffffffff99a4d43e sock_sendmsg
 ffffffff99a4d4e3 sock_write_iter
 ffffffff9947f59a do_iter_readv_writev
 ffffffff99480cf6 do_iter_write
 ffffffff99480ed8 vfs_writev
 ffffffff99480fb8 do_writev
 ffffffff99482810 sys_writev
 ffffffff99203bb3 do_syscall_64
 ffffffff99c00081 entry_SYSCALL_64_after_hwframe
   --
 7fd0cc31b6e7     __GI___writev
 12bc             [unknown]
 600000195        [unknown]
 1
[...]

[e]BPF 程序可能由于合并到主线 5.4 内核的新内核锁定功能而失败(尽管默认情况下已禁用)。这是一个Linux 安全模块LSM),它在 Linux 系统上启用了额外的“硬”安全级别。当然,安全性是一把双刃剑;拥有一个非常安全的系统意味着某些事情将无法按预期工作,其中包括一些 BPF 程序。有关内核锁定的更多信息,请参阅进一步阅读部分。

传递的-d选项开关打印分隔符--;它表示进程的内核模式和用户模式堆栈之间的边界。(不幸的是,由于大多数生产用户模式应用程序将剥离其符号信息,因此大多数用户模式堆栈帧只会显示为“[unknown]”。)至少在这个系统上,内核堆栈帧非常清晰;甚至打印了所讨论的文本(代码)函数的虚拟地址。 (为了帮助您更好地理解堆栈跟踪:首先,从下到上阅读它;其次,如前所述,在 Linux 上,任何foo()系统调用通常会成为内核中的SyS_foo()函数,并且通常SyS_foo()do_foo()的包装函数。)

请注意,stackcount-bpfcc工具仅适用于 Linux 4.6+,并且需要 root 访问权限。有关详细信息,请参阅其手册页。

作为第二个更简单的示例,我们编写一个简单的Hello, world程序(有一个无限循环的警告,以便我们可以捕获发生的write(2)系统调用),启用符号信息构建它(也就是说,使用gcc -g ...),并使用一个简单的 Bash 脚本执行与以前相同的工作:跟踪内核和用户模式堆栈的执行过程。(您将在ch6/ebpf_stacktrace_eg/中找到代码。)显示示例运行的屏幕截图(好吧,这里有一个例外:我在 x86_64 Ubuntu 20.04 LTS 主机上运行了脚本)如下:

图 6.6 - 使用 stackcount-bpfcc BCC 工具对我们的 Hello, world 进程的内核和用户模式堆栈进行跟踪的示例运行

我们在这里只是浅尝辄止;BPF 工具,如 BCC 和bpftrace,确实是在 Linux 操作系统上进行系统、应用程序跟踪和性能分析的现代、强大方法。确实要花时间学习如何使用这些强大的工具!(每个 BCC 工具都有专门的手册带有示例。)我们建议您参考进一步阅读部分,了解有关 BPF、BCC 和bpftrace的链接。

让我们通过放大镜来总结本节,看看到目前为止您学到了什么!

进程 VAS 的一览无余

在我们结束本节之前,重要的是退后一步,看看每个进程的完整 VAS,以及它对整个系统的外观;换句话说,放大并查看完整系统地址空间的“一览无余”。这就是我们尝试用以下相当大而详细的图表(图 6.7)来做的。

对于那些阅读本书的纸质副本的人,我强烈建议您从此 PDF 文档中以全彩色查看本书的图表static.packt-cdn.com/downloads/9781789953435_ColorImages.pdf

除了您刚刚了解和看到的内容 - 进程用户空间段、(用户和内核)线程和内核模式堆栈 - 不要忘记内核中还有许多其他元数据:任务结构、内核线程、内存描述符元数据结构等等。它们都是内核 VAS的一部分,通常被称为内核段。内核段中除了任务和堆栈之外还有更多内容。它还包含(显然!)静态内核(核心)代码和数据,实际上,内核的所有主要(和次要)子系统,特定于架构的代码等等(我们在第四章,编写您的第一个内核模块 - LKMs 第一部分中讨论过)。

正如刚才提到的,以下图表试图总结并展示所有(或大部分)这些信息:

图 6.7 - 用户和内核 VAS 的进程、线程、堆栈和任务结构的一览无余

哇,这是相当复杂的事情,不是吗?在前面图表中的红色框圈出了核心内核代码和数据 - 主要的内核子系统,并显示了任务结构和内核模式堆栈。其余部分被认为是非核心内容;这包括设备驱动程序。(特定于架构的代码可以被认为是核心代码;我们只是在这里单独显示它。)此外,不要让前面的信息使您感到不知所措;只需专注于我们现在关注的内容 - 进程、线程、它们的任务结构和堆栈。如果您仍然不清楚,请务必重新阅读前面的材料。

现在,让我们继续真正理解并学习如何引用每个活动线程的关键或“根”元数据结构 - 任务结构。

理解和访问内核任务结构

正如您现在所了解的,每个用户空间和内核空间线程在 Linux 内核中都由一个包含其所有属性的元数据结构表示 - 任务结构。任务结构在内核代码中表示为include/linux/sched.h:struct task_struct

不幸的是,它经常被称为“进程描述符”,导致了无尽的混乱!幸运的是,短语任务结构要好得多;它代表了一个可运行的任务,实际上是一个线程

因此,在 Linux 设计中,每个进程由一个或多个线程组成,每个线程映射到一个称为任务结构的内核数据结构struct task_struct)。

任务结构是线程的“根”元数据结构 - 它封装了操作系统为该线程所需的所有信息。这包括关于其内存(段、分页表、使用信息等)、CPU 调度详细信息、当前打开的任何文件、凭据、能力位掩码、定时器、锁定、异步 I/O(AIO)上下文、硬件上下文、信令、IPC 对象、资源限制、(可选)审计、安全和分析信息等等。

图 6.8是 Linux 内核任务结构的概念表示,以及它包含的大部分信息(元数据)。

图 6.8 - Linux 内核任务结构:struct task_struct

图 6.8可以看出,任务结构包含有关系统上每个单个任务(进程/线程)的大量信息(再次强调:这也包括内核线程)。我们以图 6.8 中的分隔概念格式显示了此数据结构中封装的不同类型的属性。此外,可以看到,某些属性将被继承给子进程或线程在fork(2)(或pthread_create(3))时;某些属性将不会被继承,而将仅仅被重置。(内核模式堆栈为

至少目前,可以说内核“了解”任务是进程还是线程。我们稍后将演示一个内核模块(ch6/foreach/thrd_showall),它将准确展示我们如何确定这一点(稍等,我们会到那里的!)。

现在让我们开始更详细地了解任务结构中一些更重要的成员;继续阅读!

在这里,我只打算让你对内核任务结构有一个“感觉”;我们现在不需要深入细节。在本书的后面部分,我们将根据需要深入研究特定领域。

查看任务结构

首先,回想一下任务结构本质上是进程或线程的“根”数据结构 - 它包含任务的所有属性(正如我们之前所见)。因此,它相当庞大;强大的crash(8)实用程序(用于分析 Linux 崩溃转储数据或调查活动系统)报告其在 x86_64 上的大小为 9,088 字节,sizeof操作符也是如此。

任务结构在include/linux/sched.h内核头文件中定义(这是一个相当关键的头文件)。在以下代码中,我们显示了它的定义,并且要注意我们只显示了其中的一些成员。(另外,像这样的<<尖括号注释>>用于非常简要地解释成员):

// include/linux/sched.h
struct task_struct {
#ifdef CONFIG_THREAD_INFO_IN_TASK
    /*
     * For reasons of header soup (see current_thread_info()), this
     * must be the first element of task_struct.
     */
    struct thread_info      thread_info;   << important flags and status bits >>
#endif
    /* -1 unrunnable, 0 runnable, >0 stopped: */
    volatile long           state;
    [...]
    void                *stack; << the location of the kernel-mode stack >>
    [...]
    /* Current CPU: */
    unsigned int cpu;
    [...]
<< the members that follow are to do with CPU scheduling; some of them are discussed in Ch 9 & 10 on CPU Scheduling >>
    int on_rq;
    int prio;
    int static_prio;
    int normal_prio;
    unsigned int rt_priority;
    const struct sched_class *sched_class;
    struct sched_entity se;
    struct sched_rt_entity rt;
    [...]

在以下代码块中继续查看任务结构,查看与内存管理(mm)、PID 和 TGID 值、凭据结构、打开文件、信号处理等相关的成员。再次强调,不打算(全部)详细研究它们;在本章的后续部分以及可能在本书的其他章节中,我们将重新讨论它们:

    [...]
    struct mm_struct *mm;      << memory management info >>
    struct mm_struct *active_mm;
    [...]
    pid_t pid;      << task PID and TGID values; explained below >>
    pid_t tgid;
    [...]
    /* Context switch counts: */
    unsigned long nvcsw;
    unsigned long nivcsw;
    [...]
    /* Effective (overridable) subjective task credentials (COW): */
    const struct cred __rcu *cred;
    [...]
    char comm[TASK_COMM_LEN];             << task name >>
    [...]
     /* Open file information: */
    struct files_struct *files;      << pointer to the 'open files' ds >>
    [...]
     /* Signal handlers: */
    struct signal_struct *signal;
    struct sighand_struct *sighand;
    sigset_t blocked;
    sigset_t real_blocked;
    [...]
#ifdef CONFIG_VMAP_STACK
    struct vm_struct *stack_vm_area;
#endif
    [...]
#ifdef CONFIG_SECURITY
    /* Used by LSM modules for access restriction: */
    void *security;
#endif
    [...]
    /* CPU-specific state of this task: */
    struct thread_struct thread;       << task hardware context detail >>
    [...]
};

请注意,前述代码中的struct task_struct成员是根据 5.4.0 内核源代码显示的;在其他内核版本中,成员可能会发生变化!当然,毋庸置疑,整本书都是如此 - 所有代码/数据都是基于 5.4.0 LTS Linux 内核呈现的(将在 2025 年 12 月之前维护)。

好了,现在你对任务结构内的成员有了更好的了解,那么你如何访问它及其各个成员呢?继续阅读。

使用 current 访问任务结构

你会回忆,在前述countem.sh脚本的示例运行中(在组织进程、线程及其堆栈 - 用户空间和内核空间部分),我们发现系统上有总共 1,234 个线程(用户和内核)是活跃的。这意味着内核内存中将有 1,234 个任务结构对象。

它们需要以内核可以在需要时轻松访问它们的方式进行组织。因此,内核内存中的所有任务结构对象都被链接到一个称为任务列表循环双向链表上。这种组织方式是为了使各种内核代码路径可以对它们进行迭代(通常是procfs代码等)。即使如此,请考虑这一点:当一个进程或线程在运行内核代码(在进程上下文中)时,它如何找出在内核内存中存在的数百或数千个task_struct中属于它的那个?这事实上是一个非平凡的任务。内核开发人员已经发展出一种方法来保证您可以找到代表当前运行内核代码的线程的特定任务结构。这是通过一个名为current的宏实现的。可以这样理解:

  • 查找current会返回正在运行内核代码的线程的task_struct指针,换句话说,当前在某个特定处理器核心上运行的进程上下文

  • current类似(但当然不完全相同)于面向对象语言中称为this指针的东西。

current宏的实现非常特定于体系结构。在这里,我们不深入研究这些令人讨厌的细节。可以说,实现经过精心设计,以便快速(通常通过O(1)算法)。例如,在一些具有许多通用寄存器的精简指令集计算机RISC)体系结构上(例如 PowerPC 和 Aarch64 处理器),有一个寄存器专门用于保存current的值!

我建议您浏览内核源树并查看current的实现细节(在arch/<arch>/asm/current.h下)。在 ARM32 上,O(1)计算会产生结果;在 AArch64 和 PowerPC 上,它存储在寄存器中(因此查找速度非常快)。在 x86_64 架构中,实现使用per-cpu 变量来保存current(避免使用昂贵的锁定)。在您的代码中包含<linux/sched.h>头文件是必需的,以包含current的定义。

我们可以使用current来解引用任务结构并从中获取信息;例如,可以按以下方式查找进程(或线程)PID 和名称:

#include <linux/sched.h>
current->pid, current->comm

在下一节中,您将看到一个完整的内核模块,它会遍历任务列表,并打印出它遇到的每个任务结构的一些细节。

确定上下文

正如您现在所知,内核代码在两种上下文中运行之一:

  • 进程(或任务)上下文

  • 中断(或原子)上下文

它们是互斥的-内核代码在任何给定时间点都在进程或原子/中断上下文中运行。

在编写内核或驱动程序代码时,通常需要首先弄清楚您正在处理的代码运行在什么上下文中。了解这一点的一种方法是使用以下宏:

#include <linux/preempt.h>
 in_task()

它返回一个布尔值:如果您的代码在进程(或任务)上下文中运行,则返回True,在这种情况下通常可以安全休眠;返回False意味着您处于某种原子或中断上下文中,这种情况下永远不安全休眠。

您可能已经遇到了in_interrupt()宏的用法;如果它返回True,则您的代码在中断上下文中,如果返回False,则不在。然而,对于现代代码的建议是依赖于这个宏(因为Bottom HalfBH)禁用可能会干扰这一点)。因此,我们建议使用in_task()代替。

但是要注意!这可能会有点棘手:虽然in_task()返回True意味着您的代码处于进程上下文中,但这个事实本身并保证当前安全休眠。休眠实际上意味着调用调度程序代码和随后的上下文切换(我们在第十章 CPU 调度程序-第一部分和第十一章 CPU 调度程序-第二部分中详细介绍了这一点)。例如,您可能处于进程上下文,但持有自旋锁(内核中非常常用的锁);在锁定和解锁之间的代码-所谓的临界区 -必须以原子方式运行!这意味着尽管您的代码可能处于进程(或任务)上下文中,但如果尝试发出任何阻塞(休眠)API,仍会导致错误!

还要注意:只有在进程上下文中运行时,current才被认为是有效的。

是的;到目前为止,您已经学到了有关任务结构的有用背景信息,以及如何通过current宏访问它,以及这样做的注意事项-例如弄清楚您的内核或驱动程序代码当前运行的上下文。因此,现在,让我们实际编写一些内核模块代码来检查内核任务结构的一部分。

通过当前使用任务结构

在这里,我们将编写一个简单的内核模块,以显示任务结构的一些成员,并揭示其进程上下文以及其初始化清理代码路径运行的情况。为此,我们编写一个show_ctx()函数,它使用current来访问任务结构的一些成员并显示它们的值。它被从initcleanup方法中调用,如下所示:

出于可读性和空间限制的原因,这里只显示了源代码的关键部分。本书的整个源代码树都可以在其 GitHub 存储库中找到;我们希望您克隆并使用它:git clone https://github.com/PacktPublishing/Linux-Kernel-Programming.git

/* code: ch6/current_affairs/current_affairs.c */[ ... ]
#include <linux/sched.h>     /* current */
#include <linux/cred.h>      /* current_{e}{u,g}id() */
#include <linux/uidgid.h>    /* {from,make}_kuid() */
[...]
#define OURMODNAME    "current_affairs"
[ ... ]

static void show_ctx(char *nm)
{
    /* Extract the task UID and EUID using helper methods provided */
    unsigned int uid = from_kuid(&init_user_ns, current_uid());
    unsigned int euid = from_kuid(&init_user_ns, current_euid());

    pr_info("%s:%s():%d ", nm, __func__, __LINE__);
    if (likely(in_task())) {
                pr_info(
                "%s: in process context ::\n"
                " PID         : %6d\n"
                " TGID        : %6d\n"
                " UID         : %6u\n"
                " EUID        : %6u (%s root)\n"
                " name        : %s\n"
                " current (ptr to our process context's task_struct) :\n"
                "           0x%pK (0x%px)\n"
 " stack start : 0x%pK (0x%px)\n",
                nm, 
                /* always better to use the helper methods provided */
                task_pid_nr(current), task_tgid_nr(current), 
                /* ... rather than the 'usual' direct lookups:
                    current->pid, current->tgid, */
                uid, euid,
                (euid == 0?"have":"don't have"),
                current->comm,
                current, current,
 current->stack, current->stack);
    } else
      pr_alert("%s: in interrupt context [Should NOT Happen here!]\n", nm);
}

正如前面的代码中所用粗体标出的那样,您可以看到(对于某些成员),我们可以简单地对current指针进行解引用,以访问各种task_struct成员并显示它们(通过内核日志缓冲区)。

太好了!前面的代码片段确实向您展示了如何通过current直接访问一些task_struct成员;但并非所有成员都可以或应该直接访问。相反,内核提供了一些辅助方法来访问它们;让我们接下来深入了解一下。

内置内核辅助方法和优化

在前面的代码中,我们使用了内核的一些内置辅助方法来提取任务结构的各个成员。这是推荐的方法;例如,我们使用task_pid_nr()来查看 PID 成员,而不是直接通过current->pid。同样,任务结构中的进程凭据(例如我们在前面的代码中显示的EUID成员)在struct cred中进行了抽象,并且通过辅助例程提供对它们的访问,就像我们在前面的代码中使用的from_kuid()一样。类似地,还有其他几种辅助方法;在include/linux/sched.h中的struct task_struct定义的下方查找它们。

为什么会这样?为什么不直接通过current-><member-name>访问任务结构成员?嗯,有各种真正的原因;也许访问需要获取(我们在本书的最后两章中详细介绍了锁定和同步的关键主题)。也许有更优化的访问方式;继续阅读以了解更多...

此外,正如前面的代码所示,我们可以通过使用in_task()宏轻松地确定内核代码(我们的内核模块)是在进程还是中断上下文中运行-如果在进程(或任务)上下文中,则返回True,否则返回False

有趣的是,我们还使用likely()宏(它变成了一个编译器__built-in_expect属性)来给编译器的分支预测设置一个提示,并优化被送入 CPU 流水线的指令序列,从而保持我们的代码在“快速路径”上(关于likely()/unlikely()宏的微优化,可以在本章的进一步阅读部分找到更多信息)。您会经常看到内核代码在开发者“知道”代码路径是可能还是不太可能的情况下使用likely()/unlikely()宏。

前面的[un]likely()宏是微优化的一个很好的例子,展示了 Linux 内核如何利用gcc(1)编译器。事实上,直到最近,Linux 内核只能使用gcc进行编译;最近的补丁正在慢慢地使得使用clang(1)进行编译成为现实。(值得一提的是,现代的Android 开源项目AOSP)是使用clang进行编译的。)

好了,现在我们已经了解了我们的内核模块的show_ctx()函数的工作原理,让我们试一试。

尝试使用内核模块打印进程上下文信息

我们构建我们的current_affair.ko内核模块(这里不显示构建输出),然后将其插入到内核空间(通常使用insmod(8))。现在让我们使用dmesg(1)查看内核日志,然后使用rmmod(8)卸载它并再次使用dmesg(1)。以下截图显示了这一过程:

图 6.9 - current_affairs.ko 内核模块的输出

显然,从前面的截图中可以看出,进程上下文 - 运行current_affairs.ko:current_affairs_init()内核代码的进程(或线程) - 是insmod进程(查看输出:'name        : insmod'),而执行清理代码的current_affairs.ko:current_affairs_exit()进程上下文是rmmod进程!

请注意前面图中左列的时间戳([sec.usec]),它们帮助我们理解rmmodinsmod后约 11 秒被调用。

这个小型演示内核模块的内涵远不止表面看到的那么简单。它实际上对于理解 Linux 内核架构非常有帮助。接下来的部分将解释为什么如此。

看到 Linux 操作系统是单片式的

除了使用current宏的练习之外,这个内核模块(ch6/current_affairs)的一个关键点是清楚地向您展示了 Linux 操作系统的单片式特性。在前面的代码中,我们看到当我们对我们的内核模块文件(current_affairs.ko)执行insmod(8)进程时,它被插入到内核中并且其init代码路径运行了;谁运行了它? 啊,这个问题通过检查输出得到了答案:insmod进程本身在进程上下文中运行它,从而证明了 Linux 内核的单片式特性!(rmmod(8)进程和cleanup代码路径也是如此;它是由rmmod进程在进程上下文中运行的。)

请注意并清楚地注意:没有一个“内核”(或内核线程)执行内核模块的代码,而是用户空间进程(或线程)本身通过发出系统调用(回想一下insmod(8)rmmod(8)工具都发出系统调用)切换到内核空间并执行内核模块的代码。这就是单片式内核的工作原理。

当然,这种内核代码的执行方式就是我们所说的在进程上下文中运行,与在中断上下文中运行相对。然而,Linux 内核并不被认为是纯粹的单片式;如果是这样的话,它将是一个硬编码的内存块。相反,像所有现代操作系统一样,Linux 支持模块化(通过 LKM 框架)。

顺便提一下,您可以在内核空间内创建和运行内核线程;当调度时,它们仍然在进程上下文中执行内核代码。

使用 printk 进行安全编码

在我们之前的内核模块演示(ch6/current_affairs/current_affairs.c)中,你可能已经注意到了printk与'特殊'%pK格式说明符的使用。我们在这里重复相关的代码片段:

 pr_info(
 [...]
     " current (ptr to our process context's task_struct) :\n"
     " 0x%pK (0x%px)\n"
     " stack start : 0x%pK (0x%px)\n",
     [...]
     current, (long unsigned)current,
     current->stack, (long unsigned)current->stack); [...]

回想一下我们在第五章中的讨论,编写你的第一个内核模块 - LKMs 第二部分,在影响系统日志的 Proc 文件系统可调参数部分,当打印地址时(首先,在生产中你真的不应该打印地址),我敦促你不要使用通常的 %p(或 %px),而是使用%pK格式说明符。这就是我们在前面的代码中所做的;这是为了安全以防止内核信息泄漏。在一个经过良好调整(为安全)的系统中,%pK 会产生一个简单的哈希值,而不是显示实际地址。为了证明这一点,我们还通过 0x%px 格式说明符显示实际的内核地址,以进行对比。

有趣的是,%pK 在默认桌面版的 Ubuntu 18.04 LTS 系统上似乎没有效果。两种格式——%pK0x%px——打印出来的值是相同的(如图 6.9 所示);这不是预期的结果。然而,在我的 x86_64 Fedora 31 VM 上,它确实按预期工作,使用 %pK 会产生一个简单的哈希(不正确)值,而使用 0x%px 会产生正确的内核地址。以下是我在 Fedora 31 VM 上的相关输出:

$ sudo insmod ./current_affairs.ko
[...]
$ dmesg
[...]
name : insmod
 current (ptr to our process context's task_struct) :
 0x0000000049ee4bd2 (0xffff9bd6770fa700)
 stack start : 0x00000000c3f1cd84 (0xffffb42280c68000)
[...]

在前面的输出中,我们可以清楚地看到区别。

在生产系统(嵌入式或其他)中要保持安全:将kernel.kptr_restrict设置为1(或者更好的是2),从而对指针进行清理,并将kernel.dmesg_restrict设置为1(只允许特权用户读取内核日志)。

现在,让我们转向更有趣的事情:在接下来的部分,你将学习如何迭代 Linux 内核的任务列表,从而实际上学习如何获取系统中每个进程和/或线程的内核级信息。

迭代内核的任务列表

正如前面提到的,所有的任务结构都以一个称为任务列表的链表形式组织在内核内存中(允许对它们进行迭代)。这个列表数据结构已经发展成为非常常用的循环双向链表。事实上,用于处理这些列表的核心内核代码已经被分解到一个名为list.h的头文件中;它是众所周知的,也被期望用于任何基于列表的工作。

include/linux/types.h:list_head数据结构形成了基本的双向循环链表;正如预期的那样,它由两个指针组成,一个指向列表上的prev成员,另一个指向next成员。

你可以通过include/linux/sched/signal.h头文件中方便提供的宏来方便地迭代与任务相关的各种列表,适用于版本>= 4.11;请注意,对于 4.10 及更早版本的内核,这些宏在include/linux/sched.h中。

现在,让我们把这个讨论变得实证和实践。在接下来的几节中,我们将编写内核模块以两种方式迭代内核任务列表:

  • :迭代内核任务列表并显示所有活动的进程

  • :迭代内核任务列表并显示所有活动的线程

我们展示了后一种情况的详细代码视图。继续阅读,并确保自己尝试一下!

迭代任务列表 I - 显示所有进程

内核提供了一个方便的例程,即for_each_process()宏,它让你可以轻松地迭代任务列表中的每个进程

// include/linux/sched/signal.h:
#define for_each_process(p) \
    for (p = &init_task ; (p = next_task(p)) != &init_task ; )

显然,这个宏扩展成一个for循环,允许我们在循环列表上进行循环。init_task是一个方便的“头”或起始指针 - 它指向第一个用户空间进程的任务结构,传统上是init(1),现在是systemd(1)

请注意,for_each_process()宏专门设计为只迭代每个进程main()线程,而不是('子'或对等)线程。

我们的ch6/foreach/prcs_showall内核模块的简短片段输出如下(在我们的 x86_64 Ubuntu 18.04 LTS 客户机系统上运行时):

$ cd ch6/foreach/prcs_showall; ../../../lkm prcs_showall
 [...]
 [ 111.657574] prcs_showall: inserted
 [ 111.658820]      Name       |  TGID  |  PID  |  RUID  |  EUID 
 [ 111.659619] systemd         |       1|      1|       0|       0
 [ 111.660330] kthreadd        |       2|      2|       0|       0
 [...]
 [ 111.778937] kworker/0:5     |    1123|   1123|       0|       0
 [ 111.779833] lkm             |    1143|   1143|    1000|    1000
 [ 111.780835] sudo            |    1536|   1536|       0|       0
 [ 111.781819] insmod          |    1537|   1537|       0|       0

请注意,在前面的片段中,每个进程的 TGID 和 PID 始终相等,'证明'for_each_process()宏只迭代每个进程的线程(而不是每个线程)。我们将在下一节中解释详细信息。

我们将留给你作为练习的是,研究和尝试运行示例内核模块ch6/foreach/prcs_showall

迭代任务列表 II-显示所有线程

为了迭代系统上每个活动正常线程,我们可以使用do_each_thread() { ... } while_each_thread() 宏对;我们编写一个示例内核模块来执行此操作(这里:ch6/foreach/thrd_showall/)。

在深入代码之前,让我们先构建它,insmod它(在我们的 x86_64 Ubuntu 18.04 LTS 客户机上),并查看它通过dmesg(1)发出的输出的底部部分。由于在这里显示完整的输出并不是真正可能的-它太大了-我只显示了以下截图中输出的底部部分。此外,我们已经复制了标题(图 6.9),以便您可以理解每列代表什么:

图 6.10-来自我们的 thrd_showall.ko 内核模块的输出

在图 6.9 中,注意所有(内核模式)栈的起始地址(第五列)都以零结尾:

0xffff .... .... .000,这意味着栈区域始终对齐在页面边界上(因为0x1000在十进制中是4096)。这是因为内核模式栈始终是固定大小的,并且是系统页面大小的倍数(通常为 4 KB)。

按照惯例,在我们的内核模块中,如果线程是内核线程,则其名称将显示在方括号内。

在继续编码之前,我们首先需要稍微详细地检查任务结构的 TGID 和 PID 成员。

区分进程和线程- TGID 和 PID

想一想:由于 Linux 内核使用一个唯一的任务结构(struct task_struct)来表示每个线程,并且其中的唯一成员具有 PID,这意味着在 Linux 内核中,每个线程都有一个唯一的 PID。这带来了一个问题:同一个进程的多个线程如何共享一个公共 PID?这违反了 POSIX.1b 标准(pthreads;事实上,有一段时间 Linux 不符合标准,造成了移植问题等)。

为了解决这个令人讨厌的用户空间标准问题,Red Hat 的 Ingo Molnar 在 2.5 内核系列中提出并主线了一个补丁。任务结构中滑入了一个新成员称为线程组标识符或 TGID。它的工作原理是:如果进程是单线程的,tgidpid的值相等。如果是多线程进程,则线程的tgid值等于其pid值;进程的其他线程将继承线程的tgid值,但将保留自己独特的pid值。

为了更好地理解这一点,让我们从前面的截图中取一个实际的例子。在图 6.9 中,注意右侧最后一列出现正整数时,表示多线程进程中的线程数。

因此,查看图 6.9 中看到的VBoxService进程;为了方便起见,我们将该片段复制如下(注意:我们:消除了第一列,dmesg时间戳,并添加了标题行,以便更好地可读性):它具有 PID 和 TGID 值为938,表示其线程(称为VBoxService;为了清晰起见,我们已用粗体字显示),以及总共九个线程

 PID  TGID        current            stack-start     Thread Name  MT?#
 938   938   0xffff9b09e99edb00  0xffffbaffc0b0c000  VBoxService   9
 938   940   0xffff9b09e98496c0  0xffffbaffc0b14000     RTThrdPP
 938   941   0xffff9b09fc30c440  0xffffbaffc0ad4000      control
 938   942   0xffff9b09fcc596c0  0xffffbaffc0a8c000     timesync
 938   943   0xffff9b09fcc5ad80  0xffffbaffc0b1c000       vminfo
 938   944   0xffff9b09e99e4440  0xffffbaffc0b24000   cpuhotplug
 938   945   0xffff9b09e99e16c0  0xffffbaffc0b2c000   memballoon
 938   946   0xffff9b09b65fad80  0xffffbaffc0b34000      vmstats
 938   947   0xffff9b09b6ae2d80  0xffffbaffc0b3c000    automount

这九个线程是什么?首先,当然,线程是VBoxService,下面显示的八个分别是:RTThrdPPcontroltimesyncvminfocpuhotplugmemballoonvmstatsautomount。我们怎么知道这一点呢?很简单:仔细看前面代码块中代表 TGID 和 PID 的第一列和第二列:如果它们相同,那么它就是进程的主线程;如果 TGID 重复,那么进程是多线程的,PID 值代表“子”线程的唯一 ID。

事实上,完全可以通过普遍存在的 GNU ps(1)命令在用户空间看到内核的 TGID/PID 表示,方法是使用它的-LA选项(还有其他方法):

$ ps -LA
    PID   LWP  TTY          TIME  CMD
      1     1  ?        00:00:02  systemd
      2     2  ?        00:00:00  kthreadd
      3     3  ?        00:00:00  rcu_gp
[...]
    938   938  ?        00:00:00  VBoxService
    938   940  ?        00:00:00  RTThrdPP
    938   941  ?        00:00:00  control
    938   942  ?        00:00:00  timesync
    938   943  ?        00:00:03  vminfo
    938   944  ?        00:00:00  cpuhotplug
    938   945  ?        00:00:00  memballoon
    938   946  ?        00:00:00  vmstats
    938   947  ?        00:00:00  automount
 [...]

ps(1)的标签如下:

  • 第一列是PID - 这实际上代表了内核中此任务的任务结构的tgid成员。

  • 第二列是LWP(轻量级进程或线程!) - 这实际上代表了内核中此任务的任务结构的pid成员。

请注意,只有使用 GNU 的ps(1)才能传递参数(如-LA)并查看线程;这在像busybox这样的轻量级ps实现中是不可能的。不过这并不是问题:你总是可以通过查看 procfs 来查找相同的信息;在这个例子中,在/proc/938/task下,你会看到代表子线程的子文件夹。猜猜:GNU 的ps实际上也是这样工作的!

好的,现在进入代码部分...

迭代任务列表 III - 代码

现在让我们看看我们的thrd_showall内核模块的(相关)代码:

// ch6/foreach/thrd_showall/thrd_showall.c */
[...]
#include <linux/sched.h>     /* current */
#include <linux/version.h>
#if LINUX_VERSION_CODE > KERNEL_VERSION(4, 10, 0)
#include <linux/sched/signal.h>
#endif
[...]

static int showthrds(void)
{
    struct task_struct *g, *t;      // 'g' : process ptr; 't': thread ptr
    [...]
#if 0
    /* the tasklist_lock reader-writer spinlock for the task list 'should'
     * be used here, but, it's not exported, hence unavailable to our 
     * kernel module */
    read_lock(&tasklist_lock);
#endif
    disp_idle_thread();

关于前面的代码,有几点需要注意:

  • 我们使用LINUX_VERSION_CODE()宏来有条件地包含一个头文件。

  • 现在请暂时忽略锁定工作 - 使用(或不使用)tasklist_lock()task_[un]lock()API。

  • 不要忘记 CPU 空闲线程!每个 CPU 核心都有一个专用的空闲线程(名为swapper/n),当没有其他线程想要运行时它就运行(n 是核心编号,从 0 开始)。我们运行的do .. while循环不从这个线程开始(ps(1)也从不显示它)。我们包括一个小例程来显示它,利用了空闲线程的硬编码任务结构在init_task处可用并导出的事实(一个细节:init_task总是指第一个 CPU 的 - 核心#0 - 空闲线程)。

让我们继续:为了迭代每个活动的线程,我们需要使用一对宏形成一个循环:do_each_thread() { ... } while_each_thread()这一对宏正是这样做的,允许我们迭代系统上的每个线程。以下代码显示了这一点:

    do_each_thread(g, t) {
        task_lock(t);
        snprintf(buf, BUFMAX-1, "%6d %6d ", g->tgid, t->pid);

        /* task_struct addr and kernel-mode stack addr */
        snprintf(tmp, TMPMAX-1, " 0x%px", t);
        strncat(buf, tmp, TMPMAX);
        snprintf(tmp, TMPMAX-1, " 0x%px", t->stack);
        strncat(buf, tmp, TMPMAX);

        [...] *<< see notes below >>*

        total++;
        memset(buf, 0, sizeof(buf));       *<< cleanup >>*
        memset(tmp, 0, sizeof(tmp));
        task_unlock(t);
     } while_each_thread(g, t); #if 0
   /* <same as above, reg the reader-writer spinlock for the task list> */
   read_unlock(&tasklist_lock);
#endif
    return total;
}

参考前面的代码,do_each_thread() { ... } while_each_thread()这一对宏形成一个循环,允许我们迭代系统上的每个线程

  • 我们遵循一种策略,使用一个临时变量(名为tmp)来获取一个数据项,然后将其附加到一个“结果”缓冲区buf中,我们在每次循环迭代时打印一次。

  • 获取TGIDPIDtask_structstack的起始地址是微不足道的 - 在这里,保持简单,我们只是使用current来解引用它们(当然,你也可以使用我们在本章前面看到的更复杂的内核辅助方法来做到这一点;在这里,我们希望保持简单)。还要注意的是,这里我们故意使用(更安全的)%pK printk 格式说明符,而是使用通用的%px说明符来显示任务结构和内核模式堆栈的实际内核虚拟地址。

  • 根据需要进行清理(增加总线程计数器,将临时缓冲区memset()NULL等)。

  • 完成后,我们返回我们迭代过的总线程数。

在下面的代码块中,我们覆盖了在前面的代码块中故意省略的代码部分。我们获取线程的名称,并在它是一个内核线程时在方括号内打印它。我们还查询进程中线程的数量。解释在代码之后。

        if (!g->mm) {    // kernel thread
        /* One might question why we don't use the get_task_comm() to
         * obtain the task's name here; the short reason: it causes a
         * deadlock! We shall explore this (and how to avoid it) in
         * some detail in the chapters on Synchronization. For now, we
         * just do it the simple way ...
         */
            snprintf(tmp, TMPMAX-1, " [%16s]", t->comm);
        } else {
            snprintf(tmp, TMPMAX-1, "  %16s ", t->comm);
        }
        strncat(buf, tmp, TMPMAX);

        /* Is this the "main" thread of a multithreaded process?
         * We check by seeing if (a) it's a user space thread,
         * (b) its TGID == its PID, and (c), there are >1 threads in
         * the process.
         * If so, display the number of threads in the overall process
         * to the right..
         */
        nr_thrds = get_nr_threads(g);
        if (g->mm && (g->tgid == t->pid) && (nr_thrds > 1)) {
            snprintf(tmp, TMPMAX-1, " %3d", nr_thrds);
            strncat(buf, tmp, TMPMAX);
        }

在前面的代码中,我们可以说以下内容:

  • 内核线程没有用户空间映射。main()线程的current->mm是指向mm_struct类型结构的指针,并表示整个进程的用户空间映射;如果为NULL,那么这是一个内核线程(因为内核线程没有用户空间映射);我们检查并相应地打印名称。

  • 我们也打印线程的名称(通过查找任务结构的comm成员)。您可能会问为什么我们不在这里使用get_task_comm()例程来获取任务的名称;简短的原因是:它会导致死锁!我们将在后面关于内核同步的章节中详细探讨这一点(以及如何避免它)。目前,我们只是用简单的方式做。

  • 我们通过get_nr_threads()宏方便地获取给定进程中线程的数量;在前面的代码块中的宏上面的代码注释中已经清楚解释了其余部分。

很好!通过这样,我们(暂时)完成了对 Linux 内核内部和架构的讨论,重点是进程、线程及其堆栈。

总结

在本章中,我们涵盖了内核内部的关键方面,这将帮助您作为内核模块或设备驱动程序的作者更好地理解操作系统的内部工作。您详细研究了进程及其线程和堆栈之间的组织和关系(无论是用户空间还是内核空间)。我们研究了内核的task_struct数据结构,并学习了如何通过内核模块以不同的方式迭代任务列表

尽管这可能不明显,但事实是,理解这些内核内部细节是成为经验丰富的内核(和/或设备驱动程序)开发人员的必要和必需步骤。本章的内容将帮助您调试许多系统编程场景,并为我们更深入地探索 Linux 内核,特别是内存管理方面奠定基础。

接下来的章节以及随后的几章确实非常关键:我们将涵盖您需要了解的关于内存管理内部的深层和复杂主题。我建议您先消化本章的内容,浏览感兴趣的进一步阅读链接,完成练习(问题部分),然后继续下一章!

问题

最后,这里是一些问题供您测试对本章材料的了解:github.com/PacktPublishing/Linux-Kernel-Programming/tree/master/questions。您会发现一些问题的答案在书的 GitHub 存储库中:github.com/PacktPublishing/Linux-Kernel-Programming/tree/master/solutions_to_assgn

进一步阅读

为了帮助您深入了解有用的材料,我们在本书的 GitHub 存储库中提供了一个相当详细的在线参考和链接列表(有时甚至包括书籍)。进一步阅读文档在这里可用:github.com/PacktPublishing/Linux-Kernel-Programming/raw/master/Further_Reading.md

第七章:内存管理内部 - 基本要点

内核内部,特别是关于内存管理的部分,是一个广阔而复杂的主题。在本书中,我不打算深入研究内核内存的细节。与此同时,我希望为像您这样的新兴内核或设备驱动程序开发人员提供足够的背景知识,以成功地解决这一关键主题。

因此,本章将帮助您充分了解 Linux 操作系统上内存管理是如何执行的;这包括深入研究虚拟内存(VM)分割,以及对进程的用户模式和内核段进行深入的检查,以及覆盖内核如何管理物理内存的基础知识。实际上,您将了解进程和系统的内存映射 - 虚拟和物理。

这些背景知识将在帮助您正确和高效地管理动态内核内存方面发挥重要作用(重点是使用可加载内核模块(LKM)框架编写内核或驱动程序代码;这方面 - 动态内存管理 - 在本书的接下来的两章中是重点)。作为一个重要的附带好处,掌握了这些知识,您将发现自己在调试用户和内核空间代码方面变得更加熟练。(这一点的重要性不言而喻!调试代码既是一门艺术,也是一门科学,也是一种现实。)

在本章中,我们将涵盖以下内容:

  • 理解虚拟内存分割

  • 检查进程 VAS

  • 检查内核段

  • 随机化内存布局 - [K]ASLR

  • 物理内存

技术要求

我假设您已经阅读了第一章,内核工作空间设置,并已经适当地准备了运行 Ubuntu 18.04 LTS(或更高版本)的虚拟机,并安装了所有必需的软件包。如果没有,我建议您先这样做。为了充分利用本书,我强烈建议您首先设置工作环境,包括克隆本书的 GitHub 代码库(github.com/PacktPublishing/Linux-Kernel-Programming),并以实际操作的方式进行工作。

我假设您熟悉基本的虚拟内存概念,用户模式进程虚拟地址空间(VAS)段的布局,用户和内核模式的堆栈,任务结构等。如果您对此不确定,我强烈建议您先阅读前一章。

理解虚拟内存分割

在本章中,我们将广泛地研究 Linux 内核以两种方式管理内存:

  • 基于虚拟内存的方法,其中内存是虚拟化的(通常情况)

  • 查看内核实际如何组织物理内存(RAM 页面)

首先,让我们从虚拟内存视图开始,然后在本章后面讨论物理内存组织。

正如我们在前一章中所看到的,在理解进程虚拟地址空间(VAS)的基础部分,进程 VAS 的一个关键属性是它是完全自包含的,一个沙盒。你不能看到盒子外面。在第六章,内核内部基本要点 - 进程和线程,图 6.2 中,我们看到进程 VAS 范围从虚拟地址0到我们简单地称为高地址。这个高地址的实际值是多少?显然,这是 VAS 的最高范围,因此取决于用于寻址的位数:

  • 在运行在 32 位处理器上的 Linux 操作系统(或为 32 位编译)上,最高虚拟地址将是2³² = 4 GB

  • 在运行在(并为)64 位处理器编译的 Linux 操作系统上,最高虚拟地址将是2⁶⁴=16 EB。(EB 是 exabyte 的缩写。相信我,这是一个巨大的数量。16 EB 相当于数字*16 x 10¹⁸。)

为了简单起见,为了使数字易于管理,让我们现在专注于 32 位地址空间(我们肯定也会涵盖 64 位寻址)。因此,根据我们的讨论,在 32 位系统上,进程 VAS 从 0 到 4 GB-这个区域包括空白空间(未使用的区域,称为稀疏区域空洞)和通常称为(或更正确地说是映射)的内存有效区域-文本、数据、库和堆栈(所有这些在第六章中已经有了详细的介绍,内核内部要点-进程和线程)。

在我们理解虚拟内存的旅程中,拿出众所周知的Hello, world C 程序,并在 Linux 系统上理解它的内部工作是很有用的;这就是下一节要讨论的内容!

深入了解-Hello, world C 程序

对了,这里有谁知道如何编写经典的Hello, world C 程序吗?好的,非常有趣,让我们来看看其中有意义的一行:

printf("Hello, world.\n");

该进程正在调用printf(3)函数。你写过printf()的代码吗?“当然没有”,你说,“它在标准的libc C 库中,通常是 Linux 上的glibc(GNU libc)。”但是等等,除非printf(以及所有其他库 API)的代码和数据实际上在进程 VAS 中,我们怎么能访问它呢?(记住,你不能看盒子外!)为此,printf(3)的代码(和数据)(实际上是glibc库的)必须在进程盒子内——进程 VAS 内被映射。它确实被映射到了进程 VAS 中,在库段或映射中(正如我们在第六章中看到的,内核内部要点-进程和线程图 6.1)。这是怎么发生的?

事实上,在应用程序启动时,作为 C 运行时环境设置的一部分,有一个小的可执行和可链接格式ELF)二进制文件(嵌入到你的a.out二进制可执行文件中)称为加载器ld.sold-linux.so)。它很早就获得了控制权。它检测所有需要的共享库,并通过打开库文件并发出mmap(2)系统调用将它们全部内存映射到进程 VAS 中-库文本(代码)和数据段。因此,一旦库的代码和数据被映射到进程 VAS 中,进程就可以访问它,因此-等待它-printf() API 可以成功调用!(我们在这里跳过了内存映射和链接的血腥细节)。

进一步验证这一点,ldd(1)脚本(以下输出来自 x86_64 系统)显示确实如此:

$ gcc helloworld.c -o helloworld
$ ./helloworld
Hello, world
$ ldd ./helloworld
 linux-vdso.so.1 (0x00007fffcfce3000)
 libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007feb7b85b000)
 /lib64/ld-linux-x86-64.so.2 (0x00007feb7be4e000)
$

需要注意的一些要点:

  • 每个 Linux 进程-自动且默认-链接到至少两个对象:glibc共享库和程序加载器(不需要显式的链接器开关)。

  • 加载程序的名称因架构而异。在我们的 x86_64 系统上,它是ld-linux-x86-64.so.2

  • 在前面的ldd输出中,括号中的地址是映射位置的虚拟地址。例如,在前面的输出中,glibc被映射到我们的进程 VAS 的用户虚拟地址UVA),等于0x00007feb7b85b000。请注意,这是运行时相关的(也因为地址空间布局随机化ASLR)语义而变化(稍后会看到))。

  • 出于安全原因(以及在除 x86 之外的架构上),最好使用objdump(1)实用程序来查找这类细节。

尝试对Hello, world二进制可执行文件执行strace(1),你会看到大量的mmap()系统调用,映射glibc(和其他)段!

让我们更深入地研究我们简单的Hello, world应用程序。

超越 printf() API

正如你所知,printf(3) API 转换为 write(2) 系统调用,这当然会将 "Hello, world" 字符串写入 stdout(默认情况下是终端窗口或控制台设备)。

我们也明白,由于write(2)是一个系统调用,这意味着运行此代码的当前进程-进程上下文-现在必须切换到内核模式并运行write(2)的内核代码(单内核架构)!确实如此。但等一下:write(2)的内核代码在内核 VAS 中(参见第六章,内核内部要点-进程和线程,图 6.1)。关键在于,如果内核 VAS 在盒子外面,那么我们怎么调用它呢?

嗯,可以通过将内核放在单独的 4GB VAS 中来完成,但这种方法会导致非常缓慢的上下文切换,所以不会这样做。

它的工程方式是这样的:用户和内核 VAS 都存在于同一个'盒子'中-可用 VAS。具体是如何实现的呢?通过分割可用地址空间,将用户和内核分配在某个User:Kernel :: u:k比例中。这被称为VM 分割(比例u:k通常以 GB、TB 甚至 PB 表示)。

以下图表代表了运行 Linux 操作系统的 ARM-32 系统上具有2:2 VM 分割(以 GB 为单位)的 32 位 Linux 进程;即,总共 4GB 的进程 VAS 被分割为 2GB 的用户空间和 2GB 的内核空间。这通常是运行 Linux 操作系统的 ARM-32 系统上的典型 VM 分割。

图 7.1- User:Kernel :: 2:2 GB VM split on an ARM-32 system running Linux

所以,现在内核 VAS 在盒子内,突然清楚并且至关重要的是要理解这一点:当用户模式进程或线程发出系统调用时,会发生上下文切换到内核的 2GB VAS(包括各种 CPU 寄存器,包括堆栈指针在内的寄存器会得到更新),在同一个进程的 VAS 内。发出系统调用的线程现在以特权内核模式在进程上下文中运行其内核代码(并且处理内核空间数据)。完成后,它从系统调用返回,上下文切换回非特权用户模式,并且现在在第一个 2GB VAS 内运行用户模式代码。

内核 VAS 的确切虚拟地址-也称为内核段-通常通过内核中的PAGE_OFFSET宏表示。我们将在描述内核段布局的宏和变量部分中进一步研究这一点,以及其他一些关键的宏。

关于 VM 分割的确切位置和大小的决定是在哪里做出的呢?啊,在 32 位 Linux 上,这是一个内核构建时可配置的。它是在内核构建中作为make [ARCH=xxx] menuconfig过程的一部分完成的-例如,当为 Broadcom BCM2835(或 BCM2837)SoC(Raspberry Pi 是一个搭载这个 SoC 的热门开发板)配置内核时。以下是来自官方内核配置文件的片段(输出来自 Raspberry Pi 控制台):

$ uname -r
5.4.51-v7+
$ sudo modprobe configs      *<< gain access to /proc/config.gz via this LKM >>* $ zcat /proc/config.gz | grep -C3 VMSPLIT
[...]
# CONFIG_BIG_LITTLE is not set
# CONFIG_VMSPLIT_3G is not set
# CONFIG_VMSPLIT_3G_OPT is not set
CONFIG_VMSPLIT_2G=y
# CONFIG_VMSPLIT_1G is not set
CONFIG_PAGE_OFFSET=0x80000000
CONFIG_NR_CPUS=4
[...]

如前面的片段所示,CONFIG_VMSPLIT_2G内核配置选项设置为y,意味着默认的 VM 分割是user:kernel :: 2:2。对于 32 位架构,VM 分割位置是可调整的(如前面的片段中所示,CONFIG_VMSPLIT_[1|2|3]GCONFIG_PAGE_OFFSET相应地设置)。对于 2:2 的 VM 分割,PAGE_OFFSET实际上是在虚拟地址0x8000 0000(2GB)的中间位置!

IA-32 处理器(Intel x86-32)的默认 VM 分割是 3:1(GB)。有趣的是,运行在 IA-32 上的(古老的)Windows 3.x 操作系统具有相同的 VM 分割,这表明这些概念基本上与操作系统无关。在本章的后面,我们将涵盖几种更多的架构及其 VM 分割,以及其他细节。

无法直接为 64 位架构配置 VM 分割。因此,现在我们了解了 32 位系统上的 VM 分割,让我们继续研究如何在 64 位系统上进行 VM 分割。

64 位 Linux 系统上的 VM 分割

首先值得注意的是,在 64 位系统上,并非所有 64 位都用于寻址。在标准或典型的 x86_64 Linux OS 配置中,使用(最低有效位LSB))48 位进行寻址。为什么不使用全部 64 位?因为太多了!没有现有的计算机接近拥有甚至一半的完整2⁶⁴ = 18,446,744,073,709,551,616 字节,相当于 16 EB(即 16,384 PB)的 RAM!

“为什么”,您可能会想,“我们为什么将其等同于 RAM?”。请继续阅读 - 在此变得清晰之前,需要涵盖更多内容。在检查内核段部分,您将完全理解这一点。

虚拟寻址和地址转换

在进一步深入了解这些细节之前,非常重要的是清楚地理解一些关键点。

考虑来自 C 程序的一个小而典型的代码片段:

int i = 5;
printf("address of i is 0x%x\n", &i);

您看到printf()发出的地址是虚拟地址而不是物理地址。我们区分两种虚拟地址:

  • 如果在用户空间进程中运行此代码,您将看到变量i的地址是 UVA。

  • 如果在内核中运行此代码,或者在内核模块中运行此代码(当然,您将使用printk() API),您将看到变量i的地址是内核虚拟地址KVA)。

接下来,虚拟地址不是绝对值(相对于0的偏移量);它实际上是位掩码

  • 在 32 位 Linux 操作系统上,32 个可用位被分为页全局目录PGD)值,页表PT)值和偏移量。

  • 这些成为MMU(现代微处理器硅片内部的内存管理单元)进行地址转换的索引。

我们不打算在这里详细介绍 MMU 级别的地址转换。这也非常与架构相关。请参考进一步阅读部分,了解有关此主题的有用链接。

  • 如预期的那样,在 64 位系统上,即使使用 48 位寻址,虚拟地址位掩码中将有更多字段。

好吧,如果这种 48 位寻址是 x86_64 处理器上的典型情况,那么 64 位虚拟地址中的位是如何布局的?未使用的 16 位 MSB 会发生什么?以下图解答了这个问题;这是 x86_64 Linux 系统上虚拟地址的分解表示:

图 7.2 - 在具有 4 KB 页面的 Intel x86_64 处理器上分解 64 位虚拟地址

基本上,使用 48 位寻址,我们使用 0 到 47 位(LSB 48 位)并忽略最高有效位MSB)的 16 位,将其视为符号扩展。不过,未使用的符号扩展 MSB 16 位的值随着您所在的地址空间而变化:

  • 内核 VAS:MSB 16 位始终设置为1

  • 用户 VAS:MSB 16 位始终设置为0

这是有用的信息!知道这一点,仅通过查看(完整的 64 位)虚拟地址,您因此可以判断它是 KVA 还是 UVA:

  • 64 位 Linux 系统上的 KVA 始终遵循格式0xffff .... .... ....

  • UVA 始终具有格式0x0000 .... .... ....

警告:前面的格式仅适用于将虚拟地址自定义为 KVA 或 UVA 的处理器(实际上是 MMU); x86 和 ARM 系列处理器属于这一范畴。

现在可以看到(我在这里重申),事实是虚拟地址不是绝对地址(绝对偏移量从零开始,正如你可能错误地想象的那样),而是实际上是位掩码。事实上,内存管理是一个复杂的领域,工作是共享的:操作系统负责创建和操作每个进程的分页表,工具链(编译器)生成虚拟地址,而处理器 MMU 实际上执行运行时地址转换,将给定的(用户或内核)虚拟地址转换为物理(RAM)地址!

我们不会在本书中深入讨论硬件分页(以及各种硬件加速技术,如转换旁路缓冲TLB)和 CPU 缓存)。这个特定的主题已经被其他一些优秀的书籍和参考网站很好地涵盖,这些书籍和网站在本章的进一步阅读部分中提到。

回到 64 位处理器上的 VAS。64 位系统上可用的 VAS 是一个巨大的2**⁶⁴ **= 16 EB16 x 10**¹⁸字节!)。故事是这样的,当 AMD 工程师首次将 Linux 内核移植到 x86_64(或 AMD64)64 位处理器时,他们必须决定如何在这个巨大的 VAS 中布置进程和内核段。即使在今天的 x86_64 Linux 操作系统上,这个巨大的 64 位 VAS 的划分基本上保持不变。这个巨大的 64 位 VAS 划分如下。在这里,我们假设 48 位寻址和 4 KB 页面大小:

  • 规范的下半部分,128 TB:用户 VAS 和虚拟地址范围从0x00x0000 7fff ffff ffff

  • 规范的上半部分,128 TB:内核 VAS 和虚拟地址范围从0xffff 8000 0000 00000xffff ffff ffff ffff

规范这个词实际上意味着根据法律根据共同惯例

在 x86_64 平台上可以看到这个 64 位 VM 分割,如下图所示:

图 7.3 - Intel x86_64(或 AMD64)16 EB VAS 布局(48 位寻址);VM 分割是用户:内核:: 128 TB:128 TB

在上图中,中间未使用的区域 - 空洞或稀疏区域 - 也称为非规范地址区域。有趣的是,使用 48 位寻址方案,绝大多数 VAS 都未被使用。这就是为什么我们称 VAS 非常稀疏。

上图显然不是按比例绘制的!请记住,这一切都是虚拟内存空间,而不是物理内存。

为了结束我们对 VM 分割的讨论,以下图表显示了不同 CPU 架构的一些常见用户:内核VM 分割比例(我们假设 MMU 页面大小为 4 KB):

图 7.4 - 不同 CPU 架构的常见用户:内核 VM 分割比例(4 KB 页面大小)

我们用粗体红色突出显示第三行,因为它被认为是常见情况:在 x86_64(或 AMD64)架构上运行 Linux,使用用户:内核:: 128 TB:128 TB VM 分割。在阅读表格时要小心:第六列和第八列的数字,结束 vaddr,每个都是单个 64 位数量,而不是两个数字。数字可能只是简单地绕回去了。因此,例如,在 x86_64 行中,第 6 列是单个数字0x0000 7fff ffff ffff而不是两个数字。

第三列,地址位,告诉我们,在 64 位处理器上,实际上没有真正的处理器使用所有 64 位进行寻址。

在 x86_64 下,上表显示了两个 VM 分割:

  • 第一个,128 TB:128 TB(4 级分页)是今天在 Linux x86_64 位系统上使用的典型 VM 分割(嵌入式笔记本电脑,个人电脑,工作站和服务器)。它将物理地址空间限制为 64 TB(RAM)。

  • 第二个,64 PB:64 PB,截至目前为止,仍然纯理论;它支持所谓的 5 级分页,从 4.14 版 Linux 开始;分配的 VAS(56 位寻址;总共 128PB 的 VAS 和 4PB 的物理地址空间!)是如此巨大,以至于截至目前为止,没有实际的计算机(尚未)使用它。

请注意,运行在 Linux 上的 AArch64(ARM-64)架构的两行仅仅是代表性的。正在开发产品的 BSP 供应商或平台团队可能会使用不同的分割。有趣的是,(旧)Windows 32 位操作系统上的 VM 分割是 2:2(GB)。

实际上驻留在内核 VAS 中的是什么,或者通常所说的内核段?所有内核代码、数据结构(包括任务结构、列表、内核模式堆栈、分页表等等)、设备驱动程序、内核模块等等都在这里(正如第六章中内核内部要点 - 进程和线程图 6.7的下半部分所显示的;我们在理解内核段部分中详细介绍了这一点)。

重要的是要意识到,在 Linux 上,作为性能优化,内核内存始终是不可交换的;也就是说,内核内存永远不会被换出到交换分区。用户空间内存页总是可以进行分页,除非被锁定(参见mlockall系统调用)。

有了这个背景,您现在可以理解完整的进程 VAS 布局。继续阅读。

进程 VAS - 完整视图

再次参考图 7.1;它显示了单个 32 位进程的实际进程 VAS 布局。当然,现实情况是 - 这是关键的 - 系统上所有活动的进程都有自己独特的用户模式 VAS,但共享相同的内核段。与图 7.1形成对比的是,它显示了 2:2(GB)的 VM 分割,下图显示了典型 IA-32 系统的实际情况,其中有 3:1(GB)的 VM 分割:

图 7.5 - 进程具有独特的用户 VAS,但共享内核段(32 位操作系统);IA-32 的 VM 分割为 3:1

请注意,在前面的图中,地址空间反映了 3:1(GB)的 VM 分割。用户地址空间从0扩展到0xbfff ffff0xc000 0000是 3GB 标记;这是PAGE_OFFSET宏的设置),内核 VAS 从0xc000 0000(3GB)扩展到0xffff ffff(4GB)。

在本章的后面,我们将介绍一个有用的实用程序procmap的用法。它将帮助您详细可视化 VAS,包括内核和用户 VAS,类似于我们之前的图表所显示的方式。

需要注意的几点:

  • 在图 7.5 中显示的示例中,PAGE_OFFSET的值为0xc000 0000

  • 我们在这里展示的图表和数字并不是所有架构上的绝对和约束性的;它们往往是非常特定于架构的,许多高度定制的 Linux 系统可能会改变它们。

  • 图 7.5详细介绍了 32 位 Linux 操作系统上的 VM 布局。在 64 位 Linux 上,概念保持不变,只是数字(显著)变化。正如前面的章节中所详细介绍的,x86_64(带 48 位寻址)Linux 系统上的 VM 分割变为User:Kernel :: 128 TB:128 TB

现在,一旦理解了进程的虚拟内存布局的基本原理,您会发现它在解密和在难以调试的情况下取得进展方面非常有帮助。像往常一样,还有更多内容;接下来的部分将介绍用户空间和内核空间内存映射(内核段),以及一些关于物理内存映射的内容。继续阅读!

检查进程 VAS

我们已经介绍了每个进程 VAS 由哪些段或映射组成(参见第六章中的理解进程虚拟地址空间(VAS)基础知识部分)。我们了解到进程 VAS 包括各种映射或段,其中包括文本(代码)、数据段、库映射,以及至少一个堆栈。在这里,我们将对此进行更详细的讨论。

能够深入内核并查看各种运行时值是开发人员像您这样的重要技能,以及用户、QA、系统管理员、DevOps 等。Linux 内核为我们提供了一个令人惊叹的接口来做到这一点 - 这就是,你猜对了,proc文件系统(procfs)。

这在 Linux 上始终存在(至少应该存在),并且挂载在/proc下。procfs系统有两个主要作用:

  • 提供一组统一的(伪或虚拟)文件和目录,使您能够深入了解内核和硬件的内部细节。

  • 提供一组统一的可写根文件,允许系统管理员修改关键的内核参数。这些文件位于/proc/sys/下,并被称为sysctl - 它们是 Linux 内核的调整旋钮。

熟悉proc文件系统确实是必须的。我建议您查看一下,并阅读关于proc(5)的优秀手册页。例如,简单地执行cat /proc/PID/status(其中PID当然是给定进程或线程的唯一进程标识符)会产生一大堆有用的进程或线程任务结构的细节!

在概念上类似于procfs的是sysfs文件系统,它挂载在/sys下(在其下是debugfs,通常挂载在/sys/kernel/debug)。sysfs是 2.6 Linux 新设备和驱动程序模型的表示;它公开了系统上所有设备的树形结构,以及几个内核调整旋钮。

详细检查用户 VAS

让我们从检查任何给定进程的用户 VAS 开始。用户 VAS 的相当详细的映射可以通过procfs获得,特别是通过/proc/PID/maps伪文件。让我们学习如何使用这个接口来窥视进程的用户空间内存映射。我们将看到两种方法:

  • 直接通过procfs接口的/proc/PID/maps伪文件

  • 使用一些有用的前端(使输出更易于理解)

让我们从第一个开始。

直接使用 procfs 查看进程内存映射

查找任意进程的内部进程细节需要root访问权限,而查找自己拥有的进程的细节(包括调用进程本身)则不需要。因此,举个简单的例子,我们将使用self关键字来查找调用进程的 VAS,而不是 PID。以下屏幕截图显示了这一点(在 x86_64 Ubuntu 18.04 LTS 客户机上):

图 7.6 - cat /proc/self/maps 命令的输出

在前面的屏幕截图中,您实际上可以看到cat进程的用户 VAS - 该进程的用户 VAS 的实际内存映射!还要注意,前面的procfs输出是按(用户)虚拟地址(UVA)升序排序的。

熟悉使用强大的mmap(2)系统调用将有助于更好地理解后续的讨论。至少要浏览一下它的手册页。

解释/proc/PID/maps 输出

要解释图 7.6 的输出,请逐行阅读。每行代表了进程的用户模式 VAS 的一个段或映射(在前面的示例中,是cat进程的)。每行包括以下字段。

为了更容易,我将只展示一行输出,我们将在接下来的注释中标记并引用这些字段:

 start_uva  -  end_uva   mode,mapping  start-off   mj:mn inode# image-name 
555d83b65000-555d83b6d000    r-xp      00000000    08:01 524313   /bin/cat

在这里,整行表示进程(用户)VAS 中的一个段,或更正确地说,是一个映射uva是用户虚拟地址。每个段的start_uvaend_uva显示为前两个字段(或列)。因此,映射(段)的长度可以轻松计算(end_uva-start_uva字节)。因此,在前面的行中,start_uva0x555d83b65000end_uva0x555d83b6d000(长度可以计算为 32 KB);但是,这个段是什么?请继续阅读...

第三个字段r-xp实际上是两个信息的组合:

  • 前三个字母表示段(通常以rwx表示)的模式(权限)。

  • 下一个字母表示映射是私有的(p)还是共享的(s)。在内部,这是由mmap(2)系统调用的第四个参数flags设置的;实际上是mmap(2)系统调用在内部负责创建进程中的每个段或映射!

  • 因此,对于前面显示的示例段,第三个字段的值为r-xp,我们现在可以知道它是一个文本(代码)段,并且是一个私有映射(如预期的那样)。

第四个字段start-off(这里是值0)是从已映射到进程 VAS 的文件开头的起始偏移量。显然,此值仅对文件映射有效。您可以通过查看倒数第二个(第六个)字段来判断当前段是否是文件映射。对于不是文件映射的映射 - 称为匿名映射 - 它始终为0(例如表示堆或栈段的映射)。在我们之前的示例行中,这是一个文件映射(/bin/cat),从该文件开头的偏移量为0字节(如我们在前一段中计算的映射长度为 32 KB)。

第五个字段(08:01)的格式为mj:mn,其中mj是设备文件的主编号,mn是映像所在设备文件的次编号。与第四个字段类似,它仅对文件映射有效,否则显示为00:00;在我们之前的示例行中,这是一个文件映射(/bin/cat),设备文件的主编号和次编号(文件所在的设备)分别为81

第六个字段(524313)表示映像文件的索引节点号 - 正在映射到进程 VAS 的文件的内容。索引节点是VFS(虚拟文件系统)的关键数据结构;它保存文件对象的所有元数据,除了其名称(名称在目录文件中)。同样,此值仅对文件映射有效,否则显示为0。实际上,这是一种快速判断映射是文件映射还是匿名映射的方法!在我们之前的示例映射中,显然是文件映射(/bin/cat),索引节点号是524313。事实上,我们可以确认:

ls -i /bin/cat
524313 /bin/cat

第七个和最后一个字段表示正在映射到用户 VAS 的文件的路径名。在这里,因为我们正在查看cat(1)进程的内存映射,路径名(对于文件映射的段)当然是/bin/cat。如果映射表示文件,则文件的索引节点号(第六个字段)显示为正值;如果不是 - 意味着是没有后备存储的纯内存或匿名映射 - 索引节点号显示为0,此字段将为空。

现在应该很明显了,但我们仍然会指出这一点 - 这是一个关键点:前面看到的所有地址都是虚拟地址,而不是物理地址。此外,它们仅属于用户空间,因此被称为UVA,并且始终通过该进程的唯一分页表访问(和转换)。此外,前面的屏幕截图是在 64 位(x86_64)Linux 客户机上拍摄的。因此,在这里,我们看到 64 位虚拟地址。

虽然虚拟地址的显示方式不是完整的 64 位数字 - 例如,显示为0x555d83b65000而不是0x0000555d83b65000 - 但我希望您注意到,因为它是用户虚拟地址UVA),最高 16 位为零!

好了,这涵盖了如何解释特定段或映射,但似乎还有一些奇怪的 - vvarvdsovsyscall映射。让我们看看它们的含义。

vsyscall 页面

您是否注意到图 7.6 的输出中有一些不太寻常的东西?那里的最后一行 - 所谓的vsyscall条目 - 映射了一个内核页面(到目前为止,您知道我们如何判断:其起始和结束虚拟地址的最高 16 位被设置)。在这里,我们只提到这是一个(旧的)用于执行系统调用的优化。它通过减轻对于一小部分不真正需要的系统调用而实际上不需要切换到内核模式来工作。

目前,在 x86 上,这些包括gettimeofday(2)time(2)getcpu(2)系统调用。实际上,上面的vvarvdso(又名 vDSO)映射是同一主题的现代变体。如果您对此感兴趣,可以访问本章的进一步阅读部分了解更多信息。

因此,您现在已经看到了如何通过直接阅读和解释/proc/PID/maps(伪)文件的输出来检查任何给定进程的用户空间内存映射。还有其他方便的前端可以这样做;我们现在将检查一些。

查看进程内存映射的前端

除了通过/proc/PID/maps(我们在上一节中看到如何解释)的原始或直接格式外,还有一些包装实用程序可以帮助我们更轻松地解释用户模式 VAS。其中包括额外的(原始)/proc/PID/smaps伪文件,pmap(1)smem(8)实用程序,以及我自己的简单实用程序(名为procmap)。

内核通过/proc/PID/smaps伪文件在proc下提供了每个段或映射的详细信息。尝试cat /proc/self/smaps来查看这些信息。您会注意到对于每个段(映射),都提供了大量详细信息。proc(5)的 man 页面有助于解释所见到的许多字段。

对于pmap(1)smem(8)实用程序,我建议您查阅它们的 man 页面以获取详细信息。例如,对于pmap(1),man 页面告诉我们更详细的-X-XX选项:

-X Show even more details than the -x option. WARNING: format changes according to /proc/PID/smaps
-XX Show everything the kernel provides

关于smem(8)实用程序,事实是它显示进程 VAS;相反,它更多地是回答一个常见问题:即确定哪个进程占用了最多的物理内存。它使用诸如Resident Set SizeRSS),Proportional Set SizePSS)和Unique Set SizeUSS)等指标来呈现更清晰的图片。我将把进一步探索这些实用程序作为一个练习留给您,亲爱的读者!

现在,让我们继续探讨如何使用一个有用的实用程序 - procmap - 以相当详细的方式查看任何给定进程的内核和用户内存映射。

procmap 进程 VAS 可视化实用程序

作为一个小型的学习和教学(以及在调试期间有帮助!)项目,我编写并托管了一个名为procmap的小型项目,可以在 GitHub 上找到:github.com/kaiwan/procmap(使用git clone进行克隆)。其README.md文件的一部分有助于解释其目的:

procmap is designed to be a console/CLI utility to visualize the complete memory map of a Linux process, in effect, to visualize the memory mappings of both the kernel and user mode Virtual Address Space (VAS). It outputs a simple visualization, in a vertically-tiled format ordered by descending virtual address, of the complete memory map of a given process (see screenshots below). The script has the intelligence to show kernel and user space mappings as well as calculate and show the sparse memory regions that will be present. Also, each segment or mapping is scaled by relative size (and color-coded for readability). On 64-bit systems, it also shows the so-called non-canonical sparse region or 'hole' (typically close to 16,384 PB on the x86_64).

顺便说一句:在撰写本材料时(2020 年 4 月/5 月),COVID-19 大流行席卷全球大部分地区。类似于早期的SETI@home项目(setiathome.berkeley.edu/),Folding@home项目(foldingathome.org/category/covid-19/)是一个分布式计算项目,利用互联网连接的家用(或任何)计算机来帮助模拟和解决与 COVID-19 治疗相关的问题(以及找到治愈我们的其他严重疾病)。您可以从foldingathome.org/start-folding/下载软件(安装它,并在系统空闲时运行)。我就是这样做的;这是在我的(本机)Ubuntu Linux 系统上运行的 FAH 查看器(一个漂亮的 GUI 显示蛋白质分子!)进程的部分截图:

$ ps -e|grep -i FAH
6190 ? 00:00:13 FAHViewer

好了,让我们使用procmap实用程序来查询它的 VAS。我们如何调用它?简单,看看接下来的内容(由于空间不足,我不会在这里显示所有信息、警告等;请自行尝试):

$ git clone https://github.com/kaiwan/procmap
$ cd procmap
$ ./procmap
Options:
 --only-user : show ONLY the user mode mappings or segments
 --only-kernel : show ONLY the kernel-space mappings or segments
 [default is to show BOTH]
 --export-maps=filename
     write all map information gleaned to the file you specify in CSV
 --export-kernel=filename
     write kernel information gleaned to the file you specify in CSV
 --verbose : verbose mode (try it! see below for details)
 --debug : run in debug mode
 --version|--ver : display version info.
See the config file as well.
[...]

请注意,这个procmap实用程序与 BSD Unix 提供的procmap实用程序不同。它还依赖于bc(1)smem(8)实用程序;请确保它们已安装。

当我只使用--pid=<PID>运行procmap实用程序时,它将显示给定进程的内核和用户空间 VAS。现在,由于我们尚未涵盖有关内核 VAS(或段)的详细信息,我不会在这里显示内核空间的详细输出;让我们把它推迟到即将到来的部分,检查内核段。随着我们的进行,您将发现procmap实用程序的部分截图仅显示用户 VAS 输出。完整的输出可能会相当冗长,当然取决于所涉及的进程;请自行尝试。

正如您将看到的,它试图以垂直平铺的格式提供完整进程内存映射的基本可视化 – 包括内核和用户空间 VAS(如前所述,这里我们只显示截断的截图):

图 7.7 – 部分截图:从 procmap 实用程序的内核 VAS 输出的第一行

请注意,从前面(部分)截图中,有一些事情:

  • procmap (Bash)脚本自动检测到我们正在运行的是 x86_64 64 位系统。

  • 虽然我们现在不专注于它,但内核 VAS 的输出首先出现;这是自然的,因为我们按照虚拟地址降序显示输出(图 7.1、7.3 和 7.5 重申了这一点)

  • 您可以看到第一行(在KERNEL VAS标题之后)对应于 VAS 的顶部 – 值为0xffff ffff ffff ffff(因为我们是 64 位)。

继续看 procmap 输出的下一部分,让我们看一下FAHViewer 进程的用户 VAS 的上端的截断视图:

图 7.8 – 部分截图:procmap 实用程序的用户 VAS 输出的前几行(高端)

图 7.8 是procmap输出的部分截图,显示了用户空间 VAS;在其顶部,您可以看到(高)端 UVA。

在我们的 x86_64 系统上(请记住,这是与架构相关的),(高)end_uva值是

0x0000 7fff ffff ffff 和 start_uva 当然是 0x0procmap 如何找出精确的地址值呢?哦,它相当复杂:对于内核空间内存信息,它使用一个内核模块(一个 LKM!)来查询内核,并根据系统架构设置一个配置文件;用户空间的细节当然来自 /proc/PID/maps 直接的 procfs 伪文件。

顺便说一句,procmap的内核组件,一个内核模块,建立了一种与用户空间进行交互的方式 – 通过创建和设置一个debugfs(伪)文件的procmap脚本。

以下屏幕截图显示了进程用户模式 VAS 的低端的部分截图,直到最低的 UVA 0x0

图 7.9 - 部分截图:进程用户 VAS 输出的最后几行(低端)来自 procmap 实用程序

最后一个映射,一个单页,如预期的那样,是空指针陷阱页(从 UVA 0x10000x0;我们将在即将到来的空指针陷阱页部分中解释其目的)。

然后,procmap实用程序(如果在其配置文件中启用)会计算并显示一些统计信息;这包括内核和用户模式 VAS 的大小,64 位系统上稀疏区域占用的用户空间内存量(通常是空间的绝大部分!)的绝对数量和百分比,报告的物理 RAM 量,最后,由ps(1)smem(8)实用程序报告的此特定进程的内存使用详细信息。

通常情况下,在 64 位系统上(参见图 7.3),进程 VAS 的稀疏(空)内存区域占用了可用地址空间的接近 100%!(通常是诸如 127.99[...] TB 的 VAS 占用了 128 TB 可用空间的情况。)这意味着 99.99[...]%的内存空间是稀疏的(空的)!这就是 64 位系统上巨大的 VAS 的现实。实际上,巨大的 128 TB 的 VAS(就像在 x86_64 上一样)中只有一小部分被使用。当然,稀疏和已使用的 VAS 的实际数量取决于特定应用程序进程的大小。

能够清晰地可视化进程 VAS 在深层次调试或分析问题时可以提供很大帮助。

如果您正在阅读本书的实体版本,请务必从出版商的网站下载图表/图像的全彩 PDF:static.packt-cdn.com/downloads/9781789953435_ColorImages.pdf

您还会看到输出末尾(如果启用)打印出的统计信息显示了目标进程设置的虚拟内存区域VMAs)的数量。接下来的部分简要解释了 VMA 是什么。让我们开始吧!

理解 VMA 的基础知识

/proc/PID/maps的输出中,实际上每行输出都是从一个称为 VMA 的内核元数据结构中推断出来的。这实际上非常简单:内核使用 VMA 数据结构来抽象我们所说的段或映射。因此,在用户 VAS 中的每个段都有一个由操作系统维护的 VMA 对象。请意识到,只有用户空间段或映射受到称为 VMA 的内核元数据结构的管理;内核段本身没有 VMA。

那么,给定进程会有多少个 VMA?嗯,它等于其用户 VAS 中的映射(段)数量。在我们的FAHViewer进程示例中,它恰好有 206 个段或映射,这意味着内核内存中为该进程维护了 206 个 VMA 元数据对象,代表了 206 个用户空间段或映射。

从编程的角度来看,内核通过根据current->mm->mmap的任务结构维护 VMA“链”(实际上是红黑树数据结构,出于效率原因)来进行管理。为什么指针称为mmap?这是非常有意义的:每次执行mmap(2)系统调用(即内存映射操作)时,内核都会在调用进程的(即在current实例内)VAS 中生成一个映射(或“段”)和代表它的 VMA 对象。

VMA 元数据结构类似于一个包含映射的伞,包括内核执行各种内存管理操作所需的所有信息:处理页面错误(非常常见),在 I/O 期间将文件内容缓存到(或从)内核页缓存中等等。

页面错误处理是一个非常重要的操作系统活动,其算法占用了相当大一部分内核 VMA 对象的使用;然而,在本书中,我们不深入讨论这些细节,因为对内核模块/驱动程序的作者来说,这些细节基本上是透明的。

为了让您感受一下,我们将在下面的片段中展示内核 VMA 数据结构的一些成员;旁边的注释有助于解释它们的目的:

// include/linux/mm_types.h
struct vm_area_struct {
    /* The first cache line has the info for VMA tree walking. */
    unsigned long vm_start;     /* Our start address within vm_mm. */
    unsigned long vm_end;       /* The first byte after our end address
    within vm_mm. */

    /* linked list of VM areas per task, sorted by address */
    struct vm_area_struct *vm_next, *vm_prev;
    struct rb_node vm_rb;
    [...]
    struct mm_struct *vm_mm;     /* The address space we belong to. */
    pgprot_t vm_page_prot;       /* Access permissions of this VMA. */
    unsigned long vm_flags;      /* Flags, see mm.h. */
    [...]
    /* Function pointers to deal with this struct. */
    const struct vm_operations_struct *vm_ops;
    /* Information about our backing store: */
    unsigned long vm_pgoff;/* Offset (within vm_file) in PAGE_SIZE units */
    struct file * vm_file;       /* File we map to (can be NULL). */
    [...]
} __randomize_layout

现在应该更清楚了cat /proc/PID/maps是如何在底层工作的:当用户空间执行cat /proc/self/maps时,cat发出了一个read(2)系统调用;这导致cat进程切换到内核模式,并在内核中以内核特权运行read(2)系统调用代码。在这里,内核虚拟文件系统开关VFS)将控制权重定向到适当的procfs回调处理程序(函数)。这段代码遍历了每个 VMA 元数据结构(对于current,也就是我们的cat进程),将相关信息发送回用户空间。cat进程然后忠实地将通过读取接收到的数据转储到stdout,因此我们看到了它:进程的所有段或映射 - 实际上是用户模式 VAS 的内存映射!

好了,通过这一部分,我们总结了检查进程用户 VAS 的细节。这种知识不仅有助于理解用户模式 VAS 的精确布局,还有助于调试用户空间内存问题!

现在,让我们继续理解内存管理的另一个关键方面 - 内核 VAS 的详细布局,换句话说,内核段。

检查内核段

正如我们在前一章中讨论过的,以及在图 7.5中所见,非常重要的是要理解所有进程都有自己独特的用户 VAS,但共享内核空间 - 我们称之为内核段或内核 VAS。让我们开始这一部分,从开始检查内核段的一些常见(与架构无关)区域。

内核段的内存布局非常依赖于架构(CPU)。然而,所有架构都有一些共同点。下面的基本图表代表了用户 VAS 和内核段(以水平平铺的格式),在 x86_32 上以 3:1 的 VM 分割中看到:

图 7.10 - 在 x86_32 上以 3:1 VM 分割为焦点的用户和内核 VAS

让我们逐个地过一遍每个区域:

  • 用户模式 VAS:这是用户 VAS;我们在前一章和本章的早些部分详细介绍了它;在这个特定的例子中,它占用了 3GB 的 VAS(从0x00xbfff ffff)。

  • 所有接下来的内容都属于内核 VAS 或内核段;在这个特定的例子中,它占用了 1GB 的 VAS(从0xc000 00000xffff ffff);现在让我们逐个部分来检查它。

  • 低端内存区域:这是平台(系统)RAM 直接映射到内核的地方。(我们将在直接映射 RAM 和地址转换部分更详细地介绍这个关键主题。如果有帮助的话,您可以先阅读该部分,然后再回到这里)。现在先跳过一点,让我们先了解一下内核段中平台 RAM 映射的基本位置,这个位置由一个名为PAGE_OFFSET的内核宏指定。这个宏的精确值非常依赖于架构;我们将把这个讨论留到后面的部分。现在,我们要求您只是相信,在具有 3:1(GB)VM 分割的 IA-32 上,PAGE_OFFSET的值是0xc000 0000

内核低内存区域的长度或大小等于系统上的 RAM 量。(至少是内核看到的 RAM 量;例如,启用 kdump 功能会让操作系统提前保留一些 RAM)。构成这个区域的虚拟地址被称为内核逻辑地址,因为它们与它们的物理对应物有固定的偏移量。核心内核和设备驱动程序可以通过各种 API(我们将在接下来的两章中详细介绍这些 API)从这个区域分配(物理连续的)内存。内核静态文本(代码)、数据和 BSS(未初始化数据)内存也驻留在这个低内存区域内。

  • 内核 vmalloc 区域:这是内核 VAS 的一个完全虚拟的区域。核心内核和/或设备驱动程序代码可以使用vmalloc()(和其他类似的)API 从这个区域分配虚拟连续的内存。同样,我们将在第八章和第九章中详细介绍这一点,即模块作者的内核内存分配第一部分模块作者的内核内存分配第二部分。这也是所谓的ioremap空间。

  • 内核模块空间:内核 VAS 的一个区域被留出来,用于存放可加载内核模块LKMs)的静态文本和数据所占用的内存。当您执行insmod(8)时,生成的[f]init_module(2)系统调用的底层内核代码会从这个区域分配内存(通常通过vmalloc() API),并将内核模块的(静态)代码和数据加载到那里。

前面的图(图 7.10)故意保持简单甚至有点模糊,因为确切的内核虚拟内存布局非常依赖于架构。我们将暂时抑制绘制详细图表的冲动。相反,为了使这个讨论不那么学究,更实用和有用,我们将在即将到来的一节中介绍一个内核模块,该模块查询并打印有关内核段布局的相关信息。只有在我们对特定架构的内核段的各个区域有了实际值之后,我们才会呈现详细的图表。

学究地(如图 7.10 所示),属于低内存区域的地址被称为内核逻辑地址(它们与它们的物理对应物有固定的偏移量),而内核段的其余地址被称为 KVA。尽管在这里做出了这样的区分,请意识到,实际上,这是一个相当学究的区分:我们通常会简单地将内核段内的所有地址称为 KVA。

在此之前,还有几个其他信息要涵盖。让我们从另一个特殊情况开始,这主要是由 32 位架构的限制带来的:内核段的所谓高内存区域。

32 位系统上的高内存

关于我们之前简要讨论过的内核低内存区域,有一个有趣的观察结果。在一个 32 位系统上,例如,3:1(GB)的 VM 分割(就像图 7.10 所描述的那样),拥有(例如)512 MB RAM 的系统将其 512 MB RAM 直接映射到从PAGE_OFFSET(3 GB 或 KVA 0xc000 0000)开始的内核中。这是非常清楚的。

但是想一想:如果系统有更多的 RAM,比如 2GB,会发生什么?现在很明显,我们无法将整个 RAM 直接映射到 lowmem 区域。它根本就放不下(例如,在这个例子中,整个可用的内核 VAS 只有 1GB,而 RAM 是 2GB)!因此,在 32 位 Linux 操作系统上,允许将一定数量的内存(通常是 IA-32 上的 768MB)直接映射,因此落入 lowmem 区域。剩下的 RAM 则间接映射到另一个内存区域,称为ZONE_HIGHMEM(我们认为它是一个高内存区域或区域,与 lowmem 相对;关于内存区域的更多信息将在后面的部分区域中介绍)。更准确地说,由于内核现在发现不可能一次性直接映射所有物理内存,它设置了一个(虚拟)区域,可以在其中设置和使用该 RAM 的临时虚拟映射。这就是所谓的高内存区域。

不要被“高内存”这个词所迷惑;首先,它不一定放在内核段的“高”位置,其次,这并不是high_memory全局变量所代表的 - 它(high_memory)代表了内核的 lowmem 区域的上限。关于这一点,后面的部分会有更多介绍,描述内核段布局的宏和变量

然而,现在(特别是 32 位系统越来越少使用),这些问题在 64 位 Linux 上完全消失了。想想看:在 64 位 Linux 上,x86_64 的内核段大小达到了 128 TB(!)。目前没有任何系统的 RAM 接近这么多。因此,所有平台的 RAM 确实(轻松地)可以直接映射到内核段,而ZONE_HIGHMEM(或等效)的需求也消失了。

再次,内核文档提供了有关这个“高内存”区域的详细信息。如果感兴趣,请查看:www.kernel.org/doc/Documentation/vm/highmem.txt

好的,现在让我们来做我们一直在等待的事情 - 编写一个内核模块(LKM)来深入了解内核段的一些细节。

编写一个内核模块来显示有关内核段的信息

正如我们所了解的,内核段由各种区域组成。有些是所有架构(与架构无关)共有的:它们包括 lowmem 区域(其中包含未压缩的内核映像 - 其代码、数据、BSS 等)、内核模块区域、vmalloc/ioremap区域等。

这些区域在内核段中的精确位置,以及可能存在的区域,都与特定的架构(CPU)有关。为了帮助理解并针对任何给定的系统进行固定,让我们开发一个内核模块,查询并打印有关内核段的各种细节(实际上,如果需要,它还会打印一些有用的用户空间内存细节)。

通过 dmesg 查看树莓派上的内核段

在跳入并分析这样一个内核模块的代码之前,事实上,类似于我们在这里尝试的事情 - 打印内核段/VAS 中各种有趣区域的位置和大小 - 已经在流行的树莓派(ARM)Linux 内核的早期引导时执行。在下面的片段中,我们展示了树莓派 3 B+(运行默认的 32 位树莓派 OS)启动时内核日志的相关输出:

rpi $ uname -r 4.19.97-v7+ rpi $ journalctl -b -k
[...]
Apr 02 14:32:48 raspberrypi kernel: Virtual kernel memory layout:
                       vector  : 0xffff0000 - 0xffff1000   (   4 kB)
                       fixmap  : 0xffc00000 - 0xfff00000   (3072 kB)
                       vmalloc : 0xbb800000 - 0xff800000   (1088 MB)
                       lowmem  : 0x80000000 - 0xbb400000   ( 948 MB)
                       modules : 0x7f000000 - 0x80000000   (  16 MB)
                         .text : 0x(ptrval) - 0x(ptrval)   (9184 kB)
                         .init : 0x(ptrval) - 0x(ptrval)   (1024 kB)
                         .data : 0x(ptrval) - 0x(ptrval)   ( 654 kB)
                          .bss : 0x(ptrval) - 0x(ptrval)   ( 823 kB)
[...]

需要注意的是,前面的打印非常特定于操作系统和设备。默认的树莓派 32 位操作系统会打印这些信息,而其他操作系统可能不会:YMMV你的情况可能有所不同!)。例如,我在设备上构建和运行的标准的树莓派 5.4 内核中,这些信息性的打印是不存在的。在最近的内核版本中(如在 4.19.97-v7+树莓派操作系统内核的前面日志中所见),出于安全原因 - 防止内核信息泄漏 - 许多早期的printk函数不会显示“真实”的内核地址(指针)值;你可能只会看到它打印了0x(ptrval)字符串。

这个0x(ptrval)输出意味着内核故意不显示甚至是散列的 printk(回想一下第五章,编写你的第一个内核模块 - LKMs 第二部分中的%pK格式说明符),因为系统熵还不够高。如果你坚持要看到一个(弱)散列的 printk,你可以在启动时传递debug_boot_weak_hash内核参数(在这里查找内核启动参数的详细信息:www.kernel.org/doc/html/latest/admin-guide/kernel-parameters.html)。

有趣的是,(如前面信息框中提到的),打印这个Virtual kernel memory layout :信息的代码非常特定于树莓派内核补丁!它可以在树莓派内核源代码树中找到:github.com/raspberrypi/linux/raw/rpi-5.4.y/arch/arm/mm/init.c

现在,为了查询和打印类似的信息,你必须首先熟悉一些关键的内核宏和全局变量;我们将在下一节中这样做。

描述内核段布局的宏和变量

要编写一个显示相关内核段信息的内核模块,我们需要知道如何询问内核这些细节。在本节中,我们将简要描述内核中表示内核段内存的一些关键宏和变量(在大多数架构上,按 KVA 降序排列):

  • 向量表 是一个常见的操作系统数据结构 - 它是一个函数指针数组(也称为切换表或跳转表)。它是特定于架构的:ARM-32 使用它来初始化它的向量,以便当处理器发生异常或模式更改(如中断,系统调用,页错误,MMU 中止等)时,处理器知道要运行的代码:
宏或变量 解释
VECTORS_BASE 通常仅适用于 ARM-32;内核向量表的起始 KVA,跨越 1 页
  • fix map 区域 是一系列编译时的特殊或保留的虚拟地址;它们在启动时被用来修复内核段中必须为其提供内存的必需内核元素。典型的例子包括初始化内核页表,早期的ioremapvmalloc区域等。同样,它是一个与架构相关的区域,因此在不同的 CPU 上使用方式不同:
宏或变量 解释
FIXADDR_START 内核 fixmap 区域的起始 KVA,跨越FIXADDR_SIZE字节
  • 内核模块 在内核段中的特定范围内分配内存 - 用于它们的静态文本和数据。内核模块区域的精确位置因架构而异。在 ARM 32 位系统上,实际上是放在用户 VAS 的正上方;而在 64 位系统上,通常放在内核段的更高位置:
内核模块(LKMs)区域 从这里分配内存用于 LKMs 的静态代码+数据
MODULES_VADDR 内核模块区域的起始 KVA
MODULES_END 内核模块区域的结束 KVA;大小为MODULES_END - MODULES_VADDR
  • KASAN: 现代内核(从 x86_64 的 4.0 版本开始,ARM64 的 4.4 版本开始)采用了一种强大的机制来检测和报告内存问题。它基于用户空间地址 SANitizerASAN)代码库,因此被称为内核地址 SANitizerKASAN)。它的强大之处在于能够(通过编译时的插装)检测内存问题,如释放后使用UAF)和越界OOB)访问(包括缓冲区溢出/溢出)。但是,它仅在 64 位 Linux 上工作,并且需要一个相当大的阴影内存区域(大小为内核 VAS 的八分之一,如果启用则显示其范围)。它是一个内核配置功能(CONFIG_KASAN),通常仅用于调试目的(但在调试和测试期间保持启用非常关键!):
KASAN 阴影内存区域(仅适用于 64 位) [可选](仅在 64 位且仅在 CONFIG_KASAN 定义的情况下;请参见以下更多信息)
KASAN_SHADOW_START KASAN 区域的 KVA 起始
KASAN_SHADOW_END KASAN 区域的 KVA 结束;大小为KASAN_SHADOW_END - KASAN_SHADOW_START
  • vmalloc 区域是为vmalloc()(及其相关函数)分配内存的空间;我们将在接下来的两章节中详细介绍各种内存分配 API:
vmalloc 区域 用于通过 vmalloc()和相关函数分配的内存
VMALLOC_START vmalloc区域的 KVA 起始
VMALLOC_END vmalloc区域的结束 KVA;大小为VMALLOC_END - VMALLOC_START
  • 低内存区域 - 根据1:1 ::物理页框:内核页的基础,直接映射到内核段的 RAM 区域 - 实际上是 Linux 内核映射和管理(通常)所有 RAM 的区域。此外,它通常在内核中设置为ZONE_NORMAL(稍后我们还将介绍区域):
低内存区域 直接映射内存区域
PAGE_OFFSET 低内存区域的 KVA 起始;也代表某些架构上内核段的起始,并且(通常)是 32 位上的 VM 分割值。
high_memory 低内存区域的结束 KVA,直接映射内存的上限;实际上,这个值减去PAGE_OFFSET就是系统上 RAM 的数量(注意,这并不一定适用于所有架构);不要与ZONE_HIGHMEM混淆。
  • 高内存区域或区域是一个可选区域。它可能存在于一些 32 位系统上(通常是当 RAM 的数量大于内核段本身的大小时)。在这种情况下,它通常设置为ZONE_HIGHMEM(稍后我们将介绍区域)。此外,您可以在之前的标题为32 位系统上的高内存的部分中了解更多关于这个高内存区域的信息:
高内存区域(仅适用于 32 位) [可选] 在一些 32 位系统上可能存在 HIGHMEM
PKMAP_BASE 高内存区域的 KVA 起始,直到LAST_PKMAP页;表示所谓的高内存页的内核映射(较旧,仅适用于 32 位)
  • 内核镜像本身(未压缩)- 其代码、init和数据区域 - 是私有符号,因此对内核模块不可用;我们不尝试打印它们:
内核(静态)镜像 未压缩内核镜像的内容(请参见以下);不导出,因此对模块不可用
_text, _etext 内核文本(代码)区域的起始和结束 KVA(分别)
__init_begin, __init_end 内核init部分区域的起始和结束 KVA(分别)
_sdata, _edata 内核静态数据区域的起始和结束 KVA(分别)
__bss_start, __bss_stop 内核 BSS(未初始化数据)区域的起始和结束 KVA(分别)
  • 用户 VAS:最后一项当然是进程用户 VAS。它位于内核段的下方(按虚拟地址降序排列),大小为TASK_SIZE字节。在本章的前面部分已经详细讨论过:
用户 VAS 用户虚拟地址空间(VAS)
(用户模式 VAS 如下)TASK_SIZE (通过procfs或我们的procmap实用程序脚本之前详细检查过);内核宏TASK_SIZE表示用户 VAS 的大小(字节)。

好了,我们已经看到了几个内核宏和变量,实际上描述了内核 VAS。

继续我们的内核模块的代码,很快您将看到它的init方法调用了两个重要的函数:

  • show_kernelseg_info(),打印相关的内核段细节

  • show_userspace_info(),打印相关的用户 VAS 细节(这是可选的,通过内核参数决定)

我们将从描述内核段函数并查看其输出开始。此外,Makefile 的设置方式是,它链接到我们的内核库代码的对象文件klib_llkd.c*,并生成一个名为show_kernel_seg.ko的内核模块对象。

试一下 - 查看内核段细节

为了清晰起见,我们将在本节中仅显示源代码的相关部分。 请从本书的 GitHub 存储库中克隆并使用完整的代码。 还要记住之前提到的procmap实用程序; 它有一个内核组件,一个 LKM,它确实与此类似 - 使内核级信息可用于用户空间。 由于它更复杂,我们不会在这里深入研究它的代码; 看到以下演示内核模块show_kernel_seg的代码在这里已经足够了:

// ch7/show_kernel_seg/kernel_seg.c
[...]
static void show_kernelseg_info(void)
{
    pr_info("\nSome Kernel Details [by decreasing address]\n"
    "+-------------------------------------------------------------+\n");
#ifdef CONFIG_ARM
  /* On ARM, the definition of VECTORS_BASE turns up only in kernels >= 4.11 */
#if LINUX_VERSION_CODE > KERNEL_VERSION(4, 11, 0)
    pr_info("|vector table: "
        " %px - %px | [%4ld KB]\n",
        SHOW_DELTA_K(VECTORS_BASE, VECTORS_BASE + PAGE_SIZE));
#endif
#endif

前面的代码片段显示了 ARM 向量表的范围。 当然,这是有条件的。 输出仅在 ARM-32 上发生 - 因此有#ifdef CONFIG_ARM预处理指令。(此外,我们使用%px printk 格式说明符确保代码是可移植的。)

在这个演示内核模块中使用的SHOW_DELTA_*()宏在我们的convenient.h头文件中定义,并且是帮助程序,使我们能够轻松显示传递给它的低值和高值,计算两个数量之间的差异,并显示它; 这是相关的代码:

// convenient.h
[...]
/* SHOW_DELTA_*(low, hi) :
 * Show the low val, high val and the delta (hi-low) in either bytes/KB/MB/GB, as required.
 * Inspired from raspberry pi kernel src: arch/arm/mm/init.c:MLM()
 */
#define SHOW_DELTA_b(low, hi) (low), (hi), ((hi) - (low))
#define SHOW_DELTA_K(low, hi) (low), (hi), (((hi) - (low)) >> 10)
#define SHOW_DELTA_M(low, hi) (low), (hi), (((hi) - (low)) >> 20)
#define SHOW_DELTA_G(low, hi) (low), (hi), (((hi) - (low)) >> 30)
#define SHOW_DELTA_MG(low, hi) (low), (hi), (((hi) - (low)) >> 20), (((hi) - (low)) >> 30)

在以下代码中,我们展示了发出printk函数描述以下区域范围的代码片段:

  • 内核模块区域

  • (可选)KASAN 区域

  • vmalloc 区域

  • 低内存和可能的高内存区域

关于内核模块区域,如下面源代码中的详细注释所解释的那样,我们尝试保持按降序 KVAs 的顺序:

// ch7/show_kernel_seg/kernel_seg.c
[...]
/* kernel module region
 * For the modules region, it's high in the kernel segment on typical 64- 
 * bit systems, but the other way around on many 32-bit systems 
 * (particularly ARM-32); so we rearrange the order in which it's shown 
 * depending on the arch, thus trying to maintain a 'by descending address' ordering. */
#if (BITS_PER_LONG == 64)
  pr_info("|module region: "
    " %px - %px | [%4ld MB]\n",
    SHOW_DELTA_M(MODULES_VADDR, MODULES_END));
#endif

#ifdef CONFIG_KASAN     // KASAN region: Kernel Address SANitizer
  pr_info("|KASAN shadow: "
    " %px - %px | [%2ld GB]\n",
    SHOW_DELTA_G(KASAN_SHADOW_START, KASAN_SHADOW_END));
#endif

  /* vmalloc region */
  pr_info("|vmalloc region: "
    " %px - %px | [%4ld MB = %2ld GB]\n",
    SHOW_DELTA_MG(VMALLOC_START, VMALLOC_END));

  /* lowmem region */
  pr_info("|lowmem region: "
    " %px - %px | [%4ld MB = %2ld GB]\n"
#if (BITS_PER_LONG == 32)
    "|            (above:PAGE_OFFSET - highmem)     |\n",
#else
    "|                (above:PAGE_OFFSET - highmem) |\n",
#endif
    SHOW_DELTA_MG((unsigned long)PAGE_OFFSET, (unsigned long)high_memory));

  /* (possible) highmem region; may be present on some 32-bit systems */
#ifdef CONFIG_HIGHMEM
  pr_info("|HIGHMEM region: "
    " %px - %px | [%4ld MB]\n",
    SHOW_DELTA_M(PKMAP_BASE, (PKMAP_BASE) + (LAST_PKMAP * PAGE_SIZE)));
#endif
[ ... ]
#if (BITS_PER_LONG == 32) /* modules region: see the comment above reg this */
  pr_info("|module region: "
    " %px - %px | [%4ld MB]\n",
    SHOW_DELTA_M(MODULES_VADDR, MODULES_END));
#endif
  pr_info(ELLPS);
}

让我们在 ARM-32 Raspberry Pi 3 B+上构建和插入我们的 LKM; 以下屏幕截图显示了它的设置,然后是内核日志:

图 7.11 - 在运行标准 Raspberry Pi 32 位 Linux 的 Raspberry Pi 3B+上显示 show_kernel_seg.ko LKM 的输出

正如预期的那样,我们收到的关于内核段的输出完全匹配标准 Raspberry Pi 内核在启动时打印的内容(您可以参考通过 dmesg 查看 Raspberry Pi 上的内核段部分来验证这一点)。 从PAGE_OFFSET的值(图 7.11 中的 KVA 0x8000 0000)可以解释出来,我们的 Raspberry Pi 的内核的 VM 分割配置为 2:2(GB)(因为十六进制值0x8000 0000在十进制基数中为 2 GB。有趣的是,更近期的 Raspberry Pi 4 Model B 设备上的默认 Raspberry Pi 32 位操作系统配置为 3:1(GB)VM 分割)。

从技术上讲,在 ARM-32 系统上,至少用户空间略低于 2 GB(2 GB - 16 MB = 2,032 MB),因为这 16 MB 被视为内核模块区域,就在PAGE_OFFSET下面;确实,这可以在图 7.11 中看到(这里的内核模块区域跨越了0x7f00 00000x8000 0000的 16 MB)。 此外,正如您很快将看到的,TASK_SIZE宏的值 - 用户 VAS 的大小 - 也反映了这一事实。

我们在以下图表中展示了大部分这些信息:

图 7.12 - Raspberry Pi 3B+上 ARM-32 进程的完整 VAS,具有 2:2 GB VM 分割

请注意,由于不同型号之间的差异、可用 RAM 的数量,甚至设备树的不同,图 7.12 中显示的布局可能与您拥有的树莓派上的布局并不完全匹配。

好了,现在您知道如何在内核模块中打印相关的内核段宏和变量,帮助您了解任何 Linux 系统上的内核 VM 布局!在接下来的部分中,我们将尝试通过我们的procmap实用程序“看”(可视化)内核 VAS。

通过 procmap 的内核 VAS

好了,这很有趣:在前面的图中以某些细节看到的内存映射布局的视图正是我们前面提到的procmap实用程序提供的!正如之前承诺的,现在让我们看一下运行procmap时内核 VAS 的截图(之前,我们展示了用户 VAS 的截图)。

为了与即时讨论保持同步,我们现在将展示procmap在同一台树莓派 3B+系统上提供内核 VAS 的“视觉”视图的截图(我们可以指定--only-kernel开关来仅显示内核 VAS;尽管我们在这里没有这样做)。由于我们必须在某个进程上运行procmap,我们任意选择systemd PID 1;我们还使用--verbose选项开关。然而,似乎失败了:

图 7.13 - 显示 procmap 内核模块构建失败的截图

为什么构建内核模块失败了(这是procmap项目的一部分)?我在项目的README.md文件中提到了这一点(github.com/kaiwan/procmap/raw/master/README.md#procmap):

[...]to build a kernel module on the target system, you will require it to have a kernel development environment setup; this boils down to having the compiler, make and - key here - the 'kernel headers' package installed for the kernel version it's currently running upon. [...]

我们的自定义5.4 内核(用于树莓派)的内核头文件包不可用,因此失败了。虽然您可以想象地将整个 5.4 树莓派内核源树复制到设备上,并设置/lib/module/<kver>/build符号链接,但这并不被认为是正确的做法。那么,正确的做法是什么?当然是从主机上交叉编译树莓派的procmap内核模块!我们在这里的第三章中涵盖了有关从源代码构建树莓派内核的交叉编译的详细信息,构建 5.x Linux 内核的第二部分,在树莓派的内核构建部分;当然,这也适用于交叉编译内核模块。

我想强调一点:树莓派上的procmap内核模块构建仅因为在运行自定义内核时缺少树莓派提供的内核头文件包而失败。如果您愿意使用默认的树莓派内核(之前称为 Raspbian OS),那么内核头文件包肯定是可安装的(或已安装),一切都将正常工作。同样,在您典型的 x86_64 Linux 发行版上,procmap.ko内核模块可以在运行时得到干净地构建和插入。请仔细阅读procmap项目的README.md文件;其中,标有IMPORTANT: Running procmap on systems other than x86_64的部分详细说明了如何交叉编译procmap内核模块。

一旦您成功在主机系统上交叉编译了procmap内核模块,通过scp(1)procmap.ko内核模块复制到设备上,并将其放置在procmap/procmap_kernel目录下;现在您已经准备好了!

这是复制到树莓派上的内核模块:

cd <...>/procmap/procmap_kernel
ls -l procmap.ko
-rw-r--r-- 1 pi pi 7909 Jul 31 07:45 procmap.ko

(您也可以在其上运行modinfo(8)实用程序,以验证它是否为 ARM 构建。)

有了这个,让我们重试一下我们的procmap运行,以显示内核 VAS 的详细信息:

图 7.14 - 显示 procmap 内核模块成功插入和各种系统详细信息的截图

现在它确实起作用了!由于我们已经将verbose选项指定给procmap,因此您可以看到它的详细进展,以及非常有用的各种感兴趣的内核变量/宏及其当前值。

好的,让我们继续查看我们真正想要的内容-树莓派 3B+上内核 VAS 的“可视地图”,按 KVA 降序排列;以下截图捕获了procmap的输出:

图 7.15-我们的 procmap 实用程序输出的部分截图,显示了树莓派 3B+上完整的内核 VAS(32 位 Linux)

完整的内核 VAS-从end_kva(值为0xffff ffff)右到内核的开始,start_kva0x7f00 0000,正如你所看到的,是内核模块区域)-被显示出来。请注意(绿色)标签右侧的某些关键地址的标注!为了完整起见,我们还在前面的截图中包括了内核-用户边界(以及用户 VAS 的上部分,就像我们一直在说的那样!)。由于前面的输出是在 32 位系统上,用户 VAS 紧随内核段。然而,在 64 位系统上,内核段和用户 VAS 之间有一个(巨大的!)“非规范”稀疏区域。在 x86_64 上(正如我们已经讨论过的),它跨越了 VAS 的绝大部分:16,383.75 拍字节(总 VAS 为 16,384 拍字节)!

我将把运行这个procmap项目的练习留给你,仔细研究你的 x86_64 或其他盒子或虚拟机上的输出。它在带有 3:1 虚拟机分割的 BeagleBone Black 嵌入式板上也能正常工作,显示了预期的详细信息。顺便说一句,这构成了一个作业。

我还提供了一个解决方案,以三个(大的、拼接在一起的)procmap输出的截图形式,分别是在本机 x86_64 系统、BeagleBone Black(AArch32)板和运行 64 位操作系统(AArch64)的树莓派上:solutions_to_assgn/ch7。研究procmap的代码特别是它的内核模块组件,肯定会有所帮助。毕竟它是开源的!

让我们通过查看我们之前的演示内核模块ch7/show_kernel_seg提供的用户段视图来完成本节。

尝试一下-用户段

现在,让我们回到我们的ch7/show_kernel_segLKM 演示程序。我们提供了一个名为show_uservas的内核模块参数(默认值为0);当设置为1时,还会显示有关进程上下文的用户空间的一些详细信息。以下是模块参数的定义:

static int show_uservas;
module_param(show_uservas, int, 0660);
MODULE_PARM_DESC(show_uservas,
"Show some user space VAS details; 0 = no (default), 1 = show");

好了,在同一设备上(我们的树莓派 3 B+),让我们再次运行我们的show_kernel_seg内核模块,这次请求它也显示用户空间的详细信息(通过前面提到的参数)。以下截图显示了完整的输出:

图 7.16-我们的 show_kernel_seg.ko LKM 的输出截图,显示了在树莓派 3B+上运行时内核和用户 VAS 的详细信息,带有树莓派 32 位 Linux 操作系统

这很有用;我们现在可以看到进程的(或多或少)完整的内存映射-所谓的“上(规范)半”内核空间以及“下(规范)半”用户空间-一次性看清楚(是的,没错,尽管procmap项目显示得更好,更详细)。

我将把运行这个内核模块的练习留给你,仔细研究你的 x86_64 或其他盒子或虚拟机上的输出。也要仔细阅读代码。我们通过从current中解引用mm_struct结构(名为mm的任务结构成员)打印了你在前面截图中看到的用户空间详细信息的代码段。回想一下,mm是进程用户映射的抽象。执行此操作的代码片段如下:

// ch7/show_kernel_seg/kernel_seg.c
[ ... ]
static void show_userspace_info(void)
{
    pr_info (
    "+------------ Above is kernel-seg; below, user VAS  ----------+\n"
    ELLPS
    "|Process environment "
      " %px - %px | [ %4zd bytes]\n"
    "| arguments "
    " %px - %px | [ %4zd bytes]\n"
    "| stack start %px\n"
    [...],
        SHOW_DELTA_b(current->mm->env_start, current->mm->env_end),
        SHOW_DELTA_b(current->mm->arg_start, current->mm->arg_end),
        current->mm->start_stack,
    [...]

还记得用户 VAS 开头的所谓空陷阱页面吗?(再次,procmap的输出-参见图 7.9显示了空陷阱页面。)让我们在下一节中看看它是用来做什么的。

空陷阱页面

您是否注意到前面的图表(图 7.9)和图 7.12 中,极左边(尽管非常小!)用户空间开头的单个页面,名为null trap页面?这是什么?很简单:虚拟页面0在硬件 MMU/PTE 级别上没有权限。因此,对该页面的任何访问,无论是rw还是x(读/写/执行),都将导致 MMU 引发所谓的故障或异常。这将使处理器跳转到 OS 处理程序(故障处理程序)。它运行,杀死试图访问没有权限的内存区域的罪犯!

非常有趣:先前提到的 OS 处理程序实际上在进程上下文中运行,猜猜current是什么:哦,它是启动这个坏NULL指针查找的进程(或线程)!在故障处理程序代码中,SIGSEGV信号被传递给故障进程(current),导致其死亡(通过段错误)。简而言之,这就是 OS 如何捕获众所周知的NULL指针解引用错误的方式。

查看内核文档中的内存布局

回到内核段;显然,对于 64 位 VAS,内核段比 32 位的要大得多。正如我们之前看到的,对于 x86_64,它通常是 128 TB。再次研究先前显示的 VM 分割表(图 7.4 中的64 位 Linux 系统上的 VM 分割部分);在那里,第四列是不同架构的 VM 分割。您可以看到在 64 位 Intel/AMD 和 AArch64(ARM64)上,这些数字比 32 位的大得多。有关特定于架构的详细信息,我们建议您参考此处有关进程虚拟内存布局的“官方”内核文档:

架构 内核源树中的文档位置
ARM-32 Documentation/arm/memory.txt
AArch64 Documentation/arm64/memory.txt
x86_64 Documentation/x86/x86_64/mm.txt 注意:此文档的可读性最近得到了极大改善(截至撰写时)Linux 4.20 的提交32b8976github.com/torvalds/linux/commit/32b89760ddf4477da436c272be2abc016e169031。我建议您浏览此文件:www.kernel.org/doc/Documentation/x86/x86_64/mm.txt

冒着重复的风险,我敦促您尝试这个show_kernel_seg内核模块 - 更好的是,procmap项目(github.com/kaiwan/procmap)- 在不同的 Linux 系统上并研究输出。然后,您可以直接看到任何给定进程的“内存映射” - 完整的进程 VAS - 包括内核段!在处理和/或调试系统层问题时,这种理解至关重要。

再次冒着过度陈述的风险,前两节 - 涵盖对用户和内核 VASes进行详细检查 - 确实非常重要。确保花费足够的时间来研究它们并处理示例代码和作业。做得好!

在我们通过 Linux 内核内存管理的旅程中继续前进,现在让我们来看看另一个有趣的主题 - [K]ASLR 通过内存布局随机化功能的保护。继续阅读!

随机化内存布局 - KASLR

在信息安全圈中,众所周知的事实是,利用proc 文件系统(procfs)和各种强大的工具,恶意用户可以预先知道进程 VAS 中各种函数和/或全局变量的精确位置(虚拟地址),从而设计攻击并最终 compromise 给定系统。因此,为了安全起见,为了使攻击者无法依赖于“已知”虚拟地址,用户空间以及内核空间支持ASLR(地址空间布局随机化)KASLR(内核 ASLR)技术(通常发音为Ass-**ler / Kass-ler)。

这里的关键词是随机化: 当启用此功能时,它会改变进程(和内核)内存布局的部分位置,以绝对数字来说,它会通过随机(页面对齐)数量偏移内存的部分从给定的基址。我们到底在谈论哪些“内存部分”?关于用户空间映射(稍后我们将讨论 KASLR),共享库的起始地址(它们的加载地址),mmap(2)-based 分配(记住,任何malloc()函数(/calloc/realloc超过 128 KB 都会成为mmap-based 分配,而不是堆外分配),堆栈起始位置,堆和 vDSO 页面;所有这些都可以在进程运行(启动)时被随机化。

因此,攻击者不能依赖于,比如说,glibc函数(比如system(3))在任何给定进程中被映射到特定的固定 UVA;不仅如此,位置每次进程运行时都会变化!在 ASLR 之前,以及在不支持或关闭 ASLR 的系统上,可以提前确定给定架构和软件版本的符号位置(procfs 加上诸如objdumpreadelfnm等实用程序使这变得非常容易)。

关键在于要意识到[K]ASLR 只是一种统计保护。事实上,通常情况下,并没有太多比特可用于随机化,因此熵并不是很好。这意味着即使在 64 位系统上,页面大小的偏移量也不是很多,因此可能导致实现受到削弱。

现在让我们简要地看一下关于用户模式和内核模式 ASLR(后者被称为 KASLR)的更多细节;以下各节分别涵盖了这些领域。

用户模式 ASLR

通常所说的 ASLR 指的是用户模式 ASLR。它的启用意味着这种保护在每个进程的用户空间映射上都是可用的。实际上,ASLR 的启用意味着用户模式进程的绝对内存映射每次运行时都会有所变化。

ASLR 在 Linux 上已经得到支持很长时间了(自 2.6.12 以来)。内核在 procfs 中有一个可调的伪文件,可以查询和设置(作为 root)ASLR 的状态;在这里:/proc/sys/kernel/randomize_va_space

它可以有三个可能的值;这三个值及其含义如下表所示:

可调值 /proc/sys/kernel/randomize_va_space中对该值的解释
0 (用户模式)ASLR 已关闭;或者可以通过在启动时传递内核参数norandmaps来关闭。
1 (用户模式)ASLR 已开启:基于mmap(2)的分配,堆栈和 vDSO 页面被随机化。这也意味着共享库加载位置和共享内存段被随机化。
2 (用户模式)ASLR 已开启:所有前述(值1加上堆位置被随机化(自 2.6.25 起);这是默认的操作系统值。

(正如前面的一节中所指出的,vsyscall 页面,vDSO 页面是一种系统调用优化,允许一些频繁发出的系统调用(gettimeofday(2)是一个典型的例子)以更少的开销来调用。如果感兴趣,您可以在这里查看有关 vDSO(7)的 man 页面的更多详细信息:man7.org/linux/man-pages/man7/vdso.7.html)

用户模式 ASLR 可以通过在启动时通过引导加载程序向内核传递norandmaps参数来关闭。

KASLR

类似于(用户)ASLR - 而且,更近期的是从 3.14 内核开始 - 甚至内核VAS 也可以通过启用 KASLR 来随机化(在某种程度上)。在这里,内核和内核段内的模块代码的基本位置将通过与 RAM 基址的页面对齐随机偏移量而被随机化。这将在该会话中保持有效;也就是说,直到重新上电或重启。

存在多个内核配置变量,使平台开发人员能够启用或禁用这些随机化选项。作为 x86 特定的一个例子,以下是直接从Documentation/x86/x86_64/mm.txt中引用的:

“请注意,如果启用了 CONFIG_RANDOMIZE_MEMORY,所有物理内存的直接映射,vmalloc/ioremap 空间和虚拟内存映射都将被随机化。它们的顺序被保留,但它们的基址将在引导时提前偏移。”

KASLR 可以通过向内核传递参数(通过引导加载程序)在引导时进行控制:

  • 通过传递nokaslr参数明确关闭

  • 通过传递kaslr参数明确打开

那么,您的 Linux 系统当前的设置是什么?我们可以更改它吗?当然可以(只要我们有root访问权限);下一节将向您展示如何通过 Bash 脚本进行操作。

使用脚本查询/设置 KASLR 状态

我们在<book-source>/ch7/ASLR_check.sh提供了一个简单的 Bash 脚本。它检查(用户模式)ASLR 和 KASLR 的存在,并打印(彩色编码!)有关它们的状态信息。它还允许您更改 ASLR 值。

让我们在我们的 x86_64 Ubuntu 18.04 客户端上试一试。由于我们的脚本被编程为彩色编码,我们在这里展示它的输出截图:

图 7.17 - 当我们的 ch7/ASLR_check.sh Bash 脚本在 x86_64 Ubuntu 客户端上运行时显示的输出截图

它运行,向您显示(至少在此框上)用户模式和 KASLR 确实已打开。不仅如此,我们编写了一个小的“测试”例程来查看 ASLR 的功能。它非常简单:运行以下命令两次:

grep -E "heap|stack" /proc/self/maps

根据您在早期章节中学到的内容,解释/proc/PID/maps 输出,您现在可以在图 7.17 中看到,堆和栈段的 UVAs 在每次运行中都是不同的,从而证明 ASLR 功能确实有效!例如,看一下起始堆 UVA:在第一次运行中,它是0x5609 15f8 2000,在第二次运行中,它是0x5585 2f9f 1000

接下来,我们将进行一个示例运行,其中我们向脚本传递参数0,从而关闭 ASLR;以下截图显示了(预期的)输出:

图 7.18 - 展示了如何通过我们的 ch7/ASLR_check.sh 脚本在 x86_64 Ubuntu 客户端上关闭 ASLR 的截图

这一次,我们可以看到 ASLR 默认是打开的,但我们关闭了它。这在上面的截图中以粗体和红色清楚地突出显示。(请记住再次打开它。)此外,正如预期的那样,由于它已关闭,堆和栈的 UVAs(分别)在两次测试运行中保持不变,这是不安全的。我将让您浏览并理解脚本的源代码。

要利用 ASLR,应用程序必须使用-fPIE-pieGCC 标志进行编译(PIE代表Position Independent Executable)。

ASLR 和 KASLR 都可以防御一些攻击向量,典型情况下是返回到 libc,Return-Oriented Programming(ROP)。然而,不幸的是,白帽和黑帽安全是一场猫鼠游戏,[K]ASLR 和类似的方法被击败是一些高级攻击确实做得很好。有关更多详细信息,请参阅本章的进一步阅读部分(在Linux 内核安全标题下)。

谈到安全性,存在许多有用的工具来对系统进行漏洞检查。查看以下内容:

  • checksec.sh脚本(www.trapkit.de/tools/checksec.html)显示各种“硬化”措施及其当前状态(对于单个文件和进程):RELRO,堆栈 canary,启用 NX,PIE,RPATH,RUNPATH,符号的存在和编译器强化。

  • grsecurity 的 PaX 套件。

  • hardening-check脚本(checksec 的替代品)。

  • kconfig-hardened-check Perl 脚本(github.com/a13xp0p0v/kconfig-hardened-check)检查(并建议)内核配置选项,以防止一些预定义的检查清单中的安全问题。

  • 其他几个:Lynis,linuxprivchecker.py,内存等等。

因此,下次你在多次运行或会话中看到不同的内核或用户虚拟地址时,你会知道这可能是由于[K]ASLR 保护功能。现在,让我们通过继续探索 Linux 内核如何组织和处理物理内存来完成本章。

物理内存

现在我们已经详细研究了虚拟内存视图,包括用户和内核 VASes,让我们转向 Linux 操作系统上物理内存组织的主题。

物理 RAM 组织

Linux 内核在启动时将物理 RAM 组织和分区为一个类似树状的层次结构,包括节点、区域和页框(页框是物理 RAM 页面)(参见图 7.19 和图 7.20)。节点被划分为区域,区域由页框组成。节点抽象了一个物理的 RAM“bank”,它将与一个或多个处理器(CPU)核心相关联。在硬件级别上,微处理器连接到 RAM 控制器芯片;任何内存控制器芯片,因此任何 RAM,也可以从任何 CPU 访问,通过一个互连。显然,能够物理上接近线程正在分配(内核)内存的核心的 RAM 将会提高性能。这个想法被支持所谓的 NUMA 模型的硬件和操作系统所利用(这个含义很快就会解释)。

节点

基本上,节点是用于表示系统主板上的物理 RAM 模块及其相关控制器芯片的数据结构。是的,我们在这里谈论的是实际的硬件通过软件元数据进行抽象。它总是与系统主板上的物理插座(或处理器核心集合)相关联。存在两种类型的层次结构:

  • 非统一内存访问(NUMA)系统:核心对内核分配请求的位置很重要(内存被统一地处理),从而提高性能。

  • 统一内存访问(UMA)系统:核心对内核分配请求的位置并不重要(内存被统一处理)

真正的 NUMA 系统是那些硬件是多核(两个或更多 CPU 核心,SMP)并且有两个或更多物理 RAM“bank”,每个与一个 CPU(或多个 CPU)相关联。换句话说,NUMA 系统将始终具有两个或更多节点,而 UMA 系统将具有一个节点(FYI,抽象节点的数据结构称为pg_data_t,在这里定义:include/linux/mmzone.h:pg_data_t)。

你可能会想为什么会有这么复杂的结构?嗯,这就是——还有什么——都是关于性能! NUMA 系统(它们通常倾向于是相当昂贵的服务器级机器)和它们运行的操作系统(通常是 Linux/Unix/Windows)都是设计成这样的方式,当一个特定 CPU 核心上的进程(或线程)想要执行内核内存分配时,软件会保证通过从最接近核心的节点获取所需的内存(RAM)来实现高性能(因此有了 NUMA 的名字!)。UMA 系统(典型的嵌入式系统、智能手机、笔记本电脑和台式电脑)不会获得这样的好处,也不会有影响。现在的企业级服务器系统可以拥有数百个处理器和数 TB,甚至数 PB 的 RAM!这些几乎总是作为 NUMA 系统进行架构。

然而,由于 Linux 的设计方式,这是一个关键点,即使是常规的 UMA 系统也被内核视为 NUMA(好吧,伪 NUMA)。它们将有恰好一个节点;所以这是一个快速检查系统是否是 NUMA 还是 UMA 的方法 - 如果有两个或更多节点,它是一个真正的 NUMA 系统;只有一个,它就是一个“伪 NUMA”或伪 NUMA 盒子。你怎么检查?numactl(8)实用程序是一种方法(尝试执行numactl --hardware)。还有其他方法(通过procfs本身)。稍等一下,你会到达那里的……

因此,一个更简单的可视化方法是:在 NUMA 盒子上,一个或多个 CPU 核心与一块(硬件模块)物理 RAM 相关联。因此,NUMA 系统总是一个对称多处理器SMP)系统。

为了使这个讨论更实际,让我们简要地想象一下实际服务器系统的微体系结构——一个运行 AMD Epyc/Ryzen/Threadripper(以及旧的 Bulldozer)CPU 的系统。它有以下内容:

  • 在主板上有两个物理插槽(P#0 和 P#1)内的 32 个 CPU 核心(由操作系统看到)。每个插槽包含一个 8x2 CPU 核心的包(8x2,因为实际上每个核心都是超线程的;操作系统甚至将超线程核心视为可用核心)。

  • 总共 32GB 的 RAM 分成四个物理内存条,每个 8GB。

因此,Linux 内存管理代码在引导时检测到这种拓扑结构后,将设置四个节点来表示它。(我们不会在这里深入讨论处理器的各种(L1/L2/L3 等)缓存;在下图后的提示框中有一种方法可以查看所有这些。)

以下概念图显示了在运行 Linux OS 的一些 AMD 服务器系统上形成的四个树状层次结构的近似情况 - 每个节点一个。图 7.19 在系统上显示了每个物理 RAM 条的节点/区域/页框:

图 7.19 - Linux 上物理内存层次结构的(近似概念视图)

使用强大的lstopo(1)实用程序(及其相关的hwloc-* - 硬件位置 - 实用程序)来图形化查看系统的硬件(CPU)拓扑结构!(在 Ubuntu 上,使用sudo apt install hwloc进行安装)。值得一提的是,由lstopo(1)生成的先前提到的 AMD 服务器系统的硬件拓扑图可以在这里看到:en.wikipedia.org/wiki/CPU_cache#/media/File:Hwloc.png

再次强调这里的关键点:为了性能(这里是指图 7.19),在某个处理器上运行一些内核或驱动程序代码的线程在进程上下文中请求内核获取一些 RAM。内核的 MM 层,了解 NUMA,将首先从 NUMA 节点#2 上的任何区域中的任何空闲 RAM 页框中服务请求(作为第一优先级),因为它“最接近”发出请求的处理器核心。以防在 NUMA 节点#2 中的任何区域中没有可用的空闲页框,内核有一个智能的回退系统。它现在可能跨连接并从另一个节点:区域请求 RAM 页框(不用担心,我们将在下一章节中更详细地介绍这些方面)。

区域

区域可以被认为是 Linux 平滑处理和处理硬件怪癖的方式。这些在 x86 上大量存在,Linux 当然是在那里“长大”的。它们还处理一些软件困难(在现在大部分是遗留的 32 位 i386 架构上查找ZONE_HIGHMEM;我们在前面的章节中讨论了这个概念,32 位系统上的高内存)。

区域由页框组成 - 物理 RAM 页。更技术性地说,每个节点内的每个区域都分配了一系列页框号PFN):

图 7.20 - Linux 上物理内存层次结构的另一个视图 - 节点、区域和页框

在图 7.10 中,您可以看到一个通用(示例)Linux 系统,具有N个节点(从0N-1),每个节点包含(假设)三个区域,每个区域由 RAM 的物理页面框架组成。每个节点的区域数量(和名称)由内核在启动时动态确定。您可以通过深入procfs来检查 Linux 系统上的层次结构。在下面的代码中,我们来看一下一个具有 16GB RAM 的本机 Linux x86_64 系统:

$ cat /proc/buddyinfo 
Node 0, zone     DMA      3      2     4    3    3    1   0   0  1   1   3 
Node 0, zone   DMA32  31306  10918  1373  942  505  196  48  16  4   0   0 
Node 0, zone  Normal  49135   7455  1917  535  237   89  19   3  0   0   0
$ 

最左边的列显示我们只有一个节点 - Node 0。这告诉我们实际上我们在一个UMA 系统上,尽管 Linux 操作系统会将其视为(伪/假)NUMA 系统。这个单一的节点0分为三个区域,标记为DMADMA32Normal,每个区域当然由页面框架组成。现在先忽略右边的数字;我们将在下一章中解释它们的含义。

Linux 在 UMA 系统上“伪造”NUMA 节点的另一种方法可以从内核日志中看到。我们在同一个本机 x86_64 系统上运行以下命令,该系统具有 16GB 的 RAM。为了便于阅读,我用省略号替换了显示时间戳和主机名的前几列:

$ journalctl -b -k --no-pager | grep -A7 "NUMA"
 <...>: No NUMA configuration found
 <...>: Faking a node at [mem 0x0000000000000000-0x00000004427fffff]
 <...>: NODE_DATA(0) allocated [mem 0x4427d5000-0x4427fffff]
 <...>: Zone ranges:
   <...>:DMA     [mem 0x0000000000001000-0x0000000000ffffff]
 <...>:   DMA32    [mem 0x0000000001000000-0x00000000ffffffff]
 <...>:   Normal   [mem 0x0000000100000000-0x00000004427fffff]
 <...>:   Device   empty
 $

我们可以清楚地看到,由于系统被检测为非 NUMA(因此是 UMA),内核会伪造一个节点。节点的范围是系统上的总 RAM 量(这里是0x0-0x00000004427fffff,确实是 16GB)。我们还可以看到在这个特定系统上,内核实例化了三个区域 - DMADMA32Normal - 来组织可用的物理页面框架。这与我们之前看到的/proc/buddyinfo输出相吻合。顺便说一下,在 Linux 上代表区域的数据结构在这里定义:include/linux/mmzone.h:struct zone。我们将在本书的后面有机会访问它。

为了更好地理解 Linux 内核如何组织 RAM,让我们从最开始 - 启动时间开始。

直接映射的 RAM 和地址转换

在启动时,Linux 内核将所有(可用的)系统 RAM(也称为平台 RAM)直接映射到内核段。因此,我们有以下内容:

  • 物理页面框架0映射到内核虚拟页面0

  • 物理页面框架1映射到内核虚拟页面1

  • 物理页面框架2映射到内核虚拟页面2,依此类推。

因此,我们称之为 1:1 或直接映射,身份映射的 RAM 或线性地址。一个关键点是所有这些内核虚拟页面都与它们的物理对应物有固定偏移(如前所述,这些内核地址被称为内核逻辑地址)。固定偏移是PAGE_OFFSET值(这里是0xc000 0000)。

所以,想象一下。在一个 32 位系统上,3:1(GB)的虚拟内存分配,物理地址0x0 = 内核逻辑地址0xc000 0000PAGE_OFFSET)。如前所述,术语内核逻辑地址适用于与其物理对应物有固定偏移的内核地址。因此,直接映射的 RAM 映射到内核逻辑地址。这个直接映射内存区域通常被称为内核段中的低内存(或简称为lowmem)区域。

我们已经在之前展示了一个几乎相同的图表,在图 7.10 中。在下图中,稍作修改,实际上显示了前三个(物理)页面框架如何映射到内核段的前三个内核虚拟页面(在内核段的低内存区域):

图 7.21 - 直接映射的 RAM - 32 位系统,3:1(GB)虚拟内存分配

例如,图 7.21 显示了在 32 位系统上平台 RAM 直接映射到内核段的情况,VM 分割比为 3:1(GB)。物理 RAM 地址0x0映射到内核的位置是PAGE_OFFSET内核宏(在前面的图中,它是内核逻辑地址0xc000 0000)。请注意,图 7.21 还显示了左侧的用户 VAS,范围从0x0PAGE_OFFSET-1(大小为TASK_SIZE字节)。我们已经在之前的检查内核段部分详细介绍了内核段的其余部分。

理解物理到虚拟页面的映射可能会诱使你得出这些看似合乎逻辑的结论:

  • 给定一个 KVA,计算相应的物理地址PA) - 也就是执行 KVA 到 PA 计算 - 只需这样做:
pa = kva - PAGE_OFFSET
  • 相反,给定一个 PA,计算相应的 KVA - 也就是执行 PA 到 KVA 计算 - 只需这样做:
kva = pa + PAGE_OFFSET

再次参考图 7.21。RAM 直接映射到内核段(从PAGE_OFFSET开始)确实预示着这个结论。所以,这是正确的。但请注意,这里请仔细注意:这些地址转换计算仅适用于直接映射或线性地址 - 换句话说,内核低端内存区域的 KVAs(技术上来说,是内核逻辑地址) - 不适用于任何其他地方的 UVAs,以及除了低端内存区域之外的任何和所有 KVAs(包括模块地址,vmalloc/ioremap(MMIO)地址,KASAN 地址,(可能的)高端内存区域地址,DMA 内存区域等)。

正如你所预料的,内核确实提供了 API 来执行这些地址转换;当然,它们的实现是与体系结构相关的。它们如下:

内核 API 它的作用
phys_addr_t virt_to_phys(volatile void *address)
void *phys_to_virt(phys_addr_t address)

x86 的virt_to_phys() API 上面有一条注释,明确提倡这个 API(以及类似的 API)不应该被驱动程序作者使用;为了清晰和完整,我们在这里重现了内核源代码中的注释:

// arch/x86/include/asm/io.h
[...]
/**
 *  virt_to_phys    -   map virtual addresses to physical
 *  @address: address to remap
 *
 *  The returned physical address is the physical (CPU) mapping for
 *  the memory address given. It is only valid to use this function on
 *  addresses directly mapped or allocated via kmalloc.
 *
 *  This function does not give bus mappings for DMA transfers. In
 *  almost all conceivable cases a device driver should not be using
 *  this function
 */
static inline phys_addr_t virt_to_phys(volatile void *address)
[...]

前面的注释提到了(非常常见的)kmalloc() API。不用担心,它在接下来的两章中会有详细介绍。当然,对于phys_to_virt() API 也有类似的注释。

那么谁会(少量地)使用这些地址转换 API(以及类似的)呢?当然是内核内部的mm代码!作为演示,我们在本书中至少在两个地方使用了它们:在下一章中,在一个名为ch8/lowlevel_mem的 LKM 中(实际上,它的使用是在我们的“内核库”代码klib_llkd.c的一个函数中)。

值得一提的是,强大的crash(8)实用程序确实可以通过其vtop(虚拟到物理)命令将任何给定的虚拟地址转换为物理地址(反之亦然,通过其ptov命令也可以实现相反的转换!)。

另一个关键点是:通过将所有物理 RAM 映射到其中,不要被误导以为内核正在保留RAM 给自己。不,它没有;它只是映射了所有可用的 RAM,从而使它可以分配给任何想要的人——核心内核代码、内核线程、设备驱动程序或用户空间应用程序。这是操作系统的工作之一;毕竟,它是系统资源管理器。当然,在引导时,一定会占用(分配)一定部分 RAM——静态内核代码、数据、内核页表等,但您应该意识到这是非常小的。举个例子,在我的 1GB RAM 的虚拟机上,内核代码、数据和 BSS 通常总共占用大约 25MB 的 RAM。所有内核内存总共约 100MB,而用户空间内存使用量大约为 550MB!几乎总是用户空间占用内存最多。

您可以尝试使用smem(8)实用程序和--system -p选项开关,以查看内存使用情况的百分比摘要(还可以使用--realmem=开关传递系统上实际的 RAM 数量)。

回到重点:我们知道内核页表在引导过程中早早地设置好了。因此,当应用程序启动时,内核已经将所有 RAM 映射并可用,准备分配!因此,我们理解,虽然内核将页框直接映射到其虚拟地址空间,用户模式进程却没有这么幸运——它们只能通过操作系统在进程创建(fork(2))时在每个进程基础上设置的分页表间接映射页框。再次强调,通过强大的mmap(2)系统调用进行内存映射可以提供将文件或匿名页面“直接映射”到用户虚拟地址空间的错觉。

还有一些额外的要点需要注意:

(a) 为了性能,内核内存(内核页面)永远不会被交换,即使它们没有被使用。

(b) 有时候,你可能会认为,用户空间内存页面通过操作系统在每个进程基础上设置的分页表,映射到(物理)页框上(假设页面是常驻的),这是相当明显的。是的,但内核内存页面呢?请非常清楚地理解这一点:所有内核页面也通过内核“主”分页表映射到页框上。内核内存也是虚拟化的,就像用户空间内存一样。 在这方面,对于您这位感兴趣的读者,我在 Stack Overflow 上发起了一个问答:内核虚拟地址到物理 RAM 的确切转换是如何进行的?:stackoverflow.com/questions/36639607/how-exactly-do-kernel-virtual-addresses-get-translated-to-physical-ram。(c) Linux 内核中已经内置了几种内存优化技术(很多是配置选项);其中包括透明巨大页面THPs)和对云/虚拟化工作负载至关重要的内核同页合并KSM,也称为内存去重) 我建议您参考本章的进一步阅读部分以获取更多信息。

好的,通过对物理 RAM 管理的一些方面进行覆盖,我们完成了本章的内容;进展非常不错!

总结

在本章中,我们深入研究了内核内存管理这一大主题,对于像您这样的内核模块或设备驱动程序作者来说,我们提供了足够详细的级别;而且还有更多内容要学习!一个关键的拼图——VM 分割以及在运行 Linux 操作系统的各种架构上如何实现它——作为一个起点。然后我们深入研究了这个分割的两个区域:首先是用户空间(进程 VAS),然后是内核 VAS(或内核段)。在这里,我们涵盖了许多细节和工具/实用程序,以及如何检查它(特别是通过相当强大的procmap实用程序)。我们构建了一个演示内核模块,可以生成内核和调用进程的相当完整的内存映射。用户和内核内存布局随机化技术([K]ASLR)也被简要讨论了一下。最后,我们看了一下 Linux 内存中 RAM 的物理组织。

本章中学到的所有信息和概念实际上都非常有用;不仅适用于设计和编写更好的内核/设备驱动程序代码,而且在遇到系统级问题和错误时也非常有用。

这一章是一个漫长而且关键的章节;完成得很好!接下来,在接下来的两章中,您将继续学习如何有效地分配(和释放)内核内存的关键和实际方面,以及这一常见活动背后的重要概念。继续前进!

问题

最后,这里有一些问题供您测试对本章材料的了解:github.com/PacktPublishing/Linux-Kernel-Programming/tree/master/questions。您会发现一些问题的答案在书的 GitHub 存储库中:github.com/PacktPublishing/Linux-Kernel-Programming/tree/master/solutions_to_assgn

进一步阅读

为了帮助您深入研究这个主题并提供有用的材料,我们在本书的 GitHub 存储库中提供了一个相当详细的在线参考和链接列表(有时甚至包括书籍)的 Further reading 文档。Further reading文档在这里可用:github.com/PacktPublishing/Linux-Kernel-Programming/raw/master/Further_Reading.md

第八章:模块作者的内核内存分配-第一部分

在前两章中,一章介绍了内核内部方面和架构,另一章介绍了内存管理内部的基本知识,我们涵盖了为本章和下一章提供所需的背景信息的关键方面。在本章和下一章中,我们将着手实际分配和释放内核内存的各种方式。我们将通过您可以测试和调整的内核模块来演示这一点,详细说明其中的原因和方法,并提供许多实用的技巧,以使像您这样的内核或驱动程序开发人员在处理内核模块内存时能够获得最大的效率。

在本章中,我们将介绍内核的两个主要内存分配器——页面分配器PA)(又称Buddy System AllocatorBSA))和 slab 分配器。我们将深入研究在内核模块中使用它们的 API 的细节。实际上,我们将远远超出简单地了解如何使用 API,清楚地展示在所有情况下都不是最佳的原因,以及如何克服这些情况。第九章,模块作者的内核内存分配-第二部分,将继续介绍内核内存分配器,深入探讨一些更高级的领域。

在本章中,我们将涵盖以下主题:

  • 介绍内核内存分配器

  • 理解和使用内核页面分配器(或 BSA)

  • 理解和使用内核 slab 分配器

  • kmalloc API 的大小限制

  • Slab 分配器-一些额外的细节

  • 使用 slab 分配器时的注意事项

技术要求

我假设您已经阅读了第一章,内核工作空间设置,并已经适当准备了一个运行 Ubuntu 18.04 LTS(或更高稳定版本)的虚拟机,并安装了所有必需的软件包。如果没有,我强烈建议您首先这样做。

为了充分利用本书,我强烈建议您首先设置好工作空间

环境,包括克隆本书的 GitHub 存储库(github.com/PacktPublishing/Linux-Kernel-Programming)以获取代码,并进行实际操作。

请参考Hands-On System Programming with Linux,Kaiwan N Billimoria, Packt (www.packtpub.com/networking-and-servers/hands-system-programming-linux)作为本章的先决条件(确实是必读的):

  • 第一章Linux 系统架构

  • 第二章虚拟内存

介绍内核内存分配器

像任何其他操作系统一样,Linux 内核需要一个稳固的算法和实现来执行一个非常关键的任务——分配和释放内存或页面帧(RAM)。Linux 操作系统中的主要(de)分配器引擎被称为 PA 或 BSA。在内部,它使用所谓的伙伴系统算法来高效地组织和分配系统 RAM 的空闲块。我们将在理解和使用内核页面分配器(或 BSA)部分找到更多关于该算法的信息。

在本章和本书中,当我们使用(de)allocate这种表示法时,请将其理解为allocatedeallocate两个词。

当然,作为不完美的,页面分配器并不是获取和释放系统内存的唯一或总是最佳方式。Linux 内核中存在其他技术来实现这一点。其中之一是内核的slab 分配器slab 缓存系统(我们在这里使用slab这个词作为这种类型分配器的通用名称,因为它起源于这个名称;实际上,Linux 内核使用的现代 slab 分配器的内部实现称为 SLUB(无队列 slab 分配器);稍后会详细介绍)。

可以这样理解:slab 分配器解决了一些问题,并通过页面分配器优化了性能。到底解决了哪些问题?我们很快就会看到。不过,现在,真的很重要的是要理解,实际(de)分配物理内存的唯一方式是通过页面分配器。页面分配器是 Linux 操作系统上内存(de)分配的主要引擎!

为了避免混淆和重复,我们从现在开始将这个主要分配引擎称为页面分配器。您将了解到它也被称为 BSA(源自驱动它的算法的名称)。

因此,slab 分配器是建立在页面分配器之上的。各种核心内核子系统以及内核中的非核心代码,如设备驱动程序,都可以直接通过页面分配器或间接通过 slab 分配器分配(和释放)内存;以下图表说明了这一点:

图 8.1 - Linux 的页面分配器引擎,上面是 slab 分配器

首先,有几件事要澄清:

  • 整个 Linux 内核及其所有核心组件和子系统(不包括内存管理子系统本身)最终都使用页面分配器(或 BSA)进行内存(de)分配。这包括非核心内容,如内核模块和设备驱动程序。

  • 前面的系统完全驻留在内核(虚拟)地址空间中,不可直接从用户空间访问。

  • 页面帧(RAM)从页面分配器获取内存的地方位于内核低内存区域,或内核段的直接映射 RAM 区域(我们在上一章节详细介绍了内核段)

  • slab 分配器最终是页面分配器的用户,因此它的内存也是从那里获取的(这再次意味着从内核低内存区域获取)

  • 用户空间使用熟悉的malloc系列 API 进行动态内存分配并不直接映射到前面的层(也就是说,在用户空间调用malloc(3)并不直接导致对页面或 slab 分配器的调用)。它是间接的。具体是如何?您将会学到;请耐心等待!(这个关键内容实际上在下一章的两个部分中找到,涉及到需求分页;在您学习那一章时要注意!)

  • 另外,要明确的是,Linux 内核内存是不可交换的。它永远不会被交换到磁盘上;这是在早期 Linux 时代决定的,以保持性能高。用户空间内存页面默认是可交换的;系统程序员可以通过mlock()/mlockall()系统调用来改变这一点。

现在,系好安全带!有了对页面分配器和 slab 分配器的基本理解,让我们开始学习 Linux 内核内存分配器的工作原理,更重要的是,如何与它们良好地配合工作。

理解和使用内核页面分配器(或 BSA)

在这一部分,您将了解 Linux 内核主要(de)分配器引擎的两个方面:

  • 首先,我们将介绍这个软件背后算法的基础知识(称为伙伴系统)。

  • 然后,我们将介绍它向内核或驱动程序开发人员公开的 API 的实际使用。

理解页面分配器背后的算法的基础知识是重要的。然后您将能够了解其优缺点,以及在哪种情况下使用哪些 API。让我们从它的内部工作原理开始。再次提醒,本书关于内部内存管理细节的范围是有限的。我们将涵盖到足够的深度,不再深入。

页面分配器的基本工作原理

我们将把这个讨论分成几个相关的部分。让我们从内核的页面分配器如何通过其 freelist 数据结构跟踪空闲物理页面帧开始。

Freelist 组织

页面分配器(伙伴系统)算法的关键是其主要内部元数据结构。它被称为伙伴系统空闲列表,由指向(非常常见的!)双向循环列表的指针数组组成。这个指针数组的索引称为列表的顺序 - 它是要提高 2 的幂。数组长度从0MAX_ORDER-1MAX_ORDER的值取决于体系结构。在 x86 和 ARM 上,它是 11,而在大型系统(如 Itanium)上,它是 17。因此,在 x86 和 ARM 上,顺序范围从 2⁰到 2¹⁰;也就是从 1 到 1,024。这是什么意思?请继续阅读...

每个双向循环链表指向大小为2^(order)的自由物理连续页面帧。因此(假设页面大小为 4 KB),我们最终得到以下列表:

  • 2⁰ = 1 页 = 4 KB 块

  • 2¹ = 2 页 = 8 KB 块

  • 2² = 4 页 = 16 KB 块

  • 2³ = 8 页 = 32 KB 块

  • 2¹⁰ = 1024 页 = 1024*4 KB = 4 MB 块

以下图表是对(单个实例的)页面分配器空闲列表的简化概念说明:

图 8.2 - 具有 4 KB 页面大小和 MAX_ORDER 为 11 的系统上的伙伴系统/页面分配器空闲列表

在上图中,每个内存“块”由一个正方形框表示(为了简单起见,我们在图中使用相同的大小)。当然,在内部,这些并不是实际的内存页面;相反,这些框代表指向物理内存帧的元数据结构(struct page)。在图的右侧,我们显示了可以排入左侧列表的每个物理连续空闲内存块的大小。

内核通过proc文件系统(在我们的 Ubuntu 虚拟机上,内存为 1 GB)为我们提供了对页面分配器当前状态的方便(汇总)视图:

图 8.3 - 样本/proc/buddyinfo 输出的带注释的屏幕截图

我们的虚拟机是一个伪 NUMA 框,有一个节点(Node 0)和两个区域(DMADMA32)。在zone XXX后面的数字是从顺序 0,顺序 1,顺序 2 一直到MAX_ORDER-1(这里是11 - 1 = 10)的空闲(物理连续!)页框的数量。因此,让我们从前面的输出中取几个例子:

  • 在节点0zone DMA的顺序0列表中有 35 个单页的空闲 RAM 块。

  • 在节点0zone DMA32,顺序3,这里显示的数字是 678;现在,取2^(order) = 2³ = 8* 页框 = 32 KB(假设页面大小为 4 KB);这意味着在该列表上有 678 个 32 KB 的物理连续空闲 RAM 块。

重要的是要注意每个块都保证是物理连续的 RAM。还要注意,给定顺序上的内存块的大小始终是前一个顺序的两倍(并且是下一个顺序的一半)。当然,这是因为它们都是 2 的幂。

请注意,MAX_ORDER可以(并且确实)随体系结构变化。在常规 x86 和 ARM 系统上,它是11,在空闲列表的顺序 10 上产生 4 MB 的物理连续 RAM 的最大块大小。在运行 Itanium(IA-64)处理器的高端企业服务器级系统上,MAX_ORDER可以高达17(意味着在空闲列表的顺序(17-1)上的最大块大小,因此在 16 的顺序上是2¹⁶ = 65,536 页 = 512 MB 块的物理连续 RAM,对于 4 KB 页面大小)。IA-64 MMU 支持从仅有的 4 KB 到 256 MB 的八种页面大小。作为另一个例子,对于 16 MB 的页面大小,顺序 16 列表可能每个具有65,536 * 16 MB = 1 TB的物理连续 RAM 块!

另一个关键点:内核保留多个 BSA 空闲列表 - 每个存在于系统上的 node:zone 都有一个!这为在 NUMA 系统上分配内存提供了一种自然的方式。

下图显示了内核如何实例化多个空闲列表-系统上每个节点:区域一个(图表来源:Professional Linux Kernel Architecture,Mauerer,Wrox Press,2008 年 10 月):

图 8.4-页面分配器(BSA)“空闲列表”,系统上每个节点:区域一个;图表来源:Professional Linux Kernel Architecture,Mauerer,Wrox Press,2008 年 10 月

此外,如图 8.5 所示,当内核被调用以通过页面分配器分配 RAM 时,它会选择最佳的空闲列表来分配内存-与请求的线程所在的节点相关联的列表(回想一下前一章的 NUMA 架构)。如果该节点没有内存或由于某种原因无法分配内存,内核将使用备用列表来确定从哪个空闲列表尝试分配内存(实际上,实际情况更加复杂;我们在页面分配器内部-更多细节部分提供了一些更多的细节)。

现在让我们以概念方式了解所有这些实际上是如何工作的。

页面分配器的工作原理

实际的(解)分配策略可以通过一个简单的例子来解释。假设一个设备驱动程序请求 128 KB 的内存。为了满足这个请求,(简化和概念化的)页面分配器算法将执行以下操作:

  1. 该算法以页面的形式表示要分配的数量(这里是 128 KB),因此,这里是(假设页面大小为 4 KB)128/4=32 页

  2. 接下来,它确定 2 必须被提高到多少次方才能得到 32。这就是log[2]32,结果是 5(因为 2⁵等于 32)。

  3. 现在,它检查适当的节点:区域页面分配器空闲列表上的顺序 5 列表。如果有可用的内存块(大小为2页=128 KB),则从列表中出列,更新列表,并分配给请求者。任务完成!返回给调用者。

为什么我们说适当的节点:区域**页面分配器空闲列表?这是否意味着有不止一个?是的,确实如此!我们再次重申:实际情况是系统上将有几个空闲列表数据结构,每个节点:区域一个(还可以在页面分配器内部-更多细节部分中查看更多细节)。

  1. 如果顺序 5 列表上没有可用的内存块(即为空),那么它将检查下一个顺序的列表;也就是顺序 6 的链表(如果不为空,它将有2⁶**页=256 KB的内存块排队,每个块的大小是我们想要的两倍)。

  2. 如果顺序 6 列表不为空,那么它将从中取出(出列)一个内存块(大小为 256 KB,是所需大小的两倍),并执行以下操作:

  • 更新列表以反映现在已经移除了一个块。

  • 将这个块切成两半,从而得到两个 128 KB 的半块或伙伴!(请参阅下面的信息框。)

  • 将一半(大小为 128 KB)迁移(入列)到顺序 5 列表。

  • 将另一半(大小为 128 KB)分配给请求者。

  • 任务完成!返回给调用者。

  1. 如果顺序 6 列表也是空的,那么它将使用顺序 7 列表重复前面的过程,直到成功为止。

  2. 如果所有剩余的高阶列表都为空(null),则请求将失败。

我们可以将内存块切成两半,因为列表上的每个块都保证是物理上连续的内存。切割后,我们得到两个半块;每个都被称为伙伴块,因此这个算法的名称。从学术角度来说,它被称为二进制伙伴系统,因为我们使用 2 的幂大小的内存块。伙伴块被定义为与另一个相同大小且物理相邻的块。

你会明白前面的描述是概念性的。实际的代码实现当然更复杂和优化。顺便说一句,代码-作为分区伙伴分配器的核心,正如它的注释所提到的,就在这里:mm/page_alloc.c:__alloc_pages_nodemask()。超出了本书的范围,我们不会尝试深入研究分配器的代码级细节。

通过几种情景来工作

现在我们已经了解了算法的基础,让我们考虑一些情景:首先是一个简单直接的情况,然后是一些更复杂的情况。

最简单的情况

假设一个内核空间设备驱动程序(或一些核心代码)请求 128 KB,并从一个空闲列表数据结构的 order 5 列表中接收到一个内存块。在以后的某个时间点,它将必然通过使用页面分配器的一个 free API 来释放内存块。现在,这个 API 的算法通过它的 order 计算出刚刚释放的块属于 order 5 列表;因此,它将其排队在那里。

更复杂的情况

现在,假设与之前的简单情况不同,当设备驱动程序请求 128 KB 时,order 5 列表为空;因此,根据页面分配器算法,我们转到下一个 order 6 的列表并检查它。假设它不为空;算法现在出列一个 256 KB 的块并将其分割(或切割)成两半。现在,一半(大小为 128 KB)发送给请求者,剩下的一半(同样大小为 128 KB)排队到 order 5 列表。

伙伴系统的真正有趣的特性是当请求者(设备驱动程序)在以后的某个时间点释放内存块时会发生什么。正如预期的那样,算法通过它的 order 计算出刚刚释放的块属于 order 5 列表。但在盲目地将其排队到那里之前,它会寻找它的伙伴块,在这种情况下,它(可能)找到了!现在它将两个伙伴块合并成一个更大的块(大小为 256 KB)并将合并后的块排队到order 6列表。这太棒了-它实际上帮助了碎片整理内存

失败的情况

现在让我们通过不使用方便的 2 的幂大小作为需求来增加趣味性。这一次,假设设备驱动程序请求大小为 132 KB 的内存块。伙伴系统分配器会怎么做?当然,它不能分配比请求的内存更少,它会分配更多-你猜到了(见图 8.2),下一个可用的内存块是大小为 256 KB 的 order 7。但消费者(驱动程序)只会看到并使用分配给它的 256 KB 块的前 132 KB。剩下的(124 KB)是浪费的(想想看,接近 50%的浪费!)。这被称为内部碎片(或浪费),是二进制伙伴系统的关键失败!

你会发现,对于这种情况确实有一种缓解方法:有一个补丁用于处理类似的情况(通过alloc_pages_exact() / free_pages_exact() API)。我们将很快介绍使用页面分配器的 API。

页面分配器内部-更多细节

在本书中,我们不打算深入研究页面分配器内部的代码级细节。话虽如此,事实是:在数据结构方面,zone结构包含一个free_area结构的数组。这是有道理的;正如你所学到的,系统上可以有(通常有)多个页面分配器空闲列表,每个节点:区域一个:

// include/linux/mmzone.h
struct zone { 
    [ ... ] 
    /* free areas of different sizes */
    struct free_area free_area[MAX_ORDER];
    [ ... ]
};

free_area结构是双向循环链表的实现(在该节点:区域内的空闲内存页框中)以及当前空闲的页框数量:

struct free_area {
    struct list_head free_list[MIGRATE_TYPES];
    unsigned long nr_free;
};

为什么是一个链表数组而不是一个链表?不深入细节,我们将提到,实际上,到目前为止,伙伴系统空闲列表的内核布局比表面上的更复杂:从 2.6.24 内核开始,我们看到的每个空闲列表实际上进一步分解为多个空闲列表,以满足不同的页面迁移类型。这是为了处理在尝试保持内存碎片整理时出现的复杂情况。除此之外,如前所述,这些空闲列表存在于系统上的每个节点:区域。因此,例如,在一个实际的 NUMA 系统上,每个节点有 4 个区域,每个节点有 3 个区域,将有 12(4 x 3)个空闲列表。不仅如此,每个空闲列表实际上进一步分解为 6 个空闲列表,每个迁移类型一个。因此,在这样的系统上,整个系统将存在6 x 12 = 72个空闲列表数据结构!

如果您感兴趣,请深入了解细节,并查看/proc/buddyinfo的输出-这是伙伴系统空闲列表状态的一个很好的总结视图(如图 8.3 所示)。接下来,为了获得更详细和更现实的视图(如前面提到的类型,显示所有空闲列表),查看/proc/pagetypeinfo(需要 root 访问)-它显示所有空闲列表(也分解为页面迁移类型)。

页面分配器(伙伴系统)算法的设计是最佳适配类之一。它的主要优点是实际上有助于在系统运行时整理物理内存。简而言之,它的优缺点如下。

页面分配器(伙伴系统)算法的优点如下:

  • 有助于碎片整理内存(防止外部碎片)

  • 保证分配物理连续的内存块

  • 保证 CPU 缓存行对齐的内存块

  • 快速(足够快;算法时间复杂度为O(log n)

另一方面,迄今为止最大的缺点是内部碎片或浪费可能过高。

好的,很棒!我们已经涵盖了页面或伙伴系统分配器内部工作的大量背景材料。现在是动手的时候:让我们现在深入了解并使用页面分配器 API 来分配和释放内存。

学习如何使用页面分配器 API

Linux 内核提供了一组 API 来通过页面分配器分配和释放内存(RAM),这些通常被称为低级(de)分配器例程。以下表格总结了页面分配 API;您会注意到所有具有两个参数的 API 或宏,第一个参数称为GFP 标志或位掩码;我们将很快详细解释它,请现在忽略它。第二个参数是order-空闲列表的顺序,即要分配的内存量为 2^(order)页帧。所有原型都可以在include/linux/gfp.h中找到:

API 或宏名称 评论 API 签名或宏
__get_free_page() 分配一个页面帧。分配的内存将具有随机内容;它是__get_free_pages()API 的包装器。返回值是刚分配的内存的内核逻辑地址的指针。 #define __get_free_page(gfp_mask) \ __get_free_pages((gfp_mask), 0)
__get_free_pages() 分配2^(order)个物理连续的页面帧。分配的内存将具有随机内容;返回值是刚分配的内存的内核逻辑地址的指针。 unsigned long __get_free_pages(gfp_t gfp_mask, unsigned int order);
get_zeroed_page() 分配一个页面帧;其内容设置为 ASCII 零(NULL;即,它被清零);返回值是刚分配的内存的内核逻辑地址的指针。 unsigned long get_zeroed_page(gfp_t gfp_mask);
alloc_page() 分配一个页面帧。分配的内存将具有随机内容;是alloc_pages() API 的包装器;返回值是指向刚分配的内存的page元数据结构的指针;可以通过page_address()函数将其转换为内核逻辑地址。 #define alloc_page(gfp_mask) \ alloc_pages(gfp_mask, 0)
alloc_pages() 分配2^(order)个物理连续页面帧。分配的内存将具有随机内容;返回值是指向刚分配的内存的page元数据结构开头的指针;可以通过page_address()函数将其转换为内核逻辑地址。 struct page * alloc_pages(gfp_t gfp_mask, unsigned int order);

表 8.1 - 低级(BSA/page)分配器 - 流行的导出分配 API

所有先前的 API 都是通过EXPORT_SYMBOL()宏导出的,因此可供内核模块和设备驱动程序开发人员使用。不用担心,您很快就会看到一个演示如何使用它们的内核模块。

Linux 内核认为维护一个(小)元数据结构来跟踪每个 RAM 页面帧是值得的。它被称为page结构。关键在于,要小心:与通常的返回指向新分配的内存块开头的指针(虚拟地址)的语义不同,注意先前提到的alloc_page()alloc_pages() API 都返回指向新分配的内存的page结构开头的指针,而不是内存块本身(其他 API 所做的)。您必须通过调用返回的页面结构地址上的page_address() API 来获取新分配的内存开头的实际指针。在编写内核模块以演示使用页面分配器 API部分的示例代码将说明所有先前 API 的用法。

在这里提到的页面分配器 API 之前,至关重要的是了解至少关于获取空闲页面(GFP)标志的基础知识,这是接下来的部分的主题。

处理 GFP 标志

您会注意到所有先前的分配器 API(或宏)的第一个参数是gfp_t gfp_mask。这是什么意思?基本上,这些是 GFP 标志。这些是内核内部内存管理代码层使用的标志(有几个)。对于典型的内核模块(或设备驱动程序)开发人员来说,只有两个 GFP 标志至关重要(如前所述,其余是用于内部使用)。它们如下:

  • GFP_KERNEL

  • GFP_ATOMIC

在通过页面分配器 API 执行内存分配时决定使用哪个是重要的;始终记住的一个关键规则是:

如果在进程上下文中并且可以安全休眠,则使用 GFP_KERNEL 标志。如果不安全休眠(通常在任何类型的原子或中断上下文中),必须使用 GFP_ATOMIC 标志。

遵循上述规则至关重要。搞错了会导致整个机器冻结、内核崩溃和/或发生随机的不良情况。那么安全/不安全休眠这些陈述到底意味着什么?为此以及更多内容,我们推迟到接下来的深入挖掘 GFP 标志部分。尽管如此,这真的很重要,所以我强烈建议您阅读它。

Linux 驱动程序验证(LDV)项目:回到第一章,内核工作空间设置,在LDV - Linux 驱动程序验证项目部分,我们提到该项目对于 Linux 模块(主要是驱动程序)以及核心内核的各种编程方面有有用的“规则”。

关于我们当前的主题,这里有一个规则,一个否定的规则,暗示着你不能这样做:“在持有自旋锁时使用阻塞内存分配”(linuxtesting.org/ldv/online?action=show_rule&rule_id=0043)。持有自旋锁时,你不允许做任何可能会阻塞的事情;这包括内核空间内存分配。因此,非常重要的是,在任何原子或非阻塞上下文中执行内存分配时,必须使用GFP_ATOMIC标志,比如在持有自旋锁时(你会发现这在互斥锁中并不适用;在持有互斥锁时,你可以执行阻塞活动)。违反这个规则会导致不稳定,甚至可能引发(隐式)死锁的可能性。LDV 页面提到了一个违反这个规则的设备驱动程序以及随后的修复(git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=5b0691508aa99d309101a49b4b084dc16b3d7019)。看一下:补丁清楚地显示了(在我们即将介绍的kzalloc()API 的上下文中)GFP_KERNEL标志被替换为GFP_ATOMIC标志。

另一个常用的 GFP 标志是__GFP_ZERO。它的使用向内核表明你想要零化的内存页面。它经常与GFP_KERNELGFP_ATOMIC标志按位或操作,以返回初始化为零的内存。

内核开发人员确实费心详细记录了 GFP 标志。在include/linux/gfp.h中有一个长而详细的注释;标题是DOC: 有用的 GFP 标志组合

目前,为了让我们快速入门,只需了解使用GFP_KERNEL标志与 Linux 内核的内存分配 API 确实是内核内部分配的常见情况。

使用页面分配器释放页面

当然,分配内存的另一面是释放内存。内核中的内存泄漏绝对不是你想要贡献的东西。在表 8.1中显示的页面分配器 API 中,这里是相应的释放 API:

API 或宏名称 评论 API 签名或宏
free_page() 释放通过__get_free_page()get_zeroed_page()alloc_page()API 分配的(单个)页面;它只是free_pages()API 的简单包装 #define free_page(addr) __free_pages((addr), 0)
free_pages() 释放通过__get_free_pages()alloc_pages()API 分配的多个页面(实际上是__free_pages()的包装) void free_pages(unsigned long addr, unsigned int order)
__free_pages() (与前一行相同,另外)这是执行实际工作的基础例程;还要注意第一个参数是指向page元数据结构的指针。 void __free_pages(struct page *page, unsigned int order)

表 8.2 - 与页面分配器一起使用的常见释放页面 API

您可以看到前面函数中实际的基础 API 是free_pages(),它本身只是mm/page_alloc.c:__free_pages()代码的包装。free_pages()API 的第一个参数是指向被释放内存块的起始指针;当然,这是分配例程的返回值。然而,基础 API__free_pages()的第一个参数是指向被释放内存块的page元数据结构的指针。

一般来说,除非您真的知道自己在做什么,您肯定应该调用foo()包装例程而不是其内部的__foo()例程。这样做的一个原因是简单的正确性(也许包装器在调用底层例程之前使用了一些必要的同步机制 - 比如锁)。另一个原因是有效性检查(这有助于代码保持健壮和安全)。通常,__foo()例程会绕过有效性检查以换取速度。

正如所有有经验的 C/C++应用程序开发人员所知,分配和随后释放内存是错误的丰富来源!这主要是因为 C 是一种无管理语言,就内存而言;因此,您可能会遇到各种各样的内存错误。这些包括众所周知的内存泄漏,读/写的缓冲区溢出/下溢,双重释放和使用后释放(UAF)错误。

不幸的是,在内核空间中也没有什么不同;只是后果会更严重!要特别小心!请务必确保以下内容:

  • 偏爱初始化分配的内存为零的例程。

  • 在执行分配时考虑并使用适当的 GFP 标志 - 更多内容请参阅GFP 标志 - 深入挖掘部分,但简而言之,请注意以下内容:

  • 在可以安全休眠的进程上下文中,使用GFP_KERNEL

  • 在原子上下文中,比如处理中断时,使用GFP_ATOMIC

  • 在使用页面分配器时(就像我们现在正在做的那样),尽量保持分配大小为圆整的 2 的幂页(关于这一点的原因以及在不需要这么多内存时如何减轻这一点 - 典型情况下 - 将在本章后续部分详细介绍)。

  • 您只会尝试释放您之前分配的内存;不用说,不要忘记释放它,也不要重复释放它。

  • 确保原始内存块的指针不受重用、操纵(ptr ++或类似的操作)和破坏,以便在完成时正确释放它。

  • 检查(并再次检查!)传递给 API 的参数。是否需要指向先前分配的块或其底层page结构的指针?

在生产中发现困难和/或担心问题?别忘了,您有帮助!学习如何使用内核内部的强大静态分析工具(Coccinelle、sparse和其他工具,如cppchecksmatch)。对于动态分析,学习如何安装和使用KASAN(内核地址消毒剂)。

回想一下我在第五章中提供的 Makefile 模板,编写您的第一个内核模块 - LKMs 第二部分,在A better Makefile template部分。它包含使用了几种这些工具的目标;请使用它!

好了,既然我们已经涵盖了页面分配器的(常见的)分配和释放 API,现在是时候将这些知识付诸实践了。让我们写一些代码!

编写一个内核模块来演示使用页面分配器 API

现在让我们动手使用我们迄今为止学到的低级页面分配器和释放 API。在本节中,我们将展示相关的代码片段,然后在必要时进行解释,来自我们的演示内核模块(ch8/lowlevel_mem/lowlevel_mem.c)。

在我们小型 LKM 的主要工作例程bsa_alloc()中,我们用粗体字突出显示了显示我们试图实现的代码注释。需要注意的几点:

  1. 首先,我们做了一些非常有趣的事情:我们使用我们的小内核“库”函数klib_llkd.c:show_phy_pages(),直接向您展示物理 RAM 页框如何在内核低端内存区域与内核虚拟页进行身份映射!(show_phy_pages()例程的确切工作将很快讨论):
// ch8/lowlevel_mem/lowlevel_mem.c
[...]
static int bsa_alloc(void)
{
    int stat = -ENOMEM;
    u64 numpg2alloc = 0;
    const struct page *pg_ptr1;

    /* 0\. Show the identity mapping: physical RAM page frames to kernel virtual
     * addresses, from PAGE_OFFSET for 5 pages */
    pr_info("%s: 0\. Show identity mapping: RAM page frames : kernel virtual pages :: 1:1\n", OURMODNAME);
    show_phy_pages((void *)PAGE_OFFSET, 5 * PAGE_SIZE, 1);
  1. 接下来,我们通过底层的__get_free_page()页面分配器 API 分配一页内存(我们之前在表 8.1中看到过):
  /* 1\. Allocate one page with the __get_free_page() API */
  gptr1 = (void *) __get_free_page(GFP_KERNEL);
  if (!gptr1) {
        pr_warn("%s: __get_free_page() failed!\n", OURMODNAME);
        /* As per convention, we emit a printk above saying that the
         * allocation failed. In practice it isn't required; the kernel
         * will definitely emit many warning printk's if a memory alloc
         * request ever fails! Thus, we do this only once (here; could also
         * use the WARN_ONCE()); from now on we don't pedantically print any
         * error message on a memory allocation request failing. */
        goto out1;
  }
  pr_info("%s: 1\. __get_free_page() alloc'ed 1 page from the BSA @ %pK (%px)\n",
      OURMODNAME, gptr1, gptr1);

注意我们发出一个printk函数,显示内核的逻辑地址。回想一下上一章,这是页面分配器内存,位于内核段/VAS 的直接映射 RAM 或 lowmem 区域。

出于安全考虑,我们应该一致且只使用%pK格式说明符来打印内核地址,以便在内核日志中显示哈希值而不是真实的虚拟地址。然而,在这里,为了向您展示实际的内核虚拟地址,我们还使用了%px格式说明符(与%pK一样,也是可移植的;出于安全考虑,请不要在生产中使用%px格式说明符)。

接下来,请注意在发出第一个__get_free_page() API(在前面的代码片段中)之后的详细注释。它提到您实际上不必打印内存不足的错误或警告消息。(好奇吗?要找出原因,请访问lkml.org/lkml/2014/6/10/382。)在这个示例模块中(以及之前的几个模块和将要跟进的模块),我们通过使用适当的 printk 格式说明符(如%zd%zu%pK%px%pa)来编码我们的 printk(或pr_foo()宏)实例,以实现可移植性。

  1. 让我们继续使用页面分配器进行第二次内存分配;请参阅以下代码片段:
/*2\. Allocate 2^bsa_alloc_order pages with the __get_free_pages() API */
  numpg2alloc = powerof(2, bsa_alloc_order); // returns 2^bsa_alloc_order
  gptr2 = (void *) __get_free_pages(GFP_KERNEL|__GFP_ZERO, bsa_alloc_order);
  if (!gptr2) {
      /* no error/warning printk now; see above comment */
      goto out2;
  }
  pr_info("%s: 2\. __get_free_pages() alloc'ed 2^%d = %lld page(s) = %lld bytes\n"
      " from the BSA @ %pK (%px)\n",
      OURMODNAME, bsa_alloc_order, powerof(2, bsa_alloc_order),
      numpg2alloc * PAGE_SIZE, gptr2, gptr2);
  pr_info(" (PAGE_SIZE = %ld bytes)\n", PAGE_SIZE);

在前面的代码片段中(请参阅代码注释),我们通过页面分配器的__get_free_pages() API(因为我们模块参数bsa_alloc_order的默认值是3)分配了 2³ - 也就是 8 页的内存。

一旁注意到,我们使用GFP_KERNEL|__GFP_ZERO GFP 标志来确保分配的内存被清零,这是最佳实践。然而,清零大内存块可能会导致轻微的性能损失。

现在,我们问自己一个问题:有没有办法验证内存是否真的是物理上连续的(承诺的)?事实证明,是的,我们实际上可以检索并打印出每个分配的页框的起始物理地址,并检索其页框号(PFN)。

PFN 是一个简单的概念:它只是索引或页码 - 例如,物理地址 8192 的 PFN 是 2(8192/4096)。由于我们已经展示了如何(以及何时可以)将内核虚拟地址转换为它们的物理对应物(反之亦然;这个覆盖在第七章中,内存管理内部 - 基本知识,在直接映射 RAM 和地址转换部分),我们就不在这里重复了。

为了完成将虚拟地址转换为物理地址并检查连续性的工作,我们编写了一个小的“库”函数,它保存在本书 GitHub 源树的根目录中的一个单独的 C 文件klib_llkd.c中。我们的意图是修改我们的内核模块的 Makefile,以便将这个库文件的代码也链接进来!(正确地完成这个工作在第五章中已经涵盖了,编写您的第一个内核模块 - LKMs 第二部分,在通过多个源文件执行库模拟部分。)这是我们对库例程的调用(就像在步骤 0 中所做的那样):

show_phy_pages(gptr2, numpg2alloc * PAGE_SIZE, 1);

以下是我们库例程的代码(在<booksrc>/klib_llkd.c源文件中;为了清晰起见,我们不会在这里展示整个代码):

// klib_llkd.c
[...]
/* show_phy_pages - show the virtual, physical addresses and PFNs of the memory range provided on a per-page basis.
 * @kaddr: the starting kernel virtual address
 * @len: length of the memory piece (bytes)
 * @contiguity_check: if True, check for physical contiguity of pages
 * 'Walk' the virtually contiguous 'array' of pages one by one (that is, page by page),  
 * printing the virt and physical address (and PFN- page frame number). This way, we can see 
 * if the memory really is *physically* contiguous or not
 */
void show_phy_pages(const void *kaddr, size_t len, bool contiguity_check)
{
    [...]
    if (len % PAGE_SIZE)
        loops++;
    for (i = 0; i < len/PAGE_SIZE; i++) {
        pa = virt_to_phys(vaddr+(i*PAGE_SIZE));
 pfn = PHYS_PFN(pa);

        if (!!contiguity_check) {
        /* what's with the 'if !!(<cond>) ...' ??
         * a 'C' trick: ensures that the if condition always evaluates
         * to a boolean - either 0 or 1 */
            if (i && pfn != prev_pfn + 1)
                pr_notice(" *** physical NON-contiguity detected ***\n");
        }
        pr_info("%05d 0x%px %pa %ld\n", i, vaddr+(i*PAGE_SIZE), &pa, pfn);
        if (!!contiguity_check)
            prev_pfn = pfn;
    }
}

研究前面的函数。我们逐个遍历给定的内存范围(虚拟页),获取物理地址和 PFN,然后通过 printk 发出(请注意,我们使用%pa格式说明符来可移植地打印物理地址 - 它需要通过引用传递)。不仅如此,如果第三个参数contiguity_check1,我们将检查 PFN 是否只相差一个数字,从而检查页面是否确实是物理上连续的。 (顺便说一句,我们使用的简单powerof()函数也在我们的库代码中。)

不过,有一个关键点:让内核模块与物理地址一起工作是极不鼓励的。只有内核的内部内存管理代码直接使用物理地址。甚至硬件设备驱动程序直接使用物理内存的真实案例非常少见(DMA 是其中之一,使用*ioremap*API 是另一个)。

我们只在这里这样做是为了证明一点-由页面分配器分配的内存(通过单个 API 调用)是物理连续的。此外,请意识到我们使用的virt_to_phys()(和其他)API 保证仅在直接映射内存(内核低内存区域)上工作,而不是在vmalloc范围、IO 内存范围、总线内存、DMA 缓冲区等其他地方。

  1. 现在,让我们继续进行内核模块代码:
    /* 3\. Allocate and init one page with the get_zeroed_page() API */
    gptr3 = (void *) get_zeroed_page(GFP_KERNEL);
    if (!gptr3)
        goto out3;
    pr_info("%s: 3\. get_zeroed_page() alloc'ed 1 page from the BSA @ %pK (%px)\n", 
        OURMODNAME, gptr3, gptr3);

如前面的代码片段所示,我们分配了一页内存,但通过使用 PA get_zeroed_page() API 确保它被清零。pr_info()显示了哈希和实际的 KVA(使用%pK%px以便地址以便以可移植的方式打印,无论你是在 32 位还是 64 位系统上运行)。

  1. 接下来,我们使用alloc_page() API 分配一页。小心!它不会返回分配页面的指针,而是返回代表分配页面的元数据结构page的指针;这是函数签名:struct page * alloc_page(gfp_mask)。因此,我们使用page_address()助手将其转换为内核逻辑(或虚拟)地址:
/* 4\. Allocate one page with the alloc_page() API.
 pg_ptr1 = alloc_page(GFP_KERNEL);
 if (!pg_ptr1)
     goto out4;

 gptr4 = page_address(pg_ptr1);
 pr_info("%s: 4\. alloc_page() alloc'ed 1 page from the BSA @ %pK (%px)\n"
         " (struct page addr=%pK (%px)\n)",
        OURMODNAME, (void *)gptr4, (void *)gptr4, pg_ptr1, pg_ptr1);

在前面的代码片段中,我们通过alloc_page() PA API 分配了一页内存。正如所解释的,我们需要将其返回的页面元数据结构转换为 KVA(或内核逻辑地址)通过page_address() API。

  1. 接下来,使用alloc_pages() API 分配和init 2³ = 8 页。与前面的代码片段一样,这里也适用相同的警告:
 /* 5\. Allocate and init 2³ = 8 pages with the alloc_pages() API.
 gptr5 = page_address(alloc_pages(GFP_KERNEL, 3));
 if (!gptr5)
     goto out5;
 pr_info("%s: 5\. alloc_pages() alloc'ed %lld pages from the BSA @ %pK (%px)\n", 
     OURMODNAME, powerof(2, 3), (void *)gptr5, (void *)gptr5);

在前面的代码片段中,我们将alloc_pages()包装在page_address() API 中,以分配2³ = 8页内存!

有趣的是,我们在代码中使用了几个本地的goto语句(请在存储库中查看代码)。仔细观察,你会注意到它实际上保持了错误处理代码路径的清晰和逻辑。这确实是 Linux 内核编码风格指南的一部分。

对(有时有争议的)goto的使用在这里清楚地记录在这里:www.kernel.org/doc/html/v5.4/process/coding-style.html#centralized-exiting-of-functions。我敦促你去查看!一旦你理解了使用模式,你会发现它有助于减少所有太典型的内存泄漏(等等)清理错误!

  1. 最后,在清理方法中,在从内核内存中删除之前,我们释放了在内核模块的清理代码中刚刚分配的所有内存块。

  2. 为了将我们的库klib_llkd代码与我们的lowlevel_mem内核模块链接起来,Makefile更改为以下内容(回想一下,我们在第五章中学习了如何将多个源文件编译成单个内核模块,编写你的第一个内核模块-LKMs 第二部分,在通过多个源文件执行库模拟部分):

 PWD                   := $(shell pwd)
 obj-m                 += lowlevel_mem_lkm.o
 lowlevel_mem_lkm-objs := lowlevel_mem.o ../../klib_lkdc.o
 EXTRA_CFLAGS          += -DDEBUG

同样,在这个示例 LKM 中,我们经常使用%px printk 格式说明符,以便我们可以看到实际的虚拟地址而不是哈希值(内核安全功能)。在这里可以,但在生产中不要这样做。

哎呀!这是相当多的内容。确保你理解了代码,然后继续看它的运行。

部署我们的 lowlevel_mem_lkm 内核模块

好了,是时候看看我们的内核模块在运行中的情况了!让我们在树莓派 4(运行默认的树莓派 OS)和 x86_64 VM(运行 Fedora 31)上构建和部署它。

在 Raspberry Pi 4 Model B 上(运行 Raspberry Pi 内核版本 5.4.79-v7l+),我们构建然后insmod(8)我们的lowlevel_mem_lkm内核模块。以下截图显示了输出:

图 8.5 - 在 Raspberry Pi 4 Model B 上的 lowlevel_mem_lkm 内核模块的输出

看看!在图 8.6 的输出的第 0 步中,我们的show_phy_pages()库例程清楚地显示 KVA 0xc000 0000具有 PA 0x0,KVA 0xc000 1000具有 PA 0x1000,依此类推,共五页(右侧还有 PFN);你可以清楚地看到物理 RAM 页框与内核虚拟页(在内核段的 lowmem 区域)的 1:1 身份映射!

接下来,使用__get_free_page()API 进行初始内存分配如预期进行。更有趣的是我们的第 2 种情况。在这里,我们可以清楚地看到每个分配的页面(从 0 到 7,共 8 页)的物理地址和 PFN 是连续的,显示出分配的内存页面确实是物理上连续的!

我们在运行我们自定义的 5.4 'debug'内核的 Ubuntu 20.04 上的 x86_64 VM 上构建和运行相同的模块。以下截图显示了输出:

图 8.6 - 在运行 Ubuntu 20.04 的 x86_64 VM 上的 lowlevel_mem_lkm 内核模块的输出

这一次(参见图 8.7),由于PAGE_OFFSET值是 64 位数量(这里的值是0xffff 8880 0000 0000),你可以再次清楚地看到物理 RAM 页框与内核虚拟地址的身份映射(5 页)。让我们花点时间仔细看看页分配器 API 返回的内核逻辑地址。在图 8.7 中,你可以看到它们都在0xffff 8880 .... ....范围内。以下片段来自 x86_64 的内核源树中的Documentation/x86/x86_64/mm.txt,记录了 x86_64 上的虚拟内存布局(部分):

如果这一切对你来说都很新奇,请参考第七章,内存管理内部-基本知识,特别是检查内核段直接映射的 RAM 和地址转换部分。

0000000000000000 - 00007fffffffffff (=47 bits) user space, different per mm hole caused by [47:63] sign extension
ffff800000000000 - ffff87ffffffffff (=43 bits) guard hole, reserved for hypervisor
ffff880000000000 - ffffc7ffffffffff (=64 TB) direct mapping of all phys. memory
ffffc80000000000 - ffffc8ffffffffff (=40 bits) hole
ffffc90000000000 - ffffe8ffffffffff (=45 bits) vmalloc/ioremap space

很清楚,不是吗?页分配器内存(伙伴系统空闲列表)直接映射到内核 VAS 的直接映射或 lowmem 区域内的空闲物理 RAM。因此,它显然会从这个区域返回内存。你可以在前面的文档输出中看到这个区域(用粗体字突出显示)- 内核直接映射或 lowmem 区域。再次强调特定的地址范围非常与架构相关。在前面的代码中,这是 x86_64 上的(最大可能的)范围。

虽然很想宣称你现在已经完成了页分配器及其 API,但现实情况是(像往常一样)并非完全如此。请继续阅读,看看为什么-理解这些方面真的很重要。

页分配器和内部碎片

虽然表面上看起来一切都很好,但我敦促你深入一点。在表面之下,一个巨大的(不愉快的!)惊喜可能在等待着你:那些毫不知情的内核/驱动程序开发人员。我们之前介绍的有关页分配器的 API(参见表 8.1)有能力在内部产生碎片-简单来说,浪费-内核内存的非常重要部分!

要理解为什么会这样,你必须至少了解页分配器算法及其空闲列表数据结构的基础知识。页分配器的基本工作部分涵盖了这一点(以防你还没有阅读,请务必阅读)。

通过几种情景部分,你会看到当我们请求方便的、完全舍入的二次幂大小的页面时,情况会非常顺利。然而,当情况不是这样时——比如说驱动程序请求 132 KB 的内存——那么我们就会遇到一个主要问题:内部碎片或浪费非常高。这是一个严重的缺点,必须加以解决。我们将看到实际上有两种方法。请继续阅读!

确切的页面分配器 API

意识到默认页面分配器(或 BSA)内存在浪费的巨大潜力后,来自 Freescale Semiconductor 的开发人员(请参见信息框)为内核页面分配器贡献了一个扩展 API 的补丁,添加了一些新的 API。

在 2.6.27-rc1 系列中,2008 年 7 月 24 日,Timur Tabi 提交了一个补丁来减轻页面分配器浪费问题。这是相关的提交:github.com/torvalds/linux/commit/2be0ffe2b29bd31d3debd0877797892ff2d91f4c

使用这些 API 可以更有效地分配大块(多个页面)内存,浪费要少得多。用于分配和释放内存的新(嗯,至少在 2008 年是新的)API 对如下:

#include <linux/gfp.h>
void *alloc_pages_exact(size_t size, gfp_t gfp_mask);
void free_pages_exact(void *virt, size_t size);

alloc_pages_exact()API 的第一个参数size是以字节为单位的,第二个参数是之前讨论过的“通常”的 GFP 标志值(在处理 GFP 标志部分;对于可能休眠的进程上下文情况,使用GFP_KERNEL,对于永不休眠的中断或原子上下文情况,使用GFP_ATOMIC)。

请注意,由此 API 分配的内存仍然保证是物理上连续的。此外,一次(通过一个函数调用)可以分配的数量受到MAX_ORDER的限制;事实上,这也适用于我们迄今为止看到的所有其他常规页面分配 API。我们将在即将到来的kmalloc API 的大小限制部分中讨论更多关于这方面的内容。在那里,你会意识到讨论实际上不仅限于 slab 缓存,还包括页面分配器!

free_pages_exact() API 只能用于释放由其对应的alloc_pages_exact()分配的内存。此外,要注意“free”例程的第一个参数当然是匹配的“alloc”例程返回的值(指向新分配的内存块的指针)。

alloc_pages_exact()的实现很简单而巧妙:它首先通过__get_free_pages()API“通常”分配整个请求的内存块。然后,它循环——从要使用的内存的末尾到实际分配的内存量(通常远大于此)——释放那些不必要的内存页面!因此,在我们的例子中,如果通过alloc_pages_exact()API 分配了 132 KB,它实际上会首先通过__get_free_pages()分配 256 KB,然后释放从 132 KB 到 256 KB 的内存!

开源之美的又一个例子!可以在这里找到使用这些 API 的演示:ch8/page_exact_loop;我们将留给你来尝试。

在我们开始这一部分之前,我们提到了解决页面分配器浪费问题的两种方法。一种是使用更有效的alloc_pages_exact()free_pages_exact()API,就像我们刚刚学到的那样;另一种是使用不同的层来分配内存——slab 分配器。我们很快就会涉及到它;在那之前,请耐心等待。接下来,让我们更详细地了解(典型的)GFP 标志以及你作为内核模块或驱动程序作者应该如何使用它们,这一点非常重要。

GFP 标志——深入挖掘

关于我们对低级页面分配器 API 的讨论,每个函数的第一个参数都是所谓的 GFP 掩码。在讨论 API 及其使用时,我们提到了一个关键规则

如果在进程上下文中并且可以安全地休眠,请使用GFP_KERNEL标志。如果不安全休眠(通常是在任何类型的中断上下文或持有某些类型的锁时),必须使用GFP_ATOMIC标志。

我们将在接下来的章节中详细阐述这一点。

永远不要在中断或原子上下文中休眠

短语安全休眠实际上是什么意思?为了回答这个问题,想想阻塞调用(API):阻塞调用是指调用进程(或线程)因为在等待某些事件而进入休眠状态,而它正在等待的事件尚未发生。因此,它等待 - 它“休眠”。当在将来的某个时间点,它正在等待的事件发生或到达时,它会被内核唤醒并继续前进。

用户空间阻塞 API 的一个例子是sleep(3)。在这里,它正在等待的事件是一定时间的流逝。另一个例子是read(2)及其变体,其中正在等待的事件是存储或网络数据的可用性。使用wait4(2),正在等待的事件是子进程的死亡或停止/继续,等等。

因此,任何可能阻塞的函数最终可能会花费一些时间处于休眠状态(在休眠时,它肯定不在 CPU 运行队列中,并且在等待队列中)。在内核模式下调用这种可能阻塞的功能(当然,这是我们在处理内核模块时所处的模式)只允许在进程上下文中在不安全休眠的上下文中调用任何类型的阻塞调用都是错误的把这看作是一个黄金法则。这也被称为在原子上下文中休眠 - 这是错误的,是有 bug 的,绝对不*应该发生。

您可能会想,我怎么能预先知道我的代码是否会进入原子或中断上下文?在某种程度上,内核会帮助我们:在配置内核时(回想一下第二章,从源代码构建 5.x Linux 内核 - 第一部分中的make menuconfig),在Kernel Hacking / Lock Debugging菜单下,有一个名为"Sleep inside atomic section checking"的布尔可调节项。打开它!(配置选项名为CONFIG_DEBUG_ATOMIC_SLEEP;您可以随时在内核配置文件中使用 grep 查找它。同样,在第五章,编写您的第一个内核模块 - LKMs 第二部分,在“配置”内核部分,这是您绝对应该打开的东西。)

另一种思考这种情况的方式是如何确切地让一个进程或线程进入休眠状态?简短的答案是通过调用调度代码 - schedule()函数。因此,根据我们刚刚学到的内容(作为推论),schedule()只能在安全休眠的上下文中调用;进程上下文通常是安全的,中断上下文永远不安全。

这一点非常重要!(我们在第四章中简要介绍了进程和中断上下文,编写您的第一个内核模块 - LKMs 第一部分,在进程和中断上下文部分中,以及开发人员如何使用in_task()宏来确定代码当前是否在进程或中断上下文中运行。)同样,您可以使用in_atomic()宏;如果代码处于原子上下文 - 在这种情况下,它通常会在没有中断的情况下运行完成 - 它返回True;否则,返回False。您可以同时处于进程上下文和原子上下文 - 例如,当持有某些类型的锁时(自旋锁;当然,我们稍后会在关于同步的章节中介绍这一点);反之则不会发生。

除了我们关注的 GFP 标志——GFP_KERNELGFP_ATOMIC之外,内核还有几个其他[__]GFP_*标志,用于内部使用;其中有几个是专门用于回收内存的。这些包括(但不限于)__GFP_IO__GFP_FS__GFP_DIRECT_RECLAIM__GFP_KSWAPD_RECLAIM__GFP_RECLAIM__GFP_NORETRY等等。在本书中,我们不打算深入研究这些细节。我建议您查看include/linux/gfp.h中对它们的详细注释(也请参阅进一步阅读部分)。

Linux 驱动程序验证LDV)项目:回到第一章,内核工作空间设置,我们提到这个项目对 Linux 模块(主要是驱动程序)以及核心内核的各种编程方面有有用的“规则”。

关于我们当前的主题,这是其中一个规则,一个否定的规则,暗示着你不能这样做:在持有 USB 设备锁时不禁用 IO 进行内存分配linuxtesting.org/ldv/online?action=show_rule&rule_id=0077)。一些快速背景:当你指定GFP_KERNEL标志时,它隐含地意味着(除其他事项外)内核可以启动 IO(输入/输出;读/写)操作来回收内存。问题是,有时这可能会有问题,不应该这样做;为了解决这个问题,你应该在分配内核内存时使用 GFP 位掩码的一部分GFP_NOIO标志。

这正是这个 LDV“规则”所指的情况:在usb_lock_device()usb_unlock_device()API 之间,不应该使用GFP_KERNEL标志,而应该使用GFP_NOIO标志。(你可以在这段代码中看到使用这个标志的几个实例:drivers/usb/core/message.c)。LDV 页面提到了一些 USB 相关的驱动程序代码源文件已经修复以符合这个规则。

好了,现在你已经掌握了大量关于页面分配器的细节(毕竟,它是 RAM(de)分配的内部“引擎”!),它的 API 以及如何使用它们,让我们继续讨论一个非常重要的主题——slab 分配器背后的动机,它的 API 以及如何使用它们。

理解和使用内核 slab 分配器

正如本章的第一节介绍内核内存分配器中所看到的,slab 分配器slab 缓存位于页面分配器(或 BSA)之上(请参阅图 8.1)。slab 分配器通过两个主要的想法或目的来证明它的存在:

  • 对象缓存:在这里,它作为常见“对象”的缓存,用于在 Linux 内核中高性能地分配(和随后释放)频繁分配的数据结构。

  • 通过提供小巧方便的大小的缓存,通常是页面的片段,来减少页面分配器的高浪费(内部碎片)。

现在让我们以更详细的方式来检查这些想法。

对象缓存的想法

好的,我们从这些设计理念中的第一个开始——常见对象的缓存概念。很久以前,SunOS 的开发人员 Jeff Bonwick 注意到操作系统内部频繁分配和释放某些内核对象(通常是数据结构)。因此,他有了在某种程度上预分配它们的想法。这演变成了我们所说的slab 缓存

因此,在 Linux 操作系统上,内核(作为引导时初始化的一部分)将相当多的对象预先分配到几个 slab 缓存中。原因是:性能!当核心内核代码(或设备驱动程序)需要为这些对象之一分配内存时,它直接请求 slab 分配器。如果有缓存,分配几乎是立即的(反之亦然在释放时)。你可能会想,这真的有必要吗?确实有!

高性能被要求的一个很好的例子是网络和块 IO 子系统的关键代码路径。正因为这个原因,内核在 slab 缓存中自动缓存预分配)了几个网络和块 IO 数据结构(网络堆栈的套接字缓冲区sk_buff,块层的biovec,当然还有核心的task_struct数据结构或对象,这是一些很好的例子)。同样,文件系统的元数据结构(如inodedentry结构等),内存描述符(struct mm_struct)等也都是在 slab 缓存中预分配的。我们能看到这些缓存的对象吗?是的,稍后我们将通过/proc/slabinfo来做到这一点。

slab(或者更正确地说,SLUB)分配器具有更优越的性能的另一个原因是传统的基于堆的分配器往往会频繁分配和释放内存,从而产生“空洞”(碎片)。因为 slab 对象在缓存中只分配一次(在启动时),并在那里释放(因此实际上并没有真正“释放”),所以性能保持很高。当然,现代内核具有智能功能,当内存压力过高时,会以一种优雅的方式开始释放 slab 缓存。

slab 缓存的当前状态 - 对象缓存、缓存中的对象数量、正在使用的数量、每个对象的大小等 - 可以通过几种方式查看:通过procsysfs文件系统的原始视图,或者通过各种前端实用程序(如slabtop(1)vmstat(8)slabinfo)的更易读的视图。在下面的代码片段中,在运行 Ubuntu 18.04 LTS 的本机 x86_64(带有 16 GB RAM),我们查看了从/proc/slabinfo输出的前 10 行:

$ sudo head /proc/slabinfo 
slabinfo - version: 2.1
# name <active_objs> <num_objs> <objsize> <objperslab> <pagesperslab> : tunables <limit> <batchcount> <sharedfactor> : slabdata <active_slabs> <num_slabs> <sharedavail>
lttng_event     0     0     280   29   2 : tunables 0 0 0 : slabdata 0 0 0
kvm_async_pf    0     0     136   30   1 : tunables 0 0 0 : slabdata 0 0 0
kvm_vcpu        0     0   24576    1   8 : tunables 0 0 0 : slabdata 0 0 0
kvm_mmu_page_header 0 0     168   24   1 : tunables 0 0 0 : slabdata 0 0 0
pte_list_desc   0     0      32  128   1 : tunables 0 0 0 : slabdata 0 0 0
i915_request  112   112     576   28   4 : tunables 0 0 0 : slabdata 4 4 0
ext4_groupinfo_4k 6482 6496 144   28   1 : tunables 0 0 0 : slabdata 232 232 0
scsi_sense_cache 325 416 128 32 1 : tunables 0 0 0 : slabdata 13 13 0

需要注意的几点:

  • 即使是读取/proc/slabinfo也需要 root 访问权限(因此,我们使用sudo(8))。

  • 在前面的输出中,最左边的一列是 slab 缓存的名称。它通常,但并不总是,与内核中实际缓存的数据结构的名称匹配。

  • 然后,对于每个缓存,以这种格式提供信息:<statistics> : <tunables> : <slabdata>。在slabinfo(5)的 man 页面中解释了标题行中显示的每个字段的含义(使用man 5 slabinfo查找)。

顺便说一句,slabinfo实用程序是内核源代码树中tools/目录下的用户空间 C 代码的一个例子(还有其他几个)。它显示了一堆 slab 层统计信息(尝试使用-X开关)。要构建它,请执行以下操作:

cd <ksrc-tree>/tools/vm
make slabinfo

在这一点上你可能会问,slab 缓存当前总共使用了多少内存?这很容易通过在/proc/meminfo中查找Slab:条目来回答,如下所示:

$ grep "^Slab:" /proc/meminfo
Slab:            1580772 kB

显然,slab 缓存可以使用大量内存!事实上,这是 Linux 上一个让新手感到困惑的常见特性:内核可以并且使用 RAM 进行缓存,从而大大提高性能。当然,它被设计为在内存压力增加时智能地减少用于缓存的内存量。在常规的 Linux 系统上,大部分内存可能用于缓存(特别是页面缓存;它用于在进行 IO 时缓存文件的内容)。这是可以接受的,只要内存压力低。free(1)实用程序清楚地显示了这一点(同样,在我的带有 16 GB RAM 的 x86_64 Ubuntu 系统上,在这个例子中):

$ free -h
              total     used     free     shared     buff/cache  available
Mem:           15Gi    5.5Gi    1.4Gi      704Mi          8.6Gi      9.0Gi
Swap:         7.6Gi       0B    7.6Gi
$ 

buff/cache列指示了 Linux 内核使用的两个缓存 - 缓冲区和页面缓存。实际上,在内核使用的各种缓存中,页面缓存是一个关键的缓存,通常占据了大部分内存使用量。

查看/proc/meminfo以获取有关系统内存使用的细粒度详细信息;显示的字段很多。proc(5)的 man 页面在/proc/meminfo部分描述了这些字段。

现在你已经了解了 slab 分配器背后的动机(这方面还有更多内容),让我们深入学习如何使用它为核心内核和模块作者提供的 API。

学习如何使用 slab 分配器 API

到目前为止,您可能已经注意到我们还没有解释 slab 分配器(缓存)背后的第二个“设计理念”,即通过提供小巧方便的缓存(通常是页面的片段)来减少页分配器的高浪费(内部碎片)。我们将看到这实际上意味着什么,以及内核 slab 分配器 API。

分配 slab 内存

尽管在 slab 层内存在多个执行内存分配和释放的 API,但只有几个真正关键的 API,其余属于“便利或辅助”功能类别(我们当然会在后面提到)。对于内核模块或设备驱动程序作者来说,关键的 slab 分配 API 如下:

#include <linux/slab.h>
void *kmalloc(size_t size, gfp_t flags);
void *kzalloc(size_t size, gfp_t flags);

在使用任何 slab 分配器 API 时,请务必包含<linux/slab.h>头文件。

kmalloc()kzalloc()例程往往是内核内存分配中最常用的 API。在 5.4.0 Linux 内核源代码树上使用非常有用的cscope(1)代码浏览工具进行快速检查(我们并不追求完全精确)后,发现了(大约)使用频率:kmalloc()被调用了大约 4600 次,而kzalloc()被调用了超过 11000 次!

这两个函数都有两个参数:要传递的第一个参数是以字节为单位所需的内存分配的大小,而第二个参数是要分配的内存类型,通过现在熟悉的 GFP 标志指定(我们已经在前面的部分中涵盖了这个主题,即处理 GFP 标志GFP 标志-深入挖掘。如果您对它们不熟悉,我建议您先阅读这些部分)。

为了减轻整数溢出(IoF)错误的风险,您应该避免动态计算要分配的内存大小(第一个参数)。内核文档警告我们要特别注意这一点(链接:

www.kernel.org/doc/html/latest/process/deprecated.html#open-coded-arithmetic-in-allocator-arguments

总的来说,要始终避免使用此处记录的过时内容:过时的接口、语言特性、属性和约定(链接:www.kernel.org/doc/html/latest/process/deprecated.html#deprecated-interfaces-language-features-attributes-and-conventions)。

成功分配后,返回值是一个指针,即刚刚分配的内存块(或 slab)的内核逻辑地址(请记住,它仍然是虚拟地址,不是物理地址)。确实,您应该注意到,除了第二个参数之外,kmalloc()kzalloc()API 与它们的用户空间对应物 glibc malloc(3)(及其伙伴)API 非常相似。不过,不要误解:它们完全不同。malloc()返回一个用户空间虚拟地址,并且如前所述,用户模式的malloc(3)和内核模式的k[m|z]alloc()之间没有直接的对应关系(因此,对malloc()的调用不会立即导致对kmalloc()的调用;稍后会详细介绍!)。

其次,重要的是要理解这些 slab 分配器 API 返回的内存保证是物理上连续的。此外,另一个关键的好处是返回地址保证在 CPU 缓存行边界上;也就是说,它将是缓存行对齐的。这两点都是重要的性能增强的好处。

每个 CPU 在 CPU 缓存<->RAM 中以原子单位读取和写入数据。缓存行的大小因 CPU 而异。你可以使用getconf(1)实用程序查找这个信息 - 例如,尝试执行getconf -a|grep LINESIZE。在现代 CPU 上,指令和数据的缓存行通常是分开的(CPU 缓存本身也是如此)。典型的 CPU 缓存行大小为 64 字节。

kmalloc()分配的内存块在分配后立即是随机的(就像malloc(3)一样)。事实上,kzalloc()被推荐和建议的 API 之所以被使用,是因为它将分配的内存设置为零。一些开发人员认为内存块的初始化需要一些时间,从而降低了性能。我们的反驳是,除非内存分配代码在一个极端时间关键的代码路径中(这在设计上并不是一个好的设计,但有时是无法避免的),你应该作为最佳实践在分配时初始化你的内存。这样可以避免一系列内存错误和安全副作用。

Linux 内核核心代码的许多部分肯定会使用 slab 层来管理内存。在其中,时间关键的代码路径 - 很好的例子可以在网络和块 IO 子系统中找到。为了最大化性能,slab(实际上是 SLUB)层代码已经被编写成无锁(通过一种称为 per-CPU 变量的无锁技术)。在进一步阅读部分中可以了解更多关于性能挑战和实现细节。

释放 slab 内存

当然,你必须在将来的某个时候释放你分配的 slab 内存(以防内存泄漏);kfree()例程就是为此目的而存在的。类似于用户空间的free(3)API,kfree()接受一个参数 - 要释放的内存块的指针。它必须是有效的内核逻辑(或虚拟)地址,并且必须已经被 slab 层 API(k[m|z]alloc()或其帮助程序之一)初始化。它的 API 签名很简单:

void kfree(const void *);

就像free(3)一样,kfree()没有返回值。如前所述,务必确保传递给kfree()的参数是k[m|z]alloc()返回的精确值。传递错误的值将导致内存损坏,最终导致系统不稳定。

还有一些额外的要点需要注意。

假设我们使用kzalloc()分配了一些 slab 内存:

static char *kptr = kzalloc(1024, GFP_KERNEL);

之后,在使用后,我们想要释放它,所以我们做以下操作:

if (kptr)
    kfree(kptr);

这段代码 - 在释放之前检查kptr的值是否不是NULL - 是不必要的;只需执行kfree(kptr);就可以了。

另一个不正确的代码示例(伪代码)如下所示:

static char *kptr = NULL;
 while (<some-condition-is-true>) {
       if (!kptr)
                kptr = kmalloc(num, GFP_KERNEL);
        [... work on the slab memory ...]
       kfree(kptr);
 }

有趣的是:在第二次循环迭代开始,程序员假设kptr指针变量在被释放时会被设置为NULL!这显然不是事实(尽管这本来是一个很好的语义;同样的论点也适用于“通常”的用户空间库 API)。因此,我们遇到了一个危险的 bug:在循环的第二次迭代中,if条件很可能会变为 false,从而跳过分配。然后,我们遇到了kfree(),这当然会破坏内存(由于双重释放的 bug)!(我们在 LKM 中提供了这种情况的演示:ch8/slab2_buggy)。

关于在分配内存后(或期间)初始化内存缓冲区,就像我们提到分配时一样,释放内存也是一样的。您应该意识到kfree()API 只是将刚释放的 slab 返回到其相应的缓存中,内部内存内容保持不变!因此,在释放内存块之前,一个(稍微迂琐的)最佳实践是清除(覆盖)内存内容。这对于安全原因尤为重要(例如在“信息泄漏”情况下,恶意攻击者可能会扫描已释放的内存以寻找“秘密”)。Linux 内核提供了kzfree()API,专门用于此目的(签名与kfree()相同)。

小心!为了覆盖“秘密”,简单的memset()目标缓冲区可能不起作用。为什么?编译器可能会优化掉代码(因为不再使用缓冲区)。大卫·惠勒在他的优秀作品安全编程 HOWTOdwheeler.com/secure-programs/)中提到了这一事实,并提供了解决方案:“似乎在所有平台上都有效的一种方法是编写具有第一个参数的内部“挥发性”的 memset 的自己的实现。”(此代码基于迈克尔·霍华德提出的解决方案):

void *guaranteed_memset(void *v,int c,size_t n)

{ volatile char *p=v; while (n--) *p++=c; return v; }

然后将此定义放入外部文件中,以强制该函数为外部函数(在相应的.h文件中定义函数,并在调用者中#include该文件,这是通常的做法)。这种方法似乎在任何优化级别下都是安全的(即使函数被内联)。

内核的kzfree()API 应该可以正常工作。在用户空间进行类似操作时要小心。

数据结构-一些设计提示

在内核空间使用 slab API 进行内存分配是非常推荐的。首先,它保证了物理上连续和缓存行对齐的内存。这对性能非常有利;此外,让我们看看一些可以带来巨大回报的快速提示。

CPU 缓存可以提供巨大的性能提升。因此,特别是对于时间关键的代码,要注意设计数据结构以获得最佳性能:

  • 将最重要的(频繁访问的,“热”)成员放在一起并置于结构的顶部。要了解原因,想象一下您的数据结构中有五个重要成员(总大小为 56 字节);将它们全部放在结构的顶部。假设 CPU 缓存行大小为 64 字节。现在,当您的代码访问任何一个这五个重要成员(无论读取/写入),所有五个成员都将被取到 CPU 缓存中,因为 CPU 的内存读/写以 CPU 缓存行大小的原子单位工作;这优化了性能(因为在缓存上的操作通常比在 RAM 上的操作快几倍)。

  • 尝试对齐结构成员,使单个成员不会“掉出”缓存行。通常,编译器在这方面会有所帮助,但您甚至可以使用编译器属性来明确指定这一点。

  • 顺序访问内存会因 CPU 缓存的有效使用而导致高性能。但是,我们不能认真地要求将所有数据结构都变成数组!经验丰富的设计师和开发人员知道使用链表是非常常见的。但是,这实际上会损害性能吗?嗯,是的,在某种程度上。因此,建议:使用链表。将列表的“节点”作为一个大数据结构(顶部和一起的“热”成员)。这样,我们尽量最大化两种情况的优势,因为大结构本质上是一个数组。(想想看,我们在第六章中看到的任务结构列表,内核内部要点-进程和线程任务列表是一个具有大数据结构作为节点的链表的完美实际例子)。

即将到来的部分涉及一个关键方面:我们确切地了解内核在通过流行的k[m|z]alloc() API 分配(slab)内存时使用的 slab 缓存。

用于 kmalloc 的实际 slab 缓存

在尝试使用基本的 slab API 创建内核模块之前,我们将进行一个快速的偏离-尽管非常重要。重要的是要了解k[m|z]alloc() API 分配的内存确切来自哪里。好吧,是来自 slab 缓存,但确切是哪些?在sudo vmstat -m的输出上快速使用grep为我们揭示了这一点(以下截图是我们的 x86_64 Ubuntu 客户端):

图 8.7-显示 kmalloc-n slab 缓存的 sudo vmstat -m 截图

这非常有趣!内核有一系列专用的 slab 缓存,用于各种大小的通用kmalloc内存,从 8192 字节到仅有 8 字节!这告诉我们一些东西-使用页面分配器,如果我们请求了,比如,12 字节的内存,它最终会给我们整个页面(4 KB)-浪费太多了。在这里,使用 slab 分配器,对 12 字节的分配请求实际上分配了 16 字节(从图 8.8 中看到的倒数第二个缓存)!太棒了。

另请注意以下内容:

  • kfree()之后,内存被释放回适当的 slab 缓存中。

  • kmalloc的 slab 缓存的精确大小因架构而异。在我们的树莓派系统(当然是 ARM CPU)上,通用内存kmalloc-N缓存范围从 64 字节到 8192 字节。

  • 前面的截图也透露了一个线索。通常,需求是小到微小的内存片段。例如,在前面的截图中,标有Num的列代表当前活动对象的数量,最大数量来自 8 字节和 16 字节的kmalloc slab 缓存(当然,这不一定总是这种情况。快速提示:使用slabtop(1)实用程序(您需要以 root 身份运行):靠近顶部的行显示当前经常使用的 slab 缓存。)

当然,Linux 不断发展。截至 5.0 主线内核,引入了一种新的kmalloc缓存类型,称为可回收缓存(命名格式为kmalloc-rcl-N)。因此,在 5.x 内核上进行与之前相同的 grep 操作也会显示这些缓存。

$ sudo vmstat -m | grep --color=auto "^kmalloc"
kmalloc-rcl-8k                0      0    8192      4
kmalloc-rcl-4k                0      0    4096      8
kmalloc-rcl-2k                0      0    2048     16
[...]
kmalloc-8k                   52     52    8192      4
kmalloc-4k                   99    120    4096      8
kmalloc-2k                  521    560    2048     16
[...]

新的kmalloc-rcl-N缓存在内部帮助更有效地回收页面并作为防止碎片化的措施。但是,像您这样的模块作者不需要关心这些细节。(此工作的提交可以在此处查看:github.com/torvalds/linux/commit/1291523f2c1d631fea34102fd241fb54a4e8f7a0。)

vmstat -m本质上是内核的/sys/kernel/slab内容的包装器(后面会有更多内容)。可以使用诸如slabtop(1)和强大的crash(1)实用程序(在“实时”系统上,相关的 crash 命令是kmem -s(或kmem -S))来查看 slab 缓存的深层内部细节。

好了!是时候再次动手演示使用板块分配器 API 的代码了!

编写一个使用基本板块 API 的内核模块

在接下来的代码片段中,看一下演示内核模块代码(位于ch8/slab1/)。在init代码中,我们仅执行了一些板块层分配(通过kmalloc()kzalloc()API),打印了一些信息,并在清理代码路径中释放了缓冲区(当然,完整的源代码可以在本书的 GitHub 存储库中找到)。让我们一步一步地看代码的相关部分。

在这个内核模块的init代码开始时,我们通过kmalloc()板块分配 API 为一个全局指针(gkptr)分配了 1,024 字节的内存(记住:指针没有内存!)。请注意,由于我们肯定是在进程上下文中运行,因此“安全地休眠”,我们在第二个参数中使用了GFP_KERNEL标志(以防您想要参考,前面的章节GFP 标志-深入挖掘已经涵盖了):

// ch8/slab1/slab1.c
[...]
#include <linux/slab.h>
[...]
static char *gkptr;
struct myctx {
    u32 iarr[100];
    u64 uarr[100];
    char uname[128], passwd[16], config[16];
};
static struct myctx *ctx;

static int __init slab1_init(void)
{
    /* 1\. Allocate slab memory for 1 KB using the kmalloc() */
    gkptr = kmalloc(1024, GFP_KERNEL);
    if (!gkptr) {
        WARN_ONCE(1, "%s: kmalloc() failed!\n", OURMODNAME);
        /* As mentioned earlier, there is really no need to print an
         * error msg when a memory alloc fails; the situation "shouldn't"  
         * typically occur, and if it does, the kernel will emit a chain 
         * of messages in any case. Here, we use the WARN_ONCE()
         * macro pedantically, and as this is a 'learning' program.. */
        goto out_fail1;
    }
    pr_info("kmalloc() succeeds, (actual KVA) ret value = %px\n", gkptr);
    /* We use the %px format specifier here to show the actual KVA; in production, Don't! */
    print_hex_dump_bytes("gkptr before memset: ", DUMP_PREFIX_OFFSET, gkptr, 32);
    memset(gkptr, 'm', 1024);
    print_hex_dump_bytes(" gkptr after memset: ", DUMP_PREFIX_OFFSET, gkptr, 32);

在前面的代码中,还要注意我们使用print_hex_dump_bytes()内核便捷例程作为以人类可读格式转储缓冲区内存的便捷方式。它的签名是:

void print_hex_dump_bytes(const char *prefix_str, int prefix_type,
     const void *buf, size_t len);

其中prefix_str是您想要添加到每行十六进制转储的任何字符串;prefix_typeDUMP_PREFIX_OFFSETDUMP_PREFIX_ADDRESSDUMP_PREFIX_NONE中的一个;buf是要进行十六进制转储的源缓冲区;len是要转储的字节数。

接下来是许多设备驱动程序遵循的典型策略(最佳实践):它们将所有所需的或上下文信息保存在一个单一的数据结构中,通常称为驱动程序上下文结构。我们通过声明一个(愚蠢/示例)名为myctx的数据结构以及一个名为ctx的全局指针来模仿这一点(结构和指针定义在前面的代码块中):

    /* 2\. Allocate memory for and initialize our 'context' structure */
    ctx = kzalloc(sizeof(struct myctx), GFP_KERNEL);
    if (!ctx)
        goto out_fail2;
    pr_info("%s: context struct alloc'ed and initialized (actual KVA ret = %px)\n",
        OURMODNAME, ctx);
    print_hex_dump_bytes("ctx: ", DUMP_PREFIX_OFFSET, ctx, 32);

    return 0;        /* success */
out_fail2:
    kfree(gkptr);
out_fail1:
    return -ENOMEM;
}

在数据结构之后,我们通过有用的kzalloc()包装 API 为ctx分配并初始化了myctx数据结构的大小。随后的hexdump将显示它确实被初始化为全零(为了可读性,我们只会“转储”前 32 个字节)。

请注意我们如何使用goto处理错误路径;这在本书的前面已经提到过几次,所以我们不会在这里重复了。最后,在内核模块的清理代码中,我们使用kfree()释放了两个缓冲区,防止内存泄漏:

static void __exit slab1_exit(void)
{
    kfree(ctx);
 kfree(gkptr);
    pr_info("%s: freed slab memory, removed\n", OURMODNAME);
}

接下来是我在我的树莓派 4 上运行的一个示例截图。我使用我们的../../lkm便捷脚本来构建、加载和执行dmesg

图 8.8-我们的 slab1.ko 内核模块在树莓派 4 上运行的部分截图

好了,现在您已经掌握了使用常见板块分配器 APIkmalloc()kzalloc()kfree()的基础知识,让我们继续。在下一节中,我们将深入探讨一个非常关键的问题-在通过板块(和页面)分配器获取的内存上的大小限制的现实。继续阅读!

kmalloc API 的大小限制

页面和板块分配器的一个关键优势是,它们在分配时提供的内存块不仅在逻辑上是连续的(显而易见),而且还保证是物理上连续的内存。这是一件大事,肯定会提高性能。

但是(总会有但是,不是吗!),正因为有了这个保证,所以在执行分配时不可能提供任意的大小。换句话说,您可以通过一次对我们亲爱的k[m|z]alloc()API 的调用从板块分配器获取的内存量是有明确限制的。这个限制是多少?(这确实是一个经常被问到的问题。)

首先,您应该了解,从技术上讲,限制由两个因素决定:

  • 系统页面大小(由PAGE_SIZE宏确定)

  • 第二,"orders"的数量(由MAX_ORDER宏确定);也就是说,在页面分配器(或 BSA)空闲列表数据结构中的列表数量(见图 8.2)

使用标准的 4 KB 页面大小和MAX_ORDER值为 11,可以使用单个kmalloc()kzalloc()API 调用分配的最大内存量为 4 MB。这在 x86_64 和 ARM 架构上都是如此。

您可能会想知道,这个 4 MB 的限制到底是如何得出的?想一想:一旦 slab 分配请求超过内核提供的最大 slab 缓存大小(通常为 8 KB),内核就会简单地将请求传递给页面分配器。页面分配器的最大可分配大小由MAX_ORDER确定。将其设置为11,最大可分配的缓冲区大小为2^((MAX_ORDER-1)) = 2¹⁰页 = 1024 页 = 1024 * 4K = 4 MB

测试极限 - 一次性内存分配

对于开发人员(以及其他所有人来说),一个非常关键的事情是要有实证精神!英语单词empirical的意思是基于所经历或所见,而不是基于理论。这是一个始终要遵循的关键规则 - 不要简单地假设事情或接受它们的表面价值。自己尝试一下,看看。

让我们做一些非常有趣的事情:编写一个内核模块,从(通用)slab 缓存中分配内存(当然是通过kmalloc()API)。我们将在循环中这样做,每次迭代分配 - 和释放 - 一个(计算出的)数量。这里的关键点是,我们将不断增加给定“步长”大小的分配量。当kmalloc()失败时,循环终止;这样,我们可以测试通过单个kmalloc()调用实际上可以分配多少内存(当然,您会意识到,kzalloc()作为kmalloc()的简单包装,面临着完全相同的限制)。

在下面的代码片段中,我们展示了相关代码。test_maxallocsz()函数从内核模块的init代码中调用:

// ch8/slab3_maxsize/slab3_maxsize.c
[...]
static int stepsz = 200000;
module_param(stepsz, int, 0644);
MODULE_PARM_DESC(stepsz,
"Amount to increase allocation by on each loop iteration (default=200000");

static int test_maxallocsz(void)
{
  size_t size2alloc = 0;
  void *p;

  while (1) {
      p = kmalloc(size2alloc, GFP_KERNEL);
      if (!p) {
          pr_alert("kmalloc fail, size2alloc=%zu\n", size2alloc);
          return -ENOMEM;
      }
      pr_info("kmalloc(%7zu) = 0x%pK\n", size2alloc, p);
      kfree(p);
 size2alloc += stepsz;
  }
  return 0;
}

顺便说一下,注意我们的printk()函数如何使用%zu格式说明符来表示size_t(本质上是一个无符号整数)变量?%zu是一个可移植性辅助工具;它使变量格式对 32 位和 64 位系统都是正确的!

让我们在我们的树莓派设备上构建(在主机上进行交叉编译)并插入这个内核模块,该设备运行我们自定义构建的 5.4.51-v7+内核;几乎立即,在insmod(8)时,您将看到一个错误消息,insmod进程打印出Cannot allocate memory;下面(截断的)截图显示了这一点:

图 8.9 - 在树莓派 3 上运行自定义 5.4.51 内核的 slab3_maxsize.ko 内核模块的第一个 insmod(8)

这是预期的!想一想,我们的内核模块代码的init函数确实在最后失败了,出现了ENOMEM。不要被这个扔出去;查看内核日志会揭示实际发生了什么。事实上,在这个内核模块的第一次测试运行中,您会发现在kmalloc()失败的地方,内核会转储一些诊断信息,包括相当长的内核堆栈跟踪。这是因为它调用了一个WARN()宏。

所以,我们的 slab 内存分配工作了,直到某个点。要清楚地看到失败点,只需在内核日志(dmesg)显示中向下滚动。以下截图显示了这一点:

图 8.10 - 部分截图显示了在树莓派 3 上运行我们的 slab3_maxsize.ko 内核模块的 dmesg 输出的下部分

啊哈,看一下输出的最后一行(图 8.11):kmalloc()在分配超过 4 MB(在 4,200,000 字节处)时失败,正如预期的那样;在那之前,它成功了。

有趣的是,注意我们故意在循环中的第一次分配中使用了大小为0;它没有失败:

  • kmalloc(0, GFP_xxx);返回零指针;在 x86[_64]上,它的值是160x10(详细信息请参阅include/linux/slab.h)。实际上,它是一个无效的虚拟地址,位于页面0NULL指针陷阱。当然,访问它将导致页面错误(源自 MMU)。

  • 同样地,尝试kfree(NULL);kfree()零指针的结果是kfree()变成了一个无操作。

等等,一个非常重要的要点要注意:在用于 kmalloc 的实际 slab 缓存部分,我们看到用于向调用者分配内存的 slab 缓存是kmalloc-nslab 缓存,其中n的范围是648192字节(在树莓派上,因此对于本讨论是 ARM)。另外,FYI,您可以执行sudo vmstat -m | grep -v "\-rcl\-" | grep --color=auto "^kmalloc"来验证这一点。

但显然,在前面的内核模块代码示例中,我们通过kmalloc()分配了更大数量的内存(从 0 字节到 4 MB)。它真正的工作方式是kmalloc()API 仅对小于或等于 8192 字节的内存分配使用kmalloc-'n'slab 缓存(如果可用);任何对更大内存块的分配请求都会传递给底层的页面(或伙伴系统)分配器!现在,回想一下我们在上一章学到的:页面分配器使用伙伴系统空闲列表(基于每个节点:区域在空闲列表上排队的内存块的最大尺寸为2^((MAX_ORDER-1)) = 2¹⁰ ,当然,这是 4 MB(给定页面大小为 4 KB 和MAX_ORDER11)。这与我们的理论讨论完美地结合在一起。

因此,从理论上和实践上来看,你现在可以看到(再次给定 4 KB 的页面大小和MAX_ORDER11),通过单次调用kmalloc()(或kzalloc())分配的内存的最大尺寸是 4 MB。

通过/proc/buddyinfo 伪文件检查

非常重要的是要意识到,尽管我们已经确定一次最多可以获得 4 MB 的 RAM,但这绝对不意味着你总是会得到那么多。不,当然不是。这完全取决于内存请求时特定空闲列表中的空闲内存量。想想看:如果你在运行了几天(或几周)的 Linux 系统上运行。找到物理上连续的 4 MB 的空闲 RAM 块的可能性是相当低的(再次取决于系统上的 RAM 量和其工作负载)。

作为一个经验法则,如果前面的实验没有产生我们认为的最大尺寸的最大分配(即 4 MB),为什么不在一个新启动的客户系统上尝试呢?现在,有物理上连续的 4 MB 的空闲 RAM 的机会要好得多。对此不确定?让我们再次进行实证研究,并查看/proc/buddyinfo的内容-在使用中和新启动的系统上-以确定内存块是否可用。在我们使用中的 x86_64 Ubuntu 客户系统上,只有 1 GB 的 RAM,我们查看到:

$ cat /proc/buddyinfo 
Node 0, zone      DMA    225  154   46   30   14   9   1   1   0   0   0 
Node 0, zone    DMA32    314  861  326  291  138  50  27   2   5   0   0 
  order --->               0    1    2    3    4   5   6   7   8   9  10

正如我们之前学到的(在空闲列表组织部分),在前面的代码块中看到的数字是顺序0MAX_ORDER-1(通常是011-1=10),它们代表该顺序中的2^(order)连续空闲页框的数量。

在前面的输出中,我们可以看到我们在10列表(即 4 MB 块)上没有空闲块(为零)。在一个新启动的 Linux 系统上,可能性很高。在接下来的输出中,在刚刚重新启动的相同系统上,我们看到在节点0,DMA32 区域有 7 个空闲的物理连续的 4 MB RAM 块可用:

$ cat /proc/buddyinfo 
Node 0, zone      DMA      10   2    2    3   3   3   3   2   2   0   0 
Node 0, zone    DMA32     276 143  349  189  99   3   6   3   6   4   7 
 order --->                0   1    2    3   4   5   6   7   8   9  10

重申这一点,在一个刚刚运行了大约半小时的树莓派上,我们有以下情况:

rpi ~/ $ cat /proc/buddyinfo 
Node 0, zone   Normal    82   32   11   6   5   3   3   3   4   4   160

在这里,有 160 个 4 MB 的物理连续 RAM 块可用(空闲)。

当然,还有更多可以探索的。在接下来的部分中,我们将介绍更多关于使用板块分配器的内容 - 资源管理的 API 替代方案,可用的额外板块辅助 API,以及现代 Linux 内核中的 cgroups 和内存的注意事项。

板块分配器 - 一些额外的细节

还有一些关键点需要探讨。首先,关于使用内核的资源管理版本的内存分配 API 的一些信息,然后是内核内部的一些额外可用的板块辅助例程,然后简要介绍 cgroups 和内存。我们强烈建议您也阅读这些部分。请继续阅读!

使用内核的资源管理内存分配 API

对于设备驱动程序来说,内核提供了一些受管理的内存分配 API。这些正式称为设备资源管理或 devres API(关于此的内核文档链接是www.kernel.org/doc/Documentation/driver-model/devres.txt)。它们都以devm_为前缀;虽然有几个,但我们在这里只关注一个常见用例 - 即在使用这些 API 替代通常的k[m|z]alloc()时。它们如下:

  • void * devm_kmalloc(struct device *dev, size_t size, gfp_t gfp);

  • void * devm_kzalloc(struct device *dev, size_t size, gfp_t gfp);

这些资源管理的 API 之所以有用,是因为开发人员无需显式释放它们分配的内存。内核资源管理框架保证它将在驱动程序分离时或者如果是内核模块时,在模块被移除时(或设备被分离时,以先发生者为准)自动释放内存缓冲区。这个特性立即增强了代码的健壮性。为什么?简单,我们都是人,都会犯错误。泄漏内存(尤其是在错误代码路径上)确实是一个相当常见的错误!

关于使用这些 API 的一些相关要点:

  • 一个关键点 - 请不要盲目尝试用相应的devm_k[m|z]alloc()替换k[m|z]alloc()!这些受资源管理的分配实际上只设计用于设备驱动程序的init和/或probe()方法(所有与内核统一设备模型一起工作的驱动程序通常会提供probe()remove()(或disconnect())方法。我们将不在这里深入讨论这些方面)。

  • devm_kzalloc()通常更受欢迎,因为它也初始化缓冲区。在内部(与kzalloc()一样),它只是devm_kmalloc() API 的一个薄包装器。

  • 第二个和第三个参数与k[m|z]alloc() API 一样 - 要分配的字节数和要使用的 GFP 标志。不过,第一个参数是指向struct device的指针。显然,它代表您的驱动程序正在驱动的设备

  • 由这些 API 分配的内存是自动释放的(在驱动程序分离或模块移除时),您不必做任何事情。但是,它可以通过devm_kfree() API 释放。不过,您这样做通常表明受管理的 API 不是正确的选择...

  • 许可:受管理的 API 仅对在 GPL 下许可的模块(以及其他可能的许可)可用。

额外的板块辅助 API

还有几个辅助的板块分配器 API,是k[m|z]alloc() API 家族的朋友。这些包括用于为数组分配内存的kcalloc()kmalloc_array() API,以及krealloc(),其行为类似于熟悉的用户空间 APIrealloc(3)

与为元素数组分配内存一起,array_size()struct_size()内核辅助程序非常有帮助。特别是,struct_size()已被广泛用于防止(实际上修复)在分配结构数组时的许多整数溢出(以及相关)错误,这确实是一个常见的任务。作为一个快速的例子,这里是来自net/bluetooth/mgmt.c的一个小代码片段:

rp = kmalloc(struct_size(rp, addr, i), GFP_KERNEL);
 if (!rp) {
     err = -ENOMEM; [...]

值得浏览一下include/linux/overflow.h内核头文件。

kzfree()类似于kfree(),但会清零(可能更大的)被释放的内存区域。(为什么更大?这将在下一节中解释。)请注意,这被认为是一种安全措施,但可能会影响性能。

这些 API 的资源管理版本也是可用的:devm_kcalloc()devm_kmalloc_array()

控制组和内存

Linux 内核支持一个非常复杂的资源管理系统,称为cgroups(控制组),简而言之,它们用于分层组织进程并执行资源管理(有关 cgroups 的更多信息,以及 cgroups v2 CPU 控制器用法示例,可以在第十一章中找到,CPU 调度器-第二部分,关于 CPU 调度)。

在几个资源控制器中,有一个用于内存带宽的控制器。通过仔细配置它,系统管理员可以有效地调节系统上内存的分配。内存保护是可能的,既可以作为(所谓的)硬保护,也可以通过某些memcg(内存 cgroup)伪文件(特别是memory.minmemory.low文件)作为尽力保护。类似地,在 cgroup 内,memory.highmemory.max伪文件是控制 cgroup 内存使用的主要机制。当然,这里提到的远不止这些,我建议你查阅有关新 cgroups(v2)的内核文档:www.kernel.org/doc/html/latest/admin-guide/cgroup-v2.html

好的,现在你已经学会了如何更好地使用 slab 分配器 API,让我们再深入一点。事实是,关于 slab 分配器 API 分配的内存块大小仍然有一些重要的注意事项。继续阅读以了解它们是什么!

使用 slab 分配器时的注意事项

我们将把这个讨论分成三部分。我们将首先重新审视一些必要的背景(我们之前已经涵盖了),然后实际上详细说明两个用例的问题-第一个非常简单,第二个是问题的更真实的案例。

背景细节和结论

到目前为止,你已经学到了一些关键点:

  • 页面(或buddy 系统分配器向调用者分配 2 的幂次方页。要提高 2 的幂次方,称为;它通常范围从010(在 x86[_64]和 ARM 上都是如此)。

  • 这很好,除非不是。当请求的内存量非常小时,浪费(或内部碎片)可能会很大。

  • 对于页面的片段请求(小于 4,096 字节)非常常见。因此,slab 分配器,叠加在页面分配器上(见图 8.1)被设计为具有对象缓存,以及小的通用内存缓存,以有效地满足对小内存量的请求。

  • 页面分配器保证物理上连续的页面和高速缓存对齐的内存。

  • slab 分配器保证物理上连续和高速缓存对齐的内存。

因此,很棒-这让我们得出结论,当需要的内存量较大且接近 2 的幂时,请使用页面分配器。当内存量相当小(小于一页)时,请使用 slab 分配器。事实上,kmalloc()的内核源代码中有一条注释,简洁地总结了应该如何使用kmalloc() API(如下所示以粗体字重现):

// include/linux/slab.h
[...]
 * kmalloc - allocate memory
 * @size: how many bytes of memory are required.
 * @flags: the type of memory to allocate.
 * kmalloc is the normal method of allocating memory
 * for objects smaller than page size in the kernel.

听起来很棒,但还有一个问题!为了看到它,让我们学习如何使用另一个有用的 slab API,ksize()。它的签名如下:

size_t ksize(const void *);

ksize()的参数是指向现有 slab 缓存的指针(它必须是有效的)。换句话说,它是 slab 分配器 API 的返回地址(通常是k[m|z]alloc())。返回值是分配的实际字节数。

好的,现在你知道ksize()的用途,让我们首先以一种更实际的方式使用它,然后再用一个更好的方式!

使用 ksize()测试 slab 分配 - 情况 1

为了理解我们的意思,考虑一个小例子(为了可读性,我们不会显示必要的有效性检查。此外,由于这是一个小的代码片段,我们没有将其提供为书中代码库中的内核模块):

struct mysmallctx {
    int tx, rx;
    char passwd[8], config[4];
} *ctx;

pr_info("sizeof struct mysmallctx = %zd bytes\n", sizeof(struct mysmallctx));
ctx = kzalloc(sizeof(struct mysmallctx), GFP_KERNEL);
pr_info("(context structure allocated and initialized to zero)\n"
        "*actual* size allocated = %zu bytes\n", ksize(ctx));

在我的 x86_64 Ubuntu 虚拟机系统上的结果输出如下:

$ dmesg
[...]
sizeof struct mysmallctx = 20 bytes
(context structure allocated and initialized to zero)
*actual* size allocated = 32 bytes

因此,我们尝试使用kzalloc()分配 20 字节,但实际上获得了 32 字节(因此浪费了 12 字节,或 60%!)。这是预期的。回想一下kmalloc-n slab 缓存 - 在 x86 上,有一个用于 16 字节的缓存,另一个用于 32 字节(还有许多其他)。因此,当我们要求介于两者之间的数量时,显然会从两者中较大的一个获取内存。(顺便说一句,在我们基于 ARM 的树莓派系统上,kmalloc的最小 slab 缓存是 64 字节,因此当我们要求 20 字节时,我们当然会得到 64 字节。)

请注意,ksize() API 仅适用于已分配的 slab 内存;您不能将其用于任何页分配器 API 的返回值(我们在理解和使用内核页分配器(或 BSA)部分中看到)。

现在是第二个更有趣的用例。

使用 ksize()测试 slab 分配 - 情况 2

好的,现在,让我们扩展我们之前的内核模块(ch8/slab3_maxsize)到ch8/slab4_actualsize。在这里,我们将执行相同的循环,使用kmalloc()分配内存并像以前一样释放它,但这一次,我们还将通过调用ksize()API 记录由 slab 层在每个循环迭代中分配给我们的实际内存量:

// ch8/slab4_actualsize/slab4_actualsize.c
static int test_maxallocsz(void)
{
    size_t size2alloc = 100, actual_alloced;
    void *p;

    pr_info("kmalloc(      n) :  Actual : Wastage : Waste %%\n");
    while (1) {
        p = kmalloc(size2alloc, GFP_KERNEL);
        if (!p) {
            pr_alert("kmalloc fail, size2alloc=%zu\n", size2alloc);
            return -ENOMEM;
        }
        actual_alloced = ksize(p);
        /* Print the size2alloc, the amount actually allocated,
         * the delta between the two, and the percentage of waste
         * (integer arithmetic, of course :-)  */
        pr_info("kmalloc(%7zu) : %7zu : %7zu : %3zu%%\n",
              size2alloc, actual_alloced, (actual_alloced-size2alloc),
              (((actual_alloced-size2alloc)*100)/size2alloc));        kfree(p);
        size2alloc += stepsz;
    }
    return 0;
}

这个内核模块的输出确实很有趣!在下图中,我们展示了我在运行我们自定义构建的 5.4.0 内核的 x86_64 Ubuntu 18.04 LTS 虚拟机上获得的输出的部分截图:

图 8.11 - slab4_actualsize.ko 内核模块的部分截图

内核模块的printk输出可以在前面的截图中清楚地看到。屏幕的其余部分是内核的诊断信息 - 这是因为内核空间内存分配请求失败而发出的。所有这些内核诊断信息都是由内核调用WARN_ONCE()宏的第一次调用产生的,因为底层页分配器代码mm/page_alloc.c:__alloc_pages_nodemask() - 众所周知的伙伴系统分配器的“核心” - 失败了!这通常不应该发生,因此有诊断信息(内核诊断的详细信息超出了本书的范围,因此我们将不予讨论。话虽如此,我们在接下来的章节中确实会在一定程度上检查内核堆栈回溯)。

解释情况 2 的输出

仔细看前面的截图(图 8.12;在这里,我们将简单地忽略由WARN()宏发出的内核诊断,因为内核级内存分配失败而调用了它!)。图 8.12 的输出有五列,如下:

  • 来自dmesg(1)的时间戳;我们忽略它。

  • kmalloc(n)kmalloc()请求的字节数(其中n是所需的数量)。

  • 由 slab 分配器分配的实际字节数(通过ksize()揭示)。

  • 浪费(字节):实际字节和所需字节之间的差异。

  • 浪费的百分比。

例如,在第二次分配中,我们请求了 200,100 字节,但实际获得了 262,144 字节(256 KB)。这是有道理的,因为这是伙伴系统空闲列表中的一个页面分配器列表的确切大小(它是6 阶,因为2⁶ = 64 页 = 64 x 4 = 256 KB;参见图 8.2)。因此,差值,或者实际上是浪费,是262,144 - 200,100 = 62,044 字节,以百分比表示,为 31%。

就像这样:请求的(或所需的)大小越接近内核可用的(或实际的)大小,浪费就越少;反之亦然。让我们从前面的输出中再看一个例子(为了清晰起见,以下是剪辑输出):

[...]
[92.273695] kmalloc(1600100) : 2097152 :  497052 : 31%
[92.274337] kmalloc(1800100) : 2097152 :  297052 : 16%
[92.275292] kmalloc(2000100) : 2097152 :   97052 :  4%
[92.276297] kmalloc(2200100) : 4194304 : 1994204 : 90%
[92.277015] kmalloc(2400100) : 4194304 : 1794204 : 74%
[92.277698] kmalloc(2600100) : 4194304 : 1594204 : 61%
[...]

从前面的输出中,您可以看到当kmalloc()请求 1,600,100 字节(大约 1.5 MB)时,实际上获得了 2,097,152 字节(确切的 2 MB),浪费为 31%。随着我们接近分配的“边界”或阈值(内核的 slab 缓存或页面分配器内存块的实际大小),浪费逐渐减少:到 16%,然后降至 4%。但是请注意:在下一个分配中,当我们跨越该阈值,要求略高于2 MB(2,200,100 字节)时,我们实际上获得了 4 MB,浪费了 90%*!然后,随着我们接近 4 MB 的内存大小,浪费再次减少...

这很重要!您可能认为仅通过使用 slab 分配器 API 非常高效,但实际上,当请求的内存量超过 slab 层可以提供的最大大小时(通常为 8 KB,在我们之前的实验中经常出现),slab 层会调用页面分配器。因此,页面分配器由于通常的浪费问题,最终分配的内存远远超过您实际需要的,或者实际上永远不会使用的。多么浪费!

寓言:检查并反复检查使用 slab API 分配内存的代码。使用ksize()对其进行试验,以找出实际分配了多少内存,而不是您认为分配了多少内存。

没有捷径。嗯,有一个:如果您需要的内存少于一页(非常典型的用例),只需使用 slab API。如果需要更多,前面的讨论就会起作用。另一件事:使用alloc_pages_exact() / free_pages_exact() API(在一个解决方案 - 精确页面分配器 API部分中介绍)也应该有助于减少浪费。

绘图

有趣的是,我们使用著名的gnuplot(1)实用程序从先前收集的数据绘制图形。实际上,我们必须最小限度地修改内核模块,只输出我们想要绘制的内容:要分配的内存量(x轴),以及运行时实际发生的浪费百分比(y轴)。您可以在书的 GitHub 存储库中找到我们略微修改的内核模块的代码,链接在这里:ch8/slab4_actualsizegithub.com/PacktPublishing/Linux-Kernel-Programming/tree/master/ch8/slab4_actualsize)。

因此,我们构建并插入这个内核模块,“整理”内核日志,将数据保存在gnuplot所需的适当的列格式中(保存在名为2plotdata.txt的文件中)。虽然我们不打算在这里深入讨论如何使用gnuplot(1)(请参阅进一步阅读部分以获取教程链接),但在以下代码片段中,我们展示了生成图形的基本命令:

gnuplot> set title "Slab/Page Allocator: Requested vs Actually allocated size Wastage in Percent"
gnuplot> set xlabel "Required size"
gnuplot> set ylabel "%age Waste"
gnuplot> plot "2plotdata.txt" using 1:100 title "Required Size" with points, "2plotdata.txt" title "Wastage %age" with linespoints 
gnuplot> 

看哪,图:

图 8.12 - 显示 kmalloc()请求的大小(x 轴)与产生的浪费(作为百分比;y 轴)的图形

这个“锯齿”形状的图表有助于可视化您刚刚学到的内容。一个kmalloc()(或kzalloc(),或者任何页面分配器 API)分配请求的大小越接近内核预定义的空闲列表大小,浪费就越少。但一旦超过这个阈值,浪费就会飙升(尖峰),接近 100%(如前图中的垂直线所示)。

因此,我们已经涵盖了大量的内容。然而,我们还没有完成:下一节非常简要地介绍了内核中实际的 slab 层实现(是的,有几种)。让我们来看看吧!

内核中的 Slab 层实现

最后,我们提到了一个事实,即至少有三种不同的互斥的内核级 slab 分配器实现;在运行时只能使用其中一种。在配置内核时选择在运行时使用的分配器(您在第二章中详细了解了此过程,从源代码构建 5.x Linux 内核-第一部分)。相关的内核配置选项如下:

  • CONFIG_SLAB

  • CONFIG_SLUB

  • CONFIG_SLOB

第一个(SLAB)是早期的、得到很好支持(但相当未优化)的分配器;第二个(SLUB,未排队的分配器)在内存效率、性能和诊断方面是对第一个的重大改进,并且是默认选择的分配器。SLOB分配器是一种极端简化,根据内核配置帮助,“在大型系统上表现不佳”。

摘要

在本章中,您详细了解了页面(或伙伴系统)和 slab 分配器的工作原理。请记住,内核内部分配(和释放)RAM 的实际“引擎”最终是页面(或伙伴系统)分配器,slab 分配器则在其上层提供了对典型小于页面大小的分配请求的优化,并有效地分配了几种众所周知的内核数据结构(“对象”)。

您学会了如何有效地使用页面和 slab 分配器提供的 API,以及几个演示内核模块,以便以实际操作的方式展示这一点。我们非常正确地关注了开发人员发出对某个N字节数的内存请求的实际问题,但您学会了这可能是非常次优的,因为内核实际上分配了更多的内存(浪费可能接近 100%)!现在您知道如何检查和减轻这些情况。干得好!

以下章节涵盖了更多关于最佳分配策略的内容,以及有关内核内存分配的一些更高级主题,包括创建自定义 slab 缓存,使用vmalloc接口,以及OOM killer的相关内容等。因此,首先确保您已经理解了本章的内容,并且已经完成了内核模块和作业(如下所示)。然后,让我们继续下一章吧!

问题

随着我们的结束,这里有一些问题供您测试对本章材料的了解:github.com/PacktPublishing/Linux-Kernel-Programming/tree/master/questions。您会在书的 GitHub 存储库中找到一些问题的答案:github.com/PacktPublishing/Linux-Kernel-Programming/tree/master/solutions_to_assgn

进一步阅读

为了帮助您深入了解这个主题并提供有用的材料,我们在本书的 GitHub 存储库中提供了一个相当详细的在线参考和链接列表(有时甚至包括书籍)。进一步阅读文档在这里可用:github.com/PacktPublishing/Linux-Kernel-Programming/raw/master/Further_Reading.md

第九章:模块作者的内核内存分配-第二部分

上一章详细介绍了通过内核中的页面(BSA)和 slab 分配器进行内存分配的可用 API 的基础知识(以及更多!)。在本章中,我们将进一步深入探讨这个广泛而有趣的主题。我们将涵盖创建自定义 slab 缓存、vmalloc接口,以及非常重要的是,鉴于选择的丰富性,应在哪种情况下使用哪些 API。关于令人恐惧的内存不足OOM)杀手和需求分页的内部内核细节有助于完善这些重要主题。

这些领域往往是在处理内核模块时理解的关键方面之一,特别是设备驱动程序。一个 Linux 系统项目突然崩溃,控制台上只有一个Killed消息,需要一些解释,对吧!?OOM 杀手就是背后的甜蜜家伙...

简而言之,在本章中,主要涵盖了以下主要领域:

  • 创建自定义 slab 缓存

  • 在 slab 层进行调试

  • 理解和使用内核 vmalloc()API

  • 内核中的内存分配-何时使用哪些 API

  • 保持存活- OOM 杀手

技术要求

我假设您已经阅读了第一章,内核工作空间设置,并已经适当地准备了一个运行 Ubuntu 18.04 LTS(或更高稳定版本)的虚拟机,并安装了所有必需的软件包。如果没有,我强烈建议您首先这样做。

此外,本章的最后一节让您故意运行一个非常占用内存的应用程序;如此占用内存以至于内核将采取一些极端的行动!显然,我强烈建议您在一个安全的、隔离的系统上尝试这样的东西,最好是一个 Linux 测试虚拟机(上面没有重要数据)。

为了充分利用本书,我强烈建议您首先设置工作空间

环境,包括克隆本书的 GitHub 存储库以获取代码,并以实际操作的方式进行工作。GitHub 存储库可以在github.com/PacktPublishing/Linux-Kernel-Programming找到。

创建自定义 slab 缓存

如前一章节中详细解释的,slab 缓存背后的关键设计概念是对象缓存的强大理念。通过缓存频繁使用的对象-实际上是数据结构-性能得到提升。因此,想象一下:如果我们正在编写一个驱动程序,在该驱动程序中,某个数据结构(对象)被非常频繁地分配和释放?通常,我们会使用通常的kzalloc()(或kmalloc())然后是kfree()API 来分配和释放这个对象。不过好消息是:Linux 内核充分地向我们模块作者公开了 slab 层 API,允许我们创建我们自己的自定义 slab 缓存。在本节中,您将学习如何利用这一强大功能。

在内核模块中创建和使用自定义 slab 缓存

在本节中,我们将创建,使用和随后销毁自定义 slab 缓存。在广义上,我们将执行以下步骤:

  1. 使用kmem_cache_create()API 创建给定大小的自定义 slab 缓存。这通常作为内核模块的初始化代码路径的一部分进行(或者在驱动程序中的探测方法中进行)。

  2. 使用 slab 缓存。在这里我们将做以下事情:

  3. 使用kmem_cache_alloc()API 来分配自定义对象的单个实例在您的 slab 缓存中。

  4. 使用对象。

  5. 使用kmem_cache_free()API 将其释放回缓存。

  6. 使用kmem_cache_destroy()在完成后销毁自定义 slab 缓存。这通常作为内核模块的清理代码路径的一部分进行(或者在驱动程序中的删除/分离/断开方法中进行)。

让我们稍微详细地探讨这些 API 中的每一个。我们从创建自定义(slab)缓存开始。

创建自定义 slab 缓存

首先,当然,让我们学习如何创建自定义的 slab 缓存。kmem_cache_create()内核 API 的签名如下:

#include <linux/slab.h>
struct kmem_cache *kmem_cache_create(const char *name, unsigned int size,  
           unsigned int align, slab_flags_t flags, void (*ctor)(void *));

第一个参数是缓存的名称 - 将由proc(因此也由proc上的其他包装工具,如vmstat(8)slabtop(1)等)显示。它通常与被缓存的数据结构或对象的名称匹配(但不一定要匹配)。

第二个参数size实际上是关键的参数-它是新缓存中每个对象的字节大小。基于此对象大小(使用最佳适配算法),内核的 slab 层构造了一个对象缓存。由于三个原因,缓存内每个对象的实际大小将比请求的稍大:

  • 一,我们总是可以提供更多,但绝不会比请求的内存少。

  • 二,需要一些用于元数据(管理信息)的空间。

  • 第三,内核在能够提供所需确切大小的缓存方面存在限制。它使用最接近的可能匹配大小的内存(回想一下第八章,模块作者的内核内存分配-第一部分,在使用 slab 分配器时的注意事项部分,我们清楚地看到实际上可能使用更多(有时是很多!)内存)。

回想一下第八章,模块作者的内核内存分配-第一部分ksize()API 可用于查询分配对象的实际大小。还有另一个 API,我们可以查询新 slab 缓存中个别对象的大小:

unsigned int kmem_cache_size(struct kmem_cache *s);。您很快将看到这个被使用。

第三个参数align是缓存内对象所需的对齐。如果不重要,只需将其传递为0。然而,通常有非常特定的对齐要求,例如,确保对象对齐到机器上的字大小(32 位或 64 位)。为此,将值传递为sizeof(long)(此参数的单位是字节,而不是位)。

第四个参数flags可以是0(表示没有特殊行为),也可以是以下标志值的按位或运算符。为了清晰起见,我们直接从源文件mm/slab_common.c的注释中复制以下标志的信息:

// mm/slab_common.c
[...]
 * The flags are
 *
 * %SLAB_POISON - Poison the slab with a known test pattern (a5a5a5a5)
 * to catch references to uninitialized memory.
 *
 * %SLAB_RED_ZONE - Insert `Red` zones around the allocated memory to check
 * for buffer overruns.
 *
 * %SLAB_HWCACHE_ALIGN - Align the objects in this cache to a hardware
 * cacheline. This can be beneficial if you're counting cycles as closely
 * as davem.
[...]

让我们快速检查一下标志:

  • 第一个标志SLAB_POISON提供了 slab 毒化,即将缓存内存初始化为先前已知的值(0xa5a5a5a5)。这样做可以在调试情况下有所帮助。

  • 第二个标志SLAB_RED_ZONE很有趣,它在分配的缓冲区周围插入红色区域(类似于保护页面)。这是检查缓冲区溢出错误的常见方法。它几乎总是在调试环境中使用(通常在开发过程中)。

  • 第三个可能的标志SLAB_HWCACHE_ALIGN非常常用,实际上也是性能推荐的。它保证所有缓存对象都对齐到硬件(CPU)缓存行大小。这正是通过流行的k[m|z]alloc()API 分配的内存如何对齐到硬件(CPU)缓存行的。

最后,kmem_cache_create()的第五个参数也非常有趣:一个函数指针,void (*ctor)(void *);。它被建模为一个构造函数(就像面向对象和 OOP 语言中的构造函数)。它方便地允许您在分配时从自定义 slab 缓存初始化 slab 对象!作为内核中此功能的一个示例,请参阅名为integrityLinux 安全模块LSM)的代码:

 security/integrity/iint.c:integrity_iintcache_init()

它调用以下内容:

iint_cache = kmem_cache_create("iint_cache", sizeof(struct integrity_iint_cache),
 0, SLAB_PANIC, init_once);

init_once()函数初始化了刚刚分配的缓存对象实例。请记住,构造函数在此缓存分配新页面时被调用。

尽管这似乎有些违直觉,但事实是现代 Linux 内核在设计方面相当面向对象。当然,代码大多是传统的过程式语言 C。然而,在内核中有大量的架构实现(驱动程序模型是其中之一)在设计上是面向对象的:通过虚拟函数指针表进行方法分派 - 策略设计模式等。在 LWN 上有一篇关于此的两部分文章,详细介绍了这一点:内核中的面向对象设计模式,第一部分,2011 年 6 月lwn.net/Articles/444910/)。

kmem_cache_create() API 的返回值在成功时是指向新创建的自定义 slab 缓存的指针,失败时是NULL。通常会将此指针保持为全局,因为您将需要访问它以实际从中分配对象(我们的下一步)。

重要的是要理解kmem_cache_create() API 只能从进程上下文中调用。许多内核代码(包括许多驱动程序)创建并使用自己的自定义 slab 缓存。例如,在 5.4.0 Linux 内核中,有超过 350 个实例调用了此 API。

好了,现在您有了一个自定义(slab)缓存,那么您究竟如何使用它来分配内存对象呢?接下来的部分将详细介绍这一点。

使用新的 slab 缓存的内存

好吧,我们创建了一个自定义的 slab 缓存。要使用它,您必须发出kmem_cache_alloc() API。它的作用是:给定一个 slab 缓存的指针(您刚刚创建的),它在该 slab 缓存上分配一个对象的单个实例(实际上,这确实是k[m|z]alloc() API 在底层是如何工作的)。它的签名如下(当然,记得始终为所有基于 slab 的 API 包含<linux/slab.h>头文件):

void *kmem_cache_alloc(struct kmem_cache *s, gfp_t gfpflags);

让我们看看它的参数:

  • kmem_cache_alloc()的第一个参数是指向我们在上一步中创建的(自定义)缓存的指针(从kmem_cache_create() API 的返回值)。

  • 第二个参数是要传递的通常的 GFP 标志(记住基本规则:对于正常的进程上下文分配,请使用GFP_KERNEL,否则如果处于任何类型的原子或中断上下文中,请使用GFP_ATOMIC)。

与现在熟悉的k[m|z]alloc() API 一样,返回值是指向新分配的内存块的指针 - 内核逻辑地址(当然是 KVA)。

使用新分配的内存对象,并在完成后,不要忘记使用以下方法释放它:

void kmem_cache_free(struct kmem_cache *, void *);

在这里,关于kmem_cache_free() API,请注意以下内容:

  • kmem_cache_free()的第一个参数再次是指向您在上一步中创建的(自定义)slab 缓存的指针(从kmem_cache_create()的返回值)。

  • 第二个参数是指向您希望释放的内存对象的指针 - 刚刚使用kmem_cache_alloc()分配的对象实例 - 因此,它将返回到由第一个参数指定的缓存!

k[z]free() API 类似,没有返回值。

销毁自定义缓存

当完全完成时(通常在内核模块的清理或退出代码路径中,或者您的驱动程序的remove方法中),您必须销毁先前创建的自定义 slab 缓存,使用以下行:

void kmem_cache_destroy(struct kmem_cache *);

参数当然是指向您在上一步中创建的(自定义)缓存的指针(从kmem_cache_create() API 的返回值)。

现在您已经了解了该过程及其相关的 API,让我们来使用一个创建自己的自定义 slab 缓存的内核模块,并在完成后销毁它。

自定义 slab - 演示内核模块

是时候动手写一些代码了!让我们看一个简单的演示,使用前面的 API 来创建我们自己的自定义 slab 缓存。像往常一样,我们这里只显示相关的代码。我建议您克隆本书的 GitHub 存储库并自己尝试一下!您可以在ch9/slab_custom/slab_custom.c中找到此文件的代码。

在我们的初始化代码路径中,我们首先调用以下函数来创建我们的自定义 slab 缓存:

// ch9/slab_custom/slab_custom.c
#define OURCACHENAME   "our_ctx"
/* Our 'demo' structure, that (we imagine) is often allocated and freed;
 * hence, we create a custom slab cache to hold pre-allocated 'instances'
 * of it... Its size: 328 bytes.
 */
struct myctx {
    u32 iarr[10];
    u64 uarr[10];
    char uname[128], passwd[16], config[64];
};
static struct kmem_cache *gctx_cachep; 

在上述代码中,我们声明了一个(全局)指针(gctx_cachep)指向即将创建的自定义 slab 缓存 - 它将保存对象;即我们虚构的经常分配的数据结构myctx

接下来,看看创建自定义 slab 缓存的代码:

static int create_our_cache(void)
{
    int ret = 0;
    void *ctor_fn = NULL;

    if (use_ctor == 1)
        ctor_fn = our_ctor;
    pr_info("sizeof our ctx structure is %zu bytes\n"
            " using custom constructor routine? %s\n",
            sizeof(struct myctx), use_ctor==1?"yes":"no");

  /* Create a new slab cache:
   * kmem_cache_create(const char *name, unsigned int size, unsigned int 
      align, slab_flags_t flags, void (*ctor)(void *));  */
    gctx_cachep = kmem_cache_create(OURCACHENAME, // name of our cache
          sizeof(struct myctx), // (min) size of each object
          sizeof(long),         // alignment
          SLAB_POISON |         /* use slab poison values (explained soon) */
          SLAB_RED_ZONE |       /* good for catching buffer under|over-flow bugs */
          SLAB_HWCACHE_ALIGN,   /* good for performance */
          ctor_fn);             // ctor: here, on by default

  if (!gctx_cachep) {
        [...]
        if (IS_ERR(gctx_cachep))
            ret = PTR_ERR(gctx_cachep);
  }
  return ret;
}

嘿,这很有趣:注意我们的缓存创建 API 提供了一个构造函数来帮助初始化任何新分配的对象;在这里:

/* The parameter is the pointer to the just allocated memory 'object' from
 * our custom slab cache; here, this is our 'constructor' routine; so, we
 * initialize our just allocated memory object.
 */
static void our_ctor(void *new)
{
    struct myctx *ctx = new;
    struct task_struct *p = current;

    /* TIP: to see how exactly we got here, insert this call:
     *  dump_stack();
     * (read it bottom-up ignoring call frames that begin with '?') */
    pr_info("in ctor: just alloced mem object is @ 0x%llx\n", ctx);

    memset(ctx, 0, sizeof(struct myctx));
    /* As a demo, we init the 'config' field of our structure to some
     * (arbitrary) 'accounting' values from our task_struct
     */
    snprintf(ctx->config, 6*sizeof(u64)+5, "%d.%d,%ld.%ld,%ld,%ld",
            p->tgid, p->pid,
            p->nvcsw, p->nivcsw, p->min_flt, p->maj_flt);
}

上述代码中的注释是不言自明的;请仔细查看。构造函数例程,如果设置(取决于我们use_ctor模块参数的值;默认为1),将在内核每当为我们的缓存分配新内存对象时自动调用。

在初始化代码路径中,我们调用use_our_cache()函数。它通过kmem_cache_alloc()API 分配了我们的myctx对象的一个实例,如果我们的自定义构造函数例程已启用,它会运行,初始化对象。然后我们将其内存转储以显示它确实按照编码进行了初始化,并在完成时释放它(为简洁起见,我们将不显示错误代码路径):

    obj = kmem_cache_alloc(gctx_cachep, GFP_KERNEL);
    pr_info("Our cache object size is %u bytes; ksize=%lu\n",
            kmem_cache_size(gctx_cachep), ksize(obj));
    print_hex_dump_bytes("obj: ", DUMP_PREFIX_OFFSET, obj, sizeof(struct myctx));
 kmem_cache_free(gctx_cachep, obj);

最后,在退出代码路径中,我们销毁我们的自定义 slab 缓存:

kmem_cache_destroy(gctx_cachep);

来自一个样本运行的以下输出帮助我们理解它是如何工作的。以下只是部分截图,显示了我们的 x86_64 Ubuntu 18.04 LTS 客户机上运行 Linux 5.4 内核的输出:

图 9.1 - 在 x86_64 VM 上的 slab_custom 内核模块的输出

太棒了!等一下,这里有几个要注意的关键点:

  • 由于我们的构造函数例程默认启用(我们的use_ctor模块参数的值为1),每当内核 slab 层为我们的新缓存分配新对象实例时,它都会运行。在这里,我们只执行了一个kmem_cache_alloc(),但我们的构造函数例程已经运行了 21 次,这意味着内核的 slab 代码(预)分配了 21 个对象给我们的全新缓存!当然,这个数字会有所变化。

  • 第二,非常重要的一点要注意!如前面的截图所示,每个对象的大小似乎是 328 字节(由sizeof()kmem_cache_size()ksize()显示)。然而,再次强调,这并不是真的!内核分配的对象的实际大小更大;我们可以通过vmstat(8)看到这一点。

$ sudo vmstat -m | head -n1
Cache                       Num  Total  Size  Pages
$ sudo vmstat -m | grep our_ctx
our_ctx                       0     21   768     21
$ 

正如我们之前看到的那样,每个分配的对象的实际大小不是 328 字节,而是 768 字节(确切的数字会有所变化;在一个案例中,我看到它是 448 字节)。这对您来说是很重要的,确实需要检查。我们在接下来的在 slab 层调试部分中展示了另一种相当容易检查这一点的方法。

FYI,您可以随时查看vmstat(8)的 man 页面,以了解先前看到的每一列的确切含义。

我们将用 slab 收缩器接口结束关于创建和使用自定义 slab 缓存的讨论。

理解 slab 收缩器

缓存对性能有利。想象一下从磁盘读取大文件的内容与从 RAM 读取其内容的情况。毫无疑问,基于 RAM 的 I/O 要快得多!可以想象,Linux 内核利用这些想法,因此维护了几个缓存-页面缓存、目录项缓存、索引节点缓存、slab 缓存等等。这些缓存确实极大地提高了性能,但是,仔细想想,实际上并不是强制性要求。当内存压力达到较高水平时(意味着使用的内存过多,可用内存过少),Linux 内核有机制智能地释放缓存(也称为内存回收-这是一个持续进行的过程;内核线程(通常命名为kswapd*)作为其管理任务的一部分回收内存;在回收内存-内核管理任务和OOM*部分中会更多地介绍)。

在 slab 缓存的情况下,事实上是一些内核子系统和驱动程序会像我们在本章前面讨论的那样创建自己的自定义 slab 缓存。为了与内核良好集成并合作,最佳实践要求您的自定义 slab 缓存代码应该注册一个 shrinker 接口。当这样做时,当内存压力足够高时,内核可能会调用多个 slab 收缩器回调,预期通过释放(收缩)slab 对象来缓解内存压力。

与内核注册 shrinker 函数的 API 是register_shrinker()API。它的单个参数(截至 Linux 5.4)是指向shrinker结构的指针。该结构包含(除其他管理成员外)两个回调例程:

  • 第一个例程count_objects()仅计算并返回将要释放的对象的数量(当实际调用时)。如果返回0,这意味着现在无法确定可释放的内存对象的数量,或者我们现在甚至不应该尝试释放任何对象。

  • 第二个例程scan_objects()仅在第一个回调例程返回非零值时调用;当 slab 缓存层调用它时,它实际上释放或收缩了相关的 slab 缓存。它返回在此回收周期中实际释放的对象数量,或者如果回收尝试无法进行(可能会导致死锁)则返回SHRINK_STOP

我们现在将通过快速总结使用此层进行内存(解)分配的利弊来结束对 slab 层的讨论-对于您作为内核/驱动程序作者来说,这是非常重要的,需要敏锐意识到!

slab 分配器-利弊-总结

在本节中,我们非常简要地总结了您现在已经学到的内容。这旨在让您快速查阅和回顾这些关键要点!

使用 slab 分配器(或 slab 缓存)API 来分配和释放内核内存的优点如下:

  • (非常)快速(因为它使用预缓存的内存对象)。

  • 保证物理上连续的内存块。

  • 当创建缓存时使用SLAB_HWCACHE_ALIGN标志时,保证硬件(CPU)缓存行对齐的内存。这适用于kmalloc()kzalloc()等。

  • 您可以为特定(频繁分配/释放)对象创建自定义的 slab 缓存。

使用 slab 分配器(或 slab 缓存)API 的缺点如下:

  • 一次只能分配有限数量的内存;通常,通过 slab 接口直接分配 8 KB,或者通过大多数当前平台上的页面分配器间接分配高达 4 MB 的内存(当然,精确的上限取决于架构)。

  • 使用k[m|z]alloc()API 不正确:请求过多的内存,或者请求一个略高于阈值的内存大小(在第八章中详细讨论,内核内存分配给模块作者-第一部分,在kmalloc API 的大小限制部分),肯定会导致内部碎片(浪费)。它的设计只是真正优化常见情况-分配小于一页大小的内存。

现在,让我们继续讨论另一个对于内核/驱动程序开发人员来说非常关键的方面-当内存分配/释放出现问题时,特别是在 slab 层内部。

在 slab 层调试

内存损坏不幸地是错误的一个非常常见的根本原因。能够调试它们是一个关键的技能。我们现在将看一下一些处理这个问题的方法。在深入细节之前,请记住,以下讨论是关于SLUB(未排队的分配器)实现的 slab 层。这是大多数 Linux 安装的默认设置(我们在第八章中提到,内核内存分配给模块作者-第一部分,内核内存分配给模块作者-第一部分,在内核中的 slab 层实现部分,当前的 Linux 内核有三个互斥的 slab 层实现)。

此外,我们的意图并不是深入讨论关于内存调试的内核调试工具-这本身就是一个庞大的话题,不幸的是超出了本书的范围。尽管如此,我会说你最好熟悉已经提到的强大框架/工具,特别是以下内容:

  • KASAN内核地址消毒剂;从 x86_64 和 AArch64,4.x 内核开始可用)

  • SLUB 调试技术(在这里介绍)

  • kmemleak(尽管 KASAN 更好)

  • kmemcheck(请注意,kmemcheck在 Linux 4.15 中被移除)

不要忘记在进一步阅读部分寻找这些链接。好的,让我们来看看一些有用的方法,帮助开发人员在 slab 层调试代码。

通过 slab 毒害调试

一个非常有用的功能是所谓的 slab 毒害。在这种情况下,“毒害”一词意味着用特定的签名字节或易于识别的模式刺激内存。然而,使用这个的前提是CONFIG_SLUB_DEBUG内核配置选项是开启的。你怎么检查?简单:

$ grep -w CONFIG_SLUB_DEBUG /boot/config-5.4.0-llkd01
CONFIG_SLUB_DEBUG=y

在前面的代码中看到的=y表示它确实是开启的。现在(假设它已经开启),如果你使用SLAB_POISON标志创建一个 slab 缓存(我们在创建自定义 slab 缓存部分中介绍了创建 slab 缓存),那么当内存被分配时,它总是被初始化为特殊值或内存模式0x5a5a5a5a-它被毒害了(这是非常有意义的:十六进制值0x5a是 ASCII 字符Z代表零)!所以,想一想,如果你在内核诊断消息或转储中看到这个值,也称为Oops,那么很有可能这是一个(不幸地相当典型的)未初始化内存错误或UMR(未初始化内存读取)。

为什么在前面的句子中使用也许这个词?嗯,简单地因为调试深藏的错误是一件非常困难的事情!可能出现的症状并不一定是问题的根本原因。因此,不幸的开发人员经常被各种红鲱引入歧途!现实是调试既是一门艺术又是一门科学;对生态系统(这里是 Linux 内核)的深入了解在帮助你有效调试困难情况方面起到了很大作用。

如果未设置SLAB_POISON标志,则未初始化的 slab 内存将设置为0x6b6b6b6b内存模式(十六进制0x6b是 ASCII 字符k(见图 9.2))。同样,当 slab 高速缓存内存被释放并且CONFIG_SLUB_DEBUG打开时,内核将相同的内存模式(0x6b6b6b6b;'k')写入其中。这也非常有用,可以让我们发现(内核认为的)未初始化或空闲内存。

毒值在include/linux/poison.h中定义如下:

/* ...and for poisoning */
#define POISON_INUSE    0x5a    /* for use-uninitialized poisoning */
#define POISON_FREE     0x6b    /* for use-after-free poisoning */
#define POISON_END      0xa5    /* end-byte of poisoning */

关于内核 SLUB 实现的 slab 分配器,让我们来看一下何时(具体情况由以下if部分确定)以及slab 中毒发生的类型的摘要视图,以及以下伪代码中的类型:

if CONFIG_SLUB_DEBUG is enabled
   AND the SLAB_POISON flag is set
   AND there's no custom constructor function
   AND it's type-safe-by-RCU

然后毒化 slab 发生如下:

  • slab 内存在初始化时设置为POISON_INUSE(0x5a = ASCII 'Z');此代码在此处:mm/slub.c:setup_page_debug()

  • slab 对象在mm/slub.c:init_object()中初始化为POISON_FREE(0x6b = ASCII 'k')

  • slab 对象的最后一个字节在mm/slub.c:init_object()中初始化为POISON_END(0xa5)

(因此,由于 slab 层执行这些 slab 内存初始化的方式,我们最终得到值0x6b(ASCII k)作为刚分配的 slab 内存的初始值)。请注意,为了使其工作,您不应安装自定义构造函数。此外,您现在可以忽略it's-type-safe-by-RCU指令;通常情况下是这样(即,“is type-safe-by-RCU”为真;FYI,RCU(Read Copy Update)是一种高级同步技术,超出了本书的范围)。从在 SLUB 调试模式下运行时 slab 的初始化方式可以看出,内存内容实际上被初始化为值POISON_FREE(0x6b = ASCII 'k')。因此,如果内存释放后此值发生变化,内核可以检测到并触发报告(通过 printk)。当然,这是一个众所周知的使用后释放UAF)内存错误的案例!类似地,在红色区域之前或之后写入(这些实际上是保护区域,通常初始化为0xbb)将触发写入缓冲区下/溢出错误,内核将报告。有用!

试一下-触发 UAF 错误

为了帮助您更好地理解这一点,我们将在本节的屏幕截图中展示一个示例。执行以下步骤:

  1. 首先确保启用了CONFIG_SLUB_DEBUG内核配置(应设置为y;这通常是发行版内核的情况)

  2. 然后,在包括内核命令行slub_debug=指令的情况下启动系统(这将打开完整的 SLUB 调试;或者您可以传递更精细的变体,例如slub_debug=FZPU(请参阅此处的内核文档以了解每个字段的解释:www.kernel.org/doc/Documentation/vm/slub.txt);作为演示,在我的 Fedora 31 虚拟机上,我传递了以下内核命令行-这里重要的是,slub_debug=FZPU以粗体字体突出显示:

$ cat /proc/cmdline
BOOT_IMAGE=(hd0,msdos1)/vmlinuz-5.4.0-llkd01 root=/dev/mapper/fedora_localhost--live-root ro resume=/dev/mapper/fedora_localhost--live-swap rd.lvm.lv=fedora_localhost-live/root rd.lvm.lv=fedora_localhost-live/swap rhgb slub_debug=FZPU 3

(有关slub_debug参数的更多详细信息,请参阅下一节​引导和运行时的 SLUB 调试选项)。

  1. 编写一个创建新的自定义 slab 高速缓存的内核模块(当然其中存在内存错误!)。确保未指定构造函数(示例代码在此处:ch9/poison_test;我将留给您浏览代码并测试的练习)。

  2. 我们在这里尝试一下:通过kmem_cache_alloc()(或等效方法)分配一些 slab 内存。下面是一个屏幕截图(图 9.2),显示分配的内存,以及在执行快速的memset()将前 16 个字节设置为z0x7a)后的相同区域:

图 9.2-分配和 memset()后的 slab 内存)。

  1. 现在,来说说 bug!在清理方法中,我们释放了分配的 slab,然后尝试对其进行另一个memset()从而触发了 UAF bug。同样,我们通过另一张屏幕截图(图 9.3)显示内核日志:

图 9.3 - 内核报告 UAF bug!

请注意内核如何报告这一点(前面图中红色的第一段文字)作为Poison overwritten bug。事实上就是这样:我们用0x21(故意是 ASCII 字符!)覆盖了0x6b毒值。在释放了来自 slab 缓存的缓冲区后,如果内核在有效负载中检测到毒值之外的任何值(POISON_FREE = 0x6b = ASCII 'k'),就会触发 bug。(还要注意,红区 - 保护区 - 的值初始化为0xbb)。

下一节将提供有关可用的 SLUB 层调试选项的更多细节。

引导和运行时的 SLUB 调试选项

在使用 SLUB 实现(默认)时,调试内核级 slab 问题非常强大,因为内核具有完整的调试信息。只是默认情况下它是关闭的。有各种方式(视口)可以打开和查看 slab 调试级别的信息;有大量的细节可用!其中一些方法包括以下内容:

  • 通过在内核命令行上传递slub_debug=字符串(当然是通过引导加载程序)。这会打开完整的 SLUB 内核级调试。

  • 要查看的特定调试信息可以通过传递给slub_debug=字符串的选项进行微调(在=后面不传递任何内容意味着启用所有 SLUB 调试选项);例如,传递slub_debug=FZ会启用以下选项:

  • F: 对齐检查(启用SLAB_DEBUG_CONSISTENCY_CHECKS);请注意,打开此选项可能会减慢系统速度。

  • Z: 红色分区。

  • 即使没有通过内核命令行打开 SLUB 调试功能,我们仍然可以通过在/sys/kernel/slab/<slab-name>下的适当伪文件中写入1(作为 root 用户)来启用/禁用它:

  • 回想一下我们之前的演示内核模块(ch9/slab_custom);一旦加载到内核中,可以像这样查看每个分配对象的理论和实际大小:

$ sudo cat /sys/kernel/slab/our_ctx/object_size  /sys/kernel/slab/our_ctx/slab_size 
328 768
    • 还有其他几个伪文件;在/sys/kernel/slab/<name-of-slab>/上执行ls(1)将会显示它们。例如,通过在/sys/kernel/slab/our_ctx/ctor上执行cat来查找到我们的ch9/slab_custom slab 缓存的构造函数:
$ sudo cat /sys/kernel/slab/our_ctx/ctor
our_ctor+0x0/0xe1 [slab_custom]

在这里可以找到一些相关的详细信息(非常有用!):SLUB 的简短用户指南www.kernel.org/doc/Documentation/vm/slub.txt)。

此外,快速查看内核源树的tools/vm文件夹将会发现一些有趣的程序(这里相关的是slabinfo.c)和一个用于生成图表的脚本(通过gnuplot(1))。前面段落提到的文档提供了有关生成图表的使用细节。

作为一个重要的附带说明,内核有大量(而且有用!)的内核参数可以在引导时(通过引导加载程序)选择性地传递给它。在这里的文档中可以看到完整的列表:内核的命令行参数www.kernel.org/doc/html/latest/admin-guide/kernel-parameters.html)。

好了,这(终于)结束了我们对 slab 分配器的覆盖(从上一章延续到这一章)。您已经了解到它是在页面分配器之上的一层,解决了两个关键问题:一是允许内核创建和维护对象缓存,以便非常高效地执行一些重要的内核数据结构的分配和释放;二是包括通用内存缓存,允许您以非常低的开销(与二进制伙伴系统分配器不同)分配小量的 RAM——页面的片段。事实就是这样:slab API 是驱动程序中真正常用的 API;不仅如此,现代驱动程序作者还利用了资源管理的devm_k{m,z}alloc() API;我们鼓励您这样做。不过要小心:我们详细讨论了实际分配的内存可能比您想象的要多(使用ksize()来找出实际分配了多少)。您还学会了如何创建自定义的 slab 缓存,以及如何进行 slab 层的调试。

现在让我们学习vmalloc() API 是什么,如何以及何时用于内核内存分配。

理解并使用内核 vmalloc() API

在前一章中,我们已经学到,内核内存分配的最终引擎只有一个——页面(或伙伴系统)分配器。在其上层是 slab 分配器(或 slab 缓存)机制。此外,内核地址空间中还有另一个完全虚拟的地址空间,可以随意分配虚拟页面,这就是所谓的内核vmalloc区域。

当虚拟页面实际被使用时(由内核中的某个东西或通过进程或线程的用户空间使用),它实际上是通过页面分配器分配的物理页面帧(这对所有用户空间内存帧也是最终真实的,尽管是间接的方式;这一点我们稍后在需求分页和 OOM部分会详细介绍)。

在内核段或 VAS(我们在第七章中详细介绍了这些内容,内存管理内部-基础,在检查内核段部分),是vmalloc地址空间,从VMALLOC_STARTVMALLOC_END-1。它起初是一个完全虚拟的区域,也就是说,它的虚拟页面最初并未映射到任何物理页面帧上。

要快速复习一下,可以重新查看用户和内核段的图表——实际上是完整的 VAS——通过重新查看图 7.12。您可以在第七章中的内存管理内部-基础部分的尝试-查看内核段详细信息部分找到这个图表。

在本书中,我们的目的不是深入研究内核的vmalloc区域的内部细节。相反,我们提供足够的信息,让您作为模块或驱动程序的作者,在运行时使用这个区域来分配虚拟内存。

学习使用 vmalloc 系列 API

您可以使用vmalloc() API 从内核的vmalloc区域中分配虚拟内存(当然是在内核空间中):

#include <linux/vmalloc.h>
void *vmalloc(unsigned long size);

关于 vmalloc 的一些关键点:

  • vmalloc()API 将连续的虚拟内存分配给调用者。并不保证分配的区域在物理上是连续的;可能是连续的,也可能不是(事实上,分配越大,物理上连续的可能性就越小)。

  • 理论上分配的虚拟页面的内容是随机的;实际上,它似乎是与架构相关的(至少在 x86_64 上,似乎会将内存区域清零);当然,(尽管可能会稍微影响性能)建议您通过使用vzalloc()包装 API 来确保内存清零。

  • vmalloc()(以及相关函数)API 只能在进程上下文中调用(因为它可能导致调用者休眠)。

  • vmalloc()的返回值是成功时的 KVA(在内核 vmalloc 区域内),失败时为NULL

  • 刚刚分配的 vmalloc 内存的起始位置保证在页面边界上(换句话说,它总是页面对齐的)。

  • 实际分配的内存(来自页面分配器)可能比请求的大小要大(因为它在内部分配足够的页面来覆盖请求的大小)

你会发现,这个 API 看起来非常类似于熟悉的用户空间malloc(3)。事实上,乍一看确实如此,只是当然,它是内核空间的分配(还要记住,两者之间没有直接的对应关系)。

在这种情况下,vmalloc()对我们模块或驱动程序的作者有什么帮助呢?当你需要一个大的虚拟连续缓冲区,其大小大于 slab API(即k{m|z}alloc()和友元)可以提供的大小时——请记住,在 ARM 和 x86[_64]上,单个分配通常为 4MB——那么你应该使用vmalloc

值得一提的是,内核出于各种原因使用vmalloc(),其中一些如下:

  • 在加载内核模块时为内核模块的(静态)内存分配空间(在kernel/module.c:load_module()中)。

  • 如果定义了CONFIG_VMAP_STACK,那么vmalloc()用于为每个线程的内核模式堆栈分配内存(在kernel/fork.c:alloc_thread_stack_node()中)。

  • 在内部,为了处理一个叫做ioremap()的操作。

  • 在 Linux 套接字过滤器(bpf)代码路径中等。

为了方便起见,内核提供了vzalloc()包装 API(类似于kzalloc())来分配并清零内存区域——这是一个良好的编码实践,但可能会稍微影响时间关键的代码路径:

void *vzalloc(unsigned long size);

一旦你使用了分配的虚拟缓冲区,当然你必须释放它:

void vfree(const void *addr);

如预期的那样,传递给vfree()的参数是v[m|z]alloc()的返回地址(甚至是这些调用的底层__vmalloc() API)。传递NULL会导致它只是无害地返回。

在下面的片段中,我们展示了我们的ch9/vmalloc_demo内核模块的一些示例代码。和往常一样,我建议你克隆本书的 GitHub 存储库并自己尝试一下(为了简洁起见,我们没有在下面的片段中显示整个源代码;我们显示了模块初始化代码调用的主要vmalloc_try()函数)。

这是代码的第一部分。如果vmalloc() API 出现任何问题,我们通过内核的pr_warn()辅助程序生成警告。请注意,以下的pr_warn()辅助程序实际上并不是必需的;在这里我有点迂腐,我们保留它……其他情况也是如此,如下所示:

// ch9/vmalloc_demo/vmalloc_demo.c
#define pr_fmt(fmt) "%s:%s(): " fmt, KBUILD_MODNAME, __func__
[...]
#define KVN_MIN_BYTES     16
#define DISP_BYTES        16
static void *vptr_rndm, *vptr_init, *kv, *kvarr, *vrx;

static int vmalloc_try(void)
{
    if (!(vptr_rndm = vmalloc(10000))) {
        pr_warn("vmalloc failed\n");
        goto err_out1;
    }
    pr_info("1\. vmalloc(): vptr_rndm = 0x%pK (actual=0x%px)\n", 
            vptr_rndm, vptr_rndm);
    print_hex_dump_bytes(" content: ", DUMP_PREFIX_NONE, vptr_rndm,     
                DISP_BYTES);

在上面的代码块中,vmalloc() API 分配了一个至少有 10,000 字节的连续内核虚拟内存区域;实际上,内存是页面对齐的!我们使用内核的print_hex_dump_bytes()辅助例程来转储这个区域的前 16 个字节。

接下来,看一下以下代码如何使用vzalloc() API 再次分配另一个至少有 10,000 字节的连续内核虚拟内存区域(尽管它是页面对齐的内存);这次,内存内容被设置为零:

    /* 2\. vzalloc(); memory contents are set to zeroes */
    if (!(vptr_init = vzalloc(10000))) {
        pr_warn("%s: vzalloc failed\n", OURMODNAME);
        goto err_out2;
    }
    pr_info("2\. vzalloc(): vptr_init = 0x%pK (actual=0x%px)\n",
            vptr_init, (TYPECST)vptr_init);
    print_hex_dump_bytes(" content: ", DUMP_PREFIX_NONE, vptr_init, 
                DISP_BYTES);

关于以下代码的一些要点:首先,注意使用goto进行错误处理(在多个goto实例的目标标签处,我们使用vfree()根据需要释放先前分配的内存缓冲区),这是典型的内核代码。其次,暂时忽略kvmalloc()kcalloc()__vmalloc()等友元例程;我们将在vmalloc 的友元部分介绍它们:

  /* 3\. kvmalloc(): allocate 'kvn' bytes with the kvmalloc(); if kvn is
   * large (enough), this will become a vmalloc() under the hood, else
   * it falls back to a kmalloc() */
    if (!(kv = kvmalloc(kvn, GFP_KERNEL))) {
        pr_warn("kvmalloc failed\n");
        goto err_out3;
    }
    [...]

    /* 4\. kcalloc(): allocate an array of 1000 64-bit quantities and zero
     * out the memory */
    if (!(kvarr = kcalloc(1000, sizeof(u64), GFP_KERNEL))) {
        pr_warn("kvmalloc_array failed\n");
        goto err_out4;
    }
    [...]
    /* 5\. __vmalloc(): <seen later> */
    [...]
    return 0;
err_out5:
  vfree(kvarr);
err_out4:
    vfree(kv);
err_out3:
    vfree(vptr_init);
err_out2:
    vfree(vptr_rndm);
err_out1:
    return -ENOMEM;
}

在我们内核模块的清理代码路径中,我们当然释放了分配的内存区域:

static void __exit vmalloc_demo_exit(void)
{
    vfree(vrx);
    kvfree(kvarr);
    kvfree(kv);
    vfree(vptr_init);
    vfree(vptr_rndm);
    pr_info("removed\n");
}

我们将让你自己尝试并验证这个演示内核模块。

现在,让我们简要地探讨另一个非常关键的方面——用户空间的malloc()或内核空间的vmalloc()内存分配如何变成物理内存?继续阅读以了解更多!

关于内存分配和需求分页的简要说明

不深入研究vmalloc()(或用户空间malloc())的内部工作细节,我们仍然会涵盖一些关键点,这些关键点是像你这样的有能力的内核/驱动程序开发人员必须理解的。

首先,vmalloc-ed 虚拟内存必须在某个时候(在使用时)变成物理内存。这种物理内存是通过内核中唯一的方式分配的 - 通过页面(或伙伴系统)分配器。这是一个有点间接的过程,简要解释如下。

使用vmalloc()时,一个关键点应该被理解:vmalloc()只会导致虚拟内存页面被分配(它们只是被操作系统标记为保留)。此时实际上并没有分配物理内存。实际的物理页面框架只有在这些虚拟页面被触摸时才会被分配 - 而且也是逐页进行 - 无论是读取、写入还是执行。直到程序或进程实际尝试使用它之前,实际上并没有分配物理内存的这一关键原则被称为各种名称 - 需求分页、延迟分配、按需分配等等。事实上,文档中明确说明了这一点:

"vmalloc 空间被懒惰地同步到使用页面错误处理程序的进程的不同 PML4/PML5 页面中..."

清楚地了解vmalloc()和相关内容以及用户空间 glibc malloc()系列例程的内存分配实际工作原理是非常有启发性的 - 这一切都是通过需求分页!这意味着这些 API 的成功返回实际上并不意味着物理内存分配。当vmalloc()或者用户空间的malloc()返回成功时,到目前为止实际上只是保留了一个虚拟内存区域;实际上还没有分配物理内存!实际的物理页面框架分配只会在虚拟页面被访问时(无论是读取、写入还是执行)逐页进行

但这是如何在内部发生的呢?简而言之,答案是:每当内核或进程访问虚拟地址时,虚拟地址都会被 CPU 核心上的硅片的一部分内存管理单元(MMU)解释。MMU 的转换旁路缓冲器(TLB)(我们没有能力在这里深入研究所有这些,抱歉!)现在将被检查是否命中。如果是,内存转换(虚拟到物理地址)已经可用;如果不是,我们有一个 TLB 缺失。如果是这样,MMU 现在将遍历进程的分页表,有效地转换虚拟地址,从而获得物理地址。它将这个地址放在地址总线上,CPU 就可以继续进行。

但是,想一想,如果 MMU 找不到匹配的物理地址会怎么样?这可能是由于许多原因之一,其中之一就是我们这里的情况 - 我们(还)没有物理页面框架,只有一个虚拟页面。在这一点上,MMU 基本上放弃了,因为它无法处理。相反,它调用操作系统的页面错误处理程序代码 - 在进程的上下文中运行的异常或错误处理程序 - 在current的上下文中。这个页面错误处理程序实际上解决了这种情况;在我们的情况下,使用vmalloc()(或者甚至是用户空间的malloc()!),它请求页面分配器为单个物理页面框架(在 order 0处)并将其映射到虚拟页面。

同样重要的是要意识到,通过页面(伙伴系统)和 slab 分配器进行的内核内存分配并不是懒惰分页(或延迟分配)的情况。在那里,当分配内存时,要理解实际的物理页面框架是立即分配的。(在 Linux 上,实际上一切都非常快,因为伙伴系统的空闲列表已经将所有系统物理 RAM 映射到内核的 lowmem 区域,因此可以随意使用。)

回想一下我们在之前的程序ch8/lowlevel_mem中所做的事情;在那里,我们使用我们的show_phy_pages()库例程来显示给定内存范围的虚拟地址、物理地址和页面帧号(PFN),从而验证低级页面分配器例程确实分配了物理连续的内存块。现在,您可能会想,为什么不在这个vmalloc_demo内核模块中调用相同的函数?如果分配的(虚拟)页面的 PFN 不是连续的,我们再次证明,确实只是虚拟连续的。尝试听起来很诱人,但是不起作用!为什么?因为,正如之前所述(在第八章中,模块作者的内核内存分配-第一部分):除了直接映射(身份映射/低内存区域)的地址之外,不要尝试将任何其他地址从虚拟转换为物理-页面或 slab 分配器提供的地址。它在vmalloc中根本不起作用。

vmalloc和一些相关信息的一些附加点将在下文中介绍;请继续阅读。

vmalloc()的朋友

在许多情况下,执行内存分配的精确 API(或内存层)对调用者并不真正重要。因此,在许多内核代码路径中出现了以下伪代码的使用模式:

kptr = kmalloc(n);
if (!kptr) {
    kptr = vmalloc(n);
    if (unlikely(!kptr))
        <... failed, cleanup ...>
}
<ok, continue with kptr>

这种代码的更清晰的替代方案是kvmalloc()API。在内部,它尝试以以下方式分配所请求的n字节的内存:首先,通过更有效的kmalloc();如果成功,很好,我们很快就获得了物理连续的内存并完成了;如果没有成功,它会回退到通过更慢但更可靠的vmalloc()分配内存(从而获得虚拟连续的内存)。它的签名如下:

#include <linux/mm.h>
void *kvmalloc(size_t size, gfp_t flags);

(记得包含头文件。)请注意,对于(内部的)vmalloc()要通过(如果需要的话),只需提供GFP_KERNEL标志。与往常一样,返回值是指向分配内存的指针(内核虚拟地址),或者在失败时为NULL。释放使用kvfree获得的内存:

void kvfree(const void *addr);

在这里,参数当然是从kvmalloc()返回的地址。

类似地,与{k|v}zalloc()API 类似,我们还有kvzalloc()API,它当然内存内容设置为零。我建议您优先使用它而不是kvmalloc()API(通常的警告:它更安全但速度稍慢)。

此外,您可以使用kvmalloc_array()API 为数组分配虚拟连续内存。它分配nsize字节的元素。其实现如下所示:

// include/linux/mm.h
static inline void *kvmalloc_array(size_t n, size_t size, gfp_t flags)
{
        size_t bytes;
        if (unlikely(check_mul_overflow(n, size, &bytes)))
                return NULL;
        return kvmalloc(bytes, flags);
}

这里的一个关键点:注意对危险的整数溢出(IoF)错误进行有效性检查;这很重要和有趣;在代码中进行类似的有效性检查,以编写健壮的代码。

接下来,kvcalloc()API 在功能上等同于用户空间 APIcalloc(3),只是kvmalloc_array()API 的简单包装器:

void *kvcalloc(size_t n, size_t size, gfp_t flags);

我们还提到,对于需要 NUMA 意识的代码(我们在第七章“内存管理内部-基本知识”中涵盖了 NUMA 和相关主题,物理 RAM 组织部分),可以使用以下 API,我们可以指定要从特定 NUMA 节点分配内存的参数(这是指向 NUMA 系统的要点;请看后面不久会出现的信息框):

void *kvmalloc_node(size_t size, gfp_t flags, int node);

同样,我们也有kzalloc_node()API,它将内存内容设置为零。

实际上,通常我们看到的大多数内核空间内存 API 最终都归结为一个以 NUMA 节点作为参数的 API。例如,对于主要的页面分配器 API 之一,__get_free_page()API 的调用链如下:

`__get_free_page() -> __get_free_pages() -> alloc_pages() -> alloc_pages_current()

-> __alloc_pages_nodemask() . **__alloc_pages_nodemask()`** API 被认为是分区伙伴分配器的核心;请注意它的第四个参数,(NUMA)nodemask:

mm/page_alloc.c:struct page *

`__alloc_pages_nodemask(gfp_t gfp_mask, unsigned int order,

int preferred_nid, nodemask_t *nodemask);`

当然,您必须释放您获取的内存;对于前面的kv*()API(和kcalloc()API),请使用kvfree()释放获得的内存。

另一个值得了解的内部细节,以及k[v|z]malloc[_array]()API 有用的原因:对于常规的kmalloc(),如果请求的内存足够小(当前定义为CONFIG_PAGE_ALLOC_COSTLY_ORDER,即3,表示 8 页或更少),内核将无限重试分配内存;这实际上会影响性能!使用kvmalloc()API,不会进行无限重试(此行为通过 GFP 标志__GFP_NORETRY|__GFP_NOWARN指定),从而加快速度。LWN 的一篇文章详细介绍了 slab 分配器的相当奇怪的无限重试语义:“太小而无法失败”的内存分配规则,Jon Corbet,2014 年 12 月lwn.net/Articles/627419/)。

关于我们在本节中看到的vmalloc_demo内核模块,再快速看一下代码(ch9/vmalloc_demo/vmalloc_demo.c)。我们使用kvmalloc()以及kcalloc()注释中的步骤 3 和 4)。让我们在 x86_64 Fedora 31 客户系统上运行它并查看输出:

图 9.4-加载我们的 vmalloc_demo.ko 内核模块时的输出

我们可以从前面的输出中的 API 中看到实际的返回(内核虚拟)地址-请注意它们都属于内核的 vmalloc 区域。注意kvmalloc()(图 9.4 中的步骤 3)的返回地址;让我们在proc下搜索一下:

$ sudo grep "⁰x00000000fb2af97f" /proc/vmallocinfo
0x00000000fb2af97f-0x00000000ddc1eb2c 5246976 0xffffffffc04a113d pages=1280 vmalloc vpages N0=1280

就是这样!我们可以清楚地看到,使用kvmalloc()API 为大量内存(5 MB)分配导致内部调用了vmalloc()API(kmalloc()API 将失败并且不会发出警告,也不会重试),因此,正如您所看到的,命中了/proc/vmallocinfo

要解释/proc/vmallocinfo的前面字段,请参阅这里的内核文档:www.kernel.org/doc/Documentation/filesystems/proc.txt

在我们的ch9/vmalloc_demo内核模块中,通过将kvnum=<# bytes to alloc>作为模块参数传递来更改通过kvmalloc()分配的内存量。

FYI,内核提供了一个内部辅助 API,vmalloc_exec()-它(再次)是vmalloc()API 的包装器,并用于分配具有执行权限的虚拟连续内存区域。一个有趣的用户是内核模块分配代码路径(kernel/module.c:module_alloc());内核模块的(可执行部分)内存空间是通过这个例程分配的。不过,这个例程并没有被导出。

我们提到的另一个辅助例程是vmalloc_user();它(再次)是vmalloc()API 的包装器,并用于分配适合映射到用户 VAS 的零内存的虚拟连续内存区域。这个例程是公开的;例如,它被几个设备驱动程序以及内核的性能事件环缓冲区使用。

指定内存保护

如果您打算为您分配的内存页面指定特定的内存保护(读、写和执行保护的组合),该怎么办?在这种情况下,使用底层的__vmalloc()API(它是公开的)。请参考内核源代码中的以下注释(mm/vmalloc.c):

* For tight control over page level allocator and protection flags
* use __vmalloc() instead.

__vmalloc()API 的签名显示了我们如何实现这一点:

void *__vmalloc(unsigned long size, gfp_t gfp_mask, pgprot_t prot);

值得一提的是,从 5.8 内核开始,__vmalloc()函数的第三个参数——pgprot_t prot已被移除(因为除了通常的用户之外,没有其他用户需要页面权限;github.com/torvalds/linux/commit/88dca4ca5a93d2c09e5bbc6a62fbfc3af83c4fca)。这告诉我们关于内核社区的另一件事——如果一个功能没有被任何人使用,它就会被简单地移除。

前两个参数是通常的嫌疑犯——以字节为单位的内存大小和用于分配的 GFP 标志。第三个参数在这里是感兴趣的:prot代表我们可以为内存页面指定的内存保护位掩码。例如,要分配 42 个设置为只读(r--)的页面,我们可以这样做:

vrx = __vmalloc(42 * PAGE_SIZE, GFP_KERNEL, PAGE_KERNEL_RO);

然后,当然,调用vfree()来将内存释放回系统。

测试它——一个快速的概念验证

我们将在我们的vmalloc_demo内核模块中尝试一个快速的概念验证。我们通过__vmalloc()内核 API 分配了一个内存区域,指定页面保护为只读(或RO)。然后我们通过读取和写入只读内存区域来测试它。以下是其中的一部分代码片段。

请注意,我们默认情况下未定义以下代码中的(愚蠢的)WR2ROMEM_BUG宏,这样你,无辜的读者,就不会让我们邪恶的vmalloc_demo内核模块在你身上崩溃。因此,为了尝试这个 PoC,请取消注释定义语句(如下所示),从而允许错误的代码执行:

static int vmalloc_try(void)
{
    [...]
    /* 5\. __vmalloc(): allocate some 42 pages and set protections to RO */
/* #undef WR2ROMEM_BUG */
#define WR2ROMEM_BUG /* 'Normal' usage: keep this commented out, else we 
                      *  will crash! Read  the book, Ch 9, for details :-) */
    if (!(vrx = __vmalloc(42*PAGE_SIZE, GFP_KERNEL, PAGE_KERNEL_RO))) {
        pr_warn("%s: __vmalloc failed\n", OURMODNAME);
        goto err_out5;
    }
    pr_info("5\. __vmalloc(): vrx = 0x%pK (actual=0x%px)\n", vrx, vrx);
    /* Try reading the memory, should be fine */
    print_hex_dump_bytes(" vrx: ", DUMP_PREFIX_NONE, vrx, DISP_BYTES);
#ifdef WR2ROMEM_BUG
    /* Try writing to the RO memory! We find that the kernel crashes
     * (emits an Oops!) */
   *(u64 *)(vrx+4) = 0xba;
#endif
    return 0;
    [...]

运行时,在我们尝试写入只读内存的地方,它会崩溃!请参见以下部分截图(图 9.5;在我们的 x86_64 Fedora 客户机上运行):

图 9.5——当我们尝试写入只读内存区域时发生的内核 Oops!

这证明了我们执行的__vmalloc() API 成功地将内存区域设置为只读。再次强调,对于前面(部分可见)的内核诊断或Oops消息的解释细节超出了本书的范围。然而,很容易看出在前面的图中突出显示的问题的根本原因:以下行文字确切地指出了这个错误的原因:

BUG: unable to handle page fault for address: ffffa858c1a39004
#PF: supervisor write access in kernel mode
#PF: error_code(0x0003) - permissions violation

在用户空间应用程序中,可以通过mprotect(2)系统调用对任意内存区域执行类似的内存保护设置;请查阅其手册以获取使用详情(它甚至友好地提供了示例代码!)。

为什么要将内存设置为只读?

在分配时指定内存保护,比如只读,可能看起来是一个相当无用的事情:那么你怎么初始化那块内存为一些有意义的内容呢?嗯,想一想——guard pages就是这种情况的完美用例(类似于 SLUB 层在调试模式下保留的 redzone 页面);它确实是有用的。

如果我们想要为某些目的而使用只读页面呢?那么,我们可以使用一些替代方法,而不是使用__vmalloc(),也许是通过mmap()方法将一些内核内存映射到用户空间,然后使用用户空间应用程序的mprotect(2)系统调用来设置适当的保护(甚至通过著名且经过测试的 LSM 框架,如 SELinux、AppArmor、Integrity 等来设置保护)。

我们用一个快速比较来结束本节:典型的内核内存分配器 API:kmalloc()vmalloc()

kmalloc()vmalloc() API——一个快速比较

以下表格中简要比较了kmalloc()(或kzalloc())和vmalloc()(或vzalloc())API:

特征 kmalloc()kzalloc() vmalloc()vzalloc()
分配的内存是 物理连续的 虚拟(逻辑)连续的
内存对齐 对硬件(CPU)缓存行对齐 页面对齐
最小粒度 与架构相关;在 x86[_64]上最低为 8 字节 1 页
性能 对于小内存分配(典型情况下)更快(分配物理 RAM);适用于小于 1 页的分配 较慢,按需分页(只分配虚拟内存;涉及页面错误处理程序的延迟分配 RAM);可以为大(虚拟)分配提供服务
大小限制 有限(通常为 4 MB) 非常大(64 位系统上内核 vmalloc 区域甚至可以达到数 TB,但 32 位系统上要少得多)
适用性 适用于几乎所有性能要求较高的用例,所需内存较小,包括 DMA(仍然,请使用 DMA API);可以在原子/中断上下文中工作 适用于大型软件(几乎)连续的缓冲区;较慢,不能在原子/中断上下文中分配

这并不意味着其中一个优于另一个。它们的使用取决于具体情况。这将引出我们下一个 - 确实非常重要的 - 话题:在何时决定使用哪种内存分配 API?做出正确的决定对于获得最佳系统性能和稳定性非常关键 - 请继续阅读以了解如何做出选择!

内核中的内存分配 - 何时使用哪些 API

迄今为止我们学到的东西的一个非常快速的总结:内核内存分配(和释放)的基础引擎称为页面(或伙伴系统)分配器。最终,每个内存分配(和随后的释放)都经过这一层。然而,它也有自己的问题,其中主要问题是内部碎片或浪费(由于其最小粒度是一个页面)。因此,我们有了位于其上面的 slab 分配器(或 slab 缓存),它提供了对象缓存的功能,并缓存页面的片段(有助于减轻页面分配器的浪费问题)。此外,不要忘记您可以创建自己的自定义 slab 缓存,并且正如我们刚刚看到的,内核有一个vmalloc区域和 API 来从中分配虚拟页面。

有了这些信息,让我们继续。要了解何时使用哪种 API,让我们首先看看内核内存分配 API 集。

可视化内核内存分配 API 集

以下概念图向我们展示了 Linux 内核的内存分配层以及其中的显著 API;请注意以下内容:

  • 在这里,我们只展示了内核向模块/驱动程序作者公开的(通常使用的)API(除了最终执行分配的__alloc_pages_nodemask() API 在底部!)。

  • 为简洁起见,我们没有展示相应的内存释放 API。

以下是一个图表,显示了几个(向模块/驱动程序作者公开的)内核内存分配 API:

图 9.6 - 概念图显示内核的内存分配 API 集(用于模块/驱动程序作者)

既然您已经看到了(公开的)可用内存分配 API 的丰富选择,接下来的部分将深入探讨如何帮助您在何种情况下做出正确的选择。

选择适当的内核内存分配 API

有了这么多选择的 API,我们该如何选择?虽然我们在本章以及上一章已经讨论过这个问题,但我们会再次总结,因为这非常重要。大体上来说,有两种看待它的方式 - 使用的 API 取决于以下因素:

  • 所需内存的数量

  • 所需的内存类型

我们将在本节中说明这两种情况。

首先,通过扫描以下流程图(从标签“从这里开始”右上方开始),决定使用哪种 API 来分配内存的类型、数量和连续性:

图 9.7 - 决定为模块/驱动程序使用哪种内核内存分配 API 的决策流程图

当然,这并不是微不足道的;不仅如此,我想提醒您回顾一下我们在本章早些时候讨论过的详细内容,包括要使用的 GFP 标志(以及不要在原子上下文中休眠的规则);实际上,以下内容:

  • 在任何原子上下文中,包括中断上下文,确保只使用GFP_ATOMIC标志。

  • 否则(进程上下文),您可以决定是否使用GFP_ATOMICGFP_KERNEL标志;当可以安全休眠时,请使用GFP_KERNEL

  • 然后,如在使用 slab 分配器时的注意事项部分所述:在使用k[m|z]alloc() API 和相关函数时,请确保使用ksize()检查实际分配的内存。

接下来,根据要分配的内存类型决定使用哪个 API,扫描以下表:

所需内存类型 分配方法 API
内核模块,典型情况:小量(少于一页),物理上连续的常规用法 Slab 分配器 `k[m
设备驱动程序:小量(<1 页),物理上连续的常规用法;适用于驱动程序probe()或 init 方法;建议驱动程序使用 资源管理 API devm_kzalloc()devm_kmalloc()

物理上连续,通用用途 | 页面分配器 | __get_free_page[s](), get_zeroed_page()

alloc_page[s][_exact]() |

对于直接内存访问DMA),物理上连续的情况下,可以使用专门的 DMA API 层,带有 CMA(或 slab/page 分配器) (这里不涵盖:`dma_alloc_coherent(), dma_map_[single sg]()`, Linux DMA 引擎 API 等)
对于大型软件缓冲区,虚拟上连续的情况下,可以通过页面分配器间接使用 `v[m z]alloc()`
在运行时大小不确定时,虚拟或物理上连续的情况下,可以使用 slab 或 vmalloc 区域 kvmalloc[_array]()
自定义数据结构(对象) 创建并使用自定义 slab 缓存 `kmem_cache_[create

(当然,这个表格与图 9.7中的流程图有一些重叠)。作为一个通用的经验法则,您的首选应该是 slab 分配器 API,即通过kzalloc()kmalloc();这些对于典型小于一页的分配来说是最有效的。此外,请记住,当运行时所需大小不确定时,您可以使用kvmalloc() API。同样,如果所需大小恰好是完全舍入的 2 的幂页数(2⁰、2¹、...、2^(MAX_ORDER-1) ),那么使用页面分配器 API 将是最佳的。

关于 DMA 和 CMA 的说明

关于 DMA 的话题,虽然其研究和使用超出了本书的范围,但我仍然想提一下,Linux 有一套专门为 DMA 设计的 API,称为DMA 引擎。执行 DMA 操作的驱动程序作者非常希望使用这些 API,而不是直接使用 slab 或页面分配器 API(微妙的硬件问题确实会出现)。

此外,几年前,三星工程师成功地将一个补丁合并到主线内核中,称为连续内存分配器CMA)。基本上,它允许分配大的物理上连续的内存块(超过典型的 4 MB 限制!)。这对于一些内存需求量大的设备的 DMA 是必需的(你想在大屏平板电脑或电视上播放超高清质量的电影吗?)。很酷的是,CMA 代码被透明地构建到 DMA 引擎和 DMA API 中。因此,像往常一样,执行 DMA 操作的驱动程序作者应该坚持使用 Linux DMA 引擎层。

如果您有兴趣了解 DMA 和 CMA,请参阅本章的进一步阅读部分提供的链接。

同时,要意识到我们的讨论大多是关于典型的内核模块或设备驱动程序作者。在操作系统本身,对单页的需求往往非常高(由于操作系统通过页面错误处理程序服务需求分页 - 即所谓的次要错误)。因此,在底层,内存管理子系统往往频繁地发出__get_free_page[s]()API。此外,为了满足页面缓存(和其他内部缓存)的内存需求,页面分配器发挥着重要作用。

好的,干得好,通过这个你(几乎!)完成了我们对各种内核内存分配层和 API(用于模块/驱动程序作者)的两章覆盖。让我们用一个重要的剩余领域来结束这个大主题 - Linux 内核(相当有争议的)OOM killer;继续阅读吧!

保持活力 - OOM killer

让我们首先介绍一些关于内核内存管理的背景细节,特别是有关回收空闲内存的内容。这将使您能够理解内核OOM killer组件是什么,如何与它一起工作,甚至如何故意调用它。

回收内存 - 内核的例行公事和 OOM

正如您所知,内核会尽量将内存页面的工作集保持在内存金字塔(或层次结构)的最高位置,以实现最佳性能。

系统上所谓的内存金字塔(或内存层次结构)包括(按顺序,从最小但速度最快到最大但速度最慢):CPU 寄存器、CPU 缓存(L1、L2、L3...)、RAM 和交换空间(原始磁盘/闪存/SSD 分区)。在我们的后续讨论中,我们忽略 CPU 寄存器,因为它们的大小微不足道。

因此,处理器使用其硬件缓存(L1、L2 等)来保存页面的工作集。但当然,CPU 缓存内存非常有限,因此很快就会用完,导致内存溢出到下一个分层级别 - RAM。在现代系统中,甚至是许多嵌入式系统,都有相当多的 RAM;但是,如果操作系统的 RAM 不足,它会将无法放入 RAM 的内存页面溢出到原始磁盘分区 - 交换空间。因此,系统继续正常工作,尽管一旦使用交换空间,性能成本就会显著增加。

为了确保 RAM 中始终有一定数量的空闲内存页面可用,Linux 内核不断进行后台页面回收工作 - 实际上,您可以将其视为例行公事。谁实际执行这项工作?kswapd内核线程不断监视系统上的内存使用情况,并在它们感觉到内存不足时调用页面回收机制。

这项页面回收工作是基于每个节点:区域的基础进行的。内核使用所谓的水印级别 - 最小、低和高 - 每个节点:区域来智能地确定何时回收内存页面。您可以随时查看/proc/zoneinfo以查看当前的水印级别。(请注意,水印级别的单位是页面。)此外,正如我们之前提到的,缓存通常是第一个受害者,并且在内存压力增加时会被缩小。

但让我们假设反面:如果所有这些内存回收工作都没有帮助,内存压力继续增加,直到完整的内存金字塔耗尽,即使是几页的内核分配也失败(或者无限重试,坦率地说,这也是无用的,也许更糟糕)?如果所有 CPU 缓存、RAM 和交换空间(几乎完全)都满了呢?嗯,大多数系统在这一点上就死了(实际上,它们并没有死,它们只是变得非常慢,看起来好像它们永远挂起)。然而,作为 Linux 的 Linux 内核在这些情况下往往是积极的;它调用一个名为 OOM killer 的组件。OOM killer 的工作 - 你猜对了! - 是识别并立即杀死内存占用进程(通过发送致命的 SIGKILL 信号;它甚至可能会杀死一大堆进程)。

正如您可能想象的那样,它也经历了自己的争议。早期版本的 OOM killer 已经(完全正确地)受到了批评。最近的版本使用了更好的启发式方法,效果相当不错。

您可以在此 LWN 文章(2015 年 12 月)中找到有关改进的 OOM killer 工作(启动策略和 OOM reaper 线程)的更多信息:Towards more predictable and reliable out-of-memory handling: lwn.net/Articles/668126/

故意调用 OOM killer

要测试内核 OOM killer,我们必须对系统施加巨大的内存压力。因此,内核将释放其武器 - OOM killer,一旦被调用,将识别并杀死一些进程。因此,显然,我强烈建议您在一个安全的隔离系统上尝试这样的东西,最好是一个测试 Linux VM(上面没有重要数据)。

通过 Magic SysRq 调用 OOM killer

内核提供了一个有趣的功能,称为 Magic SysRq:基本上,某些键盘组合(或加速器)会导致回调到一些内核代码。例如,假设它已启用,在 x86[_64]系统上按下Alt-SysRq-b组合键将导致冷启动!小心,不要随便输入任何内容,确保阅读相关文档:www.kernel.org/doc/Documentation/admin-guide/sysrq.rst

让我们尝试一些有趣的事情;我们在我们的 Fedora Linux VM 上运行以下命令:

$ cat /proc/sys/kernel/sysrq
16

这表明 Magic SysRq 功能部分启用(本节开头提到的内核文档给出了详细信息)。要完全启用它,我们运行以下命令:

$ sudo sh -c "echo 1 > /proc/sys/kernel/sysrq"

好吧,为了到达这里的要点:您可以使用 Magic SysRq 来调用 OOM killer!

小心!通过 Magic SysRq 或其他方式调用 OOM killer 导致一些进程 - 通常是进程 - 无条件死亡!

如何?以 root 身份,只需输入以下内容:

# echo f > /proc/sysrq-trigger

查看内核日志,看看是否发生了什么有趣的事情!

通过一个疯狂的分配器程序调用 OOM killer

在接下来的部分中,我们还将演示一种更加实用和有趣的方式,通过这种方式,您可以(很可能)邀请 OOM killer。编写一个简单的用户空间 C 程序,作为一个疯狂的分配器,执行(通常)成千上万的内存分配,向每个页面写入一些内容,当然,永远不释放内存,从而对内存资源施加巨大压力。

像往常一样,我们在以下片段中只显示源代码的最相关部分;请参考并克隆本书的 GitHub 存储库以获取完整的代码;请记住,这是一个用户模式应用程序,而不是内核模块:

// ch9/oom_killer_try/oom_killer_try.c
#define BLK     (getpagesize()*2)
static int force_page_fault = 0;
int main(int argc, char **argv)
{
  char *p;
  int i = 0, j = 1, stepval = 5000, verbose = 0;
  [...]

  do {
      p = (char *)malloc(BLK);
      if (!p) {
          fprintf(stderr, "%s: loop #%d: malloc failure.\n",
                  argv[0], i);
          break;
      }

      if (force_page_fault) {
          p[1103] &= 0x0b; // write something into a byte of the 1st page
          p[5227] |= 0xaa; // write something into a byte of the 2nd page
      }
      if (!(i % stepval)) { // every 'stepval' iterations..
          if (!verbose) {
              if (!(j%5)) printf(". ");
         [...]
      }
      i++;
 } while (p && (i < atoi(argv[1])));

在以下代码块中,我们展示了在 x86_64 Fedora 31 VM 上运行我们的自定义 5.4.0 Linux 内核的crazy allocator程序时获得的一些输出:

$ cat /proc/sys/vm/overcommit_memory  /proc/sys/vm/overcommit_ratio0
50                       
$                           << explained below >>

$ ./oom-killer-try
Usage: ./oom-killer-try alloc-loop-count force-page-fault[0|1] [verbose_flag[0|1]]
$ ./oom-killer-try 2000000 0
./oom-killer-try: PID 28896
..... ..... ..... ..... ..... ..... ..... ..... ..... ..... ..... ..... ..... ..... ..... ..... ..... ..... ..... ..... ..... ..... ..... ..... ..... ..... ..... ..... ..... ..... ...Killed
$

Killed消息是一个线索!用户模式进程已被内核终止。一旦我们瞥一眼内核日志,原因就显而易见了——当然是 OOM 杀手(我们在需求分页和 OOM部分展示了内核日志)。

理解 OOM 杀手背后的原理

瞥一眼我们的oom_killer_try应用程序的前面输出:(在这次运行中)出现了 33 个周期(.)之后是可怕的Killed消息。在我们的代码中,我们每次分配(2 页或 8KB)时发出一个.(通过printf)。因此,在这里,我们有 33 次 5 个周期,意味着 33 * 5 = 165 次=> 165 * 5000 * 8K ~= 6,445MB。因此,我们可以得出结论,我们的进程(虚拟地)分配了大约 6,445MB(约 6.29GB)的内存后,OOM 杀手终止了我们的进程!现在您需要理解为什么会在这个特定的数字发生这种情况。

在这个特定的 Fedora Linux VM 上,RAM 为 2GB,交换空间为 2GB;因此,在内存金字塔中,总可用内存=(CPU 缓存+)RAM + 交换空间。

这是 4GB(为了简单起见,让我们忽略 CPU 缓存中的相当微不足道的内存量)。但是,这就引出了一个问题,为什么内核在 4GB 点(或更低)没有调用 OOM 杀手呢?为什么只在大约 6GB 时?这是一个有趣的观点:Linux 内核遵循VM 过度承诺策略,故意过度承诺内存(在一定程度上)。要理解这一点,请查看当前的vm.overcommit设置:

$ cat /proc/sys/vm/overcommit_memory
0

这确实是默认值(0)。可设置的值(仅由 root 设置)如下:

"不要过度承诺。系统的总地址空间承诺不得超过交换空间加上可配置数量(默认为物理 RAM 的 50%)。根据您使用的数量,在大多数情况下,这意味着进程在访问页面时不会被终止,但将在适当的内存分配错误时收到错误。适用于希望保证其内存分配将来可用而无需初始化每个页面的应用程序"

过度承诺程度由过度承诺比率确定:

$ cat /proc/sys/vm/overcommit_ratio
50

我们将在以下部分中检查两种情况。

情况 1——vm.overcommit 设置为 2,关闭过度承诺

首先,请记住,这不是默认设置。当tunable设置为2时,用于计算总(可能过度承诺的)可用内存的公式如下:

总可用内存=(RAM + 交换空间)(过度承诺比率/100);*

这个公式仅适用于vm.overcommit == 2时。

在我们的 Fedora 31 VM 上,vm.overcommit == 2,RAM 和交换空间各为 2GB,这将产生以下结果(以 GB 为单位):

总可用内存=(2 + 2)(50/100)= 4 * 0.5 = 2GB*

这个值——(过度)承诺限制——也可以在/proc/meminfo中看到,作为CommitLimit字段。

情况 2——vm.overcommit 设置为 0,过度承诺开启,为默认设置

默认设置。vm.overcommit设置为0(而不是2):使用此设置,内核有效地计算总(过度)承诺的内存大小如下:

总可用内存=(RAM + 交换空间)(过度承诺比率+100)%;*

这个公式仅适用于vm.overcommit == 0时。

在我们的 Fedora 31 VM 上,vm.overcommit == 0,RAM 和交换空间各为 2GB,这个公式将产生以下结果(以 GB 为单位):

总可用内存=(2 + 2)(50+100)% = 4 * 150% = 6GB*

因此,系统有效地假装有总共 6GB 的内存可用。现在我们明白了:当我们的oom_killer_try进程分配了大量内存并且超出了这个限制(6GB)时,OOM killer 就会介入!

我们现在明白,内核在/proc/sys/vm下提供了几个 VM 过度承诺的可调参数,允许系统管理员(或 root)对其进行微调(包括通过将vm.overcommit设置为值2来关闭它)。乍一看,关闭它似乎很诱人。不过,请暂停一下,仔细考虑一下;在大多数工作负载上,保持内核默认的 VM 过度承诺是最好的。

例如,在我的 Fedora 31 客户端 VM 上将vm.overcommit值设置为2会导致有效可用内存变为只有 2GB。典型的内存使用,特别是在 GUI 运行时,远远超过了这个值,导致系统甚至无法在 GUI 模式下登录用户!以下链接有助于更好地了解这个主题:Linux 内核文档:www.kernel.org/doc/Documentation/vm/overcommit-accounting在 Linux 中禁用内存过度承诺的缺点是什么?www.quora.com/What-are-the-disadvantages-of-disabling-memory-overcommit-in-Linux。(请查看更多阅读部分了解更多信息。)

需求分页和 OOM

回想一下我们在本章早些时候学到的真正重要的事实,在内存分配和需求分页简要说明部分:由于操作系统使用的需求分页(或延迟分配)策略,当通过malloc(3)(和其他函数)分配内存页面时,实际上只会在进程 VAS 的某个区域保留虚拟内存空间,此时并不会分配物理内存。只有当你对虚拟页面的任何字节执行某些操作 - 读取、写入或执行 - 时,MMU 才会引发页面错误(一个次要错误),并且操作系统的页面错误处理程序会相应地运行。如果它认为这个内存访问是合法的,它会通过页面分配器分配一个物理帧。

在我们简单的oom_killer_try应用程序中,我们通过它的第三个参数force_page_fault来操纵这个想法:当设置为1时,我们通过在每个循环迭代中写入任何东西来精确模拟这种情况(如果需要,请再次查看代码)。

所以,现在你知道了这一点,让我们将我们的应用程序重新运行,将第三个参数force_page_fault设置为1,确实强制发生页面错误!这是我在我的 Fedora 31 VM 上运行此操作(在我们自定义的 5.4.0 内核上)时产生的输出:

$ cat /proc/sys/vm/overcommit_memory /proc/sys/vm/overcommit_ratio0
50
$ free -h
              total    used    free     shared   buff/cache    available
Mem:          1.9Gi   1.0Gi    76Mi       12Mi        866Mi        773Mi
Swap:         2.1Gi   3.0Mi   2.1Gi
$ ./oom-killer-try
Usage: ./oom-killer-try alloc-loop-count force-page-fault[0|1] [verbose_flag[0|1]]
$ ./oom-killer-try 900000 1
./oom_killer_try: PID 2032 (verbose mode: off)
..... ..... ..... ..... ..... ..... ..... ..... ..... ..... ..... ..... ..... ..... ..... .Killed
$ 
$ free -h
              total    used    free    shared   buff/cache     available
Mem:          1.9Gi   238Mi   1.5Gi     2.0Mi        192Mi         1.6Gi
Swap:         2.1Gi   428Mi   1.6Gi
$

这一次,你可以真切地感觉到系统在为内存而奋斗。这一次,它很快就耗尽了内存,实际物理内存已经分配。(从前面的输出中,我们在这个特定情况下看到了 15 x 5 + 1 个点(或句号);也就是说,15 乘以 5 个点加 1 个点=> = 76 次=> 76 * 5000 个循环迭代* 8K 每次迭代~= 2969 MB 虚拟和物理分配!)

显然,在这一点上,发生了两件事中的一件:

  • 系统的 RAM 和交换空间都用完了,因此无法分配页面,从而引发了 OOM killer。

  • 计算出的(人为的)内核 VM 提交限制已超出。

我们可以轻松查找这个内核 VM 提交值(再次在我运行此操作的 Fedora 31 VM 上):

$ grep CommitLimit /proc/meminfo
CommitLimit: 3182372 kB

这相当于约 3108 MB(远远超过我们计算的 2969 MB)。因此,在这种情况下,很可能是由于所有的 RAM 和交换空间都被用来运行 GUI 和现有的应用程序,第一种情况发生了。

还要注意,在运行我们的程序之前,较大系统缓存(页面和缓冲缓存)使用的内存量是相当可观的。free(1)实用程序的输出中的名为buff/cache的列显示了这一点。在运行我们疯狂的分配器应用程序之前,2GB 中的 866MB 用于页面缓存。然而,一旦我们的程序运行,它对操作系统施加了如此大的内存压力,以至于大量的交换 - 将 RAM 页面换出到名为“swap”的原始磁盘分区 - 被执行,并且所有缓存都被释放。不可避免地(因为我们拒绝释放任何内存),OOM killer 介入并杀死我们,导致大量内存被回收。OOM killer 清理后的空闲内存和缓存使用量分别为 1.5GB 和 192MB。(当前缓存使用量较低;随着系统运行,它将增加。)

查看内核日志后,确实发现 OOM killer 来过了!请注意,以下部分截图仅显示了在运行 5.4.0 内核的 x86_64 Fedora 31 虚拟机上的堆栈转储:

图 9.8 - OOM killer 后的内核日志,显示内核调用堆栈

以自下而上的方式阅读图 9.8中的内核模式堆栈(忽略以?开头的帧):显然,发生了页错误;您可以看到调用帧:page_fault() | do_page_fault() | [ ... ] | __hande_mm_fault() | __do_fault() | [ ... ] | __alloc_pages_nodemask()

想一想,这是完全正常的:MMU 在尝试为没有物理对应物的虚拟页面提供服务时引发了错误。操作系统的错误处理代码运行(在进程上下文中,意味着current运行其代码!);最终导致操作系统调用页面分配器例程的__alloc_pages_nodemask()函数,正如我们之前所了解的,这实际上是分区伙伴系统(或页面)分配器的核心 - 内存分配的引擎!

不正常的是,这一次它(__alloc_pages_nodemask()函数)失败了!这被认为是一个关键问题,导致操作系统调用 OOM killer(您可以在前面的图中看到out_of_memory调用帧)。

在诊断转储的后期,内核努力为杀死给定进程提供理由。它显示了所有线程的表格,它们的内存使用情况(以及各种其他统计数据)。实际上,由于sysctl:/proc/sys/vm/oom_dump_tasks默认为1,这些统计数据被显示出来。以下是一个示例(在以下输出中,我们已经删除了dmesg的最左边的时间戳列,以使数据更易读):

[...]
Tasks state (memory values in pages):
[ pid ]  uid  tgid total_vm    rss pgtables_bytes swapents oom_score_adj name
[  607]    0   607    11774      8   106496       361   -250 systemd-journal
[  622]    0   622    11097      0    90112      1021  -1000 systemd-udevd
[  732]    0   732     7804      0    69632       153  -1000 auditd

              [...]

[ 1950] 1000  1950    56717      1   77824        571  0    bash
[ 2032] 1000  2032   755460 434468 6086656     317451  0    oom_killer_try
oom-kill:constraint=CONSTRAINT_NONE,nodemask=(null),cpuset=/,mems_allowed=0,global_oom,task_memcg=/user.slice/user-1000.slice/session-3.scope,task=oom_killer_try,pid=2032,uid=1000
Out of memory: Killed process 2032 (oom_killer_try) total-vm:3021840kB, anon-rss:1737872kB, file-rss:0kB, shmem-rss:0kB, UID:1000 pgtables:6086656kB oom_score_adj:0
oom_reaper: reaped process 2032 (oom_killer_try), now anon-rss:0kB, file-rss:0kB, shmem-rss:0kB
$ 

在上述输出中,我们已经用粗体突出显示了rssResident Set Size)列,因为这是对所讨论进程的物理内存使用的良好指示(单位为 KB)。显然,我们的oom_killer_try进程使用了大量的物理内存。还要注意它的交换条目(swapents)数量非常高。现代内核(4.6 及更高版本)使用专门的oom_reaper内核线程来执行收割(杀死)受害进程的工作(上述输出的最后一行显示了这个内核线程收割了我们美妙的oom_killer_try进程!)。有趣的是,Linux 内核的 OOM 可以被认为是(最后的)防御措施,用来防止分叉炸弹和类似的(分布式)拒绝服务(D)DoS 攻击。

理解 OOM 分数

为了加快在关键时刻(当 OOM killer 被调用时)发现内存占用过多的进程,内核会为每个进程分配和维护一个OOM 分数(您可以随时在/proc/<pid>/oom_score伪文件中查找该值)。

OOM 分数范围是01000

  • OOM 分数为0意味着该进程没有使用任何可用内存

  • OOM 分数为1000意味着该进程使用了其可用内存的 100%

显然,具有最高 OOM 分数的进程获胜。它的奖励-它会被 OOM killer 立即杀死(说到干燥的幽默)。不过,内核有启发式方法来保护重要任务。例如,内置的启发式方法意味着 OOM killer 不会选择任何属于 root 的进程、内核线程或具有硬件设备打开的任务作为其受害者。

如果我们想确保某个进程永远不会被 OOM killer 杀死怎么办?虽然需要 root 访问权限,但这是完全可能的。内核提供了一个可调节的/proc/<pid>/oom_score_adj,即 OOM 调整值(默认为0)。net OOM 分数是oom_score值和调整值的总和:

  net_oom_score = oom_score + oom_score_adj;

因此,将进程的oom_score_adj值设置为1000几乎可以保证它会被杀死,而将其设置为-1000则产生完全相反的效果-它永远不会被选为受害者。

快速查询(甚至设置)进程的 OOM 分数(以及 OOM 调整值)的方法是使用choom(1)实用程序。例如,要查询 systemd 进程的 OOM 分数和 OOM 调整值,只需执行choom -p 1。我们做了显而易见的事情-编写了一个简单的脚本(内部使用choom(1))来查询系统上当前所有进程的 OOM 分数(在这里:ch9/query_process_oom.sh;请在您的系统上尝试一下)。快速提示:系统上 OOM 分数最高的(十个)进程可以通过以下方式快速查看(第三列是 net OOM 分数):

./query_process_oom.sh | sort -k3n | tail

随此,我们结束了本节,也结束了本章。

总结

在本章中,我们延续了上一章的内容。我们详细介绍了如何创建和使用自定义的 slab 缓存(在您的驱动程序或模块非常频繁地分配和释放某个数据结构时非常有用),以及如何使用一些内核基础设施来帮助您调试 slab(SLUB)内存问题。然后,我们了解并使用了内核的vmalloc API(和相关内容),包括如何在内存页面上设置给定的内存保护。有了丰富的内存 API 和可用的策略,您如何选择在特定情况下使用哪一个呢?我们通过一个有用的决策图和表格来解决了这个重要问题。最后,我们深入了解了内核的OOM killer组件以及如何与其一起工作。

正如我之前提到的,对 Linux 内存管理内部和导出 API 集的深入了解将对您作为内核模块和/或设备驱动程序作者有很大帮助。事实是,我们都知道,开发人员花费了大量时间在故障排除和调试代码上;在这里获得的复杂知识和技能将帮助您更好地应对这些困难。

这完成了本书对 Linux 内核内存管理的明确覆盖。尽管我们涵盖了许多领域,但也留下或只是粗略地涉及了一些领域。

事实上,Linux 内存管理是一个庞大而复杂的主题,值得为了学习、编写更高效的代码和调试复杂情况而加以理解。

学习强大的crash(1)实用程序的(基本)用法(用于深入查看内核,通过实时会话或内核转储文件),然后利用这些知识重新查看本章和上一章的内容,确实是一种强大的学习方式!

在完成了 Linux 内存管理的学习之后,接下来的两章将让您了解另一个核心操作系统主题-在 Linux 操作系统上如何执行CPU 调度。休息一下,完成以下作业和问题,浏览引起您兴趣的进一步阅读材料。然后,精力充沛地跟我一起进入下一个令人兴奋的领域!

问题

最后,这里有一些问题供您测试对本章材料的了解:github.com/PacktPublishing/Linux-Kernel-Programming/tree/master/questions。您会在本书的 GitHub 存储库中找到一些问题的答案:github.com/PacktPublishing/Linux-Kernel-Programming/tree/master/solutions_to_assgn

进一步阅读

为了帮助您深入研究这一主题并获取有用的材料,我们在本书的 GitHub 存储库中提供了一个相当详细的在线参考和链接列表(有时甚至包括书籍)的“进一步阅读”文档。进一步阅读文档在这里可用:github.com/PacktPublishing/Linux-Kernel-Programming/raw/master/Further_Reading.md

第十章:CPU 调度器 - 第一部分

在本章和下一章中,您将深入了解一个关键的操作系统主题 - 即 Linux 操作系统上的 CPU 调度。我将尝试通过提出(并回答)典型问题并执行与调度相关的常见任务来使学习更加实际。了解操作系统级别的调度工作不仅对于内核(和驱动程序)开发人员来说很重要,而且还会自动使您成为更好的系统架构师(甚至对于用户空间应用程序)。

我们将首先介绍基本背景材料;这将包括 Linux 上的内核可调度实体KSE),以及 Linux 实现的 POSIX 调度策略。然后,我们将使用工具 - perf和其他工具 - 来可视化操作系统在 CPU 上运行任务并在它们之间切换的控制流。这对于应用程序的性能分析也很有用!之后,我们将更深入地了解 Linux 上 CPU 调度的工作原理,包括模块化调度类、完全公平调度CFS)、核心调度函数的运行等。在此过程中,我们还将介绍如何以编程方式(动态地)查询和设置系统上任何线程的调度策略和优先级。

在本章中,我们将涵盖以下领域:

  • 学习 CPU 调度内部 - 第一部分 - 基本背景

  • 可视化流程

  • 学习 CPU 调度内部 - 第二部分

  • 线程 - 调度策略和优先级

  • 学习 CPU 调度内部 - 第三部分

现在,让我们开始这个有趣的话题吧!

技术要求

我假设您已经阅读了第一章 内核工作区设置,并已经适当地准备了一个运行 Ubuntu 18.04 LTS(或更高稳定版本)的客户虚拟机VM)并安装了所有必需的软件包。如果没有,我强烈建议您首先这样做。

为了充分利用本书,我强烈建议您首先设置工作环境,包括克隆本书的 GitHub 存储库以获取代码并进行实际操作。存储库可以在这里找到:github.com/PacktPublishing/Linux-Kernel-Programming

学习 CPU 调度内部 - 第一部分 - 基本背景

让我们快速了解一下我们需要了解 Linux 上 CPU 调度的基本背景信息。

请注意,在本书中,我们不打算涵盖 Linux 上熟练的系统程序员应该已经非常了解的材料;这包括基础知识,如进程(或线程)状态,状态机及其转换,以及更多关于实时性、POSIX 调度策略等的信息。这些内容(以及更多内容)已经在我之前的一本书中详细介绍过:《Linux 系统编程实战》,由 Packt 于 2018 年 10 月出版。

Linux 上的 KSE 是什么?

正如您在第六章中所学到的,在内核内部要点 - 进程和线程一节中,每个进程 - 实际上是系统上存活的每个线程 - 都被赋予一个任务结构(struct task_struct)以及用户模式和内核模式堆栈。

在这里,需要问的关键问题是:在进行调度时,它作用于哪个对象,换句话说,什么是内核可调度实体KSE?在 Linux 上,KSE 是一个线程,而不是一个进程(当然,每个进程至少包含一个线程)。因此,线程是进行调度的粒度级别。

举个例子来解释这一点:如果我们假设有一个 CPU 核心和 10 个用户空间进程,每个进程包括三个线程,再加上五个内核线程,那么我们总共有(10 x 3)+ 5,等于 35 个线程。除了五个内核线程外,每个线程都有用户和内核栈以及一个任务结构(内核线程只有内核栈和任务结构;所有这些都在第六章中得到了详细解释,内核内部要点-进程和线程,在组织进程、线程及其栈-用户空间和内核空间部分)。现在,如果所有这 35 个线程都是可运行的,那么它们将竞争单个处理器(尽管它们不太可能同时都是可运行的,但为了讨论的完整性,让我们假设它们都是可运行的),那么现在有 35 个线程竞争 CPU 资源,而不是 10 个进程和五个内核线程。

现在我们了解了 KSE 是一个线程,我们(几乎)总是在调度上下文中引用线程。既然这一点已经理解,让我们继续讨论 Linux 实现的调度策略。

POSIX 调度策略

重要的是要意识到 Linux 内核不仅实现了一个实现 CPU 调度的算法;事实上,POSIX 标准规定了一个 POSIX 兼容的操作系统必须遵循的最少三种调度策略(实际上是算法)。Linux 不仅实现了这三种,还实现了更多,采用了一种称为调度类的强大设计(稍后在本章的理解模块化调度类部分中详细介绍)。

关于 Linux 上的 POSIX 调度策略(以及更多)的信息在我早期的书籍Hands-On System Programming with Linux中有更详细的介绍,该书于 2018 年 10 月由 Packt 出版。

现在,让我们简要总结一下 POSIX 调度策略以及它们在下表中的影响:

调度策略 关键点 优先级范围
SCHED_OTHERSCHED_NORMAL 始终是默认值;具有此策略的线程是非实时的;在内部实现为完全公平调度(CFS)类(稍后在关于 CFS 和 vruntime 值部分中看到)。这种调度策略背后的动机是公平性和整体吞吐量。 实时优先级为0;非实时优先级称为 nice 值:范围从-20 到+19(较低的数字意味着更高的优先级),基数为 0

| SCHED_RR | 这种调度策略背后的动机是一种(软)实时策略,相对积极。具有有限时间片(通常默认为 100 毫秒)。

SCHED_RR线程将在以下情况下让出处理器(如果且仅如果):

  • 它在 I/O 上阻塞(进入睡眠状态)。

  • 它停止或终止。

  • 更高优先级的实时线程变为可运行状态(将抢占此线程)。

  • 它的时间片到期。|(软)实时:1 到 99(较高的数字

意味着更高的优先级)|

| SCHED_FIFO | 这种调度策略背后的动机是一种(软)实时策略,相对来说非常积极。SCHED_FIFO线程将在以下情况下让出处理器:

  • 它在 I/O 上阻塞(进入睡眠状态)。

  • 它停止或终止。

  • 更高优先级的实时线程变为可运行状态(将抢占此线程)。

它实际上有无限的时间片。|(与SCHED_RR相同)|

SCHED_BATCH 这种调度策略背后的动机是适用于非交互式批处理作业的调度策略,较少的抢占。 Nice 值范围(-20 到+19)
SCHED_IDLE 特殊情况:通常 PID0内核线程(传统上称为swapper;实际上是每个 CPU 的空闲线程)使用此策略。它始终保证是系统中优先级最低的线程,并且只在没有其他线程想要 CPU 时运行。 所有优先级中最低的(可以认为低于 nice 值+19)

重要的是要注意,当我们在上表中说实时时,我们实际上指的是实时(或者最好是实时),而不是实时操作系统RTOS)中的硬实时。Linux 是一个GPOS,一个通用操作系统,而不是 RTOS。话虽如此,您可以通过应用外部补丁系列(称为 RTL,由 Linux 基金会支持)将普通的 Linux 转换为真正的硬实时 RTOS;您将在以下章节将主线 Linux 转换为 RTOS中学习如何做到这一点。

请注意,SCHED_FIFO线程实际上具有无限的时间片,并且运行直到它希望停止或前面提到的条件之一成立。在这一点上,重要的是要理解我们只关注线程(KSE)调度;在诸如 Linux 的操作系统上,现实情况是硬件(和软件)中断总是优先的,并且甚至会抢占(内核或用户空间)SCHED_FIFO线程!请参考图 6.1 以了解这一点。此外,我们将在第十四章处理硬件中断中详细介绍硬件中断。在我们的讨论中,我们暂时将忽略中断。

优先级缩放很简单:

  • 非实时线程(SCHED_OTHER)具有0的实时优先级;这确保它们甚至不能与实时线程竞争。它们使用一个(旧的 UNIX 风格)称为nice value的优先级值,范围从-20 到+19(-20 是最高优先级,+19 是最差的)。

在现代 Linux 上的实现方式是,每个 nice 级别对应于 CPU 带宽的大约 10%的变化(或增量,加或减),这是一个相当大的数量。

  • 实时线程(SCHED_FIFO / SCHED_RR)具有 1 到 99 的实时优先级范围,1 是最低优先级,99 是最高优先级。可以这样理解:在一个不可抢占的 Linux 系统上,一个SCHED_FIFO优先级为 99 的线程在一个无法中断的无限循环中旋转,实际上会使机器挂起!(当然,即使这样也会被中断 - 包括硬中断和软中断;请参见图 6.1。)

调度策略和优先级(静态 nice 值和实时优先级)当然是任务结构的成员。线程所属的调度类是独占的:一个线程在特定时间点只能属于一个调度策略(不用担心,我们稍后将在CPU 调度内部 - 第二部分中详细介绍调度类)。

此外,您应该意识到在现代 Linux 内核上,还有其他调度类(stop-schedule 和 deadline),它们实际上比我们之前提到的 FIFO/RR 更优先(优先级更高)。既然您已经了解了基础知识,让我们继续看一些非常有趣的东西:我们实际上如何可视化控制流。继续阅读!

可视化流程

多核系统导致进程和线程在不同处理器上并发执行。这对于获得更高的吞吐量和性能非常有用,但也会导致共享可写数据的同步问题。因此,例如,在一个具有四个处理器核心的硬件平台上,我们可以期望进程(和线程)在它们上面并行执行。这并不是什么新鲜事;不过,有没有一种方法可以实际上看到哪些进程或线程在哪个 CPU 核心上执行 - 也就是说,有没有一种可视化处理器时间线的方法?事实证明确实有几种方法可以做到这一点。在接下来的章节中,我们将首先使用perf来看一种有趣的方法,然后再使用其他方法(使用 LTTng、Trace Compass 和 Ftrace)。

使用 perf 来可视化流程

Linux 拥有庞大的开发人员和质量保证QA)工具库,其中perf(1)是一个非常强大的工具。简而言之,perf工具集是在 Linux 系统上执行 CPU 性能分析的现代方式。(除了一些提示外,我们在本书中不会详细介绍perf。)

类似于古老的top(1)实用程序,要详细了解正在占用 CPU 的情况(比top(1)更详细),perf(1)一系列实用程序非常出色。不过,请注意,与应用程序相比,perf与其运行的内核紧密耦合,这是相当不寻常的。首先,你需要安装linux-tools-$(uname -r)包。此外,自定义的 5.4 内核包将不可用;因此,在使用perf时,我建议你使用标准(或发行版)内核引导你的虚拟机,安装linux-tools-$(uname -r)包,然后尝试使用perf。(当然,你也可以在内核源代码树中的tools/perf/文件夹下手动构建perf。)

安装并运行perf后,请尝试这些perf命令:

sudo perf top
sudo perf top --sort comm,dso
sudo perf top -r 90 --sort pid,comm,dso,symbol

(顺便说一句,comm意味着命令/进程的名称,**dso**动态共享对象的缩写)。使用alias会更容易;尝试这个(一行)以获得更详细的信息(调用堆栈也可以展开!):

alias ptopv='sudo perf top -r 80 -f 99 --sort pid,comm,dso,symbol --demangle-kernel -v --call-graph dwarf,fractal'

perf(1)man页面提供了详细信息;使用man perf-<foo>表示法 - 例如,man perf-top - 以获取有关perf top的帮助。

使用perf的一种方法是了解在哪个 CPU 上运行了什么任务;这是通过perf中的timechart子命令完成的。你可以使用perf记录系统范围的事件,也可以记录特定进程的事件。要记录系统范围的事件,请运行以下命令:

sudo perf timechart record

通过信号(^C)终止记录会话。这将默认生成一个名为perf.data的二进制数据文件。现在可以使用以下命令进行检查:

sudo perf timechart 

这个命令生成了一个可伸缩矢量图形SVG)文件!它可以在矢量绘图工具(如 Inkscape,或通过 ImageMagick 中的display命令)中查看,或者直接在 Web 浏览器中查看。研究时间表可能会很有趣;我建议你试试。不过,请注意,矢量图像可能会很大,因此打开需要一段时间。

以下是在运行 Ubuntu 18.10 的本机 Linux x86_64 笔记本电脑上进行的系统范围采样运行:

$ sudo perf timechart record
[sudo] password for <user>:
^C[ perf record: Woken up 18 times to write data ] 
[ perf record: Captured and wrote 6.899 MB perf.data (196166 samples) ] 
$ ls -lh perf.data 
-rw------- 1 root root 7.0M Jun 18 12:57 perf.data 
$ sudo perf timechart
Written 7.1 seconds of trace to output.svg.

可以配置perf以使用非 root 访问权限。在这里,我们不这样做;我们只是通过sudo(8)以 root 身份运行perf

perf生成的 SVG 文件的屏幕截图如下所示。要查看 SVG 文件,你可以简单地将其拖放到你的 Web 浏览器中:

图 10.1 - (部分)屏幕截图显示由 sudo perf timechart 生成的 SVG 文件

在前面的屏幕截图中,举个例子,你可以看到EMT-0线程很忙,占用了最大的 CPU 周期(不幸的是,CPU 3 这个短语不太清楚;仔细看看紫色条下面的 CPU 2)。这是有道理的;它是代表我们运行 Fedora 29 的 VirtualBox 的虚拟 CPUVCPU)的线程(EMT代表模拟器线程)!

你可以放大和缩小这个 SVG 文件,研究perf默认记录的调度和 CPU 事件。下图是前面截图的部分屏幕截图,放大 400%至 CPU 1 区域,显示了在 CPU#1 上运行的htop(紫色条实际上显示了它执行时的时间段):

图 10.2 - 对 perf timechart 的 SVG 文件的部分屏幕截图,放大 400%至 CPU 1 区域

还有什么?通过使用-I选项切换到perf timechart record,你可以请求仅记录系统范围的磁盘 I/O(和网络,显然)事件。这可能特别有用,因为通常真正的性能瓶颈是由 I/O 活动引起的(而不是 CPU;I/O 通常是罪魁祸首!)。perf-timechart(1)man页面详细介绍了更多有用的选项;例如,--callchain用于执行堆栈回溯记录。另一个例子是,--highlight <name>选项将突出显示所有名称为<name>的任务。

您可以使用perf data convert -- all --to-ctfperf的二进制perf.data记录文件转换为流行的通用跟踪格式CTF)文件格式,其中最后一个参数是存储 CTF 文件的目录。这有什么用呢?CTF 是强大的 GUI 可视化器和分析工具(例如 Trace Compass)使用的本机数据格式(稍后在第十一章中的CPU 调度程序-第二部分中可以看到)。

然而,正如 Trace Compass Perf Profiling 用户指南中所提到的那样(archive.eclipse.org/tracecompass.incubator/doc/org.eclipse.tracecompass.incubator.perf.profiling.doc.user/User-Guide.html):“并非所有 Linux 发行版都具有内置的 ctf 转换。需要使用环境变量 LIBBABELTRACE=1 和 LIBBABELTRACE_DIR=/path/to/libbabeltrace 来编译 perf(因此 linux)以启用该支持。”

不幸的是,在撰写本文时,Ubuntu 就是这种情况。

通过替代(CLI)方法来可视化流程

当然,还有其他方法可以可视化每个处理器上正在运行的内容;我们在这里提到了一些,并保存了另一个有趣的方法(LTTng),将在第十一章中的CPU 调度程序-第二部分中的使用 LTTng 和 Trace Compass 进行可视化部分中介绍。

  • 再次使用perf(1)运行sudo perf sched record命令;这将记录活动。通过使用^C信号终止它,然后使用sudo perf sched map来查看处理器上的执行情况(CLI 地图)。

  • 一些简单的 Bash 脚本可以显示在给定核心上正在执行的内容(这是对ps(1)的简单封装)。在下面的片段中,我们展示了一些示例 Bash 函数;例如,以下c0()函数显示了当前在 CPU 核心#0上正在执行的内容,而c1()则对#1核心执行相同的操作。

# Show thread(s) running on cpu core 'n' - func c'n'
function c0() 
{ 
    ps -eLF | awk '{ if($5==0) print $0}' 
} 
function c1() 
{ 
    ps -eLF | awk '{ if($5==1) print $0}' 
} 

在广泛讨论perf的话题上,Brendan Gregg 有一系列非常有用的脚本,可以在使用perf监视生产 Linux 系统时执行许多必要的工作;请在这里查看它们:github.com/brendangregg/perf-tools(一些发行版将它们作为名为perf-tools[-unstable]的软件包包含在内)。

尝试使用这些替代方案(包括perf-tools[-unstable]包)!

了解 CPU 调度内部工作原理-第二部分

本节详细介绍了内核 CPU 调度的内部工作原理,重点是现代设计的核心部分,即模块化调度类。

了解模块化调度类

内核开发人员 Ingo Molnar(以及其他人)重新设计了内核调度程序的内部结构,引入了一种称为调度类的新方法(这是在 2007 年 10 月发布 2.6.23 内核时的情况)。

顺便说一句,这里的“类”一词并非巧合;许多 Linux 内核功能本质上都是以面向对象的方式设计的。当然,C 语言不允许我们直接在代码中表达这一点(因此结构中有数据和函数指针成员的比例很高,模拟了一个类)。然而,设计往往是面向对象的(您将在Linux 内核编程第二部分书中再次看到这一点)。有关此内容的更多详细信息,请参阅本章的进一步阅读部分。

在核心调度代码下引入了一层抽象,即schedule()函数。schedule()下的这一层通常称为调度类,设计上是模块化的。这里的“模块化”意味着调度类可以从内联内核代码中添加或删除;这与可加载内核模块LKM)框架无关。

基本思想是:当核心调度程序代码(由schedule()函数封装)被调用时,了解到它下面有各种可用的调度类别,它按照预定义的优先级顺序迭代每个类别,询问每个类别是否有一个需要调度到处理器上的线程(或进程)(我们很快就会看到具体是如何做的)。

截至 Linux 内核 5.4,这些是内核中的调度类别,按优先级顺序列出,优先级最高的排在前面:

// kernel/sched/sched.h
[ ... ] 
extern const struct sched_class stop_sched_class; 
extern const struct sched_class dl_sched_class; 
extern const struct sched_class rt_sched_class; 
extern const struct sched_class fair_sched_class; 
extern const struct sched_class idle_sched_class;

这就是我们所拥有的五个调度程序类别 - 停止调度、截止时间、(软)实时、公平和空闲 - 按优先级顺序排列,从高到低。抽象这些调度类别的数据结构struct sched_class被串联在一个单链表上,核心调度代码对其进行迭代。(稍后您将了解sched_class结构是什么;现在请忽略它)。

每个线程都与其自己独特的任务结构(task_struct)相关联;在任务结构中,policy成员指定线程遵循的调度策略(通常是SCHED_FIFOSCHED_RRSCHED_OTHER中的一个)。它是独占的 - 一个线程在任何给定时间点只能遵循一个调度策略(尽管它可以改变)。类似地,任务结构的另一个成员struct sched_class保存线程所属的模块化调度类别(也是独占的)。调度策略和优先级都是动态的,可以通过编程查询和设置(或通过实用程序;您很快就会看到这一点)。

因此,您现在将意识到,所有遵循SCHED_FIFOSCHED_RR调度策略的线程都映射到rt_sched_class(在其任务结构中的sched_class),所有遵循SCHED_OTHER(或SCHED_NORMAL)的线程都映射到fair_sched_class,而空闲线程(swapper/n,其中n是从0开始的 CPU 编号)始终映射到idle_sched_class调度类别。

当内核需要进行调度时,这是基本的调用顺序:

schedule() --> __schedule() --> pick_next_task() 

前面的调度类别的实际迭代发生在这里;请参见pick_next_task()的(部分)代码,如下:

// kernel/sched/core.c
 /* 
  * Pick up the highest-prio task: 
  */ 
static inline struct task_struct * 
pick_next_task(struct rq *rq, struct task_struct *prev, struct rq_flags *rf) 
{ 
    const struct sched_class *class; 
    struct task_struct *p; 

    /* Optimization: [...] */
    [...]

   for_each_class(class) { 
        p = class->pick_next_task(rq, NULL, NULL);
        if (p)
            return p;
    }

    /* The idle class should always have a runnable task: */
    BUG();
}

前面的for_each_class()宏设置了一个for循环,用于迭代所有调度类别。其实现如下:

// kernel/sched/sched.h
[...]
#ifdef CONFIG_SMP
#define sched_class_highest (&stop_sched_class)
#else
#define sched_class_highest (&dl_sched_class)
#endif

#define for_class_range(class, _from, _to) \
    for (class = (_from); class != (_to); class = class->next)

#define for_each_class(class) \
    for_class_range(class, sched_class_highest, NULL)

从前面的实现中可以看出,代码导致每个类都被要求通过pick_next_task()"方法"来安排下一个调度的任务,从sched_class_highestNULL(意味着它们所在的链表的末尾)。现在,调度类代码确定它是否有任何想要执行的候选者。怎么做?实际上很简单;它只是查找它的runqueue数据结构。

现在,这是一个关键点:内核为每个处理器核心和每个调度类别维护一个运行队列!因此,如果我们有一个有 8 个 CPU 核心的系统,那么我们将有8 个核心 * 5 个调度类别 = 40 个运行队列!运行队列实际上是作为每个 CPU 变量实现的,这是一种有趣的无锁技术(例外情况:在单处理器UP)系统上,stop-sched类别不存在):

图 10.3 - 每个 CPU 核心每个调度类都有一个运行队列

请注意,在前面的图中,我展示运行队列的方式可能让它们看起来像数组。这并不是本意,这只是一个概念图。实际使用的运行队列数据结构取决于调度类别(类别代码实现了运行队列)。它可以是一个链表数组(就像实时类别一样),也可以是一棵树 - 一棵红黑(rb)树 - 就像公平类别一样,等等。

为了更好地理解调度器类模型,我们将设计一个例子:假设在对称多处理器(SMP)或多核系统上,我们有 100 个线程处于活动状态(在用户空间和内核空间)。其中,有一些线程在竞争 CPU;也就是说,它们处于准备运行(run)状态,意味着它们是可运行的,因此被排队在运行队列数据结构上:

  • 线程 S1:调度器类,stop-schedSS

  • 线程 D1 和 D2:调度器类,DeadlineDL

  • 线程 RT1 和 RT2:调度器类,Real TimeRT

  • 线程 F1、F2 和 F3:调度器类,CFS(或公平)

  • 线程 I1:调度器类,空闲。

想象一下,一开始,线程 F2 正在处理器核心上,愉快地执行代码。在某个时刻,内核希望在该 CPU 上切换到其他任务(是什么触发了这个?你很快就会看到)。在调度代码路径上,内核代码最终进入kernel/sched/core.c:void schedule(void)内核例程(稍后会跟进代码级细节)。现在重要的是要理解pick_next_task()例程,由schedule()调用,遍历调度器类的链表,询问每个类是否有候选者可以运行。它的代码路径(概念上,当然)看起来像这样:

  1. 核心调度器代码(schedule()):“嘿,SS,你有任何想要运行的线程吗?

  2. SS 类代码:遍历其运行队列并找到一个可运行的线程;因此它回答:“是的,我有,它是线程 S1

  3. 核心调度器代码(schedule()):“好的,让我们切换到 S1 上下文

工作完成了。但是,如果在该处理器的 SS 运行队列上没有可运行的线程 S1(或者它已经进入睡眠状态,或者已经停止,或者它在另一个 CPU 的运行队列上)。那么,SS 会说“”,然后会询问下一个最重要的调度类 DL。如果它有潜在的候选线程想要运行(在我们的例子中是 D1 和 D2),它的类代码将确定 D1 或 D2 中应该运行的线程,并且内核调度器将忠实地上下文切换到它。这个过程会继续进行 RT 和公平(CFS)调度类。(一图胜千言,对吧:参见图 10.4)。

很可能(在您典型的中度负载的 Linux 系统上),在问题 CPU 上没有 SS、DL 或 RT 候选线程想要运行,通常至少会有一个公平(CFS)线程想要运行;因此,它将被选择并进行上下文切换。如果没有想要运行的线程(没有 SS/DL/RT/CFS 类线程想要运行),这意味着系统目前处于空闲状态(懒惰的家伙)。现在,空闲类被问及是否想要运行:它总是说是!这是有道理的:毕竟,当没有其他人需要时,CPU 空闲线程的工作就是在处理器上运行。因此,在这种情况下,内核将上下文切换到空闲线程(通常标记为swapper/n,其中n是它正在执行的 CPU 编号(从0开始))。

还要注意,swapper/n(CPU 空闲)内核线程不会出现在ps(1)列表中,尽管它一直存在(回想一下我们在第六章中展示的代码,内核内部要点-进程和线程,这里:ch6/foreach/thrd_showall/thrd_showall.c。在那里,我们编写了一个disp_idle_thread()例程来显示 CPU 空闲线程的一些细节,即使我们在那里使用的内核的do_each_thread() { ... } while_each_thread()循环也不显示空闲线程)。

以下图表清楚地总结了核心调度代码如何按优先顺序调用调度类,切换到最终选择的下一个线程:

图 10.4-遍历每个调度类以选择下一个要运行的任务

在接下来的章节中,你将学习如何通过一些强大的工具来可视化内核流程。在那里,实际上可以看到对模块化调度器类进行迭代的工作。

询问调度类

核心调度器代码(pick_next_task())如何询问调度类是否有任何想要运行的线程?我们已经看到了这一点,但我觉得值得为了清晰起见重复以下代码片段(大部分从__schedule()调用,也从线程迁移代码路径调用):

// kernel/sched/core.c
[ ... ] 
static inline struct task_struct * 
pick_next_task(struct rq *rq, struct task_struct *prev, struct rq_flags *rf) 
{ 
    const struct sched_class *class;
    struct task_struct *p;
    [ ... ] 
for_each_class(class){
        p = class->pick_next_task(rq, NULL, NULL);
        if (p)
            return p;
    }
    [ ... ]

注意在这里的面向对象的特性:class->pick_next_task()代码,实际上是调用调度类classpick_next_task()方法!方便的返回值是选定任务的任务结构的指针,现在代码切换到该任务。

前面的段落当然意味着,有一个class结构,体现了我们对调度类的真正意思。确实如此:它包含了所有可能的操作,以及有用的挂钩,你可能在调度类中需要。它(令人惊讶地)被称为sched_class结构:

// location: kernel/sched/sched.h
[ ... ] 
struct sched_class {
    const struct sched_class *next;
    [...]
    void (*enqueue_task) (struct rq *rq, struct task_struct *p, int flags); 
    void (*dequeue_task) (struct rq *rq, struct task_struct *p, int flags);
    [ ... ]
    struct task_struct * (*pick_next_task)(struct rq *rq,
                           struct task_struct *prev,
                           struct rq_flags *rf);
    [ ... ] 
    void (*task_tick)(struct rq *rq, struct task_struct *p, int queued); 
    void (*task_fork)(struct task_struct *p); 
    [ ... ]
};

(这个结构的成员比我们在这里展示的要多得多;在代码中查找它)。显然,每个调度类都实例化了这个结构,并适当地填充了它的方法(当然是函数指针)。核心调度代码在调度类的链接列表上进行迭代(以及内核的其他地方),根据需要调用方法和挂钩函数,只要它不是NULL

举个例子,让我们看看公平调度类(CFS)如何实现其调度类的调度算法:

// kernel/sched/fair.c
const struct sched_class fair_sched_class = {
    .next = &idle_sched_class,
    .enqueue_task = enqueue_task_fair,
    .dequeue_task = dequeue_task_fair,
    [ ... ]
    .pick_next_task = pick_next_task_fair,
    [ ... ]
    .task_tick = task_tick_fair,
    .task_fork = task_fork_fair,
    .prio_changed = prio_changed_fair,
    [ ... ]
};

现在你看到了:公平调度类用于选择下一个要运行的任务的代码(当核心调度器询问时)是函数pick_next_task_fair()。FYI,task_ticktask_fork成员是调度类挂钩的很好的例子;这些函数将分别在每个定时器滴答(即每个定时器中断,理论上至少每秒触发CONFIG_HZ次)和当属于这个调度类的线程 fork 时,由调度器核心调用。

也许一个有趣的深入研究的 Linux 内核项目:创建你自己的调度类,具有特定的方法和挂钩,实现其内部调度算法。根据需要链接所有的部分(插入到所需优先级的调度类链接列表中等),并进行测试!现在你可以看到为什么它们被称为模块化调度类了。

现在你了解了现代模块化 CPU 调度器工作背后的架构,让我们简要地看一下 CFS 背后的算法,也许是通用 Linux 上最常用的调度类。

关于 CFS 和 vruntime 值

自 2.6.23 版本以来,CFS 一直是常规线程的事实内核 CPU 调度代码;大多数线程都是SCHED_OTHER,由 CFS 驱动。CFS 背后的驱动力是公平性和整体吞吐量。简而言之,在其实现中,内核跟踪每个可运行的 CFS(SCHED_OTHER)线程的实际 CPU 运行时间(以纳秒为粒度);具有最小运行时间的线程最值得运行,并将在下一个调度切换时被授予处理器。相反,不断占用处理器的线程将累积大量运行时间,因此将受到惩罚(这实际上是相当具有因果报应的)!

不深入讨论 CFS 实现的内部细节,任务结构中嵌入了另一个数据结构struct sched_entity,其中包含一个名为vruntime的无符号 64 位值。在简单的层面上,这是一个单调计数器,用于跟踪线程在处理器上累积(运行)的时间,以纳秒为单位。

在实践中,这里需要大量的代码级调整、检查和平衡。例如,通常情况下,内核会将vruntime值重置为0,触发另一个调度纪元。此外,还有各种可调参数在/proc/sys/kernel/sched_*下,以帮助更好地微调 CPU 调度器的行为。

CFS 如何选择下一个要运行的任务被封装在kernel/sched/fair.c:pick_next_task_fair()函数中。从理论上讲,CFS 的工作方式非常简单:将所有可运行的任务(对于该 CPU)排队到运行队列上,这是一个 rb-tree(一种自平衡二叉搜索树),使得在树上花费最少处理器时间的任务是树上最左边的叶节点,其后的节点表示下一个要运行的任务,然后是下一个。

实际上,从左到右扫描树可以给出未来任务执行的时间表。这是如何保证的?通过使用前面提到的vruntime值作为任务排队到 rb-tree 上的关键!

当内核需要调度并询问 CFS 时,CFS 类代码 - 我们已经提到过了,pick_next_task_fair()函数 - 简单地选择树上最左边的叶节点,返回嵌入其中的任务结构的指针;根据定义,它是具有最低vruntime值的任务,实际上是运行时间最短的任务!(遍历树是一个O(log n)时间复杂度算法,但由于一些代码优化和对最左边叶节点的巧妙缓存,实际上将其转换为一个非常理想的O(1)算法!)当然,实际代码比这里透露的要复杂得多;它需要进行多个检查和平衡。我们不会在这里深入讨论细节。

我们建议那些对 CFS 更多了解的人参考有关该主题的内核文档,网址为www.kernel.org/doc/Documentation/scheduler/sched-design-CFS.txt

此外,内核包含了一些在/proc/sys/kernel/sched_*下的可调参数,对调度产生直接影响。关于这些参数以及如何使用它们的说明可以在Tuning the Task Scheduler页面找到(documentation.suse.com/sles/12-SP4/html/SLES-all/cha-tuning-taskscheduler.html),而在文章www.scylladb.com/2016/06/10/read-latency-and-scylla-jmx-process/中可以找到一个出色的实际用例。

现在让我们继续学习如何查询任何给定线程的调度策略和优先级。

线程 - 调度策略和优先级

在本节中,您将学习如何查询系统上任何给定线程的调度策略和优先级。(但是关于以编程方式查询和设置相同的讨论我们推迟到下一章,在查询和设置线程的调度策略和优先级部分。)

我们了解到,在 Linux 上,线程就是 KSE;它实际上是被调度并在处理器上运行的东西。此外,Linux 有多种选择可供使用的调度策略(或算法)。策略以及分配给给定任务(进程或线程)的优先级是基于每个线程的,其中默认值始终是SCHED_OTHER策略,实时优先级为0

在给定的 Linux 系统上,我们总是可以看到所有活动的进程(通过简单的ps -A),甚至可以看到每个活动的线程(使用 GNU psps -LA)。但这并不告诉我们这些任务正在运行的调度策略和优先级;我们如何查询呢?

这其实很简单:在 shell 上,chrt(1)实用程序非常适合查询和设置给定进程的调度策略和/或优先级。使用-p选项开关发出chrt并提供 PID 作为参数,它将显示所讨论任务的调度策略以及实时优先级;例如,让我们查询init进程(或 systemd)的 PID1的情况:

$ chrt -p 1 
pid 1's current scheduling policy: SCHED_OTHER 
pid 1's current scheduling priority: 0 
$ 

像往常一样,chrt(1)man页面提供了所有选项开关及其用法;请查看一下。

在以下(部分)屏幕截图中,我们展示了一个简单的 Bash 脚本(ch10/query_task_sched.sh,本质上是chrt的包装器),它查询并显示了所有活动线程(在运行时)的调度策略和实时优先级:

图 10.5 - 我们的 ch10/query_task_sched.sh Bash 脚本的(部分)屏幕截图

一些需要注意的事项:

  • 在我们的脚本中,通过使用 GNU ps(1),使用ps -LA,我们能够捕获系统上所有活动的线程;它们的 PID 和 TID 都会显示出来。正如您在第六章中学到的,内核内部基础知识-进程和线程,PID 是内核 TGID 的用户空间等价物,而 TID 是内核 PID 的用户空间等价物。因此,我们可以得出以下结论:

  • 如果 PID 和 TID 匹配,那么它 - 在该行中看到的线程(第三列有它的名称) - 是该进程的主线程。

  • 如果 PID 和 TID 匹配,并且 PID 只出现一次,那么它是一个单线程进程。

  • 如果我们在左侧列中多次具有相同的 PID(最左侧列)和不同的 TID(第二列),那么这些是该进程的子线程(或工作线程)。我们的脚本通过将 TID 号稍微向右缩进来显示这一点。

  • 请注意,在典型的 Linux 系统(甚至是嵌入式系统)上,绝大多数线程都倾向于是非实时的(SCHED_OTHER策略)。在典型的桌面、服务器甚至嵌入式 Linux 上,大多数线程都是SCHED_OTHER(默认策略),只有少数实时线程(FIFO/RR)。DeadlineDL)和Stop-SchedSS)线程确实非常罕见。

  • 请注意以下关于前述输出中出现的实时线程的观察:

  • 我们的脚本通过在极右边显示一个星号来突出显示任何实时线程(具有策略:SCHED_FIFOSCHED_RR)。

  • 此外,任何实时优先级为 99(最大可能值)的实时线程将在极右边有三个星号(这些往往是专门的内核线程)。

  • 当与调度策略进行布尔 OR 运算时,SCHED_RESET_ON_FORK标志会禁止任何子进程(通过fork(2))继承特权调度策略(这是一项安全措施)。

  • 更改线程的调度策略和/或优先级可以使用chrt(1)来执行;但是,您应该意识到这是一个需要 root 权限的敏感操作(或者,现在应该是首选机制的能力模型,CAP_SYS_NICE能力位是相关的能力)。

我们将让您自行查看脚本(ch10/query_task_sched.sh)的代码。另外,请注意(注意!)性能和 shell 脚本实际上并不搭配(所以在性能方面不要期望太多)。想一想,shell 脚本中的每个外部命令(我们这里有几个,如awkgrepcut)都涉及到 fork-exec-wait 语义和上下文切换。而且,这些都是在循环中执行的。

tuna(8)程序可用于查询和设置各种属性;这包括进程/线程级别的调度策略/优先级和 CPU 亲和力掩码,以及中断请求(IRQ)亲和力。

你可能会问,具有SCHED_FIFO策略和实时优先级99的(少数)线程是否总是占用系统的处理器?实际上并不是;事实是这些线程大部分时间都是睡眠的。当内核确实需要它们执行一些工作时,它会唤醒它们。由于它们的实时策略和优先级,几乎可以保证它们将获得 CPU 并执行所需的时间(工作完成后再次进入睡眠状态)。关键是:当它们需要处理器时,它们将得到(类似于实时操作系统,但没有实时操作系统提供的铁定保证和确定性)。

chrt(1)实用程序如何查询(和设置)实时调度策略/优先级?嗯,这显而易见:由于它们驻留在内核虚拟地址空间(VAS)中的任务结构中,chrt进程必须发出系统调用。有几种系统调用变体执行这些任务:chrt(1)使用的是sched_getattr(2)进行查询,sched_setattr(2)系统调用用于设置调度策略和优先级。(务必查阅sched(7)手册页,了解更多与调度程序相关的系统调用的详细信息。)对chrt进行快速的strace(1)将验证这一点!

$ strace chrt -p 1
[ ... ] 
sched_getattr(1, {size=48, sched_policy=SCHED_OTHER, sched_flags=0, 
sched_nice=0, sched_priority=0, sched_runtime=0, sched_deadline=0, 
sched_period=0}, 48, 0) = 0 
fstat(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 6), ...}) = 0 
write(1, "pid 1's current scheduling polic"..., 47) = 47 
write(1, "pid 1's current scheduling prior"..., 39) = 39 
[ ... ] $ 

现在你已经掌握了查询(甚至设置)线程调度策略/优先级的实际知识,是时候深入一点了。在接下来的部分中,我们将进一步探讨 Linux CPU 调度程序的内部工作原理。我们将弄清楚谁运行调度程序的代码以及何时运行。好奇吗?继续阅读!

了解 CPU 调度内部 - 第三部分

在前面的部分中,你学到了核心内核调度代码嵌入了void schedule(void)函数中,并且模块化调度器类被迭代,最终选择一个线程进行上下文切换。这一切都很好;现在一个关键问题是:schedule()代码路径由谁和何时运行?

谁运行调度器代码?

关于调度工作方式的一个微妙但关键的误解不幸地被许多人持有:我们想象存在一种称为“调度程序”的内核线程(或类似实体),定期运行和调度任务。这是完全错误的;在像 Linux 这样的单内核操作系统中,调度是由进程上下文自身执行的,即在 CPU 上运行的常规线程!

实际上,调度代码总是由当前执行内核代码的进程上下文运行,换句话说,由current运行。

这也可能是一个适当的时机来提醒您 Linux 内核的一个“黄金规则”:调度代码绝对不能在任何原子或中断上下文中运行。换句话说,中断上下文代码必须保证是非阻塞的;这就是为什么你不能在中断上下文中使用GFP_KERNEL标志调用kmalloc() - 它可能会阻塞!但是使用GFP_ATOMIC标志就可以,因为这指示内核内存管理代码永远不会阻塞。此外,当调度代码运行时,内核抢占被禁用;这是有道理的。

调度程序何时运行?

操作系统调度程序的工作是在竞争使用处理器(CPU)资源的线程之间进行仲裁,共享处理器资源。但是如果系统很忙,有许多线程不断竞争和获取处理器呢?更准确地说,为了确保任务之间公平共享 CPU 资源,必须确保图片中的警察,即调度程序本身,定期在处理器上运行。听起来不错,但你究竟如何确保呢?

这是一个(看似)合乎逻辑的方法:当定时器中断触发时调用调度程序;也就是说,它每秒有CONFIG_HZ次运行的机会(通常设置为值 250)!不过,我们在第八章中学到了一个黄金法则,模块作者的内核内存分配 - 第一部分,在永远不要在中断或原子上下文中休眠部分:你不能在任何类型的原子或中断上下文中调用调度程序;因此,在定时器中断代码路径中调用它是被明确禁止的。那么,操作系统该怎么办呢?

实际上的做法是,定时器中断上下文和进程上下文代码路径都用于进行调度工作。我们将在下一节简要描述详细信息。

定时器中断部分

在定时器中断中(在kernel/sched/core.c:scheduler_tick()的代码中,其中中断被禁用),内核执行必要的元工作,以保持调度平稳运行;这涉及到适当地不断更新每个 CPU 的运行队列,负载平衡工作等。请注意,实际上从不在这里调用schedule()函数。最多,调度类钩子函数(对于被中断的进程上下文currentsched_class:task_tick(),如果非空,将被调用。例如,对于属于公平(CFS)类的任何线程,在task_tick_fair()中会在这里更新vruntime成员(虚拟运行时间,任务在处理器上花费的(优先级偏置)时间)。

更具体地说,前面段落中描述的所有工作都发生在定时器中断软中断TIMER_SOFTIRQ中。

现在,一个关键点,就是调度代码决定:我们是否需要抢占current?在定时器中断代码路径中,如果内核检测到当前任务已超过其时间量子,或者出于任何原因必须被抢占(也许现在运行队列上有另一个具有更高优先级的可运行线程),代码会设置一个名为need_resched的“全局”标志。(我们在“全局”一词中加引号的原因是它实际上并不是真正的全局内核;它实际上只是current实例的thread_info->flags位掩码中的一个位,名为TIF_NEED_RESCHED。为什么?这样访问位实际上更快!)值得强调的是,在典型(可能)情况下,不会有必要抢占current,因此thread_info.flags:TIF_NEED_RESCHED位将保持清除。如果设置,调度程序激活将很快发生;但具体何时发生?请继续阅读...

进程上下文部分

一旦刚刚描述的调度工作的定时器中断部分完成(当然,这些事情确实非常迅速地完成),控制权就会交回到进程上下文(线程current)中,它会运行我们认为是从中断中退出的路径。在这里,它会检查TIF_NEED_RESCHED位是否已设置 - need_resched()辅助例程会执行此任务。如果返回True,这表明需要立即进行重新调度:内核将调用schedule()!在这里,这样做是可以的,因为我们现在正在运行进程上下文。(请牢记:我们在这里谈论的所有代码都是由current,即相关的进程上下文运行的。)

当然,现在关键问题是代码的确切位置,该代码将识别TIF_NEED_RESCHED位是否已被设置(由先前描述的定时器中断部分)?啊,这就成了问题的关键:内核安排了内核代码基础中存在几个调度机会点。两个调度机会点如下:

  • 从系统调用代码路径返回。

  • 从中断代码路径返回。

所以,请考虑一下:每当运行在用户空间的任何线程发出系统调用时,该线程就会(上下文)切换到内核模式,并在内核中以内核特权运行代码。当然,系统调用是有限长度的;完成后,它们将遵循一个众所周知的返回路径,以便切换回用户模式并在那里继续执行。在这个返回路径上,引入了一个调度机会点:检查其thread_info结构中的TIF_NEED_RESCHED位是否设置。如果是,调度器就会被激活。

顺便说一句,执行此操作的代码是与体系结构相关的;在 x86 上是这里:arch/x86/entry/common.c:exit_to_usermode_loop()。在其中,与我们相关的部分是:

static void exit_to_usermode_loop(struct pt_regs *regs, u32 cached_flags)
{
[...]
 if (cached_flags & _TIF_NEED_RESCHED)
 schedule();

类似地,在处理(任何)硬件中断之后(和任何需要运行的相关软中断处理程序),在内核中的进程上下文切换回之后(内核中的一个工件——irq_exit()),但在恢复中断的上下文之前,内核检查TIF_NEED_RESCHED位:如果设置了,就调用schedule()

让我们总结一下关于设置和识别TIF_NEED_RESCHED位的前面讨论:

  • 定时器中断(软中断)在以下情况下设置thread_info:flags TIF_NEED_RESCHED位:

  • 如果调度类的scheduler_tick()钩子函数内的逻辑需要抢占;例如,在 CFS 上,如果当前任务的vruntime值超过另一个可运行线程的给定阈值(通常为 2.25 毫秒;相关的可调参数是/proc/sys/kernel/sched_min_granularity_ns)。

  • 如果一个更高优先级的线程可运行(在同一个 CPU 和运行队列上;通过try_to_wake_up())。

  • 在进程上下文中,发生了这样的事情:在中断返回和系统调用返回路径上,检查TIF_NEED_RESCHED的值:

  • 如果设置为(1),则调用schedule();否则,继续处理。

顺便说一句,这些调度机会点——从硬件中断返回或系统调用——也用作信号识别点。如果current上有信号挂起,它会在恢复上下文或返回到用户空间之前得到处理。

可抢占内核

让我们来看一个假设的情况:你在一个只有一个 CPU 的系统上运行。一个模拟时钟应用程序在 GUI 上运行,还有一个 C 程序a.out,它的一行代码是(呻吟)while(1);。那么,你认为:CPU 占用者while 1进程会无限期地占用 CPU,从而导致 GUI 时钟应用程序停止滴答(它的秒针会完全停止移动吗)?

稍加思考(和实验)就会发现,尽管有一个占用 CPU 的应用程序,GUI 时钟应用程序仍在继续滴答!实际上,这才是操作系统级调度器的全部意义:它可以并且确实抢占占用 CPU 的用户空间进程。(我们之前简要讨论了 CFS 算法;CFS 将导致侵占 CPU 的进程累积一个巨大的vruntime值,从而在其 rb-tree 运行队列上向右移动更多,从而对自身进行惩罚!)所有现代操作系统都支持这种类型的抢占——它被称为用户模式抢占

但是现在,请考虑这样一个问题:如果你在单处理器系统上编写一个执行相同while(1)无限循环的内核模块会怎样?这可能是一个问题:系统现在将会简单地挂起。操作系统如何抢占自己(因为我们知道内核模块以内核特权在内核模式下运行)?好吧,你猜怎么着:多年来,Linux 一直提供了一个构建时配置选项来使内核可抢占,CONFIG_PREEMPT。(实际上,这只是朝着减少延迟和改进内核和调度器响应的长期目标的演变。这项工作的大部分来自早期和一些持续的努力:低延迟(LowLat)补丁,(旧的)RTLinux 工作等等。我们将在下一章中更多地介绍实时(RTOS)Linux - RTL。)一旦打开了CONFIG_PREEMPT内核配置选项并构建并引导内核,我们现在运行的是一个可抢占的内核——操作系统有能力抢占自己。

要查看此选项,在make menuconfig中,导航到 General Setup | Preemption Model。

基本上有三个可用的内核配置选项,就抢占而言:

抢占类型 特点 适用于
CONFIG_PREEMPT_NONE 传统模型,面向高整体吞吐量。 服务器/企业级和计算密集型系统
CONFIG_PREEMPT_VOLUNTARY 可抢占内核(桌面);操作系统内更明确的抢占机会点;导致更低的延迟,更好的应用程序响应。通常是发行版的默认设置。 用于桌面的工作站/台式机,运行 Linux 的笔记本电脑
CONFIG_PREEMPT LowLat 内核;(几乎)整个内核都是可抢占的;意味着甚至内核代码路径的非自愿抢占现在也是可能的;以稍微降低吞吐量和略微增加运行时开销为代价,产生更低的延迟(平均为几十微秒到低百微秒范围)。 快速多媒体系统(桌面,笔记本电脑,甚至现代嵌入式产品:智能手机,平板电脑等)

kernel/Kconfig.preempt kbuild 配置文件包含了可抢占内核选项的相关菜单条目。(正如你将在下一章中看到的,当将 Linux 构建为 RTOS 时,内核抢占的第四个选择出现了。)

CPU 调度器入口点

在核心内核调度函数kernel/sched/core.c:__schedule()之前的详细注释非常值得一读;它们指定了内核 CPU 调度器的所有可能入口点。我们在这里直接从 5.4 内核代码库中复制了它们,所以一定要看一下。请记住:以下代码是由即将通过上下文切换到其他线程的进程上下文中运行的!这个线程是谁?当然是current

__schedule()函数有(其他)两个本地变量,指向名为prevnexttask_struct结构体的指针。名为prev的指针设置为rq->curr,这只是current!名为next的指针将设置为即将进行上下文切换的任务,即将运行的任务!所以,你看:current运行调度器代码,执行工作,然后通过上下文切换到next将自己从处理器中踢出!这是我们提到的大评论:

// kernel/sched/core.c/*
 * __schedule() is the main scheduler function.
 * The main means of driving the scheduler and thus entering this function are:
 * 1\. Explicit blocking: mutex, semaphore, waitqueue, etc.
 *
 * 2\. TIF_NEED_RESCHED flag is checked on interrupt and user space return
 *    paths. For example, see arch/x86/entry_64.S.
 *
 *    To drive preemption between tasks, the scheduler sets the flag in timer
 *    interrupt handler scheduler_tick().
 *
 * 3\. Wakeups don't really cause entry into schedule(). They add a
 *    task to the run-queue and that's it.
 *
 *    Now, if the new task added to the run-queue preempts the current
 *    task, then the wakeup sets TIF_NEED_RESCHED and schedule() gets
 *    called on the nearest possible occasion:
 *    - If the kernel is preemptible (CONFIG_PREEMPTION=y):
 *
 *    - in syscall or exception context, at the next outmost
 *      preempt_enable(). (this might be as soon as the wake_up()'s
 *      spin_unlock()!)
 *
 *    - in IRQ context, return from interrupt-handler to
 *      preemptible context
 *
 *    - If the kernel is not preemptible (CONFIG_PREEMPTION is not set)
 *      then at the next:
 *       - cond_resched() call
 *       - explicit schedule() call
 *       - return from syscall or exception to user-space
 *       - return from interrupt-handler to user-space
 * WARNING: must be called with preemption disabled!
 */

前面的代码是一个大评论,详细说明了内核 CPU 核心调度代码__schedule()如何被调用。__schedule()本身的一些相关片段可以在以下代码中看到,重申了我们一直在讨论的要点:

static void __sched notrace __schedule(bool preempt)
{
    struct task_struct *prev, *next;
    [...] struct rq *rq;
    int cpu;

    cpu = smp_processor_id();
    rq = cpu_rq(cpu);
    prev = rq->curr;                 *<< this is 'current' ! >>*

    [ ... ]

    next = pick_next_task(rq, prev, &rf);  *<< here we 'pick' the task to run next in an 'object-
                                          oriented' manner, as discussed earlier in detail ... >>*
    clear_tsk_need_resched(prev);
    clear_preempt_need_resched();

    if (likely(prev != next)) {
        [ ... ]
        /* Also unlocks the rq: */
        rq = context_switch(rq, prev, next, &rf);
    [ ... ]
}

接下来是关于实际上下文切换的简短说明。

上下文切换

最后,简要介绍一下(调度程序)上下文切换。上下文切换(在 CPU 调度程序的上下文中)的工作非常明显:在简单地切换到下一个任务之前,操作系统必须保存先前任务的状态,也就是当前正在执行的任务的状态;换句话说,current的状态。您会回忆起第六章中所述,内核内部要点-进程和线程,任务结构包含一个内联结构,用于存储/检索线程的硬件上下文;它是成员struct thread_struct thread(在 x86 上,它始终是任务结构的最后一个成员)。在 Linux 中,一个内联函数,kernel/sched/core.c:context_switch(),执行了这项工作,从prev任务(也就是从current)切换到next任务,即本次调度轮或抢占的赢家。这个切换基本上是在两个(特定于体系结构)阶段中完成的。

  • 内存(MM)切换:将特定于体系结构的 CPU 寄存器切换到next的内存描述符结构(struct mm_struct)。在 x86[_64]上,此寄存器称为CR3控制寄存器 3);在 ARM 上,它称为TTBR0翻译表基址寄存器0)寄存器。

  • 实际的 CPU 切换:通过保存prev的堆栈和 CPU 寄存器状态,并将next的堆栈和 CPU 寄存器状态恢复到处理器上,从prev切换到next;这是在switch_to()宏内完成的。

上下文切换的详细实现不是我们将在这里涵盖的内容;请查看进一步阅读部分以获取更多资源。

总结

在本章中,您了解了多功能 Linux 内核 CPU 调度程序的几个领域和方面。首先,您看到实际的 KSE 是一个线程而不是一个进程,然后了解了操作系统实现的可用调度策略。接下来,您了解到为了以出色的可扩展方式支持多个 CPU,内核使用了每个调度类别每个 CPU 核心一个运行队列的设计。然后介绍了如何查询任何给定线程的调度策略和优先级,以及 CPU 调度程序的内部实现的更深层细节。我们重点介绍了现代调度程序如何利用模块化调度类别设计,实际运行调度程序代码的人员以及何时运行,最后简要介绍了上下文切换。

下一章将让您继续这个旅程,更深入地了解内核级 CPU 调度程序的工作原理。我建议您首先充分消化本章的内容,解决所提出的问题,然后再继续下一章。干得好!

问题

最后,这里有一些问题供您测试对本章材料的了解:github.com/PacktPublishing/Linux-Kernel-Programming/tree/master/questions。您会发现一些问题的答案在书的 GitHub 存储库中:github.com/PacktPublishing/Linux-Kernel-Programming/tree/master/solutions_to_assgn

进一步阅读

为了帮助您深入研究有用的材料,我们在本书的 GitHub 存储库的进一步阅读文档中提供了一个相当详细的在线参考和链接列表(有时甚至包括书籍)。进一步阅读文档在这里可用:github.com/PacktPublishing/Linux-Kernel-Programming/raw/master/Further_Reading.md

第十一章:CPU 调度程序-第二部分

在我们的第二章中,我们继续讨论 Linux 内核 CPU 调度程序,延续了上一章的内容。在上一章中,我们涵盖了关于 Linux 操作系统 CPU 调度程序工作(和可视化)的几个关键领域。这包括关于 Linux 上的 KSE 是什么,Linux 实现的 POSIX 调度策略,使用perf来查看调度程序流程,以及现代调度程序设计是基于模块化调度类的。我们还介绍了如何查询任何线程的调度策略和优先级(使用一些命令行实用程序),并深入了解了操作系统调度程序的内部工作。

有了这些背景,我们现在准备在 Linux 上更多地探索 CPU 调度程序;在本章中,我们将涵盖以下领域:

  • 使用 LTTng 和trace-cmd可视化流程

  • 理解、查询和设置 CPU 亲和性掩码

  • 查询和设置线程的调度策略和优先级

  • 使用 cgroups 控制 CPU 带宽

  • 将主线 Linux 转换为 RTOS

  • 延迟及其测量

我们期望您在阅读本章之前已经阅读过(或具有相应的知识)之前的章节。

技术要求

我假设您已经阅读了(或具有相应的知识)之前的章节第一章 内核工作空间设置,并已经适当准备了一个运行 Ubuntu 18.04 LTS(或更高版本)的客户虚拟机VM)并安装了所有必需的软件包。如果没有,我强烈建议您首先这样做。

为了充分利用本书,我强烈建议您首先设置工作环境,包括克隆本书的 GitHub 存储库以获取代码,并进行实际操作。存储库可以在这里找到:github.com/PacktPublishing/Linux-Kernel-Programming

使用 LTTng 和 trace-cmd 可视化流程

在上一章中,我们看到了如何使用perf(和一些替代方案)可视化线程在处理器上的流动。现在,我们将使用更强大、更直观的性能分析工具来做到这一点:使用 LTTng(和 Trace Compass GUI)以及trace-cmd(一个 Ftrace 前端和 KernelShark GUI)。

请注意,这里的意图是仅介绍您这些强大的跟踪技术;我们没有足够的范围或空间来充分涵盖这些主题。

使用 LTTng 和 Trace Compass 进行可视化

Linux Trace Toolkit Next GenerationLTTng)是一组开源工具,使您能够同时跟踪用户空间和内核空间。有点讽刺的是,跟踪内核很容易,而跟踪用户空间(应用程序、库甚至脚本)需要开发人员手动将仪器插入应用程序(所谓的 tracepoints)(内核的 tracepoint 仪器由 LTTng 作为内核模块提供)。高质量的 LTTng 文档可以在这里在线获得:lttng.org/docs/v2.12/(截至撰写本文时,覆盖版本 2.12)。

我们在这里不涵盖 LTTng 的安装;详细信息可在lttng.org/docs/v2.12/#doc-installing-lttng找到。一旦安装完成(它有点庞大-在我的本机 x86_64 Ubuntu 系统上,有超过 40 个与 LTTng 相关的内核模块加载!),使用 LTTng-就像我们在这里做的系统范围内的内核会话-是容易的,并且分为两个明显的阶段:记录,然后是数据分析;这些步骤如下。(由于本书专注于内核开发,我们不涵盖使用 LTTng 跟踪用户空间应用程序。)

使用 LTTng 记录内核跟踪会话

您可以按照以下方式记录系统范围内的内核跟踪会话(在这里,我们故意保持讨论尽可能简单):

  1. 创建一个新会话,并将输出目录设置为<dir>以保存跟踪元数据:
sudo lttng create <session-name> --output=<dir>
  1. 只需启用所有内核事件(可能会导致生成大量跟踪元数据):
sudo lttng enable-event --kernel --all

  1. 开始记录“内核会话”:
sudo lttng start

允许一些时间流逝(您跟踪的时间越长,跟踪元数据使用的磁盘空间就越多)。在此期间,LTTng 正在记录所有内核活动。

  1. 停止记录:
sudo lttng stop
  1. 销毁会话;不用担心,这不会删除跟踪元数据:
sudo lttng destroy

所有前面的命令都应该以管理员权限(或等效权限)运行。

我有一些包装脚本可以进行跟踪(LTTng、Ftrace、trace-cmd),在github.com/kaiwan/L5_debug_trg/tree/master/kernel_debug/tracing中查看。

跟踪元数据文件(以通用跟踪格式CTF)文件格式)保存到前面指定的输出目录。

使用 GUI 进行报告 - Trace Compass

数据分析可以通过两种方式进行 - 使用通常与 LTTng 捆绑在一起的基于 CLI 的系统babeltrace,或者通过一个复杂的 GUI 称为Trace Compass。GUI 更具吸引力;我们这里只展示了它的基本用法。

Trace Compass 是一个功能强大的跨平台 GUI 应用程序,并且与 Eclipse 集成得很好。实际上,我们直接引用自 Eclipse Trace Compass 网站(projects.eclipse.org/projects/tools.tracecompass):

Eclipse Trace Compass 是一个开源应用程序,通过读取和分析系统的日志或跟踪来解决性能和可靠性问题。它的目标是提供视图、图形、指标等,以帮助从跟踪中提取有用信息,这种方式比庞大的文本转储更加用户友好和信息丰富。

它可以从这里下载(和安装):www.eclipse.org/tracecompass/

Trace Compass 最低需要安装Java Runtime EnvironmentJRE)。我在我的 Ubuntu 20.04 LTS 系统上安装了一个,使用sudo apt install openjdk-14-jre

安装完成后,启动 Trace Compass,单击“文件”|“打开跟踪”菜单,并导航到您在前面步骤中保存跟踪会话的跟踪元数据的输出目录。Trace Compass 将读取元数据并以可视化方式显示,以及提供各种透视图和工具视图。我们的简短系统范围内的内核跟踪会话的部分屏幕截图显示在这里(图 11.1);您可以清楚地看到上下文切换(显示为sched_switch事件 - 请参阅事件类型列)从gnome-shell进程到swapper/1内核线程(在 CPU#1 上运行的空闲线程):

图 11.1 - Trace Compass GUI 显示通过 LTTng 获得的示例内核跟踪会话

仔细看前面的屏幕截图(图 11.1);在下方的水平窗格中,不仅可以看到执行的内核函数,可以(在标签为内容的列下)看到每个参数在那个时间点的值!这确实非常有用。

使用 trace-cmd 进行可视化

现代 Linux 内核(从 2.6.27 开始)嵌入了一个非常强大的跟踪引擎,称为Ftrace。Ftrace 是用户空间strace(1)实用程序的粗糙内核等效物,但这样说有点贬低它了!Ftrace 允许系统管理员(或开发人员、测试人员,或任何具有 root 权限的人)直接查看内核空间中执行的每个函数,执行它的是谁(哪个线程),运行时间有多长,它调用了哪些 API,包括发生的中断(硬中断和软中断),各种类型的延迟测量等等。您可以使用 Ftrace 了解系统实用程序、应用程序和内核的实际工作原理,以及在操作系统级别执行深度跟踪。

在这本书中,我们不深入研究原始 Ftrace 的用法(因为这偏离了手头的主题);相反,使用一个用户空间包装器覆盖 Ftrace,一个更方便的接口,称为trace-cmd(1),只是更快更容易(再次强调,我们只是浅尝辄止,展示了trace-cmd的一个示例)。

对于 Ftrace 的详细信息和用法,感兴趣的读者会发现这个内核文档有用:www.kernel.org/doc/Documentation/trace/ftrace.rst

大多数现代 Linux 发行版都允许通过其软件包管理系统安装trace-cmd;例如,在 Ubuntu 上,sudo apt install trace-cmd就足以安装它(如果需要在自定义的 Linux 上,比如 ARM,您总是可以从其 GitHub 存储库上的源代码进行交叉编译:git.kernel.org/pub/scm/linux/kernel/git/rostedt/trace-cmd.git/tree/)。

让我们进行一个简单的trace-cmd会话;首先,我们将在运行ps(1)实用程序时记录数据样本;然后,我们将通过trace-cmd report命令行界面(CLI)以及一个名为 KernelShark 的 GUI 前端来检查捕获的数据(它实际上是trace-cmd包的一部分)。

使用 trace-cmd record 记录一个示例会话

在本节中,我们使用trace-cmd(1)记录一个会话;我们使用了一些(许多可能的)选项开关来记录trace-cmd;通常,trace-cmd-foo(1)(用check-eventshistrecordreportreset等替换foo)的 man 页面非常有用,可以找到各种选项开关和用法详情。特别适用于trace-cmd record的一些有用选项开关如下:

  • -o:指定输出文件名(如果未指定,则默认为trace.dat)。

  • -p:要使用的插件之一,如functionfunction_graphpreemptirqsoffirqsoffpreemptoffwakeup;在我们的小型演示中,我们使用了function-graph插件(内核中还可以配置其他几个插件)。

  • -F:要跟踪的命令(或应用程序);这非常有用,可以让您精确指定要独占跟踪的进程(或线程)(否则,跟踪所有线程在尝试解密输出时可能会产生大量噪音);同样,您可以使用-P选项开关来指定要跟踪的 PID。

  • -r priority:以指定的实时优先级运行trace-cmd线程(典型范围为 1 到 99;我们将很快介绍查询和设置线程的调度策略和优先级);这样可以更好地捕获所需的样本。

在这里,我们进行了一个快速演示:我们运行ps -LA;在运行时,所有内核流量都(独占地)由trace-cmd通过其record功能捕获(我们使用了function-graph插件):

$ sudo trace-cmd record -o trace_ps.dat -r 99 -p function_graph -F ps -LA
plugin 'function_graph'
PID     LWP TTY         TIME CMD
 1        1   ?     00:01:42 systemd
 2        2   ?     00:00:00 kthreadd
[ ... ]
32701   734 tty2   00:00:00 ThreadPoolForeg
CPU 2: 48176 events lost
CPU0 data recorded at offset=0x761000
[ ... ]
CPU3 data recorded at offset=0xf180000
114688 bytes in size
$ ls -lh trace_ps.dat
-rw-r--r-- 1 root root 242M Jun 25 11:23 trace_ps.dat
$

结果是一个相当大的数据文件(因为我们捕获了所有事件并且进行了ps -LA显示所有活动线程,所以花了一些时间,因此捕获的数据样本相当大。还要意识到,默认情况下,内核跟踪是在系统上的所有 CPU 上执行的;您可以通过-M cpumask选项进行更改)。

在上面的示例中,我们捕获了所有事件。-e选项开关允许您指定要跟踪的事件类别;例如,要跟踪ping(1)实用程序并仅捕获与网络和内核内存相关的事件,请运行以下命令:

sudo trace-cmd record -e kmem -e net -p function_graph -F ping -c1 packtpub.com

使用 trace-cmd report(CLI)进行报告和解释

从前一节继续,在命令行上,我们可以得到一个(非常!)详细的报告,说明了ps进程运行时内核中发生了什么;使用trace-cmd report命令来查看这个。我们还传递了-l选项开关:它以 Ftrace 的延迟格式显示报告,显示了许多有用的细节;-i开关当然指定了要使用的输入文件:

trace-cmd report -i ./trace_ps.dat -l > report_tc_ps.txt 

现在变得非常有趣!我们展示了我们用vim(1)打开的(巨大)输出文件的一些部分截图;首先我们有以下内容:

图 11.2 - 显示 trace-cmd 报告输出的部分屏幕截图

看看图 11.2;对内核 APIschedule()的调用被故意突出显示并以粗体字显示(图 11.2,在第785303行!)。为了解释这一行上的所有内容,我们必须理解每个(以空格分隔的)列;共有八列:

  • 第一列:这里只是vim显示的文件中的行号(让我们忽略它)。

  • 第二列:这是调用此函数的进程上下文(函数本身在第 8 列);显然,在这里,进程是ps-PID(其 PID 在-字符后附加)。

  • 第三列:有用!一系列五个字符,显示为延迟格式(我们使用了-l选项切换到trace-cmd record,记住!);这(在我们之前的情况下,是2.N..)非常有用,可以解释如下:

  • 第一个字符是它运行的 CPU 核心(所以这里是核心#2)(请注意,作为一个一般规则,除了第一个字符外,如果字符是一个句点,它意味着它是零或不适用)。

  • 第二个字符代表硬件中断状态:

  • . 意味着默认的硬件中断被启用。

  • d 意味着硬件中断当前被禁用。

  • 第三个字符代表了need_resched位(我们在前一章节中解释过,在调度程序何时运行?部分):

  • . 意味着它被清除。

  • N 意味着它被设置(这意味着内核需要尽快执行重新调度!)。

  • 第四个字符只有在中断正在进行时才有意义,否则,它只是一个,意味着我们处于进程上下文中;如果中断正在进行 - 意味着我们处于中断上下文中 - 其值是以下之一:

  • h 意味着我们正在执行硬中断(或者顶半部中断)上下文。

  • H 意味着我们正在软中断中发生的硬中断中执行。

  • s 意味着我们正在软中断(或者底半部)中断上下文中执行。

  • 第五个字符代表抢占计数或深度;如果是,它是零,意味着内核处于可抢占状态;如果不为零,会显示一个整数,意味着已经获取了那么多内核级别的锁,迫使内核进入不可抢占状态。

  • 顺便说一句,输出与 Ftrace 的原始输出非常相似,只是在原始 Ftrace 的情况下,我们只会看到四个字符 - 第一个字符(CPU 核心编号)在这里不会显示;它显示为最左边的列;这是原始 Ftrace(而不是trace-cmd)延迟格式的部分屏幕截图:

图 11.3 - 专注于原始 Ftrace 的四字符延迟格式(第四字段)的部分屏幕截图

前面的屏幕截图直接从原始 Ftrace 输出中整理出来。

    • 因此,解释我们对schedule()调用的例子,我们可以看到字符是2.N..,意味着进程ps的 PID 为22922在 CPU 核心#2 上执行在进程上下文中(没有中断),并且need-resched(技术上,thread_info.flags:TIF_NEED_RESCHED)位被设置(表示需要尽快重新调度!)。
  • (现在回到图 11.2 中的剩余列)

第四列:以秒:微秒格式的时间戳。

  • 第 5 列:发生的事件的名称(在这里,我们使用了function_graph插件,它将是funcgraph_entryfungraph_exit,分别表示函数的进入或退出)。

  • 第 6 列[可选]:前一个函数调用的持续时间,显示了所花费的时间及其单位(us = 微秒);前缀字符用于表示函数执行时间很长(我们简单地将其视为此列的一部分);来自内核 Ftrace 文档(这里:www.kernel.org/doc/Documentation/trace/ftrace.rst),我们有以下内容:

  • +,这意味着一个函数超过了 10 微秒

  • !,这意味着一个函数超过了 100 微秒

  • #,这意味着一个函数超过了 1,000 微秒

  • *,这意味着一个函数超过了 10 毫秒

  • @,这意味着一个函数超过了 100 毫秒

  • $,这意味着一个函数超过了 1 秒

  • 第 7 列:只是分隔符|

  • 第 8 列:极右列是正在执行的内核函数的名称;右边的开括号{表示刚刚调用了该函数;只有一个闭括号}的列表示前一个函数的结束(与开括号匹配)。

这种详细程度在排除内核(甚至用户空间)问题和深入了解内核流程方面非常有价值。

当使用trace-cmd record而没有使用-p function-graph选项开关时,我们失去了漂亮的缩进函数调用图形式的输出,但我们也得到了一些东西:现在你将看到每个函数调用右侧的所有函数参数及其运行时值!这在某些时候确实是一个非常有价值的辅助工具。

我忍不住想展示同一份报告中的另一个片段 - 另一个关于我们在现代 Linux 上学到的调度类如何工作的有趣例子(在上一章中介绍过);这实际上在trace-cmd输出中显示出来了:

图 11.4 - trace-cmd报告输出的部分截图

仔细解释前面的截图(图 11.4):第二行(右侧函数名列为粗体字体,紧随其后的两个函数也是如此)显示了pick_next_task_stop()函数被调用;这意味着发生了一次调度,内核中的核心调度代码按照优先级顺序遍历调度类的链表,询问每个类是否有要调度的线程;如果有,核心调度程序上下文切换到它(正如在前一章中详细解释的那样,在模块化调度类部分)。

在图 11.4 中,你真的看到了这种情况发生:核心调度代码询问stop-schedSS)、deadlineDL)和real-timeRT)类是否有任何想要运行的线程,依次调用pick_next_task_stop()pick_next_task_dl()pick_next_task_rt()函数。显然,对于所有这些类,答案都是否定的,因为接下来要运行的函数是公平(CFS)类的函数(为什么pick_next_task_fair()函数在前面的截图中没有显示呢?啊,这又是代码优化:内核开发人员知道这是可能的情况,他们会直接调用公平类代码大部分时间)。

我们在这里介绍的强大的 Ftrace 框架和trace-cmd实用程序只是基础;我建议你查阅trace-cmd-<foo>(其中<foo>被替换为recordreport等)的 man 页面,那里通常会显示很好的示例。此外,关于 Ftrace(和trace-cmd)还有一些非常好的文章 - 请参考进一步阅读部分。

使用 GUI 前端进行报告和解释

更多好消息:trace-cmd工具集包括一个 GUI 前端,用于更人性化的解释和分析,称为 KernelShark(尽管在我看来,它不像 Trace Compass 那样功能齐全)。在 Ubuntu/Debian 上安装它就像执行sudo apt install kernelshark一样简单。

下面,我们运行kernelshark,将我们之前的trace-cmd记录会话的跟踪数据文件输出作为参数传递给它(将参数调整为 KernelShark 所在位置,以引用您保存跟踪元数据的位置):

$ kernelshark ./trace_ps.dat

显示了运行前述跟踪数据的 KernelShark 的屏幕截图:

图 11.5 - 显示先前捕获的数据的 kernelshark GUI 的屏幕截图

有趣的是,ps进程在 CPU#2 上运行(正如我们之前在 CLI 版本中看到的)。在这里,我们还可以看到在较低的平铺水平窗格中执行的函数;例如,我们已经突出显示了pick_next_task_fair()的条目。列是相当明显的,Latency列格式(四个字符,而不是五个)的解释如我们之前为(原始)Ftrace 解释的那样。

快速测验:在图 11.5 中看到的 Latency 格式字段dN..意味着什么?

答案:它意味着,当前,我们有以下情况:

  • 第一列 d:硬件中断被禁用。

  • 第二列 Nneed_resched位被设置(暗示需要在下一个可用的调度机会点调用调度程序)。

  • 第三列 .:内核pick_next_task_fair()函数的代码正在进程上下文中运行(任务是ps,PID 为22545;记住,Linux 是一个单内核!)。

  • 第四列 .:抢占深度(计数)为零,暗示内核处于可抢占状态。

现在我们已经介绍了使用这些强大工具来帮助生成和可视化与内核执行和调度相关的数据,让我们继续下一个领域:在下一节中,我们将专注于另一个重要方面 - 线程的 CPU 亲和性掩码到底是什么,以及如何以编程方式(以及其他方式)获取/设置它。

理解、查询和设置 CPU 亲和性掩码

任务结构是一个根数据结构,包含几十个线程属性,其中有一些属性直接与调度有关:优先级(nice以及 RT 优先级值),调度类结构指针,线程所在的运行队列(如果有的话),等等。

其中一个重要成员是CPU 亲和性位掩码(实际的结构成员是cpumask_t cpus_allowed)。这也告诉你 CPU 亲和性位掩码是每个线程的数量;这是有道理的 - 在 Linux 上,KSE 是一个线程。它本质上是一个位数组,每个位代表一个 CPU 核心(在变量内有足够的位可用);如果对应于核心的位被设置(1),则允许在该核心上调度和执行线程;如果清除(0),则不允许。

默认情况下,所有 CPU 亲和性掩码位都被设置;因此,线程可以在任何核心上运行。例如,在一个有(操作系统看到的)四个 CPU 核心的盒子上,每个线程的默认 CPU 亲和性位掩码将是二进制11110xf)。(看一下图 11.6,看看 CPU 亲和性位掩码的概念上是什么样子。)

在运行时,调度程序决定线程实际上将在哪个核心上运行。事实上,想想看,这真的是隐含的:默认情况下,每个 CPU 核心都有一个与之关联的运行队列;每个可运行的线程将在单个 CPU 运行队列上;因此,它有资格运行,并且默认情况下在表示它的运行队列的 CPU 上运行。当然,调度程序有一个负载平衡器组件,可以根据需要将线程迁移到其他 CPU 核心(实际上是运行队列)(称为migration/n的内核线程在这个任务中协助)。

内核确实向用户空间暴露了 API(系统调用,当然,sched_{s,g}etaffinity(2)及其pthread包装库 API),这允许应用程序根据需要将线程(或多个线程)关联到特定的 CPU 核心上(按照相同的逻辑,我们也可以在内核中为任何给定的内核线程执行此操作)。例如,将 CPU 亲和性掩码设置为1010二进制,相当于十六进制的0xa,意味着该线程只能在 CPU 核心一和三上执行(从零开始计数)。

一个关键点:尽管您可以操纵 CPU 亲和性掩码,但建议避免这样做;内核调度程序详细了解 CPU 拓扑,并且可以最佳地平衡系统负载。

尽管如此,显式设置线程的 CPU 亲和性掩码可能是有益的,原因如下:

  • 通过确保线程始终在同一 CPU 核心上运行,可以大大减少缓存失效(从而减少不愉快的缓存“跳动”)。

  • 核心之间的线程迁移成本被有效地消除。

  • CPU 保留——一种策略,通过保证所有其他线程明确不允许在该核心上执行,将核心(或核心)专门分配给一个线程。

前两者在某些特殊情况下很有用;第三个,CPU 保留,往往是在一些时间关键的实时系统中使用的一种技术,其成本是合理的。但实际上,进行 CPU 保留是相当困难的,需要在(每个!)线程创建时进行操作;成本可能是禁止的。因此,这实际上是通过指定某个 CPU(或更多)从所有任务中隔离出来来实现的;Linux 内核提供了一个内核参数isolcpus来完成这项工作。

在这方面,我们直接引用了sched_{s,g}etaffinity(2)系统调用的 man 页面上的内容:

isolcpus 引导选项可用于在引导时隔离一个或多个 CPU,以便不会安排任何进程到这些 CPU 上运行。在使用此引导选项之后,将进程调度到被隔离的 CPU 的唯一方法是通过 sched_setaffinity()或 cpuset(7)机制。有关更多信息,请参阅内核源文件 Documentation/admin-guide/kernel-parameters.txt。如该文件中所述,isolcpus 是隔离 CPU 的首选机制(与手动设置系统上所有进程的 CPU 亲和性的替代方案相比)。

需要注意的是,先前提到的isolcpus内核参数现在被认为是不推荐使用的;最好使用 cgroups 的cpusets控制器代替(cpusets是一个 cgroup 特性或控制器;我们稍后在本章中会对 cgroups 进行一些介绍,在使用 cgroups 进行 CPU 带宽控制部分)。

我们建议您在内核参数文档中查看更多详细信息(在此处:www.kernel.org/doc/Documentation/admin-guide/kernel-parameters.txt),特别是在标记为isolcpus=的参数下。

既然你已经了解了它的理论,让我们实际编写一个用户空间 C 程序来查询和/或设置任何给定线程的 CPU 亲和性掩码。

查询和设置线程的 CPU 亲和性掩码

作为演示,我们提供了一个小型用户空间 C 程序来查询和设置用户空间进程(或线程)的 CPU 亲和性掩码。使用sched_getaffinity(2)系统调用来查询 CPU 亲和性掩码,并使用其对应的设置来设置它。

#define _GNU_SOURCE
#include <sched.h>

int sched_getaffinity(pid_t pid, size_t cpusetsize,
                        cpu_set_t *mask);
int sched_setaffinity(pid_t pid, size_t cpusetsize,
                        const cpu_set_t *mask);

一种名为cpu_set_t的专门数据类型用于表示 CPU 亲和掩码;它非常复杂:它的大小是根据系统上看到的 CPU 核心数量动态分配的。这种 CPU 掩码(类型为cpu_set_t)必须首先初始化为零;CPU_ZERO()宏可以实现这一点(还有几个类似的辅助宏;请参考CPU_SET(3)的手册页)。在前面的系统调用中的第二个参数是 CPU 集的大小(我们只需使用sizeof运算符来获取它)。

为了更好地理解这一点,值得看一下我们的代码的一个示例运行(ch11/cpu_affinity/userspc_cpuaffinity.c);我们在一个具有 12 个 CPU 核心的本机 Linux 系统上运行它:

图 11.6 - 我们的演示用户空间应用程序显示 CPU 亲和掩码

在这里,我们没有使用任何参数运行应用程序。在这种模式下,它查询自身的 CPU 亲和掩码(即userspc_cpuaffinity调用进程的亲和掩码)。我们打印出位掩码的位数:正如您在前面的屏幕截图中清楚地看到的那样,它是二进制1111 1111 1111(相当于0xfff),这意味着默认情况下该进程有资格在系统上的任何 12 个 CPU 核心上运行。

该应用程序通过有用的popen(3)库 API 运行nproc(1)实用程序来检测可用的 CPU 核心数量。请注意,nproc返回的值是调用进程可用的 CPU 核心数量;它可能少于实际的 CPU 核心数量(通常是相同的);可用核心数量可以通过几种方式进行更改,正确的方式是通过 cgroup cpuset资源控制器(我们稍后在本章中介绍一些关于 cgroups 的信息)。

查询代码如下:

// ch11/cpu_affinity/userspc_cpuaffinity.c

static int query_cpu_affinity(pid_t pid)
{
    cpu_set_t cpumask;

    CPU_ZERO(&cpumask);
    if (sched_getaffinity(pid, sizeof(cpu_set_t), &cpumask) < 0) {
        perror("sched_getaffinity() failed");
        return -1;
    }
    disp_cpumask(pid, &cpumask, numcores);
    return 0;
}

我们的disp_cpumask()函数绘制位掩码(请自行查看)。

如果传递了额外的参数 - 进程(或线程)的 PID 作为第一个参数,CPU 位掩码作为第二个参数 - 那么我们将尝试设置该进程(或线程)的 CPU 亲和掩码为传递的值。当然,更改 CPU 亲和掩码需要您拥有该进程或具有 root 权限(更正确地说,需要具有CAP_SYS_NICE权限)。

一个快速演示:在图 11.7 中,nproc(1)显示了 CPU 核心的数量;然后,我们运行我们的应用程序来查询和设置我们的 shell 进程的 CPU 亲和掩码。在笔记本电脑上,假设bash的亲和掩码一开始是0xfff(二进制1111 1111 1111),如预期的那样;我们将其更改为0xdae(二进制1101 1010 1110),然后再次查询以验证更改:

图 11.7 - 我们的演示应用程序查询然后设置 bash 的 CPU 亲和掩码为 0xdae

好的,这很有趣:首先,该应用程序正确地检测到了可用的 CPU 核心数量为 12;然后,它查询了(默认的)bash 进程的 CPU 亲和掩码(因为我们将其 PID 作为第一个参数传递);如预期的那样,它显示为0xfff。然后,因为我们还传递了第二个参数 - 要设置的位掩码(0xdae) - 它这样做了,将 bash 的 CPU 亲和掩码设置为0xdae。现在,由于我们所在的终端窗口正是这个 bash 进程,再次运行nproc会显示值为 8,而不是 12!这是正确的:bash 进程现在只有八个 CPU 核心可用。(这是因为我们在退出时没有将 CPU 亲和掩码恢复到其原始值。)

以下是设置 CPU 亲和掩码的相关代码:

// ch11/cpu_affinity/userspc_cpuaffinity.c
static int set_cpu_affinity(pid_t pid, unsigned long bitmask)
{
    cpu_set_t cpumask;
    int i;

    printf("\nSetting CPU affinity mask for PID %d now...\n", pid);
    CPU_ZERO(&cpumask);

    /* Iterate over the given bitmask, setting CPU bits as required */
    for (i=0; i<sizeof(unsigned long)*8; i++) {
        /* printf("bit %d: %d\n", i, (bitmask >> i) & 1); */
        if ((bitmask >> i) & 1)
            CPU_SET(i, &cpumask);
    }

    if (sched_setaffinity(pid, sizeof(cpu_set_t), &cpumask) < 0) {
        perror("sched_setaffinity() failed");
        return -1;
    }
    disp_cpumask(pid, &cpumask, numcores);
    return 0;
}

在前面的代码片段中,您可以看到我们首先适当地设置了cpu_set_t位掩码(通过循环遍历每个位),然后使用sched_setaffinity(2)系统调用在给定的pid上设置新的 CPU 亲和掩码。

使用 taskset(1)执行 CPU 亲和

类似于我们在前一章中使用方便的用户空间实用程序chrt(1)来获取(或设置)进程(或线程)的调度策略和/或优先级,您可以使用用户空间taskset(1)实用程序来获取和/或设置给定进程(或线程)的 CPU 亲和性掩码。以下是一些快速示例;请注意,这些示例是在一个具有 4 个 CPU 核心的 x86_64 Linux 系统上运行的:

  • 使用taskset查询 systemd(PID 1)的 CPU 亲和性掩码:
$ taskset -p 1
pid 1's current affinity mask: f 
$
  • 使用taskset确保编译器及其后代(汇编器和链接器)仅在前两个 CPU 核心上运行;taskset 的第一个参数是 CPU 亲和性位掩码(03是二进制0011):
$ taskset 03 gcc userspc_cpuaffinity.c -o userspc_cpuaffinity -Wall 

查阅taskset(1)的手册页面以获取完整的使用详情。

在内核线程上设置 CPU 亲和性掩码

例如,如果我们想演示一种称为 per-CPU 变量的同步技术,我们需要创建两个内核线程,并确保它们分别在不同的 CPU 核心上运行。为此,我们必须设置每个内核线程的 CPU 亲和性掩码(第一个设置为0,第二个设置为1,以便它们只在 CPU 01上执行)。问题是,这不是一个干净的工作 - 老实说,相当糟糕,绝对推荐。代码中的以下注释显示了原因:

  /* ch17/6_percpuvar/6_percpuvar.c */
  /* WARNING! This is considered a hack.
   * As sched_setaffinity() isn't exported, we don't have access to it
   * within this kernel module. So, here we resort to a hack: we use
   * kallsyms_lookup_name() (which works when CONFIG_KALLSYMS is defined)
   * to retrieve the function pointer, subsequently calling the function
   * via it's pointer (with 'C' what you do is only limited by your
   * imagination :).
   */
  ptr_sched_setaffinity = (void *)kallsyms_lookup_name("sched_setaffinity");

稍后,我们调用函数指针,实际上调用sched_setaffinity代码,如下所示:

    cpumask_clear(&mask);
    cpumask_set_cpu(cpu, &mask); // 1st param is the CPU number, not bitmask
    /* !HACK! sched_setaffinity() is NOT exported, we can't call it
     *   sched_setaffinity(0, &mask); // 0 => on self 
     * so we invoke it via it's function pointer */
    ret = (*ptr_sched_setaffinity)(0, &mask);   // 0 => on self

非常不寻常和有争议;它确实有效,但请在生产中避免这样的黑客行为。

现在你知道如何获取/设置线程的 CPU 亲和性掩码,让我们继续下一个逻辑步骤:如何获取/设置线程的调度策略和优先级!下一节将深入细节。

查询和设置线程的调度策略和优先级

在第十章中,CPU 调度器-第一部分,在线程-哪种调度策略和优先级部分,您学会了如何通过chrt(1)查询任何给定线程的调度策略和优先级(我们还演示了一个简单的 bash 脚本来实现)。在那里,我们提到了chrt(1)内部调用sched_getattr(2)系统调用来查询这些属性。

非常类似地,可以通过使用chrt(1)实用程序(例如在脚本中简单地这样做)或在(用户空间)C 应用程序中使用sched_setattr(2)系统调用来设置调度策略和优先级。此外,内核还公开其他 API:sched_{g,s}etscheduler(2)及其pthread库包装器 API,pthread_{g,s}etschedparam(3)(由于这些都是用户空间 API,我们让您自行查阅它们的手册页面以获取详细信息并尝试它们)。

在内核中-在内核线程上

现在你知道,内核绝对不是一个进程也不是一个线程。话虽如此,内核确实包含内核线程;与它们的用户空间对应物一样,内核线程可以根据需要创建(从核心内核、设备驱动程序、内核模块中)。它们是可调度实体(KSEs!),当然,它们每个都有一个任务结构;因此,它们的调度策略和优先级可以根据需要查询或设置。

因此,就要点而言:要设置内核线程的调度策略和/或优先级,内核通常使用kernel/sched/core.c:sched_setscheduler_nocheck()(GFP 导出)内核 API;在这里,我们展示了它的签名和典型用法的示例;随后的注释使其相当不言自明。

// kernel/sched/core.c
/**
 * sched_setscheduler_nocheck - change the scheduling policy and/or RT priority of a thread from kernelspace.
 * @p: the task in question.
 * @policy: new policy.
 * @param: structure containing the new RT priority.
 *
 * Just like sched_setscheduler, only don't bother checking if the
 * current context has permission. For example, this is needed in
 * stop_machine(): we create temporary high priority worker threads,
 * but our caller might not have that capability.
 *
 * Return: 0 on success. An error code otherwise.
 */
int sched_setscheduler_nocheck(struct task_struct *p, int policy,
                   const struct sched_param *param)
{
    return _sched_setscheduler(p, policy, param, false);
}
EXPORT_SYMBOL_GPL(sched_setscheduler_nocheck);

内核对内核线程的一个很好的例子是内核(相当常见地)使用线程化中断。在这里,内核必须创建一个专用的内核线程,其具有SCHED_FIFO(软)实时调度策略和实时优先级值为50(介于中间),用于处理中断。这里展示了设置内核线程调度策略和优先级的相关代码:

// kernel/irq/manage.c
static int
setup_irq_thread(struct irqaction *new, unsigned int irq, bool secondary)
{ 
    struct task_struct *t;
    struct sched_param param = {
        .sched_priority = MAX_USER_RT_PRIO/2,
    };
    [ ... ]
    sched_setscheduler_nocheck(t, SCHED_FIFO, &param);
    [ ... ]

(这里我们不展示通过kthread_create() API 创建内核线程的代码。另外,FYI,MAX_USER_RT_PRIO的值是100。)

现在您在很大程度上了解了操作系统级别的 CPU 调度是如何工作的,我们将继续进行另一个非常引人入胜的讨论——cgroups;请继续阅读!

使用 cgroups 进行 CPU 带宽控制

在过去,内核社区曾经为一个相当棘手的问题而苦苦挣扎:尽管调度算法及其实现(早期的 2.6.0 O(1)调度器,稍后(2.6.23)的完全公平调度器(CFS))承诺了完全公平的调度,但实际上并非如此。想想这个:假设您与其他九个人一起登录到 Linux 服务器。其他一切都相等的情况下,处理器时间可能(或多或少)在所有十个人之间(相对)公平地共享;当然,您会明白,真正运行的不是人,而是代表他们运行的进程和线程。

至少目前,让我们假设它基本上是公平的。但是,如果您编写一个用户空间程序,在循环中不加选择地生成多个新线程,每个线程都执行大量的 CPU 密集型工作(也许还额外分配大量内存;例如文件(解)压缩应用程序)!那么 CPU 带宽分配在任何实际意义上都不再公平,您的账户将有效地占用 CPU(也许还占用其他系统资源,如内存)!

需要一个精确有效地分配和管理 CPU(和其他资源)带宽的解决方案;最终,谷歌工程师提供了补丁,将现代 cgroups 解决方案放入了 Linux 内核(在 2.6.24 版本)。简而言之,cgroups 是一个内核功能,允许系统管理员(或任何具有 root 访问权限的人)对系统上的各种资源(或在 cgroup 词汇中称为控制器)执行带宽分配和细粒度资源管理。请注意:使用 cgroups,不仅可以仔细分配和监视处理器(CPU 带宽),还可以根据项目或产品的需要仔细分配和监视内存、网络、块 I/O(等等)带宽。

所以,嘿,您现在感兴趣了!如何启用这个 cgroups 功能?简单——这是一个您可以通过通常的方式在内核中启用(或禁用)的内核功能:通过配置内核!相关菜单(通过方便的make menuconfig界面)是General setup / Control Group support。尝试这样做:在内核配置文件中使用grep查找CGROUP;如果需要,调整内核配置,重新构建,使用新内核重新启动并进行测试。(我们在第二章中详细介绍了内核配置,从源代码构建 5.x Linux 内核–第一部分,以及在第三章中介绍了内核构建和安装,从源代码构建 5.x Linux 内核–第二部分)。

好消息:cgroups 在运行 systemd init 框架的任何(足够新的)Linux 系统上默认启用。正如刚才提到的,您可以通过查询 cgroup 控制器来查看启用的控制器,并根据需要修改配置。

从 2.6.24 开始,与所有其他内核功能一样,cgroups 不断发展。最近,已经达到了足够改进的 cgroup 功能与旧版本不兼容的地步,导致了一个新的 cgroup 发布,即被命名为 cgroups v2(或简称为 cgroups2);这在 4.5 内核系列中被宣布为生产就绪(旧版本现在被称为 cgroups v1 或遗留 cgroups 实现)。请注意,截至目前为止,两者可以并且确实共存(有一些限制;许多应用程序和框架仍然使用旧的 cgroups v1,并且尚未迁移到 v2)。

为什么要使用 cgroups v2 而不是 cgroups v1 的详细原因可以在内核文档中找到:www.kernel.org/doc/html/latest/admin-guide/cgroup-v2.html#issues-with-v1-and-rationales-for-v2

cgroups(7)手册详细描述了接口和各种可用的(资源)控制器(有时称为子系统);对于 cgroups v1,它们是cpucpuacctcpusetmemorydevicesfreezernet_clsblkioperf_eventnet_priohugetlbpidsrdma。我们建议感兴趣的读者查阅该手册以获取详细信息;例如,PIDS 控制器在防止 fork 炸弹(通常是一个愚蠢但仍然致命的 DoS 攻击,在其中fork(2)系统调用在无限循环中被发出!)方面非常有用,允许您限制可以从该 cgroup(或其后代)fork 出的进程数量。在运行 cgroups v1 的 Linux 系统上,查看/proc/cgroups的内容:它显示了可用的 v1 控制器及其当前使用情况。

控制组通过一个专门构建的合成(伪)文件系统进行公开,通常挂载在/sys/fs/cgroup下。在 cgroups v2 中,所有控制器都挂载在单个层次结构(或树)中。这与 cgroups v1 不同,cgroups v1 中可以将多个控制器挂载在多个层次结构或组下。现代 init 框架systemd同时使用 v1 和 v2 cgroups。cgroups(7)手册确实提到了systemd(1)在启动时自动挂载 cgroups v2 文件系统(在/sys/fs/cgroup/unified处)的事实。

在 cgroups v2 中,这些是支持的控制器(或资源限制器或子系统):cpucpusetiomemorypidsperf_eventrdma(前五个通常被部署)。

在本章中,重点是 CPU 调度;因此,我们不深入研究其他控制器,而是限制我们的讨论在使用 cgroups v2 cpu控制器来限制 CPU 带宽分配的示例上。有关使用其他控制器的更多信息,请参考前面提到的资源(以及本章的进一步阅读部分中找到的其他资源)。

在 Linux 系统上查找 cgroups v2

首先,让我们查找可用的 v2 控制器;要这样做,请找到 cgroups v2 挂载点;通常在这里:

$ mount | grep cgroup2 
cgroup2 on /sys/fs/cgroup/unified type cgroup2 
   (rw,nosuid,nodev,noexec,relatime,nsdelegate) 
$ sudo cat /sys/fs/cgroup/unified/cgroup.controllers 
$ 

嘿,cgroup2中没有任何控制器吗?实际上,在存在混合 cgroups,v1 和 v2 的情况下,这是默认情况(截至目前为止)。要专门使用较新版本,并且使所有配置的控制器可见,您必须首先通过在启动时传递此内核命令行参数来禁用 cgroups v1:cgroup_no_v1=all(请注意,所有可用的内核参数可以方便地在此处查看:www.kernel.org/doc/Documentation/admin-guide/kernel-parameters.txt)。

使用上述选项重新启动系统后,您可以检查您在 GRUB(在 x86 上)或者在嵌入式系统上可能通过 U-Boot 指定的内核参数是否已被内核解析:

$ cat /proc/cmdline
 BOOT_IMAGE=/boot/vmlinuz-4.15.0-118-generic root=UUID=<...> ro console=ttyS0,115200n8 console=tty0 ignore_loglevel quiet splash cgroup_no_v1=all 3
$

好的,现在让我们重试查找cgroup2控制器;您应该会发现它通常挂载在/sys/fs/cgroup/下 - unified文件夹不再存在(因为我们使用了cgroup_no_v1=all参数进行引导):

$ cat /sys/fs/cgroup/cgroup.controllers
cpu io memory pids 

啊,现在我们看到它们了(您看到的确切控制器取决于内核的配置方式)。

cgroups2 的工作规则超出了本书的范围;如果您愿意,建议您阅读这里的内容:www.kernel.org/doc/html/latest/admin-guide/cgroup-v2.html#control-group-v2。此外,cgroup 中的所有cgroup.<foo>伪文件都在核心接口文件部分(www.kernel.org/doc/html/latest/admin-guide/cgroup-v2.html#core-interface-files)中有详细描述。类似的信息也以更简单的方式呈现在cgroups(7)的出色 man 页面中(在 Ubuntu 上使用man 7 cgroups查找)。

试一试 - cgroups v2 CPU 控制器

让我们尝试一些有趣的事情:我们将在系统的 cgroups v2 层次结构下创建一个新的子组。然后我们将为其设置一个 CPU 控制器,运行一些测试进程(这些进程会占用系统的 CPU 核心),并设置一个用户指定的上限,限制这些进程实际可以使用多少 CPU 带宽!

在这里,我们概述了您通常会采取的步骤(所有这些步骤都需要您以 root 访问权限运行):

  1. 确保您的内核支持 cgroups v2:
  • 您应该在运行 4.5 或更高版本的内核。

  • 在存在混合 cgroups(旧的 v1 和较新的 v2,这是写作时的默认设置)的情况下,请检查您的内核命令行是否包含cgroup_no_v1=all字符串。在这里,我们假设 cgroup v2 层次结构得到支持并挂载在/sys/fs/cgroup下。

  1. 向 cgroups v2 层次结构添加cpu控制器;这是通过以下方式实现的,作为 root 用户:
echo "+cpu" > /sys/fs/cgroup/cgroup.subtree_control

cgroups v2 的内核文档(www.kernel.org/doc/html/latest/admin-guide/cgroup-v2.html#cpu)提到了这一点:警告:cgroup2 尚不支持对实时进程的控制,cpu 控制器只能在所有 RT 进程位于根 cgroup 时启用。请注意,系统管理软件可能已经在系统引导过程中将 RT 进程放入非根 cgroup 中,这些进程可能需要移动到根 cgroup 中,然后才能启用 cpu 控制器。

  1. 创建一个子组:这是通过在 cgroup v2 层次结构下创建一个具有所需子组名称的目录来完成的;例如,要创建一个名为test_group的子组,使用以下命令:
mkdir /sys/fs/cgroup/test_group
  1. 有趣的地方在于:设置将属于此子组的进程的最大允许 CPU 带宽;这是通过写入<cgroups-v2-mount-point>/<sub-group>/cpu.max(伪)文件来实现的。为了清楚起见,根据内核文档(www.kernel.org/doc/html/latest/admin-guide/cgroup-v2.html#cpu-interface-files)对此文件的解释如下:
cpu.max
A read-write two value file which exists on non-root cgroups. The default is “max 100000”. The maximum bandwidth limit. It’s in the following format: 
$MAX $PERIOD
which indicates that the group may consume upto $MAX in each $PERIOD duration. “max” for $MAX indicates no limit. If only one number is written, $MAX is updated.

实际上,子控制组中的所有进程将被允许在$PERIOD微秒内运行$MAX次;例如,当MAX = 300,000PERIOD = 1,000,000时,我们实际上允许子控制组中的所有进程在 1 秒内运行 0.3 秒!

  1. 将一些进程插入新的子控制组;这是通过将它们的 PID 写入<cgroups-v2-mount-point>/<sub-group>/cgroup.procs伪文件来实现的:
  • 您可以通过查找每个进程的/proc/<PID>/cgroup伪文件的内容进一步验证它们是否实际属于这个子组;如果它包含形式为0::/<sub-group>的行,则它确实属于该子组!
  1. 就是这样;新子组下的进程现在将在强加的 CPU 带宽约束下执行它们的工作;完成后,它们将像往常一样终止...您可以通过简单的rmdir <cgroups-v2-mount-point>/<sub-group>来删除子组。

实际执行上述步骤的 bash 脚本在这里可用:ch11/cgroups_v2_cpu_eg/cgv2_cpu_ctrl.sh。一定要查看它!为了使其有趣,它允许您传递最大允许的 CPU 带宽-在步骤 4中讨论的$MAX值!不仅如此;我们还故意编写了一个测试脚本(simp.sh),它会在 CPU 上进行大量操作-它们会生成我们重定向到文件的整数值。因此,它们在其生命周期内生成的整数数量是它们可用的 CPU 带宽的指示...通过这种方式,我们可以测试脚本并实际看到 cgroups(v2)的运行!

这里进行几次测试运行将帮助您理解这一点:

$ sudo ./cgv2_cpu_ctrl.sh
[sudo] password for <username>: 
Usage: cgv2_cpu_ctrl.sh max-to-utilize(us)
 This value (microseconds) is the max amount of time the processes in the sub-control
 group we create will be allowed to utilize the CPU; it's relative to the period,
 which is the value 1000000;
 So, f.e., passing the value 300,000 (out of 1,000,000) implies a max CPU utilization
 of 0.3 seconds out of 1 second (i.e., 30% utilization).
 The valid range for the $MAX value is [1000-1000000].
$ 

您需要以 root 身份运行它,并将$MAX值作为参数传递(之前看到的使用屏幕已经很清楚地解释了它,包括显示有效范围(微秒值))。

在下面的截图中,我们使用参数800000运行 bash 脚本,意味着 CPU 带宽为 1,000,000 中的 800,000;实际上,CPU 利用率为每秒 0.8 秒的相当高的 CPU 利用率(80%):

图 11.8-运行我们的 cgroups v2 CPU 控制器演示 bash 脚本,有效的最大 CPU 带宽为 80%

研究我们脚本的图 11.8输出;您可以看到它完成了它的工作:在验证 cgroup v2 支持后,它添加了一个cpu控制器并创建了一个子组(称为test_group)。然后继续启动两个名为j1j2的测试进程(实际上,它们只是指向我们的simp.sh脚本的符号链接)。一旦启动,它们当然会运行。然后脚本查询并将它们的 PID 添加到子控制组(如步骤 5所示)。我们给这两个进程 5 秒钟来运行;然后脚本显示它们写入的文件的内容。它被设计成作业j11开始写入整数,作业j2900开始写入整数。在前面的截图中,您可以清楚地看到,在其生命周期内,并在有效的 80% CPU 带宽下,作业j1从 1 到 68 输出数字;同样(在相同的约束下),作业j2900965输出数字(实际上是相似数量的工作)。然后脚本清理,终止作业并删除子组。

然而,为了真正欣赏效果,我们再次运行我们的脚本(研究以下输出),但这次最大 CPU 带宽只有 1,000($MAX值)-实际上,最大 CPU 利用率只有 0.1%!:

$ sudo ./cgv2_cpu_ctrl.sh 1000 [+] Checking for cgroup v2 kernel support
[+] Adding a 'cpu' controller to the cgroups v2 hierarchy
[+] Create a sub-group under it (here: /sys/fs/cgroup/test_group)

***
Now allowing 1000 out of a period of 1000000 by all processes (j1,j2) in this
sub-control group, i.e., .100% !
***

[+] Launch processes j1 and j2 (slinks to /home/llkd/Learn-Linux-Kernel-Development/ch11/cgroups_v2_cpu_eg/simp.sh) now ...
[+] Insert processes j1 and j2 into our new CPU ctrl sub-group
Verifying their presence...
0::/test_group
Job j1 is in our new cgroup v2 test_group
0::/test_group
Job j2 is in our new cgroup v2 test_group

............... sleep for 5 s ................

[+] killing processes j1, j2 ...
./cgv2_cpu_ctrl.sh: line 185: 10322 Killed ./j1 1 > ${OUT1}
cat 1stjob.txt
1 2 3 
cat 2ndjob.txt
900 901 
[+] Removing our cpu sub-group controller
rmdir: failed to remove '/sys/fs/cgroup/test_group': Device or resource busy
./cgv2_cpu_ctrl.sh: line 27: 10343 Killed ./j2 900 > ${OUT2}
$  

有何不同!这次我们的作业j1j2实际上只能输出两到三个整数(如前面输出中看到的作业 j1 的值为1 2 3,作业 j2 的值为900 901),清楚地证明了 cgroups v2 CPU 控制器的有效性。

容器,本质上是轻量级的虚拟机(在某种程度上),目前是一个炙手可热的商品。今天使用的大多数容器技术(Docker、LXC、Kubernetes 等)在本质上都是两种内置的 Linux 内核技术,即命名空间和 cgroups 的结合。

通过这样,我们完成了对一个非常强大和有用的内核特性:cgroups 的简要介绍。让我们继续本章的最后一部分:学习如何将常规 Linux 转换为实时操作系统!

将主线 Linux 转换为 RTOS

主线或原始的 Linux(从kernel.org下载的内核)明显不是一个实时操作系统RTOS);它是一个通用操作系统GPOS;就像 Windows,macOS,Unix 一样)。在 RTOS 中,当硬实时特性发挥作用时,软件不仅必须获得正确的结果,还有与此相关的截止日期;它必须保证每次都满足这些截止日期。尽管主线 Linux 操作系统不是 RTOS,但它的表现非常出色:它很容易符合软实时操作系统的标准(在大多数情况下都能满足截止日期)。然而,真正的硬实时领域(例如军事行动,许多类型的交通,机器人技术,电信,工厂自动化,股票交易,医疗电子设备等)需要 RTOS。

在这种情况下的另一个关键点是确定性:关于实时的一个经常被忽视的点是,软件响应时间并不总是需要非常快(比如说在几微秒内响应);它可能会慢得多(在几十毫秒的范围内);这本身并不是 RTOS 中真正重要的事情。真正重要的是系统是可靠的,以相同一致的方式工作,并始终保证截止日期得到满足。

例如,对调度请求的响应时间应该是一致的,而不是一直在变化。与所需时间(或基线)的差异通常被称为抖动;RTOS 致力于保持抖动微小,甚至可以忽略不计。在 GPOS 中,这通常是不可能的,抖动可能会变化得非常大 - 一会儿很低,下一刻很高。总的来说,能够在极端工作负荷的情况下保持稳定的响应和最小的抖动的能力被称为确定性,并且是 RTOS 的标志。为了提供这样的确定性响应,算法必须尽可能地设计为O(1)时间复杂度。

Thomas Gleixner 和社区支持已经为此目标努力了很长时间;事实上,自 2.6.18 内核以来,已经有了将 Linux 内核转换为 RTOS 的离线补丁。这些补丁可以在许多内核版本中找到,网址是:mirrors.edge.kernel.org/pub/linux/kernel/projects/rt/。这个项目的旧名称是PREEMPT_RT;后来(2015 年 10 月起),Linux 基金会LF)接管了这个项目 - 这是一个非常积极的举措! - 并将其命名为实时 LinuxRTL)协作项目(wiki.linuxfoundation.org/realtime/rtl/start#the_rtl_collaborative_project),或 RTL(不要将这个项目与 Xenomai 或 RTAI 等共核方法,或者旧的、现在已经废弃的尝试称为 RTLinux 混淆)。

当然,一个常见的问题是“为什么这些补丁不直接合并到主线中呢?”事实证明:

  • 很多 RTL 工作确实已经合并到了主线内核中;这包括重要领域,如调度子系统,互斥锁,lockdep,线程中断,PI,跟踪等。事实上,RTL 的一个持续的主要目标是尽可能多地合并它(我们在主线和 RTL - 技术差异总结部分展示了一个总结表)。

  • Linus Torvalds 认为,Linux 作为一个主要设计和架构为 GPOS,不应该具有只有 RTOS 真正需要的高度侵入性功能;因此,尽管补丁确实被合并了,但这是一个缓慢的审慎过程。

在本章的进一步阅读部分,我们包括了一些有趣的文章和有关 RTL(和硬实时)的参考资料;请阅读一下。

接下来您将要做的事情确实很有趣:您将学习如何使用 RTL 补丁对主线 5.4 LTS 内核进行打补丁、配置、构建和引导;因此,您最终将运行一个 RTOS - 实时 Linux 或 RTL!我们将在我们的 x86_64 Linux VM(或本机系统)上执行此操作。

我们不会止步于此;然后您将学习更多内容 - 常规 Linux 和 RTL 之间的技术差异,系统延迟是什么,以及如何实际测量它。为此,我们将首先在树莓派设备的内核源上应用 RTL 补丁,配置和构建它,并将其用作使用cyclictest应用程序进行系统延迟测量的测试平台(您还将学习使用现代 BPF 工具来测量调度程序延迟)。让我们首先在 x86_64 上为我们的 5.4 内核构建一个 RTL 内核!

为主线 5.x 内核(在 x86_64 上)构建 RTL

在本节中,您将逐步学习如何以实际操作的方式打补丁、配置和构建 Linux 作为 RTOS。如前一节所述,这些实时补丁已经存在很长时间了;现在是时候利用它们了。

获取 RTL 补丁

导航至mirrors.edge.kernel.org/pub/linux/kernel/projects/rt/5.4/(或者,如果您使用的是另一个内核,转到此目录的上一级目录并选择所需的内核版本):

图 11.9 - 5.4 LTS Linux 内核的 RTL 补丁的截图

您很快会注意到 RTL 补丁仅适用于所讨论的内核的某些版本(在这里是 5.4.y);接下来会有更多内容。在前面的截图中,您可以看到两种类型的补丁文件 - 解释如下:

  • patch-<kver>rt[nn].patch.[gz|xz]:前缀是patch-;这是补丁的完整集合,用于在一个统一的(压缩的)文件中打补丁到主线内核(版本<kver>)。

  • patches-<kver>-rt[nn].patch.[gz|xz]:前缀是patches-;这个压缩文件包含了用于这个版本的 RTL 的每个单独的补丁(作为单独的文件)。

(还有,正如您应该知道的,<fname>.patch.gz<fname>.patch.xz是相同的存档;只是压缩器不同 - .sign文件是 PGP 签名文件。)

我们将使用第一种类型;通过单击链接(或通过wget(1))将patch-<kver>rt[nn].patch.xz文件下载到目标系统。

请注意,对于 5.4.x 内核(截至撰写时),RTL 补丁似乎只存在于 5.4.54 和 5.4.69 版本(而不是 5.4.0,我们一直在使用的内核)。

实际上,RTL 补丁适用的特定内核版本可能与我在撰写本文时提到的不同。这是预期的 - 只需按照这里提到的步骤用您正在使用的发布号替换即可。

别担心 - 我们马上就会向您展示一个解决方法。这确实是事实;社区不可能针对每个单独的内核发布构建补丁 - 这些实在太多了。这确实有一个重要的含义:要么我们将我们的 5.4.0 内核打补丁到 5.4.69,要么我们只需下载 5.4.69 内核并对其应用 RTL 补丁。

第一种方法可行,但工作量更大(特别是在没有 git/ketchup/quilt 等补丁工具的情况下;在这里,我们选择不使用 git 来应用补丁,而是直接在稳定的内核树上工作)。由于 Linux 内核补丁是增量的,我们将不得不下载从 5.4.0 到 5.4.69 的每个补丁(总共 69 个补丁!),并依次按顺序应用它们:首先是 5.4.1,然后是 5.4.2,然后是 5.4.3,依此类推,直到最后一个!在这里,为了简化事情,我们知道要打补丁的内核是 5.4.69,所以最好直接下载并提取它。因此,前往www.kernel.org/并这样做。因此,我们最终下载了两个文件:

(如第三章 从源代码构建 5.x Linux 内核-第二部分中详细解释的那样,如果您打算为另一个目标交叉编译内核,通常的做法是在功能强大的工作站上构建它,然后在那里下载。)

接下来,提取 RTL 补丁文件以及内核代码基础tar.xz文件,以获取内核源代码树(这里是版本 5.4.69;当然,这些细节在第二章 从源代码构建 5.x Linux 内核-第一部分中已经详细介绍过)。到目前为止,您的工作目录内容应该类似于这样:

$ ls -lh
total 106M
drwxrwxr-x 24 kaiwan kaiwan 4.0K Oct  1 16:49 linux-5.4.69/
-rw-rw-r--  1 kaiwan kaiwan 105M Oct 13 16:35 linux-5.4.69.tar.xz
-rw-rw-r--  1 kaiwan kaiwan 836K Oct 13 16:33 patch-5.4.69-rt39.patch
$ 

(FYI,unxz(1)实用程序可用于提取.xz压缩的补丁文件。)对于好奇的读者:看一下补丁(文件patch-5.4.69-rt39.patch),看看为实现硬实时内核所做的所有代码级更改;当然不是简单的!技术更改的概述将在即将到来的主线和 RTL-技术差异摘要部分中看到。既然我们已经准备就绪,让我们开始将补丁应用到稳定的 5.4.69 内核树上;接下来的部分只涵盖这一点。

应用 RTL 补丁

确保将提取的补丁文件patch-5.4.69-rt39.patch放在 5.4.69 内核源代码树的上一级目录中(如前所示)。现在,让我们应用补丁。小心-(显然)不要尝试将压缩文件应用为补丁;提取并使用未压缩的补丁文件。为了确保补丁正确应用,我们首先使用--dry-run(虚拟运行)选项来使用patch(1)

$ cd linux-5.4.69
$ patch -p1 --dry-run < ../patch-5.4.69-rt39.patch 
checking file Documentation/RCU/Design/Expedited-Grace-Periods/Expedited-Grace-Periods.html
checking file Documentation/RCU/Design/Requirements/Requirements.html
[ ... ]
checking file virt/kvm/arm/arm.c
$ echo $?
0

一切顺利,现在让我们实际应用它:

$ patch -p1 < ../patch-5.4.69-rt39.patch patching file Documentation/RCU/Design/Expedited-Grace-Periods/Expedited-Grace-Periods.html
patching file Documentation/RCU/Design/Requirements/Requirements.html
[ ... ] 

太好了-我们现在已经准备好了 RTL 补丁内核!

当然,有多种方法和各种快捷方式可以使用;例如,您还可以通过xzcat ../patch-5.4.69-rt39.patch.xz | patch -p1命令(或类似命令)来实现前面的操作。

配置和构建 RTL 内核

我们在第二章 从源代码构建 5.x Linux 内核-第一部分和第三章 从源代码构建 5.x Linux 内核-第二部分中详细介绍了内核配置和构建步骤,因此我们不会在这里重复。几乎所有内容都保持不变;唯一的显著区别是我们必须配置此内核以利用 RTL(这在新的 RTL 维基网站上有解释,网址为:wiki.linuxfoundation.org/realtime/documentation/howto/applications/preemptrt_setup)。

为了将要构建的内核特性减少到大约匹配当前系统配置,我们首先在内核源树目录(linux-5.4.69)中执行以下操作(我们也在第二章中介绍过,从源代码构建 5.x Linux 内核 - 第一部分,在通过 localmodconfig 方法调整内核配置部分):

$ lsmod > /tmp/mylsmod 
$ make LSMOD=/tmp/mylsmod localmodconfig

接下来,使用make menuconfig启动内核配置:

  1. 导航到通用设置子菜单:

图 11.10 - 进行 menuconfig / 通用设置:配置 RTL 补丁内核

  1. 一旦到达那里,向下滚动到抢占模型子菜单;我们在前面的截图中看到它被突出显示,以及当前(默认)选择的抢占模型是自愿内核抢占(桌面)

  2. 在这里按Enter会进入抢占模型子菜单:

图 11.11 - 进行 menuconfig / 通用设置 / 抢占模型的配置 RTL 补丁内核

就是这样!回想一下前一章,在可抢占内核部分,我们描述了这个内核配置菜单实际上有三个项目(在图 11.11 中看到的前三个)。现在有四个。第四个项目 - 完全可抢占内核(实时)选项 - 是由于我们刚刚应用的 RTL 补丁而添加的!

  1. 因此,要为 RTL 配置内核,请向下滚动并选择完全可抢占内核(实时)菜单选项(参见图 11.1)。这对应于内核CONFIG_PREEMPT_RT配置宏,其<帮助>非常描述性(确实要看一看);事实上,它以这样的陈述结束:如果您正在构建需要实时保证的系统内核,请选择此选项

在较早版本的内核(包括 5.0.x)中,抢占模型子菜单显示了五个选择项;其中两个是用于 RT:一个称为基本 RT,另一个是我们在这里看到的第四个选择 - 现在(5.4.x)它们已经被简单地合并为一个真正的实时选项。

  1. 一旦选择了第四个选项并保存并退出menuconfig UI,(重新)检查已选择完全可抢占内核 - 实际上是 RTL:
$ grep PREEMPT_RT .config
CONFIG_PREEMPT_RT=y

好的,看起来不错!(当然,在构建之前,您可以根据产品的需要调整其他内核配置选项。)

  1. 现在让我们构建 RTL 内核:
make -j4 && sudo make modules_install install 
  1. 一旦成功构建和安装,重新启动系统;在启动时,按下一个键以显示 GRUB 引导加载程序菜单(按住其中一个Shift键可以确保在启动时显示 GRUB 菜单);在 GRUB 菜单中,选择新构建的5.4.69-rtl RTL 内核(实际上,刚刚安装的内核通常是默认选择的)。现在应该可以启动了;一旦登录并进入 shell,让我们验证内核版本:
$ uname -r
5.4.69-rt39-rtl-llkd1

注意CONFIG_LOCALVERSION设置为值-rtl-llkd1。(还可以通过uname -a看到PREEMPT RT字符串。)现在我们 - 如承诺的那样 - 运行 Linux,RTL,作为硬实时操作系统,即 RTOS!

然而,非常重要的是要理解,对于真正的硬实时,仅仅拥有一个硬实时内核是不够的;你必须非常小心地设计和编写你的用户空间(应用程序、库和工具)以及你的内核模块/驱动程序,以符合实时性。例如,频繁的页面错误可能会使确定性成为过去式,并导致高延迟(和高抖动)。 (回想一下你在第九章中学到的,模块作者的内核内存分配 - 第二部分,在内存分配和需求分页的简短说明*部分。页面错误是生活的一部分,经常发生;小的页面错误通常不会引起太多担忧。但在硬实时的情况下呢?无论如何,“主要错误”都会妨碍性能。)诸如使用mlockall(2)来锁定实时应用程序进程的所有页面可能是必需的。这里提供了编写实时代码的几种其他技术和建议:rt.wiki.kernel.org/index.php/HOWTO:_Build_an_ RT-application。(同样,关于 CPU 亲和性和屏蔽、cpuset管理、中断请求(IRQ)优先级等主题可以在先前提到的旧 RT 维基站点上找到;rt.wiki.kernel.org/index.php/Main_Page。)

所以,很好 - 现在你知道如何配置和构建 Linux 作为 RTOS!我鼓励你自己尝试一下。接下来,我们将总结标准和 RTL 内核之间的关键差异。

主线和 RTL - 技术差异总结

为了让你更深入地了解这个有趣的主题领域,在本节中,我们将进一步深入探讨:我们总结了标准(或主线)和 RTL 内核之间的关键差异。

在下表中,我们总结了标准(或主线)和 RTL 内核之间的一些关键差异。RTL 项目的主要目标是最终完全整合到常规主线内核树中。由于这个过程是渐进的,从 RTL 合并到主线的补丁是缓慢但稳定的;有趣的是,正如你可以从下表的最右列看到的那样,在撰写本文时,大部分(约 80%)的 RTL 工作实际上已经合并到了主线内核中,并且它还在继续:

组件/特性 标准或主线(原始)Linux RTL(完全可抢占/硬实时 Linux) RT 工作合并到主线?
自旋锁 自旋锁关键部分是不可抢占的内核代码 尽可能可抢占;称为“睡眠自旋锁”!实际上,自旋锁已转换为互斥锁。
中断处理 传统上通过顶半部分和底半部分(hardirq/tasklet/softirq)机制完成 线程中断:大多数中断处理在内核线程内完成(2.6.30,2009 年 6 月)。
HRTs(高分辨率定时器) 由于从 RTL 合并而可用 具有纳秒分辨率的定时器(2.6.16,2006 年 3 月)。
RW 锁 无界;写者可能会挨饿 具有有界写入延迟的公平 RW 锁。
lockdep 由于从 RTL 合并而可用 非常强大(内核空间)的工具,用于检测和证明锁的正确性或缺乏正确性。
跟踪 由于从 RTL 合并而可用的一些跟踪技术 Ftrace 的起源(在某种程度上也包括 perf)是 RT 开发人员试图找到延迟问题。
调度器 由于从 RTL 合并而可用的许多调度器功能 首先在这里进行了实时调度的工作以及截止时间调度类(SCHED_DEADLINE)(3.14,2014 年 3 月);此外,完全无滴答操作(3.10,2013 年 6 月)。

不要担心-我们一定会在书的后续章节中涵盖许多前面的细节。

当然,一个众所周知的(至少应该是)经验法则就是:没有银弹。这当然意味着,没有一个解决方案适用于所有需求。

如果你还没有这样做,请务必读一读弗雷德里克·P·布鲁克斯的《神话般的程序员:软件工程论文》这本仍然相关的书。

如第十章中所述,《CPU 调度器-第一部分》,在可抢占内核部分,Linux 内核可以配置为使用CONFIG_PREEMPT选项;这通常被称为低延迟(或LowLat)内核,并提供接近实时的性能。在许多领域(虚拟化、电信等),使用 LowLat 内核可能比使用硬实时 RTL 内核更好,主要是由于 RTL 的开销。通常情况下,使用硬实时,用户空间应用程序可能会受到吞吐量的影响,CPU 可用性降低,因此延迟更高。(请参阅进一步阅读部分,了解 Ubuntu 的一份白皮书,其中对比了原始发行版内核、低延迟可抢占内核和完全可抢占内核-实际上是 RTL 内核。)

考虑到延迟,接下来的部分将帮助您了解系统延迟的确切含义;然后,您将学习一些在实时系统上测量它的方法。继续!

延迟及其测量

我们经常遇到术语延迟;在内核的上下文中,它到底是什么意思呢?延迟的同义词是延迟,这是一个很好的提示。延迟(或延迟)是反应所需的时间 - 在我们这里的上下文中,内核调度程序唤醒用户空间线程(或进程)的时间,使其可运行,以及它实际在处理器上运行的时间是调度延迟。(不过,请注意,调度延迟这个术语也在另一个上下文中使用,指的是每个可运行任务保证至少运行一次的时间间隔;在这里的可调整项是:/proc/sys/kernel/sched_latency_ns,至少在最近的 x86_64 Linux 上,默认值为 24 毫秒)。类似地,从硬件中断发生(比如网络中断)到它实际由其处理程序例程服务的经过的时间是中断延迟。

cyclictest用户空间程序是由 Thomas Gleixner 编写的;它的目的是测量内核延迟。其输出值以微秒为单位。平均延迟和最大延迟通常是感兴趣的值-如果它们在系统的可接受范围内,那么一切都很好;如果不在范围内,这可能指向产品特定的重新设计和/或内核配置调整,检查其他时间关键的代码路径(包括用户空间)等。

让我们以 cyclictest 进程本身作为一个例子,来清楚地理解调度延迟。cyclictest 进程被运行;在内部,它发出nanosleep(2)(或者,如果传递了-n选项开关,则是clock_nanosleep(2)系统调用),将自己置于指定的时间间隔的睡眠状态。由于这些*sleep()系统调用显然是阻塞的,内核在内部将 cyclictest(为简单起见,我们在下图中将其称为ct)进程排入等待队列,这只是一个保存睡眠任务的内核数据结构。

等待队列与事件相关联;当事件发生时,内核唤醒所有在该事件上休眠的任务。在这里,所讨论的事件是定时器的到期;这是由定时器硬件发出的硬件中断(或 IRQ)来传达的;这开始了必须发生的事件链,以使 cyclictest 进程唤醒并在处理器上运行。当然,关键点在于,说起来容易做起来难:在进程实际在处理器核心上运行的路径上可能发生许多潜在的延迟!以下图表试图传达的就是潜在的延迟来源:

图 11.12 - 唤醒、上下文切换和运行 cyclictest(ct)进程的路径;可能发生多个延迟

(部分前述输入来自于优秀的演示使用和理解实时 Cyclictest 基准测试,Rowand,2013 年 10 月。)仔细研究图 11.12;它显示了从硬件中断由于定时器到期的断言(在时间t0,因为 cyclictest 进程通过nanosleep() API 发出的休眠在时间t1完成),通过 IRQ 处理(t1t3),以及 ct 进程唤醒的时间线 - 作为其结果,它被排入将来运行的核心的运行队列(在t3t4之间)。

从那里,它最终将成为调度类别的最高优先级,或者最好或最值得的任务(在时间t6;我们在前一章中介绍了这些细节),因此,它将抢占当前正在运行的线程(t6)。schedule()代码将执行(时间t7t8),上下文切换将发生在schedule()的尾端,最后(!),cyclictest 进程将实际在处理器核心上执行(时间t9)。虽然乍看起来可能很复杂,但实际情况是这是一个简化的图表,因为其他潜在的延迟源已被省略(例如,由于 IPI、SMI、缓存迁移、前述事件的多次发生、额外中断在不合适的时刻触发导致更多延迟等)。

确定具有实时优先级的用户空间任务的最大延迟值的经验法则如下:

max_latency = CLK_WAVELENGTH x 105 s

例如,树莓派 3 型号的 CPU 时钟运行频率为 1 GHz;其波长(一个时钟周期到下一个时钟周期之间的时间)是频率的倒数,即 10^(-9)或 1 纳秒。因此,根据前述方程,理论最大延迟应该是(在)10^(-7)秒,约为 10 纳秒。正如您很快会发现的,这仅仅是理论上的。

使用 cyclictest 测量调度延迟

为了使这更有趣(以及在受限系统上运行延迟测试),我们将使用众所周知的 cyclictest 应用程序进行延迟测量,同时系统处于一定负载(通过stress(1)实用程序)下运行,使用同样著名的树莓派设备。本节分为四个逻辑部分:

  1. 首先,在树莓派设备上设置工作环境。

  2. 其次,在内核源上下载和应用 RT 补丁,进行配置和构建。

  3. 第三,安装 cyclictest 应用程序,以及设备上的其他一些必需的软件包(包括stress)。

  4. 第四,运行测试用例并分析结果(甚至绘制图表来帮助分析)。

第一步和第二步的大部分内容已经在第三章中详细介绍过,从源代码构建 5.x Linux 内核-第二部分,在树莓派的内核构建部分。这包括下载树莓派特定的内核源树,配置内核和安装适当的工具链;我们不会在这里重复这些信息。唯一的显著差异是,我们首先必须将 RT 补丁应用到内核源树中,并配置为硬实时;我们将在下一节中介绍这一点。

让我们开始吧!

获取并应用 RTL 补丁集

检查运行在您的树莓派设备上的主线或发行版内核版本(用任何其他设备替换树莓派,您可能在其上运行 Linux);例如,在我使用的树莓派 3B+上,它正在运行带有 5.4.51-v7+内核的标准 Raspbian(或树莓派 OS)GNU/Linux 10(buster)。

我们希望为树莓派构建一个 RTL 内核,使其与当前运行的标准内核尽可能匹配;对于我们的情况,它正在运行 5.4.51[-v7+],最接近的可用 RTL 补丁是内核版本 5.4.y-rt[nn](mirrors.edge.kernel.org/pub/linux/kernel/projects/rt/5.4/);我们马上就会回到这一点...

让我们一步一步来:

  1. 下载树莓派特定的内核源树到您的主机系统磁盘的步骤已经在第三章中详细介绍过,从源代码构建 5.x Linux 内核-第二部分,在树莓派的内核构建部分;请参考并获取源树。

  2. 完成此步骤后,您应该会看到一个名为linux的目录;它保存了树莓派内核源代码,截至撰写本文的时间,内核版本为 5.4.y。y的值是多少?这很容易;只需执行以下操作:

$ head -n4 linux/Makefile 
# SPDX-License-Identifier: GPL-2.0
VERSION = 5
PATCHLEVEL = 4
SUBLEVEL = 70

这里的SUBLEVEL变量是y的值;显然,它是 70,使得内核版本为 5.4.70。

  1. 接下来,让我们下载适当的实时(RTL)补丁:最好是一个精确匹配,也就是说,补丁的名称应该类似于patch-5.4.70-rt[nn].tar.xz。幸运的是,它确实存在于服务器上;让我们获取它(请注意,我们下载patch-<kver>-rt[nn]文件;因为它是统一的补丁,所以更容易处理):

wget https://mirrors.edge.kernel.org/pub/linux/kernel/projects/rt/5.4/patch-5.4.70-rt40.patch.xz

这确实引发了一个问题:如果可用的 RTL 补丁的版本与设备的内核版本不完全匹配会怎么样?很不幸,这确实会发生。在这种情况下,为了最有可能将其应用于设备内核,选择最接近的匹配并尝试应用它;通常会成功,也许会有轻微的警告... 如果不行,您将不得不手动调整代码库以适应补丁集,或者切换到存在 RTL 补丁的内核版本(推荐)。

不要忘记解压补丁文件!

  1. 现在应用补丁(如前面所示,在应用 RTL 补丁部分):
cd linux
patch -p1 < ../patch-5.4.70-rt40.patch
  1. 配置打补丁的内核,打开CONFIG_PREEMPT_RT内核配置选项(如前面所述):

  2. 不过,正如我们在第三章中学到的,从源代码构建 5.x Linux 内核-第二部分,对于目标,设置初始内核配置是至关重要的;在这里,由于目标设备是树莓派 3[B+],请执行以下操作:

make ARCH=arm bcm2709_defconfig
    1. 使用make ARCH=arm menuconfig命令自定义您的内核配置。在这里,当然,您应该转到General setup / Preemption Model,并选择第四个选项,CONFIG_PREEMPT_RT,以打开硬实时抢占特性。
  1. 我还假设您已经为树莓派安装了适当的 x86_64 到 ARM32 的工具链:

make -j4 ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- zImage modules dtbs

提示:安装适当的工具链(用于 x86_64 到 ARM32)可以像这样简单地进行:sudo apt install ​crossbuild-essential-armhf。现在构建内核(与我们之前描述的配置和构建 RTL 内核部分相同),不同之处在于我们进行交叉编译(使用之前安装的 x86_64 到 ARM32 交叉编译器)。

  1. 安装刚构建的内核模块;确保你使用INSTALL_MOD_PATH环境变量指定了 SD 卡的根文件系统的位置(否则它可能会覆盖你主机上的模块,这将是灾难性的!)。假设 microSD 卡的第二个分区(包含根文件系统)挂载在/media/${USER}/rootfs下,然后执行以下操作(一行命令):
sudo env PATH=$PATH make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- INSTALL_MOD_PATH=/media/${USER}/rootfs modules_install
  1. 将图像文件(引导加载程序文件,内核zImage文件,设备树块DTB),内核模块)复制到树莓派 SD 卡上(这些细节在官方树莓派文档中有介绍:www.raspberrypi.org/documentation/linux/kernel/building.md;我们也在第三章中(轻微地)介绍了这一点,从源代码构建 5.x Linux 内核-第二部分)。

  2. 测试:使用 SD 卡中的新内核映像引导树莓派。你应该能够登录到一个 shell(通常是通过ssh)。验证内核版本和配置:

rpi ~ $ uname -a 
Linux raspberrypi 5.4.70-rt40-v7-llkd-rtl+ #1 SMP PREEMPT_RT Thu Oct 15 07:58:13 IST 2020 armv7l GNU/Linux
rpi ~ $ zcat /proc/config.gz |grep PREEMPT_RT
CONFIG_PREEMPT_RT=y

我们确实在设备上运行了一个硬实时内核!所以,很好 - 这解决了“准备”部分;现在你可以继续下一步了。

在设备上安装 cyclictest(和其他所需的软件包)

我们打算通过 cyclictest 应用程序对标准和新创建的 RTL 内核运行测试用例。这意味着,当然,我们必须首先获取 cyclictest 的源代码并在设备上构建它(请注意,这里的工作是在树莓派上进行的)。

这里有一篇文章介绍了这个过程:树莓派 3 在标准和实时 Linux 4.9 内核上的延迟metebalci.com/blog/latency-of-raspberry-pi-3-on-standard-and-real-time-linux-4.9-kernel/

它提到了在树莓派 3 上运行 RTL 内核时遇到的问题以及一个解决方法(重要!):(除了通常的参数之外)还要传递这两个内核参数:dwc_otg.fiq_enable=0dwc_otg.fiq_fsm_enable=0。你可以将这些参数放在设备上的/boot/cmdline.txt文件中。

首先,确保所有所需的软件包都已安装到你的树莓派上:

sudo apt install coreutils build-essential stress gnuplot libnuma-dev

libnuma-dev软件包是可选的,可能在树莓派 OS 上不可用(即使没有也可以继续)。

现在让我们获取 cyclictest 的源代码:

git clone git://git.kernel.org/pub/scm/utils/rt-tests/rt-tests.git

有点奇怪的是,最初只会存在一个文件,README。阅读它(惊喜,惊喜)。它告诉你如何获取和构建稳定版本;很简单,只需按照以下步骤进行:

git checkout -b stable/v1.0 origin/stable/v1.0
make

对我们来说很幸运,开源自动化开发实验室OSADL)有一个非常有用的 bash 脚本包装器,可以运行 cyclictest 甚至绘制延迟图。从这里获取脚本:www.osadl.org/uploads/media/mklatencyplot.bash(关于它的说明:https://www.osadl.org/Create-a-latency-plot-from-cyclictest-hi.bash-script-for-latency-plot.0.html?&no_cache=1&sword_list[0]=cyclictest)。我已经对它进行了轻微修改以适应我们的目的;它在本书的 GitHub 存储库中:ch11/latency_test/latency_test.sh

运行测试用例

为了对系统(调度)延迟有一个好的概念,我们将运行三个测试用例;在所有三个测试中,cyclictest 应用程序将在 stress(1) 实用程序将系统置于负载下时对系统延迟进行采样:

  1. 树莓派 3 型 B+(4 个 CPU 核心)运行 5.4 32 位 RTL 补丁内核

  2. 树莓派 3 型 B+(4 个 CPU 核心)运行标准 5.4 32 位树莓派 OS 内核

  3. x86_64(4 个 CPU 核心)Ubuntu 20.04 LTS 运行标准的 5.4(主线)64 位内核

我们使用一个名为 runtest 的小包装脚本覆盖 latency_test.sh 脚本以方便起见。它运行 latency_test.sh 脚本来测量系统延迟,同时运行 stress(1) 实用程序;它使用以下参数调用 stress,对系统施加 CPU、I/O 和内存负载:

stress --cpu 6 --io 2 --hdd 4 --hdd-bytes 1MB --vm 2 --vm-bytes 128M --timeout 1h

(顺便说一句,还有一个名为 stress-ng 的后续版本可用。)当 stress 应用程序执行加载系统时,cyclictest(8) 应用程序对系统延迟进行采样,并将其 stdout 写入文件:

sudo cyclictest --duration=1h -m -Sp90 -i200 -h400 -q >output

(请参考stress(1)cyclictest(8)的 man 页面以了解参数。)它将运行一个小时(为了更准确的结果,建议您将测试运行更长时间 - 也许 12 小时)。我们的 runtest 脚本(以及底层脚本)在内部使用适当的参数运行 cyclictest;它捕获并显示最小、平均和最大延迟的挂钟时间(通过time(1)),并生成直方图图表。请注意,这里我们运行 cyclictest 的最长持续时间为一小时。

默认情况下,我们的 runtest 包装脚本具有一个名为 LAT 的变量,其中包含以下设置的 latency_tests 目录的路径名:LAT=~/booksrc/ch11/latency_tests。确保您首先更新它以反映系统上 latency_tests 目录的位置。

我们在树莓派 3B+上运行 RTL 内核的测试用例#1 的脚本截图如下:

图 11.13 - 在受压力的 RTL 内核上运行树莓派 3B+的 cyclictest 的第一个测试用例

研究前面的截图;您可以清楚地看到系统详细信息,内核版本(请注意,这是 RTL 补丁的PREEMPT_RT内核!),以及 cyclictest 的最小、平均和最大(调度)延迟测量结果。

查看结果

我们对剩下的两个测试用例进行类似的过程,并在图 11.14 中总结所有三个的结果:

图 11.14 - 我们运行的(简单的)测试用例结果,显示了在一些压力下不同内核和系统的最小/平均/最大延迟

有趣的是,尽管 RTL 内核的最大延迟远低于其他标准内核,但最小延迟,更重要的是平均延迟,对于标准内核来说更好。这最终导致标准内核的整体吞吐量更高(这个观点之前也强调过)。

latency_test.sh bash 脚本调用 gnuplot(1) 实用程序生成图表,标题行显示最小/平均/最大延迟值(以微秒为单位)和运行测试的内核。请记住,测试用例#1 和#2 在树莓派 3B+设备上运行,而测试用例#3 在通用(更强大)的 x86_64 系统上运行。这里是所有三个测试用例的 gnuplot 图表:

图 11.15 - 测试用例#1 绘图:树莓派 3B+运行 5.4 RTL 内核的 cyclictest 延迟测量

图 11.15 显示了由gnuplot(1)(从我们的ch11/latency_test/latency_test.sh脚本中调用)绘制的测试用例#1 的图表。被测试设备(DUT),Raspberry Pi 3B+,有四个 CPU 核心(由操作系统看到)。注意图表如何告诉我们故事 - 绝大多数样本位于左上角,意味着大部分时间延迟非常小(100,000 到 1,000,000 延迟样本(y 轴)落在几微秒到 50 微秒(x 轴)之间!)。这真的很好!当然,在另一个极端会有离群值 - 所有 CPU 核心的样本具有更高的延迟(在 100 到 256 微秒之间),尽管样本数量要小得多。cyclictest 应用程序给出了最小、平均和最大系统延迟值。使用 RTL 补丁内核,虽然最大延迟实际上非常好(非常低),但平均延迟可能相当高:

图 11.16 - 测试用例#2 图:在运行标准(主线)5.4 内核的 Raspberry Pi 3B+上进行的 cyclictest 延迟测量

图 11.16 显示了测试用例#2 的图表。与先前的测试用例一样,实际上,在这里甚至更加明显,系统延迟样本的绝大多数表现出非常低的延迟!标准内核因此做得非常好;即使平均延迟也是一个“不错”的值。然而,最坏情况(最大)延迟值确实可能非常大 - 这正是为什么它不是一个 RTOS。对于大多数工作负载,延迟往往是“通常”很好的,但是一些特殊情况往往会出现。换句话说,它是不确定的 - 这是 RTOS 的关键特征:

图 11.17 - 测试用例#3 图:在运行标准(主线)5.4 内核的 x86_64 Ubuntu 20.04 LTS 上进行的 cyclictest 延迟测量

图 11.17 显示了测试用例#3 的图表。这里的方差 - 或抖动 - 更加明显(再次,非确定性!),尽管最小和平均系统延迟值确实非常好。当然,它是在一个远比前两个测试用例更强大的系统上运行的 - 一个桌面级的 x86_64 - 最大延迟值 - 尽管这里有更多的特殊情况,但往往相当高。再次强调,这不是一个 RTOS - 它不是确定性的。

你是否注意到图表清楚地展示了抖动:测试用例#1 具有最少的抖动(图表往往很快下降到 x 轴 - 这意味着很少数量的延迟样本,如果不是零,表现出较高的延迟),而测试用例#3 具有最多的抖动(图表大部分仍然远高于x轴!)。

再次强调这一点:结果清楚地表明,它是确定性的(非常少的抖动)与 RTOS,而与 GPOS 则是高度非确定性的!(作为一个经验法则,标准 Linux 在中断处理方面会产生大约+/- 10 微秒的抖动,而在运行 RTOS 的微控制器上,抖动会小得多,大约+/- 10 纳秒!)

进行这个实验,你会意识到基准测试是一件棘手的事情;你不应该对少数测试运行读太多(长时间运行测试,有一个大样本集是重要的)。使用您期望在系统上体验的真实工作负载进行测试,将是查看哪个内核配置产生更优越性能的更好方法;它确实会随着工作负载的变化而变化!

(Canonical 的一个有趣案例研究显示了某些工作负载的常规、低延迟和实时内核的统计数据;在本章的进一步阅读部分查找)。如前所述,通常情况下,RTL 内核的最大延迟特性往往会导致整体吞吐量较低(用户空间可能因为 RTL 的相当无情的优先级而遭受降低的 CPU)。

通过现代 BPF 工具测量调度器延迟

不详细介绍,但我们不得不提及最近和强大的[e]BPF Linux 内核功能及其相关前端;有一些专门用于测量调度器和运行队列相关系统延迟的工具。 (我们在第一章中介绍了[e]BPF 工具的安装,现代跟踪和性能分析与[e]BPF部分)。

以下表格总结了一些这些工具(BPF 前端);所有这些工具都需要以 root 身份运行(与任何 BPF 工具一样);它们将它们的输出显示为直方图(默认为微秒):

BPF 工具 它测量什么
runqlat-bpfcc 计算任务在运行队列上等待的时间,等待在处理器上运行
runqslower-bpfcc (读作 runqueue slower);计算任务在运行队列上等待的时间,显示只有超过给定阈值的线程,默认为 10 毫秒(可以通过传递微秒为单位的时间阈值来调整);实际上,您可以看到哪些任务面临(相对)较长的调度延迟。
runqlen-bpfcc 显示调度器运行队列长度+占用(当前排队等待运行的线程数)

这些工具还可以根据每个进程的任务基础提供这些指标,或者甚至可以根据 PID 命名空间(用于容器分析;当然,这些选项取决于具体的工具)。请查阅这些工具的 man 页面(第八部分),了解更多细节(甚至包括示例用法!)。

甚至还有更多与调度相关的[e]BPF 前端:cpudist- cpudist-bpfcccpuunclaimed-bpfccoffcputime-bpfccwakeuptime-bpfcc等等。请参阅进一步阅读部分获取资源。

所以,到目前为止,您不仅能够理解,甚至可以测量系统的延迟(通过cyclictest应用程序和一些现代 BPF 工具)。

我们在本章中结束时列出了一些杂项但有用的小(内核空间)例程供查看:

  • rt_prio(): 给定优先级作为参数,返回一个布尔值,指示它是否是实时任务。

  • rt_task(): 基于任务的优先级值,给定任务结构指针作为参数,返回一个布尔值,指示它是否是实时任务(是rt_prio()的包装)。

  • task_is_realtime(): 类似,但基于任务的调度策略。给定任务结构指针作为参数,返回一个布尔值,指示它是否是实时任务。

总结

在这本关于 Linux 操作系统上 CPU 调度的第二章中,您学到了一些关键内容。其中,您学会了如何使用强大的工具(如 LTTng 和 Trace Compass GUI)来可视化内核流,以及使用trace-cmd(1)实用程序,这是内核强大的 Ftrace 框架的便捷前端。然后,您了解了如何以编程方式查询和设置任何线程的 CPU 亲和力掩码。这自然而然地引出了如何以编程方式查询和设置任何线程的调度策略和优先级的讨论。整个“完全公平”的概念(通过 CFS 实现)被质疑,并且对称为 cgroups 的优雅解决方案进行了一些阐述。您甚至学会了如何利用 cgroups v2 CPU 控制器为子组中的进程分配所需的 CPU 带宽。然后我们了解到,尽管 Linux 是一个 GPOS,但 RTL 补丁集确实存在,一旦应用并且内核配置和构建完成,您就可以将 Linux 运行为真正的硬实时系统,即 RTOS。

最后,您学会了如何通过 cyclictest 应用程序以及一些现代 BPF 工具来测量系统的延迟。我们甚至在 Raspberry Pi 3 设备上使用 cyclictest 进行了测试,并在 RTL 和标准内核上进行了对比。

这是相当多的内容!一定要花时间透彻理解材料,并且以实际操作的方式进行工作。

问题

在我们结束时,这里有一些问题列表,供您测试对本章材料的了解:github.com/PacktPublishing/Linux-Kernel-Programming/tree/master/questions。您会发现一些问题的答案在书的 GitHub 存储库中:github.com/PacktPublishing/Linux-Kernel-Programming/tree/master/solutions_to_assgn

进一步阅读

为了帮助您深入了解这个主题并提供有用的材料,我们在本书的 GitHub 存储库中提供了一个相当详细的在线参考和链接列表(有时甚至包括书籍)的《进一步阅读》文档。进一步阅读文档在这里可用:github.com/PacktPublishing/Linux-Kernel-Programming/raw/master/Further_Reading.md

第三部分:深入探讨

在这里,您将了解一个高级和关键的主题:内核同步技术和 API 背后的概念、需求和用法。

本节包括以下章节:

  • 第十二章,内核同步-第一部分

  • 第十三章,内核同步-第二部分

第十二章:内核同步 - 第一部分

任何熟悉在多线程环境中编程的开发人员(甚至在单线程环境中,多个进程在共享内存上工作,或者中断是可能的情况下)都知道,当两个或多个线程(一般的代码路径)可能会竞争时,需要进行同步。也就是说,它们的结果是无法预测的。纯粹的代码本身从来不是问题,因为它的权限是读/执行(r-x);在多个 CPU 核心上同时读取和执行代码不仅完全正常和安全,而且是受鼓励的(它会提高吞吐量,这就是为什么多线程是个好主意)。然而,当你开始处理共享可写数据时,你就需要开始非常小心了!

围绕并发及其控制 - 同步 - 的讨论是多样的,特别是在像 Linux 内核(其子系统和相关区域,如设备驱动程序)这样的复杂软件环境中。因此,为了方便起见,我们将把这个大主题分成两章,本章和下一章。

在本章中,我们将涵盖以下主题:

  • 关键部分,独占执行和原子性

  • Linux 内核中的并发问题

  • 互斥锁还是自旋锁?何时使用

  • 使用互斥锁

  • 使用自旋锁

  • 锁定和中断

让我们开始吧!

关键部分,独占执行和原子性

想象一下,你正在为一个多核系统编写软件(嗯,现在,通常情况下,你会在多核系统上工作,甚至在大多数嵌入式项目中)。正如我们在介绍中提到的,同时运行多个代码路径不仅是安全的,而且是可取的(否则,为什么要花那些钱呢,对吧?)。另一方面,在并发(并行和同时)代码路径中,其中共享可写数据(也称为共享状态)以任何方式被访问的地方,你需要保证在任何给定的时间点,只有一个线程可以同时处理该数据!这真的很关键;为什么?想想看:如果你允许多个并发代码路径同时在共享可写数据上工作,你实际上是在自找麻烦:数据损坏("竞争")可能会发生。

什么是关键部分?

可以并行执行并且可以处理(读取和/或写入)共享可写数据(共享状态)的代码路径称为关键部分。它们需要保护免受并行性的影响。识别和保护关键部分免受同时执行是你作为设计师/架构师/开发人员必须处理的隐含要求。

关键部分是一段代码,必须要么独占地运行;也就是说,单独运行(序列化),要么是原子的;也就是说,不可分割地完成,没有中断。

通过独占执行,我们暗示在任何给定的时间点,只有一个线程在运行关键部分的代码;这显然是出于数据安全的原因而必需的。

这个概念也提出了原子性的重要概念:单个原子操作是不可分割的。在任何现代处理器上,两个操作被认为总是原子的;也就是说,它们不能被中断,并且会运行到完成:

  • 单个机器语言指令的执行。

  • 对齐的原始数据类型的读取或写入,该类型在处理器的字长内(通常为 32 位或 64 位);例如,在 64 位系统上读取或写入 64 位整数保证是原子的。读取该变量的线程永远不会看到中间、断裂或脏的结果;它们要么看到旧值,要么看到新值。

因此,如果您有一些处理共享(全局或静态)可写数据的代码行,在没有任何显式同步机制的情况下,不能保证独占运行。请注意,有时需要原子地运行临界区的代码,以及独占地运行,但并非始终如此。

当临界区的代码在安全睡眠的进程上下文中运行时(例如通过用户应用程序对驱动程序进行典型文件操作(打开、读取、写入、ioctl、mmap 等),或者内核线程或工作队列的执行路径),也许不需要临界区真正是原子的。但是,当其代码在非阻塞原子上下文中运行时(例如硬中断、tasklet 或 softirq),它必须像独占地运行一样原子地运行(我们将在使用互斥锁还是自旋锁?何时使用部分中更详细地讨论这些问题)。

一个概念性的例子将有助于澄清事情。假设三个线程(来自用户空间应用程序)在多核系统上几乎同时尝试打开和读取您的驱动程序。在没有任何干预的情况下,它们可能会同时运行临界区的代码,从而并行地处理共享可写数据,从而很可能破坏它!现在,让我们看一个概念性的图表,看看临界区代码路径内的非独占执行是错误的(我们甚至不会在这里谈论原子性):

图 12.1-一个概念性图表,显示了多个线程同时在临界区代码路径内运行,违反了临界区代码路径。

如前图所示,在您的设备驱动程序中,在其(比如)读取方法中,您正在运行一些代码以执行其工作(从硬件中读取一些数据)。让我们更深入地看一下这个图表在不同时间点进行数据访问

  • 从时间t0t1:没有或只有本地变量数据被访问。这是并发安全的,不需要保护,可以并行运行(因为每个线程都有自己的私有堆栈)。

  • 从时间t1t2:访问全局/静态共享可写数据。这是并发安全的;这是一个临界区,因此必须受到保护,以防并发访问。它应该只包含独占运行的代码(独自,一次只有一个线程,串行运行),可能是原子的。

  • 从时间t2t3:没有或只有本地变量数据被访问。这是并发安全的,不需要保护,可以并行运行(因为每个线程都有自己的私有堆栈)。

在本书中,我们假设您已经意识到需要同步临界区;我们将不再讨论这个特定的主题。有兴趣的人可以参考我的早期著作,《Linux 系统编程实战》(Packt 出版社,2018 年 10 月),其中详细介绍了这些问题(特别是第十五章使用 Pthreads 进行多线程编程第二部分-同步)。

因此,了解这一点,我们现在可以重新阐述临界区的概念,同时提到情况何时出现(在项目符号和斜体中显示在项目符号中)。临界区是必须按以下方式运行的代码:

  • (始终) 独占地:独自(串行)

  • (在原子上下文中) 原子地:不可分割地,完整地,没有中断

在下一节中,我们将看一个经典场景-全局整数的增量。

一个经典案例-全局 i++

想想这个经典的例子:在一个并发的代码路径中,一个全局的i整数正在被增加,其中多个执行线程可以同时执行。对计算机硬件和软件的天真理解会让你相信这个操作显然是原子的。然而,现代硬件和软件(编译器和操作系统)实际上比你想象的要复杂得多,因此会引起各种看不见的(对应用程序开发者来说)性能驱动的优化。

我们不打算在这里深入讨论太多细节,但现实情况是现代处理器非常复杂:它们采用了许多技术来提高性能,其中一些是超标量和超流水线执行,以便并行执行多个独立指令和各种指令的几个部分(分别),进行即时指令和/或内存重排序,在复杂的层次结构的 CPU 缓存中缓存内存,虚假共享等等!我们将在第十三章中的内核同步 - 第二部分中的缓存效应 - 虚假共享内存屏障部分中深入探讨其中的一些细节。

Matt Kline 在 2020 年 4 月发表的论文《每个系统程序员都应该了解并发性》(assets.bitbashing.io/papers/concurrency-primer.pdf)非常出色,是这个主题上必读的内容;一定要阅读!

所有这些使得情况比乍一看复杂得多。让我们继续讨论经典的i ++

static int i = 5;
[ ... ]
foo()
{
    [ ... ]
    i ++;     // is this safe? yes, if truly atomic... but is it truly atomic??
}

这个增量操作本身安全吗?简短的答案是否定的,你必须保护它。为什么?这是一个关键部分 - 我们正在访问共享的可写数据进行读取和/或写入操作。更长的答案是,这实际上取决于增量操作是否真正是原子的(不可分割的);如果是,那么i ++在并行性的情况下不会造成危险 - 如果不是,就会有危险!那么,我们如何知道i ++是否真正是原子的呢?有两件事决定了这一点:

  • 处理器的指令集架构(ISA),它确定了(在处理器的低级别上)在运行时执行的机器指令。

  • 编译器。

如果 ISA 有能力使用单个机器指令执行整数增量,并且编译器有智能使用它,那么它是真正原子的 - 它是安全的,不需要锁定。否则,它是不安全的,需要锁定!

试一试:将浏览器导航到这个精彩的编译器资源网站:godbolt.org/。选择 C 作为编程语言,然后在左侧窗格中声明全局的i整数并在一个函数中进行增量。在右侧窗格中使用适当的编译器和编译器选项进行编译。你将看到为 C 高级i ++;语句生成的实际机器代码。如果它确实是一个单一的机器指令,那么它将是安全的;如果不是,你将需要锁定。总的来说,你会发现你真的无法判断:实际上,你不能假设事情是安全的 - 你必须假设它默认是不安全的并加以保护!这可以在以下截图中看到:

图 12.2 - 即使是最新的稳定 gcc 版本,但没有优化,x86_64 gcc 对 i ++产生了多个指令

前面的截图清楚地显示了这一点:左右两个窗格中的黄色背景区域分别是 C 源代码和编译器生成的相应汇编代码(基于 x86_64 ISA 和编译器的优化级别)。默认情况下,没有优化,i ++会变成三条机器指令。这正是我们所期望的:它对应于获取(内存到寄存器)、增量存储(寄存器到内存)!现在,这不是原子的;完全有可能,在其中一条机器指令执行后,控制单元干扰并将指令流切换到不同的位置。这甚至可能导致另一个进程或线程被上下文切换!

好消息是,通过在“编译器选项...”窗口中快速添加-O2i ++只变成了一条机器指令-真正的原子操作!然而,我们无法预测这些事情;有一天,您的代码可能在一个相当低端的 ARM(RISC)系统上执行,增加了需要多条机器指令来执行i ++的机会。(不用担心-我们将在使用原子整数操作符部分介绍专门针对整数的优化锁技术)。

现代语言提供本地原子操作符;对于 C/C++来说,这是相当近期的(从 2011 年起);ISO C++11 和 ISO C11 标准为此提供了现成的和内置的原子变量。稍微搜索一下就可以快速找到它们。现代 glibc 也在使用它们。例如,如果您在用户空间中使用信号处理,您将知道要使用volatile sig_atomic_t数据类型来安全地访问和/或更新信号处理程序中的原子整数。那么内核呢?在下一章中,您将了解 Linux 内核对这一关键问题的解决方案。我们将在使用原子整数操作符使用原子位操作符部分进行介绍。

Linux 内核当然是一个并发环境:多个执行线程在多个 CPU 核心上并行运行。不仅如此,即使在单处理器(UP/单 CPU)系统上,硬件中断、陷阱、故障、异常和软件信号也可能导致数据完整性问题。毋庸置疑,保护代码路径中所需的并发性比说起来要容易;识别和保护关键部分使用诸如锁等技术的同步原语和技术是绝对必要的,这也是为什么这是本章和下一章的核心主题。

概念-锁

我们需要同步,因为事实上,如果没有任何干预,线程可以同时执行关键部分,其中共享可写数据(共享状态)正在被处理。为了消除并发性,我们需要摆脱并行性,我们需要序列化关键部分内的代码-共享数据正在被处理的地方(用于读取和/或写入)。

强制使代码路径变得序列化的常见技术是使用。基本上,锁通过保证在任何给定时间点上只有一个执行线程可以“获取”或拥有锁来工作。因此,在代码中使用锁来保护关键部分将给您我们所追求的东西-独占地运行关键部分的代码(也许是原子的;关于这一点将在后面详细介绍)。

图 12.3-一个概念图,显示了如何使用锁来保护关键部分代码路径,确保独占性

前面的图表显示了解决前面提到的情况的一种方法:使用锁来保护关键部分!锁(和解锁)在概念上是如何工作的呢?

锁的基本前提是每当有争用时 - 也就是说,当多个竞争的线程(比如,n个线程)尝试获取锁(LOCK操作)时 - 恰好只有一个线程会成功。这被称为锁的“赢家”或“所有者”。它将lock API 视为非阻塞调用,因此在执行临界区代码时会继续运行 - 并且是独占的(临界区实际上是lockunlock操作之间的代码!)。那么剩下的n-1个“失败者”线程会发生什么呢?它们(也许)将锁 API 视为阻塞调用;它们实际上在等待。等待什么?当然是由锁的所有者(“赢家”线程)执行的unlock操作!一旦解锁,剩下的n-1个线程现在竞争下一个“赢家”位置;当然,其中一个将“赢”并继续前进;在此期间,n-2个失败者现在将等待(新的)赢家的unlock;这一过程重复,直到所有n个线程(最终和顺序地)获取锁。

现在,锁当然有效,但 - 这应该是相当直观的 - 它会导致(相当大的!)开销,因为它破坏了并行性并使执行流程串行化!为了帮助您可视化这种情况,想象一个漏斗,狭窄的茎是临界区,一次只能容纳一个线程。所有其他线程都被堵住;锁创建了瓶颈:

图 12.4 - 锁创建了一个瓶颈,类似于物理漏斗

另一个经常提到的物理类比是一条高速公路,有几条车道汇入一条非常繁忙 - 交通拥堵的车道(也许是一个设计不佳的收费站)。再次,并行性 - 车辆(线程)在不同车道(CPU)中与其他车辆并行行驶 - 被丢失,需要串行行为 - 车辆被迫排队排队。

因此,作为软件架构师,我们必须尽量设计我们的产品/项目,以便最小化锁的需求。虽然在大多数现实世界的项目中完全消除全局变量是不切实际的,但优化和最小化它们的使用是必要的。我们将在以后介绍更多相关内容,包括一些非常有趣的无锁编程技术。

另一个非常关键的点是,一个新手程序员可能天真地认为对可写数据对象进行读取是完全安全的,因此不需要显式保护(除了在处理器总线大小内的对齐原始数据类型之外);这是不正确的。这种情况可能导致所谓的脏读或破碎读,即在另一个写入线程同时写入时,可能读取到过时的数据,而你 - 在没有锁的情况下 - 正在读取同一个数据项。

由于我们正在讨论原子性,正如我们刚刚了解的那样,在典型的现代微处理器上,唯一保证是原子的是单个机器语言指令或对处理器总线宽度内的对齐原始数据类型的读/写。那么,如何标记几行“C”代码,使其真正原子化?在用户空间,这甚至是不可能的(我们可以接近,但无法保证原子性)。

在用户空间应用程序中如何“接近”原子性?您可以始终构建一个用户线程来使用SCHED_FIFO策略和99的实时优先级。这样,当它想要运行时,除了硬件中断/异常之外,几乎没有其他东西可以抢占它。(旧的音频子系统实现就大量依赖于此。)

在内核空间,我们可以编写真正原子化的代码。具体来说,我们可以使用自旋锁!我们将很快更详细地了解自旋锁。

关键点摘要

让我们总结一些关于关键部分的关键点。仔细审查这些内容非常重要,保持这些内容方便,并确保您在实践中使用它们:

  • 关键部分是可以并行执行并且可以处理(读取和/或写入)共享可写数据(也称为“共享状态”)的代码路径。

  • 因为它在共享可写数据上工作,关键部分需要保护免受以下方面的影响:

  • 并行性(也就是说,它必须独立/串行/以互斥的方式运行)

  • 在原子(中断)非阻塞上下文中运行时 - 原子地:不可分割地,完整地,没有中断。一旦受保护,您可以安全地访问共享状态,直到“解锁”。

  • 代码库中的每个关键部分都必须被识别和保护:

  • 识别关键部分至关重要!仔细审查您的代码,并确保您没有错过它们。

  • 可以通过各种技术来保护它们;一个非常常见的技术是锁定(还有无锁编程,我们将在下一章节中看到)。

  • 一个常见的错误是只保护写入全局可写数据的关键部分;您还必须保护读取全局可写数据的关键部分;否则,您会面临撕裂或脏读!为了帮助澄清这一关键点,想象一下在 32 位系统上读取和写入无符号 64 位数据项;在这种情况下,操作无法是原子的(需要两次加载/存储操作)。因此,如果在一个线程中读取数据项的值的同时,另一个线程正在同时写入数据项,会发生什么?写入线程会以某种方式“锁定”,但是因为您认为读取是安全的,读取线程不会获取锁;由于不幸的时间巧合,您最终可能会执行部分/撕裂/脏读!我们将在接下来的章节和下一章节中学习如何通过使用各种技术来克服这些问题。

  • 另一个致命的错误是不使用相同的锁来保护给定的数据项。

  • 未能保护关键部分会导致数据竞争,即结果 - 被读/写的数据的实际值 - 是“竞争的”,这意味着它会根据运行时情况和时间而变化。这被称为一个错误。(一旦在“现场”中,这种错误非常难以看到,重现,确定其根本原因并修复。我们将在下一章节中介绍一些非常有用的内容,以帮助您解决这个问题,在内核中的锁调试部分;一定要阅读!)

  • 异常:在以下情况下,您是安全的(隐式地,无需显式保护):

  • 当您处理局部变量时。它们分配在线程的私有堆栈上(或者,在中断上下文中,分配在本地 IRQ 堆栈上),因此,根据定义,是安全的。

  • 当您在代码中处理共享可写数据时,这些代码不可能在另一个上下文中运行;也就是说,它是串行化的。在我们的上下文中,LKM 的initcleanup方法符合条件(它们仅在insmodrmmod上运行一次,串行地运行)。

  • 当您处理真正的常量和只读共享数据时(不过不要让 C 的const关键字愚弄您!)。

  • 锁定本质上是复杂的;您必须仔细思考,设计和实现以避免死锁。我们将在锁定指南和死锁部分中更详细地介绍这一点。

Linux 内核中的并发关注点

在内核代码中识别关键部分至关重要;如果您甚至看不到它,您如何保护它?以下是一些建议,可以帮助您作为新兴的内核/驱动程序开发人员识别并发关注点 - 因此关键部分可能出现的地方:

  • 对称多处理器SMP)系统的存在(CONFIG_SMP

  • 可抢占内核的存在

  • 阻塞 I/O

  • 硬件中断(在 SMP 或 UP 系统上)

这些是需要理解的关键点,我们将在本节中讨论每一个。

多核 SMP 系统和数据竞争

第一点是非常明显的;请看以下截图中显示的伪代码:

图 12.5 - 伪代码 - 在(虚构的)驱动程序读取方法中的临界区;由于没有锁定,这是错误的

这与我们在图 12.112.3中展示的情况类似;只是这里,我们用伪代码来展示并发性。显然,从时间t2到时间t3,驱动程序正在处理一些全局共享的可写数据,因此这是一个临界区。

现在,想象一个具有四个 CPU 核心(SMP 系统)的系统;两个用户空间进程,P1(在 CPU 0 上运行)和 P2(在 CPU 2 上运行),可以同时打开设备文件并同时发出read(2)系统调用。现在,两个进程将同时执行驱动程序读取“方法”,因此同时处理共享可写数据!这(在t2t3之间的代码)是一个临界区,由于我们违反了基本的排他规则 - 临界区必须由单个线程在任何时间点执行 - 我们很可能最终损坏数据、应用程序,甚至更糟。

换句话说,现在这是一个数据竞争;取决于微妙的时间巧合,我们可能会或可能不会生成错误(错误)。这种不确定性 - 微妙的时间巧合 - 正是发现和修复这种错误极其困难的原因(它可能逃脱了您的测试工作)。

这句格言非常不幸地是真的:测试可以检测到错误的存在,但不能检测到它们的缺失。更重要的是,如果您的测试未能捕捉到竞争(和错误),允许它们在现场自由发挥,那么情况会更糟。

您可能会觉得,由于您的产品是在单个 CPU 核心(UP)上运行的小型嵌入式系统,因此关于控制并发性(通常通过锁定)的讨论不适用于您。我们不这么认为:几乎所有现代产品,如果尚未这样做,都将转向多核(也许是在它们的下一代阶段)。更重要的是,即使是 UP 系统也存在并发性问题,我们将在下文中探讨。

可抢占内核,阻塞 I/O 和数据竞争

想象一下,您正在在已配置为可抢占的 Linux 内核上运行您的内核模块或驱动程序(即CONFIG_PREEMPT已打开;我们在第十章中涵盖了这个主题,CPU 调度器 - 第一部分)。考虑一个进程 P1,在进程上下文中运行驱动程序的读取方法代码,处理全局数组。现在,在临界区内(在时间t2t3之间),如果内核抢占了进程 P1 并切换到另一个进程 P2,后者正好在等待执行这条代码路径?这是危险的,同样是数据竞争。这甚至可能发生在 UP 系统上!

另一个有些类似的情景(同样可能发生在单核(UP)或多核系统上):进程 P1 正在通过驱动程序方法的临界区运行(在时间t2t3之间;再次参见图 12.5)。这一次,在临界区内,如果遇到阻塞调用会怎么样呢?

阻塞调用是一个导致调用进程上下文进入休眠状态,等待事件发生的函数;当事件发生时,内核将“唤醒”该任务,并且它将从中断的地方恢复执行。这也被称为 I/O 阻塞,非常常见;许多 API(包括几个用户空间库和系统调用,以及几个内核 API)本质上是阻塞的。在这种情况下,进程 P1 实际上从 CPU 上的上下文切换并进入休眠状态,这意味着schedule()的代码运行并将其排队到等待队列。在此期间,在 P1 被切换回之前,如果另一个进程 P2 被调度运行会怎么样?如果该进程也在运行这个特定的代码路径怎么办?想一想 - 当 P1 回来时,共享数据可能已经在其“下面”发生了变化,导致各种错误;再次,数据竞争,一个 bug!

硬件中断和数据竞争

最后,设想这样的情景:进程 P1 再次无辜地运行驱动程序的读取方法代码;它进入临界区(在时间t2t3之间;再次参见图 12.5)。它取得了一些进展,但然后,哎呀,硬件中断触发了(在同一个 CPU 上)!(你将在Linux 内核编程(第二部分)中详细了解。)在 Linux 操作系统上,硬件(外围)中断具有最高的优先级;它们默认情况下会抢占任何代码(包括内核代码)。因此,进程(或线程)P1 至少会被暂时搁置,从而失去处理器;中断处理代码将抢占它并运行。

好吧,你可能会想,那又怎样?的确,这是一个非常普遍的现象!硬件中断在现代系统上非常频繁地触发,有效地(字面上)中断了各种任务上下文(在你的 shell 上快速执行vmstat 3命令;system标签下的列in显示了你的系统在最近 1 秒内触发的硬件中断数量!)。要问的关键问题是:中断处理代码(无论是硬中断的顶半部分还是所谓的任务或软中断的底半部分,无论哪个发生了),是否共享并处理了它刚刚中断的进程上下文的相同可写数据?

如果是这样,那么,休斯顿,我们有问题 - 数据竞争!如果不是,那么你中断的代码对于中断代码路径来说不是一个临界区,这很好。事实是,大多数设备驱动程序确实处理中断;因此,驱动程序的作者(你!)有责任确保没有全局或静态数据 - 实际上,没有临界区 - 在进程上下文和中断代码路径之间共享。如果它们是(这确实会发生),你必须以某种方式保护这些数据,以防数据竞争和可能的损坏。

这些情景可能让你觉得防范这些并发问题是一个非常艰巨的任务;在各种可能的并发问题存在的情况下,如何确保数据的安全?有趣的是,实际的 API 并不难学习使用;我们再次强调,识别临界区是关键。

再次,关于锁(概念上)如何工作,锁定指南(非常重要;我们很快会回顾它们),以及如何防止死锁的类型,都在我早期的书籍《Linux 系统编程实战》(Packt,2018 年 10 月)中有详细介绍。这本书在第十五章 使用 Pthreads 进行多线程编程第 II 部分 - 同步中详细介绍了这些内容。

话不多说,让我们深入探讨将保护我们的临界区的主要同步技术 - 锁定。

锁定指南和死锁

锁定,本质上是一个复杂的问题;它往往会产生复杂的交织情况。不充分理解它可能会导致性能问题和错误-死锁、循环依赖、中断不安全的锁定等。以下锁定指南对确保正确编写代码至关重要:

  • 锁定粒度:锁定和解锁之间的“距离”(实际上是关键部分的长度)不应该是粗粒度的(关键部分太长),它应该是“足够细”; 这是什么意思?下面的几点解释了这一点:

  • 在这里你需要小心。在大型项目中,保持太少的锁是一个问题,保持太多的锁也是一个问题!太少的锁可能会导致性能问题(因为相同的锁会被重复使用,因此往往会受到高度争用)。

  • 拥有很多锁实际上对性能有好处,但对复杂性控制不利。这也导致另一个关键点的理解:在代码库中有很多锁的情况下,你应该非常清楚哪个锁保护哪个共享数据对象。如果你使用,比如,lockA来保护mystructX,但在一个遥远的代码路径(也许是一个中断处理程序)中你忘记了这一点,并尝试使用另一个锁,lockB,来保护同一个结构!现在这些事情可能听起来很明显,但(有经验的开发人员知道),在足够的压力下,即使显而易见的事情也并非总是显而易见!

  • 尝试平衡事物。在大型项目中,使用一个锁来保护一个全局(共享)数据结构是典型的。 (为锁变量命名可能本身就会成为一个大问题!这就是为什么我们将保护数据结构的锁放在其中作为成员。)

  • 锁的顺序至关重要;锁必须始终以相同的顺序获取,并且他们的顺序应该由所有在项目上工作的开发人员记录和遵循(注释锁也很有用;在下一章关于lockdep的部分中会更多介绍)。不正确的锁顺序经常导致死锁。

  • 尽量避免递归锁定。

  • 注意防止饥饿;验证一旦获取锁,确实“足够快”释放锁。

  • 简单是关键:尽量避免复杂性或过度设计,特别是涉及锁的复杂情况。

在锁定的话题上,(危险的)死锁问题出现了。死锁是无法取得任何进展;换句话说,应用程序和/或内核组件似乎无限期地挂起。虽然我们不打算在这里深入研究死锁的可怕细节,但我会快速提到一些可能发生的常见死锁场景:

  • 简单情况,单锁,进程上下文:

  • 我们尝试两次获取相同的锁;这会导致自死锁

  • 简单情况,多个(两个或更多)锁,进程上下文-一个例子:

  • 在 CPU 0上,线程 A 获取锁 A,然后想要获取锁 B。

  • 同时,在 CPU 1上,线程 B 获取锁 B,然后想要获取锁 A。

  • 结果是死锁,通常称为AB-BA 死锁

  • 它可以被扩展;例如,AB-BC-CA 循环依赖(A-B-C 锁链)导致死锁。

  • 复杂情况,单锁,进程和中断上下文:

  • 锁 A 在中断上下文中获取。

  • 如果中断发生(在另一个核心上),处理程序尝试获取锁 A 会发生什么?死锁是结果!因此,在中断上下文中获取的锁必须始终与中断禁用一起使用。(如何?我们将在涵盖自旋锁时更详细地讨论这个问题。)

  • 更复杂的情况,多个锁,进程和中断(硬中断和软中断)上下文

在更简单的情况下,始终遵循锁定顺序指南就足够了:始终按照有文档记录的顺序获取和释放锁(我们将在使用互斥锁部分的内核代码中提供一个示例)。然而,这可能会变得非常复杂;复杂的死锁情况甚至会使经验丰富的开发人员感到困惑。幸运的是,lockdep - Linux 内核的运行时锁依赖验证器 - 可以捕捉到每一个死锁情况!(不用担心 - 我们会到那里的:我们将在下一章中详细介绍 lockdep)。当我们涵盖自旋锁(使用自旋锁部分)时,我们将遇到类似于之前提到的进程和/或中断上下文场景;在那里清楚地说明了要使用的自旋锁类型。

关于死锁,Steve Rostedt 在 2011 年的 Linux Plumber's Conference 上做了一个关于 lockdep 的非常详细的介绍;相关幻灯片内容丰富,探讨了简单和复杂的死锁场景,以及 lockdep 如何检测它们(blog.linuxplumbersconf.org/2011/ocw/sessions/153)。

另外,现实情况是,不仅是死锁,甚至活锁情况也可能同样致命!活锁本质上是一种类似于死锁的情况;只是参与任务的状态是运行而不是等待。例如,中断“风暴”可能导致活锁;现代网络驱动程序通过关闭中断(在中断负载下)并采用称为新 API;切换中断NAPI)的轮询技术来减轻这种效应(在适当时重新打开中断;好吧,这比较复杂,但我们就到这里吧)。

对于那些一直生活在石头下的人来说,你会知道 Linux 内核有两种主要类型的锁:互斥锁和自旋锁。实际上,还有其他几种类型,包括其他同步(和“无锁”编程)技术,所有这些都将在本章和下一章中涵盖。

互斥锁还是自旋锁?在何时使用

学习使用互斥锁和自旋锁的确切语义非常简单(在内核 API 集中进行适当的抽象,使得对于典型的驱动程序开发人员或模块作者来说更容易)。在这种情况下的关键问题是一个概念性的问题:两种锁之间的真正区别是什么?更重要的是,在什么情况下应该使用哪种锁?您将在本节中找到这些问题的答案。

以前的驱动程序读取方法的伪代码(图 12.5)作为基本示例,假设有三个线程 - tAtBtC - 在并行运行(在 SMP 系统上)通过此代码。我们将通过在临界区开始之前(时间t2)获取锁来解决这个并发问题,同时避免任何数据竞争,并在临界区代码路径结束后(时间t3)释放锁(解锁)。让我们再次看一下伪代码,这次使用锁定以确保它是正确的:

图 12.6 - 伪代码 - 在(虚构的)驱动程序读取方法中的临界区;正确,带锁定

当三个线程尝试同时获取锁时,系统保证只有一个线程会获得。假设tB(线程 B)获得了锁:现在它是“赢家”或“所有者”线程。这意味着线程tAtC是“输家”;他们会等待解锁!“赢家”(tB)完成临界区并解锁锁时,之前的输家之间的竞争重新开始;其中一个将成为下一个赢家,过程重复。

两种锁类型 - 互斥锁和自旋锁 - 的关键区别在于失败者等待解锁的方式。使用互斥锁时,失败的线程会进入睡眠状态;也就是说,它们会通过睡眠来等待。当获胜者执行解锁时,内核会唤醒失败者(所有失败者),它们会再次运行,争夺锁。(事实上,互斥锁和信号量有时被称为睡眠锁。)

然而,使用自旋锁,没有睡眠的问题;失败者会在锁上旋转等待,直到它被解锁。从概念上看,情况如下:

while (locked) ;

请注意,这仅仅是概念上的。好好想一想 - 这实际上是轮询。然而,作为一个好的程序员,你会明白,轮询通常被认为是一个坏主意。那么,为什么自旋锁会这样工作呢?其实并不是这样;它只是以这种方式呈现出来是为了概念上的目的。很快你就会明白,自旋锁只在多核(SMP)系统上才有意义。在这样的系统中,当获胜的线程离开并运行临界区代码时,失败者会在其他 CPU 核心上旋转等待!实际上,在实现级别上,用于实现现代自旋锁的代码是高度优化的(并且与体系结构相关),并不是通过简单地“旋转”来工作(例如,许多 ARM 的自旋锁实现使用等待事件WFE)机器语言指令,该指令使 CPU 在低功耗状态下等待;请参阅进一步阅读部分,了解有关自旋锁内部实现的几个资源)。

理论上确定使用哪种锁

自旋锁的实现方式并不是我们关心的重点;自旋锁的开销比互斥锁低这一事实对我们很有兴趣。为什么呢?其实很简单:对于互斥锁来说,失败的线程必须进入睡眠状态。为了做到这一点,内部会调用schedule()函数,这意味着失败者将把互斥锁 API 视为一个阻塞调用!调用调度程序最终会导致处理器被切换上下文。相反,当拥有者线程解锁锁时,失败的线程必须被唤醒;同样,它将被切换回处理器。因此,互斥锁/解锁操作的最小“成本”是在给定机器上执行两次上下文切换所需的时间。(请参阅下一节中的信息框。)通过再次查看前面的屏幕截图,我们可以确定一些事情,包括在临界区(“锁定”代码路径)中花费的时间;也就是说,t_locked = t3 - t2

假设t_ctxsw代表上下文切换的时间。正如我们所了解的,互斥锁/解锁操作的最小成本是2 * t_ctxsw。现在,假设以下表达式为真:

t_locked < 2 * t_ctxsw

换句话说,如果在临界区内花费的时间少于两次上下文切换所需的时间,那么使用互斥锁就是错误的,因为这样的开销太大;执行元工作的时间比实际工作的时间更多 - 这种现象被称为抖动。这种情况 - 非常短的临界区存在的情况 - 在现代操作系统(如 Linux)中经常出现。因此,总之,对于短的非阻塞临界区,使用自旋锁(远远)优于使用互斥锁。

实际上确定使用哪种锁

因此,根据t_locked < 2 * t_ctxsw的“规则”进行操作在理论上可能很好,但等等:你真的期望精确测量每种情况中临界区内的上下文切换时间和花费的时间吗?当然不是 - 那太不现实和过分了。

从实际角度来看,可以这样理解:互斥锁通过让失败的线程在解锁时睡眠来工作;自旋锁不会(失败的线程“自旋”)。让我们回顾一下 Linux 内核的一个黄金规则:内核不能在任何类型的原子上下文中睡眠(调用schedule())。因此,我们永远不能在中断上下文中使用互斥锁,或者在任何不安全睡眠的上下文中使用;然而,使用自旋锁是可以的。(记住,一个阻塞的 API 是通过调用schedule()来使调用上下文进入睡眠状态的。)让我们总结一下:

  • 关键部分是否在原子(中断)上下文中运行,或者在进程上下文中运行,其中它不能睡眠?使用自旋锁。

  • 关键部分是否在进程上下文中运行,并且在关键部分需要睡眠吗?使用互斥锁。

当然,使用自旋锁的开销比使用互斥锁要低;因此,你甚至可以在进程上下文中使用自旋锁(比如我们虚构的驱动程序的读方法),只要关键部分不阻塞(睡眠)。

[1] 上下文切换所需的时间是不同的;它在很大程度上取决于硬件和操作系统的质量。最近(2018 年 9 月)的测量结果显示,在固定的 CPU 上,上下文切换时间大约为 1.2 到 1.5 微秒,在没有固定的情况下大约为 2.2 微秒(https://eli.thegreenplace.net/2018/measuring-context-switching-and-memory-overheads-for-linux-threads/)。

硬件和 Linux 操作系统都有了巨大的改进,因此平均上下文切换时间也有了显著提高。一篇 1998 年 12 月的 Linux Journal 文章确定,在 x86 类系统上,平均上下文切换时间为 19 微秒,最坏情况下为 30 微秒。

这带来了一个问题,我们如何知道代码当前是在进程上下文还是中断上下文中运行的?很简单:我们的PRINT_CTX()宏(在我们的convenient.h头文件中)可以告诉我们这一点:

if (in_task())
    /* we're in process context (usually safe to sleep / block) */
else
    /* we're in an atomic or interrupt context (cannot sleep / block) */

(我们的PRINT_CTX()宏的实现细节在《Linux 内核编程(第二部分)》中有介绍)。

现在你明白了在什么情况下使用互斥锁或自旋锁,让我们进入实际用法。我们将从如何使用互斥锁开始!

使用互斥锁

互斥锁也被称为可睡眠或阻塞互斥锁。正如你所学到的,如果关键部分可以睡眠(阻塞),则它们在进程上下文中使用。它们不能在任何类型的原子或中断上下文(顶半部、底半部,如 tasklets 或 softirqs 等)、内核定时器,甚至不允许阻塞的进程上下文中使用。

初始化互斥锁

在内核中,互斥锁“对象”表示为struct mutex数据结构。考虑以下代码:

#include <linux/mutex.h>
struct mutex mymtx;

要使用互斥锁,必须显式地将其初始化为解锁状态。初始化可以通过DEFINE_MUTEX()宏进行静态执行(声明和初始化对象),也可以通过mutex_init()函数进行动态执行(实际上是对__mutex_init()函数的宏包装)。

例如,要声明和初始化一个名为mymtx的互斥锁对象,我们可以使用DEFINE_MUTEX(mymtx);

我们也可以动态地做这个。为什么是动态的?通常,互斥锁是它所保护的(全局)数据结构的成员(聪明!)。例如,假设我们在驱动程序代码中有以下全局上下文结构(请注意,这段代码是虚构的):

struct mydrv_priv {
    <member 1>;
    <member 2>;
    [...]
    struct mutex mymtx; /* protects access to mydrv_priv */
    [...]
};

然后,在你的驱动程序(或 LKM)的init方法中,执行以下操作:

static int init_mydrv(struct mydrv_priv *drvctx)
{
    [...]
    mutex_init(drvctx->mymtx);
    [...]
}

将锁变量作为(父)数据结构的成员保护起来是 Linux 中常见(而巧妙)的模式;这种方法的附加好处是避免了命名空间污染,并且清楚地表明了哪个互斥锁保护了哪个共享数据项(这在庞大的项目中可能比看起来更为严重,尤其是在 Linux 内核等庞大项目中!)。

将保护全局或共享数据结构的锁作为该数据结构的成员。

正确使用互斥锁

通常情况下,你可以在内核源代码树中找到非常有见地的注释。这里有一个很好的总结了你必须遵循以正确使用互斥锁的规则的注释;请仔细阅读:

// include/linux/mutex.h
/*
 * Simple, straightforward mutexes with strict semantics:
 *
 * - only one task can hold the mutex at a time
 * - only the owner can unlock the mutex
 * - multiple unlocks are not permitted
 * - recursive locking is not permitted
 * - a mutex object must be initialized via the API
 * - a mutex object must not be initialized via memset or copying
 * - task may not exit with mutex held
 * - memory areas where held locks reside must not be freed
 * - held mutexes must not be reinitialized
 * - mutexes may not be used in hardware or software interrupt
 * contexts such as tasklets and timers
 *
 * These semantics are fully enforced when DEBUG_MUTEXES is
 * enabled. Furthermore, besides enforcing the above rules, the mutex
 * [ ... ]

作为内核开发人员,你必须理解以下内容:

  • 关键部分会导致代码路径被序列化,破坏了并行性。因此,至关重要的是将关键部分保持尽可能短。与此相关的是锁定数据,而不是代码

  • 尝试重新获取已经获取(锁定)的互斥锁——实际上是递归锁定——受支持,并且会导致自我死锁。

  • 锁定顺序:这是防止危险死锁情况的一个非常重要的经验法则。在存在多个线程和多个锁的情况下,关键的是锁定的顺序被记录并且所有在项目上工作的开发人员都严格遵循。实际的锁定顺序本身并不是不可侵犯的,但一旦决定了,就必须遵循。在浏览内核源代码树时,你会发现许多地方,内核开发人员确保这样做,并且(通常)为其他开发人员写下了一条注释以便他们看到并遵循。这里有一个来自 slab 分配器代码(mm/slub.c)的示例注释:

/*
 * Lock order:
 * 1\. slab_mutex (Global Mutex)
 * 2\. node->list_lock
 * 3\. slab_lock(page) (Only on some arches and for debugging)

现在我们从概念上理解了互斥锁的工作原理(并且理解了它们的初始化),让我们学习如何使用锁定/解锁 API。

互斥锁和解锁 API 及其用法

互斥锁的实际锁定和解锁 API 如下。以下代码分别显示了如何锁定和解锁互斥锁:

void __sched mutex_lock(struct mutex *lock);
void __sched mutex_unlock(struct mutex *lock);

(这里忽略__sched;这只是一个编译器属性,使得这个函数在WCHAN输出中消失,在 procfs 中出现,并且在ps(1)的某些选项开关下显示出来。)

再次强调,在kernel/locking/mutex.c源代码中的注释非常详细和描述性;我鼓励你更详细地查看这个文件。我们在这里只展示了其中的一些代码,这些代码直接取自 5.4 版 Linux 内核源代码树:

// kernel/locking/mutex.c
[ ... ]
/**
 * mutex_lock - acquire the mutex
 * @lock: the mutex to be acquired
 *
 * Lock the mutex exclusively for this task. If the mutex is not
 * available right now, it will sleep until it can get it.
 *
 * The mutex must later on be released by the same task that
 * acquired it. Recursive locking is not allowed. The task
 * may not exit without first unlocking the mutex. Also, kernel
 * memory where the mutex resides must not be freed with
 * the mutex still locked. The mutex must first be initialized
 * (or statically defined) before it can be locked. memset()-ing
 * the mutex to 0 is not allowed.
 *
 * (The CONFIG_DEBUG_MUTEXES .config option turns on debugging
 * checks that will enforce the restrictions and will also do
 * deadlock debugging)
 *
 * This function is similar to (but not equivalent to) down().
 */
void __sched mutex_lock(struct mutex *lock)
{
    might_sleep();

    if (!__mutex_trylock_fast(lock))
        __mutex_lock_slowpath(lock);
}
EXPORT_SYMBOL(mutex_lock);

might_sleep()是一个具有有趣调试属性的宏;它捕捉到了应该在原子上下文中执行但实际上没有执行的代码!(关于might_sleep()的解释可以在Linux 内核编程(第二部分)书中找到)。因此,请考虑:might_sleep()mutex_lock()中的第一行代码,这意味着这段代码路径不应该被任何处于原子上下文中的东西执行,因为它可能会休眠。这意味着只有在进程上下文中安全休眠时才应该使用互斥锁!

一个快速而重要的提醒:Linux 内核可以配置大量的调试选项;在这种情况下,CONFIG_DEBUG_MUTEXES配置选项将帮助你捕捉可能的与互斥锁相关的错误,包括死锁。同样,在 Kernel Hacking 菜单下,你会找到大量与调试相关的内核配置选项。我们在第五章中讨论过这一点,编写你的第一个内核模块——LKMs 第二部分。关于锁调试,有几个非常有用的内核配置;我们将在下一章中介绍这些内容,在内核中的锁调试部分。

互斥锁——通过[不]可中断的睡眠?

和我们迄今为止看到的互斥锁一样,还有更多。你已经知道 Linux 进程(或线程)会在状态机的各种状态之间循环。在 Linux 上,睡眠有两种离散状态-可中断睡眠和不可中断睡眠。处于可中断睡眠状态的进程(或线程)是敏感的,这意味着它会响应用户空间信号,而处于不可中断睡眠状态的任务对用户信号不敏感。

在一个具有人机交互的应用程序中,根据一个经验法则,你通常应该将一个进程置于可中断睡眠状态(当它在锁上阻塞时),这样就由最终用户决定是否通过按下Ctrl + C(或某种涉及信号的机制)来中止应用程序。在类 Unix 系统上通常遵循的设计规则是:提供机制,而不是策略。话虽如此,在非交互式代码路径上,通常情况下你必须等待锁来无限期地等待,这意味着已经传递给任务的信号不应该中止阻塞等待。在 Linux 上,不可中断的情况是最常见的。

所以,问题来了:mutex_lock() API 总是将调用任务置于不可中断的睡眠状态。如果这不是你想要的,可以使用mutex_lock_interruptible() API 将调用任务置于可中断的睡眠状态。在语法上有一个不同之处;后者在成功时返回整数值0,在失败时返回-EINTR(记住0/-E返回约定)(由于信号中断)。

一般来说,使用mutex_lock()比使用mutex_lock_interruptible()更快;当临界区很短时(因此几乎可以保证锁只持有很短的时间,这是一个非常理想的特性)使用它。

内核 5.4.0 包含超过 18,500 个mutex_lock()和 800 多个mutex_lock_interruptible() API 的调用实例;你可以通过内核源树上强大的cscope(1)实用程序来检查这一点。

理论上,内核也提供了mutex_destroy() API。这是mutex_init()的相反操作;它的工作是将互斥锁标记为不可用。它只能在互斥锁处于未锁定状态时调用一次,并且一旦调用,就不能再使用互斥锁。这有点理论性,因为在常规系统上,它只是一个空函数;只有在启用了CONFIG_DEBUG_MUTEXES的内核上,它才变成实际的(简单的)代码。因此,当使用互斥锁时,我们应该使用这种模式,如下面的伪代码所示:

DEFINE_MUTEX(...);        // init: initialize the mutex object
/* or */ mutex_init();
[ ... ]
    /* critical section: perform the (mutex) locking, unlocking */
    mutex_lock[_interruptible]();
    << ... critical section ... >>
    mutex_unlock();
    mutex_destroy();      // cleanup: destroy the mutex object

现在你已经学会了如何使用互斥锁 API,让我们把这些知识付诸实践。在下一节中,我们将在我们早期的一个(编写不良-没有保护!)“misc”驱动程序的基础上,通过使用互斥对象来锁定必要的临界区。

互斥锁示例驱动程序

我们在《Linux 内核编程(第二部分)》一书中的编写简单的杂项字符设备驱动程序章节中创建了一个简单的设备驱动程序代码示例,即miscdrv_rdwr。在那里,我们编写了一个简单的misc类字符设备驱动程序,并使用了一个用户空间实用程序(miscdrv_rdwr/rdwr_drv_secret.c)来从设备驱动程序的内存中读取和写入(所谓的)秘密。

然而,在那段代码中我们明显(这里应该用“惊人地”才对!)没有保护共享(全局)可写数据!这在现实世界中会让我们付出昂贵的代价。我敦促你花一些时间思考这个问题:两个(或三个或更多)用户模式进程打开这个驱动程序的设备文件,然后同时发出各种 I/O 读写操作是不可行的。在这里,全局共享可写数据(在这种特殊情况下,两个全局整数和驱动程序上下文数据结构)很容易被破坏。

因此,让我们通过复制这个驱动程序并更正我们的错误来学习(我们现在将其称为ch12/1_miscdrv_rdwr_mutexlock/1_miscdrv_rdwr_mutexlock.c),并重新编写其中的一些部分。关键是我们必须使用互斥锁来保护所有关键部分。而不是在这里显示代码(毕竟,它在本书的 GitHub 存储库中github.com/PacktPublishing/Linux-Kernel-Programming,请使用git clone!),让我们做一些有趣的事情:让我们看一下旧的未受保护版本和新的受保护代码版本之间的“差异”(diff(1)生成的差异-增量)。这里的输出已经被截断:

$ pwd
<...>/ch12/1_miscdrv_rdwr_mutexlock
$ diff -u ../../ch12/miscdrv_rdwr/miscdrv_rdwr.c miscdrv_rdwr_mutexlock.c > miscdrv_rdwr.patch
$ cat miscdrv_rdwr.patch
[ ... ]
+#include <linux/mutex.h> // mutex lock, unlock, etc
 #include "../../convenient.h"
[ ... ] 
-#define OURMODNAME "miscdrv_rdwr"
+#define OURMODNAME "miscdrv_rdwr_mutexlock"

+DEFINE_MUTEX(lock1); // this mutex lock is meant to protect the integers ga and gb
[ ... ]
+     struct mutex lock; // this mutex protects this data structure
 };
[ ... ]

在这里,我们可以看到在驱动程序的更新安全版本中,我们声明并初始化了一个名为lock1的互斥变量;我们将使用它来保护(仅用于演示目的)驱动程序中的两个全局整数gagb。接下来,重要的是,在“驱动程序上下文”数据结构drv_ctx中声明了一个名为lock的互斥锁;这将用于保护对该数据结构成员的任何和所有访问。它在init代码中初始化:

+     mutex_init(&ctx->lock);
+
+     /* Initialize the "secret" value :-) */
      strscpy(ctx->oursecret, "initmsg", 8);
-     dev_dbg(ctx->dev, "A sample print via the dev_dbg(): driver initialized\n");
+     /* Why don't we protect the above strscpy() with the mutex lock?
+      * It's working on shared writable data, yes?
+      * Yes, BUT this is the init code; it's guaranteed to run in exactly
+      * one context (typically the insmod(8) process), thus there is
+      * no concurrency possible here. The same goes for the cleanup
+      * code path.
+      */

这个详细的注释清楚地解释了为什么我们不需要在strscpy()周围进行锁定/解锁。再次强调,这应该是显而易见的,但是局部变量隐式地对每个进程上下文是私有的(因为它们驻留在该进程或线程的内核模式堆栈中),因此不需要保护(每个线程/进程都有一个变量的单独实例,所以没有人会干涉别人的工作!)。在我们忘记之前,清理代码路径(通过rmmod(8)进程上下文调用)必须销毁互斥锁:

-static void __exit miscdrv_rdwr_exit(void)
+static void __exit miscdrv_exit_mutexlock(void)
 {
+     mutex_destroy(&lock1);
+     mutex_destroy(&ctx->lock);
      misc_deregister(&llkd_miscdev);
 }

现在,让我们看一下驱动程序的打开方法的差异:

+
+     mutex_lock(&lock1);
+     ga++; gb--;
+     mutex_unlock(&lock1);
+
+     dev_info(dev, " filename: \"%s\"\n"
      [ ... ]

这就是我们操纵全局整数的地方,使其成为一个关键部分;与此程序的先前版本(在Linux 内核编程(第二部分)中)不同,在这里,我们确实保护了这个关键部分,使用了lock1互斥体。所以,关键部分在这里是代码ga++; gb--;:在(互斥体)锁定和解锁操作之间的代码。

但是(总会有但是,不是吗?),一切并不顺利!看一下mutex_unlock()代码行后面的printk函数(dev_info()):

+ dev_info(dev, " filename: \"%s\"\n"
+         " wrt open file: f_flags = 0x%x\n"
+         " ga = %d, gb = %d\n",
+         filp->f_path.dentry->d_iname, filp->f_flags, ga, gb);

这看起来对你来说没问题吗?不,仔细看:我们读取全局整数gagb的值。回想一下基本原理:在并发存在的情况下(在这个驱动程序的open方法中肯定是可能的),即使没有锁定,读取共享可写数据也可能是不安全的。如果这对你来说没有意义,请想一想:如果一个线程正在读取整数,另一个线程同时正在更新(写入)它们;那么呢?这种情况被称为脏读(或断裂读);我们可能会读取过时的数据,必须加以保护。(事实上,这并不是一个脏读的很好的例子,因为在大多数处理器上,读取和写入单个整数项目确实是一个原子操作。然而,我们不应该假设这样的事情-我们只需要做好我们的工作并保护它。)

实际上,还有另一个类似的等待中的错误:我们已经从打开文件结构(filp指针)中读取数据,而没有费心保护它(实际上,打开文件结构有一个锁;我们应该使用它!我们稍后会这样做)。

诸如脏读之类的事物发生的具体语义通常非常依赖于体系结构(机器);然而,我们作为模块或驱动程序的作者的工作是明确的:我们必须确保保护所有关键部分。这包括对共享可写数据的读取。

目前,我们将这些标记为潜在错误(错误)。我们将在使用原子整数操作符部分以更加性能友好的方式处理这个问题。查看驱动程序读取方法的差异会发现一些有趣的东西(忽略这里显示的行号;它们可能会改变):

图 12.7 - 驱动程序读取()方法的差异;查看新版本中互斥锁的使用

我们现在已经使用了驱动程序上下文结构的互斥锁来保护关键部分。对于设备驱动程序的关闭(释放)方法都是一样的(生成补丁并查看)。

请注意,用户模式应用程序保持不变,这意味着为了测试新的更安全的版本,我们必须继续使用ch12/miscdrv_rdwr/rdwr_drv_secret.c中的用户模式应用程序。在调试内核上运行和测试此驱动程序代码,该内核包含各种锁定错误和死锁检测功能,这是至关重要的(我们将在下一章中返回这些“调试”功能,在内核中的锁调试部分)。

在前面的代码中,我们在copy_to_user()例程之前刚好取得了互斥锁;这很好。然而,我们只在dev_info()之后释放它。为什么不在这个printk之前释放它,从而缩短关键部分的时间?

仔细观察dev_info()会发现为什么它在关键部分内部。我们在这里打印了三个变量的值:secret_len读取的字节数以及ctx->txctx->rx分别"传输"和"接收"的字节数。secret_len是一个局部变量,不需要保护,但另外两个变量在全局驱动程序上下文结构中,因此需要保护,即使是(可能是脏的)读取也需要保护。

互斥锁 - 一些剩余的要点

在本节中,我们将介绍有关互斥锁的一些其他要点。

互斥锁 API 变体

首先,让我们看一下互斥锁 API 的一些变体;除了可中断变体(在互斥锁 - 通过[不]可中断睡眠?部分中描述),我们还有trylock,可杀死io变体。

互斥 trylock 变体

如果您想要实现忙等待语义,即测试(互斥)锁的可用性,如果可用(表示当前未锁定),则获取/锁定它并继续关键部分代码路径?如果不可用(当前处于锁定状态),则不等待锁;而是执行其他工作并重试。实际上,这是一种非阻塞的互斥锁变体,称为 trylock;以下流程图显示了它的工作原理:

图 12.8 - "忙等待"语义,非阻塞 trylock 操作

互斥锁的此 trylock 变体的 API 如下所示:

int mutex_trylock(struct mutex *lock);

此 API 的返回值表示运行时发生了什么:

  • 返回值1表示成功获取了锁。

  • 返回值0表示锁当前被争用(已锁定)。

虽然尝试使用mutex_trylock() API 来确定互斥锁的锁定状态或解锁状态听起来很诱人,但这本质上是"竞态"。接下来,请注意,在高度争用的锁路径中使用此 trylock 变体可能会降低您获得锁的机会。trylock 变体传统上用于死锁预防代码,该代码可能需要退出某个锁定顺序序列,并通过另一个序列进行重试(排序)。

另外,关于 trylock 变体,尽管文献使用术语尝试原子地获取互斥锁,但它在原子或中断上下文中不起作用 - 它在进程上下文中起作用(与任何类型的互斥锁一样)。通常情况下,锁必须由所有者上下文调用的mutex_unlock()释放。

我建议你尝试使用 trylock 互斥锁变体作为练习。在本章末尾的问题部分查看作业!

互斥锁可中断和可杀死变体

正如您已经了解的那样,当驱动程序(或模块)愿意接受任何(用户空间)信号中断时,将使用mutex_lock_interruptible()API(并返回-ERESTARTSYS以告知内核 VFS 层执行信号处理;用户空间系统调用将以errno设置为EINTR失败)。一个例子可以在内核中的模块处理代码中找到,在delete_module(2)系统调用中(rmmod(8)调用):

// kernel/module.c
[ ... ]
SYSCALL_DEFINE2(delete_module, const char __user *, name_user,
        unsigned int, flags)
{
    struct module *mod;
    [ ... ]
    if (!capable(CAP_SYS_MODULE) || modules_disabled)
        return -EPERM;
    [ ... ]
    if (mutex_lock_interruptible(&module_mutex) != 0)
 return -EINTR;
    mod = find_module(name);
    [ ... ]
out:
    mutex_unlock(&module_mutex);
    return ret;
}

请注意 API 在失败时返回-EINTR。(SYSCALL_DEFINEn()宏成为系统调用签名;n表示此特定系统调用接受的参数数量。还要注意权限检查-除非您以 root 身份运行或具有CAP_SYS_MODULE权限(或者模块加载完全被禁用),否则系统调用将返回失败(-EPERM)。)

然而,如果您的驱动程序只愿意被致命信号(将杀死用户空间上下文的信号)中断,那么请使用mutex_lock_killable()API(签名与可中断变体相同)。

互斥锁 io 变体

mutex_lock_io()API 在语法上与mutex_lock()API 相同;唯一的区别是内核认为失败线程的等待时间与等待 I/O 相同(kernel/locking/mutex.c:mutex_lock_io()中的代码注释清楚地记录了这一点;看一看)。这在会计方面很重要。

您可以在内核中找到诸如mutex_lock[_interruptible]_nested()之类的相当奇特的 API,这里重点是nested后缀。但是,请注意,Linux 内核不建议开发人员使用嵌套(或递归)锁定(正如我们在正确使用互斥锁部分中提到的)。此外,这些 API 仅在存在CONFIG_DEBUG_LOCK_ALLOC配置选项时才会被编译;实际上,嵌套 API 是为了支持内核锁验证器机制而添加的。它们只应在特殊情况下使用(在同一类型的锁定实例之间必须包含嵌套级别的情况)。

在下一节中,我们将回答一个典型的常见问题:互斥锁和信号量对象之间有什么区别?Linux 是否甚至有信号量对象?继续阅读以了解更多!

信号量和互斥锁

Linux 内核确实提供了信号量对象,以及您可以对(二进制)信号量执行的常规操作:

  • 通过down[_interruptible]()(和变体)API 获取信号量锁

  • 通过up()API 释放信号量。

一般来说,信号量是一个较旧的实现,因此建议您使用互斥锁来替代它。

值得一看的常见问题是:互斥锁和信号量之间有什么区别?它们在概念上似乎相似,但实际上是非常不同的:

  • 信号量是互斥锁的一种更通用的形式;互斥锁可以被获取(随后释放或解锁)一次,而信号量可以被获取(随后释放)多次。

  • 互斥锁用于保护临界区免受同时访问,而信号量应该用作向另一个等待任务发出信号的机制,表明已经达到了某个里程碑(通常,生产者任务通过信号量对象发布信号,等待接收的消费者任务继续进行进一步工作)。

  • 互斥锁具有锁的所有权概念,只有所有者上下文才能执行解锁;二进制信号量没有所有权。

优先级反转和 RT 互斥锁

使用任何类型的锁时需要注意的一点是,您应该仔细设计和编码,以防止可能出现的可怕的死锁情况(关于这一点,我们将在下一章的锁验证器 lockdep - 及早捕捉锁定问题部分详细介绍)。

除了死锁,使用互斥时还会出现另一种风险情景:优先级反转(在本书中我们不会深入讨论细节)。可以说,无限的优先级反转情况可能是致命的;最终结果是产品的高(最高)优先级线程被长时间挡在 CPU 之外。

正如我在早期的书Hands-on System Programming with Linux中详细介绍的那样,正是这个优先级反转问题在 1997 年 7 月击中了 NASA 的火星探路者机器人,就在火星表面!请参阅本章的进一步阅读部分,了解有关这一问题的有趣资源,这是每个软件开发人员都应该知道的事情!

用户空间 Pthreads 互斥实现当然具有优先级继承PI)语义。但是 Linux 内核内部呢?对此,Ingo Molnar 提供了基于 PI-futex 的 RT-mutex(实时互斥;实际上是扩展为具有 PI 功能的互斥。futex(2)是一个复杂的系统调用,提供快速的用户空间互斥)。当启用CONFIG_RT_MUTEXES配置选项时,这些功能就可用了。与“常规”互斥语义非常相似,RT-mutex API 用于初始化、(解)锁定和销毁 RT-mutex 对象。(此代码已从 Ingo Molnar 的-rt树合并到主线内核中)。就实际使用而言,RT-mutex 用于内部实现 PI futex(futex(2)系统调用本身在内部实现了用户空间 Pthreads 互斥)。除此之外,内核锁定自测代码和 I2C 子系统直接使用 RT-mutex。

因此,对于典型的模块(或驱动程序)作者来说,这些 API 并不经常使用。内核确实提供了一些关于 RT-mutex 内部设计的文档,涵盖了优先级反转、优先级继承等等。

内部设计

关于内核中互斥锁的内部实现的现实情况:Linux 在可能的情况下尝试实现快速路径方法。

快速路径是最优化的高性能代码路径;例如,没有锁定和阻塞的路径。其目的是让代码尽可能地遵循这条快速路径。只有在真的不可能的情况下,内核才会退回到(可能的)“中间路径”,然后是“慢路径”方法;它仍然有效,但速度较慢。

在没有锁定争用的情况下(即,锁定状态一开始就是未锁定状态),会采用这种快速路径。因此,锁定时会立即完成,没有麻烦。然而,如果互斥已经被锁定,那么内核通常会使用中间路径的乐观自旋实现,使其更像是混合(互斥/自旋锁)锁类型。如果这也不可能,就会采用“慢路径”——试图获取锁的进程上下文可能会进入睡眠状态。如果您对其内部实现感兴趣,可以在官方内核文档中找到更多详细信息:www.kernel.org/doc/Documentation/locking/mutex-design.rst

LDV(Linux Driver Verification)项目:在第一章中,内核工作空间设置部分的LDV - Linux Driver Verification - 项目中,我们提到该项目对 Linux 模块(主要是驱动程序)以及核心内核的各种编程方面有有用的“规则”。

关于我们当前的主题,这里有一个规则:两次锁定互斥锁或在先前未锁定的情况下解锁linuxtesting.org/ldv/online?action=show_rule&rule_id=0032)。它提到了您不能使用互斥锁做的事情的类型(我们已经在正确使用互斥锁部分中涵盖了这一点)。有趣的是:您可以看到一个实际的 bug 示例 - 一个互斥锁双重获取尝试,导致(自身)死锁 - 在内核驱动程序中(以及随后的修复)。

现在您已经了解了如何使用互斥锁,让我们继续看看内核中另一个非常常见的锁 - 自旋锁。

使用自旋锁

互斥锁还是自旋锁?何时使用部分,您学会了何时使用自旋锁而不是互斥锁,反之亦然。为了方便起见,我们在此重复了我们之前提供的关键陈述:

  • 临界区是否在原子(中断)上下文中运行,或者在进程上下文中无法睡眠的情况下? 使用自旋锁。

  • 临界区是否在进程上下文中运行,且在临界区中需要睡眠? 使用互斥锁。

在这一部分,我们假设您现在决定使用自旋锁。

自旋锁 - 简单用法

对于所有自旋锁 API,您必须包含相关的头文件;即include <linux/spinlock.h>

与互斥锁类似,必须在使用之前声明和初始化自旋锁为未锁定状态。自旋锁是通过名为spinlock_ttypedef数据类型声明的“对象”(在include/linux/spinlock_types.h中定义的结构)。它可以通过spin_lock_init()宏进行动态初始化:

spinlock_t lock;
spin_lock_init(&lock);

或者,可以使用DEFINE_SPINLOCK(lock);进行静态执行(声明和初始化)。

与互斥锁一样,在(全局/静态)数据结构中声明自旋锁旨在防止并发访问,并且通常是一个非常好的主意。正如我们之前提到的,这个想法在内核中经常被使用;例如,Linux 内核上表示打开文件的数据结构称为struct file

// include/linux/fs.h
struct file {
    [...]
    struct path f_path;
    struct inode *f_inode; /* cached value */
    const struct file_operations *f_op;
    /*
     * Protects f_ep_links, f_flags.
     * Must not be taken from IRQ context.
     */
    spinlock_t f_lock;
    [...]
    struct mutex f_pos_lock;
    loff_t f_pos;
    [...]

看看这个:对于file结构,名为f_lock的自旋锁变量是保护file数据结构的f_ep_linksf_flags成员的自旋锁(它还有一个互斥锁来保护另一个成员;即文件的当前寻位位置f_pos)。

您如何实际上锁定和解锁自旋锁?内核向我们模块/驱动程序作者公开了许多 API 的变体;自旋锁的最简单形式的(解)锁 API 如下:

void spin_lock(spinlock_t *lock);
<< ... critical section ... >>
void spin_unlock(spinlock_t *lock);

请注意,自旋锁没有mutex_destroy()API 的等效物。

现在,让我们看看自旋锁 API 的运行情况!

自旋锁 - 一个示例驱动程序

与我们之前使用互斥锁示例驱动程序(互斥锁 - 一个示例驱动程序部分)类似,为了说明自旋锁的简单用法,我们将把之前的ch12/1_miscdrv_rdwr_mutexlock驱动程序复制为起始模板,然后放置在一个新的内核驱动程序中;即ch12/2_miscdrv_rdwr_spinlock。同样,在这里,我们只会展示差异的小部分(diff(1)生成的差异,我们不会展示每一行的差异,只展示相关部分):

// location: ch12/2_miscdrv_rdwr_spinlock/
+#include <linux/spinlock.h>
[ ... ]
-#define OURMODNAME "miscdrv_rdwr_mutexlock"
+#define OURMODNAME "miscdrv_rdwr_spinlock"
[ ... ]
static int ga, gb = 1;
-DEFINE_MUTEX(lock1); // this mutex lock is meant to protect the integers ga and gb
+DEFINE_SPINLOCK(lock1); // this spinlock protects the global integers ga and gb
[ ... ]
+/* The driver 'context' data structure;
+ * all relevant 'state info' reg the driver is here.
  */
 struct drv_ctx {
    struct device *dev;
@@ -63,10 +66,22 @@
    u64 config3;
 #define MAXBYTES 128
    char oursecret[MAXBYTES];
- struct mutex lock; // this mutex protects this data structure
+ struct mutex mutex; // this mutex protects this data structure
+ spinlock_t spinlock; // ...so does this spinlock
 };
 static struct drv_ctx *ctx;

这一次,为了保护我们的drv_ctx全局数据结构的成员,我们既有原始的互斥锁,也有一个新的自旋锁。这是非常常见的;互斥锁保护了临界区中可能发生阻塞的成员使用,而自旋锁用于保护在临界区中可能发生阻塞(睡眠 - 记住它可能会睡眠)的成员。

当然,我们必须确保初始化所有的锁,使它们处于未锁定状态。我们可以在驱动程序的init代码中执行这个操作(继续输出补丁):

-   mutex_init(&ctx->lock);
+   mutex_init(&ctx->mutex);
+   spin_lock_init(&ctx->spinlock);

在驱动程序的open方法中,我们用自旋锁替换了互斥锁,以保护全局整数的增量和减量:

 * open_miscdrv_rdwr()
@@ -82,14 +97,15 @@

    PRINT_CTX(); // displays process (or intr) context info

-   mutex_lock(&lock1);
+   spin_lock(&lock1);
    ga++; gb--;
-   mutex_unlock(&lock1);
+   spin_unlock(&lock1);

现在,在驱动程序的read方法中,我们使用自旋锁而不是互斥锁来保护一些关键部分:

 static ssize_t read_miscdrv_rdwr(struct file *filp, char __user *ubuf, size_t count, loff_t  *off)
 {
-   int ret = count, secret_len;
+   int ret = count, secret_len, err_path = 0;
    struct device *dev = ctx->dev;

-   mutex_lock(&ctx->lock);
+   spin_lock(&ctx->spinlock);
    secret_len = strlen(ctx->oursecret);
-   mutex_unlock(&ctx->lock);
+   spin_unlock(&ctx->spinlock);

但这还不是全部!继续进行驱动程序的read方法,仔细看一下以下代码和注释:

[ ... ]
@@ -139,20 +157,28 @@
     * member to userspace.
     */
    ret = -EFAULT;
-   mutex_lock(&ctx->lock);
+   mutex_lock(&ctx->mutex);
+   /* Why don't we just use the spinlock??
+    * Because - VERY IMP! - remember that the spinlock can only be used when
+    * the critical section will not sleep or block in any manner; here,
+    * the critical section invokes the copy_to_user(); it very much can
+    * cause a 'sleep' (a schedule()) to occur.
+    */
    if (copy_to_user(ubuf, ctx->oursecret, secret_len)) {
[ ... ]

在保护可能有阻塞 API 的关键部分数据时——比如在copy_to_user()中——我们必须只使用互斥锁!(由于空间有限,我们没有在这里显示更多的代码差异;我们希望您能阅读自旋锁示例驱动程序代码并自行尝试。)

测试——在原子上下文中睡眠

您已经学到了我们不应该在任何原子或中断上下文中睡觉(阻塞)。让我们来测试一下。一如既往,经验主义方法——即自己测试而不是依赖他人的经验——是关键!

我们如何测试这个问题呢?很简单:我们将使用一个简单的整数模块参数buggy,当设置为1时(默认值为0),会执行违反这一规则的自旋锁关键部分内的代码路径。我们将调用schedule_timeout() API(正如您在第十五章“定时器、内核线程和更多”中学到的,在“理解如何使用sleep()阻塞 API”部分内部调用schedule();这是我们在内核空间中睡眠的方式)。以下是相关代码:

// ch12/2_miscdrv_rdwr_spinlock/2_miscdrv_rdwr_spinlock.c
[ ... ]
static int buggy;
module_param(buggy, int, 0600);
MODULE_PARM_DESC(buggy,
"If 1, cause an error by issuing a blocking call within a spinlock critical section");
[ ... ]
static ssize_t write_miscdrv_rdwr(struct file *filp, const char __user *ubuf,
                size_t count, loff_t *off)
{
    int ret, err_path = 0;
    [ ... ]
    spin_lock(&ctx->spinlock);
    strscpy(ctx->oursecret, kbuf, (count > MAXBYTES ? MAXBYTES : count));
    [ ... ]
    if (1 == buggy) {
        /* We're still holding the spinlock! */
        set_current_state(TASK_INTERRUPTIBLE);
        schedule_timeout(1*HZ); /* ... and this is a blocking call!
 * Congratulations! you've just engineered a bug */
    }
    spin_unlock(&ctx->spinlock);
    [ ... ]
}

现在,让我们来测试一下这个(错误的)代码路径,分别在两个内核中进行:首先是我们定制的 5.4“调试”内核(我们在这个内核中启用了几个内核调试配置选项(主要是从make menuconfig中的Kernel Hacking菜单),如第五章中所解释的,“编写您的第一个内核模块——LKMs 第二部分”),其次是一个没有启用任何相关内核调试选项的通用发行版(我们通常在 Ubuntu 上运行)5.4 内核。

在 5.4 调试内核上进行测试

首先确保您已经构建了定制的 5.4 内核,并且所有必需的内核调试配置选项都已启用(如果需要,请回顾第五章,“编写您的第一个内核模块——LKMs 第二部分”,“配置调试内核”部分)。然后,启动调试内核(这里命名为5.4.0-llkd-dbg)。现在,在此调试内核上构建驱动程序(在ch12/2_miscdrv_rdwr_spinlock/中进行通常的make应该可以完成这一步;您可能会发现,在调试内核上,构建速度明显较慢!):

$ lsb_release -a 2>/dev/null | grep "^Description" ; uname -r
Description: Ubuntu 20.04.1 LTS
5.4.0-llkd-dbg $ make
[ ... ]
$ modinfo ./miscdrv_rdwr_spinlock.ko 
filename: /home/llkd/llkd_src/ch12/2_miscdrv_rdwr_spinlock/./miscdrv_rdwr_spinlock.ko
[ ... ]
description: LLKD book:ch12/2_miscdrv_rdwr_spinlock: simple misc char driver rewritten with spinlocks
[ ... ]
parm: buggy:If 1, cause an error by issuing a blocking call within a spinlock critical section (int)
$ sudo virt-what
virtualbox
kvm
$ 

如您所见,我们在 x86_64 Ubuntu 20.04 虚拟机上运行我们定制的 5.4.0“debug”内核。

您如何知道自己是在虚拟机(VM)上运行还是在“裸机”(本机)系统上运行?virt-what(1)是一个有用的小脚本,可以显示这一点(您可以在 Ubuntu 上使用sudo apt install virt-what进行安装)。

要运行我们的测试用例,将驱动程序插入内核,并将buggy模块参数设置为1。通过我们的用户空间应用程序调用驱动程序的read方法(即ch12/miscdrv_rdwr/rdwr_test_secret)不是问题,如下所示:

$ sudo dmesg -C
$ sudo insmod ./miscdrv_rdwr_spinlock.ko buggy=1
$ ../../ch12/miscdrv_rdwr/rdwr_test_secret 
Usage: ../../ch12/miscdrv_rdwr/rdwr_test_secret opt=read/write device_file ["secret-msg"]
 opt = 'r' => we shall issue the read(2), retrieving the 'secret' form the driver
 opt = 'w' => we shall issue the write(2), writing the secret message <secret-msg>
  (max 128 bytes)
$ 
$ ../../ch12/miscdrv_rdwr/rdwr_test_secret r /dev/llkd_miscdrv_rdwr_spinlock 
Device file /dev/llkd_miscdrv_rdwr_spinlock opened (in read-only mode): fd=3
../../ch12/miscdrv_rdwr/rdwr_test_secret: read 7 bytes from /dev/llkd_miscdrv_rdwr_spinlock
The 'secret' is:
 "initmsg"
$ 

接下来,我们通过用户模式应用程序向驱动程序发出write(2);这一次,我们的错误代码路径被执行。正如您所看到的,我们在自旋锁关键部分内(即在锁定和解锁之间)调用了schedule_timeout()。调试内核将此检测为错误,并在内核日志中生成(令人印象深刻的大量)调试诊断信息(请注意,这样的错误很可能会导致系统挂起,因此请先在虚拟机上进行测试):

图 12.9——由我们故意触发的“在原子上下文中调度”错误引发的内核诊断

前面的截图显示了发生的部分情况(在查看ch12/2_miscdrv_rdwr_spinlock/2_miscdrv_rdwr_spinlock.c中的驱动程序代码时跟随):

  1. 首先,我们有我们的用户模式应用程序的进程上下文(rdwr_test_secre;请注意名称被截断为前 16 个字符,包括NULL字节),它进入了驱动程序的写入方法;也就是write_miscdrv_rdwr()。这可以在我们有用的PRINT_CTX()宏的输出中看到(我们在这里重现了这一行):
miscdrv_rdwr_spinlock:write_miscdrv_rdwr(): 004) rdwr_test_secre :23578 | ...0 /*  write_miscdrv_rdwr() */
  1. 它从用户空间写入进程中复制新的“秘密”并写入了 24 个字节。

  2. 然后它“获取”自旋锁,进入临界区,并将这些数据复制到我们驱动程序上下文结构的oursecret成员中。

  3. 之后,if (1 == buggy) {评估为真。

  4. 然后,它调用schedule_timeout(),这是一个阻塞 API(因为它内部调用schedule()),触发了错误,这在红色下面有明显标出:

BUG: scheduling while atomic: rdwr_test_secre/23578/0x00000002
  1. 内核现在转储了大量的诊断输出。首先要转储的是调用堆栈

进程的调用堆栈或堆栈回溯(或“调用跟踪”)的内核模式堆栈 - 在这里,是我们的用户空间应用程序rdwr_drv_secret,它在进程上下文中运行我们(有错误的)驱动程序的代码 - 可以在图 12.9中清楚地看到。Call Trace:标题之后的每一行本质上都是内核堆栈上的一个调用帧。

作为提示,忽略以?符号开头的堆栈帧;它们实际上是可疑的调用帧,很可能是在相同内存区域中以前的堆栈使用中留下的“剩余物”。这里值得进行一次与内存相关的小的偏离:这就是堆栈分配的真正工作原理;堆栈内存不是按照每个调用帧的基础分配和释放的,因为那将是非常昂贵的。只有当堆栈内存页耗尽时,才会自动故障新的内存页!(回想一下我们在第九章中的讨论,模块作者的内核内存分配 - 第二部分关于内存分配和需求分页的简短说明部分。)因此,事实是,当代码调用和从函数返回时,相同的堆栈内存页往往会不断被重用。

不仅如此,出于性能原因,内存不是每次都擦除,导致以前的帧留下的残留物经常出现。(它们实际上可以“破坏”图像。然而,幸运的是,现代堆栈调用帧跟踪算法通常能够出色地找出正确的堆栈跟踪。)

按照堆栈跟踪自下而上(总是自下而上阅读),我们可以看到,正如预期的那样,我们的用户空间write(2)系统调用(它经常显示为(类似于)SyS_write或者在 x86 上显示为__x64_sys_write,尽管在图 12.9中不可见)调用了内核的 VFS 层代码(你可以在这里看到vfs_write(),它调用了__vfs_write()),进一步调用了我们的驱动程序的写入方法;也就是write_miscdrv_rdwr()!正如我们所知,这段代码调用了有错误的代码路径,我们在其中调用了schedule_timeout(),这又调用了schedule()(和__schedule()),导致整个BUG: scheduling while atomic错误触发。

调度时原子代码路径的格式是从以下代码行中检索的,可以在kernel/sched/core.c中找到:

printk(KERN_ERR "BUG: scheduling while atomic: %s/%d/0x%08x\n", prev->comm, prev->pid, preempt_count());

有趣!在这里,你可以看到它打印了以下字符串:

      BUG: scheduling while atomic: rdwr_test_secre/23578/0x00000002

atomic:之后,它打印进程名称 - PID - 然后调用preempt_count()内联函数,打印抢占深度;抢占深度是一个计数器,每次获取锁时递增,每次解锁时递减。因此,如果它是正数,这意味着代码在临界区或原子区域内;在这里,它显示为值2

请注意,这个错误在这次测试运行中得到了很好的解决,因为CONFIG_DEBUG_ATOMIC_SLEEP调试内核配置选项已经打开。这是因为我们正在运行一个自定义的“调试内核”(内核版本 5.4.0)!配置选项的详细信息(你可以在make menuconfig中交互式地找到并设置这个选项,在Kernel Hacking菜单下)如下:

// lib/Kconfig.debug
[ ... ]
config DEBUG_ATOMIC_SLEEP
    bool "Sleep inside atomic section checking"
    select PREEMPT_COUNT
    depends on DEBUG_KERNEL
    depends on !ARCH_NO_PREEMPT
    help 
      If you say Y here, various routines which may sleep will become very 
 noisy if they are called inside atomic sections: when a spinlock is
 held, inside an rcu read side critical section, inside preempt disabled
 sections, inside an interrupt, etc...

在一个 5.4 非调试发行版内核上进行测试

作为对比的测试,我们现在将在我们的 Ubuntu 20.04 LTS VM 上执行完全相同的操作,我们将通过其默认的通用“发行版”5.4 Linux 内核进行引导,通常情况下不配置为“调试”内核(这里,CONFIG_DEBUG_ATOMIC_SLEEP内核配置选项未设置)。

首先,我们插入我们的(有错误的)驱动程序。然后,当我们运行我们的rdwr_drv_secret进程以将新的秘密写入驱动程序时,错误的代码路径被执行。然而,这一次,内核没有崩溃,也没有报告任何问题(查看dmesg(1)输出可以验证这一点):

$ uname -r
5.4.0-56-generic
$ sudo insmod ./miscdrv_rdwr_spinlock.ko buggy=1
$ ../../ch12/miscdrv_rdwr/rdwr_test_secret w /dev/llkd_miscdrv_rdwr_spinlock "passwdcosts500bucksdude"
Device file /dev/llkd_miscdrv_rdwr_spinlock opened (in write-only mode): fd=3
../../ch12/miscdrv_rdwr/rdwr_test_secret: wrote 24 bytes to /dev/llkd_miscdrv_rdwr_spinlock
$ dmesg 
[ ... ]
[ 65.420017] miscdrv_rdwr_spinlock:miscdrv_init_spinlock(): LLKD misc driver (major # 10) registered, minor# = 56, dev node is /dev/llkd_miscdrv_rdwr
[ 81.665077] miscdrv_rdwr_spinlock:miscdrv_exit_spinlock(): miscdrv_rdwr_spinlock: LLKD misc driver deregistered, bye
[ 86.798720] miscdrv_rdwr_spinlock:miscdrv_init_spinlock(): VERMAGIC_STRING = 5.4.0-56-generic SMP mod_unload 
[ 86.799890] miscdrv_rdwr_spinlock:miscdrv_init_spinlock(): LLKD misc driver (major # 10) registered, minor# = 56, dev node is /dev/llkd_miscdrv_rdwr
[ 130.214238] misc llkd_miscdrv_rdwr_spinlock: filename: "llkd_miscdrv_rdwr_spinlock"
                wrt open file: f_flags = 0x8001
                ga = 1, gb = 0
[ 130.219233] misc llkd_miscdrv_rdwr_spinlock: stats: tx=0, rx=0
[ 130.219680] misc llkd_miscdrv_rdwr_spinlock: rdwr_test_secre wants to write 24 bytes
[ 130.220329] misc llkd_miscdrv_rdwr_spinlock: 24 bytes written, returning... (stats: tx=0, rx=24)
[ 131.249639] misc llkd_miscdrv_rdwr_spinlock: filename: "llkd_miscdrv_rdwr_spinlock"
                ga = 0, gb = 1
[ 131.253511] misc llkd_miscdrv_rdwr_spinlock: stats: tx=0, rx=24
$ 

我们知道我们的写入方法有一个致命的错误,但它似乎没有以任何方式失败!这真的很糟糕;这种情况可能会让你错误地得出结论,认为你的代码没有问题,而实际上一个难以察觉的错误悄悄地等待着某一天突然袭击!

为了帮助我们调查底层发生了什么,让我们再次运行我们的测试应用程序(rdwr_drv_secret进程),但这次通过强大的trace-cmd(1)工具(它是 Ftrace 内核基础设施的一个非常有用的包装器;以下是它的截断输出:

Linux 内核的Ftrace基础设施是内核的主要跟踪基础设施;它提供了几乎每个在内核空间执行的函数的详细跟踪。在这里,我们通过一个方便的前端利用 Ftrace:trace-cmd(1)实用程序。这些确实是非常强大和有用的调试工具;我们在第一章中提到了几个其他工具,但不幸的是,细节超出了本书的范围。查看手册页以了解更多信息。

$ sudo trace-cmd record -p function_graph -F ../../ch12/miscdrv_rdwr/rdwr_test_secret w /dev/llkd_miscdrv_rdwr_spinlock "passwdcosts500bucks"
$ sudo trace-cmd report -I -S -l > report.txt
$ sudo less report.txt
[ ... ]

输出可以在以下截图中看到:

图 12.10-trace-cmd(1)报告输出的部分截图

正如你所看到的,我们的用户模式应用程序中的write(2)系统调用变成了预期的vfs_write(),它本身(经过安全检查后)调用__vfs_write(),然后调用我们的驱动程序的写入方法-write_miscdrv_rdwr()函数!

在(大量的)Ftrace 输出流中,我们可以看到schedule_timeout()函数确实被调用了:

图 12.11-trace-cmd(1)报告输出的部分截图,显示了(错误的!)调用schedule_timeout()schedule()在原子上下文中

schedule_timeout()之后的几行输出中,我们可以清楚地看到schedule()被调用!所以,我们得到了答案:我们的驱动程序(当然是故意的)执行了一些错误的操作-在原子上下文中调用schedule()。但这里的关键点再次是,在这个 Ubuntu 系统上,我们没有运行“调试”内核,这就是为什么我们有以下情况:

$ grep DEBUG_ATOMIC_SLEEP /boot/config-5.4.0-56-generic
# CONFIG_DEBUG_ATOMIC_SLEEP is not set
$

这就是为什么这个错误没有被报告!这证明了在“调试”内核上运行测试用例的有用性-确实进行内核开发-一个启用了许多调试功能的内核。(作为练习,如果你还没有这样做,准备一个“调试”内核并在其上运行这个测试用例。)

LDV(Linux Driver Verification)项目:在第一章中,内核工作空间设置,在LDV-Linux 驱动程序验证项目一节中,我们提到这个项目对 Linux 模块(主要是驱动程序)以及核心内核的各种编程方面有有用的“规则”。

关于我们当前的话题,这里有一个规则:使用自旋锁和解锁函数 (linuxtesting.org/ldv/online?action=show_rule&rule_id=0039)。它提到了关于正确使用自旋锁的关键点;有趣的是,这里展示了一个实际的驱动程序中的 bug 实例,其中尝试两次释放自旋锁 - 这是对锁定规则的明显违反,导致系统不稳定。

锁定和中断

到目前为止,我们已经学会了如何使用互斥锁,以及对于自旋锁,基本的spin_[un]lock() API。自旋锁还有一些其他 API 变体,我们将在这里检查更常见的一些。

要确切理解为什么可能需要其他自旋锁 API,让我们来看一个场景:作为驱动程序作者,你发现你正在处理的设备断言了硬件中断;因此,你为其编写了中断处理程序(你可以在Linux Kernel Programming (Part 2)书中详细了解)。现在,在为驱动程序实现read方法时,你发现在其中有一个非阻塞的临界区。这很容易处理:正如你已经学到的,你应该使用自旋锁来保护它。但是,如果在read方法的临界区中,设备的硬件中断触发了怎么办?正如你所知,硬件中断会抢占任何事情;因此,控制权将转移到中断处理程序代码,抢占驱动程序的read方法。

关键问题是:这是一个问题吗?这个答案取决于你的中断处理程序和你的read方法在做什么以及它们是如何实现的。让我们想象一些情景:

  • 中断处理程序(理想情况下)只使用本地变量,因此即使read方法在临界区中,它实际上并不重要;中断处理将非常快速地完成,并且控制将被交还给被中断的内容(同样,这还有更多内容;正如你所知,任何现有的底半部分,如 tasklet 或 softirq,也可能需要执行)。换句话说,在这种情况下实际上并没有竞争。

  • 中断处理程序正在处理(全局)共享可写数据,但不是你的读取方法正在使用的数据项。因此,再次,这里没有冲突,也没有与读取代码的竞争。当然,你应该意识到,中断代码确实有一个临界区,必须受到保护(也许需要另一个自旋锁)。

  • 中断处理程序正在处理与你的read方法使用的相同的全局共享可写数据。在这种情况下,我们可以看到确实存在竞争的潜力,因此我们需要锁定!

让我们专注于第三种情况。显然,我们应该使用自旋锁来保护中断处理代码中的临界区(请记住,在任何类型的中断上下文中使用互斥锁是不允许的)。此外,除非我们在read方法和中断处理程序的代码路径中都使用完全相同的自旋锁,否则它们将根本不受保护!(在处理锁时要小心;花时间仔细思考你的设计和代码。)

让我们试着更加实际一些(暂时使用伪代码):假设我们有一个名为gCtx的全局(共享)数据结构;我们在驱动程序中的read方法以及中断处理程序(硬中断处理程序)中对其进行操作。由于它是共享的,它是一个临界区,因此需要保护;由于我们在原子(中断)上下文中运行,不能使用互斥锁,因此必须使用自旋锁(这里,自旋锁变量称为slock)。以下伪代码显示了这种情况的一些时间戳(t1, t2, ...):

// Driver read method ; WRONG ! driver_read(...)                  << time t0 >>
{
    [ ... ]
    spin_lock(&slock);
    <<--- time t1 : start of critical section >>
... << operating on global data object gCtx >> ...
    spin_unlock(&slock);
    <<--- time t2 : end of critical section >>
    [ ... ]
}                                << time t3 >>

以下伪代码是设备驱动程序的中断处理程序:

handle_interrupt(...)           << time t4; hardware interrupt fires!     >>
{
    [ ... ]
    spin_lock(&slock);
    <<--- time t5: start of critical section >>
    ... << operating on global data object gCtx >> ...
    spin_unlock(&slock);
    <<--- time t6 : end of critical section >>
    [ ... ]
}                               << time t7 >> 

这可以用以下图表总结:

图 12.12 - 时间线 - 驱动程序的读方法和硬中断处理程序在处理全局数据时按顺序运行;这里没有问题

幸运的是,一切都进行得很顺利——“幸运”是因为硬件中断是在read函数的关键部分完成之后才触发的。当然,我们不能指望运气成为我们产品的唯一安全标志!硬件中断是异步的;如果它在一个不太合适的时间(对我们来说)触发了——比如,在read方法的关键部分在时间 t1 和 t2 之间运行时会怎么样?好吧,自旋锁会执行它的工作并保护我们的数据吗?

在这一点上,中断处理程序的代码将尝试获取相同的自旋锁(&slock)。等一下——它无法“获取”它,因为它当前被锁定了!在这种情况下,它会“自旋”,实际上是在等待解锁。但是它如何解锁呢?它无法解锁,这就是我们所面临的一个(自我)死锁

有趣的是,自旋锁在 SMP(多核)系统上更直观并且更有意义。假设read方法在 CPU 核心 1 上运行;中断可以在另一个 CPU 核心,比如核心 2 上被传递。中断代码路径将在 CPU 核心 2 上的锁上“自旋”,而read方法在核心 1 上完成关键部分,然后解锁自旋锁,从而解除中断处理程序的阻塞。但是在UP(单处理器,只有一个 CPU 核心)上呢?那么它会如何工作呢?啊,所以这是解决这个难题的方法:当与中断“竞争”时,无论是单处理器还是 SMP,都简单地使用自旋锁 API 的_irq变体*:

#include <linux/spinlock.h>
void spin_lock_irq(spinlock_t *lock);

spin_lock_irq() API 在运行它的处理器核心上(即本地核心)内部禁用中断;因此,在我们的read方法中使用这个 API,将会在本地核心上禁用中断,从而通过中断使任何可能的“竞争”变得不可能。(如果中断确实在另一个 CPU 核心上触发,自旋锁技术将像之前讨论的那样正常工作!)

spin_lock_irq()的实现是相当嵌套的(就像大多数自旋锁功能一样),但是很快;在下一行,它最终会调用local_irq_disable()preempt_disable()宏,从而在它正在运行的本地处理器核心上禁用中断和内核抢占。(禁用硬件中断也会有(可取的)副作用,即禁用内核抢占。)

spin_lock_irq()与相应的spin_unlock_irq() API 配对使用。因此,对于这种情况的自旋锁的正确使用(与我们之前看到的相反)如下:

// Driver read method ; CORRECT ! driver_read(...)                  << time t0 >>
{
    [ ... ]
    spin_lock_irq(&slock);
    <<--- time t1 : start of critical section >>
*[now all interrupts + preemption on local CPU core are masked (disabled)]*
... << operating on global data object gCtx >> ...
    spin_unlock_irq(&slock);
    <<--- time t2 : end of critical section >>
    [ ... ]
}                                << time t3 >>

在我们自满地拍拍自己的后背并休息一天之前,让我们考虑另一种情况。这一次,在一个更复杂的产品(或项目)上,有可能在代码库上工作的几个开发人员中,有人故意将中断屏蔽设置为某个值,从而阻止一些中断而允许其他中断。为了举例说明,让我们假设这在之前的某个时间点 t0 发生了。现在,正如我们之前描述的,另一个开发人员(就是你!)过来了,并且为了保护驱动程序的read方法中的关键部分,使用了spin_lock_irq() API。听起来正确,是吗?是的,但是这个 API 有权利关闭(屏蔽)所有硬件中断(和内核抢占,我们暂时忽略)。它通过在本地 CPU 核心上低级地操作(非常特定于架构的)硬件中断屏蔽寄存器来实现。假设将对应于中断的位设置为1会启用该中断,而清除该位(为0)会禁用或屏蔽它。由于这个原因,我们可能会得到以下情况:

  • 时间 t0:中断屏蔽被设置为某个值,比如0x8e (10001110b),启用了一些中断并禁用了一些中断。这对项目很重要(在这里,为了简单起见,我们假设有一个 8 位的屏蔽寄存器)

[...时间流逝...].

  • 时间t1:在进入驱动程序read方法的临界区之前调用

spin_lock_irq(&slock);。这个 API 将内部效果是将注册的中断屏蔽位清零,从而禁用所有中断(正如我们认为所需的)。

  • 时间t2:现在,硬件中断无法在这个 CPU 核心上触发,所以我们继续完成临界区。完成后,我们调用

spin_unlock_irq(&slock);。这个 API 将内部效果是将注册的中断屏蔽位设置为1,重新启用所有中断。

然而,中断屏蔽寄存器现在被错误地“恢复”为值0xff (11111111b)而不是原始开发者想要、需要和假设的值0x8e!这可能会(并且可能会)在项目中造成一些问题。

解决方案非常简单:不要假设任何东西,只需保存和恢复中断屏蔽。可以通过以下 API 对实现这一点:

#include <linux/spinlock.h>
 unsigned long spin_lock_irqsave(spinlock_t *lock, unsigned long flags);
 void spin_unlock_irqrestore(spinlock_t *lock, unsigned long flags);

锁定和解锁函数的第一个参数都是要使用的自旋锁变量。第二个参数flags必须是unsigned long类型的本地变量。这将用于保存和恢复中断屏蔽:

spinlock_t slock;
spin_lock_init(&slock);
[ ... ]
driver_read(...) 
{
    [ ... ]
    spin_lock_irqsave(&slock, flags);
    << ... critical section ... >>
    spin_unlock_irqrestore(&slock, flags);
    [ ... ]
}

要严谨一点,spin_lock_irqsave()不是一个 API,而是一个宏;我们将其显示为 API 是为了可读性。此宏的返回值虽然不是 void,但这是一个内部细节(这里更新了flags参数变量)。

如果任务或软中断(底半部中断机制)有一个与您的进程上下文代码路径“竞争”的临界区,那么在这种情况下,使用spin_lock_bh()例程可能是所需的,因为它可以在本地处理器上禁用底半部,然后获取自旋锁,从而保护临界区(类似于spin_lock_irq[save]()在进程上下文中通过禁用本地核心上的硬件中断来保护临界区的方式):

void spin_lock_bh(spinlock_t *lock);

当然,在高性能敏感代码路径中,开销确实很重要(网络堆栈就是一个很好的例子)。因此,使用最简单形式的自旋锁将有助于处理更复杂的变体。尽管如此,肯定会有需要使用更强形式的自旋锁 API 的情况。例如,在 Linux 内核 5.4.0 上,这是我们看到的不同形式自旋锁 API 的使用实例数量的近似值:spin_lock():超过 9,400 个使用实例;spin_lock_irq():超过 3,600 个使用实例;spin_lock_irqsave():超过 15,000 个使用实例;和spin_lock_bh():超过 3,700 个使用实例。(我们不从中得出任何重大推论;只是我们希望指出,在 Linux 内核中广泛使用更强形式的自旋锁 API)。

最后,让我们简要说明一下自旋锁的内部实现:在底层内部方面,实现往往是非常特定于架构的代码,通常由在微处理器上执行非常快的原子机器语言指令组成。例如,在流行的 x86[_64]架构中,自旋锁最终归结为自旋锁结构成员上的原子测试和设置机器指令(通常通过cmpxchg机器语言指令实现)。在 ARM 机器上,正如我们之前提到的,实现的核心往往是wfe(等待事件,以及SetEventSEV))机器指令。(您将在进一步阅读部分找到关于其内部实现的资源)。无论如何,作为内核或驱动程序的作者,您在使用自旋锁时应该只使用公开的 API(和宏)。

使用自旋锁-快速总结

让我们快速总结一下自旋锁:

  • 最简单,开销最低:在进程上下文中保护关键部分时,使用非中断自旋锁原语spin_lock()/spin_unlock()(要么没有中断需要处理,要么有中断,但我们根本不与它们竞争;实际上,当中断不起作用或不重要时使用这个)。

  • 中等开销:当中断起作用并且很重要时(进程和中断上下文可以“竞争”;也就是说,它们共享全局数据)使用禁用中断(以及内核抢占禁用)版本,spin_lock_irq() / spin_unlock_irq()

  • 最强(相对而言),开销最高:这是使用自旋锁的最安全方式。它与中等开销相同,只是通过spin_lock_irqsave() / spin_unlock_irqrestore()对中断掩码执行保存和恢复,以确保以前的中断掩码设置不会被意外覆盖,这可能会发生在前一种情况下。

正如我们之前所看到的,自旋锁在等待锁时在其运行的处理器上“自旋”是不可能的(在另一个线程同时在同一处理器上运行时,你怎么能在一个可用的 CPU 上自旋呢?)。事实上,在 UP 系统上,自旋锁 API 的唯一真正效果是它可以禁用处理器上的硬件中断和内核抢占!然而,在 SMP(多核)系统上,自旋逻辑实际上会起作用,因此锁定语义会按预期工作。但是等一下——这不应该让你感到压力,新手内核/驱动程序开发人员;事实上,整个重点是你应该简单地按照描述使用自旋锁 API,你将永远不必担心 UP 与 SMP;做什么和不做什么的细节都被内部实现隐藏起来。

尽管这本书是基于 5.4 LTS 内核的,但 5.8 内核从实时 LinuxRTL,之前称为 PREEMPT_RT)项目中添加了一个新功能,值得在这里快速提一下:“本地锁”。虽然本地锁的主要用例是(硬)实时内核,但它们也对非实时内核有所帮助,主要是通过静态分析进行锁调试,以及通过 lockdep 进行运行时调试(我们将在下一章中介绍 lockdep)。这是有关该主题的 LWN 文章:lwn.net/Articles/828477/

通过这一点,我们完成了关于自旋锁的部分,这是 Linux 内核中几乎所有子系统(包括驱动程序)都使用的一种极其常见和关键的锁。

总结

祝贺你完成了这一章节!

理解并发及其相关问题对于任何软件专业人员来说绝对至关重要。在本章中,您学习了关于关键部分、其中独占执行的需求以及原子性含义的关键概念。然后,您了解了在为 Linux 操作系统编写代码时为什么需要关注并发。之后,我们详细介绍了实际的锁技术——互斥锁和自旋锁。您还学会了在何时使用哪种锁。最后,我们讨论了在硬件中断(以及可能的底半部分)起作用时如何处理并发问题。

但我们还没有完成!我们还需要学习更多概念和技术,这正是我们将在下一章,也是最后一章中做的。我建议你先浏览本章的内容,以及进一步阅读部分和提供的练习,然后再深入研究最后一章!

问题

最后,这里有一些问题供您测试对本章材料的了解:github.com/PacktPublishing/Linux-Kernel-Programming/tree/master/questions。您会在本书的 GitHub 存储库中找到一些问题的答案:github.com/PacktPublishing/Linux-Kernel-Programming/tree/master/solutions_to_assgn

进一步阅读

为了帮助您深入了解这个主题并提供有用的材料,我们在本书的 GitHub 存储库中提供了一个相当详细的在线参考和链接列表(有时甚至包括书籍)的《进一步阅读》文档。进一步阅读文档在这里可用:github.com/PacktPublishing/Linux-Kernel-Programming/raw/master/Further_Reading.md

第十三章:内核同步 - 第二部分

本章继续讨论上一章的内容,即内核同步和处理内核中的并发。我建议如果你还没有阅读上一章,那么先阅读上一章,然后再继续阅读本章。

在这里,我们将继续学习有关内核同步和处理内核空间并发的广泛主题。与以往一样,这些材料是针对内核和/或设备驱动程序开发人员的。在本章中,我们将涵盖以下内容:

  • 使用atomic_trefcount_t接口

  • 使用 RMW 原子操作符

  • 使用读写自旋锁

  • 缓存效应和伪共享

  • 使用基于 CPU 的无锁编程

  • 内核中的锁调试

  • 内存屏障 - 介绍

使用atomic_trefcount_t接口

在我们简单的演示杂项字符设备驱动程序(miscdrv_rdwr/miscdrv_rdwr.c)的open方法(以及其他地方),我们定义并操作了两个静态全局整数gagb

static int ga, gb = 1;
[...]
ga++; gb--;

到目前为止,你应该已经明白了,我们操作这些整数的地方是一个潜在的错误,如果保持原样:它是共享可写数据(在共享状态下),因此是一个关键部分,因此需要保护免受 并发访问。你明白了;因此,我们逐步改进了这一点。在上一章中,了解了这个问题,在我们的ch12/1_miscdrv_rdwr_mutexlock/1_miscdrv_rdwr_mutexlock.c程序中,我们首先使用互斥锁来保护关键部分。后来,你了解到,使用自旋锁来保护非阻塞关键部分,比如这个,从性能上来说会(远远)优于使用互斥锁;因此,在我们的下一个驱动程序ch12/2_miscdrv_rdwr_spinlock/2_miscdrv_rdwr_spinlock.c中,我们使用了自旋锁:

spin_lock(&lock1);
ga++; gb--;
spin_unlock(&lock1);

这很好,但我们仍然可以做得更好!原来在内核中操作全局整数是如此普遍(想想引用或资源计数器的增加和减少等),以至于内核提供了一类操作符,称为refcount原子整数操作符或接口;这些接口专门设计用于原子地(安全和不可分割地)操作只有整数

更新的refcount_t与旧的atomic_t接口

在这个主题领域的开始,重要的是要提到这一点:从 4.11 内核开始,有一个名为refcount_t的新的和更好的接口集,用于内核空间对象的引用计数。它极大地改进了内核的安全性(通过大大改进的整数溢出IoF)和使用后释放UAF)保护以及内存排序保证,而旧的atomic_t接口缺乏)。refcount_t接口,就像 Linux 上使用的其他安全技术一样,源自 The PaX Team 的工作 - pax.grsecurity.net/(它被称为PAX_REFCOUNT)。

话虽如此,事实是(截至撰写本文时),旧的atomic_t接口在内核核心和驱动程序中仍然被广泛使用(它们正在逐渐转换,旧的atomic_t接口正在移动到更新的refcount_t模型和 API 集)。因此,在这个主题中,我们同时涵盖了两者,指出差异并提到哪些refcount_t API 在适用的地方取代了atomic_t API。将refcount_t接口视为(旧的)atomic_t接口的变体,专门用于引用计数。

atomic_t运算符和refcount_t运算符之间的一个关键区别在于前者适用于有符号整数,而后者基本上设计为仅适用于unsigned int数量;更具体地,这很重要,它仅在严格指定的范围内工作:1UINT_MAX-1(或在!CONFIG_REFCOUNT_FULL时为[1..INT_MAX])。内核有一个名为CONFIG_REFCOUNT_FULL的配置选项;如果设置,它将执行(更慢和更彻底的)"完整"引用计数验证。这对安全性有益,但可能会导致性能略有下降(典型的默认设置是将此配置关闭;这是我们的 x86_64 Ubuntu guest 的情况)。

试图将refcount_t变量设置为0或负数,或者为[U]INT_MAX或更高,是不可能的;这对于防止整数下溢/上溢问题以及在许多情况下防止使用后释放类错误非常有益!(好吧,不是不可能;它会通过WARN()宏触发(吵闹的)警告。)想想看,refcount_t变量只能用于内核对象引用计数,其他用途不行。

因此,这确实是所需的行为;引用计数器必须从正值开始(通常在对象新实例化时为1),每当代码获取或获取引用时增加(或添加到),并且每当代码放置或离开对象上的引用时减少(或减去)。您应该仔细操作引用计数器(匹配您的获取和放置),始终保持其值在合法范围内。

相当令人费解的是,至少对于通用的与体系结构无关的 refcount 实现,refcount_t API 是在atomic_t API 集上内部实现的。例如,refcount_set() API - 用于将引用计数的值原子设置为传递的参数 - 在内核中是这样实现的:

// include/linux/refcount.h
/**
 * refcount_set - set a refcount's value
 * @r: the refcount
 * @n: value to which the refcount will be set
 */
static inline void refcount_set(refcount_t *r, unsigned int n)
{
    atomic_set(&r->refs, n); 
}

它是对atomic_set()的一个薄包装(我们很快会介绍)。这里的明显常见问题是:为什么要使用 refcount API?有几个原因:

  • 计数器在REFCOUNT_SATURATED值(默认设置为UINT_MAX)处饱和,并且一旦到达那里就不会再动了。这很关键:它避免了计数器的包装,这可能会导致奇怪和偶发的 UAF 错误;这甚至被认为是一个关键的安全修复(kernsec.org/wiki/index.php/Kernel_Protections/refcount_t)。

  • 一些较新的 refcount API 确实提供了内存排序保证;特别是refcount_t API - 与它们的较旧的atomic_t表亲相比 - 它们提供的内存排序保证在www.kernel.org/doc/html/latest/core-api/refcount-vs-atomic.html#refcount-t-api-compared-to-atomic-t中有明确的文档记录(如果您对底层细节感兴趣,请查看)。

  • 还要意识到,与先前提到的通用实现不同,依赖于体系结构的 refcount 实现(如果存在的话;例如,x86 有,而 ARM 没有)可能会有所不同。

内存排序到底是什么,它如何影响我们?事实上,这是一个复杂的话题,不幸的是,关于这个话题的内部细节超出了本书的范围。了解基础知识是值得的:我建议您阅读Linux-Kernel Memory ModelLKMM),其中包括处理器内存排序等内容。我们在这里向您推荐这里的良好文档:Linux-Kernel Memory Model 解释github.com/torvalds/linux/raw/master/tools/memory-model/Documentation/explanation.txt)。

更简单的 atomic_t 和 refcount_t 接口

关于atomic_t接口,我们应该提到所有以下atomic_t构造仅适用于 32 位整数;当然,现在 64 位整数已经很常见,64 位原子整数操作符也是可用的。它们通常在语义上与它们的 32 位对应物相同,区别在于名称(atomic_foo()变成atomic64_foo())。因此,64 位原子整数的主要数据类型称为atomic64_t(又名atomic_long_t)。另一方面,refcount_t接口适用于 32 位和 64 位整数。

下表显示了如何并排声明和初始化atomic_trefcount_t变量,以便您可以进行比较和对比:

(旧) atomic_t(仅限 32 位) (新) refcount_t(32 位和 64 位)
要包含的头文件 <linux/atomic.h> <linux/refcount.h>
声明和初始化变量 static atomic_t gb = ATOMIC_INIT(1); static refcount_t gb = REFCOUNT_INIT(1);

表 17.1 - 旧的 atomic_t 与新的 refcount_t 接口用于引用计数:头文件和初始化

内核中可用的所有atomic_trefcount_tAPI 的完整集合非常庞大;为了在本节中保持简单和清晰,我们只列出了一些常用的(原子 32 位)和refcount_t接口(它们作用于通用的atomic_trefcount_t变量v)。

操作 (旧) atomic_t 接口 (新) refcount_t 接口 [范围:0 到[U]INT_MAX]
要包含的头文件 <linux/atomic.h> <linux/refcount.h>
声明和初始化变量 static atomic_t v = ATOMIC_INIT(1); static refcount_t v = REFCOUNT_INIT(1);
原子性地读取v的当前值 int atomic_read(atomic_t *v) unsigned int refcount_read(const refcount_t *v)
原子性地将v设置为值i void atomic_set(atomic_t *v, i) void refcount_set(refcount_t *v, int i)
原子性地将v的值增加1 void atomic_inc(atomic_t *v) void refcount_inc(refcount_t *v)
原子性地将v的值减少1 void atomic_dec(atomic_t *v) void refcount_dec(refcount_t *v)
原子性地将i的值添加到v void atomic_add(i, atomic_t *v) void refcount_add(int i, refcount_t *v)
原子性地从v中减去i的值 void atomic_sub(i, atomic_t *v) void refcount_sub(int i, refcount_t *v)
原子性地将i的值添加到v并返回结果 int atomic_add_return(i, atomic_t *v) bool refcount_add_not_zero(int i, refcount_t *v)(不是精确匹配;将i添加到v,除非它是0。)
原子性地从v中减去i的值并返回结果 int atomic_sub_return(i, atomic_t *v) bool refcount_sub_and_test(int i, refcount_t *r)(不是精确匹配;从v中减去i并测试;如果结果引用计数为0,则返回true,否则返回false。)

表 17.2 - 旧的 atomic_t 与新的 refcount_t 接口用于引用计数:API

您现在已经看到了几个atomic_trefcount_t宏和 API;让我们快速检查内核中它们的使用示例。

在内核代码库中使用 refcount_t 的示例

在我们关于内核线程的演示内核模块之一(在ch15/kthread_simple/kthread_simple.c中),我们创建了一个内核线程,然后使用get_task_struct()内联函数来标记内核线程的任务结构正在使用中。正如您现在可以猜到的那样,get_task_struct()例程通过refcount_inc()API 增加任务结构的引用计数 - 一个名为usagerefcount_t变量:

// include/linux/sched/task.h
static inline struct task_struct *get_task_struct(struct task_struct *t) 
{
    refcount_inc(&t->usage);
    return t;
}

相反的例程put_task_struct()对引用计数执行后续递减。它内部使用的实际例程refcount_dec_and_test()测试新的 refcount 值是否已经下降到0;如果是,它返回true,如果是这种情况,这意味着任务结构没有被任何人引用。调用__put_task_struct()释放它:

static inline void put_task_struct(struct task_struct *t) 
{
    if (refcount_dec_and_test(&t->usage))
        __put_task_struct(t);
}

内核中另一个使用 refcounting API 的例子可以在kernel/user.c中找到(它有助于跟踪用户通过每个用户结构声明的进程、文件等的数量):

图 13.1 - 屏幕截图显示内核/user.c 中 refcount_t 接口的使用

查阅refcount_t API 接口文档(www.kernel.org/doc/html/latest/driver-api/basics.html#reference-counting);refcount_dec_and_lock_irqsave()返回true,如果能够将引用计数减少到0,则在禁用中断的情况下保留自旋锁,否则返回false

作为练习,将我们先前的ch16/2_miscdrv_rdwr_spinlock/miscdrv_rdwr_spinlock.c驱动程序代码转换为使用 refcount;它具有整数gagb,在读取或写入时,通过自旋锁进行保护。现在,将它们变成 refcount 变量,并在处理它们时使用适当的refcount_t API。

小心!不要让它们的值超出允许的范围,[0..[U]INT_MAX]!(请记住,对于完整的 refcount 验证(CONFIG_REFCOUNT_FULL打开),范围是[1..UINT_MAX-1],当不是完整验证(默认)时,范围是[1..INT_MAX])。这样做通常会导致调用WARN()宏(此演示中的代码在我们的 GitHub 存储库中不包括):

图 13.2 - (部分)屏幕截图显示当我们错误地尝试将 refcount_t 变量设置为<= 0 时,WARN()宏触发

内核有一个有趣且有用的测试基础设施,称为Linux 内核转储测试模块LKDTM);请参阅drivers/misc/lkdtm/refcount.c,了解对 refcount 接口运行的许多测试用例,您可以从中学习...另外,您还可以通过内核的故障注入框架使用 LKDTM 来测试和评估内核对故障情况的反应(请参阅此处的文档:使用 Linux 内核转储测试模块(LKDTM)引发崩溃 - www.kernel.org/doc/html/latest/fault-injection/provoke-crashes.html#provoking-crashes-with-linux-kernel-dump-test-module-lkdtm)。

迄今为止,所有涵盖的原子接口都是针对 32 位整数进行操作的;那么 64 位呢?接下来就是。

64 位原子整数操作符

正如本主题开头提到的,我们迄今为止处理的atomic_t整数操作符集都是针对传统的 32 位整数进行操作的(这个讨论不适用于较新的refcount_t接口;它们无论如何都是针对 32 位和 64 位数量进行操作)。显然,随着 64 位系统成为现在的常态而不是例外,内核社区为 64 位整数提供了一套相同的原子整数操作符。区别如下:

  • 将 64 位原子整数声明为atomic64_t类型的变量(即atomic_long_t)。

  • 对于所有操作符,使用atomic64_前缀代替atomic_前缀。

因此,看下面的例子:

  • 使用ATOMIC64_INIT()代替ATOMIC_INIT()

  • 使用atomic64_read()代替atomic_read()

  • 使用atomic64_dec_if_positive()代替atomic64_dec_if_positive()

最近的 C 和 C++语言标准——C11 和 C++11——提供了一个原子操作库,帮助开发人员更容易地实现原子性,因为它们具有隐式的语言支持;我们不会在这里深入讨论这个方面。可以在这里找到参考资料(C11 也有几乎相同的等价物):en.cppreference.com/w/c/atomic

请注意,所有这些例程——32 位和 64 位的原子_operators——都是与体系结构无关的。值得重复的关键一点是,对原子整数进行的任何和所有操作都必须通过将变量声明为atomic_t并通过提供的方法来完成。这包括初始化甚至(整数)读取操作。

在内部实现方面,foo()原子整数操作通常是一个宏,它变成一个内联函数,然后调用特定于体系结构的arch_foo()函数。通常情况下,浏览官方内核文档中的原子操作符总是一个好主意(在内核源树中,它在这里:Documentation/atomic_t.txt;访问www.kernel.org/doc/Documentation/atomic_t.txt)。它将众多的原子整数 API 整齐地分类为不同的集合。值得一提的是,特定于体系结构的内存排序问题会影响内部实现。在这里,我们不会深入讨论内部情况。如果感兴趣,请参考官方内核文档网站上的这个页面www.kernel.org/doc/html/v4.16/core-api/refcount-vs-atomic.html#refcount-t-api-compared-to-atomic-t(此外,关于内存排序的细节超出了本书的范围;请查看内核文档www.kernel.org/doc/Documentation/memory-barriers.txt)。

我们没有尝试在这里展示所有的原子和引用计数 API(这真的不是必要的);官方内核文档对此进行了覆盖:

让我们继续讨论在驱动程序中使用的典型构造——读取修改写入RMW)。继续阅读!

使用 RMW 原子操作符

还有一组更高级的原子操作,称为 RMW API,也是可用的。在其许多用途中(我们将在接下来的部分中列出),是对位进行原子 RMW 操作,换句话说,以原子方式(安全、不可分割地)执行位操作。作为操作设备或外围寄存器的设备驱动程序作者,这确实是您将发现自己在使用的东西。

本节材料假定您至少具有基本的访问外围设备(芯片)内存和寄存器的理解;我们在第十三章“使用硬件 I/O 内存”中详细介绍了这一点。请确保在继续之前您已经理解了它。

经常需要对寄存器执行位操作(使用按位AND&和按位OR |最常见的运算符),这是为了修改其值,设置和/或清除其中的一些位。问题是,仅仅执行一些 C 操作来查询或设置设备寄存器是不够的。不要忘记并发问题!继续阅读完整的故事。

RMW 原子操作-操作设备寄存器

首先让我们快速了解一些基础知识:一个字节由 8 位组成,从位0,即最低有效位LSB),到位7,即最高有效位MSB)。 (实际上,这在include/linux/bits.h中以BITS_PER_BYTE宏的形式正式定义,还有一些其他有趣的定义。)

寄存器基本上是外围设备中的一小块内存;通常,其大小,即寄存器位宽,为 8、16 或 32 位之一。设备寄存器提供控制、状态和其他信息,并且通常是可编程的。实际上,这在很大程度上是作为驱动程序作者将要做的事情-适当地编程设备寄存器以使设备执行某些操作,并查询它。

为了充实这个讨论,让我们考虑一个假设的设备,它有两个寄存器:一个状态寄存器和一个控制寄存器,每个寄存器宽度为 8 位。(在现实世界中,每个设备或芯片都有一个数据表,其中提供了芯片和寄存器级硬件的详细规格;这对于驱动程序作者来说是一个必不可少的文档)。硬件人员通常设计设备,使得几个寄存器按顺序组合在一个更大的内存块中;这称为寄存器银行。通过具有第一个寄存器的基址和每个后续寄存器的偏移量,很容易寻址任何给定的寄存器(在这里,我们不会深入探讨在诸如 Linux 的操作系统的虚拟地址空间中如何将寄存器“映射”)。例如,在头文件中可以这样描述(纯粹是假设的)寄存器:

#define REG_BASE        0x5a00
#define STATUS_REG      (REG_BASE+0x0)
#define CTRL_REG        (REG_BASE+0x1)

现在,假设为了打开我们的虚构设备,数据表告诉我们可以通过将控制寄存器的第7位(MSB)设置为1来实现。正如每个驱动程序作者迅速学到的那样,修改寄存器有一个神圣的序列:

  1. 读取寄存器的当前值到临时变量中。

  2. 修改变量为所需值。

  3. 变量写回寄存器。

这通常被称为RMW 序列;因此,很好,我们像这样编写(伪)代码:

turn_on_dev()
{
    u8 tmp;

    tmp = ioread8(CTRL_REG);  /* read: current register value into tmp */
    tmp |= 0x80;              /* modify: set bit 7 (MSB) */
    iowrite8(tmp, CTRL_REG);  /* write: new tmp value into register */
}

(顺便说一句,Linux 上实际使用的例程MMIO-内存映射 I/O-是ioread[8|16|32]()iowrite[8|16|32]()。)

这里的一个关键点:这还不够好;原因是并发,数据竞争!想想看:一个寄存器(CPU 和设备寄存器)实际上是一个全局共享的可写内存位置;因此,访问它构成一个临界区,您必须小心保护免受并发访问!如何做到这一点很容易;我们可以暂时使用自旋锁。将spin_[un]lock() API 插入临界区- RMW 序列的前述伪代码是微不足道的。

然而,处理小量数据时实现数据安全的更好方法是:原子操作符!然而,Linux 更进一步,为以下两种情况提供了一组原子 API:

  • 原子非 RMW 操作(我们之前看到的,在使用 atomic_t 和 refcount_t 接口部分)

  • 原子 RMW 操作;这些包括几种类型的运算符,可以归类为几个不同的类别:算术、按位、交换(交换)、引用计数、杂项和屏障

让我们不要重复造轮子;内核文档(www.kernel.org/doc/Documentation/atomic_t.txt)包含了所有所需的信息。我们将只展示这份文件的相关部分,直接引用自Documentation/atomic_t.txt内核代码库:

// Documentation/atomic_t.txt
[ ... ]
Non-RMW ops:
  atomic_read(), atomic_set()
  atomic_read_acquire(), atomic_set_release()

RMW atomic operations:

Arithmetic:
  atomic_{add,sub,inc,dec}()
  atomic_{add,sub,inc,dec}_return{,_relaxed,_acquire,_release}()
  atomic_fetch_{add,sub,inc,dec}{,_relaxed,_acquire,_release}()

Bitwise:
  atomic_{and,or,xor,andnot}()
  atomic_fetch_{and,or,xor,andnot}{,_relaxed,_acquire,_release}()

Swap:
  atomic_xchg{,_relaxed,_acquire,_release}()
  atomic_cmpxchg{,_relaxed,_acquire,_release}()
  atomic_try_cmpxchg{,_relaxed,_acquire,_release}()

Reference count (but please see refcount_t):
  atomic_add_unless(), atomic_inc_not_zero()
  atomic_sub_and_test(), atomic_dec_and_test()

Misc:
  atomic_inc_and_test(), atomic_add_negative()
  atomic_dec_unless_positive(), atomic_inc_unless_negative()
[ ... ]

好了,现在你知道了这些 RMW(和非 RMW)操作符,让我们来实际操作一下 - 我们将看看如何使用 RMW 操作符进行位操作。

使用 RMW 位操作符

在这里,我们将专注于使用 RMW 位操作符;其他操作符的探索留给您(参考提到的内核文档)。因此,让我们再次考虑如何更有效地编写我们的伪代码示例。我们可以使用set_bit() API 在任何寄存器或内存项中设置(为1)任何给定的位:

void set_bit(unsigned int nr, volatile unsigned long *p);

这个原子地 - 安全地和不可分割地 - 将p的第nr位设置为1。(事实上,设备寄存器(可能还有设备内存)被映射到内核虚拟地址空间,因此看起来就像是 RAM 位置 - 就像这里的地址p一样。这被称为 MMIO,是驱动程序作者映射和处理设备内存的常用方式。再次强调,我们在Linux 内核编程(第二部分)中介绍了这一点)

因此,使用 RMW 原子操作符,我们可以安全地实现我们之前(错误地)尝试的事情 - 用一行代码打开我们的(虚构的)设备:

set_bit(7, CTRL_REG);

以下表总结了常见的 RMW 位原子 API:

RMW 位原子 API 注释
void set_bit(unsigned int nr, volatile unsigned long *p); 原子地设置(设置为1p的第nr位。
void clear_bit(unsigned int nr, volatile unsigned long *p) 原子地清除(设置为0p的第nr位。
void change_bit(unsigned int nr, volatile unsigned long *p) 原子地切换p的第nr位。
以下 API 返回正在操作的位(nr)的先前值
int test_and_set_bit(unsigned int nr, volatile unsigned long *p) 原子地设置p的第nr位,并返回先前的值(内核 API 文档位于www.kernel.org/doc/htmldocs/kernel-api/API-test-and-set-bit.html)。
int test_and_clear_bit(unsigned int nr, volatile unsigned long *p) 原子地清除p的第nr位,并返回先前的值。
int test_and_change_bit(unsigned int nr, volatile unsigned long *p) 原子地切换p的第nr位,并返回先前的值。

表 17.3 - 常见的 RMW 位原子 API

注意:这些原子 API 不仅仅是相对于它们运行的 CPU 核心是原子的,现在也是相对于所有/其他核心是原子的。实际上,这意味着如果您在多个 CPU 上并行执行原子操作,也就是说,如果它们(可以)竞争,那么这是一个临界区,您必须用锁(通常是自旋锁)保护它!

尝试一些这些 RMW 原子 API 将有助于建立您对它们的信心;我们将在接下来的部分中这样做。

使用位原子操作符 - 一个例子

让我们来看一个快速的内核模块,演示了 Linux 内核的 RMW 原子位操作符的使用(ch13/1_rmw_atomic_bitops)。您应该意识到这些操作符可以在任何内存上工作,无论是寄存器还是 RAM;在这里,我们在示例 LKM 中的一个简单静态全局变量(名为mem)上操作。非常简单;让我们来看一下:

// ch13/1_rmw_atomic_bitops/rmw_atomic_bitops.c
[ ... ]
#include <linux/spinlock.h>
#include <linux/atomic.h>
#include <linux/bitops.h>
#include "../../convenient.h"
[ ... ]
static unsigned long mem;
static u64 t1, t2; 
static int MSB = BITS_PER_BYTE - 1;
DEFINE_SPINLOCK(slock);

我们包括所需的头文件,并声明和初始化一些全局变量(注意我们的MSB变量如何使用BIT_PER_BYTE)。我们使用一个简单的宏SHOW(),用printk显示格式化输出。init代码路径是实际工作的地方:

[ ... ]
#define SHOW(n, p, msg) do {                                   \
    pr_info("%2d:%27s: mem : %3ld = 0x%02lx\n", n, msg, p, p); \
} while (0)
[ ... ]
static int __init atomic_rmw_bitops_init(void)
{
    int i = 1, ret;

    pr_info("%s: inserted\n", OURMODNAME);
    SHOW(i++, mem, "at init");

    setmsb_optimal(i++);
    setmsb_suboptimal(i++);

    clear_bit(MSB, &mem);
    SHOW(i++, mem, "clear_bit(7,&mem)");

    change_bit(MSB, &mem);
    SHOW(i++, mem, "change_bit(7,&mem)");

    ret = test_and_set_bit(0, &mem);
    SHOW(i++, mem, "test_and_set_bit(0,&mem)");
    pr_info(" ret = %d\n", ret);

    ret = test_and_clear_bit(0, &mem);
    SHOW(i++, mem, "test_and_clear_bit(0,&mem)");
    pr_info(" ret (prev value of bit 0) = %d\n", ret);

    ret = test_and_change_bit(1, &mem);
    SHOW(i++, mem, "test_and_change_bit(1,&mem)");
    pr_info(" ret (prev value of bit 1) = %d\n", ret);

    pr_info("%2d: test_bit(%d-0,&mem):\n", i, MSB);
    for (i = MSB; i >= 0; i--)
        pr_info(" bit %d (0x%02lx) : %s\n", i, BIT(i), test_bit(i, &mem)?"set":"cleared");

    return 0; /* success */
}

我们在这里使用的 RMW 原子操作符以粗体字突出显示。这个演示的关键部分是展示使用 RMW 位原子操作符不仅更容易,而且比使用传统方法更快,传统方法是在自旋锁的限制下手动执行 RMW 操作。这是这两种方法的两个函数:

/* Set the MSB; optimally, with the set_bit() RMW atomic API */
static inline void setmsb_optimal(int i)
{
    t1 = ktime_get_real_ns();
    set_bit(MSB, &mem);
    t2 = ktime_get_real_ns();
    SHOW(i, mem, "set_bit(7,&mem)");
    SHOW_DELTA(t2, t1);
}
/* Set the MSB; the traditional way, using a spinlock to protect the RMW
 * critical section */
static inline void setmsb_suboptimal(int i)
{
    u8 tmp;

    t1 = ktime_get_real_ns();
    spin_lock(&slock);
 /* critical section: RMW : read, modify, write */
    tmp = mem;
    tmp |= 0x80; // 0x80 = 1000 0000 binary
    mem = tmp;
    spin_unlock(&slock);
    t2 = ktime_get_real_ns();

    SHOW(i, mem, "set msb suboptimal: 7,&mem");
    SHOW_DELTA(t2, t1);
}

我们在init方法中早期调用这些函数;请注意,我们通过ktime_get_real_ns()例程获取时间戳,并通过我们在convenient.h头文件中定义的SHOW_DELTA()宏显示所花费的时间。好了,这是输出:

图 13.3 - 来自我们的 ch13/1_rmw_atomic_bitops LKM 的输出截图,展示了一些原子 RMW 操作符的工作情况

(我在我的 x86_64 Ubuntu 20.04 虚拟机上运行了这个演示 LKM。)现代方法 - 通过set_bit() RMW 原子位 API - 在这个示例运行中只需 415 纳秒就能执行;传统方法慢了大约 265 倍!而且(通过set_bit())的代码也简单得多...

在与原子位操作相关的一点上,以下部分是对内核中用于搜索位掩码的高效 API 的简要介绍 - 这在内核中是一个相当常见的操作。

高效搜索位掩码

几种算法依赖于对位掩码进行非常快速的搜索;几个调度算法(例如SCHED_FIFOSCHED_RR,你在第十章和第十一章中学到的)通常在内部需要这样做。有效地实现这一点变得很重要(特别是对于操作系统级别的性能敏感的代码路径)。因此,内核提供了一些 API 来扫描给定的位掩码(这些原型可以在include/asm-generic/bitops/find.h中找到):

  • unsigned long find_first_bit(const unsigned long *addr, unsigned long size): 在内存区域中查找第一个设置的位;返回第一个设置的位的位数,否则(没有设置位)返回@size

  • unsigned long find_first_zero_bit(const unsigned long *addr, unsigned long size): 在内存区域中查找第一个清除的位;返回第一个清除的位的位数,否则(没有清除的位)返回@size

  • 其他例程包括find_next_bit()find_next_and_bit()find_last_bit()

浏览<linux/bitops.h>头文件还会发现其他一些非常有趣的宏,比如for_each_{clear,set}_bit{_from}

使用读写自旋锁

想象一下内核(或驱动程序)代码的一部分,在其中正在搜索一个大型的全局双向循环链表(有几千个节点)。现在,由于数据结构是全局的(共享和可写),访问它构成了一个需要保护的关键部分。

假设搜索列表是一个非阻塞操作的场景,你通常会使用自旋锁来保护关键部分。一个天真的方法可能会建议根本不使用锁,因为我们只是在列表中读取数据,而不是更新它。但是,当然(正如你已经学到的),即使在共享可写数据上进行读取也必须受到保护,以防止同时发生意外写入,从而导致脏读或断裂读。

因此,我们得出结论,我们需要自旋锁;我们可以想象伪代码可能看起来像这样:

spin_lock(mylist_lock);
for (p = &listhead; (p = next_node(p)) != &listhead; ) {
    << ... search for something ... 
         found? break out ... >>
}
spin_unlock(mylist_lock);

那么问题是什么?当然是性能!想象一下,在多核系统上,几个线程几乎同时到达这段代码片段;每个线程都会尝试获取自旋锁,但只有一个获胜的线程会获取它,遍历整个列表,然后执行解锁,允许下一个线程继续。换句话说,执行现在是串行化的,大大减慢了速度。但是没办法;还是有办法吗?

进入读写自旋锁。使用这种锁定构造,要求所有对受保护数据进行读取的线程都会请求读锁,而任何需要对列表进行写访问的线程都会请求独占写锁。只要没有写锁在起作用,读锁将立即授予任何请求的线程。实际上,这种构造允许所有读者并发访问数据,也就是说,实际上根本没有真正的锁定。只要只有读者,这是可以的。一旦有写入线程出现,它就会请求写锁。现在,正常的锁定语义适用:写入者必须等待所有读者解锁。一旦发生这种情况,写入者就会获得独占写锁并继续。因此,现在,如果任何读者或写者尝试访问,它们将被迫等待以在写入者解锁时进行自旋。

因此,对于那些数据访问模式中读取非常频繁而写入很少,并且关键部分相当长的情况,读写自旋锁是一种性能增强的锁。

读写自旋锁接口

使用自旋锁后,使用读写变体是很简单的;锁数据类型被抽象为rwlock_t结构(代替spinlock_t),在 API 名称方面,只需用readwrite替换spin

#include <linux/rwlock.h>
rwlock_t mylist_lock;

读写自旋锁的最基本 API 如下:

void read_lock(rwlock_t *lock);
void write_lock(rwlock_t *lock);

例如,内核的tty层有处理安全关键SAK)的代码;SAK 是一种安全功能,是一种防止特洛伊木马式凭据黑客的手段,通过终止与 TTY 设备关联的所有进程。当用户按下 SAK 时(www.kernel.org/doc/html/latest/security/sak.html),这将发生(也就是说,当用户按下 SAK,默认映射为Alt-SysRq-k序列时),在其代码路径中,它必须迭代所有任务,终止整个会话和任何打开 TTY 设备的线程。为此,它必须以读模式获取名为tasklist_lock的读写自旋锁。相关代码如下(截断),其中tasklist_lock上的read_[un]lock()被突出显示:

// drivers/tty/tty_io.c
void __do_SAK(struct tty_struct *tty)
{
    [...]
    read_lock(&tasklist_lock);
    /* Kill the entire session */
    do_each_pid_task(session, PIDTYPE_SID, p) {
        tty_notice(tty, "SAK: killed process %d (%s): by session\n", task_pid_nr(p), p->comm);
        group_send_sig_info(SIGKILL, SEND_SIG_PRIV, p, PIDTYPE_SID);
    } while_each_pid_task(session, PIDTYPE_SID, p);
    [...]
    /* Now kill any processes that happen to have the tty open */
    do_each_thread(g, p) {
        [...]
    } while_each_thread(g, p);
    read_unlock(&tasklist_lock);

另外,您可能还记得,在第六章的遍历任务列表部分,内核内部基础知识-进程和线程中,我们做了类似的事情:我们编写了一个内核模块(ch6/foreach/thrd_show_all),它遍历了任务列表中的所有线程,并输出了每个线程的一些详细信息。因此,现在我们了解了并发处理的情况,难道我们不应该使用这个锁-tasklist_lock-保护任务列表的读写自旋锁吗?是的,但它没有起作用(insmod(8)失败,并显示消息thrd_showall: Unknown symbol tasklist_lock (err -2))。原因当然是,这个tasklist_lock变量没有被导出,因此我们的内核模块无法使用它。

作为内核代码库中读写自旋锁的另一个例子,ext4文件系统在处理其范围状态树时使用了一个。我们不打算在这里深入讨论细节;我们只是简单提一下,即在这里相当频繁地使用了一个读写自旋锁(在 inode 结构中,inode->i_es_lock)来保护范围状态树免受数据竞争的影响(fs/ext4/extents_status.c)。

内核源树中有许多这样的例子;网络堆栈中的许多地方,包括 ping 代码(net/ipv4/ping.c)使用rwlock_t,路由表查找,邻居,PPP 代码,文件系统等等。

就像普通自旋锁一样,我们有典型的读写自旋锁 API 的变体:{read,write}_lock_irq{save}()与相应的{read,write}_unlock_irq{restore}(),以及{read,write}_{un}lock_bh()接口。请注意,即使是读取 IRQ 锁也会禁用内核抢占。

谨慎一些。

读写自旋锁存在问题。其中一个典型问题是,不幸的是,写者可能会饿死,当阻塞在多个读者上时。想想看:假设当前有三个读取线程持有读写锁。现在,一个写者想要锁。它必须等到所有三个读者解锁。但是如果在此期间,更多的读者出现了(这是完全可能的)?这对于写者来说是一场灾难,他现在必须等待更长的时间-实际上是挨饿。(可能需要仔细地检查或分析涉及的代码路径,以弄清楚是否确实是这种情况。)

不仅如此,缓存效应 - 即缓存乒乓 - 当几个不同 CPU 核心上的读取线程并行读取相同的共享状态时(同时持有读写锁)时,经常会发生;我们实际上在缓存效应和伪共享部分中讨论了这一点。内核关于自旋锁的文档(www.kernel.org/doc/Documentation/locking/spinlocks.txt)也说了类似的话。以下是直接引用的一句话:“注意!读写锁需要比简单自旋锁更多的原子内存操作。除非读取临界区很长,否则最好只使用自旋锁。”事实上,内核社区正在努力尽可能地删除读写自旋锁,将它们移动到更高级的无锁技术(例如RCU-读复制更新,一种先进的无锁技术)。因此,滥用读写自旋锁是不明智的。

关于自旋锁用法的简洁明了的内核文档(由 Linus Torvalds 本人编写),非常值得一读,可以在这里找到:www.kernel.org/doc/Documentation/locking/spinlocks.txt

读写信号量

我们之前提到了信号量对象(第十二章,内核同步-第一部分,在信号量和互斥体部分),将其与互斥体进行对比。在那里,您了解到最好只使用互斥体。在这里,我们指出在内核中,就像存在读写自旋锁一样,也存在读写信号量。用例和语义与读写自旋锁类似。相关的宏/API(在<linux/rwsem.h>中)是{down,up}_{read,write}_{trylock,killable}()struct mm_struct结构中的一个常见示例(它本身在任务结构中)是一个读写信号量:struct rw_semaphore mmap_sem;

结束这个讨论,我们只是简单地提到了内核中的其他几种相关的同步机制。在用户空间应用程序开发中广泛使用的一种同步机制(我们特别考虑的是 Linux 用户空间中的 Pthreads 框架)是条件变量CV)。简而言之,它提供了两个或更多线程根据数据项的值或某种特定状态进行同步的能力。在 Linux 内核中,它的等效物被称为完成机制。请在内核文档中找到有关其用法的详细信息:www.kernel.org/doc/html/latest/scheduler/completion.html#completions-wait-for-completion-barrier-apis

序列锁主要用于写入情况(相对于读写自旋锁/信号量锁,后者适用于大部分读取场景),在受保护的变量上写入远远超过读取的情况下使用。这并不是一个非常常见的情况;使用序列锁的一个很好的例子是全局变量jiffies_64的更新。

对于好奇的人,jiffies_64全局变量的更新代码从这里开始:kernel/time/tick-sched.c:tick_do_update_jiffies64()。这个函数会判断是否需要更新 jiffies,如果需要,就会调用do_timer(++ticks);来实际更新它。同时,write_seq[un]lock(&jiffies_lock);API 提供了对大部分写入关键部分的保护。

缓存效应和伪共享

现代处理器在内部使用多级并行缓存内存,以便在处理内存时提供非常显著的加速(我们在第八章中简要提到过,模块作者的内核内存分配 - 第一部分分配 slab 内存部分)。我们意识到现代 CPU 实际上并不直接读写 RAM;当软件指示要从某个地址开始读取 RAM 的一个字节时,CPU 实际上会从起始地址读取几个字节 - 一整个缓存行的字节(通常是 64 字节)填充到所有 CPU 缓存中(比如 L1、L2 和 L3:1、2 和 3 级)。这样,访问顺序内存的下几个元素会得到巨大的加速,因为首先在缓存中检查(首先在 L1 中,然后在 L2 中,然后在 L3 中,缓存命中变得可能)。它更快的原因很简单:访问 CPU 缓存内存通常需要 1 到几个(个位数)纳秒,而访问 RAM 可能需要 50 到 100 纳秒(当然,这取决于所涉及的硬件系统和你愿意花费的金额!)。

软件开发人员利用这种现象做一些事情,比如:

  • 将数据结构的重要成员放在一起(希望在一个缓存行内)并放在结构的顶部

  • 填充结构成员,使得我们不会超出缓存行(同样,这些点已经在第八章中涵盖,模块作者的内核内存分配 - 第一部分数据结构 - 一些设计提示部分)。

然而,存在风险,事情确实会出错。举个例子,考虑这样声明的两个变量:u16 ax = 1, bx = 2;u16表示无符号 16 位整数值)。

现在,它们被声明为相邻的,很可能在运行时占用相同的 CPU 缓存行。为了理解问题是什么,让我们举个例子:考虑一个双核系统,每个核有两个 CPU 缓存,L1 和 L2,以及一个公共或统一的 L3 缓存。现在,一个线程T1正在处理变量ax,另一个线程T2正在同时(在另一个 CPU 核心上)处理变量bx。所以,想一想:当线程T1在 CPU 0上从主内存(RAM)访问ax时,它的 CPU 缓存将被axbx的当前值填充(因为它们在同一个缓存行内!)。同样地,当线程T2在 CPU 1上从 RAM 访问bx时,它的 CPU 缓存也会被两个变量的当前值填充。图 13.4在概念上描述了这种情况:

图 13.4 - 当线程 T1 和 T2 并行处理两个相邻变量时,每个变量都在不同的缓存行上,CPU 缓存内存的概念描述

到目前为止还好;但是如果T1执行一个操作,比如ax ++,同时,T2执行bx ++呢?那又怎样?(顺便说一句,你可能会想:为什么他们不使用锁?有趣的是,这与本讨论无关;因为每个线程正在访问不同的变量,所以没有数据竞争。问题在于它们在同一个 CPU 缓存行中。)

这里的问题是缓存一致性。处理器和/或处理器与操作系统(这都是与体系结构相关的东西)将必须保持缓存和 RAM 相互同步或一致。因此,一旦T1修改了ax,CPU 0的特定缓存行将被使无效,也就是说,CPU 0的缓存到 RAM 的刷新将会发生,以更新 RAM 到新值,然后立即,RAM 到 CPU 1的缓存更新也必须发生,以保持一切一致!

但是缓存行也包含bx,正如我们所说,bx也被T2在 CPU 1上修改过。因此,大约在同一时间,CPU 1的缓存行将被刷新到 RAM,带有bx的新值,并随后更新到 CPU 0的缓存中(与此同时,统一的 L3 缓存也将被读取/更新)。正如你可以想象的那样,对这些变量的任何更新都将导致大量的缓存和 RAM 流量;它们会反弹。事实上,这经常被称为缓存乒乓!这种效应非常有害,会显著减慢处理速度。这种现象被称为伪共享

识别伪共享是困难的部分;我们必须寻找在共享缓存行上的变量,这些变量被不同的上下文(线程或其他)同时更新。

有趣的是,在内存管理层的一个关键数据结构的早期实现,include/linux/mmzone.h:struct zone,遭受了这个非常相同的伪共享问题:两个相邻声明的自旋锁!这个问题已经被解决(我们在第七章中简要讨论了内存区域,在物理 RAM 组织/区域部分)。

你如何修复这种伪共享?很简单:只需确保变量之间的间距足够远,以确保它们不共享相同的缓存行(通常在变量之间插入虚拟填充字节以实现此目的)。在进一步阅读部分也可以参考有关伪共享的参考资料。

无锁编程与每 CPU 变量

正如你所学到的,当操作共享可写数据时,关键部分必须以某种方式受到保护。锁定可能是最常用的技术来实现这种保护。然而,并非一切都很顺利,因为性能可能会受到影响。要了解原因,考虑一下与锁定的一些类比:一个是漏斗,漏斗的茎部只宽到足以允许一个线程一次通过,不多。另一个是繁忙高速公路上的单个收费站或繁忙十字路口的交通灯。这些类比帮助我们可视化和理解为什么锁定会导致瓶颈,在某些极端情况下会使性能减慢。更糟糕的是,这些不利影响在拥有几百个核心的高端多核系统上可能会被放大;实际上,锁定不会很好地扩展。

另一个问题是锁争用;特定锁被获取的频率有多高?在系统中增加锁的数量有助于降低两个或多个进程(或线程)之间对特定锁的争用。这被称为锁效率。然而,同样,这并不可扩展到极大程度:过一段时间后,在系统上拥有数千个锁(实际上是 Linux 内核的情况)并不是好消息——产生微妙的死锁条件的机会会显著增加。

因此,存在许多挑战-性能问题、死锁、优先级反转风险、车队(由于锁定顺序,快速代码路径可能需要等待第一个较慢的代码路径,后者也需要锁定),等等。将内核进一步发展为可扩展的方式,需要使用无锁算法及其在内核中的实现。这导致了几种创新技术,其中包括每个 CPU(PCP)数据、无锁数据结构(设计为)和 RCU。

在本书中,我们选择详细介绍每个 CPU 作为一种无锁编程技术。关于 RCU(及其相关的设计为无锁数据结构)的详细信息超出了本书的范围。请参考本章的进一步阅读部分,了解有关 RCU、其含义以及在 Linux 内核中的使用的几个有用资源。

每个 CPU 变量

正如其名称所示,每个 CPU 变量通过为系统上的每个(活动的)CPU 保留一个副本的变量,即所讨论的数据项。实际上,通过避免在线程之间共享数据,我们摆脱了并发的问题区域,即临界区。使用每个 CPU 数据技术,由于每个 CPU 都引用其自己的数据副本,运行在该处理器上的线程可以在没有竞争的情况下操纵它。 (这大致类似于局部变量;由于局部变量位于每个线程的私有堆栈上,它们不在线程之间共享,因此没有临界区,也不需要锁定。)在这里,锁定的需求也被消除了-使其成为一种无锁技术!

因此,想象一下:如果您在一个具有四个活动 CPU 核心的系统上运行,那么该系统上的每个 CPU 变量本质上是一个四个元素的数组:元素0表示第一个 CPU 上的数据值,元素1表示第二个 CPU 核心上的数据值,依此类推。了解这一点,您会意识到每个 CPU 变量在某种程度上也类似于用户空间 Pthreads 线程本地存储TLS)实现,其中每个线程自动获取带有__thread关键字标记的(TLS)变量的副本。在这里和每个 CPU 变量中,应该很明显:仅对小数据项使用每个 CPU 变量。这是因为数据项会被复制为每个 CPU 核心的一个实例(在具有几百个核心的高端系统上,开销会增加)。我们在内核代码库中提到了一些每个 CPU 使用的示例(在内核中的每个 CPU 使用部分)。

现在,在处理每个 CPU 变量时,您必须使用内核提供的辅助方法(宏和 API),而不是尝试直接访问它们(就像我们在 refcount 和 atomic 操作符中看到的那样)。

使用每个 CPU

让我们将对每个 CPU 数据的辅助 API 和宏(方法)的讨论分为两部分。首先,您将学习如何分配、初始化和随后释放每个 CPU 数据项。然后,您将学习如何使用(读/写)它。

分配、初始化和释放每个 CPU 变量

每个 CPU 变量大致分为两种类型:静态分配和动态分配。静态分配的每个 CPU 变量是在编译时分配的,通常通过其中一个宏:DEFINE_PER_CPUDECLARE_PER_CPU。使用DEFINE允许您分配和初始化变量。以下是分配一个整数作为每个 CPU 变量的示例:

#include <linux/percpu.h>
DEFINE_PER_CPU(int, pcpa);      // signature: DEFINE_PER_CPU(type, name)

现在,在一个具有四个 CPU 核心的系统上,它在初始化时会概念上看起来像这样:

图 13.5 - 在具有四个活动 CPU 的系统上对每个 CPU 数据项的概念表示

实际实现比这复杂得多,当然,可以参考本章的进一步阅读部分,了解更多内部实现的内容。

简而言之,使用每个 CPU 变量对于性能敏感的代码路径上的性能增强是有益的,因为:

  • 我们避免使用昂贵的、性能破坏的锁。

  • 每个 CPU 变量的访问和操作保证保持在一个特定的 CPU 核心上;这消除了昂贵的缓存效应,如缓存乒乓和伪共享(在缓存效应和伪共享部分中介绍)。

通过alloc_percpu()alloc_percpu_gfp()包装器宏可以动态分配每个 CPU 数据,只需传递要分配为每个 CPU 的对象的数据类型,对于后者,还要传递gfp分配标志:

alloc_percpu_gfp;

通过底层的__alloc_per_cpu[_gfp]()例程是通过EXPORT_SYMBOL_GPL()导出的(因此只能在 LKM 以 GPL 兼容许可证发布时使用)。

正如您所了解的,资源管理的devm_*() API 变体允许您(通常在编写驱动程序时)方便地使用这些例程来分配内存;内核将负责释放它,有助于防止泄漏情况。devm_alloc_percpu(dev, type)宏允许您将其用作__alloc_percpu()的资源管理版本。

通过前述例程分配的内存必须随后使用void free_percpu(void __percpu * __pdata) API 释放。

在每个 CPU 变量上执行 I/O(读取和写入)

当然,一个关键问题是,你究竟如何访问(读取)和更新(写入)每个 CPU 变量?内核提供了几个辅助例程来做到这一点;让我们举一个简单的例子来理解。我们定义一个单个整数每个 CPU 变量,并在以后的某个时间点,我们想要访问并打印它的当前值。你应该意识到,由于每个 CPU,检索到的值将根据代码当前运行的 CPU 核心自动计算;换句话说,如果以下代码在核心1上运行,那么实际上将获取pcpa[1]的值(实际操作并非完全如此;这只是概念性的):

DEFINE_PER_CPU(int, pcpa);
int val;
[ ... ]
val = get_cpu_var(pcpa);
pr_info("cpu0: pcpa = %+d\n", val);
put_cpu_var(pcpa);

{get,put}_cpu_var()宏对允许我们安全地检索或修改给定每个 CPU 变量(其参数)的每个 CPU 值。重要的是要理解get_cpu_var()put_cpu_var()之间的代码(或等效代码)实际上是一个关键部分-一个原子上下文-在这里内核抢占被禁用,任何类型的阻塞(或休眠)都是不允许的。如果您在这里做任何阻塞(休眠)的操作,那就是内核错误。例如,看看如果您尝试在get_cpu_var()/put_cpu_var()宏对之间通过vmalloc()分配内存会发生什么:

void *p;
val = get_cpu_var(pcpa);
p = vmalloc(20000);
pr_info("cpu1: pcpa = %+d\n", val);
put_cpu_var(pcpa);
vfree(p);
[ ... ]

$ sudo insmod <whatever>.ko
$ dmesg
[ ... ]
BUG: sleeping function called from invalid context at mm/slab.h:421
[67641.443225] in_atomic(): 1, irqs_disabled(): 0, pid: 12085, name:
thrd_1/1
[ ... ]
$

(顺便说一句,在关键部分内部调用printk()(或pr_<foo>())包装器是可以的,因为它们是非阻塞的。)问题在于vmalloc() API 可能是一个阻塞的;它可能会休眠(我们在第九章中详细讨论过,模块作者的内核内存分配-第二部分,在理解和使用内核 vmalloc() API部分),并且在get_cpu_var()/put_cpu_var()对之间的代码必须是原子的和非阻塞的。

在内部,get_cpu_var()宏调用preempt_disable(),禁用内核抢占,并且put_cpu_var()通过调用preempt_enable()来撤消这一操作。正如之前所见(在CPU 调度章节中),这可以嵌套,并且内核维护一个preempt_count变量来确定内核抢占是否实际上已启用或禁用。

总之,您在使用这些宏时必须仔细匹配{get,put}_cpu_var()(例如,如果我们调用get宏两次,我们也必须调用相应的put宏两次)。

get_cpu_var()是一个lvalue,因此可以进行操作;例如,要增加每个 CPU 的pcpa变量,只需执行以下操作:

get_cpu_var(pcpa) ++;
put_cpu_var(pcpa);

您还可以(安全地)通过宏检索当前每个 CPU 的值:

per_cpu(var, cpu);

因此,要为系统上的每个 CPU 核心检索每 CPU 的pcpa变量,请使用以下内容:

for_each_online_cpu(i) {
 val = per_cpu(pcpa, i);
    pr_info(" cpu %2d: pcpa = %+d\n", i, val);
}

FYI,你可以始终使用smp_processor_id()宏来确定你当前运行在哪个 CPU 核心上;事实上,这正是我们的convenient.h:PRINT_CTX()宏的工作原理。

类似地,内核提供了用于处理需要每 CPU 的变量指针的例程,{get,put}_cpu_ptr()per_cpu_ptr()宏。当处理每 CPU 数据结构时,这些宏被广泛使用(而不仅仅是一个简单的整数);我们安全地检索到我们当前运行的 CPU 的结构的指针,并使用它(per_cpu_ptr())。

每 CPU - 一个示例内核模块

通过我们的示例每 CPU 演示内核模块的实际操作会有助于使用这一强大的功能(代码在这里:ch13/2_percpu)。在这里,我们定义并使用两个每 CPU 变量:

  • 一个静态分配和初始化的每 CPU 整数

  • 一个动态分配的每 CPU 数据结构

作为帮助演示每 CPU 变量的有趣方式,让我们这样做:我们将安排我们的演示内核模块产生一对内核线程。让我们称它们为thrd_0thrd_1。此外,一旦创建,我们将利用 CPU 掩码(和 API)使我们的thrd_0内核线程在 CPU 0上运行,我们的thrd_1内核线程在 CPU 1上运行(因此,它们将被调度在只这些核心上运行;当然,我们必须在至少有两个 CPU 核心的 VM 上测试这段代码)。

以下代码片段说明了我们如何定义和使用每 CPU 变量(我们省略了创建内核线程并设置它们的 CPU 亲和性掩码的代码,因为它们与本章的覆盖范围无关;然而,浏览完整的代码并尝试它是非常重要的!):

// ch13/2_percpu/percpu_var.c
[ ... ]
/*--- The per-cpu variables, an integer 'pcpa' and a data structure --- */
/* This per-cpu integer 'pcpa' is statically allocated and initialized to 0 */
DEFINE_PER_CPU(int, pcpa);

/* This per-cpu structure will be dynamically allocated via alloc_percpu() */
static struct drv_ctx {
    int tx, rx; /* here, as a demo, we just use these two members,
                   ignoring the rest */
    [ ... ]
} *pcp_ctx;
[ ... ]

static int __init init_percpu_var(void)
{
    [ ... ]
    /* Dynamically allocate the per-cpu structures */
    ret = -ENOMEM;
 pcp_ctx = (struct drv_ctx __percpu *) alloc_percpu(struct drv_ctx);
    if (!pcp_ctx) {
        [ ... ]
}

为什么不使用资源管理的devm_alloc_percpu()呢?是的,在适当的时候你应该使用;然而,在这里,因为我们没有一个方便的struct device *dev指针,这是devm_alloc_percpu()所需的第一个参数。

顺便说一句,我在编写这个内核模块时遇到了一个问题;为了设置 CPU 掩码(为每个内核线程更改 CPU 亲和性),内核 API 是sched_setaffinity()函数,但不幸的是,这个函数对我们来说是不可导出的,因此我们无法使用它。因此,我们执行了一个绝对被认为是黑客行为的操作:通过kallsyms_lookup_name()(当CONFIG_KALLSYMS被定义时起作用)获得不合作函数的地址,然后将其作为函数指针调用。它能工作,但绝对不是编码的正确方式。

我们的设计思想是创建两个内核线程,并让它们分别操作每个 CPU 的数据变量。如果这些是普通的全局变量,这肯定构成了一个临界区,当然我们需要一个锁;但在这里,正因为它们是每 CPU,并且我们保证我们的线程在*不同的核心上运行,我们可以同时使用不同的数据更新它们!我们的内核线程工作例程如下;它的参数是线程编号(01)。我们相应地分支并操作每 CPU 数据(我们的第一个内核线程将整数递增三次,而我们的第二个内核线程将其递减三次):

/* Our kernel thread worker routine */
static int thrd_work(void *arg)
{
    int i, val;
    long thrd = (long)arg;
    struct drv_ctx *ctx;
    [ ... ]

    /* Set CPU affinity mask to 'thrd', which is either 0 or 1 */
    if (set_cpuaffinity(thrd) < 0) {
        [ ... ]
    SHOW_CPU_CTX();

    if (thrd == 0) { /* our kthread #0 runs on CPU 0 */
        for (i=0; i<THRD0_ITERS; i++) {
            /* Operate on our perpcu integer */
 val = ++ get_cpu_var(pcpa);
            pr_info(" thrd_0/cpu0: pcpa = %+d\n", val);
            put_cpu_var(pcpa);

            /* Operate on our perpcu structure */
 ctx = get_cpu_ptr(pcp_ctx);
            ctx->tx += 100;
            pr_info(" thrd_0/cpu0: pcp ctx: tx = %5d, rx = %5d\n",
                ctx->tx, ctx->rx);
            put_cpu_ptr(pcp_ctx);
        }
    } else if (thrd == 1) { /* our kthread #1 runs on CPU 1 */
        for (i=0; i<THRD1_ITERS; i++) {
            /* Operate on our perpcu integer */
 val = -- get_cpu_var(pcpa);
            pr_info(" thrd_1/cpu1: pcpa = %+d\n", val);
           put_cpu_var(pcpa);

            /* Operate on our perpcu structure */
            ctx = get_cpu_ptr(pcp_ctx); ctx->rx += 200;
            pr_info(" thrd_1/cpu1: pcp ctx: tx = %5d, rx = %5d\n",
                ctx->tx, ctx->rx); put_cpu_ptr(pcp_ctx);        }}
    disp_vars();
    pr_info("Our kernel thread #%ld exiting now...\n", thrd);
    return 0;
}

在运行时的效果是有趣的;请参阅以下内核日志:

图 13.6 - 显示我们的 ch13/2_percpu/percpu_var LKM 运行时内核日志的屏幕截图

图 13.6的最后三行输出中,你可以看到 CPU 0和 CPU 1上我们的每 CPU 数据变量的值的摘要(我们通过我们的disp_vars()函数显示)。显然,对于每 CPU 的pcpa整数(以及pcp_ctx数据结构),值是不同的,正如预期的那样,没有显式的锁

刚刚演示的内核模块使用for_each_online_cpu(i)宏在每个在线 CPU 上显示我们的 per-CPU 变量的值。接下来,如果您的虚拟机有六个 CPU,但希望其中只有两个在运行时处于“活动”状态,该怎么办?有几种安排的方法;其中一种是在启动时将maxcpus=n参数传递给 VM 的内核 - 您可以通过查找/proc/cmdline来查看是否存在:

$ cat /proc/cmdline BOOT_IMAGE=/boot/vmlinuz-5.4.0-llkd-dbg root=UUID=1c4<...> ro console=ttyS0,115200n8 console=tty0  quiet splash 3 **maxcpus=2** 还要注意我们正在运行我们自定义的5.4.0-llkd-dbg调试内核。

内核中的 per-CPU 使用

在 Linux 内核中,per-CPU 变量被广泛使用;一个有趣的案例是在 x86 架构上实现current宏(我们在第六章中介绍了使用current宏,内核内部要点-进程和线程,在使用 current 访问任务结构部分)。事实上,current经常被查找(和设置);将其作为 per-CPU 可以确保我们保持其无锁访问!以下是实现它的代码:

// arch/x86/include/asm/current.h
[ ... ]
DECLARE_PER_CPU(struct task_struct *, current_task);
static __always_inline struct task_struct *get_current(void)
{
    return this_cpu_read_stable(current_task);
}
#define current get_current()

DECLARE_PER_CPU()宏声明名为current_task的变量为struct task_struct *类型的 per-CPU 变量。get_current()内联函数在这个 per-CPU 变量上调用this_cpu_read_stable()辅助函数,从而在当前运行的 CPU 核心上读取current的值(请阅读elixir.bootlin.com/linux/v5.4/source/arch/x86/include/asm/percpu.h#L383处的注释以了解这个例程的作用)。好的,这很好,但一个常见问题:这个current_task的 per-CPU 变量在哪里更新?想一想:内核必须在上下文切换到另一个任务时更改(更新)current

这确实是情况;它确实是在上下文切换代码(arch/x86/kernel/process_64.c:__switch_to();在elixir.bootlin.com/linux/v5.4/source/arch/x86/kernel/process_64.c#L504处)中更新的。

__visible __notrace_funcgraph struct task_struct *
__switch_to(struct task_struct *prev_p, struct task_struct *next_p)
{
    [ ... ]
 this_cpu_write(current_task, next_p);
    [ ... ]
}

接下来,通过__alloc_percpu()在内核代码库中展示 per-CPU 使用的快速实验:在内核源代码树的根目录中运行cscope -d(这假定您已经通过make cscope构建了cscope索引)。在cscope菜单中,在查找调用此函数的函数:提示下,键入__alloc_percpu。结果如下:

图 13.7 - 显示调用 __alloc_percpu() API 的内核代码的(部分)cscope -d 输出的屏幕截图

当然,这只是内核代码库中 per-CPU 使用的部分列表,仅跟踪通过__alloc_percpu()底层 API 的使用。搜索调用alloc_percpu[_gfp]()__alloc_percpu[_gfp]()的包装器)的函数会有更多命中。

通过这样,我们完成了关于内核同步技术和 API 的讨论,让我们通过学习一个关键领域来结束本章:在内核代码中调试锁定问题时的工具和技巧!

内核中的锁调试

内核有几种方法可以帮助调试与内核级锁定问题有关的困难情况,死锁是其中一个主要问题。

以防您还没有,确保您首先从上一章(第十二章)的基本同步、锁定和死锁指南中阅读了基础知识,特别是独占执行和原子性Linux 内核中的并发问题部分。

在任何调试场景中,都存在不同的调试发生的时间点,因此可能需要使用不同的工具和技术。广义上说,bug 可能会在软件开发生命周期(SDLC)中的不同时间点被注意到和调试(真的):

  • 开发过程中

  • 开发之后但发布之前(测试,质量保证(QA)等)

  • 内部发布后

  • 发布后,在现场

一个众所周知且不幸的真理:bug 距离开发越远,修复的成本就越高!因此,您确实希望尽早找到并修复它们!

由于本书专注于内核开发,因此我们将在这里专注于一些工具和技术,用于在开发时调试锁定问题。

重要提示:我们期望到目前为止,您正在运行一个调试内核,即一个专门为开发/调试目的而配置的内核。性能会受到影响,但没关系 - 我们现在是在捕虫!我们在第五章中介绍了典型调试内核的配置,即编写您的第一个内核模块 - LKMs 第二部分,在配置调试内核部分,甚至提供了一个用于调试的示例内核配置文件:ch5/kconfigs/sample_kconfig_llkd_dbg.config。实际上,下面还涵盖了为锁调试配置调试内核的具体内容。

为锁调试配置调试内核

由于锁调试的相关性和重要性,我们将快速浏览一下Linux 内核补丁提交清单文档(www.kernel.org/doc/html/v5.4/process/submit-checklist.html),该文档与我们在这里讨论的内容最相关,即启用调试内核(特别是用于锁调试):

// https://www.kernel.org/doc/html/v5.4/process/submit-checklist.html
[...]
12\. Has been tested with CONFIG_PREEMPT, CONFIG_DEBUG_PREEMPT, CONFIG_DEBUG_SLAB, CONFIG_DEBUG_PAGEALLOC, CONFIG_DEBUG_MUTEXES, CONFIG_DEBUG_SPINLOCK, CONFIG_DEBUG_ATOMIC_SLEEP, CONFIG_PROVE_RCU and CONFIG_DEBUG_OBJECTS_RCU_HEAD all simultaneously enabled. 
13\. Has been build- and runtime tested with and without CONFIG_SMP and CONFIG_PREEMPT.

16\. All codepaths have been exercised with all lockdep features enabled.
[ ... ]

尽管本书未涉及,但我不能不提到一个非常强大的动态内存错误检测器,称为内核地址 SANitizerKASAN)。简而言之,它使用基于编译时的仪器化动态分析来捕获常见的与内存相关的错误(它适用于 GCC 和 Clang)。ASan地址检测器)由 Google 工程师贡献,用于监视和检测用户空间应用程序中的内存问题(在Linux 的系统编程实践书中有详细介绍,并与 valgrind 进行了比较)。内核等效物 KASAN 自 4.0 内核以来已经适用于 x86_64 和 AArch64(从 4.4 Linux 开始)。有关详细信息(如启用和使用它),可以在内核文档中找到(www.kernel.org/doc/html/v5.4/dev-tools/kasan.html#the-kernel-address-sanitizer-kasan);我强烈建议您在调试内核中启用它。

正如我们在第二章中看到的,从源代码构建 5.x Linux 内核 - 第一部分,我们可以根据我们的要求配置我们的 Linux 内核。在这里(在 5.4.0 内核源代码树的根目录中),我们执行make menuconfig并导航到内核调试/锁调试(自旋锁,互斥锁等...)菜单(见我们的 x86_64 Ubuntu 20.04 LTS 客户 VM 上的图 13.8):

图 13.8 - 截断的内核调试/锁调试(自旋锁,互斥锁等...)菜单的屏幕截图,显示了我们调试内核所需的项目已启用

图 13.8是一个(截断的)屏幕截图,显示了我们调试内核所需的<内核调试>锁调试(自旋锁,互斥锁等...)菜单中的项目已启用。

与交互式地逐个浏览每个菜单项并选择<帮助>按钮以查看其内容不同,获得相同的帮助信息的一个更简单的方法是窥视相关的 Kconfig 文件(描述菜单)。在这里,它是lib/Kconfig.debug,因为所有与调试相关的菜单都在那里。对于我们的特定情况,搜索menu "锁调试(自旋锁,互斥锁等...)"字符串,其中锁调试部分开始(见下表)。

以下表总结了每个内核锁调试配置选项帮助调试的内容(我们没有显示所有选项,对于其中一些选项,直接引用了lib/Kconfig.debug文件中的内容):

锁调试菜单标题 它的作用
锁调试:证明锁正确性(CONFIG_PROVE_LOCKING 这是lockdep内核选项-打开它以获得持续的锁正确性证明。任何与锁相关的死锁甚至在实际发生之前就会报告;非常有用!(稍后详细解释。)
锁使用统计(CONFIG_LOCK_STAT 跟踪锁争用点(稍后详细解释)。
RT 互斥锁调试,死锁检测(CONFIG_DEBUG_RT_MUTEXES 这允许自动检测和报告 rt 互斥锁语义违规和 rt 互斥锁相关的死锁(锁死)。”
自旋锁和rw-lock调试:基本检查(CONFIG_DEBUG_SPINLOCK 打开此选项(以及CONFIG_SMP)有助于捕捉缺少自旋锁初始化和其他常见自旋锁错误。
互斥锁调试:基本检查(CONFIG_DEBUG_MUTEXES 此功能允许检测和报告互斥锁语义违规。”
RW 信号量调试:基本检查(CONFIG_DEBUG_RWSEMS 允许检测和报告不匹配的 RW 信号量锁定和解锁。
锁调试:检测错误释放的活锁(CONFIG_DEBUG_LOCK_ALLOC 此功能将检查内核是否通过任何内存释放例程(kfree(),kmem_cache_free(),free_pages(),vfree()等)错误释放任何持有的锁(自旋锁,rwlock,互斥锁或 rwsem),是否通过spin_lock_init()/mutex_init()等错误重新初始化任何活锁,或者是否在任务退出期间持有任何锁。”
原子段内睡眠检查(CONFIG_DEBUG_ATOMIC_SLEEP 如果在这里选择 Y,各种可能会睡眠的例程在自旋锁被持有时,rcu 读取侧临界段内,禁用抢占段内,中断内等情况下会变得非常嘈杂...
锁 API 启动时自测试(CONFIG_DEBUG_LOCKING_API_SELFTESTS 如果您希望内核在启动时运行简短的自测试,请在此处选择 Y。自测试检查调试机制是否检测到常见类型的锁错误。(如果禁用锁调试,则当然不会检测到这些错误。)以下锁 API 已覆盖:自旋锁,读写锁,互斥锁和 rwsem。”
锁的折磨测试(CONFIG_LOCK_TORTURE_TEST 此选项提供一个内核模块,对内核锁原语进行折磨测试。如果需要,可以在运行的内核上构建内核模块进行测试。”(可以内联构建为'Y',也可以作为模块外部构建为'M')。”

表 17.4-典型的内核锁调试配置选项及其含义

如先前建议的,在开发和测试过程中使用调试内核时打开所有或大多数这些锁调试选项是一个好主意。当然,预期的是这样做可能会显著减慢执行速度(并使用更多内存);就像生活中一样,这是一个你必须决定的权衡:你在检测常见的锁问题、错误和死锁方面取得了进展,但代价是速度。这是一个你应该更愿意做出的权衡,特别是在开发(或重构)代码时。

锁验证器 lockdep - 及早捕捉锁定问题

Linux 内核具有一个非常有用的功能,可以被内核开发人员利用:运行时锁正确性或锁依赖验证器;简而言之,lockdep。基本思想是:每当内核中发生任何锁定活动时,即任何内核级别锁的获取或释放,或涉及多个锁的任何锁定序列时,lockdep 运行时就会发挥作用。

这是被跟踪或映射的(有关性能影响及其如何被缓解的更多信息,请参见下一段)。通过应用正确锁定的众所周知的规则(您在前一章的锁定指南和死锁部分中已经得到了一些提示),lockdep 然后对所做的正确性的有效性做出结论。

其美妙之处在于 lockdep 实现了 100%的数学证明(或闭合),即锁序列是正确的还是不正确的。以下是内核文档对该主题的直接引用(www.kernel.org/doc/html/v5.4/locking/lockdep-design.html):

“验证器在数学上实现了完美的‘闭合’(锁定正确性的证明),因为对于内核生命周期中至少发生一次的每个简单的独立单任务锁定序列,验证器都以 100%的确定性证明了这些锁定序列的任何组合和时序都不会导致任何类别的锁相关死锁。”

此外,lockdep 通过发出WARN*()宏来警告您任何违反以下锁定错误类别的情况:死锁/锁反转场景、循环锁依赖关系以及硬中断/软中断安全/不安全的锁定错误。这些信息是宝贵的;使用 lockdep 验证您的代码可以通过及早捕捉锁定问题来节省数百个被浪费的生产时间。 (顺便说一句,lockdep 跟踪所有锁及其锁定序列或“锁链”;这些可以通过/proc/lockdep_chains查看。)

关于性能缓解的一点说明:您可能会想象,随着成千上万甚至更多的锁实例在周围漂浮,验证每个单个锁序列将会非常慢(实际上,事实证明这是一个 O(N²)算法时间复杂度的任务!)。这根本行不通;因此,lockdep 通过验证任何锁定场景(比如,在某个代码路径上,先获取锁 A,然后获取锁 B - 这被称为锁序列锁链仅一次,即第一次发生时。 (它通过维护每个锁链的 64 位哈希来知道这一点。)

原始用户空间方法:一种非常原始的尝试检测死锁的方法是通过用户空间,只需使用 GNU ps(1);执行ps -LA -o state,pid,cmd | grep "^D"会打印出处于D(不可中断睡眠(TASK_UNINTERRUPTIBLE))状态的任何线程。这可能是由于死锁,但也可能不是;如果它持续了很长时间,那么它很可能是死锁。试一试!当然,lockdep 是一个更优越的解决方案。(请注意,这仅适用于 GNU ps,而不适用于轻量级的 ps,如busybox ps。)

其他有用的用户空间工具是strace(1)ltrace(1) - 它们分别提供了由进程(或线程)发出的每个系统和库调用的详细跟踪;您可能能够捕捉到挂起的进程/线程并查看它被卡在哪里(使用strace -p <PID>对挂起的进程可能特别有用)。

您需要明确的另一点是:lockdep发出关于(数学上)不正确的锁定的警告即使在运行时实际上没有发生死锁lockdep提供了证据表明确实存在可能在将来某个时刻导致错误(死锁、不安全的锁定等)的问题;它通常是完全正确的;认真对待并修复问题。(再说一遍,通常情况下,软件世界中没有任何东西是 100%正确的 100%的时间:如果 bug 潜入了lockdep代码本身怎么办?甚至有一个CONFIG_DEBUG_LOCKDEP配置选项。最重要的是,我们作为人类开发人员必须仔细评估情况,检查错误的积极性。)

接下来,lockdep锁类上工作;这只是一个“逻辑”锁,而不是该锁的“物理”实例。例如,内核的打开文件数据结构struct file有两个锁-互斥锁和自旋锁-lockdep将每个锁视为一个锁类。即使在运行时内存中存在数千个struct file实例,lockdep也只会跟踪它作为一个类。有关lockdep内部设计的更多详细信息,我们建议您参考官方内核文档(www.kernel.org/doc/html/v5.4/locking/lockdep-design.html)。

示例-使用 lockdep 捕获死锁 bug

在这里,我们假设您现在已经构建并正在运行一个启用了lockdep的调试内核(如在“为锁调试配置调试内核”部分中详细描述)。验证它确实已启用:

$ uname -r
5.4.0-llkd-dbg
$ grep PROVE_LOCKING /boot/config-5.4.0-llkd-dbg
CONFIG_PROVE_LOCKING=y
$

好的!现在,让我们亲自体验一些死锁,看看lockdep将如何帮助您捕获它们。继续阅读!

示例 1-使用 lockdep 捕获自死锁 bug

作为第一个示例,让我们回到我们的一个内核模块第六章中的内核内部要点-进程和线程,在遍历任务列表部分,这里:ch6/foreach/thrd_showall/thrd_showall.c。在这里,我们循环遍历每个线程,从其任务结构中打印一些细节;关于这一点,这里有一个代码片段,我们在其中获取线程的名称(回想一下,它在任务结构的一个成员中称为comm):

// ch6/foreach/thrd_showall/thrd_showall.c
static int showthrds(void)
{
    struct task_struct *g = NULL, *t = NULL; /* 'g' : process ptr; 't': thread ptr */
    [ ... ]
    do_each_thread(g, t) { /* 'g' : process ptr; 't': thread ptr */
        task_lock(t);
        [ ... ]
        if (!g->mm) {    // kernel thread
            snprintf(tmp, TMPMAX-1, " [%16s]", t->comm);
        } else {
            snprintf(tmp, TMPMAX-1, " %16s ", t->comm);
        }
        snprintf(buf, BUFMAX-1, "%s%s", buf, tmp);
        [ ... ]

这个方法有效,但似乎有更好的方法:而不是直接使用t->comm查找线程的名称(就像我们在这里做的那样),内核提供了{get,set}_task_comm()辅助例程来获取和设置任务的名称。因此,我们重写代码以使用get_task_comm()辅助宏;它的第一个参数是放置名称的缓冲区(预期您已为其分配了内存),第二个参数是要查询其名称的线程的任务结构的指针(以下代码片段来自这里:ch13/3_lockdep/buggy_thrdshow_eg/thrd_showall_buggy.c):

// ch13/3_lockdep/buggy_lockdep/thrd_showall_buggy.c
static int showthrds_buggy(void)
{
    struct task_struct *g, *t; /* 'g' : process ptr; 't': thread ptr */
    [ ... ]
    char buf[BUFMAX], tmp[TMPMAX], tasknm[TASK_COMM_LEN];
    [ ... ]
    do_each_thread(g, t) { /* 'g' : process ptr; 't': thread ptr */
        task_lock(t);
        [ ... ]
        get_task_comm(tasknm, t);
        if (!g->mm) // kernel thread
            snprintf(tmp, sizeof(tasknm)+3, " [%16s]", tasknm);
        else
            snprintf(tmp, sizeof(tasknm)+3, " %16s ", tasknm);
        [ ... ]

当编译并插入到我们的测试系统(一个 VM,谢天谢地)的内核中时,它可能会变得奇怪,甚至只是简单地挂起!(当我这样做时,我能够在系统完全无响应之前通过dmesg(1)检索内核日志。)

如果你的系统在插入这个 LKM 时就挂起了怎么办?嗯,这就是内核调试的困难所在!你可以尝试的一件事(在我在 x86_64 Fedora 29 VM 上尝试这个例子时对我有效)是重新启动挂起的 VM,并使用journalctl --since="1 hour ago"命令利用 systemd 强大的journalctl(1)工具查看内核日志;你应该能够看到lockdep的 printks。不幸的是,不能保证内核日志的关键部分会被保存到磁盘(在挂起时)以便journalctl能够检索。这就是为什么使用内核的kdump功能 - 然后使用crash(8)对内核转储映像文件进行事后分析 - 可以成为救命稻草的原因(参见本章的进一步阅读部分中有关使用kdumpcrash的资源)。

看一下内核日志,很明显:lockdep捕获到了(自身)死锁(我们在截图中展示了输出的相关部分):

图 13.9 - (部分)截图显示我们有 bug 的模块加载后的内核日志;lockdep 捕获到了自身死锁!

虽然后面还有更多的细节(包括insmod(8)的内核栈的堆栈回溯 - 因为它是进程上下文,这种情况下的寄存器值等),但我们在前面的图中看到的足以推断出发生了什么。显然,lockdep告诉我们insmod/2367 正在尝试获取锁:,然后是但任务已经持有锁:。接下来(仔细看图 13.9),insmod持有的锁是(p->alloc_lock)(暂时忽略后面的内容;我们马上会解释),实际尝试获取它的例程(在at:后面显示)是__get_task_comm+0x28/0x50。现在我们有了进展:让我们弄清楚我们调用get_task_comm()时到底发生了什么;我们发现它是一个宏,是实际工作例程__get_task_comm()的包装器。它的代码如下:

// fs/exec.c
char *__get_task_comm(char *buf, size_t buf_size, struct task_struct *tsk)
{
    task_lock(tsk);
    strncpy(buf, tsk->comm, buf_size);
    task_unlock(tsk);
    return buf; 
}
EXPORT_SYMBOL_GPL(__get_task_comm);

哦,问题就在这里:__get_task_comm()函数试图重新获取我们已经持有的同一个锁,导致(自身)死锁!我们在哪里获取它的?回想一下,在我们(有 bug 的)内核模块进入循环后的第一行代码是我们调用task_lock(t),然后几行代码后,我们调用get_task_comm(),它在内部试图重新获取同一个锁:结果就是自身死锁

do_each_thread(g, t) {   /* 'g' : process ptr; 't': thread ptr */
    task_lock(t);
    [ ... ]
    get_task_comm(tasknm, t);

此外,找到这个特定锁是很容易的;查找task_lock()例程的代码:

// include/linux/sched/task.h */
static inline void task_lock(struct task_struct *p)
{
    spin_lock(&p->alloc_lock);
}

所以,现在一切都说得通了;这是task结构中名为alloc_lock的自旋锁,就像lockdep告诉我们的那样。

lockdep的报告中有一些令人困惑的标记。看下面的这些行:

[ 1021.449384] insmod/2367 is trying to acquire lock:
[ 1021.451361] ffff88805de73f08 (&(&p->alloc_lock)->rlock){+.+.}, at: __get_task_comm+0x28/0x50
[ 1021.453676]
               but task is already holding lock:
[ 1021.457365] ffff88805de73f08 (&(&p->alloc_lock)->rlock){+.+.}, at: showthrds_buggy+0x13e/0x6d1 [thrd_showall_buggy]

忽略时间戳,前面代码块中第二行最左边的数字是用于标识特定锁序列的 64 位轻量级哈希值。请注意,它与下一行中的哈希值完全相同;因此,我们知道它是同一个锁!{+.+.}是 lockdep 对此锁获取状态的表示(含义:+表示在启用 IRQ 的情况下获取锁,.表示在禁用 IRQ 且不在 IRQ 上下文中获取锁,等等)。这些在内核文档中有解释(www.kernel.org/doc/Documentation/locking/lockdep-design.txt);我们就到此为止。

Steve Rostedt 在 2011 年的 Linux Plumber's Conference 上做了一次关于解释lockdep输出的详细演示;相关幻灯片很有启发性,探讨了简单和复杂的死锁场景以及lockdep如何检测它们:

Lockdep: How to read its cryptic output (blog.linuxplumbersconf.org/2011/ocw/sessions/153).

修复它

既然我们理解了这个问题,我们该如何解决呢?看到 lockdep 的报告(图 13.9)并解释它,很简单:(如前所述)由于任务结构自旋锁alloc_lockdo-while循环开始时已经被获取(通过task_lock(t)),确保在调用get_task_comm()例程之前(它在内部获取并释放相同的锁)解锁它,然后执行get_task_comm(),然后再次锁定它。

下面的屏幕截图(图 13.10)显示了旧版有 bug 的代码(ch13/3_lockdep/buggy_thrdshow_eg/thrd_showall_buggy.c)和我们代码的新修复版本(ch13/3_lockdep/fixed_lockdep/thrd_showall_fixed.c)之间的差异(通过diff(1)实用程序):

图 13.10 - (部分)屏幕截图显示了我们演示 thrdshow LKM 的有 bug 和修复版本之间的关键部分

太好了,下面是另一个例子 - 捕获 AB-BA 死锁!

例 2 - 使用 lockdep 捕获 AB-BA 死锁

作为另一个例子,让我们看看一个(演示)内核模块,它故意创建了一个循环依赖,最终会导致死锁。代码在这里:ch13/3_lockdep/deadlock_eg_AB-BA。我们基于我们之前的一个模块(ch13/2_percpu)创建了这个模块;正如您可能记得的那样,我们创建了两个内核线程,并确保(通过使用一个被篡改的sched_setaffinity())每个内核线程在唯一的 CPU 核心上运行(第一个内核线程在 CPU 核心0上运行,第二个在核心1上运行)。

这样,我们就有了并发性。现在,在线程内部,我们让它们使用两个自旋锁lockAlockB。了解到我们有一个具有两个或更多锁的进程上下文,我们记录并遵循锁定顺序规则:首先获取 lockA,然后获取 lockB。太好了,所以,一种不应该这样做的方式是:

kthread 0 on CPU #0                kthread 1 on CPU #1
  Take lockA                           Take lockB
     <perform work>                       <perform work>
                                          (Try and) take lockA
                                          < ... spins forever :
                                                DEADLOCK ... >
(Try and) take lockB
< ... spins forever : 
      DEADLOCK ... >

当然,这是经典的 AB-BA 死锁!因为程序(实际上是内核线程 1)忽略了锁定顺序规则(当lock_ooo模块参数设置为1时),它发生了死锁。这里是相关的代码(我们没有在这里展示整个程序;请克隆本书的 GitHub 存储库github.com/PacktPublishing/Linux-Kernel-Programming并自行尝试):

// ch13/3_lockdep/deadlock_eg_AB-BA/deadlock_eg_AB-BA.c
[ ... ]
/* Our kernel thread worker routine */
static int thrd_work(void *arg)
{
    [ ... ]
   if (thrd == 0) { /* our kthread #0 runs on CPU 0 */
        pr_info(" Thread #%ld: locking: we do:"
            " lockA --> lockB\n", thrd);
        for (i = 0; i < THRD0_ITERS; i ++) {
            /* In this thread, perform the locking per the lock ordering 'rule';
 * first take lockA, then lockB */
            pr_info(" iteration #%d on cpu #%ld\n", i, thrd);
            spin_lock(&lockA);
            DELAY_LOOP('A', 3); 
            spin_lock(&lockB);
            DELAY_LOOP('B', 2); 
            spin_unlock(&lockB);
            spin_unlock(&lockA);
        }

我们的内核线程0正确执行了,遵循了锁定顺序规则;与之前的代码相关的我们的内核线程1的代码如下:

   [ ... ]
   } else if (thrd == 1) { /* our kthread #1 runs on CPU 1 */
        for (i = 0; i < THRD1_ITERS; i ++) {
            /* In this thread, if the parameter lock_ooo is 1, *violate* the
 * lock ordering 'rule'; first (attempt to) take lockB, then lockA */
            pr_info(" iteration #%d on cpu #%ld\n", i, thrd);
            if (lock_ooo == 1) {        // violate the rule, naughty boy!
                pr_info(" Thread #%ld: locking: we do: lockB --> lockA\n",thrd);
                spin_lock(&lockB);
                DELAY_LOOP('B', 2);
                spin_lock(&lockA);
                DELAY_LOOP('A', 3);
                spin_unlock(&lockA);
                spin_unlock(&lockB);
            } else if (lock_ooo == 0) { // follow the rule, good boy!
                pr_info(" Thread #%ld: locking: we do: lockA --> lockB\n",thrd);
                spin_lock(&lockA);
                DELAY_LOOP('B', 2);
                spin_lock(&lockB);
                DELAY_LOOP('A', 3);
                spin_unlock(&lockB);
                spin_unlock(&lockA);
            }
    [ ... ]

使用lock_ooo内核模块参数设置为0(默认值)构建并运行它;我们发现,遵守锁定顺序规则,一切正常:

$ sudo insmod ./deadlock_eg_AB-BA.ko
$ dmesg
[10234.023746] deadlock_eg_AB-BA: inserted (param: lock_ooo=0)
[10234.026753] thrd_work():115: *** thread PID 6666 on cpu 0 now ***
[10234.028299] Thread #0: locking: we do: lockA --> lockB
[10234.029606] iteration #0 on cpu #0
[10234.030765] A
[10234.030766] A
[10234.030847] thrd_work():115: *** thread PID 6667 on cpu 1 now ***
[10234.031861] A
[10234.031916] B
[10234.032850] iteration #0 on cpu #1
[10234.032853] Thread #1: locking: we do: lockA --> lockB
[10234.038831] B
[10234.038836] Our kernel thread #0 exiting now...
[10234.038869] B
[10234.038870] B
[10234.042347] A
[10234.043363] A
[10234.044490] A
[10234.045551] Our kernel thread #1 exiting now...
$ 

现在,我们将其运行,将lock_ooo内核模块参数设置为1,并发现,正如预期的那样,系统被锁定!我们违反了锁定顺序规则,因此系统陷入了死锁!这次,重新启动 VM 并执行journalctl --since="10 min ago"得到了 lockdep 的报告:

======================================================
WARNING: possible circular locking dependency detected
5.4.0-llkd-dbg #2 Tainted: G OE
------------------------------------------------------
thrd_0/0/6734 is trying to acquire lock:
ffffffffc0fb2518 (lockB){+.+.}, at: thrd_work.cold+0x188/0x24c [deadlock_eg_AB_BA]

but task is already holding lock:
ffffffffc0fb2598 (lockA){+.+.}, at: thrd_work.cold+0x149/0x24c [deadlock_eg_AB_BA]

which lock already depends on the new lock.
[ ... ]
other info that might help us debug this:

 Possible unsafe locking scenario:

       CPU0                    CPU1
       ----                    ----
  lock(lockA);
                               lock(lockB);
                               lock(lockA);
  lock(lockB);

 *** DEADLOCK ***

[ ... lots more output follows ... ]

lockdep报告非常惊人。在句子可能存在不安全的锁定场景:之后的行中,它几乎精确地显示了运行时发生的情况 - CPU1 : lock(lockB); --> lock(lockA);out-of-orderooo)锁定序列!由于lockA已经被 CPU 0 上的内核线程获取,CPU 1 上的内核线程永远旋转 - 这是 AB-BA 死锁的根本原因。

此外,非常有趣的是,模块插入后不久(lock_ooo设置为1),内核还检测到了软锁定 bug。 printk 被定向到我们的控制台,日志级别为KERN_EMERG,这使我们能够看到这一点,尽管系统似乎已经挂起。它甚至显示了问题的起源地的相关内核线程(同样,这个输出是在我的 x86_64 Ubuntu 20.04 LTS VM 上运行自定义 5.4.0 调试内核):

Message from syslogd@seawolf-VirtualBox at Dec 24 11:01:51 ...
kernel:[10939.279524] watchdog: BUG: soft lockup - CPU#0 stuck for 22s! [thrd_0/0:6734]
Message from syslogd@seawolf-VirtualBox at Dec 24 11:01:51 ...
kernel:[10939.287525] watchdog: BUG: soft lockup - CPU#1 stuck for 23s! [thrd_1/1:6735]

(顺便说一句,检测到这一点并喷出前面的消息的代码在这里:kernel/watchdog.c:watchdog_timer_fn())。

另一个注意事项:/proc/lockdep_chains输出还“证明”了采用了(或存在)不正确的锁定顺序:

$ sudo cat /proc/lockdep_chains
[ ... ]
irq_context: 0
[000000005c6094ba] lockA
[000000009746aa1e] lockB
[ ... ]
irq_context: 0
[000000009746aa1e] lockB
[000000005c6094ba] lockA

还要记住,lockdep只报告一次 - 第一次 - 违反任何内核锁定规则。

锁依赖 - 注释和问题

让我们用一些关于强大的lockdep基础设施的要点来总结一下。

锁依赖注释

在用户空间,您可能熟悉使用非常有用的assert()宏。在那里,您断言一个布尔表达式,一个条件(例如,assert(p == 5);)。如果断言在运行时为真,则什么也不会发生,执行会继续;当断言为假时,进程将被中止,并且一个嘈杂的printf()会指示哪个断言以及它失败的位置。这使开发人员能够检查他们期望的运行时条件。因此,断言可能非常有价值 - 它们有助于捕获错误!

类似地,lockdep允许内核开发人员通过lockdep_assert_held()宏在特定点断言锁已持有。这称为锁依赖注释。宏定义如下:

// include/linux/lockdep.h
#define lockdep_assert_held(l) do { \
        WARN_ON(debug_locks && !lockdep_is_held(l)); \
    } while (0)

断言失败会导致警告(通过WARN_ON())。这非常有价值,因为它意味着尽管应该现在持有锁l,但实际上并没有。还要注意,这些断言只在启用锁调试时才起作用(这是内核中启用锁调试时的默认设置;只有在lockdep或其他内核锁定基础设施发生错误时才会关闭)。事实上,内核代码库在核心和驱动程序代码中都广泛使用lockdep注释。(还有一些形式的lockdep断言的变体,如lockdep_assert_held*()以及很少使用的lockdep_*pin_lock()宏。)

锁依赖问题

在使用lockdep时可能会出现一些问题:

  • 重复加载和卸载模块可能导致lockdep的内部锁类限制超出(原因在内核文档中解释,即加载x.ko内核模块会为其所有锁创建一组新的锁类,而卸载x.ko不会删除它们;实际上会被重用)。实际上,要么不要重复加载/卸载模块,要么重置系统。

  • 特别是在数据结构有大量锁的情况下(比如结构数组),未能正确初始化每个锁可能导致lockdep锁类溢出。

当锁调试被禁用时,debug_locks整数设置为0(即使在调试内核上也是如此);这可能导致出现此消息:*WARNING* lock debugging disabled!! - possibly due to a lockdep warning。这甚至可能是由于lockdep之前发出警告而发生的。重新启动系统并重试。

尽管本书基于 5.4 LTS 内核,但在撰写时最近合并到了 5.8 内核中一个强大的功能:内核并发性检查器KCSAN)。这是 Linux 内核的数据竞争检测器,通过编译时插装工作。您可以在这些 LWN 文章中找到更多详细信息:使用 KCSAN 查找竞争条件,LWN,2019 年 10 月(lwn.net/Articles/802128/)和并发错误应该害怕大坏数据竞争检测器(第一部分),LWN,2020 年 4 月(lwn.net/Articles/816850/)。

另外,值得一提的是,存在一些工具用于捕获用户空间应用程序中的锁定错误和死锁。其中包括著名的helgrind(来自 Valgrind 套件)、TSanThread Sanitizer),它提供了编译时的仪器来检查多线程应用程序中的数据竞争,以及 lockdep 本身;lockdep 也可以在用户空间中使用(作为库)!此外,现代的[e]BPF 框架提供了deadlock-bpfcc(8)前端。它专门设计用于在给定的运行进程(或线程)中查找潜在的死锁(锁顺序倒置)。

锁统计

锁可能会争用,这是当一个上下文想要获取锁,但它已经被占用,因此必须等待解锁发生。严重的争用可能会导致严重的性能瓶颈;内核提供了锁统计,以便轻松识别严重争用的锁。通过打开CONFIG_LOCK_STAT内核配置选项来启用锁统计(如果没有打开,/proc/lock_stat条目将不存在,这是大多数发行版内核的典型情况)。

锁统计代码利用了lockdep在锁定代码路径(__contended__acquired__released钩子)中插入钩子来在这些关键点收集统计信息。内核关于锁统计的文档(www.kernel.org/doc/html/latest/locking/lockstat.html#lock-statistics)清楚地传达了这些信息(以及更多信息),并提供了一个有用的状态图;请查阅。

查看锁统计

查看锁统计的一些快速提示和基本命令如下(当然,这假设CONFIG_LOCK_STAT已打开):

做什么? 命令
清除锁统计 sudo sh -c "echo 0 > /proc/lock_stat"
启用锁统计 sudo sh -c "echo 1 > /proc/sys/kernel/lock_stat"
禁用锁统计 sudo sh -c "echo 0 > /proc/sys/kernel/lock_stat"

接下来,一个简单的演示来查看锁定统计:我们编写一个非常简单的 Bash 脚本ch13/3_lockdep/lock_stats_demo.sh(在本书的 GitHub 存储库中查看其代码)。它清除并启用锁定统计,然后简单地运行cat /proc/self/cmdline命令。这实际上会触发内核深处的一系列代码运行(主要在fs/proc内);需要查找几个全局的可写数据结构。这将构成一个关键部分,因此将会获取锁。我们的脚本将禁用锁统计,然后使用 grep 查看锁定统计以查看一些锁,过滤掉其余的部分:

egrep "alloc_lock|task|mm" /proc/lock_stat                                                                        

运行后,我们得到的输出如下(同样,在我们的 x86_64 Ubuntu 20.04 LTS VM 上运行我们的自定义 5.4.0 调试内核):

图 13.11 - 屏幕截图显示我们的 lock_stats_demo.sh 脚本运行,显示一些锁统计

图 13.11 中的输出在水平方向上相当长,因此会换行。显示的时间单位是微秒。class name字段是锁类;我们可以看到与任务和内存结构(task_structmm_struct)相关的几个锁!我们不会重复材料,而是建议您查阅内核关于锁统计的文档,该文档解释了前面的每个字段(con-bounceswaittime*等等;提示:concontended的缩写)以及如何解释输出。正如预期的那样,在图 13.11中,我们可以看到在这个简单的情况下:

  • 第一个字段class_name是锁类;在这里可以看到锁的(符号)名称。

  • 对于锁没有真正的争用(字段 2 和 3)。

  • 等待时间(waittime*,字段 3 到 6)为 0。

  • acquisitions字段(#9)是锁被获取(占用)的总次数;它是正数(对于mm_struct信号量&mm->mmap_sem*甚至超过 300 次)。

  • 最后四个字段,10 到 13,是累积锁持有时间统计(holdtime-{min|max|total|avg})。同样,在这里,您可以看到 mm_struct mmap_sem*锁的平均持有时间最长。

  • (注意任务结构的自旋锁名为alloc_lock也被占用;我们在示例 1 - 使用 lockdep 捕获自死锁错误部分遇到了它)。

系统上争用最激烈的锁可以通过sudo grep ":" /proc/lock_stat | head查找。当然,您应该意识到这是上次重置(清除)锁统计信息时的情况。

请注意,由于锁调试被禁用,锁统计信息可能会被禁用;例如,您可能会遇到这种情况:

$ sudo cat /proc/lock_stat
lock_stat version 0.4
*WARNING* lock debugging disabled!! - possibly due to a lockdep warning

这个警告可能需要您重新启动系统。

好了,您几乎完成了!让我们用一些简短的内容来结束本章,介绍一下内存屏障。

内存屏障 - 介绍

最后但同样重要的是,让我们简要讨论另一个问题 - 内存屏障。这是什么意思?有时,程序流程对人类程序员来说变得不可知,因为微处理器、内存控制器和编译器可能重新排序内存读取和写入。在大多数情况下,这些“技巧”保持良性并且被优化。但是有些情况 - 通常跨硬件边界,例如多核系统上的 CPU 核心、CPU 到外围设备,以及反之亦然的UniProcessorUP) - 在这些情况下,不应该发生重新排序;必须遵守原始和预期的内存加载和存储顺序。内存屏障(通常是嵌入在*mb*()宏中的机器级指令)是一种抑制这种重新排序的方法;这是一种强制 CPU/内存控制器和编译器按照期望的顺序排序指令/数据的方法。

可以通过使用以下宏将内存屏障放置到代码路径中:#include <asm/barrier.h>

  • rmb(): 将读(或加载)内存屏障插入指令流中

  • wmb(): 将写(或存储)内存屏障插入指令流中

  • mb(): 通用内存屏障;直接引用内核内存屏障文档(www.kernel.org/doc/Documentation/memory-barriers.txt)中的描述,"通用内存屏障保证在屏障之前指定的所有 LOAD 和 STORE 操作将在屏障之后指定的所有 LOAD 和 STORE 操作之前发生。"

内存屏障确保在前面的指令或数据访问执行之前,后续的指令将不会执行,从而保持顺序。在某些(罕见)情况下,DMA 可能是一个例子,驱动程序作者使用内存屏障。在使用 DMA 时,重要的是阅读内核文档(www.kernel.org/doc/Documentation/DMA-API-HOWTO.txt)。它提到了内存屏障的使用位置以及不使用它们的危险;请参阅接下来的示例以了解更多信息。

由于内存屏障的放置通常对我们许多人来说是一个相当令人困惑的事情,我们建议您参考处理器或外围设备的相关技术参考手册,以获取更多详细信息。例如,在树莓派上,SoC 是 Broadcom BCM2835 系列;参考其外围设备手册 - BCM2835 ARM 外围设备手册(www.raspberrypi.org/app/uploads/2012/02/BCM2835-ARM-Peripherals.pdf),1.3 节,用于正确内存排序的外围设备访问注意事项 - 有助于弄清何时以及何时不使用内存屏障。

在设备驱动程序中使用内存屏障的示例

例如,以 Realtek 8139“快速以太网”网络驱动程序为例。为了通过 DMA 传输网络数据包,必须首先设置 DMA(传输)描述符对象。对于这个特定的硬件(NIC 芯片),DMA 描述符对象定义如下:

//​ drivers/net/ethernet/realtek/8139cp.c
struct cp_desc {
    __le32 opts1;
    __le32 opts2;
    __le64 addr;
};

DMA 描述符对象,名为struct cp_desc,有三个“字”。每个都必须初始化。现在,为了确保 DMA 控制器正确解释描述符,通常至关重要的是要求 DMA 描述符的写入按照驱动程序作者的意图进行。为了保证这一点,使用了内存屏障。事实上,相关的内核文档——动态 DMA 映射指南www.kernel.org/doc/Documentation/DMA-API-HOWTO.txt)——告诉我们确保这确实是这种情况。因此,例如,在设置 DMA 描述符时,必须按照以下方式编码才能在所有平台上获得正确的行为:

desc->word0 = address;
wmb();
desc->word1 = DESC_VALID;

因此,查看 DMA 传输描述符在实践中是如何设置的(由 Realtek 8139 驱动程序代码如下):

// drivers/net/ethernet/realtek/8139cp.c
[ ... ]
static netdev_tx_t cp_start_xmit([...])
{
    [ ... ]
    len = skb->len;
    mapping = dma_map_single(&cp->pdev->dev, skb->data, len, PCI_DMA_TODEVICE);
    [ ... ]
    struct cp_desc *txd;
    [ ... ]
    txd->opts2 = opts2;
    txd->addr = cpu_to_le64(mapping);
    wmb();
    opts1 |= eor | len | FirstFrag | LastFrag;
    txd->opts1 = cpu_to_le32(opts1);
    wmb();
    [...]

根据芯片数据表的要求,驱动程序要求将txd->opts2txd->addr的字存储到内存中,然后存储txd->opts1的字。由于这些写入的顺序很重要,驱动程序使用了wmb()写内存屏障。(另外,FYI,RCU 当然是适当的内存屏障的使用者,以强制执行内存顺序。)

此外,对于单个变量使用READ_ONCE()WRITE_ONCE()绝对保证编译器和 CPU 将按照您的意思执行。它将根据需要排除编译器优化,使用必要的内存屏障,并在多个核上的多个线程同时访问所涉及的变量时保证缓存一致性。

有关详细信息,请参阅内核文档中关于内存屏障的部分(www.kernel.org/doc/Documentation/DMA-API-HOWTO.txt)。好消息是,大部分情况都是在幕后处理的;对于驱动程序作者来说,只有在执行操作,如设置 DMA 描述符或启动和结束 CPU 到外围设备(反之亦然)的通信时,才可能需要屏障。

最后一件事——一个(不幸的)常见问题:使用volatile关键字会不会神奇地消除并发问题?当然不会。volatile关键字只是告诉编译器禁用围绕该变量的常见优化(代码路径之外的东西也可能修改标记为volatile的变量),仅此而已。这在处理 MMIO 时经常是必需的和有用的。关于内存屏障,有趣的是,编译器不会重新排序标记为volatile的变量的读取或写入,与其他volatile变量相比。然而,原子性是一个单独的构造,通过使用volatile关键字来保证。

总结

哇,你知道吗!?恭喜你,你做到了,你完成了这本书!

在本章中,我们继续了上一章的内容,继续学习有关内核同步的知识。在这里,您学会了如何通过atomic_t和更新refcount_t接口更有效地和安全地对整数进行锁定。在其中,您学会了如何在驱动程序作者常见的活动中,即更新设备的寄存器时,可以原子地和安全地使用典型的 RMW 序列。然后介绍了读-写自旋锁,这是有趣且有用的,尽管有一些注意事项。您将看到,由于不幸的缓存副作用,很容易错误地产生不利的性能问题,包括查看伪共享问题以及如何避免它。

开发者的福音—— 无锁算法和编程技术——然后详细介绍了 Linux 内核中的每 CPU 变量。学会如何谨慎地使用这些技术非常重要(尤其是更高级的形式,如 RCU)。最后,您将了解内存屏障是什么,它们通常在哪里使用。

您在 Linux 内核(以及相关领域,如设备驱动程序)中的长期工作之旅现在已经认真开始了。但请注意,如果没有持续的动手实践和实际操作这些材料,收获很快就会消失……我敦促您与这些主题和其他主题保持联系。随着您的知识和经验的增长,为 Linux 内核(或任何开源项目)做出贡献是一项高尚的努力,您会受益匪浅。

问题

随着我们的结束,这里有一些问题供您测试对本章材料的了解:github.com/PacktPublishing/Linux-Kernel-Programming/tree/master/questions。您会发现一些问题的答案在书的 GitHub 存储库中:github.com/PacktPublishing/Linux-Kernel-Programming/tree/master/solutions_to_assgn

进一步阅读

为了帮助您深入学习这个主题,我们在本书的 GitHub 存储库中提供了一个相当详细的在线参考和链接列表(有时甚至包括书籍)。进一步阅读文档在这里可用:github.com/PacktPublishing/Linux-Kernel-Programming/raw/master/Further_Reading.md

posted @ 2024-05-16 19:09  绝不原创的飞龙  阅读(395)  评论(0)    收藏  举报