
先讲一个我踩过的坑。去年调一块异构双核板子从核用DMA从外部ADC连续采数据主核读共享内存做处理。设计不复杂代码写起来也顺结果实测时数据隔三差五出现错位有时候一帧数据中间会莫名其妙多出一个旧值。我当时第一反应是DMA时序不对调了半天外设配置后来才发现问题根本不在DMA本身而是编译器和CPU悄悄动了指令的顺序。内存屏障这个平时不太起眼的概念到了多核和DMA共存的场景里真的就是最后一道防线。现在已经不是单核单任务的年代。MCU都在往多核走外设则越来越依赖DMA搬数据串口DMA接收、SPI DMA、TIM DMA burst、外挂AD7606这样的并行采样芯片几乎全是DMA的活。这些场景有一个共同点内存访问的顺序一旦被编译器和硬件打乱轻则数据错位重则系统状态直接崩掉。我今天想把这些细节摊开来讲从原理到代码再到实际排查尽量讲透适合正在调DMA、多核共享内存或者被“诡异数据bug”折磨的嵌入式工程师。1. 问题到底出在哪指令重排不是玄学1.1 编译器层面的重排很多人第一次听到“指令重排”第一反应是“CPU做的吧”其实编译器才是第一个下手的人。现代编译器在开启优化后会在不影响单线程语义的前提下按照自己的“性价比”重新排列内存访问指令。举个例子代码里写的是buf[0] 0x01; buf[1] 0x02; flag 1;编译器在O2优化下完全可能把flag 1提前因为后面要触发某个外设读取数据或者被另一个CPU核监控。从单线程视角看编译器觉得这三条语句互相“不相关”换个顺序没啥影响。但在共享内存和DMA的视角里顺序就是一切flag被置位意味着“数据已经准备好”结果数据还在后面没写完接收方拿到flag1就去读读到的一定是垃圾。为什么编译器敢这么做因为C标准里没有规定内存访问必须保持“代码书写顺序”。只要单线程可观察行为一致编译器就有权调度。问题是嵌入式系统里的“可观察行为”还包括DMA控制器、另一个CPU核、外设寄存器这些“看不见的观察者”它们不在标准模型里。1.2 CPU层面的乱序执行编译器搞定之后CPU也会来“补刀”。现代处理器为了绕过内存访问的高延迟会允许后续指令在前一条指令的访存结果出来之前就开始执行。只要最终结果在单核视角下“看起来”一致硬件就认为没问题。ARM Cortex-A系列里的乱序执行窗口比较大高通的、海思的、全志的SoC基本都有这个特性。更麻烦的是双核以上的Cortex-A还带多级Cache每个核的L1 Cache可能是私有的数据什么时候从私有Cache刷到内存什么时候可见于另一个核硬件只保证“弱一致性”也就是默认不保证跨核可见的顺序。1.3 为什么现在的嵌入式最容易踩这个坑以前用8051或者M3跑裸机根本没有“多核”和“高缓存”的概念指令重排要么不明显要么因为外设简单、数据量小问题被掩盖。现在的嵌入式平台变化很大双核A7、A53配一颗M4或者M核做实时控制、A核跑应用共享内存作为通信通道DMA大量使用数据搬运和CPU执行是并行的Cache和主存之间的“时间差”被放大。这两股力量叠加指令重排就从“理论问题”变成了“实际问题”。2. 内存屏障到底是什么从执行模型讲清楚2.1 先理解内存访问的“顺序契约”内存屏障memory barrier是一种指令或编译器提示用来限制它前后内存操作的重排范围。它本身不搬数据不改变数据内容只做一件事约束顺序。要理解它得先分清“编译器屏障”和“硬件屏障”两个层级。编译器屏障只约束编译器不重排比如GCC的asm volatile( ::: memory)它告诉编译器“这里有一堵墙代码顺序不能乱动”。但CPU照样可能乱序执行所以硬件屏障才是真正的“物理墙”ARM上就是DMB、DSB、ISB这组指令x86上则是mfence、lfence、sfence。具体到ARM平台几个指令的区别值得记牢指令作用典型场景DMB保证屏障前后的内存访问顺序但不等待前面的访问完成多核共享数据、flag同步DSB保证屏障前所有内存访问都完成后屏障后的访问才开始DMA启动前确保描述符写完后发出请求ISB冲刷流水线和预取主要管指令执行顺序改写代码、切换MMU场景做数据同步少用x86平台相对省心因为有较强的内存一致性模型弱内存序问题不突出但原子操作和DMA依然要小心sfence和lfence的使用。2.2 四类屏障的功能对照按照Linux内核的习惯屏障可以分成明确的两组读屏障read barrier保证屏障之前的所有读操作先于屏障之后的读操作完成。对应到语义就是acquire它适合放在“读数据”这一侧。写屏障write barrier保证屏障之前的所有写操作先于屏障之后的写操作落到内存。对应release它适合放在“发布数据”这一侧。全屏障full barrier读和写都约束最保守也最贵。编译器屏障compiler barrier只限制编译器不碰CPU最轻量。虽然实际使用中我们要根据方向选择对应类型的屏障但从API名字看Linux内核里区分得很细smp_rmb、smp_wmb、smp_mb还有DMA专用的dma_rmb和dma_wmb。2.3 屏障在什么时刻“必须”出现不是每个共享变量都要加屏障那样代码会变得没法看。我的判断标准很简单只要有“先写数据再写标志”或者“先读标志再读数据”这种顺序依赖且数据会跨核、跨DMA访问就必须考虑屏障。典型场景是生产者-消费者模式一个实体生产数据并发信号另一个实体收到信号后消费数据。信号本身没有问题有问题的是“信号和数据之间的顺序”。单核场景加编译器屏障就够了反正CPU顺序可以被硬件保证很多M系列单核芯片其实不存在乱序执行。一旦上多核必须上硬件屏障一旦有DMA介入还需要考虑Cache同步这时候单独的屏障也不一定够用。3. 多核场景数据可见性与顺序保证的双重考验3.1 两个核看到的不一定是同一个世界多核一致性问题可以拆成两个层面可见性和顺序性。可见性指的是核A写完一个变量核B什么时候能看到更新顺序性指的是核A写的两个变量核B是否按A的写入顺序看到它们。用生活类比团队协作里两个人同时改一个文档如果一个人写完A段就立刻发消息说“我改完了”另一个人打开文档看到的是旧B段那就乱套了。屏障就像是“先彻底保存再发消息”和“收到消息后刷新页面”的流程约定。写屏障确保核A在屏障之前的写操作对于后续的“事件通知”来说都是已可见的。读屏障确保核B看到通知之后屏障后的读操作能够看到通知发生前、其他核完成的所有写入。3.2 多核典型代码怎么写假设核A是生产者核B是消费者共享一个内存缓冲区和就绪标志/* 核A生产者 */ data[0] temp0; data[1] temp1; data[2] temp2; /* 写屏障确保数据写完后再更新flag */ smp_wmb(); ready_flag 1;/* 核B消费者 */ /* 读屏障确保看到flag之后能读到最新data */ smp_rmb(); if (ready_flag 1) { use(data[0], data[1], data[2]); }这里wmb和rmb必须配对出现。只加生产者一侧消费者可能因为缓存问题读到老数据只加消费者一侧生产者的写顺序可能是乱的。这是完全对称的概念缺哪边都不行。3.3 Linux驱动的习惯写法如果你写Linux驱动这套东西有现成API不需要自己拼汇编。READ_ONCE和WRITE_ONCE负责防止编译器把单次访问拆成多次smp_load_acquire、smp_store_release则直接带屏障语义。/* 生产侧 */ WRITE_ONCE(shared_buf[0], value); smp_store_release(ready_flag, 1);/* 消费侧 */ if (smp_load_acquire(ready_flag)) { local READ_ONCE(shared_buf[0]); }这里smp_store_release隐含了写屏障smp_load_acquire隐含了读屏障名字直接对应前面的概念。新手容易犯的错是只用原子变量置标志比如atomic_set(flag, 1)原子性解决的是“读改写不被打断”解决不了“数据在flag之前的顺序”问题必须要配合屏障语义。4. DMA场景屏障的视野要扩大到“第三方”4.1 DMA是一台看不见内存Cache的“核心”DMA控制器传输数据时直接操作物理内存不经过CPU Cache。从CPU视角看Cache里的数据是“最新”的但DMA实际从内存读到的可能是旧值反过来说DMA写入内存后CPU如果从Cache读可能读到的还是旧值。这个问题叫Cache一致性而屏障在这里的角色更像“内存访问顺序的保险栓”。所以DMA场景的屏障使用有一个前置条件必须先解决Cache一致性。常用的有两大类方案一是使用dma_alloc_coherent分配一致性缓冲区硬件自动保持Cache和内存同步二是使用dma_map_single/dma_map_sg这类streaming接口在提交和回收时做Cache clean或invalidate。4.2 串口DMA、SPI DMA、AD7606采样三个典型现场先说串口DMA接收。一个典型的环形缓冲实现里DMA中断作为生产者把串口数据搬到内存主循环或RTOS任务作为消费者处理数据。常见写法是先检查接收长度计数器是否变化再读数据。如果没有屏障编译器可能把“读数据”提到“读计数器”前面最后处理到不完整的一帧。再看SPI DMA搭配SPI从机或外部ADC芯片。以市面上常见的AD7606方案为例这个芯片一次可以采样8个通道SPI DMA把一整个批次的数据连续搬到内存。CPU读取这批数据前必须保证DMA完成了最后一次突发传输。否则CPU可能读到“半帧“也就是一部分是新采样一部分是上次的旧值。这种情况下不仅需要屏障约束CPU指令顺序还需要在DMA完成中断中做一次Cache invalidate。TIM DMA burst又是另一个思路。以STM32为例TIM可以和DMA配合一次性把数组里的数据搬运到多个比较/捕获寄存器用来生成PWM波形阵列或者无刷电机驱动波形。看起来DMA是“只读内存”但如果CPU正在修改这个数组的内容而前一次DMA请求还在飞行就会发生数据撕裂。4.3 DMA场景屏障的真身dma_rmb 与 dma_wmbLinux内核专门准备了两个接口给DMA场景用dma_rmb()和dma_wmb()。字面意思是对DMA操作后的内存顺序做屏障实际上它在部分架构上等同于rmb/wmb在x86上可能什么都不做因为x86的存储模型本身比较强。裸机环境下STM32和ARM Cortex-M系列怎么处理M系列核心实际上没有复杂的乱序执行编译器屏障往往就够用个别情况会用到__DSB()。这个指令确保之前的所有内存访问都完成之后才继续执行。需要它的典型场景是CPU修改DMA描述符后发出DMA传输启动命令此时必须保证描述符已经在内存里完全落定不然DMA可能读到“写了一半”的描述符启动一个奇怪长度的传输。这里有个反直觉的点Cortex-M的__DMB()同样有效而且比__DSB()轻量。很多工程师一上来就喜欢用__DSB()因为“感觉更彻底”但DSB会阻塞CPU直到内存访问全部完成代价很大DMB只保证顺序不保证完成实时性损失更小。能用DMB的时候尽量不要用DSB。5. 实操在我自己的DMA工程里加屏障的完整记录5.1 踩坑现场TIM DMA burst 更新PWM表我有一个无刷电机驱动项目用的STM32F405TIM1触发DMA每次更新事件把内存里的一组数组搬到TIM比较寄存器组用来生成多个通道的PWM。数组存在普通SRAM里DMA burst模式CPU这边有个慢速任务周期性地改数组里的值。一开始代码很简单/* 修改PWM比较值 */ duty_table[0] calc_duty(ch0); duty_table[1] calc_duty(ch1); duty_table[2] calc_duty(ch2); /* 启动下一次DMA传输 */ __HAL_TIM_ENABLE_DMA_IT(htim1, TIM_DMA_CC1);实测出现的问题很怪示波器看波形占空比变化是分段的经常是“这次只更新了通道0和通道2通道1还是旧值”而且次数随机。我用HAL_TIM_DMA_Start_IT替代手动触发也一样。一开始怀疑TIM配置有问题看了半天没收获最后反汇编才明白编译器把三个数组赋值和后面的DMA启动操作重新排序了DMA触发时只读到了部分新的数据。5.2 加屏障后的效果解决办法分两步。第一把数组放到__attribute__((aligned(4)))并加上volatile修饰告诉编译器这组数据会“被DMA异步改变”不能做缓存第二在启动DMA之前加一条内存屏障强制让前面的写操作都刷出CPU写缓冲duty_table[0] calc_duty(ch0); duty_table[1] calc_duty(ch1); duty_table[2] calc_duty(ch2); /* 关键编译硬件屏障确保三个值都落定到SRAM */ __asm volatile(dmb sy ::: memory); __HAL_TIM_ENABLE_DMA_IT(htim1, TIM_DMA_CC1);加完之后无论把编译优化调到O2还是O3占空比更新都稳定了。这里dmb sy是Cortex-M7/M4上都能识别的指令sy表示全系统范围内参与顺序约束。这个隐含的memoryclobber会让GCC把所有内存访问的调度停在这里防止编译器跨过屏障搬动上面的赋值。5.3 性能影响DMA测速对比有人担心屏障代价高。我在同一平台做了一个粗糙的DMA测速实验用DMA连续搬512字节块主核反复提交数据和启动DMA分别测“裸跑”、“加DMB”、“加DSB”三种情况下的每秒钟传输次数。版本每轮耗时相对吞吐量稳定性无屏障约1.02us/轮基准偶发错误加DMB约1.10us/轮下降约8%稳定加DSB约1.38us/轮下降约35%稳定结论很直观DMB的代价可接受但DSB在吞吐窗口里确实贵。如果一个循环里频繁启动DMA而每次都加DSB总速率损失肉眼可见。6. 常见问题与排查技巧实录6.1 问题速查表现象、原因、对策工程中遇到过的典型问题我整理成了一个表排查时直接对照现象根因方向对策串口DMA接收时数据出现“旧帧”的一部分CPU在DMA未完全写完前读了缓冲编译重排导致计数判断被提前计数器与数据访问之间加编译器屏障必要时用dma_rmb多核通信中flag置1但对端读到旧数据缺少release/acquire语义组合生产侧用smp_store_release消费侧用smp_load_acquireDMA从内存读到“写了一半”的描述符CPU写描述符后未发硬件屏障写缓冲未落盘在写描述符后加dmb sy或__DSB()用volatile标记缓冲区后仍然有错乱volatile只防编译器缓存不防CPU乱序和Cache一致性必须结合屏障和Cache clean/invalidate加了屏障后DMA吞吐量骤降过度使用DSB这类强屏障特别是在循环里把屏障放在“最后一次写之后”很多地方只需DMB或编译器屏障6.2 从反汇编确认重排问题排查重排问题时我最推荐的手段是直接看汇编。GCC工具链里用arm-none-eabi-objdump -d导出编译后的二进制arm-none-eabi-objdump -d firmware.elf disasm.txt找到对应的函数看数据写指令如str和标志置位指令如movsstr的相对位置。如果标志写入在数据写入之前就说明编译器做了重排这时候加编译器屏障通常能解决如果汇编顺序正确但运行仍出问题再看硬件层考虑Cache和弱一致性。6.3 别把屏障当万能药屏障解决的是顺序和可见性它不能替代锁也不能协调互斥。两个核同时对同一个数组做“读-改-写”屏障是帮不了忙的必须用原子操作或者锁。同时要意识到屏障的使用是具有“局部性”的一个核在写数据时加了屏障另一个核在读数时也必须对应加屏障否则这种契约就是单方面的完不成完整的同步。一个很好的习惯是给共享变量配一个清晰的状态机记录“写入前”“写入中”“写入完成”三段中间用屏障保证边界顺序。集中管理这些状态和数据的关系比到处乱加dsb要安全得多。另外多说一句带OS的平台内核的同步原语自旋锁、信号量内部已经做了屏障如果代码里加了锁通常不需要再额外手动加屏障。容易出问题的恰恰是自以为“没有并发”的裸机单核场景因为看不到并发不代表DMA没有并发DMA是宇宙中真正“看不见的第三者”。这套东西看起来玄本质就是用几行指令守住“先数据后标志”和“先标志后数据”这两条工程纪律。我自己经历了几次诡异bug现在写代码已经养成了条件反射涉及DMA描述符、中断置位标志、多核共享内存都会下意识确认顺序有没有被编译器和CPU“自作主张”过。只要把这层防线扎好数据通路再复杂都不会出乱子。