Linux 设备驱动

引言

本文整理了 Linux 内核中设备驱动的相关知识,其他 Linux 相关文章均收录于 <Linux系列文章>

设备驱动

与外设的通信通常称为输入输出(I/O),在实现外设的 I/O 时,必须着重处理三个问题:

  1. 对硬件寻址
  2. 提供访问设备的方法,而且要尽可能采用统一方案
  3. 获取可用设备列表

与外设的通信是层次化的,通过多层的抽象,使得上层应用在访问设备时,可以使用一套统一的接口。下图就展示了设备的分层抽象概念,我们可以看到,从最下层的设备开始,通过驱动程序作为粘合剂编入虚拟文件系统管理,从此设备在用户空间看来就是一个特殊的文件,而应用程序在访问各种设备时就如同访问一个个不同的文件一样,通过统一的文件读写接口,就实现了对各种设备的访问。
device-abstract-level
要知道,外设是不能直接连接到 CPU 的,它们需要通过总线连接起来,总线能够使设备与 CPU,设备与设备之间进行通信。Linux 支持的总线类型有很多,比如 PCI,ISA,USB 等。

在系统中,一般都会有多条总线,这些总线之间互相连接以扩大连接设备的规模。以 PCI 为例,下图展示 2 个 PCI 总线以桥接方式连接的例子:
pci-bridge
至此,我们初步了解了外设是如何连接到系统上的,那么系统是如何与这些设备通信的呢?这主要有 3 种方式:

  1. I/O 端口
    • 许多体系结构上都有 I/O 端口,每个设备会有一个与之对应的唯一的 I/O 端口号。在内核需要和设备进行通信时,可以通过和体系结构相关的汇编指令来发送数据给 I/O 控制器,随后 I/O 控制器根据目标设备的端口号来决定将数据发送给哪个设备(这里的端口号不是指网络层的端口)。这些端口,有些是只读的有些是可读写的,通过它们使得处理器与外设之间可以双向交换数据。
  2. I/O 内存映射
    • 除了如何定位设备之外,我们有的时候还需要对设备数据进行寻址,这很像内存的处理,我们需要能随意的跳到指定地址读写数据。因此,处理器提供了对 I/O 端口进行内存映射的功能,将特定外设的端口地址映射普通内存中,这样内核就可以想读写普通内存一样读写外设数据。处理显卡设备时,一般就会使用到它。此外,PCI 总线的寻址也是通过内存映射来完成。
  3. 轮询和中断
    • 除了访问设备外,系统还要知道设备数据是否已经准备完毕,这可以通过轮询或者中断来判断。轮询方案效率显然不够好,它旨在重复询问设备是否数据准备完毕,如果数据准备完毕则处理器取回数据。中断方案效率更好,每个 CPU 都有对应的中断线,设备可以独占某一中断线或者多个设备共享某一中断线。每个中断线有一个与之对应的编号,而内核中每个中断编号都会对应一个中断处理程序,当设备数据准备完毕时,触发中断处理程序,中断处理程序会抢占当前 CPU 的执行,CPU 转而去处理设备上准备好的数据。通过这种手段,就达到了设备通知内核的目的。

并非所有的设备都直接通过 CPU 的 I/O 语句寻址,对于某些设备的访问中间还要通过另一层接口,比如硬盘就不是直接连接在 PCI 总线上的,一般来说硬盘直接连接的是 IDE 接口,然后 IDE 接口再连接到总线上,在这种情况下内核就要先通过 I/O 指令与 IDE 通讯,然后通过 IDE 将指令落实到磁盘设备上。像 IDE,USB 这样的总线,我们称之外扩展总线,而 PCI 这类总线称为系统总线,系统总线直接与 CPU 连接,并且可以接入扩展总线或者直接连入一些外设。通过 I/O 指令,可以和直连 PCI 的外设通讯,也可以与连接在其上的扩展总线通讯,这些扩展总线一般都有自己的一套协议,通过这一层协议可以控制更下层的外设(连接到扩展总线的外设)。

访问设备

前面我们说过,外设通过驱动程序接入虚拟文件系统,这些抽象出来的特殊文件可用于访问外设。这些文件并不会存储在任何其他存储介质上(磁盘),而是存在于内存中的虚拟概念。当我们对该文件进行读写时,这些读写指令会被驱动程序处理,转化为通过 I/O 指令与设备或总线通讯的流程。

根据外设与系统的交换数据方式,一般会划分为字符型设备和块设备,前者包括键盘等设备,而后者包括磁盘等设备。我们可以通过 ls -l /dev 来查看所有的设备,在访问权限之前的 b 和 c 分别代表该设备是块设备或字符设备。在用户、组后面的两个数字分别是主设备号,和从设备号。两者合并起来是一个唯一号码,内核借此来查找对应的驱动程序。最后面的文件名主要是为了帮助用户识别设备用的,内核还是通过主从设备号来管理设备。

1
2
3
4
5
6
7
8
ls -l /dev
crw------- 1 root root 10,203 Oct 25 08:17 cuse
drwxr-xr-x 8 root root 160 Oct 25 08:17 disk
brw-rw---- 1 root disk 253, 0 Oct 25 08:17 dm-0
brw-rw---- 1 root disk 253, 1 Oct 25 08:17 dm-1
brw-rw---- 1 root disk 8, 0 Oct 25 08:17 sda
brw-rw---- 1 root disk 8, 1 Oct 25 08:17 sda1
brw-rw---- 1 root disk 8, 2 Oct 25 08:17 sda2

这里之所以采用主从两个设备号来标识,是因为系统可能包含多个同类设备,这些设备由同一个驱动程序管理。主设备号用于寻址设备驱动程序,如同上例的 dm-0dm-1 就是归属于同一个驱动程序,而从标号用于区分同一种类的各个不同的设备,值得注意的是如果硬盘分为多个区的话,每个区会有一个对应的设备项。

块设备和字符设备的主设备号可能是相同的,所以查找设备驱动时是根据设备类型+设备主编号来定位设备驱动的。

前面说过代表设备的文件都是存在于内存中的虚拟文件,当系统重启时,过时设备文件就会消失。每当一个新的设备插入系统时(USB 设备热插拔),系统中就会自动出现与之对应的设备文件。

通过虚拟文件系统,我们确实可以通过读写设备文件来访问外设,但是还有一些其他操作诸如检查特定设备的功能,设置设备的配置等很难通过直接读写设备文件来完成,因为这会和对设备数据的正常读写发生冲突,试想一下我们是通过读写设备文件来存取磁盘的数据,而我们又要通过读写设备文件来控制磁盘弹出就可能和读写的数据冲突。所以内核还提供了一套接口,ioctl,它标识输入输出控制接口,用于配置和修改特定的设备属性。和读写设备文件类似,ioctl 的操作最终也会被设备驱动程序处理。这样我们就能区分哪些指令是要读写设备数据,哪些执行是控制设备的了。

在字符设备和块设备之外,网卡在系统中具有独特的地位,它并不属于任何一类。用户进程必须通过套接字接口与网卡进行通信,套接字是一个抽象层,按照网络分层进行每一层的处理。

接入文件系统

前面说过每个设备都会有一个对应的设备文件,我们可以通过读写设备文件来访问设备。那么,它们是怎么联系起来的呢?

在虚拟文件系统中,每个文件又通过一个 inode 来管理文件的属性,对于一个设备文件来说,其 inode 会记录其主从设备号以及设备类型(字符,块)和读写操作的函数指针等。当内核为设备创建其对应的 inode 时会根据其设备类型,向 inode 中提供不同的文件操作方式(文件操作函数的指针,下层封装了与设备通讯的具体操作)。因为字符设备之间差距很大,所以内核最初只为其提供打开操作,而当打开设备文件时,才会根据设备的特性向其中加入合适的函数,使得能够在设备上执行有意义的操作,进而控制设备。而块设备的操作相对比较统一,比如打开,读,写,seek,内存映射等。

字符设备

字符设备的硬件通常比较简单,而且相关的驱动程序也不难于实现。我们知道,字符设备的 inode 中最初只有打开设备文件这一个操作函数。当我们第一次调用该方法时,它会根据设备号,查找与之对应的驱动程序接下来根据从设备号在驱动程序中查找所有与该设备相关的文件操作函数,最后将这些文件操作函数加入 inode 中。在这个过程中,内核最初为 inode 注入的一般性函数(打开函数)逐渐被与设备特性相关的具体函数所替代。下图展示主设备号为 1 的各个从设备。
device-primary-no-1
有一些设备,我们可以很熟悉,特别是 /dev/null。这里我们无须深入理解每个设备的意义,这里旨在介绍设备操作函数注入的过程。接下来的这张图,展示了根据主从设备号,来找到所有操作函数的过程。
find-device-operations-by-device-no
/dev/null 对应的函数是 null_fops,其中包括如下函数的指针:

1
2
3
4
5
6
static struct file_operations null_fops = {
.llseek = null_lseek,
.read = read_null,
.write = write_null,
.splice_write = splice_write_null,
};

至此,虚拟文件已经和设备驱动程序代码之间建立了联系,null_fops 中对应的函数就代表了驱动程序的函数指针。当我们通过标准库的读写操作,向内核发送一些系统调用时,最终就会调用上述的这些函数,这些函数的具体实现根据设备而各不相同。/dev/null 这种内存设备,实际上不会与实际的外设交互,其调用的函数由内核实现。

1
2
3
4
5
6
7
8
9
10
11
static ssize_t read_null(struct file * file,char __user * buf,
size_t count,loff_t *ppos)
{
return 0;
}

static ssize_t write_null(struct file * file,const char __user * buf,
size_t count,loff_t *ppos)
{
return count;
}

从 /dev/null 设备读取数据时,什么也不返回,向其中写入数据时会被直接忽略。其他字符设备,虽然读写函数的实际实现会和上例有很大不同,但是整体的机制是一样的。本节主要介绍这个统一的机制而不是介绍具体的某一设备实现。

块设备

块设备和字符设备主要有 3 个不同:

  1. 可以在数据中的任意位置访问
  2. 数据总以固定长度的块进行传输
  3. 对块设备的访问有大规模的缓存,已读取的数据会保存在内存中

这里有必要声明两个术语的含义,块和扇区。块是一个特定长度的字节序列,可以通过软件修改,一般可以是 512,1024,2048,4096 字节,它会受到内存页长度的限制,不能超过一个页的大小,是内核和设备之间传输的数据。扇区是一个由硬件确定的一个固定长度(大部分是 512 字节),指定了该设备最少能够传输的数据量。一个块可以理解为多个连续的扇区。因此,块长度总是扇区大小的整数倍。由于扇区是特定于硬件的常数,它用来指定某段数据的位置。内核会将每个块设备理解为一个线性表,由按整数编号的扇区组成。

块设备层不仅负责寻址块设备,为了提升块设备的性能,它还会负责预读部分,内核判断稍后应用可能会需要使用某些数据时,会提前将这部分数据读入内存,缓存起来。此外,块设备层还会保存经常用到的数据,以免重复地从硬件中读取。

块设备层是介于虚拟文件系统与设备驱动之间的一层,下图就展示块设备层的各个成员。这里我们先不讨论关于缓存的内容,那部分我们会在后面的章节介绍。用户空间对设备文件的读写操作,最终会通过 inode 的操作函数指针指向块设备层的内核代码,在这里 I/O 操作并不是立即就得到处理的,而是转化为一个个读写 Request 保存在请求队列中,每个硬盘都有一个与之对应的请求队列,I/O 调度器会负责重排合并 I/O 请求,来让相邻的块操作尽可能一起进行处理,这样就能减少频繁寻址的时间消耗。当调度器觉得是时候进行实际的块设备访问时,就会结合通用磁盘中保存的分区数据以及底层驱动程序的函数指针,来发起实际的 I/O 操作。
block-io
这里我们着重介绍请求队列相关的内容,因为它是整个块设备层的核心。请求队列中的每个请求都有指明自己所要访问的数据所处的扇区,要传输的扇区数以及最关键的传输内容所处的内存页,这些要传输的内存页是通过 BIO 实例组织的,一个请求中可以包含多个 BIO 实例,它们以链表的形式组织,每个 BIO 实例中又包含了多个内存页。这些页用于从设备接收数据,或者向设备发送数据。
bio-structure
当内核需要提交一个 I/O 请求时,会创建一个 BIO 实例,然后将其嵌入到一个请求中,并将请求置于请求队列,随后内核会处理请求队列中的请求,如果有相邻的请求,则将它们合并成一个请求,此外还会按照操作的起始扇区号进行排序,以保证磁盘可以顺序寻址访问,而不至于反复移动磁头。当调度器觉得时机合适时,会真正落实 I/O 操作,通过调用驱动程序的函数,将读写请求发送给磁盘,磁盘处理完数据后会以中断的形式通知内核。

上述的 I/O 调度方案,实际上就是电梯调度器。其主要通过合并和重排来提高磁盘访问的整体效率,但是其有一个缺点就是如果频繁的有新的 I/O 请求加入请求队列,并且这些请求都处于队列的靠近前端的位置时,会导致后端的 I/O 请求一直得不到处理的情况。

为了改善上述问题,衍生出了另一个 I/O 调度算法,也就是 deadline 算法,它基本和电梯算法一致,会尽可能地重排 I/O 请求并合并请求,但是如果有某一个请求很久都没得到处理,它会把这个请求强制提到请求队列的最前端,让它优先处理。

对于一些频繁进行读-写-读的进程,上述的算法都不能使其 IO 效率最大化,举个例子,对于那些进行视频转码的程序,它会频繁的从某一文件中读取一段数据,然后进行转码,最后写入另一个文件,如此往复执行,因为这两次读请求的扇区很大可能是相邻的,而如果在第二次读之前,磁盘进行了写操作,就会导致下次读操作会重新寻道。为了缓解这种问题,衍生出了预测调度算法,它在进行完一次读请求之后,不会立马去处理其他请求,而是等待一小段时间,如果这一小段时间内有临近的下一次读请求加入请求队列,会立刻被处理,这就消除了这次读请求的寻址消耗。如果这段时间内没有邻近的读请求到来,再去处理其他请求。

除此之外,还有一个 noop 算法,它是最简单的 I/O 调度器,因为它只会进行请求的合并而不会进行重排,对于那些没有寻址时间的设备比如固态硬盘,该调度器就是最好的。

最后还有一个完全公平排队算法,它为每个进程维护一个请求队列,每个进程的 I/O 请求会在自己的队列中排序并合并,内核以轮询的方式处理各个队列,这样IO 操作的时间会平均分配给每个进程,确保 I/O 带宽以公平的方式在不同的进程间共享。

资源分配

在设备驱动与设备之间通信的过程中,主要有两种系统资源被使用 I/O 端口和 I/O 内存。这两种资源的组织方式十分相似,首先资源会被分成很多部分,每一部分对应了一个设备或者总线,这些资源以树形结构组织,树上的每个节点都有一个父节点(USB 控制器插在 PCI 总线上,所以 USB 控制器的父节点就是 PCI 总线),一个父节点可以有多个子节点(PCI 总线可以插入多个 USB 控制器),统一父节点的所有子节点会通过链表联系起来。
device-resource-tree
I/O 内存不仅包括与外设通信直接使用的内存区域,还包括系统中可用的物理内存和 ROM 存储器。通过 cat /proc/iomem 即可查看内存的分配情况,其中的缩进关系就代表了上述的树形结构关系。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
cat /proc/iomem
00000000-00000fff : Reserved
00001000-0009ffff : System RAM
000a0000-000bffff : PCI Bus 0000:00
000c0000-000ce7ff : Video ROM
000f0000-000fffff : System ROM
00100000-2f7ae017 : System RAM
01000000-01c00dc0 : Kernel code
01c00dc1-024177ff : Kernel data
0267f000-029fffff : Kernel bss
2f7ae018-2f7bf057 : System RAM
2f7bf058-2f7c0017 : System RAM
2f7c0018-2f7dfa57 : System RAM
2f7dfa58-39005fff : System RAM
39006000-39d63fff : Reserved
39d64000-3a01cfff : System RAM
3a01d000-3abc9fff : ACPI Non-volatile Storage
3abca000-3b5befff : Reserved
3b5bf000-3b5bffff : System RAM
3b5c0000-3b645fff : Reserved
3b646000-3bffffff : System RAM
3c000000-3dffffff : Reserved
40000000-4fffffff : PCI MMCONFIG 0000 [bus 00-ff]
40000000-4fffffff : Reserved
50000000-fbffbfff : PCI Bus 0000:00
e0000000-f1ffffff : PCI Bus 0000:08
e0000000-efffffff : 0000:08:00.0
f0000000-f1ffffff : 0000:08:00.0
f1000000-f11d4fff : efifb
f2100000-f24fffff : PCI Bus 0000:02
f2100000-f24fffff : PCI Bus 0000:03
f2100000-f21fffff : PCI Bus 0000:07
f2100000-f2103fff : 0000:07:00.0
f2200000-f22fffff : PCI Bus 0000:06
f2200000-f2203fff : 0000:06:00.0
f2300000-f23fffff : PCI Bus 0000:05
f2300000-f2303fff : 0000:05:00.0
f2400000-f24fffff : PCI Bus 0000:04
f2400000-f2403fff : 0000:04:00.0

在某些总线和处理器上,仅仅为设备分配内存还不行,还需要将设备的地址空间映射到内核地址空间上才能访问设备,这是通过 ioremap 从而适当地设置系统页表实现的。在完成映射之后,内核可以通过该段地址空间直接访问外设。

下图展示了一些常用的访问 I/O 内存的函数,它们和普通内存的操作几乎没有差别。
io-memory-methods
I/O 端口是设备与总线之间通信的流行方法,我们可以通过 cat /proc/ioports 来查看 I/O 端口的分配情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
cat /proc/ioports
0000-0cf7 : PCI Bus 0000:00
0000-001f : dma1
0020-0021 : pic1
0040-0043 : timer0
0050-0053 : timer1
0060-0060 : keyboard
0061-0061 : PNP0800:00
0064-0064 : keyboard
0070-0071 : rtc0
0080-008f : dma page reg
00a0-00a1 : pic2
00c0-00df : dma2
00f0-00ff : fpu
00f0-00f0 : PNP0C04:00
02f8-02ff : serial
03f8-03ff : serial
0400-0403 : ACPI PM1a_EVT_BLK
0404-0405 : ACPI PM1a_CNT_BLK

在汇编程序层面上,端口的访问必须通过特殊的处理器命令来访问。因此内核提供了一套和 CPU 无关的一套接口来封装底层的差异性。
io-port-methods

参考内容

[1]《Linux内核设计与实现》
[2]《Linux系统编程》
[3]《深入理解Linux内核》
[4]《深入Linux内核架构》
[5] Linux 内核进程管理之进程ID
[6] 服务器三大体系SMP、NUMA、MPP介绍
[7] Linux中的物理内存管理 [一]
[8] Linux内核中的page migration和compaction机制简介
[9] 物理地址、虚拟地址(线性地址)、逻辑地址以及MMU的知识
[10] 逻辑地址
[11] linux内核学习笔记-struct vm_area_struct
[12] Linux中匿名页的反向映射
[13] 系统调用过程详解
[14] 再谈Linux内核中的RCU机制
[15] Unix domain socket 和 TCP/IP socket 的区别
[16] Linux通用块设备层
[17] ext2文件系统结构分析
[18] linux ACL权限规划:getfacl,setfacl使用
[18] 查找——图文翔解RadixTree(基数树)
[19] 页缓存page cache和地址空间address_space
[20] rocketmq使用的系统参数(dirty_background_ration dirty_ratio)
[21] Linux内存调节之zone watermark
[22] Linux的内存回收和交换
[23] Linux中的内存回收[一]
[24] linux内存源码分析 - 内存回收(整体流程)
[25] Linux 软中断机制分析
[26] 对 jiffies 溢出、回绕及 time_after 宏的理解
[27] learn-linux-network-namespace
[28] 显式拥塞通知
[29] 聊聊 TCP 长连接和心跳那些事
[30] 关于 TCP/IP,必知必会的十个问题
[31] TCP协议三次握手连接四次握手断开和DOS攻击
[32] TCP 的那些事儿(上)
[33] TCP 的那些事儿(下)

贝克街的流浪猫 wechat
您的打赏将鼓励我继续分享!
  • 本文作者: 贝克街的流浪猫
  • 本文链接: https://www.beikejiedeliulangmao.top/linux/device-driver/
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
  • 创作声明: 本文基于上述所有参考内容进行创作,其中可能涉及复制、修改或者转换,图片均来自网络,如有侵权请联系我,我会第一时间进行删除。