news 2026/10/16 4:44:03

STM32F103驱动TM1637四位数码管:从时序避坑到定时器无阻塞刷新

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32F103驱动TM1637四位数码管:从时序避坑到定时器无阻塞刷新

简介:本资源面向嵌入式开发初学者与STM32学习者,提供基于STM32F103C8T6驱动TM1637芯片控制4位数码管的完整工程代码,帮助理解GPIO模拟时序与数码管动态扫描显示的实现方法。压缩包共79个文件,约322KB,包含32个h头文件与31个c源文件,涵盖TM1637驱动、SysTick延时、LED控制及主程序逻辑,另有8个s启动文件、hex烧录固件、uvprojx与uvoptx工程配置及功能说明文档,目录按User、Libraries、Doc、Listing等模块划分,结构清晰便于查阅。目前已有6050人学习下载。读者可从中获取TM1637启动、数据传输与结束信号的软件模拟思路,掌握段码位码组织、亮度调节与循环显示等实用技巧,并借助现成工程快速验证与移植到自己的显示项目中。

1. 从一次“数码管只亮不跳”的翻车说起

前阵子帮一个做工业面板的朋友调一块板子,STM32F103 最小系统板上挂了颗 TM1637,驱动一个 4 位共阳数码管。现象很典型:上电后四位数码管全亮,显示8888,但程序里写的计数逻辑完全不生效,改一次数值要重新上电才刷新。他一开始怀疑是数码管坏了,换了三颗还是一样,最后把问题定位到 TM1637 的通信时序上——时钟线拉得太快,芯片根本没收到数据,只是上电默认全亮。

这个案例基本覆盖了 STM32 驱动 TM1637 的典型场景:用两颗 IO 口模拟 I2C 类时序,控制 4 位数码管做数值显示。TM1637 不是标准 I2C,它只有 CLK 和 DIO 两根线,协议是厂商自定义的,起始、数据、应答、停止都有固定时序要求。STM32F103 主频 72MHz,GPIO 翻转速度远超 TM1637 能接受的 100kHz 上限,所以“能亮但不刷新”几乎成了新手必踩的坑。

这份资源围绕 STM32F103 + TM1637 + 4 位数码管展开,适合正在做仪表显示、计时器、温控面板这类小项目的嵌入式开发者。如果你手头正好有 TM1637 模块和 F103 板子,想快速跑通显示逻辑,下面这套流程可以直接抄作业。

2. TM1637 协议拆解:为什么不能直接套标准 I2C

2.1 两线协议的起始、数据与应答时序

TM1637 的通信协议和标准 I2C 有本质区别。标准 I2C 有 7 位地址帧、读写位、ACK/NACK 机制,而 TM1637 是“命令 + 数据”的固定格式,没有设备地址概念。它的起始条件是:CLK 为高时,DIO 从高拉低;停止条件是:CLK 为高时,DIO 从低拉高。数据在 CLK 上升沿被采样,所以写数据时要在 CLK 低电平期间准备好 DIO,再拉高 CLK。

一个完整的写操作分三步:先发命令字节设置数据写模式,再发地址字节指定显示寄存器起始地址,最后连续发数据字节。每发完一个字节,TM1637 会在第 8 个时钟下降沿后把 DIO 拉低作为应答,主机需要释放 DIO 并读取这个低电平。很多移植代码直接照搬 I2C 的HAL_I2C_Master_Transmit,结果就是完全没反应,因为硬件 I2C 外设根本不认这套时序。

我一般用 GPIO 模拟,把 CLK 和 DIO 都配成开漏输出,外部上拉 4.7k 到 10k 电阻。开漏的好处是应答阶段主机可以释放 DIO,让 TM1637 拉低。如果配成推挽输出,读应答时会出现总线冲突,轻则数据错乱,重则烧芯片。

2.2 显示寄存器映射与亮度控制

TM1637 内部有 6 个显示寄存器,地址从0xC0到0xC5。4 位数码管只用前 4 个,对应地址0xC0、0xC1、0xC2、0xC3。每个寄存器 8 位,对应数码管一个段的位选。共阳数码管的段码是低电平点亮,所以写0xFF是全灭,写0x00是全亮。

亮度控制通过命令字节的高 3 位实现,范围 0 到 7。命令格式是0x88 | brightness,比如0x8F就是最亮。注意这个命令要单独发一次,不能和数据混在同一个写周期里。常见做法是初始化时发一次亮度命令,之后只更新显示数据。

段码表需要根据实际数码管引脚顺序来定。市面上常见的 4 位共阳数码管,段选顺序是 a、b、c、d、e、f、g、dp,对应 TM1637 的 bit0 到 bit7。但有些模块会反过来,所以第一次调试时建议先写0x00看全亮,再逐位写0x01、0x02确认映射关系。

// TM1637 命令定义 #define TM1637_CMD_SET_DATA 0x40 // 数据写模式,自动地址增加 #define TM1637_CMD_SET_ADDR 0xC0 // 显示寄存器起始地址 #define TM1637_CMD_SET_DISP 0x88 // 显示控制命令基址,低3位为亮度 // 共阳数码管段码表,0-9 和空白 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 0x00 // 空白 };

上面这段代码里,TM1637_CMD_SET_DATA的0x40表示写数据到显示寄存器,地址自动加一。TM1637_CMD_SET_ADDR的0xC0是起始地址,后面连续写 4 个字节就会依次填入 4 个显示寄存器。段码表按共阳逻辑取反后的值,如果实际模块是共阴,需要把表里的值按位取反。

2.3 为什么 F103 上必须做延时

STM32F103 的 GPIO 翻转速度在 72MHz 主频下可以达到 18MHz,而 TM1637 的 CLK 最高频率只有 100kHz 左右,典型应用在 50kHz 以下。如果不加延时,一个GPIO_SetBits到GPIO_ResetBits的周期可能只有几十纳秒,TM1637 根本来不及采样。

我一般用delay_us做微秒级延时,每个时钟周期的高电平和低电平各延时 5 到 10 微秒。这样 CLK 频率大约在 50kHz 到 100kHz 之间,既稳定又不会太慢。如果项目里已经有 SysTick 延时,直接调用即可;如果没有,可以用空循环做粗略延时,但要注意编译器优化等级,-O2下空循环可能被优化掉,需要加volatile修饰。

// 微秒级延时,基于 SysTick 或空循环 void tm1637_delay_us(uint32_t us) { // 假设系统主频 72MHz,粗略延时 volatile uint32_t i; for (i = 0; i < us * 8; i++); // 8 是经验系数,需实测调整 } // CLK 和 DIO 引脚操作宏 #define TM1637_CLK_H() GPIO_SetBits(GPIOB, GPIO_Pin_6) #define TM1637_CLK_L() GPIO_ResetBits(GPIOB, GPIO_Pin_6) #define TM1637_DIO_H() GPIO_SetBits(GPIOB, GPIO_Pin_7) #define TM1637_DIO_L() GPIO_ResetBits(GPIOB, GPIO_Pin_7) #define TM1637_DIO_READ() GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_7)

延时系数8不是固定值,跟编译器优化、主频、GPIO 操作耗时都有关。稳妥做法是用示波器量一下 CLK 周期,或者用逻辑分析仪抓一次完整通信,确认高低电平时间都在 5 微秒以上。没有仪器的話,可以先用大延时(比如 20 微秒)跑通,再逐步减小到稳定临界值。

3. 从零搭建驱动:GPIO 初始化到显示刷新

3.1 GPIO 模式选择与初始化代码

CLK 和 DIO 都配成开漏输出,速度选 2MHz 就够了,不需要 50MHz。开漏模式下,输出高电平时引脚实际是浮空状态,靠外部上拉电阻拉到高电平。这样在应答阶段,主机把 DIO 设为输入或输出高,TM1637 就能把总线拉低。

初始化时先把 CLK 和 DIO 都拉高,让总线处于空闲状态。然后发起始信号,再发命令。注意上电后 TM1637 需要一点时间稳定,我一般延时 10ms 再开始通信。

void tm1637_gpio_init(void) { GPIO_InitTypeDef gpio; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); gpio.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7; gpio.GPIO_Mode = GPIO_Mode_Out_OD; // 开漏输出 gpio.GPIO_Speed = GPIO_Speed_2MHz; GPIO_Init(GPIOB, &gpio); TM1637_CLK_H(); TM1637_DIO_H(); tm1637_delay_us(10); // 上电稳定 }

这段初始化里,GPIO_Mode_Out_OD是关键。如果误配成GPIO_Mode_Out_PP(推挽),读应答时会出现两个输出打架,DIO 电平不确定。GPIO_Speed_2MHz足够,高速模式反而容易引入振铃。

3.2 起始、停止与字节写入函数

起始信号:CLK 高时,DIO 从高变低。停止信号:CLK 高时,DIO 从低变高。写一个字节:循环 8 次,每次先拉低 CLK,准备 DIO 位,再拉高 CLK,让 TM1637 采样。写完后释放 DIO,拉低 CLK,等待应答。

void tm1637_start(void) { TM1637_CLK_H(); TM1637_DIO_H(); tm1637_delay_us(5); TM1637_DIO_L(); // CLK 高时 DIO 下降沿 tm1637_delay_us(5); TM1637_CLK_L(); tm1637_delay_us(5); } void tm1637_stop(void) { TM1637_CLK_L(); tm1637_delay_us(5); TM1637_DIO_L(); tm1637_delay_us(5); TM1637_CLK_H(); tm1637_delay_us(5); TM1637_DIO_H(); // CLK 高时 DIO 上升沿 tm1637_delay_us(5); } void tm1637_write_byte(uint8_t data) { uint8_t i; for (i = 0; i < 8; i++) { TM1637_CLK_L(); tm1637_delay_us(5); if (data & 0x01) { TM1637_DIO_H(); } else { TM1637_DIO_L(); } tm1637_delay_us(5); TM1637_CLK_H(); // 上升沿采样 tm1637_delay_us(5); data >>= 1; } // 等待应答 TM1637_CLK_L(); TM1637_DIO_H(); // 释放 DIO tm1637_delay_us(5); TM1637_CLK_H(); tm1637_delay_us(5); // 此处可读取 TM1637_DIO_READ() 判断应答,实际常省略 TM1637_CLK_L(); tm1637_delay_us(5); }

写字节函数里,数据是低位先发。每次循环先拉低 CLK,设置 DIO,再拉高 CLK。第 8 位发完后,主机释放 DIO,再给一个时钟脉冲,TM1637 会在这个脉冲期间拉低 DIO 表示应答。很多简化代码直接忽略应答检测,只要时序正确,不检测也能工作,但调试阶段建议加上判断,方便定位问题。

3.3 显示刷新与数值转换

显示一个 4 位数值,需要把数值拆成 4 个数字,查段码表,再依次写入。如果数值不足 4 位,前面补空白。写入流程是:起始 → 发0x40命令 → 停止 → 起始 → 发0xC0地址 → 连续发 4 个段码 → 停止 → 起始 → 发亮度命令 → 停止。

void tm1637_display(uint16_t num, uint8_t brightness) { uint8_t digits[4]; uint8_t i; // 拆数字,高位在前 digits[0] = num / 1000; digits[1] = (num / 100) % 10; digits[2] = (num / 10) % 10; digits[3] = num % 10; // 前导零消隐 if (num < 1000) digits[0] = 10; if (num < 100) digits[1] = 10; if (num < 10) digits[2] = 10; // 写显示数据 tm1637_start(); tm1637_write_byte(TM1637_CMD_SET_DATA); tm1637_stop(); tm1637_start(); tm1637_write_byte(TM1637_CMD_SET_ADDR); for (i = 0; i < 4; i++) { tm1637_write_byte(seg_code[digits[i]]); } tm1637_stop(); // 设置亮度 tm1637_start(); tm1637_write_byte(TM1637_CMD_SET_DISP | (brightness & 0x07)); tm1637_stop(); }

seg_code[10]是空白段码0x00,对应共阳全灭。亮度参数brightness取 0 到 7,实际用 2 到 4 比较合适,太亮刺眼,太暗看不清。如果显示负数,需要单独处理符号位,把最高位数字替换成负号段码,常见负号段码是0x40(只有 g 段亮)。

4. 避坑与排查:四个让数码管“装死”的细节

4.1 现象:全亮8888不刷新

原因:CLK 频率过高,TM1637 没收到有效数据,上电默认全亮。F103 的 GPIO 翻转太快,没有加延时,或者延时被编译器优化掉。

解决:在 CLK 高低电平切换之间加delay_us(5)以上,用volatile修饰延时变量,或者直接用 SysTick 延时。用逻辑分析仪抓一次 CLK 波形,确认频率在 100kHz 以下。

4.2 现象:显示乱码或部分段不亮

原因:段码表映射错误,或者数据位顺序搞反。共阳和共阴的段码是反的,低位先发和高位先发也会导致乱码。

解决:先写0x00确认全亮,再写0x01看哪一段亮,逐位确认映射。如果全亮变全灭,说明共阳共阴搞反了,把段码表按位取反即可。

4.3 现象:上电后完全没反应

原因:GPIO 模式配成推挽,应答阶段总线冲突;或者上拉电阻没接,开漏输出高电平时总线浮空,TM1637 识别不到高电平。

解决:确认 GPIO 配成开漏,CLK 和 DIO 各接一个 4.7k 到 10k 上拉电阻到 3.3V。如果模块自带电阻,检查是否虚焊。

4.4 现象:显示闪烁或亮度不均

原因:刷新频率太低,或者每次刷新都重发亮度命令,导致亮度寄存器被反复重置。

解决:把显示刷新放在定时器中断里,周期 10ms 到 20ms。亮度命令只在初始化时发一次,后续刷新只更新显示数据,不发亮度命令。

4.5 现象:数值变化时旧数据残留

原因:TM1637 显示寄存器有锁存,如果只写部分寄存器,未写的保持旧值。比如只更新后两位,前两位还是上次的数字。

解决:每次刷新都写满 4 个寄存器,不要只写变化的位。前导零用空白段码填充,保证 4 个寄存器都被覆盖。

5. 进阶技巧:用定时器中断做无阻塞刷新与亮度渐变

跑通基础显示后,下一步是把刷新逻辑从主循环里拿出来。主循环里调tm1637_display会阻塞几毫秒,如果项目里还有按键扫描、串口接收,响应会变慢。我一般用 TIM2 做一个 10ms 中断,在中断里更新显示缓冲区,主循环只负责改数值。

具体做法:定义一个display_buf[4]数组,主循环把要显示的数字转成段码写进缓冲区,中断里只做 TM1637 写入。这样主循环不阻塞,刷新也稳定。注意中断里的延时不能用delay_us那种空循环,会拖慢中断响应,最好用硬件定时器或者缩短延时到 2 微秒。

亮度渐变是个实用小技巧。TM1637 亮度只有 8 档,直接切换会显得生硬。可以在定时器中断里每 100ms 调整一次亮度值,从 0 到 7 再回到 0,做呼吸效果。代码很简单,维护一个方向变量,到边界就反向。

// 定时器中断里的亮度渐变逻辑 static uint8_t bright = 0; static int8_t dir = 1; void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); // 每 10 次中断(100ms)调整一次亮度 static uint8_t cnt = 0; if (++cnt >= 10) { cnt = 0; bright += dir; if (bright >= 7) { bright = 7; dir = -1; } if (bright <= 0) { bright = 0; dir = 1; } tm1637_set_brightness(bright); // 只发亮度命令 } // 刷新显示缓冲区 tm1637_refresh(display_buf); } }

这里tm1637_set_brightness只发亮度命令,不碰显示数据,所以不会引起闪烁。tm1637_refresh从缓冲区读段码写入 TM1637。两个函数都基于前面写的起始、停止、写字节函数,只是把数据来源换成缓冲区。

验证方法很简单:用逻辑分析仪抓 CLK 和 DIO,看每次刷新的数据是否和缓冲区一致;或者用手机慢动作拍数码管,看亮度变化是否平滑。如果发现亮度跳变,检查bright的边界判断是不是写成了>和<,导致越界。

从那以后我每次调 TM1637,都强制先用逻辑分析仪抓一次完整通信,确认起始、命令、数据、停止四个阶段的时序都对,再往下写业务逻辑。这个习惯帮我省掉了至少三次“以为是芯片坏了”的返工。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 4:43:49

分布式项目版本管理:代码、数据、调度全链路可复现

说实话&#xff0c;我在刚开始接手分布式计算相关项目时&#xff0c;最头疼的往往不是Spark任务怎么调优&#xff0c;也不是Flink状态怎么管理&#xff0c;而是那些“看似不起眼”的版本问题&#xff1a;一个离线任务三周前跑出来的结果&#xff0c;今天想回刷却怎么都对不上数…

作者头像 李华
网站建设 2026/10/16 4:42:52

Spring Boot 4.0集成RabbitMQ:注解驱动实现异步消息

最近我负责的一个内部模拟项目X要从老式的 HTTP 轮询改成异步消息驱动&#xff0c;正好赶上 Spring Boot 4.0 的新版本落地&#xff0c;我顺势把 RabbitMQ 也一起接了进来。折腾了大概一周&#xff0c;最直观的感受是&#xff1a;如果还像几年前那样写一堆ConnectionFactory、C…

作者头像 李华
网站建设 2026/10/16 4:42:33

L-BFGS算法精讲:高维优化中的省内存拟牛顿法

做优化的人&#xff0c;大概都经历过这种尴尬&#xff1a;目标函数公式不复杂&#xff0c;梯度也能算&#xff0c;可变量一上万&#xff0c;常规方法就顶不住了。这个时候&#xff0c;L-BFGS几乎是最顺手的工具。它的全称是Limited-memory BFGS&#xff0c;翻译过来就是“省内存…

作者头像 李华
网站建设 2026/10/16 4:42:26

大厂RAG为何不用纯向量检索?揭秘混合检索落地实践

我做过不少 RAG 项目&#xff0c;也看过很多团队从“向量检索一把梭”到“老老实实上混合检索”的全过程。标题这句话说得挺直接——大厂 RAG 从不用纯向量检索&#xff0c;听上去有点绝对&#xff0c;但对比一下生产环境和 Demo 的差距&#xff0c;你会发现这几乎是必然的选择…

作者头像 李华