I/O体系结构和设备驱动程序(四)

来源:互联网 发布:美图字体软件 编辑:程序博客网 时间:2024/05/16 06:25

3、设备文件

类Unix系统都是基于文件概念的,可以把I/O设备当作设备文件这种特殊文件来处理,这样,与磁盘上的普通文件进行交互所用的同一系统调用可直接用于I/O设备。

 

根据设备驱动程序的基本特性,设备文件可以分为以下几种:

1、块设备的数据可以被随机访问,而且从用户观点看,传送任何数据块所需的时间都是较少且大致相同的。

2、字符设备的数据或者不可以随机访问,或者可以被随机访问,但是访问随机数据所需的时间很大程度上依赖于数据在设备内的位置(例如,磁带驱动器)

3、网络设备,网络设备没有对应的设备文件

 

设备文件是存放在文件系统中的实际文件,然而,它的索引节点并不包含指向磁盘上数据块(文件的数据)的指针,因为它们是空的。相反,索引节点必须包含硬件设备的一个标识符,它对应字符或块设备文件。


传统上,设备标识符由设备文件的类型(字符或块)和一对参数组成。第一个参数称为主设备号(major number),它标识了设备的类型。通常,具有相同主设备号和类型的所有设备文件共享相同的文件操作集合,因为它们是由同一个设备驱动程序处理的。第二个参数成为次设备号(minor number),它标识了主设备号相同的设备组中的一个特定设备。

 

mknod()系统调用用来创建设备文件,其参数有设备文件名、设备类型、主设备好及次设备号,设备文件通常包含在/dev目录中。

 

设备文件通常与硬件设备(如硬盘/dev/hda),或硬件设备的某一物理或逻辑分区(如磁盘分区/dev/hda2)相对应。在某些情况下,设备文件不会和任何实际的硬件对应,而是表示一个虚拟的逻辑设备,例如/dev/null就是一个和“黑洞”对应的设备文件。

 

就内核所关心的内容而言,设备文件名无关紧要,内核仅关心设备类型、主设备号和次设备号。而应用程序常使用设备文件名。


3.1、设备文件的用户态处理

传统的Unix系统中(以及Linux的早期版本中),设备文件的主设备号和次设备号都只有8位长,在高端系统中并不够用,例如大型集群系统中需要大量的SCSI盘,每个SCSI盘上有15个分区的情况

 

真正的问题是设备文件被分配一次且永远保存在/dev目录中:因此,系统中每个逻辑设备都应该有一个与其相对应的、明确定义了设备号的设备文件。Documentation/devices.txt文件存放了官方注册的已分配设备号和/dev目录节点;include/linux/major.h文件也可能包含设备的主设备号对应的宏。但由于硬件设备数量惊人,官方注册的设备号对一般linux系统还能胜任,但不能很好的适用于大规模系统,例如上面的大型存储集群系统,如果拥有多于16个的SCSI磁盘,那么必须改变原先主设备号和次设备号的标准分配,需要改变内核源码并使系统难以维护。

 

为解决上述文件,Linux2.6已增加设备号的编码大小:目前主设备号的编码为12位,次设备号的编码为20位。通常把这两个参数合并成一个32位的dev_t变量。使用的宏有:MAJOR()、MINOR()和MKDEV()宏



l  在内核中,比特范围0-19用于从设备号,20-31用于主设备号

l  当需要在外部空间(用户空间)表示dev_t时,则将0-7用作从设备号的第一部分,8-19作为主设备号,最后的20-31作为从设备号的剩余部分

l  如果在代码中坚持使用在dev_t和外部表示之间进行转换的函数,那么即使将来内部数据类型再次发生变化,代码也无需变动。

常用的宏有:

<kdev.h>

 

 

官方注册表不能静态地分配这些附加的可用设备号,只有在处理设备号的特殊要求时才允许使用。事实上,对分配设备号和创建设备文件来说,如今更倾向的做法是:高度动态地处理设备文件。


3.2、动态分配设备号

每个设备驱动程序在注册阶段都会指定它将要处理的设备号范围,驱动程序可以只指定设备号的分配范围,无需指定精确值,在这种情况下,内核会分配一个合适的设备号范围给驱动程序。因此,新的硬件设备驱动程序不再需要从官方注册表中分配的一个设备号;它们可以仅仅使用当前系统中空闲的设备号。

 

然而这种情形下,就不能永久的创建设备文件,它只在设备驱动程序初始化一个主设备号和次设备号时才创建。因此,这就需要一个标准的方法将每个驱动程序所使用的设备号输出到用户态应用程序中,为此,设备驱动程序模型提供了一个非常好的解决办法:把主设备号和次设备号存放在/sys/class子目录下的dev属性中。


3.3、动态创建设备文件

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

 

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

3.4、设备文件的VFS处理

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

 

为了做到这点,VFS在设备文件打开时改变其缺省文件操作;因此,可把设备文件的每个系统调用都转换成与设备相关的函数的调用,而不是对主文件系统相应函数的调用。与设备相关的函数对硬件设备进行操作以完成进程所请求的操作。(注意:在路径名查找中,指向设备文件的符号链接与设备文件的作用相同)。

 

假定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的地址。正是这两个表的引入,才使得在设备文件上所发出的任何系统调用都将激活设备驱动程序的函数而不是基本文件系统的函数。

4、设备驱动程序

设备驱动程序是内核例程的集合,它使得硬件设备响应控制设备的编程接口,而该接口是一组规范的VFS函数集(open、read、lseek、ioctl等等)。这些函数的实际实现由设备驱动程序全权负责。由于每个设备都有一个唯一的I/O控制器,因此就有唯一的命令和唯一的状态信息,所以大部分I/O设备都有自己的驱动程序。

 

在使用设备驱动程序之前,有几个活动是肯定要发生的。

4.1、注册设备驱动程序

在设备文件上发出的每个系统调用都由内核转化为相应设备驱动程序对应函数的调用,为完成这个操作,设备驱动程序必须注册自己,即分配一个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()函数把设备插入到设备驱动程序模型中。

4.2、初始化设备驱动程序

对设备驱动程序进行注册和初始化是两件不同的事。设备驱动程序应当尽快被注册,以便用户态应用程序能通过相应的设备文件使用它。相反,设备驱动程序在最后可能的时刻才被初始化。事实上,初始化驱动程序意味着分配宝贵的系统资源,这些资源因此对其他驱动程序不可用了。

 

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

1、引用计数器记录当前访问设备文件的进程数。在设备文件的open方法中计数器被增加,在release方法中被减少(更确切的说,引用计数器记录引用设备文件的文件对象的个数,因为子进程可能共享文件对象)。

2、open()方法在增加引用计数器的值之前应先检查它,如果计数器为0,则设备驱动必须分配资源并激活硬件设备上的中断和DMA。

3、release方法在减少使用计数器的值之后检查它,如果计数器为0,说明已经没有进程使用这个硬件设备。如果是这样,该方法将禁止I/O控制器上的中断和DMA,然后释放所分配的资源。

4.3、监控I/O操作

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

 

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

监控I/O操作结束的两种可用技术:轮询模式(polling mode)和中断模式(interrupt mode)。

4.3.1、轮询模式

CPU轮询设备的状态寄存器,直到寄存器的值表明I/O操作已经完成为止。I/O轮询技术比较巧妙,因为驱动程序还必须记住检查可能的超时。记录超时的方法:

1、counter计数

2、在每次循环时读取节拍计数器jiffies的值,并将它与开始等待循环之前读取的原值进行比较

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

4.3.2、中断模式

如果I/O控制器能够通过IRQ线发出I/O操作结束的信号,那么中断模式才能被使用。举例如下:

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

 

实质上,驱动程序包含两个函数:

1、实现文件对象read方法的foo_read()函数;

2、处理中断的foo_interrupt()函数;

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




设备驱动程序依赖类型为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()的第二部分被执行,步骤如下:

1. 把准备在foo_dev->data变量中的字符拷贝到用户地址空间;

2. 释放foo_dev->sem信号量

 

实际设备驱动会使用超时控制,一般来说,超时控制是通过静态或动态定时器实现的;定时器必须设置为启动I/O操作后正确的时间,并在操作结束时删除。

函数foo_interrupt():



注意:三个参数中没有一个被中断处理程序使用,这是相当普遍的情况。

4.4、访问I/O共享存储器

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

 

对于连接到ISA总线上的大多数设备

I/O共享存储器通常被映射到0xa0000~0xfffff的16位物理地址范围;这就在640KB和1MB之间留出了一段空间。即物理内存布局中的“空洞”

 

对于连接到PCI总线上的设备

I/O共享存储器被映射到接近4GB的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中:



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

 

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

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


第一条语句建立一个2MB的新的线性地址区间,该区间映射了从0xfb000000开始的物理地址;第二条语句读取地址为0xfc000000的内存单元。设备驱动程序以后要取消这种映射,就必须要使用iounmap()函数。

 

在其他体系结构上,简单地间接引用物理内存单元的线性地址并不能正确访问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, 0x200000);

    t2 = readb(io_mem + 0x100000);

 

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

0 0
原创粉丝点击