☰
5个IO驱动20颗LED:查理复用实现188数码管的实战记录
2026/9/28 16:44:21 网站建设 项目流程

最近在整一个三位显示模块,需求很直接:用一块188数码管显示计数结果,里面一共20颗LED。麻烦的是主控板的IO口已经见底了,传统动态扫描要位选加段选,至少10个脚起步,我手里只剩下5个空闲IO。最后的方案是把20颗LED重新排成一个5节点网络,用查理复用把驱动全部塞进这5个IO里,一个外围芯片都没加。这篇就把这次硬件连线、电阻计算、三态扫描代码、调试踩坑的完整过程记录下来。

如果你手里也有一堆IO不多但LED不少的模块,或者一直想弄明白“为什么5个IO能控制20个LED”,这篇能给你一个直接可参考的模板。整体会按方案选型、硬件设计、代码实现、问题排查四条线展开,尽量把每个“为什么”都说透,而不是只贴一张电路图让你抄。

1. 方案选型:为什么不用传统位选加段选

1.1 传统做法的IO账本算下来不够

很多人第一反应是用动态扫描。这个思路本身没错,标准数码管也基本都是这么驱动的:把LED按位分组,公共端接位选,段端接段选,分时点亮每一位。但问题在于IO口数量。

假设模块里有20个LED,如果按“3个数字位 + 若干辅助段”来分组,动态扫描最少需要3个位选IO,还要7到8个段选IO,加起来就是10到11个IO。如果把20个LED当成独立LED一对一控制,那更夸张,要20个IO。很多板子主控芯片总共就二三十个IO,要接按键、通信、传感器,留给显示的往往就几个脚,传统做法根本塞不下。

有人说可以加锁存器或者串转并芯片,比如74HC595,用3个IO就能扩展出8路甚至更多输出。这确实是通用解,但代价是增加外围器件、多一根时钟线、多一份焊接工作。对一个只有20颗LED的小模块来说,有点“杀鸡用牛刀”。我当时的目标很明确:不加任何驱动芯片,只靠MCU自身的IO口和几颗电阻,把显示驱动搞定。所以查理复用成了最合适的选择。

1.2 查理复用的数学基础:5个IO为什么刚好是20个LED

查理复用(Charlieplexing)的核心思想,是让任意两个IO口之间都能形成一条“受控通路”。普通的IO口只有高电平和低电平两种输出状态,但单片机还有一个特殊状态:输入模式,也就是高阻态。高阻态相当于把引脚和内部电路断开,既不像高电平那样输出电流,也不像低电平那样吸收电流。正是这个“第三种状态”,让IO口之间可以组成网络。

如果n个IO口两两配对,任意两个IO之间可以接两个反向并联的LED(一个正向、一个反向),那么能驱动的LED数量就是组合数C(n,2)再乘以2,也就是n(n-1)个。对于5个IO口:C(5,2)=10,10×2=20,正好是20个LED。

这个数字不是硬凑的。每个IO口都是一个节点,任意两个节点之间可以形成两条方向相反的“有向通路”。点亮某个LED时,只需要让对应的两个节点之间存在电位差,其他节点全部切成高阻。我用一个生活化的类比来解释:5个IO口像5个人,每个人能喊话、能听声、也能完全不出声,任意两个人之间有一条电话线,每条电话线能听两个方向,总共就有20个方向的“通话线路”。想让哪颗LED亮,就在对应的线路上接通电源。

1.3 几种常见驱动方案对比

纸上谈兵不够,实际选型还是要看场景。我把几种方案放在一起对比过:

方案最少IO占用外围器件代码复杂度亮度表现适用场景
IO一对一直驱20无极低最高IO非常富余、LED很少
动态扫描位选+段选10~11位驱动三极管低高标准共阳/共阴数码管
74HC595串转并3每级1片595中高LED数量大、亮度要求高
数码管专用驱动芯片2(I2C)1片驱动IC低高标准数码管、不想写扫描
查理复用5只有电阻中中(依赖优化)IO紧张、小规模LED网络

说实话,如果IO足够多,我不会选查理复用,因为扫描亮度天生会打折扣。但真到“5个IO控制20个LED”这种极限场景,它是最省钱、最优雅的方案,只需要IO口和限流电阻,连一个三极管都不用加。

2. 硬件设计:把20个LED接成5节点网络

2.1 先确认你的数码管能不能这么玩

动手之前先泼一盆冷水:不是所有数码管都能用查理复用。常规的三位七段数码管,内部已经按共阳或共阴连接好了,很多引脚是连在一起的,并不是每个LED的两极都独立引出。你把公共端拆不开,矩阵就组不起来。

我这次用的188数码管,是按“20颗LED一体安装、内部每颗LED可独立控制”来处理的。如果你手里的模块不是这种,先别急着画板。用万用表二极管档可以快速确认:红黑表笔分别去量不同引脚,如果发现某一组引脚之间总是固定导通,不管怎么换方向都是同一个公共端,说明内部已经做了共阳或共阴连接,查理复用接不出来。

确认不了的还有一条退路:用独立LED重建这20个点。把20颗LED按目标排列焊在洞洞板上,每颗LED的两脚都用杜邦线或飞线引到矩阵节点。虽然丑,但功能完全没问题。如果连手工重建都觉得麻烦,那就老实换74HC595方案,3个IO也能控制20个LED,只是多焊一片芯片。这个决定一定要在画板之前做,否则后面全白费。

2.2 限流电阻怎么选,不会翻车

查理复用的限流电阻计算和普通LED驱动没有本质区别,关键是用“单颗LED点亮时的瞬时电流”来算。

假设供电电压是3.3V,红色LED压降约2.0V,MCU的IO口在输出低电平时还有约0.2V到0.3V的饱和压降,目标瞬时电流取10mA,那么限流电阻R=(3.3-2.0-0.3)/0.01=100Ω。这个值很常见,直接取100Ω或120Ω都行。如果供电是5V,同样取10mA,R=(5.0-2.0-0.3)/0.01=270Ω,实际用270Ω或220Ω都比较稳。

但要注意,这个瞬时电流不是LED的平均电流。在扫描模式下,每颗LED只在一部分时间里被点亮,平均电流约等于瞬时电流乘以占空比。所以实际使用中可以适当调大瞬时电流来补偿亮度,比如让单灯电流到12mA甚至15mA,但不能超过MCU单个IO口的绝对最大额定值。以STM32为例,单个IO口一般能承受25mA,整片芯片的电流也有总上限,设计时要留余量。

我踩过一个坑:一开始为了“稳妥”,把电流限制在5mA,结果扫描出来暗得几乎看不见。后来把瞬时电流提到12mA,亮度才可接受。经验是:3.3V供电加100Ω电阻,配红色高亮LED,在1/20占空比下亮度中等偏暗;如果嫌暗,最有效的不是继续加电流,而是用后面说的“只扫描亮着的LED”优化,把占空比从1/20提到1/5甚至更高,效果立竿见影。

2.3 完整接线表:20个LED的方向不能错

这是整个项目里最容易出错的地方。5个节点分别命名为A、B、C、D、E,任意两个节点之间接两颗反向并联的LED,一颗正向、一颗反向,总共10对、20颗。完整接线对应关系如下:

IO对正向LED(前阳后阴)反向LED(前阳后阴)
A-BL1: A→BL2: B→A
A-CL3: A→CL4: C→A
A-DL5: A→DL6: D→A
A-EL7: A→EL8: E→A
B-CL9: B→CL10: C→B
B-DL11: B→DL12: D→B
B-EL13: B→EL14: E→B
C-DL15: C→DL16: D→C
C-EL17: C→EL18: E→C
D-EL19: D→EL20: E→D

焊接的时候,我习惯这样做:先用标签纸给IO口标好A、B、C、D、E,然后在洞洞板上拉5根母线当作节点,再把LED按方向跨接在两条母线之间。每焊一颗就在上表打一个勾,防止漏焊或者方向焊反。全部焊完后,不要急着上电,先用万用表的二极管档逐个确认LED的位置和方向,再往MCU上接。

关于限流电阻的接法,有两种选项。一种是每颗LED都串独立电阻,稳定性最好、亮度一致性最好,但要焊20颗电阻。另一种是偷懒方案:只在每个IO节点出口处串一个电阻,总共只焊5颗。节点电阻方案的路径电阻会被两个节点电阻串联,比如A→B点亮时路径是“IO_A→节点电阻A→LED→节点电阻B→IO_B”,计算时要按两颗电阻分摊来取值。实际测试下来,5颗节点电阻的方案也能工作,唯一的缺点是多个LED同时点亮时,共用电阻上的压降会有所变化,亮度会有一点点相互影响。如果是自己焊着玩,我建议先用独立电阻,等稳定了再考虑精简。

2.4 先用仿真验证逻辑,再上实物

在Proteus里搭这个电路很快,逻辑验证也很有价值。但这里有一个非常容易踩的坑:仿真软件的IO模型不一定完美模拟“高阻态”。比如51单片机的P0口默认是开漏,P1到P3口内部带弱上拉,如果初始化代码没有把引脚切到正确的模式,仿真结果和实物会差很多。很多人在Proteus里点灯点得亮,一上板就不工作,原因往往就在IO模型上。

我建议仿真时把重点放在扫描逻辑的验证上:确认每个时隙点亮的LED是否正确、方向是否对、刷新顺序是否符合预期。至于亮度、电流这些模拟量,仿真结果只能当参考,不能替代实测。如果是在FPGA平台上做类似设计,还要注意IO约束的配置,三态逻辑如果没有在约束文件里明确表达,综合工具可能会把高阻输入优化掉,整个矩阵直接组不起来。

3. 代码实现:三态切换与扫描刷新

3.1 GPIO三态抽象层:高、低、高阻怎么切

查理复用的代码核心,就是对GPIO口进行三种状态切换:输出高电平、输出低电平、输入高阻。多数MCU的库函数都能做到,我这里给一个不依赖特定平台、偏STM32寄存器风格的通用版本。核心逻辑就是把模式切换封装成两个函数,用的时候只管设“输出”还是“输入”。

#define IO_A 0 #define IO_B 1 #define IO_C 2 #define IO_D 3 #define IO_E 4 #define MODE_INPUT 0 #define MODE_OUTPUT 1 void io_set_mode(uint8_t pin, uint8_t mode) { uint32_t shift = pin * 2; GPIOA->MODER &= ~(0x3UL << shift); if (mode == MODE_OUTPUT) { GPIOA->MODER |= (0x1UL << shift); } } void io_write(uint8_t pin, uint8_t level) { if (level) { GPIOA->ODR |= (1UL << pin); } else { GPIOA->ODR &= ~(1UL << pin); } }

这里有个细节:切换到输入模式时,要确保没有使能内部上拉或下拉电阻。因为内部上拉会把引脚默认为高电平,如果高阻态不够“干净”,其他路径的漏电流就会把不该亮的LED微微点亮。多数MCU在上电后默认是浮空输入,但如果你在初始化里开启了上拉,一定要记得关掉。

3.2 点亮单个LED的基础函数

有了三态抽象层,接下来就是核心的LED映射表。映射表的内容就是上一节那张接线表,直接定义成数组,代码看着长,但一劳永逸,后面所有扫描逻辑都依赖这张表。

typedef struct { uint8_t anode; uint8_t cathode; } led_t; const led_t led_map[20] = { {IO_A, IO_B}, {IO_B, IO_A}, // L1, L2 {IO_A, IO_C}, {IO_C, IO_A}, // L3, L4 {IO_A, IO_D}, {IO_D, IO_A}, // L5, L6 {IO_A, IO_E}, {IO_E, IO_A}, // L7, L8 {IO_B, IO_C}, {IO_C, IO_B}, // L9, L10 {IO_B, IO_D}, {IO_D, IO_B}, // L11, L12 {IO_B, IO_E}, {IO_E, IO_B}, // L13, L14 {IO_C, IO_D}, {IO_D, IO_C}, // L15, L16 {IO_C, IO_E}, {IO_E, IO_C}, // L17, L18 {IO_D, IO_E}, {IO_E, IO_D}, // L19, L20 }; void led_all_off(void) { for (uint8_t i = 0; i < 5; i++) { io_set_mode(i, MODE_INPUT); } } void led_on(uint8_t idx) { if (idx >= 20) return; led_all_off(); uint8_t a = led_map[idx].anode; uint8_t c = led_map[idx].cathode; io_set_mode(a, MODE_OUTPUT); io_write(a, 1); io_set_mode(c, MODE_OUTPUT); io_write(c, 0); }

注意一个细节:led_on函数里先调用了led_all_off,把5个IO全部切成高阻,再设置目标LED对应的两个IO。这样做的原因是防止上一时隙的配置残留——如果上次点亮的LED还占着两个IO,这次切换到另一个LED时可能造成短暂的两条通路,出现瞬间微亮或者电流冲击。先全灭再点亮,时序上更干净。

3.3 扫描刷新与亮度优化

单灯点亮函数搞定后,剩下的就是把20个LED按顺序快速轮流点亮。我一般用定时器中断,每1ms切换一个LED,那么一轮需要20ms,刷新率正好是50Hz。这个刷新率正处于人眼闪烁临界区,静态看勉强不闪,但眼睛稍微动一下会有可感知的跳变。把中断周期缩短到0.5ms,一轮10ms,刷新率100Hz,就稳很多。

基础扫描代码长这样:

uint8_t need_on[20]; // 1表示需要点亮 volatile uint8_t cur_slot = 0; void timer_isr(void) // 0.5ms或1ms定时器中断 { if (need_on[cur_slot]) { led_on(cur_slot); } else { led_all_off(); } cur_slot++; if (cur_slot >= 20) cur_slot = 0; }

但这样扫描有个明显的浪费:如果显示内容根本不需要某些LED亮,为什么还要给它们分配时间片?尤其当显示“188”这种内容时,真正亮的LED数量可能只有一半甚至更少。把不亮的LED跳过,只扫描需要亮的LED,占空比能成倍提升。

uint8_t scan_leds[20]; uint8_t scan_len = 0; volatile uint8_t scan_pos = 0; void build_scan_list(uint8_t bitmap[20]) { scan_len = 0; for (uint8_t i = 0; i < 20; i++) { if (bitmap[i]) { scan_leds[scan_len++] = i; } } if (scan_len == 0) { scan_pos = 0; led_all_off(); } } void timer_isr(void) { if (scan_len == 0) { led_all_off(); return; } if (scan_pos < scan_len) { led_on(scan_leds[scan_pos]); } scan_pos++; if (scan_pos >= scan_len) scan_pos = 0; }

这个优化带来的亮度提升非常明显。假设显示内容只用到8颗LED,那么在0.5ms时隙下,一轮只需要4ms,刷新率250Hz,每颗LED的占空比也从1/20提高到1/8,肉眼看起来亮了好几倍。实测下来,这是提升体验最直接的经验。

3.4 显示“188”的映射与平台移植

显示什么内容,本质上就是把“需要亮的LED序号”填入need_on数组或者bitmap。这一步没有通用代码,因为每颗LED在物理上排在什么位置,完全取决于你的接线和模块布局。但你完全可以做一个静态映射函数,按自己的接线把字形转换为LED列表。

我举个例子,假设你给百位的“1”分配了L0和L1,给十位的“8”分配了L2到L8,给个位的“8”分配了L9到L15,那么显示“188”时候的填充逻辑是这样的:

void show_188(void) { // 清空点灯表 memset(need_on, 0, sizeof(need_on)); // 百位“1”:按你的接线填入 need_on[0] = 1; need_on[1] = 1; // 十位“8”:7颗段LED全亮 need_on[2] = 1; need_on[3] = 1; need_on[4] = 1; need_on[5] = 1; need_on[6] = 1; need_on[7] = 1; need_on[8] = 1; // 个位“8”:又7颗 need_on[9] = 1; need_on[10] = 1; need_on[11] = 1; need_on[12] = 1; need_on[13] = 1; need_on[14] = 1; need_on[15] = 1; // 用新列表重建扫描表 build_scan_list(need_on); }

这段代码里的LED序号只是举例,实际一定要根据自己的接线表来填。我建议先做一个小工具函数,比如led_test(idx),每次点亮一颗,把20颗LED分别确认一遍身份,再写字形映射。

平台移植也很简单。Arduino的话,把io_set_mode对应到pinMode(pin, INPUT/OUTPUT),把io_write对应到digitalWrite,剩下的逻辑完全不用动。如果是51单片机,要注意P0口是开漏结构,驱动LED时最好加外部上拉,或者干脆用P1到P3口,位操作SFR即可。STM32用HAL库也能改,但HAL切换输入输出模式没有单函数接口,直接操作GPIOx->MODER寄存器反而最方便。

4. 常见问题与排查技巧实录

4.1 现象与原因速查表

做这种自己动手的项目,遇到问题非常正常。我把调试中见过的典型现象整理成了一张表:

现象可能原因解决方法
某颗LED完全不亮焊接方向反了,或映射序号对不上用万用表二极管档确认极性,核对led_map
不该亮的LED微亮IO口高阻不彻底,漏电流串过去确认GPIO配置成输入且关闭内部上拉,必要时节点加10k下拉
几颗LED同时微亮切换前一个状态没清干净每次切换前先led_all_off,再点亮目标
整屏闪烁刷新率低于50Hz,或中断周期太长把中断周期降到0.5ms,或只扫描亮着的LED
亮度不均匀各LED压降不同,或共用节点电阻每路独立限流电阻,或采用高亮LED
数字有拖影切换太快,LED结电容和寄生电容放电不彻底在切换LED时加1~2us空闲时间
IO口发烫同一IO同时驱动了过多LED检查同一时刻高/低分配,重新计算瞬时电流

这里面最讨厌的就是“微亮”和“残留拖影”。我排查微亮问题时发现,很多时候不是原理错了,而是GPIO初始化代码里不小心开启了内部上拉。内部上拉会让高阻引脚被拉到接近电源电压,而低电平引脚与它之间形成微弱压差,通过LED的漏电流就足以让LED处于半亮状态。所以一旦出现微亮,先检查引脚模式寄存器,再用示波器看高阻引脚的实际电压。

4.2 调试顺序与实用工具技巧

我的调试顺序是固定的,基本不会跳步。第一步是“逐灯点灯测试”,写一个super_loop,每500ms切换一颗LED,从L0到L19依次点亮。这一步能同时验证焊接方向、映射表、IO初始化是否正确。第二步是“三颗同亮测试”,随便挑几颗LED同时填need_on,确认扫描列表是否能承载多路点亮。第三步才是上具体的显示内容。

如果没有示波器,可以用手机摄像头帮忙看刷新过程。手机摄像头的采样帧率通常比人眼高,能拍到肉眼不容易察觉的闪烁和残留。我经常对着屏幕录像,然后慢放,就能看出扫描顺序和拖影情况。这个方法成本为零,效果却很好。

还有一个小技巧:调试串亮问题时,可以把限流电阻临时加大到1kΩ,让漏电流被压得更低,更容易定位是哪条路径在漏电。等把串亮路径找出来修好,再换回正常阻值的电阻。

5. 扩展思路:同一种方法还能玩什么

5.1 更多IO能驱动更多LED,但别贪心

查理复用的公式是N个IO最多驱动N(N-1)个LED,所以6个IO是30个,7个IO是42个。理论上扩展空间很大,但实际要控制住手。驱动数量翻倍意味着扫描周期变长、亮度下降、接线复杂度指数级上升,同时还要考虑MCU每个IO同时承载的电流。根据我的经验,10到20颗LED是查理复用的甜区,超过30颗还是老老实实用595或者带锁存的点阵驱动芯片更靠谱。

如果负载不是普通的5mmLED,而是大功率灯珠或者灯带,直接用IO口驱动就不行了,需要加达林顿管或者MOS管做功率放大,这时候驱动逻辑就完全切换到另一套思路。想清楚负载功率再做方案选型,能省掉很多后患。

5.2 从188模块到完整的显示产品

这次项目搭出来的“三态扫描+bitmap”框架,可以复用到很多小设计上:倒计时器、脉冲计数器、楼层显示、跑马灯、甚至多路按键扫描。按键矩阵和LED矩阵在原理上是互逆的,一个靠输出扫描,一个靠输入检测,代码骨架也能互相参考。

如果后续想做大屏,比如16×16点阵或者更大的LED阵列,查理复用就不合适了。那种场景下,MAX7219、74HC595阵列、专用点阵驱动芯片才是正解。另外要特别提醒一点:如果是LED背光这类需要恒定亮度的应用,不能用扫描方案,应该用恒流驱动IC,比如PT4115这类。扫描亮度再优化,本质上还是占空比调光,恒定亮度需求是满足不了的。

我在这个项目里最大的体会是,驱动方案的选择永远是个权衡题,不是越高级越好。IO紧张时,查理复用用最少的资源干完了活,这是它的价值;但当IO不紧张、LED数量又大的时候,强行用查理复用反而是在给自己挖坑。工具是死的,思路是活的,把手头零件的特性摸清楚,比照搬任何“通用电路”都重要。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询