# Linux 内核内存屏障原理六

# Linux 内核 IO 屏障的影响

当驱动开发者在执行 IO 访问操作时,应该使用以下函数:

1、inX() 与 outX()

这两个函数使用 CPU 提供的 IO访问空间,而不是 通过 MMIO 的方式来进行访问 外设。比如在 Intel 386 和 x86_64 机器上通常存在 独立的 IO 地址空间,但有一些 CPU 并不支持。详细解释请参考 intel 开发手册: CHAPTER 1 INPUT/OUTPUT。

// 独立 IO 编址 描述
In addition to transferring data to and from external memory, IA-32 processors can also transfer data to and from  input/output ports (I/O ports).// MMIO 内存地址映射 描述
I/O devices that respond like memory components can be accessed through the processor’s physical-memory  address space
1
2
3
4
5

在 PCI 总线和其他外设上,定义了 IO 独立编址 空间的概念,比如上述的 i386 和 x86_64,这些 IO 设备的地址操作 很容易的映射到 CPU 所支持的 IO空间中,但是 这些IO 操作很可能 映射到 虚拟地址 与 物理地址空间中,比如 CPU 不支持 IO 空间时。当我们在 I386 类似的 CPU 中使用 IO 指令时,这是能保证内存读写顺序的完全的顺序,但是与 CPU 不直接关联的 桥设备 比如 PCI 的主桥,并不一定保证这些顺序。详细描述 参考 :8.2.5 Strengthening or Weakening the Memory-Ordering Model。

The Intel 64 and IA-32 architectures provide several mechanisms for strengthening or weakening the memory•ordering model to handle special programming situations. These mechanisms include:
​
The I/O instructions, locking instructions, the LOCK prefix, and serializing instructions force stronger ordering  on the processor
1
2
3

inX() 与 outX() 函数的使用可以保证在使用 IO访问空间的顺序性,但并保证 其他 IO 操作类型的顺序,比如 MMIO。

2、readX() 与 writeX()

这两个函数是否能保证外设访问顺序取决于当前操作内存的特性,在 I386 上,我们通常使用 MTRR 寄存器来对这些区域进行描述。详细参考:

img

通常情况下,这两个函数访问的外设能保证完整的顺序性,但前提是:没有访问支持预取操作的外设。然而需要注意的是:在使用中间桥设备时,比如 PCI 桥,可能会按照它的设计来延迟写入数据,这时,当设备需要读取写入的相同地址的数据时,将会把这些未写入的数据转发给读取方。

当我们在这两个函数调用时,操作的 IO 设备 是可以预取的,那么我们需要使用 mmiowb() 屏障强制该设备的写操作的顺序性。其他有关于 PCI 的交互信息,请参考 PCI 规范定义。

3、readX_relaxed()

与 readX() 类似,但该函数不保证任何访问顺序性,也即不包含任何 读屏障 语义。

4、ioreadX() 与 iowriteX()

这两个函数将根据实际执行的外设访问类型,进行适当地执行,可能调用 inX() 与 outX() 函数 或者 readX()/writeX() 函数 。来看内核定义:

#ifndef CONFIG_GENERIC_IOMAP  // 若配置了普通 IO 映射,那么使用 readX 与 writeX 函数
#define ioread8(addr)       readb(addr)
#define ioread16(addr)      readw(addr)// 参考 linux 内核 x86 io.h 中的定义
#define ioread8_rep(p, dst, count) \
    insb((unsigned long) (p), (dst), (count))
#define ioread16_rep(p, dst, count) \
    insw((unsigned long) (p), (dst), (count))
#define ioread32_rep(p, dst, count) \
    insl((unsigned long) (p), (dst), (count))
1
2
3
4
5
6
7
8
9
10
11

# 假设最小执行顺序模型

在内核开发中,我们必须假定代码运行在的CPU上是弱顺序性的,也即: CPU 只会保证 最基础的程序编程的因果关系(比如:int a = 1; int b = a + 1; 的基本数据依赖性)。一些 CPU (比如 i386 和 x86_64 CPU)将会比 其他 CPU (比如:powerpc 或者 frv)能保证更强的顺序,而对于业界出了名的 DEC ALPHA CPU 则需要额外定义其他的 特定 CPU 平台代码。

这就意味着内核必须假定:CPU 可以以它所想的,为了增强指令执行性能 乱序执行指令流中的任何指令(I - CACHE中的指令),甚至在 超标量 CPU 中并行执行,如果指令流中的指令依赖于较早的指令(如上述的例子:int a = 1; int b = a + 1; 此时 b 依赖上一步读取的 a),则较早的指令CPU 必须保证在执行一条依赖执行结果的指令时,保证上一条指令一定完成,然后后面的指令才能继续执行,换句话说:只要 CPU 维持了因果关系的表象,随他怎么搞,随他怎么乱都允许。

内核还需要考虑:CPU 也可以丢弃任何最终没有执行效果的指令序列。例如,如果两个相邻的指令都将同一个值加载到同一个寄存器中,则第一个指令可能被丢弃。

类似地,必须假定我们聪明的编译器可以以它认为合适的任何方式对 IR 最终生成的 ISA 指令流重排序,因为 编译器在后端生成 目标机器码将会根据目标CPU平台来优化生成后的指令,它只需要保证 程序的因果关系正确即可。

# CPU 缓存的影响

CPU 访问内存操作在整个系统中被其他 CPU 看到的顺序,在一定程度上受到 CPU 和 内存之间的缓存 以及 维护缓存一致性操作的影响。当CPU通过缓存与系统的另一部分进行交互时,屏障在大多数情况下作用于 CPU 和 它们的缓存之间(屏障 作用于下图虚线处):

img

只要维持程序预期的因果关系,CPU 核心可以以它认为合适的任何顺序执行指令。有些指令执行加载和存储操作,然后这些操作进入要执行的内存访问队列。核心可以按照自己的意愿将这些指令放入队列中,并继续执行,直到遇到等待指令被迫等待之前的操作完成。此时,内存屏障所关心的是控制从 CPU端 到 内存端 访问的顺序,以及系统中其他组件感知到内存操作发生的顺序。

注意:

1、在有些CPU上,内存屏障是不需要的,因为 CPU 总是看到自己的加载和存储,就好像它们是按程序顺序发生的一样

2、MMIO 或其 他设备访问可以绕过缓存系统。这取决于访问设备的内存域属性和CPU 可能拥有的特殊IO指令的使用

# 缓存一致性

然而,实际执行时并不像上面看起来的那么简单:虽然缓存被期望是一致的,但不能保证一致性是有序的。这意味着,虽然在一个CPU上所做的更改最终会在所有CPU上显示,但不能保证它们在其他CPU上以相同的顺序显示。

考虑处理一个具有一对 CPU (CPU 1 和 CPU 2)的系统有一对数据缓存(CPU 1:A 和 B,CPU 2:C 和 D):

img

假设系统具有以下属性:

1、奇数号的 高速缓存行 可能在 高速缓存A、高速缓存C 中,也可能仍然驻留在内存中

2、偶数号的 高速缓存行 可能在 高速缓存B、高速缓存D 中,也可能仍然驻留在内存中

3、当CPU 正在读取一个缓存时,另一个缓存可能正在利用总线访问系统的其他部分——可能是替换一个脏的缓存行或进行预加载

4、每个缓存都有一个操作队列(invalidate queue),使得该缓存保持与系统中其余部分的数据一致性

5、对缓存中已经存在数据的缓存行进行加载操作不会刷新操作队列,即使操作队列的内容可能会影响这些加载操作的正确性

想象一下,在第一个CPU上有两个写操作,它们之间有一个写屏障,以保证它们看起来会以必要的顺序到达CPU的缓存:

CPU 1          CPU 2           注释
=============== =============== =======================================
                u == 0, v == 1 and p == &u, q == &u
v = 2;
smp_wmb();              屏障确保 CPU 1 在写入 P 之前,保证 v = 2 对其他CPU 可见,也即内存提交 
<A:modify v=2>          v 此时在缓存行 A 中的状态为 独占状态
p = &v;
<B:modify p=&v>         p 此时在缓存行 B 中的状态为 独占状态
1
2
3
4
5
6
7
8

写内存屏障会迫使系统中的其他CPU认为本地CPU的缓存已经按照正确的顺序更新。但是现在假设第二个CPU想要读取这些值:

CPU 1           CPU 2          COMMENT
=============== =============== =======================================
...
               q = p;
               x = *q;
1
2
3
4
5

上面这对读取操作可能不会按照预期的顺序进行,因为缓存行 p 可能在第二个CPU的一个缓存中被更新,而 缓存行 v 的 更新在第二个CPU的另一个缓存中被其他缓存事件延迟处理:

CPU 1           CPU 2              COMMENT
=============== =============== =======================================
                u == 0, v == 1 and p == &u, q == &u
v = 2;
smp_wmb();                     // 写屏障,保证 v 提交到内存中      
                               // C 缓存繁忙,丢到队列中待处理 ,此时 C 缓存 存在 v = 2 的消息
p = &v;         q = p;
                              // CPU 1 提交了 p=&v 操作,此时,缓存行 D 空闲,于是相应修改操作
                               // CPU 2 请求读取 在缓存 D 中的变量 p
                              // 从 D 缓存 读取 p
                x = *q;
                             // 从 C 缓存读取 *q,此时 v 还是 1
                             // C 缓存空闲,处理 v = 2 的提交消息 , 此时 v 变为 2
        
1
2
3
4
5
6
7
8
9
10
11
12
13
14

我们看到,虽然两个缓存行的修改操作最终都将在 CPU 2 上进行更新,但在没有干预的情况下,不能保证更新的顺序与在 CPU 1上提交的顺序相同。于是,为了保证顺序,我们就需要对这种行为进行干预,我们需要在两个加载操作之间插入一个 数据依赖屏障 或 读屏障。这将强制缓存在处理任何进一步的请求之前处理它的一致性队列:

CPU 1           CPU 2          COMMENT
=============== =============== =======================================
                u == 0, v == 1 and p == &u, q == &u
v = 2;
smp_wmb();                      // 写屏障,保证 v 提交到内存中   
                             // C 缓存繁忙,丢到队列中待处理 ,此时 C 缓存 存在 v = 2 的消息
p = &v;         q = p;         // 
                               //  CPU 1 提交了 p=&v 操作,此时,缓存行 D 空闲,于是相应修改操作
                               //  CPU 2 请求读取 在缓存 D 中的变量 p
                              //  从 D 缓存 读取 p
               smp_read_barrier_depends() // 依赖屏障强制 CPU 2 处理缓存 C 中 v = 2 的消息
               x = *q; // 读取到 v 的最新值 2
1
2
3
4
5
6
7
8
9
10
11
12

这类问题可能会在DEC Alpha处理器上遇到,因为它们存在多个相互独立的缓存,这样的设计可以更好地利用数据总线来提高性能。虽然当内存访问依赖于上一步的读操作时,大多数 CPU 默认在读操作上 执行数据依赖屏障,但并非所有 CPU 都是这样,因此可能不会依赖它。其他 CPU 也可能也有独立缓存,此时也必须在不同的缓存之间进行协调才能按照顺序访问内存,但也有一些特例:Alpha CPU 的语义在没有内存屏障的情况下自身消除了需要保证缓存队列处理的需要。

# 缓存一致性 与 DMA

并不是所有的系统都与DMA设备保持缓存一致性。在这种情况下,DMA 的设备可能会从内存中获取到旧数据,因为脏缓存行可能仍旧驻留在各个 CPU 的缓存中,而且可能还没有写回内存中。为了处理这个问题,内核操作中的这部分代码必须刷新每个 CPU 上的 DMA 操作的数据缓存行。

此外,在 DMA 设备将数据传入内存中后,此时写入内存中的数据可能会被从CPU的缓存回写内存时的缓存行数据(CPU 已经修改的脏数据)覆盖。为了处理这个问题,内核操作中的这部分代码必须使每个CPU上缓存的数据失效。

# 缓存一致性 与 MMIO

内存映射I/O通常通过CPU 物理内存段来映射到设备的寄存器操作,这些内存段与普通内存段有着不一样的属性。在这些属性中,通常是这样做的:对 MMIO 的访问完全绕过 CPU 缓存,访问操作将直接到达设备总线。这意味着 MMIO 访问实际上可能优先于之前对于可进行缓存的内存数据的访问。在这种情况下,内存屏障是不够的,相反,如果对缓存的写操作和 MMIO 访问相互数据依赖,则必须在两者之间执行刷新缓存操作,这时可以强制将缓存行中的数据写入主存,让设备能看到最新数据。

# CPU 和 编译器行为

程序员可能会理所当然地认为CPU会按照指定的顺序执行内存操作,因此,如果 CPU 执行以下代码片段:

a = *A;
*B = b;
c = *C;
d = *D;
*E = e;
1
2
3
4
5

然后,程序员们就会期望 CPU 会完成每条指令的内存操作,然后再进行下一条指令,从而形成一个由系统外部观察者所看到的明确的操作顺序:

LOAD *A, STORE *B, LOAD *C, LOAD *D, STORE *E
1

但是,实际上 CPU 的执行要混乱得多。对于许多 CPU 和 编译器,上面的假设不成立,因为:

1、加载操作 更可能需要立即完成以加快流水线的执行速度,而 存储操作 通常可以延迟而没有问题

2、加载操作 可以预测性地执行,如果分支指令执行时判断这些读操作不需要,则可以丢弃它

3、由于预测性的执行,将会导致在错误的时间读取到错误的顺序值(已经推测执行完毕了,但数据在推测期间被修改了)

4、可以重排访问内存的顺序,通过 提高 CPU 总线 和 高速缓存 利用率来提升处理器性能

5、当访问能够批量相邻位置的内存或I/O硬件进行通信时(利用空间局部性),可以将 加载操作 和 存储操作 组合访问以提高性能,从而降低总线事务的成本。在 访问内存 和 PCI设备时,就可能使用这样的特性

6、CPU 的数据缓存可能会影响访问顺序,虽然缓存一致性机制可能会缓解这一问题 —— 一旦存储真正到达缓存 ——但不能保证一致性协议会按照写入顺序传播到其他CPU上

所以另一个CPU可能会从上面这段代码中观察到:

LOAD *A, ..., LOAD {*C,*D}, STORE *E, STORE *B // LOAD {*C,*D} C  和 D 的访问被合并了
1

但是,CPU本身能够保证自身的一致性:在当前执行这段指令的CPU 从它本身的角度来看,访问顺序是正确的,而不需要内存屏障。例如:

    U = *A;
    *A = V;
    *A = W;
    X = *A;
    *A = Y;
    Z = *A;
1
2
3
4
5
6

假设没有其他 CPU 对数据的影响,可以假设最终结果将是:

    U == *A // U 等于 A 地址指向的原始值
    X == W
    Z == Y
    *A == Y
1
2
3
4

上面的代码会导致 CPU 执行完整的内存访问顺序,如下所述。但是,CPU 只需要 按照这个顺序执行即可,如果没有其他CPU 影响,只要最终的值保证上述的结果,那么这个执行顺序可以随意组合 或 随意丢弃多余的访问操作。

U=LOAD *A, STORE *A=V, STORE *A=W, X=LOAD *A, STORE *A=Y, Z=LOAD *A
1

当然,编译器还可能在生成ISA时,对内存访问进行合并、丢弃 或 推迟它们的执行。例如:

    *A = V;
    *A = W;
    直接合并为:
    *A = W;
    因为第一个赋值语句是不需要的
    
    由于没有写屏障,所以不需要保证因果关系,那么我们可以这样优化:
    
    *A = Y;
    Z = *A;
    
    将上述操作进行合并(最终结果是一样的,所以复用读取的 Y的值):
    *A = Y;
    Z = Y;
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# ALPHA CPU

DEC Alpha CPU是 著名的 RMO 宽松内存模型 的CPU之一。不仅如此,Alpha CPU的某些版本具有多个独立的数据缓存,允许它们在不同的时间更新各个缓存中的缓存行,尽管这两个缓存行之间的一致性对于程序执行语义很重要。这就是数据依赖屏障真正有必要用到的地方,因为这将同步两个独立缓存与内存的数据一致性,从而使指针的更改与 新数据 以正确的顺序写入。

Alpha CPU 定义了本文介绍的 Linux 内核的内存屏障模型。