☰
STM32F407独立ADC双通道扫描+DMA搬运的寄存器配置详解
2026/10/14 1:37:20 网站建设 项目流程

有一阵子没碰STM32F407的ADC了。最近一个项目需要同时采集两路模拟量,一路是电压,一路是电流传感器的输出,采样频率不用太高,但要持续刷新,还不能让CPU一直忙着读数据。翻看现成的例程,发现大部分都是HAL库版本,而我一直习惯直接写寄存器,索性对着参考手册重新捋了一遍,把“独立ADC + 规则组双通道扫描 + DMA搬运”这套配置彻底跑通了。这里把关键寄存器、完整代码和调试过程中踩过的坑记下来,方便以后直接抄,也给想深入理解寄存器配置的朋友一份可以复现的参考。

1. 从需求说起:独立ADC双通道+DMA到底在解决什么

1.1 双通道模拟采集的典型场景

单片机的ADC最常见的使用方式就是采集单个通道,比如读一个电位器电压,或者一个NTC电阻分压后的温度电压。但很多项目不止一路模拟信号:既要看电源电压,又要看负载电流;既要读环境光强度,又要读电池电压。如果用单次转换的方式轮流采集两个通道,代码虽然简单,但每次都要启停ADC、等待转换结束、手动取值。在实时性要求不高的场合没问题,可一旦系统里还有PWM、中断、通信任务,这种“读一次卡一次”的方式就很影响调度。

更好的方式是把两个通道放进ADC的规则序列里,让ADC自己按顺序连续转换,转换结果由DMA自动搬到内存数组。CPU从头到尾不参与,只负责在需要的时候去数组里拿最新数据。这正是标题里“独立ADC-DMA-双通道”要表达的核心:ADC1工作在独立模式下,规则组扫描两个通道,DMA负责搬运结果。

1.2 独立ADC、规则组和DMA三个概念先分清

在STM32F407上,“独立ADC”指的是ADC1、ADC2、ADC3各自独立工作,不使用双ADC联合采样模式。双模式是让两个ADC同步或交叉采样,用来扩展通道或者提高采样速率。我们的需求只是同一颗ADC采集两个普通引脚,所以用独立模式就够了。

“规则组”是ADC概念里的一个重要分组。F407的ADC有两种转换组:规则组和注入组。规则组适合常规的连续采集,支持多通道序列,配置好了之后ADC会自动按顺序转换。注入组适合插入的紧急采样,普通应用很少用到。DMA只搬运规则组的数据,不处理注入组,这个要在脑子里有个底。

“DMA”在这里是搬运工。ADC转换完成一个通道后,转换值会出现在数据寄存器ADC1->DR里,DMA检测到这个事件,就把这个16位数据搬到你指定的内存地址,同时内存地址自动递增。两个通道转换完,正好搬进两个连续的数组元素。

1.3 为什么我选了寄存器而不是HAL

用HAL库开发确实快,两三张图片就能让你把ADC+DMA跑起来。但代价是你很难知道底层到底发生了什么。一旦DMA不搬运、数据错位、采样值乱跳,连从哪里排查都不知道。寄存器版本则相反,每个开关都看得见摸得着,你清楚知道是哪一个位没配置对,调试时心里踏实。

另外寄存器版代码在体积和执行效率上也有优势。没有库函数的层层封装,不需要初始化一堆结构体,几十行代码就能完成整个链路。如果你之后要移植到别的系列,只要寄存器定义和时钟分频稍有变动,改起来也不会痛苦。这篇内容用的就是纯粹的寄存器操作,基于标准外设头文件,不依赖HAL库。

2. 动手前的硬件与时钟梳理

2.1 引脚通道映射先查清楚

STM32F407的ADC1拥有多个输入通道,不同引脚映射不同通道编号。我这次选择PA0和PA1作为两路模拟输入,分别对应ADC1的通道0和通道1。PA0是ADC12_IN0,PA1是ADC12_IN1,也就是说这两个通道在ADC1和ADC2上都存在,可以用ADC1来采集。

在接线层面,PA0、PA1要接模拟信号源。F407的ADC输入引脚推荐低源阻抗,最好在信号源和引脚之间串一个小的RC滤波器,比如100欧姆电阻加100nF电容到地。这个后面采样稳定性的内容里会再提。

2.2 ADC时钟分频不能拍脑袋

F407的ADC最高工作频率受VDDA电压影响,通常在VDDA为3.3V时上限约36MHz。而系统初始化后,APB2外设时钟往往是84MHz。如果直接把ADC时钟设成PCLK2/2,也就是42MHz,就会超过上限,导致ADC转换结果不稳定甚至异常。所以必须通过公共配置寄存器ADC_CCR里的ADCPRE位做分频。

ADCPRE是2位字段,共4档:00对应PCLK2/2,01对应PCLK2/4,10对应PCLK2/6,11对应PCLK2/8。我们选择PCLK2/4,即84MHz除以4等于21MHz,既满足上限要求,转换速度也足够快。这个细节很多人容易漏掉,直接用默认值跑,结果采集值总是跳,原因往往就在这里。

2.3 ADC1的DMA请求挂在哪个流上

F407的ADC1有两个DMA请求映射目标,均位于DMA2控制器上:Stream0的Channel0,以及Stream4的Channel0。我们的例程选用DMA2的Stream0,通道号选Channel0,也就是CHSEL字段配置为0。注意选择DMA流时还要考虑工程里其他外设是否已经占用这个流,避免冲突。如果Stream0已经用在了别处,可以改用Stream4,DMA配置除了中断标志位不同外基本一致。

DMA2的Stream0中断状态和中断清除标志位于LIFCR/LISR寄存器,对应位是低5位。使用中断功能时要注意看的是LISR而不是HISR,很多人容易混淆。这里为了演示简洁,我们先不开启DMA中断,直接使用循环模式,数据持续刷新到内存,主循环随时读取。

3. 寄存器位级拆解

3.1 GPIO模拟输入模式怎么配

GPIO配置看似简单,但直接影响ADC输入阻抗和漏电流。PA0和PA1需要配置为模拟模式。GPIOA的MODER寄存器是2位一组,模式3表示模拟输入。配置代码就是把MODER的第0位和第1位对应的两个2位字段都写成二进制11。

同时要把PUPDR配置为无上下拉,也就是对应位清零。如果设置了上拉或下拉,会引入额外的电阻网络,导致采样值偏大或偏小。模拟模式下,OSPEEDR和OTYPER不需要特殊配置,保持默认即可。GPIOA的时钟由AHB1总线提供,对应RCC_AHB1ENR里的GPIOAEN位。

3.2 序列寄存器SQR1和SQR3怎么安排

F407的规则序列支持最多16个转换,存储在不同寄存器里。SQR3存放序列中的第1到第6个通道编号,SQR2存放第7到第12个,SQR1存放第13到第16个以及序列长度L。我们只有两个通道,所以只需要设置SQR3和SQR1。

SQR3中每个序列项占用5位,不是连续的6位,中间有一个保留位。第1个通道字段SQ1位于bit0~bit4,第2个通道字段SQ2位于bit6~bit10。对于PA0和PA1,通道编号分别是0和1,所以要把SQ1写成0,SQ2写成1。具体代码是SQR3 = (0U << 0) | (1U << 6)。

序列长度L字段位于SQR1的bit20~bit23,表示序列长度减1。因为是2个通道,L要写成1,即SQR1 = (1U << 20)。这里容易出错:有人想当然地把L写成2,结果ADC只转换序列中的前3个通道,或者因为序列项未配置而行为异常。

3.3 采样时间寄存器SMPR2的选择

ADC的每个通道可以独立配置采样时间,位数越多采样时间越长,结果越稳定。PA0和PA1属于通道0和通道1,它们的采样时间字段在SMPR2寄存器中:通道0对应bit0~bit2,通道1对应bit3~bit5。字段值7代表最大采样时间:480个ADC时钟周期。

为什么要用480个周期?因为ADC内部采样电容需要时间充电,如果信号源阻抗较大而采样时间太短,电容还没充到最终电平就被隔离了,测量值会偏低且波动大。480个周期虽然单个转换时间长一点,但对绝大多数传感器输出很友好。如果你的信号源阻抗非常低,比如运放输出直接驱动,可以改成112个周期甚至更短,转换速度会更快。

3.4 CR1和CR2里的关键开关位

CR1里我们用到了SCAN位。扫描模式下,ADC会把SQR寄存器里排好序的通道逐个转换一遍。如果不设置SCAN,即便序列里有两个通道,ADC也只转换第一个通道就结束。

CR2里的位比较多,这里逐个说。ADON是ADC开关,上电后必须置1。CONT是连续转换使能,置1后ADC会不断重新启动整个规则序列,实现连续扫描。DMA位是DMA请求使能,置1后每次规则组转换结束都会发出DMA请求。DDS位叫做DMA disable selection,它决定在最后一个规则通道转换完成后,DMA请求是否继续产生。在连续转换模式下,如果DDS保持0,DMA可能只搬完一轮序列就停了。必须把DMA、CONT、DDS三个位都置1,才能得到持续刷新的效果。

4. 寄存器版完整代码与执行流程

4.1 时钟、GPIO与ADC校准的初始化代码

先看初始化的前半部分,完成时钟、引脚、ADC校准和规则序列配置。

#include "stm32f4xx.h" volatile uint16_t adc_buffer[2] = {0}; void ADC1_DMA_Init(void) { // 1. 开启 GPIOA、DMA2、ADC1 时钟 RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN | RCC_AHB1ENR_DMA2EN; RCC->APB2ENR |= RCC_APB2ENR_ADC1EN; // 2. PA0、PA1 配置为模拟输入,无上下拉 GPIOA->MODER &= ~((3U << 0) | (3U << 2)); GPIOA->MODER |= (3U << 0) | (3U << 2); GPIOA->PUPDR &= ~((3U << 0) | (3U << 2)); // 3. ADC 时钟分频:APB2 84MHz / 4 = 21MHz ADC1->CCR &= ~ADC_CCR_ADCPRE_Msk; ADC1->CCR |= (1U << ADC_CCR_ADCPRE_Pos); // 4. ADC1 上电并执行校准 ADC1->CR2 = 0; ADC1->CR2 |= ADC_CR2_ADON; ADC1->CR2 |= ADC_CR2_CAL; while (ADC1->CR2 & ADC_CR2_CAL); // 5. 扫描模式:规则组序列长度为2 ADC1->CR1 = ADC_CR1_SCAN; ADC1->SQR3 = (0U << 0) | (1U << 6); // SQ1=通道0,SQ2=通道1 ADC1->SQR1 = (1U << 20); // L=1,表示2个转换 // 6. 两个通道采样时间都设为480周期 ADC1->SMPR2 |= (7U << 0) | (7U << 3); }

校准这段不要省略。F407的ADC在上电后必须做一次校准,否则增益误差会比较大。校准流程是:先置ADON让ADC上电,再置CAL启动校准,然后等待CAL位被硬件清0。校准期间不能进行转换,所以顺序放在序列配置之前比较稳妥。

4.2 DMA配置代码

DMA这边的配置包括清除残留标志、设置外设地址、内存地址、传输数量和数据宽度。ADC1->DR是12位有效数据,DMA传输宽度选16位最合适,内存端也用16位。内存地址要递增,外设地址不递增,方向是外设到内存。

// 7. 关闭DMA流,防止配置期间意外使能 DMA2_Stream0->CR &= ~DMA_SxCR_EN; while (DMA2_Stream0->CR & DMA_SxCR_EN); // 8. 清除Stream0的旧中断标志 DMA2->LIFCR |= DMA_LIFCR_CTCIF0 | DMA_LIFCR_CHTIF0 | DMA_LIFCR_CTEIF0 | DMA_LIFCR_CDMEIF0 | DMA_LIFCR_CFEIF0; // 9. 外设地址指向ADC1数据寄存器,内存地址指向数组 DMA2_Stream0->PAR = (uint32_t)&ADC1->DR; DMA2_Stream0->M0AR = (uint32_t)adc_buffer; // 10. 传输次数=2,正好对应两个通道 DMA2_Stream0->NDTR = 2; // 11. 配置DMA流属性 DMA2_Stream0->CR = DMA_SxCR_CIRC | // 循环模式 DMA_SxCR_MINC | // 内存地址递增 DMA_SxCR_PSIZE_0 | // 外设16位 DMA_SxCR_MSIZE_0 | // 内存16位 DMA_SxCR_PL_0 | // 优先级高 DMA_SxCR_PL_1; // 注意CHSEL保持为0,对应Channel0;DIR保持为00,表示外设到内存 // 12. 最后使能DMA流 DMA2_Stream0->CR |= DMA_SxCR_EN; }

DMA配置的顺序有讲究。先关流,再清标志,然后写地址和数量,最后再使能。如果你直接改已经使能的DMA流,可能产生不可预期的传输行为。循环模式让DMA在传输次数减到0后自动重载NDTR,这样ADC连续转换时,DMA就会一遍遍地把最新数据覆盖到数组里。

4.3 启动转换与数据读取

ADC和DMA都初始化完后,最后一步是设置ADC的连续转换和DMA请求,然后软件触发启动。注意SWSTART位是写1启动,硬件完成后会自动清0,不需要我们手动停。

// 13. 开启连续转换和DMA持续请求 ADC1->CR2 |= ADC_CR2_CONT | ADC_CR2_DMA | ADC_CR2_DDS; // 14. 软件触发,启动规则组转换 ADC1->CR2 |= ADC_CR2_SWSTART; }

启动之后,主循环里就可以直接读数组了。因为数组声明成volatile,编译器不会优化掉读取,省得你调试时发现变量值永远不变。

int main(void) { ADC1_DMA_Init(); while (1) { uint16_t val0 = adc_buffer[0]; uint16_t val1 = adc_buffer[1]; // 把12位原始值换算成电压 float v0 = (float)val0 * 3.3f / 4095.0f; float v1 = (float)val1 * 3.3f / 4095.0f; delay_ms(100); } }

12位ADC的满量程是4095,对应参考电压3.3V。换算公式里的分母用4095还是4096,取决于你习惯怎么处理。严格来说满量程输入对应寄存器值4095,所以用4095做除法更符合实际。如果你的参考电压是其他值,公式里同步替换即可。

4.4 采样速度大致估算

你可以算一下这套配置的实际采样速度。ADC时钟21MHz,采样时间480周期,转换时间12周期,每个通道总计492周期。两个通道一轮序列需要984周期,所以一轮序列的转换时间大约是984 / 21MHz,约46.9微秒。也就是说每秒钟大概能完成21300轮扫描,每通道的采样率约21.3kSPS。

这个速度对于电压监测、电机电流、温度传感这类信号绰绰有余。如果觉得太慢,可以把采样时间改成112周期,单个通道就是124周期,一轮序列248周期,每通道采样率能拉到接近84kSPS。代价是抗源阻抗能力变差,要根据实际信号源来取舍。

5. 实测踩坑与排查实录

5.1 DMA只搬了一次数据就停了

这是最典型的坑。表现是程序刚运行时adc_buffer里还有两个正确数值,但之后再也不更新了。原因通常是CR2的DDS位没有置1,或者CONT位没置1。在扫描模式加连续转换的配置下,ADC需要在规则序列完成后继续发出DMA请求,而DDS位正是控制这个行为的开关。如果DDS为0,ADC只发一轮DMA请求,DMA循环模式接收一轮后就没有后续了。

排查时可以看DMA2_Stream0->NDTR寄存器。如果它一直停在0或者停在2,说明DMA没有新的传输。如果NDTR在2、1、0之间来回跳,说明链路是活的。另一个辅助判断是看ADC1->SR的EOC标志,但它本身是状态位,不停转换时变化很快。

5.2 两个通道的数据串位或固定顺序不对

出现adc_buffer[0]和adc_buffer[1]对应的不是PA0和PA1,说明序列项和物理通道的对应关系没对。DMA搬运的顺序严格跟随ADC规则序列转换顺序:序列第一个转换结果先进DMA,第二个紧跟其后。如果你的SQR3配置成了SQ1=通道1、SQ2=通道0,那数组里第一个值自然是通道1。

另外要留意SQR3里字段的布局。SQ2位于bit6~bit10,不是bit5开始。如果错误地写成(1U << 5),就等于把一个通道编号放进了保留位加上SQ2的低位,结果完全不可控。所以代码里尽量用(通道号 << 对应偏移),并且偏移要按参考手册的字段定义来。

5.3 采样值毛刺大,数值跳来跳去

如果你接的是高阻抗信号源,比如电位器直接输出但阻值有几十千欧,而ADC采样时间又不长,毛刺就会很明显。可以先加大采样时间到480周期,再在引脚与地之间加一个100nF左右的电容。这个电容和信号源内阻构成低通滤波,能减少高频噪声,也会让ADC采样时源电压更稳。

软件上还可以做简单滤波。读两次值取平均,或者保留最近N次做中值滤波。对于缓慢变化的模拟量,一阶滑动平均效果不错。但注意滤波会引入延迟,快速响应场景慎用。

5.4 关于校准、上电顺序和中断的几点补充

ADC校准必须在ADC空闲时做,不能在已经触发转换后执行。有些同学把校准代码放在后面,结果CAL位清不掉,程序卡死在while循环里。正确顺序就是上电后先校准,再配置序列,再开转换。

如果要用DMA传输完成中断来通知CPU处理数据,需要额外配置DMA2_Stream0的TCIE位,然后在NVIC中使能DMA2_Stream0_IRQn。中断服务函数里先判断对应的TCIF0标志,读完数据后从LIFCR的CTCIF0位清标志。用中断方式时要注意在清标志前把数据缓存走,因为下一次转换很快又会覆盖数组。

扩展到三四个通道也不难:在SQR3里继续设置SQ3、SQ4的通道编号,把SQR1的L值改成通道数减1,然后把NDTR改成通道数。数组大小跟着改就行。如果通道数超过6个,就要用上SQR2甚至SQR1里的字段,思路完全一样。

我后来把这套寄存器版代码放到某测试板上,连续跑了一整晚,两组数据都很稳定。调试时最实用的一招是:暂停程序直接看DMA2_Stream0->NDTR,如果它在2和0之间循环,说明DMA链路是活的;如果停在某个数值不动,就去查ADC的CR2、SR和DMA的CR/NDTR。只要顺着这条链路逐级检查,ADC相关的工程问题基本上都能很快定位。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询