本文译自Dan Saks的专栏文章。
内存映射I/O是能用标准C/C++做得相当好的一个东东。
设备驱动通过设备寄存器来与外围设备通信。驱动通过向设备寄存器中写入命令或者数据,或者通过读取设备寄存器来读取设备状态或者数据。
许多处理器使用内存映射I/O,就是把设备的寄存器映射到常规内存空间的固定地址。对于一个C/C++程序员来说,内存映射设备的寄存器看起来非常像一个普通的数据对象。程序可以使用普通的赋值操作符来向内存映射设备的寄存器读取或写入值。
有些处理器使用端口映射I/O,就是把设备的寄存器映射到一个单独的地址空间,这个地址空间一般来说要比常规内存小。在这些处理器上,程序必须使用特殊的机器指令来向内存映射设备的寄存器读取或写入值,比如在intel x86上使用in和out指令。对于一个C/C++程序员来说,端口映射的设备寄存器就不怎么像普通数据对象了。
C/C++的标准没有阐述端口映射I/O。执行端口映射I/O的程序必须使用不标准的,与特定平台相关的语言或者库扩展,或者更糟糕的是用汇编代码。另一方面,内存映射I/O是能用标准C/C++做得相当好的一个技术。
这篇文章将讨论不同的方法来访问内存映射设备的寄存器。
设备寄存器类型
有些设备寄存器可能只有一个字节,其他的可能有一个字或者更多。在C/C++中,对于一个设备寄存器最简单的表示就是一个大小合适的,有符号的整型对象。例如,你可能将一个单字节的寄存器声明为char或者将一个双字节的寄存器声明为unsigned short。
例如,单板机ARM Evaluator-7T有一个小类映射内存外设。该板子的文档将设备寄存器引用为特殊寄存器。这些特殊的寄存器占有64k,从地址0x03FF0000开始。这段内存是基于字节寻址的,但是每个寄存器都是四字节的字,并且按照4字节对齐。可以把每个特殊寄存器当作一个int或unsigned int来操作。有些程序员更喜欢使用指定了物理类型大小的类型,如int32_t或uint32_t。(类似int32_t和uint32_t的类型定义在C99的头文件中)
笔者喜欢使用能表达类型的意义而不是物理长度的象征类型(symbolic type ),如:
typedef unsigned int special_register;
特殊寄存器实际上是volatile实体——这种实体可以用编译器无法检测到的方式改变自己的状态。因此typedef应该是一个volatile修饰的类型的别名:
typedef unsigned int volatile special_register;
许多设备通过一个小的设备寄存器集合来交互,而不是仅仅用一个。例如,Evaluator-7T板使用5个特殊寄存器来控制两个集成定时器:
* TMOD:时间模式寄存器
* TDATA0:定时器0数据寄存器
* TDATA1: 定时器1数据寄存器
* TCNT0: 定时器0计数寄存器
* TCNT1: 定时器1计数寄存器
可以用一个如下定义的结构体来表示这些定时器寄存器:
typedef struct dual_timers dual_timers;
struct dual_timers
{
special_register TMOD;
special_register TDATA0;
special_register TDATA1;
special_register TCNT0;
special_register TCNT1;
};//译注:注意此处顺序,每个成员大小及对齐,因为后续的访问依赖于此。
在struct定义前的typedef使得dual_timers由一个不完整的类型变成了一个完整的类型。笔者更愿意使用count0来标示TCNT0,但是TCNT0是整个产品文档所使用的名字,因此最好不要改变它。
在C++中,笔者将该struct定义为一个有恰当的成员函数的class。无论dual_timers是C的struct还是C++的class不影响下面的讨论。
安置设备寄存器
一些编译器提供了语言的扩展,这些扩展使你可以把一个对象放在在一个特定的内存地址。如:使用TASKING C166/ST10 C交叉编译器的_at属性就可以写出下面的全局声明:
unsigned short count _at(0xFF08);
这用于把count声明为放置在0xFF08上的内存映射设备寄存器。其他的编译器提供#pragma指示来做相同的事。然而,_at属性和#pragma指示不是标准的。每个有类似扩展的编译器更可能支持一些不同的东西。 标准C/C++不会让你声明一个放置在特定地址上的变量。访问设备寄存器的一个通用习惯是用指针,该指针的值就是寄存器的地址。如:Evaluator-7T板上的定时器寄存器放置在地址0x03FF6000。程序可以通过指向该地址的指针访问这些寄存器。可以定义这样的指针为一个宏:
#define timers ((dual_timers *)0x03FF6000)
或者定义为一个const指针:
dual_timers *const timers = (dual_timers *)0x03FF6000;
//译注:此处可以参考我的文章《指针是通往地狱的捷径》
用其中任一种方式来定义定时器,就能够使用它来访问定时器寄存器。如:TMOD寄存器含有允许激活和禁用定时器的位,可以设置或者清除该位来达到目的。因此可以用枚举为这些位定义一些掩码:
enum { TE0 = 0x01, TE1 = 0x08 };
同时禁用这两个定时器
timers->TMOD &= ~(TE0 | TE1);
比较两者
这两个关于指针的定义——宏和const对象——很大程度上是可以互换的。然而,它们在行为上有少许不同,而且在某些平台上会产生少许不同的机器码。
笔者在先前的一个专栏里解释过,预处理器是一个截然不同的编译阶段。预处理器在编译器做其它符号处理之前执行宏替换。例如,给定timers的宏定义,预处理器把
timers->TMOD &= ~(TE0 | TE1);
翻译成:
((dual_timers *)0x03FF6000)->TMOD &= ~(TE0 | TE1);
其后的编译阶段永远都看不到timers的符号宏(dual_timers),他们仅仅看到替换后的文本。许多编译器不会将宏名传递给他们的调试器,这样宏名在调试器中是不可见的。
使用宏还会带来一个更严重的问题:宏名不遵守作用域规则。例如,不能将一个宏限制在一个局部的作用域。在函数中定义宏:
void timer_handler()
{
#define timers ((dual_timers *)0x03FF6000)
...
}
不能使该宏成为局部宏。该宏的作用于是全局的。同样,不能把一个宏定义为C++类或者名字空间的一个成员。
实际上,宏名比全局名字还要糟糕。内层域的名字会暂时覆盖掉外层的名字,但是不能覆盖掉宏名。从而导致在你不希望发生替换的时候,宏替换发生了。
把timers声明为一个const指针能避免上述问题。该名字在调试器中可见,不会有作用域的问题。另一方面,使用某些平台的某些编译器,这样的声明可能——笔者强调“可能”——会使代码变得稍微慢点,程序大小稍微大点。将该指针定义为全局的或者局部的可能会导致编译器生成不同的代码。编译在C中而不是C++中的定义可能会生成不同的代码。笔者将在下一篇专栏文章中解释为什么。
Dan Saks Saks is president of Saks & Associates, a C/C++ training and consulting
company. You can write to him at .
译注:这篇文章介绍了端口映射I/O和内存映射I/O的概念及区别,以及如何访问位于特定地址上的寄存器。好像与UNIX系统上的mmap()无关。因此该文章更适合于写驱动的朋友参考。
阅读(1043) | 评论(0) | 转发(0) |