STM32标准库驱动TM1638:数码管、按键与LED控制实战

发布时间:2026/10/17 7:04:59
STM32标准库驱动TM1638:数码管、按键与LED控制实战 1. 项目缘起与整体设计思路TM1638 这颗芯片在嵌入式圈子里算是老面孔了早些年做家电控制板、工业显示面板、电梯楼层显示器的朋友应该都不陌生。它本质上是一颗带键盘扫描功能的 LED 驱动控制专用芯片内部集成了 MCU 数字接口、数据锁存器、LED 驱动、键盘扫描等电路通过三根线STB、CLK、DIO就能跟主控 MCU 通信省下了大量 IO 口。这次我拿到的需求是用 STM32F103ZET6 的标准库函数来驱动一块 TM1638 模块模块上挂了 4 位 8 段共阴极数码管同时板载了 8 个按键和 8 个 LED 指示灯。为什么选标准库而不是 HAL 库这个问题我在实际项目里被问过很多次。STM32F103ZET6 这颗芯片在工业现场和教学场景里保有量极大很多老项目、老代码库都是基于标准库写的维护和移植的时候用标准库反而更顺手。标准库的寄存器操作更直观时序控制更精准对于 TM1638 这种对时序有一定要求的芯片来说标准库能让我把延时精确到微秒级别调试的时候心里更有底。HAL 库虽然抽象层次高、跨系列移植方便但在这种三线模拟时序的场景下反而多了一层封装出问题时排查链路更长。TM1638 模块的典型应用场景包括小型仪表显示、智能家居控制面板、教学实验板、工业设备状态指示等。它的优势在于集成度高一颗芯片搞定显示加按键扫描主控只需要三根线。这次项目里我用的是市面上常见的 TM1638 模块板载 4 位共阴极数码管、8 个独立按键、8 个 LED。数码管的段选和位选都由 TM1638 内部完成STM32 只负责通过三线串行接口发送显示数据和读取按键状态。整体设计思路是这样的STM32F103ZET6 作为主控通过 GPIO 模拟 TM1638 的三线串行时序STB 为片选/使能信号CLK 为时钟信号DIO 为双向数据线。初始化阶段配置三个 GPIO 的输入输出模式然后发送命令字设置显示模式、亮度、写显示数据、读按键。主循环里周期性刷新显示内容同时扫描按键状态按键按下时更新显示数据或执行对应动作。整个方案不依赖任何外部中断或定时器中断纯轮询方式代码结构清晰适合初学者理解和二次开发。注意TM1638 的 DIO 引脚是双向的STM32 端必须配置为开漏输出或者动态切换输入输出模式。我见过不少新手直接把 DIO 配成推挽输出结果读按键时数据死活读不回来排查半天才发现是引脚模式没切换。2. TM1638 芯片原理与通信协议拆解2.1 TM1638 内部结构框图与寄存器映射TM1638 内部有一块显示数据存储器地址从 0x00 到 0x0F共 16 个字节。每个字节对应一个数码管的段码其中偶数地址0x00、0x02、0x04...对应数码管的段选数据奇数地址0x01、0x03、0x05...对应 LED 指示灯的数据。对于 4 位 8 段数码管来说我们只需要用到 0x00 到 0x07 这 8 个地址其中 0x00、0x02、0x04、0x06 分别对应第 1 到第 4 位数码管的段码。键盘扫描部分TM1638 内部有一个 8x3 的键盘矩阵扫描电路读取按键状态时需要发送读按键命令然后连续读取 4 个字节每个字节的每一位对应一个按键的状态。实际使用中模块上的 8 个按键通常映射到前两个字节的 bit0 到 bit7。命令字方面TM1638 支持以下几种核心命令命令类型命令字二进制功能说明显示模式设置0x40写显示数据地址自动增加显示模式设置0x44写显示数据固定地址显示控制0x88-0x8F开启显示设置亮度0x88 最暗0x8F 最亮显示控制0x80-0x87关闭显示设置亮度数据命令0x40普通模式写数据读按键0x42读按键扫描数据这些命令字是 TM1638 通信的基础每次操作前都要先发送对应的命令字再发送数据。命令字的发送顺序是STB 拉低 - 发送命令字 - 发送数据 - STB 拉高。STB 拉低表示一次通信的开始拉高表示通信结束。2.2 三线串行通信时序详解TM1638 的通信协议本质上是一种类 SPI 的同步串行协议但没有标准的 SPI 那么复杂。数据在 CLK 的上升沿被采样也就是说发送数据时先在 DIO 上准备好数据位然后拉高 CLK再拉低 CLK完成一个 bit 的传输。数据位顺序是 LSB 优先也就是先发送最低位。具体时序可以拆解为以下几个步骤STB 拉低启动通信。在 DIO 上放置第 0 位数据LSB。CLK 拉高TM1638 采样 DIO 上的数据。CLK 拉低准备下一位。重复步骤 2-4直到 8 位数据发送完毕。如果是写命令STB 拉高结束如果是读数据STB 保持低电平切换 DIO 为输入模式然后在 CLK 下降沿后读取 DIO 上的数据。这里有个细节容易被忽略读数据时TM1638 是在 CLK 的下降沿输出数据所以 STM32 端应该在拉低 CLK 之后再去读 DIO 的电平。我一开始按照上升沿读数据结果读回来的全是 0xFF后来用逻辑分析仪抓了波形才发现问题所在。提示如果手头没有逻辑分析仪可以用示波器看 CLK 和 DIO 的波形确认数据在 CLK 的哪个边沿变化。这个步骤在调试通信协议时非常关键能省下大量猜测时间。2.3 共阴极数码管段码表推导共阴极数码管的段码逻辑是段选引脚输出高电平时对应的段点亮。TM1638 的显示数据存储器里每个字节的 bit0 到 bit7 分别对应数码管的 a、b、c、d、e、f、g、dp 段。所以我们要显示数字 0需要点亮 a、b、c、d、e、f 段对应的二进制是 0011 1111即 0x3F。常用数字和字母的段码表如下显示字符段码十六进制二进制00x3F0011 111110x060000 011020x5B0101 101130x4F0100 111140x660110 011050x6D0110 110160x7D0111 110170x070000 011180x7F0111 111190x6F0110 1111A0x770111 0111b0x7C0111 1100C0x390011 1001d0x5E0101 1110E0x790111 1001F0x710111 0001熄灭0x000000 0000横杠0x400100 0000这张表在实际写代码时可以直接放到一个数组里用查表法获取段码。需要注意的是不同厂家生产的数码管段码顺序可能略有差异如果显示出来的数字是乱的先检查段码表是否匹配。我遇到过一批模块a 段和 b 段的顺序是反的导致显示 1 的时候亮的是 c 段后来调整了段码表才正常。3. STM32 标准库工程搭建与 GPIO 配置3.1 工程目录结构与标准库文件裁剪拿到 STM32F103ZET6 的标准库之后第一件事是裁剪工程。标准库的 Libraries 文件夹里有一堆外设驱动文件没必要全部加进工程。这个项目只需要用到 GPIO、RCC 和基本的延时功能所以只需要保留 stm32f10x_gpio.c、stm32f10x_rcc.c、stm32f10x_usart.c调试用以及对应的头文件。启动文件选 startup_stm32f10x_hd.s因为 ZET6 是大容量产品Flash 有 512KBSRAM 有 64KB。工程目录我习惯这样组织User放 main.c、tm1638.c、tm1638.h、delay.c、delay.hLibraries放标准库的 inc 和 src 文件夹Start放启动文件和 system_stm32f10x.cOutput放编译输出的 hex 和 bin 文件这种结构清晰移植的时候直接把 User 和 Libraries 拷走就行。Keil 里记得在 Options for Target 的 C/C 选项卡里把 Include Paths 配好把 User、Libraries\inc、Start 都加进去否则编译时会报找不到头文件。3.2 GPIO 模式选择与初始化代码TM1638 的三根线分别接到 STM32 的哪几个引脚这个可以根据实际布线灵活选择。我这次用的是 PB5 接 STB、PB6 接 CLK、PB7 接 DIO都是普通 GPIO不占用复用功能。初始化的时候STB 和 CLK 配置为推挽输出DIO 配置为开漏输出速度都选 50MHz。为什么 DIO 要配开漏输出因为 DIO 是双向线STM32 需要向 TM1638 写数据也需要从 TM1638 读数据。开漏输出模式下STM32 可以拉低 DIO但拉高时需要外部上拉电阻。TM1638 模块上通常已经自带了 10K 的上拉电阻所以开漏模式完全够用。读数据的时候把 DIO 切换成浮空输入或者上拉输入模式就能正确读取 TM1638 输出的电平。初始化代码大致如下void TM1638_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); // STB - PB5 推挽输出 GPIO_InitStructure.GPIO_Pin GPIO_Pin_5; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOB, GPIO_InitStructure); // CLK - PB6 推挽输出 GPIO_InitStructure.GPIO_Pin GPIO_Pin_6; GPIO_Init(GPIOB, GPIO_InitStructure); // DIO - PB7 开漏输出 GPIO_InitStructure.GPIO_Pin GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_OD; GPIO_Init(GPIOB, GPIO_InitStructure); // 初始状态STB 和 CLK 拉高DIO 拉高 GPIO_SetBits(GPIOB, GPIO_Pin_5 | GPIO_Pin_6 | GPIO_Pin_7); }这段代码里RCC 时钟使能是必须的忘了这一句后面所有操作都是白搭。GPIO_SetBits 把三根线都拉高这是 TM1638 的空闲状态。STB 在高电平时TM1638 不响应任何通信所以初始化完成后先让总线处于空闲状态。3.3 微秒级延时函数的实现与精度校准TM1638 的通信速率虽然不算高但时序上还是有要求的。CLK 的高电平和低电平持续时间不能太短否则 TM1638 可能采样不到。根据数据手册CLK 的周期最小可以到 1us 左右但实际使用中我一般把半周期延时设在 2us 到 5us 之间这样既稳定又不会太慢。标准库没有现成的微秒延时函数需要自己用 SysTick 或者简单的循环来实现。用 SysTick 做延时比较精准但配置起来稍微麻烦一点。我这次用的是简单的循环延时在 72MHz 主频下一个简单的 for 循环大概能产生 1us 左右的延时。具体循环次数需要根据编译器的优化等级来调整我一般用 volatile 变量防止编译器优化掉循环。void delay_us(uint32_t us) { volatile uint32_t i; for(i 0; i us * 8; i); }这个 8 这个系数是在 72MHz、Keil 默认优化等级下实测出来的。如果你换了编译器或者优化等级这个系数需要重新校准。校准方法很简单写一个翻转 GPIO 的测试程序用示波器测量高电平持续时间然后调整系数直到延时准确。注意延时函数不要用得太抠门。有些朋友为了追求速度把延时压到 1us 以下结果 TM1638 工作不稳定显示闪烁或者按键读取错误。我建议半周期延时不低于 2us留足余量。4. TM1638 驱动代码逐层实现4.1 底层字节收发函数字节发送函数是整个驱动的基础所有命令和数据都是通过它发出去的。实现思路是循环 8 次每次取数据的最低位放到 DIO 上然后产生一个 CLK 脉冲。void TM1638_WriteByte(uint8_t data) { uint8_t i; for(i 0; i 8; i) { // 先拉低 CLK GPIO_ResetBits(GPIOB, GPIO_Pin_6); // 设置 DIO 电平 if(data 0x01) GPIO_SetBits(GPIOB, GPIO_Pin_7); else GPIO_ResetBits(GPIOB, GPIO_Pin_7); // 拉高 CLKTM1638 在上升沿采样 GPIO_SetBits(GPIOB, GPIO_Pin_6); delay_us(2); data 1; } GPIO_ResetBits(GPIOB, GPIO_Pin_6); }读字节函数稍微复杂一点因为需要切换 DIO 的方向。在 STB 保持低电平的情况下把 DIO 配置为输入模式然后在 CLK 下降沿后读取 DIO 电平。uint8_t TM1638_ReadByte(void) { uint8_t i, data 0; GPIO_InitTypeDef GPIO_InitStructure; // 切换 DIO 为输入模式 GPIO_InitStructure.GPIO_Pin GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; GPIO_Init(GPIOB, GPIO_InitStructure); for(i 0; i 8; i) { data 1; GPIO_ResetBits(GPIOB, GPIO_Pin_6); delay_us(2); GPIO_SetBits(GPIOB, GPIO_Pin_6); delay_us(2); if(GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_7)) data | 0x80; } // 恢复 DIO 为开漏输出 GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_OD; GPIO_Init(GPIOB, GPIO_InitStructure); return data; }这里有个细节读数据时数据是在 CLK 下降沿之后才有效的所以我在拉低 CLK 之后加了 2us 延时再拉高 CLK然后在拉高之后读取。实际测试下来这个时序很稳读回来的按键数据没有出现过误码。4.2 显示数据写入与亮度调节写显示数据的流程是STB 拉低 - 发送 0x40 命令自动地址增加模式- STB 拉高 - STB 拉低 - 发送 0xC0 起始地址 - 连续发送 8 个字节的显示数据 - STB 拉高。void TM1638_Display(uint8_t *data) { uint8_t i; // 发送显示模式命令 GPIO_ResetBits(GPIOB, GPIO_Pin_5); TM1638_WriteByte(0x40); GPIO_SetBits(GPIOB, GPIO_Pin_5); // 发送起始地址 GPIO_ResetBits(GPIOB, GPIO_Pin_5); TM1638_WriteByte(0xC0); for(i 0; i 8; i) { TM1638_WriteByte(data[i]); } GPIO_SetBits(GPIOB, GPIO_Pin_5); }亮度调节通过 0x88 到 0x8F 这 8 个命令字来实现0x88 最暗0x8F 最亮。我一般用 0x8F因为工业现场环境光比较强亮度低了看不清。如果是室内使用的设备0x8A 到 0x8C 就够用了还能省点电。void TM1638_SetBrightness(uint8_t level) { if(level 7) level 7; GPIO_ResetBits(GPIOB, GPIO_Pin_5); TM1638_WriteByte(0x88 | level); GPIO_SetBits(GPIOB, GPIO_Pin_5); }4.3 按键扫描与去抖动处理读按键的命令字是 0x42发送完命令后STB 保持低电平然后连续读取 4 个字节。每个字节的每一位对应一个按键bit 为 1 表示按下为 0 表示松开。模块上的 8 个按键通常映射到第一个字节的 bit0 到 bit7。uint8_t TM1638_ReadKeys(void) { uint8_t keys 0; GPIO_ResetBits(GPIOB, GPIO_Pin_5); TM1638_WriteByte(0x42); keys TM1638_ReadByte(); GPIO_SetBits(GPIOB, GPIO_Pin_5); return keys; }按键去抖动我一般用软件延时法检测到按键状态变化后延时 10ms 再读一次如果状态一致才确认。更稳妥的做法是用状态机记录按键的当前状态和上一次状态只有连续多次采样一致才触发动作。这个项目里按键不多用简单的延时去抖就够了。提示TM1638 的按键读取和显示写入不能同时进行因为都占用三线总线。如果主循环里既要刷新显示又要扫描按键建议把显示刷新和按键扫描分开中间加一点延时避免总线冲突。5. 常见问题排查与实操避坑指南5.1 显示异常问题速查表现象可能原因排查方法解决方案数码管完全不亮电源未接或电压不足万用表测模块 VCC 和 GND确保 5V 供电电流足够显示乱码段码表不匹配逐个点亮段测试调整段码表顺序显示闪烁刷新频率太低或延时过长示波器看 CLK 频率减小延时提高刷新率某一位不亮该位数据未写入或地址错误检查显示数据数组确认地址映射正确亮度不可调命令字错误检查亮度命令字确认 0x88-0x8F 范围按键无反应DIO 模式未切换检查读函数中的模式切换确保读之前切输入读之后切回输出按键误触发未去抖或去抖不充分观察按键波形增加去抖延时或状态机这张表是我在实际调试中总结出来的基本上覆盖了 90% 以上的常见问题。其中“按键无反应”和“显示乱码”这两个问题出现频率最高前者多半是 DIO 方向切换的问题后者多半是段码表的问题。5.2 时序调试经验与逻辑分析仪使用技巧调试 TM1638 这种串行协议逻辑分析仪是最得力的工具。我用的是 8 通道的逻辑分析仪接上 STB、CLK、DIO 三根线采样率设到 1MHz 以上就能清楚地看到每一位数据的传输过程。抓波形的时候重点看几个地方STB 的下降沿是否在 CLK 空闲时发生数据位是否在 CLK 上升沿之前稳定读数据时 DIO 的方向切换是否及时。有一次我遇到一个很奇怪的问题显示数据写入后数码管亮了一下就灭了。用逻辑分析仪抓波形发现STB 在数据发送完毕后没有及时拉高导致 TM1638 一直处于接收状态把后面的数据也当成显示数据写进去了。后来在每次通信结束后强制拉高 STB问题就解决了。注意STB 的拉高时机很关键。写命令和写数据之间STB 需要拉高再拉低表示一次独立的通信。如果 STB 一直保持低电平TM1638 会把所有数据当成一次连续写入地址会自动增加导致数据错位。5.3 电源与抗干扰注意事项TM1638 模块的电源质量直接影响显示效果和通信稳定性。我遇到过一批模块用 USB 供电时显示正常换成开关电源供电就出现闪烁和按键误触发。后来在模块的 VCC 和 GND 之间并了一个 100uF 的电解电容和一个 0.1uF 的陶瓷电容问题就消失了。这个经验说明TM1638 对电源纹波比较敏感尤其是驱动 4 位数码管全亮的时候瞬间电流能到 100mA 以上电源不稳就会导致芯片复位或通信出错。另外三根信号线如果走线较长建议串一个 100 欧姆的电阻能有效抑制振铃和过冲。如果模块和主控板是分开的用排线连接时尽量让 GND 线靠近信号线减少环路面积。6. 功能扩展与项目实战建议6.1 多位数码管动态扫描与显示缓存设计虽然 TM1638 内部已经做了动态扫描但我们在软件层面还是需要一个显示缓存数组。这个数组保存了当前要显示的内容主循环里定期把缓存写入 TM1638。这样做的好处是显示内容的更新和实际写入可以解耦避免在通信过程中修改数据导致显示错乱。显示缓存我一般定义成uint8_t DisplayBuffer[8]前 4 个字节对应 4 位数码管的段码后 4 个字节对应 8 个 LED 的状态每个字节控制两个 LED。更新显示的时候先修改缓存然后调用 TM1638_Display 函数一次性写入。uint8_t DisplayBuffer[8] {0}; void UpdateDisplay(uint8_t pos, uint8_t value) { if(pos 4) DisplayBuffer[pos * 2] SegmentTable[value]; TM1638_Display(DisplayBuffer); }这种设计在需要显示动态数据比如计数器、温度值的时候特别方便只需要更新缓存显示刷新交给统一的函数处理。6.2 按键长短按识别与组合键实现TM1638 的按键扫描只返回当前按下的状态要实现长按和短按识别需要在软件层面加一个状态机。我的做法是每 10ms 扫描一次按键记录每个按键的按下持续时间和释放时间。按下时间小于 500ms 算短按大于 1 秒算长按长按后每隔 200ms 触发一次连续动作。组合键的实现稍微复杂一点需要记录当前按下的按键集合然后判断是否满足预设的组合条件。比如同时按下按键 1 和按键 2 触发某个功能就需要在扫描到两个按键都按下时设置一个组合键标志位。typedef struct { uint8_t current; uint8_t last; uint16_t pressTime; uint8_t longPressFlag; } KeyState; KeyState keys[8]; void KeyScan(void) { uint8_t keyData TM1638_ReadKeys(); for(uint8_t i 0; i 8; i) { keys[i].current (keyData i) 0x01; if(keys[i].current !keys[i].last) { keys[i].pressTime 0; keys[i].longPressFlag 0; } else if(keys[i].current keys[i].last) { keys[i].pressTime; if(keys[i].pressTime 100 !keys[i].longPressFlag) { keys[i].longPressFlag 1; // 触发长按动作 } } else if(!keys[i].current keys[i].last) { if(keys[i].pressTime 50 !keys[i].longPressFlag) { // 触发短按动作 } } keys[i].last keys[i].current; } }这个状态机每 10ms 调用一次pressTime 的单位就是 10ms。100 对应 1 秒50 对应 500ms。实际使用中可以根据需要调整这些阈值。6.3 与上位机通信的显示数据协议设计如果项目需要跟上位机通信显示数据的协议设计就很重要了。我一般用简单的帧格式帧头0xAA 0x55 命令字 数据长度 数据 校验和。STM32 收到数据后解析命令字如果是显示更新命令就把数据写入显示缓存然后刷新 TM1638。这种协议的好处是扩展性强后面要加新的命令比如亮度调节、按键上报只需要增加命令字就行不用改帧结构。校验和用简单的累加和或者异或校验就够了工业现场如果干扰大可以用 CRC16。提示串口通信和 TM1638 通信不要在同一个中断里处理。串口中断里只负责收数据把数据放到缓冲区主循环里再解析和执行。TM1638 的通信对时序敏感放在中断里容易被打断导致通信失败。7. 个人实操体会与后续优化方向这个项目做下来最大的感受是 TM1638 这颗芯片虽然老但胜在稳定、便宜、外围简单。4 位数码管加 8 个按键加 8 个 LED一颗芯片全搞定PCB 上省了不少空间。标准库开发虽然写起来比 HAL 库繁琐一点但对时序的控制更直接调试的时候心里更有数。后续如果要优化我觉得有几个方向可以尝试。一是把延时函数换成 SysTick 或者定时器提高时序精度这样可以把通信速率提上去刷新率更高。二是把按键扫描和显示刷新放到定时器中断里主循环只处理业务逻辑代码结构会更清晰。三是如果数码管位数更多可以考虑级联多片 TM1638STB 和 CLK 共用DIO 分开控制这样能驱动更多的显示位。最后分享一个小技巧TM1638 的显示数据存储器在掉电后不会保存所以每次上电都要重新初始化。我一般在上电初始化函数里先把所有显示数据清零然后设置亮度再开启显示。这样上电后数码管是全灭状态不会出现随机亮段的情况。这个细节虽然小但能避免很多“上电显示乱码”的误判。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询