
做单片机项目显示永远是绕不开的一环。我手头用得最多的就是STM32F103C8T6最小系统板十块钱出头跑点逻辑、采个传感器数据完全够用但每次要给项目加个显示都会纠结一阵子。用LCD1602吧接线多、有背光电流、还得调对比度用OLED吧SPI或者I2C都行但室外强光下看起来费劲成本也比数码管高。后来我开始大量用TM1637两根信号线就能驱动六位共阴数码管模块自带驱动芯片、亮度可调、内部自己扫描价格便宜强光下也看得清。这篇文章就把这套方案从硬件选型、协议分析、代码实现到踩坑体会完整记录一遍给正在纠结显示方案的朋友做个参考。1. 为什么是STM32F103 TM1637这个组合1.1 数码管显示方案对比很多人一上来就想着直接用GPIO点亮数码管觉得这样最省事。实际上直接驱动一颗四位数码管段选要8个IO、位选要4个IO总共12个IOSTM32F103虽然引脚多但你把IO都花在显示上传感器、按键、通信就捉襟见肘了。而且动态扫描要不停刷新CPU稍微干点别的事显示就开始闪。我最早用51单片机做电子钟就是这么干的效果能用但不优雅。后来大家普遍会加一颗驱动芯片市面上常见这几个方案方案占用IO驱动复杂度成本实际体验GPIO直接驱动8-12个需要限流电阻、动态扫描最低亮度可控但太占引脚扫描占用CPU74HC595串转并3个仍需软件做扫描、消隐低省IO但工作量和直接驱动差不多MAX72193个SPI内置扫描但寄存器配置复杂较高功能强驱动8位数码管绰绰有余TM16372个协议简单内置扫描很低两线搞定代码量小稳定TM1637真正吸引我的地方在于它把扫描、消隐、亮度调节全部集成在芯片内部单片机只需要把段码数据按协议扔过去芯片自己会持续驱动数码管显示。这意味着CPU写入一次数据之后就可以去干别的不需要像74HC595方案那样周期性刷新。1.2 硬件连接与最小系统准备我平时常用的是一块STM32F103C8T6蓝色最小系统板芯片是Cortex-M3内核72MHz主频Flash 64KBSRAM 20KB。配合TM1637模块两者组合非常经典。TM1637模块一般是四针引出VCC、GND、CLK、DIO有的还会把数码管一起集成在板上买回来直接插杜邦线就能用。连接方式VCC接STM32F103的5V或者3.3V。接5V时数码管更亮接3.3V也能工作只是亮度稍暗。我一般接5V。GND两者共地这个必须接。CLK时钟线接单片机的PB6。DIO数据线接单片机的PB7。为什么选PB6和PB7因为这两根引脚正好是STM32F103的I2C1引脚PB6对应I2C1_SCLPB7对应I2C1_SDA。虽然我下面会说TM1637不建议直接用硬件I2C但选这两根引脚至少保留了以后切硬件的可能性而且在最小系统板上也好引出。模块上的CLK和DIO一般已经通过4.7k或10k电阻上拉到VCC。如果买的是裸芯片自己搭电路记得在CLK和DIO上各接一个4.7k电阻到VCC否则信号容易受干扰。烧录调试方面用ST-Link下载器最省心接SWD四线SWDIO、SWCLK、GND、3.3V即可。ST-Link驱动没装好是新手最常见的卡壳点装完驱动设备管理器里能看到ST-Link端口才算正常。如果用的是DAP下载器下载失败时可以检查一下BOOT0和BOOT1跳线BOOT0应该接地从Flash启动我之前因为BOOT0跳线没接对DAP一直报错检查了半天才发现是这个原因。2. TM1637的通信协议不是I2C胜似I2C2.1 两线时序的底层特征TM1637的接口协议和I2C非常像也是两根线、也有起始信号和停止信号甚至也有ACK应答所以很多人误以为它就是标准I2C设备直接用单片机的硬件I2C外设去驱动结果各种诡异问题。实际上TM1637的时序只能算是“兼容部分I2C风格”的私有协议主要有三个核心差异。第一数据传输是低位在前LSB first而标准I2C是高位在前MSB first。如果直接用硬件I2C通行的发送方式字节会被翻成反序数码管显示的内容自然不对。第二TM1637没有从机地址概念。标准I2C每次通信第一字节要带7位从机地址和读写位TM1637完全没有这个机制它靠命令字节本身来区分接下来的操作。第三TM1637的ACK时序细节和标准I2C也有区别尤其是方向切换的时机需要单片机在读应答前精确释放数据线这在硬件I2C的自动时序里并不好控制。从波形上看起始信号是CLK高电平期间DIO从高拉低停止信号是CLK高电平期间DIO从低拉高。这两个信号和I2C的定义一致所以只要你能用GPIO模拟I2C就很容易改成TM1637的时序。数据位传输时CLK低电平期间DIO设置数据CLK上升沿时TM1637采样数据。每一位数据都要拉高拉低一次CLK速度不需要太快GPIO模拟完全够用。2.2 命令帧结构TM1637一共有三类命令分别是数据命令、地址命令和显示控制命令。通信的基本单位是一次“起始信号 若干字节 停止信号”每条命令都是一次完整的起始停止周期。命令类型命令字节功能说明数据命令0x40写数据地址自动递增模式数据命令0x44写数据固定地址模式较少用地址命令0xC0设置起始地址后接段码数据显示控制0x88~0x8F开显示低3位表示亮度0-7显示控制0x80关显示完整的显示流程是这样的先发送起始信号发送数据命令0x40停止再发送起始信号发送地址命令0xC0然后连续发送6个段码数据字节每个字节后TM1637都会拉低DIO作为ACK应答发送完停止最后再发送一次起始信号发送显示控制命令0x88加上亮度值停止。这样6位数字就稳定显示出来了。有个细节很多人会忽略0x40命令和0xC0命令也需要ACK吗需要。TM1637对每一条命令、每一个数据字节都会产生ACK所以写完命令字节后要释放DIO并读取应答。如果ACK读取失败或者总线上有其他干扰通信就会错乱。2.3 为什么放弃STM32F103硬件I2C改用GPIO模拟STM32F103的硬件I2C模块是出了名的难伺候尤其是标准外设库时代很多人在配置硬件I2C时遇到总线锁死SCL被拉低再也出不来的情况。网上搜STM32 I2C bus locked能找到一大堆帖子。虽然很多锁死案例是初始化顺序或中断处理不当造成的但即便如此硬件I2C处理TM1637这种非标时序依然不划算。硬件I2C会自动发送从机地址字节而TM1637不认地址直接就会出错。你只能用“寄存器直接操作”的方式绕开地址帧这等于把硬件I2C最重要的自动寻址功能废掉剩下的时序还要手动干预。与其这么别扭不如直接用GPIO模拟代码量不大时序完全可控想延时多久就延时多久排错也直观。GPIO模拟方案还有一个好处任意两个空闲引脚都能驱动TM1637不一定要绑定I2C引脚。我实际测试中发现同样是驱动TM1637GPIO模拟的稳定性在杜邦线较长、电磁环境较差的场合明显更好。硬件I2C的上升沿陡峭在长线上容易产生振铃而GPIO模拟时你可以主动放慢时钟频率、拉宽信号宽度让信号边沿更柔和抗干扰能力更强。3. 驱动代码拆解从GPIO初始化到六位数据显示3.1 底层延时与GPIO初始化下面代码基于STM32CubeMX生成的HAL工程。先说引脚配置CLK引脚PB6配置为开漏输出DIO引脚PB7需要动态切换输入输出所以也配置为开漏输出同时外部加上拉电阻代码里配置上拉也可以。开漏输出的好处是输出低电平时能主动拉低输出高电平时引脚是高阻态依赖外部上拉。这样当我们需要释放DIO读取ACK时直接把引脚从输出模式切换成输入模式就行总线上不会出现两个设备同时驱动的冲突问题。如果你用推挽输出DIO切到输入模式的瞬间模块也在驱动DIO就可能造成短路或者电平竞争。延时函数我用了一个简单的循环加NOP72MHz主频下大约等效几微秒。其实TM1637的时钟速度上限远高于我们实际使用速度延时长一点完全没问题反而更稳。static void TM1637_Delay(void) { volatile uint32_t i; for (i 0; i 200; i) { __NOP(); } }这里用volatile修饰是防止编译器把整个空循环优化掉。实际项目里如果你想更精确控制时序可以用SysTick或DWT计数器但对TM1637这种低速设备来说一个稳定的空循环延时已经足够没必要上复杂方案。3.2 时隙驱动函数start、stop、写字节与应答检测以下是tm1637.c里的核心底层函数我把每一段都加了注释。static void TM1637_Start(void) { TM1637_DIO_Output(); HAL_GPIO_WritePin(TM1637_DIO_PORT, TM1637_DIO_PIN, GPIO_PIN_SET); HAL_GPIO_WritePin(TM1637_CLK_PORT, TM1637_CLK_PIN, GPIO_PIN_SET); TM1637_Delay(); // CLK为高时DIO从高拉低产生起始信号 HAL_GPIO_WritePin(TM1637_DIO_PORT, TM1637_DIO_PIN, GPIO_PIN_RESET); TM1637_Delay(); HAL_GPIO_WritePin(TM1637_CLK_PORT, TM1637_CLK_PIN, GPIO_PIN_RESET); TM1637_Delay(); } static void TM1637_Stop(void) { TM1637_DIO_Output(); // CLK为高时DIO从低拉高产生停止信号 HAL_GPIO_WritePin(TM1637_DIO_PORT, TM1637_DIO_PIN, GPIO_PIN_RESET); HAL_GPIO_WritePin(TM1637_CLK_PORT, TM1637_CLK_PIN, GPIO_PIN_SET); TM1637_Delay(); HAL_GPIO_WritePin(TM1637_DIO_PORT, TM1637_DIO_PIN, GPIO_PIN_SET); TM1637_Delay(); }写字节函数是核心。每个字节8位低位在前。一个时钟周期里先把CLK拉低然后设置DIO电平延时再把CLK拉高让TM1637在上升沿采样。第9个时钟周期用来读取ACK在CLK拉低时把DIO设为输入模式CLK拉高后读取DIO电平低电平表示应答成功。static uint8_t TM1637_WriteByte(uint8_t dat) { uint8_t i; TM1637_DIO_Output(); for (i 0; i 8; i) { HAL_GPIO_WritePin(TM1637_CLK_PORT, TM1637_CLK_PIN, GPIO_PIN_RESET); HAL_GPIO_WritePin(TM1637_DIO_PORT, TM1637_DIO_PIN, (dat 0x01) ? GPIO_PIN_SET : GPIO_PIN_RESET); TM1637_Delay(); HAL_GPIO_WritePin(TM1637_CLK_PORT, TM1637_CLK_PIN, GPIO_PIN_SET); TM1637_Delay(); dat 1; } // 第9个时钟读取ACK HAL_GPIO_WritePin(TM1637_CLK_PORT, TM1637_CLK_PIN, GPIO_PIN_RESET); TM1637_DIO_Input(); TM1637_Delay(); HAL_GPIO_WritePin(TM1637_CLK_PORT, TM1637_CLK_PIN, GPIO_PIN_SET); TM1637_Delay(); uint8_t ack (HAL_GPIO_ReadPin(TM1637_DIO_PORT, TM1637_DIO_PIN) GPIO_PIN_RESET) ? 1 : 0; HAL_GPIO_WritePin(TM1637_CLK_PORT, TM1637_CLK_PIN, GPIO_PIN_RESET); TM1637_Delay(); return ack; }这里要注意一个容易踩坑的细节读取ACK期间DIO被设成了输入模式写完ACK读取后函数返回时DIO还是输入状态。下一次调用Start或者WriteByte时第一步都会先调用TM1637_DIO_Output()切回输出所以不会留隐患。但如果你自己改造代码务必要记住这个状态切换否则会出现第一次通信正常、第二次数据全部错乱的现象因为DIO还悬空着呢。3.3 段码表与数字/小数点编码TM1637内部驱动的是共阴数码管所以段码是共阴编码。一个字节从bit0到bit7分别对应数码管的a、b、c、d、e、f、g、dp段某一位为1表示该段点亮。0到9的段码如下。显示字符段码显示字符段码00x3F50x6D10x0660x7D20x5B70x0730x4F80x7F40x6690x6F常用字母也可以编码比如A对应0x77b对应0x7CC对应0x39d对应0x5EE对应0x79F对应0x71横杠“-”对应0x40全灭对应0x00。小数点就是bit7置1所以给数字段码或上0x80就能显示小数点。我在代码里用的是一个常量数组static const uint8_t seg_table[] { 0x3F, 0x06, 0x5B, 0x4F, 0x66, 0x6D, 0x7D, 0x07, 0x7F, 0x6F, 0x77, 0x7C, 0x39, 0x5E, 0x79, 0x71, 0x40, 0x00 }; // 0..9, A,b,C,d,E,F, -, 全灭3.4 上层显示API与完整示例先定义头文件把几个顶层函数暴露出去这样应用层不需要关心底层时序。#ifndef __TM1637_H #define __TM1637_H #include main.h #define TM1637_CLK_PORT GPIOB #define TM1637_CLK_PIN GPIO_PIN_6 #define TM1637_DIO_PORT GPIOB #define TM1637_DIO_PIN GPIO_PIN_7 #define TM1637_ADDR_AUTO 0x40 #define TM1637_START_ADDR 0xC0 #define TM1637_DISPLAY_ON 0x88 void TM1637_Init(void); void TM1637_Display(int32_t value, uint8_t dot); void TM1637_DisplayRaw(uint8_t *buf, uint8_t len); void TM1637_SetBrightness(uint8_t level); void TM1637_Clear(void); #endif数据发送函数负责把6个段码一次性刷到TM1637上。顺序是先发0x40数据命令再发0xC0地址命令接着连续发6个字节最后发显示控制命令设置亮度和开关。注意0x40和0xC0之间、0xC0和第一个数据字节之间是不需要停止重新开始的0x40命令完成后必须有停止但0xC0和数据字节属于同一次通信周期。void TM1637_DisplayRaw(uint8_t *buf, uint8_t len) { uint8_t i; if (len 6) len 6; TM1637_SendCmd(TM1637_ADDR_AUTO); // 数据命令自动地址递增 TM1637_Start(); TM1637_WriteByte(TM1637_START_ADDR); // 地址命令从第一位数码管开始 for (i 0; i len; i) { TM1637_WriteByte(buf[i]); } TM1637_Stop(); TM1637_SendCmd(TM1637_DISPLAY_ON | (g_brightness 0x07)); }显示整数并指定小数点位void TM1637_Display(int32_t value, uint8_t dot) { uint8_t buf[6]; uint8_t neg 0; uint32_t v; int i; if (value 0) { neg 1; v (uint32_t)(-value); } else { v (uint32_t)value; } for (i 5; i 0; i--) { buf[i] seg_table[v % 10]; v / 10; } if (neg) { buf[0] seg_table[16]; // 最高位显示负号 } if (dot 6) { buf[dot] | 0x80; // 对应位加小数点 } TM1637_DisplayRaw(buf, 6); }main函数里的简单使用示例int main(void) { HAL_Init(); SystemClock_Config(); TM1637_Init(); TM1637_SetBrightness(4); int32_t count 0; while (1) { TM1637_Display(count, 1); // 第二位带小数点模拟xx.x格式 count; if (count 999) count 0; HAL_Delay(100); } }这里dot参数的含义是第几位带小数点0代表第一位1代表第二位。如果显示温度25.3度可以把整数部分和小数部分分开计算小数点位放在第二位。整体逻辑就是把整数拆成6位数字套用段码最后批量发送。4. 实际跑起来之后踩过的坑4.1 硬件I2C的坑与软件模拟的取舍我在这个项目上最早用的是硬件I2C方案想省事结果被坑了一次。现象是第一次上电时数码管能正常显示但运行几秒后显示内容就固定不动了再往下写数据没有反应。后来用示波器量波形发现SCL也就是CLK一直停在低电平这是典型的I2C总线锁死。总线锁死的根源是我在中断里处理I2C事件时对总线错误标志处理不完整导致状态机卡死在某个中间状态。硬件I2C的时序对中断响应时间有要求你在主循环里干了一件事占用CPU过久中断响应不及时时序就对不上了。换用GPIO模拟之后这个问题彻底消失。GPIO模拟不依赖外设状态机所有时序都是软件一条一条拉出来的CPU忙不忙完全不影响已经发送出去的信号。所以我的建议很明确F103上用TM1637直接用软件模拟不要纠结硬件I2C。4.2 ACK读取与DIO方向切换这是我自己写代码时踩得最深的一个坑。最初我读ACK时偷懒没有把DIO切到输入模式而是直接ReadPin读取电平结果第一个字节能读到低电平后续字节全部读到高电平数码管显示乱码。原因在于TM1637的ACK机制是靠拉低DIO实现的。如果DIO始终处于推挽输出状态当你输出高电平时TM1637根本无法把DIO拉低总线电平被单片机强行驱动ACK位自然读不到。每次写完8位数据后必须先把DIO配置成输入模式让出总线控制权TM1637才能给出应答信号。从输入模式切回输出模式时也要注意时机必须在起始信号或者下一个写字节的开头之前完成。否则你还没准备好输出TM1637可能在总线上产生了数据双方数据一冲突通信就乱了。4.3 3.3V主控驱动5V模块的电平匹配TM1637模块如果接5V供电模块上的上拉电阻通常也是拉到5V的这意味着CLK和DIO的高电平参考是5V。STM32F103的GPIO输出高电平是3.3V理论上是没法完全驱动5V高电平的逻辑。不过实测下来这种组合在大多数情况下是能稳定工作的因为数字芯片的高电平门槛通常是0.7倍VCC也就是3.5V差了那么一点点。如果你运气不好模块稳定性差或者杜邦线太长就可能出现偶发乱码。我的处理办法是把模块上拉电阻焊掉重新接一个3.3V的上拉电源让CLK和DIO的高电平和STM32F103一致这样通信就非常稳了。更简单的做法是直接用3.3V给TM1637模块供电亮度低一点但完全能用电平匹配问题就绕开了。4.4 乱码、缺笔画、闪烁的排障顺序遇到显示问题先别急着改代码按顺序排查能省不少时间。先看电源。TM1637模块全亮时电流能到几十毫安如果你用面包板供电接触不良或者线太细亮度一提高电压就跌落数码管就会闪烁甚至乱码。我遇到过一次电机启动瞬间数码管乱跳后来在模块电源脚旁边并了一个104陶瓷电容和一个100uF电解电容问题就消失了典型的电源瞬态响应不足。再看接线。缺笔画多半是DIO或CLK某一位信号太弱或是杜邦线接触不良。把线重新插一遍最好按压一下看笔画是否恢复。然后才是代码。乱码如果出现在固定的某一位上重点检查该位的段码计算如果整个六位都乱优先怀疑时序延时不够把TM1637_Delay的循环次数加大一些再试。我调试时习惯把延时调到500稳定后再逐步减小找到一个能工作的最小值。闪烁问题通常是刷新频率太低。TM1637内部扫描约500Hz我们写入数据的速度不会对它造成闪烁但如果你的主循环里每隔几百毫秒才刷新一次数码管在刷新间隙显示的还是旧数据人眼看起来就会有跳变感。想要平滑显示加个定时器每5到10毫秒刷新一次。5. 显示效果优化与功能扩展5.1 定时刷新策略很多人在主循环里直接调用TM1637_Display这在小程序里没问题但如果你有按键扫描、串口收发、传感器读取这些耗时操作刷新频率就不稳定了。我的做法是开一个定时器中断每5毫秒刷新一次显示内容主循环只负责维护一个全局显示缓冲区。volatile uint8_t disp_buf[6]; void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) ! RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); TM1637_DisplayRaw((uint8_t *)disp_buf, 6); } }这样无论主循环在忙什么数码管始终保持稳定的显示不会因为某次传感器读取时间过长而闪烁。注意定时器中断频率不能太高TM1637发送一次数据大约几百微秒5毫秒周期完全足够。5.2 搭配DHT11显示温湿度这是很常见的组合STM32F103读取DHT11温湿度传感器然后把结果显示到TM1637上。DHT11是单总线协议数据是一位一位读出来的读到的是温度和湿度的整数部分和小数部分。你可以维护一个变量把温度显示在左边三位湿度显示在右边三位中间用小数点甚至横杠分隔。关键点是DHT11读取和TM1637刷新不要互相阻塞。DHT11一次读取要等传感器拉低80us再拉高80us之后还有40位数据整个过程大约4到5毫秒。如果在读取期间完全阻塞TM1637刷新数码管就可能闪一下。用上面的定时器刷新策略就能完美解决。5.3 关于按键扫描与多模块扩展TM1637芯片本身设计上还支持按键扫描功能也就是在数码管动态扫描的同时DIO线在特定时隙可以读取外部按键状态。不过市面上大多数TM1637显示模块并没有把按键扫描电路做出来引脚也没引出来所以你买到的模块一般都只能当纯显示用。想用按键的话要么买专门的TM1637键盘显示模块要么换支持按键扫描的其它驱动芯片比如TM1638。另一个经常有人问的问题是我想显示12位数字能不能挂两个TM1637模块答案是不建议直接并联在同一个CLK和DIO上因为TM1637没有设备地址两个模块都会同时响应同一份数据显示内容会一样。想扩展显示位数最干净的办法是给每个模块分配独立的CLK和DIO引脚分别初始化、分别刷新。F103的IO足够多驱动两个甚至三个TM1637都没压力前提是每组的CLK和DIO都要独立控制不能并线。5.4 轮换显示与特殊字符如果你要显示的内容超过6位比如一个十位的订单号可以用轮换显示的方式。先用一个状态标志记录当前显示的是低六位还是高六位每隔一秒切换一次数码管交替显示两组数字。这个方案虽然不能同时看全所有位但临时用完全没有问题而且代码逻辑很简单。TM1637还可以显示一些特殊字符比如全灭、横杠、上横杠、下横杠。做倒计时的时候数字前面多余的0可以用全灭段码消隐掉这样看起来更干净。我在做时钟时用第2位的小数点当作秒分隔点每秒钟闪烁一次模拟电子表的冒号效果。做法就是每一秒钟进入中断时把小数点位的0x80置上或者清掉再刷新一遍缓冲区视觉效果很自然。最后再分享一点经验TM1637这套方案我前前后后用了快两年踩过的坑基本都在这篇文章里了。如果你正准备给STM32F103项目加一个低成本显示模块直接照着上面的代码抄一遍大概率一次就能点亮。如果第一次上电数码管没反应不要慌先拿万用表量一下模块的VCC和GND是不是真的有电压再量CLK在运行代码时有没有脉冲基本就能定位问题。个人体会是这类带驱动芯片的显示模块把时序和电平匹配搞清楚之后剩下的就是个发送函数的事非常简单。