深入理解 Linux 内核—I/O 体系结构和设备驱动程序

释放双眼,带上耳机,听听看~!

I/O 体系结构

总线:让信息在个人计算机的 CPU、RAM 和 I/O 设备之间流动的数据通路。

系统总线:所有计算机都拥有一条系统总线,连接大部分内部硬件设备。
一种典型的系统总线是 PCI(Peripheral Component Interconect)总线。

一台计算机包括几种不同类型的总线,它们通过称为“桥”的硬件设备连接在一起。
两条高速总线在内存芯片上来回传送数据:前端总线将 CPU 连接到 RAM 控制器上;后端总线将 CPU 直接连接到外部硬件的高速缓存上。
主机上的桥将系统总线和前端总线连接在一起。

CPU 和 I/O 设备之间的数据通路通常称为 I/O 总线。
每个 I/O 设备依次连接到 I/O 总线上,这种连接使用了包含 3 个元素的硬件组织层次:I/O 端口、接口和设备控制器。

深入理解 Linux 内核---I/O 体系结构和设备驱动程序

I/O 端口

每个连接到 I/O 总线上的设备都有自己的 I/O 地址集,通常称为 I/O 端口。
IBM PC 体系结构中,I/O 地址空间一共提供了 65536 个 8 位的 I/O 端口。
可从偶数地址开始,把两个连续的 8 位端口看作一个 16 位端口。
同理,从 4 的整数倍地址开始,把两个连续的 16 位端口看作一个 32 位端口。
in、ins、out 和 outs 四条汇编语言指令可允许 CPU 对 I/O 端口读写。

I/O 端口还可被映射到物理地址空间,便于使用对内存直接操作的汇编指令(mov、and 等)。
现代的硬件设备更倾向于映射的 I/O,因为这样处理速度较快,并可与 DMA 结合。

为了对 I/O 编程提供统一的方法,且不牺牲性能,每个设备的 I/O 端口被组织成一组专用寄存器。
CPU 把要发送给设备的命令写入设备控制寄存器,并从设备状态寄存器中读出表示设备内部状态的值。
CPU 还可通过读取设备输入寄存器的内容获得数据,也可通过向设备输出寄存器中写入数据而把数据输出到设备。
深入理解 Linux 内核---I/O 体系结构和设备驱动程序
为降低成本,通常把同一 I/O 端口用于不同目的。

访问 I/O 端口

为了检查哪些 I/O 端口已经分配给 I/O 设备,内核使用“资源”记录分配给每个硬件设备的 I/O 端口。

资源表示某个实体的一部分,被互斥地分配给设备驱动程序。
本节,一个资源表示 I/O 端口地址的一个范围。
每个资源对应的信息存放在 resource 数据结构中,同种资源插入到一个树型数据结构。
如,表示 I/O 端口地址范围的所有资源都包含在一个根节点为 ioport_resource 的树中。

节点的孩子被收集在一个链表中,resource 的 child 字段指向第一个元素,silbing 字段指向下一个元素。

I/O 接口

I/O 接口是处于一组 I/O 端口和对应的设备控制器之间的一种硬件电路。

I/O 接口作用:

  • 可将 I/O 端口中的值转换为设备所需的命令和数据。
  • 可检测设备状态的变化,并对起状态寄存器作用的 I/O 端口进行相应的更新。
  • 可通过一条 IRQ 线把这种电路连接到可编程中断控制器上,代表相应的设备发出中断请求。

I/O 接口类型:

  • 专用 I/O 接口:专门用于一个特定的硬件设备,可连接内部设备或外部设备。
  • 通用 I/O 接口:用来连接多个不同的硬件设备,通常为外部设备。

专用 I/O 接口

键盘接口、图像接口、磁盘接口、总线鼠标接口、网络接口。

通用 I/O 接口

并口:数据的传送以每次 1 字节(8 位)为单位进行。可连接可移动磁盘、扫描仪、备份设备等。
串口:与并口类似,但数据的传送是逐位进行的。速度低于并口,主要连接不需要高速操作的外部设备,如鼠标、打印机等。
PCMCIA 接口:方便热插拔外部设备,如硬盘、网卡等。
SCSI:把 PC 主总线连接到次总线(SCSI 总线)的电路。SCSI-2 总线共允许 8 个 PC 和外部设备(硬盘、扫描仪等)连接在一起。
通用串行总线(USB):高速运转的通用 I/O 接口,连接外部设备,替代传统的并口、串口、SCSI 接口。

设备控制器

复杂的设备可能需要一个设备控制器驱动。

控制器作用:

  • 对从 I/O 接口接收到的高级命令进行解释,并通过向设备发送适当的电信号序列强制设备执行特定的操作。
  • 对从设备接收到的电信号进行转换和适当地解释,并修改(通过 I/O 接口)状态寄存器的值。

设备驱动程序模型

诸如 PCI 这样的总线类型对硬件设备的内部设计提出了更高的要求,因此新的硬件设备即使类型不同也要有相似的功能。
对这种设备的驱动程序应当特别关注:

  • 电源管理(控制设备电源线上不同的电压级别)
  • 即插即用(配置设备时透明的资源分配)
  • 热插拔(系统允许时支持设备的插入和移走)

sysfs 文件系统

sysfs 文件系统是一种特殊的文件系统,允许用户态应用程序访问内核内部数据结构,并提供关于内核数据结构的附加信息;

sysfs 文件系统的目标是展现设备驱动程序模型组件间的层次关系,相应的高层目录:

  • block,块设备,独立于所连接的总线。
  • devices,所有被内核识别的硬件设备,依照连接它们的总线分组。
  • bus,系统中用于连接设备的总线。
  • drivers,在内核中注册的设备驱动程序 。
  • class,系统中设备的类型,同一类可能包含由不同总线连接的设备,由不同的驱动程序驱动。
  • power,处理一些硬件设备电源状态的文件。
  • firmware,处理一些硬件设备的固件的文件。

sysfs 文件系统中普通文件的主要作用是表示驱动程序的设备的属性。
如,位于目录 /sys/block/hda 下的 dev 文件含有第一个 IDE 链主磁盘的主设备好和次设备号。

kobject

kobject 是设备驱动程序模型的核心数据结构,每个 kobject 对应于 sysfs 文件系统的一个目录。

kobject 被嵌入一个叫做”容器“的更大对象中,容器描述设备驱动程序模型中的组件。

将一个 kobject 嵌入容器中允许内核:

  • 为容器保持一个引用计数器。
  • 维持容器的层次列表或组。
  • 为容器的属性提供一种用户态查看的视图。

kobject、kset 和 subsystem

kobject 结构的某些字段:

  • ktype,指向 kobj_type 对象,该对象描述了 kobject 的“类型”– 本质上,是包括 kobject 的容器的类型。

kobj_type 数据结构包括三个字段:

    • release 方法
    • 指向 sysfs 操作表的 sysfs_ops 指针
    • sysfs 文件系统的缺省属性链表
  • kref,k_ref 类型结构,包括一个 refcount 字段,为 kobject 的引用计数器,也可作为 kobject 容器的引用计数器。

kobject_get() 和 kobject_put() 分别用于增加和减少引用计数器的值;
如果该计数器的值等于 0,则释放 kobject 使用的资源,并执行 kobject 的类型描述符 kobj_type 对象的 release 方法。

  • kset,将同类型的 kobject 组织成一棵层次树。

kset 结构的某些字段:

  • list,表示包含在 kset 中的 kobject 结构的双向循环链表的首部。
  • ktype,指向 kset 中的 kobj_type 描述符的指针,被 kset 中所有的 kobject 结构共享。
  • kobj,嵌入在 kset 数据结构中的 kobject,而位于 kset 中的 kobject,其 parent 字段指向该内嵌 kobject。

因此,一个 kset 就是 kobject 结合体,但依赖于与层次树中用于引用计数和连接的更高层 kobject。
由于有了 kobject 结构,kset 数据结构可嵌入到“容器”对象中。
最后,kset 可作为其他 kset 的一个成员。

一个 subsystem 可包括不同类型的 kset,用 subsystem 数据结构描述:

  • kset,内嵌的 kset 结构。
  • rwset,读写信号量,保护递归地包含于 subsystem 中的所有 kset 和 kobject。

subsystem 可嵌入到更大的“容器”对象。
深入理解 Linux 内核---I/O 体系结构和设备驱动程序
注册 kobject、kset 和 subsystem

如果想让 kobject、kset 或 subsystem 出现在 sysfs 子树,就必须先注册它们。

通常,sysfs 文件系统的上层目录是已注册的 subsystem。

kobject_register() 初始化 kobject,并将其相应的目录增加到 sysfs 文件系统。
调用此函数前,如果 kobject 有父 set,先设置 kobject 结构中的 kset 字段。

kobject_unregister() 将 kobject 的目录从 sysfs 文件系统移走。

sysfs_create_file() 参数为 kobject 和属性描述符(许多 kobject 目录包含称为属性的普通文件),在合适的目录中创建特殊文件。

sysfs_create_link() 为目录中与其他 kojbect 关联的特定 kobject 创建一个符号链接。

设备驱动程序模型的组件

设备

设备驱动程序模型中的每个设备由一个 device 对象描述。

device 的某些字段:

  • children,parent,node:device 对象全部收集在 devices_subsys 子系统中,对应的目录为 /sys/devices。

设备按照层次关系组织:一个设备是某个“孩子”的“父亲”,子设备离开父设备无法正常工作。
parent 字段指向其父设备描述符,children 指向其子设备链表首部,node 为 children 链表中相邻元素指针。
device 对象中内嵌的 kobject 间的亲子关系也反映了设备的层次关系。

  • driver_list:每个驱动程序都保持一个 device 对象链表,链接了所有可被管理的设备,driver_list 指向链表中的相邻元素。
  • bus_list:对于任何总线类型,都由一个链表存放链接到该类型总线上的所有设备,bus_list 指向链表中的相邻元素。
  • bus:指向总线类型描述符。
  • kobj:内嵌的 kobject 结构。kobj 中的引用计数器记录 device 对象的使用情况。

device_register() 向设备驱动程序模型中插入一个新的 device uix,并自动地在 /sys/devices 目录下为其创建一个新的目录。
device_unregister() 从设备驱动程序模型移走一个设备。

通常,device 对象被静态地嵌入到一个更大的描述符(如 PCI 设备的描述符 pci_dev)中。

驱动程序

设备驱动程序模型中的每个驱动程序都可由 device_driver 对象描述。

device_driver 的某些字段:

  • probe:当总线设备驱动程序发现一个可能由它处理的设备时调用 probe 方法,以探测该硬件,从而对该设备进行进一步的检查。
  • remove:当移走一个可热插拔的设备时,驱动程序调用 remove 方法;驱动程序本身被卸载时,它所处理的每个设备也会调用 remove 方法。
  • shutdown、suspend、resume:当内核必须改变设备的供电状态时,调用这三个方法。
  • kobj:内嵌 kobject 结构,其包含的引用计数器记录 device_driver 对象的使用情况。

driver_register() 为设备驱动程序模型中插入一个新对的 device_driver 对象,并自动地在 sysfs 文件系统下为其创建一个新的目录。
driver_unregister() 从设备驱动程序模型移走一个设备驱动对象。

通常,device_driver 对象静态地被嵌入到一个更大的描述符(如 PCI 设备驱动程序的描述结构 pci_driver)中。

总线

内核支持的每一种总线类型都由一个 bus_type 对象描述。

bus_type 的某些字段:

  • bus_subsys:将嵌入在 bus_type 对象中的所有子系统集合在一起。bus_subsys 子系统与目录 /sys/bus 对应。
  • drivers,devices:每种总线的子系统通常包括两个 kset,它们是 drivers 和 devices。
    • drivers:包含描述符 device_driver,描述与该总线类型相关的所有设备驱动程序
    • devices:包含描述符 device,描述给定总线类型上连接的所有设备。
  • match:内核检查一个给定的设备是否可以由给定的驱动程序处理时,指向 match 方法。

实现:总线只需要在所支持标识符的驱动程序表中搜索设备的描述符。

  • hotplug:通过环境变量将总线的具体信息传递个用户态程序,以通过一个新的可用设备。
  • suspend、resume:当特定类型总线上的设备必须改变其供电状态时,就会执行 suspend 和 resume 方法。

每个类由一个 class 对象描述,所有的类的对象都属于 /sys/class 目录相对应的 class_subsys 子系统。
每个类对象还包括一个内嵌的子系统。

每个类对象包括一个 class_device 描述符链表,其中每个描述符描述了一个属于该类的单独逻辑设备。
class_device 结构的 dev 字段指向一个设备描述符,因此一个逻辑设备对应设备驱动程序模型中的一个给定设备。
但多个 class_device 描述符可对应同一设备,如一个硬件设备可包括几个不同的子设备,每个子设备需要要给不同的用户态接口,sysfs 文件系统中都有与它们对应的目录。

设备驱动程序模型中类的本质是提供一个标准的方法,让为向用户态应用设备导出逻辑设备的接口。
每个 class_device 描述符中内嵌一个 kobject,这是一个名为 dev 的属性。
该属性存放设备文件的主设备号和次设备号,通过它们可访问相应的逻辑设备。

设备文件

类 Unix 操作系统是基于文件概念的,因此将 I/O 设备当作设备文件。

根据设备驱动程序的基本特性,设备文件可分为两种:块和字符,差异如下:

  • 块设备的数据可被随机访问,从用户观点看,传送任何数据块的时间大致相同。块设备有硬盘、软盘等。
  • 字符设备的数据不可被随机访问,或可被随机访问,但所需的时间较大地依赖于数据在设备内的位置(磁带驱动器)。

网卡是一种例外,是不直接与设备文件相对应的硬件设备。

设备文件是存放在在文件系统中的实际文件,其索引节点对应字符或块设备文件。

设备标识符由设备文件的类型(字符或块)和一对参数组成。
第一个参数是主设备号,标识设备的类型。通常,具有相同主设备号和类型的所有设备文件共享相同的文件操作集合。
第二个参数是次设备号,标识了主设备号相同的设备组中的一个特定设备。如,由相同的磁盘控制器管理的一组磁盘具有相同的主设备号和不同的次设备号。

mknod() 创建设备文件。参数有设备文件名、设备类型、主设备号及次设备号。
设备文件通常包含在 /dev 目录中。

设备文件通常与硬件设备或硬件设备设备的某一物理或逻辑分区相对应,某些情况下表示一个虚拟的逻辑设备。

通常,就内核所关心的内容而言,设备文件名不重要,因此大部分应用程序设定为随意地与指定的设备文件交互。

设备文件的用户态处理

设备文件被分配一次且永远保存在 /dev 目录中;因此,系统中的每个逻辑设备都应该有一个与其相对应的、明确定义了设备号的设备文件。
Documentation/devices.txt 文件存放了官方注册的已分配设备号和 /dev 目录节点。

对于大规模系统,8 位次设备号远远不够,为此,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 文件。
对每个文件,udev 程序会在 /dev 目录下为它创建要给相应的设备文件,根据配置文件为其分配一个文件名并创建 一个符号链接。
最后,/dev 目录之存放了系统中内核所支持的所有设备的设备文件。

创建设备文件的时机:
通常在系统初始后,要么在加载设备驱动程序模块时,要么在一个热插拔的设备(如 USB)加入系统中时。

创建设备文件:
udev 工具集可自动地创建相应的设备文件,因为设备驱动程序模型支持设备的热插拔。
当发现一个新的设备时,内核就会产生一个新的进程执行用户态 shell 脚本文件 /sbin/hotplug,并将新设备上的有用信息作为环境变量传递给 shell 脚本。
用户态脚本读取配置文件信息并关注完成新设备初始化所必需的任何操作。
如果安装了 udev 工具集,脚本文件也会在 /dev 目录下创建适当的设备文件。

设备文件的 VFS 处理

进程访问普通文件时,会通过文件系统访问磁盘分区的一些数据块,而访问设备文件时,只需要驱动硬件设备就可以了。
VFS 可为应用程序隐藏设备文件和普通文件之间的差异。

为此,VFS 在设备文件打开时改变其缺省文件操作,将设备文件的每个系统调用都转换成设备相关的函数调用,而不是对主文件系统函数的调用。
与设备相关的函数对硬件设备进行操作以完成进程请求的操作。

假定进程在设备文件上执行 open 系统调用:

  1. 相应的服务例程解析到设备文件的路径名,并建立相应的索引节点对象、目录项对象和文件对象。
  2. 通过适当的文件系统函数读取磁盘上的相应索引节点来对索引节点对象初始化。
  3. 当确定磁盘索引节点与设备文件对象时,调用 init_special_inode(),将索引节点对象的 i_rdev 自动初始化为设备文件的主设备号和次设备号,将索引节点对象的 i_fop 字段设置为 def_blk_fops 或 def_chr_fops 文件操作表的地址。
  4. 调用 dentry_oepn() 分配一个新的文件对象并把 f_op 字段设置为 i_fop 中存放的地址,即再一次指向 def_blk_fops 或 def_chr_fops 的地址。

def_blk_fops 和 def_chr_fops 这两个表的引入使得设备文件上发出的任何系统调用都将激活设备驱动程序的函数而不是基本文件系统的函数。

设备驱动程序

设备驱动程序是内核例程的集合,使得硬件设备响应控制设备的编程接口,而该接口是一组规范的 VFS 函数集。
每个设备都有一个唯一的 I/O 控制器,因此就有唯一的命令和唯一的状态信息,所以大部分 I/O 设备都有自己的驱动程序。

注册设备驱动程序

注册一个设备驱动程序意味着分配一个新的 device_driver 描述符,将其插入到设备驱动程序模型的数据结构中,并把它与对应的设备文件连接起来。
如果设备文件对应的驱动程序以前没有注册,则对其访问会返回出错码 -ENODEV。

如果设备驱动程序被静态地编译进内核,则它的注册在内核初始化阶段进行;但如果作为一个内核模块编译,则它的注册在模块装入时进行。

为了注册一个通用的 PCI 设备,设备驱动程序需要:

  1. 分配一个 pci_driver 类型的描述符,PCI 内核层使用该描述符处理设备。
  2. 初始化描述符的一些字段后,设备驱动程序就会调用 pci_register_driver()。

pci_driver 描述符包括一个内嵌的 device_driver 描述符,pci_register_driver() 仅初始化 device_driver 中的字段。

  1. 调用 driver_register() 把驱动程序插入设备驱动程序模型的数据结构中。

注册驱动程序时,内核寻找可被该驱动程序处理但尚未获得支持的硬件设备。
为此,内核主要依靠相关的总线类型描述符 bus_type 的 match 方法,以及 device_driver 对象的 probe 方法。
当探测到可被驱动的硬件设备,内核会分配一个设备对象,然后调用 device_register() 把设备插入设备驱动程序模型中。

初始化设备驱动程序

设备驱动程序的注册应当尽快,以便用户态程序能通过相应的设备文件使用它;
而设备驱动程序的初始化则尽可能延迟,因为这意味着分配宝贵的系统资源。

为确保资源在需要时能够获得,在获得后不再被请求,设备驱动程序通常采用下列模式:

  • 引用计数器记录当前访问设备文件的进程数。在设备文件的 open 方法中计数器被增加,在 release 中被减少。
  • open 方法在增加计数器前先检查它。如果计数器为 0,则设备驱动程序必须分配资源并激活硬件设备上的中断和 DMA。
  • release 方法在减少使用计数器后检查它。如果计数器为 0,说明没有进程使用该硬件设备,禁止 I/O 控制器上的中断和 DMA,然后释放所分配的资源。

监控 I/O 操作

I/O 操作的持续时间通常不可预知,在任何情况下,启动 I/O 操作的设备驱动程序必须依靠一种监控技术在 I/O 操作终止或超时时发出信号。

终止操作时,设备驱动程序读取 I/O 接口状态寄存器的内容来确定 I/O 操作是否成功执行。
超时情况下,驱动程序知道一定出了问题,因为完成操作所允许的最大时间间隔已经用完。

监控 I/O 操作结束的两种可用技术分别称为轮询模式和中断模式。

轮询模式

CPU 轮询设备的状态寄存器,直到寄存器的值表明 I/O 操作已经完成。

下面时轮询的简单例子:


1
2
3
4
5
6
7
8
9
1for(;;)
2{
3   if(read_status(device) & DEVICE_END_OPERATIOIN)
4       break;
5   if(--count == 0)  // 粗略的超时机制
6       break;
7}
8
9

如果完成 I/O 操作需要的时间相对较多,比如毫秒级,则该模式比较低效,因为 CPU 花费宝贵的机器周期去等待 I/O 操作的完成。
在这种情况下,每次轮询操作后,可通 schedule() 自愿放弃 CPU。

中断模式

当 I/O 控制器能够通过 IRQ 线发出 I/O 操作结束的信号,中断模式才能被使用。

假定项实现一个简单的输入字符设备的驱动程序。
当用户在相应的设备文件上发出 read(),一条命令被发往设备的控制寄存器,一个不可预知的时间间隔后,设备把一个字节的数据放进输入寄存器。
设备驱动程序将该字节作为 read() 的结果返回。

这是一个用中断模式实现驱动程序的例子。实质上,驱动程序包含两个函数:

  1. 实现文件对象 read 方法的 foo_read()。
  2. 处理中断的 foo_interrupt()。

只要用户读设备文件,foo_read() 就被触发:


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
1ssize_t foo_read(struct file *filp, char *buf, size_t count, loff_t *ppos)
2{
3   // foo_dev_t 自定义描述符包含信号量 sem、等待队列 wait、标准 intr 及单个字节缓冲区 data
4
5   foo_dev_t *foo_dev = filp->private_date;  
6
7   // 获取 foo_dev->sem 信号量,确保没有其他进程访问该设备
8   if(down_interruptible(&foo_dev->sem)
9       return -ERESTARTSYS;
10
11  // 清 intr 标志。发出一个系统中断时设置
12  foo_dev->intr = 0;  
13
14  // 对 I/O 设备发出读命令
15  outb(DEV_FOO_READ, DEV_FOO_CONTROL_PORT);  
16
17  // 挂起进程,直到 intr 标志变为 1
18  wait_event_interruptible(foo_dev->wait, (foo_dev->intr == 1));
19
20  // 一定时间后,设备发出中断信号以通知 I/O 操作已经完成
21  // 数据已被放在适当的 DEV_FOO_DATA_PORT 数据端口
22  // 中断处理程序设置 intr 标志并唤醒进程,调度程序重新执行该进程
23
24  // 将 foo_dev->data 变量中的字符拷贝到用户地址空间
25  if(put_user(foo_dev->data, buf))
26      return -EFAULT;
27
28  // 释放 foo_dev->sem 信号量后终止
29  up(&foo_dev->sem);
30
31  return 1;
32}
33
34

为简单起见,上述代码没有包括任何超时控制。一般超时控制是通过静态或动态定时器实现的;定时器必须设置为 I/O 操作后正确的时间,并在操作结束时删除。


1
2
3
4
5
6
7
8
9
10
11
1void foo_interrupt(int irq, void *dev_id, struct pt_regs *regs)
2{
3   // 中断处理程序从设备的输入寄存器读字符,然后存放在 foo 全局变量指向的驱动程序描述符 foo_dev_t 的 data 字段
4   foo->data = inb(DEV_FOO_DATA_PORT);
5  
6   foo->intr = 1;  // 设置 intr 标志
7   wake_up_interruptible(&foo->wait);  // 唤醒 foo->wait 等待队列上阻塞的进程
8   return 1;
9}
10
11

访问 I/O 共享存储器

根据设备和总线的类型,I/O 共享存储器可被映射到不同的物理地址范围,主要有:

  • 对于连接到 ISA 总线上的大多数设备,I/O 共享存储器通常被映射到 0xa0000 ~ 0xfffff 的 16 位物理地址范围;

在 640KB 和 1MB 间留出了一段空间,被称为“空洞”。

  • 对于连接到 PCI 总线上的设备,I/O 共享存储器被映射到接近 4GB 的 32 位物理地址范围。

内核作用于线性地址,因此 I/O 共享存储单元地址必须大于 PAGE_OFFSET。
假设 PAGE_OFFSET 等于 0xc0000000,即第 4 个 GB。

设备驱动程序必须把 I/O 共享存储单元的物理地址转换成内核空间的线性地址。
在 PC 体系结构中,将 32 位的物理地址和 0xc0000000 常量进行或运算即可。

假设内核需要把物理地址位 0x000b0fe4 的 I/O 单元的值存放在 t1 中,把物理地址为 0xfc00000 的 I/O 单元的值存放在 t2 中:


1
2
3
4
1t1 = *((unsigned char *)(0xc00b0fe4));
2t2 = *((unsigned char *)(0xfc000000));
3
4

在初始化阶段,内核已经把可用的 RAM 物理地址映射到线性地址空间第 4 个 GB 的开始部分。
因此,分页单元把第一条语句中的地址 0xc00b0fe4 映射回原来的 I/O 物理地址 0x000b0fe4,正好落在 640KB 到 1MB 的这段“ISA 洞”中。

对于第二条语句,I/O 物理地址超过了系统 RAM 的最大物理地址。
因此,线性地址 0xfc00000 就不需要与物理地址 0xfc00000 对应。
为在内核页表中包括对该 I/O 物理地址映射的线性地址,必须对页表进行修改,可通过 ioremap() 或 ioremap_nocache() 实现。
ioremap() 与 vmalloc() 类似,都调用 get_vm_area() 为所请求的 I/O 共享存储区的大小建立一个新的 vm_struct 描述符。
然后这两个函数适当地更新常规内核页表中的对应页表项。
ioremap_nocache() 在引用再映射的线性地址时还使硬件高速缓存内容失效。

因此,第二条语句的正确形式应为:


1
2
3
4
5
6
7
1// 建立一个 2MB 的新线性地址区间,映射了从 0xfb000000 开始的物理地址
2io_mem = ioremap(0xfb000000, 0x200000);
3
4// 读取地址为 0xfc000000 的内存单元
5t2 = *((unsigned char *)(io_mem + 0x100000));
6
7

在非 PC 体系结构上,不能通过简单地间接引用物理内存单元的线性地址来正确访问 I/O 共享存储器,因此 Linux 定义了一些依赖于体系结构的函数。

直接内存访问(DMA)

现在所有的 PC 都包含一个辅助的 DMA 电路,可用来控制在 RAM 和 IO 设备之间数据的传输。
DMA 一旦被激活,就可自行传送数据;数据传送完成后,发出一个中断请求。
当 CPU 和 DMA 同时访问同一内存时,产生的冲突由一个名为内存仲裁器的硬件电路解决。

使用 DMA 最多的是磁盘驱动器和其他需要一次传送大量字节的设备。

同步 DMA 和异步 DMA

设备驱动程序可使用同步 DMA 或 异步 DMA,前者数据的传输由进程触发,后者由硬件设备触发。

采用同步 DMA 传送的例子如声卡:

  1. 用户态应用程序将声音数据(样本)写入一个与声卡的数字信号处理器(DSP)对应的设备文件。
  2. 声卡的驱动程序把写入的样本收集在内核缓冲区中。
  3. 驱动程序命令声卡把这些样本从内核缓冲区拷贝到预先定时的 DSP 中。
  4. 声卡完成数据传送时,会引发一个中断,然后驱动程序会检查内核缓冲区是否还有样本,如果有,再启动一次 DMA 数据传送。

采用异步 DMA 传送的例子如网卡:

  1. 从一个 LAN 中接收帧。
  2. 网口将收到的帧存储在自己的 I/O 共享存储器,然后引发一个中断。
  3. 驱动程序确认中断后,命令网卡把接收到的帧从 I/O 共享存储器拷贝到内核缓冲区。
  4. 数据传输完成后,网口引发新的中断,然后驱动程序将该新帧通知给上层内核层。

DMA 传送的辅助函数

DMA 辅助函数包括两个子集:老式的子集为 PCI 设备提供了与体系结构无关的函数;
新的子集保证了与总线和体系结构都无关。

总线地址

一般,启动一次数据传输前,设备驱动程序必须确保 DMA 电路可直接访问 RAM 内存单元。

三类存储器地址:逻辑地址、线性地址、物理地址,前两者在 CPU 内部使用,最后一个是 CPU 从物理上驱动数据总线所用的存储器地址。
第四类存储器地址:总线地址,是除 CPU 之外的硬件设备驱动数据总线时所用的存储器地址。

内核关心总线地址的原因:在 DMA 操作中,数据传送不需要 CPU 的参与;I/O 设备和 DMA 电路直接驱动数据总线。
因此,内核开始 DMA 操作时,必须把涉及的内存缓冲区总线地址写入 DMA 适当的 I/O 端口,或写入 I/O 设备的适当 I/O 端口。

Linux 中,数据类型 dma_addr_t 代表一个通用的总线地址。
80×86 体系结构中,dma_addr_t 对应一个 32 位长的整数,除非内核支持 PAE,此时,dam_addr_t 代表一个 64 位的整数。

pci_set_dma_mask() 和 dma_set_mask() 两个辅助函数用于检查总线是否可以接收给定大小的总线地址,如果可以,则通知总线给定的外围设备将使用该大小的总线地址。

高速缓存的一致性

系统体系结构没有必要在硬件级为硬件高速缓存与 DMA 电路之间提供一个一致性协议,
因此,执行 DMA 映射操作时,DMA 辅助函数必须考虑硬件高速缓存。

设备驱动程序开发人员可以采用两种方法处理 DMA 缓冲区,分别使用两类不同的辅助函数完成。

  • 一致性 DMA 映射,CPU 在 RAM 内存单元上所执行的每个写操作对硬件设备而言都是立即可见的。
  • 流式 DMA 映射,使用这种映射方式时,设备驱动程序必须了解高速缓存一致性问题,这可使用适当的同步辅助函数解决。

一般,如果 CPU 和 DMA 处理器以不可预知的方式去访问一个缓冲区,则必须强制使用一致性 DMA 映射方式。
其他情形下,流式 DMA 映射方式更可取,因为在一些体系结构中处理一致性 DMA 映射比较麻烦,且导致更低的系统性能。

一致性 DMA 映射的辅助函数

通常,设备驱动在初始化阶段会分配内存缓冲区并进阿里一致性 DMA 映射;卸载时释放映射和缓冲区。
pci_alloc_consistent() 和 dma_alloc_coherent() 分别分配内存缓冲区和建立一致性 DMA 映射。
它们均返回新缓冲区的线性地址和总线地址。80×86 中,返回新缓冲区的线性地址和物理地址。

pci_free_consistent() 和 dma_free_coherent() 分别释放映射和缓冲区。

流式 DMA 映射的辅助函数

流式 DMA 映射的内存缓冲区通常在数据传送之前被映射,在传送之后被取消映射。
也有可能在几次 DMA 传送过程中保持相同的映射,但在这种情况下,设备驱动程序开发人员必须知道位于内存和外围设备之间的硬件高速缓存。

为了启动一次流式 DMA 数据传送,驱动程序必须首先利用分区页框分配器或通用内存分配器来动态地分配内存缓冲区。
然后,驱动程序调用 pci_map_single() 或 dma_map_single() 建立流式 DMA 映射,这两个函数接收缓冲区的线性地址作为其参数并返回相应的总线地址。
为了释放映射,驱动程序调用相应的 pci_unmap_single() 或 dma_unmap_single()。

为避免高速缓存一致性问题,驱动程序在开始从 RAM 到设备的 DMA 数据传送之前,如有必要,调用 pci_dma_sync_single_for_device() 或 dma_sync_single_for_device()刷新与 DMA 缓冲区对应的高速缓存行。
同样地,从设备到 RAM 的一次 DMA 数据传送完成之前,设备驱动程序不可访问内存缓冲区:如果有必要,读取缓冲区前,驱动程序调用 pci_dma_sync_single_for_cpu() 或 dma_sync_single_for_cpu() 使相应的硬件高速缓存行无效。
80×86 中,上述函数几乎不做任何事情,因为硬件高速缓存和 DMA 之间的一致性由硬件维护。

高端内存内存的缓冲区页框用于 DMA 传送;
开发人员使用 pci_map_page() 或 dma_map_page(),参数为缓冲区所在页的描述符地址和页中缓冲区的偏移地址。
相应地,pci_unmap_page() 或 dma_unmap_page() 释放高端内存缓冲区的映射。

内核支持的级别

Linux 内核由三种可能的方式支持硬件设备:

  • 根本不支持,应用程序使用适当的 in 和 out 汇编语言指令直接与设备的 I/O 端口交互。
  • 最小支持,内核不识别硬件设备,但能识别它的 I/O 接口。用户程序把 I/O 接口视为能够读写字符流的顺序设备。
  • 扩展支持,内核识别硬件设备,并处理 I/O 接口本身。事实上,该种设备可能没有对应的设备文件。

第一种方式与内核设备驱动毫无关系,最常见的是 X Window 系统对图像显示的传统处理方式。
这种方法效率很高,尽管限制了 X 服务器使用 I/O 设备产生的硬件中断。
iopl() 和 ioperm() 系统调用给进程授权访问 I/O 端口,只有具有 root 权限的用户才可调用这两个系统调用。
但通过设置可执行文件的 setuid 标志,普通用户也可调用。

最小支持方法用来处理连接到通用 I/O 接口上的外部硬件设备。
内核通过提供设备文件来处理 I/O 接口;应用程序通过读写设备文件处理外部硬件设备。

最小支持比扩展支持更好,因为它保持内核尽可能小。
但基于 PCI 的通用 I/O 接口中,只有串口和并口的处理使用了这种方法。

最小支持的应用范围是有限的,因为当外部设备必须频繁地与内核内部数据结构进行交互时不能使用这种方法。

一般情况下,直接连接到 I/O 总线上的任何硬件设备(如内置硬盘)都要根据扩展支持方法处理:
内核必须为每个这样的设备提供一个设备驱动程序。
除串口和并口之外的所有通过 I/O 接口之上的连接的外部设备都需要扩展支持。

字符设备驱动程序

处理字符设备相对比较容易,因为通常并不需要复杂的缓冲策略,也不涉及磁盘高速缓存。
字符设备中,有些必须实现复杂的通信协议以驱动硬件设备,而有些仅仅需要从硬件设备的一对 I/O 端口读几个值。

字符设备驱动程序是一个由 cdev 结构描述的。

cdev 中的某些字段:

  • list,双向循环链表的首部,存放相同字符设备驱动程序所对应的字符设备文件的索引节点。
  • count,设备号的范围。设备号位于同一范围内的所有设备文件均由同一个字符设备驱动程序处理。

cdev_alloc() 动态地分配 cdev 描述符,并初始化内嵌的 kobject 数据结构,引用计数器为 0 时会自动释放该描述符。

cdev_add() 在设备驱动程序模型中注册一个 cdev 描述符。
它初始化 cdev 描述符中的 dev 和 count 字段,然后调用 kobj_map() 建立设备驱动程序模型的数据结构,把设备号范围复制到设备驱动程序的描述符中。

设备驱动程序模型为字符设备定义了一个 kobject 映射域,该映射域由一个 kobj_map 类型的描述符描述,并由全局变量 cdev_map 引用。
kobj_map 描述符包括一个散列表,有 255 个表项,由 0 ~ 255 范围的主设备号进行索引。
散列表存储 probe 类型的对象,每个对象都拥有一个已注册的主设备号和次设备号。

kobj_map() 把指定的设备号范围加入到散列表。
相应的 probe 对象的 data 字段指向设备驱动程序的 cdev 描述符。
执行 get 和 lock 方法时把 data 字段的值传递给它们。
get 方法返回值为 cdev 描述符中内嵌的 kobject 数据结构的地址;
lock 方法本质上用于增加内嵌的 kobject 数据结构的引用计数器。

kobj_lookup() 参数为 kobject 映射域和设备号;
它搜索散列表,如果找到,则返回该设备号所在范围的拥有者的 kobject 的地址。
当该函数应用到字符设备的映射域时,就返回设备驱动程序描述符 cdev 中所嵌入的 kobject 的地址。

分配设备号

为了记录目前已经分配了哪些字符设备号,内核使用散列表 chrdevs,表的大小不超过设备号的范围。
两个不同的设备号范围可能共享同一个主设备号,但范围不能重叠,因为它们的次设备号应该完全不同。
chrdevs 包含 255 个表项,由于散列函数屏蔽了主设备号的高四位,因此主设备号的个数少于 255 个。
每个表项指向冲突链表的第一个元素,而该链表是按主、次设备号的顺序递增进行排序的。

冲突链表中的每个元素是一个 char_device_struct 结构。

可采用两种方法为字符设备驱动程序分配要给范围内的设备号:

  • 所有新的设备驱动程序使用第一种方法,即使用 register_chrdev_region() 和 alloc_chrdev_region() 为驱动程序分配任意范围内的设备号。

register_chrdev_region() 不执行 cdev_add(),因此设备驱动程序在所要求的设备号范围被成功分配时必须执行 cdev_add()。

  • 第二种方法使用 register_chrdev(),它分配一个固定的设备号范围,该范围包含唯一一个主设备号及 0 ~ 255 的次设备号。

此种情况下,设备驱动程序不必调用 cdev_add()。

register_chrdev_region() 和 alloc_chrdev_region()

regiseter_chrdev_region() 接收三个参数:

  • 初始的设备号(主设备号和次设备号)
  • 请求的设备号范围大小(与次设备号的大小一样)
  • 这个范围内的设备号对应的设备驱动程序的名称。

register_chrdev_region() 检查该范围内的设备号对应的设备驱动程序的名称,检查请求的设备号范围是否跨越一些次设备号,
如果是,则确定其主设备号及覆盖整个区间的相应设备号范围;
然后,在每个相应设备号范围上调用 __regisetr_chrdev_region()。

alloc_chrdev_region() 与 register_chrdev_region() 类似,但可动态地分配一个主设备号;
因此,该函数接收的参数为:

  • 设备号范围内的初始次设备号
  • 范围的大小
  • 设备驱动程序的名称

alloc_chrdev_region() 结束时也调用 __register_chrdev_region()。

__register_chrdev_region() 执行以下步骤:

  1. 分配一个新的 char_device_struct 结构,并用 0 填充。
  2. 如果设备号范围内的主设备号为 0,那么设备驱动程序请求动态分配一个主设备号。

函数从散列表的末尾项开始继续向后寻找一个与尚未使用的主设备号对应的空冲突链表(NULL 指针),没有找到时返回一个错误码。

  1. 初始化 char_device_struct 结构中的初始设备号、范围大小及设备驱动程序名称。
  2. 执行散列函数计算与主设备号对应的散列表索引。
  3. 遍历冲突链表,为新的 char_device_struct 结构寻找正确的位置。

同时,如果找到与请求的设备号范围重叠的一个范围,则返回一个错误码。

  1. 将新的 char_device_struct 描述符插入冲突链表中。
  2. 返回新的 char_device_struct 描述符的地址。

register_chrdev()

驱动程序使用 register_chrdev() 时需要一个老式的设备号范围:一个单独的主设备号和 0 ~ 255 的次设备号范围。

参数:

  • 请求的主设备号 major(如果是 0 则动态分配)
  • 设备驱动程序的名称 name
  • 一个指针 fops(指向设备号范围内的特定字符设备文件的文件操作表)

指向下列操作:

  1. 调用 __register_chrdev_region() 分配请求的设备号范围,返回一个错误码(不能分配该范围)时函数终止执行。
  2. 为设备驱动程序分配一个新的 cdev 结构。
  3. 初始化 cdev 结构:

a. 将内嵌的 kobject 类型设置为 ktype_cdev_dynamic 类型的描述符。
b. owner = fops->owner。
c. ops = fops。
d. 将设备驱动程序的名称拷贝到内嵌的 kobject 结构里的 name 字段中。

  1. 调用 cdev_add()。
  2. 将 __register_chrdev_region() 在第 1 步中返回的 char_device_struct 描述符的 cdev 字段设置为设备驱动程序的 cdev 描述符的地址。
  3. 返回分配的设备号范围的主设备号。

访问字符设备驱动程序

open() 为服务例程触发的 dentry_open() 定制字符设备文件的文件对象的 f_open 指向 def_chr_fops 表。
这个表几乎为空,仅定义了 chrdev_open() 作为设备文件的打开方法,该方法由 dentry_open() 直接调用。

chrdev_open() 的参数:

  • 索引节点的地址 inode
  • 指向打开文件对象的指针 filp

chrdev_oepn() 执行以下操作:

  1. 检查执行设备驱动程序的 cdev 描述符的指针 inode->i_cdev。

如果该字段不为空,则 inode 结构已经被访问:如果该字段不为空,则 inode 结构已经被访问:增加 cdev 描述符的应用计数器值并跳到第 6 步。

  1. 调用 kobj_lookup() 搜索包括该设备号在内的范围,该范围不存在时返回一个错误码;否则,计算与该范围相对应的 cdev 描述符的地址。
  2. inode->i_cdev = cdev 描述符的地址。
  3. inode->i_cindex = 设备驱动程序的设备号范围内的设备号的相关索引。
  4. 将 inode 对象加入到由 cdev 描述符的 list 字段指向的链表中。
  5. filp->f_ops = cdev 描述符的 ops 字段。
  6. 如果定义了 filp->f_ops->open 方法,chrdev_open() 就执行它。

如果设备驱动程序处理一个以上的设备号,则 chrdev_open() 一般会再次设置 file 对象的文件操作,以便为所访问的设备文件安装合适的文件操作。

  1. 成功时返回 0 结束。

字符设备的缓冲策略

字符设备如声卡必须能处理所有可能情况下的蜂拥而至的数据,即使当 CPU 暂时忙于运行某个其他进程也不例外。
这可结合两种不同的技术做到:

  • 使用 DMA 方式传送数据块。
  • 运用两个或多个元素的循环缓冲区,每个元素具有一个数据块的大小。

当一个中断发生时,中断处理程序把指针移到循环缓冲区的下一个元素,以便将来的数据会存放在一个空元素中。
相反,只有驱动程序把数据成功拷贝到用户地址空间,就释放循环缓冲区中的元素,以便保存从硬件设备传送来的新数据。

循环缓冲区的作用是消除 CPU 负载的峰值。

给TA打赏
共{{data.count}}人
人已打赏
安全运维

WordPress网站专用docker容器环境带Waf

2020-7-18 20:04:44

安全运维

运维安全-Gitlab管理员权限安全思考

2021-9-19 9:16:14

个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索