
第一次看到“5个IO控制20个LED”这个标题我第一反应是它大概率是“188数码管驱动”这类显示项目。手头MCU的GPIO本来就紧巴巴的还要接按键、通信、传感器根本不可能拿20个IO去挨个推LED。后来才想起Charlieplexing查理复用这个老技术n个IO理论上可以驱动n×(n-1)个LED5个IO正好驱动20个LED严丝合缝。这篇文章就把硬件怎么搭、代码怎么写、坑在哪里一次讲清楚适合正在做简易表头、电子标签、LED装饰灯或者单纯想省IO的DIY玩家和嵌入式入门者参考。整个项目不需要专用驱动芯片只靠MCU的5个普通GPIO和三态控制就能点亮20个LED组成的“188”字形显示。我会从方案选型开始讲逐步展开LED矩阵拓扑、限流电阻计算、STM32裸机驱动代码再给出实际调试中常见的问题和排查思路。内容偏实战代码部分拿STM32F103做示例换成51、Arduino、ESP32也只需要改底层的引脚操作函数。1. 项目概述与方案选型为什么20个LED只需要5个IO1.1 “188”字形的20个LED是怎么排出来的先解决一个疑问为什么是20个LED常见的三位数字显示很多人会直接想到三只共阴/共阳数码管每只8段a到g加dp一共24个LED。但实际显示“188”这个固定值时第一个数字“1”根本不需要a、f、g、e、d这些段只需要b和c两段就够了。两个“8”各需要7段这样就是27716个LED。为了凑成更通用的20个LED方案我额外预留了4个独立LED用来做小数点和分隔点这样显示“1.88”或者“1:88”都行接口逻辑也不会增加太多。如果你手头的模块是标准三位七段数码管也可以按这个思路做把第一个数码管的不可用段当成预留LED或者直接不接、悬空。核心驱动逻辑不变只是“20个LED”变成了“有多少个就驱动多少个”。我在下面的硬件设计里为了复现方便直接采用“裸LED按字形排列”的方式不用依赖具体数码管型号。1.2 三个驱动方案对比直驱、动态扫描、查理复用做20个LED的显示通常有几种路线。第一种是最简单的直驱每个LED需要1个IO口20个LED至少20个IO。对于STM32F103C8T6这种48脚芯片来说全部GPIO也就30多个直接占掉大半不现实。第二种是动态扫描把LED按“位”分组例如3位数字用7个段选线加3个位选线大约需要10到11个IO比直驱少了一半但这仍然有点浪费而且需要处理好消隐和刷新时序。第三种就是Charlieplexing查理复用。核心思路是利用GPIO的三种状态输出高、输出低、高阻输入。任意两个IO之间都可以跨接两个方向相反的LED所以5个IO一共有C(5,2)10对组合每对可以控制2个LED总数正好是10×220个LED。这种方案IO占用最少不需要锁存器、译码器或驱动芯片只需要5颗限流电阻缺点是扫描逻辑相对复杂亮度也比直流驱动低但用于“1.88”这种固定字形的显示完全足够。我把三种方案放在一起对比大家可以根据手上资源选方案最少IO数量外围器件软件复杂度适用场景直驱20个20个限流电阻最低无扫描IO充足的固定显示动态扫描10~11个位选三极管、段限流电阻中等需要定时刷新标准数码管多位数显Charlieplexing5个5个限流电阻较高需要三态和扫描IO极度紧张的复用显示1.3 查理复用原理高阻态才是灵魂初学者很容易把Charlieplexing理解成“用两个IO控制两个LED”其实关键在于“第三态”高阻。高阻状态下引脚相当于从电路中断开不会提供电流也吸收不了电流这样一来只有被配置成高和低的两个引脚之间的LED才会形成通路并被点亮其他LED因为至少有一端接高阻回路被切断。我习惯用“水阀”来类比每个GPIO是一个三通阀可以接高压、接低压也可以完全关死。两个阀门之间接一个LED只有一侧给高压、另一侧给低压时LED两端才有电位差如果其中一个阀门关死管道里就没有水流LED自然不亮。对于5个IO假设编号为P0到P4任意一对引脚比如P0和P1之间接两个反向并联的LED一个LED的正极接P0、负极接P1另一个LED的正极接P1、负极接P0。当P0输出高、P1输出低、其他引脚高阻时第一个LED亮反过来P1输出高、P0输出低时第二个LED亮。这样一对IO就能控制2个LED。5个IO共有10个引脚对驱动20个LED数量完全对得上。2. 硬件设计原理图、引脚分配与关键参数2.1 LED矩阵拓扑5根线怎么接出20个LED具体怎么把这20个LED接到5个IO上直接看表比较清楚。我用的引脚是PB0、PB1、PB2、PB3、PB4编号记为P0到P4。表格里的“连接方式”表示点亮该LED时哪个引脚输出高、哪个引脚输出低其余引脚全部高阻。LED编号显示位置连接方式高→低说明LED0数字“1”的b段P0→P1数字1右侧竖LED1数字“1”的c段P1→P0数字1右侧竖LED2第1个“8”的a段P0→P2上横LED3第1个“8”的b段P2→P0右上竖LED4第1个“8”的c段P0→P3右下竖LED5第1个“8”的d段P3→P0下横LED6第1个“8”的e段P0→P4左下竖LED7第1个“8”的f段P4→P0左上竖LED8第1个“8”的g段P1→P2中横LED9第2个“8”的a段P2→P1上横LED10第2个“8”的b段P1→P3右上竖LED11第2个“8”的c段P3→P1右下竖LED12第2个“8”的d段P1→P4下横LED13第2个“8”的e段P4→P1左下竖LED14第2个“8”的f段P2→P3左上竖LED15第2个“8”的g段P3→P2中横LED16小数点1P2→P4小数点/分隔点LED17小数点2P4→P2小数点/分隔点LED18分隔点/冒号上P3→P4冒号或分隔LED19分隔点/冒号下P4→P3冒号或分隔这个表同时也是后面代码里led_map数组的数据来源。实际排布PCB时眼睛看到的是LED按“1.88”字形排布但电路连接必须严格按表格来这一点非常容易乱。我的做法是先在纸上画出20个LED每个LED旁边标上编号再对照表格一个个连线。2.2 限流电阻怎么选一个电阻还是五个电阻常见做法是在每一个IO引脚上串联一个限流电阻而不是给每个LED单独串电阻。这样20个LED只需要5个电阻节省PCB面积和成本。由于扫描时同一时刻通常只点亮一个LED电流路径是高电平引脚→该引脚串的电阻→LED→低电平引脚→该引脚串的电阻所以电流要经过两颗串联电阻。以STM32F103在3.3V供电为例GPIO输出高电平大约3.0V到3.3V红光LED正向压降约2.0V。如果每颗电阻取220Ω那么点亮单颗LED的电流大约是I (VOH - VF) / (R1 R2) (3.3 - 2.0) / (220 220) 2.95mA2.95mA对普通LED来说偏暗但如果是高亮LED在强光环境下也基本够用。想要亮一些可以把电阻减小到100Ω电流大约6.5mA减小到47Ω电流大约13mA接近普通LED的额定工作电流。要注意如果用的是5V系统比如STC89C52电压余量大同一颗电阻电流会明显变大建议阻值提高到330Ω或470Ω避免烧IO口。需要特别提醒Charlieplexing对限流电阻的依赖比普通直驱更强因为IO口直接驱动LED没有其他限流元件。如果漏掉电阻或者把电阻焊错位置点亮LED瞬间的电流可能超过GPIO承受能力轻则显示异常重则烧引脚。我实际测试中5V系统用330Ω、3.3V系统用100Ω到220Ω是比较稳妥的组合。2.3 最小系统与PCB布局要点MCU我选了STM32F103C8T6这只芯片便宜、资料多而且引脚支持“输入浮空”模式正好对应Charlieplexing需要的高阻态。最小系统部分用常规的8MHz晶振、复位电路、3.3V供电和BOOT0下拉即可。5个IO直接选PB0到PB4这样引脚集中PCB走线也好处理。PCB布局上我的建议是LED字形区域放在板子正面按“1.88”排布5颗限流电阻紧靠MCU引脚放置尽量避免长走线引入干扰。如果只是验证原理用洞洞板手焊也完全可行但焊之前一定要拿万用表二极管档确认LED极性否则20个LED很容易焊反几个。在没有实物的阶段可以在Proteus里先搭一个简化电路仿真把5个IO、5个电阻和20个LED按表接好跑一遍扫描代码。Proteus里的LED模型比较理想化仿真通过后仍然要回到实物验证特别是电流和亮度这些参数仿真只能做参考。3. 代码实现扫描驱动、刷新率与显示逻辑3.1 核心数据结构LED映射表与段码表写代码的第一步不是上来就操作寄存器而是先定义好映射关系。我把5个IO引脚编号和20个LED的“点亮方向”放在一个二维数组里第一维是LED编号第二维两个元素分别是高电平引脚和低电平引脚。这样后续驱动函数只需要查表即可。#define LED_NUM 20 #define IO_NUM 5 const uint8_t led_map[LED_NUM][2] { {0, 1}, {1, 0}, {0, 2}, {2, 0}, {0, 3}, {3, 0}, {0, 4}, {4, 0}, {1, 2}, {2, 1}, {1, 3}, {3, 1}, {1, 4}, {4, 1}, {2, 3}, {3, 2}, {2, 4}, {4, 2}, {3, 4}, {4, 3} };注意这个表顺序和2.1小节的“连接方式”是一一对应的比如LED0对应{0,1}意思是P0输出高、P1输出低LED0亮。对于20个LED有没有亮的需求我用一个长度为20的显示缓冲区数组disp_buf来记录1表示要亮0表示不亮。显示“1.88”时需要把对应段位置1其他段置0。uint8_t disp_buf[LED_NUM] {0}; const uint8_t disp_188[LED_NUM] { 1, 1, // 数字1的b、c段 1, 1, 1, 1, 1, 1, 1, // 第一个8的a~g段 1, 1, 1, 1, 1, 1, 1, // 第二个8的a~g段 1, 0, 0, 0 // 小数点1常亮其他点不亮 };这样做的好处是后期想显示“1.23”或者“8.88”只需要改disp_buf的内容扫描驱动代码完全不用动。如果要做成一个通用数码管驱动甚至可以再写一个数字到段码的映射函数按标准共阴七段码表填进去。3.2 GPIO三态切换高阻输入与推挽输出STM32F103的GPIO通过CRL/CRH寄存器配置模式。每个引脚占用4位其中MODE位决定输入/输出以及速度CNF位决定输入浮空/上拉或输出推挽/开漏。我们只需要用三种状态浮空输入高阻、推挽输出高、推挽输出低。下面这段代码是基于寄存器的简洁实现避免了HAL库每次重新初始化GPIO带来的额外开销。默认使用GPIOB的0到4号引脚如果你换引脚只需要修改对应寄存器和掩码。#define IO_GPIO GPIOB static void pin_set(uint8_t idx, uint8_t mode) { uint32_t shift idx * 4; uint32_t crl IO_GPIO-CRL ~(0xFUL shift); if (mode 0) { // 模式0浮空输入对应 GPIO_MODE_INPUT GPIO_NOPULL IO_GPIO-CRL crl | (0x4UL shift); } else { // 模式1/2推挽输出 IO_GPIO-CRL crl | (0x1UL shift); if (mode 1) { IO_GPIO-BSRR (1UL idx); // 输出高 } else { IO_GPIO-BRR (1UL idx); // 输出低 } } } static void all_high_z(void) { pin_set(0, 0); pin_set(1, 0); pin_set(2, 0); pin_set(3, 0); pin_set(4, 0); } static void led_on(uint8_t idx) { all_high_z(); // 先把所有IO切成高阻 pin_set(led_map[idx][0], 1); // 高电平引脚 pin_set(led_map[idx][1], 2); // 低电平引脚 }这里最关键的步骤是点亮任何一个LED之前必须先把5个IO全部设为高阻。如果不这样做上一个LED点亮时设置的输出状态可能会影响下一个LED甚至出现瞬间倒灌电流。all_high_z函数就是用来保证每次扫描都从“全断开”状态开始。3.3 定时器扫描刷新率和占空比怎么平衡Charlieplexing属于动态驱动肉眼看到“同时亮”的显示效果其实是LED按极快的速度轮流被点亮。我使用TIM2定时器中断每隔0.5ms切换一个LED这样20个LED正好10ms扫完一轮刷新率100Hz肉眼基本感觉不到闪烁。volatile uint8_t scan_idx 0; void TIM2_IRQHandler(void) { if (TIM2-SR TIM_SR_UIF) { TIM2-SR ~TIM_SR_UIF; all_high_z(); if (disp_buf[scan_idx]) { pin_set(led_map[scan_idx][0], 1); pin_set(led_map[scan_idx][1], 2); } scan_idx; if (scan_idx LED_NUM) scan_idx 0; } }如果定时器周期设成1ms一轮扫描就是20ms刷新率降到50Hz快速晃动时能看到闪烁。0.5ms是推荐值但MCU处理20个LED的切换需要时间如果扫描函数里做了太多别的操作可能要适当放宽到0.6ms或0.7ms。占空比方面每个LED在一个扫描周期内只被点亮1/20的时间也就是5%左右。对普通LED来说这个亮度会比较暗所以我在硬件设计部分反复强调“高亮LED”这是Charlieplexing项目中非常影响体验的一点。想在软件上补足亮度可以考虑分组扫描也就是在一次切换里让多个互不冲突的LED同时点亮但逻辑会复杂许多建议先把单灯扫描跑通再考虑优化。3.4 显示“1.88”的主循环与灵活扩展主循环其实很空闲因为显示内容提前放到disp_buf里扫描完全交给定时器。固定显示“1.88”的代码最短只需要复制缓冲区int main(void) { // 初始化时钟、GPIO、定时器略 for (int i 0; i LED_NUM; i) { disp_buf[i] disp_188[i]; } __enable_irq(); while (1) { // 主循环可以做其他事情或者延时 } }如果希望动态切换显示内容比如“1.88”和“8.88”来回切换只需要在主循环里修改disp_buf再加一个简单的切换延时。由于扫描由中断驱动即使主循环被阻塞显示也不会停这一点是中断扫描方案相比纯软件延时的优势。想要做亮度调节也很容易把disp_buf从“0/1”改成“0~255”灰度值在定时器中断里累加一个子计数器让每个LED在T_on/20的时间内亮、其余时间灭等效于软件PWM调光。这样就能在不用额外硬件的前提下实现LED亮度可调适用范围会比固定亮度广很多。4. 常见问题与排查技巧实录4.1 所有LED都不亮只有个别能亮这种情况最常出现在第一次上电的时候。我先检查电源和MCU是否正常运行然后挨个调用led_on(0)、led_on(1)……单独点亮看哪些灯能亮、哪些不能。如果某几个灯不亮大概率是LED极性接反或者焊接虚焊。比如表格里写的是P0→P1那LED的正极必须接P0、负极接P1。把引脚看成“高→低”方向之后一定不要只顾着按布局排LED却忽略了方向。还有一次我遇到所有led_on测试都正常但一运行扫描显示就不对后来发现是led_map数组里某个编号的引脚对写错了导致两个LED共用了同一个方向。建议正式显示之前先写一个流水灯测试按顺序把led_idx从0到19循环点亮每个亮200ms确认20个LED的位置与编号完全一致。4.2 显示有鬼影、残影数字边缘发虚鬼影是Charlieplexing最常见的问题之一。原因通常是切换槽位的瞬间没有把所有引脚切成高阻或者引脚切换太快LED内部的寄生电容让前一个LED的余辉残留。解决办法是在每次切换之前强制调用all_high_z如果还有残影可以在全高阻之后加一个极短的延时比如几个空的循环让引脚状态彻底稳定。另一个原因是刷新率不够。如果定时器周期用1ms50Hz刷新人眼会明显感觉到闪烁和拖影。我建议把周期调到0.5ms同时检查中断服务程序里不要放过多的耗时操作。扫描函数尽量只做GPIO寄存器操作不要调用HAL_Delay之类的阻塞函数。4.3 IO口发烫甚至烧坏引脚这是比较严重的问题通常是电路或代码中出现了非预期通路。典型场景是某几个LED被同时点亮但它们的引脚组合产生了交叉回路造成大电流。比如P0输出高、P1输出低同时P1还要被其他LED驱动为高此时P0和P1之间没有任何限流元件或者虽然有限流但路径不止一条电流就不可控了。我的排查思路是先在代码层面保证“同一时刻只有一个LED被点亮”也就是严格执行“先全高阻再配高一低”的顺序。然后在硬件层面检查5颗限流电阻是否都接在离MCU最近的IO线路上不要中途跳过。最后用万用表电流档串在一个LED回路里看瞬时电流是否在安全范围超过20mA就要降低电压或增大电阻。4.4 常见问题速查表现象可能原因处理办法所有LED不亮电源、接线、GPIO未初始化检查MCU供电逐个单灯测试部分LED不亮LED极性接反、虚焊、led_map错误对照表格重查极性写流水灯测试显示乱闪、残影扫描频率太低或未高阻切换提高定时器频率切换前全高阻发光亮度太暗限流电阻过大、LED非高亮减小电阻到100Ω左右换高亮LEDIO口发烫多个LED同时点亮造成过流检查扫描逻辑确认限流电阻位置Proteus正常实物异常仿真LED模型理想实物有压降差异以实物电流为准重新调整电阻5. 经验总结与后续扩展5.1 再进一步6个IO驱动30个LED搞懂了5个IO驱动20个LED也很容易扩展到6个IO驱动30个LED因为n个IO能驱动的最大LED数量就是n×(n-1)6个IO对应6×530。方法完全一样只是led_map数组扩大到30项扫描周期变成30个槽位。扩展到更多LED时要注意刷新率问题假设定时器周期仍为0.5ms30个LED扫一轮要15ms刷新率约67Hz也还能接受。如果LED数量上百就需要更快的MCU或者分组同时点亮否则亮度会很低。这也是为什么大尺寸LED点阵一般不用纯Charlieplexing而是用动态扫描加驱动芯片。5.2 和专用LED驱动芯片比Charlieplexing值不值得用做这个项目之前我也纠结过要不要直接用MAX7219或HT16K33这类LED驱动芯片。它们通过SPI/I2C通信MCU只要2到3个IO就能驱动几十个LED软件上还有现成库显示效果和数据刷新效率都更好。但硬件成本、PCB面积、采购周期都比纯GPIO方案多。Charlieplexing最适合的场合是“IO已经不够用、手头没有驱动芯片、显示内容固定、成本敏感”的项目。比如显示固定的“1.88”电压表头、小家电状态指示、简易温度标签这类场景不需要频繁刷新大量数据也用不着买芯片。如果要在同一块板子上显示动态数字并且对亮度要求高我更倾向加一颗MAX7219省心很多。5.3 个人实操体会与避坑建议这个项目做完之后我最大的体会是代码并不难写难的是建立“LED编号→引脚方向→字形位置”三者之间的映射关系。半个晚上的调试时间几乎都花在反复核对LED是否接反、引脚表是否写错上面。所以强烈建议动手前先做两个准备第一在纸上画一张20个LED的布局图标好编号第二对照原理图确认每个LED的正负极方向。都确认好再上电。还有一个小技巧调试阶段不要直接加载“1.88”的完整显示数据先用流水灯逐个点亮LED确认20个灯都能通过led_map正确寻址。这个步骤花费的时间很少但能避开后面最大的雷。如果你也打算在自己的项目里用Charlieplexing我建议先从5个IO、20个LED的小规模开始跑通之后再考虑加大LED数量。这个方案硬件极简软件有一点门槛但跨过这个门槛以后你对GPIO三态控制、动态扫描和刷新率这些基础概念的理解会明显上一个台阶。