
简介面向STM32嵌入式开发者与单片机初学者的TM1650数码管驱动资源适用于各类基于STM32的显示需求解决LED显示模块快速集成问题。代码包含完善的驱动层实现覆盖GPIO初始化、串行通信时序控制、命令与数据写入等核心环节可帮助开发者避开底层时序调试弯路。压缩包共2个文件分别为头文件与源文件头文件集中定义显示模式、亮度等级及命令字源文件提供TM1650_Init、WriteCommand、WriteData等接口整体体积仅2KB资源包体积小巧不依赖第三方库适合资源受限的嵌入式环境与快速原型开发结构精简可直接嵌入工程。目前已有4966人学习下载适合智能仪表、小家电等需要显示温度、时间等动态数值的场景。资源经测试验证调用封装函数即可刷新数码管显示同时为理解TM1650数据编码与通信协议提供清晰范例且代码风格清晰、注释明确便于二次开发与功能扩展具有较强的参考与复用价值。 上周帮朋友调一块TM1650驱动的4位数码管模块功能逻辑本来都写好了结果上电就是不亮。总线波形用示波器抓了SCL、SDA都有信号ACK也能收到数据看着也对但数码管就是没反应。折腾了半天才意识到TM1650这颗驱动芯片和很多I2C器件不太一样寄存器要先“开显示”才肯干活否则你写再多的段码数据它也只当没看见。这种“驱动芯片看似简单、实则细节不少”的体验估计不少人都经历过。这篇文章就围绕STM32和TM1650驱动程序展开把从选型、寄存器理解、代码实现到问题排查的完整链路梳理一遍。不管你是正在调智能台灯、宿舍控制面板还是毕业设计里需要一块数码管显示模块只要你用的是STM32这篇内容应该能帮你少走不少弯路。1. 为什么选TM1650这颗芯片而不是自己扫描数码管1.1 动态扫描方案的三个痛点很多新手拿到4位数码管第一反应是直接用单片机的GPIO去驱动。LED数码管本身就是一个LED阵列8字形的每一段都是一颗LED所以8段数码管加上小数点总共9个IO4位就是36个IO。就算不计成本用4位一体数码管段选和位选加起来也要12个引脚GPIO占用已经非常夸张。更麻烦的是动态扫描。为了省IO最简单粗暴的方案就是用74HC595或者其他移位寄存器把串行数据转成并行输出段选、位选分时刷新。但动态扫描有一个绕不开的问题人眼有视觉暂留想要看到稳定不闪烁的数字每一路必须在1ms到2ms内刷新一次4位扫一遍就是4ms到8ms。这意味着单片机必须定时进入中断把4位段码轮流送出去而且主程序不能长时间阻塞否则刷新间隔一旦超过20ms人眼就会看到明显的闪烁。我实际调过这种方案最崩溃的不是代码本身而是它打乱了整个程序的节奏。比如你同时在做按键消抖、I2C读取传感器、PID计算任何一个环节卡顿显示就会闪。尤其当你在调其他功能时还得不断提醒自己“这个函数不能超过1ms”。这种约束在项目后期会非常烦人。1.2 TM1650带来的改变与选型对比第一次接触TM1650的时候我最大的感受就是它把“显示刷新”这个脏活累活全部接管了。你只需要通过I2C接口把要显示的数字段码写进它的寄存器后面的位选扫描、电流驱动、动态刷新全部由芯片自己完成。CPU彻底从扫描循环里解放出来哪怕主循环里跑一个200ms的延时数码管也照样稳定显示。而且TM1650不只是LED驱动芯片它还带键盘扫描功能。一个I2C接口既驱动数码管又能读按键状态在智能台灯、温控面板这种需要“显示按键”的场景里非常合适一路I2C全搞定不用再额外接矩阵键盘扫描电路。下面是三种常见数码管驱动方案的对比我整理的时候顺便把项目里的实际体会也写进去了方案占用IOCPU工作量按键支持实际体验GPIO动态扫描12或加595后约3~4高需定时刷新实时性要求高无需另外扩展代码简单但后期调试很累74HC595扩展3~4高扫描逻辑只能自己写无适合入门学习不适合复杂项目TM1650 I2C驱动2SCL/SDA极低写完寄存器就不用管自带键扫开发效率高显示稳定2. 看懂TM1650的寄存器模型比抄代码重要2.1 从机地址与读写方向TM1650的I2C从机地址在芯片内部是固定的不能像24C02那样通过A0/A1/A2引脚去改。它的7位从机地址是0x24换算成8位写地址是0x48读地址是0x49。这一点看起来简单但确实是很多人第一个摔跤的地方。我用STM32标准库调试时第一次发0x48写地址ACK应答是正常的说明地址没写错。但读按键时如果用0x48去读就会一直卡在读不到数据的死循环里。后来翻手册才发现I2C读操作要发0x49。就这么一位之差折腾了我将近一下午。这里的一个实用经验是不管抄谁的代码先确认对方的“设备地址”写的是7位还是8位。很多厂商例程里写的是0x48这其实是8位写地址如果把它当成7位地址去处理左移一位后发出去就变成了0x90要么收不到ACK要么操作了错误的从机。STM32的I2C硬件外设有些库函数需要填7位地址有些需要填8位地址这是最容易出bug的地方。2.2 三个关键寄存器TM1650的寄存器不多玩明白三个就够用了。我把它们列成一张表驱动代码其实就是围绕这几个寄存器打转寄存器地址寄存器名称作用说明0x48写显示模式寄存器设置显示位数低2位004位、013位、102位、111位0x24写亮度/开关寄存器bit7是显示总开关1开显示bit2:0是亮度等级0~70x68~0x6B写段码数据寄存器依次对应数码管的第1到第4位存放要显示的段码0x48~0x4B读键扫数据寄存器读模式返回4个键扫数据字节解析对应按键状态这里重点说一下0x24寄存器。很多第一次用TM1650的人段码数据明明写进去了数码管却不亮问题就出在这个寄存器上。TM1650上电后显示开关默认是关闭的你必须先往0x24寄存器写入“开显示”的命令段码数据寄存器里的内容才会真正被点亮。这个设计其实是为了低功耗考虑但如果你不知道这一点很容易陷入“波形正常、数据正常、就是不亮”的怪圈。我帮朋友排查的那个台灯项目最后定位到的就是这个原因。2.3 写命令与读键扫的通信时序TM1650的I2C通信格式和普通EEPROM差异很大。写EEPROM通常是“设备地址内存地址数据”一次操作可以连续写一页数据但TM1650每次写操作都是“设备地址寄存器地址1字节数据”这样固定搭配想更新多位显示内容就得连续做多次写操作每次发送一个寄存器地址和一个数据字节。很多从其他I2C器件转过来的开发者会下意识地以为TM1650内部有“地址指针”发一次地址后可以连续写多个字节结果发出去之后只有第一个字节生效后面的数据全丢了。我调试的时候用逻辑分析仪一抓才发现芯片对寄存器地址的解析方式是“读到地址就锁定目标寄存器接下来只接受一个数据字节”。读按键的时序更特殊。它需要先发送写地址0x48然后发一个键扫寄存器地址0x48之后重新发起I2C起始信号也就是重复起始位再发送读地址0x49然后才能读取返回的键扫数据。这种“先写后读”的方式在数据手册里写得很简略我第一次实现时差点漏掉中间的重复起始信号。3. 软件模拟I2C驱动完整实现标准库版3.1 为什么我推荐给TM1650用软件模拟I2CSTM32的硬件I2C外设功能是完整的我用它驱动过很多传感器稳定可靠。但在TM1650这个场景里我更推荐软件模拟I2C因为有两个非常实际的原因。第一TM1650的从机地址是固定的0x48有时候模块厂家做PCB时会把这颗芯片和别的I2C器件挂在同一组总线上。如果你用硬件I2C就得受限于固定的SCL、SDA引脚板子布局不够灵活软件模拟I2C则可以把任意两个GPIO当作SCL和SDA想挂在哪就挂在哪板子上哪两个引脚方便就用哪两个。第二TM1650的时序虽然兼容标准I2C但它的数据手册建议波特率不需要太高。软件模拟I2C的时序完全由GPIO翻转和延时决定遇到某些兼容性不太好的模块时我可以把延时拉大一点让时序更“宽松”问题往往就好转了。硬件I2C虽然也能调速率但改起来要复位外设、重新配置寄存器调试效率明显低很多。另外软件模拟I2C还有一个隐藏的好处你可以随时用GPIO读取SDA电平打印中间状态排查问题的时候比硬件I2C黑盒模式直观太多。尤其对你这种需要跟TM1650打交道的项目我很推荐先软件模拟跑通后再考虑优化。3.2 引脚初始化与时序基础函数我习惯用PB6做SCL、PB7做SDA这个组合是STM32上比较常用的模拟I2C引脚对。如果你用的开发板这两个引脚被占用了换任意两个GPIO都行只要记得改动宏定义和GPIO配置。#define TM1650_SCL_H() GPIO_SetBits(GPIOB, GPIO_Pin_6) #define TM1650_SCL_L() GPIO_ResetBits(GPIOB, GPIO_Pin_6) #define TM1650_SDA_H() GPIO_SetBits(GPIOB, GPIO_Pin_7) #define TM1650_SDA_L() GPIO_ResetBits(GPIOB, GPIO_Pin_7) #define TM1650_SDA_READ() GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_7) static void I2C_Delay(void) { uint8_t i 8; while (i--); }GPIO初始化时SCL配置为推挽输出SDA需要根据读写的不同阶段切换输入输出方向。一个容易被忽略的细节是SDA在输出模式下建议用推挽输出而不是开漏输出。原因在于软件模拟I2C时我们完全掌握SDA的电平变化用推挽输出可以保证高电平的上升沿干净利落避免开漏模式下对上拉电阻的依赖。static void SDA_SetOutput(void) { GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOB, GPIO_InitStructure); } static void SDA_SetInput(void) { GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; GPIO_Init(GPIOB, GPIO_InitStructure); }起始信号和停止信号的实现和通用软件I2C一样我直接贴代码。需要注意的是延时函数要足够稳定不要在中断里调用这些函数否则时序会被打断。static void I2C_Start(void) { SDA_SetOutput(); TM1650_SDA_H(); TM1650_SCL_H(); I2C_Delay(); TM1650_SDA_L(); I2C_Delay(); TM1650_SCL_L(); I2C_Delay(); } static void I2C_Stop(void) { SDA_SetOutput(); TM1650_SDA_L(); TM1650_SCL_H(); I2C_Delay(); TM1650_SDA_H(); I2C_Delay(); }3.3 写寄存器与显示函数写寄存器的核心逻辑很简单起始后发送写地址0x48等待ACK然后发送寄存器地址等ACK再发送数据字节等ACK最后发停止信号。这里的ACK等待一定要加超时机制不可以无限等待。原因很简单如果SDA引脚被外部拉低或者模块未接入程序会永远卡死在等待ACK的死循环里。我见过不少人在调I2C设备时遇到“程序卡死”其实就是等待ACK时没有做超时处理。后面第四章调试部分我会专门展开讲这个问题先看完整代码uint8_t TM1650_WriteReg(uint8_t reg_addr, uint8_t data) { uint8_t ret 0; I2C_Start(); ret | I2C_WriteByte(0x48); // 写地址 ret | I2C_WriteByte(reg_addr); // 寄存器地址 ret | I2C_WriteByte(data); // 数据 I2C_Stop(); return ret; // 0表示成功非0表示某一步没收到ACK }配合8段数码管共阴段码表就能实现数字显示了。TM1650的段码顺序和常见共阴数码管一致bit0对应a段bit1对应b段依次到bit7对应dp段小数点所以常用的0~9段码可以照搬。const uint8_t seg_table[] { 0x3F, // 0 0x06, // 1 0x5B, // 2 0x4F, // 3 0x66, // 4 0x6D, // 5 0x7D, // 6 0x07, // 7 0x7F, // 8 0x6F, // 9 }; void TM1650_DisplayDecimal(uint16_t value, int8_t dot_pos) { uint8_t buf[4]; buf[0] value / 1000; // 千位 buf[1] value / 100 % 10; // 百位 buf[2] value / 10 % 10; // 十位 buf[3] value % 10; // 个位 for (int8_t i 0; i 4; i) { uint8_t seg seg_table[buf[i]]; if (i dot_pos) { seg | 0x80; // 点亮对应位的小数点 } TM1650_WriteReg(0x68 i, seg); } }这里有一个关于位序的提醒0x68对应数码管从哪边数起的第几位不同厂家的模块定义可能不同。买的模块如果接好之后发现数字反过来了不是芯片坏了而是0x68~0x6B和实际物理位序的对应关系需要调整最简单的验证方法是先只写0x68一个寄存器看是哪一位亮了。3.4 按键扫描与初始化流程TM1650的按键扫描读取比显示部分复杂一点。从机读地址是0x49你需要先发送键扫数据寄存器的起始地址通常从0x48开始然后发一个重复起始信号再切到读方向。返回的数据是4个字节分别对应芯片扫描到的按键矩阵状态。如果只是检测某个范围内的按键可以只读第一个字节省一点时间。下面是读取键扫第一字节的实现uint8_t TM1650_ReadKey(void) { uint8_t key_data 0xFF; I2C_Start(); if (I2C_WriteByte(0x48)) { I2C_Stop(); return 0xFF; } if (I2C_WriteByte(0x48)) { I2C_Stop(); return 0xFF; } I2C_Start(); // 重复起始 if (I2C_WriteByte(0x49)) { I2C_Stop(); return 0xFF; } key_data I2C_ReadByte(0); // 最后一个字节回NACK I2C_Stop(); return key_data; }返回的键扫字节中每个bit对应一个按键具体位对应关系需要结合模块原理图确认。比如我的模块上按键接在SEG1和GRID1之间那么它对应键扫数据字节的bit0按键接在SEG2和GRID1之间就对应bit1。模块PCB上通常印有K1、K2之类的丝印你做一个按键按下去读回来一个数据写死到代码里就行。初始化流程上电后建议这样做void TM1650_Init(void) { TM1650_WriteReg(0x48, 0x00); // 4位显示模式 TM1650_WriteReg(0x24, 0x81); // 开显示亮度等级1 }0x48寄存器写0x00是4位全显示。如果只需要3位可以改成0x01。0x24寄存器写0x81bit7是显示总开关低3位是亮度等级。这里我故意没有用很多例程里常见的0x88写法因为不同厂家模块对bit3的定义有细微差异0x81既能保证显示开启亮度又不会太高。亮度不够的话可以改成0x82到0x87逐级往上调反正不会烧芯片。4. 调试实录从“不亮”到“稳定显示”的排查过程4.1 现象一上电数码管全灭我帮朋友排查的台灯项目最开始的现象就是上电数码管全灭。我用示波器测SCL引脚有方波输出测SDA引脚也有信号变化用逻辑分析仪抓I2C数据确认了地址0x48、寄存器地址0x68、数据0x06数字1的段码都正确传到了总线上。从总线层面看完全没毛病。关键的一步是后来我不看数据改用电压表量TM1650的SEG和GRID引脚量到SEG引脚电压只有零点几伏GRID引脚也没有正常的扫描电压输出。这让我意识到芯片根本没进入显示状态顺着这个思路去查0x24寄存器才发现显示总开关没有打开。回过头来分析问题出在我沿用了之前驱动其他I2C芯片的经验认为“写入段码数据就一定能显示”。但TM1650的设计是段码数据寄存器和显示输出之间还有一个总开关上电默认关闭省电优先。这个坑在TM1650的例程里经常出现因为很多文档把0x24寄存器叫“亮度寄存器”让人以为它只控制亮度忘记了它还承担着显示开关的职责。4.2 现象二ST-Link突然连不上目标还有一个非常吓人的问题驱动代码写好后烧录了一次然后ST-Link就再也连不上芯片了软件报错大概类似“error: no stm32 target found”这样的提示。当时我第一反应是芯片锁死了但仔细回想发现不是问题出在GPIO复用冲突上。我在配置GPIO时把某几个引脚设置成了推挽输出而其中恰好有和SWD调试接口复用的引脚。STM32的PA13是SWDIOPA14是SWCLKPA15是JTDIPB3是JTDO/SWO。如果初始化代码里把这些引脚重映射为普通GPIO并使用调试器自然就没法和内核通信了。解决办法是把代码里和这些引脚相关的初始化部分全部注释掉重新烧录前先按住复位键点击烧录按钮软件进入连接阶段瞬间再松开复位让芯片在运行到GPIO配置之前被调试器接管。这个“按住复位再烧录”的技巧我建议所有玩STM32的人都提前知道尤其是你手头只有一块核心板的时候。4.3 现象三按键误触发与读取值错位显示正常后开始调按键。读回来的键扫数据和实际按下的按键对不上比如我按K1代码里判定的却是K3而且有时按一下会连续触发多次。按键值错位的原因很简单就是模块厂家在设计PCB时把K1、K2、K3这些丝印标号和芯片内部键扫寄存器的位没有严格对应。比如我的模块上K1实际接在SEG3和GRID2之间而不是按顺序接在SEG1上。这就要根据原理图逐个核对没有捷径。按键连续触发的问题则出在采样逻辑上。键扫数据寄存器每次读取反映的是瞬时状态你如果在主循环里不断读它按下一次键的过程中程序可能读到了多次“按下”状态。加一个简单的状态机或者延时消抖就能解决基本思路是检测到按键变化后延时10ms再去读一次确认状态没变才算有效。4.4 现象四显示乱码且偶发闪烁最后一个典型问题是乱码和闪烁。有一次我在一个项目里把TM1650的SCL引脚接到了STM32的某个定时器PWM输出引脚附近软件模拟I2C的延时函数在定时器中断频繁触发时不稳定导致数据位被拉长或缩短TM1650解析段码时就出现了乱码。这类问题的排查思路是先排除硬件干扰再看软件时序。我当时的做法是用外接3.3V电源单独给模块供电排除共地干扰然后查看GPIO配置确认SDA和SCL都没有被复用成其他外设引脚最后把软件模拟I2C的延时函数里的空循环改成关中断保护确保在传输数据期间不被中断打断问题就解决了。另外还有一个非常容易忽略的点TM1650模块的SDA、SCL两条信号线必须要有合适的上拉电阻。有些模块板上没配如果你的STM32内部上拉没有开启或者太弱波形上升沿就特别缓慢芯片会误判电平。我的经验是只要发现波形上升沿超过1us就先考虑外接一个4.7k的上拉电阻到VCC大多数兼容性问题都能缓解。5. 把驱动做得更顺手扩展、移植与低功耗设计5.1 小数点、位选与负数显示小数点显示的方法是段码或上0x80。比如要显示数字3再加上小数点段码就是0x4F | 0x80等于0xCF。这个操作在上面的显示函数里已经实现了但我想展开说的是如果只有某一位固定需要小数点比如电压显示“1.23V”这种固定格式你可以在写入该位段码时固定加上0x80不需要做成参数。负数显示稍微有点麻烦。TM1650不带符号位它只能控制每个数码管的8段你如果想显示负号就要把最左边一位的段码直接写成“负号”对应的段码也就是g段点亮其他全部熄灭段码是0x40。然后在显示负数时根据符号位确定要不要在最左边位写0x40还是很方便的。5.2 多片TM1650怎么接TM1650从机地址是固定的0x48没法通过硬件引脚去改地址所以同一组I2C总线上直接挂两片TM1650两片会同时响应数据互相干扰。如果想在一个项目里显示超过4位数字通常有三种做法。第一种是给两片TM1650分别供电用MOS管或三极管做电源切换显示哪一片就给哪一片上电这种方法功耗可以控制但电路稍复杂。第二种是找两个独立的GPIO端口分别做软件模拟I2C一片挂一组引脚这是最简单可靠的软件方案代价是额外占用2个GPIO。第三种是用带地址配置引脚的同类芯片替代比如TM1650的兄弟芯片TM1651之类的但这个要结合实际模块看不是所有芯片都支持改地址。我的建议是如果只是显示6到8位数字直接用两个GPIO口做两路软件I2C更省事代码复用性也好基本不需要改驱动逻辑。5.3 移植到HAL库和RTOS时的注意事项如果你用的是STM32CubeMX生成的HAL库工程移植驱动时只需要把GPIO操作部分和延时部分替换成HAL库对应API比如GPIO_SetBits改成HAL_GPIO_WritePin。其他I2C时序逻辑不用动毕竟软件模拟I2C的底层就是GPIO翻转和延时。在FreeRTOS环境下有一点必须注意TM1650的读写操作涉及连续的时序过程中间不能被任务调度打断。如果某个高优先级任务在TM1650数据传输过程中抢占CPUI2C波形就乱了。最简单的做法是在TM1650_WriteReg函数开头进入临界区等停止信号发送完再退出。如果项目里多个任务都会操作数码管还应该加一个互斥量保护避免两个任务同时发起显示操作导致数据互相覆盖。5.4 低功耗场景下的处理做电池供电的手持设备时TM1650本身空载功耗不大但数码管一亮起来电流就不小。4位数码管全部点亮每个段按2mA算一个数字最多亮7个段再算上十进制小数点整机显示电流轻松超过50mA。低功耗的思路一般是这样的进入待机前调用TM1650_WriteReg(0x24, 0x01)把显示总开关关掉数码管立刻熄灭唤醒后再重新写入开显示命令。另外要注意的是软件模拟I2C的GPIO在待机时不要保持高电平输出这会通过SDA、SCL引脚给芯片漏电。正确做法是待机前把GPIO全部配置为模拟输入或浮空输入这样整机电流才能压得下来。写在最后TM1650这颗芯片的驱动整体而言不复杂核心就是搞清楚三个寄存器、两段I2C时序和一个显示总开关。我接触过的项目里凡是卡住的地方基本都和寄存器理解不到位或者时序细节有关和代码量关系不大。按照我个人折腾了几个模块的经验给你一个建议新芯片到手先别急着写业务逻辑先做最小实验——初始化、点亮一位数码管、循环显示几个数字。这一套流程跑通了再往上叠加按键处理、多片显示、低功耗这些功能。这样就避免像我开始一样一口气写完所有功能然后面对一屏乱码不知道从哪里查起。本文还有配套的精品资源点击获取