Linux-设备驱动开发秘籍-全-

Linux 设备驱动开发秘籍(全)

原文:zh.annas-archive.org/md5/6B7A321F07B3F3827350A558F12EF0DA

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

内核设备驱动程序开发是复杂操作系统中最重要的部分之一,而 Linux 就是这样的操作系统。设备驱动程序对于在工业、家庭或医疗应用等真实环境中使用计算机的开发人员非常重要。事实上,即使 Linux 现在得到了广泛的支持,每天仍然会创建新的外围设备,这些设备需要驱动程序才能在 GNU/Linux 机器上得到有效使用。

本书将介绍实现完整字符驱动程序(通常称为char driver)的方法,通过介绍在内核和用户空间之间交换数据的所有必要技术,实现与外围设备中断的进程同步,访问 I/O 内存映射到(内部或外部)设备,并在内核中高效地管理时间。

本书中提供的所有代码都与 Linux 4.18+版本兼容(即最新的 5.x 内核)。这些代码可以在 Marvell ESPRESSObin 上进行测试,该设备具有内置的 ARM 64 位 CPU,但也可以在任何其他类似的 GNU/Linux 嵌入式设备上使用。通过这种方式,读者可以验证他们所读内容是否被正确理解。

本书的读者对象

如果您想了解如何在 Linux 机器上实现完整的字符驱动程序,或者想了解几种内核机制的工作原理(例如工作队列、完成和内核定时器等),以更好地理解通用驱动程序的工作原理,那么本书适合您。

如果您需要了解如何编写自定义内核模块以及如何向其传递参数,或者如何读取和更好地管理内核消息,甚至如何向内核源代码添加自定义代码,那么本书就是为您而写的。

如果您需要更好地理解设备树,如何修改它,甚至如何编写新的设备树以满足您的需求,并学习如何管理新的设备驱动程序,那么您也会从本书中受益。

本书涵盖内容

第一章,安装开发系统,介绍了如何在 Ubuntu 18.04.1 LTS 上安装完整的开发系统,以及基于 Marvell ESPRESSObin 板的完整测试系统。本章还将介绍如何使用串行控制台,如何从头开始重新编译内核,并教授一些进行交叉编译和软件仿真的技巧。

第二章,内核深度剖析,讨论了如何创建自定义内核模块,以及如何读取和管理内核消息。这些技能对于帮助开发人员理解内核内部发生的事情非常有用。

第三章,使用字符驱动程序,探讨了如何实现一个非常简单的字符驱动程序,以及如何在其与用户空间之间交换数据。本章最后提出了一些例子,以突出一切皆文件的抽象与设备驱动程序之间的关系。

第四章,使用设备树,介绍了设备树。读者将学习如何阅读和理解它,如何编写自定义设备树,然后如何编译它以获得可以传递给内核的二进制形式。本章以使用 Armada 3720、i.Mx 7Dual 和 SAMA5D3 CPU 为例,介绍了下载固件(在外围设备内)以及如何使用 Pin MUX 工具配置 CPU 引脚的部分。

第五章,管理中断和并发,介绍了如何在 Linux 内核中管理中断和并发。它展示了如何安装中断处理程序,如何推迟工作到以后的时间,以及如何管理内核定时器。在本章末尾,读者将学习如何等待事件(如等待某些数据被读取)以及如何保护他们的数据免受竞争条件的影响。

第六章,杂项内核内部,讨论如何在内核内部动态分配内存,以及如何使用几个有用的辅助函数来进行一些日常编程操作(如字符串操作、列表和哈希表操作)。本章还将介绍如何进行 I/O 内存访问,以及如何在内核内部安全地花费时间以创建明确定义的繁忙循环延迟。

第七章,高级字符驱动程序操作,介绍了字符驱动程序上所有可用的高级操作:ioctl()mmap()lseek()poll()/select()系统调用的实现,以及通过SIGIO信号进行异步 I/O。

附录 A,附加信息:使用字符驱动程序,这包含了第三章的附加信息。

附录 B,附加信息:使用设备树,这包含了第四章的附加信息。

附录 C,附加信息:管理中断和并发,这包含了第五章的附加信息。

附录 D,附加信息:杂项内核内部,这包含了第六章的附加信息。

附录 E,附加信息:高级字符驱动程序操作,这包含了第七章的附加信息。

为了充分利用本书

  • 您应该对非图形文本编辑器(如viemacsnano)有一些了解。您不能直接连接 LCD 显示器、键盘和鼠标到嵌入式套件上进行对文本文件的小修改,因此您应该对这些工具有一定的了解,以便远程进行这些修改。

  • 您应该知道如何管理 Ubuntu 系统,或者至少是一个通用的基于 GNU/Linux 的系统。我的主机 PC 运行在 Ubuntu 18.04.1 LTS 上,但您也可以使用更新的 Ubuntu LTS 版本,或者带有一些修改的基于 Debian 的系统。您也可以使用其他 GNU/Linux 发行版,但这将需要您付出一些努力,主要是关于安装交叉编译工具、库依赖和软件包管理。

本书不涵盖 Windows、macOS 等外部系统,因为您不应该使用低技术的系统来开发高技术系统的代码!

  • 熟悉 C 编程语言、C 编译器的工作原理以及如何管理 makefile 都是强制性要求。

下载示例代码文件

您可以从您在www.packt.com的账户中下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packt.com/support并注册,文件将直接通过电子邮件发送给您。

您可以按照以下步骤下载代码文件:

  1. www.packt.com登录或注册。

  2. 选择“支持”选项卡。

  3. 单击“代码下载和勘误”。

  4. 在搜索框中输入书名,然后按照屏幕上的说明操作。

下载文件后,请确保使用以下最新版本的解压缩或提取文件夹:

  • Windows 系统使用 WinRAR/7-Zip

  • Mac 系统使用 Zipeg/iZip/UnRarX

  • 7-Zip/PeaZip for Linux

该书的代码包托管在 GitHub 上,网址为github.com/giometti/linux_device_driver_development_cookbook。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Linux-Device-Driver-Development-Cookbook。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

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

下载彩色图像

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

使用的约定

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

文本文件夹名称、文件名、文件扩展名、路径名、虚拟 URL 和用户输入中的代码词显示如下:"要获取前面的内核消息,我们可以使用dmesgtail -f /var/log/kern.log命令。"

代码块设置如下:

#include <stdio.h>

int main(int argc, char *argv[])
{
    printf("Hello World!\n");

    return 0;
}

您应该注意,本书中的大多数代码都采用 4 个空格缩进,而本书提供的文件中的示例代码使用 8 个空格缩进。因此,前面的代码将如下所示:

#include <stdio.h>

int main(int argc, char *argv[])
{
        printf("Hello World!\n");

        return 0;
}

显然,它们在实践中是完全等效的!

本书中使用的嵌入式套件的任何命令行输入或输出均按以下方式呈现:

# make CFLAGS="-Wall -O2" helloworld
cc -Wall -O2 helloworld.c -o helloworld

命令以粗体显示,而它们的输出以普通文本显示。您还应该注意,由于空间限制,提示字符串已被删除;实际上,在您的终端上,完整的提示应该如下所示:

root@espressobin:~# make CFLAGS="-Wall -O2" helloworld
cc -Wall -O2 helloworld.c -o helloworld

还要注意,由于书中的空间限制,您可能会遇到非常长的命令行,如下所示:

$ make CFLAGS="-Wall -O2" \
 CC=aarch64-linux-gnu-gcc \
 chrdev_test
aarch64-linux-gnu-gcc -Wall -O2 chrdev_test.c -o chrdev_test

否则,我不得不打破命令行。但是,在一些特殊情况下,您可能会发现以下格式的输出行(特别是内核消息):

[ 526.318674] mem_alloc:mem_alloc_init: kmalloc(..., GFP_KERNEL) =ffff80007982f
000
[ 526.325210] mem_alloc:mem_alloc_init: kmalloc(..., GFP_ATOMIC) =ffff80007982f
000

不幸的是,这些行不能在印刷书籍中轻松重现,但您应该将它们视为单行。

在我的主机计算机上,作为非特权用户给出的任何命令行输入或输出均按以下方式编写:

$ tail -f /var/log/kern.log

当我需要以特权用户(root)的身份在我的主机计算机上给出命令时,命令行输入或输出将如下所示:

# insmod mem_alloc.ko

您应该注意,所有特权命令也可以由普通用户使用sudo命令以以下格式执行:

$ sudo <command>

因此,前面的命令可以由普通用户执行,如下所示:

$ sudo /insmod mem_alloc.ko

内核和日志消息

在几个 GNU/Linux 发行版上,内核消息通常具有以下形式:

[ 3.421397] mvneta d0030000.ethernet eth0: Using random mac address 3e:a1:6b:
f5:c3:2f

这是本书中的一行非常长的行,因此我们从每行的起始字符开始删除字符,直到真正的信息开始。因此,在上面的示例中,输出行将如下报告:

mvneta d0030000.ethernet eth0: Using random mac address 3e:a1:6b:f5:c3:2f

但是,正如前面所说,如果行仍然太长,它将被打破。

在终端中,长输出或重复或不太重要的行通过用三个点...替换来删除,如下所示:

output begin
output line 1
output line 2
...
output line 10
output end

当三个点位于行尾时,这意味着输出会继续,但出于空间原因,我决定将其截断。

文件修改

当您需要修改文本文件时,我将使用统一上下文差异格式,因为这是一种非常高效和紧凑的表示文本修改的方式。可以通过使用带有-u选项参数的diff命令或在git存储库中使用git diff命令来获得此格式。

作为一个简单的例子,让我们考虑file1.old中的以下文本:

This is first line
This is the second line
This is the third line
...
...
This is the last line

假设我们需要修改第三行,如下摘录所示:

This is first line
This is the second line
This is the new third line modified by me
...
...
This is the last line

您可以轻松理解,每次对文件进行简单修改都报告整个文件是不必要且占用空间;但是,通过使用统一上下文差异格式,前述修改可以写成如下形式:

$ diff -u file1.old file1.new
--- file1.old 2019-05-18 14:49:04.354377460 +0100
+++ file1.new 2019-05-18 14:51:57.450373836 +0100
@@ -1,6 +1,6 @@
 This is first line
 This is the second line
-This is the third line
+This is the new third line modified by me
 ...
 ...
 This is the last line

现在,修改非常清晰,并以紧凑的形式编写!它以两行标题开始,原始文件前面有---,新文件前面有+++。然后,它遵循一个或多个变更块,其中包含文件中的行差异。前面的示例只有一个块,其中未更改的行前面有一个空格字符,而要添加的行前面有一个+字符,要删除的行前面有一个-字符。

尽管出于空间原因,本书中大多数补丁的缩进都减少了,以适应印刷页面的宽度;但是,它们仍然是完全可读的。对于完整的补丁,您应该参考 GitHub 上提供的文件或 Packt 网站上的文件。

串行和网络连接

在本书中,我主要会使用两种不同类型的连接与嵌入式套件进行交互:串行控制台和 SSH 终端以及以太网连接。

串行控制台,通过 USB 连接实现,主要用于从命令行管理系统。它主要用于监视系统,特别是控制内核消息。

SSH 终端与串行控制台非常相似,即使不完全相同(例如,内核消息不会自动显示在终端上),但它可以像串行控制台一样用于从命令行给出命令和编辑文件。

在章节中,我将使用串行控制台上的终端或通过 SSH 连接来提供实现本书中所有原型所需的大部分命令和配置设置。

要从主机 PC 访问串行控制台,可以使用minicon命令,如下所示:

$ minicom -o -D /dev/ttyUSB0

但是,在第一章,安装开发系统中,这些方面都有解释,您不必担心。还要注意,在某些系统上,您可能需要 root 权限才能访问/dev/ttyUSB0设备。在这种情况下,您可以通过使用sudo命令或更好地通过使用以下命令将系统用户正确添加到正确的组来解决此问题:

$ sudo adduser $LOGNAME dialout

然后注销并重新登录,您应该能够无问题地访问串行设备。

要访问 SSH 终端,您可以使用以太网连接。它主要用于从主机 PC 或互联网下载文件,并且可以通过将以太网电缆连接到嵌入式套件的以太网端口,然后根据读者的 LAN 设置相应地配置端口来建立连接(请参阅第一章,安装开发系统中的所有说明)。

其他约定

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

警告或重要说明会以这种方式出现。

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

章节

在本书中,您会经常看到几个标题(准备就绪如何做它是如何工作的还有更多,和另请参阅)。

为了清晰地说明如何完成一个配方,使用以下各节:

准备就绪

本节告诉您在配方中可以期待什么,并描述如何设置配方所需的任何软件或任何预备设置。

如何做...

本节包含完成配方所需的步骤。

它是如何工作的...

本节通常包括对前一节中发生的事情的详细解释。

还有更多…

本节包括有关食谱的额外信息,以使您对食谱更加了解。

另请参阅

本节提供有关食谱的其他有用信息的链接。

联系我们

我们的读者反馈总是受欢迎的。

一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并发送电子邮件至 customercare@packtpub.com

勘误: 尽管我们已经尽一切努力确保内容的准确性,但错误确实会发生。如果您在本书中发现错误,我们将不胜感激您向我们报告。请访问 www.packt.com/submit-errata,选择您的书,点击勘误提交表格链接,并输入详细信息。

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

如果您有兴趣成为作者:如果您在某个专题上有专业知识,并且有兴趣撰写或为一本书做出贡献,请访问 authors.packtpub.com

评论

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

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

第一章:安装开发系统

在本章中,我们将介绍并设置我们的工作平台。实际上,即使我们在工作 PC 上编写并测试自己的设备驱动程序,建议使用第二台设备来测试代码。这是因为我们将在内核空间工作,即使有一个小错误也可能导致严重的故障!此外,使用一个平台,可以测试各种外设,这些外设并不总是在 PC 上可用。当然,您可以自由选择使用自己的系统来编写和测试驱动程序,但在这种情况下,您需要注意适应您的板规格所需的修改。

在本书中,我将使用Marvell ESPRESSObin系统,这是一台功能强大的ARM 64 位机器,具有许多有趣的功能。在下图中,您可以看到 ESPRESSObin 与信用卡并排,可以了解到板的真实尺寸:

我使用的是 ESPRESSObin 的 v5 版本,而在撰写本书时最新版本(于 2018 年 9 月宣布)是 v7,因此读者应该能够在本书出版时获得这个新版本。新的 ESPRESSObin v7 将提供 1GB DDR4 和 2GB DDR4 配置(而 v5 使用 DDR3 RAM 芯片),并且新的 1.2GHz 芯片组将取代目前销售的配置,其 CPU 频率限制为 800MHz 和 1GHz。即使快速查看新的板布局,我们可以看到单个 SATA 连接器取代了现有的 SATA 电源和接口的组合,LED 布局现在重新排列成一行,并且现在放置了一个内置的 eMMC。此外,这个新版本将配备一个可选的 802.11ac +蓝牙 4.2 迷你 PCIe 无线网络卡,需另外购买。

最后,您现在可以选择订购带有完整外壳的 v7 ESPRESSObin。该产品已获得 FCC 和 CE 认证,有助于实现大规模部署。有关修订版 v7(和 v5)的更多信息,请访问wiki.espressobin.net/tiki-index.php?page=Quick+User+Guide

为了测试我们的新驱动程序,我们将在本章中涵盖以下内容:

  • 设置主机

  • 使用串行控制台

  • 配置和构建内核

  • 设置目标机器

  • 在外部硬件上进行本地编译

技术要求

以下是一些有用的技术信息的网址,我们可以在这些网址上获取有关板的技术信息:

查看espressobin.net/tech-spec/上的技术规格,我们得到以下信息,可以看到 ESPRESSObin v5 在计算能力、存储、网络和可扩展性方面的优势:

系统芯片 (SoC) Marvell Armada 3700LP (88F3720) 双核 ARM Cortex A53 处理器,最高 1.2GHz
系统内存 1GB DDR3 或可选 2GB DDR3
存储 1x SATA 接口 1x 微型 SD 卡槽,可选 4GB EMMC

| 网络连接 | 1x Topaz 网络交换机 2x GbE 以太网 LAN

1x 以太网 WAN

1x 用于无线/蓝牙低功耗外设的 MiniPCIe 插槽 |

| USB | 1x USB 3.0 1x USB 2.0

1x 微型 USB 端口 |

扩展 2 个 46 针 GPIO 头,用于连接 I2C、GPIO、PWM、UART、SPI、MMC 等附件和扩展板。
杂项 复位按钮和 JTAG 接口
电源供应 12V DC 插孔或通过微型 USB 端口 5V
功耗 1GHz 时小于 1W 的热耗散

特别是,下一张截图显示了 Marvell ESPRESSObin v5 的顶部视图(从现在开始,请注意我不会再明确添加“v5”):

在前面的截图中,我们可以看到以下组件:

  • 电源连接器(12V DC 插孔)

  • 重置开关

  • 微型 USB 设备端口(串行控制台)

  • 以太网端口

  • USB 主机端口

下一张截图显示了板子的底部视图,微型 SD 卡槽位于其中;这是我们将在本章后面创建的微型 SD 卡的插入位置:

在这本书中,我们将看到如何管理(和重新安装)完整的 Debian 发行版,这将使我们能够拥有一系列准备运行的软件包,就像在普通 PC 上一样(事实上,Debian ARM64 版本等同于 Debian x86 版本)。之后,我们将为板载开发设备驱动程序,然后在可能的情况下,将它们与连接到 ESPRESSObin 本身的真实设备进行测试。本章还包括有关如何设置主机系统的简短教程,您可以使用它来设置基于 GNU/Linux 的工作机器或专用虚拟机。

本章中使用的代码和其他文件可以从 GitHub 上下载:github.com/giometti/linux_device_driver_development_cookbook/tree/master/chapter_01

设置主机机器

正如每个优秀的设备驱动程序开发者所知,主机机器是绝对必要的。

即使嵌入式设备如今变得更加强大(以及 ESPRESSObin

是其中之一),主机机器可以帮助处理一些资源密集型的任务。

因此,在本节中,我们将展示如何设置我们的主机机器。

我们决定使用的主机机器可以是普通 PC 或虚拟机——它们是等效的——但重要的是它必须运行基于 GNU/Linux 的操作系统。

准备工作

在本书中,我将使用基于 Ubuntu 18.04 LTS 的系统,但您可以决定尝试在另一个主要的 Linux 发行版中复制一些设置和安装命令,对于 Debian 衍生版来说,这将需要很少的努力,或者在非 Debian 衍生版发行版中需要更多的复杂操作。

我不打算展示如何在 PC 上或虚拟机上安装全新的 Ubuntu 系统,因为对于真正的程序员来说,这是一项非常容易的任务;然而,作为本章的最后一步(在外部硬件上进行本地编译配方),我将介绍一个有趣的跨平台环境,并详细介绍如何安装它,这个环境被证明对于在主机机器上编译外部目标代码非常有用。当我们需要在开发 PC 上运行多个不同的操作系统时,这个过程非常有用。

因此,此时,读者应该已经拥有自己的 PC 运行(本地或虚拟化)全新安装的 Ubuntu 18.04 LTS 操作系统。

主机 PC 的主要用途是编辑和交叉编译我们的新设备驱动程序,并通过串行控制台管理我们的目标设备,创建其根文件系统等等。

为了正确执行此操作,我们需要一些基本工具;其中一些是通用的,而其他一些取决于我们将要编写驱动程序的特定平台。

通用工具肯定包括编辑器、版本控制系统和编译器及其相关组件,而特定平台工具主要是交叉编译器及其相关组件(在某些平台上,我们可能需要额外的工具,但我们的需求可能有所不同,在任何情况下,每个制造商都会为我们提供所有所需的舒适编译环境)。

关于编辑器:我不打算在上面浪费任何言语,因为读者可以使用他们想要的任何编辑器(例如,我仍然使用 vi 编辑器进行编程),但是对于其他工具,我将不得不更具体。

如何做...

现在我们的 GNU/Linux 发行版已经在我们的主机 PC 上运行起来了,我们可以开始安装一些我们在本书中要使用的程序:

  1. 首先,让我们安装基本的编译工具:
$ sudo apt install gcc make pkg-config \
 bison flex ncurses-dev libssl-dev \
 qemu-user-static debootstrap

正如您已经知道的那样,sudo命令用于以特权用户身份执行命令。它应该已经存在于您的系统中,否则您可以使用apt install sudo命令作为 root 用户进行安装。

  1. 接下来,我们必须测试编译工具。我们应该能够编译一个 C 程序。作为一个简单的测试,让我们使用存储在helloworld.c文件中的以下标准Hello World代码:
#include <stdio.h>

int main(int argc, char *argv[])
{
    printf("Hello World!\n");

    return 0;
}

请记住,代码可以从我们的 GitHub 存储库中下载。

  1. 现在,我们应该能够通过使用以下命令来编译它:
$ make CFLAGS="-Wall -O2" helloworld
cc -Wall -O2 helloworld.c -o helloworld

在上面的命令中,我们同时使用了编译器和make工具,这是在舒适和可靠的方式下编译每个 Linux 驱动程序所必需的。

您可以通过查看www.gnu.org/software/make/来获取有关make的更多信息,对于gcc,您可以转到www.gnu.org/software/gcc/

  1. 最后,我们可以在主机 PC 上进行测试,如下所示:
$ ./helloworld 
Hello World!
  1. 下一步是安装交叉编译器。由于我们将使用 ARM64 系统,我们需要一个交叉编译器及其相关工具。要安装它们,我们只需使用以下命令:
$ sudo apt install gcc-7-aarch64-linux-gnu

请注意,我们还可以使用 ESPRESSObin 维基中报告的外部工具链,网址为wiki.espressobin.net/tiki-index.php?page=Build+From+Source+-+Toolchain;但是,Ubuntu 工具链运行得很完美!

  1. 安装完成后,通过使用上述Hello World程序来测试我们的新交叉编译器,如下所示:
$ sudo ln -s /usr/bin/aarch64-linux-gnu-gcc-7 /usr/bin/aarch64-linux-gnu-gcc
$ make CC=aarch64-linux-gnu-gcc CFLAGS="-Wall -O2" helloworld
aarch64-linux-gnu-gcc-7 -Wall -O2 helloworld.c -o helloworld

请注意,我已经删除了先前编译的helloworld程序,以便能够正确编译这个新版本。为此,我使用了mv helloworld helloworld.x86_64命令,因为我将再次需要 x86 版本。

还要注意,由于 Ubuntu 不会自动创建标准的交叉编译器名称aarch64-linux-gnu-gcc,我们必须在执行make之前手动执行上述ln命令。

  1. 好了,现在我们可以通过使用以下file命令来验证为 ARM64 新创建的helloworld程序的版本。这将指出程序编译为哪个平台:
$ file helloworld
helloworld: ELF 64-bit LSB shared object, ARM aarch64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-aarch64.so.1, for GNU/Linux 3.7.0, BuildID[sha1]=c0d6e9ab89057e8f9101f51ad517a253e5fc4f10, not stripped

如果我们再次在先前重命名的版本helloworld.x86_64上使用file命令,我们会得到以下结果:

$ file helloworld.x86_64 
helloworld.x86_64: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=cf932fab45d36f89c30889df98ed382f6f648203, not stripped
  1. 要测试这个新版本是否真的是为 ARM64 平台而编译的,我们可以使用QEMU,这是一个开源的通用机器模拟器和虚拟化程序,能够在运行平台上执行外部代码。要安装它,我们可以使用apt命令,如上述代码中所示,指定qemu-user-static包:
$ sudo apt install qemu-user-static
  1. 然后,我们可以执行我们的 ARM64 程序:
$ qemu-aarch64-static -L /usr/aarch64-linux-gnu/ ./helloworld
Hello World!

要获取有关 QEMU 的更多信息,一个很好的起点是它的主页www.qemu.org/

  1. 下一步是安装版本控制系统。我们必须安装用于 Linux 项目的版本控制系统,即git。要安装它,我们可以像之前一样使用以下命令:
$ sudo apt install git

如果一切正常,我们应该能够按如下方式执行它:

$ git --help
usage: git [--version] [--help] [-C <path>] [-c <name>=<value>]
           [--exec-path[=<path>]] [--html-path] [--man-path]
           [--info-path] [-p | --paginate | --no-pager]
           [--no-replace-objects] [--bare] [--git-dir=<path>]
           [--work-tree=<path>] [--namespace=<name>]
           <command> [<args>]

These are common Git commands used in various situations:

start a working area (see also: git help tutorial)
   clone Clone a repository into a new directory
   init Create an empty Git repository or reinitialise an existing one
...

在本书中,我将解释每个使用的git命令,但是为了完全了解这个强大的工具,我建议您开始阅读git-scm.com/

另请参阅

  • 有关 Debian 软件包管理的更多信息,您可以在互联网上搜索,但一个很好的起点是wiki.debian.org/Apt,而有关编译工具(gccmake和其他 GNU 软件)的最佳文档在www.gnu.org/software/

  • 然后,有关git的更好文档的最佳位置在git-scm.com/book/en/v2,那里有在线提供的精彩书籍Pro Git

使用串行控制台

正如已经说明的(以及任何嵌入式设备的真正程序员所知道的),串行控制台在设备驱动程序开发阶段是必不可少的!因此,让我们看看如何通过其串行控制台访问我们的 ESPRESSObin。

准备工作

技术要求部分的截图所示,有一个微型 USB 连接器可用,并且直接连接到 ESPRESSObin 的串行控制台。因此,使用适当的 USB 电缆,我们可以将其连接到我们的主机 PC。

如果所有连接都正常,我们可以执行任何串行终端仿真器来查看串行控制台的数据。关于这个工具,我必须声明,作为编辑程序,我们可以使用任何我们喜欢的。但是,我将展示如何安装两个更常用的终端仿真程序——minicomscreen

请注意,此工具并非绝对必需,其使用取决于您将要使用的平台;但是,在我看来,这是有史以来最强大的开发和调试工具!因此,您绝对需要它。

要安装minicom,请使用以下命令:

$ sudo apt install minicom

现在,要安装名为screen的终端仿真器我们只需将minicom字符串替换为screen数据包名称,如下所示:

$ sudo apt install screen

它们都需要一个串行端口来工作,并且调用命令非常相似。为简洁起见,我将仅报告它们与 ESPRESSObin 连接的用法;但是,有关它们的更多信息,您应该参考它们的手册页(使用man minicomman screen来显示它们)。

如何做到...

要测试与目标系统的串行连接,我们可以执行以下步骤:

  1. 首先,我们必须找到正确的串行端口。由于 ESPRESSObin 使用 USB 模拟串行端口(波特率为 115,200),通常我们的目标端口被命名为ttyUSB0(但您的情况可能有所不同,因此在继续之前让我们验证一下),因此我们必须使用以下minicom命令来连接 ESPRESSObin 串行控制台:
$ minicom -o -D /dev/ttyUSB0

要正确访问串行控制台,我们可能需要适当的权限。实际上,我们可以尝试执行前面的minicom命令,但是我们没有输出!这是因为如果我们没有足够的权限访问端口,minicom命令会悄悄退出。我们可以通过简单地使用另一个命令来验证我们的权限,如下所示:

$ cat /dev/ttyUSB0

cat: /dev/ttyUSB0: Permission denied

在这种情况下,cat命令完美地告诉我们出了什么问题,因此我们可以使用sudo来解决这个问题,或者更好的是,通过正确将我们系统的用户添加到正确的组,如下所示:

$ ls -l /dev/ttyUSB0 crw-rw---- 1 root dialout 188, 0 Jan 12 23:06 /dev /ttyUSB0

$ sudo adduser $LOGNAME dialout

然后,我们注销并重新登录,就可以无问题地访问串行设备了。

  1. 使用screen的等效命令如下所示:
$ screen /dev/ttyUSB0 115200

请注意,在minicom上,我没有指定串行通信选项(波特率,奇偶校验等),而对于screen,我在命令行上添加了波特率;这是因为我的默认minicom配置会自动使用正确的通信选项,而screen使用 9,600 波特率作为默认波特率。有关如何进行此设置以适应您的需求的进一步信息,请参阅程序手册页。

  1. 如果一切顺利,在正确的串行端口上执行终端仿真器后,打开我们的 ESPRESSObin(只需插入电源)。我们应该在终端上看到以下输出:
NOTICE: Booting Trusted Firmware
NOTICE: BL1: v1.3(release):armada-17.06.2:a37c108
NOTICE: BL1: Built : 14:31:03, Jul 5 2NOTICE: BL2: v1.3(release):armada-17.06.2:a37c108
NOTICE: BL2: Built : 14:31:04, Jul 5 201NOTICE: BL31: v1.3(release):armada-17.06.2:a37c108
NOTICE: BL31:

U-Boot 2017.03-armada-17.06.3-ga33ecb8 (Jul 05 2017 - 14:30:47 +0800)

Model: Marvell Armada 3720 Community Board ESPRESSOBin
       CPU @ 1000 [MHz]
       L2 @ 800 [MHz]
       TClock @ 200 [MHz]
       DDR @ 800 [MHz]
DRAM: 2 GiB
U-Boot DComphy-0: USB3 5 Gbps 
Comphy-1: PEX0 2.5 Gbps 
Comphy-2: SATA0 6 Gbps 
SATA link 0 timeout.
AHCI 0001.0300 32 slots 1 ports 6 Gbps 0x1 impl SATA mode
flags: ncq led only pmp fbss pio slum part sxs 
PCIE-0: Link down
MMC: sdhci@d0000: 0
SF: Detected w25q32dw with page size 256 Bytes, erase size 4 KiB, total 4 MiB
Net: eth0: neta@30000 [PRIME]
Hit any key to stop autoboot: 2 

另请参阅

配置和构建内核

现在,是时候下载内核源代码,然后配置和构建它们了。这一步是必需的,原因有几个:第一个是我们需要一个内核来引导我们的 ESPRESSObin 以启动操作系统,第二个是我们需要一个配置好的内核源树来编译我们的驱动程序。

准备就绪

由于我们的 ESPRESSObin 现在已经支持到 vanilla 内核自 4.11 版本以来,我们可以使用以下git命令获取 Linux 源代码:

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

这个命令需要很长时间才能完成,所以我建议您喝杯您最喜欢的咖啡休息一下(就像真正的程序员应该做的那样)。

完成后,我们可以进入linux目录查看 Linux 源代码:

$ cd linux/
$ ls
arch CREDITS firmware ipc lib mm scripts usr
block crypto fs Kbuild LICENSES net security virt
certs Documentation include Kconfig MAINTAINERS README sound
COPYING drivers init kernel Makefile samples tools

这些源代码与最新的内核发布相关,可能不稳定,因此为了确保我们使用的是稳定的内核发布(或长期发布),让我们提取 4.18 版本,这是撰写本章时的当前稳定发布,如下所示:

$ git checkout -b v4.18 v4.18

如何做...

在开始编译之前,我们必须配置内核和我们的编译环境。

  1. 最后一个任务非常简单,它包括执行以下环境变量分配:
$ export ARCH=arm64
$ export CROSS_COMPILE=aarch64-linux-gnu-
  1. 然后,我们可以通过简单地使用以下命令选择 ESPRESSObin 标准内核配置:
$ make defconfig

根据您使用的内核版本,默认配置文件也可能称为mvebu_defconfig,也可能称为mvebu_v5_defconfigmvebu_v7_defconfig。因此,请查看linux/arch/arm64/configs/目录,以查看哪个文件最适合您的需求。

在我的系统中,我有以下内容:

$ ls linux/arch/arm64/configs/

defconfig

  1. 如果我们希望修改此默认配置,可以执行make menuconfig命令,这将显示一个漂亮的菜单,我们可以在其中输入我们的修改以满足我们的需求。以下屏幕截图显示了内核配置菜单在终端上的外观:

  1. 在继续之前,我们必须确保分布式交换架构DSA)已经在内核中启用,否则我们将无法使用以太网端口!这是因为 ESPRESSObin 具有一个复杂(而且非常强大)的内部网络交换机,必须使用此特殊支持进行管理。

有关 DSA 的更多信息,您可以开始阅读linux/Documentation/networking/dsa/dsa.txt文件,该文件位于我们目前正在处理的内核源代码中。

  1. 要启用 DSA 支持,只需在内核菜单中导航至网络支持。转到网络选项,最后启用分布式交换架构支持条目。之后,我们必须返回到菜单的顶层,然后选择这些条目:设备驱动程序 | 网络设备支持 | 分布式交换架构驱动程序,然后启用 Marvell 88E6xxx 以太网交换芯片支持,这是 ESPRESSObin 的内置交换芯片。

请记住,要将内核功能启用为模块或内置,您需要突出显示所需的功能,然后按空格键,直到<>字符内的字符更改为(表示内置,即<>)或 M(表示模块,即)。

请注意,要将 DSA 作为内置启用而不是作为模块,我们必须禁用 802.1d 以太网桥接支持(即上面的条目)。

  1. 好了,所有内核设置都就绪后,我们可以使用以下make命令开始内核编译:
$ make Image dtbs modules

与下载命令一样,此命令将需要很长时间才能完成,因此让我建议您再休息一下。但是,为了加快编译过程,您可以尝试使用-j选项参数,告诉make使用多个并行进程来编译代码。例如,在我的机器上,有八个 CPU 线程,我使用以下命令:

$ make -j8 Image dtbs modules

因此,让我们尝试使用以下lscpu命令来获取系统的 CPU 数量:

lscpu | grep '^CPU(s):'

CPU(s): 8

或者,在 Ubuntu/Debian 上,还有预安装的nproc实用程序,因此以下命令也可以完成任务:

$ make -j$(nproc)

完成后,我们应该将内核映像放入arch/arm64/boot/Image文件中,并将设备树二进制文件放入arch/arm64/boot/dts/marvell/armada-3720-espressobin.dtb文件中,这些文件已准备好传输到我们将在下一个配方中构建的 microSD 中,设置目标机器

另请参阅

设置目标机器

现在,是时候在目标系统上安装我们需要的东西了;由于 ESPRESSObin 只带有引导加载程序出售,我们必须做一些工作,以便获得一个具有适当操作系统的完全功能系统。

在本书中,我将使用 Debian OS 为 ESPRESSObin,但您可以使用其他 OS,如wiki.espressobin.net/tiki-index.php?page=Software+HowTo中所述。在这个网站上,您可以获取有关如何正确设置 ESPRESSObin 以满足您需求的更详细信息。

准备工作

即使 ESPRESSObin 可以从不同的介质引导,我们将使用 microSD,因为这是设置系统的最简单和最有用的方式。有关不同介质,请参阅 ESPRESSObin 的维基页面—参见wiki.espressobin.net/tiki-index.php?page=Boot+from+removable+storage+-+Ubuntu以获取一些示例。

如何做到这一点...

要设置 microSD,我们必须使用我们的主机 PC,因此插入它,然后找到相应的设备。

  1. 如果我们使用 SD/microSD 插槽,一旦插入介质,我们将在内核消息中得到类似以下内容:
mmc0: cannot verify signal voltage switch
mmc0: new ultra high speed SDR50 SDHC card at address aaaa
mmcblk0: mmc0:aaaa SL08G 7.40 GiB 
 mmcblk0: p1

要在终端上获取内核消息,我们可以使用dmesg命令。

但是,如果我们要使用 microSD 到 USB 适配器内核,消息将如下所示:

usb 1-6: new high-speed USB device number 5 using xhci_hcd
usb 1-6: New USB device found, idVendor=05e3, idProduct=0736
usb 1-6: New USB device strings: Mfr=3, Product=4, SerialNumber=2
usb 1-6: Product: USB Storage
usb 1-6: Manufacturer: Generic
usb 1-6: SerialNumber: 000000000272
usb-storage 1-6:1.0: USB Mass Storage device detected
scsi host4: usb-storage 1-6:1.0
usbcore: registered new interface driver usb-storage
usbcore: registered new interface driver uas
scsi 4:0:0:0: Direct-Access Generic STORAGE DEVICE 0272 PQ: 0 ANSI: 0
sd 4:0:0:0: Attached scsi generic sg3 type 0
sd 4:0:0:0: [sdc] 15523840 512-byte logical blocks: (7.95 GB/7.40 GiB)
sd 4:0:0:0: [sdc] Write Protect is off
sd 4:0:0:0: [sdc] Mode Sense: 0b 00 00 08
sd 4:0:0:0: [sdc] No Caching mode page found
sd 4:0:0:0: [sdc] Assuming drive cache: write through
 sdc: sdc1
sd 4:0:0:0: [sdc] Attached SCSI removable disk
  1. 另一个查找介质的简单方法是使用lsblk命令,如下所示:
$ lsblk 
NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINT
loop0 7:0 0 5M 1 loop /snap/gedit/66
loop1 7:1 0 4.9M 1 loop /snap/canonical-livepatch/50
...
sdb 8:16 0 931.5G 0 disk 
└─sdb1 8:17 0 931.5G 0 part /run/schroot/mount/ubuntu-xenial-amd64-f72c490
sr0 11:0 1 1024M 0 rom 
mmcblk0 179:0 0 7.4G 0 disk 
└─mmcblk0p1
        179:1 0 7.4G 0 part /media/giometti/5C60-6750
  1. 现在很明显,我们的 microSD 卡在此列为/dev/mmcblk0,但它不是空的。由于我们想要清除它的所有内容,我们必须首先使用以下命令清除它:
$ sudo dd if=/dev/zero of=/dev/mmcblk0 bs=1M count=100
  1. 在进行清除之前,您可能需要卸载设备,以便在媒体设备上安全工作,因此让我们使用umount命令在所有设备的所有分区上卸载它们,就像我将在我的 microSD 上的唯一定义的分区中所做的那样:
$ sudo umount /dev/mmcblk0p1

对于 microSD 上定义的每个分区,您必须重复此命令。

  1. 现在,我们将使用下一个命令在空 SD 卡上创建一个新分区/dev/mmcblk0p1
$ (echo n; echo p; echo 1; echo ''; echo ''; echo w) | sudo fdisk /dev/mmcblk0

如果一切正常,我们的 microSD 介质应该显示为格式化的,如下所示:

$ sudo fdisk -l /dev/mmcblk0
Disk /dev/mmcblk0: 7.4 GiB, 7948206080 bytes, 15523840 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: dos
Disk identifier: 0x34f32673

Device Boot Start End Sectors Size Id Type
/dev/mmcblk0p1 2048 15523839 15521792 7.4G 83 Linux
  1. 然后,我们必须使用以下命令将其格式化为 EXT4:
$ sudo mkfs.ext4 -O ^metadata_csum,⁶⁴bit -L root /dev/mmcblk0p1

请注意,此命令行仅适用于e2fsprogs版本>=1.43!如果您使用较旧的版本,应使用以下命令:

$ sudo mkfs.ext4 -L root /dev/mmcblk0p1

  1. 接下来,在本地 Linux 机器上挂载此分区:
$ sudo mount /dev/mmcblk0p1 /mnt/

请注意,在某些操作系统(特别是 Ubuntu)上,一旦我们拔掉然后再次插入媒体设备,它就会自动挂载到/media/$USER/root中,其中$USER是一个保存您用户名的环境变量。例如,在我的机器上,我有以下内容:

$ ls -ld /media/$USER/root

drwxr-xr-x 3 root root 4096 Jan 10 14:28 /media/giometti/root/

添加 Debian 文件

我决定使用 Debian 作为目标操作系统,因为它是我用于开发(并且在可能的情况下用于生产)系统的最喜欢的发行版:

  1. 要安装它,我们再次使用 QEMU 软件,使用以下命令:
$ sudo qemu-debootstrap \
 --arch=arm64 \
 --include="sudo,file,openssh-server" \
 --exclude="debfoster" \
 stretch ./debian-stretch-arm64 http://deb.debian.org/debian

您可能会看到有关密钥环的警告;它们是无害的,可以安全地忽略:

W: 无法检查发布签名;

我想这是另一个咖啡时间的命令。

  1. 完成后,我们应该在debian-stretch-arm64中找到一个干净的 Debian 根文件系统,但是,在将其转移到 microSD 之前,我们应该像这样修复hostname文件的内容:
$ sudo bash -c 'echo espressobin | cat > ./debian-stretch-arm64/etc/hostname'
  1. 然后,我们必须将串行设备ttyMV0添加到/etc/securetty文件中,以便能够通过串行设备/dev/ttyMV0登录为根用户。使用以下命令:
$ sudo bash -c 'echo -e "\n# Marvell serial ports\nttyMV0" | \
 cat >> ./debian-stretch-arm64/etc/securetty'

使用man securetty获取有关通过串行连接登录根用户的更多信息。

  1. 最后一步,我们必须设置根密码:
$ sudo chroot debian-stretch-arm64/ passwd
Enter new UNIX password: 
Retype new UNIX password: 
passwd: password updated successfully

在这里,我使用root字符串作为根用户的密码(您可以选择自己的密码)。

为了进一步了解chroot命令的使用,您可以使用man chroot命令,或者继续阅读本章的最后,我将更好地解释它的工作原理。

现在,我们可以使用以下命令将所有文件安全地复制到我们的 microSD 中:

$ sudo cp -a debian-stretch-arm64/* /media/$USER/root/

这是 microSD 内容应该是这样的:

$ ls /media/$USER/root/
bin   dev  home  lost+found  mnt  proc  run   srv  tmp  var
boot  etc  lib   media       opt  root  sbin  sys  usr

添加内核

在 OS 文件之后,我们还需要内核映像来获得运行的内核,并且在前面的部分中,我们将内核映像放入arch/arm64/boot/Image文件中,并将设备树二进制文件放入arch/arm64/boot/dts/marvell/armada-3720-espressobin.dtb文件中,这些文件已准备好转移到我们新创建的 microSD 中:

  1. 让我们将它们复制到/boot目录中,就像这样:
$ sudo cp arch/arm64/boot/Image \
 arch/arm64/boot/dts/marvell/armada-3720-espressobin.dtb \
 /media/$USER/root/boot/

如果 microSD 中没有/boot目录,并且前面的命令返回错误,您可以使用以下命令进行恢复,并重新运行前面的cp命令:

$ sudo mkdir /media/$USER/root/boot

然后,/boot目录应该是这样的:

$ ls /media/$USER/root/boot/
armada-3720-espressobin.dtb  Image
  1. 前面的文件足以启动系统;但是,为了安装内核模块和头文件,这对于编译新软件很有用,我们可以在将所有 Debian 文件安装到 microSD 后使用下一个命令(以避免用 Debian 文件覆盖):
$ sudo -E make modules_install INSTALL_MOD_PATH=/media/$USER/root/
$ sudo -E make headers_install INSTALL_HDR_PATH=/media/$USER/root/usr/

好了,现在我们终于准备好将所有内容绑定在一起并运行我们的新 Debian 系统,所以让我们卸载 microSD 并将其插入 ESPRESSObin。

设置引导变量

上电后,我们应该从串行控制台获得引导加载程序的消息,然后我们应该看到超时运行到 0,然后执行自动引导:

  1. 通过按键盘上的Enter键快速停止倒计时,以获得引导加载程序的提示,如下所示:
Model: Marvell Armada 3720 Community Board ESPRESSOBin
       CPU @ 1000 [MHz]
       L2 @ 800 [MHz]
       TClock @ 200 [MHz]
       DDR @ 800 [MHz]
DRAM: 2 GiB
U-Boot DComphy-0: USB3 5 Gbps 
Comphy-1: PEX0 2.5 Gbps 
Comphy-2: SATA0 6 Gbps 
SATA link 0 timeout.
AHCI 0001.0300 32 slots 1 ports 6 Gbps 0x1 impl SATA mode
flags: ncq led only pmp fbss pio slum part sxs 
PCIE-0: Link down
MMC: sdhci@d0000: 0
SF: Detected w25q32dw with page size 256 Bytes, erase size 4 KiB, total 4 MiB
Net: eth0: neta@30000 [PRIME]
Hit any key to stop autoboot: 0 
Marvell>>

ESPRESSObin 的引导加载程序是 U-Boot,其主页位于www.denx.de/wiki/U-Boot

  1. 现在,让我们再次使用ext4ls命令检查 microSD 卡是否具有必要的文件,如下所示:
Marvell>> ext4ls mmc 0:1 boot
<DIR> 4096 .
<DIR> 4096 ..
        18489856 Image
            8359 armada-3720-espressobin.dtb

好了,一切就绪,所以只需要一些变量就可以从 microSD 卡启动。

  1. 我们可以使用echo命令在任何时候显示当前定义的变量,并且可以使用setenv命令可选地重新配置它们。首先,检查并设置正确的镜像和设备树路径和名称:
Marvell>> echo $image_name
Image
Marvell>> setenv image_name boot/Image
Marvell>> echo $fdt_name
armada-3720-espressobin.dtb
Marvell>> setenv fdt_name boot/armada-3720-espressobin.dtb

请注意,文件名是正确的,但路径名不正确;这就是为什么我使用setenv命令正确重新定义它们。

  1. 接下来,定义bootcmd变量,我们将使用它从 microSD 卡启动:
Marvell>> setenv bootcmd 'mmc dev 0; \
 ext4load mmc 0:1 $kernel_addr $image_name; \
 ext4load mmc 0:1 $fdt_addr $fdt_name; \
 setenv bootargs $console root=/dev/mmcblk0p1 rw rootwait; \
 booti $kernel_addr - $fdt_addr'

我们必须小心设置前面的根路径,指向我们提取 Debian 文件系统的位置(在我们的情况下是第一个分区)。

  1. 使用saveenv命令随时保存设置的变量。

  2. 最后,我们通过简单输入reset命令启动 ESPRESSObin,如果一切正常,我们应该看到系统启动并运行,最后,我们应该看到系统登录提示,如下所示:

Debian GNU/Linux 9 espressobin ttyMV0

giometti-VirtualBox login:
  1. 现在,使用之前设置的root密码以 root 身份登录:
Debian GNU/Linux 9 espressobin ttyMV0

espressobin login: root
Password: 
Linux espressobin 4.18.0 #2 SMP PREEMPT Sun Jan 13 13:05:03 CET 2019 aarch64

The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.

Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
root@espressobin:~# 

设置网络

好了,现在我们的 ESPRESSObin 已经准备好执行我们的代码和驱动程序了!然而,在结束本节之前,让我们看一下网络配置,因为使用 SSH 连接登录到板上或者快速复制文件可能会进一步有用(即使我们可以移除 microSD,然后直接从主机 PC 复制文件):

  1. 查看 ESPRESSObin 上可用的网络接口,我们看到以下内容:
# ip link
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT
 group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: eth0: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT group
 default qlen 532
    link/ether 3a:ac:9b:44:90:e9 brd ff:ff:ff:ff:ff:ff
3: wan@eth0: <BROADCAST,MULTICAST,M-DOWN> mtu 1500 qdisc noop state DOWN mode DE
FAULT group default qlen 1000
    link/ether 3a:ac:9b:44:90:e9 brd ff:ff:ff:ff:ff:ff
4: lan0@eth0: <BROADCAST,MULTICAST,M-DOWN> mtu 1500 qdisc noop state DOWN mode D
EFAULT group default qlen 1000
    link/ether 3a:ac:9b:44:90:e9 brd ff:ff:ff:ff:ff:ff
5: lan1@eth0: <BROADCAST,MULTICAST,M-DOWN> mtu 1500 qdisc noop state DOWN mode D
EFAULT group default qlen 1000
    link/ether 3a:ac:9b:44:90:e9 brd ff:ff:ff:ff:ff:ff

eth0接口是将 CPU 与以太网交换机连接的接口,而wanlan0lan1接口是我们可以物理连接以太网电缆的接口(请注意,系统将它们称为wan@eth0lan0@eth0lan1@eth1,以突出它们是eth0的从属)。以下是 ESPRESSObin 的照片,我们可以看到每个以太网端口及其标签:

  1. 尽管它们的名称不同,但所有端口都是等效的,因此将以太网电缆连接到一个端口(我将使用wan),然后在eth0之后启用它,如下所示:
# ip link set eth0 up
mvneta d0030000.ethernet eth0: configuring for fixed/rgmii-id link mode
mvneta d0030000.ethernet eth0: Link is Up - 1Gbps/Full - flow control off
# ip link set wan up 
mv88e6085 d0032004.mdio-mii:01 wan: configuring for phy/ link mode
mv88e6085 d0032004.mdio-mii:01 wan: Link is Up - 100Mbps/Full - flow control rx/tx

请注意,在上述输出中,还有显示一切正常时应看到的内核消息。

  1. 现在,我们可以手动设置 IP 地址,或者使用dhclient命令询问 DHCP 服务器,以获取上网所需的信息:
# dhclient wan

这是我的网络配置:

# ip addr show wan
3: wan@eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP g
roup default qlen 1000
    link/ether 9e:9f:6b:5c:cf:fc brd ff:ff:ff:ff:ff:ff
    inet 192.168.0.100/24 brd 192.168.0.255 scope global wan
       valid_lft forever preferred_lft forever
  1. 现在,我们已经准备好安装新软件,或者尝试建立与 ESPRESSObin 的 SSH 连接;为此,让我们验证/etc/ssh/sshd_config文件中是否有以下 SSH 服务器的配置:
# grep 'PermitRootLogin yes' /etc/ssh/sshd_config
PermitRootLogin yes
  1. 如果我们没有输出,就无法以 root 身份登录到我们的 ESPRESSObin,因此我们必须将PermitRootLogin设置更改为yes,然后重新启动守护程序:
# /etc/init.d/ssh restart

Restarting ssh (via systemctl): ssh.service.
  1. 现在,在主机 PC 上,我们可以尝试通过 SSH 登录,如下所示:
$ ssh root@192.168.0.100
root@192.168.0.100's password: 
Linux espressobin 4.18.0 #2 SMP PREEMPT Sun Jan 13 13:05:03 CET 2019 aarch64

The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.

Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
Last login: Thu Nov 3 17:16:59 2016
-bash: warning: setlocale: LC_ALL: cannot change locale (en_GB.UTF-8)

参见

在外部硬件上进行本地编译

在结束本章之前,我想介绍一个有趣的跨平台系统,当我们需要在开发 PC 上运行多个不同的操作系统时非常有用。当我们需要一个完整的操作系统来编译设备驱动程序或应用程序,但没有目标设备来进行编译时,这一步非常有用。我们可以使用我们的主机 PC 来跨不同的操作系统和操作系统版本为外部硬件编译代码。

准备就绪

在我的职业生涯中,我使用了大量不同的平台,并且为它们所有都有一个虚拟机非常复杂且真正消耗系统资源(特别是如果我们决定同时运行其中几个!)。这就是为什么拥有一个可以在您的 PC 上执行外部代码的轻量级系统可能会很有趣。当然,这种方法不能用于测试设备驱动程序(我们需要真正的硬件来进行测试),但我们可以用它来快速运行本地编译器和/或本地用户空间代码,以防我们的嵌入式平台出现问题。让我们看看我在说什么。

设置目标机器配方中,关于 Debian OS 安装,我们使用chroot命令设置根密码。这个命令得到了 QEMU 的支持;事实上,在debian-stretch-arm64目录中,我们有一个 ARM64 根文件系统,可以在 x86_64 平台上仅使用 QEMU 执行。很明显,以这种方式,我们应该能够执行任何我们想要的命令,当然,我们将能够像下一个配方中一样执行 Bash shell。

如何做...

现在是时候看看chroot是如何工作的了:

  1. 通过使用我们的 x86_64 主机执行 ARM64 bash命令,如下所示:
$ sudo chroot debian-stretch-arm64/ bash
bash: warning: setlocale: LC_ALL: cannot change locale (en_GB.UTF-8)
root@giometti-VirtualBox:/# 
  1. 然后,我们可以像在 ESPRESSObin 上那样使用每个 ARM64 命令;例如,要列出当前目录中的文件,我们可以使用以下命令:
# ls /
bin  dev  home media  opt   root  sbin  sys  usr
boot etc  lib  mnt    proc  run   srv   tmp  var
# cat /etc/hostname 
espressobin

但是,也有一些陷阱;例如,我们完全错过了/proc/sys目录和程序,这些程序依赖于它们,肯定会失败:

# ls /{proc,sys}
/proc:

/sys:
# ps
Error: /proc must be mounted
  To mount /proc at boot you need an /etc/fstab line like:
      proc /proc proc defaults
  In the meantime, run "mount proc /proc -t proc"

为了解决这些问题,我们可以在执行chroot之前手动挂载这些缺失的目录,但由于它们太多了,这相当麻烦,所以我们可以尝试使用schroot实用程序,它反过来可以为我们完成所有这些步骤。让我们看看如何做。

有关schroot的详细信息,您可以使用man schroot查看其手册页面。

安装和配置 schroot

在 Ubuntu 中,这个任务非常简单:

  1. 首先,我们以通常的方式安装程序:
$ sudo apt install schroot
  1. 然后,我们必须配置它,以便正确进入我们的 ARM64 系统。为此,让我们将之前创建的根文件系统复制到一个专用目录中(在那里我们还可以添加任何其他我们希望用schroot模拟的发行版):
$ sudo mkdir /srv/chroot/
$ sudo cp -a debian-stretch-arm64/ /srv/chroot/
  1. 然后,我们必须通过在schroot配置目录中添加一个新文件来为我们的新系统创建适当的配置,如下所示:
$ sudo bash -c 'cat > /etc/schroot/chroot.d/debian-stretch-arm64 <<__EOF__
[debian-stretch-arm64]
description=Debian Stretch (arm64)
directory=/srv/chroot/debian-stretch-arm64
users=giometti
#groups=sbuild
#root-groups=root
#aliases=unstable,default
type=directory
profile=desktop
personality=linux
preserve-environment=true
__EOF__'

请注意,directory参数设置为包含我们的 ARM64 系统的路径,users设置为giometti,这是我的用户名(这是允许访问chroot环境的用户的逗号分隔列表—请参阅man schroot.conf)。

从前面的设置中,我们看到profile参数设置为desktop;这意味着它将考虑/etc/schroot/desktop/目录中的所有文件。特别是,fstab文件包含我们希望挂载到系统中的所有挂载点。因此,我们应该验证它至少包含以下行:

# <filesystem> <mount point> <type> <options> <dump> <pass>
/proc           /proc         none   rw,bind   0      0
/sys            /sys          none   rw,bind   0      0
/dev            /dev          none   rw,bind   0      0
/dev/pts        /dev/pts      none   rw,bind   0      0
/home           /home         none   rw,bind   0      0
/tmp            /tmp          none   rw,bind   0      0
/opt            /opt          none   rw,bind   0      0
/srv            /srv          none   rw,bind   0      0
tmpfs           /dev/shm      tmpfs  defaults  0      0
  1. 现在,我们必须重新启动schroot服务,如下所示:
$ sudo systemctl restart schroot

请注意,您也可以使用老式的方法重新启动:

$ sudo /etc/init.d/schroot restart

  1. 现在我们可以通过要求它们schroot来列出所有可用的环境,如下所示:
$ schroot -l
 chroot:debian-stretch-arm64
  1. 好的,一切就绪,我们可以进入模拟的 ARM64 系统了:
$ schroot -c debian-stretch-arm64
bash: warning: setlocale: LC_ALL: cannot change locale (en_GB.UTF-8)

由于我们还没有安装任何区域设置支持,因此前面的警告是相当明显的,应该可以安全地忽略。

  1. 现在,为了验证我们是否真的在执行 ARM64 代码,让我们尝试一些命令。例如,我们可以使用uname命令请求一些系统信息:
$ uname -a
Linux giometti-VirtualBox 4.15.0-43-generic #46-Ubuntu SMP Thu Dec 6 14:45:28 UTC 2018 aarch64 GNU/Linux

正如我们所看到的,系统显示其平台为aarch64,即 ARM64。然后,我们可以尝试执行之前交叉编译的helloworld程序;因为在chroot之后,当前目录没有改变(我们的主目录仍然是相同的),我们可以简单地回到编译的地方,然后像往常一样执行程序:

$ cd ~/Projects/ldddc/github/chapter_1/
$ file helloworld
helloworld: ELF 64-bit LSB shared object, ARM aarch64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-aarch64.so.1, for GNU/Linux 3.7.0, BuildID[sha1]=c0d6e9ab89057e8f9101f51ad517a253e5fc4f10, not stripped
$ ./helloworld
Hello World!

该程序仍然像我们在 ARM64 系统上时一样执行。太棒了!

配置模拟的操作系统

如果我们不配置新系统进行本地编译,那么我们刚才看到的关于schroot的一切都没有意义,为了这样做,我们可以使用我们在主机 PC 上使用的每个 Debian 工具:

  1. 安装完整的编译环境后,我们可以在schroot环境中执行以下命令:
$ sudo apt install gcc make \
 bison flex ncurses-dev libssl-dev

请注意,sudo将要求您通常的密码,也就是您当前用于登录到主机 PC 的密码。

您可能不会从sudo获得密码请求,而会收到以下错误消息:

sudo: no tty present and no askpass program specified

您可以尝试再次执行前面的sudo命令,并添加-S选项参数。

apt命令可能会通知您某些软件包无法得到验证。只需忽略此警告并继续安装,按下Y键回答是。

如果一切顺利,我们现在应该能够执行之前使用的每个编译命令。例如,我们可以尝试再次本地重新编译helloworld程序(我们应该先删除当前的可执行文件;make将尝试重新编译它):

$ rm helloworld
$ make CFLAGS="-Wall -O2" helloworld
cc -Wall -O2 helloworld.c -o helloworld
$ file helloworld
helloworld: ELF 64-bit LSB shared object, ARM aarch64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-aarch64.so.1, for GNU/Linux 3.7.0, BuildID[sha1]=1393450a08fb9eea22babfb9296ce848bb806c21, not stripped
$ ./helloworld
Hello World!

请注意,网络支持是完全功能的,因此我们现在正在主机 PC 上的模拟 ARM64 环境上工作,就像我们在 ESPRESSObin 上一样。

另请参阅

第二章:深入了解内核

简单的操作系统(如 MS-DOS)总是在单 CPU 模式下执行,但类 Unix 操作系统使用双模式来有效地实现时间共享和资源分配和保护。在 Linux 中,CPU 在任何时候都处于受信任的内核模式(我们可以做任何我们想做的事情)或受限的用户模式(某些操作不允许)。所有用户进程都在用户模式下执行,而核心内核本身和大多数设备驱动程序(除了在用户空间实现的驱动程序)都在内核模式下运行,因此它们可以无限制地访问整个处理器指令集以及完整的内存和 I/O 空间。

当用户模式进程需要访问外围设备时,它不能自己完成,而必须通过设备驱动程序或其他内核模式代码通过系统调用来传递请求,系统调用在控制进程活动和管理数据交换中起着重要作用。在本章中,我们不会看到系统调用(它们将在第三章中介绍),但我们将通过直接向内核源代码添加新代码或使用内核模块来开始在内核中编程,这是另一种更灵活的方式来向内核添加代码。

一旦我们开始编写内核代码,我们必须不要忘记,当处于用户模式时,每个资源分配(CPU、RAM 等)都由内核自动管理(当进程死亡时可以适当释放它们),在内核模式下,我们被允许独占处理器,直到我们自愿放弃 CPU 或发生中断或异常;此外,如果不适当释放,每个请求的资源(如 RAM)都会丢失。这就是为什么正确管理 CPU 使用和释放我们请求的任何资源非常重要!

现在,是时候第一次跳入内核了,因此在本章中,我们将涵盖以下示例:

  • 向源代码添加自定义代码

  • 使用内核消息

  • 使用内核模块

  • 使用模块参数

技术要求

在本章中,我们需要在第一章的配置和构建内核示例中已经下载的内核源代码,当然,我们还需要安装交叉编译器,就像在第一章的设置主机机器示例中所示。本章中使用的代码和其他文件可以从 GitHub 上下载:github.com/giometti/linux_device_driver_development_cookbook/tree/master/chapter_02

向源代码添加自定义代码

作为第一步,让我们看看如何向我们的内核源代码中添加一些简单的代码。在这个示例中,我们将简单地添加一些愚蠢的代码,只是为了演示它有多容易,但在本书的后面,我们将添加更复杂的代码。

准备工作

由于我们需要将我们的代码添加到 Linux 源代码中,让我们进入存放所有源代码的目录。在我的系统中,我使用位于我的主目录中的Projects/ldddc/linux/路径。以下是内核源代码的样子:

$ cd Projects/ldddc/linux/
$ ls
arch        Documentation  Kbuild       mm               scripts   virt
block       drivers        Kconfig      modules.builtin  security  vmlinux
built-in.a  firmware       kernel       modules.order    sound     vmlinux.o
certs       fs             lib          Module.symvers   stNXtP40
COPYING     include        LICENSES     net System.map
CREDITS     init           MAINTAINERS  README tools
crypto      ipc            Makefile     samples usr

现在,我们需要设置环境变量ARCHCROSS_COMPILE,如下所示,以便能够为 ESPRESSObin 进行交叉编译代码:

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

因此,如果我们尝试执行以下make命令,系统应该像往常一样开始编译内核:

$ make Image dtbs modules
  CALL scripts/checksyscalls.sh
...

请注意,您可以通过在以下命令行上指定它们来避免导出前面的变量:

$ make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- \

Image dtbs modules

此时,内核源代码和编译环境已经准备就绪。

如何做...

让我们按照以下步骤来做:

  1. 由于本书涉及设备驱动程序,让我们从 Linux 源代码的drivers目录下开始添加我们的代码,具体来说是在drivers/misc中,杂项驱动程序所在的地方。我们应该在drivers/misc中放置一个名为dummy-code.c的文件,内容如下:
/*
 * Dummy code
 */

#include <linux/module.h>

static int __init dummy_code_init(void)
{
    printk(KERN_INFO "dummy-code loaded\n");
    return 0;
}

static void __exit dummy_code_exit(void)
{
    printk(KERN_INFO "dummy-code unloaded\n");
}

module_init(dummy_code_init);
module_exit(dummy_code_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Rodolfo Giometti");
MODULE_DESCRIPTION("Dummy code");
  1. 我们的新文件drivers/misc/dummy-code.c如果不正确地插入到内核配置和构建系统中,将不会产生任何效果。为了做到这一点,我们必须修改drivers/misc/Kconfigdrivers/misc/Makefile文件如下。前者文件必须更改如下:
--- a/drivers/misc/Kconfig
+++ b/drivers/misc/Kconfig
@@ -527,4 +527,10 @@ source "drivers/misc/echo/Kconfig"
 source "drivers/misc/cxl/Kconfig"
 source "drivers/misc/ocxl/Kconfig"
 source "drivers/misc/cardreader/Kconfig"
+
+config DUMMY_CODE
+       tristate "Dummy code"
+       default n
+       ---help---
+         This module is just for demonstration purposes.
 endmenu

后者的修改如下:

--- a/drivers/misc/Makefile
+++ b/drivers/misc/Makefile
@@ -58,3 +58,4 @@ obj-$(CONFIG_ASPEED_LPC_SNOOP) += aspeed-lpc-snoop.o
 obj-$(CONFIG_PCI_ENDPOINT_TEST) += pci_endpoint_test.o
 obj-$(CONFIG_OCXL) += ocxl/
 obj-$(CONFIG_MISC_RTSX) += cardreader/
+obj-$(CONFIG_DUMMY_CODE) += dummy-code.o

请注意,您可以通过在 Linux 源代码的主目录中使用patch命令轻松添加前面的代码以及编译所需的任何内容,如下所示:

$ patch -p1 < add_custom_code.patch

  1. 好吧,如果我们现在使用make menuconfig命令,并且我们通过设备驱动程序导航到杂项设备菜单条目的底部,我们应该会得到以下截图所示的内容:

在前面的截图中,我已经选择了虚拟代码条目,以便我们可以看到最终的设置应该是什么样子的。

请注意,虚拟代码条目必须选择为内置(*字符),而不是模块(M字符)。

还要注意,如果我们不执行make menuconfig命令,而是直接执行make Image命令来编译内核,那么构建系统将询问我们如何处理DUMMY_CODE设置,如下所示。显然,我们必须使用y字符回答是:

$ make Image

scripts/kconfig/conf --syncconfig Kconfig

*

* 重新启动配置...

*

*

* 杂项设备

*

模拟设备数字电位器(AD525X_DPOT)[N/m/y/?] n

...

虚拟代码(DUMMY_CODE)[N/m/y/?](NEW)y

  1. 如果一切都摆放正确,那么我们执行make Image命令重新编译内核。我们应该看到我们的新文件被编译然后添加到内核Image文件中,如下所示:
$ make Image
scripts/kconfig/conf --syncconfig Kconfig
...
  CC drivers/misc/dummy-code.o
  AR drivers/misc/built-in.a
  AR drivers/built-in.a
...
  LD vmlinux
  SORTEX vmlinux
  SYSMAP System.map
  OBJCOPY arch/arm64/boot/Image
  1. 好了,现在我们要做的就是用刚刚重新构建的Image文件替换 microSD 上的Image文件,然后重新启动系统(参见第一章中的如何添加内核配方,安装开发系统)。

它是如何工作的...

现在,是时候看看之前所有步骤是如何工作的了。在接下来的章节中,我们将更好地解释这段代码的真正作用。但是,目前,我们应该注意以下内容。

步骤 1中,请注意对module_init()module_exit()的调用,这是内核提供的 C 宏,用于告诉内核,在系统启动或关闭期间,必须调用我们提供的函数,名为dummy_code_init()dummy_code_exit(),这些函数只是打印一些信息消息。

在本章的后面,我们将详细了解printk()的作用以及KERN_INFO宏的含义,但是目前,我们只需要考虑它们用于在引导(或关闭)期间打印消息。例如,前面的代码指示内核在引导阶段的某个时候打印出消息 dummy-code loaded。

步骤 2中,在Makefile中,我们只是告诉内核,如果启用了CONFIG_DUMMY_CODE(即CONFIG_DUMMY_CODE=y),那么必须编译并插入内核二进制文件(链接)dummy-code.c,而使用Kconfig文件,我们只是将新模块添加到内核配置系统中。

步骤 3中,我们使用make menuconfig命令启用我们的代码的编译。

最后,在步骤 4中,我们重新编译内核以将我们的代码添加到其中。

步骤 5中,在引导过程中,我们应该看到以下内核消息:

...
loop: module loaded
dummy-code loaded
ahci-mvebu d00e0000.sata: AHCI 0001.0300 32 slots 1 ports 6 Gbps
...

另请参阅

  • 有关内核配置及其构建系统工作原理的更多信息,我们可以查看内核源代码中的内核文档文件,路径为linux/Documentation/kbuild/kconfig-macro-language.txt

使用内核消息

正如前面所述,串行控制台在我们需要从头开始设置系统时非常有用,但如果我们希望在生成时立即看到内核消息,它也非常有用。为了生成内核消息,我们可以使用多个函数,在本教程中,我们将看看它们以及如何在串行控制台或通过 SSH 连接显示消息。

准备工作

我们的 ESPRESSObin 是生成内核消息的系统,所以我们需要与它建立连接。通过串行控制台,这些消息一旦到达就会自动显示,但如果我们使用 SSH 连接,我们仍然可以通过读取特定文件来显示它们,就像以下命令一样:

# tail -f /var/log/kern.log

然而,串行控制台值得特别注意:实际上,在我们的示例中,只有当/proc/sys/kernel/printk文件中最左边的数字大于七时,内核消息才会自动显示在串行控制台上,如下所示:

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

这些魔术数字有明确定义的含义;特别是第一个代表内核必须在串行控制台上显示的错误消息级别。这些级别在linux/include/linux/kern_levels.h文件中定义,如下所示:

#define KERN_EMERG KERN_SOH "0"    /* system is unusable */
#define KERN_ALERT KERN_SOH "1"    /* action must be taken immediately */
#define KERN_CRIT KERN_SOH "2"     /* critical conditions */
#define KERN_ERR KERN_SOH "3"      /* error conditions */
#define KERN_WARNING KERN_SOH "4"  /* warning conditions */
#define KERN_NOTICE KERN_SOH "5"   /* normal but significant condition */
#define KERN_INFO KERN_SOH "6"     /* informational */
#define KERN_DEBUG KERN_SOH "7"    /* debug-level messages */

例如,如果前面文件的内容是 4,如下所示,只有具有KERN_EMERGKERN_ALERTKERN_CRITKERN_ERR级别的消息才会自动显示在串行控制台上:

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

为了允许显示所有消息、它们的子集或不显示任何消息,我们必须使用echo命令修改/proc/sys/kernel/printk文件的最左边的数字,就像在以下示例中那样,我们以这种方式完全禁用所有内核消息的打印。这是因为没有消息的优先级可以大于 0:

 # echo 0 > /proc/sys/kernel/printk

内核消息的优先级从 0(最高)开始,到 7(最低)结束!

现在我们知道如何显示内核消息,我们可以尝试对内核代码进行一些修改,以便对内核消息进行一些实验。

如何做到...

在前面的示例中,我们看到可以使用printk()函数生成内核消息,但是还有其他函数可以替代printk(),以便获得更高效的消息和更紧凑可读的代码:

  1. 使用以下宏(在include/linux/printk.h文件中定义),如下所示:
#define pr_emerg(fmt, ...) \
        printk(KERN_EMERG pr_fmt(fmt), ##__VA_ARGS__)
#define pr_alert(fmt, ...) \
        printk(KERN_ALERT pr_fmt(fmt), ##__VA_ARGS__)
#define pr_crit(fmt, ...) \
        printk(KERN_CRIT pr_fmt(fmt), ##__VA_ARGS__)
#define pr_err(fmt, ...) \
        printk(KERN_ERR pr_fmt(fmt), ##__VA_ARGS__)
#define pr_warning(fmt, ...) \
        printk(KERN_WARNING pr_fmt(fmt), ##__VA_ARGS__)
#define pr_warn pr_warning
#define pr_notice(fmt, ...) \
        printk(KERN_NOTICE pr_fmt(fmt), ##__VA_ARGS__)
#define pr_info(fmt, ...) \
        printk(KERN_INFO pr_fmt(fmt), ##__VA_ARGS__)
  1. 现在,要生成一个内核消息,我们可以这样做:查看这些定义,我们可以将前面示例中的dummy_code_init()dummy_code_exit()函数重写到dummy-code.c文件中,如下所示:
static int __init dummy_code_init(void)
{
        pr_info("dummy-code loaded\n");
        return 0;
}

static void __exit dummy_code_exit(void)
{
        pr_info("dummy-code unloaded\n");
}

工作原理...

如果我们仔细观察前面的打印函数(pr_info()和类似的函数),我们会注意到它们还依赖于pr_fmt(fmt)参数,该参数可用于向我们的消息中添加其他有用的信息。例如,以下定义通过添加当前模块和调用函数名称来改变pr_info()生成的所有消息:

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

请注意,pr_fmt()宏定义必须出现在文件的开头,甚至在包含之前,才能生效。

如果我们将这行添加到我们的dummy-code.c中,内核消息将会按照描述发生变化:

/*
 * Dummy code
 */

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

实际上,当执行pr_info()函数时,输出消息会告诉我们模块已被插入,变成以下形式,我们可以看到模块名称和调用函数名称,然后是加载消息:

dummy_code:dummy_code_init: dummy-code loaded

还有另一组打印函数,但在开始讨论它们之前,我们需要一些位于第三章中的信息,使用设备树,所以,暂时,我们只会继续使用这些函数。

还有更多...

有许多内核活动,其中许多确实很复杂,而且经常,内核开发人员必须处理几条消息,而不是所有消息都有趣;因此,我们需要找到一些方法来过滤出有趣的消息。

过滤内核消息

假设我们希望知道在引导期间检测到了哪些串行端口。我们知道可以使用tail命令,但是通过使用它,我们只能看到最新的消息;另一方面,我们可以使用cat命令来回忆自引导以来的所有内核消息,但那是大量的信息!或者,我们可以使用以下步骤来过滤内核消息:

  1. 在这里,我们使用grep命令来过滤uart(或UART)字符串中的行:
# cat /var/log/kern.log | grep -i uart
Feb 7 19:33:14 espressobin kernel: [ 0.000000] earlycon: ar3700_uart0 at MMIO 0x00000000d0012000 (options '')
Feb 7 19:33:14 espressobin kernel: [ 0.000000] bootconsole [ar3700_uart0] enabled
Feb 7 19:33:14 espressobin kernel: [ 0.000000] Kernel command line: console=ttyMV0,115200 earlycon=ar3700_uart,0xd0012000 loglevel=0 debug root=/dev/mmcblk0p1 rw rootwait net.ifnames=0 biosdevname=0
Feb 7 19:33:14 espressobin kernel: [ 0.289914] Serial: AMBA PL011 UART driver
Feb 7 19:33:14 espressobin kernel: [ 0.296443] mvebu-uart d0012000.serial: could not find pctldev for node /soc/internal-regs@d0000000/pinctrl@13800/uart1-pins, deferring probe
...

前面的输出也可以通过使用dmesg命令来获得,这是一个专为此目的设计的工具:

# dmesg | grep -i uart
[ 0.000000] earlycon: ar3700_uart0 at MMIO 0x00000000d0012000 (options '')
[ 0.000000] bootconsole [ar3700_uart0] enabled
[ 0.000000] Kernel command line: console=ttyMV0,115200 earlycon=ar3700_uart,0
xd0012000 loglevel=0 debug root=/dev/mmcblk0p1 rw rootwait net.ifnames=0 biosdev
name=0
[ 0.289914] Serial: AMBA PL011 UART driver
[ 0.296443] mvebu-uart d0012000.serial: could not find pctldev for node /soc/
internal-regs@d0000000/pinctrl@13800/uart1-pins, deferring probe
...

请注意,虽然cat显示日志文件中的所有内容,甚至是来自先前操作系统执行的非常旧的消息,但dmesg仅显示当前操作系统执行的消息。这是因为dmesg直接从当前运行的系统通过其环形缓冲区(即存储所有消息的缓冲区)获取内核消息。

  1. 另一方面,如果我们想收集有关早期引导活动的信息,我们仍然可以使用dmesg命令和head命令,以仅显示dmesg输出的前 10 行:
# dmesg | head -10 
[ 0.000000] Booting Linux on physical CPU 0x0000000000 [0x410fd034]
[ 0.000000] Linux version 4.18.0-dirty (giometti@giometti-VirtualBox) (gcc ve
rsion 7.3.0 (Ubuntu/Linaro 7.3.0-27ubuntu1~18.04)) #5 SMP PREEMPT Sun Jan 27 13:
33:24 CET 2019
[ 0.000000] Machine model: Globalscale Marvell ESPRESSOBin Board
[ 0.000000] earlycon: ar3700_uart0 at MMIO 0x00000000d0012000 (options '')
[ 0.000000] bootconsole [ar3700_uart0] enabled
[ 0.000000] efi: Getting EFI parameters from FDT:
[ 0.000000] efi: UEFI not found.
[ 0.000000] cma: Reserved 32 MiB at 0x000000007e000000
[ 0.000000] NUMA: No NUMA configuration found
[ 0.000000] NUMA: Faking a node at [mem 0x0000000000000000-0x000000007fffffff]
  1. 另一方面,如果我们对最后 10 行感兴趣,我们可以使用tail命令。实际上,我们已经看到,为了监视内核活动,我们可以像下面这样使用它:
# tail -f /var/log/kern.log

因此,要查看最后 10 行,我们可以执行以下操作:

# dmesg | tail -10 
  1. 同样,也可以使用dmesg,通过添加-w选项参数,如下例所示:
# dmesg -w
  1. dmesg命令也可以根据它们的级别过滤内核消息,方法是使用-l(或--level)选项参数,如下所示:
# dmesg -l 3 
[ 1.687783] advk-pcie d0070000.pcie: link never came up
[ 3.153849] advk-pcie d0070000.pcie: Posted PIO Response Status: CA, 0xe00 @ 0x0
[ 3.688578] Unable to create integrity sysfs dir: -19

前面的命令显示具有KERN_ERR级别的内核消息,而以下是显示具有KERN_WARNING级别的消息的命令:

# dmesg -l 4
[ 3.164121] EINJ: ACPI disabled.
[ 3.197263] cacheinfo: Unable to detect cache hierarchy for CPU 0
[ 4.572660] xenon-sdhci d00d0000.sdhci: Timing issue might occur in DDR mode
[ 5.316949] systemd-sysv-ge: 10 output lines suppressed due to ratelimiting
  1. 我们还可以组合级别,以同时具有KERN_ERRKERN_WARNING
# dmesg -l 3,4
[ 1.687783] advk-pcie d0070000.pcie: link never came up
[ 3.153849] advk-pcie d0070000.pcie: Posted PIO Response Status: CA, 0xe00 @ 0x0
[ 3.164121] EINJ: ACPI disabled.
[ 3.197263] cacheinfo: Unable to detect cache hierarchy for CPU 0
[ 3.688578] Unable to create integrity sysfs dir: -19
[ 4.572660] xenon-sdhci d00d0000.sdhci: Timing issue might occur in DDR mode
[ 5.316949] systemd-sysv-ge: 10 output lines suppressed due to ratelimiting
  1. 最后,在大量嘈杂的消息的情况下,我们可以要求系统通过使用以下命令来清除内核环形缓冲区(存储所有内核消息的地方):
# dmesg -C

现在,如果我们再次使用dmesg,我们将只看到新生成的内核消息。

另请参阅

  • 有关内核消息管理的更多信息,一个很好的起点是dmesg手册页,我们可以通过执行man dmesg命令来显示它。

使用内核模块

了解如何向内核添加自定义代码是有用的,但是,当我们必须编写新的驱动程序时,将我们的代码编写为内核模块可能更有用。实际上,通过使用模块,我们可以轻松修改内核代码,然后在不需要每次重新启动系统的情况下进行测试!我们只需删除然后重新插入模块(在必要的修改之后)以测试我们代码的新版本。

在这个示例中,我们将看看即使在内核树之外的目录中,内核模块也可以被编译。

准备工作

要将我们的dummy-code.c文件转换为内核模块,我们只需更改内核设置,允许编译我们示例模块(在内核配置菜单中用*字符替换为M)。但是,在某些情况下,将我们的驱动程序发布到与内核源代码完全分开的专用存档中可能更有用。即使在这种情况下,也不需要对现有代码进行任何更改,我们将能够在内核源树内部或者在外部编译dummy-code.c

要构建我们的第一个内核模块作为外部代码,我们可以安全地使用前面的dummy-code.c文件,然后将其放入一个专用目录,并使用以下Makefile

ifndef KERNEL_DIR
$(error KERNEL_DIR must be set in the command line)
endif
PWD := $(shell pwd)
ARCH ?= arm64
CROSS_COMPILE ?= aarch64-linux-gnu-

# This specifies the kernel module to be compiled
obj-m += dummy-code.o

# The default action
all: modules

# The main tasks
modules clean:
    make -C $(KERNEL_DIR) \
              ARCH=$(ARCH) \
              CROSS_COMPILE=$(CROSS_COMPILE) \
              SUBDIRS=$(PWD) $@

查看前面的代码,我们看到KERNEL_DIR变量必须在命令行上提供,指向 ESPRESSObin 之前编译的内核源代码的路径,而ARCHCROSS_COMPILE变量不是强制性的,因为Makefile指定了它们(但是,在命令行上提供它们将优先)。

此外,我们应该验证insmodrmmod命令是否在我们的 ESPRESSObin 中可用,如下所示:

# insmod -h
Usage:
        insmod [options] filename [args]
Options:
        -V, --version show version
        -h, --help show this help

如果不存在,那么可以通过使用通常的apt install kmod命令添加kmod软件包来安装它们。

如何做...

让我们看看如何通过以下步骤来做到这一点:

  1. 在将dummy-code.cMakefile文件放置在主机 PC 上的当前工作目录后,当使用ls命令时,它应该如下所示:
$ ls
dummy-code.c  Makefile
  1. 然后,我们可以使用以下命令编译我们的模块:
$ make KERNEL_DIR=../../../linux/
make -C ../../../linux/ \
 ARCH=arm64 \
 CROSS_COMPILE=aarch64-linux-gnu- \
 SUBDIRS=/home/giometti/Projects/ldddc/github/chapter_2/module modules
make[1]: Entering directory '/home/giometti/Projects/ldddc/linux'
 CC [M] /home/giometti/Projects/ldddc/github/chapter_2/module/dummy-code.o
 Building modules, stage 2.
 MODPOST 1 modules
 CC /home/giometti/Projects/ldddc/github/chapter_2/module/dummy-code.mod.o
 LD [M] /home/giometti/Projects/ldddc/github/chapter_2/module/dummy-code.ko
make[1]: Leaving directory '/home/giometti/Projects/ldddc/linux'

如我们所见,现在我们在当前工作目录中有几个文件,其中一个名为dummy-code.ko;这是我们的内核模块,准备好传输到 ESPRESSObin!

  1. 一旦模块已经移动到目标系统(例如,通过使用scp命令),我们可以使用insmod实用程序加载它,如下所示:
# insmod dummy-code.ko
  1. 现在,通过使用lsmod命令,我们可以要求系统显示所有加载的模块。在我的 ESPRESSObin 上,我只有dummy-code.ko模块,所以我的输出如下所示:
# lsmod 
Module         Size  Used by
dummy_code    16384  0

请注意,由于内核模块名称中的-字符被替换为_,内核模块名称的.ko后缀已被删除。

  1. 然后,我们可以使用rmmod命令从内核中删除我们的模块,如下所示:
# rmmod dummy_code

如果出现以下错误,请验证您是否运行了我们在第一章中获得的正确Image文件,安装开发系统

rmmod: ERROR: ../libkmod/libkmod.c:514 lookup_builtin_file() could not open builtin file '/lib/modules/4.18.0-dirty/modules.builtin.bin'

它是如何工作的...

insmod命令只是将我们的模块插入内核;之后,它执行module_init()函数。

在模块插入期间,如果我们在 SSH 连接上,终端上将看不到任何内容,我们必须使用dmesg来查看内核消息(或者在串行控制台上,在插入模块后,我们应该看到类似以下内容的内容:

dummy_code: loading out-of-tree module taints kernel.
dummy_code:dummy_code_init: dummy-code loaded

请注意,消息“加载非树模块会污染内核”只是一个警告,可以安全地忽略我们的目的。有关污染内核的更多信息,请参见www.kernel.org/doc/html/v4.15/admin-guide/tainted-kernels.html

rmmod命令执行module_exit()函数,然后从内核中删除模块,执行insmod的逆步骤。

另请参阅

  • 有关 modutils 的更多信息,它们的手册页是一个很好的起点(命令是:man insmodman rmmodman modinfo);此外,我们可以通过阅读其手册页(man modprobe)来了解modprobe命令。

使用模块参数

在内核模块开发过程中,动态设置一些变量在模块插入时非常有用,而不仅仅是在编译时。在 Linux 中,可以通过使用内核模块的参数来实现,这允许通过在insmod命令的命令行上指定参数来传递参数给模块。

准备工作

为了举例说明,让我们考虑一个情况,我们有一个新的模块信息文件module_par.c(此文件也在我们的 GitHub 存储库中)。

如何做...

让我们看看如何通过以下步骤来做到这一点:

  1. 首先,让我们定义我们的模块参数,如下所示:
static int var = 0x3f;
module_param(var, int, S_IRUSR | S_IWUSR);
MODULE_PARM_DESC(var, "an integer value");

static char *str = "default string";
module_param(str, charp, S_IRUSR | S_IWUSR);
MODULE_PARM_DESC(str, "a string value");

#define ARR_SIZE 8
static int arr[ARR_SIZE];
static int arr_count;
module_param_array(arr, int, &arr_count, S_IRUSR | S_IWUSR);
MODULE_PARM_DESC(arr, "an array of " __stringify(ARR_SIZE) " values");
  1. 然后,我们可以使用以下的initexit函数:
static int __init module_par_init(void)
{
    int i;

    pr_info("loaded\n");
    pr_info("var = 0x%02x\n", var);
    pr_info("str = \"%s\"\n", str);
    pr_info("arr = ");
    for (i = 0; i < ARR_SIZE; i++)
        pr_cont("%d ", arr[i]);
    pr_cont("\n");

    return 0;
}

static void __exit module_par_exit(void)
{
    pr_info("unloaded\n");
}

module_init(module_par_init);
module_exit(module_par_exit);
  1. 最后,在最后,我们可以像往常一样添加模块描述宏:
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Rodolfo Giometti");
MODULE_DESCRIPTION("Module with parameters");
MODULE_VERSION("0.1");

工作原理...

编译完成后,应该会生成一个名为module_par.ko的新文件,可以加载到我们的 ESPRESSObin 中。但在这之前,让我们使用modinfo实用程序对其进行如下操作:

# modinfo module_par.ko 
filename:    /root/module_par.ko
version:     0.1
description: Module with parameters
author:      Rodolfo Giometti
license:     GPL
srcversion:  21315B65C307ABE9769814F
depends: 
name:        module_par
vermagic:    4.18.0 SMP preempt mod_unload aarch64
parm:        var:an integer value (int)
parm:        str:a string value (charp)
parm:        arr:an array of 8 values (array of int)

modinfo命令也包含在kmod软件包中,名为insmod

正如我们在最后三行中所看到的(都以parm:字符串为前缀),我们在代码中使用module_param()module_param_array()宏定义了模块的参数列表,并使用MODULE_PARM_DESC()进行描述。

现在,如果我们像以前一样插入模块,我们会得到默认值,如下面的代码块所示:

# insmod module_par.ko 
[ 6021.345064] module_par:module_par_init: loaded
[ 6021.347028] module_par:module_par_init: var = 0x3f
[ 6021.351810] module_par:module_par_init: str = "default string"
[ 6021.357904] module_par:module_par_init: arr = 0 0 0 0 0 0 0 0

但是,如果我们使用下一个命令行,我们可以强制使用新值:

# insmod module_par.ko var=0x01 str=\"new value\" arr='1,2,3' 
[ 6074.175964] module_par:module_par_init: loaded
[ 6074.177915] module_par:module_par_init: var = 0x01
[ 6074.184932] module_par:module_par_init: str = "new value"
[ 6074.189765] module_par:module_par_init: arr = 1 2 3 0 0 0 0 0 

在尝试使用新值重新加载之前,请不要忘记使用rmmod module_par命令删除module_par模块!

最后,让我建议仔细查看以下模块参数定义:

static int var = 0x3f;
module_param(var, int, S_IRUSR | S_IWUSR);
MODULE_PARM_DESC(var, "an integer value");

首先,我们有代表参数的变量声明,然后是真正的模块参数定义(在这里我们指定类型和文件访问权限),然后是描述。

modinfo命令能够显示所有前面的信息,除了文件访问权限,这些权限是指与sysfs文件系统中的参数相关的文件!实际上,如果我们看一下/sys/module/module_par/parameters/目录,我们会得到以下内容:

# ls -l /sys/module/module_par/parameters/
total 0
-rw------- 1 root root 4096 Feb 1 12:46 arr
-rw------- 1 root root 4096 Feb 1 12:46 str
-rw------- 1 root root 4096 Feb 1 12:46 var

现在,应该清楚参数S_IRUSRS_IWUSR的含义;它们允许模块用户(即 root 用户)写入这些文件,然后从中读取相应的参数。

S_IRUSR和相关函数的定义在以下文件中:linux/include/uapi/linux/stat.h

另请参阅

第三章:使用 Char 驱动程序

设备驱动程序是一种特殊的代码(在内核空间中运行),它将物理设备与系统进行接口,并通过实现一些系统调用特殊文件上向用户空间进程提供访问,这是因为在类 Unix 的操作系统中,一切都是文件,物理设备被表示为特殊文件(通常放置在/dev目录中),每个文件连接到特定设备(因此,例如,键盘可以是名为/dev/input0的文件,串行端口可以是名为/dev/ttyS1的文件,实时时钟可以是/dev/rtc2)。

我们可以预期,网络设备属于一组特定的设备,不遵守这个规则,因为我们没有/dev/eth0文件用于eth0接口。这是真的,因为网络设备是唯一不遵守这个规则的设备类,因为与其他设备类不同,与网络相关的应用程序不关心单个网络接口;它们通过引用套接字在更高级别上工作。这就是为什么 Linux 不提供对网络设备的直接访问,就像其他设备类一样。

从下图中可以看出,内核空间用于将硬件抽象到用户空间,以便每个进程都使用相同的接口来访问外围设备,这个接口由一组系统调用组成。

该图还显示,除了使用设备驱动程序之外,还可以通过使用另一个接口(如sysfs)或实现用户空间驱动程序来访问外围设备。

由于我们的外围设备只是(特殊)文件,因此我们的驱动程序应该实现我们需要操作这些文件的系统调用,特别是用于交换数据的系统调用。例如,我们需要open()close()系统调用来启动和停止与外围设备的通信,以及read()write()系统调用来与其交换数据。

普通 C 函数和系统调用之间的主要区别在于后者主要在内核中执行,而函数仅在用户空间中执行。例如,printf()是一个函数,而write()是一个系统调用。后者(除了 C 函数的序言和尾声部分)在内核空间中执行,而前者主要在用户空间中执行,即使最终调用write()来实际将数据写入输出流(这是因为所有输入/输出数据流无论如何都必须通过内核)。

有关更多信息,请查看本书:prod.packtpub.com/hardware-and-creative/gnulinux-rapid-embedded-programming

好吧,本章将向我们展示如何至少实现open()close()read()write()系统调用,以介绍设备驱动程序编程和字符驱动程序开发的第一步。

现在是时候编写我们的第一个设备驱动程序了!在本章中,我们将从一个非常简单的字符(或 char)驱动程序开始,以涵盖以下内容:

  • 创建最简单的 char 驱动程序

  • 与 char 驱动程序交换数据

  • 使用“一切都是文件”抽象

技术要求

在本章中,我们将需要第一章和第二章中使用的内容,安装开发系统内核内部一瞥,因此请参考它们进行交叉编译、内核模块加载和管理等操作。

有关本章的更多信息,请阅读附录

本章中使用的代码和其他文件可以从 GitHub 上下载:github.com/giometti/linux_device_driver_development_cookbook/tree/master/chapter_03

创建最简单的字符驱动程序

在 Linux 内核中,存在三种主要的设备类型——字符设备、块设备和网络设备。当然,我们有三种主要的设备驱动程序类型;即字符、块和网络驱动程序。在本章中,我们将看一下字符(或字符)设备,这是一种可以作为字节流访问的外围设备,例如串行端口、音频设备等。然而,在这个配方中,我们将介绍一个真正基本的字符驱动程序,它只是注册自己,除此之外什么也不做。即使它可能看起来毫无用处,我们将发现这一步确实引入了许多新概念!

实际上,可能可以通过简单地使用sysfs提供的一些机制在外围设备和用户空间之间交换数据,而不是通过字符、块或网络驱动程序,但这是一种特殊情况,通常仅用于必须交换简单数据类型的非常简单的设备。

准备工作

要实现我们的第一个字符驱动程序,我们需要使用上一章介绍的模块。这是因为使用内核模块是我们将代码注入内核空间的最简单方法。当然,我们可以决定将我们的驱动程序编译为内核内置,但是这样做,每次修改代码时我们都必须完全重新编译内核并重新启动系统(这是一种可能性,但绝对不是最好的!)。

在继续之前,先说明一点:为了更清楚地解释字符驱动程序的工作原理,并且提供一个真正简单的示例,我决定使用传统的方式将字符驱动程序注册到内核中。没有什么需要担心的,因为这种操作模式是完全合法的,仍然得到支持,在任何情况下,在使用设备树描述字符驱动程序配方中,在第四章中,使用设备树,我将介绍目前建议的注册字符驱动程序的方式。

如何做...

让我们从 GitHub 源代码中查看chrdev_legacy.c文件。我们有我们的第一个驱动程序,所以让我们开始并详细检查它:

  1. 首先,让我们看一下文件的开头:
#define pr_fmt(fmt) "%s:%s: " fmt, KBUILD_MODNAME, __func__
#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/fs.h>

/* Device major umber */
static int major;
  1. chrdev_legacy.c的末尾,检查以下代码,其中模块的init()函数定义如下:
static int __init chrdev_init(void)
{
    int ret;

    ret = register_chrdev(0, "chrdev", &chrdev_fops);
    if (ret < 0) {
        pr_err("unable to register char device! Error %d\n", ret);
        return ret;
    }
    major = ret;
    pr_info("got major %d\n", major);

    return 0;
}

模块的exit()函数如下所示:

static void __exit chrdev_exit(void)
{
    unregister_chrdev(major, "chrdev");
}

module_init(chrdev_init);
module_exit(chrdev_exit);
  1. 如果major号是内核中用户空间的驱动程序引用,那么文件操作结构(由chrdev_fops引用)代表了我们可以在驱动程序上执行的唯一允许的系统调用,它们定义如下:
static struct file_operations chrdev_fops = {
    .owner    = THIS_MODULE,
    .read     = chrdev_read,
    .write    = chrdev_write,
    .open     = chrdev_open,
    .release  = chrdev_release
};
  1. 然后,方法基本上实现如下。这里是read()write()方法:
static ssize_t chrdev_read(struct file *filp,
                           char __user *buf, size_t count,
                           loff_t *ppos)
{
    pr_info("return EOF\n");

    return 0;
}

static ssize_t chrdev_write(struct file *filp,
                            const char __user *buf, size_t count,
                            loff_t *ppos)
{
    pr_info("got %ld bytes\n", count);

    return count;
}

这里是open()release()(又名close())方法:

static int chrdev_open(struct inode *inode, struct file *filp)
{
    pr_info("chrdev opened\n");

    return 0;
}

static int chrdev_release(struct inode *inode, struct file *filp)
{
    pr_info("chrdev released\n");

    return 0;
}
  1. 要编译代码,我们可以在主机上使用通常的方式进行,如下所示:
$ make KERNEL_DIR=../../../linux/
make -C ../../../linux/ \
            ARCH=arm64 \
            CROSS_COMPILE=aarch64-linux-gnu- \
            SUBDIRS=/home/giometti/Projects/ldddc/github/chapter_3/chrdev_legacy modules
make[1]: Entering directory '/home/giometti/Projects/ldddc/linux'
  CC [M] /home/giometti/Projects/ldddc/github/chapter_3/chrdev_legacy/chrdev_legacy.o
  Building modules, stage 2.
  MODPOST 1 modules
  CC /home/giometti/Projects/ldddc/github/chapter_3/chrdev_legacy/chrdev_legacy.mod.o
  LD [M] /home/giometti/Projects/ldddc/github/chapter_3/chrdev_legacy/chrdev_legacy.ko
make[1]: Leaving directory '/home/giometti/Projects/ldddc/linux'
  1. 然后,为了测试我们的驱动程序,我们可以在目标系统中加载它(再次可以使用scp命令将模块文件加载到 ESPRESSObin 中):
# insmod chrdev_legacy.ko 
chrdev_legacy: loading out-of-tree module taints kernel.
chrdev_legacy:chrdev_init: got major 239

好了。驱动程序已加载,我们的主要号码是239

  1. 最后,让我建议您查看 ESPRESSObin 上的/proc/devices文件。这个特殊的文件是在有人读取它时动态生成的,它保存了所有注册到系统中的字符(和块)驱动程序;这就是为什么如果我们用grep命令过滤它,我们应该找到以下内容:
# grep chrdev /proc/devices 
239 chrdev

当然,你的主要号码可能是一个不同的号码!这一点并不奇怪;只需根据你得到的号码重写下一个命令。

  1. 要在我们的驱动程序上有效地执行一些系统调用,我们可以使用存储在chrdev_test.c文件中的程序(仍然来自 GitHub 源代码);其main()函数的开头如下所示:
int main(int argc, char *argv[])
{
    int fd;
    char buf[] = "DUMMY DATA";
    int n, c;
    int ret;

    if (argc < 2) {
        fprintf(stderr, "usage: %s <dev>\n", argv[0]);
        exit(EXIT_FAILURE);
    }

    ret = open(argv[1], O_RDWR);
    if (ret < 0) {
        perror("open");
        exit(EXIT_FAILURE);
    }
    printf("file %s opened\n", argv[1]);
    fd = ret;
  1. 首先,我们需要打开文件设备,然后获取文件描述符;这可以通过使用open()系统调用来完成。

  2. 然后,main()函数继续,通过在设备中写入数据:

    for (c = 0; c < sizeof(buf); c += n) {
        ret = write(fd, buf + c, sizeof(buf) - c);
        if (ret < 0) {
            perror("write");
            exit(EXIT_FAILURE);
        }
        n = ret;

        printf("wrote %d bytes into file %s\n", n, argv[1]);
        dump("data written are: ", buf + c, n);
    }

通过刚刚写入的数据进行读取:

    for (c = 0; c < sizeof(buf); c += n) {
        ret = read(fd, buf, sizeof(buf));
        if (ret == 0) { 
            printf("read EOF\n");
            break;
        } else if (ret < 0) {
            perror("read");
            exit(EXIT_FAILURE);
        }
        n = ret;

        printf("read %d bytes from file %s\n", n, argv[1]);
        dump("data read are: ", buf, n);
    }

设备打开后,我们的程序执行write(),然后是read()系统调用。

我们应该注意,我在for()循环内调用read()write()系统调用;这种实现背后的原因将在下一个配方与字符驱动程序交换数据中更清晰地看到这些系统调用实际上是如何工作的。

  1. 最后,main()可以关闭文件设备,然后退出:
    close(fd);

    return 0;
}

通过这种方式,我们可以测试我们之前实现的系统调用。

它是如何工作的...

步骤 1中,正如您所看到的,它与我们在上一章中介绍的内核模块非常相似,即使有一些新的include文件。然而,最重要的新条目是major变量,为了理解它的用途,我们应该直接转到文件末尾,在那里找到真正的字符驱动程序注册。

在步骤 2 中,我们再次看到module_init()module_exit()函数和宏,例如MODULE_LICENSE()(请参阅第二章,内核内部,与内核模块一起工作的配方);然而,这里真正重要的是chrdev_init()chrdev_exit()函数的实际作用。实际上,chrdev_init()调用register_chrdev()函数,而register_chrdev()函数又是将新的字符驱动程序注册到系统中的函数,将其标记为chrdev,并使用提供的chrdev_fops作为文件操作,同时将返回值存储到 major 变量中。

我们应该考虑这一事实,因为如果没有返回错误,major将是系统中新驱动程序的主要引用!实际上,内核仅通过其主要编号来区分一个字符驱动程序和另一个字符驱动程序(这就是为什么我们保存它,然后在chrdev_exit()函数中将其用作unregister_chrdev()的参数)。

步骤 3中,然后每个字段指向一个明确定义的函数,这些函数又实现了系统调用体。这里唯一的非函数字段是owner,它只用于指向模块的所有者,与驱动程序无关,仅与内核模块管理系统有关。

步骤 4中,通过前面的代码,我们的字符驱动程序使用四种方法实现了四个系统调用:open()close()(称为release()),read()write(),这是我们可以在字符驱动程序中定义的非常简单的系统调用集。

请注意,此时所有方法都不执行任何操作!当我们在驱动程序上发出read()系统调用时,内核空间中的驱动程序内部将正确调用chrdev_read()方法(请参阅下一节以了解如何与用户空间交换数据)。

我交替使用函数方法名称,因为所有这些函数都可以被视为对象编程中的方法,这些函数名称根据它们应用的对象而专门化为不同的步骤。

对于驱动程序也是一样的:例如,它们都有一个read()方法,但是这个方法的行为会根据它所应用的对象(或外围设备)而改变。

步骤 6中,loading out-of-tree module taints kernel消息只是一个警告,可以安全地忽略;但请注意,模块文件名为chrdev_legacy.ko,而驱动程序的名称只是chrdev

还有更多...

我们可以验证我们的新驱动程序的工作方式,因此让我们编译之前看到的chrdev_test.c文件中存储的程序。为此,我们可以在 ESPRESSObin 上使用以下命令:

# make CFLAGS="-Wall -O2" chrdev_test
cc -Wall -O2 chrdev_test.c -o chrdev_test

如果尚未安装,可以使用常规的apt命令apt install make gcc轻松将makegcc命令安装到您的 ESPRESSObin 中(在 ESPRESSObin 连接到互联网后)。

现在我们可以通过执行它来尝试它:

# ./chrdev_test 
usage: ./chrdev_test <dev>

是的!我们必须使用哪个文件名?我们一直说我们的设备是 Unix OS 中的文件,但是哪个文件?好吧,要生成这个文件——也就是代表我们的驱动程序的文件——我们必须使用mknod命令,如下所示:

# mknod chrdev c 239 0

有关mknod命令的更多信息,您可以使用命令行man mknod查看其手册页面。

通常mknod创建的文件位于/dev目录中;但是,它们可以创建在我们希望的任何地方,这只是一个示例,展示了机制的工作原理。

上述命令在当前目录中创建一个名为chrdev的文件,这是一种特殊文件类型字符(或无缓冲),其主要编号为239(这当然是我们驱动程序的主要编号,如步骤 1中所见),次要编号为0

此时,我们还没有引入次要编号,但是,您应该将它们视为内核简单传递给驱动程序的一个简单的额外参数。驱动程序本身知道如何管理次要编号。

实际上,如果我们使用ls命令检查它,我们会看到以下内容:

# ls -l chrdev
crw-r--r-- 1 root root 239, 0 Feb 7 14:30 chrdev

这里,初始字符c指出这个chrdev文件不是一个普通文件(用-字符表示),而是一个字符设备文件。

好的。现在我们的文件已连接到我们的驱动程序,让我们在上面尝试我们的测试程序。

我们在终端上得到以下输出:

# ./chrdev_test chrdev
file chrdev opened
wrote 11 bytes into file chrdev
data written are: 44 55 4d 4d 59 20 44 41 54 41 00 
read EOF

但是,在串行控制台(或通过dmesg),我们得到以下输出:

chrdev_legacy:chrdev_open: chrdev opened
chrdev_legacy:chrdev_write: got 11 bytes
chrdev_legacy:chrdev_read: return EOF
chrdev_legacy:chrdev_release: chrdev released

这正是我们所期望的!如步骤 4中所述,我们可以验证所有系统调用open()close()(称为release())、read()write(),我们在驱动程序中定义的,是否通过调用相应的方法有效执行。

请注意,如果您直接在串行控制台上执行chrdev_test程序,所有先前的消息将重叠在一起,您可能无法轻松识别它们!因此,让我建议您使用 SSH 连接来执行测试。

另请参阅

与字符驱动程序交换数据

在本配方中,我们将看到如何根据read()write()系统调用的行为从驱动程序中读取和写入数据。

准备工作

为了修改我们的第一个字符驱动程序,以便允许它在用户空间之间交换数据,我们仍然可以在上一个配方中使用的模块上进行工作。

如何做...

为了与我们的新驱动程序交换数据,我们需要根据我们之前说的修改read()write()方法,并且我们必须添加一个数据缓冲区,用于存储交换的数据:

  1. 因此,让我们修改我们的文件chrdev_legacy.c,如下所示,以包括linux/uaccess.h文件并定义我们的内部缓冲区:
#define pr_fmt(fmt) "%s:%s: " fmt, KBUILD_MODNAME, __func__
#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/uaccess.h>

/* Device major umber */
static int major;

/* Device data */
#define BUF_LEN 300
static char chrdev_buf[BUF_LEN];
  1. 然后,chrdev_read()方法应该修改如下:
static ssize_t chrdev_read(struct file *filp,
                char __user *buf, size_t count, loff_t *ppos)
{
    int ret;

    pr_info("should read %ld bytes (*ppos=%lld)\n", 
                                     count, *ppos);

    /* Check for end-of-buffer */
    if (*ppos + count >= BUF_LEN)
        count = BUF_LEN - *ppos;

    /* Return data to the user space */
    ret = copy_to_user(buf, chrdev_buf + *ppos, count);
    if (ret < 0)
        return -EFAULT;

    *ppos += count;
    pr_info("return %ld bytes (*ppos=%lld)\n", count, *ppos);

    return count;
}

所有先前的修改和本节中的下一个修改都可以通过使用 GitHub 来源中的modify_read_write_to_chrdev_legacy.patch补丁文件轻松应用,发出以下命令行,该命令行位于chrdev_legacy.c文件所在的同一目录中:

$ patch -p3 < modify_read_write_to_chrdev_legacy.patch

  1. 我们可以重复这个过程,对chrdev_write()方法进行修改:
static ssize_t chrdev_write(struct file *filp,
             const char __user *buf, size_t count, loff_t *ppos)
{
    int ret;

    pr_info("should write %ld bytes (*ppos=%lld)\n", count, *ppos);

    /* Check for end-of-buffer */
    if (*ppos + count >= BUF_LEN)
        count = BUF_LEN - *ppos;

    /* Get data from the user space */
    ret = copy_from_user(chrdev_buf + *ppos, buf, count);
    if (ret < 0)
        return -EFAULT;

    *ppos += count;
    pr_info("got %ld bytes (*ppos=%lld)\n", count, *ppos);

    return count;
}

工作原理...

步骤 2中,通过对我们的chrdev_read()方法进行上述修改,现在我们将使用copy_to_user()函数将用户空间提供的数据复制到驱动程序的内部缓冲区中,同时移动ppos指针,并返回已读取的数据量(或错误)。

请注意,copy_from/to_user()函数在成功时返回零,或者返回非零以指示未传输的字节数,因此在这里,我们应该考虑这种情况(即使很少发生),并适当更新count,减去未传输的字节数(如果有的话),以便正确更新ppos并向用户空间返回正确的计数值。但是,为了尽可能简单,我们只是选择返回错误条件。

还要注意,如果*ppos + count指向缓冲区末尾之外,count将相应地重新计算,并且该函数将返回表示传输字节数的值,该值小于输入时提供的原始count值(该值表示提供的目标用户缓冲区的大小,因此是允许传输的数据的最大长度)。

步骤 3中,我们可以考虑与copy_to_user()返回值相同的注意事项。但是,另外在copy_from_user()上,如果无法复制一些数据,该函数将使用零字节填充已复制的数据以达到请求的大小。

正如我们所看到的,这个函数与前一个函数非常相似,即使它实现了相反的数据流。

还有更多...

修改完成并且新的驱动程序版本已经重新编译并正确加载到 ESPRESSObin 的内核中后,我们可以再次执行我们的测试程序chrdev_test。我们应该会得到以下输出:

# ./chrdev_test chrdev
file chrdev opened
wrote 11 bytes into file chrdev
data written are: 44 55 4d 4d 59 20 44 41 54 41 00 
read 11 bytes from file chrdev
data read are: 00 00 00 00 00 00 00 00 00 00 00 

从串行控制台,我们应该看到类似于以下内容:

chrdev_legacy:chrdev_open: chrdev opened
chrdev_legacy:chrdev_write: should write 11 bytes (*ppos=0)
chrdev_legacy:chrdev_write: got 11 bytes (*ppos=11)
chrdev_legacy:chrdev_read: should read 11 bytes (*ppos=11)
chrdev_legacy:chrdev_read: return 11 bytes (*ppos=22)
chrdev_legacy:chrdev_release: chrdev released

好的。我们得到了我们预期的结果!实际上,从内核消息中,我们可以看到chrdev_open()的调用,然后当调用chrdev_write()chrdev_read()时发生了什么:传输了 11 个字节,并且ppos指针移动了我们预期的位置。然后,调用了chrdev_release(),文件被关闭。

现在一个问题:如果我们再次调用前面的命令会发生什么?

嗯,我们应该期望完全相同的输出;实际上,每次打开文件时,ppos都会重新定位到文件开头(即 0),我们继续在相同的位置读取和写入。

以下是第二次执行的输出:

# ./chrdev_test chrdev
file chrdev opened
wrote 11 bytes into file chrdev
data written are: 44 55 4d 4d 59 20 44 41 54 41 00 
read 11 bytes from file chrdev
data read are: 00 00 00 00 00 00 00 00 00 00 00

此外,以下是相关的内核消息:

chrdev_legacy:chrdev_open: chrdev opened
chrdev_legacy:chrdev_write: should write 11 bytes (*ppos=0)
chrdev_legacy:chrdev_write: got 11 bytes (*ppos=11)
chrdev_legacy:chrdev_read: should read 11 bytes (*ppos=11)
chrdev_legacy:chrdev_read: return 11 bytes (*ppos=22)
chrdev_legacy:chrdev_release: chrdev released

如果我们希望读取刚刚写入的数据,我们可以修改chrdev_test程序,使其在调用write()后关闭然后重新打开文件:

...
        printf("wrote %d bytes into file %s\n", n, argv[1]);
        dump("data written are: ", buf, n);
    }

    close(fd);

    ret = open(argv[1], O_RDWR);
    if (ret < 0) {
        perror("open");
        exit(EXIT_FAILURE);
    }
    printf("file %s reopened\n", argv[1]);
    fd = ret;

    for (c = 0; c < sizeof(buf); c += n) {
        ret = read(fd, buf, sizeof(buf));
...

请注意,所有这些修改都存储在 GitHub 来源的modify_close_open_to_chrdev_test.patch补丁文件中,可以使用以下命令应用该补丁,该命令位于chrdev_test.c文件所在的位置:

$ patch -p2 < modify_close_open_to_chrdev_test.patch

现在,如果我们再次尝试执行chrdev_test,我们应该会得到以下输出:

# ./chrdev_test chrdev
file chrdev opened
wrote 11 bytes into file chrdev
data written are: 44 55 4d 4d 59 20 44 41 54 41 00 
file chrdev reopened
read 11 bytes from file chrdev
data read are: 44 55 4d 4d 59 20 44 41 54 41 00

完美!现在,我们读取到了我们写入的内容,并且从内核空间中,我们得到了以下消息:

chrdev_legacy:chrdev_open: chrdev opened
chrdev_legacy:chrdev_write: should write 11 bytes (*ppos=0)
chrdev_legacy:chrdev_write: got 11 bytes (*ppos=11)
chrdev_legacy:chrdev_release: chrdev released
chrdev_legacy:chrdev_open: chrdev opened
chrdev_legacy:chrdev_read: should read 11 bytes (*ppos=0)
chrdev_legacy:chrdev_read: return 11 bytes (*ppos=11)
chrdev_legacy:chrdev_release: chrdev released

现在,我们可以清楚地看到ppos发生了什么,以及chrdev_read()chrdev_write()方法是如何工作的,以便与用户空间交换数据。

另请参阅

  • 有关read()write()系统调用的更多信息,读者可以开始阅读相关的 man 页面,可以使用以下常规命令获得:man 2 readman 2 write

请注意,这次我们必须指定手册页的第二部分(系统调用);否则,我们将直接从第一部分(可执行程序)获取信息。

使用“一切皆文件”抽象

当我们介绍设备驱动程序时,我们说它们位于 Unix 文件抽象之下;也就是说,在类 Unix 操作系统中,一切都是文件。现在,是时候验证它了,所以让我们看看如果我们尝试对我们的新驱动程序执行一些与文件相关的实用程序会发生什么。

由于我们对chrdev_legacy.c文件进行了最新的修改,我们的驱动程序模拟了一个 300 字节长的文件(参见chrdev_buf[BUF_LEN]缓冲区,其中BUF_LEN设置为300),我们可以在其上执行read()write()系统调用,就像我们在普通文件上做的那样。

然而,我们可能仍然有一些疑问,所以让我们考虑标准的catdd命令,因为我们知道它们是用于操作文件内容的实用程序。例如,在cat命令的 man 页面中,我们可以读到以下定义:

NAME
       cat - concatenate files and print on the standard output

SYNOPSIS
       cat [OPTION]... [FILE]...

DESCRIPTION
       Concatenate FILE(s) to standard output.

对于dd,我们有以下定义:

NAME
       dd - convert and copy a file

SYNOPSIS
       dd [OPERAND]...
       dd OPTION

DESCRIPTION
       Copy a file, converting and formatting according to the operands.

我们没有看到任何与设备驱动程序的引用,只有文件,因此如果我们的驱动程序像文件一样工作,我们应该能够在其上使用这些命令!

准备就绪

为了检查“一切都是文件”的抽象,我们仍然可以使用我们的新字符驱动程序,它可以像处理常规文件一样进行管理。因此,让我们确保驱动程序已正确加载到内核中,并转到下一节。

如何操作...

让我们按照以下步骤来操作:

  1. 首先,我们可以尝试通过以下命令将所有0字符写入驱动程序的缓冲区以清除它:
# dd if=/dev/zero bs=100 count=3 of=chrdev
3+0 records in
3+0 records out
300 bytes copied, 0.0524863 s, 5.7 kB/s
  1. 现在,我们可以通过使用cat命令读取刚刚写入的数据,如下所示:
# cat chrdev | tr '\000' '0'
000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000

完美!正如我们所预期的那样,我们擦除了驱动程序的内部缓冲区。

读者应该注意,我们使用tr命令来将数据字节 0 转换为可打印字符 0;否则,我们会看到垃圾(或很可能什么也看不到)。

请参阅man tr中的tr手册页,以获取有关其用法的更多信息。

  1. 现在,我们可以尝试将普通文件数据移入我们的字符设备;例如,如果我们考虑/etc/passwd文件,我们应该看到以下内容:
# ls -lh /etc/passwd
-rw-r--r-- 1 root root 1.3K Jan 10 14:16 /etc/passwd

这个文件大于 300 字节,但我们仍然可以尝试使用下一个命令将其移入我们的字符驱动程序:

# cat /etc/passwd > chrdev
cat: write error: No space left on device

正如我们预期的那样,由于我们的文件不能容纳超过 300 字节,我们收到了错误消息。然而,真正有趣的事情在于内核中:

chrdev_legacy:chrdev_open: chrdev opened
chrdev_legacy:chrdev_write: should write 1285 bytes (*ppos=0)
chrdev_legacy:chrdev_write: got 300 bytes (*ppos=300)
chrdev_legacy:chrdev_write: should write 985 bytes (*ppos=300)
chrdev_legacy:chrdev_write: got 0 bytes (*ppos=300)
chrdev_legacy:chrdev_release: chrdev released
  1. 即使我们收到错误消息,从前面的内核消息中,我们看到确实已经写入了一些数据到我们的字符驱动程序中,所以我们可以尝试使用grep命令来找到其中的特定行:
# grep root chrdev
root:x:0:0:root:/root:/bin/bash

有关grep的更多信息,请参阅其man手册页。

由于引用根用户的行是/etc/passwd中的第一行之一,它肯定已经被复制到字符驱动程序中,然后我们按预期得到它。为了完整起见,下面报告了相对内核消息,我们可以在其中看到grep在我们的驱动程序上执行的所有系统调用:

chrdev_legacy:chrdev_open: chrdev opened
chrdev_legacy:chrdev_read: should read 32768 bytes (*ppos=0)
chrdev_legacy:chrdev_read: return 300 bytes (*ppos=300)
chrdev_legacy:chrdev_read: should read 32768 bytes (*ppos=300)
chrdev_legacy:chrdev_read: return 0 bytes (*ppos=300)
chrdev_legacy:chrdev_release: chrdev released

工作原理...

通过前面的dd命令,我们生成了三个长度为 100 字节的块,并将它们传递给write()系统调用;实际上,如果我们查看内核消息,我们明确看到了发生了什么:

chrdev_legacy:chrdev_open: chrdev opened
chrdev_legacy:chrdev_write: should write 100 bytes (*ppos=0)
chrdev_legacy:chrdev_write: got 100 bytes (*ppos=100)
chrdev_legacy:chrdev_write: should write 100 bytes (*ppos=100)
chrdev_legacy:chrdev_write: got 100 bytes (*ppos=200)
chrdev_legacy:chrdev_write: should write 100 bytes (*ppos=200)
chrdev_legacy:chrdev_write: got 100 bytes (*ppos=300)
chrdev_legacy:chrdev_release: chrdev released

首次调用后,在open()之后,ppos设置为0,然后在写入数据后移动到 100。然后,在下一次调用中,ppos每次增加 100 字节,直到达到 300。

步骤 2中,当我们发出cat命令时,看到内核空间中发生了什么是非常有趣的,所以让我们看看与此相关的内核消息:

chrdev_legacy:chrdev_open: chrdev opened
chrdev_legacy:chrdev_read: should read 131072 bytes (*ppos=0)
chrdev_legacy:chrdev_read: return 300 bytes (*ppos=300)
chrdev_legacy:chrdev_read: should read 131072 bytes (*ppos=300)
chrdev_legacy:chrdev_read: return 0 bytes (*ppos=300)
chrdev_legacy:chrdev_release: chrdev released

正如我们所看到的,cat要求 131,072 字节,但由于我们的缓冲区较短,只返回了 300 字节;然后,cat再次执行read(),要求 131,072 字节,但现在ppos指向文件末尾,因此返回 0,表示文件结束的条件。

当我们尝试将太多数据写入设备文件时,显然会收到错误消息,但真正有趣的事情在于内核中的情况:

chrdev_legacy:chrdev_open: chrdev opened
chrdev_legacy:chrdev_write: should write 1285 bytes (*ppos=0)
chrdev_legacy:chrdev_write: got 300 bytes (*ppos=300)
chrdev_legacy:chrdev_write: should write 985 bytes (*ppos=300)
chrdev_legacy:chrdev_write: got 0 bytes (*ppos=300)
chrdev_legacy:chrdev_release: chrdev released

首先,write()调用要求写入 1,285 字节(这是/etc/passwd的实际大小),但实际只写入了 300 字节(由于有限的缓冲区大小)。然后,第二个write()调用要求写入 985 字节(1,285-300字节),但现在ppos指向 300,这意味着缓冲区已满,然后返回 0(写入的字节数),这被写入命令解释为设备上没有空间的错误条件。

步骤 4中,与前面的grep命令相关的内核消息报告如下:

chrdev_legacy:chrdev_open: chrdev opened
chrdev_legacy:chrdev_read: should read 32768 bytes (*ppos=0)
chrdev_legacy:chrdev_read: return 300 bytes (*ppos=300)
chrdev_legacy:chrdev_read: should read 32768 bytes (*ppos=300)
chrdev_legacy:chrdev_read: return 0 bytes (*ppos=300)
chrdev_legacy:chrdev_release: chrdev released

我们可以很容易地看到,grep命令首先使用open()系统调用打开我们的设备文件,然后使用read()持续读取数据,直到我们的驱动程序返回文件结束(用 0 表示),最后执行close()系统调用释放我们的驱动程序。

第四章:使用设备树

现代计算机是由复杂的外围设备组成的复杂系统,这些外围设备有大量不同的配置设置;这就是为什么在专用文件中具有所有可能的设备驱动程序配置变体可以解决很多问题。有关系统结构的逻辑描述(即它们如何相互连接而不仅仅是它们的列表)可以让系统开发人员将注意力集中在设备驱动程序机制上,而不是管理所有可能的用户设置的乏味工作。

此外,了解每个外围设备如何连接到系统(例如,外围设备依赖于哪个总线)可以实现一个非常智能的外围设备管理系统。这样的系统可以正确地按照特定设备所需的顺序激活(或停用)所有子系统。

让我们来看一个例子:想象一下一个 USB 键,当插入电脑时会激活多个设备。系统知道 USB 端口连接到特定的 USB 控制器,该控制器映射到系统内存的特定地址,依此类推。

出于这些原因(和其他原因),Linux 开发人员采用了设备树,简单地说,这是一种描述硬件的数据结构。它可以将所有内核设置硬编码到代码中,而是可以在启动时由引导加载程序传递给内核的一种明确定义的数据结构中进行描述。这也是所有设备驱动程序(和其他内核实体)可以获取其配置数据的地方。

设备树和内核配置文件(Linux 源代码上级目录中的.config文件)之间的主要区别在于,虽然这些文件告诉我们内核的哪些组件已启用,哪些未启用,但设备树保存它们的配置。因此,如果我们希望将内核源代码中的驱动程序添加到我们的系统中,我们必须在.config文件中指定它。另一方面,如果我们希望指定驱动程序的设置(内存地址、特殊设置等),我们必须在设备树中指定它们。

在本章中,我们将看到如何编写设备树,以及如何从中获取有用的信息来为我们的驱动程序提供支持。

本章包括以下内容:

  • 使用设备树编译器和实用程序

  • 从设备树获取特定应用程序数据

  • 使用设备树描述字符驱动程序

  • 下载固件

  • 为特定外围设备配置 CPU 引脚

技术要求

您可以在附录中找到有关本章的更多信息。

本章中使用的代码和其他文件可以从 GitHub 下载:github.com/giometti/linux_device_driver_development_cookbook/tree/master/chapter_04

使用设备树编译器和实用程序

我们需要适当的工具将我们的代码转换为 Linux 可以理解的二进制格式。具体来说,我们需要一种方法将设备树源DTS)文件转换为其二进制形式:设备树二进制DTB)。

在本教程中,我们将了解如何在系统上安装设备树编译器dtc),以及如何使用它生成任何设备树的二进制文件。

准备工作

要将 DTS 文件转换为 DTB 文件,我们必须使用设备树编译器(名为dtc)和一组适当的工具,我们可以用来检查或操作 DTB 文件(设备树实用程序)。

每个最新的 Linux 发行版都有自己的dtc程序的副本,位于linux/scripts/dtc目录中,用于内核编译。但是,我们不需要安装 Linux 源代码来在 Ubuntu 上获得dtc及其实用程序的工作版本;实际上,我们可以使用以下常规安装命令获取它们:

$ sudo apt install device-tree-compiler

安装后,我们可以执行以下命令来显示dtc编译器的版本:

$ dtc -v
Version: DTC 1.4.5

操作步骤

现在我们准备将我们的第一个 DTS 文件转换为其等效的 DTB 二进制形式,使用以下步骤。

  1. 我们可以使用以下命令行使用dtc编译器来做到这一点:
$ dtc -o simple_platform.dtb simple_platform.dts

simple_platform.dts可以从 GitHub 源中检索;但是读者可以使用自己的 DTS 文件来测试dtc

现在我们的 DTB 文件应该在当前目录中可用:

$ file simple_platform.dtb
simple_platform.dtb: Device Tree Blob version 17, size=1602, boot CPU=0, string block size=270, DT structure block size=1276

它是如何工作的...

将 DTS 文件转换为 DTB 文件类似于正常编译器的工作,但是关于逆向操作应该说一些事情。

如果我们看一下simple_platform-reverted.dts,我们会注意到它看起来与原始的simple_platform.dts文件非常相似(除了 phandles、标签和十六进制形式的数字);事实上,关于时钟设置,我们有以下差异:

$ diff -u simple_platform.dts simple_platform-reverted.dts | tail -29
-      clks: clock@f00 {
+      clock@f00 {
           compatible = "fsl,mpc5121-clock";
           reg = <0xf00 0x100>;
-          #clock-cells = <1>;
-          clocks = <&osc>;
+          #clock-cells = <0x1>;
+          clocks = <0x1>;
           clock-names = "osc";
+          phandle = <0x3>;
       };

关于串行控制器设置,我们有以下差异:


-      serial0: serial@11100 {
+      serial@11100 {
           compatible = "fsl,mpc5125-psc-uart", "fsl,mpc5125-psc";
           reg = <0x11100 0x100>;
-          interrupt-parent = <&ipic>;
-          interrupts = <40 0x8>;
-          fsl,rx-fifo-size = <16>;
-          fsl,tx-fifo-size = <16>;
-          clocks = <&clks 47>, <&clks 34>;
+          interrupt-parent = <0x2>;
+          interrupts = <0x28 0x8>;
+          fsl,rx-fifo-size = <0x10>;
+          fsl,tx-fifo-size = <0x10>;
+          clocks = <0x3 0x2f 0x3 0x22>;
           clock-names = "ipg", "mclk";
       };
   };

从前面的输出中,我们可以看到serial0clks标签已经消失,因为它们在 DTB 文件中不需要;phandles 现在也明确报告,并且已经相应地替换为ipicclks等相应的符号名称,并且所有数字已经转换为它们的十六进制形式。

还有更多...

设备树是一个非常复杂的软件,是描述系统的强大方式,这就是为什么我们需要更多地谈论它。由于对于内核开发人员来说,管理设备树二进制形式非常有用,因此我们还应该看一下设备树实用程序。

将二进制设备树还原为其源代码

dtc程序可以逆转编译过程,允许开发人员使用以下命令行从二进制文件中检索源文件:

$ dtc -o simple_platform-reverted.dts simple_platform.dtb

当我们需要检查 DTB 文件时,这可能非常有用。

另请参阅

从设备树中获取特定应用程序的数据

现在我们知道如何读取设备树文件以及如何在用户空间中管理它。在这个配方中,我们将看到如何提取内核中保存的配置设置。

做好准备

为了完成我们的工作,我们可以使用存储在 DTB 中的所有数据来引导我们的 ESPRESSObin,然后使用 ESPRESSObin 作为系统测试。

正如我们所知,ESPRESSObin 的 DTS 文件存储在内核源代码中的linux/arch/arm64/boot/dts/marvell/armada-3720-espressobin.dts,或者可以通过执行以下代码中呈现的dtc命令从运行的内核中提取出来:

# dtc -I fs -o espressobin-reverted.dts /proc/device-tree/

现在让我们拆开这个文件,因为我们可以使用它来验证我们刚刚读取的数据是否正确。

如何做到这一点...

为了展示我们如何从运行的设备树中读取数据,我们可以使用 GitHub 源中的一个内核模块(如文件get_dt_data.c中报告的模块)。

  1. 在文件中,由于我们在模块的init()函数中没有分配任何内容,所以我们有一个空的模块exit()函数;事实上,它只是向我们展示了如何解析设备树。get_dt_data_init()函数接受一个可选的输入参数:存储在以下代码片段中定义的path变量中的设备树路径:
#define PATH_DEFAULT "/"
static char *path = PATH_DEFAULT;
module_param(path, charp, S_IRUSR | S_IWUSR);
MODULE_PARM_DESC(path, "a device tree pathname " \
                       "(default is \"" PATH_DEFAULT "\")");
  1. 然后,作为第一步,get_dt_data_init()函数使用of_find_node_by_path()函数获取指向要检查的所需节点的指针:
static int __init get_dt_data_init(void)
{
    struct device_node *node, *child;
    struct property *prop;

    pr_info("path = \"%s\"\n", path);

    /* Find node by its pathname */
    node = of_find_node_by_path(path);
    if (!node) {
        pr_err("failed to find device-tree node \"%s\"\n", path);
        return -ENODEV;
    }
    pr_info("device-tree node found!\n");
  1. 接下来,它调用print_main_prop()函数,该函数只打印节点的主要属性,如下所示:
static void print_main_prop(struct device_node *node)
{
    pr_info("+ node = %s\n", node->full_name);
    print_property_u32(node, "#address-cells");
    print_property_u32(node, "#size-cells");
    print_property_u32(node, "reg");
    print_property_string(node, "name");
    print_property_string(node, "compatible");
    print_property_string(node, "status");
}

每个打印函数都报告如下:

static void print_property_u32(struct device_node *node, const char *name)
{
    u32 val32;
    if (of_property_read_u32(node, name, &val32) == 0)
        pr_info(" \%s = %d\n", name, val32); 
}

static void print_property_string(struct device_node *node, const char *name)
{
    const char *str;
    if (of_property_read_string(node, name, &str) == 0)
        pr_info(" \%s = %s\n", name, str);
}
  1. 对于最后两个步骤,get_dt_data_init()函数使用for_each_property_of_node()宏来显示所有节点的属性,使用for_each_child_of_node()宏来迭代所有节点的子节点并显示它们的所有主要属性,如下所示:
    pr_info("now move through all properties...\n");
    for_each_property_of_node(node, prop)
        pr_info("-> %s\n", prop->name);

    /* Move through node's children... */
    pr_info("Now move through children...\n");
    for_each_child_of_node(node, child)
        print_main_prop(child);

    /* Force module unloading... */
    return -EINVAL;
    }

工作原理...

在步骤 1 中,很明显,如果我们将模块插入内核并指定path=<my_path>,我们会强制使用所需的值;否则,我们只接受默认值,即根目录(由/字符表示)。其余步骤都很容易理解。

理解代码应该非常容易;实际上,get_dt_data_init()函数只是调用of_find_node_by_path(),传递设备路径名称;没有错误,我们使用print_main_prop()来显示节点名称和一些主要(或有趣的)节点属性:

static void print_main_prop(struct device_node *node)
{
    pr_info("+ node = %s\n", node->full_name);
    print_property_u32(node, "#address-cells");
    print_property_u32(node, "#size-cells");
    print_property_u32(node, "reg");
    print_property_string(node, "name");
    print_property_string(node, "compatible");
    print_property_string(node, "status");
}

请注意,print_property_u32()print_property_string()函数的定义方式是,如果提供的属性不存在,则不显示任何内容:

static void print_property_u32(struct device_node *node, const char *name)
{
    u32 val32;
    if (of_property_read_u32(node, name, &val32) == 0)
        pr_info(" \%s = %d\n", name, val32);
}

static void print_property_string(struct device_node *node, const char *name)
{
    const char *str;
    if (of_property_read_string(node, name, &str) == 0)
        pr_info(" \%s = %s\n", name, str);
}

诸如of_property_read_u32()/of_property_read_string()for_each_child_of_node()/for_each_property_of_node()等函数在内核源码的头文件linux/include/linux/of.h中定义。

get_dt_data.c文件编译后,我们应该得到其编译版本,命名为get_dt_data.ko,适合加载到 ESPRESSObin 中:

$ make KERNEL_DIR=../../../linux
make -C ../../../linux \
            ARCH=arm64 \
            CROSS_COMPILE=aarch64-linux-gnu- \
            SUBDIRS=/home/giometti/Projects/ldddc/github/chapter_4/get_dt_data modules
make[1]: Entering directory '/home/giometti/Projects/ldddc/linux'
  CC [M] /home/giometti/Projects/ldddc/github/chapter_4/get_dt_data/get_dt_data.o
  Building modules, stage 2.
  MODPOST 1 modules
  CC /home/giometti/Projects/ldddc/github/chapter_4/get_dt_data/get_dt_data.mod.o
  LD [M] /home/giometti/Projects/ldddc/github/chapter_4/get_dt_data/get_dt_data.ko
make[1]: Leaving directory '/home/giometti/Projects/ldddc/linux'

如果我们在新创建的内核模块中使用modinfo,我们应该得到以下内容:

# modinfo get_dt_data.ko 
filename: /root/get_dt_data.ko
version: 0.1
description: Module to inspect device tree from the kernel
author: Rodolfo Giometti
license: GPL
srcversion: 6926CA8AD5E7F8B45C97CE6
depends: 
name: get_dt_data
vermagic: 4.18.0 SMP preempt mod_unload aarch64
parm: path:a device tree pathname (default is "/") (charp)

还有更多...

好的,让我们尝试使用以下命令使用path的默认值:

# insmod get_dt_data.ko

我们应该得到以下输出:

get_dt_data: path = "/"
get_dt_data: device-tree node found!
...

通过使用/作为路径名,显然我们在设备树中找到了相应的条目,因此输出继续如下:

...
get_dt_data: now getting main properties...
get_dt_data: + node = 
get_dt_data: #address-cells = 2
get_dt_data: #size-cells = 2
get_dt_data: name = 
get_dt_data: compatible = globalscale,espressobin
get_dt_data: now move through all properties...
get_dt_data: -> model
get_dt_data: -> compatible
get_dt_data: -> interrupt-parent
get_dt_data: -> #address-cells
get_dt_data: -> #size-cells
get_dt_data: -> name
...

以下是可以根据原始源或espressobin-reverted.dts文件验证的根节点的所有属性:

/ {
    #address-cells = <0x2>;
    model = "Globalscale Marvell ESPRESSOBin Board";
    #size-cells = <0x2>;
    interrupt-parent = <0x1>;
    compatible = "globalscale,espressobin", "marvell,armada3720", "marvell,armada3710";

读者应该注意,在这种情况下,name属性为空,因为我们正在检查根节点,并且对于compatible属性,只显示第一个条目,因为我们使用了of_property_read_string()函数,而不是相应的数组of_property_read_string_array()版本和相关函数。

在打印出所有节点的属性之后,我们的程序将遍历所有子节点,如下所示:

...
get_dt_data: Now move through children...
get_dt_data: + node = aliases
get_dt_data: name = aliases
get_dt_data: + node = cpus
get_dt_data: #address-cells = 1
get_dt_data: #size-cells = 0
get_dt_data: name = cpus
...
get_dt_data: + node = soc
get_dt_data: #address-cells = 2
get_dt_data: #size-cells = 2
get_dt_data: name = soc
get_dt_data: compatible = simple-bus
get_dt_data: + node = chosen
get_dt_data: name = chosen
get_dt_data: + node = memory@0
get_dt_data: reg = 0
get_dt_data: name = memory
get_dt_data: + node = regulator
get_dt_data: name = regulator
get_dt_data: compatible = regulator-gpio
...

在这一点上,get_dt_data_init()函数执行return -EINVAL,不是为了返回错误条件,而是为了强制模块卸载;实际上,作为最后打印出的消息,我们看到以下内容:

insmod: ERROR: could not insert module get_dt_data.ko: Invalid parameters

现在,只是为了展示不同的用法,我们可以尝试通过在命令行中指定path=/cpus来请求有关系统 CPU 的信息:

# insmod get_dt_data.ko path=/cpus

程序表示找到了一个节点:

get_dt_data: path = "/cpus"
get_dt_data: device-tree node found!

然后它开始打印节点的信息:

get_dt_data: now getting main properties...
get_dt_data: + node = cpus
get_dt_data: #address-cells = 1
get_dt_data: #size-cells = 0
get_dt_data: name = cpus

最后,它显示所有子节点的属性:

get_dt_data: now move through all properties...
get_dt_data: -> #address-cells
get_dt_data: -> #size-cells
get_dt_data: -> name
get_dt_data: Now move through children...
get_dt_data: + node = cpu@0
get_dt_data: reg = 0
get_dt_data: name = cpu
get_dt_data: compatible = arm,cortex-a53
get_dt_data: + node = cpu@1
get_dt_data: reg = 1
get_dt_data: name = cpu
get_dt_data: compatible = arm,cortex-a53

请注意,以下错误消息可以安全地忽略,因为我们强制它自动检索要由insmod命令卸载的模块:

insmod: ERROR: could not insert module get_dt_data.ko: Invalid parameters

以类似的方式,我们可以获取有关 I2C 控制器的信息,如下所示:

# insmod get_dt_data.ko path=/soc/internal-regs@d0000000/i2c@11000
get_dt_data: path = "/soc/internal-regs@d0000000/i2c@11000"
get_dt_data: device-tree node found!
get_dt_data: now getting main properties...
get_dt_data: + node = i2c@11000
get_dt_data: #address-cells = 1
get_dt_data: #size-cells = 0
get_dt_data: reg = 69632
get_dt_data: name = i2c
get_dt_data: compatible = marvell,armada-3700-i2c
get_dt_data: status = disabled
get_dt_data: now move through all properties...
...

另请参阅

  • 要查看检查设备树的所有可用函数,读者可以查看包含的linux/include/linux/of.h文件,该文件有很好的文档。

使用设备树描述字符驱动程序

在这一点上,我们已经有了使用设备树定义新字符设备所需的所有信息。特别是,这一次,为了注册我们的chrdev设备,我们可以使用我们在第三章中跳过的新 API,使用字符驱动程序

准备就绪

如前一段所述,我们可以使用设备树节点向系统添加新设备。特别是,我们可以获得如下所述的定义:

chrdev {
    compatible = "ldddc,chrdev";
    #address-cells = <1>;
    #size-cells = <0>;

    chrdev@2 {
        label = "cdev-eeprom";
        reg = <2>;
    };

    chrdev@4 {
        label = "cdev-rom";
        reg = <4>;
        read-only;
    };
};

所有这些修改都可以使用根目录中的add_chrdev_devices.dts.patch文件来应用,如下所示:

$ patch -p1 < ../github/chapter_04/chrdev/add_chrdev_devices.dts.patch

然后必须重新编译和重新安装内核(使用 ESPRESSObin 的 DTB 文件)才能生效。

在这个例子中,我们定义了一个chrdev节点,它定义了一个与"ldddc,chrdev"兼容的新设备集,并且有两个子节点;每个子节点定义了一个具有自己设置的特定设备。第一个子节点定义了一个标记为"cdev-eeprom""ldddc,chrdev"设备,其reg属性等于2,而第二个子节点定义了另一个标记为"cdev-rom""ldddc,chrdev"设备,其reg属性等于4,并且具有read-only属性。

#address-cells#size-cells属性必须是 1 和 0,因为子设备的reg属性包含一个表示"设备地址"的单个值。实际上,可寻址设备使用#address-cells#size-cellsreg属性将地址信息编码到设备树中。

每个可寻址设备都有一个reg属性,如下所示的列表:

reg = <address1 length1 [address2 length2] [address3 length3] ... >

每个元组表示设备使用的地址范围,每个地址或长度值都是一个或多个称为cells的 32 位整数列表(长度也可以为空,就像我们的示例一样)。

由于地址和长度字段都可能变化且大小可变,因此父节点中的#address-cells#size-cells属性用于说明每个子节点字段中有多少个 cells。

有关#address-cells#size-cellsreg属性的更多信息,可以查看www.devicetree.org/specifications/上的设备树规范。

如何做到...

现在是时候看看如何使用前面的设备树定义来创建我们的字符设备了(请注意,这次我们要创建多个设备!)。

  1. 模块的init()exit()函数都必须按照以下代码进行重写。chrdev_init()如下所示:
static int __init chrdev_init(void)
{
    int ret;

    /* Create the new class for the chrdev devices */
    chrdev_class = class_create(THIS_MODULE, "chrdev");
    if (!chrdev_class) {
        pr_err("chrdev: failed to allocate class\n");
        return -ENOMEM;
    }

    /* Allocate a region for character devices */
    ret = alloc_chrdev_region(&chrdev_devt, 0, MAX_DEVICES, "chrdev");
    if (ret < 0) {
        pr_err("failed to allocate char device region\n");
        goto remove_class;
    }

    pr_info("got major %d\n", MAJOR(chrdev_devt));

    return 0;

remove_class:
    class_destroy(chrdev_class);

    return ret;
}
  1. chrdev_exit()函数如下所示:
static void __exit chrdev_exit(void)
{
    unregister_chrdev_region(chrdev_devt, MAX_DEVICES);
    class_destroy(chrdev_class);
}

所有代码都可以从 GitHub 的chrdev.c文件中检索到。

  1. 如果我们尝试将模块插入内核,应该会得到如下内容:
# insmod chrdev.ko 
chrdev: loading out-of-tree module taints kernel.
chrdev:chrdev_init: got major 239
  1. 要创建字符设备,我们必须使用下一个chrdev_device_register()函数,但首先我们必须进行一些检查,看设备是否已经创建:
int chrdev_device_register(const char *label, unsigned int id,
                unsigned int read_only,
                struct module *owner, struct device *parent) 
{
    struct chrdev_device *chrdev;
    dev_t devt;
    int ret;

    /* First check if we are allocating a valid device... */
    if (id >= MAX_DEVICES) {
        pr_err("invalid id %d\n", id);
        return -EINVAL;
    }
    chrdev = &chrdev_array[id];

    /* ... then check if we have not busy id */
    if (chrdev->busy) {
        pr_err("id %d\n is busy", id);
        return -EBUSY; 
    }

然后我们要做的事情比前一章简单调用register_chrdev()函数要复杂一些;现在真正重要的是调用cdev_init()cdev_add()device_create()函数的顺序,这些函数实际上完成了工作,如下所示:

    /* Create the device and initialize its data */
    cdev_init(&chrdev->cdev, &chrdev_fops);
    chrdev->cdev.owner = owner;

    devt = MKDEV(MAJOR(chrdev_devt), id);
    ret = cdev_add(&chrdev->cdev, devt, 1); 
    if (ret) {
        pr_err("failed to add char device %s at %d:%d\n",
                label, MAJOR(chrdev_devt), id);
        return ret;
    }

    chrdev->dev = device_create(chrdev_class, parent, devt, chrdev,
                   "%s@%d", label, id);
    if (IS_ERR(chrdev->dev)) {
        pr_err("unable to create device %s\n", label); 
        ret = PTR_ERR(chrdev->dev);
        goto del_cdev;
    }

一旦device_create()函数返回成功,我们就使用dev_set_drvdata()函数来保存指向我们驱动程序数据的指针,然后初始化如下:

  dev_set_drvdata(chrdev->dev, chrdev);

 /* Init the chrdev data */
 chrdev->id = id; 
 chrdev->read_only = read_only;
 chrdev->busy = 1;
 strncpy(chrdev->label, label, NAME_LEN);
 memset(chrdev->buf, 0, BUF_LEN);

 dev_info(chrdev->dev, "chrdev %s with id %d added\n", label, id);

 return 0;

del_cdev:
 cdev_del(&chrdev->cdev);

 return ret;
}
EXPORT_SYMBOL(chrdev_device_register);

所有这些函数都作用于struct chrdev_device,定义如下:

/* Main struct */
struct chrdev_device {
    char label[NAME_LEN];
    unsigned int busy : 1;
    char buf[BUF_LEN];
    int read_only;

    unsigned int id; 
    struct module *owner;
    struct cdev cdev;
    struct device *dev;
};

工作原理...

步骤 1中,在chrdev_init()函数中,这次我们使用alloc_chrdev_region()函数,请求内核保留一些名为chrdev的字符设备(在我们的情况下,这个数字等同于MAX_DEVICES的定义)。chrdev信息然后存储在chrdev_devt变量中。

在这里,我们应该注意并注意,我们还通过调用class_create()函数创建了一个设备类。设备树中定义的每个设备都必须属于适当的类,由于我们的chrdev驱动程序是新的,所以我们需要一个专门的类。

在接下来的步骤中,我将更清楚地解释我们需要以这种方式进行的原因;目前,我们应该将其视为强制性的数据分配。

很明显,unregister_chrdev_region()函数只是释放了alloc_chrdev_region()分配的所有chrdev数据。

步骤 3中,如果我们查看/proc/devices文件,我们会得到以下内容:

# grep chrdev /proc/devices
239 chrdev

好了!现在我们有了类似第三章的东西,使用字符驱动程序!然而,这一次,如果我们尝试使用mknod创建一个特殊字符文件,并尝试从中读取,我们会得到一个错误!

# mknod /dev/chrdev c 239 0
# cat /dev/chrdev
cat: /dev/chrdev: No such device or address

内核告诉我们设备不存在!这是因为我们还没有创建任何东西,只是保留了一些内核内部数据。

步骤 4中,前面的字段只是相对于我们特定的实现,而最后四个字段几乎出现在每个字符驱动程序实现中:id字段只是每个chrdev的唯一标识符(请记住我们的实现支持MAX_DEVICES实例),owner指针用于存储我们驱动程序模块的所有者,cdev结构保存了关于我们字符设备的所有内核数据,dev指针指向了与设备树中指定的设备相关的内核struct device

因此,cdev_init()用于使用我们的文件操作初始化cdevcdev_add()用于定义驱动程序的主要和次要编号;device_create()用于将devt数据与由dev指向的数据连接起来;我们的chrdev类(由chrdev_class指针表示)实际上创建了字符设备。

然而,chrdev_device_register()函数并没有被chrdev.c文件中的任何函数调用;这就是为什么它被声明为一个导出符号,使用EXPORT_SYMBOL()定义。事实上,这个函数被另一个模块中的chrdev-req.c文件中定义的chrdev_req_probe()函数调用,如下面的片段所示。该函数首先了解我们需要注册多少设备:

static int chrdev_req_probe(struct platform_device *pdev)
{
    struct device *dev = &pdev->dev;
    struct fwnode_handle *child;
    struct module *owner = THIS_MODULE;
    int count, ret;

    /* If we are not registering a fixed chrdev device then get
     * the number of chrdev devices from DTS
     */
    count = device_get_child_node_count(dev);
    if (count == 0)
        return -ENODEV;
    if (count > MAX_DEVICES)
        return -ENOMEM;

然后,对于每个设备,在读取设备的属性之后,chrdev_device_register()调用来在系统上注册设备(对于设备树中报告的每个设备都会这样做,如前面的代码所示):

 device_for_each_child_node(dev, child) {
        const char *label;
        unsigned int id, ro;

        /*
         * Get device's properties
         */

        if (fwnode_property_present(child, "reg")) {
            fwnode_property_read_u32(child, "reg", &id);
        } else {
...

        }
        ro = fwnode_property_present(child, "read-only");

        /* Register the new chr device */
        ret = chrdev_device_register(label, id, ro, owner, dev);
        if (ret) { 
            dev_err(dev, "unable to register");
        }
    }

    return 0;
}

但是系统如何知道何时调用chrdev_req_probe()函数呢?如果我们继续阅读chrdev-req.c,就会很清楚;事实上,在接近结尾的地方,我们找到了以下代码:

static const struct of_device_id of_chrdev_req_match[] = {
    {
        .compatible = "ldddc,chrdev",
    },
    { /* sentinel */ }
};
MODULE_DEVICE_TABLE(of, of_chrdev_req_match);

static struct platform_driver chrdev_req_driver = {
    .probe = chrdev_req_probe,
    .remove = chrdev_req_remove,
    .driver = {
        .name = "chrdev-req",
        .of_match_table = of_chrdev_req_match,
    },
};
module_platform_driver(chrdev_req_driver);

当我们将chrdev-req.ko模块插入内核时,我们使用module_platform_driver()定义了一个新的平台驱动程序,然后内核开始寻找一个节点,其compatible属性设置为"ldddc,chrdev";如果找到了,就执行由我们设置为chrdev_req_probe()probe指针指向的函数。这将导致注册一个新的驱动程序。

在展示它是如何工作之前,让我们看看相反的步骤,用于释放我们在字符驱动程序分配期间从内核请求的任何内容。当我们移除chrdev-req.ko模块时,内核会调用平台驱动程序的remove函数,即chrdev-req.c文件中的chrdev_req_remove(),部分如下所示:

static int chrdev_req_remove(struct platform_device *pdev)
{
    struct device *dev = &pdev->dev;
    struct fwnode_handle *child;
    int ret;

    device_for_each_child_node(dev, child) {
        const char *label; 
        int id;

        /*
         * Get device's properties
         */

        if (fwnode_property_present(child, "reg")) 
            fwnode_property_read_u32(child, "reg", &id);
        else
            BUG();
        if (fwnode_property_present(child, "label"))
            fwnode_property_read_string(child, "label", &label);
        else
            BUG();

        /* Register the new chr device */
        ret = chrdev_device_unregister(label, id);
        if (ret)
            dev_err(dev, "unable to unregister");
    }

    return 0;
}

这个函数,位于chrdev.c文件中,调用chrdev_device_unregister()(对于设备树中的每个chrdev节点),如下所示;它首先进行一些健全性检查:


int chrdev_device_unregister(const char *label, unsigned int id)
{
    struct chrdev_device *chrdev;

    /* First check if we are deallocating a valid device... */
    if (id >= MAX_DEVICES) {
        pr_err("invalid id %d\n", id);
        return -EINVAL;
    }
    chrdev = &chrdev_array[id];

    /* ... then check if device is actualy allocated */
    if (!chrdev->busy || strcmp(chrdev->label, label)) {
        pr_err("id %d is not busy or label %s is not known\n",
                        id, label);
        return -EINVAL;
    }

但然后它使用device_destroy()cdev_del()函数注销驱动程序:

    /* Deinit the chrdev data */
    chrdev->id = 0;
    chrdev->busy = 0;

    dev_info(chrdev->dev, "chrdev %s with id %d removed\n", label, id);

    /* Dealocate the device */
    device_destroy(chrdev_class, chrdev->dev->devt);
    cdev_del(&chrdev->cdev);

    return 0;
}
EXPORT_SYMBOL(chrdev_device_unregister);

还有更多...

使用设备树不仅仅用于描述外围设备(以及整个系统);通过使用它,我们还可以访问 Linux 为内核开发人员提供的几个现成的有用功能。因此,让我们来看看最重要(和有用)的功能。

/dev 中如何创建设备文件

在第三章中,使用字符驱动程序,当我们创建一个新的字符设备时,用户空间中什么都没有发生,我们不得不手动使用mknod命令创建一个字符设备文件;然而,在本章中,当我们插入第二个内核模块时,它创建了我们的新chrdev设备。通过从设备树中获取它们的属性,在/dev目录中,两个新的字符文件被自动创建。

正是 Linux 的内核对象机制实现了这一点;让我们看看是如何实现的。

每当内核中创建新设备时,都会生成一个新的内核事件并发送到用户空间;然后专用应用程序会捕获这个新事件并对其进行解释。这些特殊的应用程序可能各不相同,但几乎所有重要的 Linux 发行版都使用的最著名的这种类型的应用程序是udev应用程序。

udev守护程序是为了替换和创建一个机制,以自动在/dev目录下创建特殊设备文件,并且它运行得非常好,以至于现在用于多种不同的任务。实际上,udev守护程序直接从内核接收设备内核事件(称为uevents),每当系统添加或删除设备(或更改其状态)时,它都会执行一组规则,根据其配置文件。如果规则匹配各种设备属性,则会执行规则,然后相应地在/dev目录中创建新文件;匹配规则还可以提供其他设备信息,用于创建有意义的符号链接名称,执行脚本等等!

有关udev规则的更多信息,一个很好的起点是 Debian Wiki 上的相关页面wiki.debian.org/udev

要监视这些事件,我们可以使用udevadm工具,该工具包含在udev软件包中,如以下命令所示:

# udevadm monitor -k -p -s chrdev
monitor will print the received events for:
KERNEL - the kernel uevent

通过使用monitor子命令,我们选择udevadm监视功能(因为udevadm还可以执行其他几项任务),并通过指定-k选项参数,要求仅显示内核生成的消息(因为一些消息可能也来自用户空间);此外,通过使用-p选项参数,我们要求显示事件属性,并通过-s选项参数,我们仅选择与chrdev字符串匹配的子系统的消息。

要查看所有内核消息,在chrdev模块插入时,内核发送的只需执行udevadm monitor命令,放弃所有这些选项参数。

要查看新事件,只需执行上述命令,并在另一个终端(或直接从串行控制台)重复内核模块插入。在插入chrdev-req.ko模块后,我们看到与之前相同的内核消息:

# insmod chrdev-req.ko 
chrdev cdev-eeprom@2: chrdev cdev-eeprom with id 2 added
chrdev cdev-rom@4: chrdev cdev-rom with id 4 added

然而,在我们执行udevadm消息的终端中,现在应该看到类似以下的内容:

KERNEL[14909.624343] add /devices/platform/chrdev/chrdev/cdev-eeprom@2 (chrdev)
ACTION=add
DEVNAME=/dev/cdev-eeprom@2
DEVPATH=/devices/platform/chrdev/chrdev/cdev-eeprom@2
MAJOR=239
MINOR=2
SEQNUM=2297
SUBSYSTEM=chrdev

KERNEL[14909.631813] add /devices/platform/chrdev/chrdev/cdev-rom@4 (chrdev)
ACTION=add
DEVNAME=/dev/cdev-rom@4
DEVPATH=/devices/platform/chrdev/chrdev/cdev-rom@4
MAJOR=239
MINOR=4
SEQNUM=2298
SUBSYSTEM=chrdev

这些是内核消息,通知udev已创建了两个名为/dev/cdev-eeprom@2/dev/cdev-rom@4的新设备(具有其他属性),因此udev已经拥有了创建/dev目录下新文件所需的所有信息。

下载固件

通过使用设备树,我们现在能够为我们的驱动程序指定许多不同的设置,但还有最后一件事情我们必须看到:如何将固件加载到我们的设备中。实际上,一些设备可能需要一个程序才能正常工作,出于许可证原因,这些程序不能链接到内核中。

在本节中,我们将看到一些示例,说明我们如何要求内核为我们的设备加载固件。

准备就绪

一些外围设备需要固件才能工作,然后我们需要一种机制将这些二进制数据加载到其中。 Linux 为我们提供了不同的机制来完成这项工作,它们都与request_firmware()函数有关。

每当我们在驱动程序中使用request_firmware(..., "filename", ...)函数调用(指定文件名)时,内核开始查看不同的位置:

  • 首先,它查看引导映像文件,并在必要时从中加载固件;这是因为我们可以在编译期间将二进制代码与内核捆绑在一起。但是,只有在固件是自由软件的情况下才允许此解决方案;否则它不能链接到 Linux。如果我们必须重新编译内核来更改固件数据,这种解决方案在更改固件数据时也不够灵活。

  • 如果内核中没有存储任何数据,它将开始直接从文件系统加载固件数据,从指定内核命令行的路径开始查找filename,然后在/lib/firmware/updates/<UTS_RELEASE>,然后进入/lib/firmware/updates,然后进入/lib/firmware/<UTS_RELEASE>,最后进入/lib/firmware目录。

<UTS_RELEASE>是内核发布版本号,可以通过使用uname -r命令直接从内核中获取,如下所示:

$ uname -r

4.15.0-45-generic

  • 如果最后一步也失败了,那么内核可能会尝试回退程序,这包括启用固件加载器用户辅助程序。必须通过启用以下内核配置设置来启用内核的最后一次加载固件:
CONFIG_FW_LOADER_USER_HELPER=y
CONFIG_FW_LOADER_USER_HELPER_FALLBACK=y

通过使用通常的make menuconfig方法,我们必须通过设备驱动程序,然后通用驱动程序选项,固件加载器条目来启用它们(参见下面的屏幕截图)。

在启用这些设置并重新编译内核后,我们可以详细了解如何在内核中为我们的驱动程序加载自定义固件。

如何做...

首先,我们需要一个专注于固件加载的修改版本的chrdev-req.c文件;这就是为什么最好使用另一个文件。

  1. 为了完成我们的工作,我们可以使用chrdev-fw.c文件和以下设备定义:
static const struct of_device_id of_chrdev_req_match[] = {
    {
        .compatible = "ldddc,chrdev-fw_wait",
    },
    {
        .compatible = "ldddc,chrdev-fw_nowait",
    },
    { /* sentinel */ }
};
MODULE_DEVICE_TABLE(of, of_chrdev_req_match);

static struct platform_driver chrdev_req_driver = {
    .probe = chrdev_req_probe,
    .remove = chrdev_req_remove,
    .driver = {
        .name = "chrdev-fw",
        .of_match_table = of_chrdev_req_match,
    },
};
module_platform_driver(chrdev_req_driver);

chrdev-fw.c文件可以在本章的 GitHub 源中找到。

  1. 在这种情况下,我们的探测功能可以实现如下,chrdev_req_probe()函数的开头我们读取设备的一些属性:
static int chrdev_req_probe(struct platform_device *pdev)
{
    struct device *dev = &pdev->dev;
    struct device_node *np = dev->of_node;
    struct fwnode_handle *fwh = of_fwnode_handle(np);
    struct module *owner = THIS_MODULE;
    const char *file;
    int ret = 0;

    /* Read device properties */
    if (fwnode_property_read_string(fwh, "firmware", &file)) {
        dev_err(dev, "unable to get property \"firmware\"!");
        return -EINVAL;
    }

    /* Load device firmware */
    if (of_device_is_compatible(np, "ldddc,chrdev-fw_wait"))
        ret = chrdev_load_fw_wait(dev, file);
    else if (of_device_is_compatible(np, "ldddc,chrdev-fw_nowait"))
        ret = chrdev_load_fw_nowait(dev, file);
    if (ret)
        return ret;

然后我们注册字符设备:

    /* Register the new chr device */
    ret = chrdev_device_register("chrdev-fw", 0, 0, owner, dev);
    if (ret) {
        dev_err(dev, "unable to register");
        return ret;
    }

    return 0;
}
  1. 以前的设备类型调用chrdev_load_fw_wait()函数,执行下一步。它首先请求固件的数据结构:
static int chrdev_load_fw_wait(struct device *dev, const char *file)
{
    char fw_name[FIRMWARE_NLEN];
    const struct firmware *fw;
    int ret;

    /* Compose firmware filename */
    if (strlen(file) > (128 - 6 - sizeof(FIRMWARE_VER)))
        return -EINVAL;
    sprintf(fw_name, "%s-%s.bin", file, FIRMWARE_VER);

    /* Do the firmware request */
    ret = request_firmware(&fw, fw_name, dev);
    if (ret) {
        dev_err(dev, "unable to load firmware\n");
        return ret;
    }

然后转储接收到的数据,最后释放先前分配的固件数据结构:

    dump_data(fw->data, fw->size);

    /* Firmware data has been read, now we can release it */
    release_firmware(fw);

    return 0;
}

FIRMWARE_VERFIRMWARE_NLEN宏已经在chrdev-fw.c文件中定义如下:

#define FIRMWARE_VER "1.0.0"

#define FIRMWARE_NLEN 128

它是如何工作的...

步骤 1中,在of_chrdev_req_match[]数组中,我们现在有两个设备可以用来测试不同的固件加载方式。一个名为ldddc,chrdev-fw_wait的设备可以用来测试直接从文件系统加载固件,而另一个名为ldddc,chrdev-fw_nowait的设备可以用来测试固件加载器的用户辅助程序。

我使用这两个示例来向读者展示两种不同的固件加载技术,但实际上,这两种方法可以用于不同的目的;前者可以在我们的设备需要其固件时使用,否则它无法工作(这会强制驱动程序不内置),而后者可以在我们的设备即使没有任何固件也可以部分使用,并且可以在设备初始化后稍后加载(这会删除强制内置形式)。

步骤 2中,在读取firmware属性(保存固件文件名)后,我们检查设备是否与ldddc,chrdev-fw_waitldddc,chrdev-fw_nowait设备兼容,然后调用适当的固件加载函数,然后注册新设备。

步骤 3中,chrdev_load_fw_wait()函数构建了一个以<name>-<version>.bin形式的文件名,然后调用了名为request_firmware()的有效固件加载函数。作为响应,这个函数可能返回一个错误,导致驱动程序加载时出现错误,或者它可以返回一个包含固件的适当结构,该结构将固件保存在buffer fw->data指针中,大小为long fw->size字节。dump_data()函数通过将固件数据打印到内核消息中来简单地转储固件数据,但release_firmware()函数很重要,必须调用它来通知内核我们已经读取了所有数据并完成了处理,然后它可以释放资源。

另一方面,如果在设备树中指定了ldddc,chrdev-fw_nowait设备,那么将调用chrdev_load_fw_nowait()函数。这个函数的操作方式与之前类似,但最后会调用request_firmware_nowait(),它的工作方式类似于request_firmware()。然而,如果固件不是直接从文件系统加载的,它会执行回退程序,涉及固件加载器的用户辅助程序。这个特殊的辅助程序会向udev工具(或类似工具)发送 uevent 消息,导致自动加载固件,或在 sysfs 中创建一个条目,用户可以用它来手动加载内核。

chrdev_load_fw_nowait()函数的主体如下:

static int chrdev_load_fw_nowait(struct device *dev, const char *file)
{
    char fw_name[FIRMWARE_NLEN];
    int ret;

    /* Compose firmware filename */
    if (strlen(file) > (128 - 6 - sizeof(FIRMWARE_VER)))
        return -EINVAL;
    sprintf(fw_name, "%s-%s.bin", file, FIRMWARE_VER);

    /* Do the firmware request */
    ret = request_firmware_nowait(THIS_MODULE, false, fw_name, dev,
            GFP_KERNEL, dev, chrdev_fw_cb);
    if (ret) {
        dev_err(dev,
            "unable to register call back for firmware loading\n");
        return ret;
    } 

    return 0;
}

request_firmware_nowait()request_firmware()之间的一些重要区别是,前者定义了一个回调函数,每当固件实际从用户空间加载时就会调用该函数,并且它有一个布尔值作为第二个参数,可以用来要求内核向用户空间发送 uevent 消息或不发送。通过使用一个值,我们实现了类似于request_firmware()的功能,而如果我们指定了一个 false 值(如我们的情况),我们强制手动加载固件。

然后,当用户空间进程采取所需的步骤来加载所需的固件时,将使用回调函数,我们可以像下面的例子中那样实际加载固件数据:

static void chrdev_fw_cb(const struct firmware *fw, void *context)
{
    struct device *dev = context;

    dev_info(dev, "firmware callback executed!\n");
    if (!fw) {
        dev_err(dev, "unable to load firmware\n");
        return; 
    } 

    dump_data(fw->data, fw->size);

    /* Firmware data has been read, now we can release it */
    release_firmware(fw);
}

在这个函数中,我们实际上采取了与之前相同的步骤,将固件数据转储到内核消息中。

还有更多

让我们验证一下这个步骤中的所有操作。作为第一步,让我们尝试使用ldddc,chrdev-fw_wait设备,它使用request_firmware()函数;我们需要在设备树中添加下一个条目:

--- a/arch/arm64/boot/dts/marvell/armada-3720-espressobin.dts
+++ b/arch/arm64/boot/dts/marvell/armada-3720-espressobin.dts
@@ -41,6 +41,11 @@
              3300000 0x0>;
          enable-active-high;
      };
+
+     chrdev {
+         compatible = "ldddc,chrdev-fw_wait";
+         firmware = "chrdev-wait";
+     };
 };

 /* J9 */

然后我们需要编译代码,可以通过将新的chrdev-fw.c文件添加到makefile中来实现,如下所示:

--- a/chapter_4/chrdev/Makefile
+++ b/chapter_4/chrdev/Makefile
@@ -6,7 +6,7 @@ ARCH ?= arm64
 CROSS_COMPILE ?= aarch64-linux-gnu-

 obj-m = chrdev.o
-obj-m += chrdev-req.o
+obj-m += chrdev-fw.o

 all: modules

一旦我们在 ESPRESSObin 的文件系统中有了新的模块,我们可以尝试将它们插入内核,如下所示:

# insmod chrdev.ko 
chrdev: loading out-of-tree module taints kernel.
chrdev:chrdev_init: got major 239
# insmod chrdev-fw.ko 
chrdev-fw chrdev: Direct firmware load for chrdev-wait-1.0.0.bin 
failed with error -2
chrdev-fw chrdev: Falling back to syfs fallback for: chrdev-wait-1.0.0.bin
chrdev-fw chrdev: unable to load firmware
chrdev-fw: probe of chrdev failed with error -11

正如我们所看到的,内核尝试加载chrdev-wait-1.0.0.bin文件,但由于文件系统中根本不存在,它找不到文件;然后,内核转到 sysfs 回退,但由于再次失败,我们会得到一个错误,驱动程序加载也会失败。

为了获得积极的结果,我们必须在搜索路径中添加一个名为chrdev-wait-1.0.0.bin的文件;例如,我们可以将它放在/lib/firmware/中,如下例所示:

# echo "THIS IS A DUMMY FIRMWARE FOR CHRDEV DEVICE" > \
 /lib/firmware/chrdev-wait-1.0.0.bin

如果/lib/firmware目录不存在,我们可以使用mkdir /lib/firmware命令来创建它。

现在我们可以尝试加载我们的chrdev-fw.ko模块,如下所示:

# rmmod chrdev-fw 
# insmod chrdev-fw.ko 
chrdev_fw:dump_data: 54[T] 48[H] 49[I] 53[S] 20[ ] 49[I] 53[S] 20[ ] 
chrdev_fw:dump_data: 41[A] 20[ ] 44[D] 55[U] 4d[M] 4d[M] 59[Y] 20[ ] 
chrdev_fw:dump_data: 46[F] 49[I] 52[R] 4d[M] 57[W] 41[A] 52[R] 45[E] 
chrdev_fw:dump_data: 20[ ] 46[F] 4f[O] 52[R] 20[ ] 43[C] 48[H] 52[R] 
chrdev_fw:dump_data: 44[D] 45[E] 56[V] 20[ ] 44[D] 45[E] 56[V] 49[I] 
chrdev_fw:dump_data: 43[C] 45[E] 0a[-] 
chrdev chrdev-fw@0: chrdev chrdev-fw with id 0 added

完美!现在固件已经按预期加载,chrdev设备已经正确创建。

现在我们可以尝试使用第二个设备,方法是修改设备树如下,然后重新启动 ESPRESSObin 并使用新的 DTB 文件:

--- a/arch/arm64/boot/dts/marvell/armada-3720-espressobin.dts
+++ b/arch/arm64/boot/dts/marvell/armada-3720-espressobin.dts
@@ -41,6 +41,11 @@
              3300000 0x0>;
          enable-active-high;
      };
+
+     chrdev {
+         compatible = "ldddc,chrdev-fw_nowait";
+         firmware = "chrdev-nowait";
+     };
 };

 /* J9 */

有了这些新的配置设置,如果我们尝试加载chrdev模块,我们会得到以下消息:

# insmod chrdev.ko 
chrdev: loading out-of-tree module taints kernel.
chrdev:chrdev_init: got major 239
# insmod chrdev-fw.ko 
chrdev-fw chrdev: Direct firmware load for chrdev-nowait-1.0.0.bin failed with error -2
chrdev-fw chrdev: Falling back to syfs fallback for: chrdev-nowait-1.0.0.bin
chrdev chrdev-fw@0: chrdev chrdev-fw with id 0 added

这次,内核仍然尝试直接从文件系统加载固件,但失败了,因为没有名为chrdev-nowait-1.0.0.bin的文件;然后它退回到回退固件加载器用户助手,我们已经强制进入手动模式。然而,驱动程序的探测功能成功注册了我们的chrdev驱动程序,即使没有加载固件,它现在也是完全功能的。

要手动加载固件,我们可以使用/sys/class/firmware/目录中的特殊 sysfs 条目,如下所示:

# ls /sys/class/firmware/
chrdev-nowait-1.0.0.bin  timeout

chrdev-nowait-1.0.0.bin目录被作为传递给request_firmware_nowait()函数的fw_name参数的字符串调用,并且在其中,我们找到以下文件:

# ls /sys/class/firmware/chrdev-nowait-1.0.0.bin
data  device  loading  power  subsystem  uevent

现在,自动加载固件的必需步骤如下:

# echo 1 > /sys/class/firmware/chrdev-nowait-1.0.0.bin/loading 
# echo "THIS IS A DUMMY FIRMWARE" > /sys/class/firmware/chrdev-nowait-1.0.0.bin/data 
# echo 0 > /sys/class/firmware/chrdev-nowait-1.0.0.bin/loading
chrdev-fw chrdev: firmware callback executed!
chrdev_fw:dump_data: 54[T] 48[H] 49[I] 53[S] 20[ ] 49[I] 53[S] 20[ ] 
chrdev_fw:dump_data: 41[A] 20[ ] 44[D] 55[U] 4d[M] 4d[M] 59[Y] 20[ ] 
chrdev_fw:dump_data: 46[F] 49[I] 52[R] 4d[M] 57[W] 41[A] 52[R] 45[E] 
chrdev_fw:dump_data: 0a[-] 

我们通过向loading文件写入1来开始下载过程,然后我们必须将所有固件数据复制到data文件中;然后我们通过在loading文件中写入0来完成下载。一旦我们这样做,内核就会调用我们驱动程序的回调函数,固件就会被加载。

另请参阅

为特定外围设备配置 CPU 引脚

作为设备驱动程序开发人员,这个任务非常重要,因为为了能够与外部设备(或者内部设备但带有外部信号线)进行通信,我们必须确保每个 CPU 引脚都被正确配置以与这些外部信号进行通信。在这个教程中,我们将看看如何使用设备树来配置 CPU 引脚。

操作步骤...

举个简单的例子,让我们尝试修改 ESPRESSObin 的引脚配置。

  1. 首先,我们应该通过查看/sys/bus/platform/drivers/mvebu-uart/目录中的 sysfs 来查看当前的配置,从中我们可以验证当前只有一个 UART 被启用:
# ls /sys/bus/platform/drivers/mvebu-uart/
d0012000.serial  uevent
# ls /sys/bus/platform/drivers/mvebu-uart/d0012000.serial/tty/
ttyMV0

然后mvebu-uart驱动程序管理d0012000.serial设备,可以使用/dev/ttyMV0文件进行访问。我们还可以通过查看/sys/kernel/debug/pinctrl/d0013800.pinctrl-armada_37xx-pinctrl/pinmux-pins文件在 debugfs 中验证 CPU 的引脚是如何配置的,我们可以看到只有uart1组被启用:

# cat /sys/kernel/debug/pinctrl/d0013800.pinctrl-armada_37xx-p
inctrl/pinmux-pins 
Pinmux settings per pin
Format: pin (name): mux_owner gpio_owner hog?
pin 0 (GPIO1-0): (MUX UNCLAIMED) (GPIO UNCLAIMED)
pin 1 (GPIO1-1): (MUX UNCLAIMED) (GPIO UNCLAIMED)
pin 2 (GPIO1-2): (MUX UNCLAIMED) (GPIO UNCLAIMED)
pin 3 (GPIO1-3): (MUX UNCLAIMED) GPIO1:479
pin 4 (GPIO1-4): (MUX UNCLAIMED) GPIO1:480
pin 5 (GPIO1-5): (MUX UNCLAIMED) (GPIO UNCLAIMED)
...
pin 24 (GPIO1-24): (MUX UNCLAIMED) (GPIO UNCLAIMED)
pin 25 (GPIO1-25): d0012000.serial (GPIO UNCLAIMED) function uart group uart1
pin 26 (GPIO1-26): d0012000.serial (GPIO UNCLAIMED) function uart group uart1
pin 27 (GPIO1-27): (MUX UNCLAIMED) (GPIO UNCLAIMED)
...

有关 debugfs 的更多信息,请参阅en.wikipedia.org/wiki/Debugfs 然后跟随一些外部链接。

  1. 然后我们应该尝试修改 ESPRESSObin 的 DTS 文件,以启用另一个名为uart1的 UART 设备,其自己的引脚定义在uart2_pins组中,如下所示:
--- a/arch/arm64/boot/dts/marvell/armada-3720-espressobin.dts
+++ b/arch/arm64/boot/dts/marvell/armada-3720-espressobin.dts
@@ -97,6 +97,13 @@
    status = "okay";
 };

+/* Exported on extension connector P9 at pins 24(UA2_TXD) and 26(UA2_RXD) */
+&uart1 {
+   pinctrl-names = "default";
+   pinctrl-0 = <&uart2_pins>;
+   status = "okay";
+};
+
 /*
  * Connector J17 and J18 expose a number of different features. Some pins are
  * multiplexed. This is the case for instance for the following features:

这个引脚组在linux/arch/arm64/boot/dts/marvell/armada-37xx.dtsi文件中定义如下:

    uart2_pins: uart2-pins {
        groups = "uart2";
        function = "uart";
    };

工作原理...

让我们通过测试我们的 pinctrl 修改来检查这是如何工作的。为此,我们必须像往常一样重新生成 ESPRESSObin 的 DTB 文件,并重新启动系统。如果一切顺利,我们现在应该有两个 UART 设备,如下所示:

# ls /sys/bus/platform/drivers/mvebu-uart/
d0012000.serial d0012200.serial uevent
# ls /sys/bus/platform/drivers/mvebu-uart/d0012200.serial/tty/
ttyMV1

此外,如果我们再次查看/sys/kernel/debug/pinctrl/d0013800.pinctrl-armada_37xx-pinctrl/pinmux-pins文件,我们会发现这次uart2引脚组已经被添加,然后我们的新串行端口可以在 P9 扩展连接器的 24 号和 26 号引脚上使用。

另请参阅

第五章:管理中断和并发

在实现设备驱动程序时,开发人员必须解决两个主要问题:

  • 如何与外围设备交换数据

  • 如何管理外围设备生成的中断到 CPU

第一个点(至少对于字符驱动程序)在以前的章节中已经涵盖了,而第二个点(及其相关内容)将是本章的主题。

在内核中,我们可以将 CPU(或执行某些代码的内部核心)视为运行在两个主要执行上下文中——中断上下文进程上下文。中断上下文非常容易理解;事实上,每当 CPU 执行中断处理程序时(即内核每次发生中断时执行的特殊代码),CPU 就处于这种上下文中。除此之外,中断可以由硬件或甚至软件生成;这就是为什么我们谈论硬件中断和软件中断(我们将在接下来的章节中更详细地了解软件中断),从而定义了硬件中断上下文软件中断上下文

另一方面,进程上下文是指 CPU(或其内部核心之一)在内核空间中执行进程的某些代码时(进程也在用户空间中执行,但我们这里不涉及),也就是说,当 CPU 执行进程调用的系统调用代码时。在这种情况下,很常见的是让出 CPU,然后暂停当前进程,因为外围设备的一些数据尚未准备好读取;例如;这可以通过要求调度程序接管 CPU,然后将其分配给另一个进程来完成。当这种情况发生时,我们通常说当前进程已进入睡眠状态,当数据新可用时,我们说进程已被唤醒,并且它会在先前中断的地方重新执行。

在本章中,我们将看到如何执行所有这些操作,设备驱动程序开发人员如何要求内核暂停当前的读取过程,因为外围设备尚未准备好提供请求,并且还将看到如何唤醒睡眠进程。我们还将看到如何管理对驱动程序方法的并发访问,以避免由于竞争条件导致的数据损坏,以及如何管理时间流以便在经过明确定义的时间后执行特定操作,以尊重外围设备可能需要的时间约束。

我们还将看看如何在字符驱动程序和用户空间之间交换数据,以及如何处理驱动程序应该能够管理的内核事件。第一个(也可能是最重要的)示例是如何管理中断,其次是如何推迟工作“稍后”,以及如何等待事件。我们可以使用以下方法来执行所有这些操作:

  • 实现中断处理程序

  • 推迟工作

  • 使用内核定时器管理时间

  • 等待事件

  • 执行原子操作

技术要求

有关本章的更多信息,您可以访问附录

本章中使用的代码和其他文件可以从 GitHub 下载:github.com/giometti/linux_device_driver_development_cookbook/tree/master/chapter_05

实现中断处理程序

在内核中,中断处理程序是与 CPU 中断线(或引脚)相关联的函数,当连接到该线的外围设备更改引脚状态时,Linux 会执行该函数;当这种情况发生时,会为 CPU 生成中断请求,并且被内核捕获,然后执行适当的处理程序。

在这个示例中,我们将看到如何安装一个中断处理程序,内核每次在一个明确定义的线上发生中断时都会执行该处理程序。

准备就绪

实现中断处理程序的最简单代码是linux/drivers/misc/dummy-irq.c中的代码。这是处理程序:

static int irq = -1;

static irqreturn_t dummy_interrupt(int irq, void *dev_id)
{
    static int count = 0;

    if (count == 0) {
        printk(KERN_INFO "dummy-irq: interrupt occurred on IRQ %d\n",
                irq);
        count++;
    }

    return IRQ_NONE;
}

以下是安装或删除它的代码:

static int __init dummy_irq_init(void)
{
    if (irq < 0) {
        printk(KERN_ERR "dummy-irq: no IRQ given. Use irq=N\n");
        return -EIO;
    }
    if (request_irq(irq, &dummy_interrupt, IRQF_SHARED, "dummy_irq", &irq)) {
        printk(KERN_ERR "dummy-irq: cannot register IRQ %d\n", irq);
        return -EIO;
    }
    printk(KERN_INFO "dummy-irq: registered for IRQ %d\n", irq);
    return 0;
}

static void __exit dummy_irq_exit(void)
{
    printk(KERN_INFO "dummy-irq unloaded\n");
    free_irq(irq, &irq);
}

这段代码非常简单,正如我们所看到的,它在dummy_irq_init()模块初始化函数中调用request_irq()函数,并在dummy_irq_exit()模块退出函数中调用free_irq()函数。然后,这两个函数分别要求内核将dummy_interrupt()中断处理程序连接到irq中断线,并在相反操作中将处理程序从中断线中分离。

这段代码简要地展示了如何安装中断处理程序;然而,它并没有展示设备驱动程序开发人员如何安装自己的处理程序;这就是为什么在下一节中,我们将使用一个真实的中断线的实际示例,使用通用输入输出线(GPIO)模拟。

为了实现对我们的第一个中断请求IRQ)处理程序的管理,我们可以使用一个普通的 GPIO 作为中断线;然而,在这样做之前,我们必须验证我们的 GPIO 线是否正确检测到高低输入电平。

为了管理 GPIO,我们将使用其 sysfs 接口,因此,首先,我们必须验证它是否当前对我们的内核启用,方法是检查/sys/class/gpio目录是否存在。如果不存在,我们将不得不通过使用内核配置菜单(make menuconfig)启用CONFIG_GPIO_SYSFS内核配置条目;可以通过转到设备驱动程序,然后 GPIO 支持,启用/sys/class/gpio/...(sysfs 接口)菜单条目来完成。

通过使用以下命令行,我们可以快速检查条目是否已启用:

$ rgrep CONFIG_GPIO_SYSFS .config
CONFIG_GPIO_SYSFS=y

否则,如果它没有被启用,我们将得到以下输出,然后我们必须启用它:

$ rgrep CONFIG_GPIO_SYSFS .config
# CONFIG_GPIO_SYSFS is not set

如果一切就绪,我们应该得到类似以下的内容:

# ls /sys/class/gpio/
export  gpiochip446  gpiochip476  unexport

gpiochip446gpiochip476目录代表了两个 ESPRESSObin 的 GPIO 控制器,正如我们在上一章中描述设备树时所看到的。(参见附录中的The Armada 3720部分第四章,使用设备树为特定外围设备配置 CPU 引脚部分)。exportunexport文件用于访问 GPIO 线。

为了完成我们的工作,我们需要访问映射到 ESPRESSObin 扩展#2 的引脚 12 的 MPP2_20 CPU 线;也就是说,在 ESPRESSObin 原理图上的连接器 P8(或 J18)。 (参见第一章中的技术要求部分,安装开发系统)。在 CPU 数据表中,我们发现 MPP2_20 线连接到第二个 pinctrl 控制器(在设备树中命名为南桥并映射为pinctrl_sb: pinctrl@18800)。要知道使用哪个正确的 gpiochip 设备,我们仍然可以使用 sysfs 如下:

# ls -l /sys/class/gpio/gpiochip4*
lrwxrwxrwx 1 root root 0 Mar 7 20:20 /sys/class/gpio/gpiochip446 ->
  ../../devices/platform/soc/soc:internal-regs@d0000000/d0018800.pinctrl/gpio/gpiochip446
lrwxrwxrwx 1 root root 0 Mar 7 20:20 /sys/class/gpio/gpiochip476 ->
  ../../devices/platform/soc/soc:internal-regs@d0000000/d0013800.pinctrl/gpio/gpiochip476

现在很明显我们必须使用gpiochip446。在那个目录中,我们会找到base文件,告诉我们第一个 GPIO 线的对应编号,由于我们使用的是第 20 条线,我们应该将base+20 GPIO 线导出如下:

# cat /sys/class/gpio/gpiochip446/base 
446
# echo 466 > /sys/class/gpio/export 

如果一切正常,现在在/sys/class/gpio目录中会出现一个新的gpio466条目,对应于我们刚刚导出的 GPIO 线:

# ls /sys/class/gpio/ 
export  gpio466  gpiochip446  gpiochip476  unexport

太好了!gpio466目录现在已经准备好使用了,通过查看其中的内容,我们得到以下文件:

# ls /sys/class/gpio/gpio466/
active_low device direction edge power subsystem uevent value

为了查看我们是否能够修改我们的 GPIO 线,我们可以简单地使用以下命令:

cat /sys/class/gpio/gpio466/value 
1

请注意,即使未连接,该线路也被设置为 1,因为该引脚通常配置为内部上拉,强制引脚状态为高电平。

这个输出告诉我们 GPIO 线 20 当前处于高电平,但是,如果我们将 P8 连接器的引脚 12 连接到同一连接器(P8/J8)的地线(引脚 1 或 2),GPIO 线应该转为低电平,前面的命令现在应该返回 0,如下所示:

# cat /sys/class/gpio/gpio466/value 
0

如果线路没有改变,您应该验证您是否在正确的引脚/连接器上工作。此外,您应该查看/sys/class/gpio/gpio466/direction文件,其中应该包含in字符串,如下所示:

# cat /sys/class/gpio/gpio466/direction

in

好了。现在我们准备生成我们的中断!

如何做...

通过以下步骤来看看如何做:

  1. 现在,让我们假设我们有一个专用的平台驱动程序名为irqtest,在 ESPRESSObin 设备树中定义如下:
    irqtest {
        compatible = "ldddc,irqtest";

        gpios = <&gpiosb 20 GPIO_ACTIVE_LOW>;
    };

请记住,ESPRESSObin 设备树文件是linux/arch/arm64/boot/dts/marvell/armada-3720-espressobin.dts

  1. 然后,我们必须像在上一章中那样向内核添加一个平台驱动程序,使用以下代码:
static const struct of_device_id irqtest_dt_ids[] = {
    { .compatible = "ldddc,irqtest", },
    { /* sentinel */ }
};
MODULE_DEVICE_TABLE(of, irqtest_dt_ids);

static struct platform_driver irqtest_driver = {
    .probe = irqtest_probe,
    .remove = irqtest_remove,
    .driver = {
        .name = "irqtest",
        .of_match_table = irqtest_dt_ids,
    },
};

module_platform_driver(irqtest_driver);

请注意,这里呈现的所有代码都可以通过在内核源代码的根目录中执行patch命令应用add_irqtest_module.patch补丁来从 GitHub 存储库获取,如下所示:

**$ patch -p1 < ../linux_device_driver_development_cookbook/chapter_5/add_irqtest_module.patch**

  1. 现在,我们知道一旦内核在设备树中检测到与ldddc,irqtest兼容的驱动程序,将执行以下irqtest_probe()探测函数。这个函数与前面的linux/drivers/misc/dummy-irq.c文件中的函数非常相似,即使有点更复杂。实际上,首先我们必须从设备树中读取中断信号来自哪个 GPIO 线,使用of_get_gpio()函数:
static int irqtest_probe(struct platform_device *pdev)
{
    struct device *dev = &pdev->dev;
    struct device_node *np = dev->of_node;
    int ret;

    /* Read gpios property (just the first entry) */
    ret = of_get_gpio(np, 0); 
    if (ret < 0) {
        dev_err(dev, "failed to get GPIO from device tree\n");
        return ret;
    }
    irqinfo.pin = ret;
    dev_info(dev, "got GPIO %u from DTS\n", irqinfo.pin);
  1. 然后,我们必须使用devm_gpio_request()函数向内核请求 GPIO 线:
    /* Now request the GPIO and set the line as an input */
    ret = devm_gpio_request(dev, irqinfo.pin, "irqtest");
    if (ret) {
        dev_err(dev, "failed to request GPIO %u\n", irqinfo.pin);
        return ret;
    }
    ret = gpio_direction_input(irqinfo.pin);
    if (ret) {
        dev_err(dev, "failed to set pin input direction\n");
        return -EINVAL;
    }

    /* Now ask to the kernel to convert GPIO line into an IRQ line */
    ret = gpio_to_irq(irqinfo.pin);
    if (ret < 0) {
        dev_err(dev, "failed to map GPIO to IRQ!\n");
        return -EINVAL;
    }
    irqinfo.irq = ret;
    dev_info(dev, "GPIO %u correspond to IRQ %d\n",
                irqinfo.pin, irqinfo.irq);
  1. 确定 GPIO 仅供我们使用后,我们必须将其设置为输入(中断是传入信号),使用gpio_direction_input()函数,然后我们必须使用gpio_to_irq()函数获取相应的中断线号(通常是不同的号码):
    ret = gpio_direction_input(irqinfo.pin);
    if (ret) {
        dev_err(dev, "failed to set pin input direction\n");
        return -EINVAL;
    }

    /* Now ask to the kernel to convert GPIO line into an IRQ line */
    ret = gpio_to_irq(irqinfo.pin);
    if (ret < 0) {
        dev_err(dev, "failed to map GPIO to IRQ!\n");
        return -EINVAL;
    }
    irqinfo.irq = ret;
    dev_info(dev, "GPIO %u correspond to IRQ %d\n",
                irqinfo.pin, irqinfo.irq);
  1. 之后,我们有了所有必要的信息,可以使用linux/include/linux/interrupt.h头文件中定义的request_irq()函数安装我们的中断处理程序,如下所示:
extern int __must_check
request_threaded_irq(unsigned int irq, irq_handler_t handler,
            irq_handler_t thread_fn,
            unsigned long flags, const char *name, void *dev);

static inline int __must_check
request_irq(unsigned int irq, irq_handler_t handler, unsigned long flags,
            const char *name, void *dev)
{
    return request_threaded_irq(irq, handler, NULL, flags, name, dev);
}

extern int __must_check
request_any_context_irq(unsigned int irq, irq_handler_t handler,
            unsigned long flags, const char *name, void *dev_id);
  1. 最后,handler参数指定要作为中断处理程序执行的函数,dev是一个指针,内核在执行时会原样传递给处理程序。在我们的示例中,中断处理程序定义如下:
static irqreturn_t irqtest_interrupt(int irq, void *dev_id)
{
    struct irqtest_data *info = dev_id;
    struct device *dev = info->dev;

    dev_info(dev, "interrupt occurred on IRQ %d\n", irq);

    return IRQ_HANDLED;
}

工作原理...

步骤 1中,节点声明了一个与驱动程序名为ldddc,irqtest兼容的设备,该设备需要使用gpiosb节点的 GPIO 线 20,如在 Armada 3270 设备树arch/arm64/boot/dts/marvell/armada-37xx.dtsi文件中定义的那样:

    pinctrl_sb: pinctrl@18800 {
        compatible = "marvell,armada3710-sb-pinctrl",
                 "syscon", "simple-mfd";
        reg = <0x18800 0x100>, <0x18C00 0x20>;
        /* MPP2[23:0] */
        gpiosb: gpio {
            #gpio-cells = <2>;
            gpio-ranges = <&pinctrl_sb 0 0 30>;
            gpio-controller;
            interrupt-controller;
            #interrupt-cells = <2>;
            interrupts =
            <GIC_SPI 160 IRQ_TYPE_LEVEL_HIGH>,
            <GIC_SPI 159 IRQ_TYPE_LEVEL_HIGH>,
            <GIC_SPI 158 IRQ_TYPE_LEVEL_HIGH>,
            <GIC_SPI 157 IRQ_TYPE_LEVEL_HIGH>,
            <GIC_SPI 156 IRQ_TYPE_LEVEL_HIGH>;
        };
   ...

在这里,我们确认gpiosb节点与 MPP2 线相关。

步骤 2中,我们只是在内核中声明驱动程序,而在步骤 3中,该函数从gpio属性获取 GPIO 信息,并且通过将第二个参数设置为0,我们只是请求第一个条目。返回值保存在模块的数据结构中,现在定义如下:

static struct irqtest_data {
    int irq;
    unsigned int pin;
    struct device *dev;
} irqinfo;

在步骤 4 中,实际上,devm_gpio_request()调用并不是严格需要的,因为我们在内核中,没有人可以阻止我们使用资源;但是,如果所有驱动程序都这样做,我们可以确保在有其他人持有资源时得到通知!

现在我们应该注意到devm_gpio_request()函数在模块的exit()函数irqtest_remove()中没有对应的函数。这是因为带有devm前缀的函数与能够在所有者设备从系统中移除时自动释放资源的托管设备相关。

在定义此函数的linux/drivers/gpio/devres.c文件中,我们看到以下注释,解释了此函数的工作原理:

/**

* devm_gpio_request - 为托管设备请求 GPIO

* @dev: 请求 GPIO 的设备

* @gpio: 要分配的 GPIO

* @label: 请求的 GPIO 的名称

*

* 除了额外的@dev 参数外,此函数还需要

* 使用相同的参数并执行相同的功能

* gpio_request()。使用此功能请求的 GPIO 将被

* 在驱动程序分离时自动释放。

*

* 如果使用此功能分配的 GPIO 需要被释放

* 另外,必须使用 devm_gpio_free()。

*/

这是高级资源管理,超出了本书的范围。但是,如果你感兴趣,互联网上有很多信息,以下是一个很好的文章起点:lwn.net/Articles/222860/

无论如何,devm_gpio_request()函数的正常对应函数是gpio_request()gpio_free()函数。

在第 5 步中,请注意,GPIO 线号几乎永远不对应中断线号;这就是为什么我们需要调用gpio_to_irq()函数以获取与我们的 GPIO 线相关的正确 IRQ 线的原因。

在第 6 步中,我们可以看到request_irq()函数是request_threaded_irq()函数的一个特例,它告诉我们中断处理程序可以在中断上下文中运行,或者在进程上下文中运行的内核线程中运行。

目前,我们仍然不知道什么是内核线程(它们将在第六章中解释,杂项内核内部),但应该很容易理解它们类似于在内核空间中执行的线程(或进程)。

还可以使用request_any_context_irq()函数来委托内核自动请求正常的中断处理程序或线程中的中断处理程序,具体取决于 IRQ 线的特性。

这是中断处理程序的一个非常高级的用法,当我们需要管理外围设备(如 I2C 或 SPI 设备)时,我们需要挂起中断处理程序才能从外围寄存器中读取或写入数据。

除了这些方面,所有的request_irq*()函数都需要几个参数。首先是irq线,然后是一个符号name,描述我们可以在/proc/interrupts文件中找到的中断线,然后我们可以使用flags参数来指定一些特殊设置,如下所示(请参阅linux/include/linux/interrupt.h文件以获取完整列表):

/*
 * These correspond to the IORESOURCE_IRQ_* defines in
 * linux/ioport.h to select the interrupt line behaviour. When
 * requesting an interrupt without specifying a IRQF_TRIGGER, the
 * setting should be assumed to be "as already configured", which
 * may be as per machine or firmware initialisation.
 */
#define IRQF_TRIGGER_NONE 0x00000000
#define IRQF_TRIGGER_RISING 0x00000001
#define IRQF_TRIGGER_FALLING 0x00000002
#define IRQF_TRIGGER_HIGH 0x00000004
#define IRQF_TRIGGER_LOW 0x00000008
...
/*
 * IRQF_SHARED - allow sharing the irq among several devices
 * IRQF_ONESHOT - Interrupt is not reenabled after the hardirq handler finished.
 * Used by threaded interrupts which need to keep the
 * irq line disabled until the threaded handler has been run.
 * IRQF_NO_SUSPEND - Do not disable this IRQ during suspend. Does not guarantee
 * that this interrupt will wake the system from a suspended
 * state. See Documentation/power/suspend-and-interrupts.txt
 */

当 IRQ 线与多个外围设备共享时,应该使用IRQF_SHARED标志。(现在它几乎没有用,但在过去,它非常有用,特别是在 x86 机器上。)IRQF_ONESHOT标志被系统用来确保即使线程中断处理程序也可以在其自己的 IRQ 线被禁用时运行。IRQF_NO_SUSPEND标志可用于允许我们的外围设备从挂起状态唤醒系统,通过发送适当的中断请求。(有关更多详细信息,请参阅linux/Documentation/power/suspend-and-interrupts.txt文件。)

然后,IRQF_TRIGGER_*标志可用于指定我们外围设备的 IRQ 触发模式,即中断是否必须在高电平或低电平上产生,或在上升或下降转换期间产生。

这些最后的标志组应该仔细检查设备树 pinctrl 设置;否则,我们可能会看到一些意外的行为。

在第 7 步中,由于在request_irq()函数中我们将dev参数设置为struct irqtest_data模块的指针,当irqtest_interrupt()中断处理程序执行时,它将在dev_id参数中找到我们提供给request_irq()的相同指针。通过使用这个技巧,我们可以得到从探测函数中得到的dev值,并且可以安全地将其重新用作dev_info()函数的参数,就像之前一样。

在我们的示例中,中断处理程序几乎什么都没做,只是显示一条消息。但是,通常在中断处理程序中,我们必须确认外围设备,从中读取或写入数据,然后唤醒所有正在等待外围设备活动的睡眠进程。无论如何,在最后,处理程序应该返回linux/include/linux/irqreturn.h文件中列出的一个值:

/**
 * enum irqreturn
 * @IRQ_NONE interrupt was not from this device or was not handled
 * @IRQ_HANDLED interrupt was handled by this device
 * @IRQ_WAKE_THREAD handler requests to wake the handler thread
 */

IRQ_NONE值在我们正在处理共享中断的情况下非常有用,以通知系统当前的 IRQ 不是针对我们的,并且必须传递给下一个处理程序,而IRQ_WAKE_THREAD应该在使用线程化 IRQ 处理程序的情况下使用。当然,必须使用IRQ_HANDLED来向系统报告 IRQ 已被处理。

还有更多...

如果您想要检查这是如何工作的,我们可以通过测试我们的示例来做到这一点。我们必须编译它,然后将内核与我们编译为内置的代码一起重新安装,因此让我们使用通常的make menuconfig命令并启用我们的测试代码,或者只需使用make oldconfig,在系统要求选择时回答y,如下所示:

Simple IRQ test (IRQTEST_CODE) [N/m/y/?] (NEW)

之后,我们只需重新编译和重新安装内核,然后重新启动 ESPRESSObin。如果在引导序列期间一切正常,我们应该看到内核消息如下:

irqtest irqtest: got GPIO 466 from DTS
irqtest irqtest: GPIO 466 correspond to IRQ 40
irqtest irqtest: interrupt handler for IRQ 40 is now ready!

现在,MPP2_20 线已被内核占用,并转换为编号 40 的中断线。为了验证它,我们可以查看/proc/interrupts文件,其中包含内核中所有已注册的中断线。之前,在中断处理程序注册期间,我们在request_irq()函数中使用了irqtest标签,因此我们必须使用grep在文件中搜索它,如下所示:

# grep irqtest /proc/interrupts 
 40:     0     0     GPIO2   20   Edge   irqtest

好的。中断线 40 已分配给我们的模块,我们注意到这个 IRQ 线对应于 GPIO2 组的 GPIO 线 20(即 MPP2_20 线)。如果我们查看/proc/interrupts文件的开头,我们应该得到以下输出:

# head -4 /proc/interrupts 
           CPU0   CPU1 
  1:          0      0   GICv3   25   Level   vgic
  3:       5944  20941   GICv3   30   Level   arch_timer
  4:          0      0   GICv3   27   Level   kvm guest timer
...

第一个数字是中断线;第二个和第三个数字显示 CPU0 和 CPU1 分别服务了多少次中断,因此我们可以使用这些信息来验证哪个 CPU 服务了我们的中断。

好的。现在我们准备好了。只需将引脚 12 连接到 P8 扩展连接器的引脚 1;至少应该生成一个中断,并且内核消息中应该出现以下消息:

irqtest irqtest: interrupt occurred on IRQ 40

请注意,由于在短路操作期间,电信号可能会产生多次振荡,因此您可能会收到多条消息。

最后,让我们看看如果我们尝试使用 sysfs 接口导出编号 466 的 GPIO 线会发生什么,就像我们之前做的那样:

# echo 466 > /sys/class/gpio/export 
-bash: echo: write error: Device or resource busy

现在,由于内核在我们使用devm_gpio_request()函数时请求了这样一个 GPIO,我们正确地得到了一个忙碌错误。

另请参阅

推迟工作

中断是由外围设备生成的事件,但正如前面所说的,它们并不是内核能够处理的唯一事件。事实上,还存在软件中断,类似于硬件中断,但是由软件生成。在本书中,我们将看到两个此类软件中断的示例;它们都可以用于安全地推迟将来的工作。我们还将看看设备驱动程序开发人员可以使用的一个有用机制,以捕获特殊的内核事件并根据情况执行操作(例如,当网络设备启用时,或系统正在重新启动等)。

在本教程中,我们将看到如何在内核中发生特定事件时推迟工作。

准备就绪

由于 tasklet 和 workqueue 是用来推迟工作的,它们的主要用途是在中断处理程序中,我们只需确认中断请求(通常命名为 IRQ),然后调用 tasklet/workqueue 完成工作。

但是,不要忘记这只是 tasklet 和工作队列的几种可能用法之一,当然,即使没有中断,也可以使用它们。

如何做...

在本节中,我们将使用针对先前的 irqtest.c 示例的补丁,展示关于 tasklet 和工作队列的简单示例。

在接下来的章节中,每当需要时,我们将展示这些机制的更复杂用法,但目前我们只关注理解它们的基本用法。

Tasklets

让我们按照以下步骤来做:

  1. 需要以下修改来将自定义 tasklet 调用添加到我们的 irqtest_interrupt() 中断处理程序中:
--- a/drivers/misc/irqtest.c
+++ b/drivers/misc/irqtest.c
@@ -26,9 +26,19 @@ static struct irqtest_data {
 } irqinfo;

 /*
- * The interrupt handler
+ * The interrupt handlers
  */

+static void irqtest_tasklet_handler(unsigned long flag)
+{
+     struct irqtest_data *info = (struct irqtest_data *) flag;
+     struct device *dev = info->dev;
+
+     dev_info(dev, "tasklet executed after IRQ %d", info->irq);
+}
+DECLARE_TASKLET(irqtest_tasklet, irqtest_tasklet_handler,
+                   (unsigned long) &irqinfo);
+
 static irqreturn_t irqtest_interrupt(int irq, void *dev_id)
 {
      struct irqtest_data *info = dev_id;
@@ -36,6 +46,8 @@ static irqreturn_t irqtest_interrupt(int irq, void *dev_id)

      dev_info(dev, "interrupt occurred on IRQ %d\n", irq);

+     tasklet_schedule(&irqtest_tasklet);
+
      return IRQ_HANDLED;
 }

@@ -98,6 +110,7 @@ static int irqtest_remove(struct platform_device *pdev)
 {
      struct device *dev = &pdev->dev;

+     tasklet_kill(&irqtest_tasklet);
      free_irq(irqinfo.irq, &irqinfo);
      dev_info(dev, "IRQ %d is now unmanaged!\n", irqinfo.irq);

前面的补丁可以在 GitHub 资源中的 add_tasklet_to_irqtest_module.patch 文件中找到,并且可以像往常一样应用。

patch -p1 < add_tasklet_to_irqtest_module.patch 命令。

  1. 一旦 tasklet 被定义,就可以使用 tasklet_schedule() 函数来调用它,就像之前展示的那样。要停止它,我们可以使用 tasklet_kill() 函数,在我们的示例中用于 irqtest_remove() 函数来在从内核中卸载模块之前停止 tasklet。实际上,我们必须确保在卸载模块之前,我们的驱动程序之前分配和/或启用的每个资源都已被禁用和/或释放,否则可能会发生内存损坏。

请注意,DECLARE_TASKLET() 的编译时使用并不是声明 tasklet 的唯一方式。实际上,以下是另一种方式:

--- a/drivers/misc/irqtest.c
+++ b/drivers/misc/irqtest.c
@@ -23,12 +23,21 @@ static struct irqtest_data {
      int irq;
      unsigned int pin;
      struct device *dev;
+     struct tasklet_struct task;
 } irqinfo;

 /*
- * The interrupt handler
+ * The interrupt handlers
  */

+static void irqtest_tasklet_handler(unsigned long flag)
+{
+     struct irqtest_data *info = (struct irqtest_data *) flag;
+     struct device *dev = info->dev;
+
+     dev_info(dev, "tasklet executed after IRQ %d", info->irq);
+}
+
 static irqreturn_t irqtest_interrupt(int irq, void *dev_id)
 {
      struct irqtest_data *info = dev_id;
@@ -36,6 +45,8 @@ static irqreturn_t irqtest_interrupt(int irq, void *dev_id)

      dev_info(dev, "interrupt occurred on IRQ %d\n", irq);

+     tasklet_schedule(&info->task);
+
      return IRQ_HANDLED;
 }

@@ -80,6 +91,10 @@ static int irqtest_probe(struct platform_device *pdev)
      dev_info(dev, "GPIO %u correspond to IRQ %d\n",
                                irqinfo.pin, irqinfo.irq);

然后,我们创建我们的 tasklet 如下:

+     /* Create our tasklet */
+     tasklet_init(&irqinfo.task, irqtest_tasklet_handler,
+                               (unsigned long) &irqinfo);
+
      /* Request IRQ line and setup corresponding handler */
      irqinfo.dev = dev;
      ret = request_irq(irqinfo.irq, irqtest_interrupt, 0,
@@ -98,6 +113,7 @@ static int irqtest_remove(struct platform_device *pdev)
 {
      struct device *dev = &pdev->dev;

+     tasklet_kill(&irqinfo.task);
      free_irq(irqinfo.irq, &irqinfo);
      dev_info(dev, "IRQ %d is now unmanaged!\n", irqinfo.irq);

前面的补丁可以在 GitHub 资源中的 add_tasklet_2_to_irqtest_module.patch 文件中找到,并且可以像往常一样应用。

patch -p1 < add_tasklet_2_to_irqtest_module.patch 命令。

当我们必须在设备结构中嵌入 tasklet 并动态生成它时,这种第二种形式是有用的。

工作队列

现在让我们来看看工作队列。在下面的示例中,我们添加了一个自定义工作队列,由 irqtest_wq 指针引用,并命名为 irqtest,它执行两种不同的工作,由 workdwork 结构描述:前者是正常工作,而后者代表延迟工作,即在经过一段时间延迟后执行的工作。

  1. 首先,我们必须添加我们的数据结构:
a/drivers/misc/irqtest.c
+++ b/drivers/misc/irqtest.c
@@ -14,6 +14,7 @@
 #include <linux/gpio.h>
 #include <linux/irq.h>
 #include <linux/interrupt.h>
+#include <linux/workqueue.h>

 /*
  * Module data
@@ -23,12 +24,37 @@ static struct irqtest_data {
        int irq;
      unsigned int pin;
      struct device *dev;
+     struct work_struct work;
+     struct delayed_work dwork;
 } irqinfo;

+static struct workqueue_struct *irqtest_wq;
...

所有这些修改都可以在 GitHub 资源中的 add_workqueue_to_irqtest_module.patch 文件中找到,并且可以像往常一样应用。

patch -p1 < add_workqueue_to_irqtest_module.patch 命令。

  1. 然后,我们必须创建工作队列并使其工作。对于工作队列的创建,我们可以使用 create_singlethread_workqueue() 函数,而两个工作可以通过使用 INIT_WORK()INIT_DELAYED_WORK() 进行初始化,如下所示:
@@ -80,24 +108,40 @@ static int irqtest_probe(struct platform_device *pdev)
      dev_info(dev, "GPIO %u correspond to IRQ %d\n",
                                irqinfo.pin, irqinfo.irq);

+     /* Create our work queue and init works */
+     irqtest_wq = create_singlethread_workqueue("irqtest");
+     if (!irqtest_wq) {
+         dev_err(dev, "failed to create work queue!\n");
+         return -EINVAL;
+     }
+     INIT_WORK(&irqinfo.work, irqtest_work_handler);
+     INIT_DELAYED_WORK(&irqinfo.dwork, irqtest_dwork_handler);
+
      /* Request IRQ line and setup corresponding handler */
      irqinfo.dev = dev;
      ret = request_irq(irqinfo.irq, irqtest_interrupt, 0,
                                "irqtest", &irqinfo);
      if (ret) {
          dev_err(dev, "cannot register IRQ %d\n", irqinfo.irq);
-         return -EIO;
+         goto flush_wq;
      }
      dev_info(dev, "interrupt handler for IRQ %d is now ready!\n",
                                irqinfo.irq);

      return 0;
+
+flush_wq:
+     flush_workqueue(irqtest_wq);
+     return -EIO;
 }

要创建工作队列,我们也可以使用 create_workqueue() 函数;然而,这会创建一个在系统上每个处理器都有专用线程的工作队列。在许多情况下,所有这些线程都是多余的,使用 create_singlethread_workqueue() 获得的单个工作线程就足够了。

请注意,内核文档文件(linux/Documentation/core-api/workqueue.rst)中提供的并发管理工作队列 API 表明,create_*workqueue() 函数已被弃用并计划移除。然而,它们似乎仍然广泛用于内核源代码中。

  1. 接下来是处理程序体,表示正常工作队列和延迟工作队列的有效工作负载,如下所示:
+static void irqtest_dwork_handler(struct work_struct *ptr)
+{
+     struct irqtest_data *info = container_of(ptr, struct irqtest_data,
+                                                   dwork.work);
+     struct device *dev = info->dev;
+
+     dev_info(dev, "delayed work executed after work");
+}
+
+static void irqtest_work_handler(struct work_struct *ptr)
+{
+     struct irqtest_data *info = container_of(ptr, struct irqtest_data,
+                                                   work);
+     struct device *dev = info->dev;
+
+     dev_info(dev, "work executed after IRQ %d", info->irq);
+
+     /* Schedule the delayed work after 2 seconds */
+     queue_delayed_work(irqtest_wq, &info->dwork, 2*HZ);
+}

请注意,为了指定两秒的延迟,我们使用了2*HZ代码,其中HZ是一个定义(有关HZ的更多信息,请参见下一节),表示需要多少个 jiffies 来组成一秒。因此,为了延迟两秒,我们必须将HZ乘以二。

  1. 中断处理程序现在只使用以下queue_work()函数来在返回之前执行第一个工作队列:
@@ -36,6 +62,8 @@ static irqreturn_t irqtest_interrupt(int irq, void *dev_id)

      dev_info(dev, "interrupt occurred on IRQ %d\n", irq);

+     queue_work(irqtest_wq, &info->work);
+
      return IRQ_HANDLED;
 }

因此,当irqtest_interrupt()结束时,系统会调用irqtest_work_handler(),然后调用irqtest_dwork_handler(),使用queue_delayed_work()来延迟两秒。

  1. 最后,对于任务队列,在退出模块之前,我们必须使用cancel_work_sync()取消所有工作和工作队列(如果已创建),对于延迟工作,使用cancel_delayed_work_sync(),以及(在我们的情况下)使用flush_workqueue()来停止irqtest工作队列:
 static int irqtest_remove(struct platform_device *pdev)
 {
      struct device *dev = &pdev->dev;

+     cancel_work_sync(&irqinfo.work);
+     cancel_delayed_work_sync(&irqinfo.dwork);
+     flush_workqueue(irqtest_wq);
      free_irq(irqinfo.irq, &irqinfo);
      dev_info(dev, "IRQ %d is now unmanaged!\n", irqinfo.irq);

还有更多...

我们可以通过测试示例来检查它的工作原理。因此,我们必须应用所需的补丁,然后重新编译内核,重新安装并重新启动 ESPRESSObin。

任务队列

要测试任务队列,我们可以像以前一样,将引脚 12 连接到扩展连接器 P8 的引脚 1。以下是我们应该收到的内核消息:

irqtest irqtest: interrupt occurred on IRQ 40
irqtest irqtest: tasklet executed after IRQ 40

如预期的那样,会生成一个中断,然后由硬件irqtest_interrupt()中断处理程序来管理,然后执行irqtest_tasklet_handler()任务处理程序。

工作队列

要测试工作队列,我们必须短接我们熟悉的引脚,然后应该有以下输出:

[ 33.113008] irqtest irqtest: interrupt occurred on IRQ 40
[ 33.115731] irqtest irqtest: work executed after IRQ 40
...
[ 33.514268] irqtest irqtest: interrupt occurred on IRQ 40
[ 33.516990] irqtest irqtest: work executed after IRQ 40
[ 33.533121] irqtest irqtest: interrupt occurred on IRQ 40
[ 33.535846] irqtest irqtest: work executed after IRQ 40
[ 35.138114] irqtest irqtest: delayed work executed after work

请注意,这次我没有删除内核消息的第一部分,以便查看时间,并更好地评估正常工作和延迟工作之间的延迟。

正如我们所看到的,一旦连接 ESPRESSObin 引脚,我们会有几个中断,然后是工作,但延迟的工作只执行一次。这是因为,即使安排了多次,只有第一次调用才会生效,因此我们可以看到延迟的工作最终在第一次schedule_work()调用后的 2.025106 秒后执行。这也意味着它实际上比所需和预期的两秒晚了 25.106 毫秒。这种明显的异常是由于当您要求内核安排一些工作在将来的某个时间点执行时,内核肯定会在未来的所需时间点安排您的工作,但它不会保证您会在那个时间点执行。它只会保证这样的工作不会在请求的截止日期之前执行。这种额外的随机延迟的长度取决于当时系统的工作负载水平。

另请参阅

使用内核定时器管理时间

在设备驱动程序开发过程中,可能需要在特定时刻执行多次重复操作,或者需要延迟一些代码的执行。在这些情况下,内核定时器可以帮助设备驱动程序开发人员。

在本教程中,我们将看到如何使用内核定时器在明确定义的时间间隔内执行重复的工作,或者在明确定的时间间隔之后延迟工作。

准备工作

对于内核定时器的一个简单示例,我们仍然可以使用一个内核模块,在模块的初始化函数中定义一个内核定时器。

在 GitHub 资源的chapter_05/timer目录中,有两个关于内核定时器ktimer)和高分辨率定时器hrtimer)的简单示例,在接下来的章节中,我们将详细解释它们,首先从新的高分辨率实现开始,这应该是新驱动程序中首选的。还介绍了旧的 API 以完整图片。

如何做...

hires_timer.c文件的以下主要部分包含了有关高分辨率内核定时器的简单示例。

  1. 让我们从文件的末尾开始,使用模块init()函数:
static int __init hires_timer_init(void)
{
    /* Set up hires timer delay */

    pr_info("delay is set to %dns\n", delay_ns);

    /* Setup and start the hires timer */
    hrtimer_init(&hires_tinfo.timer, CLOCK_MONOTONIC,
                HRTIMER_MODE_REL | HRTIMER_MODE_SOFT);
    hires_tinfo.timer.function = hires_timer_handler;
    hrtimer_start(&hires_tinfo.timer, ns_to_ktime(delay_ns),
                HRTIMER_MODE_REL | HRTIMER_MODE_SOFT);

    pr_info("hires timer module loaded\n");
    return 0;
}

让我们看看模块exit()函数的位置:

static void __exit hires_timer_exit(void)
{
    hrtimer_cancel(&hires_tinfo.timer);

    pr_info("hires timer module unloaded\n");
}

module_init(hires_timer_init);
module_exit(hires_timer_exit);

正如我们在模块hires_timer_init()初始化函数中所看到的,我们读取delay_ns参数,并且使用hrtimer_init()函数,首先通过指定一些特性来初始化定时器:

/* Initialize timers: */
extern void hrtimer_init(struct hrtimer *timer, clockid_t which_clock,
                         enum hrtimer_mode mode);

通过使用which_clock参数,我们要求内核使用特定的时钟。在我们的示例中,我们使用了CLOCK_MONOTONIC,这对于可靠的时间戳和准确测量短时间间隔非常有用(它从系统启动时间开始,但在挂起期间停止),但我们也可以使用其他值(请参阅linux/include/uapi/linux/time.h头文件以获取完整列表),例如:

    • CLOCK_BOOTTIME:这个时钟类似于CLOCK_MONOTONIC,但当系统进入挂起模式时不会停止。这对于需要与挂起操作同步的关键到期时间非常有用。
  • CLOCK_REALTIME:这个时钟使用相对于 1970 年开始的 UNIX 纪元时间,使用协调世界时UTC),就像gettimeofday()在用户空间中一样。这用于所有需要在重启后持续存在的时间戳,因为它可能会由于闰秒更新,网络时间协议(NTP)调整以及来自用户空间的settimeofday()操作而向后跳跃。但是,这个时钟在设备驱动程序中很少使用。

  • CLOCK_MONOTONIC_RAW:类似于CLOCK_MONOTONIC,但以硬件时钟源的相同速率运行,不会对时钟漂移进行调整(例如 NTP)。这在设备驱动程序中也很少需要。

  1. 在定时器初始化后,我们必须通过使用function指针来设置回调或处理函数,如下所示,我们已将timer.function设置为hires_timer_handler
hires_tinfo.timer.function = hires_timer_handler;

这一次,hires_tinfo模块数据结构定义如下:

static struct hires_timer_data {
    struct hrtimer timer;
    unsigned int data;
} hires_tinfo;
  1. 定时器初始化后,我们可以通过调用hrtimer_start()来启动它,在这里我们只需使用ns_to_ktime()这样的函数设置到期时间,以防我们有一个时间间隔,或者使用ktime_set(),以防我们有秒/纳秒值。

请参阅linux/include/linux/ktime.h头文件,了解更多ktime*()函数。

如果我们查看linux/include/linux/hrtimer.h文件,我们会发现启动高分辨率定时器的主要函数是hrtimer_start_range_ns(),而hrtimer_start()是该函数的一个特例,如下所示:

/* Basic timer operations: */
extern void hrtimer_start_range_ns(struct hrtimer *timer, ktime_t tim,
                           u64 range_ns, const enum hrtimer_mode mode);

/**
 * hrtimer_start - (re)start an hrtimer
 * @timer: the timer to be added
 * @tim: expiry time
 * @mode: timer mode: absolute (HRTIMER_MODE_ABS) or
 * relative (HRTIMER_MODE_REL), and pinned (HRTIMER_MODE_PINNED);
 * softirq based mode is considered for debug purpose only!
 */
static inline void hrtimer_start(struct hrtimer *timer, ktime_t tim,
                                 const enum hrtimer_mode mode)
{
    hrtimer_start_range_ns(timer, tim, 0, mode);
}

我们还发现HRTIMER_MODE_SOFT模式除了用于调试目的外,不应该使用。

通过使用hrtimer_start_range_ns()函数,我们允许range_ns时间差,这使得内核可以自由地安排实际的唤醒时间,以便既节能又性能友好。内核对到期时间加上时间差提供了正常的尽力而为的行为,但可能决定提前触发定时器,但不会早于tim到期时间。

  1. hires_timer.c文件中的hires_timer_handler()函数是回调函数的一个示例:
static enum hrtimer_restart hires_timer_handler(struct hrtimer *ptr)
{
    struct hires_timer_data *info = container_of(ptr,
                    struct hires_timer_data, timer);

    pr_info("kernel timer expired at %ld (data=%d)\n",
                jiffies, info->data++);

    /* Now forward the expiration time and ask to be rescheduled */
    hrtimer_forward_now(&info->timer, ns_to_ktime(delay_ns));
    return HRTIMER_RESTART;
}

通过使用container_of()操作符,我们可以获取指向我们的数据结构的指针(在示例中定义为struct hires_timer_data),然后,在完成工作后,我们调用hrtimer_forward_now()来设置新的到期时间,并通过返回HRTIMER_RESTART值,要求内核重新启动定时器。对于一次性定时器,我们可以返回HRTIMER_NORESTART

  1. 在模块退出时,在hires_timer_exit()函数中,我们必须使用hrtimer_cancel()函数等待定时器停止。等待定时器停止是非常重要的,因为定时器是异步事件,可能会发生我们在定时器回调执行时移除struct hires_timer_data模块释放结构,这可能导致严重的内存损坏!

请注意,同步是作为一个睡眠(或挂起)进程实现的,这意味着当我们处于中断上下文(硬或软)时,不能调用hrtimer_cancel()函数。然而,在这些情况下,我们可以使用hrtimer_try_to_cancel(),它只是在定时器正确停止(或根本不活动)时返回一个非负值。

它是如何工作的...

为了看看它是如何工作的,我们通过简单地编译代码然后将代码移动到我们的 ESPRESSObin 上来测试我们的代码。当一切就绪时,我们只需要将模块加载到内核中,如下所示:

# insmod hires_timer.ko

然后,在内核消息中,我们应该得到类似以下的内容:

[ 528.902156] hires_timer:hires_timer_init: delay is set to 1000000000ns
[ 528.911593] hires_timer:hires_timer_init: hires timer module loaded

步骤 123中设置了定时器,我们知道它已经以一秒的延迟启动。

当定时器到期时,由于步骤 4,我们执行内核定时器的处理程序:


[ 529.911604] hires_timer:hires_timer_handler: kernel timer expired at 4295024749 (data=0)
[ 530.911602] hires_timer:hires_timer_handler: kernel timer expired at 4295024999 (data=1)
[ 531.911602] hires_timer:hires_timer_handler: kernel timer expired at 4295025249 (data=2)
[ 532.911602] hires_timer:hires_timer_handler: kernel timer expired at 4295025499 (data=3)
...

我留下了时间,这样你就能了解内核定时器的精度。

正如我们所看到的,到期时间非常准确(几微秒)。

现在,由于步骤 5,如果我们移除模块,定时器会停止,如下所示:

hires_timer:hires_timer_exit: hires timer module unloaded

还有更多...

为了完善你的理解,看一下传统内核定时器 API 可能会很有趣。

传统内核定时器

ktimer.c文件包含了传统内核定时器的一个简单示例。和往常一样,让我们从文件末尾开始,那里是模块init()exit()函数所在的地方:

static int __init ktimer_init(void)
{
    /* Save kernel timer delay */
    ktinfo.delay_jiffies = msecs_to_jiffies(delay_ms);
    pr_info("delay is set to %dms (%ld jiffies)\n",
                delay_ms, ktinfo.delay_jiffies);

    /* Setup and start the kernel timer */
    timer_setup(&ktinfo.timer, ktimer_handler, 0); 
    mod_timer(&ktinfo.timer, jiffies + ktinfo.delay_jiffies);

    pr_info("kernel timer module loaded\n");
    return 0;
}

static void __exit ktimer_exit(void)
{
    del_timer_sync(&ktinfo.timer);

    pr_info("kernel timer module unloaded\n");
}

具有处理程序函数的模块数据结构如下:

static struct ktimer_data {
    struct timer_list timer;
    long delay_jiffies;
    unsigned int data;
} ktinfo;

...

static void ktimer_handler(struct timer_list *t)
{
    struct ktimer_data *info = from_timer(info, t, timer);

    pr_info("kernel timer expired at %ld (data=%d)\n",
                jiffies, info->data++);

    /* Reschedule kernel timer */
    mod_timer(&info->timer, jiffies + info->delay_jiffies);
}

正如我们所看到的,这个实现与高分辨率定时器非常相似。实际上,在ktimer_init()初始化函数中,我们读取模块的delay_ms参数,并通过使用msecs_to_jiffies()将其值转换为 jiffies,这是内核定时器的计量单位。(请记住,传统内核定时器的时间限制设置为一个 jiffy。)

然后,我们使用timer_setup()mod_timer()函数分别设置内核定时器并启动它。timer_setup()函数接受三个参数:

/**
 * timer_setup - prepare a timer for first use
 * @timer: the timer in question
 * @callback: the function to call when timer expires
 * @flags: any TIMER_* flags
 *
 * Regular timer initialization should use either DEFINE_TIMER() above,
 * or timer_setup(). For timers on the stack, timer_setup_on_stack() must
 * be used and must be balanced with a call to destroy_timer_on_stack().
 */
#define timer_setup(timer, callback, flags) \
    __init_timer((timer), (callback), (flags))

struct timer_list类型的变量timer,一个callback(或处理程序)函数,以及一些标志(在flags变量中)可以用来指定我们内核定时器的一些特殊特性。为了让你了解可用标志及其含义,以下是linux/include/linux/timer.h文件中的一些标志定义:

/*
 * A deferrable timer will work normally when the system is busy, but
 * will not cause a CPU to come out of idle just to service it; instead,
 * the timer will be serviced when the CPU eventually wakes up with a
 * subsequent non-deferrable timer.
 *
 * An irqsafe timer is executed with IRQ disabled and it's safe to wait for
 * the completion of the running instance from IRQ handlers, for example,
 * by calling del_timer_sync().
 *
 * Note: The irq disabled callback execution is a special case for
 * workqueue locking issues. It's not meant for executing random crap
 * with interrupts disabled. Abuse is monitored!
 */
#define TIMER_CPUMASK     0x0003FFFF
#define TIMER_MIGRATING   0x00040000
#define TIMER_BASEMASK    (TIMER_CPUMASK | TIMER_MIGRATING)
#define TIMER_DEFERRABLE  0x00080000
#define TIMER_PINNED      0x00100000
#define TIMER_IRQSAFE     0x00200000

关于回调函数,让我们看一下我们示例中的ktimer_handler()

static void ktimer_handler(struct timer_list *t)
{
    struct ktimer_data *info = from_timer(info, t, timer);

    pr_info("kernel timer expired at %ld (data=%d)\n",
                jiffies, info->data++);

    /* Reschedule kernel timer */
    mod_timer(&info->timer, jiffies + info->delay_jiffies);
}

通过使用from_timer(),我们可以获取到我们数据结构的指针(在示例中定义为struct ktimer_data),然后,在完成工作后,我们可以再次调用mod_timer()来重新安排新的定时器执行;否则,一切都会停止。

请注意,from_timer()函数仍然使用container_of()来完成其工作,如linux/include/linux/timer.h文件中的以下定义所示:

#define from_timer(var, callback_timer, timer_fieldname) \

container_of(callback_timer, typeof(*var), timer_fieldname).

在模块退出时,在ktimer_exit()函数中,我们必须使用del_timer_sync()函数等待定时器停止。我们之前关于等待退出的陈述仍然有效,因此,要从中断上下文中停止内核定时器,我们可以使用try_to_del_timer_sync(),它只是在定时器正确停止时返回一个非负值。

为了测试我们的代码,我们只需要编译然后将其移动到我们的 ESPRESSObin,然后我们可以按照以下方式将模块加载到内核中:

# insmod ktimer.ko

然后,在内核消息中,我们应该得到类似这样的内容:

[ 122.174020] ktimer:ktimer_init: delay is set to 1000ms (250 jiffies)
[ 122.180519] ktimer:ktimer_init: kernel timer module loaded
[ 123.206222] ktimer:ktimer_handler: kernel timer expired at 4294923072 (data=0)
[ 124.230222] ktimer:ktimer_handler: kernel timer expired at 4294923328 (data=1)
[ 125.254218] ktimer:ktimer_handler: kernel timer expired at 4294923584 (data=2)

同样,我留下了时间,让你了解内核定时器的精度。

在这里,我们发现 1000 毫秒等于 250 个 jiffies;也就是说,1 个 jiffy 等于 4 毫秒,我们还可以看到定时器的处理程序大约每秒执行一次。(与 4 毫秒非常接近的抖动,即 1 个 jiffy。)

当我们移除模块时,定时器会停止,如下所示:

ktimer:ktimer_exit: kernel timer module unloaded

另请参阅

  • 有关高分辨率内核定时器的有趣文档在内核源代码中的linux/Documentation/timers/hrtimers.txt

等待事件

在前面的章节中,我们看到如何直接在处理程序中管理中断,或者通过使用任务队列、工作队列等来推迟中断活动。此外,我们还看到如何执行周期性操作或如何将操作延迟到未来;然而,设备驱动程序可能需要等待特定事件,例如等待某些数据、等待缓冲区变满,或者等待变量达到所需值。

请不要混淆之前看到的由通知程序管理的与特定驱动程序相关的内核相关事件,与通用事件。

当没有数据可以从外围设备中读取时,读取进程必须进入睡眠状态,然后在“数据准备就绪”事件到达时被唤醒。另一个例子是当我们启动一个复杂的作业并希望在完成时得到信号;在这种情况下,我们启动作业,然后进入睡眠状态,直到“作业完成”事件到达。所有这些任务都可以通过使用等待队列(waitqueues)或完成(仍然由等待队列实现)来完成。

等待队列(或完成)只是一个队列,其中一个或多个进程等待与队列相关的事件;当事件到达时,一个、多个或甚至所有睡眠进程都会被唤醒,以便让某人来管理它。在这个示例中,我们将学习如何使用等待队列。

准备工作

为了准备一个关于等待队列的简单示例,我们可以再次使用一个内核模块,在该模块的初始化函数中定义一个内核定时器,该定时器的任务是生成我们的事件,然后我们使用等待队列或完成来等待它。

在 GitHub 资源的chapter_05/wait_event目录中,有两个关于等待队列和完成的简单示例,然后在工作原理...部分,我们将详细解释它们。

如何做...

首先,让我们看一个关于等待队列用于等待“数据大于 5”事件的简单示例。

等待队列

以下是waitqueue.c文件的主要部分,其中包含有关等待队列的简单示例。

  1. 再次从末尾开始,看一下模块的init()函数:
static int __init waitqueue_init(void)
{
    int ret;

    /* Save kernel timer delay */
    wqinfo.delay_jiffies = msecs_to_jiffies(delay_ms);
    pr_info("delay is set to %dms (%ld jiffies)\n",
                delay_ms, wqinfo.delay_jiffies);

    /* Init the wait queue */
    init_waitqueue_head(&wqinfo.waitq);

    /* Setup and start the kernel timer */
    timer_setup(&wqinfo.timer, ktimer_handler, 0);
    mod_timer(&wqinfo.timer, jiffies + wqinfo.delay_jiffies);

内核定时器启动后,我们可以使用wait_event_interruptible()函数在wqinfo.waitq等待队列上等待wqinfo.data > 5事件,如下所示:

    /* Wait for the wake up event... */
    ret = wait_event_interruptible(wqinfo.waitq, wqinfo.data > 5);
    if (ret < 0)
        goto exit;

    pr_info("got event data > 5\n");

    return 0;

exit:
    if (ret == -ERESTARTSYS)
        pr_info("interrupted by signal!\n");
    else
        pr_err("unable to wait for event\n");

    del_timer_sync(&wqinfo.timer);

    return ret;
}
  1. 现在定义了数据结构,如下所示:
static struct ktimer_data {
    struct wait_queue_head waitq;
    struct timer_list timer;
    long delay_jiffies;
    unsigned int data;
} wqinfo;
  1. 然而,在等待队列上发生任何操作之前,必须进行初始化,因此,在启动内核定时器之前,我们使用init_waitqueue_head()函数来正确设置存储在struct ktimer_data中的struct wait_queue_head waitq

如果我们查看linux/include/linux/wait.h头文件,我们可以看到wait_event_interruptible()的工作原理:

/**
 * wait_event_interruptible - sleep until a condition gets true
 * @wq_head: the waitqueue to wait on
 * @condition: a C expression for the event to wait for
 *
 * The process is put to sleep (TASK_INTERRUPTIBLE) until the
 * @condition evaluates to true or a signal is received.
 * The @condition is checked each time the waitqueue @wq_head is woken up.
 *
 * wake_up() has to be called after changing any variable that could
 * change the result of the wait condition.
 *
 * The function will return -ERESTARTSYS if it was interrupted by a
 * signal and 0 if @condition evaluated to true.
 */
#define wait_event_interruptible(wq_head, condition) \
  1. 要了解如何唤醒睡眠进程,我们应该考虑waitqueue.c文件中名为ktimer_handler()的内核定时器处理程序:
static void ktimer_handler(struct timer_list *t)
{
    struct ktimer_data *info = from_timer(info, t, timer);

    pr_info("kernel timer expired at %ld (data=%d)\n",
                jiffies, info->data++);

    /* Wake up all sleeping processes */
    wake_up_interruptible(&info->waitq);

    /* Reschedule kernel timer */
    mod_timer(&info->timer, jiffies + info->delay_jiffies);
}

完成

如果我们希望等待作业完成,我们仍然可以使用等待队列,但最好使用完成,因为它专门设计用于执行此类活动。以下是一个简单的示例,可以从 GitHub 关于竞赛的completion.c文件中检索到。

  1. 首先,让我们看看模块init()exit()函数:
static int __init completion_init(void)
{
    /* Save kernel timer delay */
    cinfo.delay_jiffies = msecs_to_jiffies(delay_ms);
    pr_info("delay is set to %dms (%ld jiffies)\n",
                delay_ms, cinfo.delay_jiffies);

    /* Init the wait queue */
    init_completion(&cinfo.done);

    /* Setup and start the kernel timer */
    timer_setup(&cinfo.timer, ktimer_handler, 0); 
    mod_timer(&cinfo.timer, jiffies + cinfo.delay_jiffies);

    /* Wait for completition... */
    wait_for_completion(&cinfo.done);

    pr_info("job done\n");

    return 0;
}

static void __exit completion_exit(void)
{
    del_timer_sync(&cinfo.timer);

    pr_info("module unloaded\n");
}
  1. 现在模块的数据结构如下:
static struct ktimer_data {
    struct completion done;
    struct timer_list timer;
    long delay_jiffies;
    unsigned int data;
} cinfo;
  1. 作业完成后,我们可以使用complete()函数向ktimer_handler()内核定时器处理程序发出完成信号:
static void ktimer_handler(struct timer_list *t)
{
    struct ktimer_data *info = from_timer(info, t, timer);

    pr_info("kernel timer expired at %ld (data=%d)\n",
                jiffies, info->data++);

    /* Signal that job is done */
    complete(&info->done);
}

当调用complete()时,等待完成的单个线程被通知:

/**
 * complete: - signals a single thread waiting on this completion
 * @x: holds the state of this particular completion
 *
 * This will wake up a single thread waiting on this completion. Threads will be
 * awakened in the same order in which they were queued.
 *
 * See also complete_all(), wait_for_completion() and related routines.
 *
 * It may be assumed that this function implies a write memory barrier before
 * changing the task state if and only if any tasks are woken up.
 */
void complete(struct completion *x)

而如果我们调用complete_all(),所有等待完成的线程都会被通知:

/**
 * complete_all: - signals all threads waiting on this completion
 * @x: holds the state of this particular completion
 *
 * This will wake up all threads waiting on this particular completion
 * event.
 * It may be assumed that this function implies a write memory barrier
 * before changing the task state if and only if any tasks are
 * woken up.
 * Since complete_all() sets the completion of @x permanently to done
 * to allow multiple waiters to finish, a call to reinit_completion()
 * must be used on @x if @x is to be used again. The code must make
 * sure that all waiters have woken and finished before reinitializing
 * @x. Also note that the function completion_done() can not be used
 * to know if there are still waiters after complete_all() has been
 * called.
 */
void complete_all(struct completion *x)

它是如何工作的...

让我们在接下来的几节中看看这是如何工作的:

等待队列

在步骤 3 中,如果条件为真,调用进程将继续执行;否则,它会进入睡眠状态,直到条件变为真或收到信号。(在这种情况下,函数返回-ERESTARTSYS值。)

为了完全理解,我们应该注意linux/include/linux/wait.h头文件中定义的另外两个等待事件函数的变体。第一个变体就是wait_event()函数,它的工作方式与wait_event_interruptible()完全相同,但它不能被任何信号中断:

/**
 * wait_event - sleep until a condition gets true
 * @wq_head: the waitqueue to wait on
 * @condition: a C expression for the event to wait for
 *
 * The process is put to sleep (TASK_UNINTERRUPTIBLE) until the
 * @condition evaluates to true. The @condition is checked each time
 * the waitqueue @wq_head is woken up.
 *
 * wake_up() has to be called after changing any variable that could
 * change the result of the wait condition.
 */
#define wait_event(wq_head, condition) \

而第二个是wait_event_timeout()wait_event_interruptible_timeout(),它的工作方式与之前相同,直到超时为止:

/** * wait_event_interruptible_timeout - sleep until a condition
 *    gets true or a timeout elapses
 * @wq_head: the waitqueue to wait on
 * @condition: a C expression for the event to wait for
 * @timeout: timeout, in jiffies
 *
 * The process is put to sleep (TASK_INTERRUPTIBLE) until the
 * @condition evaluates to true or a signal is received.
 * The @condition is checked each time the waitqueue @wq_head
 * is woken up.
 * wake_up() has to be called after changing any variable that could
 * change the result of the wait condition.
 * Returns:
 * 0 if the @condition evaluated to %false after the @timeout elapsed,
 * 1 if the @condition evaluated to %true after the @timeout elapsed,
 * the remaining jiffies (at least 1) if the @condition evaluated
 * to %true before the @timeout elapsed, or -%ERESTARTSYS if it was
 * interrupted by a signal.
 */
#define wait_event_interruptible_timeout(wq_head, condition, timeout) \

步骤 4中,在这个函数中,我们改变了存储在数据中的值,然后我们在等待队列上使用wake_up_interruptible()来通知一个正在睡眠的进程数据已经被改变,它应该醒来测试条件是否为真。

linux/include/linux/wait.h头文件中,定义了几个函数,用于通过使用通用的__wake_up()函数唤醒一个、多个或所有等待进程(可中断或不可中断):

#define wake_up(x)         __wake_up(x, TASK_NORMAL, 1, NULL)
#define wake_up_nr(x, nr)  __wake_up(x, TASK_NORMAL, nr, NULL)
#define wake_up_all(x)     __wake_up(x, TASK_NORMAL, 0, NULL)
...
#define wake_up_interruptible(x) __wake_up(x, TASK_INTERRUPTIBLE, 1, NULL)
#define wake_up_interruptible_nr(x, nr) __wake_up(x, TASK_INTERRUPTIBLE, nr, NULL)
#define wake_up_interruptible_all(x) __wake_up(x, TASK_INTERRUPTIBLE, 0, NULL)
...

在我们的示例中,我们要求数据大于五,所以前五次调用wake_up_interruptible()不应该唤醒我们的进程;让我们在下一节中验证一下!

请注意,将进入睡眠状态的进程只是insmod命令,它是调用模块初始化函数的命令。

完成

步骤 1中,我们可以看到代码与之前的等待队列示例非常相似;我们只是使用init_completion()函数像往常一样初始化完成,然后在struct ktimer_data结构中的struct completion done上调用wait_for_completion()来等待作业结束。

至于等待队列,在linux/include/linux/completion.h头文件中,我们可以找到wait_for_completion()函数的几个变体:

extern void wait_for_completion(struct completion *);
extern int wait_for_completion_interruptible(struct completion *x);
extern unsigned long wait_for_completion_timeout(struct completion *x,
                                                   unsigned long timeout);
extern long wait_for_completion_interruptible_timeout(
        struct completion *x, unsigned long timeout);

还有更多...

现在,为了在两种情况下测试我们的代码,我们必须编译内核模块,然后将它们移动到 ESPRESSObin 上;此外,为了更好地理解示例的工作原理,我们应该使用 SSH 连接,然后从另一个终端窗口查看串行控制台上的内核消息。

等待队列

当我们使用insmod插入waitqueue.ko模块时,应该注意到该进程被挂起,直到数据变大于五为止:

# insmod waitqueue.ko

insmod进程被挂起时,直到测试完成,你不应该得到提示。

在串行控制台上,我们应该收到以下消息:

waitqueue:waitqueue_init: delay is set to 1000ms (250 jiffies)
waitqueue:ktimer_handler: kernel timer expired at 4295371304 (data=0)
waitqueue:ktimer_handler: kernel timer expired at 4295371560 (data=1)
waitqueue:ktimer_handler: kernel timer expired at 4295371816 (data=2)
waitqueue:ktimer_handler: kernel timer expired at 4295372072 (data=3)
waitqueue:ktimer_handler: kernel timer expired at 4295372328 (data=4)
waitqueue:ktimer_handler: kernel timer expired at 4295372584 (data=5)
waitqueue:waitqueue_init: got event data > 5
waitqueue:ktimer_handler: kernel timer expired at 4295372840 (data=6)
...

一旦屏幕上显示了got event data > 5的消息,insmod进程应该返回,并且应该显示一个新的提示。

为了验证wait_event_interruptible()在信号到达时返回-ERESTARTSYS,我们可以卸载模块,然后重新加载它,然后在数据达到 5 之前按下CTRL+C键:

# rmmod waitqueue 
# insmod waitqueue.ko
^C

这次在内核消息中,我们应该得到类似以下的内容:

waitqueue:waitqueue_init: delay is set to 1000ms (250 jiffies)
waitqueue:ktimer_handler: kernel timer expired at 4295573632 (data=0)
waitqueue:ktimer_handler: kernel timer expired at 4295573888 (data=1)
waitqueue:waitqueue_init: interrupted by signal!

完成

要测试完成,我们必须将completion.ko模块插入内核。现在你应该注意到,如果我们按下CTRL+C,什么都不会发生,因为我们使用了wait_for_completion()而不是wait_for_completion_interruptible()

# insmod completion.ko
^C^C^C^C

然后在五秒后提示返回,内核消息类似以下内容:

completion:completion_init: delay is set to 5000ms (1250 jiffies)
completion:ktimer_handler: kernel timer expired at 4296124608 (data=0)
completion:completion_init: job done

另请参阅

执行原子操作

原子操作在设备驱动程序开发中是至关重要的一步。事实上,驱动程序不像一个从头到尾执行的普通程序,因为它提供了多种方法(例如,读取或写入外围设备的数据,或设置一些通信参数),这些方法可以异步地相互调用。所有这些方法都同时在共同的数据结构上操作,这些数据结构必须以一致的方式进行修改。这就是为什么我们需要能够执行原子操作。

Linux 内核使用各种原子操作。每个操作用于不同的操作,取决于 CPU 是否在中断或进程上下文中运行。

当 CPU 处于进程上下文时,我们可以安全地使用互斥锁,如果互斥锁被锁定,可以使当前运行的进程进入睡眠状态;然而,在中断上下文中,“进入睡眠”是不允许的,因此我们需要另一种机制,Linux 给了我们自旋锁,它允许在任何地方进行锁定,但是时间很短。这是因为自旋锁通过在当前 CPU 上执行一个忙等待的紧密循环来完成工作,如果我们停留时间太长,就会损失性能。

在这个示例中,我们将看到如何以不可中断的方式对数据进行操作,以避免数据损坏。

准备就绪

同样,为了构建我们的示例,我们可以使用一个定义了内核定时器的内核模块,在模块init()函数中,它负责生成一个异步执行,我们可以在其中使用我们的互斥机制来保护我们的数据。

在 GitHub 资源的chapter_05/atomic目录中,有关互斥锁、自旋锁和原子数据的简单示例,在接下来的章节中,我们将详细解释它们。

如何做...

在本段中,我们将介绍两个如何使用互斥锁和自旋锁的示例。我们应该将它们视为如何使用 API 的演示,因为在真实的驱动程序中,它们的使用方式有些不同,并且将在第七章 高级字符驱动程序操作和接下来的章节中进行介绍。

互斥锁

以下是mutex.c文件的结尾,其中定义了互斥锁并为模块init()函数进行了初始化:

static int __init mut_init(void)
{
    /* Save kernel timer delay */
    minfo.delay_jiffies = msecs_to_jiffies(delay_ms);
    pr_info("delay is set to %dms (%ld jiffies)\n",
                delay_ms, minfo.delay_jiffies);

    /* Init the mutex */
    mutex_init(&minfo.lock);

    /* Setup and start the kernel timer */
    timer_setup(&minfo.timer, ktimer_handler, 0); 
    mod_timer(&minfo.timer, jiffies);

    mutex_lock(&minfo.lock);
    minfo.data++;
    mutex_unlock(&minfo.lock);

    pr_info("mutex module loaded\n");
    return 0;
}

以下是模块exit()函数的初始化:

static void __exit mut_exit(void)
{
    del_timer_sync(&minfo.timer);

    pr_info("mutex module unloaded\n");
}

module_init(mut_init);
module_exit(mut_exit);
  1. 在模块初始化的mut_init()函数中,我们使用mutex_init()来初始化lock互斥锁;然后我们可以安全地启动定时器。

模块数据结构定义如下:

static struct ktimer_data {
    struct mutex lock;
    struct timer_list timer;
    long delay_jiffies;
    int data;
} minfo;
  1. 我们使用mutex_trylock()来尝试安全地获取锁:
static void ktimer_handler(struct timer_list *t)
{
    struct ktimer_data *info = from_timer(info, t, timer);
    int ret;

    pr_info("kernel timer expired at %ld (data=%d)\n",
                jiffies, info->data);
    ret = mutex_trylock(&info->lock);
    if (ret) {
        info->data++;
        mutex_unlock(&info->lock);
    } else
        pr_err("cannot get the lock!\n");

    /* Reschedule kernel timer */
    mod_timer(&info->timer, jiffies + info->delay_jiffies);
}

自旋锁

  1. 像往常一样,spinlock.c文件被用作自旋锁使用的示例。以下是模块init()函数:
static int __init spin_init(void)
{
    unsigned long flags;

    /* Save kernel timer delay */
    sinfo.delay_jiffies = msecs_to_jiffies(delay_ms);
    pr_info("delay is set to %dms (%ld jiffies)\n",
                delay_ms, sinfo.delay_jiffies);

    /* Init the spinlock */
    spin_lock_init(&sinfo.lock);

    /* Setup and start the kernel timer */
    timer_setup(&sinfo.timer, ktimer_handler, 0); 
    mod_timer(&sinfo.timer, jiffies);

    spin_lock_irqsave(&sinfo.lock, flags);
    sinfo.data++;
    spin_unlock_irqrestore(&sinfo.lock, flags);

    pr_info("spinlock module loaded\n");
    return 0;
}

以下是模块exit()函数:

static void __exit spin_exit(void)
{
    del_timer_sync(&sinfo.timer);

    pr_info("spinlock module unloaded\n");
}

module_init(spin_init);
module_exit(spin_exit);

模块数据结构如下:

static struct ktimer_data {
    struct spinlock lock;
    struct timer_list timer;
    long delay_jiffies;
    int data;
} sinfo;
  1. 在这个示例中,我们使用spin_lock_init()来初始化自旋锁,然后我们使用两个不同的函数对来保护我们的数据:spin_lock()spin_unlock();这两者都使用自旋锁来避免竞争条件,而spin_lock_irqsave()spin_unlock_irqrestore()在当前 CPU 中断被禁用时使用自旋锁:
static void ktimer_handler(struct timer_list *t)
{
    struct ktimer_data *info = from_timer(info, t, timer);

    pr_info("kernel timer expired at %ld (data=%d)\n",
                jiffies, info->data);
    spin_lock(&sinfo.lock);
    info->data++;
    spin_unlock(&info->lock);

    /* Reschedule kernel timer */
    mod_timer(&info->timer, jiffies + info->delay_jiffies);
}

通过使用spin_lock_irqsave()spin_unlock_irqrestore(),我们可以确保没有人可以中断我们,因为 IRQ 被禁用,也没有其他 CPU 可以执行我们的代码(由于自旋锁)。

工作原理...

让我们看看互斥锁和自旋锁在接下来的两个部分中是如何工作的。

互斥锁

步骤 2中,每次我们需要修改数据时,我们可以通过调用mutex_lock()mutex_unlock()对其进行保护,将互斥锁的指针作为参数传递;当然,我们不能在中断上下文中执行此操作(如内核定时器处理程序),这就是为什么我们使用mutex_trylock()来尝试安全地获取锁。

自旋锁

在步骤 1 中,示例与之前的示例非常相似,但它展示了互斥锁和自旋锁之间一个非常重要的区别:前者保护代码免受进程的并发影响,而后者保护代码免受 CPU 的并发影响!实际上,如果内核没有对称多处理支持(在内核.config文件中CONFIG_SMP=n),那么自旋锁就会消失。

这是一个非常重要的概念,设备驱动程序开发人员应该非常了解;否则,驱动程序可能根本无法工作,或者导致严重的错误。

还有更多...

由于最后的示例只是为了展示互斥锁和自旋锁,API 测试是相当无用的。然而,如果我们仍然希望这样做,程序是一样的:编译模块,然后将它们移动到 ESPRESSObin。

互斥锁

当我们插入mutex.ko模块时,输出应该类似于以下内容:

# insmod mutex.ko 
mutex:mut_init: delay is set to 1000ms (250 jiffies)
mutex:mut_init: mutex module loaded

在步骤 1 中,我们执行模块init()函数,在其中增加了一个在互斥锁保护区域内的minfo.data

mutex:ktimer_handler: kernel timer expired at 4294997916 (data=1)
mutex:ktimer_handler: kernel timer expired at 4294998168 (data=2)
mutex:ktimer_handler: kernel timer expired at 4294998424 (data=3)
...

当我们执行处理程序时,我们可以确保如果模块init()函数当前持有互斥锁,它就不能增加minfo.data

自旋锁

当我们插入spinlock.ko模块时,输出应该类似于以下内容:

# insmod spinlock.ko 
spinlock:spin_init: delay is set to 1000ms (250 jiffies)
spinlock:spin_init: spinlock module loaded

与之前一样,在步骤 1中,我们执行模块init()函数,在其中增加了一个在自旋锁保护区域内的minfo.data

spinlock:ktimer_handler: kernel timer expired at 4295019195 (data=1)
spinlock:ktimer_handler: kernel timer expired at 4295019448 (data=2)
spinlock:ktimer_handler: kernel timer expired at 4295019704 (data=3)
...

同样,在执行处理程序时,我们可以确保如果模块init()函数当前持有自旋锁,它就不能增加minfo.data

请注意,在单核机器的情况下,自旋锁会消失,并且我们可以通过禁用中断来确保minfo.data的锁。

通过使用互斥锁和自旋锁,我们可以保护数据免受竞态条件的影响;然而,Linux 为我们提供了另一个 API,原子操作。

原子数据类型

在设备驱动程序开发过程中,我们可能需要以原子方式增加或减少一个变量,或者更简单地在一个变量中设置一个或多个位。为此,我们可以使用一组变量和操作,内核保证这些操作是原子的,而不是使用复杂的互斥机制。

在 GitHub 资源的atomic.c文件中,我们可以看到一个关于原子操作的简单示例,其中原子变量可以定义如下:

static atomic_t bitmap = ATOMIC_INIT(0xff);

static struct ktimer_data {
    struct timer_list timer;
    long delay_jiffies;
    atomic_t data;
} ainfo;

此外,以下是模块init()函数:

static int __init atom_init(void)
{
    /* Save kernel timer delay */
    ainfo.delay_jiffies = msecs_to_jiffies(delay_ms);
    pr_info("delay is set to %dms (%ld jiffies)\n",
                delay_ms, ainfo.delay_jiffies);

    /* Init the atomic data */
    atomic_set(&ainfo.data, 10);

    /* Setup and start the kernel timer after required delay */
    timer_setup(&ainfo.timer, ktimer_handler, 0); 
    mod_timer(&ainfo.timer, jiffies + ainfo.delay_jiffies);

    pr_info("data=%0x\n", atomic_read(&ainfo.data));
    pr_info("bitmap=%0x\n", atomic_fetch_and(0x0f, &bitmap));

    pr_info("atomic module loaded\n");
    return 0;
}

以下是模块exit()函数:

static void __exit atom_exit(void)
{
    del_timer_sync(&ainfo.timer);

    pr_info("atomic module unloaded\n");
}

在前面的代码中,我们使用ATOMIC_INIT()来静态定义和初始化原子变量,而atomic_set()函数可以用来动态地做同样的事情。随后,原子变量可以通过使用带有atomic_*()前缀的函数来进行操作,这些函数位于linux/include/linux/atomic.hlinux/include/asm-generic/atomic.h文件中。

最后,内核定时器处理程序可以实现如下:

static void ktimer_handler(struct timer_list *t)
{
    struct ktimer_data *info = from_timer(info, t, timer);

    pr_info("kernel timer expired at %ld (data=%d)\n",
                jiffies, atomic_dec_if_positive(&info->data));

    /* Compute an atomic bitmap operation */
    atomic_xor(0xff, &bitmap);
    pr_info("bitmap=%0x\n", atomic_read(&bitmap));

    /* Reschedule kernel timer */
    mod_timer(&info->timer, jiffies + info->delay_jiffies);
}

原子数据可以通过特定值进行加法或减法运算,增加、减少、或运算、与运算、异或运算等,所有这些操作都由内核保证是原子的,因此它们的使用非常简单。

同样,测试代码是相当无用的。然而,如果我们在 ESPRESSObin 中编译然后插入atomic.ko模块,输出如下:

# insmod atomic.ko 
atomic:atom_init: delay is set to 1000ms (250 jiffies)
atomic:atom_init: data=a
atomic:atom_init: bitmap=ff
atomic:atom_init: atomic module loaded
atomic:ktimer_handler: kernel timer expired at 4295049912 (data=9)
atomic:ktimer_handler: bitmap=f0
atomic:ktimer_handler: kernel timer expired at 4295050168 (data=8)
atomic:ktimer_handler: bitmap=f
...
atomic:ktimer_handler: kernel timer expired at 4295051960 (data=1)
atomic:ktimer_handler: bitmap=f0
atomic:ktimer_handler: kernel timer expired at 4295052216 (data=0)
atomic:ktimer_handler: bitmap=f
atomic:ktimer_handler: kernel timer expired at 4295052472 (data=-1)

此时,data保持在-1,不再减少。

另请参阅

第六章:杂项内核内部

在内核开发中,我们可能需要执行一些杂项活动来实现我们的设备驱动程序,例如动态分配内存并使用特定的数据类型来存储寄存器数据,或者简单地等待一段时间,以确保外围设备已完成其复位过程。

为了执行所有这些任务,Linux 为内核开发人员提供了一套丰富的有用函数、宏和数据类型,我们将尝试通过非常简单的示例代码在本章中介绍它们,因为我们希望向读者指出如何使用它们来简化设备驱动程序开发。因此,在本章中,我们将涵盖以下内容:

  • 使用内核数据类型

  • 管理辅助函数

  • 动态内存分配

  • 管理内核链表

  • 使用内核哈希表

  • 访问 I/O 内存

  • 在内核中花费时间

技术要求

有关本章的更多信息,您可以访问附录

本章中使用的代码和其他文件可以从 GitHub 下载github.com/giometti/linux_device_driver_development_cookbook/tree/master/chapter_06

使用内核数据类型

通常,内核代码需要特定大小的数据项来匹配预定义的二进制结构,保存外围设备的寄存器数据,与用户空间通信,或者仅仅通过插入填充字段在结构内对齐数据。

有时,内核代码需要特定大小的数据项,也许是为了匹配预定义的二进制结构,与用户空间通信,保存外围设备的寄存器数据,或者仅仅通过插入填充字段在结构内对齐数据。

在本节中,我们将看到一些特殊的数据类型,内核开发人员可以使用这些类型来简化他们的日常工作。接下来,我们将看到一个固定大小数据类型的示例,这些类型非常有用,可以定义与设备或通信协议期望的数据结构完全匹配的数据类型;细心的读者会认识到,确实无法使用标准 C 类型来定义这种固定大小的数据实体,因为 C 标准并没有明确保证在所有架构中都有固定大小的表示,当我们使用类似标准 C 类型如intshortlong时。

内核提供以下数据类型,以便在需要知道数据大小时使用(它们的实际定义取决于当前使用的架构,但它们在不同架构中都被命名为相同):

  • u8: 无符号字节(8 位)

  • u16: 无符号字(16 位)

  • u32: 无符号 32 位(32 位)

  • u64: 无符号 64 位(64 位)

  • s8: 有符号字节(8 位)

  • s16: 有符号字(16 位)

  • s32: 有符号 32 位(32 位)

  • s64: 有符号 64 位(64 位)

有时,固定大小的数据类型必须用于与用户空间交换数据;然而,在这种情况下,我们不能使用前面的类型,而必须选择以下替代数据类型,这些类型等同于前面的类型,但可以在内核和用户空间中任意使用(这个概念将在第七章,高级字符驱动程序操作中的使用 ioctl()方法*中变得更加清晰):

  • __u8: 无符号字节(8 位)

  • __u16: 无符号字(16 位)

  • __u32: 无符号 32 位(32 位)

  • __u64: 无符号 64 位(64 位)

  • __s8: 有符号字节(8 位)

  • __s16: 有符号字(16 位)

  • __s32: 有符号 32 位(32 位)

  • __s64: 有符号 64 位(64 位)

所有这些固定大小的类型都在头文件linux/include/linux/types.h中定义。

做好准备

为了展示如何使用前面的数据类型,我们可以再次使用一个内核模块来执行一些内核代码,其中使用它们来定义结构中的寄存器映射。

如何做...

让我们看看如何通过以下步骤来做到这一点:

  1. 让我们看看data_type.c文件,我们将所有代码放入模块的init()函数中,如下所示:
static int __init data_types_init(void)
{
    struct dtypes_s *ptr = (struct dtypes_s *) base_addr;

    pr_info("\tu8\tu16\tu32\tu64\n");
    pr_info("size\t%ld\t%ld\t%ld\t%ld\n",
        sizeof(u8), sizeof(u16), sizeof(u32), sizeof(u64));

    pr_info("name\tptr\n");
    pr_info("reg0\t%px\n", &ptr->reg0);
    pr_info("reg1\t%px\n", &ptr->reg1);
    pr_info("reg2\t%px\n", &ptr->reg2);
    pr_info("reg3\t%px\n", &ptr->reg3);
    pr_info("reg4\t%px\n", &ptr->reg4);
    pr_info("reg5\t%px\n", &ptr->reg5);

    return -EINVAL;
}

工作原理...

在执行步骤 1之后,指针ptr将根据base_addr的值进行初始化,以便通过简单地引用struct dtypes_s的字段(在以下代码中定义)来指向正确的内存地址:

struct dtypes_s {
    u32 reg0;
    u8 pad0[2];
    u16 reg1;
    u32 pad1[2];
    u8 reg2;
    u8 reg3;
    u16 reg4;
    u32 reg5;
} __attribute__ ((packed));

在结构定义期间,我们应该意识到编译器可能会在结构本身中悄悄地插入填充,以确保每个字段都正确对齐,以便在目标处理器上获得良好的性能;避免这种行为的一种解决方法是告诉编译器结构必须是紧凑的,不添加填充。当然,这可以通过使用__attribute__ ((packed))来实现,就像以前一样。

还有更多...

如果我们希望验证这一步,我们可以通过测试代码来做到这一点。我们只需要像往常一样编译模块,然后将其移动到 ESPRESSObin,最后按照以下步骤插入内核:

# insmod data_types.ko 

您还应该收到以下错误消息:

insmod: ERROR: could not insert module data_types.ko: Invalid parameters

然而,这是由于data_types_init()函数中的最后一个return -EINVAL;我们在这里和接下来使用这个技巧,强制内核在模块的init()函数执行后移除模块。

我们得到的内核消息中的第一行是关于u8u16u32u64类型的维度如下:

data_types:data_types_init:      u8 u16 u32 u64
data_types:data_types_init: size 1  2   4   8

然后,以下行(仍然在内核消息中)向我们展示了通过使用带有u8u16u32u64的结构定义以及__attribute__ ((packed))语句可以实现的完美填充:

data_types:data_types_init: name ptr
data_types:data_types_init: reg0 0000000080000000
data_types:data_types_init: reg1 0000000080000006
data_types:data_types_init: reg2 0000000080000010
data_types:data_types_init: reg3 0000000080000011
data_types:data_types_init: reg4 0000000080000012
data_types:data_types_init: reg5 0000000080000014

另请参阅

管理辅助函数

在设备驱动程序开发过程中,我们可能需要连接字符串或计算其长度,或者只是复制或移动内存区域(或字符串)。为了在用户空间执行这些常见操作,我们可以使用几个函数,比如strcat()strlen()memcpy()(或strcpy())等等,Linux 也为我们提供了类似命名的函数,当然,这些函数在内核中是安全可用的。(请注意,内核代码不能链接到用户空间的 glibc 库。)

在本教程中,我们将看到如何使用一些内核辅助程序来管理内核中的字符串。

准备工作

如果我们在内核源代码中查看linux/include/linux/string.h包含文件,我们可以看到一长串通常的用户空间类似实用函数,如下所示:

#ifndef __HAVE_ARCH_STRCPY
extern char * strcpy(char *,const char *);
#endif
#ifndef __HAVE_ARCH_STRNCPY
extern char * strncpy(char *,const char *, __kernel_size_t);
#endif
#ifndef __HAVE_ARCH_STRLCPY
size_t strlcpy(char *, const char *, size_t);
#endif
#ifndef __HAVE_ARCH_STRSCPY
ssize_t strscpy(char *, const char *, size_t);
#endif
#ifndef __HAVE_ARCH_STRCAT
extern char * strcat(char *, const char *);
#endif
#ifndef __HAVE_ARCH_STRNCAT
extern char * strncat(char *, const char *, __kernel_size_t);
#endif
...

请注意,每个函数都被包含在#ifndef/#endif预处理器条件子句中,因为这些函数中的一些可以使用某种形式的优化来实现;因此,它们的实现可能在不同平台上有所不同。

为了展示如何使用前面的辅助函数,我们可以再次使用一个内核模块来执行使用其中一些函数的内核代码。

如何做...

让我们看看如何通过以下步骤来做到这一点:

  1. helper_funcs.c文件中,我们可以看到一些非常愚蠢的代码,它演示了我们如何使用这些辅助函数。

鼓励您修改此代码以使用不同的内核辅助函数。

  1. 所有的工作都是在模块的init()函数中完成的,就像在前面的部分一样。在这里,我们可以使用内核函数strlen()strncpy(),就像它们的用户空间对应函数一样:
static int __init helper_funcs_init(void)
{
    char str2[STR2_LEN];

    pr_info("str=\"%s\"\n", str);
    pr_info("str size=%ld\n", strlen(str));

    strncpy(str2, str, STR2_LEN);

    pr_info("str2=\"%s\"\n", str2);
    pr_info("str2 size=%ld\n", strlen(str2));

    return -EINVAL;
}

这些函数是特殊的内核实现,它们不是我们通常在正常编程中使用的用户空间函数。我们不能将内核模块与 glibc 链接!

  1. str字符串定义为模块参数如下,并且可以用于尝试不同的字符串:
static char *str = "default string";
module_param(str, charp, S_IRUSR | S_IWUSR);
MODULE_PARM_DESC(str, "a string value");

还有更多...

如果您希望测试该示例中的代码,可以通过编译它然后将其移动到 ESPRESSObin 来进行测试。

首先,我们必须将模块插入内核:

# insmod helper_funcs.ko

您可以安全地忽略以下错误消息,如之前讨论的那样:

insmod: ERROR: could not insert module helper_funcs.ko: Invalid parameters

内核消息现在应该如下所示:

helper_funcs:helper_funcs_init: str="default string"
helper_funcs:helper_funcs_init: str size=14
helper_funcs:helper_funcs_init: str2="default string"
helper_funcs:helper_funcs_init: str2 size=14

在前面的输出中,我们可以看到字符串str2只是str的副本。

但是,如果我们使用以下insmod命令,输出将会发生变化:

# insmod helper_funcs.ko str=\"very very very loooooooooong string\"
helper_funcs:helper_funcs_init: str="very very very loooooooooong string"
helper_funcs:helper_funcs_init: str size=35
helper_funcs:helper_funcs_init: str2="very very very loooooooooong str"
helper_funcs:helper_funcs_init: str2 size=32

再次,字符串str2str的副本,但其最大大小STR2_LEN定义如下:

#define STR2_LEN    32

另请参见

动态内存分配

一个好的设备驱动程序不应该支持多个外围设备(可能)也不应该是固定数量的!但是,即使我们决定将驱动程序的使用限制为一个外围设备,也可能需要管理可变数量的数据块,因此无论如何,我们都需要能够管理动态内存分配

在这个示例中,我们将看到如何在内核空间动态(并安全地)分配内存块。

如何做...

为了展示我们如何通过使用kmalloc()vmalloc()kvmalloc()从内核中分配内存,我们可以再次使用一个内核模块。

mem_alloc.c文件中,我们可以看到一些非常简单的代码,显示了内存分配如何与相关的内存释放函数一起工作:

  1. 所有的工作都是在模块的init()函数中完成的,就像以前一样。第一步是使用两个不同标志的kmalloc(),即GFP_KERNEL(可以休眠)和GFP_ATOMIC(不休眠,然后可以安全地在中断上下文中使用):
static int __init mem_alloc_init(void)
{
    void *ptr;

    pr_info("size=%ldkbytes\n", size);

    ptr = kmalloc(size << 10, GFP_KERNEL);
    pr_info("kmalloc(..., GFP_KERNEL) =%px\n", ptr);
    kfree(ptr);

    ptr = kmalloc(size << 10, GFP_ATOMIC);
    pr_info("kmalloc(..., GFP_ATOMIC) =%px\n", ptr);
    kfree(ptr);
  1. 然后,我们尝试使用vmalloc()来分配内存:
    ptr = vmalloc(size << 10);
    pr_info("vmalloc(...) =%px\n", ptr);
    vfree(ptr);
  1. 最后,我们尝试使用kvmalloc()和两个不同的标志进行两种不同的分配,即GFP_KERNEL(可以休眠)和GFP_ATOMIC(不休眠,然后可以安全地在中断上下文中使用):
    ptr = kvmalloc(size << 10, GFP_KERNEL);
    pr_info("kvmalloc(..., GFP_KERNEL)=%px\n", ptr);
    kvfree(ptr);

    ptr = kvmalloc(size << 10, GFP_ATOMIC);
    pr_info("kvmalloc(..., GFP_ATOMIC)=%px\n", ptr);
    kvfree(ptr);

    return -EINVAL;
}

请注意,对于每个分配函数,我们必须使用相关的free()函数!

要分配的内存块的大小作为内核参数传递如下:

static long size = 4;
module_param(size, long, S_IRUSR | S_IWUSR);
MODULE_PARM_DESC(size, "memory size in Kbytes");

还有更多...

好的,就像以前一样,只需编译模块,然后将其移动到 ESPRESSObin。

如果我们尝试使用默认内存大小(即 4 KB)插入模块,我们应该会得到以下内核消息:

# insmod mem_alloc.ko
mem_alloc:mem_alloc_init: size=4kbytes
mem_alloc:mem_alloc_init: kmalloc(..., GFP_KERNEL) =ffff800079831000
mem_alloc:mem_alloc_init: kmalloc(..., GFP_ATOMIC) =ffff800079831000
mem_alloc:mem_alloc_init: vmalloc(...) =ffff000009655000
mem_alloc:mem_alloc_init: kvmalloc(..., GFP_KERNEL)=ffff800079831000
mem_alloc:mem_alloc_init: kvmalloc(..., GFP_ATOMIC)=ffff800079831000

您可以安全地忽略以下错误消息,如前面讨论的那样:

insmod: ERROR: could not insert module mem_alloc.ko: Invalid parameters

这向我们表明所有分配函数都成功地完成了它们的工作。

但是,如果我们尝试增加内存块大小如下,会发生一些变化:

root@espressobin:~# insmod mem_alloc.ko size=5000
mem_alloc:mem_alloc_init: size=5000kbytes
mem_alloc:mem_alloc_init: kmalloc(..., GFP_KERNEL) =0000000000000000
mem_alloc:mem_alloc_init: kmalloc(..., GFP_ATOMIC) =0000000000000000
mem_alloc:mem_alloc_init: vmalloc(...) =ffff00000b9fb000
mem_alloc:mem_alloc_init: kvmalloc(..., GFP_KERNEL)=ffff00000c135000
mem_alloc:mem_alloc_init: kvmalloc(..., GFP_ATOMIC)=0000000000000000

现在kmalloc()函数失败,而vmalloc()由于它在非连续物理地址上分配虚拟内存空间而仍然成功。另一方面,当使用标志GFP_KERNEL调用kvmalloc()时成功,而使用标志GFP_ATOMIC时失败。(这是因为在这种特殊情况下它不能使用vmalloc()作为后备。)

另请参见

管理内核链接列表

在内核内编程时,有能力管理数据列表可能非常有用,为了减少重复的代码量,内核开发人员创建了循环双向链表的标准实现。

在这个示例中,我们将看到如何使用 Linux API 在我们的代码中使用列表。

准备工作

为了演示列表 API 的工作原理,我们可以再次使用内核模块,在模块的init()函数中执行一些操作,就像以前一样。

如何做...

list.c文件中,有我们的示例代码,所有游戏都在list_init()函数中进行:

  1. 首先,让我们看一下实现列表元素和列表头的结构的声明:
static LIST_HEAD(data_list);

struct l_struct {
    int data;
    struct list_head list;
};
  1. 现在,在list_init()中,我们定义了我们的元素:
static int __init list_init(void)
{
    struct l_struct e1 = {
        .data = 5
    };
    struct l_struct e2 = {
        .data = 1
    }; 
    struct l_struct e3 = {
        .data = 7
    };
  1. 然后,我们向列表中添加第一个元素并打印它:
    pr_info("add e1...\n");
    add_ordered_entry(&e1);
    print_entries();
  1. 接下来,我们继续添加元素并打印列表:
    pr_info("add e2, e3...\n");
    add_ordered_entry(&e2);
    add_ordered_entry(&e3);
    print_entries();
  1. 最后,我们删除一个元素:
    pr_info("del data=5...\n");
    del_entry(5);
    print_entries();

    return -EINVAL;
}
  1. 现在,让我们看看本地函数定义;要以有序模式添加元素,我们可以这样做:
static void add_ordered_entry(struct l_struct *new)
{
    struct list_head *ptr;
    struct l_struct *entry;

    list_for_each(ptr, &data_list) {
        entry = list_entry(ptr, struct l_struct, list);
        if (entry->data < new->data) {
            list_add_tail(&new->list, ptr);
            return;
        }
    }
    list_add_tail(&new->list, &data_list);
}
  1. 与此同时,可以按照以下步骤进行条目删除:
static void del_entry(int data)
{
    struct list_head *ptr;
    struct l_struct *entry;

    list_for_each(ptr, &data_list) {
        entry = list_entry(ptr, struct l_struct, list);
        if (entry->data == data) {
            list_del(ptr);
            return;
        }
    }
}
  1. 最后,可以通过以下方式打印列表中的所有元素:
static void print_entries(void)
{
    struct l_struct *entry;

    list_for_each_entry(entry, &data_list, list)
        pr_info("data=%d\n", entry->data);
}

在最后的函数中,我们使用宏list_for_each_entry()而不是list_for_each()list_entry()的组合,以获得更紧凑和可读的代码,它本质上执行相同的步骤。

该宏在linux/include/linux/list.h文件中定义如下:

/**
 * list_for_each_entry - iterate over list of given type
 * @pos: the type * to use as a loop cursor.
 * @head: the head for your list.
 * @member: the name of the list_head within the struct.
 */
#define list_for_each_entry(pos, head, member) \
        for (pos = list_first_entry(head, typeof(*pos), member); \
             &pos->member != (head); \
             pos = list_next_entry(pos, member))

还有更多...

我们可以在编译并插入到 ESPRESSObin 的内核后测试代码。要插入内核,我们使用通常的insmod命令:

# insmod list.ko 

您可以安全地忽略以下错误消息,如前所述:

insmod: ERROR: could not insert module list.ko: Invalid parameters

然后,在第一次插入后,我们得到了以下内核消息:

list:list_init: add e1...
list:print_entries: data=5

步骤 1步骤 2中,我们定义了列表的元素,而在步骤 3中,我们进行了第一次插入到列表中,之前的消息是插入后得到的结果。

步骤 4中进行第二次插入后,我们得到了以下结果:

list:list_init: add e2, e3...
list:print_entries: data=7
list:print_entries: data=5
list:print_entries: data=1

最后,在步骤 5删除后,列表变为如下:

list:list_init: del data=5...
list:print_entries: data=7
list:print_entries: data=1

请注意,在步骤 6中,我们提出了有序模式下元素插入的可能实现,但是开发人员可以根据实际情况选择最佳解决方案。对于步骤 7,我们实现了元素移除,而在步骤 8中,我们有打印函数。

另请参阅

使用内核哈希表

与内核列表一样,Linux 为内核开发人员提供了一个通用接口来管理哈希表。它们的实现是基于前一节中看到的内核列表的特殊版本,并命名为hlist(仍然是双向链表,但是只有一个指针列表头)。该 API 在头文件linux/include/linux/hashtable.h中定义。

在这个示例中,我们将展示如何使用哈希表在内核代码中使用 Linux API。

准备工作

即使在这个示例中,我们也可以使用内核模块来查看测试代码的工作原理。

如何做...

hashtable.c文件中,实现了一个与内核列表中提出的非常相似的示例:

  1. 作为第一步,我们声明哈希表、数据结构和哈希函数如下:
static DEFINE_HASHTABLE(data_hash, 1);

struct h_struct {
    int data;
    struct hlist_node node;
};

static int hash_func(int data)
{
    return data % 2;
}

我们的哈希表只有两个桶,以便能够轻松地发生碰撞,因此哈希函数的实现非常简单;它只能返回值01

  1. 然后,在模块的init()函数中,我们定义我们的节点:
static int __init hashtable_init(void)
{
    struct h_struct e1 = {
        .data = 5
    };
    struct h_struct e2 = {
        .data = 2
    };
    struct h_struct e3 = {
        .data = 7
    };
  1. 然后,我们进行第一次插入,然后打印数据:
    pr_info("add e1...\n");
    add_node(&e1);
    print_nodes();
  1. 接下来,我们继续节点插入:
    pr_info("add e2, e3...\n");
    add_node(&e2);
    add_node(&e3);
    print_nodes();
  1. 最后,我们尝试进行节点删除:
    pr_info("del data=5\n");
    del_node(5);
    print_nodes();

    return -EINVAL;
}
  1. 作为最后一步,我们可以看一下节点的插入和删除函数:
static void add_node(struct h_struct *new)
{
    int key = hash_func(new->data);

    hash_add(data_hash, &new->node, key);
}

static void del_node(int data)
{
    int key = hash_func(data);
    struct h_struct *entry; 

    hash_for_each_possible(data_hash, entry, node, key) {
        if (entry->data == data) {
            hash_del(&entry->node);
            return;
        }
    }
}

这两个函数需要密钥生成,以确保将节点添加到正确的存储桶中或从中移除。

  1. 可以通过使用hash_for_each()宏来进行哈希表的打印,如下所示:
static void print_nodes(void)
{
    int key;
    struct h_struct *entry;

    hash_for_each(data_hash, key, entry, node)
        pr_info("data=%d\n", entry->data);
}

还有更多...

同样,要测试代码,只需编译然后将内核模块插入 ESPRESSObin。

模块插入后,在内核消息中,我们应该看到第一行输出:

# insmod ./hashtable.ko 
hashtable:hashtable_init: add e1...
hashtable:print_nodes: data=5

您可以安全地忽略前面讨论过的以下错误消息:

insmod: ERROR: could not insert module hashtable.ko: Invalid parameters

步骤 1步骤 2中,我们已经定义了哈希表的节点,而在步骤 3中,我们已经对表进行了第一次插入,插入后的代码如上所示。

然后,在步骤 4中进行了第二次插入,我们添加了两个数据字段分别设置为72的节点:

hashtable:hashtable_init: add e2, e3...
hashtable:print_nodes: data=7
hashtable:print_nodes: data=2
hashtable:print_nodes: data=5

最后,在步骤 5中,我们移除了data字段设置为 5 的节点:

hashtable:hashtable_init: del data=5
hashtable:print_nodes: data=7
hashtable:print_nodes: data=2

请注意,在步骤 6中,我们展示了哈希表中节点插入的可能实现。在步骤 7中,我们有打印函数。

另请参阅

获取 I/O 内存的访问

在这个示例中,我们将看到如何访问 CPU 的内部外围设备或连接到 CPU 的任何其他内存映射设备。

准备工作

这次,我们将使用内核源代码中已经存在的代码片段来展示一个示例,因此现在没有什么需要编译,但我们可以直接转到 ESPRESSObin 的内核源代码的根目录。

如何做...

  1. 关于如何进行内存重映射的一个很好而且非常简单的例子在linux/drivers/reset/reset-sunxi.c文件的sunxi_reset_init()函数中报告如下:
static int sunxi_reset_init(struct device_node *np)
{
    struct reset_simple_data *data;
    struct resource res;
    resource_size_t size;
    int ret;

    data = kzalloc(sizeof(*data), GFP_KERNEL);
    if (!data)
        return -ENOMEM;

    ret = of_address_to_resource(np, 0, &res);
    if (ret)
        goto err_alloc;

通过使用of_address_to_resource()函数,我们询问设备树我们设备的内存映射,并将结果存储在res结构中。

  1. 然后,我们使用resource_size()函数请求内存映射大小,然后调用request_mem_region()函数,以便向内核请求独占访问res.startres.start+size-1之间的内存地址:
        size = resource_size(&res);
        if (!request_mem_region(res.start, size, np->name)) {
                ret = -EBUSY;
                goto err_alloc;
        }

如果没有人已经发出了相同的请求,该区域将被标记为我们使用的,并且标签名称存储在np->name中。

现在,名称和内存区域已经为我们保留,并且所有这些信息都可以从/proc/iomem文件中检索,如下一节所示。

  1. 在进行了所有前期操作之后,我们最终可以调用ioremap()函数来实际进行重映射:
    data->membase = ioremap(res.start, size);
    if (!data->membase) {
        ret = -ENOMEM;
        goto err_alloc;
    }

data->membase中存储了我们可以使用的虚拟地址,以便访问我们设备的寄存器。

ioremap()的原型及其对应的iounmap()在头文件linux/include/asm-generic/io.h中定义如下,当我们使用完这个映射时必须使用它:

void __iomem *ioremap(phys_addr_t phys_addr, size_t size);

void iounmap(void __iomem *addr);

请注意,在linux/include/asm-generic/io.h中,仅报告了没有 MMU 的系统的实现,因为每个平台都在linux/arch目录下有自己的实现。

工作原理...

要了解如何使用ioremap(),我们可以比较前面的代码和我们 ESPRESSObin 中的通用异步收发器UART)驱动程序在linux/drivers/tty/serial/mvebu-uart.c文件中的示例。

...
    port->membase = devm_ioremap_resource(&pdev->dev, reg);
    if (IS_ERR(port->membase))
        return -PTR_ERR(port->membase);
...
    /* UART Soft Reset*/
    writel(CTRL_SOFT_RST, port->membase + UART_CTRL(port));
    udelay(1);
    writel(0, port->membase + UART_CTRL(port));
...

上述代码是mvebu_uart_probe()函数的一部分,该函数在某个时候调用devm_ioremap_resource()函数,该函数执行与步骤 1步骤 2步骤 3中呈现的函数的组合执行类似的步骤,即of_address_to_resource()request_mem_region()ioremap()函数同时进行:它从设备树中获取信息并进行内存重映射,仅保留这些寄存器供其独占使用。

这个注册(在步骤 2中之前完成)可以通过 procfs 文件/proc/iomem进行检查,我们可以看到内存区域d0012000-d00121ff分配给serial@12000

root@espressobin:~# cat /proc/iomem 
00000000-7fffffff : System RAM
00080000-00faffff : Kernel code
010f0000-012a9fff : Kernel data
d0010600-d0010fff : spi@10600
d0012000-d00121ff : serial@12000
d0013000-d00130ff : nb-periph-clk@13000
d0013200-d00132ff : tbg@13200
d0013c00-d0013c1f : pinctrl@13800
d0018000-d00180ff : sb-periph-clk@18000
d0018c00-d0018c1f : pinctrl@18800
d001e808-d001e80b : sdhci@d0000
d0030000-d0033fff : ethernet@30000
d0058000-d005bfff : usb@58000
d005e000-d005ffff : usb@5e000
d0070000-d008ffff : pcie@d0070000
d00d0000-d00d02ff : sdhci@d0000
d00e0000-d00e1fff : sata@e0000
e8000000-e8ffffff : pcie@d0070000

正如本书中已经多次声明的那样,当我们在内核中时,没有人真的能阻止我们做某事;因此,当我谈到对内存区域的独占使用时,读者应该想象这是真实的,如果所有程序员自愿在之前的 I/O 内存区域的访问请求(比如之前发出的请求)失败后,他们都不会在该区域上发出内存访问。

另请参阅

在内核中花费时间

在这个示例中,我们将看看如何通过使用繁忙循环或可能涉及挂起的更复杂的函数来延迟将来的执行。

做好准备

即使在这个示例中,我们也可以使用内核模块来查看测试代码的工作原理。

如何做...

time.c文件中,我们可以找到一个简单的示例,说明了前面的函数是如何工作的:

  1. 作为第一步,我们声明一个实用函数来获取代码行的执行时间(以纳秒为单位):
#define print_time(str, code)     \
    do {                          \
        u64 t0, t1;               \
        t0 = ktime_get_real_ns(); \
        code;                     \
        t1 = ktime_get_real_ns(); \
        pr_info(str " -> %lluns\n", t1 - t0); \
    } while (0)

这是一个简单的技巧,定义一个宏,通过使用ktime_get_real_ns()函数执行一行代码,同时返回其执行时间,该函数返回当前系统时间(以纳秒为单位)。

有关ktime_get_real_ns()和相关函数的更多信息,您可以查看www.kernel.org/doc/html/latest/core-api/timekeeping.html

  1. 现在,对于模块的init()函数,我们可以使用我们的宏,然后调用所有前面的延迟函数如下:
static int __init time_init(void)
{
    pr_info("*delay() functions:\n");
    print_time("10ns", ndelay(10));
    print_time("10000ns", udelay(10));
    print_time("10000000ns", mdelay(10));

    pr_info("*sleep() functions:\n");
    print_time("10000ns", usleep_range(10, 10));
    print_time("10000000ns", msleep(10));
    print_time("10000000ns", msleep_interruptible(10));
    print_time("10000000000ns", ssleep(10));

    return -EINVAL;
}

还有更多...

我们可以通过编译代码并将其插入到 ESPRESSObin 内核中来测试我们的代码:

# insmod time.ko 

以下内核消息应通过在步骤 1中定义的宏来打印出来。这个宏只是通过使用ktime_get_real_ns()函数来获取传递给code参数的延迟函数的执行时间,这对于获取当前内核时间(以纳秒为单位)非常有用:

time:time_init: *delay() functions:
time:time_init: 10ns -> 480ns
time:time_init: 10000us -> 10560ns
time:time_init: 10000000ms -> 10387920ns
time:time_init: *sleep() functions:
time:time_init: 10000us -> 580720ns
time:time_init: 10000000ms -> 17979680ns
time:time_init: 10000000ms -> 17739280ns
time:time_init: 10000000000ms -> 10073738800ns

您可以安全地忽略以下错误消息,如前所述:

insmod: ERROR: could not insert module time.ko: Invalid parameters

请注意,由于ssleep(10)函数的最后调用,提示将在返回之前等待 10 秒,这是不可中断的;因此,即使我们按下Ctrl + C,我们也无法停止执行。

检查前面的输出(来自步骤 2),我们注意到ndelay()对于少量时间来说并不像预期的那样可靠,而udelay()mdelay()效果更好。至于*sleep()函数,我们必须说它们受到机器负载的严重影响,因为它们可以休眠。

另请参阅

  • 有关延迟函数的更多信息,一个很好的起点是内核文档中的linux/Documentation/timers/timers-howto.txt文件。

第七章:高级字符驱动程序操作

在之前的章节中,我们学到了一些在设备驱动程序开发中很有用的东西;然而,还需要一步。我们必须看看如何为我们的字符设备添加高级功能,并充分理解如何将用户空间进程与外围 I/O 活动同步。

在本章中,我们将看到如何为lseek()ioctl()mmap()函数实现系统调用,并且我们还将了解几种技术来使进程进入睡眠状态,以防我们的外围设备尚未有数据返回;因此,在本章中,我们将涵盖以下配方:

  • 在文件中上下移动 lseek()

  • 使用 ioctl()进行自定义命令

  • 使用 mmap()访问 I/O 内存

  • 与进程上下文锁定

  • 锁定(和同步)中断上下文

  • 使用 poll()和 select()等待 I/O 操作

  • 使用 fasync()管理异步通知

技术要求

有关更多信息,请查看本章的附录部分。

本章中使用的代码和其他文件可以从 GitHub 上下载,网址为github.com/giometti/linux_device_driver_development_cookbook/tree/master/chapter_07

在文件中上下移动 lseek()

在这个配方中,我们将更仔细地看一下如何操纵ppos指针(在第三章中的与字符驱动程序交换数据配方中描述),这与read()write()系统调用的实现有关。

准备就绪

为了提供lseek()实现的一个简单示例,我们可以在第四章中的chapter_04/chrdev目录中重用我们的chrdev驱动程序(我们需要 GitHub 存储库的chrdev.cchrdev-req.c文件),在那里我们可以根据我们的设备内存布局简单地添加我们的自定义llseek()方法。

为简单起见,我只是将这些文件复制到chapter_07/chrdev/目录中,并对其进行了重新处理。

我们还需要修改 ESPRESSObin 的 DTS 文件,就像我们在第四章中使用chapter_04/chrdev/add_chrdev_devices.dts.patch文件一样,以启用 chrdev 设备,然后最后,我们可以重用第三章中创建的chrdev_test.c程序,作为我们的lseek()实现测试的基本程序。

关于 ESPRESSObin 的 DTS 文件,我们可以通过进入内核源并执行patch命令来修补它,如下所示:

$ patch -p1 < ../github/chapter_04/chrdev/add_chrdev_devices.dts.patch 
patching file arch/arm64/boot/dts/marvell/armada-3720-espressobin.dts

然后,我们必须重新编译内核,并像我们在第一章中所做的那样,使用前述的 DTS 重新安装内核,最后,重新启动系统。

如何做...

让我们看看如何通过以下步骤来做到这一点:

  1. 首先,我们可以通过添加我们的chrdev_llseek方法来简单地重新定义struct file_operations
static const struct file_operations chrdev_fops = {
    .owner   = THIS_MODULE,
    .llseek  = chrdev_llseek,
    .read    = chrdev_read,
    .write   = chrdev_write,
    .open    = chrdev_open,
    .release = chrdev_release
};
  1. 然后,我们通过使用一个大开关来定义方法的主体,根据驱动程序的内存布局来处理SEEK_SETSEEK_CURSEEK_END可能的值:
static loff_t chrdev_llseek(struct file *filp, loff_t offset, int whence)
{
    struct chrdev_device *chrdev = filp->private_data;
    loff_t newppos;

    dev_info(chrdev->dev, "should move *ppos=%lld by whence %d off=%lld\n",
                filp->f_pos, whence, offset);

    switch (whence) {
    case SEEK_SET:
        newppos = offset; 
        break;

    case SEEK_CUR:
        newppos = filp->f_pos + offset; 
        break;

    case SEEK_END:
        newppos = BUF_LEN + offset; 
        break;

    default:
        return -EINVAL;
    }
  1. 最后,我们必须验证newppos是否仍在 0 和BUF_LEN之间,并且在肯定的情况下,我们必须更新filp->f_posnewppos值,如下所示:
    if ((newppos < 0) || (newppos >= BUF_LEN))
        return -EINVAL;

    filp->f_pos = newppos;
    dev_info(chrdev->dev, "return *ppos=%lld\n", filp->f_pos);

    return newppos;
}

请注意,可以从 GitHub 源中的chapter_07/目录中检索到chrdev.c驱动程序的新版本,该目录与本章相关。

它是如何工作的...

步骤 2中,我们应该记住每个设备有一个BUF_LEN字节的内存缓冲区,因此我们可以通过执行一些简单的操作来计算设备内的新newppos位置。

因此,对于SEEK_SET,将ppos设置为offset,我们可以简单地执行赋值操作;对于SEEK_CUR,将ppos从其当前位置(即filp->f_pos)加上offset字节,我们执行求和操作;最后,对于SEEK_END,将ppos设置为文件末尾加上offset字节,我们仍然执行与BUF_LEN缓冲区大小的求和操作,因为我们期望从用户空间得到负值或零。

还有更多...

如果您现在希望测试lseek()系统调用,我们可以修改之前报告的chrdev_test.c程序,然后尝试在我们的新驱动程序版本上执行它。

因此,让我们使用modify_lseek_to_chrdev_test.patch文件修改chrdev_test.c,如下所示:

$ cd github/chapter_03/
$ patch -p2 < ../chapter_07/chrdev/modify_lseek_to_chrdev_test.patch 

然后,我们必须重新编译它,如下所示:

$ make CFLAGS="-Wall -O2" \
 CC=aarch64-linux-gnu-gcc \
 chrdev_test
aarch64-linux-gnu-gcc -Wall -O2 chrdev_test.c -o chrdev_test

请注意,可以通过简单地删除CC=aarch64-linux-gnu-gcc设置在 ESPRESSObin 上执行此命令。

然后,我们必须将新的chrdev_test可执行文件和具有lseek()支持的chrdev.ko(以及chrdev-req.ko内核模块)移动到 ESPRESSObin,然后将它们插入内核:

# insmod chrdev.ko 
chrdev:chrdev_init: got major 239
# insmod chrdev-req.ko
chrdev cdev-eeprom@2: chrdev cdev-eeprom with id 2 added
chrdev cdev-rom@4: chrdev cdev-rom with id 4 added

这个输出来自串行控制台,因此我们也会得到内核消息。如果您通过 SSH 连接执行这些命令,您将得不到输出,您将不得不使用dmesg命令来获取前面示例中的输出。

最后,我们可以在一个 chrdev 设备上执行chrdev_test程序,如下所示:

# ./chrdev_test /dev/cdev-eeprom\@2 
file /dev/cdev-eeprom@2 opened
wrote 11 bytes into file /dev/cdev-eeprom@2
data written are: 44 55 4d 4d 59 20 44 41 54 41 00 
*ppos moved to 0
read 11 bytes from file /dev/cdev-eeprom@2
data read are: 44 55 4d 4d 59 20 44 41 54 41 00 

正如预期的那样,lseek()系统调用调用了驱动程序的chrdev_llseek()方法,这正是我们所期望的。与前述命令相关的内核消息如下所示:

chrdev cdev-eeprom@2: chrdev (id=2) opened
chrdev cdev-eeprom@2: should write 11 bytes (*ppos=0)
chrdev cdev-eeprom@2: got 11 bytes (*ppos=11)
chrdev cdev-eeprom@2: should move *ppos=11 by whence 0 off=0
chrdev cdev-eeprom@2: return *ppos=0
chrdev cdev-eeprom@2: should read 11 bytes (*ppos=0)
chrdev cdev-eeprom@2: return 11 bytes (*ppos=11)
chrdev cdev-eeprom@2: chrdev (id=2) released

因此,当第一个write()系统调用执行时,ppos从字节 0 移动到字节 11,然后由于lseek()的作用又移回到 0,最后由于read()系统调用的执行又移动到 11。

请注意,我们还可以使用dd命令调用lseek()方法,如下所示:

# dd if=/dev/cdev-eeprom\@2 skip=11 bs=1 count=3 | od -tx1
3+0 records in
3+0 records out
3 bytes copied, 0.0530299 s, 0.1 kB/s
0000000 00 00 00
0000003

在这里,我们打开设备,然后将ppos从开头向前移动 11 个字节,然后对每个进行三次 1 字节长度的读取。

在以下内核消息中,我们可以验证dd程序的行为与预期完全一致:

chrdev cdev-eeprom@2: chrdev (id=2) opened
chrdev cdev-eeprom@2: should move *ppos=0 by whence 1 off=0
chrdev cdev-eeprom@2: return *ppos=0
chrdev cdev-eeprom@2: should move *ppos=0 by whence 1 off=11
chrdev cdev-eeprom@2: return *ppos=11
chrdev cdev-eeprom@2: should read 1 bytes (*ppos=11)
chrdev cdev-eeprom@2: return 1 bytes (*ppos=12)
chrdev cdev-eeprom@2: should read 1 bytes (*ppos=12)
chrdev cdev-eeprom@2: return 1 bytes (*ppos=13)
chrdev cdev-eeprom@2: should read 1 bytes (*ppos=13)
chrdev cdev-eeprom@2: return 1 bytes (*ppos=14)
chrdev cdev-eeprom@2: chrdev (id=2) released

另请参阅

  • 有关lseek()系统调用的更多信息,一个很好的起点是它的 man 页面,可以使用man 2 lseek命令获取。

使用 ioctl()进行自定义命令

在本教程中,我们将看到如何以非常定制的方式添加自定义命令来配置或管理我们的外围设备。

准备工作完成

现在,为了展示如何在我们的驱动程序中实现ioctl()系统调用的简单示例,我们仍然可以使用之前介绍的 chrdev 驱动程序,在其中添加unlocked_ioctl()方法,如后面所述。

如何做...

让我们按照以下步骤来做:

  1. 首先,我们必须在chrdev_fops结构中添加unlocked_ioctl()方法:
static const struct file_operations chrdev_fops = {
    .owner          = THIS_MODULE,
    .unlocked_ioctl = chrdev_ioctl,
    .llseek         = chrdev_llseek,
    .read           = chrdev_read,
    .write          = chrdev_write,
    .open           = chrdev_open,
    .release        = chrdev_release
};
  1. 然后,我们添加方法的主体,在开始时,我们进行了一些赋值和检查,如下所示:
static long chrdev_ioctl(struct file *filp,
                unsigned int cmd, unsigned long arg)
{
    struct chrdev_device *chrdev = filp->private_data;
    struct chrdev_info info;
    void __user *uarg = (void __user *) arg;
    int __user *iuarg = (int __user *) arg;
    int ret;

    /* Get some command information */
    if (_IOC_TYPE(cmd) != CHRDEV_IOCTL_BASE) {
        dev_err(chrdev->dev, "command %x is not for us!\n", cmd);
        return -EINVAL;
    }
    dev_info(chrdev->dev, "cmd nr=%d size=%d dir=%x\n",
                _IOC_NR(cmd), _IOC_SIZE(cmd), _IOC_DIR(cmd));
  1. 然后,我们可以实现一个大开关来执行请求的命令,如下所示:
    switch (cmd) {
    case CHRDEV_IOC_GETINFO:
        dev_info(chrdev->dev, "CHRDEV_IOC_GETINFO\n");

        strncpy(info.label, chrdev->label, NAME_LEN);
        info.read_only = chrdev->read_only;

        ret = copy_to_user(uarg, &info, sizeof(struct chrdev_info));
        if (ret)
            return -EFAULT;

        break;

    case WDIOC_SET_RDONLY:
        dev_info(chrdev->dev, "WDIOC_SET_RDONLY\n");

        ret = get_user(chrdev->read_only, iuarg); 
        if (ret)
            return -EFAULT;

        break;

    default:
        return -ENOIOCTLCMD;
    }

    return 0;
}
  1. 在最后一步中,我们必须定义chrdev_ioctl.h包含文件,以便与用户空间共享,其中包含在前面的代码块中定义的ioctl()命令:
/*
 * Chrdev ioctl() include file
 */

#include <linux/ioctl.h>
#include <linux/types.h>

#define CHRDEV_IOCTL_BASE    'C'
#define CHRDEV_NAME_LEN      32

struct chrdev_info {
    char label[CHRDEV_NAME_LEN];
    int read_only;
};

/*
 * The ioctl() commands
 */

#define CHRDEV_IOC_GETINFO _IOR(CHRDEV_IOCTL_BASE, 0, struct chrdev_info)
#define WDIOC_SET_RDONLY _IOW(CHRDEV_IOCTL_BASE, 1, int)

工作原理...

步骤 2中,将使用infouargiuarg变量,而使用_IOC_TYPE()宏是为了通过检查命令的类型与CHRDEV_IOCTL_BASE定义相比较来验证cmd命令对我们的驱动程序是否有效。

细心的读者应该注意,由于命令类型只是一个随机数,因此此检查并不是绝对可靠的;但是,对于我们在这里的目的来说可能已经足够了。

此外,通过使用_IOC_NR()_IOC_SIZE()_IOC_DIR(),我们可以从命令中提取其他信息,这对进一步的检查可能有用。

步骤 3中,我们可以看到对于每个命令,根据它是读取还是写入(或两者),我们必须通过使用适当的访问函数从用户空间获取或放置用户数据,如第三章中所解释的那样,使用字符驱动程序,以避免内存损坏!

现在应该清楚infouargiuarg变量是如何使用的。第一个用于本地存储struct chrdev_info数据,而其他变量用于具有适当类型的数据,以便与copy_to_user()get_user()函数一起使用。

还有更多...

为了测试代码并查看其行为,我们需要制作一个适当的工具来执行我们的新ioctl()命令。

chrdev_ioctl.c文件中提供了一个示例,并在下面的片段中使用了ioctl()调用:

    /* Try reading device info */
    ret = ioctl(fd, CHRDEV_IOC_GETINFO, &info);
        if (ret < 0) {
            perror("ioctl(CHRDEV_IOC_GETINFO)");
            exit(EXIT_FAILURE);
        }
    printf("got label=%s and read_only=%d\n", info.label, info.read_only);

    /* Try toggling the device reading mode */
    read_only = !info.read_only;
    ret = ioctl(fd, WDIOC_SET_RDONLY, &read_only);
        if (ret < 0) {
            perror("ioctl(WDIOC_SET_RDONLY)");
            exit(EXIT_FAILURE);
        }
    printf("device has now read_only=%d\n", read_only);

现在,让我们在主机 PC 上使用下一个命令行编译chrdev_ioctl.c程序:

$ make CFLAGS="-Wall -O2 -Ichrdev/" \
 CC=aarch64-linux-gnu-gcc \
 chrdev_ioctl aarch64-linux-gnu-gcc -Wall -O2 chrdev_ioctl.c -o chrdev_ioctl 

请注意,这个命令也可以在 ESPRESSObin 上执行,只需删除CC=aarch64-linux-gnu-gcc设置。

现在,如果我们尝试在 chrdev 设备上执行该命令,我们应该得到以下输出:

# ./chrdev_ioctl /dev/cdev-eeprom\@2
file /dev/cdev-eeprom@2 opened
got label=cdev-eeprom and read_only=0
device has now read_only=1

当然,为了使其工作,我们将已经加载了包含ioctl()方法的新 chrdev 驱动程序版本。

在内核消息中,我们应该得到以下内容:

chrdev cdev-eeprom@2: chrdev (id=2) opened
chrdev cdev-eeprom@2: cmd nr=0 size=36 dir=2
chrdev cdev-eeprom@2: CHRDEV_IOC_GETINFO
chrdev cdev-eeprom@2: cmd nr=1 size=4 dir=1
chrdev cdev-eeprom@2: WDIOC_SET_RDONLY
chrdev cdev-eeprom@2: chrdev (id=2) released

如我们所见,在设备打开后,两个ioctl()命令按预期执行。

另请参阅

  • 有关ioctl()系统调用的更多信息,一个很好的起点是它的 man 页面,可以使用man 2 ioctl命令获得。

使用 mmap()访问 I/O 内存

在这个示例中,我们将看到如何映射一个 I/O 内存区域到进程内存空间,以便通过内存中的指针访问我们的外围设备内部。

准备就绪

现在,让我们看看如何为我们的 chrdev 驱动程序实现自定义的mmap()系统调用。

由于我们有一个完全映射到内存中的虚拟设备,我们可以假设struct chrdev_device中的buf缓冲区代表要映射的内存区域。此外,我们需要动态分配它以便重新映射;这是因为内核虚拟内存地址不能使用remap_pfn_range()函数重新映射。

这是remap_pfn_range()的唯一限制,它无法重新映射未动态分配的内核虚拟内存地址。这些地址也可以重新映射,但是使用本书未涵盖的另一种技术。

为了准备我们的驱动程序,我们必须对struct chrdev_device进行以下修改:

diff --git a/chapter_07/chrdev/chrdev.h b/chapter_07/chrdev/chrdev.h
index 6b925fe..40a244f 100644
--- a/chapter_07/chrdev/chrdev.h
+++ b/chapter_07/chrdev/chrdev.h
@@ -7,7 +7,7 @@

 #define MAX_DEVICES 8
 #define NAME_LEN    CHRDEV_NAME_LEN
-#define BUF_LEN     300
+#define BUF_LEN     PAGE_SIZE

 /*
  * Chrdev basic structs
@@ -17,7 +17,7 @@
 struct chrdev_device {
     char label[NAME_LEN];
     unsigned int busy : 1;
-    char buf[BUF_LEN];
+    char *buf;
     int read_only;

     unsigned int id;

请注意,我们还修改了缓冲区大小,至少为PAGE_SIZE长,因为我们不能重新映射小于PAGE_SIZE字节的内存区域。

然后,为了动态分配内存缓冲区,我们必须进行以下列出的修改:

diff --git a/chapter_07/chrdev/chrdev.c b/chapter_07/chrdev/chrdev.c
index 3717ad2..a8bffc3 100644
--- a/chapter_07/chrdev/chrdev.c
+++ b/chapter_07/chrdev/chrdev.c
@@ -7,6 +7,7 @@
 #include <linux/module.h>
 #include <linux/fs.h>
 #include <linux/uaccess.h>
+#include <linux/slab.h>
 #include <linux/mman.h>

@@ -246,6 +247,13 @@ int chrdev_device_register(const char *label, unsigned int 
id,
          return -EBUSY;
      }

+     /* First try to allocate memory for internal buffer */
+     chrdev->buf = kzalloc(BUF_LEN, GFP_KERNEL);
+     if (!chrdev->buf) {
+         dev_err(chrdev->dev, "cannot allocate memory buffer!\n");
+         return -ENOMEM;
+     }
+
      /* Create the device and initialize its data */
      cdev_init(&chrdev->cdev, &chrdev_fops);
      chrdev->cdev.owner = owner;
@@ -255,7 +263,7 @@ int chrdev_device_register(const char *label, unsigned int id,
      if (ret) {
          pr_err("failed to add char device %s at %d:%d\n",
                            label, MAJOR(chrdev_devt), id);
-         return ret;
+         goto kfree_buf;
      }
 chrdev->dev = device_create(chrdev_class, parent, devt, chrdev,

这是前面diff文件的延续:

@@ -272,7 +280,6 @@ int chrdev_device_register(const char *label, unsigned int id,
      chrdev->read_only = read_only;
      chrdev->busy = 1;
      strncpy(chrdev->label, label, NAME_LEN);
-     memset(chrdev->buf, 0, BUF_LEN);

      dev_info(chrdev->dev, "chrdev %s with id %d added\n", label, id);

@@ -280,6 +287,8 @@ int chrdev_device_register(const char *label, unsigned int id,

  del_cdev:
      cdev_del(&chrdev->cdev);
+ kfree_buf:
+     kfree(chrdev->buf);

      return ret;
 }
@@ -309,6 +318,9 @@ int chrdev_device_unregister(const char *label, unsigned int id)

      dev_info(chrdev->dev, "chrdev %s with id %d removed\n", label, id);

+     /* Free allocated memory */
+     kfree(chrdev->buf);
+
        /* Dealocate the device */
        device_destroy(chrdev_class, chrdev->dev->devt);
        cdev_del(&chrdev->cdev);

然而,除了这个小注释,我们可以像之前一样继续,即修改我们的 chrdev 驱动程序并添加新的方法。

如何做...

让我们按照以下步骤来做:

  1. 与前几节一样,第一步是将我们的新mmap()方法添加到驱动程序的struct file_operations中:
static const struct file_operations chrdev_fops = {
    .owner          = THIS_MODULE,
    .mmap           = chrdev_mmap,
    .unlocked_ioctl = chrdev_ioctl,
    .llseek         = chrdev_llseek,
    .read           = chrdev_read,
    .write          = chrdev_write,
    .open           = chrdev_open,
    .release        = chrdev_release
};
  1. 然后,我们添加chrdev_mmap()实现,如前一节中所解释的并在下面报告:
static int chrdev_mmap(struct file *filp, struct vm_area_struct *vma)
{
    struct chrdev_device *chrdev = filp->private_data;
    size_t size = vma->vm_end - vma->vm_start;
    phys_addr_t offset = (phys_addr_t) vma->vm_pgoff << PAGE_SHIFT;
    unsigned long pfn;

    /* Does it even fit in phys_addr_t? */
    if (offset >> PAGE_SHIFT != vma->vm_pgoff)
        return -EINVAL;

    /* We cannot mmap too big areas */
    if ((offset > BUF_LEN) || (size > BUF_LEN - offset))
        return -EINVAL;
  1. 然后,我们必须获取buf缓冲区的物理地址:
    /* Get the physical address belong the virtual kernel address */
    pfn = virt_to_phys(chrdev->buf) >> PAGE_SHIFT;

请注意,如果我们只想重新映射外围设备映射的物理地址,则不需要这一步。

  1. 最后,我们可以进行重新映射:
    /* Remap-pfn-range will mark the range VM_IO */
    if (remap_pfn_range(vma, vma->vm_start,
                pfn, size,
                vma->vm_page_prot))
        return -EAGAIN;

    return 0;
}

它是如何工作的...

步骤 2中,函数从一些健全性检查开始,我们必须验证所请求的内存区域是否与系统和外围设备的要求兼容。在我们的示例中,我们必须验证内存区域的大小和偏移量,以及映射开始的位置是否在buf的大小(BUF_LEN字节)内。

还有更多...

为了测试我们的新的mmap()实现,我们可以使用之前介绍的chrdev_mmap.c程序。在这里我们谈到了textfile.txt。要编译它,我们可以在主机 PC 上使用以下命令:

$ make CFLAGS="-Wall -O2" \
 CC=aarch64-linux-gnu-gcc \
 chrdev_mmap
aarch64-linux-gnu-gcc -Wall -O2 chrdev_mmap.c -o chrdev_mmap

请注意,可以通过简单删除CC=aarch64-linux-gnu-gcc设置在 ESPRESSObin 中执行此命令。

现在,让我们开始在驱动程序中写点东西:

# cp textfile.txt /dev/cdev-eeprom\@2

内核消息如下:

chrdev cdev-eeprom@2: chrdev (id=2) opened
chrdev cdev-eeprom@2: chrdev (id=2) released
chrdev cdev-eeprom@2: chrdev (id=2) opened
chrdev cdev-eeprom@2: should write 54 bytes (*ppos=0)
chrdev cdev-eeprom@2: got 54 bytes (*ppos=54)
chrdev cdev-eeprom@2: chrdev (id=2) released

现在,如预期的那样,在我们的内存缓冲区中有textfile.txt的内容;实际上:

# cat /dev/cdev-eeprom\@2 
This is a test file

This is line 3.

End of the file

现在我们可以尝试在我们的设备上执行chrdev_mmap程序,以验证一切是否正常工作:

# ./chrdev_mmap /dev/cdev-eeprom\@2 54
file /dev/cdev-eeprom@2 opened
got address=0xffff9896c000 and len=54
---
This is a test file

This is line 3.

End of the file

请注意,我们必须确保不指定大于设备缓冲区大小的值,例如在我们的示例中为 4,096。实际上,如果我们这样做,会出现错误:

./chrdev_mmap /dev/cdev-eeprom\@2 4097

file /dev/cdev-eeprom@2 opened

mmap: Invalid argument

这意味着我们成功了!请注意,chrdev_mmap程序(如cpcat)在通常文件和我们的字符设备上的工作完全相同。

mmap()执行相关的内核消息如下:

chrdev cdev-eeprom@2: chrdev (id=2) opened
chrdev cdev-eeprom@2: mmap vma=ffff9896c000 pfn=79ead size=1000
chrdev cdev-eeprom@2: chrdev (id=2) released

请注意,在重新映射之后,程序不执行任何系统调用来访问数据。这导致在获取对设备数据的访问权限时,可能会比我们需要使用read()write()系统调用的情况下性能更好。

我们还可以通过向chrdev_mmap程序添加可选参数0来修改缓冲区内容,如下所示:

./chrdev_mmap /dev/cdev-eeprom\@2 54 0
file /dev/cdev-eeprom@2 opened
got address=0xffff908ef000 and len=54
---
This is a test file

This is line 3.

End of the file
---
First character changed to '0'

然后,当我们使用read()系统调用和cat命令再次读取缓冲区时,我们可以看到文件中的第一个字符已经按预期更改为 0:

# cat /dev/cdev-eeprom\@2 
0his is a test file

This is line 3.

End of the file

另请参阅

使用进程上下文进行锁定

在这个示例中,我们将看到如何保护数据,以防止两个或更多进程并发访问,以避免竞争条件。

如何做...

为了简单地演示如何向 chrdev 驱动程序添加互斥体,我们可以对其进行一些修改,如下所示。

  1. 首先,我们必须在chrdev.h头文件中的驱动程序主结构中添加mux互斥体,如下所示:
/* Main struct */
struct chrdev_device {
    char label[NAME_LEN];
    unsigned int busy : 1;
    char *buf;
    int read_only;

    unsigned int id;
    struct module *owner;
    struct cdev cdev;
    struct device *dev;

    struct mutex mux;
};

这里介绍的所有修改都可以应用于 chrdev 代码,使用add_mutex_to_chrdev.patch文件中的patch命令,如下所示:

$ patch -p3 < add_mutex_to_chrdev.patch

  1. 然后,在chrdev_device_register()函数中,我们必须使用mutex_init()函数初始化互斥体:
    /* Init the chrdev data */
    chrdev->id = id;
    chrdev->read_only = read_only;
    chrdev->busy = 1;
    strncpy(chrdev->label, label, NAME_LEN);
    mutex_init(&chrdev->mux);

    dev_info(chrdev->dev, "chrdev %s with id %d added\n", label, id);

    return 0;
  1. 接下来,我们可以修改read()write()方法以保护它们。然后,read()方法应该如下所示:
static ssize_t chrdev_read(struct file *filp,
               char __user *buf, size_t count, loff_t *ppos)
{
    struct chrdev_device *chrdev = filp->private_data;
    int ret;

    dev_info(chrdev->dev, "should read %ld bytes (*ppos=%lld)\n",
                count, *ppos);
    mutex_lock(&chrdev->mux); // Grab the mutex

    /* Check for end-of-buffer */
    if (*ppos + count >= BUF_LEN)
        count = BUF_LEN - *ppos;

    /* Return data to the user space */
    ret = copy_to_user(buf, chrdev->buf + *ppos, count);
    if (ret < 0) {
        count = -EFAULT;
        goto unlock;
    }

    *ppos += count;
    dev_info(chrdev->dev, "return %ld bytes (*ppos=%lld)\n", count, *ppos);

unlock:
    mutex_unlock(&chrdev->mux); // Release the mutex

    return count;
}

write()方法报告如下:

static ssize_t chrdev_write(struct file *filp,
                const char __user *buf, size_t count, loff_t *ppos)
{
    struct chrdev_device *chrdev = filp->private_data;
    int ret;

    dev_info(chrdev->dev, "should write %ld bytes (*ppos=%lld)\n",
                count, *ppos);

    if (chrdev->read_only)
        return -EINVAL;

    mutex_lock(&chrdev->mux); // Grab the mutex

    /* Check for end-of-buffer */
    if (*ppos + count >= BUF_LEN)
        count = BUF_LEN - *ppos;

    /* Get data from the user space */
    ret = copy_from_user(chrdev->buf + *ppos, buf, count);
    if (ret < 0) {
        count = -EFAULT;
        goto unlock;
    }

    *ppos += count;
    dev_info(chrdev->dev, "got %ld bytes (*ppos=%lld)\n", count, *ppos);

unlock:
    mutex_unlock(&chrdev->mux); // Release the mutex

    return count;
}
  1. 最后,我们还必须保护ioctl()方法,因为驱动程序的read_only属性可能会改变:
static long chrdev_ioctl(struct file *filp,
            unsigned int cmd, unsigned long arg)
{
    struct chrdev_device *chrdev = filp->private_data;
    struct chrdev_info info;
    void __user *uarg = (void __user *) arg;
    int __user *iuarg = (int __user *) arg;
    int ret;

...

    /* Grab the mutex */
    mutex_lock(&chrdev->mux);

    switch (cmd) {
    case CHRDEV_IOC_GETINFO:
        dev_info(chrdev->dev, "CHRDEV_IOC_GETINFO\n");

...

    default:
        ret = -ENOIOCTLCMD;
        goto unlock;
    }
    ret = 0;

unlock:
    /* Release the mutex */
    mutex_unlock(&chrdev->mux);

    return ret;
}

这确实是一个愚蠢的例子,但你应该考虑即使ioctl()方法也可能改变驱动程序的数据缓冲区或其他共享数据的情况。

这一次,我们删除了所有的return语句,改用goto

工作原理...

展示代码的工作原理是非常困难的,因为在复制竞争条件时存在固有的困难,所以最好讨论一下我们可以从中期望什么。

但是,您仍然被鼓励测试代码,也许尝试编写一个更复杂的驱动程序,如果不正确地使用互斥体来管理并发,可能会成为一个真正的问题。

步骤 1中,我们为系统中可能有的每个 chrdev 设备添加了一个互斥体。然后,在步骤 2中初始化后,我们可以有效地使用它,如步骤 3步骤 4中所述。

通过使用mutex_lock()函数,实际上告诉内核没有其他进程可以在这一点之后并发地进行,以确保只有一个进程可以管理驱动程序的共享数据。如果其他进程确实尝试在第一个进程已经持有互斥锁的情况下获取互斥锁,新进程将在它尝试获取已锁定的互斥锁的确切时刻被放入等待队列中进入睡眠状态。

完成后,通过使用mutex_unlock(),我们通知内核mux互斥锁已被释放,因此,任何等待(即睡眠)的进程将被唤醒;然后,一旦最终重新调度运行,它可以继续并尝试,反过来,抓住锁。

请注意,在步骤 3中,在两个函数中,我们在真正有用的时候才抓住互斥锁,而不是在它们的开始;实际上,我们应该尽量保持锁定尽可能小,以保护共享数据(在我们的例子中,ppos指针和buf数据缓冲区)。通过这样做,我们将我们选择的互斥排除机制的使用限制在代码的最小可能部分(临界区),这个临界区访问我们想要保护免受在先前指定的条件下发生的竞争条件引入的可能破坏。

还要注意的是,我们必须小心,不要在释放锁之前返回,否则新的访问进程将挂起!这就是为什么我们删除了所有的return语句,除了最后一个,并且使用goto语句跳转到unlock标签。

另请参阅

  • 有关互斥锁和锁定的更多信息,请参阅内核文档目录中的linux/Documentation/locking/mutex-design.txt

使用中断上下文进行锁定(和同步)

现在,让我们看看如何避免进程上下文和中断上下文之间的竞争条件。然而,这一次我们必须比以前更加注意,因为这一次我们必须实现一个锁定机制来保护进程上下文和中断上下文之间的共享数据。但是,我们还必须为读取进程和驱动程序之间提供同步机制,以允许读取进程在驱动程序的队列中存在要读取的数据时继续执行其操作。

为了解释这个问题,最好做一个实际的例子。假设我们有一个生成数据供读取进程使用的外围设备。为了通知新数据已到达,外围设备向 CPU 发送中断,因此我们可以想象使用循环缓冲区来实现我们的驱动程序,其中中断处理程序将数据从外围设备保存到缓冲区中,并且任何读取进程可以从中获取数据。

循环缓冲区(也称为环形缓冲区)是固定大小的缓冲区,其工作方式就好像内存是连续的,所有内存位置都以循环方式处理。随着信息从缓冲区生成和消耗,不需要重新整理;我们只需调整头指针和尾指针。添加数据时,头指针前进,而消耗数据时,尾指针前进。如果到达缓冲区的末尾,那么每个指针都会简单地回到环的起始位置。

在这种情况下,我们必须保护循环缓冲区免受进程和中断上下文的竞争条件,因为两者都可以访问它,但我们还必须提供同步机制,以便在没有可供读取的数据时使任何读取进程进入睡眠状态!

第五章中,管理中断和并发,我们介绍了自旋锁,它可以用于在进程和中断上下文之间放置锁定机制;我们还介绍了等待队列,它可以用于将读取进程与中断处理程序同步。

准备工作

这一次,我们必须使用我们 chrdev 驱动程序的修改版本。在 GitHub 存储库的chapter_07/chrdev/目录中,我们可以找到实现我们修改后的驱动程序的chrdev_irq.cchrdev_irq.h文件。

我们仍然可以使用chrdev-req.ko在系统中生成 chrdev 设备,但现在内核模块将使用chrdev_irq.ko而不是chrdev.ko

此外,由于我们有一个真正的外围设备,我们可以使用内核定时器来模拟 IRQ(请参阅第五章管理中断和并发性),该定时器还使用以下get_new_char()函数触发数据生成:

/*
 * Dummy function to generate data
 */

static char get_new_char(void)
{
    static char d = 'A' - 1;

    if (++d == ('Z' + 1))
        d = 'A';

    return d;
}

该功能每次调用时都会简单地从 A 到 Z 生成一个新字符,在生成 Z 后重新从字符 A 开始。

为了集中精力关注驱动程序的锁定和同步机制,我们在这里介绍了一些有用的函数来管理循环缓冲区,这是不言自明的。以下是两个检查缓冲区是否为空或已满的函数:

/*
 * Circular buffer management functions
 */

static inline bool cbuf_is_empty(size_t head, size_t tail,
                                 size_t len)
{
    return head == tail;
}

static inline bool cbuf_is_full(size_t head, size_t tail,
                                 size_t len)
{
    head = (head + 1) % len;
    return head == tail;
}

然后,有两个函数来检查缓冲区的内存区域直到末尾有多少数据或多少空间可用。当我们必须使用memmove()等函数时,它们非常有用:

static inline size_t cbuf_count_to_end(size_t head, size_t tail,
                                  size_t len)
{
    if (head >= tail)
        return head - tail;
    else
        return len - tail + head;
}

static inline size_t cbuf_space_to_end(size_t head, size_t tail,
                                  size_t len)
{
    if (head >= tail)
        return len - head + tail - 1;
    else
        return tail - head - 1;
}

最后,我们可以使用函数正确地向前移动头部或尾部指针,以便在缓冲区末尾时重新开始:

static inline void cbuf_pointer_move(size_t *ptr, size_t n,
                                 size_t len)
{
    *ptr = (*ptr + n) % len;
}

如何做...

让我们按照以下步骤来做:

  1. 第一步是通过添加mux互斥锁(与以前一样)、lock自旋锁、内核timer和等待队列queue来重写我们驱动程序的主要结构,如下所示:
 /* Main struct */
struct chrdev_device {
    char label[NAME_LEN];
    unsigned int busy : 1;
    char *buf;
    size_t head, tail;
    int read_only;

    unsigned int id;
    struct module *owner;
    struct cdev cdev;
    struct device *dev;

    struct mutex mux;
    struct spinlock lock;
    struct wait_queue_head queue;
    struct hrtimer timer;
};
  1. 然后,在chrdev_device_register()函数中进行设备分配期间必须对其进行初始化,如下所示:
    /* Init the chrdev data */
    chrdev->id = id;
    chrdev->read_only = read_only;
    chrdev->busy = 1;
    strncpy(chrdev->label, label, NAME_LEN);
    mutex_init(&chrdev->mux);
    spin_lock_init(&chrdev->lock);
    init_waitqueue_head(&chrdev->queue);
    chrdev->head = chrdev->tail = 0;

    /* Setup and start the hires timer */
    hrtimer_init(&chrdev->timer, CLOCK_MONOTONIC,
                        HRTIMER_MODE_REL | HRTIMER_MODE_SOFT);
    chrdev->timer.function = chrdev_timer_handler;
    hrtimer_start(&chrdev->timer, ns_to_ktime(delay_ns),
                        HRTIMER_MODE_REL | HRTIMER_MODE_SOFT);
  1. 现在,read()方法的可能实现如下代码片段所示。我们首先获取互斥锁,以对其他进程进行第一次锁定:
static ssize_t chrdev_read(struct file *filp,
                           char __user *buf, size_t count, loff_t *ppos)
{
    struct chrdev_device *chrdev = filp->private_data;
    unsigned long flags;
    char tmp[256];
    size_t n;
    int ret;

    dev_info(chrdev->dev, "should read %ld bytes\n", count);

    /* Grab the mutex */
    mutex_lock(&chrdev->mux);

现在,我们可以确信没有其他进程可以超越这一点,但是在中断上下文中运行的一些核心仍然可以这样做!

  1. 这就是为什么我们需要以下步骤来确保它们与中断上下文同步:
    /* Check for some data into read buffer */
    if (filp->f_flags & O_NONBLOCK) {
        if (cbuf_is_empty(chrdev->head, chrdev->tail, BUF_LEN)) {
            ret = -EAGAIN;
            goto unlock;
        }
    } else if (wait_event_interruptible(chrdev->queue,
        !cbuf_is_empty(chrdev->head, chrdev->tail, BUF_LEN))) {
        count = -ERESTARTSYS;
        goto unlock; 
    }

    /* Grab the lock */
    spin_lock_irqsave(&chrdev->lock, flags);
  1. 当我们获取了锁时,我们可以确信我们是唯一的读取进程,并且我们也受到中断上下文的保护;因此,我们可以安全地从循环缓冲区读取数据,然后释放锁,如下所示:
    /* Get data from the circular buffer */
    n = cbuf_count_to_end(chrdev->head, chrdev->tail, BUF_LEN);
    count = min(count, n); 
    memcpy(tmp, &chrdev->buf[chrdev->tail], count);

    /* Release the lock */
    spin_unlock_irqrestore(&chrdev->lock, flags);

请注意,我们必须将数据从循环缓冲区复制到本地缓冲区,而不是直接复制到用户空间缓冲区buf,使用copy_to_user()函数;这是因为此函数可能会进入睡眠状态,而在我们睡眠时持有自旋锁是不好的!

  1. 自旋锁释放后,我们可以安全地调用copy_to_user()将数据发送到用户空间:
    /* Return data to the user space */
    ret = copy_to_user(buf, tmp, count);
    if (ret < 0) {
        ret = -EFAULT;
        goto unlock; 
    }
  1. 最后,在释放互斥锁之前,我们必须更新循环缓冲区的tail指针,如下所示:
    /* Now we can safely move the tail pointer */
    cbuf_pointer_move(&chrdev->tail, count, BUF_LEN);
    dev_info(chrdev->dev, "return %ld bytes\n", count);

unlock:
    /* Release the mutex */
    mutex_unlock(&chrdev->mux);

    return count;
}

请注意,由于在进程上下文中只有读取器,它们是唯一移动tail指针的进程(或者中断处理程序这样做——请参见下面的代码片段),我们可以确信一切都会正常工作。

  1. 最后,中断处理程序(在我们的情况下,它是由内核定时器处理程序模拟的)如下所示:
static enum hrtimer_restart chrdev_timer_handler(struct hrtimer *ptr)
{
    struct chrdev_device *chrdev = container_of(ptr,
                    struct chrdev_device, timer);

    spin_lock(&chrdev->lock);    /* grab the lock */ 

    /* Now we should check if we have some space to
     * save incoming data, otherwise they must be dropped...
     */
    if (!cbuf_is_full(chrdev->head, chrdev->tail, BUF_LEN)) {
        chrdev->buf[chrdev->head] = get_new_char();

        cbuf_pointer_move(&chrdev->head, 1, BUF_LEN);
    }
    spin_unlock(&chrdev->lock);  /* release the lock */

    /* Wake up any possible sleeping process */
    wake_up_interruptible(&chrdev->queue);

    /* Now forward the expiration time and ask to be rescheduled */
    hrtimer_forward_now(&chrdev->timer, ns_to_ktime(delay_ns));
    return HRTIMER_RESTART;
}

处理程序的主体很简单:它获取锁,然后将单个字符添加到循环缓冲区。

请注意,在这里,由于我们有一个真正的外围设备,我们只是丢弃数据;在实际情况下,驱动程序开发人员可能需要采取任何必要的措施来防止数据丢失,例如停止外围设备,然后以某种方式向用户空间发出此错误条件的信号!

此外,在退出之前,它使用wake_up_interruptible()函数唤醒等待队列上可能正在睡眠的进程。

工作原理...

这些步骤相当不言自明。但是,在步骤 4中,我们执行了两个重要步骤:第一个是如果循环缓冲区为空,则挂起进程,如果不是,则使用中断上下文抓取锁,因为我们将要访问循环缓冲区。

O_NONBLOCK标志的检查只是为了遵守read()的行为,即如果使用了O_NONBLOCK标志,那么它应该继续进行,然后如果没有数据可用,则返回EAGAIN错误。

请注意,在检查缓冲区是否为空之前,可以安全地获取锁,因为如果我们决定缓冲区为空,但同时到达了一些新数据并且O_NONBLOCK处于活动状态,我们只需返回EAGAIN(向读取进程发出重新执行操作的信号)。如果不是,我们会在等待队列上睡眠,然后会被中断处理程序唤醒(请参阅以下信息)。在这两种情况下,我们的操作都是正确的。

还有更多...

如果您希望测试代码,请编译代码并将其插入 ESPRESSObin 中:

# insmod chrdev_irq.ko 
chrdev_irq:chrdev_init: got major 239
# insmod chrdev-req.ko 
chrdev cdev-eeprom@2: chrdev cdev-eeprom with id 2 added
chrdev cdev-rom@4: chrdev cdev-rom with id 4 added

现在我们的外围设备已启用(内核定时器已在chrdev_device_register()函数中的步骤 2中启用),并且应该已经有一些数据可供读取;实际上,如果我们通过使用cat命令在驱动程序上进行read(),我们会得到以下结果:

# cat /dev/cdev-eeprom\@2 
ACEGIKMOQSUWYACEGIKMOQSUWYACEGIKMOQSUWYACEGIKMOQSUWYACEGIKMOQSUWYACEGIKMOQSUWYACEGIKMOQSUW

在这里,我们应该注意,由于我们在系统中定义了两个设备(请参阅本章开头使用的chapter_04/chrdev/add_chrdev_devices.dts.patch DTS 文件),因此get_new_char()函数每秒执行两次,这就是为什么我们得到序列ACE...而不是ABC...

在这里,一个很好的练习是修改驱动程序,当第一次打开驱动程序时启动内核定时器,然后在最后一次释放时停止它。此外,您可以尝试为每个系统中的设备提供一个每设备的get_new_char()函数来生成正确的序列(ABC...)。

相应的内核消息如下所示:

chrdev cdev-eeprom@2: chrdev (id=2) opened
chrdev cdev-eeprom@2: should read 131072 bytes
chrdev cdev-eeprom@2: return 92 bytes

在这里,由于步骤 3步骤 7read()系统调用使调用进程进入睡眠状态,然后一旦数据到达就立即返回新数据。

实际上,如果我们等一会儿,我们会看到以下内核消息每秒获得一个新字符:

...
[ 227.675229] chrdev cdev-eeprom@2: should read 131072 bytes
[ 228.292171] chrdev cdev-eeprom@2: return 1 bytes
[ 228.294129] chrdev cdev-eeprom@2: should read 131072 bytes
[ 229.292156] chrdev cdev-eeprom@2: return 1 bytes
...

我留下了时间,以便了解生成每条消息的时间。

这种行为是由步骤 8引起的,内核定时器生成新数据。

另请参阅

  • 有关自旋锁和锁定的更多信息,请参阅内核文档目录中的linux/Documentation/locking/spinlocks.txt

使用 poll()和 select()等待 I/O 操作

在本教程中,我们将了解如何要求内核在我们的驱动程序有新数据可供读取(或愿意接受新数据进行写入)时为我们检查,然后唤醒读取(或写入)进程,而不会在 I/O 操作上被阻塞。

做好准备

要测试我们的实现,我们仍然可以像以前一样使用chrdev_irq.c驱动程序;这是因为我们可以使用内核定时器模拟的新数据事件。

如何做...

让我们看看如何通过以下步骤来做到这一点:

  1. 首先,我们必须在驱动程序的struct file_operations中添加我们的新chrdev_poll()方法:
static const struct file_operations chrdev_fops = {
    .owner   = THIS_MODULE,
    .poll    = chrdev_poll,
    .llseek  = no_llseek,
    .read    = chrdev_read,
    .open    = chrdev_open,
    .release = chrdev_release
};
  1. 然后,实现如下。我们首先通过将当前设备chrdev->queue的等待队列传递给poll_wait()函数:
static __poll_t chrdev_poll(struct file *filp, poll_table *wait)
{
    struct chrdev_device *chrdev = filp->private_data;
    __poll_t mask = 0;

    poll_wait(filp, &chrdev->queue, wait);
  1. 最后,在检查循环缓冲区不为空并且我们可以继续从中读取数据之前,我们抓住互斥锁:
    /* Grab the mutex */
    mutex_lock(&chrdev->mux);

    if (!cbuf_is_empty(chrdev->head, chrdev->tail, BUF_LEN))
        mask |= EPOLLIN | EPOLLRDNORM;

    /* Release the mutex */
    mutex_unlock(&chrdev->mux);

    return mask;
}

请注意,抓取自旋锁也是不必要的。这是因为如果缓冲区为空,当新数据通过中断(在我们的模拟中是内核定时器)处理程序到达时,我们将得到通知。这将反过来调用wake_up_interruptible(&chrdev->queue),它作用于我们之前提供给poll_wait()函数的等待队列。另一方面,如果缓冲区不为空,它不可能在中断上下文中变为空,因此我们根本不可能有任何竞争条件。

还有更多...

与以前一样,如果我们希望测试代码,我们需要实现一个适当的工具来执行我们的新poll()方法。当我们将其添加到驱动程序中时,我们将获得poll()select()系统调用支持;select()的使用示例在chrdev_select.c文件中报告,在下面,有一个片段中使用了select()调用:

    while (1) {
        /* Set up reading file descriptors */
        FD_ZERO(&read_fds);
        FD_SET(STDIN_FILENO, &read_fds);
        FD_SET(fd, &read_fds);

        /* Wait for any data from our device or stdin */
        ret = select(FD_SETSIZE, &read_fds, NULL, NULL, NULL);
        if (ret < 0) {
            perror("select");
            exit(EXIT_FAILURE);
        }

        if (FD_ISSET(STDIN_FILENO, &read_fds)) {
            ret = read(STDIN_FILENO, &c, 1);
            if (ret < 0) { 
                perror("read(STDIN, ...)");
                exit(EXIT_FAILURE);
            }
            printf("got '%c' from stdin!\n", c);
        }
 ...

    }

正如我们所看到的,这个程序将使用select()系统调用来监视我们进程的标准输入通道(名为stdin)和字符设备,select()系统调用又调用我们在步骤 2步骤 3中实现的新poll()方法。

现在,让我们在我们的主机 PC 上使用下一个命令行编译chrdev_select.c程序:

$ make CFLAGS="-Wall -O2 -Ichrdev/" \
 CC=aarch64-linux-gnu-gcc \
 chrdev_select aarch64-linux-gnu-gcc -Wall -O2 chrdev_ioctl.c -o chrdev_select

请注意,这个命令可以通过简单地删除CC=aarch64-linux-gnu-gcc设置在 ESPRESSObin 上执行。

现在,如果我们尝试在 chrdev 设备上执行该命令,我们应该会得到这个输出:

# ./chrdev_select /dev/cdev-eeprom\@2
file /dev/cdev-eeprom@2 opened
got 'K' from device!
got 'M' from device!
got 'O' from device!
got 'Q' from device!
...

当然,我们已经加载了包含poll()方法的chrdev_irq驱动程序。

如果我们尝试从标准输入插入一些字符,如下所示,我们可以看到当设备有新数据时,进程可以安全地对其进行读取而不会阻塞,而当标准输入有新数据时,进程也可以做同样的事情,同样也不会阻塞:

...
got 'Y' from device!
got 'A' from device!
TEST
got 'T' from stdin!
got 'E' from stdin!
got 'S' from stdin!
got 'T' from stdin!
got '
' from stdin!
got 'C' from device!
got 'E' from device!
...

另请参阅

  • 有关poll()select()的更多信息,一个很好的起点是它们的 man 页面(man 2 pollman 2 select)。

使用fasync()管理异步通知

在这个示例中,我们将看到如何在我们的驱动程序有新数据要读取时(或者愿意接受来自用户空间的新数据)生成异步的SIGIO信号。

准备工作

与以前一样,我们仍然可以使用chrdev_irq.c驱动程序来展示我们的实现。

如何做...

让我们看看如何通过以下步骤来做到:

  1. 首先,我们必须在驱动程序的struct file_operations中添加我们的新chrdev_fasync()方法:
static const struct file_operations chrdev_fops = {
    .owner   = THIS_MODULE,
    .fasync  = chrdev_fasync,
    .poll    = chrdev_poll,
    .llseek  = no_llseek,
    .read    = chrdev_read,
    .open    = chrdev_open,
    .release = chrdev_release
};
  1. 实现如下:
static int chrdev_fasync(int fd, struct file *filp, int on)
{
    struct chrdev_device *chrdev = filp->private_data;

    return fasync_helper(fd, filp, on, &chrdev->fasync_queue);
}
  1. 最后,我们必须在我们的(模拟的)中断处理程序中添加kill_fasync()调用,以表示由于有新数据准备好被读取,可以发送SIGIO信号:
static enum hrtimer_restart chrdev_timer_handler(struct hrtimer *ptr)
{
    struct chrdev_device *chrdev = container_of(ptr,
                                    struct chrdev_device, timer);

...
    /* Wake up any possible sleeping process */
    wake_up_interruptible(&chrdev->queue);
    kill_fasync(&chrdev->fasync_queue, SIGIO, POLL_IN);

    /* Now forward the expiration time and ask to be rescheduled */
    hrtimer_forward_now(&chrdev->timer, ns_to_ktime(delay_ns));
    return HRTIMER_RESTART;
}

还有更多...

如果您希望测试代码,您需要实现一个适当的工具来执行所有步骤,以要求内核接收SIGIO信号。下面报告了chrdev_fasync.c程序的片段,其中执行了所需的操作:

    /* Try to install the signal handler and the fasync stuff */
    sigh = signal(SIGIO, sigio_handler);
    if (sigh == SIG_ERR) {
            perror("signal");
            exit(EXIT_FAILURE);
    }
    ret = fcntl(fd, F_SETOWN, getpid());
    if (ret < 0) {
            perror("fcntl(..., F_SETOWN, ...)");
            exit(EXIT_FAILURE);
    }
    flags = fcntl(fd, F_GETFL);
    if (flags < 0) {
            perror("fcntl(..., F_GETFL)");
            exit(EXIT_FAILURE);
    }
    ret = fcntl(fd, F_SETFL, flags | FASYNC);
    if (flags < 0) {
            perror("fcntl(..., F_SETFL, ...)");
            exit(EXIT_FAILURE);
    }

这段代码是要求内核调用我们在步骤 2中实现的fasync()方法。然后,每当有新数据到达时,由于步骤 3SIGIO信号将发送到我们的进程,并且信号处理程序sigio_handler()将被执行,即使进程被挂起,例如,在读取另一个文件描述符时。

void sigio_handler(int unused) {
    char c;
    int ret;

    ret = read(fd, &c, 1);
    if (ret < 0) {
        perror("read");
        exit(EXIT_FAILURE);
    }
    ret = write(STDOUT_FILENO, &c, 1);
    if (ret < 0) {
        perror("write");
        exit(EXIT_FAILURE);
    }
}

现在,让我们在我们的主机 PC 上使用下一个命令行编译chrdev_fasync.c程序:

$ make CFLAGS="-Wall -O2 -Ichrdev/" \
 CC=aarch64-linux-gnu-gcc \
 chrdev_fasync aarch64-linux-gnu-gcc -Wall -O2 chrdev_ioctl.c -o chrdev_fasync

请注意,这个命令可以通过简单地删除CC=aarch64-linux-gnu-gcc设置在 ESPRESSObin 上执行。

现在,如果我们尝试在 chrdev 设备上执行该命令,我们应该会得到以下输出:

# ./chrdev_fasync /dev/cdev-eeprom\@2 
file /dev/cdev-eeprom@2 opened
QSUWYACEGI

当然,我们已经加载了包含fasync()方法的chrdev_irq驱动程序。

在这里,进程在标准输入上的read()上被挂起,每当信号到达时,信号处理程序被执行并且新数据被读取。然而,当我们尝试向标准输入发送一些字符时,进程会如预期地读取它们:

# ./chrdev_fasync /dev/cdev-eeprom\@2 
file /dev/cdev-eeprom@2 opened
QSUWYACEGIKMOQS....
got '.' from stdin!
got '.' from stdin!
got '.' from stdin!
got '.' from stdin!
got '
' from stdin!
UWYACE

另请参阅

  • 有关fasync()方法或fcntl()系统调用的更多信息,一个很好的起点是man 2 fcntl手册页。

第八章:附加信息:使用字符驱动程序

与字符驱动程序交换数据

与外围设备交换数据意味着向其发送或接收数据,为此,我们已经看到我们必须使用在内核中定义的write()read()系统调用的原型。

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

另一方面,它们在用户空间中的对应形式如下:

ssize_t write(int fd, const void *buf, size_t count);
ssize_t read(int fd, void *buf, size_t count);

前面的原型(无论是在内核空间还是用户空间)看起来很相似,但是它们的含义当然是不同的,作为驱动程序开发人员,我们必须完全了解这些含义,以便准确地完成我们的工作。

让我们从write()开始;当我们从用户空间程序调用write()系统调用时,我们必须提供一个文件描述符fd;一个填充有要写入的数据的缓冲区buf;以及缓冲区大小count。然后,系统调用返回一个值,可以是负值(如果有错误),正值(表示实际写入的字节数),或零(表示没有写入任何内容)。

请注意,count并不代表我们希望写入多少字节,而只是缓冲区的大小!实际上,write()可以返回一个小于count的正值。这就是为什么我在chrdev_test.c程序的write()系统调用中使用了for()循环!实际上,如果我必须写入一个长度为 10 字节的缓冲区,而write()返回了 4 字节,我必须重复调用它,直到剩下的 6 字节都被写入。

从内核空间的角度来看,我们将文件描述符fd视为struct file *filp(存储有关文件描述符的内核信息),而数据缓冲区由buf指针和count变量指定(暂时不考虑ppos指针;稍后将对其进行解释)。

write()内核原型中可以看出,buf参数带有__user属性,这表明这个缓冲区来自用户空间,因此我们不能直接从中读取。实际上,这个内存区域是虚拟的,因此在执行我们的驱动程序的write()方法时,它实际上不能映射到真正的物理内存中!为了解决这种情况,内核提供了copy_from_user()函数,如下所示:

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

正如我们所看到的,这个函数从用户空间缓冲区from中获取数据,然后在验证指向from的内存区域可以进行读取后,将它们复制到指向to的缓冲区中。一旦数据被传输到内核空间(在to指向的缓冲区内),我们的驱动程序就可以自由访问它。

对于read()系统调用,执行相同的步骤(即使是相反的方向)。我们仍然有一个文件描述符fd;一个缓冲区buf,用于存放读取的数据,以及它的count大小。然后,系统调用返回一个值,可以是负值(如果有错误),正值(表示实际读取的字节数),或零(表示我们已经到达文件末尾)。

再次,我们应该注意count不是我们希望读取的字节数,而只是缓冲区的大小。实际上,read()可以返回小于count的正值,这就是为什么我在chrdev_test.c程序中将其放在for()循环中的原因。

与前面的write()情况相比,read()系统调用还可以返回0,表示文件末尾;也就是说,从这个文件描述符中没有更多的数据可用,我们应该停止读取。

与前面的write()情况一样,buf指向的缓冲区仍然带有__user属性,这意味着要从中读取数据,我们必须使用copy_to_user()函数,其定义如下:

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

copy_from_user()copy_to_user()都在linux/include/linux/uaccess.h文件中定义。

现在,在本节结束之前,我们必须花一些时间来讨论内核原型中存在的ppos指针。

当我们希望读取文件中存储的一些数据时,我们必须多次使用 read() 系统调用(特别是如果文件很大而我们的内存缓冲区很小)。为了做到这一点,我们希望简单地多次调用 read() 而不必担心在每个前一次迭代中到达的位置;例如,如果我们有一个大小为 16 KB 的文件,并且希望使用 4 KB 内存缓冲区来读取它,我们只需调用 read() 系统调用四次,但是每次调用应该如何知道前一个调用完成了它的工作?嗯,这个任务被分配给了 ppos 指针:当文件被打开时,它开始指向文件的第一个字节(索引为 0),然后每次调用 read() 时,系统调用本身将它移动到下一个位置,以便接下来的 read() 调用知道它应该从哪里开始读取下一块数据。

请注意,ppos 对于读操作和写操作都是唯一的,因此如果我们先执行 read(),然后再执行 write(),数据将被写入的位置不是文件的开头,而是在前一个 read() 调用完成其操作的地方!

第九章:附加信息:使用设备树

设备树内部

设备树是一种树形数据结构,其中的节点告诉您系统中当前存在哪些设备以及它们的配置设置。每个节点都有描述所代表设备属性/值对。每个节点只有一个父节点,但根节点没有父节点。

下面的代码显示了一个简单设备树的示例表示,该示例几乎足够完整以引导一个简单的操作系统,其中包括平台类型、CPU、内存和一个通用同步和异步收发器(UART),并描述了其时钟和中断线。设备节点显示为每个节点内的属性和值。

设备树语法几乎是自解释的;但是,我们将通过查看与第四章相关的 GitHub 存储库中的simple_platform.dts文件来详细解释它。因此,让我们从文件末尾开始查看:

        serial0: serial@11100 {
            compatible = "fsl,mpc5125-psc-uart", "fsl,mpc5125-psc";
            reg = <0x11100 0x100>;
            interrupt-parent = <&ipic>;
            interrupts = <40 0x8>; 
            fsl,rx-fifo-size = <16>;
            fsl,tx-fifo-size = <16>;
            clocks = <&clks 47>, <&clks 34>; 
            clock-names = "ipg", "mclk";
        };
    };
};

首先,我们应该注意,属性定义是以下形式的名称/值对:

[label:] property-name = value;

这是真实的,除了具有空(零长度)值的属性,其形式如下:

[label:] property-name;

例如,在前面的例子中,我们有serial@11100节点(标记为serial0)的compatible属性设置为由两个字符串"fsl,mpc5125-psc-uart""fsl,mpc5125-psc"组成的列表,而fsl,rx-fifo-size属性设置为数字16

属性值可以定义为 8、16、32 或 64 位整数元素的数组,作为以 NULL 结尾的字符串,作为字节字符串,或者这些的组合。元素的存储大小可以使用/bits/前缀进行更改,如下所示,它将属性interrupts定义为字节数组,clock-frequency定义为 64 位数字:

interrupts = /bits/ 8 <17 0xc>;
clock-frequency = /bits/ 64 <0x0000000100000000>;

/bits/前缀允许创建 8、16、32 和 64 位元素。

设备树中的每个节点都根据以下node-name@unit-address约定命名,其中node-name组件指定节点的名称(通常描述设备的一般类别),而名称的unit-address组件是特定于节点所在总线类型的。例如,在前面的例子中,我们有serial@11100,这意味着我们在地址0x11100处有一个串行端口,偏移量为soc节点的基地址0x80000000

看前面的例子,很明显每个节点都是由节点名称和单元地址定义的,用大括号标记节点定义的开始和结束(它们可能由标签前导),如下所示:

[label:] node-name[@unit-address] {
    [properties definitions]
    [child nodes]
};

设备树中的每个节点都有描述节点特征的属性;存在具有明确定义和标准化功能的标准属性,但我们也可以使用自己的属性来指定自定义值。属性由名称和值组成,对于我们串行端口的示例,我们将interrupts属性设置为<40 0x8>数组,而compatible属性设置为字符串列表,fsl,rx-fifo-size设置为数字。

设备树中的节点可以通过清楚地说明从根节点到所需节点的所有后代节点的完整路径来唯一标识。指定设备路径的约定类似于我们通常用于文件系统中的文件的路径名称;例如,在前面的定义中,我们串行端口的设备路径是/soc@80000000/serial@11100,而根节点的路径显然是/。这种情况是标签发挥作用的地方;实际上,它们可以用来代替节点的完整路径,即串行端口使用clks标签可以轻松寻址,如下所示:

    clks: clock@f00 {
        ...
    };

    serial0: serial@11100 {
        compatible = "fsl,mpc5125-psc-uart", "fsl,mpc5125-psc";
        ....     
        clocks = <&clks 47>, <&clks 34>;
        clock-names = "ipg", "mclk";
    };

我们还可以注意到serial0被定义为tty0的别名。这种语法为开发人员提供了另一种使用标签而不是使用完整路径名引用节点的方式:

    aliases {
        tty0 = &serial0;
    };

前面的定义等同于以下内容:

    aliases {
        tty0 = "/soc@80000000/serial@11100";
    }

现在很明显,标签可以在设备树源文件中作为属性句柄(标签通常被命名为 phandle)值或路径使用,具体取决于上下文。实际上,如果&字符在数组内部,则它只引用 phandle;否则(如果在数组外部),它引用路径!

别名不直接在设备树源中使用,而是由 Linux 内核进行解引用。实际上,当我们要求内核通过路径找到一个节点时(我们将很快在本章中看到这样的函数的用法,比如of_find_node_by_path()),如果路径不以/字符开头,那么路径的第一个元素必须是/aliases节点中的属性名称。该元素将被别名的完整路径替换。

在节点、标签和别名中,另一个设备树的重要实体是 phandles。官方定义告诉我们,phandle 属性指定了设备树中唯一的节点的数值标识符。实际上,其他需要引用与该属性关联的节点的节点使用了该属性值,因此这实际上只是一个绕过设备树没有指针数据类型的方法。

在上面的例子中,serial@11100节点是指定哪个节点是中断控制器,哪个节点是 phandles 使用的时钟定义的一种方式。然而,在该示例中,它们没有被显式定义,因为dtc编译器会从标签中创建 phandles。因此,在上面的例子中,我们有以下语法(已删除不需要的信息以便更好地阅读):

        ipic: interrupt-controller@c00 {
            compatible = "fsl,mpc5121-ipic", "fsl,ipic";
            ...
        };

        clks: clock@f00 {
            compatible = "fsl,mpc5121-clock";
            ...
        };

        serial0: serial@11100 {
            compatible = "fsl,mpc5125-psc-uart", "fsl,mpc5125-psc";
            ...
            interrupt-parent = <&ipic>;
            ...
            clocks = <&clks 47>, <&clks 34>; 
            ...
        };

dtc编译器是设备树编译器,在第四章中将介绍使用设备树,使用设备树编译器和实用程序。

这相当于下一个语法,其中 phandles 被显式地制作出来:

        interrupt-controller@c00 {
            compatible = "fsl,mpc5121-ipic", "fsl,ipic";
            ...
            phandle = <0x2>;
        };

        clock@f00 {
            compatible = "fsl,mpc5121-clock";
            ...
            phandle = <0x3>;
        };

        serial@11100 {
            compatible = "fsl,mpc5125-psc-uart", "fsl,mpc5125-psc";
            ...
            interrupt-parent = <0x2>;
            ...
            clocks = <0x3 0x2f 0x3 0x22>;
            ...
        };

简而言之,&字符告诉dtc后面的字符串是引用与该字符串匹配的标签的 phandle;然后它将为每个用于 phandle 引用的标签创建一个唯一的u32值。

当然,您可以在一个节点中定义自己的 phandle 属性,并在不同节点的名称上指定一个标签。然后,dtc将意识到任何明确声明的 phandle 值,并在为带标签的节点创建 phandle 值时不使用这些值。

关于设备树语法有很多要说的。然而,我们已经涵盖了足够理解如何在设备驱动程序开发过程中使用设备树的内容。

有关此主题的完整文档,请阅读www.devicetree.org/specifications/上的设备树规范。

使用设备树编译器和实用程序

以下是关于dtc及其实用程序的一些有趣用法的注释,这些用法在设备驱动程序开发和内核配置过程中可能非常有用。

获取运行设备树的源代码形式

dtc也可以用来将运行中的设备树转换为人类可读的形式!假设我们想知道我们的 ESPRESSObin 是如何配置的;首先要做的事情是查看内核源代码中 ESPRESSObin 的 DTS 文件。但是,假设我们没有它。在这种情况下,我们可以要求dtc回退到相应的 DTB 文件,就像前面的部分所示,但是假设我们仍然没有它。我们能做什么?嗯,dtc可以通过回退存储在/proc/device-tree目录中的数据再次帮助我们,该目录保存了运行设备树的文件系统表示。

实际上,我们可以使用tree命令检查/proc/device-tree目录,就像下面所示的那样(这个输出只是整个目录内容的一部分):

# tree /proc/device-tree/proc/device-tree/
|-- #address-cells
|-- #size-cells
|-- aliases
|   |-- name
|   |-- serial0
|   `-- serial1
|-- chosen
|   |-- bootargs
|   |-- name
|   `-- stdout-path
|-- compatible
|-- cpus
|   |-- #address-cells
|   |-- #size-cells
|   |-- cpu@0
|   |   |-- clocks
|   |   |-- compatible
|   |   |-- device_type
|   |   |-- enable-method
|   |   |-- name
|   |   `-- reg
...

如果不存在,可以像通常一样使用apt install tree命令安装tree命令。

然后我们可以按以下方式读取每个文件中的字符串数据:

# cat /proc/device-tree/compatible ; echo
globalscale,espressobinmarvell,armada3720marvell,armada3710
# cat /proc/device-tree/cpus/cpu\@0/compatible ; echo 
arm,cortex-a53arm,armv8

最后的echo命令只是用于在cat输出后添加一个新行字符,以获得更可读的输出。

数字必须按以下方式读取:

# cat /proc/device-tree/#size-cells | od -tx4
0000000 02000000
0000004
# cat /proc/device-tree/cpus/cpu\@1/reg | od -tx4
0000000 01000000
0000004

但是,通过使用dtc,我们可以获得更好的结果。实际上,如果我们使用下一个命令行,我们要求dtc将所有 DTB 数据转换为人类可读的形式:

# dtc -I fs -o espressobin-reverted.dts /proc/device-tree/

当然,我们还必须使用apt install device-tree-compiler命令将dtc程序安装到我们的 ESPRESSObin 中。

现在,从espressobin-reverted.dts文件中,我们可以轻松读取设备树数据:

# head -20 espressobin-reverted.dts
/dts-v1/;

/ {
    #address-cells = <0x2>;
    model = "Globalscale Marvell ESPRESSOBin Board";
    #size-cells = <0x2>;
    interrupt-parent = <0x1>;
    compatible = "globalscale,espressobin", "marvell,armada3720", "marvell,armada3710";

    memory@0 {
        device_type = "memory";
        reg = <0x0 0x0 0x0 0x80000000 0x0 0x0 0x0 0x0 0x0 0x0 0x0 0x0 0x0 0x0 0x0 0x0>;
    };

    regulator {
        regulator-max-microvolt = <0x325aa0>;
        gpios-states = <0x0>;
        regulator-boot-on;
        enable-active-high;
        regulator-min-microvolt = <0x1b7740>;
..

设备树实用程序的注意事项

如果我们查看之前安装的device-tree-compiler软件包中的程序,我们会发现除了dtc之外还有更多的程序:

$ dpkg -L device-tree-compiler | grep '/usr/bin'
/usr/bin
/usr/bin/convert-dtsv0
/usr/bin/dtc
/usr/bin/dtdiff
/usr/bin/fdtdump
/usr/bin/fdtget
/usr/bin/fdtoverlay
/usr/bin/fdtput

这些其他程序通常称为设备树实用程序,可用于检查或操作设备树的二进制形式(DTB)。

例如,我们可以使用fdtdump实用程序轻松转储 DTB 文件:

$ fdtdump simple_platform.dtb | head -23

**** fdtdump is a low-level debugging tool, not meant for general use.
**** If you want to decompile a dtb, you probably want
**** dtc -I dtb -O dts <filename>

/dts-v1/;
// magic: 0xd00dfeed
// totalsize: 0x642 (1602)
...

/ {
    model = "fsl,mpc8572ds";
    compatible = "fsl,mpc8572ds";
    #address-cells = <0x00000001>;
    #size-cells = <0x00000001>;
    interrupt-parent = <0x00000001>;
    chosen {
        bootargs = "root=/dev/sda2";
    };
    aliases {
        tty0 = "/soc@80000000/serial@11100";
    };

细心的读者会注意到fdtdump实用程序本身告诉我们它只是一个低级调试工具,然后使用dtc而不是反编译(或恢复为 DTS)DTB 文件!

另外两个有用的命令是fdtgetfdtput,可以用来读取和写入数据到我们的 DTB 文件中。以下是我们可以用来读取前述 DTB 文件的bootargs条目的命令:

$ fdtget simple_platform.dtb /chosen bootargs
root=/dev/sda2

然后,我们可以使用下一个命令进行更改:

$ fdtput -ts simple_platform.dtb /chosen bootargs 'root=/dev/sda1 rw'
$ fdtget simple_platform.dtb /chosen bootargs
root=/dev/sda1 rw

请注意,我们必须使用-ts选项参数告诉fdtput我们的数据类型,否则可能会写入错误的值!

不仅如此,我们还可以要求fdtget列出每个提供节点的所有子节点:

$ fdtget -l simple_platform.dtb /cpus /soc@80000000
cpu@0
cpu@1
interrupt-controller@c00
clock@f00
serial@11100

此外,我们还可以要求它列出每个节点的所有属性:

$ fdtget -p simple_platform.dtb /cpus /soc@80000000
#address-cells
#size-cells
compatible
#address-cells
#size-cells
device_type
ranges
reg
bus-frequency

从设备树获取特定应用程序数据

通过使用linux/drivers/of目录中的函数,我们将能够从设备树中提取我们的驱动程序所需的所有信息。例如,通过使用of_find_node_by_path()函数,我们可以通过其路径名获取节点指针:

struct device_node *of_find_node_by_path(const char *path);

然后,一旦我们有了指向设备树节点的指针,我们可以使用它来通过使用of_property_read_*()函数提取所需的信息,如下所示:

int of_property_read_u8(const struct device_node *np,
                        const char *propname,
                        u8 *out_value);
int of_property_read_u16(const struct device_node *np,
                         const char *propname,
                         u16 *out_value);
int of_property_read_u32(const struct device_node *np,
                         const char *propname,
                         u32 *out_value);
...

请注意,我们可以使用许多其他函数从设备树中提取信息,因此您可以查看linux/include/linux/of.h文件以获取完整列表。

如果我们希望解析节点的每个属性,我们可以使用for_each_property_of_node()宏来迭代它们,其定义如下:

#define for_each_property_of_node(dn, pp) \
        for (pp = dn->properties; pp != NULL; pp = pp->next)

然后,如果我们的节点有多个子节点,我们可以使用for_each_child_of_node()宏来迭代它们,其定义如下:

#define for_each_child_of_node(parent, child) \
        for (child = of_get_next_child(parent, NULL); child != NULL; \
             child = of_get_next_child(parent, child))

使用设备树描述字符驱动程序

我们已经看到,通过使用设备树,我们可以指定不同的驱动程序设置,然后修改驱动程序的功能。但是,我们的可能性并不止于此!实际上,我们可以将相同的代码用于不同的驱动程序发布版本或相同设备的不同类型。

如何管理不同的设备类型

假设我们的chrdev有另外两个实现(加上当前的实现),在这两个实现中,硬件的大部分参数都是固定的(并且是众所周知的),开发人员无法选择;在这种情况下,我们仍然可以使用节点属性来指定它们,但这样做容易出错,并且迫使用户了解这些约束。例如,如果在这两个实现中,硬件只能以只读或读/写模式工作(即用户无法自由指定read-only属性),我们可以将这些特殊情况称为"chrdev-fixed"用于读/写版本,"chrdev-fixed_read-only"用于只读版本。

此时,我们可以指定驱动程序现在与其他两个设备兼容,方法是修改of_chrdev_req_match数组,如下所示:

static const struct of_device_id of_chrdev_req_match[] = {
    {
        .compatible = "ldddc,chrdev",
    },
    {
        .compatible = "ldddc,chrdev-fixed",
        .data = &chrdev_fd,
    },
    {
        .compatible = "ldddc,chrdev-fixed_read-only",
        .data = &chrdev_fd_ro,
    },
    { /* sentinel */ }
};
MODULE_DEVICE_TABLE(of, of_chrdev_req_match);

我们只需添加两个具有适当compatible字符串和两个特殊数据条目,如下所定义:

static const struct chrdev_fixed_data chrdev_fd = {
    .label = "cdev-fixed",
};

static const struct chrdev_fixed_data chrdev_fd_ro = {
    .label = "cdev-fixedro",
    .read_only = 1, 
};

通过这种方式,我们告诉驱动程序这些设备只能有一个实例,并且它们可以以读/写或只读模式工作。通过这样做,用户可以通过简单地指定设备树来使用我们的设备,如下所示:

--- a/arch/arm64/boot/dts/marvell/armada-3720-espressobin.dts
+++ b/arch/arm64/boot/dts/marvell/armada-3720-espressobin.dts
@@ -41,6 +41,10 @@
             3300000 0x0>;
         enable-active-high;
     };
+
+    chrdev {
+        compatible = "ldddc,chrdev-fixed_read-only";
+    };
 };

 /* J9 */

同样,您必须修改 ESPRESSObin 的 DTS 文件,然后重新编译和重新安装内核。

通过使用这种解决方案,用户不需要了解硬件内部情况,因为驱动程序开发人员(在这种情况下是我们)已将其封装到驱动程序中。

可以使用of_device_is_compatible()函数为驱动程序评估此兼容属性,如下例所示,我们已修改chrdev_req_probe()函数以支持我们的chrdev特殊版本:

static int chrdev_req_probe(struct platform_device *pdev)
{
    struct device *dev = &pdev->dev;
    struct device_node *np = dev->of_node;
    const struct chrdev_fixed_data *data = of_device_get_match_data(dev);
    struct fwnode_handle *child;
    struct module *owner = THIS_MODULE;
    int count, ret;

    /* Check the chrdev device type */
    if (of_device_is_compatible(np, "ldddc,chrdev-fixed") ||
        of_device_is_compatible(np, "ldddc,chrdev-fixed_read-only")) {
        ret = chrdev_device_register(data->label, 0,
                         data->read_only, owner, dev);
        if (ret)
            dev_err(dev, "unable to register fixed");

        return ret;
    }

    /* If we are not registering a fixed chrdev device then get
     * the number of chrdev devices from DTS
     */
    count = device_get_child_node_count(dev);
    if (count == 0)
        return -ENODEV;
    if (count > MAX_DEVICES)
        return -ENOMEM;

    device_for_each_child_node(dev, child) {
        const char *label; 
        unsigned int id, ro; 
...

正如我们所看到的,在扫描节点的子项之前,我们只需验证当前已安装的系统的chrdev设备版本;在这种情况下,我们有两个新设备中的一个,因此我们相应地注册一个新的chrdev设备。

所有这些修改都可以使用add_fixed_chrdev_devices.patch文件和以下命令行进行:

$ patch -p3 < add_fixed_chrdev_devices.patch

现在我们可以通过重新编译我们的chrdev驱动程序并将其重新插入(实际上是两个模块)到 ESPRESSObin 中来尝试代码,如下所示:

# insmod chrdev.ko 
chrdev:chrdev_init: got major 239
# insmod chrdev-req.ko 
chrdev cdev-fixedro@0: chrdev cdev-fixedro with id 0 added
# ls -l /dev/cdev-fixedro\@0 
crw------- 1 root root 239, 0 Feb 28 15:23 /dev/cdev-fixedro@0

正如我们所看到的,驱动程序正确地识别出设备树中已定义了chrdev设备的特殊版本(只读版本)。

如何向设备添加 sysfs 属性

在前面的部分中,我们简要讨论了/sys/class/chrdev目录。我们说它与设备类(可以在系统中定义)和内核设备有关。实际上,当我们调用device_create()函数时,我们必须指定第一个参数,即为chrdev_init()函数分配的设备类指针,此操作将为每个chrdev设备创建/sys/class/chrdev目录,如下所示:

# ls /sys/class/chrdev/
cdev-eeprom@2 cdev-rom@4

因此,设备类将所有具有共同特征的设备分组在一起,但我们在谈论哪些特征?简单地说,这些特征或属性(我们将很快看到它们确切的名称)是关于我们设备的一组共同信息。

每次向系统添加新设备时,内核都会创建默认属性,可以在用户空间中看到这些属性,如下所示:

# ls -l /sys/class/chrdev/cdev-eeprom\@2/
total 0
-r--r--r-- 1 root root 4096 Feb 28 10:51 dev
lrwxrwxrwx 1 root root 0 Feb 28 10:51 device -> ../../../chrdev
drwxr-xr-x 2 root root 0 Feb 28 10:51 power
lrwxrwxrwx 1 root root 0 Feb 27 19:53 subsystem -> ../../../../../class/chrdev
-rw-r--r-- 1 root root 4096 Feb 27 19:53 uevent

在前面的列表中,有些是文件,有些是目录或符号链接;然而,在这里,重要的是,对于每个设备,我们都有一些描述它的属性。例如,如果我们查看dev属性,我们会得到以下内容:

# cat /sys/class/chrdev/cdev-eeprom\@2/dev
239:2

我们设备的主次编号是多少?现在的问题是,我们可以有更多(和自定义)属性吗?当然,答案是肯定的,所以让我们看看如何做到这一点。

首先,我们必须修改chrdev.c文件,向chrdev_init()添加一行,如下所示:

--- a/chapter_4/chrdev/chrdev.c
+++ b/chapter_4/chrdev/chrdev.c
@@ -216,6 +288,7 @@ static int __init chrdev_init(void)
        pr_err("chrdev: failed to allocate class\n");
        return -ENOMEM;
    }
+   chrdev_class->dev_groups = chrdev_groups;

    /* Allocate a region for character devices */
    ret = alloc_chrdev_region(&chrdev_devt, 0, MAX_DEVICES, "chrdev");

此修改只是将指向chrdev_class的结构的dev_groups字段设置为chrdev_groups结构,如下所示:

static struct attribute *chrdev_attrs[] = {
    &dev_attr_id.attr,
    &dev_attr_reset_to.attr,
    &dev_attr_read_only.attr,
    NULL,
};

static const struct attribute_group chrdev_group = {
    .attrs = chrdev_attrs,
};

static const struct attribute_group *chrdev_groups[] = {
    &chrdev_group,
    NULL,
};

本段中的所有修改都可以使用add_sysfs_attrs_chrdev.patch文件和以下命令行进行:

$ patch -p3 < add_sysfs_attrs_chrdev.patch

前面的代码是向我们的 chrdev 设备添加一组属性的复杂方式。更具体地说,该代码只是向名为idreset_toread_only的一组属性添加了单个组。所有这些属性名称仍然在修改后的chrdev.c文件中定义,如下摘录所示。这是只读属性:

static ssize_t id_show(struct device *dev,
                struct device_attribute *attr, char *buf)
{
    struct chrdev_device *chrdev = dev_get_drvdata(dev);

    return sprintf(buf, "%d\n", chrdev->id);
}
static DEVICE_ATTR_RO(id);

然后,只写属性如下:

static ssize_t reset_to_store(struct device *dev,
                struct device_attribute *attr,
                const char *buf, size_t count)
{
    struct chrdev_device *chrdev = dev_get_drvdata(dev);

    if (count > BUF_LEN)
        count = BUF_LEN;
    memcpy(chrdev->buf, buf, count);

    return count;
}
static DEVICE_ATTR_WO(reset_to);

最后,读/写属性如下:

static ssize_t read_only_show(struct device *dev,
                struct device_attribute *attr, char *buf)
{
    struct chrdev_device *chrdev = dev_get_drvdata(dev);

    return sprintf(buf, "%d\n", chrdev->read_only);
}

static ssize_t read_only_store(struct device *dev,
                struct device_attribute *attr,
                const char *buf, size_t count)
{
    struct chrdev_device *chrdev = dev_get_drvdata(dev);
    int data, ret;

    ret = sscanf(buf, "%d", &data);
    if (ret != 1)
        return -EINVAL;

    chrdev->read_only = !!data;

    return count;
}
static DEVICE_ATTR_RW(read_only);

通过使用DEVICE_ATTR_RW()DEVICE_ATTR_WO()DEVICE_ATTR_RO(),我们声明读/写、只写和只读属性,这些属性与名为chrdev_attrs的数组中的条目相关联,该数组被定义为struct attribute类型。

当我们使用 DEVICE_ATTR_RW(read_only)时,我们必须定义两个名为 read_only_show()和 read_only_store()的函数(变量名为 read_only,带有后缀 _show 和 _store),这样内核在用户空间进程在属性文件上执行 read()或 write()系统调用时会调用每个函数。当然,DEVICE_ATTR_RO()和 DEVICE_ATTR_WO()变体只需要 _show 和 _store 函数,分别。

为了更好地理解数据是如何交换的,让我们更仔细地看看这些函数。通过查看 read_only_show()函数,我们可以看到要写入的数据由 buf 指向,而通过使用 dev_get_drvdata(),我们可以获得指向我们的 struct chrdev_device 的指针,其中包含与我们自定义实现相关的所有必要信息。例如,read_only_show()函数将返回存储在 read_only 变量中的值,该值表示设备的只读属性。请注意,read_only_show()必须返回一个表示返回多少字节的正值,或者如果有任何错误则返回一个负值。

以类似的方式,read_only_store()函数为我们提供要写入buf缓冲区和count的数据,同时我们可以使用相同的技术来获得指向struct chrdev_device的指针。read_only_store()函数以人类可读形式(即 ASCII 表示)读取一个数字,然后如果我们读取值为 0,则将read_only属性设置为 0,否则设置为 1。

其他属性 id 和 reset_to 分别用于显示设备的 id 或强制内部缓冲区达到所需状态,而不管设备本身是否被定义为只读。

为了测试代码,我们必须像之前描述的那样修改 chrdev.c 文件,然后重新编译代码并将生成的内核模块移动到 ESPRESSObin。现在,如果我们插入模块,我们应该得到几乎与之前相同的内核消息,但是现在/sys/class/chrdev 子目录的内容应该已经改变。实际上,现在我们有以下内容:

# ls -l /sys/class/chrdev/cdev-eeprom\@2/
total 0
-r--r--r-- 1 root root 4096 Feb 28 13:45 dev
lrwxrwxrwx 1 root root 0 Feb 28 13:45 device -> ../../../chrdev
-r--r--r-- 1 root root 4096 Feb 28 13:45 id
drwxr-xr-x 2 root root 0 Feb 28 13:45 power
-rw-r--r-- 1 root root 4096 Feb 28 13:45 read_only
--w------- 1 root root 4096 Feb 28 13:45 reset_to
lrwxrwxrwx 1 root root 0 Feb 28 13:45 subsystem -> ../../../../../class/chrdev
-rw-r--r-- 1 root root 4096 Feb 28 13:45 uevent

正如预期的那样,我们在代码中定义了三个新属性。然后,我们可以尝试从中读取:

# cat /sys/class/chrdev/cdev-eeprom\@2/id 
2
# cat /sys/class/chrdev/cdev-eeprom\@2/read_only 
0
# cat /sys/class/chrdev/cdev-eeprom\@2/reset_to 
cat: /sys/class/chrdev/cdev-eeprom@2/reset_to: Permission denied

所有答案都如预期;实际上,cdev-eeprom 设备的 id 等于 2,并且不是只读的,而 reset_to 属性是只写的,不可读。类似的输出也可以从 cdev-rom 中获得,如下所示:

# cat /sys/class/chrdev/cdev-rom\@4/id 
4
# cat /sys/class/chrdev/cdev-rom\@4/read_only 
1

这些属性对于检查当前设备状态很有用,但也可以用于修改其行为。实际上,我们可以使用 reset_to 属性来为只读 cdev-rom 设备设置初始值,如下所示:

# echo "THIS IS A READ ONLY DEVICE!" > /sys/class/chrdev/cdev-rom\@4/reset_to 

现在/dev/cdev-rom@4 设备仍然是只读的,但不再被全部零填充:

# cat /dev/cdev-rom\@4
THIS IS A READ ONLY DEVICE!

或者,我们可以从/dev/cdev-rom@4 设备中移除只读属性:

# echo 0 > /sys/class/chrdev/cdev-rom\@4/read_only

现在,如果我们尝试再次向其中写入数据,我们会成功(echo 命令下方的内核消息是从串行控制台报告的):

root@espressobin:~# echo "TEST STRING" > /dev/cdev-rom\@4 
chrdev cdev-rom@4: chrdev (id=4) opened
chrdev cdev-rom@4: should write 12 bytes (*ppos=0)
chrdev cdev-rom@4: got 12 bytes (*ppos=12)
chrdev cdev-rom@4: chrdev (id=4) released

请注意,这样做是有效的,但会产生意外的副作用;我们可以写入设备,但新的 TEST STRING 会覆盖我们刚刚设置的新(更长的)reset_to 字符串(即 THIS IS A READ-ONLY DEVICE),因此随后的读取将会得到:

#cat /dev/cdev-rom@4

测试字符串

只读设备!

然而,这只是一个例子,我们可以安全地接受这种行为。

为特定外围设备配置 CPU 引脚

即使 ESPRESSObin 是本书的参考平台,在本段中,我们将解释内核开发人员如何修改不同平台的引脚设置,因为这个任务可能因不同的实现而有所不同。实际上,即使所有这些实现都是基于设备树的,它们之间存在一些必须概述的差异。

当前的 CPU 是非常复杂的系统——复杂到大多数 CPU 都被赋予了缩写SoC,意思是片上系统;事实上,在一个芯片上,我们可能不仅可以找到中央处理单元CPU),还有很多外围设备,CPU 可以用来与外部环境进行通信。因此,我们可以在一个芯片内找到显示控制器、键盘控制器、USB 主机或设备控制器、磁盘和网络控制器。不仅如此,现代 SoC 还有几个副本!所有这些外围设备都有自己的信号,每个信号都通过专用的物理线路进行路由,每条线路都需要一个引脚与外部环境进行通信;然而,可能会出现 CPU 引脚不足以将所有这些线路路由到外部的情况,这就是为什么大多数引脚都是多路复用的原因。这意味着,例如,CPU 可能有六个串行端口和两个以太网端口,但它们不能同时使用。这就是pinctrl 子系统发挥作用的地方。

Linux 的 pinctrl 子系统处理枚举、命名和多路可控引脚,例如软件控制的偏置和驱动模式特定的引脚,例如上拉/下拉、开漏、负载电容等。所有这些设置都可以通过引脚控制器来完成,这是一种硬件(通常是一组寄存器),可以控制 CPU 引脚,可能能够对单个引脚或引脚组进行多路复用、偏置、设置负载电容或设置驱动强度。

无符号整数从 0 到最大引脚编号用于表示我们想要控制的封装输入或输出线路。

这个数字空间是每个引脚控制器本地的,因此在系统中可能有几个这样的数字空间;每次实例化引脚控制器时,它都会注册一个包含一组引脚描述符的描述符,描述这个特定引脚控制器处理的引脚。

在本书中,我们不打算解释如何在内核中定义引脚控制器,因为这超出了本书的范围(而且也是一个相当复杂的任务),但我们将尝试为读者提供配置每个 CPU 引脚的能力,以便它们可以与他们正在开发的驱动程序一起使用,例如,在嵌入式系统行业中使用的三种最常用的 CPU。

Armada 3720

ESPRESSObin 的 CPU 是 Marvell 的 Armada 3720,我们可以通过查看linux/arch/arm64/boot/dts/marvell/armada-37xx.dtsi文件来了解其内部外围设备的情况。该文件定义了内部外围设备的内存映射(即每个外围设备在 CPU 内存中的映射方式和位置),以及按引脚控制器和引脚功能分组的所有 CPU 引脚。

例如,以下代码段定义了一个名为pinctrl@13800的引脚控制器:

   pinctrl_nb: pinctrl@13800 {
        compatible = "marvell,armada3710-nb-pinctrl",
                 "syscon", "simple-mfd";
        reg = <0x13800 0x100>, <0x13C00 0x20>;
        /* MPP1[19:0] */
        gpionb: gpio {
            #gpio-cells = <2>;
            gpio-ranges = <&pinctrl_nb 0 0 36>;
            gpio-controller;
            interrupt-controller;
            #interrupt-cells = <2>;
            interrupts =
            <GIC_SPI 51 IRQ_TYPE_LEVEL_HIGH>,
            <GIC_SPI 52 IRQ_TYPE_LEVEL_HIGH>,
            <GIC_SPI 53 IRQ_TYPE_LEVEL_HIGH>,
            <GIC_SPI 54 IRQ_TYPE_LEVEL_HIGH>,
            <GIC_SPI 55 IRQ_TYPE_LEVEL_HIGH>,
            <GIC_SPI 56 IRQ_TYPE_LEVEL_HIGH>,
            <GIC_SPI 57 IRQ_TYPE_LEVEL_HIGH>,
            <GIC_SPI 58 IRQ_TYPE_LEVEL_HIGH>,
            <GIC_SPI 152 IRQ_TYPE_LEVEL_HIGH>,
            <GIC_SPI 153 IRQ_TYPE_LEVEL_HIGH>,
            <GIC_SPI 154 IRQ_TYPE_LEVEL_HIGH>,
            <GIC_SPI 155 IRQ_TYPE_LEVEL_HIGH>;
        };

        xtalclk: xtal-clk {
            compatible = "marvell,armada-3700-xtal-clock";
            clock-output-names = "xtal"; 
            #clock-cells = <0>;
        };

        spi_quad_pins: spi-quad-pins {
            groups = "spi_quad";
            function = "spi";
        };
...

我们应该记住,这个表示法意味着它从名为internal-regs@d0000000的父节点的开头偏移0x13800处进行映射,并映射到0xd0000000处。

compatible属性表示这个引脚控制器的驱动程序(存储在linux/drivers/pinctrl/mvebu/pinctrl-armada-37xx.c文件中),而每个子节点描述了引脚的功能。我们可以看到一个带有时钟设备和一组引脚定义的 GPIO 控制器(从spi_quad_pins开始),这些引脚控制器在以下报告的代码中进行了定义。

static struct armada_37xx_pin_group armada_37xx_nb_groups[] = {
    PIN_GRP_GPIO("jtag", 20, 5, BIT(0), "jtag"),
    PIN_GRP_GPIO("sdio0", 8, 3, BIT(1), "sdio"),
    PIN_GRP_GPIO("emmc_nb", 27, 9, BIT(2), "emmc"),
    PIN_GRP_GPIO("pwm0", 11, 1, BIT(3), "pwm"),
    PIN_GRP_GPIO("pwm1", 12, 1, BIT(4), "pwm"),
    PIN_GRP_GPIO("pwm2", 13, 1, BIT(5), "pwm"),
    PIN_GRP_GPIO("pwm3", 14, 1, BIT(6), "pwm"),
    PIN_GRP_GPIO("pmic1", 17, 1, BIT(7), "pmic"),
    PIN_GRP_GPIO("pmic0", 16, 1, BIT(8), "pmic"),
    PIN_GRP_GPIO("i2c2", 2, 2, BIT(9), "i2c"),
    PIN_GRP_GPIO("i2c1", 0, 2, BIT(10), "i2c"),
    PIN_GRP_GPIO("spi_cs1", 17, 1, BIT(12), "spi"),
    PIN_GRP_GPIO_2("spi_cs2", 18, 1, BIT(13) | BIT(19), 0, BIT(13), "spi"),
    PIN_GRP_GPIO_2("spi_cs3", 19, 1, BIT(14) | BIT(19), 0, BIT(14), "spi"),
    PIN_GRP_GPIO("onewire", 4, 1, BIT(16), "onewire"),
    PIN_GRP_GPIO("uart1", 25, 2, BIT(17), "uart"),
    PIN_GRP_GPIO("spi_quad", 15, 2, BIT(18), "spi"),
    PIN_GRP_EXTRA("uart2", 9, 2, BIT(1) | BIT(13) | BIT(14) | BIT(19),
              BIT(1) | BIT(13) | BIT(14), BIT(1) | BIT(19),
              18, 2, "gpio", "uart"),
    PIN_GRP_GPIO("led0_od", 11, 1, BIT(20), "led"),
    PIN_GRP_GPIO("led1_od", 12, 1, BIT(21), "led"),
    PIN_GRP_GPIO("led2_od", 13, 1, BIT(22), "led"),
    PIN_GRP_GPIO("led3_od", 14, 1, BIT(23), "led"),

};

PIN_GRP_GPIO()PIN_GRP_GPIO_2()宏用于指定引脚组可以被内部外围设备使用,或者只能作为普通的 GPIO 线路使用。因此,当我们在 ESPRESSObin 的 DTS 文件中使用以下代码(来自linux/arch/arm64/boot/dts/marvell/armada-3720-espressobin.dts文件)时,我们要求引脚控制器为uart0设备保留uart1_pins组。

/* Exported on the micro USB connector J5 through an FTDI */
&uart0 {
    pinctrl-names = "default";
    pinctrl-0 = <&uart1_pins>;
    status = "okay";
};

请注意,status = "okay" 这一行是必需的,因为每个设备通常都是禁用的,如果不指定,设备将无法工作。

请注意,这次我们使用了 pinctrl-0 属性来声明外围设备的引脚。

pinctrl-0pinctrl-names 属性的使用与多引脚配置状态密切相关,由于空间有限,本书未对其进行报告。然而,有兴趣的读者可以查看 https://www.kernel.org/doc/Documentation/devicetree/bindings/pinctrl/pinctrl-bindings.txt 以获取更多信息。

i.MX7Dual

另一个相当著名的 CPU 是来自 Freescale 的 i.MX7Dual,它在 linux/arch/arm/boot/dts/imx7s.dtsi 设备树文件中有描述。在该文件中,我们可以看到其两个引脚控制器的定义如下:

    iomuxc_lpsr: iomuxc-lpsr@302c0000 {
        compatible = "fsl,imx7d-iomuxc-lpsr";
        reg = <0x302c0000 0x10000>;
        fsl,input-sel = <&iomuxc>;
    };

    iomuxc: iomuxc@30330000 {
        compatible = "fsl,imx7d-iomuxc";
        reg = <0x30330000 0x10000>;
    };

通过使用 compatible 属性,我们可以发现引脚控制器的驱动程序存储在文件 linux/drivers/pinctrl/freescale/pinctrl-imx7d.c 中,我们可以在其中找到所有 CPU 引脚的列表,如下所示(由于空间原因,仅报告了第二个引脚控制器的引脚):

enum imx7d_lpsr_pads {
    MX7D_PAD_GPIO1_IO00 = 0,
    MX7D_PAD_GPIO1_IO01 = 1,
    MX7D_PAD_GPIO1_IO02 = 2,
    MX7D_PAD_GPIO1_IO03 = 3,
    MX7D_PAD_GPIO1_IO04 = 4,
    MX7D_PAD_GPIO1_IO05 = 5,
    MX7D_PAD_GPIO1_IO06 = 6,
    MX7D_PAD_GPIO1_IO07 = 7,
};

然后,所有需要引脚的外围设备只需声明它们,就像从 Freescale 的 i.MX 7Dual SABRE board 的 DTS 文件中取出的以下示例一样:

...
    panel {
        compatible = "innolux,at043tn24";
        pinctrl-0 = <&pinctrl_backlight>;
        enable-gpios = <&gpio1 1 GPIO_ACTIVE_HIGH>;
        power-supply = <&reg_lcd_3v3>;

        port {
            panel_in: endpoint {
                remote-endpoint = <&display_out>; 
            };
        };
    };
};
...
&wdog1 {
    pinctrl-names = "default";
    pinctrl-0 = <&pinctrl_wdog>;
    fsl,ext-reset-output;
};
...
&iomuxc_lpsr {
    pinctrl_wdog: wdoggrp {
        fsl,pins = <
            MX7D_PAD_LPSR_GPIO1_IO00__WDOG1_WDOG_B 0x74
        >;
    };

    pinctrl_backlight: backlightgrp {
        fsl,pins = <
            MX7D_PAD_LPSR_GPIO1_IO01__GPIO1_IO1 0x110b0
        >;
    };
};

在前面的示例中,panel 节点要求 pinctrl_backlight 引脚组,而 wdog1 要求 pinctrl_wdog 引脚组;所有这些组都需要来自 lpsr 垫的引脚。

请注意,DTS 中定义的引脚可以在文件 linux/arch/arm/boot/dts/imx7d-pinfunc.h 中找到。此外,以下数字是特定的引脚设置,这些设置在 CPU 的用户手册中有解释,因此请参考手册以获取有关这些神奇数字的更多信息。

同样,pinctrl-0 属性已用于寻址默认引脚配置。

SAMA5D3

最后一个示例是关于名为 SAMA5D3 from Microchip 的 CPU,在 linux/arch/arm/boot/dts/sama5d3.dtsi 文件中有描述。引脚定义模式与前面的相似,其中引脚控制器驱动程序存储在 linux/drivers/pinctrl/pinctrl-at91.c 文件中,并且所有引脚特性都根据设备树中的定义进行管理,如下例所示:

    pinctrl@fffff200 {
        #address-cells = <1>;
        #size-cells = <1>;
        compatible = "atmel,sama5d3-pinctrl", "atmel,at91sam9x5-pinctrl", "simple-bus";
        ranges = <0xfffff200 0xfffff200 0xa00>;
        atmel,mux-mask = <
            /* A B C */
            0xffffffff 0xc0fc0000 0xc0ff0000 /* pioA */
            0xffffffff 0x0ff8ffff 0x00000000 /* pioB */
            0xffffffff 0xbc00f1ff 0x7c00fc00 /* pioC */
            0xffffffff 0xc001c0e0 0x0001c1e0 /* pioD */
            0xffffffff 0xbf9f8000 0x18000000 /* pioE */
            >;

        /* shared pinctrl settings */
        adc0 {
            pinctrl_adc0_adtrg: adc0_adtrg {
                atmel,pins =
                    <AT91_PIOD 19 AT91_PERIPH_A AT91_PINCTRL_NONE>; /* PD19 periph A ADTRG */
            };
            pinctrl_adc0_ad0: adc0_ad0 {
                atmel,pins =
                    <AT91_PIOD 20 AT91_PERIPH_A AT91_PINCTRL_NONE>; /* PD20 periph A AD0 */
            };
...
            pinctrl_adc0_ad7: adc0_ad7 {
                atmel,pins =
                    <AT91_PIOD 27 AT91_PERIPH_A AT91_PINCTRL_NONE>; /* PD27 periph A AD7 */
...

同样,当外围设备需要一个以上的引脚组时,它只需声明它们,就像从 Microchip Technology 的 SAMA5D3 Xplained board 的 DTS 文件中取出的以下代码一样:

    adc0: adc@f8018000 {
        atmel,adc-vref = <3300>; 
        atmel,adc-channels-used = <0xfe>; 
        pinctrl-0 = <
            &pinctrl_adc0_adtrg
            &pinctrl_adc0_ad1
            &pinctrl_adc0_ad2
            &pinctrl_adc0_ad3
            &pinctrl_adc0_ad4
            &pinctrl_adc0_ad5
            &pinctrl_adc0_ad6
            &pinctrl_adc0_ad7
            >;
        status = "okay"; 
    };

在前面的示例中,adc0 节点要求多个引脚组,以便能够管理其内部 ADC 外围设备。

SAMA5D3 CPU 的 DTS 模式仍然使用 pinctrl-0 属性来寻址默认引脚配置。

使用设备树描述字符驱动程序

为了测试在本章中呈现的代码,并展示一切是如何工作的,我们必须在采取任何进一步步骤之前对其进行编译:

$ make KERNEL_DIR=../../../linux
make -C ../../../linux \
            ARCH=arm64 \
            CROSS_COMPILE=aarch64-linux-gnu- \
            SUBDIRS=/home/giometti/Projects/ldddc/github/chapter_4/chrdev modules
make[1]: Entering directory '/home/giometti/Projects/ldddc/linux'
  CC [M] /home/giometti/Projects/ldddc/github/chapter_4/chrdev/chrdev.o
  CC [M] /home/giometti/Projects/ldddc/github/chapter_4/chrdev/chrdev-req.o
...
  LD [M] /home/giometti/Projects/ldddc/github/chapter_4/chrdev/chrdev.ko
make[1]: Leaving directory '/home/giometti/Projects/ldddc/linux'

然后,我们必须将 chrdev.kochrdev-req.ko 文件移动到 ESPRESSObin。现在,如果我们插入第一个模块,我们将在串行控制台上(或内核消息中)看到与之前完全相同的输出:

# insmod chrdev.ko
chrdev: loading out-of-tree module taints kernel.
chrdev:chrdev_init: got major 239

当我们插入第二个模块时,差异将会出现:

# insmod chrdev-req.ko 
chrdev cdev-eeprom@2: chrdev cdev-eeprom with id 2 added
chrdev cdev-rom@4: chrdev cdev-rom with id 4 added

太棒了!现在已经创建了两个新设备。通过这样做,以下两个字符文件已自动创建到 /dev 目录中:

# ls -l /dev/cdev*
crw------- 1 root root 239, 2 Feb 27 18:35 /dev/cdev-eeprom@2
crw------- 1 root root 239, 4 Feb 27 18:35 /dev/cdev-rom@4

实际上,这里没有什么神奇之处,而是由 udev 程序为我们完成的,这将在下一节中更深入地解释。

新设备的名称根据设备树中指定的标签进行命名(如前所述),次要编号对应于每个 reg 属性使用的值。

请注意,当我们指定 printf 格式时,cdev-eeprom@2cdev-rom@4 名称是由 device_create() 函数创建的:

device_create(... , "%s@%d", label, id);

现在我们可以尝试在我们新创建的设备中读取和写入数据。根据设备树中的定义,标记为cdev-eeprom的设备应该是读/写设备,而标记为cdev-rom的设备是只读设备。因此,让我们在/dev/cdev-eeprom@2字符设备上尝试一些简单的读/写命令:

# echo "TEST STRING" > /dev/cdev-eeprom\@2 
# cat /dev/cdev-eeprom\@2
TEST STRING

请注意在@之前的反斜杠(\)字符,否则,BASH 会生成错误。

为了验证一切是否与以前一样,相关的内核消息报告如下:

chrdev cdev-eeprom@2: chrdev (id=2) opened
chrdev cdev-eeprom@2: should write 12 bytes (*ppos=0)
chrdev cdev-eeprom@2: got 12 bytes (*ppos=12)
chrdev cdev-eeprom@2: chrdev (id=2) released
chrdev cdev-eeprom@2: chrdev (id=2) opened
chrdev cdev-eeprom@2: should read 131072 bytes (*ppos=0)
chrdev cdev-eeprom@2: return 300 bytes (*ppos=300)
chrdev cdev-eeprom@2: should read 131072 bytes (*ppos=300)
chrdev cdev-eeprom@2: return 0 bytes (*ppos=300)
chrdev cdev-eeprom@2: chrdev (id=2) released

我们可以看到,通过第一个命令,我们调用了open()系统调用,驱动程序识别出设备id等于 2,然后我们写入了 12 个字节(即TEST STRING加上终止字符);之后,我们关闭了设备。相反,使用cat命令,我们仍然打开了设备,但之后,我们进行了 131,072 字节的第一次读取(驱动程序只正确返回了 300 字节),然后进行了相同数量的字节的另一次读取,得到了答案 0,表示文件结束;因此,cat命令关闭了设备并打印了接收到的数据(或者至少是所有可打印的字节),然后退出。

现在我们可以尝试在另一个/dev/cdev-rom@4设备上执行相同的命令。输出如下:

# echo "TEST STRING" > /dev/cdev-rom\@4 
-bash: echo: write error: Invalid argument
# cat /dev/cdev-rom\@4 

第一个命令如预期般失败,而第二个似乎没有返回任何内容;然而,这是因为所有读取的数据都是 0,为了验证这一点,我们可以使用od命令如下:

# od -tx1 -N 16 /dev/cdev-rom\@4 
0000000 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0000020

这表明没有数据被写入/dev/cdev-rom@4设备,该设备在设备树中被定义为只读。

与前面的代码一样,我们可以再次查看内核消息,以验证一切是否正常(以下是与od命令相关的报告的内核消息):

chrdev cdev-rom@4: chrdev (id=4) opened
chrdev cdev-rom@4: should write 12 bytes (*ppos=0)
chrdev cdev-rom@4: chrdev (id=4) released
chrdev cdev-rom@4: chrdev (id=4) opened
chrdev cdev-rom@4: should read 131072 bytes (*ppos=0)
chrdev cdev-rom@4: return 300 bytes (*ppos=300)
chrdev cdev-rom@4: should read 131072 bytes (*ppos=300)
chrdev cdev-rom@4: return 0 bytes (*ppos=300)
chrdev cdev-rom@4: chrdev (id=4) released
chrdev cdev-rom@4: chrdev (id=4) opened
chrdev cdev-rom@4: should read 16 bytes (*ppos=0)
chrdev cdev-rom@4: return 16 bytes (*ppos=16)
chrdev cdev-rom@4: chrdev (id=4) released

在前面的输出中,我们可以看到我们首先打开了设备(这次是设备 id 等于四的设备),然后我们使用了write()系统调用,显然失败了,所以设备被简单地关闭了。接下来的两次读取与前面的读取完全相同。

现在我们应该尝试修改设备树以定义不同的 chrdev 设备,或者更好的是,应该尝试修改驱动程序以添加更多功能。

第十章:附加信息:管理中断和并发

回顾我们在第三章中所做的工作,即使用 Char 驱动程序,当我们讨论read()系统调用以及如何为我们的 char 驱动程序实现它时(请参阅 GitHub 上的chapter_4/chrdev/chrdev.c文件),我们注意到我们的实现很棘手,因为数据总是可用的:

static ssize_t chrdev_read(struct file *filp,
               char __user *buf, size_t count, loff_t *ppos)
{
    struct chrdev_device *chrdev = filp->private_data;
    int ret;

    dev_info(chrdev->dev, "should read %ld bytes (*ppos=%lld)\n",
                count, *ppos);

    /* Check for end-of-buffer */
    if (*ppos + count >= BUF_LEN)
        count = BUF_LEN - *ppos;

    /* Return data to the user space */
    ret = copy_to_user(buf, chrdev->buf + *ppos, count);
    if (ret < 0)
        return ret;

    *ppos += count;
    dev_info(chrdev->dev, "return %ld bytes (*ppos=%lld)\n", count, *ppos);

    return count;
}

在前面的示例中,chrdev->buf中的数据总是存在的,但在真实的外围设备中,这往往并不是真的;我们通常必须等待新数据,然后当前进程应该被挂起(即休眠)。这就是为什么我们的chrdev_read()应该是这样的:

static ssize_t chrdev_read(struct file *filp,
               char __user *buf, size_t count, loff_t *ppos)
{
    struct chrdev_device *chrdev = filp->private_data;
    int ret;

    /* Wait for available data */
    wait_for_event(chrdev->available > 0);

    /* Check for end-of-buffer */
    if (count > chrdev->available)
        count = chrdev->available;

    /* Return data to the user space */
    ret = copy_to_user(buf, ..., count);
    if (ret < 0)
        return ret;

    *ppos += count;

    return count;
}

请注意,由于一个真实(完整的)read()系统调用实现将在第七章中呈现,所以本示例故意不完整。在本章中,我们只是介绍机制,而不是如何在设备驱动程序中使用它们。

通过使用wait_for_event()函数,我们要求内核测试是否有一些可用数据,如果有的话,允许进程执行,否则,当前进程将被挂起,一旦条件chrdev->available > 0为真,就会再次唤醒。

外围设备通常使用中断来通知 CPU 有新数据可用(或者必须对它们进行一些重要的活动),因此很明显,我们作为设备驱动程序开发人员,必须在中断处理程序中通知内核,等待数据的睡眠进程应该被唤醒。在接下来的章节中,我们将通过使用非常简单的示例来看看内核中有哪些机制可用,并且它们如何被用来挂起一个进程,我们还将看到什么时候可以安全地这样做!事实上,如果我们要求调度程序在中断处理程序中将 CPU 撤销给当前进程以便将其分配给另一个进程,那么我们只是在进行一个无意义的操作。当我们处于中断上下文时,我们并不执行进程代码,那么我们可以撤销 CPU 给哪个进程呢?简而言之,当 CPU 处于进程上下文时,执行进程可以进入睡眠,而当我们处于中断上下文时,我们不能这样做,因为当前没有进程正式持有 CPU!

这个概念非常重要,设备驱动程序开发人员必须充分理解;事实上,如果我们尝试在 CPU 处于中断上下文时进入睡眠状态,那么将会生成一个严重的异常,并且很可能整个系统都会挂起。

另一个需要真正清楚的重要概念是原子操作。设备驱动程序不是一个有常规开始和结束的正常程序;相反,设备驱动程序是一组可以同时运行的方法和异步中断处理程序。这就是为什么我们很可能必须保护我们的数据,以防可能损坏它们的竞争条件。

例如,如果我们使用缓冲区仅保存来自外围设备的接收数据,我们必须确保数据被正确排队,以便读取进程可以读取有效数据,而且不会丢失任何信息。因此,在这些情况下,我们应该使用一些 Linux 提供给我们的互斥机制来完成我们的工作。然而,我们必须注意我们所做的事情,因为其中一些机制可以在进程或中断上下文中安全使用,而另一些则不行;其中一些只能在进程上下文中使用,如果我们在中断上下文中使用它们,可能会损坏我们的系统。

此外,我们应该考虑到现代 CPU 有多个核心,因此使用禁用 CPU 中断的技巧来获得原子代码根本行不通,必须使用特定的互斥机制。在 Linux 中,这种机制称为自旋锁,它可以在中断或进程上下文中使用,但是只能用于非常短的时间,因为它们是使用忙等待方法实现的。这意味着为了执行原子操作,当一个核心在属于这种原子操作的关键代码部分中操作时,CPU 中的所有其他核心都被排除在同一关键部分之外,通过在紧密循环中积极旋转来等待,这反过来意味着你实际上在浪费 CPU 的周期,这些周期没有做任何有用的事情。

在接下来的章节中,我们将详细讨论所有这些方面,并尝试用非常简单的例子解释它们的用法;在第七章高级字符驱动程序操作中,我们将看到如何在设备驱动程序中使用这些机制。

推迟工作

很久以前,有底半部,也就是说,硬件事件被分成两半:顶半部(硬件中断处理程序)和底半部(软件中断处理程序)。这是因为中断处理程序必须尽快执行,以准备为下一个传入的中断提供服务,因此,例如,CPU 不能在中断处理程序的主体中等待慢速外围设备发送或接收数据的时间太长。这就是为什么我们使用底半部;中断被分成两部分:顶部是真正的硬件中断处理程序,它快速执行并禁用中断,只是确认外围设备,然后启动一个底半部,启用中断,可以安全地完成发送/接收工作。

然而,底半部非常有限,因此内核开发人员在 Linux 2.4 系列中引入了tasklets。Tasklets 允许以非常简单的方式动态创建可延迟的函数;它们在软件中断上下文中执行,适合快速执行,因为它们不能休眠。但是,如果我们需要休眠,我们必须使用另一种机制。在 Linux 2.6 系列中,workqueues被引入作为 Linux 2.4 系列中已经存在的类似构造称为 taskqueue 的替代品;它们允许内核函数像 tasklets 一样被激活(或延迟)以供以后执行,但是与 tasklets(在软件中断中执行)相比,它们在称为worker threads的特殊内核线程中执行。这意味着两者都可以用于推迟工作,但是 workqueue 处理程序可以休眠。当然,这个处理程序的延迟更高,但是相比之下,workqueues 包括更丰富的工作推迟 API。

在结束本食谱之前,还有两个重要的概念要谈论:共享工作队列和container_of()宏。

共享工作队列

在食谱中的前面的例子可以通过使用共享工作队列来简化。这是内核本身定义的一个特殊工作队列,如果设备驱动程序(和其他内核实体)承诺不会长时间垄断队列(也就是说不会长时间休眠和不会长时间运行的任务),如果它们接受它们的处理程序可能需要更长时间来获得公平的 CPU 份额。如果两个条件都满足,我们可以避免使用create_singlethread_workqueue()创建自定义工作队列,并且可以通过简单地使用schedule_work()schedule_delayed_work()来安排工作。以下是处理程序:

--- a/drivers/misc/irqtest.c
+++ b/drivers/misc/irqtest.c
...
+static void irqtest_work_handler(struct work_struct *ptr)
+{
+     struct irqtest_data *info = container_of(ptr, struct irqtest_data,
+                                                      work);
+     struct device *dev = info->dev;
+
+     dev_info(dev, "work executed after IRQ %d", info->irq);
+
+     /* Schedule the delayed work after 2 seconds */
+     schedule_delayed_work(&info->dwork, 2*HZ);
+}
+
 static irqreturn_t irqtest_interrupt(int irq, void *dev_id)
 {
      struct irqtest_data *info = dev_id;
@@ -36,6 +60,8 @@ static irqreturn_t irqtest_interrupt(int irq, void *dev_id)

      dev_info(dev, "interrupt occurred on IRQ %d\n", irq);

+     schedule_work(&info->work);
+
      return IRQ_HANDLED;
 }

然后,初始化和移除的修改:

@@ -80,6 +106,10 @@ static int irqtest_probe(struct platform_device *pdev)
      dev_info(dev, "GPIO %u correspond to IRQ %d\n",
                                irqinfo.pin, irqinfo.irq);

+     /* Init works */
+     INIT_WORK(&irqinfo.work, irqtest_work_handler);
+     INIT_DELAYED_WORK(&irqinfo.dwork, irqtest_dwork_handler);
+
      /* Request IRQ line and setup corresponding handler */
      irqinfo.dev = dev;
      ret = request_irq(irqinfo.irq, irqtest_interrupt, 0,
@@ -98,6 +128,8 @@ static int irqtest_remove(struct platform_device *pdev)
 {
        struct device *dev = &pdev->dev;

+     cancel_work_sync(&irqinfo.work);
+     cancel_delayed_work_sync(&irqinfo.dwork);
      free_irq(irqinfo.irq, &irqinfo);
      dev_info(dev, "IRQ %d is now unmanaged!\n", irqinfo.irq);

前面的补丁可以在 GitHub 存储库的add_workqueue_2_to_irqtest_module.patch文件中找到,并且可以使用以下命令像往常一样应用:

$ patch -p1 < add_workqueue_2_to_irqtest_module.patch

container_of()

最后,我们应该利用一些词来解释一下container_of()宏。该宏在linux/include/linux/kernel.h中定义如下:

/**
 * container_of - cast a member of a structure out to the containing structure
 * @ptr: the pointer to the member.
 * @type: the type of the container struct this is embedded in.
 * @member: the name of the member within the struct.
 *
 */
#define container_of(ptr, type, member) ({ \
    void *__mptr = (void *)(ptr); \
    BUILD_BUG_ON_MSG(!__same_type(*(ptr), ((type *)0)->member) && \
                     !__same_type(*(ptr), void), \
                     "pointer type mismatch in container_of()"); \
    ((type *)(__mptr - offsetof(type, member))); })

container_of()函数接受三个参数:一个指针ptr,容器的type,以及指针在容器内引用的member的名称。通过使用这些信息,宏可以扩展为指向包含结构的新地址,该结构容纳了相应的成员。

因此,在我们的示例中,在irqtest_work_handler()中,我们可以获得一个指向struct irqtest_data的指针,以告诉container_of()其成员work的地址。

有关container_of()函数的更多信息,可以在互联网上找到;但是,一个很好的起点是内核源代码中的linux/Documentation/driver-model/design-patterns.txt文件,该文件描述了在使用此宏的设备驱动程序中发现的一些常见设计模式。

可能有兴趣看一下通知器链,简称通知器,它是内核提供的一种通用机制,旨在为内核元素提供一种表达对一般异步事件发生感兴趣的方式。

通知器

通知器机制的基本构建块是在linux/include/linux/notifier.h头文件中定义的struct notifier_block,如下所示:

typedef int (*notifier_fn_t)(struct notifier_block *nb,
                        unsigned long action, void *data);

struct notifier_block {
    notifier_fn_t notifier_call;
    struct notifier_block __rcu *next;
    int priority;
};

该结构包含指向发生事件时要调用的函数的指针notifier_call。当调用通知器函数时传递的参数包括指向通知器块本身的nb指针,一个依赖于特定使用链的事件action代码,以及指向未指定私有数据类型的data指针,该类型可以以与 tasklets 或 waitqueues 类似的方式使用。

next字段由通知器内部管理,而priority字段定义了在通知器链中由notifier_call指向的函数的优先级。首先执行具有更高优先级的函数。实际上,几乎所有注册都将优先级留给通知器块定义之外,这意味着它以 0 作为默认值,并且执行顺序最终取决于注册顺序(这是一种半随机顺序)。

设备驱动程序开发人员不应该需要创建自己的通知器,而且很多时候他们需要使用现有的通知器。Linux 定义了几个通知器,如下所示:

  • 网络设备通知器(参见linux/include/linux/netdevice.h)-报告网络设备的事件

  • 背光通知器(参见linux/include/linux/backlight.h)-报告 LCD 背光事件

  • 挂起通知器(参见linux/include/linux/suspend.h)-报告挂起和恢复相关事件的电源

  • 重启通知器(参见linux/include/linux/reboot.h)-报告重启请求

  • 电源供应通知器(参见linux/include/linux/power_supply.h)-报告电源供应活动

每个通知器都有一个注册函数,可以用来要求系统在特定事件发生时通知。例如,以下代码被报告为请求网络设备和重启事件的有用示例:

static int __init notifier_init(void)
{
    int ret;

    ninfo.netdevice_nb.notifier_call = netdevice_notifier;
    ninfo.netdevice_nb.priority = 10; 

    ret = register_netdevice_notifier(&ninfo.netdevice_nb);
    if (ret) {
        pr_err("unable to register netdevice notifier\n");
        return ret;
    }

    ninfo.reboot_nb.notifier_call = reboot_notifier;
    ninfo.reboot_nb.priority = 10; 

    ret = register_reboot_notifier(&ninfo.reboot_nb);
    if (ret) {
        pr_err("unable to register reboot notifier\n");
        goto unregister_netdevice;
    }

    pr_info("notifier module loaded\n");

    return 0;

unregister_netdevice:
    unregister_netdevice_notifier(&ninfo.netdevice_nb);
    return ret;
}

static void __exit notifier_exit(void)
{
    unregister_netdevice_notifier(&ninfo.netdevice_nb);
    unregister_reboot_notifier(&ninfo.reboot_nb);

    pr_info("notifier module unloaded\n");
}

这里呈现的所有代码都在 GitHub 存储库中的notifier.c文件中。

register_netdevice_notifier()register_reboot_notifier()函数都使用以下定义的两个 struct notifier_block:

static struct notifier_data {
    struct notifier_block netdevice_nb;
    struct notifier_block reboot_nb;
    unsigned int data;
} ninfo;

通知器函数的定义如下:

static int netdevice_notifier(struct notifier_block *nb,
                              unsigned long code, void *unused)
{
    struct notifier_data *ninfo = container_of(nb, struct notifier_data,
                                               netdevice_nb);

    pr_info("netdevice: event #%d with code 0x%lx caught!\n",
                    ninfo->data++, code);

    return NOTIFY_DONE;
}

static int reboot_notifier(struct notifier_block *nb,
                           unsigned long code, void *unused)
{ 
    struct notifier_data *ninfo = container_of(nb, struct notifier_data,
                                               reboot_nb);

    pr_info("reboot: event #%d with code 0x%lx caught!\n",
                    ninfo->data++, code);

    return NOTIFY_DONE;
}

通过使用container_of(),像往常一样,我们可以获得指向我们的数据结构struct notifier_data的指针;然后,一旦我们的工作完成,我们必须返回在linux/include/linux/notifier.h头文件中定义的一个固定值:

#define NOTIFY_DONE       0x0000                     /* Don't care */
#define NOTIFY_OK         0x0001                     /* Suits me */
#define NOTIFY_STOP_MASK  0x8000                     /* Don't call further */
#define NOTIFY_BAD        (NOTIFY_STOP_MASK|0x0002)  /* Bad/Veto action */

它们的含义如下:

  • NOTIFY_DONE:对此通知不感兴趣。

  • NOTIFY_OK:通知已正确处理。

  • NOTIFY_BAD:此通知出现问题,因此停止调用此事件的回调函数!

NOTIFY_STOP_MASK可以用于封装(负)errno值,如下所示:

/* Encapsulate (negative) errno value (in particular, NOTIFY_BAD <=> EPERM). */
static inline int notifier_from_errno(int err)
{
    if (err)
        return NOTIFY_STOP_MASK | (NOTIFY_OK - err);

    return NOTIFY_OK;
}

然后可以使用notifier_to_errno()检索errno值,如下所示:

/* Restore (negative) errno value from notify return value. */
static inline int notifier_to_errno(int ret)
{
    ret &= ~NOTIFY_STOP_MASK;
    return ret > NOTIFY_OK ? NOTIFY_OK - ret : 0;
}

要测试我们的简单示例,我们必须编译notifier.c内核模块,然后将notifier.ko模块移动到 ESPRESSObin,然后可以将其插入内核,如下所示:

# insmod notifier.ko 
notifier:netdevice_notifier: netdevice: event #0 with code 0x5 caught!
notifier:netdevice_notifier: netdevice: event #1 with code 0x1 caught!
notifier:netdevice_notifier: netdevice: event #2 with code 0x5 caught!
notifier:netdevice_notifier: netdevice: event #3 with code 0x5 caught!
notifier:netdevice_notifier: netdevice: event #4 with code 0x5 caught!
notifier:netdevice_notifier: netdevice: event #5 with code 0x5 caught!
notifier:notifier_init: notifier module loaded

插入后,已经通知了一些事件;但是,为了生成新事件,我们可以尝试使用以下ip命令禁用或启用网络设备:

# ip link set lan0 up
notifier:netdevice_notifier: netdevice: event #6 with code 0xd caught!
RTNETLINK answers: Network is down

代码0xd对应于linux/include/linux/netdevice.h中定义的NETDEV_PRE_UP事件:

/* netdevice notifier chain. Please remember to update netdev_cmd_to_name()
 * and the rtnetlink notification exclusion list in rtnetlink_event() when
 * adding new types.
 */
enum netdev_cmd {
    NETDEV_UP = 1, /* For now you can't veto a device up/down */
    NETDEV_DOWN,
    NETDEV_REBOOT, /* Tell a protocol stack a network interface
                      detected a hardware crash and restarted
                      - we can use this eg to kick tcp sessions
                      once done */
    NETDEV_CHANGE, /* Notify device state change */
    NETDEV_REGISTER,
    NETDEV_UNREGISTER,
    NETDEV_CHANGEMTU, /* notify after mtu change happened */
    NETDEV_CHANGEADDR,
    NETDEV_GOING_DOWN,
    NETDEV_CHANGENAME,
    NETDEV_FEAT_CHANGE,
    NETDEV_BONDING_FAILOVER,
    NETDEV_PRE_UP,
...

如果我们重新启动系统,我们应该在内核消息中看到以下消息:

# reboot
...
[ 2804.502671] notifier:reboot_notifier: reboot: event #7 with code 1 caught!

内核定时器

内核定时器是请求内核在经过明确定义的时间后执行特定函数的简单方法。 Linux 实现了两种不同类型的内核定时器:在linux/include/linux/timer.h头文件中定义的旧但仍然有效的内核定时器和在linux/include/linux/hrtimer.h头文件中定义的新的高分辨率内核定时器。即使它们实现方式不同,但两种机制的工作方式非常相似:我们必须声明一个保存定时器数据的结构,可以通过适当的函数进行初始化,然后可以使用适当的函数启动定时器。一旦到期,定时器调用处理程序执行所需的操作,最终,我们有可能停止或重新启动定时器。

传统内核定时器仅支持 1 个 jiffy 的分辨率。 jiffy 的长度取决于 Linux 内核中定义的HZ的值(请参阅linux/include/asm-generic/param.h文件);通常在 PC 和其他一些平台上为 1 毫秒,在大多数嵌入式平台上设置为 10 毫秒。过去,1 毫秒的分辨率解决了大多数设备驱动程序开发人员的问题,但是现在,大多数外围设备需要更高的分辨率才能得到正确管理。这就是为什么需要更高分辨率的定时器,允许系统在更准确的时间间隔内快速唤醒和处理数据。目前,内核定时器已被高分辨率定时器所取代(即使它们仍然在内核源代码周围使用),其目标是在 Linux 中实现 POSIX 1003.1b 第十四部分(时钟和定时器)API,即精度优于 1 个 jiffy 的定时器。

请注意,我们刚刚看到,为了延迟作业,我们还可以使用延迟工作队列。

第十一章:附加信息:杂项内核内部

以下是有关动态内存分配和 I/O 内存访问方法的一些一般信息。

在谈论动态内存分配时,我们应该记住我们是在内核中使用 C 语言进行编程,因此非常重要的一点是要记住每个分配的内存块在不再使用时必须被释放。这非常重要,因为在用户空间,当一个进程结束执行时,内核(实际上知道进程拥有的所有内存块)可以轻松地收回所有进程分配的内存;但对于内核来说,情况并非如此。实际上,要求内存块的驱动程序(或其他内核实体)必须确保释放它,否则没有人会要求它回来,内存块将丢失,直到机器重新启动。

关于对 I/O 内存的访问,这是由底层外围寄存器下的内存单元组成的区域,我们必须考虑到我们不能使用它们的物理内存地址来访问它们;相反,我们将不得不使用相应的虚拟地址。事实上,Linux 是一个使用内存管理单元(MMU)来虚拟化和保护内存访问的操作系统,因此我们必须将每个外围设备的物理内存区域重新映射到其相应的虚拟内存区域,以便能够从中读取和写入。

这个操作可以很容易地通过使用代码段中介绍的内核函数来完成,但是非常重要的一点是必须在尝试进行任何 I/O 内存访问之前完成,否则将触发段错误。这可能会终止用户空间中的进程,或者可能因为设备驱动程序中的错误而终止内核本身。

动态内存分配

分配内存的最直接方式是使用kmalloc()函数,并且为了安全起见,最好使用清除分配的内存为零的例程,例如kzalloc()函数。另一方面,如果我们需要为数组分配内存,有专门的函数kmalloc_array()kcalloc()

以下是包含内存分配内核函数(以及相关的内核内存释放函数)的一些片段,如内核源文件linux/include/linux/slab.h中所述。

/**
 * kmalloc - allocate memory
 * @size: how many bytes of memory are required.
 * @flags: the type of memory to allocate.
...
*/
static __always_inline void *kmalloc(size_t size, gfp_t flags);

/**
 * kzalloc - allocate memory. The memory is set to zero.
 * @size: how many bytes of memory are required.
 * @flags: the type of memory to allocate (see kmalloc).
 */
static inline void *kzalloc(size_t size, gfp_t flags)
{
    return kmalloc(size, flags | __GFP_ZERO);
}

/**
 * kmalloc_array - allocate memory for an array.
 * @n: number of elements.
 * @size: element size.
 * @flags: the type of memory to allocate (see kmalloc).
 */
static inline void *kmalloc_array(size_t n, size_t size, gfp_t flags);

/**
 * kcalloc - allocate memory for an array. The memory is set to zero.
 * @n: number of elements.
 * @size: element size.
 * @flags: the type of memory to allocate (see kmalloc).
 */
static inline void *kcalloc(size_t n, size_t size, gfp_t flags)
{
    return kmalloc_array(n, size, flags | __GFP_ZERO);
}

void kfree(const void *);

所有前述函数都暴露了用户空间对应的malloc()和其他内存分配函数之间的两个主要区别:

  1. 使用kmalloc()和其他类似函数分配的块的最大大小是有限的。实际限制取决于硬件和内核配置,但是最好的做法是对小于页面大小的对象使用kmalloc()和其他内核辅助函数。

定义PAGE_SIZE信息内核源文件linux/include/asm-generic/page.h中指定了构成页面大小的字节数;通常情况下,32 位系统为 4096 字节,64 位系统为 8192 字节。用户可以通过通常的内核配置机制来明确选择它。

  1. 用于动态内存分配的内核函数,如kmalloc()和类似函数,需要额外的参数;分配标志用于指定kmalloc()的行为方式,如下面从内核源文件linux/include/linux/slab.h中报告的片段所述。
/**
 * kmalloc - allocate memory
 * @size: how many bytes of memory are required.
 * @flags: the type of memory to allocate.
 *
 * kmalloc is the normal method of allocating memory
 * for objects smaller than page size in the kernel.
 *
 * The @flags argument may be one of:
 *
 * %GFP_USER - Allocate memory on behalf of user. May sleep.
 *
 * %GFP_KERNEL - Allocate normal kernel ram. May sleep.
 *
 * %GFP_ATOMIC - Allocation will not sleep. May use emergency pools.
 * For example, use this inside interrupt handlers.
 *
 * %GFP_HIGHUSER - Allocate pages from high memory.
 *
 * %GFP_NOIO - Do not do any I/O at all while trying to get memory.
 *
 * %GFP_NOFS - Do not make any fs calls while trying to get memory.
 *
 * %GFP_NOWAIT - Allocation will not sleep.
...

正如我们所看到的,存在许多标志;然而,设备驱动程序开发人员主要感兴趣的是GFP_KERNELGFP_ATOMIC

很明显,这两个标志之间的主要区别在于前者可以分配正常的内核 RAM 并且可能会休眠,而后者在不允许调用者休眠的情况下执行相同的操作。这两个函数之间的这个巨大区别告诉我们,当我们处于中断上下文或进程上下文时,我们必须使用哪个标志。

第五章所示,管理中断和并发,当我们处于中断上下文时,我们不能休眠(如上面的代码所述),在这种情况下,我们必须通过指定GFP_ATOMIC标志来调用kmalloc()和相关函数,而GFP_KERNEL标志可以在其他地方使用,需要注意的是它可能导致调用者休眠,然后 CPU 可能会让我们执行其他操作;因此,我们应该避免以下操作:

spin_lock(...);
ptr = kmalloc(..., GFP_KERNEL);
spin_unlock(...);

实际上,即使我们在进程上下文中执行,持有自旋锁的休眠kmalloc()被认为是邪恶的!因此,在这种情况下,我们无论如何都必须使用GFP_ATOMIC标志。此外,需要注意的是,对于相同原因,成功的GFP_ATOMIC分配请求的最大大小往往比GFP_KERNEL请求要小,这与物理连续内存分配有关,内核保留了有限的内存池可供原子分配使用。

关于上面的第一点,对于可分配内存块的有限大小,对于大型分配,我们可以考虑使用另一类函数:vmalloc()vzalloc(),即使我们必须强调vmalloc()和相关函数分配的内存不是物理上连续的,不能用于直接内存访问DMA)活动(而kmalloc()和相关函数,如前面所述,分配了虚拟和物理寻址空间中的连续内存区域)。

目前,本书未涉及为 DMA 活动分配内存;但是,您可以在内核源代码中的linux/Documentation/DMA-API.txtlinux/Documentation/DMA-API-HOWTO.txt文件中获取有关此问题的更多信息。

以下是vmalloc()函数的原型和在linux/include/linux/vmalloc.h头文件中报告的函数定义:

extern void *vmalloc(unsigned long size);
extern void *vzalloc(unsigned long size);

如果我们不确定分配的大小是否对于kmalloc()来说太大,我们可以使用kvmalloc()及其衍生函数。这个函数将尝试使用kmalloc()来分配内存,如果分配失败,它将退而使用vmalloc()

请注意,kvmalloc()可能返回的内存不是物理上连续的。

还有关于kvmalloc()可以与哪些GFP_*标志一起使用的限制,可以在www.kernel.org/doc/html/latest/core-api/mm-api.html#c.kvmalloc_node中的kvmalloc_node()文档中找到。

以下是linux/include/linux/mm.h头文件中报告的kvmalloc()kvzalloc()kvmalloc_array()kvcalloc()kvfree()的代码片段:

static inline void *kvmalloc(size_t size, gfp_t flags)
{
    return kvmalloc_node(size, flags, NUMA_NO_NODE);
}

static inline void *kvzalloc(size_t size, gfp_t flags)
{
    return kvmalloc(size, flags | __GFP_ZERO);
}

static inline void *kvmalloc_array(size_t n, size_t size, gfp_t flags)
{
    size_t bytes;

    if (unlikely(check_mul_overflow(n, size, &bytes)))
        return NULL;

    return kvmalloc(bytes, flags);
}

static inline void *kvcalloc(size_t n, size_t size, gfp_t flags)
{
    return kvmalloc_array(n, size, flags | __GFP_ZERO);
}

extern void kvfree(const void *addr);

内核双向链表

在使用 Linux 的双向链表接口时,我们应该始终记住这些列表函数不执行锁定,因此我们的设备驱动程序(或其他内核实体)可能尝试对同一列表执行并发操作。这就是为什么我们必须确保实现一个良好的锁定方案来保护我们的数据免受竞争条件的影响。

要使用列表机制,我们的驱动程序必须包括头文件linux/include/linux/list.h;这个文件包括头文件linux/include/linux/types.h,在这里定义了struct list_head类型的简单结构如下:

struct list_head {
    struct list_head *next, *prev;
};

正如我们所看到的,这个结构包含两个指针(prevnext)指向list_head结构;这两个指针实现了双向链表的功能。然而,有趣的是struct list_head没有专用的数据字段,就像在经典的列表实现中那样。事实上,在 Linux 内核列表实现中,数据字段并没有嵌入在列表元素本身中;相反,列表结构是被认为被封装在相关数据结构中。这可能会让人困惑,但实际上并不是;事实上,要在我们的代码中使用 Linux 列表功能,我们只需要在使用列表的结构中嵌入一个struct list_head

我们可以在设备驱动程序中声明对象结构的简单示例如下:

struct l_struct {
    int data;
    ... 
    /* other driver specific fields */
    ...
    struct list_head list;
};

通过这样做,我们创建了一个带有自定义数据的双向链表。然后,要有效地创建我们的列表,我们只需要声明并初始化列表头,使用以下代码:

struct list_head data_list;
INIT_LIST_HEAD(&data_list);

与其他内核结构一样,我们有编译时对应的宏LIST_HEAD(),它可以用于在非动态列表分配的情况下执行相同的操作。在我们的示例中,我们可以这样做:LIST_HEAD(data_list)

一旦列表头部被声明并正确初始化,我们可以使用linux/include/linux/list.h文件中的几个函数来添加、删除或执行其他列表条目操作。

如果我们查看头文件,我们可以看到以下函数用于向列表中添加或删除元素:

/**
 * list_add - add a new entry
 * @new: new entry to be added
 * @head: list head to add it after
 *
 * Insert a new entry after the specified head.
 * This is good for implementing stacks.
 */
static inline void list_add(struct list_head *new, struct list_head *head);

 * list_del - deletes entry from list.
 * @entry: the element to delete from the list.
 * Note: list_empty() on entry does not return true after this, the entry is
 * in an undefined state.
 */
static inline void list_del(struct list_head *entry);

用于用新条目替换旧条目的以下函数也是可见的:

/**
 * list_replace - replace old entry by new one
 * @old : the element to be replaced
 * @new : the new element to insert
 *
 * If @old was empty, it will be overwritten.
 */
static inline void list_replace(struct list_head *old,
                                struct list_head *new);
...

这只是所有可用函数的一个子集。鼓励您查看linux/include/linux/list.h文件以发现更多。

除了前面的函数之外,用于向列表中添加或删除条目的宏更有趣。例如,如果我们希望以有序的方式添加新条目,我们可以这样做:

void add_ordered_entry(struct l_struct *new)
{
    struct list_head *ptr;
    struct my_struct *entry;

    list_for_each(ptr, &data_list) {
        entry = list_entry(ptr, struct l_struct, list);
        if (entry->data < new->data) {
            list_add_tail(&new->list, ptr);
            return;
        }
    }
    list_add_tail(&new->list, &data_list)
}

通过使用list_for_each()宏,我们可以迭代列表,并通过使用list_entry(),我们可以获得指向我们封闭数据的指针。请注意,我们必须将指向当前元素ptr、我们的结构类型以及我们结构中的列表条目的名称(在前面的示例中为list)传递给list_entry()

最后,我们可以使用list_add_tail()函数将我们的新元素添加到正确的位置。

请注意,list_entry()只是使用container_of()宏来执行其工作。该宏在第五章管理中断和并发性container_of()宏部分中有解释。

如果我们再次查看linux/include/linux/list.h文件,我们可以看到更多的函数,我们可以使用这些函数来从列表中获取条目或以不同的方式迭代所有列表元素:

/**
 * list_entry - get the struct for this entry
 * @ptr: the &struct list_head pointer.
 * @type: the type of the struct this is embedded in.
 * @member: the name of the list_head within the struct.
 */
#define list_entry(ptr, type, member) \
    container_of(ptr, type, member)

/**
 * list_first_entry - get the first element from a list
 * @ptr: the list head to take the element from.
 * @type: the type of the struct this is embedded in.
 * @member: the name of the list_head within the struct.
 *
 * Note, that list is expected to be not empty.
 */
#define list_first_entry(ptr, type, member) \
        list_entry((ptr)->next, type, member)

/**
 * list_last_entry - get the last element from a list
 * @ptr: the list head to take the element from.
 * @type: the type of the struct this is embedded in.
 * @member: the name of the list_head within the struct.
 *
 * Note, that list is expected to be not empty.
 */
#define list_last_entry(ptr, type, member) \
        list_entry((ptr)->prev, type, member)
...

一些宏也可用于迭代每个列表的元素:

/**
 * list_for_each - iterate over a list
 * @pos: the &struct list_head to use as a loop cursor.
 * @head: the head for your list.
 */
#define list_for_each(pos, head) \
        for (pos = (head)->next; pos != (head); pos = pos->next)

/**
 * list_for_each_prev - iterate over a list backwards
 * @pos: the &struct list_head to use as a loop cursor.
 * @head: the head for your list.
 */
#define list_for_each_prev(pos, head) \
        for (pos = (head)->prev; pos != (head); pos = pos->prev)
...

再次注意,这只是所有可用函数的一个子集,因此鼓励您查看linux/include/linux/list.h文件以发现更多。

内核哈希表

如前所述,对于链表,当使用 Linux 的哈希表接口时,我们应该始终记住这些哈希函数不执行锁定,因此我们的设备驱动程序(或其他内核实体)可能尝试对同一哈希表执行并发操作。这就是为什么我们必须确保还实现了一个良好的锁定方案来保护我们的数据免受竞争条件的影响。

与内核列表一样,我们可以声明然后初始化一个具有 2 的幂位大小的哈希表,使用以下代码:

DECLARE_HASHTABLE(data_hash, bits)
hash_init(data_hash);

与列表一样,我们有编译时对应的宏DEFINE_HASHTABLE(),它可以用于在非动态哈希表分配的情况下执行相同的操作。在我们的示例中,我们可以使用DEFINE_HASHTABLE(data_hash, bits)

这将创建并初始化一个名为data_hash的表,其大小基于 2 的幂。正如刚才所说,该表是使用包含内核struct hlist_head类型的桶来实现的;这是因为内核哈希表是使用哈希链实现的,而哈希冲突只是添加到列表的头部。为了更好地看到这一点,我们可以参考DECLARE_HASHTABLE()宏的定义:

#define DECLARE_HASHTABLE(name, bits) \
    struct hlist_head name[1 << (bits)]

完成后,可以构建一个包含struct hlist_node指针的结构来保存要插入的数据,就像我们之前为列表所做的那样:

struct h_struct {
    int key;
    int data;
    ... 
    /* other driver specific fields */
    ...
    struct hlist_node node;
};

struct hlist_node及其头struct hlist_headlinux/include/linux/types.h头文件中定义如下:

struct hlist_head {
    struct hlist_node *first;
};

struct hlist_node {
    struct hlist_node *next, **pprev;
};

然后可以使用hash_add()函数将新节点添加到哈希表中,如下所示,其中&entry.node是数据结构中struct hlist_node的指针,key是哈希键:

hash_add(data_hash, &entry.node, key);

密钥可以是任何东西;但通常是通过使用特殊的哈希函数应用于要存储的数据来计算的。例如,有一个 256 个桶的哈希表,密钥可以用以下hash_func()计算:

u8 hash_func(u8 *buf, size_t len)
{
    u8 key = 0;

    for (i = 0; i < len; i++)
        key += data[i];

    return key;
}

相反的操作,即删除,可以通过使用hash_del()函数来完成,如下所示:

hash_del(&entry.node);

但是,与列表一样,最有趣的宏是用于迭代表的宏。存在两种机制;一种是遍历整个哈希表,返回每个桶中的条目:

hash_for_each(name, bkt, node, obj, member)

另一个仅返回与密钥的哈希桶对应的条目:

hash_for_each_possible(name, obj, member, key)

通过使用最后一个宏,从哈希表中删除节点的过程如下:

void del_node(int data)
{
    int key = hash_func(data);
    struct h_struct *entry;

    hash_for_each_possible(data_hash, entry, node, key) {
        if (entry->data == data) {
            hash_del(&entry->node);
            return;
        }
    }
}

请注意,此实现只删除第一个匹配的条目。

通过使用hash_for_each_possible(),我们可以迭代与密钥相关的桶中的列表。

以下是linux/include/linux/hashtable.h文件中报告的hash_add()hash_del()hash_for_each_possible()的定义:

/**
 * hash_add - add an object to a hashtable
 * @hashtable: hashtable to add to
 * @node: the &struct hlist_node of the object to be added
 * @key: the key of the object to be added
 */
#define hash_add(hashtable, node, key) \
        hlist_add_head(node, &hashtable[hash_min(key, HASH_BITS(hashtable))])

/**
 * hash_del - remove an object from a hashtable
 * @node: &struct hlist_node of the object to remove
 */
static inline void hash_del(struct hlist_node *node);

/**
 * hash_for_each_possible - iterate over all possible objects hashing to the
 * same bucket
 * @name: hashtable to iterate
 * @obj: the type * to use as a loop cursor for each entry
 * @member: the name of the hlist_node within the struct
 * @key: the key of the objects to iterate over
 */
#define hash_for_each_possible(name, obj, member, key) \
        hlist_for_each_entry(obj, &name[hash_min(key, HASH_BITS(name))], member)

这些只是管理哈希表的所有可用函数的子集。鼓励您查看linux/include/linux/hashtable.h文件以了解更多。

访问 I/O 内存

为了能够有效地与外围设备通信,我们需要一种方法来读写其寄存器,为此我们有两种方法:通过I/O 端口或通过I/O 内存。前一种机制在本书中没有涵盖,因为它在现代平台中(除了 x86 和 x86_64 之外)并不经常使用,而后者只是使用正常的内存区域来映射每个外围寄存器,这是现代 CPU 中常用的一种方法。事实上,I/O 内存映射在片上系统SoC)系统中非常常见,其中 CPU 可以通过读写到众所周知的物理地址来与其内部外围设备通信;在这种情况下,每个外围设备都有其自己的保留地址,并且每个外围设备都连接到一个寄存器。

要看我所说的一个简单示例,您可以从ww1.microchip.com/downloads/en/DeviceDoc/Atmel-11121-32-bit-Cortex-A5-Microcontroller-SAMA5D3_Datasheet_B.pdf获取 SAMA5D3 CPU 的数据表,查看第 30 页,其中报告了整个 CPU 的完整内存映射。

然后,这个 I/O 内存映射被报告在与平台相关的设备树文件中。举个例子,如果我们看一下内核源文件中linux/arch/arm64/boot/dts/marvell/armada-37xx.dtsi文件中我们 ESPRESSObin 的 CPU 的 UART 控制器的定义,我们可以看到以下设置:

soc {
    compatible = "simple-bus";
    #address-cells = <2>;
    #size-cells = <2>;
    ranges;

    internal-regs@d0000000 {
        #address-cells = <1>;
        #size-cells = <1>;
        compatible = "simple-bus";
        /* 32M internal register @ 0xd000_0000 */
        ranges = <0x0 0x0 0xd0000000 0x2000000>;

...

        uart0: serial@12000 {
            compatible = "marvell,armada-3700-uart";
            reg = <0x12000 0x200>;
            clocks = <&xtalclk>;
            interrupts =
            <GIC_SPI 11 IRQ_TYPE_LEVEL_HIGH>,
            <GIC_SPI 12 IRQ_TYPE_LEVEL_HIGH>,
            <GIC_SPI 13 IRQ_TYPE_LEVEL_HIGH>;
            interrupt-names = "uart-sum", "uart-tx", "uart-rx";
            status = "disabled";
        };

如第四章中所解释的,使用设备树,我们可以推断 UART0 控制器被映射到物理地址0xd0012000。这也被我们在启动时可以看到的以下内核消息所证实:

d0012000.serial: ttyMV0 at MMIO 0xd0012000 (irq = 0, base_baud = 
1562500) is a mvebu-uart

好的,现在我们必须记住0xd0012000是 UART 控制器的物理地址,但我们的 CPU 知道虚拟地址,因为它使用其 MMU 来访问 RAM!那么,我们如何在物理地址0xd0012000和其虚拟对应地址之间进行转换呢?答案是:通过内存重新映射。在每次读取或写入 UART 控制器的寄存器之前,必须在内核中执行此操作,否则将引发段错误。

只是为了了解物理地址和虚拟地址之间的差异以及重新映射操作的行为,我们可以看一下名为devmem2的实用程序,该实用程序可以通过 ESPRESSObin 上的wget程序从free-electrons.com/pub/mirror/devmem2.c下载:

# wget http://free-electrons.com/pub/mirror/devmem2.c

如果我们看一下代码,我们会看到以下操作:

    if((fd = open("/dev/mem", O_RDWR | O_SYNC)) == -1) FATAL;
    printf("/dev/mem opened.\n"); 
    fflush(stdout);

    /* Map one page */
    map_base = mmap(0, MAP_SIZE,
                    PROT_READ | PROT_WRITE,
                    MAP_SHARED, fd, target & ~MAP_MASK);
    if(map_base == (void *) -1) FATAL;
    printf("Memory mapped at address %p.\n", map_base); 
    fflush(stdout);

因此,devmem2程序只是打开/dev/mem设备,然后调用mmap()系统调用。这将导致在内核源文件linux/ drivers/char/mem.c中执行mmap_mem()方法,其中实现了/dev/mem字符设备:

static int mmap_mem(struct file *file, struct vm_area_struct *vma)
{
    size_t size = vma->vm_end - vma->vm_start;
    phys_addr_t offset = (phys_addr_t)vma->vm_pgoff << PAGE_SHIFT;

...

    /* Remap-pfn-range will mark the range VM_IO */
    if (remap_pfn_range(vma,
                        vma->vm_start, vma->vm_pgoff,
                        size,
                        vma->vm_page_prot)) {
        return -EAGAIN;
    }
    return 0;
}

有关这些内存重新映射操作以及remap_pfn_range()函数和类似函数的使用的更多信息将在第七章“高级字符驱动程序操作”中更清楚。

好吧,mmap_mem()方法对物理地址0xd0012000进行内存重新映射操作,将其映射为适合 CPU 访问 UART 控制器寄存器的虚拟地址。

如果我们尝试在 ESPRESSObin 上使用以下命令编译代码,我们将得到一个可执行文件,从用户空间访问 UART 控制器的寄存器:

# make CFLAGS="-Wall -O" devmem2 cc -Wall -O devmem2.c -o devmem2

您可以安全地忽略下面显示的可能的警告消息:

devmem2.c:104:33: 警告:格式“%X”需要类型为“unsigned int”的参数,

但参数 2 的类型为'off_t {aka long int}' [-Wformat=]

printf("地址 0x%X(%p)处的值:0x%X\n",target,virt_addr,read_result

);

devmem2.c:104:44: 警告:格式“%X”需要类型为“unsigned int”的参数,

但参数 4 的类型为'long unsigned int' [-Wformat=]

printf("地址 0x%X(%p)处的值:0x%X\n",target,virt_addr,read_result

);

devmem2.c:123:22: 警告:格式“%X”需要类型为“unsigned int”的参数,

但参数 2 的类型为'long unsigned int' [-Wformat=]

printf("写入 0x%X;读回 0x%X\n",writeval,read_result);

devmem2.c:123:37: 警告:格式“%X”需要类型为“unsigned int”的参数,

但参数 3 的类型为'long unsigned int' [-Wformat=]

printf("写入 0x%X;读回 0x%X\n",writeval,read_result);

然后,如果我们执行程序,我们应该得到以下输出:

# ./devmem2 0xd0012000 
/dev/mem opened.
Memory mapped at address 0xffffbd41d000.
Value at address 0xD0012000 (0xffffbd41d000): 0xD

正如我们所看到的,devmem2程序按预期打印了重新映射结果,并且实际读取是使用虚拟地址完成的,而 MMU 又将其转换为所需的物理地址0xd0012000

好了,现在清楚了,访问外围寄存器需要进行内存重新映射,我们可以假设一旦我们有了一个虚拟地址物理映射到一个寄存器,我们可以简单地引用它来实际读取或写入数据。这是错误的!实际上,尽管硬件寄存器在内存中映射和通常的 RAM 内存之间有很强的相似性,但当我们访问 I/O 寄存器时,我们必须小心避免被 CPU 或编译器优化所欺骗,这些优化可能会修改预期的 I/O 行为。

I/O 寄存器和 RAM 之间的主要区别在于 I/O 操作具有副作用,而内存操作则没有;实际上,当我们向 RAM 中写入一个值时,我们希望它不会被其他人改变,但对于 I/O 内存来说,这并不是真的,因为我们的外设可能会改变寄存器中的一些数据,即使我们向其中写入了特定的值。这是一个非常重要的事实,因为为了获得良好的性能,RAM 内容可以被缓存,并且 CPU 指令流水线可以重新排序读/写指令;此外,编译器可以自主决定将数据值放入 CPU 寄存器而不将其写入内存,即使最终将其存储到内存中,写入和读取操作都可以在缓存内存上进行,而不必到达物理 RAM。即使最终将其存储到内存中,这两种优化在 I/O 内存上是不可接受的。实际上,这些优化在应用于常规内存时是透明且良性的,但在 I/O 操作中可能是致命的,因为外设有明确定义的编程方式,对其寄存器的读写操作不能重新排序或缓存,否则会导致故障。

这些是我们不能简单地引用虚拟内存地址来从内存映射的外设中读取和写入数据的主要原因。因此,驱动程序必须确保在访问寄存器时不执行缓存操作,也不进行读取或写入重排序;解决方案是使用实际执行读写操作的特殊函数。在linux/include/asm-generic/io.h头文件中,我们可以找到这些函数,如以下示例所示:

static inline void writeb(u8 value, volatile void __iomem *addr)
{
    __io_bw();
    __raw_writeb(value, addr);
    __io_aw();
}

static inline void writew(u16 value, volatile void __iomem *addr)
{
    __io_bw();
    __raw_writew(cpu_to_le16(value), addr);
    __io_aw();
}

static inline void writel(u32 value, volatile void __iomem *addr)
{
    __io_bw();
    __raw_writel(__cpu_to_le32(value), addr);
    __io_aw();
}

#ifdef CONFIG_64BIT
static inline void writeq(u64 value, volatile void __iomem *addr)
{
    __io_bw();
    __raw_writeq(__cpu_to_le64(value), addr);
    __io_aw();
}
#endif /* CONFIG_64BIT */

前述函数仅用于写入数据;您可以查看头文件以查看读取函数的定义,例如readb()readw()readl()readq()

每个函数都定义为与要操作的寄存器的大小相对应的明确定义的数据类型一起使用;此外,它们每个都使用内存屏障来指示 CPU 按照明确定义的顺序执行读写操作。

我不打算在本书中解释内存屏障是什么;如果您感兴趣,您可以在linux/Documentation/memory-barriers.txt文件中的内核文档目录中阅读更多相关内容。

作为前述功能的一个简单示例,我们可以看一下 Linux 源文件中linux/drivers/watchdog/sunxi_wdt.c文件中的sunxi_wdt_start()函数:

static int sunxi_wdt_start(struct watchdog_device *wdt_dev)
{
...
    void __iomem *wdt_base = sunxi_wdt->wdt_base;
    const struct sunxi_wdt_reg *regs = sunxi_wdt->wdt_regs;

...

    /* Set system reset function */
    reg = readl(wdt_base + regs->wdt_cfg);
    reg &= ~(regs->wdt_reset_mask);
    reg |= regs->wdt_reset_val;
    writel(reg, wdt_base + regs->wdt_cfg);

    /* Enable watchdog */
    reg = readl(wdt_base + regs->wdt_mode);
    reg |= WDT_MODE_EN;
    writel(reg, wdt_base + regs->wdt_mode);

    return 0;
}

一旦寄存器的基地址wdt_base和寄存器的映射regs已经获得,我们可以简单地通过使用readl()writel()来执行我们的读写操作,如前面的部分所示,并且我们可以放心地确保它们将被正确执行。

在内核中花费时间

在第五章中,管理中断和并发,我们看到了如何延迟在以后的时间执行操作;然而,可能会发生这样的情况,我们仍然需要在外设上的两个操作之间等待一段时间,如下所示:

writeb(0x12, ctrl_reg);
wait_us(100);
writeb(0x00, ctrl_reg);

也就是说,如果我们需要向寄存器中写入一个值,然后等待 100 微秒,然后再写入另一个值,这些操作可以通过简单地使用linux/include/linux/delay.h头文件(和其他文件)中定义的函数来完成,而不是使用之前介绍的技术(内核定时器和工作队列等):

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

void usleep_range(unsigned long min, unsigned long max);
void msleep(unsigned int msecs);
unsigned long msleep_interruptible(unsigned int msecs);
void ssleep(unsigned int seconds);

所有这些函数都是用于延迟一定量的时间,以纳秒、微秒或毫秒(或仅以秒为单位,如ssleep())表示。

第一组函数(即*delay()函数)可以在中断或进程上下文中的任何地方使用,而第二组函数必须仅在进程上下文中使用,因为它们可能会隐式进入睡眠状态。

此外,我们看到,例如,usleep_range()函数采用最小和最大睡眠时间,以通过允许高分辨率定时器利用已经安排的中断来减少功耗,而不是仅为此睡眠安排新的中断。以下是linux/kernel/time/timer.c文件中的函数描述:

/**
 * usleep_range - Sleep for an approximate time
 * @min: Minimum time in usecs to sleep
 * @max: Maximum time in usecs to sleep
 *
 * In non-atomic context where the exact wakeup time is flexible, use
 * usleep_range() instead of udelay(). The sleep improves responsiveness
 * by avoiding the CPU-hogging busy-wait of udelay(), and the range reduces
 * power usage by allowing hrtimers to take advantage of an already-
 * scheduled interrupt instead of scheduling a new one just for this sleep.
 */
void __sched usleep_range(unsigned long min, unsigned long max);

此外,在同一文件中,我们看到msleep_interruptible()msleep()的变体,可以被信号中断(在等待事件配方中,在第五章中,管理中断和并发性,我们谈到了这种可能性),返回值只是由于中断而未睡眠的时间(以毫秒为单位):

/**
 * msleep_interruptible - sleep waiting for signals
 * @msecs: Time in milliseconds to sleep for
 */
unsigned long msleep_interruptible(unsigned int msecs);

最后,我们还应该注意以下内容:

  • *delay()函数使用时钟速度的 jiffy 估计(loops_per_jiffy值),并将忙等待足够的循环周期以实现所需的延迟。

  • *delay()函数可能会在计算出的loops_per_jiffy太低(由于执行定时器中断所需的时间)或者缓存行为影响执行循环函数所需的时间,或者由于 CPU 时钟速率的变化而提前返回。

  • udelay()是通常首选的 API,ndelay()的级别精度实际上可能不存在于许多非 PC 设备上。

  • mdelay()是对udelay()的宏包装,以考虑将大参数传递给udelay()时可能发生的溢出。这就是为什么不建议使用mdelay(),代码应该重构以允许使用msleep()

第十二章:附加信息:高级字符驱动程序操作

技术要求

当我们必须管理外围设备时,通常需要修改其内部配置设置,或者将其从用户空间映射为内存缓冲区可能很有用,就好像我们可以通过引用指针来修改内部数据一样。

例如,帧缓冲区或帧抓取器是作为用户空间的大块内存映射的良好候选者。

在这种情况下,具有lseek()ioctl()mmap()系统调用的支持是至关重要的。如果从用户空间使用这些系统调用并不复杂,在内核中,它们需要驱动程序开发人员的一些注意,特别是mmap()系统调用,它涉及内核内存管理单元MMU)。

不仅驱动程序开发人员必须注意的主要任务之一是与用户空间的数据交换机制;事实上,实现这种机制的良好实现可能会简化许多外围设备的管理。例如,使用读取和写入内存缓冲区可能会提高系统性能,当一个或多个进程访问外围设备时,为用户空间开发人员提供了一系列良好的设置和管理机制,使他们能够充分利用我们的硬件。

使用 lseek()在文件中上下移动

在这里,我们应该记住read()write()系统调用的原型如下:

ssize_t (*read) (struct file *filp,
                 char __user *buf, size_t len, loff_t *ppos);
ssize_t (*write) (struct file *filp,
                 const char __user *buff, size_t len, loff_t *ppos);

当我们使用chapter_03/chrdev_test.c文件中的程序测试我们的字符驱动程序时,我们注意到除非我们对文件进行了如下修补,否则我们无法重新读取写入的数据:

--- a/chapter_03/chrdev_test.c
+++ b/chapter_03/chrdev_test.c
@@ -55,6 +55,16 @@ int main(int argc, char *argv[])
       dump("data written are: ", buf, n);
   }

+  close(fd);
+
+  ret = open(argv[1], O_RDWR);
+  if (ret < 0) {
+      perror("open");
+      exit(EXIT_FAILURE);
+  }
+  printf("file %s reopened\n", argv[1]);
+  fd = ret;
+
   for (c = 0; c < sizeof(buf); c += n) {
       ret = read(fd, buf, sizeof(buf));
       if (ret == 0) {

这是在不关闭然后重新打开与我们的驱动程序连接的文件的情况下(这样,内核会自动将ppos指向的值重置为0)。

然而,这并不是修改ppos指向的值的唯一方法;事实上,我们也可以使用lseek()系统调用来做到这一点。系统调用的原型,如其手册页(man 2 lseek)所述,如下所示:

off_t lseek(int fd, off_t offset, int whence);

在这里,whence参数可以假定以下值(由以下代码中的定义表示):

  SEEK_SET
      The file offset is set to offset bytes.

  SEEK_CUR
      The file offset is set to its current location plus offset
      bytes.

  SEEK_END
      The file offset is set to the size of the file plus offset
      bytes.

因此,例如,如果我们希望像在第三章中所做的那样将ppos指向我们设备的数据缓冲区的开头,但是不关闭和重新打开设备文件,我们可以这样做:

--- a/chapter_03/chrdev_test.c
+++ b/chapter_03/chrdev_test.c
@@ -55,6 +55,13 @@ int main(int argc, char *argv[])
        dump("data written are: ", buf + c, n);
    }

+  ret = lseek(fd, SEEK_SET, 0);
+  if (ret < 0) {
+       perror("lseek");
+       exit(EXIT_FAILURE);
+  }
+  printf("*ppos moved to 0\n");
+
   for (c = 0; c < sizeof(buf); c += n) {
       ret = read(fd, buf, sizeof(buf));
       if (ret == 0) {

请注意,所有这些修改都存储在 GitHub 存储库中的modify_lseek_to_chrdev_test.patch文件中,可以通过在chapter_03目录中使用以下命令应用,该目录中包含chrdev_test.c文件:

$ patch -p2 < ../../chapter_07/modify_lseek_to_chrdev_test.patch

如果我们看一下linux/include/uapi/linux/fs.h头文件,我们可以看到这些定义是如何声明的:


#define SEEK_SET    0 /* seek relative to beginning of file */
#define SEEK_CUR    1 /* seek relative to current file position */
#define SEEK_END    2 /* seek relative to end of file */

lseek()的实现是如此简单,以至于在linux/fs/read_write.c文件中我们可以找到一个名为default_llseek()的此方法的默认实现。其原型如下所示:

loff_t default_llseek(struct file *file,
                      loff_t offset, int whence);

这是因为如果我们不指定自己的实现,那么内核将自动使用前面代码块中的实现。然而,如果我们快速查看default_llseek()函数,我们会注意到它对我们的设备不太适用,因为它太面向文件(也就是说,当lseek()操作的文件是真实文件而不是外围设备时,它可以很好地工作),因此我们可以使用noop_llseek()函数来代替lseek()的两种替代实现之一来执行无操作:

/**
 * noop_llseek - No Operation Performed llseek implementation
 * @file: file structure to seek on
 * @offset: file offset to seek to
 * @whence: type of seek
 *
 * This is an implementation of ->llseek useable for the rare special case when
 * userspace expects the seek to succeed but the (device) file is actually not
 * able to perform the seek. In this case you use noop_llseek() instead of
 * falling back to the default implementation of ->llseek.
 */
loff_t noop_llseek(struct file *file, loff_t offset, int whence)
{
    return file->f_pos;
}

或者我们可以返回一个错误,然后使用no_llseek()函数向用户空间发出信号,表明我们的设备不适合使用寻址:

loff_t no_llseek(struct file *file, loff_t offset, int whence)
{
    return -ESPIPE;
}

这两个前面的函数位于内核源码的linux/fs/read_write.c文件中。

这两个功能的不同用法在上面关于noop_llseek()的评论中有很好的描述;虽然default_llseek()通常不适用于字符设备,但我们可以简单地使用no_llseek(),或者在那些罕见的特殊情况下,用户空间期望寻址成功,但(设备)文件实际上无法执行寻址时,我们可以使用no_llseek()如下:

static const struct file_operations chrdev_fops = {
    .owner   = THIS_MODULE,
    .llseek  = no_llseek,
    .read    = chrdev_read,
    .write   = chrdev_write,
    .open    = chrdev_open,
    .release = chrdev_release
};

这段代码是在 GitHub 的chapter_04/chrdev/chrdev.c文件中讨论的 chrdev 字符驱动程序中提到的,如第四章中所述,使用设备树

使用 ioctl()进行自定义命令

第三章中,使用字符驱动程序,我们讨论了文件抽象,并提到字符驱动程序在用户空间的角度上与通常的文件非常相似。但是,它根本不是一个文件;它被用作文件,但它属于一个外围设备,通常需要配置外围设备才能正常工作,因为它们可能支持不同的操作方法。

例如,让我们考虑一个串行端口;它看起来像一个文件,我们可以使用read()write()系统调用进行读取或写入,但在大多数情况下,我们还必须设置一些通信参数,如波特率、奇偶校验位等。当然,这些参数不能通过read()write()来设置,也不能通过使用open()系统调用来设置(即使它可以设置一些访问模式,如只读或只写),因此内核为我们提供了一个专用的系统调用,我们可以用来设置这些串行通信参数。这个系统调用就是ioctl()

从用户空间的角度来看,它看起来像是它的 man 页面(通过使用man 2 ioctl命令可用):

SYNOPSIS
   #include <sys/ioctl.h>

   int ioctl(int fd, unsigned long request, ...);

DESCRIPTION
   The ioctl() system call manipulates the underlying device parameters of special files. In particular, many operating characteristics of character special files (e.g., terminals) may be controlled with ioctl() requests.

如前面的段落所述,ioctl()系统调用通过获取文件描述符(通过打开我们的设备获得)作为第一个参数,以及设备相关的请求代码作为第二个参数,来操作特殊文件的底层设备参数(就像我们的字符设备一样,但实际上不仅仅是这样,它也可以用于网络或块设备),最后,作为第三个可选参数,是一个无类型指针,用户空间程序员可以用来与驱动程序交换数据。

因此,借助这个通用定义,驱动程序开发人员可以实现他们的自定义命令来管理底层设备。即使不是严格要求,ioctl()命令中编码了参数是输入参数还是输出参数,以及第三个参数的字节数。用于指定ioctl()请求的宏和定义位于linux/include/uapi/asm-generic/ioctl.h文件中,如下所述:

/*
 * Used to create numbers.
 *
 * NOTE: _IOW means userland is writing and kernel is reading. _IOR
 * means userland is reading and kernel is writing.
 */
#define _IO(type,nr)            _IOC(_IOC_NONE,(type),(nr),0)
#define _IOR(type,nr,size)      _IOC(_IOC_READ,(type),(nr),(_IOC_TYPECHECK(size)))
#define _IOW(type,nr,size)      _IOC(_IOC_WRITE,(type),(nr),(_IOC_TYPECHECK(size)))
#define _IOWR(type,nr,size)     _IOC(_IOC_READ|_IOC_WRITE,(type),(nr),(_IOC_TYPECHECK(size)))

正如我们在前面的评论中也可以看到的,read()write()操作是从用户空间的角度来看的,因此当我们将一个命令标记为写入时,我们的意思是用户空间在写入,内核在读取,而当我们将一个命令标记为读取时,我们的意思是完全相反。

关于如何使用这些宏的一个非常简单的例子,我们可以看一下关于看门狗的实现,位于文件linux/include/uapi/linux/watchdog.h中:

#include <linux/ioctl.h>
#include <linux/types.h>

#define WATCHDOG_IOCTL_BASE 'W'

struct watchdog_info {
    __u32 options;          /* Options the card/driver supports */
    __u32 firmware_version; /* Firmware version of the card */
    __u8 identity[32];      /* Identity of the board */
};

#define WDIOC_GETSUPPORT    _IOR(WATCHDOG_IOCTL_BASE, 0, struct watchdog_info)
#define WDIOC_GETSTATUS     _IOR(WATCHDOG_IOCTL_BASE, 1, int)
#define WDIOC_GETBOOTSTATUS _IOR(WATCHDOG_IOCTL_BASE, 2, int)
#define WDIOC_GETTEMP       _IOR(WATCHDOG_IOCTL_BASE, 3, int)
#define WDIOC_SETOPTIONS    _IOR(WATCHDOG_IOCTL_BASE, 4, int)
#define WDIOC_KEEPALIVE     _IOR(WATCHDOG_IOCTL_BASE, 5, int)
#define WDIOC_SETTIMEOUT    _IOWR(WATCHDOG_IOCTL_BASE, 6, int)
#define WDIOC_GETTIMEOUT    _IOR(WATCHDOG_IOCTL_BASE, 7, int)
#define WDIOC_SETPRETIMEOUT _IOWR(WATCHDOG_IOCTL_BASE, 8, int)
#define WDIOC_GETPRETIMEOUT _IOR(WATCHDOG_IOCTL_BASE, 9, int)
#define WDIOC_GETTIMELEFT   _IOR(WATCHDOG_IOCTL_BASE, 10, int)

看门狗(或看门狗定时器)通常用于自动化系统。它是一个电子定时器,用于检测和从计算机故障中恢复。事实上,在正常操作期间,系统中的一个进程应定期重置看门狗定时器,以防止它超时,因此,如果由于硬件故障或程序错误,系统未能重置看门狗,定时器将过期,并且系统将自动重新启动。

这里我们定义了一些命令来管理看门狗外围设备,每个命令都使用_IOR()宏(用于指定读取命令)或_IOWR宏(用于指定读/写命令)进行定义。每个命令都有一个渐进的数字,后面跟着第三个参数指向的数据类型,它可以是一个简单类型(如前面的int类型)或一个更复杂的类型(如前面的struct watchdog_info)。最后,WATCHDOG_IOCTL_BASE通用参数只是用来添加一个随机值,以避免命令重复。

在后面我们将解释我们的示例时,这些宏中type参数(在前面的示例中为WATCHDOG_IOCTL_BASE)的使用将更加清晰。

当然,这只是一个纯粹的约定,我们可以简单地使用渐进的整数来定义我们的ioctl()命令,它仍然可以完美地工作;然而,通过这种方式行事,我们将嵌入到命令代码中很多有用的信息。

一旦所有命令都被定义,我们需要添加我们自定义的ioctl()实现,并且通过查看linux/include/linux/fs.h文件中的struct file_operations,我们可以看到其中存在两个:

struct file_operations {
...
    long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
    long (*compat_ioctl) (struct file *, unsigned int, unsigned long);

在 2.6.36 之前的内核中,只有一个ioctl()方法可以获取Big Kernel LockBKL),因此其他任何东西都无法同时执行。这导致多处理器机器上的性能非常糟糕,因此大力去除它,这就是为什么引入了unlocked_ioctl()。通过使用它,每个驱动程序开发人员都可以选择使用哪个锁。

另一方面,compat_ioctl(),尽管是同时添加的,但实际上与unlocked_ioctl()无关。它的目的是允许 32 位用户空间程序在 64 位内核上进行ioctl()调用。

最后,我们应该首先注意到命令和结构定义必须在用户空间和内核空间中使用,因此当我们定义交换的数据类型时,必须使用这两个空间都可用的数据类型(这就是为什么使用__u32类型而不是u32,后者实际上只存在于内核中)。

此外,当我们希望使用自定义的ioctl()命令时,我们必须将它们定义到一个单独的头文件中,并且必须与用户空间共享;通过这种方式,我们可以将内核代码与用户空间分开。然而,如果难以将所有用户空间代码与内核空间分开,我们可以使用__KERNEL__定义,如下面的片段所示,指示预处理器根据我们编译的空间来排除一些代码:

#ifdef __KERNEL__
  /* This is code for kernel space */
  ...
#else
  /* This is code for user space */
  ...
#endif

这就是为什么通常,包含ioctl()命令的头文件通常位于linux/include/uapi目录下,该目录包含用户空间程序编译所需的所有头文件。

使用 mmap()访问 I/O 内存

第六章杂项内核内部中的获取 I/O 内存访问中,我们看到了 MMU 的工作原理以及如何访问内存映射的外围设备。在内核空间中,我们必须指示 MMU 以便正确地将虚拟地址转换为一个正确的地址,这个地址必须指向我们外围设备所属的一个明确定义的物理地址,否则我们无法控制它!

另一方面,在该部分,我们还使用了一个名为devmem2的用户空间工具,它可以使用mmap()系统调用从用户空间访问物理地址。这个系统调用非常有趣,因为它允许我们做很多有用的事情,所以让我们先来看一下它的 man 页面(man 2 mmap):

NAME
   mmap, munmap - map or unmap files or devices into memory

SYNOPSIS
   #include <sys/mman.h>

   void *mmap(void *addr, size_t length, int prot, int flags,
                  int fd, off_t offset);
   int munmap(void *addr, size_t length);

DESCRIPTION
   mmap() creates a new mapping in the virtual address space of the call‐
   ing process. The starting address for the new mapping is specified in
   addr. The length argument specifies the length of the mapping (which
   must be greater than 0).

正如我们从前面的片段中看到的,通过使用mmap(),我们可以在调用进程的虚拟地址空间中创建一个新的映射,这个映射可以与作为参数传递的文件描述符fd相关联。

通常,此系统调用用于以这样的方式将普通文件映射到系统内存中,以便可以使用普通指针而不是通常的read()write()系统调用来寻址。

举个简单的例子,让我们考虑一个通常的文件如下:

$ cat textfile.txt 
This is a test file

This is line 3.

End of the file

这是一个包含三行文本的普通文本文件。我们可以在终端上使用cat命令读取和写入它,就像之前所述的那样;当然,我们现在知道cat命令在文件上运行open(),然后是一个或多个read()操作,然后是一个或多个write()操作,最后是标准输出(反过来是连接到我们终端的文件抽象)。但是,这个文件也可以被读取为一个 char 的内存缓冲区,使用mmap()系统调用,可以通过以下步骤完成:

    ret = open(argv[1], O_RDWR);
    if (ret < 0) {
        perror("open");
        exit(EXIT_FAILURE);
    }
    printf("file %s opened\n", argv[1]);
    fd = ret;

    /* Try to remap file into memory */
    addr = mmap(NULL, len, PROT_READ | PROT_WRITE,
                MAP_FILE | MAP_SHARED, fd, 0);
    if (addr == MAP_FAILED) {
        perror("mmap");
        exit(EXIT_FAILURE);
    }

    ptr = (char *) addr;
    for (i = 0; i < len; i++)
        printf("%c", ptr[i]);

前面示例的完整代码实现将在以下片段中呈现。这是chrdev_mmap.c文件的片段。

因此,正如我们所看到的,我们首先像往常一样打开文件,但是,我们没有使用read()系统调用,而是使用了mmap(),最后,我们使用返回的内存地址作为 char 指针来打印内存缓冲区。请注意,在mmap()之后,我们将在内存中得到文件的图像。

如果我们尝试在textfile.txt文件上执行前面的代码,我们会得到我们期望的结果:

# ls -l textfile.txt 
-rw-r--r-- 1 root root 54 May 11 16:41 textfile.txt
# ./chrdev_mmap textfile.txt 54 
file textfile.txt opened
got address=0xffff8357b000 and len=54
---
This is a test file

This is line 3.

End of the file

请注意,我使用ls命令获取了chrdev_mmap程序所需的文件长度。

现在我们应该问自己是否有办法像上面的文本文件一样映射字符设备(从用户空间的角度看起来非常类似文件);显然,答案是肯定的!我们必须使用struct file_operations中定义的mmap()方法:

struct file_operations {
...
        int (*mmap) (struct file *, struct vm_area_struct *);

除了我们已经完全了解的通常的struct file指针之外,此函数还需要vma参数(指向struct vm_area_struct的指针),用于指示应该由驱动程序映射内存的虚拟地址空间。

struct vm_area_struct包含有关连续虚拟内存区域的信息,其特征是起始地址、停止地址、长度和权限。

每个进程拥有更多的虚拟内存区域,可以通过查看名为/proc/<PID>/maps的相对 procfs 文件来检查(其中<PID>是进程的 PID 号)。

虚拟内存区域是 Linux 内存管理器的一个非常复杂的部分,本书未涉及。好奇的读者可以查看www.kernel.org/doc/html/latest/admin-guide/mm/index.html以获取更多信息。

将物理地址映射到用户地址空间,如vma参数所示,可以使用辅助函数轻松完成,例如在头文件linux/include/linux/mm.h中定义的remap_pfn_range()

int remap_pfn_range(structure vm_area_struct *vma,
                    unsigned long addr,
                    unsigned long pfn, unsigned long size,
                    pgprot_t prot);

它将由pfn寻址的连续物理地址空间映射到由vma指针表示的虚拟空间。具体来说,参数是:

  • vma - 进行映射的虚拟内存空间

  • addr - 重新映射开始的虚拟地址空间

  • pfn - 虚拟地址应映射到的物理地址(以页面帧号表示)

  • size - 要映射的内存大小(以字节为单位)

  • prot - 此映射的保护标志

因此,一个真正简单的mmap()实现,考虑到外围设备在物理地址base_addr处具有内存区域,大小为area_len,可以如下所示:

static int my_mmap(struct file *filp, struct vm_area_struct *vma)
{
    struct my_device *my_ptr = filp->private_data;
    size_t size = vma->vm_end - vma->vm_start;
    phys_addr_t offset = (phys_addr_t) vma->vm_pgoff << PAGE_SHIFT;
    unsigned long pfn;

    /* Does it even fit in phys_addr_t? */
    if (offset >> PAGE_SHIFT != vma->vm_pgoff)
        return -EINVAL;

    /* We cannot mmap too big areas */
    if ((offset > my_ptr->area_len) ||
        (size > my_ptr->area_len - offset))
        return -EINVAL;

    /* Remap-pfn-range will mark the range VM_IO */
    if (remap_pfn_range(vma, vma->vm_start,
                        my_ptr->base_addr, size,
                        vma->vm_page_prot))
        return -EAGAIN;

    return 0;
}

最后需要注意的是,remap_pfn_range()使用物理地址,而使用kmalloc()vmalloc()函数和相关函数(参见第六章杂项内核内部)分配的内存必须使用不同的方法进行管理。对于kmalloc(),我们可以使用以下方法来获取pfn参数:

unsigned long pfn = virt_to_phys(kvirt) >> PAGE_SHIFT;

其中 kvirt 是由kmalloc()返回的内核虚拟地址要重新映射,对于vmalloc(),我们可以这样做:

unsigned long pfn = vmalloc_to_pfn(vvirt);

在这里,vvirt是由vmalloc()返回的内核虚拟地址要重新映射。

请注意,使用vmalloc()分配的内存不是物理上连续的,因此,如果我们想要映射使用它分配的范围,我们必须逐个映射每个页面,并计算每个页面的物理地址。这是一个更复杂的操作,本书没有解释,因为它与设备驱动程序无关(真正的外围设备只使用物理地址)。

使用进程上下文进行锁定

了解如何避免竞争条件是很重要的,因为可能会有多个进程尝试访问我们的驱动程序,或者如何使读取进程进入睡眠状态(我们在这里讨论读取,但对于写入也是一样的)如果我们的驱动程序没有数据供应。前一种情况将在这里介绍,而后一种情况将在下一节介绍。

如果我们看一下我们的 chrdev 驱动程序中如何实现read()write()系统调用,我们很容易注意到,如果多个进程尝试进行read()调用,甚至如果一个进程尝试进行read()调用,另一个尝试进行write()调用,就会发生竞争条件。这是因为 ESPRESSObin 的 CPU 是由两个核心组成的多处理器,因此它可以有效地同时执行两个进程。

然而,即使我们的系统只有一个核心,由于例如函数copy_to_user()copy_from_user()可能使调用进程进入睡眠状态,因此调度程序可能会撤销 CPU 以便将其分配给另一个进程,这样,即使我们的系统只有一个核心,仍然可能发生read()write()方法内部的代码以交错(即非原子)方式执行。

为了避免这些情况可能发生的竞争条件,一个真正可靠的解决方案是使用互斥锁,正如第五章中所介绍的那样,管理中断和并发

我们只需要为每个 chrdev 设备使用一个互斥锁来保护对驱动程序方法的多次访问。

使用 poll()和 select()等待 I/O 操作

在现代计算机这样的复杂系统中,通常会有几个有用的外围设备来获取有关外部环境和/或系统状态的信息。有时,我们可能使用不同的进程来管理它们,但可能需要同时管理多个外围设备,但只有一个进程。

在这种情况下,我们可以想象对每个外围设备进行多次read()系统调用来获取其数据,但是如果一个外围设备非常慢,需要很长时间才能返回其数据会发生什么?如果我们这样做,可能会减慢所有数据采集的速度(甚至如果一个外围设备没有接收到新数据,可能会锁定数据采集):

fd1 = open("/dev/device1", ...);
fd2 = open("/dev/device2", ...);
fd3 = open("/dev/device3", ...);

while (1) {
    read(fd1, buf1, size1);
    read(fd2, buf2, size2);
    read(fd3, buf3, size3);

    /* Now use data from peripherals */
    ...
}

实际上,如果一个外围设备很慢,或者需要很长时间才能返回其数据,我们的循环将停止等待它,我们的程序可能无法正常工作。

一个可能的解决方案是在有问题的外围设备上使用O_NONBLOCK标志,甚至在所有外围设备上使用,但这样做可能会使 CPU 过载,产生不必要的系统调用。向内核询问哪个文件描述符属于持有准备好被读取的数据的外围设备(或者可以用于写入)可能更加优雅(和有效)。

为此,我们可以使用poll()select()系统调用。poll()手册页中指出:

NAME
   poll, ppoll - wait for some event on a file descriptor

SYNOPSIS
   #include <poll.h>

   int poll(struct pollfd *fds, nfds_t nfds, int timeout);

   #define _GNU_SOURCE /* See feature_test_macros(7) */
   #include <signal.h>
   #include <poll.h>

   int ppoll(struct pollfd *fds, nfds_t nfds,
           const struct timespec *tmo_p, const sigset_t *sigmask);

另一方面,select()手册页如下所示:

NAME
  select, pselect, FD_CLR, FD_ISSET, FD_SET, FD_ZERO - synchronous I/O
   multiplexing

SYNOPSIS
   /* According to POSIX.1-2001, POSIX.1-2008 */
   #include <sys/select.h>

   /* According to earlier standards */
   #include <sys/time.h>
   #include <sys/types.h>
   #include <unistd.h>

   int select(int nfds, fd_set *readfds, fd_set *writefds,
              fd_set *exceptfds, struct timeval *timeout);

   void FD_CLR(int fd, fd_set *set);
   int FD_ISSET(int fd, fd_set *set);
   void FD_SET(int fd, fd_set *set);
   void FD_ZERO(fd_set *set);

即使它们看起来非常不同,它们几乎做相同的事情;实际上,在内核内部,它们是通过使用相同的poll()方法来实现的,该方法在著名的struct file_operations中定义如下(请参阅linux/include/linux/fs.h文件):

struct file_operations {
...
    __poll_t (*poll) (struct file *, struct poll_table_struct *);

从内核的角度来看,poll()方法的实现非常简单;我们只需要上面使用的等待队列,然后我们必须验证我们的设备是否有一些数据要返回。简而言之,通用的poll()方法如下所示:

static __poll_t simple_poll(struct file *filp, poll_table *wait)
{
    struct simple_device *chrdev = filp->private_data;
    __poll_t mask = 0;

    poll_wait(filp, &simple_device->queue, wait);

    if (has_data_to_read(simple_device))
        mask |= EPOLLIN | EPOLLRDNORM;

    if (has_space_to_write(simple_device))
        mask |= EPOLLOUT | EPOLLWRNORM;

    return mask;
}

我们只需使用poll_wait()函数告诉内核驱动程序使用哪个等待队列来使读取或写入进程进入睡眠状态,然后我们将变量mask返回为 0;如果没有准备好要读取的数据,或者我们无法接受新的要写入的数据,我们将返回EPOLLIN | EPOLLRDNORM值,如果有一些数据可以按位读取,并且我们也愿意接受这些数据。

所有可用的poll()事件都在头文件linux/include/uapi/linux/eventpoll.h中定义。

一旦poll()方法被实现,我们可以使用它,例如,如下所示使用select()

fd_set read_fds;

fd1 = open("/dev/device1", ...);
fd2 = open("/dev/device2", ...);
fd3 = open("/dev/device3", ...);

while (1) {
    FD_ZERO(&read_fds);
    FD_SET(fd1, &read_fds);
    FD_SET(fd2, &read_fds);
    FD_SET(fd2, &read_fds);

    select(FD_SETSIZE, &read_fds, NULL, NULL, NULL);

    if (FD_ISSET(fd1, &read_fds))
        read(fd1, buf1, size1);
    if (FD_ISSET(fd2, &read_fds))
        read(fd2, buf2, size2);
    if (FD_ISSET(fd3, &read_fds))
        read(fd3, buf3, size3);

    /* Now use data from peripherals */
    ...
}

打开所有需要的文件描述符后,我们必须使用FD_ZERO()宏清除read_fds变量,然后使用FD_SET()宏将每个文件描述符添加到由read_fds表示的读取进程集合中。完成后,我们可以将read_fds传递给select(),以指示内核要观察哪些文件描述符。

请注意,通常情况下,我们应该将观察集合中文件描述符的最高编号加 1 作为select()系统调用的第一个参数;然而,我们也可以传递FD_SETSIZE值,这是系统允许的最大允许值。这可能是一个非常大的值,因此以这种方式编程会导致扫描整个文件描述符位图的低效性;好的程序员应该使用最大值加 1。

另外,请注意,我们的示例适用于读取,但完全相同的方法也适用于写入!

使用fasync()管理异步通知

在前一节中,我们考虑了一个特殊情况,即我们可能需要管理多个外围设备的情况。在这种情况下,我们可以询问内核,即准备好的文件描述符,从哪里获取数据或使用poll()select()系统调用将数据写入。然而,这不是唯一的解决方案。另一种可能性是使用fasync()方法。

通过使用这种方法,我们可以要求内核在文件描述符上发生新事件时发送信号(通常是SIGIO);当然,事件是准备好读取或准备好写入的事件,文件描述符是与我们的外围设备连接的文件描述符。

由于本书中已经介绍的方法,fasync()方法没有用户空间对应项;根本没有fasync()系统调用。我们可以通过利用fcntl()系统调用间接使用它。如果我们查看它的手册页,我们会看到以下内容:

NAME
   fcntl - manipulate file descriptor

SYNOPSIS
   #include <unistd.h>
   #include <fcntl.h>

   int fcntl(int fd, int cmd, ... /* arg */ );

...

   F_SETOWN (int)
          Set the process ID or process group ID that will receive SIGIO
          and SIGURG signals for events on the file descriptor fd. The
          target process or process group ID is specified in arg. A
          process ID is specified as a positive value; a process group ID
          is specified as a negative value. Most commonly, the calling
          process specifies itself as the owner (that is, arg is specified
          as getpid(2)).

现在,让我们一步一步来。从内核的角度来看,我们必须实现fasync()方法,如下所示(请参阅linux/include/linux/fs.h文件中的struct file_operations):

struct file_operations {
...
    int (*fsync) (struct file *, loff_t, loff_t, int datasync);

它的实现非常简单,因为通过使用fasync_helper()辅助函数,我们只需要在以下通用驱动程序中报告的步骤:

static int simple_fasync(int fd, struct file *filp, int on)
{
    struct simple_device *simple = filp->private_data;

    return fasync_helper(fd, filp, on, &simple->fasync_queue);
}

其中,fasync_queue是一个指向struct fasync_struct的指针,内核使用它来排队所有对接收SIGIO信号感兴趣的进程,每当驱动程序准备好进行读取或写入操作时。这些事件使用kill_fasync()函数通知,通常在中断处理程序中或者每当我们知道新数据已经到达或者我们准备写入时。

kill_fasync(&simple->fasync_queue, SIGIO, POLL_IN);

请注意,当数据可供读取时,我们必须使用POLL_IN,而当我们的外围设备准备好接受新数据时,我们应该使用POLL_OUT

请参阅linux/include/uapi/asm-generic/siginfo.h文件,查看所有可用的POLL_*定义。

从用户空间的角度来看,我们需要采取一些步骤来实现SIGIO信号:

  1. 首先,我们必须安装一个合适的信号处理程序。

  2. 然后,我们必须使用F_SETOWN命令调用fcntl()来设置将接收与我们的设备相关的SIGIO的进程 ID(通常称为 PID)(由文件描述符fd表示)。

  3. 然后,我们必须通过设置FASYNC位来更改描述文件访问模式的flags

一个可能的实现如下:

long flags;

fd = open("/dev/device", ...);

signal(SIGIO, sigio_handler);

fcntl(fd, F_SETOWN, getpid());

flags = fcntl(fd, F_GETFL);

fcntl(fd, F_SETFL, flags | FASYNC);
posted @   绝不原创的飞龙  阅读(148)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· CSnakes vs Python.NET:高效嵌入与灵活互通的跨语言方案对比
· DeepSeek “源神”启动!「GitHub 热点速览」
· 我与微信审核的“相爱相杀”看个人小程序副业
· Plotly.NET 一个为 .NET 打造的强大开源交互式图表库
· 上周热点回顾(2.17-2.23)
点击右上角即可分享
微信分享提示