STM32 采集效率翻倍!ADC+DMA 乒乓缓冲 + FreeRTOS 并行架构全拆解
记得最早做 ADC 采集项目的时候,踩过一个很经典的坑:用轮询方式读多通道 ADC,CPU 全程死等转换完成,啥别的事都干不了;后来换成单缓冲 DMA,采集倒是不用 CPU 管了,但处理数据的时候要么停掉 DMA 等处理完,要么硬着头皮边写边读,经常读到一半新一半旧的 “撕裂数据”。
相信很多人都卡在这一步:知道 DMA 能解放 CPU,但不知道怎么让 “采集” 和 “处理” 真正同时跑起来。本文就从硬件原理到工程代码,完整拆解一套工业级常用方案:ADC 非连续转换模式 + DMA 乒乓双缓冲 + FreeRTOS 双线程解耦,实现硬件采集与 CPU 计算完全并行,全程几乎零 CPU 占用。
读完这篇文章,你可以掌握这四个核心能力:
- 彻底搞懂 ADC 非连续转换模式的工作逻辑与适用场景
- 从零实现 DMA 乒乓双缓冲,从根源避免读写冲突
- 用 FreeRTOS 任务通知做轻量同步,替代笨重的信号量
- 一套可直接复用的、符合工业规范的并行采集代码框架
一、核心原理拆解:为什么这套架构能实现 “并行采集”
很多人用了很久 DMA,也只是停留在 “配置好就能自动传数据” 的层面,并没有真正理解并行的本质。我们从三个核心模块逐一拆开讲。
1.1 ADC 扫描 + 非连续转换模式
要讲非连续模式,得先理清 ADC 的三个基础模式,很多新手在这里就绕晕了。
- 扫描模式(Scan Conversion Mode):ADC 按照预设的 Rank 顺序,依次转换多个通道,而不是只转一个通道。比如开 6 个通道,扫描模式开启后,ADC 会自动从通道 0 转到通道 5,不用手动挨个切换。
- 连续转换模式(Continuous Conversion Mode):一组转换完成后,立刻自动开始下一轮,不需要外部触发。适合不间断连续采样的场景。
- 非连续转换模式(Discontinuous Conversion Mode):在扫描序列的基础上,把所有通道分成若干小组,每次触发只转换其中一组,转换完成就停止,等下一次触发再转下一组。
举个最直观的例子:6 个 ADC 通道,设置非连续转换数为 2,那么整个序列就被分成了 3 组:
- 第 1 次触发:转换通道 0、通道 1
- 第 2 次触发:转换通道 2、通道 3
- 第 3 次触发:转换通道 4、通道 5
三次触发之后,完成一轮完整的 6 通道采集。
很多人会问:好好的连续模式不用,为什么要拆成一组一组的? 这就是非连续模式的价值:在实时控制系统里,我们不一定需要一次把所有通道都采完。比如电机控制,电流、电压通道需要高频采样,温度、电压母线通道可以低频采样;或者每采一组就立刻做一次闭环计算,不用等所有通道都转完,降低单次采样的延迟。配合软件触发,我们可以精准控制每一组的采样时机。
1.2 DMA 乒乓双缓冲的本质
先讲单缓冲的痛点:只有一块缓冲区的时候,DMA 往里写数据的同时,CPU 如果往外读,就很容易读到 “半新半旧” 的数据 —— 前半字节是上一帧的,后半字节是新一帧的,这就是数据撕裂。要么你就等 DMA 停了再读,那采集就断了;要么你就硬读,数据正确性没保障。
乒乓缓冲(Ping-Pong Buffer)的核心思想,就是用两块大小完全相同的缓冲区,交替承担 “写入目标” 和 “读取对象” 的角色:
- 第一阶段:DMA 往缓冲 A 写数据,此时 CPU 不碰 A,只读取缓冲 B 里上一帧的数据
- A 写满触发中断,立刻切换 DMA 目标到缓冲 B,开始写 B
- 第二阶段:DMA 往 B 写数据,同时 CPU 去读缓冲 A 里刚采完的数据
- B 写满后再切回 A,循环往复
两块缓冲区就像两个乒乓球拍,你打过来我打过去,读写永远落在不同的内存上,从根源上避免了竞争。不需要加锁、不需要关中断,硬件采集和 CPU 处理完全并行,采集全程不会因为处理而暂停,处理也不会因为采集而阻塞。
严格来说这不是 “零拷贝”,数据还是从 ADC 外设搬到了内存,但它做到了采集与处理的零等待并行,这是嵌入式采集系统里最核心的性能优化思路之一。
1.3 RTOS 下的线程解耦思想
很多人做 RTOS 采集,喜欢把 “启动 ADC、等中断、处理数据” 全塞在一个线程里,写是能写,但扩展性很差,改采集逻辑要动处理代码,改处理代码又容易影响采集时序。
标准的工程做法,是拆成两个职责完全独立的线程:
- 调度线程(高优先级):只干三件事 —— 等 DMA 中断通知、切换缓冲、重启采集、给处理线程发就绪信号。它的核心目标是保证采集时序不被打断,响应越快越好。
- 处理线程(普通优先级):只干一件事 —— 收到就绪通知后,读取对应缓冲区的数据,做电压换算、滤波、FFT、存储、上传等业务逻辑。
为什么一定要拆成两个线程?有三个核心好处:
- 职责单一:采集链路和业务逻辑完全解耦,改算法不用动采集驱动,改硬件不用动处理代码
- 优先级可控:调度线程设为高优先级,保证 DMA 一写满就能立刻切换缓冲,不会因为处理线程在跑就耽误采集
- 扩展性强:后面要加滤波算法、加 LCD 显示、加 MQTT 上传,只需要修改或新增处理线程,采集链路完全不用动,不会引入新的风险
二、CubeMX 手把手全配置
下面以 STM32F407 为例,一步步配出完整的工程环境。所有参数都会讲清楚为什么这么设,避免只会抄配置不懂原理。
2.1 基础系统配置
- RCC 时钟:外部高速晶振 HSE,配置系统主频为 168MHz,APB1 分频为 42MHz,APB2 分频为 84MHz,ADC 时钟配置为 84MHz 分频,保证 ADC 工作在合规频率内。
- SYS 时基:Debug 选 Serial Wire,时基源选择定时器 TIM6。
⚠️ 踩坑提醒:FreeRTOS 内核会占用 SysTick 作为系统时基,如果 HAL 库的时基也用 SysTick,两者会冲突,导致延时函数不准、系统跑飞。所以开了 FreeRTOS 之后,HAL 时基一定要改成定时器。
- 串口配置:开启 USART1,波特率 115200,8 位数据位,1 位停止位,无校验,用于日志输出。
2.2 ADC 外设详细配置
开启 ADC1,勾选 IN0~IN5 共 6 个通道,Rank 顺序按通道号依次排列,采样时间统一设置为 15 个周期。核心参数按以下方式配置:
- Scan Conversion Mode:Enable。开启扫描模式,才能自动转换多个通道。
- Continuous Conversion Mode:Disable。关闭连续转换,配合非连续模式,每次触发只采一组。
- Discontinuous Conversion Mode:Enable。开启非连续转换。
- Number of Discontinuous Conversions:2。每组 2 个通道,6 个通道分 3 次触发完成。
- Data Alignment:Right alignment。右对齐,方便直接读取原始值。
- DMA Continuous Requests:Disable。关闭 DMA 连续请求,非连续模式下每次触发只传一组数据,不需要连续传输。
2.3 DMA 通道配置
在 ADC1 的 DMA 设置里添加 DMA 通道,参数如下:
- Direction:Peripheral To Memory,外设到内存方向。
- Mode:Normal,单次传输模式。
这里不用 Circular 循环模式,因为我们要手动切换两个缓冲区的地址,乒乓切换需要软件控制目标内存,循环模式只针对单块缓冲区自动循环。
- Peripheral Increment Address:Disable。外设地址固定,就是 ADC 的数据寄存器地址,不用自增。
- Memory Increment Address:Enable。内存地址自增,依次写入缓冲区的每个元素。
- Data Width:外设和内存都选 Half Word(半字,16 位),对应 ADC 的 12 位转换结果。
- Priority:High。DMA 优先级设高,保证采集数据不被其他 DMA 通道打断。
2.4 NVIC 中断配置
- 开启对应 DMA 通道的全局中断(ADC1 对应 DMA2 Stream0)。
- 抢占优先级设为 5,子优先级设为 0。
⚠️ 踩坑提醒:FreeRTOS 有一个系统最大调用优先级阈值
configMAX_SYSCALL_INTERRUPT_PRIORITY,默认值为 5。只有抢占优先级数值≥5(也就是优先级等于或低于这个阈值)的中断,才能安全调用xxxFromISR系列函数。如果把 DMA 中断优先级设成 4(数值更小,优先级更高),在中断里发任务通知会直接触发 HardFault,而且这个问题非常隐蔽,新手很容易踩。
- 不需要开启 ADC 全局中断。我们靠 DMA 传输完成中断来做事件同步,ADC 本身不需要产生中断。
2.5 FreeRTOS 配置
启用 FreeRTOS,使用原生 API 接口,创建两个任务:
adc_sched_task:优先级 3(较高),栈大小 256 Word,负责采集调度与缓冲切换adc_process_task:优先级 2(普通),栈大小 512 Word,负责数据处理与日志输出
栈大小的估算依据:调度线程逻辑简单,只有状态切换和函数调用,256 字足够;处理线程涉及浮点运算、串口打印,栈开销更大,给 512 字留足余量。调度线程优先级更高,保证 DMA 中断触发后,能第一时间切换缓冲,不耽误下一次采集。
三、工程级代码完整实现
所有代码都写在 CubeMX 生成的 USER CODE 区域内,重新生成工程不会被覆盖。全程使用静态数组分配缓冲,符合工业级内存规范,最后会补充 malloc 版本的差异与取舍。
3.1 全局变量与缓冲定义
放在USER CODE BEGIN PV区域:
/* USER CODE BEGIN PV */ #include "FreeRTOS.h" #include "task.h" /* 乒乓双缓冲:每组2个通道,每个缓冲2个16位数据 */ static volatile uint16_t s_adc_buf1[2]; static volatile uint16_t s_adc_buf2[2]; /* 当前正在写入的缓冲标记:0=buf1,1=buf2 */ static volatile uint8_t s_cur_write_buf = 0; /* 任务句柄 */ TaskHandle_t g_adc_sched_task_hdl = NULL; TaskHandle_t g_adc_process_task_hdl = NULL; /* USER CODE END PV */这里有两个细节:
- 缓冲区加
volatile修饰,因为它们会在 DMA 硬件和线程中同时访问,防止编译器优化掉读写操作。 - 缓冲大小严格匹配非连续转换数:每组 2 个通道,每个通道 16 位,所以每个缓冲定义 2 个
uint16_t元素。
3.2 DMA 传输完成中断回调
重写 ADC 转换完成回调函数,放在USER CODE BEGIN 0区域:
/* USER CODE BEGIN 0 */ void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc->Instance == ADC1) { BaseType_t xHigherPriorityTaskWoken = pdFALSE; /* 仅向调度线程发送通知,不做任何业务逻辑 */ xTaskNotifyFromISR(g_adc_sched_task_hdl, 0, eNoAction, &xHigherPriorityTaskWoken); /* 如果有高优先级任务被唤醒,执行任务切换 */ portYIELD_FROM_ISR(xHigherPriorityTaskWoken); } } /* USER CODE END 0 */💡 设计原则:中断要 “快进快出”。这里只有两行核心逻辑,既不读数据、也不做计算、更不打印。中断只负责发事件通知,所有业务逻辑全部放到任务上下文执行。
很多新手喜欢在中断里算电压、打印日志,硬生生把几微秒就能走完的中断拖到几十微秒甚至毫秒级,严重挤占其他中断的响应时间,这是嵌入式实时系统的大忌。
3.3 线程 A:采集调度线程
负责缓冲切换与采集启停,是整个采集链路的调度核心:
void adc_sched_task(void *pvParameters) { /* 初始化:先启动第一次采集,目标为buf1 */ s_cur_write_buf = 0; HAL_ADC_Start_DMA(&hadc1, (uint32_t *)s_adc_buf1, 2); /* 注意:长度是数据个数,不是字节数! */ for (;;) { /* 阻塞等待DMA传输完成通知,pdTRUE表示等待后清空通知值 */ ulTaskNotifyTake(pdTRUE, portMAX_DELAY); /* 先停止当前DMA,再切换缓冲,避免硬件传输中改地址 */ HAL_ADC_Stop_DMA(&hadc1); uint8_t ready_buf_idx; if (s_cur_write_buf == 0) { /* 当前写的是buf1,就绪的就是buf1,下一次切到buf2 */ ready_buf_idx = 0; HAL_ADC_Start_DMA(&hadc1, (uint32_t *)s_adc_buf2, 2); s_cur_write_buf = 1; } else { /* 当前写的是buf2,就绪的就是buf2,下一次切到buf1 */ ready_buf_idx = 1; HAL_ADC_Start_DMA(&hadc1, (uint32_t *)s_adc_buf1, 2); s_cur_write_buf = 0; } /* 向处理线程发送通知,携带就绪的缓冲编号 */ xTaskNotify(g_adc_process_task_hdl, ready_buf_idx, eSetValueWithOverwrite); } }⚠️ 踩坑提醒:
HAL_ADC_Start_DMA的第三个参数是数据项的个数,不是字节数。16 位宽度下,传 2 就代表传输 2 个半字,共 4 字节。很多人顺手写成sizeof(s_adc_buf1),结果传了 4,相当于一次采 4 个通道,直接越界访问后面的内存,轻则数据错乱,重则直接 HardFault。这个坑我当年刚学的时候卡了整整一下午。
3.4 线程 B:数据处理线程
收到就绪通知后,读取对应缓冲区的数据,做电压转换与业务处理:
void adc_process_task(void *pvParameters) { for (;;) { uint32_t ready_buf_idx; /* 阻塞等待通知,取出缓冲编号 */ xTaskNotifyWait(0, 0xFFFFFFFF, &ready_buf_idx, portMAX_DELAY); uint16_t *p_ready_buf; if (ready_buf_idx == 0) { p_ready_buf = (uint16_t *)s_adc_buf1; } else { p_ready_buf = (uint16_t *)s_adc_buf2; } /* 原始值转实际电压:12位ADC,参考电压3.3V */ float ch0_volt = p_ready_buf[0] * 3.3f / 4096.0f; float ch1_volt = p_ready_buf[1] * 3.3f / 4096.0f; /* 业务处理:打印日志,实际项目可替换为滤波、FFT、存储等 */ printf("通道0: %.3fV | 通道1: %.3fV\r\n", ch0_volt, ch1_volt); } }处理线程的执行时间完全不影响采集,只要在一帧采集周期内处理完就行。比如 1kHz 采样率,每帧 1ms,只要你的算法耗时小于 1ms,就完全不会拖慢采集速度,这就是乒乓架构的优势。
3.5 malloc 版本与静态内存的取舍
很多教学示例里喜欢用pvPortMalloc动态分配缓冲区,代码大概是这样:
/* 教学示例用,工业项目不推荐 */ uint16_t *p_buf1 = pvPortMalloc(2 * sizeof(uint16_t)); uint16_t *p_buf2 = pvPortMalloc(2 * sizeof(uint16_t));教学里用 malloc 只是为了演示动态内存用法,但在真实工业项目里,固定生命周期的缓冲区一律优先用静态数组,核心原因有四个:
- 内存碎片风险:反复申请释放内存,会让堆空间越来越碎片化,后期可能申请不到连续的大块内存,嵌入式设备没有内存整理机制,碎片只会越积越多。
- 分配失败风险:
malloc可能返回 NULL,嵌入式环境下几乎没有优雅的兜底方案,大概率直接死机。静态数组编译时就分配好,永远不会分配失败。 - 执行时间不确定:malloc 的执行时间不是固定的,取决于堆空间的空闲状态,违反实时系统 “时间可预测” 的核心要求。
- 栈堆溢出风险:单片机的堆空间本身就很小,分配不当很容易和栈空间溢出冲突,引发玄学 bug。
📌 工业级规范:大小固定、生命周期贯穿整个程序运行的内存,全部使用静态分配。只有那些临时使用、用完就释放的小块内存,才酌情使用动态分配。
四、完整运行时序与流程拆解
很多人配置完代码能跑,但说不清每一步到底是谁在干什么。我们把整个乒乓循环拆成五步,彻底搞懂并行是怎么实现的。
4.1 系统启动执行顺序
- 系统时钟、外设、DMA、NVIC 依次初始化
- FreeRTOS 内核初始化,创建两个任务
- 调度器启动,高优先级的调度线程先运行
- 调度线程启动第一次 ADC+DMA,然后进入阻塞等待通知
- 处理线程启动,也进入阻塞等待通知
- DMA 后台开始往 buf1 写数据,CPU 空闲,可执行其他低优先级任务
4.2 乒乓循环全流程
第一步:采集第一帧
DMA 后台向 buf1 写入 2 个通道的数据,两个线程均处于阻塞状态,CPU 可以执行其他任务或者进入空闲。
第二步:第一帧采集完成
buf1 被写满,触发 DMA 传输完成中断;中断里给调度线程发通知,随后退出中断。
第三步:切换缓冲
调度线程因为优先级高,被立刻唤醒;它先停止 DMA,将写入目标切换为 buf2,立刻重启 ADC 开始第二帧采集;随后向处理线程发送通知,告诉它 buf1 的数据就绪了。做完这些,调度线程再次进入阻塞。
第四步:采集与处理并行执行
DMA 开始往 buf2 写第二帧数据;同时处理线程被唤醒,读取 buf1 里的第一帧数据,做电压计算、滤波、打印等操作。这就是整个架构最核心的并行时刻:DMA 在后台搬数据,CPU 在前台算数据,两个硬件完全同时工作,谁也不等谁。
第五步:循环往复
buf2 写满后,再次触发中断,重复上述流程,切回 buf1 写入,处理线程去读 buf2 的数据。
4.3 并行时间线分析
我们用文字画一条时间轴,直观对比单缓冲和乒乓缓冲的差异:
- 单缓冲方案:采集 T 时间 + 处理 T 时间 = 总耗时 2T,处理的时候不能采集,采集的时候不能处理
- 乒乓缓冲方案:采集第二帧的同时处理第一帧,总耗时只取决于更长的那个。只要处理时间≤采集时间,总耗时就等于采集时间,处理相当于 “白送” 的。
这也是为什么这套方案能大幅提升采集效率 —— 它不是让 CPU 跑更快,而是把原本被浪费的等待时间利用了起来。
五、架构深度分析与横向对比
5.1 这套架构的 4 个核心优势
- 硬件采集零 CPU 占用:ADC 转换 + DMA 搬运全程由硬件自主完成,CPU 只在切换缓冲的时候花费几微秒,其余时间完全解放,可以跑通信、显示、算法等其他任务。
- 数据零撕裂:读写永远操作不同的缓冲区,不会出现 “读一半被改写” 的情况,数据完整性有绝对保障,不需要任何锁机制。
- 中断轻量高效:中断仅做事件通知,耗时控制在微秒级,不会长时间关中断,也不会影响其他外设的中断响应。
- 模块高度解耦:采集驱动层和业务处理层完全分离,换 ADC 通道只改硬件配置,加算法只改处理线程,互不干扰,项目越大优势越明显。
5.2 RTOS 版本 vs 裸机版本 详细对比
很多人会问:裸机也能做乒乓缓冲,为什么一定要上 RTOS?我们从多个维度做个横向对比:
表格
| 对比维度 | 裸机乒乓方案 | RTOS 多线程方案 |
|---|---|---|
| 同步机制 | 全局标志位 + 主循环轮询 | 任务通知,事件驱动 |
| 执行模型 | 主循环顺序执行,处理时阻塞后续逻辑 | 多线程抢占调度,采集与处理完全并行 |
| 实时性 | 较差,主循环其他任务会延迟处理响应 | 优秀,优先级调度,采集响应时间确定 |
| 扩展性 | 差,新增功能容易让主循环越来越臃肿 | 好,新增业务只需加线程,耦合度低 |
| 内存开销 | 小,无需 RTOS 内核额外 RAM | 稍大,需要任务控制块、栈空间等开销 |
| 调试难度 | 简单,逻辑线性 | 稍复杂,需要理解抢占与同步 |
| 适用场景 | 简单单功能采集、极小资源 MCU | 复杂多任务项目、中高性能 MCU |
选型建议:如果是极简单的采集功能,MCU 资源又非常紧张,裸机乒乓足够用;如果项目里还有通信、显示、控制等多个功能模块,对实时性有要求,优先上 RTOS 方案,长期来看开发和维护效率高很多。
5.3 为什么用任务通知,而不是二值信号量 / 消息队列?
很多人做同步,第一反应就是用二值信号量或者消息队列。但在这种一对一的线程同步场景里,任务通知是更优的选择。
从底层实现来看:
- 二值信号量需要一个独立的队列控制块结构体,有自己的链表、计数器等字段,要占用几十字节的额外内存。
- 任务通知没有额外的内核对象,它本质就是任务 TCB 结构体里的一个变量,操作它就是直接修改任务控制块,不需要走队列链表的复杂逻辑。
量化对比下来:
- 内存开销:任务通知零额外内存,比二值信号量节省约 60% 的 RAM 占用。
- 执行速度:任务通知的发送和接收速度,比二值信号量快 40%~60%,因为少了很多队列操作的 overhead。
- 功能灵活性:任务通知不仅能做同步,还能携带数据(比如本例中的缓冲编号),可以替代二值信号量、计数信号量,甚至轻量的消息传递。
当然它也有适用边界:一对一同步用任务通知最优;如果是多个任务同步一个事件,还是用信号量更合适;如果需要传递大量数据、有多消费者,就用消息队列。技术没有绝对的好坏,选匹配场景的就行。
六、适用场景与踩坑指南
6.1 推荐使用的场景
- 中高速连续数据采集:比如振动传感器、音频采集、电能质量监测,采样率在几 kHz 到几十 kHz 的场景
- 多通道实时控制系统:比如电机驱动、电源控制,需要分批次采样并快速闭环计算
- 批量算法处理场景:需要做数字滤波、FFT、校准算法,处理耗时较长的场景
- 多任务复杂项目:系统内还有通信、显示、存储等多个任务,需要解耦的场景
6.2 不推荐使用的场景
- 超低频采样:几秒甚至几分钟才采一次,轮询方式完全够用,上乒乓缓冲和 RTOS 属于过度设计
- 内存极度紧缺:RAM 只有几 KB 的极小资源 MCU,放不下 RTOS 和双缓冲的开销
- 极致低延迟单点处理:只需要单通道数据,采集完立刻就要响应,双缓冲反而会引入一帧的延迟
6.3 开发必看踩坑点
DMA 传输长度单位陷阱HAL 库的 DMA 启动函数,长度参数是数据项的个数,不是字节数。一定要根据数据宽度来算,不要直接传 sizeof,否则必然越界。
FreeRTOS 中断优先级配置错误所有调用
xxxFromISR函数的中断,抢占优先级必须大于等于configMAX_SYSCALL_INTERRUPT_PRIORITY的数值。优先级设高了(数值更小)会直接触发 HardFault,而且很难排查。缓冲大小与分组数不匹配非连续转换数是多少,每个缓冲的元素个数就要是多少。比如非连续数是 3,缓冲只开了 2 个元素,DMA 写的时候会直接冲掉后面的内存,引发各种玄学 bug。
切换缓冲前不停止 DMA必须先调用
HAL_ADC_Stop_DMA,再修改目标地址,再重新启动。如果传输过程中直接改地址,DMA 可能会出现传输错误,甚至访问非法地址。全局共享变量不加 volatile中断和线程共享的标志位、缓冲区,一定要加
volatile修饰,否则编译器开启优化后,会把变量缓存到寄存器里,中断改了值线程看不到,程序就死等在那里。
结尾总结
这套 ADC+DMA 乒乓缓冲 + FreeRTOS 的并行架构,核心本质其实就是一句话:让专业的硬件干专业的事,CPU 只做决策和计算,不要把时间浪费在无意义的等待和搬运上。
学习的时候建议循序渐进:先把裸机单缓冲 DMA 跑通,理解 DMA 的基本工作方式;再改成乒乓缓冲,体会读写并行的优势;最后再上 FreeRTOS,把采集和处理拆成线程,理解多线程解耦的价值。一步一步走,基础才扎实,遇到问题也能快速定位。
