ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

STM32多通道ADC采集:定时器触发+DMA双缓冲方案详解

STM32多通道ADC采集:定时器触发+DMA双缓冲方案详解 设备刚上电我盯着串口助手那排数字发现三相电流波形根本没法看——毛刺多、抖动大CPU还被ADC中断占得满满的。后来把方案改成定时器触发DMA双缓冲多通道采集波形才终于干净了主循环也从“一直在处理中断”变成了“偶尔出来冒个泡”。如果你也在用STM32做ADC采集而且是多通道、要固定采样率这套方案很值得抄作业。这篇文章会把整个方案从头拆一遍为什么用定时器触发而不是软件触发DMA双缓冲到底在解决什么问题CubeMX里每一步怎么配代码怎么组织以及我实测下来踩过的各种坑。适合做电机控制、传感器监测、电池电压采样、信号分析这类需要稳定采样节奏的STM32项目F103、F407、H7系列都能用。1. 方案设计定时器触发与DMA双缓冲解决什么问题1.1 为什么定时器触发比软件触发更靠谱ADC采集最核心的需求是采样时刻确定。用软件触发比如在主循环里调HAL_ADC_Start或者用定时器中断里调转换有个致命问题主循环的执行时间是不确定的中断也可能被别的更高优先级中断打断每次采样之间的间隔都在抖。采样间隔抖动对普通电压监测影响不大但一到电机电流采集、FFT频谱分析、交流波形还原这些场景就出问题了。抖动会被当作噪声叠加进信号里你测出来的波形畸变、FFT频谱出现杂散本质不是ADC精度不够而是采样时钟不稳定。定时器触发是硬件级别的同步定时器计数到设定值硬件自动拉高触发信号ADC立即开始转换整个过程不经过CPU不受中断优先级、主循环阻塞影响。采样间隔完全由定时器精度决定这是软件触发做不到的。这套方案里的定时器我一般选通用定时器TIM2、TIM3、TIM4这些带TRGO输出的因为ADC的外部触发源基本都接在这些定时器上用起来最顺手。高级定时器TIM1、TIM8也行但没必要资源配置太浪费。1.2 DMA双缓冲的本质写不冲突的流水线多通道扫描一次会产生N个数据。如果不用DMA你有两个选择一是阻塞式轮询CPU一直在等ADC转换采样率稍高CPU就白等了二是中断读取每转完一个通道进一次中断3通道就是3次中断假设采样频率是10kHz每秒就是3万次中断光进出中断的开销就够喝一壶了。DMA的活是把ADC转换完成的数据自动搬运到内存全程不需要CPU干预。单缓冲的问题是DMA往同一个Buffer里写CPU也在读这个Buffer。如果CPU处理数据的速度跟不上DMA写入速度前面还没处理完的数据就被新数据覆盖了。双缓冲解决的就是这个冲突。它准备两份BufferDMA写第一份的时候CPU处理第二份DMA写第二份的时候CPU处理第一份。两份交替使用读写永远不打架。用一个不恰当的比喻单缓冲是一个厨师只给一个菜板备菜的厨师一刀下去把正在装盘的菜都剁了双缓冲是两个菜板备菜和装盘互不干扰。STM32F103这一代DMA没有硬件双缓冲寄存器但可以通过DMA的半传输中断和传输完成中断在软件层面实现完全等价的双缓冲逻辑。F4系列的部分DMA Stream支持硬件Double Buffer模式不过在代码组织上软件拆半的做法更通用也更好理解我后面讲的都是这个思路。1.3 轮询、中断、DMA三种方案怎么选我做过一个对比核心结论用一张表讲清楚采集方式典型CPU占用3通道10kHz采样间隔抖动代码复杂度适用场景阻塞轮询接近100%CPU全耗在等待大受主循环影响低低采样率、单通道、演示代码每通道中断读取30%~50%中断开销大较大受中断响应影响中通道数少、采样率不高DMA搬运中断处理5%以下极小硬件触发保证较高多通道、高采样率、信号分析我的经验是只要通道数大于等于2且单通道采样率超过1kHz就直接上DMA方案。前期配置多花30分钟后面能省下数不清的调试时间。2. CubeMX配置从时钟树到DMA参数的每一步2.1 时钟树配置ADC时钟绝不超14MHz先解决ADC的时钟来源。以F103为例ADC挂载在APB2总线上默认APB2是72MHz。但F103的ADC最高工作频率是14MHz所以必须分频分频系数至少是6也就是72/612MHz。就是在CubeMX的Clock Configuration页面里把ADC Prescaler设为6分频PCLK2/6。很多人配置时忽略了这一项直接用默认的4分频ADC跑在18MHz上超出了规格左边红色报警也没注意到。超频状态下ADC的转换结果线性度会变差尤其高速转换时很明显。ADC时钟值必须记下来后面算转换时间要用。这里有个看起来很绕的点ADC的转换时间计算公式里有固定12.5个周期的转换开销加上你配置的采样周期总周期数乘以ADC时钟周期才是单通道转换时间。ADC时钟越小转换时间越长所以也不是无脑把ADC时钟拉满要结合采样率和采样周期平衡。2.2 ADC多通道扫描模式配置要点在CubeMX里打开ADC1把你要用的通道比如IN0、IN1、IN2勾上。关键配置项Scan Conversion ModeEnabled。多通道必须开扫描模式这样一次触发会按Rank顺序把所有通道转一遍。Continuous Conversion ModeDisabled。我们用的是外部定时器触发每触发一次转一轮转完就停在最后一个通道。如果开了连续转换ADC会不停循环转换配合外部触发会出现触发信号还没到ADC已经在转了数据全乱。Number of Conversion填通道数比如3。每个Rank对应的通道和采样周期采样周期Sampling Time这里藏着一个重要权衡。采样周期越长内部采样电容充电越充分结果越准但单通道转换时间变长。对普通电压信号源我建议至少选28.5周期。如果是高内阻的信号源比如直接接光敏电阻、热敏电阻分压选55.5周期更稳。如果信号源已经经过运放跟随、内阻很低才能用1.5周期或7.5周期这种短采样时间冲刺高采样率。ADC的External Trigger Conversion Source要选定时器的触发事件这个在下一节讲。2.3 定时器触发源TRGO的Update Event定时器触发ADC走的是定时器的TRGO事件Trigger Output。在CubeMX里配置TIM2把Trigger Output (TRGO) Parameters设置为Update Event。这样定时器每次计数器溢出更新就自动产生一个触发脉冲送给ADC。PSC和ARR怎么算记住公式定时器触发频率 定时器时钟 / (PSC 1) / (ARR 1)以F103为例TIM2挂APB1注意APB1总线频率是36MHz但定时器时钟是APB1的2倍也就是72MHz。这个坑我踩过第一次计算时直接用36MHz去算结果触发频率比预期低了一半ADC数据明显变慢。如果我要10kHz的触发频率可以设PSC71、ARR9972MHz / (711) / (991) 72MHz / 72 / 100 10kHz如果做到1kHz就设PSC71、ARR999。这两个参数直接影响采样率建议后期用宏定义留出来调。2.4 DMA参数设置Circular、半字宽与连续请求DMA是这套方案的另一半。CubeMX里切到DMA Settings添加一个ADC1的DMA请求关键参数ModeCircular。必须有这个否则DMA搬运完一轮数据就停了配置错了的表现是只有第一次触发后有数据后面全是0。Data WidthMemory和Peripheral都选Half Word半字。ADC数据寄存器是16位的宽度不匹配会导致数据错位低字节高字节乱掉。Memory Address IncrementEnabled。DMA要把数据连续写进数组地址必须自增。还有一个容易遗漏的点在F3/F4系列上CubeMX DMA配置里有一个DMA Continuous Requests选项务必设为Enabled。F1的老版本HAL库里没这个选项默认就是持续请求但F4如果不打开DMA照样只响应一次就不动了表现是采集一轮之后arr里的数据再也没更新过。这个坑已经有无数人踩过。Buffer长度怎么定义假设我要3通道每个半缓冲区存256组数据一组3个通道那么DMA总长度应该是FULL_BUF_LEN 3 * 256 768在HAL_ADC_Start_DMA里传的就是768不是256。很多人这里搞错传了256结果DMA搬了三分之一就不搬了前3通道数据有后面全空。2.5 采样率与ADC转换时间预算定时器触发频率决定了你“多久采一轮”但ADC自己转换也需要时间。如果一轮扫描还没转完下一次触发就到了ADC就会报Overrun错误数据溢出。所以必须提前算好时间预算。F103的ADC转换时间公式单通道转换时间 (采样周期 12.5) / ADC时钟注意12.5个周期是固定开销加上你配置的采样周期才是总周期。以ADC时钟12MHz、采样周期28.5为例单通道转换时间 (28.5 12.5) / 12MHz 41 / 12MHz ≈ 3.42μs 3通道一轮总耗时 ≈ 3.42 * 3 10.26μs如果定时器触发频率是10kHz周期100μs10.26μs的转换时间只占了十分之一余量非常充足。但如果你把采样率拉到100kHz周期只有10μs而转换一轮需要10.26μs这就已经超预算了Overrun必然出现。所以采样率上限不是拍脑袋定的得按这条链路算出来允许的最大触发频率 1 / (通道数 × 单通道转换时间)实际工程我建议留2倍以上裕量因为还有DMA响应延迟、总线竞争这些因素。上面3通道的例子理论上限约97kHz实际我会把触发频率控制在50kHz以内。3. 代码实现双缓冲数据流与信号处理3.1 ADC校准与DMA启动CubeMX生成工程之后代码里要补三件事校准、定义Buffer、启动DMA。F103的ADC有个自校准功能精度影响挺大。初始化后启动前调用HAL_ADCEx_Calibration_Start(hadc1);F4/H7也有校准接口函数名一样但要注意必须在ADC已有供电、还没开始转换时调用一次。我一开始在每次启动DMA前都校准反而导致启动慢后来改成初始化时只校准一次。定义Buffer时记得一个细节数组元素类型是uint16_t数据宽度和ADC寄存器匹配数组长度必须是通道数的整数倍。我用宏定义管理方便后期调整#define ADC_CH_NUM 3 // 通道数量 #define HALF_GROUP 256 // 每个半缓冲区采样组数 #define FULL_LEN (ADC_CH_NUM * HALF_GROUP * 2) // 双缓冲总共长度 __attribute__((aligned(4))) uint16_t adc_buf[FULL_LEN];aligned(4)是让数组4字节对齐F4/H7上如果开了D-Cache对齐之后做Cache操作才不出问题。F103不开Cache不影响但保留这个属性没坏处。启动采集HAL_ADC_Start_DMA(hadc1, (uint32_t *)adc_buf, FULL_LEN);启动之后定时器一跑起来ADC就开始按触发频率自动转换DMA自动搬运。CPU完全不用管。3.2 半传输与传输完成回调双缓冲的交接棒这是双缓冲方案的灵魂。DMA搬运整个Buffer当它搬完前半段时触发一次半传输中断搬完后半段时触发一次传输完成中断。利用这两个中断把Buffer拆成两块使用volatile uint8_t half_ready 0; // 前半段可读标志 volatile uint8_t full_ready 0; // 后半段可读标志 void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { // DMA刚把adc_buf[0] ~ adc_buf[HALF_GROUP*ADC_CH_NUM-1]填完 // 此时CPU可以安全处理前半段DMA正在填后半段 half_ready 1; } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { // DMA刚把整个Buffer填完开始回卷重写前半段 // 此时CPU可以安全处理后半段 full_ready 1; }主循环里这样消费数据while (1) { if (half_ready) { half_ready 0; process_adc_data(adc_buf[0], HALF_GROUP); } if (full_ready) { full_ready 0; process_adc_data(adc_buf[ADC_CH_NUM * HALF_GROUP], HALF_GROUP); } }这个逻辑的精髓在于DMA正在写前半段时CPU处理的是后半段DMA写后半段时CPU处理的是前半段。两个半段交替使用CPU读的数据永远是稳定的、不会被DMA覆盖的数据。有一个红线必须遵守在中断回调里不要做耗时操作。回调里我只置一个标志位真正数据处理全部放在主循环。如果你在回调里直接做滤波、算平均值中断时间一长DMA数据可能来不及搬最终会导致半传输、传输完成中断错过数据逐渐错位。这个纪律比代码本身更重要。3.3 数据处理去直流、归一化与滤波ADC原始值不是最终结果。我做交流信号采集时会碰到两个绕不开的问题直流偏置和幅值校准。去直流信号如果叠加了直流分量比如运放输出抬高到1.65VADC读到的是偏置后的值。处理办法是先算一个长时间平均值作为直流偏置然后每个采样点减去这个偏置#define SAMPLE_OFFSET (1700) // 实测的直流偏置对应的ADC值约1.65V/3.3V*4095 int32_t sample_centered (int32_t)adc_value - SAMPLE_OFFSET;如果偏置不确定可以在初始化时先用前几百个点求平均再自动校准。归一化把ADC原始值映射到0.0~1.0或者实际物理量。最简单的归一化是除以满量程float norm (float)adc_value / 4095.0f; // 12位ADC满量程4095要换算成电压float voltage (float)adc_value * 3.3f / 4095.0f;更严谨的做法是用万用表实测两点电压做两点标定求出系数和偏移而不是简单按3.3V理论值折算因为板子上的Vref实际可能不是标准3.3V。滤波ADC数据有随机噪声简单粗暴的办法是滑动平均uint32_t filter_buf[FILTER_N]; uint8_t filter_idx 0; uint32_t filter_sum 0; uint32_t moving_average(uint32_t new_value) { filter_sum - filter_buf[filter_idx]; filter_buf[filter_idx] new_value; filter_sum new_value; filter_idx (filter_idx 1) % FILTER_N; return filter_sum / FILTER_N; }我对噪声尖峰明显的信号会先做一次中值滤波再去滑动平均。中值滤波对脉冲噪声的抑制效果比滑动平均好很多代价是代码多一点点。简单粗暴版把5个点冒泡排序取中间值虽然慢但数据量不大时完全够用。3.4 配合串口DMA把数据送出去采集到的数据最终要输出到上位机看波形或者做记录。如果直接用阻塞式的HAL_UART_Transmit会让CPU卡在等待发送完成上得不偿失。正确姿势是用串口DMA发送HAL_UART_Transmit_DMA(huart1, (uint8_t *)adc_buf, FULL_LEN * 2);发送完成后在HAL_UART_TxCpltCallback里做下一轮发送准备。我用串口接收数据做控制命令时会配合空闲中断UART IDLE来识别一帧结束。HAL库里比较新的版本提供了HAL_UARTEx_ReceiveToIdle_DMA接口既能DMA接收又能在空闲时回调省去自己判断帧尾的麻烦。比如上位机发一个“S”字母就触发一次数据上传整条链路就完整了定时器触发ADC → DMA搬运到双缓冲 → 主循环滤波处理 → 串口DMA异步上传 → 上位机实时刷新这条链路里CPU只负责轻量的数据处理和协议判断数据搬运全部交给DMA整体占用很低。实测3通道10kHz采样串口波形上传CPU占用不到10%这个余量在复杂项目里非常宝贵。4. 避坑指南实测中踩过的7个坑4.1 定时器触发频率太高ADC疯狂Overrun症状很典型调试时发现HAL_ADC_ErrorCallback一直进错误码是HAL_ADC_ERROR_OVR采集到的数据也乱。原因就是前面算的时间预算超了ADC一轮扫描还没转完定时器下一个触发就到了。我当时把3通道的触发频率拉到100kHz单通道21周期采样ADC时钟12MHz一轮转换需要2112.5/12MHz×3≈8.4μs而100kHz的触发周期只有10μs加上DMA响应延迟几乎必然溢出。解决思路按优先级降低定时器触发频率让触发周期至少是扫描耗时的2倍以上。缩短ADC采样周期从55.5改成28.5或者7.5牺牲一点精度换速度。减少ADC通道数比如把不需要的通道从扫描序列里去掉。排查时可以先不跑业务代码只保留ADC和定时器用一个固定PSC/ARR的空跑程序看是不是还Overrun能快速定位是不是时间预算问题。4.2 数据错位DMA长度与通道数不匹配数据错位的表现是你明明配了IN0、IN1、IN2三个通道结果串口打印出来IN0的位置时而出现IN1的值时而出错乱。最常见原因是DMA长度没有按照“通道数×组数”来定义。比如我配了3通道但DMA长度设成了256而不是256×3这时候DMA只搬了前256个16位数据就回到起点后面的IN1、IN2数据搬运到底址之外数组内容完全错乱。另一个隐藏原因是CubeMX生成代码后DMA的Buffer长度被我手工改小过但通道数没变。所以排查错位问题时第一件事就是核对DMA配置里的数据长度是否等于ADC转换序列长度的整数倍。4.3 volatile、优化和中断长阻塞用Keil或IAR开-O2优化后标志位失效是很经典的问题。我在回调里置half_ready主循环里判断它编译器如果没意识到这是中断修改的变量会把判断优化成只读一次结果主循环死等。解决方式就是在共享变量的声明上加volatile修饰。我上面代码里的volatile不是随手写的是踩坑换来的教训。另一个问题是中断回调里做重活。我最早在回调里直接处理数据、做滤波、还调了HAL_UART_Transmit结果不仅阻塞时间长还导致后续DMA中断被堆积最终数据丢失。后来一律改成“回调只置标志位处理放主循环”问题立刻消失。不要小看这个纪律哪怕你在回调里只放一个HAL_GPIO_TogglePin如果采样率很高每秒几万次中断翻转IO开销也不小。再实用一点调试时可以在回调里通过一个示波器引脚输出方波测一下实际中断频率和占空比心里有底。4.4 参考电压、电源噪声与输入阻抗硬件上的坑比软件更隐蔽而且一踩就是“数据怎么调都不对”级别的。参考电压F103的Vref引脚如果直接接3.3V而这个3.3V纹波很大ADC结果必然有随机噪声。我的板子是专门给ADC供电的参考电压芯片Vref稳定后同样代码采出来的噪声从±10个LSB降到±2个LSB。条件有限的至少保证Vref引脚附近有足够的去耦电容——1μF0.1μF并联并且尽量靠近引脚。电源噪声模拟电路和数字电路共用一个电源平面时PWM驱动、电机堵转引起的电压跌落会直接串进ADC。我用过最有效的方案是ADC的模拟电源VDDA和参考电压单独滤波AGND用磁珠或0欧电阻单点连接DGND模拟走线尽量避开高频数字信号。输入阻抗这是个原理性问题。ADC内部是一个采样电容转换开始时有短暂的充电过程充电时间由信号源内阻和采样电容决定。如果信号源内阻太大采样时间又短电容来不及充满采样值就会偏低。表现是电压越大误差越大或者直流量测还行、交流量波形衰减。解决方式一是增大ADC采样周期给电容足够的充电时间二是在信号源和ADC之间加一个运放跟随器输出阻抗几乎为零这时候用短采样周期也没问题。对于直接用电阻分压测电池电压这种场景我建议分压电路的总阻值不要超过10kΩ否则高采样率下会明显拉低测量值。4.5 调试断点会让DMA“跑飞”调试多通道采集时我在半传输回调里设断点单步调试结果数据全乱了。原因是暂停在断点时定时器没有停止DMA继续搬运而CPU停在断点半传输中断得不到及时处理中断标志被挂起后续回调可能错过。正确做法是调试时先停定时器再看buffer。或者在回调里只设一个SWV/串口打印不要打断运行。另外在调试状态下观察到的adc_buf内容可能不是实时的因为DMA一直在写内存而调试器读取存在缓存数据看起来“卡住”了这并不代表程序有问题先确认DMA enable状态寄存器再说。4.6 多通道共享一个Buffer的“组”概念3个通道共享一份DMA buffer时读取数据的方式是前3个位置是IN0、IN1、IN2各一组然后又是IN0、IN1、IN2以此类推。所以处理代码里要对通道序号求模。很多人在这里直接写adc_buf[i]当成单通道处理数据自然会乱。正确方式for (int i 0; i group_count; i) { uint16_t ch0 adc_buf[i * ADC_CH_NUM 0]; uint16_t ch1 adc_buf[i * ADC_CH_NUM 1]; uint16_t ch2 adc_buf[i * ADC_CH_NUM 2]; // 处理这一组数据 }如果对单通道的采样组数没有概念最容易写出越界访问。这里我建议先用一个有界循环测试数据确认为止。4.7 F4/H7的Cache一致性如果你用的是F4系列且开启了D-CacheDMA往内存写数据后CPU读到的可能是Cache里的旧数据。这个问题在F1上不存在但F4以上很常见。解决方式是在每次读取DMA buffer前先执行一下Clean和InvalidateSCB_CleanDCache_by_Addr((uint32_t *)adc_buf, sizeof(adc_buf)); SCB_InvalidateDCache_by_Addr((uint32_t *)adc_buf, sizeof(adc_buf));这个操作要在DMA写入完成、CPU读取前调用。尤其是半传输和传输完成两个回调里对应半段的地址要单独清。体验过Cache坑的人都知道数据偶尔是新的、偶尔是旧的最折磨人。5. 常见问题速查与调试经验5.1 用“活数据”监测DMA是否在跑我调试时有个习惯在采集的同时把某个通道的原始值定时经串口打个平均值出来看这个值是否随着电位器旋转而改变。如果一动不动先怀疑DMA根本没在跑。再进一步可以直接在调试器里看DMA的寄存器状态DMA_NDTR剩余传输计数如果一直在变化说明DMA正在搬运如果静止不变说明DMA已经停了。F103的DMA1寄存器地址在调试器里能直接看到这个方法比猜数组内容快很多。还有一个实用技巧把DMA的回调函数里加一个GPIO翻转然后示波器测这个引脚的波形频率和占空比。如果频率等于你预期的采样率说明整条链路是通的。这个方法不需要任何调试器现场排查特别管用。5.2 波形可视化别急着写上位机不用急着写完整上位机调试采集链路最快的方式是VOFA或SerialPlot这类串口波形软件。协议简单每秒发几十帧文本数据就能实时出波形。我调试时会把3个通道的原始值滤波后通过串口DMA发出去格式就是逗号分隔的文本上位机按“换行符”解析成多通道曲线。这样能直观看到采样率是否稳定波形横轴是否均匀、数据是否错位通道间波形是否互换、噪声水平波形毛刺大小。等波形确认稳定了再去做正式的通信协议和上位机界面。分两步走能省下大量纠结时间。5.3 问题速查表现象可能原因检查顺序采集全是0DMA没启动、DMA Continuous Requests未使能、触发频率为01.定时器是否在跑 2.Star_DMA是否调用 3.NDTR是否变化采集全是4095输入引脚悬空、输入电压超过Vref1.万用表测引脚电压 2.检查通道映射数据错位乱序DMA长度不是通道数整数倍、采样周期太短导致阻抗问题1.核对FULL_LEN 2.增大采样周期波形毛刺大Vref不干净、采样周期过短、信号源内阻大1.测Vref纹波 2.增大采样周期 3.加跟随器只有第一轮有数据DMA配置成了Normal模式不是Circular1.检查Mode 2.检查Continuous Requests定时器中断进但回调不进NVIC里DMA中断没使能1.NVIC配置 2.检查DMA中断标志数据偶尔更新偶尔不变F4开了CacheCache污染1.ADC buffer做Cache Clean/Invalidate 2.检查对齐还有一个算不上Bug但容易让人懵的情况刚启动时第一轮半缓冲数据可能有异常因为DMA是从中途开始搬的前几个数据不可靠。处理办法是启动后先丢弃前几十组数据等数据流稳定了再开始处理。我一般在初始化时先HAL_Delay(50)然后清一次标志位把采集链路洗干净再进主循环。另外建议把通道数、半缓冲组数、定时器ARR/PSC、采样周期这些参数全部用宏定义集中管理。前期调试时我一天要改十几次采样率如果散落在代码各处改一个参数要找半天。集中管理之后调参就是改一行宏定义然后编译下载的事省下来的时间足够多看几遍波形了。这套方案我前后在F103、F407还有GD32的同类芯片上移植过核心逻辑基本不变区别只在时钟树配置和个别寄存器名字。你如果在移植时遇到某个寄存器编译不过去芯片参考手册里搜名字基本都能找到对应物。最后再分享一个实在的建议第一次调通时别追求极限采样率先用10kHz这种保守值把链路跑通验证完波形和稳定性再逐步往上拉。能把一套低占用、高确定性的采集方案稳定复现比单纯调出一个高采样率但偶尔掉链子的工程要值钱得多。
返回列表