几个月前要搞一块LED点阵装饰面板,主控用的是手头一块STM32F103最小系统板,真正能挪出来的GPIO只有10个。按传统矩阵接法,8×8的屏需要16个引脚,直接卡死;加74HC595或者MAX7219倒是能解决,但又要多焊芯片、多占板子空间。后来翻资料翻到了Charlieplexing(查理复用算法),10个GPIO理论上能驱动90颗LED,8×8点阵只占56颗,余量很足。这个项目从原理分析、电路搭建到固件调试,前后折腾了几天,这里把可复用的设计方法和踩坑记录整理出来,给同样在纠结“引脚不够用”的朋友做个参考。
1. 为什么选Charlieplexing:引脚账必须算明白
1.1 传统点阵驱动的引脚开销
LED矩阵最常规的做法是行列扫描:M×N的点阵,用M个行引脚加N个列引脚驱动,8×8就是16个引脚,16×16直接飙到32个。如果点阵还要做灰度或者高刷新,还得额外加三极管或MOS管做行列扩流,引脚占用只多不少。很多项目做到一半发现MCU引脚不够,不是因为算力不够,而是IO口被LED点阵吃光了。
上串转并芯片(74HC595、74HC164)或者LED专用驱动(MAX7219、TLC5940)确实能解,但代价是增加BOM、增加PCB面积、多等一个采购周期。有时候只是想做个小装饰屏,实在不想为它加一颗芯片。
1.2 Charlieplexing的引脚收益表
Charlieplexing的核心收益就一句话:n个GPIO理论上能驱动n×(n-1)颗LED。对比传统矩阵方案(大约(n/2)²),领先非常明显:
| GPIO数量 n | 传统矩阵最多驱动 | Charlieplexing可驱动 |
|---|---|---|
| 3 | 4 | 6 |
| 4 | 4 | 12 |
| 5 | 6 | 20 |
| 6 | 9 | 30 |
| 8 | 16 | 56 |
| 10 | 25 | 90 |
| 12 | 36 | 132 |
从表里能直观看到,5个引脚就能驱动20颗LED,8个引脚覆盖一块8×8点阵还富余8颗。我的项目用10个引脚,计划驱动一块接近10×9的点阵,放在以前想都不敢想。
1.3 代价:软件复杂度与刷新率
天下没有免费的午餐,Charlieplexing省掉硬件,代价是软件更重。因为同一时刻只能点亮少量LED,必须靠动态扫描把所有LED快速轮询一遍,才会有“同时亮着”的视觉效果。这意味着:
- 每个时隙都要重新配置多个GPIO的方向和电平
- 刷新率要足够高,否则画面闪烁
- 扫描逻辑最好跑在定时器中断里,不能被主流程阻塞
实际感受是:同样驱动24颗LED,74HC595方案的主循环几乎不需要管显示,Charlieplexing方案却要保证每200微秒左右切换一次状态。如果你的主程序有阻塞延时,显示马上就会闪。
2. Charlieplexing核心原理:把高阻态用起来
2.1 为什么高阻态是关键
平时写GPIO,要么输出高、要么输出低、要么读输入。Charlieplexing真正用起来的是第三种状态——高阻态。高阻输入模式下,引脚既不向外部供电流,也不吸收电流,电路上等效于“这根线没接”。
打个比方:你家里有三盏灯,两根电线。正常情况下两根线之间只能接一盏灯。如果能把其中一路电线临时“摘掉”,再换到另一盏灯,就能依次点亮三盏灯。Charlieplexing就是在微秒级别不停“摘线、接线”,让每对引脚之间都能临时形成一条点亮回路。
2.2 一对引脚点亮两颗LED
两个引脚A和B之间接两颗反并联的LED:一颗从A到B,另一颗从B到A。平时看这个接法会觉得“这不是短路吗”,但加上GPIO三态控制就完全成立:
- A输出高、B输出低:电流从A流向B,点亮第一颗
- A输出低、B输出高:电流从B流向A,点亮第二颗
- 其余引脚全部高阻,电流不会串到其他地方
n个引脚共有C(n,2)个引脚对,每对2颗LED,总数就是n×(n-1)。所有LED分成两两一组,每组由一根“正极引脚”和一根“负极引脚”唯一确定。
2.3 引脚组合与LED编号映射
以4个引脚P0~P3为例,可以驱动12颗LED,映射关系长这样:
| LED编号 | 正极引脚 | 负极引脚 |
|---|---|---|
| L0 | P0 | P1 |
| L1 | P1 | P0 |
| L2 | P0 | P2 |
| L3 | P2 | P0 |
| L4 | P0 | P3 |
| L5 | P3 | P0 |
| L6 | P1 | P2 |
| L7 | P2 | P1 |
| L8 | P1 | P3 |
| L9 | P3 | P1 |
| L10 | P2 | P3 |
| L11 | P3 | P2 |
规律很明显:每个无序引脚对(Pi,Pj)生成两个条目,方向一正一反。实际工程里,我习惯把这张表直接写成静态数组存在代码里,程序运行时只做索引,不做任何计算。引脚数一多,手工列表很痛苦,可以用脚本自动生成,或者初始化时用双重循环填表。
3. 电路设计与关键参数计算
3.1 每个LED支路独立串电阻
很多初学者会想:在公共供电线上放一个总限流电阻行不行?在Charlieplexing里这是错的。原因有两个:
- 电流方向是动态变化的,同一颗电阻没法兼顾正反两个方向的限流
- 不同颜色LED的正向压降差异很大(红绿蓝能差0.5V以上),公共电阻反而会造成亮度偏差
正确做法是每颗LED串联独立电阻。原则很简单:每个支路的电流路径上有且仅有一颗限流电阻,位置在LED任意一侧都可以,但必须每颗都有。
3.2 限流电阻怎么算
以3.3V供电、红光LED(正向压降约2.0V)、目标峰值电流10mA为例:
R = (VCC - Vf) / I = (3.3 - 2.0) / 0.01 = 130Ω
取标准系列值150Ω,实际峰值电流约8.7mA。如果用5V供电、同样红光LED、目标15mA:
R = (5.0 - 2.0) / 0.015 = 200Ω
取标准值220Ω。
有一个细节容易忽略:GPIO输出高电平时不是理想电压源,大电流下电压会跌落。AVR单片机在20mA输出时高电平可能从5V掉到4.2V左右;3.3V供电的MCU在20mA输出时可能只有3.0V。做精确设计时,用万用表实测VOH再代入公式,会更接近真实电流。
再补一点动态扫描的知识:每个LED实际平均电流 = 峰值电流 × 占空比。20颗LED均分时占空比只有5%,如果直接按平均电流选电阻,点亮时会非常暗。所以我通常把目标峰值电流提高到15~30mA换取亮度,但前提是LED的峰值电流规格允许。常规φ5灯珠用30mA短时间冲击没问题,贴片LED要仔细查手册。
3.3 刷新率与每颗LED点亮时间
假设总共20颗LED、刷新率200Hz,扫描周期就是:
T = 1 / 200 = 5ms
每颗LED分到的点亮时间 = 5ms / 20 = 250μs,占空比 = 1/20 = 5%。
如果觉得亮度不够,按下面两个方向调:
- 提高峰值电流,注意不要超过LED峰值电流规格
- 降低刷新率到120Hz左右,延长单颗点亮时间,但小心闪烁
人眼对60Hz以上的闪烁已经不敏感,但点阵内容如果有动态效果,建议刷到200Hz以上。内容静态时可以适当降低刷新率换亮度。
3.4 硬件走线注意事项
动态扫描的电流是脉冲状的,瞬间电流可能冲到几十毫安。走线和布线比静态电路更讲究:
- 电源走线尽量短而粗,降低公共阻抗
- GPIO到LED的线尽量短,避免长线平行走线造成串扰
- MCU电源引脚附近放一个100nF到10μF的去耦电容
- 面包板实验时,杜邦线越短越好
我在面包板调试时遇到过LED按固定顺序微亮的现象,后来发现是杜邦线太长,引脚间串扰导致高阻态不再“高阻”。换成短跳线之后问题消失。
4. 固件实现:扫描逻辑与亮度控制
4.1 核心状态切换函数
Arduino环境下最核心的函数是setPins:先把所有引脚设为INPUT(高阻),再把目标引脚分别设为OUTPUT高和OUTPUT低。代码长这样:
#define PIN_COUNT 8 const uint8_t pins[PIN_COUNT] = {2, 3, 4, 5, 6, 7, 8, 9}; void setPins(uint8_t high, uint8_t low) { // 先把所有引脚切到高阻输入,切断上一轮电流路径 for (uint8_t i = 0; i < PIN_COUNT; i++) { pinMode(pins[i], INPUT); } pinMode(pins[high], OUTPUT); digitalWrite(pins[high], HIGH); pinMode(pins[low], OUTPUT); digitalWrite(pins[low], LOW); }为什么必须先把所有引脚置为INPUT?因为不这样做,切换到下一颗LED的间隙,旧引脚仍保持输出状态,可能出现瞬时电流直通路径,轻则乱闪,重则损坏GPIO。这个“先全高阻,再开目标”的顺序绝不省。
如果MCU引脚跨多个GPIO组,写的时候注意分组处理。以STM32为例,寄存器写法比库函数更高效:
void setPins(uint8_t high, uint8_t low) { for (int i = 0; i < PIN_COUNT; i++) { // 全部设为浮空输入,高阻状态 GPIO_Config(pins[i], GPIO_MODE_IN_FLOATING); } GPIO_Config(pins[high], GPIO_MODE_OUT_PP); GPIO_SetBits(GPIOx, Pin_high); GPIO_Config(pins[low], GPIO_MODE_OUT_PP); GPIO_ResetBits(GPIOx, Pin_low); }这个示例假设所有引脚在同一个GPIOx端口。实际跨端口时,把“设置方向”和“输出电平”按端口分组处理。
4.2 按映射表扫描所有LED
定义一张映射表,结构体里只存正极引脚和负极引脚的索引:
typedef struct { uint8_t high; // 正极引脚在pins[]数组里的下标 uint8_t low; // 负极引脚在pins[]数组里的下标 } LedChannel; const LedChannel ledMap[LED_COUNT] = { {0, 1}, {1, 0}, {0, 2}, {2, 0}, // 请按实际映射表补全 // ... };主循环扫描就非常简单:
uint8_t brightness[LED_COUNT]; // 亮度,0~255 void ledScan() { for (uint8_t i = 0; i < LED_COUNT; i++) { if (brightness[i] > 0) { setPins(ledMap[i].high, ledMap[i].low); } delayMicroseconds(LED_ON_TIME_US); // 固定时隙 } allPinsOff(); } void allPinsOff() { for (uint8_t i = 0; i < PIN_COUNT; i++) { pinMode(pins[i], INPUT); } }有两个细节值得注意。第一,即使brightness[i]为0,我仍然空等一个时隙,这样刷新率恒定,不会因为显示内容变化引起扫描频率漂移。第二,全部扫完后立刻allPinsOff,否则最后一颗LED会一直亮到下一轮扫描开始,亮度不均。
4.3 软件PWM实现灰度亮度
只有开关两档的LED点阵可玩性不高,做动画需要灰度。最实用的方案是位平面(bitplane)。原理:把一帧拆成多个子帧,每个子帧对应灰度值的一个二进制位,点亮时间按1:2:4:8……的权重分配。
#define GRAY_BITS 4 // 4位灰度,16级 void ledScan() { for (uint8_t sub = 0; sub < GRAY_BITS; sub++) { for (uint8_t i = 0; i < LED_COUNT; i++) { if ((brightness[i] >> sub) & 1) { setPins(ledMap[i].high, ledMap[i].low); delayMicroseconds(BASE_SLOT_US << sub); } else { // 同样空等,保持时隙一致 delayMicroseconds(BASE_SLOT_US << sub); } } } allPinsOff(); }这里的基础时隙BASE_SLOT_US要按最高权重位设计。例如4位灰度下,一帧总点亮时间等效15个基础时隙;LED数量越多、灰度位数越高,基础时隙越短,对切换速度要求越高。
真做8位灰度、56颗LED时,基础时隙会短到接近0.4微秒,普通库函数完全跟不上,必须寄存器级操作。这也是为什么我建议:中小型点阵用4位灰度就够,想要照片级效果还是老实上驱动芯片。
4.4 用定时器中断稳定刷新
千万不要在main loop里靠delay做显示刷新,除非程序真的什么别的事都不干。更好的做法是让定时器中断踢一脚,在一个时隙触发一次,中断服务函数里设置引脚状态,主循环只修改brightness数组。
void timerISR() { static uint8_t idx = 0; allPinsOff(); if (brightness[idx] > 0) { setPins(ledMap[idx].high, ledMap[idx].low); } idx = (idx + 1) % LED_COUNT; }中断里操作要快,别调用printf、别做浮点运算。主循环就专注于业务逻辑,比如解析串口命令、更新brightness数组。显示和业务彻底解耦,后续做动画、渐变都只需要改数组。
5. 常见问题与排查实录
5.1 多颗LED同时微亮
现象是点了一颗,旁边几颗跟着微微发光。绝大多数情况是漏电流路径:当前LED支路点亮时,电流通过旁边LED的寄生回路串过去了。
排查步骤:
- 先用万用表确认非目标引脚真的处于高阻输入,而不是误配成输出
- 检查焊盘有没有桥连短路,尤其是贴片LED
- 把限流电阻阻值加大,漏电流会成比例下降
- 换用漏电流更小的LED
还有一个STM32上的小技巧:不工作的引脚配置成模拟输入模式,比普通浮空输入高阻特性更好,专门对付这种微亮问题。
5.2 扫描画面闪烁
画面整体闪,先从公式入手算刷新率:
刷新率 = 1 / (LED_COUNT × 单颗点亮时间)
如果20颗LED、单颗点亮时间500μs,刷新率只有100Hz,静态内容勉强,动态内容大概率闪。用Arduino的digitalWrite时,每次切换可能要几微秒,20颗LED轮一圈的实际开销远超理论值,刷新率被拉低。
提升刷新率的手段,按性价比排序:
- 寄存器直接操作GPIO,替换digitalWrite,这是最有效的
- 用定时器中断精确定时,减少抖动
- 降低单颗LED点亮时间,但小心LED亮度骤降
- 如果用的ESP32,考虑用RMT或者LEDC+DMA做显示刷新
5.3 LED烧毁或引脚发热
引脚发热严重,基本是两种原因:一是某次切换时两个引脚同时输出一高一低,中间没有限流元件,形成直通短路;二是多颗LED同时点亮时,GPIO总电流超过数据手册上限。
对策很明确:
- 保证每轮切换前先全部恢复高阻,再开新引脚对
- 每颗LED的独立限流电阻不能省
- 查MCU手册确认GPIO最大灌电流/拉电流,几十毫安通常可以,几百毫安一定会出问题
- 实验电源先设置小电流限流,比如50mA,防止电路错误时炸LED
5.4 常见问题速查表
| 症状 | 可能原因 | 快速排查 |
|---|---|---|
| 全部不亮 | 引脚初始化错、电源没接好 | 万用表测引脚电压;单独测一颗LED |
| 多颗微亮 | 高阻态没生效、漏电流 | 检查GPIO配置;加大限流电阻 |
| 个别不亮 | 焊反、虚焊、映射表写错 | 用万用表二极管档测LED极性;对照映射表 |
| 亮度不均 | 走线压降、LED批次差异 | 加粗走线;扫描顺序均匀化 |
| 严重闪烁 | 刷新率低 | 寄存器操作 + 定时器中断 |
| 引脚发热 | 电流过大、引脚直通 | 核对限流电阻;检查切换顺序 |
6. 扩展方向与个人经验
6.1 更大的矩阵怎么玩
LED数量超过100颗时,单个MCU直接Charlieplexing有点吃力,毕竟每个时隙只点亮少量LED。常见做法是分组驱动:把LED分成若干组,每组独立引脚,组间再分时扫描。比如两个6引脚组,每组30颗,组合起来60颗,刷新率不变但引脚翻倍。
还有组合玩法:用74HC595先把3个MCU引脚扩展成16个并行IO,再做Charlieplexing,三个引脚驱动接近240颗LED。这种方案适合极端引脚受限的场景,但软件复杂度高,建议只用在点阵规模大、驱动芯片又缺货的时候。
6.2 顺带扫描按键:一份引脚两用
Charlieplexing不仅能点灯,还能读按键。思路是:在LED扫描间隙,把所有引脚设为高阻输入,检测外部按键是否按下;检测完再切回LED驱动状态。这类功能在小型交互设备上很实用,一套引脚既当显示驱动又当输入采集,省下来的引脚可以干别的。
注意时序:按键检测前要有一个“全灭窗口”,确保所有LED关闭,否则按下的瞬间可能会误触发LED通路。窗口时间通常几百微秒就够。
6.3 选MCU时怎么判断适不适合
几个硬指标:
- GPIO数量至少3个起,越多收益越高
- GPIO方向切换要快,最好能用寄存器操作
- 有定时器中断能力,刷新不占主循环
- 打算做灰度显示时,主频建议16MHz以上
实践下来,AVR、STM32、ESP32、树莓派Pico都能跑。ESP32引脚多,但Arduino环境下的digitalWrite开销很大,要直接操作GPIO.out寄存器才能发挥Charlieplexing的优势。
6.4 个人体会
这个项目做完,我对动态扫描的理解深了一层,尤其是三态GPIO这个平时不起眼的能力。Charlieplexing适合的场景很清晰:LED数量几十颗、引脚受限、不想加驱动芯片、MCU算力还有富余。如果你的屏幕很大、亮度要求高,老老实实上MAX7219或TLC5940反而是更好选择,别为了省一颗芯片把软件搞到难以维护。
最后分享一个通用的设计习惯:所有LED状态不要直接操作引脚,而是维护一个uint8_t的brightness数组,中断消费数组,主逻辑只改数组。显示和业务彻底分离,后续做动画、渐变、文字滚动,都只是在数组上做文章,底层扫描代码一次都不用碰。这个思路放到任何动态扫描项目里都通用。