简介:面向STM8系列单片机开发者的查理复用LED控制方案,以五个通用输入输出口驱动二十个发光二极管,各LED可独立点亮或熄灭,适合电量阶梯显示、流水灯、状态指示灯等需要节省引脚资源的应用场景。资源共三个文件,分别对应C源码、头文件与效果展示图片,压缩包整体仅474KB,结构精细紧凑。头文件中预留了IO引脚宏定义,用户更换目标引脚时只需修改宏即可,不必改动主逻辑;单独控制某个LED时,可直接在参数表中选择对应编号;若需要同时点亮多个LED,则建议采用动态扫描方式,例如先让LED1亮十毫秒后关闭,再点亮LED2十毫秒后关闭,如此循环刷新,利用人眼视觉暂留呈现同时点亮的效果;作者自写自测可运行,代码风格清晰,能够直接挂接在现有工程中使用。资源同时提醒,LED驱动能力取决于引脚负载能力,设计电路时需要结合实际限流电阻与供电条件进行评估。目前已有三千二百五十九人学习下载,适合正在开发STM8多LED显示功能、希望快速掌握查理复用思想的工程师与电子爱好者参考。 第一次接触查理复用(Charlieplexing)这个概念时,我手头正好有一个很尴尬的需求:主控芯片是STM8,本身GPIO就少,结果还要驱动一组LED指示灯,而且要求每一颗都能独立亮灭。常规搞法要么加锁存器/移位寄存器,要么堆引脚做矩阵扫描,可我连一个空闲串口都快保不住了,哪还有余量给LED糟蹋。查理复用其实是个老玩法,核心就是利用GPIO的三态特性——输出高、输出低、输入高阻,让两根引脚之间挂两个反向并联的LED,n个引脚最多能控制n(n-1)个LED。5个引脚算下来正好20个,完美命中需求。这篇文章就把这个方案的原理、硬件接线、软件写法以及实际踩坑都捋一遍,适合想用最少引脚驱动大量LED、又不想多加芯片的开发者参考。
1. 方案选型:为什么是查理复用而不是矩阵或595
1.1 三态GPIO与“每对引脚两个LED”的数学原理
先把这个“5个脚控制20个灯”的底牌摊开。STM8的GPIO在输入模式下可以配置成浮空输入,浮空输入的引脚对外表现为高阻态,等效于这根线“没接”。这样一来,每个引脚就有三种可用状态:推挽输出高电平、推挽输出低电平、输入浮空。
任意选两个引脚A和B,如果A输出高、B输出低,电流就能从A流向B。这时候在A和B之间接一颗LED,A到B的方向就会亮;但同一个灯只能单向导通,所以两引脚之间只能点亮一个方向的LED。现在把思路升级一下:在A和B之间接两颗反向并联的LED,A高B低时点亮A→B那颗,A低B高时点亮B→A那颗,于是两根引脚就能独立控制两个LED。
推广到n根引脚,任意两根之间都存在一组反向并联LED,组合数就是C(n,2),每组里有2个LED,所以可控制总数是2×C(n,2)=n(n-1)。n=5时正好20个。这就是标题里“5个GPIO控制20个LED”的来历,一对也不少,一对也不多。
1.2 与矩阵扫描、移位寄存器的对比
做LED扩展,教科书方案通常是矩阵扫描。20个LED用4×5矩阵,需要4+5=9个GPIO;用595移位寄存器只需要3根线(数据、时钟、锁存),但要多加芯片、铺板面积和成本。查理复用在IO占用上比矩阵省将近一半,比595省了两根线,而且零额外芯片。
| 方案 | GPIO占用 | 额外元件 | 软件复杂度 | 平均亮度 | 适用场景 |
|---|---|---|---|---|---|
| 查理复用 | 5 | 10个电阻 | 中等 | 较低 | IO极度紧张、成本敏感 |
| 矩阵扫描 | 9 | 20个电阻 | 低 | 较低 | 行列规整的LED点阵 |
| 595移位寄存器 | 3 | 1颗595+20个电阻 | 低 | 高(可同时多路亮) | 不在乎芯片面积、要求高亮度 |
查理复用最吃亏的地方是同一时刻只能点亮一颗LED,靠人眼视觉暂留“同时”显示。如果20个LED全部点亮,每个LED的占空比只有5%,亮度会明显低于静态驱动。后面我会给出亮度补偿方案。我的选择逻辑很简单:STM8空闲引脚只有5个,又不想加芯片,那就查理复用;如果手上板子空间大、对亮度有硬要求,595才是更稳的答案。
2. 硬件设计:接线拓扑与限流电阻计算
2.1 5个引脚如何接出20颗LED
硬件连接是整个项目里最容易翻车的地方。先抽象地理解,再动手焊。
设5个引脚为P0、P1、P2、P3、P4。任意两引脚之间接一组反向并联LED。以P0和P1为例:一个LED的阳极接P0、阴极接P1;另一个LED反过来,阳极接P1、阴极接P0。这两个LED就是一对,编号时可以叫LED0和LED1。类似的组合共C(5,2)=10对,每对2颗,总共20颗。
关键点是每组反向并联LED之间要串一个限流电阻。你可以把电阻放在两颗LED的公共端,也就是“引脚→电阻→两颗反向LED→另一引脚”的结构。串在公共端的好处是无论电流往哪边流,都会经过同一个电阻,10组LED正好用10个电阻。接反的问题不用太担心,反向并联结构天然保证有一颗会亮,如果发现某颗不亮,优先检查是不是两颗LED焊反了极性。
实际在洞洞板上布局时,我建议把5个引脚想象成五角星的五个顶点,每对顶点之间通过电阻和一个LED对连接。这样走线最直观,也不会出现某个引脚下面挤了太多飞线的情况。焊之前先在纸上把10组连接的起点和终点标好,比边焊边想靠谱得多。
2.2 限流电阻的计算与电源电压选择
电阻值不能拍脑袋定。查理复用下同一时刻只有一颗LED导通,流过它的峰值电流就等于该LED的驱动电流,所以按单颗LED的工况计算即可。
最基本公式:R = (VCC - VF) / IF。
其中VCC是GPIO输出高电平时的近似电源电压,VF是LED正向压降,IF是目标峰值电流。STM8工作在5V时,红色/黄色LED的VF约1.8~2.0V,取目标电流10mA,计算得R=(5-2)/0.01=300Ω,实际取330Ω比较稳妥,既控制亮度又留有余量。如果想让LED更亮,把电流提到15mA,R=200Ω,但要注意STM8单个GPIO的驱动能力通常按20mA设计,别把它推得太满。
用3.3V供电时,红色LED压降2.0V,R=(3.3-2)/0.01=130Ω,也还行。但蓝色和白色LED的VF通常在2.8~3.3V,3.3V下电阻余量极小,亮度基本拉不起来。所以我的经验是:如果用蓝白LED,尽量用5V供电,电阻按220Ω起步,然后根据实际亮度微调。
还有一个绕不开的问题:20个LED全亮时,每个LED的占空比只有5%。实测下来,10mA峰值电流配合5%占空比,肉眼看到的效果大约相当于0.3~0.5mA直流,红黄高亮LED勉强够用,普通雾状LED就会偏暗。解决方案有两个方向:一是选用高亮型LED,二是把峰值电流提高到15mA左右换取更亮的瞬时光通量,但前提是确保IO口不超限。
2.3 引脚选择与焊接注意事项
很多人在这一步栽跟头。STM8的SWIM下载脚和复位脚都有特殊功能,如果把它们拿去驱动LED,程序下载和调试会变得非常别扭。以STM8S103F3P6这类常见芯片为例,SWIM复用引脚尽量避开,复位脚NRST也别碰,优先选择纯普通IO。
我最终用了5个口,全部集中在同一组端口上,比如PC0~PC4。这样做的好处是软件配置时可以用掩码一次性处理5个脚,代码更短,切换时序也更稳定。如果引脚分散在PA、PB、PC不同端口,每次切换都要关心不同寄存器的地址,代码会啰嗦不少。
焊接时有三个细节值得留意。第一,LED是有极性的,虽然反向并联结构容错,但两个LED之间一旦焊错脚位,会导致这对LED的“方向”和你的映射表对不上。第二,电阻不要省,哪怕你觉得某些LED亮度不够,也绝不能直接去掉电阻,否则IO口可能因为过流烧掉。第三,引脚与引脚之间的飞线要留出绝缘距离,洞洞板上铜箔间距小,热缩管或者绝缘胶带能预防意外短路。
3. 软件实现:从GPIO寄存器到20路扫描
3.1 用寄存器实现三态GPIO切换
软件是整个项目的灵魂。STM8的GPIO操作不复杂,但寄存器配置必须准确。每个端口有一组寄存器:ODR(输出数据)、IDR(输入数据)、DDR(方向)、CR1(控制1)、CR2(控制2)。我们要用到的三种状态对应关系如下:
| 引脚状态 | DDR | CR1 | ODR |
|---|---|---|---|
| 推挽输出高 | 1 | 1 | 1 |
| 推挽输出低 | 1 | 1 | 0 |
| 输入浮空(高阻) | 0 | 0 | 任意 |
注意CR2保持默认0即可,除非你需要快速翻转。这里先把基础封装写出来。假设5个引脚是PC0~PC4,寄存器地址直接用指针访问。
#define PC_ODR (*(volatile unsigned char *)0x500A) #define PC_IDR (*(volatile unsigned char *)0x500B) #define PC_DDR (*(volatile unsigned char *)0x500C) #define PC_CR1 (*(volatile unsigned char *)0x500D) #define PC_CR2 (*(volatile unsigned char *)0x500E) #define LED_ALL_PINS 0x1F // PC0~PC4 void led_all_floating(void) { PC_DDR &= ~LED_ALL_PINS; // 全部设为输入 PC_CR1 &= ~LED_ALL_PINS; // 禁止上拉,浮空 } void led_drive_pin(unsigned char pin, unsigned char high) { PC_DDR |= (1 << pin); // 输出 PC_CR1 |= (1 << pin); // 推挽 if (high) { PC_ODR |= (1 << pin); // 高电平 } else { PC_ODR &= ~(1 << pin);// 低电平 } }有了这两个函数,点亮任意一颗LED的逻辑就是三句话:先把所有引脚置为浮空输入,防止串扰;再让目标LED的阳极对应引脚输出高电平;最后让阴极对应引脚输出低电平。其他3个引脚保持高阻,电流没有别的通路,只能从阳极往阴极流。
3.2 LED映射表:20个LED与引脚方向的对应关系
20颗LED和5个引脚的对应关系需要一张映射表。我自己维护了一个二维数组,每一行表示一颗LED对应的阳极引脚和阴极引脚。顺序最好固定,方便后面做状态位操作。
const unsigned char led_map[20][2] = { {0, 1}, {1, 0}, // P0-P1 组的2颗 {0, 2}, {2, 0}, // P0-P2 组的2颗 {0, 3}, {3, 0}, // P0-P3 组的2颗 {0, 4}, {4, 0}, // P0-P4 组的2颗 {1, 2}, {2, 1}, // P1-P2 组的2颗 {1, 3}, {3, 1}, // P1-P3 组的2颗 {1, 4}, {4, 1}, // P1-P4 组的2颗 {2, 3}, {3, 2}, // P2-P3 组的2颗 {2, 4}, {4, 2}, // P2-P4 组的2颗 {3, 4}, {4, 3} // P3-P4 组的2颗 };用32位变量led_state存储20个LED的亮灭状态,bit0对应led_map[0],bit19对应led_map[19]。上层业务只需要修改led_state的某一位,比如“LED3亮”就是led_state |= (1<<3),底层扫描函数完全不用关心具体逻辑。
3.3 扫描时序与定时器刷新方案
驱动20个LED最核心的时序问题是刷新频率。人眼对50Hz以下的闪烁很敏感,所以一帧20个LED的完整扫描周期必须小于20ms,也就是刷新率高于50Hz。实际工程建议做到100Hz以上,即每10ms扫完一遍,这样几乎看不出闪烁。
最简单的实现是轮询扫描,在主循环里每次点亮一颗LED,延时一小段时间。伪代码如下:
void led_scan(void) { unsigned char i; for (i = 0; i < 20; i++) { if (led_state & (1 << i)) { led_all_floating(); led_drive_pin(led_map[i][0], 1); led_drive_pin(led_map[i][1], 0); } delay_us(50); } led_all_floating(); }这种写法的问题很直接:主循环一旦被其他任务阻塞,刷新率就变得不稳定,LED就会闪。更稳的做法是用定时器中断,把扫描任务放进中断里。以STM8的TIM4为例,配置成100us产生一次中断,每次中断处理一个LED;20次中断完成一帧,刷新率正好500Hz,肉眼完全无感。
volatile unsigned char scan_index = 0; // 假设TIM4中断已配置为100us一次 void tim4_update_isr(void) { TIM4_SR1 = 0; // 清中断标志 led_all_floating(); if (led_state & (1 << scan_index)) { led_drive_pin(led_map[scan_index][0], 1); led_drive_pin(led_map[scan_index][1], 0); } scan_index++; if (scan_index >= 20) { scan_index = 0; } }中断里有一步“全部浮空”特别重要。如果直接从上一颗LED的驱动状态切换到下一颗,上一颗的阳极引脚可能还保持高电平,瞬间会造成两条电流路径串扰。先全部浮空,再配置下一颗,相当于每次切换都“断电重启”,从根源上杜绝重影。
3.4 主循环里的使用示例
有了底层扫描,上层控制就简单了。比如做一个流水灯效果,主循环里每100ms移动一次“亮点”的位置:
while (1) { for (unsigned char i = 0; i < 20; i++) { led_state = 0; led_state |= (1 << i); delay_ms(100); } }至于“同一时间多颗LED亮”的需求,直接给led_state的多个位置1就行,扫描中断会自动按顺序点亮它们。由于扫描频率够高,人眼看到的就是多颗LED同时亮的效果。
4. 实测避坑:亮度、重影、闪烁与下载问题
4.1 亮度太暗或亮度不一致
这是查理复用最常被吐槽的点。我第一批样机焊完之后,20个LED全亮时整体偏暗,甚至有几颗明显比旁边暗。排查下来原因有两个:一是LED颜色不同导致VF差异,蓝色和红色LED在相同电流下亮度感知本来就不一样;二是不同组共用的限流电阻值一样,但VF低的LED电流更大、更亮,VF高的则相反。
解决思路是把颜色相同的LED分到同一批使用,实在混用的话,就根据每颗LED的VF单独计算电阻值。比如红色LED用330Ω、蓝色LED用220Ω,实测亮度就平衡不少。另外,查理复用天生占空比低,建议优先选高亮型LED,别在普通雾状灯珠上挣扎太久。
4.2 不该亮的LED出现微弱光晕
有一次我做呼吸灯效果时,发现目标LED旁边总有一两颗微微发光,非常掉档次。查了很久,问题出在“浮空”没有真正浮空。STM8的CR1寄存器如果被配置成1,输入模式下会开启内部上拉,LED两端就有了微弱压差,电流顺着电阻漏过去,不该亮的LED自然会有微光。
解决方法是严格保证每次切换前把所有引脚都写成CR1=0。我后来在led_all_floating里不仅清了ODR,还强制清了CR1,问题立刻消失。另外,如果LED焊得离引脚太近,助焊剂残留也可能形成几兆欧的漏电路径,清洁一下板子往往有奇效。
| 现象 | 可能原因 | 处理方法 |
|---|---|---|
| 目标灯亮,旁边灯微光 | 高阻态引脚内部上拉未关闭 | 配置输入模式时CR1清零 |
| 目标灯亮,前一个灯拖尾 | 切换前没有全部浮空 | 先全浮空,再配置新路径 |
| 整片区域微亮 | 板面助焊剂漏电 | 洗板或酒精清洁 |
4.3 肉眼可见的闪烁
闪烁一般不是硬件问题,而是刷新周期不固定。轮询扫描时如果主循环里有delay_ms(50)级别的延时,LED的刷新率会被拉低到20Hz左右,闪得没法看。后来我改用定时器中断扫描,把时隙稳定在100us,彻底解决了这个问题。
需要特别提醒的是,中断服务函数里不要做多余的事情,尤其是别调用printf或者浮点运算。STM8的主频不算高,中断处理时间必须严格小于时隙周期,否则下一轮中断到来时会丢失刷新节拍。实测下来,寄存器操作+简单状态判断的处理时间在1us以内,余量非常充足。
4.4 焊好电路后程序下载失败
这个坑极其隐蔽。我第一次布板时图省事,把SWIM引脚也当成普通GPIO用了,结果程序下载器连不上芯片,折腾了一晚上才发现是SWIM引脚上接的LED负载把下载时序拉坏了。STM8的SWIM下载协议对引脚电平比较敏感,如果这个引脚接着大电容或者LED限流电阻,可能导致下载失败。
解决方法是把LED全部安排在普通IO上,SWIM引脚保持干净。如果实在要复用,可以在下载时断开LED供电或在SWIM线路上加隔离电阻,但最省心的还是提前查手册规划好引脚分配。焊接前先用默认最小系统把下载调试跑通,再扩展LED电路,能省掉大量排查时间。
5. 更进一步:查理复用的扩展玩法
做完基础版之后,我试着把原理往别的方向延伸了一下。比如两两反向并联的LED位置可以不只放单色LED,换成红绿双色LED,或者用不同颜色的LED组合,这样每对引脚还能实现简单的颜色切换效果。调整映射表时,只需要修改对应引脚组合的方向即可。
另外,配合PWM可以实现呼吸灯。做法也不复杂:在定时器中断扫描完20个LED之后,用另一个软件计数器改变LED的目标电流时间,让每个LED的显示时隙宽度按正弦变化。由于总体扫描频率很高,视觉上就是平滑的呼吸效果。查理复用不是只能做亮灭控制的,只要把时间和电流两个维度利用起来,能玩的花样并不少。
最后分享一个实用小技巧:如果某颗LED始终微亮,且确认软件没错,可以在这颗LED两端并联一个1kΩ左右的电阻,把漏电流引导掉。但这个方法属于“治标不治本”,正常情况下不建议加,因为它会额外消耗驱动电流。项目做完后最重要的还是回到原理本身,把每个引脚的浮空配置、限流计算和扫描时序吃透,这套方案其实比想象中要稳得多。
本文还有配套的精品资源,点击获取