这些小活动你都参加了吗?快来围观一下吧!>>
电子产品世界 » 论坛首页 » 嵌入式开发 » MCU » [求助]关于内存映射函数

共5条 1/1 1 跳转至

[求助]关于内存映射函数

菜鸟
2007-03-20 00:08:08     打赏

请教各位高手!由于参考一个Linux系统下的驱动程序,不明白其中关于内存映射的一个函数

ioremap_nocache()的意思,

知情者能解释一下参数的意义吗?先谢过了!




关键词: 求助     关于     内存     映射     函数    

院士
2007-03-20 00:33:00     打赏
2楼

看看这个~~~~

LINUX 硬件管理 嵌入式 Linux-uClinux 内核技术
第 8 章 硬件管理


尽管摆弄 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 <linux/kernel.h>
void barrier(void)
这个函数通知编译器插入一个内存屏障,但对硬件无效。编译后的代码会把当前 CPU 寄存器中的所有修改过的数值存到内存,需要这些数据的时候再重新读出来。

#include <asm/system.h>
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

在适当的地方,<asm/system.h> 中定义的这些宏可以利用体系结构特有的指令更快地完成任务。

头文件 sysdep.h 中定义了本节介绍的这些宏,可供缺少这些宏的平台和内核版本使用。

8.2 使用 I/O 端口
I/O 端口是驱动程序与许多设备的之间通信方式――至少在部分时间是这样。本节讲解了使用 I/O 端口的不同函数,另外也涉及到一些可移植性问题。

我们先回忆一下,I/O 端口必须先分配,然后才能由驱动程序使用。这在第 2 章的“I/O 端口 和 I/O 内存”一节已经讨论过了,用来分配和释放端口的函数是:

#include <linux/ioport.h>
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 内核头文件中(就在与体系结构相关的头文件 <asm/io.h> 中)定义了如下一些访问 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 库在 <sys/io.h> 中定义了这些函数。如果要在用户空间代码中使用 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 <asm/io.h>
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 < ISA_REGION_END; add += STEP) {
/*
* Check for an already allocated region.
*/
if (check_mem_region (add, 2048)) {
printk(KERN_INFO "%lx: Allocated\n", add);
continue;
}
/*
* Read and write the beginning of the region and see what happens.
*/
save_flags(flags);
cli();
oldval = readb (base + add); /* Read a byte */
writeb (oldval^0xff, base + add);
mb();
newval = readb (base + add);
writeb (oldval, base + add);
restore_flags(flags);

if ((oldval^newval) == 0xff) { /* we reread our change: it's RAM */
printk(KERN_INFO "%lx: RAM\n", add);
continue;
}
if ((oldval^newval) != 0) { /* random bits changed: it's empty */
printk(KERN_INFO "%lx: empty\n", add);
continue;
}

/*
* Expansion ROM (executed at boot time by the BIOS)
* has a signature where the first byte is 0x55, the second 0xaa,
* and the third byte indicates the size of such ROM
*/
if ( (oldval == 0x55) && (readb (base + add + 1) == 0xaa)) {
int size = 512 * readb (base + add + 2);
printk(KERN_INFO "%lx: Expansion ROM, %i bytes\n",
add, size);
add += (size & ~2048) - 2048; /* skip it */
continue;
}

/*
* If the tests above failed, we still don't know if it is ROM or
* empty. Since empty memory can appear as 0x00, 0xff, or the low
* address byte, we must probe multiple bytes: if at least one of
* them is different from these three values, then this is ROM
* (though not boot ROM).
*/
printk(KERN_INFO "%lx: ", add);
for (i=0; i<5; i++) {
unsigned long radd = add + 57*(i+1); /* a "random" value */
unsigned char val = readb (base + radd);
if (val && val != 0xFF && val != ((unsigned long) radd&0xFF))
break;
}
printk("%s\n", i==5 ? "empty" : "ROM");
}

只要注意恢复探测内存时修改的字节的原始值,这种探测并不会造成和其它设备的冲突。要注意的是,写入另一个设备的内存可能会引发该设备的一些不可预测的动作。一般情况下,只要有可能,应该尽量避免使用这种探测内存的方法,但在处理旧设备时经常不得不这样做。

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 <= 0x100000)
return (void *)phys_addr;
return vremap(phys_addr, size);
}

extern inline void iounmap(void *addr)
{
if ((unsigned long)addr >= 0xA0000
&& (unsigned long)addr < 0x100000)
return;
vfree(addr);
}

如果在驱动程序中包含了 sysdep.h 头文件,就可以使用 ioremap 了,即使在访问 ISA 内存时也不会出问题。


内存区段的分配(check_mem_region 及相关函数)是在内核 2.3.17 引入的。在 2.0 和 2.2 内核没有这种内存分配的工具。如果包含了 sysdep.h 头文件,就可以随意使用这些宏了,因为在 2.0 和 2.2 上编译时,这三个宏是空的。

8.6 快速参考
本章引入下列与操纵硬件有关的符号:

#include <linux/kernel.h>
void barrier(void)
这个“软件”内存屏障要求编译器考虑执行到该指令时相关的所有内存中的变化。

#include <asm/system.h>
void rmb(void);
void wmb(void);
void mb(void);
硬件内存屏障。要求 CPU(和编译器)执行该指令时检查所有必须的内存读、写(或二者兼有)已经执行完毕。

#include <asm/io.h>
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 <linux/ioport.h>
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 <asm/io.h>
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 <linux/io.h>
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 缓冲区。



[align=right][color=#000066][此贴子已经被作者于2007-3-19 16:33:32编辑过][/color][/align]

院士
2007-03-20 00:34:00     打赏
3楼
有点长~~~~~

菜鸟
2007-03-21 04:22:00     打赏
4楼

呵呵,确实有点长,LS的能帮忙简化一下吗?

[align=right][color=#000066][此贴子已经被作者于2007-3-20 20:24:07编辑过][/color][/align]

菜鸟
2016-09-21 14:25:56     打赏
5楼

说的很好,这是什么书啊,大家知道吗


共5条 1/1 1 跳转至

回复

匿名不能发帖!请先 [ 登陆 注册 ]