几乎每种外设都通过读写寄存器来对它进行控制。大部分外设都有几个寄存器,无论是在内存地址空间还是在IO地址空间,这些寄存器的访问地址都是连续的。
Linux在所有的计算机平台上都实现了IO端口。一般 ISA
设备通常使用IO端口,而大多数 PCI
设备则把寄存器映射到谋个内存地址区段。这种IO内存的方式才是首选的方案,因为不需要特殊的处理器指令;而且 CPU
访问内存更有效率,同时在访问内存的时候,编译器在寄存器分配和寻址方式的选择上也有更多的自由。
尽管硬件寄存器和内存非常的相似,但是我们访问IO寄存器的时候,还是可能由于CPU或者编译器的不恰当的优化导致出现问题。
IO寄存器和RAM的主要区别就是IO操作具有边际效应,而内存操作则没有。
这里的 边际效应 是指:对IO寄存器的读写不仅会传递数据,还会直接触发硬件的物理行为或者改变其内部的工作状态。
而对RAM的读写只会修改存储单元的内容,不会触发硬件行为或设备状态变化。
由于内存访问速度对CPU的性能至关重要,而且也没有边际效应,所以可以使用多种方式进行优化,比如:使用高速缓存,指令重排等。
而对于驱动设备程序,不能使用这些高速缓存以及指令重排的优化。
由硬件自身缓存的问题比较容易解决:只要把底层硬件配置成在访问IO区域时禁止硬件缓存即可。
对于指令重排可以使用 内存屏障(memory barrier) 来解决。Linux 提供了4个宏来解决指令重排的问题:
#include
/*
这个函数通知编译器插入一个内存屏障,但是对硬件没有影响。编译后的代码会把当前CPU寄存器所有修改过的数值保存到内存中,需要
这些数据的时候再重新读出来。
*/
void barrier(void);
#include
/*
这些函数在已经编译的指令流中加入硬件内存屏障。
rmb(读内存屏障)保证了屏障之前的读操作一定会在之后的读操作先执行
wmb保证写操作不会乱序,mb指令保证读写操作都不会乱序。这些函数都是 barrier 的超集。
*/
void rmb(void);
void read_barrier_depends(void);
void wmb(void);
void mb(void);
/*
这些函数功能和上面的几乎一致,但是以下函数仅针对SMP系统编译时才有效。
*/
void smp_rmb(void);
void smp_read_barrier_depends(void);
void smp_wmb(void);
void smp_mb(void);
一般在设备驱动程序中,内存屏障的使用情形可能是如下:
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);
在上面的例子中,我们必须要确保设置 addr, size, operation
的操作,必须要在设置 control
的动作之前执行。
需要注意的是,大多数处理内核同步的原语,比如自旋锁和 atomic_t
操作,也能作为内存屏障使用。只是这些内核原语相比于内存屏障开销更大。
IO端口是驱动程序和许多设备之间的通信方式。
对于IO端口,我们需要向内核申请,申请成功才能使用。内核为我们提供了一个注册用的接口,它允许驱动程序声明自己需要操作的端口。
#include
struct resource *request_region(unsigned long first, unsigned long n, const char *name);
这个函数告诉内核,如果我们要申请始于 first
的 n
个端口,参数 name
应该是设备名。如果分配成功,返回非NULL
;如果返回 NULL
代表请求失败。
所有的端口分配可以从 /proc/ioports
中得到。
如果不再使用某组IO端口(一般在卸载模块的时候),则应该使用下面的函数将这些申请的端口返回给系统:
void release_region(unsigned long start, unsigned long n);
当设备驱动程序请求了要使用的IO端口的范围之后,必须读取或写入这些端口。为此,大多数硬件都会把8位,16位,32位端口区分开来。它们不能像访问系统内存那样混淆使用。
Linux内核提供了以下访问IO端口的内联函数:
/*
字节读写端口(8位)
*/
unsigned inb(unsigned port);
void outb(unsigned char byte, unsigned port);
/*
16位读写端口
*/
unsigned inw(unsigned port);
void outw(unsigned short word, unsigned port);
/*
32位读写端口
*/
unsigned inl(unsigned port);
void outl(unsigned longword, unsigned port);
上面的这些函数主要是提供给设备驱动程序使用的,但是它们也可以在用户空间使用。GNU的C库在
头文件中定义了这些函数。如果要在用户空间使用 inb
这一系列的函数,则必须:
-O
选项来强制内联函数展开;ioperm
或 iopl
系统调用来获得对IO端口进行IO操作的权限。ioperm
用来获得对单个端口的操作权限,而 iopl
用来获取对整个IO空间的操作权限。这两个函数都是 x86
平台特有的;root
权限来运行这个调用 ioperm
和 iopl
的程序;以上的IO操作都是一次传输单个数据。有的处理器上实现了一次传输一个数据序列(也就是一次传输多个数据)的特殊指令,序列中数据可以是字节(8位),字(16位)或者双字(32位) 。这些指令就叫做 串操作指令,串IO函数的原型如下:
/*
从内存地址 addr 开始连续读写count字节,适用于8位端口
*/
void insb(unsigned port, void *addr, unsigned long count);
void outsb(unsigned port, void *addr, unsigned long count);
/*
从内存地址 addr 开始连续读写coun个字,适用于16位端口
*/
void insw(unsigned port, void *addr, unsigned long count);
void outsw(unsigned port, void *addr, unsigned long count);
/*
从内存地址 addr 开始连续读写coun个双字,适用于32位端口
*/
void insl(unsigned port, void *addr, unsigned long count);
void outsl(unsigned port, void *addr, unsigned long count);
在使用串IO操作函数时,这些函数将字节流从端口中读出或者写入。因此,当端口和主机具有不同的字节序时,将会导致不可预期的后果。使用 inw
读取端口将在必要时交换字节,以便确保读入的值匹配于主机字节序。然而,串函数不会完成这种交换。
在处理器试图从总线上快速传输数据时,某些平台会(特别是 i386
平台)遇到问题。当处理器时钟比外设时钟快(比如 ISA
)时就会出现问题。解决方法是在每条IO指令之后,如果还有其他类似的指令,则插入一个小的延迟。在 x86
平台上,这种暂停可以通过对端口 0x80
的一条 out b
指令实现,或者通过使用忙等待实现。
如果有设备丢失数据的情况,或者为了防止出现丢失数据的情况,可以使用暂停式IO函数来替代普通的IO函数。这些暂停式IO函数类似于之前提到过的普通IO函数,区别是它们后面有 _p
的后缀,比如 inb_p, outb_p
。
我们用来演示设备驱动程序的端口IO的示例代码运行于通用的数字IO端口上,这种端口存在于大多数的计算机平台上。
数字IO端口最常见的形式是一个字节宽度的IO区域,它或者映射到内存,或者映射到端口。当把数值写入到输出区域时,输出引脚上的电平信号随着写入的各位而发生相应的变化。从输入区域读取到的数据则是输入引脚各位当前的逻辑电平值。
并口就是在我们在个人计算机上运行数字IO示例代码时选用的外设接口。
并口的最小配置(不涉及 ECP
和 EPP
模式) 由3个8位端口组成。PC标准中第一个并口的IO端口是从地址 0x378
开始,第二个端口是从 0x278
开始。第一个端口是一个双向的数据寄存器;它直接连接到物理连接器的 2 ~ 9
号引脚上。第二个端口是一个只读的状态寄存器;当并口连接到打印机时,该寄存器报告打印机的状态,如是否在线、缺纸、忙碌等状态。第三个端口是一个只用于输出的控制寄存器,它的作用之一是控制是否启用中断。
下面要介绍的驱动程序叫做 short
(Simple Hardware Operations and Raw Tests,简单的硬件操作和裸测试),它所做的就是读写几个8位的IO端口。默认情况下它使用的就是分配给PC并口的端口范围。每个设备节点(拥有唯一的次设备号)访问一个不同的端口。
为使 short
在系统上工作,它必须能自由的访问底层硬件设备(默认情况下就是并口),因此不能有其他驱动设备在使用同一设备。现在的大多数并口驱动程序作为模块安装,并且只在需要使用到的情况下才加载,所以一般不会发生争夺IO地址的问题。如果 short
给出一个 can't get I/O address
的错误,说明已经有其他驱动程序占用了这个端口。可以通过 /proc/ioports
一般可以找出这是哪个驱动程序在使用。
除了 x86
上普通使用的IO端口之外,和设备通信的另一种主要机制是通过使用映射到内存的寄存器或设备内存。这两种都称为IO内存,因为IO寄存器和内存的差别对软件是透明的。
IO内存仅仅是类似于RAM的一个区域,在这里处理器可以通过总线来访问设备。这种方式有很多使用场景,比如存放视频数据或者以太网数据包,也可以用来实现类似IO端口的设备寄存器。
根据计算机平台和所使用的总线的不同,IO内存可能是、也可能不是通过页表访问的。如果是经由页表访问的,内核必须首先安排物理地址使其对设备驱动程序可见(这通常意味着在进行任何IO操作之前必须先调用 ioremap
)。如果不需要访问页表,那么IO内存区域就类似于IO端口,可以直接使用相应的函数来读写他们。
不管访问IO内存时是否需要调用 ioremap
,都不鼓励直接使用直接指向IO内存的指针。
在使用之前必须首先分配IO内存区域。用于分配内存区域的函数接口在
头文件中:
struct resource *request_mem_region(unsigned long start, unsigned long len, char *name);
从 start
开始分配 len
字节的内存区域。如果成功返回指向分配区域的指针;否则返回 NULL
。
不再使用分配的内存,调用以下接口来释放资源:
void release_mem_region(unsigned long start, unsigned long len);
接着一旦调用 ioremap
函数,设备驱动程序即可访问任意的IO内存地址了,而无论IO内存地址是否直接映射到虚拟地址空间。但需要记住的是,由 ioremap
返回的地址不应该直接引用,而应该使用内核提供的 accessor
函数。
重新复习一下 ioremap
函数:
#include
void *ioremap(unsigned long phys_addr, unsigned long size);
void *ioremap_nocache(unsigned long phys_addr, unsigned long size);
void *iounmap(void *addr);
要从IO内存中读取,可以使用下列函数:
unsigned int ioread8(void *addr);
unsigned int ioread16(void *addr);
unsigned int ioread32(void *addr);
还有一组写入IO内存的类似函数:
void iowrite8(u8 value, void *addr);
void iowrite16(u16 value, void *addr);
void iowrite32(u32 value, void *addr);
如果必须在给定的IO内存地址读写一系列的值,则可以使用下列函数:
void ioread8_rep(void *addr, void *buf, unsigned long count);
void ioread16_rep(void *addr, void *buf, unsigned long count);
void ioread32_rep(void *addr, void *buf, unsigned long count);
void iowrite8_rep(void *addr, const void *buf, unsigned long count);
void iowrite16_rep(void *addr, const void *buf, unsigned long count);
void iowrite32_rep(void *addr, const void *buf, unsigned long count);
如果要在IO内存之上执行操作,可以使用下列函数:
void memset_io(void *addr, u8 value, unsigned int count);
void memcpy_fromio(void *dest, void *source, unsigned int count);
void memcpy_toio(void *dest, void *source, unsigned int count);
某些硬件具有一种有趣的特性:某些版本使用IO端口,而其他版本使用IO内存。为了让处理这类硬件的驱动程序更加易于编写,也为了最小化IO端口和内存访问之间的表面区别,内核提供了 ioport_map
函数:
void *ioport_remap(unsigned long port, unsigned int count);
该函数重新映射 count
个 IO端口,使其看起来像IO内存。此后,驱动程序可以在这个函数返回的地址上使用 ioread8
及其同类函数,这样就不用区分IO端口和IO内存之间的区别了。
当不再需要这种映射时,需要调用下面的函数来撤销:
void ioport_unmap(void *addr);
这些函数使得IO端口看起来像内存。但需要注意的是,在重新映射之前,我们必须通过 request_region
来申请分配这些IO端口。