1. 一块LED同时当发射器和接收器这事到底靠不靠谱第一次看到两个ESP32各用一颗LED互相通信这个想法时我的直觉是这不就是拿灯当对讲机用吗但仔细一想LED本质上是个PN结二极管正向通电会发光反向偏置时结电容会随光照变化——也就是说它天生就具备发光和感光两种能力。PacketLED这个项目正是利用了这个特性每块ESP32只接一颗LED通过快速切换LED的工作状态让它一会儿当发送端正向驱动发光一会儿当接收端反向偏置采样环境光变化从而实现双向数据通信。这个项目的核心价值不在于替代WiFi或蓝牙而在于它用一种极简的硬件结构两颗LED、两块ESP32、几根杜邦线演示了光通信的基本原理和ADC采样的实战应用。适合谁看如果你正在学ESP32的GPIO控制、ADC采样、外部中断或者想找一个能同时练到模拟电路数字通信嵌入式编程的小项目PacketLED是个非常好的练手选择。它不需要额外的传感器模块不需要复杂的协议栈所有代码都能在Arduino IDE里跑通。我实测下来这套方案在室内正常光照下通信距离10-20厘米是稳的速率不用追求太高每秒几十个比特足够传传感器数据或简单指令。下面我把整个项目的设计思路、硬件搭建、代码实现和踩坑经验完整拆一遍。2. 为什么选LED而不是红外对管或激光模块2.1 LED的双向能力从哪来普通LED的等效电路可以简化成一个理想二极管并联一个结电容。正向导通时电子和空穴复合释放光子这就是发光反向偏置时二极管截止但结电容仍然存在而且这个电容的放电速率与光照强度正相关——光照越强光生载流子越多结电容放电越快。所以如果你给LED反向充一个电压然后让它通过一个高阻值电阻放电用ADC测量放电到某个阈值所需的时间就能反推出环境光强度。PacketLED的做法更直接发送端LED快速亮灭接收端LED处于反向偏置状态ADC持续采样接收端LED两端的电压。当发送端的光照到接收端时接收端LED的等效电阻下降分压点电压发生变化ADC读到的值就会波动。通过检测这个波动就能还原出0和1。2.2 和红外对管方案的对比方案元件数量成本通信距离调试难度适合场景PacketLEDLED对LED2颗LED2块ESP32极低5-20cm中等学习光通信原理、ADC实战红外对管IR LED光敏管4个元件2块MCU低10-50cm低遥控、避障激光模块2个激光头2块MCU中1-5m高需对准长距离点对点超声波2个超声模块中2-4m低测距、避障选LED对LED的核心原因是一颗LED同时承担发射和接收两个角色硬件最简而且能逼着你去理解模拟采样的本质。红外对管虽然更稳定但发射和接收是分开的反而掩盖了PN结双向特性这个知识点。2.3 关键参数的计算依据LED反向放电时间与光照的关系可以用近似公式描述t_discharge ≈ R_pullup × C_junction × ln(V_initial / V_threshold)其中C_junction是结电容典型值在10-100pF量级R_pullup是下拉电阻我选的是1MΩ。假设C_junction50pFR_pullup1MΩV_initial3.3VV_threshold1.65V那么t_discharge ≈ 1e6 × 50e-12 × ln(2) ≈ 34.7微秒这意味着ADC采样窗口至少要在几十微秒量级才能捕捉到有效变化。ESP32的ADC采样率在默认配置下大约每秒钟几万次完全够用。但要注意ESP32的ADC在低电压段线性度较差所以我建议把阈值设在1.5V-2.5V之间避开非线性区。3. 硬件搭建两颗LED怎么接才不会烧3.1 电路连接清单每块ESP32需要连接的东西很少LED正极长脚接GPIO 25DAC输出也可以当普通GPIO用LED负极短脚接GPIO 34ADC输入仅输入引脚在GPIO 34和GND之间并联一个1MΩ电阻下拉用于反向放电两块ESP32共地GND连GND这里有个细节GPIO 34是ESP32的ADC1通道6只能作为输入没有内部上拉。所以外部那个1MΩ下拉电阻是必须的否则反向偏置时LED两端电压会浮空ADC读到的值没有意义。3.2 为什么选GPIO 25和GPIO 34GPIO 25在ESP32上对应DAC1可以输出真正的模拟电压。虽然PacketLED只需要数字高低电平但用DAC引脚的好处是如果你想做更精细的光强调制比如模拟调幅可以直接改DAC输出值不用换引脚。GPIO 34是ADC1的通道ADC1在WiFi关闭时噪声最小采样最稳定。注意ESP32的ADC2在WiFi开启时无法使用这是硬件限制。PacketLED不需要WiFi所以用ADC1完全没问题。3.3 限流电阻要不要加发送端LED正向发光时GPIO 25输出3.3VLED正向压降约2.0V红光或3.0V蓝光/白光如果不加限流电阻电流可能超过GPIO的最大驱动能力ESP32单个GPIO推荐最大12mA绝对最大40mA。我建议在GPIO 25和LED正极之间串一个100Ω-220Ω的电阻。但这里有个矛盾加了限流电阻后接收端反向偏置时这个电阻也会影响放电回路。实测下来100Ω的电阻对接收影响很小因为反向偏置时LED等效电阻在兆欧级100Ω可以忽略。所以发送端串100Ω接收端下拉1MΩ这个组合是安全的。3.4 两块板子的摆放两颗LED要对准间距控制在5-15厘米。太近了接收端容易饱和ADC一直读到最大值太远了信号太弱。我试过用热缩管把两颗LED套在一起间距固定5厘米效果最稳。如果要做演示可以用纸板做个遮光筒减少环境光干扰。4. 代码实现状态机ADC采样边沿检测4.1 整体通信协议设计PacketLED的通信是半双工的同一时刻一块ESP32发送另一块接收。协议很简单空闲状态发送端LED灭接收端ADC持续采样起始位发送端LED亮100微秒接收端检测到ADC值跳变数据位每个bit用LED亮/灭表示持续200微秒停止位发送端LED灭500微秒表示一帧结束一帧传8个bit加上起始和停止总共10个时间单位。按每个单位200微秒算一帧2毫秒理论速率500bps。实际因为ADC采样和软件开销我实测能稳定跑到100-200bps。4.2 发送端代码#define LED_TX 25 #define BIT_DURATION 200 // 微秒 void sendByte(uint8_t data) { // 起始位 digitalWrite(LED_TX, HIGH); delayMicroseconds(BIT_DURATION); // 8个数据位低位先发 for (int i 0; i 8; i) { digitalWrite(LED_TX, (data i) 1 ? HIGH : LOW); delayMicroseconds(BIT_DURATION); } // 停止位 digitalWrite(LED_TX, LOW); delayMicroseconds(BIT_DURATION * 2); }这段代码的关键点是delayMicroseconds的精度。ESP32的Arduino核心在默认情况下delayMicroseconds的误差在±10%左右对于200微秒的位宽来说误差20微秒接收端完全能容忍。但如果你把位宽降到50微秒以下误差就可能导致误码。4.3 接收端代码接收端不能用digitalRead因为LED反向偏置时电压是模拟量必须用analogRead。而且不能阻塞式等待要用状态机定时器中断。#define LED_RX 34 #define THRESHOLD 2000 // ADC阈值12位ADC范围0-4095 volatile bool receiving false; volatile uint8_t rxBuffer 0; volatile int bitCount 0; volatile unsigned long lastEdgeTime 0; void IRAM_ATTR onTimer() { int adcValue analogRead(LED_RX); if (!receiving) { // 检测起始位ADC值突然升高 if (adcValue THRESHOLD) { receiving true; bitCount 0; rxBuffer 0; lastEdgeTime micros(); } } else { // 在位宽中间采样 if (micros() - lastEdgeTime BIT_DURATION / 2) { if (adcValue THRESHOLD) { rxBuffer | (1 bitCount); } bitCount; lastEdgeTime micros(); if (bitCount 8) { receiving false; // 这里处理接收到的字节 Serial.println(rxBuffer, HEX); } } } }这里我用了一个硬件定时器每50微秒触发一次中断在中断里做ADC采样和状态判断。为什么不在loop里做因为loop里如果有其他任务比如串口打印采样间隔会不稳定导致位同步失败。4.4 定时器配置hw_timer_t *timer NULL; void setup() { Serial.begin(115200); pinMode(LED_TX, OUTPUT); timer timerBegin(0, 80, true); // 分频801MHz计数频率 timerAttachInterrupt(timer, onTimer, true); timerAlarmWrite(timer, 50, true); // 每50微秒触发 timerAlarmEnable(timer); }分频80意味着计数频率是80MHz/801MHz也就是每微秒计数一次。timerAlarmWrite(timer, 50, true)设置报警值为50所以每50微秒触发一次中断。这个频率足够捕捉200微秒的位宽每个bit采样4次。5. 调试过程中最容易翻车的三个地方5.1 ADC读数跳变太大根本分不清0和1这是最常见的问题。原因通常有三个一是环境光太强比如日光灯有100Hz闪烁会直接耦合到ADC读数里二是下拉电阻太大1MΩ虽然省电但抗干扰能力差三是ESP32的ADC本身噪声就有±50个LSB。我的解决办法是软件滤波动态阈值。软件滤波用滑动平均连续采5次去掉最大最小值再平均。动态阈值则是在空闲状态持续记录ADC的最大值和最小值取中间值作为阈值。这样即使环境光缓慢变化阈值也能自动跟随。int getFilteredADC() { int samples[5]; for (int i 0; i 5; i) { samples[i] analogRead(LED_RX); delayMicroseconds(10); } // 冒泡排序 for (int i 0; i 4; i) { for (int j i 1; j 5; j) { if (samples[i] samples[j]) { int temp samples[i]; samples[i] samples[j]; samples[j] temp; } } } return (samples[1] samples[2] samples[3]) / 3; }5.2 两块板子互相干扰发送时自己收到了自己的信号半双工通信的经典问题。如果发送端LED的光直接照到自己的接收端LED比如两颗LED靠太近就会形成自环。解决办法有两个一是物理隔离用遮光板把发送端和接收端隔开二是软件上做时分复用发送时关闭接收中断接收时关闭发送。我采用的是第二种在发送函数开头加timerAlarmDisable(timer)发送完再timerAlarmEnable(timer)。但这样会丢失发送期间可能到达的数据所以协议上要保证一问一答不能同时发送。5.3 通信距离一超过15厘米就断LED的发光强度随距离平方衰减接收端ADC读到的变化量会急剧减小。我实测在20厘米时ADC变化量只有100左右而噪声就有50信噪比只有2:1误码率很高。提升距离的办法一是增大发送端电流把100Ω限流电阻换成47Ω但要注意不要超过GPIO最大电流二是降低通信速率位宽从200微秒增加到500微秒给ADC更多采样时间三是用高亮度LED比如透明的红光LED比雾状红光LED指向性更好。提示如果你需要更远的距离建议换用红外LED红外接收管方案那是另一个项目了。PacketLED的定位就是短距离、极简硬件。6. 从PacketLED能延伸出哪些玩法6.1 双向通信两块板子轮流当发送端目前的代码是单向的一块发一块收。改成双向只需要加一个角色切换逻辑每块板子发送完一帧后立即切换成接收模式等待对方的应答。关键是切换时机要同步我建议用固定的时间槽0-10毫秒A发送10-20毫秒B发送循环往复。6.2 用LED传传感器数据既然能传字节就能传传感器数据。比如一块ESP32接DHT11测温度湿度把数据打包成4个字节通过LED发给另一块ESP32显示在OLED上。这样你就有了一个光隔离的无线传感器节点虽然距离短但完全不怕电磁干扰。6.3 改成光控PWM调光把发送端的数字高低电平改成PWM接收端ADC读到的就是模拟光强。这样可以用一颗LED传模拟量比如把电位器的电压值通过光传给另一块板子。这个玩法更接近光耦的原理但用的是普通LED。6.4 多节点组网如果三块ESP32各有一颗LED能不能组网理论上可以但需要解决冲突检测。我试过用载波侦听的思路发送前先监听一段时间如果ADC没有跳变才发送。但LED的指向性太强第三块板子很难同时收到两块板子的信号所以实际组网意义不大。更适合做点对点。7. 我在这个项目里踩过的坑和总结的经验第一个坑是GPIO 34没有内部上拉。我一开始偷懒没接外部下拉电阻结果ADC读数一直在飘从0到4095随机跳。后来查了ESP32的引脚定义才发现GPIO 34/35/36/39都是纯输入引脚内部没有上拉下拉。接上1MΩ电阻后立刻稳定了。第二个坑是ADC衰减设置。ESP32的ADC默认衰减是11dB量程0-3.3V。但如果你用analogSetAttenuation(ADC_0db)量程只有0-1.1V读数会更精确但范围小。PacketLED里LED反向电压最高也就2V左右所以我用的是6dB衰减量程0-2.2V兼顾精度和范围。第三个坑是中断里的analogRead。ESP32的Arduino核心在中断里调用analogRead是不安全的因为analogRead内部有锁。我一开始在定时器中断里直接调analogRead跑几分钟就死机。后来改成在中断里只做标记在主循环里做实际采样但这样又失去了定时精度。最终的方案是用ESP32的adc1_get_raw底层函数它没有锁可以在中断里安全调用。#include driver/adc.h void IRAM_ATTR onTimer() { int adcValue adc1_get_raw(ADC1_CHANNEL_6); // GPIO 34 // ... 后续处理 }这个函数直接读ADC寄存器速度比analogRead快得多而且可以在中断里安全使用。但要注意使用前需要先配置ADC通道adc1_config_width(ADC_WIDTH_BIT_12); adc1_config_channel_atten(ADC1_CHANNEL_6, ADC_ATTEN_DB_6);第四个坑是两块板子的地线没接。我一开始只连了LED和GPIO忘了把两块ESP32的GND连在一起结果接收端ADC读数完全乱套。因为ADC测量的是相对于本地GND的电压如果两块板子不共地参考电平就不一致。接上地线后立刻正常。最后分享一个小技巧如果你觉得调阈值太麻烦可以在接收端加一个自动校准流程。上电后前2秒发送端不发光接收端记录ADC的最大值和最小值取中间值作为阈值。然后发送端发一个已知的测试字节比如0xAA接收端验证是否收到如果误码就微调阈值。这个自动校准能让你的PacketLED在不同光照环境下都能快速部署。这个项目我断断续续玩了两周从最开始的完全收不到信号到后来稳定跑100bps中间换了三次电路、改了五版代码。最大的收获不是学会了光通信而是对ADC采样、中断安全、GPIO电气特性这些基础知识点有了实打实的理解。如果你也在学ESP32强烈建议动手搭一遍比看十篇教程都管用。