从0开始的ADC信号采集(从入门到入土-基础篇)

最近正好被困在了这个地方,网上看了很多教程都不是特别适合新手,我跟着他们学也是一知半解,各种开源工程不是丢三落四,就是不通用。

那么本文将会手把手带你进行ADC采集。这个往后会考虑是不是要放进BIF函数里进行封装。

本文以通用STM32最小开发板-STM32F103C8T6为例进行讲解,请通篇按序阅读

有一些链接为了保证文章的干净和整洁以及方便阅读的要求,直接以嵌入文本的方式以放在文章里,手机端阅读会有些不便,比如说我的 |*上一篇文章|(上一篇文章附上了链接以进行跳转,有*提示,并用分隔符分割出,仅限嵌入文本无提示的,因为我在写完文章后面发现这样的阅读真的不方便,所以进行了一点改进

从ADC公式->STM32ADC硬件原理->ADC配置细节->ADC系统运行逻辑->ADC函数讲解->ADC实战(我能想到的ADC差不多都在这,每个章节都有一些tip,内容远远不止这些,为了辛苦费,要个关注不过分吧,我的分享永远不开VIP,大家可以放心支持,因为我也买不起VIP)

这个教程写了我一个星期,6万多字,家人们,点赞、收藏、评论加关注,良心博主不迷路😭


 内容介绍

1.ADC的工作原理,ADC的硬件资料,STM32的引脚资料

2.基于标准库和HAL库两种ADC配置方式

---这里是写完教程的我,我真的写不动了,写了一个星期,4万字还没写完,我真的不想写了,HAL期末后再写,请原谅我😭后面说我会怎么怎么写HAL库的就略过,不想改了

3.STM32的单通道、双通道和多通道配置方式

4.ADC的妙用和拓展(只有ADC采样正弦波,其他的扩展会连着HAL库在ADC进阶篇讲)

(不想了解底层细节,直接跳转到ADC环境配置进入代码教程部分)

我认为这样才算称得上比较全面的讲解,但现在这样的教程几乎看不到了

我所有的STM32教学都不会附带完整的代码(即工程文件),但是我会拆分写在文章里,有问题可以在评论区里提,有代码需求可以去看其他博主的博客直接搬运。

STM32视频资源推荐(B站:

江协-标准库大神,手把手交给你,但容易睡着-标准

铁头山羊-细致而通俗,实时更新,但容易睡着-标准+HAL

keysking-生动有趣,通俗易懂,但不够全面-HAL库

有很多底层性的东西可以关注我以进行补足pwp,但我目前只有博客,视频后面再考虑,现在时间紧张,暂时没有这么多时间写教程,国赛后时间会十分充沛,正好国赛后我也要沉下心好好补基础


ADC简介

ADC-Analog-Digital Converter 模拟-数字转换器

这个模拟信号和数字信号在上文正好和大家进行了分享和讲解。那么有从模拟信号到数字信号,怎么能没有数字信号到模拟信号呢?

这就是STM32的另一个功能-DAC(Digital-to-Analog Converter)数字-模拟转换器

实际上,除了DAC,我们所熟知的数字-模拟的实现方式是PWM输出。

这个会在后面进行PID调制PWM、SPWM、SVPWM波的博客里进行讲解,这里仅介绍ADC(主要是那块我现在也不是特别懂)

在进行ADC采集的时候,我们经常会遇到一个问题->ADC采集的数据一直在变化,这时因为什么呢?这是我最近在stack overflow上才回答的一个问题。

很多人对代码有一定的错误地认识,是因为我们认为我们地写出来什么,得到的反馈就是什么,他们往往忽视了硬件电路上的问题。比如说我们使用ADC采样电压,一直在变化,它可能是代码、可能开发板设计的问题也可能是你输入的问题,而不仅仅在于代码。

我们因为经常用集成好的单片机开发板而误以为我们给单片机画的板子自带了这些性能,这也算是一个小小的误区。

ADC采集通常用于信号采样以进行电路控制,比如说BUCK\BOOST的控制部分。这里,我们会分别介绍单通道、双通道、多通道采样分别应该怎么实现(因为我在学的时候太笨了,我就像在这里讲述的详细一点,让你们不会踩我的坑。


ADC工作原理

原理

我们在学习一个东西的时候也不应该说从原理开始,但是原理你应该知道才会辅助你了解。我相信点开这个教程的同学大多数都是知道ADC是干什么的,那么你们知道ADC采样的原理吗?

ADC作为模数转换器它有很多个类型:

逐次逼近型SAR ADC、流水线、闪速型和Σ-▲ADC等多种类型,我看了一下,这个占篇幅太多了,而且我目前也没有这个时间写这个部分,留到下一章讲,或者我在网上找了讲的不错的:

什么是SAR ADC?

如果你不着急的话可以先去这里看看。

在这里我们需要强化一下ADC采样的概念,也是关乎到ADC采样的控制核心:

A/D的转换(模拟->数字)主要由四个部分构成:采样、保持、量化、编码

采样就是上面介绍的几个模式,每个模式所应对的情况不尽相同。

SAR ADC主要是利用电荷守恒定律,外部电压对电容阵列(x个电容并联,比如说你是12位逼近器,x=12,每个电容分别为1/2的x次方依次并联->二分法查找思想)先进行充电后断电锁住电荷,充电公式:

此时,比如说你的电容:8、4、2、1(4位逼近),然后切换不同电容的下极板,通过电压比较器比较得出1/0,1保留,0舍弃。

你可以通俗的理解为每个电容代表了不同大小的砝码,但是你只能按顺序放置。

你的输入电压是3.1V,VREF-5V,你先将8uF电容的下极板切换为VREF,

电荷守恒定律:V~new~ = (Σ(C_i × V_i)) / C~total~

比较器检测到电压: (5V×8 + 0×4 + 0×2 + 0×1)/(8+4+2+1) ≈ 2.67V

2.67<3.1,置1保留8uF电容,下一次4uF:

新电压= (5×8 + 5×4 + 0×2 + 0×1)/15 = 4V

4>3.1V,置0,4uF下极板切换到地,2uF下极板切换到VREF,

电压: (5×8 + 0×4 + 5×2 + 0×1)/15 ≈ 3.33V

3.33>3.1,2置0,然后1uF,

(5×8 + 0×4 + 0×2 + 5×1)/15 ≈ 3V

3<3.1,1置1,则此时单片机转换而来的数据位1001。

通过公式进行计算:9(1001由二进制转换为十进制)/16(1111)✖VREF(参考电压)=2.81V

这就是量化误差的由来以及SAR ADC的读取部分的大致工作原理。

那么我们通常把这个量化的误差理解为什么呢?那就是分辨率。

你的SAR ADC逼近位数越多,那么你最终采样到的电压也就越精细,量化误差越小。

这个时候聪明的你肯定注意到了一个细节,就是采样电压它是通过电容这类有缓冲作用的元器件,这也必然导致我们电极板充电的时间会随之增加。但是,我们如果想要去充电,那么必然有一段时间ADC是无法采样的,这就是信号离散化。

对于连续系统的离散化你可以去看这个:

知乎:一文书尽离散化——连续系统离散化原理及应用

讲的很深入,非常不错!

公式:

每个公式和定理在下面都有讲解。

采样定理(奈奎斯特定理)​​:

采样频率 fs​ 必须至少为信号最高频率 fmax​ 的 ​2倍​(即 fs​≥2fmax​),否则会出现混叠(Aliasing)​,导致信号失真。

但是为了还原波形细节,我们一般选择10倍的信号频率作为采样率。

量化间隔(步长)​​:

假设输入电压范围为 [Vmin​,Vmax​],量化步长 Δ 为:\Delta = \frac{V_{max} - V_{min}}{2^{N}}

​​量化规则​:

每个采样值 x[n] 被映射到最接近的量化电平。例如,​四舍五入截断
量化值 xq​[n] 可表示为:

X_{​{q}[n]} = round(\frac{x[n]-V_{min}}{\Delta })\cdot \Delta + V_{min}

量化误差​:

误差范围为 ±Δ/2,属于非线性的不可逆失真。量化位数越高,误差越小。

举个离子:

输入范围0-5V(VREF),8位ADC,则你的步长Δ = 5/256(2的8次方)≈19.53mV,如果采样值是2.5V,则量化值位:round(2.5/0.01953) = 128(对应二进制:10000000)

这里我们需要考虑的问题显而易见了:

采样频率:过高增加数据量,过低导致混叠

采样周期:过小无法完整充电,过大精度不够

这其中还有一个容易被忽视的问题,信噪比(SNR-Signal-to-Noise Ratio):理论最大SNR为 6.02N+1.76dB(N 为位数)。

这里简单介绍一下信噪比吧,通常不用考虑,所以会被忽视:

衡量信号中有用信号强度与噪声强度的比值,通常用分贝(dB)​表示。它反映了信号的纯净程度,SNR越高,信号质量越好。

SNR_{(dB)}= 10\log 10^{\frac{Psignal}{Pnoise}}

我们的12位逼近器的SNRmax约等于74.00dB

对于混叠现象大家可能没有比较清晰的概念,这是我在B站上看到的一个很不错的讲解视频:

[DSP][搬]采样和混叠(数字信号处理)

如果你没有时间的话,你可以直观地看下面这几张截图:

蓝色是输入波形,点是采样点,间隔是体现为采样频率,黄色是当前采样点满足的可能的其他正弦波,即混叠现象。

你会发现,我们如果有每个正弦波的最大值和最小值就能得到正确的正弦波,而这正式信号频率的两倍(的模糊决定),这种情况下只有一个频率是正弦曲线的总和。

但是我们不可能每次都精准地采集到最大值和最小值(因为你一定刚好就是在波峰或波谷的位置进行进行采集,比如说是这样:

为了避免这种最坏的情况,我们需要fs >2 *fmax ,这就是奈奎斯特定理(同样,我们也不会使用正好的2fmax),一般是10倍,为了还原细节


硬件资源介绍

那么,你已经有了丰厚的理论基础,现在我们来看看硬件上ADC的一些基础。

(这里有很多硬件资源无法讲完,我会列出一个小的清单,在这里捎带着介绍一下,往后我会再更新一章专门介绍STM32硬件的博客,欠的债越来越多了)

STM32F1系列有着18个输入通道,分为16个外部输入通道和2个内部信号源。

但是我们的STM32F103C8T6的ADC资源:ADC1、ADC2,10个外部输入通道,这是因为F103C8T6有一些引脚并没有引出,但是已经够用了。

虽然但是,我一直有一个疑惑,相信你们也是如此,STM32F103C8T6不是48个引脚吗,为什么只引出了40个引脚(实际可用的GPIO引脚是37个,但是中间商赚引脚,我们买到的最小系统板一般只有32个可用引脚。其实部分引脚用于电源和调试去了,但他们本来也就不是GPIO)

引脚ADC通道ADC1ADC2

A0

0
A11
A22
A33
A44
A55
A66
A77
B08
B19
C010
C111
C212
C313
C414
C515
-内部温度传感器16
-内部参考电压17

为了搞清楚这些引脚拿去干什么了,所以我又去翻看了一下它的原理图。

全引脚定义

网上已经有大佬总结出来了,你可以通过这个链接跳转过去。

STM32F103C8T6 引脚功能详述

原理图资源可以去看江协的官方网站,下载同步资源,这张图是模块资料->STM32核心板原理图

江协科技官网跳转链接

BOOT1和PB2

首先,我们发现B2引脚和BOOT1引脚集成在了一起,你可以在你的复位按键上方找到该引脚。

BOOT1是什么呢?BOOT1是启动配置引脚,仅在芯片上电或复位时进行采样,其他时间则作为普通的GPIO端口使用。所以很多如我在内的新手都忽视了PB2这个引脚资源(电路资本家发现了STM32的新的压榨资源,桀桀桀)

BOOT1是干什么的呢?它时和BOOT0一同配置STM32的启动模式的:

BOOT0

BOOT1模式
00主Flash启动(默认)
10系统存储器启动(用于串口下载
11内置SRAM启动

具体的启动模式我们其实并不需要关心,默认模式就是我们所需要的使用模式,如果你想了解更多,或者开发新的模式的话,那么你可以去深入了解。

[深入剖析STM32]STM32 启动流程详解 <-仅有流程

【官网】STM32的启动模配置与应用 <-仅有模式介绍

A13和A14

同样的,你可能会对这个A13和A14也有一点问题,我们没有看到这个A13和A14在哪里啊?

其实,A13和A14已经引出来了,它就是我们的STM32专用调试引脚(SWD-Serial Wire Debug)接口,这是ARM Cortex-M内核的标准调试协议

C0-C12

细心的朋友已经看到原理图里有个严重的问题,GPIO C去哪里了?怎么没有看到C0-C12号引脚呢?

这其实是因为STM32F103C8T6的芯片封装有两种,分别是TSSOP20和LQFP48,对于TSS封装的芯片,根本不存在C引脚,对于LQ封装的,理论上存在,但是考虑到实际电路就没有引出。

这其中包括但不限于PCB的空间限制,ADC通道已经足够使用了和避免对SWD干扰。

对于STM32F103C8T6的硬件资源介绍会在往后有时间进行讲解。


ADC通道

配置的内容

我将我目前所使用过和配置过的两类ADC采集方式进行整理和说明:

1.ADC采集直流电->单通道(独立通道 ->多通道(独立/轮询

2.ADC采集交流电->单通道(有DMA和无DMA ->多通道(独立/轮询-设置采样波/信号触发

ADC的系统构架

因为一次看太多就像看天书一样,我们一部分一部分地看:

(该图片资源来自江协科技的资源包里->课程PPT->88页,往后图片内容不再赘述)

左边的GPIO端口对应不同的输入通道(上面展示过了),实际上我们的单片机只有16个通道。

STM32的ADC比其他的ADC高级在于别人以一个通道为单位进行调用,而ADC将不同的通道合成为一个组,通过调用这个组而实现一次性调用很多个通道。

如图所示,这两个组分别是注入通道和规则通道。

粗看,规则通道所能调用的通道很多,但是往后面看去,规则通道的数据寄存器只有1*16位,也就是说这个寄存器单次只能储存一个通道的值,其他通道的数据会被挤掉(如丢失、覆盖)。此时我们就需要DMA来帮忙搬运,就是把当前已经存储的数据移到其他的地方,以避免采集到的数据被覆盖。DMA的功能不仅如此,还能解放CPU的资源,大大提升STM32的性能。

DMA加缓冲区就像将军骑白马,稳还快!DMA的原理和误区纠正在下面。

规则组和注入组

规则组(Regular Channels)

功能:用于常规的ADC转换任务,通常用于周期性采样或连续监测多个模拟信号

触发方式:软件触发(代码->寄存器启动)、硬件触发(->代码->定时器、外部中断)

数据存储:转换的结果都会存储在单一的规则数据寄存器(DR)中,每次转换会覆盖前一次的结果(这就是前面说的为什么需要DMA的原因)

(仅有1个DR-Data Rejister,规则通道)

因为很多人在读取数据或者编写特定外设的位的时候喜欢调用它们的地址(比如我,这样会更直接、更安全、更稳定,但如果写错了就会犯错)这部分放在了下面

工作模式:单次转换/触发后只转换一次指定的通道,可以用于调试或者放在主循环里重复调用

连续转换/自动重复转换当前的通道或扫描序列

扫描模式/按照定义的顺序(通过SQRx寄存器配置)转换多个通道

应用场景包括但不限于:周期性采集传感器数据(如温度、电压)、需要连续监测但优先级较低的信号

注入组(Injected Channels)

功能:注入通道用于高优先级、突发性的模拟信号采集,可中断规则通道的转换(类似“插队”,通俗来讲,就是应用了定时器的抢断机制

触发方式:软件触发或者硬件触发,但是不同于规则组,它是检测到事件后进行的更改和处理

数据存储:有独立的4个注入数据寄存器(JDR1~JDR4),每个通道的数据相互独立,不会覆盖,因此可以进行抢占,不了解地可以去了解一下定时器中断

抢占机制:当注入通道被触发时,当前规则通道地转换会被暂停,注入通道转换后规则通道继续,也就是说注入组和规则组是可以并存的。

工作模式:单次注入:触发后转换指定的注入通道序列(通过JSQR配置,最多四个通道)

自动注入:在规则通道组转换完成后自动启动注入组(需要配置JAUTO位)

触发延迟:可设置延迟时间(JDISCEN),避免频繁抢占,保证系统的稳定和安全

应用场景:紧急事件处理(如过压和过流,这个功能可以看门狗以解决,扩展会讲,不需要着急,需要低延迟相应的关键信号-如电机控制中的故障检测

使用说明

根据上面的说明,大家应该能够了解规则组和注入组之间的区别了吧

如果说规则通道是配置通道的优先级,那么注入通道就是配置抢占优先级以处理更加紧急的事情,对于定时器中断,我也在网上找了一篇很好的教程在这里分享给大家

STM32定时器原理-讲解 -后续我也会重新整理写一篇

STM32定时器配置-代码

那么除了以上这些,我们还需要关注一些使用规则组和注入组的相关事项:

除了前文说过的数据覆盖问题,就是我们所说的时序控制问题,这个时序控制本文不进行讲解,这个就是配置优先级,每个人的使用不同,你也就只是需要改一下数字而已,有什么不懂的可以去看上面的定时器博客

专有名词讲解-详情见汉译版参考手册-P171

SQRx - 规则通道序列寄存器

全称:Sequence Register for regulat Channels

地址:0x4001 2400 + 0x2C(x=1),0x4001 2400 + 0x30(2),0x4001 2400 + 0x34(3

每个SQRx分别对应规则序列的四个通道

功能:配置规则通道地转换顺序

这就是用来规划你提前配置好地采样顺序,SQRx就是这个顺序的”排队表

具体说明:

SQR1、SQR2.......(具体数量请看单片机手册)共同决定转换序列

SQx(Sequencex)位域:指定第x个转换的ADC通道号(如SQ1=0,表示第一个转换的就是通道0)

L位(Sequence Length):定义总共有多少个通道要进行转换(L=3表示3个通道)

JDRx - 注入通道数据寄存器

全称:Injected Data Register

功能:用于存储注入通道的转换结果

地址:1:0x4001 2400 + 0x3C,2:0x4001 2400 + 0x40,

0x4001 2400 + 0x44,0x4001 2400 + 0x48

规则通道只有一个DR寄存器,每个通道的结果都能单独保存(4个

使用:读取JDR1获取第一个注入通道的结果,JDR2获取第二个,以此类推

场景:适用于紧急数据(过压、过流保护),即使规则通道正在转换,也能立即保存注入数据

JSQR - 注入通道序列寄存器

全称:Injected Sequence Register

功能:配置注入通道的转换顺序(类似于SQRx,但只给注入通道使用,最多支持4个

地址:0x4001 2400 + 0x38 

关键位:

JSQx(Injected Sequence x):定义第x个转换的ADC通道(JSQ1=3表示第一个转换的是通道3)

JL(Injected Sequence Length):定义注入通道的数量

JAUTO - 自动注入模式 

全称:Auto-Injected Mode

功能:让注入通道在规则通道转换完成后自动触发 (Bit 10)

启动后规则通道转换完成最后一个通道的时候,最自动启动注入通道的转换(不需要额外触发)

使用场景:需要周期性采集高级优先级数据(电机控制,每次采集电流后立即采集温度保护信号

比如说,JAUTO=1时,也就是规则通道轮询完一遍后,注入通道自动检查一遍

JDISCEN - 注入通道间断模式

全称:Injected Discontinuous Mode

功能:ADC_CR1的一个位,用于限制注入通道的触发频率 (Bit12)

启用后,注入通道的触发会进行“延迟”,以避免频繁抢占规则通道导致系统无法处理而过载崩溃

使用场景:比如我们给注入通道的触发信号十分频繁(如高频噪声,但实际我们只是想间隔采样

这怎么理解呢,就比如说现在你和你的搭档每个人各有一个不同的焊接任务,但是你们共用一套焊接设备,如果你频繁地抢占焊枪,而你的同伴焊一半就被抢走,那么他将无法很好地完成任务,甚至没法工作,所以,为了避免影响两个人整体的工作效率,我们约定在一段时间内仅能抢占一次,以保证你的同伴当前成功焊接了当前他正在处理的元器件,这时交给我们就不会影响整体进度

介绍这些专有名词不仅是为了让大家更好的了解这些东西,也是因为我们需要知道我们配置的是什么东西,用来干什么,去解决什么。

ADC模数转换器详解

ADC地址调用

ADC的寄存器地址由两个部分构成:ADC独立模数转换器的基地址+特定寄存器偏移地址

ADC10x40012400
ADC20x40012800

寄存器偏移量

ADC_SR状态寄存器0x00
ADC_CR1控制寄存器10x04
ADC_CR2控制寄存器20x08
ADC_SMPR1采样时间寄存器10x0C
ADC_SMPR2采样时间寄存器20x10
ADC_JOFR1注入数据偏移寄存器10x14
ADC_JOFR2注入数据偏移寄存器20x18
ADC_JOFR3注入数据偏移寄存器3

0x1C

ADC_JOFR4注入数据偏移寄存器40x20
ADC_HTR高阈值寄存器0x24
ADC_LTR低阈值寄存器0x28
ADC_SQR1定序器寄存器10x2C
ADC_SQR2定序寄存器20x30
ADC_SQR3定序寄存器30x34
ADC_JSQR注入定序寄存器0x38
ADC_JDR1注入数据寄存器10x3C
ADC_JDR2注入数据寄存器20x40
ADC_JDR3注入数据寄存器30x44
ADC_JDR4注入数据寄存器40x48
ADC_DR数据寄存器0x4C

主要的数据寄存器我在上面都介绍过了,剩下的那些一般用不到,我可能会在后面找时间补充一下,大概率是梅西。

那么我们一般如何去调用呢?比如说我想要操作ADC1的数据寄存器地址:

#define ADC1_DR_Address ((uint32_t)0x4001244C)
//宏定义地址以进行操作     转型    基地址+偏移量

 0x40012400+0x4C = 0x4001244C,不要简单地认为是合并在一起,如何操作这个地址在后面具体配置的时候讲

ADC1和ADC2到底是什么?

ADC1和ADC2是两个独立的模数转换器(ADC)硬件模块,他们可以复用相同的GPIO引脚,这也是他们为什么能会有一些引脚同时属于ADC1和ADC2.

这个时候,你一定会十分疑惑,那么定时器会区分高低定时器,中级定时器,低级定时器,那么ADC是不是也会区分不同的功能呢?

答案是显而易见的。对于ADC1和ADC2来说,它们的功能和性能也有区分。

特性ADC1ADC2
DMA支持支持不支持
触发源定时器、外部中断、软件触发等同ADC1,但灵活性低
双ADC模式可作为主ADC(master

只能作为从ADC(slave

数据寄存器独立的DR和JDRx同ADC1,但没有DMA自动传输
应用主数据采集、告诉采样辅助采样、低优先级信号

其中ADC1作为主ADC模数转换模块,负责主要任务,它一般配置为规则组,而ADC2作为从ADC模块,用来辅助ADC1进行处理,所以ADC2一般用来作为注入组。

不要认为注入组的抢占优先级就更加重要!这是我写完ADC_Mode回来标注的一句话。

相信你现在一定看的云里雾里的,为什么它们会有这样的区别,为什么它们一个支持DMA一个不支持DMA?如果您对这方面的探索欲不高,那么您完全可以跳过这里,直接使用A0-A3这四个复用引脚,同时支持ADC1和ADC2,因为对于平常的使用和比赛来说,四个通道已经足够足够用了。而且对于其他的芯片来说,复用引脚还会更多。

使用ADC的常见问题
ADC1和ADC2能同时采集同一个引脚吗

-> 不能!同一个引脚在同一个时间只能分配给一个ADC,就像一个电烙铁,同一时间只能给一个人用。

如果你想要同步采样(不是同时采样!注意专业术语),可以使用双ADC模式,ADC1和ADC2分贝采样不同的引脚,这个时候就是你有两个电烙铁了,所以可以同时焊不同的板子了。

为什么ADC2不能配置DMA呢?

这时因为硬件的设计导致的必然结果。前文说过ADC1是主,ADC2是从,这个“主”与“从”的本质就在于这个DMA通道的归属权上。

STM32F103C8T6中,DMA控制器(DMA1)的通道1固定分配给ADC1,这时硬件层面上的设计,当ADC1完成转换时,DMA会自动将数据从ADC1->DR搬运到内存,无需CPU干预。而ADC2没有绑定任何的DMA通道,因此无法直接触发DMA传输。

既然我这么说,那么就是有方法让ADC2使用DMA传输?

为什么ADC1和DMA捆绑在了一起

STM32F103C8T6的DMA1仅有7个通道,而且这些通道已经被其他外设(如SPI,USART,TIM)占用了,ADC2没有被分配优先级,我们来详细看一下这个DMA1通道的配置和绑定细节

DMA1通道绑定外设用途
通道1ADC1ADC数据搬运
通道2SPI1_RXSPI接收数据
通道3SPI1_TXSPI发送数据
通道4USART1_TX串口发送数据
通道5USART1_RX串口接收数据
通道6TIM1_CH1/2/3定时器PWM/捕获
通道7TIM_CH1/2定时器PWM捕获

每个DMA通道的默认外设关联都是芯片设计时确定的,其绑定的DMA通道(通道2/3)也无法直接分配给其他外设,因为硬件上不支持动态重映射。

 你会发现它这个请求一览里有很多外设,我给大家列出的是默认优先绑定

一个通道在一个时间只能选择其中1个外设,硬件设计的时候已经规定了每个通道的“首选外设”(即默认优先绑定的外设)。我们可以通过配置外设的DMA使能位来选择是否使用DMA,但是我们无法改变通道的物理连接,这也是ADC2无法使用DMA的原因。

(超不经意不小心不是故意地截图到了ST标志,无意地告诉你数据都是在数据手册证明过的OvO)

那么,如果你去看了官方地数据手册,你会发现DMA总线上还有一个DMA2。

(DMA控制器的内容在官方数据手册-汉译版-144页)

这里要纠正一下大家的错误,你们可能没有注意到官方DMA框图下面的一句话:

DMA2控制器仅在大容量和互联网产品里可以使用。

(江协给的参考手册是2009年的,并不是最新的,下面是最新的文档,但是是英文版)

最新的官方文档在这里:STM32F103C8T6 - 21版

这是英文版本,你们可以下载沉浸式翻译插件以方面阅读,不过应该已经有人翻译过了,但是我目前还没有搜到,或者你们可以去立创商城看看,但是那个是数据手册,如果有大佬路过之类可以留一个链接。


ADC环境配置

到这里已经1万字了,赶上我更新好几天的小说了TAT。

掌握以上内容的你已经超过90%的人了,你了解了ADC模数转换器的底层逻辑,了解了STM32F103C8T6的硬件配置,了解了和ADC有关的主要寄存器,但是现在的你一定十分混乱,需要找到一个东西去帮你把这些东西串联在一起。

我一般在看别人的教程的时候都习惯记笔记,不知道大家有没有这个习惯,如果你有这个习惯你在阅读接下来的内容就需要频繁往上翻。

ADC系统逻辑

ADC的系统框架内容

那么通过上面的讲述,我们整理一下现在所有的STM32中我们所需要知道和配置的部分:

1.模拟输入部分:

16个外部模拟输入通道、2个内部信号源、模拟开关矩阵

2.转换核心:

12位逐次逼近型ADC、采样保持电路、比较器和DAC组成的转换逻辑(本文暂不涉及)

3.时钟系统:

ADC预分频器(APB2时钟)、采样时钟保持

4.触发系统:

软件触发、硬件触发(TIMx、EXTI等)

5.数据流控制:

规则组和注入组通道、数据寄存器(DR)、DMA接口

6.中断系统:

EOC(转换结束)、AWD(模拟看门狗)、JEOC(注入组转换结束)

我们现在来比对一下,现在所有的这里面的大部分内容我们都是介绍过的,但是还有一些我们不是特别了解,而且我们也不知道它们如何串联在一起的,那么我们现在从整个系统逻辑入手来逐一进行梳理和整理吧。

ADC系统工作本质

在开始后续内容之前,我们再来重申一遍ADC系统的工作本质。

我们使用ADC的核心目的就是将连续变化的模拟信号转换成离散的数字量,这个过程需要协调多个硬件模块共同来完成。STM32的ADC其实质就是一个“信号加工厂”,用于对输入信号进行加工处理,最终转换成数字值,存入寄存器。

当你有了对ADC清晰的认识的时候,我们再来看ADC的系统就会十分清晰。

ADC系统运行逻辑

ADC完整的系统框架(09老版本)

我比对了两个版本的图,完全一样,所以就用中文版方便讲解。如果你想顺便学学英语,就去看官方文档自己尝试翻译一下pwp,汉译的未来靠你们了。

信号输入阶段

这部分就是选择输入通道,就像 |*多路复用器| 一样,我们从17个通道中选择一个。

我们通过上文所说过的ADC_SQRx寄存器来配置转换序列以决定当前转换哪个通道,但是现在大家肯定还是有一定的疑惑,因为我在上面说过的ADC_SQRx是用来排序的,它和硬件之间是怎么联动的?


SQRx寄存器 -> 序列寄存器 -> 通道选择解码器 -> 模拟多路开关 -> ADC核心


如果这块再往下了解去,我们就可以手搓单片机了哈哈,说不定未来我真的会开出这么一个教程,毕竟现在都有手搓计算机啦pwp

不过,为了满足大家(我)的好奇心,我还是在这里简单说明一下吧:

通道选择编码器

将SQRx中的数字编号(Channel_x)转换位物理通道选择信号,每个GPIO引脚都连接着一个模拟开关,具体实现细节我尚未了解,后面有时间学完后和大家分享一下。

SQRx的数字编号如Channel_1 就是打开模拟开关 GPIO_PA1

模拟多路开关

相信你们对于这个模拟多路开关还是有一点的好奇心,因为凡是开关都有一定的延迟。

模拟多路开关在切换时会自动插入一个通道切换延迟-tSTAB

 这部分就是电路设计部分的知识,|*tSTAB| 属于|*PSS| 仿真后得出来的数据,就使用来说,我们根本不需要考虑这些东西。

💴-时序配合问题-重点

时序配合问题是我们整个系统运行所必须考虑的问题,对ADC采样来说,时序配合问题包括以下几个方面,在后面配置的时候会进行应用讲解和分析

以工作在7.5MHz时钟且采样时间为55.5周期的ADC:

通道切换时间 ≈ 4个ADC周期(533ns)

采样时间 = 55.5周期 ≈ 7.4us

转换时间 = 12.5周期 ≈ 1.67us

总转换时间/通道 ≈ 9.6us

具体的计算公式在后面,这里讲并没有意义,只是让你有一个印象

👍-SQRx的工作模式-重点

SQRx的不同工作模式对其也有着不同的影响,那么它有哪些工作模式:

这个比较简单,一次说清,后面不再赘叙(这个语言啊,如果说我懒得说了,就会挨骂,如果简单地说不再赘叙就会觉得这个人高深莫测,哈哈O i O,我才不是懒,我是高深莫测)

1.单次非扫描模式(SCAN = 0)

全称-Single Conversion,Non-Scan Mode

工作原理:ADC仅执行一次转换,完成后自动停止,如果配置了多个通道,则需要手动切换通道并重新启动转换

特点:简单低功耗,适合单通道、低频采样,比如说我们的按键检测就是这一原理,正好最近想要换键盘。单次非扫描模式没有自动序列,需要软件干预才能切换通道。

配置模式:CONT=0 - 非连续模式 ;SCAN=0 - 非扫描模式

触发方式可以是软件触发(SWSTART)或者硬件触发

2.连续扫描模式(SCAN=1 + CONT=1)

全称-Scan Mode

工作原理:自动按预定义顺序(SQRx或JSQR寄存器)循环转换多个通道,可配合连续模式(CONT=1)实现不间断循环扫描

特点:高效多通道处理,自动切换,解放大脑,释放你和单片机的CPU;支持DMA,数据自动储存,随心调用,这么好的模式你还在等什么呢?

配置关键:SCAN=1,并配置通道序列长度(L)及其顺序,使能DMA(DMAEN=1)以自动传输数据

3.注入组插队机制(JAUTO=0)

全称-Injected Group

工作原理:就像外部中断触发一样,注入组触发中断,打断常规组(规则组)的转换,立即执行。而且,注入组拥有专属的触发源、通道序列和数据寄存器(JDRx)

特点:紧急事件响应,如过压保护,紧急信号处理,保留数据,注入组转换后,规则组可以继续未完成的序列。

配置关键:设置JAUTO=0 - 禁止使用自动注入,配置触发源(如外部中断),定义注入通道数(JL)和顺序(JSQR)

🐏-转换执行阶段

此时,我们ADCCLK时钟推动12位主次逼近寄存器SAR工作,即时钟驱动。

因为SAR的转换过程是一步一步进行的,而这个一步一步进行的节奏和频率就由ADCCLK进行约束和控制。

🌳-时钟树-( ̄︶ ̄)↗ 

这是STM32的总线矩阵图(截选),我们可以发现ADC是连接在APB2外设上的,DMA1和DMA2是通过DMA总线以进行访问。

但其实除了这两个直观的总线,我们还经常忽略ADC的时钟总线-ADCCLK

时钟数见汉译版手册-P56

这就是我们ADC来配置采样频率的核心部分,也是最主要的部分-时钟数

如果你是第一次看到这么复杂的一张图的话肯定有些无从下手,但其实如果你仔细看我的话的话就应该看出来了,我们实际上考虑的部分只有ADCCLK。

除此之外,如果你使用过Cube Mx的话,那么你一定知道Cube Mx内部简化的时钟树,这个我们讲到那里的时候再说。

💢ADCCLK-ADC时钟
ADCCLK的来源

ADCCLK通常由ADC的总线时钟(PCLK2)分频得到,具体的路径如下:

主时钟(HCLK) -> APB2分频器 -> PCLK2 -> ADC预分频器 ->ADCCLK

HCLK我好像也没有看到它啊?博主,你是不是在瞎讲?

HCLK其实就是AHB总线时钟,整个系统的核心时钟。但是如果你仔细去看的话,就会发现HCLK的前面似乎还有东西,也就是说它的也有时钟源。

HCLK的时钟来源

这东西真的是一环陶喆一环啊。

HCLK的时钟来源有以下四个:

1.HSI(内部高速时钟)

芯片内置的RC振荡器,一般为8MHz,但是精度比较低(1%~2%)

不需要外部元器件,芯片内部集成,启动快,稳定性差

2.HSE(外部高速时钟)

外部晶振或时钟源,频率一般为4~25MHz,如8MHz

精度高(±0.1%),需要外部硬件支持(我们的最小系统核心板已经集成了这么一颗小小的晶振)

3.PLL |*锁相环| 输出

通过倍频HSI或HSE得到更高频率(如72MHz)

能够提供高频稳定时钟,高性能应用的标配

4. |*HSE旁路模式|(少数型号)

直接使用外部时钟信号(不是晶振),跳过内部振荡器电路,具体的我也不懂,大家可以自己去了解了解

误区纠正

这里有必要纠正一下大家看完这部分内容产生的误区,就是把HSI、HSE是时钟源,但是PLL和分频器这类叫做时钟过滤器,是用来对原始信号进行倍频、分频和整形的。而这个PLL是可选的,并非必须的。

ADCCLK的分频设置

如上文图片上所属,ADCCLK的频率必须要满足ADC的硬件限制(≤14MHz),而STM32则提供了分频选项以帮助你进行调节:

分频系数

提供的分频比有:2/4/6/8(F1系列)

通过RCC_CFGR和ADCPRE位(或者是ADCx_CCR寄存器)设置

计算公式

ADCCLK时钟频率: f_{ADCCLK} = \frac{PCLK2}{Div} (≤14MHz -保证12位精度)

中文英文表达
分频系数Divider / Prescaler
时钟分频器Clock Divider
预分频系数Prescaler Value
分频比Division Factor / Divisor

我觉得你们可能会因为这个英文看不懂而产生问题,所以就顺便给你们把英文页罗列出来啦。

ADCCLK与ADC性能之间的关系
转换时间

为防止你搞混,我把计算的符号都罗列出来:

参数符号说明单位
ADCCLKfadcclkADC模块的输入时钟频率Hz
采样周期TsampleADC对输入信号采样的时间长度ADCCLK周期
转换周期TconvADC完成模数转换的固定时间ADCCLK周期
总转换时间Ttotal单次转换的总时间s
采样频率fsample每秒采样次数Hz
分频系数DivPCLK2的分频比-

总转换时间(单次采样):T_{total} = \frac{Tsample + Tconv}{f_{ADCCLK}}

采样周期: T_{sample} = (SMPx + 0.5) \times T_{ADCCLK}

SMPx的值由 ADC_SMPR1/2 寄存器进行配置,如ADC_SampleTime_7Cycles5对应7.5周期)

 SMPx配置推荐应用表:

SMPx的值推荐场景
1.5

超高速信号(>1MHz

7.5中速信号(100KHz~1MHz
13.5通用信号(10KHz~100KHz
28.5高阻抗信号(1KΩ~10KΩ
41.5较高阻抗信号(<1KΩ
55.5温度传感器,电位器
71.5高阻抗慢变信号(>50KΩ
239.5极高阻抗(>100KΩ

转换周期:固定为12.5 ADCCLK周期(12位分辨率)

比如说ADCCLK = 18MHz,采样周期=3 ADCCLK周期,则

Tconv = (3 + 12.5)/18Mhz ≈ 0.86us

最大采样率

理论最大采样率 (即无扫描模式)

f_{sample} = \frac{1}{T_{conv}}

以上面的例子继续进行计算->f_{sample} ≈ 1.16MSPS

PWM的输出和这个有些不同,因为PWM依靠的是定时器以生成固定频率的波,大家不要把PWM的计算方式和ADC搞混,PWM输出在往后会进行更新。

SMPx值如何配置

虽然上面给了一张比较方便的应用场景表,但是这还是无法让大家深层次地理解如何去配置SMPx的值,一般我这么讲肯定是十分重要的东西

我们要再了解一下SMPx的值的概念 - 采样时间是ADC对输入信号采样的持续时间,由ADC_SMPR1和ADC_SMPR2寄存器中的SMPx位控制

关键的影响因素:信号源阻抗 - ADC时钟频率(通过上面表格就应该看出来了

信号源阻抗:高阻抗的信号源(如传感器传出)需要更长的采样时间,以确保SAR内部的电容能够充分充电,在这里有一个经验公式,顾名思义,就是由经验得出来的:

T_{sample-min} \geq (R_s + R_{ADC}) \times C_{ADC} \times \ln (2^{12})

Tsample-min :最小采样时间

写道这里的时候,突然想到一个问题,很多博主可能不会标注这个公式的下标,所以写出来的公式比较奇怪:比如说我想要写T_{ax(inx)} LaTex: T_{ax(inx)} _{你的下标任意内容},不要前缀也可以

回到上面的内容,R_{ADC} ≈ 1KΩ , C_{ADC} ≈ 8pF (数据手册)

ADC的时钟频率-f_{ADCCLK}

最大时钟为14MHz(PCLIK2 分频得到)

计算样例

信号源阻抗:Rs = 10KΩ ,Tmin ≈ (10K + 1K) ✖ 8pF ✖ 8.3 ≈ 0.73us

对应ADC的周期数配置应该是:0.73us ✖ 14MHz ≈ 10.2

你应当选择一个至少大于10.2的档 - 13.5

但其实,我们一般配置完后所得的ADC时钟频率应该是12MHz

因为如果你想要满足14Mhz,只能用72MHz/6 = 12MHz 

现在我们已经把最核心的计算部分搞定了,给自己鼓鼓劲,我们继续ψ(`∇´)ψ

🐺-数据输出阶段

这里涉及的核心部分是数据对齐方式:

系统根据ADC_CR2的ALIGN位来决定是右对齐还是左对齐,然后将转换的值储存进ADC_DR寄存器里,并标志位置,EOC(转换结束)标志置1,若是能中断则触发NVIC

我们先来说一下这个数据对齐的方式:

数据对齐方式

在STM32的ADC中,数据对齐方式决定了转换结果在数据寄存器中的存储格式。不同的存储格式直接影响了数据的读取和处理效率,尤其是在需要高位精度或特定数据格式时尤为重要。

而这个数据的存储还关乎到我们上面说过的ADC的分辨率,不同的分辨率最终存储的数据大小也是不一样的,STM32F1转换的结果范围是 0x000到0xFFF (0~4095)

右对齐(Right Alignment)

转换结果的最低位(LSB)对齐到寄存器的第0位,高位补0:

|15 14 13 12|11 10 9 8|7 6 5 4|3 2 1 0|

|0    0   0   0 | ADC转换结果-12位       |

这种对齐方式适用于直接读取完整精度的场景,如电压计算,

为什么呢?如果的转换结果是0xABC,那么我们存储的结果就是0x0ABC,数据本身并没有变化

这样读取的数据直观,直接反应原始转换值,适合需要高精度计算的场景

电压转换公式:(Voltage = (adc_value * Vref) / 4095)

实际电压 = 转换值 * 参考电压 / 4095(类似于百分比

左对齐(Left Alignment)

转换结果的最高位(MSB)对齐到寄存器的第15位,低位补0:

|15 14 13 12|11 10 9 8|7 6 5 4|3 2 1 0|

| ADC转换结果-12位               |0 0 0 0|

适用于快速处理或兼容8位系统的场景。

实际上左对齐所能做到的功能最多,它可以向右移动4位就能获取完整的12位:

uint16_t adc_value = (ADC1->DR >> 4) & 0x0FFF; // 先右移4位,再屏蔽高4位

 而如果是读取8位数据的话,就是右移8位,兼容了老系统的8位系统。

差别

这时候就有大聪明要说,左对齐也能具备右对齐的功能,为什么不全用左对齐呢?

因为左对齐如果想要读取完整的12位会需要额外的操作,增大系统的负载量。

那我们平常不都是直接读取数据吗?左对齐有什么用?

左对齐的设计初衷就是为了优先暴露数据的最高有效位,方便快速获取近似值,虽然我不知道这个近似值可以用来干什么,但一定有特定的使用场合。

但其实,我们平常使用,用右对齐就可以了,不需要考虑那么多。

其中,对于右对齐和左对齐读取数据还涉及一部分知识:高位和低位。这个对于本章学习并没有实质性的帮助, 大家自己去搜索就可以啦。

EOC(End Of Conversion)-标志位

EOC是STM32 ADC模块中一个重要的状态标志位,用于指示ADC转换是否完成。

EOC的本质

EOC是ADC状态寄存器(ADC_SR)中的一个位(bit1),

当ADC开始转换的时候,硬件自动清除EOC位-置0

当ADC完成第一次转换的时候,硬件自动设置EOC位-置1

该标志位也可以通过软件编程的方式来写0清除

EOC与其寄存器

ADC_SR寄存器的结构:

Bit 0: AWD -模拟开门狗标志

Bit 1: EOC-转换结束标志

Bit 2: JEOC - 注入通道转换结束标志

Bit 3: JSTRT - 注入通道转换开始标志

Bit 4: STRT - 规则组注入通道转换开始标志

邱豆妈带,注入通道有始有终,那规则组呢?去🐀了吗?

📌注:ADC_SR寄存器有规则组转换结束标志,那就是EOC标志位,但是你们必须要理顺清楚EOC的置位逻辑:在所有规则通道都完成后才置位。

EOC不仅是单通道转换结束的标志,也是规则组转换完成的标志。STM32没有单独为规则组设计另一个标志位。

EOC的工作模式

单次转换工作模式下,每次启动转换后EOC都会被置0,转换完成后EOC会被置1,读取ADC_DR后EOC自动清0。

连续转换模式下,第一次转换完成后EOC置1,后续每次转换完成都会置1,即使上次的EOC没有被清楚,需要手动清楚EOC标志

触发控制流

软件触发:直接设置ADON/SWSTART 位启动

硬件触发:外部事件(如定时器CCx)产生脉冲信号,通过触发控制器启动转换。

软件触发(Software Trigger)

就是通过程序代码直接配置寄存器或调用库函数来触发某个操作。

这样有什么特点或者说是好处:

1.即时性:CPU直接控制,无需等待外部事件

2.灵活性:可以在任意时刻通过代码触发

3.无硬件依赖:不依赖外部信号或硬件连接

那么平常的使用场景或者样例有哪些呢?

1.定时器启动/停止:通过TIMx ->CR1寄存器的CEN位启动定时器,软件的配置的时候会再次说

2.ADC单次转换:写入ADCx ->CR2的SWSTART位

3.DMA传输启动:通过DMA函数触发传输

硬件触发(Hardware Trigger)

有外部硬件信号(GPIO、定时器、其他外设)自动触发某个操作

特点:

1.同步性:与硬件事件严格同步(如上升沿、定时器溢出)

2.低CPU开销:无需CPU干预,适合实时性要求高的场景

3.多外设联动:可实现外设间的自动协作(如定时器触发ADC采样)

常见的使用场景:

1.定时器输出:TIMx的更新事件(UEV)、比较匹配(CCx)触发ADC\DAC

2.外部中断:EXTI线检测GPIO边沿触发ADC或DMA

3.其他外设:如DMA请求由USART的RX事件触发(接收信号)->本文不讲解这个内容

一些🗡意

当你需要精准时序(如PWM同步ADC采样):选择硬件触发以保证同步性

当你需要动态控制,比如说按键触发:软件触发

低功耗的场景:硬件触发可以让CPU进入低功耗模式(如STOP模式)

低功耗模式在江协科技的视频里面有讲过


 标准库ADC函数

恭喜,你终于掌握了所有和ADC有关的基础知识,现在我们需要用代码来把我们所学的东西编写进单片机里以进行控制。

还记得最开始我们说的ADC的工作模式吗?

标准库的书写是全文最详细的,因为HAL库是直接用Cube Mx进行配置的,具体的细节我就不讲解了。既然已经有这样的东西帮我们写好了,我们就不用去写,而且HAL就是对标准库的二次封装,后面我也会试着封装一下对应的BIF函数辅助大家使用和配置。

因为老版本的BIF函数库有一定的错误,内部结构我还在优化,大概在下学期才能往下发布。

至于STM32的BIF函数库的首次亮相和使用会在我们未来的一个开源项目和大家见面,这个项目就是一个数控电源,但是加了一些没用的东西。

内容目录:

单次非扫描:单通道、双通道

扫描模式:双通道、多通道

注入通道:多通道(双通道的意义不大),其本质就是把多通道的那些多出来的通道删掉

DMA配合:扫描模式-双通道

实战样例:ADC采样正弦波(扫描定期采样、DMA、RMS有效值计算、串口打印)


ADC函数

在正式开始写ADC代码之前,我必须告诉你这些常用函数,后面就不再叙述,直接展示代码,描述代码逻辑(类似于GPIO、RCC这些基础函数就没有展示,往后也不会叙述):

1初始化和配置函数

void ADC_Init(ADC_TypeDef* ADCx, ADC_InitTypeDef* ADC_InitStruct);

ADCx - 你使用的ADC外设,ADC_InitStruct 你配置的ADC结构体

ADC_InitTypeDef ADC_InitStructure;

ADC_InitStructure(这个实例的名字本身并没有进行规定,只是行业范围内默认这么进行配置,你可以随便明明,比如说go_to_west也是可以的哈哈哈

typedef struct
{
    uint32_t ADC_Mode; //协同工作模式
    FunctionalState ADC_ScanConvMode; //扫描模式
    FunctionalState ADC_ContinuousConvMode; //连续模式
    uint32_t ADC_ExternalTrigConv; //触发源
    uint32_t ADC_DataAlign; //数据对齐方式
    uint8_t ADC_NbrOfChannel; //转换通道数目
}

那么这个结构体里面有哪些参数需要我们进行配置呢?

ADC_Mode:

我把所有的模式都汇总成表格以及它们的使用场景都放在了这里:

模式名称特点
Independent独立工作模式每个ADC都独立工作
RegInjecSimult规则组+注入组同步保护电路
RegSimult_AlterTrig规则组同步+交替触发提高采样率
InjecSimult_FastInter1注入组同步+快速交替高速+高优先级采样
InjecSimult_SlowInter1注入组同步+慢速交替低功耗+高优先级
InjecSimult纯注入组同步关键数据优先
RegSimult纯规则组同步多ADC同步采样
FastInter1快速交替高采样率
SlowInter1慢速交替低功耗
AlterTrig交替触发避免冲突

当然,不是所有的模式我都会使用和讲解,事实上,我只会详细讲解两三个常用的,剩下的都放在拓展里面简单讲解一下使用的流程和配置的方法,以及一些注意事项,以备大家的不时之需。

独立工作模式
#define ADC_Mode_Independent ((uint32_t)0x00000000)

 每个ADC都独立工作,互不干扰,适用于单ADC系统,或者多ADC之间不需要同步或交替采样。

每个ADC都可以单独配置触发源(如软件出触发、定时器触发)、适用于简单的单通道或多通道轮流采样

规则组和注入组同步模式
#define ADC_Mode_RegInjecSimult ((uint32_t)0x00010000)

 规则组和注入组同时采样,适用于双ADC系统。

适用于需要同时采样两个不同的信号,如(电流和电压)、规则组用于主采样,注入组用于突发高优先级采样(过流、过压保护)

ADC1的规则组和ADC2的注入组同时触发,在电力检测和电机控制等需要快速响应的场景经常使用。

纯规则组同步模式
#define ADC_Mode_RegSimult ((uint32_t)0x00060000)

同步模式下的ADC1和ADC2必须使用的是相同的触发源!

规则组同步采样,注入组独立工作

需要两个ADC同时采集相同或者不同的信号(如差分输入)或者多通道同步数据采集(如3相电流检测)

纯规则组同步模式能够提高采样精度,如ADC1采集一个数据,ADC2采集一个数据,然后求平均值。

交替触发模式
#define ADC_Mode_AlterTrig ((uint32_t)0x00090000)

ADC1和ADC2交替触发,不同步

两个ADC独立工作,交替触发以减少冲突,适用于多任务数据采集(如ADC1采集温度,ADC2采集电压等等)

这就是我主要讲解的四个模式,其他的6个模式在拓展里面只会略过。

这个时候,你可能会产生疑问:扫描模式在哪里?

ADC_ScanConvMode

扫描模式,配置选项:ENABLE / DISABLE

此时你一定会有一个大大的疑问,扫描模式难道不应该是ADC_Mode中的一个选项吗?为什么会成为ADC_InitStructure里的一个参数了?

这是每个新手常见的问题,混淆了ADC_Mode和ADC_ScanConvMode所用来定义的事项。

名称ADC_ScanConvModeADC_Mode
作用控制多通道自动转换顺序定义ADC之间的同步/交替方式
场景单/多通道连续采样多ADC协同工作
关系独立于ADC_Mode可能需要扫描模式的配合

现在你应该明晰这两者之间的区别了吧,扫描模式定义的是ADC的通道读取顺序,决定是否进行多通道读取,应用于读取方面,而ADC_Mode定义的是ADC1和ADC2之间的工作模式。

再次声明!误区纠正

在讲了这么多内容,如果你一口气读到这里的话,你现在一定会产生的混淆:

ADC1是规则组,ADC2是注入组,这里需要再次声明!

规则组和注入组是ADC1和ADC2所共有的功能,而ADC1和ADC2是两个不同的STM32内置的ADC模数转换器,规则组和注入组可以在同一个ADC中同时存在!不要弄错,如果忘记了回到上面重新阅读。(到这里已经20000多字了,为自己点个👍吧,也在评论区给博主一点回馈吧,这真的是我继续更新下去的动力/(ㄒoㄒ)/~~)

其中,ADC1和ADC2可以配置为不同的组别,如ADC1为规则组,ADC2为注入组,这就是ADC的协同工作,后面会展示的。

在这里你还是会有一个误区,就是规则组和注入组如果放在同一个ADC模块里,他们的触发源是不是必须相同。

答案是不需要,两这个触发源可以独立配置,如规则组有定时器1触发,而注入组由外部中断触发(不要把触发源和时钟源混淆在一起,这都是我走过的路TAT)

这个时候你就会迷茫,啊?这个不是那个,那个又是那个,我学的是个什么?

这个Mode到底在配置些什么?

没害怕孩子,你的担心都是正常的,只有你在使用多个ADC的时候才需要关注这个东西,平常只是用ADC1就设置为独立模式就可以啦。

ADC_Mode的作用是定义这些ADC之间是如何配合工作的,可是博主,你上面不是都是用规则组和注入组作区分的吗?

我们用通俗一点的话再来解释一遍吧:

重新认识ADC_Mode
模式场景说明
Independent1和2各自干自己的活,互不干扰
RegSimult1和2做同样的事情
FastInter11和2轮流干活,接替式
RegInjecSimult1只负责常规工作,2负责紧急任务
InjecSimult只有2负责紧急任务,1不参与同步

这里你应该有了大致的了解,但还是有一些迷糊,说不上来:

这是因为模糊性介绍所带来的概念性错误认知,因为我们并不知道这个事情它到底是什么,我们只知道它们就是这么干一件事情,这个接替既然是接替,拿他和慢速接替有什么区别,接替也有速度之分?这个速度定义的是什么。对于纯注入组同步,怎么2就负责,1就不参与呢?为什么在规则组的时候1和2就都是参与的呢?它们之间为什么会产生这样的差异呢?这么设计我们应该怎么使用,这么配置会不会有什么弊端,它这个模式的使用是不是什么要求?紧急任务紧急在哪里?我没有这个概念。独立,独立在哪里?不是所有的任务都一定有一个先后之分吗?它们之间一定会存在差异啊......

当你有这种模糊的感觉就说明你和我一样有着一颗敢于探求真理的心,对一切都保持怀疑,坚信自己所证实过的。也有很多人说这么做会降低我们的学习效率,别人都这么说了,教程既然都这么说的,那就直接用就可以了。但是我并不这么认为,这种想法就是把学习和抄等同在了一起。

这就好比抄了一份试卷和靠着自己真才实学完成一份满分试卷,这两个结果是截然不同的,如果下次再次同类型的东西,他们就没有那种举一反三的自信。

既然选择学习,那就脚踏实地,头也不回地往死里学,如果你要抄,那就往死里抄,抄的越多越好,争取把天下所有的东西都抄完。

我们再再再再次解释一下这个ADC_Mode,因为我还没有讲解到这个核心的点子上,所以你们才会有这样的感觉(心机boy 😜特意这么配置的文章顺序,就是为了这么一小段鸡汤,那些直接抄代码的就看不到)

ADC1和ADC2是你读取的通道,而规则组和注入组是ADC的通道性质,而ADC_Mode是用来规范不同通道和不同性质的组合模式。现在你是不是一瞬间就明悟了。ADC_Mode的本质和核心是定义ADC1和ADC2的同步和交替模式。

同步注入组为什么不同步

这是上面唯一我觉得有价值单独拎出来讲一下的问题,因为这涉及比较底层的只是。

我们受”同步规则组“这个模式的影响,让我们理所当然地把同步的对象当成工作任务,但实际上这个同步指的是ADC1和ADC2同时被触发。

同时被触发不就是同时工作同一件事情吗?产生这样的疑惑是因为同步规则组中的ADC1和ADC2都是作为规则组同时进行同一件事情,而再同步注入组里并不是这样的。

模式对象ADC1ADC2效果
规则组同步ADC1和ADC2都是规则组转换规则组通道同时转换规则组通道两个ADC的规则组同步输出
注入组同步

ADC1规则组

ADC2注入组

转换规则组通道同时转换注入组通道规则组和注入组的数据对齐

看到这个表格的时候你肯定会提出疑问,为什么注入组同步不是两个注入组。

当时我也是这么想的,但是很快我就意识到这个想法有多么傻了。

给你思考1分钟,还没思考出来我就是这个表情🙄💅💅

举一个十分形象的例子,当洪水来临的时候,你的左脑触发中断,告诉你左边跑,你的右脑触发中断,告诉你向右边跑,最要命的是他们同时控制你的身体,现在就应该知道这个想法为什么行不通了吧。因为我们所配置的优先级是同一个通道里的优先级,两个通道就像你的左脑和右脑,你能说你的左脑和右脑哪个优先级更高吗?我们只能说我们左脑或者右脑哪个更发达,但发达不意味着优先级就高,这是所有学习电子同学的通病。

但是有一些敏锐的同学会问,两个ADC不是相互独立,有着独立的ADC通道吗?

这个时候我就要再用一个表情来说明💅💅🙄

注入组为什么叫注入组,就是用来打断规则组的,如果没有规则组,注入组打断什么?触发什么?那不就和规则组一样了吗?

但其实,🧐这两个原因不是主要的,因为ADC2的注入组和ADC1的规则组绑定的,这个模式就是为了这一设计而存在的。

ADC_ContinuousConvMode

连续转换模式,配置选项:ENABLE / DISABLE

在连续转换模式下,ADC启动后会自动不间断地执行转换,无需外部触或软件干预。每次转换完成后,ADC立即开始下一次转换,形成连续的数据流。

在连续转换模式下,ADC每完成一次转换就会自动重新启动,解放双手,无需手动触发(软件触发/硬件触发)

数据仍然是存储在ADC数据寄存器里,你可以通过DMA中断读取,避免数据丢失

这个时候,聪明的同学就又要问了:

扫描和连续之间的差异

这就是你混淆了这个 “不间断”工作的实质和核心了,

我们用一个表格来说明扫描和连续之间的差异:

名称扫描模式连续转换模式
对象多通道的切换顺序通道的重复触发方式
功能按顺序自动切换通道自动重复启动转换
触发每次扫描都需要触发仅首次需要触发,后续自动循环

现在你应该明白了二者之间的差别了吧,这个扫描模式和连续转换模式作用的对象都是不同的。

ADC_ExternalTrigConv

外部触发源,配置选项:

#define ADC_ExternalTrigConv_T1_CC1                ((uint32_t)0x00000000) /*!< For ADC1 and ADC2 */
#define ADC_ExternalTrigConv_T1_CC2                ((uint32_t)0x00020000) /*!< For ADC1 and ADC2 */
#define ADC_ExternalTrigConv_T2_CC2                ((uint32_t)0x00060000) /*!< For ADC1 and ADC2 */
#define ADC_ExternalTrigConv_T3_TRGO               ((uint32_t)0x00080000) /*!< For ADC1 and ADC2 */
#define ADC_ExternalTrigConv_T4_CC4                ((uint32_t)0x000A0000) /*!< For ADC1 and ADC2 */
#define ADC_ExternalTrigConv_Ext_IT11_TIM8_TRGO    ((uint32_t)0x000C0000) /*!< For ADC1 and ADC2 */

#define ADC_ExternalTrigConv_T1_CC3                ((uint32_t)0x00040000) /*!< For ADC1, ADC2 and ADC3 */
#define ADC_ExternalTrigConv_None                  ((uint32_t)0x000E0000) /*!< For ADC1, ADC2 and ADC3 */

#define ADC_ExternalTrigConv_T3_CC1                ((uint32_t)0x00000000) /*!< For ADC3 only */
#define ADC_ExternalTrigConv_T2_CC3                ((uint32_t)0x00020000) /*!< For ADC3 only */
#define ADC_ExternalTrigConv_T8_CC1                ((uint32_t)0x00060000) /*!< For ADC3 only */
#define ADC_ExternalTrigConv_T8_TRGO               ((uint32_t)0x00080000) /*!< For ADC3 only */
#define ADC_ExternalTrigConv_T5_CC1                ((uint32_t)0x000A0000) /*!< For ADC3 only */
#define ADC_ExternalTrigConv_T5_CC3                ((uint32_t)0x000C0000) /*!< For ADC3 only */
选项触发源场景
ADC_ExternalTrigConv_None软件触发(手动单次采集、测试
ADC_ExternalTrigConv_T1_CC1TIM1 - 1PWM同步采样
ADC_ExternalTrigConv_T1_CC2TIM1 - 2多通道定时触发
ADC_ExternalTrigConv_T1_CC3TIM1 - 3高精度定时采样
ADC_ExternalTrigConv_T2_CC2TIM2 - 2低速定时触发
ADC_ExternalTrigConv_T3_TRGOTIM3触发输出定时器主模式触发
ADC_ExternalTrigConv_T4_CC4TIM4 - 4特定定时器触发
ADC_ExternalTrigConv_Ext_IT11外部中断线11外部事件触发
ADC_ExternalTrigConv_T8_CC1TIM8 - 1高级定时器触发
ADC_ExternalTrigConv_T8_TRGOTIM8触发输出高级定时器主模式触发

【注,TIM1 - 1表示TIM 捕获/比较通道1 ,其他同理】

当然,不同系列的单片机所支持的触发源也略有不同,这个需要自己去看数据手册。

这里我给大家简单说明一下一些主要的触发源所对应的不同的中断事件:

定时器的那些事

对于高级定时器来说(TIM1/TIM8),可以生成更加复杂的PWM,具有死区控制和刹车功能;

对于通用定时器(TIM2-5)来说,它只用于普通的定时任务。

基本定时器(TIM6/7)不能直接触发ADC,需要软件或者主模式以触发。

但是你一定还注意到了这些定时器有着不同的名字,它们主要分为:捕获/比较通道,定时器TRGO输出和外部中断线,那么这三个之间有什么区别呢?

触发源信号特性应用
捕获/比较通道可调相位电机控制、同步采样
定时器TRGO输出固定周期(更新事件触发周期性采样
外部中断线异步事件触发按键唤醒、突发信号检测

你现在对此还是会有一些迷茫,不知道它们具体的实现是什么,我们进一步来讲:

(其中外部中断大家都十分了解,在这里就不讲述,就区分一下CCx和TRGO的区别。

CCx捕获比较通道

每个CCx 通道都配备有独立的比较寄存器(TIMx_CCRx),当计数器(TIMx_CNT)与CCRx匹配时,触发这些行为:

[定时器不会的上面给了学习链接-注入组那里]

输出比较(改变引脚电平-生成PWM或者单脉冲),输入捕获(记录当前计数器的值-测量脉冲宽度),触发ADC(通过硬件信号直接启动ADC转换-如TIM1_CC1触发ADC1)

TRGO触发输出

TRGO是定时器的全局事件输出,由TIMx_CR2.MMS为控制其触发源,有以下事件可触发:

更新事件(UEV) -计数器溢出/下溢时触发

捕获/比较事件:任一CCx通道匹配时触发(需要配置MMS=010)

复位事件:计数器被复位时触发

这些东西我看别人的定时器讲解好像没有,等下次我写定时器的时候给大家详细地讲解一下,这里就不占篇幅了。

这个|*TRGO| 的触发和CCx不一样,这篇教程我没有用过,大家可以自己去了解。


嘿,这里是后面回来的我,我发现这个定时器不讲后面你们可能会出问题,所以在这里解释几个核心问题:

ADC为什么需要触发源

ADC本身就是一个”模数转换器“,它需要明确的启动信号才能开始转换,如果没有触发源,那么它就只能通过软件手动启动。

那为什么不用软件手动启动呢?因为软件的执行都是存在延迟的,无法精确控制采样时刻,而且软件的实行需要占用CPU的资源,不适合高速或周期性采样。

触发源的功能不仅仅是为了给ADC一个启动信号,还是为了同步硬件之间的工作。对于“对于硬件同步大家应该还是会存在一点问题-硬件同步到底是什么,怎么个同步法,它们同步的到底是什么东西。这应该是你完整阅读教程到这里的一个问题:

硬件同步

同步指的是什么?同步指的是时间对齐和数据一致。

ADC的采样时刻与其他外设的关键事件(如PWM波形的上升沿、传感器信号就绪)完全对齐。在多外设协作时(如同步采集电流和电压,保证数据都是在同一时刻的物理量。

这样抽象的距离并不足以让你理解,我们用更加通俗的例子来说明:

现在有这么一个场景,你是一位五星级大厨,你正在炸鸡腿,你有两个步骤,一个步骤是油温达到155℃时放入鸡腿(我查过OvO),我们不用这么麻烦,你需要炸假设5分钟后捞出来。

软件触发-CPU:

你现在就是这个CPU,你现在如果全靠感觉放鸡腿和捞鸡腿,鸡腿很容易没熟或者炸糊。

又或者说你聪明一点,你用手机定了一个时,但是闹钟响起到你放鸡腿和捞鸡腿中间你有一个过程,你需要听到闹钟,然后走到锅前,拿起鸡腿(假设你是铁砂掌),放进去这个过程就会有着延迟,在高精度的系统里是十分致命的。

硬件触发:

但是现在科技强国,时代变化了,你做了一套自动化机械设备,当它检测到油温达到155℃时立刻放进鸡腿,这个过程几乎是同时的,就像齐跳反射一样。这个时候的延迟仅有鸡腿下落的时间,这是不可避免的,几乎是同步进行的。然后开始计时,过了5分钟,立刻捞出鸡腿,这就是硬件触发

现在你们应该能够明白这个过程了吧。但是,有些细心的人可能还是有疑问:对于ADC来说,它收到触发信号又是在做什么呢?

比如说我们设置定时器为上升沿触发:硬件检测到定时器脉冲的上升沿,ADC开始采集模拟信号,将模拟信号转化为数字值,

这个过程会有一小段不可避免的延迟->触发到采样的时间(通常为1-2个ADC时钟周期吗12MHz的情况下一般是83ns)->采样时间延迟(55.5周期、12MHz是4.63us,总转换时间的延迟约为5.67us)

那么系统内部是如何保证严格同步的,那就是把定时器的触发信号通过芯片内部的专用线路直接连接到ADC,无需经过CPU,延迟极低(纳秒级)

不需要太过较真,了解一下就可以啦

ADC_DataAlign

数据对齐方式,选项:

#define ADC_DataAlign_Right                        ((uint32_t)0x00000000)
#define ADC_DataAlign_Left                         ((uint32_t)0x00000800)

分别是左对齐和右对齐,上面讲过了。

ADC_NbrOfChannel

每次转换的通道数,(1-16)

你一次用了多少个ADC通道就输入多少个通道,多通道的时候需要配置前后顺序。

【注:ADC_InitStructure的配置是以单个ADC为单位进行配置的,所以你写的转换通道数目是你该结构体配置的ADC中的通道数目,不要弄错了】

----

嘿,这是后面的我,你现在需要注意,这个配置参数是你每次转换的通道数目!不是你总共要使用的通道数目!我们平常使用扫描模式久了会把这个参数配置当成我们实际上调用的通道数目,在单次非扫描模式下,这个永远是1!!

----

现在,你已经把最核心的ADC_InitStructure配置完成了,快给自己👏,我们继续。

ADC_DeInit()
void ADC_DeInit(ADC_TypeDef* ADCx)
{
  /* Check the parameters */
  assert_param(IS_ADC_ALL_PERIPH(ADCx));
  
  if (ADCx == ADC1)
  {
    /* Enable ADC1 reset state */
    RCC_APB2PeriphResetCmd(RCC_APB2Periph_ADC1, ENABLE);
    /* Release ADC1 from reset state */
    RCC_APB2PeriphResetCmd(RCC_APB2Periph_ADC1, DISABLE);
  }
  else if (ADCx == ADC2)
  {
    /* Enable ADC2 reset state */
    RCC_APB2PeriphResetCmd(RCC_APB2Periph_ADC2, ENABLE);
    /* Release ADC2 from reset state */
    RCC_APB2PeriphResetCmd(RCC_APB2Periph_ADC2, DISABLE);
  }
  else
  {
    if (ADCx == ADC3)
    {
      /* Enable ADC3 reset state */
      RCC_APB2PeriphResetCmd(RCC_APB2Periph_ADC3, ENABLE);
      /* Release ADC3 from reset state */
      RCC_APB2PeriphResetCmd(RCC_APB2Periph_ADC3, DISABLE);
    }
  }
}

该函数的作用就是复位ADC外设寄存器到默认值,禁用ADC外设,重置ADC相关的状态标志,为ADC重新配置做准备。

这个默认值相信大家可能会犯糊涂,这里我列了一个表格:

寄存器重置后的状态
控制寄存器(CRx所有位清0->ADC的配置丢失
状态寄存器(SR所有标志位清0(EOC
序列寄存器(SQR/JQR通道序列配置被清楚->无有效转换通道
采样时间寄存器恢复默认值(通常位最短采样时间
校准数据校准值丢失->需要重新校准
DMA关联如果启动了DMA,那么DMA会中断,但配置本身不会受影响,如果你想重置和清楚有单独的函数

大家现在应该大致清楚这里所说的重置是什么意思了吧。

ADC_StructInit()
void ADC_StructInit(ADC_InitTypeDef* ADC_InitStruct)
{
  /* Reset ADC init structure parameters values */
  /* Initialize the ADC_Mode member */
  ADC_InitStruct->ADC_Mode = ADC_Mode_Independent;
  /* initialize the ADC_ScanConvMode member */
  ADC_InitStruct->ADC_ScanConvMode = DISABLE;
  /* Initialize the ADC_ContinuousConvMode member */
  ADC_InitStruct->ADC_ContinuousConvMode = DISABLE;
  /* Initialize the ADC_ExternalTrigConv member */
  ADC_InitStruct->ADC_ExternalTrigConv = ADC_ExternalTrigConv_T1_CC1;
  /* Initialize the ADC_DataAlign member */
  ADC_InitStruct->ADC_DataAlign = ADC_DataAlign_Right;
  /* Initialize the ADC_NbrOfChannel member */
  ADC_InitStruct->ADC_NbrOfChannel = 1;
}

这个函数显而易见,就是初始化ADC_InitTypeDef结构体为默认值,默认值如上面代码所示。

2.校准函数

对于ACD来说,校准是一个提高精度的必要过程,我先从ADC内部的校准机制开始讲起。

ADC的内部机制

STM32的ADC校准主要是通过内部的硬件电路自动完成,

1.偏移误差(Offset Calibration):首先单片机内部将ACD输入连接到内部参考地(或者是特定电压),测量并记录输入时的输出值(即偏移误差,单片机计算补偿值存储在内部寄存器中。(就是直接减去偏移误差

2.增益校准(Gain Calibration):内部将ADC输入连接到内部参考电压,测量并记录满量程时的输出值,计算增益补偿系数。

3.|*校准实现方式| :硬件自动完成,你只需要调用代码就可以。这其中时专用的校准电路和算法,这块没学,你们可以自己去看,这块我不懂就不👄👄了(等我学会了再发💬

这个增益校准有个计算公式,在这里我也顺便和大家唠叨一下:

GAIN_{ERROR} = (GAIN_{RAW} - OFFSET_{CAL}) / GAIN_{ideal} - 1

 GAIN_ideal:理想值 = VREFINT / VREF * 4095

GAIN_RAW:输出值,VREFINT:参考电压(通常是3.3V,OFFSET_CAL:偏移误差

增益误差到底是什么,这个增益增在哪里?

增益误差是指ADC转换的斜率与理想斜率的偏差 - 斜率在线性函数中体现为增益系数

理想情况下,满量程输入应产生满量程输出 - 4095(3.3V时

但是,好消息是,STM32F1不支持增益校准🤡,我去了解了这么一顿结果发现STM32F1不支持

我必须让你们也学一下:

可以看到ST公司给出的参考手册里(两版手册相同,都没有给出增益误差)

现在你了解单片机的ADC校准机制,现在我们来看下ADC的校准代码:

ADC_ResetCalibration()

复位ADC标准寄存器,复位的概念同上上面

void ADC_ResetCalibration(ADC_TypeDef* ADCx)
{
  /* Check the parameters */
  assert_param(IS_ADC_ALL_PERIPH(ADCx));
  /* Resets the selected ADC calibration registers */  
  ADCx->CR2 |= CR2_RSTCAL_Set;
}

 虽然说是概念相同,但还是有一点的区别:复位ADC校准寄存器会清楚校准计算电路的中间状态,而不是已经计算出来的偏移误差。

复位前如果已经校准,就会应用之前的偏移补偿值,也就是说他不会自动清零补偿值,但是它只是在等待新的校准过程中的新补偿值。

如果复位后没有重新校准,那么它可能会继续使用旧的补偿值,也可能表现出为校准状态。这个数据手册上我没看到,但应该就是这两个中的一个,不过我们使用的话肯定不会这么用的。

ADC_GetResetCalibrationStatus()

获取复位标准状态,参数,你的目标ADC外设

FlagStatus ADC_GetResetCalibrationStatus(ADC_TypeDef* ADCx)
{
  FlagStatus bitstatus = RESET;
  /* Check the parameters */
  assert_param(IS_ADC_ALL_PERIPH(ADCx));
  /* Check the status of RSTCAL bit */
  if ((ADCx->CR2 & CR2_RSTCAL_Set) != (uint32_t)RESET)
  {
    /* RSTCAL bit is set */
    bitstatus = SET;
  }
  else
  {
    /* RSTCAL bit is reset */
    bitstatus = RESET;
  }
  /* Return the RSTCAL bit status */
  return  bitstatus;
}

返回SET(校准中),或者RESET(完成)

这个时候你可能会产生疑惑:我们来收集这个复位校准状态干什么呢?

如果你学过STM32单片机的话🙄就应该知一个名为按键消抖的说法。

这个函数也是一样,它替代的是消抖函数的获取引脚电平状态的那个函数。具体的校准函数在后面都会有,这里就不说了

 ADC_StartCalibration()

启动ADC校准,参数:你使用的ADC外设

void ADC_StartCalibration(ADC_TypeDef* ADCx)
{
  assert_param(IS_ADC_ALL_PERIPH(ADCx));
  ADCx->CR2 |= CR2_CAL_Set;
}
ADC_GetCalibrationStatus()

获取校准状态,参数:目标ADC外设

FlagStatus ADC_GetCalibrationStatus(ADC_TypeDef* ADCx)
{
  FlagStatus bitstatus = RESET;
  assert_param(IS_ADC_ALL_PERIPH(ADCx));
  if ((ADCx->CR2 & CR2_CAL_Set) != (uint32_t)RESET)
  {
    bitstatus = SET;
  }
  else
  {
    bitstatus = RESET;
  }
  return bitstatus;
}

返回状态同获取复位校准状态一样。

这个时候你就会产生疑惑了,如果获取复位校准状态和获取校准状态有什么区别呢?

如果你仔细看过前面的教程的话,就应该知道复位会把你所有的配置都清除,把寄存器重置到默认状态,而校准才是真正调用ADC内部的电路,计算误差消除硬件误差的过程。

3.通道配置函数

从这里开始所有的函数我只给你展示它的函数声明,不再展示它的具体内容,如果你想看内部代码,可以复制函数声明到keil里面进行跳转检索。

ADC_RegularChannelConfig

配置规则组通道

void ADC_RegularChannelConfig(ADC_TypeDef* ADCx, uint8_t ADC_Channel, uint8_t Rank, uint8_t ADC_SampleTime);

ADCx:目标外设

ADC_Channel:通道编号,如:ADC_Channel_0

Rank:通道转换顺序(1-16)

ADC_SampleTime:采样时间,如ADC_SampleTime_55Cycles5(前面讲过了

如果你启用了扫描模式,就要给每一个通道都配置一下这个RegualrConfig,注意,这个函数是对单个通道进行排序,所以你使用了多少个规则组通道(x),你就要调用这个函数多少次(x)

ADC_InjectedChannelConfig

配置注入组通道

void ADC_InjectedChannelConfig(ADC_TypeDef* ADCx, uint8_t ADC_Channel, uint8_t Rank, uint8_t ADC_SampleTime)

对于规则组和注入组通道的讲解在前面已经十分全面了,这个函数的参数同上面的基本一样,不同的是注入组通道仅有4个,所以转换顺序的范围是(1-4)

4.转换控制函数

转换控制函数主要用来启动、管理和停止ADC的模数转换过程,确保模拟信号能够被正确采样并转换为数字值。

ADC_SoftwareStartConvCmd

启用/禁用软件触发模式

void ADC_SoftwareStartConvCmd(ADC_TypeDef* ADCx, FunctionalState NewState)

ADCx:目标外设

NewState:ENABLE 或 DISABLE 启用或禁用

 ADC_GetSoftwareStartConvStatus

获取软件触发状态

FlagStatus ADC_GetSoftwareStartConvStatus(ADC_TypeDef* ADCx)

前面忘记说了,FlagStatus这种函数声明就是用来判断和检测状态的,返回某个状态。

那么软件触发状态又是什么东西呢?

软件触发状态就是通过软件命令(而非硬件事件)启动ADC转换时的工作状态。

那我们需要这个软件触发状态干什么工作呢?我们主要用该函数检测当前ADC外设是否处于软件命令的触发状态,其实质和核心都和前面的获取复位状态一样,为了避免在同一时间因任务冲突产生工作内容丢失,或者是时序错误问题。

ADC_ExternalTrigConvCmd

启用/禁用外部中断触发

void ADC_ExternalTrigConvCmd(ADC_TypeDef* ADCx, FunctionalState NewState)

 5.数据获取函数

现在你掌握的函数已经能够做一个基本的ADC采集函数了,采集完自然要有读取。

ADC_GetConverSionValue

获取规则组的转换结果

uint16_t ADC_GetConversionValue(ADC_TypeDef* ADCx)

 这个时候,你又会产生疑问,每个ADC的外设不是都有很多个通道吗?那么我们如果仅仅使用一个ADCx它读取的数据会不会少呢?还是说它是直接读取所有的通道呢?

这个问题,我们在上文已经说过,规则组仅有一个16位寄存器,这就说明它最多只能储存一个转换数据,所以这个规则组就是读取这个寄存器上的数据,那么我们应该怎么读取到这个完整的数据呢?就需要我们前面说过的DMA了,帮我们把每个通道的结果保存到指定的内存数组里。

ADC_GetInjectedConversionValue

获取注入组转换结果

uint16_t ADC_GetInjectedConversionValue(ADC_TypeDef* ADCx, uint8_t ADC_InjectedChannel)

到了注入组的转换结果这里,你就会发现它多了一个通道配置项,因为注入组是一个4*16位的数据寄存器,这个时候你就会又有新的疑惑,如果说前面一个通道,我们可以通多DMA搬运,那我们注入组应该怎么办呢?

这其实也很简单,我们只需要在配置抢断函数的时候调用一下这个函数,把当前储存的数据保存起来就可以了。

6.其他的功能函数

对于ADC的中断和DMA控制在后面单独讲解DMA机制的时候再进行介绍。

我之前也说过ADC有很多的功能,它的函数库自然也不是只有这些函数,我就拿三个比较经典的为大家讲解一下

ADC_TempSensorVrefintCmd

启用/禁用内部温度传感器和参考电压通道

void ADC_TempSensorVrefintCmd(FunctionalState NewState)

前面说过温度传感器和参考电压通道是单独设计出来的,所以我们有一个专用的函数进行调用。

当然,这不仅是为了方便大家进行区分而做出的设计,因为我们平常并不需要使用这两个通道,所以设计师为了节省功耗,把这两个通道与ADC1的输入断开,只有使用这个函数进行使能才能连接到ADC输入多路复用器里面,否则转启动转换也无法读取数据。

那又会有人问啦:我们进行ADC转换和校准的时候不是都需要使用这个参考电压吗,那我们是不是要一直保持这个参考电压通道打开呢?

这个疑问解决的点在于这个函数它控制的是VREF是否接入ADC主通道,而不是VREF在硬件内部是不是开启。在系统内部,转换的校准过程并不是依靠ADC主通道来进行的,而是内部电路进行的,也就是说这个VREF是否开启哦都不影响我们这个校准和转换的进行,这一设计也是为了减少大家的工作量,或者说是减少出错的可能。

当然,如果你想要读取VREF的值你就需要打开,接入ADC主通道的核心目的就是为了读取。

ADC_GetFlagStatus

获取ADC的状态标志

FlagStatus ADC_GetFlagStatus(ADC_TypeDef* ADCx, uint8_t ADC_FLAG)

ADC_FLAG就是你想检查的标志位,

#define ADC_FLAG_AWD                               ((uint8_t)0x01)
#define ADC_FLAG_EOC                               ((uint8_t)0x02)
#define ADC_FLAG_JEOC                              ((uint8_t)0x04)
#define ADC_FLAG_JSTRT                             ((uint8_t)0x08)
#define ADC_FLAG_STRT                              ((uint8_t)0x10)

一般就是你想验证什么东西,或者搭配if条件语句检测ADC状态实现某种特定的功能的,但其实我们用不到的。

ADC_ClearFlag

清除标志位

void ADC_ClearFlag(ADC_TypeDef* ADCx, uint8_t ADC_FLAG)

把目标位进行清除-恢复初始值。


标准库配置

在上面我们已经和大家说明了这个我们接下来会使用的一些主要的ADC标准库函数,其他的ADC函数要是上面没出现过这里会顺带一嘴讲一下,DMA会在开篇的时候进行讲解。

配置函数说明:请确保你有一定的STM32基础,其中包括但不限于定时器、中断、GPIO等内容,我讲述的配置的所有的流程,包含整个工程里你需要书写的内容。其函数需要自行封装,主函数调用方式也会进行讲解,所有代码都是在实机运行得出结果确认代码可用后发布,若有问题,请先自行思考,实在无法解决再来问我,如果您不喜欢我这样的分享方式,您可以另寻高就。

单次非扫描模式:

单通道-PA0为例

1.使能ADC和GPIO时钟和ADC时钟分频

RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1 | RCC_APB2Periph_GPIOA ,ENABLE);
RCC_ADCCLKConfig(RCC_PCLK2_Div6);	

2.配置GPIO为模拟输入模式

GPIO_InitTypeDef GPIO_InitStructure;

GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN;
GPIO_Init(GPIOA,&GPIO_InitStructure);

3.配置ADC通道

ADC_InitTypeDef ADC_InitStructure;

ADC_InitStructure.ADC_Mode = ADC_Mode_Independent; // 独立模式
ADC_InitStructure.ADC_ScanConvMode = DISABLE; //非扫描模式
ADC_InitStructure.ADC_ContinuousConvMode = DISABLE; //非连续模式-单次转换
ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None; //软件触发
ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right; //数据右对齐
ADC_InitStructure.ADC_NbrOfChannel = 1; //转换通道数目-1
ADC_Init(ADC1,&ADC_InitStructure);

通过上面这个代码大家应该能清楚地看到整个ADC通道配置所需要的最基本的东西。

4.配置ADC参数

ADC_RegularChannelConfig(ADC1,ADC_Channel_0,1,ADC_SampleTime_55Cycles5);

一般来说,我们都配置采样事件为55.5个周期如果你实在不知道应该怎么配置的话。具体的计算公式我在上面已经详细讲解过了 

5.使能ADC

ADC_Cmd(ADC1,ENABLE);

6.校准ADC(可选但推荐)

ADC_ResetCalibration(ADC1);
while(ADC_GetResetCalibrationStatus(ADC1));
ADC_ResetCalibration(ADC1);
while(ADC_GetCalibrationStatus(ADC1));

7.读取ADC转换结果

ADC_SoftwareStartConvCmd(ADC1,ENABLE); //软件触发ADC-1次
while(!ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC)); //等待ADC的EOC结束位
return ADC_GetConversionValue(ADC1); //返回ADC读取值

这就是最基本的ADC库函数你需要配置的东西,相信你一定看懂了上面所有的内容了。

对于规则组单通道的工作模式,我们用一个变量实时反应出来就可以。

你可以用江协的OLED把数据显示出来:

请确保你已经将江协的OLED配置文件放进工程里:

#include "OLED.h"
#include "BIF.h"
#include <stdio.h>

uint16_t AD; //AD表示的是模数转换值
float voltage; //voltage表示的是实际电压值
char arr[10];

int main()
{
    BIF_ADC_Init();
    OLED_Init();

    OLED_ShowString(1,1,"AD:");
    OLED_ShowString(2,1,"Vo:");

    while(1)
    {
    AD = BIF_Read_ADC();
    voltage = (float)AD / 4095 * 3.3; //实际值转换公式
    snprintf(arr,sizeof(arr),"%.2f",voltage);
    OLED_ShowNum(1,4,AD,4);
    OLED_ShowNum(2,4,voltage,4);
    
    Delay_ms(100); //如果不加延迟,数值变化太快看不清
    }
}

【注:不要命名为ADC_Init,标准库里已经有个一个名为ADC_Init的函数,不要怪我没提醒你

🙄💅💅不要问我怎么知道的,能问这个问题的都是伪人】 

我们如果想要显示浮点数,我们可以通过<stdio.h>头文件里面的snprintf函数将浮点数转换为字符数组,再通过ShowString展示出来。

首先是通过BIF_ADC_Init函数封装了前6步,该函数是对ADC外设进行初始化,然后是通过模数转换公式计算实际值,上面也都讲过。BIF_Read_ADC就是第7步的读取值的函数。

然后通过OLED展示出来,江协的配置应该是B8是SCL,B9是SDA。

【注:Read函数放在函数while循环里和while循环外是两个概念,如果你放在循环外那就单次调用单次,而你放在循环里面就是一直调用,从而看起来像是一直在采样一样】

连续转换模式的重申

那么这个时候你作为初学者一定会产生的误解:

那连续模式是不是就是把Read函数放在while循环里和while循环外的区别呢?

【注:连续是相对单次来说的,连续中的定义是转换的连续,而不是读取的连续】

双通道-PA0\PA1为例

很多人会问,博主博主,你怎么就只用这几个引脚,该不会是不会吧,你也太逊啦。

不要杠,我就是不会🥴这都被你看出来了,所以等你来写这部分的代码。

其实你用久了就会发现,单片机用来用去哪个引脚干什么其实已经都固定下来了,换其他通道只要是ADC1也就是改个数字的事情。至于你说ADC2🙄你重头看一遍吧💅💅

配置说明 - PA0电压,PA1电流

这次的函数数学方式略微有些不同,首先,我们使用了两个通道,其次,我们采集了两个不同的东西,每个通道对应不同的数值。

我们首先要配置GPIO端口:

GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1;

非扫描模式下没有规则组排序这么一说,所以我们可以把读取单独封装为一个函数

uint16_t BIF_ADC_Read(uint8_t channel)
{
	ADC_RegularChannelConfig(ADC1,channel,1,ADC_SampleTime_55Cycles5);
	ADC_SoftwareStartConvCmd(ADC1,ENABLE);
	while(!ADC_GetFlagStatus(ADC1,ADC_FLAG_EOC));
	ADC_ClearFlag(ADC1,ADC_FLAG_EOC);
	return ADC_GetConversionValue(ADC1);
}

【注:我们把通道的参数配置函数 Config 放在了这个函数里,而不是BIF_ADC_Init里面】

这个函数就是为例读取通道的ADC转换值的,然后我们需要读取这个值,为了方便,我直接集成在一个函数里面了,

void BIF_Turn_num(float *current,float *voltage)
{
	uint16_t adc_value;
	float vref = 3.3f;
	adc_value = BIF_ADC_ReadChannel(ADC_Channel_0);
	*voltage = (float)adc_value * vref / 4095.0f;
	adc_value = BIF_ADC_ReadChannel(ADC_Channel_1);
	*current = (float)adc_value * vref / 4095.0f / 0.1f;
}

那么主函数里我们应该怎么做呢? 

#include "OLED.h"
#include "BIF.h"
#include <stdio.h>

float Voltage,Current;
char v[10];
char A[10];

int main()
{
    BIF_ADC_Init();
    OLED_Init();
    OLED_ShowString(1,1,"V:");
    OLED_ShowString(2,1,"A:");    

    while(1)
    {
        BIF_Turn_num(&Current,&Voltage);
        snprintf(v,sizeof(v),"%.2f",Voltage);
		snprintf(A,sizeof(A),"%.2f",Current);
		OLED_ShowString(1, 3, v);
		OLED_ShowString(2, 3, A);
        Delay_ms(100);
    }
}

extern关键字

这里🧺(浇给)大家一个小技巧,如果你就是搞不懂这个extern,如何让封装函数库内的变量能在全局调用,那么你就把所有变量都写在主函数里,然后其他函数直接作用于地址。

既然说都说到这里了,我们顺便说一下这个问题吧:

假设你现在封装了一个函数库BIF,在这个函数库里面你有int a,uint32_t b,float c,char d,int e[10],string f,typdef struct g;我们分别应该怎么做呢?

#ifndef __BIF_H
#define __BIF_H
#include <stdint.h> //规范uint32_t,其实没有必要
#include "string.h" //字符串

extern int a;
extern uint32_t;
extern float c;
extern char d;
extern int e[10];
extern string f;
extern struct ADC_Struct g;

#endif

然后在.c文件把这些东西去掉extern定义一遍就可以了,然后你在主函数里调用这个头文件,你就可以直接使用这些变量了,就是不需要再去声明了,如直接是 a = 100。

extern的作用在这里就是高速编译器:这个变量/函数已经在别的地方定义了,你不需要在这里分配内存或生成代码,直接引用它们就可以。

如果你执意再次定义了一遍,就会报错 - 重复定义。

现在的你已经完全掌握了单次非扫描模式了!多通道就是双通道基础上进行扩展BIF_Turn_num函数而已,增加参数和转换关系就可以了。

接下来我们来了解一下单次扫描模式:

单次扫描模式:

我们同样是A0采集电压,A1采集电流

GPIO引脚的配置不变,我们只需要改变ADC1的配置

我的代码只展示相比较于上面进行修改的部分

ADC_InitStructure.ADC_ScanConvMode = ENABLE;
ADC_InitStructure.ADC_NbrOfChannel = 2; //一次扫描2个通道
ADC_RegularChannelConfig(ADC1,ADC_Channel_0,1,ADC_SampleTime_55Cycles5);
ADC_RegularChannelConfig(ADC1,ADC_Channel_1,2,ADC_SampleTime_55Cycles5);

但是需要注意的是,我们还需要加上一个软件触发转换,因为我们的扫描模式就是软件触发的。

ADC_SoftwareStartConvCmd(ADC1,ENABLE);

扫描模式需要在定义的时候就要配置好它的顺序,然后就是使能,校准代码都一样。

不同的就是读取ADC值,因为我们单次非扫描的时候我们是运行一次读一次,是因为我们一次调用它只运行一次,我们正好读取一次值,但是我们现在一次读取多个值应该怎么办呢?

这个时候如果你用这个人机函数去读:

void ADC_ReadScanChannel(uint16_t *voltage,uint16_t *current)
{
	ADC_SoftwareStartConvCmd(ADC1,ENABLE);
	while(!ADC_GetFlagStatus(ADC1,ADC_FLAG_EOC));
	*voltage = ADC_GetConversionValue(ADC1);
	*current = ADC_GetConversionValue(ADC1);
	ADC_ClearFlag(ADC1,ADC_FLAG_EOC);
}

就会发现你读取的A0和A1都是同一个数(看也能看出来,这是我第一次问AI写的,AI去🐀

如果你想快速验证这个ADC是否正常,最简单的方法就是一个引脚接3.3V,一个引脚悬空,如果这都一样那就说明有问题了。

(还有,不要去评论区刷诶呀,你就是代码没结果,所以才不给工程🙄💅💅,那是因为图片太占地方了,其实可以用串口打印的,这个后面再说,因为前面的东西已经不想改了)

看到博主那沧桑的手了吗😭看到那台破旧的电脑了吗,还不快支持我一下

这就是前面说的数据覆盖现象,因为只有一个16位的数据寄存器,所以A1的数据覆盖了A0,导致数据锁在了A1上(A1接3.3V和A1没接3.3V,看现象) 

这个时候我们就要请出我们的DMA了!

⭐ - DMA - 🔥👑

DMA(Direct Memory Access,直接存储访问)

它是STM32的一个外设,它允许数据在外设和存储器之间或存储器和存储器之间直接传输,无需CPU的干预。这大大提高了数据传输效率,减轻了CPU的负担。

当然,它也是用来解决规则组数据覆盖问题的必要手段!

前面说过STM32有两个DMA,但是我们的STM32F103C8T6还是太弱小了,只有一个DMA1,不懂的回去看,以后不再说这句话了)

那么DMA的寄存器有哪些呢?

DMA_ISR

中断状态寄存器

DMA_IFCR中断标志清除寄存器
DMA_CCRx通道x配置寄存器
DMA_CNDTRx通道x传输数量寄存器

DMA_CPAPx

通道x外设地址寄存器
DMA_CMARx通道x存储器地址寄存器

先不着急了解这个寄存器,我们先来看看这个DMA的软件配置:

同样的,DMA有其主线:

RCC_AHBPeriphClockCmd(RCC_AHBperiph_DMA1,ENABLE);

注意,是AHB总线上!不要用APB2总线进行使能。

我们初始化DMA有哪些工作呢:

外设地址、存储器地址、传输方向、数据宽度、传输模式、优先级、是否使能中断、使能DMA通道、配置外设使用DMA

DMA也有DMA配套的配置结构体:

DMA_InitTypeDef来配置结构体DMA_InitStructure

DMA_PeripheralBaseAddr

设置外设的基地址,即数据来源或目标外设寄存器地址,就是你从哪里读,或者你要把数据写进哪里的这个地址

对于ADC1来说我们从外设到内存:设置为外设数据寄存器地址:&ADC1->DR(数据从该地址复制到内存

对于内存到外设的传输,我们可以设置为:&USART1->DR(数据从内存复制到该地址

有一个要点大家容易忽视就是必须使用外设数据寄存器的物理地址,即地址太过于麻烦,所以太菜的设计师已经帮我们想好了,我们直接使用 &外设->DR 获取就可以

但是需要注意的是,我们必须要使用(uint32_t)强制转换符,这是STM32外设的地址类型。

DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR;
DMA_MemoryBaseAddr

设置内存基地址,即数据来源或者目的内存地址,你要写进哪里

既然你有了读的地方,你肯定有一个需要写东西记录的地方

但是这个数据储存的格式和你们想的可能不太一样,一般来说,我们一组采集多个数据,然后我们需要储存在一个数组里面:

uint16_t ADC[100]; //储存的地方
DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)ADC;

数组的名字就是它的基地址,所以这里直接写的是ADC

DMA_DIR

设置数据传输方向,我们到底是传输到外设,还是传输到内存?没错,我们需要一个方向

DMA_DIR_PeripheralSRC //外设为源(外设→内存)
DMA_DIR_PeripheralDSt //外设为目的(内存→外设)
DMA_BufferSize

设置DMA传输的数据量-数据单元(单位取决于数据宽度),表示要传输的数据项数,不是字节数!

最大值为65535 (16为寄存器)

相信你还是没有看懂,这个定义的是DMA单次传输的数据单元数量(如字节、半字或字),传输完成后,DMA会自动停止,循环模式下会重新开始

比如说你设置了128:

DMA_InitStructure.DMA_BufferSize = 128;

在传输的过程中,这个数值会自动递减,读取DMA_CNDTRx寄存器剩余的数据量,直到128减到0后,停止或重新开始读取,记住这个东西,读取正弦波的时候也需要这个东西。

也就是说你这个东西一定要大于0,配置为0还问我🙋‍♂️🙋‍♀️博主,为什么我的DMA没有启动,我这边建议你买一个喜之郎果冻,然后成为👩‍🚀👨‍🚀赶紧回到你的星球上去,太吓人了💀建议你去淘宝买一个爱因斯坦的脑子,博主就买了,很好用,快去试试吧🤓👉 爱因斯坦大脑的购买链接

实际上这个函数要和另一个函数参数配置搭配起来使用:

DMA_PeripheralDataSize

上面的BufferSize是定义数据单元的传输量,这个就是定义每个数据单元的数据宽度:

#define DMA_PeripheralDataSize_Byte        ((uint32_t)0x00000000)//8位数据
#define DMA_PeripheralDataSize_HalfWord    ((uint32_t)0x00000100)//16位数据
#define DMA_PeripheralDataSize_Word        ((uint32_t)0x00000200)//32位数据

分别有三个选项:

8位数据:传输总字节数=BufferSize * 1,16位是*2,32位是*4

比如说你是128 - Budder, 32位 - size ,那么总字节数就是:512

字节是什么概念?

1字节 = 8位(0~255),就是8位2进制构成1字节,也就是0000 0000 ~ 1111 1111

比如说我们有一个uint8_t a[10]的数组,这个数组有10个8位数,就是10字节

同样的,这里也分循环模式和非循环模式,在循环模式下,DMA内部会自动重置,重新开始,而普通模式(非循环,就是手动重启或配置

DMA_PeripheralInc

【注:Periphera - L - i - nc】前面是小写的L后面是大写的i不要弄错了

设置外设地址是否自动递增:

#define DMA_PeripheralInc_Enable           ((uint32_t)0x00000040)
#define DMA_PeripheralInc_Disable          ((uint32_t)0x00000000)

使能后传输后外设地址递增,这个递增其实就相当于递增一个偏移量,不过我们的外设数据寄存器地址一般都是固定的,大家平常选DISABLE就可以,如果你是多个寄存器,那就另当别论了

DMA_MemoryInc

设置内存地址是否递增

既然有外设递增,内存又怎么能不递增呢?

#define DMA_MemoryInc_Enable               ((uint32_t)0x00000080)
#define DMA_MemoryInc_Disable              ((uint32_t)0x00000000)

一般我们的数据缓存器都是以数组形式展现的,而我们都知道假如说我们的数组基地址arr[0] -> 0x00,那么arr[1]就是在基地址上增加一个偏移量0x01,以此类推。

如果你使用的是数组就ENABLE,如果是单个变量,就DISABLE,这边建议数组,数组不仅能保存更多的数据,还起到缓冲区的作用,更安全可靠和稳定

DMA_MemoryDataSize

设置内存数据宽度,通常与外设的数据宽度相同,因为是直接复制过来,但是需要注意数据对齐和截断的问题,我都是直接相同的,不同的没用过,可以自己去了解

同时你也要注意内存缓冲区的数据类型是否和你设置的内存数据宽度是否一样,这个是一定的

#define DMA_MemoryDataSize_Byte            ((uint32_t)0x00000000)
#define DMA_MemoryDataSize_HalfWord        ((uint32_t)0x00000400)
#define DMA_MemoryDataSize_Word            ((uint32_t)0x00000800)
DMA_Mode

DMA的工作模式,DMA有正常模式和循环模式两种,单次传输就用正常模式,多次就用循环

#define DMA_Mode_Circular                  ((uint32_t)0x00000020)//循环
#define DMA_Mode_Normal                    ((uint32_t)0x00000000)//普通
DMA_Priority

设置DMA通道优先级,我们只配置ADC,用不到这个东西

#define DMA_Priority_VeryHigh              ((uint32_t)0x00003000)
#define DMA_Priority_High                  ((uint32_t)0x00002000)
#define DMA_Priority_Medium                ((uint32_t)0x00001000)
#define DMA_Priority_Low                   ((uint32_t)0x00000000)

随便就可以了,前面说过DMA支持多个外设,所以有优先级之分,这个看个人需求,因为,这东西我目前每设置过什么优先级

 DMA_M2M

设置是否位内存到内存的模式(有意思的是这个模式的英文:Memory-to-Memoty,2英文two发音和to相似,使得,蛮有趣的,和大家提一嘴,没什么用处,无人在意,好卑微的博主😢)

#define DMA_M2M_Enable                     ((uint32_t)0x00004000)
#define DMA_M2M_Disable                    ((uint32_t)0x00000000)

 这个宝子们不用担心,因为STM32F103C8T6穷的可怕,只有一个DMA,不支持内存到内存传输,必须设为DISABLE

如果有人就是想用,这个内存到内存到底是什么意思呢?也就是寄存器到寄存器之间的数据传输

DMA将数据从源内存地址直接复制到目标内存地址,全称不经过CPU,不涉及外设(除了M2M,还有M2P和P2M,P代表外设,因为我们这里配置的是DMA1的通道1-ADC1仅支持M2M)

这个时候你可能就会思考,如果仅仅是复制数据,一个DMA也可以吧。

其实不然,F103C8T6仅有一个DMA1,7个通道里只有DMA1支持内存到内存传输,DMA通道通常被设计为优先服务外设请求(USART\ADC\SPI),这是为了保证系统运行正常!如果你启用M2M,那么因为我们只有一个ADC1通道支持M2M,通道1工作在M2M里,就没办法相应外设的DMA请求,外设就无法使用DMA功能,导致数据传输失败,如果你有多个支持M2M的通道,那么就可以使用另一个DMA来进行M2M,而不会干扰这个DMA与外设之间的正常工作了

其实根本没有必要,因为你可以直接使用CPU(代码)进行复制

最后我们需要用DMA_Init和DMA_Cmd进行应用使能:

void DMA_Init(DMA_Channel_TypeDef* DMAy_Channelx, DMA_InitTypeDef* DMA_InitStruct);
void DMA_Cmd(DMA_Channel_TypeDef* DMAy_Channelx, FunctionalState NewState);

【注:这里不是写DMA1,而是端口加通道,如DMA1_Channel1】 

除了这个,我们还需要应用ADC的DMA,ADC1触发DMA1的信号:

ADC_DMACmd(ADC1, ENABLE);

至此,你已经学会DMA的配置了,我们回到刚才的双通道扫描采集配置上,如果你还记得的话

现在我们来实践完整地配置一下这个DMA吧:

uint16_t ADC[10];
RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1,ENABLE);
DMA_InitTypeDef DMA_InitStructure;
DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR;
DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)ADC;
DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC;
DMA_InitStructure.DMA_BufferSize = 2;
DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord;
DMA_InitStructure.DMA_PeripheralInc = DISABLE;
DMA_InitStructure.DMA_MemoryInc = ENABLE;
DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord;
DMA_InitStructure.DMA_Mode = DMA_Mode_Normal; //注:我们这里是单次采集,ADC并非连续采样
DMA_InitStructure.DMA_Priority = DMA_Priority_High;
DMA_InitStructure.DMA_M2M = DMA_M2M_Disable;
DMA_Init(DMA1_Channel1,&DMA_InitStructure);

DMA_Cmd(DMA1_Channel1,ENABLE);
ADC_DMACmd(ADC1,ENABLE);

如果你问我这个数组封装在了函数库,主函数如何调用,你可以回家了,或者重开了

神秘指数:⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐

然后稍微修改一下我们的主函数:

因为DMA是直接保存进我们的数组里,所以直接用数组转换就可以。

#include "BIF.h"
#include "OLED.h"

int main()
{
    OLED_Init();
    BIF_ADC_Init()
    OLED_ShowString(1, 1, "V:");
	OLED_ShowString(2, 1, "A:");
    ADC_SoftwareStartConvCmd(ADC1,ENABLE);
    while(1)
    {
        oltage = (float)ADC[0] / 4095 * 3.3;
		Current = (float)ADC[1] / 4095 * 3.3;
		snprintf(v,sizeof(v),"%.2f",Voltage);
		snprintf(A,sizeof(A),"%.2f",Current);
		OLED_ShowString(1, 3, v);
		OLED_ShowString(2, 3, A);	
        Delay_ms(100);
    }

}

但是很快,你会发现,这个函数烧录进去后显示屏给的结果是0,也就是说ADC根本没有读取到值?这是为什么呢?究竟是DMA没有传数据,还是ADC没有采集到值?我们可以用直接的代码直接获取当前的ADC值以进行检测:

 你会发现,ADC是采集到值的,那为什么DMA没有传输呢?

DMA传输的问题

这个时候做一个有趣的实验,你可以给DMA_Cmd使能加一个条件:

while(!DMA_GetFlagStatus(DMA1_FLAG_TC1));

你会发现你的显示屏卡在了这个DMA上,没有示数。

也就是说DMA它根本没有工作!摸🐟被发现了吧,这是因为DMA的时序和ADC不同步导致的。 

记录一下我看到它卡死的精神状态 - 已🐀勿扰 

这个时候,可能就应该放弃了,但是,我们的探索精神不允许,我想到一个办法,就是重复调用和配置DMA,就像我们单次非扫描模式进行多通道采集一样,重复调用。

然后我发现了一个很有趣的现象,就是DMA1的Channel1它似乎只返会第一次采样的值,储存在ADC[0]里,但是第二个通道一直都没有数据,也即是说MemoryInc并没有正确应用。

我很快想到一个问题,是1个对多个的问题,还是1个对1个的问题。

DMA是因为时序它只能采集到第一个呢,还是DMA它储存模式有问题,只保存了第一个呢?对于这个问题,我试着增加了一个通道-A2,这个时候你会发现,结果不出所料,仍然只有ADC[0]有值,也就是说时序问题是次要的,主要的是它这个内部问题。

虽然我不知道我到底再纠结什么,因为这个东西做下去好像真的没有什么意义。

这个时候我们不由而然地想到一个问题,那就是——DMA的多通道采集时需要开启循环模式才能支持的。我当时甚至去怀疑了我的函数库有问题(地址重映射有问题,单片机电路出错,以及单片机把A0A1分成了ADC1和ADC2,然后我整个干不动了)

然后我发现单次扫描模式和循环DMA模式下DMA根本没结果,还不如前面的普通模式。

哦,对了,这是我的调试代码,我不想再复原了,这块我解决后再发教程和大家分享,或者有大佬可以帮帮我

//BIF.c

uint16_t ADC[3]; //我当时怀疑这个系统内部是不是有一个叫ADC的数组

void BIF_ADC_Init()
{
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE);
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
	RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1,ENABLE);

    RCC_ADCCLKConfig(RCC_PCLK2_Div6);
    
    GPIO_InitTypeDef GPIO_InitStructure;
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN;
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0|GPIO_Pin_1|GPIO_Pin_2;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(GPIOA, &GPIO_InitStructure);

    ADC_RegularChannelConfig(ADC1,ADC_Channel_0,1,ADC_SampleTime_55Cycles5);
	ADC_RegularChannelConfig(ADC1,ADC_Channel_1,2,ADC_SampleTime_55Cycles5);
//	ADC_RegularChannelConfig(ADC1,ADC_Channel_2,3,ADC_SampleTime_55Cycles5);//测试

    ADC_InitTypeDef ADC_InitStructure;
	ADC_InitStructure.ADC_Mode = ADC_Mode_Independent;
	ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right;
	ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None;
	ADC_InitStructure.ADC_ContinuousConvMode = DISABLE;
	ADC_InitStructure.ADC_ScanConvMode = ENABLE;
	ADC_InitStructure.ADC_NbrOfChannel = 2;	
	ADC_Init(ADC1, &ADC_InitStructure);

    DMA_InitTypeDef DMA_InitStructure;
	DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR;
	DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)ADC;//觉得不保险可以:&ADC[0]
	DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC;
	DMA_InitStructure.DMA_BufferSize = 2;
	DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord;
	DMA_InitStructure.DMA_PeripheralInc = DISABLE;
	DMA_InitStructure.DMA_MemoryInc = ENABLE;
	DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; //16位
	DMA_InitStructure.DMA_Mode = DMA_Mode_Normal; //注:我们这里是单次采集
	DMA_InitStructure.DMA_Priority = DMA_Priority_Medium;
	DMA_InitStructure.DMA_M2M = DMA_M2M_Disable;
	DMA_Init(DMA1_Channel1,&DMA_InitStructure);

    ADC_Cmd(ADC1,ENABLE);
    ADC_ResetCalibration(ADC1);	
	while (ADC_GetResetCalibrationStatus(ADC1) == SET);
	ADC_StartCalibration(ADC1);
	while (ADC_GetCalibrationStatus(ADC1) == SET);
    
    ADC_DMACmd(ADC1,ENABLE);//这个位置没有严格要求
}
void BIF_ADC_Read(uint16_t *voltage,uint16_t *current)
{
	ADC_SoftwareStartConvCmd(ADC1,ENABLE);
	while(!ADC_GetFlagStatus(ADC1,ADC_FLAG_EOC));
	*voltage = ADC_GetConversionValue(ADC1);
	*current = ADC_GetConversionValue(ADC1);
	ADC_ClearFlag(ADC1,ADC_FLAG_EOC);
}
void BIF_DMA_Read(uint16_t *voltage,uint16_t *current)
{
 	DMA_Cmd(DMA1_Channel1,DISABLE);
 	DMA_SetCurrDataCounter(DMA1_Channel1,2);
	DMA_ClearFlag(DMA1_FLAG_TC1);
	ADC_ClearFlag(ADC1,ADC_FLAG_EOC);
 	DMA_Cmd(DMA1_Channel1,ENABLE);
// 	ADC_DMACmd(ADC1,ENABLE);
	ADC_SoftwareStartConvCmd(ADC1,ENABLE);
	while(!DMA_GetFlagStatus(DMA1_FLAG_TC1) && !ADC_GetFlagStatus(ADC1,ADC_FLAG_EOC));
//	*voltage = ADC_GetConversionValue(ADC1); //当时用来做内部测试
//	*current = ADC_GetConversionValue(ADC1);
 	ADC_ClearFlag(ADC1,ADC_FLAG_EOC);
 	DMA_SetCurrDataCounter(DMA1_Channel1, 2);  // 重置传输长度
//	DMA_ClearFlag(DMA1_FLAG_TC1);
}
//main.c
uint16_t ADValue;			//定义AD值变量
uint16_t voltage;
uint16_t current;
float Voltage;				//定义电压变量
float Current;
char v[10];
char A[10];

int main(void)
{
	OLED_Init();
	BIF_ADC_Init();
//	Voltage = (float)ADC[0] / 4095 * 3.3;
//		Current = (float)ADC[1] / 4095 * 3.3;
//		snprintf(v,sizeof(v),"%.2f",Voltage);
//		snprintf(A,sizeof(A),"%.2f",Current);
//		OLED_ShowString(1, 3, v);
//		OLED_ShowString(2, 3, A);
	OLED_ShowString(1, 1, "V:");
	OLED_ShowString(2, 1, "A:");
	OLED_ShowString(3, 1, "t1");
	OLED_ShowString(4, 1, "t2");

	while (1)
	{
		BIF_DMA_Read(&voltage,&current);
		OLED_ShowNum(1,3,ADC[0],4);
		OLED_ShowNum(2,3,ADC[1],4);
//		if (DMA_GetFlagStatus(DMA1_FLAG_TC1) == SET) 
//		{
//		OLED_ShowNum(1,3,ADC[0],4);
//		OLED_ShowNum(2,3,ADC[1],4);
//		}
		BIF_ADC_Read(&voltage,&current);
//		ADValue = AD_GetValue();
//		Voltage = (float)ADValue / 4095 * 3.3;
//		ADC_ReadScanChannel(&voltage,&current);
		
		OLED_ShowNum(3,3,(uint32_t)voltage,4);
		OLED_ShowNum(4,3,(uint32_t)current,4);
		Delay_ms(100);	
	}
}

我小小地修改了一下代码,如果有问题请告诉我,我保留了调试的痕迹,大家可以看一下,大部分都删掉了,太乱了。

注意那个重置传输长度,需要同步DMA配置一起更改

A0锁3.3V测试结果,这样也算双通道采集了,毕竟两个通道的数都采集到了。

哦,还有一个有趣的测试:你可能注意到BIF_DMA_Read函数传进去两个指针,这个当时是想把BIF_ADC_Read写到一起调用,结果DMA🐀了,它显示的数据和ADC一模一样,我忘记拍照片了,结果是V:4095,A:0000,t14095,t24095,我真的💀不想💬anything

-----这里是后面回来的我,我突然发现我代码写错了,我真的🐀了,配置没有错误:

DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable;//我以为是ENABLE
DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable;

这个keil竟然没有报错!这样修改完成后,代码是正正常的:

 

这样说明系统默认配置是DISABLE,我专门写错的pwp,就是为了告诉你们这件事情。

为什么A1的示数和T1,T2不同呢?如果你仔细看过我的代码就知道我是分两次调用的,不是同一次调用,所以示数不一样才对,而且A1和t1\t2的值很相近,你可以锁电压以进行测试。

再给大家一个课后小作业吧,你可以自己尝试去测试一下,DMA一次只搬运一个数据的结果是什么样的,你可以直观地感受DMA搬运的逻辑,快把你的结果分享到评论区或者B站报告一下吧

至此单次扫描完美落幕,但基本上能讲的东西都讲了,接下来看我们最常用的循环扫描模式+DMA轮询模式吧

正弦波采样

如果我没记错的话,应该是这个名字

我推荐的话你们可以直接去看江协的代码,DMA的数据搬运功能江协讲的十分详细了,代码也有,大家自己去运行一便感受一下就可以了

多通道ADC轮询采样江协也有这块现成的代码,上面单次扫描模式已经教给你如何改通道数量了,你不懂的话再问我,这里直接讲ADC和DMA的经典应用-正弦波采样

还记得最开篇的量化规则和频率设置所需要注意的细节吗?忘记了就回去看一眼

循环采样模式样板

这里我直接给大家展示一下连续扫描模式下多通道的ADC采样模板

uint16_t ADC[3]; //我当时怀疑这个系统内部是不是有一个叫ADC的数组

void BIF_ADC_Init()
{
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE);
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
	RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1,ENABLE);

    RCC_ADCCLKConfig(RCC_PCLK2_Div6);
    
    GPIO_InitTypeDef GPIO_InitStructure;
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN;
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0|GPIO_Pin_1|GPIO_Pin_2; //<-
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(GPIOA, &GPIO_InitStructure);

    ADC_RegularChannelConfig(ADC1,ADC_Channel_0,1,ADC_SampleTime_55Cycles5);
	ADC_RegularChannelConfig(ADC1,ADC_Channel_1,2,ADC_SampleTime_55Cycles5);
	ADC_RegularChannelConfig(ADC1,ADC_Channel_2,3,ADC_SampleTime_55Cycles5);//测试<-

    ADC_InitTypeDef ADC_InitStructure;
	ADC_InitStructure.ADC_Mode = ADC_Mode_Independent;
	ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right;
	ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None;
	ADC_InitStructure.ADC_ContinuousConvMode = ENABLE; //<-
	ADC_InitStructure.ADC_ScanConvMode = ENABLE;
	ADC_InitStructure.ADC_NbrOfChannel = 3; //<-	
	ADC_Init(ADC1, &ADC_InitStructure);

    DMA_InitTypeDef DMA_InitStructure;
	DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR;
	DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)ADC;//觉得不保险可以:&ADC[0]
	DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC;
	DMA_InitStructure.DMA_BufferSize = 3; //<-
	DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord;
	DMA_InitStructure.DMA_PeripheralInc = DISABLE;
	DMA_InitStructure.DMA_MemoryInc = ENABLE;
	DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; //16位
	DMA_InitStructure.DMA_Mode = DMA_Mode_Circular; //注:我们这里是单次采集
	DMA_InitStructure.DMA_Priority = DMA_Priority_Medium;
	DMA_InitStructure.DMA_M2M = DMA_M2M_Disable;
	DMA_Init(DMA1_Channel1,&DMA_InitStructure);
    
    DMA_Cmd(DMA1,Channel1,ENABLE);
    ADC_DMACmd(ADC1,ENABLE);
    ADC_Cmd(ADC1,ENABLE);
    ADC_ResetCalibration(ADC1);	
	while (ADC_GetResetCalibrationStatus(ADC1) == SET);
	ADC_StartCalibration(ADC1);
	while (ADC_GetCalibrationStatus(ADC1) == SET);
    
    ADC_SoftwareStartConvCmd(ADC1,ENABLE);
}

A0锁单片机3.3V,A1锁地,A2悬空。

这里你需要知道正弦波采样到底采样的是什么?

如果正常来说,我们直接采样正弦波的点似乎很简单,但是这样的数据我们交给PID根本无法处理,因为它是不断变化的,所以,我们应该把它稳定在一个值以进行评估当前的状态,那就是有效值计算。

RMS有效值计算

当然,你的正弦波输入范围要在0-3.3V之间。

V_{RMS} = \sqrt{\frac{1}{N}\sum_{i=1}^{N}V_i^{2}}

代码实现:

#include "math.h"

float result = 0.0f;
for(int i=0;i<BUFFER_SIZE;I++)
result += ADC[i] * ADC[i];
float rms = sqrt(result / BUFFER_SIZE);

是不是非常的简单,BUFFER_SIZE就是你单位周期采集的数据量,假设我们采集一个1KHz的波,我们需要至少2KHz采样率,但为了还原细节,一般是10KHz的采样频率。

BUFFER_{SIZE} = f_s \times T_{total}

Ttotal是什么?就是你采样的单位是什么,1KHz是波形的频率,T = 1ms,如果你仅仅考虑的是1ms,那么你的采集结果会因为单周期畸变(过零抖动)而导致RMS跳变,如果我们将时间窗口放长为10个周期,那么你单次计算出来的RMS-10ms的值,这样你的N就越大,更多的采样点取平均会让你的结果更加平滑。

[什么是过0抖动:周期性信号(如正弦波、方波)在通过零点(电压从正变负或从负变正的瞬间)时,由于噪声、硬件缺陷或采样误差导致的实际过零点时刻与理想过零点时刻之间的时间偏差。这种抖动会影响信号的时间精度,尤其在需要精确同步或相位测量的应用中]

后续更新设计电路(我学习电路设计)时会再讲这里的过零抖动-低EMI技术。

定时器简述

嘿,这里是后面的我😉我最终觉得你们可能无法看懂别人的定时器教程,我在这里简单给你们介绍一下这里ADC配置中所设计的定时器的知识,我简直是👼,还不点赞支持一波

(详细的定时器教程链接:(写好后这里是一个日期,插入了链接,你可以直接跳转))

定时器分为基本定时器(TIM6-7)、通用定时器(TIM2-5,9-14)、高级定时器(TIM1,TIM8)

这里,我们都是使用通用定时器触发中断,主要使用的功能是输入捕获。

定时器的基本工作原理

定时器的时钟来自:APB总线时钟,TIMx_ETR引脚,内部触发输入(ITRx其他的定时器输入,外部捕获引脚TIx)

时钟源怎么来的你不需要关心,我们只需要知道怎么配置就可以。

对于ADC来说它有两个重要参数你必须知道:

PSC

全称-Prescaler 预分频器

作用:降低定时器时钟频率

其工作逻辑就是100个东西传给PSC,你是计数器,然后设置PSC=4,PSC从0开始数,0-1-2-3-4,告诉你它数完了一次,然后它继续从0开始数, 直到100个东西数完,你一共说道来自PSC的20个信号,此时就是时钟频率降低了

因为是从0开始数,所以实际的预分频系数 = 预分频器值 +1

时钟频率 = 输入时钟频率 / (PSC+1)

ARR

Period - 自动重装载值

本质 - 16位计数器 (0~65535)

决定计数器的周期,当计数器的周期达到ARR的值时,根据计数模式产生相应事件

可以搭配预装载,这个时候ARR处理的就是PSC降频后的数据

定时周期 = 计数器时钟频率 / (ARR+1)

计数模式

对于ADC采样来说,你一定要知道的是STM32定时器的计数模式:

1.向上计数模式:

计数器从0计数到自动重装载值(ARR),然后重新从0开始,产生上溢时间

就好比,你的ARR是5,计数器从0开始数,0-1-2-3-4-5(ARR+1),到5后产生一个事件,并复位到0重新计数,那么这个时候比如说你给进来60个数,经过ARR后产生的事件只有10个

2.向下计数模式:

我当时看别人的定时器教程有人说没有向下计数模式,给博主吓了一跳,于是我去看了官方手册

向下计数模式就是从ARR向下数数,不多说

3.中央对齐模式

 就是把向上计数和向下计数合在一起视为一个周期,当他从0读到ARR,还要从ARR读到0后才会生成一个完成事件信号。

PSC和ARR区别

一些新手很难区分的清对于单个定时器来说,PSC和ARR的作用具体有什么差别,从功能上看,他们都是对输入信号进行降频。

在定义的时候也说明了,PSC是降低定时器时钟频率,因为定时器的输入时钟很高,72MHz,直接计数,计数器会溢出过快(65535的情况下0.9ms就会溢出,这会大大增加单片机的功耗,而且不方便于我们处理这么高频率的信号

实质上,直接控制触发中断的是ARR,由ARR触发中断信号

而且,STM32并不仅仅是考虑到这个,如果仅仅是增大这个STM32的降频范围,他可以只增加这个ARR的处理范围,因为A*B可以组合出更多的可能,生成更加多样的PWM波,后面的SPWM就会讲解。

而且,你还需要知道的是

定时器里是每个通道都配备了一个预分频器,不是所有通道走进一个预分频器然后处理。

计数模式区分

 你可能会想到,计数器的工作模式这么多,它们的本质都是计数,他区分这么多工作模式不是多此一举吗?

向上计数很简单,比如说我们开车的时候,油门踩下去,速度是一点点提上去的。

向下计数模式更适合于一些特定的需求,比如说你想模拟刹车,这个时候你用向下计数模式来模拟速度逐渐下降  - PWM 都可以直接以PWM为载体示例

中间对齐模式比如说我们需要对称的PWM,如H桥驱动,中心对其模式会简化死区的插入逻辑,在FOC算法里,就需要中心对齐的PWM来同步采样电流

定时器触发ADC采样

这个时候你一定会疑惑, ADC本身不是可以持续采样,DMA能够控制采样数量,为什么会需要用到定时器呢?

这就是时序的问题,在连续采样模式下,ADC的采样间隔由AC时钟和转换时间决定,但实际间隔中会包含系统延迟,终端延迟,总线冲突和谐等因素影响,这样的影响会导致我们的ADC自由采样点不均匀,感兴趣自己可以去尝试一下,虽然你测试完可能会跟我说它看起来听均匀的🙄

这样的非均匀采样会引入频谱泄漏(Spectral Leakage),影响精度,而我们使用定时器以固定频率触发ADC,确保每个采样点间隔严格一致。

均匀的采样是离散傅里叶变换(DFT)和RMS计算的前提条件,自由采样和定时器触发最直观的现象就是是否有相位漂移。

而且前面我记得说过一个例子,那个做鸡腿的,ADC自由采样就是人为控制,而定时器触发就是科技强国,自动控制,这下你应该明白了。

使用定时器触发不仅解放了CPU,还提高了精度,何乐而不为,如果你说你就是不想用定时器,你就是要与众不同🤓,你可以尝试用GPIO引脚设置定时信号,虽然这就是多此一举,但是满足了你的与众不同🙄💅💅,但其实那个就是模拟定时器的作用,简而言之,毫无用处

既然要使用定时器,不要告诉我你不会定时器。

使用定时器就要配置ADC时钟源,其中包括:

ADC预分频设置为12MHz就可以,同上面代码。

f_{TIM} = \frac{f_s}{BUFFER_{SIZE}}(如果需要实时处理,那么你可以配置为触发单次采样)

触发频率:f_{trigger} = \frac{72MHz}{(PSC+1) *(ARR+1) }

那么对于我们要采集的1KHz ,我们应该怎么设置这个BUFFER_SIZE PSC 和 ARR呢

PSC和ARR值的计算和选择

这也是很多新手会面临的一个问题,在采集正弦波的时候困在这里

而这种问题,不仅在配置ADC的定时器上出现,在未来还有BUCK/BOOST电路的电容、电阻、电感、MOS管、二极管的选择和计算,就这一章对应的SPWM也是设计这个问题的。

对于这种配置的问题,往往是靠经验积累而成,没经验的我们怎么配置?

我们首先先列出已知量和目标量:

已知量:正弦波频率->1KHz

未知量:BUFFER_SIZE、PSC、ARR

用一个数来计算3个未知的数?这是在痴人说梦吧。

这就是我们的误区:约束 而非 计算

但实际上,从最开始我就讲的一个理念:约束、区间:把值配置在一个合理的区间就是我们的最终目标,而不是把值锁死在一个目标上。如果你是高精度要求的系统,你肯定有更多的限制和已知量去约束未知量,从而减小未知量的可取区间,这个可取区间>=1, 

1KHz的采样周期是1ms,为了让系统最终转换的RMS值更加稳定,又要保证系统的实时性,你给自己一个设想的值,比如说人眼的视觉停留时间是0.5s,500ms,我们肯定不能大于这个值。那么我们就随便一点,10个周期,20个周期,这是为了在精度范围内尽可能地提高转换效率。

其实5-20个周期都没有什么关系啦,我们取个比较中间的值,就10吧。(不要强,我就是这么随便配置的,一般就是5-10个周期)

全局调用的话,你就直接宏定义

采样频率我们一直都说>=10倍的信号频率,我们就试一下10KHz能不能正常采样,M是这样的

#define BUFFER_SIZE 100 // 10 * 10

然后全局调用这个头文件就可以啦,不会的去🐀

此时,怕你看不懂,我再标注一下采样频率fs = 10KHz,采样周期Ttotal为10ms

BUFFER_SIZE就是你一个采样周期为100个点

这个时候就要选择PSC和ARR,我们前面讲了这个定时器的触发中断频率的公式,也就是让f_trigger = 10KHz(采样频率),然后你就配嘛:

1.固定ARR,计算PSC,一般我们的TIM_CLK都是72MHz,我们如果想要让它变成10KHz,

我们首先要把这个72M变成720KHz,然后720 / 72 = 10,用这样最简单的计算方式带入约束

(1MHz = 1000KHz)

那么我们就可以把ARR设置为100-1,PSC设置为72-1,这种格式很容易看懂。

2.固定PSC,计算ARR,两个值换一下,都试试就可以,这样你就掌握了,是不是很简单

为了防止你不会,我们后面有一个作业,大家记得完成试一下,参考答案晚点会更新进文章

TIM代码配置

现在我们开始配置TIM啦,我一般使用TIM2,大多数人喜欢TIM3,其实都一样,没想到,写单片机还可以看到看个人喜好这句话,感觉我有点不专业,哈哈

//BIF.c
#include "stm32f10x.h"                  // Device header
#include "math.h"
#include <stdint.h>

#define BUFFER_SIZE 100
uint16_t ADC[100];
float sum =0;

void BIF_ADC_Init()
{
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1 | RCC_APB2Periph_GPIOA , ENABLE);
	RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1,ENABLE);
	RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3,ENABLE);
	
	GPIO_InitTypeDef GPIO_InitStructure;
	ADC_InitTypeDef ADC_InitStructure;
	DMA_InitTypeDef DMA_InitStructure;
	TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStructure;
	
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN;
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; //不写这句话也没关系,默认
	GPIO_Init(GPIOA,&GPIO_InitStructure);
	
	ADC_InitStructure.ADC_ContinuousConvMode = DISABLE; //TIM触发
	ADC_InitStructure.ADC_ScanConvMode = DISABLE; //我们这里是单通道
	ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right;
	ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_T3_TRGO; //TIM3触发源
	ADC_InitStructure.ADC_Mode = ADC_Mode_Independent;
	ADC_InitStructure.ADC_NbrOfChannel = 1;
	ADC_Init(ADC1,&ADC_InitStructure);
	
	ADC_RegularChannelConfig(ADC1,ADC_Channel_0,1,ADC_SampleTime_55Cycles5);
	
	DMA_DeInit(DMA1_Channel1);
	DMA_InitStructure.DMA_Mode = DMA_Mode_Circular;
	DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)ADC1->DR;
	DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)ADC;
	DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC;
	DMA_InitStructure.DMA_BufferSize = 100;
	DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord;
	DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable;
	DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable;
	DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord;
	DMA_InitStructure.DMA_Priority = DMA_Priority_High;
	DMA_InitStructure.DMA_M2M = DMA_M2M_Disable;
	DMA_Init(DMA1_Channel1,&DMA_InitStructure);
	
	DMA_ClearFlag(DMA1_FLAG_TC1);
	DMA_ITConfig(DMA1_Channel1,DMA_IT_TC,ENABLE);
	
	TIM_TimeBaseInitStructure.TIM_Period = 100-1; //ARR=99
	TIM_TimeBaseInitStructure.TIM_Prescaler = 72-1; //PSC=71
	TIM_TimeBaseInitStructure.TIM_ClockDivision = 0;
	TIM_TimeBaseInitStructure.TIM_CounterMode = TIM_CounterMode_Up;
//	TIM_TimeBaseInitStructure.TIM_RepetitionCounter = ?? ->这是高级定时器的功能
	TIM_TimeBaseInit(TIM3,&TIM_TimeBaseInitStructure);
	TIM_CtrlPWMOutputs(TIM3,ENABLE);
	
	ADC_Cmd(ADC1,ENABLE);
	
	ADC_ResetCalibration(ADC1);	
	while (ADC_GetResetCalibrationStatus(ADC1) == SET);
	ADC_StartCalibration(ADC1);
	while (ADC_GetCalibrationStatus(ADC1) == SET);
	
	ADC_DMACmd(ADC1,ENABLE);
	TIM_SelectOutputTrigger(TIM3,TIM_TRGOSource_Update);
	TIM_Cmd(TIM3,ENABLE);
	DMA_Cmd(DMA1_Channel1,ENABLE);
	
}

uint16_t i=0;

float Calculate()
{
	sum = 0;
	for(i=0;i<BUFFER_SIZE;i++)
	sum += ADC[i] * ADC[i];
	return sqrt(sum/BUFFER_SIZE);
}
#include "stm32f10x.h"                  // Device header
#include "OLED.h"
#include "BIF.h"
#include "stdio.h"
#include "Delay.h"

float rms;
char arr[10];

int main()
{
	OLED_Init();
	BIF_ADC_Init();
	
	OLED_ShowString(1,1,"tru");
	OLED_ShowString(2,1,"rms");
	
	ADC_ExternalTrigConvCmd(ADC1,ENABLE);
	
	while(1)
	{
		rms = Calculate();
		OLED_ShowNum(1,4,ADC[99],4);
		snprintf(arr,sizeof(arr),"%.2f",rms);
		OLED_ShowString(2,4,arr);
	}

}

因为这个ADC采集正弦波确实有点小的难度,和我之前没有讲到的点,所以这个就把完整的工程放出来,但是如果你真的去运行,会发现这个代码和之前一样卡住了!

真的去🐀吧DMA,我真的受够你了。这次并不是因为编写错误,而是内部少了一点东西,大家对照我上面配置的DMA看一下少了什么东西pwp

一会我会带着从头配置一边,现在先看你的基础和代码感觉。没错,那就是我们缺少NVIC中断的配置,而且,TIM2不能够作为ADC的触发源(前面表格展示过),而且作为中断,我们却仍然使用的是软件启动,而且我们需要来设置系统时钟,上面这个代码是我第一次写的ADC采集正弦波代码,下面我们开始从头配置一下吧:

正确的-正弦波采样

上面主要是讲这个配置的大体流程,顺便来检测大家的基础,不会的自己去补吧OvO

成果展示在我的主页里面:

成果展示

RCC时钟配置:

void BIF_RCC_Configuration(void)
{
    ErrorStatus HSEStartUpStatus;
    RCC_DeInit();
    RCC_HSEConfig(RCC_HSE_ON);
    HSEStartUpStatus = RCC_WaitForHSEStartUp();
    if(HSEStartUpStatus == SUCCESS)
    {
        RCC_HCLKConfig(RCC_SYSCLK_Div1);
        RCC_PCLK2Config(RCC_HCLK_Div1);
        RCC_PCLK1Config(RCC_HCLK_Div2);
        FLASH_SetLatency(FLASH_Latency_2);
        FLASH_PrefetchBufferCmd(FLASH_PrefetchBuffer_Enable);
        RCC_PLLConfig(RCC_PLLSource_HSE_Div1, RCC_PLLMul_9);
        RCC_PLLCmd(ENABLE);
        while(RCC_GetFlagStatus(RCC_FLAG_PLLRDY) == RESET);
        RCC_SYSCLKConfig(RCC_SYSCLKSource_PLLCLK);
        while(RCC_GetSYSCLKSource() != 0x08);
    }
    RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE);
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1 | RCC_APB2Periph_GPIOA, ENABLE);
    RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE);
}

GPIO引脚陪配置

void GPIO_Configuration(void)
{
    GPIO_InitTypeDef GPIO_InitStructure;
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN;
    GPIO_Init(GPIOA, &GPIO_InitStructure);
}

TIM定时器配置

void TIM3_Configuration(void)
{
    TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure;
    TIM_TimeBaseStructure.TIM_Period = 72 - 1;
    TIM_TimeBaseStructure.TIM_Prescaler = 100 - 1;
    TIM_TimeBaseStructure.TIM_ClockDivision = 0;
    TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up;
    TIM_TimeBaseInit(TIM3, &TIM_TimeBaseStructure);
    TIM_SelectOutputTrigger(TIM3, TIM_TRGOSource_Update);
    TIM_Cmd(TIM3, ENABLE);
}

DMA配置:

void DMA_Configuration(void)
{
    DMA_InitTypeDef DMA_InitStructure;
    DMA_DeInit(DMA1_Channel1);
    DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR;
    DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)ADC;
    DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC;
    DMA_InitStructure.DMA_BufferSize = BUFFER_SIZE;
    DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable;
    DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable;
    DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord;
    DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord;
    DMA_InitStructure.DMA_Mode = DMA_Mode_Circular;
    DMA_InitStructure.DMA_Priority = DMA_Priority_High;
    DMA_InitStructure.DMA_M2M = DMA_M2M_Disable;
    DMA_Init(DMA1_Channel1, &DMA_InitStructure);
    DMA_Cmd(DMA1_Channel1, ENABLE);
}

ADC配置:

void ADC_Configuration(void)
{
    ADC_InitTypeDef ADC_InitStructure;
    ADC_InitStructure.ADC_Mode = ADC_Mode_Independent;
    ADC_InitStructure.ADC_ScanConvMode = DISABLE; // 单通道
    ADC_InitStructure.ADC_ContinuousConvMode = DISABLE; // 触发转换
    ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_T3_TRGO;
    ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right;
    ADC_InitStructure.ADC_NbrOfChannel = 1;
    ADC_Init(ADC1, &ADC_InitStructure);
    ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5);
    ADC_DMACmd(ADC1, ENABLE);
    ADC_Cmd(ADC1, ENABLE);
    ADC_ResetCalibration(ADC1);
    while(ADC_GetResetCalibrationStatus(ADC1));
    ADC_StartCalibration(ADC1);
    while(ADC_GetCalibrationStatus(ADC1));
}

NVIC配置:

void NVIC_Configuration(void)
{
    NVIC_InitTypeDef NVIC_InitStructure;
    NVIC_InitStructure.NVIC_IRQChannel = DMA1_Channel1_IRQn;
    NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 0;
    NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0;
    NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
    NVIC_Init(&NVIC_InitStructure);
    DMA_ITConfig(DMA1_Channel1, DMA_IT_TC, ENABLE);
}

RMS计算

float CalculateRMS(uint16_t *buffer, uint16_t size)
{
    uint32_t sum = 0;
    float rms = 0.0;
    for(uint16_t i = 0; i < size; i++)
    {
        sum += (uint32_t)buffer[i] * buffer[i]; 
	    OLED_ShowNum(3,4,i,4); //测试i
    }
    rms = sqrt((float)sum / size);
    return rms;
}

BIF.h里

#ifndef __BIF_H
#define __BIF_H

#include <stdint.h>
#define BUFFER_SIZE 100
extern uint16_t ADC[100];
extern float rms;
extern float sum;

void RCC_Configuration(void);
void GPIO_Configuration(void);
void TIM3_Configuration(void);
void DMA_Configuration(void);
void ADC_Configuration(void);
void NVIC_Configuration(void);
float CalculateRMS(uint16_t *buffer, uint16_t size);

#endif

main.c

#include "stm32f10x.h"                  // Device header
#include "OLED.h"
#include "BIF.h"
#include "stdio.h"
#include "Delay.h"
#include "math.h"

char arr[10];
uint16_t real_num;
int main()
{
	RCC_Configuration();
   GPIO_Configuration();
   NVIC_Configuration();
   DMA_Configuration();
   TIM3_Configuration();
   ADC_Configuration();
	OLED_Init();
    ADC_SoftwareStartConvCmd(ADC1, ENABLE);
	OLED_ShowString(1,1,"tru"); //采集实际值
	OLED_ShowString(2,1,"rms"); //RMS值
	OLED_ShowString(3,1,"i:"); //测试是否在计算
	OLED_ShowString(4,1,"num"); //ADC1的外设地址值
	while(1)
	{
		rms = CalculateRMS(ADC, BUFFER_SIZE);
		real_num = *(&ADC1->DR);
		OLED_ShowNum(4,4,real_num,4);
		OLED_ShowNum(1,4,ADC[0],4);
		snprintf(arr,sizeof(arr),"%.2f",rms);
		OLED_ShowString(2,4,arr);
	}
}

 这就是一个标准的STM32进行ADC采集正弦波,具体的讲解放会在进阶篇讲解,今天已经临近23:00了,该睡觉了,不挣扎了。

至于双通道,多通道我们等进阶篇再讲,就这样了,博主要🐀了。

那么有什么问题在评论区问我。

评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值