编译乱序vs执行乱序
背景
今天留意了一下linux内核对writel和readl的实现,涉及到了dmb,imb这类屏障指令,过去对这类机制的了解比较模糊,所以查阅了一些资料,做一下记录。
1 |
|
什么是编译乱序和执行乱序
编译乱序
现代高性能编译器在目标码优化时都具备对指令进行乱序优化的能力,编译器可以对访存指令进行乱序,减少逻辑上不必要的访存,以此来尽量提高Cache命中率和CPU的Load/Store单元的工作效率
打开编译优化后,看到生成的汇编码并没有严格按照代码的逻辑顺序,这是正常现象。
执行乱序
在处理器上执行时,后发射的指令可能先执行完,这是处理器的“乱序执行(Out-of-OrderExecution)”策略
高级的CPU可以根据自己缓存的组织特性,将访存指令重新排序执行。连续地址的访问可能会先执行,因为这样缓存命中率高。有的还允许访存的非阻塞,即如果前面一条访存指令因为缓存不命中,造成长延时的存储访问时,后面的访存指令可以先执行,以便从缓存中取数。因此,即使是从汇编上看顺序正确的指令,其执行的顺序也是不可预知的。
案例1
下面假设一个简单的场景,写端申请一个foo结构对象,并对他的各个成员初始化,再将这个foo对象的地址赋值给foo结构体指针gp
struct foo {
int a;
int b;
int c;
};
struct foo *gp = NULL;
/* . . . */
p = kmalloc(sizeof(*p), GFP_KERNEL);
p->a = 1;
p->b = 2;
p->c = 3;
gp = p;
如果读端做简单处理,可能程序的结果不符合预期:
p = gp;
if (p != NULL) {
do_something_with(p->a, p->b, p->c);
}
由于编译乱序 ,gp = p;
可能被放置到对p的各个成员初始化之前,读端检查到gp已经初始化,进入do_something_with,此时传递的入参是堆上的垃圾值。
针对编译乱序,可以使用类似linux内核的writel和readl的实现,加入内存屏障 指令
#define barrier() __asm__ __volatile__("": : : "memory")
barrier就象是c代码中的一个栅栏,将代码逻辑分成两段,barrier之前的代码和barrier之后的代码在经过编译器编译后顺序不能乱掉。也就是说,barrier之后的c代码对应的汇编,不能跑到barrier之前去,反之亦然。
这个barrier宏用内嵌汇编来实现,破坏描述部分为“memory”
“memory”通知编译器,此段内嵌汇编修改了memory中的内容,asm之前的c代码块和之后的c代码块看到的memory可能是不一样的,对memory的访问不能依赖之前的缓存,需要重新加载。
即便我们可以通过内存屏障来保障编译器不对指令做乱序排放,也可能会因为执行乱序 导致不期望的结果发生。
案例2
举另一个例子,ARM v6/v7处理器会对以下指令顺序进行优化:
LDR r0, [r1] ;
STR r2, [r3] ;
假设第一条LDR指令导致缓存未命中,这样缓存就会填充行,并需要较多的时钟周期才能完成 。老的ARM处理器,比如ARM926EJ-S会等
待这个动作完成,再执行下一条STR指令。而ARM
v6/v7处理器会识别出下一条指令(STR)且不需要等待第一条指令(LDR)完成(并不依赖于r0的值),即会先执行STR指令,而不是等待LDR指令完成。
对于大多数体系结构而言,尽管每个CPU都是乱序执行,但是这一乱序对于单核的程序执行是不可见的,因为单个CPU在碰到依赖点(后面的指令依赖于前面指令的执行结果)的时候会等待,所以程序员可能感觉不到这个乱序过程。但是这个依赖点等待的过程,在SMP处理器里面对于其他核是不可见的。比如若在CPU0上执行:
while (f == 0);
print x;
在CPU1上执行:
x = 42;
f = 1;
我们不能武断地认为CPU0上打印的x一定等于42,因为CPU1上即便“f=1”编译在“x=42”后面,执行时仍然可能先于“x=42”完成
,所以这个时候CPU0上打印的x不一定就是42。
处理器为了解决多核间一个核的内存行为对另外一个核可见的问题 ,引入了一些内存屏障的指令。譬如,ARM处理器的屏障指令包括:
DMB(数据内存屏障):在DMB之后的显式内存访问执行前,保证所有在DMB指令之前的内存访问完成;
DSB(数据同步屏障):等待所有在DSB指令之前的指令完成(位于此指令前的所有显式内存访问均完成,位于此指令前的所有缓存、跳转预测和TLB维护操作全部完成);
ISB(指令同步屏障):Flush流水线,使得所有ISB之后执行的指令都是从缓存或内存中获得的。
Linux内核的自旋锁、互斥体等互斥逻辑,需要用到上述指令:在请求获得锁时,调用屏障指令;在解锁时,也需要调用屏障指令。
案例3
mutex_lock
1LOCKED EQU 1
2UNLOCKED EQU 0
3lock_mutex
4 ; 互斥量是否锁定?
5 LDREX r1, [r0] ; 检查是否锁定
6 CMP r1, #LOCKED ; 和"locked"比较
7 WFEEQ ; 互斥量已经锁定,进入休眠
8 BEQ lock_mutex ; 被唤醒,重新检查互斥量是否锁定
9 ; 尝试锁定互斥量
10 MOV r1, #LOCKED
11 STREX r2, r1, [r0] ; 尝试锁定
12 CMP r2, #0x0 ; 检查STR指令是否完成
13 BNE lock_mutex ; 如果失败,重试
14 DMB ; 进入被保护的资源前需要隔离,保证互斥量已经被更新
15 BX lr
16
17unlock_mutex
18 DMB ; 保证资源的访问已经结束
19 MOV r1, #UNLOCKED ; 向锁定域写"unlocked"
20 STR r1, [r0]
21
22 DSB ; 保证在CPU唤醒前完成互斥量状态更新
23 SEV ; 像其他CPU发送事件,唤醒任何等待事件的CPU
24
25 BX lr
案例4
preempt_disable
再来看一个例子:
preempt_disable()
临界区
preempt_enable
有些共享资源可以通过禁止任务抢占来进行保护,因此临界区代码被preempt_disable和preempt_enable给保护起来。其实,我们知道所谓的preempt
enable和disable其实就是对当前进程的struct thread_info中的preempt_count进行加一和减一的操作。具体的代码如下:
#define preempt_disable() \
do { \
preempt_count_inc(); \
barrier(); \
} while
linux kernel中的定义和我们的想像一样,除了barrier这个优化屏障。barrier就象是c代码中的一个栅栏,将代码逻辑分成两段,barrier之前的代码和barrier之后的代码在经过编译器编译后顺序不能乱掉。也就是说,barrier之后的c代码对应的汇编,不能跑到barrier之前去,反之亦然。之所以这么做是因为在我们这个场景中,如果编译为了榨取CPU的performace而对汇编指令进行重排,那么临界区的代码就有可能位于preempt_count_inc之外,从而起不到保护作用。
案例5
针对外设 – DMA配置与启动
前面提到每个CPU都是乱序执行,但是单个CPU在碰到依赖点的时候会等待,所以执行乱序对单核不一定可见。
但是,当程序在访问外设的寄存器时,这些寄存器的访问顺序在CPU的逻辑上构不成依赖关系,但是从外设的逻辑角度来讲,可能需要固定的寄存器读写顺序,这个时候,也需要使用CPU的内存屏障指令。
在Linux内核中,定义了读写屏障mb()、读屏障rmb()、写屏障wmb()、以及作用于寄存器读写的__iormb()、__iowmb()这样的屏障API。读写寄存器的readl_relaxed()和readl()、writel_relaxed()和writel()API的区别就体现在有无屏障方面。
比如我们通过writel_relaxed()写完DMA的开始地址、结束地址,大小之后,我们一定要调用writel()来启动DMA。
writel_relaxed(DMA_SRC_REG, src_addr);
writel_relaxed(DMA_DST_REG, dst_addr);
writel_relaxed(DMA_SIZE_REG, size);
writel (DMA_ENABLE, 1);
ARM Arch的Strongly Order
举例一个uart的console write接口实现demo:
do {
获取TX FIFO状态寄存器
barrier();
} while (TX FIFO没有ready);
写TX FIFO寄存器;
一般来说ARM架构下,外设硬件的IO地址也被映射到了一段内存地址空间,对编译器而言,它并不知道这些地址空间是属于外设的。因此,对于上面的代码,如果没有barrier的话,获取TX FIFO状态寄存器的指令可能和写TX FIFO寄存器指令进行重新排序,在这种情况下,程序逻辑就不对了,因为我们必须要保证TX FIFO
ready的情况下才能写TX FIFO寄存器。
对于multi core的情况,上面的代码逻辑也是OK的,因为在调用console write函数的时候,要获取一个console semaphore ,确保了只有一个thread进入,因此,console write的代码不会在多个CPU上并发。
和preempt count的例子一样,我们可以问同样的问题,如果CPU是乱序执行(out-of-order
excution)的呢?barrier只是保证compiler输出的汇编指令的顺序是OK的,不能确保CPU执行时候的乱序。
对这个问题的回答来自ARM architecture的内存访问模型:
对于program order是A1–>A2的情况(A1和A2都是对Device或是Strongly-ordered的memory进行访问的指令),ARM保证A1也是先于A2执行的。因此,在这样的场景下,使用barrier足够了。
对于X86也是类似的,虽然它没有对IO space采样memory
mapping的方式,但是,X86的所有操作IO端口的指令都是被顺执行的,不需要考虑memory access order。
BCM手册里发现的一点细节
BCM这个对外设映射内存访问的内存屏障要求是不是有点怪,他这意思是同一个外设里的内存访问顺序他可以保证,但是如果中途切换到另一个外设就要使用memory barrier,其他架构也类似?
BCD_MEM_barrier.png