Skip to content

Latest commit

 

History

History
1031 lines (784 loc) · 57.2 KB

File metadata and controls

1031 lines (784 loc) · 57.2 KB

十一、与设备驱动程序接口

内核设备驱动程序是向系统的其余部分公开底层硬件的机制。 作为嵌入式系统的开发人员,您需要了解这些设备驱动程序如何适应整个体系结构,以及如何从 用户空间程序访问它们。 您的系统可能会有一些新奇的硬件,您必须找出访问它们的方法。 在许多情况下,您会发现已经为您提供了设备驱动程序,您可以在不编写任何内核代码的情况下实现您想要的一切。 例如,您可以使用sysfs中的 文件操作 GPIO 管脚和 LED,并且可以使用一些库来访问串行总线,包括SPI(串行外围接口)和I2C(内部集成电路)。

有很多地方可以了解如何编写设备驱动程序,但很少有人告诉您为什么要这样做,以及在这样做时有哪些选择。 这就是我想在这里介绍的内容。 但是,请记住,这不是一本专门编写内核设备驱动程序的书,这里给出的信息是为了帮助您导航,而不一定是在那里安家。 有许多好书和文章可以帮助您编写设备驱动程序,其中一些列在本章末尾的进一步阅读部分。

在本章中,我们将介绍以下主题:

  • 设备驱动程序的作用
  • 字符设备
  • 数据块设备
  • 网络设备
  • 在运行时查找有关驱动程序的信息
  • 查找正确的设备驱动程序
  • 用户空间中的设备驱动程序
  • 编写内核设备驱动程序
  • 发现硬件配置

我们开始吧!

技术要求

要按照本章中的示例操作,请确保您具备以下条件:

  • 一种基于 Linux 的主机系统
  • 一种 microSD 卡读卡器和卡
  • ♪Beaglebone Black♪
  • 5V 1A 直流电源
  • 用于网络连接的以太网电缆和端口

本章的所有代码都可以在本书 GitHub 存储库的Chapter11文件夹中找到:https://github.com/PacktPublishing/Mastering-Embedded-Linux-Programming-Third-Edition

设备驱动程序的角色

正如我在第 4 章配置和构建内核中提到的,内核的功能之一是封装计算机系统的许多硬件接口,并以一致的方式将它们呈现给用户空间程序。 内核具有旨在简化设备驱动程序编写的框架,设备驱动程序是在上面的内核和下面的硬件之间进行协调的一段代码。 可以编写设备驱动程序来控制诸如 UART 或 MMC 控制器之类的物理设备,或者它可以表示诸如空设备(/dev/null)或内存磁盘之类的虚拟设备。 一个驱动程序可以控制多个同类设备。

内核设备驱动程序代码以高特权级别运行,内核的其余部分也是如此。 它可以完全访问处理器地址空间和硬件寄存器。 它可以处理中断和 DMA 传输。 它还可以利用复杂的内核基础设施进行同步和内存管理。 但是,您应该意识到这也有不利的一面;如果错误驱动程序出现问题,它可能会真的出错并导致系统崩溃。 因此,存在一个原则,即设备驱动程序应该尽可能简单,只向做出真正决策的应用提供信息。 您经常听到这被表示为内核中没有策略。 用户空间负责设置管理系统整体行为的策略。 例如,加载内核模块以响应外部事件(如插入新的 USB 设备)是用户空间程序udev的责任,而不是内核的责任。 内核只是提供了一种加载内核模块的方法。

在 Linux 中,主要有三种类型的设备驱动程序:

  • 字符:这是,表示具有丰富功能且应用代码和驱动程序之间只有一层的无缓冲 I/O。 它是实现自定义设备驱动程序的首选。
  • :它有一个接口,专为海量存储设备的块 I/O 量身定做。 有一层厚厚的缓冲层,旨在使磁盘尽可能快地读写,这使得它不适合其他任何东西。
  • 网络:这类似于块设备,但用于发送和接收网络数据包,而不是磁盘块。

还有第四种类型,它将自己表示为伪文件系统之一中的一组文件。 例如,您可以通过 /sys/class/gpio中的一组文件访问 GPIO 驱动程序,正如我将在本章后面描述的那样。 让我们从 中更详细地了解这三种基本设备类型开始。

字符设备

字符设备在用户空间中由称为设备节点的特殊文件标识。 此文件名使用与其关联的主号和次号映射到设备驱动程序。 一般而言,主编号将设备节点映射到特定的设备驱动程序,而次要编号告诉驱动程序正在访问哪个接口。 例如,ARM 多功能 PB 上第一个串口的设备节点命名为/dev/ttyAMA0,其主编号为204,从编号为64。 第二个串行端口的设备节点具有相同的主编号,因为它由相同的设备驱动程序处理,但次要编号是65。 我们可以从下面的目录列表中看到所有四个串行端口的编号:

# ls -l /dev/ttyAMA*
crw-rw---- 1 root root 204, 64 Jan 1 1970 /dev/ttyAMA0
crw-rw---- 1 root root 204, 65 Jan 1 1970 /dev/ttyAMA1
crw-rw---- 1 root root 204, 66 Jan 1 1970 /dev/ttyAMA2
crw-rw---- 1 root root 204, 67 Jan 1 1970 /dev/ttyAMA3

可以在Documentation/devices.txt的内核文档中找到标准主号和次号的列表。 该列表不会经常更新,也不包括上一段中描述的ttyAMA设备。 不过,如果您查看drivers/tty/serial/amba-pl011.c中的内核源代码,您将会看到声明主号和次号的位置:

#define SERIAL_AMBA_MAJOR 204
#define SERIAL_AMBA_MINOR 64

在设备有多个实例的情况下,就像ttyAMA驱动程序一样,形成设备节点名称的约定是采用基本名称ttyAMA,并在本例中将实例编号从0附加到3

正如我在第 5 章构建根文件系统中提到的,可以通过几种方式创建设备节点:

  • devtmpfs:设备节点是在设备驱动程序使用驱动程序提供的基本名称(ttyAMA)和实例编号注册新设备接口时创建的。
  • udevmdev(没有devtmpfs):基本上与devtmpfs相同,只是用户空间守护进程程序必须从sysfs提取设备名称并创建节点。 我稍后会谈到sysfs
  • mknod:如果您使用的是静态设备节点,则使用mknod手动创建它们。

从我在这里使用的数字中,您可能会有这样的印象:主要数字和次要数字都是 0 到 255 范围内的 8 位数字。 事实上,从 Linux2.6 开始,主数字是 12 位长,这给出了从 1 到 4,095 的有效数字,次要数字是 20 位,从 0 到 1,048,575。

当您打开字符设备节点时,内核会检查主号和次号是否落入字符设备驱动程序注册的范围内。 如果是,则将调用传递给驱动程序;否则,打开调用失败。 设备驱动程序可以提取次要编号以找出要使用的硬件接口。

要编写访问设备驱动程序的程序,您必须对其工作原理有一定的了解。 换句话说,设备驱动程序与文件不同:您对其执行的操作会更改设备的状态。 一个简单的例子是伪随机数生成器urandom,它会在您每次读取随机数据时返回字节数。 下面是一个执行此操作的程序(您可以在MELP/Chapter11/read-urandom中找到代码):

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int main(void)
{
   int f;
   unsigned int rnd;
   int n;
   f = open("/dev/urandom", O_RDONLY);
   if (f < 0) {
      perror("Failed to open urandom");
      return 1;
   }
   n = read(f, &rnd, sizeof(rnd));
   if (n != sizeof(rnd)) {
      perror("Problem reading urandom");
      return 1;
   }
   printf("Random number = 0x%x\n", rnd);
   close(f);
   return 0;
}

Unix 驱动程序模型的优点是,一旦我们知道有一个名为urandom的设备,每次我们从它读取数据时,它都会返回一组新的伪随机数据,所以我们不需要知道任何关于它的其他信息。 我们只能使用标准函数,如open(2)read(2)close(2)

给小费 / 翻倒 / 倾覆

您可以改用称为fopen(3)fread(3)fclose(3)的流 I/O 函数,但是这些函数中隐含的缓冲通常会导致意外的行为。 例如,fwrite(3)通常只写入用户空间缓冲区,而不写入设备。 您需要调用fflush(3)来强制写出缓冲区。 因此,在调用设备驱动程序时最好不要使用流 I/O 函数。

大多数设备驱动程序都使用字符界面。 大容量存储设备是一个明显的例外。 读取和写入磁盘需要数据块接口以获得最大速度。

块设备

块设备也与设备节点相关联,设备节点也有主编号和 从编号。

给小费 / 翻倒 / 倾覆

虽然字符和块设备使用主编号和次要编号进行标识,但它们位于不同的命名空间中。 主号为4的字符驱动程序与主号为4的块驱动程序没有任何关系。

对于块设备,主要编号用于标识设备驱动程序,次要编号用于标识分区。 让我们以 Beaglebone Black 上的 MMC 驱动程序为例:

# ls -l /dev/mmcblk*
brw-rw---- 1 root disk 179, 0 Jan 1 2000 /dev/mmcblk0
brw-rw---- 1 root disk 179, 1 Jan 1 2000 /dev/mmcblk0p1
brw-rw---- 1 root disk 179, 2 Jan 1 2000 /dev/mmcblk0p2
brw-rw---- 1 root disk 179, 8 Jan 1 2000 /dev/mmcblk1
brw-rw---- 1 root disk 179, 16 Jan 1 2000 /dev/mmcblk1boot0
brw-rw---- 1 root disk 179, 24 Jan 1 2000 /dev/mmcblk1boot1
brw-rw---- 1 root disk 179, 9 Jan 1 2000 /dev/mmcblk1p1
brw-rw---- 1 root disk 179, 10 Jan 1 2000 /dev/mmcblk1p2

这里,mmcblk0是 microSD 卡插槽,它有一个有两个分区的卡,mmcblk1是 eMMC 芯片,它也有两个分区。 MMC 块驱动程序的主编号是179(您可以在devices.txt中查找)。 次要编号在范围内用于标识不同的物理 MMC 设备以及该设备上的存储介质分区。 对于 MMC 驱动程序,每个设备的范围是 8 个次要编号:从07的次要编号是第一个设备的次要编号,从815的数字是第二个设备的次要编号,依此类推。 在每个范围内,第一个次要编号将整个设备表示为原始扇区,其他编号最多表示七个分区。 在 eMMC 芯片上,有两个 128 KiB 的内存区域预留给引导加载程序使用。 它们表示为称为mmcblk1boot0mmcblk1boot1的两个设备,它们分别具有次要编号1624

作为另一个示例,您可能知道 SCSI 磁盘驱动程序,称为sd,它用于控制使用 SCSI 命令集的一系列磁盘,包括 SCSI、SATA、USB 大容量存储和通用闪存(UFS)。 它的主编号为8,每个接口(或磁盘)的范围为 16 个次要编号。 从015的次要编号表示具有名为sdasda15的设备节点的第一个接口,从1631的编号表示包含设备节点sdbsdb15的第二个磁盘,依此类推。 这将持续到从240255的第 16 个磁盘,节点名为sdp。 因为 SCSI 磁盘非常流行,所以还有其他主要数字为它们保留,但我们在这里不必担心这一点。

MMC 和 SCSI 块驱动程序都希望在磁盘的起始处找到分区表。 分区表是使用fdisksfidskparted等实用程序创建的。

用户空间程序可以通过设备节点直接打开块设备并与其交互。 不过,这并不常见,通常只在执行管理操作(如创建分区、使用文件系统格式化分区和挂载)时才执行。 挂载文件系统后,您可以通过该文件系统中的文件间接与块设备交互。

大多数块设备都有一个可以工作的内核驱动程序,所以我们很少需要编写自己的驱动程序。 网络设备也是如此。 就像文件系统抽象块设备的细节一样,网络堆栈消除了与网络设备直接交互的需要。

网络设备

网络设备不能通过设备节点访问,并且它们没有主编号和次要编号。 取而代之的是,内核根据字符串和实例号为网络设备分配一个名称。 以下是网络驱动程序注册接口方式的示例:

my_netdev = alloc_netdev(0, "net%d", NET_NAME_UNKNOWN, netdev_setup);
ret = register_netdev(my_netdev);

这将在第一次调用时创建名为net0的网络设备,在第二次调用时创建名为net1的网络设备,依此类推。 更常见的名称包括loeth0wlan0。 请注意,这是其开头的名称;设备管理器(如udev)稍后可能会将其更改为其他名称。

通常,网络接口名称仅在使用实用程序(如ipifconfig)配置网络以建立网络地址和路由时使用。 此后,通过打开套接字并让网络层决定如何将其路由到正确的接口,您可以间接地与网络驱动程序交互。

但是,通过创建套接字并使用include/linux/sockios.h中列出的ioctl命令,可以直接从用户空间访问网络设备。 例如,此程序使用SIOCGIFHWADDR向驱动程序查询硬件(MAC)地址(代码在MELP/Chapter11/show-mac-addresses中):

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <linux/sockios.h>
#include <net/if.h>
int main(int argc, char *argv[])
{
   int s;
   int ret;
   struct ifreq ifr;
   int i;
   if (argc != 2) {
      printf("Usage %s [network interface]\n", argv[0]);
      return 1;
   }
   s = socket(PF_INET, SOCK_DGRAM, 0);
   if (s < 0) {
      perror("socket");
      return 1;
   }
   strcpy(ifr.ifr_name, argv[1]);
   ret = ioctl(s, SIOCGIFHWADDR, &ifr);
   if (ret < 0) {
      perror("ioctl");
      return 1;
   }
   for (i = 0; i < 6; i++)
      printf("%02x:", (unsigned char)ifr.ifr_hwaddr.sa_data[i]);
   printf("\n");
   close(s);
   return 0;
}

此程序采用网络接口名称作为参数。 打开套接字后,我们将接口名称复制到一个结构中,并将该结构传递给套接字上的ioctl调用,然后打印出结果 MAC 地址。

现在我们已经知道了三类设备驱动程序是什么,我们如何列出系统上正在使用的不同驱动程序?

在运行时查找有关驱动程序的信息

一旦您有了运行的 Linux 系统,了解哪些设备驱动程序已经加载以及它们处于什么状态是很有用的。 您可以通过阅读 /proc/sys中的文件找到很多信息。

首先,您可以通过读取/proc/devices列出当前已加载并处于活动状态的字符和块设备驱动程序:

# cat /proc/devices
Character devices:
  1 mem 
  2 pty 
  3 ttyp 
  4 /dev/vc/0 
  4 tty 
  4 ttyS 
  5 /dev/tty 
  5 /dev/console 
  5 /dev/ptmx 
  7 vcs 
 10 misc 
 13 input 
 29 fb 
 81 video4linux 
 89 i2c 
 90 mtd
116 alsa
128 ptm
136 pts
153 spi
180 usb
189 usb_device
204 ttySC
204 ttyAMA
207 ttymxc
226 drm
239 ttyLP
240 ttyTHS
241 ttySiRF
242 ttyPS
243 ttyWMT
244 ttyAS
245 ttyO
246 ttyMSM
247 ttyAML
248 bsg
249 iio
250 watchdog
251 ptp
252 pps
253 media
254 rtc
Block devices:
259 blkext
  7 loop
  8 sd
 11 sr
 31 mtdblock
 65 sd
 66 sd
 67 sd
 68 sd
 69 sd
 70 sd
 71 sd
128 sd
129 sd
130 sd
131 sd
132 sd
133 sd
134 sd
135 sd
179 mmc

对于每个驱动程序,您可以看到主机号和基本名称。 但是,这不会告诉您每个驱动程序连接到多少个设备。 它只显示ttyAMA,但不会给您提供是否连接到四个真实串行端口的线索。 稍后我们讨论sysfs时,我会再谈到这一点。

当然,网络设备不会出现在此列表中,因为它们没有设备节点。 相反,您可以使用ifconfigip等工具来获取网络设备列表:

# ip link show
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state
UNKNOWN mode DEFAULT
 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: eth0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc
pfifo_fast state DOWN mode DEFAULT qlen 1000
 link/ether 54:4a:16:bb:b7:03 brd ff:ff:ff:ff:ff:ff
3: usb0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc
pfifo_fast state UP mode DEFAULT qlen 1000
 link/ether aa:fb:7f:5e:a8:d5 brd ff:ff:ff:ff:ff:ff

您还可以使用熟知的lsusblspci命令查找连接到 USB 或 PCI 总线的设备。 在各自的手册页面和大量的在线指南中都有关于它们的信息,所以我在这里不再进一步描述它们。

真正有趣的信息在sysfs中,这是我们要讨论的下一个主题。

从 sysfs 获取信息

您可以用一种迂腐的方式将定义为内核对象、属性和关系的表示形式。 内核对象是目录,属性是文件,并且关系是从一个对象到另一个对象的符号链接。 从更实际的角度来看,由于 Linux 设备驱动程序模型将所有设备和驱动程序表示为内核对象,因此您可以通过查看/sys来查看摆在您面前的系统的内核视图,如下所示:

# ls /sys
block class devices fs module
bus dev firmware kernel power

在发现有关设备和驱动程序的信息的上下文中,我将查看其中的三个目录:devicesclassblock

设备-/sys/device

这是内核对自引导以来发现的设备以及它们如何相互连接的视图。 它是由系统总线在顶层组织的,因此您看到的内容因系统而异。 这是 ARM 多功能的 QEMU 仿真:

# ls /sys/devices
platform software system tracepoint virtual

所有系统上都有三个目录:

  • system/:这包含系统核心的设备,包括 CPU 和时钟。
  • virtual/:它包含基于内存的设备。 您将在virtual/mem中找到显示为/dev/null/dev/random/dev/zero的存储设备。 您可以在virtual/net中找到环回设备lo
  • platform/:对于未通过传统硬件总线连接的设备来说,这是一个包罗万象的方案。 这可能是嵌入式设备上的几乎所有东西。

其他设备出现在与实际系统总线相对应的目录中。 例如,PCI 根总线(如果有)显示为pci0000:00

导航此层次结构相当困难,因为它需要一些系统拓扑知识,并且路径名变得相当长且很难记住。 为方便起见,/sys/class/sys/block提供了两种不同的设备视图。

驱动程序-/sys/class

这是按其类型显示的设备驱动程序的视图。 换句话说,它是一种软件视图,而不是硬件视图。 每个子目录代表一类驱动程序,并由驱动程序框架的一个组件实现。 例如,UART 设备由tty层管理,您可以在/sys/class/tty层中找到它们。 同样,您将在/sys/class/net中找到网络设备,在/sys/class/input中找到键盘、触摸屏和鼠标等输入设备。

该类型设备的每个实例的每个子目录中都有一个符号链接,指向其在/sys/device中的表示。

举一个具体的例子,让我们看看多功能 PB 上的串行端口。 首先,我们可以看到它们有四个:

# ls -d /sys/class/tty/ttyAMA*
/sys/class/tty/ttyAMA0 /sys/class/tty/ttyAMA2
/sys/class/tty/ttyAMA1 /sys/class/tty/ttyAMA3

每个目录都是与设备接口实例相关联的内核对象的表示。 查看其中一个目录,我们可以看到对象的属性(表示为文件)以及与其他对象的关系(表示为链接):

# ls /sys/class/tty/ttyAMA0
close_delay flags line uartclk
closing_wait io_type port uevent
custom_divisor iomem_base power xmit_fifo_size
dev iomem_reg_shift subsystem
device irq type

名为device的链接指向设备的硬件对象。 名为subsystem的链路指向父子系统/sys/class/tty。 其余的目录条目是属性。 有些特定于串行端口,如xmit_fifo_size、 ,而另一些适用于多种类型的设备,如中断号irq和设备号dev。 有些属性文件是可写的,允许您在运行时调整驱动程序中的参数。

dev属性特别有趣。 如果查看其值,您会发现 如下所示:

# cat /sys/class/tty/ttyAMA0/dev
204:64

这些是本设备的主号和次号。 此属性是在驱动程序注册此接口时创建的。 udevmdev正是从该文件中找到设备驱动程序的主号和次号。

块驱动程序-/sys/block

设备模型的另一个视图对本讨论很重要:您将在/sys/block中找到的块驱动程序视图。 每个块设备都有一个子目录。 此示例取自 Beaglebone Black:

# ls /sys/block
loop0 loop4 mmcblk0 ram0 ram12 ram2 ram6
loop1 loop5 mmcblk1 ram1 ram13 ram3 ram7
loop2 loop6 mmcblk1boot0 ram10 ram14 ram4 ram8
loop3 loop7 mmcblk1boot1 ram11 ram15 ram5 ram9

如果您查看mmcblk1,这是该板上的 eMMC 芯片,您将看到接口的属性和其中的分区:

# ls /sys/block/mmcblk1
alignment_offset ext_range mmcblk1p1 ro
bdi force_ro mmcblk1p2 size
capability holders power slaves
dev inflight queue stat
device mmcblk1boot0 range subsystem
discard_alignment mmcblk1boot1 removable uevent

因此,结论是您可以通过阅读sysfs来了解系统上的设备(硬件)和驱动程序(软件)。

查找正确的设备驱动程序

典型的嵌入式电路板基于制造商的参考设计,并对其进行了修改以使其适合特定应用。 参考板附带的 BSP 应支持该板上的所有外围设备。 但是,然后你可以定制设计,也许可以添加一个通过 I2C 连接的温度传感器,通过 GPIO 引脚连接的一些灯和按钮,通过 MIPI 接口连接的显示面板,或者许多其他东西。 您的工作是创建一个自定义内核来控制所有这些设备,但是您从哪里开始寻找支持所有这些外围设备的设备驱动程序呢?

最明显的地方是制造商网站上的驱动程序支持页面,或者你可以直接询问他们。 根据我的经验,这很少得到您想要的结果;硬件制造商并不是特别精通 Linux,而且他们经常给您提供误导性的信息。 他们可能有专有的驱动程序作为二进制 BLOB,或者他们可能有源代码,但针对的内核版本与您拥有的版本不同。 所以,一定要试试这条路线。 就我个人而言,我会一直尝试为手头的任务找到一个开源驱动程序。

您的内核中可能已经有了支持:主流 Linux 中有数千个驱动程序,供应商内核中也有许多特定于供应商的驱动程序。 首先运行make menuconfig(或xconfig)并搜索产品名称或编号。 如果找不到完全匹配的产品,请尝试更通用的搜索,因为大多数司机处理的是同一系列的一系列产品。 接下来,尝试搜索drivers目录中的代码(grep在这里是您的朋友)。

如果你仍然没有驱动程序,你可以尝试在线搜索,并在相关论坛中询问,看看是否有更高版本的 Linux 的驱动程序。 如果您找到一个,您应该认真考虑更新 BSP 以使用较新的内核。 有时,这是不切实际的,因此它可能不得不考虑将驱动程序反向移植到您的内核。 如果内核版本相似,这可能很容易,但如果它们之间的间隔超过 12 到 18 个月,那么代码很可能会发生变化,以至于您必须重写驱动程序的一大块才能将其与内核集成。 如果所有这些选项都失败了,您将不得不自己编写缺失的内核驱动程序来寻找解决方案。 然而,这并不总是必要的。 我们将在下一节中研究这一点。

用户空间中的设备驱动程序

在开始编写设备驱动程序之前,请暂停片刻考虑是否真的需要这样做。 有适用于许多常见设备类型的通用设备驱动程序,它们允许您直接从用户空间与硬件交互,而无需编写一行内核代码。 用户空间代码当然更容易编写和调试。 它也不在 GPL 的覆盖范围内,尽管我觉得这本身并不是这样做的一个很好的理由。

这些驱动程序分为两大类:一类是通过sysfs中的文件控制的驱动程序,包括 GPIO 和 LED;另一类是通过设备节点(如 I2C)公开通用接口的串行总线。

GPIO

通用输入/输出(GPIO)是最简单的数字接口形式,因为它允许您直接访问个硬件引脚,每个引脚可以处于两种状态之一:高或低。 在大多数情况下,您可以将 GPIO 引脚配置为输入或输出。 您甚至可以使用一组 GPIO 引脚,通过操作软件中的每个位来创建更高级别的接口,如 I2C 或 SPI,这种技术称为位碰撞。 主要的限制是软件循环的速度和精度,以及要专门用于它们的 CPU 周期数。 一般而言,除非您配置实时内核,否则很难获得比毫秒更高的计时器精度,正如我们将在第 21 章实时编程中看到的那样。 GPIO 更常见的用例是读取按钮和数字传感器以及控制 LED、电机和继电器。

大多数 SoC 都有很多 GPIO 位,这些位集中在 GPIO 寄存器中,通常每个寄存器 32 位。 片上 GPIO 位通过多路复用器(称为管脚多路复用器)路由到芯片封装上的 GPIO 管脚。 在电源管理芯片中以及通过 I2C 或 SPI 总线连接的专用 GPIO 扩展器中,可能还有额外的 GPIO 引脚可用。 所有这些多样性都是由称为gpiolib的内核子系统处理的,它实际上不是一个库,而是用于以一致的方式公开 I/O 的基础设施 GPIO 驱动程序。 在Documentation/gpio中有关于内核源代码中gpiolib实现的详细信息,驱动程序本身的代码在drivers/gpio中。

应用可以通过/sys/class/gpio目录中的文件与gpiolib交互。 以下是您将在典型嵌入式电路板 (Beaglebone Black)上看到的一个示例:

# ls /sys/class/gpio
export gpiochip0 gpiochip32 gpiochip64 gpiochip96 unexport

名为gpiochip0gpiochip96的目录代表四个 GPIO 寄存器,每个寄存器都有 32 个 GPIO 位。 如果您查看其中一个gpiochip目录,您将看到以下内容:

# ls /sys/class/gpio/gpiochip96
base label ngpio power subsystem uevent

名为base的文件包含寄存器中第一个 GPIO 引脚的编号,而ngpio包含寄存器中的位数。 在本例中,gpiochip96/base是 96,gpiochip96/ngpio是 32,这表明它包含 GPIO 位 96 到 127。 一个寄存器中的最后一个 GPIO 和下一个寄存器中的第一个 GPIO 之间可能存在间隙。

要从用户空间控制 GPIO 位,首先必须将其从内核空间导出,这可以通过将 GPIO 编号写入/sys/class/gpio/export来实现。 此示例显示了连接到 Beaglebone Black 上的 User LED 0 的 GPIO 53 的流程:

# echo 53 > /sys/class/gpio/export
# ls /sys/class/gpio
export gpio53 gpiochip0 gpiochip32 gpiochip64 gpiochip96 unexport

现在,有了一个新目录gpio53,其中包含您需要控制 管脚的文件。

重要音符

如果内核已经声明了 GPIO 位,您将不能以这种方式导出它。

gpio53目录包含以下文件:

# ls /sys/class/gpio/gpio53
active_low direction power uevent
device edge subsystem value

引脚作为输入开始。 要将其更改为输出,请将out写入direction文件。 文件值包含管脚的当前状态,低位为0,高位为1。 如果是输出,可以通过将01写入value来更改状态。 有时,低电压和高电压在硬件中的含义相反(硬件工程师喜欢这样做),因此写入1active_low会颠倒value的含义,因此低电压报告为1,高电压报告为0

您可以通过将 GPIO 编号写入/sys/class/gpio/unexport来从用户空间控制中移除 GPIO。

处理来自 GPIO 的中断

在许多情况下,可以将 GPIO 输入配置为在更改状态时生成中断,这允许您等待中断,而不是在低效的软件循环中轮询。 如果 GPIO 位可以生成中断,则存在名为edge的文件。 最初,它具有名为none的值,这意味着它不会生成中断。 要启用中断,可以将其设置为下列值之一:

  • rising:上升沿中断
  • falling:下降沿中断
  • both:上升沿和下降沿均中断
  • none:无中断(默认)

如果要等待 GPIO 48 上的下降沿,则必须首先启用中断:

# echo 48 > /sys/class/gpio/export
# echo falling > /sys/class/gpio/gpio48/edge

要等待来自 GPIO 的中断,请执行以下步骤:

  1. 首先,调用epoll_create创建epoll通知工具:

    int ep;
    ep = epoll_create(1);
  2. 接下来,openGPIO 和read得出其初始值:

    int f;
    int n;
    char value[4];
    f = open("/sys/class/gpio/gpio48/value", O_RDONLY | O_NONBLOCK);
    […]
    n = read(f, &value, sizeof(value));
    if (n > 0) {
         printf("Initial value value=%c\n",
               value[0]);
         lseek(f, 0, SEEK_SET);
    }
  3. 调用epoll_ctl将 GPIO 的文件描述符注册到POLLPRI作为事件:

    struct epoll_event ev, events;
    ev.events = EPOLLPRI;
    ev.data.fd = f;
    int ret;
    ret = epoll_ctl(ep, EPOLL_CTL_ADD, f, &ev);
  4. 最后,使用epoll_wait函数等待中断:

    while (1) {
         printf("Waiting\n");
         ret = epoll_wait(ep, &events, 1, -1);
         if (ret > 0) {
               n = read(f, &value, sizeof(value));
               printf("Button pressed: value=%c\n", value[0]);
               lseek(f, 0, SEEK_SET);
         }
    }

这个程序的完整源代码,以及一个Makefile和 GPIO 配置脚本,可以在本书的代码归档中包含的MELP/Chapter11/gpio-int/目录中找到。

虽然我们可以使用selectpoll来处理中断,但与其他两个系统调用不同的是,epoll的性能不会随着被监视的文件描述符数量的增加而迅速下降。

与 GPIO 类似,LED 也可以从sysfs访问。 然而,界面却有明显的不同。

LED

LED 通常通过 GPIO 引脚进行控制,但还有另一个内核子系统提供专门针对此目的的更专门的控制。 leds内核子系统增加了设置亮度的功能(如果 LED 具有该功能),并且它可以处理以其他方式连接的 LED,而不是简单的 GPIO 引脚。 可以将其配置为在某个事件(如阻止设备访问或仅是心跳)时触发 LED,以显示设备正在工作。 您必须使用CONFIG_LEDS_CLASS选项和适合您的 LED 触发操作来配置内核。 有关Documentation/leds/的更多信息,请参阅drivers/leds/中的驱动程序。

与 GPIO 一样,LED 通过/sys/class/leds目录中sysfs中的接口进行控制。 在 Beaglebone Black 的情况下,LED 的名称以devicename:colour:function的形式编码在设备树中,如下所示:

# ls /sys/class/leds
beaglebone:green:heartbeat beaglebone:green:usr2
beaglebone:green:mmc0 beaglebone:green:usr3

现在,我们可以查看其中一个 LED 的属性:

# cd /sys/class/leds/beaglebone\:green\:usr2
# ls
brightness max_brightness subsystem uevent
device power trigger

请注意,shell 需要使用前导反斜杠来转义路径中的冒号。

brightness文件控制 LED 的亮度,可以是介于0(关闭)和max_brightness(完全打开)之间的数字。 如果 LED 不支持中等亮度,则任何非零值都会将其打开。 名为trigger的文件列出了触发 LED 点亮的事件 。 触发器列表取决于实现。 下面是 的一个例子:

# cat trigger
none mmc0 mmc1 timer oneshot heartbeat backlight gpio [cpu0]
default-on

当前选择的触发器显示在方括号中。 您可以通过将其他触发器之一写入文件来更改它。 如果要完全通过brightness控制 LED,请选择none。 如果将trigger设置为timer,则会出现两个额外的文件,允许您以毫秒为单位设置打开和关闭时间:

# echo timer > trigger
# ls
brightness delay_on max_brightness subsystem uevent
delay_off device power trigger
# cat delay_on
500
# cat /sys/class/leds/beaglebone:green:heartbeat/delay_off
500

如果 LED 具有片上定时器硬件,则闪烁不会中断 CPU。

I2C

I2C 是一种简单低速二线制总线,在嵌入式主板上很常见,通常为,用于访问不在 SoC 上的外围设备,如显示控制器、摄像机传感器、GPIO 扩展器等。 PC 上有一个相关标准,称为系统管理总线(SMBus),用于访问温度和电压传感器。 SMBus 是 I2C 的子集。

I2C 是主从协议,主机是 SoC 上的一个或多个主机控制器。 从机具有制造商分配的 7 位地址(请参阅数据手册),每条总线最多允许 128 个节点,但保留了 16 个节点,因此实际上只允许 112 个节点。 主设备可以发起与从设备之一的读或写事务。 通常,第一个字节用于指定从机上的寄存器,其余字节则是从该寄存器读取或写入的数据。

每个主机控制器都有一个设备节点;例如,此 SoC 有四个:

# ls -l /dev/i2c*
crw-rw---- 1 root i2c 89, 0 Jan 1 00:18 /dev/i2c-0
crw-rw---- 1 root i2c 89, 1 Jan 1 00:18 /dev/i2c-1
crw-rw---- 1 root i2c 89, 2 Jan 1 00:18 /dev/i2c-2
crw-rw---- 1 root i2c 89, 3 Jan 1 00:18 /dev/i2c-3

器件接口提供一系列ioctl命令,用于查询主机控制器并将readwrite命令发送到 I2C 从机。 有一个名为i2c-tools的包,它使用此接口提供与 I2C 设备交互的基本命令行工具。 工具如下:

  • i2cdetect:列出 I2C 适配器并探测总线。
  • i2cdump:这会转储来自 I2C 外设所有寄存器的数据。
  • i2cget:这将从 I2C 从机读取数据。
  • i2cset:将数据写入 I2C 从机。

i2c-tools包可以在 Buildroot 和 Yocto 项目中获得,也可以在大多数主流发行版中找到。 因此,只要您知道从机的地址和协议,编写一个用户空间程序来与设备对话就很简单了。 下例显示如何从 AT24C512B EEPROM 读取前四个字节,该 EEPROM 安装在 I2C 总线 0 上的 Beaglebone Black 上。 它的从机地址为0x50(其代码在MELP/Chapter11/i2c-example中):

#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <linux/i2c-dev.h>
#define I2C_ADDRESS 0x50
int main(void)
{
   int f;
   int n;
   char buf[10];
   f = open("/dev/i2c-0", O_RDWR);
   /* Set the address of the i2c slave device */
   ioctl(f, I2C_SLAVE, I2C_ADDRESS);
   /* Set the 16-bit address to read from to 0 */
   buf[0] = 0; /* address byte 1 */
   buf[1] = 0; /* address byte 2 */
   n = write(f, buf, 2);
   /* Now read 4 bytes from that address */
   n = read(f, buf, 4);
   printf("0x%x 0x%x0 0x%x 0x%x\n",
   buf[0], buf[1], buf[2], buf[3]);
   close(f);
   return 0;
}

该程序类似于i2cget,不同之处在于读取的地址和寄存器字节都是硬编码的,而不是作为参数传入。 我们可以使用i2cdetect来发现 I2C 总线上任何外围设备的地址。 i2cdetect可能会使 I2C 外设处于不良状态或锁定总线,因此最好在使用后重新启动。 外设的数据表告诉我们寄存器对应的是什么。 有了这些信息,我们就可以使用i2cset通过 I2C 写入其寄存器。 这些 I2C 命令可以轻松转换为 C 函数库,以便与外设接口。

重要注

Documentation/i2c/dev-interface中有更多关于 Linux 实现 I2C 的信息。 主机控制器驱动程序在drivers/i2c/busses中。

另一个流行的通信协议是串行外设接口(SPI),它使用 4 线总线。

SPI

SPI 总线类似于 I2C,但速度要快得多,最高可达数十 MHz。 该接口使用四条线路,具有独立的发送和接收线路,使其能够在全双工模式下运行。 总线上的每个芯片用专用芯片选择线选择。 它通常用于连接触摸屏传感器、显示控制器和串行 NOR 闪存设备。

与 I2C 一样,它是主从协议,大多数 SoC 实现一个或多个主机控制器。 有一个通用的 SPI 设备驱动程序,您可以通过CONFIG_SPI_SPIDEV内核配置启用它。 它为每个 SPI 控制器创建一个设备节点,允许您从用户空间访问 SPI 芯片。 设备节点命名为spidev[bus].[chip select]

# ls -l /dev/spi*
crw-rw---- 1 root root 153, 0 Jan 1 00:29 /dev/spidev1.0

有关使用spidev接口的示例,请参考Documentation/spi中的示例代码。

到目前为止,我们看到的设备驱动程序在 Linux 内核中都有长期的上游支持。 因为这些设备驱动程序都是通用的(GPIO、LED、I2C 和 SPI),所以从用户空间访问它们很简单。 在某些情况下,您会遇到缺少兼容内核设备驱动程序的硬件。 该硬件可能是您的产品的核心(例如,激光雷达、SDR 等)。 在 SoC 和该硬件之间也可能有 FPGA。 在这些情况下,除了编写自己的内核模块之外,您可能别无选择。

编写内核设备驱动程序

最后,当用尽之前的所有用户空间选项时,您会发现自己必须编写设备驱动程序才能访问连接到设备的硬件。 字符驱动程序是最灵活的,应该可以满足您 90%的需求;如果您使用的是网络接口,则适用于网络驱动程序,而块驱动程序适用于大容量存储。 编写内核驱动程序的任务很复杂,超出了本书的范围。 在结尾处有一些参考资料可以帮助你上路。 在本节中,我想概述可用于与驱动程序交互的选项-这是一个通常不会涉及的主题-并向您展示字符设备驱动程序的基本框架。

字符驱动接口设计

Main Character 驱动程序接口基于字节流,就像使用串行端口一样。 然而,许多设备并不符合这种描述:例如,机器人手臂的控制器需要移动和旋转每个关节的功能。 幸运的是,除了readwrite之外,还有其他方式可以与设备驱动程序通信:

  • ioctlioctl函数允许您向驱动程序传递两个参数 ,这些参数可以有您喜欢的任何含义。 按照惯例,第一个参数是 命令,它选择驱动程序中的几个函数之一,而第二个参数是指向结构的指针,该结构充当输入和输出参数的容器。 这是一个空白画布,允许您设计任何您喜欢的程序界面。 当驱动程序和应用紧密联系在一起并由同一团队编写时,这种情况很常见。 但是,内核中不推荐使用ioctl,您会发现很难在上游获得接受ioctl新用法的任何驱动程序。 内核维护人员不喜欢ioctl,因为使内核代码和应用代码过于相互依赖,很难跨内核版本和架构保持两者的步调一致。
  • sysfs:这是现在做事情的首选方式,前面描述的 GPIO 接口就是一个很好的例子。 的优点是,只要您为文件选择描述性名称,它就具有一定的自文档化功能。 它也是可编写脚本的,因为文件的内容通常是文本字符串。 另一方面,如果需要一次更改多个值,则每个文件必须包含单个值的要求使得很难实现原子性。 相反,ioctl在单个函数调用中传递结构中的所有参数。
  • mmap:通过将内核内存映射到用户空间,从而绕过内核,您可以直接访问内核缓冲区和硬件寄存器。 您可能仍然需要一些内核代码来处理中断和 DMA。 有一个子系统封装了这个概念,称为uio,它是User I/O的缩写。 Documentation/DocBook/uio-howto中有更多文档,drivers/uio中有示例驱动程序。
  • sigio:您可以使用名为kill_fasync()的内核函数从驱动程序发送信号,以通知应用发生事件,如输入准备就绪或收到中断。 按照惯例,使用名为SIGIO的信号,但它可以是任何信号。 您可以在 UIO 驱动程序drivers/uio/uio.c和 RTC 驱动程序drivers/char/rtc.c中看到一些示例。 主要问题是很难在用户空间中编写可靠的信号处理程序,因此它仍然是一个很少使用的工具。
  • debugfs:这是另一个伪文件系统,它将内核数据表示为文件和目录,类似于procsysfs。 主要区别在于debugfs不得包含系统正常运行所需的信息;它仅用于调试和跟踪信息。 它被安装为mount -t debugfs debug /sys/kernel/debug。 在Documentation/filesystems/debugfs.txt内核文档中对debugfs有很好的描述。
  • proc:所有新代码都不推荐使用proc文件系统,除非它与进程有关,这是文件系统最初的预期用途。 但是,您可以使用proc发布您选择的任何信息。 而且,与sysfsdebugfs不同,它可用于非 GPL 模块。
  • netlink:这是套接字协议族。 AF_NETLINK创建将内核空间链接到用户空间的套接字。 最初创建它是为了让网络工具可以与 Linux 网络代码通信,以访问路由表和其他详细信息。 udev还使用它将事件从内核传递到udev守护进程。 它很少在一般设备驱动程序中使用。

内核源代码中有个前面所有文件系统的示例,您可以为您的驱动程序代码设计非常有趣的接口。 唯一的普遍规则是最小惊讶原则。 换句话说,使用您的驱动程序的应用编写人员应该会发现,一切都是以一种合乎逻辑的方式工作的,没有任何怪异或奇怪之处。

设备驱动程序剖析

现在是通过查看简单设备驱动程序的代码来将一些线程结合在一起的时候了。

下面是名为dummy的设备驱动程序的开始,它创建了四个可以通过/dev/dummy0/dev/dummy3访问的设备:

#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/init.h>
#include <linux/fs.h>
#include <linux/device.h>
#define DEVICE_NAME "dummy"
#define MAJOR_NUM 42
#define NUM_DEVICES 4
static struct class *dummy_class;

接下来,我们将定义字符设备接口的dummy_open()dummy_release()dummy_read()dummy_write()函数:

static int dummy_open(struct inode *inode, struct file *file)
{
   pr_info("%s\n", __func__);
   return 0;
}
static int dummy_release(struct inode *inode, struct file *file)
{
   pr_info("%s\n", __func__);
   return 0;
}
static ssize_t dummy_read(struct file *file,
 char *buffer, size_t length, loff_t * offset)
{
   pr_info("%s %u\n", __func__, length);
   return 0;
}
static ssize_t dummy_write(struct file *file,
 const char *buffer, size_t length, loff_t * offset)
{
   pr_info("%s %u\n", __func__, length);
   return length;
}

之后,我们需要初始化file_operations结构并定义 dummy_init()dummy_exit()函数,这些函数在加载和卸载驱动程序时调用:

struct file_operations dummy_fops = {
   .owner = THIS_MODULE,
   .open = dummy_open,
   .release = dummy_release,
   .read = dummy_read,
   .write = dummy_write,
};
int __init dummy_init(void)
{
   int ret;
   int i;
   printk("Dummy loaded\n");
   ret = register_chrdev(MAJOR_NUM, DEVICE_NAME, &dummy_fops);
   if (ret != 0)
      return ret;
   dummy_class = class_create(THIS_MODULE, DEVICE_NAME);
   for (i = 0; i < NUM_DEVICES; i++) {
      device_create(dummy_class, NULL,
                    MKDEV(MAJOR_NUM, i), NULL, "dummy%d", i);
   }
   return 0;
}
void __exit dummy_exit(void)
{
   int i;
   for (i = 0; i < NUM_DEVICES; i++) {
      device_destroy(dummy_class, MKDEV(MAJOR_NUM, i));
   }
   class_destroy(dummy_class);
   unregister_chrdev(MAJOR_NUM, DEVICE_NAME);
   printk("Dummy unloaded\n");
}

在代码的末尾,名为module_initmodule_exit的宏指定加载和卸载模块时要调用的函数:

module_init(dummy_init);
module_exit(dummy_exit);

最后三个名为MODULE_*的宏添加了有关模块的一些基本信息:

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Chris Simmonds");
MODULE_DESCRIPTION("A dummy driver");

可以使用modinfo命令从编译的内核模块检索此信息。 驱动程序的完整源代码可以在MELP/Chapter11/dummy-driver目录中找到,该目录包含在本书的代码归档中。

加载模块时,会调用dummy_init()函数。 当 IS 调用register_chrdev,传递一个指向struct file_operations的指针(其中包含指向驱动程序实现的四个函数的指针)时,您可以看到它成为字符设备的点。 虽然register_chrdev告诉内核有一个主号为42的驱动程序,但它没有说明驱动程序的类,因此它不会在/sys/class中创建条目。

如果在/sys/class中没有条目,则设备管理器无法创建设备节点。 因此,接下来的几行代码创建了一个设备类dummy,以及该类中名为dummy0dummy3的四个设备。 结果是,/sys/class/dummy目录是在驱动程序初始化时创建的,它包含子目录dummy0dummy3。 每个子目录都包含一个文件dev,其中包含设备的主号和次号。 这就是设备管理器创建设备节点所需的全部内容:/dev/dummy0/dev/dummy3

dummy_exit()函数必须释放dummy_init()所声明的资源,这在这里意味着释放设备类别和主机号。

该驱动程序的文件操作由dummy_open()dummy_read()dummy_write()dummy_release()实现,它们分别在用户空间程序调用open(2)read(2)write(2)close(2)时调用。 它们只是打印一条内核消息,这样您就可以看到它们被调用了。 您可以使用echo命令从命令行演示这一点:

# echo hello > /dev/dummy0
dummy_open
dummy_write 6
dummy_release

在本例中,出现这些消息是因为我登录到了控制台,并且默认情况下内核消息会打印到控制台。 如果您没有登录控制台,您仍然可以使用dmesg命令查看内核消息。

该驱动程序的完整源代码不到 100 行,但足以说明设备节点和驱动程序代码之间的链接是如何工作的;设备类是如何创建的,从而允许设备管理器在加载驱动程序时自动创建设备节点;以及数据是如何在用户和内核空间之间移动的。 接下来,您需要构建它。

编译内核模块

此时,您有一些要在目标系统上编译和测试的驱动程序代码。 您可以将其复制到内核源码树中并修改生成文件来构建它,也可以将其编译为树外的模块。 让我们从树上开始建造。

您将需要一个简单的 Makefile,它使用内核构建系统来完成所有繁重的工作:

LINUXDIR := $(HOME)/MELP/build/linux
obj-m := dummy.o
all:
     make ARCH=arm CROSS_COMPILE=arm-cortex_a8-linux-gnueabihf- \
     -C $(LINUXDIR) M=$(shell pwd)
clean:
     make -C $(LINUXDIR) M=$(shell pwd) clean

LINUXDIR设置为要在其上运行模块的目标设备的内核目录。 obj-m := dummy.o代码将调用内核构建规则来获取源文件dummy.c,并创建内核模块dummy.ko。 在下一节中,我将向您展示如何加载内核模块。

重要注

内核模块在内核版本和配置之间不是二进制兼容的:模块将只加载到编译时使用的内核上。

如果您想要在内核源代码树中构建驱动程序,过程非常简单。 选择适合您拥有的驱动程序类型的目录。 该驱动程序是一个基本的字符设备,所以我将把dummy.c放在drivers/char中。 然后,编辑目录中的 Makefile,并添加一行,将驱动程序无条件构建为一个模块,如下所示:

obj-m += dummy.o

或者,您可以添加以下行以无条件地将其构建为内置:

obj-y += dummy.o

如果您希望使驱动程序可选,您可以向Kconfig 文件添加一个menu选项,并使编译以配置选项为条件,正如我在了解内核配置部分的第 4 章配置和构建内核中描述的 。

加载内核模块

您可以分别使用简单的insmodlsmodrmmod命令加载、卸载和列出模块。 在这里,他们正在加载dummy驱动程序:

# insmod /lib/modules/4.8.12-yocto-standard/kernel/drivers/dummy.ko
# lsmod
 Tainted: G
dummy 2062 0 - Live 0xbf004000 (O)
# rmmod dummy

如果模块放在/lib/modules/<kernel release>中的子目录中,您可以使用depmod -a命令创建一个模块依赖关系数据库,如下所示:

# depmod -a
# ls /lib/modules/4.8.12-yocto-standard
kernel   modules.alias   modules.dep   modules.symbols

modprobe命令使用modules.*文件中的信息按名称而不是按完整路径查找模块。 modprobe还有许多其他功能,所有这些功能都在modprobe(8)手册页上进行了描述。

既然我们已经编写并加载了我们的虚拟内核模块,那么我们如何让它与一些真正的硬件对话呢? 我们需要通过设备树或平台数据将驱动程序绑定到该硬件。 发现硬件并将该硬件链接到设备驱动程序是下一节的主题。

发现硬件配置

虚拟驱动程序演示了设备驱动程序的结构,但它缺乏与真实硬件的交互,因为它只操纵内存结构。 设备驱动程序通常是用来与硬件交互的。 其中的一部分是能够首先发现硬件,记住它可能在不同的配置中位于不同的地址。

在某些情况下,硬件本身提供信息。 可发现总线(如 PCI 或 USB)上的设备具有查询模式,该模式返回资源要求和唯一标识符。 内核将标识符和可能的其他特征与设备驱动程序相匹配,并将它们结合在一起。

然而,嵌入式电路板上的大多数硬件块都没有这样的标识符。 您必须自己以设备树或称为的 C 结构(称为平台数据)的形式提供信息。

在 Linux 的标准驱动程序模型中,设备驱动程序向相应的子系统注册:PCI、USB、开放固件(设备树)、平台设备等。 注册包括标识符和称为probe函数的回调函数,如果硬件的 ID 和驱动程序的 ID 之间存在匹配,则调用该回调函数。 对于 PCI 和 USB,ID 基于供应商和设备的产品 ID;对于设备树和平台设备,ID 是名称(文本字符串)。

设备树

我在第 3 章All About Bootloaders中介绍了设备树。 在这里,我想向您展示 Linux 设备驱动程序是如何与这些信息联系在一起的。

作为示例,我将使用 ARM 通用板arch/arm/boot/dts/versatile-ab.dts,其以太网适配器定义如下:

net@10010000 {
   compatible = "smsc,lan91c111";
   reg = <0x10010000 0x10000>;
   interrupts = <25>;
};

请特别注意此节点的compatible属性。 该字符串值稍后将在以太网适配器的源代码中重新出现。 我们将在第 12 章中了解有关设备树的更多信息

平台数据

在缺乏设备树支持的情况下,有一种使用 C 结构描述硬件的后备方法,称为平台数据。

每个硬件都由struct platform_device描述,它有一个名称和一个指向资源数组的指针。 资源的类型由标志确定,这些标志包括以下内容:

  • IORESOURCE_MEM:这是内存区域的物理地址。
  • IORESOURCE_IO:这是 I/O 寄存器的物理地址或端口号。
  • IORESOURCE_IRQ:这是中断号。

以下是取自arch/arm/machversatile/core.c的以太网控制器的平台数据的示例,为清楚起见,已对其进行了编辑:

#define VERSATILE_ETH_BASE 0x10010000
#define IRQ_ETH 25
static struct resource smc91x_resources[] = {
 [0] = {
   .start = VERSATILE_ETH_BASE,
   .end = VERSATILE_ETH_BASE + SZ_64K - 1,
   .flags = IORESOURCE_MEM,
},
 [1] = {
   .start = IRQ_ETH,
   .end = IRQ_ETH,
   .flags = IORESOURCE_IRQ,
},
};
static struct platform_device smc91x_device = {
  .name = "smc91x",
  .id = 0,
  .num_resources = ARRAY_SIZE(smc91x_resources),
  .resource = smc91x_resources,
};

它有 64KB 的存储区和一个中断。 平台数据必须向内核注册,通常在主板初始化时:

void __init versatile_init(void)
{
   platform_device_register(&versatile_flash_device);
   platform_device_register(&versatile_i2c_device);
   platform_device_register(&smc91x_device);
   […]

此处显示的平台数据在功能上等同于以前的设备树源,只是name字段取代了compatible属性。

将硬件与设备驱动程序链接

在前面的节中,您看到了如何使用设备树和平台数据描述以太网适配器。 对应的驱动程序代码在drivers/net/ethernet/smsc/smc91x.c中,它可以处理设备树和平台数据。 以下是初始化代码,为清楚起见再次进行了编辑:

static const struct of_device_id smc91x_match[] = {
   { .compatible = "smsc,lan91c94", },
   { .compatible = "smsc,lan91c111", },
   {},
};
MODULE_DEVICE_TABLE(of, smc91x_match);
static struct platform_driver smc_driver = {
   .probe = smc_drv_probe,
   .remove = smc_drv_remove,
   .driver = {
      .name = "smc91x",
      .of_match_table = of_match_ptr(smc91x_match),
   },
};
static int __init smc_driver_init(void)
{
   return platform_driver_register(&smc_driver);
}
static void __exit smc_driver_exit(void)
{
   platform_driver_unregister(&smc_driver);
}
module_init(smc_driver_init);
module_exit(smc_driver_exit);

当驱动程序初始化时,它调用platform_driver_register(),指向struct platform_driver,其中有一个对probe函数的回调、一个驱动程序名称smc91x和一个指向struct of_device_id的指针。

如果此驱动程序已由设备树配置,内核将查找设备树节点中的compatible属性与兼容结构元素所指向的字符串之间的匹配。 对于每个匹配,它调用probe函数。

另一方面,如果它是通过平台数据配置的,则对于driver.name所指向的字符串的每个匹配,都将调用probe函数。

函数probe提取有关接口的信息:

static int smc_drv_probe(struct platform_device *pdev)
{
   struct smc91x_platdata *pd = dev_get_platdata(&pdev->dev);
   const struct of_device_id *match = NULL;
   struct resource *res, *ires;
   int irq;
   res = platform_get_resource(pdev, IORESOURCE_MEM, 0);
   ires platform_get_resource(pdev, IORESOURCE_IRQ, 0);
   […]
   addr = ioremap(res->start, SMC_IO_EXTENT);
   irq = ires->start;
   […]
}

platform_get_resource()的调用从设备树或平台数据中提取内存和irq信息。 由驱动程序来映射内存并安装中断处理程序。 第三个参数(在前两种情况下均为零)在存在该特定类型的多个资源时开始起作用。

设备树允许您配置的不仅仅是基本内存范围和中断。 probe函数中有一段代码可以从设备树中提取可选参数。 在此代码片段中,它获取register-io-width属性:

match = of_match_device(of_match_ptr(smc91x_match), &pdev->dev);
if (match) {
   struct device_node *np = pdev->dev.of_node;
   u32 val;
   […]
   of_property_read_u32(np, "reg-io-width", &val);
   […]
}

对于大多数驱动程序,具体的绑定记录在Documentation/devicetree/bindings中。 对于这个特定的驱动程序,信息在Documentation/devicetree/bindings/net/smsc911x.txt中。

这里要记住的主要事情是,驱动程序应该注册一个probe函数和足够的信息,以便内核调用probe,因为它找到了与它所知道的硬件相匹配的硬件。 设备树描述的硬件和设备驱动程序之间的链接是通过compatible属性实现的。 平台数据和驱动程序之间的链接是通过名称实现的。

摘要

设备驱动程序负责处理设备(通常是物理硬件,有时也包括虚拟接口),并以一致且有用的方式将它们呈现给用户空间。 Linux 设备驱动程序分为三大类:字符驱动程序、块驱动程序和网络驱动程序。 在这三个接口中,字符驱动程序接口是最灵活的,因此也是最常见的。 Linux 驱动程序适合称为驱动程序模型的框架,该模型通过sysfs公开。 在/sys中几乎可以看到设备和驱动程序的整个状态。

每个嵌入式系统都有自己独特的一组硬件接口和要求。 Linux 提供了大多数标准接口的驱动程序,通过选择正确的内核配置,您可以非常快地获得工作的目标板。 这就给您留下了非标准组件,您必须为其添加自己的设备支持。

在某些情况下,您可以通过使用 GPIO、I2C 等的通用驱动程序来避开这个问题,并编写用户空间代码来完成这项工作。 我推荐以此为起点,因为它使您有机会在不编写内核代码的情况下熟悉硬件。 编写内核驱动程序并不是特别困难,但是您需要仔细编写代码,以免影响系统的稳定性。

到目前为止,我已经讨论了内核驱动程序代码的编写;如果您沿着这条路线走下去,您将不可避免地想知道如何检查它是否正常工作并检测任何 bug。 我将在第 19 章使用 GDB调试中讨论该主题。

下一章将介绍用户空间初始化以及init程序的不同选项,从简单的 BusyBox 到更复杂的系统。

进一步阅读

以下资源包含有关本章中介绍的主题的更多信息: