原文:annas-archive.org/md5/e409561761c67e6644a54ed53a248850

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Linux 内核是一个复杂、可移植、模块化且广泛使用的软件,运行在约 80% 的服务器和嵌入式系统上,覆盖了全球超过一半的设备。设备驱动在 Linux 系统的运行表现中起着至关重要的作用。随着 Linux 成为最流行的操作系统之一,开发个人设备驱动的兴趣也在稳步增加。

设备驱动是用户空间与硬件设备之间的桥梁,通过内核实现。

本书将从两章开始,帮助你了解驱动的基础知识,为你踏上长达数章的 Linux 内核之旅做准备。接下来,本书将覆盖基于 Linux 子系统的驱动开发,如内存管理、工业输入/输出IIO)、通用输入/输出GPIO)、中断请求IRQ)管理,以及 互连电路I2C)和 串行外设接口SPI)。本书还将介绍一种直接内存访问和寄存器映射抽象的实践方法。

本书中的源代码已经在 x86 PC 和 SECO 的 UDOO QUAD(基于 NXP 的 ARM i.MX6)上进行了测试,配备了足够的功能和连接,以便我们覆盖本书中讨论的所有测试内容。还提供了一些驱动程序用于测试目的,适用于廉价组件,例如 MCP23016 和 24LC512,分别是 I2C GPIO 控制器和 EEPROM 存储器。

本书结束时,你将熟悉设备驱动开发的概念,并能够使用最后一个稳定的内核分支(本书撰写时为 v5.10.y)从零开始编写任何设备驱动程序。

本书适用人群

为了充分利用本书的内容,读者应具备基本的 C 编程和 Linux 命令知识。本书涉及使用 v5.10 版本内核的广泛应用的嵌入式设备的 Linux 驱动开发。本书主要面向嵌入式工程师、Linux 系统管理员、开发人员和内核黑客。无论你是软件开发人员、系统架构师,还是愿意深入了解 Linux 驱动开发的创作者,本书都适合你。

本书内容

第一章内核开发介绍,介绍了 Linux 内核的开发过程。本章将讨论内核的下载、配置和编译步骤,适用于 x86 系统和基于 ARM 的系统。

第二章理解 Linux 内核模块基本概念,通过内核模块讨论 Linux 的模块化,并描述了其加载/卸载过程。还介绍了模块架构和一些基本概念。

第三章处理内核核心帮助程序,详细讲解了常用的内核函数和机制,如工作队列、等待队列、互斥锁、自旋锁以及任何有助于提高驱动程序可靠性的设施。

第四章编写字符设备驱动程序,重点介绍了通过字符设备将设备功能导出到用户空间,以及使用 ioctl 接口支持自定义命令。

第五章理解和利用设备树,讨论了声明和描述设备给内核的机制。本章解释了设备寻址、资源处理以及设备树(DT)中支持的所有数据类型及其内核 API。

第六章设备、驱动程序与平台抽象简介,解释了平台设备的概念、伪平台总线的概念,以及设备与驱动程序匹配机制。

第七章理解平台设备和驱动程序的概念,以一种通用的方式描述了平台驱动程序架构,以及如何处理平台数据。

第八章编写 I2C 设备驱动程序,深入探讨了 I2C 设备驱动程序架构、数据结构,以及总线上设备寻址和访问方法。

第九章编写 SPI 设备驱动程序,描述了基于 SPI 的设备驱动程序架构以及涉及的数据结构。本章讨论了每个设备的访问方法和具体特性,以及应避免的陷阱。SPI 设备树绑定也进行了讨论。

第十章理解 Linux 内核内存分配,首先介绍了虚拟内存的概念,以描述整个内核内存布局。本章接着讲解内核内存管理子系统,讨论内存分配与映射、它们的 API 以及涉及这些机制的所有设备,还包括内核的缓存机制。

第十一章实现直接内存访问(DMA)支持,介绍了 DMA 及其新的内核 API:DMA 引擎 API。本章将讨论不同的 DMA 映射,并描述如何解决缓存一致性问题。此外,本章总结了所有概念,并给出了一个通用的使用案例。

第十二章抽象化内存访问——Regmap API 简介:寄存器映射抽象,概述了寄存器映射 API 及其如何抽象底层 SPI 和 I2C 事务。本章描述了通用 API 以及专用 API。

第十三章揭秘内核 IRQ 框架,揭秘了 Linux IRQ 核心。本章介绍了 Linux IRQ 管理,从中断在系统中的传播开始,到中断控制器驱动,解释了 IRQ 多路复用的概念,并使用 Linux IRQ 域 API。

第十四章Linux 设备模型简介,概述了 Linux 的核心,描述了内核中对象的表示方式,以及 Linux 在底层的设计方式,从 kobject 到设备,再到总线、类和设备驱动的结构。

第十五章深入研究 IIO 框架,介绍了内核数据采集与测量框架,处理 libiio,涉及触发缓冲区和连续数据采集。

第十六章充分利用引脚控制器和 GPIO 子系统,描述了内核引脚控制基础设施和 API,以及 GPIO 芯片驱动和 gpiolib,这是处理 GPIO 的内核 API。本章还讨论了已废弃的基于整数的 GPIO 接口,以及新的基于描述符的接口,并介绍了如何在设备树中配置它们。最后,它还涵盖了 libgpiod,这是官方的用户空间 GPIO 处理库。

第十七章利用 Linux 内核输入子系统,提供了输入子系统的整体视图,涉及基于 IRQ 的输入设备和轮询输入设备,并介绍了两种 API。本章解释并展示了用户空间代码如何处理这些设备。

为了最大限度地发挥本书的作用

本书假设你具备中等水平的 Linux 操作系统理解能力以及 C 编程的基础知识(至少包括数据结构、指针处理和内存分配)。所有代码示例均已在 Linux 内核 v5.10 中进行测试。如果某一章节需要额外的技能,本书会提供相关文档链接,帮助你快速掌握这些技能。

https://github.com/OpenDocCN/freelearn-linux-pt5-zh/raw/master/docs/linux-dvc-dvr-dev-2e/img/B17934_Preface_001.jpg

其他必要的软件包在书中的专门章节中有描述。下载内核源码时需要互联网连接。

如果你使用的是本书的数字版,我们建议你自己输入代码或从本书的 GitHub 仓库中获取代码(相关链接将在下一节提供)。这样做可以帮助你避免由于复制粘贴代码可能导致的错误。

下载示例代码文件

你可以从 GitHub 下载本书的示例代码文件,地址为github.com/PacktPublishing/Linux-Device-Driver-Development-Second-Edition。如果代码有更新,它会在 GitHub 仓库中更新。

我们的丰富书籍和视频目录中还有其他代码包,您可以访问github.com/PacktPublishing/查看。快来看看吧!

下载彩色图片

我们还提供了一个 PDF 文件,包含本书中使用的屏幕截图和图表的彩色图像。您可以在此下载:static.packt-cdn.com/downloads/9781803240060_ColorImages.pdf

使用的约定

本书中使用了若干文本约定。

文本中的代码:表示文本中的代码词、数据库表名、文件夹名称、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 账号。例如:“我们可以使用spin_lock()spin_unlock()内联函数来锁定/解锁自旋锁,这两个函数都定义在include/linux/spinlock.h中。”

代码块格式如下:

struct mutex {
    atomic_long_t owner;
    spinlock_t wait_lock;
#ifdef CONFIG_MUTEX_SPIN_ON_OWNER
    struct optimistic_spin_queue osq; /* Spinner MCS lock */

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

struct fake_data {
    struct i2c_client *client;
    u16 reg_conf;
    struct mutex mutex;
};

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

[342081.385491] Wait queue example
[342081.385505] Going to sleep my_init
[342081.385515] Waitqueue module handler work_handler
[342086.387017] Wake up the sleeping module

提示或重要说明

显示如下。

联系我们

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

customercare@packtpub.com并在邮件主题中提到书名。

勘误:尽管我们已尽最大努力确保内容的准确性,但错误仍然可能发生。如果你在本书中发现了错误,我们将不胜感激,如果你能将其报告给我们。请访问www.packtpub.com/support/errata并填写表格。

copyright@packt.com,并附上材料链接。

如果你有兴趣成为作者:如果你在某个主题上拥有专业知识,并且有兴趣撰写或参与编写一本书,请访问authors.packtpub.com

分享你的想法

一旦你阅读了*《Linux 设备驱动开发(第二版)》*,我们很想听听你的想法!请点击这里直接进入亚马逊评论页面,分享你的反馈。

你的评论对我们和技术社区都非常重要,并将帮助我们确保提供优质的内容。

第一部分 - Linux 内核开发基础

本节帮助你迈出进入 Linux 内核开发的第一步。在这里,我们介绍 Linux 内核架构(其结构和构建系统)、内核编译及设备驱动开发。作为必修步骤,我们介绍内核开发者必须掌握的最常用概念,如睡眠、锁机制、基本工作调度和中断处理机制。最后,我们介绍了必不可少的字符设备驱动程序,通过标准系统调用或扩展的命令集,实现内核空间与用户空间之间的交互。

本节将涵盖以下章节:

  • 第一章内核开发简介

  • 第二章理解 Linux 内核模块基本概念

  • 第三章处理内核核心助手

  • 第四章编写字符设备驱动程序

第一章:第一章:内核开发简介

Linux 起初是芬兰学生 Linus Torvalds 于 1991 年发起的一个兴趣项目。该项目逐渐发展壮大,目前全球约有千名贡献者。如今,Linux 已成为嵌入式系统以及服务器中不可或缺的一部分。内核是操作系统的核心部分,其开发并非简单。Linux 相对于其他操作系统有许多优势;它是免费的,文档完善并拥有庞大的社区,能够跨平台移植,提供源代码访问,并且有大量免费的开源软件。

本书将尽量保持通用性。有一个特殊的主题,称为设备树,尚未完全成为x86特性。该主题将专门讨论 ARM 处理器,特别是那些完全支持设备树的 ARM 处理器。为什么选择这些架构?因为它们主要用于桌面和服务器(x86)以及嵌入式系统(ARM)。

本章将涵盖以下主题:

  • 设置开发环境

  • 理解内核配置过程

  • 构建内核

设置开发环境

当你从事嵌入式系统领域工作时,有一些术语你必须熟悉,甚至在设置环境之前。它们如下:

  • 目标:这是构建过程生成二进制文件的机器。这个机器将运行该二进制文件。

  • 宿主:这是构建过程发生的机器。

  • 编译:这也叫做本地编译或本地构建。当目标和宿主相同,即你在机器 A(宿主)上构建一个将在同一台机器(A,目标)或相同类型的机器上执行的二进制文件时,就会发生本地编译。本地编译需要本地编译器。因此,本地编译器是指目标和宿主相同的编译器。

  • 交叉编译:在这里,目标和宿主是不同的。它是指你从机器 A(宿主)构建一个二进制文件,最终将在机器 B(目标)上执行。在这种情况下,宿主(机器 A)必须安装支持目标架构的交叉编译器。因此,交叉编译器是一个目标和宿主不同的编译器。

由于嵌入式计算机资源有限或减少(如 CPU、RAM、磁盘等),通常宿主机为 x86 机器,这些机器更强大,资源更多,有助于加速开发过程。然而,在过去几年中,嵌入式计算机变得更强大,越来越倾向于用于本地编译(因此作为宿主)。一个典型的例子是 Raspberry Pi 4,它配备了强大的四核 CPU 和最高 8 GB 的 RAM。

在本章中,我们将使用 x86 机器作为主机,进行本地构建或交叉编译。因此,任何“本地构建”的术语都将指“x86 本地构建”。基于此,我正在运行Ubuntu 18.04

要快速检查这些信息,你可以使用以下命令:

lsb_release -a
Distributor ID: Ubuntu
Description:    Ubuntu 18.04.5 LTS
Release:    18.04
Codename:   bionic

我的计算机是一个lscpu命令提取的信息,16 GB 内存,256 GB SSD,和一个 1 TB 的机械硬盘(这些信息可以通过df -h命令获得)。也就是说,一个四核 CPU 和 4 或 8 GB 的内存就足够了,但构建时间会相应增加。我的最爱编辑器是Vim,不过你可以使用你最习惯的编辑器。如果你使用的是台式机,可以使用Visual Studio CodeVS Code),它正在变得越来越流行。

现在,我们已经熟悉了将要使用的与编译相关的关键字,接下来可以开始准备主机机器了。

设置主机机器

在你开始开发过程之前,你需要设置一个环境。专门用于 Linux 开发的环境是相当简单的——至少在基于 Debian的系统上(这是我们的情况)。

在主机机器上,你需要安装以下几个包:

$ sudo apt update
$ sudo apt install gawk wget git diffstat unzip \
       texinfo gcc-multilib build-essential chrpath socat \
       libsdl1.2-dev xterm ncurses-dev lzop libelf-dev make

在前面的代码中,我们安装了一些开发工具和一些必需的库,以便在配置 Linux 内核时能拥有一个良好的用户界面。

现在,我们需要安装编译器和工具(链接器、汇编器等),以便构建过程能够正常工作并生成目标的可执行文件。这一套工具被称为Binutils,而编译器 + Binutils(如果有其他构建时依赖库的话)组合称为工具链。所以,你需要理解*“我需要一个针对<此>架构的工具链”*或类似句子的意思。

理解并安装工具链

在我们开始编译之前,我们需要安装本地编译或 ARM 交叉编译所需的必要包和工具;也就是说,工具链。GCC 是 Linux 内核支持的编译器。内核中定义的许多宏都是与 GCC 相关的。因此,我们将使用 GCC 作为我们的(交叉)编译器。

对于本地编译,你可以使用以下工具链安装命令:

sudo apt install gcc binutils

当你需要进行交叉编译时,必须识别并安装正确的工具链。与本地编译器相比,交叉编译器的可执行文件会以目标操作系统、架构和(有时)库的名称为前缀。因此,为了识别特定架构的工具链,定义了一个命名约定:arch[-vendor][-os]-abi。让我们来看看这个模式中各个字段的含义:

  • arch 用于识别架构;也就是说,armmipsx86i686等。

  • vendor 是工具链供应商(公司);也就是说,BootlinLinaronone(如果没有供应商)或干脆省略该字段,等等。

  • os是目标操作系统,即linuxnone(裸机)。如果省略,则假定为裸机。

  • abi代表应用二进制接口。它指的是底层二进制文件的外观、函数调用约定、参数传递方式等。可能的约定包括eabignueabignueabihf。让我们更详细地了解这些:

    • eabi表示将编译的代码将在裸机 ARM 核心上运行。

    • gnueabi表示将为 Linux 编译代码。

    • gnueabihfgnueabi相同,但末尾的hf表示硬浮点,这意味着编译器及其底层库使用硬件浮点指令,而不是使用软件实现的浮点指令(例如定点软件实现)。如果没有浮点硬件,指令将被拦截并由浮点仿真模块执行。当使用软件仿真时,功能上的唯一实际差异是执行速度较慢。

以下是一些工具链名称,用来说明此命名模式的使用:

  • arm-none-eabi:这是一个针对 ARM 架构的工具链。它没有供应商,目标是裸机系统(不面向操作系统),并符合 ARM EABI 规范。

  • arm-none-linux-gnueabiarm-linux-gnueabi:这是一个工具链,用于为 ARM 架构生成可在 Linux 上运行的对象文件,且使用工具链提供的默认配置(ABI)。请注意,arm-none-linux-gnueabiarm-linux-gnueabi相同,因为正如我们所见,当没有指定供应商时,假定没有供应商。该工具链的硬件浮点版本为arm-linux-gnueabihfarm-none-linux-gnueabihf

现在我们已经熟悉了工具链命名约定,我们可以确定哪个工具链可以用于为我们的目标架构进行交叉编译。

要为 32 位 ARM 机器进行交叉编译,我们将使用以下命令安装工具链:

$ sudo apt install gcc-arm-linux-gnueabihf binutils-arm-linux-gnueabihf

请注意,Linux 树和 GCC 中的 64 位 ARM 后端/支持被称为gcc-aarch64-linux-gnu*,而 Binutils 必须被命名为类似binutils-aarch64-linux-gnu*的名称。因此,对于 64 位 ARM 工具链,我们将使用以下命令:

$ sudo apt install make gcc-aarch64-linux-gnu binutils-aarch64-linux-gnu

注意

请注意,aarch64 只支持/提供硬件浮点的 aarch64 工具链。因此,无需在末尾指定hf

请注意,并非所有版本的编译器都可以编译特定的 Linux 内核版本。因此,处理 Linux 内核版本和编译器(GCC)版本非常重要。虽然前面的命令安装了由你的发行版支持的最新版本,但也可以指定某个特定版本。为此,可以使用gcc-<version>-<arch>-linux-gnu*

例如,要为 aarch64 安装 GCC 8 版本,可以使用以下命令:

sudo apt install gcc-8-aarch64-linux-gnu

现在我们的工具链已安装完毕,我们可以查看由我们的发行版包管理器选定的版本。例如,要检查安装的 aarch64 交叉编译器版本,我们可以使用以下命令:

$ aarch64-linux-gnu-gcc --version
aarch64-linux-gnu-gcc (Ubuntu/Linaro 7.5.0-3ubuntu1~18.04) 7.5.0
Copyright (C) 2017 Free Software Foundation, Inc.
[...]

对于 32 位 ARM 变体,我们可以使用以下命令:

$ arm-linux-gnueabihf-gcc --version
arm-linux-gnueabihf-gcc (Ubuntu/Linaro 7.5.0-3ubuntu1~18.04) 7.5.0
Copyright (C) 2017 Free Software Foundation, Inc.
[...]

最后,对于本地版本,我们可以使用以下命令:

$ gcc --version
gcc (Ubuntu 7.5.0-3ubuntu1~18.04) 7.5.0
Copyright (C) 2017 Free Software Foundation, Inc.

现在我们已经设置好了环境,并确保使用了正确的工具版本,我们可以开始下载 Linux 内核源代码并深入研究它们。

获取源代码

在早期的内核时代(直到 2003 年),使用奇偶版本控制风格,其中奇数版本为稳定版,偶数版本为不稳定版。当 2.6 版本发布时,版本控制方案改为X.Y.Z。我们来详细看看这个:

  • X:这是实际的内核版本,也叫主版本。当发生向后不兼容的 API 变更时,它会递增。

  • Y:这是次要修订。在以向后兼容的方式添加功能后,它会递增。

  • Z:也叫 PATCH,代表与修复 BUG 相关的版本。

这叫做语义版本控制,直到版本2.6.39,当时林纳斯·托瓦兹决定将版本号提升到 3.0,这也意味着 2011 年语义版本控制的结束。此时,采用了 X.Y 的版本方案。

当版本达到 3.20 时,林纳斯认为他再也无法增加 Y 了。因此,他决定切换到一个任意的版本控制方案,每当 Y 变得足够大,导致他用完了手指和脚趾来计数时,就递增 X。这就是为什么版本直接从 3.20 跳跃到 4.0 的原因。

现在,内核使用一个任意的X.Y版本控制方案,这与语义版本控制无关。

根据 Linux 内核发布模型,内核始终有两个最新版本:稳定版本和长期支持LTS)版本。所有的 BUG 修复和新特性由子系统维护者收集并准备好,然后提交给林纳斯·托瓦兹以纳入他的 Linux 树,这棵树被称为主线 Linux 树,也叫做Git 仓库。这是每个稳定版本的起点。

在每个新内核版本发布之前,它会通过发布候选标签提交到社区,以便开发人员可以测试和完善所有的新特性。根据他在此周期中收到的反馈,林纳斯决定最终版本是否准备好发布。当林纳斯确信新内核准备好发布时,他会做出最终发布。我们称这个发布为“稳定版”,表示它不是“发布候选版”:这些版本是vX.Y版本。

没有严格的发布时间表,但新的主线内核一般每 2-3 个月发布一次。稳定的内核发布基于林纳斯的发布;也就是说,基于主线树的发布。

一旦 Linus 发布了稳定内核,它也会出现在 linux-stable 树中(可通过 git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/ 获取),并成为一个分支。在这里,它可以接收 bug 修复。这个树被称为稳定树,因为它用于跟踪以前发布的稳定内核。它由 Greg Kroah-Hartman 维护和管理。然而,所有的修复必须首先进入 Linus 的树,即主线代码库。一旦主线代码库中的 bug 被修复,它可以应用于先前发布且仍由内核开发社区维护的内核。所有回溯到稳定版本的修复必须满足一套重要的标准才能被考虑——其中之一就是它们“必须已经存在于 Linus 的树中”。

注意

Bugfix 内核版本被认为是稳定的。

例如,当 Linus 发布 4.9 内核时,稳定内核会根据内核的版本编号方案发布;即 4.9.1、4.9.2、4.9.3 等。这些版本被称为 bugfix 内核版本,在提到其分支时,通常用 “4.9.y” 来简化。每个稳定内核发布树由单一的内核开发者维护,负责选择所需的补丁并经过审核/发布过程。通常,直到下一个主线内核发布之前,只有少数几个 bugfix 内核版本——除非它被指定为 长期维护内核

每个子系统和内核维护者的代码库都托管在这里:git.kernel.org/pub/scm/linux/kernel/git/。在这里,我们还可以找到 Linus 或稳定树。在 Linus 树中 (git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/),只有一个分支,即主分支。其标签要么是稳定版本,要么是发布候选版本。在稳定树中 (git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/),每个稳定内核版本都有一个分支(命名为 <A.B>.y,其中 <A.B> 是 Linus 树中的发布版本),每个分支包含其修复内核版本。

下载源代码并进行组织

在本书中,我们将使用 Linus 的树,可以使用以下命令进行下载:

git clone https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git --depth 1 
git checkout v5.10
ls

在前面的命令中,我们使用了 --depth 1 来避免下载历史记录(或者说,只挑选最后一次提交的历史),这可以显著减少下载的大小并节省时间。由于 Git 支持分支和标签,checkout 命令允许你切换到特定的标签或分支。在这个例子中,我们切换到了 v5.10 标签。

注意

在本书中,我们将处理 Linux 内核 v5.10。

让我们来看看主源代码目录的内容:

  • arch/:为了尽可能地通用,架构特定的代码与其他代码分开。该目录包含按架构组织的处理器特定代码,如 alpha/arm/mips/arm64/ 等。

  • block/:该目录包含块存储设备的代码。

  • crypto/:该目录包含加密 API 和加密算法的代码。

  • certs/:该目录包含证书和签名文件,以启用模块签名,使内核加载已签名的模块。

  • documentation/:该目录包含用于不同内核框架和子系统的 API 描述。在向公共论坛提问之前,你应该查看这里。

  • drivers/:这是最大的目录,因为随着设备驱动的合并,它不断增长。它包含每个设备驱动,并按子目录进行组织。

  • fs/:该目录包含内核支持的不同文件系统的实现,例如 NTFS、FAT、ETX{2,3,4}、sysfs、procfs、NFS 等。

  • include/:该目录包含内核头文件。

  • init/:该目录包含初始化和启动代码。

  • ipc/:该目录包含进程间通信IPC)机制的实现,如消息队列、信号量和共享内存。

  • kernel/:该目录包含与架构无关的基本内核部分。

  • lib/:此目录包含库例程和一些辅助函数,包括通用内核对象kobject)处理程序和循环冗余码CRC)计算函数。

  • mm/:该目录包含内存管理代码。

  • net/:该目录包含网络(无论是哪种网络类型)协议代码。

  • samples/:该目录包含用于各种子系统的设备驱动示例。

  • scripts/:该目录包含与内核一起使用的脚本和工具。这里还有一些其他有用的工具。

  • security/:该目录包含安全框架代码。

  • sound/:你猜这里有什么:音频子系统代码。

  • tools/:该目录包含用于各种子系统的 Linux 内核开发和测试工具,例如 USB、vhost 测试模块、GPIO、IIO 和 SPI 等。

  • usr/:该目录目前包含 initramfs 实现。

  • virt/:这是虚拟化目录,包含用于虚拟机监控器的内核虚拟机KVM)模块。

为了强制可移植性,任何架构特定的代码应放在 arch 目录中。此外,与用户空间 API 相关的内核代码(如系统调用、/proc/sys 等)不会改变,因为修改它会破坏现有的程序。

在本节中,我们已经熟悉了 Linux 内核的源代码内容。经过所有源代码的学习后,配置它们以编译内核似乎是很自然的事情。在下一节中,我们将学习内核配置是如何工作的。

配置和构建 Linux 内核

Linux 内核源码中有大量的驱动程序/特性和构建选项。配置过程包括选择哪些特性/驱动程序将成为编译过程的一部分。根据我们是否进行本地编译或交叉编译,有一些环境变量必须在配置过程之前定义。

指定编译选项

内核的 Makefile 调用的编译器是 $(CROSS_COMPILE)gcc。也就是说,CROSS_COMPILE 是交叉编译工具的前缀(如 gccasldobjcopy 等),在调用 make 时必须指定,或者在执行任何 make 命令之前已经被导出。只有 gcc 及其相关的 Binutils 可执行文件会以 $(CROSS_COMPILE) 为前缀。

请注意,Linux 内核构建基础设施会根据目标架构做出各种假设,并启用选项/特性/标志。为了实现这一点,除了交叉编译器前缀外,还必须指定目标的架构。这可以通过 ARCH 环境变量来完成。

因此,一个典型的 Linux 配置或构建命令看起来如下所示:

ARCH=<XXXX> CROSS_COMPILE=<YYYY> make menuconfig

它也可以如下所示:

ARCH=<XXXX> CROSS_COMPILE=<YYYY> make <make-target>

如果你不想在启动命令时指定这些环境变量,可以将它们导出到当前 shell 中。以下是一个示例:

export CROSS_COMPILE=aarch64-linux-gnu-
export ARCH=aarch64

请记住,如果没有指定这些变量,默认将以本地主机作为目标;也就是说,如果省略或未设置 CROSS_COMPILE,那么 $(CROSS_COMPILE)gcc 将变为 gcc,其他被调用的工具也会是相同的(例如,$(CROSS_COMPILE)ld 会变成 ld)。

同样地,如果 ARCH(目标架构)被省略或未设置,它将默认为执行 make 的主机架构。它将默认设置为 $(uname -m)

结果,你应该保持 CROSS_COMPILEARCH 未定义,以便使用 gcc 本地编译内核以适应主机架构。

理解内核配置过程

Linux 内核是一个 基于 Makefile 的项目,包含成千上万的选项和驱动程序。每个启用的选项可能会使另一个选项可用,或者将特定代码引入构建中。为了配置内核,你可以使用 make menuconfig 进行基于 ncurses 的界面配置,或者使用 make xconfig 进行基于 X 的界面配置。基于 ncurses 的界面如下所示:

https://github.com/OpenDocCN/freelearn-linux-pt5-zh/raw/master/docs/linux-dvc-dvr-dev-2e/img/B17934_Fig_1.1.jpg

](https://github.com/OpenDocCN/freelearn-linux-pt5-zh/raw/master/docs/linux-dvc-dvr-dev-2e/img/B17934_Fig_1.1.jpg)

图 1.1 – 内核配置界面

对于大多数选项,您有三个选择。然而,在配置 Linux 内核时,我们可以列出五种类型的选项:

  • 布尔选项,您可以选择两种状态:

    • (blank),表示跳过此功能。一旦在配置菜单中高亮显示此选项,您可以按<n>键跳过该功能。这相当于 false。当禁用时,配置文件中的相应配置选项会被注释掉。

    • (*),表示将其静态编译到内核中。这意味着在内核首次加载时,它将始终存在。这相当于 true。您可以通过选择此功能并按下 <y> 键来启用该功能。生成的选项将在配置文件中显示为 CONFIG_<OPTION>=y;例如,CONFIG_INPUT_EVDEV=y

  • 三态选项,除了可以取布尔状态外,还可以取第三种状态,这在配置窗口中标记为 (M)。这将在配置文件中生成 CONFIG_<OPTION>=m;例如,CONFIG_INPUT_EVDEV=m。为了生成可加载模块(前提是该选项允许),您可以选择该功能并按下 M 键。

  • 字符串选项,期望字符串值;例如,CONFIG_CMDLINE="noinitrd console=ttymxc0,115200"

  • 十六进制选项,期望十六进制值;例如,CONFIG_PAGE_OFFSET=0x80000000

  • 整数选项,期望整数值;例如,CONFIG_CONSOLE_LOGLEVEL_DEFAULT=7

选定的选项将被存储在源代码树根目录下的 .config 文件中。

很难知道哪个配置在您的平台上能够正常工作。在大多数情况下,您无需从头开始配置。每个架构目录中都有默认且有效的配置文件,您可以将其作为起点(重要的是要从一个已经工作的配置开始):

ls arch/<your_arch>/configs/

对于基于 32 位 ARM 的 CPU,这些配置文件可以在 arch/arm/configs/ 中找到。在该架构中,通常每个 CPU 系列都有一个默认配置文件。例如,对于 i.MX6-7 处理器,默认配置文件是 arch/arm/configs/imx_v6_v7_defconfig。然而,在 ARM 64 位 CPU 上,只有一个大默认配置文件可供定制;它位于 arch/arm64/configs/ 中,文件名为 defconfig。类似地,对于 x86 处理器,我们可以在 arch/x86/configs/ 中找到文件。这里将有两个默认配置文件——i386_defconfigx86_64_defconfig,分别对应 32 位和 64 位 x86 架构。

内核配置命令,给定默认的配置文件,格式如下:

make <foo_defconfig>

这将生成一个新的 .config 文件到主(根)目录,而旧的 .config 文件将被重命名为 .config.old。这样可以方便地恢复之前的配置更改。然后,您可以使用以下命令来定制配置:

make menuconfig

保存你的更改将更新你的.config文件。虽然你可以与队友共享此配置,但最好是创建一个与 Linux 内核源代码中提供的最小格式相同的默认配置文件。为此,你可以使用以下命令:

make savedefconfig

此命令将创建一个最小化的(因为它不会存储非默认设置)配置文件。生成的默认配置文件将被命名为defconfig并存储在源代码树的根目录下。你可以使用以下命令将其存储在其他位置:

mv defconfig arch/<arch>/configs/myown_defconfig

通过这种方式,你可以在内核源代码中共享一个参考配置,其他开发者现在可以通过运行以下命令获取与你相同的.config文件:

make myown_defconfig

注释

请注意,对于交叉编译,ARCHCROSS_COMPILE必须在执行任何make命令之前设置,即使是内核配置也是如此。否则,你的配置可能会发生意外变化。

以下是你可以使用的各种配置命令,具体取决于目标系统:

  • 对于 64 位 x86 本地编译,过程相当简单(可以省略编译选项):

    make x86_64_defconfig
    make menuconfig
    
  • 给定一个 32 位 ARM i.MX6 基础板,你可以执行以下命令:

    ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- make imx_v6_v7_defconfig
    ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- make menuconfig
    

使用第一个命令时,你会将默认选项存储在.config文件中,而使用后者,你可以根据需要更新(添加/删除)各种选项。

  • 对于 64 位 ARM 板,你可以执行以下命令:

    ARCH=aarch64 CROSS_COMPILE=aarch64-linux-gnu- make defconfig
    ARCH=aarch64 CROSS_COMPILE=aarch64-linux-gnu- make menuconfig
    

使用xconfig时,你可能会遇到 Qt4 错误。在这种情况下,你应该使用以下命令安装缺失的软件包:

sudo apt install qt4-dev-tools qt4-qmake

注释

你可能会从旧内核切换到新内核。给定旧的.config文件,你可以将其复制到新的内核源代码树中并运行make oldconfig。如果新内核中有新选项,你将被提示是否包括这些选项。不过,你可能想使用这些选项的默认值。在这种情况下,你应该运行make olddefconfig。最后,为了对每个新选项说“不”,你应该运行make oldnoconfig

可能有更好的方法来找到初始配置文件,特别是如果你的机器已经在运行的情况下。Debian 和 Ubuntu Linux 发行版将.config文件保存在/boot目录中,因此你可以使用以下命令复制此配置文件:

cp /boot/config-`uname -r` .config

其他发行版可能不会这样做。因此,我建议你始终启用IKCONFIGIKCONFIG_PROC内核配置选项,这将通过/proc/configs.gz启用对.config的访问。这是一种标准方法,也适用于嵌入式发行版。

一些有用的内核配置功能

现在我们可以配置内核了,下面列举一些可能值得在你的内核中启用的有用配置功能:

  • IKCONFIGIKCONFIG_PROC:这些是对我来说最重要的选项。它使得你的内核配置在运行时可用,并可以在 /proc/config.gz 中查看。它非常有用,可以在其他系统上重用此配置,或者简单地查看某个特性是否启用;例如:

    # zcat /proc/config.gz | grep CONFIG_SOUND
    CONFIG_SOUND=y
    CONFIG_SOUND_OSS_CORE=y
    CONFIG_SOUND_OSS_CORE_PRECLAIM=y
    # CONFIG_SOUNDWIRE is not set
    #  
    
  • CMDLINE_EXTENDCMDLINE:第一个选项是一个布尔值,它允许你在配置中扩展内核命令行,而第二个选项是一个字符串,包含实际的命令行扩展值;例如,CMDLINE="noinitrd usbcore.authorized_default=0"

  • CONFIG_KALLSYMS:这是一个布尔选项,它使得内核符号表(符号与地址之间的映射)可以在/proc/kallsyms中查看。这对于跟踪工具和其他需要将内核符号映射到地址的工具非常有用。在打印oops消息时会使用此选项。如果没有它,oops列表将输出十六进制数据,难以解读。

  • CONFIG_PRINTK_TIME:此选项在打印内核消息时显示时间信息。它可能有助于为运行时发生的事件添加时间戳。

  • CONFIG_INPUT_EVBUG:此选项允许你调试输入设备。

  • CONFIG_MAGIC_SYSRQ:此选项允许你在系统崩溃后,通过按组合键来控制系统(如重启、转储一些状态信息等)。

  • DEBUG_FS:此选项启用对调试文件系统的支持,GPIOCLOCKDMAREGMAPIRQs 和其他多个子系统可以从中进行调试。

  • FTRACEDYNAMIC_FTRACE:这些选项启用强大的 ftrace 跟踪器,可以跟踪整个系统。一旦启用 ftrace,还可以启用一些枚举选项:

    • FUNCTION_TRACER:此选项允许你跟踪内核中的任何非内联函数。

    • FUNCTION_GRAPH_TRACER:此选项与前一个命令相同,但它显示一个调用图(调用者和被调用者函数)。

    • IRQSOFF_TRACER:此选项允许你跟踪内核中 IRQ 关闭的时段。

    • PREEMPT_TRACER:此选项允许你测量抢占关闭的延迟。

    • SCHED_TRACER:此选项允许你跟踪调度延迟。

在内核配置完成后,必须构建内核以生成可运行的内核。在下一节中,我们将描述内核构建过程以及预期的构建产物。

构建 Linux 内核

此步骤要求你在配置步骤时所在的同一 shell 中执行;否则,你需要重新定义 ARCHCROSS_COMPILE 环境变量。

Linux 是一个基于 Makefile 的项目。构建此类项目需要使用 make 工具并执行 make 命令。对于 Linux 内核,此命令必须从主内核源目录以普通用户身份执行。

默认情况下,如果未指定,make 目标是 all。在 Linux 内核源代码中,对于 x86 架构,此目标指向(或依赖于)vmlinux bzImage modules 目标;对于 ARM 或 aarch64 架构,它对应于 vmlinux zImage modules dtbs 目标。

在这些目标中,bzImage 是一个特定于 x86 的 make 目标,它会生成一个名为 bzImage 的二进制文件。vmlinux 是一个 make 目标,它会生成一个名为 vmlinux 的 Linux 镜像。zImagedtbs 都是特定于 ARM 和 aarch64 的 make 目标。第一个生成一个与其同名的 Linux 镜像,而第二个则构建目标 CPU 变体的设备树源文件。modules 是一个 make 目标,它会构建所有选中的模块(在配置中标记为 m 的模块)。

在构建过程中,你可以通过运行多个并行任务来利用主机的 CPU 性能,这得益于 -j make 选项。以下是一个示例:

make -j16

大多数人将他们的 -j 数量定义为核心数的 1.5 倍。就我而言,我总是使用 ncpus * 2

你可以像这样构建 Linux 内核:

  • 对于本地编译,使用以下命令:

    make -j16
    
  • 对于 32 位 ARM 交叉编译,使用以下命令:

    ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- make -j16
    

每个 make 目标都可以单独调用,如下所示:

ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- make dtbs

你也可以这样做:

ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- make zImage -j16

最后,你还可以这样做:

make bzImage -j16

注意

我在命令中使用了 -j16,因为我的主机有一个 8 核 CPU。此任务数量必须根据你的主机配置进行调整。

在你的 32 位 ARM 交叉编译工作结束时,你会看到类似以下内容:

[…]
  LZO     arch/arm/boot/compressed/piggy_data
  CC      arch/arm/boot/compressed/misc.o
  CC      arch/arm/boot/compressed/decompress.o
  CC      arch/arm/boot/compressed/string.o
  SHIPPED arch/arm/boot/compressed/hyp-stub.S
  SHIPPED arch/arm/boot/compressed/lib1funcs.S
  SHIPPED arch/arm/boot/compressed/ashldi3.S
  SHIPPED arch/arm/boot/compressed/bswapsdi2.S
  AS      arch/arm/boot/compressed/hyp-stub.o
  AS      arch/arm/boot/compressed/lib1funcs.o
  AS      arch/arm/boot/compressed/ashldi3.o
  AS      arch/arm/boot/compressed/bswapsdi2.o
  AS      arch/arm/boot/compressed/piggy.o
  LD      arch/arm/boot/compressed/vmlinux
  OBJCOPY arch/arm/boot/zImage
  Kernel: arch/arm/boot/zImage is ready

通过使用默认目标,构建过程中会生成多个二进制文件,具体取决于架构。这些文件如下所示:

  • arch/<arch>/boot/Image:一个未压缩的内核镜像,可以用来启动

  • arch/<arch>/boot/*Image*:一个压缩的内核镜像,也可以用来启动:

这是 x86 的 bzImage(即 “big zImage”),ARM 或 aarch64 的 zImage,以及其他架构的 vary

  • arch/<arch>/boot/dts/*.dtb:为所选的 CPU 变体提供已编译的设备树二进制文件。

  • vmlinux:这是一个原始的、未压缩和未剥离的 ELF 格式内核镜像。它通常用于调试,但通常不用于启动。

现在我们知道如何(交叉)编译 Linux 内核了,接下来学习如何安装它。

安装 Linux 内核

Linux 内核安装过程在本地编译和交叉编译方面有所不同:

  • 在本地安装(即你正在安装主机)时,你可以简单地运行 sudo make install。你必须使用 sudo,因为安装将发生在 /boot 目录下。如果你进行的是 x86 本地安装,以下文件会被安装:

    • /boot/vmlinuz-<version>:这是 vmlinux 的压缩和剥离版本。它与 arch/<arch>/boot 中的内核镜像相同。

    • /boot/System.map-<version>:存储内核符号表(内核符号与其地址之间的映射),不仅用于调试,还允许某些内核模块解析它们的符号并正确加载。此文件仅包含静态的内核符号表,而运行中内核的/proc/kallsyms(前提是配置文件中启用了CONFIG_KALLSYMS)包含System.map和已加载的内核模块符号。

    • /boot/config-<version>:这对应于已构建版本的内核配置。

  • 嵌入式安装通常使用单个文件内核。此外,目标设备无法访问,因此更倾向于手动安装。因此,嵌入式 Linux 构建系统(如 Yocto 或 Buildroot)会使用内部脚本将内核映像放置在目标根文件系统中。虽然嵌入式安装由于使用了构建系统可能较为简单,但本地安装(尤其是 x86 本地安装)可能需要运行额外的引导加载程序相关命令(如update-grub2),使新内核对系统可见。

现在我们已经了解了内核配置,包括构建和安装过程,让我们来看一下内核模块,它们允许你在运行时扩展内核。

构建和安装模块

可以使用modules目标单独构建模块。使用modules_install目标可以安装它们。模块会在与其源代码相对应的同一目录中构建。因此,生成的内核对象会分散在内核源树中:

  • 对于本地构建和安装,你可以使用以下命令:

    make modules
    sudo make modules_install
    

生成的模块将安装在/lib/modules/$(uname -r)/kernel/中,目录结构与其源代码相应。可以使用INSTALL_MOD_PATH环境变量指定自定义安装路径。

  • 当你为嵌入式系统进行交叉编译时,和所有make命令一样,必须指定ARCHCROSS_COMPILE。由于无法将目录安装到目标设备的文件系统中,嵌入式 Linux 构建系统(如 Yocto 或 Buildroot)会将INSTALL_MOD_PATH设置为对应目标根文件系统的路径,以便最终的根文件系统镜像包含已构建的模块;否则,模块将安装在主机上。以下是 32 位 ARM 架构的示例:

    ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- make modules
    ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- INSTALL_MOD_PATH=<dir> make modules_install
    

除了随模块一起提供的kernel目录外,以下文件也会安装在/lib/modules/<version>目录中:

  • modules.builtin:列出了所有内核对象(.ko),这些对象是内核构建时包含的。它被模块加载工具(如modprobe)使用,以确保在加载已经构建进内核的模块时不会失败。modules.builtin.bin是它的二进制版本。

  • modules.alias:这个文件包含了模块加载工具的别名,这些工具用于匹配驱动程序和设备。模块别名的概念将在第六章《设备、驱动程序和平台抽象介绍》中进行解释。modules.alias.bin是它的二进制等效文件。

  • modules.dep:这个文件列出了模块及其依赖关系。modules.dep.bin是它的二进制版本。

  • modules.symbols:这个文件告诉我们一个给定符号属于哪个模块。它们的形式为 alias symbol:<symbol> <modulename>。例如,alias symbol:v4l2_async_notifier_register videodevmodules.symbols.bin是这个文件的二进制版本。

到此为止,我们已经安装了必要的模块,学习了如何构建和安装 Linux 内核和模块。我们也学会了如何配置 Linux 内核并添加所需的功能。

总结

在这一章中,你学习了如何下载 Linux 源代码并进行第一次构建。我们还介绍了一些常见操作,比如配置或选择合适的工具链。也就是说,这一章内容较为简短,主要是一个介绍。因此,在下一章中,我们将详细讲解内核构建过程、如何编译驱动程序(无论是外部编译还是作为内核的一部分),以及一些在开始长时间的内核开发之前你应该掌握的基础知识。让我们来看看吧!

第二章:第二章:理解 Linux 内核模块的基本概念

内核模块是一种软件,旨在通过新增功能扩展 Linux 内核。内核模块可以是设备驱动程序,在这种情况下,它将控制和管理特定的硬件设备,因此被称为设备驱动程序。模块也可以添加框架支持(例如IIO,即工业输入输出框架)、扩展现有框架,甚至是新的文件系统或其扩展。需要记住的是,内核模块不一定是设备驱动程序,而设备驱动程序始终是内核模块。

与内核模块相对的是,可能存在简单模块或用户空间模块,它们运行在用户空间,权限较低。然而,本书仅处理内核空间模块,特别是 Linux 内核模块。

话虽如此,本章将讨论以下主题:

  • 模块概念简介

  • 构建 Linux 内核模块

  • 处理符号导出和模块依赖

  • 学习一些 Linux 内核编程技巧

模块概念简介

在构建 Linux 内核时,最终生成的映像是由所有与配置中启用的功能相对应的目标文件链接而成的单一文件。因此,所有包含的功能在内核启动时就能立即可用,即使文件系统尚未准备好或不存在。这些功能是内建的,相应的模块称为静态模块。这样的模块在内核映像中始终可用,因此无法卸载,但代价是最终内核映像的体积增加。静态模块也被称为内建模块,因为它是最终内核映像输出的一部分。任何代码的更改都需要重新构建整个内核。

然而,一些功能(如设备驱动程序、文件系统和框架)可以编译为可加载模块。这些模块与最终的内核映像分离,按需加载。它们可以被视为插件,能够动态加载/卸载,以便在运行时向内核添加或删除功能。由于每个模块作为单独的文件存储在文件系统中,因此使用可加载模块需要访问文件系统。

总结来说,模块对于 Linux 内核就像插件(附加组件)对于用户软件(例如 Firefox)。当它与生成的内核映像静态链接时,称为内建。它如果被构建为一个单独的文件(可以加载/卸载),则称为可加载模块。它在不需要重启机器的情况下动态扩展内核功能。

为了支持模块加载,内核必须启用以下选项进行构建:

CONFIG_MODULES=y

卸载模块是内核的一项特性,可以根据CONFIG_MODULE_UNLOAD内核配置选项启用或禁用。没有这个选项,我们将无法卸载任何模块。因此,为了能够卸载模块,必须启用以下功能:

CONFIG_MODULE_UNLOAD=y

也就是说,内核足够智能,可以防止卸载可能会破坏系统的模块(例如,因为这些模块正在使用中),即使被要求卸载。这是因为内核会保持模块使用的引用计数,从而知道模块当前是否正在使用。如果内核认为卸载模块不安全,它将不会卸载。但是,我们可以通过以下配置功能来改变这种行为:

MODULE_FORCE_UNLOAD=y

上面的选项允许我们强制卸载模块。

现在我们已经了解了模块背后的主要概念,让我们开始实践,首先介绍一个模块框架,它将作为本章的基础。

案例研究——模块框架

让我们考虑以下hello-world模块。它将是我们在本章中工作的基础。我们将其编译单元命名为helloworld.c,其内容如下:

#include <linux/module.h>
#include <linux/init.h>
static int __init helloworld_init(void) {
    pr_info("Hello world initialization!\n");
    return 0;
}
static void __exit helloworld_exit(void) {
    pr_info("Hello world exit\n");
}
module_init(helloworld_init);
module_exit(helloworld_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("John Madieu <john.madieu@gmail.com>");
MODULE_DESCRIPTION("Linux kernel module skeleton");

在前面的框架中,头文件是特定于 Linux 内核的,因此使用了linux/xxx.hmodule.h头文件是所有内核模块的必需文件,init.h是为了使用__init__exit宏而需要的。其他内容将在接下来的部分中描述。为了构建这个框架模块,我们需要编写一个特殊的 makefile,稍后在本章中会涉及到。

模块的入口和退出点

内核模块的最小要求是一个初始化方法。这是必须的。如果模块可以构建为可加载模块,则还必须提供exit方法。第一个方法是入口点,对应于模块加载时调用的函数(modprobeinsmod),而后者是清理和退出点,对应于模块卸载时执行的函数(rmmodmodprobe -r)。

您需要做的就是通知内核哪些函数应该作为入口或出口点执行。helloworld_inithelloworld_exit函数可以使用任何名称。实际上唯一强制要求的是将它们标识为相应的初始化和退出函数,并将它们作为参数传递给module_init()module_exit()宏。

总结一下,module_init()用于声明在模块加载时应该调用的函数(当模块构建为可加载内核模块时,通过insmodmodprobe),或者当内核达到与此模块对应的运行级别时(当它是内置的)。初始化函数中执行的内容将定义模块的行为。module_exit()仅在模块可以构建为可加载内核模块时使用。它声明了模块卸载时应该调用的函数(通过rmmod)。

initexit方法只会被调用一次,无论模块当前处理多少设备,只要该模块是设备驱动程序。对于平台(或类似的)设备驱动程序的模块来说,通常会在其init函数中注册一个平台驱动程序,并关联probe/remove回调,这样每次模块处理的设备添加或从系统中移除时,init函数就会被调用。在这种情况下,它们只需在exit方法中注销平台驱动程序。

__init 和 __exit 属性

__init__exit是内核宏,在include/linux/init.h中定义,如下所示:

#define __init      __section(.init.text)
#define __exit      __section(.exit.text)

__init关键字告诉链接器将它们前缀的符号(变量或函数)放置在结果内核目标文件中的专用段中。内核事先已知该段,并在模块加载且初始化函数完成后释放。此功能仅适用于内建模块,而不适用于可加载模块。内核会在启动过程中首次运行驱动程序的初始化函数。由于驱动程序不能卸载,因此初始化函数在下次重启之前永远不会再次被调用。因此,之后无需再保留对该初始化函数的引用。__exit关键字及exit方法也遵循相同的规则,编译时将会忽略对应的代码,如果模块被静态编译到内核中,或者未启用模块卸载支持,因为在这两种情况下,退出函数从未被调用。__exit对可加载模块没有影响。

总之,__init__exit是 Linux 指令(宏),用于包装 GNU C 编译器属性,这些属性用于符号位置的设置。它们指示编译器将它们前缀的代码分别放置在.init.text.exit.text段中,尽管内核可以访问不同的对象段。

模块信息和元数据

无需读取其代码,应该可以收集一些关于给定模块的信息(例如,作者、模块参数描述和许可证)。内核模块使用其.modinfo段来存储模块信息。任何MODULE_*宏都将更新该段的内容,并传入作为参数的值。部分宏如MODULE_DESCRIPTION()MODULE_AUTHOR()MODULE_LICENSE()。也就是说,内核提供的真实底层宏来添加条目到模块信息段是MODULE_INFO(tag, info),它以tag = "info"的形式添加通用信息。这意味着驱动程序作者可以添加任何自由格式的信息,举例如下:

MODULE_INFO(my_field_name, "What easy value");

除了我们定义的自定义信息外,还有一些标准信息我们应该提供,内核为此提供了宏:

  • MODULE_LICENSE:许可证将定义如何与你的源代码共享(或不共享)给其他开发者。MODULE_LICENSE() 告诉内核我们的模块采用何种许可证。它会影响模块的行为,因为与 GPL(通用公共许可证)不兼容的许可证会导致模块无法看到/使用内核通过 EXPORT_SYMBOL_GPL() 宏导出的符号,这些符号仅供与 GPL 兼容的模块使用。这与 EXPORT_SYMBOL() 相反,后者导出任何许可证模块的函数。加载一个与 GPL 不兼容的模块也会导致内核被污染;这意味着加载了非开源或不受信任的代码,并且你可能无法获得社区的支持。记住,没有 MODULE_LICENSE() 的模块也不被视为开源,并且会污染内核。可以在 include/linux/module.h 中找到可用的许可证,描述内核支持的许可证类型。

  • MODULE_AUTHOR() 声明模块的作者:MODULE_AUTHOR("John Madieu <john.madieu@foobar.com>");。一个模块可以有多个作者。在这种情况下,每个作者必须使用 MODULE_AUTHOR() 声明:

    MODULE_AUTHOR("John Madieu <john.madieu@foobar.com>");
    MODULE_AUTHOR("Lorem Ipsum <l.ipsum@foobar.com>");
    
  • MODULE_DESCRIPTION() 简要描述模块的功能:MODULE_DESCRIPTION("Hello, world! Module")

你可以使用 objdump -d -j .modinfo 命令查看内核模块的 .modeinfo 部分内容。对于交叉编译的模块,你应该使用 $(CORSS_COMPILE)objdump 来代替。

在我们完成提供模块信息和元数据的步骤后,这些是编写 Linux 内核模块时的最后要求,让我们学习如何构建这些模块。

构建 Linux 内核模块

编译内核模块有两种解决方案:

  • 第一个解决方案是当代码位于内核源树之外时,也称为外部构建。模块源代码位于不同的目录。以这种方式构建模块不能与内核配置/编译过程集成,模块需要单独构建。需要注意的是,使用这种解决方案时,模块不能在最终的内核镜像中静态链接——也就是说,它不能被内建。外部构建只允许生成可加载的内核模块。

  • 第二种解决方案是在内核树中,它允许你将代码上游化,因为它与内核配置/编译过程紧密集成。这个解决方案允许你生成静态链接模块(也叫内建模块)或可加载的内核模块。

现在我们已经列举并给出了构建内核模块的两种可能解决方案的特点,在研究它们之前,让我们先深入了解一下 Linux 内核的构建过程。这将帮助我们理解每种解决方案的编译前提条件。

理解 Linux 内核构建系统

Linux 内核维护其自己的构建系统。它称为 Kconfig,用于功能选择,主要与内核树构建一起使用,以及 Kbuild(注意这次 K 是大写)或 Makefile,用于编译规则。

Kbuild 或 Makefile 文件

从此构建系统内部,makefile 可以称为MakefileKbuild。如果两个文件都存在,则仅使用Kbuild。也就是说,makefile 是用于执行一组操作的特殊文件,其中最常见的是程序的编译。有一个专用工具来解析 makefile,称为make。使用此工具,内核模块构建命令模式如下所示:

make -C $KERNEL_SRC M=$(shell pwd) [target]

在上述模式中,$KERNEL_SRC 指的是预构建内核目录的路径,-C $KERNEL_SRC 指示 make 在执行时转到指定目录并在完成后返回,M=$(shell pwd) 指示内核构建系统回到此目录以找到正在构建的模块。给定给 M 的值是模块源代码所在目录(或相关的 Kbuild 文件)的绝对路径。[target] 对应于构建外部模块时可用的 make 目标的子集。这些如下:

  • modules:这是外部模块的默认目标。它的功能与未指定目标时相同。

  • modules_install:这会安装外部模块(s)。默认位置是/lib/modules/<kernel_release>/extra/。此路径可以被覆盖。

  • clean:这将删除所有生成的文件(仅在模块目录中)。

然而,我们还没有告诉构建系统要构建或链接哪些对象文件。我们必须指定要构建的模块名称,以及必需的源文件列表。可以简单地如下一行:

obj-<X> := <module_name>.o

在上述中,内核构建系统将从 <module_name>.c<module_name>.S 构建 <module_name>.o,并在链接后将其结果为 <module_name>.ko 内核可加载模块或将其作为单文件内核映像的一部分。<X> 可以是 ym 或留空。

如何以及是否构建或链接 mymodule.o 取决于 <X> 的值:

  • 如果 <X> 设置为 m,则使用 obj-m 变量,并且 mymodule.o 将作为可加载的内核模块构建。

  • 如果 <X> 设置为 y,则使用 obj-y 变量,并且 mymodule.o 将作为内核的一部分构建。然后你会说"foo 是一个内置的内核模块"。

  • 如果未设置 <X>,则使用 obj- 变量,并且 mymodule.o 将根本不会构建。

然而,通常使用 obj-$(CONFIG_XXX) 模式,其中 CONFIG_XXX 是内核配置选项,在内核配置过程中设置或不设置。例如以下是一个示例:

obj-$(CONFIG_MYMODULE) += mymodule.o

$(CONFIG_MYMODULE)的值根据内核配置时的值(通过menuconfig显示)会被评估为ym或空(无)。如果CONFIG_MYMODULE既不是y也不是m,则该文件既不会被编译也不会被链接。y表示内建(在内核配置过程中表示yes),而m表示可加载模块。$(CONFIG_MYMODULE)从正常的配置过程中提取正确的答案。

到目前为止,我们假设模块是由一个单独的.c源文件构建的。当模块是由多个源文件构建时,需要添加一行来列出这些源文件,如下所示:

<module_name>-y := <file1>.o <file2>.o

前面的说明表示<module_name>.ko将由两个文件file1.cfile2.c构建。然而,如果你想构建两个模块,例如foo.kobar.koMakefile的行应如下所示:

obj-m := foo.o bar.o

如果foo.obar.o是由不同于foo.cbar.c的源文件生成的,你可以指定每个目标文件的适当源文件,如下所示:

obj-m := foo.o bar.o
foo-y := foo1.o foo2.o . . .
bar-y := bar1.o bar2.o bar3.o . . .

以下是列出构建给定模块所需源文件的另一个示例:

obj-m := 8123.o
8123-y := 8123_if.o 8123_pci.o 8123_bin.o

前面的示例表明,8123应该通过构建和链接8123_if.c8123_pci.c8123_bin.c文件来构建为一个可加载的内核模块。

除了文件作为生成的构建产物的一部分,Makefile文件还可以包含编译器和链接器标志,如下所示:

ccflags-y := -I$(src)/include
ccflags-y += -I$(src)/src/hal/include
ldflags-y := -T$(src)foo_sections.lds

这里需要注意的是,除了我们在示例中设置的值外,也可以指定类似的标志。

另一个obj-<X>的使用案例如下所述:

obj-<X> += somedir/

这意味着内核构建系统应该进入名为somedir的目录,并查找其中的任何MakefileKbuild文件,处理它们以决定应该构建哪些目标。

我们可以用以下Makefile总结刚才所说的内容:

# kbuild part of makefile
obj-m := helloworld.o
#the following is just an example
#ldflags-y := -T foo_sections.lds
# normal makefile
KERNEL_SRC ?= /lib/modules/$(shell uname -r)/build
all default: modules
install: modules_install
modules modules_install help clean:
    $(MAKE) -C $(KERNEL_SRC) M=$(shell pwd) $@

以下描述了这个简化的Makefile框架:

  • obj-m := helloworld.oobj-m列出了我们希望构建的模块。对于每个<filename>.o,构建系统将查找<filename>.c<filename>.S来进行构建。obj-m用于构建可加载的内核模块,而obj-y将导致内建的内核模块。

  • KERNEL_SRC= /lib/modules/$(shell uname -r)/buildKERNEL_SRC是预构建内核源代码的位置。正如我们之前所说,我们需要一个预构建的内核来构建任何模块。如果你是从源代码构建内核的,你应该用已构建的源目录的绝对路径来设置此变量。–C指示make工具切换到指定的目录并读取Makefile

  • M=$(shell pwd):这与内核构建系统有关。内核的Makefile使用此变量来定位外部模块的目录进行构建。你的.c文件应该放在该目录中。

  • all default: modules:这一行指示make工具将modules目标作为alldefault目标的依赖项执行。换句话说,make defaultmake all或简单的make命令将在执行任何后续命令之前执行make modules

  • modules modules_install help clean:这一行表示在该 makefile 中有效的目标列表。

  • $(MAKE) -C $(KERNELDIR) M=$(shell pwd) $@:这是为先前列举的每个目标执行的规则。$@将被替换为传递给make的参数,其中包括目标。使用这种魔术词可以防止我们编写与目标数目相同(相同)的行。换句话说,如果你运行make modules$@将被替换为modules,规则将变为$(MAKE) -C $(KERNELDIR) M=$(shell pwd) modules

现在我们熟悉了内核构建系统的要求,让我们看看模块是如何实际构建的。

树外构建

在构建外部模块之前,你需要拥有一个完整的、预编译的内核源代码树。预构建的内核版本必须与你将加载并使用模块的内核版本相同。有两种方式可以获得预构建的内核版本:

  • 自行构建(我们之前讨论过的):这可以用于本地编译和交叉编译。使用像 Yocto 或 Buildroot 这样的构建系统可能会有所帮助。

  • 从发行版软件包源安装linux-headers-*包:这仅适用于 x86 本地编译,除非你的嵌入式目标运行的是维护软件包源的 Linux 发行版(例如 Raspbian)。

必须注意的是,无法通过树外构建来构建内建的内核模块。原因是,树外构建 Linux 内核模块需要一个预构建或准备好的内核。

本地和树外模块编译

使用本地内核模块构建时,最简单的方法是安装预构建的内核头文件,并在 makefile 中将其目录路径作为内核目录。在我们开始这样做之前,可以使用以下命令安装头文件:

sudo apt update
sudo apt install linux-headers-$(uname -r)

这将安装预配置和预构建的内核头文件(不是整个源代码树)到/usr/src/linux-headers-$(uname -r)。将会有一个符号链接/lib/modules/$(uname -r)/build,指向之前安装的头文件。这个路径应该在Makefile中作为内核目录进行指定。你应该记住,$(uname -r)对应的是正在使用的内核版本。

现在,当你完成 makefile 后,仍然在模块源代码目录中,运行make命令或make modules

$ make
make -C /lib/modules/ 5.11.0-37-generic/build \
    M=/home/john/driver/helloworld modules
make[1]: Entering directory '/usr/src/linux-headers- 5.11.0-37-generic'
  CC [M]  /media/jma/DATA/work/tutos/sources/helloworld/helloworld.o
  Building modules, stage 2.
  MODPOST 1 modules
  CC      /media/jma/DATA/work/tutos/sources/helloworld/helloworld.mod.o
  LD [M]  /media/jma/DATA/work/tutos/sources/helloworld/helloworld.ko
make[1]: Leaving directory '/usr/src/linux-headers- 5.11.0-37-generic'

在构建结束时,你将获得以下内容:

$ ls
helloworld.c  helloworld.ko  helloworld.mod.c  helloworld.mod.o  helloworld.o  Makefile  modules.order  Module.symvers

测试时,你可以执行以下操作:

$ sudo insmod  helloworld.ko
$ sudo rmmod helloworld
$ dmesg
[...]
[308342.285157] Hello world initialization!
[308372.084288] Hello world exit

前面的示例仅处理本地构建,即在运行标准发行版的机器上编译,允许我们利用其包存储库安装预构建的内核头文件。在接下来的章节中,我们将讨论树外模块的交叉编译。

树外模块交叉编译

当涉及到交叉编译树外内核模块时,内核的make命令需要知道两个变量。这些是ARCHCROSS_COMPILE,分别代表目标架构和交叉编译器前缀。此外,必须在 makefile 中指定目标架构的预构建内核的位置。在我们的框架中,我们称其为KERNEL_SRC

在使用像 Yocto 这样的构建系统时,Linux 内核首先作为依赖项进行交叉编译,然后才开始交叉编译模块。也就是说,我自愿使用了KERNEL_SRC变量名来表示预构建内核目录,因为 Yocto 会自动为内核模块食谱导出这个变量。它在module.bbclass类中被设置为STAGING_KERNEL_DIR的值,该类被所有内核模块食谱继承。

也就是说,本地编译和树外内核模块交叉编译之间的区别在于最终的make命令,对于 32 位 Arm 架构,命令如下:

make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf-

对于 64 位变种,它看起来如下:

make ARCH=aarch64 CROSS_COMPILE=aarch64-linux-gnu-

之前的命令假设已经在 makefile 中指定了交叉编译的内核源路径。

树内构建

树内模块构建需要处理一个额外的文件Kconfig,该文件允许我们在配置菜单中公开模块特性。也就是说,在你能够在内核树中构建模块之前,你应该先确定应该在哪个目录中托管源文件。考虑到你的文件名是mychardev.c,它包含了你特定字符设备驱动程序的源代码,它应该被更改到内核源代码中的drivers/char目录。驱动程序中的每个子目录都有MakefileKconfig文件。

将以下内容添加到该目录的Kconfig文件中:

config PACKT_MYCDEV
    tristate "Our packtpub special Character driver"
    default m
    help
      Say Y here to support /dev/mycdev char device.
      The /dev/mycdev is used to access packtpub.

在该目录的Makefile中,添加以下行:

obj-$(CONFIG_PACKT_MYCDEV)   += mychardev.o

更新Makefile时要小心——.o文件名必须与.c文件的确切名称匹配。如果源文件是foobar.c,则必须在Makefile中使用foobar.o。为了将你的模块构建为可加载的内核模块,请在arch/arm/configs目录中的defconfig板文件中添加以下行:

CONFIG_PACKT_MYCDEV=m

你还可以运行menuconfig从 UI 中选择它,运行make来构建内核,然后运行make modules来构建模块(包括你的模块)。要将驱动程序构建为内核模块,只需将m替换为y

CONFIG_PACKT_MYCDEV=y

这里描述的内容是嵌入式板制造商为其板提供板级支持包BSP)时所做的工作,内核中已经包含了他们自定义的驱动程序:

https://github.com/OpenDocCN/freelearn-linux-pt5-zh/raw/master/docs/linux-dvc-dvr-dev-2e/img/#

](https://github.com/OpenDocCN/freelearn-linux-pt5-zh/raw/master/docs/linux-dvc-dvr-dev-2e/img/B17934_02_001.jpg)

图 2.1 – 内核树中的 Packt_dev 模块

配置完成后,你可以使用make构建内核,并使用make modules构建模块。

包含在内核源代码树中的模块安装在/lib/modules/$(uname -r)/kernel/目录下。在你的 Linux 系统中,它的路径是/lib/modules/$(uname -r)/kernel/

现在我们已经熟悉了树外或树内内核模块的编译,接下来让我们看看如何通过允许传递参数来调整模块的行为。

处理模块参数

与用户程序类似,内核模块可以从命令行接收参数。这使得我们可以根据给定的参数动态地改变模块的行为,这可以帮助开发者在测试/调试会话中不必反复修改和编译模块。为了设置这一点,我们应该首先声明将保存命令行参数值的变量,并对每个变量使用module_param()宏。该宏定义在include/linux/moduleparam.h中(代码中也应该包含此文件 – #include <linux/moduleparam.h>),如下所示:

module_param(name, type, perm);

此宏包含以下元素:

  • name:作为参数使用的变量名称。

  • type:参数的类型(boolcharpbyteshortushortintuintlongulong),其中charp表示字符指针

  • perm:表示/sys/module/<module>/parameters/<param>文件的权限。一些常见权限有S_IWUSRS_IRUSRS_IXUSRS_IRGRPS_WGRPS_IRUGO,其中适用如下:

    • S_I只是一个前缀。

    • R = 读取,W = 写入,X = 执行。

    • USR = 用户,GRP = 组,UGO = 用户、组和其他人。

你最终可以使用|(即OR操作)来设置多个权限。如果perm0,则不会创建 Sysfs 中的文件参数。你应该只使用S_IRUGO只读参数,我强烈推荐这样做;通过与其他属性进行OR操作,你可以获得更细粒度的属性。

在使用模块参数时,MODULE_PARM_DESC可以逐个参数进行使用,用于描述每个参数。此宏会填充每个参数描述的模块信息部分。以下是一个示例,来自本书代码仓库中提供的helloworld-params.c源文件:

#include <linux/moduleparam.h>
[...]
static char *mystr = "hello";
static int myint = 1;
static int myarr[3] = {0, 1, 2};
module_param(myint, int, S_IRUGO);
module_param(mystr, charp, S_IRUGO);
module_param_array(myarr, int,NULL, S_IWUSR|S_IRUSR);
MODULE_PARM_DESC(myint,"this is my int variable");
MODULE_PARM_DESC(mystr,"this is my char pointer variable");
MODULE_PARM_DESC(myarr,"this is my array of int");
static int foo()
{
    pr_info("mystring is a string: %s\n",
             mystr);
    pr_info("Array elements: %d\t%d\t%d",
             myarr[0], myarr[1], myarr[2]);
    return myint;
}

为了加载模块并传递我们的参数,我们执行以下操作:

# insmod hellomodule-params.ko mystring="packtpub" myint=15 myArray=1,2,3

也就是说,我们可以在加载模块之前使用modinfo来显示该模块支持的参数描述:

$ modinfo ./helloworld-params.ko 
filename:       /home/jma/work/tutos/sources/helloworld/./helloworld-params.ko
license:      GPL
author:       John Madieu <john.madieu@gmail.com>
srcversion:   BBF43E098EAB5D2E2DD78C0
depends:        
vermagic:     4.4.0-93-generic SMP mod_unload modversions 
parm:         myint:this is my int variable (int)
parm:         mystr:this is my char pointer variable (charp)
parm:         myarr:this is my array of int (array of int)

你也可以在/sys/module/<name>/parameters中的 Sysfs 中找到并编辑已加载模块的当前参数值。在该目录中,每个参数都有一个文件,文件中包含参数值。如果相关文件具有写权限,则可以更改这些参数值(这取决于模块代码)。

以下是一个示例:

echo 0 > /sys/module/usbcore/parameters/authorized_default

不仅仅是可加载的内核模块可以接受参数。只要模块是内核构建的一部分,你可以通过 Linux 内核命令行(由引导加载程序传递或由CONFIG_CMDLINE配置选项提供)为该模块指定参数。

其形式如下:

[initial command line ...] my_module.param=value

在此示例中,my_module对应于模块名称,value是分配给此参数的值。

既然我们能够处理模块参数,让我们深入探讨一个不太明显的场景,在这个场景中,我们将学习 Linux 内核本身及其构建系统如何处理模块依赖关系。

处理符号导出和模块依赖关系

只有有限数量的内核函数可以从内核模块中调用。为了让内核模块能够访问,函数和变量必须由内核显式导出。因此,Linux 内核提供了两个宏,用于导出函数和变量。它们分别是:

  • EXPORT_SYMBOL(symbolname):此宏将函数或变量导出给所有模块。

  • EXPORT_SYMBOL_GPL(symbolname):此宏仅将函数或变量导出给 GPL 模块。

EXPORT_SYMBOL()或其 GPL 版本是 Linux 内核宏,使符号对可加载内核模块或动态加载模块可用(前提是这些模块添加了extern声明——也就是说,包含了导出符号的编译单元的头文件)。EXPORT_SYMBOL()指示 Kbuild 机制将作为参数传递的符号包含在全局内核符号列表中。结果,内核模块可以访问这些符号。内核本身构建的代码(与可加载的内核模块相对)当然可以通过extern声明访问任何非静态符号,就像传统的 C 代码一样。

这些宏还允许我们从可加载的内核模块中导出符号,这些符号可以从其他可加载的内核模块访问。有趣的是,一个模块导出的符号会变得对另一个可能依赖于它的模块可访问!正常的驱动程序不应该需要任何未导出的函数。

模块依赖关系概述

模块 B 对模块 A 的依赖关系是,模块 B 使用了模块 A 导出的一个或多个符号。接下来我们将在下一节中查看 Linux 内核基础设施如何处理此类依赖关系。

depmod 工具

depmod是一个工具,你可以在内核构建过程中运行它来生成模块依赖文件。它通过读取/lib/modules/<kernel_release>/中的每个模块,确定应该导出哪些符号,以及需要哪些符号。该过程的结果会写入modules.dep文件及其二进制版本modules.dep.bin。这是一种模块索引。

模块加载和卸载

为了使一个模块能够正常运行,您应该将其加载到 Linux 内核中,您可以使用insmod并将模块路径作为参数传递,这是开发过程中首选的方法,或者使用modprobe,这是一个巧妙的命令,但在生产系统中更为推荐使用。

手动加载

手动加载需要用户干预,该用户应具有root权限。实现此目的的两种经典方法是modprobeinsmod,其具体描述如下。

在开发过程中,通常使用insmod来加载模块。insmod应该接收要加载的模块的路径,如下所示:

insmod /path/to/mydrv.ko

这是模块加载的低级形式,它构成了其他模块加载方法的基础,也是本书中将要使用的方法。另一方面,modprobe主要由系统管理员或生产系统中使用。modprobe是一个巧妙的命令,它解析modules.dep文件(前面已经讨论过),以便先加载依赖项,然后再加载给定的模块。它像包管理器一样自动处理模块依赖关系。其调用方式如下:

modprobe mydrv

是否能够使用modprobe取决于depmod是否能识别模块的安装。

自动加载

depmod工具不仅仅构建modules.depmodules.dep.bin文件;它的功能远不止此。当内核开发人员编写驱动程序时,他们清楚地知道驱动程序将支持哪些硬件。接着,他们负责为驱动程序提供所有受支持设备的产品 ID 和供应商 ID。depmod还处理模块文件,以提取和收集这些信息,并生成modules.alias文件,该文件位于/lib/modules/<kernel_release>/modules.alias,它将设备与其驱动程序进行映射:

modules.alias的摘录如下:

alias usb:v0403pFF1Cd*dc*dsc*dp*ic*isc*ip*in* ftdi_sio
alias usb:v0403pFF18d*dc*dsc*dp*ic*isc*ip*in* ftdi_sio
alias usb:v0403pDAFFd*dc*dsc*dp*ic*isc*ip*in* ftdi_sio
alias usb:v0403pDAFEd*dc*dsc*dp*ic*isc*ip*in* ftdi_sio
alias usb:v0403pDAFDd*dc*dsc*dp*ic*isc*ip*in* ftdi_sio
alias usb:v0403pDAFCd*dc*dsc*dp*ic*isc*ip*in* ftdi_sio
[...]

在这一步,您需要一个用户空间的热插拔代理(或设备管理器),通常是udev(或mdev),它将注册到内核以在新设备出现时接收通知。

通知是由内核完成的,它将设备的描述(产品 ID、供应商 ID、类别、设备类别、设备子类别、接口以及任何其他可以识别设备的信息)发送给热插拔守护进程,该进程根据这些信息调用modprobe。然后,modprobe解析modules.alias文件,以匹配与设备相关的驱动程序。在加载模块之前,modprobe会查找module.dep中的依赖项。如果发现任何依赖项,它们将在关联模块加载之前加载;否则,模块将直接加载。

还有一种方法可以在启动时自动加载模块。这可以通过/etc/modules-load.d/<filename>.conf来实现。如果你希望在启动时加载某些模块,只需创建一个/etc/modules-load.d/<filename>.conf文件,并按行添加应该加载的模块名称。<filename>对你来说是有意义的,通常人们使用module/etc/modules-load.d/modules.conf。你可以根据需要创建任意数量的.conf文件。

/etc/modules-load.d/mymodules.conf的示例如下:

#This line is a comment
uio
iwlwifi

这些配置文件由systemd-modules-load.service处理,前提是systemd是你机器上的初始化管理器。在SysVinit系统中,这些文件由/etc/init.d/kmod脚本处理。

模块卸载

卸载模块的常用命令是rmmod。这比卸载使用insmod命令加载的模块更为合适。该命令应该传入要卸载的模块名称作为参数:

rmmod -f mymodule

另一方面,用于智能卸载模块的更高层命令是modeprobe –r,它会自动卸载未使用的依赖:

modeprobe -r mymodule

正如你可能猜到的,这对开发人员来说是一个有用的选项。最后,我们可以通过lsmod命令检查模块是否已加载,命令如下:

$ lsmod
Module                  Size  Used by
btrfs                1327104  0
blake2b_generic        20480  0
xor                    24576  1 btrfs
raid6_pq              114688  1 btrfs
ufs                    81920  0
[...]

输出包括模块的名称、使用的内存量、其他依赖于它的模块数量,最后是这些模块的名称。lsmod的输出实际上是对/proc /modules下可见文件的一种良好格式化视图,这是列出已加载模块的文件:

$ cat /proc/modules 
btrfs 1327104 0 - Live 0x0000000000000000
blake2b_generic 20480 0 - Live 0x0000000000000000
xor 24576 1 btrfs, Live 0x0000000000000000
raid6_pq 114688 1 btrfs, Live 0x0000000000000000
ufs 81920 0 - Live 0x0000000000000000
qnx4 16384 0 - Live 0x0000000000000000

上述输出是原始且格式较差的,因此更建议使用lsmod

现在我们熟悉了内核模块管理,让我们通过学习内核开发人员采用的一些技巧,进一步提升我们的内核开发技能。

学习一些 Linux 内核编程技巧

Linux 内核开发是从别人那里学习,而不是重新发明轮子。当进行内核开发时,有一套规则需要遵循。单独一章内容不足以涵盖这些规则。因此,我挑选了两条对我来说最为相关的规则,它们在进行用户空间编程时可能会发生变化:错误处理和消息打印。

在用户空间,退出main()方法足以恢复所有可能发生的错误。而在内核中,情况并非如此,尤其是它直接处理硬件。消息打印方面也有所不同,我们将在本节中详细探讨。

错误处理

对于给定错误返回错误代码不正确可能导致内核或用户空间应用程序误解并做出错误决策,从而产生不必要的行为。为了保持清晰,内核树中预定义了几乎涵盖你可能遇到的每种情况的错误。一些错误(及其含义)定义在include/uapi/asm-generic/errno-base.h中,其他的列表可以在include/uapi/asm-generic/errno.h中找到。以下是该错误列表的摘录,来自include/uapi/asm-generic/errno-base.h

#define  EPERM    1    /* Operation not permitted */
#define  ENOENT   2    /* No such file or directory */
#define  ESRCH    3    /* No such process */
#define  EINTR    4    /* Interrupted system call */
#define  EIO      5    /* I/O error */
#define  ENXIO    6    /* No such device or address */
#define  E2BIG    7    /* Argument list too long */
#define  ENOEXEC  8    /* Exec format error */
#define  EBADF    9    /* Bad file number */
#define  ECHILD   10   /* No child processes */
#define  EAGAIN   11   /* Try again */
#define  ENOMEM   12   /* Out of memory */
#define  EACCES   13   /* Permission denied */
#define  EFAULT   14   /* Bad address */
#define  ENOTBLK  15   /* Block device required */
#define  EBUSY    16   /* Device or resource busy */
#define  EEXIST   17   /* File exists */
#define  EXDEV    18   /* Cross-device link */
#define  ENODEV   19   /* No such device */
[...]

大多数情况下,返回错误的标准方式是以return –ERROR的形式,特别是在回答系统调用时。例如,对于 I/O 错误,错误代码是EIO,你应该返回-EIO,如下所示:

dev = init(&ptr);
if(!dev)
    return –EIO

错误有时会跨越内核空间并传播到用户空间。如果返回的错误是对系统调用(openreadioctlmmap)的响应,值将自动赋给用户空间的errno全局变量,之后你可以使用strerror(errno)将错误翻译为可读的字符串:

#include <errno.h>  /* to access errno global variable */
#include <string.h>
[...]
if(wite(fd, buf, 1) < 0) {
    printf("something gone wrong! %s\n", strerror(errno));
}
[...]

当遇到错误时,你必须撤销在错误发生之前设置的所有内容。通常的做法是使用goto语句:

ret = 0;
ptr = kmalloc(sizeof (device_t));
if(!ptr) {
        ret = -ENOMEM
        goto err_alloc;
}
dev = init(&ptr);
if(!dev) {
        ret = -EIO
        goto err_init;
}
return 0;
err_init:
        free(ptr);
err_alloc:
        return ret;

使用goto语句的原因很简单。当处理错误时,假设在步骤 5,你需要清理之前的操作(步骤 4321),而不是做大量的嵌套检查操作,如下所示:

if (ops1() != ERR) {
    if (ops2() != ERR) {
        if (ops3() != ERR) {
            if (ops4() != ERR) {

这样做可读性差,容易出错且让人困惑(可读性还依赖于缩进)。通过使用goto语句,我们可以获得直接的控制流,如下所示:

if (ops1() == ERR) // ||
    goto error1;   // ||
if (ops2() == ERR) // ||
    goto error2;   // ||
if (ops3() == ERR) // ||
    goto error3;   // ||
if (ops4() == ERR) // VV
    goto error4;
error5:
[...]
error4:
[...]
error3:
[...]
error2:
[...]
error1:
[...]

也就是说,你应该只在函数中向前使用goto,而不是向后使用,也不要用它来实现循环(就像在汇编语言中那样)。

处理空指针错误

当谈到从应返回指针的函数中返回错误时,函数通常返回NULL指针。这是有效的,但这种方法相当没有意义,因为我们并不完全知道为何返回这个NULL指针。为此,内核提供了三个函数,ERR_PTRIS_ERRPTR_ERR,其定义如下:

void *ERR_PTR(long error);
long IS_ERR(const void *ptr);
long PTR_ERR(const void *ptr);

第一个宏将错误值作为指针返回。可以将其视为错误值到指针的宏。给定一个在内存分配失败后可能返回-ENOMEM的函数,我们必须做类似return ERR_PTR(-ENOMEM);的操作。第二个宏用于检查返回值是否是指针错误,通过if(IS_ERR(foo))进行判断。最后一个宏返回实际的错误代码,return PTR_ERR(foo)。可以将其视为指针到错误值的宏。

以下是如何使用ERR_PTRIS_ERRPTR_ERR的示例:

static struct iio_dev *indiodev_setup(){
    [...]
    struct iio_dev *indio_dev;
    indio_dev = devm_iio_device_alloc(&data->client->dev,
                                      sizeof(data));
    if (!indio_dev)
        return ERR_PTR(-ENOMEM);
    [...]
    return indio_dev;
}
static int foo_probe([...]){
    [...]
    struct iio_dev *my_indio_dev = indiodev_setup();
    if (IS_ERR(my_indio_dev))
        return PTR_ERR(data->acc_indio_dev);
    [...]
}

这是一个与错误处理相关的优点,它也摘自内核编码风格,规定如果函数名称是一个动作或命令式命令,该函数应返回一个整数错误代码。然而,如果函数名称是一个谓词,那么该函数应返回一个布尔值,表示操作的成功状态。

例如,Add work是一个命令,因此add_work()函数返回0表示成功,或返回-EBUSY表示失败。PCI device present是一个谓词,正因如此,pci_dev_present()函数在成功找到匹配设备时返回1,如果未找到则返回0

消息打印 – 告别 printk,长存 dev_, pr_ 和 net_* API

除了通知用户正在发生什么,打印是最初的调试技术。printk()对于内核而言,正如printf()对于用户空间一样。printk()长期以来一直以分级方式主导着内核消息的打印。编写的消息可以通过dmesg命令显示。根据消息打印的重要性,printk()允许你在include/linux/kern_levels.h中选择八个日志级别消息,并附带其含义。

如今,虽然printk()仍然是低级消息打印 API,但 printk/日志级别对已被编码成明确命名的辅助函数,并且推荐在新驱动中使用。它们如下所示:

  • pr_<level>(...):此函数用于非设备驱动的常规模块。

  • dev_<level>(struct device *dev, ...):此函数用于非网络设备的设备驱动(也称为netdev驱动)。

  • netdev_<level>(struct net_device *dev, ...):此函数仅在netdev驱动中使用。

在所有这些辅助函数中,<level>表示编码成具有相当有意义名称的日志级别,如下表所示:

https://github.com/OpenDocCN/freelearn-linux-pt5-zh/raw/master/docs/linux-dvc-dvr-dev-2e/img/B17934_02_Table_1.jpg

表 2.1 – Linux 内核打印 API

日志级别的工作原理是,每当打印消息时,内核会将该消息的日志级别与当前的控制台日志级别进行比较;如果前者的级别更高(数值更低),则该消息将立即打印到控制台。你可以通过以下命令检查你的日志级别参数:

cat /proc/sys/kernel/printk
4        4         1        7

在上述输出中,第一个值是当前的日志级别(4)。根据这一点,任何以更高重要性(更低的日志级别)打印的消息也会在控制台显示。第二个值是默认的日志级别,依据CONFIG_DEFAULT_MESSAGE_LOGLEVEL选项。其他值与本章的目的无关,因此我们可以忽略它们。

当前日志级别可以通过以下命令更改:

echo <level> > /proc/sys/kernel/printk

此外,你可以通过自定义字符串来为模块的输出消息添加前缀。要实现这一点,你需要定义pr_fmt宏。通常会使用模块名称来定义该消息前缀,如下所示:

#define pr_fmt(fmt) KBUILD_MODNAME ": " fmt

为了获得更简洁的日志输出,一些重写函数使用当前函数名称作为前缀,如下所示:

#define pr_fmt(fmt) "%s: " fmt, __func__

如果我们考虑内核源代码树中的net/bluetooth/lib.c文件,可以看到第一行有以下内容:

#define pr_fmt(fmt) "Bluetooth: " fmt

有了这一行,任何pr_<level>(我们在一个常规模块中,而不是设备驱动程序)日志调用都会生成一个以Bluetooth:为前缀的日志,类似于以下内容:

$ dmesg | grep Bluetooth
[ 3.294445] Bluetooth: Core ver 2.22
[ 3.294458] Bluetooth: HCI device and connection manager initialized
[ 3.294460] Bluetooth: HCI socket layer initialized
[ 3.294462] Bluetooth: L2CAP socket layer initialized
[ 3.294465] Bluetooth: SCO socket layer initialized
[...]

这一切都是关于消息打印的。我们已经学习了如何根据情况选择和使用合适的打印 API。

我们现在已经完成了内核模块介绍系列。在这个阶段,你应该能够下载、配置并(交叉)编译 Linux 内核,以及编写和构建针对该内核的内核模块。

注意

printk()(或其编码的辅助函数)永远不会阻塞,并且足够安全,即使在原子上下文中也可以调用。它尝试锁定控制台并打印消息。如果锁定失败,输出将被写入缓冲区,函数将返回,永不阻塞。当前控制台持有者随后会被通知到有新消息,并在释放控制台之前打印这些消息。

总结

本章介绍了驱动开发的基础知识,并解释了内置内核模块和可加载内核模块的概念,以及它们的加载与卸载。即使你不能与用户空间进行交互,你也已经准备好编写一个工作模块,打印格式化的消息,并理解init/exit的概念。

下一章将讨论 Linux 内核核心功能,它与本章一起构成了 Linux 内核开发的瑞士军刀。在下一章中,你将能够针对增强功能,执行可能影响系统的复杂操作,并与 Linux 内核的核心进行交互。

第三章:第三章:处理内核核心助手

Linux 内核是一个独立的软件——正如本章所述——它不依赖任何外部库,而是实现了它所需的所有功能(从列表管理到压缩算法,所有内容都从零开始实现)。它实现了现代库中可能遇到的所有机制,甚至更多,例如压缩、字符串函数等。我们将一步一步地探讨这些功能的最重要方面。

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

  • Linux 内核锁机制和共享资源

  • 处理内核等待、休眠和延迟机制

  • 理解 Linux 内核时间管理

  • 实现工作延迟机制

  • 内核中断处理

Linux 内核锁机制和共享资源

当多个竞争者可以访问一个资源时(无论是否排他性),这个资源就被视为共享资源。当资源是排他性时,访问必须同步,以确保只有被允许的竞争者能够拥有该资源。这些资源可能是内存位置或外部设备,而竞争者可能是处理器、进程或线程。操作系统通过原子修改一个存储资源当前状态的变量来实现互斥,使得所有可能同时访问该变量的竞争者都能看到这种变化。原子性保证了修改要么完全成功,要么完全失败。现代操作系统通常依赖硬件(它应支持原子操作)来实现同步,尽管一个简单的系统也可以通过禁用中断(避免调度)来确保临界代码段的原子性。

我们可以列举出两种同步机制,如下所示:

  • :用于互斥。当一个竞争者持有锁时,其他竞争者不能持有该锁(其他人被排除在外)。内核中最常见的锁是自旋锁和互斥锁。

  • 条件变量:用于等待某个变化。内核中对此的实现方式不同,稍后我们将看到。

在谈到锁机制时,硬件通过原子操作提供这种同步功能,内核利用这些原子操作来实现锁设施。同步原语是用来协调对共享资源访问的数据结构。因为只有一个竞争者可以持有锁(从而访问共享资源),所以它可以对与锁相关的资源执行任意操作,这对其他竞争者而言是原子的。

除了处理给定共享资源的独占所有权外,还有一些情况更适合等待资源状态的变化——例如,等待一个列表包含至少一个对象(其状态从空变为非空),或者等待任务完成(例如,直接内存访问DMA)事务)。Linux 内核没有实现条件变量。在用户空间,我们可以考虑为这两种情况使用条件变量,但为了实现相同或更好的效果,内核提供了以下机制:

  • 等待队列:等待状态变化——旨在与锁一起工作

  • 完成队列:等待给定计算的完成,主要与 DMA 一起使用

所有上述机制都由 Linux 内核支持,并通过一组简化的应用程序编程接口API)暴露给驱动程序(这大大简化了开发人员的使用),我们将在接下来的章节中讨论这些内容。

自旋锁

自旋锁是一种基于硬件的锁定原语,它依赖硬件能力提供原子操作(例如 test_and_set,在非原子实现中会导致读取、修改和写入操作)。它是最简单的基础锁定原语,按以下场景描述的方式工作。

CPUB正在运行,并且任务 B 想要获取自旋锁(任务 B 调用自旋锁的锁定函数),而该自旋锁已经被另一个 while 循环占用(因此阻塞任务 B),直到另一个 CPU 释放锁(任务 A 调用自旋锁的释放函数)。这种自旋只会发生在多核机器上(因此前面描述的用例涉及多个 CPU),因为在单核机器上不会发生(任务要么持有自旋锁并继续执行,要么一直不运行直到锁被释放)。自旋锁被认为是由 CPU 持有的锁,与互斥锁(我们将在本章的下一节讨论)不同,互斥锁是由任务持有的锁。

自旋锁通过禁用本地 CPU 上的调度程序来工作(即运行调用自旋锁锁定 API 的任务的 CPU)。这也意味着,当前在该 CPU 上运行的任务不能被抢占,除非通过中断请求IRQ),前提是它们在本地 CPU 上没有被禁用(稍后会详细讨论)。换句话说,自旋锁保护的是每次只能被一个 CPU 获取/访问的资源。这使得自旋锁适用于对称多处理SMP)安全性和执行原子任务。

注意

自旋锁不仅利用硬件原子操作功能。在 Linux 内核中,例如,抢占状态取决于每个 CPU 的一个变量,如果该变量等于0,则表示抢占已启用;如果大于 0,则表示抢占已禁用(schedule() 变得无效)。因此,禁用抢占(preempt_disable())是通过将当前每个 CPU 变量(实际上是 preempt_count)加 1 来实现的,而 preempt_enable() 则从该变量中减去 1,检查新值是否为 0,并调用 schedule()。这些加减操作是原子的,因此依赖于 CPU 能提供原子加减功能。

自旋锁可以通过静态方式使用 DEFINE_SPINLOCK 宏创建,如此处所示,或者通过在未初始化的自旋锁上调用 spin_lock_init() 以动态方式创建:

static DEFINE_SPINLOCK(my_spinlock);

要了解这个是如何工作的,我们必须查看include/linux/spinlock_types.h中这个宏的定义,如下所示:

#define DEFINE_SPINLOCK(x) spinlock_t x = \
                                 __SPIN_LOCK_UNLOCKED(x)

这可以如下使用:

static DEFINE_SPINLOCK(foo_lock);

之后,可以通过自旋锁的名称 foo_lock 来访问它,其地址为 &foo_lock

然而,对于动态(运行时)分配,最好将自旋锁嵌入到一个更大的结构中,为该结构分配内存,然后在自旋锁元素上调用 spin_lock_init(),如下代码片段所示:

struct bigger_struct {
    spinlock_t lock;
    unsigned int foo;
    [...]
};
static struct bigger_struct *fake_init_function()
{
    struct bigger_struct *bs;
    bs = kmalloc(sizeof(struct bigger_struct), GFP_KERNEL);
    if (!bs)
        return -ENOMEM;
    spin_lock_init(&bs->lock);
    return bs;
}

尽可能使用 DEFINE_SPINLOCK 是更好的选择。它提供了编译时初始化,并且需要更少的代码行,且没有实际的缺点。在这一步中,我们可以使用 spin_lock()spin_unlock() 内联函数来锁定/解锁自旋锁,这些函数都定义在 include/linux/spinlock.h 中,如下所示:

static __always_inline void spin_unlock(spinlock_t *lock)
static __always_inline void spin_lock(spinlock_t *lock)

尽管如此,以这种方式使用自旋锁是有已知的限制的。虽然自旋锁可以防止本地 CPU 上的抢占,但它并不能防止 CPU 被中断占用(从而执行中断处理程序)。假设有这样一种情况,CPU 代表任务 A 持有一个“自旋锁”以保护某个资源,并且发生了一个中断。CPU 会停止当前的任务并跳转到该中断处理程序。到此为止,情况良好。现在,假设这个 IRQ 处理程序需要获取同一个自旋锁(你可能已经猜到,资源与中断处理程序是共享的)。它会无限期地自旋,试图获取一个已经被任务所锁住的锁,这个任务正是它被抢占的任务。这种情况肯定会导致死锁。

为了解决这个问题,Linux 内核为自旋锁提供了 _irq 变体函数,这些函数除了禁用/启用抢占外,还禁用/启用本地 CPU 上的中断。这些函数是 spin_lock_irq()spin_unlock_irq(),定义如下:

static void spin_unlock_irq(spinlock_t *lock)
static void spin_lock_irq(spinlock_t *lock)

我们可能认为这个解决方案足够了,但实际上并非如此。_irq 变种部分解决了这个问题。假设在代码开始加锁之前,处理器上的中断已经被禁用;当你调用 spin_unlock_irq() 时,不仅会释放锁,还会启用中断,但这可能是错误的,因为 spin_unlock_irq() 无法知道在加锁之前哪些中断是被启用的,哪些没有。

让我们考虑以下示例:

  • 假设在获取自旋锁之前,xy 中断已经被禁用,而 z 并没有被禁用。

  • spin_lock_irq() 会禁用中断(此时 xyz 都被禁用)并获取锁。

  • spin_unlock_irq() 将会启用中断。此时,xyz 都被启用了,而在获取锁之前并非如此。问题就在这里。

这使得在 IRQ 上下文外调用 spin_lock_irq() 时变得不安全,因为它的对应函数 spin_unlock_irq() 会愚蠢地启用中断,存在启用那些在 spin_lock_irq() 调用时未启用的中断的风险。因此,只有在你确认中断已经启用时,才应该使用 spin_lock_irq()——也就是说,你确定没有其他操作禁用了本地 CPU 上的中断。

现在,假设你在获取锁之前将中断状态保存在一个变量中,并在释放锁时精确地恢复它们,那么就不会再有任何问题。为了实现这一点,内核提供了 _irqsave 变种函数,它们的行为与 _irq 函数完全相同,此外还具备保存和恢复中断状态的功能。它们是 spin_lock_irqsave()spin_lock_irqrestore(),定义如下:

spin_lock_irqsave(spinlock_t *lock, unsigned long flags)
spin_unlock_irqrestore(spinlock_t *lock, unsigned long flags)

注意

spin_lock() 及其所有变种会自动调用 preempt_disable(),禁用本地 CPU 上的抢占,而 spin_unlock() 及其变种会调用 preempt_enable(),尝试启用抢占(是的—是尝试!!!这取决于是否有其他自旋锁被锁定,这将影响抢占计数器的值),并且在启用时会内部调用 schedule()(具体取决于当前计数器的值,当前值应该是 0)。因此,spin_unlock() 是一个抢占点,可能会重新启用抢占。

禁用抢占与禁用中断

尽管禁用中断可能会防止内核的抢占(调度器滴答禁用),但没有什么能阻止受保护的代码段调用调度器(schedule()函数)。许多内核函数间接调用调度器,例如那些处理自旋锁的函数。因此,即使是一个简单的printk()函数也可能调用调度器,因为它处理保护内核消息缓冲区的自旋锁。内核通过增加或减少一个名为preempt_count的内核全局变量和每个 CPU 的变量(默认为0,表示启用)来禁用或启用调度器(从而禁用或启用抢占)。当该变量大于0时(通过schedule()函数检查),调度器会直接返回并不执行任何操作。每次调用spin_lock*系列函数时,该变量会递增。另一方面,释放自旋锁(任何spin_unlock*系列函数)时,它会从1递减,并且当它的值达到0时,调度器会被调用,这意味着你的临界区并非完全原子化。

因此,仅禁用中断能保护你免受内核抢占,仅在受保护的代码段没有触发抢占时有效。也就是说,锁定自旋锁的代码可能无法休眠,因为无法唤醒它(记住——本地 CPU 上的定时器中断和/或调度器是禁用的)。

互斥锁

互斥锁是我们本章讨论的第二种也是最后一种锁定原语。它的行为完全像自旋锁,唯一的区别是你的代码可以休眠。如果你尝试锁定一个已被另一个任务持有的互斥锁,你的任务将被挂起,并且只有在互斥锁被释放时才会被唤醒。这次没有自旋,也就是说在你的任务处于休眠状态时,CPU 可以做其他事情。正如我之前提到的,自旋锁是由 CPU 持有的锁。而互斥锁,则是由任务持有的锁。

**互斥锁(mutex)**是一个简单的数据结构,包含一个等待队列(用于将竞争者置于休眠状态)和一个自旋锁,用于保护对该等待队列的访问,具体如下代码片段所示:

struct mutex {
    atomic_long_t owner;
    spinlock_t wait_lock;
#ifdef CONFIG_MUTEX_SPIN_ON_OWNER
    struct optimistic_spin_queue osq; /* Spinner MCS lock */
#endif
    struct list_head wait_list;
[...]
};

在前面的代码片段中,为了提高可读性,已移除仅在调试模式下使用的元素。不过,正如我们所见,互斥锁是建立在自旋锁之上的。owner表示持有(拥有)锁的进程。wait_list是互斥锁的竞争者被置于休眠状态的队列。wait_lock是保护wait_list操作(移除或插入竞争者)自旋锁。它有助于在 SMP 系统上保持wait_list的一致性。

互斥锁的 API 可以在include/linux/mutex.h头文件中找到。在获取和释放互斥锁之前,必须对其进行初始化。与其他内核核心数据结构一样,互斥锁有一个静态初始化,如下所示:

static DEFINE_MUTEX(my_mutex);

下面是DEFINE_MUTEX()宏的定义:

#define DEFINE_MUTEX(mutexname) \
struct mutex mutexname = __MUTEX_INITIALIZER(mutexname)

内核提供的第二种方法是动态初始化,得益于调用__mutex_init()低级函数,实际上它被一个更用户友好的宏mutex_init()包装。你可以在以下代码片段中看到它的应用:

struct fake_data {
    struct i2c_client *client;
    u16 reg_conf;
    struct mutex mutex;
};
static int fake_probe(struct i2c_client *client)
{
[...]
    mutex_init(&data->mutex);
[...]
}

获取(即锁定)互斥锁就像调用以下三个函数中的一个一样简单:

void mutex_lock(struct mutex *lock);
int mutex_lock_interruptible(struct mutex *lock);
int mutex_lock_killable(struct mutex *lock);

如果互斥锁是空闲的(未锁定),你的任务将立即获取它,而无需进入睡眠状态。否则,任务将进入睡眠状态,具体取决于你使用的锁定函数。使用mutex_lock()时,任务将进入不可中断的睡眠状态(TASK_UNINTERRUPTIBLE),等待互斥锁被释放(如果它被另一个任务持有)。mutex_lock_interruptible()会将任务放入可中断的睡眠状态,在这种状态下,任何信号都可以打断睡眠。mutex_lock_killable()则只允许通过实际杀死任务的信号来打断睡眠。每个函数在成功获取锁时返回0。此外,可中断的变种会在锁定尝试被信号中断时返回-EINTR

无论使用哪种锁定函数,互斥锁的拥有者(并且只有拥有者)应使用mutex_unlock()来释放互斥锁,定义如下:

void mutex_unlock(struct mutex *lock);

如果需要检查互斥锁的状态,可以使用mutex_is_locked(),如下所示:

static bool mutex_is_locked(struct mutex *lock)

该函数仅检查互斥锁的拥有者是否为NULL,如果是,则返回true,否则返回false

注意

推荐仅在能够保证互斥锁不会被持有很长时间时使用mutex_lock()。否则,应使用可中断的变种。

使用互斥锁时有一些特定的规则。最重要的规则列在include/linux/mutex.h内核互斥锁 API 头文件中,其中一些规则在这里进行了概述:

  • 互斥锁一次只能由一个任务持有。

  • 一旦被持有,互斥锁只能由拥有者解锁(即锁定它的任务)。

  • 不允许多次、递归或嵌套的锁/解锁操作。

  • 互斥锁对象必须通过 API 进行初始化。它不能通过复制或使用memset进行初始化,就像持有的互斥锁不能重新初始化一样。

  • 持有互斥锁的任务不能退出,就像持有锁的内存区域必须不被释放一样。

  • 互斥锁不能在硬件或软件中断上下文中使用,例如任务块和定时器。

所有这些使得互斥锁适用于以下情况:

  • 仅在用户上下文中进行锁定

  • 如果受保护的资源不是从 IRQ 处理程序访问的,并且操作不需要原子性

然而,对于非常小的关键部分来说,使用自旋锁可能更加节省(在 CPU 周期方面),因为自旋锁仅挂起调度程序并开始自旋,而使用互斥锁的成本则要高得多,因为它需要挂起当前任务并将其插入互斥锁的等待队列,需要调度程序切换到另一个任务,并在互斥锁释放后重新安排休眠任务。

尝试锁定方法

有些情况下,我们可能只需要在其他地方没有其他竞争者持有锁时才获取锁。这样的方法试图获取锁,并立即(如果我们使用自旋锁则不会自旋,如果使用互斥锁则不会休眠)返回状态值,显示锁是否已成功锁定。

自旋锁和互斥锁 API 都提供了尝试锁定的方法。它们分别是 spin_trylock()mutex_trylock(),您可以在此处看到后者的示例。这两种方法在失败时(锁已被锁定)返回 0,成功时返回 1。因此,结合 if 语句使用这些函数是有意义的:

int mutex_trylock(struct mutex *lock)

spin_trylock() 实际上针对自旋锁。如果自旋锁尚未被锁定,它将锁定自旋锁,就像 spin_lock() 方法一样。然而,在自旋锁已被锁定的情况下,它会立即返回 0 而不进行自旋。您可以在这里看到它的作用:

static DEFINE_SPINLOCK(foo_lock);
[...]
static void foo(void)
{
    [...]
    if (!spin_trylock(&foo_lock)) {
        /* Failure! the spinlock is already locked */
        [...]
        return;
    }
    /*
    * reaching this part of the code means that the
    * spinlock has been successfully locked
    */
    [...]
    spin_unlock(&foo_lock);
    [...]
}

另一方面,mutex_trylock() 针对互斥锁。如果互斥锁尚未被锁定,它将锁定互斥锁,就像 mutex_lock() 方法一样。然而,在互斥锁已被锁定的情况下,它会立即返回 0 而不休眠。您可以在以下代码片段中看到其示例:

static DEFINE_MUTEX(bar_mutex);
[...]
static void bar (void)
{
    [...]
    if (!mutex_trylock(&bar_mutex)){
        /* Failure! the mutex is already locked */
        [...]
        return;
    }
    /*
     * reaching this part of the code means that the
     * mutex has been successfully acquired
     */
    [...]
    mutex_unlock(&bar_mutex);
    [...]
}

在上述摘录中,mutex_trylock()if 语句一起使用,以便驱动程序可以调整其行为。

现在我们已经完成了尝试锁定的变体,让我们转向一个完全不同的概念——学习如何显式延迟执行。

处理内核等待、休眠和延迟机制

本节中的睡眠一词指的是一种机制,任务(代表正在运行的内核代码)自愿释放处理器,可能会调度另一个任务。简单的睡眠是指任务进入睡眠状态,并在给定时间后被唤醒(例如,主动延迟操作),但也有基于外部事件(如数据可用性)的睡眠机制。简单睡眠通过内核中的专用 API 实现;从这种睡眠中醒来是隐式的(由内核自己处理),在持续时间到期后进行。另一种睡眠机制是基于事件的,唤醒过程是显式的(另一个任务必须基于条件显式唤醒我们,否则我们将永远处于睡眠状态),除非指定了睡眠超时。该机制在内核中通过等待队列的概念实现。也就是说,睡眠 API 和等待队列都实现了我们可以称之为被动等待的功能。两者的区别在于唤醒过程的实现方式。

等待队列

内核调度器管理一个待执行任务的列表(处于TASK_RUNNING状态的任务),称为运行队列。另一方面,处于睡眠状态的任务,无论是可中断的还是不可中断的(处于TASK_INTERRUPTIBLETASK_UNINTERRUPTIBLE状态),都有自己的队列,称为等待队列。

等待队列是一个更高级的机制,主要用于处理阻塞true,等待某个事件发生,或感知数据或资源的可用性。为了理解它们是如何工作的,我们可以查看以下include/linux/wait.h中的结构:

struct wait_queue_head {
    spinlock_t lock;
    struct list_head head;
};

等待队列就是一个列表(其中包含等待唤醒的睡眠进程)和一个自旋锁,用于保护对该列表的访问。当多个进程希望在等待一个或多个事件发生以便被唤醒时,可以使用等待队列。头成员实际上是一个等待事件发生的进程列表。每个希望在等待事件发生时进入睡眠的进程都会在睡眠前将自己加入该列表。进程在列表中时,称为等待队列条目。当事件发生时,列表中的一个或多个进程被唤醒并从列表中移除。

我们可以通过两种方式声明和初始化一个等待队列。第一种方法是静态使用DECLARE_WAIT_QUEUE_HEAD,如下所示:

DECLARE_WAIT_QUEUE_HEAD(my_event);

另外,我们也可以动态使用init_waitqueue_head(),如下所示:

wait_queue_head_t my_event;
init_waitqueue_head(&my_event);

任何希望在等待my_event发生时睡眠的进程,都可以调用wait_event_interruptible()wait_event()。大多数情况下,事件只是资源变得可用,因此进程只有在首次检查到资源可用性之后,才会进入睡眠状态。为了简化操作,这些函数都接受一个表达式作为第二个参数,使得进程只有在该表达式评估为false时才会进入睡眠状态,如下方代码片段所示:

wait_event(&my_event, (event_occured == 1));
/* or */
wait_event_interruptible(&my_event, (event_occured == 1));

wait_event()wait_event_interruptible()在调用时仅评估条件。如果条件为false,则进程会进入TASK_UNINTERRUPTIBLETASK_INTERRUPTIBLE(对于_interruptible变种)状态,并从运行队列中移除。

注意

wait_event()将进程置于独占等待状态,也就是不可中断的睡眠,因此不能被信号中断。它应仅用于关键任务。在大多数情况下,建议使用可中断函数。

在某些情况下,你可能不仅需要条件为true,还需要在特定的等待时间后超时。你可以使用wait_event_timeout()来处理这种情况,其原型如下所示:

wait_event_timeout(wq_head, condition, timeout)

这个函数有两种行为,取决于是否已超时。这些行为在此列出:

  • 0表示条件评估为false1表示条件评估为true

  • true

超时的时间单位是 jiffy。有一些便捷的 API 可以将常用的时间单位(如毫秒和微秒)转换为 jiffy,定义如下:

unsigned long msecs_to_jiffies(const unsigned int m)
unsigned long usecs_to_jiffies(const unsigned int u)

在任何可能影响等待条件结果的变量变化后,你必须调用相应的wake_up*系列函数。也就是说,为了唤醒一个在等待队列中休眠的进程,你应该调用wake_up()wake_up_all()wake_up_interruptible()wake_up_interruptible_all()。每当你调用这些函数时,条件会重新评估。如果此时条件为true,那么在等待队列中的进程(或对于_all()变种,所有进程)将被唤醒,其状态被设置为TASK_RUNNING;否则(如果条件为false),则没有任何事情发生。以下代码片段阐明了这一概念:

wake_up(&my_event);
wake_up_all(&my_event);
wake_up_interruptible(&my_event);
wake_up_interruptible_all(&my_event);

在前面的代码片段中,wake_up()将仅唤醒等待队列中的一个进程,而wake_up_all()将唤醒等待队列中的所有进程。另一方面,wake_up_interruptible()将仅唤醒在可中断睡眠状态下的一个进程,而wake_up_interruptible_all()将唤醒所有在可中断睡眠状态下的进程。

因为它们可能会被信号中断,你应该检查_interruptible变种的返回值。非零值表示你的休眠已经被某种信号中断,驱动程序应返回ERESTARTSYS,如下代码片段所示:

#include <linux/module.h>
#include <linux/init.h>
#include <linux/sched.h>
#include <linux/time.h>
#include <linux/delay.h>
#include<linux/workqueue.h>
static DECLARE_WAIT_QUEUE_HEAD(my_wq);
static int condition = 0;
/* declare a work queue*/
static struct work_struct wrk;
static void work_handler(struct work_struct *work)
{
    pr_info("Waitqueue module handler %s\n", __FUNCTION__);
    msleep(5000);
    pr_info("Wake up the sleeping module\n");
    condition = 1;
    wake_up_interruptible(&my_wq);
}
static int __init my_init(void)
{
    pr_info("Wait queue example\n");
    INIT_WORK(&wrk, work_handler);
    schedule_work(&wrk);
    pr_info("Going to sleep %s\n", __FUNCTION__);
    if (wait_event_interruptible(my_wq, condition != 0)) {
        pr_info("Our sleep has been interrupted\n");
        return -ERESTARTSYS;
    }
    pr_info("woken up by the work job\n");
    return 0;
}
void my_exit(void)
{
    pr_info("waitqueue example cleanup\n");
}
module_init(my_init)
module_exit(my_exit);
MODULE_AUTHOR("John Madieu <john.madieu@gmail.com>");
MODULE_LICENSE("GPL");

在前面的示例中,我们使用了msleep() API,稍后会进行解释。回到代码的行为——当前进程(实际上是insmod)将被放入等待队列休眠 5 秒,并由工作处理程序唤醒。dmesg输出如下所示:

[342081.385491] Wait queue example
[342081.385505] Going to sleep my_init
[342081.385515] Waitqueue module handler work_handler
[342086.387017] Wake up the sleeping module
[342086.387096] woken up by the work job
[342092.912033] waitqueue example cleanup

现在我们已经掌握了等待队列的概念,它允许我们将进程置于睡眠状态,并等待它们被唤醒,让我们学习另一种简单的休眠机制,它仅仅是无条件地延迟执行流程。

内核中的简单休眠

这个简单的休眠也可以称为 #include <linux/delay>,这将使以下函数可用:

usleep_range(unsigned long min, unsigned long max)
msleep(unsigned long msecs)
msleep(unsigned long msecs)
msleep_interruptible(unsigned long msecs)

在前面的 API 中,msecs 是休眠的毫秒数,minmax 是休眠时间的最小值和最大值(微秒)。

usleep_range() API 依赖于 µsecs 或小的 msecs(介于 10 微秒到 20 毫秒之间),避免了 udelay() 的忙等待循环。

msleep*() API 是基于 jiffies/传统定时器的。对于较长时间的毫秒级休眠(10 毫秒或更多),应使用这个 API。此 API 将当前任务设置为 TASK_UNINTERRUPTIBLE,而 msleep_interruptible() 则在调度休眠之前将当前任务设置为 TASK_INTERRUPTIBLE。简而言之,区别在于休眠是否可以被信号提前结束。除非你需要可中断的版本,否则推荐使用 msleep()

本节中的 API 仅适用于在非原子上下文中插入延迟。

内核延迟或忙等待

首先,本节中的“延迟”一词可以视为忙等待,因为任务主动等待(对应 for()while() 循环),消耗 CPU 资源,与休眠(被动延迟,任务在等待时处于休眠状态)形成对比。

即使是忙等待循环,驱动程序也必须包含 #include <linux/delay>,这也会使以下 API 可用:

ndelay(unsigned long nsecs)
udelay(unsigned long usecs)
mdelay(unsigned long msecs)

这类 API 的优势在于它们可以在原子和非原子上下文中都能使用。

ndelay 的精度取决于你的定时器的准确性(在嵌入式设备上,ndelay 的精度可能并不存在,许多非 PC 设备上可能并不具备这种精度。相反,你更可能遇到以下情况:

  • udelay:这个 API 基于忙等待循环。它将通过忙等待足够的循环次数来实现所需的延迟。如果你需要休眠几微秒(< ~10 微秒),应使用这个函数。即使是休眠少于 10 微秒,也推荐使用这个 API,因为在较慢的系统(某些嵌入式 SoC)上,设置 usleep 的 hrtimers 开销可能不值得。这样的评估显然取决于你的具体情况,但这是需要注意的。

  • mdelay:这是一个包装 udelay 的宏,用于处理传递大参数给 udelay 时可能发生的溢出。通常不推荐使用 mdelay,应该重构代码以使用 msleep

在这一步,我们已经完成了 Linux 内核的休眠或延迟机制。我们应该能够设计并实现一个时间片管理的执行流程。接下来,我们可以深入了解 Linux 内核如何管理时间。

理解 Linux 内核的时间管理

时间是计算机系统中最常用的资源之一,仅次于内存。几乎所有操作都需要时间:定时器、休眠、调度以及许多其他任务。

Linux 内核包含软件定时器概念,以使内核函数能够在稍后的时间被调用。

时钟源、时钟事件和滴答设备的概念

在原始的 Linux 定时器实现中,主要硬件定时器主要用于时间 keeping。它还被编程为以 HZ 的频率定期触发中断,其对应的周期称为滴答(稍后将在本章的 滴答与 HZ 部分解释)。这些每 1/HZ 秒生成的中断(现在仍然如此)被称为 tick。在本节中,术语 tick 将指代以 1/HZ 周期生成的中断。

整个系统的时间管理(无论是来自内核还是用户空间)都与 jiffies 绑定,jiffies 也是内核中的一个全局变量,在每次 tick 时递增。除了递增 jiffies 的值(在其上实现了定时器轮),tick 处理程序还负责进程调度、统计更新和性能分析。

从内核版本 2.6.21 开始,作为第一个改进,hrt timers 实现被合并(现在通过 CONFIG_HIGH_RES_TIMERS 可用)。该特性是(并且仍然是)透明的,伴随着 hrtimer 定时器作为独立功能,其引入了一种新的数据类型 ktime_t,用于以纳秒为单位存储时间值。然而,旧有的(基于 tick 和低分辨率的)定时器实现仍然存在。该改进使得 nanosleep()nanosleep()clock_nanosleep() 成为可能,这是通过对 nanosleep() 和 POSIX 定时器的转换实现的。如果没有这个改进,定时器事件能够达到的最佳精度将是 1 个滴答,其持续时间取决于内核中 HZ 的值。

注意

也就是说,高分辨率定时器的实现是一个独立的特性。无论平台如何,都可以启用 hrtimer 定时器,但它们是否工作在高分辨率模式取决于底层硬件定时器。否则,系统将处于低分辨率低分辨率)模式。

随着内核的演进,改进不断进行,直到通用时钟事件接口的出现,引入了时钟源、时钟事件和滴答设备的概念。这完全改变了内核中的时间管理,并对 CPU 电源管理产生了影响。

时钟源框架和时钟源设备

时钟源是一个单调的、原子性的、自由运行的计数器。可以将其视为一个定时器,作为一个自由运行的计数器,提供时间戳和读取单调递增的时间值。对时钟源设备执行的常见操作是读取计数器的值。

在内核中,有一个 clocksource_list 全局列表,用于跟踪已注册到系统的时钟源设备,按评级顺序入队。这使得 Linux 内核能够了解所有已注册的时钟源设备,并切换到具有更好评级和特性的时钟源。例如,在每次注册新的时钟源后,都会调用 __clocksource_select() 函数,确保始终选择最佳时钟源。时钟源通过 clocksource_mmio_init()clocksource_register_hz() 注册(你可以通过 grep 查找这些词)。然而,时钟源设备驱动程序位于内核源代码的 drivers/clocksource/ 中。

在正在运行的 Linux 系统上,列出已注册的时钟源设备的最直观方式是通过查看内核日志消息缓冲区中的 clocksource 字眼,如下所示:

https://github.com/OpenDocCN/freelearn-linux-pt5-zh/raw/master/docs/linux-dvc-dvr-dev-2e/img/B17934_03_001.jpg

图 3.1 – 系统时钟源列表

注意

在上述输出日志(来自 Pi 4)中,jiffies 时钟源是基于滴答粒度的,并且始终由内核以 clocksource_jiffies 注册在 kernel/time/jiffies.c 中,是有效评级值最低的时钟源(即作为最后的备用)。在 x86 平台上,这个时钟源被精细化并重命名为 refined-jiffies—请参阅 arch/x86/kernel/setup.c 中的 register_refined_jiffies() 函数调用。

然而,最推荐的方式(特别是在 dmesg 缓冲区已滚动或已清除的情况下)是通过读取 /sys/devices/system/clocksource/clocksource0/available_clocksource 文件的内容来枚举当前正在运行的 Linux 系统上可用的时钟源,代码示例如下(在 Pi 4 上):

root@raspberrypi4-64-d0:~# cat  /sys/devices/system/clocksource/clocksource0/available_clocksource 
arch_sys_counter 
root@raspberrypi4-64-d0:~#

在 i.MX6 板上,我们有以下内容:

root@udoo-labcsmart:~# cat  /sys/devices/system/clocksource/clocksource0/available_clocksource 
mxc_timer1 
root@udoo-labcsmart:~#

要检查当前使用的时钟源,可以使用以下代码:

root@raspberrypi4-64-d0:~# cat  /sys/devices/system/clocksource/clocksource0/current_clocksource 
arch_sys_counter
root@raspberrypi4-64-d0:~#

在我的 x86 机器上,我们有以下内容,显示了可用的时钟源以及当前使用的时钟源:

jma@labcsmart:~$ cat /sys/devices/system/clocksource/clocksource0/available_clocksource
tsc hpet acpi_pm 
jma@labcsmart:~$ cat /sys/devices/system/clocksource/clocksource0/current_clocksource 
tsc
jma@labcsmart:~$

要更改当前的时钟源,可以将某个可用时钟源的名称写入 current_clocksource 文件,如下所示:

jma@labcsmart:~$ echo acpi_pm >  /sys/devices/system/clocksource/clocksource0/current_clocksource 
jma@labcsmart:~$

更改当前时钟源时必须小心,因为内核在启动过程中选择的当前时钟源始终是最佳的。

Linux 内核时间管理

时钟源设备的主要目标之一是为时间管理器提供时间信息。一个系统中可以有多个时钟源,但时间管理器会选择精度最高的时钟源进行使用。时间管理器需要定期获取时钟源的值,以更新系统时间,通常在滴答处理过程中进行更新,如下图所示:

https://github.com/OpenDocCN/freelearn-linux-pt5-zh/raw/master/docs/linux-dvc-dvr-dev-2e/img/B17934_03_002.jpg

图 3.2 – Linux 内核时间管理器实现

时间管理器提供几种类型的时间:xtime单调时间原始单调时间启动时间,这些在此处有更详细的说明:

  • xtime:这是墙钟时间(实时时间),表示由实时时钟RTC)芯片提供的当前时间。

  • 单调时间:自系统开启以来的累计时间,但不计入系统休眠时间。

  • 原始单调时间:与单调时间含义相同,但它更加纯粹,不会受到网络时间协议NTP)时间调整的影响。

  • 启动时间:将系统休眠期间的时间加到单调时间上,得到系统开机后的总时间。

下表显示了不同类型的时间及其内核获取函数:

https://github.com/OpenDocCN/freelearn-linux-pt5-zh/raw/master/docs/linux-dvc-dvr-dev-2e/img/Table_01.jpg

表 3.1 – Linux 内核时间管理函数

现在我们已经了解了 Linux 内核的时间管理机制和 API,可以开始学习时间管理中的另一个概念——时钟事件框架。

时钟事件框架和时钟事件设备

在引入时钟事件概念之前,硬件定时器的本地性并未被考虑。时钟源/事件硬件被编程为每秒周期性生成 HZ 个滴答(中断),每个滴答之间的间隔为一个 jiffy。随着时钟事件/源在内核中的引入,时钟中断被抽象为事件。时钟事件框架的主要功能是分发时钟中断(事件)并设置下一个触发条件。它是一个通用的下一个事件中断编程框架。

时钟事件设备是可以触发中断的设备,允许我们编程设置下一个中断(事件)将在未来何时触发。每个时钟事件设备驱动程序必须提供一个 set_next_event 函数(如果是基于 hrtimer 的时钟事件设备,则为 set_next_ktime),该函数将在使用底层时钟事件设备编程下一个中断时由框架调用。

时钟事件设备与时钟源设备是正交的。这也是它们的驱动程序通常位于与时钟源设备驱动程序相同位置(有时甚至在相同的编译单元)的原因——也就是在 drivers/clocksource 目录下。在大多数平台上,时钟事件和时钟源可能会使用相同的硬件和寄存器范围,但它们本质上是不同的。例如,BCM2835 系统定时器就是一个位于树莓派中使用的 BCM2835 上的内存映射外设。它具有一个以 1 兆赫MHz)运行的 64 位自由计数器,以及四个不同的“输出比较寄存器”,可用于调度中断。在这种情况下,驱动程序通常会在同一编译单元中注册时钟源和时钟事件设备。

在运行中的 Linux 系统中,可以通过 /sys/devices/system/clockevents/ 目录列出可用的时钟事件设备。以下是一个 Pi 4 的示例:

root@raspberrypi4-64-d0:~# ls /sys/devices/system/clockevents/         
broadcast    clockevent1  clockevent3  uevent
clockevent0  clockevent2  power
root@raspberrypi4-64-d0:~#

在一台双核 i.MX6 系统上,我们有以下内容:

root@udoo-labcsmart:~# ls /sys/devices/system/clockevents/
broadcast    clockevent0  clockevent1  consumers    power        suppliers    uevent
root@empair-labcsmart:~#

最后,在我的多核机器上,我们有以下配置:

jma@labcsmart:~$ ls /sys/devices/system/clockevents/
broadcast  clockevent0  clockevent1  clockevent2  clockevent3  clockevent4  clockevent5  clockevent6  clockevent7  power  uevent
jma@labcsmart:~$

从系统上可用的时钟事件设备的前述列表中,我们可以得出以下结论:

  • 系统中有与 CPU 数量相同的时钟事件设备(允许每个 CPU 使用独立的时钟设备,从而涉及定时器本地性)。

  • 总是有一个奇怪的目录,broadcast。我们将在接下来的章节中讨论这个特定的定时器。

要了解给定时钟事件设备的底层定时器,可以读取时钟事件目录中的 current_device 内容。接下来我们将通过三个不同机器的例子进行展示。

在 i.MX 6 平台上,我们有以下配置:

root@udoo-labcsmart:~# cat /sys/devices/system/clockevents/clockevent0/current_device 
local_timer
root@udoo-labcsmart:~# cat /sys/devices/system/clockevents/clockevent1/current_device 
local_timer

在 Pi 4 上,我们有以下配置:

root@raspberrypi4-64-d0:~# cat /sys/devices/system/clockevents/clockevent2/current_device
arch_sys_timer
root@raspberrypi4-64-d0:~# cat /sys/devices/system/clockevents/clockevent3/current_device
arch_sys_timer

在我的 x86 运行的机器上,我们有以下配置:

jma@labcsmart:~$ cat /sys/devices/system/clockevents/clockevent0/current_device
lapic-deadline
jma@labcsmart:~$ cat /sys/devices/system/clockevents/clockevent1/current_device
lapic-deadline

为了提高可读性,我们决定仅读取两个条目,从我们读取的内容中可以得出以下结论:

  • 时钟事件设备由相同的硬件定时器支持,这与支持时钟源设备的硬件定时器不同。

  • 至少需要两个硬件定时器来支持高分辨率定时器接口,一个充当时钟源角色,另一个(理想情况下是每个 CPU)支持时钟事件设备。

时钟事件设备可以被配置为在单次模式或周期模式下工作,如下所示:

  • 在周期模式下,它被配置为每 1/HZ 秒生成一个滴答,并执行所有传统(低分辨率)定时器滴答所做的工作,如更新 jiffies、统计 CPU 时间等。换句话说,在周期模式下,它的使用方式与传统低分辨率定时器相同,但是在新基础设施下运行。

  • 单次模式使硬件在从当前时间经过特定数量的周期后生成一个滴答。它通常用于编程下一个中断,以便在 CPU 进入空闲状态之前唤醒 CPU。

为了跟踪时钟事件设备的工作模式,引入了滴答设备的概念。这个概念将在下一节中进一步解释。

滴答设备

滴答设备是时钟事件设备的软件扩展,提供按规律时间间隔发生的连续滴答事件。每当注册一个新的时钟事件设备时,内核会自动创建一个滴答设备,并始终选择最合适的时钟事件设备。因此,毫无疑问,滴答设备与时钟事件设备绑定,且滴答设备由时钟事件设备支持。

滴答设备数据结构的定义如下代码片段所示:

struct tick_device {
    struct clock_event_device *evtdev;
    enum tick_device_mode mode;
};

在这个数据结构中,evtdev 是由滴答设备抽象出的时钟事件设备。mode 用于跟踪底层时钟事件的工作模式。因此,当说一个滴答设备处于周期模式时,也意味着底层的时钟事件设备被配置为以这种模式工作。以下图示说明了这一点:

https://github.com/OpenDocCN/freelearn-linux-pt5-zh/raw/master/docs/linux-dvc-dvr-dev-2e/img/B17934_03_003.jpg

图 3.3 – 时钟事件与滴答设备的关联

一个滴答设备可以是全局性的(系统范围)或局部的(每个 CPU 的滴答设备)。是否必须是全局滴答设备由框架决定,框架根据底层时钟事件设备的特性选择一个本地滴答设备。每种类型的滴答设备的描述如下:

  • 每个 CPU 的滴答设备用于提供本地 CPU 功能,例如进程会计、性能分析,显然还包括 CPU 本地的周期性滴答(周期模式)和 CPU 本地的下一个事件中断(非周期模式),以及 CPU 本地的 hrtimers 管理(参见 update_process_times() 函数了解如何处理这些)。在定时器核心代码中,存在一个 tick_cpu_device 每 CPU 变量,它表示系统中每个 CPU 的滴答设备实例。

  • 全局滴答设备负责提供主要运行 do_timer()update_wall_time() 函数的周期性滴答。因此,第一个函数更新全局 jiffies 值并更新系统负载平均值,而后者更新墙时间(存储在 xtime 中,记录从 1970 年 1 月 1 日至今的时间差),并运行任何已过期的动态定时器(例如,运行本地进程定时器)。在定时器核心代码中,存在 tick_do_timer_cpu 全局变量,它保存具有全局滴答设备角色的 CPU 编号——即执行 do_timer() 的那个 CPU。还有另一个全局变量 tick_next_period,它跟踪全局滴答设备下次触发的时间。

    注意

    这也意味着 jiffies 变量始终由一个核心管理,但其功能管理亲和性可以随着 tick_do_timer_cpu 的变化而从一个核心跳到另一个核心。

从其中断例程中,底层时钟事件设备的驱动程序必须调用 evtdev->event_handler(),这是由框架安装的时钟设备的默认处理程序。虽然设备驱动程序对此透明,但该处理程序是由框架根据其他参数设置的,如下所示:两个内核配置选项(CONFIG_HIGH_RES_TIMERSCONFIG_NO_HZ)、底层硬件定时器的分辨率,以及滴答设备是以动态模式运行还是单次模式运行。

NO_HZ 是启用动态滴答支持的内核选项,而 HIGH_RES_TIMERS 允许使用 hrtimer API。启用 hrtimers 后,基础代码仍然是基于滴答驱动的,但周期性滴答中断被 hrtimers 下的定时器所取代(在定时器软中断上下文中调用 softirq)。然而,hrtimers 是否能够以高分辨率模式工作,取决于底层硬件定时器是否为高分辨率。如果不是,hrtimers 将由旧的低分辨率基于滴答的定时器提供支持。

Tick 设备可以在单次模式或周期模式下运行。在周期模式下,框架通过控制结构使用每个 CPU 的 hrtimer 来模拟 ticks,这样基础代码依然是由 tick 驱动的,但周期性 tick 中断被嵌入控制结构中的 hrtimer 定时器所替代。这个控制结构是一个 tick_sched 结构,定义如下:

struct tick_sched {
    struct hrtimer               sched_timer;
    enum tick_nohz_mode          nohz_mode;
[...]
};

然后,声明一个每个 CPU 实例的控制结构,如下所示:

static DEFINE_PER_CPU(struct tick_sched, tick_cpu_sched);

这个每个 CPU 的实例允许每个 CPU 执行 tick 模拟,通过 sched_timer 元素驱动低分辨率定时器的处理,并定期重新编程为下一个低分辨率定时器过期的时间间隔。然而,这似乎显而易见,因为每个 CPU 都有自己的运行队列和就绪进程列表来管理。

tick_sched 元素可以由框架配置为三种不同的模式,描述如下:

  • NOHZ_MODE_INACTIVE:该模式表示没有动态 tick 也没有 hrtimer 支持。它是系统初始化期间的状态。在此模式下,本地每个 CPU 的 tick 设备事件处理程序是 tick_handle_periodic(),并且进入 idle 时会被 tick 定时器中断打断。

  • NOHZ_MODE_LOWRES:这也是 lowres 模式,意味着启用了低分辨率模式下的动态 tick。这意味着系统未找到高分辨率硬件定时器,因此 hrtimer 无法以高分辨率模式工作,而是以低精度模式工作,精度与低精度定时器相同(tick_nohz_handler()),并且进入 idle 状态时不会被 tick 定时器中断打断。

  • NOHZ_MODE_HIGHRES:这也是 highres 模式。在此模式下,启用了动态 tick 和 hrtimer 的“高分辨率”模式。每个 CPU 的本地 tick 设备事件处理程序是 hrtimer_interrupt()。在这里,hrtimer 以高精度模式工作,精度与硬件定时器相同(tick_device,而传统的 tick 定时器被转换为 hrtimer 的子定时器)。

核心相关的源代码位于kernel/time/目录中,具体实现位于tick-*.c文件中。这些文件包括tick-broadcast.ctick-common.ctick-broadcast-hrtimer.ctick-legacy.ctick-oneshot.ctick-sched.c

广播 tick 设备

在实现 CPU 电源管理的平台上,大多数(如果不是所有)支持时钟事件设备的硬件定时器会在某些 CPUidle 状态下关闭。为了保持软件定时器的功能,内核依赖于平台中始终开启的时钟事件设备(即由始终开启的定时器支持),用于在定时器过期时中继中断信号。这个始终开启的定时器在 /sys/devices/system/clockevents/ 中被称为 broadcast 目录。

注意

广播 tick 设备能够通过发出 wake_up_nohz_cpu() 来唤醒任何 CPU,专门用于此目的。

要查看支持广播设备的底层定时器,可以读取其目录中的 current_device 变量。

在 x86 平台上,我们得到以下输出:

jma@labcsmart:~$ cat /sys/devices/system/clockevents/broadcast/current_device
hpet

Pi 4 的输出如下:

root@raspberrypi4-64-d0:~# cat /sys/devices/system/clockevents/broadcast/current_device
bc_hrtimer

最后,i.MX 6 的广播设备由以下定时器支持:

root@udoo-labcsmart:~# cat /sys/devices/system/clockevents/broadcast/current_device 
mxc_timer1

从前面的输出可以看出,支持广播设备的定时器不同于时钟源、时钟事件和广播设备的定时器。

是否可以将滴答设备用作广播设备,由tick_install_broadcast_device()核心函数决定,该函数在每次滴答设备注册时调用。此函数会排除带有CLOCK_EVT_FEAT_C3STOP标志的滴答设备(意味着底层时钟事件设备的定时器在C3空闲状态下停止),并依赖其他标准(例如支持单次模式——即设置了CLOCK_EVT_FEAT_ONESHOT标志)。最后,kernel/time/tick-broadcast.c中定义的tick_broadcast_device全局变量包含作为广播设备的滴答设备。当选择滴答设备作为广播设备时,其下一个事件处理程序将设置为tick_handle_periodic_broadcast(),而不是tick_handle_periodic()

然而,一些平台实现了 CPU 核心门控,但没有始终开启的硬件定时器。对于这样的平台,内核提供了一个基于内核 hrtimer 的时钟事件设备,该设备在启动时无条件注册(并可以选择作为滴答广播设备),其评分值最低,以便系统中任何支持广播的硬件时钟事件设备都会优先选择,而不是滴答广播设备。

这个基于 hrtimer 的时钟事件设备依赖于一个动态选择的 CPU(以便如果有一个 CPU 即将进入深度睡眠状态,且其唤醒时间早于 hrtimer 到期时间,则该 CPU 成为新的广播 CPU)始终保持供电。然后,这个 CPU 将通过其硬件本地定时器设备将定时器中断传递给处于深度空闲状态的其他 CPU。它实现于kernel/time/tick-broadcast-hrtimer.c,注册为ce_broadcast_hrtimer,其name字段设置为bc_hrtimer。例如,如你所见,它是 Pi 4 平台使用的广播滴答设备。

注意

不言而喻,保持一个始终开启的 CPU 对电源管理平台功能有影响,并使得CPUidle子优化,因为内核至少会让一个 CPU 始终处于浅空闲状态以传递定时器中断。这是在 CPU 电源管理和高分辨率定时器接口之间的折衷,至少它让内核保持一个具有某些工作电源管理功能的系统。

理解sched_clock函数

sched_clock()是一个内核时间跟踪和时间戳功能,它返回系统启动以来的纳秒数。它在kernel/sched/clock.c中被弱定义(以允许架构或平台代码重写),如下所示:

unsigned long long __weak sched_clock(void)

例如,它是提供时间戳给printk()的功能,或者在使用ktime_get_boottime()或相关内核 API 时被调用。默认情况下,它采用基于跳跃(jiffy)实现的方式(这可能影响调度的准确性)。如果被重写,新的实现必须返回一个 64 位的单调时间戳,单位为纳秒,表示自上次重启以来的纳秒数。大多数平台通过直接读取计时器寄存器来实现这一点。在缺乏计时器的的平台上,这一功能是通过与主时钟源设备相同的计时器来实现的。例如,在 Raspberry Pi 上就是这种情况。当出现这种情况时,读取主时钟源设备值的寄存器和读取_sched_clock()值的寄存器是相同的:参见 drivers/clocksource/bcm2835_timer.c

驱动_sched_clock()的计时器必须比时钟源计时器更快。如其名称所示,它主要供调度器使用,这意味着它被调用的频率远高于其他计时器。如果必须在与时钟源的准确性之间做出权衡,可能会在_sched_clock()中牺牲准确性以提高速度。

_sched_clock()没有被直接重写时,内核时间核心提供了一个 sched_clock_register() 助手函数,用来提供一个平台相关的计时器读取函数以及一个评级值。无论如何,这个计时器读取函数最终会出现在cd内核时间框架变量中,该变量的类型为 struct clock_data(假设新的底层计时器的速率大于当前函数所驱动的计时器的速率)。

动态滴答/无滴答内核

动态滴答是迁移到高分辨率计时器接口的逻辑结果。在引入动态滴答之前,周期性滴答会定期发出中断(每秒 HZ 次)来驱动操作系统。即使没有任务或计时器处理程序需要运行,这也会让系统保持唤醒状态。使用这种方法时,CPU 无法进行长时间休息,必须在没有实际目的的情况下唤醒。

动态滴答机制提供了解决方案,允许在某些时间间隔内停止周期性滴答以节省电力。采用这种新方法时,只有当有任务需要执行时,周期性滴答才会被重新启用;否则,它们会被禁用。

它是如何工作的?当 CPU 没有更多任务运行(即空闲任务已调度到此 CPU 上)时,只有两种事件可以在 CPU 空闲后产生新的工作:一个是内部内核计时器的到期,这是可预测的,另一个是 I/O 操作的完成。当 CPU 进入空闲状态时,计时器框架会检查下一个调度的计时器事件,如果该事件晚于下一个周期性滴答,它会将每个 CPU 的时钟事件设备重新编程为这个较晚的事件。这样可以使空闲的 CPU 进入更长时间的空闲休眠,而不被周期性滴答不必要地打断。

然而,某些系统有低功耗状态,在这些系统中,甚至每个 CPU 的时钟事件设备也会停止。在这样的平台上,是广播 tick 设备被编程为触发下一个未来事件。

在 CPU 进入此类空闲状态(do_idle()函数)之前,它会调用 tick 广播框架(tick_nohz_idle_enter()),并禁用其tick_device变量的周期性 tick(见tick_nohz_idle_stop_tick())。然后,将该 CPU 添加到待唤醒 CPU 的列表中,方法是设置tick_broadcast_mask“广播映射”变量中与该 CPU 对应的位,该位图表示处于睡眠模式的处理器列表。接着,框架计算该 CPU 必须被唤醒的时间(即其下一个事件时间);如果该时间早于当前tick_broadcast_device已编程的时间,则更新tick_broadcast_device应中断的时间,以反映新值,并将该新值编程到支持广播 tick 设备的时钟事件设备中。即将进入深度空闲状态的 CPU 的tick_cpu_device变量现在进入关闭模式,这意味着它不再工作。

前述过程会在每次 CPU 进入深度空闲状态时重复,tick_broadcast_device变量被编程为在进入深度空闲状态的 CPU 的最早唤醒时间触发。

当 tick 广播设备下一事件触发时,它会查看睡眠中的 CPU 的位掩码,寻找拥有可能已过期定时器的 CPU,并向位掩码中任何可能托管过期定时器的远程 CPU 发送 IPI。

然而,如果 CPU 因中断而离开空闲状态(架构代码调用handle_IRQ(),间接调用tick_irq_enter()),则启用该 CPU 的 tick 设备(首先以单次模式),并在执行任何任务之前,调用tick_nohz_irq_enter()函数,确保jiffies是最新的,以便中断处理程序不必处理过时的 jiffy 值,然后恢复周期性 tick,该 tick 会一直保持活动状态,直到下一次调用tick_nohz_idle_stop_tick()(实际上是从do_idle()调用的)。

使用标准的内核低精度(低分辨率)定时器

标准(现为遗留的,也称为低分辨率)定时器是以jiffies粒度运行的内核定时器。这些定时器的分辨率绑定于常规系统 tick 的分辨率,而系统 tick 的分辨率取决于内核用于控制调度和执行函数在未来某一时刻(基于 jiffies)的架构和配置(即CONFIG_HZ,或简单地说HZ)。

Jiffies 和 HZ

一个 jiffy 是内核时间单位,其持续时间取决于HZ的值,HZ代表内核中jiffies变量的增量频率。每个增量事件称为一个 tick。基于jiffies的时钟源是最小公倍数时钟源,应该在任何系统上运行。

由于jiffies变量每秒增加HZ次,如果HZ = 1,000,则它每秒增加 1,000 次(即每 1/1,000 秒增加一次 tick,或者说是 1 毫秒)。大多数情况下,HZ默认值为100,而在 x86 上默认值为250,这将导致分辨率为 10 毫秒或 4 毫秒。

以下是两个运行系统上不同HZ值:

jma@labcsmart:~$ grep ‘CONFIG_HZ=' /boot/config-$(uname -r)
CONFIG_HZ=250
jma@labcsmart:~$

前面的代码已经在运行的 x86 机器上执行。在运行的 ARM 机器上,我们有如下情况:

root@udoo-labcsmart:~# zcat /proc/config.gz |grep CONFIG_HZ
CONFIG_HZ_100=y
root@udoo-labcsmart:~#

前面的代码表示当前HZ值为100

内核定时器 API

在内核中,定时器表示为struct timer_list的一个实例,定义如下:

struct timer_list {
    struct hlist_node entry;
    unsigned long expires;
    void (*function)(struct timer_list *);
    u32 flags;
);

在前面的数据结构中,expires是 jiffies 中的绝对值,定义了该定时器未来何时过期。entry是内核用于在每个 CPU 的定时器全局列表中跟踪该定时器的字段。flags是按位或的位掩码,表示定时器标志,如定时器的管理方式以及回调将在哪个 CPU 上调度,function是当定时器过期时执行的回调函数。

你可以使用timer_setup()动态定义一个定时器,或者使用DEFINE_TIMER()静态创建一个定时器。

注意

在 Linux 内核版本 4.15 之前,setup_timer()用作动态版本。

以下是两个宏的定义:

void timer_setup( struct timer_list *timer,        \
           void (*function)( struct timer_list *), \
           unsigned int flags);
#define DEFINE_TIMER(_name, _function) [...]

在定时器初始化之后,必须设置其过期延迟,然后才能使用以下 API 之一启动定时器:

int mod_timer(struct timer_list *timer,
               unsigned long expires);
void add_timer(struct timer_list *timer)

mod_timer()函数用于设置初始过期延迟或更新活动定时器的值,这意味着在非活动定时器上调用此函数将激活该定时器。

注意

启动定时器意味着准备并排队此定时器。也就是说,当定时器只是准备好、排队并倒计时,等待过期后运行回调函数时,它被称为挂起。

你应该更倾向于使用这个函数,而不是add_timer(),后者是专门用于启动非活动定时器的函数。在调用add_timer()之前,必须设置定时器的过期延迟和回调函数,如下所示:

my_timer.expires = jiffies + ((12 * HZ) / 10); /* 1.2s */
add_timer(&my_timer);

mod_timer()返回的值取决于定时器在调用之前的状态。在非活动定时器上调用mod_timer()时,成功返回0,而在挂起定时器或回调函数正在执行的定时器上成功调用时,返回1。这意味着从定时器回调中执行mod_timer()是完全安全的。当在活动定时器上调用时,它等同于del_timer(timer); timer->expires = expires; add_timer(timer);

完成计时器的使用后,可以通过以下函数之一将其释放或取消:

int del_timer(struct timer_list *timer);
int del_timer_sync(struct timer_list *timer);

del_timer()从计时器管理队列中移除(出队)timer对象。如果成功,它会返回一个不同的值,具体取决于它是在非活动计时器上调用还是在活动计时器上调用。在第一种情况下,它返回0,而在第二种情况下,即使该计时器的回调函数正在执行,它也会返回1

让我们考虑以下执行流程,其中一个计时器在一个 CPU 上被删除,而其回调在另一个 CPU 上执行:

mainline (CPUx)                  handler(CPUy)
==============                   =============
                                 enter xxx_timer()
del_timer()
kfree(some_resource)
                                 access(some_resource)

在前面的代码片段中,使用del_timer()并不能保证回调函数不再运行。这里有另一个例子:

mainline (CPUx)            handler(CPUy)
==============             =============
                           enter xxx_timer()
 del_timer()
 kfree(timer)
                           mod_timer(timer)

del_timer()返回时,它仅保证计时器已被停用并从队列中移除,确保它未来不会被执行。然而,在多处理器机器上,计时器函数可能已经在另一个处理器上执行。在这种情况下,应该使用del_timer_sync(),它将停用计时器并等待任何正在执行的处理程序退出后再返回。此函数会检查每个处理器,确保给定的计时器没有在其他地方运行。通过在前述竞态条件示例中使用del_timer_sync(),可以在不担心资源是否在回调中使用的情况下调用kfree()。您应该几乎总是使用del_timer_sync(),而不是del_timer()。驱动程序不能持有锁,阻止处理程序完成;否则会导致死锁。这使得del_timer()在上下文上是不可知的,因为它是异步的,而del_timer_sync()仅应在非原子上下文中使用。

此外,为了确保正确性,我们可以使用以下 API 独立检查计时器是否待处理:

int timer_pending(const struct timer_list *timer);

这个函数检查该计时器是否已经启动并待处理。

以下代码片段展示了标准内核计时器的基本用法:

#include <linux/init.h>
#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/timer.h>
static struct timer_list my_timer;
void my_timer_callback(struct timer_list *t)
{
    pr_info("Timer callback&; called\n");
}

static int __init my_init(void)
{
    int retval;
    pr_info("Timer module loaded\n");
    timer_setup(&my_timer, my_timer_callback, 0);
    pr_info("Setup timer to fire in 500ms (%ld)\n",
              jiffies);
    retval = mod_timer(&my_timer,
                        jiffies + msecs_to_jiffies(500));
    if (retval)
        pr_info("Timer firing failed\n");

    return 0;
}

static void my_exit(void)
{
    int retval;
    retval = del_timer(&my_timer);
    /* Is timer still active (1) or no (0) */
    if (retval)
        pr_info("The timer is still in use...\n");
    pr_info("Timer module unloaded\n");
}
module_init(my_init);
module_exit(my_exit);
MODULE_AUTHOR("John Madieu <john.madieu@gmail.com>");
MODULE_DESCRIPTION("Standard timer example");
MODULE_LICENSE("GPL");

在前面的示例中,我们演示了标准计时器的基本用法。我们请求 500 毫秒的超时。也就是说,这种计时器的时间单位是滴答。所以,要以人类易读的格式(秒或毫秒)传递超时值,必须使用转换辅助函数。这里列出了一些:

unsigned long msecs_to_jiffies(const unsigned int m)
unsigned long usecs_to_jiffies(const unsigned int u)
unsigned long timespec64_to_jiffies(
                  const struct timespec64 *value);

使用前述的辅助函数,您不应期望有比“滴答”(jiffy)更高的精度。例如,使用usecs_to_jiffies(100)会返回一个滴答值。返回的值会被四舍五入到最接近的滴答值。

为了将额外的参数传递给计时器回调,首选的方式是将它们作为元素嵌入到一个结构中,与计时器一起使用,并在元素上使用from_timer()宏来检索更大的结构,然后可以访问每个元素。这个宏定义如下:

#define from_timer(var, callback_timer, timer_fieldname) \
    container_of(callback_timer, typeof(*var), timer_fieldname) 

例如,假设我们需要向定时器回调传递两个元素,第一个元素是struct sometype类型,第二个元素是一个整数。为了传递参数,我们定义一个额外的结构体,如下所示:

struct fake_data {
    struct timer_list timer;
    struct sometype foo;
    int bar;
};

接下来,我们将嵌入式定时器传递给设置函数,如下所示:

struct fake_data *fd = alloc_init_fake_data();
timer_setup(&fd->timer, timer_callback, 0);

在回调函数中,你必须使用from_timer变量来检索较大的结构体,从中可以访问参数。下面是一个使用示例:

void timer_callback(struct timer_list *t)
{
    struct fake_data *fd = from_timer(fd, t, timer);
    sometype data = fd->data;
    int var = fd->bar;
[...]
}

在前面的代码片段中,我们描述了如何将数据传递给定时器回调以及如何使用容器结构来获取该数据。默认情况下,指向timer_list的指针被传递给回调函数,而不是在 4.15 之前版本中使用的unsigned long数据类型。

高分辨率定时器(hrtimers)

虽然传统的定时器实现与滴答时间相关联,但高精度定时器提供了纳秒级和与滴答时间无关的定时精度,满足了对精确时间应用或内核驱动程序的紧迫需求。该功能已在内核 v2.6.16 中引入,并可以通过在内核配置中启用CONFIG_HIGH_RES_TIMERS选项来启用。

虽然标准定时器接口使用jiffies表示时间值,但高分辨率定时器接口引入了一种新的数据类型,允许我们保存时间值:ktime_t,它是一个简单的 64 位标量值。

注意

在内核 3.17 之前,ktime_t类型在 32 位或 64 位机器上有不同的表示方式。在 64 位 CPU 上,它被表示为纯 64 位的纳秒值,就像现在内核中的所有地方一样;而在 32 位 CPU 上,它则被表示为一个由两个 32 位字段组成的数据结构([seconds - nanoseconds]对)。

Hrtimer API 需要#include <linux/hrtimer.h>头文件。也就是说,在该头文件中,表征高分辨率定时器的结构体定义如下:

struct hrtimer {
    ktime_t                 _softexpires;
    enum hrtimer_restart    (*function)(struct hrtimer *);
    struct hrtimer_clock_base    *base;
    u8                     state;
[...]
};

数据结构中的元素已缩短到最严格的最小值,以覆盖本书的需求。接下来,我们将讨论它们的含义。

在使用 hrtimer 之前,必须先用hrtimer_init()进行初始化,定义如下:

void hrtimer_init(struct hrtimer *timer,
                  clockid_t which_clock,
                  enum hrtimer_mode mode);

在前面的函数中,timer是指向要初始化的 hrtimer 的指针。clock_id指示必须使用哪种类型的时钟来为该 hrtimer 提供时间。以下是一些常见的选项:

  • CLOCK_REALTIME:选择实时时间,即墙钟时间。如果系统时间发生变化,它可能会影响该定时器。

  • CLOCK_MONOTONIC:这是增量时间,不受系统变化的影响。然而,当系统进入休眠或挂起时,它会停止增加。

  • CLOCK_BOOTTIME:系统的运行时间。与CLOCK_MONOTONIC类似,区别在于它包括了睡眠时间。当系统挂起时,它仍会增加。

在前面的代码片段中,mode参数指示 hrtimer 应如何工作。以下是一些可能的选项:

  • HRTIMER_MODE_ABS:这意味着定时器在指定的绝对时间后到期。

  • HRTIMER_MODE_REL:该定时器将在指定的相对时间后过期。

  • HRTIMER_MODE_PINNED:该 hrtimer 绑定到一个 CPU 上。仅在启动 hrtimer 时才考虑此标志,以确保定时器触发并在与队列相同的 CPU 上执行回调。

  • HRTIMER_MODE_ABS_PINNED:这是第一和第三个标志的组合。

  • HRTIMER_MODE_REL_PINNED:这是第二个和第三个标志的组合。

在初始化 hrtimer 后,必须为其分配一个回调函数,当定时器过期时,该回调函数将被执行。以下代码片段显示了预期的原型:

enum hrtimer_restart callback(struct hrtimer *h);

hrtimer_restart是回调返回的类型。它必须是HRTIMER_NORESTART,表示定时器不需要重新启动(用于执行一次性操作),或者是HRTIMER_RESTART,表示定时器需要重新启动(用于模拟周期模式)。在第一种情况下,当返回HRTIMER_NORESTART时,如果需要,驱动程序必须显式地重新启动定时器(例如使用hrtimer_start())。而当返回HRTIMER_RESTART时,定时器的重新启动是隐式的,由内核处理。然而,驱动程序在回调返回之前需要重置超时时间。为了做到这一点,驱动程序可以使用hrtimer_forward(),其定义如下:

u64 hrtimer_forward(struct hrtimer *timer,
                    ktime_t now, ktime_t interval)

在前面的代码片段中,timer是需要转发的 hrtimer,now是定时器必须从哪个时间点开始转发,而interval是定时器需要转发的未来时间。需要注意的是,这仅更新定时器的过期时间,而不会重新排队定时器。

now参数可以通过不同方式获取,可以使用ktime_get()来获取当前的单调时钟时间,或者使用hrtimer_get_expires()来返回定时器应该过期的时间,之后再进行转发。以下代码片段说明了这一点:

hrtimer_forward(hrtimer, ktime_get(), ms_to_ktime(500));
/* or */
hrtimer_forward(handle, hrtimer_get_expires(handle),
                 ns_to_ktime(450));

在前面示例的第一行中,hrtimer 从当前时间开始向前推移 500 毫秒,而第二行则是从定时器原本应该过期的时间点开始,向前推移 450 纳秒。示例中的第一行等同于hrtimer_forward_now(),它将 hrtimer 从当前时间开始推送到指定时间(从现在开始)。其声明如下:

u64 hrtimer_forward_now(struct hrtimer *timer,
                          ktime_t interval)

现在定时器已经设置并且回调函数已定义,可以使用hrtimer_start()启动定时器,函数原型如下:

int hrtimer_start(struct hrtimer *timer, ktime_t time,
                    const enum hrtimer_mode mode);

在前面的代码片段中,mode表示定时器的过期模式,可以是HRTIMER_MODE_ABS,表示绝对时间值,或者是HRTIMER_MODE_REL,表示相对于当前时间的时间值。此参数必须与初始化时的模式参数一致。timer参数是指向已初始化的 hrtimer 的指针。最后,time是 hrtimer 的过期时间。由于它是ktime_t类型,提供了多种辅助函数,可以从不同的时间单位生成ktime_t元素,具体如下:

ktime_t ktime_set(const s64 secs,
                  const unsigned long nsecs);
ktime_t ns_to_ktime(u64 ns);
ktime_t ms_to_ktime(u64 ms);

在前面的列表中,ktime_set() 会根据给定的秒数和纳秒数生成一个 ktime_t 元素。ns_to_ktime()ms_to_ktime() 分别会根据给定的纳秒数或毫秒数生成一个 ktime_t 元素。

您也可能对返回纳秒/微秒数感兴趣,可以使用以下函数,给定一个 ktime_t 输入元素:

s64 ktime_to_ns(const ktime_t kt)
s64 ktime_to_us(const ktime_t kt)

此外,给定一个或两个 ktime_t 元素,您可以使用以下助手函数进行一些算术运算:

ktime_t ktime_sub(const ktime_t lhs, const ktime_t rhs);
ktime_t ktime_sub(const ktime_t lhs, const ktime_t rhs);
ktime_t ktime_add(const ktime_t add1, const ktime_t add2);
ktime_t ktime_add_ns(const ktime_t kt, u64 nsec);

要对 ktime 对象进行加减运算,可以分别使用 ktime_sub()ktime_add()ktime_add_ns() 会将 ktime_t 元素按指定的纳秒数进行递增。ktime_add_us() 是另一个微秒级的变体。对于减法操作,可以使用 ktime_sub_ns()ktime_sub_us()

在调用 hrtimer_start() 后,hrtimer 将被激活(启用)并入队到一个(按时间顺序排列的)每 CPU 桶中,等待到期。这个桶将局部于调用 hrtimer_start() 的 CPU,但不能保证回调会在这个 CPU 上运行(可能会发生迁移)。对于绑定到特定 CPU 的 hrtimer,您应当在初始化 hrtimer 时使用 *_PINNED 模式变体。

入队的 hrtimer 始终会启动。一旦定时器到期,其回调函数会被调用,取决于返回值,hrtimer 可能会重新排队,也可能不会。为了取消定时器,驱动程序可以使用 hrtimer_cancel()hrtimer_try_to_cancel(),声明如下:

int hrtimer_cancel(struct hrtimer *timer);
int hrtimer_try_to_cancel(struct hrtimer *timer);

如果定时器在调用时未处于激活状态,两个函数都会返回 0hrtimer_try_to_cancel() 如果定时器处于激活状态(正在运行但未执行回调函数)且已成功取消,将返回 1,如果回调函数正在执行,则返回 -1。另一方面,hrtimer_cancel() 如果回调函数尚未运行,将取消定时器;如果回调函数正在执行,将等待回调函数完成。当 hrtimer_cancel() 返回时,可以保证定时器已不再激活,并且其过期函数未在运行。

然而,驱动程序可以使用以下代码独立检查 hrtimer 回调是否仍在运行:

int hrtimer_callback_running(struct hrtimer *timer);

例如,hrtimer_try_to_cancel() 内部会调用 hrtimer_callback_running(),如果回调正在运行,则返回 -1

让我们编写一个模块示例,将我们的 hrtimer 知识付诸实践。我们首先从编写回调函数开始,如下所示:

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/hrtimer.h>
#include <linux/ktime.h>
static struct hrtimer hr_timer;
static enum hrtimer_restart timer_callback(struct hrtimer *timer)
{
    pr_info("Hello from timer!\n");
#ifdef PERIODIC_MS_500
    hrtimer_forward_now(timer, ms_to_ktime(500));
    return HRTIMER_RESTART;
#else
    return HRTIMER_NORESTART;
#endif
}

在前面的 hrtimer 回调函数中,我们可以决定以单次模式或周期模式运行。对于周期模式,用户必须定义 PERIODIC_MS_500,在这种情况下,定时器将在当前 hrtimer 时钟基准时间的基础上向前推迟 500 毫秒,然后重新排队。

然后,模块的其余实现如下所示:

static int __init hrtimer_module_init(void)
{;
    ktime_t init_time;
    init_time = ktime_set(1, 1000);
    hrtimer_init(&hr_timer, CLOCK_MONOTONIC,
                   HRTIMER_MODE_REL);
    hr_timer.function = &timer_callback;
    hrtimer_start(&hr_timer, init_time, HRTIMER_MODE_REL);
    return 0;
}
static void __exit hrtimer_module_exit(void) {
    int ret;
    ret = hrtimer_cancel(&hr_timer);
    if (ret)
        pr_info("Our timer is still in use...\n");
     pr_info("Uninstalling hrtimer module\n");
}
module_init(hrtimer_module_init);
module_exit(hrtimer_module_exit);

在前面的实现中,我们生成了一个初始的ktime_t元素,表示 1 秒和 1000 纳秒——即 1 秒和 1 毫秒,作为初始过期时长。当 hrtimer 第一次超时时,我们的回调函数被调用。如果定义了PERIODIC_MS_500,则 hrtimer 会延迟 500 毫秒触发,回调会在初始调用后定期触发(每 500 毫秒触发一次);否则,它是一次性调用。

实现工作延迟机制

延迟是一个将任务安排到未来执行的方式。这是一种推迟报告动作的方式。显然,内核提供了实现这种机制的设施;它允许您推迟函数(无论其类型如何)的调用,并在稍后执行。在内核中有三种这样的机制,如下所述:

  • 软中断:在原子上下文中执行

  • 任务 let:在原子上下文中执行

  • 工作队列:在进程上下文中执行

在接下来的三节中,我们将详细了解它们的实现。

软中断

正如其名称所示,kernel/softirq.c位于内核源代码树中,驱动程序若希望使用此 API,需包含<linux/interrupt.h>

软中断通过struct softirq_action结构表示,定义如下:

struct softirq_action {
    void (*action)(struct softirq_action *);
};

这个结构体嵌入了一个指针,指向当软中断被触发时要执行的函数。因此,您的软中断处理程序的原型应该如下所示:

void softirq_handler(struct softirq_action *h)

运行软中断处理程序会执行这个动作函数,它只有一个参数:指向相应softirq_action结构的指针。您可以通过open_softirq()函数在运行时注册软中断处理程序,如下所示:

void open_softirq(int nr,
                  void (*action)(struct softirq_action *))

nr表示软中断的索引,也被认为是软中断的优先级(其中 0 为最高优先级)。action是指向软中断处理程序的指针。可能的索引在以下代码片段中列出:

enum
{
    HI_SOFTIRQ=0,   /* High-priority tasklets */
    TIMER_SOFTIRQ,  /* Timers */
    NET_TX_SOFTIRQ, /* Send network packets */
    NET_RX_SOFTIRQ, /* Receive network packets */
    BLOCK_SOFTIRQ,  /* Block devices */
    BLOCK_IOPOLL_SOFTIRQ, /* Block devices with I/O polling
                           * blocked on other CPUs */
    TASKLET_SOFTIRQ,/* Normal Priority tasklets */
    SCHED_SOFTIRQ,  /* Scheduler */
    HRTIMER_SOFTIRQ,/* High-resolution timers */
    RCU_SOFTIRQ,    /* RCU locking */
    NR_SOFTIRQS     /* This only represent the number
                     * of softirqs type, 10 actually */
};

索引较低的软中断(优先级较高)会在索引较高的软中断(优先级较低)之前运行。内核中所有可用软中断的名称列在以下数组中:

const char * const softirq_to_name[NR_SOFTIRQS] = {
    "HI", "TIMER", "NET_TX", "NET_RX", "BLOCK", "BLOCK_IOPOLL", 
    "TASKLET", "SCHED", "HRTIMER", "RCU"
};

很容易在/proc/softirqs虚拟文件的输出中检查一些内容,如下所示:

root@udoo-labcsmart:~# cat /proc/softirqs
                    CPU0       CPU1       
          HI:       3535          1
       TIMER:    4211589    4748893
      NET_TX:    1277827         39
      NET_RX:    1665450          0
       BLOCK:       1978        201
    IRQ_POLL:          0          0
     TASKLET:     455761         33
       SCHED:    4212802    4750408
     HRTIMER:          3          0
         RCU:     438826     286874
root@udoo-labcsmart:~#

kernel/softirq.c中声明了一个NR_SOFTIRQS条目的struct softirq_action数组,如下所示:

static struct softirq_action softirq_vec[NR_SOFTIRQS] ;

该数组中的每一项可能包含一个——且只能包含一个——软中断。因此,最多可以注册NR_SOFTIRQS个软中断(实际上,在写作时的最后一个稳定版本 v5.10 中为 10 个)。以下是kernel/softirq.c中的代码片段:

void open_softirq(int nr,
                  void (*action)(struct softirq_action *))
{
    softirq_vec[nr].action = action;
}

一个具体的例子是网络子系统,它注册了它需要的软中断(在net/core/dev.c中),如下所示:

open_softirq(NET_TX_SOFTIRQ, net_tx_action);
open_softirq(NET_RX_SOFTIRQ, net_rx_action);

在已注册的软中断执行之前,它需要被激活或调度。为此,您需要调用raise_softirq()raise_softirq_irqoff()(如果中断已关闭),如下所示的代码片段:

void __raise_softirq_irqoff(unsigned int nr)
void raise_softirq_irqoff(unsigned int nr)
void raise_softirq(unsigned int nr)

第一个函数简单地在每 CPU 软中断位图中设置适当的位(在 kernel/softirq.c 中为每 CPU 分配的 struct irq_cpustat_t 数据结构中的 __softirq_pending 字段),如下所示:

irq_cpustat_t irq_stat[NR_CPUS] ____cacheline_aligned;
EXPORT_SYMBOL(irq_stat);

当检查标志时,允许其运行。此函数在此处仅用于学习目的,不应直接使用。

raise_softirq_irqoff 需要在禁用中断时调用。首先,它内部调用之前描述的 __raise_softirq_irqoff() 来激活软中断。之后,它通过 in_interrupt() 宏来检查是否从中断上下文(硬中断或软中断)中调用(该宏简单返回 current_thread_info()->preempt_count 的值,其中 0 表示启用了抢占,表明我们不在中断上下文中,大于 0 的值表示我们在中断上下文中)。如果 in_interrupt() > 0,则在中断上下文中不执行任何操作,因为软中断标志在任何 I/O IRQ 处理程序的退出路径上都会被检查(请参见 ARM 的 asm_do_IRQ() 或 x86 平台的 do_IRQ(),它们调用 irq_exit())。在这里,软中断在中断上下文中运行。但是,如果 in_interrupt() == 0,它将调用 wakeup_softirqd(),负责唤醒本地 CPU 的 ksoftirqd 线程(实际上是调度它),以确保尽快但这次在进程上下文中运行软中断。

raise_softirq,另一方面,首先调用 local_irq_save()(保存当前中断标志并在本地处理器上禁用中断)。然后调用之前描述的 raise_softirq_irqoff(),以便在本地 CPU 上调度软中断(请记住——此函数必须在本地 CPU 上禁用 IRQ 时调用)。最后,调用 local_irq_restore() 以恢复先前保存的中断标志。

有关软中断的几点需要记住:

  • 一个软中断永远不能抢占另一个软中断。只有硬件中断可以。软中断以高优先级执行,调度程序抢占被禁用但 IRQ 已启用。这使得软中断适合系统上最关键和重要的延迟处理。

  • 当处理程序在 CPU 上运行时,此 CPU 上的其他软中断被禁用。但是,软中断可以同时运行。在一个软中断运行时,另一个软中断(甚至是相同的软中断)可以在另一个处理器上运行。这是软中断相对于硬中断的主要优势之一,也是它们在可能需要大量 CPU 力的网络子系统中使用的原因。

  • 大多数情况下,在硬件中断处理程序的返回路径中调度软中断。如果在退出中断上下文时仍然挂起,它将在本地 ksoftirqd 线程被分配到 CPU 时在进程上下文中运行。它们的执行可能在以下情况下被触发:

    • 通过本地每 CPU 定时器中断(仅在 SMP 系统上,启用 CONFIG_SMP)。参见 timer_tick()update_process_times()run_local_timers()

    • 通过调用local_bh_enable()函数(主要由网络子系统调用,用于处理接收/发送软中断)。

    • 在任何 I/O IRQ 处理程序的退出路径上(参见do_IRQ,它调用irq_exit(),进而调用invoke_softirq())。

    • 当本地ksoftirqd线程被分配给 CPU 时(即被唤醒)。

负责遍历软中断待处理位图并执行它们的实际内核函数是__do_softirq(),该函数定义在kernel/softirq.c中。此函数总是在禁用本地 CPU 中断的情况下被调用。它完成以下任务:

  • 一旦被调用,该函数首先将当前每个 CPU 的待处理软中断位图保存在名为pending的变量中,并通过__local_bh_disable_ip在本地禁用软中断。

  • 然后,它重置当前每个 CPU 的待处理位图(该位图已被保存),然后重新启用中断(软中断在启用中断时运行)。

  • 此后,它进入一个while循环,检查保存的位图中的待处理软中断。如果没有待处理的软中断,它将执行每个待处理软中断的处理程序,并小心地增加其执行统计数据。

  • 在所有待处理的 IRQ 处理程序执行完毕后(即我们已经脱离了while循环),__do_softirq()再次读取每个 CPU 的待处理位图,以检查在while循环期间是否有新的软中断被调度。如果有待处理的软中断,整个过程将重新启动(基于goto循环),从第 2 步开始。这有助于处理例如自我重新调度的软中断。

然而,如果发生以下条件之一,__do_softirq()将不会重复执行:

  • 它已经重复执行了最多MAX_SOFTIRQ_RESTART次,该值在kernel/softirq.c中被设置为 10。这是软中断处理循环的上限,而不是前面描述的while循环的上限。

  • 它已经占用了 CPU 超过了MAX_SOFTIRQ_TIME,该值在kernel/softirq.c中被设置为 2 毫秒(msecs_to_jiffies(2)),因为这会阻止调度程序被启用。

如果发生前述两种情况之一,__do_softirq()将中断其循环并调用wakeup_softirqd(),以唤醒本地的ksoftirqd线程,后者将以进程上下文的方式执行待处理的软中断。由于do_softirq在内核中的多个点被调用,因此很可能会有另一次调用__do_softirq()来处理待处理的软中断,而ksoftirqd线程还没有机会运行。

关于 ksoftirqd 的一些说明

ksoftirqd是一个每个 CPU 上的内核线程,用于处理未服务的软中断。它会在内核启动过程中很早被创建,如kernel/softirq.c中所述,并在这里展示:

static __init int spawn_ksoftirqd(void)
{
    cpuhp_setup_state_nocalls(CPUHP_SOFTIRQ_DEAD, "softirq:dead",
                          NULL, takeover_tasklets);
    BUG_ON(smpboot_register_percpu_thread(&softirq_threads));
    return 0;
}
early_initcall(spawn_ksoftirqd);

在执行前述代码片段中的top命令后,我们可以看到一些ksoftirqd/<n>条目,其中<n>是运行ksoftirqd线程的 CPU 的逻辑 CPU 索引。由于ksoftirqd线程在进程上下文中运行,因此它们等同于经典的进程/线程,因此它们会争夺 CPU。如果ksoftirqd线程长时间占用 CPU,可能表明系统负载过重。

任务 let

在开始讨论任务 let之前,您必须注意到这些任务将会被移除出 Linux 内核,因此本节的目的纯粹是为了教学目的,帮助您理解它们在旧版内核模块中的使用。因此,您不应在开发中使用这些。

任务 let 是基于HI_SOFTIRQTASKLET_SOFTIRQ构建的下半部。基于HI_SOFTIRQ的任务 let 在基于TASKLET_SOFTIRQ的任务 let 之前运行。简而言之,任务 let 是 softirq 并遵循相同的规则。然而,与 softirq 不同的是,相同的任务 let 永远不会并发运行。任务 let API 非常基础且直观。

任务 let 由<linux/interrupt.h>中定义的struct tasklet_struct结构表示。这个结构的每个实例表示一个独特的任务 let,如以下代码片段所示:

struct tasklet_struct
{
	struct tasklet_struct *next;
	unsigned long state;
	atomic_t count;
	bool use_callback;
	union {
		void (*func)(unsigned long data);
		void (*callback)(struct tasklet_struct *t);
	};
	unsigned long data;
};

尽管此 API 计划被移除,但与其旧版实现相比,它已略微现代化。回调函数存储在callback()字段中,而不是func(),后者仍然保留以兼容旧实现。这个新回调仅接受一个指向tasklet_struct结构的指针作为唯一参数。处理程序将由底层的 softirq 执行。它相当于 softirq 中的action,具有相同的原型和相同的参数意义。data将作为其唯一参数传递。

执行callback()处理程序或func()处理程序,取决于任务 let 的初始化方式。任务 let 可以使用DECLARE_TASKLET()宏或DECLARE_TASKLET_OLD()宏静态初始化。这些宏的定义如下:

#define DECLARE_TASKLET_OLD(name, _func)       \
    struct tasklet_struct name = {             \
    .count = ATOMIC_INIT(0),            	   \
    .func = _func,                    	        \
}
#define DECLARE_TASKLET(name, _callback)       \
     struct tasklet_struct name = {            \
     .count = ATOMIC_INIT(0),                  \
     .callback = _callback,                    \
     .use_callback = true,                     \
}

从我们可以看到的情况,使用DECLARE_TASKLET_OLD()时,保留了旧的实现方式,并且将func()作为回调函数。因此,提供的处理程序原型必须如下所示:

void foo(unsigned long data);

通过使用DECLARE_TASKLET()callback字段作为处理程序,并且use_callback字段被设置为true(这是因为任务 let 核心检查这个值来确定必须调用的处理程序)。在这种情况下,回调的原型如下所示:

void foo(struct tasklet_struct *t)

在之前的代码段中,t指针在调用处理程序时由任务核心传递。它将指向你的任务。由于指向任务的指针作为参数传递给回调,通常将任务对象嵌入到一个更大的、用户特定的结构体中,指向该结构体的指针可以通过container_of()宏获得。为了实现这一点,你应该使用动态初始化,这可以通过tasklet_setup()函数来实现,其定义如下:

void tasklet_setup(struct tasklet_struct *t,
     void (*callback)(struct tasklet_struct *));

根据之前的原型,我们可以猜测,使用动态初始化时,我们别无选择,只能使用新的实现,其中callback字段被用作任务处理程序。

使用静态或动态方法取决于你需要实现的目标,例如,如果你希望任务对整个模块是唯一的,或者希望每个探测的设备拥有独立的任务,甚至更多,如果你需要对任务有直接或间接的引用。

默认情况下,当任务被调度时,初始化后的任务是可运行的:它被认为是启用的DECLARE_TASKLET_DISABLED是静态初始化默认禁用任务的替代方法。对于动态初始化的任务,除非在其动态初始化后调用tasklet_disable(),否则没有这种替代方法。禁用的任务将需要调用tasklet_enable()函数才能使其可运行。任务是通过tasklet_schedule()tasklet_hi_schedule()函数进行调度的(类似于触发 softirq)。你可以使用tasklet_disable() API 来禁用已调度或正在运行的任务。此函数会禁用任务,且仅当任务完成执行(假设它正在运行)后才返回。之后,任务仍然可以被调度,但在再次启用之前不会在 CPU 上运行。异步变种tasklet_disable_nosync()也可以使用,它会立即返回,即使任务的终止尚未发生。此外,一个已经被禁用多次的任务,应该启用相同次数(这是由内核通过struct tasklet对象中的count字段强制执行并验证的)。以下代码段说明了上述提到的任务 API 的定义:

DECLARE_TASKLET(name, _callback)
DECLARE_TASKLET_DISABLED(name, _callback);
DECLARE_TASKLET_OLD(name, func);
void tasklet_setup(struct tasklet_struct *t,
     void (*callback)(struct tasklet_struct *));
void tasklet_enable(struct tasklet_struct *t);
void tasklet_disable(struct tasklet_struct *t);
void tasklet_schedule(struct tasklet_struct *t);
void tasklet_hi_schedule(struct tasklet_struct *t);

内核在每个 CPU 上维护正常优先级和高优先级任务的两个队列(每个 CPU 维护自己的低优先级和高优先级队列对)。tasklet_schedule()将任务添加到其所在 CPU 的正常优先级队列中,并调度相关的软中断,使用TASKLET_SOFTIRQ标志。使用tasklet_hi_schedule()时,任务被添加到高优先级队列中(仍然是所在队列),并调度相关的软中断,使用HI_SOFTIRQ标志。当任务被调度时,其TASKLET_STATE_SCHED标志被设置,并且任务被排队执行。在执行时,TASKLET_STATE_RUN标志被设置,TASKLET_STATE_SCHED状态被移除,这样任务在执行过程中可以重新调度,无论是由任务本身还是在中断处理程序中触发。

对于一个已经调度但还未开始执行的任务,调用tasklet_schedule()不会有任何效果,从而确保任务只执行一次。任务可以自行重新调度,并且你可以在任务中安全地调用tasklet_schedule()。高优先级任务总是比正常任务先执行,因此应小心使用,否则可能会增加系统延迟。停止任务非常简单,只需调用tasklet_kill(),如下代码片段所示,这将防止任务再次运行,或者如果任务当前已经被调度执行,则等待其完成后再终止。如果任务重新调度自己,在调用此函数之前,你应先阻止任务重新调度自己:

void tasklet_kill(struct tasklet_struct *t);

编写你的任务处理程序

话虽如此,让我们来看一些使用案例,如下所示:

# #include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/interrupt.h>    /* for tasklets api */
/* Tasklet handler, that just prints the handler name */
void tasklet_function(struct tasklet_struct *t)
{
    pr_info("running %s\n", __func__);
}
DECLARE_TASKLET(my_tasklet, tasklet_function);
static int __init my_init(void)
{
    /* Schedule the handler */
    tasklet_schedule(&my_tasklet);
    pr_info("tasklet example\n");
    return 0;
}
void my_exit( void )
{
    /* Stop the tasklet before we exit */
    tasklet_kill(&my_tasklet);
    pr_info("tasklet example cleanup\n");
    return;
}
module_init(my_init);
module_exit(my_exit);
MODULE_AUTHOR("John Madieu <john.madieu@gmail.com>");
MODULE_LICENSE("GPL");

在前面的代码片段中,我们静态声明了我们的my_tasklet任务,并定义了当该任务被调度时应调用的函数。由于我们没有使用_OLD变体,因此我们将处理程序的原型定义为与任务对象中的callback字段相同。

注意

任务 API 已被弃用,建议考虑使用线程化的 IRQ 代替。

工作队列

自 Linux 内核 2.6 以来添加的,最常用且简单的延迟机制是工作队列。作为一种延迟机制,它采取了与我们看到的其他机制相反的方式,只在可抢占的上下文中运行。它是唯一在需要休眠的情况下可用的选择,除非你隐式创建一个内核线程或使用线程化的中断。也就是说,工作队列是建立在内核线程之上的,出于这个简单的原因,本书中我们不会深入讲解内核线程。

在工作队列子系统的核心部分,有两个数据结构能够很好地解释其背后的概念,如下所示:

  • 要推迟的工作(称为工作项),在内核中通过 struct work_struct 实例表示,指示要执行的处理函数。如果你需要在工作提交到工作队列后延迟执行,内核提供了一个 struct delayed_work 实例。工作项是一个简单的结构,仅包含指向要调度的异步执行函数的指针。总结来说,我们可以列出两种工作项结构,如下所示:

    • work_struct 结构体,用于在系统允许时尽快调度任务运行。

    • delayed_work 结构体,用于在至少给定的时间间隔后调度任务运行。

  • 工作队列本身,由 struct workqueue_struct 实例表示,是一个放置工作项的结构体。它是一个工作项的队列。

除了这些数据结构之外,还有两个通用术语你应该熟悉,如下所示:

  • 工作线程,这些线程专门执行并从队列中一个接一个地拉取函数。

  • 工作线程池:这是一个工作线程的集合(线程池),用于更好地管理工作线程。

使用工作队列的第一步是创建一个工作项,由 struct work_struct 或延迟变体的 struct delayed_work 表示,定义在 linux/workqueue.h 中。内核提供了 DECLARE_WORK 宏用于静态声明和初始化工作结构,或动态使用 INIT_WORK 宏。如果你需要延迟工作,可以使用 INIT_DELAYED_WORK 宏进行动态分配和初始化,或使用 DECLARE_DELAYED_WORK 进行静态声明。你可以在以下代码片段中看到这些宏的应用:

DECLARE_WORK(name, function)
DECLARE_DELAYED_WORK(name, function)
INIT_WORK(work, func );
INIT_DELAYED_WORK( work, func);

下面是我们的工作项结构的样子:

struct work_struct {
    atomic_long_t data;
    struct list_head entry;
    work_func_t func;
};
struct delayed_work {
    struct work_struct work;
    struct timer_list timer;
    struct workqueue_struct *wq;
    int cpu;
};

func 字段,类型为 work_func_t,告诉我们更多关于 work 函数头部的信息,如此处所示:

typedef void (*work_func_t)(struct work_struct *work);

work 是一个输入参数,对应于需要调度的工作结构。如果你提交了延迟工作,它将对应于 delayed_work.work 字段。然后,需要使用 to_delayed_work() 函数来获取底层的延迟工作结构,如以下代码片段所示:

struct delayed_work *to_delayed_work(
                struct work_struct *work)

工作队列基础设施允许驱动程序创建一个专用的内核线程(工作队列),称为工作线程,用于执行工作函数。可以通过以下函数创建一个新的工作队列:

struct workqueue_struct *create_workqueue(const char *name)
struct workqueue_struct *create_singlethread_workqueue(
                                          const char *name)

create_workqueue()在系统的每个 CPU 上创建一个专用线程(工作者线程)。例如,在一个 8 核系统上,将创建 8 个内核线程来运行提交到您工作队列的工作。除非有充分的理由要为每个 CPU 创建一个线程,否则应优先选择单线程变体。在大多数情况下,一个单系统内核线程应该足够了。在这种情况下,您应该使用create_singlethread_workqueue(),它创建一个单线程工作队列。普通工作或延迟工作可以排队到同一个队列。为了在创建的工作队列上安排工作,您可以使用queue_work()queue_delayed_work(),具体定义如下:

bool queue_work(struct workqueue_struct *wq,
                 struct work_struct *work)
bool queue_delayed_work(struct workqueue_struct *wq,
                        struct delayed_work *dwork,
                        unsigned long delay)

这些函数如果工作已经在队列中,则返回false,否则返回truequeue_dalayed_work()用于安排稍后执行的工作项(延迟执行)。延迟的时间单位是一个节拍。但是,有 API 可以将毫秒和微秒转换为节拍,定义如下:

unsigned long msecs_to_jiffies(const unsigned int m)
unsigned long usecs_to_jiffies(const unsigned int u)

以下示例将使用 200 毫秒作为延迟:

queue_delayed_work(my_wq, &drvdata->tx_work,
                  usecs_to_jiffies(200));

请不要期望此延迟精确,因为延迟将四舍五入为最接近的节拍值。因此,即使请求 200 微秒,也应该期望一个节拍。可以通过调用cancel_delayed_work()cancel_delayed_work_sync()cancel_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)

cancel_work_sync()同步取消给定的工作——换句话说,它取消工作并等待其执行完成。内核保证此函数返回后,work不会在任何 CPU 上挂起或执行,即使工作迁移到另一个工作队列或重新排队。如果work处于挂起状态,则返回true,否则返回false

cancel_delayed_work()异步取消延迟条目。如果dwork处于挂起状态并已取消,则返回true(实际上是非零值),如果未挂起,则返回false,可能是因为实际上正在运行,因此在cancel_delayed_work()返回后可能仍在运行。为确保工作确实运行到结束,您可以使用flush_workqueue()来刷新给定队列中的每个工作项,或者使用cancel_delayed_work_sync(),这是cancel_delayed_work()的同步版本。

完成工作队列后,应使用destroy_workqueue()销毁它,如下所示:

void flush_workqueue(struct worksqueue_struct * queue); 
void destroy_workqueue(structure workqueque_struct *queue);

在等待任何挂起的工作执行时,_sync变体函数会休眠,因此只能从进程上下文中调用。

内核全局工作队列——共享队列

在大多数情况下,您的代码并不一定需要自己专用线程集合的性能,并且因为create_workqueue()为每个 CPU 创建一个工作线程,因此在非常大的多 CPU 系统上使用它可能不是一个好主意。您可能想要使用内核共享队列,该队列已经预先分配了一组内核线程(在启动期间,通过workqueue_init_early()函数)来运行工作。

这个内核全局工作队列就是所谓的system_wq工作队列,在kernel/workqueue.c中定义。实际上,每个 CPU 都有一个实例,每个实例由一个名为events/n的专用线程支持,其中n是绑定该线程的处理器编号(或索引)。

您可以使用以下函数之一将工作项排入默认系统工作队列:

int schedule_work(struct work_struct *work);
int schedule_delayed_work(struct delayed_work *dwork,
                          unsigned long delay);
int schedule_work_on(int cpu,
               struct work_struct *work);
int schedule_delayed_work_on(int cpu,
                struct delayed_work *dwork,
                unsigned long delay);

schedule_work()会立即调度工作,并在当前处理器的工作线程唤醒后尽快执行该工作。使用schedule_delayed_work()时,工作将在未来的某个时间被放入队列,延迟计时器触发后才会执行。_on变体用于在特定的 CPU 上调度工作(不一定是当前的 CPU)。这些函数中的每一个都会将工作排入系统的共享工作队列system_wq,该队列在kernel/workqueue.c中定义如下:

struct workqueue_struct *system_wq __read_mostly;
EXPORT_SYMBOL(system_wq);

您还应该注意,由于该系统工作队列是共享的,因此不应将运行时间过长的工作排入队列,否则可能会减慢其他竞争工作,从而使它们在执行前等待的时间比应有的更长。

为了刷新内核全局工作队列——即确保给定的工作批次完成——我们可以使用flush_scheduled_work(),如下所示:

void flush_scheduled_work(void);

flush_scheduled_work()是一个包装器,它在system_wq上调用flush_workqueue()。请注意,system_wq工作队列中可能有您没有提交且无法控制的工作。因此,完全刷新此工作队列是过度的,建议使用cancel_delayed_work_sync()cancel_work_sync()来代替。

注意

除非有强烈的理由创建专用线程,否则推荐使用默认的(内核全局)线程。

新一代工作队列

原始(现在的遗留)工作队列实现使用了两种类型的工作队列:一种是整个系统共享单个线程,另一种是每个 CPU 都有一个线程。然而,对于越来越多的 CPU,这导致了一些局限性,具体如下:

  • 在非常大的系统上,内核可能会耗尽init进程启动所需的资源。

  • 多线程工作队列提供了较差的并发管理,因为它们的线程与系统上其他线程争夺 CPU。当 CPU 竞争者增多时,这会引入一些开销——也就是比必要的更多的上下文切换。

  • 消耗了远超实际需要的资源。

此外,需要动态或细粒度并发控制的子系统必须实现自己的线程池。因此,设计了新的工作队列 API,传统的工作队列 API(create_workqueue()create_singlethread_workqueue()create_freezable_workqueue())将被移除,尽管它们实际上是围绕新 API 设计的封装,所谓的并发管理工作队列cmwq),使用由所有工作队列共享的每个 CPU 的工作池,从而自动提供动态且灵活的并发级别,抽象化这些细节供 API 用户使用。

cmwq

cmwq 是工作队列 API 的升级版。使用这个新 API 意味着你在 alloc_workqueue() 函数和 alloc_ordered_workqueue() 宏之间做出选择来创建一个工作队列。它们都分配一个工作队列,并在成功时返回指向它的指针,在失败时返回 NULL。返回的工作队列可以通过 destroy_workqueue() 函数释放。你可以在以下代码片段中看到代码的示例:

struct workqueue_struct *alloc_workqueue(const char *fmt,
                             unsigned int flags,
                             int max_active, ...);
#define alloc_ordered_workqueue(fmt, flags, args...) [...]
void destroy_workqueue(struct workqueue_struct *wq)

fmt 是工作队列名称的 printf 格式,args...fmt 的参数。

destroy_workqueue() 在你完成工作队列的使用后需要调用。当前待处理的所有工作会首先完成,然后内核才会真正销毁工作队列。alloc_workqueue() 基于 max_active 创建一个工作队列,max_active 定义了并发级别,通过限制该工作队列在任何给定 CPU 上同时执行的工作(任务,实际上是)数量。例如,max_active 值为 5 时,意味着每个 CPU 上最多只能同时执行 5 个该工作队列的工作项。另一方面,alloc_ordered_workqueue() 创建一个按队列顺序逐个处理每个工作项的工作队列(即先进先出FIFO)顺序)。

flags 控制工作项如何以及何时排队、分配执行资源、调度和执行。在这个新的 API 中使用了各种标志,我们应该花些时间讨论一下,具体如下:

  • WQ_UNBOUND:传统的工作队列每个 CPU 有一个工作线程,并设计为在提交任务的 CPU 上运行任务。内核调度器别无选择,只能始终将工作线程调度到定义它的 CPU 上。采用这种方法时,即便是单个工作队列也能够防止 CPU 处于空闲状态并被关闭,从而导致增加的功耗或不良的调度策略。WQ_UNBOUND 关闭了上述行为。工作项不再绑定到某个 CPU 上,因此称为无绑定工作队列。不再存在本地性,调度器可以根据需要在任何 CPU 上重新调度工作线程。现在,调度器拥有最终决定权,可以平衡 CPU 负载,尤其对于长时间运行和有时需要大量 CPU 资源的任务。

  • WQ_MEM_RECLAIM:这个标志需要在那些需要在内存回收路径中保证前进进度的工作队列中设置(当空闲内存低得危险时,系统处于内存压力状态)。在这种情况下,GFP_KERNEL分配可能会阻塞并导致整个工作队列死锁。工作队列随后将保证至少有一个准备好的工作线程——一个所谓的“救援线程”——为其预留,无论内存压力如何,以便它能够向前推进。对于每个设置了此标志的工作队列,都会分配一个救援线程。

假设我们在工作队列W中有三个工作项(w1w2w3)。w1执行一些工作后,等待w3完成(假设它依赖于w3的计算结果)。之后,w2(与其他工作项无关)进行一些kmalloc()分配(GFP_KERNEL),可是——内存不足。在w2被阻塞时,它仍然占用W的工作队列。这导致w3无法运行,尽管w2w3之间没有依赖关系。由于内存不足,无法为w3分配新线程。预分配的线程肯定能解决这个问题,方法不是神奇地为w2分配内存,而是通过运行w3来让w1继续它的工作,以此类推。只要有足够的内存,w2会尽快继续其进程。这个预分配的线程,如果你认为工作队列可能会在内存回收路径中使用,就是所谓的WQ_MEM_RECLAIM标志。从这次提交开始,该标志取代了旧有的WQ_RESCUER标志:git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=493008a8e475771a2126e0ce95a73e35b371d277

注意

内存回收是 Linux 在内存分配路径上的一个机制,其操作是:在将当前内存的内容移动到其他地方后,才会进行内存分配。

  • WQ_FREEZABLE:此标志用于电源管理目的。设置此标志的工作队列将在系统挂起或休眠时被冻结。在冻结路径上,所有当前工作项将被处理。当冻结完成后,系统解冻之前将不会执行任何新工作项。与文件系统相关的工作队列可能会使用此标志(即确保对文件的修改被推送到磁盘,或者在冻结路径上创建休眠映像,并且在创建休眠映像后,磁盘上不再进行任何修改。在这种情况下,不可冻结项或做法不同可能会导致文件系统损坏。例如,所有fs/xfs/xfs_super.c)都确保一旦冻结基础设施冻结内核线程并创建休眠映像,磁盘上不再进行任何更改。如果你的工作队列可以作为系统的休眠/挂起/恢复过程的一部分运行任务,绝对不应该设置此标志。有关此主题的更多信息,请参见Documentation/power/freezing-of-tasks.txt,并查看freeze_workqueues_begin()thaw_workqueues()内核函数。

  • WQ_HIGHPRI:设置此标志的任务会立即运行,并且不会等待 CPU 变得可用。此标志用于排队需要高优先级执行的工作项的工作队列。这些工作队列的工作线程具有较高的优先级(较低的 nice 值)。在早期的 cmwq 中,高优先级工作项会被排队到全局普通优先级工作列表的头部,以便它们能立即运行。如今,普通优先级和高优先级工作队列之间没有交互,因为每个工作队列都有自己的工作列表和自己的工作池。高优先级工作队列的工作项被排队到目标 CPU 的高优先级工作池中。此工作队列中的任务不应阻塞太多。如果你不希望你的工作项与普通或较低优先级的任务竞争 CPU 资源,可以使用此标志。例如,Crypto 和块设备子系统就使用此标志。

  • WQ_CPU_INTENSIVE:CPU 密集型工作队列的工作项可能会消耗大量 CPU 周期,并且不参与工作队列的并发管理。相反,就像任何其他任务一样,它们的执行由系统调度程序调控,这使得此标志对于可能消耗大量 CPU 时间的绑定工作项非常有用。尽管系统调度程序控制它们的执行,但并发管理控制它们执行的开始,且可运行的非 CPU 密集型工作项可能会导致 CPU 密集型工作项的延迟。cryptodm-crypt子系统使用此类工作队列。为了防止此类任务延迟其他非 CPU 密集型工作项的执行,在工作队列代码确定 CPU 是否可用时,它们将不被考虑。

为了与旧的工作队列 API 保持兼容并具备功能兼容性,进行了以下映射,以保持该 API 与原始 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 | WQ_UNBOUND|WQ_MEM_RECLAIM, 1)

总结来说,alloc_ordered_workqueue()实际上取代了create_freezable_workqueue()create_singlethread_workqueue()(根据此提交:git.kernel.org/pub/scm/linux/kernel/git/next/linux-next.git/commit/?id=81dcaf6516d8)。通过alloc_ordered_workqueue()分配的工作队列是无绑定的,并且max_active被设置为1

当调度工作队列中的任务时,使用queue_work_on()将工作项排队到特定 CPU 上的任务将在该 CPU 上执行。通过queue_work()排队的工作项会优先选择排队的 CPU,但无法保证局部性。

注意

注意,schedule_work()是一个包装器,调用的是系统工作队列(system_wq)上的queue_work(),而schedule_work_on()queue_work_on()的包装器。此外,记住以下内容:system_wq = alloc_workqueue("events", 0, 0);。你可以查看内核源代码中的kernel/workqueue.c文件中的workqueue_init_early()函数,了解其他系统级工作队列是如何创建的。

我们已经完成了新的 Linux 内核工作队列管理实现,即 cmwq。由于工作队列可以用来延迟中断处理程序中的工作任务,因此我们可以继续学习下一部分,了解如何处理 Linux 内核中的中断。

内核中断处理

除了服务进程和用户请求外,Linux 内核的另一个任务是管理硬件并与硬件通信。这是通过中断实现的,可以是从 CPU 到设备的中断,也可以是从设备到 CPU 的中断。中断是外部硬件设备向处理器发送的信号,请求立即处理。在中断对 CPU 可见之前,必须通过中断控制器启用该中断。中断控制器本身就是一个设备,主要任务是将中断路由到 CPU。

Linux 内核允许我们为感兴趣的中断提供处理程序,以便当这些中断被触发时,我们的处理程序会执行。

中断是设备停止内核并告诉它发生了有趣或重要的事情的方式。这些在 Linux 系统中称为 IRQ(中断请求)。中断的主要优势在于避免了设备轮询。由设备决定其状态是否发生变化,而不是由我们去轮询它。

若要在中断发生时收到通知,您需要注册该 IRQ,提供一个称为中断处理程序的函数,该函数将在每次该中断被触发时被调用。

设计和注册中断处理程序

当一个中断处理程序被执行时,它会在本地 CPU 上禁用中断的情况下运行。这就涉及到在设计中断服务例程ISR)时需要遵守某些约束,如下所示:

  • 执行时间:由于 IRQ 处理程序是在本地 CPU 上禁用中断的情况下运行,因此代码必须尽可能简短、小巧,并且足够快速,以确保能够快速重新启用之前禁用的本地 CPU 中断,以免错过任何进一步发生的 IRQ。耗时的 IRQ 处理程序可能会显著改变系统的实时特性并使其变慢。

  • 执行上下文:由于中断处理程序是在原子上下文中执行的,因此禁止进行睡眠(或可能导致睡眠的其他机制——如互斥锁、从内核到用户空间或反向复制数据等)。任何需要或涉及睡眠的代码必须推迟到另一个、更安全的上下文中(即进程上下文)。

IRQ 处理程序需要传递两个参数:安装处理程序的中断线路和外设的唯一设备 IDUDI)(通常作为上下文数据结构使用——即指向相关硬件设备的每设备或私有结构的指针),如下所示:

typedef irqreturn_t (*irq_handler_t)(int, void *);

设备驱动程序希望为给定的 IRQ 注册中断处理程序时,应调用 devm_request_irq(),该函数在 <linux/interrupt.h> 中定义,如下所示:

devm_request_irq(struct device *dev, unsigned int irq,
                  irq_handler_t handler,
                  unsigned long irqflags, 
                  onst char *devname, void *dev_id)

上述函数参数列表中的 dev 是负责 IRQ 线路的设备,irq 代表中断线路(即发出中断的设备的中断号),用于注册 handler。在验证请求之前,内核将确保请求的中断是有效的,并且不会已经分配给另一个设备,除非两个设备都请求共享该 irq 线路(借助 flags)。handler 是指向中断处理程序的函数指针,flags 代表中断标志。namedev,每个注册的处理程序都应具有唯一的 name,并且对于共享 IRQ 线路来说不能为 NULL,因为它是内核 IRQ 核心用来识别设备的标识符。常见的使用方式是提供指向设备结构的指针或指向任何每设备(并且可能对处理程序有用的)数据结构的指针,因为当中断发生时,中断线路(irq)和此参数都会传递给注册的处理程序,后者可以将此数据作为上下文数据用于进一步处理。

flags 通过以下掩码来改变 IRQ 线路或其处理程序的状态或行为,这些掩码可以通过按位“或”运算来形成最终所需的位掩码,以满足您的需求:

#define IRQF_SHARED 0x00000080
#define IRQF_PROBE_SHARED 0x00000100
#define IRQF_NOBALANCING 0x00000800
#define IRQF_IRQPOLL 0x00001000
#define IRQF_ONESHOT 0x00002000
#define IRQF_NO_SUSPEND 0x00004000
#define IRQF_FORCE_RESUME 0x00008000
#define IRQF_NO_THREAD 0x00010000
#define IRQF_EARLY_RESUME 0x000200002
#define IRQF_COND_SUSPEND 0x00040000

请注意,flags 也可以是 0。接下来我们将解释一些重要的标志——其余的请用户自行探索 include/linux/interrupt.h,以下是我们将详细讨论的标志:

  • IRQF_NOBALANCING 排除该中断不参与 IRQ 平衡,这是一个将中断分布/重新定位到不同 CPU 上的机制,旨在提高性能。它相当于防止该 IRQ 的 CPU 亲和性被更改。这个标志在多核系统上才有意义,可能有助于为时钟源或时钟事件设备提供灵活的设置,避免将事件错误地归属到错误的核心。

  • IRQF_IRQPOLL:该标志允许实现一个 irqpoll 机制,旨在修复中断问题,意味着该处理程序应该被添加到已知的中断处理程序列表中,当给定的中断没有被处理时,可以查看该列表。

  • IRQF_ONESHOT:通常,实际的中断线路在其硬中断处理程序完成后会被重新启用,无论它是否唤醒线程化的处理程序。这个标志会在硬中断处理程序完成后保持中断线路禁用。它必须在线程化中断上设置(稍后我们会讨论),对于这些中断,必须保持中断线路禁用,直到线程化的处理程序完成,之后才会重新启用。

  • IRQF_NO_SUSPEND 不会在系统休眠/挂起期间禁用 IRQ。它的意思是中断能够唤醒处于挂起状态的系统。这种 IRQ 可能是定时器中断,甚至在系统挂起期间也可能触发并需要处理。整个 IRQ 线路都受此标志的影响,因此如果 IRQ 是共享的,每个为该共享线路注册的处理程序都会执行,而不仅仅是安装此标志的处理程序。你应尽量避免同时使用 IRQF_NO_SUSPENDIRQF_SHARED

  • IRQF_FORCE_RESUME 即使在设置了 IRQF_NO_SUSPEND 的情况下,也会启用系统恢复路径中的 IRQ。

  • IRQF_NO_THREAD 防止中断处理程序被线程化。这个标志会覆盖内核的 threadirqs 命令行选项,该选项强制所有中断都进行线程化。此标志的引入是为了解决一些中断(例如定时器中断,即使在所有中断处理程序都强制线程化时,也无法线程化)无法线程化的问题。

  • IRQF_TIMER 将该处理程序标记为特定于系统定时器中断。它有助于在系统挂起期间不禁用定时器 IRQ,以确保正常恢复,并在启用完全抢占(即 PREEMPT_RT)时不对它们进行线程化。它只是 IRQF_NO_SUSPEND | IRQF_NO_THREAD 的别名。

  • IRQF_EARLY_RESUME系统核心syscore)操作的恢复时间点,而不是在设备恢复时,提前恢复 IRQ。以下链接指向引入该支持的提交消息:lkml.org/lkml/2013/11/20/89

  • IRQF_SHARED允许多个设备共享中断线。但是,所有需要共享该中断线的设备驱动程序必须设置此标志;否则,处理程序注册将失败。

我们还必须考虑中断处理程序的irqreturn_t返回类型,因为它可能涉及在处理程序返回后进行进一步的操作。可能的返回值如下所示:

  • IRQ_NONE:在共享中断线中,一旦发生中断,内核的 IRQ 核心会依次遍历为该线注册的处理程序,并按注册顺序执行它们。然后,驱动程序有责任检查是否是其设备发出了中断。如果中断不是来自其设备,它必须返回IRQ_NONE,以指示内核调用下一个注册的中断处理程序。此返回值通常用于共享中断线,因为它通知内核该中断不是来自我们的设备。但是,如果给定 IRQ 线上的 100,000 个中断中有 99,900 个没有得到处理,内核就会假设该 IRQ 某种程度上已卡住,抛出诊断信息,并尝试关闭该 IRQ。关于这方面的更多信息,您可以查看内核源代码中的__report_bad_irq()函数。

  • IRQ_HANDLED:如果中断已成功处理,则应返回此值。在线程中断中,这个值表示在控制器级别确认了中断(而不会唤醒线程处理程序)。

  • IRQ_WAKE_THREAD:在线程中断处理程序中,硬中断处理程序必须返回此值以唤醒处理程序线程。在这种情况下,只有在先前通过devm_request_threaded_irq()注册的线程处理程序中,才会返回IRQ_HANDLED。我们将在本章后面讨论这一点。

    注意

    您绝不应该在中断处理程序内重新启用 IRQ,因为这会允许“中断重入”。

devm_request_irq()request_irq()的受管版本,定义如下:

int request_irq(unsigned int irq, irq_handler_t handler,
                unsigned long flags, const char *name,
                void *dev) 

它们的变量含义相同。如果驱动程序使用了受管版本,IRQ 核心将负责释放资源。在其他情况下,例如在卸载路径或设备离开时,驱动程序必须通过使用free_irq()来注销中断处理程序,从而释放 IRQ 资源,定义如下:

void free_irq(unsigned int irq, void *dev_id)

free_irq()会移除处理程序(当涉及共享中断时,通过dev_id来标识)并禁用中断线。如果中断线是共享的,处理程序仅从该irq的处理程序列表中移除,并且当最后一个处理程序被移除时,将来会禁用中断线。此外,如果可能,您的代码必须确保在调用此函数之前,实际禁用驱动卡上的中断,因为遗漏这一步可能会导致伪中断(spurious IRQ)。

关于中断,有一些事项值得在此提及,您永远不应忘记,具体如下:

  • 在 Linux 系统中,当 CPU 正在执行 IRQ 的处理程序时,所有中断都在该 CPU 上被禁用,且正在服务的中断会在其他所有核心上被屏蔽。这意味着中断处理程序不需要具备可重入性,因为在当前处理程序完成之前,永远不会接收到相同的中断。然而,除了正在服务的中断,其他所有中断都保持启用(或者我们可以说,保持不变)在其他核心上,因此其他中断会继续被服务,尽管当前的中断线总是被禁用,且本地 CPU 上的进一步中断也被禁用。结果,相同的中断处理程序永远不会并发执行来处理嵌套的中断。这使得编写中断处理程序变得更加容易。

  • 需要禁用中断运行的关键区域应尽可能限制。为了记住这一点,可以告诉自己,中断处理程序已经中断了其他代码,并且需要将 CPU 交还给其他任务。

  • 中断上下文有其自己的(固定且相当小的)栈大小。因此,在运行 ISR 时禁用 IRQ 是完全合理的,因为可重入性可能会导致栈溢出,特别是当发生过多的抢占时。

  • 中断处理程序不能阻塞;它们不在进程上下文中运行。因此,您不能在中断处理程序中执行以下操作:

    • 你不能向用户空间传输数据或从用户空间传输数据,因为这可能会阻塞。

    • 你不能进入休眠状态或依赖可能导致休眠的代码,比如调用wait_event()、使用除GFP_ATOMIC之外的任何内存分配标志,或使用互斥量/信号量。线程化的处理程序可以处理这些情况。

    • 你不能触发或调用schedule()

      注意

      如果设备在 IRQ 被禁用(或屏蔽)时发出 IRQ 请求(在控制器层面),它将完全无法处理(在流程处理程序中被屏蔽)。但是,如果 IRQ 启用(或取消屏蔽)时该请求仍然挂起(在设备层面),中断会立即发生。

      中断的不可重入性概念意味着,如果中断已经处于活动状态,它不能再次进入,直到活动状态被清除。

理解顶半部和底半部的概念

外部设备向 CPU 发送中断请求,以便信号特定事件或请求服务。如前所述,糟糕的中断管理可能显著增加系统的延迟,并降低其实时性能。我们还提到,中断处理——至少是硬中断处理程序——必须非常快速,不仅为了保持系统响应性,还为了不丢失其他中断事件。

这里的思路是将中断处理程序分成两部分。第一部分(实际上是一个函数)将在所谓的硬中断上下文中运行,并禁用中断,执行最少的必要工作(如进行一些快速的健全性检查——本质上是时间敏感的任务,读写硬件寄存器,快速处理这些数据,并向触发中断的设备确认中断)。这一部分即是 Linux 系统中的上半部分。然后,上半部分将调度一个线程处理程序,后者将运行所谓的下半部分函数,重新启用中断,这是中断的第二部分。下半部分可以执行耗时的操作(如缓冲区处理)和可能需要休眠的任务,因为它是在一个线程中运行的。

这种分割会显著提高系统的响应性,因为禁用 IRQ 的时间被减少到最小,并且由于下半部分在内核线程中运行,它们与运行队列中的其他进程争夺 CPU 资源。此外,它们可能已经设置了实时属性。上半部分实际上是通过devm_request_irq()注册的处理程序。当使用devm_request_threaded_irq()时,正如我们将在下一节看到的,上半部分是传递给该函数的第一个处理程序。

如前所述,在实现工作延迟机制部分,下半部分现在代表了从中断处理程序内调度的任何任务(或工作)。下半部分是使用工作延迟机制设计的,这些我们之前已经看到过。

根据你选择的类型,它可能会在(软件)中断上下文或进程上下文中运行。这些包括 softirqs、tasklets、工作队列和线程化 IRQ。

注意

Tasklets 和 softirqs 与“线程中断”机制无关,因为它们在各自的特殊(原子)上下文中运行。

由于 softirq 处理程序在高优先级下运行,并且调度器抢占被禁用,直到它们完成之前不会将 CPU 让给进程/线程,因此在使用它们进行下半部分委派时必须小心。由于现在为特定进程分配的时间片可能会有所不同,因此没有严格的规则来规定 softirq 处理程序完成所需的时间,以免减慢系统速度,因为内核无法为其他进程分配 CPU 时间。我认为不应超过半个时钟周期。

硬件中断(实际上是上半部分)必须尽可能快速,通常只是读写 I/O 内存。任何其他计算应推迟到下半部分处理,下半部分的主要目标是执行由上半部分未完成的、与中断无关的耗时工作。关于上半部分和下半部分的工作分配没有明确的指导方针。以下是一些建议:

  • 与硬件相关或时间敏感的工作可以在上半部分执行。

  • 如果工作确实不需要中断,它可以在上半部分执行。

  • 从我的角度来看,其他所有工作都可以推迟并在底半部分执行,底半部分会在启用中断时运行,并且在系统较空闲时进行处理。

  • 如果硬中断(hard IRQ)足够快速,能够在几微秒内处理并确认中断,那么完全没有必要使用底半部分的委托。

与线程化 IRQ 处理程序的工作

线程化中断处理程序的引入是为了减少在中断处理程序中花费的时间,并将剩余的工作(即处理)推迟到内核线程中。因此,硬中断部分(top half)会进行快速的合理性检查,例如确保中断来自其设备并相应地唤醒底半部分。线程化的中断处理程序将在自己的线程中运行,可能是在其父线程中(如果它们有父线程),或者在一个独立的内核线程中。此外,专用的内核线程可以设置实时优先级,尽管它默认运行在正常的实时优先级(即,MAX_USER_RT_PRIO/2,如你在kernel/irq/manage.c中的setup_irq_thread()函数中看到的)。

基于线程化中断的通用规则很简单:将硬中断处理程序保持尽可能简单,并将尽可能多的工作推迟到内核线程中(最好是将所有工作推迟)。如果你希望请求线程化中断处理,应该使用devm_request_threaded_irq()。它的原型如下:

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);

这个函数接受两个特殊参数,我们应该花一些时间来了解它们,handlerthread_fn。它们在这里进行了更详细的说明:

  • handler 会在中断发生时立即运行,处于中断上下文中,充当硬中断处理程序。它的工作通常包括读取中断原因(在设备的状态寄存器中)以确定是否以及如何处理该中断(这在IRQ_NONE中很常见。这个返回值通常只有在共享中断线中才有意义)。

如果这个硬中断处理程序能够足够快地完成中断处理(这不是一个普遍规则,但假设不超过半个时间片——即,如果CONFIG_HZ(定义时间片的值)设置为1000,那么最长为 500 微秒),对于某些中断原因,处理完后应该返回IRQ_HANDLED来确认中断。未在这个时间范围内完成的中断处理应该被推迟到线程化的中断处理程序中。在这种情况下,硬中断处理程序应该返回IRQ_WAKE_THREAD来唤醒线程化处理程序。返回IRQ_WAKE_THREAD只有在同时提供了thread_fn处理程序时才有意义。

  • thread_fn是当硬中断处理程序返回IRQ_WAKE_THREAD时添加到调度器运行队列中的线程处理程序。如果thread_fnNULL而处理程序已设置并返回IRQ_WAKE_THREAD,那么在硬中断处理程序的返回路径中不会发生任何操作,只会发出一个简单的警告消息(我们可以在内核源代码中的__irq_wake_thread()函数中看到这一点)。由于thread_fn与运行队列中的其他进程竞争 CPU,它可能会立即执行,也可能在系统负载较低时稍后执行。该函数应在完成中断处理后返回IRQ_HANDLED。之后,关联的内核线程将被从运行队列中移除并进入阻塞状态,直到通过硬中断功能再次唤醒。

如果handlerNULLthread_fn != NULL,内核将安装一个默认的硬中断处理程序。这是默认的主要处理程序,它什么也不做,只是返回IRQ_WAKE_THREAD,以唤醒关联的内核线程,该线程将执行thread_fn处理程序。

它的实现如下:

/* Default primary interrupt handler for threaded
 * interrupts. Assigned as primary handler when
 * request_threaded_irq is called with handler == NULL.
 * Useful for oneshot interrupts.
 */
static irqreturn_t irq_default_primary_handler(int irq,
                                         void *dev_id)
{
    return IRQ_WAKE_THREAD;
}
int request_threaded_irq(unsigned int irq,
          irq_handler_t handler, irq_handler_t thread_fn,
          unsigned long irqflags, const char *devname,
          void *dev_id)
{
[...]
    if (!handler) {
        if (!thread_fn)
            return -EINVAL;
        handler = irq_default_primary_handler;
    }
[...]
}
EXPORT_SYMBOL(request_threaded_irq);

这使得可以将中断处理程序的执行完全移到进程上下文中,从而防止有缺陷的驱动程序(实际上是有缺陷的中断处理程序)破坏整个系统,并减少中断延迟。

在新的内核版本中,request_irq()仅仅是将request_threaded_irq()包装起来,并将thread_fn参数设置为NULLdevm_变体也是如此)。

请注意,当你从硬中断处理程序返回时(无论返回值是什么),中断会在中断控制器级别被确认,这样就允许你考虑其他中断。在这种情况下,如果中断在设备级别没有被确认,中断将一次又一次地触发,对于级别触发的中断来说,会导致堆栈溢出(或永远卡在硬中断处理程序中),因为触发中断的设备仍然保持中断线有效。

对于线程中断实现,当驱动程序需要在一个线程中运行底半部分时,它们必须在硬中断处理程序中屏蔽设备级别的中断。这需要访问发出中断的设备,但对于某些位于慢速总线(如 I2C 或IRQF_ONESHOT)上的设备来说,这并不总是可能的。此操作不再是强制性的,因为它有助于在线程处理程序运行时保持控制器级别的中断禁用。但是,驱动程序必须在线程处理程序中清除设备中断,才能完成中断处理。

使用devm_request_threaded()(或非托管变体),通过省略硬中断处理程序,可以请求一个专门的线程中断。在这种情况下,必须设置IRQF_ONESHOT标志,否则内核会报错,因为线程处理程序将在设备和控制器级别的中断未屏蔽的情况下运行。

这里是一个示例:

static irqreturn_t data_event_handler(int irq,
                                      void *dev_id)
{
    struct big_structure *bs = dev_id;
    clear_device_interupt(bs);
    process_data(bs->buffer);
    return IRQ_HANDLED;
}
static int my_probe(struct i2c_client *client)
{
[...]
    if (client->irq > 0) {
        ret = request_threaded_irq(client->irq, NULL, 
                &data_event_handler,
                IRQF_TRIGGER_LOW | IRQF_ONESHOT,
                id->name, private);
        if (ret)
            goto error_irq;
    }
...
    return 0;
error_irq:
    do_cleanup();
    return ret;
}

在前面的示例中,我们的设备位于 I2C 总线上,因此访问该设备可能会导致底层任务进入休眠。此类操作绝不能在硬中断处理程序中执行。

以下是链接中介绍 IRQF_ONESHOT 标志的消息摘录,并解释了它的作用(完整消息可以通过此链接找到:lkml.iu.edu/hypermail/linux/kernel/0908.1/02114.html):

它允许驱动程序请求在硬中断上下文处理程序执行并且线程被唤醒之后,中断在控制器级别不被解除屏蔽。中断线在线程处理程序执行后才会解除屏蔽。

如果一个驱动程序为给定的 IRQ 设置了 IRQF_SHAREDIRQF_ONESHOT 标志,那么共享该 IRQ 的其他驱动程序也必须设置相同的标志。/proc/interrupts 文件列出了每个 IRQ 的处理次数、请求时给定的 IRQ 名称,以及注册该中断处理程序的驱动程序的以逗号分隔的列表。

为 IRQ 设置线程化是处理可能占用过多 CPU 周期的中断(例如,超过一个时钟周期)时的最佳选择,例如批量数据处理。线程化 IRQ 使得能够单独管理其相关线程的优先级和 CPU 亲和性。由于这一概念来源于实时内核树,因此它满足了实时系统的许多要求,例如允许细粒度的优先级模型和减少内核中的中断延迟。你可以查看 /proc/irq/<IRQ>/smp_affinity,它可以用来获取或设置相应 <IRQ> 的亲和性。该文件返回并接受一个位掩码,表示哪些处理器可以处理为此 IRQ 注册的 ISR。通过这种方式,你可以—例如—决定将硬中断处理程序的亲和性设置为一个 CPU,同时将线程化处理程序的亲和性设置为另一个 CPU。

请求一个与上下文无关的 IRQ

请求 IRQ 的驱动程序必须事先了解中断的性质,并决定其处理程序是否可以在硬中断上下文中运行,这可能会影响 devm_request_irq()devm_request_threaded_irq() 之间的选择。

这些方法的问题在于,有时请求 IRQ 的驱动程序并不了解提供此 IRQ 线路的中断控制器的性质,尤其是当中断控制器是一个独立芯片时(通常是一个 request_any_context_irq() 函数,驱动程序请求 IRQ 时将知道处理程序是否会在线程上下文中运行,并根据需要调用 request_threaded_irq()request_irq())。这意味着无论与我们设备相关的 IRQ 是来自可能不进入休眠的内存映射中断控制器,还是来自可能进入休眠的中断控制器(如 I2C/SPI 总线后),都不需要修改代码。其原型如下所示:

int request_any_context_irq(unsigned int irq,
                irq_handler_t handler, unsigned long flags,
                const char *name, void *dev_id)

devm_request_any_context_irq()devm_request_irq()有相同的接口,但语义不同。根据底层上下文(硬件平台),devm_request_any_context_irq()会选择使用request_irq()进行硬中断处理,或者使用request_threaded_irq()进行线程化处理。它在失败时返回负错误值,成功时返回IRQC_IS_HARDIRQ(表示使用硬中断处理方法)或IRQC_IS_NESTED(表示使用线程化方法)。通过这个函数,中断处理程序的行为在运行时决定。有关更多信息,可以通过以下链接查看引入它的内核提交:git.kernel.org/pub/scm/linux/kernel/git/next/linux-next.git/commit/?id=ae731f8d0785

使用devm_request_any_context_irq()的优点在于,驱动程序不需要关心 IRQ 处理程序中可以做什么,因为处理程序将运行的上下文取决于提供 IRQ 线路的中断控制器。例如,对于一个基于 GPIO-IRQ 的设备驱动程序,如果 GPIO 属于一个位于 I2C 或 SPI 总线上的控制器(GPIO 访问可能会休眠),处理程序将会是线程化的。否则(即 GPIO 访问不休眠,并且是内存映射的,属于 SoC 的一部分),处理程序将在硬中断处理程序中运行。

在下面的示例中,设备期望一个映射到 GPIO 的 IRQ 线路。驱动程序不能假设给定的 GPIO 线路会是内存映射的,来自 SoC。它也可能来自一个独立的 I2C 或 SPI GPIO 控制器。一个好的做法是在这里使用request_any_context_irq()

static irqreturn_t packt_btn_interrupt(int irq,
                                       void *dev_id)
{
    struct btn_data *priv = dev_id;
    input_report_key(priv->i_dev, BTN_0,
        gpiod_get_value(priv->btn_gpiod) & 1);
    input_sync(priv->i_dev);
    return IRQ_HANDLED;
}
static int btn_probe(struct platform_device *pdev)
{
    struct gpio_desc *gpiod;
    int ret, irq;
    gpiod = gpiod_get(&pdev->dev, "button", GPIOD_IN);
    if (IS_ERR(gpiod))
        return -ENODEV;
    priv->irq = gpiod_to_irq(priv->btn_gpiod);
    priv->btn_gpiod = gpiod;
[...]
    ret = request_any_context_irq(priv->irq,
              packt_btn_interrupt,
             (IRQF_TRIGGER_FALLING | IRQF_TRIGGER_RISING),
             "packt-input-button", priv);
    if (ret < 0)
        goto err_btn;
    return 0;
err_btn:
    do_cleanup();
    return ret;
}

前面的代码足够简单,但相当安全,因为devm_request_any_context_irq()完成了这个任务,防止了误判底层 GPIO 的类型。这种方法的优点在于,你不需要关心提供 IRQ 线路的中断控制器的性质。在我们的例子中,如果 GPIO 属于一个位于 I2C 或 SPI 总线上的控制器,处理程序将会是线程化的。否则(即内存映射的情况),处理程序将在硬中断上下文中运行。

使用工作队列延迟底半部处理

由于我们已经在专门的章节中讨论了工作队列 API,现在最好在这里给出一个示例。这个示例并非没有错误,也没有经过测试,它只是一个展示,目的是突出通过工作队列延迟底半部的概念。

让我们从定义一个数据结构开始,这个结构将保存我们在后续开发中需要的元素,如下所示:

struct private_struct {
    int counter;
    struct work_struct my_work;
    void __iomem *reg_base;
    spinlock_t lock;
    int irq;
    /* Other fields */
    [...]
};

在前面的数据结构中,我们的工作结构由my_work元素表示。这里没有使用指针,因为我们需要使用container_of()宏来获取指向初始数据结构的指针。接下来,我们可以定义一个方法,该方法将在工作线程中调用,如下所示:

static void work_handler(struct work_struct *work)
{
    int i;
    unsigned long flags;
    struct private_data *my_data =
          container_of(work, struct private_data, my_work);
    /*
     * Processing at least half of MIN_REQUIRED_FIFO_SIZE
     * prior to re-enabling the irq at device level,
     * so that buffer can receive further data
     */
    for (i = 0, i < MIN_REQUIRED_FIFO_SIZE, i++) {
        device_pop_and_process_data_buffer();
        if (i == MIN_REQUIRED_FIFO_SIZE / 2)
            enable_irq_at_device_level(my_data);
    }
    spin_lock_irqsave(&my_data->lock, flags);
    my_data->buf_counter -= MIN_REQUIRED_FIFO_SIZE;
    spin_unlock_irqrestore(&my_data->lock, flags);
}

在前面的工作结构中,当足够的数据被缓冲时,我们开始数据处理。现在,我们可以提供 IRQ 处理程序,负责调度我们的工作,如下所示:

/* This is our hard-IRQ handler. */
static irqreturn_t my_interrupt_handler(int irq,
                                        void *dev_id)
{
    u32 status;
    unsigned long flags;
    struct private_struct *my_data = dev_id;
    /* we read the status register to know what to do */
    status = readl(my_data->reg_base + REG_STATUS_OFFSET);
    /*
     * Ack irq at device level. We are safe if another
     * irq pokes since it is disabled at controller
     * level while we are in this handler
     */
    writel(my_data->reg_base + REG_STATUS_OFFSET,
            status | MASK_IRQ_ACK);
    /*
     * Protecting the shared resource, since the worker
     * also accesses this counter
     */
    spin_lock_irqsave(&my_data->lock, flags);
    my_data->buf_counter++;
    spin_unlock_irqrestore(&my_data->lock, flags);
    /*
     * Our device raised an interrupt to inform it has
     * new data in its fifo. But is it enough for us
     * to be processed ?
     */
    if (my_data->buf_counter != MIN_REQUIRED_FIFO_SIZE)) {
       /* ack and re-enable this irq at controller level */
       return IRQ_HANDLED;
    } else {
        /* Right. prior to scheduling the worker and
         * returning from this handler, we need to
         * disable the irq at device level
         */
        writel(my_data->reg_base + REG_STATUS_OFFSET,
                MASK_IRQ_DISABLE);
        schedule_work(&my_work);
    }
    /* This will re-enable the irq at controller level */
    return IRQ_HANDLED;
};

IRQ 处理程序中的注释已经足够有意义。schedule_work()是调度我们工作的函数。最后,我们可以编写探测方法,请求 IRQ 并注册前面的处理程序,如下所示:

static int foo_probe(struct platform_device *pdev)
{
    struct resource *mem;
    struct private_struct *my_data;
    my_data = alloc_some_memory(
                        sizeof(struct private_struct));
    mem = platform_get_resource(pdev, IORESOURCE_MEM, 0);
    my_data->reg_base = ioremap(ioremap(mem->start,
                                resource_size(mem)););
    if (IS_ERR(my_data->reg_base))
        return PTR_ERR(my_data->reg_base);
    /*
     * workqueue initialization. "work_handler" is
     * the callback that will be executed when our work
     * is scheduled.
     */
    INIT_WORK(&my_data->my_work, work_handler);
    spin_lock_init(&my_data->lock);
    my_data->irq = platform_get_irq(pdev, 0);
    if (devm_request_irq(&pdev->dev, my_data->irq,
                        my_interrupt_handler, 0,
                        pdev->name, my_data))
        handler_this_error()
    return 0;
}

前面探测方法的结构毫无疑问地表明我们正在面对一个平台设备驱动程序。这里使用了通用 IRQ 和工作队列 API 来初始化我们的工作队列并注册处理程序。

在中断处理程序中加锁

在 SMP 系统中常常使用自旋锁,因为这可以保证在 CPU 级别的互斥性。因此,如果某个资源只与线程化的底半部分共享(即它永远不会从硬件 IRQ 访问),那么使用互斥锁更为合适,正如我们在以下示例中所见:

static int my_probe(struct platform_device *pdev)
{
    int irq;
    int ret;
    irq = platform_get_irq(pdev, i);
    ret = devm_request_threaded_irq(&pdev->dev, irq, NULL,
                my_threaded_irq, IRQF_ONESHOT,
                dev_name(dev), my_data);
[...]
    return 0;
}
static irqreturn_t my_threaded_irq(int irq, void *dev_id)
{
    struct priv_struct *my_data = dev_id;
    /* Save FIFO Underrun & Transfer Error status */
    mutex_lock(&my_data->fifo_lock);
    /*
     * Accessing the device's buffer through i2c
     */
    device_get_i2c_buffer_and_push_to_fifo();
    mutex_unlock(&ldev->fifo_lock);
    return IRQ_HANDLED;
}

然而,如果从硬中断处理程序中访问共享资源,则必须使用_irqsave变体的自旋锁,如以下示例所示,从探测方法开始:

static int my_probe(struct platform_device *pdev)
{
    int irq;
    int ret;
    [...]
    irq = platform_get_irq(pdev, 0);
    if (irq < 0)
        goto handle_get_irq_error;
    ret = devm_request_threaded_irq(&pdev->dev, irq,
                    hard_handler, threaded_handler, 
                    IRQF_ONESHOT, dev_name(dev), my_data);
    if (ret < 0)
        goto err_cleanup_irq;
     [...]
    return 0;
}

现在探测方法已经实现,我们来实现顶半部分——即硬件 IRQ 处理程序——如下所示:

static irqreturn_t hard_handler(int irq, void *dev_id)
{
    struct priv_struct *my_data = dev_id;
    u32 status;
    unsigned long flags;
    /* Protecting the shared resource */
    spin_lock_irqsave(&my_data->lock, flags);
    my_data->status = __raw_readl(
            my_data->mmio_base + my_data->foo.reg_offset);
    spin_unlock_irqrestore(&my_data->lock, flags);
    /* Let us schedule the bottom-half */
    return IRQ_WAKE_THREAD;
}

顶半部分的返回值将唤醒线程化的底半部分,底半部分的实现如下:

static irqreturn_t threaded_handler(int irq, void *dev_id)
{
    struct priv_struct *my_data = dev_id;
    spin_lock_irqsave(&my_data->lock, flags);
    /* doing sanity depending on the status */
    process_status(my_data->status);
    spin_unlock_irqrestore(&my_data->lock, flags);
    /*
     * content of status not needed anymore, let's do
     * some other work
     */
     [...]
    return IRQ_HANDLED;
}

在请求 IRQ 线路时,如果设置了IRQF_ONESHOT标志,可能不需要在硬件 IRQ 和其线程化对应部分之间进行保护。该标志会在硬中断处理程序完成后禁用中断。设置此标志后,IRQ 线路将一直禁用,直到线程化处理程序运行完毕。这样,硬件处理程序和线程化处理程序就不会发生竞争,因此可能不需要为两者之间共享的资源加锁。

总结

本章讨论了开始驱动开发的基本元素,介绍了驱动程序中常用的机制,如工作调度、时间管理、中断处理和锁原语。本章非常重要,因为它讨论了本书其他章节依赖的主题。

例如,下一章将讨论字符设备,它将使用本章中讨论的一些元素。

Logo

openvela 操作系统专为 AIoT 领域量身定制,以轻量化、标准兼容、安全性和高度可扩展性为核心特点。openvela 以其卓越的技术优势,已成为众多物联网设备和 AI 硬件的技术首选,涵盖了智能手表、运动手环、智能音箱、耳机、智能家居设备以及机器人等多个领域。

更多推荐