ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32F103C8+DS18B20数码管显示实战:时序优化与工程配置

STM32F103C8+DS18B20数码管显示实战:时序优化与工程配置 简介基于STM32F103C8单片机的温度传感器数码管显示程序是一份面向STM32初学者的完整KEIL工程源码实例。该项目以ARM Cortex-M3内核MCU为核心通过ADC采集温度传感器信号并驱动数码管实时显示温度值涵盖GPIO配置、模数转换、段码/位码驱动及C语言编程等关键知识点适合正在学习STM32开发流程的读者参考实践。压缩包共203个文件以C源码、头文件、编译输出的目标文件、调试配置文件等为主整体大小5.19MBKEIL工程结构完整便于直接打开学习。内容预览可见包含tm1640、lm75a、usart、flash等模块文件可了解各功能模块的划分方式。该资源已有171人学习是一份能帮助读者理解STM32外设操作与嵌入式软件调试方法的实用参考资料。1. 基于 STM32F103C8 的温度传感器数码管显示程序先搞定时间再谈显示网上这类工程很多但真正上板跑过的人多半都遇到过两个怪现象DS18B20 读出固定 85.0℃或者数码管在“闪烁”却看不出温度数值在跟随变化。前者是单总线时序没建立起来后者是动态扫描和温度采集任务互相干扰。温度传感器、数码管、STM32F103C8 这三样东西单独看都不难放到一起就变成对 GPIO 配置、微秒级延时和程序调度的综合考验。这篇文章按硬件选型、数码管扫描、DS18B20 驱动、KEIL 工程配置的顺序展开最后给出用逻辑分析仪验证时序的具体方法。刚完成点灯实验的初学者能照着搭从 51 转过来的工程师也可以重点看 STM32F103C8 上延时实现和 JTAG 引脚复用这两个容易翻车的点。2. 温度传感器选型与 STM32F103C8 引脚分配DS18B20、NTC 与数码管接线2.1 温度传感器选型DS18B20 还是 NTC标题只写了“温度传感器”没有限定型号实际项目里最常见的两个选择是 DS18B20 数字传感器和 NTC 热敏电阻。我的建议是学习、比赛、快速验证用 DS18B20因为单总线协议只占一个 IO数据直接给温度值不需要 ADC 和标定产品追求低成本、板子空间受限时用 NTC一颗电阻加一个分压电容就完事。我一般这样对比选型对比项DS18B20NTC 10k接口单总线1 个 IOADC1 个 IO 分压电阻测温范围-55℃ ~ 125℃取决于型号常见 -40℃ ~ 125℃精度±0.5℃10 位模式依赖分压电阻精度和标定输出形式数字值12 位分辨率模拟量需查表或公式换算代码量单总线时序 暂存器解析ADC 初始化 查表插值成本约 3~8 元约 0.1~1 元注意一个容易误判的现象DS18B20 上电后暂存器默认值是 0x0550恰好对应 85℃。很多人第一次上电看到数码管显示 85.0以为是传感器坏了实际上这是器件出厂值只要时序正确启动一次温度转换后就会被真实值覆盖。这个知识点放在选型段里说是因为接下来所有调试都围绕“区分真温度和假 85℃”展开。2.2 STM32F103C8 引脚分配与 GPIO 初始化用 DS18B20 加 4 位数码管的组合时我习惯把段选放在 PB0~PB7位选放 PA1~PA3数据线放 PA0。PA15、PB3、PB4 尽量留出来这三个引脚默认是 JTAG 功能需要额外配置才能当普通 GPIO 用新手在这里浪费的时间比写驱动还多。功能引脚方向说明DS18B20 数据 DQPA0开漏输出 / 输入外接 4.7kΩ 上拉到 3.3V数码管段选 a~g、dpPB0 ~ PB7推挽输出经 220Ω ~ 1kΩ 限流电阻数码管位选 1PA1推挽输出经三极管驱动公共端数码管位选 2PA2推挽输出经三极管驱动公共端数码管位选 3PA3推挽输出经三极管驱动公共端如果位选不够用才考虑启用 PB3、PB4 和 PA15此时需要在 GPIO 初始化前加一行重映射RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO, ENABLE); GPIO_PinRemapConfig(GPIO_Remap_SWJ_JTAGDisable, ENABLE);这句只关闭 JTAG保留 SWD 下载调试口 PA13/PA14。我不建议把 SWD 也关掉因为 KEIL 里在线调试和烧录都依赖它为了多两个 IO 失去调试能力不划算。GPIO 初始化代码如下直接放在 main 函数开头void GPIO_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB, ENABLE); // PA0: DS18B20 DQ开漏输出外部 4.7k 上拉 GPIO_InitStructure.GPIO_Pin GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_OD; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); // PB0~PB7: 数码管段选推挽输出 GPIO_InitStructure.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3 | GPIO_Pin_4 | GPIO_Pin_5 | GPIO_Pin_6 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_Init(GPIOB, GPIO_InitStructure); // PA1~PA3: 数码管位选推挽输出 GPIO_InitStructure.GPIO_Pin GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_Init(GPIOA, GPIO_InitStructure); }数据线为什么用开漏而不是推挽单总线上设备会主动拉低电平表示应答如果主机用推挽输出且当前输出高电平设备拉低时就等于两个输出在打架轻则读不到数据重则损伤引脚。开漏模式下主机只能拉低或释放释放后靠外部 4.7kΩ 上拉恢复高电平设备也有同样的拉低权利这是单总线协议能跑起来的硬件基础。线长超过 30cm 时我会把上拉改成 2.2kΩ否则分布电容会让上升沿变钝读时序的采样点容易踩到不确定电平。2.3 数码管驱动电路限流电阻与位选驱动能力数码管通常用动态扫描驱动同一时刻只点亮一位轮流点亮所有位靠人眼视觉暂留形成完整显示。动态扫描下段选引脚需要提供每段约 2~5mA 的电流4 位数码管全亮时最大段电流也就是单个峰值不会叠加所以段选可以直连 GPIO。位选不一样。位选要承担当前位全部点亮段的电流8 段全亮就是 16~40mA超过 STM32F103C8 GPIO 的灌电流规格。我一般不用 IO 直驱位选而是加一颗 NPN 三极管比如常见的 S8050位选 GPIO 接 1kΩ 基极电阻发射极接地集电极接数码管公共端。这样 GPIO 只提供微安级基极电流数码管电流直接走三极管和电源。共阳数码管则换成 PNP 管接法对称。段选限流电阻选多少取决于数码管型号和供电电压。3.3V 供电下红色数码管常见压降约 2V限流电阻 220Ω 时段电流约 5mA亮度足够如果发现数字偏暗优先检查位选三极管是否饱和导通而不是盲目减小限流电阻。每段串一个电阻不要把 8 段的公共端串一个电阻否则不同亮灭组合会导致分压变化亮度不均匀。3. 数码管动态扫描驱动段码表、2ms 刷新周期与显示缓冲区3.1 动态扫描原理与刷新率选择静态驱动下每个数码管需要独立锁存数据4 位就要 32 个 IOSTM32F103C8 带不动。动态扫描用“位选 段选”复用结构4 位数码管只需要 8 个段选加 3~4 个位选IO 占用大幅下降。代价是每一瞬间只有一位在亮必须保证刷新率足够高人眼才不会察觉闪烁。我一般把每位点亮时间固定为 2ms。4 位数码管一轮扫描 8ms刷新率 125Hz3 位则 6ms 一轮约 166Hz。这个指标远高于人眼能分辨的 50~60Hz即使环境光变化也不容易看到闪烁。刷新率和位数直接相关可以这样估算位数每轮周期刷新率3 位6ms166Hz4 位8ms125Hz6 位12ms83Hz刷新率不是越高越好。扫描周期缩短意味着每位点亮时间变短同样段电流下亮度下降反过来周期拉长到 16ms 以上动态显示看起来像在抖。2ms 是我测下来在亮度和流畅度之间比较平衡的取值。3.2 段码表与显示缓冲区段码表是数码管驱动的核心数据。共阴数码管公共端接低电平某段对应的引脚输出高电平该段点亮。字节最低位对应 a 段最高位对应 dp 段。#define SEG_BLANK 0x00 #define SEG_MINUS 0x40 #define SEG_DP 0x80 const uint8_t SEG_CODE[] { 0x3F, // 0 0x06, // 1 0x5B, // 2 0x4F, // 3 0x66, // 4 0x6D, // 5 0x7D, // 6 0x07, // 7 0x7F, // 8 0x6F, // 9 0x77, // A 0x7C, // b 0x39, // C 0x5E, // d 0x79, // E 0x71, // F };如果手头是共阳数码管最简单的方式是拿共阴表整体按位取反或者在初始化时写一个转换函数生成共阳表。工程里我通常只维护一份表通过宏控制是否取反避免两处段码不一致导致显示错字。显示缓冲区我直接存“最终要输出到 GPIO 的段码”而不是存数字。扫描函数不做查表只把缓冲区内容搬到位选对应的端口上这样中断里执行路径最短也减少查表出错的可能。volatile uint8_t display_buf[4]; void Display_Scan(void) { static uint8_t pos 0; GPIO_ResetBits(GPIOA, GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3); GPIO_Write(GPIOB, display_buf[pos]); GPIO_SetBits(GPIOA, GPIO_Pin_1 pos); pos (pos 1) 0x03; }display_buf 用 volatile 修饰是因为它同时在主循环温度更新函数和 SysTick 中断扫描函数中被访问volatile 提醒编译器每次重新从内存取最新值。扫描顺序是先全灭位选再更新段选最后点亮新位。如果反过来切换瞬间旧位会带着新段码亮一小段时间视觉上就是“拖影”。GPIO_ResetBits 一次关掉三个位选比逐个关要少几个时钟周期中断里能省一点是一点。3.3 用 SysTick 定时 2ms 调用扫描函数显示扫描必须放到定时中断里不能放到 while(1) 主循环。否则 DS18B20 转换期间主循环再快也要等待数码管就会出现明显卡顿。int main(void) { SysTick_Config(SystemCoreClock / 500); // 2ms 中断周期 while (1) { Temp_Task(); } } void SysTick_Handler(void) { Display_Scan(); if (timer_flag 0xFFFFFFFF) { timer_flag; } }这里 SusTick 配置的分频参数是 SystemCoreClock / 50072MHz 主频下计数值 1440002ms 触发一次。timer_flag 在中断里累加供主循环里的温度状态机判断 750ms 转换等待时间。这样做的一个好处是温度采集代码里没有任何阻塞延时DS18B20 转换期间数码管照常刷新两个任务互不拖累。3.4 用状态机组织温度显示任务温度采集不应该写成“发转换命令后死等 750ms”的同步代码。我常用的做法是四状态小状态机把一次完整采集拆成空闲、启动转换、等待、读取更新四步。typedef enum { TEMP_IDLE, TEMP_CONVERT, TEMP_WAIT, TEMP_READ } temp_state_t; void Temp_Task(void) { static temp_state_t state TEMP_IDLE; switch (state) { case TEMP_IDLE: DS18B20_Reset(); DS18B20_WriteByte(0xCC); DS18B20_WriteByte(0x44); state TEMP_CONVERT; break; case TEMP_CONVERT: state TEMP_WAIT; timer_flag 0; break; case TEMP_WAIT: if (timer_flag 750) { state TEMP_READ; } break; case TEMP_READ: Format_Temp(DS18B20_ReadTemperature(), display_buf); state TEMP_IDLE; break; } }这个骨架看起来简单但它是把显示、采集、等待解耦的关键。后面做按键消抖、Modbus 帧接收、OLED 菜单时都可以用同样思路事件驱动 时间片轮询而不是阻塞式 delay。状态机版本的温度任务只用了 4 个字节的局部状态可重入性也比裸阻塞函数好。4. DS18B20 单总线读取与温度显示DWT 延时、负温和小数处理4.1 单总线为什么需要精确微秒延时用 DWT 替代 for 循环DS18B20 的所有时序都以微秒为单位复位脉冲要求 480μs写时隙最小 60μs读采样要求在拉低后 15μs 内完成。这些时间窗口随芯片和线长有一定余量但主循环里插入中断、编译器优化级别变化都会让 for 空循环的延时不可控尤其在 KEIL 开 O2 优化后空循环可能被直接优化掉。我一般用 Cortex-M3 内核自带的 DWT 计数器做微秒延时。它独立于 SysTick不会和 2ms 显示中断互相干扰。void DWT_Delay_Init(void) { CoreDebug-DEMCR | CoreDebug_DEMCR_TRCENA_Msk; DWT-CYCCNT 0; DWT-CTRL | DWT_CTRL_CYCCNTENA_Msk; } void DWT_Delay_us(uint32_t us) { uint32_t start DWT-CYCCNT; uint32_t ticks us * (SystemCoreClock / 1000000); while ((DWT-CYCCNT - start) ticks); }DWT_Delay_Init 要在 main 开头调用一次。SystemCoreClock 在 system_stm32f10x.c 中维护默认 72MHz 时每微秒 72 个计数。注意减法溢出的情况连续两次读取 CYCCNT 的差值按 uint32_t 计算即使计数器绕回一圈也能得到正确结果所以不必担心长时间运行。4.2 复位、写时隙、读时隙的实现单总线操作有三个基础动作。复位时序是最容易出问题的主机拉低 480μs 释放等待 DS18B20 拉低总线约 60~240μs 作为存在脉冲。如果接线没问题但一直读不到数据先量复位后 DQ 线上有没有这个低脉冲。void DS18B20_Reset(void) { GPIO_ResetBits(GPIOA, GPIO_Pin_0); DWT_Delay_us(480); GPIO_SetBits(GPIOA, GPIO_Pin_0); DWT_Delay_us(100); }写时隙是主机主动拉低总线然后在规定时间内决定释放还是继续拉低。写 1 和写 0 的区别在于低电平持续时间DS18B20 在拉低后的 15~60μs 窗口内采样总线电平。void DS18B20_WriteByte(uint8_t data) { for (uint8_t i 0; i 8; i) { if (data 0x01) { GPIO_ResetBits(GPIOA, GPIO_Pin_0); DWT_Delay_us(6); GPIO_SetBits(GPIOA, GPIO_Pin_0); DWT_Delay_us(64); } else { GPIO_ResetBits(GPIOA, GPIO_Pin_0); DWT_Delay_us(60); GPIO_SetBits(GPIOA, GPIO_Pin_0); DWT_Delay_us(10); } data 1; } }读时隙由主机拉低总线启动释放后 DS18B20 会在短时间窗口内决定是否拉低总线表示数据 1 或 0。主机必须在拉低后的 15μs 内完成采样我的代码在释放后立刻读取留出余量给 GPIO 翻转指令本身的耗时。uint8_t DS18B20_ReadByte(void) { uint8_t data 0; for (uint8_t i 0; i 8; i) { data 1; GPIO_ResetBits(GPIOA, GPIO_Pin_0); DWT_Delay_us(2); GPIO_SetBits(GPIOA, GPIO_Pin_0); if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0)) { data | 0x80; } DWT_Delay_us(60); } return data; }三个时序的时间参数可以汇总成一张表调试时对照逻辑分析仪看时序主机动作时间要求复位脉冲拉低后释放低电平 480μs写 1拉低 6μs 释放采样前保持高写 0拉低 60μs 再释放全程低电平读采样拉低 2μs 释放释放后 15μs 内读4.3 温度读取流程与负温处理完成一次温度采样要发三条指令复位、跳过 ROM 匹配0xCC、启动温度转换0x44。转换时间在 12 位分辨率下最长 750ms所以状态机里等了 750 个 1ms 计数。读取时再次复位发跳过 ROM 和读暂存器指令0xBE然后连续读两个字节低位在前。float DS18B20_ReadTemperature(void) { uint8_t lsb, msb; int16_t raw; DS18B20_Reset(); DS18B20_WriteByte(0xCC); DS18B20_WriteByte(0xBE); lsb DS18B20_ReadByte(); msb DS18B20_ReadByte(); raw (msb 8) | lsb; if (raw 0x8000) { raw raw - 0x10000; } return raw * 0.0625f; }负温度处理是容易被忽略的点。12 位模式下温度寄存器是带符号补码最高位为 1 表示负值。直接强转 float 会得到一个很大正数必须先把 int16_t 扩展成符号正确的负数。0.0625 的来历12 位分辨率下 LSB 对应 1/16 0.0625℃。这个返回值是 float但数码管只显示一位小数格式化时再四舍五入。4.4 温度格式化与主循环刷新显示缓冲区直接存段码所以格式化函数要把温度值拆成“十位、个位带小数点、小数位”负温时最高位显示负号。void Format_Temp(float temp, uint8_t *buf) { uint8_t t; buf[0] (temp 0) ? SEG_MINUS : SEG_BLANK; if (temp 0) { temp -temp; } t (uint8_t)(temp * 10.0f 0.5f); // 四舍五入保留一位小数 buf[1] (t / 100) ? SEG_CODE[t / 100] : SEG_BLANK; buf[2] SEG_CODE[(t / 10) % 10] | SEG_DP; buf[3] SEG_CODE[t % 10]; }温度 25.5℃ 时buf[1] 是数字 2buf[2] 是数字 5 的段码并加上 0x80 点亮小数点buf[3] 是数字 5。这样显示效果就是“25.5”。如果替换成 NTC算法则完全不同NTC 先用 ADC 采集分压电压再根据 B 值公式反算温度查表法在资源紧张的芯片上比 logf 快得多。把传感器读取统一封装成 float 返回后Format_Temp 和状态机主流程完全不用改这是分层设计带来的直接好处。5. KEIL uVision5 工程配置与上板验证Flash 算法、ISP 和逻辑分析仪5.1 新建工程的四个关键设置用标准外设库在 KEIL uVision5 新建 STM32F103C8 工程时四个地方最容易配置错任何一个不对都会导致编译通过但上板无反应配置项推荐设置说明DeviceSTM32F103C8属于中容量产品别选成 C8T6 的封装变体C/C DefineSTM32F10X_MD, USE_STDPERIPH_DRIVER中容量芯片必须定义 MD否则外设寄存器地址错位启动文件startup_stm32f10x_md.s和高容量 HD 文件不同选错会造成启动即 HardFaultFlash AlgorithmSTM32F10x Med-density Flash 64K下载算法缺失会报 Flash Download failedDefine 里 STM32F10X_MD 对应 64KB Flash 的中容量型号这是 stm32f10x.h 里选择外设寄存器结构体的关键宏。USE_STDPERIPH_DRIVER 让标准库的函数定义生效。如果直接用标准库模板工程这两项往往已经配好但自己从零建工程时经常忘。5.2 ST-Link 下载与串口 ISP 引导调试下载首选 ST-LinkOptions for Target 的 Debug 页选择 ST-Link DebuggerSettings 里确认能识别到 SWD 设备Flash Download 页勾选 Reset and Run点击下载即可直接运行。整个过程不占用 USARTSWD 只用到 PA13、PA14 两个引脚。没有 ST-Link 时可以用串口 ISP 烧写。STM32F103C8 出厂时自带 ROM bootloader将 BOOT0 跳到 1、BOOT1 保持 0复位后芯片进入 ISP 模式通过 PA9/PA10 串口接收程序。下载完成后把 BOOT0 跳回 0 再复位程序从 Flash 启动。这个方法对“手里只有 USB 转 TTL 模块”的场景很实用。5.3 上板常见故障排查现象最常见原因处理方式温度恒为 85.0℃DS18B20 通信失败读到上电默认值检查 4.7kΩ 上拉、DQ 接线、延时是否被优化数码管整体微亮但无字位选直驱电流不足位选加 S8050 三极管显示拖影、重影扫描时未先消隐位选先关全部位选再写段选某一位不亮或一直亮对应位选的引脚是 JTAG 功能脚关闭 JTAG 或换用 PA1~PA3数值变化但会闪温度更新和扫描抢用显示缓冲区更新 buffer 时短暂关中断或使用双缓冲最容易迷惑的情况是 ST-Link 第一次下载成功后第二次报错。这通常不是工程问题而是 Options for Target 里 Flash Download 的编程算法没选对KEIL 找不到匹配的 Flash 驱动。按 5.1 的表重新添加一次即可。5.4 用逻辑分析仪验证单总线与数码管扫描时序逻辑分析仪是最低成本的验证手段几十块钱的四通道版本就够用。测量 DQ 信号时复位脉冲应该看到典型宽度 480μs 的低电平随后 DS18B20 拉出一个约 100μs 的存在脉冲。写时隙中能数出 8 个一组的高低变化读暂存器阶段总线方向切换后出现连续 16 个读时隙。如果捕捉到的低电平宽度普遍偏窄说明延时函数偏快优先检查 SystemCoreClock 是否真的等于 72MHz。数码管这边测位选信号对 GND 的波形4 位配置下周期应为 8ms高电平宽度约 2ms。段选数据和位选切换的相对位置能直接看出消隐是否生效位选关闭到下一段选变化之间应有一次 GPIO_Write 动作如果段选先变、位选后灭就是消隐顺序写反了。我在项目验收时习惯把 DQ 复位波形的宽度作为自动化断言逻辑分析仪测出 420μs 以上就放行低于 400μs 直接判定时序异常这个阈值比手册的 480μs 略宽容但足以筛掉大多数初始化问题。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进