原理和实战解析Linux中如何正确地使用内存屏障(下)
接上文原理和实战解析Linux中如何正确地使用内存屏障(上)
实战一:运行Linux的多核通过中断通信

它的一般模式是:CPU0在DDR填入一段数据,然后通过store指令写INTR的寄存器向CPU1发送中断。
store数据
barrier?
store intr寄存器
中间应该用什么barrier?我们来回忆一下三要素:
a. 谁和谁保序? -> CPU0和CPU1这2个observer之间看到保序
b. 在哪里保序? -> 只需要CPU1看到CPU0写入DDR和intr寄存器是保序的
c. 朝哪个方向保序? -> CPU0写入一段数据,然后写入intr寄存器,只需要在st方向保序。
由此,我们得出结论,应该使用的barrier是:dmb + ish + st,显然就是smp_wmb。内核代码drivers/irqchip/irq-bcm2836.c也可以证实这一点:

里面的注释非常清晰,smp_wmb()保证了发起IPI之前,其他CPU应该先观察到内存的数据在位。
现在我们把INTR换成gic-v3,就会变地tricky很多。gic-v3的IPI寄存器并不是映射到内存空间的,而是一个sys寄存器,通过MSR来写入。前面我们说过DMB只能搞定load/store之间,搞不定load/store与其他东西之间。
最开始的gic-v3驱动的作者其实也误用了smp_wmb,造成了该驱动的稳定性问题。于是Shanker Donthineni童鞋进行了一个修复,这个修复的commit如下:

这个commit解释了我们不能用dmb搞定memory和sysreg之间的事情,于是这个patch替换为了更强力的wmb(),那么这个替换是正确的吗?
我们还是套一下三要素:
a. 谁和谁保序? -> CPU0和CPU1保序
b. 在哪里保序? -> 只需要CPU1看到CPU0写入DDR后,再看到它写sysreg
c. 朝哪个方向保序? -> CPU0写入一段数据,然后写入sysreg寄存器,只需要在st方向保序。
我们要进行保序的是CPU0和CPU1之间,显然他们属于inner。于是,我们得出正确的barrier应该是:dsb + ish + st,wmb()属于用力过猛了,因为wmb = dsb(st),保序范围是full system。基于此,笔者再次在主线内核对Shanker Donthineni童鞋的“修复”进行了“修复”,缩小屏障的范围,提升性能:

实战二:写入数据到内存后,发起DMA
下面我们把需求变更为,CPU写入一段数据后,写Ethernet控制器与CPU之间的doorbell,发起DMA操作发包。

我们还是套一下三要素:
a. 谁和谁保序? -> CPU和EMAC的DMA保序,DMA和CPU显然不是inner
b. 在哪里保序? -> 只需要EMAC的DMA看到CPU写入发包数据后,再看到它写doorbell
c. 朝哪个方向保序? -> CPU写入一段数据,然后写入doorbell,只需要在st方向保序。
于是,我们得出正确的barrier应该是:dmb + osh + st,为什么是dmb呢,因为doorbell也是store写的。我们来看看Yunsheng Lin童鞋的这个commit,它把用力过猛的wmb(),替换成了用writel()来写doorbell:

在ARM64平台下,writel内嵌了一个dmb + osh + st,这个从代码里面可以看出来:

同样的逻辑也可能发生在CPU与其他outer组件之间,比如CPU与ARM64的SMMU:

实战三:CPU与MCU通过共享内存和hwspinlock通信
下面我们把场景变更为主CPU和另外一个cortex-m的MCU通过一片共享内存通信,对这片共享内存的访问透过硬件里面自带的hwspinlock(hardware spinlock)来解决。

我们想象CPU持有了hwspinlock,然后读取对方cortex-m给它写入共享内存的数据,并写入一些数据到共享内存,然后解锁spinlock,通知cortex-m,这个时候cortex-m很快就可以持有锁。
我们还是套一下三要素:
a. 谁和谁保序? -> CPU和Cortex-M保序
b. 在哪里保序? -> CPU读写共享内存后,写入hwspinlock寄存器解锁,需要cortex-m看到同样的顺序
c. 朝哪个方向保序? -> CPU读写数据,然后释放hwspinlock,我们要保证,CPU的写入对cortex-m可见;我们同时要保证,CPU放锁前的共享内存读已经完成,如果我们不能保证解锁之前CPU的读已经完成,cortex-m很可能马上写入新数据,然后CPU读到新的数据。所以这个保序是双向的。

里面用的是mb(),这是一个dsb+full system+ld+st,读代码的注释也是一种享受。
实战四:S MMU与CPU通过一个queue通信
现在我们把场景切换为,SMMU与CPU之间,通过一片放入共享内存的queue来通信,比如SMMU要通知CPU一些什么event,它会把event放入queue,放完了SMMU会更新另外一个pointer内存,表示queue增长到哪里了。

然后CPU通过这样的逻辑来工作

这是一种典型的控制依赖,而控制依赖并不能被硬件自动保序,CPU完全可以在if(pointer满足什么条件)满足之前,投机load了queue的内容,从而load到了错误的queue内容。
我们还是套一下三要素:
a.谁和谁保序? -> CPU和SMMU保序
b.在哪里保序? -> 要保证CPU先读取SMMU的pointer后,再读取SMMU写入的queue;
c.朝哪个方向保序? -> CPU读pointer,再读queue内容,在load方向保序
于是,我们得出正确的barrier应该是:dmb + osh + ld,我们来看看wangzhou童鞋的这个修复:

ARM64平台的readl()也内嵌了dmb + osh + ld屏障。显然这个修复的价值是非常大的,这是一个由弱变强的过程。前面我们说过,由强变弱是性能问题,而由弱变强则往往修复的是稳定性问题。也就是这种用错了弱barrier的场景,往往bug非常难再现,需要很长时间的测试才再现一次。
实战五:修改页表PTE后刷新tlb
现在我们的故事演变成了,CPU0修改了页表PTE,然后通知其他所有CPU,PTE应该被更新,其他CPU需要刷新TLB。

它的一般流程是CPU调用set_pte_at()修改了内存里面的PTE,然后进行tlbi等动作。这里就变地非常复杂了:

我们看看barrier1,它在屏障store和tlbi之间,由于二者一个是狗狗,一个是消杀烟雾,显然不能是dmb,只能是dsb;我们需要CPU1看到set_pte_at的动作先于tlbi的动作,所以这个屏障的范围应该是ISH;由于屏障需要保障的是set_pte_at的store,而不是load,所以方向是st,由此我们得出第一个barrier应该是:dsb + ish + st。
详细的流程我们可以参考下如下代码:

barrier2用的是dsb(ish),它保证了inner内的CPU都先看到了tlbi的完成;barrier3用的isb(),它保证了CPU fetch到PTE修正之后的指令。
结语
本文对Linux内核的内存屏障的原理和用法进行一些分析和实战,它并未覆盖内存屏障的全部知识,但是应该可应付工程里面90%以上的迷惘和困惑。
原文作者:内核工匠
