news 2026/10/14 1:38:37

STM32F407寄存器版:独立ADC+DMA双通道采集详解与避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32F407寄存器版:独立ADC+DMA双通道采集详解与避坑指南

上个月调一块基于STM32F407的模拟量采集小板,两路电压都要连续读,不想让CPU频繁进中断,也不想翻HAL库那层封装,干脆用寄存器把独立ADC-DMA-双通道这套流程从头敲了一遍。调完发现,寄存器版本其实比库版本更直接,尤其当你需要确认某个位为什么那样配,翻参考手册的速度比翻库源码快多了。这篇文章就把寄存器版的配置思路、完整代码和几个典型坑一起放出来,给同样不想用大框架的朋友做个参考。

1. 独立ADC-DMA双通道:先分清你要的是不是“真双通道”

1.1 独立ADC扫描模式才是默认主力

STM32F407片上带了三个ADC,每个ADC都有一套独立的转换控制逻辑。但注意,每个ADC只有一个规则数据寄存器,也就是说不管你把多少个通道排进序列,每次转换完成的数据都只存在同一个DR寄存器里。所以同时采两路模拟量,最朴素的办法就是让单个ADC工作在扫描模式,一次触发后按顺序把多个通道轮流转一遍。对标题里的“独立ADA”三个字,我的理解是不启用双ADC同步、不搞交叉采样,只靠一个ADC靠扫描序列去处理多通道。这也是大多数实际项目里真正需要的功能。

很多人看到“ADC-DMA-双通道”,第一反应是开两个ADC,一个ADC采一路,最后拼到一起。这个想法本身没毛病,但在F407上有硬约束:ADC1、ADC2和ADC3的DMA请求都映射到DMA2的同一个Stream,具体是DMA2 Stream0,Channel0。既然请求线都是同一条,硬件上就不可能让ADC1和ADC2都独立走DMA。非要用双ADC,那就得进双ADC模式,那是另一套寄存器逻辑,和标题里的“独立”两个字不是一回事。所以本篇文章锁定的方案是:单ADC(ADC1)独立模式,扫描两个外部通道,DMA循环搬运。

1.2 扫描模式下一次触发发生了什么

把触发过程拆开看,SWSTART置位后,ADC会先找到规则组序列里的第一个通道,采样保持、转换,结果写入ADC1->DR,同时置位EOC标志,如果开启了DMA请求,DMA控制器被ADC的硬件请求拉起来,把DR里的16位有效数据搬到内存;紧接着ADC自动开始序列里的第二个通道,转换完再次触发DMA请求,再搬一次。两路都转完,这一轮序列结束。如果开启了连续转换CONT,ADC马上开始下一轮序列,DMA在循环模式CIRC下也一直循环搬运。

这个流程里最关键的是DMA搬数据的速度必须快于下一次转换完成的时间,否则DR被新数据覆盖,上一路的结果就丢了。F407的ADC时钟最高36MHz,目标采样周期即使设到84个周期,一个通道完整转换也只需要约100个ADC时钟周期,大概几微秒,DMA在几十纳秒量级就能搬完数据,完全来得及。因此片内DMA做这个搬运是绰绰有余的。

1.3 这种方案适合什么场景

只要你对两路信号之间的“同时性”没有硬性要求,这种单ADC扫描+DMA就是性价比最高的做法。温度、电池电压、电位器、光敏电阻这类慢变信号,两路之间相差一个转换周期完全无感。主程序只需要直接读缓冲区里的最新值,CPU占用几乎为零。如果要求两个通道必须在同一时刻采样,比如交流电压电流相位分析,那就必须换双ADC同步模式,不是本文讨论的独立ADC方案。

2. 寄存器手里必须有的三张底牌:时钟树、GPIO和关键位

2.1 ADC时钟必须压在36MHz以下

寄存器开发第一步不是写ADC,而是确认时钟。F407的ADC挂在APB2总线上,ADC输入时钟是通过APB2预分频再分一次得到的。绝大多数默认时钟配置会把APB2设为84MHz,如果ADC预分频保持2分频,ADC时钟就是42MHz,超过芯片手册标称的36MHz上限。超频状态下ADC能跑,但转换结果会出现莫名其妙的抖动,甚至差分非线性变差。

我一般先把ADC时钟压到4分频,也就是84MHz除以4等于21MHz。12位分辨率下,最短转换周期约15个ADC时钟周期,算下来单通道最快不到1微秒,对常用传感器采集完全够。RCC配置里改ADCPRE字段即可,在STM32F4的手册里这个字段占RCC_CFGR的[15:14],01对应4分频。代码直接写成:

RCC->CFGR &= ~(0x3UL << 14); RCC->CFGR |= (0x1UL << 14); // ADCPRE=01,PCLK2/4=21MHz

如果你的板子上APB2不是84MHz,那要按实际频率重新算分频系数,原则只有一个:ADC输入时钟不能超过36MHz。

2.2 GPIO模拟模式不只是改MODER

这里用ADC1的通道0和通道3,对应引脚是PA0和PA3。配置GPIO最常犯的错是只开时钟、不设MODER,结果引脚还保留在数字输入状态,内部施密特触发器和上拉/下拉会影响模拟采样值。

正确做法是把这两个引脚的模式位全部写成模拟模式,也就是MODER对应两位为11:

GPIOA->MODER |= (3UL << (0 * 2)) | (3UL << (3 * 2));

模拟模式下PUPDR和OSPEEDR都无所谓,不需要额外设置。有一点要注意,别用复用功能模式,复用模式下引脚会连接外设,但测电压不需要数字外设介入,只有模拟模式才会把数字输入输出路径断开。

2.3 核心寄存器位速查表

配置过程中会频繁和几个寄存器打交道,先列一个我调试时打印在桌面上对着看的速查表:

寄存器位/字段作用说明
ADC_CR1SCAN(bit8)开启扫描模式,使规则序列能转换多个通道
ADC_CR2ADON(bit0)上电使能ADC
ADC_CR2CONT(bit1)连续转换,一轮结束后自动开启下一轮
ADC_CR2DMA(bit8)使能ADC DMA请求输出
ADC_CR2DDS(bit9)DMA disable selection,置1让DMA请求不被ADC自动关闭
ADC_CR2SWSTART(bit30)软件触发,启动一次规则组转换
ADC_SQR1L[23:20]规则序列长度减1,两通道写1
ADC_SQR3SQ1[4:0], SQ2[9:6]序列第1、第2个通道号
ADC_SMPR2SMP0[2:0], SMP3[11:9]通道0和3的采样周期
DMA2 Stream0 CRDIR[7:6]数据传输方向,外设到内存用00
DMA2 Stream0 CRCIRC(bit8)循环模式,计数到0自动重装
DMA2 Stream0 CRMINC(bit10)内存地址自增
DMA2 Stream0 CRPSIZE[12:11], MSIZE[14:13]外设和内存数据宽度,ADC结果用16位半字

这个表不需要背,但配置前扫一眼能少翻好几次手册。

3. 先把ADC寄存器敲完整:从校准到扫描序列

3.1 ADON上电与校准流程

ADC初始化最容易被忽略的是上电后立刻配置寄存器,而没有给内部模拟电路一个稳定时间。虽然大部分情况下能工作,但稳妥起见,我会先置ADON位,延时几十微秒,再执行校准。

F4的校准就是置CR2里的校准位,然后等硬件把校准位清掉。不同头文件里对这个位的命名有点乱,有的叫ADC_CR2_CAL,有的叫ADC_CR2_ADCAL,本质都是bit2。用位操作更直接:

ADC1->CR2 |= (1UL << 2); // 开始校准 while (ADC1->CR2 & (1UL << 2)); // 等待校准完成

校准会修正ADC内部器件的偏移误差,做完之后零点更准。校准应在ADC上电稳定之后、正式转换之前执行一次。校准期间不要触发转换,否则结果没有意义。

3.2 扫描序列:L长度和SQ通道号

序列配置围绕SQR1和SQR3展开。SQR1的L字段告诉ADC这一轮序列里有多少个转换,如果是两个通道,L就写1,因为L的值等于转换数量减一。SQR3的低位段依次保存每个通道号,SQ1从bit0开始、占5位,SQ2从bit6开始、再占5位。PA0对应的ADC输入通道是0,PA3对应通道3。

代码里这部分我习惯写成:

ADC1->CR1 |= ADC_CR1_SCAN; // 开启扫描模式 ADC1->SQR1 = (1UL << 20); // L=1,表示序列里两个转换 ADC1->SQR3 = (0UL << 0) // SQ1 = 通道0 | (3UL << 6); // SQ2 = 通道3

采样周期放在SMPR2里。通道0的采样周期控制位占了SMPR2的[2:0],通道3占了[11:9],即第9、10、11位。这里我按采样周期为84个ADC时钟周期来配,把SMP值写成0b100:

ADC1->SMPR2 |= (0x4UL << (0 * 3)) // SMP0 = 100,84 cycles | (0x4UL << (3 * 3)); // SMP3 = 100,84 cycles

采样周期越大,内部采样电容充电越充分,对高内阻信号越友好。代价是最大转换速度变慢,对两路慢信号无所谓。

3.3 连续转换与DMA请求位:DDS为什么必须置1

扫描模式打开后,还要把连续转换打开,否则SWSTART触发一轮就停了。同时要打开DMA请求输出,这是ADC和DMA之间唯一的握手信号。

最关键的是DDS位。这个位叫“DMA disable selection”,它决定了ADC在规则序列转换结束后是否要禁用DMA请求输出。如果DDS保持默认0,在扫描模式一轮结束时,ADC会认为DMA该停了,后面即便有连续转换,也不再发出DMA请求。典型现象就是DMA只搬运了两次,第二轮开始数据全不更新。把DDS写1,DMA请求在整个连续转换过程中始终保持,DMA循环模式才能一直工作下去。

ADC1->CR2 |= ADC_CR2_CONT | ADC_CR2_DMA | ADC_CR2_DDS;

连续转换、DMA使能、DDS保持使能,这三个缺一不可。我调试时踩过DDS没置位的坑,现象非常隐蔽,因为ADC的EOC标志还会继续置位,你单看ADC状态会觉得一切正常,但缓冲区就是不动。

3.4 软件触发:SWSTART

最后置SWSTART位,ADC就会从序列第一个通道开始工作。由于已经开了CONT,之后无需再操作这个位。

ADC1->CR2 |= ADC_CR2_SWSTART;

注意SWSTART是软件置位、硬件自动清除的触发控制位。启动后可以去读DMA缓冲区,理论上EVEN地在更新,不需要CPU参与。

4. 把DMA2 Stream0配置成“自动搬运工”:请求映射、缓冲区和循环

4.1 ADC1的DMA请求只能在DMA2的Stream0 Channel0

F407的DMA控制器有两套,DMA1和DMA2。ADC1的DMA请求固定挂在DMA2上,具体是Stream0的Channel0。这不是你想选就能选的,硬件手册里的请求映射表写得清清楚楚,用错DMA1或者用错Stream,ADC转换标志照常产生,但DMA永远等不到外设请求。

配置前记得打开DMA2时钟,代码写:

RCC->AHB1ENR |= RCC_AHB1ENR_DMA2EN;

DMA2_Stream0的CHSEL字段默认值是0,恰好对应Channel0,所以在复位状态下不需要额外选通道。但为了代码可读和万一之前的初始化代码动过,还是建议显式把CHSEL清零。

4.2 NDTR、MINC、CIRC、PSIZE怎么配

DMA搬运不是复制粘贴那么简单,几个参数必须按照ADC的外设行为去配:

第一,外设地址固定为ADC1的DR寄存器地址,不能递增,所以PINC保持0。

第二,内存地址必须递增,让第一个通道结果落入adc_buf[0],第二个通道结果落入adc_buf[1]。如果MINC不置位,两次搬运都会写同一个地址,永远只能保留最后一路数据。

第三,NDTR设为2。NDTR是本次传输的数据项数量,两个通道各需要搬一次,正好等于序列长度。如果设成4,DMA会连续搬4个半字进内存,前两次是通道0和通道1,后两次又是下一轮的两个数据,这样缓冲区里会覆盖出多轮数据,读取时就要小心。

第四,开启循环模式CIRC。循环模式下NDTR递减到0后自动重装,内存地址回到起点,整个搬运过程不需要CPU重新配置。这个模式配合ADC连续转换,是最省心的组合。

第五,数据宽度全部用16位半字。ADC结果是12位,放在DR的低16位,PSIZE=01,MSIZE=01。不要用32位宽度,虽然也合法,但会白白多搬一倍的无效高16位数据。

DMA配置代码整理如下:

volatile uint16_t adc_buf[2]; void DMA_ADC1_Config(void) { // 先关闭DMA Stream DMA2_Stream0->CR &= ~DMA_SxCR_EN; while (DMA2_Stream0->CR & DMA_SxCR_EN); // 外设到内存,16位,内存自增,循环 DMA2_Stream0->CR = (0UL << 6) // DIR=00,外设到内存 | (1UL << 8) // CIRC=1,循环模式 | (0UL << 9) // PINC=0,外设地址固定 | (1UL << 10) // MINC=1,内存地址自增 | (1UL << 11) // PSIZE=16bit | (1UL << 13) // MSIZE=16bit | (0UL << 25); // CHSEL=0,Channel0 DMA2_Stream0->NDTR = 2; // 传输两个半字 DMA2_Stream0->PAR = (uint32_t)&ADC1->DR; // 外设寄存器地址 DMA2_Stream0->M0AR = (uint32_t)adc_buf; // 内存缓冲区 DMA2_Stream0->CR |= DMA_SxCR_EN; // 使能Stream }

这个配置已经足够。FIFO相关位全部保持默认,让DMA工作在直接模式,不要开FIFO。用FIFO反而会引入一次突发缓冲,对单次ADC结果来说没有任何收益,还可能把时序搞乱。

4.3 优先级和直接模式

DMA2 Stream0的优先级我用默认低优先级。很多初学者一看到“低优先级”就觉得慢,其实对只有一个外设接在同一个Channel0上的情况,低优先级照样能及时响应。真正需要调优先级的是多个DMA流同时跑、都在抢总线资源时的场景。如果你后面又把USB的DMA、串口的DMA挂到同一DMA上,再去把ADC这个流的PL字段调高也不迟。

直接模式意味着DMA不会经过FIFO缓冲,每收到一个ADC请求就立刻传输一个数据项。这种模式的触发间隔完全跟着ADC转换走,简单可靠。配置里PFCTRL保持0即可。

5. 数据拿到手后怎么判读:缓冲区顺序、换算和稳定性

5.1 永远按SQR顺序读数据

DMA不知道你采集的是什么通道,它只负责把DR里的值按顺序搬到数组里。所以读数据时不要自己脑补通道顺序,必须严格按SQR3里的SQ1、SQ2顺序。上面的例子里SQ1是通道0,SQ2是通道3,读取就是:

uint16_t ch0 = adc_buf[0]; uint16_t ch1 = adc_buf[1];

缓冲区建议用volatile修饰,否则编译器可能把读操作优化掉,主程序里看到的永远是好几天前的值。如果要拿两路连续的多次采样做滤波,可以一次性把adc_buf的4个元素全存了再处理,比如NDTR设4后,索引0和2是同通道的两次结果,索引1和3是另一通道的两次结果,做一个三点平均或者判定毛刺都非常方便。

5.2 电压换算的精度细节

F407的ADC是12位,满量程数值4095,参考电压VREF+决定这个满量程对应多少伏。F407没有内部固定2.5V基准,大部分板子直接把3.3V供给VREF+。换算公式就是:

float volt0 = ch0 * 3.3f / 4095.0f; float volt1 = ch1 * 3.3f / 4095.0f;

如果你对电压精度有要求,这个3.3应该用万用表实测供电电压,而不是拍脑袋写3.3。实测板子的VREF+是3.28V,那就写3.28,否则可能整体偏差0.5%以上。另外,如果信号有叠加直流偏置,还要先做零点校正,这一步通常在系统校准里做,不是ADC本身的事。

5.3 验证数据是否正确的三个信号

把配置跑通之后,我用可调稳压电源做了三组输入验证,结果如下表:

PA0输入电压PA3输入电压adc_buf[0]理论adc_buf[1]理论说明
0V0V00验证零点和DMA方向
1.65V0V20470验证第一通道
3.30V1.65V40952047验证两路同时采集

实测读回来数值会在理论值附近上下波动几个LSB,这是正常噪声。如果波动到几十个LSB,那要检查采样周期和模拟前端走线,而不是纠结寄存器配置。

6. 调试中踩过的坑:DMA只搬一次、数据全零、通道错位

6.1 DMA只搬运一次就罢工

这是独立ADC-DMA最容易出的问题,现象是上电后缓冲区里有第一轮数据,之后再也不变。可能原因有三个:

第一,DDS没置1。刚才说过,ADC序列结束后硬件礼貌地替你把DMA请求关了,DDS置1后这个问题会消失。

第二,DMA循环模式没开。一个NDTR跑完,Stream就停了。可以检查DMA2_Stream0->CR的CIRC位,如果为0,NDTR减到0后传输结束,必须要重装才能继续。

第三,在调试器里手动把NDTR清零,或者之前写过一次NDTR改小了。遇到这种,最干脆的办法是打断当前传输,重新配置Stream:

DMA2_Stream0->CR &= ~DMA_SxCR_EN; while (DMA2_Stream0->CR & DMA_SxCR_EN); DMA2_Stream0->NDTR = 2; DMA2_Stream0->CR |= DMA_SxCR_EN;

调试时还有一个假象:如果直接在调试器里看NDTR,它通常一直是初始值,因为循环模式下计数到0会瞬间重装,你根本抓不到递减过程。看到NDTR不变并不是DMA没工作,看adc_buf里数据在跳才是真相。

6.2 数据永远是0x000

数据全零,首先想到信号线是不是真接到了PA0和PA3。其次回头看GPIO模式。我遇到过几次是因为GPIO时钟没开,PA0和PA3默认浮空,读出来随机跳动甚至全零。再就是ADC时钟没使能,ADC寄存器写不进值,这种在调试前先看一眼RCC->APB2ENR。

还有一个隐蔽原因:SQR3里通道号写错,比如把11号通道的编号写到SQ1里,而11号通道对应引脚是PC1,PA0上根本没接信号,DR自然读出来是0。序列里每个通道号必须和实际引脚对应,查一下F407的数据手册外部通道表。

6.3 两个通道的数据反了或者和预期不同

数据反了大概率是SQR3里SQ1、SQ2顺序和你心里想的顺序不一致。你以为先采PA3,结果代码里先写的是SQ1=通道3,后写SQ2=通道0,那adc_buf[0]就是PA3的数据。这种不算错误,只是顺序问题,统一按代码里的顺序读即可。

最隐蔽的错位发生在采样周期设置不足时。比如信号源内阻几十千欧,采样电容还没来得及充满,ADC就开始比较,结果不仅数值偏低,还可能因为两个通道的源阻抗不同显得“通道之间串扰”。PA0那路源阻抗高,读数明显比预期低,PA3正常,你会以为是两个通道反了。其实把SMP采样周期加到144或480周期,问题就消失。寄存器里SMP3写0b110或者0b111即可:

ADC1->SMPR2 &= ~(0x7UL << (3 * 3)); // 清掉原采样周期 ADC1->SMPR2 |= (0x7UL << (3 * 3)); // SMP3=111,480 cycles

6.4 数值毛刺大

毛刺大的第一反应是检查采样周期。3个周期的采样保持时间非常短,面对直流信号还勉强,面对高阻抗信号就真的抓瞎。第二反应是看供电噪声,ADC的VREF如果和数字供电混在一起,纹波会直接叠进采样值。第三反应才是软件滤波。

寄存器版本做软件滤波很简单,直接把adc_buf的NDTR调大,让DMA连续存多轮数据,主程序取平均值。比如NDTR=8,缓冲区里就是4轮的四个数据,均值处理一下,毛刺肉眼可见变小。当然这只解决系统噪声,不解决硬件设计上的地线问题。

最后再说一个个人习惯:这套寄存器初始化我后来顺手封装成一个函数,通道顺序和采样周期都做成宏,换通道就只改SQR和GPIO那两行,DMA配置完全不用动。如果你也只是采几路模拟量,先把单ADC扫描+DMA跑通,再考虑要不要上双ADC同步。真实项目里,把基础方案做稳带来的收益,往往比追逐更高指标的硬件组合大得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 1:38:24

EMC通用标准与产品族标准:区别、优先级与实操选择指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/14 1:38:03

从JSON到Parquet:半结构化数据全链路处理与优化实战

1. 半结构化数据到底是什么&#xff0c;为什么大数据绕不开它1.1 从数据形态演变看半结构化数据的定位做了这几年大数据的人应该都有同感&#xff1a;早期大家张口闭口都是“结构化数据”&#xff0c;建表、写SQL、跑报表&#xff0c;一切井然有序。可随着业务系统越接越多&…

作者头像 李华
网站建设 2026/10/14 1:37:55

单链表头尾插法:带尾指针的工业级实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/14 1:37:39

RabbitMQ延迟消息实战:TTL+死信队列+Topic交换机实现任意时延投递

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/14 1:37:20

基于Jetpack MVVM的Android快速开发框架设计与落地复盘

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/14 1:37:18

均值滤波原理与工程实践:从去噪基础到边界策略和性能优化

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华