39

修志龙_ZenonXiu · 2022年09月03日 · 上海市

老生常谈volatile

Volatile作为面试常见问题和实际工作中常用到的关键词,大多数人都有比较好的理解,但也存在一些误解。本文只讨论volatile在C/C++中的使用,在Java中,Volatile的用处有所不同。
在C规范里, ISO/IEC 9899 C11 – clause 6.7.3

An object that has volatile-qualified type may be modified in ways unknown to the implementation or have other unknown side effects. Therefore any expression referring to such an object shall be evaluated strictly according to the rules of the abstract machine, as described in 5.1.2.3. Furthermore, at every sequence point the value last stored in the object shall agree with that prescribed by the abstract machine, except as modified by the unknown factors mentioned previously.134) What constitutes an access to an object that has volatile-qualified type is implementation-defined.

134) A volatile declaration may be used to describe an object corresponding to a memory-mapped input/output port or an object accessed by an asynchronously interrupting function. Actions on objects so declared shall not be “optimized out” by an implementation or reordered except as permitted by the rules for evaluating expressions.

基本上,C规范说的是,
Volatile修饰的变量可以在当前程序之外别改变,所以编译器不应该优化这个变量的访问。
Volatile告诉编译器它修饰的变量随时都有可能改变,因此编译后的程序每次需要存储或读取这个变量的时候,都会直接从变量地址中读取数据。如果没有volatile关键字,则编译器可能优化读取和存储,可能暂时使用寄存器中的值,如果这个变量由别的程序更新了的话,将出现不一致的现象。
没有volatile,编译器可以对变量的访问顺序进行调整,也可以合并变量访问。

比如,

  1. 这个变量可能会被多线程或多核系统中的另一线程或CPU修改。
  2. 处理这个变量的程序可能会被中断,ISR里面会修改这个变量,再返回到被中断程序后,这个变量的值会变动。
  3. 有些外设的访问,比如FIFO的访问,对同一个外设地址的访问,每次都会得到不同的值。或者写外设命令到地址。这些访问都不应该被优化。比如
    a. flash编程,将flash命令序列写到同一个flash控制器寄存器,
    b. 设置一个外设寄存器的bit, 如果读出这个寄存器的同一 bit,看其是否变为0来判断是否设置成功。

多线程,多核,ISR共享变量

图片.png

在这个例子里,由汇编代码(-O1,-O2, -O3优化级别)可以看出, 对于Thread0的代码,没有volatile,compiler只读取一次*pFlag (Flag的值)到寄存器w0中,如果这次读到的值不是1,那么会陷入无限循环,虽然Flag的值在另一个Thread/CPU/ISR中变为1,但是Thread0已经感知不到了(只使用已经放在寄存里的值)。

如果使用volatile,
图片.png
这告诉compiler不要优化对Flag访问,因此我们看到Thread0中为while(*pFlag) 每次访问都生成了LDR指令从memory里读取,这样的话, Flag在其他 Thread/CPU/ISR改变,就可以被Thread0感知。

对外设的访问

有些外设的设计,在设置一个外设寄存器bit后,需要轮询这个bit看看是否被硬件清零,来给软件判断外设设置是否成功。
由下面左图所示,没有volatile, Compiler会认为(port|=0x01)已经设置了这个bit, 下面(port&0x1==1)一定会返回TRUE,所以直接把while优化成无限循环。
图片.png
如果加上volatile, 右边图所示, compiler对while(*port&0x1==1)处理时,每次都从内存取新的值。

再看一个将flash命令序列写到同一个flash控制器寄存器的例子,
图片.png

没有volatile, compiler可能会合并这个两个命令的写,最后对flash控制器只写了READ_COMMAND. 需要加上volatile, 可以避免对*port访问的优化。

另外一个例子,
图片.png

Compiler barrier

你也许在Linux kernel 代码里看到过barrier(); 或是asm(“memory”), 或在内嵌汇编中看到最后cobble list里有 :“memory”. 比如,
图片.png

static inline unsigned long arch_local_irq_save(void)
{
    unsigned long flags;
    asm volatile(
        "mrs    %0, daif        //   
        arch_local_irq_save\n"
        "msr    daifset, #2"
        : "=r" (flags)
        :
        : "memory");
    return flags;
}

请注意,这里的barrier();实际上只对compiler生成代码有影响的,与CPU hardware对内存访问的reorder, merge等无关,它不是用来控制hardware保序指令。
Barrier();其实和 “memory”等同,虽然他们也经常和硬件保序指令一起使用。
图片.png

由上面的代码可以看出,“memory“告诉compiler, 不要将代码中”memory”之前的内存访问和memory”之后的内存访问放在一起优化,”memory”之前的内存改写,需要从寄存器中存回内存。
Volatile可以一个变量访问,“memory“影响其之前和之后的所以变量。

这里简单列举一下,硬件barrier指令和compiler “memory“ barrier一起使用的例子,
smp_mb()在kernel中用来保序, 在arm64上,其最终实现

#define dmb(opt)    asm volatile("dmb " #opt : : : "memory")
#define dsb(opt)    asm volatile("dsb " #opt : : : "memory")

这个也容易理解,没有compiler barrier, 虽然硬件上可以保序,但是如果compiler生成的代码本身就reorder了,只有硬件也达不到效果。

READ_ONCE/WRITE_ONCE

Linux kernel中经常会使用READ_ONCE, WRITE_ONCE,READ_ONCE和WRITE_ONCE宏保证读写操作不被编译器优化掉。实际上是用volatile和barrier()来实现的。

static __always_inline void __write_once_size(volatile void *p, void *res, int size)
{
    switch (size) {
    case 1: *(volatile __u8 *)p = *(__u8 *)res; 
    break;
    case 2: *(volatile __u16 *)p = *(__u16 *)res; 
    break;
    case 4: *(volatile __u32 *)p = *(__u32 *)res; 
    break;
    case 8: *(volatile __u64 *)p = *(__u64 *)res; 
    break;
    default:
        barrier();
        __builtin_memcpy((void *)p, (const void 
        *)res, size);
        barrier();
    }
}

__read_once_size函数要完成的操作是将要读取的变量的值拷贝到临时定义的局部联合体变量__u中。如果要读取变量的长度是1、2、4、8字节的时候,直接使用取指针赋值就行了,由于要读取变量的指针已经被转成了volatile的,编译器保证这个操作不会被优化。如果要读取的变量不是上面说的整字节,那么就要用__builtin_memcpy操作进行拷贝了,但前后都需要加上编译器屏障barrier(),这样就可以保证__builtin_memcpy函数调用本身不会被编译器优化掉。
图片.png
使用READ_ONCE, WRITE_ONCE的好处是,只需要在需要从内存load/store才这样做,这比用volatile直接定义变量对全部的访问都要从内存load/store的方式更灵活一些。

内嵌汇编中的volatile

作为系统软件工程师,经常会用到内嵌汇编,

        asm volatile("@    __xchg1\n"
        "    swpb    %0, %1, [%2]"
            : "=&r" (ret)
            : "r" (x), "r" (ptr)
            : "memory", "cc");

asm后面volatile代码是什么意思呢?
这个volatile主要是告诉compiler不要优化内嵌汇编里的代码,没有volatile,compiler可能会根据它的理解把内嵌汇编指令和其他C代码一起优化。

下面说一些volatile使用的一些误区。

Volatile的读写是原子操作吗?

有些C/C++ programmer认为volatile的读/写操作是原子操作,其实并不是。
如上所述,volatile可以避免一些compiler对访问的优化,但并不现在compiler产生什么访问指令去访问变量。
图片.png

在上面的例子里面,在32位系统上访问一个volatile的long long类型变量,我们可以看到compiler产生的访问是LDM/STM, 它并不能保证读或写是64bit的原子操作。

因此,不太建议使用指针直接访问外设,而是调用kernel IO访问函数,在其最后实现会采用汇编来控制访问指令,

#define readw(c)        ({ u16 __v = readw_relaxed(c); __iormb(); __v; })

static inline void __raw_writew(u16 val, volatile void __iomem *addr)
{
    asm volatile("strh %1, %0"
             : : "Q" (*(volatile u16 __force *)addr), 
             "r" (val));
}

用了volatile,写操作一定会被别的Thread马上看到吗?

Volatile虽然可能每次写都写到内存中去,这个内存可以是cache或主内存/外设,但是因为memory consistency model的存在,并不能保证这个写马上对其他Thread可见,需要内存屏障指令的帮忙。

什么时候不应该使用“volatile”类型?

volatile的关键是用来消除优化。在内核中,程序员必须防止意外的并发访问破坏共享的数据结构,这其实是一个完全不同的任务。用来防止意外并发访问的保护措施,可以更加高效的避免大多数优化相关的问题。
过多使用volatile会导致没有必要的内存访问,影响系统的性能。
内核提供了很多原语来保证并发访问时的数据安全(自旋锁, 互斥量,内存屏障等等),同样可以防止意外的优化。如果可以正确使用这些内核原语,那么就没有必要再使用volatile。

    spin_lock(&the_lock);
    access_shared_data(&shared_data);
    spin_unlock(&the_lock);

如果所有的代码都遵循加锁规则,当持有the_lock的时候,不可能意外的改变shared_data的值。任何可能访问该数据的其他代码都会在这个锁上等待。自旋锁原语跟内存屏障--意味着数据访问不会跨越它们而被优化。
我们看一个arm64的spinlock的实现,

static inline void arch_spin_lock(arch_spinlock_t *lock)
{
    unsigned int tmp;
    arch_spinlock_t lockval, newval;

    asm volatile(
    /* Atomically increment the next ticket. */
"    prfm    pstl1strm, %3\n"
"1:    ldaxr    %w0, %3\n"
"    add    %w1, %w0, %w5\n"
"    stxr    %w2, %w1, %3\n"
"    cbnz    %w2, 1b\n"
    /* Did we get the lock? */
"    eor    %w1, %w0, %w0, ror #16\n"
"    cbz    %w1, 3f\n"
    /*
     * No: spin on the owner. Send a local event to 
     avoid missing an
     * unlock before the exclusive load.
     */
"    sevl\n"
"2:    wfe\n"
"    ldaxrh    %w2, %4\n"
"    eor    %w1, %w2, %w0, lsr #16\n"
"    cbnz    %w1, 2b\n"
    /* We got the lock. Critical section starts here. */
"3:"
    : "=&r" (lockval), "=&r" (newval), "=&r" (tmp), 
    "+Q" (*lock)
    : "Q" (lock->owner), "I" (1 << TICKET_SHIFT)
    : "memory");
}

实际上它已经结合了已经保序(load-acquire, ldaxrh)和“memory” compiler barrier, 保证在锁保护的临界区里的share data的访问,不会被compiler优化到获得到锁之前去访问,也通过load-acquire这个指令保证硬件不会将share data的访问乱序到获得锁之前。

如果shared_data被声明为volatile,锁操作将仍然是必须的。就算我们知道没有其他人正在
使用它,编译器也将被阻止优化对临界区内shared_data的访问。

一点Java volatile知识

Java volatile也不能保证原子性,但保证了变量的可见性(visibility)。被volatile关键字修饰的变量,如果值发生了变更,其他线程立马可见

推荐阅读
关注数
8682
文章数
54
mindshare_zenon
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息