
玩STM32的时候最烦的事情之一就是数码管占IO。一个四位八段数码管如果直接动态扫描段选加位选少说也要十个八个引脚PCB布线一紧张就想骂人。TM1650这颗芯片就是专门解决这个问题的两线串行一颗芯片最多控制四位八段数码管还兼顾键盘扫描显示亮度还能软件调。而“模拟I2C”这个词听起来像什么高深技术实际上就是不用STM32自带的I2C外设直接找两个普通IO口用程序把I2C的开始、停止、应答和字节传输一笔一笔画出来。只要把时序搞明白什么引脚都能驱动PB6、PB7这种被硬件I2C绑死的口再也不用专门给外设让位。这篇文章我会把TM1650的协议细节、完整模拟I2C代码和实际调试中踩过的坑全部摊开讲。1. 为什么放着硬件I2C不用偏要拿普通IO模拟1.1 先搞明白STM32硬件I2C那点“老毛病”STM32的硬件I2C外设本身是挺好用的但它在引脚分配上有个很现实的问题I2C1的SCL和SDA默认挂在PB6、PB7I2C2挂在PB10、PB11虽然大多芯片支持重映射可重映射之后往往又和别的外设打架。更麻烦的是很多封装比较小的STM32芯片这些引脚可能没引出来或者已经被ADC、PWM、串口占用了。你总不能让整个PCB布局反过来迁就一个数码管芯片。另外STM32F1时代的硬件I2C在工程师圈子里口碑一直有点微妙。早年的标准外设库版本在一些环境下的确会出现BUSY位卡死、异常中断之类的问题导致不少人只要一提到“硬件I2C”就头皮发麻。虽然现在HAL库已经改进很多但“用模拟I2C更可控”这个思路一直在嵌入式项目里流传。对于TM1650这种两线驱动芯片模拟I2C完全够用还免去了配置复用功能、处理中断标志的一堆麻烦。1.2 TM1650到底是什么为什么选它TM1650是一款LED驱动控制专用芯片最常见的应用就是驱动四位八段数码管。市面上大量带冒号、带小数点、甚至带按键的四位数码管模块很多用的就是它。它内部集成了显示驱动和键盘扫描电路和主控之间只需要两根线一根时钟、一根数据。选它的理由很直白第一两线通信省引脚第二内部恒流源数码管不需要外接限流电阻亮度等级还能用软件调第三支持3.3V和5V供电和STM32配合很顺第四驱动共阴数码管非常常见接线简单模块种类多、便宜。比起用74HC595走串转并或者用一堆IO做动态扫描TM1650能把软件和硬件都从显示这件事里解放出来。1.3 模拟I2C的适用边界与取舍模拟I2C不是说所有场景都优于硬件I2C它有自己的取舍。优点很明显引脚任意、逻辑透明、不依赖芯片外设的坑想在哪根GPIO上跑就在哪根GPIO上跑调试的时候逻辑分析仪一看就知道对不对。缺点也客观存在CPU要全程参与每个bit的翻转无法像硬件I2C那样用中断或DMA来卸载CPU速率上限也会受限。但在显示场景里这些缺点根本不算事。TM1650工作的数据传输速率本来就不高模拟出来的I2C时钟一般几十到一百kHz刷新几位数码管绰绰有余。而且数码管的刷新率本质上是很慢的人眼看起来连续稳定就行你甚至不需要每毫秒都去刷它。所以用普通IO模拟I2C驱动TM1650在“省事”和“够用”之间正好卡在最舒服的位置。2. TM1650的核心细节命令、段码、寄存器理清再动手2.1 TM1650的通信协议与命令格式TM1650的接口和标准I2C非常像但不是100%完全等价很多资料管它叫“两线串行接口”。它的从机地址通常是0x247位地址换算成8位写地址就是0x48。对你没看错0x24左移一位就是0x48所以很多代码里写的是0x48。这个地址后面跟着命令或数据时看起来会有点绕因为控制命令里也经常出现0x48。完整的一次写入过程是这样的主机发送启动条件STARTSCL为高时SDA从高变低。主机发送设备写地址0x48等待从机ACK。主机发送命令字节或显示地址等待ACK。主机发送数据字节等待ACK。主机发送停止条件STOPSCL为高时SDA从低变高。我平时用的四位数码管模块初始化时发送0x48作为“打开显示并设置亮度”的命令。0x48这个命令字节拆开来看低两位控制亮度等级对应四档亮度再往上一位是显示开关。也就是说0x48到0x4B分别代表“开显示 亮度0、亮度1、亮度2、亮度3”这个范围正好符合TM1650的四级亮度调节。如果只想要固定一种亮度初始化时发0x48就够了。这里特别提醒不同厂家用TM1650做的模块命令细节可能会有差异尤其是“显示地址”的编码方式。有的模块直接用0、1、2、3表示四个数码管有的模块习惯用0x68、0x6A、0x6C、0x6E这种地址。所以拿到模块第一件事是看数据手册或者用逻辑分析仪抓一下商家例程的波形确认地址含义。后面我提供的代码会做一个可配置的偏移地址宏就是为了应对这种情况。2.2 段码表与数码管位序TM1650内部没有把数字“3”自动翻译成段码的能力它需要你直接告诉它每一段亮不亮。共阴数码管中段码的bit0对应a段bit1对应b段依次往下到bit7对应小数点。所以数字0到9以及常用字符的段码是下面这组显示字符共阴段码说明00x3Fa b c d e f亮10x06b c亮20x5Ba b d e g30x4Fa b c d g40x66b c f g50x6Da c d f g60x7Da c d e f g70x07a b c80x7F全部亮90x6Fa b c d f gA0x77a b c e f gF0x71a e f g-0x40g段亮熄灭0x00全灭如果要在某个数字右边加小数点直接把段码的bit7置1也就是段码或上0x80。比如显示“3.”就是0x4F | 0x80 0xCF。位序问题一定要实际验证。TM1650模块上的四个数码管哪个算第0位、哪个算第3位不同模块定义不一样。我之前买过两个不同商家的模块一个从左到右是0到3另一个从左到右是3到0代码不调整就只能显示镜像。所以调试的时候先让四个位分别显示1、2、3、4看实际顺序再决定要不要做位序映射。2.3 亮度控制和显示开关亮度调节这块其实很简单。低两位从00到11对应四档亮度命令字节从0x48到0x4B数值越大亮度越高。想要让显示关闭有的模块支持0x40作为关显示命令但具体定义要看数据手册。我使用的模块因为一直需要显示通常只做初始化时打开显示后面就不去关它了。在写驱动函数时我会把亮度等级作为参数传进去用一句cmd 0x48 | (level 0x03)拼出来这样调用方想调亮度就只改参数就行。至于从机地址我建议用一个宏固定下来写代码时不要到处写魔鬼数字不然以后换模块型号时改起来会崩溃。3. 手把手实现模拟I2C驱动TM1650完整代码3.1 硬件连接与CubeMX配置接线非常简单只需要四根线TM1650模块引脚接到STM32引脚说明VCC3.3V或5V注意模块电平要求GNDGND共地SCLPA0任意GPIO时钟线SDAPA1任意GPIO数据线这里我特意没有选PB6、PB7就是为了演示“普通IO也能干这活”。PA0和PA1在F103上很常见如果你这两个引脚被TIM2的PWM占用换成别的空闲GPIO完全没问题。模拟I2C的精髓就是SCL和SDA接到哪两个脚代码里的宏就改成哪两个脚。STM32CubeMX里的配置要注意一个细节SCL引脚我建议配置为推挽输出SDA引脚配置为开漏输出。为什么SDA要用开漏因为I2C应答的时候从机要把SDA拉低来通知主机如果SDA始终是推挽输出主机和从机就会打架。开漏输出配合外部上拉电阻让主机既能输出低电平又能通过释放引脚来读取SDA上的电平。具体的CubeMX配置步骤创建工程选择芯片型号配好时钟树F103可以配到72MHz。在Pinout界面把PA0配置为GPIO_OutputPA1也配置为GPIO_Output。点开PA0的GPIO设置Mode选Output Push PullSpeed选HighPull-up打开。点开PA1的GPIO设置Mode选Output Open DrainSpeed选HighPull-up打开。生成工程代码在User Code区域添加下面的驱动代码。如果你的模块板上已经有上拉电阻那内部上拉加不加都无所谓如果模块是裸芯片外部SDA上拉电阻最好加上2.2k到4.7k都可以具体看走线长度。我常用的做法是模块板上没有上拉就在STM32配置内部上拉实测短距离也能工作但长线或复杂电磁环境还是外部上拉更稳。3.2 底层模拟I2C函数起始、停止、发字节、读ACK底层函数是整个模拟I2C的地基我把它们独立出来方便以后驱动其他I2C器件时复用。核心思路是SCL负责“节奏”SDA负责“数据”所有状态变化都发生在SCL为低的时候SCL为高时SDA必须保持稳定。#include main.h // 引脚宏定义按实际接线修改 #define TM1650_SCL_PORT GPIOA #define TM1650_SCL_PIN GPIO_PIN_0 #define TM1650_SDA_PORT GPIOA #define TM1650_SDA_PIN GPIO_PIN_1 #define SCL_LOW() HAL_GPIO_WritePin(TM1650_SCL_PORT, TM1650_SCL_PIN, GPIO_PIN_RESET) #define SCL_HIGH() HAL_GPIO_WritePin(TM1650_SCL_PORT, TM1650_SCL_PIN, GPIO_PIN_SET) #define SDA_LOW() HAL_GPIO_WritePin(TM1650_SDA_PORT, TM1650_SDA_PIN, GPIO_PIN_RESET) #define SDA_HIGH() HAL_GPIO_WritePin(TM1650_SDA_PORT, TM1650_SDA_PIN, GPIO_PIN_SET) #define SDA_READ() HAL_GPIO_ReadPin(TM1650_SDA_PORT, TM1650_SDA_PIN) // 延时函数72MHz主频下大约几百纳秒到一微秒 static void I2C_Delay(void) { volatile uint32_t i 20; while (i--) { __NOP(); } } // 启动条件SCL高电平期间SDA从高变低 static void I2C_Start(void) { SCL_HIGH(); SDA_HIGH(); I2C_Delay(); SDA_LOW(); I2C_Delay(); SCL_LOW(); I2C_Delay(); } // 停止条件SCL高电平期间SDA从低变高 static void I2C_Stop(void) { SDA_LOW(); SCL_HIGH(); I2C_Delay(); SDA_HIGH(); I2C_Delay(); } // 发送一个字节高位在前 static void I2C_SendByte(uint8_t dat) { for (uint8_t mask 0x80; mask ! 0; mask 1) { if (dat mask) { SDA_HIGH(); } else { SDA_LOW(); } I2C_Delay(); SCL_HIGH(); I2C_Delay(); SCL_LOW(); I2C_Delay(); } } // 等待从机应答返回1表示收到ACK static uint8_t I2C_WaitAck(void) { uint8_t ack 0; SDA_HIGH(); // 释放SDA从机可以拉低表示应答 I2C_Delay(); SCL_HIGH(); // 第9个时钟脉冲从机在此时拉低SDA I2C_Delay(); ack (SDA_READ() GPIO_PIN_RESET) ? 1 : 0; SCL_LOW(); I2C_Delay(); return ack; }这段代码里最重要的是I2C_WaitAck。若SDA用的是推挽输出读ACK前必须把SDA临时切成输入模式我让SDA直接配成开漏输出通过写1来释放引脚再由上拉电阻把SDA拉高。这样从机应答拉低SDA时主机读到的就是真正的引脚电平不用反复切换GPIO方向代码简洁逻辑也清晰。3.3 上层TM1650驱动与显示函数有了底层模拟I2CTM1650的操作就是按协议串起来。重点在于写地址是0x48打开显示和亮度控制的命令也是0x48开头这两个宏虽然数值一样语义完全不同。#define TM1650_WR_ADDR 0x48 // 7位从机地址0x24左移一位 #define TM1650_CMD_ON 0x48 // 显示开 亮度等级0 #define TM1650_DIGIT0_ADDR 0x00 // 第0位显示地址按模块手册调整 // 初始化TM1650打开显示设置初始亮度 void TM1650_Init(uint8_t brightness) { I2C_Start(); I2C_SendByte(TM1650_WR_ADDR); I2C_WaitAck(); I2C_SendByte(TM1650_CMD_ON | (brightness 0x03)); I2C_WaitAck(); I2C_Stop(); } // 设置亮度level范围0~3 void TM1650_SetBrightness(uint8_t level) { I2C_Start(); I2C_SendByte(TM1650_WR_ADDR); I2C_WaitAck(); I2C_SendByte(TM1650_CMD_ON | (level 0x03)); I2C_WaitAck(); I2C_Stop(); } // 向指定位置写一个段码 void TM1650_WriteSeg(uint8_t pos, uint8_t seg) { if (pos 3) { return; } I2C_Start(); I2C_SendByte(TM1650_WR_ADDR); I2C_WaitAck(); I2C_SendByte(TM1650_DIGIT0_ADDR pos); I2C_WaitAck(); I2C_SendByte(seg); I2C_WaitAck(); I2C_Stop(); } // 同时显示多位段码len最大4 void TM1650_ShowSegments(const uint8_t *seg, uint8_t len) { for (uint8_t i 0; i len; i) { TM1650_WriteSeg(i, seg[i]); } }我没有去做“一次启动连续写多字节”的优化而是采用每一位单独发起一次Start和Stop原因很简单不同模块对连续写支持的态度不一样逐位写虽然看起来笨一点但兼容性最好。显示刷新又不需要高频率这点速度损失完全无所谓。要是你确认自己的模块支持自动地址递增再改成burst写也不迟。3.4 主函数演示让数码管跑起来段码表放在这里顺便加一个简单的四位数显示函数。注意主函数里不要用HAL_Delay做过长时间的延时去刷数码管因为模拟I2C一旦被打断太久虽然不会导致数据错误但显示会明显闪烁。// 共阴数码管段码表0~9AF负号熄灭 const uint8_t segTable[] { 0x3F, 0x06, 0x5B, 0x4F, 0x66, 0x6D, 0x7D, 0x07, 0x7F, 0x6F, 0x77, 0x71, 0x40, 0x00 }; // 把四位数码管全部熄灭 void TM1650_Clear(void) { uint8_t empty[4] {0x00, 0x00, 0x00, 0x00}; TM1650_ShowSegments(empty, 4); } // 显示一个不大于9999的十进制数 void TM1650_ShowNumber(uint16_t num) { uint8_t buf[4]; buf[0] segTable[(num / 1000) % 10]; buf[1] segTable[(num / 100) % 10]; buf[2] segTable[(num / 10) % 10]; buf[3] segTable[num % 10]; TM1650_ShowSegments(buf, 4); } int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); TM1650_Init(2); // 打开显示亮度等级2 TM1650_SetBrightness(3); // 如果觉得暗直接调到最高亮度 TM1650_ShowNumber(1234); // 先显示一个固定数字测试 uint16_t counter 0; while (1) { TM1650_ShowNumber(counter % 10000); counter; HAL_Delay(200); // 每200ms跳一个数 } }想要显示带小数点的数字比如温度“23.5度”只需要在对应位的段码上或0x80uint8_t buf[4]; buf[0] segTable[2]; // 2 buf[1] segTable[3] | 0x80; // 3. buf[2] segTable[5]; // 5 buf[3] 0x00; // 最后一位不显示 TM1650_ShowSegments(buf, 4);这套代码采用HAL库如果你用的是标准外设库只需要把几个宏和GPIO操作替换成自己的寄存器操作或库函数即可I2C时序逻辑完全不用改。4. 实测遇到的坑与排查方法4.1 不亮、乱码、闪烁的常见原因我最早调这块模块的时候遇到过好几个非常典型的问题列出来给正要踩坑的朋友。第一个坑完全不亮。当时我一度以为模块坏了后来发现是共地问题。TM1650模块的GND和STM32的GND没有连到一起SDA和SCL虽然有电平但回路不通自然不会亮。另外模块的VCC没接好或者接反也很容易导致不亮上电前用万用表确认一下VCC和GND最稳妥。第二个坑乱码。常见原因有三个。一是共阴共阳搞反了共阴数码管用我上面的段码表如果模块是共阳段码要按位取反二是一开始的初始化命令没发对模块没有正确进入显示模式三是位序映射不对第0位模块上显示在最右边导致数字顺序全乱。第三个坑显示正常但亮度特别暗。TM1650有四级亮度默认命令“0x48”是最低亮度看起来会比较弱。直接改成TM1650_Init(3)或者调用TM1650_SetBrightness(3)亮度就上来了。第四个坑跟着主循环的HAL_Delay一起闪。数码管本身是静态驱动数据写一次就会保持但如果我在主循环里每5ms就去刷一遍显示同时又穿插了比较长的延时显示就会有可感知的闪烁。解决办法是只在数值变化时刷新不变化就别刷或者把刷新频率控制在30Hz以上。4.2 用逻辑分析仪快速定位问题模拟I2C最方便的就是调试逻辑分析仪一挂所有问题都藏不住。把逻辑分析仪的CH0接SCLCH1接SDA地线接GND抓一段信号就知道主机到底发出了什么。重点看三个地方启动和停止条件是否完整。SCL为高时SDA产生一个下降沿是启动产生一个上升沿是停止。如果只是SDA在那抖SCL没配合时序就不对。第一个字节是不是0x48。设备地址发错的话后面所有命令都不会有响应。第9个时钟脉冲上SDA有没有被拉低。正常情况下主机发完每个字节后都会在第9个时钟检测到从机的ACK低电平如果SDA一直是高说明从机根本没理你。我之前排查过一次不显示问题逻辑分析仪显示启动条件、地址、命令全都正常就是第9个时钟后SDA一直是高后来发现是模块的SDA引脚虚焊补焊之后一下就好了。所以示波器或逻辑分析仪真的是嵌入式调试的“眼睛”强烈建议备一个便宜的。4.3 几个提高稳定性的经验模拟I2C看着简单要让它稳定跑很久还是有点讲究的。先说上拉电阻SDA开漏模式下必须有上拉不然引脚悬空信号容易受干扰。模块板上假如没有STM32内部上拉也能保证基本工作但如果线长超过10cm还是外接2.2k到4.7k的上拉更保险。再说传输线长度。I2C本来是为板内通信设计的线越长寄生电容越大信号边沿越缓。如果非要拉一根20cm的线去接模块SCL频率要相应降低也就是把I2C_Delay里的空循环数加大否则就可能出现偶发乱码。我曾经在一个项目里把TM1650放在另一个板子上中间用排线连接刚开始跑着跑着就花屏后来把延时加大一倍问题就消失了。还有一个容易被忽略的点GND线要足够粗。数码管点亮的时候电流虽然不大但瞬间电流变化可能影响信号地电位尤其和电机、继电器这类大电流器件共板时GND反弹会造成随机乱码。这种情况下尽量让显示模块单独走地线或者至少用比较宽的敷铜。最后说一下多块TM1650模块怎么接。TM1650很多模块没有可配置的地址引脚不太方便直接挂在同一条I2C总线上区分。真要是想同时驱动两组四位显示我一般老实点每组模块用独立的两个GPIO分别模拟I2C。反正STM32引脚多模拟I2C写起来也快比硬要把两个相同地址的芯片挂在一起省心得多。这些经验都是拿实际模块一点点试出来的。我开始的时候也迷信过“直接用硬件I2C就行”后来发现很多项目真正缺的并不是那点外设资源而是一个能随插随用的显示方案。TM1650加普通IO模拟I2C这套组合不管是做温湿度计、电子时钟还是给毕业设计的智能台灯加一个显示面板都足够稳也足够灵活。调试显示问题时记得备一台逻辑分析仪先抓波形再怀疑代码这样排查起来会高效很多。