欢迎光临
我们一直在努力

06 嵌入式技术应用设计实训 | ADC 模拟量采集

嵌入式技术应用设计实训 | 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 格柱条 + 超限提示)。

本节目标

  • 能说出 12 位 ADC 的 0~4095 是怎么来的,并算出本课的换算公式与分辨率(3.3V / 4096 ≈ 0.8mV)
  • 能照着 ADC1_Init() 说出五个步骤(ADC 时钟 6 分频 → 开时钟 → PB1 配模拟输入 → 配参数 → 校准),并把现象跑通:拧电位器,串口上的原始值在 0~4095 之间变
  • 能解释“采样时间为什么不能太短”“为什么要采几次求平均”,并在串口上亲眼看到改动前后的差别
  • 一、原理说明

    在这里插入图片描述

    在这里插入图片描述

    现实世界里的电压是连续的——电位器拧到哪儿,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 决定:

    SamplingTime大概耗时(12MHz ADC 时钟下)什么时候用
    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)两个现象:同一份采样,两种“看”法

    现象 1:数字电压表现象 2:电压柱
    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

    操作步骤(照着做就能跑):

  • 上电接线:板上标着 ST-Link 的 USB 口用一根线插到电脑(供电 + 下载 + 串口都走它),不用再接别的线
  • Keil 打开工程:课时文档/代码/课时06-ADC 模拟量采集/ADC实验/USER/ADC.uvprojx;Options for Target → Debug 选 ST-Link Debugger;到 Target 勾上 Use MicroLIB(不勾,printf 不出声)
  • 先自己算一遍:分辨率 3300 / 4096 ≈ 0.806 mV;比如原始值 2048 → 2048 × 3300 / 4096 = 1650 mV
  • 选现象:main.c 开头 #define APP_MODE 1(数字电压表)或 2(电压柱)
  • 编译下载:Build(F7)确认 0 Error(s), 0 Warning(s) → Download(F8)
  • 连串口看现象:串口助手选课时03 记下的那个 COM 号(STLink Virtual COM Port)、115200-8-N-1,打开串口,按 RST;然后慢慢拧电位器,盯着串口上的数字
  • 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 分步提示

  • 先把“表”跑通:现象 1 拧电位器,确认原始值在 0~4095、电压跟着变,且手算对得上
  • 再加柱条:bars = adc * 20 / 4096;先在纸上算几个点(1024→5 格、2048→10 格、3072→15 格),再对照串口
  • 再做两个旋钮:把采样时间、平均次数抽成宏;改一次看一次抖动,把结论写进注释
  • 加分从简单的开始:先加“超限点灯”(就一句 HAL_GPIO_WritePin),再加按键/定时器
  • 自检:0 Error(s), 0 Warning(s);拧到两端柱条是 0 格 / 19~20 格,且不许越界;printf 的列宽对齐
  • 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)

    原始值电压(×3300/4096)百分比(×100/4096)柱条(20 格,×20/4096)
    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 数码管显示:动态扫描
    赞(0)
    未经允许不得转载:171主机测评 » 06 嵌入式技术应用设计实训 | ADC 模拟量采集
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址