
1. 项目缘起当树莓派遇上机械开关与OLED玩树莓派的朋友估计都经历过一个阶段从点亮LED、控制继电器到连接各种传感器最后总会觉得那些插在面包板上的杜邦线还有那些简陋的按钮看起来总有点“临时感”。项目做完了想固定下来或者想做一个更酷、更“像样”的交互界面就成了新的需求。这不我手头正好有个闲置的树莓派3B琢磨着给它做个“专属盾板”把几个常用的机械开关和一个OLED屏幕集成上去让它从一个开发板变成一个可以直接上手操作、状态一目了然的“小设备”。这个想法其实很常见但真动手做你会发现里面有不少门道。比如机械开关怎么选是选带灯的船型开关还是复古的拨动开关OLED屏幕用I2C还是SPI接口GPIO引脚怎么分配才合理既满足功能又不冲突更重要的是这个“盾板”Shield怎么设计才能既稳固又美观还能方便地插拔我这次做的就是一个集成了三个机械开关电源、功能A、功能B和一个0.96寸I2C OLED屏幕的扩展板。它可以直接插在树莓派3B的GPIO排针上无需飞线开机即用。下面我就把从选型、设计、焊接到软件驱动、界面编写的全过程以及踩过的坑和总结的经验毫无保留地分享出来。2. 核心硬件选型与设计思路做硬件项目第一步永远是选型和规划。这一步没想清楚后面焊接、编程都会遇到各种麻烦。我的核心需求很明确为树莓派3B增加物理开关控制和本地信息显示能力。2.1 机械开关不只是通断更是交互的质感首先说机械开关。我放弃了常见的轻触开关选择了真正的机械开关。原因很简单状态明确、手感扎实、可靠性高。轻触开关需要软件去判断“按下”和“弹起”两个状态而机械开关如拨动开关、船型开关的物理位置就代表了状态对于控制电源、切换模式这种重要功能物理反馈更让人安心。我选了三种开关主电源开关船型开关带灯用于控制树莓派的外接电源非软件关机。选择带灯型号可以在开关接通时亮起直观显示供电状态。这里有个关键点树莓派本身的运行电源5V不建议直接用这个开关切断突然断电可能损坏SD卡。我的做法是用这个开关控制一个5V继电器模块由继电器来通断树莓派的5V输入实现“软”硬件开关。开关本身的指示灯电流很小通常10-20mA可以直接用树莓派的3.3V GPIO驱动串联一个合适的限流电阻如220Ω即可。功能模式开关双位拨动开关这是一个SPDT单刀双掷开关。我把它配置成模式选择器比如向上拨动是“模式A”向下拨动是“模式B”。在软件里只需要读取一个GPIO的高低电平就能判断当前模式逻辑非常简单清晰。复位/动作开关瞬时按钮开关这是一个常开的瞬动按钮。用于执行某个特定动作比如重启某个服务、清除显示、或作为确认键。软件上需要检测下降沿或上升沿触发。引脚分配考量树莓派3B的40针GPIO中需要避开一些有特殊功能的引脚。例如GPIO2和GPIO3默认有上拉电阻用于I2CGPIO14和GPIO15是UART串口。我的分配策略是电源开关指示灯GPIO17输出。模式开关GPIO27输入启用内部上拉电阻这样开关另一端接地即可。动作按钮GPIO22输入启用内部上拉电阻。预留1-2个GPIO以备不时之需。2.2 OLED屏幕为何选择I2C接口的SSD1306OLED屏幕种类繁多我选择了最常见的0.96寸、128x64分辨率、使用SSD1306驱动芯片的型号并且特意选择了I2C接口而非SPI接口。这是经过权衡的节省GPIO引脚I2C只需要2根线SDA, SCL加上电源和地总共4根线。而SPI至少需要4根数据线CS, DC, RES, SDA, SCLK中的若干根。对于GPIO资源并不算极度充裕的树莓派尤其是当你要接其他设备时I2C的优势明显。接线简单I2C是总线式结构理论上可以挂载多个设备地址不同即可。虽然这个项目只用一个屏幕但简洁的布线让PCB设计也更简单。软件生态成熟无论是Python的luma.oled库还是C语言的wiringPi、bcm2835库对SSD1306 I2C的支持都非常完善示例代码丰富。性能足够对于显示系统状态、传感器数据、时钟日期这类信息I2C的刷新速度完全够用。除非你需要播放动画或高速刷新否则I2C是更优解。屏幕的I2C地址通常是0x3C也有部分是0x3D可以通过i2cdetect命令查看。它直接连接树莓派的I2C1接口GPIO2SDA和GPIO3SCL。2.3 盾板Shield设计从洞洞板到定制PCB最初的方案是用洞洞板万用板焊接。但很快发现问题树莓派的GPIO排针是2x20的双排洞洞板对位焊接非常麻烦稳定性也一般插拔几次就可能松动。于是我决定设计一块简单的定制PCB。使用立创EDA这类免费工具过程比想象中简单新建一个项目选择“树莓派3B Shield”作为板型软件会自动生成一个包含40Pin GPIO孔位的边框。放置元器件将树莓派的40针母座用于插入树莓派排针、三个开关、OLED屏幕的4Pin排母用于插接OLED模块摆放到合适位置。原理图连线根据之前的引脚规划进行连接。特别注意要在GPIO输入引脚如接模式开关、动作按钮的引脚到地之间连接一个0.1uF的电容用于硬件消抖虽然软件也能消抖但硬件更可靠。同时为LED指示灯串联限流电阻。PCB布局在PCB视图里调整元器件位置使其紧凑美观。然后进行布线。对于这种低频数字电路线宽用0.3mm-0.5mm即可。关键点确保树莓派母座与OLED排母、开关焊盘之间的位置关系准确避免安装时发生干涉。打样将设计好的Gerber文件发给PCB制板厂。5cm*5cm以内的小板子通常几十块钱就能做5-10片性价比很高。收到PCB后焊接过程就是一种享受了。所有焊盘都规整对齐使用焊锡膏和热风枪可以快速完成焊接最终成品非常稳固专业。3. 软件环境搭建与驱动测试硬件组装完毕接下来就是让树莓派“认识”这些新部件。首先确保系统是最新的Raspbian/Raspberry Pi OS。3.1 启用接口与安装基础工具通过sudo raspi-config进入配置界面在Interface Options-I2C中启用I2C接口。同样在Interface Options-SPI中虽然本项目未使用SPI但可以保持禁用或启用不影响。在Performance Options-GPU Memory中如果后续需要图形界面可以分配64MB或以上。更新系统并安装必要工具sudo apt update sudo apt upgrade -y sudo apt install python3-pip python3-pil python3-smbus i2c-tools -yi2c-tools包里的i2cdetect命令非常重要用于检测I2C设备。3.2 测试I2C OLED屏幕首先连接OLED屏幕到PCB的4Pin插座确保VCC接3.3V树莓派GPIO是3.3V电平接5V可能会烧毁屏幕或树莓派。然后重启或运行sudo i2cset -y 1 0x3C 0x00 0xAE假设地址0x3C来初步唤醒屏幕但更常用的方法是使用i2cdetect扫描。sudo i2cdetect -y 1如果一切正常你应该能看到类似下面的输出在地址3C或3D处显示UU或3C表示设备被识别。0 1 2 3 4 5 6 7 8 9 a b c d e f 00: -- -- -- -- -- -- -- -- 10: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 20: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 30: -- -- -- -- -- -- -- -- -- -- -- -- UU -- -- --接下来安装Python的OLED驱动库。luma.oled库是一个优秀的选择sudo pip3 install luma.oled然后创建一个简单的测试脚本test_oled.pyfrom luma.core.interface.serial import i2c from luma.core.render import canvas from luma.oled.device import ssd1306 from PIL import ImageFont, ImageDraw import time # 初始化I2C和设备 serial i2c(port1, address0x3C) # port1 代表 /dev/i2c-1 device ssd1306(serial) # 使用默认字体 font ImageFont.load_default() with canvas(device) as draw: draw.rectangle(device.bounding_box, outlinewhite, fillblack) draw.text((10, 20), Hello, Pi 3B!, fontfont, fillwhite) draw.text((10, 40), OLED Shield Ready, fontfont, fillwhite) time.sleep(5) # 显示5秒运行python3 test_oled.py如果屏幕成功显示文字那么OLED的硬件和基础驱动就通过了。3.3 测试机械开关开关的测试更简单本质就是读取GPIO的状态。我们使用RPi.GPIO库。首先安装通常已预装sudo pip3 install RPi.GPIO创建测试脚本test_switches.py。这里以模式开关GPIO27 输入 内部上拉和动作按钮GPIO22 输入 内部上拉为例import RPi.GPIO as GPIO import time # 设置引脚编号模式为BCM GPIO.setmode(GPIO.BCM) # 定义引脚 MODE_SWITCH_PIN 27 ACTION_BUTTON_PIN 22 POWER_LED_PIN 17 # 电源指示灯输出 # 设置引脚模式 GPIO.setup(MODE_SWITCH_PIN, GPIO.IN, pull_up_downGPIO.PUD_UP) # 模式开关内部上拉 GPIO.setup(ACTION_BUTTON_PIN, GPIO.IN, pull_up_downGPIO.PUD_UP) # 动作按钮内部上拉 GPIO.setup(POWER_LED_PIN, GPIO.OUT) GPIO.output(POWER_LED_PIN, GPIO.HIGH) # 默认点亮电源LED print(开始测试开关。按下CtrlC退出。) try: while True: # 读取模式开关状态0按下/接地 1断开/上拉 mode_state GPIO.input(MODE_SWITCH_PIN) # 读取动作按钮状态并检测下降沿按下瞬间 # 注意简单的轮询方式防抖不佳。生产代码应用事件检测。 button_state GPIO.input(ACTION_BUTTON_PIN) if mode_state GPIO.LOW: print(模式开关: ON (模式B), end | ) else: print(模式开关: OFF (模式A), end | ) if button_state GPIO.LOW: print(动作按钮: 被按下) else: print(动作按钮: 释放) time.sleep(0.2) # 降低轮询频率 except KeyboardInterrupt: print(\n测试结束。) finally: GPIO.cleanup() # 清理GPIO设置运行这个脚本拨动模式开关按下动作按钮观察终端打印的状态是否与物理操作一致。这里暴露了第一个坑机械开关的抖动。你会发现一次拨动可能打印出多次状态变化。这就是为什么需要在硬件并联电容或软件延时去抖上进行处理。4. 软件消抖与可靠的状态读取硬件消抖在开关引脚和地之间加电容是最佳实践它能从物理层面滤除抖动。但在软件层面我们也必须做处理尤其是对于动作按钮这种需要检测“按下”事件的场景。4.1 针对模式开关状态读取的稳定性处理模式开关我们只关心它稳定后的状态不关心变化过程。可以采用“多次采样取一致”的方法。def read_mode_switch(pin, samples5, interval0.01): 读取模式开关的稳定状态。 采样多次只有所有采样值一致时才返回该状态否则返回上一次的稳定状态。 readings [] for _ in range(samples): readings.append(GPIO.input(pin)) time.sleep(interval) # 如果所有读数都相同 if all(r readings[0] for r in readings): return readings[0] else: # 读数不一致说明在抖动返回上一次的稳定状态需要外部变量保存 # 这里简化为返回None由调用者处理 return None # 在循环中使用 last_stable_mode None current_raw read_mode_switch(MODE_SWITCH_PIN) if current_raw is not None: if current_raw ! last_stable_mode: last_stable_mode current_raw if last_stable_mode GPIO.LOW: print(模式已稳定切换到: B) else: print(模式已稳定切换到: A)4.2 针对动作按钮边缘检测与防抖对于动作按钮我们需要检测“按下”这个事件下降沿并在事件触发后执行相应函数。RPi.GPIO库提供了事件检测功能结合防抖时间参数可以很好地工作。import RPi.GPIO as GPIO import time GPIO.setmode(GPIO.BCM) ACTION_BUTTON_PIN 22 GPIO.setup(ACTION_BUTTON_PIN, GPIO.IN, pull_up_downGPIO.PUD_UP) # 定义按钮回调函数 def button_callback(channel): # 注意在回调函数中尽量避免耗时操作 print(f按钮在引脚 {channel} 上被按下执行动作...) # 这里可以调用你的功能函数例如刷新屏幕、发送网络请求等 # 添加事件检测设置防抖时间为200毫秒 # bouncetime参数的单位是毫秒它告诉库在检测到一个边沿后忽略接下来一段时间内的抖动。 GPIO.add_event_detect(ACTION_BUTTON_PIN, GPIO.FALLING, callbackbutton_callback, bouncetime200) print(等待按钮按下...按下CtrlC退出。) try: while True: time.sleep(10) # 主程序可以去做其他事情 except KeyboardInterrupt: print(\n程序退出。) finally: GPIO.cleanup()使用add_event_detect是处理按钮事件的最佳方式之一它是异步的不阻塞主循环。bouncetime200意味着在检测到一次按下下降沿后的200ms内再产生的下降沿会被忽略有效避免了因抖动导致的多次触发。5. 整合应用一个系统状态显示器硬件驱动都调通了现在我们来做一个综合应用一个在OLED屏幕上实时显示树莓派系统状态IP地址、CPU温度、负载、时间日期并通过机械开关控制显示模式或执行刷新的小工具。5.1 获取系统信息我们需要编写几个函数来获取系统信息import subprocess import socket import psutil # 需要安装: pip3 install psutil def get_ip_address(): 获取本机IP地址优先取有线eth0其次无线wlan0 try: # 获取主机名 hostname socket.gethostname() # 获取所有IP ip_list socket.gethostbyname_ex(hostname)[2] # 过滤掉回环地址 ip_list [ip for ip in ip_list if not ip.startswith(127.)] return ip_list[0] if ip_list else N/A except: return N/A def get_cpu_temp(): 获取CPU温度树莓派专用 try: with open(/sys/class/thermal/thermal_zone0/temp, r) as f: temp float(f.read()) / 1000.0 return f{temp:.1f}°C except: return N/A def get_cpu_load(): 获取CPU负载1分钟平均 try: load psutil.getloadavg()[0] return f{load:.2f} except: return N/A def get_memory_usage(): 获取内存使用率 try: mem psutil.virtual_memory() return f{mem.percent}% except: return N/A5.2 主程序逻辑整合现在我们将OLED显示、开关状态读取、系统信息获取整合到一个主循环中。我们设计两个显示模式通过模式开关切换模式A默认显示系统概览IP、CPU温度、负载、内存。模式B显示大型时钟和日期。动作按钮用于手动刷新屏幕或切换信息页本例中用于强制刷新。#!/usr/bin/env python3 import time import RPi.GPIO as GPIO from luma.core.interface.serial import i2c from luma.core.render import canvas from luma.oled.device import ssd1306 from PIL import ImageFont import subprocess import socket import psutil from datetime import datetime # --- GPIO 配置 --- GPIO.setmode(GPIO.BCM) MODE_PIN 27 ACTION_PIN 22 POWER_LED_PIN 17 GPIO.setup(MODE_PIN, GPIO.IN, pull_up_downGPIO.PUD_UP) GPIO.setup(ACTION_PIN, GPIO.IN, pull_up_downGPIO.PUD_UP) GPIO.setup(POWER_LED_PIN, GPIO.OUT) GPIO.output(POWER_LED_PIN, GPIO.HIGH) # --- OLED 初始化 --- serial i2c(port1, address0x3C) device ssd1306(serial) # 尝试加载稍大的字体如果失败则用默认字体 try: font_small ImageFont.truetype(/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf, 12) font_large ImageFont.truetype(/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf, 24) except: print(未找到指定字体使用默认字体。) font_small ImageFont.load_default() font_large ImageFont.load_default() # --- 状态变量 --- current_mode A # 初始模式 last_mode_read GPIO.HIGH last_action_state GPIO.HIGH display_needs_refresh True last_refresh_time 0 refresh_interval 2 # 自动刷新间隔秒 # --- 信息获取函数 (同上此处省略以节省篇幅) --- def get_ip_address(): #... def get_cpu_temp(): #... def get_cpu_load(): #... def get_memory_usage(): #... def read_stable_switch(pin, samples7, interval0.005): 稳定的开关状态读取 states [GPIO.input(pin) for _ in range(samples)] time.sleep(interval) if all(s states[0] for s in states): return states[0] return None def draw_mode_a(): 绘制模式A系统状态 with canvas(device) as draw: draw.rectangle(device.bounding_box, outlinewhite, fillblack) draw.text((5, 2), fIP: {get_ip_address()}, fontfont_small, fillwhite) draw.text((5, 18), fCPU: {get_cpu_temp()} {get_cpu_load()}, fontfont_small, fillwhite) draw.text((5, 34), fMem: {get_memory_usage()}, fontfont_small, fillwhite) draw.text((5, 50), fMode: {current_mode} | {datetime.now().strftime(%H:%M)}, fontfont_small, fillwhite) def draw_mode_b(): 绘制模式B大时钟 with canvas(device) as draw: draw.rectangle(device.bounding_box, outlinewhite, fillblack) current_time datetime.now().strftime(%H:%M:%S) current_date datetime.now().strftime(%Y-%m-%d) # 居中显示 time_bbox draw.textbbox((0,0), current_time, fontfont_large) date_bbox draw.textbbox((0,0), current_date, fontfont_small) time_x (device.width - (time_bbox[2] - time_bbox[0])) // 2 date_x (device.width - (date_bbox[2] - date_bbox[0])) // 2 draw.text((time_x, 15), current_time, fontfont_large, fillwhite) draw.text((date_x, 45), current_date, fontfont_small, fillwhite) # --- 主循环 --- print(树莓派3B 机械开关与OLED盾板控制程序已启动。) try: while True: now time.time() # 1. 检查模式开关 stable_mode read_stable_switch(MODE_PIN) if stable_mode is not None: # GPIO.HIGH (1) 对应模式A GPIO.LOW (0) 对应模式B new_mode A if stable_mode GPIO.HIGH else B if new_mode ! current_mode: current_mode new_mode display_needs_refresh True print(f模式切换至: {current_mode}) # 2. 检查动作按钮简单轮询防抖 current_action GPIO.input(ACTION_PIN) if current_action GPIO.LOW and last_action_state GPIO.HIGH: # 检测到下降沿按下 time.sleep(0.02) # 延时20ms防抖 if GPIO.input(ACTION_PIN) GPIO.LOW: # 确认仍然按下 print(动作按钮被按下强制刷新屏幕。) display_needs_refresh True last_action_state current_action # 3. 定时刷新或需要时刷新显示 if display_needs_refresh or (now - last_refresh_time) refresh_interval: if current_mode A: draw_mode_a() else: draw_mode_b() display_needs_refresh False last_refresh_time now time.sleep(0.05) # 主循环延迟降低CPU占用 except KeyboardInterrupt: print(\n程序被用户中断。) finally: # 清理屏幕 with canvas(device) as draw: draw.rectangle(device.bounding_box, outlinewhite, fillblack) device.clear() GPIO.output(POWER_LED_PIN, GPIO.LOW) GPIO.cleanup() print(GPIO已清理程序退出。)这个程序创建了一个持续运行的服务。模式开关可以在系统状态视图和大时钟视图之间切换。动作按钮提供手动刷新。OLED屏幕会定期或根据事件更新显示实时信息。电源LED常亮指示盾板已上电工作。6. 进阶优化与生产部署上面的程序已经可以工作但作为一个“产品”还需要考虑更多。6.1 将程序设置为系统服务我们不想每次启动树莓派都手动运行这个Python脚本。最好的方式是将其设置为一个系统服务systemd service。创建一个服务文件sudo nano /etc/systemd/system/pi_shield.service写入以下内容[Unit] DescriptionRaspberry Pi 3B Shield Control Service Aftermulti-user.target [Service] Typesimple Userpi ExecStart/usr/bin/python3 /home/pi/pi_shield_main.py Restarton-abort StandardOutputsyslog StandardErrorsyslog [Install] WantedBymulti-user.target保存退出。然后启用并启动服务sudo systemctl daemon-reload sudo systemctl enable pi_shield.service sudo systemctl start pi_shield.service检查服务状态sudo systemctl status pi_shield.service。这样树莓派启动后我们的盾板控制程序就会自动运行。6.2 功耗与性能考量OLED屏幕烧屏OLED屏幕如果长时间显示静态内容可能会产生“烧屏”现象。我们的程序显示内容在不断变化时间每秒都在变这很好。但如果你显示的是长期不变的Logo或文字可以考虑加入一些像素位移或定期切换显示内容的逻辑。CPU占用我们的主循环有一个time.sleep(0.05)这已经将CPU占用率降得很低。使用systemd服务管理也比用cron或screen更规范、更省资源。电源管理如果项目是电池供电需要考虑功耗。可以增加一个功能当模式开关处于某个位置如“OFF”一段时间后自动关闭OLED屏幕背光SSD1306可以通过命令关闭显示并让Python程序进入低功耗轮询状态。6.3 扩展思路这个盾板是一个很好的基础平台可以扩展很多功能增加旋转编码器替换掉模式开关和动作按钮用一个旋转编码器来实现菜单浏览、数值调整交互会更灵活。连接传感器利用剩余的GPIO或I2C接口接入温湿度传感器如DHT22、BME280直接在屏幕上显示环境数据。网络功能让树莓派作为一个小型服务器通过OLED和开关配置Wi-Fi或者显示网络请求的状态。外壳设计为整个树莓派和盾板设计一个3D打印外壳将开关和屏幕露出来形成一个完整的嵌入式设备。从一堆散乱的元件到一块集成度高的定制盾板再到一个稳定运行的系统服务这个过程充满了硬件和软件结合的乐趣。最大的收获不是最终做出来的那个小设备而是在解决每一个具体问题如开关抖动、I2C地址冲突、字体渲染、服务自启动时积累的经验。这些经验下次做任何嵌入式或物联网项目时都能直接用上。