做嵌入式这段时间,我反而越来越喜欢用寄存器方式写STM32F407的外设驱动。标题里这个“ADC-DMA-单通道”组合,看起来是个入门到不能再入门的例程,但实际动手把寄存器从头配置一遍,你会发现HAL库帮你打包了很多细节,而这些细节恰恰是最容易踩坑的地方。这篇文章就把我实际调试这个项目时用到的寄存器配置、时钟参数计算、DMA配合逻辑,以及最后排错的心得完整写出来,希望对想脱离库函数做底层开发的朋友能有点参考价值。
1. 项目背景与寄存器方案的实际意义
1.1 为什么选寄存器版本
先说个最直接的原因:代码小、可控性强。用HAL库生成一个ADC采集工程,编译出来通常是几十KB起步,而寄存器版本加上启动文件往往不到10KB,在一些对Flash容量压得很紧的产品上,这个差距很实在。
更重要的原因是排查问题的效率。HAL库把外设的状态机封装在结构体和函数内部,真出了问题,你不光要看寄存器手册,还要去翻库函数源码,看它到底做了哪几步、有没有在某个条件分支里把位给清掉。寄存器版本则没有这层遮蔽,配置了什么、没配置什么,一清二楚。调试时打开仿真器看外设寄存器,直接就能定位问题是出在时钟、采样时间、DMA方向还是触发方式上。
另外一个点可能很多人没意识到:寄存器版本对“理解芯片工作方式”的帮助非常大。F407的ADC有规则组、注入组、扫描模式、连续模式、外部触发、DMA请求等一堆概念,HAL库里一个Start_DMA函数就把它们全包装了。搞清楚这些概念之间的关联后,换到任何一款带ADC的单片机上都能快速上手,因为底层原理是通的。
1.2 我的硬件准备与接线
我用的是一块很常见的STM32F407VGT6开发板,ADC部分选择了ADC1的通道1,也就是PA1引脚。这个引脚本身没有特殊复用冲突,用作模拟输入非常合适,电位器接法如下:
- 电位器两端分别接3.3V和GND
- 滑动端接PA1
- 模块上再并一个100nF电容到地,用于滤除采集点的高频噪声
实际项目里如果采集的是传感器输出,尽量先看清传感器输出阻抗。如果源阻抗太高,采样瞬间内部采样电容充电不够充分,得到的数值会有明显跳动。后面我会专门讲这个问题。
2. 时钟树与ADC采样率之间的关系
2.1 ADC时钟从哪里来
STM32F407的ADC外设挂在APB2总线上,但它的工作时钟并不是直接使用APB2时钟,而是先经过一个专门的分频器,再由这个分频器输出给ADC内核。这个分频器位于RCC->CFGR寄存器的高位,称为ADCPRE位段,可选的分频系数为2、4、6、8。
很多开发板默认系统时钟是168MHz,APB2总线频率为84MHz。如果直接把这个84MHz送给ADC内核,就超出了数据手册规定的36MHz上限,转换结果是不可靠的。所以我这里将ADCPRE配置为4分频,得到21MHz的ADC时钟。
RCC->CFGR &= ~RCC_CFGR_ADCPRE; RCC->CFGR |= RCC_CFGR_ADCPRE_DIV4;这里有一个容易忽略的地方:分频后的ADC时钟才是后续所有时间计算的基础,而一定要记得ADC时钟不允许超过36MHz。开发板上如果有其他外设重配了RCC->CFGR,务必检查自己的配置是否被后续代码覆盖。
2.2 采样时间决定了转换周期和速率
ADC一次规则转换的总时间由两部分组成:采样阶段时长和逐次逼近阶段时长。F407的逐次逼近阶段固定为12个ADC时钟周期,采样阶段则由SMP位决定。
我在这份工程里把通道1的采样周期配置为28个ADC时钟:
ADC1->SMPR2 &= ~(7UL << 3); ADC1->SMPR2 |= (2UL << 3);在21MHz ADC时钟下,单个时钟周期约47.6ns。一次转换所需时间就是:
| 采样周期 | 总周期数(SMP+12) | 单次转换时间 | 对应采样率 |
|---|---|---|---|
| 3 | 15 | 0.71us | 1.40Msps |
| 15 | 27 | 1.29us | 778ksps |
| 28 | 40 | 1.90us | 525ksps |
| 56 | 68 | 3.24us | 309ksps |
| 84 | 96 | 4.57us | 219ksps |
| 112 | 124 | 5.90us | 169ksps |
| 144 | 156 | 7.43us | 135ksps |
| 480 | 492 | 23.43us | 42.7ksps |
选择采样时间时要考虑两件事。第一,外部信号源阻抗高不高,如果传感器输出阻抗大,应当选长一点采样时间。第二,需要的采样率够不够,如果做音频采样或高速信号抓取,就得在精度和速度之间找平衡。日常电位器采集、温度传感器这类慢速信号,用28或56周期完全够用,还附带滤波效果。
3. ADC与DMA的寄存器配置细节
3.1 GPIO时钟与模拟输入模式
使用PA1前,先打开GPIOA的AHB1时钟,再把引脚配置为模拟模式。模拟模式下,引脚的数字输入和输出缓冲器被禁用,外部信号可以直达ADC采样开关内部,这是所有ADC引脚必须做的第一步。
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN; GPIOA->MODER &= ~(3UL << 2); GPIOA->MODER |= (2UL << 2); // PA1 = 模拟模式 GPIOA->PUPDR &= ~(3UL << 2); // 禁止上拉下拉很多朋友直接用HAL库久了,以为只要调用HAL_ADC_Init就行,会忽视GPIO模拟模式的重要性。之前我见过一个现象:引脚没设置成模拟模式,ADC读到的值一直固定在某个值附近不变化,因为GPIO内部的施密特触发器还在工作,信号被数字电路绑定住了。
3.2 ADC外设时钟与校准顺序
ADC1挂载在APB2总线上,所以需要单独打开APB2外设时钟:
RCC->APB2ENR |= RCC_APB2ENR_ADC1EN;接下来是校准操作。很多人会跳过这一步,但这很容易让ADC转换结果带上固定偏差。F407的校准流程要求ADC处于关闭状态,也就是ADON位为0时,把CR2寄存器的CAL位置1。硬件会在校准完成后自动清除CAL位,此时校准系数已经写入ADC内部。
ADC1->CR2 = 0; // 确保ADC处于关闭状态 ADC1->CR2 |= ADC_CR2_CAL; while (ADC1->CR2 & ADC_CR2_CAL); // 等待校准完成校准代码放在ADC使能之前执行,这个顺序是个硬性约束。如果ADC已经上电,直接置CAL会得到错误结果。我的惯例做法:初始化函数开头先把CR2清零,再执行校准,这样即使重复调用初始化函数也不会出问题。
3.3 规则组序列寄存器设置
F407的ADC转换序列放在SQR寄存器组里。规则组最多可以排16个转换通道,SQR1的L位保存的是“序列长度减1”。单通道场景下,序列长度就是1,所以L填写0。
转换序列中的第一个转换放在SQR3寄存器的SQ1位置。这里我要做的是通道1,就直接把SQR3的低5位写成1:
ADC1->SQR1 &= ~(15UL << 20); // L = 0,表示1次转换 ADC1->SQR3 = 1; // SQ1 = ADC1_IN1如果不写SQR3,复位后它默认是0,规则序列就会去转换通道0,到时候读到的数值和接线对不上,容易白忙一场。
这个位置也顺带解释了“单通道”和“扫描模式”的关系。当前序列只有1个转换,即使把CR1的SCAN位置1,实际行为和关闭扫描没有区别。真正用到多通道时,扫描模式必须打开,DMA会把每个通道结果按序列顺序连续写到内存里。
3.4 DMA流与通道选择
STM32F407的每个外设DMA请求都对应特定的流和通道组合。ADC1的DMA请求可以连接到DMA2的流0或流4,通道均为通道0。工程里我使用DMA2流0通道0。
打开DMA2时钟后,先配置外设地址、内存地址和传输数量,再写控制寄存器:
RCC->AHB1ENR |= RCC_AHB1ENR_DMA2EN; DMA2_Stream0->CR = 0; while (DMA2_Stream0->CR & DMA_SxCR_EN); // 确保流处于禁用状态 DMA2_Stream0->PAR = (uint32_t)&ADC1->DR; // 外设地址:ADC1数据寄存器 DMA2_Stream0->M0AR = (uint32_t)adc_samples; // 内存地址:数组 DMA2_Stream0->NDTR = 8; // 传输8次后循环 DMA2_Stream0->CR |= DMA_SxCR_CL...控制寄存器里的关键位说明:
- DIR方向位设为00,表示外设到内存,也就是DMA外设读取ADC数据寄存器,写入内存数组
- PSIZE和MSIZE设为16位,因为ADC1_DR的有效数据是右对齐的16位
- MINC置1,内存地址每传输一次递增一次,这样才能连续填充数组
- PINC不能置1,ADC数据寄存器是单地址,外设地址不需要递增
- CIRC置1,用循环模式配合ADC的连续转换
- PL位设置为高优先级,避免在密集DMA环境下队列延迟影响采样
通道选择位CHSEL要填0,对应DMA2的通道0:
DMA2_Stream0->CR |= (0UL << 25); // CHSEL = 000不同型号芯片CHSEL位宽度可能不同,最稳妥的办法是直接用以下方式写数值,然后对照参考手册逐位核对。我在调试时经常打开外设寄存器窗口,把DMA_SxCR实际值和期望值比对。
最后使能DMA流:
DMA2_Stream0->CR |= DMA_SxCR_EN;3.5 ADC的DMA请求、连续转换与软件触发
ADC侧还有三个控制位必须理解清楚,它们直接影响DMA的运行状态。
第一个就是CR2的DMA位。它只是许可ADC产生DMA请求,不打开它,ADC即便转换完成也不会把结果推向DMA。
第二个是DDS位。这个位的含义很多人在初次配置时都会忽略:它决定DMA请求是一锤子买卖还是持续有效。当DMA位为1且DDS为1时,规则通道转换结束后会持续产生DMA请求,配合DMA循环模式,可以实现无需CPU介入的连续采样。如果DDS保持0,DMA完成设定数量的传输后请求就被切断,即使MCU和DMA流都还在循环模式,数据流也会中止。
第三个是CONT位,也就是连续转换模式。置1后,一次规则通道序列转换完成后立即启动下一轮,不需要再次软件触发。连续转换和DMA循环搭配是最常用的组合。
ADC1->CR2 |= ADC_CR2_DMA | ADC_CR2_DDS | ADC_CR2_CONT; ADC1->CR2 |= ADC_CR2_ADON;最后是启动转换。当EXTEN保持0、外部触发功能关闭时,把CR2的SWSTART位置1即可。连续模式下,只需要启动这一次,后面ADC就会自动循环运行:
ADC1->CR2 |= ADC_CR2_SWSTART;这里有经验的点就是:SWSTART必须在ADON置位后设置。如果写入顺序反了,或者把ADON和SWSTART放在同一条语句里,有些芯片配置序列会产生不可预测的启动失败。养成先ADON再SWSTART的习惯,能少踩很多坑。
4. 可以直接上板的寄存器工程代码
4.1 完整配置流程
把上面所有配置整合成一个初始化函数,我在工程里大致是这样写的:
#include "stm32f4xx.h" static volatile uint16_t adc_samples[8]; static void ADC1_DMA_Init(void) { /* 1. 开启外设时钟 */ RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN | RCC_AHB1ENR_DMA2EN; RCC->APB2ENR |= RCC_APB2ENR_ADC1EN; /* 2. PA1 配置为模拟输入 */ GPIOA->MODER &= ~(3UL << 2); GPIOA->MODER |= (2UL << 2); GPIOA->PUPDR &= ~(3UL << 2); /* 3. ADC时钟分频:PCLK2 / 4 = 21MHz */ RCC->CFGR &= ~RCC_CFGR_ADCPRE; RCC->CFGR |= RCC_CFGR_ADCPRE_DIV4; /* 4. ADC关闭状态校准 */ ADC1->CR2 = 0; ADC1->CR1 = 0; ADC1->CR2 |= ADC_CR2_CAL; while (ADC1->CR2 & ADC_CR2_CAL); /* 5. 规则序列:单通道,通道1 */ ADC1->SQR1 &= ~(15UL << 20); ADC1->SQR3 = 1; /* 6. 通道1采样时间:28周期 */ ADC1->SMPR2 &= ~(7UL << 3); ADC1->SMPR2 |= (2UL << 3); /* 7. DMA2流0通道0 */ DMA2_Stream0->CR = 0; while (DMA2_Stream0->CR & DMA_SxCR_EN); DMA2_Stream0->PAR = (uint32_t)&ADC1->DR; DMA2_Stream0->M0AR = (uint32_t)adc_samples; DMA2_Stream0->NDTR = 8; DMA2_Stream0->CR |= DMA_SxCR_MSIZE_0; // 内存16位 DMA2_Stream0->CR |= DMA_SxCR_PSIZE_0; // 外设16位 DMA2_Stream0->CR |= DMA_SxCR_MINC; // 内存地址递增 DMA2_Stream0->CR |= DMA_SxCR_CIRC; // 循环模式 DMA2_Stream0->CR |= (2UL << 16); // 优先级高 DMA2_Stream0->CR |= DMA_SxCR_EN; /* 8. 配置ADC连续模式与DMA请求 */ ADC1->CR2 |= ADC_CR2_DMA | ADC_CR2_DDS | ADC_CR2_CONT; /* 9. 上电并通过软件触发启动 */ ADC1->CR2 |= ADC_CR2_ADON; ADC1->CR2 |= ADC_CR2_SWSTART; }需要提一下,DMA_SxCR的CHSEL字段默认是0,所以我没有显式写通道选择位。如果工程里曾复用DMA2的其他流,记得要重置CR后再配置。
4.2 只要最新值,不要环形缓冲
上面的例子使用了8个16位元素的数组,DMA每转换一次就往数组的下一个位置写,写满8个后回到起点。这在需要观察一段波形或做简易滑动平均时很方便,但如果你想在应用里直接拿“最新一次转换结果”,环形缓冲会让主循环分不清哪个下标才是最新的。
解决思路很简单:把传输次数改成1,同时关闭内存地址递增。这样DMA从ADC数据寄存器搬运结果后,永远写入同一个内存地址,也就是一个全局变量,读取时永远不会拿到历史数据。
static volatile uint16_t adc_latest; DMA2_Stream0->NDTR = 1; DMA2_Stream0->M0AR = (uint32_t)&adc_latest; DMA2_Stream0->CR |= DMA_SxCR_MSIZE_0 | DMA_SxCR_PSIZE_0; /* 不置MINC位 */实际使用中,连续模式加循环DMA加NDTR=1的配置,比我最初想的要稳定得多。每次ADC转换完成后,新数值直接覆盖旧数值,主循环什么时候读取都是最新结果。前提是CONFIRC,因为仅在非环形模式下,样本指针会随时间漂移。
4.3 主循环中如何读数据
如果使用的是adc_samples[8]这种环形方式,简单做法是把数组的任意一个位置作为输出电压近似值。严谨一点,可以在DMA传输完成中断里处理数据,但单通道连续采样没必要把简单问题复杂化。
如果使用的是adc_latest,读取更直接:
uint16_t val = adc_latest; float voltage = (float)val * 3.3f / 4095.0f;主循环里读这个变量不会因为DMA写入而崩溃,因为ADC数据寄存器是16位,写入操作是原子完成的。不过不建议在主循环里频繁对这个变量做大量计算,连续模式下样本速率可以达到每秒几十万个,计算跟不上反而造成逻辑混乱。
5. 常见故障与现场排查心得
5.1 读到的数据一直为0
这种情况我遇到十次有八次是GPIO模式没配置成模拟,或者配置成了复用模式。先查GPIOA->MODER,确认PA1两位确实是二进制10。其次查ADC1的SQR3寄存器,确认SQ1段里写的是1,而不是0。如果SQR3还是复位值,配置的通道就是ADC1的通道0,而实际接线接在通道1上,读自然为0。
另外一个看起来隐蔽、实际很常见的原因是PA1引脚没连接好。电位器滑动端悬空时,ADC输入相当于浮空,读到的往往接近0或随机跳动。用万用表量一下PA1的实际电压最直接。
5.2 数值波动很大,或者明显偏低
如果读数不是稳定的0,而是大幅度跳变,先往电源方向查。开发板USB供电的3.3V噪声通常比较大,ADC的参考电压VREF可能和实际3.3V不完全一致。解决方法是采集点加一个100nF电容到地,必要时并联一个10k电阻做下拉。
如果信号源阻抗很高,采样时间也尽量拉长。比如传感器输出经过一个几十千欧的分压电阻网络,28周期采样时间可能不够,把SMPR2改成480周期再看波形,通常能明显改善。
5.3 DMA传输几次就停住
DMA停止不动,第一嫌疑就是DDS位没置1。只配置了DMA位,没有配置DDS位,ADC虽然在连续转换,但DMA请求并不会持续给出。这个问题在HAL库里被封装得很隐蔽,寄存器版本却直接暴露在CR2寄存器的第9位上。
第二怀疑对象是DMA流被异常终止。打开DMA2流0的中断标志寄存器,看看TEIF或者FEIF有没有被置位。如果传输出现错误,流会停在第0号,再次使能前要先清标志。
5.4 溢出标志OVR如何处理
连续转换模式下,如果DMA请求没有被及时响应,ADC1的数据寄存器就会在下一轮转换完成前被覆盖,OVR标志被硬件置起。多数情况下OVR事件本身不会让ADC停摆,但处理不当会让主循环拿到的样本时间乱序。
我的处理策略是:利用ADC1的SR寄存器OVR位做一个全局错误计数,一旦出现就说明DMA带宽可能不够,要么降低采样时间,要么提高DMA优先级。若需要完全清掉OVR,请参照参考手册建议的流程,先置DMA位为0,再清OVR,最后重新使能DMA位。
5.5 从单通道扩展到多个通道
这个工程本身是单通道,但寄存器思路可以平滑扩展:修改SQR1的L位为通道数减1,再在SQR3、SQR2甚至SQR1里分别填写所需通道编号,同时把NDTR改成通道数量,DMA内存数组大小也要对应增加。
扩展后要注意三个点:第一,所有通道尽量用相同采样时间,否则连续模式下不同通道的转换周期不同步;第二,扫描模式必须使能,否则只转第一个通道;第三,DMA输出的数据顺序就是SQR里排列的顺序,读取数组时要按这个顺序对应实际信号,这是一个非常容易搞错的地方。
6. 调试过程中的经验与建议
寄存器版本调试ADC-DMA的过程,本质上是一场x读外设寄存器状态的分析。我最常做的是在仿真器里同时打开ADC_SR、ADC_CR2、DMA_SxCR和内存窗口,观察每次软件触发后各个寄存器之间的变化时序。
一个很实用的小技巧:先把DMA和连续模式全部关掉,每次手动把SWSTART位置1,然后在仿真器里查看ADC1->DR的数值。这样可以把ADC本身和DMA传输解耦,快速定位问题出在模拟链路还是数据搬运环节。我每次接手新的ADC项目,都会先用这个方法验一遍硬件通路。
另一个经验是关于代码组织的。寄存器工程不写注释的话,几天后再看很容易忘掉某个位为什么这么配。我都会在每个配置块前写上对应的参考手册章节,后续维护时一句“见手册第X章”能省下大量翻手册时间。
最后建议手头常备数据手册和参考手册的PDF,重点看ADC和DMA两章。遇到位名对不上、宏定义含义模糊的情况,不要猜,直接看寄存器位定义表。F407这款芯片的寄存器文档写得非常直白,尤其DMA的流映射表,看一眼就知道ADC1该接哪个流哪个通道,比盲目调库函数靠谱得多。