前阵子做一个采集板,要同时看三路模拟量:一路电位器、一路 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 本身不难,难的是”你以为它在按你想的节奏采”。把采样时序画出来,很多问题一眼就看得到。