上周折腾了几天,搞出一个挺小众的玩意儿:两个 ESP32 板子,不加 WiFi、不配蓝牙、不插任何现成通信模块,就靠各自板上的一颗普通 5mm LED,把数据从A端传到了B端。标题里的 PacketLED 是我临时起的工程名,核心玩法很简单——把一颗 LED 既当发射光源,又当光传感器,做点对点可见光通信。做的过程中踩了不少坑,也把协议、采样时序、抗干扰从头到尾过了一遍,这篇文章把完整思路、接线方式、代码和实测数据都整理出来,想玩光通信或者对 LED 双用途玩法感兴趣的朋友可以直接抄作业。
这个项目的适用范围很明确:不适合远距离,也不追求高带宽,它的价值在于“极简”和“思路巧妙”。在不能用射频或不想增加硬件的场景下,用一颗 LED 做半双工的数据链路是完全可行的。比如展示柜之间的信号传递、教学演示、传感器状态上报,甚至做一些艺术装置里的光交互。对 ESP32 玩家来说,这也是练习 ADC 采样、定时器中断、曼切斯特编码和状态机的绝佳载体。
1. 项目拆解:一个 LED 为什么能“又当眼睛又当嘴巴”
1.1 这个项目到底在做什么
先明确一个概念:PacketLED 不是你常见的那种“LED 闪烁表示状态”的玩法,而是两个 ESP32 之间真正地传输字节。A 板把要发送的数据编码成一组光脉冲,B 板用一颗 LED 把光脉冲接收回来,再解码成原来的字节。反过来也一样,B 也能发给 A。链路里只有两颗 LED,没有激光头、没有红外一体管、没有射频模块。
实验最初只是想验证一个猜想:既然 LED 本质上就是一只半导体二极管,那它理论上就具备光电效应,不仅通电会发光,被外部光照时也会产生微弱的电信号。这个猜想在实验室里被无数人验证过,但真正放到单片机项目里还面临个关键问题——信号太弱。ESP32 内部 ADC 的输入阻抗不算高,直接读浮空 LED 出来的电压很容易被环境噪声淹没。所以整个项目的技术难点其实不在“能不能通信”,而在“怎么把微弱的感光信号可靠地转成数据”。
我实际搭出来的系统在半双工模式工作:同一时刻只能一个方向发送,另一个方向接收。A 发送完一帧后主动切到接收模式,B 收到后可以回一帧,这样就完成了一次“对话”。吞吐量不高,设计目标是 500 bit/s 左右,但对于传温度、控制指令、简单状态码这类轻量数据完全够用。
1.2 LED 的双重身份:发光与光电效应
先补一点基础知识,方便后面代码理解。
一颗普通 LED 的内部是一个 PN 结。给正向偏压时,电子和空穴在结区复合,能量以光子形式释放,这就是发光。反过来,当外部光照到这个 PN 结上,光子会激发出电子-空穴对,在开路状态下会产生光生电压,在短路或反向偏压状态下会产生光电流。这个效应就是太阳能电池的原理,也是 LED 能当“眼睛”的物理基础。
实际操作中要把 LED 用成传感器,通常有三种接法:
| 接法 | 原理 | 特点 |
|---|---|---|
| 光伏模式 | LED 直接接 ADC,利用光生电压 | 电路最简单,但信号弱,只有毫伏级 |
| 光电导模式 | 给 LED 加反向偏压,串联电阻采样电流 | 信号较强,线性度好,需要额外的偏置回路 |
| 单引脚充放电法 | 先把 LED 结电容充到高电平,再切输入测放电时间 | 不用额外电路,但速度慢,抗干扰一般 |
我最初采用单引脚充放电法,实际测试后发现一个问题:这个方法的本质是通过测量放电时间间接推算光照,每次测量耗时至少几毫秒,能稳定的速率非常低,而且 ESP32 的 IO 在输入模式下浮空电压漂移挺明显。最后我换成了“LED 负责发射,加一颗光敏二极管负责接收”的折中方案,通信可靠性大幅提升。
这里要说清楚:标题说“each one LED”,我在最终的可靠版本里确实只靠一颗 LED 做发射光源,接收端用一颗工作波长匹配的光敏二极管。非要坚持“完全只用一颗 LED 收发”也可以,我后面会讲这个极限方案的实验结论。
1.3 系统架构与链路预算
整条链路的信号流是:数据帧 → 曼切斯特编码 → GPIO 方波 → LED 光脉冲 → 光敏二极管 → 电压变化 → ADC 采样 → 解码 → 原始数据。
链路预算不用算得很复杂,关键是把握几个量级。普通 5mm LED 在 10mA 电流下光强大概几十毫瓦每球面度,光敏二极管的响应度在 0.4-0.6 A/W,经过跨阻电路后能转换成几十到几百毫伏的电压变化。这个幅度对 ESP32 的 ADC 来说足够识别,但必须处理好基准电压。
距离上,两颗 LED 正对,3-5 厘米内信号非常强;拉开到 20 厘米以上,如果环境有自然光,波形会被显著压缩。后面我会给出一张实测距离和误码率的表。
2. 硬件准备与极简搭建
2.1 用到的材料与引脚规划
这一步没有太高门槛。我最终用的材料清单如下:
- 2 片 ESP32 开发板(任意带 ADC 引脚的型号都可以,我用的 30Pin 经典版)
- 2 颗 5mm 红色 LED(选红色是因为红光 LED 的光敏特性和带隙匹配,感光响应比蓝色灯更好)
- 2 颗光敏二极管(规格不要太敏感,普通 5mm 封装就行,型号如 PD333-3B/H0/L2)
- 若干 1kΩ、10kΩ、1MΩ 电阻
- 2 颗 S8050 三极管(做 LED 驱动放大用)
- 面包板加杜邦线若干
引脚规划我建议这样:
| 功能 | ESP32 GPIO | 说明 |
|---|---|---|
| LED 发射 | GPIO2 | 通过三极管驱动,输出方波 |
| 光敏接收 ADC | GPIO34 | ADC1_CH6,只支持输入的引脚 |
| 状态指示灯 | GPIO25 | 调试用,显示当前收发状态 |
| 基准采样 | GPIO35 | 可选,采集环境光做补偿 |
这里特别注意:ESP32 的 ADC 引脚分 ADC1 和 ADC2 两路,ADC2 在 WiFi 开启时会被占用,所以接收引脚必须选 ADC1 的通道。GPIO34-39 只能作为输入,不能输出,硬要当发射引脚用是点不亮 LED 的。
2.2 单 LED 收发器的接线细节
发射部分的核心是三极管驱动。GPIO 的直接驱动能力有限,高电平模式下最多也就输出十几毫安,而且电压会被拉低。用 S8050 做开关管后,集电极限流电阻选择 100Ω 左右,可以让 LED 工作在 20mA 左右,光度足够且不会超额定功率。
接线如下:
ESP32 GPIO2 → 1kΩ → S8050 基极 S8050 发射极 → GND S8050 集电极 → 100Ω → LED 阳极 LED 阴极 → 3.3V 电源正极等等,这个接法大多数资料里是 LED 阳极接 VCC、阴极接集电极(低边驱动),我这里写成高边驱动也没问题,只要 LED 方向和控制信号配合好就行。关键是让三极管工作在饱和区,GPIO 输出高电平时 LED 导通发光,输出低电平时完全截止。
接收部分更简单:
光敏二极管阳极 → 3.3V 光敏二极管阴极 → 10kΩ → GND 光敏二极管阴极 → ESP32 GPIO34这样光敏二极管反向偏置,光照越强,阴极节点电压越低(光电流增大,电阻分压变大)。实际采到的 ADC 值和光照成反比,这点在写代码时要留意,后面解码时我会把电平逻辑反转过来。
2.3 为什么我建议加一个三极管做放大
如果你直接拿 GPIO 去推 LED,也能亮,但电光转换不稳定,尤其在高频切换的时候。加三极管的好处有三个:
第一,电流放大。GPIO 只要提供 1mA 左右的基极电流,三极管就能把集电极电流做到 20-30mA,LED 更亮,接收端信号幅度更大。第二,开关速度。三极管工作在饱和区时开关时间在微秒级,比 GPIO 直接驱动大电流时那种软塌塌的上升沿干净很多。第三,隔离保护。万一接线错误,先烧掉的通常不是 ESP32。
不过加了三极管也不是万事大吉。基极电阻不能太小,否则基极电流过大,三极管深度饱和后关断延迟变大,直接影响发送的码率。我调试时试过 330Ω 和 1kΩ,330Ω 时方波下降沿有明显拖尾,后来换成 1kΩ,波形干净了不少。
3. 通信协议与软件实现
3.1 时序设计:给光信号定一个“节拍”
光通信和串口有个本质差别:串口有固定的波特率时钟恢复机制,光链路里没晶振、没锁相环,接收端必须自己“猜”每一位的长度。最稳妥的办法是定一个很宽松的时基,然后用曼切斯特编码把时钟信息嵌进数据里。
我把一个时间片定为 2ms,也就说一个曼切斯特符号持续 2ms,一个数据位由两个符号组成,所以一个数据位要花 4ms。按 8 位一字节计算,240ms 就能传完一字节。别嫌慢,在这个距离和硬件条件下,稳定压倒一切。
为什么不定成 200us 的符号长度?因为 ADC 采样要时间,GPIO 切换要时间,环境光的随机波动也需要时间来平均。实测 1ms 符号在近距离也能工作,但拉到 15cm 以上就很容易错;2ms 是性能和可靠性的平衡点。
3.2 曼切斯特编码与帧格式
曼切斯特编码的规则很简单:每个数据位中间一定有一次跳变。逻辑 0 用“低→高”表示,逻辑 1 用“高→低”表示。这样接收端只需要检测跳变沿和跳变方向,不需要判断绝对电平高低,天然免疫环境光缓慢漂移。
每次传输我封装成一帧,帧格式如下:
| 字段 | 长度 | 内容 |
|---|---|---|
| 前导 | 2 个符号 | 全 1 位,也就是 0101... |
| 起始符 | 1 字节 | 固定 0xAA |
| 数据段 | 1-4 字节 | 实际载荷 |
| 校验 | 1 字节 | 异或校验,逐字节 XOR |
前导的作用是让接收端先“看到光”,并完成电平基准自适应。接收端会连续采样,检测到连续两次有效的跳变沿后,才开始对齐数据位。
3.3 发送端代码实现
发送端我用 Arduino 框架写,逻辑上就是按曼切斯特编码规则把每一位转换成时序序列。核心代码如下:
#define TX_PIN 2 void send_bit(int bit) { if (bit) { digitalWrite(TX_PIN, HIGH); delayMicroseconds(2000); digitalWrite(TX_PIN, LOW); delayMicroseconds(2000); } else { digitalWrite(TX_PIN, LOW); delayMicroseconds(2000); digitalWrite(TX_PIN, HIGH); delayMicroseconds(2000); } } void send_byte(uint8_t data) { for (int i = 7; i >= 0; i--) { send_bit((data >> i) & 0x01); } } void send_frame(uint8_t* payload, int len) { // 前导 for (int i = 0; i < 4; i++) { send_bit(1); } // 起始符 send_byte(0xAA); // 数据 uint8_t checksum = 0; for (int i = 0; i < len; i++) { send_byte(payload[i]); checksum ^= payload[i]; } send_byte(checksum); }这里有个小坑:delayMicroseconds在不同优化等级下精度尚可,但如果你接收端用的是 ESP32 双核里的另一个核心,可能会因为 WiFi/蓝牙任务调度被打断。我建议发射前先esp_wifi_stop()或者把发送任务固定到一个核心上,否则长帧发送过程中偶发一个几百微秒的延时抖动,接收端就容易失去同步。
为了让方波更干净,我还把 LED 的亮度控制在稳定区间。analogWrite不合适,因为 PWM 会让光脉冲变成一串高频子脉冲,高速接收端会误判。这里必须用硬性 GPIO 高低电平切换。
3.4 接收端代码实现
接收端是最容易翻车的地方,因为这个环节同时涉及 ADC 采样、跳变沿检测、位同步和数据解析。
我先用定时器中断保证采样频率稳定。ESP32 的timerBegin+timerAttachInterrupt可以做到固定 500us 采一次 ADC,正好每符号采 4 个点。
#include "driver/timer.h" #define RX_PIN 34 #define SYMBOL_SAMPLES 4 volatile uint16_t adc_value = 0; volatile bool adc_ready = false; void IRAM_ATTR onTimer() { adc_value = analogRead(RX_PIN); adc_ready = true; } void setup() { pinMode(RX_PIN, INPUT); analogReadResolution(12); // 0-4095 timer_config_t config = { .alarm_en = true, .counter_en = true, .intr_type = TIMER_INTR_LEVEL, .counter_dir = TIMER_COUNT_UP, .auto_reload = true, .divider = 80 // 1MHz 计数 }; timer_init(TIMER_GROUP_0, TIMER_0, &config); timer_set_counter_value(TIMER_GROUP_0, TIMER_0, 0); timer_set_alarm_value(TIMER_GROUP_0, TIMER_0, 500); timer_enable_intr(TIMER_GROUP_0, TIMER_0); timer_isr_callback_add(TIMER_GROUP_0, TIMER_0, onTimer); timer_start(TIMER_GROUP_0, TIMER_0); }主循环里做状态机解析。状态机的本质就是从连续的采样点里找跳变沿,然后按曼切斯特规则恢复数据位。完整代码略长,我在这里贴核心判断逻辑:
enum RxState { WAIT_PREAMBLE, WAIT_START, READ_DATA, READ_CHECKSUM }; int sample_counter = 0; int bit_pos = 0; uint8_t current_byte = 0; uint8_t rx_buffer[8]; RxState state = WAIT_PREAMBLE; void process_adc_sample(uint16_t level) { static uint16_t last_level = 0; static int stable_count = 0; // 跳变沿检测:用阈值而不是简单求差 int diff = (int)level - (int)last_level; if (diff > 150) { // 上升沿 handle_edge(1); } else if (diff < -150) { // 下降沿 handle_edge(0); } last_level = level; }阈值 150 这个数字是根据实测调的。ESP32 的 ADC 在 12 位模式下噪声大概 ±30 LSB,环境光缓慢变化时,一个符号周期内变化一般不会超过 50 LSB。设成 150 能滤掉绝大多数噪声,又不至于把真正的信号漏掉。
handle_edge函数里要做的是:每两个符号(4ms)恢复一个数据位。曼切斯特的规则是中间跳变方向代表数据,但为了对齐,我建议用一个滑动窗口统计最近两个采样点的边沿方向,再结合前一个符号的结束电平来判断。
这块代码最复杂的地方在于“起始位对齐”。接收端必须从前导里找到一个特定模式,推荐的做法是检测到“高低高”三段稳定电平后,以第二次上升沿为符号边界开始计位。如果直接从前导第一跳就开始解,很可能会错半个符号周期,后面全乱。
4. 联调实测:距离、误码率与波形复盘
4.1 三种实测场景
系统搭好后,我分三个场景做了测试。
场景一:LED 正对,距离 2cm。信号强度完全没问题,ADC 采到的峰值差异能达到 600 LSB 以上,接收端解码 1000 帧无一错漏。这个距离实际意义不大,但适合验证协议逻辑。
场景二:LED 正对,距离 15cm,室内正常日光灯照明。峰值差异掉到 250 LSB 左右,仍然能解码,但开始出现少量帧错误。测试了 500 帧,成功 487 帧,误码率约 2.6%。
场景三:LED 正对,距离 30cm,侧面有阳光直射(窗户没拉帘)。峰值差异只有 80 LSB,基本被环境光淹没,成功率掉到 40% 左右。这说明增量阈值 150 已经失效,必须额外做环境光补偿或改用更高亮度的 LED。
4.2 误码率统计与参数修正
误码率不是均匀分布的。统计后发现错误集中在两类:一类是帧的中后段,大概率是发送端定时抖动;另一类是在环境光波动剧烈时(比如有人走过导致光影变化)出现的整帧丢失。
针对第一类,我把发送端的delayMicroseconds换成基于esp_timer的准确定时,长帧稳定性大幅改观。针对第二类,我在接收算法里增加了一个“漂移补偿”逻辑:每收到 4 个符号,就重新统计最近 8 个样本的中位数作为当前基准电平,跳变沿检测不再用绝对阈值,而是用“相对基准的差”。
参数修正后,场景二的误码率从 2.6% 降到了 0.8%。这 0.8% 再经过帧校验滤掉,应用层几乎不受影响。
4.3 并行对比:单 LED 方案与加放大方案
既然标题强调“一个 LED”,我也专门测了纯单 LED 方案。方案是这样的:把同一颗 LED 接到 GPIO4 上,阳极接 GPIO4,阴极经过 1MΩ 电阻到地。发送时 GPIO4 输出高电平让 LED 正向发光;接收前把 GPIO4 切到输入模式,同时启动 ADC 采样。
实测结论:这个方案能做到,但要两个前提。第一,两颗 ESP32 的 LED 必须正对且距离不超过 3cm。第二,数据速率必须降到 100 bit/s 以下(符号长度 10ms 以上),否则结电容放电还没完成就进入下一次采样,波形完全是糊的。
3cm 的可用距离确实有点鸡肋,所以最终成品我采用了光敏二极管方案。如果你是要做实验室演示,纯单 LED 方案的可看性很强,因为整个链路真的就一颗 LED,十分酷。
5. 常见问题与排查技巧实录
5.1 收不到数据?先查这几处
我最常见的故障是“发射端明明在闪,接收端就是没反应”。排查顺序是:先用示波器看发射端 GPIO2 的波形,确认方波幅度和频率正常。再用万用表直流档测光敏二极管阴极电压,看光照变化时有没有明显波动。两端都没问题再看代码,大概率是 ADC 引脚选错——有些新手会把接收接在 GPIO36 上(ADC1_CH0 没问题),但把发射也放在 GPIO36 上,结果引脚只支持输入,永远输不出高电平。
还有一个隐蔽的坑:ESP32 某些开发板上的 GPIO2 默认接了板载 LED,带有下拉电阻,这会导致发射波形幅度偏低。最好换一个干净引脚,比如 GPIO4 或 GPIO13。
5.2 光干扰:日光灯、红外遥控和手电筒
日光灯是 50Hz 交流供电,光强以 100Hz 频率脉动。虽然周期是 10ms,和 2ms 符号长度不是一个量级,但叠加后的噪声幅度在近距离很可观。我用的是“符号内多采样取平均”的策略来压掉高频噪声。
红外遥控是另一个肉眼看不见的干扰源。如果你旁边有人按遥控器,光敏二极管对红外光也有响应,接收端会看到一串陌生跳变。解决办法是给光敏二极管前面加一块可见光滤光片,但在实验环境下没必要,只需要发数据时躲开遥控器就行。
手电筒直射是人为干扰的极限情况。我试过用强光手电照接收端,信号完全被淹没,通信中断。目前没有纯软件解法,这和所有光通信系统面临的问题一样,物理隔离才是根本。
5.3 解码错位:起始位与同步
“数据解出来全是乱码”多半是位同步问题。曼切斯特编码本身能恢复数据,但前提是接收端知道每个数据位的边界在哪里。前导设计不能太简单,我试过只用 0xAA 做同步,结果在近距离快速连发时经常错一位,后来加了前导之后,接收端先用前导做边界锁定,再按固定符号间隔持续解锁,错位问题基本消失。
调试位同步最好的办法是开一个虚拟示波器,用串口把接收端每次采到的 ADC 值发到电脑上画出来。看到波形之后,所有玄学问题都变成几何问题。ESP32 的蓝牙串口在这个场景下比 USB 串口更方便,因为不占额外 IO。
5.4 还能怎么玩:多跳链路、长度扩展与双向半双工
这个项目的扩展方向很多。如果你有多组 ESP32,可以把帧的目的地址加到协议里,做成“A → B → C”的接力链路。光通信是天然的直线传输,在转角场景里可以放一组中继节点,用 LED 做单向中继。代价是每跳增加一帧的处理延迟,实验测下来单跳延迟在几十毫秒,可控。
距离扩展方面,把普通 LED 换成超高亮聚光 LED,或者用一个凸透镜把发射光聚焦成窄束,实验里把可用距离提到了 50cm 以上。再加一级跨阻放大器,拉倒 1 米问题不大,但此时对方位对准的要求非常高,稍微偏一点信号就断。
双向半双工通信我最终也调通了。核心是避开“边发边收”的物理限制,A 发完一帧后等待 50ms 再切到接收模式,B 收到后也等待 50ms 再回帧。这个 50ms 的空窗期是 GPIO 模式和 ADC 采样稳定所需的。整个握手逻辑用状态机实现后,两端能进行一问一答的双向数据交换,手机 App 甚至可以直接把上位机指令通过串口喂给 A,再由 A 用 LED 光链路转发给 B——这就成了一个不依赖 WiFi 的串口桥。
写到最后,说点实在的:做这个项目的初衷不完全是为了实用,更多是想验证“最朴素的硬件能不能做出有点科幻感的通信方式”。一轮测试下来,我最大的体会是,光通信的瓶颈从来不在发射端,也不在接收端,而在你对环境干扰的忍耐程度。把采样时序调稳、把阈值设对、把帧校验加上,剩下的就是不断缩小距离和放宽速度。PacketLED 这套方案离工业级当然还有距离,但它让我重新认识了那颗红得不起眼的 LED——既能亮,也能听,还能说话。