Linux设备驱动笔记.docx
《Linux设备驱动笔记.docx》由会员分享,可在线阅读,更多相关《Linux设备驱动笔记.docx(12页珍藏版)》请在冰豆网上搜索。
![Linux设备驱动笔记.docx](https://file1.bdocx.com/fileroot1/2023-1/29/ba770b1a-c43f-4323-93c3-bb5d57ed147b/ba770b1a-c43f-4323-93c3-bb5d57ed147b1.gif)
Linux设备驱动笔记
阐述Linux设备文件管理硬件设备
有人说Linux已经成为过去式了,这是因为你不懂Linux,如果你想在了解Linux设备文件管理硬件设备,希望本文对你学习Linux设备文件管理硬件设备有帮助。
设备管理是操作系统五大管理中最复杂的部分。
与Unix系统一样,Linux系统采用设备文件统一管理硬件设备,从而将硬件设备的特性及管理细节对用户隐藏起来,实现用户程序与设备无关性。
在Linux系统中,硬件设备分为两种,即块设备和字符设备。
1特别文件
用户是通过文件系统与设备接口的,所有设备都作为特别文件,从而在管理上就具有一些共性。
(1)每个设备都对应文件系统中的一个索引节点,都有一个文件名。
设备的文件名一般由两部分构成,第一部分是主设备号,第二部分是次设备号。
主设备号代表设备的类型,可以惟一地确定设备的驱动程序和界面,如hd表示IDE硬盘,sd表示SCSI硬盘,tty表示终端设备等;次设备号代表同类设备中的序号,如hda表示IDE主硬盘,hdb表示IDE从硬盘等。
(2)应用程序通常可以通过系统调用open()打开设备文件,建立起与目标设备的连接。
(3)对设备的使用类似于对文件的存取。
打开设备文件以后,就可以通过read()、write()、ioctl()等文件操作对目标设备进行操作。
(4)设备驱动程序都是系统内核的一部分,它们必须为系统内核或它们的子系统提供一个标准的接口。
例如,一个终端驱动程序必须为Linux内核提供一个文件I/O接口;一个SCSI设备驱动程序应该为SCSI子系统提供一个SCSI设备接口,同时SCSI子系统也应为内核提供文件I/O和缓冲区。
(5)设备驱动程序利用一些标准的内核服务,如内存分配等。
另外,大多数Linux设备驱动程序都可以在需要时装入内核,不需要时可以卸载下来。
处于应用层的进程通过文件描述字fd与已打开文件的file结构相联系。
在文件系统层,按照文件系统的操作规则对该文件进行相应处理。
对于一般文件(即磁盘文件),要进行空间的映射—从普通文件的逻辑空间映射到设备的逻辑空间,然后在设备驱动层做进一步映射—从设备的逻辑空间映射到物理空间(即设备的物理地址空间),进而驱动底层物理设备工作。
对于设备文件,则文件的逻辑空间通常就等价于设备的逻辑空间,然后从设备的逻辑空间映射到设备的物理空间,再驱动底层的物理设备工作。
2设备驱动程序和内核之间的接口
Linux系统和设备驱动程序之间使用标准的交互接口。
无论是字符设备、块设备还是网络设备的驱动程序,当内核请求它们提供服务时,都使用同样的接口。
Linux提供了一种全新的机制,就是“可安装模块”。
可安装模块是可以在系统运行时动态地安装和拆卸的内核模块。
利用这个机制,可以根据需要在不必对内核重新编译连接的条件下,将可安装模块动态插入运行中的内核,成为其中一个有机组成部分,或者从内核卸载已安装的模块。
设备驱动程序或与设备驱动紧密相关的部分(如文件系统)都是利用可安装模块实现的。
在应用程序界面上,利用内核提供的系统调用来实现可安装模块的动态安装和拆卸。
但通常情况下,用户是利用系统提供的插入模块工具和移走模块工具来装卸可安装模块。
插入模块的工作主要如下:
(1)打开要安装的模块,把它读到用户空间。
这种“模块”就是经过编译但尚未连接的.o文件。
(2)必须把模块内涉及对外访问的符号(函数名或变量名)连接到内核,即把这些符号在内核映像中的地址填入该模块需要访问这些符号的指令及数据结构中。
(3)在内核创建一个module数据结构,并申请所需的系统空间。
(4)最后,把用户空间中完成了连接的模块映像装入内核空间,并在内核中“登记”本模块的有关数据结构(如file_operations结构),其中有指向执行相关操作函数的指针。
如前所述,Linux系统是一个动态的操作系统。
用户根据工作中的需要,会对系统中设备重新配置,如安装新的打印机、卸载老式终端等。
这样,每当Linux系统内核初启时,它都要对硬件配置进行检测,很有可能会检测到不同的物理设备,就需要不同的驱动程序。
在构建系统内核时,可以使用配置脚本将设备驱动程序包含在系统内核中。
在系统启动时对这些驱动程序初始化,它们可能未找到所控制的设备,而另外的设备驱动程序可以在需要时作为内核模块装入到系统内核中。
为了适应设备驱动程序动态连接的特性,设备驱动程序在其初始化时就在系统内核中进行登记。
Linux系统利用设备驱动程序的登记表作为内核与驱动程序接口的一部分,这些表中包括指向有关处理程序的指针和其它信息。
linux设备驱动(十五)--与硬件通信
(1)I/O端口和I/O内存:
每种外设都是通过读写寄存器来进行控制。
在硬件层,内存区和I/O区域没有概念上的区别:
它们都是通过向在地址总线和控制总线发出电平信号来进行访问,再通过数据总线读写数据。
因为外设要与I\O总线匹配,而大部分流行的I/O总线是基于个人计算机模型(主要是x86家族:
它为读和写I/O端口提供了独立的线路和特殊的CPU指令),所以即便那些没有单独I/O端口地址空间的处理器,在访问外设时也要模拟成读写I\O端口。
这一功能通常由外围芯片组(PC中的南北桥)或 CPU中的附加电路实现(嵌入式中的方法) 。
Linux在所有的计算机平台上实现了I/O端口。
但不是所有的设备都将寄存器映射到I/O端口。
虽然ISA设备普遍使用I/O端口,但大部分PCI设备则把寄存器映射到某个内存地址区,这种I/O内存方法通常是首选的。
因为它无需使用特殊的处理器指令,CPU核访问内存更有效率,且编译器在访问内存时在寄存器分配和寻址模式的选择上有更多自由。
(2)I/O寄存器和常规内存:
尽管硬件寄存器和内存非常相似,但程序员在访问I/O寄存器的时候必须注意避免由于CPU或编译器不恰当的优化而改变预期的I/O操作(也即对寄存器的地址都声明为volatile)。
I/O寄存器和RAM的最主要区别就是I/O操作具有边际效应(其实边际效应就是对I/O寄存器操作,导致高低电平的变化,从而促使硬件进行相对应的行为)。
因为存储单元的访问速度对CPU性能至关重要,编译器会对源代码进行优化,主要是:
使用高速缓存保存数值和重新编排读/写指令顺序。
但对I/O寄存器操作来说,这些优化可能造成致命错误。
因此,驱动程序必须确保在操作I/O寄存器时,不使用高速缓存,且不能重新编排读/写指令顺序。
解决的方法:
对于硬件自身缓存引起的问题:
只要把底层硬件配置成在访问I/O区域时禁止硬件缓存即可。
对于编译器优化和硬件重新排序引起的问题:
对硬件必须以特定顺序执行的操作之间设置内存屏障。
Linux提供了以下宏来解决可能的排序问题:
#include,voidbarrier(void)这个函数通知编译器插入一个内存屏障,但对硬件没影响。
编译后的代码会把当前CPU寄存器的所有修改过的数值保存到内存中,需要这些数据时再读出来。
对barrier的调用,可阻止在屏障前后的编译器优化,但硬件能完成自己的重新排序。
其实 中并没有这个函数,因为它是在kernel.h包含的头文件compiler.h中定义的*/#include
#definebarrier() _memory_barrier()
但在内核中也有如下定义方式:
#definebarrier() _asm_volatile("":
:
:
"memory")
CPU越过内存屏障后,将刷新自已对存储器的缓冲状态。
这条语句实际上不生成任何代码,但可使gcc在barrier()之后刷新寄存器对变量的分配。
#include
void rmb(void); /*保证任何出现于屏障前的读在执行任何后续的读之前完成*/
void wmb(void); /*保证任何出现于屏障前的写在执行任何后续的写之前完成*/
void mb(void); /*保证任何出现于屏障前的读写操作在执行任何后续的读写操作之前完成*/
void read_barrier_depends(void); /*一种特殊的、弱些的读屏障形式。
rmb阻止屏障前后的所有读指令的重新排序,read_barrier_depends只阻止依赖于其他读指令返回的数据的读指令的重新排序。
区别微小,且不在所有体系中存在。
除非你确切地理解它们的差别,并确信完整的读屏障会增加系统开销,否则应当始终使用rmb。
*/
/*以上指令是barrier的超集*/
void smp_rmb(void);
void smp_read_barrier_depends(void);
void smp_wmb(void);
void smp_mb(void);
/*仅当内核为SMP系统编译时插入硬件屏障;否则,它们都扩展为一个简单的屏障调用。
*/
这里介绍个小资料
1.内核中往往有如下语句:
#define_set_task_state(tsk,state_value)\
do{(tsk)->state=state_value;}while(0)
#defineset_task_state(tsk,state_value)\
set_mb((tsk)->state,state_value)
两者区别在于:
set_task_state(tsk,state_value)带有一个memorybarrier,而_set_task_state却没有。
当task的state为RUNNING时,由于scheduler可能会访问这个state,因此此时要改变为其他状态(如INTERRUPTIBLE),则应该用set_task_state来保证其原子性。
而当state不为RUNNING时,因为没有人会访问task,所以可以用_set_task_state。
但用set_task_state总是安全的,但_set_task_state会比较快。
2.在include/asm-i386/system.h中,定义了如下一条语句:
#definemb()__asm____volatile__("lock;addl$0,0(%%esp)":
:
:
"memory") 分析如下几点:
1)set_mb(),mb(),barrier()函数追踪到底,就是__asm____volatile__("":
:
:
"memory"),而这行代码就是内存屏障。
2)__asm__用于指示编译器在此插入汇编语句
3)__volatile__用于告诉编译器,严禁将此处的汇编语句与其它的语句重组合优化。
即:
原原本本按原来的样子处理这这里的汇编。
4)memory强制gcc编译器假设RAM所有内存单元均被汇编指令修改,这样cpu中的registers和cache中已缓存的内存单元中的数据将作废。
cpu将不得不在需要的时候重新读取内存中的数据。
这就阻止了cpu又将registers,cache中的数据用于去优化指令,而避免去访问内存。
5)"":
:
:
表示这是个空指令。
barrier()不用在此插入一条串行化汇编指令。
在后文将讨论什么叫串行化指令。
6)__asm__,__volatile__,memory在前面已经解释
7)lock前缀表示将后面这句汇编语句:
"addl$0,0(%%esp)"作为cpu的一个内存屏障。
8)addl$0,0(%%esp)表示将数值0加到esp寄存器中,而该寄存器指向栈顶的内存单元。
加上一个0,esp寄存器的数值依然不变。
即这是一条无用的汇编指令。
在此利用这条无价值的汇编指令来配合lock指令,在__asm__,__volatile__,memory的作用下,用作cpu的内存屏障。
9)set_current_state()和__set_current_state()区别就不难看出。
10)至于barrier()就很易懂了。
3.#include
"voidrmb(void);"
"voidwmb(void);"
"voidmb(void);"
这些函数在已编译的指令流中插入硬件内存屏障;具体的插入方法是平台相关的。
rmb(读内存屏障)保证了屏障之前的读操作一定会在后来的读操作执行之前完成。
wmb保证写操作不会乱序,mb指令保证了两者都不会。
这些函数都是barrier函数的超集。
解释一下:
编译器或现在的处理器常会自作聪明地对指令序列进行一些处理,比如数据缓存,读写指令乱序执行等等。
如果优化对象是普通内存,那么一般会提升性能而且不会产生逻辑错误。
但如果对I/O操作进行类似优化很可能造成致命错误。
所以要使用内存屏障,以强制该语句前后的指令以正确的次序完成。
其实在指令序列中放一个wmb的效果是使得指令执行到该处时,把所有缓存的数据写到该写的地方,同时使得wmb前面的写指令一定会在wmb的写指令之前执行。
这里有篇文章,分析的很好:
典型的应用:
writel(dev->registers.addr, io_destination_address);
writel(dev->registers.size, io_size);
writel(dev->registers.operation, DEV_READ);
wmb();/*类似一条分界线,上面的写操作必然会在下面的写操作前完成,但是上面的三个写操作的排序无法保证*/
writel(dev->registers.control, DEV_GO);
内存屏障影响性能,所以应当只在确实需要它们的地方使用。
不同的类型对性能的影响也不同,因此要尽可能地使用需要的特定类型。
值得注意的是大部分处理同步的内核原语,例如自旋锁和atomic_t,也可作为内存屏障使用。
某些体系允许赋值和内存屏障组合,以提高效率。
它们定义如下:
#define set_mb(var, value) do {var = value; mb();} while 0
/*以下宏定义在ARM体系中不存在*/
#define set_wmb(var, value) do {var = value; wmb();} while 0
#define set_rmb(var, value) do {var = value; rmb();} while 0
使用do..while来构成宏,使得宏展开后可以作为一个完整的语句。
使用I/O端口
I/O端口是驱动程序与许多设备之间进行通信的方式。
I/O端口分配
在尚未取得对这些端口的独占访问之前,不应对这些端口进行操作。
内核提供了一个注册用的接口,他允许驱动程序声明自己要操作的端口。
接口函数是request_region:
#include
structresource*request_region(unsignedlongfirst,unsignedlongn,constchar*name);
这个函数告诉内核,我们要使用起始于first的n个端口,参数name应该是设备的名称。
如果分配成功,则返回为NULL值。
如果request_region返回NULL,则不能使用这些期望的端口。
所有端口分配信息可从/proc/ioports中得到。
如果不再需要使用某组I/O端口,则应该使用下面的函数将这些端口释放掉。
voidrelease_region(unsignedlongstart,unsignedlongn);
下面的函数允许驱动程序检查给定的端口集是否可用:
intcheck_region(unsignedlongfirst,unsignedlongn);
check_region这个函数并不赞成使用,因为其检查过程不是原子的。
但request_region驱动程序可以使用它,这个函数执行了必要的锁定。
操作I/O端口
当驱动程序请求了要使用的I/O端口范围后,必须读取或写入这些I/O端口。
为此,大多数硬件都会把8位、16位、32位的端口区分开来。
一般他们不能像访问系统内存那样使用。
因此,C语言必须调用不同的函数来访问大小不同的窗口。
有些只支持内存映射的I/O寄存器的计算机体系架构通过把I/O端口地址重新映射到内存地址来伪装端口I/O,并且为了易于移植,内核对驱动程序隐藏了这些细节。
linux内核头文件中定义了如下一些访问I/O端口的内联函数。
unsigned inb(unsigned port);
void outb(unsigned char byte, unsigned port);
/*读/写字节端口(8位宽)。
port参数某些平台定义为unsignedlong,有些为unsignedshort。
inb的返回类型也体系而不同。
*/
unsigned inw(unsigned port);
void outw(unsigned short word, unsigned port);
/*访问16位端口(一个字宽)*/
unsigned inl(unsigned port);
void outl(unsigned longword, unsigned port);
/*访问32位端口。
longword声明有的平台为unsignedlong,有的为unsignedint。
*/
注意这里没有定义64为I/O端口操作,即使在64位的体系结构上,端口地址空间也只适用最大32位的数据通路。
用户空间访问I/O端口
以上函数主要提供给设备驱动使用,但它们也可在用户空间使用,至少在PC上可以。
GNUC库在中定义了它们。
如果在用户空间代码中使用必须满足以下条件:
(1)程序必须使用-O选项编译来强制扩展内联函数。
(2)必须用ioperm和iopl系统调用(#include) 来获得对端口I/O操作的权限。
ioperm为获取单独端口操作权限,而iopl为整个I/O空间的操作权限。
(x86特有的)
(3)程序以root来调用ioperm 和iopl,或是其父进程必须以root获得端口操作权限。
(x86特有的)
若平台没有ioperm和iopl系统调用,用户空间可以仍然通过使用/dev/prot设备文件访问I/O端口。
注意:
这个文件的定义是体系相关的,并且I/O端口必须先被注册。
串操作
除了一次传输一个数据的I/O操作,一些处理器实现了一次传输一个数据序列的特殊指令,序列中的数据单位可以是字节、字或双字,这是所谓的串操作指令。
它们完成任务比一个C语言循环更快。
下列宏定义实现了串I/O,它们有的通过单个机器指令实现;但如果目标处理器没有进行串I/O的指令,则通过执行一个紧凑的循环实现。
有的体系的原型如下:
void insb(unsigned port, void *addr, unsigned long count);
void outsb(unsigned port, void *addr, unsigned long count);
void insw(unsigned port, void *addr, unsigned long count);
void outsw(unsigned port, void *addr, unsigned long count);
void insl(unsigned port, void *addr, unsigned long count);
void outsl(unsigned port, void *addr, unsigned long count);
使用时注意:
它们直接将字节流从端口中读取或写入。
当端口和主机系统有不同的字节序时,会导致不可预期的结果。
使用inw读取端口应在必要时自行转换字节序,以匹配主机字节序。
然而串函数不会完成这种交换。
暂停式 I/O
为了匹配低速外设的速度,有时若I/O指令后面还紧跟着另一个类似的I/O指令,就必须在I/O指令后面插入一个小延时。
在这种情况下,可以使用暂停式的I/O函数代替通常的I/O函数,它们的名字以_p结尾,如inb_p、outb_p等等。
这些函数定义被大部分体系支持,尽管它们常常被扩展为与非暂停式I/O同样的代码。
因为如果体系使用一个合理的现代外设总线,就没有必要额外暂停。
细节可参考平台的asm子目录的io.h文件。
以下是include\asm-arm\io.h中的宏定义:
#define outb_p(val,port) outb((val),(port))
#define outw_p(val,port) outw((val),(port))
#define outl_p(val,port) outl((val),(port))
#define inb_p(port) inb((port))
#define inw_p(port) inw((port))
#define inl_p(port) inl((port))
#define outsb_p(port,from,len) outsb(port,from,len)
#define outsw_p(port,from,len) outsw(port,from,len)
#define outsl_p(port,from,len) outsl(port,from,len)
#define insb_p(port,to,len) insb(port,to,len)
#define insw_p(port,to,len) insw(port,to,len)
#define insl_p(port,to,len) insl(port,to,len)
由此可见,由于ARM使用内部总线,就没有必要额外暂停,所以暂停式的I/O函数被扩展为与非暂停式I/O同样的代码。
平台相关性
由于自身的特性,I/O指令与处理器密切相关的,非常难以隐藏系统间的不同。
所以大部分的关于端口I/O的源码是平台依赖的。
以下是x86和ARM所使用函数的总结:
IA-32(x86)
x86_64
这个体系支持所有的以上描述的函数,端口号是unsignedshort类型。
ARM
端口映射到内存,支持所有函数。
串操作用C语言实现。
端口是unsignedint类型。
PowerPC和PowerPC64
支持所有函数,在32位系统上,端口类型为unsignedchar*,在64位系统上,端口类型为unsignedlong。
x86家族以外的处理器都不为端口提供独立的地址空间。
解惑-驱动开发中的I/O地址空间:
使用I/O内存
除了X86上普遍使用的I/O端口之外,和设备通信的另一种主要机制是通过使用映射到内存