基于STM8S的TM1650数码管驱动:I2C实现与工程实战指南

发布时间:2026/10/6 11:21:38
基于STM8S的TM1650数码管驱动:I2C实现与工程实战指南
1. 为什么选择TM1650数码管驱动方案的现实考量做单片机项目只要涉及到显示数码管永远是绕不开的选项。便宜、直观、阳光下可读性强这几个优点让它在家电、仪表、工控面板上依然是绝对主力。但驱动数码管这件事本身有个很尴尬的矛盾段数越多占用的IO口越多。一个标准的4位数码管不带驱动芯片的话段码需要7到8个引脚位选需要4个引脚加起来就是12个左右的GPIO。对STM8S003这种20脚封装的芯片来说12个IO几乎是灭顶之灾——你还要留引脚给按键、传感器、通讯接口根本分不过来。常见的解法有几个用74HC595串转并两个IO就能搞定但595只管数据扩展位选还得自己用IO扫而且动态扫描的刷新时序要在主循环里不停跑很耗CPU。用MAX7219也可以SPI接口芯片自带扫描逻辑但MAX7219是5V供电为主价格也偏贵还要外加限流电阻。TM1650走的是另一条路线它是I2C接口的4位LED驱动芯片两线制通讯内置显示RAM、亮度调节、按键扫描功能最关键的是它集成了恒流源段码输出不需要外接限流电阻。这就意味着MCU端只占用两个IO脚PCB上省掉8个电阻位刷新扫描完全由芯片自己完成主控只需要在数据变化时发一次数据。我最早还担心这类国产驱动芯片的I2C时序会不会很挑实际用下来发现TM1650的兼容性相当好标准I2C协议100kHz的速率完全够用。这个芯片在淘宝上零售也就几毛钱一片大批量拿货还能更低做产品的话成本优势非常明显。另外一个很多人忽略的点TM1650自带按键扫描功能。它支持最多7个按键的矩阵扫描中断引脚可以拉低通知MCU有按键事件。如果你做的是控制面板类产品一颗芯片同时解决显示和按键输入两个需求这可是实打实的BOM成本缩减。2. 芯片内部机制拆解显示RAM、命令字和时序细节2.1 内部结构一个从I2C角度看很标准的从设备TM1650的内部结构可以分成三块I2C通讯接口、显示控制逻辑、按键扫描逻辑。I2C接口负责接收主控发送的命令和数据遵循标准I2C协议7位从机地址是0x488位地址模式下是0x90写地址/0x91读地址。显示控制逻辑里有4个字节的显示RAM分别对应4位数字。写入的数据不是直接驱动段码的IO电平而是先翻译成内部显示缓存由芯片内部的扫描电路自动轮询点亮每一位。这个设计的好处在于主控写完数据就解放了后面的事全部交给TM1650。动态扫描的刷新率由芯片内部振荡器决定对MCU的实时性要求几乎降为零。2.2 显示寄存器地址和数据结构TM1650的显示寄存器地址从0x68开始8位模式下是0x68 | 0x01作为下一次写操作地址连续4个地址对应GRID1到GRID4也就是从高位到低位的4位数码管。每个显示寄存器的数据格式是[DP] [G] [F] [E] [D] [C] [B] [A]低位在前。这里要特别注意TM1650的段码顺序跟常见的共阴数码管段码表不完全一致它把G段放在了第6位DP在最高位。直接照搬网上通用的七段码表会出现显示错乱这是新手最容易踩的坑。通用段码对应关系段ABCDEFGDP位序bit0bit1bit2bit3bit4bit5bit6bit7数字0到9的段码值00x3F10x0620x5B30x4F40x6650x6D60x7D70x0780x7F90x6F这个段码表和74HC595驱动共阴数码管用的表是一样的因为段位顺序恰好一致。但如果你之前用过TM1637这类芯片就要注意了TM1637的段序是反的直接用会全部乱掉。2.3 命令字显示模式、亮度控制和关断TM1650还有一个命令寄存器地址看作是0x48命令地址。命令字的结构是位功能bit7 - bit6固定为00bit5 - bit4亮度等级00到11共4级bit3显示开关1开0关bit2 - bit1模式选择固定为01表示4位显示模式bit0固定为0实际的命令字组合关显示0x00开显示最低亮度0x01开显示最高亮度0x07这里有个很有意思的细节TM1650的亮度调节不是靠调节恒流源的电流大小而是通过调整占空比实现的。所以低亮度下如果仔细观察能看到轻微的闪烁感这在拍视频或者用相机拍摄时尤其明显。如果你的产品需要被摄像头拍照采集建议直接用最高亮度档位。2.4 典型时序完整的写数据流程写一个字节到显示寄存器的时序如下主控发START信号发送从机地址0x48写模式等待ACK发送目标寄存器地址0x68 位序号等待ACK发送要写入的段码数据等待ACK发STOP信号注意TM1650接收到数据后如果ACK正常它会自动把地址指针加1指向下一个显示寄存器。所以连写4个字节时可以只在开头发一次寄存器地址后面连续写数据就能自动填充到4个显示位不用每写一位都重新发地址。3. STM8S端驱动实现从底层GPIO模拟到模块化封装3.1 硬件连接方案我用的主控是STM8S003F3P620脚封装I2C的两个引脚是PB4SCL和PB5SDA。TM1650那边的VCC接3.3V或者5V都可以STM8S003是5V耐压的电平上不存在兼容问题。连接要点SCL接PB4SDA接PB5VCC接3.3V或5VGND共地SCL和SDA各接一个4.7kΩ上拉电阻到VCCTM1650内部虽然也有上拉但实际测试下来外部上拉还是必要的尤其是I2C线缆稍长或者环境干扰比较大的场景。不加上拉电阻的话通信距离超过10厘米就可能出现数据错误表现是数码管偶尔闪一下或者某一位数字不对。如果PB4和PB5被占用可以考虑用软件I2C换到任意两个GPIO上这个后面会讲。3.2 软件I2C实现不依赖硬件外设的通用方案我推荐的方案是先用软件I2C把功能跑通再考虑要不要切换到硬件I2C。软件I2C的好处是引脚任意、时序可控、调试直观而且代码量并不大。// 引脚定义 #define TM1650_SCL_PIN PB4 #define TM1650_SDA_PIN PB5 // 宏定义方便切换高低电平 #define SCL_1() GPIO_WriteHigh(GPIOB, GPIO_PIN_4) #define SCL_0() GPIO_WriteLow(GPIOB, GPIO_PIN_4) #define SDA_1() GPIO_WriteHigh(GPIOB, GPIO_PIN_5) #define SDA_0() GPIO_WriteLow(GPIOB, GPIO_PIN_5) #define SDA_READ() GPIO_ReadInputPin(GPIOB, GPIO_PIN_5)核心的I2C比特级操作void TM1650_Delay(void) { // 约5us延时对应100kHz的I2C时钟 // 在16MHz系统时钟下空循环几个周期即可 } void TM1650_Start(void) { SDA_1(); SCL_1(); TM1650_Delay(); SDA_0(); TM1650_Delay(); SCL_0(); TM1650_Delay(); } void TM1650_Stop(void) { SDA_0(); SCL_1(); TM1650_Delay(); SDA_1(); TM1650_Delay(); } void TM1650_WriteByte(unsigned char dat) { unsigned char i; for(i 0; i 8; i) { if(dat 0x01) SDA_1(); else SDA_0(); TM1650_Delay(); SCL_1(); TM1650_Delay(); SCL_0(); TM1650_Delay(); dat 1; } }这里有个关键点TM1650的数据是低位先传输的。如果你的主控数据位顺序搞反了写进去的段码会对不上。上面的代码里用了dat 1从最低位开始发送对应的是正确的段码顺序。3.3 完整驱动层代码// 写数据到显示RAM带寄存器地址 void TM1650_WriteReg(unsigned char addr, unsigned char dat) { TM1650_Start(); TM1650_WriteByte(0x48); // 从机地址写模式 TM1650_WaitAck(); TM1650_WriteByte(addr); TM1650_WaitAck(); TM1650_WriteByte(dat); TM1650_WaitAck(); TM1650_Stop(); } // 一次写入4个显示数据利用地址自动加1特性 void TM1650_WriteAll(unsigned char *dat) { unsigned char i; TM1650_Start(); TM1650_WriteByte(0x48); TM1650_WaitAck(); TM1650_WriteByte(0x68); // 第一个显示寄存器地址 TM1650_WaitAck(); for(i 0; i 4; i) { TM1650_WriteByte(dat[i]); TM1650_WaitAck(); } TM1650_Stop(); } // 设置显示模式 void TM1650_SetBrightness(unsigned char level) { unsigned char cmd 0x01; // 默认开显示 if(level 3) level 3; cmd | (level 4); // 亮度位 TM1650_Start(); TM1650_WriteByte(0x48); TM1650_WaitAck(); TM1650_WriteByte(cmd); TM1650_WaitAck(); TM1650_Stop(); }WaitAck的实现unsigned char TM1650_WaitAck(void) { unsigned char timeout 0xFF; SDA_1(); // 释放SDA让从机控制 TM1650_Delay(); SCL_1(); TM1650_Delay(); while(SDA_READ()) { if(--timeout 0) { SCL_0(); return 1; // 超时无ACK } } SCL_0(); return 0; }这个函数有个实际工程价值如果TM1650没焊好或者地址不对WaitAck会返回超时你可以在主循环里做错误处理而不是傻等。我用这个方法排查过一次焊接不良的问题效率比拿着示波器到处点高多了。3.4 显示数据处理从整数到段码的转换驱动有了接下来是显示层。实际产品里显示的数字不一定是单个字节而是需要把一个多位整数拆分到每一位上再查表得到段码。// 0-9段码表下标对应数值 const unsigned char tm1650_segcode[] { 0x3F, 0x06, 0x5B, 0x4F, 0x66, 0x6D, 0x7D, 0x07, 0x7F, 0x6F }; // 显示一个4位整数支持前导零消隐 void TM1650_ShowNumber(unsigned int num) { unsigned char buf[4]; buf[0] tm1650_segcode[(num / 1000) % 10]; buf[1] tm1650_segcode[(num / 100) % 10]; buf[2] tm1650_segcode[(num / 10) % 10]; buf[3] tm1650_segcode[num % 10]; TM1650_WriteAll(buf); }如果要做前导零消隐需要在赋值之前判断高位是否为0如果为0就填入0x00而不是0x3F。否则显示结果会出现0123这样的诡异效果用户看着就很业余。小数点控制是独立于数字段码的额外操作要在原有段码基础上或上0x80例如显示3.buf[3] tm1650_segcode[3] | 0x80;4. 字符映射、动态刷新和按键扫描的进阶玩法4.1 显示字母和自定义符号数码管不止能显示数字常用的十六进制字符就透露出数码管其实还可以显示不少字母A、b、C、d、E、F这些就不说了H、J、L、n、o、P、r、t、U、y其实也可以做到。字符段码A0x77b0x7CC0x39d0x5EE0x79F0x71H0x76J0x0EL0x38n0x54o0x5CP0x73r0x50t0x78U0x3Ey0x6E这个能力在显示状态信息时特别有用。比如做充电管理设备可以显示bAtt表示电池状态PASS表示认证通过Err表示异常。比单纯显示数字直观很多。4.2 利用扫描特性做无闪烁的动态显示TM1650本身自带扫描功能但在实际应用中如果你需要显示的内容超过4位比如6位数字可以用多片TM1650级联或者用动态切换的方式。多片级联的接线方式每片TM1650的I2C地址可以通过外接引脚配置默认地址是0x48部分型号可以通过地址脚改成0x4A。把两片接到同一I2C总线上分别设置不同地址主控就能独立控制两组显示。用软件I2C做多片驱动时注意总线上的设备多了之后上拉电阻的阻值可能需要调整。两片TM1650都挂上去后建议把上拉电阻从4.7kΩ换到2.2kΩ保证总线上的信号上升沿足够陡峭。我遇到过两片级联时偶尔第二位闪一下的情况排查下来就是上拉电阻阻值偏大导致的电平建立时间太长。4.3 按键扫描多一个功能少一颗芯片TM1650的按键扫描是该芯片一个容易被忽略的功能。它支持最多7个按键或减少按键数换取更大的扫描矩阵内部有扫描逻辑不需要主控参与检测到按键后通过INT引脚拉低来通知主控。按键扫描的寄存器地址在0x4F读模式地址是0x4F | 0x01 0x4F。读取方式unsigned char TM1650_ReadKey(void) { unsigned char key 0; TM1650_Start(); TM1650_WriteByte(0x49); // 从机地址读模式 TM1650_WaitAck(); key TM1650_ReadByte(); TM1650_Stop(); return key; }读回来的字节每个位对应一个按键状态。按键未按下为1按下为0具体极性以芯片手册为准不同批次可能相反。在中断里读到按键变化后置一个标志位主循环里处理按键业务逻辑这种结构对实时性要求不高的产品完全够用。需要留意的是按键扫描复用显示扫描的引脚所以如果键按下时数码管正在扫描该位会有极短暂的显示扰动但实际因为扫描频率足够高kHz级别肉眼完全看不出来。5. 实测中的疑难问题闪烁、乱码和通讯异常的排查链路5.1 现象一上电后数码管全亮但不显示内容这是最典型的接线问题。排查路径先量TM1650的VCC和GND电压确认供电正常用示波器看SCL和SDA按一下复位键看有没有波形出现。完全没有波形说明代码没跑到写显示函数检查初始化流程确认从机地址。TM1650的地址是0x48写操作是0x48读操作是0x49。很多新手把写地址当成0x90在用这是从机的8位地址需要左移一位才能得到。如果你用的是0x90去写从机不会应答表现为完全无反应这个现象我见过不下十次基本都是地址位没搞对。I2C的地址有两种表示法7位地址和8位地址。TM1650的7位地址是0x48在8位表示法里变成了0x90写入方向和0x91读取方向。代码里到底用哪个取决于你发送地址的函数是把地址当7位还是8位处理。5.2 现象二有的数字显示正常有的乱码如果0到3显示正常4到9显示不对或者数字多了个横杠那大概率是段码表的问题。我之前提到过TM1650的段序和某些驱动芯片反序的问题。验证方法很简单往某一位写0x01如果最下面的A段亮了说明段序是正常的如果亮的是别的段说明顺序不对需要把段码表的每一位反过来。处理方式有两种一是改段码表把每个数字的段码按反向位序重新算一遍二是在写入TM1650之前把数据位序反转——就是用逐位反转函数处理一下一行代码的事。5.3 现象三刷新时闪烁或某一位偶尔不亮这个我排了很久才定位到原因硬件I2C的时钟配置问题。STM8S的硬件I2C外设时钟频率默认配置不当或者主频和I2C时钟分频设置不匹配会导致时序过快超过TM1650能容忍的上限。虽然TM1650标称支持400kHz高速模式但实际上那么高速率下信号完整性稍微差一点就会出现随机性的通信错误。处理办法有两个方向一是代码层加容错——每次写完显示数据后读回校验不对就重写。这样能解决偶发问题但治标不治本。二是查根源——把I2C时钟频率降到100kHz。用硬件I2C的话设置好时序寄存器即可。用软件I2C的话调大TM1650_Delay()的延时时间就能解决。// STM8S硬件I2C 100kHz初始化示例 void I2C_Init_100kHz(void) { // 时钟频率 主时钟 / (2 * (FREQR 2) * (CCR 1)) // 主时钟16MHz时100kHz对应的CCR值需要查表计算 I2C-FREQR 16; // 主频16MHz I2C-CCRL 0x28; // 低字节 I2C-CCRH 0x00; // 高字节 I2C-TRISER 17; // 100kHz模式下的上升时间配置 I2C-CR1 0x01; // 使能I2C }5.4 现象四首次上电显示正常运行一段时间后显示冻结这可能不是TM1650的问题而是主控进入了低功耗模式或者看门狗复位导致IO口状态被重置。排查思路看门狗复位后GPIO口默认是输入浮空状态I2C引脚失去驱动能力。在系统初始化时重新配置GPIO和I2C外设能解决如果要在低功耗模式下保持显示可以在进入停机模式之前把I2C引脚配置为输出高电平等效于释放总线TM1650会保持最后的显示内容6. 与TM1637、74HC595方案的横向对比及选型建议项目里遇到过不少次方案选型纠结这里把主流方案拉出来做个对比方便你决策。特性TM1650TM163774HC595接口I2C2线2线类I2CSPI/串行3线驱动位数4位8位含时钟显示不限可级联限流电阻不需要不需要每段必须亮度调节内置4级内置8级靠PWM或电阻按键扫描支持不支持不支持显示刷新芯片自动芯片自动主控扫描单价约0.3-0.5元约0.5-0.8元约0.2元电阻成本典型场景4位数显按键面板时钟/温度计大量LED驱动直接给结论做恒温控制器、定时器、小家电面板4位数码管2到3个按键TM1650是最优解一颗芯片全搞定做数字时钟带时分秒TM1637天然支持8位虽然也要动态扫描但芯片自己处理了比TM1650少一片做流水灯、点阵或者LED数量极多的情况595方案更灵活但代码复杂度明显上升STM8S这款8位MCU主频16MHzFlash 8KB起步资源虽然紧张但跑TM1650驱动绰绰有余。整个驱动代码加显示逻辑不会超过2KB Flash留给业务逻辑的空间还很充裕。7. 基于STM8S的完整工程示例秒表计数器完整跑一个例子吧。做一个带按键的秒表按下按键开始计时再按停止长按清零。用TM1650显示计数值。7.1 硬件连接STM8S003F3P6主控板TM1650模块SCL接PB4SDA接PB5一个按键接PD2另一端接GND内部上拉3.3V/5V共电7.2 main.c完整逻辑#include stm8s.h #include tm1650.h // 按键宏定义 #define KEY_PIN GPIO_ReadInputPin(GPIOD, GPIO_PIN_2) // 全局状态 volatile unsigned int counter 0; // 计数值 volatile unsigned char running 0; // 运行标志 volatile unsigned char key_flag 0; // 按键事件标志 // 定时器2中断1ms周期用于计时和按键消抖 void TIM2_UPD_OVF_BRK_IRQHandler(void) __interrupt(13) { static unsigned int ms_count 0; static unsigned char key_pressed 0; static unsigned char key_prev 1; static unsigned int press_time 0; if(TIM2-SR1 TIM2_SR1_UIF) { TIM2-SR1 ~TIM2_SR1_UIF; // 计时逻辑 if(running) { ms_count; if(ms_count 1000) { ms_count 0; counter; if(counter 9999) counter 0; } } // 按键消抖 短按/长按区分 unsigned char key_now KEY_PIN; if(key_now 0 key_prev 1) { // 按键按下开始计时 if(press_time 0) press_time 1; } else if(key_now 0) { if(press_time 60000) // 60秒上限 press_time; } else if(key_now 1 key_prev 0) { // 按键释放 if(press_time 50) // 小于50ms视为抖动忽略 { press_time 0; } else if(press_time 1000) // 短按 { running !running; } else // 长按 { counter 0; running 0; } key_flag 1; press_time 0; } key_prev key_now; } } void main(void) { // 系统时钟16MHz CLK-CKDIVR 0x00; // GPIO初始化 GPIO_Init(GPIOB, GPIO_PIN_4, GPIO_MODE_OUT_PP_HIGH_FAST); GPIO_Init(GPIOB, GPIO_PIN_5, GPIO_MODE_OUT_OD_HIGH_FAST); GPIO_Init(GPIOD, GPIO_PIN_2, GPIO_MODE_IN_PU_IT); // TM1650初始化 TM1650_SetBrightness(3); TM1650_WriteAll(NULL); // 定时器2初始化1ms中断 TIM2-PSCR 0x00; TIM2-ARRH 0x3E; TIM2-ARRL 0x80; // 16MHz / (20000) 800Hz即1.25ms周期实际按需调整 TIM2-IER TIM2_IER_UIE; TIM2-CR1 TIM2_CR1_CEN; enableInterrupts(); unsigned char display_buf[4]; while(1) { // 将计数值拆分到显示缓冲区 display_buf[0] tm1650_segcode[(counter / 1000) % 10]; display_buf[1] tm1650_segcode[(counter / 100) % 10]; display_buf[2] tm1650_segcode[(counter / 10) % 10]; display_buf[3] tm1650_segcode[counter % 10]; // 只在数据变化时刷新避免无意义的I2C通信 static unsigned int last_counter 0xFFFF; if(counter ! last_counter) { TM1650_WriteAll(display_buf); last_counter counter; } } }这个例子里有个小技巧值得说一下last_counter变量用来记录上次显示的值只有计数变化才更新显示。这个优化虽然对I2C总线压力影响不大但在低功耗场景里能有效减少无谓的通讯电流消耗。主循环空转时整个系统功耗可以降得很低。7.3 时序验证方法如果你没有示波器也可以用逻辑分析仪或者干脆用最笨的办法在I2C发送函数里加GPIO翻转用万用表测频率。// 调试辅助在SCL时钟函数中翻转一个空闲GPIO PD3翻转的频率 I2C比特率实测PD3引脚频率是50kHz左右对应100kHz的I2C时钟如果频率偏差过大调整延时时间让波形符合标准即可。8. 项目落地后的几点经验总结这个项目从画原理图到功能跑通前后用了大概两天时间。除去等物料的时间实际调试不到半天就完成了。中间踩的几个坑前面都提到了地址位搞错、段码表不对、上位机测试时刷新闪屏。有几个经验如果想直接复用的话可以记住第一TM1650的数据手册一定要看英文原版。网上中文版的手册有些翻译错误尤其寄存器地址和位定义部分一旦被带偏排查起来很浪费时间。第二I2C总线上只挂TM1650一个设备的时候初始化顺序无所谓。但如果总线上还有EEPROM、传感器等其他设备注意TM1650不支持时钟拉伸和多主模式需要确保总线上只能有一个主设备。第三批量生产时TM1650的焊接质量直接影响I2C通信稳定性。回流焊后建议增加ICT测试或者上电自检程序上电时写一串固定数据读回校验失败就亮错误码这样生产线上能快速筛出焊接不良的板子。第四如果你做的是电池供电产品TM1650在待机状态下的功耗要特别注意。它没有专门的休眠模式显示关闭时只是内部扫描停止芯片本身的静态功耗还是在的。实测大概在几百微安级别对电池供电的产品来说不可忽略。按照我的经验这套驱动代码稍微改改就能移植到STM32、GD32、新唐等任何有I2C外设的MCU上只要把底层的GPIO操作换成对应平台的库函数就行上层的显示逻辑、段码表、按键处理完全通用。最后分享一个之前没提过的细节TM1650的SDA引脚允许接5V电平但SCL不推荐超过芯片的电源电压。所以如果你用5V给TM1650供电而主控是3.3V的STM32SCL信号会从3.3V拉到5V之间有压差。这种情况下建议在SCL上加一个1kΩ串联电阻可以有效避免长期运行下的电平冲突隐患。实测这个电阻不会影响100kHz的通信质量但对可靠性提升很有帮助。