ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32用TM1650驱动数码管:模拟I2C实现省IO显示方案

STM32用TM1650驱动数码管:模拟I2C实现省IO显示方案 前阵子给一个小温控装置加显示4位数码管摆在那很合适但我看了一眼板子上还剩下的IO口心里凉了半截。直接驱动4位共阴数码管段码要8个IO位选要4个IO加起来12个。对于一个普通STM32项目来说这基本把资源吃得差不多了。后来换了TM1650数码管模块两根线搞定显示刷新、亮度控制全部交给芯片代码里只需要关注显示什么数字完全不用管动态扫描。而且我用的是普通IO口模拟I2C没有动用芯片自带的硬件I2C外设省心又灵活。这篇文章就分享一下我自己的完整做法和踩坑记录代码用STM32标准库写的你改一下引脚定义就能直接用。如果你也遇到GPIO不够用、模块驱动不亮、或者纠结模拟I2C时序的问题这篇应该能帮上忙。1. 先算一笔账普通IO直接驱数码管 vs TM16501.1 直接驱动数码管IO成本一眼算清很多新手第一次接触数码管第一反应就是段码接IO位选接IO直接驱动。这个思路本身没问题但成本必须算清楚。一个4位共阴数码管若采用静态驱动8个段a、b、c、d、e、f、g、dp各占1个IO4个位选占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 什么时候不该选TM1650TM1650不是万能的。如果项目只需要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对应的段码是0x3Fa、b、c、d、e、f六段点亮数字1对应0x06b、c两段点亮。如果你手里的模块是共阳数码管段码就需要全部反相甚至可能因为电平不匹配根本点不亮。所以买模块的时候一定确认清楚目标就是TM1650共阴4位数码管模块绝大多数市面上的4位模块都是按照共阴段码设计的。段码表里最高位0x80通常对应小数点。也就是说想显示3.就在数字3的段码0x4F基础上加上0x80得到0xCF。这个在实际时钟、温度显示里非常常用。4. 完整驱动代码标准库版烧进去就能显示4.1 引脚定义与GPIO初始化我这里用PB6做SCLPB7做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驱动封装初始化、显示数字、清屏有了底层I2CTM1650的驱动就水到渠成了。#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上逻辑都没改只换了引脚定义。实际调的时候别急着一次点亮先确认地址、再量引脚、最后看段码一步步来基本都能跑通。
返回列表