volatile(英译:易变的)是一个特征修饰符关键字,防止编译器对修饰的变量相关代码进行优化,每次使用都重新读取变量的值,而不是使用寄存器里的备份。
volatile字面意思不太好理解,其实它是提醒编译器这个变量是易变的,不要去优化它!
XBYTE[2]=0x55;
XBYTE[2]=0x56;
XBYTE[2]=0x57;
XBYTE[2]=0x58;
对外部硬件而言,上述四条语句分别表示不同的操作,会产生四种不同的动作,但是编译器却会对上述四条语句进行优化,认为只有XBYTE[2]=0x58(即忽略前三条语句,只产生一条机器代码)。如果键入volatile,则编译器会逐一地进行编译并产生相应的机器代码(产生四条代码)。
下面几个情况在声明的时候需要用volatile关键字对其修饰:
1)、并行设备的硬件寄存器(如:状态寄存器)
2)、一个中断服务子程序中会访问到的非自动变量(Non-automatic variables)
3)、多线程应用中被几个任务共享的变量
答: 是的。
例如是只读的状态寄存器。
volatile 是不让编译器去优化它。const 是不让程序去修改它。
答: 是的。
例如当一个中断服务子程序修改一个指向一个buffer的指针时。
int square(volatile int *ptr)
{
return ((*ptr) * (*ptr));
}
答: 不能。该代码本意是返回 ptr 指向的值的平方,但*ptr指向一个volatile型参数,编译器将产生类似下面的代码:
int square(volatile int* &ptr) //这里参数应该申明为引用,不然函数体里只会使用副本,外部没法更改
{
int a = *ptr;
int b = *ptr;
return a*b;
}
由于*ptr的值可能在两次取值语句之间发生改变,因此a和b可能是不同的。结果,这段代码可能返回的不是你所期望的平方值!正确的代码如下:
int square(volatile int*ptr)
{
int a = *ptr;
return a*a;
}
1)、中断服务程序中修改的供其它程序检测的变量需要加volatile;
2)、多任务环境下各任务间共享的标志应该加volatile;
3)、存储器映射的硬件寄存器通常也要加volatile说明,因为每次对它的读写都可能有不同意义;
以上几种情况还要同时考虑数据的完整性(比如标志读了一半被打断了重写),volatile 是不具备原子性的,所以要考虑临界资源的访问冲突问题。
1)中可以通过关中断来实现,或其他方法实现;
2)中可以禁止任务调度,或其他方法实现;
3)中则只能依靠硬件的良好设计了。
临界区:是指一个访问共用资源的程序片段。共用资源称为临界资源。
临界资源:具有无法同时被多个线程(比如多任务,比如中断服务程序)访问的特性,一次只能供一个线程使用。例如:共用的设备或存储器。打印机;中断服务程序与其他程序、中断与任务、任务与任务共享的变量等。
原子:指化学反应不可再分的基本微粒。这是物理化学中的概念,原子基本特性就是不可分割性。
原子性:指事务的不可分割性,一个事务的所有操作要么不间断地全部被执行,要么一个也没有执行。
原子操作:是指一系列不能被打断的操作。可以是一个步骤的操作,也可以是多个步骤的操作。原子操作依赖底层CPU实现。原子操作与临界区是密切相关的,可以说原子操作就是临界区引发出来的需求。
临界资源保护的核心就是在临界资源竞争中,让其不被破坏。为达到不被破坏目的,那就让临界资源一次只能供一个线程使用。能实现这样效果的立马会想到开关中断,开停调度器等。
像在FreeRTO临界段的实现就是用开关中断实现的。
功能 | API函数 | 说明 |
---|---|---|
进入临界区 | taskENTER_CRITICAL() | |
taskENTER_CRITICAL_FROM_ISR() | 用于中断 | |
退出临界区 | taskEXIT_CRITICAL() | |
taskEXIT_CRITICAL_FROM_ISR() | 用于中断 |
如果访问=临界资源本身就是一个原子操作(比如一条指令就可以访问完成),这样也就不需要做开关中断的处理了。像早期的CPU的汇编一条指令是原子操作,但后来指令变啦,比如X86的块操作、SIMD之类的指令都不再是原子操作了。所以即使在单核微处理器中也不能把每一条汇编指令都认为是原子操作了,唯一能确定是原子操作的,恐怕只有读写寄存器了。
在linux内核提供了一系列函数来实现内核中的原子操作,这些函数又分为两类,分别针对位和整型变量进行原子操作。它们的共同点是在任何情况下操作都是原子的,内核代码可以安全地调用它们而不被打断。位和整型变量原子操作都依赖底层CPU的原子操作实现,因此所有这些函数都与CPU的架构密切相关。
在linux中,实现文件上锁的函数有lock和fcntl,其中lock用于对文件施加建议性锁,而fcntl不仅可以施加建议性锁,还可以施加强制锁。同时,fcntl还能对文件的莫一记录进行上锁,也即是记录锁。记录锁分为读取锁(共享锁)和写入锁(排斥锁)。
自旋锁,是锁的一种,自旋锁是专为防止多处理器并发而引入的一种锁,它在内核中大量应用于中断处理等部分(对于单处理器来说,防止中断处理中的并发可简单采用关闭中断的方式,不需要自旋锁)。
自旋锁最多只能被一个内核任务持有,如果一个内核任务试图请求一个已被争用(已经被持有)的自旋锁,那么这个任务就会一直进行忙循环——旋转——等待锁重新可用。要是锁未被争用,请求它的内核任务便能立刻得到它并且继续进行。自旋锁可以在任何时刻防止多于一个的内核任务同时进入临界区,因此这种锁可有效地避免多处理器上并发运行的内核任务竞争共享资源。事实上,自旋锁的初衷就是:在短期间内进行轻量级的锁定。一个被争用的自旋锁使得请求它的线程在等待锁重新可用的期间进行自旋(特别浪费处理器时间),所以自旋锁不应该被持有时间过长。如果需要长时间锁定的话, 最好使用信号量。
自旋锁的基本形式如下:
spin_lock(&mr_lock);
//临界区
spin_unlock(&mr_lock);
因为自旋锁在同一时刻只能被最多一个内核任务持有,所以一个时刻只有一个线程允许存在于临界区中。这点很好地满足了对称多处理机器需要的锁定服务。在单处理器上,自旋锁仅仅当作一个设置内核抢占的开关。如果内核抢占也不存在,那么自旋锁会在编译时被完全剔除出内核。
简单的说,自旋锁在内核中主要用来防止多处理器中并发访问临界区,防止内核抢占造成的竞争。另外自旋锁不允许任务睡眠(持有自旋锁的任务睡眠会造成自死锁——因为睡眠有可能造成持有锁的内核任务被重新调度,而再次申请自己已持有的锁),它能够在中断上下文中使用。
死锁:假设有一个或多个内核任务和一个或多个资源,每个内核都在等待其中的一个资源,但所有的资源都已经被占用了。这便会发生所有内核任务都在相互等待,但它们永远不会释放已经占有的资源,于是任何内核任务都无法获得所需要的资源,无法继续运行,这便意味着死锁发生了。自死琐是说自己占有了某个资源,然后自己又申请自己已占有的资源,显然不可能再获得该资源,因此就自缚手脚了
临界资源的保护在liunx、RTOS系统中都有封装好的API函数,在单片机裸机就没有API,需要用户自己处理。不过原理方法是一样的。
过程:关中断–>>临界资源–>>开中断
#define INT_DIS() disable_interrupt() //关闭中断
#define INT_EN() enable_interrupt() //打开中断
优点:简单,执行速度快(只有一条指令),在临界保护操作频繁的场合优势突出。
缺点:不能临界区嵌套,临界区嵌套存在隐患。如果在A函数的临界代码区调用了另一个函数B,B函数内也有临界代码区,从B函数返回时,中断被打开了。这将造成A函数后续代码失去临界保护。
示例:禁止中断方法保护临界资源
大循环(后台)
INT_DIS() //禁止定时中断
访问临界资源;
INT_EN() //开启允许定时中断
定时器中断(前台)
操作全局变量A;
关中断前将总中断允许控制位状态所在的寄存器压入堆栈保存起来,然后再关中断保护临界区代码,之后根据堆栈内保存的控制字决定是否开启中断。在临界代码执行完毕之后,将中断允许状态将恢复到进入临界区之前的状态。
关中断前将总中断允许控制位状态保存到一个变量里,然后再关中断保护临界代码,之后根据保存的控制字决定是否恢复中断。同样可以实现退出临界区时恢复进入前的中断允许状态。
过程:中断状态存入变量–>>关中断–>>全局变量A–>>根据中断状态使能中断
缺点:每一段临界代码都要额外耗费两个字节的存储空间。
void EnterCritical(unsigned int *pSRVal)
{
*pSRVal = _get_SR_register(); //保存中断状态
INT_DIS(); //禁止中断,进入临界区。这里要考虑:进入临界区之前是什么状态,如果本来就是禁止中断的呢?
}
void ExitCritical(unsigned int * pSRVal)
{
if(*pSRVal & GIE) //判断进入临界区前的状态,如果是使能中断的状态,则开启中断。
{
INT_EN();
}
}
void Function_A(void)
{
unsigned int GIE_Val;
....
EnterCritical(&GIE_Val); //进入临界代码区,保存当前中断状态在GIE_Val变量中;
......
...... //临界区代码
ExitCritical(&GIE_Val); //退出临界区,并根据GIE_Val变量决定是否开中断;
.....
}
将进入临界代码的次数和退出临界代码的次数进行统计,如果各临界代码之间有调用关系,则只是对最外层的临界代码区进行中断开关操作。
过程:类同第三种方法,不过中断只操作最外层
unsigned char criticalNesting = 0;
unsigned int interruptStatusValue = 0;
void EnterCritical(void)
{
if(criticalNesting == 0) //只对最外层进行操作
{
interruptStatusValue = _get_SR_register(); //保存中断状态
INT_DIS(); //禁止中断,进入临界区。这里要考虑:进入临界区之前是什么状态,如果本来就是禁止中断的呢?
}
criticalNesting++; //全局变量,是临界段的嵌套计数
}
void ExitCritical(void)
{
criticalNesting--;
if(criticalNesting == 0) //只对最外层操作.
{
if(interruptStatusValue & GIE) //判断进入临界区前的状态,如果是使能中断的状态,则开启中断。
{
INT_EN();
}
}
}
有的时候,如果访问临界资源的过程比较长,可以对临界资源做一个副本拷贝,用拷贝的副本作为模块处理的数据。
以上文‘第一种方法’为例说明实现方法:
关中断–>>访问全局变量A–>>副本拷贝a–>>开中断->>操作副本拷贝a
如果说临界资源比较复杂,若用拷贝副本方式也是很消耗资源的问题,这种情况可以做一个锁来解决!
下面以51内核裸机前后台程序架构为例:定时器与大循环共享临界资源。
示例:加锁的方法保护临界资源
大循环(后台)
ET0 = 0; //禁止定时中断
Lock = 1;
ET0 = 1; //开启允许定时中断
访问临界资源;
ET0 = 0; //禁止定时中断
Lock = 0;
ET0 = 1; //开启允许定时中断
定时器中断(前台)
if(lock == 0)
{
操作临界资源;
}
else
{
;
}
若Lock = 1;
与Lock = 0;
对应汇编指令是原子操作可以不用开关中断保护此锁
示例:加锁的方法保护临界资源
大循环(后台)
Lock = 1; //若此条语句对应汇编指令是原子操作可以不用开关中断保护此锁
访问临界资源;
Lock = 0; //若此条语句对应汇编指令是原子操作可以不用开关中断保护此锁
定时器中断(前台)
if(lock ==0)
{
操作临界资源;
}
else
{
;
}
举个例子:假如A线程用变量a作为临时存储区时,如果运行到一半中断发生了,而中断里也会用到该变量,等中断返回时,变量a中的内容已经被破坏了,进程A并不知道这一点,于是得到错误的运行结果.
避免的办法看似简单:拿个变量来当标志,为0时表示没有用共享资源,为1时表示正在使用.使用时先检查该标志,为1时等待,为0时则置1,并使用资源,用完后将标志置回0,于是资源冲突问题就解决了.
是吗?问题转移了----当你检查完变量,发现它为0,正要将它置1时,此时进程被打断,于是别的进程仍会毫不客气的使用该资源,要命的是在多任务系统中,当CPU控制权回来时,刚才使用这个资源的进程还没用完这个资源呢,于是出错了。
可以看出来,必须有一种这样的指令:先检查某变量,当它为0时则置1并跳转,而这一系列过程不允许被打断,拥有这种能力的指令就叫作"原子操作指令".
由于51没有这种指令,这也就是为什么楼上很多人说要关中断的原因了.事实上,关中断这种方式也用于我们桌面机系统! LINUX就用了很多关中断。
虽然51没有上面说的这种指令,却有另一个指令:DJNZ—先减再检查,并根据比较结果跳转,而这足够完成临界区操作了.系统初始化是必须事先将s置初值为1。
mutex_loop:
DJNZ s, mutex_wait ;先将s减1,再判断s是否为0,不为0跳到mutex_wait处执行,否则顺序执行。
....共享资源操作代码(也就是临界区)
INC s
RET
mutex_wait:
INC s
JMP mutex_loop
以上代码在任何情况下(跑飞不算,呵呵)不会发生两个进程同时进人临界区的情况.
你一定会想到,假如将上述的代码写成一个⼦程序,那就可以作为一个标准函数供C调用了,恭喜你,这就是标准的信号量。
1、volatile中断全局变量只能确保可见性,无法保证原子性。要时刻明白中断全局变量是临界区资源,共享访问时需要保护。
2、访问临界资源要注意访问嵌套问题。