简介:面向STM8单片机开发者,资源演示了用5个GPIO口可靠控制20个LED的查理复用方案,适用于引脚资源紧张但又需要多路指示、电量阶梯显示或流水灯等场景。包内共3个文件:C源码工程文件、头文件和一张接线/效果预览图,压缩后仅474KB,体积小巧;代码为作者自写自测,可直接挂载到现有工程中,头文件统一管理IO宏定义,更换引脚只需修改此处。单灯点灭可直接调用参数表选择目标,多个LED同时点亮则需配置分时扫描,比如LED1亮10ms后关闭再切换LED2,循环往复避免亮度损失;同时资源也提示了注意IO负载能力,方便开发者自行权衡。目前该资源已有3259人学习,对入门查理复用或想精简IO的嵌入式爱好者有直接借鉴价值。 如果板子上的GPIO不够用,又恰好是STM8这类8位小芯片,你多半会下意识想到扩展芯片。但有一次我在一个空间极小的板卡上,需要点亮20颗LED,每颗还要单独控制,当时实在塞不下一颗74HC595,更别说TM1640之类的驱动芯片了。最后用的是查理复用(Charlieplexing),也就是标题里说的“查理复用”:只用5个普通GPIO口,通过扫描方式驱动20颗LED,LED之间完全独立亮灭,一颗额外芯片都不用加。
这套方案放在STM8上非常合适,因为STM8的GPIO支持推挽输出、输入浮空等高阻态切换,这正是查理复用能跑起来的硬件基础。下面我按自己实际做过的项目,把原理、引脚映射、GPIO配置、扫描代码和调试中踩过的坑完整过一遍。无论你是刚开始玩STM8,还是已经在用IO扩展方案,都可以参考这份实现。
1. 从IO不够用说起:为什么我会选查理复用而不是扩展芯片
先说我当时的场景。MCU用的是STM8S003F3,这颗芯片便宜、资源够普通小产品用,但GPIO数量确实紧巴巴的。项目里除了20颗LED,还有按键、通讯口和几个状态检测脚,留给LED的引脚只剩5个。按传统做法,20颗LED要么用4行5列的矩阵扫描,至少9个IO,要么挂一颗74HC595,虽然只占3个IO,但要多一颗芯片、多几条走线。
我列了一下当时可选的方案:
- 74HC595串转并:占用3个IO,扩展8路,20路需要3片级联,PCB面积和成本都上去了。
- PCF8574这类I2C扩展芯片:只占2个IO,但需要I2C时序,而且一颗芯片最多也就8个IO,20颗LED还是要再搭配其他方案。
- TM1640/HT16K33这类LED驱动芯片:做键盘扫描和数码管很顺手,但我要的不是数码管,只是普通LED指示灯,专门加一颗驱动芯片有点浪费。
- 查理复用:不加任何芯片,5个GPIO直接驱动20颗LED,每颗独立控制。唯一的要求是这5个引脚必须支持推挽输出和输入浮空之间的快速切换,STM8完全满足。
从成本、面积、调试复杂度综合看,查理复用几乎是为这种场景量身定做的。它没有额外的芯片依赖,PCB上就是5条线和一串LED,甚至比矩阵扫描还省引脚。
当然我也得先接受它的物理局限:这20颗LED不是同时持续亮的,而是靠快速扫描轮流点亮。每颗LED在一个扫描周期里只分到约1/20的时间,靠人眼视觉暂留形成“看起来整片都亮着”的效果。如果要求20颗LED同时满电流高亮度,那查理复用就不合适,得老老实实上驱动芯片;如果只是做指示灯、电平显示、简单的跑马灯或者时钟,它绰绰有余。
2. 5个引脚变20路输出的核心原理
查理复用说起来其实不复杂,就一句话:任意两个GPIO之间可以接两只方向相反的LED,通过对引脚做“高、低、高阻”三态组合,让某个时间只有一对引脚被真正驱动。
先看最简单的两个引脚情况。假设有A、B两个引脚,中间可以接两只LED,一只阳极接A、阴极接B,另一只阳极接B、阴极接A。当A输出高、B输出低时,第一只亮;反过来B输出高、A输出低时,第二只亮。这样两个引脚就能控制两只LED。
如果引脚只能输出高和低,那做不到这种效果。因为你把其它引脚固定成高或低,电流就会绕路,不该亮的LED也可能被点亮。所以查理复用的关键就是引脚还需要第三种状态:高阻输入,也就是“我既不高也不低,不参与电路”。
三个引脚时,任意两脚之间都能放两只LED,一共是3×2=6只。四个引脚就是4×3=12只。推广到N个引脚,任意两个引脚组成一对,每对能接方向相反的两只LED,总数是N×(N-1)。用5个引脚,正好是5×4=20颗。这个公式是查理复用最核心的算术基础,也是我最初觉得“5个引脚控制20颗LED”不可思议的地方。
实际扫描时,一颗LED对应一个“槽位”。比如LED编号0对应“引脚0高、引脚1低”,LED编号1对应“引脚1高、引脚0低”。每一个时间片里,我先把所有引脚全部切换到输入浮空,然后只把当前槽位需要的两个引脚改成推挽输出,一个输出高,一个输出低,其余三个保持高阻。就这样一个槽位一个槽位地轮询,一轮跑完20颗LED,再从头开始。
这里有一个新手最容易混淆的点:既然是扫描,那“独立控制”到底是什么意思?这里说的独立控制,指的是每一颗LED的亮灭状态是独立维护的。我定义一个状态数组,每个槽位是否真正点亮,取决于状态数组里对应这一位是1还是0。扫描到某一位时,如果它该亮,就给对应引脚输出驱动信号;如果它不该亮,就跳过这个槽位,不输出。这样每颗LED都是独立控制的,虽然物理上同一时刻只有一颗LED可能导通,但人眼看起来整块区域所有该亮的灯都稳定亮着。
3. STM8的GPIO模式切换:高、低、断开缺一不可
查理复用的软件实现,本质上就是不停地在“推挽输出高”“推挽输出低”“输入浮空”三种模式之间切换GPIO。STM8的GPIO寄存器和常见单片机不太一样,它靠DDR、CR1、CR2三组寄存器组合出不同模式,这一点必须先搞清楚。
3.1 STM8端口寄存器的模式组合
我以STM8S系列常见的PD端口为例。每个端口相关的寄存器是PD_DDR、PD_CR1、PD_CR2,另外还有PD_ODR输出数据寄存器和PD_IDR输入数据寄存器。对单个引脚来说,模式由DDR和CR1共同决定:
| 引脚模式 | DDR | CR1 | 说明 |
|---|---|---|---|
| 输入浮空 | 0 | 0 | 高阻态,查理复用里的“断开” |
| 输入上拉 | 0 | 1 | 内部弱上拉,一般不用在这里 |
| 推挽输出 | 1 | 1 | 既能输出高,也能输出低 |
| 开漏输出 | 1 | 0 | 只能输出低或高阻,外部需要上拉 |
CR2在输出模式下控制翻转速度,在输入模式下和施密特触发器相关。对查理复用来说,CR2按常规配置即可,不影响核心逻辑。
为什么“输入浮空”而不是“输出0”来表示不选中的引脚?这个坑我一开始犯过。当时图省事,把所有不选的引脚都输出0,结果扫描时出现严重的鬼影。原因是,当两个被驱动的引脚之间如果中间还串着其它引脚,而这些其它引脚又是低电平,就可能形成一条不该存在的回路。比如A高、B低、C低时,A到B的LED和A到C的LED同时都有导通条件,结果一次点亮了两颗灯。把不参与本次驱动的引脚全部切到输入浮空,它们就不提供任何电流路径,鬼影自然消失。
3.2 初始化与引脚模式切换代码
代码里我封装了三个函数,分别负责“全部释放为高阻”“某引脚推挽输出高”“某引脚推挽输出低”。完整代码如下:
#define LED_PIN_MASK 0x1F // 使用PD0~PD4,5个引脚 // 把所有LED引脚全部设为输入浮空,相当于全部“断开” static void allPinsFloating(void) { PD_DDR &= ~LED_PIN_MASK; PD_CR1 &= ~LED_PIN_MASK; PD_CR2 &= ~LED_PIN_MASK; } // 让第pin个引脚推挽输出高电平 static void pinPushPullHigh(uint8_t pin) { PD_DDR |= (1 << pin); // 设为输出 PD_CR1 |= (1 << pin); // 推挽模式 PD_CR2 |= (1 << pin); // 快速翻转 PD_ODR |= (1 << pin); // 输出高 } // 让第pin个引脚推挽输出低电平 static void pinPushPullLow(uint8_t pin) { PD_DDR |= (1 << pin); PD_CR1 |= (1 << pin); PD_CR2 |= (1 << pin); PD_ODR &= ~(1 << pin); // 输出低 }初始化时也要先把所有引脚设置为输入浮空,避免上电瞬间引脚处于不确定输出状态导致LED乱亮。
这里提醒一句:如果你的芯片用ST-Link的SWIM接口调试,SWIM通常在PD1上。例子里的PD0~PD4只是为了统一说明,实际画板时建议把LED引脚放到其它端口,比如PA0~PA4或者PB0~PB4,具体看芯片型号和引脚复用,别把调试引脚和LED硬挤在一起。
4. LED编号表与限流电阻怎么定
有了模式切换函数,下一步就是把20颗LED和5个引脚之间的映射关系固定下来。映射一旦出错,你点亮的可能是另一颗灯,排查起来非常头疼。
4.1 20路映射表
我习惯用两个数组来定义映射关系,一个数组存每颗LED的阳极引脚编号,另一个数组存阴极引脚编号。以PD0~PD4为例,索引从0开始,对应PD0、PD1、PD2、PD3、PD4:
#define TOTAL_LEDS 20 // 每颗LED对应的阳极引脚 const uint8_t ledAnode[TOTAL_LEDS] = { 0, 1, 0, 2, 0, 3, 0, 4, 1, 2, 1, 3, 1, 4, 2, 3, 2, 4, 3, 4 }; // 每颗LED对应的阴极引脚 const uint8_t ledCathode[TOTAL_LEDS] = { 1, 0, 2, 0, 3, 0, 4, 0, 2, 1, 3, 1, 4, 1, 3, 2, 4, 2, 4, 3 };比如LED编号0,阳极在PD0、阴极在PD1;LED编号1,阳极在PD1、阴极在PD0。这套数组是自动生成的,规则就是遍历所有不相同的引脚组合,先把“小号作阳极、大号作阴极”的配对排完,再排“大号作阳极、小号作阴极”的反向配对,刚好20组。
实际接线时,按这个数组把LED的阳极和阴极分别焊到对应引脚上即可。如果手头有LED灯板,也可以按照灯位顺序,把“灯0”到“灯19”依次对应到数组里的0到19号。
4.2 限流电阻计算
电阻选择不能按普通常亮LED的方法直接算,因为每个LED实际占空比只有约1/20。以3.3V供电、普通红色LED压降约1.8V为例,如果希望扫描时的瞬时电流约10mA:
R = (3.3 - 1.8) / 0.01 = 150Ω,取常用150Ω或180Ω。这时单颗LED承受的平均电流只有10mA/20=0.5mA左右,亮度不算高。如果觉得暗,可以适当减小电阻到100Ω,但要注意瞬时电流会升到15mA左右,仍然在STM8的GPIO绝对最大额定值以内(一般约20mA)。不要为了追求亮度把瞬时电流拉到30mA以上,长期工作可能导致MCU引脚老化或发热。
我的经验是每个LED单独串一颗电阻,而不是20颗LED共用一颗。查理复用的电流路径很复杂,共用电阻会让不同LED的实际电流差异很大,而且一旦某个LED损坏,其它灯的电流也会被波及。每个人电阻多一点贴片元件,但对稳定性和调试效率的帮助是值得的。
5. 定时器中断里的扫描代码:从亮灭控制到PWM调光
映射表有了,GPIO模式切换函数也有了,接下来就是让系统按节奏扫描20个LED槽位。我建议把扫描函数放到定时器中断里,而不是放在主循环里用延时。因为主循环里只要插一段按键扫描或通讯处理,扫描节奏就会乱,LED会出现明显的闪烁或不均匀。
5.1 基础扫描与独立亮灭控制
核心数据结构是一个状态数组:
volatile uint8_t ledState[TOTAL_LEDS]; // 1亮0灭 void ledSet(uint8_t idx, uint8_t on) { if (idx < TOTAL_LEDS) { ledState[idx] = on ? 1 : 0; } }主程序里只要调用ledSet就能单独控制任意一颗LED。扫描函数如下:
static uint8_t scanIndex = 0; void charlieScan(void) { uint8_t a, c; // 第一步:先全部断开 allPinsFloating(); // 第二步:如果当前槽位的LED需要点亮,才驱动对应引脚 if (ledState[scanIndex]) { a = ledAnode[scanIndex]; c = ledCathode[scanIndex]; pinPushPullHigh(a); pinPushPullLow(c); } // 第三步:切换到下一个槽位 scanIndex++; if (scanIndex >= TOTAL_LEDS) { scanIndex = 0; } }注意第一步必须先执行。如果在切换前没有把之前两个输出引脚释放回高阻,而是直接从上一组高低组合改到下一组高低组合,就可能导致两个LED同时导通,或者出现短暂的桥路,表现为某个不该亮的LED闪了一下。
定时器中断频率这里我按刷新率200Hz来算。因为20颗LED轮完一轮是一帧,一帧内每颗LED只出现一次,所以中断频率应该是200×20=4000Hz。用STM8内部16MHz时钟,TIM4预分频选16分频,计数寄存器ARR设为249,得到:
16MHz / 16 / (249+1) = 4000Hz
TIM4初始化代码可以这样写:
void TIM4_Init(void) { TIM4_PSCR = 0x04; // 16分频 TIM4_ARR = 249; // 4000Hz更新中断 TIM4_IER = 0x01; // 开启更新中断 TIM4_CR1 = 0x01; // 启动定时器 }中断服务函数里只需要清标志位并调用charlieScan即可:
INTERRUPT_HANDLER(TIM4_UPD_OVF_IRQHandler, 23) { TIM4_SR1 = 0x00; // 清除更新标志 charlieScan(); }如果MCU主频不是16MHz,或者你想要的刷新率不同,套用上面公式重新算分频和ARR就行。低于通常50Hz的刷新率,人眼就会感到闪烁;我实际操作时即使50Hz也感觉有些闪,所以直接跑到了200Hz,效果稳定很多。
5.2 扩展软件PWM调光
如果想更进一步,让每颗LED不仅可亮灭,还能调节亮度,可以把ledState数组扩展为ledBrightness数组,用分级PWM的方式实现。基本思路是:把一帧再拆成多个亮度等级,扫描第level级时,只有亮度值大于level的LED才点亮。
#define BRIGHTNESS_LEVELS 8 volatile uint8_t ledBrightness[TOTAL_LEDS]; static uint8_t currentLed = 0; static uint8_t grayLevel = 0; void charlieScanPwm(void) { uint8_t a, c; allPinsFloating(); if (ledBrightness[currentLed] > grayLevel) { a = ledAnode[currentLed]; c = ledCathode[currentLed]; pinPushPullHigh(a); pinPushPullLow(c); } currentLed++; if (currentLed >= TOTAL_LEDS) { currentLed = 0; grayLevel++; if (grayLevel >= BRIGHTNESS_LEVELS) { grayLevel = 0; } } }这时每颗LED的最终刷新率会下降,因为一帧里要乘上亮度等级数。为了保持刷新率,定时器中断频率需要提高到原来等级数的倍数。比如原来200Hz刷新率、8级亮度,中断频率就要到200×8×20=32000Hz,直接跑到32kHz,对STM8来说能接受,但主循环里的任务会显得有点挤。一般做呼吸灯或跑马灯,用4级亮度已经能看出渐变效果,中断频率16kHz就够了。
6. 实测中的鬼影、闪烁和GPIO电流:这几个坑要注意
最后分享一些调试中的真实问题,这些问题文档里基本不会写,但几乎是每次做查理复用都会碰到的。
6.1 鬼影是怎么来的
鬼影就是不该亮的LED微微发亮。最常见原因是扫描函数里没有“先全部释放为高阻”。我在早期版本里图省事,先把上一组引脚改成输入浮空,再配置下一组,但顺序不对,结果PH0高电平瞬间通过多个引脚串到其它LED,出现了短暂的低亮度闪烁。
后来我把第一步固定成无条件全释放,而且写在函数最前面,这个问题就消失了。还有一个容易忽略的情况:如果用的是超高亮LED,反向漏电流比普通LED大一点,即使引脚已经浮空,也可能出现极微弱的鬼影。解决办法是在每颗LED两端并联一个1kΩ到10kΩ的泄放电阻,给漏电流一个低阻路径,不过大多数普通指示灯LED不需要这么处理。
6.2 刷新率低了会闪烁
如果LED肉眼可见地抖动,先别怀疑硬件,检查中断频率。我刚开始用TIM4的时候,分频和ARR没有算准,实际中断只有几百赫兹,轮完20颗LED之后整个刷新率只有十几赫兹,那种闪烁简直是灾难。重新按公式算过后才稳定。
一个更隐蔽的问题是,如果在主循环里用了长阻塞延时,比如按键消抖延时20ms,扫描照样会每中断一次执行一次,理论上不会因为主循环阻塞而停顿,除非你把中断关掉了。ST芯片库的库函数有时候会隐式操作中断优先级,调试不确定时可以加个IO翻转看波形,用逻辑分析仪数一下每秒扫描了多少轮。
6.3 GPIO电流别按平均电流估算
最后是一个容易吃大亏的地方。设计限流电阻时,我习惯同时check一下GPIO的最大瞬时电流。虽然前面算过平均电流只有1mA左右,但因为扫描模式下LED瞬时电流可能达到15~20mA,而同一个引脚在扫描周期里会频繁工作,所以不能只看平均电流。保守做法是把每个引脚的瞬时电流控制在10mA以内,这样平均电流基本都在1mA以下,GPIO基本不会发热。
如果你非要更高亮度,优先换高光效LED,而不是一味增大峰值电流。STM8的GPIO驱动能力有限,把电流硬往上推的结果是亮度提升不明显,反而芯片先发烫。真正需要高亮场景,老老实实在5个GPIO后面加一级三极管或MOS管驱动,或者换用专用LED驱动芯片。
按我这个结构做下来,5个GPIO驱动20颗独立LED的板子完全可以稳定跑。软件上就是个定时中断加一张映射表,硬件上就是LED加电阻,调试时重点盯住扫描顺序和刷新率这两个点,几乎不会出大问题。
本文还有配套的精品资源,点击获取