STM32F103C8+DS18B20数码管显示实战:时序优化与工程配置
2026/9/13 5:27:36 网站建设 项目流程

简介:基于STM32F103C8单片机的温度传感器数码管显示程序,是一份面向STM32初学者的完整KEIL工程源码实例。该项目以ARM Cortex-M3内核MCU为核心,通过ADC采集温度传感器信号,并驱动数码管实时显示温度值,涵盖GPIO配置、模数转换、段码/位码驱动及C语言编程等关键知识点,适合正在学习STM32开发流程的读者参考实践。压缩包共203个文件,以C源码、头文件、编译输出的目标文件、调试配置文件等为主,整体大小5.19MB,KEIL工程结构完整,便于直接打开学习。内容预览可见包含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 个 IOADC,1 个 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 个 IO,STM32F103C8 带不动。动态扫描用“位选 + 段选”复用结构,4 位数码管只需要 8 个段选加 3~4 个位选,IO 占用大幅下降。代价是每一瞬间只有一位在亮,必须保证刷新率足够高,人眼才不会察觉闪烁。

我一般把每位点亮时间固定为 2ms。4 位数码管一轮扫描 8ms,刷新率 125Hz;3 位则 6ms 一轮,约 166Hz。这个指标远高于人眼能分辨的 50~60Hz,即使环境光变化也不容易看到闪烁。刷新率和位数直接相关,可以这样估算:

位数每轮周期刷新率
3 位6ms166Hz
4 位8ms125Hz
6 位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 / 500,72MHz 主频下计数值 144000,2ms 触发一次。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] 是数字 2,buf[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 文件不同,选错会造成启动即 HardFault
Flash AlgorithmSTM32F10x Med-density Flash 64K下载算法缺失会报 Flash Download failed

Define 里 STM32F10X_MD 对应 64KB Flash 的中容量型号,这是 stm32f10x.h 里选择外设寄存器结构体的关键宏。USE_STDPERIPH_DRIVER 让标准库的函数定义生效。如果直接用标准库模板工程,这两项往往已经配好,但自己从零建工程时经常忘。

5.2 ST-Link 下载与串口 ISP 引导

调试下载首选 ST-Link:Options for Target 的 Debug 页选择 ST-Link Debugger,Settings 里确认能识别到 SWD 设备,Flash Download 页勾选 Reset and Run,点击下载即可直接运行。整个过程不占用 USART,SWD 只用到 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 略宽容,但足以筛掉大多数初始化问题。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询