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

I/0体系结构和设备驱动程序

I/O体系结构

为了确保计算机能够正常工作,必须提供数据通路,让信息在连接到个人计算机的CPU、RAM和I/O设备之间流动。这些数据通路总称为总线,担当计算机内部主通信通道的作用。

所有计算机都拥有一条系统总线,它连接大部分内部硬件设备。一种典型的系统总线是PCl(Peripheral Component Interconnect)总线。目前使用其他类型的总线也很多,例如ISA、EISA、MCA、SCSI和USB。典型的情况是,一台计算机包括几种不同类型的总线,它们通过被称作“桥”的硬件设备连接在一起。两条高速总线用于在内存芯片上来回传送数据:
前端总线将CPU连接到RAM控制器上,而后端总线将CPU直接连接到外部硬件的高速缓存上。主机上的桥将系统总线和前端总线连接在一起。

任何I/O设备有且仅能连接一条总线。总线的类型影响I/O设备的内部设计,也影响着内核如何处理设备。本节我们将讨论所有PC体系结构共有的功能性特点,而不具体介绍特定总线类型的技术细节。

CPU和I/O设备之间的数据通路通常称为I/O总线。80x86微处理器使用I6位的地址总线对I/O设备进行寻址,使用8位、16位或32位的数据总线传输数据。每个I/O设备依次连接到I/O总线上,这种连接使用了包含3个元素的硬件组织层次:I/O端口、接口和设备控制器。图13-1显示了I/O体系结构的这些成分。

在这里插入图片描述

I/O端口

每个连接到I/O总线上的设备都有自己的I/O地址集,通常称为I/O端口(1/O port)。在IBM PC体系结构中,I/O地址空间一共提供了65536个8位的I/O端口。可以把两个连续的8位端口看成一个16位端口,但是这必须从偶数地址开始。同理,也可以把两个连续的I6位端口看成一个32位端口,但是这必须是从4的整数倍地址开始。

有四条专用的汇编语言指令可以允许CPU对I/O端口进行读写,它们是in、ins、out.和outs。在执行其中的一条指令时,CPU使用地址总线选择所请求的I/O端口,使用数据总线在CPU寄存器和端口之间传送数据。

I/O端口还可以被映射到物理地址空间。因此,处理器和I/O设备之间的通信就可以使用对内存直接进行操作的汇编语言指令(例如,mov、and、or等等)。现代的硬件设备更倾向于映射的I/O,因为这样处理的速度较快,并可以和DMA结合起来。

系统设计者的主要目的是对I/O编程提供统一的方法,但又不牺牲性能。为了达到这个目的,每个设备的I/O端口都被组织成如图13-2所示的一组专用寄存器。CPU把要发送给设备的命令写入设备控制寄存器(device control register),并从设备状态寄存器(device status register)中读出表示设备内部状态的值。CPU还可以通过读取设备输入寄存器(device input register)的内容从设备取得数据,也可以通过向设备输出寄存器(device output register)中写入字节而把数据输出到设备。
在这里插入图片描述

为了降低成本,通常把同一I/O端口用于不同目的。例如,某些位描述设备的状态,而其他位指定向设备发出的命令。同理,也可以把同一I/O端口用作输入寄存器或输出寄存器。

访问I/O端口

in、out、ins和outs汇编语言指令都可以访问I/O端口。内核中包含了以下辅助函数来简化这种访问:

inb(),inw(),inl()
	分别从I/O端口读取1、2或4个连续字节。后缀“b”、“w”、“I”分别代表一个字节(8位)、一个字(16位)以及一个长整型(32位)。
inb_p(),inw_p(),inl_p()
	分别从I/O端口读取1、2或4个连续字节,然后执行一条“哑元(dummy,即空指令)”指令使CPU暂停。
outb(),outw(),outl()
	分别向一个I/O端口写入1、2或4个连续字节。
outb_p(),outw_p(),outl_p()
	分别向一个I/O端口写入1、2或4个连续字节,然后执行一条“哑元”指令使CPU 暂停。
insb(),insw(),insl()
	分别从I/O端口读取以1、2或4个字节为一组的连续字节序列。字节序列的长度由该函数的参数给出。
outsb(),outsw(),outsl()
	分别向I/O端口写入以1、2或4个字节为一组的连续字节序列。

虽然访问I/O端口非常简单,但是检测哪些I/O端口已经分配给I/O设备可能就不这么简单了,对基于ISA总线的系统来说更是如此。通常,I/O设备驱动程序为了探测硬件设备,需要盲目地向某一I/O端口写入数据;但是,如果其他硬件设备已经使用了这个端口,那么系统就会崩溃。为了防止这种情况的发生,内核必须使用“资源”来记录分配给每个硬件设备的I/O端口。

资源(resource)表示某个实体的一部分,这部分被互斥地分配给设备驱动程序。在我们的情况中,一个资源表示I/O端口地址的一个范围。每个资源对应的信息存放在resource数据结构中,其字段如表13-1所示。所有的同种资源都插入到一个树型数据结构中;例如,表示I/O端口地址范围的所有资源都包含在一个根节点为ioport_resource 的树中。
在这里插入图片描述

节点的孩子被收集在一个链表中,其第一个元素由child指向。sibling字段指向链表中的下一个节点。为什么使用树?
例如,考虑一下IDE硬盘接口所使用的I/O端口地址——比如说从0xf000到0xf00f。然后,start字段为0xf000且end字段为0xf00f的这样一个资源包含在树中,控制器的常规名字存放在name字段中。但是,IDE设备驱动程序需要记住另外的信息,也就是IDE链(IDE chain)的主盘(master disk)使用0xf000~0xf007 的子范围,从盘(slave disk)使用0xf008~0xf00f的子范围。
为了做到这点,设备驱动程序把两个子范围对应的孩子插入到0xf000,0xf00f的整个范围对应的资源下。一般来说,树中的每个节点肯定相当于父节点对应范围的一个子范围。I/O端口资源树(ioport_resource)的根节点跨越了整个I/O地址空间(从端口0~65535)。

任何设备驱动程序都可以使用下面三个函数,传递给它们的参数为资源树的根节点和要插入的新资源数据结构的地址:

request_resource()
	把一个给定范围分配给一个I/O设备。
allocate_resource()
	在资源树中寻找一个给定大小和排列方式的可用范围;
	若存在,就将这个范围分配给一个I/O设备
	(主要由PCI设备驱动程序使用,这种驱动程序可以配置成使用任意的端口号和主板上的内存地址对其进行配置)。
release_resource()
	释放以前分配给I/O设备的给定范围。

内核也为以上应用于I/O端口的函数定义了一些快捷函数:request_region()分配I/O 端口的给定范围,release_region()释放以前分配给I/O端口的范围。当前分配给I/O 设备的所有I/O地址的树都可以从/proc/ioports文件中获得。

I/O接口

I/O接口(1/0 interface)是处于一组I/O端口和对应的设备控制器之间的一种硬件电路。它起翻译器的作用,即把I/O端口中的值转换成设备所需要的命令和数据。在相反的方向上,它检测设备状态的变化,并对起状态寄存器作用的I/O端口进行相应的更新。还可以通过一条IRQ线把这种电路连接到可编程中断控制器上,以使它代表相应的设备发出中断请求。有两种类型的接口:

专用1/0接口
	专门用于一个特定的硬件设备。在一些情况下,设备控制器与这种I/O接口处于同一块卡中(注1)。
	连接到专用I/O接口上的设备可以是内部设备(位于PC机箱内部的设备),也可以是外部设备(位于PC机箱外部的设备)。
通用I/O接口
	用来连接多个不同的硬件设备。连接到通用I/O接口上的设备通常都是外部设备。

每块卡都要插入PC的一个可用空闲总线插槽中。如果一块卡通过一条外部电缆连接到一个外部设备上,那么在PC后面的面板中就有一个对应的连接器。

专用IO接口

专用I/O接口的种类很多,因此目前已装在PC上设备的种类也很多,我们无法一一列出,在此只列出一些最通用的接口:

键盘接口
	连接到一个键盘控制器上,这个控制器包含一个专用微处理器。
	这个微处理器对按下的组合键进行译码,产生一个中断并把相应的键盘扫描码写入输入寄存器。
图形接口
	和图形卡中对应的控制器封装在一起,图形卡有自己的帧缓冲区,还有一个专用处理器以及存放在只读存储器(ROM)芯片中的一些代码。
	帧缓冲区是显卡上固化的存储器,其中存放的是当前屏幕内容的图形描述。
磁盘接口
	由一条电缆连接到磁盘控制器,通常磁盘控制器与磁盘放在一起。
	例如,IDE接口由一条40线的带形电缆连接到智能磁盘控制器上,在磁盘本身就可以找到这个控制器。
总线鼠标接口
	由一条电缆把接口和控制器连接在一起,控制器就包含在鼠标中。
网络接口
	与网卡中的相应控制器封装在一起,用以接收或发送网络报文。虽然广泛采用的网络标准很多,但还是以太网(IEEE 802.3)最为通用。

通用IO接口

现代PC都包含连接很多外部设备的几个通用I/O接口。最常用的接口有:

并口
	传统上用于连接打印机,它还可以用来连接可移动磁盘、扫描仪、备份设备、其他计算机等等。数据的传送以每次1字节(8位)为单位进行。
串口
	与并口类似,但数据的传送是逐位进行的。
	串口包括一个通用异步收发器(UART)芯片,它可以把要发送的字节信息拆分成位序列,也可以把接收到的位流重新组装成字节信息。
	由于串口本质上速度低于并口,因此主要用于连接那些不需要高速操作的外部设备,如调制解调器、鼠标以及打印机。
CMCIA接口
	大多数便携式计算机都包含这种接口。
	在不重新启动系统的情况下,这种形状类似于信用卡的外部设备可以被插入插槽或从插槽中拔走。
	最常用的PCMCIA设备是硬盘、调制解调器、网卡和扩展RAM。
SCSI(小型计算机系统接口)接口
	是把PC主总线连接到次总线(称为SCSI总线)的电路。
	SCSI-2总线允许一共8 个PC和外部设备(硬盘、扫描仪、CR-ROM刻录机等等)连接在一起。
	如果有附加接口,宽带SCSI-2和新的SCSI-3接口可以允许你连接多达16个以上的设备。
	SCSI标准是通过SCSI总线连接设备的通信协议。
通用串行总线(USB)
	高速运转的通用I/O接口,可用于连接外部设备,代替传统的并口、串口以及SCSI接口。

设备控制器

复杂的设备可能需要一个设备控制器(device controller)来驱动。从本质上说,控制器起两个重要作用:

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

典型的设备控制器是磁盘控制器,它从微处理器(通过I/O接口)接收诸如“写这个数据块”之类的高级命令,并将其转换成诸如“把磁头定位在正确的磁道上”和“把数据写入这个磁道”之类的低级磁盘操作。现代的磁盘控制器相当复杂,因为它们可以把磁盘数据快速保存到内存的高速缓存中,还可以根据实际磁盘的几何结构重新安排CPU的高级请求,使其最优化。

比较简单的设备没有设备控制器,可编程中断控制器(参见第四章中的“中断和异常”一节)和可编程间隔定时器(参见第六章中的“可编程间隔定时器(PIT)一节)”就是这样的设备。

很多硬件设备都有自己的存储器,通常称之为I/O共享存储器。例如,所有比较新的图形卡在帧缓冲区中都有几MB的RAM,用它来存放要在屏幕上显示的屏幕映像。我们将在本章的“访问I/O共享存储器”一节中讨论I/O共享存储器。

设备驱动程序模型

Linux内核的早期版本为设备驱动程序的开发者提供微不足道的基本功能:分配动态内存,保留I/O地址范围或中断请求(IRQ),激活一个中断服务例程来响应设备的中断。事实上,在更老的硬件设备上编程棘手而困难重重,还有即使两种不同的硬件设备连在同一条总线上,但二者也很少有共同点。因此,试图为这种硬件设备的驱动程序开发者提供一种统一的模型是难以做到的。

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

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

系统中所有硬件设备由内核全权负责电源管理。例如,在以电池供电的计算机进入“待机”状态时,内核应立刻强制每个硬件设备(硬盘、显卡、声卡、网卡、总线控制器等等)处于低功率状态。因此,每个能够响应“待机”状态的设备驱动程序必须包含一个回调函数,它能够使得硬件设备处于低功率状态。而且,硬件设备必须按准确的顺序进人“待机”状态,否则一-些设备可能会处于错误的电源状态。例如,内核必须首先将硬盘置于“待机”状态,然后才是它们的磁盘控制器,因为若按照相反的顺序执行,磁盘控制器就不能向硬盘发送命令。

为了实现这些操作,Linux 2.6提供了一些数据结构和辅助函数,它们为系统中所有的总线、设备以及设备驱动程序提供了一个统一的视图;这个框架被称为设备驱动程序模型。

sysfs文件系统

sysfs文件系统是一种特殊的文件系统,被安装于/sys目录下的/proc文件系统相似。/proc 文件系统是首次被设计成允许用户态应用程序访问内核内部数据结构的一种文件系统。/sysfs文件系统本质上与/proc有相同的目的,但是它还提供关于内核数据结构的附加信息;此外,/sysfs的组织结构比/proc更有条理。或许,在不远的将来,/proc和/sysfs将会继续共存。sysfs文件系统的目标是要展现设备驱动程序模型组件间的层次关系。该文件系统的相应高层目录是:

block
	块设备,它们独立于所连接的总线。
devices
	所有被内核识别的硬件设备,依照连接它们的总线对其进行组织。
bus
	系统中用于连接设备的总线。
drivers
	在内核中注册的设备驱动程序。
class
	系统中设备的类型(声卡、网卡、显卡等等);同一类可能包含由不同总线连接的设备,于是由不同的驱动程序驱动。
power
	处理一些硬件设备电源状态的文件。
firmware
	处理一些硬件设备的固件的文件。

sysfs文件系统中所表示的设备驱动程序模型组件之间的关系就像目录和文件之间符号链接的关系一样。例如,文件/sys/block/sda/device可以是一个符号链接,指向在/sys/devices/pci0000:00(表示连接到PCI总线的SCSI控制器)中嵌入的一个子目录。此外,文件/sys/block/sda/device/block是到目录/sys/block/sda的一个符号链接,这表明这个PCI设备是SCSI磁盘的控制器。

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

kobject

设备驱动程序模型的核心数据结构是一个普通的数据结构,叫做kobject,它与sysfs文件系统自然地绑定在一起:每个kobject对应于sysfs文件系统中的一个目录。kobject被嵌入一个叫做“容器”的更大对象中,容器描述设备驱动程序模型中的组件(注2)。容器的典型例子有总线、设备以及驱动程序的描述符;例如,第一个IDE磁盘的第一个分区描述符对应于/sys/block/hda/hdal目录。将一个kobject嵌入容器中允许内核:

  1. 为容器保持一个引用计数器。
  2. 维持容器的层次列表或组(例如,与块设备相关的sysfs目录为每个磁盘分区包含一个不同的子目录)。
  3. 为容器的属性提供一种用户态查看的视图。

kobject、kset和subsystem每个kobject由kobject数据结构描述,其各字段如表13-2所示。
在这里插入图片描述
ktype字段指向kobj_type对象,该对象描述了kobject的“类型” ——本质上,它描述的是包括kobject的容器的类型。kobj_type数据结构包括三个字段:release方法(当kobject被释放时执行),指向sysfs操作表的sysfs_ops指针以及sysfs文件系统的缺省属性链表。

kref字段是一个k_ref类型的结构,它仅包括一个refcount字段。顾名思义,这个字段就是kobject的引用计数器,但它也可以作为kobject容器的引用计数器。kobject_get()和kobject_put()函数分别用于增加和减少引用计数器的值;如果该计数器的值等于0,就会释放kobject使用的资源,并且执行kobject的类型描述符kobj_type对象的release 方法。该方法用于释放容器本身,通常只有在动态地分配kobject容器时才定义该方法。通过kset数据结构可将kobjects组织成一棵层次树。kset是同类型kobject结构的一个集合体——也就是说,相关的kobject包含在同类型的容器中。kset数据结构的字段如表13-3所示。
在这里插入图片描述
list字段表示包含在kset中的kobject结构的双向循环链表的首部。ktype字段是指向kset中的kobj_type描述符的指针,该描述符被kset中所有的kobject结构共享。

kobj字段是嵌入在kset数据结构中的kobject;而位于kset中的kobject,其parent字段指向这个内嵌的kobject结构。因此,一个kset就是kobject集合体,但是它依赖于层次树中用于引用计数和连接的更高层kobject。这种设计编码效率很高,并可获得最大的灵活性。例如,分别用于增加和减少kset引用计数器值的kset_get()函数和kset_put()函数,只需简单地调用内嵌的kobject结构中的kobject_get()函数和kobject_put()函数;因为kset的引用计数器只不过是内嵌在kset中的类型为kobject 的kobj的引用计数器。而且,由于有了内嵌的kobject结构,kset数据结构可以嵌入到“容器”对象中,非常类似于嵌入的kobject数据结构。最后,kset可以作为其他kset的一个成员:它足以将内嵌的kobject插入到更高层次的kset中。还存在所谓subsystem的kset集合。一个subsystem可以包括不同类型的kset,用包含两个字段的subsystem数据结构来描述:

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

subsystem数据结构甚至也可以嵌入到一个更大的“容器”对象中;因此,容器的引用计数器也是内嵌subsystem的引用计数器——也就是嵌入在subsystem中的kset所嵌的kobject的引用计数器。subsys_get()和subsys_put()函数分别用于增加和减少这个引用计数器的值。

图13-3显示了设备驱动程序模型层次的一个例子。bus子系统包括一个pci子系统,pci 子系统又依次包含驱动程序的一个kset。这个kset包含一个串口kobject(具有唯一new-id属性的串口对应的设备驱动器程序)。
在这里插入图片描述

注册kobject、kset和subsystem

一般来说,如果想让kobject、kset或subsystem出现在sysfs子树中,就必须首先注册它们。与kobject对应的目录总是出现在其父kobject的目录中。例如,位于同一个kset中的kobject的目录出现在kset本身的目录中。因此,sysfs子树的结构就描述了各种已注册的kobject之间以及各种容器对象之间的层次关系。

通常,sysfs文件系统的上层目录肯定是已注册的subsystem。kobject_register()函数用于初始化kobject,并且将其相应的目录增加到sysfs文件系统中。在调用此函数之前,调用程序应该先设置kobject结构中的kset字段,使它指向其父kset(如果有的话)。kobject_unregister()函数则将kobject的目录从sysfs文件系统中移走。为了更易于内核开发者进行开发,Linux也提供了kset_register()和kset_unregister()函数,以及subsystem_register()subsystem_unregister()函数,但本质上它们是围绕kobject_register()和kobject_unregister()的封装函数。

如前所述,许多kobject目录都包括称作属性(attribute)的普通文件。sysfs_create_file()函数接收kobject的地址和属性描述符作为它的参数,并在合适的目录中创建特殊文件。sysfs文件系统中所描述的对象间的其他关系可以通过符号链接的方式来建立:sysfs_create_link()函数为目录中与其他kobject相关联的特定kobject创建一个符号链接。

设备驱动程序模型的组件

设备驱动程序模型建立在几个基本数据结构之上,这些结构描述了总线、设备、设备驱动器等等。让我们来考察一下它们。

设备

设备驱动程序模型中的每个设备是由一个device对象来描述的,其字段如表13-4所示。
在这里插入图片描述
在这里插入图片描述device对象全部收集在devices_subsys子系统中,该子系统对应的目录为/sys/devices (参见前面的“kobject”一节)。设备是按照层次关系组织的:一个设备是某个“孩子”的“父亲”,其条件为子设备离开父设备无法正常工作。例如,在基于PCI总线的计算机上,位于PCI总线和USB总线之间的桥就是连接在USB总线上的所有设备的父设备。device对象的parent字段是指向其父设备描述符的指针,children字段是子设备链表的首部,而node字段存放指向children链表中相邻元素的指针。device对象中内嵌的kobject间的亲子关系也反映了设备的层次关系;因此,/sys/devices下的目录结构与硬件设备的物理组织是匹配的。

每个设备驱动程序都保持一个device对象链表,其中链接了所有可被管理的设备;device对象的driver_list字段存放指向相邻对象的指针,而driver字段指向设备驱动程序的描述符。此外,对于任何总线类型来说,都有一个链表存放连接到该类型总线上的所有设备;device对象的bus_list字段存放指向相邻对象的指针,而bus字段指向总线类型描述符。

引用计数器记录device对象的使用情况,它包含在kobject类型的kobj结构中,通过调用get_device()和put_device()函数分别增加和减少该计数器的值。

device_register()函数的功能是往设备驱动程序模型中插入一个新的device对象,并自动地在/sys/devices目录下为其创建一个新的目录。相反地,device_unregister()函数的功能是从设备驱动程序模型中移走一个设备。通常,device对象被静态地嵌入到一个更大的描述符中。例如,PCI设备是由数据结构pci_dev描述;该数据结构的dev字段就是一个device对象,而其他字段则是PCI总线所特有的。在PCI内核层上,当注册或注销设备时就会分别执行device_register()函数和device_unregister()函数。

驱动程序

设备驱动程序模型中的每个驱动程序都可由device_driver对象描述,其各字段如表13-5所示。
在这里插入图片描述
device_driver对象包括四个方法,它们用于处理热插拔、即插即用和电源管理。当总线设备驱动程序发现一个可能由它处理的设备时就会调用probe方法;相应的函数将会探测该硬件,从而对该设备进行更进一步的检查。当移走一个可热插拔的设备时驱动程序会调用remove方法;而驱动程序本身被卸载时,它所处理的每个设备也会调用remove方法。当内核必须改变设备的供电状态时,设备会调用shutdown、suspend和resume三个方法。

内嵌在描述符中的kobject类型的kobj所包含的引用计数器用于记录device_driver对象的使用情况。通过调用get_driver()函数和put_driver()函数可分别增加和减少该计数器的值。driver_register()函数的功能是往设备驱动程序模型中插入一个新的device_driver 对象,并自动地在sysfs文件系统下为其创建一个新的目录。相反,driver_unregister()函数的功能则是从设备驱动程序模型中移走一个设备驱动对象。通常,device_driver对象静态地被嵌入到一个更大的描述符中。例如,PCI设备驱动程序是由数据结构pci_driver描述的;该数据结构的driver字段是一个device_driver 对象,而其他字段则是PCI总线所特有的。

总线

内核所支持的每一种总线类型都由一个bus_type对象描述,其各字段如表13-6所示。
在这里插入图片描述
在这里插入图片描述
每个bus_type类型的对象都包含一个内嵌的子系统;存放于bus_subsys变量中的子系统把嵌入在bus_type对象中的所有子系统都集合在一起。bus_subsys子系统与目录/sys/bus是对应的;因此,例如,有一个/sys/bus/pci目录,它与PCI总线类型相对应。每种总线的子系统通常包括两个kset,它们是drivers和devices(分别对应于bus_type 对象中的drivers和devices字段)。

名为drivers的kset包含描述符device_driver,它描述与该总线类型相关的所有设备驱动程序,而名为devices的kset包含描述符device,它描述给定总线类型上连接的所有设备。因为设备的kobject目录已经出现在/sys/devices下的sysfs文件系统中,所以每种总线子系统的devices目录存放了指向/sys/devices下目录的符号链接。bus_for_each_drv()和bus_for_each_dev()函数分别用于循环扫描drivers和devices 链表中的所有元素。当内核检查一个给定的设备否可以由给定的驱动程序处理时,就会执行match方法。对于连接设备的总线而言,即使其上每个设备的标识符都拥有一个特定的格式,实现match方法的函数通常也很简单,因为它只需要在所支持标识符的驱动程序表中搜索设备的描述符。在设备驱动程序模型中注册某个设备时会执行hotplug方法;实现函数应该通过环境变量把总线的具体信息传递给用户态程序,以通告一个新的可用设备(参见后面的“注册设备驱动程序”一节)。最后,当特定类型总线上的设备必须改变其供电状态时,就会执行suspend和resume方法。

每个类是由一个class对象描述的。所有的类对象都属于与/sys/class目录相对应的class_subsys子系统。此外,每个类对象还包括一个内嵌的子系统;因此,例如有一个/sys/class/input目录,它就与设备驱动程序模型的input类相对应。每个类对象包括一个class_device描述符链表,其中每个描述符描述了一个属于该类的单独逻辑设备。class_device结构中包含一个dev字段,它指向一个设备描述符,因此一个逻辑设备总是对应于设备驱动程序模型中的一个给定的设备。然而,可以存在多个class_device描述符对应同一个设备。事实上,一个硬件设备可能包括几个不同的子设备,每个子设备都需要一个不同的用户态接口。

例如,声卡就是一个硬件设备,它通常包括一个DSP(digital singnal processor,数字信号处理器)、一个混音器、一个游戏端口接口等等;每个子设备需要一个属于自己的用户态接口,因此sysfs.文件系统中都有与它们相对应的目录。同一类中的设备驱动程序可以对用户态应用程序提供相同的功能;

例如,声卡上的所有设备驱动程序都提供一个可以向DSP中写入声音样本的方法。设备驱动程序模型中的类本质上是要提供一个标准的方法,从而为向用户态应用程序导出逻辑设备的接口。每个class_device描述符中内嵌一个kobject,这是一个名为dev 的属性(特殊文件)。该属性存放设备文件的主设备号和次设备号,通过它们可以访问相应的逻辑设备(参见下一节)。

设备文件

正如在第一章中所提到的那样,类Unix操作系统都是基于文件概念的,文件是由字节序列而构成的信息载体。根据这一点,可以把I/O设备当作设备文件(device file)这种所谓的特殊文件来处理;因此,与磁盘上的普通文件进行交互所用的同一系统调用可直接用于I/O设备。

例如,用同一write()系统调用既可以向普通文件中写入数据,也可以通过向/dev/lp0设备文件中写入数据从而把数据发往打印机。根据设备驱动程序的基本特性,设备文件可以分为两种:块和字符。这两种硬件设备之间的差异并不容易划分,但我们至少可以假定以下的差异:

  1. 块设备的数据可以被随机访问,而且从人类用户的观点看,传送任何数据块所需的时间都是较少且大致相同的。块设备的典型例子是硬盘、软盘、CD-ROM驱动器及DVD播放器。
  2. 字符设备的数据或者不可以被随机访问(考虑声卡这样的例子),或者可以被随机访问,但是访问随机数据所需的时间很大程度上依赖于数据在设备内的位置(考虑磁带驱动器这样的例子)。

网卡是这种模式的一种明显的例外,因为网卡是不直接与设备文件相对应的硬件设备。自从Unix操作系统早期版本以来,设备文件就一直在使用。设备文件是存放在文件系统中的实际文件。然而,它的索引节点并不包含指向磁盘上数据块(文件的数据)的指针,因为它们是空的。相反,索引节点必须包含硬件设备的一个标识符,它对应字符或块设备文件。传统上,设备标识符由设备文件的类型(字符或块)和一对参数组成。第一个参数称为主设备号(major number),它标识了设备的类型。通常,具有相同主设备号和类型的所有设备文件共享相同的文件操作集合,因为它们是由同一个设备驱动程序处理的。第二个参数称为次设备号(minor number),它标识了主设备号相同的设备组中的一个特定设备。例如,由相同的磁盘控制器管理的一组磁盘具有相同的主设备号和不同的次设备号。

mknod()系统调用用来创建设备文件。其参数有设备文件名、设备类型、主设备号及次设备号。设备文件通常包含在/dev目录中。表13-7显示了一些设备文件的属性。注意字符设备和块设备有独立的编号,因此,块设备(3,0)不同于字符设备(3,0)。
在这里插入图片描述
设备文件通常与硬件设备(如硬盘/dev/hda),或硬件设备的某一物理或逻辑分区(如磁盘分区/dev/hda2)相对应。但在某些情况下,设备文件不会和任何实际的硬件对应,而是表示一个虚拟的逻辑设备。例如,/dev/null就是一个和“黑洞”对应的设备文件,所有写入这个文件的数据都被简单地丢弃,因此,该文件看起来总为空。
就内核所关心的内容而言,设备文件名是无关紧要的。如果你建立了一个名为/mp/disk 的设备文件,类型为“块”,主设备号是3,次设备号是0,那么这个设备文件就和表13-7 中的/dev)hda等价。另一方面,对某些应用程序来说,设备文件名可能就很有意义。例如,通信程序可能假设第一个串口和/dev/ttySO设备文件对应。但是,通常可以把大部分应用程序设定为随意地与指定的设备文件进行交互。

设备文件的用户态处理

传统的Unix系统中(以及Linux的早期版本中),设备文件的主设备号和次设备号都是8位长。因此,最多只能有65536个块设备文件和65536个字符设备文件。你可能认为这些已经足够了,但遗憾的是它们并不够用。真正的问题是设备文件被分配一次且永远保存在/dev目录中;因此,系统中的每个逻辑设备都应该有一个与其相对应的、明确定义了设备号的设备文件。Documentation/devices.txt文件存放了官方注册的已分配设备号和/dev目录节点;include /linux/major.h文件也可能包含设备的主设备号对应的宏。
不幸的是,如今各种不同的硬件设备数量惊人,几乎分配了所有的设备号。官方注册的设备号对于一般的Linux系统还能胜任;然而,它却不能很好地适用于大规模的系统。此外,高端系统可能使用数百或数千的同类型磁盘,因而8位的次设备号是远远不够的。
例如,注册表为16个SCSI磁盘保留了设备号,而每个SCS[磁盘拥有15个分区;如果一个高端系统拥有多于16个的SCSI磁盘,那么必须改变原先主设备号和次设备号的标准分配——这是一个非常繁琐的工作,它需要改变内核源代码并且使得系统难以维护。

为了解决上述问题,Linux 2.6已经增加了设备号的编码大小:目前主设备号的编码为12位,次设备号的编码为20位。通常把这两个参数合并成一个32位的dev_t变量MAJOR宏和MINOR宏可以从dev_t中分别提取主设备号和次设备号,而MKDEV宏可以把主设备号和次设备号合并成一个dev_t值。为了实现向后兼容,内核仍然可以正确地处理设备号编码为16位的老式设备文件。官方注册表不能静态地分配这些附加的可用设备号,只有在处理设备号的特殊要求时才允许使用。事实上,对分配设备号和创建设备文件来说,如今更倾向的做法是高度动态地处理设备文件。

动态分配设备号

每个设备驱动程序在注册阶段都会指定它将要处理的设备号范围(参见后面的“注册设备驱动程序”一节)。然而,驱动程序可以只指定设备号的分配范围,无需指定精确的值:在这种情形下,内核会分配一个合适的设备号范围给驱动程序。
因此,新的硬件设备驱动程序不再需要从官方注册表中分配的一个设备号;它们可以仅仅使用当前系统中空闲的设备号。然而,在这种情形下,就不能永久性地创建设备文件;它只在设备驱动程序初始化一个主设备号和次设备号时才创建。因此,这就需要有一个标准的方法将每个驱动程序所使用的设备号输出到用户态应用程序中。正如我们在前面“设备驱动程序模型的组件”一节所看到的,设备驱动程序模型提供了一个非常好的解决办法:把主设备号和次设备号存放在/sys/class子目录下的dev属性中。

动态创建设备文件

Linux内核可以动态地创建设备文件:它无需把每一个可能想到的硬件设备的设备文件都填充到/dev目录下,因为设备文件可以按照需要来创建。由于设备驱动程序模型的存在,Linux 2.6内核提供了一个非常简单的方法来处理这个问题。系统中必须安装一组称为udev工具集的用户态程序。当系统启动时,/dev目录是清空的,这时udev程序将扫描/sys/class子目录来寻找dev文件。对每一个这样的文件(主设备号和次设备号的组合表示一个内核所支持的逻辑设备文件),udev程序都会在/dev目录下为它创建一个相应的设备文件。udev程序也会根据配置文件为其分配一个文件名并创建一个符号链接,该方法类似于Unix设备文件的传统命名模式。最后,/dev目录里只存放了系统中内核所支持的所有设备的设备文件,而没有任何其他的文件。通常在系统初始化后才创建设备文件。它要么发生在加载设备驱动程序(系统尚未支持该设备)所在的模块时,要么发生在一个热拔插的设备(如USB外围设备)加入系统中时。

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

设备文件的VFS处理

虽然设备文件也在系统的目录树中,但是它们和普通文件以及目录文件有根本的不同。当进程访问普通文件时,它会通过文件系统访问磁盘分区中的一些数据块;而在进程访问设备文件时,它只要驱动硬件设备就可以了。

例如,进程可以访问一个设备文件以从连接到计算机的温度计读取房间的温度。为应用程序隐藏设备文件与普通文件之间的差异正是VFS的责任。为了做到这点,VFS在设备文件打开时改变其缺省文件操作;因此,可以把设备文件的每个系统调用都转换成与设备相关的函数的调用,而不是对主文件系统相应函数的调用。与设备相关的函数对硬件设备进行操作以完成进程所请求的操作(注4)。让我们假定进程在设备文件(块或字符类型)上执行open()系统调用。这个系统调用所执行的操作已经在第十二章“open()系统调用”一节进行了描述。从本质上说,相应的服务例程解析到设备文件的路径名,并建立相应的索引节点对象、目录项对象和文件对象。通过适当的文件系统函数(通常为ext2_read_inode()或ext3_read_inode();参见第十八章)读取磁盘上的相应索引节点来对索引节点对象进行初始化。

当这个函数确定磁盘索引节点与设备文件对应时,则调用init_special_inode(),该函数把索引节点对象的i_rdev字段初始化为设备文件的主设备号和次设备号,而把索引节点对象的i_fop字段设置为def_blk_fops或者def_chr_fops文件操作表的地址(根据设备文件的类型)。因此,open()系统调用的服务例程也调用dentry_open()函数,后者分配一个新的文件对象并把其f_op字段设置为i_fop中存放的地址,即再一次指向def_blk_fops或def_chr_fops的地址。正是这两个表的引入,才使得在设备文件上所发出的任何系统调用都将激活设备驱动程序的函数而不是基本文件系统的函数。

设备驱动程序

设备驱动程序是内核例程的集合,它使得硬件设备响应控制设备的编程接口,而该接口是一组规范的VFS函数集(open,read,lseek,ioctl等等)。这些函数的实际实现由设备驱动程序全权负责。由于每个设备都有一个唯一的I/O控制器,因此就有唯一的命令和唯一的状态信息,所以大部分IO设备都有自己的驱动程序。设备驱动程序的种类有很多。它们在对用户态应用程序提供支持的级别上有很大的不同,也对来自硬件设备的数据采集有不同的缓冲策略。这些选择极大地影响了设备驱动程序的内部结构,我们将在“直接内存访问(DMA)”和“字符设备的缓冲策略”两节进行讨论。设备驱动程序并不仅仅由实现设备文件操作的函数组成。在使用设备驱动程序之前,有几个活动是肯定要发生的。我们将在下面几节考察它们。

注册设备驱动程序

我们知道在设备文件上发出的每个系统调用都由内核转化为对相应设备驱动程序的对应函数的调用。为了完成这个操作,设备驱动程序必须注册自己。换句话说,注册一个设备驱动程序意味着分配一个新的device_driver描述符,将其插入到设备驱动程序模型的数据结构中(参见“设备驱动程序模型的组件”一节),并把它与对应的设备文件(可能是多个设备文件)连接起来。如果设备文件对应的驱动程序以前没有注册,则对该设备文件的访问会返回错误码-ENODEV。如果设备驱动程序被静态地编译进内核,则它的注册在内核初始化阶段进行。相反,如果驱动程序是作为一个内核模块来编译的(参见附录二),则它的注册在模块装入时进行。在后一种情况下,设备驱动程序也可以在模块卸载时注销自己。

例如,我们考虑一个通用的PCI设备。为了能正确地对其进行处理,其设备驱动程序必须分配一个pci_driver类型的描述符,PCI内核层使用该描述符来处理设备。初始化描述符的一些字段后,设备驱动程序就会调用pci_register_driver()函数。事实上,pci_driver描述符包括一个内嵌的device_driver描述符(参见前面的“设备驱动程序模型的组件”一节);pci_register_driver()函数仅仅初始化内嵌的驱动程序描述符中的字段,然后调用driver_register()函数把驱动程序插入设备驱动程序模型的数据结构中。

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

初始化设备驱动程序

对设备驱动程序进行注册和初始化是两件不同的事。设备驱动程序应当尽快被注册,以便用户态应用程序能通过相应的设备文件使用它。相反,设备驱动程序在最后可能的时刻才被初始化。事实上,初始化驱动程序意味着分配宝贵的系统资源,这些资源因此就对其他驱动程序不可用了。我们已经在第四章“I/O中断处理”一节看到一个例子:把IRQ分配给设备通常是自动进行的,这正好发生在使用设备之前,因为多个设备可能共享同一条IRQ线。其他可以在最后时刻被分配的资源是用于DMA传送缓冲区的页框和DMA通道本身(用于像软盘驱动器那样的老式非PCI设备)。

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

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

监控I/O操作

I/O操作的持续时间通常是不可预知的。这可能和机械装置的情况有关(对于要传送的数据块来说是磁头的当前位置),和实际的随机事件有关(数据包什么时候到达网卡),还和人为因素有关(用户在键盘上按下一个键或者发现打印机夹纸了)。在任何情况下,启动I/O操作的设备驱动程序都必须依靠一种监控技术在I/O操作终止或超时时发出信号。

在终止操作的情况下,设备驱动程序读取I/O接口状态寄存器的内容来确定I/O操作是否成功执行。在超时的情况下,驱动程序知道一定出了问题,因为完成操作所允许的最大时间间隔已经用完,但什么也没做。监控I/O操作结束的两种可用技术分别称为轮询模式(polling mode)和中断模式(interrupt mode)。

轮询模式

CPU依照这种技术重复检查(轮询)设备的状态寄存器,直到寄存器的值表明I/O操作已经完成为止。我们已经在第五章的“自旋锁”一节中提到一种基于轮询的技术:当处理器试图获得一个繁忙的自旋锁时,它就重复地查询变量的值,直到该值变成0为止。但是,应用到I/O操作中的轮询技术更加巧妙,这是因为驱动程序还必须记住检查可能的超时。下面是轮询的一个简单例子:

for (;;){
	if(read_status(device)& DEVICE_END_OPERATION)break;
	if(--count == 0}break;
}

在进入循环之前,count变量已被初始化,每次循环都对count的值减1,因此就可以使用这个变量实现一种粗略的超时机制。另外,更精确的超时机制可以通过这样的方法实现:在每次循环时读取节拍计数器jiffies的值(请参看第六章中的“更新时间和日期”一节),并将它与开始等待循环之前读取的原值进行比较。

如果完成I/O操作需要的时间相对较多,比如说毫秒级,那么这种模式就变得低效,因为CPU花费宝贵的机器周期去等待I/O操作的完成。在这种情况下,在每次轮询操作之后,可以通过把schedule()的调用插入到循环内部来自愿放弃CPU。

中断模式

如果I/O控制器能够通过IRQ线发出I/O操作结束的信号,那么中断模式才能被使用。我们现在通过一个简单的例子说明中断模式如何工作。假定我们想实现一个简单的输入字符设备的驱动程序。当用户在相应的设备文件上发出read()系统调用时,一条输入命令被发往设备的控制寄存器。在一个不可预知的长时间间隔后,设备把一个字节的数据放进输入寄存器。设备驱动程序然后将这个字节作为read()系统调用的结果返回。这是一个用中断模式实现驱动程序的典型例子。实质上,驱动程序包含两个函数:

  1. 实现文件对象read方法的foo_read()函数。
  2. 处理中断的foo_interrupt()函数。只要用户读设备文件,foo_read()函数就被触发:
ssize_t foo_read(struct file *filp,char *buf,size_t count, loff_t *ppos)
{
	foo_dev_t* foo_dev =filp->private_data;
	if(dowm_interruptible(&foo_dev->sem)
		return -ERESTARTSYS;
	foo_dev->intr = 0;
	outb(DEV_FOO_READ,DEV_F0O_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驱动程序都依赖中断处理程序及read和write方法均访问的数据结构。foo_dev_t描述符的地址通常存放在设备文件的文件对象的private_data字段中或一个全局变量中。foo_read()函数的主要操作如下:

  1. 获取foo_dev->sem信号量,因此确保没有其他进程访问该设备。
  2. 清intr标志。
  3. 对I/O设备发出读命令。
  4. 执行wait_event_interruptible以挂起进程,直到intr标志变为1。这个宏已在第三章“等待队列”一节描述过。一定时间后,我们的设备发出中断信号以通知I/O操作已经完成,数据已经放在适当的DEV_FOO_DATA_PORT数据端口。中断处理程序置intr标志并唤醒进程。当调度程序决定重新执行这个进程时,foo_read()的第二部分被执行,步骤如下:
  5. 把准备在foo_dev->data变量中的字符拷贝到用户地址空间。
  6. 释放foo_dev->sem信号量后终止。

为了简单起见,我们没有包含任何超时控制。一般来说,超时控制是通过静态或动态定时器实现的(参见第六章);定时器必须设置为启动I/O操作后正确的时间,并在操作结束时删除。让我们来看一下foo_interrupt()函数的代码:

void 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 l;	
}

中断处理程序从设备的输入寄存器中读字符,并把它存放在foo全局变量指向的驱动程序描述符foo_dev_t的data字段中。然后设置intr标志,并调用wake_up_interruptible()函数唤醒在foo->wait等待队列上阻塞的进程。注意,三个参数中没有一个被中断处理程序使用,这是相当普遍的情况。

访问I/O共享存储器

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

对于连接到ISA总线上的大多数设备
	I/O共享存储器通常被映射到0xa0000~0xfffff的16位物理地址范围;
	这就在640 KB和1MB之间留出了一段空间,就是我们在第二章的“物理内存布局”一节中所介绍的那个“空洞”。
对于连接到PCl总线上的设备
	I/O共享存储器被映射到接近4 GB的32位物理地址范围。这种类型的设备更加容易处理。

几年以前,Intel引入了图形加速端口(AGP)标准,该标准是适合于高性能图形卡的PCI 的增强。这种卡除了有自己的I/O共享存储器外,还能够通过图形地址再映像表(GART)这个特殊的硬件电路直接对主板的RAM部分进行寻址。GART电路能够使AGP卡比老式的PCI卡具有更高的数据传输速率。然而,从内核的观点看,物理存储器位于何处根本没有什么关系,GART映射的存储器与其他种类I/O共享存储器的处理方式完全一样。设备驱动程序如何访问一个I/O共享存储器单元?让我们从比较简单的PC体系结构开始入手,之后再扩展到其他体系结构。

不要忘了内核程序作用于线性地址,因此I/O共享存储器单元必须表示成大于PAGE_OFFSET的地址。在后面的讨论中,我们假设PAGE_OFFSET等于0xc0000000,也就是说,内核线性地址是在第4个GB。设备驱动程序必须把I/O共享存储器单元的物理地址转换成内核空间的线性地址。在PC 体系结构中,这可以简单地把32位的物理地址和0xc0000000常量进行或运算得到。

例如,假设内核需要把物理地址为0x000b0fe4的I/O单元的值存放在t1中,把物理地址为0xfc000000的I/O单元的值存放在t2中。你可能认为使用下面的表达式就可以完成这项工作:

t1=*((unsigned char *)(0xc00b0fe4));
t2 =*((unsigned char *)(0xfc000000));

在初始化阶段,内核已经把可用的RAM物理地址映射到线性地址空间第4个GB的开始部分。因此,分页单元把出现在第一个语句中的线性地址0xc00b0fe4映射回到原来的I/O物理地址0x000b0fe4,这正好落在从640KB到IMB的这段“ISA洞”中(请参看第二章的“Linux中的分页”一节)。这工作得很好。

但是,对于第二个语句来说,这里有一个问题,因为其I/O物理地址超过了系统RAM的最大物理地址。因此,线性地址0xfc000000就不需要与物理地址0xfc000000相对应。在这种情况下,为了在内核页表中包括对这个I/O物理地址进行映射的线性地址,必须对页表进行修改。这可以通过调用ioremap()或ioremap_nocache()函数来实现。第一个函数与vmalloc()函数类似,都调用get_vm_area()为所请求的I/O共享存储器区的大小建立一个新的vm_struct描述符(请参看第八章中的“非连续内存器区的描述符”一节)。然后,这两个函数适当地更新常规内核页表中的对应页表项。ioremap_nocache()不同于ioremap(),因为前者在适当地引用再映射的线性地址时还使硬件高速缓存内容失效。因此,第二个语句的正确形式应该为:

io_mem = ioremap(0xfb000000,0×200000);
t2 =*((unsigned char *)(io_mem +0x100000));

第一条语句建立一个2MB的新的线性地址区间,该区间映射了从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共享存储器区域

因此,对于0xfc000000I/O单元的访问推荐使用这样的方法:

io_mem = ioremap(0xfb000000,0×200000);
t2 = readb(io_mem + 0x100000);

正是由于这些函数,就可以隐藏不同平台访问I/O共享存储器所用方法的差异。

直接内存访问(DMA)

在最初的PC体系结构中,CPU是系统中唯一的总线主控器,也就是说,为了提取和存储RAM存储单元的值,CPU是唯一可以驱动地址/数据总线的硬件设备。随着更多诸如PCI这样的现代总线体系结构的出现,如果提供合适的电路,每一个外围设备都可以充当总线主控器。因此,现在所有的PC都包含一个辅助的DMA电路,它可以用来控制在RAM和I/O设备之间数据的传送。DMA一旦被CPU激活,就可以自行传送数据;当数据传送完成之后,DMA发出一个中断请求。当CPU和DMA同时访问同一内存单元时,所产生的冲突由一个名为内存仲裁器(参见第五章中的“原子操作”一节)的硬件电路来解决。使用DMA最多的是磁盘驱动器和其他需要一次传送大量字节的设备。因为DMA的设置时间相当长,所以在传送数量很少的数据时直接使用CPU效率更高。

原来的ISA总线所使用的DMA电路非常复杂,难于对其进行编程,并且限于物理内存的低16MB。PCI和SCSI总线所使用的最新DMA电路依靠总线中的专用硬件电路,这就简化了设备驱动程序开发人员的开发工作。

同步DMA和异步DMA

设备驱动程序可以采用两种方式使用DMA,分别是同步DMA和异步DMA。第一种方式,数据的传送是由进程触发的;而第二种方式,数据的传送是由硬件设备触发的。采用同步DMA传送的例子如声卡,它可以播放电影音乐。

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

采用异步DMA传送的例子如网卡,它可以从一个LAN中接收帧(数据包)。网卡将接收到的帧存储在自己的I/O共享存储器中,然后引发一个中断。其驱动程序确认该中断后,命令网卡将接收到的帧从I/O共享存储器拷贝到内核缓冲区。当数据传送完成后,网卡会引发新的中断,然后驱动程序将这个新帧通知给上层内核层。

DMA传送的辅助函数

当为使用DMA传送方式的设备设计驱动程序时,开发者编写的代码应该与体系结构和总线(就DMA传送方式来说)二者都不相关。由于内核提供了丰富的DMA辅助函数,因而现在上述目标是可以实现的。这些辅助函数隐藏了不同硬件体系结构的DMA实现机制的差异。

这是DMA辅助函数的两个子集:
老式的子集为PCI设备提供了与体系结构无关的函数;新的子集则保证了与总线和体系结构两者都无关。我们现在将介绍其中的一些函数,同时指出DMA的一些硬件特性。

总线地址

DMA的每次数据传送(至少)需要一个内存缓冲区,它包含硬件设备要读出或写入的数据。一般而言,启动一次数据传送前,设备驱动程序必须确保DMA电路可以直接访问RAM内存单元。到现在为止,我们已区分了三类存储器地址:逻辑地址、线性地址以及物理地址,前两个在CPU内部使用,最后一个是CPU从物理上驱动数据总线所用的存储器地址。但是,还有第四种存储器地址,称为总线地址(bus address),它是除CPU之外的硬件设备驱动数据总线时所用的存储器地址。

从根本上说,内核为什么应该关心总线地址呢?
这是因为在DMA操作中,数据传送不需要CPU的参与;I/O设备和DMA电路直接驱动数据总线。因此,当内核开始DMA操作时,必须把所涉及的内存缓冲区总线地址或写入DMA适当的I/O端口,或写入I/O设备适当的I/O端口。

在80x86体系结构中,总线地址与物理地址是一致的。然而,其他的体系结构例如Sun 公司的SPARC和HP的Alpha都包括一个所谓的I/O存储器管理单元(IO-MMU)的硬件电路,它类似于微处理器的分页单元,将物理地址映射为总线地址。使用DMA的所有I/O驱动程序在启动一次数据传送前必须设置好IO-MMU。

不同的总线具有不同的总线地址大小。例如,ISA的总线地址是24位长,因此,在80x86 体系结构中,可以在物理内存的低16 MB中完成DMA传送——这就是为什么DMA 使用的内存缓冲区分配在ZONE_DMA内存区中(设置了GFP_DMA标志)。

原来的PCI标准定义了32位的总线地址;但是,一些PCI硬件设备最初是为ISA总线而设计的,因此它们仍然访问不了物理地址0x00ffffff以上的RAM内存单元。新的PCI-X标准采用64位的总线地址并允许DMA电路可以直接寻址更高的内存。

在Linux中,数据类型dma_addr_t代表一个通用的总线地址。在80x86体系结构中,dma_addr_t对应一个32位长的整数,除非内核支持PAE [参见第二章的“物理地址扩展(PAE)分页机制”一节],在这种情形下,dma_addr_t代表一个64位的整数。
pci_set_dma_mask()和dma_set_mask()两个辅助函数用于检查总线是否可以接收给定大小的总线地址(mask),如果可以,则通知总线层给定的外围设备将使用该大小的总线地址。

高速缓存的一致性

系统体系结构没有必要在硬件级为硬件高速缓存与DMA电路之间提供一个一致性协议,因此,执行DMA映射操作时,DMA辅助函数必须考虑硬件高速缓存。为了弄清楚这是为什么,假设设备驱动程序把一些数据填充到内存缓冲区中,然后立刻命令硬件设备利用DMA传送方式读取该数据。如果DMA访问这些物理RAM内存单元,而相应的硬件高速缓存行的内容还没有写入RAM中,那么硬件设备所读取的值就是内存缓冲区中的旧值。

设备驱动程序开发人员可以采用两种方法来处理DMA缓冲区,他们分别使用两类不同的辅助函数来完成。用Linux的术语来说,开发人员在下面两种DMA映射类型中进行选择:

一致性DMA映射
	使用这种映射方式时,内核必须保证内存与硬件设备间高速缓存一致性不是什么问题;
	也就是说CPU在RAM内存单元上所执行的每个写操作对硬件设备而言都是立即可见的,反过来也一样。这种映射方式也称为“同步的”或“一致的”。
流式DMA映射
	使用这种映射方式时,设备驱动程序必须了解高速缓存一致性问题,这可以使用适当的同步辅助函数来解决。这种映射方式也称为“异步的”或“非一致性的”。

在80×86体系结构中使用DMA时,从不存在高速缓存一致性根本不是什么问题,因为硬件设备驱动程序本身会“窥探”所访问的硬件高速缓存。因此,80×86体系结构中为硬件设备所设计的驱动程序会从前述的两种DMA映射方式中选择一个:它们二者在本质上是等价的。另一方面,在诸如MIPS、SPARC以及PowerPC的一些模型等许多其他的体系结构中,硬件设备通常不窥探硬件高速缓存,因而就会产生高速缓存一致性问题。总的来说,为与体系结构无关的驱动程序选择一个合适的DMA映射方式是很重要的。

一般来说,如果CPU和DMA处理器以不可预知的方式去访问一个缓冲区,那么必须强制使用一致性DMA映射方式(例如,SCSI适配器的command数据结构的缓冲区)。其他情形下,流式DMA映射方式更可取,因为在一些体系结构中处理一致性DMA映射是很麻烦的,并且可能导致更低的系统性能。

一致性DMA映射的辅助函数

通常,设备驱动程序在初始化阶段会分配内存缓冲区并建立一致性DMA映射;在卸载时释放映射和缓冲区。为了分配内存缓冲区和建立一致性DMA映射,内核提供了依赖体系结构的pci_alloc_consistent()和dma_alloc_coherent()两个函数。它们均返回新缓冲区的线性地址和总线地址。在80x86体系结构中,它们返回新缓冲区的线性地址和物理地址。为了释放映射和缓冲区,内核提供了pci_free_consistent()和dma_free_coherent()两个函数。

流式DMA映射的辅助函数

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

为了启动一次流式DMA数据传送,驱动程序必须首先利用分区页框分配器(参见第八章的“分区页框分配器”一节)或通用内存分配器(参见第八章的“通用对象”一节)来动态地分配内存缓冲区。然后,驱动程序调用pci_map_single()函数或者dma_map_single()函数建立流式DMA映射,这两个函数接收缓冲区的线性地址作为其参数并返回相应的总线地址。为了释放该映射,驱动程序调用相应的pci_urmap_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()函数使相应的硬件高速缓存行无效。

在80x86体系结构中,上述函数几乎不做任何事情,因为硬件高速缓存和DMA之间的一致性是由硬件来维护的。即使是高端内存的缓冲区(参见第八章的“高端内存页框的内核映射”一节)也可以用于DMA传送;开发人员使用pci_map_page()或dma_map_page()函数,给其传递的参数为缓冲区所在页的描述符地址和页中缓冲区的偏移地址。相应地,为了释放高端内存缓冲区的映射,开发人员使用pci_unmap_page()或dma_unmap_page()函数。

内核支持的级别

Linux内核并不完全支持所有可能存在的I/O设备。一般来说,事实上有三种可能的方式支持硬件设备:

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

第一种方式与内核设备驱动程序毫无关系,最常见的例子是X Window系统对图形显示的传统处理方式。这种方法效率很高,尽管它限制了X服务器使用I/O设备产生的硬件中断。为了让X服务器访问所请求的I/O端口,这种方法还需要做一些其他努力。

正如第三章的“任务状态段”一节中所介绍的那样,iopl()和ioperm()系统调用给进程授权访问I/O端口。只有具有root权限的用户才可以调用这两个系统调用。但是通过设置可执行文件的setuid标志,普通用户也可以使用这些程序(参见第二十章中的“进程的信任状和权能”一节)。

新近的Linux版本支持几种广泛使用的图形卡。/dev/fb设备文件为图形卡的帧缓冲区提供了一种抽象,并允许应用软件无需知道图形接口的I/O端口的任何事情就可以访问它。此外,内核提供了直接绘制基本架构(Direct Rendering Infrastructure,DRI),DRI允许应用软件充分挖掘3D加速图形卡的硬件特性。不管怎样,传统的“自己动手配置”X Window系统服务器还依然被广泛采用。

最小支持方法是用来处理连接到通用I/O接口上的外部硬件设备的。内核通过提供设备文件(由此而提供一个设备驱动程序)来处理I/O接口;应用程序通过读写设备文件来处理外部硬件设备。最小支持优于扩展支持,因为它保持内核尽可能小。但是,在基于PC的通用I/O接口之中,只有串口和并口的处理使用了这种方法。因此,诸如X服务器之类的应用程序可以直接控制串口鼠标,而串口调制解调器通常都需要一个诸如Minicom、Seyon或PPP(点对点协议)守护进程之类的通信程序。

最小支持的应用范围是有限的,因为当外部设备必须频繁地与内核内部数据结构进行交互时不能使用这种方法。例如,考虑一个连到通用I/O接口上的可移动硬盘。应用程序不能和所有的内核数据结构进程交互,也不能与识别磁盘所需要的函数和装载文件系统所需要的函数进行交互,因此,这种情况下就必须使用扩展支持。一般情况下,直接连接到I/O总线上的任何硬件设备(如内置硬盘)都要根据扩展支持方法进行处理:内核必须为每个这样的设备提供一个设备驱动程序。通用串行总线(USB)、笔记本电脑上的PCMCIA接口或者SCSI接口——简而言之,除串口和并口之外的所有通用I/O接口之上连接的外部设备都需要扩展支持。

值得注意的是,与标准文件相关的系统调用,如open()、read()和write(),并不总让应用程序完全控制底层硬件设备。事实上,VFS的“最小公分母(lowest-common-denominator)”方法没有包含某些设备所需的特殊命令,或不让应用程序检查设备是否处于某一特殊的内部状态。已引入的ioct1()系统调用可以满足这样的需要。这个系统调用除了设备文件的文件描述符和另一个表示请求的32位参数之外,还可以接收任意多个额外的参数。例如,特殊的ioct1()请求可以用来获得CD-ROM的音量或者弹出CD-ROM介质。应用程序可以用这类ioctl()请求提供一个CD播放器的用户接口。

字符设备驱动程序

处理字符设备相对比较容易,因为通常并不需要复杂的缓冲策略,也不涉及磁盘高速缓存。当然,字符设备在它们的需求方面有所不同:有些必须实现复杂的通信协议以驱动硬件设备,而有些仅仅需要从硬件设备的一对I/O端口读几个值。例如,多端口串口卡设备(一个硬件设备提供多个串口)的驱动程序比总线鼠标的设备驱动程序要复杂得多。另一方面,块设备驱动程序本身就比字符设备驱动程序复杂得多。

事实上,应用程序可以反复地要求读或写同一个数据块。此外,访问这些设备通常是很慢的。这些特性对磁盘驱动程序的结构产生了深刻的影响。然而,就如我们将在下一章看到的,内核提供了诸如页面高速缓存和块I/O子系统这些高级组件去处理驱动程序。在本章剩下的部分中我们把注意力集中于字符设备驱动程序。字符设备驱动程序是由一个cdev结构描述的,其字段如表13-8所示。
在这里插入图片描述
list字段是双向循环链表的首部,该链表用于收集相同字符设备驱动程序所对应的字符设备文件的索引节点。可能很多设备文件具有相同的设备号,并对应于相同的字符设备。此外,一个设备驱动程序对应的设备号可以是一个范围,而不仅仅是一个号;设备号位于同一范围内的所有设备文件均由同一个字符设备驱动程序处理。设备号范围的大小存放在count字段中。

cdev_alloc()函数的功能是动态地分配cdev描述符,并初始化内嵌的kobject数据结构,因此在引用计数器的值变为0时会自动释放该描述符。cdev_add()函数的功能是在设备驱动程序模型中注册一个cdev描述符。它初始化cdev 描述符中的dev和count字段,然后调用kobj_map()函数。kobj_map()则依次建立设备驱动程序模型的数据结构,把设备号范围复制到设备驱动程序的描述符中。

设备驱动程序模型为字符设备定义了一个kobject映射域,该映射域由一个kobj_map类型的描述符描述,并由全局变量cdev_map引用。kobj_map描述符包括一个散列表,它有255个表项,并由0~255范围的主设备号进行索引。散列表存放probe类型的对象,每个对象都拥有一个已注册的主设备号和次设备号,其中各字段如表13-9所示。
在这里插入图片描述
调用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结构,其各字段如表13-10所示。
在这里插入图片描述
在这里插入图片描述
本质上可以采用两种方法为字符设备驱动程序分配一个范围内的设备号。所有新的设备驱动程序使用第一种方法,该方法使用register_chrdev_region()函数和alloc_chrdev_region()函数为驱动程序分配任意范围内的设备号。例如,为了获得从dev(类型为dev_t)开始的大小为size的一个设备号范围:

register_chrdev_region(dev,size,"foo");

上述函数并不执行cdev_add(),因此设备驱动程序在所要求的设备号范围被成功分配时必须执行cdev_add()函数。第二种方法使用register_chrdev()函数,它分配一个固定的设备号范围,该范围包含唯一一个主设备号以及0~255的次设备号。在这种情形下,设备驱动程序不必调用cdev_add()函数。

register_chrdev_region()函数和alloc_chrdev_region()函数

register_chrdev_region()函数接收三个参数:初始的设备号(主设备号和次设备号)、请求的设备号范围大小(与次设备号的大小一样)以及这个范围内的设备号对应的设备驱动程序的名称。该函数检查请求的设备号范围是否跨越一些次设备号,如果是,则确定其主设备号以及覆盖整个区间的相应设备号范围;然后,在每个相应设备号范围上调用__register_chrdev_region()函数(参见下文)。

alloc_chrdev_region()函数与register_chrdev_region()相似,但它可以动态地分配一个主设备号;因此,该函数接收的参数为设备号范围内的初始次设备号、范围的大小以及设备驱动程序的名称。结束时它也调用__register_chrdev_region()函数。__register_chrdev_region()函数执行以下步骤:

  1. 分配一个新的char_device_struct结构,并用0填充。
  2. 如果设备号范围内的主设备号为0,那么设备驱动程序请求动态分配一个主设备号。函数从散列表的末尾表项开始继续向后寻找一个与尚未使用的主设备号对应的空冲突链表(NULL指针)。若没有找到空表项,则返回一个错误码(注6)。
  3. 初始化char_device_struct结构中的初始设备号、范围大小以及设备驱动程序名称。
  4. 执行散列函数计算与主设备号对应的散列表索引。
  5. 遍历冲突链表,为新的char_device_struct结构寻找正确的位置。同时,如果找到与请求的设备号范围重叠的一个范围,则返回一个错误码。
  6. 将新的char_device_struct描述符插入冲突链表中。
  7. 返回新的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类型的描述符(参见前面的“kobject”一节)。
    b. 将owner字段设置为fops->owner的内容。
    c. 将ops字段设置为文件操作表的地址fops。
    d. 将设备驱动程序的名称拷贝到内嵌的kobject结构里的name字段中。
  4. 调用cdev_add()函数(在前面解释过)。
  5. 将__register_chrdev_region()函数在第1步中返回的char_device_struct描述符的cdev字段设置为设备驱动程序的cdev描述符的地址。
  6. 返回分配的设备号范围的主设备号。

访问字符设备驱动程序

我们在“设备文件的VFS处理”一节中曾提到,由open()系统调用服务例程触发的dentry_open()函数定制字符设备文件的文件对象的f_op字段,以使它指向def_chr_fops表。这个表几乎为空;它仅仅定义了chrdev_open()函数作为设备文件的打开方法。这个方法由dentry_open()直接调用。chrdev_open()函数接收的参数为索引节点的地址inode、指向所打开文件对象的指针filp。本质上它执行以下操作:

  1. 检查指向设备驱动程序的cdev描述符的指针inode->i_cdev。如果该字段不为空,则inode结构已经被访问:增加cdev描述符的引用计数器值并跳转到第6步。
  2. 调用kobj_lookup()函数搜索包括该设备号在内的范围。如果该范围不存在,则返回一个错误码;否则,函数计算与该范围相对应的cdev描述符的地址。
  3. 将inode对象的inode->i_cdev字段设置为cdev描述符的地址。
  4. 将inode->i_cindex字段设置为设备驱动程序的设备号范围内的设备号的相关索引(设备号范围内的第一个次设备号的索引值为0,第二个为1,依此类推)。
  5. 将inode对象加入到由cdev描述符的list字段所指向的链表中。
  6. 将filp->f_ops文件操作指针初始化为cdev描述符的ops字段的值。
  7. 如果定义了filp->f_ops->open方法,chrdev_open()函数就会执行该方法。若设备驱动程序处理一个以上的设备号,则chrdev_open()一般会再次设置file对象的文件操作,这样可以为所访问的设备文件安装合适的文件操作。
  8. 成功时返回0结束。

字符设备的缓冲策略

传统的类Unix操作系统把硬件设备划分为块设备和字符设备。但是,这种分类并不能说明整个事实。某些设备在一次单独的I/O操作中能够传送大量的数据,而有些设备则只能传送几个字符。

例如,PS/2鼠标驱动程序在每次读操作中获得几个字节——它们对应鼠标按钮的状态和屏幕上鼠标的指针。这种设备是最容易处理的。首先从设备的输入寄存器中一次读一个字符的输入数据,并存放在合适的内核数据结构中;然后,在空闲时把这个数据拷贝到进程的地址空间。同理,把输出数据首先从进程的地址空间拷贝到合适的内核数据结构中,然后,再一次一个字符地写到I/O设备的输出寄存器。显然,这种设备的I/O驱动程序没有使用DMA,因为CPU建立DMA I/O操作所花费的时间跟把数据移到I/O 端口所花费的时间差不多。

另一方面,内核也必须准备处理在每次I/O操作中产生大量字节的设备,这些设备或者是诸如声卡或网卡的顺序设备,或者是诸如各类磁盘(软盘、光盘、SCS1磁盘等)的随机访问设备。例如,假定你已经为自己的计算机配置了声卡,以便能够录下来自麦克风的声音。声卡以固定的频率(比如说44.14kHz)对来自麦克风的电信号进行采样,并产生一个16位数的输入数据块的流。声卡驱动程序必须能处理所有可能情况下这种蜂拥而至的数据,即使当CPU暂时忙于运行某个其他进程也不例外。

这可以结合两种不同的技术做到:

  1. 使用DMA方式传送数据块。
  2. 运用两个或多个元素的循环缓冲区,每个元素具有一个数据块的大小。
    当一个中断(发送一个信号表明新的数据块已被读入)发生时,中断处理程序把指针移到循环缓冲区的下一个元素,以便将来的数据会存放在一个空元素中。相反,只要驱动程序把数据成功地拷贝到用户地址空间,就释放循环缓冲区中的元素,以便用它来保存从硬件设备传送来的新数据。循环缓冲区的作用是消除CPU负载的峰值;即使接收数据的用户态应用程序因为其他高优先级任务而慢下来,DMA也要能够继续填充循环缓冲区中的元素,因为中断处理程序代表当前运行的进程执行。

当接收来自网卡的数据包时有类似的情况发生,只是在这种情况下,进入的数据流都是异步的。数据包被互相独立地接收,且两个连续的数据包之间到达的时间间隔是不可预测的。总而言之,顺序设备的缓冲区是容易处理的,因为同一缓冲区从不会被重用:音频应用程序不能要求麦克风重新传送同一数据块。我们将在第十五章中看到对随机访问设备(各种各样的磁盘)进行缓冲是相当复杂的。

猜你喜欢

转载自blog.csdn.net/x13262608581/article/details/132289113
今日推荐