前些年我接手过一个内部测试工具,跑在单核处理器上,代码里到处是共享变量的直接读写,没有任何同步原语。它跑了很久,稳定得像座钟。后来需求升级,要把这个工具拆成多核并行,于是问题接踵而至:偶发的数据异常、子模块间状态不同步、调试器一挂就好、一跑就坏。
排查到最后,问题几乎都指向同一个东西:指令重排。
内存屏障、多核、DMA、指令重排,这几个词在嵌入式圈子里被反复提起。很多人对它的理解停留在"加个屏障防止乱序",可到底什么时候该加、加在哪、加了之后代价是什么,能说清楚的人不多。这篇文章我想从底层原理到实际落地的顺序,把我踩过坑之后理解的东西完整梳理一遍。适合正在做多核驱动、DMA搬运、或者从单核往多核迁移的开发者参考,也适合那些被复现率很低的偶发Bug折磨到怀疑人生的朋友。
1. 从一段"跑得好好的代码"说起:重排是怎么发生的
1.1 你以为的顺序,编译器并不认账
先看一段最简单的代码:
int flag = 0; int data = 0; void producer(void) { data = 42; // ① flag = 1; // ② } void consumer(void) { while (flag == 0) { } // ③ use(data); // ④ }单核场景下,这段代码在O0优化下没有任何问题。但打开O2优化之后,编译器可能这样想:①和②之间没有数据依赖,把②挪到①之前,如果flag先变成1,消费者就能更早开始跑④,从而获得"看起来"更高的并行度。
这就是编译器的指令重排。它依据的是C/C++抽象机模型——单线程视角下,只要不改变程序的可观察行为,它爱怎么排怎么排。问题是,你的程序从来不是单线程视角,它有另一个核、有一块DMA控制器、有一个中断处理器在旁边看着。编译器不关心这些,它只认语言标准。
很多嵌入式工程师第一次遇到这类问题,是在开启O2之后程序忽然跑飞,关掉优化又好了。这不是玄学,是编译屏障缺失的典型症状。
1.2 CPU乱序执行:把操作拆碎再按需组装
就算你用__attribute__((optimize("O0")))或者volatile把所有变量都限制住,让编译器老老实实按源码顺序生成指令,麻烦也还没结束。
现代CPU执行指令时,内部会把指令拆成微操作,然后根据执行单元的空闲情况动态调度。不同的执行单元——访存单元、整数ALU、浮点单元——各自有各自的流水线,两条没有数据依赖的store指令,可能因为访存单元的资源竞争、或者缓存行的命中状态不同,在后端真正提交到缓存/内存的顺序,跟前端发射的顺序不一样。
这就是CPU级别的乱序执行。
此外还有存储缓冲区的因素。CPU写内存时,不会每次都直接穿透到缓存和主存,而是先写进自己的store buffer,再异步刷下去。另一个核读到的,可能是还没来得及刷出去的旧值,或者刷的顺序跟store buffer里的顺序不一致。
所以"重排"这件事,是编译器和硬件两层叠加的产物。你写出来的源码顺序,只是你个人的意愿,不是系统的承诺。
1.3 为什么单核没问题,多核一上来就翻车
单核环境下有一个隐含的保证:所有执行流共享同一个处理器核心,不管它内部怎么乱序,在中断或异常返回的边界上,处理器会自己保证一致性。换句话说,单核上同一个执行流看到的自己写出去的数据顺序,是符合程序序的。问题不暴露。
多核系统的运行方式完全不同。每个核有自己的存储层次,自己的store buffer,自己的L1 cache。你在这个核上写了data=42; flag=1;,另一个核可能先看到flag=1,再看到的data还是旧值。更麻烦的是,这个"旧值"可能是某级缓存里还没被同步出去的残影。
我曾经在一个四核平台上遇到过一个非常典型的例子:核0做数据采集,写完环形缓冲区后置位计数器;核1负责消费,先读计数器,再回读数据。偶发性地,核1读到的数据总有一部分是上轮的旧帧。所有逻辑检查都正常,数据指针、长度字段、校验值全部正确——唯独内容不对。
这就是乱序加缓存双重视图下的产物,单核时代根本不会碰到。
2. 内存屏障:给"多参与者系统"立规矩
2.1 屏障在做什么:先画一个坐标轴
内存屏障的本质是约束——约束当前处理器对内存访问的相对顺序。它不是一个物理量,不是一个寄存器,而是CPU指令集提供的一组特殊指令,告诉处理器:在这条指令之前的所有内存访问,必须在这条指令之后的内存访问之前被全局可见。
可以类比成一条斑马线。行人和车辆的通行规则本来交错混乱,斑马线划定了一条强制等待边界,用于避免冲突。CPU的load/store操作就是那些行人和车辆,内存屏障就是这条斑马线。
从实现层面看,屏障一般做的事情包含:清空或标记store buffer、等待之前的访存操作完成、根据屏障类型刷新流水线状态。不同架构的具体微架构实现差异很大,但对外表现的语义都是"顺序约束"。
2.2 四种屏障组合与语义
内存访问分读和写两种,所以屏障也有四种组合:
| 屏障类型 | 名称 | 含义 |
|---|---|---|
| LoadLoad | 读读屏障 | 屏障前的读操作先于屏障后的读操作完成 |
| StoreStore | 写写屏障 | 屏障前的写操作先于屏障后的写操作对其他观察者可见 |
| LoadStore | 读写屏障 | 屏障前的读操作先于屏障后的写操作完成 |
| StoreLoad | 写读屏障 | 屏障前的写操作先于屏障后的读操作完成,语义最强 |
实际里我们一般不单独使用LoadLoad或StoreStore这种原子语义,而是用组合起来的形式:完整屏障(full barrier)同时拦读写,读屏障(rmb)只管读操作排序,写屏障(wmb)只管写操作排序。
最关键也最容易出错的是StoreLoad屏障。在x86上,它是唯一一种硬件会真正关心的重排类型,实现代价也最高;在ARM上则没有这种不对称性,各种重排都可能发生。
2.3 硬件屏障定胜负:ARM与x86的差异
聊到具体架构,差异比很多人想象中要大。
x86采用的是TSO(Total Store Order)内存模型。它保证除了StoreLoad之外,其余三种顺序都不会被硬件打破。也就是说,在x86上你看到的乱序问题,要么来自编译器重排,要么来自StoreLoad重排。这也是为什么x86上裸写共享变量、不加屏障,很多时候居然能跑出"正确"结果——不是没有问题,是问题被模型掩盖了。
ARM是弱内存序模型,编译器可以做各种重排,CPU也可以做各种重排。ARM的屏障指令有三条:DMB、DSB、ISB。
- DMB:保证屏障两侧的数据访问顺序,但不阻断后续指令的执行。它只影响内存访问的排序。
- DSB:比DMB更强,除了排序,还会阻塞直到之前的所有内存访问完成,并且会等待缓存/缓冲排空。它还会阻断后续指令的执行,直到条件满足。
- ISB:刷流水线并清空预取队列,主要用来保证指令执行环境的一致性,比如修改了MMU配置、打开了某条系统寄存器之后,需要ISB让后续指令看到新配置。
x86上对应的是lfence、sfence、mfence三条指令,分别对应读屏障、写屏障、全屏障。因为TSO模型本身已经保证了很多东西,x86的实践频率比ARM低得多。
在嵌入式领域,ARM是绝对的主战场,所以记住DMB和DSB的语义区别非常重要。写驱动时我习惯遵循一个原则:只约束排序选DMB,需要冲刷缓冲区、等待全局可见时选DSB。用错的话,要么不够用,要么白白损失一大截性能。
2.4 为什么说它是"最后一道防线"
操作系统和并发库提供的高层同步机制——互斥锁、读写锁、原子操作、RCU——它们的内部实现,最终都要落到内存屏障上。这些机制是个壳,屏障是内核。
对于驱动开发者、BSP工程师来说,不一定会去写一个锁的实现,但你要明白:当你绕过这些机制,直接操作硬件、共享状态、DMA描述符时,屏障就是你手里最后一件武器。再往下,就没有什么可以由软件控制的手段来约束顺序了——剩下的只能靠硬件缓存一致性协议去保证,而那已经超出了你的代码控制范围。
理解这一层,你才会真正敬畏屏障。它不是"加着玩玩"的保险,而是你与处理器之间签署的、关于顺序的最底层契约。
3. 编译期屏障:一半的战场
3.1 barrier()宏与内存clobber
绝大多数嵌入式工程师接触到的第一个"屏障",其实是编译器屏障,不是上面说的CPU指令。
Linux内核里有barrier()宏,就是经典的编译屏障:
#define barrier() __asm__ __volatile__("" ::: "memory")这段内联汇编没有实际指令,只有一个"memory" clobber。它的作用是告诉GCC:这里有一个对内存的未知访问点,你之前缓存的内存值信息全部作废,不准把访存操作跨过这条线来回移动。
应用编译屏障后,上面的生产者代码就变成了:
void producer(void) { data = 42; barrier(); /* 编译器:不准越过我去重排两个写操作 */ flag = 1; }它约束的是编译器,不约束CPU。如果你的目标平台是单核且CPU不乱序(极少见,因为现代ARM核都会乱序),编译屏障可能就够用了。但绝大多数场景下,编译屏障只是第一步。
3.2 volatile的局限:管得了编译器,管不了CPU
很多文章会告诉你"把共享变量声明为volatile就行",这是嵌入式领域流传最广的错误认知之一。
volatile的作用是:告诉编译器这个变量可能会被外部意外修改,不要对它做优化——不能合并读、不能消除写、不能缓存在寄存器里。它确实能阻止一部分编译器重排,但它管不了CPU的乱序执行。
更麻烦的是,volatile在C/C++标准里对于多线程内存序的行为几乎是未定义的。C11之前的标准压根没提线程,C11之后有了_Atomic和memory_order,volatile依然不是同步原语。
在嵌入式驱动场景里,volatile唯一合适的用途是访问内存映射的硬件寄存器(MMIO),或者被中断和DMA异步修改的寄存级状态。它充当的只是一个"编译器别优化我"的标记,绝不是一个同步机制。
我见过一个同事在DMA描述符上加volatile,然后信誓旦旦说屏障加了没问题,结果在弱序平台上跑高压测试,传输偶发错位。他没搞清楚一件事:DMA描述符和CPU之间的同步,靠的是CPU屏障加上缓存维护,不是靠volatile。
3.3 常见使用场景:中断、状态机、双缓冲
编译屏障在哪些场景下够用?我说三个典型。
第一个是单核下的中断处理。中断处理程序和被中断的主流程共享变量时,主流程写变量、开中断使能,中断处理程序读变量。因为单核只有一个执行流,中断返回时硬件会保证上下文一致性,乱序的影响只在编译器层面。所以编译屏障够用。
第二个是状态机的标志位切换。单核场景下,某个核自己独占更新权,另一个观察者(比如一个纯检测逻辑)只是读取状态,不依赖写入方的其他内存视图,也可以只用编译屏障。
第三个是双缓冲切换。单核下,两个缓冲区的内容分别准备完毕后,通过一个切换标志发布新缓冲区。只要不存在DMA或者其他主设备同时读内存,编译屏障在这个场景下就是足够的。
多核场景一律不要指望编译屏障,老老实实上CPU屏障。
4. DMA场景下真正的坑:顺序与一致性
4.1 DMA和cache的恩怨:两个"缓存视图"
DMA场景的复杂之处在于,DMA控制器和CPU核心是两套独立的内存访问主体。CPU的访问路径要经过cache,DMA的访问路径在传统设计中不经过CPU的cache,而是直接访问主存。
这就出现了一个严重问题:CPU写data到cache之后,flag也写了,DMA看完flag去读data,读到的可能是主存中的旧值——因为data还躺在cache里没写回主存。反过来也一样,DMA往内存写了数据,更新了状态位,CPU读状态位发现"数据准备好了",转头去读data,结果读到的是cache里残留的旧数据。
这是我见过的最难查的一类Bug。因为从逻辑上看,flag和数据都被正确赋值了,代码顺序也没有问题,但内容就是不对。
此时加内存屏障能解决顺序问题吗?能,但只解决了一半——屏障可以保证flag的写排在data的写之后"全局可见",可前提是data得先从cache刷到内存,屏障本身并不保证替你刷cache。这就是为什么很多嵌入式程序员加了屏障之后问题依旧:顺序是对的,但数据还在错误的存储层级上。
4.2 只有屏障还不够:从"顺序问题"到"一致性问题"
所以DMA场景需要区分两个概念:
- 顺序问题:多个访问操作的全局可见顺序。靠CPU屏障解决。
- 一致性问题:CPU视图与主存视图之间数据是否一致。靠cache维护指令(clean和invalidate)解决。
CPU写完后,要执行cache clean(写回)操作,把数据从cache强制刷到主存,然后才能让DMA去读取。反过来,CPU在读取DMA写入的数据之前,要先执行cache invalidate(无效化)操作,把cache中的数据作废,让后续访问必须从主存重新加载。
这里有一个顺序陷阱:clean和invalidate操作本身也有顺序要求,一般建议在clean和访问之间加屏障,在invalidate之前也要确保DMA的数据已经真正到达主存,这通常由硬件的DMA完成中断来保证。
好在Linux内核已经把这些细节封装进了DMA API——dma_map_single、dma_unmap_single、dma_alloc_coherent等,它们会在合适的时机插入屏障和cache维护操作。裸机环境下没有这些API的庇护,你就得自己动手把这些步骤做完整。
4.3 一套可抄的作业:DMA收包流程中的屏障安排
我以最常见的"DMA控制器写内存,CPU中断读取"流程为例子,把屏障的位置标注出来,这是我在实际项目里验证过的一套顺序。
硬件约定:DMA从外设接收数据,写入内存中的缓冲区,然后写一个描述符状态字段,最后触发中断。
/* 中断处理函数 */ void dma_rx_irq_handler(struct dma_channel *ch) { /* ① 先读状态字段,确认数据真正就绪 */ unsigned int status = READ_ONCE(ch->desc.status); /* ② DMA写的状态字段,与DMA写的数据之间保证顺序 */ dma_rmb(); /* 读屏障:status读到1之后,后续读缓冲区的操作不会先于它执行 */ /* ③ 此时可以安全读取缓冲区数据 */ memcpy(rx_buf, ch->desc.buf, ch->desc.len); /* ④ 处理完数据之后,准备让DMA重新利用缓冲区 */ /* 先让前面的数据读操作完成 */ dma_wmb(); /* 写屏障:完成整个读取操作的排序 */ /* ⑤ 无效化cache,保证下次DMA写入后,CPU能读到新数据 */ cache_invalidate(ch->desc.buf, ch->desc.len); ch->desc.status = 0; /* ⑥ 清除描述符状态后,写门铃寄存器通知DMA继续 */ dma_wmb(); writel(1, ch->doorbell_reg); }每一步都有它的道理。第②步的dma_rmb()防止CPU在确认status之前就去读数据,如果缓冲区内有loop循环的旧数据,你会读到垃圾。第④步的dma_wmb()确保上一次处理期的数据读取全部完成之后,才允许DMA覆盖缓冲区。第⑤步的invalidate是缓存一致性维护,与屏障配合,保证下次读取是从主存而不是从cache拿值。第⑥步的屏障保证写门铃操作不会先于缓冲区和状态字段的更新被DMA看到。
这套流程有两点值得注意:第一,屏障的种类选择——读路径用读屏障,写路径用写屏障,不同方向解决不同问题;第二,cache操作和屏障必须区分开——它们不能相互替代,但必须协同工作。
4.4 门铃寄存器与描述符:别忘了硬件也是"核"
DMA控制器在读写内存时,同样存在顺序感知问题。现代SoC的DMA引擎在设计上通常不是逐条按地址顺序访问,而是可能有多个未完成的读写请求在路线上并行。你在CPU里排好的顺序,到了DMA那里未必还是你排好的顺序。
门铃机制是典型场景。驱动要通知DMA开始搬运一批新数据,它先更新描述符内容,然后往门铃寄存器写一个值。如果门铃写的动作在总线上先于描述符更新完成,DMA引擎就会读到旧描述符,搬运错误数据或者长度。
解决方式就是在写描述符和写门铃之间加一个写屏障,而且要确保它是全局可见的。这里的实践经验是:门铃前的屏障用完整的dsb比dmb更稳妥——虽然贵一点,但它等待之前的所有内存访问完成才继续,避免某些SoC总线桥的缓冲乱序造成门铃先到。
很多芯片手册在描述门铃机制时,都会明确要求软件在更新描述符和置位门铃之间插入屏障指令。手册里的话往往很简短,但背后的坑非常深。我曾经在一个网络控制器驱动里漏了这道屏障,在高吞吐压力测试下出现的传输损坏率大约千分之一——低到常规测试根本测不出来,高到生产环境不可接受。排查过程极其痛苦,最后加了一行代码,千分之一变成零。
5. Linux内核里的屏障实践:代码级解读
5.1 smp_mb / smp_rmb / smp_wmb:为什么前面有smp_
Linux内核提供了几个屏障宏:smp_mb()、smp_rmb()、smp_wmb(),还有对应的非smp版本mb()、rmb()、wmb()。它们的区别在于,smp_前缀的屏障只对SMP(对称多处理器)环境有意义,在非SMP内核编译中会退化成编译屏障或空操作。
为什么要这样设计?因为单核环境没必要支付CPU屏障的代价。例如一个只运行在单核上的内核配置,所有听上去需要屏障的地方,编译器屏障已经足够,而smp_版本会自动做这个适配。写驱动时,代表多核共享的变量保护用smp_系列;代表与硬件设备交互的共享内存保护用不带前缀的mb()系列——因为硬件设备不会因为你没有SMP就变老实,它始终是另一个观察者。
这个区分很多初学者会搞混。记住一个判断标准:同步的对手是你自己的其他CPU核心,还是外部的硬件设备。前者用smp_,后者用不带前缀的版本。两者叠加使用的情况也很常见——先smp_wmb()保证多核视角,再wmb()保证设备总线视角。
5.2 生产者-消费者用屏障的正确写法
再看一次开头那段生产者-消费者代码,这次用标准写法修正:
/* 生产者 */ void producer(void) { data = 42; smp_wmb(); /* 写屏障:data的写入先于flag发布可见 */ WRITE_ONCE(flag, 1); } /* 消费者 */ void consumer(void) { while (!READ_ONCE(flag)) { } smp_rmb(); /* 读屏障:flag读到1之后,读data一定不会读到旧值 */ use(data); }这里用了WRITE_ONCE和READ_ONCE而不是裸访问,是为了避免编译器把flag的读写优化成寄存器操作,同时防止读写合并。它们本质上是带volatile语义的访问封装。
WRITE_ONCE和READ_ONCE解决"编译器可能把这个变量完全优化掉"的问题,屏障解决"顺序与可见性"的问题。很多人在生产环境中只写了WRITE_ONCE/READ_ONCE,或只写了屏障,都是不完整的组合。
READ_ONCE(flag)读到的flag为1,保证数据已经完整写入并全局可见——这是dma_rmb()在那个循环里扮演的角色。没有这个读屏障,消费者有可能先读data后读flag,落回与老代码一样的深渊。
5.3 dma_alloc_coherent 与 DMA API:权责边界
Linux里最常见的一组分配函数,dma_alloc_coherent()分配的是"一致性内存"。它返回的地址经过特殊处理,通常是uncached或write-through的映射,CPU侧和DMA侧看到的视图一致。
用dma_alloc_coherent()分配的内存,你的API责任就大大减轻——不需要手动做cache的clean/invalidate,因为它设计上就不经过常规cache路径。这也意味着,对这种内存访问,你主要关心的是顺序问题,一致性问题由分配方式解决了。
但如果用dma_map_single()做流式映射,责任还在于使用者。dma_map_single()在map和unmap时会插入必要的cache维护。而你还需要在map之前和unmap之后,自己处理好屏障。
很多驱动bug的根源在于kernel API的合理但复杂的用法理解不到位。记住权责边界:数据方向的维护交给API,顺序关系的管理留给自己。哪怕API内部已经做了屏障,你仍然要在合适的语义位置使用屏障,因为API不知道你的业务逻辑在哪一步发布状态。
5.4 别把屏障当锁用:原子性到底是谁管的
最后也是最容易被误解的一点:内存屏障不提供原子性。
一个32位变量在32位平台上的读写通常是原子的,但一个64位变量在32位平台上由两条指令完成读写,此时屏障没有办法保证"要么读到完整新值,要么读到完整旧值"。屏障只保证顺序,不保证中间状态。
我经常看到类似的代码:
flag = 1; smp_wmb();这个写法正确,但它只是发布标志,不是锁。假设两个线程同时往一个指针里写值,屏障不能阻止它们的写穿线,因为data = p这个赋值本身可能就不是原子的。此时你需要的是原子交换指令或自旋锁。
锁内部需要屏障,但屏障不能替代锁。这个理解一旦建立,很多"加一堆屏障却还是出错"的问题就迎刃而解——你解决的根本不是同一个维度的问题。
6. 实战排查:数据被"吃"掉的那些日子
6.1 症状统计与第一步排查
多核与DMA场景下,典型症状可以列出一张速查表:
| 症状 | 可能的根因 | 排查方向 |
|---|---|---|
| 偶发数据错位、校验失败 | 顺序问题/cache一致性问题 | 加屏障、加cache维护 |
| 性能抖动但无数据错误 | 屏障或cache操作副作用 | 检查屏障类型是否过强 |
| 中断处理卡死或死锁 | 屏障等待不当 | 检查DSB等待的访问完成条件 |
| 局域网高压下偶发丢包 | 门铃之前缺屏障或cache不一致 | 检查描述符更新与门铃顺序 |
| 多核下日志错乱、状态不同步 | 共享变量缺屏障 | 逐个检查共享数据发布/消费端 |
第一步排查永远是"复现优先"。低概率偶发问题如果你不能稳定复现,所有排查都是空谈。我的经验是用网络高负载、多核高并发、长时间压测来制造压力条件,再结合插桩打印和寄存器快照缩小范围。
6.2 区分cache问题与乱序问题的两个方法
当你怀疑某个偶发问题与内存屏障有关时,先确认它到底是乱序还是cache多视图。
方法一:关闭编译优化,看是否复现。如果O0下复现概率剧降甚至消失,首先怀疑编译器重排;如果O0下依然偶发,更大可能是CPU乱序或cache问题。
方法二:强制缓存操作,看是否影响。在可疑数据访问前后,人为加一次完整的cache clean/invalidate操作。如果概率显著变化,说明cache一致性问题占据主导。如果加cache操作毫无变化,重点排查乱序。
这两个手段组合起来,能快速把问题域切分到正确的层面。我见过不少工程师在cache问题和乱序问题之间来回打转,就是因为没有做这个二分。
6.3 排查工具与复现思路
裸机环境下,能用的工具相对有限。JTAG调试器可以直接观察cache内容,对比cache和主存的值是否一致。逻辑分析仪挂在总线上去看实际访存序列,可以感知总线级别的乱序行为。这些手段比较重,但面对疑难杂症时比拍脑袋有效得多。
Linux环境下工具丰富很多。能想到的就有:
perf的mem事件可以观测缓存未命中数据,辅助定位cache业务问题。- 断电调试器看复杂场景的cache一致性。
- 直接操作
/dev/mem配合devmem2读写寄存器,验证外设视角的内存内容。 - 使用KCSAN(Kernel Concurrency Sanitizer)检测数据竞争,它能在早期阶段就报告可疑的并发访问。
复现思路上,我的建议是"降低特异性、提高压力":把DMA buffer尺寸调小,让描述符更新更频繁;把中断优先级调高,制造更多抢占;调紧凑循环,让生产者和消费者的操作间隙缩短。这些手段都在逼迫重排窗口暴露出来。
6.4 避免"加屏障到处撒"的冲动式修复
排查到崩溃边缘时,很容易产生"反正加屏障没坏处,到处都加"的念头。这个冲动必须克制。
屏障是有成本的,尤其在ARM上,一个dsb可能消耗几十到上百个周期。它还会阻塞流水线,影响关键路径的实时性。在中断处理程序里加一个过强的dsb,可能导致中断响应延迟劣化到不可接受。
更重要的是,乱加屏障会掩盖问题,而不是解决问题。如果某个变量缺失的屏障被一个远程位置的冗余屏障掩盖了,代码看起来正常,但下次重构或优化一改动,问题会换个形式再冒出来。
正确的做法是画一张"谁在什么时候访问哪些共享内存,发布点在哪,消费点在哪"的表格,逐个确认每一对关系之间是否都有恰当的屏障。我称它为"双向往来核对法":每次发布和每次消费之间,都要掌握一个完整的对照。
7. 性能和代价:屏障不是免费的
7.1 DMB/DSB 有多慢:实测数据
这里我给一组经验量级,不是精确数字,因为具体延迟取决于微架构和总线状态。在一个我常用的中端ARM SoC上,单条dmb大约需要几十个周期,dsb在访存队列深、总线繁忙时可能几百甚至上千周期。如果是在快速路径上频繁使用,性能损耗会非常显著。
很多实时嵌入式系统对中断延迟有硬性要求。中断处理中如果加了一条dsb,最坏情况下的延迟增量可能直接超标。这也是为什么我一直强调:屏障类型的选择要匹配场景,不是越强越好。
7.2 快速路径上怎么少用屏障
少用屏障并不等于降低安全性,而是通过设计降低屏障的使用频率。
第一种办法是合并发布点。多个标志位不要在每次修改时都发布一次,而是攒到一个结构体里,最后一次性发布。发布一次就只需要一个屏障。
第二种办法是减少共享访问粒度。把共享数据从"每次收发都修改"调整为"每N次形成一个批次再发布",消费者批量处理。
第三种办法是使用无锁单生产者单消费者队列,用精心设计的内存序减少屏障数量。这类队列在环状缓冲区的读/写指针发布点各需要一个屏障,频繁路径上的其他操作不需要屏障保护。
这些手段都是在"必须的屏障"与"不必要的屏障"之间做取舍。屏障的数量跟共享数据的边界数量相关,边界设计得越少,屏障开销就越低。设计时多付出一些结构上的思考,运行时就能少付出一些周期。
7.3 我在实际项目里的几条经验
我自己踩过几次坑之后,总结了几条规则,在这里分享出来。
第一,写驱动时,每个涉及共享内存的接口函数开头先问自己:谁写、谁读、顺序假设是什么。答不上来就不要往下写代码。
第二,加屏障时在注释里写明保护的场景。半年之后回来看代码,你不会记得当初为什么加这个屏障。注释可以不写架构细节,但一定要写同步关系。
第三,测试时使用比生产环境更高的并发和压力。屏障Bug通常不是立刻暴露的,而是累积到某个临界点才爆发。我自己遇到的大概率问题,几乎都在高负载压测超过数小时才出现。
第四,代码评审时,把屏障和锁一样看待。任何新增的屏障都需要有一个"为什么这里必须有"的明确答案。没有答案的屏障,要么删掉,要么想清楚再补上。
第五,别怕使用文档。芯片手册的"Memory ordering"章节和内核文档里对内存屏障的阐述,几乎收录了你能遇到的所有坑。我每次遇到模糊场景,第一反应都是翻文档,而不是自己瞎试。文档里的简短一段话,往往是芯片设计者用血泪换来的经验。