编译乱序和执行乱序

编译乱序和执行乱序
理解Linux内核的锁机制, 还需要理解编译器和处理器的特点。 比如下面一段代码, 写端申请一个新的struct foo
结构体并初始化其中的a、 b、 c, 之后把结构体地址赋值给全局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);
}
有两种可能的原因会造成程序出错, 一种可能性是编译乱序, 另外一种可能性是执行乱序。
关于编译方面, C语言顺序的“p->a=1; p->b=2; p->c=3; gp=p; ”的编译结果的指令顺序可能是gp的赋值指令发
生在a、 b、 c的赋值之前。 现代的高性能编译器在目标码优化上都具备对指令进行乱序优化的能力。 编译器可以
对访存的指令进行乱序, 减少逻辑上不必要的访存, 以及尽量提高Cache命中率和CPU的Load/Store单元的工作
效率。 因此在打开编译器优化以后, 看到生成的汇编码并没有严格按照代码的逻辑顺序, 这是正常的。
解决编译乱序问题, 需要通过barrier() 编译屏障进行。 我们可以在代码中设置barrier() 屏障, 这个屏障可以
阻挡编译器的优化。 对于编译器来说, 设置编译屏障可以保证屏障前的语句和屏障后的语句不乱“串门”。
比如, 下面的一段代码在e=d[4095]与b=a、 c=a之间没有编译屏障:
int main(int argc, char *argv[])
{
int a = 0, b, c, d[4096], e;
e = d[4095];b = a;
c = a;
printf("a:%d b:%d c:%d e:%d\n", a, b, c, e);
return 0;
}
用“arm-linux-gnueabihf-gcc-O2”优化编译, 反汇编结果是:
int main(int argc, char *argv[])
{
831c: b530 push {r4, r5, lr}
831e: f5ad 4d80 sub.w sp, sp, #16384 ; 0x4000
8322: b083 sub sp, #12
8324: 2100 movs r1, #0
8326: f50d 4580 add.w r5, sp, #16384 ; 0x4000
832a: f248 4018 movw r0, #33816 ; 0x8418
832e: 3504 adds r5, #4
8330: 460a mov r2, r1 -> b= a;
8332: 460b mov r3, r1 -> c= a;
8334: f2c0 0000 movt r0, #0
8338: 682c ldr r4, [r5, #0]
833a: 9400 str r4, [sp, #0] -> e = d[4095];
833c: f7ff efd4 blx 82e8 <_init+0x20>
}
显然, 尽管源代码级别b=a、 c=a发生在e=d[4095]之后, 但是目标代码的b=a、 c=a指令发生在e=d[4095]之前。
假设我们重新编写代码, 在e=d[4095]与b=a、 c=a之间加上编译屏障:
#define barrier() __asm__ __volatile__("": : :"memory")
int main(int argc, char *argv[])
{
int a = 0, b, c, d[4096], e;
e = d[4095];
barrier();b = a;
c = a;
printf("a:%d b:%d c:%d e:%d\n", a, b, c, e);
return 0;
}
再次用“arm-linux-gnueabihf-gcc-O2”优化编译, 反汇编结果是:
int main(int argc, char *argv[])
{
831c: b510 push {r4, lr}
831e: f5ad 4d80 sub.w sp, sp, #16384 ; 0x4000
8322: b082 sub sp, #8
8324: f50d 4380 add.w r3, sp, #16384 ; 0x4000
8328: 3304 adds r3, #4
832a: 681c ldr r4, [r3, #0]
832c: 2100 movs r1, #0
832e: f248 4018 movw r0, #33816 ; 0x8418
8332: f2c0 0000 movt r0, #0
8336: 9400 str r4, [sp, #0] -> e = d[4095];
8338: 460a mov r2, r1 -> b= a;
833a: 460b mov r3, r1 -> c= a;
833c: f7ff efd4 blx 82e8 <_init+0x20>
}
因为“__asm____volatile__("": : : "memory") ”这个编译屏障的存在, 原来的3条指令的顺序“拨乱反正”了。
关于解决编译乱序的问题, C语言volatile关键字的作用较弱, 它更多的只是避免内存访问行为的合并, 对C编译
器而言, volatile是暗示除了当前的执行线索以外, 其他的执行线索也可能改变某内存, 所以它的含义是“易变
的”。 换句话说, 就是如果线程A读取var这个内存中的变量两次而没有修改var, 编译器可能觉得读一次就行了,
第2次直接取第1次的结果。 但是如果加了volatile关键字来形容var, 则就是告诉编译器线程B、 线程C或者其他执
行实体可能把var改掉了, 因此编译器就不会再把线程A代码的第2次内存读取优化掉了。 另外, volatile也不具备
保护临界资源的作用。 总之, Linux内核明显不太喜欢volatile, 这可参考内核源代码下的文档
Documentation/volatile-considered-harmful.txt。
编译乱序是编译器的行为, 而执行乱序则是处理器运行时的行为。 执行乱序是指即便编译的二进制指令的顺序
按照“p->a=1; p->b=2; p->c=3; gp=p; ”排放, 在处理器上执行时, 后发射的指令还是可能先执行完, 这是处理
器的“乱序执行(Out-of-Order Execution) ”策略。 高级的CPU可以根据自己缓存的组织特性, 将访存指令重新排
序执行。 连续地址的访问可能会先执行, 因为这样缓存命中率高。 有的还允许访存的非阻塞, 即如果前面一条
访存指令因为缓存不命中, 造成长延时的存储访问时, 后面的访存指令可以先执行, 以便从缓存中取数。 因
此, 即使是从汇编上看顺序正确的指令, 其执行的顺序也是不可预知的。举个例子, 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内核的自旋锁、 互斥体等互斥逻辑, 需要用到上述指令: 在请求获得锁时, 调用屏障指令; 在解锁时, 也
需要调用屏障指令。 代码清单7.1的汇编代码描绘了一个简单的互斥逻辑, 留意其中的第14行和22行。 关于ldrex
和strex指令的作用, 会在7.3节详述。
代码清单7.1 基于内存屏障指令的互斥逻辑
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
前面提到每个CPU都是乱序执行, 但是单个CPU在碰到依赖点的时候会等待, 所以执行乱序对单核不一定可
见。 但是, 当程序在访问外设的寄存器时, 这些寄存器的访问顺序在CPU的逻辑上构不成依赖关系, 但是从外
设的逻辑角度来讲, 可能需要固定的寄存器读写顺序, 这个时候, 也需要使用CPU的内存屏障指令。 内核文档
Documentation/memory-barriers.txt和Documentation/io_ordering.txt对此进行了描述。
在Linux内核中, 定义了读写屏障mb() 、 读屏障rmb() 、 写屏障wmb() 、 以及作用于寄存器读写的
__iormb() 、 __iowmb() 这样的屏障API。 读写寄存器的readl_relaxed() 和readl() 、 writel_relaxed() 和
writel() API的区别就体现在有无屏障方面。
#define readb(c) ({ u8 __v = readb_relaxed(c); __iormb(); __v; })
#define readw(c) ({ u16 __v = readw_relaxed(c); __iormb(); __v; })
#define readl(c) ({ u32 __v = readl_relaxed(c); __iormb(); __v; })
#define writeb(v,c) ({ __iowmb(); writeb_relaxed(v,c); })
#define writew(v,c) ({ __iowmb(); writew_relaxed(v,c); })
#define writel(v,c) ({ __iowmb(); writel_relaxed(v,c); })
比如我们通过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);

你可能感兴趣的:(编译)