STM32F103 GPIO模拟时序驱动TM1637四位数码管:从协议到代码
简介面向嵌入式初学者的STM32F103驱动TM1637芯片控制4位数码管工程资料包解决MCU与显示驱动芯片间的时序配合问题。资源基于STM32F103C8T6通过GPIO口模拟DIO与CLK时序讲解启动停止信号、段码位码逐位发送、循环显示等完整流程并配套可直接编译运行的Keil工程。压缩包共79个文件以C源码31个.c、头文件32个.h和启动汇编8个.s为主另有hex固件、uvprojx工程文件与功能说明文档整体仅322KB轻量便于下载。已有6055人学习浏览。资料内Project、User、Libraries、Doc、Listing等目录结构清晰含SysTick延时、LED控制等外设驱动实例可帮助理解STM32标准库的代码组织方式读者既能直接烧录hex查看效果也能基于源码扩展亮度调节、滚动显示等功能适合课程设计或入门实践参考。1. 两个GPIO点亮四位数码管STM32F103与TM1637为什么是常用组合第一次用 STM32F103 驱动四位数码管时我走了不少弯路直接用 GPIO 怼段选和位选几个引脚不够用换 74HC595 又得调移位时钟代码写得又长又绕。后来换成 TM1637 芯片驱动 4 位数码管方案一下子简单到只剩两根线——时钟线 CLK 和数据线 DIO剩下的就是软件模拟两线时序。这个组合特别适合温控表头、计数器、可调电源显示这类小显示需求也适合刚入门想快速点亮数码管的人。这篇笔记把通信时序、接线、驱动代码和踩过的坑一次性整理清楚照着抄基本能一遍跑通。2. 通信时序剖析TM1637不是I2C别拿硬件外设去套很多第一次用 TM1637 的人会把它当成 I2C 设备直接打开 STM32F103 的硬件 I2C 外设去发数据结果要么不出数要么偶尔出乱码。这不能全怪芯片TM1637 的接口只能算「和 I2C 长得像」帧结构、应答方式、地址机制全都不一样。想把这颗芯片用稳最好把它的协议从头到尾捋一遍。2.1 三个命令寄存器和数据帧结构TM1637 的通信由一组 8 位命令字控制日常驱动四位共阴数码管只需要用到三组命令范围作用说明0x40 / 0x44数据模式设置0x40 表示写数据后地址自动递增0x44 表示固定地址写入0xC0 ~ 0xC3显示地址对应四位数码管0xC0 是第一位0xC3 是第四位0x80 ~ 0x8F显示控制bit3 控制显示开关低 3 位控制亮度 0~7每次传输都从 Start 开始、Stop 结束。Start 条件是 CLK 高电平时 DIO 从高拉低Stop 条件是 CLK 高电平时 DIO 从低拉高。每一帧可以带一个命令字节、一个地址字节、若干数据字节字节之间由第 9 个时钟的应答位隔开。常见的完整写数过程是Start - 0x44 - Stop - Start - 0xC0地址 - 段码数据 - Stop。这套流程并不复杂但每一个字节的位时序都得按芯片的要求来。2.2 时钟与应答CLK频率上限和ACK读取TM1637 对 CLK 的翻转速度有限制软件模拟时反而不是越快越好。数据在 CLK 上升沿被采样所以写数据时必须先把 CLK 拉低再去改变 DIO 电平等数据稳定后再拉高 CLK。如果 CLK 上升沿来得太快DIO 上的数据还没来得及稳定芯片采到的就是上一次的旧电平显示自然就乱了。时序参数含义建议值CLK 高电平时间采样窗口≥ 3usCLK 低电平时间数据切换窗口≥ 3usDIO 建立时间DIO 变化到 CLK 上升沿的间隔≥ 2us应答采样第 9 个时钟DIO 为低表示 ACK读取 IDR很多现成例程把 CLK 高低电平延时做到 5us 级别这样每个字节约 90us刷新 4 位显示一次不到 0.5ms对 F103 的 72MHz 主频来说占用很小。如果你在主循环里每秒刷新 100 次CPU 也只花掉 5% 左右的时间完全够用。应答位是另一个关键点。每发送完一个字节第 9 个时钟期间 DIO 会切换为输入方向芯片通过拉低 DIO 表示收到了数据。读回来的值如果是 0说明 ACK 正常如果一直是 1就是通信没建立起来。这个细节很多人忽略导致代码能显示但心里没底后面避坑章节会专门说。2.3 为什么不用STM32F103的硬件I2C外设F103 的硬件 I2C 外设确实存在但 TM1637 这套协议并不适合直接套用。原因有三个。TM1637 没有从机地址这个概念。I2C 总线靠设备地址区分挂在同一根线上的多个设备硬件 I2C 外设在发送数据前会自动带上地址字节可是 TM1637 压根不认地址多出来的地址字节会被当成普通数据整个帧就错位了。TM1637 的应答行为也和标准 I2C 不一样硬件外设的 ACK 管理逻辑没法完全对上。再加上 F103 硬件 I2C 的库实现在状态机处理上有不少让人头疼的边角问题与其去绕弯子不如直接宣布GPIO 模拟时序代码量更小行为完全可控。我一般把 CLK 和 DIO 分配到两个普通 GPIO用宏定义做主函数里的「软件 I2C」原语后面想换引脚只需要改宏。3. 接线与初始化GPIO选型、上拉电阻和微秒延时基准如果说协议是软件的根那接线和初始化就是硬件的地基。TM1637 模块常见的物理接口只有 4 个脚VCC、GND、CLK、DIO。映射到 STM32F103 上怎么选引脚、要不要上拉、延时基准怎么搭这三个问题决定了后面能不能稳定复现。3.1 引脚分配与供电PA0/PA1、3.3V共地我推荐把 CLK 接到 PA0DIO 接到 PA1原因很简单这两个引脚在 F103 最小系统板上最容易引出而且默认不占用 JTAG、串口和 LED 资源。接线表如下模块引脚接 F103说明VCC3.3V与 MCU 同一路电源保证电平一致GNDGND共地CLKPA0时钟信号始终推挽输出DIOPA1数据信号读取 ACK 时切换输入供电这里有一个隐藏风险。TM1637 芯片本身支持较宽的供电范围市面上不少模块为了兼容 Arduino 的 5V IO会把 VCC 接到 5V。但 STM32F103 的 PA0/PA1 不是 5V 容忍引脚如果模块 VCC 接了 5VDIO 和 CLK 的高电平就会超过 3.3V长期跑可能损伤 GPIO。稳妥做法是模块和单片机统一用 3.3V 供电两个引脚之间不会出现电平打架。另外模块板上通常已经焊了上拉电阻到 VCCDIO 空闲时为高这一点对接下来的方向切换很重要先确认你的模块上有这两个电阻一般 10k 左右。3.2 GPIO初始化与DIO方向切换TM1637 的 DIO 是双向口写数据时是输出读应答时是输入。所以初始化要分两步走先让 CLK 和 DIO 都处于推挽输出状态上电初始电平都拉高然后在每次读应答前临时把 DIO 切到输入读完再切回输出。#define TM1637_CLK_PIN GPIO_Pin_0 #define TM1637_DIO_PIN GPIO_Pin_1 #define TM1637_GPIO_PORT GPIOA void TM1637_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // CLK 始终为推挽输出 GPIO_InitStructure.GPIO_Pin TM1637_CLK_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); // DIO 先设为输出读写时序里再动态切换 GPIO_InitStructure.GPIO_Pin TM1637_DIO_PIN; GPIO_Init(GPIOA, GPIO_InitStructure); // 初始状态CLK 和 DIO 都拉高 GPIO_SetBits(GPIOA, TM1637_CLK_PIN | TM1637_DIO_PIN); }GPIO 速度选择 50MHz 是为了让翻转足够快不至于在长线连接时把方波变成三角波。DIO 切换输入时不要用浮空输入而要用上拉输入原因前面提过模块上拉电阻到 VCC空闲时 DIO 就是高电平输入模式下读取 IDR 才能稳定判断应答位。void TM1637_DIO_SetInput(void) { GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin TM1637_DIO_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; GPIO_Init(GPIOA, GPIO_InitStructure); }切换到输入模式后再读 GPIOA-IDR 的第 1 位。优先读 IDR 而不是 ODR因为 ODR 是输出数据锁存器在输入模式下读到的是最后一次写入的值不具有实际电平意义。这个细节如果不注意会出现「明明 DIO 已经被芯片拉低程序却读到一个 1」的诡异现象。3.3 微秒延时DWT比空循环靠谱STM32F103 主频 72MHz一条普通指令大约 14ns。用 for 循环做微秒级延时循环体里几条指令加起来的耗时很容易被编译器优化掉或者在不同优化等级下表现完全不同。用 DWT 数据观察点计数器实现延时可以做到和主频严格同步。static void TM1637_DelayUs(uint32_t us) { DWT-CYCCNT 0; while (DWT-CYCCNT (SystemCoreClock / 1000000U) * us); } void TM1637_DWT_Init(void) { CoreDebug-DEMCR | CoreDebug_DEMCR_TRCENA_Msk; DWT-CYCCNT 0; DWT-CTRL | DWT_CTRL_CYCCNTENA_Msk; }DWT 使能只需要在系统初始化时调用一次。之后的延时函数就能精确到微秒。如果项目里已经用了 SysTick 做系统 tick不想再占用 DWT也可以用 SysTick 的校准值推算但代码会绕一些。我自己的习惯是凡是 GPIO 模拟时序的外设一律用 DWT 做延时基准排错时少一个变量。4. 驱动代码落地应答读取、段码表与显示API协议理解了接线确认了延时基准也有了接下来就是把每一拍时序翻译成代码。这一章给出可以直接抄进工程的完整驱动按「时序原语 - 显示控制 - 段码表 - 上层显示函数」四层拆开每层都说明为什么这么写。4.1 时序原语Start、Stop与写字节Start 和 Stop 是两个最底层的函数所有通信都由它们包裹。Start 在 CLK 高电平期间把 DIO 拉低Stop 在 CLK 高电平期间把 DIO 拉高之前提过的起始和停止条件在这里落地。static void TM1637_Start(void) { TM1637_DIO_SetOutput(); CLK_HIGH(); DIO_HIGH(); TM1637_DelayUs(3); DIO_LOW(); // CLK 高时 DIO 由高到低产生起始条件 TM1637_DelayUs(3); CLK_LOW(); } static void TM1637_Stop(void) { TM1637_DIO_SetOutput(); CLK_LOW(); DIO_LOW(); TM1637_DelayUs(3); CLK_HIGH(); // CLK 高时 DIO 由低到高产生停止条件 TM1637_DelayUs(3); DIO_HIGH(); TM1637_DelayUs(3); }每次 Start 和 Stop 之前先把 DIO 切回输出模式是为了避免上一次读应答后 DIO 还停留在输入状态导致下一次写数据时电平不受控。CLK 拉低的时机要保证在 DIO 变化之前完成这样 DIO 上的电平变化是在 CLK 低电平期间发生的符合 TM1637 的采样逻辑。写一个字节的核心循环比较直观8 个数据位低位先发。每发一位前先拉低 CLK、设置 DIO、延时建立、再拉高 CLK、延时保持。第 8 位结束后进入第 9 个时钟读应答。static uint8_t TM1637_WriteByte(uint8_t data) { uint8_t i; uint8_t ack; TM1637_DIO_SetOutput(); for (i 0; i 8; i) { CLK_LOW(); if (data 0x01) { DIO_HIGH(); } else { DIO_LOW(); } TM1637_DelayUs(3); CLK_HIGH(); TM1637_DelayUs(3); data 1; } // 第 9 个时钟DIO 切换输入读取 ACK CLK_LOW(); TM1637_DIO_SetInput(); TM1637_DelayUs(3); CLK_HIGH(); TM1637_DelayUs(3); ack (GPIO_ReadInputDataBit(GPIOA, TM1637_DIO_PIN) Bit_RESET) ? 0 : 1; CLK_LOW(); TM1637_DIO_SetOutput(); TM1637_DelayUs(3); return ack; // 返回 0 表示收到 ACK返回 1 表示无应答 }注意 data 是逐步右移的每次判断最低位这对应 TM1637 的 LSB first 时序。读应答的窗口在第 9 个时钟的高电平期间DIO 被芯片拉低时读到 Bit_RESET。ACK 之后必须把 DIO 立即切回输出否则下一次 Start 的起始条件无法正确产生。4.2 固定地址模式与亮度控制显示单个数字用固定地址模式最直观每次指定第几位就只写那一位的段码。这样更新某一位时不会影响其他三位也方便后续做低功耗。void TM1637_Display(uint8_t addr, uint8_t seg) { TM1637_Start(); TM1637_WriteByte(0x44); // 固定地址模式 TM1637_Stop(); TM1637_Start(); TM1637_WriteByte(0xC0 | (addr 0x03)); // 显示地址 0xC0 ~ 0xC3 TM1637_WriteByte(seg); // 段码数据 TM1637_Stop(); }0x44 这个命令告诉芯片接下来每次写数据都要带上明确地址地址不会自增。如果你希望一次写完四位可以把 0x44 换成 0x40然后 0xC0 之后连续写 4 个段码字节地址自动递增。两种模式都能用但固定地址模式在只改一位时更省时后面低功耗章节还会用到。亮度控制和清屏也是两条命令的事void TM1637_SetBrightness(uint8_t brightness, uint8_t on) { uint8_t cmd 0x88; if (on) { cmd | 0x08; // bit3 1 开显示 } cmd | (brightness 0x07); // 低 3 位亮度 0~7 TM1637_Start(); TM1637_WriteByte(cmd); TM1637_Stop(); }0x88 是显示控制命令的基地址。on 参数控制 bit3brightness 只取低 3 位。注意关显示和灭数码管不是一回事关显示只是停止扫描段码数据还锁存在芯片里重新打开显示后数据原样恢复。利用这个特性可以在暂时不看数据的时候直接关显示省电而不需要清空每一位。4.3 段码表从共阴数码管推出0~9和负号TM1637 内部是恒流源驱动结构SEG 端输出高电平点亮段GRID 端吸收电流作为公共端因此它匹配的是共阴数码管。段码表按 a 到 dp 的顺序排列bit0 对应 a 段bit1 对应 b 段一直到 bit6 对应 g 段bit7 对应小数点。数字 0 需要点亮 a、b、c、d、e、f 共 6 段二进制就是 0011 1111十六进制 0x3F。const uint8_t TM1637_SegTable[] { 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 };负号只需要点亮 g 段也就是 0x40。小数点则是段码或上 0x80例如显示「3.」就传 0x4F | 0x80 0xCF这会点亮 c、d、g 段和 dp 点。数字 8 是 0x7F加上 dp 变成 0xFF。这个段码表不需要背但一定要理解推导逻辑因为后面如果遇到共阳数码管的模块整个表都要按位取反不懂原理的人会在那里卡很久。4.4 显示整数正负数、补零和消隐有了上面的原语和段码表显示一个四位数就只剩地址映射的问题。按 PCB 丝印从左到右对应地址 0 到 3最高位放 addr0最低位放 addr3void TM1637_ShowNumber(uint16_t num) { TM1637_Display(0, TM1637_SegTable[(num / 1000) % 10]); TM1637_Display(1, TM1637_SegTable[(num / 100) % 10]); TM1637_Display(2, TM1637_SegTable[(num / 10) % 10]); TM1637_Display(3, TM1637_SegTable[num % 10]); }如果模块的丝印方向反了显示会变成倒序把 addr0 和 addr3 对调、addr1 和 addr2 对调即可。这个函数是补零模式传入 12 会显示「0012」适合计时器、计数器这类需要固定位宽的场合。如果不需要前导零要从最高位开始检查遇到 0 就写 0x00 熄灭直到出现第一个非零数字。负号的显示策略要提前想清楚。四位只有四个位置负号必须占一位。约定负号放在最高位 addr0实际数字用低三位显示范围就变成 -999 ~ 999。也可以把负号放在最低位形如「123-」改地址映射就行。我建议大部分表头类项目直接选 -999 ~ 999 的约定代码简单判断也直观。void TM1637_ShowSigned(int16_t num) { uint8_t neg 0; if (num 0) { neg 1; num -num; } if (neg) { TM1637_Display(0, 0x40); // 负号 TM1637_Display(1, TM1637_SegTable[(num / 100) % 10]); TM1637_Display(2, TM1637_SegTable[(num / 10) % 10]); TM1637_Display(3, TM1637_SegTable[num % 10]); } else { TM1637_ShowNumber((uint16_t)num); } }注意 -999 以内的数num 取绝对值后是 0~999除以 100、10、1 取模最高位被负号占用不会越界。如果传入 -1234绝对值 1234 的千位 1 根本无处安放显示结果会是错误的所以调用前要自己限制范围。5. 避坑清单CLK太快、ACK读不到、共阳段码反、负号丢位这章是血泪经验汇总每一条都是实际跑过、排查过的坑。按现象、原因、解决三段式写方便你遇到问题时直接对号入座。5.1 时序和电平相关的坑CLK太快、ACK读不到、GPIO被其他功能复用现象显示内容随机乱跳有时某一位固定显示同一段有时刷新后完全错乱。 原因CLK 翻转速度过快TM1637 在 CLK 上升沿采样 DIO 时数据还没建立稳定。空 for 循环延时在 -O2 优化下会被大幅缩短导致实际 CLK 半周期只有几百纳秒。 解决把延时函数换成前面 DWT 版本CLK 高低电平各延时 3us 以上再拉高。实测 5us 级别在 F103 上最稳刷新速度没有肉眼可见的差异但帧错误率明显下降。现象主机发送完一轮命令后读 ACK 永远返回 1但数码管又能亮数据好像也写进去了。 原因最常见的是 DIO 没切到输入模式就去读 IDR或者 GPIO 输入模式配成了浮空输入。DIO 在浮空状态下没有确定电平再加上模块上拉电阻如果虚焊读到的就是随机的 1。 解决确认 DIO 切换函数里用的是 GPIO_Mode_IPU读 ACK 前加一句 CLK_LOW() 保证时序对齐然后直接读 IDR。还要用万用表量一下模块 DIO 引脚对 VCC 的电压正常情况下空闲时应该接近 VCC。如果没有上拉电阻飞线补一个 10k 电阻到 VCC。现象数码管显示正常但串口、ADC 或者按键扫描功能一起用时显示开始闪。 原因GPIO 模拟时序的每个字节耗时约 90us如果这段时间里系统 tick 中断或者串口中断频繁抢占 CPUCLK/DIO 的时序会被拉长甚至切碎TM1637 就会丢失已经发了一半的数据。 解决显示刷新函数尽量在主循环里调用不要在中断服务函数里直接写显示。如果中断确实无法避开可以在显示函数开头进入临界区关闭优先级较低的中断等一帧数据发完再恢复。5.2 段码、供电和上电顺序相关的坑共阳段码反、上电乱闪、负号丢位现象显示数字 0 时出现的是中间一横显示 1 时变成了右侧两竖所有数字都像被镜像了一样。 原因数码管是共阳的而 TM1637 的 SEG 端是拉电流输出只能驱动共阴数码管。部分市售模块直接焊了共阳数码管没有在 PCB 上做反向电路于是点亮 a 段的电平逻辑完全反过来。 解决先确认模块是不是共阴。把段码全部按位取反0x3F 取反变成 0xC00x06 取反变成 0xF9以此类推。dp 位也一样取反后 0x7F 的 8 会变成 0x80。如果模块有跳线或说明标注「共阳版」优先换模块而不是改代码因为恒流源驱动不匹配会影响亮度一致性。现象每次上电或复位瞬间数码管全亮一下随机显示几个 8 字然后才进入正常显示。 原因TM1637 上电时显示控制寄存器状态未定义如果 MCU 复位瞬间 GPIO 处于浮空状态CLK 和 DIO 的电平随机变化芯片可能误捕获一个写命令把显示打开。 解决在 TM1637_GPIO_Init 最后先调用一次 TM1637_SetBrightness(0, 0) 强制关显示等段码数据全部准备好后再 SetBrightness(2, 1) 打开。硬件上可以在 CLK 和 DIO 对地各加一个 1k 下拉电阻确保复位期间电平稳定为低避免误触发起始条件。现象显示负数时负号把最高位占了数字位数不够比如 -1234 显示成 -234超出范围的值没有做任何处理。 原因四位数码管只有四个位置符号位和数字位必须共享这 4 位。很多例程只判断了num 0就去点亮负号没有做范围约束。 解决和项目需求约定一个明确的显示策略。做温度范围 -50 ~ 150 的直接用 -999 ~ 999 的三位数字加一位负号做计数器则补零显示并单独用指示灯表示正负。代码里在入口处做一次范围钳位超出约定范围的直接显示 ERR 或熄灭最高位避免出现误导性的数值。6. 刷新策略与低功耗亮度分档、滚动显示和睡眠配合TM1637 内部有数据锁存不像动态扫描数码管那样需要 MCU 持续刷新才能维持显示。静态显示时数据稳定锁存在芯片里MCU 写完一遍就可以去干别的这个特性对低功耗设计非常友好。亮度分档是低功耗的第一板斧。TM1637 的亮度由 PWM 控制0x88 之后低 3 位从 0 到 7 对应 8 档。白天用 7 档晚上用 2 档室内环境其实 2~3 档已经足够清楚。每次切换亮度只需要重发一次显示控制命令不用重发段码数据。如果你做的是电池供电的小仪表把亮度从 7 降到 2数码管的平均电流能降下 30% 以上。第二板斧是按需刷新。既然数据锁存在芯片里就不要用一个 while 循环每秒刷 50 次正确的做法是只在数值变化的那一瞬间去更新对应的位。固定地址模式在这里价值很大计数器只有最低位在变就只写 addr3 一个字节其他三位完全不用碰。volatile uint16_t g_displayValue 0; uint16_t lastValue 0; while (1) { if (g_displayValue ! lastValue) { TM1637_ShowNumber(g_displayValue); // 数值变化时一次性刷新 lastValue g_displayValue; } // 其余时间 MCU 可以执行其他任务或者进入睡眠 }需要滚动显示长数值时可以维护一个偏移量每隔 500ms 整体左移一位把新数据从右侧挤进来。实现时先算好当前窗口的 4 个字符再映射到 addr0~addr3不需要改动时序原语。我之前做一款电池供电的小仪表时在数码管刷新上吃过亏。一开始贪省事用自动地址递增模式每次把 4 位全刷一遍整机电流比预期高不少。后来改成固定地址模式加按需刷新亮度固定到 2 档电流直接降下来一大截。从那以后我每次写显示驱动都会先想清楚哪些位真的变了值不值得为「省事」多刷一遍。希望帮到你。本文还有配套的精品资源点击获取