
1. 为什么我在这个项目里选了TM1640而不是TM1637或TM1638最近帮朋友做了一个带数码管显示的定时器小设备MCU用的是STM32F103显示部分需要驱动8位共阴数码管。一开始我脑子里第一个冒出来的是TM1637毕竟网上资料多、用的人也多但仔细一看TM1637最多只能驱动4位驱动8位数码管要么用两片要么换方案。后来我把天微这系列芯片挨个捋了一遍最后定下了TM1640两线接口、单芯片就能带8位正好卡在需求上。TM1640本质是一颗LED驱动控制芯片内部集成了MCU数字接口、数据锁存器和恒流驱动电路。对STM32来说它不占用定时器资源也不需要额外的扫描代码MCU只负责往它的显示RAM里写数据剩下的动态扫描、消隐、刷新都由芯片自己完成。这在嵌入式项目里是很大的优势尤其是MCU还要同时处理按键、通信、控制逻辑的时候省下的CPU时间非常可观。1.1 TM1640核心参数和管脚速览先从硬件说起。TM1640常见封装是SOP28管脚不少但真正和MCU打交道的只有两根线CLK时钟和DIN数据剩下的都是驱动LED用的SEG和GRID引脚。我列一下关键参数参数数值/说明接口方式两线串行CLK DIN显示模式8位 × 16段显示RAM16字节8个GRID每个GRID对应2字节亮度调节8级通过显示控制命令设置工作电压常见3.0V ~ 5.5V范围建议与MCU同电压驱动能力SEG输出恒流GRID灌电流可直驱8位数码管注意一点DIN是纯输入引脚不像TM1637的DIO是双向的TM1640不需要读ACK应答。这意味着驱动实现比TM1637还简单一点STM32的GPIO全程保持推挽输出就行不用在输入输出模式之间切换。1.2 天微家族三兄弟的选型对比很多人在TM1637、TM1638、TM1640之间纠结我直接给个对比表方便以后选型芯片接口线数最大位数显示段数适合场景TM16372线CLKDIO双向DIO4位8段小家电、4位时钟、温湿度计TM16383线CLKDIOSTB8位8段带按键扫描的显示面板TM16402线CLKDINDIN单向8位16段8位数码管、8×16点阵、多点LED指示灯选型的核心逻辑是位数不够就淘汰TM1637直接出局不需要按键扫描就别上TM1638因为三线比两线多一根线布线麻烦。TM1640优势在于同样两线但位数翻倍且16段能力在驱动带小数点和双色LED的场景下特别有用。我最后选了TM1640还有一个原因是它内部自带RC振荡器不需要外部晶振硬件上省事。2. TM1640的两线协议拆解起始、停止、LSB先行的字节时序很多第一次接触TM1640的人会以为它用I2C毕竟两线、SCL/SDA长得像但实际上它和I2C没有任何关系纯粹是一套私有协议。如果你拿I2C那套带地址、带ACK的思路去调试大概率会卡住。这里把协议逐条拆开讲清楚。2.1 逐位时序与关键约束TM1640的时序本质只有三个动作起始条件、发送字节、停止条件。先看最容易忽略的一点字节传输是LSB先出也就是先发最低位这和大多数人的直觉相反。很多网上代码抄过来显示乱码十有八九就是这一位序搞反了。起始条件的定义是CLK为高电平期间DIN从高电平跳变到低电平。停止条件则是CLK为高电平期间DIN从低电平跳变到高电平。数据位的发送规则是CLK拉低时改变DIN的电平然后CLK拉高产生上升沿芯片在上升沿锁存数据。这里有个小细节值得展开为什么不是CLK低电平采样因为TM1640内部逻辑是在CLK上升沿把DIN的电平打进移位寄存器所以STM32这边的原则是“先摆数据、再给时钟”。你如果把顺序写成先拉高CLK再改DIN芯片采到的就是上一次的数据整个字节就偏移了。2.2 三条命令数据命令、地址命令、显示控制命令TM1640的指令系统就三条命令非常精简命令命令字范围作用数据命令设置0x40 / 0x44设置写显示RAM的模式显示控制命令0x88 ~ 0x8F开关显示、设置亮度地址命令设置0xC0 ~ 0xCF指定显示RAM地址数据命令0x40是自动地址递增模式适合一次性刷新全部显示RAM0x44是固定地址模式适合只更新某一位数据。显示控制命令0x88表示关闭显示0x89到0x8F对应亮度1到7级注意0x8F是最亮而不是0x89这个顺序别搞反。地址命令0xC0到0xCF对应16字节的显示RAM后面跟上要写入的数据。自动递增模式下写完一字节地址自动加一连续写完16字节后自动回绕到0xC0。2.3 显示RAM的16字节布局0xC0到0xCF的真相TM1640号称8位×16段背后的RAM布局不是简单的“8位数码管8字节”而是每个GRID对应2字节。准确映射关系如下地址含义0xC0GRID1 的 SEG1 ~ SEG80xC1GRID1 的 SEG9 ~ SEG160xC2GRID2 的 SEG1 ~ SEG80xC3GRID2 的 SEG9 ~ SEG16......0xCEGRID8 的 SEG1 ~ SEG80xCFGRID8 的 SEG9 ~ SEG16如果数码管的8个段选接在SEG1~SEG8上那么第n位从1开始的段码要写到地址0xC0 (n-1)*2对应的字节也就是0xC0、0xC2、0xC4这样的偶数地址。奇数地址留给SEG9~SEG16扩展用比如指示灯、双色LED的第二种颜色。这个规则非常关键我见过不止一个人在这里翻车只发8字节想驱动8位数码管结果数据和位选对不上。3. STM32驱动程序从零实现GPIO模拟完整代码原理清楚了代码就是水到渠成的事。我用STM32F103 HAL库做示例但底层只用GPIO置位/复位换成标准库、LL库或者寄存器操作都只需要改宏定义。3.1 硬件连接GPIO选择、上拉与电源我的接线非常朴素PB6接CLKPB7接DIN两个引脚都配置成推挽输出。电路上给TM1640的VCC和GND之间并联一个10uF电解电容和104瓷片电容防止数码管瞬间点亮时电压跌落。引脚定义和GPIO初始化代码#include main.h #include string.h /* 根据实际接线修改引脚 */ #define TM1640_CLK_PORT GPIOB #define TM1640_CLK_PIN GPIO_PIN_6 #define TM1640_DIN_PORT GPIOB #define TM1640_DIN_PIN GPIO_PIN_7 #define TM1640_CLK_SET() HAL_GPIO_WritePin(TM1640_CLK_PORT, TM1640_CLK_PIN, GPIO_PIN_SET) #define TM1640_CLK_RESET() HAL_GPIO_WritePin(TM1640_CLK_PORT, TM1640_CLK_PIN, GPIO_PIN_RESET) #define TM1640_DIN_SET() HAL_GPIO_WritePin(TM1640_DIN_PORT, TM1640_DIN_PIN, GPIO_PIN_SET) #define TM1640_DIN_RESET() HAL_GPIO_WritePin(TM1640_DIN_PORT, TM1640_DIN_PIN, GPIO_PIN_RESET) void TM1640_Init(void) { GPIO_InitTypeDef gpio {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); gpio.Pin TM1640_CLK_PIN | TM1640_DIN_PIN; gpio.Mode GPIO_MODE_OUTPUT_PP; gpio.Pull GPIO_NOPULL; gpio.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOB, gpio); /* 上电后先把两根线拉高避免TM1640误判起始条件 */ TM1640_CLK_SET(); TM1640_DIN_SET(); }有一个细节很多人忽略STM32的GPIO在上电默认是浮空输入如果用外接上拉到5V的电路GPIO还没初始化时DIN可能处于不定状态。所以初始化函数里我先拉高CLK和DIN再做后续操作保证TM1640不会在上电瞬间收到一串乱码。3.2 底层时序函数Start/Stop/WriteByte延时函数我用DWT实现注意运行前要初始化DWT计数器。如果用的MCU是M0内核没有DWT可以换成SysTick延时或者简单的空循环只是精度没那么重要TM1640对时序的宽容度其实很高。static void TM1640_DelayUs(uint32_t us) { uint32_t start DWT-CYCCNT; uint32_t ticks us * (SystemCoreClock / 1000000U); while ((DWT-CYCCNT - start) ticks); }时序函数的完整实现static void TM1640_Start(void) { TM1640_DIN_SET(); TM1640_CLK_SET(); TM1640_DelayUs(2); TM1640_DIN_RESET(); /* CLK高电平期间DIN从高到低 起始条件 */ TM1640_DelayUs(2); TM1640_CLK_RESET(); } static void TM1640_Stop(void) { TM1640_DIN_RESET(); TM1640_CLK_SET(); TM1640_DelayUs(2); TM1640_DIN_SET(); /* CLK高电平期间DIN从低到高 停止条件 */ TM1640_DelayUs(2); } static void TM1640_WriteByte(uint8_t dat) { uint8_t i; for (i 0; i 8; i) { TM1640_CLK_RESET(); /* 先拉低时钟再改数据 */ if (dat 0x01) TM1640_DIN_SET(); else TM1640_DIN_RESET(); TM1640_DelayUs(1); /* 数据建立时间 */ TM1640_CLK_SET(); /* 上升沿锁存数据 */ TM1640_DelayUs(1); /* 保持时间 */ dat 1; /* LSB first先发最低位 */ } } static void TM1640_SendCommand(uint8_t cmd) { TM1640_Start(); TM1640_WriteByte(cmd); TM1640_Stop(); }这里的时序参数1~2微秒是我实测下来的稳妥值通信速率大概在几百kHz足以满足数码管刷新需求。如果你追求极限速度把延时缩小到几百纳秒也行但我是做产品不是跑分稳定压倒一切慢一点无妨。3.3 三层封装初始化、清屏、全量刷新与单点控制有了底层函数上层就是套模板。我维护了一个16字节的显示缓存数组g_dispBuf所有显示操作先改缓存再整体刷新到TM1640。这样做的好处是逻辑清晰特定位的开关、数字更新、点亮小数点都只改缓存里的对应字节不用担心TM1640内部状态被搞乱。static uint8_t g_dispBuf[16]; void TM1640_Clear(void) { memset(g_dispBuf, 0, sizeof(g_dispBuf)); TM1640_Refresh(); } void TM1640_Refresh(void) { uint8_t i; TM1640_Start(); TM1640_WriteByte(0x40); /* 自动地址递增模式 */ TM1640_Stop(); TM1640_Start(); TM1640_WriteByte(0xC0); /* 从首地址0xC0开始 */ for (i 0; i 16; i) { TM1640_WriteByte(g_dispBuf[i]); } TM1640_Stop(); } void TM1640_SetBrightness(uint8_t level) { if (level 7) level 7; TM1640_SendCommand(0x88 | level); /* 0x88关显示0x89~0x8F亮度1~7 */ }TM1640_Init里我会依次调用清屏和亮度设置void TM1640_Init(void) { /* 前面GPIO配置代码省略 */ TM1640_Clear(); /* 清掉上电随机RAM */ TM1640_SetBrightness(4); /* 亮度等级4看场景调 */ }3.4 段码表与共阴数码管的位序段码表是另一个高发翻车点。同样一个数字“0”共阴和共阳的段码完全不同同一共阴管如果段选接的是SEG9~SEG16前8段和后8段的位序也不同。我项目里用的是共阴数码管段选a~gdp依次接到SEG1~SEG8段码定义如下/* 共阴数码管段码表SEG1a, SEG2b, ..., SEG7g, SEG8dp 字节位bit0a, bit1b, ..., bit6g, bit7dp */ const uint8_t SEG_CODE[] { 0x3F, /* 0 */ 0x06, /* 1 */ 0x5B, /* 2 */ 0x4F, /* 3 */ 0x66, /* 4 */ 0x6D, /* 5 */ 0x7D, /* 6 */ 0x07, /* 7 */ 0x7F, /* 8 */ 0x6F, /* 9 */ 0x77, /* A */ 0x7C, /* b */ 0x39, /* C */ 0x5E, /* d */ 0x79, /* E */ 0x71, /* F */ };如果段选接在SEG9~SEG16这个表就要放到g_dispBuf[(pos-1)*2 1]这个奇数字节里。最稳妥的办法是在硬件事先统一约定数码管段选只接SEG1~SEG8奇数地址字节留给扩展LED这样代码不用做条件分支。显示一位数字的函数void TM1640_DisplayDigit(uint8_t pos, uint8_t num, uint8_t showDot) { uint8_t code; if (pos 1 || pos 8) return; if (num 15) num 0; code SEG_CODE[num]; if (showDot) code | 0x80; /* 点亮小数点 */ g_dispBuf[(pos - 1) * 2] code; /* 段码写入GRID的SEG1~SEG8字节 */ TM1640_Refresh(); }4. “关掉某一位”的本质与显示效果控制热搜词里有个很具体的问题“tm1640关掉某位显示”这是我最初也没想到会被反复搜的场景。其实搞清楚显示RAM布局后这个操作简单到只有一行代码。4.1 熄灭特定位、点亮带小数点的数字TM1640数码管某一位能不能显示取决于该GRID对应字节里写了什么。把对应字节写0这一位就彻底熄灭写段码就显示对应数字。所以“关掉某位显示”的实现是void TM1640_ClearGrid(uint8_t pos) { if (pos 1 || pos 8) return; g_dispBuf[(pos - 1) * 2] 0x00; /* 将GRID对应的SEG1~SEG8全部关闭 */ TM1640_Refresh(); }因为使用了显示缓存方案这种单点操作不会影响其他位的显示状态也不需要记住之前显示过什么。如果你不想用缓存也可以切到固定地址模式只向指定地址写0void TM1640_ClearGridFixed(uint8_t pos) { uint8_t addr; if (pos 1 || pos 8) return; addr 0xC0 (pos - 1) * 2; TM1640_Start(); TM1640_WriteByte(0x44); /* 固定地址模式 */ TM1640_Stop(); TM1640_Start(); TM1640_WriteByte(addr); TM1640_WriteByte(0x00); /* 只清这一位 */ TM1640_Stop(); }特别注意从自动递增模式切到固定地址模式或者反向切换必须发送对应的数据命令否则TM1640还停留在上一个模式里。这条容易在调试时被忽略。点亮带小数点的数字很简单在段码基础上或上0x80。比如显示“5.”TM1640_DisplayDigit(1, 5, 1); /* 第1位显示5并带小数点 */4.2 亮度等级调节与闪烁动画亮度调节已经写好了TM1640_SetBrightness(level)随时可调用。注意它的实现原理是PWM占空比所以亮度等级是全局的不是某一位单独调。如果产品需要“高亮主显区、暗显辅显区”靠TM1640本身做不到得在硬件上把不同GRID分开供电或用外部调光。闪烁效果也不需要定时器中断轮询扫描应用层定时翻转某个缓存字节再调Refresh就行。例如让第2位以250ms周期闪烁void app_timer_irq_1ms(void) { static uint16_t cnt 0; if (cnt 250) { cnt 0; blinkState !blinkState; if (blinkState) g_dispBuf[2] SEG_CODE[8]; /* 显示8 */ else g_dispBuf[2] 0x00; /* 熄灭 */ TM1640_Refresh(); } }这种写法把显示刷新和业务逻辑分离MCU的主循环可以专心处理其他事情。TM1640的优势在这一刻体现得特别明显芯片自己负责扫描刷新频率和视觉稳定性和MCU负载完全无关。4.3 多片TM1640扩展的正确姿势有人问能不能把多片TM1640串起来用我想说明确一点不行。TM1640的DIN是输入没有数据输出引脚做不了菊花链级联。多片扩展只有两种靠谱方案第一种是每片独立接一组CLK和DIN也就是MCU多分配4个GPIO。这种方案灵活两片TM1640各自独立控制互不干扰缺点是占引脚。第二种是用一个GPIO扩展芯片比如PCA9555或模拟开关把同一组CLK/DIN分时切换到多片TM1640相当于给每片加片选。虽然TM1640没有片选脚但你可以把DIN串在模拟开关后面不选中的片保持DIN悬空或固定电平。实际项目中如果8位数码管都不够用我通常不会再加TM1640而是直接换TM1629或MAX7219这类支持级联/SDO输出的芯片扩展性更好。选型不看单芯片容量要看整条产品线的扩展空间。5. 调试中真实踩过的坑与排查方法这一节全是真金白银换来的经验。驱动代码写起来不难难的是显示异常时的排查思路。我把这半年被问得最多、也是我自己踩过的几个坑集中列出来。5.1 乱码的元凶上电随机RAM与初始化顺序第一次上电时我的数码管出现了一堆毫无规律的点亮段看起来像灵异现象。后来翻手册才确认TM1640的显示RAM上电内容是不确定的如果不先清屏直接显示RAM里的随机值会立刻反映到数码管上。解决方法是把清屏放在初始化最前面而且要在发送任何显示控制命令之前完成。正确顺序是GPIO初始化 → 拉高CLK/DIN → 写0x40清屏命令或者直接发0xC0地址16个0字节 → 设置亮度 → 正常显示。我的TM1640_Init里已经把Clear放在SetBrightness之前就是为了避免乱码闪现。5.2 显示花屏与中断打断的保护策略另一个高频问题显示数据偶尔错乱一位数字突然变成乱码过一会儿又正常。排查到最后发现是中断惹的祸。GPIO模拟发送一个字节需要几百微秒期间如果有高优先级中断触发而且中断服务函数里恰好操作了DIN或CLK所在的GPIO时序就会被撕碎。最直接的方案是在Refresh发送期间关中断__disable_irq(); TM1640_Refresh(); __enable_irq();代价是全局关中断时间大约几百微秒对实时性要求极高的系统不友好。更好的方案是把发送延时时序压缩到纳秒级减少关中断窗口或者把CLK/DIN放到不常被中断服务程序碰的GPIO上。还有一个进阶思路用SPI外设的SCK和MOSI引脚模拟TM1640时序配合DMA后台发送MCU几乎不需要参与位级操作但这需要额外控制DIN引脚来制造起始/停止条件复杂度高了一截不是所有项目都值得。5.3 3.3V MCU驱动5V TM1640的电平兼容陷阱这个坑非常隐蔽。如果TM1640的VCC接5V而STM32是3.3V供电GPIO输出的高电平只有3.3V但TM1640在5V供电下的输入高电平阈值可能是0.7×VCC也就是3.5V左右。3.3V达不到阈值芯片可能偶尔能工作、偶尔失灵表现为“用手摸一下线就好松手又坏”。我的建议是TM1640的VCC直接用3.3V与STM32同电源逻辑电平完全匹配。如果应用场景必须要5V供电可以在CLK和DIN上串两个MOSFET电平转换器或者把STM32的GPIO配置为开漏输出外部上拉到5V。别指望推挽输出硬扛5VSTM32的引脚虽然多数耐5V但电平阈值问题解决不了。5.4 用逻辑分析仪快速验证时序调试TM1640最舒服的工具不是示波器而是逻辑分析仪几十块的就行。接上CLK和DIN在软件里设置触发边沿为DIN下降沿然后抓一次完整的Refresh过程重点确认三件事第一起始条件是不是CLK高时DIN下拉。第二每个字节是不是LSB先出观察第一个bit是不是0x01命令0x40就是0x40本身但数据0x01会表现为最高位最后一个出来。第三地址0xC0之后是不是跟着16个字节的数据中间没有多余的停止条件。如果你看到地址0xC0后面只跟了8个字节就停了那多半是有人按TM1637的8字节习惯写了循环次数把地址0xC0~0xC7这8个单元当成了8位数码管的段码。这个错误非常经典我见过至少三个开源项目栽在这里。另外一个小技巧逻辑分析仪还能顺便看DIN上有没有毛刺。毛刺通常来自供电波动或GPIO翻转速度过慢如果抓到的波形上升沿有明显台阶先查电源电容是不是不够再查GPIO速度配置是不是设成了LOW。把GPIO Speed设成HIGH50MHz等级之后台阶问题一般会消失。我个人的体会是TM1640驱动本身不难难的是把硬件布局、电平匹配、中断保护和RAM映射这些细节一次想清楚。宁可多花十分钟把索引规则写在代码注释里也别在焊完板子之后对着乱码猜原因。现在这套驱动已经被我整理成公司内部的公共模块换了三个项目、两代MCU引脚定义改一下就能跑算是把经验沉淀下来了。