做显示类项目的时候,数字面板是最常见又最容易被低估的一环。前阵子帮朋友改一款小家电的显示板,主控是STM32F103,原来用的TM1637,因为产品要加功能,显示位数从6位变成8位,TM1637撑不住了,只能换方案。我第一反应就是TM1640——同样是两线串口、同样支持按键扫描、价格几乎没差,但SEG从8路翻到16路,8位数码管或者128点LED点阵都能带,属于典型的“加量不加价”。驱动写完以后顺手整理了一篇东西,从芯片特性、通信时序到STM32上的完整驱动代码都过一遍。
TM1640这颗芯片在国产LED驱动IC里出镜率很高,尤其是在各类家电面板、温控器、小仪表上。它内置了显示RAM和硬件动态扫描,软件只需要往RAM里写数据就行,不像74HC595那类方案要自己不停刷位选。对STM32这种主频动不动几十上百兆的MCU来说,驱动TM1640的代码量极小,GPIO模拟时序完全够用,甚至可以不用定时器。这篇文章我把整个驱动流程拆开讲,从选型、硬件、协议到完整代码和排障经验都写清楚,适合正在做数码管、点阵屏、小家电HMI项目的朋友参考。
1. TM1640芯片核心特性与应用定位
1.1 TM1640内部结构与关键参数
TM1640是一颗LED驱动控制专用电路,内部集成了MCU数字接口、数据锁存器、LED高压驱动以及动态扫描控制逻辑。它对外提供16个段输出脚(SEG1~SEG16)和8个位输出脚(GRID1~GRID8),最多可以驱动16×8共128个LED点,或者8位共阴数码管。这里的“位”就是数码管的公共端,“段”就是数码管的a/b/c/d/e/f/g/dp以及扩展段。
芯片只需要CLK和DIN两线即可通信,属于类SPI的串行协议,但和标准SPI又不完全一样,后面我会细说。它的内置RC振荡器负责生成扫描时序,也就是说芯片自己会不停轮询8个GRID,软件写完显示数据后完全不用管刷新这件事。这个特性带来的最大好处是CPU占用率极低——你可以在main循环里做其他事,屏不会闪,也不会拖慢业务逻辑。
关键参数方面,典型工作电压是5V,手册一般标注4.5V~5.5V。CLK/DIN输入兼容TTL电平,3.3V的MCU直驱在绝大多数情况下都能正常工作,不过这个我们后面有个专门章节聊。显示控制方面,内置8级亮度(占空比)调节和显示开关控制,通过一条命令就能切换。此外它还支持按键扫描,最多可以读24个键,这个功能在一些面板类产品里非常实用,一颗芯片同时搞定显示和按键输入。
1.2 为什么STM32项目选TM1640而不是TM1637/MAX7219/74HC595
很多人在选数码管驱动芯片时容易纠结,因为方案实在太多。我把自己用过的几类方案放进一张表里对比,优劣一眼就能看出来。
| 方案 | 接口 | 最大驱动 | 按键扫描 | 软件复杂度 | 成本 |
|---|---|---|---|---|---|
| TM1637 | 两线CLK/DIO | 6位×8段 | 无 | 低 | 很低 |
| TM1640 | 两线CLK/DIN | 8位×16段,128点 | 最多24键 | 低 | 很低 |
| MAX7219 | 4线SPI | 8位×8段 / 8×8点阵 | 无 | 低 | 较高 |
| 74HC595方案 | 串行移位 | 取决于级联数量 | 无 | 高,需自扫 | 低 |
TM1637最常用在4位、6位时钟面板上,但最高只支持6位×8段,位数一多就顶不住了。TM1640一下子把段扩展到16路,位扩展到8路,意味着你可以驱动8位普通数码管,也可以驱动“米字管”这类段数更多的显示器件,甚至直接怼8×16的单色点阵。价格上两者差距非常小,如果PCB空间和引脚不敏感,直接上TM1640其实是更划算的选择。
MAX7219走标准SPI,级联方便,Maxim原厂或者国产兼容料都有,适合做点阵屏、跑马灯。但它价格贵,而且内置的BCD译码有时候反而限制灵活性,你想显示自定义符号还得绕开它去用非译码模式。TM1640所有段码都由软件控制,本质上就是一块“RAM映像”的驱动器,想显示什么图案完全自己说了算。
74HC595方案是最原始的,两三片595加上三极管位选可以实现8位数码管,但你需要自己写动态扫描程序,扫描频率、位选切换、消隐处理稍微没做好就会出现闪烁和重影。对一个本身就有主业务流程的STM32项目来说,这种软件扫描方案纯属给自己找事。TM1640硬件自动扫描,写一次数据刷新一辈子,这是它最大的竞争优势。
1.3 TM1640在STM32项目里的典型应用
根据我看到的真实项目,TM1640主要出现在这几类场景里。
第一类是家电显示面板:微波炉、热水器、空调线控器、电磁炉这类的8段或16段数码管显示,搭配几个按键,一颗TM1640就能搞定显示+按键。主控通常是STM32F0、F1这种入门级型号,资源完全够用。
第二类是仪器仪表:需要显示多组数值的小仪表,比如压力表、温度巡检仪、电压电流表,多位数码管加上指示灯,TM1640的SEG引脚还可以复用一部分来做LED指示灯,实际上就是把指示灯当作点阵里的“常亮点”处理。
第三类是8×16点阵屏:很多小型滚动字幕屏用两个8×8点阵模块拼接,TM1640的SEG1~SEG16刚好好接16列,GRID1~GRID8接8行,一帧16字节,动画和滚动文字都很好实现。虽然现在LED点阵屏大多用专用驱动IC加恒流源,但这种小尺寸、低成本的场景TM1640还是有一席之地。
2. STM32驱动TM1640的硬件连接与协议实现
2.1 引脚接线与电源处理
TM1640的接线非常简单,对STM32来说只要占用两个GPIO。我个人习惯使用PA0和PA1这种相邻引脚,方便后期画PCB走线,但其实任意普通GPIO都可以,芯片对引脚没有特殊功能要求。
| TM1640引脚 | 连接目标 |
|---|---|
| VDD | 5V电源 |
| GND | 系统地 |
| CLK | STM32任意GPIO,例如PA0 |
| DIN | STM32任意GPIO,例如PA1 |
| SEG1~SEG16 | 数码管段脚 / 点阵列 |
| GRID1~GRID8 | 数码管公共端 / 点阵行 |
电源部分需要特别注意。TM1640的VDD引脚旁边要加0.1uF陶瓷去耦电容,并且尽量靠近芯片引脚放置。如果供电走线比较长,建议再并一个大电容,比如10uF~47uF的电解电容。LED扫描时电流变化很剧烈,电源纹波一大,轻则显示亮度不均,重则导致芯片复位或者数据错乱。这个问题在批量产品里特别常见,别省这两颗电容的钱。
数码管限流电阻的取值也是一个值得说的地方。TM1640的SEG和GRID输出有一定的带载能力,但具体限流电阻怎么选,要看工作电压、LED压降和扫描占空比。以5V供电、红色数码管(压降约1.8V)、8位扫描占空比1/8为例,如果想让平均亮度看起来比较舒服,峰值电流可以取20mA左右,单颗LED的限流电阻就是R=(5V-1.8V-Vce)/0.02A,大致在150Ω~220Ω之间。很多成品模块上用的是330Ω甚至470Ω,亮度稍微暗一点但对芯片的功耗压力小。实际取值可以在150Ω~1kΩ之间调试,看最终视觉效果。电阻放在SEG端还是GRID端都一样工作,关键是每一段都要有一个限流电阻,否则段与段之间的电流会不均衡。
一个很容易踩的坑是数码管极性。TM1640只能驱动共阴数码管,也就是说公共端必须接GRID,段脚接SEG。如果你手里拿到的是共阳数码管,要么换成共阴的,要么就得额外加三极管反相驱动,那就失去这个驱动芯片的意义了。买模块的时候一定要看清楚是共阴还是共阳,很多国产数码管丝印上不会写,最好拿万用表二极管档实测确认。
2.2 TM1640的通信协议时序拆解
TM1640的通信协议是两线制,CLK提供时钟,DIN传输数据。它和标准SPI的时序很接近,但不能直接复用SPI外设,核心区别在于TM1640有独立的起始条件和停止条件。
起始条件:CLK为高电平时,DIN从高电平跳变到低电平。停止条件:CLK为低电平时,DIN从低电平跳变到高电平,然后CLK拉高。这里要注意,TM1640的起始/停止条件是在CLK高低电平状态下DIN的跳变方向,和I2C的起始停止类似,但不完全一样。
写数据的时候,每一位的时序是:先把CLK拉低,然后在DIN上设置好这一位的电平,再拉高CLK,芯片在CLK上升沿锁存数据。数据位是MSB先行,一个字节8位,从bit7开始发。发送完一个字节后,芯片会在第9个时钟周期把DIN拉低作为应答(ACK)。这个ACK可以判读也可以不判读——实际使用中,绝大多数场景不需要关心ACK,因为芯片处理数据的速度远快于总线发送速度,MCU发完命令和数据后直接发停止条件,芯片完全跟得上。
一条完整的“写数据”流程通常是这样:发起始条件,发送数据命令字节(0x40表示自动地址递增,0x44表示固定地址),发停止条件;再发起始条件,发送地址命令字节(0xC0加上0~15的地址),然后连续发送若干个数据字节,最后发停止条件。之所以命令和地址之间要插入停止条件,是为了让逻辑更清晰,芯片其实也支持一次传输里连续发多个字节而不加停止条件,因为芯片是靠指令的最高有效位来区分命令和数据类型的,但显式加停止条件更保险,也方便后期用逻辑分析仪排查问题。
TM1640的指令集并不复杂,实际项目里常用的就下面几条:
| 指令 | 二进制 | 说明 |
|---|---|---|
| 0x40 | 0100 0000 | 数据命令:自动地址递增,连续写16字节 |
| 0x44 | 0100 0100 | 数据命令:固定地址,写单字节 |
| 0x42 | 0100 0010 | 数据命令:读键盘扫描数据 |
| 0xC0~0xCF | 1100 AAAA | 地址命令:设置起始地址0~15 |
| 0x80 | 1000 0000 | 显示控制:关显示 |
| 0x88 | 1000 1000 | 显示控制:开显示,亮度0 |
| 0x8F | 1000 1111 | 显示控制:开显示,亮度7(最亮) |
显示控制命令的0x88~0x8F区间实际上就是0x88加上低三位的亮度值,0x80是关显示。这个设计在代码实现上非常方便,直接做位或操作就行。
2.3 GPIO模拟时序的完整代码实现
到这里就是很多人最关心的代码部分了。我用HAL库写了一套完整的底层驱动,但代码里把GPIO操作封装成了宏,换到标准库或者LL库只需要改这几个宏就行。
/* tm1640.h */ #ifndef __TM1640_H #define __TM1640_H #include "main.h" /* 引脚定义,根据实际原理图修改 */ #define TM1640_CLK_PORT GPIOA #define TM1640_CLK_PIN GPIO_PIN_0 #define TM1640_DAT_PORT GPIOA #define TM1640_DAT_PIN GPIO_PIN_1 #define TM1640_CLK_H() HAL_GPIO_WritePin(TM1640_CLK_PORT, TM1640_CLK_PIN, GPIO_PIN_SET) #define TM1640_CLK_L() HAL_GPIO_WritePin(TM1640_CLK_PORT, TM1640_CLK_PIN, GPIO_PIN_RESET) #define TM1640_DAT_H() HAL_GPIO_WritePin(TM1640_DAT_PORT, TM1640_DAT_PIN, GPIO_PIN_SET) #define TM1640_DAT_L() HAL_GPIO_WritePin(TM1640_DAT_PORT, TM1640_DAT_PIN, GPIO_PIN_RESET) #define TM1640_CMD_DATA_AUTO_ADDR 0x40 #define TM1640_CMD_DATA_FIXED_ADDR 0x44 #define TM1640_CMD_ADDR_BASE 0xC0 #define TM1640_CMD_DISPLAY_OFF 0x80 #define TM1640_CMD_DISPLAY_ON 0x88 void TM1640_Init(void); void TM1640_Clear(void); void TM1640_SetBrightness(uint8_t level); void TM1640_DisplayOn(uint8_t level); void TM1640_DisplayOff(void); void TM1640_WriteData(uint8_t addr, const uint8_t *buf, uint8_t len); void TM1640_WriteByteAt(uint8_t addr, uint8_t dat); #endif/* tm1640.c */ #include "tm1640.h" /* 软件延时,根据主频调整。72MHz主频下大约1us~2us */ static void TM1640_Delay(void) { volatile uint32_t i = 20; while (i--) { __NOP(); } } static void TM1640_Start(void) { TM1640_CLK_H(); TM1640_DAT_H(); TM1640_Delay(); TM1640_DAT_L(); TM1640_Delay(); } static void TM1640_Stop(void) { TM1640_CLK_L(); TM1640_DAT_L(); TM1640_Delay(); TM1640_CLK_H(); TM1640_Delay(); TM1640_DAT_H(); TM1640_Delay(); } static void TM1640_WriteByte(uint8_t dat) { uint8_t i; for (i = 0; i < 8; i++) { TM1640_CLK_L(); if (dat & 0x80) TM1640_DAT_H(); else TM1640_DAT_L(); TM1640_Delay(); TM1640_CLK_H(); TM1640_Delay(); dat <<= 1; } /* 第9个时钟用于芯片ACK应答,这里不做判读 */ TM1640_CLK_L(); TM1640_Delay(); TM1640_CLK_H(); TM1640_Delay(); } static void TM1640_SendCmd(uint8_t cmd) { TM1640_Start(); TM1640_WriteByte(cmd); TM1640_Stop(); } void TM1640_WriteData(uint8_t addr, const uint8_t *buf, uint8_t len) { uint8_t i; TM1640_Start(); TM1640_WriteByte(TM1640_CMD_DATA_AUTO_ADDR); TM1640_Stop(); TM1640_Start(); TM1640_WriteByte(TM1640_CMD_ADDR_BASE | addr); for (i = 0; i < len; i++) TM1640_WriteByte(buf[i]); TM1640_Stop(); } void TM1640_WriteByteAt(uint8_t addr, uint8_t dat) { TM1640_Start(); TM1640_WriteByte(TM1640_CMD_DATA_FIXED_ADDR); TM1640_Stop(); TM1640_Start(); TM1640_WriteByte(TM1640_CMD_ADDR_BASE | addr); TM1640_WriteByte(dat); TM1640_Stop(); } void TM1640_SetBrightness(uint8_t level) { if (level > 7) level = 7; TM1640_SendCmd(TM1640_CMD_DISPLAY_ON | level); } void TM1640_DisplayOn(uint8_t level) { TM1640_SetBrightness(level); } void TM1640_DisplayOff(void) { TM1640_SendCmd(TM1640_CMD_DISPLAY_OFF); } void TM1640_Clear(void) { uint8_t i; for (i = 0; i < 16; i++) { TM1640_Start(); TM1640_WriteByte(TM1640_CMD_DATA_FIXED_ADDR); TM1640_Stop(); TM1640_Start(); TM1640_WriteByte(TM1640_CMD_ADDR_BASE | i); TM1640_WriteByte(0x00); TM1640_Stop(); } } void TM1640_Init(void) { TM1640_DisplayOff(); TM1640_Clear(); TM1640_SetBrightness(3); }这套代码的要点在于,把起始、停止、写字节这些基础时序封装成内部静态函数,对外只暴露SendCmd、WriteData、WriteByteAt、SetBrightness这类高层接口。上层应用永远不需要关心CLK和DIN怎么变化,只需要发命令和数据即可。TM1640_WriteData用的是自动地址递增模式,适合一次写多个字节,比如一次刷新8位数码管的所有段缓存。TM1640_WriteByteAt用的是固定地址模式,适合单独修改某一位的显示内容,后面讲“关闭某一位显示”时会用到。
GPIO在main函数里需要初始化为推挽输出,速度建议选择LOW或者MEDIUM,不要用VERY_HIGH。GPIO速度太高会导致信号的上升沿和下降沿非常陡峭,长线传输时振铃明显,反而容易造成误码。初始化代码大致是这样:
GPIO_InitTypeDef gpio = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); gpio.Pin = GPIO_PIN_0 | GPIO_PIN_1; gpio.Mode = GPIO_MODE_OUTPUT_PP; gpio.Pull = GPIO_NOPULL; gpio.Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(GPIOA, &gpio);软件延时的时间不需要太精确,只要保证CLK和DIN的建立时间满足芯片要求。按72MHz主频、循环20次来算,每半周期大约1us~2us,CLK频率大概在200kHz~500kHz之间,对于显示刷新来说绰绰有余。如果你用的是168MHz的F4或者更高主频的H7,这个延时循环次数可能要加大一些,比如改成40或者用nop计数,实测正常显示就不用调。
3. 显示驱动的完整实现与应用技巧
3.1 共阴数码管的段码设计与RAM映射
TM1640内部有16字节显示RAM,地址0x00~0x0F。这16字节和引脚的对应关系需要记清楚:
| RAM地址 | 对应GRID | 对应的SEG |
|---|---|---|
| 0x00 | GRID1 | SEG1~SEG8 |
| 0x01 | GRID2 | SEG1~SEG8 |
| 0x02 | GRID3 | SEG1~SEG8 |
| 0x03 | GRID4 | SEG1~SEG8 |
| 0x04 | GRID5 | SEG1~SEG8 |
| 0x05 | GRID6 | SEG1~SEG8 |
| 0x06 | GRID7 | SEG1~SEG8 |
| 0x07 | GRID8 | SEG1~SEG8 |
| 0x08 | GRID1 | SEG9~SEG16 |
| 0x09 | GRID2 | SEG9~SEG16 |
| ... | ... | ... |
| 0x0F | GRID8 | SEG9~SEG16 |
普通8位数码管每位包含a、b、c、d、e、f、g、dp共8段,接SEG1~SEG8就够了,所以实际数据只涉及地址0x00~0x07,高字节地址0x08~0x0F用不到。如果驱动的是“米字管”这类多段显示器,或者8×16点阵,才会用到高字节部分。
每个字节内的bit位和SEG引脚的对应关系也要注意:bit0对应SEG1,bit1对应SEG2,一直到bit7对应SEG8。如果你的PCB上段脚不是按a、b、c...顺序接到SEG1~SEG8的,那就需要调整段码表,这是显示乱码最常见的原因。
下面这份共阴数码管标准段码表可以直接复制使用,bit0=a,bit1=b,bit2=c,bit3=d,bit4=e,bit5=f,bit6=g,bit7=dp:
| 字符 | 段码 |
|---|---|
| 0 | 0x3F |
| 1 | 0x06 |
| 2 | 0x5B |
| 3 | 0x4F |
| 4 | 0x66 |
| 5 | 0x6D |
| 6 | 0x7D |
| 7 | 0x07 |
| 8 | 0x7F |
| 9 | 0x6F |
| A | 0x77 |
| b | 0x7C |
| C | 0x39 |
| d | 0x5E |
| E | 0x79 |
| F | 0x71 |
| 空白 | 0x00 |
| 仅小数点 | 0x80 |
| 度符号° | 0x63 |
| 横杠- | 0x40 |
3.2 更新显示:一次性刷新与单独修改的取舍
显示驱动的核心思想是“软件维护缓存,按需写入芯片”。我习惯在内存里维护一个8字节的显示缓存g_dispBuf,每个字节对应一位数码管的段码。业务逻辑只修改这个缓存,真正把数据写到TM1640的操作统一由一个刷新函数处理。
一次性刷新所有位的代码很简单:
uint8_t g_dispBuf[8]; void TM1640_UpdateDisplay(void) { TM1640_WriteData(0, g_dispBuf, 8); }8位数据一次写入,自动地址递增模式下芯片内部地址自动从0x00走到0x07,刚好覆盖低8个字节。这种方式的优点是效率高,一次Start/Stop就搞定全部数据,总线上占用的时间非常短。
但如果你只想修改某一位,比如把第3位的数字从5改成8,用自动地址递增模式写1个字节其实也不会影响其他位,因为len=1只写了1个字节。不过有个更稳妥的写法是固定地址模式,也就是前面提过的TM1640_WriteByteAt。它的好处是芯片地址完全由命令指定,不依赖于上一次传输后芯片内部的地址状态,逻辑上更干净。
“关闭某一位显示”这个需求在项目中非常常见。比如一个8位的面板,这次只需要显示4位数字,其他位应该灭掉。此时只需把对应位置的缓存改成0x00,再调用TM1640_WriteByteAt单独写进去就行:
void TM1640_ClearPos(uint8_t pos) { if (pos > 7) return; g_dispBuf[pos] = 0x00; TM1640_WriteByteAt(pos, 0x00); }如果某一位需要保留小数点,而数字本身要灭掉,则把该位缓存写成0x80,这样只有dp段亮,其他段全灭。这个技巧在显示出类似“1.23”这种带小数点的数值时很实用——你只需要在整数部分显示完之后,把小数点的段单独加上去。
要注意的是,修改缓存和写入芯片这两步必须同时做。如果只改缓存忘了调用写入函数,屏上不会变化;如果只调用写入函数但忘了改缓存,下一次刷新会把旧值又写回去。很多人在开发时遇到“改了不生效”或者“显示乱跳”都是这个原因。
3.3 亮度控制与显示开关的细节
TM1640的亮度控制是通过0x88~0x8F这条显示控制命令实现的,低三位是亮度等级,从0到7共8档。这个亮度调节是芯片内部硬件实现的,通过调整扫描脉宽来改变LED平均电流,整个过程不需要CPU参与,调到最低亮度也不会出现软件PWM那种闪烁感。
使用的时候有一个坑:0x88这条命令,它既做了亮度设置又做了显示开关。也就是说,你每次调用TM1640_SetBrightness的时候,如果之前处于关显示状态,调用后会自动开显示。反过来,如果你先调用TM1640_DisplayOff()关屏,紧接着用SetBrightness设置亮度,屏又亮了。这是个很容易忽略的细节。
我建议在应用层做一个简单的状态管理,记录当前亮度变量g_brightness,开关显示和设置亮度分开处理,所有地方都通过状态变量来操作,避免直接发命令导致状态混乱。比如:
static uint8_t g_brightness = 3; void TM1640_SetBrightness(uint8_t level) { if (level > 7) level = 7; g_brightness = level; TM1640_SendCmd(TM1640_CMD_DISPLAY_ON | g_brightness); } void TM1640_DisplayOn(void) { TM1640_SendCmd(TM1640_CMD_DISPLAY_ON | g_brightness); }上次我在一个项目里就吃过亏,初始化时先DisplayOff再SetBrightness,结果屏怎么都不灭。后来看命令集才发现0x88本身带开显示功能,相当于发了一条“开且调亮度”的命令。现在我的建议是:初始化时先DisplayOff清屏,然后直接SetBrightness(level),让它一次性开起来,不要在中途反复开关。
3.4 扩展:8×16点阵屏驱动与简单动画
如果你不打算驱动数码管,而是想驱动8×16单色点阵屏,TM1640同样可以胜任。接线方式是24个引脚全都用上——16列接SEG1~SEG16,8行接GRID1~GRID8。这样RAM的16个字节全部用满,每个字节对应一行,低字节控制左8列,高字节控制右8列。
显示一帧图像只需要构造一个16字节的数组,然后TM1640_WriteData(0, matrix, 16)一次性写进去。比如显示一个向左移动的箭头图案,只需要准备几帧不同的数据,按照时间间隔依次写入就行。
实现滚动字幕的核心思路是:准备一张总宽度超过16列的文字位图,用一个偏移变量控制当前窗口的位置,每次取出16列的数据放到matrix里,然后写入TM1640。16字节的写入时间在几百微秒级别,即便以50Hz的频率刷新动画,占用的CPU时间也可以忽略不计。
需要注意的是点阵屏限流电阻的取值和数码管不太一样。点阵里每个LED的导通时间取决于扫描到哪一行,占空比只有1/8,所以峰值电流可以适当大一些,比如25mA~30mA,这样平均亮度才能上去。如果发现点阵整体偏暗,优先检查限流电阻是不是太大了,而不是急着加大TM1640的亮度等级——亮度过高也会让芯片功耗超标。
4. 按键扫描功能与工程扩展
4.1 读键命令与代码实现
TM1640一个容易被忽略的功能是自带按键扫描。这意味着你可以在同一个两颗线的总线上,同时实现显示和按键输入,面板器件数量可以省掉好几个,PCB走线也简单很多。
按键扫描的启动方法很简单:发送0x42命令后,芯片会把当前按键扫描的结果通过DIN脚输出。输出数据共24位,分为3个字节,每个字节对应8个按键,总共24个按键。读数据时的时序和写数据正好相反:写数据是在CLK上升沿锁存DIN,而读数据需要在CLK下降沿之后再采样DIN,此时DIN的角色变为了输出。
读键部分的实现代码如下:
uint32_t TM1640_ReadKey(void) { uint8_t keyBuf[3] = {0, 0, 0}; uint8_t i; GPIO_InitTypeDef gpio = {0}; TM1640_Start(); TM1640_WriteByte(0x42); TM1640_Stop(); /* 将DIN切换为输入模式,带上拉 */ gpio.Pin = TM1640_DAT_PIN; gpio.Mode = GPIO_MODE_INPUT; gpio.Pull = GPIO_PULLUP; HAL_GPIO_Init(TM1640_DAT_PORT, &gpio); for (i = 0; i < 24; i++) { TM1640_CLK_L(); TM1640_Delay(); if (HAL_GPIO_ReadPin(TM1640_DAT_PORT, TM1640_DAT_PIN)) keyBuf[i / 8] |= (0x80 >> (i % 8)); TM1640_CLK_H(); TM1640_Delay(); } /* 恢复DIN为推挽输出 */ gpio.Pin = TM1640_DAT_PIN; gpio.Mode = GPIO_MODE_OUTPUT_PP; gpio.Pull = GPIO_NOPULL; gpio.Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(TM1640_DAT_PORT, &gpio); return (keyBuf[0] << 16) | (keyBuf[1] << 8) | keyBuf[2]; }这段代码的核心步骤是:先发送0x42命令,然后立刻把DIN方向切换到输入,接着在每一个CLK下降沿后读取DIN电平,读完24位后恢复DIN为输出模式。注意方向切换的顺序,如果发送完命令后没有把DIN改成输入就直接读,读到的永远是自己输出寄存器里的值,按键不管你按得多用力都没反应。
TM1640按键数据的逻辑一般是按下为0、释放为1,和很多矩阵键盘的读法一样,所以拿到原始数据后建议按位取反,这样按下对应的位就是1,判断起来更直观。有些资料上也直接用原始值判断“某位是否为0”,都可以,只要前后统一就行。
4.2 按键矩阵映射的验证方法
按键数据里每一位对应哪个物理按键,这个映射关系在不同模块上可能不一样,因为按键扫描矩阵的接法取决于模块设计,不是所有模块都按标准丝印来。所以拿到一个新模块时,我强烈建议先写一个测试程序:每10ms读一次按键数据,通过串口把原始值打出来,然后逐个按键按下,记录按下瞬间的bit位。
实际操作时流程是这样的:把读到的24位数据打印成3个字节的十六进制数,比如“FF FF FF”表示没有按键按下。按一下第一个按键,观察哪一位变成了0,记下来;松开,继续按第二个按键。把每个按键对应的bit位整理成一张映射表,写进代码里作为常量。这样做的成本极低,但能避免后期因为按键错乱而反复返工的痛苦。
如果按键会用到“长按”“连按”这类功能,记得在应用层做好消抖和去重。我常用的方案是每10ms扫描一次,连续3次读到相同键值才认为按键有效,然后触发一次事件。这个消抖思路对任何按键都适用,不限于TM1640的键扫。
5. 常见问题与排查实录
5.1 故障速查表
把我在实际项目和论坛里看到的高频问题整理成一张速查表,哪个现象对着哪一行查,效率高很多。
| 现象 | 可能原因 | 排查/解决 |
|---|---|---|
| 完全不显示 | 电源接反/电压不足 | 测量VDD是否为4.5V以上,检查GND是否共地 |
| 完全不显示 | DIN和CLK接反 | 交换两根线试试 |
| 完全不显示 | 数码管是共阳 | TM1640只支持共阴,换共阴数码管 |
| 部分位数码管不亮 | 该位GRID虚焊或限流电阻问题 | 用万用表检查对应GRID引脚到数码管公共端通断 |
| 显示乱码/字符错乱 | 段码表与SEG顺序不匹配 | 确认bit0是否对应实际接线的SEG1 |
| 显示乱码/字符错乱 | 地址偏移写错 | 确认写入数据时起始地址是否正确 |
| 显示亮度低 | 限流电阻过大 | 减小限流电阻,比如从470Ω降到220Ω |
| 显示亮度低 | 3.3V供电 | 改回5V供电 |
| 整屏闪烁 | 电源纹波大 | VDD旁加0.1uF和10uF电容 |
| 整屏闪烁 | 软件频繁写数据 | 减少无谓刷新,只在缓存变化时写入 |
| 某位有重影/微亮 | 位选扫描异常 | 检查该位GRID是否短路到相邻脚 |
| 按键无反应 | DIN方向未切换 | 确认读键前已把DIN切为输入 |
| 按键无反应 | 命令发错 | 检查是否发送0x42 |
5.2 3.3V与5V电平匹配问题的实测
3.3V的STM32直驱5V供电的TM1640,这个问法几乎每次做硬件评审都会被提出来。按手册值来算,TM1640的输入高电平门槛一般是0.7×VDD,5V供电时大约3.5V,而STM32的3.3V推挽输出高电平最多也就3.3V,看起来不够。但实际使用中,3.3V直驱在绝大多数短距离飞线场景都工作正常,原因在于CMOS工艺的输入电路实际翻转点通常在1.5V~2V附近,手册的阈值是设计保证值,不是实际的触发点。
不过“能用”不代表“可靠”。在继电器、电机这类强干扰源附近,3.3V高电平的噪声容限确实偏小,线稍微长一点就可能被干扰信号误触发,导致显示错乱。我的处理原则是:开发调试阶段直驱完全没问题,但设计产品和批量生产时,如果VDD确定用5V,给DIN和CLK加10kΩ上拉到5V,或者用开漏输出加外部上拉的方式来做电平转换。STM32的GPIO大多数是FT引脚,耐5V,可以直接开漏加上拉;如果不是FT引脚,那就加一个电平转换芯片或者用三极管搭一个。
还有个折中方案:TM1640的VDD直接接3.3V。芯片在这个电压下也能跑,显示亮度会低一些,扫描时序、RAM读写都不会受影响。如果产品对亮度要求不高,而且不想处理电平匹配问题,这个方案最省事。但要注意,芯片的宽电压范围不是官方推荐的,量产前得做高低温测试,确认在极限温度下仍然稳定。
5.3 提高显示稳定的几个细节
最后分享几个我这几年做显示驱动总结出来的细节。这些点单看都很小,但叠加在一起,往往就是产品稳定性和“能用就行”的区别。
第一,GPIO速度不要开太高。很多HAL库生成的代码默认把GPIO速度配成VERY_HIGH,这对高速通信未必有好处,反而会让信号边沿产生振铃。驱动TM1640这种低速两线协议,GPIO速度配LOW或者MEDIUM就够了,能滤掉一部分高频干扰,波形还更干净。
第二,接线过长时做滤波。CLK和DIN线上串一个33Ω~100Ω的电阻,或者在靠近MCU端对地加一个20pF~50pF的电容,都能吸收振铃。如果线的长度超过20cm,还应该在插件上加上拉电阻到VDD。
第三,显示数据写入要尽量打包。一次写多个字节比多次写单字节好,既节省总线时间,也减少命令之间的状态切换窗口。特别是显示动画和滚动字幕时,一帧数据组好以后一次TM1640_WriteData写完,不要写一个字节又去干别的事再回来写下一个。
第四,上电初始化必须清RAM。TM1640内部RAM在上电瞬间是随机值,如果不做清屏操作,屏幕上可能出现花屏或者乱码。我的Init函数里就是先DisplayOff再逐字节写0x00清RAM,最后再设置亮度开显示。这个顺序不能反,先开显示再清RAM的瞬间屏幕会闪一下。
第五,调试时务必用逻辑分析仪抓时序。TM1640这种两线协议,用示波器看DIN和CLK波形,起始条件、停止条件、字节位序这些一目了然。之前有个同事调了一整天不显示,后来用逻辑分析仪一抓发现Stop函数里DIN电平顺序反了。工具用对,问题立刻现形。
结语:一点实操体会
TM1640这个芯片我用过好几次,给我最大的感受是“皮实”。命令不多,时序不苛刻,对GPIO模拟来说几乎是零门槛,不容易烧,出问题大多出在硬件接线和段码表上。按照我自己的习惯,每个新项目拿到板子,第一件事就是先把底层时序和RAM读写调通,在串口或者调试器里确认缓存数据变了、芯片对应引脚也变了,再往上叠业务逻辑。这样后面不管做数码管、点阵还是按键扫描,都是在稳定的地基上盖楼。
如果你也准备在STM32项目里用TM1640,我的建议是:先把TM1640_WriteByteAt和TM1640_WriteData这两个函数练熟,再配合一张共阴数码管段码表,就可以应付绝大多数显示需求了。按键扫描功能前期可以先不实现,等显示部分稳定了再加,这样排错范围能缩小很多。如果碰到复杂的人机交互界面,显示和按键逻辑最好分层处理,显示层只管往缓存里写段码,按键层只管返回“哪个键被按下”,上层业务把它们串起来。这样代码结构清晰,后期功能扩展也省力。