跳过正文

Linux kernel内存屏障详解

·504 字·3 分钟
jiladahe1997
作者
jiladahe1997
To see the world as it is, and to love it

简介:
#

有两种内存屏障:

  1. 编译器内存屏障
  2. CPU内存屏障


1.编译器内存屏障
#

考虑以下代码:

#include <stdio.h>
void main() {
    int a = 0;
    int b = 0; 

    a = b + 1;
    b = 0;

    printf("%d %d\n",a,b);
}

//O0优化 aarch64-linux-gnu-gcc memory_order_test.c -S -O0
    ...
    str	wzr, [sp, 28] //a=0
    str	wzr, [sp, 24] //b=0
    ldr	w0, [sp, 24]  
    add	w0, w0, 1     //a=b+1
    str	w0, [sp, 28] 
    str	wzr, [sp, 24] //b=0
    ldr	w2, [sp, 24]
    ldr	w1, [sp, 28]
    adrp	x0, .LC0
    add	x0, x0, :lo12:.LC0
    bl	printf
    ...

//10优化 aarch64-linux-gnu-gcc memory_order_test.c -S -O1
    ...
    mov	x29, sp
    mov	w2, 0 // b = 0
    mov	w1, 1 // a = 1
    adrp	x0, .LC0
    add	x0, x0, :lo12:.LC0
    bl	printf
    ...

可以看出,在两种优化等级下,a和b赋值的顺序是不同的

因此此时就引入了一个很有趣的问题,假设此时正好切换到了其他线程执行,或者是触发了中断执行,结果会怎么样呢? 答案是会出现意料之外的情况。


编译器屏障 compiler barrier
#

参考:https://preshing.com/20120625/memory-ordering-at-compile-time/

https://coffeebeforearch.github.io/2020/11/21/compiler-memory-ordering.html

为了避免编译时出现这种问题,需要使用编译器屏障来显示的让编译器不要进行优化:

...
a = b + 1;
//添加这一行告诉编译器不要把b=0优化到这一行前面。
asm volatile("" ::: "memory");
b = 0;
...

这样就能保证b的赋值在a之后。

举例:自旋锁的场景
#

在裸机编程中我们需要实现spin_lock自旋锁的功能,通常是只需要关闭中断即可,但是在实际使用时经常遇到中断返回后出现 prefetch abort、undef等cpu异常。

这是因为自旋锁没有起到作用造成的:

// 有问题的代码:
do_something();
//spin_lock
disable_irq();
do_something_important();

由于编译器的优化,会将代码顺序修改,可能something_important的内容放到了 disable_irq 之前执行。

//正确的代码
do_something();
//spin_lock
disable_irq();
asm volatile("" ::: "memory");
do_something_important();



2.CPU内存屏障 cpu barrier
#

考虑以下汇编代码:

    ...
    a = 1; //第一行
    printf("hello1:0x%x \n", somedata1); //第二行
    printf("hello2:0x%x \n", somedata2); //第三行
    f = a; //第四行
    ...
    

根据arm官方介绍: arm官方文档:memory_systems__ordering__and_barriers (本文接下来的内容都是对这篇文章的解读:)

ARM CPU对以上代码的执行顺序,从硬件 级别可能进行优化,优化为:
第二行
第三行
第一行
第四行

对CPU底层而言,读写操作可能是并行的,所以可能会先执行2、3行,再执行1、4行。 但这看起来貌似会导致问题? 我们仔细考虑以下几种场景:


场景1:单CPU单线程
#

只要第四行和第一行顺序能够保证,第二行和第三行的顺序我们并不关注,而文档确实有说明,CPU会解析出第一行和第四行存在地址依赖关系,从而保证第一行和第四行的执行顺序:

For accesses to the same bytes, ordering must be maintained. The processor needs to detect the read-after-write hazard and ensure that the accesses are ordered correctly for the intended outcome.


场景2:单CPU多线程(不包括DMA)
#

问题也不大,如果在执行完第三行时正好发生了线程切换,而此时第一行还没有被写入,新的线程即使读取了变量a,也会遵循上述的地址依赖关系。


场景3:多CPU(包括控制器DMA场景)
#

这种场景就会出现问题了,新的CPU如果尝试读取a变量,会读到奇怪的值,因为新的CPU不知道此时a变量还存在一个pending的写入。

例如如下代码:

//thread 1
a=1;
flag=true;

//thread 2
a=0;
while(flag==false);
print("a=%d",a);

//可能输出 a=0



因此在SMP架构上,在切换线程时,通常需要使用内存屏障,比如在linux kernel自旋锁的代码中(smp_mb函数):

static __always_inline void arch_spin_lock(arch_spinlock_t *lock)
{
	u32 val = atomic_fetch_add(1<<16, lock);
	u16 ticket = val >> 16;

	if (ticket == (u16)val)
		return;

	/*
	 * atomic_cond_read_acquire() is RCpc, but rather than defining a
	 * custom cond_read_rcsc() here we just emit a full fence.  We only
	 * need the prior reads before subsequent writes ordering from
	 * smb_mb(), but as atomic_cond_read_acquire() just emits reads and we
	 * have no outstanding writes due to the atomic_fetch_add() the extra
	 * orderings are free.
	 */
	atomic_cond_read_acquire(lock, ticket == (u16)VAL);
	smp_mb();
}



举例: ARM64上的应用程序出现数据不一致的问题
#

两个线程,一个写数据、一个读数据,线程间使用一个变量 flag来同步。

问题: flag变量已经修改了,但是写的数据还没有写入进去,需要加dsb内存屏障才行。


#include "stdint.h"
#include "stdbool.h"
#include "stdio.h"
#include <pthread.h>

static uint8_t test_array[128] = {0};
static bool flag = false;

void* read_thread(void * parameter) {
    while(1) {
        if(flag == true) {
            //读取并检查数据
            for(int i=0;i< (sizeof(test_array) - 1);i++) {
                if(test_array[i] != test_array[i+1]) {
                      //数据不一致,出错
                      printf("error test_array[%d]=0x%x but test_array[%d]=0x%x\n", i, test_array[i], i+1, test_array[i+1]);
                }
            }

            flag = false;
        }
    }
}

void* write_thread(void * parameter) {
    int count = 0;
    while(1) {
        if(flag == false) {
            count++;
            for(int i=0;i<sizeof(test_array);i++) {
                if((count % 2) == 0)
                    test_array[i] = 0xAA;
                else
                    test_array[i] = 0xBB;
            }
            __asm__ volatile("dsb sy");

            flag = true;
        }
    }
}


int main() {
    //两个线程,一个写test_array,一个读test_array,是否会出现不一致的情况
    pthread_t thread1;
    pthread_t thread2;
    if (pthread_create(&thread1, NULL, read_thread, NULL) != 0) {
        perror("pthread_create1");
        return 1;
    }
    if (pthread_create(&thread2, NULL, write_thread, NULL) != 0) {
        perror("pthread_create2");
        return 1;
    }

    // 等待线程完成
    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);

    while(1);
}










其他参考文档:
#

1. kernel barrier文档
#

2. 和volatile的区别:
#

https://stackoverflow.com/questions/1787450/how-do-i-understand-read-memory-barriers-and-volatile
volatile只是防止编译器对代码的优化,例如

int a = *(int*)0x24001010;
while(a==0);//这里期望每次都去0x24001010这块内存取值

上述代码中的a可能会被优化,while中不会每次都去读取0x24001010这块内存的值。