☰
STM32用TM1650驱动数码管:模拟I2C实现省IO显示方案
2026/10/5 6:22:48 网站建设 项目流程

前阵子给一个小温控装置加显示,4位数码管摆在那很合适,但我看了一眼板子上还剩下的IO口,心里凉了半截。直接驱动4位共阴数码管,段码要8个IO,位选要4个IO,加起来12个。对于一个普通STM32项目来说,这基本把资源吃得差不多了。后来换了TM1650数码管模块,两根线搞定,显示刷新、亮度控制全部交给芯片,代码里只需要关注"显示什么数字",完全不用管动态扫描。而且我用的是普通IO口模拟I2C,没有动用芯片自带的硬件I2C外设,省心又灵活。这篇文章就分享一下我自己的完整做法和踩坑记录,代码用STM32标准库写的,你改一下引脚定义就能直接用。如果你也遇到GPIO不够用、模块驱动不亮、或者纠结模拟I2C时序的问题,这篇应该能帮上忙。

1. 先算一笔账:普通IO直接驱数码管 vs TM1650

1.1 直接驱动数码管,IO成本一眼算清

很多新手第一次接触数码管,第一反应就是"段码接IO,位选接IO,直接驱动"。这个思路本身没问题,但成本必须算清楚。

一个4位共阴数码管,若采用静态驱动,8个段(a、b、c、d、e、f、g、dp)各占1个IO,4个位选占4个IO,加起来12个IO。若采用动态扫描,可以在一定程度上减少段选电阻和位驱动电路,但IO占用并没有减少,只是把驱动电流的压力转移到了三极管或ULN2003上。

而且STM32的GPIO本身驱动能力有限,F103系列单个IO最大灌电流约25mA,但整个芯片的电流预算有限。数码管每段正常工作电流在5mA到20mA之间,4位全部点亮时,电流会非常可观。所以直接驱动并不是简单的"IO够不够"问题,还要面对限流电阻、位驱动力、刷新频率、发光亮度不均匀等一系列麻烦。

1.2 TM1650方案:省下来的IO能干嘛

TM1650是一个I2C接口的LED驱动芯片,特别适合驱动4位以内的数码管。它内部集成锁存器、驱动电路和键盘扫描电路。从MCU角度看,你只需要两根线——SCL和SDA,然后按I2C协议丢数据过去,剩下的扫描、锁存、驱动全部由TM1650自己完成。

我们看对比:

方案IO占用外围电路显示方式适合场景
IO直接驱动12个限流电阻、三极管/ULN2003动态扫描或静态锁存引脚富余、想完全自己控制
74HC595级联3个595芯片、限流电阻串行转并行位数多、可级联、价格低
TM16502个模块自带驱动和上拉I2C写寄存器4位以内、省IO、可扩展按键

我那次项目里做完TM1650之后,省下的10个IO用在了几个按键和状态LED上,整个板子不用换大封装MCU,成本也控制住了。对很多小产品来说,IO就是钱,能省就省。

1.3 什么时候不该选TM1650

TM1650不是万能的。如果项目只需要1位或2位数码管,直接用IO驱动更简单,代码量也更小。如果位数超过4位,比如8位数码管,TM1650一个芯片管不了,需要两片或多片,这时74HC595级联可能更划算。另外,如果项目环境电磁干扰比较厉害,模拟I2C又处理不好线路上的毛刺,板载硬件I2C+中断的方式会更稳定。但大部分桌面小制作、仪表显示、温度显示场景,TM1650是性价比很高的选择。

2. 模拟I2C的原理:为什么普通IO口能干硬件I2C的活

2.1 I2C通信的最小单元

很多人一听"I2C协议"就觉得很难,其实把它拆开看,总共就几个动作:起始信号、停止信号、写一个字节、读一个字节、应答信号。

起始信号的定义是:在SCL保持高电平期间,SDA由高电平跳变为低电平。停止信号则是:在SCL保持高电平期间,SDA由低电平跳变为高电平。写字节和读字节都遵循"SCL低电平时改变数据,SCL高电平时采样数据"的规则,一次传输8个bit,第9个时钟用来传输应答位。

理论上,只要GPIO能输出高低电平、能读回引脚电平,就能实现I2C。这也是模拟I2C能被广泛应用的根本原因。硬件I2C无非是把这些时序用状态机封装好,但对使用者来说,反而多了配置和调试成本。

2.2 GPIO开漏输出:SDA的释放和读取

用普通IO口模拟I2C,最关键的技巧是SDA引脚要配置为开漏输出。

开漏输出是什么意思?简单说,这个引脚内部只有一个下拉MOS管,输出低电平时主动拉低,输出高电平时其实是"释放",引脚电平由外部上拉电阻决定。这一个特性对模拟I2C太重要了,因为I2C的应答机制要求从设备能把SDA拉低,主机在第9个时钟释放SDA来读取这个电平。

如果SDA配置成推挽输出,读引脚前就得切换方向,代码多几行不说,还容易出现竞态问题。用开漏输出就省事很多:发完8个bit后,直接把SDA输出寄存器写成1,引脚被释放,从设备如果拉低了SDA,我们就能读到一个0,这就是ACK;如果没人应答,由于上拉电阻的存在,SDA保持高电平,我们读到1,就是NACK。

SCL一般建议用推挽输出。虽然I2C标准里SCL也是开漏,但主机这边的SCL通常不会被从设备拉低,直接用推挽输出驱动高电平更干脆,还能少一根上拉电阻。我给模块用的就是SCL推挽、SDA开漏这个组合。

2.3 时序延时怎么定,关系到通信稳定性

模拟I2C的时序完全靠延时函数控制。标准I2C有100kHz(标准模式)和400kHz(快速模式)等速率等级,100kHz下每一个bit的时钟高电平和低电平持续时间大约都是5微秒。

在实际项目里,我习惯把速率控制在100kHz附近,原因有两个:第一,TM1650本身不是高速器件,就算给它400kHz,显示的刷新效果也不会有肉眼可见的提升;第二,模拟I2C走的是普通IO翻转,如果延时太小,在长线上容易出现波形边沿不够陡、采样点抖动的问题。

延时函数可以用SysTick实现的微秒延时,也可以在未优化编译下用空循环。72MHz主频下,空循环40次大约能产生2到5微秒的延时,已经够用。关键是根据实际抓到的波形调整,不要太死板。

3. TM1650寄存器与段码:点亮数码管前必须搞清楚的几件事

3.1 0x48还是0x90?器件地址先搞对

我第一次调TM1650的时候,在网上抄了一堆代码,有的写0x48,有的写0x90,还有的写0x24。当时还纳闷,同一个芯片怎么地址还不一样,后来查了手册才明白,这三个数字只是同一个地址的不同写法。

TM1650的I2C从机地址由ADR引脚决定,常见情况下7位地址是0x24,那么在8位写地址格式下,最低位补0,得到0x48。代码里如果发送0x48,实际上就是把0x24左移一位再加写标记0。而有些模块原理图上把ADR引脚接高电平,7位地址变成0x48,写操作时就要发0x90。

一句话总结:代码里的第一个地址字节到底写多少,取决于你的模块。遇到不亮的情况,优先改这个数,往往就解决了。判断方法很简单,发完地址字节之后看从设备有没有回ACK,如果返回无应答,就换另一个地址试试。

3.2 显示控制寄存器和段码寄存器

TM1650内部有若干寄存器,其中和显示相关的有两类:一类是显示控制寄存器,一类是段码显示寄存器。

常见模块上,显示控制寄存器地址是0x48,向它写入0x01之类的方式可以开启显示,同时配置亮度。段码显示寄存器通常从0x68开始,一共4个,分别对应4位数字。每次写一位数字,就是先发器件地址,再发目标寄存器的地址,然后发这段数码管的段码值。

这里很容易踩一个混淆的坑:器件地址可能是0x48,显示控制寄存器地址也可能是0x48,两个0x48连在一起很容易让人发懵。我后来在代码里把两个宏分开定义,一个叫TM1650_DEV_ADDR,一个叫TM1650_REG_MODE,逻辑就清楚多了。

3.3 段码表与共阴共阳

TM1650设计上是驱动共阴数码管的,段输出为高电平有效。也就是说,数字0对应的段码是0x3F(a、b、c、d、e、f六段点亮),数字1对应0x06(b、c两段点亮)。

如果你手里的模块是共阳数码管,段码就需要全部反相,甚至可能因为电平不匹配根本点不亮。所以买模块的时候一定确认清楚,目标就是"TM1650共阴4位数码管模块",绝大多数市面上的4位模块都是按照共阴段码设计的。

段码表里最高位0x80通常对应小数点。也就是说,想显示"3.",就在数字3的段码0x4F基础上加上0x80,得到0xCF。这个在实际时钟、温度显示里非常常用。

4. 完整驱动代码:标准库版,烧进去就能显示

4.1 引脚定义与GPIO初始化

我这里用PB6做SCL,PB7做SDA。这两个引脚在F103上和硬件I2C1复用,但你把它当普通IO来用完全没问题,软件模拟不看这些。

#define TM1650_SCL_PORT GPIOB #define TM1650_SCL_PIN GPIO_Pin_6 #define TM1650_SDA_PORT GPIOB #define TM1650_SDA_PIN GPIO_Pin_7 #define SCL_H() GPIO_SetBits(TM1650_SCL_PORT, TM1650_SCL_PIN) #define SCL_L() GPIO_ResetBits(TM1650_SCL_PORT, TM1650_SCL_PIN) #define SDA_H() GPIO_SetBits(TM1650_SDA_PORT, TM1650_SDA_PIN) #define SDA_L() GPIO_ResetBits(TM1650_SDA_PORT, TM1650_SDA_PIN) #define SDA_READ() GPIO_ReadInputDataBit(TM1650_SDA_PORT, TM1650_SDA_PIN)

GPIO初始化时,SCL配置为推挽输出,SDA配置为开漏输出。开漏输出的SDA需要外部上拉电阻,一般4.7k到10k都行。很多成品模块板上已经焊了上拉,不需要再加;如果是自己搭的电路,SDA到3.3V之间务必接一个10k电阻。

void SoftI2C_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin = TM1650_SCL_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(TM1650_SCL_PORT, &GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin = TM1650_SDA_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_OD; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(TM1650_SDA_PORT, &GPIO_InitStructure); SCL_H(); SDA_H(); }

4.2 模拟I2C底层:Start、Stop、SendByte

这一部分是最核心的代码。Start和Stop的时序严格按照定义来实现,SendByte则是在SCL低电平期间改SDA,高电平期间保持数据稳定。

static void SoftI2C_Delay(void) { uint32_t i; for (i = 0; i < 40; i++); /* 72MHz下约2~5us,接近100kHz */ } void SoftI2C_Start(void) { SDA_H(); SCL_H(); SoftI2C_Delay(); SDA_L(); /* SCL高电平期间,SDA下降沿 = 起始 */ SoftI2C_Delay(); SCL_L(); SoftI2C_Delay(); } void SoftI2C_Stop(void) { SDA_L(); SCL_H(); SoftI2C_Delay(); SDA_H(); /* SCL高电平期间,SDA上升沿 = 停止 */ SoftI2C_Delay(); } uint8_t SoftI2C_SendByte(uint8_t dat) { uint8_t i; for (i = 0; i < 8; i++) { if (dat & 0x80) SDA_H(); else SDA_L(); dat <<= 1; SoftI2C_Delay(); SCL_H(); SoftI2C_Delay(); SCL_L(); SoftI2C_Delay(); } /* 第9个时钟释放SDA,读取应答 */ SDA_H(); SoftI2C_Delay(); SCL_H(); SoftI2C_Delay(); if (SDA_READ() == 0) /* 从设备拉低SDA */ { SCL_L(); SoftI2C_Delay(); return 0; /* 应答正常 */ } SCL_L(); SoftI2C_Delay(); return 1; /* 无应答 */ }

有一点需要注意,SDA是开漏输出,SDA_H()其实只是释放引脚,真正的高电平靠外部上拉。所以在第9个时钟里直接读SDA_READ(),完全不需要切换方向。很多新手在这里纠结"输出模式下能不能读引脚",STM32的开漏输出模式是可以读IDR的,放心用。

4.3 TM1650驱动封装:初始化、显示数字、清屏

有了底层I2C,TM1650的驱动就水到渠成了。

#define TM1650_DEV_ADDR 0x48 /* 8位写地址,对应7位地址0x24;若无效试0x90 */ #define TM1650_REG_MODE 0x48 /* 显示控制寄存器 */ #define TM1650_REG_DIG0 0x68 /* 第1位段码寄存器 */ static const uint8_t TM1650_SegTable[] = { 0x3F, 0x06, 0x5B, 0x4F, 0x66, /* 0 1 2 3 4 */ 0x6D, 0x7D, 0x07, 0x7F, 0x6F, /* 5 6 7 8 9 */ 0x77, 0x7C, 0x39, 0x5E, 0x79, 0x71 /* A b C d E F */ }; static void TM1650_WriteSeg(uint8_t pos, uint8_t seg) { SoftI2C_Start(); if (SoftI2C_SendByte(TM1650_DEV_ADDR) == 0) { SoftI2C_SendByte(TM1650_REG_DIG0 + pos); SoftI2C_SendByte(seg); } SoftI2C_Stop(); } void TM1650_Init(void) { SoftI2C_Start(); if (SoftI2C_SendByte(TM1650_DEV_ADDR) == 0) { SoftI2C_SendByte(TM1650_REG_MODE); SoftI2C_SendByte(0x01); /* 开启显示,默认亮度 */ } SoftI2C_Stop(); } void TM1650_ShowDigit(uint8_t pos, uint8_t num, uint8_t dot) { uint8_t seg = TM1650_SegTable[num & 0x0F]; if (dot) seg |= 0x80; TM1650_WriteSeg(pos, seg); } void TM1650_Clear(void) { uint8_t i; for (i = 0; i < 4; i++) TM1650_WriteSeg(i, 0x00); }

注意TM1650_Clear不能调用TM1650_ShowDigit再传数字0,因为数字0的段码是0x3F,会把0显示出来。要清屏,直接往段码寄存器写0x00才对。

4.4 主函数示例

主函数里先初始化,然后循环显示四个数字。

int main(void) { /* 如果你工程里有延时函数,记得初始化 */ SoftI2C_Init(); TM1650_Init(); while (1) { TM1650_ShowDigit(0, 1, 0); /* 第1位显示1 */ TM1650_ShowDigit(1, 2, 0); /* 第2位显示2 */ TM1650_ShowDigit(2, 3, 1); /* 第3位显示3. */ TM1650_ShowDigit(3, 4, 0); /* 第4位显示4 */ } }

这里有个小细节:TM1650自带锁存功能,写完段码后数字会一直保持,不需要像直接驱动那样反复动态扫描。这个特性非常省心,MCU写完就可以去干别的事。

5. 实测排坑:从"完全不亮"到"稳定显示",我遇到的五个问题

5.1 器件地址不对,总线上的设备根本没回应

现象就是模块怎么都不亮,代码翻来覆去检查也没发现问题。用逻辑分析仪抓SDA和SCL波形,能看到主机在发地址字节,但第9个时钟SDA一直是高电平,说明从设备没有应答。

原因就是开头说的地址写法问题。我这边模块默认支持0x48这个8位写地址,但如果你买的模块ADR引脚接法不同,可能需要0x90。

排查思路很简单:在SoftI2C_SendByte返回1的时候,加一个调试变量灯报警,或者直接在初始化时多试几个地址。比如把TM1650_DEV_ADDR从0x48改成0x90重新编译烧录,看能不能亮。三次以内准能定位。

5.2 模块没上拉电阻,SDA线上全是毛刺

还有一次是在我自己搭的洞洞板电路上,SDA没有接上拉电阻。现象更诡异:有时能亮,有时不亮,LED亮度还不稳定,用示波器看SDA波形,上升沿特别缓慢,中间还有抖动。

这是因为开漏输出下,SDA高电平状态完全依赖外部上拉,没有上拉就相当于天线,电平飘忽不定。解决方法很简单,SDA到3.3V之间接一个10k电阻,问题立刻消失。如果模块上已经有上拉,就不要重复加了,两个上拉并联会降低阻抗,虽然不至于损坏芯片,但会让信号边沿变陡,反而可能引入反射。

5.3 初始化命令少发一个字节,数码管一直灭

网上有些例程初始化只写一句"发送0x48",就认为已经开启显示。但这其实分两种情况:有的模块固件把0x48当独立显示开启命令,发完就亮;有的模块需要往0x48寄存器写入具体数据,比如0x01,才真正打开显示。

如果你的模块属于后一种,初始化时发送地址后跟一个0x48就被停止了,TM1650会一直等待后续数据,自动进入一种"卡住"的状态。这也是我为什么在TM1650_Init里写完整的三段结构:地址、寄存器、数据。如果换用其他模块后初始化不亮,先看看模块资料里是哪种命令格式再来改代码。

5.4 数字显示反了或乱码

模块能亮了,但显示1的时候亮的是最右边而不是最左边,或者显示0变成了8里面缺两笔、多两笔。

先说方向问题:TM1650的4个段码寄存器从0x68到0x6B,对应哪一位数码管,完全取决于模块PCB布线。有的模块0x68是最左边一位,有的模块0x68是最右边一位。我一般先写"1234",看哪一位对应哪个寄存器地址,然后在代码里做一个简单的位序映射。比如:

static uint8_t pos_map[4] = {3, 2, 1, 0};

调换一下即可,非常方便。

再说乱码问题:段码表是按照标准共阴数码管排列的,如果模块的a段没有接到芯片的SEG1,而是接到了SEG2,那数字当然不对。这时需要查模块原理图,然后按实际段序重新排列段码表的bit位置。大部分市售模块都直接用标准映射,但DIY模块不做这个保证。

5.5 模拟I2C和硬件外设共用引脚的冲突

最后一个坑是引脚复用问题。PB6/PB7在F103上默认连接I2C1外设,但如果你之前在其他代码里用到了定时器通道重映射、USART重映射,或者开启了JTAG的部分引脚,GPIO初始化可能被干扰。

另外需要注意,PB3、PB4、PA13、PA14这类引脚默认被调试接口占用,用作普通IO前要先禁用JTAG或SWD。选引脚时尽量避开它们。模拟I2C本身没有硬件外设那样复杂的复用关系,但一旦某个引脚被打到其他外设上,排查起来反而更困难。所以我写代码时习惯先把所有相关的RCC时钟和外设配置列出来,确认没有重叠再烧录。

6. 在项目里继续扩展:时间显示、亮度调节和按键扫描

6.1 带冒号的时钟显示

4位数码管最常见的应用就是小时和分钟显示,中间两个点其实是独立的小数点位。比如显示"12:34",需要在分钟的高位上把小数点量起来。我的处理方式是把两个冒号接到某一位的DP段,然后固定给该位段码加上0x80。

TM1650_ShowDigit(0, hour / 10, 0); TM1650_ShowDigit(1, hour % 10, 1); /* 冒号闪烁或常亮 */ TM1650_ShowDigit(2, min / 10, 0); TM1650_ShowDigit(3, min % 10, 0);

如果想让冒号每秒闪烁,就在主循环里隔500ms重新写一次该位的段码,利用TM1650的锁存特性,完全不会出现闪烁不均的问题。

6.2 亮度调节

TM1650的显示控制寄存器低4位一般用来设置亮度。不同模块数据手册的描述略有一点差异,有些是0到7共8级,有些是1到8级。我实际测试下来,默认的0x01已经比较合适,如果放在卧室里嫌亮,可以把0x01改成0x00甚至关闭显示;如果放在室外,就把亮度等级调高。

调整亮度时只需要重新初始化一次显示控制寄存器,不需要重新写段码,当前显示内容会保留。这个特性很适合做待机自动降低亮度的功能。

6.3 利用TM1650的按键扫描能力

TM1650除了驱动数码管,还带键盘扫描功能。如果你买的模块有按键接口,就可以用同一个I2C总线去读按键值,完全不用额外占用IO。

读操作需要实现一个SoftI2C_ReadByte,过程就是释放SDA,然后在每一个SCL时钟的高电平阶段读一次电平,连续读8位,最后主机根据是否需要继续读,发送ACK或NACK。TM1650读键值一般是一次把8个键值全部读出来,每次读操作最后需要发一个非应答信号结束。

uint8_t SoftI2C_ReadByte(uint8_t ack) { uint8_t i, dat = 0; SDA_H(); /* 开漏输出下即释放总线 */ for (i = 0; i < 8; i++) { dat <<= 1; SCL_H(); SoftI2C_Delay(); if (SDA_READ()) dat |= 0x01; SCL_L(); SoftI2C_Delay(); } if (ack) SDA_L(); else SDA_H(); SCL_H(); SoftI2C_Delay(); SCL_L(); SoftI2C_Delay(); SDA_H(); return dat; }

这个功能对做菜单设置类项目很实用,一条总线上既挂显示又挂按键,布线简单,IO占用也只有两根,是我比较推荐的组合方式。

如果你用的是HAL库,其实不用重写太多东西,把我上面代码里的GPIO_SetBits、GPIO_ResetBits、GPIO_ReadInputDataBit换成HAL_GPIO_WritePin和HAL_GPIO_ReadPin就行,模拟I2C的逻辑完全不变。文章里这套代码我已经在F103C8T6上跑过,也帮朋友移植到F407上,逻辑都没改,只换了引脚定义。实际调的时候别急着一次点亮,先确认地址、再量引脚、最后看段码,一步步来,基本都能跑通。

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

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

立即咨询