前阵子做一个采集板,要同时看三路模拟量:一路电位器、一路 NTC 热敏、一路电池分压。一开始图省事,用最朴素的写法——主循环里挨个通道 HAL_ADC_Start + HAL_ADC_PollForConversion 再读值。跑起来才发现两个问题:三路读数之间的时间间隔完全不可控,而且一旦主循环里有别的事情耽搁,采样点就稀了。
这篇把 ADC 采样的三种配置讲清楚,以及为什么多通道场景基本绕不开 DMA。
先看三种配置的差别
| 配置 | 触发方式 | CPU 占用 | 采样间隔 | 适用场景 |
|---|---|---|---|---|
| 单次转换 + 轮询 | 软件逐次启动 | 高(阻塞等待) | 不确定,取决于主循环 | 上电自检、偶尔测一次 |
| 连续转换 + 轮询读 | 启动一次后自动循环 | 中 | 由 ADC 时钟决定,均匀 | 单通道、要求不高的场合 |
| 连续转换 + DMA | 启动一次后自动搬运 | 极低 | 均匀,且与 CPU 无关 | 多通道扫描、波形采集 |
关键差别不在”快”,而在确定性。轮询写法里,两次采样之间隔多久取决于你那轮主循环跑了什么;DMA 写法里,采样节奏由 ADC 自己的时钟和 DMA 请求决定,主循环忙不忙都不影响。
单通道:连续转换就够了
如果只采一路,ContinuousConvMode = ENABLE 配一次 HAL_ADC_Start(),之后随时读 ADC1->DR 就是最新值。这种写法很多教程在用,也确实是单通道最简单的方式。
但它有个隐式前提:你读得比它转得快。12 位 ADC 在 14MHz ADC 时钟、239.5 周期采样时间下,一次转换大约 18 微秒。如果主循环里有延时或者阻塞操作,两次读数之间实际跨了好几次转换,你读到的只是其中一个。对慢变量(比如温度)没关系,对波形就是灾难。
多通道:扫描模式必须配 DMA
这是最容易踩的地方。开了 ScanConvMode = ENABLE 之后,ADC 会按规则组的顺序依次转换各个通道,但数据寄存器 DR 只有一个。转换完通道 1 的数据会在通道 2 转换完成时被覆盖。
也就是说,不开 DMA 的扫描模式,你在序列末尾读到的永远是最后一个通道的值,前面几个通道的数据全丢了。这不是配置错了,是硬件结构决定的。
正确做法是让 DMA 在每次 EOC 时把 DR 搬走:
#define ADC_CH_NUM 3
#define ADC_BUF_LEN (ADC_CH_NUM * 8) /* 每个通道 8 个点,共 24 个半字 */
uint16_t adc_buf[ADC_BUF_LEN];
void adc_init(void)
{
/* CubeMX 里对应的配置:
ScanConvMode = ENABLE
ContinuousConvMode = ENABLE
DMAContinuousRequests = ENABLE
NbrOfConversion = 3
规则组顺序:Rank1=IN0(电位器) Rank2=IN1(NTC) Rank3=IN2(电池)
*/
HAL_ADCEx_Calibration_Start(&hadc1); /* F1/F4 需要先校准 */
HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf, ADC_BUF_LEN);
}
HAL_ADC_Start_DMA 的第三个参数是传输次数,不是字节数。这里缓冲区是 uint16_t,DMA 配置成半字(Half Word)宽度,所以 24 个元素就传 24 次。
环形缓冲 + 半传输中断:不要每次都等整块填满
DMA 配成 DMA_CIRCULAR 之后会一直循环填这块缓冲。如果只在”传输完成”中断里处理,你会一次拿到一整块,但处理期间新数据已经在覆盖缓冲区开头了。
标准的做法是用半传输 + 传输完成两个中断做双缓冲:
static volatile uint8_t half_ready = 0;
/* 前半块填满 */
void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc)
{
if (hadc->Instance == ADC1) {
process_block(&adc_buf[0], ADC_BUF_LEN / 2);
}
}
/* 后半块填满 */
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc)
{
if (hadc->Instance == ADC1) {
process_block(&adc_buf[ADC_BUF_LEN / 2], ADC_BUF_LEN / 2);
}
}
这样”填前半块”和”处理后半块”是并行的,只要 process_block 的耗时小于半块填满的时间,就永远不会丢数据。
process_block 里只做轻活——求平均、判阈值、塞进队列。不要在回调里做浮点运算和串口打印,中断里拖太久,下一半块照样会被覆盖。
四个我踩过的坑
一、采样时间给太短,读数偏低。 NTC 前面串了 10k 分压,源阻抗不低。采样时间用默认的 1.5 周期时,采样电容还没充满就转换了,读数系统性偏低。改成 239.5 周期后正常。经验值:源阻抗超过 10k,采样时间往大了给。
二、忘了校准。
F1/F4 系列上电后要跑一次 HAL_ADCEx_Calibration_Start(),不做的话零点和增益都有偏差。这个坑很安静——数据”看起来能用”,只是不准。
三、DMA 缓冲区和 ADC 对齐问题。
用 uint16_t 缓冲时,DMA 的 MemDataAlignment 必须设成 HALFWORD。设成 WORD 会出现两个通道数据拼在一个 32 位里,解析出来全是乱的。
四、多通道数据交错,不是分块。
DMA 搬过来的是 [CH0, CH1, CH2, CH0, CH1, CH2, ...] 这样按转换顺序交错的,不是先存满 CH0 再存 CH1。所以取某一路要按步长跳:
/* 取第 ch 路在本次块内的平均 */
uint16_t channel_avg(const uint16_t *block, int block_len, int ch)
{
uint32_t sum = 0;
int cnt = 0;
for (int i = ch; i < block_len; i += ADC_CH_NUM) {
sum += block[i];
cnt++;
}
return cnt ? (uint16_t)(sum / cnt) : 0;
}
什么时候不需要 DMA
也别为了”规范”硬上。下面这几种情况,轮询反而更省事:
- 上电只测一次电池电压
- 采样频率低于 1Hz 的慢变量(比如环境温度)
- 内存极度紧张的小容量型号(DMA 缓冲区也是 RAM)
- 只需要一路、且主循环节奏稳定的场景
判断标准很简单:采样点之间的时间间隔对你有没有意义。没意义就轮询,有意义就上 DMA。
小结
- 单通道、要求不高:连续转换 + 随时读 DR
- 多通道:必须 DMA,否则只能拿到最后一个通道
- 要波形或要均匀采样:连续 + 环形 DMA + 半传输中断双缓冲
- 回调里只做轻活,重活丢给主循环
ADC 本身不难,难的是”你以为它在按你想的节奏采”。把采样时序画出来,很多问题一眼就看得到。
评论与讨论
使用 GitHub 账号登录即可留言(首次使用需在 giscus 中授权)