Chinaunix首页 | 论坛 | 博客
  • 博客访问: 161856
  • 博文数量: 53
  • 博客积分: 2042
  • 博客等级: 大尉
  • 技术积分: 425
  • 用 户 组: 普通用户
  • 注册时间: 2010-01-15 21:39
文章存档

2011年(6)

2010年(47)

分类: LINUX

2010-06-02 21:37:53

尽管摆弄 scull 和其他一些玩具程式对理解 Linux 设备驱动程式的软件接口非常有帮助,但实现真正的设备还是要涉及实际硬件。设备驱动程式是软件概念和硬件电路之间的一个抽象层,因此,两者都需要谈谈。到目前为止,我们已周详讨论了软件上的一些细节;本章将完成另外一部分,介绍驱动程式是怎么在保持可移植性的前提下访问 I/O 端口和 I/O 内存的。
和前面相同,本章尽可能不针对特定设备。在需要示例的场合,我们使用简单的数字 I/O 端口(比如标准 PC 并口)来讲解 I/O 指令,使用普通的帧缓存显示内存来讲解内存映射 I/O。
我们选择使用并口是因为他是最简单的输入/输出端口。几乎所有的计算机上都有并口,并实现了原始的 I/O:写到设备的数据位出目前输出引脚上,而输入引脚的电压值能由处理器直接获取。实践中,我们必须将 LED 连接到并口上才能真正“看到”数字 I/O 操作的结果,相关底层硬件也非常容易使用。
8.1  I/O 端口和 I/O 内存
每种外设都通过读写寄存器进行控制。大部分外设都有几个寄存器,不管是在内存地址空间还是在 I/O 地址空间,这些寄存器的访问地址都是连续的。
在硬件级上,内存区域和 I/O 区域没有概念上的差别:他们都通过向地址总线和控制总线发送电平信号进行访问(比如读和写信号)*,再通过数据总线读写数据。
注:并非所有的计算机平台都使用读和写信号;有些使用不同的方式处理外部电路。不过这些差别对软件是无关的,为简化讨论,这里假定所有平台都用读和写信号。
一些 CPU 制造厂商在他们的芯片中使用单一地址空间,另一些则为外设保留了独立的地址空间以便和内存区分开来。一些处理器(主要是 x86 家族的)还为 I/O 端口的读和写使用分离的连线,并且使用特别的 CPU 指令访问端口。
因为外设要和外围总线相匹配,而最流行的 I/O 总线是基于个人计算机模型的,所以即使原本没有独立的 I/O 端口地址空间的处理器,在访问外设时也要虚拟成读写 I/O 端口。这通常是由外部芯片组或 CPU 核心中的附加电路来实现的。后一种方式只在嵌入式的微处理器中比较多见。
基于同样的原因,Linux 在所有的计算机平台上都实现了 I/O 端口,包括使用单一地址空间的 CPU 在内。端口操作的具体实现则依赖于宿主计算机的特定模型和制造了(因为不同的模型使用不同的芯片组把总线操作映射到内存地址空间)。
即使外设总线为 I/O 端口保留了分离的地址空间,也不是所有设备都会把寄存器映射到 I/O 端口。ISA 设备普遍使用 I/O 端口,大多数 PCI 设备则把寄存器映射到某个内存地址区段。这种 I/O 内存通常是最佳选择方案,因为不必特别的处理器指令;而且 CPU 核心访问内存更有效率,访问内存时,编译器在寄存器分配和寻址方式选择上也有更多的自由。
8.1.1  I/O 寄存器和常规内存
尽管硬件寄存器和内存非常相似,程式员在访问 I/O 寄存器的时候必须注意避免由于 CPU 或编译器不恰当的优化而改动预期的 I/O 动作。
I/O 寄存器和 RAM 的最主要差别就是 I/O 操作具有边际效应,而内存操作则没有:内存写操作的唯一结果就是在指定位置存储一个数值;内存读操作则仅仅返回指定位置最后一次写入的数值。由于内存访问速度对 CPU 的性能至关重要,而且也没有边际效应,所以可用多种方法进行优化,如使用高速缓存保存数值,重新排序读/写指令等。
编译器能够将数值缓存在 CPU 寄存器中而不写入内存,即使存储数据,读写操作也都能在高速缓存中进行而不用访问物理 RAM。无论在编译器一级或是硬件一级,指令的重新排序都有可能发生:一个指令序列如果以不同于程式文本中的次序运行常常能执行得更快,例如在防止 RISC 处理器流水线的互锁时就是如此。在 CISC 处理器上,耗时的操作则能和运行较快的操作并发执行。
在对常规内存进行这些优化的时候,优化过程是透明的,而且效果良好(至少在单处理器系统上是这样)。但对 I/O 操作来说这些优化非常可能造成致命的错误,因为他们会干扰“边际效应”,而这却是驱动程式访问 I/O 寄存器的主要目的。处理器无法预料到某些其他进程(在另一个处理器上运行,或在某个 I/O 控制器中)是否会依赖于内存访问的顺序。因此驱动程式必须确保不会使用高速缓存,并且在访问寄存器时不会发生读或写指令的重新排序:编译器或 CPU 可能会自作聪明地重新排序所需求的操作,结果是发生奇怪的错误,并且非常难调试。
由硬件自身缓存引起的问题非常好解决:底层硬件设置成(能是自动的或是由 Linux 初始化代码完成)访问 I/O 区域时(不管是内存还是端口)禁止硬件缓存就行了。
由编译器优化和硬件重新排序引起的问题的解决办法是,在从硬件角度看必须以特定顺序执行的操作之间设置内存屏障。Linux 提供了4个宏来解决所有可能的排序问题。
#include  
void barrier(void)
这个函数通知编译器插入一个内存屏障,但对硬件无效。编译后的代码会把当前 CPU 寄存器中的所有修改过的数值存到内存,需要这些数据的时候再重新读出来。
#include  
void rmb(void);
void wmb(void);
void mb(void);
这些函数在已编译的指令流中插入硬件内存屏障;具体的插入方法是平台相关的。rmb(读内存屏障)确保了屏障之前的读操作一定会在后来的读操作执行之前完成。wmb 确保写操作不会乱序,mb 指令确保了两者都不会。这些函数都是 barrier 的超集。
设备驱动程式中使用内存屏障的典型格式如下:
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);
在这个例子中,最重要的是要确保控制某特定操作的所有设备寄存器一定要在操作开始之前正确设置。其中的内存屏障会强制写操作以必需的次序完成。
因为内存屏障会影响系统性能,所以应该只用于真正需要的地方。不同类型的内存屏障影响性能的方面也不同,所以最佳尽可能使用针对需要的特定类型。例如在当前的 x86 体系结构上,由于处理器之外的写不会重新排序,wmb 就没什么用。可是读会重新排序,所以 mb 就会比 wmb 慢一些。
注意其他大多数的处理同步的内核原语,如 spinlock 和 atomic_t 操作,也能作为内存屏障使用。
在有些体系结构上允许把赋值语句和内存屏障进行合并以提高效率。2.4 版本内核提供了几个执行这种合并的宏;他们默认情况下定义如下:
#define set_mb(var, value)  do {var = value; mb();}  while 0
#define set_wmb(var, value) do {var = value; wmb();} while 0
#define set_rmb(var, value) do {var = value; rmb();} while 0
在适当的地方, 中定义的这些宏能利用体系结构特有的指令更快地完成任务。
头文件 sysdep.h 中定义了本节介绍的这些宏,可供缺少这些宏的平台和内核版本使用。
8.2  使用 I/O 端口
I/O 端口是驱动程式和许多设备的之间通信方式――至少在部分时间是这样。本节讲解了使用 I/O 端口的不同函数,另外也涉及到一些可移植性问题。
我们先回忆一下,I/O 端口必须先分配,然后才能由驱动程式使用。这在第 2 章的“I/O 端口 和 I/O 内存”一节已讨论过了,用来分配和释放端口的函数是:
#include  
int check_region(unsigned long start, unsigned long len);
struct resource *request_region(unsigned long start,
      unsigned long len, char *name);
void release_region(unsigned long start, unsigned long len);
驱动程式请求了需要使用的 I/O 端口范围后,他必须读并且/或写这些端口。为此,大多数硬件都把 8 位、16 位和 32 位的端口区分开来。他们不能象访问系统内存那样混淆*。因此,C 语言程式必须调用不同的函数来访问大小不同的端口。如前一节所述,那些只支持映射到内存的 I/O 寄存器的计算机体系结构通过把 I/O 端口地址重新映射到内存地址来模拟端口 I/O,并且为了易于移植,内核对驱动程式隐藏了这些细节。Linux 内核头文件中(就在和体系结构相关的头文件  中)定义了如下一些访问 I/O 端口的内联函数。
注:有时 I/O 端口是和内存相同对待的,(例如)能将 2 个 8 位的操作合并成一个 16 位的操作。例如,PC 的显示卡就能,但一般来说不能认为一定具有这种特性。
警告:从目前开始,如果我使用 unsigned 而不进一步指定类型信息的话,那么就是在谈及一个和体系结构相关的定义,此时不必关心他的准确特性。这些函数基本是可移植的,因为编译器在赋值时会自动进行强制类型转换 (cast)--强制转换成 unsigned 类型防止了编译时出现的警告信息。只要程式员赋值时注意避免溢出,这种强制类型转换就不会丢失信息。在本章剩余部分将会一直保持这种“不完整的类型定义”的方式。
unsigned inb(unsigned port);
void outb(unsigned char byte, unsigned port);
按字节( 8 位宽度)读写端口。port 参数在一些平台上定义为 unsigned long,而在另一些平台上定义为 unsigned short。不同平台上 inb 返回值的类型也不相同。
unsigned inw(unsigned port);
void outw(unsigned short word, unsigned port);
这些函数用于访问 16 位端口(“字宽度”);不能用于 M68k 或 S390 平台,因为这些平台只支持字节宽度的 I/O 操作。
unsigned inl(unsigned port);
void outl(unsigned longword, unsigned port);
这些函数用于访问 32 位端口。longword 参数根据不同平台定义成 unsigned long 类型或 unsigned int 类型。和字宽度 I/O 相同,“长字”I/O 在 M68k 和 S390 平台上也不能用。
注意这里没有定义 64 位的 I/O 操作。即使在 64 位的体系结构上,端口地址空间也只使用最大 32 位的数据通路。
上面这些函数主要是提供给设备驱动程式使用的,但他们也能在用户空间使用,至少在 PC 类计算机上能使用。GNU 的 C 库在  中定义了这些函数。如果要在用户空间代码中使用 inb 及其相关函数,必须满足下面这些条件:

  • 编译该程式时必须带 -O 选项来强制内联函数的展开。
  • 必须用 ioperm 或 iopl 来获取对端口进行I/O操作的许可。ioperm 用来获取对单个端口的操作许可,而 iopl 用来获取对整个 I/O 空间的操作许可。这两个函数都是 Intel 平台特有的。
  • 必须以 root 身份运行该程式才能调用 ioperm 或 iopl*。或,该程式的某个祖先已以 root 身份获取了对端口操作的权限。

注:从技术上说,必须有 CAP_SYS_RAWIO 的权能,不过这和在当前系统以 root 身份运行是相同的。
如果宿主平台没有 ioperm 和 iopl 系统调用,用户空间程式仍然能使用 /dev/port 设备文件访问 I/O 端口。不过要注意,该设备文件的含义和平台的相关性是非常强的,并且除 PC 上以外,他几乎没有用处。
示例程式 misc-progs/inp.c 和 misc-progs/outp.c 是在用户空间通过命令行读写端口的一个小工具。他们会以多个名字安装(如 inpb、inpw,inpl)并且按用户调用的名字分别操作字节端口、字端口或双字端口。如果没有 ioperm,他们就使用 /dev/port。
如果想冒险,能将他们设置上 SUID 位,这样,不用显式地获取特权就能使用硬件了。
8.2.1  串操作
以上的 I/O 操作都是一次传输一个数据,作为补充,有些处理器上实现了一次传输一个数据序列的特别指令,序列中的数据单位能是字节、字或双字。这些指令称为串操作指令,他们执行这些任务时比一个 C 语言写的循环语句快得多。下面列出的宏实现了串 I/O ,他们或使用一条机器指令实现,或在没有串 I/O 指令的平台上使用紧凑循环实现。M68k 和 S390 平台上没有定义这些宏。这不会影响可移植性,因为这些平台通常不会和其他平台使用同样的设备驱动程式,他们的外设总线不同。
串 I/O 函数的原型如下:
void insb(unsigned port, void *addr, unsigned long count);
void outsb(unsigned port, void *addr, unsigned long count);
从内存地址 addr 开始连续读写 count 数目的字节。只对单一端口 port 读取或写入数据。
void insw(unsigned port, void *addr, unsigned long count);
void outsw(unsigned port, void *addr, unsigned long count);
对一个16 位端口读写 16 位数据。
void insl(unsigned port, void *addr, unsigned long count);
void outsl(unsigned port, void *addr, unsigned long count);
对一个 32 位端口读写 32 位数据。
8.2.2  暂停式 I/O
某些平台,特别是 i386 平台上,当处理器和总线之间的数据传输太快时会引起问题。因为相对于 ISA 总线,处理器的时钟频率太快,当设备板卡速度太慢时,这个问题就会暴露出来。解决方法是,如果一条I/O 指令后还跟着另一条 I/O 指令,就在两条指令间插入一小段延迟。如果有设备丢失数据的情况,或为了防止设备可能会丢失数据的情况,能使用暂停式的 I/O 函数来取代通常的 I/O 函数。这些暂停式的 I/O 函数非常象前面已列出的那些 I/O 函数,不同之处是他们的名字用 _p 结尾;如 inb_p,outb_p,等等。在 Linux 支持的大多数平台上都定义了这些函数,不过他们常常扩展为和非暂停式 I/O 同样的代码,因为如果某种体系结构不使用过时的外设总线,就不必额外的暂停。
8.2.3  平台相关性
由于自身的特性,I/O 指令是和处理器密切相关的。因为他们的工作涉及到处理器移入移出数据的细节,所以隐藏平台间的差异非常困难。因此,大部分和I/O端口有关的原始码都和平台相关。
回头看看前面的函数列表,能看到一处不兼容的地方:数据类型。函数的参数类型根据各平台体系结构上的不同要相应地使用不同的数据类型。例如,port 参数在 x86 平台(处理器只支持 64KB 字节的 I/O 空间)上定义为 unsigned short,但在其他平台上定义为 unsigned long。在那些平台上端口是和内存在同一地址空间内的一些特定区域。
其他一些和平台相关的问题来源于处理器基本结构上的差异,因此也无法避免。因为本书假定读者不会在不了解底层硬件的情况下为特定的系统编写驱动程式,所以不会周详讨论这些差异。下面是内核 2.4 版本支持的体系结构能使用的函数的总结:
IA-32 (x86)
该体系结构支持本章提到的所有函数。端口号的类型是 unsigned short。
IA-64 (Itanium)
支持所有函数;端口类型是unsigned long(映射到内存)。串操作函数是用 C 语言实现的。
Alpha
支持所有函数,而 I/O 端口是映射到内存的。基于不同的 Alpha 平台上使用的芯片组的不同,端口 I/O 操作的实现也有所不同。串操作是用 C 语言实现的,在文件 arch/alpha/lib/io.c 中定义。端口类型是 unsigned long。
ARM
端口映射到内存,支持所有函数;串操作用 C 语言实现。端口类型是 unsigned int。
M68k
端口映射到内存,只支持字节类型的函数。不支持串操作,端口类型是 unsigned  char *。
MIPS
MIPS64
MIPS 端口支持所有函数。因为该处理器不提供机器一级的串 I/O 操作,所以串操作是用汇编语言写的紧凑循环(tight loop)实现的。端口映射到内存;端口类型在 32 位处理器上是 unsigned int,在 64 位处理器上是 unsigned long。
PowerPC
支持所有函数;端口类型为 unsigned char *。
S390
类似于 M68k,该平台的头文件只支持字节宽度的端口 I/O,不支持串操作。端口类型是字符型(char)指针,映射到内存。
Super-H
端口类型是 unsigned int(映射到内存),支持所有函数。
SPARC
SPARC64
和前面相同,I/O 空间映射到内存。端口操作函数的 port 参数类型是 unsigned  long。
感兴趣的读者能从 io.h 文件获得更多信息,除了在本章介绍的函数,一些和体系结构相关的函数有时也由该文件定义。不过要注意这些文件阅读起来会比较困难。
值得提及的是,x86 家族之外的处理器都不为端口提供不同的地址空间,尽管使用其中几种处理器的机器带有 ISA 和 PCI 插槽(两种总线都实现了不同的 I/O 和内存地址空间)。
除此以外,一些处理器(特别是早期的 Alpha 处理器)没有一次传输 1 或 2 个字节的指令*。因此,他们的外设芯片通过把端口映射到内存地址空间的特别地址范围来模拟 8 位和 16 位的 I/O 访问。这样,对同一个端口的 inb 和 inw 指令实现为两个 32 位的读不同内存地址的操作。幸好,本章前面介绍的宏的内部实现对驱动程式研发人员隐藏了这些细节,不过这个特点还是非常有趣的。想进一步深入的读者能看 include/asm-alpha/core_lca.h 中的例子。
注:单字节 I/O 操作并没有想象中那么重要,因为这种操作非常少发生。为了读写任意地址空间的单个字节,需要实现一条从寄存器组数据总线低位到外部数据总线任意字节地址的数据通路。这种数据通路在每一次数据传输中都需要额外的逻辑门。不使用这类字节宽度的存取指令能提升系统总体性能。
I/O 操作在各个平台上执行的细节在对应平台的编程手册中有周详的叙述;也可从 Web 上下载这些手册的 PDF 文件。
8.3  使用数字 I/O 端口
我们用来演示设备驱动程式的端口 I/O 的示例代码工作于通用的数字 I/O 端口上;这种端口在大多数计算机平台上都能找到。
数字 I/O 端口最普通的形式是个字节宽度的 I/O 区域,他或映射到内存,或映射到端口。当数值写入到输出区域时,输出引脚上的电平信号随着写入的各位发生相应变化。从输入区域读到的数据则是输入引脚各位当前的逻辑电平值。
这类 I/O 端口的具体实现和软件接口是因系统而异的。大多数情况下,I/O 引脚是由两个 I/O 区域控制的:一个区域中能选择用于输入和输出的引脚,另一个区域中能读写实际逻辑电平。不过有时候情况简单些,每个位不是输入就是输出(不过在这种情况下不能再称为“通用 I/O”了);所有个人计算机上都能找到的并口就是这样的非通用的 I/O 端口。我们随后介绍的示例代码要用到这些 I/O 引脚。
8.3.1  并口简介
因为假定大多数读者使用的都是称为“个人计算机”的 x86 平台,所以解释一下 PC 并口的设计是必要的。并口也是在个人计算机上运行的数字 I/O 示例代码选用的外设接口。尽管许多读者可能已有了并口规格说明,为了方便还是在这里概括一下。
并口的最小设置(不涉及 ECP 和 EPP 模式)由 3 个 8 位端口组成。PC 标准中第一个并口的 I/O 端口是从地址 0x378 开始,第二个端口是从地址 0x278 开始。第一个端口是个双向的数据寄存器;他直接连接到物理插口的 2 到 9 号引脚上。第二个端口是个只读的状态寄存器;当并口连接到打印机时,该寄存器报告打印机的状态,如是否在线、缺纸、正忙等等。第三个端口是个只用于输出的控制寄存器,他的作用之一是控制是否打开中断。
在并行通信中使用的电平信号是标准的 TTL 电平:0伏和5伏,逻辑阈值大约为 1.2 伏;端口需求至少满足标准的TTL LS电流规格,而现代的大部分并口电流和电压都超过这个规格。
技巧:并口插座没有和计算机的内部电路隔离,这一点在试图把逻辑门直接连到端口时非常有用。但要注意正确连线;否则在测试自己制定的电路时,并口非常容易被烧毁。如果担心会破坏主板的话,能选用可插拔的并行接口。
位规范显示在图8-1中。能读写 12 个输出位和 5 个输入位,其中一些位在他们的信号通路上会有逻辑上的翻转。唯一一个不和所有信号引脚有联系的位是 2 号端口的第 4 位(0x10),他打开来自并口的中断。我们将在第 9 章“中断处理”中的一个中断处理程式实现中使用到他。


图 8-1:并口的插线引脚
8.3.2  示例驱动程式
下面要介绍的驱动程式叫做 short(Simple Hardware Operations and Raw Tests,简单硬件的操作和原始测试)。他所做的就是读写几个 8 位端口,其中第一个是加载时选定的。默认情况下他使用的就是分配给 PC 并口的端口范围。每个设备节点(拥有唯一的次设备号)访问一个不同的端口。short 设备没有所有实际用途,使用他只是为了能用一条指令来从外部对端口进行操作。如果读者不太了解端口 I/O,那么能通过使用 short 来熟悉他,能测量他传输数据时消耗的时间或进行其他的测试。
为使 short 在系统上工作,他必须能自由地访问底层硬件设备(默认情况就是并口),因此不能有其他驱动程式在使用同一设备。目前的大多数 Linux 发布版本将并口驱动程式作为模块安装,并且只在需要用到的时候才加载,所以一般不会发生争夺 I/O 地址的问题。不过,如果 short 给出一个“can’t get I/O address,无法获得 I/O 地址”错误(可能在控制台或系统日志文件中)的话,说明可能已有其他驱动程式占用了这个端口。通过检查 /proc/ioports 一般能找出这是哪个驱动程式。这种情况相同适用于并口之外的其他 I/O 设备。
从目前开始,为简化讨论,我们所指的设备都是并口。不过也能在模块加载时通过设置参数 base 把 short 重定向到其他 I/O 设备。这样示例代码能在所有拥有对数字 I/O 接口访问权限的 Linux 平台上运行,这些接口必须是能用 outb 和 inb 进行访问的(尽管实际硬件在除 x86 的所有平台上都是映射到内存的)。在随后的“使用 I/O 内存”中,我们还将展示 short 是怎么用于通用的映射到内存的数字 I/O 的。
为了观察并口插座上发生了什么,并且如果读者喜欢操作硬件,那么能焊几个 LED 到输出引脚上。每个LED都要串联一个1KΩ的电阻到一个接地的引脚上(除非使用的 LED 已有内建电阻)。如果将输出引脚接到输入引脚上,就能产生自己的输入供输入端口读取。
注意不能仅仅通过把打印机连到并口来观察送给 short 的数据。因为这个驱动程式只实现了简单的 I/O 端口访问,不能提供打印机操作数据时所需的握手信号。
如果读者想将 LED 焊到 D 型插座上来观察并行数据,建议不要使用 9 号和 10 号引脚,因为在运行第 9 章的示例代码时我们要连上他们。
至于 short ,他通过 /dev/short0 读写位于 I/O 地址 base(除非加载时修改,否则就是 0x378)的8 位端口。/dev/short1 写位于 base+1 的 8 位端口,依此类推,直到 base+7。
/dev/short0 实际执行的输出操作是个使用 outb 的紧凑循环。这里还使用了内存屏障指令来确保输出操作会实际执行而不是被优化掉。
while (count--) {
   outb(*(ptr++), address);
   wmb();
}
能运行下面的命令来使 LED 发光:
echo  -n "any string"  > /dev/short0
每个 LED 监视输出端口的一个位。注意只有最后写的字符数据才会在输出引脚上稳定地保持下来而被观察到。因此,建议将 -n 选项传给 echo 程式来制止输出字符后的自动换行。
读端口也是使用类似的函数,只是用 inb 代替了outb。为了从并口读取“有意义的”值,需要将某个硬件连到并口插座的输入引脚上来产生信号。如果没有输入信号,只会读到始终是相同字节的无穷输出流。如果选择从输出端口读入,将会取回写到该端口的最后一个值(对并口和其他大多数普通数字 I/O 电路都是如此)。因此,不想摆弄烙铁的读者能运行下面的命令在端口 0x378 读取当前的输出值:
dd if=/dev/short0 bs=1 count=1 | od -t x1
为了示范所有 I/O 指令的使用,每个 short 设备都提供了 3 个变种:/dev/short0 执行的是上面的循环;/dev/short0p 使用了 outb_p 和 inb_p 来替代前者使用的“较快的”函数,/dev/short0s 使用串指令。这样的设备共有 8 个,从 short0 到 short7。PC 并口只有三个端口,如果读者使用了其他不同的 I/O 设备进行测试,就可能需要更多的端口。
虽然 short 驱动程式只完成了最低限度的硬件控制,但这对演示 I/O 端口指令的使用已足够了。感兴趣的读者能去看 parport 和 parport_pc 两个模块的源码,看看实际上为支持使用并口的设备(打印机、磁带备份,网络接口)所需的复杂工作。
8.4  使用 I/O 内存
除了 x86 上普遍使用的 I/O 端口,和设备通信的另一种主要机制是通过使用映射到内存的寄存器或设备内存。这两种都称为 I/O 内存,因为寄存器和内存的差别对软件是透明的。
I/O 内存仅仅是类似 RAM 的一个区域,在那里处理器能通过总线访问设备。这种内存有非常多用途,比如存放视频数据或网络包;这些用设备寄存器也能实现,其行为类似于 I/O 端口(比如,读写时有边际效应)。
访问 I/O 内存的方法和计算机体系结构、总线,及设备是否正在使用有关,不过原理都是相同的。本章主要讨论 ISA 和 PCI 内存,同时也试着介绍一些通用的知识。尽管这里介绍了 PCI 内存的访问,但关于 PCI 的周详讨论将放到第 15 章中进行。
根据计算机平台和所使用总线的不同,I/O 内存可能是,也可能不是通过页表访问的。如果访问是经由页表进行的,内核必须首先安排物理地址使其对设备驱动程式可见(这通常意味着在进行所有 I/O 之前必须先调用 ioremap)。如果访问无需页表,那么 I/O 内存区域就非常象 I/O 端口,能使用适当形式的函数读写他们。
不管访问 I/O 内存时是否需要调用 ioremap,都不鼓励直接使用指向 I/O 内存的指针。尽管(在“I/O 端口和 I/O 内存”介绍过)I/O 内存在硬件一级是象普通 RAM 相同寻址的,但在“I/O 寄存器和常规内存”中描述过的那些需要额外小心的情况中已建议不要使用普通指针。相反,使用“包装的”函数访问 I/O 内存,一方面在所有平台上都是安全的,另一方面,在能直接对指针指向的内存区域执行操作的时候,该函数是经过优化的。
因此,即使在 x86 上直接使用指针(目前)能工作(而不是使用正确的宏),这种做法也会影响驱动程式的可移植性和可读性。
在第 2 章中说过,设备内存区域在使用前必须先分配。这和 I/O 端口注册过程类似,是由下列函数完成的:
int check_mem_region(unsigned long start, unsigned long len);
void request_mem_region(unsigned long start, unsigned long len,
char *name);
void release_mem_region(unsigned long start, unsigned long len);
传给函数的 start 参数是内存区的物理地址,此时还没有发生所有重映射。这些函数通常的使用方式如下:
if (check_mem_region(mem_addr, mem_size)) {
   printk("drivername: memory already in use\n");
   return -EBUSY;
}
   request_mem_region(mem_addr, mem_size, "drivername");
   [...]
   release_mem_region(mem_addr, mem_size);
8.4.1  直接映射的内存
几种计算机平台上保留了部分内存地址空间留给 I/O 区域,并且自动禁止对该内存范围内的所有(虚拟)地址进行内存管理。
用在个人数字助理(PDA)中的 MIPS 处理器就是这种设置的一个有趣的实例。两个各为 512 MB 的地址段直接映射到物理地址,对这些地址范围内的所有内存访问都绕过 MMU,也绕过缓存。这些 512 MB 地址段中的一部分是为外设保留的,驱动程式能用这些无缓存的地址范围直接访问设备的 I/O 内存。
其他平台使用另外的方式提供直接映射的地址段:有些使用特别的地址空间来解析物理地址(例如,SPARC64 就使用了一个特别的“地址空间标识符”),更有一些则使用虚拟地址,这些虚拟地址被设置成访问时绕过处理器缓存。
当需要访问直接映射的 I/O 内存区时,仍然不应该直接使用 I/O 指针指向的地址――即使在某些体系结构这么做也能正常工作。为了编写出的代码在各种系统和内核版本都能工作,应该避免使用直接访问的方式,而代之以下列函数。
unsigned readb(address);
unsigned readw(address);
unsigned readl(address);
这些宏用来从 I/O 内存接收 8 位、16位和32位的数据。使用宏的好处是不用考虑参数的类型:参数 address 是在使用前才强制转换的,因为这个值“不清晰是整数还是指针,所以两者都要接收”(摘自 asm-alpha/io.h)。读函数和写函数都不会检查参数 address 是否合法,因为这在解析指针指向区域的同时就能知道(我们已知道有时他们确实扩展成指针的反引用操作)。
void writeb(unsigned value, address);
void writew(unsigned value, address);
void writel(unsigned value, address);
类似前面的函数,这些函数(宏)用来写 8 位、16位和32位的数据。
memset_io(address, value, count);
当需要在 I/O 内存上调用 memset 时,这个函数能满足需要,同时他保持了原来的 memset 的语义。
memcpy_fromio(dest, source, num);
memcpy_toio(dest, source, num);
这两个函数用来和 I/O 内存交换成块的数据,功能类似于 C 库函数 memcpy。
在较新的内核版本中,这些函数在所有体系结构中都是可用的。当然具体实现会有不同:在一些平台上是扩展成指针操作的宏,在另一些平台上是真正的函数。不过作为驱动程式研发人员,不必关心他们具体是怎样工作的,只要会用就行了。
一些 64 位平台还提供了 readq 和 writeq 用于 PCI 总线上的 4 字(8 字节)内存操作。这个 4 字(quad-word)的命名是个历史遗留问题,那时候所有的处理器都只有 16 位的字。实际上,目前把 32 位的数值命名为 L(长字)已是不正确的了,不过如果对所有东西都重新命名,只会把事情搞得更复杂。
8.4.2  在 short 中使用 I/O 内存
前面介绍的 short 示例模块访问的是 I/O 端口,他也能访问 I/O 内存。为此必须在加载时通知他使用 I/O 内存,另外还要修改 base 的地址以使其指向 I/O 区域。
例如,我们用下列命令在一块 MIPS 研发板上点亮调试用的 LED:
mips.root# ./short_load use_mem=1 base=0xb7ffffc0
mips.root# echo -n 7 > /dev/short0
在 short 中使用 I/O 内存和使用 I/O 端口是相同的;不过,因为没有给 I/O 内存使用的暂停式指令和串操作指令,所以访问 /dev/short0p 和 /dev/short0s 时,操作和 /dev/short0 是相同的。
下列片段显示了 short 写内存区域时使用的循环:
while (count--) {
   writeb(*(ptr++), address);
   wmb();
}
注意这里用了写内存屏障。因为在许多体系结构上 writeb 会转化成一个直接赋值语句,为确保写操作按照预想顺序执行,使用内存屏障是必要的。
8.4.3  通过软件映射的 I/O 内存
尽管 MIPS 类的处理器使用直接映射的 I/O 内存,但这种方式在目前的平台中是相当少见的;特别是当使用外设总线处理映射到内存的设备时更是如此。
使用 I/O 内存时最普遍的硬件和软件处理方式是这样的:设备对应于某些约定的物理地址,不过 CPU 并没有预先定义访问他们的虚拟地址。这些约定的物理地址能是硬连接到设备上的,也能是在启动时由系统固件(如 BIOS)指定的。前一种的例子有 ISA 设备,他的地址或是固化在设备的逻辑电路中,因而已在局部设备内存中静态赋值,或是通过物理跳线设置;后一种的例子有 PCI 设备,他的地址是由系统软件赋值并写入设备内存的,只在设备加电时才存在。
不管哪种方式,为了让软件能访问 I/O 内存,必须有一种把虚拟地址赋于设备的方法。这个任务是由 ioremap 函数完成的,我们在“vmalloc 和相关函数”中已有介绍。这个函数因为和内存的使用相关,所以已在前面的章节中讲解过了,他就是为了把虚拟地址指定到 I/O 内存区域而专门设计的。此外,由内核研发人员实现的 ioremap 在用于直接映射的 I/O 地址时不起所有作用。
一旦有了 ioremap 和 iounmap ,设备驱动程式就能访问所有 I/O 内存地址,而不管他是否直接映射到虚拟地址空间。不过要记住,这些地址不能直接引用,而应该使用象 readb 这样的函数。这样,在设置了 use_mem 参数时,通过在 short 模块中使用 ioremap/iounmap 调用,就能让 short 既能在 MIPS 的 I/O 内存方式下工作,也能在更普通的 ISA/PCI x86 I/O 内存方式下工作。
在示范 short 怎么调用这些函数之前,先复习一下函数的原型,同时介绍一些在前面章节中忽略的细节。
这些函数定义如下:
#include  
void *ioremap(unsigned long phys_addr, unsigned long size);
void *ioremap_nocache(unsigned long phys_addr, unsigned long size);
void iounmap(void * addr);
首先,注意新函数 ioremap_nocache。第 7 章中没有具体讲解他,因为他的含义是和硬件相关的。引用内核中的一个头文件的描述:“如果有某些控制寄存器在这个区域,并且不希望发生写操作合并或读缓存的话,能使用他。”实际上,在大多数计算机平台上这个函数的实现和 ioremap 是完全相同的:因为在所有 I/O 内存都已能通过非缓存地址访问的情况下,就不必实现一个独立的,非缓存的 ioremap 了。
ioremap 的另一个重要特点是在内核 2.0 中他的行为和后来内核中的不同。在 Linux 2.0 中,该函数(那时称为 vremap)不能映射所有没有对齐页边界的内存区。这是个明智的选择,因为在 CPU 一级所有操作都是以页面大小的粒度进行的。不过,有时候需要映射小的 I/O 寄存器区域,而这些寄存器的(物理)地址不是按页面对齐的。为适应这种新需求,内核 2.1.131 及后续版本中允许重映射未对齐的地址。
short 模块为了保持和 2.0 的兼容,同时为了能够访问非页面对齐的寄存器,没有直接调用 ioremap,而是使用了下列代码:
/* Remap a not (necessarily) aligned port region */
void *short_remap(unsigned long phys_addr)
{
   /* The code comes mainly from arch/any/mm/ioremap.c */
   unsigned long offset, last_addr, size;
   last_addr = phys_addr + SHORT_NR_PORTS - 1;
   offset = phys_addr & ~PAGE_MASK;
   
   /* Adjust the begin and end to remap a full page */
   phys_addr &= PAGE_MASK;
   size = PAGE_ALIGN(last_addr) - phys_addr;
   return ioremap(phys_addr, size) + offset;
}
/* Unmap a region obtained with short_remap */
void short_unmap(void *virt_add)
{
   iounmap((void *)((unsigned long)virt_add & PAGE_MASK));
}
8.4.4  1M地址空间之下的ISA内存
最广为人知的 I/O 内存区之一就是个人计算机上的 ISA 内存段。他的内存范围在 640(0xA0000)KB 到 1(0x100000)MB 之间。因此他正好出目前常规系统 RAM 的中间。这种地址安排看上去可能有点奇怪;因为这个设计决策是 80 年代早期作出的,在当时看来没有人会用到 640 KB 以上的内存。
这个内存段属于非直接映射一类的内存*。能利用 short 模块在该内存段中读写几个字节,前面介绍过,在加载模块时要设置 use_mem 标志。
注:实际并非完全如此。因为该内存段非常小而且使用频繁,所以内核在启动时就建立了访问这些地址的页表。不过,访问他们使用的虚拟地址和实际物理地址并不相同,所以无论怎么都是要使用 ioremap 的。另外,内核 2.0 对该地址段是直接映射的,见"向后兼容"和 2.0 版本相关的部分。
尽管 ISA I/O 内存只存在于 x86 类的计算机上,我们还是介绍一下,并附以一个示例程式。
本章不讨论 PCI 内存,因为他是 I/O 内存中最“干净”的一种:只要知道了物理地址,就能简单地重映射并访问他。PCI I/O 内存的“问题”在于,他不适合于用作本章的工作示例,因为无法预先知道 PCI 内存会映射到哪一段物理地址,也就不知道访问这些地址段是否安全。这里选择讲解 ISA 内存段,是因为他不那么“干净”,更适合运行示例代码。
为了示范对 ISA 内存的访问,我们要用到另一个有点“愚笨”的小模块(是示例源码的一部分)。实际上这个模块就叫作 silly,是“Simple Tool for Unloading and Printing ISA Data,卸载及打印 ISA 数据的简单工具”的简称。
这个模块补充了 short 的功能,他能访问整个 384 KB 的内存空间,还演示了所有不同的 I/O 函数。该模块包括四个用了不同的数据传输函数来完成相同任务的设备节点。silly 设备就象 I/O 内存之上的一个窗口,和 /dev/mem 的工作有些类似。对该设备能读、写数据或 lseek 到一个任意的 I/O 内存地址。
因为 silly 提供对 ISA 内存的访问,所以启动他时必须把物理 ISA 地址映射到内核虚拟地址中。在较早的 Linux 内核中,只需简单地把要用的 ISA 地址赋值给一个指针,然后直接解析他就能了。但在目前的内核中,必须配合虚拟内存系统工作,首先重新映射该地址段。这种映射是由 ioremap 完成的,这在前面讲解 short 时已介绍过了:
#define ISA_BASE    0xA0000
#define ISA_MAX    0x100000  /* for general memory access */
   /* this line appears in silly_init */
   io_base = ioremap(ISA_BASE, ISA_MAX - ISA_BASE);
ioremap 返回一个指针值,以供 readb 或其他在“直接映射的内存”一节中介绍的函数使用。
目前回头看看示例代码中这些函数是怎么使用的。/dev/sillyb 的次设备号是 0,通过 readb 和 writeb 访问 I/O 内存。下面代码展示了读操作的实现,其中地址段 0xA0000-0xFFFFF 作为 0-0x5FFFF 段的一个虚拟文件对待。read 函数中包括一个 switch 语句来处理不同的访问模式。这里是 sillyb 的 case 语句:
case M_8:
while (count) {
     *ptr = readb(add);
     add++; count--; ptr++;
}
break;
下面的两个设备是 /dev/sillyw(次设备号为 1)和 /dev/sillyl(次设备号为 2)。他们和 /dev/sillyb 差不多,只不过分别使用了 16 位和 32 位的函数。下面是 sillyl 的 write 的实现,是 switch 语句中的一部分:
case M_32:
while (count >= 4) {
     writel(*(u32 *)ptr, add);
     add+=4; count-=4; ptr+=4;
}
break;
最后一个设备是 /dev/sillycp(次设备号为 3),他使用 memcpy_*io 函数完成相同任务。他的 read 实现的核心部分如下:
case M_memcpy:
memcpy_fromio(ptr, add, count);
break;
因为使用了 ioremap 来提供对 ISA 内存区的访问,silly 模块卸载时必须调用 iounmap:
iounmap(io_base);
8.4.5  isa_readb 及相关函数
看看内核原始码,能发现一组函数,他们的名字类似于 isa_readb。实际上,上面描述的每个函数都有一个等价的以?isa_?开头的函数。这些函数提供了一种不必独立的 ioremap 步骤就能访问 ISA 内存的方法。不过内核研发人员解释说,这些函数只是暂时性的,用于帮助移植驱动程式,将来他们会消失。所以,最佳避免使用这些函数。
8.4.6  探测 ISA 内存
尽管目前的大多数设备都是基于更好的 I/O 总线结构的,比如 PCI,不过有时程式员还是得对付 ISA 设备和他们的 I/O 内存,所以我们为此花些篇幅。我们不涉及高端的 ISA 内存(称为 memory hole,内存洞,在 14 MB 到 16 MB 的物理地址段中),因为目前那种 I/O 内存已极其少见,而且目前主流的主板和内核都不支持他了。为访问这种 I/O 内存段需要修改内核初始化代码,所以这里不再讨论了。
当使用内存映射的 ISA 设备时,驱动程式研发人员常常会忽略对应的 I/O 内存在物理地址空间的位置,因为实际地址通常是由用户从一个可能的地址范围中分配的。否则检查一个指定地址上是否存在设备就非常简单了。
内存资源管理设置是有助于内存探测的,因为他能识别已由其他设备使用的内存区段。不过,资源管理器不能分辨哪些设备的驱动程式已加载,或一个给定的区域是否包含有你感兴趣的设备。虽然如此,在实际探测内存、检查地址内容时他仍然是必需的。可能会遇见 3 种截然不同的情况:映射到目标地址上的是 RAM,或是 ROM(例如 VGA BIOS),或该区域是空闲的。
skull 示例代码示范了处理这些内存的一种方法,由于 skull 和所有物理设备都不相关,他只是打印出 640 KB 到 1 MB 内存段的信息,然后就退出了。不过其中用来分析内存的代码是值得描述一下的,他示范了怎么进行内存探测。
检查 RAM 段的代码使用 cli 关闭了中断,因为这些内存段只能通过物理地写入数据随后重新读出的方法才能识别,而在测试过程中,真正 RAM 中的内容可能被中断处理程式修改。下列的代码并不总是正确,因为如果一个设备正在写他自己的内存段,同时测试代码又正在扫描这个区段,测试程式就会误认为该板卡的 RAM 内存段是个空的区段。不过,这种情况并不常见。
unsigned char oldval, newval; /* values read from memory   */
unsigned long flags;          /* used to hold system flags */
unsigned long add, i;
void *base;
   
/* Use ioremap to get a handle on our region */
base = ioremap(ISA_REGION_BEGIN, ISA_REGION_END - ISA_REGION_BEGIN);
base -= ISA_REGION_BEGIN;  /* Do the offset once */
   
/* probe all the memory hole in 2-KB steps */
for (add = ISA_REGION_BEGIN; add
只要注意恢复探测内存时修改的字节的原始值,这种探测并不会造成和其他设备的冲突。要注意的是,写入另一个设备的内存可能会引发该设备的一些不可预测的动作。一般情况下,只要有可能,应该尽量避免使用这种探测内存的方法,但在处理旧设备时经常不得不这样做。
8.5  向后兼容性
幸好,在基本硬件的访问方面变化非常少。编写向后兼容的驱动程式时只需要记住有限的几点就行了。
硬件内存屏障在内核 2.0 版本是没有的。那时支持的平台上,不必这类处理指令排序的功能。通过在驱动程式中包含 sysdep.h 头文件能修正这个问题,他把硬件屏障定义为和软件屏障相同。
类似的,在旧内核中并不是所有的端口访问函数(inb 和相关函数)在所有体系结构上都能支持。特别是串操作指令,常常没有。我们没有在 sysdep.h 中提供这些函数:这不是个容易完成的任务,而且也不太值得,因为这些函数依赖于具体的硬件。
在 Linux 2.0 中,ioremap 和 iounmap 分别称为 vremap 和 vfree。参数和功能则完全相同。因此,通常把这两个函数定义成映射到旧的对应函数就行了。
不幸的是,尽管 vremap 在提供对“高端”内存(如 PCI 卡上的内存)的访问上和 ioremap 别无二致,他却不能重映射 ISA 内存段。在以前,对该内存段的访问是通过直接使用指针完成的,所以不必重映射该地址空间。因此,一个更完整的 x86 平台、Linux 2.0 上实现 ioremap 的解决方法如下:
extern inline void *ioremap(unsigned long phys_addr, unsigned long size)
{
   if (phys_addr >= 0xA0000 && phys_addr + size = 0xA0000
           && (unsigned long)addr
如果在驱动程式中包含了 sysdep.h 头文件,就能使用 ioremap 了,即使在访问 ISA 内存时也不会出问题。
内存区段的分配(check_mem_region 及相关函数)是在内核 2.3.17 引入的。在 2.0 和 2.2 内核没有这种内存分配的工具。如果包含了 sysdep.h 头文件,就能随意使用这些宏了,因为在 2.0 和 2.2 上编译时,这三个宏是空的。
8.6  快速参考
本章引入下列和操纵硬件有关的符号:
#include  
void barrier(void)
这个“软件”内存屏障需求编译器考虑执行到该指令时相关的所有内存中的变化。
#include  
void rmb(void);
void wmb(void);
void mb(void);
硬件内存屏障。需求 CPU(和编译器)执行该指令时检查所有必须的内存读、写(或二者兼有)已执行完毕。
#include  
unsigned inb(unsigned port);
void outb(unsigned char byte, unsigned port);
unsigned inw(unsigned port);
void outw(unsigned short word, unsigned port);
unsigned inl(unsigned port);
void outl(unsigned doubleword, unsigned port);
这些函数读写 I/O 端口。如果用户空间的程式有访问端口的权限,也能调用这些函数。
unsigned inb_p(unsigned port);
...
有时候需要用到 SLOW_DOWN_IO 来处理 x86 平台上的低速 ISA 板卡。如果 I/O 操作之后需要一小段延时,能用上面介绍的函数的 6 个暂停式的变体。这些暂停式的函数都以 _p 结尾。
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);
这些“串操作函数”为输入端口和内存区之间的数据传输做了优化。这类传输是通过对同一端口连续读写 count 次实现的。
#include  
int check_region(unsigned long start, unsigned long len);
void request_region(unsigned long start, unsigned long len, char *name);
void release_region(unsigned long start, unsigned long len);
为 I/O 端口分配资源的函数。check 函数在成功时返回 0,出错时返回负值。
int check_mem_region(unsigned long start, unsigned long len);
void request_mem_region(unsigned long start, unsigned long len, char *name);
void release_mem_region(unsigned long start, unsigned long len);
这些函数处理对内存区的资源分配。
#include  
void *ioremap(unsigned long phys_addr, unsigned long size);
void *ioremap_nocache(unsigned long phys_addr, unsigned long size);
void iounmap(void *virt_addr);
ioremap 把一个物理地址段重新映射到处理器的虚拟地址空间,以供内核使用。iounmap 用来解除这个映射。
#include  
unsigned readb(address);
unsigned readw(address);
unsigned readl(address);
void writeb(unsigned value, address);
void writew(unsigned value, address);
void writel(unsigned value, address);
memset_io(address, value, count);
memcpy_fromio(dest, source, nbytes);
memcpy_toio(dest, source, nbytes);
用这些函数能访问 I/O 内存区,包括低端的 ISA 内存和高端的 PCI 缓冲区。
阅读(752) | 评论(0) | 转发(0) |
给主人留下些什么吧!~~