最近又翻出TM1640这颗老芯片来用,起因是一个小项目要驱动8位数码管,IO口本来就紧张,还得留几个口给按键和传感器。以前我都是拿74HC595一片一片串,这次直接换成TM1640,三根GPIO就全搞定,而且亮度、段码、键盘扫描全都省心很多。写这篇文章主要想把这颗芯片和STM32配合的完整经验整理一遍:从TM1640的指令结构、硬件接线,到GPIO模拟时序、显示缓存刷新、按键读取,再到实际项目中容易踩的坑,都会讲到。适合刚入门STM32、想在数码管显示上少走弯路的朋友,也适合正在做温湿度计、智能台灯、计时器这类带数码管显示项目的人参考。
1. TM1640这颗芯片到底能干什么
1.1 一颗芯片同时管显示和按键
TM1640是一颗LED驱动控制芯片,内置RC振荡器,往上连MCU只需要STB、CLK、DIN三根线。它最大的卖点就是“以一当十”:内部有16个字节的显示寄存器,按最大配置可以驱动8位共阴数码管,每个数码管对应8个段,也就是16×8的矩阵能力。如果你不驱动数码管,而是驱动独立LED,最多可以点亮128个灯珠,做跑马灯、电平指示器都非常好用。
我记得第一次用的时候还特意对比过TM1637和TM1638。TM1637只有两线(CLK、DIO),适合驱动4位数码管,但功能相对简单,按键扫描也不够完整;TM1638虽然自带按键扫描,引脚比TM1640多一些,做8位数码管+多按键的面板很舒服。TM1640的定位介于两者之间,兼顾显示和键扫,而且段驱动能力更强,芯片面积也小,SOP28封装直接贴在PCB背面就行。做产品的时候如果主控引脚特别紧张,TM1640是很合适的方案。
1.2 搞清楚它的引脚和内部寄存器
用之前先看引脚。常见封装是SOP28/DIP28,核心引脚其实不多:
| 引脚 | 方向 | 作用 |
|---|---|---|
| VDD、GND | 电源 | 供电,注意工作电压范围 |
| STB | 输入 | 片选,整个通信周期保持低电平 |
| CLK | 输入 | 时钟,一个时钟沿传一位数据 |
| DIN | 双向 | 数据输入,键扫模式下会反向输出数据 |
| SEG1~SEG16 | 输出 | 段驱动,接数码管的段引脚 |
| GRID1~GRID8 | 输出 | 位驱动,接数码管的位引脚 |
内部显示寄存器的地址从0xC0开始,一共16个字节,正好对应GRID1~GRID8在8段模式下的全部位。如果要驱动8位数码管,每个位占一个字节,字节里每一位对应一个SEG引脚(a~g和dp)。控制寄存器有三个大类:数据指令设置(0x40/0x44这类)、显示控制指令(0x88~0x8F附近)、地址指令(0xC0~0xCF)。网上很多代码一上来就是TM1640_SendCmd(0x40)、TM1640_SendCmd(0x8F),其实就是往这三个寄存器里写值。
有一点我刚开始没注意:TM1640的数据手册版本很多,不同批次对亮度档位和键扫地址的定义会有一点差异。最稳妥的做法是到货之后先把自己手里芯片型号对应的手册找出来,哪怕只是PDF截图也行,写代码前对照一下指令地址再动手。
2. 硬件连接与电路设计实战
2.1 一个可以直接照抄的接线方案
如果只想快速启动,拿STM32F103C8T6这类最小系统板加一个TM1640模块就行。我这次用的是STM32的GPIOB引脚,全部配置为推挽输出,接线关系如下:
| STM32引脚 | TM1640引脚 |
|---|---|
| PB0 | STB |
| PB1 | CLK |
| PB2 | DIN |
| 3.3V或5V | VDD |
| GND | GND |
如果你是自己画板子,而不是买现成模块,还要注意数码管是共阴还是共阳。TM1640设计上对应共阴数码管,SEG引脚输出高电平点亮段码,GRID引脚通过内部MOS管拉低选通某一位,所以位选线接数码管的公共阴极,段选线接各段阳极,这个方向不能接反。接反之后的现象非常典型:数码管完全黑屏,或者段位全亮不受控制,因为电流方向反了,芯片根本没法正常驱动。
2.2 电源、限流电阻和电平匹配
供电是第一个容易踩坑的地方。TM1640的规格书里VDD范围通常是4.5V~5.5V,但市面上很多模块做成了3.3V也能跑。我用3.3V供电跑过一段时间,显示亮度略低,但功能正常。如果对稳定性有要求,建议直接按5V供电设计。另一个更隐蔽的问题是电平匹配:STM32工作在3.3V,如果TM1640也是3.3V供电就没什么可担心的;但如果你把TM1640的VDD接到了5V,而STM32的IO输出高电平最大只有3.3V,TM1640输入引脚的高电平阈值在某些手册里要接近0.7×VDD,也就是3.5V左右,这时候3.3V的高电平显得不够“高”,偶尔会出现数据错乱、显示闪烁。稳妥的做法是在中间加一级电平转换,或者用一个NPN三极管/74LVC245做电平适配。做小批量样品时我偷懒直接3.3V对3.3V供电,省掉转换电路,实测稳定,但批量产品最好还是按手册留够余量。
数码管段选线上要有限流电阻。TM1640内部没有集成限流功能,如果不加电阻,段电流可能远超数码管额定值,短时间就能看到数码管发暗甚至烧段。常见的做法是每个SEG引脚串一个330Ω~1kΩ的电阻。具体阻值根据供电电压、数码管压降和期望亮度计算:假设VDD=5V,数码管一个段的压降约2V,芯片内部导通压降按0.5V算,那么限流电阻上的电压约2.5V,想要10mA段电流就选250Ω,取标准值330Ω;想要亮度高一点就降到220Ω,但别低于100Ω。模块化的TM1640板子一般已经带上了排阻,自己做板子时记得预留位置。
3. 软件驱动实现:从GPIO模拟到显示函数
3.1 为什么不用硬件I2C而要GPIO模拟
不少人第一次接触TM1640会以为它跟I2C兼容,因为CLK和DIN长得就像SCL和SDA。但严格来说它不是标准I2C设备:没有器件地址、没有ACK应答机制,通信由STB片选信号控制,每一帧数据从STB拉低开始、STB拉高结束,中间完全由主机控制时钟和数据。标准I2C要处理起始位、停止位、应答位,和TM1640的时序对不上。所以最省事的方案是直接用GPIO模拟时序,这也是绝大多数TM1640驱动库的做法。
用GPIO模拟还有个好处:不占用I2C外设资源。你一个STM32项目里往往还挂着EEPROM、温湿度传感器,硬件I2C接口留给它们用,数码管显示用模拟时序来跑,互不干扰。有些朋友非要拿硬件I2C去试TM1640,最后发现读不到ACK、数据错位,徒增烦恼。
3.2 GPIO初始化和微秒延时
我用的是STM32 HAL库,但下面是通用逻辑,标准库只要把GPIO操作换成自己的宏就行。初始化代码把STB、CLK、DIN三个引脚都配成推挽输出:
#define TM1640_GPIO_PORT GPIOB #define TM1640_STB_PIN GPIO_PIN_0 #define TM1640_CLK_PIN GPIO_PIN_1 #define TM1640_DIN_PIN GPIO_PIN_2 #define TM1640_STB_HIGH() HAL_GPIO_WritePin(TM1640_GPIO_PORT, TM1640_STB_PIN, GPIO_PIN_SET) #define TM1640_STB_LOW() HAL_GPIO_WritePin(TM1640_GPIO_PORT, TM1640_STB_PIN, GPIO_PIN_RESET) #define TM1640_CLK_HIGH() HAL_GPIO_WritePin(TM1640_GPIO_PORT, TM1640_CLK_PIN, GPIO_PIN_SET) #define TM1640_CLK_LOW() HAL_GPIO_WritePin(TM1640_GPIO_PORT, TM1640_CLK_PIN, GPIO_PIN_RESET) #define TM1640_DIN_HIGH() HAL_GPIO_WritePin(TM1640_GPIO_PORT, TM1640_DIN_PIN, GPIO_PIN_SET) #define TM1640_DIN_LOW() HAL_GPIO_WritePin(TM1640_GPIO_PORT, TM1640_DIN_PIN, GPIO_PIN_RESET) void TM1640_Init(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); GPIO_InitStruct.Pin = TM1640_STB_PIN | TM1640_CLK_PIN | TM1640_DIN_PIN; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Pull = GPIO_PULLUP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(TM1640_GPIO_PORT, &GPIO_InitStruct); TM1640_STB_HIGH(); TM1640_CLK_HIGH(); TM1640_DIN_HIGH(); }数据手册对CLK频率的上限写得比较宽松,常见驱动里每一位数据之间的延时用几百纳秒到几微秒都没问题。STM32主频72MHz,空跑几条指令就能到纳秒级,完全满足要求。我以前还专门写过一个delay_us的微秒延时函数,后来实测发现只要不跑极端情况,普通的空循环就够了。建议你在每个时钟周期里加一点延时,比如两个__NOP(),可以避免在EMC环境差的时候因为时序太苛刻导致数据漂移。
3.3 核心时序:写字节、发指令、刷显存
TM1640通信一个字节的格式是LSB先传,也就是最低位在前。写字节的逻辑很清晰:先把CLK拉低,在CLK低电平期间把DIN设置成当前位的电平,然后把CLK拉高,形成一个上升沿。代码如下:
void TM1640_WriteByte(uint8_t data) { for (uint8_t i = 0; i < 8; i++) { TM1640_CLK_LOW(); if (data & 0x01) { TM1640_DIN_HIGH(); } else { TM1640_DIN_LOW(); } data >>= 1; TM1640_CLK_HIGH(); } }如果你看到网上的代码是先在CLK高时改DIN、再拉低CLK,也能工作,因为本质上都是保证DIN在某个时钟沿期间稳定。不用纠结哪一种是“标准”,用逻辑分析仪看一下波形,只要数据能稳定写进芯片就说明时序方向是对的。
发送指令和写显示数据都要借助STB拉低来开启一帧传输。
void TM1640_SendCommand(uint8_t cmd) { TM1640_STB_LOW(); TM1640_WriteByte(cmd); TM1640_STB_HIGH(); } void TM1640_WriteData(uint8_t addr, uint8_t *data, uint8_t len) { TM1640_SendCommand(0x40); // 数据指令:写显示寄存器,地址自动递增 TM1640_STB_LOW(); TM1640_WriteByte(0xC0 + addr); // 首地址 for (uint8_t i = 0; i < len; i++) { TM1640_WriteByte(data[i]); } TM1640_STB_HIGH(); TM1640_SendCommand(0x8F); // 开显示,最大亮度 }这里有两个细节值得注意。第一,0x40这个指令表示后续的写操作采用“自动地址递增”模式,也就是说写完一个字节后芯片内部地址会自动加1,适合一次性把16字节的显存全部刷新。第二,如果你只想改某一个地址的数据,不想影响其它位,可以用固定地址模式,指令改成0x44,然后每次写一个地址加一个数据。固定地址模式在做闪烁特效、独立关某一位显示时特别有用,后面会专门讲。
3.4 让数字真正显示出来:段码表和全流程
写数据之前必须先把数字或字符翻译成段码。8位数码管按标准排列,a段是低位还是高位由电路决定,最常见的段码表如下:
const uint8_t SEG_CODE[] = { 0x3F, 0x06, 0x5B, 0x4F, 0x66, 0x6D, 0x7D, 0x07, // 0 1 2 3 4 5 6 7 0x7F, 0x6F, 0x77, 0x7C, 0x39, 0x5E, 0x79, 0x71, // 8 9 A b C d E F 0x00, 0x40, 0x80, 0xBF // 空格 负号 小数点(按需) };这里面的0x3F对应数字0,二进制展开是0b00111111,从a到g对应7个段全亮,正好显示一个“0”。“-”号是0x40,只点亮g段;“8.”这种带小数点的显示需要把对应数字的段码和0x80按位或运算,比如SEG_CODE[8] | 0x80。
显示流程其实三句话就能说清:把要显示的数字通过段码表转成段码数组,调用TM1640_WriteData(0, buf, len)把所有段码写进芯片,芯片自己会循环扫描点亮数码管,MCU不需要参与动态刷新。这一点是我觉得TM1640最省心的地方——软件上不用写扫描中断,MCU的时间可以用来处理按键、通信和业务逻辑。
如果需要显示整数变量,可以把数字按位拆开。比如要显示一个三位数temp:
uint8_t buf[8] = {0}; buf[0] = SEG_CODE[(temp / 100) % 10]; buf[1] = SEG_CODE[(temp / 10) % 10]; buf[2] = SEG_CODE[temp % 10]; TM1640_WriteData(0, buf, 3);注意buf里没用的位置要填0x00(段码表里的“空格”),否则会延续上一次显示的内容,出现“鬼影”或者多显示一位的情况。
4. 进阶玩法与项目集成
4.1 亮度控制与单独关掉某一位显示
当显示内容稳定之后,自然要处理两个需求:调亮度和让某一位熄灭。
亮度调节靠显示控制指令完成。0x8F在我的模块上是最大亮度,如果你想让屏幕暗一点,把指令改成0x88~0x8E之间对应的档位即可。具体的亮度级数和0x88附近值的对应关系,不同批次的手册写得不太一样,建议从最高的0x8F往下降,找到合适自己产品观感的亮度档,不要盲信网上的代码。原理是显示控制指令的最低几位控制扫描占空比,占空比越大,数码管平均电流越大,看起来就越亮。
单独关掉某一位显示,用固定地址模式最合适。比如我只想让第三位灭掉,可以把该地址的段码写成0x00然后整体刷新,也可以用0x44固定地址指令只改那一位:
void TM1640_SetOneBit(uint8_t addr, uint8_t segData) { TM1640_SendCommand(0x44); // 固定地址写入 TM1640_STB_LOW(); TM1640_WriteByte(0xC0 + addr); TM1640_WriteByte(segData); TM1640_STB_HIGH(); TM1640_SendCommand(0x8F); // 刷新显示控制,保证显示使能 }调用的时候传入addr和0x00就可以把某位熄灭。有人问为什么不直接把显示控制指令改成0x88关显示,因为那样会把整块屏全部熄灭,达不到只关某一位的效果。这两种指令的应用场景完全不同,别混淆了。
4.2 键盘扫描接口:让DIN反过来读数据
TM1640的另一项重要能力是键盘扫描。它和SEG引脚配合可以组成按键矩阵,让你省掉独立按键芯片或者额外的IO扩展器。按键扫描模式下DIN会变成输出方向,往MCU传回键扫数据,这一点和普通写显示数据完全不同,使用时要特别小心。
读取键扫数据的典型流程是:先发送读键扫指令,比如0x48,然后STB拉低,接着把DIN配置成输入模式,再通过CLK时钟把数据一位一位读回来。读取时要注意GPIO方向切换——如果读完按键忘记把DIN恢复成输出模式,下一次写显示数据时DIN会一直拉不起来,导致显示乱码。我曾经在这个问题上折腾了半小时,最后发现就是GPIO方向没切回来,这也是用TM1640做键扫最大的“坑”之一。
如果你的项目只用数码管显示、不需要按键,那DIN就老老实实一直做输出。需要按键的时候,建议把读键函数封装好,在函数入口切换方向、出口切换回输出,从设计上避免状态残留。
4.3 在RTOS和定时器中断里刷新显示的经验
做实际项目时,显示刷新最好集中管理。如果跑FreeRTOS,可以专门开一个显示任务,用队列接收症状数据的更新请求,再由这个任务统一调用TM1640的写显示函数。好处是多个业务模块不会同时抢GPIO,也不会出现一个任务写一半、另一个任务又把数据改掉的竞态条件。没有RTOS时,可以把显示刷新放进定时器中断,比如每2ms刷新一次缓存,但要注意:不要在中断服务函数里执行过于复杂的逻辑,TM1640一帧数据最多几十微秒,中断里跑完全没问题,但如果你在中断里做长延时就会卡住主循环。
还有一个容易忽略的点:STM32的GPIO速度等级会影响信号边沿。通过PWM和定时器做LED亮度控制时,把GPIO设置为高速模式,CLK边沿更陡,数据采样的不确定性窗口更小。但高速模式也会引入更多EMI噪音,如果PCB布线不好,反而可能因为振铃导致数据错乱。我一般折中选GPIO_SPEED_FREQ_HIGH,然后在CLK、DIN、STB线上各串联一个33Ω左右的电阻,实测对信号质量改善比较明显。
5. 常见问题排查与避坑记录
5.1 典型故障速查表
把过去遇到的和身边朋友遇到的问题整理成一张表,排查时按着顺序过一遍,能省不少时间:
| 故障现象 | 可能原因 | 排查方法 |
|---|---|---|
| 完全黑屏,无任何显示 | VDD供电异常、数码管共阴/共阳接反、STB始终为高 | 先量VDD电压,再量STB是否被拉低 |
| 显示乱码或固定显示错误段码 | 段码表与硬件段序不对应、GPIO初始化模式不对 | 用逻辑分析仪抓DIN波形,对比手册时序 |
| 某一位始终不亮 | 对应GRID引脚虚焊、该位地址写错、缓存数据是0x00 | 单独调用SetOneBit写入0xFF测试该位 |
| 整体亮度不一致 | 限流电阻阻值偏差、数码管参数不一致、亮度档不合适 | 测量各段电流,检查排阻精度 |
| 亮度控制无效 | 显示控制指令发送后又被后面的0x8F覆盖 | 检查是否有别处代码重复发送亮度指令 |
| 按键读不到 | DIN方向没切到输入、键扫地址不匹配、GPIO上拉不足 | 读键函数入口切输入,用万用表量DIN电平变化 |
5.2 调试工具和排查思路
有人问我为什么代码照着网上的写还是黑屏,我一般都会反问一句:你拿逻辑分析仪看过波形吗?STM32这类单片机用逻辑分析仪抓TM1640的时序是一个非常高效的调试手段。三根线的波形只要抓下来,先看STB有没有在每次传输前被拉低,再看CLK上有没有正常的时钟脉冲,最后看DIN的数据和预期是否一致。任何一步不对,问题直接定位到对应的代码段。
一个更简单的中间检查手段是:上电后先别急着显示具体数值,给每个显示地址写0xFF。如果数码管出现“8.”满天飞的情况,说明GPIO和芯片通信基本正常,问题出在段码转换或缓存内容上;如果写0xFF依然全黑,基本就是硬件连接问题或者STB/CLK信号没送到。这种“先全亮、后内容”的分步排查法,比直接盯着主程序找bug要快得多。
5.3 几个容易忽略的细节
经验贴最后想补充几个细节,都是小问题,但都可能导致大返工。
第一,TM1640没有复位引脚,上电后寄存器状态不保证。上电初始化时一定要重新发送显示控制指令,否则芯片可能处于显示关闭状态。有些人把初始化代码写在很后面,结果前面一大段程序里屏幕一直黑着,还以为是通信问题。第二,在STM32调试时如果用了SWDIO引脚来驱动数码管,会导致调试器连接不稳定。我建议把TM1640的三根控制线放在PB0/PB1/PB2这种默认不参与调试的引脚上,或者干脆用别的引脚组,避免边调试边显示时互相干扰。第三,市面上TM1640模块质量参差不齐,有些模块上的数码管没有限流电阻,长时间高亮度使用会烫手。批量采购前一定要先测一下工作温度和段电流,不能只看模块便宜。
还有关于Proteus仿真:有些人问能不能在Proteus里先仿真STM32+TM1640,再动硬件。我的建议是,Proteus仿真可以帮你验证逻辑流程和段码转换,但没法完整模拟芯片的电气特性和时序边沿,尤其是模拟I2C这种依赖延时的地方,真机上的时序表现跟仿真完全不同。在仿真里调通的代码拿到真机上,大概率还需要调延时参数。
写在最后的实操体会
前阵子把一个带TM1640的方案交给同事接手,我给他写了两条硬性要求:一是所有对TM1640的操作都封装在一个.C文件里,禁止业务代码直接调GPIO宏;二是每次写显存之前先想清楚这次要改哪些位,能用固定地址就不要整屏刷新。这两条坚持下来,后面的调试和维护舒服很多,因为显示相关的问题被严格限制在了一个模块里。
再分享一个小经验:遇到显示异常时,先查硬件连接、再查段码表、最后才怀疑时序,这个顺序能帮你快速缩小范围。芯片本身出问题的情况其实很少,多数故障都出在接线、供电和代码细节上。如果你也是第一次在这个芯片上“翻车”,别急着退货,按上面的排查清单走一遍,多半能找到原因。