BLOG

Record, summarize, and improve.

屏障

内存屏障以两种方式影响访问内存的指令:

  • 提供对内存访问指令执行顺序的控制
  • 提供对内存访问指令何时完成的控制。

目前有多种内存一致性模型:

顺序存储模型(sequential consistency model)

完全存储定序(total store order)

部分存储定序(part store order)

宽松存储模型(relax memory order)

内存屏障类型

如上所述,编译器和处理器都可以以需要使用内存屏障的方式优化指令的执行。影响编译器和处理器的内存屏障是硬件内存屏障,只影响编译器的内存屏障是软件内存屏障

除了硬件和软件内存屏障之外,内存屏障还可以限制为内存读取、内存写入或两者兼而有之。影响读取和写入的内存屏障是完整内存屏障

还有一类内存屏障是多处理器环境特有的。这些内存屏障的名称以“ smp ”为前缀。在多处理器系统上,这些屏障是硬件内存屏障而在单处理器系统上,它们是软件内存屏障

所述barrier()宏是唯一的软件存储器屏障,并且它是一个完整的存储器屏障。Linux 内核中的所有其他内存屏障都是硬件屏障硬件内存屏障是隐含的软件屏障

为什么加入barrier()后的汇编代码就是正确的呢?
因为barrier()作用是告诉 compiler 内存中的值已经变化,后面的操作都需要重新从内存load,而不能使用寄存器缓存的值。

CPU乱序 和 编译器乱序的关系

smp_wmb smp_rmb smp_mb等都是防止CPU乱序的指令封装,是不是意味着这些接口仅仅阻止CPU乱序,而允许编译器乱序呢?
答案肯定是不可能的,这里有个点需要记住,所有的CPU内存屏障封装都隐式包含了编译器屏障。

什么时候需要考虑编译器乱序的问题

1、有共享数据需要访问,而且是无锁访问。
2、代码存在并发的场景,有竞争的可能。

内存屏障接口

mb()

#include <asm/system.h>

void mb(void);

这个函数插入了一个硬件内存屏障,防止任何内存访问被移动并在屏障的另一侧执行。它保证任何在内存屏障之前启动的内存访问都会在通过屏障之前完成,而所有后续的内存访问都会在屏障之后执行。

rmb()

#include <asm/system.h>

void rmb(void);

这个函数插入了一个硬件内存屏障,防止任何内存读取访问被移动并在屏障的另一侧执行。它保证任何在内存屏障之前启动的内存读取访问将在通过屏障之前完成,而所有后续的内存读取访问将在屏障之后执行。

wmb()

#include <asm/system.h>

void wmb(void);

这个函数插入了一个硬件内存屏障,防止任何内存写访问被移动并在屏障的另一侧执行。它保证任何在内存屏障之前启动的内存写访问在通过屏障之前完成,所有后续的内存写访问将在屏障之后执行。

barrier()

#include <linux/kernel.h>

void barrier(void);

这个函数插入了一个软件内存屏障,影响了编译器代码的生成,但它不影响硬件对指令的执行。编译器将把它在寄存器中加载的任何修改过的值保存到内存中,在下次需要时,它将从内存中重新读取所有的值。

smp_mb()

#include <asm/system.h>

void smp_mb(void);

这个函数在多处理器系统上与mb()函数相同,在单处理器系统上与 barrier()函数相同。

smp_rmb()

#include <asm/system.h>

void smp_rmb(void);

这个函数在多处理器系统上与rmb()函数相同,在单处理器系统上与 barrier()函数相同。

smp_wmb()

#include <asm/system.h>

void smp_wmb(void);

这个函数在多处理器系统上与wmb()函数相同,在单处理器系统上与barrier()函数相同。

一、内存类型

ARMv8架构将系统中所有的内存,按照它们的特性,划分成两种,即普通内存和设备内存。并且它们是互斥的,也就是说系统中的某段内存要么是普通内存,要么是设备内存,不能都是。

1)普通内存(Normal Memory)

普通内存的特性是,在没有别的写入的情况下,每次读取出来的值都是一样的。针对普通内存,Arm处理器会采用比较激进的优化方式,从而导致指令重排序的问题。

普通内存可以被指定为支持缓存(Cached)或不支持缓存(Non-Cached)。如果两个模块之间不支持数据一致性协议,那么它们之间的共享内存一定是不支持缓存的。

2)设备内存(Device Memory)

设备内存一般是对外部设备的一段内存映射,在没有写入的情况下,可能每次读取出来的值都不一样。也有可能写入这段内存会产生别的边际效应,如触发一个中断。

二、共享域

为了支持数据一致性协议,需要增加硬件很多开销,会降低系统的性能,同时也会增加系统的功耗。但是,很多时候并不需要系统中的所有模块之间都保持数据一致性,而只需要在系统中的某些模块之间保证数据一致性就行了。因此,需要对系统中的所有模块,根据数据一致性的要求,做出更细粒度的划分。

ARMv8架构将这种划分称作为域(Domain),并且一共划分成了四类:

1)非共享(Non-shareable)域

处于这个域中的内存只由当前CPU核访问,既然只能自己访问,那当然不用考虑跟系统中的其它模块,如其它CPU核或其它设备之间的数据同步问题。所以,如果一个内存区域是非共享的,系统中没有任何硬件会保证其缓存一致性。如果一不小心共享出去了,别的CPU核可以访问了,那必须由软件自己来保证其一致性。

2)内部共享(Inner Shareable)域

处于这个域中的内存可以由系统中的多个模块同时访问,并且系统硬件保证对于这段内存,对于处于同一个内部共享域中的所有模块,保证缓存一致性。

一个系统中可以同时存在多个内部共享域,对一个内部共享域做出的操作不会影响另外一个内部共享域。

3)外部共享(Outer Shareable)域

处于这个域中的内存也可以由系统中的多个模块同时访问,并且系统硬件保证对于这段内存,对于处于同一个外部共享域中的所有模块,保证缓存一致性。外部共享域可以包含一个或多个内部共享域,但是一个内部共享域只能属于一个外部共享域,不能被多个外部共享域共享。

对一个外部共享域做出的操作会影响到其包含的所有的内部共享域。

4)全系统共享(Full System)域

这个很好理解,表示对内存的修改可以被系统中的所有模块都感知到。

在一个具体的系统中,不同域的划分是由硬件平台设计者决定的,不由软件控制。并且,Arm的文档中也没有提及具体要怎么划分。但有一些指导原则,一般在一个操作系统中可以看到的所有CPU核要分配在一个内部域里面,如下图所示:

Image in a image block

这些域的划分只是为了更细粒度的管理内存的缓存一致性,理论上所有内存都放到全系统共享域中,从功能上说也可以,但会影响性能。

可缓存性和共享性一定是对普通内存才有的概念。设备内存一定是不支持缓存的,且是外部共享的。

三、屏障

前面说的缓存一致性保证的是对同一段内存的操作,系统中的所有模块,在经过一定的时间后,大家读取到的值最终是一样的。比如说,CPU0对全局变量A写入1,而CPU1对全局变量A写入2,那么经过一定时间后,两颗CPU看到全局变量的值要么都是1要么都是2,不会一个是1一个是2。但是,缓存一致性并不能保证内存操作的顺序,这个需要内存屏障来保证。

在ARMv8架构下,共有下面四种指令提供所谓的屏障的功能:

1)指令同步屏障(ISB, Instruction Synchronization Barrier)

Arm系统有两种类型的缓存,一种是指令缓存(I-Cache),还有一种是数据缓存(D-Cache)。因此,内存屏障指令还要区分是对指令的还是针对普通数据的。

指令同步屏障会清洗当前CPU核的流水线和指令预取缓冲,以保证所有这条指令前面的指令都执行完毕之后,才执行这条指令后面的指令。

这是用来保证任何后续指令的获取,再次保证特权和访问与当前的MMU配置进行检查。它被用来确保任何先前执行的改变上下文的操作,例如对系统控制寄存器的写入,在ISB完成时已经完成。

在硬件方面,这可能意味着指令流水线被刷新,比如说。这方面的典型应用是在内存管理、缓存控制和上下文切换代码中,或者是代码在内存中被移动的地方。

2)数据内存屏障(DMB, Data Memory Barrier)

数据内存屏障保证,站在系统中其它同属一个域的模块来看,在这条指令之前的存储器访问操作(包括加载或存储)一定比在这条指令之后的存储器访问操作,先被感知到。也就是说,数据内存屏障指令阻止在这条指令之前的所有存储器访问操作被重排序到这条指令之后,同时也会阻止这条指令之后的所有存储器访问指令被重排序到这条指令之前。

这可以防止数据访问指令的重新排序跨越障碍指令。在DMB之前由这个处理器执行的所有数据访问,也就是加载或存储,但不是指令获取,在DMB之后的任何数据访问之前,在指定的可共享域内的所有其他主站都是可见的。

确保在执行任何后续数据访问之前,前面的任何明确的数据或统一的缓存维护操作已经完成

3)数据同步屏障(DSB, Data Synchronization Barrier)

数据同步屏障在保证和前面的数据内存屏障相同的存储器访问操作顺序的同时,还会保证在这条指令之后的所有指令(不光是存储器操作指令)一定会在这条指令之后才会执行。因此,数据同步屏障比数据内存屏障更加严格。

这可以防止数据访问指令的重新排序跨越障碍指令。在DMB之前由这个处理器执行的所有数据访问,也就是加载或存储,但不是指令获取,在DMB之后的任何数据访问之前,对指定的可共享域内的所有其他主站都是可见的。

DMB和DSB指令都需要带一个参数,这个参数指明了数据屏障指令的作用范围和针对的共享域。共享域前面说过了,一共有四种。作用范围表示数据屏障指令具体对哪些存储器访问操作起作用,ARMv8共定义了三种,分别是:

  1. Load - Load, Load - Store:表示内存屏障保证其之前的所有加载操作一定在其之前完成,其之后的所有加载和存储操作一定在其之后才开始,但是其之前的存储操作有可能会在其之后才执行。
  2. Store - Store:表示内存屏障保证其之前的所有存储操作一定在其之前完成,而其之后的存储操作一定在其之后才能开始,但是对于加载操作没有任何限制。
  3. Any - Any:表示内存屏障保证其之前的所有加载和存储操作一定在其之前完成,而其后的所有加载和存储操作一定在其之后才能开始。

注意,ARMv8不提供所谓的Store-Load型的顺序保证,如果真的需要这种保证,只能使用Any-Any型的。关于DMB和DSB指令的参数,可以总结为如下表格:

4)单向屏障(Load-Acquire, Store-Release)

前面的所有屏障指令对于ARMv8之前所有的架构都有效,在ARMv8架构下,还加入了一类单向屏障指令,也就是所谓的Load-Acquire(LDAR指令)和Store-Release(STLR指令)。

普通的内存屏障一般是双向的,也就是可以控制内存屏障之前的某些存储操作要在内存屏障之前完成,并且内存屏障之后的某些存储操作要在内存屏障之后才能开始。但是Load-Acquire和Store-Release却只限定了单个方向的:

  • Load-Acquire:这条指令之后的所有加载和存储操作一定不会被重排序到这条指令之前,但是没有要求这条指令之前的所有加载和存储操作一定不能被重排序到这条指令之后。所以,可以看出来,这条指令是个单向屏障,只挡住了后面出现的所有内存操作指令,但是没有挡住这条指令之前的所有内存操作指令。
  • Store-Release:这条指令之前的所有加载和存储才做一定不会被重排序到这条指令之后,但是没有要求这条指令之后的所有加载和存储操作一定不能被重排序到这条指令之前。所以,这条指令也是一个单向屏障,只挡住了前面出现的所有内存操作指令,但是没有挡住这条指令之后的所有内存操作指令。

LDAR和STLR指令也有作用的共享域,只不过没有明确在指令中表示出来。这两条指令的共享域就是它们操作的内存的共享域。比如,如果LDAR指令读取内存的地址是属于内部共享域的,那么这条指令锁提供的屏障也只是作用于这个内部共享域。

还有一点,如果LDAR指令出现在STLR指令之后,处理器也会保证LDAR指令一定不会被重排序到STLR指令之前。

单向屏障的作用范围可以总结为下面这张图:

Image in a image block
5)非时间性的加载和存储

ARMv8中的一个新概念是非时间性的加载和存储。这些是LDNP和STNP指令,执行一对寄存器值的读取或写入。它们还向内存系统发出提示,说明缓存对该数据没有用处。这个提示并不禁止内存系统的活动,如地址的缓存、预加载或收集,而只是表明缓存不太可能提高性能。一个典型的用例可能是流式数据,但是你应该注意,有效地使用这些指令需要针对微架构的方法。

非时间性的加载和存储放宽了内存排序的要求。在上面的例子中,LDNP指令可能在前面的LDR指令之前被观察到,这可能导致从X0的不可预测的地址读取。

例如:
LDR X0, [X3]
LDNP X2, X1, [X0]

要纠正上述问题,您需要一个明确的负载屏障:
LDR X0, [X3]
DMB NSHLD
LDNP X2, X1, [X0]

四、Linux内核中对内存屏障的定义

Linux将不同类型的内存屏障和ARMv8提供的屏障指令都做了映射(代码位于arch/arm64/include/asm/barrier.h中):

#define isb()		asm volatile("isb" : : : "memory")#define dmb(opt)	asm volatile("dmb " #opt : : : "memory")#define dsb(opt)	asm volatile("dsb " #opt : : : "memory")
......
#define spec_bar()	asm volatile(ALTERNATIVE("dsb nsh\nisb\n",		\
						 SB_BARRIER_INSN"nop\n",	\
						 ARM64_HAS_SB))#define mb()		dsb(sy)#define rmb()		dsb(ld)#define wmb()		dsb(st)#define dma_rmb()	dmb(oshld)#define dma_wmb()	dmb(oshst)
......
#define __smp_mb()	dmb(ish)#define __smp_rmb()	dmb(ishld)#define __smp_wmb()	dmb(ishst)

没有带任何前缀的mb()、rmb()和wmb()都是映射到了DSB指令,并且针对的共享域是最大的全系统共享域。作用范围最大的是mb()屏障,它被映射成限制Any-Any类型的,rmb()被映射成限制Load-Load, Load-Store类型的,搞好完美匹配对读内存屏障的要求,wmb()被映射成限制Store-Store类型的。

DMA设备都是属于外部共享域,所以dma_rmb()的参数是oshld,而dma_wmb的参数是oshst。并且它们只是限制对内存的操作,不限制其它指令,所以使用的是DMB指令。

带__smp_前缀的,表示限制的是所有CPU核,前面提到过一般在一个操作系统中可以看到的所有CPU核要分配在一个内部域里面,因此这里使用的参数都以i打头,表示作用域是内部共享域。

spec_bar()宏比较特殊,表示禁止当前CPU核的预测机制,使用了DSB指令,作用域是非共享域,只对当前CPU核有效。