先交代一下背景:最近刚好要做一个小项目,需要一块能实时显示转速和计数值的屏幕,翻了一圈手头的库存,最后选了STM32F103最小系统板加TM1637数码管显示模块。这套组合在嵌入式圈子里非常常见,发光醒目、接线简单、代码量又不大,尤其适合做速度、温度、计数值这类数字型数据的直观展示。我这次把完整的驱动方法、接线细节和调试经验都整理出来,给正准备用STM32F103和TM1637数码管做显示的朋友一个可以直接抄作业的参考。
TM1637这个模块很多人第一反应是“Arduino用的”,其实它在STM32上一样好使。它的本质是一个带键盘扫描接口的LED驱动控制芯片,通过两根线(CLK时钟、DIO数据)就能驱动最多8位共阴数码管,单片机只需要按协议把要显示的段码发过去,剩下的动态扫描、刷新、限流都由芯片自己完成,CPU负担极小。对STM32F103这种主频72MHz的芯片来说,驱动TM1637简直绰绰有余,而且用普通GPIO模拟时序即可,不需要占用任何片上外设。
这篇文章适合谁看?一种是刚接触STM32、想做一个能真正跑起来的上手项目的初学者,另一种是项目里需要显示模块但不想上OLED、LCD这类带缓存和字库的屏、只想要低成本高亮显示的开发者。我会从芯片工作原理讲到硬件接线,再给出一套可直接编译运行的完整驱动,最后把调试中容易踩的坑都列出来。跟着走一遍,你基本能彻底搞清楚这类两线制显示驱动的套路。
1. 项目整体设计与思路拆解
1.1 为什么是STM32F103加TM1637
先说选型逻辑。STM32F103这颗芯片在国产开发板和工业产品里出货量极大,原因很简单:便宜、耐造、资料全。手头这块最小系统板是STM32F103C8T6,72MHz主频,20K RAM,64K Flash,GPIO支持推挽、开漏、复用等多种模式,完全能满足驱动TM1637的需求。
TM1637的选型则更看重它的几个特点,这几点在你做方案对比时也值得记住:
- 两线制通信,比直接用74HC595移位寄存器省线,比并行8根数据线更是省了一大截,在需要穿过转接板或者做小体积产品时非常友好。
- 自带动态扫描逻辑。普通数码管需要单片机不断刷新每位,占用CPU时间;而TM1637内部有驱动电路和恒流源,单片机只要把段码写入芯片内部的显示寄存器,芯片就会自动扫描点亮数码管,CPU完全不用管刷新。
- 支持级联扩展。一个TM1637最多可以驱动4位或6位数码管(取决于模块设计),足够绝大多数数字显示场景。
这一套组合在成本和效果上平衡得很好,整片芯片在市场上的价格比一个普通四位共阴数码管还要便宜,模块化产品也不过几块钱,做出来效果又比单颗数码管好看得多。
1.2 TM1637通讯协议和I2C的异同
我第一次看TM1637数据手册的时候,第一反应是“这和I2C差不多嘛”。确实,TM1637的时序大量借鉴了I2C,但它并不是标准的I2C协议,这个差异直接决定了你不能用STM32的硬件I2C外设直接驱动,必须用GPIO模拟。
两者的区别主要在三点:
- 标准I2C有器件地址,通信前要先发从机地址;TM1637没有地址概念,数据直接发给固定内部寄存器。
- I2C每传输一个字节后从机回ACK(数据线拉低应答);TM1637也有应答,但应答发生在第9个时钟周期的下降沿,而且它还不要求在停止信号前必须有NACK。
- I2C总线上可以挂多个器件,靠地址区分;TM1637是独占总线型,一主一从一对一通信。
因此动手之前脑子里必须有这个认知:网上照搬别的I2C设备的驱动代码是行不通的,必须按照TM1637自己的时序图来写。这也是为什么很多新手直接套I2C驱动、最后发现数码管毫无反应的原因。
1.3 整体方案和资源占用预估
本项目不占用USART、不占用定时器、不占用I2C外设,只用了两个普通GPIO口(我这里定义为PA1和PA2),这样安排之后,串口还能留着打调试信息,定时器还能做测速输入捕获,端口资源非常宽裕。
数据刷新的时间开销也小得可以忽略不计:TM1637的CLK频率一般建议控制在250kHz以下,我们软件模拟时每bit延时10微秒左右,一个字节8bit加上起始停止和应答,总共大约120到150微秒,显示四位数字两个字节也就300微秒上下,对主循环而言这点时间根本感知不到。
2. 硬件准备与接线细节
2.1 需要的器材清单
搭建这套系统需要的硬件极少,具体如下:
| 器件 | 型号/规格 | 数量 |
|---|---|---|
| 主控板 | STM32F103C8T6最小系统板 | 1 |
| 显示模块 | TM1637四位数码管模块(带小数点) | 1 |
| 杜邦线 | 母对母 | 4根 |
| 供电 | 5V USB线(给开发板供电即可) | 1 |
| 可选 | USB转TTL调试模块(串口调试用) | 1 |
STM32F103C8T6的GPIO输出能力在3.3V逻辑下驱动TM1637完全没问题,但模块本身为了驱动LED,电源脚最好是接到5V。这是因为TM1637内部虽然有恒流驱动,但模块上的数码管如果是共阴设计,段电流还是从VCC经过内部寄存器流出去的,供电电压太低会影响亮度一致性。如果你手上只有3.3V供电,也能点亮,但多位同时显示时亮度会明显不均匀,尤其是百位和个位这种离电源远端的位置。
2.2 具体接线对照表
下面是我实际测试时使用的接线方案,引脚号和模块丝印一一对应:
| TM1637模块引脚 | 连接到STM32F103 | 说明 |
|---|---|---|
| VCC | 5V引脚(或3V3,见注意事项) | 模块电源,建议5V |
| GND | GND | 必须共地 |
| CLK | PA1 | 时钟线,开漏输出加上拉 |
| DIO | PA2 | 数据线,开漏输出加上拉 |
注意CLK和DIO可以接到任意带外部中断或普通输入功能的GPIO,并不局限于PA1/PA2。我这里选PA1和PA2纯粹是这两脚在最小系统板上比较顺路,方便杜邦线走线。你若想用PB口或者其他空闲引脚,只要在代码里改两个宏定义即可。
2.3 硬件上最容易踩的几个坑
第一,务必共地。这句话几乎每次都要强调,但每次还是有人踩。开发板用USB供电,模块用独立电源,如果你不把两边的GND接在一起,CLK和DIO上的高低电平参考点就不一致,数据通信必然出错。表现为:数码管时而乱码、时而闪烁、甚至完全没反应。
第二,5V还是3.3V要分清。我在测试中发现很多标注“3.3V-5V兼容”的TM1637模块,其内部并没有做电平转换,只是靠开漏加上拉电阻来适应不同电压。STM32F103的GPIO耐压是5V,所以即使DIO被模块上拉到5V也不至于烧引脚,但如果你用3.3V供电的模块配上5V上拉,长时间运行有几个场景还是会有风险。稳妥方案是:模块电源用5V,但CLK和DIO用开漏输出,内部上拉或者外部上拉到3.3V,这样逻辑电平与STM32 GPIO完全一致。
第三,杜邦线接触不良问题比想象中高发。TM1637的CLK频率虽然不高,但杜邦线插得松的时候,每一次晃动都可能导致瞬间掉线,芯片误判为停止信号,显示随机跳变。排查的时候可以先用力按一下杜邦线两头,如果显示恢复正常,大概率就是接触问题。
第四,不要在没接限流电阻的情况下让所有段长时间全亮。模块本身设计有驱动电流,一般每位数码管段电流在10mA左右,可如果模块上用的是高亮共阴数码管且没有做限流处理,长时间全亮会导致芯片发烫。正常显示数字没毛病,但你写测试程序让所有位都显示“8.”的时候,最好过几秒就停一下。
3. 核心驱动逻辑与代码实现
3.1 TM1637通信时序拆解
TM1637的一次完整数据交互分为起始、数据传输、应答、停止四个阶段。每个阶段的时间都需要满足芯片时序要求,我结合手册和实测给出下面的推荐参数:
| 阶段 | 操作 | 时间要求 |
|---|---|---|
| 起始 | CLK拉高,DIO从高拉低,CLK再拉低 | 建立时间约5us,保持时间约5us |
| 数据写入 | 每bit在CLK低电平期间改变DIO,CLK高电平期间保持 | 高低电平各约5到10us |
| 应答 | 第9个CLK脉冲,芯片将DIO拉低 | 约10us |
| 停止 | CLK拉高,DIO在CLK高电平期间从低拉高 | 约5us |
数据字节都是低位在前发送,这点非常关键,如果你写成高位在前,整个显示内容会完全不对,而且在某些段码下可能表现为个别位乱码。
TM1637的指令分三类,都是单字节:
- 数据命令设置:0x40表示写入显示寄存器,地址自动加1;0x44表示固定地址写数据;0x42表示读按键扫描。
- 地址命令:0xC0到0xC5,对应显示寄存器的第0到第5地址。四位数字的话,从0xC0开始连续写4个字节即可。
- 显示控制命令:0x88到0x8F。低三位是亮度等级,0x88最暗,0x8F最亮,其中0x88到0x8B其实是占空比1/16到10/16,0x8C到0x8F是更高的亮度档。实际用下来,0x8B到0x8F这段肉眼亮度差异不大,但功耗差异明显。
我的做法是显示前先发0x40数据命令,再发0xC0地址命令,然后连续发送N个段码字节,最后发0x8F显示控制命令开显示并设置亮度。每一步之间首尾相连,形成一次完整通信。
3.2 段码表与显示数据处理
TM1637模块上的数码管基本都是共阴结构,段码对应关系是:段a是最低bit,段b次低bit,一直到段g和段dp,也就是bit7对应小数点。
常用的0到9段码表如下:
| 字符 | 段码(无小数点) | 段码(带小数点) |
|---|---|---|
| 0 | 0x3F | 0xBF |
| 1 | 0x06 | 0x86 |
| 2 | 0x5B | 0xDB |
| 3 | 0x4F | 0xCF |
| 4 | 0x66 | 0xE6 |
| 5 | 0x6D | 0xED |
| 6 | 0x7D | 0xFD |
| 7 | 0x07 | 0x87 |
| 8 | 0x7F | 0xFF |
| 9 | 0x6F | 0xEF |
除了数字,TM1637还能显示A到F、H、L、P、U等字母,做十六进制显示或者简单提示非常实用。冒号显示则需要看模块是否设计了第2位的dp段,有些四位模块中间是带冒号的,你只需要把对应位段码或上0x80即可。
3.3 完整驱动代码实现
下面给出我调试通过的完整驱动,基于STM32标准外设库,如果你用HAL库,只需要把GPIO读写部分替换成HAL_GPIO_WritePin即可,协议逻辑完全通用。
先定义端口宏:
#define TM1637_CLK_PORT GPIOA #define TM1637_CLK_PIN GPIO_Pin_1 #define TM1637_DIO_PORT GPIOA #define TM1637_DIO_PIN GPIO_Pin_2 #define TM1637_CLK_HIGH() GPIO_SetBits(TM1637_CLK_PORT, TM1637_CLK_PIN) #define TM1637_CLK_LOW() GPIO_ResetBits(TM1637_CLK_PORT, TM1637_CLK_PIN) #define TM1637_DIO_HIGH() GPIO_SetBits(TM1637_DIO_PORT, TM1637_DIO_PIN) #define TM1637_DIO_LOW() GPIO_ResetBits(TM1637_DIO_PORT, TM1637_DIO_PIN)GPIO初始化这里有个非常关键的细节:CLK和DIO都要配置为开漏输出并打开内部上拉。这样可以避免输出高低电平时的电流灌入问题,也让DIO在读应答时可以直接读取引脚电平。
void TM1637_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin = TM1637_CLK_PIN | TM1637_DIO_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_OD; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); TM1637_CLK_HIGH(); TM1637_DIO_HIGH(); }延时函数用简单的循环即可,不需要上定时器:
static void TM1637_Delay_us(uint32_t us) { uint32_t i; for (i = 0; i < us * 8; i++) { __NOP(); } }这个延时是根据72MHz主频估算的,8次空操作大约1微秒,实际效果和编译器优化等级有点关系,调试时若发现通信不稳定,可以适当把系数调大。
起始和停止信号:
static void TM1637_Start(void) { TM1637_CLK_HIGH(); TM1637_DIO_HIGH(); TM1637_Delay_us(5); TM1637_DIO_LOW(); TM1637_Delay_us(5); TM1637_CLK_LOW(); } static void TM1637_Stop(void) { TM1637_CLK_LOW(); TM1637_Delay_us(5); TM1637_DIO_LOW(); TM1637_Delay_us(5); TM1637_CLK_HIGH(); TM1637_Delay_us(5); TM1637_DIO_HIGH(); TM1637_Delay_us(5); }写一个字节并等待应答:
static uint8_t TM1637_WriteByte(uint8_t dat) { uint8_t i; uint8_t ack; for (i = 0; i < 8; i++) { TM1637_CLK_LOW(); TM1637_Delay_us(2); if (dat & 0x01) { TM1637_DIO_HIGH(); } else { TM1637_DIO_LOW(); } TM1637_Delay_us(2); TM1637_CLK_HIGH(); TM1637_Delay_us(2); dat >>= 1; } TM1637_CLK_LOW(); TM1637_Delay_us(2); TM1637_DIO_HIGH(); // 释放DIO,等待从机拉低应答 TM1637_Delay_us(2); TM1637_CLK_HIGH(); TM1637_Delay_us(2); ack = GPIO_ReadInputDataBit(TM1637_DIO_PORT, TM1637_DIO_PIN); TM1637_Delay_us(2); TM1637_CLK_LOW(); return ack; }注意写入数据的循环里,每一位数据必须在CLK为低电平时准备好DIO状态,然后CLK拉高让芯片采样,顺序不能反。这也是新手最容易写错的地方,写反了会导致整个传输完全错位。
显示控制接口:
void TM1637_Display(uint8_t *buf, uint8_t len, uint8_t brightness) { uint8_t i; TM1637_Start(); TM1637_WriteByte(0x40); // 数据命令:写显示寄存器,地址自动加1 TM1637_Stop(); TM1637_Start(); TM1637_WriteByte(0xC0); // 地址命令:从0地址开始 for (i = 0; i < len; i++) { TM1637_WriteByte(buf[i]); } TM1637_Stop(); TM1637_Start(); TM1637_WriteByte(0x88 | brightness); // 显示控制命令:开显示并设置亮度 TM1637_Stop(); }主函数里的测试代码:
uint8_t seg_data[4]; uint16_t count = 0; int main(void) { SystemInit(); TM1637_GPIO_Init(); while (1) { seg_data[0] = SEG_TABLE[count / 1000]; seg_data[1] = SEG_TABLE[(count / 100) % 10]; seg_data[2] = SEG_TABLE[(count / 10) % 10]; seg_data[3] = SEG_TABLE[count % 10]; TM1637_Display(seg_data, 4, 0x07); count++; if (count > 9999) { count = 0; } TM1637_Delay_us(50000); } }段码表自己在头文件里定义一份数组就行,把上面表格里的值填进去。测试下来从0000一直加到9999,循环显示非常稳定。
3.4 代码细节的几个为什么
有人可能会问,为什么GPIO不用推挽输出而要用开漏?原因一是开漏输出时高电平靠上拉电阻实现,DIO在读应答时不会因为外部拉低而和内部驱动打架;二是模块上如果还有其他设备,开漏更方便实现线与逻辑。哪怕你这个项目里只有一块TM1637,也建议保持开漏,这套代码以后复用到别的项目不需要改,能避免不少隐藏问题。
还有一点值得说的是延时参数。网上很多人写的TM1637延时用的是delay(5),实际上不同的STM32主频和优化等级下,同样的循环次数产生的真实延时差别很大。我的经验是:先把延时调大一点,比如每步15微秒,确认通信正常后再逐步减小,找到你自己的板子能稳定工作的最小值。这样既保证了可靠性,又能把刷新时间压到最短。
4. 调试过程与常见问题排查实录
4.1 从零开始点亮一块数码管的调试顺序
很多新手一上来就把四位数全显示,出了问题完全不知道从哪里查。我习惯从简单到复杂分步调:
第一步,只显示一位。给地址0xC0发送一个0x3F,如果显示“0”,说明起始、停止、写字节、地址命令这条链路都是通的。如果这位都不亮,优先检查电源和CLK/DIO接线。
第二步,设置亮度。把显示控制命令从0x88到0x8F挨个试一遍,观察亮度变化。如果亮度完全没变化,多半是显示控制命令没发出去,或者模块的亮度调节引脚被硬件固定住了。
第三步,连续显示。用自动地址加1模式连续写两位,确认地址自动递增功能正常,然后扩展到四位。
用这个顺序调,通常半小时内能定位到问题。
4.2 常见问题速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 数码管完全无显示 | 供电没有、共地没接、CLK/DIO接反 | 先用万用表测量模块VCC和GND之间是否有电压 |
| 显示内容随机乱码 | 段码表用错、高低位顺序不对、杜邦线接触不良 | 显示固定值0x3F,确认一位正常后再扩展 |
| 亮度不均匀 | 模块供电不足、上拉电阻阻值偏差 | 改用5V供电并检查接线电阻 |
| 时而显示时而不显示 | 初始化和发送间隔过近、干扰造成通信错位 | 上电后延时50ms以上再做第一次显示 |
| 显示暗淡 | 显示控制命令没有正常设置 | 检查0x88到0x8F命令是否发送 |
| 数字对但小数点错 | 段码bit7处理错误 | 确认module的dp段是否接在bit7 |
还有一个特别隐蔽的坑,我在实际项目中遇到过:TM1637模块和主控板共用一个5V电源,但电源是USB口直接供过来的,当数码管全部点亮时电流突然增大,USB口压降过大导致STM32F103复位。表现为:接上数码管后开发板反复重启,拔掉数码管就正常。解决方法是给数码管单独供电,或者选择输出电流更大的USB口,从根源上解决电源跌落的问题。
4.3 和STM32F103其他外设协同排查的经验
这里结合一下平时被问得比较多的几个场景。比如用STM32F103的串口1和串口3做调试信息输出时,如果串口调试信息偶尔乱码,而同时又在刷TM1637显示,很可能是两个外设共用了一个中断优先级处理不当造成的。TM1637如果显示函数里正好被串口中断打断,通信时序会被拉长,虽然不一定会出错,但如果你在中断服务函数里调用了显示函数,那就很容易出问题。我的建议是:显示函数不要放在中断里,主循环每隔一段时间刷新即可。
再比如有人喜欢用TM1637配合Modbus协议做远程显示。Modbus的帧接收通常用串口中断加定时器判断帧间隔,如果显示刷新也放在主循环里,完全没问题。但如果你在串口接收中断里处理完一帧后立刻调用TM1637显示,就有可能导致主循环里的其他任务被延迟。正确做法是把接收到的数据存入缓冲区,主循环解析之后再刷显示,保持显示和通信解耦。
4.4 用示波器验证时序
调试嵌入式外设,逻辑分析仪或者示波器几乎是必杀技。把CH1夹在CLK,CH2夹在DIO,观察通信波形:起始信号应该是CLK高电平时DIO从高拉低,停止信号是CLK高电平时DIO从低拉高。数据位则是CLK高电平期间DIO保持稳定,CLK低电平期间DIO变化。
如果波形里DIO的电平变化正好发生在CLK上升沿附近,说明你的代码时序余量太小,要么加大延时,要么检查代码里CLK和DIO的操作顺序。很多网上搬运的代码在上述顺序上存在差异,用示波器一眼就能看出来谁对谁错。
5. 扩展玩法与应用场景提升
5.1 用TM1637做时钟和秒表
如果你觉得只显示数字太单调,可以把TM1637模块和STM32F103的定时器结合起来做一个电子时钟。STM32F103内部的RTC或者定时器产生秒脉冲,主循环里读取时间并刷新显示,TM1637只需要处理显示,秒表走时完全交给定时器。
需要注意的是,四位TM1637显示时间时分和分秒时需要处理分隔逻辑:比如显示“12:30”时,难点在于中间的冒号不是独立的显示单元,而是第二位和第三位数字之间的小数点。具体实现时把第二位的dp段置1即可,段码或上0x80。刷新的时机放在分钟变化时更新一次,如果秒也在显示则每秒刷新一次,也不会有什么负担。
5.2 和按键组合实现参数设置
TM1637还有很多模块型号自带按键扫描功能,它的数据命令0x42是读按键扫描,可以配合TM1637自身的键盘扫描接口实现按键输入。不过MCU直接接独立按键也很快,不一定非得用芯片自带的键盘功能,因为按键消抖、编码这些最终还是得自己写。
我用这个方案做过一个简易的定时器,两个按键分别调整设定值和启动停止,数码管实时显示剩余秒数。整套系统跑下来非常稳定,数码管的高亮特性在环境光很强的车间里也清晰可见,这也是TM1637比LCD、OLED在很多工业场景下更实用的原因。
5.3 显示数据和测速模块联动的参考方案
如果你后面做小车测速或者电机转速测量,STM32F103的定时器输入捕获模块采集霍尔编码器或者光电码盘脉冲,主循环里换算成转速,然后送往TM1637显示。这个方案性能上是完全的降维打击,即使转速高达每秒几千转,数码管也只需要每秒刷新20次,CPU占用率不到1%。
在做这类联动时记得给显示刷新留一个比较宽裕的间隔,不要每几毫秒刷一次,那样不仅数码管肉眼看起来没有差别,还会让其他任务时不时被打断。显示刷新帧率有个20到30Hz就足够了,人眼感知已经非常流畅。
5.4 为什么不是OLED
很多新手会问,OLED显示模块也不贵,为什么不直接上OLED?OLED的优势是显示内容灵活、支持汉字和图形,缺点是成本略高、功耗略大、在阳光下可视性差。TM1637数码管的硬伤是不能显示复杂内容,但优势恰恰是简单、抗干扰、发光效率高,在工控表头、设备计数器这类场景,数码管这种“土办法”反而是最可靠的选择。所以做方案选型的时候,不要只看参数,先问一句:你到底需要这块屏幕显示什么?数字多,就数码管;内容复杂,再上OLED。
6. 最后再分享两个实用技巧
第一个技巧是模块上电后不要立即进行第一次显示操作。TM1637内部上电复位需要一点时间,如果你在STM32F103初始化完后立刻调用显示函数,芯片可能还没准备好,导致第一次写入无效,后续数据又全部错位。稳妥做法是在主函数开头加一个50到100毫秒的延时,让模块稳定了再通信。别小看这个延时,很多“时好时坏”的问题就是它引起的。
第二个技巧是关于固件升级的:如果你后续要芯片通过串口协议接收数据并显示,建议给显示命令加一个简单的帧头校验。TM1637本身没有数据校验机制,外部干扰可能让段码寄存器里的内容变成随机值,你会看到数码管突然跳出个奇怪字符。虽然主循环持续刷新能自动恢复,但如果刷新频率低,错误可能停留好几秒。我在实际项目里是把显示刷新放在主循环的固定槽位,并且每次刷新都用当前最新数据,这样即使偶尔出错,下一个刷新周期就会覆盖掉,完全不影响观感。
这套STM32F103加TM1637的小项目,调试完之后给我最大的感受是:数码管驱动这种活看起来简单,但把时序吃透、把供电和重置时序这些细节处理好之后,稳定性和可维护性会有质的提升。如果你按照这篇文章把驱动跑通,后续再接触其他两线制LED驱动芯片,比如类似协议的显示芯片,你会发现很多套路都是通用的,直接改改寄存器和命令字节就能上手。
希望这篇记录能帮你少走点弯路。