先讲一个我踩过的坑。去年调一块异构双核板子,从核用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被置位意味着“数据已经准备好”,结果数据还在后面没写完,接收方拿到flag=1就去读,读到的一定是垃圾。
为什么编译器敢这么做?因为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_wmb
Linux内核专门准备了两个接口给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表
我有一个无刷电机驱动项目,用的STM32F405,TIM1触发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_acquire |
| DMA从内存读到“写了一半”的描述符 | 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)和标志置位指令(如movs+str)的相对位置。如果标志写入在数据写入之前,就说明编译器做了重排,这时候加编译器屏障通常能解决;如果汇编顺序正确但运行仍出问题,再看硬件层,考虑Cache和弱一致性。
6.3 别把屏障当万能药
屏障解决的是顺序和可见性,它不能替代锁,也不能协调互斥。两个核同时对同一个数组做“读-改-写”,屏障是帮不了忙的,必须用原子操作或者锁。同时要意识到屏障的使用是具有“局部性”的:一个核在写数据时加了屏障,另一个核在读数时也必须对应加屏障,否则这种契约就是单方面的,完不成完整的同步。
一个很好的习惯是:给共享变量配一个清晰的状态机,记录“写入前”“写入中”“写入完成”三段,中间用屏障保证边界顺序。集中管理这些状态和数据的关系,比到处乱加dsb要安全得多。
另外多说一句:带OS的平台,内核的同步原语(自旋锁、信号量)内部已经做了屏障,如果代码里加了锁,通常不需要再额外手动加屏障。容易出问题的恰恰是自以为“没有并发”的裸机单核场景,因为看不到并发不代表DMA没有并发,DMA是宇宙中真正“看不见的第三者”。
这套东西看起来玄,本质就是用几行指令守住“先数据后标志”和“先标志后数据”这两条工程纪律。我自己经历了几次诡异bug,现在写代码已经养成了条件反射:涉及DMA描述符、中断置位标志、多核共享内存,都会下意识确认顺序有没有被编译器和CPU“自作主张”过。只要把这层防线扎好,数据通路再复杂都不会出乱子。