Linux内核驱动 - STEMHA's Blog

Linux内核驱动

设备文件

类Unix操作系统都是基于文件概念的。

  • 可以直接把I/O设备当作设备文件(device file)来处理。
  • 操作普通文件的系统调用也可以直接用于I/O设备。

设备文件划分

根据设备驱动程序的基本特性划分

  • 块设备:块设备的数据可以被随机访问。
  • 字符设备:字符设备的数据不可以随机访问(声卡)。或者可以随机访问但是随机访问数据时间很大程度上依赖于数据在设备内的位置(磁带)。
  • 网卡是例外,网卡是不直接与设备文件相对应的硬件设备。

设备文件属性

设备文件的索引节点

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

设备标识符:

  • 由设备文件的类型(字符或者块)和一对参数组成(主设备号,从设备号)。
    • 主设备号:标识了设备的类型。通常,具有相同主设备号和类型的所有设备文件共享相同的文件操作集合,因为它们是由同一个设备驱动程序处理的。
    • 从设备号:标识了主设备号相同的设备组中的一个特定设备。例如相同磁盘控制器下的一组磁盘,有相同的主设备号,但是拥有不同的从设备号码。

创建设备文件:
mknod()系统调用用来创建设备文件。参数有设备文件名,设备类型,主设备号以及次设备号。

  • 设备文件通常在/dev下。
  • 设备文件通常与硬件设备(比如硬盘/dev/hda),或者硬件设备的某一个物理或者逻辑分区(比如磁盘分区/dev/hda2)对应。
  • 有些情况下,设备文件不会和任何的实际硬件对应,而仅仅标识一个虚拟的逻辑设备。
    • 比如 /dev/null 。 黑洞
  • 注意:块设备的编号(3,0)不等同与字符设备的(3,0)

设备文件的用户态处理

早期的Unix系统中,设备的主设备号和次设备号是8位长,并不够用。

真正的问题是设备文件被分配一次并且永远保留在/dev中;系统中的每个逻辑设备都应该有一个与其相对应的,明确定义了设备号的设备文件。

  • Documentation/devices.txt 存放了官方注册的已经分配的设备号和/dev目录节节点
  • include/linux/major.h 文件也可能包含了设备的主设备号对应的宏。

为了解决上述问题:
从linux2.6开始,增加了设备号码的大小;

  • 主设备号为12位
  • 从设备号为20位
  • 通常把两个参数合并为一个32位的dev_t变量;
  • MAJOR宏MINOR宏 可以从dev_t中分别提取主设备号和次设备号
  • MKDEV宏把主设备号和次设备号合并称为一个dev_t值

动态分配设备号

分配设备号和创建设备文件来说,倾向做法是高度动态地处理设备文件。

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

概括来说就是,动态分配设备号给设备文件,也就是说设备文件不能永久创建,设备文件的设备号在创建的时候才能确定。那么用户态的应用程序如何才能知道动态分配给设备文件的设备号是什么呢?Linux中通过文件(/sys/class子目录下的dev属性)来传递这个信息给用户态应用程序

动态创建设备文件

Linux内核可以动态地创建设备文件:它无需把每一个可能想到的硬件设备的设备文件都填充到/dev目录下,因为设备文件可以按照需要来创建。
由于设备驱动程序模型的存在,在Linux2.6内核提供了一个简单的方法来处理:系统中必须安装一组udev工具集的用户态程序。

  1. 系统启动时,/dev目录是清空的,这时udev程序将扫描/sys/class子目录来寻找dev文件。
  2. 对每一个这样的文件(主设备号和次设备号的组合表示一个内核所支持的逻辑设备文件),udev程序都会在/dev目录下为它创建一个相应的设备文件。udev程序也会根据配置文件为其分配一个文件名并创建一个符号链接,该方法类似于Unix设备文件的传统命名模式。
  3. 最后,/dev目录里只存放了系统中内核所支持的所有设备的设备文件,而没有任何其他的文件。

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

设备文件的 VFS处理

虽然设备文件也在系统的目录树中,但是它们和普通文件及目录文件有根本的不同:

  • 进程访问普通文件时,它会通过文件系统访问磁盘分区中的一些数据块。
  • 进程访问设备文件时,它只要驱动硬件设备就可以了。

隐藏设备文件和普通文件之间的差异就是vfs的责任

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

例子:
假定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设备都有自己的驱动程序。

设备驱动程序的组成?

使用驱动设备有几个活动肯定是要发生的。

  1. 注册设备驱动程序
  2. 初始化设备驱动程序
  3. 监控I/O操作
  4. 访问I/O共享存储器

注册设备驱动程序

为什么要注册设备驱动程序?

在设备文件上发出的系统调用都由内核转化为相应设备驱动程序的对应函数的调用。
为了完成这个操作,设备驱动程序必须注册自己。

设备驱动程序注册的过程

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

  • 如果设备驱动程序是静态编译到内核的,则它的注册在内核初始化阶段进行。
  • 如果是作为一个内核模块来编译的,则它的注册在模块装入的时候进行,而且在模块卸载的时候也会注销自己。

通用PCI设备的注册示例
该设备必须分配一个pci_driver类型的描述符,pci内核层使用该描述符来处理设备。
初始化描述符的一些字段,设备驱动程序调用pci_regester_driver()函数。

  • pci_driver描述符包括一个内嵌的device_driver描述符
  • pci_regester_driver()函数仅仅初始化内嵌的驱动程序描述符中的字段,然后调用device_register()函数把驱动程序插入设备驱动程序模型的数据结构中。

注册设备驱动程序的时候 内核会寻找可能由该驱动程序处理但是尚未获得支持的硬件设备:

  • 主要依靠bus_type的match方法以及device_driver对象的probe方法。如果探测到可被驱动程序处理的硬件设备,内核会分配一个设备对象,然后调用device_register()函数把设备插入设备驱动模型中。

初始化设备驱动程序

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

监控I/O操作

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

  • 终止操作的情况下:设备驱动程序读取I/O接口状态寄存器的内容来确定I/O操作是否成功执行。
  • 超时的情况下:时间过了,驱动程序就知道出了什么问题了。

轮询模式

CPU依靠这种技术重复检查(轮询)设备的状态寄存器,直到寄存器的值表明I/O操作完成。有点类似于自旋锁。

如果完成I/O操作需要的时间相对较多,比如说毫秒级别,那么这种模式就变得低效,因为CPU花费宝贵的机器周期去等待I/O操作的完成。

轮询的简单例子:

1
2
3
4
5
6
7
for(;;)
{
if(read_status(device)&DEVICE_END_OPERATION)
break;
if(--count==0) //粗略的超时机制,精确的可以使用jiffies
break;
}

中断模式

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

例子:实现一个简单的输入字符设备的驱动程序。

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

驱动程序包含两个函数:

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

用户读设备文件,foo_read()函数就触发,foo_read()函数主要操作如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
//参数filp是设备文件,buf是输入数据缓存,count是输入数据长度,ppos当前位置
ssize_t foo_read(struct file *filp, char *buf, size_t count, loff_t *ppos)
{
  foo_dev_t * foo_dev = filp->private_data; //foo_dev_t 自定义描述符
      if (down_interruptible(&foo_dev->sem) //获取foo_dev->sem信号量
          return -ERESTARTSYS;
      foo_dev->intr = 0; //清intr标志
      outb(DEV_FOO_READ, DEV_FOO_CONTROL_PORT); //对I/O设备发出读命令
      wait_event_interruptible(foo_dev->wait, (foo_dev->intr = =1)); //执行wait_event_interruptible以挂起进程,直到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()函数的代码:

1
2
3
4
5
6
7
irqreturn_t foo_interrupt(int irq, void *dev_id, struct pt_regs *regs)
{
    foo->data = inb(DEV_FOO_DATA_PORT);//从设备的输入寄存器中读字符
    foo->intr = 1; //设置intr标志
    wake_up_interruptible(&foo->wait); //调用`wake_up_interruptible()函数`唤醒在`foo->wait等待队列上`阻塞的进程。
    return 1;
}

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

访问I/O共享存储器(内存映射)

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

  • 对于连接到ISA总线上的大多数设备
    • I/O共享存储器通常被映射到Oxa0000一Oxfffff的16位物理地址范围;这就在640 KB和1 MB之间留出了一段空间。
  • 对连接到PCI总线上的设备
    • I/O共享存储器被映射到接近4GB的32位物理地址范围。这种类型的设备更加容易处理。

I/O共享存储器机制是极其重要的,因为建立好这些映射以后,访问设备接口中的存储器如同访问内存一样简单,就不需要那么多纷繁复杂的I/O交换了,大大提升了系统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中。你可能认为使用下面的表达式就可以完成这项工作:

1
2
    t1 = *((unsigned char *)(0xc00b0fe4));   //括号里面的是线性地址
    t2 = *((unsigned char *)(0xfc000000)); //强制转换为指针类型,也就是地址,然后再进行取地址所存的值

在初始化阶段,内核已经把可用的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
2
    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单元的访问推荐使用这样的方法:

1
2
    io_mem = ioremap(0xfb000000, 0x200000);
    t2 = readb(io_mem + 0x100000);

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

//上面这一段话实在是太复杂了,现在总结一下;

一些概念

IO空间与内存空间
在X86处理器才存在IO空间,是相对于内存空间的概念。目前大多数嵌入式处理器(如ARM、PowerPC等)并不提供IO空间。所以内存空间是必须的,IO空间是可选的。嵌入式开发只关心内存空间即可。
IO空间和内存空间是彼此独立的地址空间,在32位的X86处理器中,IO空间大小为64K ,内存空间为4G。

参考资料

深入理解LINUX内核
Linux下对IO内存的访问 //一些概念的介绍
Linux内核开发之内存与I/O访问(一)

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×