类Unix操作系统都是基于文件概念的。
根据设备驱动程序的基本特性划分
块设备
:块设备的数据可以被随机访问。字符设备
:字符设备的数据不可以随机访问(声卡)。或者可以随机访问但是随机访问数据时间很大程度上依赖于数据在设备内的位置(磁带)。设备文件的索引节点
:
设备标识符
:
主设备号
:标识了设备的类型。通常,具有相同主设备号和类型的所有设备文件共享相同的文件操作集合,因为它们是由同一个设备驱动程序处理的。从设备号
:标识了主设备号相同的设备组中的一个特定设备。例如相同磁盘控制器下的一组磁盘,有相同的主设备号,但是拥有不同的从设备号码。创建设备文件:mknod()系统调用
用来创建设备文件。参数有设备文件名,设备类型,主设备号以及次设备号。
早期的Unix系统中,设备的主设备号和次设备号是8位长,并不够用。
真正的问题是设备文件被分配一次并且永远保留在/dev中;系统中的每个逻辑设备都应该有一个与其相对应的,明确定义了设备号的设备文件。
Documentation/devices.txt
存放了官方注册的已经分配的设备号和/dev目录节节点include/linux/major.h
文件也可能包含了设备的主设备号对应的宏。为了解决上述问题:
从linux2.6开始,增加了设备号码的大小;
MAJOR宏
和 MINOR宏
可以从dev_t中分别提取主设备号和次设备号MKDEV宏
把主设备号和次设备号合并称为一个dev_t值分配设备号和创建设备文件来说,倾向做法是高度动态地处理设备文件。
主设备号和次设备号存放在/sys/class子目录下的dev属性
中。概括来说就是,动态分配设备号给设备文件,也就是说设备文件不能永久创建,设备文件的设备号在创建的时候才能确定。那么用户态的应用程序如何才能知道动态分配给设备文件的设备号是什么呢?Linux中通过文件(/sys/class子目录下的dev属性)来传递这个信息给用户态应用程序
Linux内核可以动态地创建设备文件:它无需把每一个可能想到的硬件设备的设备文件都填充到/dev目录下,因为设备文件可以按照需要来创建。
由于设备驱动程序模型的存在,在Linux2.6内核提供了一个简单的方法来处理:系统中必须安装一组udev工具集的用户态程序。
通常在系统初始化后才创建设备文件。它要么发生在加载设备驱动程序所在的模块时,要么发生在一个热插拔的设备加入系统中时。udev工具集可以自动地创建相应的设备文件,因为设备驱动程序模型支持设备的热插拔。当发现一个新的设备时,内核会产生一个新的进程来执行用户态shell脚本文件/sbin/hotplug,并将新设备上的有用信息作为环境变量传递给shell脚本,用户态脚本文件读取配置文件信息并关注完成新设备初始化所必需的任何操作。如果安装了udev工具集,脚本文件也会在/dev目录下创建适当的设备文件。
虽然设备文件也在系统的目录树中,但是它们和普通文件及目录文件有根本的不同:
隐藏设备文件和普通文件之间的差异就是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
的地址。正是这两个表的引入,才使得在设备文件上所发出的任何系统调用都将激活设备驱动程序的函数而不是基本文件系统的函数。
设备驱动程序
是内核例程的集合,它使硬件设备响应控制设备的编程接口
。
该编程接口是一组规范的VFS函数集合(open read lseek ioctl)。这些函数的实际实现由设备驱动程序全权负责。
每一个设备都有自己的I/O控制器,因此就有唯一的命令和唯一的状态信息,所以大部分I/O设备都有自己的驱动程序。
设备驱动程序的组成?
使用驱动设备有几个活动肯定是要发生的。
为什么要注册设备驱动程序?
在设备文件上发出的系统调用都由内核转化为相应设备驱动程序的对应函数的调用。
为了完成这个操作,设备驱动程序必须注册自己。
注册一个设备驱动程序意味着分配一个新的device_driver描述符,将其插入到设备驱动程序模型的数据结构中,并把它对应的设备文件(可能多个)连接起来。
如果设备文件对应的驱动程序以前没有注册,则对该设备文件的访问会返回错误码-ENODEV。
通用PCI设备的注册示例
:
该设备必须分配一个pci_driver类型的描述符,pci内核层使用该描述符来处理设备。
初始化描述符的一些字段,设备驱动程序调用pci_regester_driver()函数。
device_driver
描述符device_register()
函数把驱动程序插入设备驱动程序模型的数据结构中。注册设备驱动程序的时候 内核会寻找可能由该驱动程序处理但是尚未获得支持的硬件设备:
设备驱动程序的注册和初始化是不同的。
设备驱动程序应该尽快被注册,以便用户态应用程序能够通过相应的设备文件来使用它。
设备驱动程序在最后可能的时刻才会被初始化。因为,初始化驱动程序意味着分配宝贵的系统资源,这些资源因此就对其他驱动程序不可用了。
I/O操作的持续时间通常不可预知。在任何情况下,启动I/O操作的设备驱动程序都必须依靠一种监控技术在I/O操作终止或超时时候发出信号。
CPU依靠这种技术重复检查(轮询)设备的状态寄存器,直到寄存器的值表明I/O操作完成。有点类似于自旋锁。
如果完成I/O操作需要的时间相对较多,比如说毫秒级别,那么这种模式就变得低效,因为CPU花费宝贵的机器周期去等待I/O操作的完成。
轮询的简单例子:
1 | for(;;) |
如果I/O控制器能够控制IRQ总线发出I/O操作结束的信号,那么中断模式才能被使用。
例子:实现一个简单的输入字符设备的驱动程序。
驱动程序包含两个函数:
用户读设备文件,foo_read()函数就触发,foo_read()函数主要操作如下:
1 | //参数filp是设备文件,buf是输入数据缓存,count是输入数据长度,ppos当前位置 |
设备驱动程序依赖类型为foo_dev_t
的自定义描述符;
一般而言,所有使用中断的I/O驱动程序都依赖中断处理程序及read和write方法均访问的数据结构。foo_dev_t描述符
的地址通常存放在设备文件的文件对象的private_data字段
中或一个全局变量中。
foo_read()函数的主要操作如下:
一定时间后,我们的设备发出中断信号以通知I/O操作已经完成,数据已经放在适当的DEV_FOO_DATA_PORT数据端口。中断处理程序置intr标志并唤醒进程。
当调度程序决定重新执行这个进程时,foo_read()的第二部分被执行,步骤如下:
把准备在foo_dev->data变量中的字符拷贝到用户地址空间。
释放foo_dev->sem信号量后终止。
为了简单起见,没有包含任何超时控制。一般来说,超时控制是通过静态或动态定时器实现的;定时器必须设置为启动I/O操作后正确的时间,并在操作结束时删除。
foo_interrupt()函数的代码:
1 | irqreturn_t foo_interrupt(int irq, void *dev_id, struct pt_regs *regs) |
中断处理程序从设备的输入寄存器
中读字符,并把它存放在foo全局变量
指向的驱动程序描述符foo_dev_t
的data字段
中。
然后设置intr标志,并调用wake_up_interruptible()函数
唤醒在foo->wait等待队列上
阻塞的进程。
注意:三个参数中没有一个被中断处理程序使用,这是其实是相当普遍的情况。
根据设备和总线的类型,现代PC体系结构里的I/O共享存储器可以被映射到不同的物理地址范围。主要有:
I/O共享存储器机制
是极其重要的,因为建立好这些映射以后,访问设备接口中的存储器如同访问内存一样简单,就不需要那么多纷繁复杂的I/O交换了,大大提升了系统I/O处理的效率。
设备驱动程序如何访问一个I/O共享存储器单元?让我们从比较简单的PC体系结构开始人手,之后再扩展到其他体系结构。
不要忘了内核程序作用于线性地址,因此I/O共享存储器单元必须表示成大于PAGE_OFFSET
的地址(???),这样,才有利于对I/O共享存储器单元的物理地址进行映射。
我们假设PAGE_OFFSET等于Oxc0000000(在x86的32为体系中,实际上也是这样干的),也就是说,内核线性地址是在第4个GB。
设备驱动程序必须把I/O共享存储器单元的物理地址
转换成内核空间的线性地址
:
例如,假设内核需要把物理地址为Ox000b0fe4
的I/O单元的值存放在t1中,把物理地址为Oxfc000000
的I/O单元的值存放在t2中。你可能认为使用下面的表达式就可以完成这项工作:
1 | t1 = *((unsigned char *)(0xc00b0fe4)); //括号里面的是线性地址 |
在初始化阶段,内核已经把可用的RAM物理地址映射到线性地址空间第4个GB的开始部分。
因此,分页单元把出现在第一个语句中的线性地址OXCOObOfe4映射回到原来的I/O物理地址OXOOObOfe4,这正好落在从640KB到IMB的这段“ISA洞”中。这工作得很好。
但是,对于第二个语句来说,这里有一个问题,因为其I/O物理地址超过了系统RAM的最大物理地址(加上Oxc0000000常量会超过32位)。因此,线性地址Oxfc000000就不需要与物理地址Oxfc000000相对应。在这种情况下,为了在内核页表中包括对这个I/O物理地址进行映射的线性地址,必须对页表进行修改。这可以通过调用ioremap()或ioremap_nocache()函数来实现,第一个函数与vmalloc()函数类似,都调用get_vm_area()为所请求的I/O共享存储区的大小建立一个新的vm_struct描述符。然后,这两个函数适当地更新常规内核页表中的对应页表项。ioremap_nocache()不同于ioremap(),因为前者在适当地引用再映射的线性地址时还使硬件高速缓存内容失效。
因此,第二个语句的正确形式应该为:
1 | io_mem = ioremap(0xfb000000, 0x200000); |
第一条语句建立一个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单元的访问推荐使用这样的方法:
1 | io_mem = ioremap(0xfb000000, 0x200000); |
正是由于这些函数,就可以隐藏不同平台访问I/O共享存储器所用方法的差异。
//上面这一段话实在是太复杂了,现在总结一下;
IO空间与内存空间
在X86处理器才存在IO空间,是相对于内存空间的概念。目前大多数嵌入式处理器(如ARM、PowerPC等)并不提供IO空间。所以内存空间是必须的,IO空间是可选的。嵌入式开发只关心内存空间即可。
IO空间和内存空间是彼此独立的地址空间,在32位的X86处理器中,IO空间大小为64K ,内存空间为4G。
深入理解LINUX内核
Linux下对IO内存的访问 //一些概念的介绍
Linux内核开发之内存与I/O访问(一)
Update your browser to view this website correctly. Update my browser now