视觉暂留与引脚复用:用11个GPIO驱动24颗LED的嵌入式实践
1. 项目概述用视觉暂留“欺骗”眼睛驱动24颗LED玩嵌入式开发的朋友估计都遇到过引脚不够用的尴尬。手里有个不错的显示模块但算来算去IO口数量就是差那么几个。最近我在Pimoroni上看到一个挺有意思的双色LED条形图模块它在一个封装里集成了12个段每个段里又有一红一绿两颗LED加起来总共24颗灯。按常理独立驱动这24颗灯就算用最省引脚的多路复用也得不少线。但这个模块的封装背面赫然只有14个引脚其中还有三对是内部连通的实际可用的独立控制引脚只有11个。这立刻勾起了我的兴趣怎么用11个引脚去控制24个独立的发光状态答案就在我们每天都会用到却常常被忽略的生理现象——视觉暂留。我们的眼睛和大脑在处理快速变化的图像时会有短暂的“延迟”和“融合”效果。电影、电视都是基于这个原理。在这个项目里我们同样可以利用它。我们不需要同时点亮所有LED而是以极高的速度轮流扫描点亮目标LED。只要这个速度足够快通常超过每秒60次在人眼看来所有的LED就像是同时稳定地点亮一样。这就是本次项目的核心思路用时间换空间以动态扫描克服硬件引脚数量的限制。我选择使用Adafruit的ItsyBitsy M4 Express作为主控并用CircuitPython来编写程序。选择CircuitPython的原因很简单它对硬件抽象做得很好语法接近标准Python读写GPIO就像操作变量一样简单特别适合快速验证想法和原型开发。整个项目的目标是实现两种动态效果一是让一个红、绿或黄色的光点沿着条形图来回移动二是根据输入值显示一个从左对齐的、红绿或黄色的“进度条”。为了提供输入我使用了一个10KΩ的电位器将其旋转角度映射到0-12的数值用来控制光点位置或进度条长度。2. 核心硬件解析与电路设计思路2.1 双色LED条形图模块的引脚奥秘驱动这个模块的第一步是彻底理解它的内部结构。它并非24颗LED的简单堆砌而是经过了精心的电气设计以实现引脚复用。模块的12个段被分成了3个“区段”低区左侧4段、中区中间4段和高区右侧4段。每个区段共享一个公共阳极。具体来说低区Low包含第1至第4段其公共阳极连接在引脚1和引脚14上这两脚在模块内部是相连的。中区Mid包含第5至第8段公共阳极连接在引脚6和引脚9上内部相连。高区High包含第9至第12段公共阳极连接在引脚7和引脚8上内部相连。这意味着阳极控制实际上只有3个独立节点Low, Mid, High每个节点控制一个区段里的8颗LED4段 × 红绿双色。阴极侧则按颜色分组。所有12个段的红色LED阴极是独立的分别引出到引脚2、3、4、5对应低区到高区的红色阴极。同样所有12个段的绿色LED阴极也是独立的分别引出到引脚13、12、11、10对应低区到高区的绿色阴极顺序相反。这里需要注意绿色阴极的引脚编号顺序是反的这在编程时是关键的细节。所以总计控制引脚为3个阳极选择引脚 4个红色阴极引脚 4个绿色阴极引脚 11个独立控制引脚。这正好印证了模块的设计。注意模块数据手册通常不直接给出这种复用结构的真值表需要自己通过万用表二极管档位测量验证或者根据厂商提供的示例原理图反推。务必确认阳极和阴极的对应关系接反了会导致无法点亮或逻辑混乱。2.2 主控与外围器件选型考量主控Adafruit ItsyBitsy M4 Express我选择这款板子有几个原因。首先它基于ATSAMD51这是一颗Cortex-M4内核的微控制器运行频率高达120MHz性能足以支撑高速、稳定的扫描刷新确保无闪烁的视觉暂留效果。其次它原生支持CircuitPython固件安装和开发环境搭建非常方便。最后它体积小巧但GPIO数量充足且有模拟输入引脚用于读取电位器完全符合项目需求。限流电阻的计算与选择ItsyBitsy M4 Express的GPIO输出电压是3.3V。双色LED以普通5mm为例的正向压降Vf通常在1.8V-2.2V红和3.0V-3.2V绿之间。为了保证LED亮度适中且不损坏MCU引脚通常单个引脚最大灌电流约20-25mA必须串联限流电阻。我们以压降较高的绿色LED做保守计算电源电压 (Vcc): 3.3VLED正向压降 (Vf_green): 取3.0V目标电流 (I_target): 设为10mA一个明亮且安全的数值所需电阻 R (Vcc - Vf) / I (3.3V - 3.0V) / 0.01A 30Ω以压降较低的红色LED计算Vf_red: 取2.0VR (3.3V - 2.0V) / 0.01A 130Ω考虑到电阻的标准阻值、为MCU引脚留有余量以及简化物料我选择了330Ω的电阻。对于绿色LED实际电流约为 (3.3V-3.0V)/330Ω ≈ 0.9mA亮度可能稍暗但足够指示对于红色LED电流约为 (3.3V-2.0V)/330Ω ≈ 3.9mA亮度会更高。这也解释了为什么在后来的效果中红绿混合的“黄色”会偏橙红——因为红色更亮。如果想得到更纯的黄色可以在红色LED的阴极通路上再串联一个额外的电阻例如100-220Ω来降低红色亮度。输入设备10KΩ线性电位器电位器在这里充当模拟输入传感器。将其两端分别接3.3V和GND中间滑动端接MCU的模拟输入引脚。旋转电位器时滑动端的电压在0-3.3V之间线性变化。MCU内部的ADC模数转换器将这个电压值转换为数字量例如0-65535我们再通过程序将其映射到0-12的区间用于控制显示。选择10KΩ是常见值在功耗和抗噪声能力之间取得平衡。2.3 电路连接实战与布线技巧我选择使用条状万用板进行焊接以获得更可靠的连接适合长期演示。如果只是临时验证面包板也是完全可行的。连接清单如下电源将ItsyBitsy的3.3V输出端连接到面包板或万用板的正极总线GND连接到负极总线。电位器电位器两端引脚分别接3.3V和GND中间引脚接ItsyBitsy的某个模拟输入引脚例如A1。LED模块阳极模块的三个阳极区Pin1/14, Pin6/9, Pin7/8各通过一个330Ω电阻连接到ItsyBitsy的三个GPIO引脚例如D13,D12,D11。注意模块上成对的阳极引脚是内部连通的任选其一连接电阻即可另一个可以悬空或一起接上。LED模块阴极模块的8个阴极引脚红Pin2,3,4,5绿Pin13,12,11,10直接连接到ItsyBitsy的另外8个GPIO引脚例如D10, D9, D8, D7, D6, D5, D4, D3。务必对照原理图确认阴极顺序与程序中的定义一致。共地确保ItsyBitsy、电位器、LED模块的GND全部连接在一起。实操心得在万用板上布线时建议先用记号笔根据原理图画出连接关系。对于数字电路电源去耦很重要可以在ItsyBitsy的3.3V和GND引脚附近焊接一个0.1uF-10uF的陶瓷电容以滤除电源噪声。所有信号线尽量短避免平行长线可以减少干扰。3. CircuitPython驱动程序设计详解3.1 开发环境搭建与基础库导入首先需要准备CircuitPython开发环境访问Adafruit官网下载对应ItsyBitsy M4 Express的最新版CircuitPython UF2固件文件。按住ItsyBitsy上的BOOT或RESET按钮同时通过USB连接电脑待出现ITSYM4BOOT磁盘后松开。将下载的UF2文件拖入该磁盘板子会自动重启并出现一个名为CIRCUITPY的新磁盘。安装Mu Editor或任何你喜欢的代码编辑器。Mu Editor集成了串行REPL和代码上传功能对初学者很友好。在CIRCUITPY磁盘的根目录下我们会创建主程序文件code.py。CircuitPython启动后会自动执行这个文件。程序开头需要导入必要的库import board import digitalio import analogio import timeboard定义了该开发板所有引脚的易记名称如board.D13。digitalio用于配置和控制数字输入输出引脚驱动LED。analogio用于读取模拟输入引脚的值电位器。time提供延时函数控制扫描时序。3.2 硬件初始化与引脚配置接下来我们需要严格按照硬件连接初始化所有用到的GPIO。# --- 阳极引脚配置 (区段选择) --- # 这些引脚输出高电平时对应区段的LED才有可能导通 anode_pins [board.D13, board.D12, board.D11] # 对应 Low, Mid, High 区段 anodes [] for pin in anode_pins: io digitalio.DigitalInOut(pin) io.direction digitalio.Direction.OUTPUT io.value False # 初始化为低电平关闭所有区段 anodes.append(io) # --- 阴极引脚配置 (LED颜色选择) --- # 红色LED阴极引脚 (顺序: 段1 - 段4) red_cathode_pins [board.D10, board.D9, board.D8, board.D7] # 绿色LED阴极引脚 (顺序: 段1 - 段4) 注意模块上引脚号是反的 green_cathode_pins [board.D6, board.D5, board.D4, board.D3] red_cathodes [] green_cathodes [] for pin in red_cathode_pins: io digitalio.DigitalInOut(pin) io.direction digitalio.Direction.OUTPUT io.value True # 初始化为高电平关闭LED因为阳极是低 red_cathodes.append(io) for pin in green_cathode_pins: io digitalio.DigitalInOut(pin) io.direction digitalio.Direction.OUTPUT io.value True green_cathodes.append(io) # --- 电位器 (模拟输入) 配置 --- potentiometer analogio.AnalogIn(board.A1)关键点解析方向与初始值GPIO被设置为OUTPUT方向。对于阳极我们初始化为False低电平因为我们的电路是共阳极接法阳极低电平则整个区段失能。对于阴极初始化为True高电平因为阴极高电平会阻止电流流过阳极低时无论阴极高低都不会亮但为安全起见先置高。驱动逻辑要点亮某个区段的一颗特定颜色的LED需要1) 将该区段对应的阳极置高2) 将该LED对应的阴极置低。其他所有阳极应置低其他所有阴极应置高。3.3 核心扫描显示函数实现这是整个项目的引擎负责以视觉暂留原理刷新显示。def set_led(segment, color): 点亮指定段和颜色的LED。 segment: 0-11对应从左到右的12个段。 color: R(红), G(绿), Y(黄)。 注意此函数执行时间必须极短它只设置一个瞬间状态。 需要被高频循环调用才能形成稳定显示。 # 1. 首先关闭所有阳极和阴极消隐 for anode in anodes: anode.value False for cathode in red_cathodes green_cathodes: cathode.value True # 2. 计算目标LED的区段和段内索引 # 区段: 0Low, 1Mid, 2High anode_index segment // 4 # 段内索引: 0-3 segment_index segment % 4 # 3. 根据颜色设置阴极 if color R or color Y: red_cathodes[segment_index].value False # 红色阴极拉低 if color G or color Y: # 注意绿色阴极引脚顺序是物理反向的但我们的列表顺序是逻辑顺序(段1-段4) # 所以索引可以直接使用 segment_index green_cathodes[segment_index].value False # 绿色阴极拉低 # 4. 开启对应区段的阳极 anodes[anode_index].value True # 函数结束引脚状态保持直到下一次调用被刷新。这个函数是“静态”的它只在某一时刻点亮一颗或两颗如果是黄色LED。要形成动态效果我们需要一个主循环根据当前想要显示的模式光点或进度条快速循环调用set_led。3.4 主循环逻辑与模式控制主循环需要完成几件事读取电位器值、更新显示模式、以极高频率刷新LED。# 显示模式dot 移动光点 bar 进度条 display_mode dot # 光点移动方向 dot_direction 1 # 1向右 -1向左 dot_position 0 dot_color Y # 上次模式切换时间用于防抖 last_mode_change time.monotonic() mode_debounce_delay 0.5 # 秒 # 主循环 while True: # 1. 读取电位器并映射到0-12 pot_raw potentiometer.value # ADC是16位 (0-65535)映射到0-12 pot_value int((pot_raw / 65535) * 13) # 13是为了包含0-12共13个整数 pot_value min(pot_value, 12) # 确保不超过12 # 2. 简单的模式切换例如通过连接一个按钮到D0这里用条件模拟 # 假设当电位器值快速跳到0又回来时切换模式实际应用建议用按钮 current_time time.monotonic() if pot_value 0 and (current_time - last_mode_change) mode_debounce_delay: display_mode bar if display_mode dot else dot last_mode_change current_time print(Switched mode to:, display_mode) # 3. 根据模式更新显示 if display_mode dot: # 移动光点模式电位器控制颜色 if pot_value 0: dot_color R elif pot_value 4: dot_color Y else: dot_color G # 更新光点位置 dot_position dot_direction if dot_position 0: dot_position 0 dot_direction 1 elif dot_position 11: dot_position 11 dot_direction -1 # 刷新显示高速循环中每次只画光点 # 为了实现平滑移动刷新率必须远高于人眼识别闪烁的频率60Hz set_led(dot_position, dot_color) time.sleep(0.05) # 控制光点移动速度并非扫描延时 elif display_mode bar: # 进度条模式电位器值直接对应点亮段数 bar_length pot_value bar_color G if pot_value 6 else Y if pot_value 9 else R # 关键视觉暂留扫描实现进度条 # 我们快速扫描所有需要点亮的段 for seg in range(bar_length): set_led(seg, bar_color) # 这里不需要延时因为set_led本身执行很快 # 并且会被循环快速重复调用。 # 对于未点亮的部分我们不做任何操作set_led函数已消隐 # 4. 控制整体刷新率 # 主循环的迭代速度就是扫描速度。 # 可以通过微调或使用更精确的定时来控制。 # 对于进度条模式循环中会遍历多个段实际每段的点亮时间更短 # 需要确保总刷新率足够高。一个简单的办法是限制循环最大频率。 time.sleep(0.001) # 主循环最小延时防止CPU占用率100%逻辑深度剖析映射计算pot_value int((pot_raw / 65535) * 13)将0-65535映射到0-12。乘以13是为了得到0-12包含的13个整数区间。int()向下取整所以pot_value范围是0-12。视觉暂留的实现在bar模式下for seg in range(bar_length):循环会依次且非常快地点亮第0段到第bar_length-1段。因为主循环整体在不停重复每秒数百甚至上千次每次循环都会重新绘制整个进度条。人眼看到的就是一个稳定的、连续的光条。刷新率等于主循环频率除以需要绘制的段数。如果主循环每秒1000次绘制5段长的光条那么每段的刷新率是200Hz远高于视觉暂留阈值。延时控制time.sleep(0.001)给主循环一个微小延时。一方面可以降低CPU使用率另一方面也是控制整体刷新率。如果去掉它循环会跑得飞快刷新率极高但可能造成电源噪声稍大。这个值可以根据实际情况调整。4. 项目调试与效果优化实录4.1 常见问题与排查指南在实际焊接和编程中你可能会遇到以下问题问题现象可能原因排查步骤与解决方案所有LED都不亮1. 电源未接通或电压不对。2. 共阳极未正确接到3.3V通过电阻。3. 主控程序未运行或code.py有语法错误。1. 用万用表测量ItsyBitsy的3.3V输出和GND之间电压。2. 检查三个330Ω电阻是否一端接3.3V另一端接模块阳极引脚。3. 连接USB用Mu Editor打开串行REPL查看是否有错误信息。尝试运行简单测试代码如print(“Hello”)。只有部分区段LED能亮1. 某个阳极电阻虚焊或接错引脚。2. 代码中对应阳极的GPIO配置错误。1. 检查对应不能亮区段的阳极电阻连接。用万用表通断档测量。2. 在REPL中手动设置该阳极引脚为高电平看对应区段是否有LED微亮阴极可能为高阻。单个LED无法点亮1. 该LED对应的阴极引脚连接错误或虚焊。2. 模块内部该LED损坏概率低。3. 程序中阴极引脚顺序定义错误。1. 确认硬件连接。尝试在代码中单独测试设置对应阳极高其他阳极低设置该阴极低其他阴极高。观察是否点亮。2. 交换怀疑损坏的LED所在段的红绿阴极信号如果颜色跟着信号走则LED是好的。显示闪烁严重1. 扫描刷新率过低低于60Hz。2. 主循环中有不必要的长延时(time.sleep)。3.set_led函数执行太慢。1. 计算刷新率。如果显示N个LED主循环一次耗时T秒则刷新率1/(N*T)。确保60Hz。2. 移除或减少bar模式循环内的延时。确保主循环的sleep非常短。3. 优化set_led函数减少循环和函数调用开销。“黄色”显示为橙色红色LED和绿色LED的亮度不一致通常红色更亮。这是硬件特性。解决方案在红色LED的阴极通路上串联一个额外的电阻如100-220Ω以降低红色LED的电流使其亮度与绿色匹配。需要实验确定阻值。电位器控制不跟手或跳变1. ADC读取有噪声。2. 映射算法不连续或电位器本身有抖动。1.软件滤波采用滑动平均滤波。例如保存最近5次的ADC读数取平均值作为当前值。2.硬件滤波在电位器输出端与地之间并联一个0.1uF的电容可滤除高频噪声。3.死区处理在映射值变化小于某个阈值时保持原值不变避免显示抖动。4.2 软件滤波算法示例在while True循环开头读取电位器部分可以改进为# 滑动平均滤波 readings [] # 在循环外初始化一个空列表 READING_COUNT 5 while True: pot_raw potentiometer.value readings.append(pot_raw) if len(readings) READING_COUNT: readings.pop(0) # 移除最旧的读数 filtered_raw sum(readings) // len(readings) # 计算平均值 pot_value int((filtered_raw / 65535) * 13) pot_value min(pot_value, 12) # ... 后续代码4.3 性能优化与扩展思路1. 使用array和memoryview提升速度CircuitPython中直接操作digitalio对象的.value属性有一定开销。对于需要极高刷新率的应用可以考虑使用bitbangio或直接操作寄存器但这更复杂。一个折中方案是确保set_led函数尽可能精简。2. 引入色彩混合与亮度控制目前的“黄色”是红绿同时点亮的结果。如果想实现更丰富的颜色如橙色、黄绿或者调节亮度可以引入PWM脉宽调制。CircuitPython的pwmio模块可以让你控制引脚输出方波的占空比从而控制LED的平均电流实现灰度或亮度调节。不过这需要将阴极控制引脚更换为支持PWM的引脚并且会大大增加代码复杂度因为需要为每个LED维护一个PWM对象和占空比值刷新率管理也更困难。3. 扩展输入方式除了电位器可以增加按钮来切换显示模式、改变颜色主题。增加旋转编码器可以更精确地控制数值。甚至可以通过光敏电阻实现环境光自适应亮度调节。4. 代码结构化将显示驱动部分抽象成一个BarGraph类将硬件细节封装起来。主程序只需要调用类似graph.set_bar(length, color)或graph.set_dot(position, color)的方法使代码更清晰易于维护和移植到其他项目。这个项目虽然小但完整地展示了从硬件原理分析、电路设计、到软件驱动和视觉算法视觉暂留的嵌入式开发全流程。它证明了即使在资源受限的微控制器上通过巧妙的算法和对硬件特性的深入理解也能实现超出硬件接口能力的视觉效果。下次当你遇到引脚不够时不妨想想是否能用时间高速扫描来换取空间更多设备。