文章目录
ADC简介
传感器模块概述
在我们的配件盒当中,有几个比较常见的传感器模块,如下图所示。它们分别是:
光敏电阻传感器模块:

光敏电阻传感器用于检测环境光照强度,其核心器件为光敏电阻(LDR)。
其工作原理如下:
- 光照越强 → 光敏电阻的电阻越小
- 光照越弱 → 光敏电阻的电阻越大
模块内部采用分压电路设计,将光敏电阻随光照而改变的阻值变化转换为电压变化输出。

输出形式有两种:
- 模拟输出(AO):直接输出连续变化的电压信号(模拟信号),用于 ADC 采集
- 数字输出(DO):通过LM393电压比较器,配合电位器设定阈值,最终输出高低电平信号(0或1)。
温度电阻传感器模块:

温度电阻传感器通常采用热敏电阻(NTC)作为核心元件。
其工作原理如下:
- 温度升高 → 热敏电阻的阻值减小(NTC特性)
- 温度降低 → 热敏电阻的阻值增大
同样通过分压电路将电阻变化转换为电压输出。
原理和光敏电阻传感器是一模一样的,只是采集的物理信号量改变了。
气敏电阻传感器模块(MQ系列空气传感器):

MQ 系列气体传感器是一类常见的气体检测模块,如 MQ-2、MQ-135 等。
其核心器件为半导体气敏电阻(SnO₂ 材料)。
其工作原理如下:
- 内部加热丝将敏感材料加热至工作温度
- 气体与材料表面发生化学反应
- 改变材料导电能力
- 导致电阻值发生变化
最终表现为:
气体浓度变化 → 电阻变化 → 电压变化
最终仍然通过分压电路将电阻变化转换为电压输出。
以上三类传感器虽然检测对象不同,但具有一个共同特点:
它们的核心本质都是"电阻随物理量变化"。
系统中的信号转换过程可以统一表示为:
某种被监测的物理量改变 → 电阻变化 → 分压电路 → 电压信号 → ADC采集 / 比较起输出高低电平
在前面的课程学习中,我们已经知道如何使用传感器模块的DO口输出:
- 输出1表示一种状态
- 输出0表示一种状态
所以DO口的输出显然是一种数字输出模式。
它虽然能表示两种和物理量相关的状态,但还是不够直观的表示物理量本身。
今天我们来学习传感器模块的AO口输出功能,通过采集AO口的输出,就可以更直接的获取物理量的相关数据。
当然,我们首先需要明确的一点是:
传感器模块的 AO 口输出的是连续变化的电压信号,属于模拟信号输出。
模拟信号和数字信号
关于模拟信号和数字信号,我们之前已经简单提到过。
它们之间的区别,可以用一句话概括:
模拟信号是连续的,数字信号是跳变的、离散的。
自然界中的物理量(如温度、光照、声音、电压等)本质上都是连续变化的,因此属于模拟信号。
从理论上来说:
模拟信号在时间和数值上都是连续的,其精度可以无限细分。
也就是说:
任意两个模拟信号的数值之间,还可以继续细分出无限多的数值。
正因为这一点:
我们实际上无法精确地记录或存储一个真正的模拟信号。
比如:
我的身高是180cm,真的是刚好180吗?
不可能"刚好就是 180.000000...... cm",它一定是一个非常精细的连续值,只是我们做了近似表达。
而数字信号,则是对模拟信号的一种处理结果。
数字信号是通过对模拟信号进行"采样"和"量化"得到的离散数值。
所以:
数字信号其实可以理解成,是对模拟信号在某一个时刻的一种"近似表示",是一种为了便于表述和存储使用的估值和约数。
总之:
- 所谓模拟信号,是出现在自然界当中的数据信号,其数据随时间连续变化,数据的精度理论上无限大。真实,但不易描述和存储。
- 所谓数字信号,是对模拟信号的近似表示和约数,它的数值随时间离散变化,数据的精度是有限的。数据不真实,但易于描述和存储。
比如参考下图:

自然界的温度是连续变化的,而且温度的数值理论上是精度无限的,这就是模拟信号。
模拟信号是无法直接存储在计算机当中的:
因为计算机进行数据采集存储总会存在一个时间间隔,而且计算机由于存储位数的限制,也不可能存储无限精度的数据。
现在传感器模块的AO口输出的是连续变化的电压模拟信号,单片机肯定是无法直接保存使用的。
还需要经历一步:将模拟电压信号,转换成数字电压信号的过程。
此时就需要使用单片机的ADC外设。
在嵌入式系统当中,外围传感器采集模拟信号数据后,需要使用ADC模块转换成数字信号,然后才可以进行存储。
ADC概述
ADC也是单片机内一种常见,常用的片内外设。我们可以从ADC这个名字入手,来探究一下什么是ADC。
**ADC(Analog-to-Digital Converter)**即模拟信号 - 数字信号转换器,是嵌入式系统中实现模拟信号数字化的核心功能模块。

在实际的嵌入式系统中,各类传感器通常会将外界的物理量(如温度、光照、气体浓度等),转换为随之变化的电压信号。
随后,ADC(模数转换器)模块会对该电压信号进行处理,将其转换为对应的电压数字量(离散数值)。
这样,程序员就可以通过读取 ADC 的电压数字值结果,间接获取当前物理量的近似值。
举一个例子,以温度传感器为例:
- 外界温度发生变化
- 热敏电阻的阻值发生变化
- 通过分压电路转换为电压信号(模拟量)
- ADC 将该模拟电压量转换为数字电压量
- 程序员获取这个数字电压量,从而更直观的在数字上感受温度发生了改变。
- 当然,通过这个数字电压量,还可以通过公式来计算近似温度值。
总结流程如下:
温度改变 → 热敏电阻的阻值变化 → 电压变化(模拟量) → ADC → 电压数字量 → 温度计算
ADC模数转换的工作原理
单片机中的ADC外设,其核心作用就是:把连续变化的电压(模拟量),转换为离散的数字值。
也就是"模数转换"。
那么,它是如何完成这个过程的呢?
核心步骤是两步:
- 采样,在某一个时刻,读取当前的电压值,就像"拍一张照片"。
- 模拟信号是连续变化的,要想转换为一个固定的数字值,首先必须在某一时刻"固定住"这个信号
- 采样的频率越高,对原始信号连续变化特点的还原就越准确。
- 量化,将采样得到的电压值,用一个整数进行表示,这个整数值就是电压的数字值。
ADC 的本质就是:在某一时刻测量电压,并用一个数字去表示这个电压。
采样、采样时间和采样频率
ADC一次转换,大致经历两个阶段:
- 采样时间,ADC外设内部有一个电容器件,采样本质上就是"电容"充电的过程。
- 采样时间是ADC的可选配置参数。
- 采样时间越长,电容有更充足的时间"充电",采样结果会更接近真实电压。
- 采样时间短,单次采样的总时间就越短,可以实现更高的采样速度。但如果电容充电不足,可能导致采样的数据偏小或不稳定。
- 转换时间,把模拟电压信号转换成数字信号。在STM32中,这个时间是固定的,为12.5个ADC时钟周期。
一次ADC转换总时间:
ADC一次转换总时间 = 采样时间 + 12.5 cycles
ADC的采样频率计算公式:
采样频率 = ADC时钟 / (采样时间 + 12.5)
所以,如果想要ADC采样的快,比如1s内采样个十万次,那么就要尽量:
- 提高ADC时钟
- 降低采样时间
但需要注意:
采样频率的提升,通常是以一定的准确性为代价的。
因为采样时间缩短,电容充电不充分,可能导致采样结果产生误差。
如果不追求高速采样,建议尽量选择较长的采样时间,这样可以获得更稳定、更准确的采样结果。
量化、量化值和分辨率
采样阶段得到的是某一时刻的"模拟电压值",理论上这个电压是精度无限的。
例如:
2.0131000...
省略号表示精度无限
量化的过程,就是把这个连续电压,映射为一个有限精度的数字值。
那么ADC模块是如何完成量化过程的呢?
画了一个简化的ADC量化过程工作原理图:

其核心就在于:
STM32F103C8T6单片机的ADC是一个12位逐次逼近型ADC。
那么什么是12位逐次逼近型ADC呢?
可以简单理解为:
ADC通过"不断试探"的方式,用一个12位的数字,逐步逼近输入电压的真实值。
其基本工作过程如下:
- ADC内部有一个DAC(数模转换器)和一个电压比较器
- 从最高位(MSB)开始,逐位尝试将12位数据寄存器的对应位设为1
- DAC转换器根据当前数字值生成一个参考电压(数字信号转换成模拟信号)
- 比较该电压与ADC模拟输入电压的大小
- 若DAC电压 > 输入电压 → 说明电压没达到这么高,当前位清零
- 若DAC电压 ≤ 输入电压 → 说明电压可能更高,当前位置位保留
- 继续向低位判断每一位,直到最低位(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
例如:
- 12位ADC,它的分辨率是:3.3 / 4096 ≈ 0.8mV
- 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个。
即:
- PA0 ~ PA7,ADC12_IN0 ~ ADC12_IN7,一共8个通道。
- PB0 对应 ADC12_IN8
- PB1 对应 ADC12_IN9
考虑到:
- USART2占用PA2和PA3
- SPI1占用PA5、PA6、PA7
排除这些冲突引脚以后,还能用于 ADC 采样的就只剩下:
- PA0 → ADC12_IN0
- PA1 → ADC12_IN1
- PA4 → ADC12_IN4
- PB0 → ADC12_IN8
- PB1 → ADC12_IN9
也就是说:
只有5个引脚PA0、PA1、PA4、PB0、PB1,可以用于连接传感器模块的AO口。
为什么是ADC12_XX
在 STM32F103 系列单片机中,经常可以看到类似 ADC12_IN0、ADC12_IN1 这样的标注方式。
这里的 ADC12 并不是表示"第12个ADC",而是表示:
该通道同时连接到 ADC1 和 ADC2 两个 ADC 外设。
也就是说:
- ADC12_IN0 表示:该引脚既可以作为 ADC1 的通道0,也可以作为 ADC2 的通道0
- ADC12_IN1 表示:该引脚既可以被 ADC1 使用,也可以被 ADC2 使用
本质上,这些模拟输入引脚在硬件上是共享给 ADC1 和 ADC2 的,只是通过内部多路复用器选择由哪一个 ADC 来进行采样。
因此:
ADC12_INx 的含义可以理解为:该引脚对应 ADC1 和 ADC2 的第 x 号通道
在实际使用中:
- 如果使用 ADC1,则配置为 ADC1 的通道 x
- 如果使用 ADC2,则配置为 ADC2 的通道 x
但需要注意:
同一个通道在同一时刻只能被一个 ADC 使用,不能同时被 ADC1 和 ADC2 采样。
这种命名方式的目的,是为了明确说明该引脚资源是两个 ADC 外设共享的。
ADC外设框图
从《STM32F1参考手册》中,我们可以获取以下ADC外设框图:

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

下面来解释一下这个简化框图。
简化ADC外设框图讲解
首先从最左上方的电源与参考电压部分开始。
ADC 的转换必须依赖参考电压和模拟电源,包括:
- VREF+、VREF-:用于确定 ADC 的测量范围(通常为 0 ~ 3.3V)
- VDDA、VSSA:分别是模拟电源和模拟地,为 ADC 提供工作电源,可以理解成为片上外设供电。
可以理解为:
ADC 的测量范围和精度,首先由这一部分决定。
接下来是模拟信号的输入部分。
包括外部引脚 ADCx_IN0 ~ ADCx_IN15,以及内部信号(温度传感器、VREFINT)。
这些信号通过 GPIO 进入 ADC,本质上是待测的"模拟电压来源"。
然后进入模拟多路开关(多路复用器)。
由于 ADC 一次只能转换一个通道,因此需要在多个输入之间进行选择。
规则通道最多支持 16 个通道轮询,注入通道最多支持 4 个通道。
这一步的本质是:
从多个输入中选出当前要转换的那个通道。
之后信号分为两条路径:规则通道和注入通道。
规则通道用于常规采样,按设定顺序依次转换;
注入通道优先级更高,可以在规则通道转换过程中插入执行。
这一部分体现的是 ADC 的通道调度机制。
在规则通道中,还涉及两个非常重要的工作模式:扫描模式和连续转换模式。
扫描模式(Scan Mode):
- 当配置多个通道时,ADC 会按照设定的顺序,依次对多个通道进行转换。
- 如果关闭扫描模式,则每次只转换一个通道。
连续转换模式(Continuous Mode):
- 开启后,ADC 在完成一次转换后,会自动再次启动下一次转换;
- 关闭时,则每次转换都需要重新触发。
可以理解为:
- 扫描模式决定"采几个通道";
- 连续转换决定"采一次还是一直采"。
规则通道用于常规采样,按照配置好的顺序依次转换;
注入通道优先级更高,可以在规则通道执行过程中插入转换。
这一部分体现的是 ADC 的通道调度机制。
接下来是触发控制部分。
ADC 转换必须由触发信号启动,分为两种方式:
软件触发:由程序主动启动转换;
硬件触发:由其他外设硬件的事件触发。比如定时器事件触发。
需要特别强调:
硬件触发不经过 CPU,是由外设直接启动 ADC 转换。
随后进入 ADC 核心模块(模拟到数字转换器)。
这一部分完成模数转换,通过逐次逼近的方式,将输入电压转换为 12 位数字量。
转换完成后,数据进入结果寄存器。
规则通道的结果存放在 ADC_DR 寄存器中;
注入通道有独立的寄存器,用于保存其转换结果。
这一块的就是量化的工作原理,在上面我们已经讲过了。
最后是 ADC 的时钟部分。
ADC 需要时钟驱动,该时钟来自 APB2,并经过 ADC 预分频器分频后提供给 ADC 内核。
ADC 的转换速度与该时钟直接相关。
需要注意的是:ADC的输入时钟频率不得超过14MHz。
最后做一个总结,ADC外设的工作流程,大体上是:
提供参考 → 选择通道 → 触发转换 → 模数转换 → 存储结果
扩展/了解:为什么有14MHz的限制
需要注意的是:ADC 的输入时钟频率不得超过 14MHz。
这是因为 ADC 内部是逐次逼近结构,一次转换需要经过采样、比较转换等多个步骤,并不是一个时钟周期就能完成。
如果时钟频率过高,看起来转换速度会变快,但实际上会导致:
- 采样电容来不及充电,电压不稳定;
- DAC 和比较器来不及响应;
从而使转换结果出现误差,甚至不稳定。
因此必须限制 ADC 时钟频率,保证内部电路有足够的建立时间。
可以简单理解为:
ADC 不是越快越好,时钟过快反而会降低测量精度。
主动降低ADC时钟频率,让它"慢"下来,是为了保证ADC能够更精准的工作。
具体代码的编写
实际上我们学到今天,对于某个外设配置使用,应当在心里已经有了一个大体的流程。
ADC模块的初始化配置,实际上也只需要以下几个步骤:
- 开启ADC外设时钟,若还需要使用其它外设则也需要同步开启它们的时钟。
- 调用ADC_Init函数,初始化ADC外设。
- 调用ADC_Cmd函数,使能开启ADC外设。
- ...(如果有的话)
下面我们就逐一介绍一下这些步骤。
开启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的通道和固定的引脚绑定:
- 外部传感器的AO口和PA0相连,则使用通道0
- 外部传感器的AO口和PA1相连,则使用通道1
- 外部传感器的AO口和PA2相连,则使用通道2
- ...
具体内容如下图所示:

所以我们可以把两个传感器的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进行数据转换。
触发方式可以粗略的分为两种:
- 自动触发:也叫做硬件触发、外部事件触发,比如借助定时器,借助外部事件等方式进行触发。
- 手动触发:也叫软件触发,即配置ADC外设模块自行触发数据转换,实际上就是需要程序员手动调用函数来触发数据采样和完成转换。
出于简单实现功能考虑,我们的实验就选择手动触发的方式,此时成员的赋值代码如下:
c
ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None; // 不使用外部触发,采用软件触发
ADC_DataAlign
ADC_DataAlign:
英文全称:Data Alignment,即数据对齐方式,该成员用于设置ADC转换后的数字电压信号的数据存储对齐方式。
它一共有两种对齐方式:
- 左对齐
- 右对齐
这两种对齐格式分别意味着什么呢?
这就不得不提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。
然后我们再算权重:
- b11位为最高位,所以当只有b11位取1时,就表示实际电压为2 ^ 11 * 0.0008056641V = 1.65V
- 当只有b10位取1时,就表示实际电压为2 ^ 10 * 0.0008056641V = 0.825V
- 当只有b9位取1时,就表示实际电压为2 ^ 9 * 0.0008056641V =0.4125V
- ...
全部数据可以参考下列表格:
| 位数(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 个通道时,是否开启扫描模式没有影响
- 当配置多个通道时,必须开启扫描模式,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);
其函数的前两个参数非常简单,可以直接传参:
- ADCx参数,指定ADC外设,直接传参
ADC1或者ADC2 - ADC_Channel参数,指定数据采集的通道号。
- 由于我们使用单通道数据采集,所以在需要采集某个通道数据时,就直接传参对应通道即可。
- 比如我们上面使用的通道0:
ADC_Channel_0和通道1:ADC_Channel_1。
随后我们来看一下第三个参数:Rank
该参数表示: 如果 使用多个通道 采样,Rank 用来指定该通道在转换序列中的顺序。
但我们仅使用简单的单通道数据采集,所以这个参数可以直接传参1。
重点来看一下最后一个参数: ADC_SampleTime
这个参数用于设置ADC 的采样时间,由于 ADC 开始转换之前,ADC 采样电容(S/H 电容) 需要充电,以便后续转换。
所以ADC_SampleTime这个采样时间设置,就决定了电容的充电时长:
- 采样时间 越短,充电时间就越短,采样速率越快,转换速率就越快,但精度可能下降。
- 采样时间 越长,充电就更充分,精度就更高,但相应速率就会降低。
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 外设(ADC1、ADC2) |
NewState |
FunctionalState |
ENABLE 触发转换,DISABLE 停止转换 |
注意事项:
- 只有ADC设置为软件触发模式,才需要调用该函数手动触发ADC采集和转换数据。
- 由于上述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外设名即可,如ADC1、ADC2。
该函数调用就表示从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);
}
以上。