ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

STM32F103驱动TM1637数码管显示:协议、时序与实战指南

STM32F103驱动TM1637数码管显示:协议、时序与实战指南 前几天帮朋友弄一个恒温箱控制器他手上正好有一块STM32F103最小系统板和一个TM1637四位数码管模块让我帮他接上显示温度。说实话这类屏幕方案我平时用OLED比较多但TM1637在工业小仪表、桌面时钟、温控器、充电器显示面板这些场景里实在太常见了成本低、驱动简单、占用引脚少非常适合STM32F103这种资源不算富裕的MCU。我用了大概十分钟就把硬件接好二十分钟把驱动代码写完调通整个过程比想象中顺很多。这篇东西我把思路从头到尾整理一遍包括TM1637模块的通信协议、STM32F103的GPIO模拟时序、如何在main函数里封装出实用的显示接口以及我实测中踩过的几个坑。不管你是刚入门单片机还是想快速搭一个数码管显示功能照着做都能跑起来。1. 项目背景与模块选型思路1.1 为什么用TM1637而不是直接驱动数码管很多初学者看到数码管第一反应是“直接用单片机引脚点亮不就行了”。确实单个一位数码管要显示0到9至少需要8个段选引脚如果带小数点就是8段再加上位选总引脚数一下就上去了。STM32F103虽然引脚多但如果你同时要接按键、传感器、串口、PWM引脚根本不够分而且动态扫描还需要CPU不断刷新处理其他事情时很容易导致显示闪烁。TM1637这颗芯片最聪明的地方在于它把数码管的段选和位选全部接管了单片机只需要两根IO线——一个时钟CLK一个数据DIO——就能控制4位甚至6位数码管。内部自带显存和译码电路你告诉它“第一位显示5第二位显示8”它自己完成动态扫描CPU完全不用操心刷新频率。对STM32F103来说GPIO模拟两根线的时序毫无压力比用硬件I2C还省事。这颗芯片还有个细节值得说它内部集成了键盘扫描功能最多可以接8个按键。很多温控器产品就是用一个TM1637同时搞定数码管显示和按键输入PCB布线和单片机资源开销都省到极致。1.2 TM1637、74HC595、直接驱动的取舍我做过的项目里数码管驱动方案基本跑不出下面这几种这里直接给结论方案占用引脚显示位数CPU负担成本适用场景单片机直接驱动动态扫描12~16个4~8位高需频繁刷新最低引脚充裕、简单实验74HC595串转并3个8位以上中仍需动态扫描很低数码管位数多、要求低成本TM1637驱动芯片2个4~6位极低芯片自动扫描低4位数码管、温控器、桌面时钟MAX7219驱动芯片3个SPI接口8位极低较高8位以上显示、需要亮度调节我的建议很明确如果只显示4位数字TM1637是综合体验最好的选择模块价格几块钱代码量也不大。如果是做8位以上的大屏显示或者需要精确控制某一位的单独开关那老老实实上74HC595或者MAX7219。2. TM1637模块工作原理与通信协议2.1 硬件结构与时序基础TM1637外观上是个SOP20封装的芯片市面上常见的四位数码管模块大多直接把芯片贴在一小块PCB背面正面是共阴数码管留出4个引脚VCC、GND、CLK、DIO。VCC我实测3.3V和5V都能正常工作STM32F103的IO输出高电平是3.3VTM1637的逻辑高电平阈值大约2.0V所以直接用3.3V供电、用GPIO直连CLK和DIO没有任何电平匹配问题。这里有个非常关键的细节TM1637的DIO引脚不是纯粹的双向IO它是开漏输出结构芯片内部有上拉电阻但上拉能力比较弱。所以我们在STM32F103初始化GPIO时DIO必须配置为开漏输出模式而且必须开启内部上拉。提示如果你把DIO配置成推挽输出通信大概率仍然能工作但一旦TM1637拉低DIO表示应答时两边就会发生电平冲突轻则数据错乱重则长期工作后GPIO口损坏。开漏加外部上拉才是最稳妥的做法。时序上TM1637是一种类I2C但不完全兼容I2C的协议。它的基本单位是8位数据从最低位开始传输。CLK为高时DIO上的电平就是有效数据CLK下降沿写入数据。每次传输8位之后第9个CLK时钟周期用于应答——注意这里的应答是DIO被从机拉低跟I2C的应答方向一致但电平极性稍有差异实际用的时候直接模拟“发完8位后释放DIO检测它是否为低电平”就能通过。我把完整时序归纳成下面几个环节起始信号CLK高电平时DIO由高变低数据写入每个CLK下降沿前先设置好DIO电平然后拉低CLK应答检测在第8个数据位之后释放DIO产生第9个下降沿读DIO是否被拉低停止信号CLK高电平时DIO由低变高2.2 命令格式与显存映射TM1637的命令按其功能分成三类每一条都是单独发送的一字节数据命令设置比如“写数据到显示寄存器”“读按键扫描值”最常用的是0x40含义是自动地址递增方式写显存地址命令设置指定接下来从哪一位显存开始写地址范围是0xC0到0xC5对应从左边数第1位到第6位显示控制命令高四位是固定1000接下来是亮度等级最低位是开关控制。比如0x8F表示开显示且亮度最大0x88表示关显示0x8A是亮度最低但保持显示这里要注意一下很多模块背面标注的是4位数码管这时有效地址只有0xC0到0xC3四个。但TM1637芯片最多支持6位所以如果你手上是6位模块地址就可以写到0xC5。数据写入的流程是这样的发送起始信号发送0x40告诉芯片“我要写显存”发送停止信号再次发送起始信号发送地址0xC0连续发送N个字节的段码数据芯片内部地址自动递增发送停止信号发送起始信号发送显示控制命令比如0x8F发送停止信号很多人第一次写代码容易漏掉第8到第10步结果是数据全部写进显存了但芯片显示控制位还处于关闭状态数码管就是死活不亮。这个问题我后面还会在问题排查里专门提。3. STM32F103驱动程序设计3.1 引脚分配与GPIO初始化我的接线方案非常直接选用PA0和PA1这两个引脚理由是它们默认就是普通GPIO不涉及JTAG、串口下载、ADC等复用功能改起来不会影响调试器的连接。模块引脚STM32F103引脚备注VCC3.3V模块内部逻辑供电与MCU同电源GNDGND共地必须保证CLKPA0推挽输出DIOPA1开漏输出并启用内部上拉这里说明一下为什么CLK用推挽而DIO用开漏。CLK始终由单片机主动驱动方向不会变推挽输出能提供更强的驱动能力上升沿更陡峭在长线连接时抗干扰性更好。DIO需要双向传输必须开漏加外部上拉这是硬件层面决定的不是代码习惯问题。GPIO初始化代码直接用标准外设库写void TM1637_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // CLK 推挽输出 GPIO_InitStructure.GPIO_Pin GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); // DIO 开漏输出内部上拉 GPIO_InitStructure.GPIO_Pin GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_OD; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); GPIO_SetBits(GPIOA, GPIO_Pin_0); // CLK 默认高 GPIO_SetBits(GPIOA, GPIO_Pin_1); // DIO 默认高释放总线 }初始化完成之后DIO默认状态是高电平等于释放了总线。接下来就可以写底层时序函数了。我建议把时钟延时放在GPIO翻转之间而不是用死循环的delay()这样代码在不同主频下都有更好的可移植性。我用的是SysTick微秒级延时函数STM32F103跑72MHz时每翻转一次引脚之后延时5微秒就非常稳定。这个速度不算快但绝对够用数码管刷新不需要高频操作。3.2 用模拟时序实现通信底层核心函数就四个起始信号、停止信号、写一个字节并读应答、发送命令。我先把代码放出来然后逐行解释。#define TM1637_CLK_HIGH() GPIO_SetBits(GPIOA, GPIO_Pin_0) #define TM1637_CLK_LOW() GPIO_ResetBits(GPIOA, GPIO_Pin_0) #define TM1637_DIO_HIGH() GPIO_SetBits(GPIOA, GPIO_Pin_1) #define TM1637_DIO_LOW() GPIO_ResetBits(GPIOA, GPIO_Pin_1) static void TM1637_Delay(void) { delay_us(5); } void TM1637_Start(void) { TM1637_DIO_HIGH(); TM1637_CLK_HIGH(); TM1637_Delay(); TM1637_DIO_LOW(); TM1637_Delay(); TM1637_CLK_LOW(); TM1637_Delay(); } void TM1637_Stop(void) { TM1637_CLK_LOW(); TM1637_DIO_LOW(); TM1637_Delay(); TM1637_CLK_HIGH(); TM1637_Delay(); TM1637_DIO_HIGH(); TM1637_Delay(); } uint8_t TM1637_WriteByte(uint8_t data) { uint8_t i; uint8_t ack 0; for (i 0; i 8; i) { // 先拉低CLK再改变DIO避免数据在时钟高电平时跳变 TM1637_CLK_LOW(); if (data 0x01) { TM1637_DIO_HIGH(); } else { TM1637_DIO_LOW(); } TM1637_Delay(); TM1637_CLK_HIGH(); TM1637_Delay(); data 1; } // 第9个时钟释放DIO读取应答 TM1637_CLK_LOW(); TM1637_Delay(); TM1637_DIO_HIGH(); // 释放总线切换为输入态 TM1637_Delay(); TM1637_CLK_HIGH(); TM1637_Delay(); if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_1) 0) { ack 1; } else { ack 0; } TM1637_CLK_LOW(); TM1637_Delay(); return ack; }这个代码里有几个地方必须强调。第一写数据之前必须先拉低CLK这时候DIO的变化不会被芯片误判为起始信号因为起始信号的判定条件是CLK高电平时DIO由高变低。第二数据是从最低位开始发的所以if (data 0x01)然后右移这个顺序千万别反了反了显示出来的数字完全是乱的。第三第9个时钟采样应答之前DIO要先释放为高阻状态然后才拉高CLK这样才能在CLK高电平期间稳定读到DIO的电平。如果顺序搞反可能在CLK上升沿瞬间DIO还没被释放读到的永远是自己的高电平。有的模块上TM1637没有接应答上拉电阻或者DIO开漏后上拉不够强的时候读应答可能不稳定。处理方法是如果应答检测失败可以不理会它继续往下发数据。因为实际工作中写显存很少出现寻址错误只要时序对数据就能进去。我在代码里把应答结果返回出来但上层调用时只做打印提示不阻塞流程。3.3 段码表与显示函数封装TM1637内部不负责把ASCII字符或数字转换成段码它只负责把显存里的8位数据对应到数码管的a到g段和小数点。所以我们需要自己维护一张段码表。共阴数码管段码表如下位序是从最低位开始依次是a、b、c、d、e、f、g、dpconst uint8_t TM1637_SegTable[] { 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 0x00, // 熄灭 0x80, // 小数点 };为什么0是0x3F从最低位开始a段1、b段1、c段1、d段1、e段1、f段1、g段0合起来就是0b00111111也就是0x3F。如果你手头的数码管是共阳或者段序引脚不一致这个表要重新推导不要直接抄。接下来写显示函数。我的设计思路很简单调用方向某个位置写入一个字符或数字底层自动更新显存。先封装一个单字节写显存接口void TM1637_WriteData(uint8_t addr, uint8_t data) { TM1637_Start(); TM1637_WriteByte(0x40); // 数据命令自动地址递增写显存 TM1637_Stop(); TM1637_Start(); TM1637_WriteByte(0xC0 | addr); // 地址命令 TM1637_WriteByte(data); // 段码数据 TM1637_Stop(); TM1637_Start(); TM1637_WriteByte(0x8F); // 显示控制开显示最大亮度 TM1637_Stop(); }有人可能会问为什么不一次把所有位的数据连续写完非要一位一位写如果每次写一位都发一遍0x40和0x8F效率确实低对数码管这种低频设备来说1ms和10ms的差异根本看不出来所以怎么简洁怎么来。如果你有性能洁癖可以改成批量写void TM1637_DisplayFull(uint8_t *seg_data, uint8_t len) { uint8_t i; TM1637_Start(); TM1637_WriteByte(0x40); TM1637_Stop(); TM1637_Start(); TM1637_WriteByte(0xC0); for (i 0; i len; i) { TM1637_WriteByte(seg_data[i]); } TM1637_Stop(); TM1637_Start(); TM1637_WriteByte(0x8F); TM1637_Stop(); }批量写入方式的优势是只通过一次START和STOP就完成整段显存更新中间地址自动递增不会出现多位数据之间闪烁的问题。不过我在实际项目里更喜欢一个更直观的接口直接传入要显示的4个数字和小数点标志void TM1637_DisplayNumber(int16_t num, uint8_t dot_pos) { uint8_t buf[4]; uint8_t i; if (num 0) { num -num; buf[0] TM1637_SegTable[16]; // 负号我没放进去需要自己加 } buf[0] TM1637_SegTable[(num / 1000) % 10]; buf[1] TM1637_SegTable[(num / 100) % 10]; buf[2] TM1637_SegTable[(num / 10) % 10]; buf[3] TM1637_SegTable[num % 10]; if (dot_pos 4) { buf[dot_pos] | 0x80; // 加上小数点段 } TM1637_DisplayFull(buf, 4); }注意buf[dot_pos] | 0x80是把该位段码的最高位置1这样就在对应数码管上点亮小数点。如果你的温控器需要显示25.6这种格式就把小数点位设为1。3.4 扩展功能时钟显示与按键扫描既然是做温控器我顺手把时钟显示也实现了。利用TM1637可以显示4位数字的特点用HH:MM格式显示时间冒号用一个特制的段码点亮数码管中间的两个小数点。不过四位数码管中间没有冒号灯实际表现是第二位和第三位之间没有分隔比较丑。更好的做法是直接用小数点当冒号把dot_pos设为1让第二位的小数点常亮模拟冒号效果。这是我实测下来颜值最高的方案。TM1637的按键扫描功能也很实用。芯片支持最多8个按键通过DIO读取。扫描时序跟写数据类似但需要发送0x42读按键命令然后连续读取两个字节的按键状态。硬件上按键一端接DIO另一端接GND或VCC不同接法读到的键值不同。这一块我在本文不多展开后面如果大家有兴趣我可以单独写一篇。4. 实测中遇到的坑与排查指南4.1 数码管不亮或亮度异常的排查先说最常见的情况代码烧进去了段码也写进去了但数码管就是不亮。我从实践中总结了一个排查顺序一步步来基本都能解决。第一步查供电。TM1637模块的VCC必须和STM32F103共用一个电源域我见过有人在面包板上用电脑USB单独给模块供电结果两边GND没接在一起导致通信电平完全无效。第二步测CLK和DIO的静态电平正常情况下空闲时两个引脚都应该是高电平如果CLK是0V大概率是GPIO配置成开漏并且没开上拉了。第三步量TM1637的VCC引脚如果模块上自带稳压芯片电压应该稳定在3.3V或者5V。如果供电都正常但还是不亮那就要怀疑是不是忘了发显示控制命令。很多人写完数据命令和地址命令就结束了显存里明明有数据芯片显示控制寄存器却是关闭状态。显示控制命令0x8F的最后一位必须是1如果是0x88无论显存怎么写都不会亮。还有一个亮度相关的坑。TM1637的亮度调节范围是0x88到0x8F低三位表示亮度等级。实际测试下来亮度等级0和1在室内光线下基本看不出来但功耗差别不小。如果你做低功耗设备可以把亮度等级设为1肉眼观察依然清晰电流能省下几百微安。4.2 显示乱码和数字错位的深层原因显示乱码比较常见的原因有三个我分别说一下。第一个原因是段码表不匹配。如果你手头的数码管段序跟我的表不一致显示出来的字符就会乱掉。最简单的方法是写一个自检程序让每一位依次点亮a到g和dp段观察实际发光顺序再反推段码表。这个自检代码非常简单for (i 0; i 8; i) { seg 0x01 i; TM1637_WriteData(0, seg); delay_ms(300); }第二个原因是通信时序太快。很多STM32F103代码是从Arduino移植过来的Arduino的digitalWrite本身就慢换成STM32后GPIO翻转速度可能快了几十倍时序余量不足就会随机出错。解法很简单在两个引脚翻转之间加3到5微秒延时把速度降下来。TM1637芯片本身就不是高规格器件给它一点余量。第三个原因是我实际遇到的一个很诡异的现象明明写的是0xC0开始的首地址但显示内容整体偏移了一位。排查后发现是DIO引脚在读取应答时切换输入输出状态没切干净导致第9个时钟的应答位实际影响了下一次数据写入。解决方法是每次TM1637_WriteByte结束时显式把DIO设置为输出高电平确保总线回到确定状态。我建一个常见问题速查表方便收藏现象可能原因处理办法完全不亮未发送显示控制命令写入0x8F开显示完全不亮通信线接反CLK和DIO交换检查接线按顺序单色亮测试完全不亮模块供电电压异常检查VCC与GND与MCU共地数字乱码段码表不匹配用自检程序反推段码数字乱码时序过快引脚翻转间加延时亮度不均匀某一位接触不良重新插拔或补焊对应位显示内容错位应答检测干扰总线状态写完后强制DIO输出高4.3 硬件连接上的注意事项TM1637模块和STM32F103之间连线尽量短我测试过超过20厘米的杜邦线在72MHz主频下会出现偶发通信失败缩短到10厘米以内就稳定了。如果你必须长距离走线可以把CLK和DIO的驱动速度降下来延时从5微秒加大到20微秒牺牲一点刷新率换稳定性。模块供电建议单独对VCC脚加一个0.1微法去耦电容位置越靠近模块越好。如果模块和STM32F103之间还有其他大功率负载比如继电器、电机驱动一定要在电源入口做好滤波否则数码管显示会出现亮度抖动的现象。我怀疑这跟TM1637内部上电时序和电源纹波有关实测加电容后问题明显改善。还有一点STM32F103开发板上如果接到了5V的VIN引脚而模块VCC接到了3.3V两边电平不一样需要仔细确认。我上面用的方案是模块VCC接3.3VCLK和DIO直连没有任何电平转换问题。如果你手头只有5V供电的TM1637模块那需要确认模块上的电平转换电路是否存在否则最好用两个电阻分压或者加一个电平转换芯片。5. 实操过程与完整例程5.1 从接线到跑通的完整流程我把自己调试的完整流程记录在这里照着做基本一次点亮。第一步准备器材。STM32F103最小系统板一块、TM1637四位数码管模块一个、面包板一块、杜邦线四根。我用的是国产最小系统板STM32F103C8T6主频72MHz标准外设库工程。第二步接线。按前面表格把模块引脚和STM32F103连好。VCC接3.3VGND接GNDCLK接PA0DIO接PA1。建议先接电源再接信号线避免插拔信号线时产生瞬态电平损坏GPIO。第三步创建工程。新建一个标准外设库工程把system_stm32f10x.c和TM1637.c加进去。main函数里先初始化systick再调用TM1637_GPIO_Init最后循环显示一组数字。第四步烧录验证。我把完整main函数贴出来方便直接测试#include stm32f10x.h #include delay.h #include TM1637.h int main(void) { uint8_t i; uint8_t test_data[4]; Delay_Init(); TM1637_GPIO_Init(); // 开机自检依次显示0到9 for (i 0; i 10; i) { test_data[0] TM1637_SegTable[i]; test_data[1] TM1637_SegTable[9 - i]; test_data[2] TM1637_SegTable[i]; test_data[3] TM1637_SegTable[9 - i]; TM1637_DisplayFull(test_data, 4); Delay_Ms(200); } // 正式显示1234 test_data[0] TM1637_SegTable[1]; test_data[1] TM1637_SegTable[2]; test_data[2] TM1637_SegTable[3]; test_data[3] TM1637_SegTable[4]; TM1637_DisplayFull(test_data, 4); while (1) { } }第五步观察现象。正常情况是数码管自检阶段依次显示0到9的交替组合最后稳定显示1234。如果乱码、偏移或者不亮参考上一节的排查表。5.2 代码工程化的一些建议如果你不只是做个实验而是要做正式产品代码结构需要调整。我把TM1637的驱动单独封装成TM1637.c和TM1637.h对外只暴露四个函数TM1637_Init、TM1637_DisplayNumber、TM1637_DisplayString、TM1637_SetBrightness。上层应用完全不关心TM1637的时序细节想换显示模块的时候只改驱动层不用动业务逻辑。TM1637.h内容如下#ifndef __TM1637_H #define __TM1637_H #include stm32f10x.h #define TM1637_ADDR_START 0xC0 #define TM1637_CMD_DATA 0x40 #define TM1637_CMD_CTRL 0x8F void TM1637_GPIO_Init(void); void TM1637_DisplayNumber(int16_t num, uint8_t dot_pos); void TM1637_DisplayString(const char *str); void TM1637_SetBrightness(uint8_t brightness); #endifTM1637_DisplayString可以用来显示“8888”“HELP”“OFFS”这类简单字符我通常用它做故障提示。比如传感器读取异常时显示“Err”比看串口日志直观得多。5.3 在FreeRTOS里使用TM1637的注意点最近帮人移植了一个FreeRTOS的温控工程里面也用了TM1637。这种情况下要特别小心一个坑模拟时序函数如果被打断显示数据会损坏。假设高优先级任务正在执行TM1637的数据写入突然被定时器任务抢占时钟线停在半中间等一会儿高优先级任务回来后继续翻转引脚TM1637可能已经把中间状态误判成停止或起始信号最终显示乱码。解决办法有两个方向。第一个是给TM1637驱动加临界区保护在TM1637_Start和TM1637_Stop之间关中断。代码很简单void TM1637_DisplayNumber(int16_t num, uint8_t dot_pos) { __disable_irq(); // 计算段码并写入 __enable_irq(); }但是要控制临界区时间不能太长。我的实测值是显示4位数字完整写入需要大约1毫秒这个时间对绝大多数实时系统来说都可以接受但如果系统里有对时序要求极高的外设比如1ms中断的PWM控制你就要小心了。第二个方案是做一个显存缓冲区和刷新任务显示刷新由低优先级任务周期执行其他任务只修改缓冲区不直接操作IO。这个方案更优雅也是产品级的做法。底层显示任务每次获取缓冲区快照后用一个完整的不被打断的时序把数据刷到TM1637。优先级怎么设置我建议刷新任务优先级设在中等偏下因为数码管显示偶尔延迟几十毫秒用户根本感知不到但高优先级任务被抢占了可能导致控制逻辑抖动。实际项目中我把显示任务放在osPriorityLow控制任务放在osPriorityHigh跑了一整晚没出现显示异常。6. 个人实操中的一些体会这段时间反复调TM1637有几个体会特别想分享。第一很多单片机通信问题都不是协议太复杂而是电平状态没搞对。TM1637的DIO要求开漏加上拉和标准I2C的SDA思路一模一样把这个细节刻在脑子里以后遇到任何开漏总线都不会犯低级错误。第二调试数码管显示时不要一上来就写完整业务逻辑先用一个固定数据自检确认硬件通路没问题再往上叠代码排错效率高得多。另外我建议有条件的备一个逻辑分析仪哪怕是二三十块钱的简易版也行。看波形能直接确认起始信号、数据位、应答位是不是符合预期比人眼盯着数码管猜乱码原因快得多。我之前有一次通讯时好时坏用逻辑分析仪一看才发现是CLK下降沿之后DIO还没稳定时序窗口太窄加延时后立刻解决。TM1637作为一颗老芯片性能和功能谈不上惊艳但稳定、便宜、资料多在温控器、仪表、家电面板这些对成本敏感的领域依然活跃。STM32F103驱动它代码量很小哪怕新手跟着这篇文章操作大概一晚上也能跑通。以后再做显示功能时我推荐你在预算和引脚都宽裕时试试OLED在追求简单实用时直接选TM1637两者各有各的用武之地。
返回列表