在 STM32 项目中,ADC + DMA 是非常常见的数据采集方案。简单场景下,开启 DMA 循环模式就能拿到数据;但一旦采样率提高、通道数增多、算法处理变重,单缓冲方案很容易出现数据被覆盖、处理不及时、偶发毛刺等问题。双缓冲采样的价值就在这里:DMA 负责持续搬运,CPU 处理已经完成的一半缓冲区,两边通过半传输和全传输中断交替协作。
本文以 STM32 ADC 采样为例,讲清楚 DMA 双缓冲的工程结构、常见代码写法、缓存一致性问题、调试方法和上线检查点。虽然不同 STM32 系列的寄存器和 HAL 接口略有差异,但设计思路是通用的。
一、为什么单缓冲容易出问题
最常见的单缓冲写法是 ADC 连续转换,DMA 循环搬运到一个数组里,任务定时读取数组:
c
uint16_t adc_buf[ADC_CH_NUM];
HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf, ADC_CH_NUM);
这种方式的问题是 CPU 不知道"哪一组数据是完整的一帧"。如果 DMA 正在写数组,任务同时读取,就可能读到一半旧数据、一半新数据。采样率低时问题不明显,采样率上去后就会变成偶发异常。
典型现象如下:
| 现象 | 可能原因 |
|---|---|
| 波形偶尔跳点 | CPU 读取时 DMA 正在覆盖缓冲区 |
| 多通道数据错位 | 没有按完整扫描序列处理 |
| 压力测试时丢帧 | 算法处理时间超过半缓冲填充时间 |
| Debug 正常 Release 异常 | 缓存、优化、变量可见性问题 |
双缓冲的本质是把缓冲区拆成两个半区:
text
DMA 写前半区 -> 半传输中断 -> CPU 处理前半区
DMA 写后半区 -> 全传输中断 -> CPU 处理后半区
二、数据流设计
一个比较稳的采样链路如下:
text
ADC Scan
|
v
DMA circular buffer
|
+-- HT interrupt -> sample block A ready
|
+-- TC interrupt -> sample block B ready
|
v
采样处理任务
|
v
滤波 / 标定 / 协议上报
建议不要在 DMA 中断里做滤波、换算和协议打包。中断里只标记哪个半区准备好了,然后通过队列或任务通知交给处理任务。
三、缓冲区大小怎么选
缓冲区大小需要同时考虑通道数、采样率、处理耗时和系统延迟。
假设:
- ADC 通道数:8
- 每通道采样率:2 kHz
- 一帧包含 8 个通道各 1 次采样
- 半缓冲包含 32 帧
那么半缓冲处理周期为:
text
32 / 2000 = 16 ms
这意味着 CPU 必须在 16 ms 内处理完一个半缓冲,否则下一轮 DMA 会覆盖还没处理完的数据。工程上最好保留至少 50% 余量,也就是处理耗时控制在 8 ms 以内。
缓冲区定义示例:
c
#define ADC_CH_NUM 8
#define ADC_FRAME_PER_HALF 32
#define ADC_HALF_WORDS (ADC_CH_NUM * ADC_FRAME_PER_HALF)
#define ADC_DMA_WORDS (ADC_HALF_WORDS * 2)
static uint16_t adc_dma_buf[ADC_DMA_WORDS];
如果芯片带 DCache,例如 Cortex-M7,需要按 cache line 对齐:
c
#define CACHE_LINE_SIZE 32
__attribute__((aligned(CACHE_LINE_SIZE)))
static uint16_t adc_dma_buf[ADC_DMA_WORDS];
四、HAL 方式实现双缓冲处理
HAL 的循环 DMA 模式可以通过半传输和全传输回调处理双缓冲:
c
typedef enum {
ADC_BLOCK_FIRST = 0,
ADC_BLOCK_SECOND = 1,
} adc_block_id_t;
typedef struct {
adc_block_id_t id;
uint16_t *data;
uint32_t words;
} adc_block_msg_t;
static QueueHandle_t adc_block_q;
void adc_start(void)
{
adc_block_q = xQueueCreate(4, sizeof(adc_block_msg_t));
HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_dma_buf, ADC_DMA_WORDS);
}
void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc)
{
BaseType_t wake = pdFALSE;
adc_block_msg_t msg = {
.id = ADC_BLOCK_FIRST,
.data = &adc_dma_buf[0],
.words = ADC_HALF_WORDS,
};
xQueueSendFromISR(adc_block_q, &msg, &wake);
portYIELD_FROM_ISR(wake);
}
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc)
{
BaseType_t wake = pdFALSE;
adc_block_msg_t msg = {
.id = ADC_BLOCK_SECOND,
.data = &adc_dma_buf[ADC_HALF_WORDS],
.words = ADC_HALF_WORDS,
};
xQueueSendFromISR(adc_block_q, &msg, &wake);
portYIELD_FROM_ISR(wake);
}
处理任务:
c
static void adc_process_task(void *arg)
{
adc_block_msg_t msg;
for (;;) {
if (xQueueReceive(adc_block_q, &msg, portMAX_DELAY) != pdTRUE) {
continue;
}
adc_process_block(msg.data, msg.words);
}
}
注意:如果 adc_process_block() 耗时可能接近半缓冲周期,就不要直接处理 DMA 原始区。可以把半区数据快速复制到处理池,或者设计多块 ping-pong buffer。
五、带 DCache 芯片的缓存一致性
在 STM32H7、部分 Cortex-M7 平台上,DMA 写内存时不会自动更新 CPU DCache。CPU 读缓冲区前必须 invalidate 对应 cache 区域,否则读到的可能是旧数据。
示例:
c
static void adc_invalidate_cache(uint16_t *ptr, uint32_t words)
{
uintptr_t addr = (uintptr_t)ptr;
uint32_t size = words * sizeof(uint16_t);
addr &= ~(CACHE_LINE_SIZE - 1);
size = (size + CACHE_LINE_SIZE - 1) & ~(CACHE_LINE_SIZE - 1);
SCB_InvalidateDCache_by_Addr((uint32_t *)addr, (int32_t)size);
}
static void adc_process_block(uint16_t *data, uint32_t words)
{
adc_invalidate_cache(data, words);
for (uint32_t i = 0; i < words; i += ADC_CH_NUM) {
process_one_frame(&data[i]);
}
}
如果缓冲区放在不可缓存 SRAM 区域,也可以避免这个问题,但要确认 linker script 和 MPU 配置正确。不要只靠"测试看起来没问题"判断 cache 一致性。
六、过载检测和丢帧统计
双缓冲不是无限吞吐。如果处理任务来不及消费,队列会堆积,最终还是会丢数据。建议加入统计:
c
typedef struct {
uint32_t ht_count;
uint32_t tc_count;
uint32_t queue_full;
uint32_t max_process_tick;
} adc_diag_t;
static volatile adc_diag_t adc_diag;
static void adc_send_from_isr(adc_block_msg_t *msg, BaseType_t *wake)
{
if (xQueueSendFromISR(adc_block_q, msg, wake) != pdTRUE) {
adc_diag.queue_full++;
}
}
处理耗时统计:
c
static void adc_process_task(void *arg)
{
adc_block_msg_t msg;
for (;;) {
xQueueReceive(adc_block_q, &msg, portMAX_DELAY);
TickType_t start = xTaskGetTickCount();
adc_process_block(msg.data, msg.words);
TickType_t cost = xTaskGetTickCount() - start;
if (cost > adc_diag.max_process_tick) {
adc_diag.max_process_tick = cost;
}
}
}
这些统计可以通过 shell、串口命令、debugfs 或诊断协议导出。现场问题发生时,有统计比只看波形更有效。
七、调试方法
1. 用 GPIO 测中断和处理耗时
在半传输/全传输回调和处理任务入口翻转 GPIO,用逻辑分析仪观察:
c
void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc)
{
DBG_GPIO_SET();
/* send message */
DBG_GPIO_CLR();
}
如果中断间隔稳定,但处理任务脉宽越来越长,说明 CPU 算法或同步链路有瓶颈。如果中断本身不稳定,要回头查 ADC 触发源、DMA 配置和总线负载。
2. 用固定输入源验证数据顺序
多通道扫描时建议先接固定电压或函数发生器,确认通道顺序和采样间隔。不要一开始就接复杂传感器,否则很难判断是硬件输入问题还是 DMA 数据错位。
3. 压测组合
双缓冲采样要和其它高负载场景一起压测:
- 高速串口收发。
- Flash 写入。
- 网络发送。
- 屏幕刷新。
- 日志大量输出。
因为 DMA、CPU、总线、cache、Flash 都可能互相影响。
八、真实案例:采样波形每隔几秒跳一次
某项目使用 STM32H743 做 12 路 ADC 采样,实验室低负载时波形正常,一打开以太网上报后,每隔几秒出现一次尖峰。最初怀疑 ADC 前端模拟电路,示波器测输入却很稳定。
排查过程:
- 给 DMA 半传输和全传输中断打 GPIO,发现中断周期稳定。
- 给采样处理任务打 GPIO,发现网络发送期间处理耗时变长。
- 打印
queue_full,发现尖峰前队列已满过几次。 - 检查代码发现处理任务里直接做浮点滤波和 JSON 打包。
- H743 开了 DCache,但 DMA 缓冲区没有 invalidate。
最终修复:
- ADC 处理任务只做标定和轻量滤波。
- JSON 打包放到低优先级通信任务。
- DMA buffer 对齐到 32 字节,并在读取前 invalidate。
- 队列满时记录丢帧并使用上一帧有效值,不输出半帧数据。
- 逻辑分析仪验证处理耗时小于半缓冲周期的 40%。
九、常见坑点表
| 坑点 | 后果 | 修复建议 |
|---|---|---|
| 半传输中断里直接做算法 | ISR 时间过长,影响系统响应 | ISR 只发通知 |
| 没有 cache invalidate | 读到旧数据或偶发跳点 | 对齐缓冲区并维护 DCache |
| 缓冲区太小 | 处理稍慢就覆盖 | 根据采样率和 WCET 计算 |
| 多通道顺序没校验 | 通道值错位 | 用固定输入源逐通道验证 |
| 队列满不统计 | 丢帧无证据 | 增加 queue_full 和最大耗时统计 |
| ADC 触发源不稳定 | 采样间隔抖动 | 使用定时器触发 ADC |
十、上线检查清单
- ADC 触发源明确,采样率可测。
- DMA 使用循环模式,半传输和全传输回调都已覆盖。
- 半缓冲时间至少大于处理最坏耗时的 2 倍。
- ISR 中不做滤波、打印、协议打包。
- Cortex-M7 平台已处理 DCache 一致性。
- 统计了丢帧、队列满、最大处理耗时。
- 使用逻辑分析仪或示波器验证中断周期和任务耗时。
- 进行通信、存储、显示同时开启的压力测试。
十一、总结
STM32 DMA 双缓冲采样的关键不是把 DMA 打开,而是让"DMA 写入"和"CPU 处理"形成清晰边界。只要缓冲区大小、cache 一致性、ISR 责任、处理耗时和过载统计都设计到位,ADC 连续采样就能从"看起来能跑"变成"现场可诊断、可压测、可上线"。