简介:STM32 ADC基于HAL库开发的完整工程资源包,面向嵌入式工程师、电子竞赛参赛者及需要掌握ADC多通道采样、DMA传输和低功耗设计的开发者。资源包含1000个文件,以C源文件与头文件为主,附带汇编启动文件、链接脚本、工程配置文件以及ARM数学库,压缩包约24.08MB,整体目录结构清晰,便于快速定位源码。内容覆盖ADC初始化、通道优先级配置、定时器或外部触发源设置、中断/同步转换启动、结果读取及停止流程,并深入展示了多通道DMA采集、校准函数调用、电源模式选择和抗噪声采样时间调整等实用技巧。通过研读工程源码,可系统掌握基于HAL库的ADC初始化、通道配置、触发方式、数据读取及错误处理等完整流程,缩短项目开发周期。已有226人学习下载,适合作为基于HAL库进行STM32 ADC开发的实战参考资料。
1. 从寄存器到HAL库,ADC开发为什么会换成另一套思路
1.1 为什么我最终放弃了纯寄存器写法
前阵子给一块电池管理板做固件,MCU用的是STM32F103C8T6,需要一路采集电池电压、一路采集采样电阻压降、还要预留一路给温敏电阻。刚开始我图省事,直接对着参考手册用寄存器写ADC初始化,确实快,但问题出现在换板子的时候:同一颗芯片换到另一块电路板,引脚变了、触发方式变了,我又得从头翻手册核对每个位。后来把整个采集逻辑迁到HAL库,CubeMX重新生成一次初始化代码,业务代码几乎没动就跑了。对于ADC这种牵涉时钟、引脚、通道、DMA多个模块的外设,HAL库的最大价值不是“少写几行”,而是把可移植性和可维护性提上来了。
很多老工程师觉得HAL库代码冗余、执行效率低,这个观点在严格实时控制的场景有道理,但在绝大多数ADC采样场景下并不成立。ADC本身是毫秒甚至微秒级的转换过程,MCU主频动辄几十兆赫兹,HAL库多出来的函数调用时间完全在可接受范围内。我更看重的是:HAL库把ADC的启动、轮询、中断、DMA回调都统一成了HAL_ADC_XXX这样的API,调试时只需要关注业务逻辑,不用在寄存器位定义里反复横跳。
1.2 HAL库ADC的核心代码路径,先建立整体认知
用HAL库写ADC,第一件事是明白它绕不开的几个结构体和回调。每个ADC外设都有一个ADC_HandleTypeDef句柄,比如ADC_HandleTypeDef hadc1,所有API操作都围绕这个句柄展开。初始化代码由CubeMX生成,里面已经帮你把ADC时钟、引脚复用、DMA映射都配好了,用户需要关心的在实际项目中是下面这条调用链。
轮询模式的调用链最简单:
HAL_ADC_Start(&hadc1); // 启动ADC转换 HAL_ADC_PollForConversion(&hadc1, 100); // 等待转换完成 uint32_t value = HAL_ADC_GetValue(&hadc1); // 读取结果中断模式则是:
HAL_ADC_Start_IT(&hadc1); // 转换完成后自动进入弱函数 void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { ... }DMA模式是:
HAL_ADC_Start_DMA(&hadc1, buffer, buffer_len); // 数据由DMA持续搬运到内存,转换完成后同样进入上面的回调还有一个容易被忽略的函数是HAL_ADC_MspInit,它在HAL_ADC_Init内部被自动调用,负责使能ADC时钟、配置引脚、配置DMA。CubeMX生成的代码里它长这样:
void HAL_ADC_MspInit(ADC_HandleTypeDef* hadc) { // 使能ADC时钟 __HAL_RCC_ADC1_CLK_ENABLE(); // 配置引脚为模拟模式 GPIO_InitStruct.Mode = GPIO_MODE_ANALOG; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); // 配置DMA句柄 hdma_adc1.Instance = DMA1_Channel1; HAL_DMA_Init(&hdma_adc1); __HAL_LINKDMA(hadc, DMA_Handle, hdma_adc1); }理解这条调用链之后,你调试ADC时就能快速定位问题到底出在“配置阶段”还是“数据获取阶段”,而不是一上来就怀疑是芯片坏了或硬件连错了。如果你正从标准库切到HAL库,我建议先跑通一个最简单的单通道轮询例子,把上面的链路过一遍,再上多通道DMA。
2. ADC采样前的几个关键参数,配错全是坑
2.1 ADC时钟上限与转换时间计算
CubeMX里ADC配置界面第一个参数就是Clock Prescaler,也就是ADC时钟分频系数。很多人随便选一个,最后发现采样值不准,根因往往在这里。STM32F1系列ADC挂在APB2总线上,APB2默认频率一般是72MHz,而ADC模块最高只能跑到14MHz,所以至少要分频到6分频(72/6=12MHz)或者8分频(9MHz)。分频系数太低会导致ADC工作频率超标,转换结果根本不可靠,这种情况下软件滤波也救不回来。
ADC单次转换时间由采样时间和转换周期两部分组成。F1的采样时间可选1.5、7.5、13.5、28.5、41.5、71.5、239.5个ADC时钟周期,转换周期固定是12.5个周期。比如ADC时钟12MHz、采样时间41.5周期,那么一次转换时间就是(41.5 + 12.5)/12MHz = 4.5微秒。如果采样时间设成1.5周期,转换时间就只有1.17微秒左右,速度很快,但内部采样电容充电时间不够,采集高阻抗信号源时误差会明显变大。
实际项目中需要根据信号源输出阻抗来选采样时间。信号源阻抗小、驱动能力强,可以选短采样时间保证采样率;信号源阻抗大,比如直接接一个几十千欧的分压电阻网络,建议用41.5周期以上,否则你看到的数据可能就是“飘”的。
2.2 通道配置、分辨率和对齐方式
CubeMX里添加ADC通道时,除了选择通道号,还要配置采样时间、分辨率、数据对齐方式。STM32的ADC分辨率默认12位,采样值范围0~4095。板子上的参考电压如果是3.3V,那么一个LSB对应的电压约为0.8毫伏。如果需要更高采样率而不是精度,可以把分辨率降到10位、8位甚至6位,转换周期会缩短,但精度损失也很直接,没什么黑科技。
对齐方式一般选右对齐,因为ADC的数据寄存器是16位,而实际数据只占低12位,右对齐读取时直接强转uint16_t即可。左对齐通常用于和DAC、PWM等模块对接的特定场景,普通采集用不到。
配置通道时还有一个容易忽略的点:CubeMX里每个通道都可以单独设置采样时间。多通道扫描时,某个通道的源阻抗大,就单独给它加长采样时间,而其他通道保持短采样时间,这样能在整体采样速度不受太大影响的前提下提升精度。我用HAL_ADC_ConfigChannel配置通道时,会刻意把电桥这种高阻抗通道的采样时间拉长。
2.3 规则组与注入组的选择,到底该用哪个
STM32的ADC分成规则组和注入组两组转换通道。规则组数量最多可以有16个通道,日常采集基本都在用规则组;注入组更像“高优先级打断”,它可以在规则组转换过程中插入一次转换,适合需要紧急响应的场景,比如过流保护瞬间采集电流值。
实际开发中绝大多数需求用规则组就够了,因为规则组配合DMA可以把所有通道的数据连续搬运到内存。注入组的问题在于它没有DMA通道,转换完成只能靠中断或轮询读取,吞吐量上不去。我见过有人为了“看起来更高级”给关键电压通道配了注入组,结果代码复杂度上去了,数据却没有更准。如果你不是正在做电机控制、开关电源这类需要同步触发采样的项目,建议先老老实实用规则组。
3. 轮询、中断、DMA三种读取方式怎么选
3.1 轮询模式:简单,但别让主循环卡住
轮询模式是最基础的方式,代码只有三行,也是我调试新板子时第一个验证ADC功能的方法。它的核心逻辑就是启动后死等转换完成标志位。轮询模式适合采样频率很低、一次转换完成后主循环能很快处理完数据的场景,比如每秒读一次温度、读一次电压。
但轮询模式有个非常容易踩的坑,就是HAL_ADC_PollForConversion的超时参数。我见过有人写成HAL_MAX_DELAY,然后ADC硬件出了问题,整个程序就卡死在等待标志位上。建议超时时间根据转换时间留好余量,比如上面算出来是4.5微秒,你至少给100毫秒,宁可超时返回错误也别让系统卡死。实际项目中我会在规约里预留超时处理,一旦ADC转换超时,就上报故障而不是静默卡住。
3.2 中断模式:适合低频突发采样
中断模式在启动后立即返回,转换完成自动进入HAL_ADC_ConvCpltCallback回调函数。这个模式适合需要异步采样、不想阻塞主循环的场景,比如按键消抖定时器里触发一次ADC读取,或者串口收到命令后采集电流。
中断模式的坑是很多人忘了在回调里重新启动下一次转换。除非你开启了连续转换模式,否则每次转换完成后ADC外设就停了,如果不再次调用HAL_ADC_Start_IT,采样就会停在那儿。正确写法是:
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc->Instance == ADC1) { uint32_t adc_value = HAL_ADC_GetValue(&hadc1); // 处理业务数据 HAL_ADC_Start_IT(&hadc1); // 再次启动,保持连续采样 } }同时在中断回调里不要做耗时操作,比如立即把数据通过阻塞式串口发送出去,否则会拖慢中断处理,影响其他更紧急的中断。
3.3 DMA模式:多通道连续采集的正确打开方式
多通道扫描模式下,规则组只有一个数据寄存器,如果不用DMA,每转换完一个通道你都得赶紧读走,否则下一个通道的转换结果会把上一个覆盖。DMA的作用就是把每次转换结果自动搬到你指定的内存数组里,完全不需要CPU介入。
调用方式:
uint16_t adc_buffer[3]; HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buffer, 3);开启后DMA会按照通道配置顺序,把三次转换的结果依次写入adc_buffer[0]、adc_buffer[1]、adc_buffer[2]。当一组扫描完成,HAL_ADC_ConvCpltCallback会被调用。如果你在CubeMX里把DMA模式设成Circular循环模式,同时开启了ADC连续转换,那么DMA会一直搬数据,省去重复启动的麻烦。这也是我实际项目中最常用的方式,系统启动后启动一次DMA,之后就在内存里不断拿到最新数据。
3.4 三种方式对比
| 模式 | 适用场景 | 优点 | 典型坑 |
|---|---|---|---|
| 轮询 | 单通道、慢速、调试 | 代码简单,逻辑直观 | 超时设置不当会导致卡死 |
| 中断 | 单通道、突发、异步 | 不阻塞主循环 | 忘记再次启动下一次转换 |
| DMA | 多通道、连续、高速 | CPU占用低,数据自动搬运 | 缓冲区大小/类型配错,数据错位 |
4. 多通道扫描+DMA实战:三路电压采集
4.1 CubeMX里的配置顺序,直接影响数据顺序
在CubeMX中给ADC1添加三个通道后,会看到Rank1、Rank2、Rank3列表,这个顺序就是硬件实际转换的顺序。比如我把PA0配置成Rank1对应ADC_CHANNEL_0,PA1配置成Rank2对应ADC_CHANNEL_1,PA2配置成Rank3对应ADC_CHANNEL_2,那么DMA搬回数组后,buffer[0]就是PA0的值,buffer[1]就是PA1的值,buffer[2]就是PA2的值。
配置时要确认几个关键选项:Scan Conversion Mode必须Enabled,Continuous Conversion Mode根据需求决定,DMA Continuous Requests也建议Enabled。如果扫描模式没开,即使你配置了多个通道,ADC也只会转换第一个Rank,DMA收到的全是同一个通道的数据。这三个参数我每次都会反复检查,因为它们太容易在生成代码后被忽略。
4.2 用户代码的完整写法
CubeMX生成基础代码后,用户只需要在main函数里做两件事:初始化后校准ADC,启动DMA转换。一个最小可跑的例子如下:
uint16_t adc_buffer[3]; int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_DMA_Init(); MX_ADC1_Init(); // 上电校准ADC,F1系列强烈建议执行 HAL_ADCEx_Calibration_Start(&hadc1); // 启动DMA采集,三通道连续转换 HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buffer, 3); while (1) { // 主循环里可以直接读取adc_buffer // 注意volatile声明,或在使用时关中断防止数据被DMA更新打断 } } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc->Instance == ADC1) { // 一组转换完成,可以拷贝数据或置标志位 for (int i = 0; i < 3; i++) { adc_values[i] = adc_buffer[i]; } } }有一点必须提醒:adc_buffer必须是全局变量或static变量,不能是局部变量,因为DMA写入是在主循环背后异步发生的。如果数组定义在函数内部,编译器可能把它放在栈上,地址不固定,DMA写到一半栈回收就出乱子了。我早期就吃过这个亏,数据莫名其妙全是0,查了半天才发现是数组生命周期问题。
4.3 我踩过的两个DMA坑
第一个坑是DMA回调里直接做串口发送。有一次用阻塞式HAL_UART_Transmit在回调里打印三路ADC值,结果采样频率一提高,串口发送占用的时间比ADC转换时间还长,整个程序卡到没法看。后来改成回调里只置一个标志位,主循环里再打印,问题立刻消失。回调里的原则只有一个:能少做事就少做事。
第二个坑是DMA半传输中断和完全传输中断的区别。HAL库提供两个回调,HAL_ADC_ConvHalfCpltCallback和HAL_ADC_ConvCpltCallback。在循环DMA模式下,半传输和完全传输会交替触发。如果你用三通道,半传输可能发生在第一个通道或第二个通道之后,具体位置取决于缓冲区大小和DMA传输宽度。处理不好就会出现“这轮数据从中间开始”的错觉。最稳妥的做法是只用完全传输回调,并且只在回调里做数据拷贝,不用处理半传输。
5. ADC采样值乱跳的排查链路
5.1 软件层面:先检查采样时间和转换节奏
ADC数值跳动是群里问得最多的问题。我的排查顺序是从软件到硬件,先排除容易改的,再动烙铁。
第一步看采样时间。如果信号源带高阻抗且采样时间设置过短,采集到的值是明显偏低的,而且会随温度、湿度轻微波动。先把所有通道采样时间调到最大档,看看数值是否稳定,如果稳定了再逐通道调短,找到临界值。第二步看转换节奏。假如你开了连续转换,主循环每隔100毫秒才读一次adc_buffer,看起来好像是“实时”数据,但其实buffer里的值可能已经被DMA更新了无数次,你读到的是最近一次转换的结果。如果电源本身有纹波,采样点刚好落在纹波波峰和波谷之间,数值跳动就非常正常,这时可以考虑用定时器触发ADC,让采样相位固定。
第三步检查代码里有没有无意中修改了ADC引脚的工作模式。比如你已经把PA1配为模拟输入,但如果后续某段代码又把PA1复用为GPIO输出,那么ADC通道输入就变得不稳。特别是在一些多功能板子上,引脚复用是排查时需要重点怀疑的对象。
5.2 硬件层面:参考电压与输入阻抗的影响
软件查完没问题,就要考虑硬件了。STM32 ADC的转换结果是基于参考电压的。F103这一类芯片的VREF+引脚如果接到3.3V,那么3.3V自身的纹波就直接进入ADC结果。开关电源供电时,纹波往往在几十毫伏级别,12位ADC完全能分辨出来。这种情况下软件做均值滤波能缓解,但不能根治,更好的做法是参考电压用独立的基准源,或者在VREF+引脚加高精度稳压和滤波电容。
输入阻抗是另一个高频问题。ADC内部采样电容的充电电流需要信号源来提供。如果你用一个高阻值分压电阻网络,比如100k欧姆分压电池电压,那么采样瞬间电容充电会让信号源电压被拉低,导致测量偏低。最简单的解决办法是减小分压电阻阻值,或者加一个电压跟随器(运算放大器缓冲)。我在产品上都是用跟随器加RC低通滤波,效果比在软件里反复调参立竿见影。
5.3 一个通用滤波函数,先顶住现场问题
在硬件优化之前,软件滤波是最快见效的方法。不同项目我用的滤波策略不一样,最简单实用的是去极值均值滤波:连续采多个数,去掉最大值和最小值,剩下的取平均。这样既能滤掉毛刺,又不会过度平滑导致响应迟钝。一个可以直接用的函数:
#define FILTER_N 8 uint32_t ADC_Filter(void) { uint32_t sum = 0; uint32_t min_val = 0xFFFFFFFF; uint32_t max_val = 0; uint32_t value; for (int i = 0; i < FILTER_N; i++) { value = HAL_ADC_GetValue(&hadc1); if (value < min_val) min_val = value; if (value > max_val) max_val = value; sum += value; } return (sum - min_val - max_val) / (FILTER_N - 2); }用的时候注意:如果只是低频缓慢信号,这个函数很合适;如果要做电流环或电压环控制,滤波会引入相位延迟,反而可能让系统不稳定。经验是控制回路里尽量少滤波,或者用一阶低通滤波并调好截止频率。
6. HAL库用好这些细节,能少踩一半坑
6.1 上电校准这件小事,千万别省
STM32F1系列的ADC硬件上带校准功能,但很多人不知道。在初始化完成后调用一次HAL_ADCEx_Calibration_Start,可以修正内部偏移误差。我第一次用F103时没做校准,采集小信号电压发现零点有十几个字的偏移,查了半天资料才补上这个函数。现在的习惯是每次上电都做一次校准,代码也不复杂:
if (HAL_ADCEx_Calibration_Start(&hadc1) != HAL_OK) { Error_Handler(); }校准需要在ADC被使能且没有转换任务时调用,所以放在初始化之后、启动DMA之前最合适。F4、H7等系列的校准API略不同,但思路一致,换系列时留意一下手册就行。
6.2 停止ADC时有顺序讲究,直接调用HAL_ADC_Stop可能出错
用DMA模式时,停止ADC不能只调用HAL_ADC_Stop,否则DMA可能还在运行,状态机也容易搞乱。安全的停止方法是先停止DMA转换:
HAL_ADC_Stop_DMA(&hadc1);如果以后需要重新启动,再调用HAL_ADC_Start_DMA。如果你的系统有低功耗要求,进入休眠前也要先停掉ADC和DMA。我遇到过的情况是:休眠前只调了HAL_ADC_Stop,DMA还在跑,结果唤醒后ADC配置错乱,数据完全是错的。后来统一改成HAL_ADC_Stop_DMA后问题消失。
6.3 换芯片时的几个提醒
STM32生态里ADC外设看起来同名,但不同系列细节差异很大。F1的ADC最高时钟14MHz,F4可以直接36MHz;F1的采样周期档位是1.5/7.5/13.5/28.5/41.5/71.5/239.5,F4的档位变成了1.5/7.5/15/22.5/30/37.5/47.5/62.5/92.5/247.5/480等。也就是说,你在F103上调好的参数直接搬到F407上,采样时间和转换时间都会变,必须重新算。CubeMX的好处是它会根据目标芯片自动过滤可选项,但你仍然需要根据实际信号源调整采样时间,不能完全依赖默认值。
还有一点是关于DMA请求映射。F1的ADC DMA请求固定在DMA1的某个通道,F4的ADC可以用DMA2的stream,环境在CubeMX里配置起来很简单,但如果你自己手写底层代码,就很容易在映射关系上出错。在HAL库开发生态下,我强烈建议让CubeMX生成底层配置,自己只维护业务逻辑,这也是我从寄存器切到HAL库之后最大的感受:不是HAL库不会坑你,而是你终于可以把精力从寄存器位定义转移到真正需要解决的信号调理、数据可靠性这些工程问题上了。
本文还有配套的精品资源,点击获取