嵌入式技术应用设计实训 | ADC 模拟量采集
本课程开源地址(Gitee):https://gitee.com/fujianxinxi/qianrushishixundianzi.git 课件、示例代码与验收脚本都在该仓库,可直接 git clone 或下载 ZIP 使用。
B站配套视频合集:https://space.bilibili.com/572740825/lists?sid=9228122 合集《嵌入式技术应用设计实训|STM32F103 从点灯到智能家居》:每课配一节视频,可先看现象演示,再照着做。
开发板资料A盘(百度网盘):https://pan.baidu.com/s/1_EXrjRUewwmGMgcy38zEZg(提取码:zbri) 正点原子官方资料下载:http://www.openedv.com/docs/boards/stm32/zdyz_stm32f103_nano.html
模块一 基础外设开发 · 2 学时 · 环境:Keil MDK5 + VS Code + 正点原子 STM32F103 Nano V1.3
一句话概述:从“单片机怎么读懂电压”入手,讲清 12 位 ADC 的 0~4095、×3300/4096 的换算与 0.8mV 分辨率;把 ADC 初始化的五步写进 main.c(ADC 时钟 6 分频 → 开时钟 → PB1 配模拟输入 → 配参数 → 校准),采样三步(配通道 / 启动 / 等转换完成)也看得见。两个现象都用 printf:数字电压表(原始值 + 电压 + 百分比)与电压柱(20 格柱条 + 超限提示)。
本节目标
一、原理说明


现实世界里的电压是连续的——电位器拧到哪儿,PB1 上就是多少伏,中间有无数个值;可单片机里的一切都是 0 和 1,它读不出“1.65V 还是 1.66V”。ADC 就是芯片上专门干这件事的部件:它把某个引脚上的电压,按参考电压(本板 3.3V)等分成 4096 份,量出来落在第几份,就给出 0~4095 之间的一个整数。所以我们在程序里根本看不到“电压”,只有一个 0~4095 的“档位”——想要电压值,得自己按 原始值 × 3300 / 4096 换算回来。这件事很“模拟”,也最容易受干扰,所以本课还要顺手讲清两个细节:采样时间(要给内部的采样电容充够电,太快读数就飘)和多次平均(把随机噪声平掉,小数点后面就不乱跳)。
二、讲解内容
2.1 知识讲解
1)模拟量 vs 数字量:ADC 是中间的“翻译官”
| 长什么样 | 连续变化:0V → 1.1V → 1.234V → 3.3V,中间有无数个数 | 一个个离散的整数 |
| 本课的例子 | 电位器滑到哪儿,PB1 上就是多少伏 | ADC 给出的 0 ~ 4095 |
| 谁负责 | 电位器(分压) | ADC(模数转换) |
| 怎么互相换算 | 电压 → 数字:值 = 电压 / 3.3V × 4096 | 数字 → 电压:电压(mV) = 值 × 3300 / 4096 |
2)12 位就是 0~4095:一个“档位”≈ 0.8mV
“12 位”的意思是 ADC 用 12 个二进制位表示结果,能表示 2¹² = 4096 个值,编号从 0 到 4095:
0V → 0 3.3V 被切成 4096 份
一份 = 3.3V / 4096 ≈ 0.0008V = 0.8mV ← 这就是"分辨率"
3.3V → 4095 (也就是:能分辨的最小电压变化是 0.8mV)
3)换算公式与几个对照点
电压(mV) = 原始值 × 3300 / 4096
| 0 | 0 mV | 电位器拧到一头 |
| 1024 | 825 mV | 约 1/4 量程 |
| 2048 | 1650 mV | 约一半 |
| 3072 | 2475 mV | 约 3/4 量程 |
| 4095 | 3299 mV | 另一头(≈3.3V) |
两个容易纠结的小问题
① **为什么最高档是 4095,不是 4096?**12 位有 4096 个“格子”,编号只能是 0~4095。 ② 为什么满量程时百分比显示 99%?因为 4095 / 4096 = 0.9998。用 4095 当分母就是 100%,两种写法差的就是最后那一格(0.8mV)——这 0.8mV 就是 ADC 的量化误差,本质上无法消除。看到 99% 别以为是 bug。
4)ADC 要配哪几样(本课初始化五步)
| ① ADC 时钟分频 | ADC 时钟不能超过 14MHz,本板 PCLK2 = 72MHz,要 6 分频 | RCC_ADCPCLK2_DIV6 + HAL_RCCEx_PeriphCLKConfig() | 默认 72/2 = 36MHz 超频,读数不可信(还不会报错) |
| ② 开外设时钟 | ADC1 自己 + PB1 所在的 GPIOB | __HAL_RCC_ADC1_CLK_ENABLE() 等两行 | 后面全白写 |
| ③ 配引脚 | PB1 配成模拟输入 | GPIO_MODE_ANALOG + GPIO_NOPULL | 引脚还当数字口用,量不准(带上下拉更会把电压抬高) |
| ④ 配参数 | 12 位、右对齐、单次转换、软件触发 | ADC1_Handler.Init.* + HAL_ADC_Init() | 参数不对,读出的不是 0~4095 |
| ⑤ 校准 | 用之前校一次零漂 | HAL_ADCEx_Calibration_Start() | 读数会整体偏一点 |
5)读一次 ADC 的四个动作
配通道 → 启动转换 → 等它转完 → 取值
| 配通道 | HAL_ADC_ConfigChannel(&ADC1_Handler, &ch_conf) | 告诉 ADC“这次测哪个通道、采样多久”;ADC_CHANNEL_9 就是 PB1 |
| 启动 | HAL_ADC_Start(&ADC1_Handler) | 因为我们配的是 ADC_SOFTWARE_START(软件触发),调它才转一次 |
| 等转换完 | HAL_ADC_PollForConversion(&ADC1_Handler, 10) | 轮询等待,最多 10ms;这一步卡住 CPU,所以本课一次只读一个值 |
| 取值 | HAL_ADC_GetValue(&ADC1_Handler) | 读出 12 位结果,0~4095 |
6)采样时间:为什么默认要 239.5 个周期那么长?
ADC 内部有一个很小的采样电容,测量时要先把引脚上的电压“灌”进这个电容,灌满了才量得准。这个充电过程需要时间,而这个时间由 SamplingTime 决定:
| ADC_SAMPLETIME_1CYCLE_5 | ~0.1 µs | 信号源阻抗很低、要求快 |
| ADC_SAMPLETIME_239CYCLES_5 | ~20 µs | 本课默认:电位器这类信号源阻抗不低,宁可慢一点也要准 |
采样时间太短 → 电容没充满 → 读数偏小、而且抖。练习 P6.3 就是让你把它改短,亲眼看到“抖”。
7)为什么要采几次求平均?
ADC 的最后一位(0.8mV)本来就会上下跳,加上电源和走线的噪声,一次读数会来回变。把采 n 次的结果加起来除以 n(本课 n = 3),随机噪声互相抵消一部分,读数就稳了:
| 1 次 | 小数点后面乱跳 | 最快 |
| 3 次(本课) | 稳不少 | 读一次约 60 µs |
| 20 次 | 很稳 | 慢,而且遇到真变化会“钝” |
8)两个现象:同一份采样,两种“看”法
| APP_MODE | 1 | 2 |
| 串口每行 | ADC = 2048 V = 1650 mV (1.650 V) 50% | V = 1650 mV [##########……….] 50% |
| 额外内容 | —— | 超 2.5V 时打印 !! 超限 !! |
| 讲什么 | 原始值 / 电压 / 百分比三种说法一起给,拧电位器时三个数同步变 | 把“连续量”画成一条会伸缩的柱条——这就是最朴素的“仪表盘”,也是后面做“阈值报警”的雏形 |
2.2 代码示例
本课改两个文件、搬空两个文件;另外两个目录本课不用(都在 课时文档/代码/课时06-ADC 模拟量采集/ADC实验/ 下):
USER/main.c ← 本课主角:APP_MODE / UART1_Init / ADC1_Init / adc_read / adc_read_avg / main
SYSTEM/usart/usart.c、usart.h ← 沿用课时03~05 的"只发送"版本,供 printf 使用
HARDWARE/ADC/adc.c、adc.h ← 搬空了,只留一段说明(代码已全部进 main.c)
HARDWARE/SMG/、HARDWARE/TIMER/ ← 本课不用:数码管和它的扫描定时器,原样留在工程里没动
工程里其余文件(HARDWARE/LED、SYSTEM/sys、SYSTEM/delay、HALLIB、CORE)都是现成的,不用动。
为什么本课不用数码管?厂家这个例程为了把数字“显示”出来,额外引入了 74HC138(位选)+ 74HC595(段选) 两片芯片和 5 根控制线,还占用了 PB3/PB4(JTAG 引脚,用之前得先把 JTAG 关掉),并且专门开了一个 2ms 定时器只为数码管动态扫描——这些都和“ADC 怎么读电压”没关系,初学很容易被带跑。本课把显示交给串口 + printf(课时03 已经学过的工具),ADC 本身才是重点;数码管留到课时07 单独讲。
SYSTEM/usart/usart.h(完整代码)
#ifndef __USART_H
#define __USART_H
#include "stdio.h"
#include "sys.h"
//////////////////////////////////////////////////////////////////////////////////
// 《嵌入式技术应用设计实训》课时06 ADC 模拟量采集 —— 串口(只用发送)
// 本文件基于正点原子 ALIENTEK NANO STM32F103 厂家例程改写,
// 原例程版权归广州市星翼电子科技有限公司所有。
//
// 本课串口只干一件事:用 printf 把 ADC 采到的数值打到电脑上看。
// printf ──> fputc() ──> uart_putc() ──> USART1
// 串口初始化写在 USER/main.c 的 UART1_Init() 里。
//////////////////////////////////////////////////////////////////////////////////
extern UART_HandleTypeDef UART1_Handler; // USART1 句柄:usart.c 里定义、main.c 里初始化
void uart_send(const u8 *buf, u16 len); // 本课唯一的发送出口
#endif
SYSTEM/usart/usart.c(完整代码)
#include "sys.h"
#include "usart.h"
//////////////////////////////////////////////////////////////////////////////////
// 《嵌入式技术应用设计实训》课时06 ADC 模拟量采集 —— 串口底层(只用发送)
// 本文件基于正点原子 ALIENTEK NANO STM32F103 厂家例程改写,
// 原例程版权归广州市星翼电子科技有限公司所有。
//
// 只留一条发送路径(沿用课时03/04/05 的做法):
// ① uart_putc() 最底层:等上一个字节发完,再把一个字节写进数据寄存器
// ② uart_send() 对外唯一出口
// ③ fputc() 把 printf 接到 uart_putc() 上
//////////////////////////////////////////////////////////////////////////////////
UART_HandleTypeDef UART1_Handler; // USART1 句柄
// 最底层:发 1 个字节(SR 的 bit6 是 TC,等上一个字节发完再写 DR)
static void uart_putc(u8 ch)
{
while ((USART1->SR & 0x40) == 0); // 等 TC = 1
USART1->DR = ch; // 写数据寄存器,硬件自动一个位一个位发出去
}
// 对外唯一出口:发一串字节
void uart_send(const u8 *buf, u16 len)
{
while (len—) uart_putc(*buf++);
while ((USART1->SR & 0x40) == 0); // 等最后一个字节真正发完再返回
}
// printf 重定向:printf 最终是一个字符一个字符地调 fputc()
// Keil 里必须勾选 Options for Target → Target → Use MicroLIB,否则 printf 不出声
#pragma import(__use_no_semihosting) // 不使用半主机模式,免得卡在 BKPT 上
struct __FILE // 标准库要求有这个类型
{
int handle;
};
FILE __stdout;
void _sys_exit(int x) // 屏蔽半主机相关的退出函数
{
x = x;
}
int fputc(int ch, FILE *f)
{
uart_putc((u8)ch); // 和 uart_send() 走同一条路
return ch;
}
USER/main.c(完整代码)
#include "sys.h"
#include "delay.h"
#include "usart.h"
#include "led.h"
//////////////////////////////////////////////////////////////////////////////////
// 《嵌入式技术应用设计实训》课时06 ADC 模拟量采集
// 本文件基于正点原子 ALIENTEK NANO STM32F103 厂家例程(ADC实验-HAL库版本)改写,
// 原例程版权归广州市星翼电子科技有限公司所有。
//
// 硬件:正点原子 STM32F103 Nano V1.3(全部板载,不用接线)
// PB1(= ADC1 的通道 9)接板载电位器:拧它,PB1 上的电压就在 0~3.3V 之间变化
// USART1 → PA9 = TX、PA10 = RX,板上已连到板载 ST-Link 虚拟串口,用 printf 把数值打到电脑上
// 8 个 LED:PC0~PC7(本课不用它,只是上电全灭,别让灯悬空;加分项"越限点灯"会用到)
//
// 本课要看清的一件事:**ADC 把 0~3.3V 的电压,翻译成 0~4095 的数字**
// 电压(mV) = 原始值 × 3300 / 4096
// 12 位 ADC 把 3.3V 切成 4096 份,一份 ≈ 0.8mV —— 这就是"分辨率"
//
// 两个现象(改下面 APP_MODE 那一行,换现象要重新编译下载):
// 现象 1 数字电压表:每 200ms 打印一行"原始值 + 电压(mV) + 百分比"
// 现象 2 电压柱 :数字后面再画一条 20 格的柱条,随电压伸缩;超过 2.5V 提示"超限"
//
// 本课同样把 ADC 初始化写在 main.c 里(和课时03/04/05 一致):
// 时钟、引脚、参数、校准一步步都看得见,不是黑盒。
//////////////////////////////////////////////////////////////////////////////////
/* ==================== 演示现象选择(改这一行即可) ====================
1 = 数字电压表:原始值 + 电压(mV) + 百分比
2 = 电压柱 :数值 + 20 格柱条 + 超限提示
改完必须重新编译下载,否则板子上跑的还是上一个程序。
==================================================================== */
#define APP_MODE 1
#if (APP_MODE != 1) && (APP_MODE != 2)
#error "APP_MODE 只能填 1 或 2,请修改 main.c 开头的 APP_MODE 宏"
#endif
ADC_HandleTypeDef ADC1_Handler; // ADC1 句柄
// ——————————————————————————
// 串口 1 初始化(沿用课时03/04/05):本课用它打印 ADC 的数值
// ① 开时钟 ② 配 PA9/PA10 复用 ③ 配 115200-8-N-1
// ——————————————————————————
static void UART1_Init(u32 bound)
{
GPIO_InitTypeDef GPIO_Initure;
/* ① 开时钟(不开时钟,后面写寄存器全部无效,而且不报错) */
__HAL_RCC_GPIOA_CLK_ENABLE(); // PA9 / PA10 在 GPIOA
__HAL_RCC_USART1_CLK_ENABLE(); // USART1 自己
__HAL_RCC_AFIO_CLK_ENABLE(); // STM32F1 的复用功能要经过 AFIO
/* ② 配引脚:把 PA9、PA10 交给 USART1 去管 */
GPIO_Initure.Pin = GPIO_PIN_9; // PA9 = TX
GPIO_Initure.Mode = GPIO_MODE_AF_PP; // 复用推挽输出
GPIO_Initure.Pull = GPIO_PULLUP;
GPIO_Initure.Speed = GPIO_SPEED_FREQ_HIGH;
HAL_GPIO_Init(GPIOA, &GPIO_Initure);
GPIO_Initure.Pin = GPIO_PIN_10; // PA10 = RX
GPIO_Initure.Mode = GPIO_MODE_AF_INPUT; // 复用输入
HAL_GPIO_Init(GPIOA, &GPIO_Initure);
/* ③ 配参数:两边必须一模一样 */
UART1_Handler.Instance = USART1;
UART1_Handler.Init.BaudRate = bound; // 115200
UART1_Handler.Init.WordLength = UART_WORDLENGTH_8B; // 8 位数据位
UART1_Handler.Init.StopBits = UART_STOPBITS_1; // 1 位停止位
UART1_Handler.Init.Parity = UART_PARITY_NONE; // 无校验
UART1_Handler.Init.HwFlowCtl = UART_HWCONTROL_NONE; // 无硬件流控
UART1_Handler.Init.Mode = UART_MODE_TX_RX; // 收发都开
HAL_UART_Init(&UART1_Handler);
}
// ——————————————————————————
// ADC1 初始化:PB1(= 通道 9)上接的是板载电位器
// 分五步,前三步和前面几课一个套路:
// ① ADC 时钟分频:ADC 的时钟**不能超过 14MHz**。本板 PCLK2 = 72MHz,
// 所以必须 6 分频 → 12MHz(不配的话默认 72/2 = 36MHz,超频,读数不可信)
// ② 开外设时钟:ADC1 自己 + PB1 所在的 GPIOB
// ③ 配引脚:PB1 配成"模拟输入"(GPIO_MODE_ANALOG,不带上下拉)
// ④ 配参数:12 位、右对齐、单次转换、软件触发
// ⑤ 校准:ADC 出厂有零漂,用之前先校一次(HAL 要求)
// ——————————————————————————
static void ADC1_Init(void)
{
GPIO_InitTypeDef GPIO_Initure;
RCC_PeriphCLKInitTypeDef ADC_CLKInit;
/* ① ADC 时钟:72MHz / 6 = 12MHz */
ADC_CLKInit.PeriphClockSelection = RCC_PERIPHCLK_ADC; // 要配的是 ADC 的时钟
ADC_CLKInit.AdcClockSelection = RCC_ADCPCLK2_DIV6; // PCLK2 6 分频 → 12MHz
HAL_RCCEx_PeriphCLKConfig(&ADC_CLKInit);
/* ② 开时钟 */
__HAL_RCC_ADC1_CLK_ENABLE(); // ADC1 自己(挂在 APB2 上)
__HAL_RCC_GPIOB_CLK_ENABLE(); // PB1 在 GPIOB
/* ③ 配引脚:模拟输入——引脚直接接到 ADC 的采样电路上,不经过数字输入缓冲 */
GPIO_Initure.Pin = GPIO_PIN_1; // PB1 = ADC1 的通道 9
GPIO_Initure.Mode = GPIO_MODE_ANALOG; // 模拟模式(既不是输入,也不是复用)
GPIO_Initure.Pull = GPIO_NOPULL; // 不带上下拉(带上拉会把电压"抬"高)
HAL_GPIO_Init(GPIOB, &GPIO_Initure);
/* ④ 配参数 */
ADC1_Handler.Instance = ADC1;
ADC1_Handler.Init.DataAlign = ADC_DATAALIGN_RIGHT; // 右对齐:读出来就是 0~4095
ADC1_Handler.Init.ScanConvMode = DISABLE; // 非扫描:只测一个通道
ADC1_Handler.Init.ContinuousConvMode = DISABLE; // 不连续转换:每次都要手动启动
ADC1_Handler.Init.NbrOfConversion = 1; // 规则序列里只有 1 个通道
ADC1_Handler.Init.DiscontinuousConvMode = DISABLE; // 不连续采样:关
ADC1_Handler.Init.NbrOfDiscConversion = 0;
ADC1_Handler.Init.ExternalTrigConv = ADC_SOFTWARE_START; // 软件触发:调 HAL_ADC_Start 才转一次
HAL_ADC_Init(&ADC1_Handler);
/* ⑤ 校准:不做这一步,读出来的值会偏 */
HAL_ADCEx_Calibration_Start(&ADC1_Handler);
}
// ——————————————————————————
// 读一次 ADC:配通道 → 启动转换 → 等它转完 → 取值
// 配通道:告诉 ADC"这次测哪个通道、采样多久"
// 启动 :软件触发(因为上面配的是 ADC_SOFTWARE_START)
// 等待 :轮询,最多等 10ms
// 取值 :12 位结果,0~4095
// ——————————————————————————
static u16 adc_read(u32 ch)
{
ADC_ChannelConfTypeDef ch_conf;
ch_conf.Channel = ch; // 通道(本课 = ADC_CHANNEL_9,因为接在 PB1)
ch_conf.Rank = 1; // 放在转换序列的第 1 位
ch_conf.SamplingTime = ADC_SAMPLETIME_239CYCLES_5; // 采样时间:越长越稳(源阻抗高就必须长)
HAL_ADC_ConfigChannel(&ADC1_Handler, &ch_conf);
HAL_ADC_Start(&ADC1_Handler); // 开始转换
HAL_ADC_PollForConversion(&ADC1_Handler, 10); // 等它转完(最多 10ms)
return (u16)HAL_ADC_GetValue(&ADC1_Handler); // 读出 0~4095
}
// ——————————————————————————
// 采 times 次求平均:把随机噪声"平"掉,小数点后面就不会乱跳
// 代价:采得越多越稳,但读一次也越慢
// ——————————————————————————
static u16 adc_read_avg(u32 ch, u8 times)
{
u32 sum = 0;
u8 t;
for (t = 0; t < times; t++) sum += adc_read(ch);
return sum / times;
}
int main(void)
{
u16 adc; // 原始值:0~4095
u32 mv; // 电压:单位 mV
#if (APP_MODE == 2)
u8 bars; // 柱条格数:0~20
u8 i;
#endif
HAL_Init(); // 初始化 HAL 库:顺带配好 1ms 时基
Stm32_Clock_Init(RCC_PLL_MUL9); // 系统时钟:倍频到 72MHz
delay_init(72); // 初始化延时函数
LED_Init(); // 8 个 LED 上电全灭(本课不用它,只是别留悬空的灯)
UART1_Init(115200); // 串口 1:用来打印 ADC 的数值
ADC1_Init(); // ADC1:PB1 上的电位器
printf("\\r\\n");
printf("嵌入式技术应用设计实训 | 课时06 ADC 模拟量采集\\r\\n");
printf("拧板上的电位器,PB1 上的电压在 0~3.3V 之间变化\\r\\n\\r\\n");
#if (APP_MODE == 1)
/* ==================== 现象 1:数字电压表 ====================
每 200ms 打印一行:原始值(0~4095) + 电压(mV) + 百分比
参考对照:1024 → 825mV;2048 → 1650mV;3072 → 2475mV;4095 → 3299mV
(为什么满量程是 99% 不是 100%?因为最高档是 4095,除以 4096 差一点点) */
while (1)
{
adc = adc_read_avg(ADC_CHANNEL_9, 3); // 采 3 次求平均
mv = (u32)adc * 3300 / 4096; // 整数换算:0~4095 → 0~3300mV
printf("ADC = %4u V = %4u mV (%u.%03u V) %3u%%\\r\\n",
adc, mv, mv / 1000, mv % 1000, (u32)adc * 100 / 4096);
delay_ms(200);
}
#elif (APP_MODE == 2)
/* ==================== 现象 2:电压柱 ====================
在数字后面再画一条 20 格的柱条,随电压伸缩(0~3.3V → 0~20 格);
电压超过 2.5V(原始值约 3103)时,额外打一个"超限"提示——这就是"报警"的雏形 */
while (1)
{
adc = adc_read_avg(ADC_CHANNEL_9, 3);
mv = (u32)adc * 3300 / 4096;
bars = (u8)((u32)adc * 20 / 4096); // 0~4095 → 0~20 格
printf("V = %4u mV [", mv);
for (i = 0; i < 20; i++) printf("%s", i < bars ? "#" : ".");
printf("] %3u%%", (u32)adc * 100 / 4096);
if (mv > 2500) printf(" !! 超限 !!"); // 2.5V 以上报警
printf("\\r\\n");
delay_ms(200);
}
#endif
}
HARDWARE/ADC/adc.c、adc.h(本课为“空壳”)
厂家把 ADC 代码放在这两个文件里,本课把它们搬进了 main.c,所以这两个文件只剩一段说明(原文见附录 B)。HARDWARE/SMG/(数码管)和 HARDWARE/TIMER/(它的扫描定时器)本课完全没动、也没用。
代码里最值得盯住的 4 处
| RCC_ADCPCLK2_DIV6 | ADC 时钟不能超 14MHz:72MHz 必须 6 分频到 12MHz;不配就默认 36MHz 超频,读数不可信还不报错 |
| GPIO_MODE_ANALOG + GPIO_NOPULL | 模拟输入要把引脚直接交给 ADC;带上下拉会把你测的电压“抬”高 |
| HAL_ADCEx_Calibration_Start() | 校准零漂;少了这一步整条曲线都会偏 |
| adc_read_avg(ADC_CHANNEL_9, 3) | 采 3 次求平均去噪声;配合 delay_ms(200) 就是“每 200ms 读一次表” |
三、实验操作与现象
3.1 接线与操作步骤
本课不用接任何线:电位器、LED 都在板上,串口走板载 ST-Link 的虚拟串口。
| 板载电位器 | 板上的可调电阻(一个小十字旋钮/旋钮帽) | 拧它,PB1 上的电压就在 0~3.3V 之间变——这是本课唯一的“输入” |
| 板载 ST-Link(下载 + 串口一体) | 板上标着 ST-Link 的 USB 口 → 电脑 USB 口 | 一根线管两件事:下载程序 + 串口收发;设备管理器 → 端口里就是 STMicroelectronics STLink Virtual COM Port (COMx) |
| 板上的 SWD 排针 | (本课程不接) | 备用口:只有换外接下载器时才接 SWDIO / SWCLK / GND |
操作步骤(照着做就能跑):
3.2 观察到的现象
本课现象以随课视频演示为准;文档不逐条配截图,你自己做完请拍一张或录一段,作为提交物。
| 现象 1 · 串口 | 每 200ms 一行:ADC = 2048 V = 1650 mV (1.650 V) 50%,三列对齐 | 拧电位器:原始值在 0~4095 之间连续变,电压在 0~3299mV 之间跟着变;2048 ↔ 1650mV 要对得上 |
| 现象 2 · 串口 | V = 1650 mV [##########……….] 50%,柱条随拧动伸缩 | 柱条格数 ≈ 原始值 / 4096 × 20;拧到 2.5V 以上会多出 !! 超限 !! |
| 板载 8 个 LED | 全部熄灭(本课不用它们) | 若有灯微亮,说明 LED_Init() 没执行,或 PC 口被别的代码动过 |
要学生自己填的一张表(课堂练习 P6.1 用):
| 拧到一头 | ||||
| 中间附近 | ||||
| 拧到另一头 |
图注:现象 1,串口每 200ms 打印一行原始值 / 电压 / 百分比(截图待补)

图注:现象 2,柱条随电位器拧动伸缩,超过 2.5V 出现“超限”提示(截图待补)
看不到现象怎么办(按最可能的顺序查):
| 串口一行都没有 | 没勾 Use MicroLIB;或 COM 口 / 波特率不对 | 见课时03 排查表 |
| 原始值一直是 0,拧了也不动 | ① PB1 上不是电位器(板子版本不同)② PB1 没配成模拟输入 | 先用万用表量 PB1 对 GND 的电压(拧电位器时应在 0~3.3V 变);再确认 GPIO_MODE_ANALOG 与 GPIO_PIN_1 |
| 原始值一直是 4095(或顶死不动) | 引脚被上拉了,或接到了 3.3V | Pull 必须是 GPIO_NOPULL |
| 数值上下来回跳得厉害 | 噪声大 / 采样时间太短 / 平均次数太少 | 先把平均次数调到 10,再确认采样时间是 239CYCLES_5(对应练习 P6.3、P6.4) |
| 数值整体明显偏小 | ADC 时钟超频(忘了 6 分频) | 确认 RCC_ADCPCLK2_DIV6 那句在 |
| 拧到顶也只显示 99% | 正常:4095 / 4096 = 0.9998 | 想显示 100% 就把分母改成 4095(见 2.1 第 3 条) |
执行前后对比(改一个数,看串口上的差别)
| 采样时间 239CYCLES_5 → 1CYCLE_5 | 读数比较稳 | 小数点后明显乱跳(采样电容没充满) |
| 平均次数 3 → 1,再 → 20 | 跳动小 | 1 次时乱跳、20 次时几乎不动,但反应变“钝” |
| 打印节奏 delay_ms(200) → 1000 | 一秒 5 行 | 一秒 1 行:更“稳”,但变化看得慢 |
| 分母 4096 → 4095 | 满量程 99% | 满量程 100%(差的还是那一格 0.8mV) |
四、学生练习
课堂上按顺序做,一条一条改、一条一条看串口:
| P6.1 | 摸清量程(把 3.2 的表填完) | 慢慢拧电位器到一头、中间、另一头,把三个数抄下来 | 原始值 ≈0 ↔ ≈4095、电压 ≈0 ↔ ≈3299mV;手算值和串口打印值对得上(±1) |
| P6.2 | 手算验证公式 | 随手记下串口某一行的原始值(比如 2048),自己按 ×3300/4096 算出 mV | 和串口那行的电压一致——证明公式你懂了,不是背的 |
| P6.3 | 把采样时间改短 | ADC_SAMPLETIME_239CYCLES_5 → ADC_SAMPLETIME_1CYCLE_5 | 小数点后面抖得明显;改回去就不抖了 → 结论:采样时间要给电容充够电 |
| P6.4 | 改平均次数 | adc_read_avg(ADC_CHANNEL_9, 3) → 1,再 → 10 | 1 次最跳、10 次最稳;代价是读数变“慢” |
| P6.5 | 改显示节奏 | delay_ms(200) → 1000 | 一秒一行:数字更好读,但拧电位器的“跟手感”变差 |
| P6.6 | 挑战:给柱条加"刻度感" | 把 20 格改成 40 格(更细),或每 5 格用一个 ` | ` 做刻度;要求不越界(0 格、40 格都不许崩) |
五、大作业(串口电压表 + 电压柱)
本课不碰数码管,所以大纲里的实验五「ADC 模拟采集与数码管显示」留到课时07(数码管课)一起做——那时两样都学过了,合起来正合适。本课的“五、”按课时01~05 的写法,仍写大作业。 当堂布置、当堂动手,做不完的课后接着做,下次课开课先收。
5.1 任务书
必做 1|数字电压表
- 每 200 ms 打印一行,三列对齐:原始值(0~4095)、电压(mV,整数换算)、百分比
- 电压必须用整数算(× 3300 / 4096),不许用浮点
- 参考格式:ADC = 2048 V = 1650 mV (1.650 V) 50%
必做 2|文本电压柱
- 同一行画出 20 格柱条:V = 1650 mV [##########……….] 50%
- 随电位器拧动平滑伸缩,两端不越界(不许出现多于 20 格或负数格)
- 电压超过 2.5V 时多打一个 !! 超限 !!
必做 3|把两个“旋钮”做成可调
- 把采样时间和平均次数各做成一个 #define(或用参数传入),并在注释里各写一句作用
- 要求:改这两个值,串口上的“抖动程度”要有肉眼可见的变化(对应练习 P6.3 / P6.4 的结论)
加分项(任选,做几项算几项)
| 1 | 越限报警:电压超过 2.5V 时,除了打印提示,还把板载 DS0 点亮(低电平点亮) | 课时01 |
| 2 | 按键切显示:KEY0 按一下,在“原始值 / 电压 / 百分比”三种显示之间轮换 | 课时02 |
| 3 | 定时器定时采样:用 TIM3 每 200 ms 中断里只置一个标志,主循环看到标志才采样 + 打印 | 课时04(“中断只置标志、主循环干活”) |
挑战项|自己标定
- 拧到 3~4 个位置,用万用表量 PB1 对 GND 的实际电压,和串口打印值逐一对比
- 算一算最大误差有多少 mV,并用一句话解释误差来源(提示:参考电压不一定是精确的 3.300V、量化误差 0.8mV、噪声、电位器本身)
5.2 分步提示
5.3 现象与验收标准
| 量程 | 拧到两端:原始值 ≈0 ↔ ≈4095、电压 ≈0 ↔ ≈3299mV |
| 换算 | 随手挑一行,原始值 × 3300 ÷ 4096 手算结果与串口打印一致(±1) |
| 柱条 | 20 格随电压单调伸缩;两端不越界;2.5V 以上出现 !! 超限 !! |
| 抖动 | 采样时间/平均次数改动后,抖动程度有明显变化(能说清为什么) |
| 编译下载 | 0 Error(s), 0 Warning(s);Programming Done. Verify OK. |
| 加分 / 挑战 | 按 5.1 逐项现场演示(做多少算多少) |
5.4 提交物
- 代码:课时文档/代码/课时06-ADC 模拟量采集/ADC实验/USER/main.c
- 实测表(3.2 节那张,3 行填全)+ 串口截图 2 张(现象 1、现象 2 各一张)
- 一段 15 秒以内的视频:拧电位器,串口数值与柱条同步变化
- 一句话说明:“为什么最高档是 4095 而不是 4096?0.8mV 是怎么来的?”
六、本节小结与下节预告
本节让单片机第一次“读懂”了外面的电压:ADC 把 0~3.3V 切成 4096 份,给出 0~4095 的整数,我们再用 ×3300/4096 把它换回电压——一份 0.8mV,就是这块 ADC 能分辨的最小变化。初始化按五步走(ADC 时钟 6 分频 → 开时钟 → PB1 配模拟输入 → 配参数 → 校准),每一步“不做会怎样”都写在表里;读一次则只有四个动作(配通道 → 启动 → 等转换 → 取值),而采样时间和平均次数这两个“旋钮”,决定了读数稳不稳。最后两个现象都靠 printf:数字电压表把“原始值 / 电压 / 百分比”一起给出来,电压柱把连续量画成会伸缩的柱子,还带一个“超限”提示——这正是后面做传感器报警的雏形。三句话记住本课:12 位就是 0~4095,一份 0.8mV;PB1 要配成模拟输入(GPIO_MODE_ANALOG);ADC 时钟不能超 14MHz,所以必须 6 分频。下节进入数码管:把数字“显示”出来(动态扫描 + 74HC138/74HC595),做完正好把大纲里的实验五(ADC + 数码管)收口。
本节函数与宏速查
| ADC1_Init() | ADC 初始化(本课写在 main.c):时钟分频 → 开时钟 → 配 PB1 → 配参数 → 校准 |
| RCC_ADCPCLK2_DIV6 + HAL_RCCEx_PeriphCLKConfig() | ADC 时钟 72MHz → 12MHz(ADC 最高 14MHz,不配就是 36MHz 超频) |
| __HAL_RCC_ADC1_CLK_ENABLE() / __HAL_RCC_GPIOB_CLK_ENABLE() | 给 ADC1 和 PB1 所在的 GPIOB 开时钟 |
| GPIO_MODE_ANALOG + GPIO_NOPULL | 把 PB1 交给 ADC(模拟输入,不带上下拉) |
| HAL_ADC_Init(&hadc) | 把 Init.* 参数写进寄存器(12 位、右对齐、单次、软件触发) |
| HAL_ADCEx_Calibration_Start(&hadc) | 校准零漂,不校准读数会偏 |
| HAL_ADC_ConfigChannel(&hadc, &ch) | 配通道:测哪个通道(ADC_CHANNEL_9 = PB1)、采样多久 |
| HAL_ADC_Start(&hadc) | 软件触发一次转换 |
| HAL_ADC_PollForConversion(&hadc, 10) | 轮询等转换完成(最多 10ms) |
| HAL_ADC_GetValue(&hadc) | 读出 12 位结果 0~4095 |
| ADC_SAMPLETIME_239CYCLES_5 | 采样时间:越长越稳(HAL 里还有 1.5 / 7.5 / 13.5 … 可选) |
| 电压(mV) = 值 × 3300 / 4096 | 数字 → 电压的换算;分辨率 3300/4096 ≈ 0.8mV |
附录
A. 怎么编译下载(照着做就能跑)
1. 上电接线:板上标着 ST-Link 的 USB 口用一根线插电脑(供电 + 下载 + 串口都走它,不用接别的线)
2. Keil 打开:课时文档/代码/课时06-ADC 模拟量采集/ADC实验/USER/ADC.uvprojx
3. 选下载器:Options for Target → Debug → ST-Link Debugger → OK
4. 勾 MicroLIB:Options for Target → Target → 勾 Use MicroLIB(printf 才出声)
5. 选现象 :main.c 开头 #define APP_MODE 1(数字电压表)或 2(电压柱)
6. 编译 :Build(F7),确认 0 Error(s), 0 Warning(s)
7. 下载 :Download(F8),确认 Programming Done. Verify OK.
8. 看现象 :串口助手选对 COM 口、115200-8-N-1 并打开串口,按 RST,
然后慢慢拧板上的电位器:串口上的数值(和柱条)跟着变
B. 本课代码
本课改两个文件、搬空两个文件,还有两个目录本课不用(完整代码见 2.2 节):
| USER/ADC.uvprojx | Keil 工程文件(用 Keil 打开这个) |
| USER/main.c | 本课主角:APP_MODE、UART1_Init()、ADC1_Init()、adc_read()、adc_read_avg()、main() |
| SYSTEM/usart/usart.h | 串口(只发送)接口:uart_send() 声明、UART1_Handler 外部声明 |
| SYSTEM/usart/usart.c | 串口底层:uart_putc() / uart_send() / fputc()(沿用课时03~05 的“只发送”版本) |
| HARDWARE/ADC/adc.c、adc.h | 搬空了,只剩一段说明(代码已进 main.c);保留是为了不动 .uvprojx |
| HARDWARE/SMG/、HARDWARE/TIMER/ | 本课不用:数码管驱动(74HC138+74HC595)和它的 2ms 扫描定时器,原样留着,课时07 再用 |
| HARDWARE/LED/led.c、led.h | LED 驱动:PC0~PC7 配成推挽输出并上电置全灭(本课只调用 LED_Init() 让灯别悬空) |
| SYSTEM/sys、SYSTEM/delay | 时钟配置、SysTick 延时(delay_ms) |
| HALLIB/、CORE/ | STM32F1 HAL 库与启动文件 |
| OBJ/ | 编译输出(已加入 .gitignore,不进仓库) |
HARDWARE/ADC/adc.h(完整代码)
#ifndef __ADC_H
#define __ADC_H
//////////////////////////////////////////////////////////////////////////////////
// 《嵌入式技术应用设计实训》课时06 ADC 模拟量采集
//
// 说明:本课把 ADC 的代码全部写在 USER/main.c 里——
// ADC1_Init() 初始化(ADC 时钟分频 → 开时钟 → 配 PB1 模拟输入 → 配参数 → 校准)
// adc_read() 读一次:配通道 → 启动转换 → 等转换完 → 取值
// adc_read_avg() 采 n 次求平均(去噪声)
// 一节课就能看全"电压是怎么变成 0~4095 的",不用在文件之间跳。
//
// 所以本文件暂时只留这段说明,没有任何声明。
// 工程里保留 HARDWARE/ADC 目录,是为了和厂家例程的工程结构一致(.uvprojx 不用改)。
//////////////////////////////////////////////////////////////////////////////////
#endif
HARDWARE/ADC/adc.c(完整代码)
//////////////////////////////////////////////////////////////////////////////////
// 《嵌入式技术应用设计实训》课时06 ADC 模拟量采集
//
// 本文件是厂家例程(ADC实验-HAL库版)里放 ADC 代码的地方,厂家写法是:
// Adc_Init() 初始化(含 ADC 时钟分频、开时钟、配 PB1)
// HAL_ADC_MspInit() 开 ADC1 / GPIOB 时钟,把 PB1 配成模拟输入(藏在 HAL 回调里)
// Get_Adc() 读一次:配通道 → HAL_ADC_Start → 等转换 → 取值
// Get_Adc_Average() 采 n 次求平均
// 这些东西全在这个文件里,学生打开 main.c 只看到一句 Adc_Init(),
// "电压从哪个引脚进来、怎么变成 0~4095"完全看不见——课时04、课时05 已经踩过这个坑。
//
// 本课改法(与课时03/04/05 一致):把初始化搬进 USER/main.c,看得见全链路:
// USER/main.c:UART1_Init() / ADC1_Init() / adc_read() / adc_read_avg() / main()
//
// 顺便记一句:厂家这个例程为了"显示"数值,额外引入了数码管
// (HARDWARE/SMG:74HC138 位选 + 74HC595 段选)和一个只为数码管动态扫描而存在的
// 定时器(HARDWARE/TIMER,2ms 中断)——本课**两个都不用**:数值直接用 printf 打到
// 电脑上看,ADC 本身才是本课要讲的东西。那两个目录原样留在工程里(不动 .uvprojx),
// 等课时07 讲数码管时还会用到它们。
//////////////////////////////////////////////////////////////////////////////////
C. ADC 换算速查(12 位、参考电压 3.3V)
| 0 | 0 mV | 0% | 0 格 |
| 512 | 412 mV | 12% | 2 格 |
| 1024 | 825 mV | 25% | 5 格 |
| 2048 | 1650 mV | 50% | 10 格 |
| 3072 | 2475 mV | 75% | 15 格 |
| 4095 | 3299 mV | 99% | 19 格 |
注意最后一行:三个数都“差一格”。这不是 bug,是两件事叠在一起: ① 量化误差——12 位 ADC 的最高档就是 4095,4095/4096 = 0.9998; ② 整数除法截断——4095 × 20 / 4096 = 19(小数被丢掉了)。 想让满量程显示 100% 和满格,把分母从 4096 改成 4095 即可(电压用 ×3300/4095 时,满量程正好 3300mV);本质差的就是那 0.8mV。
相关文档
- 课程大纲(课时06 在“一、基础外设开发模块”)
- 课程文档写作规范(0.1 教学环境约定)
- 上一课:课时05 PWM 输出:频率与占空比
- 下一课:课时07 数码管显示:动态扫描

