尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

STM8用查理复用:5个GPIO驱动20颗独立LED完整方案

STM8用查理复用:5个GPIO驱动20颗独立LED完整方案 简介面向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颗LEDLED之间完全独立亮灭一颗额外芯片都不用加。这套方案放在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个IO20颗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×26只。四个引脚就是4×312只。推广到N个引脚任意两个引脚组成一对每对能接方向相反的两只LED总数是N×(N-1)。用5个引脚正好是5×420颗。这个公式是查理复用最核心的算术基础也是我最初觉得“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共同决定引脚模式DDRCR1说明输入浮空00高阻态查理复用里的“断开”输入上拉01内部弱上拉一般不用在这里推挽输出11既能输出高也能输出低开漏输出10只能输出低或高阻外部需要上拉CR2在输出模式下控制翻转速度在输入模式下和施密特触发器相关。对查理复用来说CR2按常规配置即可不影响核心逻辑。为什么“输入浮空”而不是“输出0”来表示不选中的引脚这个坑我一开始犯过。当时图省事把所有不选的引脚都输出0结果扫描时出现严重的鬼影。原因是当两个被驱动的引脚之间如果中间还串着其它引脚而这些其它引脚又是低电平就可能形成一条不该存在的回路。比如A高、B低、C低时A到B的LED和A到C的LED同时都有导通条件结果一次点亮了两颗灯。把不参与本次驱动的引脚全部切到输入浮空它们就不提供任何电流路径鬼影自然消失。3.2 初始化与引脚模式切换代码代码里我封装了三个函数分别负责“全部释放为高阻”“某引脚推挽输出高”“某引脚推挽输出低”。完整代码如下#define LED_PIN_MASK 0x1F // 使用PD0~PD45个引脚 // 把所有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、阴极在PD1LED编号1阳极在PD1、阴极在PD0。这套数组是自动生成的规则就是遍历所有不相同的引脚组合先把“小号作阳极、大号作阴极”的配对排完再排“大号作阳极、小号作阴极”的反向配对刚好20组。实际接线时按这个数组把LED的阳极和阴极分别焊到对应引脚上即可。如果手头有LED灯板也可以按照灯位顺序把“灯0”到“灯19”依次对应到数组里的0到19号。4.2 限流电阻计算电阻选择不能按普通常亮LED的方法直接算因为每个LED实际占空比只有约1/20。以3.3V供电、普通红色LED压降约1.8V为例如果希望扫描时的瞬时电流约10mAR (3.3 - 1.8) / 0.01 150Ω取常用150Ω或180Ω。这时单颗LED承受的平均电流只有10mA/200.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×204000Hz。用STM8内部16MHz时钟TIM4预分频选16分频计数寄存器ARR设为249得到16MHz / 16 / (2491) 4000HzTIM4初始化代码可以这样写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×2032000Hz直接跑到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加电阻调试时重点盯住扫描顺序和刷新率这两个点几乎不会出大问题。本文还有配套的精品资源点击获取
返回列表