Linux-设备驱动开发第二版-全-

Linux 设备驱动开发第二版(全)

原文: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 中进行测试。如果某一章节需要额外的技能,本书会提供相关文档链接,帮助你快速掌握这些技能。

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

如果你使用的是本书的数字版,我们建议你自己输入代码或从本书的 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 的界面如下所示:

图 1.1 – 内核配置界面

](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)时所做的工作,内核中已经包含了他们自定义的驱动程序:

图 2.1 – 内核树中的 Packt_dev 模块

](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>表示编码成具有相当有意义名称的日志级别,如下表所示:

表 2.1 – Linux 内核打印 API

表 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 字眼,如下所示:

图 3.1 – 系统时钟源列表

图 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 内核时间管理

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

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

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

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

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

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

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

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

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

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

表 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 用于跟踪底层时钟事件的工作模式。因此,当说一个滴答设备处于周期模式时,也意味着底层的时钟事件设备被配置为以这种模式工作。以下图示说明了这一点:

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

图 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 线路将一直禁用,直到线程化处理程序运行完毕。这样,硬件处理程序和线程化处理程序就不会发生竞争,因此可能不需要为两者之间共享的资源加锁。

总结

本章讨论了开始驱动开发的基本元素,介绍了驱动程序中常用的机制,如工作调度、时间管理、中断处理和锁原语。本章非常重要,因为它讨论了本书其他章节依赖的主题。

例如,下一章将讨论字符设备,它将使用本章中讨论的一些元素。

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

基于 Unix 的系统通过特殊文件将硬件暴露给用户空间,这些文件在设备注册到系统后会在/dev目录下创建。愿意访问某个设备的程序必须在/dev中找到相应的设备文件,并在其上执行适当的系统调用,该系统调用将被重定向到与该特殊文件相关联的底层设备的驱动程序。尽管系统调用的重定向是由操作系统完成的,但支持哪些系统调用取决于设备类型和驱动程序的实现。

关于设备类型,硬件角度上有很多种,但它们被归为/dev目录下的两类特殊设备文件——块设备字符设备。它们通过访问方式、速度以及数据在它们与系统之间传输的方式来区分。通常,字符设备比较慢,数据传输是按字节顺序从用户应用程序中逐个字节传输的(一个字符接一个字符——因此得名)。这样的设备包括串行端口和输入设备(键盘、鼠标、触控板、视频设备等)。另一方面,块设备比较快,因为它们的访问频繁且数据以块为单位传输。这类设备本质上是存储设备(硬盘、光盘驱动器、固态硬盘等)。

在本章中,我们将重点讨论字符设备及其驱动程序、它们的 API 以及它们的常见数据结构。我们将介绍大部分概念并编写我们的第一个字符设备驱动程序。

本章将涵盖以下主题:

  • 主设备号和次设备号的概念

  • 字符设备数据结构介绍

  • 创建设备节点

  • 实现文件操作

主设备号和次设备号的概念

Linux 一直通过一个唯一的标识符来强制执行设备文件的识别,该标识符由两部分组成,/dev,字符或块设备文件通过其类型来识别,可以通过ls -l命令查看:

$ ls -la /dev
crw-------  1 root root    254,     0 août  22 20:28 gpiochip0
crw-------  1 root root    240,     0 août  22 20:28 hidraw0
[...]
brw-rw----  1 root disk    259,     0 août  22 20:28 nvme0n1
brw-rw----  1 root disk    259,     1 août  22 20:28 nvme0n1p1
brw-rw----  1 root disk    259,     2 août  22 20:28 nvme0n1p2
[...]
crw-rw----+ 1 root video    81,     0 août  22 20:28 video0
crw-rw----+ 1 root video    81,     1 août  22 20:28 video1

从前面的摘录中,第一列中,c标识字符设备文件,b标识块设备文件。在第五列和第六列中,我们可以看到主设备号和次设备号。主设备号要么标识设备类型,要么与驱动程序绑定。次设备号要么在驱动程序中本地标识设备,要么标识同类型的设备。这就解释了为什么前面的输出中有些设备文件具有相同的主设备号。

现在我们已经掌握了 Linux 系统中字符设备的基本概念,我们可以开始探索内核代码,从介绍主要数据结构开始。

字符设备数据结构介绍

字符设备驱动程序是内核源代码中最基本的设备驱动程序。字符设备在内核中以struct cdev实例的形式表示,该结构在include/linux/cdev.h中声明:

struct cdev {
    struct kobject kobj;
    struct module *owner;
    const struct file_operations *ops;
    dev_t dev;
[...]
};

前面的摘录仅列出了我们感兴趣的元素。以下显示了这些元素在此数据结构中的含义:

  • kobj: 这是该字符设备对象的基础内核对象,用于实施 Linux 设备模型。我们将在第十四章Linux 设备模型介绍中讨论这一点。

  • owner: 应该使用THIS_MODULE宏进行设置。

  • ops: 这是与此字符设备关联的文件操作集合。

  • dev: 这是字符设备标识符。

引入了这个数据结构后,接下来讨论的逻辑下一个数据结构是暴露给系统调用依赖的文件操作。然后让我们介绍允许用户空间与内核空间通过字符设备进行交互的数据结构。

设备文件操作简介

cdev->ops元素指向给定设备支持的文件操作。每个这些操作都是特定系统调用的目标,当用户空间程序在字符设备上调用系统调用时,内核将此系统调用重定向到其在cdev->ops中的文件操作对应项。struct file_operations是保存这些操作的数据结构。它看起来像这样:

struct file_operations {
   struct module *owner;
   loff_t (*llseek) (struct file *, loff_t, int);
   ssize_t (*read) (struct file *, char __user *,
                     size_t, loff_t *);
   ssize_t (*write) (struct file *, const char __user *,
                     size_t, loff_t *);
   unsigned int (*poll) (struct file *,
                         struct poll_table_struct *);
   int (*mmap) (struct file *, struct vm_area_struct *);
   int (*open) (struct inode *, struct file *);
   int (*flush) (struct file *, fl_owner_t id);
   long (*unlocked_ioctl) (struct file *, unsigned int,
                           unsigned long);
   int (*release) (struct inode *, struct file *);
   int (*fsync) (struct file *, loff_t, loff_t,
                 int datasync);
   int (*flock) (struct file *, int, struct file_lock *);
   [...]
};

前面的摘录仅列出了结构的重要方法,特别是与本书需求相关的方法。完整代码在内核源码的include/linux/fs.h中。每个这些回调函数是系统调用的后端,并且它们都不是强制性的。以下解释了结构中元素的含义:

  • struct module *owner: 这是一个必填字段,应指向拥有此结构的模块。它用于正确的引用计数。大多数情况下,它设置为THIS_MODULE,这是在<linux/module.h>中定义的宏。

  • loff_t (*llseek) (struct file *, loff_t, int);: 此方法用于移动文件中当前光标位置,第一个参数是文件。成功移动后,函数必须返回新位置,否则必须返回负值。如果未实现此方法,则在该文件上执行的每个查找都将通过修改文件结构(file->f_pos)中的位置计数器成功执行,除了相对于文件末尾的查找将失败。

  • ssize_t (*read) (struct file *, char *, size_t, loff_t *);: 此函数的作用是从设备中检索数据。由于返回值是"有符号大小"类型,因此此函数必须返回成功读取的字节数(正数),否则在错误时返回适当的负代码。如果未实现此函数,则对设备文件的任何read()系统调用都将失败,并返回-EINVAL("无效参数")。

  • ssize_t (*write) (struct file *, const char *, size_t, loff_t *);:此函数的作用是向设备发送数据。像read()函数一样,它必须返回一个正数,表示已成功写入的字节数,否则返回适当的负值代码以指示错误。同样,如果驱动程序中未实现此功能,则尝试执行write()系统调用时将失败,并返回-EINVAL

  • int (*flush) (struct file *, fl_owner_t id);:当文件结构被释放时,将调用此操作。与open类似,release可以是NULL

  • unsigned int (*poll) (struct file *, struct poll_table_struct *);:此文件操作必须返回一个位掩码,描述设备的状态。它是poll()select()系统调用的内核后端,二者用于查询设备是否可写、可读,或是否处于某些特殊状态。调用此方法的任何程序都会阻塞,直到设备进入请求的状态。如果此文件操作未实现,则设备始终被假定为可读、可写且不处于特殊状态。

  • int (*mmap) (struct file *, struct vm_area_struct *);:此操作用于请求将设备内存的部分或全部映射到进程的地址空间。如果此文件操作未实现,则对设备文件调用mmap()系统调用时会失败,并返回-ENODEV

  • int (*open) (struct inode *, struct file *);:此文件操作是open()系统调用的后端。如果未实现(即NULL),则任何尝试打开设备的操作都会成功,并且驱动程序不会收到此操作的通知。

  • int (*release) (struct inode *, struct file *);:当文件被释放时,响应close()系统调用时调用此操作。像open一样,release不是强制性的,可以是NULL

  • int (*fsync) (struct file *, loff_t, loff_t, int datasync);:此操作是fsync()系统调用的后端,旨在刷新任何待处理的数据。如果未实现,对设备文件的任何fsync()调用都会失败,并返回-EINVAL

  • long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);:这是ioctl系统调用的后端,其目的是扩展可以发送到设备的命令(例如格式化软盘的某个磁道,这既不是读取也不是写入)。由此函数定义的命令将扩展一组已经被内核识别的预定义命令,而无需参考此文件操作。因此,对于任何未定义的命令(无论是因为此函数未实现,还是因为它不支持指定的命令),系统调用将返回-ENOTTY,表示“设备没有此类 ioctl”。此函数返回的任何非负值都将传回调用程序,以表示成功完成。

现在我们已经熟悉了文件操作回调,让我们深入了解内核的机制,学习文件如何处理,从而更好地理解字符设备背后的机制。

内核中的文件表示

查看文件操作表时,每个操作的至少一个参数要么是struct inode类型,要么是struct file类型。struct inode表示磁盘上的一个文件。但是,要引用一个打开的文件(与进程中的文件描述符相关联),则使用struct file结构。

以下是inode结构的声明:

struct inode {
    [...]
    union {
        struct pipe_inode_info  *i_pipe;
        struct cdev    *i_cdev;
        char           *i_link;
        unsigned       i_dir_seq;
    };
    [...]
}

该结构体中最重要的字段是union,特别是i_cdev元素,当底层文件是字符设备时会设置此元素。这使得在struct inodestruct cdev之间来回切换成为可能。

另一方面,struct file是一个文件系统数据结构,保存有关文件的信息(如文件类型、字符、块、管道等),其中大多数信息仅与操作系统相关。struct file结构(定义在include/linux/fs.h中)具有以下定义:

struct file {
[...]
   struct path f_path;
   struct inode *f_inode;
   const struct file_operations *f_op;
   loff_t f_pos;
   void *private_data;
[...]
}

在上述数据结构中,f_path表示文件在文件系统中的实际路径,f_inode是指向此打开文件的底层inode。这使得通过f_inode元素在struct file和底层cdev之间来回切换成为可能。f_op表示文件操作表。由于struct file表示一个打开的文件描述符,它追踪其打开实例中的当前读写位置。这是通过f_pos元素完成的,f_pos是当前的读写位置。

创建设备节点

设备节点的创建使其对用户可见,并允许用户与底层设备进行交互。Linux 在创建设备节点之前需要经过一些中间步骤,接下来的部分将讨论这些步骤。

设备标识

为了精确标识设备,它们的标识符必须是唯一的。虽然标识符可以动态分配,但大多数驱动程序仍然使用静态标识符以兼容为主。不论分配方式如何,Linux 内核会将文件设备号存储在dev_t类型的元素中,它是一个 32 位无符号整数,其中前12位表示主设备号,剩余的20位表示次设备号。

所有这些内容都在include/linux/kdev_t.h中声明,该文件包含了多个宏,其中一些宏可以根据给定的dev_t类型变量,返回设备的次设备号或主设备号:

#define MINORBITS    20
#define MINORMASK    ((1U << MINORBITS) - 1)
#define MAJOR(dev)    ((unsigned int) ((dev) >> MINORBITS))
#define MINOR(dev)    ((unsigned int) ((dev) & MINORMASK))
#define MKDEV(ma,mi)  (((ma) << MINORBITS) | (mi))

最后的宏接受一个次设备号和主设备号,返回一个dev_t类型的标识符,内核使用这个标识符来存储设备标识符。前面的摘录也描述了如何通过位移构建字符设备标识符。到此为止,我们可以深入到代码中,使用内核提供的 API 来进行代码分配。

字符设备号的注册与注销

处理设备号的方式有两种——注册(静态方法)和分配(动态方法)。注册,也叫静态分配,只有在你提前知道想要开始使用的主设备号时才有用,前提是确保它不会与另一个驱动程序使用相同的主设备号(虽然这并不总是可预测的)。注册是一种暴力方法,你通过提供起始的主设备号/次设备号对和次设备号的数量,让内核知道你想要哪些设备号,内核会根据可用性来分配它们或不分配。用于设备号注册的函数如下:

int register_chrdev_region(dev_t first, unsigned int count,
                           char *name);

该方法在成功时返回0,失败时返回负错误代码。first参数是你必须使用主设备号和所需范围的第一个次设备号构建的标识符。你可以使用MKDEV(maj, min)宏来实现。count是所需连续设备次设备号的数量,name应该是关联的设备或驱动程序的名称。

然而,请注意,register_chrdev_region()如果你确切知道想要的设备号,它会表现得很好,当然,这些号必须在你运行的系统中可用。由于这可能与其他设备驱动程序发生冲突,因此建议使用动态分配方式,内核可以自动为你分配一个主设备号。你必须使用alloc_chrdev_region()来进行动态分配。以下是它的原型:

int alloc_chrdev_region(
                      dev_t *dev, unsigned int firstminor,
                      unsigned int count, char *name);

该方法在成功时返回0,失败时返回负错误代码。dev是唯一的输出参数。它表示内核分配的第一个号(使用分配的主设备号和请求的第一个次设备号构建)。firstminor是请求的次设备号范围中的第一个,count是你需要的连续次设备号的数量,name应该是关联的设备或驱动程序的名称。

静态分配和动态分配的区别在于,前者需要你提前知道所需的设备号。将驱动程序加载到另一台机器上时,无法保证所选设备号在该机器上是空闲的,这可能导致冲突和问题。新驱动程序建议使用动态分配来获取主设备号,而不是从当前空闲的设备号中随机选择,这样可能会导致冲突。换句话说,你的驱动程序最好使用alloc_chrdev_region(),而不是register_chrdev_region()

在系统上初始化并注册字符设备

字符设备的注册是通过指定设备标识符(dev_t类型)来进行的。在本章中,我们将使用动态分配,使用alloc_chrdev_region()。标识符分配后,必须使用cdev_init()cdev_add()分别初始化字符设备并将其添加到系统中。以下是它们的原型:

void cdev_init(struct cdev *cdev,
               const struct file_operations *fops);
int cdev_add (struct cdev * p, dev_t dev, unsigned count);

cdev_init()中,cdev是要初始化的结构体,fops是该设备的file_operations实例,使其准备好添加到系统中。在cdev_add()中,p是设备的cdev结构体,dev是该设备负责的第一个设备号(动态获取),count是与该设备对应的连续次设备号的数量。成功时,cdev_add()返回0,否则返回负错误代码。

cdev_add()的相应反向操作是cdev_del(),它将字符设备从系统中移除,具有以下原型:

void cdev_del(struct cdev *);

在这一步,设备已成为系统的一部分,但尚未物理存在。换句话说,它尚未在/dev中可见。为了创建节点,必须使用device_create(),它具有以下原型:

struct device * device_create(struct class *class,
                              struct device *parent,
                              dev_t devt,
                              void *drvdata, 
                              const char *fmt, ...)

该方法创建一个设备并将其注册到 Sysfs 中。在其参数中,class是指向struct class的指针,表示该设备应注册到的类,parent是指向该新设备父设备struct device的指针(如果有的话),devt是要添加的字符设备的设备号,drvdata是要添加到设备的回调数据。

重要提示

如果有多个次设备,device_create()device_destroy() API 可以放入一个for循环中,并且<device name format>字符串可以与循环计数器一起拼接,如下所示:

device_create(class, NULL, MKDEV(MAJOR(first_devt), MINOR(first_devt) + i), NULL, "mynull%d", i);

因为设备在创建之前需要一个现有的类,你必须创建一个类或者使用一个已存在的类。现在,我们将创建一个类,为此,我们需要使用class_create()函数,声明如下:

struct class * class_create(struct module * owner,
                            const char * name);

之后,该类将在/sys/class中可见,我们可以使用该类创建设备。以下是一个粗略的示例:

#define EEP_NBANK 8
#define EEP_DEVICE_NAME "eep-mem"
#define EEP_CLASS "eep-class"
static struct class *eep_class;
static struct cdev eep_cdev[EEP_NBANK];
static dev_t dev_num;
static int __init my_init(void)
{
    int i;
    dev_t curr_dev;
    /* Request for a major and EEP_NBANK minors */
    alloc_chrdev_region(&dev_num, 0, EEP_NBANK, 
                        EEP_DEVICE_NAME);
    /* create our device class, visible in /sys/class */
    eep_class = class_create(THIS_MODULE, EEP_CLASS);
    /* Each bank is represented as a character device (cdev) */
    for (i = 0; i < EEP_NBANK; i++) {
        /* bind file_operations to the cdev */
        cdev_init(&my_cdev[i], &eep_fops);
        eep_cdev[i].owner = THIS_MODULE;
        /* Device number to use to add cdev to the core */
        curr_dev = MKDEV(MAJOR(dev_num),
                          MINOR(dev_num) + i);
        /* Make the device live for the users to access */
        cdev_add(&eep_cdev[i], curr_dev, 1);
        /* create a node for each device */
        device_create(eep_class,
              NULL,     /* no parent device */
              curr_dev,
              NULL,     /* no additional data */
              EEP_DEVICE_NAME "%d", i); /* eep-mem[0-7] */
    }
    return 0;
}

在前面的代码中,device_create()将为每个设备创建一个节点——/dev/eep-mem0/dev/eep-mem1,依此类推,我们的类由eep_class表示。此外,设备还可以在/sys/class/eep-class下查看。与此同时,反向操作如下所示:

for (i = 0; i < EEP_NBANK; i++) {
    device_destroy(eep_class,
              MKDEV(MAJOR(dev_num), (MINOR(dev_num) +i)));
    cdev_del(&eep_cdev[i]);
}
class_unregister(eep_class);
class_destroy(eep_class);
unregister_chrdev_region(chardev_devt, EEP_NBANK);

在前面的代码中,device_destroy()将从/dev中删除设备节点,cdev_del()将使系统忘记这个字符设备,class_unregister()class_destroy()将注销并从系统中移除类,最后,unregister_chrdev_region()将释放我们的设备号。

现在我们已经熟悉了关于字符设备的所有前提知识,接下来可以开始实现一个文件操作,让用户能够与底层设备交互。

实现文件操作

在上一节介绍了文件操作之后,是时候实现这些操作来增强驱动程序功能,并通过系统调用将设备的方法暴露给用户空间。每个方法都有其特定的特点,我们将在本节中重点介绍。

在内核空间和用户空间之间交换数据

正如我们在介绍文件操作表时所看到的,readwrite方法用于与底层设备交换数据。它们都是系统调用,这意味着数据将从用户空间传输到内核空间,或从内核空间传输到用户空间。在查看readwrite方法原型时,第一个引起我们注意的是__user的使用。它是Sparse(一个用于帮助内核检查潜在编码错误的语义检查工具)用来提醒开发者他们即将不正确地使用一个不可信的指针(或者在当前虚拟地址映射中可能无效的指针),开发者不应解引用该指针,而应该使用专门的内核函数来访问指针指向的内存。

这引出了两个主要函数,它们允许我们在内核空间和用户空间之间交换数据:copy_from_user()copy_to_user(),分别用于将缓冲区从用户空间复制到内核空间,以及从内核空间复制到用户空间:

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

在这两种情况下,前缀为__user的指针指向用户空间(不可信)内存。n表示要复制的字节数,既可以是从用户空间到内核空间,也可以是从内核空间到用户空间。from表示源地址,to是目标地址。每个操作都会返回无法复制的字节数(如果有的话),如果成功则返回0。需要注意的是,这些例程可能会休眠,因为它们在用户上下文中运行,不需要在原子上下文中调用。

实现打开文件操作

open文件操作是open系统调用的后端。通常使用此方法进行设备和数据结构初始化,初始化后如果成功应返回0,如果发生错误则返回一个负的错误码。open文件操作的原型定义如下:

int (*open) (struct inode *inode, struct file *filp);

如果没有实现,设备打开将始终成功,但驱动程序不会意识到这一点。如果设备不需要特殊的初始化,这通常不会成为问题。

每个设备的数据

正如我们在文件操作原型中看到的,几乎总是有一个struct file参数。struct file有一个空闲使用的元素,即private_data。如果设置了file->private_data,它将在对相同文件描述符调用的其他系统调用中可用。你可以在文件描述符的生命周期内使用这个字段。在open方法中设置这个字段是一个好习惯,因为它总是任何文件上的第一个系统调用。

以下是我们的数据结构:

struct pcf2127 {
    struct cdev cdev;
    unsigned char *sram_data;
    struct i2c_client *client;
    int sram_size;
    [...]
};

基于此数据结构,open 方法将如下所示:

static unsigned int sram_major = 0;
static struct class *sram_class = NULL;
static int sram_open(struct inode *inode,
                     struct file *filp)
{
    unsigned int maj = imajor(inode);
    unsigned int min = iminor(inode);
    struct pcf2127 *pcf = NULL;
    pcf = container_of(inode->i_cdev,
                        struct pcf2127, cdev);
    pcf->sram_size = SRAM_SIZE;
    if (maj != sram_major || min < 0 ){
        pr_err ("device not found\n");
        return -ENODEV; /* No such device */
    }
    /* prepare the buffer if the device is
     * opened for the first time
       */
    if (pcf->sram_data == NULL) {
        pcf->sram_data =
                  kzalloc(pcf->sram_size, GFP_KERNEL);
        if (pcf->sram_data == NULL) {
            pr_err("memory allocation failed\n");
            return -ENOMEM;
        }
    }
    filp->private_data = pcf;
    return 0;
}

大多数时候,open 操作进行一些初始化并请求在用户保持设备节点的打开实例期间使用的资源。在设备关闭时,必须撤销和释放此操作中所做的所有工作,正如我们将在下一操作中看到的那样。

实现释放文件操作

release 方法在设备关闭时调用,是 open 方法的反向操作。你必须撤销在 open 操作中所做的一切。这可能包括释放分配的私有内存、关闭设备(如果支持),并在最后一次关闭时丢弃所有缓冲区(如果设备支持多次打开,或者驱动程序可以处理多个设备)。

以下是 release 函数的摘录:

static int sram_release(struct inode *inode,
                        struct file *filp)
{
    struct pcf2127 *pcf = NULL;
    pcf = container_of(inode->i_cdev,
                        struct pcf2127, cdev);
    mutex_lock(&device_list_lock);
    filp->private_data = NULL;
    /* last close? */
    pcf2127->users--;
    if (!pcf2127->users) {
        kfree(tx_buffer);
        kfree(rx_buffer);
        tx_buffer = NULL;
        rx_buffer = NULL;
        [...]
        if (any_other_dynamic_struct)
            kfree(any_other_dynamic_struct);
    }
    mutex_unlock(&device_list_lock);
    return 0;
}

上述代码释放了设备节点打开时获得的所有资源。这实际上是此文件操作中需要做的所有事情。如果设备节点由硬件设备支持,此操作还可以将设备置于适当的状态。

此时,我们能够实现字符设备的入口点(open)和出口点(release)。现在剩下的就是实现每个可能的操作。

实现写文件操作

write 方法用于向设备发送数据;每当用户在设备的文件上调用 write() 系统调用时,内核实现的相应方法会被调用。其原型如下:

ssize_t(*write)(struct file *filp, const char __user *buf,
                size_t count, loff_t *pos);

此文件操作必须返回已写入的字节数(大小),以下是其参数的定义:

  • *buf 表示来自用户空间的数据缓冲区。

  • count 是请求传输的大小。

  • *pos 表示从文件中的哪个位置开始写入数据(如果字符设备文件由内存支持,则表示相应内存区域的位置)。

通常,在执行此文件操作时,首先需要检查来自用户空间的无效请求或错误请求(例如,检查内存支持设备的大小限制以及大小溢出)。以下是一个示例:

/* if trying to Write beyond the end of the file,
 * return error. "filesize" here corresponds to the size
 * of the device memory (if any)
 */
if (*pos >= filesize) return –EINVAL;

检查完成后,通常需要进行一些调整,尤其是对于 count,以确保不超过文件大小。此步骤也不是强制性的:

/* filesize corresponds to the size of device memory */
if (*pos + count > filesize) 
    count = filesize - *pos;

下一步是找到开始写入的位置。只有在设备由物理内存支持的情况下,这一步才相关,在这种情况下,write() 方法应当存储给定的数据:

/* convert pos into valid address */
void *from = pos_to_address(*pos); 

最后,你可以将数据从用户空间复制到内核内存,然后在支持设备上执行 write 操作,并调整 *pos,如下所示:

if (copy_from_user(dev->buffer, buf, count) != 0){
    retval = -EFAULT;
    goto out;
}
/* now move data from dev->buffer to physical device */
write_error = device_write(dev->buffer, count);
if (write_error)
    return –EFAULT;
/* Increase the current position of the cursor in the file,
 * according to the number of bytes written and finally,
 * return the number of bytes copied
 */
*pos += count;
return count;

以下是 write 方法的示例,概述了目前为止描述的步骤:

ssize_t 
eeprom_write(struct file *filp, const char __user *buf,
             size_t count, loff_t *f_pos)
{
    struct eeprom_dev *eep = filp->private_data;
    int part_origin = PART_SIZE * eep->part_index;
    int register_address;
    ssize_t retval = 0;
    /* step (1) */
    if (*f_pos >= eep->part_size) 
        /* Can't write beyond the end of a partition. */
        return -EINVAL;
    /* step (2) */
    if (*pos + count > eep->part_size)
        count = eep->part_size - *pos;
    /* step (3) */
    register_address = part_origin + *pos;
    /* step(4) */
    /* Copy data from user space to kernel space */
    if (copy_from_user(eep->data, buf, count) != 0)
        return -EFAULT;
    /* step (5) */
    /* perform the write to the device */
    if (write_to_device(register_address, buff, count)
        < 0){
        pr_err("i2c_transfer failed\n");  
        return –EFAULT;
     }
    /* step (6) */
    *f_pos += count;
    return count;
}

在数据被读取并处理后,可能需要将处理结果写回文件。由于我们从写操作开始,接下来我们可能想到的是read操作,如下节所示。

实现读取文件操作

read方法具有以下原型:

ssize_t (*read) (struct file *filp, char __user *buf,
                 size_t count, loff_t *pos);

此操作是read()系统调用的后端。它的参数如下所示:

  • *buf是我们从用户空间接收的缓冲区。

  • count是请求的传输大小(用户缓冲区的大小)。

  • *pos表示数据应从文件中读取的起始位置。

它必须返回成功读取的数据大小。尽管这个大小可能小于count(例如,在到达文件末尾之前,未达到用户请求的count)。

实现读取操作看起来与写操作类似,因为需要执行一些健全性检查。首先,您可以防止读取超出文件大小,并返回文件结束响应:

if (*pos >= filesize)
    return 0; /* 0 means EOF */

然后,您应该确保读取的字节数不能超过文件大小,并可以适当调整count

if (*pos + count > filesize)
    count = filesize – (*pos);

接下来,您可以找到开始读取的位置,然后将数据复制到用户空间的缓冲区中,并在失败时返回错误,然后根据读取的字节数更新文件的当前位置信息,并返回复制的字节数:

/* convert pos into valid address */
void *from = pos_to_address (*pos); 
sent = copy_to_user(buf, from, count);
if (sent)
    return –EFAULT;
*pos += count;
return count;

以下是一个驱动程序read()文件操作的示例,旨在概述这里可以做的事情:

ssize_t  eep_read(struct file *filp, char __user *buf,
                  size_t count, loff_t *f_pos)
{
    struct eeprom_dev *eep = filp->private_data;
    if (*f_pos >= EEP_SIZE) /* EOF */
        return 0;
    if (*f_pos + count > EEP_SIZE)
        count = EEP_SIZE - *f_pos;
    /* Find location of next data bytes */
    int part_origin  =  PART_SIZE * eep->part_index;
    int eep_reg_addr_start  =  part_origin + *pos;
    /* perform the read from the device */
    if (read_from_device(eep_reg_addr_start, buff, count)
        < 0){
        pr_err("i2c_transfer failed\n");  
        return –EFAULT;
    } 
    /* copy from kernel to user space */
    if(copy_to_user(buf, dev->data, count) != 0)
        return -EIO;
    *f_pos += count;
    return count;
}

尽管读取和写入数据会移动光标位置,但有一种操作的主要目的是在不接触数据的情况下仅移动光标位置。此操作有助于通过将光标移到所需位置,从任何地方开始读写数据。

实现llseek文件操作

llseek文件操作是lseek()系统调用的内核后端,用于在文件中移动光标位置。它的原型如下所示:

loff_t(*llseek) (struct file *filp, loff_t offset,
                 int whence);

此回调必须返回文件中的新位置。以下是其参数的定义:

  • loff_t是一个偏移量,相对于当前文件位置,定义了要更改的位置。

  • whence定义了从哪里开始定位。可能的值如下:

    • SEEK_SET:将光标定位到相对于文件开头的位置

    • SEEK_CUR:将光标定位到相对于当前文件位置的位置

    • SEEK_END:将光标定位到相对于文件末尾的位置

在实现此操作时,使用switch语句检查每个可能的whence情况是一种良好的实践,因为它们是有限的,并相应地调整新位置:

switch( whence ){
    case SEEK_SET:/* relative from the beginning of file */
        newpos = offset; /* offset become the new position */
        break;
    case SEEK_CUR: /* relative to current file position */
        /* just add offset to the current position */
        newpos = file->f_pos + offset;
        break;
    case SEEK_END: /* relative to end of file */
        newpos = filesize + offset;
        break;
    default:
        return -EINVAL;
}
/* Check whether newpos is valid **/
if ( newpos < 0 )
    return –EINVAL;
/* Update f_pos with the new position */
filp->f_pos = newpos;
/* Return the new file-pointer position */
return newpos;

在前面的内核后端摘录之后,以下是一个用户程序的示例,该程序将依次读取并定位到文件中。底层驱动程序随后将执行llseek()文件操作入口:

#include <unistd.h>
#include <fcntl.h>
#include <sys/types.h>
#include <stdio.h>
#define CHAR_DEVICE "foo"
int main(int argc, char **argv)
{
    int fd = 0;
    char buf[20];
    if ((fd = open(CHAR_DEVICE, O_RDONLY)) < -1)
        return 1;
    /* Read 20 bytes */
    if (read(fd, buf, 20) != 20)
        return 1;
    printf("%s\n", buf);
    /* Move the cursor to ten time relative to
     * its actual position
     */
    if (lseek(fd, 10, SEEK_CUR) < 0)
        return 1;
    if (read(fd, buf, 20) != 20) 
        return 1;
    printf("%s\n",buf);
    /* Move the cursor seven time, relative from
     * the beginning of the file
     */
    if (lseek(fd, 7, SEEK_SET) < 0)
        return 1;
    if (read(fd, buf, 20) != 20)
        return 1;
    printf("%s\n",buf);
    close(fd);
    return 0;
}

该代码产生以下输出:

jma@jma:~/work/tutos/sources$ cat toto 
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
jma@jma:~/work/tutos/sources$ ./seek 
Lorem ipsum dolor si
nsectetur adipiscing
psum dolor sit amet,
jma@jma:~/work/tutos/sources$

在本节中,我们通过示例解释了寻址的概念,展示了相对寻址和绝对寻址是如何工作的。现在我们已经完成了数据操作的部分,可以转到下一个操作,感知字符设备中数据的可读性或可写性。

poll方法

poll方法是poll()select()系统调用的后端。这些系统调用用于被动地(通过休眠,避免浪费 CPU 周期)感知文件的可读性/可写性。为了支持这些系统调用,驱动程序必须实现poll,其原型如下:

unsigned int (*poll) (struct file *, struct poll_table_struct *);

这个方法实现的核心函数是poll_wait(),定义在<linux/poll.h>中,这是你必须在驱动程序代码中包含的头文件。它的声明如下:

void poll_wait(struct file * filp,
               wait_queue_head_t * wait_address, poll_table *p)

poll_wait()将与struct file结构相关联的设备(作为第一个参数传递)添加到可以唤醒进程的设备列表中(根据在第三个参数struct poll_table结构中注册的事件),并将进程置于由第二个参数struct wait_queue_head_t结构表示的等待队列中。用户进程可以调用poll()select()epoll()系统调用,将一组文件添加到等待列表中,以便了解相关设备的就绪情况。然后,内核会调用与每个设备文件关联的驱动程序的poll方法。每个驱动程序的poll方法应该调用poll_wait(),以便注册需要通知的事件,并将该进程休眠,直到其中一个事件发生,并将该驱动程序注册为能够唤醒该进程的设备。通常的做法是根据select()(或poll())系统调用支持的事件类型为每个事件类型使用一个等待队列(一个用于可读性,另一个用于可写性,必要时还可以为异常使用一个)。

如果有数据可读,则(*poll)文件操作的返回值必须设置POLLIN | POLLRDNORM;如果设备是可写的,则设置POLLOUT | POLLWRNORM;如果没有新数据且设备尚不可写,则返回0。在以下示例中,我们假设设备支持阻塞的读写。当然,你也可以只实现其中一个。如果驱动程序没有定义此方法,设备将被视为始终可读和可写,poll()select()系统调用将立即返回。

实现poll操作可能需要调整读写文件操作方式,在写入时通知读者数据可读,在读取时通知写者数据可写:

#include <linux/poll.h>
/* declare a wait queue for each event type (read, write ...) */
static DECLARE_WAIT_QUEUE_HEAD(my_wq);
static DECLARE_WAIT_QUEUE_HEAD(my_rq);
static unsigned int eep_poll(struct file *file,
                             poll_table *wait)
{
    unsigned int reval_mask = 0;
    poll_wait(file, &my_wq, wait);
    poll_wait(file, &my_rq, wait);
    if (new_data_is_ready)
        reval_mask |= (POLLIN | POLLRDNORM);
    if (ready_to_be_written)
       reval_mask |= (POLLOUT | POLLWRNORM);
    return reval_mask;
}

在前面的代码片段中,我们实现了poll操作,当设备不可写或不可读时,进程会被挂起。然而,当这些状态发生变化时,没有通知机制。因此,write操作(或任何使数据可用的操作,如 IRQ)必须通知在可读等待队列中挂起的进程;同样,read操作(或任何使设备准备好可写的操作)必须通知在可写等待队列中挂起的进程。以下是一个示例:

wake_up_interruptible(&my_rq); /* Ready to read */
/* set flag accordingly in case poll is called */
new_data_is_ready = true;
wake_up_interruptible(&my_wq); /* Ready to be written to */
ready_to_be_written = true;

更精确地说,你可以通过驱动程序的write()方法通知可读事件,这意味着写入的数据可以被读取,或者通过 IRQ 处理程序通知可读事件,这意味着外部设备发送了一些可以读取的数据。另一方面,你可以通过驱动程序的read()方法通知可写事件,这意味着缓冲区为空并且可以重新填充,或者通过 IRQ 处理程序通知可写事件,这意味着设备已完成数据发送操作并准备再次接受数据。不要忘记在状态改变时将标志重置为false

以下是使用select()在给定字符设备上检测数据可用性的代码片段:

#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/select.h>
#define NUMBER_OF_BYTE 100
#define CHAR_DEVICE "/dev/packt_char"
char data[NUMBER_OF_BYTE];
int main(int argc, char **argv)
{
    int fd, retval;
    ssize_t read_count;
    fd_set readfds;
    fd = open(CHAR_DEVICE, O_RDONLY);
    if(fd < 0)
        /* Print a message and exit*/
        [...]
    while(1){ 
        FD_ZERO(&readfds);
        FD_SET(fd, &readfds);
       ret = select(fd + 1, &readfds, NULL, NULL, NULL);
        /* From here, the process is already notified */
        if (ret == -1) {
            fprintf(stderr, "select: an error ocurred");
            break;
        }

        /* we are interested in one file only */
        if (FD_ISSET(fd, &readfds)) {
            read_count = read(fd, data, NUMBER_OF_BYTE);
            if (read_count < 0)
                /* An error occurred. Handle this */
                [...]
            if (read_count != NUMBER_OF_BYTE)
                /* We have read less than needed bytes */
                [...] /* handle this */
            else
            /* Now we can process the data we have read */
            [...]
        }
    }    
    close(fd);
    return EXIT_SUCCESS;
}

在前面的代码示例中,我们使用了不带超时的select(),这种方式意味着我们将只在“读取”事件发生时得到通知。从那一行开始,进程会被挂起,直到它收到注册的事件通知。

ioctl 方法

一个典型的 Linux 系统包含大约 350 个-ENOTTY错误,这些错误会发生在任何ioctl()系统调用中。以下是其原型:

long ioctl(struct file *f, unsigned int cmd,
           unsigned long arg);

在前面的原型中,f是指向文件描述符的指针,表示已打开的设备实例,cmd是 ioctl 命令,arg是一个用户参数,可以是任何用户内存的地址,驱动程序可以在其上调用copy_to_user()copy_from_user()。为了简洁起见,并出于显而易见的原因,IOCTL 命令需要由一个唯一的编号来标识,这个编号应该在系统中唯一。IOCTL 编号的唯一性可以防止我们将正确的命令发送到错误的设备,或者将错误的参数传递给正确的命令(因为 IOCTL 编号重复)。Linux 提供了四个辅助宏来创建 IOCTL 标识符,具体取决于是否有数据传输以及传输的方向。它们各自的原型如下:

_IO(MAGIC, SEQ_NO)
_IOR(MAGIC, SEQ_NO, TYPE)
_IOW(MAGIC, SEQ_NO, TYPE)
_IORW(MAGIC, SEQ_NO, TYPE)

它们的描述如下:

  • _IO:IOCTL 命令不需要数据传输。

  • _IOR:这意味着我们正在创建一个 IOCTL 命令编号,用于将信息从内核传递到用户空间(即读取数据)。驱动程序将允许返回sizeof(TYPE)字节给用户,而不会将这个返回值视为错误。

  • _IOW:这与_IOR相同,但这次是用户向驱动程序发送数据。

  • _IOWR:IOCTL 命令需要同时提供写入和读取参数。

它们的参数含义(按传递顺序)在此处进行了描述:

  • 一个由 8 位编码的数字(0255),称为魔术数字

  • 一个序列号或命令 ID,大小也是 8 位。

  • 一个数据类型(如果有的话),它将告诉内核要复制的大小。这可以是一个结构体或数据类型的名称。

这在内核源代码的Documentation/ioctl/ioctl-decoding.txt中有很好的文档记录,现有的 IOCTL 命令列在Documentation/ioctl/ioctl-number.txt中,这是创建你自己 IOCTL 命令时的一个好起点。

生成一个 IOCTL 数字(一个命令)

建议在专用的头文件中生成你自己的 IOCTL 数字,因为这个头文件也应该在用户空间中可用。换句话说,你应该处理 IOCTL 头文件的重复(例如通过符号链接),确保内核中有一个,用户空间中也有一个,这样用户应用程序可以包含它。现在让我们在一个实际示例中生成一些 IOCTL 数字,并称这个头文件为eep_ioctl.h

#ifndef PACKT_IOCTL_H
#define PACKT_IOCTL_H
/* We need to choose a magic number for our driver,
 * and sequential numbers for each command:
 */
#define EEP_MAGIC 'E'
#define ERASE_SEQ_NO 0x01
#define RENAME_SEQ_NO 0x02
#define GET_FOO 0x03
#define GET_SIZE 0x04
/*
 * Partition name must be 32 byte max
 */
#define MAX_PART_NAME 32
/*
 * Now let's define our ioctl numbers:
 */
#define EEP_ERASE _IO(EEP_MAGIC, ERASE_SEQ_NO)
#define EEP_RENAME_PART _IOW(EEP_MAGIC, RENAME_SEQ_NO, \
                             unsigned long)
#define EEP_GET_FOO  _IOR(EEP_MAGIC, GET_FOO, \
                          struct my_struct *)
#define EEP_GET_SIZE _IOR(EEP_MAGIC, GET_SIZE, int *)
#endif

在定义命令之后,头文件需要包含在最终代码中。此外,由于它们都是唯一且有限的,使用switch ... case语句处理每个命令,并在调用未定义的ioctl命令时返回-ENOTTY错误代码是一个好习惯。以下是一个示例:

#include "eep_ioctl.h"
static long eep_ioctl(struct file *f, unsigned int cmd,
                      unsigned long arg)
{
    int part;
    char *buf = NULL;
    int size = 2048;
    switch(cmd){
        case EEP_ERASE:
            erase_eepreom();
            break;
        case EEP_RENAME_PART:
            buf = kmalloc(MAX_PART_NAME, GFP_KERNEL);
            copy_from_user(buf, (char *)arg,
                            MAX_PART_NAME);
            rename_part(buf);
            break;
        case EEP_GET_SIZE:
            if (copy_to_user((int*)arg,
                           &size, sizeof(int)))
                return -EFAULT;
            break;
        default:
            return –ENOTTY;
    }
    return 0;
}

内核和用户空间都必须包含包含 IOCTL 命令的头文件。因此,在前面摘录的第一行中,我们包含了eep_ioctl.h,这是定义我们 IOCTL 命令的头文件。

如果你认为你的 IOCTL 命令需要多个参数,你应该将这些参数收集到一个结构体中,并只需将结构体的指针传递给ioctl

现在,从用户空间,你必须使用与驱动代码中相同的ioctl头文件:

#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include "eep_ioctl.h" /* our ioctl header file */
int main()
{
    int size = 0;
    int fd;
    char *new_name = "lorem_ipsum";
    fd = open("/dev/eep-mem1", O_RDWR);
    if (fd < 0){
        printf("Error while opening the eeprom\n");
        return 1;
    }
    /* ioctl to erase partition */
    ioctl(fd, EEP_ERASE);
    /* call to get partition size */
    ioctl(fd, EEP_GET_SIZE, &size);
    /* rename partition */
    ioctl(fd, EEP_RENAME_PART, new_name);  
    close(fd);
    return 0;
}

在前面的代码中,我们展示了如何从用户空间使用内核的 IOCTL 命令。也就是说,在本节中,我们学习了如何实现字符设备的ioctl回调,以及如何在内核和用户空间之间交换数据。

总结

在本章中,我们已经解开了字符设备的神秘面纱,并且我们已经看到如何通过设备文件让用户与我们的驱动程序进行交互。我们学会了如何将文件操作暴露给用户空间,并在内核中控制它们的行为。我们甚至走得更远,现在你甚至能够实现多设备支持。

下一章将更侧重于硬件,因为它涉及设备树,这是一种机制,允许系统中的硬件设备向内核声明。下章见。

第二部分 - Linux 内核平台抽象与设备驱动程序

在本节中,我们将首先介绍设备树的概念,使我们能够声明和描述系统中不可发现的设备,然后我们将学习如何处理这些设备。在处理这些设备的过程中,我们将引入平台设备及其驱动程序的概念,并学习如何编写 I2C 和 SPI 设备驱动程序。

本章节将涵盖以下内容:

  • 第五章理解和利用设备树

  • 第六章设备、驱动程序与平台抽象概述

  • 第七章理解平台设备与驱动程序的概念

  • 第八章编写 I2C 设备驱动程序

  • 第九章编写 SPI 设备驱动程序

第五章:第五章:理解和利用设备树

设备树是一个易于阅读的硬件描述文件,具有类似 JSON 的格式风格。它是一个简单的树形结构,其中设备通过节点及其属性表示。这些属性可以为空(即仅通过键描述布尔值),也可以是键值对,其中值可以包含任意字节流。本章是设备树的简要介绍。每个内核子系统或框架都有其自己的设备树绑定,我们将在处理相关主题时讨论这些特定的绑定。

设备树起源于Open FirmwareOF),这是一个计算机公司支持的标准,其主要目的是定义计算机固件系统的接口。也就是说,你可以在www.devicetree.org/了解更多关于设备树规范的信息。因此,本章将介绍设备树的基础知识,包括以下内容:

  • 理解设备树机制的基本概念

  • 描述数据类型及其 API

  • 表示和访问设备

  • 处理资源

理解设备树机制的基本概念

设备树的支持在内核中通过将CONFIG_OF选项设置为Y来启用。要从驱动程序中调用设备树 API,必须添加以下头文件:

#include <linux/of.h>
#include <linux/of_device.h>

设备树支持一些数据类型和写作约定,我们可以通过一个示例节点描述来总结:

/* This is a comment */
// This is another comment
node_label: nodename@reg{
   string-property = "a string";
   string-list = "red fish", "blue fish";
   one-int-property = <197>; /* One cell in the property */
   int-list-property = <0xbeef 123 0xabcd4>;
   mixed-list-property = "a string", <35>,[0x01 0x23 0x45];
   byte-array-property = [0x01 0x23 0x45 0x67];
   boolean-property;
};

在前面的示例中,int-list-property是一个属性,其中每个数字(或单元)是一个 32 位整数(uint32),该属性包含三个单元。这里,mixed-list-property顾名思义,是一个具有混合元素类型的属性。

以下是设备树中使用的一些数据类型的定义:

  • 文本字符串用双引号表示。你可以使用逗号来创建字符串的列表。

  • 单元是由尖括号括起来的 32 位无符号整数。

  • 布尔数据不过是一个空属性。其真假值取决于该属性是否存在。

我们已经轻松列举了可以在设备树中找到的数据类型。在开始学习可以用来解析这些数据的 API 之前,首先让我们了解一下设备树的命名约定是如何工作的。

设备树命名约定

每个节点必须有一个形式为<name>[@<address>]的名称,其中<name>是一个最长为 31 个字符的字符串,[@<address>]是可选的,取决于该节点是否表示一个可寻址的设备。也就是说,<address>应该是用来访问设备的主要地址。例如,对于一个内存映射设备,它必须对应其内存区域的起始地址,I2C 设备的总线设备地址,和 SPI 设备节点的芯片选择索引(相对于控制器)。

以下是一些设备命名的示例:

i2c@021a0000 {
    compatible = "fsl,imx6q-i2c", "fsl,imx21-i2c";
    reg = <0x021a0000 0x4000>;
    [...]
    expander@20 {
        compatible = "microchip,mcp23017";
        reg = <20>;
        [...]       
    };
};

在前面的设备树摘录中,I2C 控制器是一个内存映射设备。因此,节点名称中的地址部分对应于其内存区域的起始位置,相对于片上系统SoC)的内存映射。然而,扩展器是一个 I2C 设备。因此,其节点名称中的地址部分对应于其 I2C 地址。

别名、标签、phandle 和路径概念简介

别名、标签、phandle 和路径是处理设备树时需要熟悉的关键字。处理设备驱动时,你很可能会遇到这些术语中的至少一个,甚至是所有术语。为了描述这些术语,我们以以下设备树摘录为例:

aliases {
    ethernet0 = &fec;
    gpio0 = &gpio1;
    [...];
};
bus@2000000 { /* AIPS1 */
    gpio1: gpio@209c000 {
        compatible = "fsl,imx6q-gpio", "fsl,imx35-gpio";
        reg = <0x0209c000 0x4000>;
        interrupts = <0 66 IRQ_TYPE_LEVEL_HIGH>,
                    <0 67 IRQ_TYPE_LEVEL_HIGH>;
        gpio-controller;
        #gpio-cells = <2>;
        interrupt-controller;
        #interrupt-cells = <2>;
    };
    [...];
};
bus@2100000 { /* AIPS2 */
    [...]
    i2c1: i2c@21a0000 {
        compatible = "fsl,imx6q-i2c", "fsl,imx21-i2c";
        reg = <0x021a0000 0x4000>;
        interrupts = <0 36 IRQ_TYPE_LEVEL_HIGH>;
        clocks = <&clks IMX6QDL_CLK_I2C1>;
    };
};
&i2c1 {
    eeprom-24c512@55 {
        compatible = "atmel,24c512";
        reg = <0x55>;
    };
    accelerometer@1d {
        compatible = "adi,adxl345";
        reg = <0x1d>;
        interrupt-parent = <&gpio1>;
        interrupts = <24 IRQ_TYPE_LEVEL_HIGH>,
        <25 IRQ_TYPE_LEVEL_HIGH>;
        [...]
    };
[...]
};

在设备树中,节点可以通过两种方式引用:通过路径或通过phandle属性,有时为了历史原因,可能会在linux,phandle属性中重复出现。

然而,设备树源格式允许将标签附加到任何节点或属性值上。由于标签必须在特定板卡的整个设备树源中是唯一的,因此显然可以使用标签来标识节点,并且已做出决定这么做。因此,已经在逻辑中加入了处理:当标签作为单元格属性中的&前缀时,它会被替换为该标签所附加节点的 phandle。此外,使用相同的逻辑,当标签在单元格外(简单的值赋值)以&符号为前缀时,它会被替换为该标签所附加节点的完整路径。通过这种方式,phandle 和路径引用可以通过引用标签来自动生成,而不必显式指定 phandle 值或节点的完整路径。

注意

标签仅用于设备树源格式,并不会被编码在dtc工具中,该工具会从节点中移除该标签并为该节点添加一个phandle属性,生成并分配一个唯一的 32 位值。然后,dtc工具将在每个引用该节点的单元格中使用该phandle(以&符号为前缀)。

回到前面的摘录,gpio@0209c000节点被标记为gpio1,这个标签也被作为引用使用。这将指示 DTC 为此节点生成一个 phandle。因此,在accelerometer@1d节点中,interrupt-parent属性内的单元格值(&gpio1)将被替换为gpio1所附加的节点的 phandle(单元格内部的赋值)。以同样的方式,在aliases节点中,&gpio1将被替换为gpio1所附加的节点的完整路径(单元格外部的赋值)。

在编译和反编译原始设备树摘录后,我们得到以下内容,其中标签不再存在,标签引用已被 phandle 或完整节点路径所替代:

aliases {
    gpio0 = "/soc/aips-bus@2000000/gpio@209c000";
    ethernet0 = "/soc/aips-bus@2100000/ethernet@2188000";
    [...]
};
aips-bus@2000000 {
    gpio@209c000 {
        compatible = "fsl,imx6q-gpio", "fsl,imx35-gpio";
        gpio-controller;
        #interrupt-cells = <0x2>;
        interrupts = <0x0 0x42 0x4 0x0 0x43 0x4>;
        phandle = <0x40>;
        reg = <0x209c000 0x4000>;
        #gpio-cells = <0x2>;
        interrupt-controller;
    };
};
aips-bus@2100000 {
    i2c@21a8000 {
        compatible = "fsl,imx6q-i2c", "fsl,imx21-i2c";
        clocks = <0x4 0x7f>;
        interrupts = <0x0 0x26 0x4>;
        reg = <0x21a8000 0x4000>;
        eeprom-24c512@55 {
            compatible = "atmel,24c512";
            reg = <0x55>;
        };
        accelerometer@1d {
            compatible = "adi,adxl345";
            interrupt-parent = <0x40>;
            interrupts = <0x18 0x4 0x19 0x4>;
            reg = <0x1d>;
        };
    };
};

在前面的代码片段中,加速度计节点的interrupt-parent属性单元被赋值为0x40。查看gpio@209c000节点,我们可以看到该值对应其phandle属性的值,该值在编译时由 DTC 生成。aliases节点也是如此,其中节点引用已经被替换为其完整路径。

这引出了别名的定义;别名只是通过它们的绝对路径进行引用的节点,用于快速查找。aliases节点可以看作是一个快速查找表。与标签不同,别名确实会出现在输出的设备树中,尽管路径是通过引用标签生成的。使用别名时,可以通过在别名部分查找来获取它所引用节点的句柄,而不是像通过phandle查找时那样在整个设备树中查找。别名可以看作是一种快捷方式,类似于我们在 Unix shell 中设置的别名,用来引用完整的/长的/重复的路径/命令。

Linux 内核会解除引用别名,而不是直接在设备树源中使用它们。当使用of_find_node_by_path()of_find_node_opts_by_path()根据路径查找节点时,如果提供的路径没有以/开头,则路径的第一个元素必须是/aliases节点中的属性名称。该元素将被该别名的完整路径所替代。

注意

给节点打标签只有在该节点打算从另一个节点的属性中引用时才有意义。你可以将标签视为指向节点的指针,可以通过路径或引用来进行。

理解节点和属性的覆盖

在仔细查看解编译片段的结果后,你应该注意到另一件事:在原始源文件中,i2c@21a0000节点是通过其标签作为外部节点(&i2c1 { [...] })引用的,并且有一些内容。然而,奇怪的是,在解编译后,i2c@21a0000节点的最终内容已经与外部引用的内容合并,而外部引用节点不再存在。

这是标签的第三种用途:允许你覆盖节点和属性。在外部引用中,任何新内容(如节点或属性)都将在编译时附加到原始节点内容上。然而,在出现重复时(无论是节点还是属性),外部引用的内容将优先于原始内容。

参考以下示例:

bus@2100000 { /* AIPS2 */
    [...]
    i2c1: i2c@21a0000 {
        [...]
        status = "disabled";
    };
};

让我们通过其i2c1标签引用i2c@21a0000节点,如下所示:

&i2c1 {
    [...]
    status = "okay";
};

我们将看到编译结果是status属性的值为"okay"。这是因为外部引用的内容优先于原始内容。

总结来说,后面的定义总是会覆盖前面的定义。为了完全覆盖一个节点,你只需要像重新定义属性一样重新定义它们。

设备树源和编译器

.dts扩展名,而二进制形式则有.dtb.dtbo扩展名。.dtbo是一个特殊的扩展名,用于编译的设备树覆盖(.dtsi文本文件(其中末尾的i表示“包含”))。这些文件承载 SoC 级别的定义,并打算被包含在.dts文件中,后者则承载板级别的定义。

设备树的语法允许你使用/include/#include来包含其他文件。这个包含机制使得使用#define成为可能,但最重要的是,它允许你在共享文件中提取多个平台的共同部分。

这种提取使得你能够将源文件拆分为树形结构,其中最常见的是 SoC 级别,由 SoC 供应商(例如 NXP)提供,其次是系统模块SoM)级别(例如 Engicam),最后是承载板或客户板级别。

因此,所有使用相同 SoC 的电子板不需要从头开始重新定义 SoC 的所有外设:这些描述被提取到一个公共文件中。根据约定,这种公共文件使用.dtsi扩展名,而最终的设备树使用.dts扩展名。

在 Linux 内核源码中,ARM 设备树源文件可以在arch/arm/boot/dts/arch/arm64/boot/dts/<vendor>/目录下找到,分别用于 32 位和 64 位 ARM SoC/板卡。在这两个目录中,都有一个Makefile文件,列出了可以编译的设备树源文件。

用于将 DTS 文件编译为 DTB 文件的工具叫做 DTC。DTC 的源代码存在于两个地方:

  • 作为一个独立的上游项目:DTC 上游项目托管在git.kernel.org/cgit/utils/dtc/dtc.git。它会定期拉取到 Linux 内核源树中。

  • scripts/dtc/。新版本会定期从上游项目拉取。DTC 在 Linux 内核构建过程中作为依赖项构建(例如,在编译设备树之前)。如果你希望在 Linux 内核源树中显式构建它,可以使用make scripts命令。

从内核源代码的主目录中,你可以选择编译特定的设备树或为特定 SoC 编译所有设备树。在这两种情况下,必须启用适当的配置选项以启用这些设备树文件。对于单个设备树的编译,make 目标是将.dts文件的名称中的.dts更改为.dtb。对于所有启用的设备树进行编译,应该使用的 make 目标是dtbs。在这两种情况下,你应确保已设置启用dtb的配置选项。

考虑以下来自arch/arm/boot/dts/Makefile的摘录:

dtb-$(CONFIG_SOC_IMX6Q) += \
    imx6dl-alti6p.dtb \
    imx6dl-aristainetos_7.dtb \
[...]
    imx6q-hummingboard.dtb \
    imx6q-hummingboard2.dtb \
    imx6q-hummingboard2-emmc-som-v15.dtb \
    imx6q-hummingboard2-som-v15.dtb \
    imx6q-icore.dtb \
[...]

通过启用 CONFIG_SOC_IMX6Q,你可以编译列表中所有的设备树文件,或者针对特定的设备树进行编译。通过运行 make dtbs,内核 DTC 将编译所有启用配置选项中列出的设备树文件。

首先,确保已经设置了适当的配置选项:

$ grep CONFIG_SOC_IMX6Q .config
CONFIG_SOC_IMX6Q =y

然后,我们来编译所有的设备树文件:

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

假设我们的平台是 ARM64 平台,我们将使用以下命令:

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

再次提醒,必须设置正确的内核配置选项。

你可以使用以下命令来针对特定的设备树构建(例如 imx6q-hummingboard2.dts):

ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- make imx6q-hummingboard2.dtb

必须注意,给定一个已编译的设备树(.dtb)文件,你可以进行反向操作,提取源代码(.dts)文件:

$ dtc -I dtb -O dts arch/arm/boot/dts/imx6q-hummingboard2.dtb > path/to/my_devicetree.dts

出于调试目的,可能会将运行系统的当前设备树暴露给用户空间,也就是所谓的实时设备树。为此,必须启用内核 CONFIG_PROC_DEVICETREE 配置选项。然后,你可以在 /proc/device-tree 目录中浏览和查看设备树。

如果在运行的系统上安装了 DTC,可以使用以下命令将文件系统树转换为更易读的形式:

# dtc -I fs -O dts /sys/firmware/devicetree/base > MySBC.dts

在此命令返回后,MySBC.dts 文件将包含与当前设备树相对应的源代码。

设备树覆盖

设备树覆盖是一种机制,允许你对实时设备树进行修补,即在运行时修改当前的设备树。它通过更新现有的节点和属性或创建新的节点和属性,允许你在运行时更新当前的设备树。然而,它不允许删除节点或属性。

设备树覆盖具有以下格式:

/dts-v1/;
/plugin/; /* allow undefined label references and record them */
/{
    fragment@0 { /* first child node */
        target=<phandle>; /* phandle of the target node to extend */
    or
        target-path="/path"; /* full path of the node to extend */
        __overlay__ {
            property-a;  /* add property-a to the target */
            property-b = <0x80>; /* update property-b value */
            node-a { /* add to an existing, or create a node-a */
                    ...
            };
        };
    };
    fragment@1 {
         /* second fragment overlay ... */
    };
    /* more fragments follow */
}

从前面的摘录中,我们可以注意到,每个需要覆盖的基础设备树节点都必须被包含在覆盖设备树中的 fragment 节点内。

然后,每个片段有两个元素:

  • 这两个属性中的一个可能如下所示:

    • target-path: 这指定了片段将要修改的节点的绝对路径。

    • target: 这指定了片段将要修改的节点别名的相对路径(以 & 符号为前缀)。

  • 一个名为 __overlay__ 的节点,包含应该应用到被引用节点的更改。这些更改可以是新的节点(被添加的)、新的属性(被添加的),或者现有的属性(被新值覆盖)。不允许进行删除操作,因为无法删除节点或属性。

现在我们已经掌握了设备树覆盖的基础知识,接下来我们可以学习它们是如何被编译并转化为二进制 blob,随时加载的。

构建设备树覆盖

除非设备树覆盖仅在根节点下添加新节点(在这种情况下,它可以在片段中指定 / 作为 target-path 属性),否则指定目标节点时通过其 phandle(<&label_name>)会更容易,因为这可以避免我们手动计算节点的完整路径(尤其是当它是嵌套的)。

问题在于,基础设备树和覆盖之间没有直接的关联或链接。它们各自独立构建。因此,从设备树覆盖中引用远程节点(即基础设备树中的节点)会引发错误,覆盖的构建将因为未定义的引用或标签而失败。这就像在没有符号解析空间的情况下构建一个动态链接的应用程序。

为了解决这个问题,DTC 已添加对 -@ 命令行标志的支持。必须为基础设备树和所有覆盖指定该标志,才能编译。它会指示 DTC 在根节点中生成额外的节点(如 __symbols____fixups____local_fixups__),这些节点包含用于解析 phandle 名称的翻译数据。这些额外的节点分布如下:

  • -@ 选项添加到构建覆盖时,它会识别标记设备树片段/对象的 /plugin/; 行。该行控制 __fixups____local_fixups__ 节点的生成。

  • -@ 选项添加到构建基础设备树时,/plugin/; 不存在,因此源被识别为基础设备树,这会导致仅生成 __symbols__ 节点。

这些额外的节点为符号解析提供了空间。

注意

-@ 选项的支持仅在 dtc 版本 1.4.4 或更高版本中找到。只有 Linux 内核版本 v4.14 或更高版本包括一个符合此要求的内置 dtc 版本。如果设备树覆盖中仅使用 target-path 属性(即非 phandle 基于属性),则不需要此选项。

构建二进制设备树覆盖的过程与构建传统的二进制设备树相同。例如,考虑以下基础设备树,我们将其命名为 base.dts

/dts-v1/;
/ {
        foo: foonode {
                foo-bool-property;
                foo-int-property = <0x80>;
                status = "disabled";
        };
};

接下来,让我们使用以下命令构建基础设备树:

dtc -@ -I dts -O dtb -o base.dtb base.dts

在下一步中,让我们考虑以下设备树覆盖,我们将其命名为 foo-verlay.dts

/dts-v1/;
/plugin/;
/ {
        fragment@1 {
                target = <&foo>;
                __overlay__ {
                        overlay-1-property;
                        status = "okay";
                        bar: barnode {
                                bar-property;
                        };
                };
        };
};

在前面的设备树覆盖中,基础设备树中 foo 节点的 status 属性已从 disabled 修改为 okay,这将激活该节点。随后,添加了 overlay-1-property 布尔属性,最后,添加了一个 bar 子节点,并包含一个布尔属性。此设备树覆盖可以通过以下命令进行编译:

dtc -@ -I dts -O dtb -o foo-overlay.dtbo foo-overlay.dts

如你所见,-@ 标志已添加到两侧,提供了符号解析的空间。

注意

在 Yocto 构建系统中,你可以将此标志添加到机器配置中,或者在开发过程中,将其添加到local.conf文件中,如下所示:DEVICETREE_FLAGS += "-@"

为了让 Linux 内核构建设备树覆盖文件,你应当将其添加到你所使用的 SoC 架构的Makefile设备树中,例如arch/arm64/boot/dts/freescale/Makefilearm/arm/boot/dts/Makefile,并使用dtbo扩展名,如下所示:

dtb-y += foo-overlay.dtbo

既然我们已经能够构建自己的设备树覆盖文件,接下来我们可以考虑一个逻辑上的下一步,即将这些覆盖文件加载到系统中。

通过 configfs 加载设备树覆盖文件

本节的名称表明了设备树覆盖文件将被加载的方式(configfs),因为加载设备树覆盖文件的方式不止一种。在这一节中,我们将专注于在一个已经启动并且根文件系统已经挂载的系统中加载覆盖文件。

为了做到这一点,你的内核必须在编译时启用了CONFIG_OF_OVERLAYCONFIG_CONFIGFS,才能使接下来的步骤有效。以下是一个检查示例,假设目标系统上已经有了内核配置:

~# zcat /proc/config.gz | grep CONFIGFS
CONFIG_CONFIGFS_FS=y
~# zcat /proc/config.gz | grep OF_OVERLAY
CONFIG_OF_OVERLAY=y
~# 

现在是时候使用configfs将 DTB 插入到正在运行的内核中了。首先,如果configfs文件系统尚未挂载,你需要先将其挂载:

# mount -t configfs none /sys/kernel/config

configfs正确挂载后,目录应该包含基本的子目录(device-tree/overlays),根据我们的挂载路径,这将会是/sys/kernel/config/device-tree/overlays,如下所示:

# mkdir -p /sys/kernel/config/device-tree/overlays/

然后,必须从overlays目录中添加每个覆盖文件条目。需要注意的是,覆盖条目的创建和操作是通过标准的文件系统 I/O 进行的。

为了加载一个覆盖文件,必须在overlays目录下创建一个与该覆盖文件对应的目录。以我们的例子为例,我们使用名称foo

# mkdir /sys/kernel/config/device-tree/overlays/foo

接下来,为了有效加载覆盖文件,你可以通过echo命令将覆盖固件文件路径写入到path属性文件中,如下所示:

# echo /path/to/foo-overlay.dtbo > /sys/kernel/config/device-tree/overlays/foo/path

另外,你可以将覆盖文件的内容通过cat命令写入到dtbo文件中:

# cat foo.dtbo > /sys/kernel/config/device-tree/overlays/foo/dtbo

之后,覆盖文件将会应用,设备将会根据需要被创建或销毁。

要移除覆盖文件并撤销其更改,你只需执行rmdir命令删除相应的覆盖文件目录。在我们的例子中,应该如下所示:

# rmdir /sys/kernel/config/device-tree/overlays/foo

尽管你已经动态加载了设备树覆盖文件,但这还不够;为了让新添加的设备节点工作,设备驱动程序也需要加载,除非这个驱动程序是内建的并且已经启用(即在make menuconfig期间选择了y)。

到此为止,我们已经完成了设备树编译相关的工作。现在,我们可以开始学习如何编写我们自己的设备树,从设备寻址和表示开始。

表示和寻址设备

在设备树中,节点是设备的表示单元。换句话说,一个设备至少由一个节点表示。接下来,设备节点可以包含其他节点(从而创建父子关系)或包含属性(这些属性描述与它们所填充节点对应的设备)。

尽管每个设备都可以独立操作,但也有一些情况,其中一个设备可能希望被其父设备访问,或者父设备可能希望访问其子设备。例如,当总线控制器(父节点)想要访问其总线上一个或多个设备(作为子节点声明)时,就会发生这种情况。典型的例子包括 I2C 控制器与 I2C 设备,SPI 控制器与 SPI 设备,CPU 与内存映射设备等。因此,设备地址分配的概念应运而生。设备地址分配通过 reg 属性引入,该属性用于每个可寻址设备,但其含义或解释依赖于父设备(通常是总线控制器)。子设备中 reg 的含义和解释取决于其父设备的 #address-cells#size-cells 属性。#(井号)字符作为前缀出现在 size-cellsaddress-cells 中,可以理解为“长度”。

每个可寻址设备都会有一个 reg 属性,它是一个元组列表,形式为 reg = <address0 size0 [address1 size1] [address2 size2] ... >,其中每个元组代表设备使用的地址范围。#size-cells 表示用于表示大小的 32 位单元数量,如果大小不相关,可能为 0。另一方面,#address-cells 表示用于表示地址的 32 位单元数量。换句话说,每个元组的地址元素是根据 #address-cells 来解释的,它使用与大小元素相同的大小,而大小元素则根据 #size-cells 来解释。

总结来说,可寻址设备继承其父设备的 #size-cell#address-cell 属性,而父设备通常是表示总线控制器的节点。某一设备中是否存在 #size-cell#address-cell 不会影响设备本身,但会影响它的子设备(如果它们是可寻址的)。

现在我们已经看到了地址分配的一般原理,让我们开始讨论针对不可发现设备的具体地址分配,从 SPI 和 I2C 开始。

处理 SPI 和 I2C 设备地址分配

SPI 和 I2C 设备都属于非内存映射设备,因为它们的地址无法被 CPU 访问。相反,父设备的驱动程序(总线控制器驱动程序)将代表 CPU 进行间接访问。每个 I2C/SPI 设备节点总是表示为设备所在的 I2C/SPI 控制器节点的子节点。对于非内存映射设备,#size-cells属性为 0,寻址元组中的大小元素为空。这意味着这种设备的reg属性始终只有一个单元。以下是一个示例:

&i2c3 {
    [...]
    status = "okay";
    temperature-sensor@49 {
        compatible = "national,lm73";
        reg = <0x49>;
    };
    pcf8523: rtc@68 {
        compatible = "nxp,pcf8523";
        reg = <0x68>;
    };
};
&ecspi1 {
    fsl,spi-num-chipselects = <3>;
    cs-gpios = <&gpio5 17 0>, <&gpio5 17 0>, <&gpio5 17 0>;
    status = "okay";
    [...]
    ad7606r8_0: ad7606r8@1 {
        compatible = "ad7606-8";
        reg = <1>;
        spi-max-frequency = <1000000>;
        interrupt-parent = <&gpio4>;
        interrupts = <30 0x0>;
        convst-gpio = <&gpio6 18 0>;
    };
};

如果你查看arch/arm/boot/dts/imx6qdl.dtsi中的 SoC 级文件,你会注意到,在 I2C 和 SPI 控制器节点(标记为i2c3ecspi1)中,#size-cells#address-cells分别被设置为01。这有助于你理解它们的reg属性,其中地址值只需要一个单元,而大小值不需要单元。

I2C 设备的reg属性用于指定设备在总线上的地址。对于 SPI 设备,reg表示在控制器节点所拥有的芯片选择线列表中分配给设备的芯片选择索引。例如,对于ad7606r8 ADC,芯片选择索引为 1,对应于<&gpio5 17 0>中的cs-gpios。这就是控制器节点的芯片选择列表。其他控制器的绑定方式可能不同,你应参考它们的文档,位于Documentation/devicetree/bindings/spi

内存映射设备与设备寻址

本节讨论简单的内存映射设备,其中内存区域可以被 CPU 访问。对于这种设备节点,reg属性仍然定义设备的地址,并采用reg = <address0 size0 [address1 size1] [address2 size2] ... >的模式。每个区域都用一个单元组表示,第一个单元是内存区域的基地址,第二个单元是该区域的大小。这可以转化为以下模式:reg = <base0 length0 [base1 length1] [address2 length2] ... >。在这里,每个元组表示设备使用的地址范围。

让我们考虑以下示例:

soc {
    #address-cells = <1>;
    #size-cells = <1>;
    compatible = "simple-bus";
    aips-bus@02000000 { /* AIPS1 */
        compatible = "fsl,aips-bus", "simple-bus";
        #address-cells = <1>;
        #size-cells = <1>;
        reg = <0x02000000 0x100000>;
        [...];
        spba-bus@02000000 {
            compatible = "fsl,spba-bus", "simple-bus";
            #address-cells = <1>;
            #size-cells = <1>;
             reg = <0x02000000 0x40000>;
             [...]

            ecspi1: ecspi@02008000 {
                #address-cells = <1>;
                #size-cells = <0>;
                compatible = "fsl,imx6q-ecspi", "fsl,imx51-ecspi";
                reg = <0x02008000 0x4000>;
                [...]
            };

            i2c1: i2c@021a0000 {
                #address-cells = <1>;
                #size-cells = <0>;
                compatible = "fsl,imx6q-i2c", "fsl,imx21-i2c";
                reg = <0x021a0000 0x4000>;
              [...]
            };
        };
    };
};

在前面的摘录中,compatible属性中包含simple-bus的设备节点是 SoC 内部内存映射总线控制器,用于将 IP 核心(如 I2C、SPI、USB、以太网及其他内部 SoC IP)连接到 CPU。它们的子节点,可能是 IP 核心或其他内部总线,继承自#address-cells#size-cells属性。我们可以看到,i2c@021a0000 I2C 控制器(标记为i2c1)连接到spba-bus@02000000总线。它们都会在运行时作为平台设备出现在系统中。另一方面,这个 I2C 控制器通过定义自己的#address-cells#size-cells属性来更改其寻址方案,连接到它的 I2C 设备将继承这些属性。SPI 控制器也是如此。

总结来说,在实际应用中,如果不清楚父节点的#size-cells#address-cells属性,你不应当解读节点中的reg属性。内存映射设备必须在其reg属性的size字段中设置设备内存区域的大小,同时还需要定义address字段,该字段必须与 SoC 内存映射中设备内存区域的起始位置对应,如 SoC 的数据手册所示。

注意

simple-bus-compatible 字符串还表明该总线没有特殊的驱动程序,无法动态探测该总线,且直接子节点(仅限一级子节点)将作为平台设备注册。有时,这在板级设备树中用于实例化基于 GPIO 的固定电压调节器。

资源处理

设备驱动的主要目的是为给定设备提供一组驱动功能,并向用户暴露其能力。在这里,目标是收集设备的配置参数,特别是资源(如内存区域、中断线、DMA 通道等),这些资源将帮助驱动程序执行其工作。

struct resource

一旦探测完成,分配给设备的资源(无论是在设备中还是在板/机器文件中)都会由of_platformplatform核心使用struct resource进行收集和分配,具体如下:

struct resource {
    resource_size_t start;
    resource_size_t end;
    const char *name;
    unsigned long flags;
[...]
};

以下列出了数据结构中各个元素的含义:

  • start:根据资源标志,这可以是内存区域的起始地址、IRQ 线编号、DMA 通道编号或寄存器偏移量。

  • end:这是内存区域的结束位置或寄存器偏移的结束位置。在 IRQ 或 DMA 通道的情况下,大多数时候,startend的值是相同的。

  • name:这是资源的名称(如果有的话)。我们将在下一节中详细讨论它。

  • flags:指示资源的类型。可能的值包括以下几种:

    • IORESOURCE_IO:指示 PCI/ISA I/O 端口区域。start是该区域的第一个端口,end是最后一个端口。

    • IORESOURCE_MEM:用于 I/O 内存区域。start表示区域的起始地址,end表示区域的结束地址。

    • IORESOURCE_REG:这指的是寄存器偏移量。它主要用于 MFD 设备。start表示相对于父设备寄存器的偏移量,而end表示寄存器段的结束位置。

    • IORESOURCE_IRQ:该资源是 IRQ 线编号。在这种情况下,startend的值要么相同,要么end无关紧要。

    • IORESOURCE_DMA:表示该资源是一个 DMA 通道编号。你应当像处理 IRQ 一样处理end。然而,当 DMA 通道标识符有多个单元,或者当有多个 DMA 控制器时,情况并没有很好地定义。IORESOURCE_DMA在多控制器系统中不可扩展。

每个资源分配一个数据结构实例。这意味着,对于一个被分配了两个内存区域和一个 IRQ 线的设备,将分配三个数据结构。此外,同一类型的资源将按照它们在设备树(或板文件)中声明的顺序进行分配和索引(从 0 开始)。这意味着第一个分配的内存区域将具有索引 0,依此类推。

为了获取适当的资源,我们将使用一个通用的 API,platform_get_resource(),并传入资源类型以及该类型下的资源索引。该函数的定义如下:

struct resource *platform_get_resource(
                      struct platform_device *dev,
                      unsigned int type, unsigned int num)

在前面的原型中,dev是我们为其编写驱动程序的平台设备,type是资源类型,num是该资源在同一类型中的索引。成功时,该函数返回指向struct resource的有效指针,失败则返回NULL

当同一类型下有多个资源时,使用索引可能会导致误解。你可能决定依赖资源名称作为替代方案,这时将引入platform_get_resource()的命名版本,即platform_get_resource_byname()。该函数传入一个资源标志(或类型)及其名称,返回相应的资源,无论它们在设备树中的声明顺序如何。

它的定义如下:

struct resource *platform_get_resource_byname(
                             struct platform_device *dev,
                             unsigned int type,
                           const char *name)

为了理解如何使用这个功能,首先让我们介绍一下命名资源的概念。我们将接下来讨论这个内容。

命名资源的概念

当驱动程序期望某种类型的资源列表时(假设是两个 IRQ 线,第一个用于 Tx,第二个用于 Rx),列表的顺序并不能得到保证,驱动程序不能做任何假设。如果驱动程序的逻辑硬编码假设先是 Rx IRQ,但设备树中是先配置了 Tx,会发生什么情况呢?为了避免这种不匹配,提出了命名资源的概念(如时钟、IRQ、DMA 通道和内存区域)。这包括定义资源列表并为其命名。无论它们的索引是什么,给定的名称总是能匹配到相应的资源。命名资源的概念还使得设备树的资源分配更加易于阅读和理解。

用于命名资源的对应属性如下:

  • reg-names:这是reg属性中内存区域的名称列表。

  • interrupt-names:这是为interrupts属性中的每个中断线命名的。

  • dma-names:这是用于dma属性的。

  • clock-names:这是用来给clocks属性中的时钟命名的。请注意,书中不会讨论时钟的相关内容。

为了演示这个概念,假设我们有如下的虚拟设备节点条目:

fake_device {
    compatible = "packt,fake-device";
    reg = <0x4a064000 0x800>,
          <0x4a064800 0x200>,
          <0x4a064c00 0x200>;
    reg-names = "ohci", "ehci", "config";
    interrupts = <0 66 IRQ_TYPE_LEVEL_HIGH>,
                 <0 67 IRQ_TYPE_LEVEL_HIGH>;
    interrupt-names = "ohci", "ehci";
};

在前面的示例中,设备被分配了三个内存区域和两条中断线。资源名称列表和资源之间存在一一映射关系。这意味着,例如,索引为0的名称将被分配给相同索引的资源。驱动程序中提取每个命名资源的代码如下:

struct resource *res_mem_config, resirq, *res_mem1;
int txirq, rxirq;
/*let's grab region <0x4a064000 0x800>*/
res_mem1 = platform_get_resource_byname(pdev,
                   IORESOURCE_MEM, "ohci");
/*let's grab region <0x4a064c00 0x200>*/
res_mem_config = platform_get_resource_byname(pdev,
                  IORESOURCE_MEM, "config");
txirq = platform_get_resource_byname(pdev, 
                          IORESOURCE_IRQ, "ohci");
rxirq = platform_get_resource_byname(pdev,
                          IORESOURCE_MEM, "ehci");

正如你所看到的,以命名方式请求资源更不容易出错。也就是说,platform_get_resource()platform_get_resource_byname()是通用 API,用于处理资源。然而,也有专门的 API 可以帮助你减少开发工作量(例如platform_get_irq_byname()platform_get_irq()platform_get_and_ioremap_resource()),我们将在后面的章节中学习这些内容。

提取特定应用数据

特定应用数据是指超出通用资源的数据(既不是 IRQ 号、内存区域、调节器,也不是时钟)。这些是可以分配给设备的任意属性和子节点。通常,这些属性使用厂商前缀。它们可以是任何类型的字符串、布尔值或整数值,以及它们在 Linux 源代码中drivers/of/base.c中定义的 API。请注意,我们在这里讨论的示例并不详尽。现在,让我们重用本章早些时候定义的节点:

node_label: nodename@reg{
    string-property = "a string";
    string-list = "red fish", "blue fish";
    one-int-property = <197>; /* One cell property */
   /* in the following line, each number (cell) is a
    * 32-bit integer(uint32). There are 3 cells in
    * this property */
    int-list-property = <0xbeef 123 0xabcd4>;
    mixed-list-property = "a string", <0xadbcd45>, 
                          <35>, [0x01 0x23 0x45];
    byte-array-property = [0x01 0x23 0x45 0x67];
    one-cell-property = <197>;
    boolean-property;
};

在接下来的章节中,我们将学习如何获取前面设备树节点摘录中的每个属性。

提取字符串属性

以下是前面示例的摘录,展示了一个单独的字符串和多个字符串属性:

string-property = "a string";
string-list = "red fish", "blue fish";

在驱动程序中,根据需求可以使用不同的 API。你应该使用of_property_read_string()来读取单个字符串值属性。其原型定义如下:

int of_property_read_string(const struct device_node *np,
                           const char *propname,
                           const char **out_string)
int of_property_read_string_index(const struct 
                                 device_node *np,
                                 const char *propname,
                                 int index, 
                                 const char **output)
int of_property_read_string_array(
                  const struct device_node *np,
                  const char *propname, 
                  const char **out_strs,
                  size_t sz)

在前面的函数中,np是需要读取字符串属性的节点,propname是包含字符串或字符串列表的属性名称,sz是要读取的数组元素数量,out_string是一个输出参数,其指针值将被更改为指向字符串值。

如果属性没有值,of_property_read_string()of_property_read_string_index()将返回-ENODATA。如果属性根本不存在,它们将返回-EINVAL。此外,如果字符串在属性数据的长度范围内没有以NULL结尾,则会返回-EILSEQ。最后,如果成功,它们将返回0

of_property_read_string_array() 在给定的设备树节点中搜索指定的属性,检索该属性中的一组 NULL 终止的字符串值(实际上是指向这些字符串的指针,而不是副本),并将其分配给 out_strs。除了 of_property_read_string()of_property_read_string_index() 返回的值外,这个函数有个特别之处,即如果目标指针数组不是 NULL,它会返回已读取的字符串数量。如果您只是想计算属性中的字符串数量,可以省略 out_strs 参数(设置为 NULL)。

以下代码展示了如何使用它们:

size_t count;
const char **res;
const char *my_string = NULL;
const char *blue_fish = NULL;
of_property_read_string(pdev->dev.of_node,
                      "string-property", &my_string);
of_property_read_string_index(pdev->dev.of_node,
                      "string-list", 1, &blue_fish);
count = of_property_read_string_array(dp, 
                      "string-list", res, count);

在上面的示例中,我们学习了如何提取字符串属性,无论是来自单一值属性还是来自列表。需要注意的是,返回的是字符串(或字符串列表)的指针,而不是副本。此外,在最后一行中,我们看到如何提取数组中给定数量的 NULL 终止字符串元素。同样,这里返回的是指向这些元素的指针,而不是它们的副本。

读取单元格和无符号 32 位整数

这是我们的 int 属性:

one-int-property = <197>;
int-list-property = <1350000 0x54dae47 1250000 1200000>;

在驱动程序中,与字符串属性一样,您可以根据需要选择一组 API。您应该使用 of_property_read_u32() 来读取单元格值。其原型定义如下:

int of_property_read_u32(const struct device_node *np,
                        const char *propname,
                        u32 *out_value)
int of_property_read_u32_index(
                      const struct device_node *np,
                      const char *propname,
                      u32 index, u32 *out_value)
int of_property_read_u32_array(
                      const struct device_node *np,
                      const char *propname,
                      u32 *out_values, size_t sz)

上述 API 的行为与它们的 _string_string_index_string_array 对应函数相同。这是因为它们在成功时都返回 0,如果属性根本不存在,则返回 -EINVAL,如果属性没有值,则返回 -ENODATA。不同之处在于,读取的值类型在此情况下为 u32,如果属性数据不够大,则会返回 -EOVERFLOW 错误,并且 out_values 必须先分配,因为返回的值是已复制的,而不是原始数据。

以下是我们在示例中使用这些 API 的方法,涉及 int 类型和我们的 int 属性列表:

unsigned int number;
of_property_read_u32(pdev->dev.of_node,
                    "one-cell-property", &number);

您可以使用 of_property_read_u32_array 来读取一个单元格列表。其原型如下:

int of_property_read_u32_array(
                           const struct device_node *np,
                           const char *propname,
                           u32 *out_values, size_t sz);

这里,sz 是要读取的数组元素数量。请查看 drivers/of/base.c 以了解如何解释其返回值:

unsigned int cells_array[4]; /* return value by copy */
if (!of_property_read_u32_array(pdev->dev.of_node,
    "int-list-property", cells_array, 4))
    dev_info(&pdev->dev, "u32 list read successfully\n");
/* can now process values in cells_array */
[...]

在这里,我们演示了如何轻松处理一系列单元格,即 32 位整数值的数组。在下一节中,我们将看到如何使用布尔属性来处理这些。

处理布尔属性

您应该使用 of_property_read_bool() 来读取函数第二个参数中给定名称的布尔属性:

bool my_bool = of_property_read_bool(pdev->dev.of_node,
                                  "boolean-property");
if(my_bool){
    /* boolean is true */
} else
    /* boolean is false */
}

上面的示例演示了如何处理布尔属性。现在我们可以学习更复杂的 API,从提取和解析子节点开始。

提取和解析子节点

请注意,你可以将任何你想要的子节点添加到设备节点中。这种用法在许多应用场景中很常见,例如在 MTD 设备节点中表示分区,或在电源管理芯片节点中描述调节器约束。例如,给定一个表示闪存设备的节点,分区可以作为嵌套的子节点来表示。以下示例展示了如何实现这一点:

eeprom: ee24lc512@55 {
    compatible = "microchip,24xx512";
    reg = <0x55>;
    partition@1 {
        read-only;
        part-name = "private";
        offset = <0>;
        size = <1024>;
    };
    config@2 {
        part-name = "data";
        offset = <1024>;
        size = <64512>;
    };
};

你可以使用for_each_child_of_node()来遍历给定节点的子节点:

structdevice_node *np = pdev->dev.of_node;
structdevice_node *sub_np;
for_each_child_of_node(np, sub_np) {
    /* sub_np will point successively to each sub-node */
    [...]
    int size;
    of_property_read_u32(client->dev.of_node,
                        "size", &size);
    ...
}

在前面的示例中,我们学习了如何遍历给定设备树节点的子节点。

总结

从硬编码的设备配置切换到设备树的时机已经到来。本章为你提供了处理设备树的所有基础知识。现在你已经具备了自定义或添加任何节点和属性到设备树中的必要技能,并能从驱动中提取它们。

在下一章中,我们将讨论 I2C 驱动,并使用设备树 API 来枚举和配置我们的 I2C 设备。

第六章:第六章:设备、驱动程序与平台抽象简介

Linux 设备模型LDM)是 Linux 内核中引入的一个概念,用于描述和管理内核对象(例如需要引用计数的对象,如文件、设备、总线甚至驱动程序),以及它们的层级关系和如何与其他对象绑定。LDM 引入了对象生命周期管理、引用计数、内核中的面向对象OO)编程风格和其他优势(如代码重用与重构、自动资源释放等),这些内容在此不做讨论。

由于引用计数和生命周期管理位于 LDM 的最低层次,我们将讨论更高级的表示方式,如处理常见的内核数据对象和结构,包括设备驱动程序总线

本章将涵盖以下主题:

  • Linux 内核平台抽象和数据结构

  • 设备与驱动程序匹配机制的解释

Linux 内核平台抽象和数据结构

Linux 设备模型基于一些基本数据结构构建,包括struct devicestruct device_driverstruct bus_type。第一个数据结构表示要驱动的设备,第二个是每个驱动设备的软件实体的数据结构,后者表示设备与 CPU 之间的通道。

设备基本结构

设备有助于提取物理或虚拟设备。它们是基于struct device结构构建的,值得先介绍该结构,具体内容可见include/linux/device.h

struct device {
    struct device         *parent;
    struct kobject        kobj;
    struct bus_type       *bus;
    struct device_driver  *driver;
    void *platform_data;
    void *driver_data;
    struct dev_pm_domain  *pm_domain;
    struct device_node    *of_node; 
    struct fwnode_handle  *fwnode;
    dev_t       devt;
    u32         id;
    [...]
};

让我们看一下这个结构中的每个元素:

  • parent:这是设备的“父”设备,即该设备所附着的设备。在大多数情况下,父设备是某种总线或主控制器。如果parentNULL,则说明该设备是顶级设备。例如,总线控制器设备就是这种情况。

  • kobj:这是最低级的数据结构,用于跟踪内核对象(如总线、驱动程序、设备等)。这是 LDM 的核心部分。我们将在第十四章中讨论这一点,Linux 设备模型简介

  • bus:这指定了设备所在的总线类型。它是设备与 CPU 之间的通道。

  • driver:这指定了哪个驱动程序分配了此设备。

  • platform_data:这是设备特定的、提供平台数据的字段。当设备在板级文件中声明时,该字段会自动设置。换句话说,它指向板级设置文件中描述设备及其接线方式的特定结构。它有助于减少设备驱动代码中的#ifdef使用。它包含诸如芯片变体、GPIO 引脚角色和中断线等资源。

  • driver_data:这是一个私有指针,用于存储与驱动程序相关的信息。总线控制器驱动程序负责提供辅助函数,这些访问器用于获取/设置此字段。

  • pm_domain:此字段指定在系统电源状态变化时执行的电源管理特定回调:挂起、休眠、系统恢复以及在运行时 PM 过渡期间,包含子系统级和驱动程序级的回调。

  • of_node:这是与该设备关联的设备树节点。此字段由platform_data自动填充,或者of_node设置为确定设备声明的位置。

  • id:这是设备实例。

设备通常不是通过裸设备结构表示的,因为大多数子系统会追踪它们所托管的设备的额外信息;相反,该结构通常嵌入在设备的更高级表示中。struct i2c_clientstruct spi_devicestruct usb_devicestruct platform_device结构都嵌入了一个struct device元素(spi_device->devi2c_client->devusb_device->devplatform_device->dev)。

设备驱动程序基础结构

接下来我们需要介绍的是struct device_driver结构。此结构是任何设备驱动程序的基本元素。在面向对象的编程语言中,这个结构就像基类,每个设备驱动程序都将继承它。

此数据结构在include/linux/device/driver.h中定义,如下所示:

struct device_driver {
    const char        *name;
    struct bus_type   *bus;
     struct module    *owner;
     const struct of_device_id   *of_match_table;
     const struct acpi_device_id *acpi_match_table;
     int (*probe) (struct device *dev);
     int (*remove) (struct device *dev);
    void (*shutdown) (struct device *dev);
    int (*suspend) (struct device *dev,
                      pm_message_t state);
    int (*resume) (struct device *dev);
    const struct dev_pm_ops *pm;
};

让我们来看一下这个结构中的每个元素:

  • name:这是设备驱动程序的名称。当没有匹配方法成功时,它会作为回退(即,它会将此名称与设备名称匹配)。

  • bus:此字段是必填项。它表示此驱动程序所属设备的总线。如果此字段未设置,驱动程序注册将失败,因为正是其probe方法负责将驱动程序与设备匹配。

  • owner:此字段指定模块所有者。

  • of_match_table:这是开放固件表。它表示用于设备树匹配的struct of_device_id元素数组。

  • acpi_match_table:这是 ACPI 匹配表。它与of_match_table相同,但用于 ACPI 匹配,在本教程中不会讨论该内容。

  • probe:此函数用于查询特定设备是否存在,驱动程序是否可以与其配合工作,然后将驱动程序绑定到特定设备上。总线驱动程序负责在特定时刻调用此函数,我们稍后将讨论此内容。

  • remove:当设备从系统中移除时,会调用此方法将其与驱动程序解绑。

  • shutdown:当设备即将关闭时,会发出此命令。

  • suspend:这是一个回调函数,允许将设备置于睡眠模式,通常是低功耗状态。

  • resume:这是由驱动程序核心调用,用于唤醒处于睡眠模式的设备。

  • pm:这表示一组与该驱动程序匹配的设备相关的电源管理回调函数。

在前述数据结构中,shutdownsuspendresumepm 元素是可选的,因为它们用于电源管理目的。是否提供这些元素取决于底层设备的能力(例如,设备是否可以关闭、挂起或执行其他与电源管理相关的功能)。

驱动程序注册

首先,你应该记住,注册设备包括将设备插入到由其总线驱动程序维护的设备列表中。同样,注册设备驱动程序是将该驱动程序推入由其所在总线驱动程序维护的驱动程序列表中。例如,注册 USB 设备驱动程序会将该驱动程序插入到由 USB 控制器驱动程序维护的驱动程序列表中。注册 SPI 设备驱动程序的过程也是一样,它会将该驱动程序排入由 SPI 控制器驱动程序维护的驱动程序列表中。driver_register() 是一个低级函数,用于将设备驱动程序注册到总线中。它会将驱动程序添加到总线的驱动程序列表中。当设备驱动程序与总线注册后,内核会遍历总线的设备列表,对于每个没有关联驱动程序的设备,调用总线的 match() 回调函数,以查看该驱动程序是否可以处理这些设备。当匹配成功时,设备与设备驱动程序将被绑定在一起。将设备与设备驱动程序关联的过程称为绑定。

你可能永远不想直接使用 driver_register();由总线驱动程序提供特定总线的注册函数,通常是基于 driver_register() 的包装函数。到目前为止,特定总线的注册函数一直遵循 {bus_name}_register_driver() 模式。例如,USB、SPI、I2C 和 PCI 驱动程序的注册函数分别为 usb_register_driver()spi_register_driver()i2c_register_driver()pci_register_driver()

推荐在模块的init/exit函数中注册/反注册驱动程序,这些函数分别在模块加载/卸载阶段执行。在许多情况下,注册/反注册驱动程序是你在这些init/exit函数中唯一需要执行的操作。在这种情况下,每个总线核心提供一个特定的辅助宏,它将被扩展为模块的init/exit函数,并内部调用总线特定的注册/反注册函数。这些总线宏遵循module_{bus_name}_driver(__{bus_name}_driver);模式,其中__{bus_name}_driver是相应总线的驱动程序结构。下表展示了 Linux 支持的一些总线及其宏(此表并不详尽):

表 6.1 – 一些总线及其(反)注册宏

表 6.1 – 一些总线及其(反)注册宏

总线控制器代码负责提供这些宏,但并非总是如此。例如,MDIO 总线驱动程序(用于控制网络设备的 2 线串行总线)并未提供module_mdio_driver()宏。在编写驱动程序之前,你应该检查该宏是否存在于设备所在的总线上。以下代码块展示了两个不同总线的示例——一个使用总线提供的注册/反注册宏,另一个则没有使用。我们来看一下不使用宏时的代码:

static struct platform_driver mypdrv = {
    .probe = my_pdrv_probe,
    .remove = my_pdrv_remove,
    .driver = {
        .name = KBUILD_MODNAME,
        .owner = THIS_MODULE,
    },
};
static int __init my_drv_init(void)
{
    /* Registering with Kernel */
    platform_driver_register(&mypdrv);
    return 0;
}
static void __exit my_pdrv_remove (void)
{
    /* Unregistering from Kernel */
    platform_driver_unregister(&my_driver);
}
module_init(my_drv_init);
module_exit(my_pdrv_remove);

上述示例完全没有使用宏。现在,让我们来看一个使用宏的示例:

static struct platform_driver mypdrv = {
    .probe = my_pdrv_probe,
    .remove = my_pdrv_remove,
    .driver = {
        .name = KBUILD_MODNAME,
        .owner = THIS_MODULE,
    },
};
module_platform_driver(my_driver);

在这里,你可以看到代码是如何进行因式分解的,这在编写驱动程序时是一个很大的优势。

在驱动程序中暴露支持的设备

内核必须了解由给定驱动程序支持的设备及其是否存在于系统中,以便当它们出现在系统(总线)上时,内核知道哪个驱动程序负责它,并运行其探测函数。也就是说,驱动程序的probe()函数只有在该驱动程序已加载时才会运行(这是用户空间操作);否则,什么也不会发生。下一节将解释如何管理驱动程序的自动加载,以便当设备出现时,驱动程序会自动加载,并调用其探测函数。

如果我们查看每个总线特定的设备驱动程序结构(struct platform_driverstruct i2c_driverstruct spi_driverstruct pci_driverstruct usb_driver),我们会看到有一个id_table字段,其类型取决于总线类型。该字段应包含与驱动程序支持的设备 ID 对应的数组。下表展示了常见总线及其设备 ID 结构:

表 6.2 – 一些总线及其设备识别数据结构

表 6.2 – 一些总线及其设备识别数据结构

我故意省略了两个特殊情况:设备树和 ACPI。它们可以暴露设备,以便可以通过driver.of_match_tabledriver.acpi_match_table字段在设备树或 ACPI 中声明设备,这些字段并不是总线特定驱动结构的直接元素:

表 6.3 – 仿真总线及其设备识别数据结构

表 6.3 – 仿真总线及其设备识别数据结构

这些结构体都定义在内核源码中的include/linux/mod_devicetable.h文件中,它们的名称遵循{bus_name}_device_id的模式。我们已经在相应的章节中讨论了每个结构体。因此,让我们来看一个示例,它使用struct spi_device_idstruct of_device_id来声明该驱动的设备树(新的并且推荐的),并暴露 SPI 设备(elixir.free-electrons.com/linux/v4.10/source/drivers/gpio/gpio-mcp23s08.c):

static const struct spi_device_id mcp23s08_ids[] = {
    { "mcp23s08", MCP_TYPE_S08 },
    { "mcp23s17", MCP_TYPE_S17 },
    { "mcp23s18", MCP_TYPE_S18 },
    { },
};
static const struct of_device_id mcp23s08_spi_of_match[] = {
    {
        .compatible = "microchip,mcp23s08",
        .data = (void *) MCP_TYPE_S08,
    },
    {
        .compatible = "microchip,mcp23s17",
        .data = (void *) MCP_TYPE_S17,
    },
    {
        .compatible = "microchip,mcp23s18",
        .data = (void *) MCP_TYPE_S18,
    },
    { },
};
static struct spi_driver mcp23s08_driver = {
    .probe  = mcp23s08_probe, /* don't care about this */
    .remove = mcp23s08_remove, /* don't care about this */
    .id_table = mcp23s08_ids,
    .driver = {
        .name    = "mcp23s08",
        .of_match_table =
                of_match_ptr(mcp23s08_spi_of_match),
    },
};

上面的摘录展示了驱动如何声明它支持的设备。由于我们的示例是 SPI 驱动,涉及的结构体是struct spi_device_id,此外还有struct of_device_id,它用于任何需要根据驱动中的compatible字符串来匹配设备的驱动。

现在我们已经学习了驱动如何暴露它支持的设备,接下来让我们深入了解设备与驱动绑定的机制,理解当设备与驱动匹配时,底层发生了什么。

设备/驱动匹配和模块(自动)加载

请注意这一部分,尽管我们将部分重复之前讨论的内容。总线是设备驱动和设备依赖的基本元素。从硬件的角度来看,总线是设备与 CPU 之间的连接,而从软件的角度来看,总线驱动是设备与其驱动之间的连接。每当设备或驱动被添加/注册到系统时,它会自动被添加到一个由总线驱动维护的列表中。例如,注册一个可以由给定驱动(i2c,当然是)管理的 I2C 设备列表时,会将这些设备排入一个全局列表,这个列表由 I2C 适配器驱动维护,并提供一个 USB 设备表,该表会将这些设备插入到 USB 控制器驱动维护的设备列表中。另一个例子是注册一个新的 SPI 驱动,它会将该驱动插入到由 SPI 控制器驱动维护的驱动列表中。没有这些,内核将无法知道哪个驱动应该处理哪个设备。

每个设备驱动程序应该暴露它支持的设备列表,并且应该让驱动核心(特别是总线驱动程序)能够访问该列表。这个设备列表叫做id_table,它在驱动程序代码中声明并填充。这个表格是一个设备 ID 的数组,其中每个 ID 的类型取决于设备的类型(如 I2C、SPI、USB 等)。通过这种方式,每当设备出现在总线上时,总线驱动程序会遍历其设备驱动程序的列表,并查看每个 ID 表中的条目,以寻找与新设备对应的条目。所有包含该设备 ID 的驱动程序的probe()函数都会运行,并将新设备作为参数传递。这个过程被称为匹配循环。对于驱动程序,它的工作方式类似。每当一个新的驱动程序被注册到总线时,总线驱动程序会遍历其设备列表,并查找已注册驱动程序的id_table中出现的设备 ID。对于每次匹配,总线驱动程序都会将相应的设备作为参数传递给驱动程序的probe()函数,并根据匹配的次数运行该函数。

匹配循环的问题在于只有已加载的模块才会调用它们的探测函数。换句话说,如果相应的模块未加载(通过insmodmodprobe)或是内置的,匹配循环将变得无用。在设备出现在总线之前,您必须手动加载该模块。解决这个问题的方法是模块自动加载。由于大多数情况下,模块加载是用户空间的操作(当内核没有通过request_module()函数请求该模块时),因此内核必须找到一种方法,将驱动程序及其设备表暴露给用户空间。因此,出现了一个名为MODULE_DEVICE_TABLE()的宏:

MODULE_DEVICE_TABLE(<bus_type_name>,  <array_of_ids>)

这个宏用于支持热插拔,描述了每个特定驱动程序可以支持哪些设备。在编译时,构建过程从驱动程序中提取这些信息,并生成一个可读的表格,名为modules.alias,该表格位于/lib/modules/kernel_version/目录下。

<bus_type_name>参数应该是需要添加模块自动加载支持的总线的通用名称。对于 SPI 总线,应该是spi;对于设备树,应该是of;对于 I2C,应该是i2c,以此类推。换句话说,它应该是前一个表格中总线类型第一列的元素之一(注意,并非所有总线都列出)。让我们为之前使用的相同驱动程序(gpio-mcp23s08)添加模块自动加载支持:

MODULE_DEVICE_TABLE(spi, mcp23s08_ids);
MODULE_DEVICE_TABLE(of, mcp23s08_spi_of_match);

现在,让我们看看将这两行添加到运行基于 Yocto 的镜像的 i.MX6 板上的modules.alias文件时会发生什么:

root:/lib/modules/5.10.10+fslc+g8dc0fcb# cat modules.alias
# Aliases extracted from modules themselves.
alias fs-msdos msdos
alias fs-binfmt_misc binfmt_misc
alias fs-configfs configfs
alias iso9660 isofs
alias fs-iso9660 isofs
alias fs-udf udf
alias of:N*T*Cmicrochip,mcp23s17* gpio_mcp23s08
alias of:N*T*Cmicrochip,mcp23s18* gpio_mcp23s08
alias of:N*T*Cmicrochip,mcp23s08* gpio_mcp23s08
alias spi:mcp23s17 gpio_mcp23s08
alias spi:mcp23s18 gpio_mcp23s08
alias spi:mcp23s08 gpio_mcp23s08
alias usb:v0C72p0011d*dc*dsc*dp*ic*isc*ip*in* peak_usb
alias usb:v0C72p0012d*dc*dsc*dp*ic*isc*ip*in* peak_usb
alias usb:v0C72p000Dd*dc*dsc*dp*ic*isc*ip*in* peak_usb
alias usb:v0C72p000Cd*dc*dsc*dp*ic*isc*ip*in* peak_usb
alias pci:v00008086d000015B8sv*sd*bc*sc*i* e1000e
alias pci:v00008086d000015B7sv*sd*bc*sc*i* e1000e
[...]
alias usb:v0416pA91Ad*dc*dsc*dp*ic0Eisc01ip00in* uvcvideo
alias of:N*T*Ciio-hwmon* iio_hwmon
alias i2c:lm73 lm73
alias spi:ad7606-4 ad7606_spi
alias spi:ad7606-6 ad7606_spi
alias spi:ad7606-8 ad7606_spi

解决方案的第二部分是内核通知用户空间某些事件(称为pci:v00008086d000015B8sv*sd*bc*sc*i*)。该事件将由系统热插拔管理器捕获(通过查找具有相同别名的条目并加载相应的模块(例如,e1000))。一旦模块被加载,设备将会被探测。这就是简单的MODULE_DEVICE_TABLE()宏如何改变你的生活。

设备声明 – 填充设备

设备声明不属于 LDM 的一部分。它包括声明系统中存在(或不存在)的设备,而模块设备表则涉及将驱动程序与其支持的设备进行匹配。有三个地方可以声明/填充设备:

  • 来自板文件或独立模块(较旧且现在已弃用)

  • 来自设备树(新的推荐方法)

  • 来自高级配置和电源接口ACPI),此部分内容在此不讨论

为了由驱动程序处理,任何已声明的设备应该至少存在于一个模块设备表中;否则,设备将被忽略,除非一个包含该设备 ID 的驱动程序模块设备表被加载或已经被加载。

总线结构

最后,存在struct bus_type结构,这是内核内部表示总线的结构(无论是物理总线还是虚拟总线)。struct bus_type是设备(struct device)和驱动程序(struct device_driver)之间的连接。如果没有它,系统中将不会附加任何内容,因为总线(bus_type)负责匹配设备和驱动程序:

struct bus_type {
    const char    *name;
    struct device    *dev_root;
    int (*match)(struct device *dev,
                   struct device_driver *drv);
    int (*probe)(struct device *dev);
    int (*remove)(struct device *dev);
    /* [...] */
};

让我们看一下此结构中的元素:

  • name:这是总线的名称,它将出现在/sys/bus/中。

  • match:这是一个回调函数,当一个新设备或驱动程序被添加到总线时会被调用。该回调函数必须足够智能,当设备和驱动程序匹配时,返回一个非零值。设备和驱动程序作为参数传递。匹配回调函数的主要目的是允许总线确定是否可以通过给定的驱动程序处理特定设备,或者如果给定的驱动程序支持该设备,则进行其他逻辑处理。通常,验证过程通过简单的字符串比较(设备和驱动程序名称,或表格和设备树DT)兼容属性)来完成。对于枚举设备(如 PCI 和 USB),验证过程是通过比较驱动程序支持的设备 ID 与给定设备的设备 ID 来完成的,而不会牺牲总线特定功能。

  • probe:这是一个回调函数,当一个新设备或驱动程序被添加到总线时,并且一旦匹配发生时,都会调用此函数。此函数负责分配特定的总线设备结构,并调用给定驱动程序的探测函数,该函数应该管理设备(我们之前已分配过)。

  • remove:当设备从总线中移除时,会调用此回调函数。

当你为一个设备编写驱动程序时,如果该设备位于一个名为总线控制器的物理总线上,它必须依赖该总线的驱动程序,该驱动程序被称为控制器驱动程序,它负责在设备之间共享总线访问权限。控制器驱动程序提供了一个抽象层,位于你的设备和总线之间。每当你在 I2C 或 USB 总线上执行事务(读取或写入)时,I2C/USB 总线控制器将在后台透明地处理这些事务(管理时钟、数据移位等)。每个总线控制器驱动都会导出一组函数,以简化为位于该总线上的设备编写驱动程序的工作。这适用于每种总线(I2C、SPI、USB、PCI、SDIO 等)。

现在我们已经了解了总线驱动程序以及模块如何加载,我们将讨论匹配机制,它尝试将特定设备绑定到其驱动程序。

设备与驱动匹配机制说明

设备驱动和设备总是与总线一起注册。对于导出由驱动程序支持的设备,您可以使用driver.of_match_tabledriver.of_match_table<bus>_driver.id_table(该表特定于设备类型;例如,i2c_device.id_tableplatform_device.id_table)。

每个总线驱动都负责提供其匹配函数,每当有新的设备或设备驱动程序注册到该总线时,内核会运行该函数。也就是说,平台设备有三种匹配机制,所有这些机制都基于字符串比较。这些匹配机制分别基于 DT 表、ACPI 表、设备和驱动程序名称。让我们看看伪平台和 i2c 总线是如何使用这些机制实现匹配函数的:

static int platform_match(struct device *dev,
                           struct device_driver *drv)
{
    struct platform_device *pdev =
                         to_platform_device(dev);
    struct platform_driver *pdrv =
                         to_platform_driver(drv);
     /* Only bind to the matching driver when
     * driver_override is set
     */
    if (pdev->driver_override)
        return !strcmp(pdev->driver_override, drv->name);
    /* Attempt an OF style match first */
    if (of_driver_match_device(dev, drv))
        return 1;
    /* Then try ACPI style match */
    if (acpi_driver_match_device(dev, drv))
    return 1;
    /* Then try to match against the id table */
    if (pdrv->id_table)
        return platform_match_id(pdrv->id_table,
                                       pdev) != NULL;
    /* fall-back to driver name match */
    return (strcmp(pdev->name, drv->name) == 0);
}

上述代码展示了drivers/base/platform.c。下面的代码展示了 I2C 总线匹配函数,该函数在drivers/i2c/i2c-core.c中定义:

static const struct i2c_device_id *i2c_match_id(
            const struct i2c_device_id *id,
            const struct i2c_client *client)
{
    while (id->name[0]) {
        if (strcmp(client->name, id->name) == 0)
            return id;
        id++;
    }
    return NULL;
}
static int i2c_device_match(struct device *dev, struct
          device_driver *drv)
{
    struct i2c_client *client = i2c_verify_client(dev);
    struct i2c_driver *driver;
    if (!client)
        return 0;
    /* Attempt an OF style match */
    if (of_driver_match_device(dev, drv))
        return 1;
    /* Then ACPI style match */
    if (acpi_driver_match_device(dev, drv))
        return 1;
    driver = to_i2c_driver(drv);
    /* match on an id table if there is one */
    if (driver->id_table)
        return i2c_match_id(driver->id_table,
                               client) != NULL;
    return 0;
}

案例研究 – OF 匹配机制

在设备树中,每个设备都由一个节点表示,并声明为其总线节点的子节点。在启动时,内核(OF核心)解析设备树中的每个总线节点(以及它们的子节点,即坐落在其上的设备)。对于每个设备节点,内核将执行以下操作:

  • 确定该节点所属的总线。

  • 分配一个平台设备,并使用of_device_alloc()函数根据节点中包含的属性进行初始化。built_pdev->dev.of_node将设置为当前设备树节点。

  • 使用bus_for_each_drv()函数遍历与之前识别的总线相关的(由其维护的)设备驱动列表。

  • 对于列表中的每个驱动,核心将执行以下操作:

    1. 调用总线匹配函数,传入作为参数的已找到的驱动程序和先前构建的设备结构;即bus_found->match(cur_drv, cur_dev);

    2. 如果此总线驱动程序支持 DT 匹配机制,则总线匹配功能将调用 of_driver_match_device(),并传入前面提到的相同参数;即 of_driver_match_device(ur_drv, cur_dev)

    3. of_driver_match_device 将遍历与当前驱动程序相关联的 of_match_table 表(它是一个 of_device_id 结构体元素的数组)。对于数组中的每个 of_device_id,内核将比较当前 of_device_id 元素与 built_pdev->dev.of_node 的兼容性属性。如果它们相同(假设匹配成功),当前驱动程序的探测函数将被执行。

  • 如果没有找到支持该设备的驱动程序,该设备仍将与总线注册。然后,探测机制将推迟到稍后执行,以便每当有新驱动程序与该总线注册时,核心将遍历由总线维护的设备列表;任何没有与之关联驱动程序的设备将再次被探测。对于每个设备,将比较其关联的 of_node 的兼容性属性与新注册驱动程序的 of_match_table 数组中每个 of_device_id 的兼容性属性。

驱动程序是如何与设备匹配的,这些设备是在设备树中声明的。对于每种类型的设备声明(如板文件、ACPI 等),其匹配方式是相同的。

总结

在本章中,你学习了如何处理设备和驱动程序,以及它们之间的关联方式。我们还揭示了匹配机制。确保在继续学习 第七章,《理解平台设备和驱动程序的概念》、第八章,《编写 I2C 设备驱动程序》和 第九章,《编写 SPI 设备驱动程序》之前,理解这一部分内容,这些章节将涉及设备驱动程序开发,主要包括与设备、驱动程序和总线结构的交互。

在下一章,我们将深入探讨 平台驱动程序开发

第七章:第七章:理解平台设备和驱动程序的概念

Linux 内核通过使用总线的概念来管理设备,也就是说,CPU 与这些设备之间的连接。一些总线足够智能,内嵌了发现逻辑来枚举连接在其上的设备。在这种总线下,Linux 内核在启动初期会请求这些总线返回它们枚举的设备以及它们所需的资源(如中断线和内存区域),以便设备能够正常工作。PCI、USB 和 SATA 总线都属于这类可发现总线。

不幸的是,现实并不总是如此美好。仍有许多设备是 CPU 无法检测到的。这些不可发现的设备大多数位于芯片上,虽然有一些设备位于速度较慢或没有设备发现支持的总线上。

因此,内核必须提供接收硬件信息的机制,用户必须向内核提供这些设备的位置信息。在 Linux 内核中,这些不可发现的设备被称为 平台设备。由于它们不位于 I2C、SPI 或任何不可发现总线等已知总线上,Linux 内核实现了 平台总线(也称为 伪平台总线)的概念,以维持设备始终通过总线连接到 CPU 的范式。

本章将学习如何以及在哪里实例化平台设备及其资源,并学习如何编写它们的驱动程序,即平台驱动程序。为此,本章将分为以下几个主题:

  • 理解 Linux 内核中的平台核心抽象

  • 处理平台设备

  • 平台驱动程序抽象及架构

  • 从零开始编写平台驱动程序的示例

理解 Linux 内核中的平台核心抽象

为了涵盖随着 系统芯片SoCs)越来越流行,越来越多使用的不可发现设备,平台核心已被引入。在此框架下,最重要的三个数据结构如下:表示平台设备的一个,表示其资源的另一个,最后一个数据结构表示平台驱动程序。

平台设备在内核中表示为 struct platform_device 的一个实例,该结构在 <linux/platform_device.h> 中定义,如下所示:

struct platform_device {
     const char       *name;
     u32              id;
     struct device    dev;
     u32              num_resources;
     struct resource *resource;
     const struct platform_device_id  *id_entry;
     struct mfd_cell *mfd_cell;
};

在之前的数据结构中,name 是平台设备的名称。分配给平台设备的名称必须小心选择。平台设备在伪平台总线匹配函数中与相应的驱动程序进行匹配,即 platform_match()。在此函数中,在某些情况下(没有设备树或 ACPI 支持且没有 id 表匹配),匹配会回退到名称匹配,比较驱动程序的名称和平台设备的名称。

dev是 Linux 设备模型的底层设备结构,id用于扩展该设备名称。以下描述了id的使用方式:

  • id-1(对应PLATFORM_DEVID_NONE宏)时,底层设备名称将与平台设备名称相同。平台核心将执行以下操作:

    dev_set_name(&pdev->dev, "%s", pdev->name);
    
  • id-2(对应PLATFORM_DEVID_AUTO宏)时,内核将自动生成一个有效的 ID,并将底层设备命名如下:

    dev_set_name(&pdev->dev, "%s.%d.auto", pdev->name,
                 <auto_id>);
    
  • 在其他情况下,id将按如下方式使用:

    dev_set_name(&pdev->dev, "%s.%d", pdev->name,
                 pdev->id);
    

resource是分配给平台设备的资源数组,num_resources是该数组中元素的数量。

在平台设备和驱动通过 ID 表匹配的情况下,pdev->id_entry(其类型为struct platform_device_id)将指向匹配的 ID 表条目,从而使平台驱动与该平台设备匹配。

无论平台设备如何注册,它们都需要由合适的驱动程序驱动,即平台驱动。这些驱动程序必须实现一组回调函数,平台核心将在设备出现在平台总线时调用这些回调函数。

平台驱动在 Linux 内核中以struct platform_driver的实例形式表示,定义如下:

struct platform_driver {
     int (*probe)(struct platform_device *);
     int (*remove)(struct platform_device *);
     void (*shutdown)(struct platform_device *);
     int (*suspend)(struct platform_device *,
                         pm_message_t state);
     int (*resume)(struct platform_device *);
     struct device_driver driver;
     const struct platform_device_id *id_table;
     bool prevent_deferred_probe;
}; 

以下描述了该数据结构中使用的元素:

  • probe(): 这是在设备与驱动匹配后,设备请求使用你的驱动时调用的函数。稍后我们将看到核心如何调用probe。其声明如下:

    int my_pdrv_probe(struct platform_device *pdev)
    

内核负责提供platform_device参数。当设备驱动程序在内核中注册时,probe由总线驱动程序调用。

  • remove(): 当驱动程序不再被设备需要时,调用该函数以移除驱动,声明形式如下:

    static void my_pdrv_remove(struct platform_device *pdev)
    
  • driver是设备模型的底层驱动结构,必须提供名称(名称的选择必须谨慎)、所有者和一些其他字段(如设备树匹配表),稍后我们会看到。在平台驱动方面,在驱动和设备匹配之前,platform_device.nameplatform_driver.driver.name字段必须相同。

  • id_table是平台驱动提供给总线代码的绑定实际设备到驱动的方式之一。另一种方式是通过设备树,这将在在驱动中提供支持的设备章节中讨论。

现在我们已经介绍了平台设备和平台驱动数据结构,让我们超越这些,尝试理解它们是如何创建和注册到系统中的。

处理平台设备

在开始编写平台驱动之前,本节将教你如何以及在哪里实例化平台设备。只有在此之后,我们才会深入研究平台驱动的实现。Linux 内核中的平台设备实例化已存在很长时间,并在各个内核版本中得到了改进,我们将在本节讨论每种实例化方法的具体细节。

分配和注册平台设备

由于平台设备无法使自己为系统所知,因此它们必须手动填充并与其资源和私有数据一起注册到系统中。在平台核心早期,平台设备在板文件arch/arm/mach-*中声明(对于 i.MX6 而言,是arch/arm/mach-imx/mach-imx6q.c),并通过platform_device_add()platform_device_register()使内核知道,具体取决于每个平台设备的分配方式。

这使我们得出结论,分配和注册平台设备有两种方法。

一种静态方法,涉及在代码中列举平台设备,并对每个设备调用platform_device_register()进行注册。此 API 定义如下:

int platform_device_register(struct platform_device *pdev)

这样的使用示例如下:

static struct platform_device my_pdev = {
    .name           = "my_drv_name",
    .id             = 0,
    .ressource         = jz4740_udc_resources,
    .num_ressources    = ARRY_SIZE(jz4740_udc_resources),
};
int foo()
{
    [...]
    return platform_device_register(&my_pdev);
}

在静态注册中,平台设备被静态初始化并传递给platform_device_register()。此方法还允许批量添加平台设备。相应的代码可以使用platform_add_devices(),该函数接受指向平台设备的指针数组和数组中的元素数量。此函数定义如下:

int platform_add_devices(struct platform_device **devs,
                          int num);

另一方面,动态分配需要首先调用platform_device_alloc()来动态分配和初始化平台设备,定义如下:

struct platform_device *platform_device_alloc(
                          const char *name, int id);

在参数中,name是我们要添加的设备的基本名称,id是平台设备的实例 ID。如果成功,则此函数返回有效的平台设备对象,出错时返回NULL

以这种方式分配的平台设备使用platform_device_add() API 进行注册,定义如下:

int platform_device_add(struct platform_device *pdev);

作为唯一参数,平台设备已使用platform_device_alloc()进行分配。如果成功,则此函数返回0,如果失败则返回负错误代码。

如果使用platform_device_add()进行平台设备注册失败,则应使用platform_device_put()释放平台设备结构占用的内存,定义如下:

void platform_device_put(struct platform_device *pdev);

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

status = platform_device_add(evm_led_dev);
if (status < 0) {
     platform_device_put(evm_led_dev);
[...]
}

也就是说,无论平台设备以何种方式分配和注册,都必须使用platform_device_unregister()进行注销,定义如下:

void platform_device_unregister(struct platform_device *pdev)

请注意,platform_device_unregister()内部调用platform_device_put()

现在我们知道了如何通过传统方式实例化平台设备,接下来让我们看看采用另一种方法——设备树,来实现相同目标会有多简洁。

如何不将平台设备分配给你的代码

正如我们之前所说,平台设备曾经是通过板文件或其他驱动程序进行填充的。使用这种方法时没有灵活性。添加/删除平台设备,最好的情况是重新编译一个模块,最坏的情况是重新编译整个内核。这个已弃用的方法也不可移植。

如今,设备树已经存在了相当长的一段时间。这个机制用于声明系统中存在的非可发现设备。作为一个独立的实体,它可以独立于内核或其他模块构建。事实证明,这可能是填充平台设备的最佳替代方案。

这是通过将这些平台设备声明为设备树中的节点来实现的,声明的位置是一个兼容字符串属性为 simple-bus 的节点。这个兼容字符串意味着该节点被视为一个总线,不需要特定的处理或驱动程序。而且,它还表示没有办法动态探测这个总线,唯一找到这个总线下子设备的方法是通过设备树中的地址信息。然后,在 Linux 的实现中,最终会为每个在 compatible 属性中包含 simple-bus 的节点下的第一层子节点创建一个平台设备。

以下是一个演示:

foo {
    compatible = "simple-bus";
    bar: bar@0 {
        compatible = "labcsmart,something";
        [...]
        baz: baz@0 {
            compatible = "labcsmart,anotherthing";
            [...]
        }
    }
    foz: foz@1 {
        compatible = "company,product";
        [...]
    };
}

在上面的示例中,只有 barfoz 节点会被注册为平台设备。baz 节点不会(因为它的直接父节点没有在兼容字符串中包含 simple-bus)。因此,如果存在任何平台驱动程序,其兼容性匹配表中有 company, product 和/或 labcsmart,something,那么这些平台设备将会被探测到。

常见的用法是将 SoC 节点或片上内存映射总线下的设备声明为片上设备。另一个常见用法是声明稳压器设备,如下所示:

regulators {
    compatible = "simple-bus";
    #address-cells = <1>;
    #size-cells = <0>;

    reg_usb_h1_vbus: regulator@0 {
        compatible = "regulator-fixed";
        reg = <0>;
        regulator-name = "usb_h1_vbus";
        regulator-min-microvolt = <5000000>;
        regulator-max-microvolt = <5000000>;
        enable-active-high;
        startup-delay-us = <2>; 
        gpio = <&gpio7 12 0>;
    };
    reg_panel: regulator@1 {
        compatible = "regulator-fixed";
        reg = <1>;
        regulator-name = "lcd_panel";
        enable-active-high;
         gpio = <&gpio1 2 0>;
     };
};

在上面的示例中,将会注册两个平台设备,每个设备对应一个固定的稳压器。

与平台资源的交互

在与热插拔设备相对的端,热插拔设备会列举其所需的资源并进行广告宣传,而内核对系统中存在哪些平台设备、它们的功能或它们所需的资源以确保正常工作一无所知。没有自动协商过程,因此,任何有关给定平台设备所需资源的信息提供给内核都会受到欢迎。这些资源可能包括 IRQ 行、DMA 通道、内存区域、I/O 端口等等。

在平台代码中,资源被表示为一个 struct resource 实例,它在 include/linux/ioport.h 中定义,如下所示:

struct resource {
     resource_size_t start;
     resource_size_t end;
     const char *name;
     unsigned long flags;
};

在前述数据结构中,start/end 指向资源的起始/结束。它们指示 I/O 或内存区域的起始和终止位置。因为 IRQ 线、总线和 DMA 通道没有范围,所以通常将 start/end 赋予相同的值。

flags 是一个掩码,用于描述资源的类型,例如 IORESOURCE_BUS。可能的值如下:

  • IORESOURCE_IO 用于 PCI/ISA I/O 端口

  • IORESOURCE_MEM 用于内存区域

  • IORESOURCE_REG 用于寄存器偏移量

  • IORESOURCE_IRQ 用于 IRQ 线

  • IORESOURCE_DMA 用于 DMA 通道

  • IORESOURCE_BUS 用于总线

最后,name 元素标识或描述资源,因为可以通过名称提取资源。

将这些资源分配给平台设备可以通过两种方式进行,首先,在声明和注册平台设备的同一编译单元中进行,其次,从设备树中进行。

在本节中,我们描述了资源并展示了它们的使用方法。接下来让我们看看它们如何在下一节中提供给平台设备。

平台资源配置 - 旧的和已弃用的方法

此方法用于不支持设备树的内核或者不存在这种选择时。它主要用于多功能设备MFD),其中主(外围)芯片与子设备共享其资源。

通过这种方法,资源的提供方式与平台设备相同。以下是一个示例:

static struct resource foo_resources[] = {
     [0] = { /* The first memory region */
          .start = 0x10000000,
          .end   = 0x10001000,
          .flags = IORESOURCE_MEM,
          .name  = "mem1",
     },
     [1] = {
          .start = JZ4740_UDC_BASE_ADDR2,
          .end   = JZ4740_UDC_BASE_ADDR2 + 0x10000 -1,
          .flags = IORESOURCE_MEM,
          .name  = "mem2",
     },
     [2] = {
          .start = 90,
          .end   = 90,
          .flags = IORESOURCE_IRQ,
          .name  = "mc-irq",
     },
};

前文摘录显示了三种资源(两种内存类型和一种 IRQ 类型),它们的类型可以用 IORESOURCE_IRQIORESOURCE_MEM 进行标识。第一个是 4 KB 的内存区域,而第二个也是一个内存区域,其范围由宏定义,最后是 IRQ 90。

将这些资源分配给平台设备是一个简单的操作。如果平台设备已静态分配,则应以以下方式分配资源:

static struct platform_device foo_pdev = {
     .name = "foo-device",
     .resource             = foo_resources,
     .num_ressources = ARRY_SIZE(foo_resources),
[...]
};

对于动态分配的平台设备,这是在函数内部完成的,应该类似以下内容:

struct platform_device *foo_pdev;
[...]
my_pdev = platform_device_alloc("foo-device", ...);
if (!my_pdev)
           return -ENOMEM;
my_pdev->resource = foo_resources;
my_pdev->num_ressources = ARRY_SIZE(foo_resources);

有几个辅助函数可从资源数组中获取数据;这些包括以下内容:

struct resource *platform_get_resource(
                       struct platform_device *pdev, 
                       unsigned int type, unsigned int n);
struct resource *platform_get_resource_byname(
                      struct platform_device *pdev,
                      unsigned int type, const char *name);
int platform_get_irq(struct platform_device *pdev,
                     unsigned int n);

n 参数表示所需类型的资源中的哪一个,其中零表示第一个。因此,例如,驱动程序可以使用以下内容找到其第二个 MMIO 区域:

r = platform_get_resource(pdev, IORESOURCE_MEM, 1);

此函数的使用方法在《第五章》的处理资源部分中有解释,理解和利用设备树

理解平台数据的概念

到目前为止,我们使用的 struct resource 足以为简单的平台设备实例化资源,但许多设备比这复杂得多。这种数据结构只能编码有限类型的信息。作为扩展,platform_device.device.platform_data 用于为平台设备分配任何其他额外信息。

数据可以被包含在一个更大的结构体中,并分配到这个额外的字段中。这些数据可以是驱动程序能够理解的任何类型,但大多数情况下,它们是我们在前面章节中列举的资源类型之外的其他数据类型。例如,这些数据可能是调节器约束或甚至是指向每个设备函数的指针。

让我们在下面的代码块中描述与一组指向特定于平台设备类型的函数的指针对应的额外平台数据:

static struct foo_low_level foo_ops = {
     .owner          = THIS_MODULE,
     .hw_init         = trizeps_pcmcia_hw_init,
     .socket_state     = trizeps_pcmcia_socket_state,
     .configure_socket = trizeps_pcmcia_configure_socket,
     .socket_init     = trizeps_pcmcia_socket_init,
     .socket_suspend  = trizeps_pcmcia_socket_suspend,
[...]
};

对于静态分配的平台设备,我们将执行以下命令:

static struct platform_device my_device = {
     .name = "foo-pdev",
     .dev  = {
          .platform_data   = (void*)&trizeps_pcmcia_ops,
     },
     [...]
};

如果平台设备被发现是动态分配的,它将通过platform_device_add_data()助手进行分配,定义如下:

int platform_device_add_data(struct platform_device *pdev,
                        const void *data, size_t size);

上述函数在成功时返回0data是用作平台数据的数据,size是该数据的大小。

回到我们之前举的函数集合的例子,我们将执行以下操作:

int ret;
[…]
ret = platform_device_add_data(foo_pdev,
                  &foo_ops, sizeof(foo_ops));
if (ret == 0)
    ret = platform_device_add(trizeps_pcmcia_device);
if (ret)
     platform_device_put(trizeps_pcmcia_device);

在平台驱动程序中,平台设备将其pdev->dev.platform_dat元素指向平台数据。虽然我们可以取消引用这个字段,但建议使用内核提供的函数dev_get_platdata(),定义如下:

void *dev_get_platdata(const struct device *dev)

然后,为了获取包含的函数结构集,驱动程序可以执行以下操作:

struct foo_low_level *my_ops =
          dev_get_platdata(&pdev->dev);

驱动程序无法检查传递的数据类型。驱动程序只能假设它们已被提供了一个预期类型的结构,因为平台数据接口缺乏任何类型检查。

平台资源配置 – 新的推荐方法

第一种资源配置方法有一些缺点,包括任何更改都需要重新构建内核或已更改的模块,这可能会增加内核的大小。

随着设备树的到来,事情变得更简单了。为了保持兼容性,设备树中指定的内存区域、中断和 DMA 资源会被平台核心转换为struct resources实例,以便platform_get_resource()platform_get_resource_by_name(),甚至platform_get_irq()都可以返回适当的资源,无论该资源是通过传统方式填充的还是来自设备树的。这可以在第五章中的处理资源部分得到验证,理解和利用设备树

不用多说,设备树允许传递驱动程序可能需要了解的任何类型的信息。这些数据类型可用于传递任何设备/驱动程序特定的数据。这可以通过阅读第五章中的提取特定应用数据部分来实现,理解和利用设备树

然而,设备树代码并不知道给定驱动程序为其平台数据使用的具体结构,因此无法以这种形式提供这些信息。为了传递额外的数据,驱动程序可以使用of_device_id条目中的.data字段,该条目促使平台设备和驱动程序匹配。该字段可以指向平台数据。请参见本章中的驱动程序中支持设备的配置部分。

传统方式下期望平台数据的驱动程序应检查platform_device->dev.platform_data指针。如果那里有非空值,这意味着设备是通过传统方式实例化的,并且没有使用设备树;在这种情况下,应继续使用平台数据。如果设备是从设备树代码实例化的,platform_data指针将为NULL,表明必须直接从设备树获取信息。在这种情况下,驱动程序将在平台设备的dev.of_node字段中找到device_node指针。然后,可以使用各种设备树访问例程(最显著的是of_get_property())从设备树中提取所需的数据。

现在我们已经熟悉了平台资源配置,接下来让我们学习如何设计一个平台设备驱动程序。

平台驱动程序抽象和架构

在继续之前,让我们先提醒一下。并非所有平台设备都由平台驱动程序处理(或者,应该说,由伪平台驱动程序处理)。平台驱动程序专用于那些不依赖传统总线的设备。I2C 设备或 SPI 设备是平台设备,但分别依赖 I2C 或 SPI 总线,而不是平台总线。所有操作都需要通过平台驱动程序手动完成。

探测和释放平台设备

平台驱动程序的入口点是探测方法,该方法在与平台设备匹配后被调用。该探测方法的原型如下:

int pdrv_probe(struct platform_device *pdev)

pdev对应的是通过传统方式实例化的或由于相关设备树节点的直接父节点具有simple-bus兼容属性而由平台核心分配的新平台设备。如果有,平台数据和资源也将相应地设置。如果参数中的平台设备是驱动程序所期望的设备,则此方法必须返回0。否则,必须返回适当的负错误代码。

无论平台设备是通过传统方式实例化还是通过设备树实例化,都可以使用常规的平台核心 API(如platform_get_resource()platform_get_resource_by_name(),甚至platform_get_irq()等)提取其资源。

探测方法必须请求驱动程序所需的任何资源(如 GPIO、时钟、IIO 通道等)和数据。如果需要进行映射,探测方法是执行此操作的合适位置。

probe方法中完成的所有操作必须在设备离开系统或取消注册平台驱动程序时撤消。remove方法是实现这一点的适当位置。它必须具有以下原型:

int pdrv_remove(struct platform_device *dev)

此函数必须仅在所有操作均已撤消并清理完成时返回0。否则,必须返回适当的错误代码,以便通知用户。参数中包括已传递给probe函数的相同平台设备。

从头开始编写平台驱动程序的示例部分中,我们讨论了实现proberemove方法的所有技巧和特殊情况。

现在驱动程序回调已准备就绪,我们可以填充由此驱动程序处理的设备。

驱动程序中支持的设备供应

我们的平台驱动程序本身是无用的。要使其对设备有用,必须告知内核它可以管理哪些设备。为实现此目的,必须提供 ID 表,分配给platform_driver.id_table字段。这将允许平台设备匹配。但是,为了将此功能扩展到模块自动加载,必须将相同的表提供给MODULE_DEVICE_TABLE,以便生成模块别名。

回到代码,该表中的每个条目均为struct platform_device_id类型,定义如下:

struct platform_device_id {
     char name[PLATFORM_NAME_SIZE];
     kernel_ulong_t driver_data;
};

在上述数据结构中,name是设备的描述性名称,driver_data是驱动程序状态值。它可以设置为指向每个设备数据结构的指针。以下是一个示例,摘自drivers/mmc/host/mxs-mmc.c

static const struct platform_device_id mxs_ssp_ids[] = {
     {
          .name = "imx23-mmc",
          .driver_data = IMX23_SSP,
     }, {
          .name = "imx28-mmc",
          .driver_data = IMX28_SSP,
     }, {
          /* sentinel */
     }
};
MODULE_DEVICE_TABLE(platform, mxs_ssp_ids);

当将mxs_ssp_ids分配给platform_driver.id_table字段时,平台设备将能够根据它们的名称与任何platform_device_id.name条目匹配此驱动程序。platform_device.id_entry将指向触发匹配的此表中的条目。

要允许匹配通过其兼容字符串在设备树中声明的平台设备,平台驱动程序必须使用struct of_device_id元素的列表设置platform_driver.driver.of_match_table。然后,为了允许从设备树匹配进行模块自动加载,必须将此设备树匹配表提供给MODULE_DEVICE_TABLE。以下是一个示例:

static const struct of_device_id mxs_mmc_dt_ids[] = {
    {
        .compatible = "fsl,imx23-mmc",
        .data = (void *) IMX23_SSP,
    },{ 
        .compatible = "fsl,imx28-mmc",
        .data = (void *) IMX28_SSP,
    }, {
        /* sentinel */ 
    }
};
MODULE_DEVICE_TABLE(of, mxs_mmc_dt_ids);

如果在驱动程序中设置了of_device_id,则匹配是通过与设备节点中的兼容属性的任何of_device_id.compatible元素匹配来判断的。要获取引起匹配的of_device_id条目,驱动程序应调用of_match_device(),并传递设备树匹配表和底层设备结构platform_device.dev作为参数。

以下是一个示例:

static int mxs_mmc_probe(struct platform_device *pdev)
{
    const struct of_device_id *of_id =
        of_match_device(mxs_mmc_dt_ids, &pdev->dev);
    struct device_node *np = pdev->dev.of_node;
[...]
}

在定义这些匹配表后,它们可以按以下方式分配给平台驱动程序结构:

static struct platform_driver imx_uart_platform_driver = {
    .probe = imx_uart_probe,
    .remove = imx_uart_remove,
    .id_table = imx_uart_devtype,
    .driver = {
        .name = "imx-uart",
        .of_match_table = imx_uart_dt_ids,
    },
};

在前述最终平台驱动程序数据结构中,我们根据设备树匹配表或 ID 表,最后根据驱动程序名称,为设备提供匹配平台驱动程序的机会。

驱动程序初始化和注册

将平台驱动程序注册到内核中,就像在模块初始化函数中调用platform_driver_register()platform_driver_probe()一样简单。然后,为了删除已注册的驱动程序,模块必须调用platform_driver_unregister()来取消注册该驱动程序。

以下是这些函数的各自原型:

int platform_driver_register(struct platform_driver *drv);
void platform_driver_unregister(struct platform_driver *);
int platform_driver_probe(struct platform_driver *drv,
                  int (*probe)(struct platform_device *))

这两个探测函数之间的区别如下:

  • platform_driver_register()函数将驱动程序注册并将其放入内核维护的驱动程序列表中,这意味着每当出现与平台设备匹配的情况时,内核可以按需调用其probe()函数。为了防止驱动程序被插入并注册到该列表中,可以使用下一个函数。也就是说,任何使用platform_driver_register()注册的平台驱动程序都必须通过platform_driver_unregister()取消注册。

  • 使用platform_driver_probe()时,内核会立即运行匹配循环,检查是否有平台设备可以与此平台驱动程序匹配,并在每个匹配的设备上调用probe方法。如果此时没有找到设备,平台驱动程序将被简单地忽略。该方法防止了延迟探测,因为它不会将驱动程序注册到系统中。在这里,probe函数被放置在__init段中,该段在内核启动完成后会被释放(前提是驱动程序是静态编译的,即内置驱动程序),从而防止了延迟探测,并减少了驱动程序的内存占用。如果你确信设备在系统中存在,可以使用这种方法:

    ret = platform_driver_probe(&mypdrv, my_pdrv_probe);
    

如果设备已知不可热插拔,可以将probe()过程放置在__init段中。

注册平台驱动程序的合适时机是在模块加载后,这使我们可以说,合适的做法是在模块初始化函数中进行注册。同样,取消注册驱动程序也必须在模块的卸载路径中完成。由于这一点,我们可以说,module_exit函数是取消注册平台驱动程序的合适位置。

以下是一个典型的平台驱动程序注册到平台核心的示例:

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/platform_device.h>
static int my_pdrv_probe (struct platform_device *pdev)
{
     pr_info("Hello! device probed!\n");
     return 0;
}
static void my_pdrv_remove(struct platform_device *pdev)
{
     pr_info("good bye reader!\n");
}
static struct platform_driver mypdrv = {
     .probe     = my_pdrv_probe,
     .remove     = my_pdrv_remove,
[...]
};
static int __init foo_init(void)
{
     [...] /* My init code */
     return platform_driver_register(&mypdrv);
}
module_init(foo_init);

static void __exit foo_cleanup(void)
{
     [...] /* My clean up code */
     platform_driver_unregister(&my_driver);
}
module_exit(foo_cleanup);

我们的模块在初始化/退出函数中除了通过平台总线核心注册/注销平台驱动程序之外,不做其他操作。这是一个常见的情况,其中模块的初始化/退出方法非常简单。在这种情况下,我们可以去掉module_init()module_exit(),而使用module_platform_driver()宏,代码如下:

[...]
static int my_pdrv_probe(struct platform_device *pdev)
{
     [...]
}
static void my_pdrv_remove(struct platform_device *pdev)
{
     [...]
}
static struct platform_driver mypdrv = {
     [...]
};
module_platform_driver(mypdrv);

尽管在前面的示例中我们仅插入了快照代码,但这已经足够演示如何减少代码并使其更简洁。

为确保我们在相同的知识基础上,下面将总结本章所学的所有概念,并将其应用于一个实际的平台驱动程序。

从头开始编写平台驱动程序的示例

本节将尽可能总结到目前为止在本章中获得的知识。现在,让我们设想一个内存映射的平台设备,其可以通过的内存范围从0x02008000开始,大小为0x4000。然后,假设这个平台设备在完成任务后能够中断 CPU,且该中断线路编号为31。为了简化问题,假设该设备不需要其他资源(我们本可以设想时钟、DMA、调节器等资源)。

首先,让我们从设备树实例化这个平台设备。如果你记得的话,为了让平台核心将一个节点注册为平台设备,这个节点的直接父节点必须在其兼容性字符串列表中包含simple-bus,而这正是这里实现的内容:

demo {
    compatible = "simple-bus";
    demo_pdev: demo_pdev@0 {
        compatible = "labcsmart,demo-pdev";
        reg = <0x02008000 0x4000>;
        interrupts = <0 31 IRQ_TYPE_LEVEL_HIGH>;
    };
};

如果我们需要以传统的方式实例化这个平台设备及其资源,我们将不得不在一个我们称之为demo-pdev-init.c的文件中执行类似以下的操作:

#include <linux/module.h>
#include <linux/init.h>
#include <linux/platform_device.h>
#define DEV_BASE 0x02008000
#define PDEV_IRQ 31
static struct resource pdev_resource[] = {
    [0] = {
        .start = DEV_BASE,
        .end   = DEV_BASE + 0x4000,
        .flags = IORESOURCE_MEM,
    },
    [1] = {
        .start = PDEV_IRQ,
        .end   = PDEV_IRQ,
        .flags = IORESOURCE_IRQ,
    },
};

在前面的内容中,我们首先定义了平台资源。现在我们可以实例化平台设备并分配这些资源,如下所示,仍然是在demo-pdev-init.c中:

struct platform_device demo_pdev = {
    .name        = "demo_pdev",
    .id          = 0,
    .num_resources = ARRAY_SIZE(pdev_resource),
    .resource      = pdev_resource,
    /*.dev  = {
     *   .platform_data  = (void*)&big_struct_1,
     *}*/,
};

在前面的内容中,我们注释掉了与dev相关的赋值操作,目的是为了展示我们本可以定义一个更大的结构体,包含驱动程序期望的任何其他信息,并将这个数据结构作为平台数据传递。

现在所有数据结构都已设置完毕,我们可以通过在demo-pdev-init.c的底部添加以下内容来注册平台设备:

static int demo_pdev_init()
{
    return platform_device_register(&demo_pdev);
}
module_init(demo_pdev_init);
static void demo_pdev_exit()
{
    platform_device_unregister(&demo_pdev);
}
module_exit(demo_pdev_exit);

前面的代码除了将平台设备注册到系统中,别无他用。在这个例子中,我们使用了静态初始化。也就是说,动态初始化和静态初始化并没有太大区别。

现在我们完成了平台设备的实例化,接下来让我们关注平台驱动程序本身,其编译单元名为demo-pdriver.c,并向其中添加以下头文件:

#include <linux/module.h>
#include <linux/init.h>
#include <linux/interrupt.h>
#include <linux/of_platform.h>
#include <linux/platform_device.h>

现在,必要的头文件已到位,允许我们支持所需的 API 并使用适当的数据结构,让我们从设备树中开始列举能够与此平台驱动程序匹配的设备:

static const struct of_device_id labcsmart_dt_ids[] = {
    {
        .compatible = " labcsmart,demo-pdev",
        /* .data = (void *)&big_struct_1, */
    },{ 
        .compatible = " labcsmart,other-pdev ",
        /* .data = (void *) &big_struct_2, */
    }, {
        /* sentinel */ 
    }
};
MODULE_DEVICE_TABLE(of, labcsmart_dt_ids);

在前面的内容中,设备树匹配表列出了通过compatible属性区分的支持设备。同样,.data赋值被注释掉,目的是展示如何根据与平台设备匹配的条目传递平台特定数据。这个平台特定的结构体本可以是big_struct_1big_struct_2。当你需要传递平台数据并且仍然想使用设备树时,我推荐使用这种方法。

为了提供使用 ID 表进行匹配的机会,我们如下所示填充了这样一个表:

static const struct platform_device_id labcsmart_ids[] = {
     {
          .name = "demo-pdev",
          /*.driver_data = &big_struct_1,*/
     }, {
           .name = "other-pdev",
           /*.driver_data = &big_struct_2,*/
     }, {
           /* sentinel */
     }
};
MODULE_DEVICE_TABLE(platform, labcsmart_ids);

前述内容不需要特别的注释,除了被注释掉的部分,它展示了如何根据所处理的设备使用额外的数据。

在这个阶段,我们可以继续实现探测方法。仍然在我们的演示平台驱动编译单元中,我们添加了如下内容:

static u32 *reg_base;
static struct resource *res_irq;
static int demo_pdrv_probe(struct platform_device *pdev)
{
    struct resource *regs;
    regs = platform_get_resource(pdev, IORESOURCE_MEM, 0);
    if (!regs) {
        dev_err(&pdev->dev, "could not get IO memory\n");
        return -ENXIO;
    }
    /* map the base register */ 
    reg_base = devm_ioremap(&pdev->dev, regs->start,
                              resource_size(regs));
    if (!reg_base) {
        dev_err(&pdev->dev, "could not remap memory\n");
        return 0;
    }
    res_irq = platform_get_resource(pdev,
                                   IORESOURCE_IRQ, 0);
    /* we could have used
     * irqnum = platform_get_irq(pdev, 0); */
    devm_request_irq(&pdev->dev, res_irq->start,
                   top_half, IRQF_TRIGGER_FALLING,
                   "demo-pdev", NULL);
[...]
     return 0;
}

在前述的探测方法中,可以添加很多东西或者以不同的方式处理。第一个情况是如果我们的驱动程序仅符合设备树,并且相关的平台注册设备也从设备树实例化。我们可以这样做:

struct device_node *np = pdev->dev.of_node;
const struct of_device_id *of_id =
        of_match_device(labcsmart_dt_ids, &pdev->dev);
struct big_struct *pdata_struct = (big_struct*)of_id->data;

在前述内容中,我们获取了与平台注册设备关联的设备树节点的引用,在此基础上我们可以使用任何与设备树相关的 API(如of_get_property())从中提取数据。接下来,在通过匹配表提供受支持设备时,如果传递了平台特定的数据结构,我们使用of_match_device()来指向对应匹配项的条目,并提取平台特定数据。

如果匹配是通过 ID 表发生的,pdev->id_entry将指向发生匹配的条目,pdev->id_entry->driver_data将指向适当的更大结构。

然而,如果平台数据是以传统方式声明的,我们将使用如下内容:

struct big_struct *pdata_struct =
                 (big_struct*)dev_get_platdata(&pdev->dev);

现在我们已经完成了探测方法,我想提醒你一个特别的点:在该方法中,我们仅使用了带有devm_前缀的函数来处理资源。这些函数会在适当的时机自动释放资源。

这意味着我们不需要一个remove方法。然而,为了教学目的,以下是如果我们不使用资源管理帮助函数时,remove方法的实现方式:

int demo_pdrv_remove(struct platform_device *dev)
{
    free_irq(res_irq->start, NULL);
    iounmap(reg_base);
    return 0;
}

在前述内容中,IRQ 资源被释放,内存映射被销毁。

好了,现在一切都就绪。我们可以使用以下内容来初始化并注册平台驱动:

static struct platform_driver demo_driver = {
    .probe     = demo_pdrv_probe,
    /* .remove = demo_pdrv_remove, */
    .driver    = {
        .owner    = THIS_MODULE,
        .name    = "demo_pdev",
    },
};
module_platform_driver(demo_driver);

还有一个重要点需要考虑:平台设备、平台驱动以及设备树中的设备节点名称都相同,即demo_pdev。这提供了一种即使在设备树、ID 表和 ACPI 匹配失败时,通过平台设备和平台驱动名称进行匹配的机会,因为名称匹配会作为所有其他匹配失败后的后备方案。

总结

内核伪平台总线不再对你保留任何秘密。通过总线匹配机制,你可以理解你的驱动程序是如何、何时、为何加载的,以及它是为哪个设备编写的。我们可以根据所需的匹配机制实现任何探测功能。由于设备驱动程序的主要目的是处理设备,我们现在能够在系统中填充设备,无论是通过传统方式还是通过设备树。为了完美收尾,我们从零开始实现了一个功能性平台驱动示例。

在下一章中,我们将继续学习如何为不可发现的设备编写驱动程序,不过这次是在 I2C 总线上进行。

第八章:第八章:编写 I2C 设备驱动程序

I2C代表集成电路互联。它是由 Philips(现在是 NXP)发明的串行、支持多主机的异步总线,尽管多主机模式并不广泛使用。I2C 是一个双线总线,分别称为串行数据SDA)和串行时钟SCL,或SCK)。I2C 设备是通过 I2C 总线与其他设备进行交互的芯片。在此总线上,SDA 和 SCL 都是开漏/开集电极,这意味着每个设备可以将其输出拉低,但都不能将输出拉高,而是需要上拉电阻。SCL 由主设备生成,用于同步数据(通过 SDA 传输)在总线上的传输。主设备和从设备都可以发送数据(当然不是同时),因此 SDA 是双向线。也就是说,SCL 信号也是双向的,因为从设备可以通过保持 SCL 线低电平来拉伸时钟。总线由主设备控制,在我们的案例中,主设备是系统级芯片SoC)的一部分。该总线在嵌入式系统中广泛使用,用于连接串行 EEPROM、RTC 芯片、GPIO 扩展器、温度传感器等设备。

以下图示展示了连接到 I2C 总线的各种设备(也称为从设备):

图 8.1 – I2C 总线和设备表示

图 8.1 – I2C 总线和设备表示

从上面的示意图来看,我们可以将 Linux 内核的 I2C 框架表示如下:

CPU <--platform bus-->i2c adapter<---i2c bus---> i2c slave

CPU 是主机,负责管理 I2C 控制器,也叫做 I2C 适配器,它实现了 I2C 协议,并管理承载 I2C 设备的总线段。在内核 I2C 框架中,适配器由平台驱动管理,而从设备由 I2C 驱动管理。然而,两个驱动程序都使用 I2C 核心提供的 API。在本章中,我们将重点介绍 I2C(从设备)驱动程序,尽管如果有需要,也会提到适配器。

回到硬件,I2C 时钟速度从 10 kHz 到 100 kHz 不等,也可以从 400 kHz 到 2 MHz。I2C 没有严格的数据传输速度要求,所有连接在特定总线上的从设备将使用该总线配置的相同时钟速度。这与内核源代码中的drivers/i2c/busses/i2c-imx.c不同,I2C 的规范可以在www.nxp.com/docs/en/user-guide/UM10204.pdf找到。

现在我们知道将要处理 I2C 设备驱动程序,本章将涉及以下主题:

  • Linux 内核中的 I2C 框架抽象

  • I2C 驱动程序的抽象和架构

  • 如何不编写 I2C 设备驱动程序

Linux 内核中的 I2C 框架抽象

Linux 内核 I2C 框架由几个数据结构组成,其中最重要的是以下内容:

  • i2c_adapter:用于抽象 I2C 主设备。它用于标识一个物理 I2C 总线。

  • i2c_algorithm:它抽象了 I2C 总线事务接口。这里的事务意味着传输,例如读取或写入操作。

  • i2c_client:用于抽象表示位于 I2C 总线上的从设备。

  • i2c_driver:从设备的驱动程序。它包含一组特定的驱动功能,用于处理该设备。

  • i2c_msg:这是 I2C 事务一个段的低级表示。该数据结构定义了设备地址、事务标志(例如,它是传输还是接收)、指向要发送/接收的数据的指针,以及数据的大小。

由于本章的范围仅限于从设备驱动程序,我们将重点关注最后三个数据结构。不过,为了帮助您理解这一点,我们需要介绍适配器和算法数据结构。

struct i2c_adapter 的简要介绍

内核使用 struct i2c_adapter 来表示一个物理的 I2C 总线,并包含访问该总线所需的算法。它的定义如下:

struct i2c_adapter {
    struct module *owner;
    const struct i2c_algorithm *algo;
    [...]
};

在前面的数据结构中,我们有以下内容:

  • owner:大多数情况下,这个值被设置为 THIS_MODULE。它是所有者,并用于引用计数。

  • algo:这是一个回调函数集,由控制器(主设备)驱动程序用于驱动 I2C 线路。这些回调允许你生成访问 I2C 总线所需的信号。

算法的数据结构有如下定义:

struct i2c_algorithm {
    int (*master_xfer)(struct i2c_adapter *adap,
                       struct i2c_msg *msgs, int num);
    int (*smbus_xfer)(struct i2c_adapter *adap, u16 addr,
              unsigned short flags, char read_write,
              u8 command, int size, 
              union i2c_smbus_data *data);
    /* To determine what the adapter supports */
    u32 (*functionality)(struct i2c_adapter *adap);
[...]
};

在前面的数据结构中,已省略不重要的字段。让我们看一下摘录中的每个元素:

  • master_xfer:这是核心传输函数。对于该算法驱动程序提供的基本 I2C 访问,必须提供此函数。当 I2C 设备驱动程序需要与底层 I2C 设备通信时,会调用此函数。然而,如果它未实现(如果是 NULL),则会调用 smbus_xfer 函数。

  • smbus_xfer:这是一个函数指针,如果 I2C 控制器驱动程序的算法驱动程序可以执行 SMBus 访问,它会被 I2C 控制器驱动程序设置。每当 I2C 芯片驱动程序希望使用 SMBus 协议与芯片设备通信时,就会使用它。如果它是 NULL,则使用 master_xfer 函数,SMBus 会被模拟。

  • functionality:这是一个函数指针,由 I2C 核心调用,用于确定适配器的功能。它通知您该 I2C 适配器驱动程序可以进行何种类型的读取和写入操作。

在前面的代码中,functionality 是一个健全性回调函数。核心或设备驱动程序可以调用它(通过 i2c_check_functionality())来检查在我们启动访问之前,给定的适配器是否能够提供所需的 I2C 访问。例如,并不是所有的适配器都支持 10 位寻址模式。因此,在芯片驱动中调用 i2c_check_functionality(client->adapter, I2C_FUNC_10BIT_ADDR) 来检查适配器是否支持这一功能是安全的。所有的标志都是以 I2C_FUNC_XXX 的形式表示的。虽然每个标志可以单独检查,但 I2C 核心已经将它们拆分为逻辑功能,如下所示:

#define I2C_FUNC_I2C              0x00000001
#define I2C_FUNC_10BIT_ADDR       0x00000002
#define I2C_FUNC_SMBUS_BYTE   (I2C_FUNC_SMBUS_READ_BYTE | \
                         I2C_FUNC_SMBUS_WRITE_BYTE)
#define I2C_FUNC_SMBUS_BYTE_DATA \
                      (I2C_FUNC_SMBUS_READ_BYTE_DATA | \
                       I2C_FUNC_SMBUS_WRITE_BYTE_DATA)
#define I2C_FUNC_SMBUS_WORD_DATA \
                     (I2C_FUNC_SMBUS_READ_WORD_DATA | \
                      I2C_FUNC_SMBUS_WRITE_WORD_DATA)
#define I2C_FUNC_SMBUS_BLOCK_DATA \
                     (I2C_FUNC_SMBUS_READ_BLOCK_DATA | \
                      I2C_FUNC_SMBUS_WRITE_BLOCK_DATA)
#define I2C_FUNC_SMBUS_I2C_BLOCK \
                     (I2C_FUNC_SMBUS_READ_I2C_BLOCK | \
                      I2C_FUNC_SMBUS_WRITE_I2C_BLOCK)

在前面的代码中,您可以检查 I2C_FUNC_SMBUS_BYTE 标志,以确保适配器支持 SMBus 字节定向命令。

本介绍关于 I2C 控制器的内容将在后续章节中根据需要进行参考。尽管本章的主要目的是讨论 I2C 客户端驱动程序(我们将在下一节中讨论),但理解这些内容可能会更有意义。

I2C 客户端和驱动程序数据结构

第一个也是最明显的数据结构是 struct i2c_client 结构,它的声明方式如下:

struct i2c_client {
    unsigned short flags;
    unsigned short addr;
    char name[I2C_NAME_SIZE];
    struct i2c_adapter *adapter;
    struct device dev;
    int irq;
};

在前面的数据结构中,包含 I2C 设备的属性,flags 表示设备标志,其中最重要的是表示是否为 10 位芯片地址的标志。addr 包含芯片地址。对于 7 位地址芯片,它将存储在最低的 7 位中。name 包含设备名称,限制为 I2C_NAME_SIZE (在 include/linux/mod_devicetable.h 中设置为 20)个字符。adapter 是设备所在的适配器(记住,它是 I2C 总线)。dev 是设备模型的底层设备结构,irq 是分配给设备的中断线。

现在我们已经熟悉了 I2C 设备的数据结构,让我们关注它的驱动程序,它由 struct i2c_driver 抽象表示。它可以这样声明:

struct i2c_driver {
    unsigned int class;
    /* Standard driver model interfaces */
    int (*probe)(struct i2c_client *client,
                  const struct i2c_device_id *id);
    int (*remove)(struct i2c_client *client);
    int (*probe_new)(struct i2c_client *client);
    void (*shutdown)(struct i2c_client *client);
    struct device_driver driver;
    const struct i2c_device_id *id_table;
};

让我们看看数据结构中的每个元素:

  • probe:设备绑定的回调函数,成功时应返回 0,失败时返回相应的错误代码。

  • remove:设备解绑的回调函数。它必须撤销在 probe 中所做的操作。

  • shutdown:设备关闭的回调函数。

  • probe_new:新的驱动程序模型接口。它将废弃传统的 probe 方法,以去除其常用的未使用第二个参数(即 struct i2c_device_id 参数)。

  • driver:底层设备驱动模型的驱动程序结构。

  • id_table:该驱动程序支持的 I2C 设备列表。

该系列的第三个也是最后一个数据结构是 struct i2c_msg,它代表一次 I2C 事务的一个操作。它的声明方式如下:

struct i2c_msg { 
    __u16 addr; 
    __u16 flags; 
#define I2C_M_TEN 0x0010 
#define I2C_M_RD 0x0001 
    __u16 len; 
    __u8 * buf; 
};

这个数据结构中的每个元素都可以自我解释。让我们更详细地看看它们:

  • addr:这始终是从设备地址。

  • flags:因为一个事务可能由多个操作组成,因此该元素表示此操作的标志。如果是写操作(主设备发送给从设备),则应将其设置为 0。但是,如果是读操作(主设备从从设备读取数据),则可以将其与 I2C_M_RDI2C_M_TEN 进行或运算,I2C_M_TEN 适用于 10 位芯片地址的设备。

  • length:这是缓冲区中数据的大小。在读取操作中,它对应于要从设备读取的字节数,并存储在 buf 中。在写操作中,它表示要写入设备的 buf 中的字节数。

  • buf:这是读/写缓冲区,必须根据 length 分配。

    注意

    由于 i2c_msg.lenu16 类型,您必须确保您的读/写缓冲区的大小始终小于 216(64k)。

现在我们已经讨论了最重要的 I2C 数据结构,让我们看看 I2C 核心暴露的 API(大多数涉及 I2C 适配器的底层实现),以便充分利用我们的设备。

I2C 通信 API

一旦驱动程序和数据结构初始化完成,从属设备和主设备之间的通信就可以开始。串行总线事务仅仅是简单的寄存器访问问题,无论是读取还是写入其内容。I2C 设备遵循这一原则。

普通 I2C 通信

我们将从最低层开始——i2c_transfer() 是用于传输 I2C 消息的核心函数。其他 API 封装了此函数,背后由适配器的 algo->master_xfer 支持。以下是其原型:

int i2c_transfer(struct i2c_adapter *adap,
              struct i2c_msg *msg, int num);

使用 i2c_transfer() 时,在同一事务的相同读/写操作中,字节之间不会发送停止位。这对于那些在地址写入和数据读取之间不需要停止位的设备很有用,例如。以下代码展示了它的使用方式:

static int i2c_read_bytes(struct i2c_client *client, 
                          u8 cmd, u8 *data, u8 data_len)
{
    struct i2c_msg msgs[2];
    int ret;
    u8 *buffer;
    buffer = kzalloc(data_len, GFP_KERNEL);
    if (!buffer)
        return -ENOMEM;;
    msgs[0].addr = client->addr;
    msgs[0].flags = client->flags;
    msgs[0].len = 1;
    msgs[0].buf = &cmd;
    msgs[1].addr = client->addr;
    msgs[1].flags = client->flags | I2C_M_RD;
    msgs[1].len = data_len;
    msgs[1].buf = buffer;
    ret = i2c_transfer(client->adapter, msgs, 2);
    if (ret < 0)
        dev_err(&client->adapter->dev,
                 "i2c read failed\n");
    else
        memcpy(data, buffer, data_len);
    kfree(buffer);
    return ret;
}

如果设备在读取序列中间需要一个停止位,您应该将事务拆分为两部分(两个操作)——i2c_transfer 用于地址写入(包含单次写操作的事务),另一个 i2c_transfer 用于数据读取(包含单次读操作的事务),如下所示:

static int i2c_read_bytes(struct i2c_client *client, 
                          u8 cmd, u8 *data, u8 data_len)
{
    struct i2c_msg msgs[2];
    int ret;
    u8 *buffer;
    buffer = kzalloc(data_len, GFP_KERNEL);
    if (!buffer)
        return -ENOMEM;;
    msgs[0].addr = client->addr;
    msgs[0].flags = client->flags;
    msgs[0].len = 1;
    msgs[0].buf = &cmd;
    ret = i2c_transfer(client->adapter, msgs, 1);
    if (ret < 0) {
        dev_err(&client->adapter->dev, 
                "i2c read failed\n");
        kfree(buffer);
        return ret;
    }
    msgs[1].addr = client->addr;
    msgs[1].flags = client->flags | I2C_M_RD;
    msgs[1].len = data_len;
    msgs[1].buf = buffer;
    ret = i2c_transfer(client->adapter, &msgs[1], 1);
    if (ret < 0)
        dev_err(&client->adapter->dev, 
                "i2c read failed\n");
    else
        memcpy(data, buffer, data_len);
    kfree(buffer);
    return ret;
}

否则,您可以使用其他替代 API,如 i2c_master_sendi2c_master_recv,分别用于发送和接收数据:

int i2c_master_send(struct i2c_client *client,
             const char *buf, int count);
int i2c_master_recv(struct i2c_client *client,
             char *buf, int count);

这些 API 都是在 i2c_transfer() 的基础上实现的。i2c_master_send() 实际上实现了一个包含单次写操作的 I2C 事务,而 i2c_master_recv() 则实现了一个包含单次读操作的 I2C 事务。

第一个参数是要访问的 I2C 设备。第二个参数是读/写缓冲区,第三个参数表示要读取或写入的字节数。返回值是读取/写入的字节数。以下代码是我们之前摘录的简化版:

static int i2c_read_bytes(struct i2c_client *client, 
                          u8 cmd, u8 *data, u8 data_len)
{
    struct i2c_msg msgs[2];
    int ret;
    u8 *buffer;
    buffer = kzalloc(data_len, GFP_KERNEL);
    if (!buffer)
        return -ENOMEM;;
    ret = i2c_master_send(client, &cmd, 1);
    if (ret < 0) {
        dev_err(&client->adapter->dev, 
                "i2c read failed\n");
        kfree(buffer);
        return ret;
    }
    ret = i2c_master_recv(client, buffer, data_len);
    if (ret < 0)
        dev_err(&client->adapter->dev, 
                "i2c read failed\n");
    else
        memcpy(data, buffer, data_len);
    kfree(buffer);
    return ret;
}

至此,我们已经熟悉了内核中如何实现普通的 I2C API。然而,我们还需要处理一类设备——SMBus 兼容设备——这些设备不能与 I2C 设备混淆,尽管它们位于同一物理总线上。

系统管理总线(SMBus)兼容的函数

SMBus 是由英特尔开发的双线总线,与 I2C 非常相似。更重要的是,SMBus 是 I2C 的子集,这意味着 I2C 设备兼容 SMBus,但反之则不然。SMBus 是 I2C 的子集,这意味着 I2C 控制器支持大多数 SMBus 操作。然而,对于 SMBus 控制器来说并非如此,因为它们可能不支持 I2C 控制器所支持的所有协议选项。因此,如果你对所编写驱动的芯片有疑问,最好使用 SMBus 方法。

以下是一些 SMBus API 的示例:

s32 i2c_smbus_read_byte_data(struct i2c_client *client, 
                             u8 command);
s32 i2c_smbus_write_byte_data(struct i2c_client *client,
                               u8 command, u8 value);
s32 i2c_smbus_read_word_data(struct i2c_client *client, 
                             u8 command);
s32 i2c_smbus_write_word_data(struct i2c_client *client,
                              u8 command, u16 value);
s32 i2c_smbus_read_block_data(struct i2c_client *client,
                              u8 command, u8 *values);
s32 i2c_smbus_write_block_data(struct i2c_client *client,
                               u8 command, u8 length, 
                               const u8 *values);

完整的 SMBus API 列表可以在内核源代码的 include/linux/i2c.h 中找到。每个函数都是自解释的。以下示例展示了一个简单的读写操作,使用 SMBus 兼容的 API 访问 I2C GPIO 扩展器:

struct mcp23016 {
    struct i2c_client   *client;
    struct gpio_chip    chip;
    struct mutex        lock;
};
[...]
static int mcp23016_set(struct mcp23016 *mcp,
             unsigned offset, intval)
{
    s32 value;
    unsigned bank = offset / 8;
    u8 reg_gpio = (bank == 0) ? GP0 : GP1;
    unsigned bit = offset % 8;
    value = i2c_smbus_read_byte_data(mcp->client, 
                                     reg_gpio);
    if (value >= 0) {
        if (val)
            value |= 1 << bit;
        else
            value &= ~(1 << bit);
        return i2c_smbus_write_byte_data(mcp->client,
                                         reg_gpio, value);
    } else
        return value;
}

SMBus 部分非常简单,仅包含可用的 API 列表。现在,我们可以使用普通的 I2C 函数或 SMBus 函数访问设备,接下来我们可以开始实现 I2C 驱动程序的主体部分。

I2C 驱动程序抽象和架构

如前一节所示,struct i2c_driver 结构体包含了处理其负责的 I2C 设备所需的驱动方法。一旦设备被添加到总线上,它就需要被探测,这使得 i2c_driver.probe_new 方法成为驱动程序的入口点。

探测 I2C 设备

struct i2c_driver 结构中的 probe() 回调函数每次当一个 I2C 设备在总线上实例化并声明使用该驱动程序时都会被调用。它负责以下任务:

  • 使用 i2c_check_functionality() 函数检查 I2C 总线控制器(I2C 适配器)是否支持设备所需的功能。

  • 检查设备是否是我们预期的设备

  • 初始化设备

  • 如果需要,设置特定设备的数据

  • 注册到适当的内核框架中

以前,探测回调函数是分配给 struct i2c_driverprobe 元素,并且具有以下原型:

int foo_probe(struct i2c_client *client,
            const struct i2c_device_id *id)

由于第二个参数很少使用,这个回调函数已经被弃用,取而代之的是 probe_new,其原型如下:

int probe(struct i2c_client *client)

在前面的原型中,struct i2c_client 指针代表了 I2C 设备本身。这个参数由内核根据设备的描述预构建并初始化,这些描述可以在设备树或板文件中完成。

不建议在 probe 方法中过早地访问设备。由于每个 I2C 适配器具有不同的能力,因此最好先请求设备以了解其支持的功能,并根据这些信息调整驱动程序的行为:

#define CHIP_ID 0x13
#define DA311_REG_CHIP_ID  0x000f
static int fake_i2c_probe(struct i2c_client *client)
{
    int err;
    int ret;
    if (!i2c_check_functionality(client->adapter,
            I2C_FUNC_SMBUS_BYTE_DATA))
        return -EIO;
    /* read family id */
    ret = i2c_smbus_read_byte_data(client, REG_CHIP_ID);
    if (ret != CHIP_ID)
        return (ret < 0) ? ret : -ENODEV;
    /* register with other frameworks */
    [...]
    return 0;
}

在前面的示例中,我们检查了底层适配器是否支持设备所需的类型/命令。只有在通过了成功的健全性检查后,我们才能安全地访问设备,并在必要时进一步分配资源并与其他框架注册。

实现i2c_driver.remove方法

i2c_driver.remove回调必须撤销在probe函数中所做的工作。它必须从每个在probe中注册的框架中注销,并释放请求的每个资源。该回调具有以下原型:

static int remove(struct i2c_device *client)

在前面的代码行中,client是核心传递给probe方法的相同 I2C 设备数据结构。这意味着你在探测时存储的任何数据都可以在这里检索。例如,你可能需要根据在probe函数中设置的私有数据来处理一些清理工作或其他操作:

static int mc9s08dz60_remove(struct i2c_client *client)
{
    struct mc9s08dz60 *mc9s;
    /* We retrieve our private data */
    mc9s = i2c_get_clientdata(client);
   /* Which hold gpiochip we want to work on */
    return gpiochip_remove(&mc9s->chip);
}

前面的示例简单且可能代表你在驱动程序中看到的大多数情况。由于此回调应在成功时返回零,失败的原因可能包括设备无法关机、设备仍在使用中等。这意味着可能会有一些情况,在这些情况下你需要查询设备并在此回调函数中执行一些额外的操作。

在开发过程的这个阶段,所有回调都已准备好。现在,是时候让驱动程序向 I2C 核心注册了,我们将在下一节中看到这一点。

驱动程序初始化和注册

I2C 驱动程序通过i2c_add_driver()i2c_del_driver()API 与核心进行注册和注销。前者是一个宏,背后由i2c_register_driver()函数实现。以下代码展示了它们各自的原型:

int i2c_add_driver(struct i2c_driver *drv);
void i2c_del_driver(struct i2c_driver *drv);

在这两个函数中,drv是先前设置的 I2C 驱动程序结构。注册 API 在成功时返回零,失败时返回负错误代码。

驱动程序的注册通常发生在模块初始化中,而注销此驱动程序通常在模块退出方法中完成。以下是 I2C 驱动程序注册的典型示例:

static int __init foo_init(void)
{
    [...] /*My init code */
      return i2c_add_driver(&foo_driver);
}
module_init(foo_init);
static void __exit foo_cleanup(void)
{
    [...] /* My clean up code */
      i2c_del_driver(&foo_driver);
}
module_exit(foo_cleanup);

如果驱动程序在模块初始化/清理过程中只需要注册/注销驱动程序,那么可以使用module_i2c_driver()宏来简化前面的代码,如下所示:

module_i2c_driver(foo_driver);

此宏将在构建时扩展为模块中的适当初始化/退出方法,这些方法将处理 I2C 驱动程序的注册/注销。

在驱动程序中配置设备

为了让匹配循环在我们的 I2C 驱动程序中被调用,i2c_driver.id_table字段必须设置为 I2C 设备 ID 的列表,每个 ID 由struct i2c_device_id数据结构的一个实例描述,其定义如下:

struct i2c_device_id {
   char name[I2C_NAME_SIZE];
   kernel_ulong_t driver_data;
};

在上述数据结构中,name是设备的描述性名称,而driver_data是驱动程序状态数据,它是驱动程序私有的。它可以设置为指向每个设备的数据结构,例如。此外,为了设备匹配和模块(自动)加载的目的,这个设备 ID 数组还需要传递给MODULE_DEVICE_TABLE宏。

然而,这与设备树匹配无关。为了使设备树中的设备节点与我们的驱动程序匹配,我们的驱动程序的i2c_driver.device.of_match_table元素必须设置为一个包含struct of_device_id类型元素的列表。该列表中的每个条目将描述一个可以从设备树中匹配的 I2C 设备。以下是该数据结构的定义:

struct of_device_id {
[...]
    char  compatible[128];
    const void *data;
};

在上述数据结构中,compatible是一个相当描述性的字符串,可以用于设备树中匹配该驱动程序,而data则可以指向任何内容,例如每个设备的资源。同样,为了设备树匹配后的模块(自动)加载,这个列表必须传递给MODULE_DEVICE_TABLE宏。

以下是一个示例:

#define ID_FOR_FOO_DEVICE  0
#define ID_FOR_BAR_DEVICE  1 
static struct i2c_device_id foo_idtable[] = {
   { "foo", ID_FOR_FOO_DEVICE },
   { "bar", ID_FOR_BAR_DEVICE },
   { },
};
MODULE_DEVICE_TABLE(i2c, foo_idtable);

现在,对于设备树匹配后的模块加载,我们需要做以下工作:

static const struct of_device_id foobar_of_match[] = {
        { .compatible = "packtpub,foobar-device" },
        { .compatible = "packtpub,barfoo-device" },
        {},
};
MODULE_DEVICE_TABLE(of, foobar_of_match);

该摘录展示了i2c_driver的最终内容,设置了相应的设备表指针:

static struct i2c_driver foo_driver = {
    .driver         = {
        .name   = "foo",
        /* The below line adds Device Tree support */
        .of_match_table = of_match_ptr(foobar_of_match),
    },
    .probe          = fake_i2c_probe,
    .remove         = fake_i2c_remove,
    .id_table       = foo_idtable,
};

在上述代码中,我们可以看到一旦设置完毕,I2C 驱动程序结构将是什么样子。

实例化 I2C 设备

我们将使用设备树声明,因为尽管板文件目前还在旧驱动程序中使用,但它已经是一个远离我们的时代。I2C 设备必须作为它们所在总线节点的子节点(子节点)进行声明。以下是它们绑定所需的属性:

  • reg:表示设备在总线上的地址。

  • compatible:这是一个用于将设备与驱动程序匹配的字符串。它必须与驱动程序的of_match_table中的一个条目匹配。

以下是同一适配器上声明的两个 I2C 设备的示例:

&i2c2 { /* Phandle of the bus node */
    pcf8523: rtc@68 {
        compatible = "nxp,pcf8523";
        reg = <0x68>;
    };
    eeprom: ee24lc512@55 { /* eeprom device */
        compatible = "labcsmart,ee24lc512";
        reg = <0x55>;
    };
};

上述示例声明了一个地址为0x68的 RTC 芯片和一个地址为0x55的 EEPROM,它们都位于同一个总线上,即 SoC 的 I2C 总线 2。I2C 核心将依赖于compatible字符串属性和i2c_device_id表来绑定设备和驱动程序。第一次尝试是通过兼容字符串(即OF样式,即设备树)来匹配设备;如果失败,I2C 核心将尝试通过id表来匹配设备。

如何不编写 I2C 设备驱动程序

决定不编写设备驱动程序的做法是编写适当的用户代码来处理底层硬件。尽管这是用户代码,但内核始终会介入以简化开发过程。I2C 适配器在用户空间由内核以字符设备的形式暴露,路径为/dev/i2c-<X>,其中<X>是总线号。一旦你打开了与设备所在适配器相对应的字符设备文件,你就可以执行一系列命令。

首先,用于从用户空间处理 I2C 设备的所需头文件如下:

#include <linux/i2c-dev.h>
#include <i2c/smbus.h>
#include <linux/i2c.h>

以下是可能的命令:

  • ioctl(file, I2C_FUNCS, unsigned long *funcs):此命令可能是您应该发出的第一个命令。它相当于内核中的 i2c_check_functionality(),用于返回所需的适配器功能(在 *funcs 参数中)。返回的标志也以 I2C_FUNC_* 形式表示:

    unsigned long funcs;
    if (ioctl(file, I2C_FUNCS, &funcs) < 0)
            return -errno;
    if (!(funcs & I2C_FUNC_SMBUS_QUICK)) {
        /* Oops, SMBus write_quick) not available! */
        exit(1);
    }
    /* Now it is safe to use SMBus write_quick command */
    
  • ioctl(file, I2C_TENBIT, long select):在这里,您可以选择与之通信的从设备是否是 10 位地址芯片(select = 1)或不是(select = 0)。

  • ioctl(file, I2C_SLAVE, long addr):此命令用于设置您需要在此适配器上与之通信的芯片地址。地址存储在 addr 的低 7 位中(对于 10 位地址,地址会传递在低 10 位中)。该芯片可能已在使用中,此时您可以使用 I2C_SLAVE_FORCE 强制使用。

  • ioctl(file, I2C_RDWR, struct i2c_rdwr_ioctl_data *msgset):您可以使用此命令执行不间断的 I2C 读写操作。感兴趣的结构体是 struct i2c_rdwr_ioctl_data,其定义如下:

    struct i2c_rdwr_ioctl_data {
      struct i2c_msg *msgs; /* ptr to array of messages */
      int nmsgs; /* number of messages to exchange */
    }
    

以下是使用此 IOCTL 的示例:

    int ret;
    uint8_t buf [5] = {regaddr, '0x55', '0x65', 
                       '0x88', '0x14'};
    struct i2c_msg messages[] = {
        {
            .addr = dev,
            .buf = buf,
            .len = 5, /* buf size is 5 */
        },
    };
    struct i2c_rdwr_ioctl_data payload = {
        .msgs = messages,
        .nmsgs = sizeof(messages) 
                 /sizeof(messages[0]),
    };
    ret = ioctl(file, I2C_RDWR, &payload);

您还可以使用 read()write() 调用进行简单的 I2C 事务(在使用 I2C_SLAVE 设置地址后)。

  • ioctl(file, I2C_SMBUS, struct i2c_smbus_ioctl_data *args):此命令用于发起 SMBus 传输。主要的结构体参数具有如下原型:

    struct i2c_smbus_ioctl_data {
        __u8 read_write;
        __u8 command;
        __u32 size;
        union i2c_smbus_data __user *data;
    };
    

在上述数据结构中,read_write 决定传输方向——I2C_SMBUS_READ 为读取,I2C_SMBUS_WRITE 为写入。command 是芯片可以解释的命令,例如可能是寄存器地址。size 是消息的长度,而 buf 是消息缓冲区。请注意,I2C 核心已经暴露了标准化的大小。这些大小分别是 I2C_SMBUS_BYTEI2C_SMBUS_BYTE_DATAI2C_SMBUS_WORD_DATAI2C_SMBUS_BLOCK_DATAI2C_SMBUS_I2C_BLOCK_DATA,用于 1、2、3、5 和 8 字节。完整列表请参见 include/uapi/linux/i2c.h。以下是一个示例,展示了如何在用户空间进行 SMBus 传输:

    uint8_t buf [5] = {'0x55', '0x65', '0x88'};
    struct i2c_smbus_ioctl_data payload = {
        .read_write = I2C_SMBUS_WRITE,
        .size = I2C_SMBUS_WORD_DATA,
        .command = regaddr,
        .data = (void *) buf,
    };
    ret = ioctl (fd, I2C_SLAVE_FORCE, dev);
    if (ret < 0)
        /* handle errors */
    ret = ioctl (fd, I2C_SMBUS, &payload);
    if (ret < 0)
        /* handle errors */

由于您可以使用简单的 read()/write() 系统调用来执行基本的 I2C 传输(尽管每次传输后都会发送停止位),I2C 核心提供了以下 API 来执行 SMBus 传输:

__s32 i2c_smbus_write_quick(int file, __u8 value);
__s32 i2c_smbus_read_byte(int file);
__s32 i2c_smbus_write_byte(int file, __u8 value);
__s32 i2c_smbus_read_byte_data(int file, __u8 command);
__s32 i2c_smbus_write_byte_data(int file, __u8 command,
                                 __u8 value);
__s32 i2c_smbus_read_word_data(int file, __u8 command);
__s32 i2c_smbus_write_word_data(int file, __u8 command,
                                 __u16 value);
__s32 i2c_smbus_read_block_data(int file, __u8 command,
                                 __u8 *values);
__s32 i2c_smbus_write_block_data(int file, __u8 command,
                              __u8 length, __u8 *values);

建议您使用这些函数,而不是使用 IOCTL。如果发生错误,所有这些事务将返回 -1;您可以检查 errno 以更好地理解出了什么问题。在成功的情况下,*_write_* 事务将返回 0,而 *_read_* 事务将返回读取的值,除了 *_read_block_*,它将返回已读取的值的数量。在面向块的操作中,缓冲区不需要超过 32 字节。

除了需要编写代码的 API,你还可以使用 CLI 包 i2ctools,它附带了以下工具:

  • i2cdetect:一个命令,用于列举给定适配器上的 I2C 设备。

  • i2cget:用于转储设备寄存器的内容。

  • i2cset:用于设置设备寄存器的内容。

在这一节中,我们学习了如何使用用户空间的 API 和命令行工具与 I2C 设备进行通信。尽管这些方法对于原型开发非常有用,但处理支持中断或其他基于内核的资源(如时钟)的设备可能会变得比较困难。

总结

在这一章中,我们讨论了 I2C 设备驱动程序。现在,是时候选择市场上的任何 I2C 设备,并编写相应的驱动程序以及必要的设备树支持了。本章讲解了内核 I2C 核心及其相关 API,包括设备树支持,帮助你掌握与 I2C 设备通信所需的技能。你现在应该能够编写高效的探测函数并将其注册到内核 I2C 核心中。

在下一章中,我们将使用本章所学的技能来开发一个 SPI 设备驱动程序。

第九章:第九章:编写 SPI 设备驱动程序

串行外围接口 (SPI) 至少是一个 4 线总线 – 主机输入从机输出 (MISO), 主机输出从机输入 (MOSI), 串行时钟 (SCK), 和 芯片选择 (CS) – 用于连接串行闪存和模数/数模转换器。主机始终生成时钟。其速度可达到 80 MHz,但实际上没有速度限制(这比 I2C 快得多)。同样适用于 CS 线,始终由主机管理。

每个这些信号名称都有一个同义词:

  • 每当你看到 从机输入主机输出 (SIMO), 从机数据输入 (SDI), 或 数据输入 (DI), 它们指的是 MOSI。

  • 从机输出主机输入 (SOMI), 从机数据输出 (SDO), 和 数据输出 (DO) 指的是 MISO。

  • 串行时钟 (SCK), 时钟 (CLK), 和 串行时钟 (SCL) 是指 SCK。

  • S̅ S̅ 是从机选择线,也称为 CS。可以使用 CSx(其中 x 是索引,如 CS0、CS1)、EN 和 ENB,意思是使能。CS 通常是一个低有效信号。

下图显示了 SPI 设备通过其暴露的总线连接到控制器的方式:

图 9.1 – SPI 从机设备和主机互连

图 9.1 – SPI 从机设备和主机互连

从上图可以看出,我们可以将 Linux 内核中的 SPI 框架表示如下:

CPU <--platform bus--> SPI master <---SPI bus---> SPI slave

CPU 是托管 SPI 控制器的主机,也称为 SPI 主控,负责管理托管 SPI 从机设备的总线段。在内核 SPI 框架中,总线由平台驱动程序管理,而从机则由 SPI 设备驱动程序驱动。但是,这两种驱动程序都使用 SPI 核心提供的 API。在本章中,我们将重点关注 SPI(从机)设备驱动程序,但如有必要,也会提及控制器。

本章将介绍诸如以下的 SPI 驱动程序概念:

  • 理解 Linux 内核中的 SPI 框架抽象

  • 处理 SPI 驱动程序抽象和架构

  • 学习如何不编写 SPI 设备驱动程序

理解 Linux 内核中的 SPI 框架抽象

Linux 内核 SPI 框架由几个数据结构组成,其中最重要的是以下内容:

  • spi_controller,用于抽象 SPI 主设备。

  • spi_device,用于抽象连接到 SPI 总线上的从机设备。

  • spi_driver,从机设备的驱动程序。

  • spi_transfer,这是协议的低级表示中的一个片段。它表示主机与从机之间的单个操作。它期望 Tx 和/或 Rx 缓冲区以及要交换的数据长度和可选的 CS 行为。

  • spi_message,这是一个原子传输序列。

现在让我们逐个介绍这些数据结构,从最复杂的开始,即代表 SPI 控制器数据结构。

简要介绍 struct spi_controller

在本章中,我们将引用控制器,因为它与 SPI 框架中由从设备和其他数据结构深度耦合。因此,有必要介绍其数据结构,表示为 struct spi_controller,并定义如下:

struct spi_controller {
    struct device     dev;
    u16               num_chipselect;
    u32               min_speed_hz;
    u32               max_speed_hz;
    int               (*setup)(struct spi_device *spi);
    int (*set_cs_timing)(struct spi_device *spi,
                          struct spi_delay *setup,
                          struct spi_delay *hold,
                          struct spi_delay *inactive);
    int    (*transfer)(struct spi_device *spi,
                         struct spi_message *mesg);
    bool    (*can_dma)(struct spi_controller *ctlr,
                        struct spi_device *spi,
                        struct spi_transfer *xfer);
    struct kthread_worker  *kworker;
    struct kthread_work    pump_messages;
    spinlock_t             queue_lock;
    struct list_head       queue;
    struct spi_message     *cur_msg;
    bool                   busy;
    bool                   running;
    bool                   rt;
    int (*transfer_one_message)(
                      struct spi_controller *ctlr,
                      struct spi_message *mesg);
[...]
    int (*transfer_one_message)(
            struct spi_controller *ctlr,
            struct spi_message *mesg);
    void (*set_cs)(struct spi_device *spi, bool enable);
    int (*transfer_one)(struct spi_controller *ctlr,
                    struct spi_device *spi,
                    struct spi_transfer *transfer);
[...]
    /* DMA channels for use with core dmaengine helpers */
    struct dma_chan    *dma_tx;
    struct dma_chan    *dma_rx;
    /* dummy data for full duplex devices */
    Void              *dummy_rx;
    Void              *dummy_tx;
};

仅列出了本章用于更好理解数据结构的关键元素。以下列表解释了它们的用途:

  • num_chipselect 表示分配给此控制器的 CS 数量。CS 用于区分单独的 SPI 从设备,并从 0 开始编号。

  • min_speed_hzmax_speed_hz 分别是此控制器支持的最低和最高传输速度。

  • set_cs_timing 是一个方法,当 SPI 控制器支持 CS 时序配置时提供,在这种情况下,客户端驱动程序将调用 spi_set_cs_timing() 来设置请求的时序。该方法已在最近的内核版本中被该补丁弃用:lore.kernel.org/lkml/20210609071918.2852069-1-gregkh@linuxfoundation.org/)。

  • transfer 将消息添加到控制器的传输队列中。在控制器注册路径中(感谢 spi_register_controller()),SPI 核心会检查该字段是否为 NULL

    • 如果为 NULL,SPI 核心将检查 transfer_onetransfer_one_message 是否已设置,在这种情况下,假设该控制器支持消息排队,并调用 spi_controller_initialize_queue(),该函数将把此字段设置为 spi_queued_transfer(这是 SPI 核心帮助程序,用于将 SPI 消息排入控制器的队列,并在 kworker 没有运行或繁忙时调度消息泵)。

      • 此外,spi_controller_initialize_queue() 将为该控制器创建一个专用的 kthread 工作线程(kworker 元素)和一个工作结构体(pump_messages 元素)。这个工作线程将被频繁调度,以按照 FIFO 顺序处理消息队列。

      • 接下来,SPI 核心将控制器的 queued 元素设置为 true。

      • 最后,如果驱动程序在调用注册 API 之前已将控制器的 rt 元素设置为 true,则 SPI 核心将把工作线程的调度策略设置为实时 FIFO 策略,优先级为 50。

    • 如果为 NULL,并且 transfer_onetransfer_one_message 也都是 NULL,则发生错误,且控制器未注册。

    • 如果不是 NULL,SPI 核心假定控制器不支持队列,也不会调用 spi_controller_initialize_queue()

  • transfer_onetransfer_one_message是互斥的。如果两者都设置,SPI 核心将不会调用前者。transfer_one传输单个 SPI 传输,并没有spi_message的概念。如果驱动程序提供了transfer_one_message,它必须基于spi_message工作,并将负责处理消息中的所有传输。那些不需要处理消息算法的控制器驱动程序只需要设置transfer_one回调,在这种情况下,SPI 核心将会设置transfer_one_messagespi_transfer_one_messagespi_transfer_one_message将在调用驱动程序提供的transfer_one回调之前,处理所有的消息逻辑、时序、CS 以及其他硬件相关的属性。除非传输中有spi_transfer.cs_change = 1的传输修改了它,否则 CS 将在整个消息传输过程中保持活动状态。消息传输将使用之前通过setup()为此设备应用的时钟和 SPI 模式参数来执行。

  • kworker:这是专门用于消息泵处理的内核线程。

  • pump_messages:这是一个工作结构数据结构的抽象,用于调度处理 SPI 消息队列的函数。它被调度在kworker中。该工作结构由spi_pump_messages()方法支持,后者会检查队列中是否有需要处理的 SPI 消息,如果有,则调用驱动程序来初始化硬件并传输每个消息。

  • queue_lock:用于同步访问消息队列的自旋锁。

  • queue:此控制器的消息队列。

  • idling:这表示控制器设备是否进入空闲状态。

  • cur_msg:当前正在传输的 SPI 消息。

  • busy:这表示消息泵的忙碌状态。

  • running:这表示消息泵正在运行。

  • Rt:这表示kworker是否会以实时优先级运行消息泵。

  • dma_tx:DMA 传输通道(当控制器支持时)。

  • dma_rx:DMA 接收通道(当控制器支持时)。

SPI 传输始终读取和写入相同数量的字节,这意味着即使客户端驱动程序发起的是半双工传输,SPI 核心也会通过使用dummy_rxdummy_tx来模拟全双工,从而实现这一目的:

  • dummy_rx:这是一个虚拟接收缓冲区,用于全双工设备。当传输的接收缓冲区为NULL时,接收到的数据将首先被转移到此虚拟接收缓冲区,然后再被丢弃。

  • dummy_tx:这是一个虚拟传输缓冲区,用于全双工设备。当传输的发送缓冲区为NULL时,这个虚拟发送缓冲区将被填充为零,并作为传输的发送缓冲区使用。

请注意,SPI 核心将 SPI 消息泵工作任务命名为控制器设备名称(dev->name),该名称在spi_register_controller()中设置,如下所示:

dev_set_name(&ctlr->dev, "spi%u", ctlr->bus_num);

后来,当工作线程在队列初始化期间创建时(记住,spi_controller_initialize_queue()),它将被赋予以下名称:

ctlr->kworker = kthread_create_worker(0, dev_name(&ctlr->dev));

要识别系统中的 SPI 消息泵工作线程,你可以运行以下命令:

root@yocto-imx6:~# ps | grep spi
65 root         0 SW   [spi1]

在前面的代码片段中,我们可以看到工作线程的名称由总线名称和总线编号组成。

在本节中,我们分析了控制器端的概念,以帮助理解 Linux 内核中整个 SPI 从设备的实现。这个数据结构的重要性非常大,我建议每当你在后续章节中遇到不理解的机制时,都可以回过头来阅读本节内容。现在我们可以真正转向 SPI 设备的数据结构了。

struct spi_device 结构体

第一个也是最明显的数据结构,struct spi_device 表示一个 SPI 设备,并在 include/linux/spi/spi.h 中定义:

struct spi_device {
    struct device dev;
    struct spi_controller  *controller;
    struct spi_master *master;
    u32         max_speed_hz;
    u8          chip_select;
    u8          bits_per_word;
    bool        rt;
    u16         mode;
    int          irq;
    [...]
    int cs_gpio; /* LEGACY: chip select gpio */
    struct gpio_desc *cs_gpiod; /* chip select gpio desc */
    struct spi_delay word_delay; /* inter-word delay */
    /* the statistics */
    struct spi_statistics statistics;
};

为了可读性,列出的字段数减少到本书目的所需的最少数量。以下列表详细说明了此结构中每个元素的含义:

  • controller 表示该从设备所属的 SPI 控制器。换句话说,它表示设备连接的 SPI 控制器(总线)。

  • master 元素仍然存在,是为了兼容性原因,并且很快会被弃用。它曾是控制器的旧名称。

  • max_speed_hz 是与此从设备一起使用的最大时钟速率;此参数可以通过驱动程序内部进行更改。我们可以使用 spi_transfer.speed_hz 来覆盖该参数,应用于每个传输。稍后我们将讨论 SPI 传输。

  • chip_select 是分配给该设备的 CS 线。默认情况下,它是低电平有效的。可以通过在 mode 中添加 SPI_CS_HIGH 标志来更改此行为。

  • rt,如果为 true,将使 controller 的消息泵工作线程作为实时任务运行。

  • mode 定义了数据如何时钟化。设备驱动程序可以更改此设置。数据时钟化默认是每个字传输时的 MSB。此行为可以通过指定 SPI_LSB_FIRST 来覆盖。

  • irq 表示中断号(在你的板初始化文件中注册为设备资源,或者通过设备树进行注册),你应该将其传递给 request_irq() 来接收该设备的中断。

  • cs_gpiocs_gpiod 都是可选的。前者是基于整数的传统 GPIO 号,表示 CS 线,而后者是新的、推荐的接口,基于 GPIO 描述符。

关于 SPI 模式的一点说明——它们是通过两个特性构建的:

  • CPOL,即初始时钟极性:

    • 0:初始时钟状态为低电平,第一个边沿为上升。

    • 1:初始时钟状态为高电平,第一个状态为下降。

  • CPHA 是时钟相位,决定数据在何种边沿被采样:

    • 0:数据在下降沿(高到低过渡)时被锁存,而输出在上升沿变化。

    • 1:数据在上升沿(低到高的转换)时锁存,输出在下降沿时变化。

这使我们能够区分四种 SPI 模式,这些模式是由两个主要宏的混合衍生而来,这些宏在 include/linux/spi/spi.h 中定义如下:

#define    SPI_CPHA    0x01
#define    SPI_CPOL    0x02

这些宏的组合给出了以下 SPI 模式:

表 9.1 – SPI 模式内核定义

表 9.1 – SPI 模式内核定义

以下图表表示了每个 SPI 模式,顺序与前面的数组定义一致。也就是说,仅表示了 MOSI 线,但 MISO 线的原理是相同的。

图 9.2 – SPI 操作模式

图 9.2 – SPI 操作模式

现在我们已经熟悉了 SPI 设备数据结构及其操作模式,我们可以切换到第二重要的数据结构,即表示 SPI 设备驱动程序的结构。

spi_driver 结构体

也叫做协议驱动程序,SPI 设备驱动程序负责驱动连接在 SPI 总线上的设备。它通过 struct spi_driver 在内核中进行抽象,声明如下:

struct spi_driver {
   const struct spi_device_id *id_table;
   int         (*probe)(struct spi_device *spi);
   int         (*remove)(struct spi_device *spi);
   void        (*shutdown)(struct spi_device *spi);
   struct device_driver    driver;
};

以下列表概述了此数据结构中元素的含义:

  • id_table:这是此驱动程序支持的 SPI 设备列表。

  • probe:此方法将此驱动程序绑定到 SPI 设备。此函数会在任何声明为该驱动程序的设备上调用,并决定该驱动程序是否负责该设备。如果是,则发生绑定过程。

  • remove:将此驱动程序从 SPI 设备中解绑。

  • shutdown:此方法在系统状态变更时调用,例如关闭电源和停止操作。

  • driver:这是设备和驱动模型的低级驱动程序结构。

目前我们只能说这些数据结构的情况,除了每个 SPI 设备驱动程序必须填充并暴露该类型的一个实例之外。

消息传输数据结构

SPI I/O 模型由一组排队的消息组成,每条消息可以包含一个或多个 SPI 传输。单个消息由一个或多个 struct spi_transfer 对象组成,每个传输代表一个全双工的 SPI 事务。消息可以同步或异步提交和处理。以下是解释消息和传输概念的示意图:

图 9.3 – 示例 SPI 消息结构

图 9.3 – 示例 SPI 消息结构

现在我们已经了解了理论方面的内容,可以介绍 SPI 传输数据结构,其声明如下:

struct spi_transfer {
    const void    *tx_buf;
    void          *rx_buf;
    unsigned    len;
    dma_addr_t    tx_dma;
    dma_addr_t    rx_dma;
    struct sg_table tx_sg;
    struct sg_table rx_sg;
    unsigned    cs_change:1;
    unsigned    tx_nbits:3;
    unsigned    rx_nbits:3;
#define    SPI_NBITS_SINGLE 0x01 /* 1bit transfer */
#define    SPI_NBITS_DUAL        0x02 /* 2bits transfer */
#define    SPI_NBITS_QUAD        0x04 /* 4bits transfer */
    u8        bits_per_word;
    u16       delay_usecs;
    struct    spi_delay    delay;
    struct spi_delay  cs_change_delay;
    struct spi_delay  word_delay;
    u32        speed_hz;
    u32        effective_speed_hz;
[...]
    struct list_head transfer_list;
#define SPI_TRANS_FAIL_NO_START  BIT(0)
    u16        error;
};

以下是数据结构中每个元素的含义:

  • tx_buf 是指向包含待写入数据的缓冲区的指针。如果设置为 NULL,则此传输将被视为半双工读取事务。需要通过 DMA 执行 SPI 事务时,它应该是 DMA 安全的。

  • rx_buf是一个数据缓冲区,用于读取数据(具有与tx_buf相同的属性),或者在只写事务中为NULL

  • tx_dmatx_buf,前提是spi_message.is_dma_mapped被设置为1

  • rx_dmatx_dma相同,但用于rx_buf

  • len表示rxtx缓冲区的字节大小。只有len字节会被移出(或移入),并且尝试移出部分字会导致错误。

  • speed_hz覆盖了spi_device.max_speed_hz中指定的默认速度,但仅适用于当前的传输。如果为0,则使用默认值(来自spi_device)。

  • bits_per_word:数据传输涉及一个或多个字。字是数据单元,其大小(以位为单位)根据需求而变化。在这里,bits_per_word表示此 SPI 传输中一个字的位数大小。这将覆盖spi_device.bits_per_word中提供的默认值。如果为0,则使用默认值(来自spi_device)。

  • cs_change决定在此传输完成后 CS 是否变为不活动。所有 SPI 传输都以适当的 CS 信号激活开始。通常,它会保持选中状态,直到消息中的最后一个传输完成。通过使用cs_change,驱动程序可以改变 CS 信号。

该标志用于在消息的中间使 CS 暂时失效(即在处理指定的spi_transfer之前),如果该传输不是消息中的最后一个。以这种方式切换 CS 可能是完成芯片命令所必需的,从而允许单个 SPI 消息处理整个芯片事务集。

  • delay_usecs表示在此传输之后,延迟(以微秒为单位),然后(可选地)更改chip_select状态,接着开始下一个传输或完成此spi_message

    注意

    SPI 传输总是写入与读取相同数量的字节,即使在半双工传输中也是如此。SPI 核心通过控制器的dummy_rxdummy_tx元素实现这一点。当传输缓冲区为 null 时,spi_transfer->tx_buf将被设置为控制器的dummy_tx。然后,零将被移出,同时将从从设备接收到的数据填充到rx_buf中。如果接收缓冲区为 null,则spi_transfer->rx_buf将被设置为控制器的dummy_rx,并且接收到的数据将被丢弃。

struct spi_message

spi_message用于原子地发出一系列传输,每个传输由一个struct spi_transfer实例表示。我们之所以称之为原子,是因为在进行中的序列完成之前,其他任何spi_message都不能使用该 SPI 总线。需要注意的是,有些平台能够通过单个编程 DMA 传输处理多个此类序列。SPI 消息结构具有以下声明:

struct spi_message {
       struct list_head     transfers;
       struct spi_device    *spi;
       unsigned       is_dma_mapped:1;
       /* completion is reported through a callback */
       void                 (*complete)(void *context);
       void                 *context;
       unsigned       frame_length;
       unsigned       actual_length;
       int                  status;
    };

以下列表概述了此数据结构中各元素的含义:

  • transfers 是构成消息的传输列表。我们稍后会看到如何将传输添加到此列表。在该原子组中的最后一个传输使用 spi_transfer.cs_change 标志,可能会减少芯片取消选择和选择操作的成本。

  • is_dma_mapped 向控制器指示是否使用 DMA(或不使用 DMA)来执行事务。你的代码需要为每个传输缓冲区提供 DMA 和 CPU 虚拟地址。

  • complete 是在事务完成时调用的回调,context 是传递给回调的参数。

  • frame_length 会自动设置为消息中所有字节的总数。

  • actual_length 是所有成功片段中传输的字节数。

  • status 报告传输的状态。成功时为0,否则为-errno

spi_transfer 元素在消息中按 FIFO 顺序处理。在消息完成之前(即在完成回调执行之前),你必须确保不使用传输缓冲区,以避免数据损坏。提交 spi_message(及其 spi_transfers)到下层的代码负责管理其内存。驱动程序一旦提交消息(及其传输),必须忽略该消息(及其传输),至少要等到其完成回调被调用。

访问 SPI 设备

一个 SPI 控制器能够与一个或多个从设备进行通信,也就是说,与一个或多个 struct spi_device 进行通信。它们构成一个小型总线,共享 MOSI、MISO 和 SCK 信号,但不共享 CS 信号。由于这些共享信号在芯片未被选择时会被忽略,因此每个设备都可以被编程以使用不同的时钟速率。SPI 控制器驱动程序通过 spi_message 事务队列来管理与这些设备的通信,将数据在 CPU 内存和 SPI 从设备之间传输。对于它排队的每个消息实例,它会在事务完成时调用该消息的完成回调。

在消息提交到总线之前,它必须通过 spi_message_init() 进行初始化,该函数的原型如下:

void spi_message_init(struct spi_message *message)

该函数将零初始化结构中的每个元素,并初始化传输列表。对于要添加到消息中的每个传输,你应当对该传输调用 spi_message_add_tail(),这将导致该传输被排入消息的传输列表。它具有如下声明:

spi_message_add_tail(struct spi_transfer *t, struct spi_message *m)

一旦完成此操作,你有两种选择来开始事务:

  • int spi_sync(struct spi_device *spi, struct spi_message *message),成功时返回0,否则返回负的错误代码。该函数可能会休眠,且不能在中断上下文中使用。需要注意的是,该函数可能会以不可中断的方式休眠,并且不允许指定超时。具有 DMA 能力的控制器驱动程序可能会利用该 DMA 特性,直接将数据推送或拉取到/从消息缓冲区。

SPI 设备的 CS 会在整个消息期间(从第一个传输到最后一个)由核心激活,然后通常在消息之间禁用。有些驱动程序为了最小化选择芯片的影响(例如为了节省电力),会保持芯片处于选中状态,预期下一个消息会发送到同一芯片。

  • spi_async()函数可以在任何上下文中使用(无论是否为原子上下文),其原型为int spi_async(struct spi_device *spi, struct spi_message *message)。此函数与上下文无关,因为它只进行提交,处理是异步的。然而,完成回调会在无法休眠的上下文中调用。在调用此回调之前,message->status的值是未定义的。回调调用时,message->status保存完成状态,状态为0表示完全成功,或者是负的错误代码。

在回调返回后,发起传输请求的驱动程序可以释放相关的内存,因为它不再被任何 SPI 核心或控制器驱动代码使用。直到当前处理的消息的完成回调返回,排队到该设备的任何后续spi_message才会被处理。这条规则同样适用于同步传输调用,因为它们是该核心异步原语的封装。此函数成功时返回0,否则返回负的错误代码。

以下是一个驱动程序的摘录,演示了 SPI 消息和传输的初始化与提交:

Static int regmap_spi_gather_write(
                    void *context, const void *reg,
                    size_t reg_len, const void *val,
                    size_t val_len)
{
    struct device *dev = context;
    struct spi_device *spi = to_spi_device(dev);
    struct spi_message m;
    u32 addr;
    struct spi_transfer t[2] = {
      { .tx_buf = &addr, .len = reg_len, .cs_change = 0,},
      { .tx_buf = val, .len = val_len, },
    };
    addr = TCAN4X5X_WRITE_CMD  |
             (*((u16 *)reg) << 8) | val_len >> 2;
    spi_message_init(&m);
    spi_message_add_tail(&t[0], &m);
    spi_message_add_tail(&t[1], &m);
    return spi_sync(spi, &m);
}

然而,前面的摘录展示了静态初始化,在执行时,消息和传输会在函数返回路径中被丢弃。有些情况下,驱动程序可能希望在驱动生命周期内预分配消息及其传输,以避免频繁的初始化开销。在这种情况下,可以通过spi_message_alloc()使用动态分配,并通过spi_message_free()释放。它们具有以下原型:

struct spi_message *spi_message_alloc(unsigned ntrans,
                                      gfp_t flags)
void spi_message_free(struct spi_message *m)

在前面的代码片段中,ntrans是分配给这个新spi_message的传输次数,flags代表新分配内存的标志,使用GFP_KERNEL就足够了。如果成功,这个函数会返回新分配的消息结构及其传输。你可以使用内核列表相关宏来访问传输元素,例如list_first_entrylist_next_entry,甚至是list_for_each_entry。以下是展示这些宏使用的示例:

/* Completion handler for async SPI transfers */
static void my_complete(void *context)
{
    struct spi_message *msg = context;
    /* doing some other stuffs */
    […]
    spi_message_free(m);
}
static int example_spi_async(struct spi_device *spi,
              struct my_fake_spi_reg *cmds, unsigned len)
{
    struct spi_transfer *xfer;
    struct spi_message *msg;
    msg = spi_message_alloc(len, GFP_KERNEL);
    if (!msg)
         return -ENOMEM;
    msg->complete = my_complete;
    msg->context = msg;
    list_for_each_entry(xfer, &msg->transfers,
               transfer_list) {
        xfer->tx_buf = (u8 *)cmds;
        /* feel free to handle .rx_buf, and so on */
        [...]
        xfer->len = 2;
        xfer->cs_change = true;
         cmds++;
    }
    return spi_async(spi, msg);
}

在前面的摘录中,我们不仅展示了如何使用动态的消息和传输分配,还展示了如何使用spi_async()。这个示例其实没多大用处,因为分配的消息和传输在完成后立即被释放。使用动态分配的最佳实践是动态分配发送和接收缓冲区,并将它们保持在驱动程序生命周期内易于访问的位置。

但请注意,设备驱动程序负责以最合适的方式组织消息和传输,如下所示:

  • 何时开始双向读写,以及如何安排其一系列 spi_transfer 请求

  • I/O 缓冲区准备,了解每个 spi_transfer 都为每个传输方向包装一个缓冲区,支持全双工传输(即使一个指针为 NULL,在这种情况下控制器将使用其中一个虚拟缓冲区)

  • 可选地使用 spi_transfer.delay_usecs 来定义传输后的短延迟

  • 是否在传输后通过使用 spi_transfer.cs_change 标志来改变(使之不活跃)CS 信号

使用 spi_async 时,设备驱动程序将消息排队,注册完成回调,唤醒消息泵并立即返回。完成回调将在传输完成时被调用。由于消息排队和消息泵调度都不能阻塞,spi_async 函数被认为是与上下文无关的。然而,它要求你在访问提交的 spi_transfer 指针中的缓冲区之前等待完成回调。另一方面,spi_sync 排队消息并在完成之前阻塞。它不需要完成回调。当 spi_sync 返回时,访问数据缓冲区是安全的。如果你查看 drivers/spi/spi.c 中的实现,你会看到它使用 spi_async 将调用线程置于休眠状态,直到完成回调被调用。自 4.0 内核以来,spi_sync 有了改进,当队列中没有内容时,消息泵将在调用者的上下文中执行,而不是在消息泵线程中执行,从而避免了上下文切换的开销。

在介绍了 SPI 框架的最重要数据结构和 API 之后,我们可以讨论实际的驱动程序实现。

处理 SPI 驱动程序抽象和架构

这是驱动程序逻辑发生的地方。它包括用一组驱动函数填充 struct spi_driver,这些函数允许探测并控制底层设备。

探测设备

SPI 设备由 spi_driver.probe 回调函数探测。该探测回调函数负责确保驱动程序在设备绑定之前能够识别该设备。此回调函数具有以下原型:

int probe(struct spi_device *spi)

该方法在成功时必须返回 0,否则返回负数错误码。唯一的参数是要探测的 SPI 设备,其结构已由内核根据设备树中的描述进行预初始化。

然而,正如我们在描述其数据结构时所看到的,大多数(如果不是所有)SPI 设备的属性都可以被覆盖。SPI 协议驱动可能需要更新传输模式,如果设备无法使用其默认设置。它们也可能需要根据初始值更新时钟频率或字长。这是通过spi_setup()辅助函数实现的,原型如下:

int spi_setup(struct spi_device * spi)

此函数必须在能够独占休眠的上下文中调用。它期望传入一个 SPI 设备结构体,该结构体的属性必须在各自的字段中设置,以便进行覆盖。更改将在下次访问设备时生效(无论是读取操作还是写入操作,前提是该设备已被选中),但SPI_CS_HIGH会立即生效。此函数在返回时会取消选择 SPI 设备。此函数成功时返回0,失败时返回负值。值得注意的是,它的返回值,因为如果驱动程序提供了底层控制器或其驱动程序不支持的选项,则该调用将不会成功。例如,一些硬件使用九位字、最低有效位LSB)优先的线编码或高电平有效的 CS 来处理线传输,而其他硬件则不支持。

你可能希望在probe()中调用spi_setup(),在向设备提交任何 I/O 请求之前。然而,只要该设备没有挂起的消息,它可以在代码中的任何位置被调用。

以下是一个探测示例,它设置 SPI 设备,检查其家庭 ID,并在成功时返回0(设备已识别):

#define FAMILY_ID 0x57
static int fake_probe(struct spi_device *spi)
{
    int err;
    u8 id;
    spi->max_speed_hz =
               min(spi->max_speed_hz, DEFAULT_FREQ);
    spi->bits_per_word = 8;
    spi->mode = SPI_MODE_0;
    spi->rt = true;
    err = spi_setup(spi);
    if (err)
        return err;
    /* read family id */
    err = get_chip_version(spi, &id);
    if (err)
         return -EIO;
    /* verify family id */
    if (id != FAMILY_ID) {
        dev_err(&spi->dev"
    "chip family: expected 0x%02x but 0x%02x rea"\n",
               FAMILY_ID, id);
          return -ENODEV;
    }
    /* register with other frameworks */
    [...]
    return 0;
}

一个实际的探测方法可能还需要处理一些驱动状态数据结构或其他每个设备的特定数据结构。关于get_chip_version()函数,它可能有以下实现:

#define REG_FAMILY_ID 0x2445
#define DEFAULT_FREQ 10000000
static int get_chip_version(spi_device *spi, u8 *id)
{
    struct spi_transfer t[2];
    struct spi_message m;
    u16 cmd;
    int err;
    cmd = REG_FAMILY_ID;
    spi_message_init(&m);
    memset(&t, 0, sizeof(t));
    t[0].tx_buf = &cmd;
    t[0].len = sizeof(cmd);
    spi_message_add_tail(&t[0], &m);
    t[1].rx_buf = id;
    t[1].len = 1;
    spi_message_add_tail(&t[1], &m);
    return spi_sync(spi, &m);
}

现在我们已经了解了如何探测 SPI 设备,接下来讨论如何告诉 SPI 核心驱动程序可以支持哪些设备将会很有用。

注意

SPI 核心允许使用spi_get_drvdata()spi_set_drvdata()来设置/获取驱动程序状态数据,就像我们在讨论 I2C 设备驱动时在第八章**,编写 I2C 设备驱动中所做的那样。

在驱动中配置设备

正如我们需要一个i2c_device_id列表来告知 I2C 核心我们的 I2C 驱动可以支持哪些设备一样,我们也必须提供一个spi_device_id数组来通知 SPI 核心我们的 SPI 驱动支持哪些设备。填充该数组后,必须将其分配给spi_driver.id_table字段。此外,为了设备匹配和模块加载目的,这个数组还需要传递给MODULE_DEVICE_TABLE宏。struct spi_device_idinclude/linux/mod_devicetable.h中有如下声明:

struct spi_device_id {
   char name[SPI_NAME_SIZE];
   kernel_ulong_t driver_data;
};

在前面的数据结构中,name是设备的描述性名称,driver_data是驱动程序的状态值。它可以通过指向每个设备数据结构的指针来设置。以下是一个示例:

#define ID_FOR_FOO_DEVICE   0
#define ID_FOR_BAR_DEVICE   1 
static struct spi_device_id foo_idtable[] = {
  "{ ""oo", ID_FOR_FOO_DEVICE },
  "{ ""ar", ID_FOR_BAR_DEVICE },
   { },
};
MODULE_DEVICE_TABLE(spi, foo_idtable);

为了能够匹配设备树中声明的设备,我们需要定义一个struct of_device_id元素的数组,并将其赋值给spi_driver.of_match_table,同时在其上调用MODULE_DEVICE_TABLE宏。以下是一个示例,示例还展示了在设置好后的spi_driver结构体的样子:

static const struct of_device_id foobar_of_match[] = {
        { .compatible"= "packtpub,foobar-dev"ce" },
        { .compatible"= "packtpub,barfoo-dev"ce" },
        {},
};
MODULE_DEVICE_TABLE(of, foobar_of_match);

以下摘录展示了最终的spi_driver内容:

static struct spi_driver foo_driver = {
    .driver         = {
        .name  "= ""oo",
        /* The below line adds Device Tree support */
        .of_match_table = of_match_ptr(foobar_of_match),
    },
    .probe          = my_spi_probe,
    .id_table       = foo_idtable,
};

在前面,我们可以看到设置好的 SPI 驱动结构是怎样的。然而,还有一个缺失的元素——spi_driver.remove回调,它用于撤销探测函数中所做的操作。

实现spi_driver.remove方法

remove回调必须用于释放所有获取的资源,并撤销探测时所做的操作。该回调有以下原型:

static int remove(struct spi_device *spi)

在前面的代码片段中,spi是 SPI 设备数据结构,和传递给probe回调的结构相同,这简化了设备状态数据结构在探测和设备移除之间的跟踪。该方法成功时返回0,失败时返回负的错误码。你必须确保设备保持一致且稳定的状态。以下是一个示例实现:

static int mc33880_remove(struct spi_device *spi)
{
    struct mc33880 *mc;
    mc = spi_get_drvdata(spi); /* Get our data back */
    if (!mc)
        return -ENODEV;
    /*
     * unregister from frameworks with which we
     * registered in the probe function
     */
    gpiochip_remove(&mc->chip);
    [...]
    /* releasing any resource */
    mutex_destroy(&mc->lock);
    return 0;
}

在前面的示例中,代码处理了从框架中注销和释放资源的工作。这是你在 90%的情况下会遇到的经典场景。

驱动初始化和注册

在这个实现步骤中,你的代码几乎完成了,并且你希望通知 SPI 核心你的 SPI 驱动。这就是驱动注册。对于 SPI 设备驱动,SPI 核心提供了spi_register_driver()spi_unregister_driver()来分别注册和注销 SPI 设备驱动。它们的原型如下:

int spi_register_driver(struct spi_driver *sdrv);
void spi_unregister_driver(struct spi_driver *sdrv);

在这两个函数中,sdrv是先前设置好的 SPI 驱动结构。注册 API 成功时返回 0,失败时返回负的错误码。

驱动注册和注销通常发生在模块初始化和模块退出方法中。以下是一个典型的 SPI 驱动注册示例:

static int __init foo_init(void)
{
   [...] /*My init code */
   return spi_register_driver(&foo_driver);
}
module_init(foo_init);
static void __exit foo_cleanup(void)
{
   [...] /* My clean up code */
   spi_unregister_driver(&foo_driver);
}
module_exit(foo_cleanup);

如果你在模块初始化时仅进行驱动的注册/注销操作,你可以像下面这样使用module_spi_driver()来简化代码:

module_spi_driver(foo_driver);

该宏将填充模块初始化和清理函数,并会在其中调用spi_register_driverspi_unregister_driver

实例化 SPI 设备

SPI 从节点必须是 SPI 控制器节点的子节点。在主模式下,可以有一个或多个从节点(最多与 CS 数量相同)。

所需的属性如下:

  • compatible:在驱动中定义的用于匹配的兼容字符串

  • reg:设备相对于控制器的 CS 索引

  • spi-max-frequency:设备的最大 SPI 时钟速度(单位:Hz)

所有从属节点都可以包含以下可选属性:

  • spi-cpol:布尔属性,如果存在,表示设备需要反向时钟极性CPOL)模式。

  • spi-cpha:布尔属性,表示该设备需要偏移的时钟相位CPHA)模式。

  • spi-cs-hi–h:空属性,表示设备需要 CS 高电平激活。

  • spi-3wire:布尔属性,表示该设备需要 3 线模式才能正常工作。

  • spi-lsb-first:布尔属性,表示该设备需要 LSB 优先模式。

  • spi-tx-bus-width:该属性表示用于 MOSI 的总线宽度。如果没有该属性,则默认为1

  • spi-rx-bus-width:该属性用于表示用于 MISO 的总线宽度。如果没有该属性,则默认为1

  • spi-rx-delay-–s:用于指定读取传输后的微秒延迟。

  • spi-tx-delay-us:用于指定写入传输后的微秒延迟。

以下是 SPI 设备的实际设备树列表:

ecspi1 {
    fsl,spi-num-CSs = <3>;
    cs-gpios = <&gpio5 17 0>, <&gpio5 17 0>, <&gpio5 17 0>;
    pinctrl-0 = <&pinctrl_ecspi1 &pinctrl_ecspi1_cs>;
    #address-cells = <1>;
    #size-cells = <0>;
    compatible"= "fsl,imx6q-ec"pi", "fsl,imx51-ec"pi";
    reg = <0x02008000 0x4000>;
    status"= "o"ay";
    ad7606r8_0: ad7606r8@0 {
        compatible"= "ad760"-8";
        reg = <0>;
        spi-max-frequency = <1000000>;
        interrupt-parent = <&gpio4>;
        interrupts = <30 0x0>;
   };
   label: fake_spi_device@1 {
        compatible"= "packtpub,foobar-dev"ce";
        reg = <1>;
        a-string-param"= "stringva"ue";
        spi-cs-high;
   };
   mcp2515can: can@2 {
        compatible"= "microchip,mcp2"15";
        reg = <2>;
        spi-max-frequency = <1000000>;
        clocks = <&clk8m>;
        interrupt-parent = <&gpio4>;
        interrupts = <29 IRQ_TYPE_LEVEL_LOW>;
    };
};

在前面的设备树片段中,ecspi1表示主 SPI 控制器。fake_spi_devicemcp2515can表示 SPI 从设备,它们的reg属性表示相对于主设备的 CS 索引。

现在我们已经熟悉了 SPI 从设备框架的所有内核方面,接下来让我们看看如何避免处理内核并尝试在用户空间实现所有功能。

学习如何避免编写 SPI 设备驱动程序

处理 SPI 设备的常见方法是编写内核代码来驱动该设备。如今,spidev接口使得即使不编写一行内核代码,也可以处理此类设备。然而,使用此接口应仅限于简单的用例,例如与从属微控制器通信或原型开发。使用此接口时,您将无法处理设备可能支持的各种中断IRQ),也无法利用其他内核框架。

spidev接口以/dev/spidevX.Y的形式暴露字符设备节点,其中X表示设备所在的总线,Y表示在设备树中分配给该设备节点的 CS 索引(相对于控制器)。例如,/dev/spidev1.0表示 SPI 总线1上的设备0。同样适用于 sysfs 目录条目,其形式为/sys/class/spidev/spidevX.Y

在字符设备出现在用户空间之前,设备节点必须在设备树中声明为 SPI 控制器节点的子节点。以下是一个示例:

&ecspi2 {
    pinctrl-names"= "defa"lt";
    pinctrl-0 = <&pinctrl_teoulora_ecspi2>;
    cs-gpios = <&gpio2 26 1
                &gpio2 27 1>;
    num-cs = <2>;
    status"= "o"ay";
    spidev@0 {
        reg = <0>;
        compatib"e="semtech,sx1"01";
        spi-max-frequency = <20000000>;
    };
};

在前面的代码片段中,spidev@0 对应我们的 SPI 设备节点。reg = <0> 告诉控制器该设备使用的是第一个 CS 引脚(索引从 0 开始)。compatible="semtech,sx1301" 属性用于匹配 spidev 驱动程序中的一个条目。现在不再推荐使用 "spidev" 作为兼容字符串——如果尝试使用它,你会收到警告。最后,spi-max-frequency = <20000000> 设置了我们设备的默认时钟频率(此处为 20 MHz),除非通过相应的 API 进行更改。

从用户空间开始,处理 spidev 接口所需的头文件如下:

#include <fcntl.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <linux/types.h>
#include <linux/spi/spidev.h>

由于它是一个字符设备,允许(事实上这是唯一的选择)使用基本的系统调用,如 open()read()write()ioctl()close()。以下示例展示了一些基本用法,仅使用 read()write() 操作:

#include <stdio.h>
#include <stdlib.h>
int main(int argc, char **argv) 
{
   int i,fd;
   char *device = "/dev/spidev0.0";
   char wr_buf[]={0xff,0x00,0x1f,0x0f};
   char rd_buf[10]; 

   fd = open(device, O_RDWR);
   if (fd <= 0) { 
         printf("Failed to open SPI device %s\n", device);
         exit(1);
   }

   if (write(fd, wr_buf, sizeof(wr_buf)) != sizeof(wr_buf))
         perror("Write Error");
   if (read(fd, rd_buf, sizeof(rd_buf)) != sizeof(rd_buf))
         perror("Read Error");
   else
         for (i = 0; i < sizeof(rd_buf); i++)
             printf("0x%02X ", rd_buf[i]);
   close(fd);
   return 0;
}

在前面的代码中,需要注意的是,标准的 read()write() 操作仅支持半双工,而且每次操作之间都会禁用 CS。要实现全双工工作,唯一的选择是使用 ioctl() 接口,在该接口中可以随意传递输入和输出缓冲区。此外,借助 ioctl() 接口,你可以使用一组 SPI_IOC_RD_*SPI_IOC_WR_* 命令来获取 RD 并设置 WR,从而覆盖设备的当前设置。有关这些命令的完整列表和文档,可以在内核源码中的 Documentation/spi/spidev 找到。

ioctl() 接口允许在不禁用 CS 的情况下执行复合操作,并通过 SPI_IOC_MESSAGE(N) 请求实现。一个新的数据结构被引入,即 struct spi_ioc_transfer,它是用户空间中 struct spi_transfer 的等效结构。以下是 ioctl 命令的示例:

#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* include required headers, listed early in the section */
[...]
static int pabort(const char *s)
{
    perror(s);
    return -1;
}
static int spi_device_setup(int fd)
{
    int mode, speed, a, b, i;
    int bits = 8;
    /* spi mode: mode 0 */
    mode = SPI_MODE_0;
    a = ioctl(fd, SPI_IOC_WR_MODE, &mode); /* set mode */
    b = ioctl(fd, SPI_IOC_RD_MODE, &mode); /* get mode */
    if ((a < 0) || (b < 0)) {
        return pabort("can't set spi mode");
    }
    /* Clock max speed in Hz */
    speed = 8000000; /* 8 MHz */
    a = ioctl(fd, SPI_IOC_WR_MAX_SPEED_HZ, &speed); /* set */
    b = ioctl(fd, SPI_IOC_RD_MAX_SPEED_HZ, &speed); /* get */
    if ((a < 0) || (b < 0))
        return pabort("fail to set max speed hz");
    /*
     * Set SPI to MSB first.
     * Here, 0 means "not to use LSB first".
     * To use LSB first, argument should be > 0
     */
    i = 0;
    a = ioctl(dev, SPI_IOC_WR_LSB_FIRST, &i);
    b = ioctl(dev, SPI_IOC_RD_LSB_FIRST, &i);
    if ((a < 0) || (b < 0))
        pabort("Fail to set MSB first\n");

    /* setting SPI to 8 bits per word */
    bits = 8;
    a = ioctl(dev, SPI_IOC_WR_BITS_PER_WORD, &bits); /* set */
    b = ioctl(dev, SPI_IOC_RD_BITS_PER_WORD, &bits); /* get */
    if ((a < 0) || (b < 0))
        pabort("Fail to set bits per word\n");

    return 0;
}

在前面的例子中,getter 仅用于演示目的。执行 SPI_IOC_WR_* 命令后,使用 SPI_IOC_RD_* 命令并非强制性的。现在我们已经了解了大多数这些 ioctl 命令,接下来我们来看看如何开始传输:

static void do_transfer(int fd)
{
    int ret;
    char txbuf[] = {0x0B, 0x02, 0xB5};
    char rxbuf[3] = {0, };
    char cmd_buff = 0x9f;
    struct spi_ioc_transfer tr[2] = {
        0 = {
          .tx_buf = (unsigned long)&cmd_buff,
          .len = 1,
          .cs_change = 1;    /* We need CS to change */
          .delay_usecs = 50, /* wait after this transfer */
          .bits_per_word = 8,
        },
        [1] = {
          .tx_buf = (unsigned long)tx,
          .rx_buf = (unsigned long)rx,
          .len = txbuf(tx),
          .bits_per_word = 8,
        },
    };
    ret = ioctl(fd, SPI_IOC_MESSAGE(2), &tr);
    if (ret == 1){
        perror("can't send spi message");
        exit(1);
    }
    for (ret = 0; ret < sizeof(tx); ret++)
        printf("%.2X ", rx[ret]);
    printf("\n");
}

上述内容展示了用户空间中消息和传输事务的概念。现在我们的帮助程序已定义,我们可以编写主代码来使用它们,如下所示:

int main(int argc, char **argv)
{
    char *device = "/dev/spidev0.0";
    int fd;
    int error;
    fd = open(device, O_RDWR);
    if (fd < 0)
        return pabort("Can't open device ");
    error = spi_device_setup(fd);
    if (error)
        exit (1);

    do_transfer(fd);

    close(fd);
    return 0;
}

我们现在已经完成了主函数的部分。本节内容教我们如何使用用户空间的 SPI API 和命令与设备进行交互。然而,我们受到一些限制,无法利用设备中断线或其他内核框架。

总结

在本章中,我们处理了 SPI 驱动程序,并且现在可以利用这个比 I2C 快得多的串行(全双工)总线。我们走遍了这个框架中的所有数据结构,并讨论了如何通过 SPI 进行数据传输,这是我们涵盖的最重要部分。也就是说,我们通过这些总线访问的内存是外部存储器——为了避免 SPI 和 I2C API 的使用,我们可能需要更多的抽象层。

这就是下一章的内容,它讲解了 regmap API,该 API 提供了更高、更统一的抽象层次,使得 SPI(和 I2C)命令对你变得透明。

第三部分 - 最大化利用硬件资源

本节将讨论 Linux 内核内存管理、内核中断管理的高级概念以及直接内存访问。接着,为了简化内存访问操作,我们将使用 Linux 内核中实现的内存访问抽象——regmap。最后,我们将介绍 Linux 设备模型,以便更好地理解和概览系统上的设备层级。

本节将涵盖以下章节:

  • 第十章理解 Linux 内核内存分配

  • 第十一章实现直接内存访问(DMA)支持

  • 第十二章内存访问抽象——Regmap API 介绍:寄存器映射抽象

  • 第十三章揭开内核 IRQ 框架的神秘面纱

  • 第十四章Linux 设备模型介绍

第十章:第十章:理解 Linux 内核内存分配

Linux 系统使用一种被称为“虚拟内存”的幻觉。这个机制使得每个内存地址都是虚拟的,这意味着它们并不直接指向 RAM 中的任何地址。通过这种方式,每当我们访问某个内存位置时,都会执行一个转换机制,以便匹配相应的物理内存。

在本章中,我们将处理整个 Linux 内存分配与管理系统,涵盖以下主题:

  • Linux 内核内存相关术语简介

  • 揭开地址转换与 MMU 的神秘面纱

  • 处理内存分配机制

  • 使用 I/O 内存与硬件通信

  • 内存重映射

Linux 内核内存相关术语简介

虽然系统内存(也称为 RAM)在某些允许扩展的计算机中可以增加,但物理内存在计算机系统中是有限的资源。

虚拟内存是一个概念,是给予每个进程的幻觉,使其认为自己拥有大量且几乎无限的内存,有时甚至超过系统实际拥有的内存。为了设置一切,我们将介绍地址空间、虚拟或逻辑地址、物理地址和总线地址等术语:

  • 物理地址标识一个物理(RAM)位置。由于虚拟内存机制,用户或内核永远不会直接处理物理地址,而是通过其对应的逻辑地址进行访问。

  • 虚拟地址不一定在物理上存在。该地址作为参考,用于通过内存管理单元MMU)代表 CPU 访问物理内存位置。MMU 位于 CPU 核心与内存之间,通常是物理 CPU 的一部分。也就是说,在 ARM 架构中,它是受许可核心的一部分。然后,它负责每次访问内存位置时将虚拟地址转换为物理地址。这个机制被称为地址转换

  • 逻辑地址是由线性映射产生的地址。它是PAGE_OFFSET之上的映射结果。这类地址是虚拟地址,与其物理地址有固定偏移。因此,逻辑地址始终是虚拟地址,而反之则不成立。

  • 在计算机系统中,地址空间是为计算实体(在我们这里是 CPU)分配的所有可能地址的内存量。这个地址空间可以是虚拟的或物理的。物理地址空间的最大值可以达到系统中安装的 RAM 的容量(理论上受限于 CPU 地址总线和寄存器的宽度),而虚拟地址的范围可以扩展到 RAM 或操作系统架构允许的最高地址(例如,在 1 GB RAM 系统上最多支持 4 GB 的虚拟内存地址)。

由于 MMU 是内存管理的核心,它将内存组织为固定大小的逻辑单元,称为。页的大小是 2 的幂,以字节为单位,并在不同的系统中有所不同。一个页由一个页框支持,页的大小与页框匹配。在深入学习内存管理之前,我们先介绍一下其他术语:

  • 内存页、虚拟页或简称页是用来指代一个固定长度的(PAGE_SIZE)虚拟内存块的术语。相同的术语“页”也作为内核数据结构,表示一个内存页。

  • 另一方面,框架(或页框)指的是物理内存(RAM)中的一个固定长度块,操作系统将页面映射到这个块上。页的大小与页框大小匹配。每个页框都有一个编号,称为页框号PFN)。

  • 接下来是页表这一术语,它是一个内核和架构数据结构,用于存储虚拟地址与物理地址之间的映射关系。页/框的键值对描述了页表中的单个条目,代表一个映射。

最后,“页对齐”这一术语用于描述从页面的起始位置开始的地址。不言而喻,任何地址是系统页大小的倍数的内存,都被认为是页对齐的。例如,在一个 4 KB 页大小的系统中,4.09620.480409.600是页对齐的内存地址实例。

注意

页的大小由 MMU 固定,操作系统无法修改它。一些处理器允许使用多种页大小(例如,ARMv8-A 支持三种不同的粒度大小:4 KB、16 KB 和 64 KB),操作系统可以决定使用哪种大小。不过,4 KB 是广泛使用的页粒度。

既然处理内存时常用的术语已经介绍完毕,那么我们就专注于内核如何进行内存管理和组织。

Linux 是一个虚拟内存操作系统。在一个运行中的 Linux 系统中,每个进程甚至内核本身(以及某些设备)都会被分配地址空间,这些空间是处理器虚拟地址空间的一部分(需要注意的是,内核和进程并不处理物理地址——只有 MMU 处理)。虽然这个虚拟地址空间被划分为内核空间和用户空间,但上半部分用于内核,下半部分用于用户空间。

划分因架构而异,并由 CONFIG_PAGE_OFFSET 内核配置选项控制。对于 32 位系统,默认划分在 0xC0000000。这被称为 3 GB/1 GB 划分,其中用户空间分配了较低的 3 GB 虚拟地址空间。然而,通过调整 CONFIG_VMSPLIT_1GCONFIG_VMSPLIT_2GCONFIG_VMSPLIT_3G_OPT 内核配置选项(参见 arch/x86/Kconfigarch/arm/Kconfig),内核也可以获得不同大小的地址空间。对于 64 位系统,划分因架构而异,但其值较高:64 位 ARM 为 0x8000000000000000,x86_64 为 0xffff880000000000

在 32 位系统上,采用默认划分方案时,一个典型进程的虚拟地址空间布局如下所示:

图 10.1 – 32 位系统内存划分

图 10.1 – 32 位系统内存划分

虽然在 64 位系统上这种布局是透明的,但在 32 位机器上有一些特性需要介绍。在接下来的章节中,我们将详细研究这种内存划分的原因、用途及其应用场景。

32 位系统上的内核地址空间布局 – 低内存与高内存的概念

在理想的情况下,所有内存都是永久可映射的。然而,在 32 位系统上存在一些限制,导致只有一部分 RAM 被永久映射。这部分内存可以被内核直接访问(通过简单的解引用),并被称为 低内存,而未被永久映射的(物理)内存部分则被称为 高内存。不同架构的限制决定了这个边界的具体位置。例如,英特尔核心只能永久映射前 1 GB 的 RAM。实际上,这个量略小,为 896 MiB 的 RAM,因为其中的一部分低内存用于动态映射高内存:

图 10.2 – 高内存与低内存的划分

图 10.2 – 高内存与低内存的划分

在前面的图示中,我们可以看到,内核地址空间的 128 MB 用于动态映射需要时的高内存 RAM。另一方面,896 MB 的内核地址空间被永久并线性地映射到低内存的 896 MB RAM。

高内存机制还可以在 1 GB RAM 系统上使用,动态映射用户内存,只要内核需要访问。内核能够将整个 RAM 映射到其地址空间并不意味着用户空间无法访问它。一个 RAM 页框架可以有多个映射;它可以同时永久映射到内核内存空间,并在进程被选中执行时映射到用户空间的某个地址。

注意

给定一个虚拟地址,您可以通过使用前面显示的进程布局来区分它是内核空间还是用户空间地址。PAGE_OFFSET 以下的每个地址来自用户空间;否则,它来自内核空间。

低内存详细信息

内核地址空间的前 896 MB 构成低内存区域。在引导过程的早期,内核会将这 896 MB 的地址空间永久映射到物理 RAM 上。由该映射得到的地址被称为LOWMEM,并且专门保留给直接内存访问DMA)使用。由于硬件限制,硬件并不总是允许将所有页视为相同。我们可以在内核空间中识别出三个不同的内存区域:

  • ZONE_DMA:该区域包含 16 MB 以下的内存页帧,专门保留给 DMA 使用。

  • ZONE_NORMAL:该区域包含 16 MB 以上且小于 896 MB 的内存页帧,供正常使用。

  • ZONE_HIGHMEM:该区域包含 896 MB 及以上的内存页帧。

然而,在一个 512 MB 的系统上,将没有ZONE_HIGHMEM,16 MB 用于ZONE_DMA,剩余的 496 MB 用于ZONE_NORMAL

从前面的所有内容,我们可以完善对逻辑地址的定义,补充说明这些地址是在内核空间中按物理地址线性映射的地址,并且可以通过使用偏移量获取相应的物理地址。内核虚拟地址与逻辑地址类似,都是从内核空间地址映射到物理地址的映射。然而,它们之间的区别在于,内核虚拟地址并不总是像逻辑地址那样具有相同的线性、一对一的物理位置映射。

注意

你可以使用__pa(address)宏将物理地址转换为逻辑地址,并使用__va(address)宏进行反向转换。

理解高内存

内核地址空间的前 128 MB 被称为HIGHMEM区域。

内核会动态创建访问高内存的映射,并在使用完毕后销毁。这使得高内存的访问速度较慢。然而,由于 64 位系统具有巨大的地址范围(264 TB),因此高内存的概念在 64 位系统中不存在,3 GB/1 GB(或任何类似的拆分方案)的划分已经不再有意义。

从内核看进程地址空间的概览

在 Linux 系统中,每个进程在内核中都由struct task_struct的一个实例表示(见include/linux/sched.h),该结构描述了该进程。在进程开始运行之前,会为它分配一个内存映射表,存储在struct mm_struct类型的变量中(见include/linux/mm_types.h)。通过查看struct task_struct定义的以下片段,可以验证这一点,该片段嵌入了指向struct mm_struct类型元素的指针:

struct task_struct{
    […]
    struct mm_struct *mm, *active_mm;
    […]
}

在内核中,有一个全局变量始终指向当前进程,current,而current->mm字段指向当前进程的内存映射表。在进一步解释之前,我们先来看一下struct mm_struct数据结构的以下片段:

struct mm_struct {
    struct vm_area_struct *mmap;
    unsigned long mmap_base;
    unsigned long task_size;
    unsigned long highest_vm_end;
    pgd_t * pgd;
    atomic_t mm_users;
    atomic_t mm_count;
    atomic_long_t nr_ptes;
#if CONFIG_PGTABLE_LEVELS > 2
    atomic_long_t nr_pmds;
#endif
    int map_count;
    spinlock_t page_table_lock;
    unsigned long total_vm;
    unsigned long locked_vm;
    unsigned long pinned_vm;
    unsigned long data_vm;
    unsigned long exec_vm;
    unsigned long stack_vm;
    unsigned long start_code, end_code, start_data, end_data;
    unsigned long start_brk, brk, start_stack;
    unsigned long arg_start, arg_end, env_start, env_end;
    /* ref to file /proc/<pid>/exe symlink points to */
    struct file __rcu *exe_file;
};

我故意移除了一些我们不感兴趣的字段。有些字段我们稍后会讲到:例如pgd,它是指向进程基地址(第一个条目)的一级表(页全局目录,缩写为PGD)的指针,写入在 CPU 的上下文切换时的转换表基地址。为了更好地理解这个数据结构,我们可以使用以下图表:

图 10.3 – 进程地址空间

图 10.3 – 进程地址空间

从进程的角度来看,内存映射可以被视为一组专门用于连续虚拟地址范围的页表项。这个“连续虚拟地址范围”被称为内存区域,或虚拟内存区域VMA)。每个内存映射都由起始地址和长度、权限(例如程序是否可以从该内存中读取、写入或执行)以及相关资源(例如物理页面、交换页面和文件内容)描述。

mm_struct有两种方式来存储进程区域(VMAs):

  • 在一棵红黑树(自平衡二叉查找树)中,根元素由mm_struct->mm_rb字段指向

  • 在一个链表中,第一个元素由mm_struct->mmap字段指向

现在我们已经概览了进程地址空间,并且看到它是由一组虚拟内存区域组成的,接下来让我们深入研究这些内存区域背后的机制。

理解 VMA 的概念

在内核中,进程的内存映射被组织成若干个区域,每个区域被称为 VMA。供您参考,在 Linux 系统上的每个运行中的进程中,代码段、每个映射的文件区域(例如库文件)或每个独立的内存映射(如果有的话)都是由 VMA 实现的。VMA 是一个与架构无关的结构,具有权限和访问控制标志,由起始地址和长度定义。它们的大小始终是页大小(PAGE_SIZE)的倍数。一个 VMA 由几个页面组成,每个页面在页表中都有一个条目(页表项PTE))。

VMA 在内核中表示为struct vma_area结构的一个实例,定义如下:

struct vm_area_struct {
    unsigned long vm_start; 
    unsigned long vm_end;
    struct vm_area_struct *vm_next, *vm_prev;
    struct mm_struct *vm_mm;
    pgprot_t vm_page_prot;
    unsigned long vm_flags;
    unsigned long vm_pgoff;
    struct file * vm_file;
    [...]
}

为了提高本节的可读性和易理解性,只有与我们相关的元素被列出。然而,剩余元素的含义如下:

  • vm_start是 VMA 在地址空间(vm_mm)中的起始地址,它是该 VMA 内的第一个地址。

  • vm_endvm_mm中我们结束地址之后的第一个字节,它是该 VMA 外部的第一个地址。

  • vm_nextvm_prev用于实现按地址排序的每个任务的 VMA 链表。

  • vm_mm是该 VMA 所属的进程地址空间。

  • vm_page_protvm_flags表示 VMA 的访问权限。前者是一个架构级数据类型,其更新直接应用于底层架构的 PTE。它是vm_flags的缓存转换形式,后者以架构无关的方式存储适当的保护位和映射类型。

  • vm_file是支持该映射的文件。对于匿名映射(如进程的堆或栈),此值可以为NULL

  • vm_pgoff是偏移量(在vm_file内)以页大小为单位。此偏移量以页数来度量。

以下图是进程内存映射的概览,突出显示了每个 VMA 并描述其一些结构元素:

图 10.4 – 进程内存映射

图 10.4 – 进程内存映射

前面的图片(来源:http://duartes.org/gustavo/blog/post/how-the-kernel-manages-your-memory/)描述了一个进程(从/bin/gonzo启动)的内存映射(VMA)。我们可以看到struct task_struct与其地址空间元素(mm)之间的交互,后者列出了并描述了每个 VMA(起始、结束及其后备文件)。

你可以使用find_vma()函数来查找与给定虚拟地址对应的 VMA。find_vma()linux/mm.h中声明,形式如下:

extern struct vm_area_struct * find_vma(
           struct mm_struct * mm, unsigned long addr);

该函数搜索并返回第一个满足vm_start <= addr < vm_end的 VMA,若没有找到则返回NULLmm是要搜索的进程地址空间。对于当前进程,它可以是current->mm。以下是一个示例:

struct vm_area_struct *vma =
                     find_vma(task->mm, 0x603000);
if (vma == NULL) /* Not found ? */
    return -EFAULT;
/* Beyond the end of returned VMA ? */
if (0x13000 >= vma->vm_end)
    return -EFAULT;

前面的代码片段将寻找一个 VMA,其内存边界包含0x603000

给定一个进程,其标识符为<PID>,可以通过读取/proc/<PID>/maps/proc/<PID>/smaps/proc/<PID>/pagemap文件来获取该进程的所有内存映射。以下列出了一个正在运行的进程(进程标识符 PID 为1073)的映射:

# cat /proc/1073/maps 
00400000-00403000 r-xp 00000000 b3:04 6438             /usr/sbin/net-listener
00602000-00603000 rw-p 00002000 b3:04 6438             /usr/sbin/net-listener
00603000-00624000 rw-p 00000000 00:00 0                [heap]
7f0eebe4d000-7f0eebe54000 r-xp 00000000 b3:04 11717    /usr/lib/libffi.so.6.0.4
7f0eebe54000-7f0eec054000 ---p 00007000 b3:04 11717    /usr/lib/libffi.so.6.0.4
7f0eec054000-7f0eec055000 rw-p 00007000 b3:04 11717    /usr/lib/libffi.so.6.0.4
7f0eec055000-7f0eec069000 r-xp 00000000 b3:04 21629    /lib/libresolv-2.22.so
7f0eec069000-7f0eec268000 ---p 00014000 b3:04 21629    /lib/libresolv-2.22.so
[...]
7f0eee1e7000-7f0eee1e8000 rw-s 00000000 00:12 12532    /dev/shm/sem.thk-mcp-231016-sema
[...]

前面的每一行表示一个 VMA,字段对应于{地址(起始-结束)} {权限} {偏移量} {设备(主:次)} {inode} {路径名(映像)}的模式:

  • address:表示 VMA 的起始和结束地址。

  • permissions:描述区域的访问权限:r(读),w(写),x(执行)。p表示映射是私有的,s表示共享映射。

  • offset:如果是文件映射(mmap系统调用),则为映射发生时在文件中的偏移量。否则为0

  • major:minor:如果是文件映射,这代表存储文件的设备的主次设备号(设备持有文件)。

  • inode:如果是从文件映射,则为映射文件的inode号。

  • pathname:这是映射文件的名称,否则留空。还有其他区域名称,例如 [heap][stack][vdso](代表虚拟动态共享对象,一种由内核映射到每个进程地址空间中的共享库,目的是减少系统调用切换到内核模式时的性能损失)。

分配给进程的每个页面都属于某个区域,因此任何不在 VMA 中的页面都不存在,也无法被进程引用。

高内存非常适合用户空间,因为它的地址空间必须显式地进行映射。因此,大多数高内存被用户应用程序占用。__GFP_HIGHMEMGFP_HIGHUSER 是请求分配(潜在)高内存的标志。没有这些标志,所有内核分配只会返回低内存。在 Linux 中,无法从用户空间分配连续的物理内存。

既然 VMAs 对我们已经没有秘密可言,那么我们就来描述将其翻译到相应物理地址的硬件概念(如果有的话),或者它们的创建和分配方式。

破解地址翻译和 MMU

MMU 不仅将虚拟地址转换为物理地址,还保护内存免受未授权访问。给定一个进程,任何需要从该进程访问的页面必须存在于其一个 VMA 中,因此必须存在于进程的页表中(每个进程都有自己的页表)。

回顾一下,内存是通过固定大小的块进行组织的,虚拟内存使用页面,而物理内存使用帧。在我们的例子中,大小是 4 KB。然而,它在内核中是通过 PAGE_SIZE 宏定义并访问的。请记住,页面大小是由硬件强制的。以 4 KB 页面大小的系统为例,字节 0 到 4095 属于页面 0,字节 4096 到 8191 属于页面 1,依此类推。

引入了页表的概念来管理页面和帧之间的映射。页面被分布在表中,每个 PTE 对应一个页面和一个帧之间的映射。然后,每个进程都会获得一组页表来描述其所有的内存区域。

为了遍历页面,每个页面都会分配一个索引,称为页号。当涉及到帧时,它是页面帧号PFN)。这样,VMA(更准确地说是逻辑地址)由两部分组成:页号和偏移量。在 32 位系统中,偏移量表示地址的低 12 位,而在 8 KB 页大小系统中,偏移量表示低 13 位。以下图展示了地址被分为页号和偏移量的概念:

图 10.5 – 逻辑地址表示

图 10.5 – 逻辑地址表示

操作系统或 CPU 如何知道哪个物理地址对应给定的逻辑地址?它们使用页表作为转换表,并且知道每个条目的索引是虚拟页号,该索引位置的值是 PFN。为了根据虚拟内存访问物理内存,操作系统首先提取偏移量和虚拟页号,然后遍历进程的页表,将虚拟页号与物理页进行匹配。一旦匹配成功,就可以访问该页框中的数据:

图 10.6 – 地址转换

图 10.6 – 地址转换

偏移量用于指向框架中的正确位置。页表不仅保存物理页号和虚拟页号之间的映射,还包含访问控制信息(读/写权限、特权等)。

以下图描述了地址解码和页表查找,以指向适当框架中的适当位置:

 图 10.7 – 虚拟地址到物理地址的转换

图 10.7 – 虚拟地址到物理地址的转换

用于表示偏移量的位数由 PAGE_SHIFT 内核宏定义。PAGE_SHIFT 是将 1 位左移多少次以获得 PAGE_SIZE 值的次数。它也是将页面的逻辑地址右移多少次以获得其页号的次数,这对于物理地址也是一样,用于获得其页框号。这个宏与架构相关,也取决于页面粒度。其值可以视为以下内容:

#ifdef CONFIG_ARM64_64K_PAGES
#define PAGE_SHIFT        16
#elif defined(CONFIG_ARM64_16K_PAGES)
#define PAGE_SHIFT       14
#else
#define PAGE_SHIFT        12
#endif
#define PAGE_SIZE        (_AC(1, UL) << PAGE_SHIFT)

前述内容表明,默认情况下(无论是 ARM 还是 ARM64),PAGE_SHIFT12,意味着 4 KB 的页面大小。在 ARM64 上,选择 16 KB 或 64 KB 页面大小时,PAGE_SHIFT1416

根据我们对地址转换的理解,页表是一个部分解决方案。让我们看看为什么。大多数 32 位架构需要 32 位(4 字节)来表示一个页表项。在这种系统(32 位)中,每个进程都有其私有的 3 GB 用户地址空间,我们需要 786,432 个条目来表示并覆盖一个进程的地址空间。仅仅为了存储内存映射,所需的物理内存就过多。事实上,一个进程通常只会使用其虚拟地址空间中的一小部分,但这些部分是分散的。为了解决这个问题,引入了“层次”概念。页表通过层级(页级)进行分层。存储多级页表所需的空间仅依赖于实际使用的虚拟地址空间,而不是与虚拟地址空间的最大大小成比例。这样,未使用的内存不再被表示,页表遍历时间也得到了减少。此外,级别 N 中的每个表项将指向级别 N+1 中的一个条目,级别 1 是较高层次。

Linux 支持最多四级分页。然而,使用多少级别是与架构相关的。以下是每个级别的描述:

  • 内核中的pgd_t类型(通常是unsigned long)指向第二级表中的一个条目。在 Linux 内核中,struct task_struct表示一个进程的描述,它有一个成员(mm),该成员的类型是struct mm_struct,用于表征和表示进程的内存空间。在struct mm_struct中,有一个处理器特定的字段pgd,它是指向进程的一级(PGD)页表的第一个条目(条目 0)的指针。每个进程有且仅有一个 PGD,最多可以包含 1,024 个条目。

  • 页上级目录(PUD):表示间接映射的第二级。

  • 页中间目录(PMD):这是第三个间接映射级别。

  • pte_t,每个条目指向一个物理页。

    注意

    并非所有级别都被使用。i.MX6 的 MMU 仅支持二级页表(PGD 和 PTE),这对于几乎所有 32 位 CPU 都是如此。在这种情况下,PUD 和 PMD 被简单忽略。

重要的是要知道 MMU 不存储任何映射。它是一个位于 RAM 中的数据结构。而是 CPU 中有一个特殊的寄存器,称为pdg字段,指向struct mm_structcurrent->mm.pgd == TTBR0

在上下文切换时(当新进程被调度并分配 CPU 时),内核立即配置 MMU 并用新进程的pgd更新 PTBR。现在,当虚拟地址传给 MMU 时,MMU 会使用 PTBR 的内容定位到进程的一级页表(PGD),然后利用虚拟地址的最高有效位MSBs)提取的一级索引找到合适的表条目,该条目包含指向合适的二级页表基地址的指针。然后,从该基地址开始,MMU 使用二级索引查找合适的条目,依此类推,直到找到 PTE。ARM 架构(在我们的案例中是 i.MX6)有二级页表。在这种情况下,二级条目是 PTE,指向物理页(PFN)。此时,仅能找到物理页。为了访问页内的准确内存位置,MMU 会提取内存偏移量,这也是虚拟地址的一部分,并指向物理页中的相同偏移。

为了便于理解,前述描述仅限于二级分页方案,但可以轻松扩展。下图是此二级分页方案的表示:

图 10.8 – 二级地址转换方案

图 10.8 – 二级地址转换方案

当一个进程需要从某个内存位置读取或写入数据时(当然,我们讨论的是虚拟内存),MMU 会将该进程的页面表转换到正确的条目(PTE)。虚拟页面号会从虚拟地址中提取,并由处理器作为索引查找进程的页面表,以检索其页面表条目。如果在该偏移量处有有效的页面表条目,处理器将从此条目中获取页面帧号。如果没有,这意味着该进程访问了其虚拟内存中未映射的区域。此时会触发页面错误,操作系统应该处理此问题。

在实际情况中,地址转换需要页面表遍历,它不总是一次完成的操作。每个表级别至少需要一次内存访问。一个四级页面表将需要四次内存访问。换句话说,每次虚拟访问都会导致五次物理内存访问。如果虚拟内存的访问比物理访问慢四倍,那么虚拟内存的概念将毫无意义。幸运的是,系统级芯片(SoC)制造商努力找到了一种巧妙的技巧来解决这个性能问题:现代 CPU 使用一种称为翻译后备缓冲区TLB)的小型关联且非常快速的内存,用于缓存最近访问的虚拟页面的 PTE。

页面查找与 TLB

在 MMU 进行地址转换之前,还有另一个步骤。由于存在用于缓存最近访问数据的缓存,也有一个用于缓存最近翻译的地址的缓存。数据缓存加速了数据访问过程,TLB 加速了虚拟地址的转换(是的,地址转换是一个耗时的任务)。它是内容可寻址存储器CAM),其中关键字是虚拟地址,值是物理地址。换句话说,TLB 是 MMU 的一个缓存。在每次内存访问时,MMU 首先检查 TLB 中最近使用的页面,TLB 中包含一些虚拟地址范围,这些虚拟地址范围目前已分配给物理页面。

TLB 是如何工作的?

在内存访问时,CPU 遍历 TLB,尝试查找正在访问的页面的虚拟页面号。这个步骤叫做TLB 查找。当找到 TLB 条目(发生匹配)时,称为 TLB 命中,CPU 会继续运行,并使用在 TLB 条目中找到的 PFN 来计算目标物理地址。当发生 TLB 命时,不会发生页面错误。如果在 TLB 中找到翻译,虚拟内存访问的速度将和物理访问一样快。如果没有 TLB 命中,则称为 TLB 未命中。

在 TLB 未命中的情况下,有两种可能性。根据处理器类型,TLB 未命中事件可以由软件、硬件或通过 MMU 来处理:

  • 软件处理:CPU 触发 TLB 未命中中断,操作系统捕获该中断。操作系统随后遍历进程的页表以找到正确的 PTE。如果有匹配且有效的条目,CPU 将在 TLB 中安装新的转换。否则,将执行页面故障处理程序。

  • 硬件处理:由 CPU(实际上是 MMU)在硬件上遍历进程的页表。如果有匹配,CPU 将把新的转换添加到 TLB 中。否则,CPU 会触发页面故障中断,由操作系统处理。

在这两种情况下,页面故障处理程序都是相同的,do_page_fault()。该函数是架构相关的;对于 ARM,它在 arch/arm/mm/fault.c 中定义。

以下是描述 TLB 查找、TLB 命中或 TLB 未命中事件的示意图:

图 10.9 – MMU 和 TLB 遍历过程

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

图 10.9 – MMU 和 TLB 遍历过程

页表和页目录条目依赖于架构。操作系统必须确保表的结构与 MMU 识别的结构相匹配。在 ARM 处理器上,转换表的位置必须写入 control 协处理器 15(CP15) c2 寄存器,然后通过写入 CP15 c1 寄存器启用缓存和 MMU。详细信息请查看 infocenter.arm.com/help/index.jsp?topic=/com.arm.doc.dui0056d/BABHJIBH.htminfocenter.arm.com/help/index.jsp?topic=/com.arm.doc.ddi0433c/CIHFDBEJ.html

现在,既然我们已经掌握了地址转换机制及其与 TLB 的配合,我们可以讨论内存分配,这涉及到在幕后操作页表项。

处理内存分配机制及其 API

在进入 API 列表之前,我们先从以下图示开始,展示了在基于 Linux 的系统中存在的不同内存分配器,稍后我们将讨论这些分配器:

图 10.10 – 内核内存分配器概览

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

图 10.10 – 内核内存分配器概览

上述示意图的灵感来源于 bootlin.com/doc/training/linux-kernel/linux-kernel-slides.pdf。图中显示的是一种满足各种内存请求的分配机制。根据你的内存需求,你可以选择最接近目标的分配器。最基础的分配器是 kmalloc API。虽然 kmalloc 可以用来从 slab 分配器请求内存,但我们也可以直接与 slab 交互,从其缓存中请求内存,甚至构建我们自己的缓存。

让我们从内存分配的主分配器和最低级别分配器——页分配器开始,它是其他分配器的派生来源。

页分配器

页面分配器是 Linux 系统中的低级分配器,它作为其他分配器的基础。这个分配器带来了页面(虚拟)和页面框架(物理)的概念。因此,系统的物理内存被分割成固定大小的块(称为 struct page 结构,我们将使用专门的 API 来操作它,下一节中会介绍)。

页面分配 API

这是最低级的分配器。它使用伙伴算法分配和回收页面块。页面按 2 的幂大小分配(以获得伙伴算法的最佳效果)。这意味着它可以分配 1 个页面、2 个页面、4 个页面、8 个页面、16 个页面,依此类推。通过这种分配返回的页面是物理连续的。alloc_pages() 是主要的 API,其定义如下:

struct page *alloc_pages(gfp_t mask, unsigned int order)

上述函数在无法分配页面时返回 NULL。否则,它会分配 2 顺序的页面并返回指向 struct page 实例的指针,该指针指向预留块的第一个页面。然而,有一个帮助宏 alloc_page(),它可以用于分配单个页面。其定义如下:

#define alloc_page(gfp_mask) alloc_pages(gfp_mask, 0)

这个宏封装了 alloc_pages(),并将顺序参数设置为 0

__free_pages() 必须用于释放使用 alloc_pages() 函数分配的内存页面。它接受一个指向分配块第一个页面的指针作为参数,以及用于分配时相同的顺序。其定义如下:

void __free_pages(struct page *page, unsigned int order);

还有其他以相同方式工作的函数,但它们返回的是预留块的(逻辑)地址,而不是 struct page 的实例。这些是 __get_free_pages()__get_free_page(),它们的定义如下:

unsigned long __get_free_pages(gfp_t mask,
                               unsigned int order);
unsigned long get_zeroed_page(gfp_t mask);

free_pages() 用于释放使用 __get_free_pages() 分配的页面。它接受表示分配页面起始区域的内核地址,以及顺序,它应与用于分配时相同:

free_pages(unsigned long addr, unsigned int order);

无论分配类型是什么,mask 指定了应从哪些内存区域分配页面以及分配器的行为。以下是可能的值:

  • GFP_USER:用于用户内存分配。

  • GFP_KERNEL:用于内核分配的常用标志。

  • GFP_HIGHMEM:这会请求从 HIGH_MEM 区域分配内存。

  • GFP_ATOMIC:这以原子方式分配内存,不能进入睡眠状态。它在我们需要从中断上下文分配内存时使用。

然而,你应该注意,无论是否在 __get_free_pages()(或 __get_free_page())中指定 GFP_HIGHMEM 标志,它都不会被考虑。这些函数会屏蔽掉该标志,以确保返回的地址永远不会表示高内存页面(因为它们具有非线性/永久映射)。如果你需要高内存,使用 alloc_pages() 然后使用 kmap() 来访问它。

__free_pages()free_pages() 可以混合使用。它们之间的主要区别是 free_page() 接受一个逻辑地址作为参数,而 __free_page() 接受一个 struct page 结构。

注意

可用的最大阶数因架构不同而异。它取决于 FORCE_MAX_ZONEORDER 内核配置选项,默认值为 11。在这种情况下,您可以分配的页面数为 1,024。也就是说,在一个 4 KB 大小的系统上,您最多可以分配 1,024 x 4 KB = 4 MB 的内存。在 ARM64 上,最大阶数随所选页面大小而变化。如果是 16 KB 页面大小,最大阶数为 12,如果是 64 KB 页面大小,最大阶数为 14。这些每次分配的大小限制对于 kmalloc() 同样适用。

页面与地址转换函数

内核提供了一些便捷的函数,可以在 struct page 实例和它们对应的逻辑地址之间来回转换,这在处理内存时的不同阶段都非常有用。page_to_virt() 函数用于将一个 struct page(例如,alloc_pages() 返回的)转换为内核逻辑地址。或者,virt_to_page() 接受一个内核逻辑地址并返回其关联的 struct page 实例(就像是使用 alloc_pages() 函数分配的那样)。virt_to_page()page_to_virt() 都在 <asm/page.h> 中声明,具体如下:

struct page *virt_to_page(void *kaddr);
void *page_to_virt(struct page *pg)

还有一个宏 page_address(),它简单地封装了 page_to_virt(),其声明如下:

void *page_address(const struct page *page)

它返回传入参数的页面的逻辑地址。

slab 分配器

slab 分配器是 kmalloc() 所依赖的分配器。它的主要目的是消除由于内存(释放)分配造成的碎片,这种碎片由伙伴系统在小尺寸内存分配时产生,并加速常用对象的内存分配。

理解伙伴算法

为了分配内存,所请求的大小会向上舍入到二的幂,伙伴分配器会搜索相应的列表。如果请求的列表中没有条目,来自下一个较大列表(该列表中的块是上一个列表大小的两倍)的一项会被拆分成两半(称为 buddies)。分配器使用第一半,而另一半会被添加到下一个较小的列表中。这是一个递归的过程,直到伙伴分配器成功找到一个可以拆分的块,或者达到最大块大小并且没有可用的空闲块为止。

以下案例研究受到了 dysphoria.net/OperatingSystems1/4_allocation_buddy_system.html 的深刻启发。例如,如果最小分配大小为 1K 字节,且内存大小为 1MB,则伙伴分配器将为 1K 字节、2K 字节、4K 字节、8K 字节、16K 字节、32K 字节、64K 字节、128K 字节、256K 字节、512K 字节、1MB 字节的空闲块分别创建一个空列表。除了 1MB 列表外,所有列表最初都是空的,1MB 列表只有一个空洞。

现在假设我们要分配一个 70K 的块,伙伴分配器会将其向上舍入到 128K,并最终将 1MB 分割为两个 512K 块,然后是 256K,最后是 128K,然后它会将其中一个 128K 块分配给用户。以下是总结这个场景的方案:

图 10.11 – 使用伙伴算法分配

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

图 10.11 – 使用伙伴算法分配

释放速度与分配速度一样快。以下是总结释放算法的图示:

图 10.12 – 使用伙伴算法释放

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

图 10.12 – 使用伙伴算法释放

在上图中,我们可以看到使用伙伴算法释放内存的过程。下一节我们将研究建立在此算法之上的 slab 分配器。

走进 slab 分配器

在介绍 slab 分配器之前,首先定义它使用的一些术语:

  • inodemutexe 对象。一个 slab 可以看作是一个大小相同的块的数组。

  • inode 对象仅限于此。

Slabs 可能处于以下几种状态:

  • :表示 slabs 上的所有对象(块)都标记为可用。

  • 部分使用:slab 中既有已用对象也有空闲对象。

  • :表示 slabs 上的所有对象都标记为已用。

内存分配器负责构建缓存。最初,每个 slab 都是空的并标记为“空”。当为内核对象分配内存时,分配器会在该类型对象的缓存中查找一个空闲位置。如果未找到,分配器将分配一个新的 slab 并将其添加到缓存中。新对象会从该 slab 中分配,且该 slab 会被标记为“部分使用”。当代码完成对内存的使用(内存释放)后,对象会被简单地返回到 slab 缓存中,并恢复到初始化状态。这也是内核提供帮助函数来获取已清零初始化内存的原因,这样我们可以消除先前的内容。slab 会保持其对象的引用计数,以便在缓存中的所有 slabs 都满了且需要请求另一个对象时,slab 分配器负责添加新的 slabs。

以下图示说明了 slabs、缓存及其不同状态的概念:

图 10.13 – Slabs 和缓存

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

图 10.13 – Slabs 和缓存

有点像创建一个每个对象的分配器。内核为每种类型的对象分配一个缓存,并且只有相同类型的对象可以存储在缓存中(例如,只有task_struct结构体)。

内核中有不同种类的 slab 分配器,具体取决于是否需要紧凑性、缓存友好性或原始速度。它们包括以下几种:

  • SLAB(slab 分配器),它是尽可能缓存友好的。这是最初的内存分配器。

  • SLOB(简单块列表),它尽可能紧凑,适用于内存非常低的系统,主要是嵌入式系统,内存为几兆字节或几十兆字节。

  • CONFIG_SLUB=y)。请查看此补丁:git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=a0acd820807680d2ccc4ef3448387fcdbf152c73

    注意

    slab 这个术语已经成为一种通用名称,指代一种使用对象缓存的内存分配策略,能够高效地分配和释放内核对象。它不应与同名的分配器 SLAB 混淆,后者如今已经被 SLUB 取代。

kmalloc 家族分配

kmalloc()是一个内核内存分配函数。它分配物理上连续的(但不一定是页面对齐的)内存。下图描述了内存如何分配并返回给调用者:

图 10.14 – kmalloc 内存组织

图 10.14 – kmalloc 内存组织

这个分配 API 是内核中通用的最高级别内存分配 API,它依赖于 SLAB 分配器。kmalloc()返回的内存具有内核逻辑地址,因为它是从LOW_MEM区域分配的,除非指定了HIGH_MEM。它在 <linux/slab.h> 中声明,这是在使用 API 之前需要包含的头文件。定义如下:

void *kmalloc(size_t size, int flags);

在前面的代码中,size指定要分配的内存大小(以字节为单位)。flags决定了内存应该如何和在哪里分配。可用的标志与页面分配器相同(GFP_KERNELGFP_ATOMICGFP_DMA 等),以下是它们的定义:

  • GFP_KERNEL:这是标准标志。我们不能在中断处理程序中使用这个标志,因为它的代码可能会休眠。它总是从LOM_MEM区域返回内存(因此,是一个逻辑地址)。

  • GFP_ATOMIC:这保证了分配的原子性。该标志用于在中断上下文中需要分配内存时。由于内存是从紧急池或内存中分配的,因此不应滥用此标志。

  • GFP_USER:这为用户空间进程分配内存。分配的内存与分配给内核的内存是分开的。

  • GFP_NOWAIT:如果分配是在原子上下文中进行的,例如中断处理程序使用时,应使用此标志。此标志会在分配时防止直接回收、I/O 和文件系统操作。与 GFP_ATOMIC 不同,它不使用内存预留。因此,在内存紧张时,GFP_NOWAIT 的分配可能会失败。

  • GFP_NOIO:与 GFP_USER 类似,这可能会阻塞,但与 GFP_USER 不同,它不会启动磁盘 I/O。换句话说,它在分配内存时会阻止任何 I/O 操作。此标志主要用于块设备/磁盘层。

  • GFP_NOFS:这将使用直接回收,但不会使用任何文件系统接口。

  • __GFP_NOFAIL:虚拟内存实现必须无限期重试,因为调用者无法处理分配失败。分配可能会一直阻塞,但永远不会失败。因此,测试是否失败是没有意义的。

  • GFP_HIGHUSER:请求从 HIGH_MEMORY 区域分配内存。

  • GFP_DMA:从 DMA_ZONE 分配内存。

在成功分配内存后,kmalloc() 返回分配块的虚拟(逻辑,除非指定了高内存)地址,并保证该内存是物理连续的。如果发生错误,它将返回 NULL

对于设备驱动程序,建议使用托管版本 devm_kmalloc(),它不一定需要释放内存,因为内存管理由内存核心内部处理。以下是其原型:

void *devm_kmalloc(struct device *dev, size_t size,
                   gfp_t gfp);

在前面的原型中,dev 是为其分配内存的设备。

注意,kmalloc() 在分配小内存时依赖于 SLAB 缓存。为此,它可能会将分配区域的大小内部向上舍入到可以容纳该内存的最小 SLAB 缓存的大小。这可能会导致返回的内存量超过请求的内存。然而,可以使用 ksize() 来确定实际分配的内存量(以字节为单位)。即使最初通过 kmalloc() 调用指定了较小的内存量,你仍然可以使用这部分额外的内存。

以下是 ksize 原型:

size_t ksize(const void *objp);

在前面的例子中,objp 是将返回其实际字节大小的对象。

kmalloc()具有与页面相关的分配 API 相同的大小限制。例如,默认情况下将 FORCE_MAX_ZONEORDER 设置为 11,则每次通过 kmalloc() 分配的最大大小为 4 MB

kfree 函数用于释放由 kmalloc() 分配的内存。它的定义如下:

void kfree(const void *ptr)

以下是使用 kmalloc()kfree() 分别分配和释放内存的示例:

#include <linux/init.h>
#include <linux/module.h>
#include <linux/slab.h>
#include <linux/mm.h>
static void *ptr; 
static int alloc_init(void) 
{
    size_t size = 1024; /* allocate 1024 bytes */ 
    ptr = kmalloc(size,GFP_KERNEL); 
    if(!ptr) {
        /* handle error */
        pr_err("memory allocation failed\n"); 
        return -ENOMEM; 
    } else {
        pr_info("Memory allocated successfully\n"); 
    }
    return 0;
}
static void alloc_exit(void)
{
    kfree(ptr); 
    pr_info("Memory freed\n"); 
}
module_init(alloc_init); 
module_exit(alloc_exit);
MODULE_LICENSE("GPL"); 
MODULE_AUTHOR("John Madieu");

内核提供了基于 kmalloc() 的其他辅助函数,如下所示:

void kzalloc(size_t size, gfp_t flags);
void kzfree(const void *p);
void *kcalloc(size_t n, size_t size, gfp_t flags);
void *krealloc(const void *p, size_t new_size,
                gfp_t flags);

krealloc() 是内核空间中与用户空间 realloc() 函数相对应的函数。由于 kmalloc() 返回的内存保留了其先前状态的内容,因此可以通过 kzalloc() 请求一块已初始化为零的 kmalloc 分配内存。kzfree() 是释放 kzalloc() 分配内存的函数,而 kcalloc() 用于为数组分配内存,其 nsize 参数分别表示数组中元素的数量和每个元素的大小。

由于 kmalloc() 返回的是内核永久映射中的内存区域,因此可以使用 virt_to_phys() 将逻辑地址转换为物理地址,或者使用 virt_to_bus() 转换为 I/O 总线地址。这些宏内部会调用 __pa()__va(),如果有必要的话。物理地址(virt_to_phys(kmalloc'ed address))右移 PAGE_SHIFT 后,将生成从中分配内存块的第一个页面的 PFN(pfn)。

vmalloc 系列分配

vmalloc() 是我们在本书中讨论的最后一个内核分配器。它返回的内存在虚拟地址空间中是唯一连续的。底层的内存帧是分散的,如下图所示:

图 10.15 – vmalloc 内存组织

图 10.15 – vmalloc 内存组织

在前面的示意图中,我们可以看到内存并非物理连续的。此外,vmalloc() 返回的内存总是来自 HIGH_MEM 区域。返回的地址是纯粹的虚拟地址(而非逻辑地址),不能转换为物理地址或总线地址,因为无法保证背后的内存是物理连续的。这意味着 vmalloc() 返回的内存不能在微处理器之外使用(例如,不能轻易用于 DMA 目的)。使用 vmalloc() 为大量页面(例如,单独分配一个页面没有意义)分配内存是正确的,它们仅存在于软件中,如网络缓冲区。需要注意的是,vmalloc() 的速度比 kmalloc() 和页面分配器函数慢,因为它既要检索内存,又要构建页表,甚至可能需要重新映射到虚拟连续的范围,而 kmalloc() 从不做这些操作。

在使用 vmalloc() API 之前,你应当包含以下头文件:

#include <linux/vmalloc.h>

以下是 vmalloc 系列的原型:

void *vmalloc(unsigned long size);
void *vzalloc(unsigned long size);
void vfree(void *addr);

在上述原型中,参数 size 是你需要分配的内存大小。成功分配内存后,它返回分配内存块的第一个字节的地址。失败时,返回 NULLvfree() 执行反向操作,释放 vmalloc() 分配的内存。vzalloc 变体返回已初始化为零的内存。

以下是使用 vmalloc 的示例:

#include<linux/init.h>
#include<linux/module.h>
#include <linux/vmalloc.h>
Static void *ptr;
static int alloc_init(void)
{
    unsigned long size = 8192; /* 2 x 4KB */
    ptr = vmalloc(size);
    if(!ptr)
    {
        /* handle error */
        pr_err("memory allocation failed\n");
        return -ENOMEM;
    } else {
        pr_info("Memory allocated successfully\n");
    }
    return 0;
}
static void my_vmalloc_exit(void)
{
    vfree(ptr);
    pr_info("Memory freed\n");
}
module_init(my_vmalloc_init);
module_exit(my_vmalloc_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("john Madieu, john.madieu@gmail.com");

vmalloc()将分配不连续的物理页面,并将它们映射到一个连续的虚拟地址区域。这些vmalloc虚拟地址的限制在内核空间的一个区域内,由VMALLOC_STARTVMALLOC_END定义,这些是与架构相关的。内核通过暴露/proc/vmallocinfo来显示系统上所有vmalloc分配的内存。

进程内存分配背后的短故事

vmalloc()偏好使用HIGH_MEM区域(如果存在),该区域适用于进程,因为它们需要隐式和动态映射。然而,由于内存是有限资源,内核只有在必要时(通过读或写访问时)才会报告分配帧页面(物理页面)。这种按需分配被称为懒分配,它消除了分配永远不会使用的页面的风险。

每当请求页面时,仅更新页表;在大多数情况下,会创建一个新条目,这意味着只分配了虚拟内存。只有当用户访问页面时,才会触发一个名为page fault的中断。这个中断有一个专用的处理程序,叫做page fault handler,由 MMU 在尝试访问未立即成功的虚拟内存时调用。

实际上,当访问类型是(读、写或执行)时,只要页面在页表中没有设置适当的权限位来允许该类型的访问,都会触发页面错误中断。对该中断的响应分为以下三种方式之一:

  • 硬错误:当页面不在任何地方(既不在物理内存中,也不在内存映射文件中)时,意味着处理程序无法立即解决该错误。处理程序将执行 I/O 操作,以准备解决该错误所需的物理页面,并可能暂停被中断的进程,切换到其他进程,直到系统解决问题。

  • 软错误:当页面存在于内存的其他地方(另一个进程的工作集内)时,意味着错误处理程序可以通过立即将物理内存页面附加到适当的页表项,调整该项,并恢复中断的指令来解决错误。

  • 无法解决的错误:这将导致总线错误或段错误segv)。一个段错误信号SIGSEGV)会发送到故障进程,终止它(默认行为),除非已为 SIGSEV 安装了信号处理程序,以更改默认行为。

总结来说,内存映射通常一开始不会附加物理页面,只有通过定义虚拟地址范围而没有关联的物理内存。实际的物理内存是在内存访问时,通过页面错误异常动态分配的,因为内核提供了一些标志来判断尝试访问是否合法,并指定页面错误处理程序的行为。因此,brk() 用户空间、mmap() 和类似的函数会分配(虚拟)空间,但物理内存会在稍后附加。

注意

在中断上下文中发生页面错误会导致双重错误中断,通常会让内核崩溃(调用 panic() 函数)。这就是为什么在中断上下文中分配的内存来自于一个内存池的原因,因为该内存池不会引发页面错误中断。如果在处理双重错误时发生中断,将生成三重错误异常,导致 CPU 关闭并且操作系统立即重启。此行为依赖于架构。

写时复制情况

让我们考虑一个需要两个或多个任务共享的内存区域或数据。fork() 系统调用是一种机制,它允许操作系统不会立即分配内存,也不会将内存复制到每个共享数据的任务中,直到其中一个任务修改(写入)它——在这种情况下,为它分配内存以便拥有其私有副本(因此得名,写时复制)。接下来我们以一个共享内存页面为例,描述 page fault handler 如何管理写时复制:

  1. 当页面需要共享时,将为每个访问共享页面的进程的页面表添加一个指向此共享页面的页面表项(PTE),并且该 PTE 目标标记为不可写。这是一个初始映射。

  2. 映射将导致为每个进程创建一个 VMA,并将其添加到每个进程的 VMA 列表中。共享页面会与这些 VMA 关联(即,之前为每个进程创建的 VMA),并且这次标记为可写。只要没有进程尝试修改共享页面的内容,就不会发生其他事情。

  3. 当其中一个进程尝试写入共享页面(第一次写入时),fault handler 会注意到 PTE 标志(之前标记为不可写)和 VMA 标志(标记为可写)之间的区别,这意味着,“嘿,这是写时复制。” 然后它将分配一个物理页面,并将其分配给先前添加的 PTE(从而替换先前分配的共享页面),更新 PTE 标志(这些标志之一将标记该 PTE 为可写),刷新 TLB 条目,接着执行 do_wp_page() 函数,将共享地址中的内容复制到新的位置,该位置是进程私有的。此进程的后续写入将写入私有副本,而不是共享页面。

现在我们可以结束关于进程内存分配的部分内容,我们已经对其有了熟悉的了解。我们也学习了延迟分配机制以及什么是 CoW(写时复制)。我们也可以总结关于内核内存分配的学习内容。此时,我们可以转向 I/O 内存操作,开始与硬件设备进行交互。

使用 I/O 内存与硬件进行通信

到目前为止,我们主要处理了主内存,通常将内存视为 RAM。但需要注意,RAM 只是众多外围设备中的一种,它的内存范围对应于其大小。RAM 的独特之处在于,它完全由内核管理,对用户透明。RAM 控制器连接到 CPU 的数据/控制/地址总线,并与其他设备共享这些总线。这些设备被称为内存映射设备,因为它们在这些总线上的局部性,和与这些设备的通信(输入/输出操作)被称为内存映射 I/O。这些设备包括 CPU 提供的各种总线控制器(如 USB、UART、SPI、I2C、PCI 和 SATA),以及一些 IP,如 VPU、GPU、图像处理单元(IPU)和安全非易失性存储(SNVS,NXP 的 i.MX 芯片中的功能)。

在 32 位系统中,CPU 最多可以选择 232 个内存位置(从00xFFFFFFFF)。问题是,并非所有这些地址都指向 RAM。一些地址是为外围设备访问保留的,称为 I/O 内存。这些 I/O 内存被划分为不同大小的范围,并分配给这些外围设备,以便每当 CPU 收到来自内核的物理内存访问请求时,它可以将该请求路由到其地址范围包含指定物理地址的设备。分配给每个设备(包括 RAM 控制器)的地址范围通常在 SoC 数据手册中描述,通常会有一个叫做内存映射的部分。

由于内核仅使用虚拟地址(通过页表),访问任何设备的特定地址都需要先将该地址映射(如果存在 IOMMU,即 I/O 设备的 MMU 等效物,这一点尤其适用)。这种将 RAM 模块之外的内存地址映射会在系统地址空间中产生一个经典的空洞(因为地址空间在内存和 I/O 之间共享)。

下图描述了 I/O 内存和主内存在 CPU 中的视图:

图 10.16 – (IO)MMU 和主内存概述

图 10.16 – (IO)MMU 和主内存概述

注意

始终记住,CPU 通过 MMU 的视角来看主内存(RAM),而通过 IOMMU 的视角来看设备。

这样做的主要优点是,传输数据到内存和 I/O 所使用的指令相同,这减少了软件编码的逻辑。然而,也有一些缺点。第一个缺点是,必须为每个设备完全解码整个地址总线,这增加了为机器添加硬件的成本,导致架构复杂。

另一个不便之处是,在 32 位系统上,即使安装了 4 GB 的内存,操作系统也永远不会使用整个大小,因为内存映射设备所造成的地址空间空洞,导致部分地址空间被占用。x86 架构采用了另一种方法,称为 inout(通常在汇编中使用)。在这种情况下,设备寄存器不是内存映射的,系统可以访问整个 RAM 的地址范围。

PIO 设备访问

在使用 PIO 的系统中,I/O 设备被映射到一个独立的地址空间。通常通过使用不同的信号线来区分内存访问和设备访问。这类系统有两个不同的地址空间,一个是系统内存的地址空间,我们之前讨论过,另一个是 I/O 端口的地址空间,有时被称为端口地址空间,最多支持 65,536 个端口。这是一种老方法,现在已经很少见。

内核导出了一些函数(符号)来处理 I/O 端口。在访问任何端口区域之前,我们必须首先通知内核我们正在使用一个端口范围,使用 request_region() 函数,如果出错将返回 NULL。完成该区域的使用后,我们必须调用 release_region()。这两个函数都在 linux/ioport.h 中声明,如下所示:

struct ressource *request_region(unsigned long start,
                         unsigned long len, char *name);
void release_region(unsigned long start,
                         unsigned long len);

这些是礼貌函数,通知内核你打算使用/释放从 start 开始的 len 端口区域。name 参数应该设置为设备的名称或有意义的名称。不过,它们的使用并不是强制的。它可以防止两个或更多驱动程序引用相同的端口范围。你可以通过读取 /proc/ioports 文件的内容来查看系统上当前正在使用的端口。

在区域预留成功后,可以使用以下 API 来访问端口:

u8 inb(unsigned long addr)
u16 inw(unsigned long addr)
u32 inl(unsigned long addr)

上述函数分别从 addr 端口读取 8、16 或 32 位(宽)数据。其写入变体定义如下:

void outb(u8 b, unsigned long addr)
void outw(u16 b, unsigned long addr)
void outl(u32 b, unsigned long addr)

上述函数将 b 数据写入 addr 端口,数据可以是 8、16 或 32 位大小。

PIO 使用一组不同的指令来访问 I/O 端口或 MMIO,这是一种劣势,因为它比普通内存操作需要更多的指令来完成相同的任务。例如,MMIO 中 1 位测试只需要一条指令,而 PIO 则需要先将数据读取到寄存器中,然后再测试位,这需要超过一条指令。PIO 的一个优点是,它解码地址所需的逻辑较少,降低了添加硬件设备的成本。

MMIO 设备访问

主内存地址与 MMIO 地址位于相同的地址空间。内核将设备寄存器映射到原本应该由 RAM 使用的一部分地址空间,从而实现 I/O 设备寄存器的访问。因此,与 I/O 设备进行通信类似于对分配给该设备的内存地址进行读写操作。

如果我们需要访问例如分配给 IPU-2 的 4 MB I/O 内存(从0x024000000x027fffff),CPU(通过 IOMMU)可以分配给我们0x100000000x103FFFFF的虚拟地址。当然,这不会消耗物理 RAM(除非用于构建和存储页表项),只是地址空间(你现在明白为什么 32 位系统在扩展卡,如具有 GB 内存的高端 GPU 时会遇到问题了吗?),意味着内核将不再使用这个虚拟内存范围来映射 RAM。现在,写入/读取内存,如0x10000004,将被路由到 IPU-2 设备。这就是内存映射 I/O 的基本前提。

与 PIO 类似,MMIO 函数用于通知内核我们打算使用某个内存区域。请记住,这些信息只是一个纯粹的预留。它们是request_mem_region()release_mem_region(),定义如下:

struct ressource* request_mem_region(unsigned long start,
                           unsigned long len, char *name)
void release_mem_region(unsigned long start, 
                        unsigned long len)

这些只是礼貌性函数,前者构建并返回一个合适的resource结构,表示内存区域的起始地址和长度,后者则释放它。

然而,对于设备驱动程序,推荐使用管理版本,因为它简化了代码并处理了资源的释放。该管理版本定义如下:

struct ressource* devm_request_region(
               struct device *dev, resource_size_t start,
               resource_size_t n, const char *name);

在前面的代码中,dev是拥有内存区域的设备,其他参数与非管理版本相同。成功请求后,内存区域将显示在/proc/iomem中,这是一个包含系统内存区域使用情况的文件。

在访问一个内存区域之前(并且在成功请求后),该区域必须通过调用特定的与架构相关的函数映射到内核地址空间中(这些函数利用 IOMMU 构建页表,因此不能从中断处理程序中调用)。这些函数是ioremap()iounmap(),它们也处理缓存一致性。以下是它们的定义:

void __iomem *ioremap(unsigned long phys_addr,
                      unsigned long size);
void iounmap(void __iomem *addr);

在前面的函数中,phys_addr对应设备在设备树或板文件中指定的物理地址。size对应映射区域的大小。ioremap()返回一个指向映射区域起始位置的__iomem void指针。再次建议使用管理版本,定义如下:

void __iomem *devm_ioremap(struct device *dev,
                           resource_size_t offset,
                           resource_size_t size);

注意

ioremap()构建新的页表,就像vmalloc()一样。然而,它并不实际分配任何内存,而是返回一个特殊的虚拟地址,用于访问指定的 I/O 地址。在 32 位系统中,MMIO 通过窃取物理内存地址空间为内存映射 I/O 设备创建映射,这是一个缺点,因为它会阻止系统将被窃取的内存用于一般的 RAM 用途。

由于映射 API 是架构相关的,你不应解除引用这些指针(即通过读取/写入指针值来获取/设置其值),即使在某些架构上可以这样做。内核提供了可移植的函数来访问内存映射区域。这些函数包括:

unsigned int ioread8(void __iomem *addr);
unsigned int ioread16(void __iomem *addr);
unsigned int ioread32(void __iomem *addr);
void iowrite8(u8 value, void __iomem *addr);
void iowrite16(u16 value, void __iomem *addr);
void iowrite32(u32 value, void __iomem *addr);

前述函数分别用于读取和写入 8 位、16 位和 32 位的值。

注意

__iomem 是一个内核标记,供 Sparse 使用,Sparse 是一个内核的语义检查工具,用于发现可能的编码错误。它防止将普通指针(例如解除引用)与 I/O 内存指针混用。

在本节中,我们学习了如何通过专用的 API 将内存映射的设备内存映射到内核地址空间,以访问其寄存器。这将有助于驱动片上设备。

内存(重新)映射

内核内存有时需要重新映射,要么从内核映射到用户空间,要么从高内存映射到低内存区域(从内核空间到内核空间)。常见的情况是将内核内存重新映射到用户空间,但也有其他情况,例如当我们需要访问高内存时。

理解 kmap() 的使用

Linux 内核将其地址空间的 896 MB 永久映射到物理内存的下部 896 MB(低内存)。在一个 4 GB 的系统上,内核只能映射剩余的 3.2 GB 物理内存(高内存),而剩下的只有 128 MB。不过,由于低内存具有永久且一对一的映射,内核可以直接访问它。对于高内存(896 MB 之前的内存),内核必须将请求的高内存区域映射到其地址空间,而前面提到的 128 MB 就是专门为此预留的。执行这个操作的函数是 kmap()kmap() 函数用于将给定页面映射到内核地址空间。

void *kmap(struct page *page);

page 是一个指向 struct page 结构的指针,用于映射。当高内存页面被分配时,它不能直接访问。我们调用 kmap() 函数将高内存临时映射到内核地址空间。该映射将持续到调用 kunmap() 为止:

void kunmap(struct page *page);

临时是指映射在不再需要时应立即撤销。最佳的编程实践是,当高内存映射不再需要时,取消映射。

这个函数适用于高内存和低内存。然而,如果页面结构位于低内存中,则仅返回页面的虚拟地址(因为低内存页面已经有了永久映射)。如果页面属于高内存,则会在内核的页表中创建永久映射,并返回地址:

void *kmap(struct page *page)
{
    BUG_ON(in_interrupt());
    if (!PageHighMem(page))
        return page_address(page);
    return kmap_high(page);
}

kmap_high()kunmap_high(),它们定义在mm/highmem.c中,是这些实现的核心。然而,kmap()使用在启动时分配的物理连续页表将页面映射到内核空间。由于这些页表是相互连接的,因此移动起来非常简单,无需一直查询页目录。你应该注意,kmap页表对应于以PKMAP BASE开头的内核虚拟地址,这在不同架构中有所不同,且它的页表项引用计数保存在一个名为pkmap_count的独立数组中。

要映射到内核空间的页面框架作为struct *page参数传递给kmap(),这可以是普通页或HIGHMEM页;在第一种情况下,kmap()直接返回直接映射的地址。对于HIGHMEM页,kmap()会在启动时分配的kmap页表中查找一个未使用的条目——即pkmap_count值为零的条目。如果没有找到,它会进入睡眠状态,等待另一个进程kunmap一个页面。当它找到未使用的条目时,它会插入我们要映射的物理页地址,并同时递增对应页表项的pkmap_count引用计数,然后将虚拟地址返回给调用者。页面结构体的page->virtual也会更新,以反映映射的地址。

kunmap()接收一个struct page*,表示要解除映射的页面。它查找该页面虚拟地址的pkmap_count条目,并对其进行递减操作。

将内核内存映射到用户空间

映射物理地址是最常见的操作之一,尤其是在嵌入式系统中。有时,你可能希望将部分内核内存共享给用户空间。如前所述,CPU 在用户空间运行时处于非特权模式。为了让一个进程访问内核内存区域,我们需要将该区域重新映射到进程地址空间。

使用 remap_pfn_range

remap_pfn_range()通过 VMA 将物理连续内存映射到进程地址空间。它对于实现mmap文件操作非常有用,mmap()系统调用的后台就是基于这个操作。

在给定区域的起始位置和长度后,调用mmap()系统调用时,CPU 将切换到特权模式。初始内核代码将创建一个几乎为空的 VMA,其大小与请求的映射区域相同,并运行相应的file_operations.mmap回调,将 VMA 作为参数传递。然后,此回调应调用remap_pfn_range()。该函数将更新 VMA,并在将其添加到进程的页表之前推导出映射区域的内核 PTE,并设置不同的保护标志。当然,进程的 VMA 列表将通过插入 VMA 条目(具有适当的属性)进行更新,使用推导出的 PTE 来访问相同的内存。通过这种方式,内核和用户空间将通过各自的页表指向相同的物理内存区域,但具有不同的保护标志。因此,内核只是复制了 PTE,每个 PTE 都有自己的属性,而不是通过复制浪费内存和 CPU 周期。

remap_pfn_range()定义如下:

int remap_pfn_range(struct vm_area_struct *vma,
                    unsigned long addr,
                    unsigned long pfn,
                    unsigned long size, pgprot_t flags);

成功调用将返回0,失败时返回负错误码。此函数的大部分参数是在调用mmap()系统调用时提供的。以下是它们的描述:

  • vma:这是内核在调用file_operations.mmap时提供的虚拟内存区域。它对应于用户进程的 VMA,映射应该在其中进行。

  • addr:这是 VMA 应开始映射的用户(虚拟)地址(大多数情况下是vma->vm_start)。它将导致从addraddr + size的映射。

  • pfn:这表示要映射的物理内存区域的页帧号。要获得这个页帧号,我们必须考虑内存分配是如何执行的:

    • 对于使用kmalloc()或任何返回内核逻辑地址的分配 API(例如,使用GFP_KERNEL标志的__get_free_pages())分配的内存,可以通过以下方式获取pfn(获取物理地址并将此地址右移PAGE_SHIFT次):

          unsigned long pfn =
            virt_to_phys((void *)kmalloc_area)>>PAGE_SHIFT; 
      
    • 对于使用alloc_pages()分配的内存,我们可以使用以下方法(其中page是分配时返回的指针):

          unsigned long pfn = page_to_pfn(page)
      
    • 最后,对于使用vmalloc()分配的内存,可以使用以下方法:

          unsigned long pfn = vmalloc_to_pfn(vmalloc_area);
      
  • size:这是要重新映射区域的大小(以字节为单位)。如果它不是页对齐的,内核将自动将其对齐到(下一个)页边界。

  • flags:这表示为新的虚拟内存区域(VMA)请求的保护。驱动程序可以更改最终的值,但应使用初始的默认值(位于vma->vm_page_prot中)作为框架,通过按位“或”操作符(C 语言中的|)来处理。这些默认值是由用户空间设置的。以下是一些标志的例子:

    • VM_IO,它指定了一个设备的内存映射 I/O。

    • VM_PFNMAP,用于指定一个没有管理 struct page 的页面范围,仅使用原始的 PFN。这通常用于 I/O 内存映射。换句话说,这意味着基础页面只是原始的 PFN 映射,而没有与之关联的 struct page

    • VM_DONTCOPY,告诉内核在进行进程分叉时不要复制此虚拟内存区域(VMA)。

    • VM_DONTEXPAND,防止 VMA 在使用 mremap() 时扩展。

    • VM_DONTDUMP,防止 VMA 被包含在核心转储中,即使 VM_IO 被关闭。

内存映射需要内存区域的大小是 PAGE_SIZE 的倍数。例如,你应该分配整页,而不是使用 kmalloc 分配的缓冲区。kmalloc() 如果请求的大小不是 PAGE_SIZE 的倍数,可能返回一个没有页对齐的指针,这时使用这样一个未对齐的地址进行 remap_pfn_range() 映射是非常糟糕的主意。没有任何保障能够确保 kmalloc() 返回的地址是页对齐的,因此你可能会破坏内核中的 slab 内部数据结构。你应该使用 kmalloc(PAGE_SIZE * npages),或者更好地,使用页分配 API(或类似的,因为这些函数始终返回页对齐的指针)。

如果你的存储对象(文件或设备)支持偏移量,那么应该考虑 VMA 偏移量(映射必须开始的位置偏移)来计算映射开始的物理页号(PFN)。vma->vm_pgoff 将包含此偏移量(如果用户空间在 mmap() 中指定)值,单位是页数。最终的 PFN 计算(或映射起始位置)将如下所示:

unsigned long pos
unsigned long off = vma->vm_pgoff;
/*compute the initial PFN according to the memory area */
[...]
/* Then compute the final position */
pos = pfn + off
[...]
return remap_pfn_range(vma, vma->vm_start,
        pos, vma->vm_end - vma->vm_start, 
         vma->vm_page_prot);

在前面的摘录中,偏移量(以页数为单位)已经包含在最终位置计算中。然而,如果驱动程序实现不需要其支持,可以忽略此偏移量。

注意

偏移量也可以通过左移 PAGE_SIZE 来计算字节数偏移量(offset = vma->vm_pgoff << PAGE_SHIFT),然后在计算最终 PFN 前,将该偏移量加到内存起始地址(pfn = virt_to_phys(kmalloc_area + offset) >> PAGE_SHIFT)。

重新映射由 vmalloc 分配的页面

请注意,使用 vmalloc() 分配的内存不是物理连续的,因此,如果你需要映射使用 vmalloc() 分配的内存区域,必须单独映射每个页面并为每个页面计算物理地址。这可以通过遍历该 vmalloc 分配的内存区域中的所有页面,并按照以下方式调用 remap_pfn_range() 来实现:

while (length > 0) {
    pfn = vmalloc_to_pfn(vmalloc_area_ptr);
    if ((ret = remap_pfn_range(vma, start, pfn, 
      PAGE_SIZE, PAGE_SHARED)) < 0) {
        return ret;
    }
    start += PAGE_SIZE;
    vmalloc_area_ptr += PAGE_SIZE;
    length -= PAGE_SIZE;
}

在前面的摘录中,length 对应于 VMA 大小(length = vma->vm_end - vma->vm_start)。pfn 会为每个页面进行计算,下一次映射的起始地址会增加 PAGE_SIZE,以便映射该区域中的下一个页面。start 的初始值是 start = vma->vm_start

也就是说,在内核内部,vmalloc 分配的内存可以正常使用。分页使用仅在重新映射时需要。

重新映射 I/O 内存

重新映射 I/O 内存需要设备的物理地址,这些地址通常在设备树或板文件中指定。在这种情况下,为了移植性,应该使用的函数是 io_remap_pfn_range(),其参数与 remap_pfn_range() 相同。唯一不同的是 PFN 的来源。其原型如下所示:

int io_remap_page_range(struct vm_area_struct *vma,
                     unsigned long start,
                     unsigned long phys_pfn,
                     unsigned long size, pgprot_t flags);

在前面的函数中,vmastartremap_pfn_range() 中的意义相同。然而,phys_pfn 在获取方式上有所不同;它必须对应物理 I/O 内存地址,因为它已经被传递给了 ioremap(),并右移了 PAGE_SHIFT 次。

然而,对于常见的驱动程序使用,有一个简化版的 io_remap_pfn_range()vm_iomap_memory()。这个简化版定义如下:

int vm_iomap_memory(struct vm_area_struct *vma,
                    phys_addr_t start, unsigned long len)

在前面的函数中,vma 是用户 VMA(虚拟内存区域)进行映射的位置。start 是要映射的 I/O 内存区域的起始地址(它本应已传递给 ioremap()),len 是该区域的大小。通过 vm_iomap_memory(),驱动程序只需要提供要映射的物理内存范围;该函数会从 vma 信息中推导出其余部分。与 io_remap_pfn_range() 一样,函数在成功时返回 0,否则返回负错误代码。

内存重新映射和缓存问题

尽管缓存通常是一个好主意,但它可能会引入副作用,尤其是对于内存映射设备(甚至是 RAM)来说,当写入 mmap 映射的寄存器的值必须立即对设备可见时,缓存可能会导致问题。

需要注意的是,默认情况下,内核会将内存映射到用户空间,并启用缓存和缓冲区。为了更改默认行为,驱动程序必须在调用重新映射 API 之前禁用 VMA 上的缓存。为此,内核提供了 pgprot_noncached()。除了禁用缓存外,该函数还会禁用指定区域的缓冲区能力。此助手函数接受一个初始的 VMA 访问保护,并返回禁用缓存后的更新版本。

它的使用方式如下:

vma->vm_page_prot = pgprot_noncached(vma->vm_page_prot);

在测试我为内存映射设备开发的驱动程序时,我遇到了一个问题:在启用缓存时,当我通过 mmap 映射的区域在用户空间更新设备寄存器,并且设备看到该更新时,延迟大约为 20 毫秒。

禁用缓存后,这个延迟几乎消失了,降到了 200 微秒以下。太神奇了!

实现 mmap 文件操作

从用户空间,通过mmap()系统调用将物理内存映射到调用进程的地址空间。为了在驱动中支持这个系统调用,该驱动必须实现file_operations.mmap钩子。在映射完成后,用户进程将能够通过返回的地址直接写入设备内存。内核将通过常规的指针解引用,将对映射区域的任何访问转换为文件操作。

mmap()系统调用声明如下:

int mmap (void *addr, size_t len, int prot,
           int flags, int fd, ff_t offset);

从内核端,驱动的文件操作结构(struct file_operations结构)中的mmap字段具有以下原型:

int (*mmap)(struct file *filp,
             struct vm_area_struct *vma);

在前面的文件操作函数中,filp是指向驱动打开的设备文件的指针,结果是通过fd参数(在系统调用中给定)进行转换的。vma是由内核分配并作为参数提供的。它指向用户进程的 VMA,映射应该放置在其中。要理解内核如何创建新的 VMA,它使用传递给mmap()系统调用的参数,这些参数会以某种方式影响 VMA 的某些字段,如下所示:

  • addr是用户空间的虚拟地址,映射应从此地址开始。它会影响vma->vm_start。如果为NULL(便于移植的方式),内核将自动选择一个空闲地址。

  • len指定映射的长度,并间接影响vma->vm_end。请记住,VMA 的大小始终是PAGE_SIZE的倍数。这意味着PAGE_SIZE是 VMA 可以拥有的最小大小。如果len参数不是页大小的倍数,它将向上舍入到下一个最高的页大小倍数。

  • prot影响 VMA 的权限,驱动可以在vma->vm_page_prot中找到。

  • flags决定了驱动可以在vma->vm_flags中找到的映射类型。映射可以是私有的或共享的。

  • offset指定映射区域内的偏移量。它由内核计算,并存储在以PAGE_SIZE为单位的vma->vm_pgoff中。

在定义了所有这些参数后,我们可以将mmap文件操作实现拆分为以下步骤:

  1. 获取映射偏移量并检查其是否超出缓冲区大小:

    unsigned long offset = vma->vm_pgoff << PAGE_SHIFT; 
    if (offset >= buffer_size)
            return -EINVAL;
    
  2. 检查映射长度是否大于我们的缓冲区大小:

    unsigned long size = vma->vm_end - vma->vm_start;
    if (buffer_size < (size + offset))
        return -EINVAL;
    
  3. 计算与offset在缓冲区中的位置对应的 PFN。注意,PFN 的获取方式取决于缓冲区的分配方式:

    unsigned long pfn;    
    pfn = virt_to_phys(buffer + offset) >> PAGE_SHIFT;
    
  4. 设置适当的标志,必要时禁用缓存:

    • 使用vma->vm_page_prot = pgprot_noncached(vma->vm_page_prot);禁用缓存。

    • 如果需要,设置VM_IO标志:vma->vm_flags |= VM_IO;。它还会防止 VMA 包含在进程的核心转储中。

    • 防止 VMA 被交换出去:vma->vm_flags |= VM_DONTEXPAND | VM_DONTDUMP。在 3.7 版本之前的内核中,VM_RESERVED会被使用。

  5. 调用remap_pfn_range(),传入之前计算的 PFN、size和保护标志。在进行 I/O 内存映射时,我们将使用vm_iomap_memory()

    if (remap_pfn_range(vma, vma->vm_start, pfn,
                       size, vma->vm_page_prot)) {
        return -EAGAIN;
    }
    return 0;
    
  6. 最后,将函数传递给struct file_operations结构:

    static const struct file_operations my_fops = {
        .owner = THIS_MODULE,
        [...]
        .mmap = my_mmap,
        [...]
    };
    

这个文件操作的实现结束了我们关于内存映射的系列内容。在本节中,我们学习了映射在背后是如何工作的,以及所有相关机制,包括缓存考虑。

总结

本章是最重要的章节之一。它揭示了 Linux 内核中的内存管理与分配(如何分配以及在哪里分配)。它详细讲解了映射和地址转换的工作原理。其他一些方面,例如与硬件设备通信和为用户空间重新映射内存(代表mmap()系统调用)也进行了详细讨论。

这为引入和理解下一章奠定了坚实基础,该章讨论的是直接内存访问DMA)。

第十一章:第十一章:实现直接内存访问(DMA)支持

直接内存访问DMA)是计算机系统的一项功能,允许设备在无需 CPU 干预的情况下访问主系统内存,从而使 CPU 能专注于其他任务。它的应用包括网络流量加速、音频数据或视频帧抓取,其使用范围不限于特定领域。负责管理 DMA 事务的外设是 DMA 控制器,现代大多数处理器和微控制器中都有该控制器。

该功能的工作原理如下:当驱动程序需要传输一块数据时,驱动程序设置 DMA 控制器,指定源地址、目标地址以及需要复制的字节总数。然后,DMA 控制器会自动将数据从源地址传输到目标地址,而无需占用 CPU 的周期。当剩余的字节数达到零时,数据块传输结束,驱动程序会收到通知。

注意

DMA 并不总是意味着复制操作会更快。它不会直接带来性能提升,但首先,它提供了一个真正的后台操作,释放 CPU 去处理其他任务;其次,在 DMA 操作过程中,由于保持了 CPU 缓存/预取器的状态,性能上会有所提升(如果使用普通的 memcpy,并由 CPU 执行,可能会导致状态紊乱)。

本章将处理一致性和非一致性 DMA 映射,以及一致性问题、DMA 引擎的 API 和 DMA 与 DT 绑定。更具体地说,我们将涵盖以下主题:

  • 设置 DMA 映射

  • 完成概念介绍

  • 使用 DMA 引擎的 API

  • 综合起来——单缓冲区 DMA 映射

  • 关于循环 DMA 的说明

  • 理解 DMA 和 DT 绑定

设置 DMA 映射

对于任何类型的 DMA 传输,您需要提供源地址、目标地址以及要传输的字数。在外设 DMA 的情况下,这个外设的 FIFO 会作为源或目标,取决于传输方向。当外设作为源时,目标地址是一个内存位置(内部或外部)。当外设作为目标时,源地址是一个内存位置(内部或外部)。

换句话说,DMA 传输需要适当的内存映射。接下来的章节将详细讨论这一点。

缓存一致性与 DMA 的概念

在配备缓存的 CPU 上,最近访问的内存区域会被缓存,包括为 DMA 映射的内存区域。事实上,两个独立设备之间共享的内存通常是缓存一致性问题的根源。缓存不一致性来源于其他设备可能没有意识到另一个设备的写操作。另一方面,缓存一致性确保每个写操作看起来都像是瞬间发生的,这意味着所有共享同一内存区域的设备看到的是完全相同的变化顺序。

在《Linux 设备驱动程序(LDD3)》第三版的以下摘录中,详细阐述了缓存一致性问题的情形:

“让我们假设一台 CPU 配备了缓存,并且外部内存可以通过 DMA 直接访问。当 CPU 访问内存中的位置 X 时,当前的值将被存储在缓存中。随后的对 X 的操作将更新 X 的缓存副本,但不会更新 X 在外部内存中的版本,假设是写回缓存。如果缓存没有在设备下次尝试访问 X 之前刷新到内存,设备将接收到 X 的过时值。同样地,如果当设备写入新值到内存时缓存副本没有失效,CPU 将对 X 的过时值进行操作。”

解决这个问题有两种方法:

  • 一种基于硬件的解决方案。这种系统是一致性的系统。

  • 一种基于软件的解决方案,操作系统负责确保缓存一致性。这种系统是非一致性的系统。

现在我们已经了解了 DMA 的缓存方面,让我们向前迈一步,学习如何执行 DMA 的内存映射。

DMA 的内存映射

为 DMA 目的分配的内存缓冲区必须进行适当的映射。DMA 映射包括分配一个适合 DMA 的内存缓冲区,并为该缓冲区生成一个总线地址。

我们区分两种类型的 DMA 映射——一致性 DMA 映射流式 DMA 映射。前者自动解决缓存一致性问题,使其成为多个传输中无需在传输之间取消映射而重复使用的好候选方案。这可能会在一些平台上带来相当大的开销,而且,保持内存同步本身就有成本。流式映射在编码方面有很多限制,并且不会自动解决一致性问题,尽管有一个解决方案,它由每次传输之间的多个函数调用组成。一致性映射通常在驱动程序的生命周期内存在,而一个流式映射通常在 DMA 传输完成后会被取消映射。

注意

建议在可以时使用流式映射,在必须时使用一致性映射。如果缓冲区会被 CPU 或 DMA 控制器不可预测地访问,你应该考虑使用一致性映射,因为内存始终会被同步。否则,你应该使用流式映射,因为你确切知道何时需要访问缓冲区,在这种情况下,你会先刷新缓存(从而同步缓冲区),然后再访问缓冲区。

处理 DMA 映射的主要头文件是:

 #include <linux/dma-mapping.h>

然而,根据映射的不同,可以使用不同的 API。在进一步讨论 API 之前,我们需要理解在 DMA 映射过程中执行的操作:

  1. 假设设备支持 DMA,如果驱动程序使用kmalloc()设置缓冲区,它将获得一个虚拟地址(我们称之为X),但此时该地址尚未指向任何地方。

  2. 虚拟内存系统(由 MMU,即内存管理单元协助)将 X 映射到系统 RAM 中的物理地址(我们称之为 Y),前提是系统中仍有可用的空闲内存。

由于 DMA 不通过 CPU 虚拟内存系统流动,驱动程序可以使用虚拟地址 X 来访问此时的缓冲区,但设备本身不能。

  1. 在一些简单系统(没有 I/O MMU 的系统)中,设备可以直接对物理地址 Y 进行 DMA。但在许多其他系统中,设备通过 I/O MMU 观察主内存;因此,存在将 DMA 地址转换为物理地址的 I/O MMU 硬件,例如,它将 Z 转换为 Y

  2. 这时 DMA API 介入:

    • 驱动程序可以将虚拟地址 X 传递给类似于 dma_map_single() 的函数(我们将在本章的 单缓冲区映射 部分中讨论),该函数设置任何适当的 I/O MMU 映射并返回 DMA 地址 Z

    • 驱动程序接着指示设备执行 DMA 操作到 Z

    • I/O MMU 最终将其映射到系统 RAM 中地址为 Y 的缓冲区。

现在我们已经介绍了用于 DMA 的内存映射概念,我们可以开始创建映射,从最简单的一致性 DMA 映射开始。

创建一致性的 DMA 映射

这种映射通常用于持久性双向 I/O 缓冲区。以下函数设置了一个一致性的映射:

void *dma_alloc_coherent(struct device *dev, size_t size,
                      dma_addr_t *dma_handle, gfp_t flag) 

这个函数负责缓冲区的分配和映射。它返回一个内核虚拟地址,该地址对应于该缓冲区,宽度为size字节,并且 CPU 可以访问。size参数可能会引起误解,因为它首先传递给get_order() API,以获得与此大小对应的页面顺序。因此,这个映射至少是页面大小的,并且页面数量是 2 的幂。dev是你的设备结构。第三个参数是一个输出参数,指向相关的总线地址。为映射分配的内存保证是物理连续的,标志决定了内存应如何分配,通常是GFP_KERNEL,或者在原子上下文中是GFP_ATOMIC

请注意,这个映射称为以下内容:

  • 一致性(coherent)是因为缓冲区内容在所有子系统中始终相同(无论是设备还是 CPU)。

  • 同步,因为设备或 CPU 的写入可以立即读取,而无需担心缓存一致性。

要释放映射,可以使用以下 API:

void dma_free_coherent(struct device *dev, size_t size,
                 void *cpu_addr, dma_addr_t dma_handle);

在前面的原型中,cpu_addrdma_handle分别对应由dma_alloc_coherent()返回的内核虚拟地址和总线地址。这两个参数是 MMU(返回虚拟地址)和 I/O MMU(返回总线地址)释放其映射时所需的。

创建流式 DMA 映射

流式 DMA 映射内存缓冲区通常在传输之前映射,传输之后取消映射。这种映射有更多的约束,并且与一致性映射不同,原因如下:

  • 映射需要与先前动态分配的缓冲区一起工作。

  • 映射可以接受多个不连续且分散的缓冲区。

  • 对于读事务(设备到 CPU),缓冲区属于设备,而不是 CPU。在 CPU 使用这些缓冲区之前,应先取消映射(在dma_unmap_{single,sg}()之后),或者必须在这些缓冲区上调用dma_sync_{single,sg}_for_cpu()。这样做的主要原因是为了缓存目的。

  • 对于写事务(CPU 到设备),驱动程序应在建立映射之前将数据放入缓冲区。

  • 必须指定传输方向,并且数据应该按照该方向进行移动和使用。

流式映射有两种形式:

  • 单缓冲区映射,允许映射一个物理连续的缓冲区

  • 散射/聚集映射,允许传递多个缓冲区(分散在内存中)

对于这两种映射,传输方向应通过enum dma_data_direction类型的符号来指定,定义在include/linux/dma-direction.h中,如下所示:

enum dma_data_direction {
     DMA_BIDIRECTIONAL = 0,
     DMA_TO_DEVICE = 1,
     DMA_FROM_DEVICE = 2,
     DMA_NONE = 3,
};

在上面的摘录中,每个元素都相当自解释。

注意

一致性映射隐式地设置了一个方向属性,值为DMA_BIDIRECTIONAL

现在我们已经了解了两种流式 DMA 映射方法,我们可以深入了解它们的实现,首先从单缓冲区映射开始。

单缓冲区映射

单缓冲区映射是一种偶尔传输的流式映射。你可以使用dma_map_single()函数设置这样的映射,该函数具有以下定义:

dma_addr_t dma_map_single(struct device *dev, void *ptr,
        size_t size, enum dma_data_direction direction);

当 CPU 是源(它写入设备)、CPU 是目标(它从设备读取)或该映射的访问是双向的(在一致性映射中隐式使用)时,方向应分别为DMA_TO_DEVICEDMA_FROM_DEVICEDMA_BIDIRECTIONALdev是你硬件设备的底层device结构,ptr是一个输出参数,是缓冲区的内核虚拟地址。此函数返回dma_addr_t类型的元素,这是 I/O MMU(如果存在)返回的设备总线地址,以便设备能够进行 DMA 操作。你应该使用dma_mapping_error()(如果没有错误发生,必须返回0)来检查映射是否返回了有效地址,并且如果发生错误,不要继续操作。

这种映射可以通过以下函数释放:

void dma_unmap_single(struct device *dev, 
                      dma_addr_t dma_addr, size_t size, 
                      enum dma_data_direction direction);
int dma_mapping_error(struct device *dev, 
                      dma_addr_t dma_addr);

另一种映射是散列/收集映射,因为内存缓冲区在分配时被分散(散布)到系统中,并由驱动程序收集。

散列/收集映射

散列/收集映射是一种特殊类型的流式 DMA 映射,允许在一次操作中传输多个内存缓冲区,而不是逐个映射每个缓冲区并依次传输它们。假设你有多个缓冲区,它们可能在物理上并不连续,但需要同时传输到设备或从设备传输。这种情况可能会由于以下原因发生:

  • 一个readvwritev系统调用

  • 一个磁盘 I/O 请求

  • 或者仅仅是一个页面列表或一个 vmalloc 区域

在你可以发出这种映射之前,你必须设置一个散列元素数组,每个元素应描述单个缓冲区的映射。散列元素在内核中被抽象为struct scatterlist的实例,定义如下:

struct scatterlist {
     unsigned long page_link;
     unsigned int     offset;
     unsigned int     length;
     dma_addr_t       dma_address;
     unsigned int     dma_length;
};

要设置一个散列列表映射,你应当执行以下操作:

  • 分配你的散布缓冲区。

  • 创建一个散列元素数组,使用sg_init_table()初始化该数组,并使用sg_set_buf()将已分配的内存填充到此数组中。请注意,每个散列元素条目必须是页面大小,最后一个元素除外,最后一个元素可能不遵循此规则。

  • 在散列列表上调用dma_map_sg()

  • DMA 操作完成后,调用dma_unmap_sg()取消映射散列列表。

以下是描述散列列表大部分概念的图示:

图 11.1 – 散列/收集内存组织

图 11.1 – 散列/收集内存组织

虽然可以单独进行 DMA 操作每个缓冲区的内容,但散列/聚集(scatter/gather)使得可以通过将散列列表数组的指针和其长度(数组中的条目数)一起发送到设备,来一次性进行 DMA 操作整个列表。

sg_init_table()sg_set_buf()dma_map_sg() 的原型如下:

void sg_init_table(struct scatterlist *sgl, 
                   unsigned int nents)
void sg_set_buf(struct scatterlist *sg, const void *buf,
                unsigned int buflen)
int dma_map_sg(struct device *dev, 
               struct scatterlist *sglist, int nents,
               enum dma_data_direction dir);

在上述 API 中,sgl 是要初始化的 scatterlist 数组,nents 是该数组中的条目数量。sg_set_buf()scatterlist 条目设置为指向给定数据。在其参数中,sgscatterlist 条目,data 是与该条目对应的缓冲区,buflen 是缓冲区的大小。dma_map_sg() 返回已成功映射的列表中元素的数量,这意味着它永远不会小于零。如果发生错误,此函数将返回零。

以下是演示散列/聚集映射原理的代码示例:

u32 *wbuf, *wbuf2, *wbuf3;
wbuf = kzalloc(SDMA_BUF_SIZE, GFP_DMA);
wbuf2 = kzalloc(SDMA_BUF_SIZE, GFP_DMA);
wbuf3 = kzalloc(SDMA_BUF_SIZE/2, GFP_DMA);
struct scatterlist sg[3];
sg_init_table(sg, 3);
sg_set_buf(&sg[0], wbuf, SDMA_BUF_SIZE);
sg_set_buf(&sg[1], wbuf2, SDMA_BUF_SIZE);
sg_set_buf(&sg[2], wbuf3, SDMA_BUF_SIZE/2);
ret = dma_map_sg(dev, sg, 3, DMA_TO_DEVICE);
if (ret != 3) {
     /*handle this error*/
}
/* As of now you can use 'ret' or 'sg_dma_len(sgl)' to retrieve the
 * length of the scatterlist array.
 */

在散列/聚集映射中,单缓冲区映射部分中描述的相同规则也适用于散列/聚集。

要取消映射列表,必须使用 dma_unmap_sg(),其定义如下:

void dma_unmap_sg_attrs(struct device *dev, struct scatterlist *sg,
                          enum dma_data_direction dir, int nents)

dev 是指向已用于映射的相同设备的指针,sg 是要取消映射的散列列表(实际上是指向列表中第一个元素的指针),dir 是 DMA 方向,应该映射映射方向,nents 是列表中元素的数量。

以下是取消映射前一个实现的示例:

dma_unmap_sg(dev, sg, 3, DMA_TO_DEVICE);

在前面的示例中,我们使用了与映射过程中相同的参数。

流式映射的隐式和显式缓存一致性

在任何流式映射中,dma_map_single()/dma_unmap_single()dma_map_sg()/dma_unmap_sg() 成对调用时,会处理缓存一致性。在外向 DMA 传输(从 CPU 到设备,设置 DMA_TO_DEVICE 方向标志)情况下,由于数据必须在建立映射之前就位于缓冲区中,dma_map_sg()/dma_map_single() 将处理缓存一致性。在设备到 CPU 的情况(设置 DMA_FROM_DEVICE 方向标志),必须首先释放映射,然后 CPU 才能访问缓冲区。这是因为 dma_unmap_single()/dma_unmap_sg() 也会隐式处理缓存一致性。

然而,如果你需要多次使用相同的流式 DMA 区域,并在 DMA 传输之间访问数据,缓冲区必须正确同步,以便设备和 CPU 都能看到 DMA 缓冲区的最新正确副本。为了避免缓存一致性问题,驱动程序必须在从 RAM 向设备启动 DMA 传输之前(在将数据放入缓冲区并实际将缓冲区交给硬件之前)调用 dma_sync_{single,sg}_for_device()。这个函数调用会在必要时刷新与 DMA 缓冲区对应的缓存行。类似地,驱动程序在完成从设备到 RAM 的 DMA 传输后不应立即访问内存缓冲区;相反,在读取缓冲区之前,驱动程序应调用 dma_sync_{single,sg}_for_cpu(),如果需要,它将使相关硬件缓存行失效。换句话说,当源缓冲区是设备内存时,应使缓存失效(缓存数据并不脏,因为 CPU 没有向任何缓冲区写入数据);而如果源是 RAM(目标是设备内存),这意味着 CPU 可能已经向源缓冲区写入了一些数据,并且这些数据可能在缓存行中,因此缓存应被刷新。

以下是这些同步 API 的原型:

void dma_sync_sg_for_cpu(struct device *dev,
                     struct scatterlist *sg,
                     int nents,
                     enum dma_data_direction direction);
void dma_sync_sg_for_device(struct device *dev,
                     struct scatterlist *sg, int nents,
                     enum dma_data_direction direction);
void dma_sync_single_for_cpu(struct device *dev, 
                     dma_addr_t addr, size_t size,
                     enum dma_data_direction dir)
void dma_sync_single_for_device(struct device *dev,
                     dma_addr_t addr, size_t size,
                     enum dma_data_direction dir)

在之前的所有 API 中,方向参数必须与映射相应缓冲区时指定的方向保持一致。

在本节中,我们已经学习了如何设置流式 DMA 映射。现在我们完成了映射的部分,接下来介绍完成概念,用于通知 DMA 传输完成。

完成概念介绍

本节将简要描述完成以及 DMA 传输使用的必要 API 部分。有关完整描述,请随时查看 Documentation/scheduler/completion.txt 中的内核文档。在内核编程中,典型的做法是启动当前线程外的某些活动,然后等待其完成。完成是等待非常常见的过程完成时,等待队列或休眠 API 的良好替代方案。完成变量使用等待队列来实现,唯一的区别是它们使开发者的工作变得更简单,因为不需要维护等待队列,这使得代码的意图非常清晰易懂。

使用完成时需要这个头文件:

#include <linux/completion.h> 

完成变量在内核中表示为一个结构体 completion 的实例,可以像下面这样静态初始化:

DECLARE_COMPLETION(my_comp);

动态分配初始化可以如下进行:

struct completion my_comp;
init_completion(&my_comp);

当驱动程序启动必须等待完成的工作(在我们这里是 DMA 事务)时,它只需要将完成事件传递给 wait_for_completion() 函数,该函数的原型如下:

void wait_for_completion(struct completion *comp);

当完成发生时,驱动程序可以使用以下 API 之一唤醒等待者:

void complete(struct completion *comp);
void complete_all(struct completion *comp);

complete() 只会唤醒一个等待的任务,而 complete_all() 会唤醒所有等待该事件的任务。完成操作是以一种确保即使在 wait_for_completion() 之前调用 complete() 时也能正常工作的方式实现的。

在本节中,我们已经学会了实现一个完成回调,以通知 DMA 传输的完成状态。现在我们已经掌握了 DMA 的所有常见概念,可以开始使用 DMA 引擎 API 来应用这些概念,这也有助于我们更好地理解当一切合并在一起时,事物是如何运作的。

使用 DMA 引擎的 API

DMA 引擎是一个通用的内核框架,用于开发 DMA 控制器驱动程序并从消费端利用该控制器。通过这个框架,DMA 控制器驱动程序暴露出一组可以供客户端设备使用的通道。这个框架使得客户端驱动(也称为从属驱动)能够请求并使用来自控制器的 DMA 通道来发起 DMA 传输。

以下图示展示了这一框架的分层结构,显示了该框架如何与 Linux 内核集成:

图 11.2 – DMA 引擎框架

图 11.2 – DMA 引擎框架

在这里,我们将简单介绍该(从属)API,仅适用于从属 DMA 使用。这里的强制性头文件如下:

#include <linux/dmaengine.h>

从属 DMA 的使用是直接的,包括以下步骤:

  1. 通知内核设备的 DMA 地址能力。

  2. 请求 DMA 通道。

  3. 如果成功,配置这个 DMA 通道。

  4. 准备或配置 DMA 传输。在此步骤中,返回一个表示传输的传输描述符。

  5. 使用描述符提交 DMA 传输。然后,传输被添加到控制器的待处理队列中,等待指定通道的处理。此步骤返回一个特殊的 cookie,你可以用它来检查 DMA 活动的进展。

  6. 在指定通道上启动 DMA 传输,以便当通道空闲时,队列中的第一个传输开始。

现在我们已经了解了实现 DMA 传输所需的步骤,让我们在使用相应的 API 之前,先了解 DMA 引擎框架中涉及的数据结构。

简要介绍 DMA 控制器接口

Linux 中 DMA 的使用包括两个部分:控制器,执行内存传输(不需要 CPU 介入),以及通道,客户端驱动(即支持 DMA 的驱动)通过通道向控制器提交任务。无需多言,控制器和其通道是紧密耦合的,因为前者向客户端暴露后者。

尽管本章面向 DMA 客户端驱动,但为了便于理解,我们将介绍一些控制器数据结构和 API。

DMA 控制器数据结构

DMA 控制器在 Linux 内核中作为struct dma_device的一个实例进行抽象。仅凭控制器本身,若没有客户端使用它暴露的通道,则控制器是无用的。此外,控制器驱动必须暴露用于通道配置的回调,正如其数据结构中所指定的那样,该数据结构具有以下定义:

struct dma_device {
    unsigned int chancnt;
    unsigned int privatecnt;
    struct list_head channels;
    struct list_head global_node;
    struct dma_filter filter;
    dma_cap_mask_t  cap_mask;
    u32 src_addr_widths;
    u32 dst_addr_widths;
    u32 directions;
    int (*device_alloc_chan_resources)(
                                  struct dma_chan *chan);
    void (*device_free_chan_resources)(
                                  struct dma_chan *chan);
    struct dma_async_tx_descriptor 
     *(*device_prep_dma_memcpy)(
        struct dma_chan *chan, dma_addr_t dst, 
        dma_addr_t src, size_t len, unsigned long flags);
    struct dma_async_tx_descriptor 
      *(*device_prep_dma_memset)(
       struct dma_chan *chan, dma_addr_t dest, int value,
       size_t len, unsigned long flags);
    struct dma_async_tx_descriptor
      *(*device_prep_dma_memset_sg)(
         struct dma_chan *chan, struct scatterlist *sg,
         unsigned int nents, int value,
         unsigned long flags);
    struct dma_async_tx_descriptor  
      *(*device_prep_dma_interrupt)(
         struct dma_chan *chan, unsigned long flags);
    struct dma_async_tx_descriptor 
      *(*device_prep_slave_sg)(
         struct dma_chan *chan, struct scatterlist *sgl,
           unsigned int sg_len,
           enum dma_transfer_direction direction,
           unsigned long flags, void *context);
    struct dma_async_tx_descriptor 
      *(*device_prep_dma_cyclic)(
           struct dma_chan *chan, dma_addr_t buf_addr,
           size_t buf_len, size_t period_len,
           enum dma_transfer_direction direction,
           unsigned long flags);
     void (*device_caps)(struct dma_chan *chan,
                     struct dma_slave_caps *caps);
     int (*device_config)(struct dma_chan *chan,
                     struct dma_slave_config *config);
     void (*device_synchronize)(struct dma_chan *chan);
     enum dma_status (*device_tx_status)(
              struct dma_chan *chan, dma_cookie_t cookie,
              struct dma_tx_state *txstate);
     void (*device_issue_pending)(struct dma_chan *chan);
     void (*device_release)(struct dma_device *dev);
};

该数据结构的完整定义可在include/linux/dmaengine.h中找到。本章节仅列出了我们关注的字段。它们的含义如下:

  • chancnt:指定该控制器支持多少个 DMA 通道

  • channelsstruct dma_chan结构的列表,表示该控制器暴露的 DMA 通道

  • privatecnt:由dma_request_channel()请求的 DMA 通道数量,dma_request_channel()是 DMA 引擎 API 用于请求 DMA 通道

  • cap_mask:一个或多个dma_capability标志,表示该控制器的能力

以下是可能的值:

enum dma_transaction_type {
    DMA_MEMCPY,     /* Memory to memory copy */
    DMA_XOR,  /* Memory to memory XOR*/
    DMA_PQ,   /* Memory to memory P+Q computation */
    DMA_XOR_VAL, /* Memory buffer parity check using 
                  * XOR */
    DMA_PQ_VAL,  /* Memory buffer parity check using 
                  * P+Q */
    DMA_INTERRUPT,  /* The device can generate dummy
                     * transfer that will generate 
                     * interrupts */
    DMA_MEMSET_SG,  /* Prepares a memset operation over a 
                     * scatter list */
    DMA_SLAVE,      /* Slave DMA operation, either to or
                     * from a device */
    DMA_PRIVATE,    /* channels are not to be used 
                     * for global memcpy. Usually
                     *used with DMA_SLAVE */
    DMA_SLAVE,      /* Memory to device transfers */
    DMA_CYCLIC,     /* can handle cyclic tranfers */
    DMA_INTERLEAVE, /* Memory to memory interleaved
                     * transfer */
}

作为一个例子,这个元素在 i.MX DMA 控制器驱动中设置如下:

dma_cap_set(DMA_SLAVE, sdma->dma_device.cap_mask);
dma_cap_set(DMA_CYCLIC, sdma->dma_device.cap_mask);
dma_cap_set(DMA_MEMCPY, sdma->dma_device.cap_mask);
  • src_addr_widths:设备支持的源地址宽度的位掩码。此宽度必须以字节为单位提供;例如,如果设备支持4的宽度,则掩码应设置为BIT(4)

  • dst_addr_widths:设备支持的目标地址宽度的位掩码。

  • directions:设备支持的从属方向的位掩码。由于enum dma_transfer_direction中没有为每种类型定义一个位标志,DMA 控制器应设置BIT(<TYPE>),且控制器也应检查相同的内容。

它在 i.MX SDMA 控制器驱动中设置如下:

#define SDMA_DMA_DIRECTIONS (BIT(DMA_DEV_TO_MEM) | \
                     BIT(DMA_MEM_TO_DEV) | \
                       BIT(DMA_DEV_TO_DEV))
[...]
sdma->dma_device.directions = SDMA_DMA_DIRECTIONS;
  • device_alloc_chan_resources:分配资源并返回分配的描述符数量。由 DMA 引擎核心在请求该控制器的通道时调用。

  • device_free_chan_resources:一个回调,允许释放 DMA 通道的资源。

前述为通用回调,以下是依赖于控制器能力的控制器回调,只有在cap_mask中设置了相关能力位掩码时,必须提供此回调。

  • device_prep_dma_memcpy准备一个 memcpy 操作。如果DMA_MEMCPYcap_mask中设置,则此元素必须设置。对于每个设置的标志,必须提供相应的回调,否则控制器注册将失败。所有device_prep_*回调都如此。

  • device_prep_dma_xor:准备一个 XOR 操作。

  • device_prep_dma_xor_val:准备一个异或验证操作。

  • device_prep_dma_memset:准备一个 memset 操作。

  • device_prep_dma_memset_sg:准备一个在散列表上进行 memset 操作。

  • device_prep_dma_interrupt:准备一个链终止中断操作。

  • device_prep_slave_sg:准备一个从属 DMA 操作。

  • device_prep_dma_cyclic:准备一个循环 DMA 操作。此类 DMA 操作通常用于音频或 UART 驱动程序。该函数需要一个大小为 buf_len 的缓冲区。每当传输 period_len 字节后,将调用回调函数。我们将在 关于循环 DMA 部分讨论此类 DMA。

  • device_prep_interleaved_dma:以通用方式传输表达式。

  • device_config:将新的配置推送到通道,成功时返回 0,否则返回错误代码。

  • device_pause:暂停通道上的任何当前传输,并返回 0(如果暂停有效),否则返回错误代码。

  • device_resume:恢复之前暂停的通道上的任何传输。返回 0,否则返回错误代码。

  • device_terminate_all:一个回调,用于中止通道上的所有传输,并在成功时返回 0,否则返回错误代码。

  • device_synchronize:一个回调函数,用于将传输结束的同步操作与当前上下文对接。

  • device_tx_status:轮询事务是否完成。可选的 txstate 参数可用于获取包含附加传输状态信息的结构体;否则,该调用将仅返回一个简单的状态代码。

  • device_issue_pending:一个强制性的回调,用于将待处理的事务推送到硬件。这是 dma_async_issue_pending() API 的后端实现。

虽然大多数驱动程序直接调用这些回调函数(通过 dma_chan->dma_dev->device_prep_dma_*),但你应该使用 dmaengine_prep_* DMA 引擎 API,这些 API 在调用相应回调之前会进行一些有效性检查。例如,对于内存到内存的传输,驱动程序应使用 device_prep_dma_memcpy() 包装函数。

DMA 通道数据结构

DMA 通道是客户端驱动程序提交 DMA 事务(I/O 数据传输)给 DMA 控制器的方式。其工作原理是:支持 DMA 的驱动程序(客户端驱动程序)请求一个或多个通道,重新配置该通道,并要求控制器使用此通道执行已提交的 DMA 传输。通道定义如下:

struct dma_chan {
     struct dma_device *device;
     struct device *slave;
     dma_cookie_t cookie;
     dma_cookie_t completed_cookie;
[...]
};

你可以将 DMA 通道看作是用于 I/O 数据传输的高速公路。以下是该数据结构中每个元素的含义:

  • device:指向提供此通道的 DMA 设备(控制器)的指针。如果通道成功请求,字段绝不应为 NULL,因为一个通道始终属于一个控制器。

  • slave:指向使用此通道的设备底层 struct device 结构的指针(其驱动程序为客户端驱动程序)。

  • cookie:表示该通道返回给客户端的最后一个 cookie 值。

  • Completed_cookie:此通道的最后一个完成的 cookie。

此数据结构的完整定义可以在 include/linux/dmaengine.h 文件中找到。

注意

在 DMA 引擎框架中,cookie 只是一个 DMA 事务标识符,允许检查它标识的事务的状态和进展。

DMA 事务描述符数据结构

事务描述符除了描述和表征 DMA 事务(或在语言滥用下称为 DMA 传输)外没有其他功能。这样的描述符在内核中使用struct dma_async_tx_descriptor数据结构表示,其定义如下:

struct dma_async_tx_descriptor {
     dma_cookie_t cookie;
     struct dma_chan *chan;
     dma_async_tx_callback callback;
     void *callback_param;
[...]
};

我们在这个数据结构中保留的每个元素的含义在这里列出:

  • cookie:此事务的跟踪 cookie。它允许检查此事务的进展。

  • chan:此操作的目标通道。

  • callback:一旦操作完成,应调用的函数。

  • callback_param:作为回调函数的参数传递。

你可以在include/linux/dmaengine.h中找到完整的数据结构描述。

处理设备的 DMA 地址能力

内核默认认为你的设备可以处理 32 位 DMA 地址。然而,设备能够访问的 DMA 内存地址范围可能是有限的,这可能是由于制造商或历史原因。例如,一些设备可能只支持地址的低 24 位。这一限制源于 ISA 总线,ISA 总线是 24 位宽,并且 DMA 缓冲区只能位于系统内存的底部 16 MB 区域。

然而,你可以使用 DMA 掩码的概念来告知内核这种限制,目的是通知内核你的设备的 DMA 地址能力。

这可以通过使用dma_set_mask_and_coherent()来实现,其原型如下:

int dma_set_mask_and_coherent(struct device *dev,
                              u64 mask);

上述函数会为流式映射和一致性映射设置相同的掩码,因为 DMA API 保证一致性 DMA 掩码可以设置为与流式 DMA 掩码相同或更小。

然而,对于特殊需求,你可以使用dma_set_mask()dma_set_coherent_mask()来相应地设置掩码。这些 API 的原型如下:

int dma_set_mask(struct device *dev, u64 mask);
int dma_set_coherent_mask(struct device *dev, u64 mask);

在这些函数中,dev是底层设备结构,而mask是描述设备支持的地址位的位掩码,可以使用DMA_BIT_MASK宏和实际的位顺序来指定。

dma_set_mask()dma_set_coherent_mask()都返回零,表示在给定的地址掩码下,设备可以在机器上正确执行 DMA。任何其他返回值都表示错误,意味着给定的掩码太小,无法在该系统上支持。在这种失败的情况下,你可以在驱动程序中回退到非 DMA 模式进行数据传输,或者如果 DMA 是强制性的,直接禁用设备中需要 DMA 支持的功能,甚至完全不探测该设备。

当设置 DMA 掩码失败时,建议驱动程序打印内核警告(dev_warn()pr_warn())消息。以下是一个声卡的伪代码示例:

#define PLAYBACK_ADDRESS_BITS DMA_BIT_MASK(32)
#define RECORD_ADDRESS_BITS DMA_BIT_MASK(24)
struct my_sound_card *card;
struct device *dev;
...
if (!dma_set_mask(dev, PLAYBACK_ADDRESS_BITS)) {
     card->playback_enabled = 1;
} else {
    card->playback_enabled = 0;
    dev_warn(dev,
     "%s: Playback disabled due to DMA limitations\n",
     card->name);
}
if (!dma_set_mask(dev, RECORD_ADDRESS_BITS)) {
    card->record_enabled = 1;
} else {
    card->record_enabled = 0;
    dev_warn(dev, 
          "%s: Record disabled due to DMA limitations\n",
          card->name);
}

在前面的示例中,我们使用了 DMA_BIT_MASK 宏来定义 DMA 掩码。然后,当所需的 DMA 掩码不被支持时,我们禁用了必须支持 DMA 的功能。在任何情况下,都会打印警告。

请求 DMA 通道

通过 dma_request_channel() 请求一个通道。它的原型如下:

struct dma_chan *dma_request_channel(
                      const dma_cap_mask_t *mask,
                      dma_filter_fn fn, void *fn_param);

在前面的例子中,掩码必须是一个位掩码,表示通道必须满足的能力。它本质上用于指定驱动程序需要执行的传输类型,该类型必须在 dma_device.cap_mask 中得到支持。

dma_cap_zero()dma_cap_set() 函数用于清除掩码并设置所需的能力;例如:

dma_cap_mask my_dma_cap_mask;
struct dma_chan *chan;
dma_cap_zero(my_dma_cap_mask);
/* Memory 2 memory copy */
dma_cap_set(DMA_MEMCPY, my_dma_cap_mask); 
chan = dma_request_channel(my_dma_cap_mask, NULL, NULL);

fn 是一个回调指针,其类型具有以下定义:

typedef bool (*dma_filter_fn)(struct dma_chan *chan,
                void *filter_param);

实际上,dma_request_channel() 会遍历系统中可用的 DMA 控制器(dma_device_list,定义在 drivers/dma/dmaengine.c 中),并为每个控制器查找与请求匹配的通道。如果 filter_fn 参数(可选)为 NULL,则 dma_request_channel() 会直接返回第一个满足能力掩码的通道。否则,当掩码参数不足以指定所需的通道时,可以使用 filter_fn 例程作为过滤器,这样系统中的每个可用通道都会被传递给此回调函数,以供接受与否。内核会为系统中每个空闲的通道调用一次 filter_fn 例程。当发现合适的通道时,filter_fn 应返回 DMA_ACK,这将标记给定的通道为 dma_request_channel() 的返回值。

通过此接口分配的通道在调用 dma_release_channel() 之前是独占的。其定义如下:

void dma_release_channel(struct dma_chan *chan)

此 API 释放 DMA 通道并使其可以被其他客户端请求。

作为附加信息,可以使用 ls /sys/class/dma/ 命令在用户空间列出系统中可用的 DMA 通道,如下所示:

root@raspberrypi4-64:~# ls /sys/class/dma/
dma0chan0  dma0chan1  dma0chan2  dma0chan3  dma0chan4  dma0chan5  dma0chan6  dma0chan7  dma1chan0  dma1chan1

在前面的代码片段中,chan<chan-index> 通道名称与其所属的 DMA 控制器 dma<dma-index> 连接。通过打印相应通道目录中的 in_use 文件值,可以查看一个通道是否正在使用,如下所示:

root@raspberrypi4-64:~# cat /sys/class/dma/dma0chan0/in_use 
1
root@raspberrypi4-64:~# cat /sys/class/dma/dma0chan1/in_use 
1
root@raspberrypi4-64:~# cat /sys/class/dma/dma0chan2/in_use 
1
root@raspberrypi4-64:~# cat /sys/class/dma/dma0chan3/in_use 
0
root@raspberrypi4-64:~# cat /sys/class/dma/dma0chan4/in_use 
0
root@raspberrypi4-64:~# cat /sys/class/dma/dma0chan5/in_use 
0
root@raspberrypi4-64:~# cat /sys/class/dma/dma0chan6/in_use 
0
root@raspberrypi4-64:~#

在前面的例子中,我们可以看到,dma0chan1 正在使用中,而 dma0chan6 并未使用。

配置 DMA 通道

为了让 DMA 传输在通道上正常工作,必须对该通道应用特定客户端的配置。因此,DMA 引擎框架通过使用struct dma_slave_config数据结构来允许此配置,该结构表示 DMA 通道的运行时配置。这使得客户端能够指定例如 DMA 方向、DMA 地址(源和目标)、总线宽度和 DMA 突发长度等参数,以适应外设。然后,这一配置通过dmaengine_slave_config()函数应用于底层硬件,函数定义如下:

int dmaengine_slave_config(struct dma_chan *chan,
                         struct dma_slave_config *config)

chan参数表示要配置的 DMA 通道,而config是要应用的配置。

为了更好地微调这个配置,我们需要查看定义如下的struct dma_slave_config结构:

struct dma_slave_config {
     enum dma_transfer_direction direction;
     phys_addr_t src_addr;
     phys_addr_t dst_addr;
     enum dma_slave_buswidth src_addr_width;
     enum dma_slave_buswidth dst_addr_width;
     u32 src_maxburst;
     u32 dst_maxburst;
     [...]
};

这里是结构体中每个元素的含义:

  • direction指示数据是否应在此从设备通道中流入或流出。目前的可能值如下:

    /* dma transfer mode and direction indicator */
    enum dma_transfer_direction {
        DMA_MEM_TO_MEM, /* Async/Memcpy mode */
        DMA_MEM_TO_DEV, /* From Memory to Device */
        DMA_DEV_TO_MEM, /* From Device to Memory */
        DMA_DEV_TO_DEV, /* From Device to Device */
        DMA_TRANS_NONE, 
    };
    
  • src_addr:这是 DMA 从设备读取数据的缓冲区的物理地址(实际上是总线地址)。如果源是内存,则此元素会被忽略。dst_addr是 DMA 将数据写入的缓冲区的物理地址(总线地址),如果源是内存,则会被忽略。src_addr_width是源(RX)寄存器的宽度,以字节为单位,DMA 数据应从中读取。如果源是内存,具体是否适用将取决于架构。同样,dst_addr_widthsrc_addr_width相同,但用于目标(TX)。

任何总线宽度必须是以下枚举值之一:

enum dma_slave_buswidth {
    DMA_SLAVE_BUSWIDTH_UNDEFINED = 0,
    DMA_SLAVE_BUSWIDTH_1_BYTE = 1,
    DMA_SLAVE_BUSWIDTH_2_BYTES = 2,
    DMA_SLAVE_BUSWIDTH_3_BYTES = 3,
    DMA_SLAVE_BUSWIDTH_4_BYTES = 4,
    DMA_SLAVE_BUSWIDTH_8_BYTES = 8,
    DMA_SLAVE_BUSWIDTH_16_BYTES = 16,
    DMA_SLAVE_BUSWIDTH_32_BYTES = 32,
    DMA_SLAVE_BUSWIDTH_64_BYTES = 64,
};
  • src_maxburs:这是可以在单个突发中发送到设备的最大字数(将字视为src_addr_width成员的单位,而不是字节)。在 I/O 外设上,通常使用 FIFO 深度的一半,以防溢出。在内存源上,这可能适用,也可能不适用。dst_maxburstsrc_maxburst类似,但它用于目标设备。

以下是 DMA 通道配置的示例:

struct dma_chan *my_dma_chan;
dma_addr_t dma_src_addr, dma_dst_addr;
struct dma_slave_config channel_cfg = {0};
/* No filter callback, neither filter param */
my_dma_chan = dma_request_channel(my_dma_cap_mask,
                                   NULL, NULL);
/* scr_addr and dst_addr are ignored for mem to mem copy */
channel_cfg.direction = DMA_MEM_TO_MEM;
channel_cfg.dst_addr_width = DMA_SLAVE_BUSWIDTH_32_BYTES;
dmaengine_slave_config(my_dma_chan, &channel_cfg);

在前面的代码片段中,dma_request_channel()被用来请求一个 DMA 通道,接着通过dmaengine_slave_config()对其进行配置。

配置 DMA 传输

这一步允许定义传输的类型。通过与 DMA 通道关联的控制器的device_prep_dma_*回调函数来配置(或者说准备)DMA 传输。每个这些 API 返回一个传输描述符,表示为struct dma_async_tx_descriptor数据结构,可以在稍后提交传输之前用于定制。

对于内存到内存的传输,例如,你应该使用device_prep_dma_memcpy回调函数,如以下代码所示:

struct dma_device *dma_dev = my_dma_chan->device;
struct dma_async_tx_descriptor *tx_desc = NULL;
tx_desc = dma_dev->device_prep_dma_memcpy(
                          my_dma_chan, dma_dst_addr,
                          dma_src_addr, BUFFER_SIZE, 0);
if (!tx_desc) {
    /* dma_unmap_* the buffer */
    handle_error();
}

在前面的代码示例中,我们直接调用了控制器回调,虽然我们本可以先检查其是否存在。然而,出于合理性和可移植性考虑,建议使用 dmaengine_prep_* DMA 引擎 API,而不是直接调用控制器回调。我们的 tx_desc 赋值将具有以下形式:

tx_desc = dmaengine_prep_dma_memcpy(my_dma_chan,
             dma_dst_addr, dma_src_addr, BUFFER_SIZE, 0);

这种方法在涉及可能发生变化的控制器数据结构时更安全且具有可移植性。

此外,客户端驱动可以使用 dma_async_tx_descriptor 结构的 callback 元素(由 dmaengine_prep_* 函数返回)来提供完成回调。

提交 DMA 传输

为了将事务放入驱动程序待处理队列中,使用 dmaengine_submit(),它具有以下原型:

dma_cookie_t dmaengine_submit(
                  struct dma_async_tx_descriptor *desc)

这个 API 是控制器 device_issue_pending 回调的前端。此函数返回一个 cookie,你可以使用它来通过其他 DMA 引擎检查 DMA 活动的进展。为了检查返回的 cookie 是否有效,你可以使用 dma_submit_error() 辅助函数,正如我们在示例中将看到的那样。如果尚未提供完成回调,可以在提交传输之前进行设置,如下摘录所示:

struct completion transfer_ok;
init_completion(&transfer_ok);
/*
 * you can also set the parameter to be given to this 
 * callback in tx->callback_param
 */
Tx_desc->callback = my_dma_callback;
/* Submitting our DMA transfer */
dma_cookie_t cookie = dmaengine_submit(tx);
if (dma_submit_error(cookie)) {
    /* handle error */
    [...]
}

上面的摘录非常简短且易于理解。要传递给回调的参数必须设置在描述符的 callback_param 字段中。它可以是一个设备状态结构,例如。

注意

每当 DMA 传输完成后,DMA 控制器会产生一个中断,之后队列中的下一个传输将被启动,并激活一个任务。若客户端驱动提供了完成回调,则当任务被调度时,任务将调用该回调。因此,完成回调在中断上下文中运行。

发起待处理的 DMA 请求并等待回调通知

启动事务是 DMA 传输设置的最后一步。通过调用 dma_async_issue_pending() 在该通道上激活待处理队列中的事务。如果通道空闲,则队列中的第一个事务将被启动,随后的事务将排队。DMA 操作完成后,下一个事务会启动,并触发任务。该任务负责在设置了回调的情况下调用客户端驱动的完成回调例程以进行通知:

void dma_async_issue_pending(struct dma_chan *chan);

该函数是控制器 device_issue_pending 回调的封装。其用法示例如下所示:

dma_async_issue_pending(my_dma_chan);
wait_for_completion(&transfer_ok);
/* may be unmap buffer if necessary and if it is not
 * done in the completion callback yet
 */
[...]
/* Process buffer through rx_data and tx_data virtual addresses. */
[...]

wait_for_completion() 函数会阻塞,将当前任务挂起,直到我们的 DMA 回调被调用,更新(完成)我们的完成变量,以便恢复被阻塞的代码。这是 while (!done) msleep(SOME_TIME); 的一个很好的替代方案。以下是一个示例:

static void my_dma_complete_callback (void *param)
{
    complete(transfer_ok);
[...]
}

这一切都在我们的 DMA 传输实现中。当完成回调返回时,主代码将恢复并继续正常工作流程。

经过 DMA 引擎 API 的讲解后,我们可以通过一个完整的示例来总结这些知识,如下节所示。

将所有内容汇集在一起——单缓冲区 DMA 映射

假设我们希望映射一个单一缓冲区(流式映射),并将数据从源src通过 DMA 传输到目标dst。我们将使用一个字符设备,这样在该设备中进行任何写操作时都会触发 DMA,而任何读操作都会对比源和目标是否匹配。

首先,我们列出所需的头文件以便调用必要的 API:

#define pr_fmt(fmt) "DMA-TEST: " fmt
#include <linux/module.h>
#include <linux/slab.h>
#include <linux/init.h>
#include <linux/dma-mapping.h>
#include <linux/fs.h>
#include <linux/dmaengine.h>
#include <linux/device.h>
#include <linux/io.h>
#include <linux/delay.h>

现在让我们为驱动程序定义一些全局变量:

/* we need page aligned buffers */
#define DMA_BUF_SIZE  2 * PAGE_SIZE
static u32 *wbuf;
static u32 *rbuf;
static int dma_result;
static int gMajor; /* major number of device */
static struct class *dma_test_class;
static struct completion dma_m2m_ok;
static struct dma_chan *dma_m2m_chan;

在前面的代码中,wbuf代表源缓冲区,rbuf代表目标缓冲区。由于我们的实现是基于字符设备的,gMajordma_test_class用于表示字符设备的主设备号和类别。

由于 DMA 映射需要提供设备结构作为第一个参数,我们来创建一个虚拟的设备结构:

static void dev_release(struct device *dev)
{
    pr_info( "releasing dma capable device\n");
}
static struct device dev = {
    .release = dev_release,
    .coherent_dma_mask = ~0, // allow any address
    .dma_mask = &dev.coherent_dma_mask,// use the same mask
};

由于我们使用的是静态设备,我们在设备结构中设置了设备的 DMA 掩码。在平台驱动中,我们会使用dma_set_mask_and_coherent()来实现这一点。

现在是实现我们第一个文件操作——open方法的时候了,在我们的案例中,这个方法只是简单地分配缓冲区:

int dma_open(struct inode * inode, struct file * filp)
{     
     init_completion(&dma_m2m_ok);
     wbuf = kzalloc(DMA_BUF_SIZE, GFP_KERNEL | GFP_DMA);
     if(!wbuf) {
           pr_err("Failed to allocate wbuf!\n");
           return -ENOMEM;
     }
     rbuf = kzalloc(DMA_BUF_SIZE, GFP_KERNEL | GFP_DMA);
     if(!rbuf) {
           kfree(wbuf);
           pr_err("Failed to allocate rbuf!\n");
           return -ENOMEM;
     }
     return 0;
}

前述的字符设备的open操作除了分配将用于我们传输的缓冲区外,什么也不做。这些缓冲区将在设备文件关闭时被释放,这将导致调用我们设备的释放函数,具体实现如下:

int dma_release(struct inode * inode, struct file * filp)
{
     kfree(wbuf);
     kfree(rbuf);
     return 0;
}

我们进入了read方法的实现。这个方法将简单地向内核消息缓冲区添加一条记录,报告 DMA 操作的结果。它的实现如下:

ssize_t dma_read (struct file *filp, char __user * buf,
                   size_t count, loff_t * offset)
{
     pr_info("DMA result: %d!\n", dma_result);
     return 0;
}

现在进入与 DMA 相关的部分。我们首先实现完成回调,这个回调仅仅是调用complete()函数在我们的完成结构上,并在内核日志缓冲区中添加一条跟踪信息。其实现如下:

static void dma_m2m_callback(void *data)
{
    pr_info("in %s\n",__func__);
    complete(&dma_m2m_ok);
}

我们决定在write方法中实现所有的 DMA 逻辑。做出这个选择没有技术上的原因。用户可以根据以下实现自由地调整代码架构:

ssize_t dma_write(struct file * filp,
                  const char __user * buf,
                  size_t count, loff_t * offset)
{
    u32 *index, i;
    size_t err = count;
    dma_cookie_t cookie;
    dma_cap_mask_t dma_m2m_mask;
    dma_addr_t dma_src, dma_dst;
    struct dma_slave_config dma_m2m_config = {0};
    struct dma_async_tx_descriptor *dma_m2m_desc;

在前面的代码中,有些变量是我们执行内存到内存 DMA 传输时需要的。

既然我们的变量已经定义好了,我们就用一些内容初始化源缓冲区,这些内容稍后会通过 DMA 操作被复制到目标缓冲区:

    pr_info("Initializing buffer\n");
    index = wbuf;
    for (i = 0; i < DMA_BUF_SIZE/4; i++) {
        *(index + i) = 0x56565656;
    }
    data_dump("WBUF initialized buffer", (u8*)wbuf,
               DMA_BUF_SIZE);
    pr_info("Buffer initialized\n");

源缓冲区已经准备好,我们现在可以开始与 DMA 相关的代码。在第一步中,我们初始化能力并请求一个 DMA 通道:

     dma_cap_zero(dma_m2m_mask);
     dma_cap_set(DMA_MEMCPY, dma_m2m_mask);
     dma_m2m_chan = dma_request_channel(dma_m2m_mask,
                                         NULL, NULL);
     if (!dma_m2m_chan) {
           pr_err("Error requesting the DMA channel\n");
           return -EINVAL;
     } else {
           pr_info("Got DMA channel %d\n",
                    dma_m2m_chan->chan_id);
     }

在前面的代码中,通道也可以通过dma_m2m_chan = dma_request_chan_by_mask(&dma_m2m_mask);来注册。使用这种方法的好处是,只需要在参数中指定掩码,驱动程序就不需要关心其他参数。

在第二步中,我们设置从设备和控制器特定的参数,然后为源缓冲区和目标缓冲区创建映射:

     dma_m2m_config.direction = DMA_MEM_TO_MEM;
     dma_m2m_config.dst_addr_width =
                     DMA_SLAVE_BUSWIDTH_4_BYTES;
     dmaengine_slave_config(dma_m2m_chan,
                            &dma_m2m_config);
     pr_info("DMA channel configured\n");
     /* Grab bus addresses to prepare the DMA transfer */
     dma_src = dma_map_single(&dev, wbuf, DMA_BUF_SIZE,    
                               DMA_TO_DEVICE);
     if (dma_mapping_error(&dev, dma_src)) {
           pr_err("Could not map src buffer\n");
           err = -ENOMEM;
           goto channel_release;
     }
     dma_dst = dma_map_single(&dev, rbuf, DMA_BUF_SIZE,
                               DMA_FROM_DEVICE);
     if (dma_mapping_error(&dev, dma_dst)) {
           dma_unmap_single(&dev, dma_src,
                            DMA_BUF_SIZE, DMA_TO_DEVICE);
           err = -ENOMEM;
           goto channel_release;
     }
     pr_info("DMA mappings created\n");

在第三步中,我们获取事务的描述符:

    dma_m2m_desc = 
        dmaengine_prep_dma_memcpy(dma_m2m_chan,
                      dma_dst, dma_src, DMA_BUF_SIZE,0);
     if (!dma_m2m_desc) {
           pr_err("error in prep_dma_sg\n");
           err = -EINVAL;
           goto dma_unmap;
     }
     dma_m2m_desc->callback = dma_m2m_callback;

调用dmaengine_prep_dma_memcpy()会触发dma_m2m_chan->device->device_prep_dma_memcpy()。然而,建议使用 DMA 引擎方法,因为它更加便携。

在第四步中,我们提交 DMA 事务:

     cookie = dmaengine_submit(dma_m2m_desc);
     if (dma_submit_error(cookie)) {
           pr_err("Unable to submit the DMA coockie\n");
           err = -EINVAL;
           goto dma_unmap;
     }
     pr_info("Got this cookie: %d\n", cookie);

现在事务已被提交,我们可以进入第五步,也是最后一步,我们发起待处理的 DMA 请求,并等待回调通知:

     dma_async_issue_pending(dma_m2m_chan);
     pr_info("waiting for DMA transaction...\n");
     /* you also can use wait_for_completion_timeout() */
     wait_for_completion(&dma_m2m_ok);

在代码的这一点,DMA 事务已经完成,我们可以检查源缓冲区和目标缓冲区是否具有相同的内容。然而,在访问缓冲区之前,它们必须先同步;幸运的是,解除映射方法会隐式执行缓冲区同步:

dma_unmap:
    /* we do not care about the source anymore */
    dma_unmap_single(&dev, dma_src, DMA_BUF_SIZE,
                       DMA_TO_DEVICE);
    /* unmap the DMA memory destination for CPU access.
     * This will sync the buffer */
    dma_unmap_single(&dev, dma_dst, DMA_BUF_SIZE,
                       DMA_FROM_DEVICE);
    /* 
     * if no error occured, then we are safe to access 
     * the buffer. The buffer must be synced first, and 
     * thanks to dma_unmap_single(), it is.
     */
    if (err >= 0) {
        pr_info("Checking if DMA succeed ...\n");
        for (i = 0; i < DMA_BUF_SIZE/4; i++) {
            if (*(rbuf+i) != *(wbuf+i)) {
                pr_err("Single DMA buffer copy falled!, 
                        r=%x,w=%x,%d\n",
                        *(rbuf+i), *(wbuf+i), i);
                return err;
            }
        }
        pr_info("buffer copy passed!\n");
        dma_result = 1;
        data_dump("RBUF DMA buffer", (u8*)rbuf, 
                  DMA_BUF_SIZE);
    }
channel_release:
     dma_release_channel(dma_m2m_chan);
     dma_m2m_chan = NULL;
     return err;
}

在前面的写操作中,我们经历了执行 DMA 传输所需的五个步骤:请求 DMA 通道;配置该通道;准备 DMA 传输;提交该传输;然后触发传输并在此过程中提供完成回调。

完成操作定义后,我们可以设置文件操作数据结构,具体如下:

struct file_operations dma_fops = {
     .open = dma_open,
     .read = dma_read,
     .write = dma_write,
     .release = dma_release,
};

现在文件操作已设置完成,我们可以实现模块的init函数,在其中创建并注册字符设备,具体如下:

int __init dma_init_module(void)
{
    int error;
    struct device *dma_test_dev;
    /* register a character device */
    error = register_chrdev(0, "dma_test", &dma_fops);
    if (error < 0) {
      pr_err("DMA test driver can't get major number\n");
        return error;
    }
    gMajor = error;
    pr_info("DMA test major number = %d\n",gMajor);
    dma_test_class = class_create(THIS_MODULE, 
                                  "dma_test");
    if (IS_ERR(dma_test_class)) {
       pr_err("Error creating dma test module class.\n");
       unregister_chrdev(gMajor, "dma_test");
       return PTR_ERR(dma_test_class);
    }
    dma_test_dev = device_create(dma_test_class, NULL,
                     MKDEV(gMajor, 0), NULL, "dma_test");
    if (IS_ERR(dma_test_dev)) {
       pr_err("Error creating dma test class device.\n");
       class_destroy(dma_test_class);
       unregister_chrdev(gMajor, "dma_test");
       return PTR_ERR(dma_test_dev);
    }
     dev_set_name(&dev, "dmda-test-dev");
     device_register(&dev);
     pr_info("DMA test Driver Module loaded\n");
     return 0;
}

模块初始化将创建并注册一个字符设备。当模块卸载时,必须撤销此操作,即在模块的exit方法中,具体实现如下:

static void dma_cleanup_module(void)
{
    unregister_chrdev(gMajor, "dma_test");
    device_destroy(dma_test_class, MKDEV(gMajor, 0));
    class_destroy(dma_test_class);
    device_unregister(&dev);
    pr_info("DMA test Driver Module Unloaded\n");
}

在这一点上,我们可以将模块的初始化和退出方法注册到驱动核心,并为模块提供元数据。操作步骤如下:

module_init(dma_init_module);
module_exit(dma_cleanup_module);
MODULE_AUTHOR("John Madieu, <john.madieu@laabcsmart.com>");
MODULE_DESCRIPTION("DMA test driver");
MODULE_LICENSE("GPL");

完整代码可以在书籍的仓库中找到,位于chapter-12/目录下。

现在我们已经熟悉了 DMA 引擎 API,并通过具体示例总结了我们的技能,我们可以讨论一种特定的 DMA 传输模式——循环 DMA,它主要用于 UART 驱动程序。

关于循环 DMA

循环模式是一种特殊的 DMA 传输模式,在这种模式下,I/O 外设驱动数据事务,定期触发数据传输。在处理 DMA 控制器可能暴露的回调时,我们看到了dma_device.device_prep_dma_cyclic,它是dmaengine_prep_dma_cyclic()的后端,具有以下原型:

struct dma_async_tx_descriptor 
     *dmaengine_prep_dma_cyclic(
             struct dma_chan *chan, dma_addr_t buf_addr,
             size_t buf_len, size_t period_len,
             enum dma_transfer_direction dir,
             unsigned long flags)

上述 API 接受五个参数:chan,分配的 DMA 通道结构;buf_addr,映射的 DMA 缓冲区的句柄;buf_len,DMA 缓冲区的大小;period_len,一个循环周期的大小;dir,DMA 传输的方向;flags,此传输的控制标志。如果成功,该函数返回一个 DMA 通道描述符结构,可用于为 DMA 传输分配完成函数。大多数情况下,flags 对应于 DMA_PREP_INTERRUPT,这意味着每次周期完成时都应调用 DMA 传输回调函数。

循环模式主要用于 TTY 驱动程序,其中数据被输入到一个先进先出FIFO)环形缓冲区。在这种模式下,分配的 DMA 缓冲区被划分为大小相等的周期(通常称为循环周期),以便每次完成一次传输时,回调函数都会被调用。

实现的回调函数用于跟踪环形缓冲区的状态,缓冲区管理则使用内核环形缓冲区 API 实现(因此需要包含 <linux/circ_buf.h>):

图 11.3 – 循环 DMA 环形缓冲区

图 11.3 – 循环 DMA 环形缓冲区

以下是 drivers/tty/serial/atmel_serial.c 中的一个例子,展示了循环 DMA 的原理。

驱动程序首先准备 DMA 资源,具体如下:

static int atmel_prepare_rx_dma(struct uart_port *port)
{
    struct atmel_uart_port *atmel_port = 
                       to_atmel_uart_port(port);
     struct device *mfd_dev = port->dev->parent;
     struct dma_async_tx_descriptor *desc;
     dma_cap_mask_t        mask;
     struct dma_slave_config config;
     struct circ_buf       *ring;
     int ret, nent;
     ring = &atmel_port->rx_ring;
     dma_cap_zero(mask);
     dma_cap_set(DMA_CYCLIC, mask);
    atmel_port->chan_rx = 
              dma_request_slave_channel(mfd_dev, "rx");
    sg_init_one(&atmel_port->sg_rx, ring->buf,
                  sizeof(struct atmel_uart_char) *
                    ATMEL_SERIAL_RINGSIZE);
    nent = dma_map_sg(port->dev, &atmel_port->sg_rx, 1,
                       DMA_FROM_DEVICE);
    /* Configure the slave DMA */
    [...]
    ret = dmaengine_slave_config(atmel_port->chan_rx,
                           &config);
    /* Prepare a cyclic dma transfer, assign 2
     * descriptors, each one is half ring buffer size */
     desc =
       dmaengine_prep_dma_cyclic(atmel_port->chan_rx,
           sg_dma_address(&atmel_port->sg_rx),
           sg_dma_len(&atmel_port->sg_rx),
           sg_dma_len(&atmel_port->sg_rx)/2,
           DMA_DEV_TO_MEM, DMA_PREP_INTERRUPT);
    desc->callback = atmel_complete_rx_dma;
    desc->callback_param = port;
    atmel_port->desc_rx = desc;
    atmel_port->cookie_rx = dmaengine_submit(desc);
    dma_async_issue_pending(chan);
    return 0;
chan_err:
[...]
}

为了提高可读性,省略了错误检查。该函数首先设置适当的 DMA 能力掩码(使用 dma_set_cap()),然后请求 DMA 通道。请求通道后,创建映射(一个流式映射),并使用 dmaengine_slave_config() 配置通道。随后,通过 dmaengine_prep_dma_cyclic() 获取一个循环 DMA 传输描述符,DMA_PREP_INTERRUPT 用于指示 DMA 引擎核心在每个周期的传输结束时调用回调函数。获取的描述符在提交给 DMA 控制器之前,会与回调函数及其参数一起配置,并通过 dmaengine_submit() 提交,并通过 dma_async_issue_pending() 激活。

atmel_complete_rx_dma() 回调将调度一个任务,该任务的处理函数是 atmel_tasklet_rx_func(),它将调用真正的 DMA 完成回调 atmel_rx_from_dma(),实现如下:

static void atmel_rx_from_dma(struct uart_port *port)
{
    struct atmel_uart_port *atmel_port =
                               to_atmel_uart_port(port);
    struct tty_port *tport = &port->state->port;
    struct circ_buf *ring = &atmel_port->rx_ring;
    struct dma_chan *chan = atmel_port->chan_rx;
    struct dma_tx_state state;
    enum dma_status dmastat;
    size_t count;
    dmastat = dmaengine_tx_status(chan,
                  atmel_port->cookie_rx, &state);
    /* CPU claims ownership of RX DMA buffer */
    dma_sync_sg_for_cpu(port->dev, &atmel_port->sg_rx, 1,
                        DMA_FROM_DEVICE);
    /* The current transfer size should not be larger 
     * than the dma buffer length.
     */
    ring->head =
         sg_dma_len(&atmel_port->sg_rx) - state.residue;

    /* we first read from tail to the end of the buffer
     * then reset tail */
    if (ring->head < ring->tail) {
        count =
            sg_dma_len(&atmel_port->sg_rx) - ring->tail;
        tty_insert_flip_string(tport,
                          ring->buf + ring->tail, count);
           ring->tail = 0;
           port->icount.rx += count;
     }
     /* Finally we read data from tail to head */
     if (ring->tail < ring->head) {
           count = ring->head - ring->tail;
        tty_insert_flip_string(tport,
                         ring->buf + ring->tail, count);
        /* Wrap ring->head if needed */
        if (ring->head >= sg_dma_len(&atmel_port->sg_rx))
            ring->head = 0;
        ring->tail = ring->head;
        port->icount.rx += count;
     }
    /* USART retrieves ownership of RX DMA buffer */
    dma_sync_sg_for_device(port->dev, &atmel_port->sg_rx,
                            1, DMA_FROM_DEVICE);
     [...]
     tty_flip_buffer_push(tport);
[...]
}

在 DMA 完成回调中,我们可以看到,在 CPU 访问缓冲区之前,调用了 dma_sync_sg_for_cpu() 来使相应的硬件缓存行无效。然后,执行一些环形缓冲区和 TTY 相关的操作(分别是读取接收到的数据并将其转发到 TTY 层)。最后,在调用 dma_sync_sg_for_device() 后,缓冲区被归还给设备。

总结一下,前面的示例不仅展示了循环 DMA 的工作原理,还展示了如何在传输之间(无论是 CPU 还是设备使用和重用缓冲区时)解决一致性问题。

现在我们已经熟悉了循环 DMA,我们已完成关于 DMA 传输和 DMA 引擎 API 的系列内容。我们学会了如何设置传输、启动它们,并等待它们完成。

在下一节中,我们将学习如何从设备树和代码中指定和获取 DMA 通道。

理解 DMA 和 DT 绑定

DMA 通道的 DT 绑定依赖于 DMA 控制器节点,这与 SoC 相关,并且某些参数(如 DMA 单元)可能因 SoC 的不同而有所变化。本示例仅关注 i.MX SDMA 控制器,可以在内核源代码中找到,路径为 Documentation/devicetree/bindings/dma/fsl-imx-sdma.txt

消费者绑定

根据 SDMA 事件映射表,以下代码展示了 i.MX 6Dual/6Quad 中外设的 DMA 请求信号:

uart1: serial@02020000 {
    compatible = "fsl,imx6sx-uart", "fsl,imx21-uart";
    reg = <0x02020000 0x4000>;
    interrupts = <GIC_SPI 26 IRQ_TYPE_LEVEL_HIGH>;
    clocks = <&clks IMX6SX_CLK_UART_IPG>,
                <&clks IMX6SX_CLK_UART_SERIAL>;
    clock-names = "ipg", "per";
    dmas = <&sdma 25 4 0>, <&sdma 26 4 0>;
    dma-names = "rx", "tx";
    status = "disabled";
};

dma 属性中的第二个单元(25 和 26)对应于 DMA 请求/事件 ID。这些值来自 SoC 手册(在我们的例子中是 i.MX53)。你可以查看 community.nxp.com/servlet/JiveServlet/download/614186-1-373516/iMX6_Firmware_Guide.pdf 和 Linux 参考手册 community.nxp.com/servlet/JiveServlet/download/614186-1-373515/i.MX_Linux_Reference_Manual.pdf

第三个单元表示使用优先级。请求指定参数的驱动代码在接下来的部分中定义。你可以在内核源代码树中的 drivers/tty/serial/imx.c 文件中找到完整的代码。以下是从设备树中抓取元素的代码摘录:

static int imx_uart_dma_init(struct imx_port *sport)
{
    struct dma_slave_config slave_config = {};
    struct device *dev = sport->port.dev;
    int ret;
    /* Prepare for RX : */
    sport->dma_chan_rx =
               dma_request_slave_channel(dev, "rx");
    if (!sport->dma_chan_rx)
        /* cannot get the DMA channel. handle error */
        [...]
    [...] /* configure the slave channel */
    ret = dmaengine_slave_config(sport->dma_chan_rx,
                                 &slave_config);
[...]
    /* Prepare for TX */
    sport->dma_chan_tx =
                 dma_request_slave_channel(dev, "tx");
    if (!sport->dma_chan_tx) {
        /* cannot get the DMA channel. handle error */
        [...]
    [...] /* configure the slave channel */
    ret = dmaengine_slave_config(sport->dma_chan_tx,
                                 &slave_config);
    if (ret) {
        [...] /* handle error */
    }
    [...]
}

这里的关键调用是 dma_request_slave_channel(),它将使用 of_dma_request_slave_channel() 解析设备节点(在 DT 中),根据 DMA 通道名称收集通道设置(参考 第六章理解和利用设备树)。

总结

DMA 是许多现代 CPU 中都具备的特性。本章将为你提供必要的步骤,帮助你充分利用该设备,使用内核 DMA 映射和 DMA 引擎 API。在本章结束后,我确信你将能够设置至少一个内存到内存的 DMA 传输。更多信息可以在 Documentation/dmaengine/ 中找到,位于内核源代码树中。

然而,下一章将讨论 regmap,它引入了面向内存的抽象,并统一了对面向内存设备(如 I2C、SPI 或内存映射设备)的访问。

第十二章:第十二章:抽象内存访问 – Regmap API 简介:寄存器映射抽象

在开发 Regmap API 之前,处理 SPI、I2C 或内存映射设备的设备驱动程序存在冗余代码。这些驱动程序中有很多非常相似的代码用于访问硬件设备寄存器。

下图展示了在引入 Regmap 之前,SPI、I2C 和内存映射相关的 API 如何单独使用:

图 12.1 – Regmap 引入前的 I2C、SPI 和内存映射访问

图 12.1 – Regmap 引入前的 I2C、SPI 和内存映射访问

Regmap API 于 Linux 内核的 v3.1 版本中引入,提出了一个解决方案,将这些相似的寄存器访问代码进行抽象并统一,节省了代码量,并使基础设施共享变得更加容易。接下来,便是如何初始化和配置 regmap 结构,并流畅地处理任何读写/修改操作,无论是 SPI、I2C 还是内存映射。

以下图示描述了此 API 的统一化:

图 12.2 – Regmap 引入后的 I2C、SPI 和内存映射访问

图 12.2 – Regmap 引入后的 I2C、SPI 和内存映射访问

上图展示了 Regmap 如何统一设备与各自总线框架之间的事务。在本章中,我们将尽可能涵盖该框架提供的 API 的各个方面,从初始化到复杂的使用场景。

本章将通过以下主题详细讲解 Regmap 框架:

  • Regmap 数据结构简介

  • 处理 Regmap 初始化

  • 使用 Regmap 寄存器访问函数

  • 基于 Regmap 的 SPI 驱动示例 – 整合所有内容

  • 从用户空间利用 Regmap

Regmap 数据结构简介

Regmap 框架通过 CONFIG_REGMAP 内核配置选项启用,由几个数据结构组成,其中最重要的是 struct regmap_config,它表示 Regmap 配置,和 struct regmap,它是 Regmap 实例本身。也就是说,所有 Regmap 数据结构都定义在 include/linux/regmap.h 中。因此,这个头文件必须在所有基于 Regmap 的驱动程序中包含:

#include <linux/regmap.h>

包含前述头文件足以充分利用 Regmap 框架。通过此头文件,许多数据结构将变得可用,其中 struct regmap_config 是最重要的,我们将在下一节中详细描述。

理解 struct regmap_config 结构体

struct regmap_config 存储驱动生命周期内寄存器映射的配置。你在此设置的内容会影响内存的读写操作。这是最重要的结构,定义如下:

struct regmap_config {
    const char *name;
    int reg_bits;
    int reg_stride;
    int pad_bits;
    int val_bits;
    bool (*writeable_reg)(struct device *dev, 
                          unsigned int reg);
    bool (*readable_reg)(struct device *dev, 
                         unsigned int reg);
    bool (*volatile_reg)(struct device *dev, 
                         unsigned int reg);
    bool (*precious_reg)(struct device *dev, 
                         unsigned int reg);
    bool disable_locking;
    regmap_lock lock;
    regmap_unlock unlock;
    void *lock_arg;
    int (*reg_read)(void *context, unsigned int reg,
                    unsigned int *val);
    int (*reg_write)(void *context, unsigned int reg,
                    unsigned int val);
    bool fast_io;
    unsigned int max_register;
    const struct regmap_access_table *wr_table;
    const struct regmap_access_table *rd_table;
    const struct regmap_access_table *volatile_table;
    const struct regmap_access_table *precious_table;
[...]
    const struct reg_default *reg_defaults;
    unsigned int num_reg_defaults;
    enum regcache_type cache_type;
    const void *reg_defaults_raw;
    unsigned int num_reg_defaults_raw;
    unsigned long read_flag_mask;
    unsigned long write_flag_mask;
    bool use_single_rw;
    bool can_multi_write;
    enum regmap_endian reg_format_endian;
    enum regmap_endian val_format_endian;
    const struct regmap_range_cfg *ranges;
    unsigned int num_ranges;
}

不要担心这个结构有多复杂,所有元素都是自解释的。然而,为了更清晰,下面将详细说明它们的含义:

  • reg_bits 是一个必填字段,它是寄存器地址中有效位的数量。这是寄存器地址的位大小。

  • reg_stride 表示有效寄存器地址必须是该值的倍数。如果设置为 0,则使用 1 作为默认值。如果设置为 4,例如,只有当地址是 4 的倍数时,该地址才被认为是有效的。

  • pad_bits 是寄存器和数值之间的填充位数。这是格式化时需要(左)移位寄存器值的位数。

  • val_bits 表示用于存储寄存器值的位数。它是一个必填字段。

  • writeable_reg 是一个可选的回调函数。如果提供,它将在需要写寄存器时由 Regmap 子系统使用。在写入寄存器之前,系统会自动调用此函数检查该寄存器是否可以写入。以下是使用该函数的示例:

    static bool foo_writeable_register(struct device *dev,
                                        unsigned int reg)
    {
        switch (reg) {
        case 0x30 ... 0x38:
        case 0x40 ... 0x45:
        case 0x50 ... 0x57:
        case 0x60 ... 0x6e:
        case 0x70 ... 0x75:
        case 0x80 ... 0x85:
        case 0x90 ... 0x95:
        case 0xa0 ... 0xa5:
        case 0xb0 ... 0xb2:
            return true;
        default:
            return false;
        }
    }
    
  • readable_regwriteable_reg 相同,但用于所有寄存器读操作。

  • volatile_reg 是一个可选的回调函数,每次需要通过 Regmap 缓存读写寄存器时都会调用。如果寄存器是易失性的,该函数应返回 true。然后将直接对寄存器进行读写。如果返回 false,则表示寄存器是可缓存的。在这种情况下,读取操作将使用缓存,写入操作时缓存将被写入:

    static bool foo_volatile_register(struct device *dev,
                                        unsigned int reg)
    {
        switch (reg) {
        case 0x24 ... 0x29:
        case 0xb6 ... 0xb8:
            return true;
        default:
            return false;
        }
    }
    
  • precious_reg: 一些设备对其某些寄存器的读取很敏感,特别是像清除读中断状态寄存器这样的寄存器。设置此项后,如果指定寄存器属于此类,必须返回 true,防止核心(例如 debugfs)内部生成任何读取操作。这样,只有驱动程序显式读取时才允许操作。

  • disable_locking 表示是否应使用以下锁定/解锁回调。如果为 false,则表示不使用任何锁定机制。意味着该 regmap 对象要么由外部方式保护,要么保证不会被多个线程访问。

  • lock/unlock 是可选的锁定/解锁回调函数,覆盖 regmap 的默认锁定/解锁函数,基于自旋锁或互斥锁,具体取决于访问底层设备是否会使调用者进入休眠状态。

  • lock_arg 将作为锁定/解锁函数的唯一参数(如果未重写常规锁定/解锁函数,则忽略)。

  • reg_read: 您的设备可能不支持简单的 I2C/SPI 读操作。您将不得不编写自己的定制读函数。reg_read 应指向该函数。也就是说,大多数设备并不需要这样做。

  • reg_writereg_read 相同,但用于写操作。

  • fast_io表示寄存器 IO 是快速的。如果设置该项,regmap将使用自旋锁而不是互斥锁来执行锁定操作。如果使用了自定义的锁/解锁函数(此处不讨论),则该字段将被忽略(请参阅内核源码中struct regmap_configlock/unlock字段)。它应该仅用于“无总线”情况(MMIO 设备),因为访问 I2C、SPI 或类似总线可能会导致调用者被挂起。

  • max_register:这个可选元素指定了最大有效寄存器地址,超过该地址将不允许进行任何操作。

  • wr_table:你可以提供一个regmap_access_table对象,代替writeable_reg回调。该对象是一个结构体,包含yes_rangesno_range字段,均为指向struct regmap_range的指针。任何属于yes_range条目的寄存器都被视为可写,而属于no_range条目的寄存器则被视为不可写。

  • rd_tablewr_table相同,但适用于任何读取操作。

  • volatile_table:你可以提供volatile_table代替volatile_reg。其原理与wr_tablerd_table相同,但适用于缓存机制。

  • precious_table:与上述类似,适用于珍贵寄存器。

  • reg_defaultsreg_default类型元素的数组,每个reg_default元素是一个{reg, value}结构,表示寄存器的上电复位值。与缓存一起使用,以便对该数组中存在的地址进行读取,并且自上电复位后未进行写操作时,将返回该数组中的默认寄存器值,而无需执行任何设备读取事务。一个例子是 IIO 设备驱动程序,链接如下:elixir.bootlin.com/linux/v5.10/source/drivers/iio/light/apds9960.c

  • num_reg_defaultsreg_defaults中元素的数量。

  • cache_type:实际的缓存类型,可以是REGCACHE_NONEREGCACHE_RBTREEREGCACHE_COMPRESSEDREGCACHE_FLAT

  • read_flag_mask:这是读取操作时需要应用于寄存器高字节中的掩码。通常,SPI 或 I2C 中的写或读操作的高字节中的最高位被设置,以区分写操作和读操作。

  • write_flag_mask:写操作时需要在寄存器的高字节中设置的掩码。

  • use_single_rw是一个布尔值,如果设置该项,将指示寄存器映射将设备上的任何批量写入或读取操作转换为一系列单次写入或读取操作。这对于不支持批量读写的设备非常有用。

  • can_multi_write仅针对写操作。如果设置该项,表示该设备支持批量写操作的多重写模式。如果未设置,则多重写请求将被拆分为单独的写操作。

你可以查看include/linux/regmap.h获取每个元素的更多详细信息。以下是regmap_config初始化的示例:

static const struct regmap_config regmap_config = {
    .reg_bits       = 8,
    .val_bits       = 8,
    .max_register   = LM3533_REG_MAX,
    .readable_reg   = lm3533_readable_register,
    .volatile_reg   = lm3533_volatile_register,
    .precious_reg   = lm3533_precious_register,
};

前面的示例展示了如何构建一个基本的寄存器映射配置。尽管配置数据结构中只设置了少数几个元素,但通过学习我们描述的每个元素,可以设置更高级的配置。

现在我们已经了解了 Regmap 配置,接下来我们来看看如何使用这个配置与初始化 API 进行配合,满足我们的需求。

处理 Regmap 初始化

正如我们之前所说,Regmap API 支持 SPI、I2C 和内存映射寄存器访问。通过CONFIG_REGMAP_SPICONFIG_REGMAP_I2CCONFIG_REGMAP_MMIO内核配置选项,可以在内核中启用它们各自的支持。它的功能远不止于此,还可以管理 IRQ,但这超出了本书的范围。根据驱动程序需要支持的内存访问方式,你需要在探测函数中调用devm_regmap_init_i2c()devm_regmap_init_spi()devm_regmap_init_mmio()之一。要编写通用驱动程序,Regmap 是你可以做出的最佳选择。

Regmap API 是通用且一致的,初始化只在总线类型之间有所不同。其他函数是相同的。一个好的实践是总是在探测函数中初始化寄存器映射,并且在使用以下 API 初始化寄存器映射之前,必须始终填写regmap_config元素:

struct regmap *devm_regmap_init_spi(struct spi_device *spi,
                            const struct regmap_config);
struct regmap *devm_regmap_init_i2c(struct i2c_client *i2c,
                            const struct regmap_config);
struct regmap * devm_regmap_init_mmio(
                        struct device *dev,
                        void __iomem *regs,
                        const struct regmap_config *config)

这些是资源管理的 API,其分配的资源会在设备离开系统或驱动程序卸载时自动释放。在前面的原型中,返回值将是指向有效的struct regmap对象的指针,或者在失败时返回ERR_PTR()错误。regs是指向内存映射 IO 区域的指针(由devm_ioremap_resource()或任何ioremap*系列函数返回)。dev是要与之交互的设备(struct device),在内存映射的regmap的情况下,spii2c分别是要与之交互的 SPI 或 I2C 设备,在 SPI 或 I2C 类型的regmap中。

调用其中一个函数即可开始与底层设备交互。无论 Regmap 是 I2C、SPI 还是内存映射寄存器映射,如果它没有通过资源管理的 API 变体进行初始化,则必须使用regmap_exit()函数来释放它:

void regmap_exit(struct regmap *map)

此函数简单地释放先前分配的寄存器映射。

现在寄存器访问方法已经定义,我们可以跳转到设备访问函数,这些函数允许从设备寄存器中读取或写入数据。

使用 Regmap 寄存器访问函数

重映射寄存器访问方法处理数据解析、格式化和传输。在大多数情况下,设备访问是通过regmap_read()regmap_write()regmap_update_bits()来执行的,这三个函数是进行设备数据读写时的重要 API。它们各自的原型如下:

int regmap_read(struct regmap *map, unsigned int reg,
                 unsigned int *val);
int regmap_write(struct regmap *map, unsigned int reg,
                 unsigned int val);
int regmap_update_bits(struct regmap *map,
                 unsigned int reg, unsigned int mask,
                 unsigned int val);

regmap_write() 向设备写入数据。如果在 regmap_config 中设置了 max_register,则会使用它来检查需要访问的寄存器地址是否超出范围。如果传递的寄存器地址小于或等于 max_register,则执行下一步操作;否则,Regmap 核心会返回无效的 I/O 错误(-EIO)。紧接着,调用 writeable_reg 回调函数。回调函数必须返回 true 才能进入下一步。如果返回 false,则返回 -EIO,并停止写操作。如果设置了 wr_table 而不是 writeable_reg,则会发生以下情况:

  • 如果寄存器地址位于 no_ranges 中,则返回 -EIO

  • 如果寄存器地址位于 yes_ranges 中,则执行下一步操作。

  • 如果寄存器地址不在 yes_rangeno_range 中,则返回 -EIO,并终止操作。

如果 cache_type != REGCACHE_NONE,则启用缓存。在这种情况下,首先会使用新值更新缓存项,然后再执行硬件写入。否则,不执行缓存操作。如果提供了 reg_write 回调函数,则使用它来执行写操作。否则,将执行通用的 Regmap 写入功能,将数据写入指定的寄存器地址。

regmap_read() 从设备中读取数据。它与 regmap_write() 的工作方式完全相同,使用了适当的数据结构(readable_regrd_table)。因此,如果提供了 reg_read,则使用它来执行读取操作;否则,将执行通用的寄存器映射读取功能。

regmap_update_bits() 是一个三合一函数。它执行指定寄存器地址的读/修改/写周期。它是 _regmap_update_bits 的包装器,代码如下:

static int _regmap_update_bits(struct regmap *map,
             unsigned int reg, unsigned int mask,
             unsigned int val, bool *change,
             bool force_write)
{
    int ret;
    unsigned int tmp, orig;
    if (change)
        *change = false;
    if (regmap_volatile(map, reg) &&
                   map->reg_update_bits) {
        ret = map->reg_update_bits(map->bus_context,
                                   reg, mask, val);
        if (ret == 0 && change)
            *change = true;
    } else {
        ret = _regmap_read(map, reg, &orig);
        if (ret != 0)
            return ret;
        tmp = orig & ~mask;
        tmp |= val & mask;
        if (force_write || (tmp != orig)) {
            ret = _regmap_write(map, reg, tmp);
            if (ret == 0 && change)
                *change = true;
        }
    }
    return ret;
}

这样,你需要更新的位必须在 mask 中设置为 1,并且相应的位应设置为你需要赋予它们的值 val

举个例子,若要将第一位和第三位设置为 1,则 mask 应该是 0b00000101,值应为 0bxxxxx1x1。若要清除第七位,mask 必须是 0b01000000,值应为 0bx0xxxxxx,依此类推。

批量和多个寄存器读/写的 API

regmap_multi_reg_write() 是允许你向设备写入多个寄存器的 API 之一。它的原型如下:

int regmap_multi_reg_write(struct regmap *map,
                    const struct reg_sequence *regs,
                    int num_regs)

在这个原型中,regs 是一个 reg_sequence 类型元素的数组,表示带有可选延迟(单位为微秒)的寄存器/值对的写入序列,延迟应用于每次写入之后。以下是该数据结构的定义:

struct reg_sequence {
    unsigned int reg;
    unsigned int def;
    unsigned int delay_us;
};

在前面的数据结构中,reg 是寄存器地址,def 是寄存器值,delay_us 是在寄存器写入后应用的延迟,单位为微秒。

以下是此类序列的一个用法:

static const struct reg_sequence foo_default_regs[] = {
    { FOO_REG1,       0xB8 },
    { BAR_REG1,       0x00 },
    { FOO_BAR_REG1,   0x10 },
    { REG_INIT,       0x00 },
    { REG_POWER,      0x00 },
    { REG_BLABLA,     0x00 },
};
static int probe ( ...)
{
    [...]
    ret = regmap_multi_reg_write(my_regmap,
                          foo_default_regs,
                          ARRAY_SIZE(foo_default_regs));
    [...]
}

在前面的内容中,我们学习了如何使用第一个多寄存器写入 API,该 API 接收一组寄存器及其对应的值。

还有regmap_bulk_read()regmap_bulk_write(),可以用于从设备读取/写入多个寄存器。它们适用于大块数据,并定义如下:

int regmap_bulk_read(struct regmap *map,
                     unsigned int reg, void *val,
                     size_tval_count);
int regmap_bulk_write(struct regmap *map,
                      unsigned int reg,
                      const void *val, size_t val_count);

在前面函数的参数中,map是要操作的寄存器映射,reg是读取/写入操作开始的寄存器地址。在读取的情况下,val将包含读取的值;它必须被分配足够的空间来存储至少与设备本地寄存器大小相等的count值。在写入操作的情况下,val必须指向要写入设备的数据数组。最后,countval中的元素数量。

理解 Regmap 缓存系统

显然,Regmap 支持数据缓存。是否使用缓存系统取决于regmap_configcache_type字段的值。在查看include/linux/regmap.h时,接受的值如下:

/* An enum of all the supported cache types */
enum regcache_type {
   REGCACHE_NONE,
   REGCACHE_RBTREE,
   REGCACHE_COMPRESSED,
   REGCACHE_FLAT,
};

缓存类型默认为REGCACHE_NONE,意味着缓存被禁用。其他值仅定义了缓存应该如何存储。

你的设备可能在某些寄存器中有预定义的上电复位值。这些值可以存储在数组中,以便任何读取操作都返回数组中包含的值。然而,任何写入操作都会影响设备中的实际寄存器,并更新数组中的内容。这是一种我们可以用来加速设备访问的缓存。这个数组就是reg_defaults。查看源代码,其结构如下:

struct reg_default {
    unsigned int reg;
    unsigned int def;
};

在前面的数据结构中,reg是寄存器地址,def是寄存器的默认值。如果cache_type设置为 none,则会忽略reg_defaults。如果没有设置default_reg元素,但仍启用了缓存,相应的缓存结构将为你创建。

它使用起来非常简单。只需声明它,并将其作为参数传递给regmap_config结构体。让我们看一下drivers/regulator/ltc3589.c中的 LTC3589 调节器驱动程序:

static const struct reg_default ltc3589_reg_defaults[] = {
{ LTC3589_SCR1,   0x00 },
{ LTC3589_OVEN,   0x00 },
{ LTC3589_SCR2,   0x00 },
{ LTC3589_VCCR,   0x00 },
{ LTC3589_B1DTV1, 0x19 },
{ LTC3589_B1DTV2, 0x19 },
{ LTC3589_VRRCR,  0xff },
{ LTC3589_B2DTV1, 0x19 },
{ LTC3589_B2DTV2, 0x19 },
{ LTC3589_B3DTV1, 0x19 },
{ LTC3589_B3DTV2, 0x19 },
{ LTC3589_L2DTV1, 0x19 },
{ LTC3589_L2DTV2, 0x19 },
};
static const struct regmap_config ltc3589_regmap_config = {
        .reg_bits = 8,
        .val_bits = 8,
        .writeable_reg = ltc3589_writeable_reg,
        .readable_reg = ltc3589_readable_reg,
        .volatile_reg = ltc3589_volatile_reg,
        .max_register = LTC3589_L2DTV2,
        .reg_defaults = ltc3589_reg_defaults,
        .num_reg_defaults = ARRAY_SIZE(ltc3589_reg_defaults),
        .use_single_rw = true,
        .cache_type = REGCACHE_RBTREE,
};

对数组中任何一个寄存器进行读取操作时,将立即返回数组中的值。然而,写入操作会在设备本身上执行,并更新数组中受影响的寄存器。这样,读取LTC3589_VRRCR寄存器将返回0xff,并且向该寄存器写入任何值时,它会更新该寄存器在数组中的条目,以便任何新的读取操作都能直接从缓存中返回上次写入的值。

现在,我们能够使用 Regmap API 来访问设备寄存器,无论这些设备依赖于哪种底层总线,接下来是总结我们到目前为止所学到的知识,并通过一个实际的示例来展示。

基于 Regmap 的 SPI 驱动示例——将其整合在一起

设置 Regmap 所涉及的所有步骤,从配置到设备寄存器访问,可以列举如下:

  • 根据设备的特性设置 struct regmap_config 对象。如果需要,可以定义寄存器范围、默认值、cache_type 等。如果需要自定义读/写函数,将其传递给 reg_read/reg_write 字段。

  • probe 函数中,根据与底层设备的连接类型(I2C、SPI 或内存映射)使用 devm_regmap_init_i2c()devm_regmap_init_spi()devm_regmap_init_mmio() 分配寄存器映射。

  • 每当需要读/写寄存器时,调用 remap_[read|write] 函数。

  • 当寄存器映射完成后,假设你使用了资源管理 API,devres 核心会负责释放 Regmap 资源,无需其他操作;否则,你必须调用 regmap_exit() 来释放在 probe 中分配的寄存器映射。

现在让我们在一个实际的驱动示例中实现这些步骤,利用 Regmap 框架的优势。

一个 Regmap 示例

为了实现我们的目标,首先让我们描述一个假设的 SPI 设备,以便我们可以使用 Regmap 框架编写驱动。为了便于理解,假设该设备具有以下特性:

  • 该设备支持 8 位寄存器寻址和 8 位寄存器值。

  • 该设备中可访问的最大地址是 0x80(这并不一定意味着该设备有 0x80 个寄存器)。

  • 写掩码是 0x80,有效地址范围如下:

    • 0x200x4F

    • 0x600x7F

  • 由于该设备支持简单的 SPI 读/写操作,因此无需提供自定义的读/写函数。

现在我们完成了设备和 Regmap 规范的定义,可以开始编写代码了。

以下是处理 Regmap 所需的头文件:

#include <linux/regmap.h>

根据驱动中所需的 API,可能还需要包括其他头文件。

接下来,我们按如下方式定义私有数据结构:

struct private_struct
{
    /* Feel free to add whatever you want here */
    struct regmap *map;
    int foo;
};

然后,我们定义一个读/写寄存器范围,即允许访问的寄存器:

static const struct regmap_range wr_rd_range[] =
{
    {
            .range_min = 0x20,
            .range_max = 0x4F,
    },{
            .range_min = 0x60,
            .range_max = 0x7F
    },
}; 
struct regmap_access_table drv_wr_table =
{
    .yes_ranges =   wr_rd_range,
    .n_yes_ranges = ARRAY_SIZE(wr_rd_range),
};
struct regmap_access_table drv_rd_table =
{
    .yes_ranges =   wr_rd_range,
    .n_yes_ranges = ARRAY_SIZE(wr_rd_range),
};

然而,必须注意,如果设置了 writeable_reg 和/或 readable_reg,则无需提供 wr_table 和/或 rd_table

之后,我们定义回调函数,该函数将在每次进行写或读操作时调用。每个回调必须返回 true,如果允许对寄存器执行指定的操作:

static bool writeable_reg(struct device *dev,
                          unsigned int reg)
{
    if (reg>= 0x20 &&reg<= 0x4F)
        return true;
    if (reg>= 0x60 &&reg<= 0x7F)
        return true;
    return false;
}
static bool readable_reg(struct device *dev,
                         unsigned int reg)
{
    if (reg>= 0x20 &&reg<= 0x4F)
        return true;
    if (reg>= 0x60 &&reg<= 0x7F)
        return true;
    return false;
}

既然所有与 Regmap 相关的操作都已定义,我们可以按如下方式实现驱动的 probe 方法:

static int my_spi_drv_probe(struct spi_device *dev)
{
    struct regmap_config config;
    struct private_struct *priv;
    unsigned char data;
    /* setup the regmap configuration */
    memset(&config, 0, sizeof(config));
    config.reg_bits = 8;
    config.val_bits = 8;
    config.write_flag_mask = 0x80;
    config.max_register = 0x80;
    config.fast_io = true;
    config.writeable_reg = drv_writeable_reg;
    config.readable_reg = drv_readable_reg;
    /* 
     * If writeable_reg and readable_reg are set,
     * there is no need to provide wr_table nor rd_table.
     * Uncomment below code only if you do not want to use
     * writeable_reg nor readable_reg.
     */
    //config.wr_table = drv_wr_table;
    //config.rd_table = drv_rd_table;
    /* allocate the private data structures */
    /* priv = kzalloc */
    /* Init the regmap spi configuration */
    priv->map = devm_regmap_init_spi(dev, &config);
    /* Use devm_regmap_init_i2c in case of i2c bus */
    /* 
     * Let us write into some register
     * Keep in mind that, below operation will remain same
     * whether you use SPI, I2C, or memory mapped Regmap.
     * It is and advantage when you use regmap.
     */ 
    regmap_read(priv->map, 0x30, &data);
    [...] /* Process data */
    data = 0x24;
    regmap_write(priv->map, 0x23, data); /* write new value */
    /* set bit 2 (starting from 0) and bit 6
     * of register 0x44 */
    regmap_update_bits(priv->map, 0x44,
                       0b00100010, 0xFF);
    [...] /* Lot of stuff */     
    return 0;
}

在前面的 probe 方法中,命令比代码还要多。我们仅仅需要展示如何将设备规格转换为寄存器映射配置,并将其用作访问设备寄存器的主要方法。

现在我们已经完成了内核中的 Regmap 部分,接下来我们将在下一节中展示用户空间如何充分利用该框架。

从用户空间使用 Regmap

可以通过 debugfs 文件系统从用户空间监控寄存器映射。首先,需要通过CONFIG_DEBUG_FS内核配置选项启用 debugfs。然后,可以使用以下命令挂载 debugfs:

mount -t debugfs none /sys/kernel/debug

此后,debugfs 寄存器映射实现可以在/sys/kernel/debug/regmap/下找到。这个由drivers/base/regmap/regmap-debugfs.c实现的 debugfs 视图包含基于 Regmap API 的驱动程序/外设的寄存器缓存(镜像)。

从 Regmap 主调试文件系统(debugfs)目录中,我们可以使用以下命令获取基于 Regmap API 的驱动程序的设备列表:

root@jetson-nano-devkit:~# ls -l /sys/kernel/debug/regmap/
drwxr-xr-x   2 root  root   0 Jan  1  1970 4-003c-power-slave
drwxr-xr-x   2 root  root   0 Jan  1  1970 4-0068
drwxr-xr-x   2 root  root   0 Jan  1  1970 700e3000.mipical
drwxr-xr-x   2 root  root   0 Jan  1  1970 702d3000.amx
drwxr-xr-x   2 root  root   0 Jan  1  1970 702d3100.amx
drwxr-xr-x   2 root  root   0 Jan  1  1970 hdaudioC0D3-hdaudio
drwxr-xr-x   2 root  root   0 Jan  1  1970 tegra210-admaif
drwxr-xr-x   2 root  root   0 Jan  1  1970 tegra210-adx.0
[...]
root@jetson-nano-devkit:~#

在每个目录中,可能包含以下一个或多个文件:

  • access:对每个寄存器的各种访问权限进行编码,模式为readable writable volatile precious

    root@jetson-nano-devkit:~# cat /sys/kernel/debug/regmap/4-003c-power-slave/access 
    00: y y y n
    01: y y y n
    02: y y y n
    03: y y y n
    04: y y y n
    05: y y y n
    06: y y y n
    07: y y y n
    08: y y y n
    [...]
    5c: y y y n
    5d: y y y n
    5e: y y y n
    

例如,5e: y y y n这一行表示地址为5e的寄存器是可读的、可写的、易失的,但不是珍贵的。

  • name:与寄存器映射相关联的驱动程序名称。检查相应的驱动程序。例如,702d3000.amx寄存器映射条目:

    root@jetson-nano-devkit:~# cat /sys/kernel/debug/regmap/702d3000.amx/name 
    tegra210-amx
    

然而,有些 Regmap 条目以dummy-开头,如下所示:

root@raspberrypi4-64:~# ls -l /sys/kernel/debug/regmap/
drwxr-xr-x  2 root root 0 Jan 1 1970 dummy-avs-monitor@fd5d2000
root@raspberrypi4-64:~#

当没有关联的/dev条目(devtmpfs)时,设置这种条目。您可以通过打印底层设备名称来检查这一点,名称将为nodev,如下所示:

root@raspberrypi4-64:~# cat /sys/kernel/debug/regmap/dummy-avs-monitor\@fd5d2000/name 
nodev
root@raspberrypi4-64:~#

您可以查找dummy-后缀名称,以便在设备树中找到相关节点,例如,dummy-avs-monitor@fd5d2000

avs_monitor: avs-monitor@7d5d2000 {
    compatible = "brcm,bcm2711-avs-monitor",
                 "syscon", "simple-mfd";
    reg = <0x7d5d2000 0xf00>;
[...]
};
  • cache_bypass:将寄存器映射置于仅缓存模式。如果启用,写入寄存器映射将仅更新硬件,而不会直接更新缓存:

    root@jetson-nano-devkit:~# cat /sys/kernel/debug/regmap/702d3000.amx/cache_bypass 
    N
    

要启用缓存绕过,您应该在此文件中回显Y,如下所示:

root@jetson-nano-devkit:~# echo Y > /sys/kernel/debug/regmap/702d30[579449.571475] tegra210-amx tegra210-amx.0: debugfs cache_bypass=Y forced
00.amx/cache_bypass 
root@jetson-nano-devkit:~#

这将额外在内核日志缓冲区中打印一条消息。

  • cache_dirty:表示硬件寄存器已重置为默认值,并且硬件寄存器与缓存状态不匹配。读取的值可以是YN

  • cache_only:在此文件中回显N将禁用此寄存器映射的缓存,同时触发缓存同步,而写入Y将强制此寄存器映射仅缓存。当读取此文件的值时,将根据当前启用的缓存状态返回YN。在此值为true时发生的任何写入都会被缓存(只会更新寄存器缓存,不会发生硬件更改)。

  • range:寄存器映射的有效寄存器范围:

    root@jetson-nano-devkit:~# cat /sys/kernel/debug/regmap/4-003c-power-slave/range 
    0-5e
    
  • rbtree:提供rbtree缓存增加的内存开销:

    root@jetson-nano-devkit:~# cat /sys/kernel/debug/regmap/4-003c-power-slave/rbtree 
    0-5e (95)
    1 nodes, 95 registers, average 95 registers, used 175 bytes
    
  • registers:用于读取和写入与寄存器映射关联的实际寄存器的文件:

    root@jetson-nano-devkit:~# cat /sys/kernel/debug/regmap/4-003c-power-slave/registers 
    00: d2
    01: 1f
    02: 00
    03: dc
    04: 0f
    05: 00
    06: 00
    07: 00
    08: 02
    [...]
    5c: 35
    5d: 81
    5e: 00
    #
    

在前面的输出中,首先显示寄存器地址,然后显示其内容。

本节非常简短,但简明扼要地描述了来自用户空间的 Regmap 监控。它允许读取和写入寄存器内容,并在某些情况下更改底层 Regmap 的行为。

总结

本章专注于与寄存器访问相关的 Regmap API。它的简单性应该能让你了解它有多么有用和广泛应用。本章展示了你需要了解的所有关于 Regmap API 的内容。现在你应该能够将任何标准的 SPI/I2C/内存映射驱动程序转换为 Regmap 驱动程序。

下一章将介绍 Linux 下的 IRQ 管理,然而在接下来的两章中,我们将讲解 IIO 设备,这是一种用于模拟到数字转换器的框架。这类设备通常位于 SPI/I2C 总线之上。对于我们来说,可能会面临一个挑战,那就是在本章结束时,使用 Regmap API 编写一个 IIO 驱动程序。

第十三章:第十三章:解密内核 IRQ 框架

Linux 是一个系统,在该系统中,设备通过中断请求IRQs)通知内核事件,尽管某些设备是轮询的。CPU 显示 IRQ 线路,无论是否共享,供连接的设备使用,以便当设备需要 CPU 时,它会向 CPU 发送请求。当 CPU 收到请求时,它会停止当前工作并保存其上下文,以便处理设备发出的请求。在处理完设备请求后,CPU 会恢复到中断发生时停止的状态。

在本章中,我们将处理内核提供的用于管理 IRQ 的 API,以及如何进行多路复用。此外,我们还将分析并深入探讨中断控制器驱动的编写。

总结一下,本章将涵盖以下主题:

  • 中断的简要介绍

  • 理解中断控制器和中断多路复用

  • 深入探讨高级外设 IRQ 管理

  • 解密每个 CPU 的中断

中断的简要介绍

在许多平台上,有一个特殊的设备负责管理 IRQ 线路。这个设备就是中断控制器,它位于 CPU 和它所管理的中断线路之间。下面是一个展示发生交互的图示:

图 13.1 – 中断控制器与 IRQ 线路

图 13.1 – 中断控制器与 IRQ 线路

不仅设备可以产生中断,一些处理器操作也可以。由此产生两种不同类型的中断:

  • 同步中断,称为异常,是由 CPU 在处理指令时产生的。这些是不可屏蔽中断NMIs),通常由硬件故障等严重故障引起。它们总是由 CPU 处理。

  • 异步中断,称为中断,由其他硬件设备发出。这些是正常的并且是可屏蔽中断。这些是本章接下来部分将讨论的内容。

在深入了解 Linux 内核中的中断管理之前,我们先谈谈异常。

异常是编程错误的结果,由内核处理,内核会向程序发送信号并尝试从错误中恢复。这些错误分为两类,列举如下:

  • 处理器检测到的异常:这些是 CPU 在响应异常情况时产生的异常,可分为三类:

    • 故障,通常可以纠正(虚假的指令)。

    • 陷阱,通常发生在用户进程中(无效的内存访问、除以零),也是一种响应系统调用而切换到内核模式的机制。如果内核代码导致了陷阱,它会立即引发 panic。

    • 中止 – 严重错误。

  • 程序化异常:这些是由程序员请求的,并像陷阱一样处理。

现在我们已经介绍了不同类型的中断,接下来让我们学习它们在中断控制器中的实现。

理解中断控制器和中断复用。

来自 CPU 的单一中断通常不足以满足需求。大多数系统有数十个或数百个中断。此时就需要中断控制器,它允许这些中断进行复用。架构或平台特定的实现通常会提供一些特定的功能,如下所示:

  • 屏蔽/解除屏蔽单个中断

  • 设置优先级。

  • SMP 亲和性。

  • 异常特性,例如唤醒中断。

IRQ 管理和中断控制器驱动都依赖于 IRQ 域的概念,该概念基于以下结构:

  • struct irq_chip:这是中断控制器的数据结构。该结构还实现了一组方法,用于驱动中断控制器,并且这些方法是由核心 IRQ 代码直接调用的。

  • struct irqdomain:该结构提供以下选项:

    • 指向中断控制器固件节点的指针(fwnode

    • 用于将 IRQ 的固件描述转换为此中断控制器本地的 ID(hwirq,也称为硬件 IRQ 编号)的函数。

    • 一种从hwirq中获取 IRQ 的 Linux 视图(virq,也称为虚拟 IRQ 编号)的方法。

  • struct irq_desc:该结构是 Linux 对中断的表示。它包含有关中断的所有信息,并与 Linux 中断编号一一对应。

  • struct irq_action:该结构用于描述 IRQ 处理程序。

  • struct irq_data:该结构嵌套在struct irq_desc结构中,并提供以下信息:

    • 与管理此中断的 IRQ 芯片相关的数据。

    • 包含virqhwirq

    • 指向struct irq_chip(IRQ 芯片数据结构)的指针。请注意,大多数与 IRQ 芯片相关的函数调用都将irq_data作为参数传递,通过它可以获取相应的struct irq_desc

上述所有数据结构都是 IRQ 域 API 的一部分。中断控制器在内核中由struct irq_chip结构的一个实例表示,该结构描述了实际的硬件设备,以及 IRQ 核心使用的一些方法。以下代码块展示了它的定义:

struct irq_chip {
    struct device    *parent_device;
    const char       *name;
    void   (*irq_enable)(struct irq_data *data);
    void   (*irq_disable)(struct irq_data *data);
    void   (*irq_ack)(struct irq_data *data);
    void   (*irq_mask)(struct irq_data *data);
    void   (*irq_unmask)(struct irq_data *data);
    void   (*irq_eoi)(struct irq_data *data);
    int    (*irq_set_affinity)(struct irq_data *data,
                const struct cpumask *dest, bool force);
    int    (*irq_retrigger)(struct irq_data *data);
    int    (*irq_set_type)(struct irq_data *data,
                           unsigned int flow_type);
    int    (*irq_set_wake)(struct irq_data *data,
                           unsigned int on);
    void   (*irq_bus_lock)(struct irq_data *data);
    void   (*irq_bus_sync_unlock)(struct irq_data *data);
    int   (*irq_get_irqchip_state)(struct irq_data *data,
               enum irqchip_irq_state which, bool *state);
    int   (*irq_set_irqchip_state)(struct irq_data *data,
               enum irqchip_irq_state which, bool state);
    void  (*ipi_send_single)(struct irq_data *data, 
                              unsigned int cpu);
   void   (*ipi_send_mask)(struct irq_data *data,
                           const struct cpumask *dest);
    unsigned long    flags;
};

以下列表解释了结构中各个元素的含义:

  • parent_device:这是指向此 IRQ 芯片父设备的指针。

  • name:这是/proc/interrupts文件的名称。

  • irq_enable:该钩子用于启用中断。如果未设置(即NULL),则默认为chip->unmask

  • irq_disable:该函数用于禁用中断。

  • irq_ack:该回调函数用于确认一个中断。handle_edge_irq() 会在任何情况下调用该函数,因此对于使用 handle_edge_irq() 来处理中断的 IRQ 控制器驱动程序,必须定义此回调函数(即使是空壳函数)。对于这样的控制器,此回调函数会在中断开始时被调用。有些控制器不需要这个函数。Linux 在中断被触发后立即调用此函数,远在处理中断之前。某些实现中此函数被映射到 chip->disable(),以便如果有另一个中断请求在线路上,当前中断请求处理完之前不会再次触发中断。

  • irq_mask:这是一个钩子函数,用于在硬件中屏蔽中断源,以便该中断不再被触发。

  • irq_unmask:该钩子函数用于解除中断源的屏蔽。

  • irq_eoi:Linux 调用此 chip->enable() 来逆转 chip->ack() 中执行的操作。

  • irq_set_affinity:此函数仅在 SMP 机器上设置 CPU 亲和性。在这样的机器上,此函数用于指定处理中断的 CPU。单处理器环境中不使用此函数,因为中断始终在同一个 CPU 上处理。

  • irq_retrigger:此函数会在硬件中重新触发中断,从而将 IRQ 重新发送到 CPU。

  • irq_set_type:此函数设置 IRQ 的流类型,例如 IRQ_TYPE_LEVEL

  • irq_set_wake:此函数用于启用/禁用 IRQ 的电源管理唤醒功能。

  • irq_bus_lock:此函数用于锁定对慢速总线(I2C)芯片的访问。在这里锁定一个互斥量就足够了。

  • irq_bus_sync_unlock:此函数同步并解锁慢速总线(I2C)芯片,同时解锁先前锁定的互斥量。

  • irq_get_irqchip_stateirq_set_irqchip_state:这两个函数分别用于返回或设置中断的内部状态。

  • ipi_send_singleipi_send_mask:这两个函数分别用于向单个 CPU 或通过掩码定义的一组 CPU 发送 处理器间中断IPIs)。在 SMP 系统中,IPIs 用于从本地 CPU 生成 CPU 远程中断。我们将在本章的 破解每个 CPU 中断 部分讨论这一点。

每个中断控制器都有一个域,这个域对控制器的作用类似于地址空间对进程的作用(参见 第十章理解 Linux 内核内存分配)。中断控制器域在内核中通过 struct irq_domain 结构来描述。它管理硬件 IRQ 编号与 Linux IRQ 编号(即虚拟 IRQ)之间的映射。它是硬件中断编号转换对象。以下代码块展示了其定义:

struct irq_domain {
    const char *name;
    const struct irq_domain_ops *ops;
    void *host_data;
    unsigned int flags;
    unsigned int mapcount;
    /* Optional data */
    struct fwnode_handle *fwnode;
    [...]
};

为了可读性,只有与我们相关的元素被列出。以下列表告诉我们它们的含义:

  • name:这是中断域的名称。

  • ops:这是指向 IRQ 域方法的指针。

  • host_data:这是一个供拥有者使用的私有数据指针。IRQ 域核心代码不会修改它。

  • flags:这是每个 IRQ 域的标志。

  • mapcount:这是该 IRQ 域中已映射的中断数量。

  • 与所有其他元素一样,fwnode是可选的。它是指向与 IRQ 域相关联的设备树DT)节点的指针。用于解码 DT 中断说明符。

中断控制器驱动程序通过调用其中一个irq_domain_add_<mapping_method>()函数来创建并注册 IRQ 域,其中<mapping_method>hwirq应如何映射到 Linux virq的映射方法。这些函数在下列列表中有详细描述:

  • irq_domain_add_linear():该函数使用一个由hwirq编号索引的固定大小表。当一个hwirq编号被映射时,会为该hwirq分配一个irq_desc对象,并将 IRQ 编号存储在表中。这种线性映射适用于hwirq数量固定且较少的控制器或域(~ < 256)。这种映射的不便之处在于表的大小,它的大小与最大的hwirq编号相同。因此,IRQ 编号查找时间是固定的,IRQ 描述符只会为正在使用的 IRQ 分配。大多数驱动程序应使用线性映射。该函数的原型如下所示:

    struct irq_domain *irq_domain_add_linear(
                       struct device_node *of_node,
                       unsigned int size,
                       const struct irq_domain_ops *ops,
                       void *host_data)
    
  • irq_domain_add_tree():使用这种映射,IRQ 域在基数树中维护virqs(Linux IRQ 编号)和hwirq(硬件中断编号)之间的映射。当一个hwirq被映射时,会分配一个irq_desc对象,并将该硬件 IRQ 编号用作基数树的查找键。如果hwirq编号可能非常大,那么树形映射是一个可行的解决方案,因为它不需要分配一个和最大hwirq编号一样大的表。缺点是,hwirq到 IRQ 编号的查找会受到表中条目数量的影响。只有极少数驱动程序需要这种映射。内核中使用此 API 的用户不到 10 个。它的原型如下所示:

    struct irq_domain *irq_domain_add_tree(
                      struct device_node *of_node,
                      const struct irq_domain_ops *ops,
                      void *host_data)
    
  • irq_domain_add_nomap():你可能永远不会使用这种方法。不过,它的完整描述可以在Documentation/IRQ-domain.txt中找到,位于内核源代码树中。其原型如下所示:

    struct irq_domain *irq_domain_add_nomap(
                       struct device_node *of_node,
                       unsigned int max_irq,
                       const struct irq_domain_ops *ops,
                       void *host_data)
    

在这些函数中,of_node是指向中断控制器的 DT 节点的指针。size对应于域中中断的数量。ops表示映射/取消映射域的回调,host_data是控制器的私有数据指针。

当 IRQ 域最初创建时,它是空的(没有映射)。当 IRQ 芯片驱动程序调用irq_create_mapping()时,会创建并添加映射,函数原型如下所示:

unsigned int irq_create_mapping(struct irq_domain 
              *domain, irq_hw_number_t hwirq)

在前面的函数中,domain是此硬件中断所属的域,或者为NULL表示默认域;hwirq表示该域空间中的硬件中断编号。

如果 hwirq 数字在 IRQ 域中还不存在映射,该函数将分配一个新的 Linux IRQ 描述符(struct irq_desc)结构,同时返回一个虚拟中断号。然后,它将通过 irq_domain_associate() 函数将其与 hwirq 数字关联(该函数又调用 irq_domain_ops.map 回调,以便驱动程序可以执行任何所需的硬件设置)。为了理解这一段,我们需要描述 IRQ 域操作数据结构(struct irq_domain_ops),该结构在下面的代码块中定义:

struct irq_domain_ops {
    int (*map)(struct irq_domain *d, unsigned int virq,
          irq_hw_number_t hw);
    void (*unmap)(struct irq_domain *d, 
                   unsigned int virq);
    int (*xlate)(struct irq_domain *d, 
                   struct device_node *node,
                   const u32 *intspec,
                   unsigned int intsize,
                   unsigned long *out_hwirq, 
                   unsigned int *out_type);
[...]
};

数据结构中的元素已限制在本章的范围内。然而,完整的数据结构可以在内核源代码的 include/linux/irqdomain.h 中找到。以下列表告诉我们已列举元素的含义:

  • map: 这个回调函数创建或更新 virq 数字和 hwirq 数字之间的映射。该回调函数对于给定的映射只会被调用一次。它通常使用 irq_set_chip_and_handler()virq 数字与给定的处理程序映射,以便调用 generic_handle_irq()handle_nested_irq() 时触发该处理程序。irq_set_chip_and_handler() 函数定义如下所示:

    void irq_set_chip_and_handler(unsigned int irq,
                              struct irq_chip *chip,
                              irq_flow_handler_t handle)
    

在此函数中,irq 是作为参数传递给 map() 函数的 Linux IRQ,而 chip 是你的 IRQ 芯片。然而,有些虚拟控制器几乎不需要在其 irq_chip 结构中做任何事情。在这种情况下,驱动程序传递 dummy_irq_chip,它在 kernel/irq/dummychip.c 中定义,是为这类控制器预定义的内核 irq_chip 结构。handle 决定了中断流处理器,它是调用通过 request_irq() 注册的实际处理器的那个。它的值取决于 IRQ 是边沿触发还是电平触发。在两种情况下,handle 应设置为 handle_edge_irqhandle_level_irq。这两者都是内核帮助函数,在调用真实的 IRQ 处理程序之前和之后执行一些操作。示例如下所示:

static int ativic32_irq_domain_map(
                struct irq_domain *id,
                unsigned int virq, 
                irq_hw_number_t hw)
{
[...]
    if (int_trigger_type & (BIT(hw))) {
        irq_set_chip_and_handler(virq, 
                     &ativic32_chip,
                     handle_edge_irq);
        type = IRQ_TYPE_EDGE_RISING;
    } else {
        irq_set_chip_and_handler(virq, 
                     &ativic32_chip, 
                     handle_level_irq);
        type = IRQ_TYPE_LEVEL_HIGH;
    }
    irqd_set_trigger_type(irq_data, type);
    return 0;
}
  • xlate: 给定一个带有中断说明符的设备树节点,该钩子解码该说明符中的硬件中断号及其对应的 Linux 中断类型值。根据设备树控制器节点中指定的 #interrupt-cells 值,内核提供通用的翻译函数:

    • irq_domain_xlate_twocell(): 用于直接两单元绑定的通用翻译函数。它与具有两单元绑定的设备树 IRQ 说明符一起工作,其中单元值直接映射到 hwirq 数字和 Linux IRQ 标志。

    • irq_domain_xlate_onecell(): 一种通用的 xlate 方法,用于直接一单元绑定。

    • Irq_domain_xlate_onetwocell(): 一种通用的 xlate 方法,用于一或两单元绑定。

域操作的示例如下所示:

static struct irq_domain_ops mcp23016_irq_domain_ops = {
    .map    = mcp23016_irq_domain_map,
    .xlate  = irq_domain_xlate_twocell,
};

当接收到中断时,irq_find_mapping() 函数会用来从 hwirq 编号查找 Linux IRQ 编号。当然,映射必须在返回之前存在。Linux IRQ 编号总是与一个 struct irq_desc 结构关联,后者是 Linux 描述 IRQ 的结构,定义如下:

struct irq_desc {
    struct irq_data        irq_data;
    unsigned int __percpu  *kstat_irqs;
    irq_flow_handler_t     handle_irq;
    struct irqaction       *action;
    unsigned int           irqs_unhandled;
    raw_spinlock_t         lock;
    struct cpumask         *percpu_enabled;
    atomic_t               threads_active;
    wait_queue_head_t      wait_for_threads;
#ifdef CONFIG_PM_SLEEP
    unsigned int           nr_actions;
    unsigned int           no_suspend_depth;
    unsigned int           force_resume_depth;
#endif
#ifdef CONFIG_PROC_FS
    struct proc_dir_entry   *dir;
#endif
    Int               parent_irq;
    struct module     *owner;
    const char        *name;
};

该数据结构中的一些字段故意缺失。其余字段的定义如下:

  • kstat_irqs:这是自启动以来每个 CPU 的 IRQ 统计信息。

  • handle_irq:这是高级的 IRQ 事件处理程序。

  • action:表示该描述符的 IRQ 动作列表。

  • irqs_unhandled:这是处理未被处理的虚假中断的统计字段。

  • lock:表示 SMP 的锁定。

  • threads_active:当前为该描述符运行的 IRQ 动作线程数量。

  • wait_for_threads:表示 sync_irq 等待线程处理程序的等待队列。

  • nr_actions:这是安装在该描述符上的动作数量。

  • no_suspend_depthforce_resume_depth:表示在具有 IRQF_NO_SUSPENDIRQF_FORCE_RESUME 标志的 IRQ 描述符上 irqaction 实例的数量。

  • dir:表示 /proc/irq/ 的 procfs 条目。

  • name:命名流处理程序,在 /proc/interrupts 输出中可见。

在注册中断处理程序时,该处理程序会被添加到与该中断线关联的 irq_desc.action 列表的末尾。例如,每次调用 request_irq()(或者线程版本的 request_threaded_irq())都会创建并将一个 struct irqaction 结构添加到 irq_desc.action 列表的末尾(知道 irq_desc 是该中断的描述符)。对于共享中断,该字段将包含与注册的处理程序数量相等的 irqaction 对象。IRQ 动作数据结构的定义如下:

struct irqaction {
    irq_handler_t     handler;
    void              *dev_id;
    void __percpu     *percpu_dev_id;
    struct irqaction  *next;
    irq_handler_t     thread_fn;
    struct task_struct     *thread;
    unsigned int      irq;
    unsigned int      flags;
    unsigned long     thread_flags;
    unsigned long     thread_mask;
    const char        *name;
    struct proc_dir_entry   *dir;
};

该数据结构中每个元素的含义如下:

  • handler:这是非线程的(硬)中断处理程序函数。

  • name:设备名称。

  • dev_id:这是一个标识符,用于识别设备。

  • percpu_dev_id:这是一个每个 CPU 的标识符,用于识别设备。

  • next:这是指向共享中断的下一个 IRQ 动作的指针。

  • irq:这是 Linux 中断号(virq)。

  • flags:表示 IRQ 标志(见 IRQF_*)。

  • thread_fn:这是线程中断处理程序函数,适用于线程中断。

  • thread:这是指向线程结构的指针,适用于线程中断。

  • thread_flags:表示与线程相关的标志。

  • thread_mask:这是用于跟踪线程活动的位掩码。

  • dir:指向 /proc/irq/NN/<name>/ 条目。

以下是 struct irq_data 结构中重要字段的定义,它是每个 IRQ 芯片的数据,传递给芯片函数:

struct irq_data {
    [...]
    unsigned int     irq;
    unsigned long           hwirq;
    struct irq_chip         *chip;
    struct irq_domain *domain;
    void              *chip_data;
};

以下列表给出了该数据结构中元素的含义:

  • irq: 这是中断编号(Linux IRQ 编号)。

  • hwirq: 这是硬件中断编号,局部于 irq_data.domain 中断域。

  • chip: 这是低级中断控制器硬件访问。

  • domain: 这是中断翻译域,负责在 hwirq 编号和 Linux IRQ 编号之间进行映射。

  • chip_data: 这是平台特定的、每个芯片私有的数据,用于芯片方法,以便共享芯片实现。

既然我们已经熟悉了 IRQ 框架的数据结构,我们可以更进一步,研究中断是如何被请求并在处理链中传播的。

深入研究高级外设 IRQ 管理

第三章处理内核核心助手 中,我们介绍了外设 IRQ,使用 request_irq()request_threaded_irq()。在前者中,您注册一个处理程序(上半部),该处理程序将在原子上下文中执行,从中您可以使用本章讨论的机制之一调度下半部。而在 _threaded 变体中,您可以为函数提供上半部和下半部,这样前者将作为硬 IRQ 处理程序运行,可能会决定是否提升第二个并在线程中运行的处理程序。

这些方法的问题在于,有时请求 IRQ 的驱动程序并不了解提供此 IRQ 线路的中断控制器的性质,尤其是在中断控制器是独立芯片(通常是通过 SPI 或 I2C 总线连接的 GPIO 扩展器)时。此时,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);

下面是函数中每个参数的含义:

  • irq: 这是要分配的中断线。

  • handler: 这是 IRQ 发生时调用的函数。根据上下文,这个函数可能作为硬 IRQ 运行,也可能是线程化的。

  • flags: 这是中断类型标志。与 request_irq() 中的标志相同。

  • name: 这将用于调试目的,在 /proc/interrupts 中命名中断。

  • dev_id: 这是一个传递回处理函数的 cookie。

request_any_context_irq() 表示你可以获得硬中断或线程中断。它的工作方式与常规的 request_irq() 相同,不同之处在于它会检查 IRQ 是否配置为嵌套中断,并调用正确的后端。换句话说,它根据上下文选择硬中断或线程处理方法。此函数在失败时返回负值。成功时,它返回 IRQC_IS_HARDIRQIRQC_IS_NESTED。以下代码块展示了一个用例:

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) {
        dev_err(&pdev->dev,
           "Unable to request GPIO interrupt line\n");
        goto err_btn;
    }
    return ret;
}

上述代码摘自一个输入设备驱动的示例。使用 request_any_context_irq() 的优点是,你不需要关心 IRQ 处理程序可以做什么,因为处理程序运行的上下文取决于提供 IRQ 线路的中断控制器。在我们的示例中,如果 GPIO 属于一个通过 I2C 或 SPI 总线连接的控制器,那么处理程序将是线程化的。否则(内存映射),处理程序将在硬中断上下文中运行。

理解 IRQ 和传播

假设我们有一个 GPIO 控制器,其中断线路连接到 SoC 上的本地 GPIO,如下图所示:

图 13.2 – 中断传播

图 13.2 – 中断传播

IRQ 始终基于 Linux IRQ 编号进行处理(而非 hwirq)。在 Linux 系统中请求 IRQ 的通用函数是 request_threaded_irq()request_irq()request_threaded_irq() 的封装,只是不提供底半部分。以下代码块展示了它的原型:

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)

当调用时,该函数使用 irq_to_desc() 宏提取与 IRQ 关联的 struct irq_desc。然后,它分配一个新的 struct irqaction 结构并进行设置,填充如处理程序和标志等参数。以下代码块是摘录:

action->handler = handler;
action->thread_fn = thread_fn;
action->flags = irqflags;
action->name = devname;
action->dev_id = dev_id;

同一个函数最终通过调用 __setup_irq()(通过 setup_irq())函数将描述符插入/注册到适当的 IRQ 列表中,该函数定义在 kernel/irq/manage.c 中。

现在,当 IRQ 被触发时,内核执行一些汇编代码以保存当前状态,并跳转到特定架构的处理程序 handle_arch_irq。对于 ARM 架构,这个处理程序通过 setup_arch() 函数在 arch/arm/kernel/setup.c 中的 struct machine_desc 中设置,并分配 handle_irq 字段的值。赋值过程如下:

handle_arch_irq = mdesc->handle_irq

对于使用 ARM 的 SoC,handle_irq 回调设置为 gic_handle_irq,在 drivers/irqchip/irq-gic.cdrivers/irqchip/irq-gic-v3.c 中:

set_handle_irq(gic_handle_irq);

gic_handle_irq() 调用 handle_domain_irq(),接着执行 generic_handle_irq(),再调用 generic_handle_irq_desc(),最终调用 desc->handle_irq()。整个链条可以在 arch/arm/kernel/irq.c 中看到。现在,handle_irq 是流处理程序的实际调用,我们在图中将其注册为 mcp23016_irq_handler

gic_hande_irq()是一个 GIC 中断处理程序。generic_handle_irq()将执行 SoC 的 GPIO4 IRQ 的处理程序,该处理程序将查找发出中断的 GPIO 引脚,并调用generic_handle_irq_desc()

链接 IRQ

本节描述了父中断处理程序如何调用其子中断处理程序,进而调用其子中断处理程序,依此类推。内核提供了两种方法来在父设备(中断控制器)的 IRQ 处理程序中调用子设备的中断处理程序。这些方法是链式方法和嵌套方法。

链式中断

这种方法用于 SoC 内部的 GPIO 控制器,这些控制器是内存映射的,并且在访问这些控制器时不会使调用者进入睡眠状态。链式中断意味着这些中断只是函数调用的链(例如,SoC 的 GPIO 模块中断处理程序是从 GIC 中断处理程序中调用的,就像一个函数调用一样)。generic_handle_irq()用于中断链式处理。子 IRQ 处理程序是从父硬 IRQ 处理程序内部调用的。这意味着即使在子中断处理程序中,我们仍然处于一个原子上下文(硬件中断),并且驱动程序不得调用可能导致睡眠的函数。

嵌套中断

通过这种流程,函数调用是嵌套的,这意味着中断处理程序不会在父处理程序中被调用。handle_nested_irq()用于创建嵌套中断子 IRQ。处理程序是在为此目的创建的新线程中调用的。此方法用于那些位于慢速总线(如 SPI 或 I2C,如 GPIO 扩展器)上的控制器,其访问可能会进入睡眠状态(I2C 和 SPI 访问过程可能会进入睡眠)。在进程上下文中运行的嵌套中断处理程序可以调用任何可能进入睡眠状态的函数。

解密每 CPU 中断

最常见的 ARM 中断控制器,ARM 多核处理器中的 GIC,支持三种类型的中断:

  • CPU 专用中断:这些中断是每个 CPU 专用的。如果触发,类似每 CPU 的中断将仅在目标 CPU 或与之绑定的 CPU 上进行服务。专用中断可以分为两类:

    • 专用外设中断PPIs):这些中断是专用的,仅能由绑定到 CPU 的硬件生成。

    • 软件生成的中断SGIs):与 PPIs 不同,SGIs 是由软件生成的。由于这一点,SGIs 通常作为多核系统中核心间通信的中断 IPIs 使用,这意味着一个 CPU 可以生成一个中断(通过将适当的消息,包括中断 ID 和目标 CPU 写入 GIC 控制器)到其他 CPU。这就是本节要讨论的内容。

  • 共享外设中断SPIs)(与 SPI 总线不同):这些是我们到目前为止讨论的经典中断。这些中断可以路由到任何 CPU。

在具有支持每核私有中断的中断控制器的系统中,某些 IRQ 控制器寄存器将被分区,这样它们只能从一个核心访问(例如,一个核心只能读取/写入它自己的中断配置)。通常,为了实现这一点,一些中断控制器寄存器是按 CPU 分区的;一个 CPU 可以通过写入它的分区寄存器来启用本地中断。

在 GIC 中,分配器块和 CPU 接口块在逻辑上是分区的。在与中断源交互时,分配器块对中断进行优先级排序,并将其传递给 CPU 接口块。CPU 接口块与系统中的处理器连接,管理与其连接的处理器的优先级屏蔽和抢占。

GIC 支持最多 8 个 CPU 接口,每个接口最多可以处理 1,020 个中断。中断 ID 号 0 到 1019 由 GIC 按如下方式分配:

  • 中断号 0 到 31 是私有于 CPU 接口的中断。这些私有中断在分配器块中被分区,并按如下方式划分:

    • SGIs 使用分区的中断号 0 到 15。

    • PPIs 使用分区的中断号 16 到 31。例如,在 SMP 系统中,时钟事件设备提供的每 CPU 定时器可以生成这样的中断。

  • SPIs 使用中断号 32 到 1,019。

  • 剩余的中断是保留的,即中断号 1020 到 1023。

现在我们已经熟悉了 ARM GIC 中断家族,接下来可以专注于我们感兴趣的家族,也就是 SGIs。

SGIs 和 IPIs

在 ARM 处理器中,共有 16 个 SGI,编号从 0 到 15,但 Linux 内核仅注册了其中的一部分:精确来说是八个(从 0 到 7)。SGI8 到 SGI15 当前未被使用。已注册的 SGI 是那些在 enum ipi_msg_type 中定义的,具体如下:

enum ipi_msg_type {
    IPI_WAKEUP,
    IPI_TIMER,
    IPI_RESCHEDULE,
    IPI_CALL_FUNC,
    IPI_CPU_STOP,
    IPI_IRQ_WORK,
    IPI_COMPLETION,
    NR_IPI,
[...]
    MAX_IPI
};

它们各自的描述可以在一个字符串数组 ipi_types 中找到,该数组在下方代码块中定义:

static const char *ipi_types[NR_IPI] = {
    [IPI_WAKEUP] = "CPU wakeup interrupts",
    [IPI_TIMER] = "Timer broadcast interrupts",
    [IPI_RESCHEDULE] = "Rescheduling interrupts",
    [IPI_CALL_FUNC]  = "Function call interrupts",
    [IPI_CPU_STOP]   = "CPU stop interrupts",
    [IPI_IRQ_WORK]   = "IRQ work interrupts",
    [IPI_COMPLETION] = "completion interrupts",
};

IPI 在 set_smp_ipi_range() 函数中注册,具体定义见下方代码块:

void __init set_smp_ipi_range(int ipi_base, int n)
{
    int i;
    WARN_ON(n < MAX_IPI);
    nr_ipi = min(n, MAX_IPI);
    for (i = 0; i < nr_ipi; i++) {
        int err;
        err = request_percpu_irq(ipi_base + i,
                 ipi_handler, "IPI", &irq_stat);
        WARN_ON(err);
        ipi_desc[i] = irq_to_desc(ipi_base + i);
        irq_set_status_flags(ipi_base + i, IRQ_HIDDEN);
    }
    ipi_irq_base = ipi_base;
    /* Setup the boot CPU immediately */
    ipi_setup(smp_processor_id());
}

在前面的代码块中,每个 IPI 都是通过 request_percpu_irq() 在每个 CPU 上注册的。我们可以看到,所有的 IPI 都使用相同的处理程序 ipi_handler(),其定义如下:

static irqreturn_t ipi_handler(int irq, void *data)
{
    do_handle_IPI(irq - ipi_irq_base);
    return IRQ_HANDLED;
}

在处理程序中执行的底层函数是 do_handle_IPI(),其定义如下:

static void do_handle_IPI(int ipinr)
{
    unsigned int cpu = smp_processor_id();
    if ((unsigned)ipinr < NR_IPI)
        trace_ipi_entry_rcuidle(ipi_types[ipinr]);
    switch (ipinr) {
    case IPI_WAKEUP:
        break;
#ifdef CONFIG_GENERIC_CLOCKEVENTS_BROADCAST
    case IPI_TIMER:
        tick_receive_broadcast();
        break;
#endif
    case IPI_RESCHEDULE:
        scheduler_ipi();
        break;
    case IPI_CPU_STOP:
        ipi_cpu_stop(cpu);
        break;
[...]
    default:
        pr_crit("CPU%u: Unknown IPI message 0x%x\n",
                cpu, ipinr);
        break;
    }
    if ((unsigned)ipinr < NR_IPI)
         trace_ipi_exit_rcuidle(ipi_types[ipinr]);
}

从前面的函数中,

  • IPI_WAKEUP:用于唤醒并启动一个二级 CPU。它主要由引导 CPU 发出。

  • IPI_RESCHEDULE:Linux 内核使用重新调度中断来告诉其他 CPU 核心调度一个线程。SMP 系统上的调度器通过这种方式分配负载到多个 CPU 核心上。一般来说,理想的状态是让所有核心上尽可能多的进程在低功耗(低时钟频率)下运行,而不是让一个忙碌的核心全速运行,而其他核心处于睡眠状态。当调度器需要将任务从一个核心转移到另一个睡眠核心时,调度器会向该睡眠核心发送一个内核 IPI 消息,导致它从低功耗睡眠状态中醒来并开始执行进程。这些 IPI 事件会被 powertop 报告为“重新调度中断”。

  • IPI_TIMER:这是定时器广播中断。这个 IPI 模拟了在空闲 CPU 上的定时器中断。它是由广播时钟事件/滴答设备发送到 tick_broadcast_mask 中表示的 CPU 上,tick_broadcast_mask 是表示处于睡眠模式的处理器列表的位图。滴答设备和广播掩码将在 第三章中讨论,处理内核核心助手

  • IPI_CPU_STOP:当一个 CPU 上发生内核恐慌时,其他 CPU 会通过 IPI_CPU_STOP IPI 消息指示转储它们的堆栈并停止执行。目标 CPU 并不会关闭或下线;相反,它们会停止执行并进入低功耗循环,处于等待事件WFE)状态。

  • IPI_CALL_FUNC:用于在另一个处理器上下文中运行函数。

  • IPI_IRQ_WORK:用于在硬件 IRQ 上下文中运行任务。内核提供了一系列机制来推迟任务的执行,特别是将任务从硬件中断上下文中移出。然而,偶尔可能需要在硬件中断上下文中运行任务,而此时并没有硬件便捷地发出中断信号。为了实现这一点,可以使用 IPI 来在硬件中断上下文中运行任务。这主要用于从非屏蔽中断中运行的代码,这类代码需要与系统的其他部分进行交互。

在运行中的系统上,你可以通过 /proc/interrupt 文件查找可用的 IPI,如下所示的代码块所示:

root@udoo-labcsmart:~# cat /proc/interrupts | grep IPI
IPI0:          0          0  CPU wakeup interrupts
IPI1:         29         22  Timer broadcast interrupts
IPI2:      84306     322774  Rescheduling interrupts
IPI3:        970       1264  Function call interruptsIPI4:          0          0  CPU stop interrupts
IPI5:    2505436    4064821  IRQ work interrupts
IPI6:          0          0  completion interrupts
root@udoo-labcsmart:~#

在此处显示的命令输出中,第一列是 IPI 标识符,最后一列是 IPI 的描述。中间的列是每个 CPU 上相应的执行次数。

概述

现在,IRQ 多路复用对你来说没有什么秘密了。我们已经讨论了 Linux 系统中 IRQ 管理的最重要元素:IRQ 域 API。你已经掌握了理解现有中断控制器驱动程序的基础知识,以及它们在设备树中的绑定。我们讨论了 IRQ 传播,以便探索请求和处理程序调用之间发生了什么。

在下一章中,我们将讨论一个完全不同的话题:Linux 设备模型。

第十四章:第十四章:Linux 设备模型简介

直到 2.5 版本之前,Linux 内核没有办法描述和管理对象,代码的可重用性也不像现在这样强大。换句话说,当时没有设备拓扑,也没有像现在在 sysfs 中那样的组织结构。没有关于子系统关系的信息,也没有关于系统是如何组合在一起的信息。然后出现了Linux 设备模型LDM),它引入了以下功能:

  • 类的概念。它们用于将相同类型或暴露相同功能的设备分组(例如,鼠标和键盘都是输入设备)。

  • 通过虚拟文件系统与用户空间进行通信,允许你管理和枚举设备以及它们从用户空间暴露的属性。

  • 使用引用计数进行对象生命周期管理。

  • 电源管理功能,允许你管理设备关闭的顺序。

  • 代码的可重用性。类和框架暴露接口,表现得像一种契约,任何注册它们的驱动程序都必须遵守。

  • 面向对象OO)风格的编程和内核中的封装。

在本章中,我们将利用 LDM,通过 sysfs 文件系统将一些属性导出到用户空间。为此,我们将覆盖以下主题:

  • LDM 数据结构简介

  • 深入了解 LDM

  • 从 sysfs 看设备模型概述

LDM 数据结构简介

Linux 设备模型引入了设备层次结构。它建立在一些数据结构之上。其中包括总线,它在内核中表示为struct bus_type的一个实例;设备驱动程序,它由struct device_driver结构表示;以及设备,它是最后一个元素,表示为struct device结构的一个实例。在本节中,我们将介绍所有这些结构,并学习它们如何相互作用。

总线数据结构

总线是设备与处理器之间的通道链接。管理总线并向设备暴露协议的硬件实体被称为总线控制器。例如,USB 控制器提供 USB 支持,而 I2C 控制器提供 I2C 总线支持。然而,总线控制器作为一个设备本身,必须像任何设备一样进行注册。它将是需要连接到此总线的设备的父设备。换句话说,坐在总线上的每个设备的父设备字段必须指向总线设备。总线在内核中由struct bus_type结构表示,其定义如下:

struct bus_type {
    const char        *name;
    const char        *dev_name;
    struct device     *dev_root;
    const struct attribute_group **bus_groups;
    const struct attribute_group **dev_groups;
    const struct attribute_group **drv_groups;
    int (*match)(struct device *dev,
                 struct device_driver *drv);
    int (*probe)(struct device *dev);
    void (*sync_state)(struct device *dev);
    int (*remove)(struct device *dev);
    void (*shutdown)(struct device *dev);
    int (*suspend)(struct device *dev, pm_message_t state);
    int (*resume)(struct device *dev);
    const struct dev_pm_ops *pm;
[...]
};

这里只列出了与本书相关的元素;让我们更详细地了解它们:

  • match 是一个回调函数,每当新设备或驱动程序被添加到此总线时都会调用该回调。回调函数必须足够智能,当设备与驱动程序之间有匹配时,应返回一个非零值。match 回调的主要目的是让总线判断一个特定设备是否可以由给定的驱动程序处理,或者如果该驱动程序支持该设备的其他逻辑。大多数情况下,这种验证是通过简单的字符串比较完成的(设备和驱动程序名称,是否是表格和设备树兼容属性,等等)。对于枚举设备(如 PCI、USB),验证是通过比较驱动程序支持的设备 ID 与给定设备的设备 ID 来完成的,而不会牺牲总线特定的功能。

  • probe 是一个回调函数,当新设备或驱动程序在匹配后被添加到该总线时调用。此函数负责分配特定的总线设备结构,并调用给定驱动程序的 probe 函数,该函数负责管理设备(先前分配的)。

  • remove 在设备离开该总线时被调用。

  • suspend 是在总线上的设备需要进入睡眠模式时调用的方法。

  • resume 在总线上的设备必须从睡眠模式中恢复时调用。

  • pm 是此总线的电源管理操作集,也可能调用驱动程序特定的电源管理操作。

  • drv_groups 是指向 struct attribute_group 元素列表(数组)的指针,每个元素都有一个指向 struct attribute 元素列表(数组)的指针。它表示总线上设备驱动程序的默认属性。传递到此字段的属性将会赋予每个注册到总线上的驱动程序。这些属性可以在驱动程序的目录 /sys/bus/<bus-name>/drivers/<driver-name> 中找到。

  • dev_groups 表示总线上设备的默认属性。任何通过 struct attribute_group 元素的列表/数组传递到此字段的属性,将会赋予每个注册到总线上的设备。这些属性可以在设备的目录 /sys/bus/<bus-name>/devices/<device-name> 中找到。

  • bus_group 保持总线注册到核心时自动添加的默认属性集(组)。

除了定义bus_type外,总线驱动程序还必须定义一个特定于总线的驱动程序结构,它扩展了通用的struct device_driver结构,以及一个特定于总线的设备结构,它扩展了通用的struct device结构。这两者都是设备模型核心的一部分。总线驱动程序还必须为每个在探测时发现的物理设备分配一个特定于总线的设备结构。它还负责设置设备的总线和父字段,并将其注册到 LDM 核心。这些字段必须指向总线驱动程序中定义的bus_typebus_device结构,LDM 核心将使用它们来构建设备层级结构并初始化其他字段。

每个总线内部管理着两个重要的列表:一个是已添加并挂载在其上的设备列表,另一个是已注册到该总线的驱动程序列表。每当你添加/注册或删除/注销设备/驱动程序时,相应的列表都会更新,加入新的条目。总线驱动程序必须提供辅助函数,以便注册/注销可以处理该总线上设备的驱动程序,以及注册/注销挂载在总线上的设备。这些辅助函数总是封装了 LDM 核心提供的通用函数,即driver_register()device_register()driver_unregister()device_unregister()

现在,让我们开始编写一个新的总线基础设施,叫做PACKTPACKT将成为我们的总线;挂载在这条总线上的设备将是PACKT设备,而它们的驱动程序将是PACKT驱动程序。让我们开始编写允许我们注册PACKT设备和驱动程序的助手函数:

/*
 * Let's write and export symbols that people
 * writing drivers for packt devices must use.
 */
int packt_register_driver(struct packt_driver *driver)
{    
    driver->driver.bus = &packt_bus_type;
    return driver_register(&driver->driver);
}
EXPORT_SYMBOL(packt_register_driver);
void packt_unregister_driver(struct packt_driver *driver)
{
    driver_unregister(&driver->driver);
}
EXPORT_SYMBOL(packt_unregister_driver);
int packt_register_device(struct packt_device *packt)
{
    packt->dev.bus = &packt_bus_type;
    return device_register(&packt->dev);
}
EXPORT_SYMBOL(packt_device_register);
void packt_unregister_device(struct packt_device *packt)
{
    device_unregister(&packt->dev);
}
EXPORT_SYMBOL(packt_unregister_device);

现在我们已经创建了注册助手函数,接下来编写唯一的函数,允许我们分配一个新的PACKT设备并将其注册到PACKT核心:

struct packt_device * packt_device_alloc(const char *name,
                                          int id)
{
    struct packt_device    *packt_dev;
    int                    status;
    packt_dev = kzalloc(sizeof(*packt_dev), GFP_KERNEL);
    if (!packt_dev)
        return NULL;
    /* devices on the bus are children of the bus device */
    strcpy(packt_dev->name, name);
    packt_dev->dev.id = id;
    dev_dbg(&packt_dev->dev,
      "device [%s] registered with PACKT bus\n",
       packt_dev->name);
    return packt_dev;
}
EXPORT_SYMBOL_GPL(packt_device_alloc);

packt_device_alloc()函数分配了一个与总线相关的设备结构,必须使用它来将PACKT设备注册到总线。此时,我们应该暴露一些助手函数,允许我们分配一个PACKT控制器设备并注册它——也就是说,注册一个新的PACKT总线。为此,我们必须定义PACKT控制器数据结构,如下所示:

struct packt_controller {
    char name[48];
    struct device dev;    /* the controller device */
    struct list_head  list;
    int (*send_msg) (stuct packt_device *pdev,
                       const char *msg, int count);
    int (*recv_msg) (stuct packt_device *pdev,
                        char *dest, int count);
};

在前面的数据结构中,name表示控制器的名称,dev表示与该控制器关联的底层struct devicelist用于将该控制器插入到系统的PACKT控制器全局列表中,而send_msgrecv_msg是控制器必须提供的钩子函数,用于访问挂载在其上的PACKT设备:

/* system global list of controllers */
static LIST_HEAD(packt_controller_list);
struct packt_controller
    *packt_alloc_controller(struct device *dev)
{
    struct packt_controller *ctlr;
    if (!dev)
        return NULL;
    ctlr = kzalloc(sizeof(packt_controller), GFP_KERNEL);
    if (!ctlr)
        return NULL;
    device_initialize(&ctlr->dev);
    [...]
    return ctlr;
}
EXPORT_SYMBOL_GPL(packt_alloc_controller);
int packt_register_controller(
                           struct packt_controller *ctlr)
{
    /* must provide at least on hook */
if (!ctlr->send_msg && !ctlr->recv_msg){
        pr_err("Registering PACKT controller failure\n");
    }
    device_add(&ctlr->dev);
    [...] /* other sanity check */
    list_add_tail(&ctlr->list, &packt_controller_list);
}
EXPORT_SYMBOL_GPL(packt_register_controller);

在这两个函数中,我们演示了控制器分配和注册操作的实现。分配方法分配内存并进行一些基本的初始化,为驱动程序留出空间以完成其余操作。请注意,在注册控制器之后,它会出现在 sysfs 中的/sys/devices下。任何添加到该总线的设备将会出现在/sys/devices/packt-0/下。

总线注册

总线控制器本身就是一个设备,在大多数情况下,总线是内存映射的平台设备(即使是总线也支持设备枚举)。例如,PCI 控制器就是一个平台设备,它的相应驱动程序也是。我们应该使用bus_register(struct *bus_type)函数将总线注册到内核中。PACKT总线结构如下所示:

/* This is our bus structure */
struct bus_type packt_bus_type = {
    .name     = "packt",
    .match    = packt_device_match,
    .probe    = packt_device_probe,
    .remove   = packt_device_remove,
    .shutdown = packt_device_shutdown,
};

现在基本的总线操作已经定义,我们需要注册PACKT总线框架并使其可供控制器和从属驱动程序使用。总线控制器本身就是一个设备;它必须注册到内核,并作为总线上设备的父设备。这个过程在总线控制器的探测或init函数中完成。对于PACKT总线,代码如下:

static int __init packt_init(void)
{
    int status;
    status = bus_register(&packt_bus_type);
    if (status < 0)
        goto err0;
    status = class_register(&packt_master_class);
    if (status < 0)
        goto err1;
    return 0;
err1:
    bus_unregister(&packt_bus_type);
err0:
    return status;
}
postcore_initcall(packt_init);

当设备由总线控制器驱动程序注册时,设备的parent成员必须指向总线控制器设备(这应由设备驱动程序完成),并且其bus属性必须指向PACKT总线类型(这是由核心完成的),以构建物理设备树。要注册一个PACKT设备,必须调用packt_device_register(),该函数应接收一个使用packt_device_alloc()分配的PACKT设备作为参数:

int packt_device_register(struct packt_device *packt)
{
    packt->dev.bus = &packt_bus_type;
    return device_register(&packt->dev);
}
EXPORT_SYMBOL(packt_device_register);

现在我们完成了总线注册,让我们看看驱动程序的架构,看看它是如何设计的。

驱动程序数据结构

驱动程序是一组允许我们驱动给定设备的方法。全局设备层次结构允许系统中所有设备以相同的方式表示。这使得核心能够简单地浏览设备树并执行诸如正确顺序的电源管理过渡等任务。

每个设备驱动程序都表示为struct device_driver的一个实例,其定义如下:

struct device_driver {
    const char        *name;
    struct bus_type   *bus;
    struct module     *owner;
    const struct of_device_id   *of_match_table;
    const struct acpi_device_id  *acpi_match_table;
    int (*probe) (struct device *dev);
    int (*remove) (struct device *dev);
    void (*shutdown) (struct device *dev);
    int (*suspend) (struct device *dev,
                    pm_message_t state);
    int (*resume) (struct device *dev);
    const struct attribute_group **groups;
    const struct dev_pm_ops *pm;
};

让我们看看这个数据结构中的元素:

  • name表示驱动程序的名称。它可以用于匹配,通过将其与设备的名称进行比较。

  • bus表示该驱动程序所处的总线。bus驱动程序必须填写此字段。

  • module表示拥有此驱动程序的模块。在 99%的情况下,您必须将此字段设置为THIS_MODULE

  • of_match_table是指向struct of_device_id数组的指针。struct of_device_id结构用于通过一个特殊的文件(称为设备树)执行开放固件匹配,该文件在启动过程中传递给内核:

    struct of_device_id {
        char       compatible[128];
        const void *data;
    };
    
  • suspendresume 是电源管理回调,分别在设备进入睡眠状态或从睡眠状态唤醒时调用。remove 回调在设备被物理移除系统时,或者当其引用计数达到 0 时被调用。系统重启时也会调用 remove 回调。

  • probe 是在尝试将驱动程序绑定到设备时运行的探测回调。总线驱动程序负责调用设备驱动程序的探测函数。

  • group 是指向 struct attribute_group 列表(数组)的指针,用作驱动程序的默认属性。优先使用此方法,而不是单独创建属性。

现在我们已经熟悉了驱动程序数据结构及其所有元素,让我们了解内核提供了哪些 API 供我们注册它。

驱动程序注册

低级 driver_register() 函数用于将设备驱动程序与总线注册,并将其添加到总线的驱动程序列表中。当设备驱动程序与总线注册时,核心会遍历该总线上所有设备的列表,并为每个没有驱动程序的设备调用总线的匹配回调。这样做是为了查找是否有设备是该驱动程序可以处理的。

以下是我们驱动程序基础设施的声明:

/*
 * Bus specific driver structure
 * You should provide your device's probe
 * and remove functions.
 */
struct packt_driver {
    int     (*probe)(struct packt_device *packt);
    int     (*remove)(struct packt_device *packt);
    void    (*shutdown)(struct packt_device *packt);
    struct device_driver driver;
    const struct i2c_device_id *id_table;
};
#define to_packt_driver(d) \
        container_of(d, struct packt_driver, driver)
#define to_packt_device(d) container_of(d, \
                             struct packt_device, dev)

在我们的示例中,有两个辅助宏,用于根据通用的 struct devicestruct driver 获取 PACKT 设备和 PACKT 驱动程序。

然后是用于标识 PACKT 设备的结构,定义如下:

struct packt_device_id {
    char name[PACKT_NAME_SIZE];
    kernel_ulong_t driver_data;   /* Data private to the driver */
};

设备和设备驱动程序在匹配时绑定在一起。绑定是将设备与设备驱动程序关联的过程,由总线框架执行。

现在,让我们回到将驱动程序与 PACKT 总线注册的问题。驱动程序必须使用 packt_register_driver(struct packt_driver *driver),它是 driver_register() 的封装。driver 参数必须在注册 PACKT 驱动程序之前填写好。LDM 核心提供了辅助函数,用于遍历已注册到总线的驱动程序列表:

int bus_for_each_drv(struct bus_type * bus,
                struct device_driver * start, void * data,
                int (*fn)(struct device_driver *, void *));

这个辅助函数遍历总线的驱动程序列表,并为列表中的每个驱动程序调用 fn 回调。

设备数据结构

struct device 是通用数据结构,用于描述和表征系统中的每个设备,无论其是否为物理设备。它包含关于设备物理属性的详细信息,并提供适当的链接信息,以帮助构建合适的设备树和引用计数:

struct device {
    struct device *parent;
    struct kobject kobj;
    const struct device_type *type;
    struct bus_type      *bus;
    struct device_driver *driver;
    void    *platform_data;
    void    *driver_data;
    struct device_node      *of_node;
    struct class *class;
    const struct attribute_group **groups;
    void    (*release)(struct device *dev);
[...]
};

为了便于阅读,前面的数据结构已被简化。尽管如此,我们来看一下已提供的元素:

  • parent 表示设备的父设备,用于构建设备树层次结构。当与总线注册时,总线驱动程序负责用总线设备设置此字段。

  • bus 表示设备所在的总线。总线驱动程序必须填写此字段。

  • type标识设备的类型。

  • kobj是 kobject,用于处理引用计数和设备模型支持。

  • of_node是指向与设备相关的开放固件(设备树)节点的指针。由总线驱动来设置此字段。

  • platform_data是指向特定于设备的平臺数据的指针。它通常在设备配置期间在板级特定文件中声明。

  • driver_data是指向驱动程序私有数据的指针。

  • class是指向该设备所属类的指针。

  • group是指向struct attribute_group类型的列表(数组)的指针,并用作设备的默认属性。你应该使用这个,而不是单独创建属性。

  • release是一个回调函数,当设备的引用计数降到零时被调用。总线有责任设置这个字段。PACKT总线驱动展示了如何执行此操作。

现在我们已经描述了设备的结构,接下来让我们学习如何通过注册将它纳入系统。

设备注册

device_register()是 LDM 核心提供的一个函数,用于将设备注册到总线。在这个调用之后,总线的驱动程序列表会被遍历,寻找支持该设备的驱动程序;然后,这个设备会被添加到总线的设备列表中。device_register()内部调用device_add()

int device_add(struct device *dev)
{
    [...]
    bus_probe_device(dev);
        if (parent)
                klist_add_tail(&dev->p->knode_parent,
                            &parent->p->klist_children);
    [...]
}

内核提供的帮助函数,用于遍历总线的设备列表的是bus_for_each_dev。它的定义如下:

int bus_for_each_dev(struct bus_type * bus,
                    struct device * start, void * data,
                    int (*fn)(struct device *, void *));

每当一个设备被添加时,核心会调用总线驱动的匹配方法(bus_type->match)。如果匹配函数成功,核心会调用总线驱动的探测函数(bus_type->probe),前提是设备和驱动作为参数匹配。然后,由总线驱动来调用设备驱动的探测方法(即driver->probe)。对于我们的PACKT总线驱动,用于注册设备的函数是packt_device_register(struct packt_device *packt),该函数内部调用device_register()。这里的参数是一个通过packt_device_alloc()分配的PACKT设备。

总线特定的设备数据结构随后定义如下:

/*
 * Bus specific device structure
 * This is what a PACKT device structure looks like
 */
struct packt_device {
    struct module        *owner;
    unsigned char        name[30];
    unsigned long        price;
    struct device        dev;
};

在前面的代码中,dev是设备模型的底层struct device结构,而name是设备的名称。

现在我们已经定义了数据结构,让我们了解一下 LDM 的底层机制。

更深入了解 LDM

到目前为止,我们讨论了总线、驱动程序和设备,它们被用来构建系统的设备拓扑。虽然这是真的,但前面讨论的只是冰山一角。在内部,LDM 依赖于三个最低级的数据结构,它们分别是kobjectkobj_typekset。这些结构用于链接对象。

在我们进一步讨论之前,让我们定义一下本章中将要使用的一些术语:

  • struct kobject

  • 属性:一个属性(或 sysfs 属性)在 sysfs 中表现为一个文件。在内核中,它可以映射到任何事物:一个变量、一个设备属性、一个缓冲区,或者任何可能需要导出到外部的对驱动程序有用的东西。

在本节中,我们将了解这些结构如何参与设备模型。

理解 kobject 结构

struct kobject,即内核对象(也简称为 struct kobject,在内核中的某个地方徘徊)。此外,一个 kobject 可以导出一个或多个属性,这些属性在该 Kobject 的 sysfs 目录中表现为文件。现在,让我们回到代码中 —— struct kobject 在内核中的定义如下:

struct kobject {
    const char              *name;
    struct list_head        entry;
    struct kobject          *parent;
    struct kset             *kset;
    struct kobj_type        *ktype;
    struct sysfs_dirent     *sd;
    struct kref             kref;
[...]
};

在前面的数据结构中,仅列出了主要元素。让我们更详细地查看它们:

  • name 是此 kobject 的名称。可以通过 kobject_set_name(struct kobject *kobj, const char *name) 函数修改该名称。它用作此 kobject 目录的名称。

  • parent 是指向另一个 kobject 的指针,该 kobject 被视为此 kobject 的父对象。它用于构建拓扑并描述对象之间的关系。

  • sd 指向一个 struct sysfs_dirent 结构体,表示此 kobject 在 sysfs 中的目录。name 将用作此目录的名称。如果 parent 被设置,则该目录将成为父目录中的子目录。

  • kref 为 kobject 提供引用计数。它帮助跟踪对象是否仍在使用中,并在不再使用时可能释放它。或者,它可以防止对象在仍被使用时被删除。当它在内核对象中使用时,初始值为 1

  • ktype 描述了 kobject。每个 kobject 在创建时都会赋予一组默认属性。ktype 元素属于 struct kobj_type 结构体,用于指定这些默认属性。这样的结构允许内核对象共享公共操作(sysfs_ops),无论这些对象是否在功能上相关。

  • kset 告诉我们该对象属于哪个对象集(组)。

在 kobject 被使用之前,必须对其进行(独占)动态分配并初始化。为此,驱动程序可以使用 kzalloc()(或 kmalloc())或 kobject_create() 函数。使用 kzalloc() 时,对象会被分配并且为空,需要使用另一个 API kobject_init() 来初始化。使用 kobject_create() 时,分配和初始化是隐式的。这些 API 定义如下:

void kobject_init(struct kobject *kobj,
                  struct kobj_type *ktype)
struct kobject *kobject_create(void)

在前面的代码中,kobject_init()的第一个参数期望一个已通过kzalloc()等方法分配的 kobject。第二个参数ktype是必需的,不能为NULL;否则,内核将报错(dump_stack())。另一方面,kobject_create()不期望任何参数;它隐式地完成了分配和初始化(它内部调用了kzalloc()kobject_init())。如果成功,它将返回一个刚刚初始化的 kobject 对象。

一旦一个 kobject 被初始化,驱动程序可以使用kobject_add()将这个对象与系统连接,创建这个 kobject 的 sysfs 目录项。这个目录会创建在哪里取决于 kobject 的父元素是否已设置。也就是说,如果父元素没有设置,可以在使用kobject_add()将 kobject 添加到系统时指定,kobject_add()的定义如下:

int kobject_add(struct kobject *kobj, struct kobject *parent,
                const char *fmt, ...);

在前面的函数中,kobj是要添加到系统中的内核对象,parent是它的父对象。kobject目录将作为父目录的子目录创建。如果parentNULL,目录将直接在/sys/下创建。

与其单独使用kobject_init()kobject_create(),然后再使用kobject_add(),不如使用kobject_init_and_add(),它将这些操作组合在一起。它的定义如下:

int kobject_init_and_add(struct kobject *kobj,
                         struct kobj_type *ktype,
                         struct kobject *parent,
                         const char *fmt, ...);

在前面的接口中,首先需要分配对象。还有另一个辅助函数会隐式地分配、初始化并将 kobject 添加到系统中。这个函数是kobject_create_and_add(),其定义如下:

struct kobject * kobject_create_and_add(const char *name,
                                     struct kobject *parent);

前面的函数接受一个 kobject 名称,用作目录名,以及一个父 kobject,其创建的目录将作为子目录。如果将NULL作为第二个参数传递,则目录将直接创建在/sys/下。

也就是说,内核中一些预定义的 kobjects 已经代表了/sys/下的一些目录。让我们看看其中的几个:

  • kernel_kobj:这个 kobject 负责/sys/kernel目录。

  • mm_kobj:这个对象负责/sys/kernel/mm

  • fs_kobj:这是文件系统 kobject,负责/sys/fs

  • hypervisor_kobj:这个对象负责/sys/hypervisor

  • power_kobj:这是一个电源管理 kobject,用于/sys/power的起始位置。

  • firmware_kobj:这是拥有/sys/firmware目录的固件 kobject。

一旦完成 kobject 的使用,驱动程序应该释放它。你可以使用的低级函数是kobject_release()。然而,这个 API 没有考虑 kobject 的其他潜在用户。它是原始且简单的。建议使用kobject_put()代替,它会递减 kobject 的引用计数,并在新的引用计数值为0时释放该 kobject。记住,当 kobject 被初始化时,引用计数值被设置为1。此外,建议用户将 kobject 的使用包装到kobject_get()kobject_put()函数中,其中kobject_get()只会增加引用计数值。

这些 API 具有以下原型:

void kobject_put(struct kobject * kobj);
struct kobject *kobject_get(struct kobject *kobj);

在前面的代码中,kobject_get()将 kobject 作为参数来增加引用计数,并在初始化参数后返回相同的 kobject。kobject_put()将从引用计数中减去 1,如果新的值为0,则会自动调用kobject_release()来释放该对象。

以下代码展示了如何结合kobject_create()kobject_init()kobject_add()来创建并将一个内核对象添加到系统中:

/* Somewhere */
static struct kobject *mykobj;
[...]
mykobj = kobject_create();
if (!mykobj)
    return -ENOMEM;
kobject_init(mykobj, &my_ktype);
if (kobject_add(mykobj, NULL, "%s", "hello")) {
    pr_info("ldm: kobject_add() failed\n");
    kobject_put(mykobj);
    mykobj = NULL;
    return -1;
}

正如我们所看到的,我们可以使用一体化函数,如kobject_create_and_add(),它内部调用kobject_create()kobject_add()。以下是drivers/base/core.c中的摘录,展示了如何使用它:

static struct kobject * class_kobj   = NULL;
static struct kobject * devices_kobj = NULL;
/* Create /sys/class */
class_kobj = kobject_create_and_add("class", NULL);
if (!class_kobj)
    return -ENOMEM;
[...]
/* Create /sys/devices */
devices_kobj = kobject_create_and_add("devices", NULL);
if (!devices_kobj)
    return -ENOMEM;

请记住,对于每个struct kobject,相应的 kobject 目录可以在/sys/中找到,父目录由kobj->parent指向。

注意

由于在初始化 kobject 时必须提供一个kobj_type,因此一体化的辅助函数使用默认的、由内核提供的kobj_type;即dynamic_kobj_ktype。因此,除非你有充分的理由初始化你的kobj_type(通常是在你希望填充一些默认属性时),否则你应该使用kobject_create*()变体,它们使用内核提供的 kobject 类型,而不是kobject_init*(),后者需要提供你自己的初始化kobj_type

理解 kobj_type 结构

struct kobj_type结构,即内核对象类型,是一个定义 kobject 元素行为并控制该 kobject 在创建或销毁时如何处理的数据结构。此外,kobj_type还包含 kobject 的默认属性,以及允许它操作这些属性的钩子函数。

因为同一类型的大多数设备具有相同的属性,这些属性被隔离并存储在ktype元素中。这样可以灵活地管理它们。每个 kobject 都必须有一个关联的kobj_type结构。其数据结构定义如下:

struct kobj_type {
    void (*release)(struct kobject *);
    const struct sysfs_ops sysfs_ops;
    struct attribute **default_attrs;
};

在前面的数据结构中,release 是一个回调函数,在 kobject 的释放路径上被调用,允许驱动程序有机会释放为该 kobject 分配的资源。当 kobject_put() 即将释放 kobject 时,这个回调函数会隐式运行。default_attrs 是一个指向 attribute 结构体的指针数组。该字段列出了每个该类型 kobject 创建的属性,而 sysfs_ops 提供了一组方法,允许你访问这些属性。

以下代码展示了内核中 struct sysfs_ops 数据结构的定义:

struct sysfs_ops {
    ssize_t (*show)(struct kobject *kobj,
                   struct attribute *attr, char *buf);
    ssize_t (*store)(struct kobject *kobj,
                   struct attribute *attr, const char *buf,
                   size_t size);
};

在前面的代码中,show 是一个回调函数,在读取由此 kobj_type 显示的属性时被调用——即每当从用户空间读取某个属性时。buf 是输出缓冲区。缓冲区的大小是固定的,长度为 PAGE_SIZE。必须暴露的数据需要放入 buf 中,最好使用 scnprintf()。最后,如果回调成功,它必须返回写入缓冲区的数据的大小(以字节为单位),如果失败则返回负值错误。每个属性应根据 sysfs 规则包含/提供一个单一的、可读的人类值或属性;如果你有大量数据要返回,应该考虑将其分解为多个属性。

store 用于写入操作——即当用户向属性写入数据时。它的 buf 参数最大为 PAGE_SIZE,但可以更小。成功时,必须返回从缓冲区读取的数据大小(以字节为单位),如果失败(或收到不期望的值)则返回负值错误。

attr 指针作为参数传递给这两个方法,可以用来确定正在访问的属性是哪一个。show/store 方法经常会对属性名称进行一系列测试来判断这一点。另一方面,其他实现则将属性的结构体封装在一个包含数据的结构体中,该结构体提供了返回属性值所需的数据(例如 struct kobject_attributestruct device_attributestruct driver_attributestruct class_attribute 等);在这种情况下,container_of 宏用于获取指向封装结构体的指针。这是 kobj_sysfs_ops 使用的方法,kobj_sysfs_ops 代表由 dynamic_kobj_ktype 提供的操作。两个方法在本书的示例中都有演示。

理解 kset 结构

struct kset 的目的是将相关的内核对象组合在一起。kset 代表内核对象集合,可以理解为一组 kobject。换句话说,kset 将相关的 kobject 聚集在一个地方,例如所有的 块设备

kset 数据结构在内核中定义如下:

struct kset {
    struct list_head list; 
    spinlock_t list_lock;
    struct kobject kobj;
 };

数据结构中的所有元素都非常直观。简而言之,listkset 中所有 kobjects 的链表,list_lock 是一个自旋锁,用于保护链表访问(在向 kset 中添加或移除 kobject 元素时),而 kobj 代表该集合的基础类 kobject。这个 kobject 将作为添加到集合中的 kobjects 的默认父级,前提是其父级为 NULL

每个注册的 kset 对应一个由其 kobj 元素代表创建的 sysfs 目录。可以使用 kset_create_and_add() 函数创建并添加一个 kset,并通过 kset_unregister() 将其移除。以下代码显示了两者的定义:

struct kset * kset_create_and_add(const char *name,
                      const struct kset_uevent_ops *u,
                      struct kobject *parent_kobj);
void kset_unregister (struct kset * k);

在前面的 API 中,namekset 的名称,也用作为 kset 创建的目录名称。u 参数是指向 struct uevent_ops 的指针,表示一组 kset,例如,它可以添加新的环境变量或在需要时过滤掉 uevents。这个参数可以是(而且大多数时候是)NULL。最后,parent_kobjkset 的父级 kobject。

将 kobject 添加到集合中非常简单,只需为正确的 kset 指定它的 .kset 字段:

static struct kobject foo_kobj, bar_kobj;
[...]
example_kset = kset_create_and_add("kset_example",
                           NULL, kernel_kobj);
 /* since we have a kset for this kobject,
  * we need to set it before calling into the kobject core.
  */
foo_kobj.kset = example_kset;
bar_kobj.kset = example_kset;

retval = kobject_init_and_add(&foo_kobj, &foo_ktype,
                              NULL, "foo_name");
retval = kobject_init_and_add(&bar_kobj, &bar_ktype,
                              NULL, "bar_name");

一旦完成对 kset 的操作,可以通过 kset_unregister() 释放它,之后当不再使用时,它将被动态地解除分配。以下代码将释放我们示例中的 kset

kset_unregister(example_kset);

现在我们已经熟悉了 kobjects 和类型结构,让我们来学习如何处理非默认的 sysfs 属性。

处理非默认属性

属性是通过 kobjects 导出到用户空间的 sysfs 文件。虽然默认属性大多数时候已经足够,但你可以添加其他属性。一个属性可以是可读的、可写的,或者两者都能从用户空间进行操作。

一个属性定义如下所示:

struct attribute {
        char              *name;
        struct module     *owner;
        umode_t           mode;
};

在属性数据结构中,name 是属性的名称,也就是对应文件条目的名称。owner 是属性的拥有者——大多数情况下是 THIS_MODULE——而 modeuser-group-otherugo)格式指定此属性的读写权限。

默认属性使用起来非常方便,但灵活性不足。而且,除非通过它们的 kobj_type sysfs ops,否则简单的属性不能被读写,这意味着如果属性太多,show/store 函数中的分支会变得混乱。为了解决这个问题,kobject 核心提供了一种机制,将每个属性嵌入到一个封装的特殊数据结构中:struct kobj_attribute。这个数据结构提供了读取和写入的包装例程。

struct kobj_attribute(在 include/linux/kobject.h 中定义)如下所示:

struct kobj_attribute {
 struct attribute attr;
 ssize_t (*show)(struct kobject *kobj,
                 struct kobj_attribute *attr, char *buf);
 ssize_t (*store)(struct kobject *kobj,
                 struct kobj_attribute *attr,
                 const char *buf, size_t count);
};

在这个数据结构中,attr 是表示要创建的文件的属性,show 是一个指向函数的指针,该函数将在从用户空间读取文件时被调用,store 是一个指向函数的指针,该函数将在从用户空间写入文件时被调用。

使用封装的 kobj_attribute 结构使开发变得更加通用,并扩展了属性的灵活性。这样,指向 attr 的指针会被传递给 storeshow 函数,且不仅可以用来确定哪个属性正在被访问,还可以用来获取封装的结构体(即 kobj_attribute),从而能够调用该属性的 show/store 方法。为了做到这一点,您可以使用 container_of 宏来获取指向嵌套结构体的指针。

以下是来自 lib/kobject.c 的一个摘录,演示了在内核提供的 sysfs 操作元素 kobj_sysfs_opsshowstore 方法中这种通用机制的应用。这个元素也是 sysfs 操作的数据结构(kobj_type->sysfs_ops 元素),它被 dynamic_kobj_ktype 使用:

static ssize_t kobj_attr_show(struct kobject *kobj,
                     struct attribute *attr, char *buf)
{
   struct kobj_attribute *kattr;
   ssize_t ret = -EIO;
   kattr = container_of(attr, struct kobj_attribute, attr);
   if (kattr->show)
       ret = kattr->show(kobj, kattr, buf);
   return ret;
}
static ssize_t kobj_attr_store(struct kobject *kobj,
                          struct attribute *attr, 
                          const char *buf, size_t count)
{
   struct kobj_attribute *kattr;
   ssize_t ret = -EIO;
   kattr = container_of(attr, struct kobj_attribute, attr);
   if (kattr->store)
       ret = kattr->store(kobj, kattr, buf, count);
   return ret;
}
const struct sysfs_ops kobj_sysfs_ops = {
   .show  = kobj_attr_show,
   .store = kobj_attr_store,
};

在前面的代码中,container_of 宏完成了所有工作。这也让我们放心,使用这种方法,我们与所有总线、设备、类和驱动相关的 kobject 实现保持兼容,正如我们将在下一节中看到的那样。

让我们回到这些 API。您可能总是会预先知道希望暴露哪些属性;因此,这些属性几乎总是会静态声明。为了帮助这一点,内核提供了 __ATTR 宏来初始化 kobj_attribute。该宏的定义如下:

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

在前面的宏定义中,_name 将被转换为字符串并用作属性名,_mode 表示属性的权限,_show_store 分别是指向属性的 showstore 方法的指针。

以下是两个属性声明的示例,barfoo(此示例将在本节后面作为基础使用):

static struct kobj_attribute foo_attr =
    __ATTR(foo, 0660, attr_show, attr_store);
static struct kobj_attribute bar_attr =
    __ATTR(bar, 0660, attr_show, attr_store);

在前面的示例中,我们有两个权限为 0660 的属性。第一个属性名为 foo,第二个属性名为 bar,这两个属性都使用相同的 showstore 方法。

现在,我们必须创建底层文件。用于在 sysfs 文件系统中添加/删除属性的低级内核 API 分别是 sysfs_create_file()sysfs_remove_file(),它们的定义如下:

int sysfs_create_file(struct kobject * kobj,
                      const struct attribute * attr);
void sysfs_remove_file(struct kobject * kobj,
                        const struct attribute * attr);

sysfs_create_file() 在成功时返回 0,在失败时返回负错误。sysfs_remove_file() 必须给定相同的参数来删除文件属性。

让我们使用这些 API 将 barfoo 属性添加到系统中:

struct kobject *demo_kobj;
int err;
demo_kobj = kobject_create_and_add("demo", kernel_kobj);
if (!demo_kobj) {
    pr_err("demo: demo_kobj registration failed.\n");
    return -ENOMEM;
}
err = sysfs_create_file(demo_kobj, &foo_attr.attr);
if (err)
    pr_err("unable to create foo attribute\n");
err = sysfs_create_file(demo_kobj, &bar_attr.attr);
if (err){
    sysfs_remove_file(demo_kobj, &foo_attr.attr);
    pr_err("unable to create bar attribute\n");
}

一旦执行了前面的代码,barfoo 文件将在 sysfs 中显示,并位于 /sys/demo 目录下。在我们的示例中,我们使用了 __ATTR 宏来定义我们的属性。我们需要指定名称、模式和 show/store 方法。内核提供了便捷的宏,用于最常见的情况,以简化属性的指定和代码的编写,使其更加简洁、可读和易于理解。这些宏如下:

  • __ATTR_RO(name):假设 name_showshow 回调函数的名称,并将模式设置为 0444

  • __ATTR_WO(name):假设 name_storestore 函数的名称,并将模式限制为 0200,这意味着仅有 root 用户可以写入。

  • __ATTR_RW(name):假设 name_showname_store 分别是 showstore 回调函数的名称,并将模式设置为 0644

  • __ATTR_NULL:它作为列表的结束符使用。它将两个名称都设置为 NULL,并作为列表结束的指示符(见 kernel/workqueue.c)。

所有这些宏只期望属性名作为参数。这些宏的不同之处在于,与 __ATTR 不同,后者的 store/show 函数名可以是任意的,而这里的属性是在假定 showstore 方法分别命名为 <attribute_name>_show<attribute_name>_store 的前提下构建的。以下代码通过 __ATTR_RW_MODE 宏展示了这一点,该宏的定义如下:

#define __ATTR_RW_MODE(_name, _mode) {                 \
    .attr = { .name = __stringify(_name),              \
          .mode = VERIFY_OCTAL_PERMISSIONS(_mode) },\
    .show   = _name##_show,                            \
    .store  = _name##_store,                          \
}

如我们所见,.show.store 字段分别设置为属性名称,后缀分别为 _show_store。我们来看以下示例:

static struct kobj_attribute attr_foo = __ATTR_RW(foo);

前面的属性声明假定 showstore 方法分别被定义为 foo_showfoo_store

注意

如果你需要为所有属性提供一对 store/show 操作,你应该使用 __ATTR 来定义这些属性。然而,如果处理这些属性需要为每个属性提供一对 show/store 操作,你可以使用其他属性定义宏。

以下代码展示了我们之前定义的 foobar 属性的 show/store 函数的实现:

static ssize_t attr_store(struct kobject *kobj,
                      struct kobj_attribute *attr,
                      const char *buf, size_t count)
{
    int value, ret;
    ret = kstrtoint(buf, 10, &value);
    if (ret < 0)
        return ret;
    if (strcmp(attr->attr.name, "foo") == 0)
        foo = value;
    else /* if (strcmp(attr->attr.name, "bar") == 0) */
        bar = value;
    return count;
}
static ssize_t attr_show(struct kobject *kobj,
                      struct kobj_attribute *attr,
                    char *buf)
{
    int value;
    if (strcmp(attr->attr.name, "foo") == 0)
        value = foo;
    else
        value = bar;
     return sprintf(buf, "%d\n", value);
}

在前面的代码中,我们没有为每个属性提供一对 show/store 操作,而是为所有属性使用了相同的函数对,并通过各自的名称区分这些属性。这是在使用通用的 kobject_attribute 而不是框架特定的属性时的常见做法。这是因为它们有时会为每个属性强制要求不同的 show/store 函数名称,因为它们不依赖于 __ATTR 宏来定义属性。

与二进制属性的配合

到目前为止,我们已经熟悉了 sysfs 语句,并提到过属性必须以人类可读的文本格式存储单一的属性/值,并且该属性有一个 PAGE_SIZE 限制。然而,可能会出现一些情况,尽管很少见,但需要交换更大的数据并以二进制格式传输,例如,所有数据都支持随机访问。一个典型的例子是设备固件传输,其中用户空间会上传一些二进制数据,这些数据会被推送到硬件或 PCI 设备中,暴露部分或全部的配置地址空间。

为了涵盖这些情况,sysfs 框架提供了二进制属性。请注意,这些属性用于发送/接收内核完全不进行解释或操作的二进制数据。它应作为与硬件之间的传递通道,内核不会对其进行任何解释。你可以执行的唯一操作是对魔数和大小等进行一些检查。

现在,让我们回到代码中。二进制属性使用 struct bin_attribute 来表示,定义如下:

struct bin_attribute {
    struct attribute attr;
    size_t     size;
    void             *private;
    ssize_t (*read)(struct file *filp,
              struct kobject *kobj,
              struct bin_attribute *attr,
              char *buffer, loff_t off, size_t count);
    ssize_t (*write)(struct file *filp,
             struct kobject *kobj,
             struct bin_attribute *attr,
             const char *buffer, 
             loff_t off, size_t count);
    int (*mmap)(struct file *filp, struct kobject *kobj,
                  struct bin_attribute *attr,
                  struct vm_area_struct *vma);
};

在前面的代码中,attr 是该二进制属性的底层经典属性,包含该二进制属性的名称、所有者和权限。size 表示二进制属性的最大大小(如果没有最大限制,则为零)。private 是一个可以用作任何方便的字段。大多数时候,它被分配为二进制属性的缓冲区。read()write()mmap() 函数是可选的,其工作方式与正常的 char 驱动程序等效。它们的参数中,filp 是与该属性相关联的已打开文件指针实例,kobj 是与此二进制属性相关联的底层 kobjectbuffer 是分别用于读取或写入操作的输入或输出缓冲区。off 是所有类型文件的所有读取或写入方法中都会出现的偏移量参数,它表示从文件开始的偏移量——也就是二进制数据中的偏移量。最后,count 是读取或写入的字节数。

注意

尽管二进制属性可能没有大小限制,但较大的数据始终按 PAGE_SIZE 块请求/发送。这意味着,例如,对于单次加载,write() 函数可能会被多次调用。然而,这种拆分是由内核处理的,因此对驱动程序是透明的。缺点是,sysfs 没有办法标记一系列写操作的结束,因此实现二进制属性的代码必须通过其他方式来解决这个问题。

要创建二进制属性,必须先分配并初始化。与经典属性一样,二进制属性的分配有两种方式——静态分配和动态分配。对于静态分配,框架提供了底层的 __BIN_ATTR 宏,其定义如下:

#define __BIN_ATTR(_name, _mode, _read, _write, _size) {  \
   .attr = { .name = __stringify(_name), .mode = _mode }, \
   .read    = _read,                        \
   .write   = _write,                       \
    .size      = _size,                          \

它的工作原理类似于__ATTR宏。在参数方面,_name是二进制属性的名称,_mode表示其权限,_read_write分别是读取和写入函数,_size是二进制属性的大小。

与经典属性一样,二进制属性也有自己的高级辅助宏,以简化定义过程。以下是一些此类宏:

BIN_ATTR_RO(name, size)
BIN_ATTR_WO(name, size)
BIN_ATTR_RW(name, size)

这些宏声明了一个struct bin_attribute的实例,其对应的变量命名为bin_attribute_<name>,如下所示的BIN_ATTR定义:

#define BIN_ATTR_RW(_name, _size)         \
struct bin_attribute bin_attr_##_name =   \
                __BIN_ATTR_RW(_name, _size)

此外,与经典属性一样,这些高级宏期望读写方法分别命名为<attribute_name>_read<attribute_name>_write,如下所示的__BIN_ATTR_RW定义:

#define __BIN_ATTR_RW(_name, _size) \
 __BIN_ATTR(_name, 0644, _name##_read, _name##_write, \
             _size)

对于动态分配,简单的kzalloc()就足够了。然而,动态分配的二进制属性必须使用sysfs_bin_attr_init()进行初始化,如下所示:

void sysfs_bin_attr_init(strict bin_attribute *bin_attr)

在此之后,驱动程序必须设置其他属性,如基础属性的模式、名称和权限,此外还可以选择设置读写/映射函数。

与经典属性不同,经典属性可以作为默认属性设置,而二进制属性必须显式创建。可以使用sysfs_create_bin_file()来实现,如下所示:

int sysfs_create_bin_file(struct kobject *kobj, 
                          struct bin_attribute *attr);

该函数在成功时返回0,失败时返回负错误。当你完成对二进制属性的操作后,可以使用sysfs_remove_bin_file()将其删除,定义如下:

int sysfs_remove_bin_file(struct kobject *kobj, 
                          struct bin_attribute *attr);

以下是一个摘录(完整版本可以在drivers/i2c/i2c-slave-eeprom.c中找到),展示了一个已分配并初始化的动态二进制属性的具体使用示例:

struct eeprom_data {
[...]
    struct bin_attribute bin;
    u8 buffer[];
};
static int i2c_slave_eeprom_probe(
                             struct i2c_client *client)
{
    struct eeprom_data *eeprom;
    int ret;
    unsigned int size = FIELD_GET(I2C_SLAVE_BYTELEN,
                                  id->driver_data) + 1;
    eeprom = devm_kzalloc(&client->dev,
                sizeof(struct eeprom_data) + size,
                GFP_KERNEL);
    if (!eeprom)
        return -ENOMEM;
    [...]
    sysfs_bin_attr_init(&eeprom->bin);
    eeprom->bin.attr.name = "slave-eeprom";
    eeprom->bin.attr.mode = S_IRUSR | S_IWUSR;
    eeprom->bin.read = i2c_slave_eeprom_bin_read;
    eeprom->bin.write = i2c_slave_eeprom_bin_write;
    eeprom->bin.size = size;
    ret = sysfs_create_bin_file(&client->dev.kobj,
                                  &eeprom->bin);
    if (ret)
        return ret;
    [...]
    return 0;
};

当卸载模块路径或设备断开时,相关的二进制文件会被删除,如下所示:

static int i2c_slave_eeprom_remove(struct i2c_client *client)
{
   struct eeprom_data *eeprom = i2c_get_clientdata(client);
   sysfs_remove_bin_file(&client->dev.kobj, &eeprom->bin);
[...]
   return 0;
}

然后,在实现读写功能时,可以使用memcpy()在数据之间来回移动,如下所示:

static ssize_t i2c_slave_eeprom_bin_read(struct file *filp,
          struct kobject *kobj, struct bin_attribute *attr,
          char *buf, loff_t off, size_t count)
{
    struct eeprom_data *eeprom;
    eeprom = dev_get_drvdata(kobj_to_dev(kobj));
[...]
    memcpy(buf, &eeprom->buffer[off], count);
[...]
    return count;
}
static ssize_t i2c_slave_eeprom_bin_write(
         struct file *filp, struct kobject *kobj,
         struct bin_attribute *attr,
         char *buf, loff_t off, size_t count)
{
     struct eeprom_data *eeprom;
     eeprom = dev_get_drvdata(kobj_to_dev(kobj));
[...]
     memcpy(&eeprom->buffer[off], buf, count);
[...]
    return count;
}

在前面的摘录中,偏移量(off 参数)指示数据应读取/写入的位置,count 决定了数据的大小。

属性组的概念

到目前为止,我们已经学习了如何通过调用sysfs_create_file()sysfs_create_bin_file()函数单独添加(二进制)属性。如果我们需要添加的属性不多,这已经足够,但当属性数量增加时,在添加或删除时可能会变得非常麻烦。驱动程序需要遍历所有属性,逐个创建它们,或者调用sysfs_create_file()与属性数量相同的次数。这时,属性组就显得尤为重要。它依赖于struct attribute_group结构,定义如下:

struct attribute_group {
    const char        *name;
    umode_t           (*is_visible)(struct kobject *,
                         struct attribute *, int);
    umode_t           (*is_bin_visible)(struct kobject *,
                         struct bin_attribute *, int);
    struct attribute  **attrs;
    struct bin_attribute   **bin_attrs;
};

如果没有命名,属性组将在定义属性组时将所有属性直接放入 kobject 的目录中。然而,如果提供了 name,则会为属性创建一个子目录,并且该目录的名称将是属性组的名称。is_visible() 是一个可选的回调函数,用于返回与属性组中特定属性相关的权限。它会为组中的每个(非二进制)属性反复调用。这个回调函数必须返回属性的读/写权限,或者如果该属性根本不应该被访问,则返回 0is_bin_visible()is_visible() 针对二进制属性的对应函数。返回的值/权限将替代在 struct attribute 中定义的静态权限。attrs 元素是指向以 NULL 结尾的属性列表的指针,而 bin_attrs 是二进制属性的对应元素。

用于向文件系统添加/删除属性组的内核函数如下:

int sysfs_create_group(struct kobject *kobj,
                       const struct attribute_group *grp)
void sysfs_remove_group(struct kobject * kobj,
                        const struct attribute_group * grp)

回到我们的示例,使用标准属性,barfoo 两个属性可以嵌入到一个 struct attribute_group 中。这将允许我们通过一次函数调用将它们添加到系统中,如下所示:

static struct kobj_attribute foo_attr =
    __ATTR(foo, 0660, attr_show, attr_store);
static struct kobj_attribute bar_attr =
    __ATTR(bar, 0660, attr_show, attr_store);
/* attrs is aa array of pointers to attributes */
static struct attribute *demo_attrs[] = {
    &bar_foo_attr.attr,
    &bar_attr.attr,
    NULL,
};
static struct attribute_group my_attr_group = {
    .attrs = demo_attrs,
    /*.bin_attrs = demo_bin_attrs,*/
};

最后,要一次性创建属性,我们需要使用 sysfs_create_group(),如下代码所示:

struct kobject *demo_kobj;
int err;
demo_kobj = kobject_create_and_add("demo", kernel_kobj);
if (!demo_kobj) {
    pr_err("demo: demo_kobj registration failed.\n");
    return -ENOMEM;
}
err = sysfs_create_group(demo_kobj, &foo_attr.attr);

在这里,我们展示了创建属性组的重要性,以及如何轻松使用它们的 API。虽然到目前为止我们一直在讨论通用内容,但在下一节中,我们将学习如何创建特定框架的属性。

创建符号链接

驱动程序可以使用 sysfs_{create|remove}_link() 函数在现有的 kobject(目录)上创建/删除符号链接,如下所示:

int sysfs_create_link(struct kobject * kobj,
                     struct kobject * target, char * name); 
void sysfs_remove_link(struct kobject * kobj, char * name);

这允许一个对象存在于多个位置,甚至创建一个快捷方式。create 函数将创建一个名为 name 的符号链接,指向远程 target kobject 的 sysfs 入口。该链接将被创建在 kobj kobject 目录下。一个著名的例子是设备同时出现在 /sys/bus/sys/devices 中,因为总线控制器首先是一个设备,之后才暴露出一个总线。然而,请注意,创建的任何符号链接都会是持久性的(除非系统重启),即使目标被移除后也是如此。因此,驱动程序必须考虑在关联设备离开系统或模块卸载时的情况。

sysfs 设备模型概览

sysfs 是一个非持久性的虚拟文件系统,提供系统的全局视图,并通过其 kobjects 显示内核对象层次结构(拓扑)。每个 kobject 都表现为一个目录。这些目录中的文件表示由相关 kobject 导出的内核变量。这些文件称为属性,可以进行读取或写入。

如果任何已注册的 kobject 在 sysfs 中创建了一个目录,目录的创建位置取决于该 kobject 的父对象(它也是一个 kobject,因此突显了内部对象的层次结构)。在 sysfs 中,顶级目录表示对象层次结构的共同祖先或对象所属的子系统。

这些顶级 sysfs 目录可以在/sys/目录中找到,如下所示:

/sys$ tree -L 1
├── block
├── bus
├── class
├── dev
├── devices
├── firmware
├── fs
├── hypervisor
├── kernel
├── module
└── power

block包含系统中每个块设备的一个目录。每个目录包含该设备上分区的子目录。bus包含系统中已注册的总线。dev以原始方式(没有层次结构)包含已注册的设备节点,每个节点是指向/sys/devices目录中真实设备的符号链接。devices目录展示了系统中设备拓扑的真实视图。firmware展示了系统特定的低级子系统树,如 ACPI、EFI 和 OF(设备树)。fs列出了系统上使用的文件系统。kernel包含内核配置选项和状态信息。最后,module是已加载模块的列表,power是来自用户空间的系统电源管理控制接口。

每个这些目录都对应一个 kobject,其中一些作为内核符号被导出。它们如下所示:

  • kernel_kobj,对应于/sys/kernel

  • power_kobj,对应于/sys/power

  • firmware_kobj,对应于/sys/firmware。它在drivers/base/firmware.c源文件中被导出。

  • hypervisor_kobj,对应于/sys/hypervisor。它在drivers/base/hypervisor.c源文件中被导出。

  • fs_kobj,对应于/sys/fs。它在fs/namespace.c源文件中被导出。

其余的class/dev/devices/在启动时由内核源中的devices_init()函数在drivers/base/core.c中创建,block/block/genhd.c中创建,bus/作为ksetdrivers/base/bus.c中创建。

创建与设备、驱动、总线和类相关的属性

到目前为止,我们已经学会了如何创建专用的 kobject 以填充其中的属性。然而,设备、驱动、总线和类框架提供了属性抽象和文件创建,其中创建的属性直接与适当 kobject 目录中的各自框架相关联。

为此,每个框架提供了一个特定于框架的属性数据结构,封装了默认属性并允许我们提供自定义的 show/store 回调。这些数据结构分别是struct device_attributestruct driver_attributestruct bus_attributestruct class_attribute,它们分别对应设备、驱动、总线和类框架。它们的定义类似于kobj_attribute,但使用不同的名称。让我们看看它们各自的数据结构:

  • 设备有以下的属性数据结构:

    struct driver_attribute {
        struct attribute attr;
        ssize_t (*show)(struct device_driver *driver,
                    char *buf);
        ssize_t (*store)(struct device_driver *driver,
                    const char *buf, size_t count);
    };
    
  • 类有以下的属性数据结构:

    struct class_attribute {
        struct attribute attr;
        ssize_t (*show)(struct class *class,
                 struct class_attribute *attr, char *buf);
        ssize_t (*store)(struct class *class,
                 struct class_attribute *attr,
                 const char *buf, size_t count);
    };
    
  • 总线框架具有以下属性数据结构:

    struct bus_attribute {
        struct attribute  attr;
        ssize_t (*show)(struct bus_type *bus, char *buf);
        ssize_t (*store)(struct bus_type *bus,
                     const char *buf, size_t count);
    };
    
  • 设备具有以下属性数据结构:

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

前述设备特定数据结构的show函数需要一个额外的count参数,而其他函数则没有。

它们可以通过kzalloc()动态分配,并通过设置其内部属性元素的字段并提供适当的回调函数来初始化。然而,每个框架都提供了一组宏,用于静态分配、初始化并分配单个实例的各自属性数据结构。让我们看看这些宏:

  • 总线基础设施提供了以下宏:

    BUS_ATTR_RW(_name)
    BUS_ATTR_RO(_name)
    BUS_ATTR_WO(_name)
    

使用这些特定于总线框架的宏,生成的总线属性变量将命名为bus_attr_<_name>。例如,BUS_ATTR_RW(foo)生成的变量名将是bus_attr_foo,类型为struct bus_attribute

  • 对于驱动程序,提供了以下宏:

    DRIVER_ATTR_RW(_name)
    DRIVER_ATTR_RO(_name)
    DRIVER_ATTR_WO(_name)
    

这些特定于驱动程序的属性定义宏将使用driver_attr_<_name>模式命名生成的变量。因此,DRIVER_ATTR_RW(foo)生成的变量将是struct driver_attribute类型,命名为driver_attr_foo

  • 类框架使用以下宏:

    CLASS_ATTR_RW(_name)
    CLASS_ATTR_RO(_name)
    CLASS_ATTR_WO(_name)
    

使用这些特定于类的宏,生成的变量将是struct class_atribute类型,并且将基于class_attr_<_name>模式命名。因此,CLASS_ATTR_RW(foo)生成的变量名将是class_attr_foo

  • 最后,设备特定属性可以通过以下宏静态分配和初始化:

    DEVICE_ATTR(_name, _mode, _show, _store)
    DEVICE_ATTR_RW(_name)
    DEVICE_ATTR_RO(_name)
    DEVICE_ATTR_WO(_name)
    

设备特定属性定义宏使用它们自己的变量名模式,即dev_attr_<_name>。因此,例如,DEVICE_ATTR_RO(foo)将生成一个名为dev_attr_foostruct device_attribute对象。

因为所有这些宏都建立在__ATTR_RW__ATTR_RO__ATTR_WO之上,它们静态分配并初始化一个框架特定属性数据结构的单个实例,并假设显示/存储函数分别命名为<attribute_name>_show<attribute_name>_store(记住,这是因为它们不依赖于__ATTR宏)。DEVICE_ATTR()有一个例外,它使用传入的显示/存储函数,不加任何后缀或前缀。这个例外是因为DEVICE_ATTR依赖__ATTR来定义属性。

正如我们所见,所有这些特定于框架的宏都使用预定义的前缀来命名生成的框架特定属性对象变量。让我们看看以下类属性:

static CLASS_ATTR_RW(foo);

这将创建一个名为class_attr_foostruct class_attribute类型的静态变量,并假定其显示和存储函数分别命名为foo_showfoo_store。可以在一个组中通过其内部属性元素来引用,如下所示:

static struct attribute *fake_class_attrs[] = {
    &class_attr_foo.attr,
    [...]
    NULL,
};
static struct attribute_group fake_attr_group = {
    .attrs = fake_class_attrs,
};

创建相应文件时最重要的事情是,驱动程序可以从以下列表中选择适当的 API:

int device_create_file(struct device *device,
            const struct device_attribute *entry);
int driver_create_file(struct device_driver *driver,
            const struct driver_attribute *attr);
int bus_create_file(struct bus_type *bus,
            struct bus_attribute *);
int class_create_file(struct class *class,
            const struct class_attribute *attr)

在这里,devicedriverbusclass参数分别表示设备、驱动、总线和类别实体,属性必须添加到这些实体中。此外,属性将被创建在每个实体的内部 kobject 所对应的目录中,代码如下所示:

int device_create_file(struct device *dev,
                   const struct device_attribute *attr)
{
    [...]
    error = sysfs_create_file(&dev->kobj, &attr->attr);
    [...]
}
int class_create_file(struct class *cls,
                    const struct class_attribute *attr)
{
    [...]
    error =
        sysfs_create_file(&cls->p->class_subsys.kobj,
                          &attr->attr);
    return error;
}
int bus_create_file(struct bus_type *bus,
                   struct bus_attribute *attr)
{
    [...]
    error =
        sysfs_create_file(&bus->p->subsys.kobj,
                           &attr->attr);
    [...]
}

为了一举两得,前面的代码还展示了device_create_file()bus_create_file()driver_create_file()class_create_file()都内部调用了sysfs_create_file()

一旦完成每个属性对象,必须调用适当的移除方法。以下代码展示了可能的选项:

void device_remove_file(struct device *device,
             const struct device_attribute *entry);
void driver_remove_file(struct device_driver *driver,
                const struct driver_attribute *attr);
void bus_remove_file(struct bus_type *,
                struct bus_attribute *);
void class_remove_file(struct class *class,
                       const struct class_attribute *attr);

这些 API 都期望与创建属性时传递的参数相同。

既然你已经知道了如何调用kobj_atribute元素的内部 show/store 函数,那么你应该很清楚如何调用那些框架特定的 show/store 函数了。

让我们看看设备的实现。设备框架有一个内部的kobj_type,它实现了设备特定的 show 和 store 函数。这些函数将内部属性元素作为其中一个参数。然后,container_of宏会获取封闭数据结构的指针(即框架特定的属性数据结构),从而调用框架特定的 show 和 store 函数。

以下是drivers/base/core.c中的一段代码,展示了设备特定的sysfs_ops实现:

static ssize_t dev_attr_show(struct kobject *kobj,
                            struct attribute *attr,
                            char *buf)
{
    struct device_attribute *dev_attr = to_dev_attr(attr);
    struct device *dev = kobj_to_dev(kobj);
    ssize_t ret = -EIO;
    if (dev_attr->show)
          ret = dev_attr->show(dev, dev_attr, buf);
    if (ret >= (ssize_t)PAGE_SIZE) {
        print_symbol("dev_attr_show:
                        %s returned bad count\n",
                    (unsigned long)dev_attr->show);
    }
    return ret;
}
static ssize_t dev_attr_store(struct kobject *kobj,
                      struct attribute *attr,
                      const char *buf, size_t count)
{
    struct device_attribute *dev_attr = to_dev_attr(attr);
    struct device *dev = kobj_to_dev(kobj);
    ssize_t ret = -EIO;
    if (dev_attr->store)
        ret = dev_attr->store(dev, dev_attr, buf, count);
    return ret;
}
static const struct sysfs_ops dev_sysfs_ops = {
    .show     = dev_attr_show,
    .store    = dev_attr_store,
};

注意,在前面的代码中,to_dev_attr(),即使用container_of的宏,定义如下:

#define to_dev_attr(_attr) \
       container_of(_attr, struct device_attribute, attr)

总线(在drivers/base/bus.c中)、驱动(在drivers/base/bus.c中)和类别(在drivers/base/class.c中)属性的原理是相同的。

使 sysfs 属性兼容轮询和选择

尽管这不是处理 sysfs 属性的必需条件,但这里的主要思想是允许在给定属性上使用poll()select()系统调用,来被动地等待变化。这种变化可以是固件变得可用、警报通知,或属性值发生变化。当用户在文件上休眠等待变化时,驱动程序必须调用sysfs_notify()来唤醒任何休眠的用户。

这个通知 API 的定义如下:

void sysfs_notify(struct kobject *kobj, const char *dir,
                  const char *attr)

如果dir参数不是NULL,则用于在kobj的目录中查找包含属性的子目录(假设是由sysfs_create_group创建的)。此调用将使任何轮询进程唤醒并处理事件(可能是读取新值、处理警报等)。

注意

如果没有此功能调用,则不会有任何通知;因此,任何轮询进程将最终无限期地等待(除非在系统调用中指定了超时)。

下面的代码展示了属性的store()函数,本书提供了该代码:

static ssize_t store(struct kobject *kobj,
                     struct attribute *attr,
                     const char *buf, size_t len)
{
    struct d_attr *da = container_of(attr, struct d_attr, 
                                      attr);
    sscanf(buf, "%d", &da->value);
    pr_info("sysfs_foo store %s = %d\n",
             a->attr.name, a->value);
    if (strcmp(a->attr.name, "foo") == 0){
        foo.value = a->value;
        sysfs_notify(mykobj, NULL, "foo");
    }
    else if(strcmp(a->attr.name, "bar") == 0){
        bar.value = a->value;
        sysfs_notify(mykobj, NULL, "bar");
    }
    return sizeof(int);
}

在前面的代码中,一旦值更新,调用sysfs_notify()是有意义的,这样用户代码就可以读取准确的值。

用户代码可以直接将已打开的属性文件传递给poll()select(),而不必读取该属性的初始内容。这是开发人员的方便做法。然而,请注意,在通知时,poll()返回POLLERR|POLLPRI(这些是标志,用户在调用poll()时必须请求),而select()则返回文件描述符,指示它是等待读取、写入还是异常事件。

总结

完成本章后,您应该熟悉 LDM 及其数据结构(总线、类、设备和驱动程序),以及其低级数据结构,包括kobjectksetkobj_typeattributes(或这些的组合)。您现在应该知道内核内对象是如何表示的(设备拓扑),并能够创建一个属性(或属性组),通过 sysfs 暴露设备或驱动程序的特性和属性。

在下一章中,我们将介绍IIO工业 I/O)框架,它大量使用 sysfs 的功能。

第四部分 - 嵌入式世界的其他内核子系统

本节将涉及 IIO 框架、引脚控制和 GPIO 系统以及其他重要的功能丰富的概念。在本节中,你还将深入理解映射到中断的 GPIO 及其处理机制,描述涉及的每个内核数据结构和 API。最后,你将学习如何处理输入设备驱动程序。不言而喻,用户空间如何处理已列举的子系统也将在本节中介绍。

本节将涵盖以下章节:

  • 第十五章深入了解 IIO 框架

  • 第十六章充分利用引脚控制器和 GPIO 子系统

  • 第十七章利用 Linux 内核输入子系统

第十五章:第十五章:深入挖掘 IIO 框架

工业输入/输出 (IIO) 是一个内核子系统,专门用于模数转换器 (ADC) 和数模转换器 (DAC) 。随着传感器数量的增加(具有模数转换或数模转换功能的测量设备),以及不同代码实现的散布在内核源代码中,收集它们变得必要。这正是 IIO 框架所做的,以一种通用的方式。Jonathan Cameron 和 Linux IIO 社区自 2009 年以来一直在开发该框架。加速度计、陀螺仪、电流/电压测量芯片、光传感器和压力传感器都属于 IIO 设备家族。

IIO 模型基于设备和通道架构:

  • 该设备代表芯片本身,是层次结构的顶层。

  • 通道代表设备的单个采集线路。一个设备可能有一个或多个通道。例如,加速度计是一个具有三个通道的设备,每个轴对应一个通道(xyz)。

IIO 芯片是物理硬件传感器/转换器。它作为字符设备(当支持触发缓冲区时)和一个 sysfs 目录条目暴露给用户空间,该条目将包含一组文件,其中一些文件代表通道。

这两种方式可以用来从用户空间与 IIO 设备进行交互:

  • /sys/bus/iio/iio:deviceX/,一个 sysfs 目录,表示设备及其通道

  • /dev/iio:deviceX,一个字符设备,导出设备的事件和数据缓冲区

一张图胜过千言万语,下面是展示 IIO 框架概述的图:

图 15.1 – IIO 框架概述

图 15.1 – IIO 框架概述

上图展示了 IIO 框架在内核和用户空间之间的组织方式。驱动程序管理硬件,并将处理信息报告给 IIO 核心,利用 IIO 核心暴露的一组设施和 API。然后,IIO 子系统通过 sysfs 接口和字符设备将整个底层机制抽象到用户空间,用户可以在其之上执行系统调用。

IIO API 分布在几个头文件中,具体如下:

/* mandatory, the core */
#include <linux/iio/iio.h>
/* mandatory since sysfs is used */
#include <linux/iio/sysfs.h>
/* Optional. Advanced feature, to manage iio events */
#include <linux/iio/events.h> 
/* mandatory for triggered buffers */
#include <linux/iio/buffer.h>
/* rarely used. Only if the driver implements a trigger */
#include <linux/iio/trigger.h>

在本章中,我们将描述并处理 IIO 框架中的每个概念,例如遍历其数据结构(设备、通道等),处理触发缓冲区支持和连续捕获,以及其 sysfs 接口,探索现有的 IIO 触发器,学习如何在单次模式或连续模式下捕获数据,并列出可以帮助开发者测试其设备的工具。

换句话说,本章将涵盖以下主题:

  • IIO 数据结构简介

  • 集成 IIO 触发缓冲区支持

  • 访问 IIO 数据

  • 处理内核中的 IIO 消费者接口

  • 浏览用户空间 IIO 工具

IIO 数据结构简介

IIO 框架由几个数据结构组成,其中一个表示 IIO 设备,另一个描述该设备,最后一个列举设备暴露的通道。IIO 设备在内核中表示为 struct iio_dev 实例,并通过 struct iio_info 结构描述。所有重要的 IIO 结构都定义在 include/linux/iio/iio.h 中。

理解 struct iio_dev 结构

struct iio_dev 结构表示 IIO 设备,描述设备及其驱动程序。它告诉我们设备上可用的通道数量以及设备可以操作的模式(例如一次性转换或触发缓冲区)。此外,该数据结构还暴露了一些驱动程序需要提供的挂钩。

该数据结构的定义如下:

struct iio_dev {
    [...]
    int             modes;
    int             currentmode;
    struct device   dev;
    struct iio_buffer           *buffer;
    int                         scan_bytes;
    const unsigned long         *available_scan_masks;
    const unsigned long         *active_scan_mask;
    bool                        scan_timestamp;
    struct iio_trigger          *trig;
    struct iio_poll_func        *pollfunc;
    struct iio_chan_spec const  *channels;
    int                         num_channels;
    const char                  *name;
    const struct iio_info       *info;
    const struct iio_buffer_setup_ops   *setup_ops;
    struct cdev                 chrdev;
};

为了提高可读性,在前面的摘录中只列出了与我们相关的元素。完整的结构定义位于 include/linux/iio/iio.h。以下是数据结构中各元素的含义:

  • modes 表示设备支持的不同模式。可能的模式如下:

    • INDIO_DIRECT_MODE:表示设备提供类似 sysfs 的接口。

    • INDIO_BUFFER_TRIGGERED:表示设备支持与缓冲区相关的硬件触发。当你使用 iio_triggered_buffer_setup() 函数设置触发缓冲区时,此标志模式会自动设置。

    • INDIO_BUFFER_SOFTWARE:在连续转换中,缓冲区将由内核软件实现。内核将数据推送到内部 FIFO,并在达到指定水位时可能会触发中断。

    • INDIO_BUFFER_HARDWARE:表示设备具有硬件缓冲区。在连续转换中,缓冲区由设备处理。这意味着数据流可以直接从硬件后端获取。

    • INDIO_ALL_BUFFER_MODES:前三种模式的联合。

    • INDIO_EVENT_TRIGGERED:转换可以由某种事件触发,例如 ADC 上的阈值电压达到,但没有中断或定时器触发。此标志适用于没有其他触发转换方式的带有比较器的芯片。

    • INDIO_HARDWARE_TRIGGERED:可以通过硬件事件触发,例如 IRQ 或时钟事件。

    • INDIO_ALL_TRIGGERED_MODESINDIO_BUFFER_TRIGGEREDINDIO_EVENT_TRIGGEREDINDIO_HARDWARE_TRIGGERED 的联合。

  • currentmode:表示设备使用的模式。

  • dev:表示 IIO 设备所绑定的 struct device 结构(根据 Linux 设备模型)。

  • buffer:这是你的数据缓冲区,当使用触发缓冲区模式时,它会被推送到用户空间。当你使用 iio_triggered_buffer_setup 函数启用触发缓冲区支持时,它会自动分配并与设备关联。

  • scan_bytes:这是捕获的字节数,用于填充缓冲区。在使用来自用户空间的触发缓冲区时,缓冲区的大小应该至少为indio->scan_bytes字节。

  • available_scan_masks:这是一个可选的允许的位掩码数组。使用触发缓冲区时,可以启用要捕获并输入 IIO 缓冲区的通道。如果不希望启用某些通道,应该仅在此数组中填写允许的通道。以下是一个加速度计(具有 X、Y 和 Z 通道)的示例:

    /*
     * Bitmasks 0x7 (0b111) and 0 (0b000) are allowed.
     * It means one can enable none or all of them.
     * You can't for example enable only channel X and Y
     */
    static const unsigned long my_scan_masks[] = {0x7, 0};
    indio_dev->available_scan_masks = my_scan_masks;
    
  • active_scan_mask:这是已启用通道的位掩码。只有来自这些通道的数据应该被推送到缓冲区。例如,对于一个八通道的 ADC 转换器,如果只启用第一个(索引 0)、第三个(索引 2)和最后一个(索引 7)通道,则位掩码将为0b100001010x85)。active_scan_mask将被设置为0x85。然后,驱动程序可以使用for_each_set_bit宏遍历每个位,获取相应通道的数据并填充缓冲区。

  • scan_timestamp:该字段指示是否将捕获的时间戳推送到缓冲区。如果为true,时间戳将作为缓冲区的最后一个元素推送。时间戳大小为 8 字节(64 位)。

  • trig:这是当前的设备触发器(当支持缓冲区模式时)。

  • pollfunc:这是在触发接收到时运行的函数。

  • channels:表示表通道规范结构,用于描述设备具有的每个通道。

  • num_channels:表示在channels中指定的通道数量。

  • name:表示设备名称。

  • info:来自驱动程序的回调和常量信息。

  • setup_ops:在缓冲区启用/禁用前后调用的一组回调函数。这个结构在include/linux/iio/iio.h中定义,如下所示:

    struct iio_buffer_setup_ops {
        int (* preenable) (struct iio_dev *);
        int (* postenable) (struct iio_dev *);
        int (* predisable) (struct iio_dev *);
        int (* postdisable) (struct iio_dev *);
        bool (* validate_scan_mask) (
                         struct iio_dev *indio_dev,
                         const unsigned long *scan_mask);
    };
    

请注意,数据结构中的每个回调都是可选的。

  • chrdev:IIO 核心创建的关联字符设备,文件操作表为iio_buffer_fileops

现在我们已经熟悉了 IIO 设备结构,下一步是为其分配内存。实现此目标的合适函数是devm_iio_device_alloc(),它是iio_device_alloc()的管理版本,定义如下:

struct iio_dev *devm_iio_device_alloc(struct device *dev,
                                       int sizeof_priv)

推荐在新的驱动程序中使用管理版本,因为devres核心会在不再需要时自动释放内存。在上述函数原型中,dev是分配iio_dev的设备,sizeof_priv是为任何私有数据结构分配的额外内存空间。如果分配失败,函数将返回NULL

在分配了 IIO 设备内存后,下一步是初始化不同的字段。完成后,设备必须使用devm_iio_device_register()函数注册到 IIO 子系统中,该函数的原型如下所示:

int devm_iio_device_register(struct device *dev,
                             struct iio_dev *indio_dev);

这个函数是iio_device_register()的托管版本,并负责在驱动程序分离时取消注册 IIO 设备。在其参数中,dev是与为其分配了 IIO 设备的设备相同的设备,indio_dev是先前初始化的 IIO 设备。此函数成功执行后(返回0),设备将准备好接受来自用户空间的请求。以下是一个示例,展示了如何注册 IIO 设备:

static int ad7476_probe(struct spi_device *spi)
{
    struct ad7476_state *st;
    struct iio_dev *indio_dev;
    int ret;
    indio_dev = devm_iio_device_alloc(&spi->dev, 
                                        sizeof(*st));
    if (!indio_dev)
         return -ENOMEM;
    /* st is given the address of reserved memory for
    * private data 
    */
    st = iio_priv(indio_dev);
    [...]
    /* iio device setup */
    indio_dev->name = spi_get_device_id(spi)->name;
    indio_dev->modes = INDIO_DIRECT_MODE;
    indio_dev->num_channels = 2;
    [...]
    return devm_iio_device_register(&spi->dev, indio_dev);
}

如果发生错误,devm_iio_device_register()将返回负的错误代码。非托管变体的反向操作(通常在释放函数中进行)是iio_device_unregister(),其声明如下:

void iio_device_unregister(struct iio_dev *indio_dev)

然而,托管注册负责在驱动程序分离或设备离开系统时取消注册设备。此外,由于我们使用了托管分配变体,因此无需手动释放内存,因为这将由核心内部处理。

你可能也注意到我们在摘录中使用了一个新函数,iio_priv()。这个访问器返回与 IIO 设备分配的私有数据的地址。建议使用这个函数,而不是直接解引用。例如,给定一个 IIO 设备,可以按如下方式检索相应的私有数据:

struct my_private_data *the_data = iio_priv(indio_dev);

IIO 设备本身是没有用的。现在我们完成了主要的 IIO 设备数据结构,我们必须添加一组钩子,以便与设备进行交互。

理解struct iio_info结构

struct iio_info结构用于声明 IIO 核心读取/写入通道/属性值时使用的钩子。以下是其声明的一部分:

struct iio_info {
    const struct attribute_group  *attrs;
    int (*read_raw)(struct iio_dev *indio_dev,
            struct iio_chan_spec const *chan,
            int *val, int *val2, long mask);
    int (*write_raw)(struct iio_dev *indio_dev,
             struct iio_chan_spec const *chan,
             int val, int val2, long mask);
    [...]
};

同样,完整的定义可以在/include/linux/iio/iio.h中找到。对于前面结构摘录中的枚举元素,以下是它们的含义:

  • attrs表示暴露给用户空间的设备属性。

  • read_raw是当用户读取设备 sysfs 文件属性时调用的回调。mask参数是一个位掩码,允许我们知道请求的值类型。chan参数让我们知道相关的通道。*val*val2是输出参数,必须包含组成返回值的元素。它们必须使用从设备读取的原始值进行设置。

该回调的返回值是标准化的,指示 IIO 核心如何处理*val*val2以计算真实值。可能的返回值如下:

  • IIO_VAL_INT:输出值是一个整数。在这种情况下,驱动程序必须仅设置*val

  • IIO_VAL_INT_PLUS_MICRO:输出值由整数部分和微小部分组成。驱动程序必须使用整数值设置*val,而*val2必须设置为微小值。

  • IIO_VAL_INT_PLUS_NANO:这与微小部分相同,但*val2必须设置为纳米值。

  • IIO_VAL_INT_PLUS_MICRO_DB:输出值中的*val必须设置为整数部分,*val2必须设置为微部分(如果有的话)。

  • IIO_VAL_INT_MULTIPLEval被视为整数数组,*val2是数组中的条目数。它们必须相应地设置。val的最大大小是INDIO_MAX_RAW_ELEMENTS,定义为4

  • IIO_VAL_FRACTIONAL:最终值是分数形式。驱动程序必须将*val设置为分子,*val2设置为分母。

  • IIO_VAL_FRACTIONAL_LOG2:最终值是对数分数。IIO 核心期望分母(*val2)作为实际分母的log2形式指定。例如,对于 ADC 和 DAC,这通常是有效位数。*val是一个正常的整数分母。

  • IIO_VAL_CHAR:IIO 核心期望*val是一个字符。这个通常与IIO_CHAN_INFO_THERMOCOUPLE_TYPE掩码一起使用,在这种情况下,驱动程序必须返回热电偶的类型。

所有前述内容并不改变这一事实:在发生错误的情况下,回调必须返回一个负的错误代码,例如-EINVAL。我建议你查看drivers/iio/inkern.c源文件中iio_convert_raw_to_processed_unlocked()函数,了解最终值是如何处理的。

  • write_raw是用于将值写入设备的回调。例如,你可以用它来设置采样频率或改变量程。

设置struct iio_info结构体的示例如下:

static const struct iio_info iio_dummy_info = {
    .read_raw = &iio_dummy_read_raw,
    .write_raw = &iio_dummy_write_raw,
    [...]
};
/*
 * Provide device type specific interface functions and
 * constant data.
 */
indio_dev->info = &iio_dummy_info;

你不能将这个struct iio_info与用户空间的iio_info工具混淆,后者是libiio包的一部分。

IIO 通道的概念

在 IIO 术语中,通道表示传感器的单一采集线路。这意味着传感器能够提供/感知的每一个数据测量实体称为struct iio_chan_spec,这是表示和描述内核中单一通道的结构体,如下所示:

struct iio_chan_spec {
    enum iio_chan_type    type;
    int               channel;
    int               channel2;
    unsigned long     address;
    int               scan_index;
    struct {
        char sign;
        u8   realbits;
        u8   storagebits;
        u8   shift;
        u8   repeat;
        enum iio_endian endianness;
    } scan_type;
    long              info_mask_separate;
    long              info_mask_shared_by_type;
    long              info_mask_shared_by_dir;
    long              info_mask_shared_by_all;
    const struct iio_event_spec *event_spec;
    unsigned int      num_event_specs;
    const struct iio_chan_spec_ext_info *ext_info;
    const char        *extend_name;
    const char        *datasheet_name;
    unsigned          modified:1;
    unsigned          indexed:1;
    unsigned          output:1;
    unsigned          differential:1;
};

以下是数据结构中各元素的含义:

  • type指定通道进行的测量类型。在电压测量的情况下,它应该是IIO_VOLTAGE。对于光传感器,是IIO_LIGHT。对于加速度计,使用IIO_ACCEL。所有可用的类型在include/uapi/linux/iio/types.h中定义,作为enum iio_chan_type。要为给定的转换器编写驱动程序,你需要查看该文件,以确定每个转换器通道属于哪种类型。

  • channel.indexed被设置为1时指定通道索引。

  • channel2.modified被设置为1时指定通道修改器。

  • scan_indexscan_type 字段用于在使用缓冲触发时识别缓冲区中的元素。scan_index 设置捕获的通道在缓冲区中的位置。通道按 scan_index 从最低索引(最先放置)到最高索引排列。将 .scan_index 设置为 -1 将防止通道被缓冲捕获(在 scan_elements 目录中没有条目)。此子结构中的元素具有以下含义:

    • sign: su 指定符号(有符号(补码)或无符号)。

    • realbits: 有效数据位的数量。

    • storagebits: 此通道在缓冲区中占用的位数。也就是说,一个值可以用 12 位来编码,但它在缓冲区中占用了 16 位(存储位)。因此,数据必须右移四次才能得到实际值。此参数取决于设备,您应参考其数据手册。

    • shift: 表示在屏蔽掉未使用的位之前,数据值应该右移的次数。此参数并非总是必需的。如果有效位数等于存储位数,则移位值为 0。此参数也可以在设备数据手册中找到。

    • repeat: 实际/存储位重复的次数。

    • endianness: 表示数据的字节序。它是 enum iio_endian 类型,应该设置为 IIO_CPUIIO_LEIIO_BE 中的一个,分别表示本地 CPU 字节序、小端字节序或大端字节序。

  • modified 字段指定是否对该通道属性名称应用修改器。如果是,修改器将设置在 .channel2 中。(例如,IIO_MOD_XIIO_MOD_YIIO_MOD_Z 是针对 XYZ 轴的轴向传感器的修改器)。可用的修改器列表在内核 IIO 头文件中定义为 enum iio_modifier。修改器仅在 sysfs 中改变通道属性名称,而不改变其值。

  • indexed 指定通道属性名称是否具有索引。如果有,索引将在 .channel 字段中指定。

  • info_mask_separate 将该属性标记为特定于此通道。

  • info_mask_shared_by_type 将该属性标记为同一类型的所有通道共享。导出的信息由同一类型的所有通道共享。

  • info_mask_shared_by_dir 将该属性标记为同一方向的所有通道共享。导出的信息由同一方向的所有通道共享。

  • info_mask_shared_by_all 将该属性标记为所有通道共享,无论其类型或方向如何。导出的信息由所有通道共享。

iio_chan_spec.info_mask_* 元素是掩码,用于根据其共享信息指定暴露给用户空间的通道 sysfs 属性。因此,掩码必须通过按位或(OR)一个或多个位掩码来设置,这些位掩码都在 include/linux/iio/types.h 中定义,如下所示:

enum iio_chan_info_enum {
    IIO_CHAN_INFO_RAW = 0,
    IIO_CHAN_INFO_PROCESSED,
    IIO_CHAN_INFO_SCALE,
    IIO_CHAN_INFO_OFFSET,
    IIO_CHAN_INFO_CALIBSCALE,
    [...]
    IIO_CHAN_INFO_SAMP_FREQ,
    IIO_CHAN_INFO_FREQUENCY,
    IIO_CHAN_INFO_PHASE,
    IIO_CHAN_INFO_HARDWAREGAIN,
    IIO_CHAN_INFO_HYSTERESIS,
    [...]
};

以下是指定给定通道的掩码的示例:

iio_chan->info_mask_separate = BIT(IIO_CHAN_INFO_RAW) |
                    BIT(IIO_CHAN_INFO_PROCESSED);

这意味着原始和处理过的属性是特定于通道的。

注意

虽然在前面的 struct iio_chan_spec 结构描述中没有明确指定,术语 属性 指的是 sysfs 属性。这适用于整章内容。

通过描述通道数据结构,我们来解开通道属性命名的谜团,它遵循特定的约定。

通道属性命名约定

属性的名称由 IIO 核心根据预定义模式自动生成,模式为 {direction}_{type}{index}_{modifier}_{info_mask}。以下是模式中每个字段的描述:

  • {direction} 对应于属性方向,依据 drivers/iio/industrialio-core.c 中的 struct iio_direction 结构:

    static const char * const iio_direction[] = {
        [0] = "in",
        [1] = "out",
    };
    

请注意,输入通道是可以生成样本的通道(此类通道在读取方法中处理,例如 ADC 通道)。另一方面,输出通道是可以接收样本的通道(此类通道在写入方法中处理,例如 DAC 通道)。

  • {type} 对应于通道类型字符串,依据 drivers/iio/industrialio-core.c 中的常量 iio_chan_type_name_spec 字符数组(由 enum iio_chan_type 类型的通道类型索引)定义,如下所示:

    static const char * const iio_chan_type_name_spec[] = {
        [IIO_VOLTAGE] = "voltage",
        [IIO_CURRENT] = "current",
        [IIO_POWER] = "power",
        [IIO_ACCEL] = "accel",
        [...]
        [IIO_UVINDEX] = "uvindex",
        [IIO_ELECTRICALCONDUCTIVITY] =
                      "electricalconductivity",
        [IIO_COUNT] = "count",
        [IIO_INDEX] = "index",
        [IIO_GRAVITY] = "gravity",
    };
    
  • {index} 取决于通道 .indexed 字段是否被设置。如果已设置,索引将从 .channel 字段中获取,并用来替代 {index} 模式。

  • {modifier} 模式取决于通道 .modified 字段是否被设置。如果已设置,修饰符将从 .channel2 字段中获取,且模式中的 {modifier} 字段将根据 char 数组 struct iio_modifier_names 结构进行替换:

    static const char * const iio_modifier_names[] = {
        [IIO_MOD_X] = "x",
        [IIO_MOD_Y] = "y",
        [IIO_MOD_Z] = "z",
        [IIO_MOD_X_AND_Y] = "x&y",
        [IIO_MOD_X_AND_Z] = "x&z",
        [IIO_MOD_Y_AND_Z] = "y&z",
        [...]
        [IIO_MOD_CO2] = "co2",
        [IIO_MOD_VOC] = "voc",
    };
    
  • {info_mask} 取决于通道信息掩码,私有或共享,索引 iio_chan_info_postfix 字符数组中的值,定义如下:

    /* relies on pairs of these shared then separate */
    static const char * const iio_chan_info_postfix[] = {
         [IIO_CHAN_INFO_RAW] = "raw",
         [IIO_CHAN_INFO_PROCESSED] = "input",
         [IIO_CHAN_INFO_SCALE] = "scale",
         [IIO_CHAN_INFO_CALIBBIAS] = "calibbias",
         [...]
         [IIO_CHAN_INFO_SAMP_FREQ] = "sampling_frequency",
         [IIO_CHAN_INFO_FREQUENCY] = "frequency",
         [...]
    };
    

通道命名约定现在应该没有更多的疑问了。既然我们已经熟悉了命名规则,接下来让我们学习如何准确地识别通道。

注意

在这个命名模式中,如果某个元素不存在,那么直接前面的下划线将被省略。例如,如果未指定修饰符,则模式将变为 {direction}_{type}{index}_{info_mask},而不是 {direction}_{type}{index}__{info_mask}

区分通道

当存在多个相同类型的数据通道时,你可能会遇到一些困难。这个难题是 如何精确识别每个通道。对此有两种解决方案:索引修饰符

使用索引进行通道标识

给定一个具有单个通道线的 ADC 设备,不需要索引。其通道定义如下:

static const struct iio_chan_spec adc_channels[] = {
        {
             .type = IIO_VOLTAGE,
             .info_mask_separate = BIT(IIO_CHAN_INFO_RAW),
        },
}

根据前面的摘录,属性名称将是 in_voltage_raw,其绝对 sysfs 路径将是 /sys/bus/iio/iio:deviceX/in_voltage_raw

假设 ADC 有四个甚至八个通道。我们该如何识别每个通道?解决方案是使用索引。将 .indexed 字段设置为 1,可以通过 .channel 值修改通道属性名称,替换命名模式中的 {index}

static const struct iio_chan_spec adc_channels[] = {
    {
        .type = IIO_VOLTAGE,
        .indexed = 1,
        .channel = 0,
        .info_mask_separate = BIT(IIO_CHAN_INFO_RAW),
    },
    {
        .type = IIO_VOLTAGE,
        .indexed = 1,
        .channel = 1,
        .info_mask_separate = BIT(IIO_CHAN_INFO_RAW),
    },
    {
        .type = IIO_VOLTAGE,
        .indexed = 1,
        .channel = 2,
        .info_mask_separate = BIT(IIO_CHAN_INFO_RAW),
    },
    {
        .type = IIO_VOLTAGE,
        .indexed = 1,
        .channel = 3,
        .info_mask_separate = BIT(IIO_CHAN_INFO_RAW),
    },
}

以下是生成的通道属性的完整 sysfs 路径:

/sys/bus/iio/iio:deviceX/in_voltage0_raw
/sys/bus/iio/iio:deviceX/in_voltage1_raw
/sys/bus/iio/iio:deviceX/in_voltage2_raw
/sys/bus/iio/iio:deviceX/in_voltage3_raw

如我们所见,即使它们都是同类型的,它们也通过索引区分开来。

使用修饰符进行通道识别

为了突出修饰符的概念,假设有一个带有两个通道的光传感器——一个用于红外光,另一个同时用于红外光和可见光。如果没有索引或修饰符,属性名称会是 in_intensity_raw。在这里使用索引可能会容易出错,因为如果有 in_intensity0_ir_rawin_intensity1_ir_raw,它们看起来像是同类型的通道,这毫无意义。使用修饰符可以帮助我们获得有意义的属性名称。通道定义可以如下所示:

static const struct iio_chan_spec mylight_channels[] = {
    {
        .type = IIO_INTENSITY,
        .modified = 1,
        .channel2 = IIO_MOD_LIGHT_IR,
        .info_mask_separate = BIT(IIO_CHAN_INFO_RAW),
        .info_mask_shared = BIT(IIO_CHAN_INFO_SAMP_FREQ),
    },
    {
        .type = IIO_INTENSITY,
        .modified = 1,
        .channel2 = IIO_MOD_LIGHT_BOTH,
        .info_mask_separate = BIT(IIO_CHAN_INFO_RAW),
        .info_mask_shared = BIT(IIO_CHAN_INFO_SAMP_FREQ),
    },
    {
        .type = IIO_LIGHT,
        .info_mask_separate = BIT(IIO_CHAN_INFO_PROCESSED),
        .info_mask_shared = BIT(IIO_CHAN_INFO_SAMP_FREQ),
    },
}

结果生成的属性如下:

  • /sys/bus/iio/iio:deviceX/in_intensity_ir_raw 用于测量红外强度的通道

  • /sys/bus/iio/iio:deviceX/in_intensity_both_raw 用于同时测量红外和可见光的通道

  • /sys/bus/iio/iio:deviceX/in_illuminance_input 用于处理后的数据

  • /sys/bus/iio/iio:deviceX/sampling_frequency 用于采样频率,所有设备共享

这一点在加速度计中同样适用,我们将在后续的案例研究中看到。现在,让我们通过实现一个虚拟的 IIO 驱动来总结到目前为止讨论的内容。

将所有内容整合起来——编写一个虚拟的 IIO 驱动

让我们通过一个简单的虚拟驱动程序来总结到目前为止的内容,该驱动将暴露四个电压通道。暂时我们不关心 read()write() 函数。

首先,让我们定义开发过程中需要的头文件:

#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/platform_device.h>
#include <linux/interrupt.h>
#include <linux/of.h>
#include <linux/iio/iio.h>

然后,由于通道描述是一个通用且重复的操作,我们可以定义一个宏,来为我们填充通道描述,如下所示:

#define FAKE_VOLTAGE_CHANNEL(num)                \
  {                                              \
     .type = IIO_VOLTAGE,                        \
     .indexed = 1,                               \
     .channel = (num),                           \
     .address = (num),                           \
     .info_mask_separate = BIT(IIO_CHAN_INFO_RAW),        \
     .info_mask_shared_by_type = BIT(IIO_CHAN_INFO_SCALE) \
  }

在定义了通道填充宏之后,我们可以定义我们的驱动状态数据结构,如下所示:

struct my_private_data {
    int foo;
    int bar;
    struct mutex lock;
};

之前定义的数据结构是没有用的。它仅仅是用来展示这个概念。接着,因为我们在这个虚拟驱动程序示例中不需要读取或写入操作,让我们创建空的读取和写入函数,这些函数只返回 0(表示一切正常):

static int fake_read_raw(struct iio_dev *indio_dev,
    struct iio_chan_spec const *channel, int *val,
    int *val2, long mask)
{
    return 0;
}
static int fake_write_raw(struct iio_dev *indio_dev,
                     struct iio_chan_spec const *chan,
                     int val, int val2, long mask)
{
    return 0;
}

现在我们可以使用之前定义的宏来声明我们的 IIO 通道。此外,我们还可以按如下方式设置 iio_info 数据结构,并在同一时间分配虚拟的读取和写入操作:

static const struct iio_chan_spec fake_channels[] = {
     FAKE_VOLTAGE_CHANNEL(0),
     FAKE_VOLTAGE_CHANNEL(1),
     FAKE_VOLTAGE_CHANNEL(2),
     FAKE_VOLTAGE_CHANNEL(3),
};
static const struct iio_info fake_iio_info = {
     .read_raw  = fake_read_raw,
     .write_raw = fake_write_raw,
     .driver_module = THIS_MODULE,
};

现在,所有必要的 IIO 数据结构已经设置完毕,我们可以切换到与平台驱动相关的数据结构并实现其方法,如下所示:

static const struct of_device_id iio_dummy_ids[] = {
    { .compatible = "packt,iio-dummy-random", },
    { /* sentinel */ }
};
static int my_pdrv_probe (struct platform_device *pdev)
{
     struct iio_dev *indio_dev;
     struct my_private_data *data;
     indio_dev = devm_iio_device_alloc(&pdev->dev,
                                      sizeof(*data));
     if (!indio_dev) {
        dev_err(&pdev->dev, "iio allocation failed!\n");
        return -ENOMEM;
     }
    data = iio_priv(indio_dev);
    mutex_init(&data->lock);
    indio_dev->dev.parent = &pdev->dev;
    indio_dev->info = &fake_iio_info;
    indio_dev->name = KBUILD_MODNAME;
    indio_dev->modes = INDIO_DIRECT_MODE;
    indio_dev->channels = fake_channels;
    indio_dev->num_channels = ARRAY_SIZE(fake_channels);
    indio_dev->available_scan_masks = 0xF;
    devm_iio_device_register(&pdev->dev, indio_dev);
    platform_set_drvdata(pdev, indio_dev);
    return 0;
}

在前述的探测方法中,我们仅使用了资源管理的 API 进行分配和注册。这大大简化了代码,并且去除了驱动程序的 remove 方法。驱动程序的声明和注册将如下所示:

static struct platform_driver my_iio_pdrv = {
    .probe      = my_pdrv_probe,
    .driver     = {
        .name     = "iio-dummy-random",
        .of_match_table = of_match_ptr(iio_dummy_ids),  
        .owner    = THIS_MODULE,
    },
};
module_platform_driver(my_iio_pdrv);
MODULE_AUTHOR("John Madieu <john.madieu@labcsmart.com>");
MODULE_LICENSE("GPL");

加载上述模块后,在系统中列出可用的 IIO 设备时,您将看到以下输出:

~# ls -l /sys/bus/iio/devices/
lrwxrwxrwx    1 root     root             0 Jul 31 20:26 iio:device0 -> ../../../devices/platform/iio-dummy-random.0/iio:device0
lrwxrwxrwx    1 root     root             0 Jul 31 20:23 iio_sysfs_trigger -> ../../../devices/iio_sysfs_trigger

~# ls /sys/bus/iio/devices/iio\:device0/
dev                              in_voltage2_raw        name                uevent
in_voltage0_raw        in_voltage3_raw        power
in_voltage1_raw        in_voltage_scale        subsystem
~# cat /sys/bus/iio/devices/iio:device0/name 
iio_dummy_random

注意

一个非常完整的 IIO 驱动程序,可以用于学习目的或开发模型,是位于 drivers/iio/dummy/iio_simple_dummy.c 的 IIO 简单虚拟驱动程序。通过启用 IIO_SIMPLE_DUMMY 内核配置选项,可以在目标上使其可用。

现在我们已经介绍了基本的 IIO 概念,接下来我们可以进一步实现缓冲区支持和触发器概念。

集成 IIO 触发的缓冲区支持

在数据采集应用程序中,能够基于一些外部信号或事件(触发器)捕获数据可能会很有用。这些触发器可能包括以下几种:

  • 一个数据就绪信号

  • 一个连接到某个外部系统(如 GPIO 或其他)的 IRQ 线路

  • 在处理器的周期性中断(例如定时器)

  • 用户空间读取/写入 sysfs 中的特定文件

IIO 设备驱动程序与触发器完全解耦,触发器的驱动程序实现位于 drivers/iio/trigger/ 中。触发器可以初始化一个或多个设备的数据采集。这些触发器用于填充缓冲区,并通过在 IIO 设备注册期间创建的字符设备暴露给用户空间。

您可以开发自己的触发器驱动程序,但这超出了本书的范围。我们将专注于现有的触发器。这些触发器如下:

  • iio-trig-interrupt:这允许使用 IRQ 作为 IIO 触发器。在旧版本的内核中(v3.11 之前),它曾经是 iio-trig-gpio。要支持此触发模式,您应在内核配置中启用 CONFIG_IIO_INTERRUPT_TRIGGER。如果作为模块构建,则模块将命名为 iio-trig-interrupt

  • iio-trig-hrtimer:提供一个基于频率的 IIO 触发器,使用高分辨率定时器作为中断源(自内核 v4.5 起)。在旧版本的内核中,它曾经是 iio-trig-rtc。要在内核中支持此触发模式,必须启用 IIO_HRTIMER_TRIGGER 配置选项。如果作为模块构建,则模块将命名为 iio-trig-hrtimer

  • iio-trig-sysfs:这允许我们使用 SYSFS 条目来触发数据采集。CONFIG_IIO_SYSFS_TRIGGER 是内核选项,用于支持此触发模式。

  • iio-trig-bfin-timer:这使我们能够使用 Blackfin 定时器作为 IIO 触发器(仍处于阶段中)。

IIO 提供了一个 API,允许我们执行以下操作:

  • 声明任何数量的触发器。

  • 选择哪些通道将其数据推送到缓冲区中。

如果你的 IIO 设备支持触发器缓冲区,你必须设置iio_dev.pollfunc,它会在触发器触发时执行。这个处理程序的职责是通过indio_dev->active_scan_mask查找启用的通道,获取它们的数据,并通过iio_push_to_buffers_with_timestamp函数将数据推送到indio_dev->buffer中。因此,在 IIO 子系统中,缓冲区和触发器是紧密关联的。

IIO 核心提供了一组辅助函数,用于设置触发器缓冲区,你可以在drivers/iio/industrialio-triggered-buffer.c中找到它们。以下是支持触发器缓冲区的步骤:

  1. 如果需要,填写iio_buffer_setup_ops结构:

    const struct iio_buffer_setup_ops sensor_buffer_setup_ops = {
      .preenable    = my_sensor_buffer_preenable,
      .postenable   = my_sensor_buffer_postenable,
      .postdisable  = my_sensor_buffer_postdisable,
      .predisable   = my_sensor_buffer_predisable,
    };
    
  2. 编写与触发器相关的上半部分。在 99%的情况下,你只需要提供与捕获相关的时间戳:

    irqreturn_t sensor_iio_pollfunc(int irq, void *p)
    {
        pf->timestamp = iio_get_time_ns(
                           (struct indio_dev *)p);
        return IRQ_WAKE_THREAD;
    }
    

然后我们返回一个特殊值,以便内核知道它必须调度底半部分,这将在一个线程上下文中运行。

  1. 编写触发器的底半部分,它将从每个启用的通道获取数据并将其推送到缓冲区:

    irqreturn_t sensor_trigger_handler(int irq, void *p)
    {
        u16 buf[8];
        int bit, i = 0;
        struct iio_poll_func *pf = p;
        struct iio_dev *indio_dev = pf->indio_dev;
        /* one can use lock here to protect the buffer */
        /* mutex_lock(&my_mutex); */
        /* read data for each active channel */
        for_each_set_bit(bit, indio_dev->active_scan_mask,
                         indio_dev->masklength)
            buf[i++] = sensor_get_data(bit);
        /*
         * If iio_dev.scan_timestamp = true, the capture 
         * timestamp will be pushed and stored too, 
         * as the last element in the sample data buffer 
         * before pushing it to the device buffers.
         */
        iio_push_to_buffers_with_timestamp(indio_dev, buf,
                                            timestamp);
        /* Please unlock any lock */
        /* mutex_unlock(&my_mutex); */
        /* Notify trigger */
        iio_trigger_notify_done(indio_dev->trig);
        return IRQ_HANDLED;
    }
    
  2. 最后,在探测函数中,你必须在注册设备之前设置缓冲区本身:

    iio_triggered_buffer_setup(
        indio_dev, sensor_iio_pollfunc,
        sensor_trigger_handler,
        sensor_buffer_setup_ops);
    

这里的关键函数是iio_triggered_buffer_setup()。它还会将INDIO_BUFFER_TRIGGERED能力授予设备,意味着可以使用轮询环形缓冲区。

当触发器(来自用户空间)被分配给设备时,驱动程序无法知道捕获何时被触发。这就是为什么,在连续缓冲捕获活动时,你应该阻止(通过返回错误)驱动程序处理 sysfs 每通道的数据捕获(由read_raw()钩子执行),以避免不确定的行为,因为触发器处理程序和read_raw()钩子会同时尝试访问设备。用于检查当前是否启用了缓冲模式的函数是iio_buffer_enabled()。该钩子如下所示:

static int my_read_raw(struct iio_dev *indio_dev,
               const struct iio_chan_spec *chan,
               int *val, int *val2, long mask)
{
    [...]
    switch (mask) {
    case IIO_CHAN_INFO_RAW:
        if (iio_buffer_enabled(indio_dev))
            return -EBUSY;
    [...]    
}

iio_buffer_enabled()函数只是测试设备当前模式是否与 IIO 缓冲区模式之一相对应。该函数在include/linux/iio/iio.h中定义如下:

static bool iio_buffer_enabled(struct iio_dev *indio_dev)
{
   return indio_dev->currentmode
       & (INDIO_BUFFER_TRIGGERED | INDIO_BUFFER_HARDWARE |
           INDIO_BUFFER_SOFTWARE);
} 

现在,让我们描述一下在前面的代码中使用的一些重要内容:

  • iio_buffer_setup_ops提供了在缓冲区配置过程中固定步骤时调用的缓冲区设置函数(启用/禁用前后)。如果没有指定,IIO 核心将为你的设备提供默认的iio_triggered_buffer_setup_ops

  • sensor_iio_pollfunc是触发器的上半部分。像所有上半部分一样,它在中断上下文中运行,并且必须尽量减少处理工作。99%的情况下,记录与捕获相关的时间戳就足够了。你仍然可以使用默认的 IIO 函数iio_pollfunc_store_time()

  • sensor_trigger_handler 是下半部分,运行在内核线程中,允许你进行任何处理,甚至获取互斥锁或休眠。重的处理工作应该在这里进行。这里的大部分工作包括从设备读取数据,并将这些数据与在上半部分记录的时间戳一起存储到内部缓冲区中,并将其推送到 IIO 设备缓冲区。

    注意

    触发缓冲区涉及一个触发器。它告诉驱动程序何时从设备读取样本并将其放入缓冲区。触发缓冲区并不是编写 IIO 设备驱动程序的必需条件。你也可以通过读取通道的原始属性来使用单次捕获,这将只执行单次转换(对于被读取的通道属性)。缓冲模式允许连续转换,从而在一次捕获中捕捉多个通道。

现在我们已经熟悉了触发缓冲区在内核中的所有方面,让我们介绍如何使用 sysfs 接口在用户空间进行设置。

IIO 触发器和 sysfs(用户空间)

在运行时,有两个 sysfs 目录可以管理触发器:

  • /sys/bus/iio/devices/trigger<Y>/:一旦 IIO 触发器在 IIO 核心中注册,这个目录将会创建。在此路径中,<Y> 对应于具有索引的触发器。该目录中至少有一个 name 属性,这是触发器的名称,之后可以用于与设备关联。

  • /sys/bus/iio/devices/iio:deviceX/trigger/*:如果你的设备支持触发缓冲区,此目录将自动创建。通过在此目录中的 current_trigger 文件中写入触发器的名称,可以将触发器与我们的设备关联。

在枚举了与触发器相关的 sysfs 目录之后,让我们开始描述 sysfs 触发器接口是如何工作的。

sysfs 触发器接口

在内核中通过 CONFIG_IIO_SYSFS_TRIGGER=y 配置选项启用 sysfs 触发器,这将自动创建 /sys/bus/iio/devices/iio_sysfs_trigger/ 文件夹,该文件夹可用于 sysfs 触发器管理。该目录中将有两个文件,add_triggerremove_trigger。其驱动程序是 drivers/iio/trigger/iio-trig-sysfs.c。以下是这些属性的描述:

  • add_trigger:用于创建一个新的 sysfs 触发器。你可以通过将一个正值(将用作触发器 ID)写入该文件来创建一个新的触发器。它会创建一个新的 sysfs 触发器,可以在/sys/bus/iio/devices/triggerX访问,其中X是触发器编号。例如,echo 2 > add_trigger将创建一个新的 sysfs 触发器,可以在/sys/bus/iio/devices/trigger2访问。如果系统中已经存在一个带有提供 ID 的触发器,将返回无效参数的消息。sysfs 触发器的名称模式为sysfstrig{ID}echo 2 > add_trigger命令将创建/sys/bus/iio/devices/trigger2触发器,其名称为sysfstrig2,你可以通过cat /sys/bus/iio/devices/trigger2/name来检查它。每个 sysfs 触发器至少包含一个文件:trigger_now。将1写入该文件将指示所有设备,若其current_trigger中有相应的触发器名称,则开始捕获并将数据推送到各自的缓冲区。每个设备的缓冲区必须设置大小并启用(echo 1 > /sys/bus/iio/devices/iio:deviceX/buffer/enable)。

  • remove_trigger:用于删除触发器。以下命令足以删除之前创建的触发器:

    echo 2 > remove_trigger
    

如你所见,在创建触发器时使用的add_trigger中的值必须与删除触发器时使用的值相同。

注意

你应该注意,驱动程序只有在关联的触发器被触发时才会捕获数据。因此,使用 sysfs 触发器时,数据只会在trigger_now属性中写入1时被捕获。因此,要实现连续数据捕获,你应该在一个循环中根据需要的样本数多次运行echo 1 > trigger_now,例如。因为单次执行echo 1 > trigger_now相当于一次触发,因此只会执行一次捕获,并将其推送到缓冲区。对于基于中断的触发器,数据会在每次发生中断时被捕获并推送到缓冲区。

现在我们完成了触发器的设置,接下来必须将该触发器分配给设备,以便它可以在此设备上触发数据捕获,正如我们将在下一部分看到的那样。

将设备与触发器绑定

将设备与给定触发器关联的过程是将触发器的名称写入设备触发器目录下的current_trigger文件。例如,假设我们需要将设备与索引为2的触发器绑定:

# set trigger2 as current trigger for device0
echo sysfstrig2 > /sys/bus/iio/devices/iio:device0/trigger/current_trigger

要从设备中分离触发器,应将空字符串写入设备触发器目录下的current_trigger文件,如下所示:

echo "" > iio:device0/trigger/current_trigger

在本章后面的部分(使用 sysfs 触发器捕获数据部分),我们将看到一个实际的例子,涉及用于数据捕获的 sysfs 触发器。

中断触发器接口

假设我们有以下样本:

static struct resource iio_irq_trigger_resources[] = {
    [0] = {
        .start = IRQ_NR_FOR_YOUR_IRQ,
        .flags = IORESOURCE_IRQ | IORESOURCE_IRQ_LOWEDGE,
    },
};
static struct platform_device iio_irq_trigger = {
    .name = "iio_interrupt_trigger",
    .num_resources = ARRAY_SIZE(iio_irq_trigger_resources),
    .resource = iio_irq_trigger_resources,
};
platform_device_register(&iio_irq_trigger);

在此示例中,我们声明我们的 IRQ-(使用 request_irq() 注册的 IIO 中断触发器)作为平台设备。它将导致 IRQ 触发器独立模块(源文件为 drivers/iio/trigger/iio-trig-interrupt.c)被加载。探测成功后,将有一个与触发器相对应的目录。IRQ 触发器的名称采用 irqtrigX 形式,其中 X 对应于刚刚传递的 IRQ。此名称是您将在 /proc/interrupt 中看到的名称:

$ cd /sys/bus/iio/devices/trigger0/
$ cat name
    irqtrig85

就像我们对其他触发器所做的那样,您只需将该触发器分配给您的设备,将其名称写入设备的 current_trigger 文件中:

echo "irqtrig85" > /sys/bus/iio/devices/iio:device0/trigger/current_trigger

现在,每当中断触发时,将捕获设备数据。

IRQ 触发器驱动程序实现在 drivers/iio/trigger/iio-trig-interrupt.c 中。由于驱动程序需要资源,我们可以使用设备树进行配置,无需进行任何代码更改,唯一的条件是遵守 compatible 属性,如下所示:

mylabel: my_trigger@0{
    compatible = "iio_interrupt_trigger";
    interrupt-parent = <&gpio4>;
    interrupts = <30 0x0>;
};

本示例假设 IRQ 线为 GPIO#30,属于 gpio4 GPIO 控制器节点。这包括使用 GPIO 作为中断源,因此每当 GPIO 变化到给定状态时,都会引发中断,从而触发捕获。

hrtimer 触发器接口

hrtimer trigger 实现在 drivers/iio/trigger/iio-trig-hrtimer.c 中,并依赖于 configfs 文件系统(请参阅内核源中的 Documentation/iio/iio_configfs.txt),可以通过 CONFIG_IIO_CONFIGFS 配置选项启用,并安装在我们的系统上(通常位于 /config 目录下):

$ mkdir /config
$ mount -t configfs none /config

现在,加载 iio-trig-hrtimer 模块将创建可在 /config/iio 下访问的 IIO 组,允许用户在 /config/iio/triggers/hrtimer 下创建 hrtimer 触发器。以下是一个示例:

# create a hrtimer trigger
$ mkdir /config/iio/triggers/hrtimer/my_trigger_name
# remove the trigger
$ rmdir /config/iio/triggers/hrtimer/my_trigger_name

每个 hrtimer 触发器在触发器目录中包含一个 sampling_frequency 属性。本章稍后的 使用 hrtimer 触发器进行数据捕获 部分提供了一个完整且可工作的示例。

IIO 缓冲区

IIO 缓冲区提供连续数据捕获,允许同时读取多个数据通道。该缓冲区可通过用户空间中的 /dev/iio:device 字符设备节点访问。在触发器处理程序内部,用于填充缓冲区的函数是 iio_push_to_buffers_with_timestamp()。为了为设备分配和设置触发器缓冲区,驱动程序必须使用 iio_triggered_buffer_setup()

IIO 缓冲区 sysfs 接口

IIO 缓冲区有一个关联的属性目录,位于 /sys/bus/iio/iio:deviceX/buffer/* 下。以下是一些现有的属性:

  • length: 缓冲区的容量。它代表可以由缓冲区存储的总数据样本数。它是缓冲区包含的扫描次数。

  • enable: 激活缓冲区捕获并启动缓冲区捕获。

  • watermark:这个属性自内核版本 v4.2 起就已可用。它是一个正数,指定阻塞读取应等待多少个扫描元素。例如,使用 poll() 系统调用时,它会阻塞,直到达到水印值。只有当水印大于请求的读取数量时,这个参数才有意义。它不会影响非阻塞读取。通过在 poll() 上阻塞并设置超时,你可以获得最大延迟保证,并在超时后读取可用的样本。

既然我们已经列举并描述了 IIO 缓冲区目录中存在的属性,接下来让我们讨论如何设置 IIO 缓冲区。

IIO 缓冲区设置

需要读取数据并将其推送到缓冲区的通道称为 /sys/bus/iio/iio:deviceX/scan_elements/* 目录,包含以下属性:

  • *_en:这是属性名的后缀,用于启用该通道。只有当其属性值非零时,触发的捕获才会包含该通道的数据样本。例如,in_voltage0_enin_voltage1_en 是启用 in_voltage0in_voltage1 的属性。因此,如果 in_voltage1_en 的值非零,那么触发捕获时底层 IIO 设备的输出将包括 in_voltage1 通道的值。

  • type:描述了缓冲区内扫描元素数据的存储方式,以及它是如何从用户空间读取的。例如,in_voltage0_type 是一个通道类型的示例。格式遵循以下模式:[be|le]:[s|u]bits/storagebitsXrepeat[>>shift]。以下是每个字段的含义:

    • bele 指定字节序(大端或小端)。

    • su 指定符号,表示有符号(补码)或无符号。

    • bits 是有效数据位的数量。

    • storagebits 是该通道在缓冲区中占用的位数。也就是说,一个值可能实际是用 12 位(bits)编码的,但在缓冲区中占用 16 位(storagebits)。因此,你需要将数据右移四次以获得实际值。这个参数取决于设备,具体情况请参考设备的数据手册。

    • shift 表示在屏蔽掉未使用的位之前,数据值需要右移的次数。这个参数并非总是需要的。如果有效位数等于存储位数,则 shift 为 0。你也可以在设备的数据手册中找到这个参数。

    • repeat 元素指定 bits/storagebits 重复的次数。当 repeat 元素为 01 时,重复值会被省略。

最好的解释这个部分的方法是提供内核文档的摘录,你可以在这里找到:www.kernel.org/doc/html/latest/driver-api/iio/buffers.html。让我们考虑一个 12 位分辨率的三轴加速度计驱动程序,其中数据存储在两个 8 位(即 16 位)寄存器中,如下所示:

   7   6   5   4   3   2   1   0
 +---+---+---+---+---+---+---+---+
 |D3 |D2 |D1 |D0 | X | X | X | X | (LOW byte, address 0x06)
 +---+---+---+---+---+---+---+---+
   7   6   5   4   3   2   1   0
 +---+---+---+---+---+---+---+---+
 |D11|D10|D9 |D8 |D7 |D6 |D5 |D4 |(HIGH byte, address 0x07)
 +---+---+---+---+---+---+---+---+

根据前述描述,每个轴将具有以下扫描元素:

$ cat  /sys/bus/iio/devices/iio:device0/scan_elements/in_accel_y_type
le:s12/16>>4

你应该将其理解为小端有符号数据,大小为 16 位,需要右移 4 位,然后掩码出 12 位有效数据。

struct iio_chan_spec中负责确定如何将通道值存储到缓冲区的元素是scant_type

struct iio_chan_spec {
    [...]
    struct {
        char sign; /* either u or s as explained above */
        u8 realbits;
        u8 storagebits;
        u8 shift;
        u8 repeat;
        enum iio_endian endianness;
    } scan_type;
    [...]
};

该结构完全匹配[be|le]:[s|u]bits/storagebitsXrepeat[>>shift],这是前面描述的模式。让我们看看结构的每个部分:

  • sign表示数据的符号,并与模式中的[s|u]匹配。

  • realbits对应于模式中的bits

  • storagebits与模式中的storagebits匹配。

  • shift对应于模式中的shift,以及repeat

  • iio_indian表示字节序,并与模式中的[be|le]匹配。

此时,我们应该能够实现与前面解释的类型对应的 IIO 通道结构:

struct struct iio_chan_spec accel_channels[] = {
    {
        .type = IIO_ACCEL,
        .modified = 1,
        .channel2 = IIO_MOD_X,
        /* other stuff here */
        .scan_index = 0,
        .scan_type = {
            .sign = 's',
            .realbits = 12,
            .storagebits = 16,
            .shift = 4,
            .endianness = IIO_LE,
            },
    }
    /* similar for Y (with channel2 = IIO_MOD_Y, 
     * scan_index = 1) and Z (with channel2 
     * = IIO_MOD_Z, scan_index = 2) axis 
     */
}

缓冲区和触发支持是我们学习 IIO 框架的最后一个概念。现在我们已经熟悉了这些,我们可以将所有内容整合在一起,并通过一个具体的简洁示例总结我们所学到的知识。

将它们结合在一起

让我们仔细看看来自博世的 BMA220 数字三轴加速度传感器。这是一款兼容 SPI/I2C 的设备,具有 8 位大小的寄存器,并且配备了一个片上运动触发中断控制器,用于感应倾斜、运动和冲击振动。它的技术资料可以在这里找到:www.mouser.fr/pdfdocs/BSTBMA220DS00308.PDF。由于CONFIG_BMA200内核配置选项的支持,它的驱动程序也可以使用。我们一起来看一下。

我们首先使用struct iio_chan_spec声明通道。如果将使用触发缓冲区,则需要填写scan_indexscan_type字段。以下代码片段展示了我们声明的通道:

#define BMA220_DATA_SHIFT        2
#define BMA220_DEVICE_NAME       "bma220"
#define BMA220_SCALE_AVAILABLE   "0.623 1.248 2.491 4.983"
#define BMA220_ACCEL_CHANNEL(index, reg, axis) {    \ 
    .type = IIO_ACCEL,                            \
    .address = reg,                               \
    .modified = 1,                                \
    .channel2 = IIO_MOD_##axis,                   \
    .info_mask_separate = BIT(IIO_CHAN_INFO_RAW), \
    .info_mask_shared_by_type = BIT(IIO_CHAN_INFO_SCALE),\
    .scan_index = index,                          \
    .scan_type = {                                \
         .sign = 's',                             \
         .realbits = 6,                           \
         .storagebits = 8,                        \
         .shift = BMA220_DATA_SHIFT,              \
         .endianness = IIO_CPU,                   \
    },                                            \
}
static const struct iio_chan_spec bma220_channels[] = {
    BMA220_ACCEL_CHANNEL(0, BMA220_REG_ACCEL_X, X),
    BMA220_ACCEL_CHANNEL(1, BMA220_REG_ACCEL_Y, Y),
    BMA220_ACCEL_CHANNEL(2, BMA220_REG_ACCEL_Z, Z),
};

.info_mask_separate = BIT(IIO_CHAN_INFO_RAW)表示每个通道将有一个*_raw的 sysfs 条目(属性),而.info_mask_shared_by_type = BIT(IIO_CHAN_INFO_SCALE)表示所有同类型的通道只有一个*_scale的 sysfs 条目:

jma@jma:~$ ls -l /sys/bus/iio/devices/iio:device0/
(...)
# without modifier, a channel name would have in_accel_raw (bad)
-rw-r--r-- 1 root root 4096 jul 20 14:13 in_accel_scale
-rw-r--r-- 1 root root 4096 jul 20 14:13 in_accel_x_raw
-rw-r--r-- 1 root root 4096 jul 20 14:13 in_accel_y_raw
-rw-r--r-- 1 root root 4096 jul 20 14:13 in_accel_z_raw
(...)

读取in_accel_scale时调用read_raw()钩子,并将掩码设置为IIO_CHAN_INFO_SCALE。读取in_accel_x_raw时调用read_raw()钩子,并将掩码设置为IIO_CHAN_INFO_RAW。然后,真实值为raw_value x scale

.scan_type 表示每个通道的返回值是带符号的,大小为 8 位(在缓冲区中占 8 位),但有效载荷只占 6 位,数据必须先右移两位,然后才能去除未使用的位。任何扫描元素类型将如下所示:

$ cat /sys/bus/iio/devices/iio:device0/scan_elements/in_accel_x_type
le:s6/8>>2

以下是我们的 pullfunc(实际上是底半部),它从设备读取一个样本并将读取的值推送到缓冲区(iio_push_to_buffers_with_timestamp())。完成后,我们通知核心(iio_trigger_notify_done()):

static irqreturn_t bma220_trigger_handler(int irq, void *p)
{
    int ret;
    struct iio_poll_func *pf = p;
    struct iio_dev *indio_dev = pf->indio_dev;
    struct bma220_data *data = iio_priv(indio_dev);
    struct spi_device *spi = data->spi_device;
    mutex_lock(&data->lock);
    data->tx_buf[0] =
                BMA220_REG_ACCEL_X | BMA220_READ_MASK;
    ret = spi_write_then_read(spi, data->tx_buf,
                       1, data->buffer,
             ARRAY_SIZE(bma220_channels) - 1);
    if (ret < 0)
         goto err;
    iio_push_to_buffers_with_timestamp(indio_dev,
                           data->buffer, pf->timestamp);
err:
    mutex_unlock(&data->lock);
    iio_trigger_notify_done(indio_dev->trig);
    return IRQ_HANDLED;
}

以下是读取功能。它是每次读取设备的 sysfs 条目时调用的钩子:

static int bma220_read_raw(struct iio_dev *indio_dev,
            struct iio_chan_spec const *chan,
            int *val, int *val2, long mask)
{
    int ret;
    u8 range_idx;
    struct bma220_data *data = iio_priv(indio_dev);
    switch (mask) {
     case IIO_CHAN_INFO_RAW:
           /* do not process single-channel read 
            * if buffer mode is enabled
            */
           if (iio_buffer_enabled(indio_dev))
                  return -EBUSY;
           /* Else we read the channel */
            ret = bma220_read_reg(data->spi_device,
                                    chan->address);
            if (ret < 0)
                     return -EINVAL;
             *val = sign_extend32(ret >> BMA220_DATA_SHIFT, 
                                   5);
             return IIO_VAL_INT;
     case IIO_CHAN_INFO_SCALE:
             ret = bma220_read_reg(data->spi_device,
                                    BMA220_REG_RANGE);
            if (ret < 0)
                     return ret;
             range_idx = ret & BMA220_RANGE_MASK;
             *val = bma220_scale_table[range_idx][0];
             *val2 = bma220_scale_table[range_idx][1];
             return IIO_VAL_INT_PLUS_MICRO;
    }
    return -EINVAL;
}

当你读取 *raw sysfs 文件时,钩子将根据 mask 参数中的 IIO_CHAN_INFO_RAW*chan 参数中的相应通道进行调用。*val*val2 实际上是输出参数,必须用原始值(从设备读取)设置它们。对 *scale sysfs 文件的任何读取都会调用带有 IIO_CHAN_INFO_SCALE 的钩子,其他属性掩码也类似。

相同的原理适用于写入功能,用于向设备写入一个值。你的驱动程序有 80% 的机会不需要 write 操作。在下面的示例中,write 钩子允许用户更改设备的缩放比例,尽管其他参数也可以更改,如采样频率或数字到模拟的原始值:

static int bma220_write_raw(struct iio_dev *indio_dev,
                  struct iio_chan_spec const *chan,
                  int val, int val2, long mask)
{
     int i;
     int ret;
     int index = -1;
     struct bma220_data *data = iio_priv(indio_dev);
     switch (mask) {
     case IIO_CHAN_INFO_SCALE:
      for (i = 0; i < ARRAY_SIZE(bma220_scale_table); i++)
      if (val == bma220_scale_table[i][0] &&
             val2 == bma220_scale_table[i][1]) {
                 index = i;
                 break;
             }
      if (index < 0)
        return -EINVAL;
      mutex_lock(&data->lock);
      data->tx_buf[0] = BMA220_REG_RANGE;
      data->tx_buf[1] = index;
      ret = spi_write(data->spi_device, data->tx_buf,
            sizeof(data->tx_buf));
      if (ret < 0)
           dev_err(&data->spi_device->dev,
               "failed to set measurement range\n");
       mutex_unlock(&data->lock);
      return 0;
    }
    return -EINVAL;
}

当你向设备写入一个值时,将调用此功能,并且仅支持缩放值的变化。在用户空间中的一个使用示例可能是 echo $desired_scale > /sys/bus/iio/devices/iio:devices0/in_accel_scale

现在是时候填充一个 struct iio_info 结构体并将其传递给我们的 iio_device 了:

static const struct iio_info bma220_info = {
    .driver_module    = THIS_MODULE,
    .read_raw         = bma220_read_raw,
    .write_raw      = bma220_write_raw, 
      /* Only if your needed */
};

probe 函数中,我们分配并设置一个 struct iio_dev iio 设备。私有数据的内存也会被保留:

/*
 * We only provide two mask possibilities, 
 * allowing to select none or all channels.
 */
static const unsigned long bma220_accel_scan_masks[] = {
    BIT(AXIS_X) | BIT(AXIS_Y) | BIT(AXIS_Z),
    0
};
static int bma220_probe(struct spi_device *spi)
{
    int ret;
    struct iio_dev *indio_dev;
    struct bma220_data *data;
    indio_dev = devm_iio_device_alloc(&spi->dev,
                                        sizeof(*data));
    if (!indio_dev) {
        dev_err(&spi->dev, "iio allocation failed!\n");
         return -ENOMEM;
    }
    data = iio_priv(indio_dev);
    data->spi_device = spi;
    spi_set_drvdata(spi, indio_dev);
    mutex_init(&data->lock);
     indio_dev->dev.parent = &spi->dev;
     indio_dev->info = &bma220_info;
     indio_dev->name = BMA220_DEVICE_NAME;
     indio_dev->modes = INDIO_DIRECT_MODE;
     indio_dev->channels = bma220_channels;
     indio_dev->num_channels = ARRAY_SIZE(bma220_channels);
     indio_dev->available_scan_masks =
                                 bma220_accel_scan_masks;
    ret = bma220_init(data->spi_device);
    if (ret < 0)
        return ret;
    /* this will enable trigger buffer 
     * support for the device */
    ret = iio_triggered_buffer_setup(indio_dev,
                            iio_pollfunc_store_time,
                            bma220_trigger_handler, NULL);
    if (ret < 0) {
        dev_err(&spi->dev,
                    "iio triggered buffer setup failed\n");
        goto err_suspend;
    }
    ret = devm_iio_device_register(&spi->dev, indio_dev);
    if (ret < 0) {
        dev_err(&spi->dev, "iio_device_register 
                              failed\n");
        iio_triggered_buffer_cleanup(indio_dev);
        goto err_suspend;
    }
     return 0;
err_suspend:
    return bma220_deinit(spi);
}

你可以通过 CONFIG_BMA220 内核选项启用此驱动程序。这意味着,该功能仅在内核版本 v4.8 或更高版本中可用。对于旧版本内核,你可以使用 BMA180,它可以通过 CONFIG_BMA180 选项启用。

注意

要在 IIO 简单虚拟驱动程序中启用缓冲捕获,必须启用 IIO_SIMPLE_DUMMY_BUFFER 内核配置选项。

现在我们已经熟悉了 IIO 缓冲区,我们将学习如何访问来自 IIO 设备并由通道采集产生的数据。

访问 IIO 数据

你可能已经猜到了,使用 IIO 框架访问数据的方式只有两种:通过 sysfs 通道进行单次捕获,或者通过 IIO 字符设备进行连续模式(触发的缓冲区)。

单次捕获

单次数据捕获是通过 sysfs 接口完成的。通过读取与通道对应的 sysfs 条目,你只会捕获该通道的特定数据。假设我们有一个温度传感器,包含两个通道:一个用于环境温度,另一个用于热电偶温度:

# cd /sys/bus/iio/devices/iio:device0
# cat in_voltage3_raw
6646 
# cat in_voltage_scale
0.305175781

处理后的值是通过将缩放值与原始值相乘得到的:

电压值:6646 * 0.305175781 = 2028.19824053

设备数据表明,过程值以毫伏为单位。在我们的例子中,它对应于2.02819 V

访问数据缓冲区

为了使触发采集工作,驱动程序必须实现触发器支持。然后,要从用户空间采集数据,您必须创建触发器、分配触发器、启用 ADC 通道、设置缓冲区的尺寸并启用它。以下部分提供了这部分代码。

使用 sysfs 触发器进行数据采集

使用 sysfs 触发器进行数据采集需要发送一组命令和几个 sysfs 文件。让我们通过以下步骤来了解如何实现:

  1. 创建触发器:在触发器分配给任何设备之前,应先创建触发器:

    echo 0 > /sys/devices/iio_sysfs_trigger/add_trigger
    

在前面的命令中,0对应于我们需要分配给触发器的索引。执行此命令后,触发器目录将在/sys/bus/iio/devices/下作为trigger0可用。触发器的完整路径将是/sys/bus/iio/devices/trigger0

  1. 使用0作为索引,触发器将被命名为sysfstrig0

    echo sysfstrig0 >
    /sys/bus/iio/devices/iio:device0/trigger/current_trigger
    

我们也可以使用这个命令:

cat /sys/bus/iio/devices/trigger0/name > /sys/bus/iio/devices/iio:device0/trigger/current_trigger. 

然而,如果您写入的值与现有触发器名称不对应,则不会发生任何事情。为了确保触发器已成功定义,您可以使用以下命令:

cat /sys/bus/iio/devices/iio:device0/trigger/current_trigger
  1. 驱动程序中的available_scan_masks

    echo 1 > /sys/bus/iio/devices/iio:device0/scan_elements/in_voltage4_en
    echo 1 > /sys/bus/iio/devices/iio:device0/scan_elements/in_voltage5_en
    echo 1 > /sys/bus/iio/devices/iio:device0/scan_elements/in_voltage6_en
    echo 1 > /sys/bus/iio/devices/iio:device0/scan_elements/in_voltage7_en
    
  2. 设置缓冲区大小:在这里,您应该设置缓冲区可以容纳的样本集数:

    echo 100 > /sys/bus/iio/devices/iio:device0/buffer/length
    
  3. 启用缓冲区:此步骤包括将缓冲区标记为准备好接收推送数据:

    echo 1 > /sys/bus/iio/devices/iio:device0/buffer/enable
    

要停止采集,我们必须在同一文件中写入0

  1. 触发触发器:启动采集。这必须根据缓冲区所需的数据样本数在循环中执行多次,例如:

    echo 1 > /sys/bus/iio/devices/trigger0/trigger_now
    

现在采集已完成,您可以执行以下操作。

  1. 禁用缓冲区:

    echo 0 > /sys/bus/iio/devices/iio:device0/buffer/enable
    
  2. 分离触发器:

    echo "" > /sys/bus/iio/devices/iio:device0/trigger/current_trigger
    
  3. 转储我们的 IIO 字符设备内容:

    cat /dev/iio\:device0 | xxd –
    

现在我们已经学习了如何使用 sysfs 触发器,处理基于 hrtimer 的触发器会更容易一些,因为它们在理论原理上使用了相同的方法。

使用 hrtimer 触发器进行数据采集

hrtimer 是具有纳秒级粒度的高分辨率内核定时器,当硬件允许时。与基于 sysfs 的触发器一样,使用 hrtimer 触发器进行数据采集需要几个命令来进行设置。这些命令可以分为以下几个步骤:

  1. 创建基于 hrtimer 的触发器:

    mkdir /sys/kernel/config/iio/triggers/hrtimer/trigger0
    

上述命令将创建一个名为trigger0的触发器。这个名称将用于将触发器分配给设备。

  1. 定义采样频率:

    echo 50 > /sys/bus/iio/devices/trigger0/sampling_frequency
    

config目录中没有hrtimer触发器类型的可配置属性。它引入了samping_frequency属性到触发器目录。该属性设置以赫兹为单位的轮询频率,精度为毫赫兹。在前面的示例中,我们定义了 50 Hz 的轮询(每 20 毫秒一次)。

  1. 将触发器与 IIO 设备链接:

    echo trigger0 > /sys/bus/iio/devices/iio:device0/trigger/current_trigger
    
  2. 选择要在哪些通道上捕获数据并推送到缓冲区:

    # echo 1 > /sys/bus/iio/devices/iio:device0/scan_elements/in_voltage4_en
    # echo 1 > /sys/bus/iio/devices/iio:device0/scan_elements/in_voltage5_en
    # echo 1 > /sys/bus/iio/devices/iio:device0/scan_elements/in_voltage6_en
    # echo 1 > /sys/bus/iio/devices/iio:device0/scan_elements/in_voltage7_en
    
  3. 启动 hrtimer 捕获,它将在我们之前定义的频率下,并在先前启用的通道上执行周期性数据捕获:

    echo 1 > /sys/bus/iio/devices/iio:device0/buffer/enable
    
  4. 最后,可以使用cat /dev/iio\:device0 | xxd –来转储数据。由于触发器是 hrtimer,数据将在每个 hrtimer 周期间隔捕获并推送。

  5. 要禁用此周期性捕获,使用的命令如下:

echo 0 > /sys/bus/iio/devices/iio:device0/buffer/enable

  1. 然后,要删除此 hrtimer 触发器,必须使用以下命令:

rmdir /sys/kernel/config/iio/triggers/hrtimer/trigger0

我们可以注意到,设置一个简单的 sysfs 触发器或基于 hrtimer 的触发器是多么容易。它们都只需要几个命令来设置并启动捕获。然而,如果没有按正确的方式解读,捕获的数据将毫无意义,甚至可能误导,这是我们将在下一节中讨论的内容。

解读数据

现在一切已设置好,我们可以使用以下命令转储数据:

# cat /dev/iio:device0 | xxd -
0000000: 0188 1a30 0000 0000 8312 68a8 c24f 5a14  ...0......h..OZ.
0000010: 0188 1a30 0000 0000 192d 98a9 c24f 5a14  ...0.....-...OZ.
[...]

上述命令将转储原始数据,这些数据需要进一步处理才能获得真实数据。为了能够理解数据输出并处理它,我们需要查看通道类型,如下所示:

$ cat /sys/bus/iio/devices/iio:device0/scan_elements/in_voltage_type
be:s14/16>>2

在前述内容中,be:s14/16>>2表示大端(be:)有符号数据(s),存储在 16 位中,但其实际位数为 14。此外,这也意味着数据必须右移两次(>>2)以获得实际值。例如,要获得第一个样本的电压值(0x188),该值必须右移两次以屏蔽未使用的位:0x188 >> 2 = 0x62 = 98。现在,实际值是98 * 250 = 24500 = 24.5 V。如果有一个偏移量属性,实际值将是(raw + offset) * scale

我们现在已经熟悉了 IIO 数据访问(来自用户空间),并且也完成了内核中的 IIO 生产者接口。不仅仅是用户空间可以从 IIO 通道中消费数据,内核中也有一个接口,我们将在下一节中讨论。

处理内核中的 IIO 消费者接口

到目前为止,我们已经处理了用户空间消费者接口,因为数据是在用户空间中消费的。某些情况下,驱动程序需要一个专用的 IIO 通道。例如,一个需要测量电池电压的电池充电器。这种测量可以通过使用专用的 IIO 通道来实现。

IIO 通道的分配是在设备树中完成的。从生产者方面来看,只需要做一件事:根据 IIO 设备的通道数量指定#io-channel-cells属性。通常,对于单个 IIO 输出的节点,它为0;对于多个 IIO 输出的节点,它为1。以下是一个示例:

adc: max1139@35 {
    compatible = "maxim,max1139";
    reg = <0x35>;
    #io-channel-cells = <1>;
};

在消费者端,需要提供一些属性。以下是需要的属性:

  • io-channels:这是唯一的必需属性。它表示 phandle(设备树节点的引用或指针)和 IIO 指定符对的列表,每对表示设备的一个 IIO 输入。请注意,如果 IIO 提供者的#io-channel-cells属性为0,则在消费者节点中引用它时应仅指定 phandle 部分。这适用于单通道 IIO 设备,例如温度传感器。否则,必须同时指定 phandle 和通道索引。

  • io-channel-names:这是一个可选但推荐的属性,它是一个 IIO 通道名称字符串的列表。这些名称必须与其对应通道的顺序相同,这些通道在io-channels属性中枚举。消费者驱动程序应该使用这些名称将 IIO 输入名称与 IIO 指定符匹配。这可以简化驱动程序中的通道识别。

下面是一个示例:

device {
    io-channels = <&adc 1>, <&ref 0>;
    io-channel-names = "vcc", "vdd";
};

前面的节点描述了一个具有两个 IIO 资源的设备,分别命名为vccvddvcc通道来自于&adc设备的输出1,而vdd通道则来自于&ref设备的输出0

另一个使用同一 ADC 的多个通道的示例如下:

some_consumer {
    compatible = "some-consumer";
    io-channels = <&adc 10>, <&adc 11>;
    io-channel-names = "adc1", "adc2";
};

现在我们已经熟悉了 IIO 绑定和通道占用的概念,可以看到如何使用内核 IIO 消费者 API 来操作这些通道。

消费者内核 API

内核 IIO 消费者接口依赖于一些函数和数据结构。以下是主要的 API:

struct iio_channel *devm_iio_channel_get(
       struct device *dev, const char *consumer_channel);
struct iio_channel * devm_iio_channel_get_all(
                                      struct device *dev);
int iio_get_channel_type(struct iio_channel *channel,
                     enum iio_chan_type *type);
int iio_read_channel_processed(struct iio_channel *chan,
                               int *val);
int iio_read_channel_raw(struct iio_channel *chan,
                         int *val);

以下是每个 API 的描述:

  • devm_iio_channel_get():用于获取单个通道。dev是消费者设备的指针,consumer_channelio-channel-names属性中指定的通道名称。成功时,它返回一个指向有效 IIO 通道的指针;如果无法获取 IIO 通道,则返回指向负错误号的指针。

  • devm_iio_channel_get_all():用于查找 IIO 通道。如果无法获取 IIO 通道,它返回指向负错误号的指针;否则,它返回一个以空iio_dev指针终止的iio_channel结构体数组。假设我们有以下消费者节点:

    iio-hwmon {
        compatible = "iio-hwmon";
        io-channels = <&adc 0>, <&adc 1>, <&adc 2>,
        <&adc 3>, <&adc 4>, <&adc 5>,
        <&adc 6>, <&adc 7>, <&adc 8>,
        <&adc 9>;
    };
    

以下代码示例演示了如何使用devm_iio_channel_get_all()来获取 IIO 通道。该代码还展示了如何检查最后一个有效通道(即指针为空的iio_dev):

    struct iio_channel *channels;
    struct device *dev = &pdev->dev;
    int num_adc_channels;
    channels = devm_iio_channel_get_all(dev);
    if (IS_ERR(channels)) {
        if (PTR_ERR(channels) == -ENODEV)
            return -EPROBE_DEFER;
            return PTR_ERR(channels);
    }
    num_adc_channels = 0;
    /* count how many attributes we have */
    while (channels[num_adc_channels].indio_dev)
            num_adc_channels++;
    if (num_adc_channels !=
        EXPECTED_ADC_CHAN_COUNT) {
            dev_err(dev,
               "Inadequate ADC channels specified\n");
           return -EINVAL;
    }
  • iio_get_channel_type():返回通道的类型,例如 IIO_VOLTAGEIIO_TEMP。此函数将通道的enum iio_chan_type填充到type输出参数中。如果出错,函数返回负错误号;否则,返回0

  • iio_read_channel_processed():读取通道的处理值,单位正确,例如电压为微伏,温度为毫度。val是读取的处理值。此函数在成功时返回0,否则返回负值。

  • iio_read_channel_raw():用于从通道读取原始值。在这种情况下,消费者可能需要比例(iio_read_channel_scale())和偏移(iio_read_channel_offset())来计算处理后的值。val 是读取回来的原始值。

在前述的 API 中,struct iio_channel 表示从消费者角度来看 IIO 通道。它有以下声明:

struct iio_channel {
    struct iio_dev *indio_dev;
    const struct iio_chan_spec *channel;
    void *data;
};

在前面的代码中,iio_dev 是 IIO 设备,表示该通道所属的设备,而 channel 是提供者所看到的底层通道规范。

编写用户空间的 IIO 应用程序

在经历了内核端的实现后,可能会有兴趣看看另一个方面,即用户空间。用户空间中的 IIO 支持可以通过 sysfs 处理,也可以使用 libiio,这是一个专门为此目的开发的库,并且跟随内核端的演变。该库抽象了硬件的低级细节,并提供了一个简单且全面的编程接口,适用于复杂的项目。

在本节中,我们将使用版本 0.21 的库,文档可以在这里找到:analogdevicesinc.github.io/libiio/v0.21/libiio/index.html

libiio 可以运行在以下平台上:

  • 目标,即运行 Linux 的嵌入式系统,其中包括用于与物理连接到系统的设备(如 ADC 和 DAC)进行交互的 IIO 驱动程序。

  • 通过网络、USB 或串行连接与嵌入式系统连接的远程计算机。该远程计算机可以是运行 Linux 发行版、Windows、macOS 或 OpenBSD/NetBSD 的 PC。此远程 PC 通过运行在目标上的 iiod 服务器与嵌入式系统通信。

以下图表总结了架构:

图 15.2 – libiio 概览

图 15.2 – libiio 概览

libiio 是围绕五个概念构建的,每个概念对应一个数据结构,共同构成几乎所有的 API。这些概念如下:

  • 后端:表示您的应用程序与 IIO 设备所在目标之间的连接性(或通信通道)。此后端(即连接性)可以通过 USB、网络、串行或本地连接。无论硬件连接如何,支持的后端在库的编译时定义。

  • 上下文:上下文是一个库实例,表示 IIO 设备的集合,在大多数情况下,它对应于运行目标上 IIO 设备的全局视图。通过这种方式,一个上下文聚合了目标包含的所有 IIO 设备、它们的通道以及它们的属性。例如,在查找 IIO 设备时,代码必须创建一个上下文,并从该上下文请求目标 IIO 设备。

由于应用可能远程运行在目标板上,上下文将需要与目标建立通信通道。这时后端将发挥作用。因此,一个上下文必须由后端支持,后端代表了目标和运行应用的机器之间的连接。然而,远程运行的应用并不总是能够了解目标环境;因此,库允许查找可用的后端,从而支持包括动态行为在内的多种功能。这一查找过程被称为 IIO 上下文扫描。也就是说,如果应用本地运行在目标上,可能不需要扫描。

一个上下文通过struct iio_context的实例表示。一个上下文对象可以包含零个或多个设备。然而,一个设备对象只与一个上下文关联。

  • struct iio_device,是用户空间(实际上是libiio)对应内核中的struct iio_dev。一个设备对象可以包含零个或多个通道,而一个通道只与一个设备关联。

  • struct iio_buffer。一个设备可以与一个缓冲区对象关联,而一个缓冲区只与一个设备关联。

  • struct iio_channel。一个设备可以包含零个或多个通道,而一个通道只与一个设备关联。

在熟悉这些概念之后,我们可以将 IIO 应用开发分为以下几个步骤:

  1. 创建一个上下文,在(可选地)扫描可用的后端以创建该上下文之后。

  2. 遍历所有设备,或寻找并选择感兴趣的设备,最终通过其属性获取/设置设备参数。

  3. 遍历设备通道并启用感兴趣的通道(或禁用我们不感兴趣的通道)。最终通过其属性获取/设置通道参数。

  4. 如果设备需要触发器,则将触发器与该设备关联。该触发器必须在创建上下文之前创建。

  5. 创建缓冲区并将该缓冲区与设备关联,然后开始流式传输。

  6. 启动捕获并读取数据。

扫描并创建 IIO 上下文

创建上下文时,库会识别可用的 IIO 设备(包括触发器),并为每个设备识别其通道;然后,它会识别所有设备和通道特定的属性,并识别所有通道共享的属性;最后,库会创建一个上下文,将所有这些实体放入其中。

可以使用以下 API 之一创建上下文:

iio_create_local_context()
iio_create_network_context()
iio_create_context_from_uri()
iio_context_clone(const struct iio_context *ctx)

每个这些函数在成功时返回有效的上下文对象,否则返回NULL,并且errno会适当设置。也就是说,尽管它们返回相同的值,它们的参数可能有所不同,如下所述:

  • iio_create_local_context():用于创建本地上下文:

    struct iio_context * local_ctx;
    local_ctx = iio_create_local_context();
    

请注意,本地后端通过 sysfs 虚拟文件系统与 Linux 内核接口。

  • iio_create_network_context():创建一个网络上下文。它的参数是一个字符串,表示远程目标的 IPv4 或 IPv6 网络地址:

    struct iio_context * network_ctx;
    network_ctx =
          iio_create_network_context("192.168.100.15");
    
  • 可以使用基于 URI 的 API iio_create_context_from_uri() 创建 USB 上下文。参数是一个字符串,用于标识 USB 设备,使用以下模式:usb:[device:port:instance]

    struct iio_context * usb_ctx;
    usb_ctx = iio_create_context_from_uri("usb:3.80.5");
    
  • 串口上下文与 USB 上下文类似,使用基于 URI 的 API。但是,它的 URI 必须匹配以下模式:serial:[port][,baud][,config]

    struct iio_context * serial_ctx;
    serial_ctx = iio_create_context_from_uri(
                       "serial:/dev/ttyUSB0,115200,8n1"); 
    
  • iio_create_context_from_uri() 是一个基于 URI 的 API,参数为一个有效的 URI(以要使用的后端开头)。对于本地上下文,URI 必须为 "local:"。对于基于 URI 的网络上下文,URI 模式必须匹配 "ip:<ipaddr>",其中 <ipaddr> 是远程目标的 IPv4 或 IPv6 地址。有关基于 URI 的上下文的更多信息,请参见:analogdevicesinc.github.io/libiio/v0.21/libiio/group__Context.html#gafdcee40508700fa395370b6c636e16fe

  • iio_context_clone() 复制给定的上下文,并返回新的克隆对象。此函数不支持 usb: 上下文,因为 libusb 只能一次性声明接口。

在创建上下文之前,用户可能希望扫描可用的上下文(即查找可用的后端)。为了找出哪些 IIO 上下文是可用的,用户的代码必须执行以下操作:

  • 调用 iio_create_scan_context() 创建一个 iio_scan_context 对象的实例。此函数的第一个参数是一个字符串,用作过滤器(usb:ip:local:serial:,或者它们的混合,例如 usb:ip,其中默认值(NULL)表示任何已编译的后端)。

  • 调用 iio_scan_context_get_info_list(),将先前的 iio_scan_context 对象作为参数传递。此方法将返回一个 iio_context_info 对象数组,这些对象来自 iio_scan_context 对象。可以使用 iio_context_info_get_description()iio_context_info_get_uri() 来检查每个 iio_context_info 对象,以确定要附加到哪个 URI。

  • 完成后,必须分别使用 iio_context_info_list_free()iio_scan_context_destroy() 释放 info 对象数组和 scan 对象。

以下是扫描可用上下文并创建一个上下文的演示:

int i;
ssize_t nb_ctx;
const char *uri;
struct iio_context *ctx = NULL;
#ifdef CHECK_REMOTE
struct iio_context_info **info;
struct iio_scan_context *scan_ctx =
                  iio_create_scan_context("usb:ip:", 0);
if (!scan_ctx) {
    printf("Unable to create scan context!\n");
    return NULL;
}
nb_ctx = iio_scan_context_get_info_list(scan_ctx, &info);
if (nb_ctx < 0) {
    printf("Unable to scan!\n");
    iio_scan_context_destroy(scan_ctx);
    return NULL;
}
for (i = 0; i < nb_ctx; i++) {
    uri = iio_context_info_get_uri(info[0]);
    if (strcmp ("usb:", uri) == 0) {
        ctx = iio_create_context_from_uri(uri);
        break;
    }
    if (strcmp ("ip:", uri) == 0) { 
        ctx = 
            iio_create_context_from_uri("ip:192.168.3.18");
        break;
    }
}
iio_context_info_list_free(info);
iio_scan_context_destroy(scan_ctx);
#endif
if (!ctx) {
    printf("creating local context\n");
    ctx = iio_create_local_context();
    if (!ctx) {
       printf("unable to create local context\n");
       goto err_free_info_list;
    }
}
return ctx;

在前面的代码中,如果定义了 CHECK_REMOTE 宏,代码将首先通过过滤 USB 和网络上下文来扫描可用的上下文(即后端)。代码首先查找 USB 上下文,然后再查找网络上下文。如果没有找到,它将回退到本地上下文。

此外,您可以使用以下 API 获取一些与上下文相关的信息:

int iio_context_get_version (
       const struct iio_context * ctx,
       unsigned int *major, unsigned int *minor,
       char git_tag[8])
const char * iio_context_get_name(
                      const struct iio_context *ctx)
const char * iio_context_get_description(
                      const struct iio_context *ctx)

在上述 API 中,iio_context_get_version()将返回正在使用的后端版本,输出参数包括majorminorgit_tagiio_context_get_name()返回一个指向静态NULL终止字符串的指针,该字符串对应后端名称,当上下文是通过本地、XML 和网络后端创建时,名称分别为localxmlnetwork

以下是一个演示:

unsigned int major, minor;
char git_tag[8];
struct iio_context *ctx;
[...] /* the context must be created */
iio_context_get_version(ctx, &major, &minor, git_tag);
printf("Backend version: %u.%u (git tag: %s)\n",
           major, minor, git_tag);
printf("Backend description string: %s\n",
           iio_context_get_description(ctx));

现在上下文已经创建,并且我们能够读取其信息,用户可能会有兴趣遍历上下文,即导航该上下文包含的实体,例如获取 IIO 设备的数量或获取给定设备的实例。

注意

上下文是目标上 IIO 实体的即时且固定的视图。例如,如果用户在创建上下文之后创建了一个 IIO 触发设备,则无法从该上下文访问该触发设备。由于没有上下文同步 API,因此正确的做法是销毁并重新创建对象,或者在程序开始时创建所需的动态 IIO 元素,再创建上下文。

遍历并管理 IIO 设备

以下是用于在 IIO 上下文中遍历设备的 API:

unsigned int iio_context_get_devices_count(
                            const struct iio_context *ctx)
struct iio_device * iio_context_get_device(
         const struct iio_context *ctx, unsigned int index)
struct iio_device * iio_context_find_device(
         const struct iio_context *ctx, const char *name)

从上下文中,iio_context_get_devices_count()返回该上下文中 IIO 设备的数量。

iio_context_get_device()返回指定 IIO 设备的句柄,该设备通过其索引(或 ID)来指定。该 ID 对应/sys/bus/iio/devices/iio:device<X>/中的<X>。例如,/sys/bus/iio/devices/iio:device1设备的 ID 为1。如果索引无效,则返回NULL。或者,给定设备对象,可以通过iio_device_get_id()获取其 ID。

iio_context_find_device()通过名称查找 IIO 设备。此名称必须与驱动程序中指定的iio_indev->name对应。你可以通过使用专门的iio_device_get_name()API 或读取该设备的 sysfs 目录中的name属性来获取此名称:

root:/sys/bus/iio/devices/iio:device1> cat name
ad9361-phy

以下是遍历所有设备并打印其名称和 ID 的示例:

struct iio_context * local_ctx;
local_ctx = iio_create_local_context();
int i;
for (i = 0; i < iio_context_get_devices_count(local_ctx);
     ++i) {
    struct iio_device *dev =
           iio_context_get_device(local_ctx, i);
    const char *name = iio_device_get_name(dev);
    printf("\t%s: %s\r\n", iio_device_get_id(dev), name );
}
iio_context_destroy(ctx);

上面的代码示例遍历上下文中存在的 IIO 设备(本地上下文),并打印它们的名称和 ID。

遍历并管理 IIO 通道

主要的通道管理 API 如下:

unsigned int iio_device_get_channels_count(
                              const struct iio_device *dev)
struct iio_channel* iio_device_get_channel(
         const struct iio_device *dev, unsigned int index)
struct iio_channel* iio_device_find_channel(
                            const struct iio_device *dev,
                            const char *name, bool output)

我们可以通过iio_device_get_channels_count()iio_device对象中获取可用通道的数量。然后,可以通过iio_device_get_channel()访问每个iio_channel对象,指定该通道的索引。例如,在一个三轴(x, y, z)加速度计上,iio_device_get_channel(iio_device, 0)将对应获取通道 0,即accel_x。在一个八通道的 ADC 转换器上,iio_device_get_channel(iio_device, 0)将对应获取通道 0,即voltage0

或者,可以使用iio_device_find_channel()按名称查找通道,该函数期望的参数是通道名称和一个布尔值,用于指示该通道是否为输出。如果你记得,在通道属性命名约定部分中,我们看到属性名称遵循以下模式:{direction}_{type}{index}_{modifier}_{info_mask}。在此模式中,需要与iio_device_find_channel()一起使用的子集是{type}{index}_{modifier}。然后,取决于布尔参数的值,最终名称将通过添加in_out_作为前缀来获得。例如,要获取加速度计的X通道,我们将使用iio_device_find_channel(iio_device, "accel_x", 0)。对于模拟-数字转换器的第一个通道,我们将使用iio_device_find_channel(iio_device, "voltage0", 0)

以下是遍历所有设备及每个设备的所有通道的示例:

struct iio_context * local_ctx;
struct iio_channel *chan;
local_ctx = iio_create_local_context();
int i, j;
for (i = 0; i < iio_context_get_devices_count(local_ctx);
       ++i) {
    struct iio_device *dev = 
           iio_context_get_device(local_ctx, i);
    printf("Device %d\n", i);
    for (j = 0; j < iio_device_get_channels_count(dev);
            ++j) {
        chan = iio_device_get_channel(dev, j);
        const char *name = iio_channel_get_name(ch) ? :
                              iio_channel_get_id(ch);
        printf("\tchannel %d: %s\n", j, name);
    }
}

上述代码创建一个本地上下文,并遍历该上下文中的所有设备。然后,对于每个设备,它遍历通道并打印其名称。

此外,还有一些杂项 API 允许我们获取通道属性。以下是这些 API:

bool iio_channel_is_output(const struct iio_channel *chn); 
const char* iio_channel_get_id(
                            const struct iio_channel *chn);
enum iio_modifier iio_channel_get_modifier(
                            const struct iio_channel *chn);
enum iio_chan_type iio_channel_get_type(
                            const struct iio_channel *chn);
const char* iio_channel_get_name(
                            const struct iio_channel *chn);

在上述 API 中,第一个检查 IIO 通道是否为输出,其他的主要返回名称模式中每个元素的值。

与触发器一起工作

libiio中,触发器被视为设备,因为它们都由struct iio_device表示。触发器必须在创建上下文之前创建,否则该上下文无法看到/使用该触发器。

为此,您必须自己创建触发器,正如我们在IIO 触发器和 sysfs(用户空间)部分看到的那样。然后,要从上下文中找到此触发器,由于它被视为设备,您可以使用我们在遍历和管理 IIO 设备部分中描述的设备相关查找 API 之一。在本节中,让我们使用iio_context_find_device(),正如您所记得,它定义如下:

struct iio_device* iio_context_find_device(
          const struct iio_context *ctx, const char *name) 

该函数根据给定上下文中的设备名称查找设备。这就是为什么触发器必须在创建上下文之前创建的原因。在参数中,ctx是查找触发器的上下文,name是触发器的名称,就像你在current_trigger sysfs 文件中写的那样。

一旦找到触发器,必须使用iio_device_set_trigger()将其分配给设备,定义如下:

int iio_device_set_trigger(const struct iio_device *dev,
                          const struct iio_device *trig)

该函数将触发器trig与设备dev关联,并在成功时返回0,失败时返回负的errno代码。如果trig参数为NULL,则与给定设备关联的任何触发器将被解除关联。换句话说,要将触发器从设备中解除关联,应调用iio_device_set_trigger(dev, NULL)

让我们通过一个小例子来看一下触发器查找和关联是如何工作的:

struct iio_context *ctx;
struct iio_device *trigger, *dev;
[...]
ctx = iio_create_local_context();
/* at least 2 iio_device must exist:
 * a trigger and a device */
if (!(iio_context_get_devices_count(ctx) > 1))
    return -1;
trigger = iio_context_find_device(ctx, "hrtimer-1");
if (!trigger) {
    printf("no trigger found\n");
    return -1;
}
dev = iio_context_find_device(ctx, "iio-device-dummy");
if (!dev) {
    printf("unable to find the IIO device\n");
    return -1;
}
printf("Enabling IIO buffer trigger\n");
iio_device_set_trigger(dev, trigger);
[...]
/* When done with the trigger */
iio_device_set_trigger(dev, NULL);

在前面的示例中,我们首先创建一个本地上下文,并确保该上下文包含至少两个设备。然后,从该上下文中查找一个名为hrtimer-1的触发器和一个名为iio-device-dummy的设备。一旦找到这两个对象,我们将触发器与设备关联。最后,当触发器不再使用时,它将与设备解除关联。

创建缓冲区并读取数据样本

请注意,我们感兴趣的通道需要在创建缓冲区之前启用。为此,你可以使用以下 API:

void iio_channel_enable(struct iio_channel * chn)
bool iio_channel_is_enabled(struct iio_channel * chn)

第一个函数启用通道,以便其数据将被捕获并推送到缓冲区。第二个是一个帮助函数,用于检查通道是否已经启用。

为了禁用通道,你可以使用iio_channel_disable(),其定义如下:

void iio_channel_disable(struct iio_channel * chn)

现在我们能够启用通道,我们需要捕获它们的数据。我们可以使用iio_device_create_buffer()创建一个缓冲区,其定义如下:

struct iio_buffer * iio_device_create_buffer(
        const struct iio_device *dev,
        size_t samples_count, bool cyclic)

该函数配置并启用缓冲区。在前面的函数中,samples_count是缓冲区能够存储的总数据样本数,无论启用的通道数量如何。它对应于IIO 缓冲区 sysfs 接口章节中描述的length属性。cyclic如果为true,则启用循环模式。此模式仅对输出设备有意义(如 DAC)。然而,在本节中,我们仅处理输入设备(即 ADC)。

一旦你完成了对缓冲区的操作,你可以调用iio_buffer_destroy()来销毁该缓冲区,这将禁用缓冲区(从而停止捕获)并释放数据结构。此 API 的定义如下:

void  iio_buffer_destroy(struct iio_buffer *buf)

请注意,捕获一旦缓冲区创建成功后就会开始,即在iio_device_create_buffer()成功后。然而,样本仅会被推送到内核缓冲区。为了将样本从内核缓冲区获取到用户空间缓冲区,我们需要使用iio_buffer_refill()。虽然iio_device_create_buffer()只需调用一次以创建缓冲区并启动内核中的连续捕获,iio_buffer_refill()必须每次调用来获取内核缓冲区中的样本。它可以在处理循环中使用,例如。以下是它的定义:

ssize_t iio_buffer_refill (struct iio_buffer *buf)

使用iio_device_create_buffer(),通过低速接口,内核会分配一个单独的底层缓冲区块(其大小为samples_count * nb_buffers * sample_size)来处理捕获,并立即开始将样本填充到其中。默认情况下,默认的块数量为4,可以通过iio_device_set_kernel_buffers_count()进行更改,其定义如下:

int iio_device_set_kernel_buffers_count(
                const struct iio_device *dev,
                unsigned int nb_buffers)

在高速模式下,内核分配 nb_buffers 个缓冲块,通过输入队列(空缓冲区)和输出队列(包含样本的缓冲区)以 FIFO 概念进行管理。在创建时,所有缓冲区都会填充样本并放入输出队列。当调用 iio_buffer_refill() 时,输出队列中的第一个缓冲区的数据会被推送(或映射)到用户空间,并且该缓冲区会被放回输入队列,等待再次填充。在下一次调用 iio_buffer_refill() 时,将使用第二个缓冲区,依此类推,不断循环。需要注意的是,小缓冲区会导致较低的延迟,但开销较大;而大缓冲区会导致较低的开销,但延迟较高。应用程序必须在延迟和管理开销之间做出权衡。当循环模式为 true 时,将只创建一个缓冲区,无论指定的块数是多少。

为了读取数据样本,可以使用以下 API:

void iio_buffer_destroy(struct iio_buffer *buf) 
void* iio_buffer_end(const struct iio_buffer *cbuf)  
void* iio_buffer_start(const struct iio_buffer *buf) 
ptrdiff_t iio_buffer_step(const struct iio_buffer *buf)
void* iio_buffer_first(const struct iio_buffer *buf,
                         const struct iio_channel *chn)
ssize_t iio_buffer_foreach_sample(struct iio_buffer *buf,
          ssize_t(*callback)(const struct iio_channel *chn,
                         void *src, size_t bytes, void *d),
          void *data)

以下是每个列出 API 的含义和用法:

  • iio_buffer_end() 返回指向缓冲区中最后一个样本之后的用户空间地址的指针。

  • iio_buffer_start() 返回用户空间缓冲区的地址。然而,请注意,在调用 iio_buffer_refill() 后,该地址可能会发生变化(尤其是在使用高速接口时,多个缓冲块被使用)。

  • iio_buffer_step() 返回缓冲区中样本集之间的间隔。也就是说,它返回同一通道中两个连续样本地址之间的差值。

  • iio_buffer_first() 返回给定通道的第一个样本的地址,如果缓冲区中没有该通道的样本,则返回缓冲区末尾的地址。

  • iio_buffer_foreach_sample() 遍历缓冲区中的每个样本,并为找到的每个样本调用提供的回调函数。

前述 API 列表可以根据读取数据样本的方式分为三类。

缓冲区指针读取

在这种读取方法中,iio_buffer_first()iio_buffer_step()iio_buffer_end() 配合使用,以便迭代缓冲区中给定通道的所有样本。可以通过以下方式实现:

for (void *ptr = iio_buffer_first(buffer, chan);
           ptr < iio_buffer_end(buffer);
           ptr += iio_buffer_step(buffer)) {
[...]
}

在前面的例子中,在循环中,ptr 将指向我们感兴趣的通道样本,即 chan

以下是一个示例:

const struct iio_data_format *fmt;
unsigned int i, repeat;
struct iio_channel *channels[8] = {0};
ptrdiff_t p_inc;
char *p_dat;
[...]
IIOC_DBG("Enter buffer refill loop.\n");
while (true) {
    nbytes = iio_buffer_refill(buf);
    p_inc = iio_buffer_step(buf);
    p_end = iio_buffer_end(buf);
    for (i = 0; i < channel_count; ++i) {
        fmt = iio_channel_get_data_format(channels[i]);
        repeat = fmt->repeat ? : 1;
        for (p_dat = iio_buffer_first(rxbuf, channels[i]);
                     p_dat < p_end; p_dat += p_inc) {
            for (j = 0; j < repeat; ++j) {
                if (fmt->length/8 == sizeof(int16_t))
                    printf("Read 16bit value: " "%" PRIi16, 
                            ((int16_t *)p_dat)[j]);
                else if (fmt->length/8 == sizeof(int64_t))
                    printf("Read 64bit value: " "%" PRIi64,
                           ((int64_t *)p_dat)[j]);
            }
        }
    }
    printf("\n");
}

前面的代码读取通道数据格式,以检查值是否重复。这个重复对应于 iio_chan_spec.scan_type.repeat。然后,假设代码可以处理两种不同的转换器(第一种使用 16 位编码数据,第二种使用 64 位编码数据),会进行数据长度的检查,以便以适当的格式打印。这段长度对应于 iio_chan_spec.scan_type.storagebits。需要注意的是,PRIi16PRIi64int16_tint64_t 的整数 printf 格式。

基于回调的样本读取

在基于回调的样本读取中,iio_buffer_foreach_sample() 是读取逻辑的核心。它的定义如下:

ssize_t iio_buffer_foreach_sample(struct iio_buffer *buf,
            ssize_t(*)(const struct iio_channel *chn,
                void *src, size_t bytes, void *d) callback,
            void *data)

此函数为缓冲区中找到的每个样本调用提供的回调。data 是用户数据,如果设置,将作为最后一个参数传递给回调。此函数迭代样本,每个样本都会被读取并传递给回调,以及产生该样本的通道。此回调具有以下定义:

ssize_t sample_cb(const struct iio_channel *chn,
              void *src, size_t bytes, __notused void *d)

回调函数接收四个参数,如下所示:

  • 指向生成样本的 iio_channel 结构的指针

  • 指向样本本身的指针

  • 样本的长度(以字节为单位),即存储位数除以 8,即 iio_chan_spec.scan_type.storagebits/8

  • 可选择传递给 iio_buffer_foreach_sample() 的用户指定指针

此方法可用于从缓冲区读取(对于输入设备)或写入(对于输出设备)。与前一方法的主要区别在于,回调函数对缓冲区中的每个样本进行调用,而不是按通道顺序,而是按照它们在缓冲区中出现的顺序。

以下是这种类型回调实现的示例:

static ssize_t sample_cb(const struct iio_channel *chn,
              void *src, size_t bytes, __notused void *d)
{
    const struct iio_data_format *fmt = 
                         iio_channel_get_data_format(chn);
    unsigned int j, repeat = fmt->repeat ? : 1;
    printf("%s ", iio_channel_get_id(chn));
    for (j = 0; j < repeat; ++j) {
        if (bytes == sizeof(int16_t))
            printf("Read 16bit value: " "%" PRIi16,
                   ((int16_t *)src)[j]);
        else if (bytes == sizeof(int64_t))
            printf("Read 64bit value: " "%" PRIi64, 
                   ((int64_t *)src)[j]);
    }
    return bytes * repeat;
}

然后,在主代码中,我们循环并迭代缓冲区中的样本,如下所示:

int ret;
[...]
IIOC_DBG("Enter buffer refill loop.\n");
while (true) {
    nbytes = iio_buffer_refill(buf);
    ret = iio_buffer_foreach_sample(buf, sample_cb, NULL);
    if (ret < 0) {
        char text[256];
        iio_strerror(-ret, buf, sizeof(text));
        printf("%s (%d) while processing buffer\n",
                text, ret);
    }
    printf("\n");
}

前述代码不直接处理样本,而是将工作委托给回调函数。

高级通道(原始)读取

此读取系列中的最后一种方法是使用 iio_channel 类提供的高级函数之一。这些函数包括 iio_channel_read_raw()iio_channel_write_raw()iio_channel_read()iio_channel_write(),均定义如下:

size_t iio_channel_read_raw(const struct iio_channel *chn,
        struct iio_buffer *buffer, void *dst, size_t len) 
size_t iio_channel_read(onst struct iio_channel *chn,
        struct iio_buffer *buffer, void *dst, size_t len)
size_t iio_channel_write_raw(const struct iio_channel *chn,
        struct iio_buffer * buffer, const void *src,
        size_t len)
size_t iio_channel_write(const struct iio_channel *chn,
        struct iio_buffer *buffer, const void *src,
        size_t len)

前两者基本上会将通道(chan)的前 N 个样本复制到预先分配的用户指定缓冲区(dst)中(N 取决于此缓冲区的大小和样本的存储大小,即 iio_chan_spec.scan_type.storagebits / 8)。两者之间的区别在于 _raw 变体不会转换样本,并且用户缓冲区将包含原始数据,而另一个变体将转换每个样本,以便用户缓冲区将包含处理过的值。这些函数在某种程度上是分解(因为它们针对多个通道中的一个通道的样本)给定通道的样本。

另一方面,iio_channel_write_raw()iio_channel_write() 将从用户指定的缓冲区复制样本数据到设备,通过定位给定的通道。这些函数在收集目标通道的样本时进行多路复用。两者之间的区别在于 _raw 变体会直接复制数据,而另一个会在发送到设备之前将数据转换为硬件格式。

让我们尝试使用前述的 API 从设备中读取数据:

#define CBUF_LENGTH 2048 /* the number of sample we need */
[...]
const struct iio_data_format *fmt;
unsigned int i, repeat;
struct iio_channel *chan[8] = {0};
[...]
IIOC_DBG("Enter buffer refill loop.\n");
while (true) {
    nbytes = iio_buffer_refill(buf);
    for (i = 0; i < channel_count; ++i) {
        uint8_t *c_buf;
        size_t sample, bytes;
        fmt = iio_channel_get_data_format(chan[i]);
        repeat = fmt->repeat ? : 1;
        size_t sample_size = fmt->length / 8 * repeat;
        c_buf = malloc(sample_size * CBUF_LENGTH);
        if (!c_buf) {
            printf("No memory space for c_buf\n");
            return -1;
        }
        if (buffer_read_method == CHANNEL_READ_RAW)
            bytes = iio_channel_read_raw(chan[i], buf,
                     c_buf, sample_size * CBUF_LENGTH);
        else
            bytes = iio_channel_read(chan[i], buf, c_buf,
                     sample_size * CBUF_LENGTH);
        printf("%s ", iio_channel_get_id(chan[i]));
        for (sample = 0; sample < bytes / sample_size;
               ++sample) {
            for (j = 0; j < repeat; ++j) {
               if (fmt->length / 8 == sizeof(int16_t))
                   printf("%" PRIi16 " ",
                           ((int16_t *)buf)[sample+j]);
               else if (fmt->length / 8 == sizeof(int64_t))
                   printf("%" PRId64 " ",
                          ((int64_t *)buf)[sample+j]);
            }
        }
        free(c_buf);
    }
    printf("\n");
}

在上述示例中,我们首先使用 iio_buffer_refill() 从内核获取数据样本。然后,对于每个通道,我们使用 iio_channel_get_data_format() 获取该通道的数据格式,并从中获取该通道的样本大小。之后,我们利用该样本的大小来计算为接收该通道样本而分配的用户缓冲区大小。获取通道的样本大小可以让我们准确地确定需要分配的用户缓冲区的大小。

探索用户空间的 IIO 工具

尽管我们已经完成了捕获 IIO 数据所需的步骤,但每个步骤必须手动执行,可能会显得繁琐且令人困惑。幸运的是,你可以使用一些有用的工具来简化并加速处理 IIO 设备的应用程序开发。这些工具都来自 libiio 包,它是由模拟器件公司(Analog Devices, Inc.)开发的,用于与 IIO 设备交互,获取地址:github.com/analogdevicesinc/libiio

用户空间应用程序可以轻松使用 libiio 库,该库实际上是一个包装器,依赖于以下接口:

  • /sys/bus/iio/devices,IIO sysfs 接口,主要用于配置/设置

  • /dev/iio/deviceX 字符设备,用于数据/采集

上述内容正是我们到目前为止手动操作过的内容。该工具的源代码可以在库的 tests 目录下找到:github.com/analogdevicesinc/libiio/tree/master/tests 提供了以下工具:

  • iiod 服务器守护进程,作为网络后端,通过网络链接为任何应用程序提供服务

  • iio_info 用于转储属性

  • iio_readdev 用于从设备读取或扫描数据

本章结尾时,我们列举了一些工具,这些工具可以简化原型开发或设备/驱动程序测试。相关链接指向这些工具的源代码、文档或使用示例。

总结

阅读完本章后,你将熟悉 IIO 框架和词汇。你将了解通道、设备和触发器的概念。你甚至可以通过 sysfs 或字符设备从用户空间操作你的 IIO 设备。现在是编写你自己的 IIO 驱动程序的时候了。市面上有很多现有的驱动程序,它们不支持触发缓冲区。你可以尝试为其中之一添加此功能。

在下一章中,我们将探索 GPIO 子系统,它是本章也已经介绍的一个基本概念。

第十六章:第十六章:最大化利用引脚控制器和 GPIO 子系统

系统芯片SoC)变得越来越复杂,功能越来越丰富。这些功能大多通过来自 SoC 的电气线路暴露出来,并被称为引脚。这些引脚大多数被路由到或与多个功能模块复用(例如,UART、SPI、RGMI、通用输入输出GPIO)等),负责配置这些引脚并在操作模式之间切换(在功能模块之间切换)的底层设备被称为引脚控制器

其中一种配置这些引脚的模式是GPIO。接着是 Linux GPIO 子系统,它使驱动程序能够读取 GPIO 配置引脚的高低电平信号,并且能够在 GPIO 配置的引脚上驱动信号的高/低电平。另一方面,引脚控制(简称pinctrl)子系统使得一些引脚/引脚组可以用于不同的功能,并能够配置引脚的电子属性,如上升/下降速率、上拉/下拉电阻、滞后等。

总结来说,引脚控制器主要做两件事:引脚复用,即将相同的引脚用于不同的目的,以及引脚配置,即配置引脚的电子属性。然后,GPIO 子系统允许驱动引脚,前提是这些引脚已经通过引脚控制器配置为 GPIO 模式。

在本章中,将通过以下主题介绍引脚控制器和 GPIO 子系统:

  • 一些硬件术语介绍

  • 引脚控制子系统简介

  • 处理 GPIO 控制器接口

  • 最大化利用 GPIO 消费者接口

  • 学习如何不编写 GPIO 客户端驱动

一些硬件术语介绍

Linux 内核 GPIO 子系统不仅仅是 GPIO 翻转。它与引脚控制子系统紧密耦合;它们共享一些术语和概念,我们需要在此介绍:

  • 引脚接触点:引脚是一个物理输入或输出线/导线,用于传输电信号到或来自一个组件。在电路图中,“引脚”这一术语被广泛使用。另一方面,接触点是印刷电路板或集成电路的接触面区域。因此,引脚来源于接触点,且默认情况下,引脚即为接触点。

  • GPIO:大多数 MCU 和 CPU 可以在多个功能模块之间共享一个接触点。这是通过对接触点的输入和输出信号进行复用来实现的。引脚/接触点可以操作的不同模式被称为ALT 模式(或备用模式),通常 CPU 支持每个接触点最多八种设置(或模式)。GPIO 就是其中一种模式。它允许在配置为输入时改变引脚方向并读取其值,或在配置为输出时设置其值。其他模式包括 ADC、UART Tx、UART Rx、SPI MOSI、SPI MISO、PWM 等。

  • 引脚控制器:这是底层设备或控制器(或者更确切地说,是一组寄存器),允许你执行引脚复用(也称为pinmuxpinmuxing),以便将同一个引脚用于不同的目的。除了引脚复用,它还允许引脚配置,即配置引脚的电子属性。以下是这些属性的一些示例:

    • 偏置,即设置初始工作条件,例如将引脚接地或连接到 Vdd。这与上拉和下拉有所不同,后者是另一种属性。

    • 引脚去抖动,即应在此时间后认为状态有效。例如,这可以防止按键盘上附着在 GPIO 线上的多个按键。

    • 切换速率,它决定了引脚在两个逻辑状态之间切换的速度。它使我们能够控制输出信号的上升和下降时间。必须找到一个权衡,因为快速切换状态会消耗更多的电力并产生尖峰,因此,除非是快速控制信号(如并行接口:EIM、EB&、SPI 或 SDRAM 等需要快速切换的信号),否则应偏好较低的切换速率。

    • 上拉/下拉电阻

  • GPIO 控制器:这是当引脚处于 GPIO 模式时,允许你驱动引脚的设备。它允许更改 GPIO 的方向和值。

根据之前的定义,已经为编写引脚控制器或 GPIO 控制器驱动程序建立了一些通用规则,它们如下所示:

  • 如果你的 GPIO/引脚控制器仅能执行简单的 GPIO 操作,只需在drivers/gpio/gpio-foo.c中实现struct gpio_chip并留在那里。不要使用通用的或旧式的基于数字的 GPIO。

  • 如果 GPIO/引脚控制器除了 GPIO 功能外还能生成中断,请将其保存在drivers/gpio中;只需填写struct irq_chip并将其注册到 IRQ 子系统中。

  • 如果该控制器支持引脚复用、高级引脚驱动强度、复杂的偏置等,请在drivers/pinctrl/pinctrl-foo.c中实现复合引脚控制器驱动程序。

  • 维护struct gpio_chipstruct irq_chipstruct pinctrl_desc接口。

现在我们已经熟悉了与底层硬件设备相关的术语,让我们介绍 Linux 实现,从引脚控制子系统开始。

引脚控制子系统介绍

引脚控制器允许收集引脚、这些引脚应该操作的模式及其配置。驱动程序负责根据要实现的功能提供适当的回调集,前提是底层硬件支持这些功能。

引脚控制器描述符数据结构定义如下:

struct pinctrl_desc {
     const char *name;
     const struct pinctrl_pin_desc *pins;
     unsigned int npins;
     const struct pinctrl_ops *pctlops;
     const struct pinmux_ops *pmxops;
     const struct pinconf_ops *confops;
     struct module *owner;
[...]
};

在该引脚控制器数据结构中,仅列出了相关元素,以下是它们的含义:

  • name是引脚控制器的名称。

  • pins: 描述此控制器可以处理的所有引脚的引脚描述符数组。需要注意的是,控制器方将每个引脚/垫片表示为struct pinctrl_pin_desc的实例,定义如下:

    struct pinctrl_pin_desc {
         unsigned number;
         const char *name;
    [...]
    };
    

在上述数据结构中,number表示来自引脚控制器全局引脚号空间的唯一引脚编号,name是该引脚的名称。

  • npins: pins数组中的描述符数量,通常通过ARRAY_SIZE()pins字段中获得。

  • pctlops存储引脚控制操作表,用于支持引脚分组等全局概念。这是可选的。

  • pmxops表示如果您的驱动程序支持引脚多路复用,则为pinmux操作表。

  • confops: 如果您的驱动程序支持引脚配置,这是引脚配置操作表。

一旦适当的回调被定义,并且此数据结构已经初始化,它可以被传递给devm_pinctrl_register(),定义如下:

struct pinctrl_dev *devm_pinctrl_register(
                      struct device *dev,
                      struct pinctrl_desc *pctldesc,
                      void *driver_data);

上述函数将把引脚控制器注册到系统,并同时返回一个指向struct pinctrl_dev实例的指针,表示引脚控制器设备,作为参数传递给引脚控制器驱动暴露的绝大多数(如果不是所有)回调操作。出现错误时,函数返回一个错误指针,可以通过PTR_ERR进行处理。

控制器的控制、多路复用和配置操作表应根据底层硬件支持的特性进行设置。它们各自的数据结构在头文件中定义,这些头文件必须包含在驱动程序中,具体如下:

#include <linux/pinctrl/pinconf.h>
#include <linux/pinctrl/pinconf-generic.h>
#include <linux/pinctrl/pinctrl.h>
#include <linux/pinctrl/pinmux.h>

当涉及到引脚控制消费者接口时,必须使用以下头文件:

#include <linux/pinctrl/consumer.h>

在消费者驱动程序访问引脚之前,必须将引脚分配给需要控制它们的设备。推荐的引脚分配方式是通过设备树DT)。引脚组如何在设备树中分配,密切依赖于平台,因此也与引脚控制器驱动程序及其绑定有关。

每个引脚控制状态都会分配一个连续的整数 ID,从 0 开始。可以使用name属性列表将字符串映射到这些 ID 上,以确保相同的名称始终指向相同的 ID。不言而喻,必须在每个设备的设备树节点(DT 节点)中定义的状态集由该设备的绑定决定。这个绑定还决定是否定义必须提供的状态 ID 集,或者是否定义必须提供的状态名称集。在任何情况下,都可以使用两个属性将引脚配置节点分配给设备:

  • pinctrl-<ID>:此项允许为设备的特定状态提供所需的引脚配置列表。它是一个由<ID>标识的phandle列表,每个phandle指向一个引脚配置节点。这些引用的引脚配置节点必须是它们所属引脚控制器节点的子节点(或嵌套节点)。该属性可以接受多个条目,以便为特定设备状态配置和使用多个引脚组,同时允许指定来自不同引脚控制器的引脚。

  • pinctrl-names:此项允许根据设备拥有引脚组的状态为pinctrl-<ID>属性命名。列表条目 0 为 ID 为 0 的状态定义名称,条目 1 为 ID 为 1 的状态定义名称,以此类推。状态 ID 0 通常命名为default。标准化状态的列表可以在include/linux/pinctrl/pinctrl-state.h中找到。然而,客户端或消费者驱动程序可以根据需要实现任何状态,只要该状态在设备绑定描述中有所记录。

下面是设备树的摘录,展示了一些设备节点以及它们的引脚控制节点。我们将此摘录命名为pinctrl-excerpt

&usdhc4 {
[...]
     pinctrl-0 = <&pinctrl_usdhc4_1>;
     pinctrl-names = "default";
};
gpio-keys {
    compatible = "gpio-keys";
    pinctrl-names = "default";
    pinctrl-0 = <&pinctrl_io_foo &pinctrl_io_bar>;
};
iomuxc@020e0000 { /* Pin controller node */
    compatible = "fsl,imx6q-iomuxc";
    reg = <0x020e0000 0x4000>;
    /* shared pinctrl settings */
    usdhc4 { /* first node describing the function */
        pinctrl_usdhc4_1: usdhc4grp-1 { /* second node */
            fsl,pins = <
                MX6QDL_PAD_SD4_CMD__SD4_CMD    0x17059
                MX6QDL_PAD_SD4_CLK__SD4_CLK    0x10059
                MX6QDL_PAD_SD4_DAT0__SD4_DATA0 0x17059
                MX6QDL_PAD_SD4_DAT1__SD4_DATA1 0x17059
                MX6QDL_PAD_SD4_DAT2__SD4_DATA2 0x17059
                MX6QDL_PAD_SD4_DAT3__SD4_DATA3 0x17059
                [...]
            >;
        };
    };
    [...]
    uart3 {
        pinctrl_uart3_1: uart3grp-1 {
            fsl,pins = <
                MX6QDL_PAD_EIM_D24__UART3_TX_DATA 0x1b0b1
                MX6QDL_PAD_EIM_D25__UART3_RX_DATA 0x1b0b1
            >;
        };
    };
    // GPIOs (Inputs)
   gpios {
        pinctrl_io_foo: pinctrl_io_foo {
           fsl,pins = <
              MX6QDL_PAD_DISP0_DAT15__GPIO5_IO09  0x1f059
              MX6QDL_PAD_DISP0_DAT13__GPIO5_IO07  0x1f059
           >;
        };
        pinctrl_io_bar: pinctrl_io_bar {
           fsl,pins = <
              MX6QDL_PAD_DISP0_DAT11__GPIO5_IO05  0x1f059
              MX6QDL_PAD_DISP0_DAT9__GPIO4_IO30   0x1f059
              MX6QDL_PAD_DISP0_DAT7__GPIO4_IO28   0x1f059
           >;
        };
    };
};

在前面的例子中,引脚配置以<PIN_FUNCTION> <PIN_SETTING>的形式给出,其中<PIN_FUNCTION>可以看作是引脚功能或引脚模式,而<PIN_SETTING>表示引脚的电气特性:

MX6QDL_PAD_DISP0_DAT15__GPIO5_IO09 0x80000000

在这个摘录中,MX6QDL_PAD_DISP0_DAT15__GPIO5_IO09表示引脚功能/模式,在此情况下为 GPIO,而0x80000000表示引脚设置或电气特性。

让我们考虑一个新的例子,如下所示:

MX6QDL_PAD_EIM_D25__UART3_RX_DATA 0x1b0b1

在这个摘录中,MX6QDL_PAD_EIM_D25__UART3_RX_DATA表示引脚功能,是 UART3 的 RX 线路,而0x1b0b1表示其电气设置。

引脚功能是一个宏,其值对引脚控制器驱动程序有意义。通常,这些宏在arch/<arch>/boot/dts/目录下的头文件中定义。例如,如果使用的是 UDOO quad,它具有 i.MX6 四核(32 位 ARM),那么引脚功能头文件为arch/arm/boot/dts/imx6q-pinfunc.h。以下是 GPIO5 控制器第五行对应的宏:

#define MX6QDL_PAD_DISP0_DAT11__GPIO5_IO05 0x19c 0x4b0 0x000 0x5 0x0

<PIN_SETTING>可用于设置诸如上拉、下拉、保持器、驱动强度等功能。如何指定这些设置取决于引脚控制器绑定,且其值的含义依赖于 SoC 数据手册,通常在 IOMUX 部分中可以找到。在 i.MX6 IOMUXC 中,仅使用最低 17 位来实现此目的。

回到pinctrl-excerpt,在选择引脚组并应用其配置之前,驱动程序必须首先通过devm_inctrl_get()函数获取该引脚组的句柄,然后使用pinctrl_lookup_state()选择合适的状态,最后通过pinctrl_select_state()将相应的配置状态应用于硬件。

以下是一个示例,展示了如何抓取一个引脚控制组并应用其默认配置:

#include <linux/pinctrl/consumer.h>
int ret;
struct pinctrl_state *s;
struct pinctrl *p;
foo_probe()
{
    p = devm_pinctrl_get(dev);
    if (IS_ERR(p))
        return PTR_ERR(p);
    s = pinctrl_lookup_state(p, name);
    if (IS_ERR(s))
        return PTR_ERR(s);
    ret = pinctrl_select_state(p, s);
    if (ret < 0) // on error
        return ret;
[...]
}

与其他资源(如内存区域、时钟等)一样,从 probe() 函数中抓取引脚并应用其配置是一个良好的实践。然而,这一操作如此常见,以至于它已被集成到 Linux 设备核心中,作为设备探测过程中的一个步骤。因此,在设备探测时,设备核心会执行以下操作:

  • 使用 devm_pinctrl_get() 抓取即将被探测的设备分配的引脚。

  • 使用 pinctrl_lookup_state() 查找默认引脚状态(PINCTRL_STATE_DEFAULT)。

  • 同时使用相同的 API 查找初始化(即设备初始化期间)引脚状态(PINCTRL_STATE_INIT)。

  • 如果有初始化引脚状态,则应用它,否则应用默认引脚状态。

  • 如果启用了电源管理,则查找可选的休眠(PINCTRL_STATE_SLEEP)和空闲(PINCTRL_STATE_IDLE)引脚状态,以便在后续的电源管理相关操作中使用。

请参阅 pinctrl_bind_pins() 函数(定义在 drivers/base/pinctrl.c 中)和 really_probe() 函数(定义在 drivers/base/dd.c 中),后者调用了前者。这些函数将帮助您了解如何在设备探测过程中将引脚绑定到设备。

注意

pinctrl_select_state() 内部调用 pinmux_enable_setting(),后者又在引脚控制(引脚组)节点中的每个引脚上调用 pin_request()

pinctrl_put() 函数可用于释放通过非管理 API,即 pinctrl_get() 请求的引脚控制。也就是说,您可以使用 devm_pinctrl_get_select(),给定要选择的状态名称,以便一次性配置引脚复用。此函数在 include/linux/pinctrl/consumer.h 中定义,如下所示:

static struct pinctrl *devm_pinctrl_get_select(
                  struct device *dev, const char *name)

在前面的原型中,name 是在 pinctrl-name 属性中编写的状态名称。如果状态名称是 default,可以使用辅助函数 devm_pinctr_get_select_default(),它是 devm_pinctrl_get_select() 的一个封装,如下所示:

static struct pinctrl * pinctrl_get_select_default(
                                      struct device *dev)
{
   return pinctrl_get_select(dev, PINCTRL_STATE_DEFAULT);
}

现在我们已经熟悉了引脚控制子系统(包括控制器和消费者接口),我们可以学习如何处理 GPIO 控制器,了解 GPIO 是引脚可以工作的操作模式。

处理 GPIO 控制器接口

GPIO 控制器接口围绕一个单一的数据结构 struct gpio_chip 设计。这个数据结构提供了一组函数,其中包括设置 GPIO 方向(输入和输出)的方法、用于访问 GPIO 值(获取和设置)的方法、将特定 GPIO 映射到 IRQ 并返回相关 Linux 中断号的方法,以及 debugfs 转储方法(显示如拉高配置等附加状态)。除了这些函数外,该数据结构还提供了一个标志,用于确定控制器的性质,即允许检查该控制器的访问器是否可能进入休眠状态。在这个数据结构内,驱动程序还可以设置 GPIO 基础编号,从此编号开始 GPIO 编号。

回到代码,GPIO 控制器表示为 struct gpio_chip 的一个实例,该结构体在 <linux/gpio/driver.h> 中定义如下:

struct gpio_chip {
     const char       *label;
     struct gpio_device    *gpiodev;
     struct device         *parent;
     struct module         *owner;
     int        (*request)(struct gpio_chip *gc,
                           unsigned int offset);
     void       (*free)(struct gpio_chip *gc,
                           unsigned int offset);
     int       (*get_direction)(struct gpio_chip *gc,
                           unsigned int offset);
     int       (*direction_input)(struct gpio_chip *gc,
                           unsigned int offset);
     int       (*direction_output)(struct gpio_chip *gc,
                      unsigned int offset, int value);
     int       (*get)(struct gpio_chip *gc,
                           unsigned int offset);
     int       (*get_multiple)(struct gpio_chip *gc,
                           unsigned long *mask,
                           unsigned long *bits);
     void       (*set)(struct gpio_chip *gc,
                      unsigned int offset, int value);
     void       (*set_multiple)(struct gpio_chip *gc,
                           unsigned long *mask,
                           unsigned long *bits);
     int        (*set_config)(struct gpio_chip *gc,
                            unsigned int offset,
                            unsigned long config);
     int        (*to_irq)(struct gpio_chip *gc,
                           unsigned int offset);
     int       (*init_valid_mask)(struct gpio_chip *gc,
                              unsigned long *valid_mask,
                              unsigned int ngpios);
     int       (*add_pin_ranges)(struct gpio_chip *gc);
     int        base;
     u16        ngpio;
     const char *const *names;
     bool       can_sleep;
#if IS_ENABLED(CONFIG_GPIO_GENERIC)
     unsigned long (*read_reg)(void __iomem *reg);
     void (*write_reg)(void __iomem *reg, unsigned long data);
     bool be_bits;
     void __iomem *reg_dat;
     void __iomem *reg_set;
     void __iomem *reg_clr;
     void __iomem *reg_dir_out;
     void __iomem *reg_dir_in;
     bool bgpio_dir_unreadable;
     int bgpio_bits;
     spinlock_t bgpio_lock;
     unsigned long bgpio_data;
     unsigned long bgpio_dir;
#endif /* CONFIG_GPIO_GENERIC */
#ifdef CONFIG_GPIOLIB_IRQCHIP
     struct gpio_irq_chip irq;
#endif /* CONFIG_GPIOLIB_IRQCHIP */
     unsigned long *valid_mask;
#if defined(CONFIG_OF_GPIO)
     struct device_node *of_node;
     unsigned int of_gpio_n_cells;
     int (*of_xlate)(struct gpio_chip *gc,
                const struct of_phandle_args *gpiospec,
                u32 *flags);
#endif /* CONFIG_OF_GPIO */
};

以下是结构体中各个元素的含义:

  • label:这是 GPIO 控制器的功能名称。它可以是一个零件编号,或者是实现该功能的 SoC IP 块的名称。

  • gpiodev:这是 GPIO 控制器的内部状态容器。它也是通过该结构体暴露与该 GPIO 控制器相关联的字符设备。

  • request 是一个可选的挂钩,用于芯片特定的激活。如果提供,它会在每次调用 gpio_request()gpiod_get() 时,在分配 GPIO 之前执行。

  • free 是一个可选的挂钩,用于芯片特定的停用。如果提供,它会在你调用 gpiod_put()gpio_free() 时,在 GPIO 被释放之前执行。

  • get_direction 每当你需要知道 GPIO 偏移量的方向时执行。返回值应为 0 表示输出,1 表示输入(与 GPIOF_DIR_XXX 相同),或者返回负错误码。

  • direction_input 将信号偏移量配置为输入,或者返回一个错误。

  • get 返回 GPIO 偏移量的值;对于输出信号,这返回的是实际感测到的值,或者返回零。

  • set 将输出值分配给 GPIO 偏移量。

  • set_multiple 在需要为由 mask 定义的多个信号分配输出值时调用。如果未提供,内核将安装一个通用挂钩,遍历掩码位,并在每个设置的位上执行 chip->set(i)。请参见这里如何实现此功能:

     static void gpio_chip_set_multiple(
                        struct gpio_chip *chip,
                        unsigned long *mask,
                        unsigned long *bits)
    {
        if (chip->set_multiple) {
            chip->set_multiple(chip, mask, bits);
        } else {
            unsigned int i;
            /*
             * set outputs if the corresponding
             * mask bit is set
             */
            for_each_set_bit(i, mask, chip->ngpio)
                chip->set(chip, i, test_bit(i, bits));
            }
    }
    
  • set_debounce 如果控制器支持,此挂钩是一个可选的回调函数,用于设置指定 GPIO 的去抖动时间。

  • to_irq 是一个可选的挂钩,用于提供 GPIO 到 IRQ 的映射。每当你想执行 gpio_to_irq()gpiod_to_irq() 函数时,它都会被调用。此实现可能不会进入休眠状态。

  • base 指示该芯片处理的第一个 GPIO 编号;或者,如果在注册时为负数,内核将自动(动态)分配一个编号。

  • ngpio 是该控制器提供的 GPIO 数量,从 base 开始到(base + ngpio - 1)。

  • names,如果不是 NULL,必须是一个字符串数组,用作此芯片中 GPIO 的替代名称。该数组必须与 ngpio 的大小一致,任何不需要别名的 GPIO 可以在数组中将其条目设置为 NULL

  • can_sleep 是一个布尔标志,表示 get()/set() 方法是否可能会使系统进入休眠状态。这对于那些位于 I2C 或 SPI 总线上的 GPIO 控制器(也称为 GPIO 扩展器)来说是必需的,因为它们的访问可能会导致休眠。这意味着,如果芯片支持 IRQ,则这些 IRQ 必须使用线程处理,因为芯片访问可能会在读取 IRQ 状态寄存器时导致休眠。对于映射到内存的 GPIO 控制器(SoC 的一部分),此值可以设置为 false

  • CONFIG_GPIO_GENERIC 启用的元素与通用内存映射的 GPIO 控制器有关,具有标准寄存器集。

  • irq:如果该 GPIO 控制器可以将 GPIO 映射到 IRQ,则为该控制器的 IRQ 芯片。在这种情况下,必须在注册 GPIO 控制器之前设置此字段。

  • valid_mask:如果不是 NULL,则此元素包含一个 GPIO 的位掩码,表示该芯片中哪些 GPIO 是有效的。

  • of_node 是指向表示该 GPIO 控制器的设备树节点的指针。

  • of_gpio_n_cells 是用于形成 GPIO 说明符的单元数量。

  • of_xlate 是一个回调函数,用于将设备树中的 GPIO 说明符转换为与芯片相关的 GPIO 编号和标志。当设备树中指定此控制器的 GPIO 引脚时,将调用此钩子进行解析。如果未提供此回调,GPIO 核心将默认设置为 of_gpio_simple_xlate(),这是一个通用的 GPIO 核心助手,支持两单元说明符。第一个单元表示 GPIO 编号,第二个单元表示 GPIO 标志。此外,在设置默认回调时,of_gpio_n_cells 将设置为 2。如果 GPIO 控制器需要一个或多个单元说明符,则必须实现相应的翻译回调。

每个 GPIO 控制器都会暴露多个信号,这些信号在函数调用中通过 0 到 (ngpio - 1) 范围内的偏移值来标识。当通过 gpio_get_value(gpio) 等调用引用这些 GPIO 引脚时,偏移量由将 base 从 GPIO 编号中减去得到,并传递给底层驱动程序函数(例如 gpio_chip->get())。然后,控制器驱动程序应具有将此偏移量映射到与 GPIO 相关的控制/状态寄存器的逻辑。

编写 GPIO 控制器驱动程序

一个 GPIO 控制器只需要与其支持的功能相对应的一组回调。定义完所有感兴趣的回调并设置其他字段后,驱动程序应在配置的struct gpio_chip结构上调用devm_gpiochip_add_data(),以便将控制器注册到内核中。你可能已经猜到,最好使用托管 API(以devm_为前缀),因为它会在必要时处理芯片移除和资源释放。如果你使用了传统方法,则必须使用gpiochip_remove()在必要时移除芯片:

int gpiochip_add_data(struct gpio_chip *gc, void *data)
int devm_gpiochip_add_data(struct device *dev,
                        struct gpio_chip *gc, void *data)

在上述原型中,gc是要注册的芯片,data是与该芯片相关的驱动程序私有数据。如果芯片无法注册(例如,gc->base无效或已与其他芯片关联),它们将返回一个负的错误代码。否则,它们将返回零作为成功代码。

然而,也有一些引脚控制器与 GPIO 芯片紧密耦合,二者在同一个驱动程序中实现,很多代码都在drivers/pinctrl/pinctrl-*.c中。在这样的驱动程序中,当调用gpiochip_add_data()时,对于支持设备树的系统,GPIO 核心将检查引脚控制的设备节点是否具有gpio-ranges属性。如果存在,它将负责为驱动程序添加引脚范围。

然而,驱动程序必须调用gpiochip_add_pin_range(),以便与不设置gpio-ranges属性的旧设备树文件或使用 ACPI 的系统兼容。以下是一个示例:

if (!of_find_property(np, "gpio-ranges", NULL)) {
     ret = gpiochip_add_pin_range(chip,
                   dev_name(hw->dev), 0, 0, chip->ngpio);
     if (ret < 0) {
           gpiochip_remove(chip);
           return ret;
     }
}

再次强调,前述内容仅用于向后兼容那些没有gpio-ranges属性的旧pinctrl节点。否则,从支持设备树的引脚控制器驱动程序直接调用gpiochip_add_pin_range()已被弃用。请参阅Documentation/devicetree/bindings/gpio/gpio.txt中的第 2.1 节,了解如何通过gpio-ranges属性绑定引脚控制器和 GPIO 驱动程序。

我们可以看到编写一个 GPIO 控制器驱动程序是多么容易。在本书的源码仓库中,你将找到一个功能正常的 GPIO 控制器驱动程序,适用于来自 Microchip 的 MCP23016 I2C I/O 扩展器,其数据手册可以在ww1.microchip.com/downloads/en/DeviceDoc/20090C.pdf找到。

要编写此类驱动程序,应该包含以下头文件:

#include <linux/gpio.h> 

以下是我们为控制器编写的驱动程序的摘录:

#define GPIO_NUM 16
struct mcp23016 {
    struct i2c_client *client;
    struct gpio_chip gpiochip;
    struct mutex lock;
};
static int mcp23016_probe(struct i2c_client *client)
{
  struct mcp23016 *mcp;

  if (!i2c_check_functionality(client->adapter,
      I2C_FUNC_SMBUS_BYTE_DATA))
    return -EIO;
  mcp = devm_kzalloc(&client->dev, sizeof(*mcp),
                      GFP_KERNEL);
  if (!mcp)
    return -ENOMEM;
  mcp->gpiochip.label = client->name;
  mcp->gpiochip.base = -1;
  mcp->gpiochip.dev = &client->dev;
  mcp->gpiochip.owner = THIS_MODULE;
  mcp->gpiochip.ngpio = GPIO_NUM; /* 16 */
  /* may not be accessed from atomic context */
  mcp->gpiochip.can_sleep = 1; 
  mcp->gpiochip.get = mcp23016_get_value;
  mcp->gpiochip.set = mcp23016_set_value;
  mcp->gpiochip.direction_output =
                          mcp23016_direction_output;
  mcp->gpiochip.direction_input =
                          mcp23016_direction_input;
  mcp->client = client;
  i2c_set_clientdata(client, mcp);
  return devm_gpiochip_add_data(&client->dev,
                                &mcp->gpiochip, mcp);
}

在上述摘录中,GPIO 芯片数据结构在传递给devm_gpiochip_get_data()之前已经设置好,该函数用于将 GPIO 控制器注册到系统中。结果,GPIO 字符设备节点将出现在/dev下。

启用 IRQ 芯片的 GPIO 控制器

可以通过设置嵌入到 GPIO 控制器数据结构中的 struct gpio_irq_chip 结构来启用 IRQ 芯片支持。这个 struct gpio_irq_chip 结构用于将与 GPIO 芯片的中断处理相关的所有字段进行分组,定义如下:

struct gpio_irq_chip {
     struct irq_chip *chip;
     struct irq_domain *domain;
     const struct irq_domain_ops *domain_ops;
     irq_flow_handler_t handler;
     unsigned int default_type;
     irq_flow_handler_t parent_handler;
     union {
          void *parent_handler_data;
          void **parent_handler_data_array;
     };
     unsigned int num_parents;
     unsigned int *parents;
     unsigned int *map;
     bool threaded;
     bool per_parent_data;
     int (*init_hw)(struct gpio_chip *gc);
     void (*init_valid_mask)(struct gpio_chip *gc,
                      unsigned long *valid_mask,
                      unsigned int ngpios);
     unsigned long *valid_mask;
     unsigned int first;
     void       (*irq_enable)(struct irq_data *data);
     void       (*irq_disable)(struct irq_data *data);
     void       (*irq_unmask)(struct irq_data *data);
     void       (*irq_mask)(struct irq_data *data);
};

有一些架构可能涉及多个中断控制器,负责将设备的中断传递到目标 CPU。这个功能可以通过设置内核中的 CONFIG_IRQ_DOMAIN_HIERARCHY 配置选项来启用。

在前面的数据结构中,一些元素被省略了。这些是由 CONFIG_IRQ_DOMAIN_HIERARCHY 条件控制的元素,即 IRQ 域层次结构相关的字段,本章不会讨论这些内容。对于剩余的元素,以下是它们的定义:

  • chip 是 IRQ 芯片的实现。

  • domain 是与 chip 相关的 IRQ 中断转换域;它负责在 GPIO 硬件 IRQ 编号和 Linux IRQ 编号之间进行映射。

  • domain_ops 表示与 IRQ 域相关联的一组中断域操作。

  • handler 是 GPIO IRQ 的高级中断流处理程序(通常是预定义的 IRQ 核心函数)。本节稍后会对该字段进行说明。

  • default_type 是在 GPIO 驱动程序初始化期间应用的默认 IRQ 触发类型。

  • parent_handler 是 GPIO 芯片父中断的中断处理程序。如果父中断是嵌套的而不是链式的,这个字段可能为 NULL。此外,将此元素设置为 NULL 将允许在驱动程序中处理父 IRQ。如果提供了该处理程序,则 gpio_chip.can_sleep 不能设置为 true,因为无法在可能休眠的芯片上处理链式中断。

  • parent_handler_dataparent_handler_data_array 是与父中断关联的数据,并传递给父中断的处理程序。如果 per_parent_datafalse,则它可以是一个单一的指针;如果为 true,则是一个包含 num_parents 指针的数组。若 per_parent_datatrue,则 parent_handler_data_array 不能为 NULL

  • num_parents 是 GPIO 芯片的中断父级数量。

  • parents 是 GPIO 芯片的中断父级列表。由于驱动程序拥有这个列表,核心只会引用它,而不会修改它。

  • map 是每个 GPIO 芯片引脚的中断父级列表。

  • threaded 表示中断处理是否是线程化的(使用嵌套线程)。

  • per_parent_data 指示 parent_handler_data_array 是否描述一个 num_parents 大小的数组,用作父数据。

  • init_hw 是一个可选的例程,用于在添加 IRQ 芯片之前初始化硬件。当驱动程序需要清除与 IRQ 相关的寄存器,以避免不必要的事件时,这非常有用。

  • init_valid_mask是一个可选的回调函数,可以用于初始化valid_mask,如果并非所有 GPIO 线路都可以作为有效的中断源,则使用此回调。可能存在一些无法触发中断的线路,当定义此回调时,它会接收到valid_mask中的位图,该位图会将ngpios个比特位(从0..(ngpios-1))设置为1,表示有效。回调函数可以直接将一些位设置为0,如果这些线路不能用于中断。

  • valid_mask 如果不是 NULL,则包含一个 GPIO 位掩码,表示哪些 GPIO 是有效的,可以包含在芯片的 IRQ 域中。

  • first 在静态 IRQ 分配的情况下是必需的。如果设置了此选项,irq_domain_add_simple()将在初始化期间分配(从该值开始)并映射所有 IRQ。

  • irq_enableirq_disableirq_unmaskirq_mask 分别存储了旧的 irq_chip.irq_enableirq_chip.irq_disableirq_chip.irq_unmaskirq_chip.irq_mask 回调函数。详细说明请参见 第十三章揭开内核 IRQ 框架的神秘面纱

在假设你的中断是 1 对 1 映射到 GPIO 线路索引的前提下,gpiolib将处理大量的开销代码。在这种 1 对 1 映射中,GPIO 线路偏移量 0 映射到硬件 IRQ 0,GPIO 线路偏移量1映射到硬件 IRQ 1,以此类推,直到 GPIO 线路偏移量ngpio-1,它映射到硬件 IRQ ngpio-1。位掩码valid_maskgpio_irq_chip中的need_valid_mask标志可以用来屏蔽掉一些无效的 GPIO 线路,防止它们与 IRQ 关联,前提是某些 GPIO 线路没有对应的 IRQ。

我们可以将 GPIO IRQ 芯片分为两大类:

  • 级联中断芯片:这表示 GPIO 芯片具有一条公共的中断输出线路,该线路会被该芯片上任何启用的 GPIO 线路触发。该中断输出线路随后会被路由到上一级的父中断控制器,在最简单的情况下就是系统的主中断控制器。IRQ 芯片通过检查 GPIO 控制器内部的位来实现此功能,以确定是哪一条线路触发了中断。为了弄清楚这一点,驱动程序中的 IRQ 芯片部分需要检查寄存器,几乎肯定需要通过清除一些位来确认它正在处理该中断(有时通过简单地读取状态寄存器来隐式完成),并且还需要设置诸如边缘敏感性(例如上升沿、下降沿或高/低电平中断等)等配置。

  • 层次化中断芯片:这意味着每条 GPIO 线路通过一条专用的 IRQ 线路连接到上一级的父中断控制器。无需查询 GPIO 硬件即可确定哪条线路触发了中断,但你可能需要确认中断并配置边缘敏感性。

级联 GPIO IRQ 芯片通常可以分为以下三类:

  • 链式级联 GPIO IRQ 芯片:这类芯片通常出现在 SoC 上。也就是说,GPIO 有一个快速的 IRQ 流量处理程序,该处理程序会在父 IRQ 处理程序(通常是系统中断控制器)的链式调用下被调用。这意味着父 IRQ 芯片会立即调用 GPIO IRQ 芯片的处理程序,同时禁用 IRQ。在其中断处理程序中,GPIO IRQ 芯片将调用类似如下的内容:

      static irqreturn_t foo_gpio_irq(int irq, void *data)
          chained_irq_enter(...);
          generic_handle_irq(...);
          chained_irq_exit(...);
    

由于一切都发生在回调中,链式 GPIO IRQ 芯片无法将 .can_sleep 标志设置为 true。因此,不能使用像 I2C 这样的慢速总线流量。

  • 链式 GPIO IRQCHIPS,但它们不使用链式 IRQ 处理程序。GPIO IRQ 会通过通用的 IRQ 处理程序进行分发,该处理程序是通过 request_irq() 指定的。在这个 IRQ 处理程序中,GPIO IRQ 芯片最终会调用类似以下的代码:

    static irqreturn_t gpio_rcar_irq_handler(int irq,
                                        void *dev_id)
        /* go through the entire GPIOs and handle
         * all interrupts
         */
        for each detected GPIO IRQ
            generic_handle_irq(...);
    
  • 嵌套线程 GPIO IRQ 芯片:离芯片的 GPIO 扩展器以及任何其他处于睡眠总线上的 GPIO IRQ 芯片,如 I2C 或 SPI,均属于此类别。

当然,这类驱动程序需要通过缓慢的总线流量来读取 IRQ 状态和其他信息,这些流量可能会导致进一步的 IRQ,因此不能在禁用 IRQ 的快速 IRQ 处理程序中处理。相反,它们必须创建一个线程,并在中断被驱动程序处理之前屏蔽父 IRQ 线。此类驱动程序的显著特点是它在中断处理程序中会调用类似以下的代码:

static irqreturn_t pcf857x_irq(int irq,
                               void *data)
{
     struct pcf857x *gpio = data;
     unsigned long change, i, status;
     status = gpio->read(gpio->client);
     mutex_lock(&gpio->lock);
     change = (gpio->status ^ status) &
              gpio->irq_enabled;
     gpio->status = status;
     mutex_unlock(&gpio->lock);
     for_each_set_bit(i, &change, gpio->chip.ngpio)
      child_irq = irq_find_mapping(
                     gpio->chip.irq.domain, i);
        handle_nested_irq(child_irq);
     return IRQ_HANDLED;
}

线程化 GPIO IRQ 芯片的特点在于,它们将 .can_sleep 标志设置为 true,表示在访问 GPIO 时该芯片可以进入休眠状态。

注意

值得回顾的是,gpio_irq_chip.handler 是中断流量处理程序。它是高级的 IRQ 事件处理程序,用于调用客户端驱动程序通过 request_irq()request_threaded_irq() 注册的底层处理程序。其值取决于 IRQ 是否是边缘触发或电平触发。它通常是一个预定义的 IRQ 核心函数,介于 handle_simple_irqhandle_edge_irqhandle_level_irq 之间。这些都是内核助手函数,它们在调用真正的 IRQ 处理程序之前和之后执行一些操作。

当父中断处理程序调用 generic_handle_irq()handle_nested_irq() 时,中断核心将查找与传递的 Linux IRQ 编号对应的 IRQ 描述符结构(Linux 对中断的视图,struct irq_desc *desc = irq_to_desc(irq)),并在该描述符上调用 generic_handle_irq_desc(),这将导致 desc->handle_irq(desc)。你应该注意,desc->handle_irq 对应的是之前提供的高级 IRQ 处理程序,该处理程序在通过 irq_set_chip_and_handler() 映射此 IRQ 时已分配给 IRQ 描述符。猜猜看,这些 GPIO IRQ 的映射是通过 gpiochip_irq_map 完成的,这是默认 IRQ 域操作表(gpiochip_domain_ops)的 .map 回调,如果驱动程序未提供,它将由 GPIO 核心分配给 GPIO IRQ 芯片。

总结一下,desc->handle_irq = gpio_irq_chip.handler,它可能是 handle_level_irqhandle_simple_irqhandle_edge_irq 或(很少)由驱动程序提供的函数。

在 GPIO 芯片中添加 IRQ 芯片支持的示例

在本节中,我们将展示如何将 IRQ 芯片的支持添加到 GPIO 控制器驱动程序中。为此,我们将更新我们最初的驱动程序,更具体地说,是更新探测方法,并实现一个中断处理程序,该处理程序将包含 IRQ 处理逻辑。

让我们考虑以下图示:

图 16.1 – 中断复用

图 16.1 – 中断复用

在之前的图示中,假设我们已经将 io_0io_1 配置为中断线(这是 DeviceAdeviceB 所看到的)。

无论中断是否发生在 io_0io_1,相同的父中断线将在 GPIO 芯片上被触发。在此步骤中,GPIO 芯片驱动程序必须通过读取 GPIO 控制器的 GPIO 状态寄存器,找出哪个 GPIO 线路(io_0io_1)真正触发了中断。就像在 MCP23016 芯片的案例中一样,单一的中断线(实际是父中断线)可以用于复用 16 个 GPIO 中断。

现在,让我们更新最初的 GPIO 控制器驱动程序。必须注意的是,由于该设备位于一个较慢的总线上,我们别无选择,只能实现嵌套(线程化)中断流处理。

我们从定义 IRQ 芯片数据结构开始,其中包含 IRQ 核心可以使用的一组回调函数。以下是摘录:

static struct irq_chip mcp23016_irq_chip = {
     .name = "gpio-mcp23016",
     .irq_mask = mcp23016_irq_mask,
     .irq_unmask = mcp23016_irq_unmask,
     .irq_set_type = mcp23016_irq_set_type,
};

在前述内容中,已定义的回调函数取决于需要。在我们的案例中,我们只实现了与中断(取消)屏蔽相关的回调函数,以及允许设置 IRQ 类型的回调函数。要查看 struct irq_chip 结构的完整描述,您可以参考 第十三章揭开内核 IRQ 框架的面纱

现在 IRQ 芯片数据结构已经设置完成,我们可以按照以下方式修改探测方法:

static int mcp23016_probe(struct i2c_client *client)
{
    struct gpio_irq_chip *girq;
    struct irq_chip *irqc;
[...]
    girq = &mcp->gpiochip.irq;
    girq->chip = &mcp23016_irq_chip;
    /* This will let us handling the parent IRQ in the driver */
    girq->parent_handler = NULL;
    girq->num_parents = 0;
    girq->parents = NULL;
    girq->default_type = IRQ_TYPE_NONE;
    girq->handler = handle_level_irq;
    girq->threaded = true;
[...]
    /*
     * Directly request the irq here instead of passing
     * a flow-handler.
     */
    err = devm_request_threaded_irq(
                      &client->dev,
                      client->irq,
                      NULL, mcp23016_irq,
                      IRQF_TRIGGER_RISING | IRQF_ONESHOT,
                      dev_name(&i2c->dev), mcp);
[...]

    return devm_gpiochip_add_data(&client->dev,
                                &mcp->gpiochip, mcp);
}

在之前的探测方法更新中,我们首先初始化了嵌入到struct gpio_chip中的struct gpio_irq_chip数据结构,然后注册了一个 IRQ 处理程序,该处理程序作为父级 IRQ 处理程序,负责查询底层 GPIO 芯片,检查任何启用了 IRQ 的 GPIO 是否发生变化,并在有变化时运行其 IRQ 处理程序。

最后,以下是我们的 IRQ 处理程序,必须在probe函数之前实现:

static irqreturn_t mcp23016_irq(int irq, void *data)
{
    struct mcp23016 *mcp = data;
    unsigned long status, changes, child_irq, i;
    status = read_gpio_status(mcp);
    mutex_lock(&mcp->lock);
    change = mcp->status ^ status;
    mcp->status = status;
    mutex_unlock(&mcp->lock);
    /* Do some stuff, may be adapting "change" according to level */
    [...]
    for_each_set_bit(i, &change, mcp->gpiochip.ngpio) {
        child_irq =
           irq_find_mapping(mcp->gpiochip.irq.domain, i);
        handle_nested_irq(child_irq);
    }
    return IRQ_HANDLED;
}

在我们的中断处理程序中,我们简单地读取当前 GPIO,并将其与旧状态进行比较,以确定发生变化的 GPIO。所有的技巧都由handle_nested_irq()处理,具体说明请参见第十三章揭秘内核 IRQ 框架

现在我们完成了并且熟悉了 GPIO 控制器驱动程序中 IRQ 芯片的实现,我们可以学习这些 GPIO 控制器的绑定,这将允许在设备树中声明 GPIO 芯片硬件,驱动程序可以理解。

GPIO 控制器绑定

设备树是嵌入式平台上声明和描述设备的事实标准,特别是在 ARM 架构中。因此,建议新的驱动程序提供相关的设备绑定。

回到 GPIO 控制器,有一些强制性的属性需要提供:

  • compatible:这是一个字符串列表,用于匹配将处理该设备的驱动程序。

  • gpio-controller:这是一个属性,表示此节点代表一个 GPIO 控制器,告知设备树核心。

  • gpio-cells:表示用于描述 GPIO 说明符的单元数。它应与gpio_chip.of_gpio_n_cells对应,或者与gpio_chip.of_xlate可以处理的值相匹配。对于一个不太复杂的控制器,通常是<2>,第一个单元表示 GPIO 编号,第二个单元定义标志位。

如果 GPIO 控制器支持 IRQ 芯片功能,则还需要定义额外的强制性属性,即该控制器是否允许将其 GPIO 线路映射到 IRQ。对于这样的 GPIO 控制器,必须提供以下强制性属性:

  • interrupt-controller:某些控制器提供映射到 GPIO 的 IRQ。在这种情况下,属性#interrupt-cells也应该设置,通常使用2,但这取决于您的需求。第一个单元是引脚编号,第二个单元表示中断标志。

  • #interrupt-cells:必须定义为 IRQ 域的xlate钩子支持的值,即irq_domain_ops.xlate。通常,irq_domain_xlate_twocell会作为这个钩子的设置,这是一个通用的内核 IRQ 核心辅助程序,能够处理一个二元单元说明符。

从列出的属性中,我们可以在其总线节点下声明 GPIO 控制器,如下所示:

&i2c1
    expander: mcp23016@20 {
        compatible = "microchip,mcp23016";
        reg = <0x20>;
        gpio-controller;
        #gpio-cells = <2>;
        interrupt-controller;
        #interrupt-cells = <2>;
        interrupt-parent = <&gpio4>;
        interrupts = <29 IRQ_TYPE_EDGE_FALLING>;
    };
};

这就是控制器端的所有内容。为了展示客户端如何使用 MCP23016 提供的资源,假设我们有两个设备,设备 A,名为foo,设备 B,名为barbar设备从我们的控制器中消耗了两个 GPIO 线(它们将用于输出模式),而foo设备希望将一个 GPIO 映射到 IRQ。这个配置可以在设备树中声明如下:

parent_node {
    compatible = "simple-bus";
    foo_device: foo_device@1c {
        [...]
        reg = <0x1c>;
        interrupt-parent = <&expander>;
        interrupts = <2 IRQ_TYPE_EDGE_RISING>;
    };
    bar_device {
        [...]
        reset-gpios = <&expander 8 GPIO_ACTIVE_HIGH>;
        power-gpios = <&expander 12 GPIO_ACTIVE_HIGH>;
        [...]
    };
};

在前面的摘录中,parent_node被赋予了simple-bus作为compatible字符串,以指示设备树核心和平台核心实例化两个平台设备,它们对应我们的节点。在该摘录中,我们还展示了如何指定 GPIO 以及来自我们控制器的 IRQ。每个属性使用的单元数与控制器绑定中的声明相匹配。

GPIO 与针脚控制器的交互

这两个子系统是紧密相关的。针脚控制器可以将 GPIO 控制器提供的部分或所有 GPIO 路由到封装上的引脚。这允许这些引脚在 GPIO 和其他功能之间进行复用(也称为引脚复用)。

然后可能需要了解哪些 GPIO 与哪些针脚控制器上的引脚对应。gpio-ranges属性,下面将描述,表示通过一组离散的范围,映射从针脚控制器本地编号空间到 GPIO 控制器本地编号空间的引脚。

gpio-ranges格式如下:

<[pin controller phandle], [GPIO controller offset], [pin controller offset], [number of pins]>;

GPIO 控制器的偏移量指的是包含范围定义的 GPIO 控制器节点。必须遵循Documentation/pinctrl/pinctrl-bindings.txt中定义的绑定规则,这些绑定规则由phandle引用的针脚控制器节点来实现。

每个偏移量是一个介于 0 和 N 之间的数字。如果范围构造得当,理论上可以将任意数量的范围叠加起来,仅需一个引脚到 GPIO 线的映射,但在实际中,这些范围通常作为离散集一起收集。

以下是一个例子:

gpio-ranges = <&foo 0 20 10>, <&bar 10 50 20>;

这意味着以下内容:

  • 针脚控制器foo上的十个引脚(20..29)被映射到 GPIO 线0..9

  • 针脚控制器bar上的二十个引脚(50..69)被映射到 GPIO 线10..29

必须注意,GPIO 有一个全局编号空间,而针脚控制器有一个本地编号空间,因此我们需要定义一种方法来交叉引用它们。

我们希望将引脚GPIO_5_29与针脚控制器编号空间中的引脚编号89进行映射。以下是定义 GPIO 与引脚控制子系统之间映射的设备树属性:

&gpio5 {
    gpio-ranges = <&pinctrl 29 89 1> ;
}

在前面的摘录中,来自 GPIO 银行 5 的第 29 条 GPIO 线的 1 条 GPIO 线将被映射到针脚控制器pinctrl上从 89 开始的引脚范围。

为了在实际平台上说明这一点,假设我们有 i.MX6 SoC,它每个银行有 32 个 GPIO。以下是来自 i.MX6 SoC 的针脚控制器节点摘录,声明在arch/arm/boot/dts/imx6qdl.dtsi中,其驱动程序是drivers/pinctrl/freescale/pinctrl-imx6dl.c

iomuxc: pinctrl@20e0000 {
    compatible = "fsl,imx6dl-iomuxc", "fsl,imx6q-iomuxc";
    reg = <0x20e0000 0x4000>;
};

现在引脚控制器已声明,在相同的基础设备树arch/arm/boot/dts/imx6qdl.dtsi中声明了一个 GPIO 控制器(bank3),如下所示:

gpio3: gpio@20a4000 {
    compatible = "fsl,imx6q-gpio", "fsl,imx35-gpio";
    reg = <0x020a4000 0x4000>;
    interrupts = <0 70 IRQ_TYPE_LEVEL_HIGH>,
                 <0 71 IRQ_TYPE_LEVEL_HIGH>;
    gpio-controller;
    #gpio-cells = <2>;
    interrupt-controller;
    #interrupt-cells = <2>;
};

供参考,这个 GPIO 控制器的驱动程序是drivers/gpio/gpio-mxc.c。在基础设备树中声明 GPIO 控制器节点后,类似的 GPIO 控制器节点将在 SoC 特定的基础设备树arch/arm/boot/dts/imx6q.dtsi中被覆盖,如下所示:

&gpio3 {
     gpio-ranges = <&iomuxc 0 69 16>, <&iomuxc 16 36 8>,
                  <&iomuxc 24 45 8>;
};

上述对 GPIO 控制器节点的覆盖意味着以下内容:

  • <&iomuxc 0 69 16>表示,从引脚控制器iomuxc上的引脚 69(到 84)开始的 16 个引脚被映射到从索引 0(到 15)开始的 GPIO 线路。

  • <&iomuxc 16 36 8>表示,从引脚控制器iomuxc上的引脚 36(到 43)开始的 8 个引脚被映射到从索引 16(到 23)开始的 GPIO 线路。

  • <&iomuxc 24 45 8>表示,从引脚控制器iomuxc上的引脚 45(到 52)开始的 8 个引脚被映射到从索引 24(到 31)开始的 GPIO 线路。

如预期的那样,我们有一个 32 线的 GPIO 银行,16 + 8 + 8

目前,我们能够理解现有的 GPIO 控制器,并实例化与一个或多个引脚控制器交互的 GPIO 控制器。从 GPIO 控制器绑定学习曲线的最后一步开始,让我们学习如何占用 GPIO,以避免编写特定的驱动程序(或防止使用现有的驱动程序)来控制它们。

引脚占用(Pin hogging)

GPIO 芯片可以包含 GPIO hog 定义。GPIO 占用是一种机制,提供自动 GPIO 请求和配置,作为 GPIO 控制器驱动程序探测功能的一部分。这意味着,一旦引脚控制设备注册,GPIO 核心将尝试在其上调用devm_pinctrl_get()lookup_state()select_state()

以下是每个 GPIO hog 定义所需的属性,这些属性表示为 GPIO 控制器的子节点:

  • gpio-hog:一个属性,指示该子节点是否代表一个 GPIO hog。

  • gpios:包含每个 GPIO 的 GPIO 信息(ID、标志等)。将包含其父节点(GPIO 控制器节点)指定的单元数的整数倍。

  • 只能指定以下属性之一,按列出的顺序扫描。这意味着,当多个属性存在时,它们将按此顺序搜索,第一个匹配的将被视为预期配置:

    • input:一个属性,指定 GPIO 方向应设置为输入。

    • output-low:一个属性,指定 GPIO 应配置为输出,且初始状态为低。

    • output-high:一个属性,指定 GPIO 方向应设置为输出,并且初始值为高。

一个可选的属性是line-name:GPIO 标签名称。如果没有该属性,将使用节点名称。

以下是一个摘录,其中我们首先在引脚控制器节点中声明(作为 GPIO)我们感兴趣的引脚:

&iomuxc {
[...]
    pinctrl_gpio3_hog: gpio3hoggrp {
        fsl,pins = <
            MX6QDL_PAD_EIM_D19__GPIO3_IO19      0x1b0b0
            MX6QDL_PAD_EIM_D20__GPIO3_IO20      0x1b0b0
            MX6QDL_PAD_EIM_D22__GPIO3_IO22      0x1b0b0
            MX6QDL_PAD_EIM_D23__GPIO3_IO23      0x1b0b0
        >;
    };
[...]
}

在声明了相关引脚之后,我们可以在 GPIO 控制器所在节点下占用每个 GPIO,如下所示:

&gpio3 {
    pinctrl-names = "default";
    pinctrl-0 = <&pinctrl_gpio3_hog>;
    usb-emulation-hog {
        gpio-hog;
        gpios = <19 GPIO_ACTIVE_HIGH>;
        output-low;
        line-name = "usb-emulation";
    };
    usb-mode1-hog {
        gpio-hog;
        gpios = <20 GPIO_ACTIVE_HIGH>;
        output-high;
        line-name = "usb-mode1";
    };
    usb-pwr-hog {
        gpio-hog;
        gpios = <22 GPIO_ACTIVE_LOW>;
        output-high;
       line-name = "usb-pwr-ctrl-en-n";
    };
    usb-mode2-hog {
        gpio-hog;
        gpios = <23 GPIO_ACTIVE_HIGH>;
        output-high;
        line-name = "usb-mode2";
    };
};

必须注意,GPIO 占用应仅用于那些没有被特定驱动程序控制的引脚。

GPIO 占用是 GPIO 控制器端的最后一部分。现在控制器已经没有什么秘密可言,让我们切换到消费者接口。

最大限度地利用 GPIO 消费者接口

GPIO 是一种功能或模式,指的是引脚在硬件中的操作方式。它不过是一个数字线路,可以作为输入或输出,只有两个值(或状态):1 表示高,0 表示低。内核的 GPIO 子系统包含了你需要的所有功能,以便在驱动程序中设置和管理 GPIO 线路。

在驱动程序中使用 GPIO 之前,必须首先由内核声明它。这是一种控制 GPIO 并禁止其他驱动程序使用它的手段,同时也防止控制器驱动程序被卸载。

在声明 GPIO 的控制权后,可以执行以下操作:

  • 设置方向,并根据需要设置 GPIO 配置。

  • 如果作为输出使用,开始切换其输出状态(驱动线路为高或低)。

  • 如果作为输入使用,设置防抖时间间隔(如果需要)并读取状态。对于映射到 IRQ 的 GPIO 线路,配置中断应在何种边缘/电平触发,并注册一个处理程序,当中断发生时该处理程序将被执行。

在 Linux 内核中,有两种不同的方式来处理 GPIO:

  • 传统的、已弃用的基于整数的接口,使用整数来表示 GPIO。

  • 新的基于描述符的接口,其中 GPIO 由一个不透明的结构表示和描述,并提供专用的 API。这是推荐的方式。

虽然我们将在本章中讨论这两种方法,但我们从传统接口开始。

基于整数的 GPIO 接口——现在已被弃用

基于整数的接口是 Linux 系统中最常见的 GPIO 使用方式,无论是在内核中还是在用户空间中。在这种模式下,GPIO 由一个整数标识,该整数用于每个需要在此 GPIO 上执行的操作。以下是包含传统 GPIO 访问函数的头文件:

#include <linux/gpio.h>

基于整数的接口依赖于一组函数,定义如下:

bool gpio_is_valid(int number);
int  gpio_request(unsigned gpio, const char *label);
int  gpio_get_value_cansleep(unsigned gpio);
int  gpio_direction_input(unsigned gpio);
int  gpio_direction_output(unsigned gpio, int value);
void gpio_set_value(unsigned int gpio, int value);
int  gpio_get_value(unsigned gpio);
void gpio_set_value_cansleep(unsigned gpio, int value);
int gpio_get_value_cansleep(unsigned gpio);
void gpio_free(unsigned int gpio);

所有前述功能都映射到由 GPIO 控制器通过其struct gpio_chip结构提供的一组回调函数,正是通过这些回调函数,它暴露了一个通用的回调函数集。

在所有这些函数中,gpio表示我们感兴趣的 GPIO 编号。在使用 GPIO 之前,客户端驱动程序必须调用gpio_request()来获取该线的所有权,并且非常重要的是,防止此 GPIO 控制器驱动程序被卸载。在同一个函数中,label是内核用于标记/描述 GPIO 的标签,它会出现在/sys/kernel/debug/gpio 中。gpio_request()在成功时返回0,在出错时返回负的错误代码。如果不确定,在请求 GPIO 之前,你可以使用gpio_is_valid()函数检查指定的 GPIO 编号在系统中是否有效。

一旦驱动程序拥有了 GPIO,它就可以根据需要改变方向,判断是应该设置为输入还是输出,可以使用gpio_direction_input()gpio_direction_output()函数。在这些函数中,gpio是驱动程序需要设置方向的 GPIO 编号,这个编号应该已经被请求过。当配置 GPIO 为输出时,还会有第二个参数value,它是 GPIO 在输出方向生效后应该处于的初始状态。再次强调,这两个函数的返回值在成功时是0,失败时则返回一个负的错误代码。从内部来看,这些函数会映射到由 GPIO 芯片驱动程序导出的低级回调函数,这些回调函数提供了 GPIO 线gpio

一些 GPIO 控制器允许你调整 GPIO 的去抖动间隔(这只有在 GPIO 线配置为输入时才有用)。这个参数可以通过gpio_set_debounce()函数来设置。在这个函数中,debounce参数是去抖动时间,单位是毫秒。

由于在驱动程序的探测方法中抓取和配置资源是一个好的实践,GPIO 线必须遵循这一规则。

需要注意的是,GPIO 管理(无论是配置还是获取/设置值)并不是与上下文无关的;也就是说,有一些内存映射的 GPIO 控制器可以在任何上下文(进程和原子上下文)中访问。另一方面,也有一些 GPIO 是由位于慢总线(如 I2C 或 SPI)的离散芯片提供的,这些 GPIO 可能会进入休眠状态(因为在这些总线上发送/接收命令需要等待进入队列的首位才能传输命令并获取响应)。这些 GPIO 必须仅能在进程上下文中操作。一个设计良好的控制器驱动程序必须能够告知客户端它的 GPIO 驱动方法是否可能会休眠。这可以通过gpio_cansleep()函数进行检查。该函数对于那些控制器位于慢总线上的 GPIO 线返回true,而对于属于内存映射控制器的 GPIO 返回false

现在 GPIO 已经请求并配置完毕,我们可以使用适当的 API 来设置/获取它们的值。这里再次提到,使用的 API 取决于上下文。对于内存映射的 GPIO 控制器,可以使用gpio_get_value()gpio_set_value()来访问其 GPIO 线路。第一个函数返回一个表示 GPIO 状态的值,第二个函数将设置 GPIO 的值,该 GPIO 应该通过gpio_direction_output()配置为输出。对于这两个函数,value可以视为布尔值,零表示低电平,非零值表示高电平。

如果不确定 GPIO 来源的 GPIO 控制器类型,驱动程序应该使用与上下文无关的 API,gpio_get_value_cansleep()gpio_set_value_cansleep()。这些 API 在多线程上下文中安全使用,同时也能在原子上下文中工作。

注意

传统的(即基于整数的)接口支持通过设备树指定 GPIO,在这种情况下,用于获取这些 GPIO 的 API 是of_get_gpio()of_get_named_gpio()或类似的 API。这里只是为了学习目的提及这些,本文将不再讨论。

映射到 IRQ 的 GPIO

有些 GPIO 控制器允许将它们的 GPIO 线路映射到 IRQ。这些 IRQ 可以是边缘触发或电平触发。配置取决于需求。GPIO 控制器负责提供 GPIO 与其 IRQ 之间的映射。

如果 IRQ 已经在设备树中指定,并且底层设备是 I2C 或 SPI 设备,那么消费者驱动程序只需要正常请求 IRQ,因为在设备树解析时,设备树中映射到 IRQ 的 GPIO 将由设备树核心转换并分配给你的设备结构,即i2c_client.irqspi_device.irq。在我们在GPIO 控制器绑定章节中看到的foo_device示例中就是这种情况。对于其他设备类型,你需要调用irq_of_parse_and_map()或类似的 API。

然而,如果驱动程序收到一个 GPIO(例如来自模块参数)或从设备树中指定但没有映射到 IRQ,那么驱动程序必须使用gpio_to_irq()将给定的 GPIO 编号映射到其 IRQ 编号:

int gpio_to_irq(unsigned gpio)

该函数返回相应的 Linux IRQ 号,可以将其传递给request_irq()(或其线程版本request_threaded_irq())来注册该 IRQ 的处理程序:

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); 
int request_any_context_irq (unsigned int irq,
                       irq_handler_t handler,
                       unsigned long flags,
                       const char * name,
                       void * dev_id);

request_any_context_irq()足够智能,可以识别 GPIO 控制器中 IRQ 芯片支持的底层上下文。如果该控制器的访问者可以休眠,request_any_context_irq()将请求一个线程 IRQ,否则将请求一个原子上下文 IRQ。

以下是一个简短的示例,演示到目前为止我们所讨论的内容:

static irqreturn_t my_interrupt_handler(int irq,
                                        void *dev_id)
{
    [...]
    return IRQ_HANDLED;
}
static int foo_probe(struct i2c_client *client)
{
    [...]
    struct device_node *np = client->dev.of_node;
    int gpio_int = of_get_gpio(np, 0);
    int irq_num = gpio_to_irq(gpio_int);
    int error =
        devm_request_threaded_irq(&client->dev, irq_num,
               NULL, my_interrupt_handler,
               IRQF_TRIGGER_RISING | IRQF_ONESHOT,
               input_dev->name, my_data_struct);
    if (error) {
        dev_err(&client->dev, "irq %d requested failed,
                 %d\n", client->irq, error);
        return error;
    }
    [...]
    return 0;
}

在前面的摘录中,我们演示了如何使用基于整数的传统接口来获取设备树中指定的 GPIO,以及如何使用旧的 API 将该 GPIO 转换为有效的 Linux IRQ 号。这些是需要强调的主要内容。

尽管已经废弃,但我们简要介绍了传统的 GPIO API。如推荐的那样,在下一节中,我们将讨论新的基于描述符的 GPIO 接口。

基于描述符的 GPIO 接口:新的推荐方式

使用新的基于描述符的 GPIO 接口后,子系统已面向生产者/消费者。基于描述符的 GPIO 接口所需的头文件如下:

#include <linux/gpio/consumer.h>

使用基于描述符的接口时,GPIO 通过一个一致的数据结构struct gpio_desc进行描述和表征,该结构定义如下:

struct gpio_desc {
     struct gpio_chip *chip;
     unsigned long    flags;
     const char       *label;
};

在前述数据结构中,chip是提供该 GPIO 线路的控制器;flags是描述 GPIO 特性的标志,label是描述 GPIO 名称的标签。

在请求和获取 GPIO 的所有权之前,这些 GPIO 必须已经在某个地方被指定或映射。也就是说,它们应该分配给驱动程序,而在传统的基于整数的接口中,驱动程序可以直接从任何地方获取一个数字并将其请求为 GPIO。由于基于描述符的 GPIO 由不透明结构表示,因此不再可能使用这种方法。

使用新的接口时,GPIO 必须专门映射到名称或索引,并同时指定提供相关 GPIO 的 GPIO 芯片。这为我们提供了三种方式来指定和分配 GPIO 给驱动程序:

  • 平台数据映射:在这种情况下,例如,映射是在板文件中完成的。

  • 设备树:映射是在设备树中完成的。这是本书中我们将讨论的映射。

  • 高级配置和电源接口映射(ACPI):这是 ACPI 风格的映射。在基于 x86 的系统中,这是最常见的配置。

现在我们已经完成了 GPIO 描述符接口的介绍,让我们学习如何将其映射并分配给设备。

设备树中的 GPIO 描述符映射及其 API

GPIO 描述符映射在消费者设备的设备树节点中定义。GPIO 描述符映射属性必须命名为<name>-gpios<name>-gpio,其中<name>应足够有意义,能够描述 GPIO 将用于的功能。这是强制性的。

其原因在于,基于描述符的 GPIO 查找依赖于gpio_suffixes[]变量,这是一个在drivers/gpio/gpiolib.h中定义的gpiolib变量,定义如下:

static const char * const gpio_suffixes[] =
                          { "gpios", "gpio" };

该变量在设备树查找和基于 ACPI 的查找中都会使用。为了查看它如何工作,我们来看它是如何在of_find_gpio()中使用的,这是设备树的低级 GPIO 查找函数,定义如下:

static struct gpio_desc *of_find_gpio(
                           struct device *dev,
                           const char *con_id,
                           unsigned int idx,
                           enum gpio_lookup_flags *flags)
{
    /* 32 is max size of property name */
    char prop_name[32];
    enum of_gpio_flags of_flags;
    struct gpio_desc *desc;
    unsigned int i;
    /* Try GPIO property "foo-gpios" and "foo-gpio" */
    for (i = 0; i < ARRAY_SIZE(gpio_suffixes); i++) {
        if (con_id)
            snprintf(prop_name, sizeof(prop_name),
                      "%s-%s", con_id,
                      gpio_suffixes[i]);
        else
            snprintf(prop_name, sizeof(prop_name), "%s",
                       gpio_suffixes[i]);
        desc = of_get_named_gpiod_flags(dev->of_node,
                                      prop_name, idx,
                                      &of_flags);
        if (!IS_ERR(desc) || PTR_ERR(desc) != -ENOENT)
            break;
[...]
}

现在我们来看以下节点,这是Documentation/gpio/board.txt的一个摘录:

foo_device {
    compatible = "acme,foo";
    [...]
    led-gpios = <&gpio 15 GPIO_ACTIVE_HIGH>, /* red */
                <&gpio 16 GPIO_ACTIVE_HIGH>, /* green */
                <&gpio 17 GPIO_ACTIVE_HIGH>; /* blue */
    power-gpio = <&gpio 1 GPIO_ACTIVE_LOW>;
    reset-gpio = <&gpio 1 GPIO_ACTIVE_LOW>;
};

这就是映射应如何显示的样子,包含有意义的名称,对应于分配给 GPIO 的功能。这个摘录将作为本节其余部分的基础,用于演示基于描述符的 GPIO 接口的使用。

现在,既然 GPIO 已在设备树中指定,首先要做的是分配 GPIO 描述符并获得这些 GPIO 的所有权。这可以通过使用 gpiod_get()gpiod_getindex()gpiod_get_optional() 来完成,定义如下:

struct gpio_desc *gpiod_get_index(struct device *dev,
                                 const char *con_id,
                                 unsigned int idx,
                                 enum gpiod_flags flags)
struct gpio_desc *gpiod_get(struct device *dev,
                            const char *con_id,
                            enum gpiod_flags flags)
struct gpio_desc *gpiod_get_optional(struct device *dev,
                                     const char *con_id,
                                     enum gpiod_flags flags);

必须注意,我们还可以使用这些 API 的设备管理变体,定义如下:

struct gpio_desc *devm_gpiod_get_index(
                                struct device *dev,
                                const char *con_id,
                                unsigned int idx,
                                enum gpiod_flags flags);
struct gpio_desc *devm_gpiod_get(struct device *dev,
                               const char *con_id,
                               enum gpiod_flags flags);
struct gpio_desc *devm_gpiod_get_optional(
                                struct device *dev,
                                const char *con_id,
                                enum gpiod_flags flags);

两个非 _optional 函数如果未分配指定功能的 GPIO,或者出现其他错误,将返回 -ENOENT 或负数错误。如果成功,将返回对应 GPIO 的 GPIO 描述符。第一种方法返回某个特定索引的 GPIO 的 GPIO 描述符(当规范器是 GPIO 列表时很有用),而第二个函数始终返回索引为 0 的 GPIO(单一 GPIO 映射)。_optional 变体对于需要处理可选 GPIO 的驱动程序很有用;它与 gpiod_get() 相同,只是当没有 GPIO 被分配给设备(即未在设备树中指定)时,返回 NULL

在参数中,dev 是 GPIO 描述符所属的设备。它是驱动程序负责的底层 device 结构;例如,i2c_client.devspi_device.devplatform_device.devcon_id 是 GPIO 在消费者接口中的功能,它对应于设备树中 GPIO 规范器属性名称的 <name> 前缀。idx 是 GPIO 的索引(从 0 开始),当规范器包含 GPIO 列表时使用。flags 是一个可选参数,用于确定 GPIO 初始化标志,以配置方向和/或初始输出值。它是 enum gpiod_flags 的一个实例,定义在 include/linux/gpio/consumer.h 中,如下所示:

enum gpiod_flags {
    GPIOD_ASIS  = 0,
    GPIOD_IN = GPIOD_FLAGS_BIT_DIR_SET,
    GPIOD_OUT_LOW = GPIOD_FLAGS_BIT_DIR_SET |
                    GPIOD_FLAGS_BIT_DIR_OUT,
    GPIOD_OUT_HIGH = GPIOD_FLAGS_BIT_DIR_SET |
                     GPIOD_FLAGS_BIT_DIR_OUT |
                     GPIOD_FLAGS_BIT_DIR_VAL,
};

下面将演示如何在驱动程序中使用这些 API:

struct gpio_desc *red, *green, *blue, *power;
red = gpiod_get_index(dev, "led", 0, GPIOD_OUT_HIGH);
green = gpiod_get_index(dev, "led", 1, GPIOD_OUT_HIGH);
blue = gpiod_get_index(dev, "led", 2, GPIOD_OUT_HIGH);
power = gpiod_get(dev, "power", GPIOD_OUT_HIGH);

为了提高可读性,前面的代码没有执行错误检查。LED GPIO 将为高电平有效,但电源 GPIO 将为低电平有效(也就是说,gpiod_is_active_low(power) 在这种情况下返回 true)。

由于 flags 参数是可选的,可能存在未指定初始标志的情况,或者在需要更改 GPIO 初始功能时。为了解决这个问题,驱动程序可以使用 gpiod_direction_input()gpiod_direction_output() 来更改 GPIO 方向。这些 API 定义如下:

int gpiod_direction_input(struct gpio_desc *desc);
int gpiod_direction_output(struct gpio_desc *desc,
                           int value);

在前面的 API 中,desc 是相关 GPIO 的 GPIO 描述符,而 value 是当 GPIO 配置为输出时要应用的初始值。

必须注意,与基于整数的接口一样,必须给予同样的关注。换句话说,驱动程序必须关注底层 GPIO 芯片是否是内存映射的(因此可以在任何上下文中访问),或者是否位于慢速总线上(这将要求仅在进程或线程上下文中访问芯片)。这可以通过使用gpiod_cansleep()函数来实现,定义如下:

int gpiod_cansleep(const struct gpio_desc *desc);

如果底层硬件可以在访问期间将调用者置于休眠状态,则此函数返回true。在这种情况下,驱动程序应该使用专用 API。

以下是用于获取或设置位于慢速总线上的控制器上的 GPIO 值的 API,即对于gpiod_cansleep()返回true的 GPIO 描述符:

int gpiod_get_value_cansleep(const struct gpio_desc *desc);
void gpiod_set_value_cansleep(struct gpio_desc *desc,
                              int value);

如果底层芯片是内存映射的,则可以使用以下 API:

int gpiod_get_value(const struct gpio_desc *desc);
void gpiod_set_value(struct gpio_desc *desc, int value);

只有在驱动程序打算从中断处理程序或任何其他原子上下文中访问 GPIO 时,才需要考虑上下文。否则,您可以直接使用常规 API,即没有_cansleep后缀的 API。

gpiod_to_irq()可用于获取与映射到 IRQ 的 GPIO 描述符对应的 IRQ 号:

int gpiod_to_irq(const struct gpio_desc *desc);

生成的 IRQ 号可以与request_irq()函数(或者线程化版本request_threaded_irq())一起使用。如果驱动程序不需要关心底层硬件芯片所支持的上下文,则可以改用request_any_context_irq()。也就是说,驱动程序可以使用这些函数的设备管理变体,即devm_request_irq()devm_request_threaded_irq()devm_request_any_context_irq()

如果由于某种原因模块需要在基于描述符的接口和基于整数的传统接口之间切换,可以使用desc_to_gpio()gpio_to_desc()API 进行转换。它们的定义如下:

/* Convert between the old gpio_ and new gpiod_ interfaces */
struct gpio_desc *gpio_to_desc(unsigned gpio);
int desc_to_gpio(const struct gpio_desc *desc);

在前面的内容中,gpio_to_desc()接受一个传统的 GPIO 编号作为参数,并返回关联的 GPIO 描述符,而desc_to_gpio()则执行相反操作。

使用设备管理的 API 的优势在于,驱动程序无需关心释放 GPIO,因为这将由 GPIO 核心处理。如果使用的是非管理的 API 请求 GPIO 描述符,则必须显式地使用gpiod_put()释放该描述符,定义如下:

void gpiod_put(struct gpio_desc *desc);

现在我们已经完成了消费者端的描述符接口 API,接下来让我们通过一个具体的示例总结所学内容,从设备树的映射到基于消费者 API 的消费者代码。

将所有内容汇总

以下驱动程序总结了描述符接口中介绍的概念。在这个示例中,我们需要四个 GPIO,分配如下:两个用于 LED(红色和绿色,然后配置为输出),两个用于按钮(因此配置为输入)。要实现的逻辑是,只有当按钮 2 被按下时,按下按钮 1 才会切换两个 LED。

为了实现这一目标,让我们考虑设备树中的以下映射:

foo_device {
    compatible = "packt,gpio-descriptor-sample";
    led-gpios = <&gpio2 15 GPIO_ACTIVE_HIGH>, // red 
                <&gpio2 16 GPIO_ACTIVE_HIGH>, // green 
    btn1-gpios = <&gpio2 1 GPIO_ACTIVE_LOW>;
    btn2-gpios = <&gpio2 31 GPIO_ACTIVE_LOW>;
};

现在,GPIO 已经在设备树中映射完毕,让我们编写一个将利用这些 GPIO 的平台驱动程序:

#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/platform_device.h> /* platform devices */
#include <linux/gpio/consumer.h>   /* GPIO Descriptor */
#include <linux/interrupt.h>       /* IRQ */
#include <linux/of.h>              /* Device Tree */
static struct gpio_desc *red, *green, *btn1, *btn2;
static unsigned int irq, led_state = 0;
static irq_handler_t btn1_irq_handler(unsigned int irq,
                                      void *dev_id)
{
    unsigned int btn2_state;
    btn2_state = gpiod_get_value(btn2);
    if (btn2_state) {
        led_state = 1 – led_state;
        gpiod_set_value(red, led_state);
        gpiod_set_value(green, led_state);
    }
    pr_info("btn1 interrupt: Interrupt! btn2 state is %d)\n",
            led_state);
    return IRQ_HANDLED;
}

在前面的代码中,我们从 IRQ 处理程序开始。切换逻辑由led_state = 1 – led_state实现。接下来,我们实现驱动程序的probe方法,如下所示:

static int my_pdrv_probe (struct platform_device *pdev)
{
    int retval;
    struct device *dev = &pdev->dev;
    red = devm_gpiod_get_index(dev, "led", 0,
                               GPIOD_OUT_LOW);
    green = devm_gpiod_get_index(dev, "led", 1,
                                 GPIOD_OUT_LOW);
    /* Configure GPIO Buttons as input */
    btn1 = devm_gpiod_get(dev, "led", 0, GPIOD_IN);
    btn2 = devm_gpiod_get(dev, "led", 1, GPIOD_IN);
    irq = gpiod_to_irq(btn1);
    retval = devm_request_threaded_irq(dev, irq, NULL,
                         btn1_pushed_irq_handler,
                         IRQF_TRIGGER_LOW | IRQF_ONESHOT,
                         "gpio-descriptor-sample", NULL);
    pr_info("Hello! device probed!\n");
    return 0;
}

前面的probe方法相当简单。我们首先请求 GPIO,然后将按钮 1 的 GPIO 线转换为有效的 IRQ 号,接着为该 IRQ 注册一个处理程序。需要注意的是,我们在该方法中只使用了设备管理 API。

最后,我们在填充和注册平台设备驱动程序之前,设置一个设备 ID 表,如下所示:

static const struct of_device_id gpiod_dt_ids[] = {
    { .compatible = "packt,gpio-descriptor-sample", },
    { /* sentinel */ }
};
static struct platform_driver mypdrv = {
    .probe      = my_pdrv_probe,
    .driver     = {
        .name     = "gpio_descriptor_sample",
        .of_match_table = of_match_ptr(gpiod_dt_ids),  
        .owner    = THIS_MODULE,
    },
};
module_platform_driver(mypdrv);
MODULE_AUTHOR("John Madieu <john.madieu@labcsmart.com>");
MODULE_LICENSE("GPL");

我们可能会想,为什么没有释放 GPIO 描述符或中断。原因是我们在probe函数中只使用了设备管理 API。多亏了这些 API,我们不需要显式地释放任何内容,因此我们可以省略平台驱动程序中的remove方法。

如果我们使用非管理 API,remove方法可能如下所示:

static void my_pdrv_remove(struct platform_device *pdev)
{
    free_irq(irq, NULL);
    gpiod_put(red);
    gpiod_put(green);
    gpiod_put(btn1);
    gpiod_put(btn2);
    pr_info("good bye reader!\n");
}
static struct platform_driver mypdrv = {
    [...]
    .remove     = my_pdrv_remove,
    [...]
};

在前面的代码中,我们注意到使用了常规的gpiod_put()free_irq()API 来释放 GPIO 描述符和 IRQ 线路。

在本节中,我们完成了 GPIO 管理的内核部分,包括控制器端和客户端端。如本书所述,有时我们可能希望避免编写特定的内核代码。关于 GPIO,下一节将教我们如何避免编写 GPIO 客户端驱动程序来控制这些 GPIO。

学习如何避免编写 GPIO 客户端驱动程序

在某些情况下,编写用户空间代码可以实现与编写内核驱动程序相同的目标。此外,GPIO 框架是用户空间中最常用的框架之一。因此,不言而喻,处理它的方式在用户空间中有多种可能性,其中一些我们将在本章中介绍。

告别传统的 GPIO sysfs 接口

Sysfs 已经在用户空间中管理 GPIO 很长时间了。尽管它计划被移除,但 sysfs GPIO 接口仍然有几天的使用时间。CONFIG_GPIO_SYSFS仍然可以启用它,但不推荐使用,并且它将从主线 Linux 中移除。该接口允许通过一组文件来管理和控制 GPIO。它位于/sys/class/gpio/,以下是涉及的常见目录路径和属性:

  • /sys/class/gpio/:这一切从这里开始。该目录下有两个特殊文件,exportunexport,以及与系统中注册的每个 GPIO 控制器相对应的多个目录:

    • export:通过将 GPIO 的编号写入此文件,我们请求内核将该 GPIO 的控制权导出到用户空间。例如,输入echo 21 > export将为 GPIO#21创建一个gpio21节点(并创建一个相同名称的子目录),前提是该 GPIO 尚未被内核代码请求。

    • unexport:通过将相同的 GPIO 编号写入此文件,可以撤销导出到用户空间的效果。例如,通过export文件导出的gpio21节点,将通过输入echo 21 > unexport删除。

在成功注册gpio_chip后,将创建一个目录项,其路径类似于/sys/class/gpio/gpiochipX/,其中X是 GPIO 控制器的基础(从#X开始提供 GPIO 的控制器),并具有以下属性:

  • base,其值与X相同,且对应于gpio_chip.base(如果是静态分配),并且是该芯片管理的第一个 GPIO。

  • label,用于诊断(不一定唯一)。

  • ngpio,告诉我们此控制器提供多少个 GPIO(从NN + ngpio - 1)。这与gpio_chip.ngpios中定义的相同。

  • /sys/class/gpio/gpioN/:此目录对应于 GPIO 线路N,该线路通过export文件或直接从内核导出。/sys/class/gpio/gpio42/(用于 GPIO#42)是一个示例。此类目录包含以下读/写属性:

    • direction:使用此文件来获取/设置 GPIO 方向。可接受的值是inout字符串。该属性通常是可写的,写入out值将默认将 GPIO 初始化为低电平。为了确保无故障操作,可以写入低电平和高电平,以配置 GPIO 为输出并设置初始值。如果 GPIO 已从内核导出(请参阅gpiod_export()gpio_export()函数),则该属性将缺失,同时禁用方向更改。

    • value:该属性可用于根据 GPIO 线路的方向、输入或输出状态来获取或设置 GPIO 的状态。如果 GPIO 配置为输出,写入任何非零值将设置输出为高电平,而写入0将设置输出为低电平。如果该引脚可以设置为中断生成线路,并且已设置为这样,那么可以在该文件上使用poll()系统函数,并且当中断发生时会返回。使用poll()时,必须设置事件POLLPRIPOLLERR。如果使用的是select(),则应将文件描述符设置在exceptfds中。poll()返回后,用户代码应使用lseek()将光标移到 sysfs 文件的开头并读取新值,或者关闭文件并重新打开以读取值。这与我们讨论过的可轮询 sysfs 属性原理相同。

    • edge 决定信号的边缘,决定何时返回 poll()select() 函数。可接受的值有:nonerisingfallingboth。此可读可写的文件仅在 GPIO 可配置为中断生成输入引脚时才会存在。

    • active_low:读取时,属性值返回 0(表示 false)或 1(表示 true)。写入任何非零值将反转 value 属性的读取和写入值。现有的以及后续的 poll()/select() 函数支持通过 edge 属性来配置上升沿和下降沿的事件。

以下是一个简短的命令序列,演示了如何使用 sysfs 接口从用户空间驱动 GPIO:

# echo 24 > /sys/class/gpio/export
# echo out > /sys/class/gpio/gpio24/direction
# echo 1 > /sys/class/gpio/gpio24/value
# echo 0 > /sys/class/gpio/gpio24/value
# echo high > /sys/class/gpio/gpio24/direction # shorthand for out/1
# echo low > /sys/class/gpio/gpio24/direction # shorthand for out/0

我们不再花时间讨论这个遗留接口了。现在,马上切换到内核开发者提供的新 GPIO 管理接口——Libgpiod 库。

欢迎使用 Libgpiod GPIO 库

内核 Linux GPIO 用户空间 sysfs 已被弃用并且已停止维护。也就是说,它曾经面临许多问题,其中一些如下:

  • 状态不再与进程绑定。

  • 缺乏对 sysfs 属性的并发访问管理。

  • 缺乏对批量 GPIO 操作的支持,也就是无法通过单个命令(一次性)对一组 GPIO 执行操作。

  • 设置 GPIO 值时需要进行大量操作(打开并写入 export 文件,打开并写入 direction 文件,打开并写入 value 文件)。

  • 不可靠的轮询——用户代码必须在 /sys/class/gpio/gpioX/value 上轮询,每次事件发生时,必须 close/re-openlseek 文件后再重新读取新值,这可能导致事件丢失。

  • 无法设置 GPIO 电气属性。

  • 如果进程崩溃,GPIO 会保持已导出状态;没有上下文概念。

为了解决 sysfs 接口的局限性,已经开发了新的 GPIO 接口,基于描述符的 GPIO 接口。它包含 GPIO 字符设备——一种新的用户 API,合并在 Linux v4.8 中。这个新接口引入了以下改进:

  • 每个 GPIO 芯片一个设备文件:/dev/gpiochip0/dev/gpiochip1/dev/gpiochipX 等。

  • 这类似于其他内核接口:open() + ioctl() + poll() + read() + close()

  • 通过批量相关 API,可以一次请求多个线路(用于读取/设置值)。

  • 可以通过名称查找 GPIO 线路和芯片,这样会更可靠。

  • 开源和开漏标志,用户/消费者字符串,以及 uevents。

  • 可靠的轮询,防止事件丢失。

Libgpiod 配备了 C API,允许你充分利用系统上注册的任何 GPIO 芯片。并且也支持 C++ 和 Python 语言。该 API 文档齐全,内容广泛,无法在这里完全覆盖。基本的使用案例通常遵循以下步骤:

  1. 通过调用gpiod_chip_open*函数之一来打开所需的 GPIO 芯片字符设备,例如gpiod_chip_open_by_name()gpiod_chip_open_lookup()。此函数将返回指向struct gpiod_chip的指针,该指针将在后续的 API 调用中使用。

  2. 通过调用gpiod_chip_get_line()来获取所需 GPIO 线路的句柄,该函数将返回指向struct gpiod_line实例的指针。虽然之前的 API 返回的是单个 GPIO 线路的句柄,但如果需要一次获取多个 GPIO 线路,可以使用gpiod_chip_get_lines()函数。gpiod_chip_get_lines()将返回指向struct gpiod_line_bulk实例的指针,该实例可用于后续的批量操作。另一个可以返回一组 GPIO 句柄的 API 是gpiod_chip_get_all_lines(),它会返回给定 GPIO 芯片的所有线路,存储在struct gpiod_line_bulk中。当你拥有这样的 GPIO 对象集时,可以使用gpiod_line_bulk_get_line()API 来请求特定索引位置的 GPIO 线路。

  3. 通过调用gpiod_line_request_input()gpiod_line_request_output()来请求将线路用作输入或输出。对于一组 GPIO 线路的批量操作,可以改为使用gpiod_line_request_bulk_input()gpiod_line_request_bulk_output()

  4. 通过调用gpiod_line_get_value()来读取单个 GPIO 线路的输入值,或者在处理一组 GPIO 时,使用gpiod_line_get_value_bulk()。对于输出 GPIO 线路,可以通过调用gpiod_line_set_value()来设置单个 GPIO 线路的电平,或者使用gpiod_line_set_value_bulk()来设置一组输出 GPIO 的电平。

  5. 完成后,通过调用gpiod_line_release()gpiod_line_release_bulk()来释放线路。

  6. 一旦所有 GPIO 线路被释放,可以通过对每个 GPIO 芯片调用gpiod_chip_close()来释放相关芯片。

完成后应调用gpiod_line_release()来释放 GPIO 线路。释放的 GPIO 线路作为参数传入。然而,如果需要释放的是一组 GPIO,应该改为使用gpiod_line_release_bulk()。需要注意的是,如果这些线路之前没有一起请求过(没有使用gpiod_line_request_bulk()进行请求),那么调用gpiod_line_release_bulk()的行为是未定义的。

有一些可能值得一提的健全性 API,定义如下:

bool gpiod_line_is_free(struct gpiod_line *line);
bool gpiod_line_is_requested(struct gpiod_line *line);

在前面的 API 中,gpiod_line_is_requested()可用于检查调用的用户是否拥有该 GPIO 线路。此函数如果line已经被请求,则返回true,否则返回false。它不同于gpiod_line_is_free(),后者用于检查调用的用户是否既没有请求该line的所有权,也没有在其上设置任何事件通知。它如果line是空闲的,则返回true,否则返回false

其他 API 可用于实现更高级的功能,如为上拉或下拉电阻配置引脚模式,或注册回调函数,在事件发生时调用该函数,例如输入引脚电平变化的情况,正如我们将在下一节中看到的。

事件驱动(中断驱动)GPIO

中断驱动的 GPIO 处理包括获取一个(struct gpiod_line)或多个(struct gpiod_line_bulk)GPIO 句柄,并监听这些 GPIO 线路上的事件,事件监听可以是无限期的或定时的。

GPIO 线路事件通过一个struct gpiod_line_event对象进行抽象,定义如下:

struct gpiod_line_event {
    struct timespec ts;
    int event_type;
};

在前述数据结构中,ts是表示等待事件超时的时间规范数据结构,event_type是事件类型,它可以是GPIOD_LINE_EVENT_RISING_EDGEGPIOD_LINE_EVENT_FALLING_EDGE,分别表示上升沿事件或下降沿事件。

在使用gpiod_chip_get_line()gpiod_chip_get_lines()gpiod_chip_get_all_lines()获取 GPIO 句柄后,用户代码应使用以下 API 之一请求在这些 GPIO 句柄上发生感兴趣的事件:

int gpiod_line_request_rising_edge_events(
                          struct gpiod_line *line,
                          const char *consumer);
int gpiod_line_request_bulk_rising_edge_events(
                          struct gpiod_line_bulk *bulk,
                          const char *consumer);
int gpiod_line_request_falling_edge_events(
                          struct gpiod_line *line,
                          const char *consumer);
int gpiod_line_request_bulk_falling_edge_events(
                          struct gpiod_line_bulk *bulk,
                          const char *consumer);
int gpiod_line_request_both_edges_events(
                          struct gpiod_line *line,
                          const char *consumer);
int gpiod_line_request_bulk_both_edges_events(
                          struct gpiod_line_bulk *bulk,
                          const char *consumer);

上述 API 分别请求单个 GPIO 线路或一组 GPIO 线路上的上升沿、下降沿或双沿事件(即与批量相关的 API)。

在请求了事件之后,用户代码可以使用以下 API 之一,等待感兴趣的 GPIO 线路上的事件发生:

int gpiod_line_event_wait(struct gpiod_line *line,
                  const struct timespec *timeout);
int gpiod_line_event_wait_bulk(
                  struct gpiod_line_bulk *bulk,
                  const struct timespec *timeout,
                  struct gpiod_line_bulk *event_bulk);

在前述代码中,gpiod_line_event_wait()用于等待单个 GPIO 线路上的事件,而gpiod_line_event_wait_bulk()则会等待一组 GPIO 线路上的事件。在参数中,line是用于单个 GPIO 监控时等待事件的 GPIO 线路,而bulk是用于批量监控时的一组 GPIO 线路。最后,event_bulk是一个输出参数,保存着发生感兴趣事件的 GPIO 线路集合。这些都是阻塞 API,只有在感兴趣的事件发生或超时后,才会继续执行流程。

一旦阻塞函数返回,必须使用gpiod_line_event_read()来读取在之前提到的监控函数返回的 GPIO 线路上发生的事件。此 API 的原型如下:

int gpiod_line_event_read(struct gpiod_line *line,
                         struct gpiod_line_event *event);

如果出错,API 返回-1,否则返回0。在参数中,line是要读取事件的 GPIO 线路,event是输出参数,事件数据将被复制到该事件缓冲区。

以下是请求事件、读取和处理该事件的示例:

char *chipname = "gpiochip0";
int ret;
struct gpiod_chip *chip;
struct gpiod_line *input_line;
struct gpiod_line_event event;
unsigned int line_num = 25;  /* GPIO Pin #25 */
chip = gpiod_chip_open_by_name(chipname);
if (!chip) {
     perror("Open chip failed\n");
     return -1;
}
input_line = gpiod_chip_get_line(chip, line_num);
if (!input_line) {
     perror("Get line failed\n");
     ret = -1;
     goto close_chip;
}
ret = gpiod_line_request_rising_edge_events(input_line,
                                            "gpio-test");
if (ret < 0) {
     perror("Request event notification failed\n");
     ret = -1;
     goto release_line;
}
while (1) {
  gpiod_line_event_wait(input_line, NULL); /* blocking */
  if (gpiod_line_event_read(input_line, &event) != 0)
        continue;

    /* should always be a rising event in our example */
    if (event.event_type != GPIOD_LINE_EVENT_RISING_EDGE)
        continue;
    [...]
}
release_line:
     gpiod_line_release(input_line);
close_chip:
     gpiod_chip_close(chip);
     return ret;

在前面的代码片段中,我们首先通过名称查找 GPIO 芯片,并使用返回的 GPIO 芯片句柄获取 GPIO 线路#25 的句柄。接下来,我们请求在该 GPIO 线路上触发上升沿事件通知(中断驱动)。然后,我们进入循环,等待事件的发生,读取发生了什么事件,并验证它是否是上升沿事件。

除了前面的代码示例外,假设现在有一个更复杂的例子,我们监控五个 GPIO 线,首先需要引入必要的头文件:

// file event-bulk.c
#include <gpiod.h>
#include <error.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/time.h>

接着,让我们提供程序中使用的静态变量:

static struct gpiod_chip *chip;
static struct gpiod_line_bulk gpio_lines;
static struct gpiod_line_bulk gpio_events;
/* use GPIOs #4, #7, #9, #15, and #31 as input */
static unsigned int gpio_offsets[] = {4, 7, 9, 15, 31};

在前面的代码片段中,chip将持有我们感兴趣的 GPIO 芯片的句柄。gpio_lines将持有事件驱动的 GPIO 线的句柄,即需要被监控的 GPIO 线。最后,gpio_events将传递给库函数,监控过程中,该参数会被填充为发生事件的 GPIO 线的句柄。

最后,让我们开始实现main方法:

int main(int argc, char *argv[])
{
    int err;
    int values[5] = {-1};
    struct timespec timeout;
    chip = gpiod_chip_open("/dev/gpiochip0");
    if (!chip) {
        perror("gpiod_chip_open");
        goto cleanup;
    }

在前面的代码片段中,我们仅仅是打开了 GPIO 芯片设备并保持了指向它的指针。接下来,我们需要获取感兴趣的 GPIO 线句柄,并将它们存储在gpio_lines中:

    err = gpiod_chip_get_lines(chip, gpio_offsets, 5,
                               &gpio_lines);
    if (err) {
        perror("gpiod_chip_get_lines");
        goto cleanup;
    }

然后,我们使用这些 GPIO 线句柄来请求它们底层 GPIO 线的事件监控。因为我们感兴趣的是多个 GPIO 线,所以我们使用bulk API 变体,如下所示:

    err = gpiod_line_request_bulk_rising_edge_events(
                    &gpio_lines, "rising edge example");
    if(err) {
        perror(
           "gpiod_line_request_bulk_rising_edge_events");
        goto cleanup;
    }

在前面的代码片段中,gpiod_line_request_bulk_rising_edge_events()将请求上升沿事件通知。现在我们已经为 GPIO 请求了事件驱动的监控,可以在这些 GPIO 线上的阻塞监控 API 如下调用:

    /* Timeout of 60 seconds, pass in NULL to wait forever */
    timeout.tv_sec = 60;
    timeout.tv_nsec = 0;
    printf("waiting for rising edge event \n");
marker1:
    err = gpiod_line_event_wait_bulk(&gpio_lines,
                                     &timeout, &gpio_events);
    if (err == -1) {
        perror("gpiod_line_event_wait_bulk");
        goto cleanup;
    } else if (err == 0) {
        fprintf(stderr, "wait timed out\n");
        goto cleanup;
    }

在前面的代码片段中,由于我们需要时间限制的事件轮询,我们设置了一个struct timespec数据结构,其中包含期望的超时值,并将其传递给gpiod_line_event_wait_bulk()

也就是说,达到这一步(通过轮询函数)意味着要么阻塞监控 API 超时,要么至少有一个被监控的 GPIO 线上发生了事件。发生事件的 GPIO 句柄存储在gpio_events中,这是一个输出参数,而被监控的 GPIO 线列表则通过gpio_lines传递。需要注意的是,gpio_linesgpio_events都是批量 GPIO 数据结构。

如果我们有兴趣读取发生事件的 GPIO 线的值,可以按如下方式进行操作:

    err = gpiod_line_get_value_bulk(&gpio_events, values);
    if(err) {
        perror("gpiod_line_get_value_bulk");
        goto cleanup;
    }

如果我们不想读取发生事件的 GPIO 线的值,而是需要读取所有被监控 GPIO 线的值,我们可以将前面的代码中的gpio_events替换为gpio_lines

接下来,如果我们对gpio_events中每个 GPIO 线发生的事件类型感兴趣,可以按如下方式操作:

    for (int i = 0;
         i < gpiod_line_bulk_num_lines(&gpio_events);
         i++) {
        struct gpiod_line* line;
        struct gpiod_line_event event;
        line = gpiod_line_bulk_get_line(&gpio_events, i);
        if(!line) {
           fprintf(stderr, "unable to get line %d\n", i);
           continue;
        }
        if (gpiod_line_event_read(line, &event) != 0)
            continue;
        printf("line %s, %d\n", gpiod_line_name(line),
           gpiod_line_offset(line));
    }
marker2:

在前面的代码中,我们遍历了gpio_events中的每个 GPIO 线,该列表表示已发生事件的 GPIO 线。gpiod_line_bulk_num_lines()函数获取线批量对象中所持有的 GPIO 线数量,gpiod_line_bulk_get_line()函数根据给定的偏移量,从线批量对象中获取对应的线句柄,这个偏移量是本地的。需要注意的是,为了实现相同的目标,我们本可以使用gpiod_line_bulk_foreach_line()宏。

然后,在每个批量对象中的 GPIO 引脚上,我们调用 gpiod_line_event_read()gpiod_line_name()gpiod_line_offset()。第一个函数将检索与该引脚上发生的事件对应的事件数据结构。接下来,我们可以使用类似 if (event.event_type != GPIOD_LINE_EVENT_RISING_EDGE) 这样的代码检查发生的事件类型(尤其是在同时监控两种事件类型时)是否符合预期。第二个函数是一个辅助函数,用于检索 GPIO 引脚名称,而第三个函数 gpiod_line_offset() 则检索 GPIO 引脚的偏移量,这是全局于运行中的系统的。

如果我们想要无限期地或在特定轮次内监控这些 GPIO 引脚,我们可以将 marker1marker2 标签之间的代码包裹在 while()for() 循环中。

在执行流程结束时,我们进行一些清理工作,例如:

cleanup:
    gpiod_line_release_bulk(&gpio_lines);
    gpiod_chip_close(chip);
    return EXIT_SUCCESS;
}

之前的清理代码片段首先释放我们请求的所有 GPIO 引脚,然后关闭相关的 GPIO 芯片。

注意

必须注意,批量 GPIO 监控必须按每个 GPIO 芯片进行。也就是说,不建议将来自不同 GPIO 芯片的 GPIO 引脚嵌入到同一批量对象中。

现在我们已经完成了 API 的使用并在实践中演示了它,我们可以切换到与 libgpiod 库一起提供的命令行工具。

命令行工具

如果你只需要执行简单的 GPIO 操作,Gpiod 库包括一组特别方便的命令行工具,这些工具可以用于交互式地探索 GPIO 功能,并可以在 shell 脚本中使用,避免编写 C 或 C++ 代码。以下是可用的命令:

  • gpiodetect: 显示系统上所有 GPIO 芯片的列表,包括它们的名称、标签和 GPIO 引脚的数量。

  • gpioinfo: 显示所选 GPIO 芯片所有引脚的名称、使用者、方向、活动状态和其他标志。gpioinfo gpiochip6 是一个示例。如果未指定 GPIO 芯片,命令将遍历系统中的所有 GPIO 芯片并列出其相关引脚。

  • gpioget: 获取指定的 GPIO 引脚的值。

  • gpioset: 设置指定 GPIO 引脚的值,并可能将其保持导出,直到发生超时、用户输入或信号。

  • gpiofind: 给定一个引脚名称,找到相关的 GPIO 芯片名称和引脚偏移量。

  • gpiomon: 通过等待这些引脚上的事件来监控 GPIO。此命令允许你指定要监控的事件以及在退出前应处理多少个事件,或者是否将事件报告到控制台。

现在我们列出了可用的命令行工具,我们可以继续了解 GPIO 子系统提供的另一种机制,这使得我们可以在用户空间中利用这些工具。

GPIO 聚合器

现在,GPIO 访问控制使用新接口上的/dev/gpiochip*权限。典型的 Unix 文件系统权限使得这些字符设备的访问控制采用全有或全无的方式。与早期的/sys/class/gpio接口相比,这种新接口提供了许多优点,我们在欢迎使用 Libgpiod GPIO 库章节的开头列出了这些优点。然而,一个缺点是,它为每个 GPIO 芯片创建了一个设备文件,这意味着访问权限是在每个 GPIO 芯片的基础上定义的,而不是每个 GPIO 线路。

结果是/dev/gpiochip*设备。

该功能对于将一组 GPIO 指定给特定用户并实施访问控制非常有用。此外,将 GPIO 导出到虚拟机也变得更加简化和安全,因为虚拟机只需获取整个 GPIO 控制器,不再需要担心哪些 GPIO 需要获取,哪些不需要,从而减少了攻击面。其文档可以在Documentation/admin-guide/gpio/gpio-aggregator.rst中找到。

要在内核中支持 GPIO 聚合器,必须在内核配置中启用CONFIG_GPIO_AGGREGATOR=y。此功能可以通过 sysfs 或设备树配置,正如我们将在接下来的章节中看到的那样。

使用 sysfs 聚合 GPIO

聚合的 GPIO 控制器通过向 sysfs 中的只写属性文件写入进行实例化和销毁,主要来自/sys/bus/platform/drivers/gpio-aggregator/目录。

此目录包含以下属性:

  • new_device:用于通过写入描述要聚合的 GPIO 的字符串来请求内核实例化一个聚合的 GPIO 控制器。new_device文件理解格式[<gpioA>] [<gpiochipB> <offsets>] ...

    • <gpioA>是 GPIO 线路名称。

    • <gpiochipB>是 GPIO 芯片标签。

    • <offsets>是一个用逗号分隔的 GPIO 偏移量和/或 GPIO 偏移量范围(以破折号表示)的列表。

  • delete_device:用于请求内核在使用后销毁聚合的 GPIO 控制器。

以下是一个示例,通过将e6052000.gpio的 GPIO 线路 19 与e6050000.gpio的 GPIO 线路 20-21 聚合到一个新的gpio_chip中来实例化一个新的 GPIO 聚合器:

# echo 'e6052000.gpio 19 e6050000.gpio 20-21' > /sys/bus/platform/drivers/gpio-aggregator/new_device
# gpioinfo gpio-aggregator.0
     gpiochip12 - 3 lines:
     line 0: unnamed unused input active-high
     line 1: unnamed unused input active-high
     line 2: unnamed unused input active-high
# chown geert /dev/gpiochip12

使用后,可以通过以下命令销毁先前创建的聚合 GPIO 控制器,假设其名称为gpio-aggregator.0

$ echo gpio-aggregator.0 > delete_device

从之前的示例来看,聚合后的 GPIO 芯片是gpiochip12,其拥有三条 GPIO 线路。我们本可以使用gpioinfo gpiochip12,而不是gpioinfo gpio-aggregator.0

从设备树中聚合 GPIO

设备树也可以用来聚合 GPIO。为此,只需定义一个节点,使用gpio-aggregator作为兼容字符串,并将gpios属性设置为希望成为新 GPIO 芯片一部分的 GPIO 列表。此技术的一个独特之处在于,像任何其他 GPIO 控制器一样,GPIO 线路可以被命名,并随后通过使用libgpiod库的用户空间应用程序查询。

接下来,我们将演示如何使用来自设备树的多个 GPIO 线路与 GPIO 聚合器。首先,我们列出在新 GPIO 芯片中需要使用的引脚。我们在引脚控制器节点下进行如下操作:

&iomuxc {
[...]
   aggregator {
      pinctrl_aggregator_pins: aggretatorgrp {
         fsl,pins = <
           MX6QDL_PAD_EIM_D30__GPIO3_IO30      0x80000000  
           MX6QDL_PAD_EIM_D23__GPIO3_IO23      0x80000000 
           MX6QDL_PAD_ENET_TXD1__GPIO1_IO29    0x80000000
           MX6QDL_PAD_ENET_RX_ER__GPIO1_IO24   0x80000000
           MX6QDL_PAD_EIM_D25__GPIO3_IO25      0x80000000
           MX6QDL_PAD_EIM_LBA__GPIO2_IO27      0x80000000
           MX6QDL_PAD_EIM_EB2__GPIO2_IO30      0x80000000
           MX6QDL_PAD_SD3_DAT4__GPIO7_IO01     0x80000000
         >;
      };
   };
}

现在我们已经配置好了引脚,可以按如下方式声明我们的 GPIO 聚合器:

gpio-aggregator {
    pinctrl-names = "default";
    pinctrl-0 = <&pinctrl_aggregator_pins>;
    compatible = "gpio-aggregator";
    gpios = <&gpio3 30 GPIO_ACTIVE_HIGH>,
            <&gpio3 23 GPIO_ACTIVE_HIGH>,
            <&gpio1 29 GPIO_ACTIVE_HIGH>,
            <&gpio1 25 GPIO_ACTIVE_HIGH>,
            <&gpio3 25 GPIO_ACTIVE_HIGH>,
            <&gpio2 27 GPIO_ACTIVE_HIGH>,
            <&gpio2 30 GPIO_ACTIVE_HIGH>,
            <&gpio7 1 GPIO_ACTIVE_HIGH>;
    gpio-line-names = "line_a", "line_b", "line_c",
            "line_d", "line_e", "line_f", "line_g",
            "line_h";
};

在这个例子中,pinctrl_aggregator_pins是 GPIO 引脚节点,它必须已经在引脚控制器节点下实例化。gpios包含了新 GPIO 芯片必须由哪些 GPIO 线路组成的列表。最后,gpio-line-names的意思是:GPIO 控制器gpio3的第 30 号引脚被使用并命名为line_a,GPIO 控制器gpio3的第 23 号引脚被使用并命名为line_b,GPIO 控制器gpio1的第 29 号引脚被使用并命名为line_c,依此类推,到 GPIO 控制器gpio7的第 1 号引脚,命名为line_h

从用户空间,我们可以看到 GPIO 芯片及其聚合的线路:

# gpioinfo
[...]
gpiochip9 - 8 lines:
    line 0: "line_a" unused input active-high
    line 1: "line_b" unused input active-high
[...]
    line 7: "line_g" unused input active-high
[...]

我们可以通过线路名称搜索 GPIO 芯片和线路号:

# gpiofind 'line_b'
gpiochip9 1

我们可以通过引脚名称访问 GPIO 线路:

# gpioget $(gpiofind 'line_b')
1
#
# gpioset $(gpiofind 'line_h')=1
# gpioset $(gpiofind 'line_h')=0

我们可以更改 GPIO 芯片设备文件的所有权,以允许用户或组访问附加的线路:

# chown $user:$group /dev/gpiochip9
# chmod 660 /dev/gpiochip9

由聚合器创建的 GPIO 芯片可以从 sysfs 中获取,路径为/sys/bus/platform/devices/gpio-aggregator/

使用通用 GPIO 驱动程序聚合 GPIO

如果没有特定的内核驱动程序,GPIO 聚合器可以作为一个通用驱动程序,用于设备树中描述的简单 GPIO 操作设备。修改gpio-aggregator驱动程序或在 sysfs 中写入driver_override文件都是将设备绑定到 GPIO 聚合器的选项。

在继续之前,让我们先讨论一下driver_override文件;该文件位于/sys/bus/platform/devices/.../driver_override。此文件指定设备的驱动程序,将覆盖标准设备树、ACPI、ID 表和名称匹配,正如我们在第六章《设备、驱动程序与平台抽象介绍》中所见。需要注意的是,只有名称与写入driver_override的值匹配的驱动程序才能绑定到该设备。覆盖操作通过向driver_override文件写入一个字符串来设置(echo vfio-platform > driver_override),并且可以通过向文件写入空字符串来清除(echo > driver_override)。这将设备恢复到默认的匹配规则绑定。然而,必须注意,写入driver_override并不会解除设备与现有驱动程序的绑定,也不会自动加载提供的驱动程序。如果没有加载具有匹配名称的驱动程序,设备将不会绑定到任何驱动程序。设备还可以使用driver_override名称,如none,来选择不进行驱动程序绑定。driver_override不支持解析分隔符,并且只能提供一个驱动程序。

例如,给定一个door设备,它是一个由 GPIO 操作的设备,描述在设备树中,使用其自己的兼容值,如下所示:

door {
     compatible = "myvendor,mydoor";
     gpios = <&gpio2 19 GPIO_ACTIVE_HIGH>,
           <&gpio2 20 GPIO_ACTIVE_LOW>;
     gpio-line-names = "open", "lock";
};

它可以通过以下任一方法绑定到 GPIO 聚合器:

  • 将其兼容值添加到drivers/gpio/gpio-aggregator.c中的gpio_aggregator_dt_ids[]

  • 使用driver_override手动绑定

第一个方法非常直接:

$ echo gpio-aggregator > /sys/bus/platform/devices/door/driver_override
$ echo door > /sys/bus/platform/drivers/gpio-aggregator/bind

在之前的命令中,我们已将驱动程序的名称(此处为gpio-aggregator)写入设备目录/sys/bus/platform/devices/<device-name>/中的driver_override文件。之后,我们通过将设备名称写入驱动目录/sys/bus/<bus-name>/drivers/<driver-name>/中的bind文件,将设备绑定到驱动程序。需要注意的是,<bus-name>对应于驱动程序所属的总线框架。它可以是i2cspiplatformpciisausb等。

绑定后,将创建一个新的 GPIO 芯片door。然后,可以如下操作获取其信息:

$ gpioinfo door
gpiochip12 - 2 lines:
     line   0:       "open"       unused   input  active-high
     line   1:       "lock"       unused   input  active-high

接下来,可以像任何其他正常(非虚拟)GPIO 芯片一样,使用库 API 在这个 GPIO 芯片上进行操作。

我们现在已经完成了特别是从用户空间的 GPIO 聚合,以及一般的用户空间 GPIO 管理。我们已经学会了如何创建虚拟 GPIO 芯片以隔离一组 GPIO,并且学会了如何使用 GPIO 库来驱动这些 GPIO。

总结

在本章中,我们介绍了引脚控制框架,并描述了它与 GPIO 子系统的交互。我们学习了如何从内核和用户空间处理 GPIO,无论是作为控制器还是消费者。尽管遗留的基于整数的接口已被弃用,但由于它仍然被广泛使用,因此我们介绍了它。此外,我们还介绍了一些高级话题,如 GPIO 芯片中的 IRQ 芯片支持以及 GPIO 与 IRQ 的映射。本章的结尾,我们学习了如何通过编写 C 代码或使用标准 Linux GPIO 库libgpiod提供的专用命令行工具,从用户空间处理 GPIO。

在下一章中,我们将讨论输入设备,它们可以通过 GPIO 实现。

第十七章:第十七章:利用 Linux 内核输入子系统

输入设备是你用来与系统交互的设备。这些设备包括按钮、键盘、触摸屏、鼠标等。它们通过发送事件与系统交互,这些事件会被输入核心捕获并广播到系统中。本章将解释输入核心用来处理输入设备的每个结构,以及如何从用户空间管理事件。

本章将涵盖以下主题:

  • Linux 内核输入子系统介绍——其数据结构和 API

  • 分配并注册输入设备

  • 使用轮询输入设备

  • 生成并报告输入事件

  • 从用户空间处理输入设备

Linux 内核输入子系统介绍——其数据结构和 API

该子系统的主要数据结构和 API 可以在include/linux/input.h文件中找到。任何输入设备驱动程序都需要包含以下行:

#include <linux/input.h>

无论是什么类型的输入设备,无论它发送什么类型的事件,输入设备在内核中都表示为input_dev结构的一个实例:

struct input_dev {
  const char *name;
  const char *phys;
  unsigned long evbit[BITS_TO_LONGS(EV_CNT)];
  unsigned long keybit[BITS_TO_LONGS(KEY_CNT)];
  unsigned long relbit[BITS_TO_LONGS(REL_CNT)];
  unsigned long absbit[BITS_TO_LONGS(ABS_CNT)];
  unsigned long mscbit[BITS_TO_LONGS(MSC_CNT)];
  unsigned int repeat_key;
  int rep[REP_CNT];
  struct input_absinfo *absinfo;
  unsigned long key[BITS_TO_LONGS(KEY_CNT)];
  int (*open)(struct input_dev *dev);
  void (*close)(struct input_dev *dev);
  unsigned int users;
  struct device dev;
  unsigned int num_vals;
  unsigned int max_vals;
  struct input_value *vals;
  bool devres_managed;
};

为了可读性,结构中的一些元素已被省略。我们将更详细地查看这些字段:

  • name表示设备的名称。

  • phys是设备在系统层次结构中的物理路径。

  • evbit是设备支持的事件类型的位图。以下是一些事件:

    • EV_KEY用于支持发送键事件的设备(例如,键盘、按钮等)

    • EV_REL用于支持发送相对位置的设备(例如,鼠标、数字化仪等)

    • EV_ABS用于支持发送绝对位置的设备(例如,操纵杆)。事件的列表可以在内核源代码中的include/linux/input-event-codes.h文件中找到。你可以使用set_bit()宏根据输入设备的能力设置相应的位。当然,一个设备可以支持多个事件类型。例如,一个鼠标驱动程序将同时设置EV_KEYEV_REL,如下所示:

          set_bit(EV_KEY, my_input_dev->evbit);
          set_bit(EV_REL, my_input_dev->evbit);
      
  • keybit用于EV_KEY启用的设备,包含设备暴露的键/按钮位图;例如,BTN_0KEY_AKEY_B等。键/按钮的完整列表可以在include/linux/input-event-codes.h文件中找到。

  • relbit用于EV_REL启用的设备,包含设备的相对轴位图;例如,REL_XREL_YREL_Z等。可以查看include/linux/input-event-codes.h文件以获取完整的列表。

  • absbit用于EV_ABS启用的设备,包含设备的绝对轴位图;例如,ABS_YABS_X等。可以查看前面同样的文件以获取完整的列表。

  • mscbit用于EV_MSC启用的设备,包含设备支持的各种事件的位图。

  • repeat_key 存储最后按下的按键码;当软件实现自动重复功能时会使用此值。

  • rep 存储当前的自动重复参数值,通常包括延迟和速率。

  • absinfo 是一个 &struct input_absinfo 元素的数组,保存关于绝对轴的信息(当前值、minmaxflatfuzz 和分辨率)。你应该使用 input_set_abs_params() 函数来设置这些值:

    void input_set_abs_params(struct input_dev *dev,
                           unsigned int axis, int min,
                           int max, int fuzz, int flat)
    
  • minmax 分别指定下限和上限值,fuzz 表示指定输入设备指定通道上的预期噪声。以下示例中,我们设置了每个通道的边界:

    #define ABSMAX_ACC_VAL      0x01FF
    #define ABSMIN_ACC_VAL      -(ABSMAX_ACC_VAL)
    [...]
    set_bit(EV_ABS, idev->evbit);
    input_set_abs_params(idev, ABS_X, ABSMIN_ACC_VAL,
                         ABSMAX_ACC_VAL, 0, 0);
    input_set_abs_params(idev, ABS_Y, ABSMIN_ACC_VAL,
                         ABSMAX_ACC_VAL, 0, 0);
    input_set_abs_params(idev, ABS_Z, ABSMIN_ACC_VAL,
                         ABSMAX_ACC_VAL, 0, 0);
    
  • key 反映设备按键/按钮的当前状态。

  • open 是当第一个用户调用 input_open_device() 时调用的方法。使用此方法准备设备,例如中断请求、轮询线程启动等。

  • close 是当最后一个用户调用 input_close_device() 时调用的函数。在这里,你可以停止轮询(因为它消耗了大量资源)。

  • users 存储打开该设备的用户(输入处理程序)数量。它用于 input_open_device()input_close_device(),确保 dev->open() 仅在第一个用户打开设备时调用,并且 dev->close() 仅在最后一个用户关闭设备时调用。

  • dev 是与该设备关联的 struct device(用于设备模型)。

  • num_vals 是当前帧中排队的值的数量。

  • max_vals 是在一帧中排队的最大值数量。

  • Vals 是当前帧中排队的值的数组。

  • devres_managed 表示设备是通过 devres 框架管理的,不需要显式地注销或释放。

现在你已经熟悉了主要输入设备的数据结构,我们可以开始在系统中注册这些设备。

分配和注册输入设备

在系统能够看到输入设备支持的事件之前,必须先使用 devm_input_allocate_device() API 为该设备分配内存。然后,设备需要通过 input_device_register() 注册到系统中。前者 API 会在设备离开系统时负责释放内存并注销设备。然而,仍然可以使用非管理的分配,但不推荐使用,input_allocate_device()。使用非管理分配时,驱动程序需要负责确保在卸载路径上分别调用 input_unregister_device()input_free_device() 来注销设备和释放其内存。以下是这些 API 的原型:

struct input_dev *input_allocate_device(void)
struct input_dev *devm_input_allocate_device(
                                       struct device *dev)
void input_free_device(struct input_dev *dev)
int input_register_device(struct input_dev *dev)
void input_unregister_device(struct input_dev *dev)

设备分配可能会导致睡眠,因此不得在原子上下文中或持有自旋锁时调用。以下是一个位于 I2C 总线上的输入设备 probe 函数的摘录:

struct input_dev *idev;
int error;
/*
 * such allocation will take care of memory freeing and
 * device unregistering
 */
idev = devm_input_allocate_device(&client->dev);
if (!idev)
    return -ENOMEM;
idev->name = BMA150_DRIVER;
idev->phys = BMA150_DRIVER "/input0";
idev->id.bustype = BUS_I2C;
idev->dev.parent = &client->dev;
set_bit(EV_ABS, idev->evbit);
input_set_abs_params(idev, ABS_X, ABSMIN_ACC_VAL,
                     ABSMAX_ACC_VAL, 0, 0);
input_set_abs_params(idev, ABS_Y, ABSMIN_ACC_VAL,
                     ABSMAX_ACC_VAL, 0, 0);
input_set_abs_params(idev, ABS_Z, ABSMIN_ACC_VAL,
                     ABSMAX_ACC_VAL, 0, 0);
error = input_register_device(idev);
if (error)
    return error;
error = devm_request_threaded_irq(&client->dev,
            client->irq,
            NULL, my_irq_thread,
            IRQF_TRIGGER_RISING | IRQF_ONESHOT,
            BMA150_DRIVER, NULL);
if (error) {
    dev_err(&client->dev, "irq request failed %d, 
           error %d\n", client->irq, error);
    return error;
}
return 0;

如你所见,在前面的代码中,当发生错误时,没有进行内存释放或设备注销,因为我们对输入设备和中断请求(IRQ)都使用了托管分配。这意味着输入设备有一个 IRQ 线路,以便我们能接收到底层设备状态变化的通知。但这并不总是成立,因为系统可能没有可用的 IRQ 线路,在这种情况下,输入核心必须频繁地轮询设备,以免错过事件。我们将在下一节讨论这一点。

使用轮询输入设备

轮询输入设备是依赖于轮询来感知设备状态变化的特殊输入设备;通用输入设备类型则依赖 IRQ 来感知变化并将事件发送到输入核心。

在内核中,轮询输入设备被描述为 struct input_polled_dev 结构的一个实例,这是一个围绕通用 struct input_dev 结构的包装器。以下是它的声明:

struct input_polled_dev {
    void *private;
    void (*open)(struct input_polled_dev *dev);
    void (*close)(struct input_polled_dev *dev);
    void (*poll)(struct input_polled_dev *dev);
    unsigned int poll_interval; /* msec */
    unsigned int poll_interval_max; /* msec */
    unsigned int poll_interval_min; /* msec */

    struct input_dev *input;
    bool devres_managed;
};

让我们来看看这个结构体中的元素:

  • private 是驱动程序的私有数据。

  • open 是一个可选方法,用于准备设备进行轮询(启用设备,并有时刷新设备状态)。

  • close 是一个可选方法,当设备不再被轮询时会被调用。它用于将设备置于低功耗模式。

  • poll 是一个必需的方法,每当设备需要被轮询时就会被调用。它会以 poll_interval 的频率被调用。

  • poll_interval 是应该调用 poll() 方法的频率。除非在注册设备时进行覆盖,否则默认为 500 毫秒。

  • poll_interval_max 指定了轮询间隔的上限。默认为 poll_interval 的初始值。

  • poll_interval_min 指定了轮询间隔的下限。默认为 0。

  • input 是轮询设备所依赖的输入设备。它必须由驱动程序初始化(通过其 ID、名称和位)。轮询输入设备仅提供一个接口,使用轮询而不是 IRQ 来感知设备状态变化。

可以通过 devm_input_allocate_polled_device() 为轮询输入设备分配内存。这是一个托管分配 API,负责在适当的时候释放内存和注销设备。同样,非托管 API 也可以用于分配,即 input_allocate_polled_device(),在这种情况下,你必须自己负责调用 input_free_polled_device()。以下代码展示了这些 API 的原型:

struct input_polled_dev
     *devm_input_allocate_polled_device(
                                  struct device *dev)
struct input_polled_dev *input_allocate_polled_device(void)
void input_free_polled_device(struct 
                               input_polled_dev *dev)

对于资源管理设备,input_dev->devres_managed 字段将由输入核心设置为 true。然后,你应该初始化底层 struct input_dev 的必需字段,正如我们在前一节中看到的那样。轮询间隔也必须设置,否则它将默认为 500 毫秒。

一旦字段分配和初始化完成,可以使用 input_register_polled_device() 来注册轮询输入设备,成功时返回 0。对于托管分配,注销由系统处理;你需要自己调用 input_unregister_polled_device() 执行反向操作。以下是它们的原型:

int input_register_polled_device(
                        struct input_polled_dev *dev)
void  input_unregister_polled_device(
                        struct input_polled_dev *dev)

这样的设备的 probe() 函数的一个典型示例如下所示。首先,我们定义驱动程序数据结构,它将收集所有必要的资源:

struct my_struct {
    struct input_pulled_dev *polldev;
    struct gpio_desc *gpio_btn;
    [...]
}

一旦驱动程序数据结构被定义,probe() 函数就可以实现。以下是它的实现:

static int button_probe(struct platform_device *pdev)
{
    struct my_struct *ms;
    struct input_dev *input_dev;
    int error;
    struct device *dev = &pdev->dev;
    ms = devm_kzalloc(dev, sizeof(*ms), GFP_KERNEL);
    if (!ms)
        return -ENOMEM;
    ms->polldev = devm_input_allocate_polled_device(dev);
    if (!ms->polldev)
        return -ENOMEM;
    /* This gpio is not mapped to IRQ */
    ms->gpio_btn = devm_gpiod_get(dev, "my-btn", GPIOD_IN);
    ms->polldev->private = ms;
    ms->polldev->poll = my_btn_poll;
    ms->polldev->poll_interval = 200;/* Poll every 200ms */
    ms->polldev->open = my_btn_open;
     /* Initializing the underlying input_dev fields */
    input_dev = ms->poll_dev->input;
    input_dev->name = "System Reset Btn";
    /* The gpio belongs to an expander sitting on I2C */
    input_dev->id.bustype = BUS_I2C; 
    input_dev->dev.parent = dev;
    /* Declare the events generated by this driver */
    set_bit(EV_KEY, input_dev->evbit);
    set_bit(BTN_0, input_dev->keybit); /* buttons */
    retval = input_register_polled_device(ms->poll_dev);
    if (retval) {
        dev_err(dev, "Failed to register input device\n");
        return retval;  
    }
    return 0;
}

再次强调,当发生错误时,由于我们使用了托管分配,注销和释放操作并非由我们自己处理。

以下是我们的 open 回调可能的样子:

static void my_btn_open(struct input_polled_dev *poll_dev)
{
    struct my_strut *ms = poll_dev->private;
    dev_dbg(&ms->poll_dev->input->dev, "reset open()\n");
}

在我们的示例中,它没有任何操作。然而,open 方法用于准备设备所需的资源。

是否应实现轮询输入设备是一个简单的决策。通常,如果有 IRQ 线可用,可以使用经典的输入设备;否则,可以退而使用轮询设备:

if(client->irq > 0){
    /* Use generic input device */
} else {
    /* Use polled device */
}

在选择实现轮询输入设备还是基于 IRQ 的输入设备时,可能还需要考虑其他因素;前面的代码只是一个建议。

现在我们已经熟悉了这部分输入设备,我们可以考虑注册和注销输入设备。也就是说,即使输入设备已注册,我们仍然无法与其交互。在下一节中,我们将学习如何让输入设备向内核报告事件。

生成并报告输入事件

设备的分配和注册是必要的,但如果设备无法向输入核心报告事件,那么它们就毫无意义,因为输入设备的设计目的就是报告事件。根据我们设备可以支持的事件类型,内核提供了相应的 API 来将这些事件报告给核心。

对于一个支持 EV_XXX 的设备,对应的报告函数将是 input_report_xxx()。下表显示了最重要的事件类型与其报告函数之间的映射关系:

表 17.1 – 映射输入设备的能力与报告 API

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

表 17.1 – 映射输入设备的能力与报告 API

这些报告 API 的原型如下:

void input_report_abs(struct input_dev *dev,
                      unsigned int code, int value)
void input_report_key(struct input_dev *dev,
                      unsigned int code, int value)
void input_report_rel(struct input_dev *dev,
                      unsigned int code, int value)

可用报告函数的列表可以在内核源文件 include/linux/input.h 中找到。它们都有相同的框架:

  • dev 是负责该事件的输入设备。

  • code 表示事件代码;例如,REL_XKEY_BACKSPACE。完整的列表可以在 include/linux/input-event-codes.h 中找到。

  • value 是事件携带的值。对于 EV_REL 事件类型,它携带相对变化。对于 EV_ABS(如摇杆等)事件类型,它包含绝对新值。对于 EV_KEY 事件类型,按键释放时应设置为 0,按键按下时为 1,自动重复时为 2

一旦所有这些更改被报告,驱动程序应在输入设备上调用 input_sync(),以指示该事件已经完成。输入子系统将把这些事件收集到一个数据包中,并通过 /dev/input/event<X> 发送,这个字符设备代表我们在系统上的 struct input_dev。在这里,<X> 是输入核心分配给驱动程序的接口编号:

void input_sync(struct input_dev *dev)

让我们看一个例子。以下是 drivers/input/misc/bma150.c 的摘录:

static void threaded_report_xyz(struct bma150_data *bma150)
{
  u8 data[BMA150_XYZ_DATA_SIZE];
  s16 x, y, z;
  s32 ret;
  ret = i2c_smbus_read_i2c_block_data(bma150->client,
                       BMA150_ACC_X_LSB_REG,
                       BMA150_XYZ_DATA_SIZE, data);
  if (ret != BMA150_XYZ_DATA_SIZE)
    return;
  x = ((0xc0 & data[0]) >> 6) | (data[1] << 2);
  y = ((0xc0 & data[2]) >> 6) | (data[3] << 2);
  z = ((0xc0 & data[4]) >> 6) | (data[5] << 2);
  /* sign extension */
  x = (s16) (x << 6) >> 6;
  y = (s16) (y << 6) >> 6;
  z = (s16) (z << 6) >> 6;
  input_report_abs(bma150->input, ABS_X, x);
  input_report_abs(bma150->input, ABS_Y, y);
  input_report_abs(bma150->input, ABS_Z, z);
  /* Indicate this event is complete */
  input_sync(bma150->input);
}

在前面的摘录中,input_sync() 告诉核心将三个报告视为同一个事件。这是有道理的,因为位置有三个坐标轴(XYZ),我们不希望 XYZ 被单独报告。

报告事件的最佳位置是在轮询设备的轮询函数内部,或在启用中断(IRQ)的设备的 IRQ 例程(无论是线程化部分还是非线程化部分)中。如果您执行了可能导致睡眠的操作,您应在 IRQ 处理程序的线程化部分内处理报告。以下代码展示了我们的初始示例如何实现 poll 方法:

static void my_btn_poll(struct input_polled_dev *poll_dev)
{
    struct my_struct *ms = polldev->private;
    struct i2c_client *client = mcp->client;

    input_report_key(polldev->input, BTN_0,
                     gpiod_get_value(ms->rgpio_btn) & 1);
    input_sync(poll_dev->input);
}

在前面的代码中,我们的输入设备报告了 0 键码。在接下来的部分,我们将讨论用户空间如何处理这些报告事件和码值。

从用户空间处理输入设备

每个已经成功注册到系统中的输入设备(无论是否轮询)都会在 /dev/input/ 目录下创建一个节点。在我的例子中,该节点对应于 event0,因为它是我目标板上的第一个也是唯一的输入设备。您可以使用 udevadm 工具来显示设备的信息:

# udevadm info /dev/input/event0 
P: /devices/platform/input-button.0/input/input0/event0
N: input/event0
S: input/by-path/platform-input-button.0-event
E: DEVLINKS=/dev/input/by-path/platform-input-button.0-event
E: DEVNAME=/dev/input/event0
E: DEVPATH=/devices/platform/input-button.0/input/input0/event0
E: ID_INPUT=1
E: ID_PATH=platform-input-button.0
E: ID_PATH_TAG=platform-input-button_0
E: MAJOR=13
E: MINOR=64
E: SUBSYSTEM=input
E: USEC_INITIALIZED=74842430

另一个可以使用的工具是 evetest,它允许您打印设备支持的按键。它还可以在设备报告事件时捕获并打印这些事件。以下代码展示了它在我们输入设备上的使用:

# evtest /dev/input/event0 
input device opened()
Input driver version is 1.0.1
Input device ID: bus 0x0 vendor 0x0 product 0x0 version 0x0
Input device name: "Packt Btn"
Supported events:
   Event type 0 (EV_SYN)
   Event type 1 (EV_KEY)
      Event code 256 (BTN_0)

不仅是我们为其编写了驱动程序的输入设备可以通过 evetest 管理。在以下示例中,我正在使用连接到我计算机的 USB-C 耳机。由于它提供了音量相关的按键,因此它具有输入设备功能:

jma@labcsmart-sqy:~$ sudo evtest /dev/input/event4
Input driver version is 1.0.1
Input device ID: bus 0x3 vendor 0x12d1 product 0x3a07 version 0x111
Input device name: "Synaptics HUAWEI USB-C HEADSET"
Supported events:
  Event type 0 (EV_SYN)
  Event type 1 (EV_KEY)
    Event code 114 (KEY_VOLUMEDOWN)
    Event code 115 (KEY_VOLUMEUP)
    Event code 164 (KEY_PLAYPAUSE)
    Event code 582 (KEY_VOICECOMMAND)
  Event type 4 (EV_MSC)
    Event code 4 (MSC_SCAN)
Properties:
Testing ... (interrupt to exit)
Event: time 1640231369.347093, type 4 (EV_MSC), code 4 (MSC_SCAN), value c00e9
Event: time 1640231369.347093, type 1 (EV_KEY), code 115 (KEY_VOLUMEUP), value 1
Event: time 1640231369.347093, -------------- SYN_REPORT ------------
Event: time 1640231369.487017, type 4 (EV_MSC), code 4 (MSC_SCAN), value c00e9
Event: time 1640231369.487017, type 1 (EV_KEY), code 115 (KEY_VOLUMEUP), value 0
Event: time 1640231369.487017, -------------- SYN_REPORT ------------

在前面的代码中,我按下了音量增大键以查看如何报告。evtest 甚至可以与您的键盘一起使用,唯一的条件是您需要在 /dev/input/ 中识别相应的输入设备节点。

正如我们所见,每个注册的输入设备都由 /dev/input/event<X> 字符设备表示,我们可以使用它从用户空间读取事件。读取该文件的应用程序将收到以 struct input_event 格式的事件数据包,该格式有以下声明:

struct input_event {
  struct timeval time;
  __u16 type;
  __u16 code;
  __s32 value;
}

让我们看一下结构中每个元素的含义:

  • time 是一个时间戳,表示事件发生的时间。

  • type 是事件类型;例如,EV_KEY 表示按键按下或释放,EV_REL 表示相对运动,或 EV_ABS 表示绝对运动。更多类型可以在 include/linux/input-event-codes.h 中找到。

  • code 是事件代码;例如,REL_XKEY_BACKSPACE。同样,完整的列表可以在 include/linux/input-event-codes.h 中找到。

  • value 是事件携带的值。对于 EV_REL 事件类型,它携带相对变化;对于 EV_ABS(例如游戏摇杆)事件类型,它包含绝对新值;对于 EV_KEY 事件类型,按键释放时设置为 0,按键按下时设置为 1,自动重复时设置为 2

用户空间应用程序可以使用阻塞和非阻塞读取,也可以使用 poll()select() 系统调用,在打开设备后被通知事件。以下是 select() 系统调用的示例。我们从列出需要实现示例的头文件开始:

#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <linux/input.h>
#include <sys/select.h>

然后,我们必须将输入设备路径定义为宏,因为它将被频繁使用:

#define INPUT_DEVICE "/dev/input/event0"
int main(int argc, char **argv)
{   
    int fd, ret;
    struct input_event event;
    ssize_t bytesRead;
    fd_set readfds;

接下来,我们必须打开输入设备,并保持其文件描述符以备后用。如果打开输入设备失败,将视为错误,因此我们必须退出程序:

    fd = open(INPUT_DEVICE, O_RDONLY);
    if(fd < 0){
        fprintf(stderr,
             "Error opening %s for reading", INPUT_DEVICE);
        exit(EXIT_FAILURE);
    }

现在,我们已经有了一个表示打开的输入设备的文件描述符。我们可以使用 select() 系统调用来感知任何按键按下或释放:

    while(1){ 
        FD_ZERO(&readfds);
        FD_SET(fd, &readfds);
        ret = select(fd + 1, &readfds, NULL, NULL, NULL);
        if (ret == -1) {
            fprintf(stderr,
                   "select call on %s: an error ocurred",
                    INPUT_DEVICE);
            break;
        }
        else if (!ret) { /* If we used timeout */
            fprintf(stderr,
                    "select on %s: TIMEOUT", INPUT_DEVICE);
            break;
        }

此时,我们已经对 select() 的返回路径进行了必要的有效性检查。请注意,如果在任何文件描述符准备好之前超时,select() 将返回零,因此在前面的代码中使用了 else if

变更现在已经生效,让我们读取数据查看它对应的内容:

        /* File descriptor is now ready */
        if (FD_ISSET(fd, &readfds)) {
            bytesRead = read(fd, &event,
                             sizeof(struct input_event));
            if(bytesRead == -1)
                /* Process read input error*/
                [...]
            if(bytesRead != sizeof(struct input_event))
                /* Read value is not an input event */
                [...] /* handle this error */

如果执行流到达这一点,意味着一切顺利。现在,我们可以遍历输入设备支持的事件,并将它们与输入核心报告的事件进行比较,以便做出决策:

            /* We could have done a switch/case if we had
             * many codes to look for */
            if (event.code == BTN_0) {
                /* it concerns our button */
                if (event.value == 0) {
                    /* Process keyRelease if need be */
                    [...]
                }
                else if(event.value == 1){
                    /* Process KeyPress */
                    [...]
                }
            }
        }
    }
    close(fd);
    return EXIT_SUCCESS;
}

为了进一步调试,如果你的输入设备是基于 GPIO 的,你可以依次按下/释放按钮,检查 GPIO 的状态是否发生了变化:

# cat /sys/kernel/debug/gpio  | grep button
 gpio-195 (gpio-btn         ) in  hi    
# cat /sys/kernel/debug/gpio  | grep button
 gpio-195 (gpio-btn         ) in  lo

此外,如果输入设备有一个 IRQ 线路,检查该 IRQ 线路的统计数据可能是有意义的,以确保其一致性。例如,在这里,我们必须检查请求是否成功,以及它触发了多少次:

# cat /proc/interrupts | grep packt
160:      0      0      0      0  gpio-mxc   0  packt-input-button

在本节中,我们学习了如何处理用户空间中的输入设备,并提供了一些调试提示,以便在出现问题时进行排查。我们使用了 select() 系统调用来感知输入事件,尽管我们也可以使用 poll()

概述

本章描述了输入框架,并强调了轮询输入设备和中断驱动输入设备之间的区别。到此为止,你应该已经具备了编写任何输入设备驱动程序的必要知识,无论其类型如何,支持何种输入事件。用户空间接口也进行了讨论,并提供了一个示例。

Packt.com

订阅我们的在线数字图书馆,全面访问超过 7000 本书籍和视频,以及行业领先的工具,帮助你规划个人发展并推进职业生涯。欲了解更多信息,请访问我们的网站。

第十八章:为什么要订阅?

  • 通过来自 4000 多位行业专业人士的实用电子书和视频,减少学习时间,增加编程时间

  • 使用专门为你设计的技能计划提高学习效果

  • 每月获得一本免费电子书或视频

  • 完全可搜索,便于快速访问重要信息

  • 复制、粘贴、打印和收藏内容

你知道 Packt 提供了每本书的电子书版本,包括 PDF 和 ePub 格式文件吗?你可以在packt.com升级到电子书版本,作为印刷书籍的客户,你还可以享受电子书版本的折扣。详情请通过customercare@packtpub.com联系我们。

www.packt.com,你还可以阅读一系列免费的技术文章,注册各种免费的新闻通讯,并获得有关 Packt 书籍和电子书的独家折扣和优惠。

你可能会喜欢的其他书籍

如果你喜欢这本书,你可能对 Packt 出版的其他书籍感兴趣:

掌握 Linux 设备驱动开发

John Madieu

ISBN: 9781789342048

  • 探索并采用 Linux 内核帮助程序来进行锁定、工作延迟和中断管理

  • 了解 Regmap 子系统,管理内存访问,并与 IRQ 子系统协同工作

  • 掌握 PCI 子系统,并为 PCI 设备编写可靠的驱动程序

  • 使用 ALSA SoC 和 V4L2 框架编写完整的多媒体设备驱动

  • 使用内核电源管理框架构建省电设备驱动

  • 了解如何最大限度地利用 NVMEM 和 Watchdog 等杂项内核子系统

Linux 内核编程

Kaiwan N Billimoria

ISBN: 9781789953435

  • 为 5.x 内核编写高质量的模块化内核代码(LKM 框架)

  • 配置并从源代码构建内核

  • 探索 Linux 内核架构

  • 掌握关于内核内存管理的关键内部机制

  • 了解并使用各种动态内核内存分配/释放 API

  • 发现与内核中的 CPU 调度相关的关键内部机制

  • 了解内核并发问题

  • 了解如何使用关键的内核同步原语

Packt 正在寻找像你这样的作者

如果你有兴趣成为 Packt 的作者,请访问 authors.packtpub.com 并立即申请。我们已经与成千上万的开发者和技术专家合作,帮助他们将自己的见解分享给全球技术社区。你可以提交一般申请、申请我们正在招募作者的特定热门话题,或者提交你自己的想法。

分享你的想法

现在你已经完成了 Linux 设备驱动开发 - 第二版,我们很希望听到你的想法!如果你是通过 Amazon 购买的这本书,请 点击这里直接进入 Amazon 评论页面 并分享你的反馈,或在你购买的站点上留下评论。

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

你可能会喜欢的其他书籍

你可能会喜欢的其他书籍

posted @ 2025-07-05 19:50  绝不原创的飞龙  阅读(156)  评论(0)    收藏  举报