我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

STM32F030R8Tx使用HAL库实现ADC DMA多通道采集与问题排查

STM32F030R8Tx使用HAL库实现ADC DMA多通道采集与问题排查 很多做嵌入式的朋友第一次在 STM32F030R8Tx 上跑 ADC 时第一反应通常是轮询或者开个定时器中断里采一次。这个思路不能说错但一旦通道数变多、采样率提上来CPU 就开始被频繁的“读数据、搬数据、算数据”拖住系统的实时性反而变差。我在 F0 这类入门级 Cortex-M0 片子上做多路模拟量采集时更愿意把数据搬运这件事交给 DMA——用 HAL 库配置一次之后 ADC 不断转换DMA 不断把结果写进内存CPU 只在需要时消费这些数据。这篇文章就以“STM32F030R8Tx 使用HAL库实现 ADC DMA 功能”为主线把从 CubeMX 配置到代码实现、再到问题排查的完整过程拆开讲一遍。适合刚接触 HAL 库的入门者也适合已经能跑 ADC 但想把采样机制做得更稳的开发者。文中会用两块简单通道作为例子实际项目里需要一个、三个、八个通道时思路完全一样。1. 整体设计与思路拆解1.1 为什么优先选 ADC DMA而不是轮询或中断ADC 采样这件事本质上就是“不断把模拟电压转换成数字值”。最直接的做法是轮询开一次转换然后等EOC标志位置位再读数据寄存器。这种写法代码量少却有个很明显的代价——CPU 必须一直盯着 ADC 忙不忙期间什么正事都干不了。中断方式比轮询好一些ADC 转换完成会产生中断CPU 在中断里读数据。可问题在于如果采样率很高中断触发频率也会很高每次中断都要进栈、出栈再维护一堆标志位对 Cortex-M0 这种不带太多花哨特性的内核来说压力并不小。更麻烦的是如果中断服务程序里做了乘法、滤波之类的事中断占用的时间会让主循环响应变得极不稳定。DMA 方案完全不同。配置好之后ADC 每次转换完毕DMA 会直接把数据寄存器里的值搬运到内存整个过程不需要 CPU 插手。你只需要在内存里准备一块缓冲区设置缓冲区长度和循环模式数据就会像流水一样不停流进去。CPU 可以专心做控制逻辑、人机交互、通信协议这些真正需要算力的事情。这里用一个表把三种方案的差异列出来方便新手上手时做选择对比维度轮询方式中断方式DMA 方式CPU 占用高循环等待中频繁进中断极低只处理结果实现复杂度最简单简单稍复杂适合采样率很低中等高多通道连续采集不推荐容易被打断最合适数据连续性差可能丢点取决于中断延迟好循环写入我在工程里其实很少一概而论。如果只是每隔几秒读一次温度轮询完全没问题如果要做电机电流监测、音频采样、多路传感器的高速采集那基本就是 ADC DMA 的天下。1.2 STM32F030R8Tx 的 ADC 与 DMA 资源到底够不够用F0 系列属于 STM32 里的入门级产品但在模拟采集这件事上一点都不含糊。STM32F030R8Tx 内部带一个 12 位逐次逼近型 ADC有多路采样通道满足常见的三五路电压、温度、电流采样绰绰有余。12 位分辨率意味着满量程对应 4096 个刻度对于大多数传感器应用来说已经比 8 位、10 位 ADC 平滑得多。DMA 方面F0 带一个 DMA1外设请求映射在几个 Channel 上。ADC1 通常可以通过 DMA1 实现外设到内存的循环搬运。很多人担心资源不够其实只有一路 ADC 和一个 DMA 控制器反而让配置变得清晰不用像 F4 那样在 DMA2 和双 ADC 之间反复纠结。需要注意的其实是 SRAM 大小。STM32F030R8Tx 的 Flash 和 SRAM 都不算大缓冲区不能随便乱开。你如果直接声明一个几百条数据的uint32_t数组做滤波很快内存就紧张了。我的习惯是先用小缓冲区把 ADCDMA 链路跑通确认数据正确再按实际采样率和处理需求去扩展缓冲。扩展时也要算清楚缓冲区越大单次 DMA 回调和搬数据的时间越长但内存占用也越高。1.3 整条数据流该怎么理解从硬件到软件这条链路的顺序大概是模拟信号通路 - ADC引脚采样 - ADC规则组转换 - ADC数据寄存器 - DMA1请求 - DMA搬运到内存缓冲区 - 完成/半完成回调 - 主循环读取处理 / 滤波 / 上报这里有个容易绕晕的点DMA 搬运的长度到底按什么算。在 HAL 库里HAL_ADC_Start_DMA的第三个参数是“要搬运多少个 ADC 转换结果”不是字节数。假设你配置了 2 个通道DMA 每次循环要写完 2 个采样结果那长度就是 2。如果你把缓冲区长度设成 32也就是说 DMA 要连续写完 32 个 ADC 转换结果后才触发一次完整回调。这个“结果数”和我们常说的“字节数”是两回事代码里必须先把逻辑缕清。可以把 ADC 规则组想象成一台自动售货机通道就是货道扫描模式下机器会按顺序从每个货道掉出一瓶饮料每掉一瓶DMA 就往内存隔间里放一瓶。缓冲区长度就是隔间数量循环模式就是放满后机器自动清零重新放。2. CubeMX 工程配置要点与参数选择2.1 时钟树与 ADC 时钟怎么设工程第一步是配置时钟。STM32F030R8Tx 外部可以接晶振但很多自制板为了方便直接用内部 HSI 启动。CubeMX 里可以把系统时钟拉高到 48MHz甚至很多基础工程也默认这么干。ADC 不是所有时钟都能直接用的因为 ADC 模块有最高工作频率限制通常要往 14MHz 以下压。有朋友在这步踩过坑系统时钟 48MHzADC 时钟直接拿系统时钟不做分频结果采样值跳动厉害甚至工作不稳定。原因很简单ADC 时钟超过了规格。CubeMX 的时钟配置页里能直接看到 ADC Kernel Clock 的实际数值把它除以 2 或者除以 4确保落在器件支持的范围内。以保守为原则我会选一个能整除出安全频率的分频系数别看着某个数值“看起来差不多”就往下走。如果你用的是电池供电、对功耗敏感的设备系统时钟不一定要跑到 48MHz。ADC 采样速率够用就行低速采样时把系统时钟降到 16MHz甚至 8MHz功耗表现会好很多。ADCDMA 这套机制在小体积嵌入式设备上最大的价值之一就是“外设自己干活CPU 能睡觉”。2.2 ADC 工作模式连续转换、扫描转换、采样时间进入 ADC1 配置页面后几个关键选项要明确。Continuous Conversion连续转换模式。开启后ADC 会不停地启动转换不需要每次重新触发。做直流或缓变信号采样时基本都开。Scan Conversion扫描模式。因为我们要采多路通道必须开启。不开的话即使配置了多个 RankADC 也只会转那一个通道。Number of Conversion这里填 2表示规则组里有两路转换序列。Rank 1 是第一路通道Rank 2 是第二路通道顺序就是你缓冲区里数据出现的顺序。采样时间这个参数很多人不太在意但它直接影响测量精度。采样时间本质上是 ADC 内部采样开关对信号源保持电压的时间。信号源内阻较大时采样时间太短充电不够充分采到的值就会比实际值偏低。普通的电压分压电路、电位器输出把采样时间设到中档以上一般没大问题如果是高内阻的传感器输出建议直接拉到最长档位。代价是转换速率降低但精度稳定了很多。在触发源里日常实验可以选软件触发也就是调用启动函数后开始转换。后面要精确控制采样周期我会改用定时器触发这个在第五章展开。2.3 DMA 请求配置与数据宽度最容易被忽略的地方ADC 配置完之后切到 DMA 设置页面添加一条 DMA Request。关键参数我分享一下自己的选择Direction 选 PeripheralToMemory方向是 ADC 数据寄存器到内存。Peripheral Increment 关闭因为外设地址始终只有一个 ADC_DR。Memory Increment 开启内存地址需要依次递增不然所有数据都写到一个位置。Peripheral Data Alignment 和 Memory Data Alignment 都选 Half Word。Mode 选 Circular也就是循环模式。这是实现持续采集的前提选成 Normal 的话采集完一轮 DMA 就停了。Priority 根据项目里其他 DMA 请求的情况来定。如果只有 ADC 在用 DMAHigh 也没有问题如果还有串口、定时器共用 DMA优先级就要好好权衡。数据宽度尤其容易错。ADC 数据寄存器底层是 16 位的12 位转换结果右对齐后存在低 12 位里面。所以 DMA 搬运时用 Half Word16 位最自然。内存缓冲区用uint16_t数组每个数组元素正好放一路转换结果。有些朋友习惯全用 Word结果内存缓冲区类型就成了uint32_t这样倒也不是完全不能跑但读数据时很容易混淆到底哪个字节有效也浪费内存。提示F0 的 DMA 在 CubeMX 里生成时ADC1 通常是映射到 DMA1 的某一个 Channel 上。你不需要背死是 Channel 几只要在 DMA Request 下拉框里选择 ADC1生成代码时看到的实际句柄就是唯一的。2.4 生成代码后的三项自检CubeMX 生成代码后不要急着改先检查这三件事。第一DMA 中断是否在 NVIC 里被勾选。HAL 库的 DMA 传输完成回调依赖 DMA 中断如果 NVIC 里没有使能 DMA 通道中断ADC 可能一直在采DMA 可能一直在搬但你的HAL_ADC_ConvCpltCallback永远不会被调用。这会让人产生“程序卡在启动函数里”的错觉。第二MX_DMA_Init必须放在MX_ADC1_Init之前。ADC 初始化函数会通过HAL_ADC_MspInit把 DMA 句柄和 ADC 句柄绑定在一起如果 DMA 还没初始化绑定关系就不完整启动时容易出错。CubeMX 默认生成的顺序一般是符合依赖关系的但如果你手动调整过函数顺序一定要小心。第三确认hadc1.Init.NbrOfConversion和你要采集的通道数一致。这个值决定了硬件规则组有几个转换步骤如果这里写 1无论你到底在代码里加了几路通道DMA 缓冲区里都只有一路。3. 核心代码实现与关键细节3.1 数据缓冲区的类型选择与定义缓冲区类型我建议直接用uint16_t。原因是 12 位 ADC 结果放在uint16_t变量里内存布局干净换算电压值也顺手。不要看到HAL_ADC_Start_DMA的函数原型里第三个参数是uint32_t*就以为必须声明uint32_t数组。那个类型转换仅仅是接口约束真正写内存时依然按照 CubeMX 里配置的 Half Word 来写。下面的定义是一套比较标准的示例#define ADC_NUM_CHANNELS 2u #define ADC_DMA_BUF_LEN ADC_NUM_CHANNELS uint16_t adc_dma_buf[ADC_DMA_BUF_LEN]; uint16_t adc_result[ADC_DMA_BUF_LEN]; volatile uint8_t adc_conv_complete 0;adc_dma_buf是 DMA 直接写入的底层缓冲区adc_result是主循环读取时使用的数据副本adc_conv_complete是回调与主循环之间的握手标志。标志变量一定要加volatile否则编译器的优化可能会让主循环读到一个缓存后的旧值导致事件丢失。关于缓冲区大小我再多一句如果只想跑通流程长度等于通道数即可。如果后面要做平均滤波可以把长度设成“通道数 × 采样次数”。比如两路通道做 8 次滑动平均就是2 * 8 16。但要注意缓冲区越大会导致单次 DMA 完整周期变长具体长度还是要根据采样率和 SRAM 余额来平衡。3.2 启动 ADC 采集的完整流程在main函数中完成所有初始化之后启动采集int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_DMA_Init(); MX_ADC1_Init(); HAL_ADC_Start_DMA(hadc1, (uint32_t *)adc_dma_buf, ADC_DMA_BUF_LEN); while (1) { if (adc_conv_complete) { adc_conv_complete 0; for (uint32_t i 0; i ADC_NUM_CHANNELS; i) { adc_result[i] adc_dma_buf[i]; } // 在这里做电压换算、滤波、通信上报等工作 } } }HAL_ADC_Start_DMA会做几件事把 DMA 配置成循环模式、启动传输、校准并启动 ADC。这里不需要额外调用HAL_ADC_Start不然反而可能重复启动。程序启动后ADC 会按照配置的序列连续转换DMA 则负责把结果持续写进adc_dma_buf。当 DMA 写完一个完整缓冲区时中断触发之后会跳到回调函数。如果你在回调之后发现数据没有更新可以重新检查 CubeMX 里有没有开启 Continuous Conversion 和 Circular 模式。很多“只采了一次就再也不动”的现象都出在这两个开关上。3.3 回调函数如何写才够安全HAL 库提供了几个现成的回调在 ADC 场景下最关键的是转换完成回调和半转换完成回调。写回调的第一原则是回调运行在中断上下文里不要在里面做耗时操作更不要调用延时和打印函数。否则整个中断响应会被拖长DMA 数据也许下轮就到CPU 却还停在回调里。一段安全且实用的写法是void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { adc_conv_complete 1; } }如果还要处理 DMA 半满中断可以加半转换回调void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { // 可以在这里处理缓冲区前半部分的数据拷贝 } }半转换回调是双缓冲机制的基础。当缓冲区长度为 32 时DMA 写入前 16 个数据后触发一次半满中断写入后 16 个数据后触发一次满中断。这样 CPU 可以在 DMA 继续写后半段的同时处理前半段数据完全错开不会读写到同一个位置。另外HAL 库还有错误回调。ADC 出现 Overrun 事件时可以通过下面的方式清掉标志并重新启动void HAL_ADC_ErrorCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { HAL_ADC_Stop_DMA(hadc1); __HAL_ADC_CLEAR_FLAG(hadc1, ADC_FLAG_OVR); HAL_ADC_Start_DMA(hadc1, (uint32_t *)adc_dma_buf, ADC_DMA_BUF_LEN); } }这里面的逻辑是先把链路停干净清掉 Overrun 标志再重新启动。直接在当前的错误状态上继续跑很容易反复触发错误回调。3.4 从原始值到真实物理量的换算DMA 拿到的是 ADC 数字代码不是直接可读的电压。如果参考电压就是供电电压板子上 VDDA 约 3.3V那换算关系非常简单static float adc_to_voltage(uint16_t raw) { const float vref 3.3f; return ((float)raw / 4095.0f) * vref; }有的参考手册习惯写除以 4096表示分辨率有的工程习惯写除以 4095表示满刻度对应点。两种写法差异极小工程上更重要的是让参考电压足够稳。如果板子上的 VDDA 纹波大ADC 结果会跟着抖动后面做再多数字滤波也只是掩盖硬件问题。建议在 VDDA 和地之间就近放置 100nF 电容必要时再并一个 10µF 钽电容。滤波方面最简单的滑动平均就够用。把 DMA 缓冲区长度设置成多轮采样结果然后逐个累加取均值uint16_t raw_sum 0; for (uint32_t i 0; i ADC_NUM_CHANNELS; i) { raw_sum 0; for (uint32_t j i; j ADC_DMA_BUF_LEN; j ADC_NUM_CHANNELS) { raw_sum adc_dma_buf[j]; } adc_result[i] raw_sum / (ADC_DMA_BUF_LEN / ADC_NUM_CHANNELS); }注意循环的步长是通道数这样每组里相同通道的数据才会被一起平均。千万不要简单地从头到尾乱加否则两路信号的均值会互相污染。4. 常见问题与排查技巧实录4.1 只采到一次数据后面再也不更新了这是 ADC DMA 调试中最常见的问题。大概率原因是 CubeMX 里的两个“循环”没有同时开启ADC 的 Continuous Conversion 没开或者 DMA 的 Mode 选了 Normal。连续转换保证 ADC 持续出数据DMA 循环模式保证数据能不断写入缓冲区。两者缺一不可。还有一个隐藏原因是 DMA 中断根本没进入。虽然数据一直在写但你的处理流程依赖回调标志位回调不执行主循环永远等不到新数据。检查方法很简单在回调里设置一个临时断点如果断点从不命中就去 NVIC 设置里确认 DMA 通道中断是否已使能。另外可以留意一下 Overrun 错误。ADC 转换速度比 DMA 搬运速度快时ADC 数据寄存器里的旧数据还没被 DMA 读走新结果又出来就会触发 Overrun。现象是采集突然停住、错误回调出现或者数据出现一段固定值。处理办法就是清标志重启或者降低 ADC 采样时钟、加长采样时间、提高 DMA 优先级。4.2 数据看起来有值但通道顺序怎么都对不上通道顺序问题多出在 Rank 配置和缓冲区读取方式不一致上。CubeMX 里 Rank 1 可以是 ADC_CHANNEL_0Rank 2 可以是 ADC_CHANNEL_1DMA 缓冲区里第一个结果就是 Rank 1 的数据第二个才是 Rank 2 的数据。这不是按 ADC 通道号从大到小排的而是按 Rank 顺序排的。调试这类问题时我会在目标引脚上接一个固定的已知电压。比如将 PA0 接 1.0VPA1 接地然后看缓冲区里哪个元素接近 1.0V 对应的数值就能立刻判断出顺序。确认顺序后要么调整 CubeMX 里的 Rank要么调整代码里读数组的下标。别指望两路信号用肉眼从波形里猜顺序直接拿已知值去对效率最高。4.3 数值整体偏大或偏小线性度也不行先看采样时间。如果信号源内阻高而采样时间又很短采样电容没充满就开始转换结果必然偏低。把采样时间提高一两个档位再量很多时候数据就正常了。再看参考电压。F0 这类片子不少封装没有独立的 VREF 引脚参考电压就是 VDDA。供电芯片的输出如果并不精确等于 3.3V而是 3.26V 或者 3.36V那你用 3.3V 做换算系数肯定不准。最直接的办法是用万用表实测 VDDA 电压并把实测值填到代码里。实在还有偏差就用软件校准。拿一个精密电压源在量程中间附近给两个已知电压点采集原始值算出一组斜率和偏置voltage_real (float)adc_raw * gain offset;硬件上没改善时软件校准是最快兜底方案尤其适合批量产品中的个体差异修正。4.4 HAL_ADC_Start_DMA 之后返回 HAL_BUSY 怎么办HAL_BUSY 说明 ADC 外设正被占用通常是因为上一次采集还没停干净。比如你之前调用过HAL_ADC_Start_DMA后来直接在中断里又调用了一次就会出现资源锁冲突。解决办法是严格按照“先停止再启动”的顺序操作HAL_ADC_Stop_DMA(hadc1); HAL_ADC_Stop(hadc1); HAL_ADC_Start_DMA(hadc1, (uint32_t *)adc_dma_buf, ADC_DMA_BUF_LEN);注意HAL_ADC_Stop_DMA和HAL_ADC_Stop是两个动作。前者管 DMA 那部分后者管 ADC 本身的工作状态。有些 HAL 版本里 Stop 函数内部会做状态判断不返回错误但还是建议两个都调用确保底层状态机完全复位。还有就是不要在多个位置同时操作同一个 ADC 句柄。比如主循环里调启动串口回调里又调启动一旦发生重入Hook 内部的状态标志就会“锁死”。对这种资源应当设立一个统一的服务函数所有地方都通过同一个接口来启停不要四处直接操作句柄。4.5 调试现场如何用断点和窗口快速定位问题我在实际调试中会同时盯几个变量adc_dma_buf数组里的原始值、adc_conv_complete标志、以及错误回调有没有被触发。如果标志一直为 0看 DMA 中断如果标志正常但数组内容不变看 ADC 连续转换和 DMA 循环模式如果值乱跳先看通道顺序再看电源纹波。一个很实用的技巧是把数组丢到调试器的 Watch 窗口里同时打开实时刷新。当采样速度比较低时比如每秒几百次肉眼就能看到 DMA 缓冲区里的数值在跳动。跳起来了说明链路是通的剩下的无非是精度和顺序问题。如果完全不变赶紧回到配置层面检查开关。5. 进阶玩法把 ADC DMA 变成真正可复用的采样引擎5.1 用双缓冲把数据处理和采集彻底分开你可能会发现在回调里直接对数据做滤波、换算中断时间会变长在main的while里靠标志位等数据又可能和 DMA 写入产生竞争。更好的办法是双缓冲。做法很简单声明一块2 × N的缓冲区DMA 从这个缓冲区起始地址开始循环搬运。当 DMA 写完前半段时触发半满回调写完最后一段时触发满回调。处理代码只在某个半区里操作而 DMA 永远在写另一个半区。两者互不相交不需要频繁禁用外设。#define ADC_OVS_TOTAL 32u uint16_t adc_dma_buf[ADC_OVS_TOTAL]; uint16_t adc_result[ADC_NUM_CHANNELS]; void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { // 在这里处理 adc_dma_buf[0] 到 adc_dma_buf[ADC_OVS_TOTAL/2 - 1] } } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { // 在这里处理 adc_dma_buf[ADC_OVS_TOTAL/2] 到 adc_dma_buf[ADC_OVS_TOTAL - 1] } }这个方案在采样率高、通道多的工程里效果明显。它能同时实现硬实时采集和数据处理也是我从“能跑”到“好用”的分水岭。5.2 用定时器触发代替连续转换有些场景不需要“既快又连续”的采样而是需要严格的等间隔采样。比如每 1ms 采一次或者每次 PWM 周期采一次。这时我会把 ADC 的 Continuous Conversion 关掉改用定时器触发。CubeMX 里把 ADC 的 External Trigger 选成某个定时器的 TRGO 事件再让定时器以固定周期输出触发信号。启动时仍然调用HAL_ADC_Start_DMA但实际开始采样的节奏是由定时器控制的。这样采样间隔稳定不受主循环和中断调度影响。对电机控制、电源环路这类对时间敏感的场景这是比较标准的选择。需要注意的是关掉连续转换后不要指望一次定时器触发能把所有通道反复转换好多次规则组里配置了几路通道一次触发最多按顺序转换这几路然后停下等下一次触发。5.3 停止采集与异常恢复要养成习惯代码里光会启动还不够项目量产之后还可能需要停止采集、进入低功耗、或者从异常状态恢复。停止时我习惯按下面的顺序处理HAL_ADC_Stop_DMA(hadc1); HAL_ADC_Stop(hadc1); HAL_DMA_Abort(hdma_adc1);HAL_DMA_Abort可以确保 DMA 通道也彻底释放。如果不释放 DMA后面其他外设想用同一条 DMA 通道时会碰到资源冲突。低功耗模式下只有这些外设都停止工作芯片才能真正进入低功耗状态。如果工程里做了异常恢复最好把重启采集封装成独立函数不要在回调里直接把启动逻辑散落得到处都是。一个稳定的启动函数内部重新初始化缓冲区、清除标志位、启动 DMA再配合错误回调足够覆盖大部分异常场景。我在实际项目里的体会是ADC DMA 这个组合本身不难真正的难点在于把缓冲区、回调、通道顺序、外设启动次序这些细节一次想清楚。很多看起来“玄学”的采样问题最后都能归到某个开关没开、某个顺序没排对、某个中断没使能。先花十分钟在 CubeMX 里把配置梳理干净后面写代码就会顺畅得多。
返回列表