STM32单片机学习(32) —— ADC

文章目录

ADC简介

传感器模块概述

在我们的配件盒当中,有几个比较常见的传感器模块,如下图所示。它们分别是:

光敏电阻传感器模块:

光敏电阻传感器用于检测环境光照强度,其核心器件为光敏电阻(LDR)。

其工作原理如下:

  1. 光照越强 → 光敏电阻的电阻越小
  2. 光照越弱 → 光敏电阻的电阻越大

模块内部采用分压电路设计,将光敏电阻随光照而改变的阻值变化转换为电压变化输出。

输出形式有两种:

  1. 模拟输出(AO):直接输出连续变化的电压信号(模拟信号),用于 ADC 采集
  2. 数字输出(DO):通过LM393电压比较器,配合电位器设定阈值,最终输出高低电平信号(0或1)。

温度电阻传感器模块:

温度电阻传感器通常采用热敏电阻(NTC)作为核心元件。

其工作原理如下:

  1. 温度升高 → 热敏电阻的阻值减小(NTC特性)
  2. 温度降低 → 热敏电阻的阻值增大

同样通过分压电路将电阻变化转换为电压输出。

原理和光敏电阻传感器是一模一样的,只是采集的物理信号量改变了。


气敏电阻传感器模块(MQ系列空气传感器):

MQ 系列气体传感器是一类常见的气体检测模块,如 MQ-2、MQ-135 等。

其核心器件为半导体气敏电阻(SnO₂ 材料)。

其工作原理如下:

  1. 内部加热丝将敏感材料加热至工作温度
  2. 气体与材料表面发生化学反应
  3. 改变材料导电能力
  4. 导致电阻值发生变化

最终表现为:

气体浓度变化 → 电阻变化 → 电压变化

最终仍然通过分压电路将电阻变化转换为电压输出。


以上三类传感器虽然检测对象不同,但具有一个共同特点:

它们的核心本质都是"电阻随物理量变化"。

系统中的信号转换过程可以统一表示为:

某种被监测的物理量改变 → 电阻变化 → 分压电路 → 电压信号 → ADC采集 / 比较起输出高低电平


在前面的课程学习中,我们已经知道如何使用传感器模块的DO口输出:

  1. 输出1表示一种状态
  2. 输出0表示一种状态

所以DO口的输出显然是一种数字输出模式。

它虽然能表示两种和物理量相关的状态,但还是不够直观的表示物理量本身。

今天我们来学习传感器模块的AO口输出功能,通过采集AO口的输出,就可以更直接的获取物理量的相关数据。

当然,我们首先需要明确的一点是:

传感器模块的 AO 口输出的是连续变化的电压信号,属于模拟信号输出。

模拟信号和数字信号

关于模拟信号和数字信号,我们之前已经简单提到过。

它们之间的区别,可以用一句话概括:

模拟信号是连续的,数字信号是跳变的、离散的。

自然界中的物理量(如温度、光照、声音、电压等)本质上都是连续变化的,因此属于模拟信号。

从理论上来说:

模拟信号在时间和数值上都是连续的,其精度可以无限细分。

也就是说:

任意两个模拟信号的数值之间,还可以继续细分出无限多的数值。

正因为这一点:

我们实际上无法精确地记录或存储一个真正的模拟信号。

比如:

我的身高是180cm,真的是刚好180吗?

不可能"刚好就是 180.000000...... cm",它一定是一个非常精细的连续值,只是我们做了近似表达。

而数字信号,则是对模拟信号的一种处理结果。

数字信号是通过对模拟信号进行"采样"和"量化"得到的离散数值。

所以:

数字信号其实可以理解成,是对模拟信号在某一个时刻的一种"近似表示",是一种为了便于表述和存储使用的估值和约数。

总之:

  1. 所谓模拟信号,是出现在自然界当中的数据信号,其数据随时间连续变化,数据的精度理论上无限大。真实,但不易描述和存储。
  2. 所谓数字信号,是对模拟信号的近似表示和约数,它的数值随时间离散变化,数据的精度是有限的。数据不真实,但易于描述和存储。

比如参考下图:

自然界的温度是连续变化的,而且温度的数值理论上是精度无限的,这就是模拟信号。

模拟信号是无法直接存储在计算机当中的:

因为计算机进行数据采集存储总会存在一个时间间隔,而且计算机由于存储位数的限制,也不可能存储无限精度的数据。


现在传感器模块的AO口输出的是连续变化的电压模拟信号,单片机肯定是无法直接保存使用的。

还需要经历一步:将模拟电压信号,转换成数字电压信号的过程。

此时就需要使用单片机的ADC外设。

在嵌入式系统当中,外围传感器采集模拟信号数据后,需要使用ADC模块转换成数字信号,然后才可以进行存储。

ADC概述

ADC也是单片机内一种常见,常用的片内外设。我们可以从ADC这个名字入手,来探究一下什么是ADC。

**ADC(Analog-to-Digital Converter)**即模拟信号 - 数字信号转换器,是嵌入式系统中实现模拟信号数字化的核心功能模块。

在实际的嵌入式系统中,各类传感器通常会将外界的物理量(如温度、光照、气体浓度等),转换为随之变化的电压信号。

随后,ADC(模数转换器)模块会对该电压信号进行处理,将其转换为对应的电压数字量(离散数值)。

这样,程序员就可以通过读取 ADC 的电压数字值结果,间接获取当前物理量的近似值。

举一个例子,以温度传感器为例:

  1. 外界温度发生变化
  2. 热敏电阻的阻值发生变化
  3. 通过分压电路转换为电压信号(模拟量)
  4. ADC 将该模拟电压量转换为数字电压量
  5. 程序员获取这个数字电压量,从而更直观的在数字上感受温度发生了改变。
  6. 当然,通过这个数字电压量,还可以通过公式来计算近似温度值。

总结流程如下:

温度改变 → 热敏电阻的阻值变化 → 电压变化(模拟量) → ADC → 电压数字量 → 温度计算

ADC模数转换的工作原理

单片机中的ADC外设,其核心作用就是:把连续变化的电压(模拟量),转换为离散的数字值。

也就是"模数转换"。

那么,它是如何完成这个过程的呢?

核心步骤是两步:

  1. 采样,在某一个时刻,读取当前的电压值,就像"拍一张照片"。
    1. 模拟信号是连续变化的,要想转换为一个固定的数字值,首先必须在某一时刻"固定住"这个信号
    2. 采样的频率越高,对原始信号连续变化特点的还原就越准确。
  2. 量化,将采样得到的电压值,用一个整数进行表示,这个整数值就是电压的数字值。

ADC 的本质就是:在某一时刻测量电压,并用一个数字去表示这个电压。

采样、采样时间和采样频率

ADC一次转换,大致经历两个阶段:

  1. 采样时间,ADC外设内部有一个电容器件,采样本质上就是"电容"充电的过程。
    1. 采样时间是ADC的可选配置参数。
    2. 采样时间越长,电容有更充足的时间"充电",采样结果会更接近真实电压。
    3. 采样时间短,单次采样的总时间就越短,可以实现更高的采样速度。但如果电容充电不足,可能导致采样的数据偏小或不稳定。
  2. 转换时间,把模拟电压信号转换成数字信号。在STM32中,这个时间是固定的,为12.5个ADC时钟周期。

一次ADC转换总时间:

ADC一次转换总时间 = 采样时间 + 12.5 cycles

ADC的采样频率计算公式:

采样频率 = ADC时钟 / (采样时间 + 12.5)


所以,如果想要ADC采样的快,比如1s内采样个十万次,那么就要尽量:

  1. 提高ADC时钟
  2. 降低采样时间

但需要注意:

采样频率的提升,通常是以一定的准确性为代价的。

因为采样时间缩短,电容充电不充分,可能导致采样结果产生误差。

如果不追求高速采样,建议尽量选择较长的采样时间,这样可以获得更稳定、更准确的采样结果。

量化、量化值和分辨率

采样阶段得到的是某一时刻的"模拟电压值",理论上这个电压是精度无限的。

例如:

2.0131000...

省略号表示精度无限

量化的过程,就是把这个连续电压,映射为一个有限精度的数字值。

那么ADC模块是如何完成量化过程的呢?

画了一个简化的ADC量化过程工作原理图:

其核心就在于:

STM32F103C8T6单片机的ADC是一个12位逐次逼近型ADC。

那么什么是12位逐次逼近型ADC呢?

可以简单理解为:

ADC通过"不断试探"的方式,用一个12位的数字,逐步逼近输入电压的真实值。

其基本工作过程如下:

  1. ADC内部有一个DAC(数模转换器)和一个电压比较器
  2. 从最高位(MSB)开始,逐位尝试将12位数据寄存器的对应位设为1
  3. DAC转换器根据当前数字值生成一个参考电压(数字信号转换成模拟信号)
  4. 比较该电压与ADC模拟输入电压的大小
    1. 若DAC电压 > 输入电压 → 说明电压没达到这么高,当前位清零
    2. 若DAC电压 ≤ 输入电压 → 说明电压可能更高,当前位置位保留
  5. 继续向低位判断每一位,直到最低位(LSB)

最终得到一个12位的数字结果,即量化值
举一个具体例子:

假设参考电压为3.3V,输入电压为2.0131.......V

由于是12位ADC,总共有4096个量化等级。

每一级对应的电压约为:3.3 / 4096 ≈ 0.000805V

接下来从最高位开始试探,过程如下:

首先试探 bit11(最高位):

1000 0000 0000 = 2048

对应电压:

2048 / 4096 × 3.3

= 0.5 × 3.3

= 1.65V

因为 1.65V < 2.0131....V

这说明1000 0000 0000表示的电压可能还不够,保留最高位的1,继续向下试探。

试探 bit10:

1000 0000 0000 = 2048 + 1024 = 3072

对应电压:

3072 / 4096 × 3.3

= 0.75 × 3.3

= 2.475V

因为 2.475V > 2.0131V

这说明1100 0000 0000表示的电压已经超了,所以bit10位的1丢弃清理,继续向下试探。

当前结果:

1000 0000 0000

试探 bit9:

1010 0000 0000 = 2048 + 512 = 2560

对应电压:

2560 / 4096 × 3.3

= 0.625 × 3.3

= 2.0625V

因为 2.0625V > 2.0131V

这说明1010 0000 0000表示的电压已经超了,所以bit9位的1丢弃清理,继续向下试探。

当前结果:

1000 0000 0000

试探 bit8:

1001 0000 0000 = 2048 + 256 = 2304

对应电压:

2304 / 4096 × 3.3

= 0.5625 × 3.3

≈ 1.856V

因为 1.856V < 2.0131V

这说明1001 0000 0000表示的电压可能还不够,所以bit8位的1保留,继续向下试探。

当前结果:

1001 0000 0000

试探 bit7:

1001 1000 0000 = 2304 + 128 = 2432

对应电压:

2432 / 4096 × 3.3

≈ 0.59375 × 3.3

≈ 1.959V

因为 1.959V < 2.0131V

这说明1001 1000 0000表示的电压可能还不够,所以bit7位的1保留,继续向下试探。

当前结果:

1001 1000 0000

试探 bit6:

1001 1100 0000 = 2432 + 64 = 2496

对应电压:

2496 / 4096 × 3.3

≈ 0.609375 × 3.3

≈ 2.010V

仍然略小于 2.0131V → bit6 保留

当前结果:

1001 1100 0000

试探 bit5:

1001 1110 0000 = 2496 + 32 = 2528

对应电压:

2528 / 4096 × 3.3

≈ 0.6171875 × 3.3

≈ 2.036V

因为 2.036V > 2.0131V

说明超了,bit5 清零

当前结果:

1001 1100 0000

试探 bit4:

1001 1101 0000 = 2496 + 16 = 2512

对应电压:

2512 / 4096 × 3.3

= 0.61328125 × 3.3

≈ 2.024V

因为 2.024V > 2.0131V

说明超了,bit4 清零

当前结果:

1001 1100 0000

试探 bit3:

1001 1100 1000 = 2496 + 8 = 2504

对应电压:

2504 / 4096 × 3.3

= 0.611328125 × 3.3

≈ 2.017V

因为 2.017V > 2.0131V

说明超了,bit3 清零

当前结果:

1001 1100 0000

试探 bit2:

1001 1100 0100 = 2496 + 4 = 2500

对应电压:

2500 / 4096 × 3.3

= 0.6103515625 × 3.3

≈ 2.014V

因为 2.014V > 2.0131V

说明还是略大,bit2 清零

当前结果:

1001 1100 0000

试探 bit1:

1001 1100 0010 = 2496 + 2 = 2498

对应电压:

2498 / 4096 × 3.3

= 0.60986328125 × 3.3

≈ 2.0135V

因为 2.0135V > 2.0131V

说明略大,bit1 清零

当前结果:

1001 1100 0000

试探 bit0(最低位):

1001 1100 0001 = 2496 + 1 = 2497

对应电压:

2497 / 4096 × 3.3

= 0.609619140625 × 3.3

≈ 2.0127V

因为 2.0127V < 2.0131V

所以 bit0 保留为 1

当前结果:

1001 1100 0001

最终结果为:

1001 1100 0001 = 2497

对应电压约为:

2497 / 4096 × 3.3 ≈ 2.0127V

可以看到,ADC 经过从 bit11 到 bit0 的逐位试探,

最终得到了一个最接近输入电压 2.0131......V 的 12 位整数值 2497。

这个过程的本质就是:

从高位到低位逐位试探,每试探一位,就比较一次大小,若超过输入电压则清零,未超过则保留。


ADC 会把参考电压范围划分为 份(N 为位数),位数越高,划分越细,每一份所对应的电压区间就越小。

在专业术语中,这个"最小可分辨的电压变化量"称为:ADC 的分辨率(Resolution)

其计算公式是:

ADC分辨率 = 参考电压 / 2^N

例如:

  1. 12位ADC,它的分辨率是:3.3 / 4096 ≈ 0.8mV
  2. 16 位 ADC,它的分辨率是:3.3 / 65536 ≈ 0.05mV

所以,很显然,我们可以得到一个结论:

ADC 的位数越多,精度越高。

ADC位数越高 → ADC分辨率越小 → 电压区分能力越强 → ADC测量越精细

但需要注意:

位数只是理论精度,实际精度还会受到噪声、电源、硬件电路等因素影响。

量化值和实际物理值

在 ADC 转换过程中,输出结果是一个数字量,称为量化值

例如 12 位 ADC 外设模块,其输出量化值取值范围为 0 ~ 4095。

该值本质上只是对输入电压的离散编码,本身不直接具有物理意义。

实际物理值(Physical Value)指的是对应的真实量,例如电压、温度或光照强度等。

如果你需要真实的物理值数据,那么首先你需要把量化值转换成输入电压数值:

输入电压 = 量化值 / × 参考电压

得到这个输入电压后,可以根据传感器的参考手册,亦或者根据经验总结,可以将电压值进一步转换成真实物理量。


比如:

参考电压为 3.3V,ADC 为 12 位,某次采样得到的量化值为:3000。

代入公式计算:

输入电压 = 3000 / 4096 × 3.3 ≈ 0.7324 × 3.3 ≈ 2.42V

可以看出:

量化值 3000 对应的实际电压约为 2.42V

ADC外设的使用

下面结合单片机上具体的 ADC 外设,来讲解 ADC 的实际使用方法。

也就是讲一下ADC外设,如何配置,如何让它可以正常工作。

首先,我们使用的STM32F103C8T6单片机一共有2个ADC外设,都挂载在APB2外设总线上,如下图所示:

通常来说,通常只需要选择其中一个 ADC 即可满足需求,比如ADC1。

ADC的引脚选择

首先,若想要使用ADC功能,完成模数转换,传感器的AO口必须接入特定ADC引脚。

如下图所示:

其中,PC0~PC3这四个引脚,我们使用的单片机是没有的。

所以能使用的ADC引脚实际只有10个,即单片机可用的外部ADC通道有10个。

即:

  1. PA0 ~ PA7,ADC12_IN0 ~ ADC12_IN7,一共8个通道。
  2. PB0 对应 ADC12_IN8
  3. PB1 对应 ADC12_IN9

考虑到:

  1. USART2占用PA2和PA3
  2. SPI1占用PA5、PA6、PA7

排除这些冲突引脚以后,还能用于 ADC 采样的就只剩下:

  1. PA0 → ADC12_IN0
  2. PA1 → ADC12_IN1
  3. PA4 → ADC12_IN4
  4. PB0 → ADC12_IN8
  5. PB1 → ADC12_IN9

也就是说:

只有5个引脚PA0、PA1、PA4、PB0、PB1,可以用于连接传感器模块的AO口。

为什么是ADC12_XX

在 STM32F103 系列单片机中,经常可以看到类似 ADC12_IN0、ADC12_IN1 这样的标注方式。

这里的 ADC12 并不是表示"第12个ADC",而是表示:

该通道同时连接到 ADC1 和 ADC2 两个 ADC 外设。

也就是说:

  1. ADC12_IN0 表示:该引脚既可以作为 ADC1 的通道0,也可以作为 ADC2 的通道0
  2. ADC12_IN1 表示:该引脚既可以被 ADC1 使用,也可以被 ADC2 使用

本质上,这些模拟输入引脚在硬件上是共享给 ADC1 和 ADC2 的,只是通过内部多路复用器选择由哪一个 ADC 来进行采样。

因此:

ADC12_INx 的含义可以理解为:该引脚对应 ADC1 和 ADC2 的第 x 号通道

在实际使用中:

  1. 如果使用 ADC1,则配置为 ADC1 的通道 x
  2. 如果使用 ADC2,则配置为 ADC2 的通道 x

但需要注意:

同一个通道在同一时刻只能被一个 ADC 使用,不能同时被 ADC1 和 ADC2 采样。

这种命名方式的目的,是为了明确说明该引脚资源是两个 ADC 外设共享的。

ADC外设框图

从《STM32F1参考手册》中,我们可以获取以下ADC外设框图:

对我们而言,完整搞懂这个框图并没有太多必要,我们可以将框图精简成如下图所示:

下面来解释一下这个简化框图。

简化ADC外设框图讲解

首先从最左上方的电源与参考电压部分开始。

ADC 的转换必须依赖参考电压和模拟电源,包括:

  1. VREF+、VREF-:用于确定 ADC 的测量范围(通常为 0 ~ 3.3V)
  2. VDDA、VSSA:分别是模拟电源和模拟地,为 ADC 提供工作电源,可以理解成为片上外设供电。

可以理解为:

ADC 的测量范围和精度,首先由这一部分决定。

接下来是模拟信号的输入部分。

包括外部引脚 ADCx_IN0 ~ ADCx_IN15,以及内部信号(温度传感器、VREFINT)。

这些信号通过 GPIO 进入 ADC,本质上是待测的"模拟电压来源"。

然后进入模拟多路开关(多路复用器)。

由于 ADC 一次只能转换一个通道,因此需要在多个输入之间进行选择。

规则通道最多支持 16 个通道轮询,注入通道最多支持 4 个通道。

这一步的本质是:

从多个输入中选出当前要转换的那个通道。

之后信号分为两条路径:规则通道和注入通道。

规则通道用于常规采样,按设定顺序依次转换;

注入通道优先级更高,可以在规则通道转换过程中插入执行。

这一部分体现的是 ADC 的通道调度机制。

在规则通道中,还涉及两个非常重要的工作模式:扫描模式和连续转换模式。

扫描模式(Scan Mode):

  1. 当配置多个通道时,ADC 会按照设定的顺序,依次对多个通道进行转换。
  2. 如果关闭扫描模式,则每次只转换一个通道。

连续转换模式(Continuous Mode):

  1. 开启后,ADC 在完成一次转换后,会自动再次启动下一次转换;
  2. 关闭时,则每次转换都需要重新触发。

可以理解为:

  1. 扫描模式决定"采几个通道";
  2. 连续转换决定"采一次还是一直采"。

规则通道用于常规采样,按照配置好的顺序依次转换;

注入通道优先级更高,可以在规则通道执行过程中插入转换。

这一部分体现的是 ADC 的通道调度机制。

接下来是触发控制部分。

ADC 转换必须由触发信号启动,分为两种方式:

软件触发:由程序主动启动转换;

硬件触发:由其他外设硬件的事件触发。比如定时器事件触发。

需要特别强调:

硬件触发不经过 CPU,是由外设直接启动 ADC 转换。

随后进入 ADC 核心模块(模拟到数字转换器)。

这一部分完成模数转换,通过逐次逼近的方式,将输入电压转换为 12 位数字量。

转换完成后,数据进入结果寄存器。

规则通道的结果存放在 ADC_DR 寄存器中;

注入通道有独立的寄存器,用于保存其转换结果。

这一块的就是量化的工作原理,在上面我们已经讲过了。

最后是 ADC 的时钟部分。

ADC 需要时钟驱动,该时钟来自 APB2,并经过 ADC 预分频器分频后提供给 ADC 内核。

ADC 的转换速度与该时钟直接相关。

需要注意的是:ADC的输入时钟频率不得超过14MHz。

最后做一个总结,ADC外设的工作流程,大体上是:

提供参考 → 选择通道 → 触发转换 → 模数转换 → 存储结果

扩展/了解:为什么有14MHz的限制

需要注意的是:ADC 的输入时钟频率不得超过 14MHz。

这是因为 ADC 内部是逐次逼近结构,一次转换需要经过采样、比较转换等多个步骤,并不是一个时钟周期就能完成。

如果时钟频率过高,看起来转换速度会变快,但实际上会导致:

  1. 采样电容来不及充电,电压不稳定;
  2. DAC 和比较器来不及响应;

从而使转换结果出现误差,甚至不稳定。

因此必须限制 ADC 时钟频率,保证内部电路有足够的建立时间。

可以简单理解为:

ADC 不是越快越好,时钟过快反而会降低测量精度。

主动降低ADC时钟频率,让它"慢"下来,是为了保证ADC能够更精准的工作。

具体代码的编写

实际上我们学到今天,对于某个外设配置使用,应当在心里已经有了一个大体的流程。

ADC模块的初始化配置,实际上也只需要以下几个步骤:

  1. 开启ADC外设时钟,若还需要使用其它外设则也需要同步开启它们的时钟。
  2. 调用ADC_Init函数,初始化ADC外设。
  3. 调用ADC_Cmd函数,使能开启ADC外设。
  4. ...(如果有的话)

下面我们就逐一介绍一下这些步骤。

开启ADC外设时钟

假如我们使用ADC1外设,它挂载在APB2外设总线上。

那么如何开启ADC1外设时钟呢?是不是就是直接写代码如下:

复制代码
RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE);

大体上确实是这样的,但在这一句代码之前还需要做一步小的操作,那就是分频时钟频率。

这是因为在开发手册文档中,官方明确提出了:ADC的输入时钟频率不得超过14MHz。

ADC的输入时钟频率上限(如14MHz)

由硬件电路设计极限和信号稳定性决定,超出会导致转换精度下降或数据错误,厂商设定此值以确保可靠性和抗噪能力。

APB2外设总线的默认时钟频率是72MHZ,这个数值显然远超14MHZ,那么如何实现分频呢?

只需要调用函数"RCC_ADCCLKConfig"即可。

该函数就专门用于分配ADC外设的输入时钟频率,其函数声明如下:

c 复制代码
void RCC_ADCCLKConfig(uint32_t RCC_PCLK2);

其形参RCC_PCLK2的传参可以是下列取值:

c 复制代码
RCC_PCLK2_Div2: ADC clock = PCLK2/2
RCC_PCLK2_Div4: ADC clock = PCLK2/4
RCC_PCLK2_Div6: ADC clock = PCLK2/6
RCC_PCLK2_Div8: ADC clock = PCLK2/8

这些取值就分别表示:二分频、四分频、六分频、以及八分频。

由于默认时钟频率是72MHZ,那么选择六分频就得到最终ADC输入时钟频率为12MHZ,这是一个常见的选择。

所以开启ADC外设时钟的代码最终就写成下列格式内容:

c 复制代码
// 配置 ADC 时钟,确保低于 14MHz
RCC_ADCCLKConfig(RCC_PCLK2_Div6); // 设置 ADC 时钟 = APB2 时钟 / 6 = 12MHz(恰好合适)
// 使能 ADC1 外设时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE);

这是第一步。

选择模拟信号的输入引脚

在配置使用ADC时,通道是一个重要数据,而ADC的通道和固定的引脚绑定:

  1. 外部传感器的AO口和PA0相连,则使用通道0
  2. 外部传感器的AO口和PA1相连,则使用通道1
  3. 外部传感器的AO口和PA2相连,则使用通道2
  4. ...

具体内容如下图所示:

所以我们可以把两个传感器的AO口分别和PA0与PA1引脚相连,并且使用ADC外设的通道0和通道1进行数据转换。

这两个引脚应该设置为什么模式呢?

它们用于接收模拟信号的输入,那么自然这两个引脚就应当被设置为模拟输入模式!

实验电路接线图

我们在本实验中使用光敏电阻传感器和热敏电阻传感器。

传感器通过 AO 口输出模拟电压信号,分别接入单片机的 PA0 和 PA1 引脚。

单片机内部的 ADC 对这两个模拟电压进行采样,并转换为对应的数字量。

随后,再根据传感器特性,将数字电压值进一步换算为实际的光照强度和温度数据,最终显示在 OLED 屏幕上。

这就是本实验的核心目标:

完成"模拟信号采集 → ADC转换 → 物理量计算 → 显示输出"的完整流程。

下面是实验的电路接线图:

在接线时,都是使用杜邦线连接的,注意不要接错了。

输入引脚初始化

由于前面的铺垫,两个模拟信号输入的引脚的初始化,就非常简单了。

可以直接参考下列代码:

c 复制代码
// 开启 ADC1 和 GPIOA 时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);

// GPIO 配置模拟信号输入引脚
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1;  // 选择 PA0 和 PA1 作为 ADC 输入通道0和1
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN;  // 配置为模拟输入模式
GPIO_Init(GPIOA, &GPIO_InitStructure);  // 初始化 GPIOA

ADC外设模块的初始化

ADC外设模块的初始化,使用一个我们"看起来"特别熟悉的函数:ADC_Init函数。

该函数的声明如下:

c 复制代码
void ADC_Init(ADC_TypeDef* ADCx, ADC_InitTypeDef* ADC_InitStruct);

第一个参数非常简单,表示要初始化的ADC外设的模块名字。

这里直接传参"ADC1"即可。

函数的第二个参数,需要传参一个ADC_InitTypeDef结构图对象指针。

也就是需要创建一个该类型结构体对象,然后逐一给结构体对象的成员赋值,最终再将该结构体对象的指针作为参数传递。

该结构体当中需要手动赋值的一共有以下六个成员:

c 复制代码
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_InitTypeDef;

下面逐一讲解一下这些成员的赋值:

ADC_Mode成员

ADC_Mode:

用于设置 ADC 的工作模式,出于简单使用考虑,我们的实验中仅需要一个ADC外设工作就足够实现功能。

所以该成员的取值可以直接填:ADC_Mode_Independent

具体的赋值代码如下:

c 复制代码
ADC_InitStruct.ADC_Mode = ADC_Mode_Independent;

即选择独立工作模式,只使用1个ADC外设。

ADC_ScanConvMode

ADC_ScanConvMode:

英文全称:Scan Conversion Mode,用于设定是否开启扫描模式,即ADC外设工作时是否自动按照顺序切换多个通道实现转换功能。

在我们的实验当中,虽然用到了两个通道,但完全可以一次转换使用某个单一通道,所以没必要使用扫描模式,直接关闭扫描模式即可。

具体的赋值代码如下:

c 复制代码
ADC_InitStructure.ADC_ScanConvMode = DISABLE;

ADC_ContinuousConvMode

ADC_ContinuousConvMode:

英文全称:Continuous Conversion Mode,用于配置是否开启自动连续转换模式,即是否自动的重复的完成数据采样以及转换。

为了简单起见,我们选择每次都手动进行信号转换,而不进行自动转换,所以也要关闭这个自动转换模式。

具体的赋值代码如下:

c 复制代码
ADC_InitStructure.ADC_ContinuousConvMode = DISABLE; 

ADC_ExternalTrigConv

ADC_ExternalTrigConv:

英文全称:External Trigger Conversion

在STM32的ADC配置中,ADC_ExternalTrigConv 参数用于设置ADC转换的外部触发源,即什么情况和场景下,会触发ADC进行数据转换。

触发方式可以粗略的分为两种:

  1. 自动触发:也叫做硬件触发、外部事件触发,比如借助定时器,借助外部事件等方式进行触发。
  2. 手动触发:也叫软件触发,即配置ADC外设模块自行触发数据转换,实际上就是需要程序员手动调用函数来触发数据采样和完成转换。

出于简单实现功能考虑,我们的实验就选择手动触发的方式,此时成员的赋值代码如下:

c 复制代码
ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None;  // 不使用外部触发,采用软件触发

ADC_DataAlign

ADC_DataAlign:

英文全称:Data Alignment,即数据对齐方式,该成员用于设置ADC转换后的数字电压信号的数据存储对齐方式。

它一共有两种对齐方式:

  1. 左对齐
  2. 右对齐

这两种对齐格式分别意味着什么呢?

这就不得不提ADC外设的一个重要属性:分辨率,它用于表示最终转换结果存储的位数。

我们使用的ADC都是12位的,即最终ADC转换后的最终数据是使用12位二进制位存储的。

而ADC外设存储最终结果的寄存器共有16位,于是左对齐和右对齐就如下图所示:

一般我们建议大家直接选择右对齐的方式,因为这种对齐方式,结果寄存器的高位补0,更便于计算。

那么这个结果寄存器当中存储的是不是真实的电压值呢?

答:其实并不是,结果寄存器当中存储的只是电压数字信号的特殊表示形式,要想得到真实的电压值还需要进行一步转换。

那么为什么需要转换呢?以及如何进行转换呢?

这里就需要了解ADC外设的工作原理了,即"逐次逼近型ADC"。

由于我们使用的是12位ADC,所以它的最终结果取值是0 ~ 4095,一共有4096种可能性。

单片机的供电电压是3.3V,也就是说这个3.3V要被分为4096份,每一份是3.3 / 4096 = 0.0008056641V。

然后我们再算权重:

  1. b11位为最高位,所以当只有b11位取1时,就表示实际电压为2 ^ 11 * 0.0008056641V = 1.65V
  2. 当只有b10位取1时,就表示实际电压为2 ^ 10 * 0.0008056641V = 0.825V
  3. 当只有b9位取1时,就表示实际电压为2 ^ 9 * 0.0008056641V =0.4125V
  4. ...

全部数据可以参考下列表格:

位数(bX) 对应的二进制值 权重(实际电压增量)
b11(最高位)
b10
b9
b8
b7
b6
b5
b4
b3
b2
b1
b0(最低位)

举一个例子:

当实际电压为2.2V时,结果寄存器应该存储哪个二进制数呢?

整个计算过程就是从最高位权重开始累加,只要该位累加后还不超过这个实际电压,那么该位就可以取1。

计算过程如下:

位数 (bX) 权重 (V) 累积电压计算过程 (V) 是否已超出 该位是否置 1 当前 ADC 结果
b11 1.65V 1.65V 未超出 1000 0000 0000
b10 0.825V 1.65V + 0.825V = 2.475V 已超出 1000 0000 0000
b9 0.4125V 1.65V + 0.4125V = 2.0625V 未超出 1010 0000 0000
b8 0.206V 2.0625V + 0.206V = 2.2685V 已超出 1010 0000 0000
b7 0.1035V 2.0625V + 0.1035V = 2.166V 未超出 1010 1000 0000
b6 0.0517V 2.166V + 0.0517V = 2.2177V 已超出 1010 1000 0000
b5 0.0258V 2.166V + 0.0258V = 2.1918V 未超出 1010 1010 0000
b4 0.0129V 2.1918V + 0.0129V = 2.2047V 已超出 1010 1010 000
b3 0.0064V 2.1918V + 0.0064V = 2.1982V 未超出 1010 1010 1000
b2 0.0032V 2.1982V + 0.0032V = 2.2014V 已超出 1010 1010 1000
b1 0.0016V 2.1982V + 0.0016V = 2.1998V 未超出 1010 1010 1010
b0 0.0008V 2.1998V + 0.0008V = 2.2006V 已超出 1010 1010 1010

于是ADC的结果寄存器当中存储的数据就是: 1010 1010 1010,即0XAAA,十进制数就是2730。

也就是说,当实际输入电压是2.2V这个模拟电压信号量时,ADC转换后输出的数字电压值是:2730

那么如何将这个ADC转换后的结果,再转换成实际的电压值呢?

很简单,2730这个值表示实际电压值有2730份,而每一份是3.3 / 4096 = 0.0008056641V,那么它表示的实际电压就是:

2730 * 0.0008056641 = 2.1994628906V

这个结果为什么和实际电压值不同呢?

很简单,这就是模拟信号连续、且精度无限和数字信号不连续,精度有限的差别,所以模数转换是存在误差的。

而且不难发现,随着ADC分辨率位数的增加,误差就会减少。

所以提升ADC的分辨率是有效提升数据采集精度的方式,当然代价就是更贵以及更高的功耗。

回到ADC_DataAlign成员的配置本身,这个成语的配置代码如下:

c 复制代码
ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right;  // 结果右对齐(低位对齐方式)

以上。

ADC_NbrOfChannel

ADC_NbrOfChannel:

英文全称: Number of Channels,即一次性ADC转换的通道数量。出于简单和本实验的需求,我们直接传参数值1就可以了。

此时成员的赋值代码如下:

c 复制代码
ADC_InitStructure.ADC_NbrOfChannel = 1;

ADC_NbrOfChannel 与扫描模式(Scan Mode)是配合使用的。

  1. 当只配置 1 个通道时,是否开启扫描模式没有影响
  2. 当配置多个通道时,必须开启扫描模式,ADC 才会按顺序依次转换多个通道

开启/使能ADC外设

和很多外设一样,在调用ADC_Init函数完成外设初始化以后,需要使用相应的Cmd函数用于开启此外设。

ADC外设也不例外。

函数调用代码如下:

c 复制代码
ADC_Cmd(ADC1, ENABLE);

ADC外设复位校准和校准操作

ADC作为一种转换数据信号的外设,它的精度是非常重要的。

所以ADC外设内部提供了一个用于实现校准的寄存器,但是在进行校准之前,建议先复位这个校准寄存器,以防止上一次的校准数据影响到这一次的校准。

其函数调用代码如下:

c 复制代码
// 复位 ADC1 校准
ADC_ResetCalibration(ADC1);

while (ADC_GetResetCalibrationStatus(ADC1));  // 等待复位完成

复位校准完成后,就可以开始进行ADC校准了,其函数调用代码如下:

c 复制代码
// 开始 ADC1 校准
ADC_StartCalibration(ADC1);

while (ADC_GetCalibrationStatus(ADC1));  // 等待校准完成

为什么需要校准?

实际上官方手册文档上对校准操作是有说明的,手册上明确指出:"校准可大幅减小因内部电容器组的变化而造成的准精度误差。"

所以在ADC外设上电开启(也就是ADC_Cmd函数执行)后,就需要执行上述ADC校准操作,以获取最佳的转换精度,减少误差。

完整代码

至此,关于一个ADC外设的初始化操作,就全部完成了。完整的参考代码如下:

c 复制代码
/**
 * @brief  配置 ADC1 以进行模数转换
 * @note   该函数初始化 ADC1,并配置其转换模式、数据对齐方式等
 * @param  无
 * @retval 无
 */
void ADC_Config(void) {
    // 配置 ADC 时钟,确保低于 14MHz
    RCC_ADCCLKConfig(RCC_PCLK2_Div6); // 设置 ADC 时钟 = APB2 时钟 / 6 = 12MHz(恰好合适)

    // 开启 ADC1 和 GPIOA 时钟
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1 | RCC_APB2Periph_GPIOA, ENABLE);

    // GPIO 配置模拟信号输入引脚
    GPIO_InitTypeDef GPIO_InitStructure;
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1;  // 选择 PA0 和 PA1 作为 ADC 输入通道
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN;  // 配置为模拟输入模式
    GPIO_Init(GPIOA, &GPIO_InitStructure);  // 初始化 GPIOA

    // ADC 配置
    ADC_InitTypeDef ADC_InitStructure;
    ADC_InitStructure.ADC_Mode = ADC_Mode_Independent;  // 独立模式,不与其他 ADC 共享
    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);  // 初始化 ADC1

    // 开启 ADC1
    ADC_Cmd(ADC1, ENABLE);

    // 复位 ADC1 校准
    ADC_ResetCalibration(ADC1);

    while (ADC_GetResetCalibrationStatus(ADC1));  // 等待复位完成

    // 开始 ADC1 校准
    ADC_StartCalibration(ADC1);

    while (ADC_GetCalibrationStatus(ADC1));  // 等待校准完成
}

以上。

读取ADC转换后的结果

在上述配置ADC外设完成后,ADC就可以开始工作了,我们就可以读取ADC转换后的结果了。

这里先来讲解几个重要的函数。

ADC_RegularChannelConfig函数

ADC_RegularChannelConfig,正如这个函数的名字,RegularChannel规则通道,该函数用于ADC外设的规则通道。

通俗的说,该函数用于设置从哪个ADC通道上,以何种规则顺序,以何种采样速度完成数据的采集工作。

该设置一旦完成,就相当于ADC外设做好了采集数据、转换数据的准备。

该函数的声明如下:

c 复制代码
void ADC_RegularChannelConfig(ADC_TypeDef* ADCx, uint8_t ADC_Channel, uint8_t Rank, uint8_t ADC_SampleTime);

其函数的前两个参数非常简单,可以直接传参:

  1. ADCx参数,指定ADC外设,直接传参ADC1或者ADC2
  2. ADC_Channel参数,指定数据采集的通道号。
    1. 由于我们使用单通道数据采集,所以在需要采集某个通道数据时,就直接传参对应通道即可。
    2. 比如我们上面使用的通道0: ADC_Channel_0和通道1: ADC_Channel_1

随后我们来看一下第三个参数:Rank

该参数表示: 如果 使用多个通道 采样,Rank 用来指定该通道在转换序列中的顺序

但我们仅使用简单的单通道数据采集,所以这个参数可以直接传参1。

重点来看一下最后一个参数: ADC_SampleTime

这个参数用于设置ADC 的采样时间,由于 ADC 开始转换之前,ADC 采样电容(S/H 电容) 需要充电,以便后续转换。

所以ADC_SampleTime这个采样时间设置,就决定了电容的充电时长:

  1. 采样时间 越短,充电时间就越短,采样速率越快,转换速率就越快,但精度可能下降。
  2. 采样时间 越长,充电就更充分,精度就更高,但相应速率就会降低。

ADC_SampleTime 可选值如下表格:

宏定义 采样时间
ADC_SampleTime_1Cycles5 1.5个时钟周期
ADC_SampleTime_7Cycles5 7.5个时钟周期
ADC_SampleTime_13Cycles5 13.5个时钟周期
ADC_SampleTime_28Cycles5 28.5个时钟周期
ADC_SampleTime_41Cycles5 41.5个时钟周期
ADC_SampleTime_55Cycles5 55.5个时钟周期
ADC_SampleTime_71Cycles5 71.5个时钟周期
ADC_SampleTime_239Cycles5 239.5个时钟周期

设置好了采样周期后,相应的一次转换的总转换时间就可以计算了。

STM32F103系列芯片 的 ADC 总转换时间可以由下列公式来进行计算:

采样时间周期

其中:T_ADC_Clock一个ADC 时钟周期所耗时间 ,ADC时钟频率由 RCC_ADCCLKConfig() 函数调用设置,我们使用的时钟周期是12MHZ。

如此我们可以计算出,ADC一个时钟频率时间是:1 / 12MHz 秒。

加上的12.5是什么意思呢?

12.5就是12.5个时钟周期,我们查看硬件手册的说明,STM32F103 系列芯片的ADC外设在采集完数据后的,转换数据的时间是固定的。

固定为12.5个ADC时钟周期时间。

为什么固定为12.5个ADC时钟周期呢?

因为ADC是12位的,每一个时钟周期用于确定对应位的值为0或1,再留半个周期进行数据存储,总共12.5个时钟周期。

所以ADC外设,完成一次采样,并且转换数据的总转换时间,其计算结果就如下表格所示:

宏定义 总转换时间(Cycles) 总转换时间(µs,ADC 时钟 = 12MHz)
ADC_SampleTime_1Cycles5 1.5 + 12.5个时钟周期 1.17 µs
ADC_SampleTime_7Cycles5 7.5 + 12.5个时钟周期 1.67 µs
ADC_SampleTime_13Cycles5 13.5 + 12.5个时钟周期 2.17 µs
ADC_SampleTime_28Cycles5 28.5 + 12.5个时钟周期 3.42 µs
ADC_SampleTime_41Cycles5 41.5 + 12.5个时钟周期 4.50 µs
ADC_SampleTime_55Cycles5 55.5 + 12.5个时钟周期 5.67 µs
ADC_SampleTime_71Cycles5 71.5 + 12.5个时钟周期 7.00 µs
ADC_SampleTime_239Cycles5 239.5 + 12.5个时钟周期 21.04 µs

那我们应该选择什么样的采样时间呢?

采样时间的选择主要靠模拟信号源的阻抗决定。

在模拟电路中,信号源阻抗 (R_source) 是信号源输出端的等效电阻,它表示信号源驱动电流的能力。

简单来说,信号源阻抗越高,提供的电流越小,ADC 采样电容充电就越慢,因此 需要更长的采样时间 (ADC_SampleTime) 以确保准确测量。

下面给出一张表格展示了常见的信号源阻抗和采样时间的选择:

信号源阻抗 (R_source) 推荐 ADC_SampleTime 适用场景
< 1kΩ 1.5 ~ 7.5 Cycles 运放缓冲输出、低阻抗电压测量
1kΩ ~ 10kΩ 7.5 ~ 13.5 Cycles 分压电路、电位器、低阻抗 NTC
10kΩ ~ 50kΩ 28.5 ~ 55.5 Cycles 光敏电阻(LDR)、NTC 热敏电阻
50kΩ ~ 100kΩ 55.5 ~ 71.5 Cycles 高阻抗 NTC、电容式传感器

由于我们实验的目标就是采集光敏电阻传感器、热敏电阻传感器的模拟信号,所以这里我们选择55.5个时钟周期时间作为采样时间。

以上,这个函数调用的代码就如下:

c 复制代码
// 配置 ADC 通道(指定ADC1外设, ADC通道,单通道采样,采样时间 55.5 时钟周期)
ADC_RegularChannelConfig(ADC1, ADC_Channel_x, 1, ADC_SampleTime_55Cycles5);

当然,如果不追求采样速度,更加追求采样的精准度,可以进一步放慢采样时间。

ADC_SoftwareStartConvCmd函数

完成上面ADC通道规则配置后,就可以开始ADC采集数据、转换数据的流程了。

在上面配置ADC外设时,我们选择的触发方式是软件触发,即使用某个函数调用触发ADC的数据采集、转换流程。

这个函数就是ADC_SoftwareStartConvCmd。

该函数的声明如下:

复制代码
void ADC_SoftwareStartConvCmd(ADC_TypeDef* ADCx, FunctionalState NewState);

其函数传参可以参考下列表格:

参数 类型 作用
ADCx ADC_TypeDef* 选择 ADC 外设(ADC1ADC2
NewState FunctionalState ENABLE 触发转换,DISABLE 停止转换

注意事项:

  1. 只有ADC设置为软件触发模式,才需要调用该函数手动触发ADC采集和转换数据。
  2. 由于上述ADC初始化时,禁用了连续转换模式,所以每转换一次数据都需要调用一次该函数。

在上面讲解ADC_RegularChannelConfig函数时,我们知道: ADC从采样数据到完成转换数据,将结果存储到结果寄存器是需要耗费很多时间的。

那么在读结果寄存器的数据时,就需要等待ADC采样、转换、存储数据全部完成后,才能够进行读取。

如何实现这样的等待过程呢?

很简单,依靠标志位。

标志位EOC(End of Conversion)被置为1时,就表示ADC采样、转换、存储数据全部完成。

我们可以通过函数: ADC_GetFlagStatus来获取ADC外设标志位的状态,于是一次完整的软件触发ADC采样转换数据的代码就是:

c 复制代码
// 开始 ADC1采样,转换数据的过程(软件触发)
ADC_SoftwareStartConvCmd(ADC1, ENABLE);

// 等待转换完成(检查 EOC 标志)
while (!ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC));

// 代码执行到这里, 说明可以读ADC数据了

那么怎么读ADC结果寄存器当中的数据呢?

ADC_GetConversionValue函数

ADC_GetConversionValue函数就用于ADC一次数据采集转换后,结果数据寄存器当中的16位数值。(文档上面已经分析过这个数值了)

其函数声明如下:

c 复制代码
uint16_t ADC_GetConversionValue(ADC_TypeDef* ADCx);

其传参非常简单,传入对应ADC外设名即可,如ADC1ADC2

该函数调用就表示从ADCx外设中读取对应通道的转换结果。

重要注意事项:

我们已经知道EOC标志位在转换完成时会被自动置为1,那么读完数据后就应该置为0,以进行下一次ADC采集转换数据。

那么需要手动将EOC标志位置为0吗?

不需要,按照官方手册文档中的说明,在调用ADC_GetConversionValue函数读数据后,EOC标志位会自动置为0!

读ADC结果的完整代码

c 复制代码
/**
 * @brief  读取指定通道的 ADC 值
 * @note   该函数启动 ADC1 进行单次转换,并返回转换结果
 * @param  channel: 要转换的 ADC 通道号(ADC_Channel_0 ~ ADC_Channel_17)
 * @retval uint16_t: 12 位 ADC 转换结果(范围 0 ~ 4095)
 */
uint16_t ADC_Read(uint8_t channel) {
    // 配置 ADC 通道(指定通道,规则组序号 1,采样时间 55.5 周期)
    ADC_RegularChannelConfig(ADC1, channel, 1, ADC_SampleTime_55Cycles5);

    // 开始 ADC1采样,转换数据的过程(软件触发)
    ADC_SoftwareStartConvCmd(ADC1, ENABLE);

    // 等待转换完成(检查 EOC 标志, 等待该标志被置为1)
    while (!ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC));

    // 读取并返回转换结果, 自动将EOC标志位置为0
    return ADC_GetConversionValue(ADC1);
}

以上。

相关推荐
lizhihai_9927 分钟前
股市学习心得-AI 产业链核心标的梳理清单
大数据·服务器·人工智能·科技·学习
kebidaixu34 分钟前
FreeRTOS 移植到 STM32F407VETX 记录(一)
stm32·单片机·嵌入式硬件
吃好睡好便好1 小时前
说说科学爬山
学习·生活
半条-咸鱼1 小时前
【INACCESSIBLE_BOOT_DEVICE】安装 Config Tool 后 Windows 蓝屏,最终通过 VMware 虚拟机解决
windows·stm32·vmware·芯片
lunzi_08262 小时前
【学习笔记】《Python编程 从入门到实践》第8章:函数定义、参数传递与模块导入
笔记·python·学习
点灯小铭2 小时前
基于单片机的数码管定时插座设计与定时开关功能实现
单片机·嵌入式硬件·毕业设计·课程设计·期末大作业
零陵上将军_xdr3 小时前
后端转全栈学习-Day5-JavaScript 基础-3
开发语言·javascript·学习
05大叔3 小时前
对话系统学习,问答型数据库,闲聊型对话数据库
学习
nashane3 小时前
HarmonyOS 6商城开发学习:抢票倒计时与系统日历提醒——票务类场景的完整落地思路
学习·华为·harmonyos