现在的位置: 首页 > 综合 > 正文

C语言之volatile【整理】

2013年11月12日 ⁄ 综合 ⁄ 共 8023字 ⁄ 字号 评论关闭

 

根据c/c++语法,const可以出现的地方,volatile几乎也都可以出现。但是,const修饰的对象其值不能改变,而volatile修饰的对象其值可以随意地改变,也就是说,volatile对象值可能会改变,即使没有任何代码去改变它。在这一点上,最典型的例子就是内存映射的设备寄存器和多线程中的共享对象。懂得使用volatile也是一门小小的艺术。使用volatile约束符可以阻止编译器对代码过分优化防止出现一些你意想不到的情况,达不到预期的结果;过频地使用volatile很可能会增加代码尺寸和降低性能。下面举个例子来说明volatile在优化中的微妙作用。
1.阻止编译器优化
  ARM Evaluator-7T模拟单机板使用基于内存映射的设备寄存器叫特殊寄存器,用来
控制和交互外围设备。CPU对内存的操作可以做到按位进行,而特殊寄存器是4字节对齐并占四个字节。你可以象unsigned int变量一样操作特殊寄存器(有些人可能更喜欢uint32_t,认为这样体现寄存器占用4个字节的特点。uint32_t在C99 头文件<stdint.h>中有定义)。而这里,为了体现寄存器本身作为寄存器的含义而非它的物理意义的,我们做如下定义:
typedef uint32_t special_register;

  Evaluator-7T板子上有一个按钮(可以认为是外设之一)。按下该按钮可以对IOPDATA寄存器第8位置1,相反,释放按钮会将该位重新清0。我们使用枚举方法为IOPDATA寄存器的第8位置定义一个掩码mask:
enum { button = 0x100 };

IOPDATA寄存器对应的地址为0x3FF5008,我们可以用宏形象地定义IOPDATA:

#define IOPDATA (*(special_register *)0x03FF5008)

有了这个定义,我们执行下面的循环就可以使CPU一直等待该按钮被按下:
while ((IOPDATA & button) == 0)
    ;

  然而这个期望必须建立在编译器不对代码进行优化的前提假设之上。如果编译器优化这段代码,那么它会认为在这个循环中没有什么会改变IOPDATA而且认为条件判断结果总是真或假,最终优化的结果是只对(IOPDATA & button)==0判断一次,之后的循环都不在对其进行判断,其等同于:
if ((IOPDATA & button) == 0)
    for (;;)
        ;

  显然,如果条件判断结果为真(那么之后都会认为是真),那么这段代码将会陷入死循环。如果判断为假,那么循环就此结束。可以看出,优化的代码效率更高,因为每次循环相比原来的执行时间要短。不幸的是,这段优化代码使得它根本就不能响应按钮的每次动作。那么,如何解决这个问题呢?解决的关键就是不要让编译器优化这段代码,使用volatile就可以办到这一点。我们修改前面关于IOPDATA的宏定义:
#define IOPDATA (*(special_register volatile *)0x03FF5008)

这个定义将IOPDATA 定义为volatile类型的寄存器。volatile隐含地告诉编译器特殊寄存器可能会改变内容,即使没有任何显式地代码去改变它的内容。这样一来,编译器就不对IOPDATA作优化,而是每次都去访问IOPDATA,这其实正是我们所期望的。

2.无意中降低了效率
  有时候,如果不注意的话,使用volatile会无意中降低代码效率。举个例子。Evaluator-7T有一个七段数码显示器。
  在IOPDATA 寄存器中第10到16位用来控制显示器的每一段。比如第10位就是用来控制顶部的那段显示,置1则点亮它,清0则熄灭它。我们可以定义一个掩码mask来覆盖从第10到16的所有位:
enum { display = 0x1FC00 };
假设变量b用来控制这7段显示器的每一段显示,并且b的值已经你想要设置值(准备用来显示哪几段和熄灭哪几段,其它无关的位均为0)。那么你想要改变设置新的显示方式的操作就是:
IOPDATA = b;
但是这种赋值可能会改变第10到16位之外的其它位,这是我们不期望的。所以,采用下面的方法更好:
IOPDATA |= b
但是,使用 |= 并不能熄灭那些已经点亮的显示段(1 | 0 -> 1),所以我们可以用下面的函数达到目的:
void display_put(uint32_t b)
{
    IOPDATA &= ~display;    /*熄灭所有的段*/
    IOPDATA |= b;        /*点亮想要的段*/
}

  不过,可能没想到的是这样的操作在无意中降低了代码效率。因为我们定义IOPDATA为
volatile类型,它阻止了编译器对代码的优化,要求任何读写IOPDATA的操作都死死板板地进行。IOPDATA &= ~display的等价表现为IOPDATA = IOPDATA & ~display,也就是先从IOPDATA读出内容然后与上~display,最后又回写IOPDATA。同理,IOPDATA |=b也有相似的过程。整个过程分别有2次读IOPDATA和2次写IOPDATA的操作。如果IOPDATA不使用volatile,那么编译器会要求将IOPDATA & ~display的结果放在CPU寄存器中,直到完成IOPDATA |= b操作才写回特殊寄存器IOPDATA。显然后者较之前者分别省掉了1次读IOPDATA和1次I写OPDATA的耗时操作(外设操作是最耗时的),效率要高很多。如果你想使用volatile但又能使能优化功能,你可以将函数作如下的修改:

void display_put(uint32_t b)
{
    register uint32_t temp = IOPDATA;/*定义局部变量*/
    temp &= ~display;         /*读取IOPDATA内容到temp*/
    temp |= b;              /*将temp内容或上b*/
    IOPDATA = temp;          /*将结果写回IOPDATA*/
}

这样做有点烦琐,下面的等效方法更简单:
void display_put(uint32_t b)
{
    IOPDATA = (IOPDATA & ~display) | b;
}

结论:从该例子看出,它并不鼓励使用volatile,即使要用也要很小心,因为volatile可能在无意中降低了代码效率,而你却无法察觉。但是,我们说,不鼓励并不是说就不能或不要用,而是要懂得何时用,怎么用好它。其所谓智用了。

在上文中提到,volatile定义的对象其内容可能会忽然的变化。换句话讲,如果你定义了一个volatile对象,就等于你告诉编译器该对象的内容可能会改变,即使代码中没有任何语句去改变该对象。编译器访问非volatile对象和volatile对象的方式很不一样。对于前者(经优化后),它先将非volatile对象的内容读到CPU寄存器中,等操作CPU寄存器一段时间后,才最终将CPU寄存器的内容写回volatile对象。然而,对于volatile对象就没有这种优化操作。这时候编译器有些“笨”,代码要求它读取或写入volatile,它就马上如实地去做。前一篇《慎重使用》主要讲述如何明智地正确使用volatile,本篇文章通过一些实际应用进一步阐述volatile在解决嵌入式问题中的一些微妙作用并继续深入探讨使用volatile要注意的一些细节问题。
 
1.构造内存映射的设备寄存器
  许多处理器使用内存映射的I/O设备。这些设备将寄存器映射到普通内存空间的某些固定地址。这些基于内存映射的设备寄存器看起来与一般的数据对象没啥两样。在《慎重使用》中提到ARM Evaluator-7T 的特殊寄存器的定义为:
typedef uint32_t special_register;
在嵌入式应用中,许多设备有时候不仅仅与一个寄存器打交道,有时可能与多个寄存器的集合同时打交道。在Evaluator-7T板子上,串口UART就是一个很好的例子。在这个板子上有两个UART,UART0和UART1。每个UART都由6个特殊寄存器控制。我们可以通过一个数据结构来表示这些寄存器的集合:
 
注意:数据结构UART和标识符UART的不同使用方法和位置。
 
typedef struct UART UART;
struct UART
{
  special_register ULCON;
  special_register UCON;    /*控制*/
  special_register USTAT;     /*状态*/
  special_register UTXBUF;    /*发送缓冲*/
  special_register URXBUF;    /*接收缓冲*/
  special_register UBRDIV;   
};
UART0对应的特殊寄存器被映射到0x03FFD000。我们有两种方法来访问该寄存器,一种是《智用篇》中提到过的宏定义方法:
#define UART0 ((UART *)0x03FFD000)
另一种是通过常量指针:
UART *const UART0  = (UART *) 0x03FFD000;
 
2.使用volatile
  《慎重使用》提到,如果你不希望编译器对你的代码作优化以防止出现你预想不到的情况,那么使用volatile是不二之选。显然,要访问串口的设备寄存器,我们必须要关掉编译器优化。现在,volatile可以大显身手了。我们修改前面的定义为:
#define UART0 ((UART volatile *) 0x03FFD000)
或:
UART volatile *const UART0  = (UART *) 0x03FFD000;
如果使用后者(常量指针),就建议做强制转化:
UART volatile *const UART0  = (UART volatile *)0x03FFD000;
但这并不是必须。对于任意类型T,c编译器提供T指针到volatile T指针的标准内置转化,就如同T指针到const T指针的转化,整个过程自动完成。另外,将一个对象定义为volatile类型,那么该对象中的所有成员也将成为volatile类型。显然,在UART0前面加volatile类型,不可避免在其它地方也必须要加上volatile。
比如,我们有下面的函数实现串口的字符串输出:
void put(char const *s, UART *u);
如果UART0是指向UART对象的volatile指针,那么如下调用会有什么问题呢:
put("hello, world/n", UART0);  
编译出错通不过!因为编译器不会将volatile UART指针转化为UART指针,所以我们能做的就是将其强制转化:/*UART == struct UART*/
put("hello, world/n", (UART *)UART0);/*volatile UART -> UART*/
这个强制转化虽然骗过了编译器,但在运行态(run time)可能会出问题。因为这时编译器将volatile类型UART0当做非volatile类型使用。为了避免这个缺陷,可以这样声明:
void put(char const *s, UART volatile *u);

注意:在这里加了volatile之后,在其它相关的地方别忘了也要加上volatile!
 
2.准确地构造寄存器
  先看下面对UART0的声明:UART volatile*const UART0 = ...;
这种添加volatile的同时还添加const的做法有下面微妙的隐含功能:UART结构本身并不是volatile的,这个声明使得UART0指向一个volatile类型的UART常量对象。然而,其它的串口比如UART1有可能不是定义成volatile类型(有可能将UART1定义成UART类型)。除非系统确实有这样区分的需要,否则这种不一致并不是值得提倡的编程风格。解决这种问题的方法之一就是将volatile引入到UART类型:
typedef struct UART volatile UART;
有些人可能更愿意这么定义:
typedef volatile struct UART UART;
但我本人推荐将const/volatile放到类型右侧的定义风格(即前者的风格)。使用上面的定义,我们不用担心哪里是否遗漏了volatile。另外,UART0的定义也修正为:
#define UART0 ((UART *)0x03FFD000)

UART *const UART0  = (UART *) 0x03FFD000;
而put函数也修正为:
void put(char const *s, UART *u);
这时的UART已是volatile类型。如果UART1定义成UART类型,那么显然它也是volatile类型。先打住,假如有人将UART1定义为struct UART呢?:
struct UART *const UART1  = (struct UART *) 0x03FF...;
哎呀,没错!我们遗漏了有人可能用struct UART 定义UART1的可能,这种定义使得
对UART1的访问还是非volatile方式。到此,我们可以看出将UART 定义为 volatile struct UART 并不能防止有人做出不恰当或不统一的定义。所以,想从根本上解决这种不一致的问题,就只能这么定义:
typedef struct  
{  
  special_register ULCON;
  special_register UCON;     /*控制*/
  special_register USTAT;      /*状态*/
  special_register UTXBUF;     /*发送缓冲*/
  special_register URXBUF;     /*接收缓冲*/
  special_register UBRDIV;
} volatile UART;
这样使得任何使用UART的地方都是volatile类型的。
或:
struct UART
{   
  special_register volatile ULCON;
  special_register volatile UCON;    /*控制*/
  special_register volatile USTAT;     /*状态*/
  special_register volatile UTXBUF;   /*发送缓冲*/
  special_register volatile URXBUF;    /*接收缓冲*/
  special_register volatile UBRDIV;
};/*UART结构每个成员都是volatile 类型*/
虽然我们用上面的方法解决UART结构类型和struct UART类型的不统一,但可以看出special_register不是固有的volatile类型,所以在别的地方,特殊积存器可能不是volatile类型(有人可能不需要用UART来定义寄存器组,他要的只是用special_register定义单个寄存器)。为了从根本上彻底解决这种潜在问题,需要将special_register
作如下定义:
typedef uint32_t volatile special_register;
 
这样一来,不论你定义寄存器组还是单个寄存器都是volatile类型的!
 
总结:本篇文章始终围绕设备寄存器定义,就volatile到底该用在什么地方,该用在什么位置展开深入的分析讨论,最终得到将special_register定义为volatile类型是嵌入式应用中最理想的设计。

上文主要探讨关于volatile在定义设备寄存器时应该放到什么位置最合适的问题。另外,在文章中也提到下面两个观点:
*对任意数据类型T,C提供一种标准内置的转换。这个转化可以完成从T指针到volatile T指针的转换,并规定其逆过程即volatile T指针向T指针转换为非法。
*const指针和volatile指针在转换规则方面具有相似性。
本篇文章就后一个观点继续深入探讨。
本人认为const指针的转换规则与const指针的基本一致,因此只要我们懂得其中的一种规则,那么另外的一种就可以不攻自破。关键就是要懂得其中的共同规律,而不是去死记硬背一些具体应用。
1.自相矛盾
T *p;
...
void f(T const *qc);
如果调用f(p)将p传入函数,T指针将会转换成const T指针。整个转换过程是自动完成的,不必人为地强制转换T指针。这是一条潜规则。相反,在下面情况下,如果调用g(pc),就会产生编译错误:
T const *pc;
...
void g(T *q);
因为编译器拒绝将const T指针转换成T指针。这也是一条潜规则。
让记住下面的推断:如果你许诺你使用const是不让其它程序改变const对象的内容,那么你自己在后面编写const相关代码时必须要遵守这个许诺。就象一些做官的,表面是一套,背后又是另一套,最后对自己的所做所为不能自圆其说!
下面举个简单的例子来说明诺言是怎么许下,又是怎么被打破的。
假设有人写了下面的代码:
int const *p;
显然,他希望通过const阻止任何有意图去修改const对象的内容的行为,可他又继续写下了"挨扁"的代码:
*p += 3; /*改变p指向的内容*/
++(*p);
因为,他自己又去修改p指针指向的内容,自相矛盾啊!!!
那让我们回头看原先的代码:
T const *pc;
...
void g(T *q);
当你定义const类型的指针pc,等价于你对编译器许诺说我决不允许有代码直接地或间接地甚至潜在地去修改pc指向的内容。当然,我们的编译器是“大好人”,肯定会爽快地答应。接着,你又对编译器许诺说g函数可以修改通过q传入的任何指针的内容。最后你试着调用g(pc)将p通过pc传入g。这时编译器肯定看不过去了,一定会这样地质问你:
你为何将const指针pc传入可能会改变pc指向内容的g函数呢,你不是决不允许其它代码直接地或间接地甚至潜在地去修改pc指向的内容吗,你现在将pc传入g函数不是自己打自己嘴巴吗?嘿嘿,哑口无言了吧!所以,既然做出了许诺,就要坚持到底
继续下面的代码:
T *p;
...
void f(T const *qc);
显然,你许诺编译器说任何代码都可以改变p指向的内容并且你编写的f函数不会改变通过qc传入的其它指针指向的内容。编译器又一次爽快地答应了你。最后你调用了f(p)。这次,编译器只是对你笑笑,心理暗自道:小样你可别让我逮到在f函数中调用诸如g之类可能会改变p指向的代码哦!

2.Const vs Volatile
前面提过,const指针的转换规则与const指针的基本一致。不同的是const是你答应编译器不会编写可能改变const对象指向的内容的代码,而volatile则是编译器答应你不会对相关代码进行优化。
看下面的代码:
T volatile *pv;
...
void g(T *q);
对比const可以知道,调用g(pv)肯定会出现编译错误。因为你跟编译器说不要间接或直接地甚至潜在地优化pv相关的代码,同时你又有跟编译器说它可以优化通过q传入的指针的相关代码。如果你调用g(pv),将不能优化的pv传入可能会优化pv的g函数,显然也是危险并且自相矛盾的做法。
再看:
T *p;
...
void h(T volatile *qv);
对比const可以知道,调用h(p)不会有事,因为编译履行了它的诺言,不在h函数中优化通过qv传入的任何指针相关的代码。

结论:const指针的转换规则与const指针的基本一致,主要的不同在于谁许下了诺言。对于const,诺言的主体是我们自己,而对于volatile则是编译器。不论谁许了诺,都必须遵守并兑现它。

抱歉!评论已关闭.