资讯动态

内存屏障实战指南:多核与DMA场景下如何防止指令重排

发布时间:2026/10/26 17:47:32 来源:尧图企业网站定制
那段日子我在调一块双核板子现象特别诡异一个核心在中断里往共享缓冲区写完数据、再置一个标志位另一个核心在轮询标志位等到标志位置位后去读缓冲区。逻辑上没有任何问题volatile也加了但程序跑久了就会读到脏数据——缓冲区的一半还是旧值。排查到最后我盯着内核文档里那行“memory barrier”发呆突然意识到一件事在单核、顺序执行的世界里我们习惯把“写代码的顺序”默认为“CPU执行的顺序”但多核和DMA场景下这个默认完全不成立。这篇文章要聊的就是内存屏障它为什么是多核与DMA场景下防止指令重排的最后一道防线什么场景下必须用以及我在实际调板、调驱动过程中踩过的坑。适合搞嵌入式底层、内核驱动、以及被并发问题折磨过的同学读。1. 深夜排障一个“逻辑完全正确”却很诡异的Bug现场1.1 现象标志位置位了另一个核心却读不到背景板子是双核MCU同构SMP我用一块共享内存做核间消息传递。核心1在中断服务函数里完成“写数据 → 置标志位”核心0在主循环里等标志位置位后读数据。代码大概长这样/* 共享内存用 volatile 防止编译器优化掉 */ static volatile int flag 0; static uint32_t payload[64]; /* 运行在核心1的中断服务函数 */ void ISR_core1(void) { for (int i 0; i 64; i) payload[i] i * 0x5A; flag 1; /* 发布通知核心0数据已就绪 */ } /* 运行在核心0的主循环 */ void main_core0(void) { while (flag 0) { } /* 等待发布 */ use(payload); /* 这时候读数据 */ }合不合理看起来天经地义。单核单片机工程师基本都会这么写而且大概率不会出错。但在我这块板子上就是会偶发读到部分旧值flag已经是1了payload却还没写完。1.2 排查链路从逻辑检查到JTAG盯内存我第一反应是代码逻辑问题。检查了中断是否真的执行、flag地址是否有两处写入、数组是否越界。然后开了JTAG直接在核心0等待时读内存窗口——结果很迷内存里flag就是1payload数据也是完整的。也就是说“数据在内存层面明明是对的”但我核心0读到的payload却可能不是它最新的值。这个现象光靠“看内存”是看不出问题的因为问题出在存取路径上。后来我把优化等级从-O0调到-O2现象从“偶尔”变成“经常”这才意识到优化器参与进来了。再后来把核心0和核心1的缓存状态分开观察才把矛头指向了内存屏障。1.3 先给结论程序顺序不等于执行顺序很多嵌入式开发者习惯性认为“我代码里先写的就先执行、先执行完的另一个CPU就能看到”。这在单核顺序执行模型下勉强成立但在多核、带缓存、乱序执行的CPU上根本不成立。编译器会在无依赖的情况下调整指令顺序优化性能CPU流水线也会为了填满执行单元而乱序执行指令缓存一致性协议在核间同步时又有自己的时序。于是结论就变成了你要另外一个核心或一台外设看到某种顺序就必须显式地告诉它们这个顺序的边界——这个边界就是内存屏障。注意这里说的“多核”并不只指Linux SMP的大核处理器很多双核MCU、多核DSP、甚至“CPUDMA”这种非对称访问场景同样存在顺序问题。2. 指令重排的两个推手编译器优化与CPU乱序执行2.1 编译器重排优化器眼里的“等价变换”编译器做重排其实非常激进。标准C语言在单线程抽象机里对程序顺序是有要求的但凡是“观测不到的副作用”之间的顺序优化器有权调整。上面的代码如果有些变量不跨线程编译器完全可能把flag1挪到数据写完之后甚至合并掉一些中间状态。即便加了volatile也只能保证flag这个变量本身不被优化掉完全不能保证payload和flag之间的相对顺序。看个具体例子。编译器在优化下面两行时如果它分析出x和y没有依赖关系完全可以先把y2执行了x 1; y 2;假设x是外设寄存器、y是另一个核需要的标志位呢结果就是灾难。这也就是为什么Linux内核里有barrier()这类纯编译器屏障宏专门用来告诉GCC“这里你绝对不要给我跨行搬指令”。2.2 CPU乱序执行为了流水线效率硬件也学会了“插队”编译器重排说完了CPU的乱序执行更隐蔽。现代CPU执行一条store指令时并不需要等它真正写入缓存/内存才继续执行下一条指令。CPU会用寄存器重命名、多发射、乱序执行等机制尽量让流水线不空转。只要最终结果和“按顺序执行”的结果一致单核视角CPU就认为语义没被破坏。问题恰恰出在“单核视角”这四个字。在双核场景下核心1观察核心0的内存访问顺序跟核心0自己观察自己的顺序完全不同。核心0觉得“我已经先写了payload再写flag中间还隔了足够长时间”但核心1可能先看到flag的更新再看到payload的写入——因为payload的写入还在某个Store Buffer里没落到缓存。2.3 存储缓冲与失效队列乱序的物理基础要理解内存屏障为什么有效关键要理解两个硬件队列Store Buffer存储缓冲处理器执行store指令时先把数据写到Store Buffer再由缓冲异步地写回缓存/内存。好处是store指令不用等待慢速内存坏处是其他核心暂时看不到这个store。写屏障做的就是让屏障之前的store按顺序冲刷出去并等待相关缓存一致性操作完成。Invalidate Queue失效队列缓存一致性协议中一个核心要独占修改某个缓存行得先让其他核心里对应的缓存行失效。这个失效请求到达后CPU可能把它放进Invalidate Queue排队处理而不是立刻执行。如果随后CPU从该缓存行读数据就可能读到旧数据——因为失效还没被处理。读屏障的职责就是等失效队列处理完再继续后续的读操作。这两个队列的存在把“内存屏障是否有效”从玄学变成了清晰的硬件语义屏障指令会阻塞在这种队列的关键路径上保证顺序可见性。屏障类别作用对象解决什么ARM指令示例编译器屏障编译器防止优化器乱排指令barrier()读屏障CPU缓存/一致性防止后读越过前读dmb ld/ Linuxrmb写屏障CPU缓存/一致性防止前写越过后续写dmb st/ Linuxwmb全屏障两者读写均限制顺序dmb sy/ Linuxmb3. 屏障的“正确打开方式”从指令语义到内核屏障宏3.1 先破除一个广泛流行的误解说到内存屏障很多文章会讲“用它来刷新缓存”这个说法其实很不准确。内存屏障并不主动刷新任何缓存它做的是让CPU在关键节点上“等待并排序”等待Store Buffer里的数据按序一路冲到缓存/内存等待Invalidate Queue里的失效请求处理完毕或者配合DMB/DSB指令完成总线域的同步。所以它的本质是让“可见性”和“顺序性”在硬件层面得到保证而不是去清数据。因果关系也要搞清楚如果数据已经躺在某个核心的私有利缓存里另一个核心或DMA要访问它光靠屏障是无解的还得靠缓存一致性协议、缓存维护指令或者直接使用非cacheable内存。所以“屏障”和“缓存维护”是两件配套但不同的事。3.2 三种屏障的语义与对应指令ARM架构里常见的有三条DMB数据内存屏障。确保屏障前的所有内存访问不会越过屏障后的内存访问但它不要求屏障前的访问“执行完”只要求“访问的顺序不会被重排”。DSB数据同步屏障。比DMB更强它会等屏障前的所有内存访问彻底完成才允许后续访问执行。典型场景是操作外设寄存器后必须确保硬件真的看到了寄存器写入再继续下一步。ISB指令同步屏障。它影响的是指令流水线不是普通数据访问。主要用于修改系统控制寄存器、缓存/MMU配置生效等场景保证后续指令重新取指。在实际Cortex-A平台上最常用的内联写法#define mb() __asm__ __volatile__(dmb sy ::: memory) #define rmb() __asm__ __volatile__(dmb ld ::: memory) #define wmb() __asm__ __volatile__(dmb st ::: memory) #define dsb() __asm__ __volatile__(dsb sy ::: memory) #define isb() __asm__ __volatile__(isb ::: memory)补充一个容易踩的细节dmb ld、dmb st这种带ld/st限定的写法是ARMv8的UAL汇编风格在ARMv7里需要通过dmb 0xEld或dmb 0xFst这类编码来区分。嵌入式大多数场景直接用dmb sy就够了不需要过度细分。另外DMB指令还带共享域参数比如ish、ishst、osh。SMP场景下用ishinner shareable domain就够了很多Linux内核代码里会看到dmb ish。别小看这个参数它决定了这条屏障到底约束了哪些核心的可见性。3.3 Linux内核里的屏障宏别搞混了内核里屏障宏其实分几个层级barrier()纯编译器屏障不产生CPU指令。防止GCC把屏障前后的内存访问乱排裸机代码里也常用。rmb()/wmb()/mb()架构相关在SMP开启时会生成真正的CPU屏障指令在SMP关闭时退化成编译器屏障。smp_rmb()/smp_wmb()/smp_mb()专门用于SMP场景的屏障宏。单核编译时变成编译器屏障多核编译时才生成CPU指令。写驱动时优先用smp_*版本。还有一对容易被忽略但很重要的宏READ_ONCE()和WRITE_ONCE()。它们保证一个变量在单次访问里被完整读取/写入避免编译器把一次访问拆成多次或者合并相邻访问。它们不等同于屏障但很多“共享标志位”问题其实只要这两个宏就能解决一半。我的建议是先保证每个共享变量都走READ_ONCE/WRITE_ONCE再考虑要不要加屏障。4. 多核实战从自旋锁到无锁环形队列的屏障位点4.1 锁里面已经替你放好了屏障先给一个最容易被误解的结论如果你用对了锁内核/编译器/CPU已经在锁的实现里放好足够的内存屏障了。拿Linux的自旋锁举例自旋锁的lock/unlock内部会插入完整的屏障。拿到锁的临界区里的内存操作不会跑到锁之外对方释放锁后看到的操作也不会晚到锁之后。也就是说锁本身就是“内存屏障的封装”。如果你写出了这样的代码spin_lock(lock); mb(); /* 多余的屏障 */ critical_section(); spin_unlock(lock);那纯粹是画蛇添足多出来的mb只会拖慢系统。真正需要小心的是别用锁保护A数据又用锁保护B数据然后让另一个核在没拿锁的情况下读A和B——那屏障帮不了你因为锁的屏障只在“临界区访问”这个契约下生效。4.2 无锁环形队列屏障放在哪非常讲究很多高性能场景为了省锁会做单生产者单消费者的环形队列。这类代码在Linux内核里非常常见也是理解屏障放位的绝佳教材。struct ring { uint32_t head; /* 生产者写 */ uint32_t tail; /* 消费者写 */ uint32_t buf[N]; }; /* 生产者核心A/中断上下文 */ void ring_push(struct ring *r, uint32_t v) { r-buf[r-head (N - 1)] v; smp_wmb(); /* 关键屏障1buf的写入必须对消费者可见 */ WRITE_ONCE(r-head, r-head 1); } /* 消费者核心B/主循环 */ bool ring_pop(struct ring *r, uint32_t *out) { if (READ_ONCE(r-head) r-tail) return false; smp_rmb(); /* 关键屏障2先看到head更新再读buf */ *out r-buf[r-tail (N - 1)]; r-tail r-tail 1; return true; }分析一下两处屏障的必要性生产者在写buf之后、更新head之前放了一个写屏障。这保证一个事实当消费者看到head变大了它一定已经能看到buf的内容。如果这里不加屏障CPU可能先把head更新写入缓存buf的写入还卡在Store Buffer里消费者就会读到空洞数据。消费者在读head之后、读buf之前放了一个读屏障。这保证它读到新head后后续的buf读取不会被缓存的旧数据处理。如果这里不加读屏障消费者可能乱序发出load先读了buf后读到head新值。这个结构几乎是每一本讲并发编程的书都会出现的基础模型但工作里很多人写的第一版都没有屏障跑单核不触发一上多核或开启-O2就偶发数据错乱和文章开头那个bug一模一样。4.3 中断与进程上下文哪些屏障其实不用你管在Linux驱动开发里中断上下文硬中断/软中断和普通进程上下文之间的共享数据很多人会焦虑地到处插屏障。其实先别忙看看内核API已经替你做了什么场景内核API屏障是否已包含临界区互斥spin_lock/spin_unlock已封装原子计数/位操作atomic_*/set_bit/clear_bit已封装唤醒等待队列wake_up前后内核在排队机制里已处理使能/关闭中断local_irq_save/restore中断打断天然维护顺序如果是自己裸写共享标志位规则就很简单写数据 →smp_wmb()→ 更新标志位读标志位 →smp_rmb()→ 读数据。别把屏障放在临界区的中间也不要在屏障前后颠倒了发布/订阅的顺序。5. DMA场景的屏障盲区从描述符同步到串口DMA迷案5.1 DMA控制器也是“一个核心”只是它在总线上很多人一提到内存屏障就只想到CPU核间忽略了DMA控制器。严格来说DMA是一个独立的总线主设备它同样会主动读写内存和CPU之间也有一致性/顺序性问题。比如你在代码里写了desc-src (uint32_t)src; desc-dst (uint32_t)dst; desc-len len; HAL_DMA_Start_IT(hdma, ...);如果没有适当的顺序保证编译器可能把最后那句启动DMA的命令先编译执行而DMA已经开始读了描述符里的字段还没全部准备好于是搬了一个长度不对、地址不对的数据。这种情况在带DMA的驱动里非常典型所以很多BSP代码在填写完描述符后会加一个dma_wmb()确保描述符内容对外设可见后再写“门铃寄存器”启动位去通知DMA。5.2 描述符环的同步点非对称屏障设计网卡、存储控制器这类批量传输场景里DMA描述符是一个环形缓冲CPU和DMA分别在环的两端生产/消费。这里的屏障是非对称的CPU把新描述符写到环上需要dma_wmb()之后再更新自己的写索引或通知DMA的doorbell保证DMA不会读到半截描述符。DMA完成传输后会更新描述符的状态字段同时触发中断CPU在中断里读状态字段前需要dma_rmb()来确保读到的状态是DMA已经写入的最新值。这里说的是“DMA场景的读屏障/写屏障”不是“通用核间屏障”。Linux内核里有专门的dma_wmb()和dma_rmb()宏其实就是针对DMA/外设访问做了折中的屏障实现写驱动时优先用它们。不过这里有个很容易踩的坑如果内存是cacheable的DMA写入会绕过CPU的缓存这时候光有屏障根本不够。必须用dma_alloc_coherent()分配一致内存或者用dma_map_single()配合方向参数做cache clean/invalidate。换句话说屏障解决“顺序”缓存维护解决“一致性”两者必须搭配。5.3 缓存一致性的第二层麻烦为什么说“清cache”不是屏障能完成的一个非常经典的嵌入式场景STM32H7有D-CacheCPU往一个数组写数据然后启动DMA把这个数组搬到外设。如果没有先把CPU缓存里的数据clean到内存里DMA会从内存里拿旧数据。这个场景不是“内存屏障”能救的必须调SCB_CleanDCache()这类接口。反过来DMA从外设收到数据写入内存CPU后续要读这块内存。如果DMA写入时CPU缓存里恰好残留了这块地址的旧缓存行CPU会直接读到缓存的旧值。这就要在CPU读之前做SCB_InvalidateDCache()或先clean再invalidate。给个简单的对照表场景该用屏障该用缓存维护/DMA APICPU核间共享是否缓存一致性协议负责CPU与DMA共享一致内存是描述符/状态位是保证内存属性一致CPU与DMA共享普通cacheable内存否屏障不够是clean/invalidate或DMA API5.4 串口DMA接收“首帧丢失”的完整排查链路串口DMA接收首帧丢失几乎是每个用STM32F1系列HAL库的人都踩过的坑。这个问题和内存屏障关系不大但正好说明“DMA同步问题”和“指令重排”容易被混淆。我用STM32F103C8T6复现过一次配好串口DMA接收环形缓冲第一次收到数据时最开始一部分出现丢失连续接收时又正常。排查链路是这样的先排除配置时序问题多半出在“先使能串口接收后使能DMA”这类顺序上。最正确的顺序是先配置好DMA、初始化DMA通道且使能传输最后再使能USART的接收和DMA请求位USART_CR3的DMAR位。否则USART收到第一个字节时DMA通道还没准备好数据直接溢出。检查HAL句柄是否被复用如果同时开了多个DMA通道注意hdma句柄是否被重复使用或覆盖。检查NDTR计数在DMA传输完成中断里看hdma-Instance-NDTR可以判断实际搬了多少字节。若计数不对往往是长度配置或DMA循环模式设置问题不是“重排”。检查缓存一致性带D-Cache芯片如果是H7这类有Cache的芯片DMA搬到内存后CPU必须先做SCB_InvalidateDCache()再访问缓冲区否则就会看到旧数据。很多人把这种现象误判成“内存屏障缺失”实际是缓存没失效。最后才考虑编译器/CPU屏障确认上述都正确、但读到的数据窗口里某一个变量的发布顺序还是有偏差才去补dmb/dsb或使用READ_ONCE。绝大多数串口DMA首帧丢失跟“指令重排”真没关系别一上来就上纲上线。提示经验法则是先怀疑配置时序和cache一致性再怀疑乱序。乱序是最后的嫌疑犯而不是第一嫌疑犯。5.5 顺带说下DMA测速时的数据可见性很多朋友做DMA带宽测试时也会发现“测速结果跳来跳去”。除了时钟和总线负载波动外还有一个不显眼的原因测试程序在DMA写完后立刻用CPU去读结果缓冲区如果没做读屏障或缓存失效读到的很可能是缓存里的旧数据测出的带宽要么虚高命中了缓存要么虚低等待无效操作。正确的测法应该是DMA完成中断里先做必要的rmb/cache invalidate再统计或者干脆在DMA搬运完成后再统一清理不要让被测路径混入没必要的同步开销。否则测出来的数字不是DMA的极限而是你同步代码的下限。6. Cortex-M与Cortex-A的屏障差别嵌入式里哪些时候真的需要它6.1 Cortex-M上很少听说要加屏障原因很直接Cortex-M系列处理器普遍是顺序执行的没有乱序执行能力。M0/M0/M3/M4基本没有D-CacheM7、H7除外所以双核MCU之间共享数据时缓存一致性挑战主要在带D-Cache的型号上。但这不等于Cortex-M世界完全不需要屏障。当你在写外设寄存器比如启动DMA、切换DMA流、配置复位时编译器优化和总线矩阵仲裁也会引入顺序问题。CMSIS已经提供了__DMB()、__DSB()、__ISB()裸机里该用就用__DSB(); /* 确保前面的外设寄存器写入真正被硬件接收 */ HAL_UART_Receive_DMA(huart, buffer, len);很多老工程师习惯在初始化DMA并且操作复位/使能寄存器前后插入__DSB()避免配置还没落对外设就启动干活了。这是有道理的和“核间内存屏障”依然是两码事但同属顺序保证的范畴。6.2 Cortex-A平台上DMB/DSB/ISB怎么用才合适Cortex-A是乱序多发射架构内存屏障是不能回避的话题。如果你写的板级代码既不在Linux内核里又需要多核同步可以直接用内联汇编#define mb() __asm__ __volatile__(dmb ish ::: memory) #define rmb() __asm__ __volatile__(dmb ish ld ::: memory) #define wmb() __asm__ __volatile__(dmb ish st ::: memory) #define dsb() __asm__ __volatile__(dsb ish ::: memory)选择建议两个CPU核之间共享数据、发布状态用dmb ish就够了它覆盖内部共享域。修改外设/时钟/门铃寄存器希望硬件立即看到用dsb因为它会等待前面的store彻底完成。修改MMU页表、系统控制寄存器或者做指令缓存维护后想让流水线重新取指用isb。如果只是防编译器优化不用花钱请CPU屏障barrier()或asm volatile( ::: memory)搞定。6.3 我个人调了几个月屏障问题的体会把内存屏障当作“最后一道防线”是准确的但它不是万灵药。接触过的绝大多数并发/DMA问题根源是三个第一编译器优化下的共享变量没做READ_ONCE/WRITE_ONCE第二有D-Cache的芯片上漏做了缓存一致性维护第三配置DMA与外设的启动顺序搞反了。真正的CPU乱序重排问题反而很少。所以我的排查顺序永远是先用对原子宏/锁/正确内存属性然后检查缓存维护和DMA启动时序最后才去计算屏障该放哪。想明白这一点就会被带回一个事实内存屏障解决的是“顺序可见性”而工程里很多人抱怨的竞态问题其实是“内存和缓存之间谁来当裁判”的问题。理解了这一层再回头去放屏障你会放得又稳又少。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑