STM32 ADC 模数转换 学习笔记
基于示例工程:
D:\下载\STM32入门教程资料\程序源码\STM32Project-有注释版\7-1 AD单通道
硬件:STM32F103C8 + 标准外设库 V3.5.0(Keil 工程)
前置:已学完 GPIO、中断、定时器、PWM。ADC 是单片机第一次真正"接触模拟世界"的地方——它让只会说 0 和 1 的单片机,能读出连续变化的电压。
一、什么是 ADC?为什么需要它?
1.1 回到《前置基础》的一句话
你的单片机引脚只会输出高/低电平(0 / 1),它是数字世界的产物。但现实世界是模拟的:电位器旋钮的角度、温度传感器的电压、声音的波形……都是连续变化的。
ADC(Analog-to-Digital Converter,模数转换器)就是把"连续变化的电压"翻译成"数字 0~4095"的硬件。
电位器分压电压 0~3.3V(模拟、连续)
│ ADC 转换
▼
数字值 0~4095(数字、离散)
│ 公式换算
▼
0.00V ~ 3.30V(又变回人能读懂的电压)
一句话:ADC 是数字单片机看模拟世界的"眼睛"。
1.2 生活类比
想象一把只有 4096 个刻度的尺子,量程 0~3.3V。ADC 就是拿这把尺子去量引脚上的电压,量出来是"第几格",就输出几。
- 电压 0V → 第 0 格 → 输出 0
- 电压 1.65V → 第 2048 格 → 输出 2048
- 电压 3.3V → 第 4095 格 → 输出 4095
刻度之间那些"细到量不出来"的部分,就是 量化误差(见 2.2)。刻度的总数,就是 分辨率。
二、ADC 的核心参数(先建立概念)
2.1 分辨率(Resolution)
STM32F103 的 ADC 是 12 位的,所以结果范围是 0 ~ 4095(共 4096 个值)。
能分辨的最小电压 = 满量程 ÷ 2^位数 = 3.3V ÷ 4096 ≈ 0.0008V = 0.8mV
- 位数越多 → 分辨率越高 → 量得越精细(12位、16位、24位…)
- 12 位在单片机里是很常见的折中:精度够用,转换快,寄存器正好 16 位能装下
2.2 量化与量化误差
把连续电压"分段"成 4096 格,就是量化。落在两格之间的真实电压,会被"就近取整"到某一格,这个抹掉的误差叫量化误差,最大不超过半个格(约 0.4mV)。量化误差是 ADC 天生的,无法消除,只能通过提高位数减小。
2.3 参考电压(VREF)
ADC 的"满量程"由参考电压决定:输入电压到达 VREF 时,ADC 输出最大(4095)。
- STM32F103 的 VREF+ 通常直接接 3.3V(即 VDDA),所以满量程是 3.3V。
- 换算公式里那个
* 3.3就是参考电压。 - 输入电压不能超过参考电压! 超过会烧引脚。所以外部信号进 PA0 前,必须确保在 0~3.3V 范围内。
2.4 转换时间与采样保持
ADC 转换不是瞬时的,需要时间。转换前还要采样——把引脚电压"抓住"并保持住,让内部电路稳定测量。
- 采样时间越长,结果越准,但转换越慢。
- STM32F103 ADC 时钟上限 14MHz,本项目配成 12MHz(见 4.2)。
- 本项目采样时间 55.5 个周期,总转换时间 ≈ (55.5 + 12.5) ÷ 12MHz ≈ 5.7μs,约每秒 17 万次——对"读电位器"来说绰绰有余。
三、STM32F103 的 ADC 资源
| 资源 | 数量 | 说明 |
|---|---|---|
| ADC 外设 | 3 个(ADC1 / ADC2 / ADC3) | 本项目用 ADC1 |
| 通道 | 每个 ADC 有 16 个外部通道(0~15) | 通道0 = PA0,通道1 = PA1 … |
| 分辨率 | 12 位 | 结果 0~4095 |
| 规则组 / 注入组 | 规则组可排最多 16 路;注入组是"插队"的紧急转换 | 本项目只用规则组单通道 |
| 触发方式 | 软件触发 / 定时器触发 / 外部引脚触发 | 本项目软件触发 |
通道和引脚的对应关系(STM32F103,记住常用的前几个):
| 通道 | 引脚 | 通道 | 引脚 |
|---|---|---|---|
| 通道0 | PA0 | 通道4 | PA4 |
| 通道1 | PA1 | 通道5 | PA5 |
| 通道2 | PA2 | 通道6 | PA6 |
| 通道3 | PA3 | 通道7 | PA7 |
另外还有两个内部通道:通道16 = 内部温度传感器,通道17 = 内部参考电压(1.2V 左右)。测芯片温度用通道16。
四、逐行精讲 AD_Init(AD.c)
4.1 开启时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE); // 开 ADC1 时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // 开 GPIOA 时钟
- ADC1 在 APB2 总线上(所以用
RCC_APB2PeriphClockCmd),GPIOA 也在 APB2。 - 老规矩:用外设先开时钟。
4.2 设置 ADC 时钟
RCC_ADCCLKConfig(RCC_PCLK2_Div6); // 72MHz / 6 = 12MHz
- ADC 有自己独立的时钟,由 APB2(72MHz)分频得到。
- 上限是 14MHz。所以 72MHz 只能分频成:72/2=36(超限❌)、72/4=18(超限❌)、72/6=12MHz(✅)、72/8=9MHz(✅)。
- 本项目用 6 分频 = 12MHz,在允许范围内,速度也够快。
4.3 配置 GPIO:PA0 为模拟输入
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);
GPIO_Mode_AIN(Analog Input,模拟输入) 是 GPIO 的又一个新模式。对照你学过的模式:
| 模式 | 用途 |
|---|---|
Out_PP |
普通推挽输出(LED) |
AF_PP |
复用推挽输出(PWM 等外设控制输出) |
IPU / IPD / IN_FLOATING |
数字输入(按键) |
AIN |
模拟输入(ADC 专用) |
为什么用 AIN? 读模拟电压时,引脚内部的数字输入缓冲器必须断开(否则数字电路会干扰模拟信号),让电压直接进入 ADC 的采样电路。这个模式把引脚内部配置成"纯模拟通路"。
4.4 规则组通道配置
ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5);
这句的含义:把"通道0(PA0)"放进 ADC1 规则组序列的第 1 位,采样时间 55.5 个周期。
ADC_Channel_0:要转换的通道(PA0)。1:在规则组序列里的位置。本项目只转换一个通道,放第 1 位即可。ADC_SampleTime_55Cycles5:采样时间。可选 1.5 / 7.5 / 13.5 / 28.5 / 41.5 / 55.5 / 71.5 / 239.5 个周期。采样时间越长越准但越慢,新手用 55.5 或 71.5 更稳。
4.5 ADC 初始化(核心,参数最多)
ADC_InitStructure.ADC_Mode = ADC_Mode_Independent; // 独立模式(只用 ADC1)
ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right; // 右对齐
ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None; // 软件触发
ADC_InitStructure.ADC_ContinuousConvMode = DISABLE; // 单次转换
ADC_InitStructure.ADC_ScanConvMode = DISABLE; // 非扫描(只转一路)
ADC_InitStructure.ADC_NbrOfChannel = 1; // 通道数 = 1
ADC_Init(ADC1, &ADC_InitStructure);
逐项解释(每个参数都对照"单通道读一次电位器"这个需求来理解):
| 参数 | 本项目值 | 含义 | 别的可能 |
|---|---|---|---|
ADC_Mode |
独立 | 只用 ADC1 | 双 ADC 同步/交叉模式 |
ADC_DataAlign |
右对齐 | 12 位结果靠右存,高 4 位补 0 | 左对齐(读高 8 位用) |
ADC_ExternalTrigConv |
None | 软件触发 | 定时器触发(自动连续采样) |
ADC_ContinuousConvMode |
DISABLE | 单次转换:转完一次就停 | 连续转换:自己不停转 |
ADC_ScanConvMode |
DISABLE | 非扫描:只转序列第 1 位 | 扫描:把序列里多个通道依次转完 |
ADC_NbrOfChannel |
1 | 序列里通道个数 | 多通道时才 >1 |
两个容易混的概念(重点):
- 单次 vs 连续(ContinuousConvMode):单次 = 你喊一声转一次;连续 = 转换完自动又开始下一次,像流水一样不停。
- 非扫描 vs 扫描(ScanConvMode):非扫描 = 只转序列第 1 位;扫描 = 把序列里排好的多个通道依次全转一遍(转完一轮产生 EOC)。
本项目:单次 + 非扫描 = "手动喊一次,转一路"。最简单。
4.6 使能 ADC
ADC_Cmd(ADC1, ENABLE);
启动 ADC。老规矩,别漏。
4.7 ADC 校准(固定流程,照抄)
ADC_ResetCalibration(ADC1); // 复位校准
while (ADC_GetResetCalibrationStatus(ADC1) == SET); // 等复位完成
ADC_StartCalibration(ADC1); // 开始校准
while (ADC_GetCalibrationStatus(ADC1) == SET); // 等校准完成
- ADC 内部有电容等模拟电路,上电后会有微小偏差。校准就是让 ADC 自己测出偏差并修正。
- 这是固定流程:每次上电使能后都要执行一次,照着写即可,不用深究。
- 校准完成后,ADC 才能放心使用。
五、AD_GetValue 逐行(怎么真正读一个值)
uint16_t AD_GetValue(void)
{
ADC_SoftwareStartConvCmd(ADC1, ENABLE); // ① 软件触发,开始转换
while (ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC) == RESET); // ② 等转换结束
return ADC_GetConversionValue(ADC1); // ③ 读结果
}
三个动作对应"喊一声 → 等它量完 → 拿结果":
- 触发:因为配置的是软件触发,这里喊一声"开始转换"。转换是硬件自动进行的,CPU 不等它。
- 等待 EOC 标志:EOC = End Of Conversion(转换结束)。硬件转完会把这个标志位置 1。这里用轮询——不断问"好了吗?",没好就一直等。(这是最简单的方式;以后可换成中断/DMA,CPU 就不用干等。)
- 读结果:从 ADC 的数据寄存器读走 12 位结果(0~4095)。
注意:读数据寄存器后,EOC 标志会自动清除,下次可以继续触发。
六、电压换算公式(main.c 的精华)
ADValue = AD_GetValue(); // 0~4095
Voltage = (float)ADValue / 4095 * 3.3; // 换算成 0~3.3V
6.1 公式推导
12 位 ADC,输入 0V 对应 0,输入 3.3V(参考电压)对应 4095,是线性关系:
电压 = AD值 ÷ 最大AD值 × 参考电压 = ADValue / 4095 × 3.3
| ADValue | 计算 | 电压 |
|---|---|---|
| 0 | 0/4095×3.3 | 0.00V |
| 2048 | 2048/4095×3.3 | ≈ 1.65V |
| 4095 | 4095/4095×3.3 | 3.30V |
6.2 为什么要有 (float)ADValue / 4095
ADValue 是整数(uint16_t)。整数除法 ADValue / 4095 会把结果截断成 0 或 1,再乘以 3.3 就只有 0 或 3.3 两个值,完全错误。
(float)ADValue 把 ADValue 强制转成浮点数,之后整个除法就是浮点除法,小数就保住了。这是单片机编程里最常见的坑之一:整数除法丢小数。
错误:ADValue / 4095 * 3.3 // 先整数除法,结果≈0或1
正确:(float)ADValue / 4095 * 3.3 // 先转浮点,保留小数
6.3 OLED 显示小数的一个技巧
OLED_ShowNum(2, 9, Voltage, 1); // 整数部分
OLED_ShowNum(2, 11, (uint16_t)(Voltage * 100) % 100, 2); // 小数部分
OLED 显示不了浮点,于是拆开:
- 整数部分直接显示
Voltage(隐式转成整数)。 - 小数部分:
Voltage * 100把小数移到整数位(如 1.657→165.7),取整后% 100取出后两位(→65),显示成.65。
小提示:
OLED_ShowNum(2, 9, Voltage, 1)里直接把 float 传给形参(uint32_t),C 会隐式截断成整数,能跑但对编译器不够友好,写成(uint16_t)Voltage更规范。
七、main.c 主程序逻辑
uint16_t ADValue; // AD 原始值
float Voltage; // 换算后的电压
int main(void)
{
OLED_Init();
AD_Init();
OLED_ShowString(1, 1, "ADValue:");
OLED_ShowString(2, 1, "Voltage:0.00V");
while (1)
{
ADValue = AD_GetValue(); // 读一次 AD 值
Voltage = (float)ADValue / 4095 * 3.3; // 换成电压
OLED_ShowNum(1, 9, ADValue, 4); // 显示 AD 值
OLED_ShowNum(2, 9, Voltage, 1); // 显示电压整数
OLED_ShowNum(2, 11, (uint16_t)(Voltage * 100) % 100, 2); // 显示电压小数
Delay_ms(100); // 100ms 读一次
}
}
流程:初始化 → 主循环里"读一次 → 换算 → 显示 → 延时 100ms → 再读"。旋动电位器,OLED 上的电压实时变化。
Delay_ms(100) 控制采样节奏:每 100ms 刷新一次显示。想刷更快就减小延时。
八、硬件接线与注意事项
电位器(或任何 0~3.3V 可调电压)──► PA0
GND 共地
- 输入电压必须 0~3.3V。超过 3.3V 可能烧 ADC/引脚;超过后结果会"卡"在 4095 不再变化(也叫"饱和")。
- 如果信号本身可能是 0~5V(比如某些传感器输出),要先分压(两个电阻分压)再进 PA0,不能直接接。
- 信号源要稳定;如果读到的值上下乱跳,可以在 AD 值上加滤波(多读几次取平均,见练习 3)。
九、常见问题与调试
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 读数恒为 0 | PA0 没接信号 / 悬空 | 给 PA0 接一个明确电压(如接 3.3V)看是否变 4095 |
| 读数恒为 4095 | 输入电压超过参考电压 | 确认输入在 0~3.3V;检查是否错接了 5V |
| 读数跳变厉害 | 信号本身不稳 / 没滤波 | 软件多采几次取平均(见练习3) |
| 读数没变化 | 忘了触发 / 没等 EOC / 引脚配错通道 | 确认 AD_GetValue 三步齐全;确认通道0=PA0 |
| 换成其他脚不工作 | 通道和引脚没对应 | 查通道表:PA1=通道1,PA2=通道2… |
| 精度差 | ADC 时钟超 14MHz / 采样时间太短 | 用 6 或 8 分频;采样时间用大档 |
万能排查顺序:先确认输入电压在范围内 → 再看引脚是不是 AIN 模式 → 再查通道号对不对 → 最后查转换流程(触发/等待/读)。
十、动手扩展练习
- 多通道扫描:把 PA0、PA1、PA2 三个通道都接上,配扫描模式,一次转完三路,分别显示。
- ADC + 中断:把"等待 EOC"从轮询改成中断(
ADC_ITConfig+ADC_IRQHandler),转完自动进中断读值,CPU 不再干等。 - 软件滤波(均值):连续读 10 次取平均,观察读数是不是更稳定。
- 定时器触发 + 连续转换 + DMA:让 ADC 以固定频率自动采样,用 DMA 把结果搬进数组——这是做示波器/音频采集的雏形,也是 ADC 的最高效用法。
- 内部温度传感器:用通道16 读芯片温度(需要参考手册里的计算公式)。
- AD 值控制 PWM:读电位器 → 映射到占空比 → 控制 LED 亮度或舵机角度。把 ADC 和前面的 PWM 串起来!
附录:本工程用到的标准库函数
| 函数 | 作用 |
|---|---|
RCC_ADCCLKConfig(RCC_PCLK2_Div6) |
设置 ADC 时钟 = APB2 / 6 = 12MHz |
ADC_RegularChannelConfig(ADC1, ch, rank, time) |
把某个通道放进规则组某位置,设采样时间 |
ADC_Init(ADC1, &init) |
配置 ADC 模式/对齐/触发/单次连续/扫描 |
ADC_Cmd(ADC1, ENABLE) |
使能 ADC |
ADC_ResetCalibration / ADC_StartCalibration |
校准(固定流程) |
ADC_SoftwareStartConvCmd(ADC1, ENABLE) |
软件触发一次转换 |
ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC) |
查转换结束标志 |
ADC_GetConversionValue(ADC1) |
读转换结果(0~4095) |
本篇一句话总结:ADC 把 0~~3.3V 的模拟电压"量化"成 12 位的数字 0~~4095。工程里做对了三件事——PA0 配成模拟输入(AIN)、ADC 时钟分频到 12MHz、单通道单次软件触发;读值时"软件触发 → 等 EOC → 读数据寄存器"三步走;最后用
Voltage = ADValue/4095*3.3换回电压,别忘了(float)防止整数除法丢小数。从此单片机就能"看见"模拟世界了。