设备驱动程序是一组内核例程的集合,它使得硬件设备响应控制设备的编程接口,最关键的是该接口是一组规范的VFS函数集(open, read, lseek, ioctl等等)。这些函数的实际实现由设备驱动程序全权负责。由于每个设备都有一个唯一的I/O控制器,因此就有唯一的命令和唯一的状态信息,所以大部分I/O设备都有自己的驱动程序。
设备驱动程序的种类有很多。它们在对用户态应用程序提供支持的级别上有很大的不同,也对来自硬件设备的数据采集有不同的缓冲策略。这些选择极大地影响了设备驱动程序的内部结构,我们将在本节中重点进行分析。
设备驱动程序并不仅仅由实现设备文件操作的函数组成。在使用设备驱动程序之前,有几个活动是肯定要发生的。
1.1 设备驱动程序的注册
前面提到,我们对设备文件发出的每个系统调用都由内核转化为对相应设备驱动程序的对应函数的调用。为了完成这个操作,设备驱动程序必须注册自己。换句话说,注册一个设备驱动程序意味着分配一个新的device_driver描述符,将其插入到设备驱动程序模型的数据结构中(也就是以kobject的形式表现在sysfs体系中),并把它与对应的设备文件(可能是多个设备文件)连接起来。如果设备文件对应的驱动程序以前没有注册,则对该设备文件的访问会返回错误码-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()函数把设备插入设备驱动程序模型中。
1.2 初始化设备驱动程序
注意,对设备驱动程序进行注册和初始化是两回事。设备驱动程序应当尽快被注册,以便用户态应用程序能通过相应的设备文件使用它。相反,设备驱动程序在最后可能的时刻才被初始化。事实上,初始化驱动程序意味着分配宝贵的系统资源,那么这些资源因此就对其他驱动程序不可用了。
例如IRQ资源:把IRQ分配给设备通常是自动进行的,这正好发生在使用设备之前,因为多个设备可能共享同一条IRQ线。还有一些可以在最后时刻被分配的资源如内存中用于DMA传送缓冲区的页框和DMA通道本身(用于像软盘驱动器那样的老式非PCI设备)。
为了确保资源在需要时能够获得,在获得后不再被其他驱动程序请求,设备驱动程序通常采用下列模式进行初始化和释放:
1. 引用计数器记录当前访问设备文件的进程数。在设备文件的open方法中计数器被增加,在release方法中被减少。
2. open方法在增加引用计数器的值之前先检查它。如果计数器为0,则设备驱动程序必须分配资源并激活硬件设备上的中断和DMA。
3. release方法在减少使用计数器的值之后检查它。如果计数器为0,说明已经没有进程使用这个硬件设备。如果是这样,该方法将禁止I/O控制器上的中断和DMA,然后释放所分配的资源。
1.3 监控I/O操作
对I/O操作的监控是一项非常重要的工作,因为I/O操作的持续时间通常是不可预知的。这可能和机械装置的情况有关(对于要传送的数据块来说是磁头的当前位置),和实际的随机事件有关(数据包什么时候到达网卡),还和人为因素有关(用户在键盘上按下一个键或者发现打印机夹纸了)。在任何情况下,启动I/O操作的设备驱动程序都必须依靠一种监控技术在I/O操作终止或超时时发出信号。
在终止操作的情况下,设备驱动程序读取I/O接口状态寄存器的内容来确定I/O操作是否成功执行。在超时的情况下,驱动程序知道一定出了问题,因为完成操作所允许的最大时间间隔已经用完,但什么也没做。
监控I/O操作结束的两种可用技术分别称为轮询模式(polling mode)和中断模式(interrupt mode)。
1.3.1 轮询模式
CPU依照这种技术重复检查(轮询)设备的状态寄存器,直到寄存器的值表明I/O操作已经完成为止。Linux的“自旋锁”机制就是一种基于轮询的技术:当处理器试图获得一个繁忙的自旋锁时,它就重复地查询变量的值,直到该值变成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。
1.3.2中断模式
使用中断模式有个前提,那就是I/O控制器必须能够通过IRQ线发出I/O操作结束的信号,这样,中断模式才能被使用。
我们现在通过一个简单的例子说明中断模式如何工作。假定我们想实现一个简单的输入字符设备的驱动程序。当用户在相应的设备文件上发出read()系统调用时,一条输入命令被发往设备的控制寄存器。在一个不可预知的长时间间隔后,设备把一个字节的数据放进输入寄存器,并通过I/O控制器发送一个中断。设备驱动程序然后将这个字节作为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)
{/* 参数filp是设备文件,buf是输入数据缓存,count是输入数据长度,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驱动程序都依赖中断处理程序及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()函数的代码:
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全局变量指向的驱动程序描述符foo_dev_t的data字段中。然后设置intr标志,并调用wake_up_interruptible()函数唤醒在foo->wait等待队列上阻塞的进程。
注意,三个参数中没有一个被中断处理程序使用,这是其实是相当普遍的情况。
1.4 访问I/0共享存储器
根据设备和总线的类型,现代PC体系结构里的I/O共享存储器可以被映射到不同的物理地址范围。主要有:
对于连接到ISA,总线上的大多数没备
I/O共享存储器通常被映射到Oxa0000一Oxfffff的16位物理地址范围;这就在640 KB和1 MB之间留出了一段空间。
对子连接到PCI,总线上的设备
I/O共享存储器被映射到接近4 GB的32位物理地址范围。这种类型的设备更加容易处理。
I/O共享存储器机制是极其重要的,因为建立好这些映射以后,访问设备接口中的存储器如同访问内存一样简单,就不需要那么多纷繁复杂的I/O交换了,大大提升了系统I/O处理的效率。
几年以前,Intel引入了图形加速端口(AGP)标准,该标准是适合于高性能图形卡的PCI的增强。这种卡除了有自己的I/O共享存储器外,还能够通过图形地址再映像表(GART)这个特殊的硬件电路直接对主板的RAM部分进行寻址。GART电路能够使AGP卡比老的PCI卡具有更高的数据传输速率。然而,从内核的观点看,物理存储器位于何处根本没有什么关系,GART映射的存储器与其他种类I/O共享存储器的处理方式完全一样。
设备驱动程序如何访问一个I/O共享存储器单元?让我们从比较简单的PC体系结构开始人手,之后再扩展到其他体系结构。
不要忘了内核程序作用于线性地址,因此I/O共享存储器单元必须表示成大于PAGE_OFFSET的地址,这样,才有利于对I/O共享存储器单元的物理地址进行映射。我们假设PAGE_OFFSET等于Oxc0000000(在x86的32为体系中,实际上也是这样干的),也就是说,内核线性地址是在第4个GB。
设备驱动程序必须把I/O共享存储器单元的物理地址转换成内核空间的线性地址。在PC体系结构中,这可以简单地把32位的物理地址和Oxc0000000常量进行或运算得到。例如,假设内核需要把物理地址为Ox000b0fe4的I/O单元的值存放在t1中,把物理地址为Oxfc000000的I/O单元的值存放在t2中。你可能认为使用下面的表达式就可以完成这项工作:
t1 = *((unsigned char *)(0xc00b0fe4));
t2 = *((unsigned char *)(0xfc000000));
在初始化阶段,内核已经把可用的RAM物理地址映射到线性地址空间第4个GB的开始部分。因此,分页单元把出现在第一个语句中的线性地址OXCOObOfe4映射回到原来的I/O物理地址OXOOObOfe4,这正好落在从640KB到IMB的这段“ISA洞”中。这工作得很好。
但是,对于第二个语句来说,这里有一个问题,因为其I/O物理地址超过了系统RAM的最大物理地址。因此,线性地址Oxfc000000就不需要与物理地址Oxfc000000相对应。在这种情况下,为了在内核页表中包括对这个I/O物理地址进行映射的线性地址,必须对页表进行修改。这可以通过调用ioremap()或ioremap_nocache()函数来实现,第一个函数与vmalloc()函数类似,都调用get_vm_area()为所请求的I/O共享存储区的大小建立一个新的vm_struct描述符。然后,这两个函数适当地更新常规内核页表中的对应页表项。ioremap_nocache()不同于ioremap(),因为前者在适当地引用再映射的线性地址时还使硬件高速缓存内容失效。
因此,第二个语句的正确形式应该为:
io_mem = ioremap(0xfb000000, 0x200000);
t2 = *((unsigned char *)(io_mem + 0x100000));
第一条语句建立一个2MB的新的线性地址区间,该区间映射了从Oxfb000000开始的物理地址,第二条语句读取地址为Oxfc000000的内存单元。设备驱动程序以后要取消这种映射,就必须使用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共享存储器区域
最后,对于Oxfc000000 I/O单元的访问推荐使用这样的方法:
io_mem = ioremap(0xfb000000, 0x200000);
t2 = readb(io_mem + 0x100000);
正是由于这些函数,就可以隐藏不同平台访问I/O共享存储器所用方法的差异。
1.5 直接存储访问机制(DMA)
在最初的PC体系结构中,CPU是系统中唯一的总线主控器,也就是说,为了提取和存储RAM存储单元的值,CPU是唯一可以驱动地址/数据总线的硬件设备。随着更多诸如PCI这样的现代总线体系结构的出现,如果提供合适的电路,每一个外围设备都可以充当总线主控器。因此,现在所有的PC都包含一个辅助的DMA电路,它可以用来控制在RAM和I/O设备之间数据的传送。DMA一旦被CPU激活,就可以自行传送数据;当数据传送完成之后,DMA发出一个中断请求。当CPU和DMA同时访问同一内存单元时,所产生的冲突由一个名为内存仲裁器的硬件电路来解决。
使用DMA最多的是磁盘驱动器和其他需要一次传送大量字节的设备。因为DMA的设置时间相当长,所以在传送数量很少的数据时直接使用CPU效率更高。
原来的ISA总线所使用的DMA电路非常复杂,难于对其进行编程,并且限于物理内存的低16M8。PCI和SCSI总线所使用的最新DMA电路依靠总线中的专用硬件电路,这就简化了设备驱动程序开发人员的开发工作。
1.5.1 同步DMA和异步DMA
设备驱动程序可以采用两种方式使用DMA,分别是同步DMA和异步DMA。第一种方式,数据的传送是由进程触发的;而第二种方式,数据的传送是由硬件设备触发的。
采用同步DMA传送的例子如声卡,它可以播放电影音乐。用户态应用程序将声音数据(称为样本)写入一个与声卡的数字信号处理器(DSP)相对应的设备文件中。声卡的驱动程序把写入的这些样本收集在内核缓冲区中。同时,驱动程序命令声卡把这些样本从内核缓冲区拷贝到预先定时的DSP中。当声卡完成数据传送时,就会引发一个中断,然后驱动程序会检查内核缓冲区是否还有要播放的样本;如果没有,驱动程序就再启动一次DMA数据传送。
采用异步DMA传送的例子如网卡,它可以从一个LAN中接收帧(数据包)。网卡将接收到的帧存储在自己的I/O共享存储器中,然后引发一个中断。其驱动程序确认该中断后,命令网卡将接收到的帧从I/O共享存储器拷贝到内核缓冲区。当数据传送完成后,网卡会引发新的中断,然后驱动程序将这个新帧通知给上层内核层。
1.5.2 DMA传送的辅助函数
当为使用DMA传送方式的设备设计驱动程序时,开发者编写的代码应该与体系结构和总线(就DMA传送方式来说)二者都不相关。由于内核提供了丰富的DMA辅助函数,因而现在上述目标是可以实现的。这些辅助函数隐藏了不同硬件体系结构的DMA实现机制的差异。
这是DMA辅助函数的两下子集:老式的子集为PCI设备提供了与体系结构无关的函数;新的子集则保证了与总线和体系结构两者都无关。我们现在将介绍其中的一些函数,同时指出DMA的一些硬件特性。
1、总线地址
DMA的每次数据传送(至少)需要一个内存缓冲区,它包含硬件设备要读出或写入的数据。一般而言,启动一次数据传送前,设备驱动程序必须确保DMA电路可以直接访问RAM内存单元。
到现在为止,我们已区分了三类存储器地址:逻辑地址、线性地址以及物理地址,前两个在CPU内部使用,最后一个是CPU利用硬件分页机制从物理上驱动数据总线所用的内存RAM物理地址。但是,还有第四种存储器地址,称为总线地址(bus address),它是除CPU之外的硬件设备驱动数据总线时所用的存储器地址。
从根本上说,内核为什么应该关心总线地址呢?这是因为在DMA操作中,数据传送不需要CPU的参与;I/O设备和DMA电路直接驱动数据总线(注意,这里有可能会跟CPU产生竞争数据总线的情况)。因此,当内核开始DMA操作时,必须把所涉及的内存缓冲区总线地址要么写人DMA适当的I/O端口,要么写人I/O设备适当的I/O端口。
在80x86体系结构中,总线地址与物理地址是一致的,也就是前面我们谈到的I/O共享存储。然而,其他的体系结构例如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,原来的PCI标准定义了32位的总线地址;但是,一些PCI硬件设备最初是为ISA总线而设计的,因此它们仍然访问不了物理地址Ox00ffffff以上的RAM内存单元。新的PCI-E标准采用64位的总线地址并允许DMA电路可以直接寻址更高的内存。
在Linux中,数据类型dma_addr_t代表一个通用的总线地址。在80x86体系结构中,dma_addr_t对应一个32位长的整数,除非内核支持PAE,在这种情形下,dma_addr_t代表一个64位的整数。
pci_set_dma_mask()和dma_set_mask()两个辅助函数用于检查总线是否可以接收给定大小的总线地址(mask),如果可以,则通知总线层给定的外围设备将使用该大小的总线地址。
2、高速缓存的一致性
高速缓存一致性问题,类似于内核同步访问机制,系统体系结构没有必要在硬件级为硬件高速缓存与DMA电路之间提供一个一致性协议,因此,执行DMA映射操作时,DMA辅助函数必须考虑硬件高速缓存。为了弄清楚这是为什么,假设设备驱动程序把一些数据填充到内存缓冲区中,然后立刻命令硬件设备利用DMA传送方式读取该数据。如果DMA访问这些物理RAM内存单元,而相应的硬件高速缓存行的内容还没有写入RAM中,那么硬件设备所读取的值就是内存缓冲区中的旧值。
设备驱动程序开发人员可以采用两种方法来处理DMA缓冲区,他们分别使用两类不同的辅助函数来完成。用Linux的术语来说,开发人员在下面两种DMA映射类型中进行选择:
一致性DMA映射
使用这种映射方式时,内核必须保证内存与硬件设备间高速缓存一致性不是什么问题;也就是说CPU在RAM内存单元上所执行的每个写操作对硬件设备而言都是立即可见的,反过来也一样。这种映射方式也称为“同步的”或“一致的”。
流式DMA映射
使用这种映射方式时,设备驱动程序必须了解高速缓存一致性问题,这可以使用适当的同步辅助函数来解决。这种映射方式也称为“异步的”或“非一致性的”。
在80x86体系结构中使用DMA时,不存在高速缓存一致性问题,因为硬件设备驱动程序本身会“窥探”所访问的硬件高速缓存。因此,80x86体系结构中为硬件设备所设计的驱动程序会从前述的两种DMA映射方式中选择一个:它们二者在本质上是等价的。另一方面,在诸如MIPS、SPARC以及PowerPC的一些模型等许多其他的体系结构中,硬件设备通常不窥探硬件高速缓存,因而就会产生高速缓存一致性问题。总的来说,为与体系结构无关的驱动程序选择一个合适的DMA映射方式是很重要。
一般来说,如果CPU和DMA处理器以不可预知的方式去访问一个缓冲区,那么必须强制使用一致性DMA映射方式(例如,SCSI适配器的command数据结构的缓冲区)。其他情形下,流式DMA映射方式更可取,因为在一些体系结构中处理一致性DMA映射是很麻烦的,并且可能导致更低的系统性能。
3、一致性DMA映射的辅助函数
通常,设备驱动程序在初始化阶段会分配内存缓冲区并建立一致性DMA映射;在卸载时释放映射和缓冲区。为了分配内存缓冲区和建立一致性DMA映射,内核提供了依赖体系结构的pci_alloc_consistent()和dma_alloc_coherent()两个函数。它们均返回新缓冲区的线性地址和总线地址。在80x86体系结构中,它们返回新缓冲区的线性地址和物理地址。为了释放映射和缓冲区,内核提供了pci_free_consistent()和dma_free_coherent()两个函数。
4、流式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()函数使相应的硬件高速缓存行无效。在80x86体系结构中,上述函数几乎不做任何事情,因为硬件高速缓存和DMA之间的一致性是由硬件来维护的。
即使是高端内存的缓冲区也可以用于DMA传送;开发人员使用pci_map_page()或dma_map_page()函数,给其传递的参数为缓冲区所在页的描述符地址和页中缓冲区的偏移地址。相应地,为了释放高端内存缓冲区的映射,开发人员使用pci_unmap_page()或dma_unmap_page()函数。
1.6 内核支持级别
最后,我们来总结一下内核对I/O设备的支持级别。Linux内核并不完全支持所有可能存在的I/O设备。一般来说,有三种可能的方式支持硬件设备:
压根就不支持:
应用程序使用适当的in和out汇编语言指令直接与设备的I/O端口进行交互。
最小支持:
内核不识别硬件设备,但能识别它的I/O接口。用户程序把I/O接口视为能够读写字符流的顺序设备。
扩展支持:
内核识别硬件设备,并处理I/O接口本身。不过,这种设备可能没有对应的设备文件