简介:面向STM32嵌入式开发者的CH455四位数码管驱动方案,基于STM32Cube HAL库编写,适用于需要快速实现数码管显示与按键扫描的仪表、控制面板等场景。压缩包共2个文件,含1个C源文件和1个头文件,采用IIC两线接口驱动CH455,SDA、SCL引脚均可自定义:在CubeMX中设置标签为TUF_SDA、TUF_SCL,并同步修改头文件注释处引脚定义即可完成底层适配。使用时先在main.c中调用TUF_DPY_Write_Cmd(CH455_SYSON)开启键盘显示功能,再调用TUF_DPY_SHOW_NUM(float num)直接显示浮点数,适合输出传感器读数、计算结果等。CH455驱动可有效节省主控GPIO资源,配合HAL库的IIC功能,整套资源仅约2KB,代码精简且易于移植到不同STM32型号。已有1682人学习下载,适合具备基础HAL库知识、希望快速上手CH455驱动的开发者借鉴。
1. 为什么这个标题值得花时间:HAL 库 + CH455 是低成本数码管显示的省心解
STM32Cube HAL 库驱动 CH455 四位数码管,这个组合解决的是嵌入式开发里最常见的一件事:想显示数字,但 GPIO 不够用。CH455 通过 I2C 把段选、位选都接管了,CPU 只需要在内容变化时发几个字节,之后芯片自己完成动态扫描,不占主循环。相比 74HC595 需要自己写移位时序、相比 TM1637 需要读厂商私有协议,CH455 用标准 I2C 通信,工程上更干净,排查起来也更直接。
我最初接手一个带四位小数显的仪表板需求时,第一反应是 GPIO 直接扫,结果一算引脚差点不够,后来换成 CH455 方案,一版就点亮了。这篇笔记把 CubeMX 配置、HAL I2C 发送、段码拆帧、避坑清单一次讲透,适合正在做实验箱、工业面板、充电桩显示模块的开发者。
2. CH455 的通信机制:为什么一个字符要拆成两个半字节发
很多第一次用 CH455 的人会拿它当普通数码管驱动 IC 来理解,以为像 TM1650 那样一个寄存器写一个完整段码。实际上 CH455 的帧结构设计很特别——它内部寄存器是 4 位宽的,I2C 每个字节的高 4 位是寄存器地址,低 4 位才是数据。这段话值得读两遍,因为后面所有调试困惑都从这里来。
2.1 段码 8 位、寄存器 4 位:拆帧是硬性规定
数码管一个字符包含 7 段加小数点,正好 8 位,可以对应一个 uint8_t。但 CH455 设计成每次只能写 4 位数据,所以一位数码管的段码必须拆成低 4 位和高 4 位两次发送,分别写到不同的寄存器地址去。用地址位来区分高半字节还是低半字节,而不是像某些芯片那样用一个额外的标志位。
我一般这样定义帧结构:内部地址 0x00 到 0x03 对应第 1 到第 4 位数码管的低 4 段,地址 0x04 到 0x07 对应第 1 到第 4 位数码管的高 4 段。所以发送时一个数据字节是(地址 << 4) | (半字节段码)。举个例子,要让第 1 位数码管显示数字 0,段码是 0x3F,低 4 位是 0x0F,高 4 位是 0x03,分别组装成 0x0F 和 0x43 发送,这才算写完整一个字符。
这个设计与芯片的物理引脚排列有关。CH455 内部做动态扫描,每时每刻只有一个位在被点亮,位与位之间靠 CH455 自己轮流选通,不需要 CPU 反复送位选信号。因此 CPU 要做的事情只剩两件:组装帧、走 I2C 发出去。
2.2 用 HAL 库的 I2C 外设还是 GPIO 模拟:我为什么选前者
CH455 是标准 I2C 从机,理论上用两根 GPIO 模拟时序也能驱动。但我建议直接用 STM32Cube 生成的硬件 I2C 外设,理由有三个:首先,硬件 I2C 的时序由外设保证,起始条件、停止条件、应答位的电平跳变不用软件去卡延时,代码更接近「发送数据」这个语义;其次,HAL 库已经封装好超时机制,总线上出问题不会让程序死等;最后,后续如果要挂多个 I2C 设备,硬件外设 + 地址区分能省很多接线。
GPIO 模拟不是不能做,它适合在引脚实在不够、或者想彻底绕开 I2C 外设 bug 的极端场景。但代价是时序代码里全是延时微调,换一块板子、换一颗晶振可能就得重新调。用 HAL 库硬件 I2C,CubeMX 里勾选一下就有可用的句柄 hi2c1,发送函数一句话搞定。
2.3 最小发送模型:从 CubeMX 工程到第一个字节
新一点的 CubeMX 生成 HAL 库工程后,默认不会把 I2C 引脚映射到正确的位置,所以第一步是在 Pinout 视图里把 SCL、SDA 所在的引脚功能选成 I2C1_SCL、I2C1_SDA。我通常把 PB8 配给 I2C1_SCL,PB9 配给 I2C1_SDA,这是最常见的一组复用脚,具体按自己板子的丝印调整。
时钟配置上,I2C 外设挂在 APB1 总线上,CubeMX 会自动根据系统时钟算出分频。为了减少变量,我第一版会把 I2C Speed Mode 设成 Standard Mode,也就是 100kHz。CH455 对时序要求不算苛刻,但 100kHz 在排错时宽容度最高,等整块板子稳定了再改 400kHz 也不迟。工程生成后,验证通信是否通的一条最小代码是这样:
/* CH455 的 7 位从机地址,HAL 内部会自动左移 1 位拼读写标志 */ #define CH455_ADDR 0x48 /* 发送一个字节:高 4 位是内部寄存器地址,低 4 位是段码半字节 */ uint8_t test_frame = 0x0F; HAL_StatusTypeDef st = HAL_I2C_Master_Transmit(&hi2c1, CH455_ADDR, &test_frame, 1, 100); if (st != HAL_OK) { /* 返回非 OK,先查 ACK、查接线、查地址,别急着看显不显示 */ }第一次跑这段代码,返回值是 HAL_OK 才算跨过了最基础的一关。HAL_I2C_Master_Transmit 的第三个参数是发送缓冲区,第四个参数是长度,第五个参数是超时毫秒数。这里地址参数填 0x48,而不是 0x90,因为 HAL 库要求的是 7 位地址,库内部会自己做左移补位。这是新手最容易翻车的地方,后面避坑章节还会单独展开。
3. 四位共阴数码管与 CH455 的接线、CubeMX 参数配置
硬件接线看着简单,实际上段序、公共端、限流电阻这几个细节没处理好,后面全是玄学问题。这一章把接线表和 CubeMX 参数一起列出来,照着做基本能省掉三分之二的调试时间。
3.1 段线、位线、电源:一张接线表说清楚
CH455 驱动的是共阴数码管,这一点要先确认。四位共阴数码管有 12 根引脚:8 根段脚(a 到 g 加 dp)和 4 根位脚(第 1 位到第 4 位的公共阴极)。CH455 的 SEG0 到 SEG7 依次接数码管的 a 到 dp,DIG0 到 DIG3 接四个位脚。如果手里是共阳数码管,不要硬接,CH455 内部设计是按共阴来的,共阳管需要额外加三极管反相,已经失去了省引脚的意义。
接线顺序本身不出问题,容易出问题的是段序对应。有些数码管内部把 dp 放在段码最高位,有些则把 g 放在最高位,所以同一张段码表在不同厂家数码管上显示的结果可能完全不同。我一般第一版先用0x0F这种只有低 4 段亮的测试值,逐位确认段脚和段码位的映射关系,再固化段码表。
下表是常见接法,实际以你自己板子的丝印为准:
| CH455 引脚 | 接往对象 | 备注 |
|---|---|---|
| SCL | STM32 PB8 | I2C1 时钟,建议外部 4.7k 上拉 |
| SDA | STM32 PB9 | I2C1 数据,建议外部 4.7k 上拉 |
| VCC | 3.3V | 与 MCU 同电压域最省事 |
| GND | GND | 与 MCU 共地 |
| SEG0-SEG7 | 数码管 a-dp | 段线上串限流电阻 |
| DIG0-DIG3 | 数码管位 1-4 | 共阴公共端,不要串电阻 |
限流电阻的取值要单独说。段线上的电阻直接决定亮度,阻值太小电流大伤数码管,阻值太大亮度不足。我常用 330 欧起步,先看显示效果,再往 470 欧或 220 欧调。位线上不要串电阻,因为公共端电流是八段之和,串了会造成不同位数亮度差异明显。
3.2 CubeMX 配置项:I2C 速度、上拉、时钟树的几个关键参数
CubeMX 里 I2C 参数页主要有 Speed Mode、Own Address、Address Detection Mode、Clock No Stretch Mode 这几项。CH455 是从机,MCU 是主机,Own Address 填什么都无所谓,只要不和总线上其他从机冲突。Address Detection Mode 选 7-bit,Clock No Stretch Mode 保持 Disable,这些保持默认就行。
真正要核对的是 Speed Mode 和时钟树。如果选 Fast Mode,CubeMX 会要求把上升沿时间等参数填好,不同板子布线质量不一样,可能因为边沿太缓导致通信失败。我建议第一版老老实实选 Standard Mode,100kHz。时钟树部分确认 APB1 时钟不为 0,并且 I2C1 的时钟源已经挂上,否则 HAL_I2C_Init 会返回失败。
写到这里有个容易忽略的点:CH455 的 SCL、SDA 是开漏结构,必须靠上拉电阻才能输出高电平。STM32 的部分引脚可以启用内部上拉,但内部上拉阻值偏大,在长线上不够稳。我一般会在板上加两颗 4.7k 外部上拉电阻,一个接到 SCL,一个接到 SDA,哪怕只做面包板原型也值得加上。别小看这一步,很多「偶尔通信失败」在加了上拉之后就消失了。
初始化完成后,可以用 HAL_I2C_IsDeviceReady 来探测从机是否存在。这个函数会发送地址并等待应答,返回 HAL_OK 说明 CH455 在总线上应答了:
uint32_t tick = HAL_GetTick(); /* 对焦点:发送带读标志的地址,看 CH455 是否 ACK */ if (HAL_I2C_IsDeviceReady(&hi2c1, CH455_ADDR, 10, 100) != HAL_OK) { /* 超时还返回失败,大概率是接线、上拉或地址问题 */ }如果这一步失败,不要继续往下调显示逻辑,先把电路查明白。I2C 是同步协议,没有应答意味着物理层不通,后面写再多显示代码都是白搭。硬件探测通过后,再进入驱动函数封装,才算真正开始写显示逻辑。
4. 手写 CH455 驱动:段码表、拆帧函数、四位刷新一次跑通
到这一步,I2C 通信已经通了,接下来要做的就是把它封装成三个层次:段码表、单字符写入、四位数字刷新。这里给出的代码是经过实际项目验证的常见写法,直接用 HAL 库 API 实现,不依赖任何私有协议库。
4.1 共阴段码表:先定义清楚,后面少改
段码表是显示逻辑的地基。对共阴数码管,要点亮的段对应位写入 1,段码就是这些位的组合。下面这张表覆盖了 0 到 9 和几个常用字母,小数点位置单独留出来,需要时可以和数字段码做或运算。
/* 共阴数码管段码表:数字 0-9,位序为 a b c d e f g dp */ const uint8_t seg_table[10] = { 0x3F, /* 0: a b c d e f 亮 */ 0x06, /* 1: b c 亮 */ 0x5B, /* 2: a b d e g 亮 */ 0x4F, /* 3: a b c d g 亮 */ 0x66, /* 4: b c f g 亮 */ 0x6D, /* 5: a c d f g 亮 */ 0x7D, /* 6: a c d e f g 亮 */ 0x07, /* 7: a b c 亮 */ 0x7F, /* 8: 全段 亮 */ 0x6F, /* 9: a b c d f g 亮 */ };这张表建立在段脚按 a、b、c、d、e、f、g、dp 的顺序接在 SEG0 到 SEG7 的前提下。如果你的板子段序不是这样,比如把 dp 放在最前面,表里的值就要整体按位重排。我建议先用一根杜邦线把每一位单独短接测试,确认段序后再填表。
4.2 单字符写入:把段码拆成低 4 位和高 4 位
核心函数就一个,把位序号和段码组织成两个帧字节,一次 I2C 传输发出去。这里要再次强调地址偏移:第 0 到 3 位对应低 4 段地址,第 4 到 7 位对应高 4 段地址,所以高半字节的寄存器地址要加 4。
#define CH455_ADDR 0x48 /* 向指定位写入段码,bit_index 取值 0-3,对应第 1 到第 4 位数码管 */ void ch455_set_segment(uint8_t bit_index, uint8_t seg) { /* 第一帧:低 4 位段码,寄存器地址 = bit_index */ uint8_t low_frame = (bit_index << 4) | (seg & 0x0F); /* 第二帧:高 4 位段码,寄存器地址 = bit_index + 4 */ uint8_t high_frame = ((bit_index + 4) << 4) | ((seg >> 4) & 0x0F); uint8_t buf[2] = { low_frame, high_frame }; /* 一次传输两个字节,HAL 内部会先发地址再发数据 */ HAL_I2C_Master_Transmit(&hi2c1, CH455_ADDR, buf, 2, 100); }这个函数的关键在于bit_index不要超过 3,否则高帧地址会越界到其他控制寄存器。发送长度是 2,代表两个数据帧,I2C 总线上会出现一次起始条件、一次地址字节、两个数据字节、一个停止条件。CH455 收到第一个字节后,内部解析出地址和半段码;收到第二个字节后,解析出另一半,合在一起更新对应位的显示缓存。
有两点想提醒:第一,这个函数对每一位都做一次 I2C 传输,刷新四位就要调用四次,每次需要几百微秒,对显示这种低频操作完全够用;第二,不要试图把四个位的帧合并成一个长 buffer 一次性发,除非你确认寄存器地址是递增的,而 CH455 的低 4 位地址和高 4 位地址是跳变的,合并反而要先重新排序,得不偿失。
4.3 四位数字刷新:一次性显示一个整数
有了单字符函数,显示整数就是把数字拆成个、十、百、千位,逐位调用。注意显示的是明码数字还是 BCD 码,这里按明码写,最简单直接。
void ch455_display_number(uint16_t num) { uint8_t digits[4]; /* 依次取出千、百、十、个位 */ digits[0] = (num / 1000) % 10; digits[1] = (num / 100) % 10; digits[2] = (num / 10) % 10; digits[3] = num % 10; for (uint8_t i = 0; i < 4; i++) { ch455_set_segment(i, seg_table[digits[i]]); } }主循环里调用这个函数就够了,不需要定时器去刷新,因为动态扫描的工作全在 CH455 内部完成。CPU 只在数字变化时传一次数据,中途即使主循环被别的任务卡住,数码管也能保持显示内容不闪。这是 CH455 和 GPIO 直接扫最大的区别,也是它适合做后台任务的工业面板的原因。
如果想让某些位熄灭,比如只显示三位数,就给对应位写入 0x00。0x00 段码表示所有段都灭,这一位看起来就是空白的。小数点的处理也简单:把seg_table[digits[i]] | 0x80传入即可,0x80 对应 dp 段。需要注意的是,段码表里如果 dp 不在最高位,那 0x80 可能需要换位置,这又回到了段序核对的问题上。
5. 避坑与排查:CH455 驱动的 5 个高频翻车点
CH455 本身不算复杂,但因为它把地址和数据揉在一个字节里,再加上 HAL 库对地址参数的处理方式,实际调试时会遇到一些看起来「不科学」的故障。下面这五条是我自己踩过、也看别人踩过的坑,按现象、原因、解决的顺序说清楚。
5.1 I2C 应答失败:地址填 0x90 是第一个拦路虎
现象:HAL_I2C_Master_Transmit 返回 HAL_ERROR,用示波器看 SDA 上从机一直没有拉低应答位,或者 HAL_I2C_IsDeviceReady 永远返回超时。原因多半是地址填错了。很多资料把 CH455 的写地址写成 0x90,这是 8 位地址形态,也就是 7 位地址 0x48 左移一位再补 0。但 HAL 库的接口要求传 7 位地址,库内部会再左移一次。如果你填 0x90,实际发送的地址变成 0x120,超出 7 位范围,从机根本不认。解决:把地址定义成 0x48,不要自己移位,更不要从旧例程里照抄带 0x90 的写法。
5.2 只有一半段亮:段码拆帧时寄存器地址偏移搞错
现象:显示数字 8,结果只有上面三横亮,下面三横和中间一横完全不亮。原因是对第 0 位的高 4 段寄存器地址写成了bit_index + 1,而不是bit_index + 4。CH455 的地址范围从 0 到 7,前 4 个给低段,后 4 个给高段,加 4 才能跳到正确的高段寄存器。如果加 1,实际写到的是后一位的低段寄存器,把别的位的显示内容弄乱了。解决:先固定只显示一个数字,把两个帧字节打印出来,对照手册确认地址高 4 位的值在 0 到 7 之间,并且和自己的位对应关系一致。
5.3 SDA 一直为低:I2C 总线被从机锁死
现象:程序一走到 I2C 初始化,SDA 就被拉低,SCL 正常,复位 MCU 也没用。原因可能是上电瞬间 CH455 检测到了不完整的起始条件,进入了一个等待后续数据的状态,而后续数据永远没来。解决:在初始化 I2C 外设之前,用 GPIO 手动把 SCL 拉高,然后对 SDA 连续输出 9 个高低脉冲,相当于给它一个假的时钟序列,让它退出错误状态。这个过程也叫 I2C 总线复位,是处理从机锁死的通用手段。
/* 手动复位 I2C 总线:9 个时钟脉冲释放被拉低的 SDA */ void i2c_bus_reset(void) { GPIO_InitTypeDef gpio = {0}; gpio.Pin = GPIO_PIN_9; /* SDA */ gpio.Mode = GPIO_MODE_OUTPUT_OD; gpio.Pull = GPIO_PULLUP; HAL_GPIO_Init(GPIOB, &gpio); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_8, GPIO_PIN_SET); /* SCL 保持高 */ for (uint8_t i = 0; i < 9; i++) { HAL_GPIO_WritePin(GPIOB, GPIO_PIN_9, GPIO_PIN_RESET); HAL_Delay(1); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_9, GPIO_PIN_SET); HAL_Delay(1); } /* 复位完成,重新初始化 I2C 外设 */ }这个函数的实质是让 SDA 在 SCL 高电平期间反复变化,从从机的视角看就是连续收到不算数的起始事件,最终它会放弃等待,释放总线。执行完再回到正常初始化流程,一般就能恢复通信。我习惯在 main 函数最开始调用一次,成本很低,但能省掉很多不明原因的首次上电死锁。
5.4 四位亮度不均匀:限流电阻放错了位置
现象:数码管显示时,有一个位明显比其余三个暗,或者数字在切换时亮度跳动。原因很可能是在位线上串了限流电阻。四位共阴数码管的公共端流过的电流是八段之和,一旦这里串了电阻,每一位的公共端电压都会被抬高,段电流随之下降。不同位的压降差异会造成亮度不一致。解决:限流电阻只放在段线上,位线直接接 CH455 的 DIG 脚。亮度统一后,再微调段电阻阻值来匹配总体亮度。
5.5 调试器连着正常,重新上电就黑屏
现象:在线调试的时候,数码管显示一切正常,断开调试器、断电再上电,数码管完全不亮,但 MCU 程序明明在跑。原因通常是上电时 MCU 已经先跑起来了,I2C 外设初始化完成,但 CH455 的供电还没稳定,芯片处于未上电或半上电状态,丢失了第一次写入的数据。解决:在 main 函数进入主循环前,加一个 50 到 100ms 的延时,让 CH455 稳定。如果还是不行,那就在初始化 I2C 后重新调用一次全灭函数,把所有位写 0x00,把从机的内部状态拉回已知值。这个问题本质是上电时序问题,不算驱动 bug,但会让人误以为代码有问题,值得记住。
6. 往深走一步:逻辑分析仪验证时序与按键扫描的扩展空间
前面五章已经能把四位数码管正常点亮,但工程上「能亮」和「可靠」还不是一回事。最后一章回到验证手段和一个常见扩展方向,这两点对新老手都有价值。
6.1 用逻辑分析仪看帧结构,不用猜
我强烈建议把逻辑分析仪接到 SCL 和 SDA 上,抓一次 ch455_set_segment 的完整波形。重点看三件事:地址字节是不是 0x90(也就是 7 位地址 0x48 加写位 0),数据字节是否按高低半帧排列,ACK 位是否正常。眼不见为净,但 I2C 这种时序问题靠猜效率太低,抓一次波形,所有关于地址和帧结构的疑问都会消失。逻辑分析仪不必高档,采样率 20MHz 的入门款足够看 100kHz 的 I2C 波形。
抓完波形你会发现,显示 8 这个字符时,第 0 位的数据字节是0x0F和0x43,这正好对应低 4 段全亮和高 4 段中 g、dp 两段熄灭的状态。以后遇到显示异常,先用波形确认数据对不对,再看硬件,问题就分成两半了,排查范围小得多。
6.2 从显示到输入:一个 I2C 地址还能读按键
CH455 本身带按键扫描功能,这也是当初选它而不是单纯用移位寄存器方案的原因。同一个 I2C 地址,写方向是发显示帧,读方向就能读回按键状态。硬件上按键接在 SEG 和 DIG 之间,形成交叉矩阵,CH455 扫描到闭合后把键值放在内部寄存器,MCU 通过 I2C 读回来。驱动写法和显示类似,把 HAL_I2C_Master_Transmit 换成 HAL_I2C_Master_Receive 即可,只是读回的字节每个位代表一个按键位置。
这部分没有在标题要求的范围内,但如果你的面板既要显示又要按键,CH455 一颗芯片就能把两件事都干了,等于省掉一个独立按键扫描芯片。我上次做某跨平台系统的控制面板时,就是先实现显示再顺手把按键扫描加上,整个输入输出模块只占用两条 I2C 线,外围干干净净。
回顾整个调试过程,我最深的教训是:遇到通信类芯片,先花十分钟确认地址和帧格式,再动手写显示逻辑。省下的时间远比想象中多。纸上谈兵说「应该没问题」的每一处,最后都变成了一次示波器探头、一颗上拉电阻、或是一行地址左移的修正。希望这篇笔记能帮你少走这几步弯路。
本文还有配套的精品资源,点击获取