STM32用UART驱动TM1652数码管:节省GPIO的另类方案

发布时间:2026/9/28 1:07:11
STM32用UART驱动TM1652数码管:节省GPIO的另类方案 前阵子做一个小型温控仪表面板上需要4位LED数码管做数值显示MCU用的是STM32F103工程基于STM32CubeMX生成。板子画完才发现 GPIO 已经快被占满了手头还剩一路 UART2 闲着正好项目里又要用一颗 TM1652 数码管驱动芯片。我当时就琢磨能不能用这路 UART 去驱动 TM1652把数码管点亮折腾了两天最终效果还不错动态刷新稳定CPU 占用也低串口还能兼职调试。这篇就完整复盘一下这套方案的落地过程CubeMX 里怎么配置、TM1652 的时序怎么和 UART 帧结构匹配、动态显示代码怎么写以及我踩过的几个坑希望能给同样在资源紧张的情况下想办法驱动数码管的朋友一点参考。1. 整体设计思路为什么“浪费”一路 UART 去驱动数码管1.1 项目背景与需求拆解这个仪表需要显示温度和设定值人机交互就一个按键加 4 位数码管。MCU 资源上SPI 和 I2C 都被别的外设占着GPIO 所剩无几。常规驱动数码管的方案无非三种直接 GPIO 扫描、用专用 LED 驱动芯片、用 I2C/SPI 扩展芯片。但当时的情况是这几种方式要么引线太多要么外设已经被占用。唯一空着的就是 UART2而且我本来就要用它做调试输出。也就是说这路串口如果既能调试又能驱动数码管就能省下一堆 GPIO也不用改板子。这个需求拆解下来有三个关键点第一TM1652 这种芯片本身支持单线串行通信时序上有被 UART 模拟的可行性第二动态显示需要周期性刷新如果用阻塞式发送主循环会被拖死所以必须考虑 DMA 或者中断方式第三代码结构上要尽量把显示逻辑和硬件发送解耦这样以后移植到其他 MCU 也方便。1.2 方案选型对比为什么最终选了 UART TM1652我在动手前把几个方案都列了一遍做了个粗略对比方案占用资源时序要求我的顾虑GPIO 直接扫描数码管8段4位12个GPIO需要软件延时刷新占 CPU引脚不够且刷新会干扰其他逻辑GPIO 模拟 TM1652 协议1个GPIO但要用定时器精确延时位时序靠延时翻转精度难保证定时器资源紧张代码可读性差I2C 转 GPIO 扩展芯片占用 I2C 外设简单芯片便宜当时 I2C 被传感器占用UART 驱动 TM16521路 UART TX加1根信号线需匹配 TM1652 位时序方案需要验证但这种玩法在低成本方案里是成立的最后选 UART 方案说白了就是“废物利用”。UART 发送数据时线上本身就有起始位、数据位、停止位的电平跳变这跟 TM1652 的单线串行协议在某种程度上是兼容的。只要波特率匹配得上发送一个字节就能在信号线上产生一段可控的高/低电平序列正好用来模拟 TM1652 的时序。这个思路有点野但实测可用而且配合 DMA 之后显示刷新几乎不占用 CPU这是 GPIO 延时模拟做不到的。2. 硬件连接与协议适配原理2.1 引脚分配与电路搭建我用的 TM1652 是 SOP16 封装驱动的是共阴数码管。它本身的引脚挺多但和 MCU 相关的其实就一个 DIN 信号线。电路连接很简单模块端连接端说明TM1652 DINMCU PA2USART2_TX数据输入核心信号线TM1652 VDD3.3V或5V看模块供电注意和 MCU 电平匹配TM1652 GNDGND共地TM1652 SEG1-SEG8数码管段选引脚接 8 段含小数点TM1652 GRID1-GRID4数码管位选引脚按你实际位数接这里有两个坑要先说。第一TM1652 的 DIN 是输入脚电平范围一般和它的供电电压有关如果你的 TM1652 用 5V 供电而 MCU 是 3.3V最好加一级三极管或者电平转换不然高电平识别会有隐患。我这次是 3.3V 供电直连没问题。第二TM1652 和数码管之间最好加 100nF 去耦电容尤其是动态扫描的时候瞬间电流大电源不稳会出现亮度不均甚至乱码。2.2 TM1652 的通信协议与 UART 适配原理TM1652 的通信协议本质上是一种异步串行协议数据线空闲时为高电平起始信号是一个低电平脉冲之后是一位一位的数据。很多资料里写着“串行数据输入时钟由内部产生”意思是它对时序精度有一定容忍度但位电平的宽度比例最好保持在某个范围内比如逻辑 0 和逻辑 1 的高/低电平时间不能太离谱。UART 的帧结构里起始位是低电平停止位是高电平8 个数据位在中间。如果我用标准波特率发送一个字节线上就会产生一个固定宽度的低电平脉冲起始位和后续的数据位序列。这里的关键思路是用 UART 每个字节的帧波形去模拟 TM1652 要求的一个位电平。说白了就是发送一个特定字节人为构造出“像逻辑 0”或者“像逻辑 1”的电平段。我在网上见过两种做法。一种是发送 0x00 代表逻辑 0发送 0xFF 代表逻辑 1。因为 0x00 的字节帧里 8 个数据位全是低电平只有停止位是高电平整体看就是一个很长的低电平0xFF 则几乎全是高电平。另一种是发送 0x55 和 0xAA让数据线产生交替方波靠定时器在另一端采样解码。TM1652 是接收端不是解码端所以用 0x00/0xFF 这种极端的字节更直观。实际的位时序是靠波特率换算的。UART 每发送一个字节线上有 10 个位时间1 起始 8 数据 1 停止。如果 TM1652 的位时间宽度要求是 ( T_{bit} )那对应的 UART 波特率就是[ 波特率 \frac{10}{T_{bit}} ]比如我查 TM1652 手册它的位时间在几十微秒量级假设要求 50us 一个位那波特率就是 10 / 0.00005 200000选个接近的标准波特率 230400 就行。如果位时间 100us波特率就是 100000接近 115200。我实际用 115200 跑得很稳说明这款芯片对时序容差是够的。这里要补充一句不同厂家的 TM1652 模块时序参数可能不一样具体以你手里的 datasheet 为准。3. STM32CubeMX 工程配置实战3.1 时钟与 UART 外设配置我的板子用的外部 8MHz 晶振系统时钟跑到 72MHz。在 CubeMX 的 RCC 里选 HSE 外部晶振然后 Clock Configuration 里把 HCLK 设为 72MHz。这个步骤不用多说注意 APB2 和 APB1 的分频不要搞错USART2 挂在 APB1 上时钟是 36MHz波特率计算会自动处理。接下来配置 USART2Mode 选 Asynchronous也就是异步收发模式。波特率我填 115200数据位 8停止位 1无校验无流控。这些参数直接决定了前面说的位时序宽度。有一点值得强调如果只是想让数码管正常显示其实不需要开启 UART 的接收功能只用 TX 就够了。但 CubeMX 里 Mode 如果选 AsynchronousRX 引脚会被配置成输入也不影响使用。我习惯把 RX 也留着因为调试的时候还可以用串口助手往 MCU 发指令顺便测试显示效果。3.2 DMA 与中断配置动态显示最忌讳阻塞。如果用HAL_UART_Transmit一帧一帧发每发一个字节要等发送完成显示 4 位数码管再加上命令字一帧刷新就要阻塞几毫秒主循环根本没法做别的事。所以这里我建议用 DMA 发送。CubeMX 里的配置路径是USART2 - DMA Settings - Add - USART2_TX方向 Memory To Peripheral优先级 Medium 或 High 都行。然后在 NVIC Settings 里勾选 USART2 global interrupt。DMA 的传输完成中断和 UART 的发送完成中断都会进同一个处理函数我在回调函数里置一个标志位主循环只要看标志位就知道上一批显示数据发完了没有可以安全地更新显存并触发下一轮发送。这里有个细节DMA 模式和 UART 发送的配合。USART 的 DMA 发送有两种模式Normal 和 Circular。做显示刷新用 Normal 就行每次手动触发一次发送。Circular 更适合连续的定时发送场景比如以后要改成后台常驻刷新但初期调试用 Normal 更直观。3.3 生成工程后的基础验证CubeMX 生成代码之后我习惯先不写任何驱动直接在 main 函数里用HAL_UART_Transmit循环发送 0x00 和 0xFF用示波器或者逻辑分析仪看 PA2 的波形。这一步是验证时序的关键因为如果波形出来后占空比不对后面显示必然乱码趁早发现能省很多调试时间。没有示波器也可以量电压。发送 0x00 的时候信号线上大部分时间应该是低电平用万用表看平均电压会偏低发送 0xFF 的时候平均电压会接近高电平。这个土办法虽然粗糙但能快速确认电路有没有虚焊、电平转换有没有接反。验证波形正常之后再进入 TM1652 的驱动代码编写。4. 驱动代码与动态显示实现4.1 底层字节发送封装驱动代码我分了三层。底层只做一件事把一个字节通过 UART 发出去不管是阻塞还是 DMA。中间层把 TM1652 的协议封装成函数比如写显存、写命令。上层就是显示接口比如显示一个数字、显示一个温度值。先看底层。我用 DMA 发送但保留了阻塞模式作为调试备用。完整代码大致是这样// 发送一个字节模拟TM1652的一个位电平 // data为0时表示低电平为1时表示高电平 void TM1652_SendBit(uint8_t level) { uint8_t buf[1]; if (level) buf[0] 0xFF; else buf[0] 0x00; // DMA发送版 // HAL_UART_Transmit_DMA(huart2, buf, 1); // 阻塞版调试时用 HAL_UART_Transmit(huart2, buf, 1, HAL_MAX_DELAY); }为什么用 0x00 和 0xFF因为 UART 帧的起始位是低停止位是高数据位如果全 0整个字节帧除了最后一个停止位几乎全是低电平数据位全 1帧里绝大部分是高电平。这样的波形在 TM1652 看来就是一个足够宽的低电平或者高电平能满足位识别需求。UART 发送一个字节需要的时间是固定的所以每一位的宽度都由波特率决定。发送 0x00 时低电平时间为 9/10 字节时间高电平时间为 1/10 字节时间发送 0xFF 时反过来。这个 90:10 的占空比虽然不是完美的方波但实测 TM1652 能正确识别。如果后续显示不稳定可以调整成 0xFE高电平更长或 0x01低电平更长把占空比往 50% 靠这也是一个调试技巧。4.2 TM1652 通信协议封装TM1652 传输数据时一般是先发一个起始位然后发命令字节或数据字节高位或者低位先发要看手册。我这次测试的模块是低位先发和 UART 的 LSB first 正好一致。写数据的过程就是一位一位调用上面的TM1652_SendBit。void TM1652_SendByte(uint8_t data) { // 起始位拉低 TM1652_SendBit(0); // 8个数据位LSB first for (uint8_t i 0; i 8; i) { TM1652_SendBit((data i) 0x01); } // 停止位拉高 TM1652_SendBit(1); }这里最需要注意的就是起始位、数据位、停止位不能漏。UART 的帧本身已经有起始位和停止位了但 TM1652 的协议也要求有一组固定的起始/停止所以发送一个字节时信号线上会出现“UART 起始位 TM1652 起始位 数据 TM1652 停止位 UART 停止位”这种叠加波形。刚开始我图省事直接用一个HAL_UART_Transmit(huart2, data, 1, ...)想轻轻松松发完结果数码管完全没反应就是因为没有构造出 TM1652 自己能识别的完整帧。所以这里不要偷懒每一位都当作独立的 UART 字节来发送。虽然浪费带宽但驱动 TM1652 本身数据量很小完全够用。4.3 显示数据转换与显存设计TM1652 内部有显存地址对应不同的位和段。我需要先定义一个数组把要显示的数字转成段码然后按协议写到 TM1652 的显示寄存器里。段码表和数码管接线有关系我的是共阴数码管段码如下// 段码表共阴字型 0-9 const uint8_t segTable[] { 0x3F, // 0 0x06, // 1 0x5B, // 2 0x4F, // 3 0x66, // 4 0x6D, // 5 0x7D, // 6 0x07, // 7 0x7F, // 8 0x6F, // 9 };显存数组我定义成 4 个字节分别对应 4 位数码管uint8_t displayBuf[4] {0x00, 0x00, 0x00, 0x00};显示一个数字时先把段码填进显存再调用刷新函数把显存整体发到 TM1652。这样上层逻辑只管改displayBuf底层刷新函数每次只做搬运结构上很干净。4.4 动态刷新与亮度控制TM1652 支持静态显示也支持动态扫描但为了降低功耗和方便扩展位数我用了动态刷新。刷新频率至少要在 100Hz 以上才不会闪烁具体做法是在定时器中断或者主循环里每隔 2ms 调用一次TM1652_UpdateDisplay()。这个函数把displayBuf里的 4 个字节发送给 TM1652每一位发送中间加一点延时确保 TM1652 能正确处理。如果需要控制亮度可以通过 TM1652 的亮度等级寄存器设置不同模块命令不一样我用的模块是给驱动 IC 发送一个控制字范围 1-8数值越大越亮。用 DMA 发送时刷新函数要检查上一次发送是否完成否则连续写同一个 DMA 缓冲区会出问题。我定义了一个全局标志volatile uint8_t uartTxComplete 1; void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART2) { uartTxComplete 1; } }然后刷新函数写成这样void TM1652_UpdateDisplay(void) { if (!uartTxComplete) return; // 发送命令开显示设置亮度 TM1652_SendCmd(0x48); // 示例命令实际按手册核对 // 发送显存数据 uartTxComplete 0; for (uint8_t i 0; i 4; i) { uint8_t buf[1] {displayBuf[i]}; HAL_UART_Transmit_DMA(huart2, buf, 1); // 上一次DMA发送还没结束就开始下一次会丢失数据 while (!uartTxComplete); uartTxComplete 0; } }这种写法看起来带了while等待但因为每个字节只有几十微秒实际影响很小。如果是在中断里刷新建议改成状态机不要在中断里长时间阻塞。主循环里这样做完全没问题。动态显示的效果核心就是“不断刷新显存周期性搬运”其实和 GPIO 扫描数码管是一个思路只不过 TM1652 帮我们把位选和段选都处理了省心很多。而且 UART 发送是硬件行为发送期间 CPU 可以去做别的事这一点比纯 GPIO 模拟强太多。5. 常见问题排查与避坑记录5.1 我把实际遇到和可能遇到的问题整理成一个速查表现象可能原因解决思路数码管完全不亮TM1652 电源没接好或者 DIN 信号反相检查 VDD/GND示波器量 DIN 波形显示乱码波特率不匹配TM1652 识别不了信号用逻辑分析仪抓波形确认位宽显示缺笔画段码表不对共阴/共阳搞混确认数码管类型换段码表闪烁严重刷新率太低或者每条显示数据间隔太长提高扫描频率减少发送间隔亮度不均电源端电容不够扫描瞬间压降大数码管附近加 100nF 10uF 电容DMA 发送卡死上次发送还没完成又调用 DMA 发送检查uartTxComplete标志发送前等待完成上电偶尔显示异常TM1652 复位时序不稳定上电延时 50ms 再初始化多试几次复位命令这里我想重点说两个最隐蔽的问题。第一是相位问题也就是信号极性。UART 空闲时是高电平起始位是低电平但有些 TM1652 模块的数据手册要求 DIN 在空闲时是低电平这就意味着直接连接可能完全不工作。遇到这种情况要么加一个反相器要么调整发送逻辑让空闲电平匹配。我在测试时因为用的模块恰好兼容 UART 电平所以绕过了这个坑但如果你移植到其他板子上务必先看时序图再接线。第二是命令字和数据地址问题。TM1652 的常用命令和寄存器地址在不同厂家的芯片上有细微区别有些芯片上电默认就是显示模式有些必须先发送开显示命令。我建议第一次调试时先在main开头发几个测试命令比如依次发送 0x48、0x68、0x01看数码管某一个段能不能点亮这样可以快速确认通信协议方向是否正确。5.2 调试技巧用串口助手当“遥控器”这个方案有个天然优势UART 本来就能收发调试的时候我直接用 USB 转串口连到 MCU 的 RX 脚在电脑上打开串口助手往 MCU 发特定指令就能远程控制显示的内容。比如收到字符1就显示数字 1收到c就关闭显示。主循环里的代码就是解析一个字节然后更新显存几行代码就能实现。用这种方式调试真的省了不少时间。以前调数码管要么改代码重新烧录要么用示波器一点一点看波形现在直接在串口助手里敲数字就能看到数码管变化效率高很多。这个技巧强烈推荐给同样在用 UART 驱动显示芯片的朋友。5.3 我的实操心得这次用 UART 驱动 TM1652让我最大的体会是很多外设的通信协议并没有想象中那么“神圣”UART 这种看似只能点对点传数据的外设一样可以通过字节组合模拟出其他时序。但前提是你对协议的核心参数足够敏感特别是时序容差、电平极性、位宽计算任何一个弄错整条链路就崩了。如果让我再做一个类似项目我会优先考虑是不是真的必须用 UART 驱动还是说 GPIO 模拟更稳。UART 驱动 TM1652 的适用场景是串口本来就空闲、GPIO 紧缺、显示数据量小、不需要极高刷新率。如果这些条件都满足这个方案就是一个性价比很高的选择。反之如果项目里串口要承担重要通信任务还是老老实实给数码管分配 GPIO 或者换一颗 I2C 接口的驱动芯片别为了省事惹出更多麻烦。这个方案后来我又迁移到了 GD32 和 CH32 上整体代码只需要改HAL_UART_Transmit_DMA对应的库函数名称上层显示逻辑一行都不动。如果你也在拿其他型号的 MCU 做类似的事不妨试试这个思路成本低、见效快还能省下一堆 GPIO。最后再提一句TM1652 的模块版本有点多买的时候尽量认准正经封装多做一轮时序测试再量产能避免不少售后问题。