图 1:第 13 章章首插图
这一篇在干嘛?
上一章的虚拟文件系统(VFS)把”读文件、写文件”抽象成了统一的接口,但真正干活的是底下的硬件设备。本章讲清楚内核是怎么和硬件打交道的:I/O 端口、设备驱动模型(sysfs/kobject)、设备文件、设备驱动的注册与监控方式,以及字符设备驱动的完整实现套路。这是理解后面块设备驱动(第 14 章)的地基。
I/O 体系结构 | 设备驱动模型与 sysfs | kobject:模型的骨架 | 设备文件 | 驱动注册与初始化 | 监控 I/O 操作 | 访问 I/O 共享内存 | DMA 直接内存访问 | 内核支持的层次 | 字符设备驱动
I/O 体系结构:总线、端口、接口与控制器
计算机要正常工作,必须在 CPU、RAM 和一堆 I/O 设备之间打通数据的”公路”。这些公路就是总线(bus)。
一台典型的 PC 里有好几条不同类型的总线(PCI、ISA、USB、SCSI 等),它们通过叫做**桥(bridge)**的硬件设备互相连接。还有两条高速总线专门负责内存读写:前端总线连接 CPU 和 RAM 控制器,后端总线连接 CPU 和外部硬件缓存。**主桥(host bridge)**则把系统总线和前端总线连在一起。
每个 I/O 设备都挂在某一条总线上,而且只能挂在一条上。连接 CPU 与 I/O 设备的数据通路统称为 I/O 总线。从 CPU 到具体设备,中间要经过最多三层硬件:
- I/O 端口(I/O ports)——设备的”地址门牌”;
- I/O 接口(I/O interface)——端口与设备控制器之间的”翻译官”;
- 设备控制器(device controller)——真正指挥设备动作的电路。
图 1:PC 的 I/O 体系结构
I/O 端口
连接到 I/O 总线的每个设备都有一组自己的 I/O 地址,通常称为 I/O 端口。在 IBM PC 体系结构中,I/O 地址空间最多提供 65536 个 8 位端口。两个连续的 8 位端口可以当作一个 16 位端口(必须从偶数地址开始),两个连续的 16 位端口可以当作一个 32 位端口(必须从 4 的倍数地址开始)。
CPU 用四条特殊的汇编指令访问端口:in、ins、out、outs,在 CPU 寄存器和端口之间传输数据。
I/O 端口也可以映射到物理地址空间里,这样 CPU 就能用普通的内存指令(mov、and、or 等)直接和设备通信。现代硬件设备更偏好这种内存映射 I/O,因为它速度更快,还能和 DMA 配合使用。
为了给程序员一个统一的编程界面,每个设备的端口被组织成一组专用寄存器:
- 控制寄存器:CPU 往里写要发给设备的命令;
- 状态寄存器:CPU 从里读设备的内部状态;
- 输入寄存器:CPU 从里读设备送来的数据;
- 输出寄存器:CPU 往里写要发给设备的数据。
图 2:专用 I/O 端口(控制、状态、输入、输出寄存器)
为了省钱,同一个端口经常身兼数职:某些位表示状态,某些位表示命令;同一个端口可能既是输入寄存器又是输出寄存器。
访问 I/O 端口:内核的辅助函数
内核提供了一组封装函数来简化端口访问:
| 函数族 | 作用 |
|---|---|
inb() / inw() / inl() | 从端口读 1、2、4 个连续字节(b=字节,w=字,l=长字) |
inb_p() / inw_p() / inl_p() | 同上,但读完后执行一条”哑指令”插入暂停 |
outb() / outw() / outl() | 向端口写 1、2、4 个连续字节 |
outb_p() / outw_p() / outl_p() | 同上,写完后插入暂停 |
insb() / insw() / insl() | 按 1、2、4 字节为一组,连续读一串字节 |
outsb() / outsw() / outsl() | 按 1、2、4 字节为一组,连续写一串字节 |
访问端口本身很简单,难的是知道哪些端口被哪个设备占了。尤其是 ISA 总线的系统,驱动程序常常要”盲写”某些端口去探测硬件——如果这个端口已被别的设备占用,系统就可能崩溃。
为了防止这种情况,内核用**资源(resource)**机制登记每个硬件设备占用的 I/O 端口。一个资源就代表一段可独占分配的 I/O 端口地址范围,其数据结构如下:
| 类型 | 字段 | 说明 |
|---|---|---|
const char * | name | 资源拥有者的描述 |
unsigned long | start | 资源范围的起点 |
unsigned long | end | 资源范围的终点 |
unsigned long | flags | 各种标志 |
struct resource * | parent | 指向资源树中的父节点 |
struct resource * | sibling | 指向兄弟节点 |
struct resource * | child | 指向第一个子节点 |
所有同类的资源挂成一棵树。为什么用树?举个例子:IDE 硬盘接口占用了 0xf000–0xf00f 这段端口,于是树里有一个对应整个范围的节点;但 IDE 驱动还要记住 0xf000–0xf007 给主盘用、0xf008–0xf00f 给从盘用——于是它在这个节点下面再挂两个子节点。规则就是:树中每个节点的范围必须是父节点范围的子区间。I/O 端口资源树的根叫 ioport_resource,覆盖整个 I/O 地址空间(0–65535)。
驱动程序常用三个函数操作资源树:
request_resource():把给定范围分配给一个设备;allocate_resource():在树中找一个给定大小和对齐方式的空闲范围并分配(PCI 设备驱动主要用它,因为 PCI 设备的端口号可以任意配置);release_resource():释放先前分配的范围。
针对 I/O 端口还有两个快捷封装:request_region() 和 release_region()。当前已分配的 I/O 地址树可以从 /proc/ioports 文件里看到。
I/O 接口
I/O 接口是插在一组 I/O 端口和对应设备控制器之间的硬件电路,相当于”翻译官”:把端口里的值翻译成设备的命令和数据;反过来监测设备状态的变化,更新充当状态寄存器的端口。它还常常通过一条 IRQ 线连到可编程中断控制器,替设备发出中断请求。
接口分两类:
- 定制 I/O 接口:专为某种设备服务。常见的有键盘接口(内部微处理器解码按键组合、产生中断、把扫描码放进输入寄存器)、图形接口(显卡上有自己的帧缓冲、专用处理器和 ROM 代码)、磁盘接口(通过电缆连接集成在磁盘上的智能控制器,如 IDE)、总线鼠标接口、网络接口(以太网 IEEE 802.3 最常见)。
- 通用 I/O 接口:可连接多种外部设备。常见的有:
- 并行口:一次传 1 字节,传统上接打印机,也能接活动磁盘、扫描仪等;
- 串行口:一次传 1 位,内含 UART 芯片负责字节与比特流之间的串并转换,速度慢,适合接调制解调器、鼠标、打印机;
- PCMCIA 接口:多见于笔记本电脑,信用卡大小的设备可不重启插拔;
- SCSI 接口:把 PC 主总线连到一条二级 SCSI 总线,SCSI-2 可挂 8 个设备,Wide SCSI-2/SCSI-3 可挂 16 个以上;
- USB:高速通用接口,取代了传统上接并口、串口、SCSI 的各种外设。
设备控制器
复杂设备还需要一个设备控制器来驱动它,控制器承担两个角色:
- 把从 I/O 接口收到的高层命令翻译成具体的电信号序列,驱使设备执行动作;
- 把设备发来的电信号转换并解释,通过 I/O 接口修改状态寄存器的值。
典型的例子是磁盘控制器:它收到”写这块数据”这样的高层命令,把它变成”把磁头定位到正确磁道”、“把数据写进磁道”这样的低层磁盘操作。现代磁盘控制器非常复杂,自带快速磁盘缓存,还能根据磁盘几何参数重排 CPU 的高层请求。
简单设备则没有控制器,比如可编程中断控制器(第 4 章)和可编程间隔定时器(第 6 章)。另外很多设备自带内存,叫做 I/O 共享内存——比如显卡帧缓冲里的几十兆 RAM,用来存放屏幕图像,后面”访问 I/O 共享内存”一节再细讲。
设备驱动模型与 sysfs
早期 Linux 内核对驱动开发者提供的支持很有限:分配动态内存、预留 I/O 地址或 IRQ 线、响应中断。因为老硬件彼此差异太大,没什么公共性,统一模型没有意义。
但现在不同了。PCI 这类总线对硬件内部设计提出了统一要求,不同种类的现代设备都支持相似的功能,驱动通常要处理:
- 电源管理:比如笔记本进入”待机”时,内核必须让每个设备都进入低功耗状态,而且要按正确顺序进行——必须先让硬盘待机、再让磁盘控制器待机,反过来的话就没法给硬盘发命令了;
- 即插即用(PnP):配置设备时透明地分配资源;
- 热插拔:系统运行中插入/拔出设备。
为了统一处理这些事情,Linux 2.6 提供了一套数据结构和辅助函数,把系统中所有的总线、设备、驱动纳入统一框架——这就是设备驱动模型。
sysfs 文件系统
sysfs 是一个类似 /proc 的特殊文件系统,通常挂载在 /sys 目录。它的目标也是让用户态程序访问内核内部数据结构,但比 /proc 组织得更有条理。sysfs 的一个核心目标是把设备驱动模型各组件之间的层次关系暴露出来。它的顶层目录包括:
| 目录 | 内容 |
|---|---|
block | 块设备(不管挂在哪条总线上) |
devices | 内核识别的所有硬件设备,按所属总线组织 |
bus | 系统中的各类总线 |
drivers | 内核中已注册的设备驱动 |
class | 设备的类型(声卡、网卡、显卡等);同一类可包含不同总线、不同驱动的设备 |
power | 处理某些设备电源状态的文件 |
firmware | 处理某些设备固件的文件 |
组件之间的关系用符号链接表达。比如 /sys/block/sda/device 可能是一个指向 /sys/devices/pci0000:00 下某个子目录的符号链接(代表接在 PCI 总线上的 SCSI 控制器)。sysfs 里的普通文件则表示驱动和设备的属性,比如 /sys/block/hda/dev 文件里存着第一个 IDE 链上主盘的主从设备号。
kobject:模型的骨架
设备驱动模型的核心数据结构叫 kobject,它与 sysfs 天然绑定:每个 kobject 对应 sysfs 里的一个目录。
kobject 总是嵌入在更大的对象——“容器”——里面,容器才是真正描述总线、设备、驱动的东西。比如第一个 IDE 磁盘第一个分区的描述符就对应 /sys/block/hda/hda1 目录。把 kobject 嵌进容器,内核就能:
- 为容器维护引用计数器;
- 维护容器的层次列表或集合(比如一个块设备的 sysfs 目录下,每个分区一个子目录);
- 为容器属性提供用户态视图。
kobject、kset 与 subsystem
kobject 由 kobject 结构表示:
| 类型 | 字段 | 说明 |
|---|---|---|
char * | k_name | 指向容器名字符串的指针 |
char [] | name | 容器名(若不超过 20 字节) |
struct k_ref | kref | 容器的引用计数器 |
struct list_head | entry | kobject 所在链表的指针 |
struct kobject * | parent | 父 kobject 指针 |
struct kset * | kset | 所在 kset 的指针 |
struct kobj_type * | ktype | kobject 类型描述符指针 |
struct dentry * | dentry | 关联的 sysfs 文件的 dentry |
ktype 指向的 kobj_type 对象包含三项:release 方法(kobject 被释放时执行)、sysfs_ops 指针(sysfs 操作表)、默认属性列表。
kref 就是引用计数。kobject_get() 增加计数,kobject_put() 减少计数;计数归零时释放 kobject 使用的资源并执行 release 方法——这个方法通常只有在容器是动态分配的情况下才定义,作用是释放容器本身。
kobject 可以通过 kset 组织成层次树。kset 是同类型 kobject 的集合:
| 类型 | 字段 | 说明 |
|---|---|---|
struct subsystem * | subsys | 指向 subsystem 描述符 |
struct kobj_type * | ktype | kset 的 kobject 类型描述符 |
struct list_head | list | kset 所含 kobject 链表的头 |
struct kobject | kobj | 内嵌的 kobject |
struct kset_hotplug_ops * | hotplug_ops | 热插拔回调函数表 |
巧妙之处在于 kset 内部也嵌了一个 kobject(kobj 字段),集合中所有 kobject 的 parent 都指向它。这样 kset 自身的引用计数就是内嵌 kobject 的引用计数(kset_get()/kset_put() 只是对内嵌 kobject 调 kobject_get()/kobject_put()),而且 kset 自己还能再成为另一个 kset 的成员——只要把内嵌 kobject 插进上层的 kset 即可。
kset 的集合叫 subsystem(子系统),一个 subsystem 可以包含不同类型的 kset。它的结构只有两个字段:内嵌的 kset(存放收录的 kset)和一个 rwsem 读写信号量(递归地保护子系统内所有的 kset 和 kobject)。subsystem 同样可以嵌入更大的容器,容器的引用计数就是内嵌 subsystem 的引用计数——subsys_get() 和 subsys_put() 负责增减它。
图 4:设备驱动模型层次结构示例——bus 子系统包含 pci 子系统,后者包含 drivers kset,其中 serial kobject 带一个 new-id 属性
注册 kobject、kset 与 subsystem
想让 kobject/kset/subsystem 出现在 sysfs 树里,必须先注册它。kobject 对应的目录总是出现在其父 kobject 的目录下——同一 kset 里的 kobject 目录都出现在 kset 自己的目录下。因此 sysfs 子树的结构直接反映了各容器对象之间的层次关系。sysfs 顶层目录通常就对应已注册的 subsystem。
kobject_register():初始化 kobject 并在 sysfs 中创建对应目录(调用前应设置kset字段指向父 kset);kobject_unregister():从 sysfs 中移除目录;kset_register()/kset_unregister()、subsystem_register()/subsystem_unregister():本质上都是对上面两个函数的封装。
kobject 目录下的普通文件是属性:sysfs_create_file() 接收 kobject 地址和属性描述符,在正确目录里创建特殊文件。跨目录的关系用符号链接表达:sysfs_create_link() 为给定 kobject 在另一个 kobject 的目录下创建符号链接。
模型的三大组件:device、device_driver、bus_type
设备用 device 对象描述(关键字段节选):
| 类型 | 字段 | 说明 |
|---|---|---|
struct device * | parent | 父设备指针 |
struct list_head | children | 子设备链表头 |
struct kobject | kobj | 内嵌 kobject |
char [] | bus_id | 设备在宿主总线上的位置 |
struct bus_type * | bus | 宿主总线 |
struct device_driver * | driver | 控制它的驱动 |
void * | driver_data | 驱动的私有数据 |
struct dev_pm_info | power | 电源管理信息 |
unsigned long long * | dma_mask | 设备的 DMA 掩码 |
void (*)(struct device *) | release | 释放设备描述符的回调 |
所有 device 对象都收在 devices_subsys 子系统里,对应 /sys/devices 目录。设备是分层的:如果子设备离开父设备就无法工作,那父设备就是它的 parent。比如 PCI/USB 桥就是 USB 总线上所有设备的父设备。device 对象通常静态嵌入更大的描述符——例如 PCI 设备用 pci_dev 描述,其中的 dev 字段就是一个 device 对象。引用计数通过 get_device()/put_device() 增减;device_register() 把新设备插入模型并自动在 /sys/devices 下创建目录,device_unregister() 反之。
驱动用 device_driver 对象描述:
| 类型 | 字段 | 说明 |
|---|---|---|
char * | name | 驱动名 |
struct bus_type * | bus | 支持设备所在的总线 |
struct kobject | kobj | 内嵌 kobject |
struct list_head | devices | 驱动所支持的所有设备链表头 |
int (*)(struct device *) | probe | 探测设备(确认驱动能否处理它) |
int (*)(struct device *) | remove | 设备被移除时调用 |
void (*)(struct device *) | shutdown | 设备关机时调用 |
int (*)(struct device *, unsigned long, unsigned long) | suspend | 设备进入低功耗状态时调用 |
int (*)(struct device *, unsigned long) | resume | 设备恢复正常状态时调用 |
probe、remove、shutdown/suspend/resume 这几组方法分别服务热插拔、即插即用和电源管理。驱动对象同样嵌入更大的描述符(如 PCI 驱动用 pci_driver),注册用 driver_register()/driver_unregister(),引用计数用 get_driver()/put_driver()。
总线类型用 bus_type 对象描述:
| 类型 | 字段 | 说明 |
|---|---|---|
char * | name | 总线类型名 |
struct kset | drivers | 驱动的 kobject 集合 |
struct kset | devices | 设备的 kobject 集合 |
int (*)(struct device *, struct device_driver *) | match | 检查某驱动是否支持某设备 |
int (*)(struct device *, char **, int, char *, int) | hotplug | 设备注册时调用 |
int (*)(struct device *, unsigned long) | suspend | 保存硬件上下文并改变电源级别 |
int (*)(struct device *) | resume | 恢复电源级别和硬件上下文 |
每个 bus_type 内嵌一个 subsystem;所有这些 subsystem 又收在 bus_subsys 里,对应 /sys/bus 目录——比如有 /sys/bus/pci。每条总线的 subsystem 通常只有 drivers 和 devices 两个 kset:前者放该总线类型的所有驱动描述符,后者放所有设备描述符(因为设备的 kobject 目录已经出现在 /sys/devices 下了,所以这里的 devices 目录里放的是指向那些目录的符号链接)。bus_for_each_drv() 和 bus_for_each_dev() 分别遍历驱动链表和设备链表。
match 方法在内核需要判断”某个驱动能否处理某个设备”时执行,通常就是在驱动的支持标识符表里查找设备标识符。hotplug 方法在设备注册进模型时执行,负责把总线特定的信息加进环境变量,传递给被通知的用户态程序。
类(class)
每个类由一个 class 对象描述,全部属于对应 /sys/class 的 class_subsys 子系统——比如 /sys/class/input 对应 input 类。类对象里有一个 class_device 描述符链表,每个 class_device 代表属于该类的一个逻辑设备,其 dev 字段指向一个 device 描述符。一个硬件设备可以对应多个 class_device——比如声卡包含 DSP、混音器、游戏口等多个子设备,每个子设备都需要自己的用户态接口。同一类的驱动应该向用户态提供相同的功能。每个 class_device 内嵌的 kobject 有个叫 dev 的属性文件,存放访问对应逻辑设备所需的设备文件主从号。
设备文件
Unix 的哲学是”一切皆文件”:I/O 设备也被当作特殊的文件——设备文件。于是读写普通文件用的同一套系统调用就能直接操作设备:同一个 write() 既可以写磁盘上的常规文件,也可以往 /dev/lp0 设备文件里写数据来驱动打印机。
按底层驱动的特点,设备文件分块设备和字符设备两类,界限不算特别严格,但大致可以这么区分:
- 块设备的数据可以随机寻址,传输一个数据块耗时短且(至少从用户角度看)大致相同。典型:硬盘、软盘、CD-ROM、DVD。
- 字符设备的数据要么不能随机寻址(比如声卡),要么随机访问的耗时强烈依赖于数据在设备中的位置(比如磁带机)。
网卡是个著名的例外——它是硬件设备,却不与设备文件直接关联。
设备文件从早期 Unix 就存在。它的 inode 不需要指向磁盘数据块的指针(它根本没有数据),取而代之的是硬件设备的标识符。传统上这个标识符由三部分组成:
- 设备文件类型(字符或块);
- 主设备号(major):标识设备类型。主设备号相同、类型相同的设备文件共享同一套文件操作,因为它们由同一个驱动处理;
- 次设备号(minor):在同一主设备号的一组设备中标识具体某一个。比如同一磁盘控制器管的一组磁盘主设备号相同、次设备号不同。
mknod() 系统调用创建设备文件,参数是文件名、类型和主从号。设备文件通常放在 /dev 目录。注意字符设备和块设备的编号是相互独立的:块设备 (3,0) 和字符设备 (3,0) 是两回事。
| 名称 | 类型 | 主号 | 次号 | 说明 |
|---|---|---|---|---|
/dev/fd0 | 块 | 2 | 0 | 软盘 |
/dev/hda | 块 | 3 | 0 | 第一个 IDE 磁盘 |
/dev/hda2 | 块 | 3 | 2 | 第一个 IDE 磁盘的第二个主分区 |
/dev/hdb | 块 | 3 | 64 | 第二个 IDE 磁盘 |
/dev/hdb3 | 块 | 3 | 67 | 第二个 IDE 磁盘的第三个主分区 |
/dev/ttyp0 | 字符 | 3 | 0 | 终端 |
/dev/console | 字符 | 5 | 1 | 控制台 |
/dev/lp1 | 字符 | 6 | 1 | 并行打印机 |
/dev/ttyS0 | 字符 | 4 | 64 | 第一个串口 |
/dev/rtc | 字符 | 10 | 135 | 实时时钟 |
/dev/null | 字符 | 1 | 3 | 空设备(黑洞) |
设备文件也可以对应虚构的逻辑设备——/dev/null 就是”黑洞”,写进去的数据全被丢弃,读它永远是空的。对内核来说设备文件叫什么名字无关紧要:你在 /tmp/disk 建一个块类型、主号 3 次号 0 的设备文件,效果和 /dev/hda 完全一样。但文件名对某些应用程序可能有意义,比如通信程序可能默认第一个串口叫 /dev/ttyS0。
用户态处理:动态设备号与 udev
传统 Unix 里主从号各 8 位,最多 65536 个块设备文件加 65536 个字符设备文件。听起来够用,实际上不够——问题不在数量,而在静态分配:设备文件传统上一次性永久分配在 /dev 里,官方注册表(Documentation/devices.txt)登记了所有已分配的设备号。可如今硬件种类太多,设备号几乎分配完了;高端系统可能用成百上千块同型磁盘,8 位次设备号根本不够(注册表只给 16 块 SCSI 磁盘每块 15 个分区预留了编号,超过就得改内核源码)。
Linux 2.6 的解法有两条:
- 扩大设备号:主号 12 位,次号 20 位,通常装在一个 32 位的
dev_t变量里。MAJOR、MINOR宏从dev_t里提取主从号,MKDEV宏把两个号编码成dev_t。老式 16 位设备号的文件仍被兼容处理。 - 动态化:
- 动态分配设备号:驱动注册时声明要处理的设备号范围,也可以不指定具体值、让内核分一段可用的给它。新设备不必再去官方注册表登记了。但这样一来设备文件就不能一次建好了,必须在驱动初始化之后按实际主从号创建——设备驱动模型提供了优雅的出口:主从号存放在
/sys/class各子目录的dev属性文件里。 - 动态创建设备文件:安装 udev 工具集。系统启动时清空
/dev,然后 udev 程序扫描/sys/class的子目录找dev文件,根据其中的主从号在/dev里创建对应的设备文件,并按配置文件分配文件名、建立符号链接,尽量沿用传统的 Unix 命名习惯。最终/dev里只有本系统内核支持的设备的文件,一个不多。 - 系统运行中新加载驱动模块或热插拔设备(如 USB 外设)时也能自动补建文件:每当内核发现新设备,就启动一个进程执行用户态的
/sbin/hotplug脚本,把设备信息通过环境变量传过去;装了 udev 的话,脚本就会在/dev里建好设备文件。
- 动态分配设备号:驱动注册时声明要处理的设备号范围,也可以不指定具体值、让内核分一段可用的给它。新设备不必再去官方注册表登记了。但这样一来设备文件就不能一次建好了,必须在驱动初始化之后按实际主从号创建——设备驱动模型提供了优雅的出口:主从号存放在
VFS 如何处理设备文件
设备文件活在目录树里,但和常规文件有本质区别:进程访问常规文件,访问的是磁盘分区上的一些数据块;进程访问设备文件,访问的是一台硬件设备。VFS 的职责就是把这种差别对应用程序隐藏起来。
办法是:打开设备文件时,VFS 改掉它的默认文件操作表——此后对该文件的每个系统调用都翻译成与设备相关的函数调用,而不是宿主文件系统的函数。
以 open() 为例:服务例程解析路径、建立 inode、dentry、file 对象。文件系统读取磁盘 inode 初始化 inode 对象(通常是 ext2_read_inode() 之类);当它发现这个磁盘 inode 对应的是设备文件时,就调用 init_special_inode(),把设备文件的主从号存进 inode 的 i_rdev 字段,并把 i_fop 字段设成 def_blk_fops(块设备)或 def_chr_fops(字符设备)两张通用操作表之一的地址。open() 服务例程随后调用 dentry_open() 分配 file 对象,其 f_op 又被设成同样的表。这样,对设备文件的每个系统调用都会激活设备驱动里的函数,而不是底层文件系统的函数。
设备驱动的注册与初始化
设备驱动就是一组内核例程,它让硬件设备响应 VFS 定义的标准编程接口(open、read、lseek、ioctl 等)。每种设备的 I/O 控制器不同、命令不同、状态信息不同,所以大多数设备都有自己的驱动。
注册
要让”系统调用 → 驱动函数”这条路走得通,驱动必须先注册自己:分配一个 device_driver 描述符、插入设备驱动模型的数据结构、关联到对应的设备文件。访问未注册驱动的设备文件会返回 -ENODEV 错误码。
静态编译进内核的驱动在内核初始化阶段注册;编译成模块的驱动在模块加载时注册(卸载模块时可以反注册)。
以 PCI 设备为例:驱动分配一个 pci_driver 描述符,初始化其中一些字段,然后调用 pci_register_driver()。这个描述符里内嵌了一个 device_driver,pci_register_driver() 只是初始化内嵌描述符并调用 driver_register()。注册时内核会寻找尚未被支持、但可能由此驱动处理的硬件设备——依靠总线的 match 方法和驱动的 probe 方法。一旦找到可处理的设备,内核就分配 device 对象并调用 device_register() 把它插入模型。
初始化:越晚越好
注册和初始化是两回事,时机策略正好相反:
- 注册要尽早——让用户态程序尽快能通过设备文件使用驱动;
- 初始化要尽量晚——初始化意味着分配宝贵的系统资源,占了就轮不到别的驱动用了。
第 4 章见过一个例子:IRQ 的分配通常是动态的、用之前才做,因为好几台设备可能共享同一条 IRQ 线。同样能”最后时刻再要”的资源还有 DMA 传输缓冲区的页框、DMA 通道本身(老式非 PCI 设备如软盘驱动用后者)。
为避免重复索要资源,驱动通常采用使用计数器模式:
- 使用计数器记录当前正在访问设备文件的进程数,
open方法里加一,release方法里减一; open方法在加计数之前先看一眼:如果计数为零(自己是第一个用户),就分配资源、在硬件上启用中断和 DMA;release方法在减计数之后再看看:如果计数为零(没别人在用了),就在 I/O 控制器上禁用中断和 DMA,然后释放资源。
监控 I/O 操作:轮询与中断
I/O 操作要多久完成往往无法预测:可能取决于机械因素(磁头当前位置)、随机事件(网卡上何时到达数据包)、人为因素(用户何时按键、何时发现打印机卡纸)。发起 I/O 操作的驱动必须依赖某种监控技术,要么等到操作完成,要么等到超时。操作正常结束就读状态寄存器确认成败;超时则说明出了岔子。
两种技术:轮询模式和中断模式。
轮询模式
CPU 反复读取设备的状态寄存器,直到它的值表明操作完成。和第 5 章自旋锁的轮询类似,但 I/O 轮询还得记得检查超时:
for (;;) {
if (read_status(device) & DEVICE_END_OPERATION) break;
if (--count == 0) break;
}count 变量在进入循环前初始化,每轮递减,充当粗糙的超时机制。更精确的做法是每轮读一次 jiffies 计数(第 6 章)与开始等待前的值比较。
如果 I/O 操作要花几毫秒这样的”长时间”,纯轮询太浪费 CPU——可以在循环里插入 schedule() 调用,每次轮询后主动让出 CPU。
中断模式
只有当 I/O 控制器能通过 IRQ 线报告”操作做完了”,才能用中断模式。
看一个最简单的例子:一个输入型字符设备。用户对设备文件发 read() 时,驱动向设备控制寄存器发读命令;经过一段不可预测的时间后,设备把一个字节数据放进输入寄存器;驱动把这个字节作为 read() 的返回结果。
驱动包含两个函数:实现 read 方法的 foo_read() 和处理中断的 foo_interrupt():
ssize_t foo_read(struct file *filp, char *buf, size_t count,
loff_t *ppos)
{
foo_dev_t * foo_dev = filp->private_data;
if (down_interruptible(&foo_dev->sem)
return -ERESTARTSYS;
foo_dev->intr = 0;
outb(DEV_FOO_READ, DEV_FOO_CONTROL_PORT);
wait_event_interruptible(foo_dev->wait, (foo_dev->intr==1));
if (put_user(foo_dev->data, buf))
return -EFAULT;
up(&foo_dev->sem);
return 1;
}驱动依赖一个自定义的 foo_dev_t 描述符:sem 信号量防止并发访问硬件,wait 等待队列,intr 标志在设备发中断时置位,data 是单字节缓冲——中断处理程序写它,read 方法读它。所有使用中断的 I/O 驱动都有这种”中断处理程序与读写方法共享的数据结构”,其地址通常存在设备文件的 private_data 字段或全局变量里。
foo_read() 的流程:
- 获取
foo_dev->sem信号量,确保没有别的进程在访问设备; - 清除
intr标志; - 向 I/O 设备发读命令;
- 执行
wait_event_interruptible挂起进程,直到intr变成 1(第 3 章讲过这个宏)。
一段时间后设备发出中断,中断处理程序把数据端口里的字节读走并唤醒进程。调度器再次执行该进程时,foo_read() 的后半段把 foo_dev->data 拷贝到用户地址空间,释放信号量后返回。
中断处理程序的代码:
irqreturn_t foo_interrupt(int irq, void *dev_id, struct pt_regs *regs)
{
foo->data = inb(DEV_FOO_DATA_PORT);
foo->intr = 1;
wake_up_interruptible(&foo->wait);
return 1;
}它从设备输入寄存器读出字符存进 foo_dev_t 描述符的 data 字段,置位 intr,然后 wake_up_interruptible() 唤醒阻塞在 foo->wait 队列里的进程。注意三个参数一个都没用上——这在中断处理程序里相当常见。
本例没做超时控制。一般做法是用静态或动态定时器(第 6 章):发起 I/O 前设定好定时器,操作结束后撤掉。
常见坑:轮询还是中断?
短操作(微秒级)用轮询简单高效;长操作(毫秒级以上)硬轮询会白白烧掉 CPU 周期——要么在轮询循环里插
schedule()让出 CPU,要么改用中断模式。反过来,设备如果不支持中断线通知,想用中断模式也没门。选错模式是初学者写驱动最常见的性能灾难。
访问 I/O 共享内存
PC 体系结构中,I/O 共享内存按设备和总线类型映射到不同的物理地址范围:
- ISA 总线的大多数设备:映射在 0xa0000–0xfffff 的 16 位物理地址区间——这就是第 2 章提到的 640 KB 到 1 MB 之间的”洞”;
- PCI 总线的设备:映射在接近 4 GB 边界的 32 位物理地址,好处理得多。
此外 Intel 推出的 AGP(加速图形端口)标准在 PCI 基础上增强了高性能显卡:除了自己的 I/O 共享内存,还能通过叫 GART(图形地址重映射表)的硬件电路直接寻址主板 RAM 的一部分,从而获得远高于老 PCI 卡的传输速率。不过对内核来说物理内存在哪无所谓,GART 映射的内存和其他 I/O 共享内存一样处理。
内核程序操作的是线性地址,所以必须把 I/O 物理地址翻译成大于 PAGE_OFFSET 的内核线性地址(这里假设 PAGE_OFFSET = 0xc0000000,即内核线性地址在第 4 个 GB)。在 PC 上,把 32 位物理地址与 0xc0000000 做 OR 运算就行。比如要读物理地址 0x000b0fe4 和 0xfc000000 两处的 I/O 内存:
t1 = *((unsigned char *) (0xc00b0fe4));
t2 = *((unsigned char *) (0xfc000000));第一条没问题:初始化阶段内核已把可用 RAM 的物理地址映射到线性空间第 4 GB 的前段,分页单元会把 0xc00b0fe4 翻译回 0x000b0fe4(位于 640 KB–1 MB 的”ISA 洞”里,见第 2 章)。
第二条就出问题了:这个物理地址比系统 RAM 的最大物理地址还大,线性地址 0xfc000000 并不对应物理地址 0xfc000000。这时必须修改内核页表,建立映射——用 ioremap() 或 ioremap_nocache()。ioremap() 类似 vmalloc():调用 get_vm_area() 创建一个 vm_struct 描述符(第 8 章),得到一段大小合适的线性地址区间,然后更新内核主页表;ioremap_nocache() 额外在访问这些线性地址时禁用硬件缓存。正确的写法:
io_mem = ioremap(0xfb000000, 0x200000);
t2 = *((unsigned char *)(io_mem + 0x100000));第一条语句新建一段 2 MB 的线性地址区间,映射从 0xfb000000 开始的物理地址;第二条读到的就是物理地址 0xfc000000 处。用完必须调 iounmap() 撤销映射。
在某些非 PC 体系结构上,不能简单地通过解引用线性地址访问 I/O 共享内存。因此 Linux 定义了一组体系结构相关的函数,访问 I/O 共享内存应该始终用它们:
readb()/readw()/readl():从 I/O 共享内存读 1、2、4 字节;writeb()/writew()/writel():向 I/O 共享内存写 1、2、4 字节;memcpy_fromio()/memcpy_toio():在 I/O 共享内存与动态内存之间拷贝数据块;memset_io():用固定值填充 I/O 共享内存区域。
推荐的写法:
io_mem = ioremap(0xfb000000, 0x200000);
t2 = readb(io_mem + 0x100000);这样所有平台相关的访问细节都被隐藏起来了。
常见坑:直接解引用高位 I/O 地址
在 x86 上对 0xc0000000 以上的”物理地址”直接做 OR 出来的线性地址解引用,只对 RAM 和 ISA 洞里的地址有效;高于 RAM 顶端的 I/O 地址必须先
ioremap(),否则你读写的很可能是无关的内核内存——这种 bug 不崩溃、只是悄悄读错写错,最难查。
DMA:让设备自己搬数据
最初的 PC 里 CPU 是系统唯一的总线主控(bus master)——只有它能驱动地址/数据总线读写 RAM。而 PCI 这类现代总线允许每个外设装备合适电路后也当总线主控。于是所有 PC 都有辅助 DMA 电路,能在 RAM 与 I/O 设备之间搬运数据:CPU 激活 DMA 后就可以撒手不管,传输由 DMA 独立完成,结束时 DMA 发一个中断。CPU 和 DMA 电路同时访问同一内存位置的冲突由内存仲裁器解决(第 5 章)。
DMA 主要用于磁盘驱动等一次传大量字节的设备。DMA 的建立时间相对较高,字节数很少时直接用 CPU 搬反而更快。老 ISA 的 DMA 电路复杂难用、只能访问低 16 MB 物理内存;PCI 和 SCSI 总线的现代 DMA 靠总线上的专用电路,驱动开发者省心多了。
同步与异步 DMA
驱动使用 DMA 有两种方式:
- 同步 DMA:传输由进程触发。例子:声卡播放音乐。应用把声音样本写到声卡 DSP 对应的设备文件,驱动把样本攒在内核缓冲里,同时指示声卡按精确的时间节奏用 DMA 从内核缓冲拷贝样本到 DSP;声卡传完一批就发中断,驱动检查内核缓冲里还有没有待播放的样本,有就再启动一轮 DMA。
- 异步 DMA:传输由硬件设备触发。例子:网卡从局域网收帧。网卡把帧存进自己的 I/O 共享内存再发中断;驱动应答中断后指示网卡把帧从 I/O 共享内存拷进内核缓冲;传输完成网卡再发一次中断,驱动通知上层内核来了新帧。
总线地址
DMA 传输至少涉及一个内存缓冲区。激活传输前,驱动必须确保 DMA 电路能直接访问这些 RAM 位置——于是出现了第四种内存地址:总线地址(bus address)。前三种(逻辑、线性、物理)都是 CPU 视角的地址,而总线地址是除 CPU 以外所有硬件设备驱动数据总线时使用的地址。设置 DMA 时,内核必须把缓冲区的总线地址写进 DMA 或 I/O 设备的端口。
在 80×86 上总线地址恰好等于物理地址。但 Sun 的 SPARC、HP 的 Alpha 等体系结构有 I/O 内存管理单元(IO-MMU)——类似 CPU 的分页单元,把物理地址映射成总线地址,使用 DMA 的驱动必须先设置好 IO-MMU。
不同总线的总线地址宽度不同:ISA 是 24 位,所以 80×86 上 ISA DMA 只能访问低 16 MB 物理内存——这就是为什么这种 DMA 的缓冲区必须用 GFP_DMA 标志从 ZONE_DMA 区分配。原始 PCI 标准是 32 位总线地址(但有些从 ISA 时代设计的 PCI 设备仍然摸不到 0x00ffffff 以上的 RAM);新的 PCI-X 标准用 64 位,可直接寻址高端内存。Linux 用 dma_addr_t 类型表示通用总线地址(80×86 上是 32 位整数;内核支持 PAE 时是 64 位,见第 2 章)。pci_set_dma_mask() / dma_set_mask() 辅助函数检查总线是否接受给定宽度的总线地址,接受则通知总线层该外设将使用这种宽度。
缓存一致性
硬件层面不一定有硬件缓存与 DMA 电路之间的一致性协议,所以 DMA 辅助函数必须把硬件缓存考虑进来。设想:驱动往内存缓冲填好数据,立刻让设备用 DMA 去读——如果 DMA 读的是物理 RAM,而对应的硬件缓存行还没写回 RAM,设备拿到的就是旧值。
驱动开发者有两种 DMA 映射类型可选:
- 一致性 DMA 映射(coherent,也叫同步/一致映射):内核保证没有缓存一致性问题——CPU 对内存的每次写立刻对硬件设备可见,反之亦然;
- 流式 DMA 映射(streaming,也叫异步/非一致映射):驱动必须自己用同步辅助函数处理缓存一致性问题。
80×86 上从来不会有 DMA 缓存一致性问题,因为硬件设备自己会”监听(snoop)“硬件缓存的访问——所以 x86 驱动选哪种都等价。但在 MIPS、SPARC、部分 PowerPC 上设备不总是监听缓存,问题就来了。通用规则:缓冲区被 CPU 和 DMA 处理器以不可预测的方式访问时,必须用一致性映射(比如 SCSI 适配器命令数据结构的缓冲区);其他情况优先流式映射——有些体系结构上一致性映射的处理很笨重、会拖慢系统。
一致性映射的辅助函数:通常驱动在初始化阶段分配缓冲并建立映射,卸载时释放。用 pci_alloc_consistent() / dma_alloc_coherent() 分配并建立映射(同时返回新缓冲的线性地址和总线地址),用 pci_free_consistent() / dma_free_coherent() 释放。
流式映射的辅助函数:缓冲区通常在传输前映射、传输后解除。流程是:先用页框分配器(第 8 章)或通用内存分配器动态分配缓冲,然后调 pci_map_single() / dma_map_single()(参数是缓冲区线性地址,返回其总线地址)建立映射;用 pci_unmap_single() / dma_unmap_single() 解除。
为避免一致性问题:
- RAM→设备传输开始前,调
pci_dma_sync_single_for_device()/dma_sync_single_for_device(),必要时把对应缓存行刷出; - 设备→RAM 传输结束后、驱动读缓冲前,调
pci_dma_sync_single_for_cpu()/dma_sync_single_for_cpu(),必要时使对应缓存行失效。
在 80×86 上这些函数几乎什么也不做(一致性由硬件维护)。高端内存(第 8 章)里的缓冲区也能做 DMA:用 pci_map_page() / dma_map_page()(传页描述符地址和缓冲在页内的偏移),解除用 pci_unmap_page() / dma_unmap_page()。
内核支持的层次
内核并不会完整支持所有现存 I/O 设备。一般有三种支持程度:
- 完全不支持:应用程序直接用
in/out汇编指令和设备的 I/O 端口打交道。最典型的例子是 X Window 系统对图形显示的传统处理方式——很高效,但 X 服务器无法利用设备中断,还需要额外努力让它拿到访问 I/O 端口的权限(第 3 章讲过iopl()和ioperm()系统调用,只有 root 特权的程序能调,可用 setuid 位把这种程序开放给普通用户)。近年内核已支持不少主流显卡:/dev/fb设备文件抽象了显卡帧缓冲,应用不必了解图形接口的 I/O 端口就能访问它;内核还支持 DRI(直接渲染基础设施)让软件发挥 3D 加速卡的硬件能力。不过传统的”自己动手”X 服务器仍被广泛采用。 - 最小支持:内核不认识硬件设备本身,但认识它的 I/O 接口。用户程序把接口当作能读写字符序列的顺序设备用。这适合接在通用 I/O 接口上的外部设备——内核为接口提供设备文件(和驱动),应用程序通过读写设备文件来驾驭外部设备。最小支持比扩展支持好,因为内核体积小。但常见通用接口里只有串口和并口能这么办:串行鼠标直接由 X 服务器之类的程序控制,串行调制解调器永远需要 Minicom、PPP 守护进程这样的通信程序。最小支持的适用面有限——外部设备若要与内核内部数据结构深度交互就不行了。比如接在通用接口上的活动硬盘:应用没法操作识别磁盘、挂载文件系统所需的所有内核数据结构和函数,这时扩展支持是必须的。
- 扩展支持:内核认识硬件设备并亲自处理 I/O 接口,甚至可能根本没有设备文件。直接接在 I/O 总线上的设备(如内置硬盘)一律走扩展支持;USB、PCMCIA、SCSI 接口上的外设——即除串口并口外的所有通用接口——也需要扩展支持。
还有一点值得注意:open()、read()、write() 这些标准文件系统调用并不总能给应用对硬件的完全控制——VFS 的”最大公约数”接口容纳不下某些设备需要的特殊命令,也没法查询设备是否处于某个内部状态。ioctl() 系统调用就是为此而生:除了设备文件的文件描述符和一个指定请求的 32 位参数外,它还能接受任意多个附加参数。比如获取 CD-ROM 音量、弹出光盘这些请求都有专门的 ioctl()——CD 播放器的用户界面就是靠它们实现的。
字符设备驱动
字符设备相对好处理:通常不需要复杂的缓冲策略,也不涉及磁盘缓存。当然字符设备之间差别也很大——有的要实现复杂的通信协议,有的只需从两个 I/O 端口读几个值。多串口卡的驱动就比总线鼠标的驱动复杂得多。块设备驱动则天生更复杂:应用有权反复读写同一块数据,而且访问通常很慢——内核用页缓存和块 I/O 子系统来对付(后面几章)。本章聚焦字符设备驱动。
字符设备驱动由 cdev 结构描述:
| 类型 | 字段 | 说明 |
|---|---|---|
struct kobject | kobj | 内嵌 kobject |
struct module * | owner | 实现驱动的模块指针 |
struct file_operations * | ops | 驱动的文件操作表 |
struct list_head | list | 引用本字符设备的设备文件 inode 链表头 |
dev_t | dev | 分配给驱动的起始主从号 |
unsigned int | count | 分配的设备号范围大小 |
list 是双向循环链表头,收集引用同一字符设备驱动的所有设备文件 inode——同号的设备文件可能有很多个,都指向同一个字符设备;而且一个驱动可以关联一段设备号范围而不只是一个号,落在范围内的设备文件都归它管,范围大小记在 count 里。
cdev_alloc():动态分配 cdev 描述符并初始化内嵌 kobject,引用计数归零时自动释放;cdev_add():把 cdev 描述符注册进设备驱动模型——初始化dev和count字段,然后调kobj_map()把设备号区间和驱动描述符”粘”在一起。
设备驱动模型为字符设备定义了一个 kobject 映射域,用 cdev_map 全局变量引用的 kobj_map 描述符表示:一张按主设备号索引的 255 项哈希表,每项存一个 probe 对象,对应一个已注册的设备号区间:
| 类型 | 字段 | 说明 |
|---|---|---|
struct probe * | next | 哈希冲突链表的下一元素 |
dev_t | dev | 区间起始设备号(主号和次号) |
unsigned long | range | 区间大小 |
struct module * | owner | 实现驱动的模块指针 |
struct kobject (*)(dev_t, int *, void *) | get | 探测区间拥有者的方法 |
int (*)(dev_t, void *) | lock | 增加区间拥有者引用计数的方法 |
void * | data | 拥有者的私有数据 |
kobj_map() 被调用时把指定设备号区间加入哈希表,probe 对象的 data 字段指向驱动的 cdev 描述符;这里的 get 方法就是返回 cdev 内嵌 kobject 的地址,lock 方法就是增加内嵌 kobject 的引用计数。kobj_lookup() 接收映射域和设备号,在哈希表里搜索包含该设备号的区间,返回拥有者 kobject 的地址——对字符设备映射域而言,就是拥有该设备号区间的驱动 cdev 描述符内嵌的 kobject。
分配设备号
内核用哈希表 chrdevs 记录当前已分配的字符设备号区间。两个区间可以共享主设备号,但不能重叠,次设备号必须全部不同。表有 255 项,哈希函数把主设备号的高 4 位掩掉——小于 255 的主设备号各自散列到不同项里。每项指向按主从号递增排序的冲突链表,链表元素是 char_device_struct 结构:
| 类型 | 字段 | 说明 |
|---|---|---|
unsigned char_device_struct * | next | 冲突链表下一元素 |
unsigned int | major | 区间的主设备号 |
unsigned int | baseminor | 区间的起始次设备号 |
int | minorct | 区间大小 |
const char * | name | 处理该区间的驱动名 |
struct file_operations * | fops | 未使用 |
struct cdev * | cdev | 指向字符设备驱动描述符 |
给字符设备驱动分配设备号范围有两种方法:
方法一(新驱动应该用):register_chrdev_region() / alloc_chrdev_region(),可分配任意范围。比如申请从 dev 开始、大小为 size 的区间:
register_chrdev_region(dev, size, "foo");这两个函数不会执行 cdev_add(),驱动必须在区间分配成功后自己调用它。
__register_chrdev_region() 的执行步骤:
- 分配一个新的
char_device_struct结构并清零; - 若区间主设备号为零,说明驱动请求动态分配主号:从哈希表最后一项向前找空冲突链表(NULL 指针),空链表对应未使用的主设备号;找不到就返回错误码;
- 用区间起始设备号、区间大小、驱动名初始化结构体字段;
- 执行哈希函数算出主设备号对应的表项下标;
- 沿冲突链表找新结构的正确插入位置;途中若发现与所求区间重叠的区间,返回错误码;
- 把新
char_device_struct描述符插入冲突链表; - 返回新描述符的地址。
方法二(老式):register_chrdev(),分配固定区间——单个主设备号、次号 0–255。此时驱动不得再调 cdev_add()。它接收所求主号 major(零表示动态分配)、驱动名 name 和文件操作表指针 fops,执行:
- 调
__register_chrdev_region()分配区间,失败则终止; - 为驱动分配新的 cdev 结构;
- 初始化 cdev 结构:把内嵌 kobject 的类型设为
ktype_cdev_dynamic;owner字段设为fops->owner;ops字段设为fops;把驱动名拷进内嵌 kobject 的name字段; - 调用
cdev_add(); - 把步骤 1 返回的
char_device_struct描述符的cdev字段设为驱动的 cdev 描述符地址; - 返回分配区间的主设备号。
访问字符设备驱动
回顾”VFS 如何处理设备文件”:open() 触发的 dentry_open() 把字符设备文件 file 对象的 f_op 设为 def_chr_fops 表。这张表几乎是空的,只定义了一个 open 方法——chrdev_open(),而且它随即就被 dentry_open() 调用。
chrdev_open() 接收被打开设备文件的 inode 和 file 对象地址,大致执行:
- 检查
inode->i_cdev指向的 cdev 描述符。若非 NULL,说明该 inode 已被访问过:增加 cdev 引用计数,跳到第 6 步; - 调
kobj_lookup()搜索包含该设备号的区间;不存在则返回错误码,否则算出关联的 cdev 描述符地址; - 把 cdev 描述符地址存进
inode->i_cdev; - 把
inode->i_cindex设为该设备号在驱动区间内的相对下标(区间第一个次号是 0,第二个是 1……); - 把 inode 对象加进 cdev 描述符
list字段指向的链表; - 用 cdev 描述符
ops字段的内容初始化filp->f_ops; - 若
filp->f_ops->open方法有定义则执行它。驱动若管理多个设备号,这个函数通常会再次设置 file 对象的文件操作表,为被访问的设备文件装上合适的操作集; - 返回零(成功)。
字符设备的缓冲策略
把设备分成块设备和字符设备并不能说明全部问题——有的设备一次 I/O 能吞吐大量数据,有的只传几个字符。
每次 I/O 数据量小的设备(如 PS/2 鼠标,每次读只拿到几个字节:按键状态、指针位置)最好处理:输入数据先一个字符一个字符从设备输入寄存器读进内核数据结构,再从容拷入进程地址空间;输出数据先从进程地址空间拷进内核数据结构,再一个字符一个字符写进设备输出寄存器。这类驱动不用 DMA——建立 DMA 操作花的 CPU 时间和搬运这点数据的时间差不多。
每次 I/O 吞吐大量数据的设备就不同了:顺序设备如声卡、网卡,随机访问设备如各种磁盘(软盘、CD-ROM、SCSI 盘)。假设你的声卡正在录音:它以固定速率(比如 44.14 kHz)对麦克风信号采样,产生 16 位数字流,分成一块块输入数据。声卡驱动必须应付这股数据洪流——哪怕 CPU 正忙着跑别的进程。
办法是两种技术结合:
- 用 DMA 成块传输数据;
- 用循环缓冲区:两个或更多元素,每个元素装一块数据。中断到来(新数据块读好)时,中断处理程序前移循环缓冲的指针,让后续数据写进空元素;驱动成功把一块数据拷进用户地址空间后,就释放一个元素,供新数据使用。
循环缓冲的角色是抹平 CPU 负载的峰值:即便接收数据的应用被高优先级任务拖慢了,DMA 仍能继续填充循环缓冲的元素,因为中断处理程序是代表当前运行进程执行的。
网卡收包也是类似情形,只是数据流是异步的——包与包之间独立到达,间隔不可预测。
总的说来,顺序设备的缓冲处理容易,因为同一个缓冲永远不会被复用——音频应用不可能要求麦克风把刚才那块数据重发一遍。随机访问设备(各种磁盘)的缓冲处理要复杂得多——这正是第 15 章页缓存的主题。
通关标准
能不看书回答:CPU 访问一个设备有哪几层硬件(端口→接口→控制器)?
ioremap()解决什么问题、为什么直接 OR 0xc0000000 不够?一致性 DMA 映射和流式映射怎么选?open()一个字符设备文件后,f_op是怎么一步步从def_chr_fops换成驱动自己的操作表的?
为什么设备驱动要"尽早注册、尽量晚初始化"?
注册只是把驱动描述符挂进设备驱动模型、让设备文件能找到它,代价很小,早做让用户态尽早可用。初始化要分配 IRQ、DMA 缓冲、DMA 通道等稀缺资源(有些设备还共享 IRQ 线),占住不用就是浪费,所以推迟到第一次
open()(使用计数器从 0 变 1)时才做,最后一个用户release()(计数归 0)时释放。
内核怎么防止两个驱动抢用同一组 I/O 端口?
用资源树(
resource结构)。每种可独占分配的实体(I/O 端口、IRQ、DMA)都有一棵树,节点记录区间的 start/end/flags 和父子兄弟指针,子节点范围必须是父节点范围的子区间。驱动调request_resource()申请、release_resource()释放,/proc/ioports可以查看当前 I/O 端口分配情况。
udev 是如何动态创建设备文件的?为什么要动态创建?
因为设备号可以动态分配,驱动拿到的主从号事先不可知,所以设备文件不能一次永久建好。驱动模型把每个逻辑设备的主从号放在
/sys/class子目录的dev属性文件里;启动时 udev 扫描这些文件,在/dev里按配置规则建出设备文件和符号链接。运行中热插拔设备时,内核执行/sbin/hotplug脚本并传环境变量,由 udev 补建文件。
流式 DMA 传输前后要调用哪两个同步函数?分别防什么问题?
RAM→设备传输前调
dma_sync_single_for_device(),把 CPU 脏了的缓存行刷到内存,防设备读到旧值;设备→RAM 传输后、CPU 读缓冲前调dma_sync_single_for_cpu(),使缓存中可能过期的行失效,防 CPU 读到旧值。80×86 上这两个函数几乎是空操作,因为硬件自己监听缓存保持一致;MIPS、SPARC 等架构上则必不可少。
字符设备的循环缓冲区解决什么问题?
抹平 CPU 负载峰值。DMA 持续把设备数据块写进循环缓冲的元素,中断处理程序只负责前移指针;即使接收数据的应用进程被高优先级任务长时间抢占,数据也不会丢——等驱动有空再把块拷到用户空间并腾出元素。对声卡录音这类定时敏感的顺序数据流尤其关键。