C 关键字之别搞砸了 volatile


原博文地址:http://www.embedded.com/design/programming-languages-and-tools/4442490/C-keywords--Don-t-flame-out-over-volatile

考虑以下代码
struct _a_struct{
      int x;
      int y
      volatile bool alive=false;
} ASTRUCT;
 
ASTRUCT a_struct;
 
//Thread 1
a_struct.x = x;
a_struct.y = y;
a_struct.alive =true;
 
//thread 2 
if (a_struct.alive==true)
{
  draw_struct(a_struct.x, a_struct.y);
}
        这是一个在两个线程之间实现共享对象的正常情况,一个线程正在更新对象数据,另一个线程则等待同一个对象数据。上面的代码似乎没啥问题,但问题还是隐藏在里面。它不会产生预期的结果。我们可以在结构体中都用volatile修饰,但这将产生低效的代码。既然不想失去效率,又想在两个线程之间共享数据,这篇文章将会解释上面的代码有啥问题,为什么应该避免对volatile的乱用。
        本文将分为两段讲解。第一部分将尝试解决有关volatile关键字的疑问。我们将讨论其语义:声明和使用,使用在多进程环境及内核设置中的应用。第二部分将展示volatile关键字的重要性,比如在非局部跳转语句setjmp 、 longjmp以及信号管理和内联汇编。我写这篇文章的动机是理清volatile关键字的混乱用法,同时用作使用指导。但从我所了解的情况看,问题不是我们不知道如何使用这个关键字,而是不知道什么时候不该用它。
volatile的基本用法如下示:
1.用于内存IO映射
2.在多任务中用作全局共享数据
3.在中断里使用

         关于以上用法随处可见,而且也是这种反反复复的基本用法导致对volatile的使用开始泛滥。能见到的代码基本逃不出上面三种情况。例如我曾经遇到中断中的全局变量导致的问题,并把关键字volatile放到我能想到可能引起问题的地方,并且解决了这个问题。但是,事实是这仅仅隐藏了问题而没有解决问题。没错,几个月后问题就找上门了,同步数据丢失!

好吧,先看看定义
The second edition of K&R introduced volatile as follows
        volatile关键字用于强力抑制编译器对代码的优化。
        volatile关键字会使编译器注意它所修饰的特殊内存映射区域。有个新闻帖子指出在volatile关键字出现之前,编译器不得不用一种奇怪搞笑的形式来区分一块区域是否被用于内存映射。所以volatile是C语言的重要补充。除了以上的基本用法,很大程度上对volatile的使用都是有问题的。
       在早前的单核处理器并且顺序执行指令的时代,代码按序执行,从来不会像汽车那样有什么刮擦追尾之类的幺蛾子。如今我们按照C标准写出的抽象机代码,已经与实际执行的情况大相径庭了。事实上按照C标准的规定编译器的终极目标是能产生可执行的代码。而编译器唯一不能做的就是对volatile限定对象的删除及重访问(C-99),同时对非volatile限定对象则可以自由的删除、访问。通过特定的表达式编译器依然可以对变量升级为volatile类型限定。因此,了解volatile在基本用例中的使用,如声明和赋值也是必要的。下面将讲volatile对象与volatile指针。

        先来点基础的吧
int* pi; // is a pointer to an int.
int volatile* pvi; // is a pointer to a volatile int; meaning the object it is pointing to, is volatile
int* volatile vpi; // is a volatile pointer to an int; pointer is volatile, the object it is pointing to is an int. This is of fairly less use.
int volatile* volatile vpvi; // volatile pointer to a volatile int; both the pointer and the memory location it is pointing to, are volatile int.
        这种定义性还是看英文的比较好。
        作为一个普遍的经验法则,当阅读复杂的声明时,规则是从右边开始阅读,然后读到左边。这个规则对其他声明来说也适用。这规则也是为啥修饰符放到被修饰对象右侧的原因。

Complex Objects
You can declare a volatile struct as

struct volatile _a
{
      int mem_i;
      int* pmem_i;
} A;

        A中的成员也会被被volatile修饰。考虑如下代码

A a_struct; //a_struct is volatile
a_struct.mem_i; // the type is volatile int
a_struct.pmem_i; // the type is, wait for it, int* volatile (the pointer is volatile not the object it is pointing to) 即 指针变量被修饰,而非对象

        在定义结构体时一定要小心上面第二个成员的情况,虽然指针变量被修饰了,但是很不幸,获取指针指向的对象操作会被编译器优化。
        运气差点,编译器都不会提示任何警告,但由此可能导致的错误却一直在。

CHECKPOINT

When using volatile on a collective such as structs, volatile is prepended to left of the identifier and to the right of the type. 结构体类型中volatile的位置:类型的右边,标识符的左边

Assignments 传递
int volatile *pvi; //pointer to volatile int.
int* pi; //pointer to an int.
pvi = pi;

         非volatile指针变量传给volatile限定的指针变量会发生什么呢?
         结果是*pi会被隐式转换为volatile限定。如果不想让编译器对*pi的读取进行优化,可以先用volatile对*pi修饰然后再取消修饰。
C标准中的说法:
"If it is necessary to access a nonvolatile object using volatile semantics, the technique is to cast the address of the object to the appropriate pointer-to-qualified type, then dereference that pointer."如果非得使用volatile语法对非volatile修饰的对象进行访问,一个技巧是将对象地址转换为合适的指针型,然后用引这个指针。(纠结dereference 翻译,意思是取指针指向变量的内容?)

CHECKPOINT

When modifying a volatile type with non-volatile type, the compiler will implicitly cast the non-volatile type to a volatile type. 

       反过来 如果是 
    pi=pvi;
         这时编译器会提示警告类似于搞什么我不懂,我得先把volatile关键字干掉。在一些老编译器压根儿没提示,到时候你也不知道会出什么幺蛾子。
(我用vs2010试了一下:IntelliSense: 不能将 "volatile int *" 类型的值分配到 "int *" 类型的实体)

Function Parameters 函数参数

int i;
int* pi;
int volatile* pvi;
int func(int i, int* pi);
 
func(i,pvi);
上面代码一样不能通过编译,定义形参 int* pi,但传递的是volatile修饰的实参pvi。根据C标准,没有这样使用情况,意味着啥都可能发生。

       "If an attempt is made to refer to an object defined with a volatile-qualified type through us of an lvalue with non-volatile-qualified type, the behavior is undefined".

CHECKPOINT

        When modifying a non-volatile type with volatile type, the compiler should throw a warning, if it doesn’t then raise a ticket to your compiler team. 不要尝试用volatile修饰的类型去修改未经volatile修饰的类型,一般编译器会报错,要是没报错就给编译小组张红牌吧。

         Volatile And Atomic Accesses随变性与原子访问
        “原子”意味着对变量的访问是“一拍”,不能被中断,或在可被中断打断的多个步骤中访问。

瞅瞅问题吧

        下面表示中断的情形,wait函数由用户程序调用,timer_interrupt()是中断函数,用于增加wait函数中的变量值。同样的情形发生在多线程中,一个线程用于更新数据,而另一个线程调用这些数据。
volatile uint16_t my_delay;
void wait(uint16_t time) {
      my_delay = 0;
      while (my_delay<time) {
      /* wait .... */
      }
}
void timer_interrupt(void) {
      my_delay++;
}[i] 
    看似还不错,但是运行后不一定会有预期的结果,因为自加指令不是原子操作指令,虽然有些架构含有原子操作级的自加指令,但一般情况下还是还不能确定自加指令是原子操作级,即对变量my_delay的操作包括读,加,写三步。问题就变成了定时中断中对my_delay操作时,读、加完成后突然又有中断发生了并且函数wait()读取了my_delay的值,当然是旧值,属于误读。这会让我们重新审视对volatile修饰的需求,在这个例子中volatile修饰不能解决问题,关键还是要明白哪些是原子操作指令并且在多线程中如何使用原子指令。通常情况下,在多线程同时操作共享变量时必须使用原子操作指令。
        一条自加操作会被编译器解析成三条操作指令,更有一条指令在实现被当作两次使用,例如armv7 strd。

CHECKPOINTS
  1. All C, C++ operations are non-atomic unless specified by the compiler and hardware vendor.有软硬件同时指明的才是原子操作指令
  2. Naturally aligned reads are atomic. But we can’t be sure if 8-byte word accesses are atomic or not.8字节的读不一定是原子操作指令
  3. Naturally unaligned reads are non-atomic.非对齐的读操作通常不是原子操作
  4. Composite operations, the read modify write type, are non-atomic. 复合操作也不是说原子操作指令

Solution 解决

解决办法就是加访问锁,不管是中断锁还是调度锁等等,在上面的例子中加中断锁,使得在对my_delay操作的时候锁死中断(操作结束后再打开中断)。下面的代码中EnterCritical and ExitCritical 用于关闭中断。
       
volatile uint16_t my_delay; 
void wait(uint16_t time) {
uint16_t tmp;
  
EnterCritical();
my_delay = 0;
ExitCritical();
      
do {
            EnterCritical();
            tmp = my_delay;
            ExitCritical();
    
      } while(tmp<time);
}
 
void TimerInterrupt(void) {
      EnterCritical();
      my_delay++;
      ExitCritical();
}

Volatile And Reordering

        在多线程或中断中使用volatile关键字可能会导致重排序问题,下面是一个简单的例子,在嵌入式环境下常见的情况。在多个线程中,其中一个线程发送消息,然后更新发送标志,另一个线程等待更新标志,以读取更新后的消息。代码如下
volatile int ready;       
int Message[100];      
 
void foo( int i ) 
{     
Message[i/10] = 42;      
Ready = 1;     
}
 
void Thread2(i)
{
while(ready != 1);
//read message
}
        在上面的代码中,foo()函数更新消息队列并更新消息标识(告知线程2数据已准备好),线程2等待消息标识被置为1,然后读取消息队列。注意ready被volatile修饰,如果没有修饰,那么编译器可能会将while循环语句优化掉,这还没完。如果使用GCC编译器-O2优化级别对上面代码进行编译,就会发现消息标识位在消息队列更新前已经更新了,线程2会一直读取历史数据队列。作为开发人员的默认解决方案是把消息也用volatile修饰起来,这会造成代码执行的更低效。更糟糕的是问题可能还没解决,因为机器会自行重排序,是的,机器会对代码进行重排序。为啥呢?

Compiler Reordering:

       上面的代码是一种明显的编译器重排序情况,事实上只要输出结果一致,编译器可以在他认为有必要的时候随意排列任意代码。因此为了让读写操作按序进行,多数编译器都有一个被叫做编译屏障的语言扩展。
asm volatile ("" : : : "memory");
       解决问题的办法就是使用编译语言扩展
volatile int ready;
int message[100];
 
void foo (int i) {
message[i/10] = 42;
asm volatile ("" : : : "memory");
ready = 1;
}
        这条语句之前限制了所有RAM的访问,语句之后再重新载入访问,在编译屏障前后也不允许编译器排序代码。虽然主要的编译器gcc,英特尔CC,和LLVM提供这个选项,但编译屏障本身不是一个很好的解决方案,一些编译器也不提供这个选项。最好的解决办法是使用内存屏障。

CPU Reordering:

        CPU乱序,也被成为机器重排序,在当今计算机中是一种更微妙、难以发现的存在。大意是发生总线级的读操作时,数据还没准备好,但另一条独立的指令却被执行了,当数据准备好后,再执行读操作。这当然只是一种计算机执行乱序方式,实际上代码在计算机上被乱序执行的方式多到爆。
        CPU乱序的解决方案是使用内存屏障。并且几乎所有的处理器都提供内存屏障。下面是一个基于FreeRTOS的例子,在ISR中创建上下文切换。在上下文切换之前设置内存屏障,所有的数据和指令代码都是同步的。
void vPortYieldFromISR(void) {
/* Set a PendSV to request a context switch. */
*(portNVIC_INT_CTRL) = portNVIC_PENDSVSET_BIT;
/* Barriers are normally not required but do ensure the code is completely within the specified behavior for the architecture. */
__asm volatile("dsb");
__asm volatile("isb");
}
         上面两条汇编指令 dsb isb 分别实现数据同步屏障,指令同步屏障。从而保证数据和指令程序段执行的序列化,有关内存屏障可阅读:http://infocenter.arm.com/help/index.jsp?topic=/com.arm.doc.dai0321a/index.html
In most multi-threaded setting you will use a variant of enter_critical and exit_critical region that will enforce serialization within and across the region, they serve as locks. 
         在大部分多线程环境中都有功能类似于锁的叫enter_critical 和exit_critical 的区域限定变量,并保证在限定区域内以及上下文切换时代码执行的序列化。(真难翻译)
合理的使用这种锁可以保证切换上下文时非期望的乱序发生。当然也能阻止编译器瞎优化,此时volatile修饰的对象在区域内就没事用了。
 
        讲了这么多,关键是要注意对volatile用法的理解,并深入明白上述问题代码的根源,下一篇文章讨论什么时候volatile是必须的。
      (不知道为啥一复制就乱码,这个编辑器太疵毛了,所以后面有一小部分关于内核的部分没翻译)
        自己看看吧
In a kernel setting as in multithreaded environment you can be sure to have kernel locking primitives which make the data safe i.e. mutexes, spinlocks and barriers. These locks are designed to also prevent unwanted optimizations and make sure the operations are atomic on both the cpu and compiler level. Thus if they are being used properly then you don’t need volatile.

Thus the only significant use of volatile (that one can think of) in a kernel setting can be for accessing a memory mapped IO. Since you don’t want the compiler to optimize out the register accesses within the critical region, you still have to use volatile inside the critical region even if you use locks around those accesses. But in most kernel settings you have special accessor functions for accessing IO memory regions, because accessing this memory region directly is frowned upon in a kernel setting. These accessor functions must make sure to prevent any unwanted optimizations and if they do it properly then volatile is not needed.

关于多线程指令重排序可参考:http://blog.csdn.net/beiyetengqing/article/details/49580559

发布了7 篇原创文章 · 获赞 24 · 访问量 3万+
展开阅读全文

没有更多推荐了,返回首页

©️2019 CSDN 皮肤主题: 大白 设计师: CSDN官方博客

分享到微信朋友圈

×

扫一扫,手机浏览