1. 为什么多通道ADC轮询在H7上值得单独聊一聊
STM32H7这颗芯片的ADC模块,跟F1、F4时代完全不是一个量级的东西。很多人从F103转过来,第一反应是"ADC不还是那个ADC吗,配置一下通道、开个转换、读DR寄存器就完事了"。结果一上手H7就发现,CubeMX里ADC的配置页面多了一堆没见过的选项,什么Conversion Mode、Oversampling、Offset、Regular Sequence的Rank排列,还有那个让人头大的Clock Prescaler。更坑的是,H7的ADC时钟源默认走的是per_ck或者pll2_p,跟系统时钟不是一条线,配错了要么转换结果全是0,要么采样率跟预期差一大截。
这篇内容要解决的就是一个非常具体的场景:用STM32H7的ADC1,在轮询模式下依次采集多个通道的模拟量,不依赖DMA,不依赖中断,纯靠查询EOC标志位来读取数据。你可能会问,都2025年了为什么还要用轮询?答案很实际——当你只需要采集三四个通道、采样率要求不高(比如几十到几百Hz)、而且系统里已经有其他中断在跑的时候,轮询反而是最省心、最不容易出幺蛾子的方案。DMA配置错了会静默丢数据,中断优先级配不好会互相打架,轮询虽然"笨",但它透明、可控、调试起来一目了然。
适合读这篇的人:手上有H7的开发板(不管是Nucleo-H723、H743还是自己画的板子),需要在项目里采集几路电压、电流或者传感器信号,对实时性要求不算苛刻,但希望代码干净、逻辑清晰、不引入额外复杂度。如果你正在做工业数据采集、设备状态监测、或者简单的多路模拟量巡检,这套方案可以直接拿去用。
我下面会从CubeMX的时钟树配置开始,一步步拆到代码层面的轮询逻辑,中间会重点讲几个H7特有的坑,比如ADC时钟分频的计算、采样时间的选取、以及多通道扫描模式下EOC和EOS的区别。这些细节在官方手册里都有,但散落在不同章节,实际配的时候很容易漏。
2. CubeMX里ADC时钟树的配置逻辑与常见误判
2.1 H7的ADC时钟来源跟F4完全不是一回事
在F4时代,ADC时钟直接挂在APB2上,分频系数就是简单的2/4/6/8。到了H7,ADC有自己的独立时钟域,时钟源可以选per_ck、pll2_p、pll3_r或者hse/2之类。CubeMX的Clock Configuration页面里,ADC的时钟输入是一个单独的mux选择器,你得先确定用哪个源,再看分频。
我一般推荐用pll2_p作为ADC时钟源,原因是pll2通常用来给其他外设提供时钟,频率比较灵活,而且可以独立于系统时钟调整。假设你的H7主频跑在480MHz,pll2_p设成120MHz,那么ADC的输入时钟就是120MHz。接下来关键的一步是设置ADC的Prescaler,H7的ADC时钟分频寄存器叫ADC_CLK_PRESC,分频系数可以是1/2/4/6/8/10/12/16/32/64/128/256。注意这个分频是两级的:先经过一个固定的/2或者/4(取决于ADC_CLK_PRESC的配置),再经过一个可编程的分频。
实际计算的时候,H7的ADC最大时钟频率是50MHz(不同型号可能略有差异,查你手上的数据手册确认)。如果你pll2_p给了120MHz,那分频系数至少要选4,得到30MHz,留有余量。选2的话就是60MHz,超频了,转换结果会不稳定。我见过有人图省事直接选1,结果采出来的数据跳得跟心电图一样,查了半天以为是硬件滤波没做好,其实是ADC时钟超了。
提示:CubeMX在Clock Configuration页面会实时显示ADC的最终时钟频率,如果超过芯片手册标称的最大值,它会标红。但有时候标红了也能生成代码,别忽略这个警告。
2.2 采样时间不是随便选的,跟信号源阻抗直接相关
H7的ADC每个通道可以独立配置采样时间,范围从1.5个ADC时钟周期到810.5个周期。采样时间越长,采样电容充电越充分,结果越准,但单次转换耗时也越长。很多人直接选个默认的,然后发现高阻抗信号源采出来偏差很大。
这里有个经验公式:采样时间(秒)≥ (信号源阻抗 + 内部采样开关阻抗) × 采样电容 × ln(2^N),其中N是ADC分辨率。H7的采样电容大概是4pF左右,内部开关阻抗跟采样时间设置有关。如果你采的是一个经过运放缓冲的信号,源阻抗很低(几十欧姆),那1.5个周期就够了。但如果你直接采一个分压电阻网络,源阻抗可能到几十kΩ,那就得把采样时间拉到最大。
我一般会在CubeMX里把采样时间设成64.5 cycles或者387.5 cycles作为起步,前者适合大多数低阻抗场景,后者适合高阻抗或者需要高精度的场合。具体选哪个,后面在代码里可以动态改,但CubeMX里先给个保守值。
2.3 扫描模式与连续转换模式的组合逻辑
H7的ADC支持扫描模式(Scan Mode),开启后会自动按Rank顺序依次转换多个通道。轮询模式下,你需要关注两个标志位:EOC(End of Conversion)和EOS(End of Sequence)。EOC在每个通道转换完成后置位,EOS在整个序列完成后置位。
如果你开了扫描模式但只查EOC,那每次读DR寄存器的时候,数据可能是当前通道的,也可能是上一个通道的,取决于你读的时机。正确的做法是:在扫描模式下,每个通道转换完成后EOC置位,你读DR的同时EOC自动清除,然后ADC自动开始下一个通道的转换。当所有通道转换完,EOS置位。所以轮询的逻辑应该是:等EOC,读数据,存到对应通道的数组里,循环直到EOS置位。
CubeMX里还有一个选项叫Continuous Conversion Mode,开了之后ADC会在序列结束后自动重新开始,不需要手动触发。轮询模式下我建议不开这个,因为你需要精确控制每次采集的时机,开了之后ADC一直在跑,你读数据的速度跟不上就会覆盖。
3. 从CubeMX生成代码到第一个可运行的轮询采集
3.1 引脚与通道的映射关系确认
在CubeMX的Pinout视图里,找到你要用的ADC通道对应的引脚。比如ADC1_IN3对应PA3,ADC1_IN4对应PA4,等等。注意H7的ADC通道跟引脚的对应关系跟F4不一样,别照搬旧项目的配置。你可以在CubeMX里直接点引脚,选择ADCx_INy,它会自动把对应的通道加到Regular Sequence里。
这里有个细节:H7的ADC1和ADC2可以工作在双重模式(Dual Mode),但轮询模式下我建议只用单个ADC,别开双重,否则EOC标志位的逻辑会变复杂。如果你确实需要更高的采样率,那应该考虑DMA或者双重交替模式,那是另一个话题了。
3.2 Regular Sequence的Rank排列
在ADC配置页面的Regular Conversion Mode里,你会看到Number of Conversions和Rank列表。假设你要采3个通道:IN3、IN4、IN5。Number of Conversions设成3,然后Rank1选IN3,Rank2选IN4,Rank3选IN5。采样时间可以每个通道单独设,也可以在Common Settings里统一设。
注意:Rank的顺序决定了转换的顺序,不是按通道号自动排的。你可以把采样时间长的通道放在后面,这样前面的通道转换完之后,ADC有更多时间准备。
3.3 生成代码后的初始化检查
CubeMX生成代码后,先别急着写业务逻辑,打开adc.c文件,检查几个关键点:
hadc1.Init.ClockPrescaler是否跟你计算的一致hadc1.Init.ScanConvMode是否使能hadc1.Init.ContinuousConvMode是否禁用hadc1.Init.DiscontinuousConvMode是否禁用(轮询模式下不需要)hadc1.Init.NbrOfConversion是否等于你的通道数hadc1.Init.EOCSelection是设成EOC还是EOS
最后一项特别关键。如果你设成EOC,那每个通道转换完都会置位EOC,你需要自己计数。如果设成EOS,那只有整个序列完成才置位。轮询模式下我建议设成EOC,然后手动判断是否所有通道都读完了。
4. 轮询采集的核心代码结构与实测时序分析
4.1 启动转换与等待EOC的标准流程
H7的ADC启动转换有三种方式:软件触发、硬件触发、或者连续模式自动触发。轮询模式下我们用软件触发,调用HAL_ADC_Start(),然后循环等待EOC标志。
HAL_ADC_Start(&hadc1); for (int i = 0; i < ADC_CHANNEL_COUNT; i++) { // 等待当前通道转换完成 while (!__HAL_ADC_GET_FLAG(&hadc1, ADC_FLAG_EOC)) { // 可以加超时计数,防止死循环 } // 读取转换结果 adc_values[i] = HAL_ADC_GetValue(&hadc1); } // 等待整个序列完成 while (!__HAL_ADC_GET_FLAG(&hadc1, ADC_FLAG_EOS)) { } HAL_ADC_Stop(&hadc1);这段代码看起来简单,但有几个坑:
第一,HAL_ADC_GetValue()内部会读DR寄存器,读完之后EOC自动清除。但如果你用的是LL库或者直接操作寄存器,要确认清除机制。
第二,EOC标志在扫描模式下每个通道都会置位,所以循环次数必须等于通道数。如果你只循环了2次但配了3个通道,那第三个通道的数据就丢了,而且EOS可能一直不置位。
第三,超时保护一定要加。如果ADC时钟没配好或者通道配置有误,EOC可能永远不置位,没有超时的话程序就卡死了。
4.2 实测时序:一次完整采集到底花多长时间
我用手上的H743 Nucleo板实测过,ADC时钟设成30MHz,采样时间64.5 cycles,3个通道。单次转换的时间计算如下:
- 采样阶段:64.5 cycles / 30MHz = 2.15μs
- 转换阶段:H7的ADC是逐次逼近型,12位分辨率需要大概12.5个ADC时钟周期,约0.42μs
- 单通道总时间:约2.57μs
- 3个通道:约7.71μs
加上软件开销(循环判断、函数调用),实际一次完整采集大概在10-12μs左右。也就是说,轮询模式下你最高能做到80kHz左右的采样率(3通道合计)。当然这是理论值,实际跑的时候如果系统里有其他中断,时间会被拉长。
如果你需要更高的采样率,那就得考虑DMA或者双重模式了。但80kHz对于大多数工业数据采集场景已经绰绰有余。
4.3 数据对齐与均值滤波的处理
H7的ADC支持12位、10位、8位、6位分辨率,默认是12位。读出来的数据是右对齐的,范围0-4095。如果你开了过采样(Oversampling),数据位数会变,右对齐的偏移量也不同。
我一般会在读完之后做一个简单的滑动平均滤波,窗口大小取8或者16。注意不要在轮询循环里做浮点运算,H7虽然有FPU,但浮点运算会拉长循环时间,影响采样时序。用整数移位来实现除法:
// 假设adc_values是uint16_t数组,filtered是滤波后的值 filtered[i] = (filtered[i] * 7 + adc_values[i]) / 8; // 等价于右移3位或者用累加和的方式,每8次采集求一次平均,这样计算量更小。
5. 那些CubeMX不会告诉你的H7 ADC轮询坑
5.1 ADC上电后的稳定时间被严重低估
H7的ADC在从掉电模式唤醒或者刚上电的时候,需要一段稳定时间。官方手册里写的是至少1μs,但实际测试下来,如果你在HAL_ADC_Init()之后立刻开始转换,前几次的结果会明显偏低。我一般会在初始化之后加一个HAL_Delay(1),或者先做几次丢弃采集。
// 初始化后先丢弃前3次采集 for (int i = 0; i < 3; i++) { HAL_ADC_Start(&hadc1); while (!__HAL_ADC_GET_FLAG(&hadc1, ADC_FLAG_EOC)); HAL_ADC_GetValue(&hadc1); HAL_ADC_Stop(&hadc1); }这个坑在F4上不明显,因为F4的ADC稳定时间短。H7的模拟前端更复杂,稳定时间更长,忽略这一点会导致低值信号采不准。
5.2 通道切换时的串扰问题
H7的ADC内部有多路复用器,切换通道的时候,如果前一个通道的电压跟当前通道差异很大,采样电容上残留的电荷会影响当前通道的读数。表现就是:采完一个3.3V的通道,立刻切到一个0.1V的通道,读出来的值会偏高。
解决办法有两个:一是增加采样时间,让采样电容有足够时间充放电;二是在通道之间插入一个空转换,或者把采样时间短的通道放在电压变化大的通道后面。我在实际项目里一般会把采样时间统一设成387.5 cycles,虽然慢一点,但串扰基本消失。
5.3 温度对ADC精度的影响不可忽略
H7的ADC内部有一个温度传感器通道(ADC_CHANNEL_TEMPSENSOR),但那个是测芯片温度的,不是环境温度。如果你用ADC采外部传感器,要注意H7的ADC参考电压(VREF+)会随温度漂移。H7的VREF+可以外部提供,也可以用内部的VREFBUF。内部VREFBUF的精度大概是±0.1%,温度漂移在几十ppm/°C量级。如果你的应用对精度要求高,建议外部加一个高精度基准源,比如REF3033之类的。
5.4 轮询模式下的中断干扰
如果你的系统里有其他中断(比如串口接收、定时器),它们会在轮询循环执行期间打断ADC的等待。这本身不是问题,但如果中断服务程序执行时间过长,会导致EOC标志被延迟读取,极端情况下下一个通道的转换已经完成,EOC被覆盖,你就丢了一个通道的数据。
解决办法:要么把ADC轮询放在高优先级的中断里,要么在轮询期间关闭全局中断(不推荐,会影响系统实时性)。我一般会评估中断的执行时间,如果都在几微秒以内,那影响可以忽略。
6. 从轮询到DMA的平滑过渡思路
轮询方案跑通之后,如果你发现采样率不够、或者CPU占用太高,下一步自然是上DMA。H7的DMA跟F4不一样,它有一个DMAMUX(DMA请求复用器),可以把ADC的请求映射到任意DMA流上。CubeMX里配置DMA的时候,注意选择DMA1或DMA2,然后设置Circular模式或者Normal模式。
从轮询切到DMA,代码改动其实不大:把HAL_ADC_Start()换成HAL_ADC_Start_DMA(),传入缓冲区指针和长度,然后在DMA完成回调里处理数据。但要注意,DMA模式下EOC标志位的处理方式不同,HAL库会自动处理,你不需要手动查标志。
我一般会保留轮询模式的代码作为一个"调试模式",在系统启动时先跑一遍轮询,确认ADC硬件没问题,再切到DMA。这样排查问题的时候可以快速定位是硬件问题还是DMA配置问题。
7. 几个实际项目中的参数配置参考
下面这张表是我在几个不同项目里用过的ADC配置,可以直接参考:
| 项目类型 | ADC时钟 | 采样时间 | 通道数 | 分辨率 | 过采样 | 实测采样率 |
|---|---|---|---|---|---|---|
| 低速传感器巡检 | 20MHz | 387.5 cycles | 4 | 12位 | 关闭 | 约15kHz |
| 电机电流采样 | 40MHz | 64.5 cycles | 2 | 12位 | 4x | 约50kHz |
| 电池电压监测 | 10MHz | 810.5 cycles | 6 | 12位 | 关闭 | 约3kHz |
| 音频信号采集 | 50MHz | 1.5 cycles | 1 | 12位 | 关闭 | 约200kHz |
注意最后一行的音频采集,单通道、最短采样时间、最高ADC时钟,轮询模式下能做到200kHz左右。但这时候CPU基本被占满了,实际项目里还是建议上DMA。
提示:过采样(Oversampling)开启后,ADC会自动做多次转换并求平均,结果位数会增加。比如12位+4x过采样,结果变成14位。这时候读出来的数据范围是0-16383,不是0-4095,代码里的换算系数要相应调整。
8. 调试阶段快速验证ADC是否正常工作的技巧
刚配好ADC的时候,怎么快速判断它是不是在正常工作?我的做法是:先把一个通道接到GND,读出来应该是0附近;再接到VREF+,读出来应该是4095附近。如果GND读出来是几百,那说明有偏移或者串扰;如果VREF+读出来不到4095,那可能是参考电压不对或者采样时间不够。
还有一个更直观的方法:用DAC输出一个已知电压,接到ADC输入上,看读出来的值跟理论值差多少。H7的DAC和ADC可以同时用,DAC输出1.65V(VREF/2),ADC读出来应该在2048左右。偏差在±10以内算正常,超过±50就要查配置了。
如果手头没有DAC,也可以用PWM加RC滤波产生一个可调的模拟电压,虽然精度不高,但用来验证ADC的通道切换和基本功能足够了。
9. 个人在实际项目中的几点体会
轮询模式最大的优势是确定性。你知道每一次采集什么时候开始、什么时候结束、数据存在哪里。DMA虽然效率高,但一旦配置有误,数据丢了你可能半天都发现不了。我在一个工业巡检项目里,最开始用DMA采8个通道,跑了几天才发现第7通道的数据一直是0,查了半天是DMA缓冲区长度算错了。后来改成轮询,虽然CPU占用高了点,但数据从来没丢过。
另一个体会是:H7的ADC对电源质量很敏感。如果你用的开发板是USB供电,ADC的噪声会明显比外部稳压电源大。我在采一个毫伏级信号的时候,USB供电下读出来的数据跳变有十几个LSB,换成电池供电后就降到两三个LSB。所以如果你的应用对精度要求高,电源部分一定要处理好,VDDA和VREF+的滤波电容不能省。
最后,CubeMX生成的ADC初始化代码里,HAL_ADCEx_Calibration_Start()这个函数一定要调用。H7的ADC出厂校准值存在OTP区域,不调用校准的话,精度会差很多。这个函数在HAL_ADC_Init()之后、HAL_ADC_Start()之前调用,只需要调一次。我见过有人忘了调,结果采出来的数据整体偏了5%左右,查了好久才发现是校准没做。