ESP32 LED光通信实战:从零搭建PacketLED协议

发布时间:2026/10/4 12:50:58
ESP32 LED光通信实战:从零搭建PacketLED协议 1. 项目缘起与整体设计思路1.1 为什么想到让两颗 ESP32 用 LED 互相“说话”先说清楚这个项目到底在干什么。PacketLED 的核心思路非常朴素两颗 ESP32 开发板各自接一颗 LED不接任何额外的通信模块不连 WiFi不走蓝牙也不拉 UART 线就让这两颗芯片通过“一颗 LED 发光、另一颗 LED 接收光”的方式完成数据传递。发送端控制 LED 的亮灭节奏接收端用 ADC 采样这颗 LED 两端的电压变化把光信号还原成电信号再解码成字节最终实现两块板子之间的单向或双向通信。这个想法听起来像是“玩具”但它背后涉及的知识点非常密集GPIO 输出控制、ADC 采样与滤波、时序协议设计、帧同步、误码处理、中断与任务调度。对于已经玩过点灯、串口打印、简单传感器读取的嵌入式爱好者来说这是一个非常好的进阶练手项目。它逼着你从“调库”走向“自己定义协议、自己处理噪声、自己解决同步问题”。我最初动这个念头是因为手头有两块 ESP32 开发板一块是经典的 ESP32-WROOM-32另一块是 ESP32-C3。平时做项目总是习惯性地上 WiFi 或蓝牙但有些场景下无线协议栈太重功耗也高而红外、超声波又需要额外器件。LED 本身就是最便宜的光电器件正向导通时发光反向或弱光条件下它其实也能当光敏元件用。很多老玩家都知道 LED 可以当光电二极管使只是响应速度和灵敏度不如专用器件但对于低速数据通信来说完全够用。这个项目的目标读者很明确已经会 Arduino 基础操作、能点亮 LED、能读 ADC、想进一步理解“通信协议到底怎么从零搭起来”的人。如果你连pinMode和analogRead都没用过建议先把这两件事跑通再来看。整个项目不需要额外采购任何模块只需要两块 ESP32、两颗 LED、若干电阻和杜邦线成本极低但学到的东西比买一个现成通信模块多得多。1.2 方案选型为什么不用红外接收头、不用光敏电阻有人会问既然要传光信号为什么不直接用红外发射管加一体化接收头原因有三点。第一红外接收头内部自带解调电路通常只认 38kHz 载波你没法自由定义任意低速波形协议被限死了。第二手头不一定有红外对管但 LED 人人都有。第三用 LED 当接收器你能完整地看到“光→电流→电压→ADC 数值”这条链路对理解模拟前端非常有帮助。那为什么不用光敏电阻光敏电阻响应慢暗电阻和亮电阻变化虽然大但恢复时间长做几百赫兹以上的调制基本没戏。LED 结电容小反向偏置或零偏置下受光照产生的光电流虽然微弱但响应速度可以到微秒级适合做调制通信。当然直接用 LED 做接收信号幅度很小通常只有几十毫伏到几百毫伏必须借助 ESP32 内部 ADC 或者外部运放来放大。ESP32 的 ADC 虽然线性度一般但做这种低速、短距离通信配合软件滤波和阈值判决完全能跑起来。另一个关键选型是通信距离。这个项目不适合做几米以上的传输最佳距离是两颗 LED 面对面、间距 1 到 5 厘米。距离越近接收端 ADC 读数变化越明显误码率越低。如果你想拉远必须加透镜或者用高亮 LED 加大驱动电流但那样接收端就需要跨阻放大器复杂度上升。我的建议是先把近距离跑通再考虑优化。1.3 整体架构发送端、接收端与协议分层整个系统分成三层来理解。最底层是物理层发送端 GPIO 驱动 LED接收端 ADC 采样 LED 两端电压。中间是链路层定义“0”和“1”的波形表示、帧起始标志、帧结束标志、校验方式。最上层是应用层把要传的字节组装成帧接收端解析后打印到串口或者控制板载 LED 作为指示。发送端我用的是 ESP32 的 GPIO 口直接驱动 LED串联一个 220 欧姆限流电阻。为什么是 220 欧姆ESP32 GPIO 高电平输出约 3.3VLED 正向压降约 2.0V红色那么电阻上的压降是 1.3V电流约 5.9mA。这个电流足够让 LED 明显发光又不会超过 ESP32 单个 GPIO 的推荐拉电流约 12mA 持续。如果你用蓝色或白色 LED正向压降约 3.0V220 欧姆下电流只有 1.4mA亮度会偏暗建议换成 100 欧姆。但电阻太小又可能超过 GPIO 电流上限所以红色或黄色 LED 是最稳妥的选择。接收端我用的是另一颗 LED反向并联或者直接并联在 ADC 输入脚和地之间。这里有个细节LED 正向接在 ADC 脚上时发送端的光会让接收 LED 产生光生电压但这个电压方向与 LED 正向导通方向一致ADC 能读到正值。实际测试中接收 LED 两端不加偏置时光生电压只有几十毫伏ESP32 ADC 在 0 到 100mV 区间噪声很大。所以我后来改成给接收 LED 加一个反向偏置也就是阴极接 3.3V阳极接 ADC 脚并通过高阻值电阻接地。这样光照产生的光电流会在电阻上形成电压ADC 读数变化更明显。这个偏置方式下文会详细讲。协议层我设计得非常简单用脉宽区分“0”和“1”。短脉冲代表 0长脉冲代表 1帧与帧之间用更长的间隔作为起始标志。接收端用 ADC 连续采样记录超过阈值的持续时间再根据持续时间判决比特值。这个方案的好处是不需要精确的时钟同步对采样率要求也不高ESP32 的 ADC 在 Arduino 环境下跑 10kHz 采样率很轻松。2. 核心细节解析与实操要点2.1 LED 当光接收器原理与偏置电路设计LED 本质上是一个 PN 结二极管。当它正向导通时会发光这是电生光。反过来当光子打到 PN 结上时会在耗尽区激发出电子空穴对如果此时 PN 结处于反向偏置或者零偏置这些载流子会被内建电场分离形成光电流。这就是 LED 可以作为光接收器的物理基础。只不过 LED 的结面积大、结电容大响应速度不如专用光电二极管但做几十 kHz 以下的通信绰绰有余。关键问题是光电流非常小。一颗普通 5mm 红色 LED在 1 厘米距离、发送端 6mA 驱动下接收端短路光电流大约在几十微安到几百微安量级。如果你直接把这个电流灌进一个电阻比如 1 兆欧那么电压就是几十毫伏到几百毫伏。ESP32 的 ADC 量程是 0 到 3.3V12 位分辨率下 1LSB 约 0.8mV理论上能分辨但实际噪声可能有几个 LSB所以信号必须足够大。我的做法是给接收 LED 加反向偏置。具体接法接收 LED 的阴极接 ESP32 的 3.3V阳极接 ADC 输入脚比如 GPIO34同时阳极通过一个 1 兆欧电阻接地。这样 LED 处于反向偏置状态光照产生的光电流从 3.3V 经过 LED 流向电阻在电阻上形成电压。无光时ADC 读数接近 0有光时ADC 读数上升。实测在 1 厘米距离下有光和无光的 ADC 差值可以达到 200 到 500 个计数12 位下信噪比足够判决。注意ESP32 的 ADC1 通道GPIO32 到 GPIO39在 WiFi 开启时也能用但 ADC2 通道与 WiFi 冲突。这个项目虽然不用 WiFi但养成用 ADC1 的习惯没坏处。另外ESP32-C3 的 ADC 引脚分布不同GPIO0 到 GPIO4 是 ADC1接线前务必查一下自己板子的引脚图。电阻值的选择需要权衡。电阻越大光电流产生的电压越大但 RC 时间常数也越大响应变慢。1 兆欧配合 LED 结电容约 100pF和布线电容时间常数大约 100 微秒对应带宽约 1.6kHz。如果你把通信速率定在 100 到 500 比特每秒这个带宽完全够用。如果想提速到几千比特每秒电阻要降到 100 千欧左右但信号幅度也会同比下降需要加运放放大。2.2 发送端驱动电路限流、亮度与调制深度发送端看起来简单就是 GPIO 推 LED但有几个坑我踩过。第一ESP32 的 GPIO 在高电平时的驱动能力不是对称的拉电流和灌电流能力不同。数据手册里单个 GPIO 持续拉电流推荐不超过 12mA瞬间可以到 40mA。如果你用 100 欧姆电阻接红色 LED电流约 13mA短时间没问题但长时间跑可能发热。我最终用 220 欧姆电流约 6mA亮度足够板子也不烫。第二LED 的调制深度直接影响接收端判决。所谓调制深度就是“亮”和“灭”两种状态下接收端 ADC 读数的差值。如果你用 PWM 调光让 LED 一直半亮然后在此基础上叠加调制接收端看到的交流分量就小判决困难。所以这个项目里发送端 LED 必须是完全亮和完全灭两种状态不能有基础亮度。代码里就是digitalWrite(pin, HIGH)和digitalWrite(pin, LOW)不要用analogWrite。第三发送端的 LED 和接收端的 LED 要尽量对准。LED 的发光角度通常有 20 到 60 度接收 LED 的感光面也有方向性。我试过把两颗 LED 平放在桌面上间距 2 厘米接收信号很弱后来把两颗 LED 的头部正对间距 1 厘米信号立刻强了很多。如果你要做成固定装置可以用热缩管把两颗 LED 套在一起中间留 5 毫米间隙这样抗环境光干扰能力也更强。发送端的代码逻辑很简单先把要发送的字节拆成比特每个比特根据 0 或 1 输出不同宽度的脉冲比特之间插入固定间隔。比如我用 200 微秒脉冲代表 0600 微秒脉冲代表 1比特间隔 400 微秒。这样接收端只需要测量高电平持续时间大于 400 微秒判为 1小于 400 微秒判为 0。帧起始用一个 1200 微秒的长脉冲帧结束用 800 微秒脉冲。这些时间参数不是固定的你可以根据实际采样率和 LED 响应速度调整。2.3 接收端 ADC 采样与软件滤波接收端是整个项目最难的部分。ESP32 的 ADC 噪声大是出了名的尤其是低电压区间。我一开始直接读analogRead发现无光时读数在 0 到 80 之间跳有光时在 200 到 350 之间跳虽然能区分但偶尔会有毛刺导致误判。后来我加了两级滤波第一级是硬件 RC 低通在 ADC 脚和地之间并一个 100nF 电容截止频率约 1.6kHz把高频噪声滤掉。第二级是软件滑动平均连续采 8 个点取平均等效采样率降到 1.25kHz但信号干净很多。采样率的选择需要计算。假设最短脉冲是 200 微秒那么采样周期必须远小于 200 微秒否则可能采不到这个脉冲。我设定采样周期 50 微秒也就是 20kHz 采样率。但 ESP32 的analogRead在 Arduino 环境下单次转换约 100 微秒达不到 20kHz。所以我改用adc1_get_raw或者analogReadMillis这类更底层的接口或者直接用定时器触发采样。实际测试中我用micros()做时间戳循环里连续读 ADC记录每次读数超过阈值的时间点这样虽然采样间隔不均匀但通过时间戳可以准确计算脉冲宽度。阈值判决是另一个关键。固定阈值在环境光变化时会失效。比如白天室内光和晚上台灯下无光时的 ADC 基线完全不同。我的做法是动态阈值每次通信开始前先让发送端 LED 熄灭 100 毫秒接收端在这段时间内采集 100 个样本取最大值作为“暗电平”然后加上一个固定偏移量比如 100作为判决阈值。这样无论环境光怎么变阈值都能自适应。如果环境光很强暗电平本身就很高信号可能被淹没这时候就需要给接收 LED 加遮光罩或者提高发送端驱动电流。实操心得ESP32 的 ADC 在 0 到 0.1V 区间非线性非常严重读数基本不可用。所以接收 LED 的偏置电阻不要太大要让有光时的电压落在 0.3V 到 2.5V 之间。如果发现读数总是很小先检查 LED 是不是接反了再检查电阻是不是太大。3. 实操过程与核心环节实现3.1 硬件连接清单与接线步骤先列一下我用的物料。两块 ESP32 开发板我用的是一块 ESP32-WROOM-32 和一块 ESP32-C3其实两块一样的也行。两颗 5mm 红色 LED正向压降约 2.0V。电阻若干发送端用 220 欧姆接收端偏置用 1 兆欧滤波电容用 100nF。杜邦线若干面包板一块。发送端接线ESP32 的 GPIO25 接 220 欧姆电阻一端电阻另一端接发送 LED 的阳极发送 LED 的阴极接 GND。GPIO25 是 DAC 引脚但这里当普通 GPIO 用没问题。你也可以用 GPIO2板载 LED 就在 GPIO2 上但板载 LED 通常有电阻和驱动电路亮度可能不够建议外接。接收端接线接收 LED 的阴极接 3.3V阳极接 GPIO34ADC1 通道 6。GPIO34 同时接 1 兆欧电阻一端电阻另一端接 GND。GPIO34 再并一个 100nF 电容到 GND。注意 GPIO34 是输入专用引脚没有内部上拉正好适合做 ADC 输入。如果你用 ESP32-C3ADC1 通道在 GPIO0 到 GPIO4选 GPIO1 或 GPIO2 都行但注意 GPIO2 可能影响启动模式建议用 GPIO1。两颗 LED 的摆放把发送 LED 和接收 LED 的头部正对间距 1 到 2 厘米。我用热缩管剪了一小段把两颗 LED 套在一起中间留 5 毫米空隙这样环境光干扰小很多。如果你没有热缩管用黑色电工胶带卷一个筒也行。3.2 发送端代码实现与时间参数计算发送端代码的核心是一个sendByte函数和一个sendFrame函数。先定义时间参数#define BIT_0_US 200 #define BIT_1_US 600 #define BIT_GAP_US 400 #define FRAME_START_US 1200 #define FRAME_END_US 800为什么这么定假设接收端采样周期 50 微秒那么 200 微秒的脉冲会被采到 4 个点600 微秒采到 12 个点区分度足够。比特间隔 400 微秒是为了让接收端有时间复位判决状态避免上一个脉冲的拖尾影响下一个。帧起始 1200 微秒比任何数据脉冲都长接收端一旦检测到超过 1000 微秒的高电平就知道新帧开始了。发送一个字节的流程先发帧起始脉冲然后从最高位到最低位依次发送 8 个比特每个比特根据值是 0 还是 1 输出对应宽度的脉冲脉冲之间插入 400 微秒低电平。8 个比特发完后发帧结束脉冲。如果要发多个字节可以在帧结束前连续发或者每帧只发一个字节。我为了简单每帧只发一个字节帧之间间隔 10 毫秒。代码里用delayMicroseconds做精确延时。注意 ESP32 的delayMicroseconds在 Arduino 环境下精度还可以但如果你开了 WiFi 或蓝牙可能会有抖动。这个项目不需要无线功能建议在代码里关掉 WiFiWiFi.mode(WIFI_OFF)减少干扰。void sendBit(int bit) { digitalWrite(LED_PIN, HIGH); if (bit 0) { delayMicroseconds(BIT_0_US); } else { delayMicroseconds(BIT_1_US); } digitalWrite(LED_PIN, LOW); delayMicroseconds(BIT_GAP_US); } void sendByte(uint8_t data) { digitalWrite(LED_PIN, HIGH); delayMicroseconds(FRAME_START_US); digitalWrite(LED_PIN, LOW); delayMicroseconds(BIT_GAP_US); for (int i 7; i 0; i--) { sendBit((data i) 1); } digitalWrite(LED_PIN, HIGH); delayMicroseconds(FRAME_END_US); digitalWrite(LED_PIN, LOW); delayMicroseconds(10000); }3.3 接收端代码实现与脉冲宽度测量接收端的核心是持续采样 ADC检测高电平脉冲的起始和结束计算持续时间然后判决比特。我用了一个状态机状态 0 是等待帧起始状态 1 是接收数据比特状态 2 是等待帧结束。采样循环里我先读 ADC 值和动态阈值比较。如果超过阈值且上一个状态是低电平记录当前micros()作为脉冲起始时间。如果低于阈值且上一个状态是高电平记录当前micros()作为脉冲结束时间计算宽度。然后根据宽度判决如果宽度在 1000 到 1400 微秒之间认为是帧起始清零比特计数器进入接收状态。如果宽度在 500 到 700 微秒之间判为 1。如果宽度在 150 到 250 微秒之间判为 0。如果宽度在 700 到 900 微秒之间认为是帧结束把收到的字节打印出来。动态阈值的计算在 setup 里先让发送端不发数据接收端采集 200 个样本取最大值作为暗电平然后阈值设为暗电平加 80。这个 80 是经验值你可以根据实际信号幅度调整。如果信号很强可以设 150如果信号弱设 50。int readADC() { return analogRead(ADC_PIN); } void loop() { int val readADC(); unsigned long now micros(); if (val threshold lastState LOW) { pulseStart now; lastState HIGH; } else if (val threshold lastState HIGH) { unsigned long width now - pulseStart; lastState LOW; if (width 1000 width 1400) { bitCount 0; currentByte 0; receiving true; } else if (receiving) { if (width 500 width 700) { currentByte (currentByte 1) | 1; bitCount; } else if (width 150 width 250) { currentByte (currentByte 1) | 0; bitCount; } else if (width 700 width 900) { if (bitCount 8) { Serial.print(Received: ); Serial.println(currentByte); } receiving false; } } } }这段代码在 ESP32-WROOM-32 上跑接收端串口能稳定打印出发送端发来的字节。我测试连续发送 0x00 到 0xFF接收端偶尔会丢一两个字节但大部分都能正确收到。丢字节的原因主要是环境光突变或者采样时机不巧后面可以通过加校验位和重传机制改善。3.4 双向通信与帧校验的扩展思路单向跑通后你可以把两块板子都配上发送 LED 和接收 LED实现双向通信。但双向有个问题如果两边同时发送会互相干扰。解决办法是定义主从角色主机先发从机收到后回复主机收到回复后再发下一帧。这就是最简单的停等协议。帧校验我建议加一个简单的校验和。在 8 个数据位后面再加 8 个校验位校验位是数据位的按位取反或者累加和。接收端收到数据后计算校验如果不匹配就丢弃。这样虽然速率降低一半但可靠性大幅提升。如果你追求速率可以用 4 位数据加 4 位校验或者用 CRC-8。注意双向通信时两颗 LED 面对面发送时自己的接收 LED 也会收到自己的光造成自干扰。解决办法是发送时暂时关闭自己的接收采样或者用方向性遮挡让发送 LED 的光主要照向对方而不是自己的接收 LED。我用热缩管套住两颗 LED 时把发送和接收分开套中间加一层黑色隔板效果不错。4. 常见问题与排查技巧实录4.1 接收端读数始终为零或变化极小这是最常见的问题。先检查接收 LED 的极性。反向偏置接法是阴极接 3.3V阳极接 ADC 脚。如果你接反了LED 正向导通ADC 会读到接近 3.3V 的固定值光照变化完全看不出来。用万用表二极管档测一下 LED 极性长脚是阳极短脚是阴极或者看 LED 内部电极小的是阳极。如果极性没错检查偏置电阻。1 兆欧电阻如果虚焊或者阻值不对光电流没有路径电压上不去。用万用表测电阻两端应该是 1 兆欧左右。再检查 ADC 引脚是否选对ESP32-WROOM-32 的 GPIO34 是 ADC1 通道 6analogRead(34)应该能读到值。如果你用的是 ESP32-C3ADC 引脚编号不同analogRead(1)对应 GPIO1。还有一个可能是发送端 LED 太暗。用手机摄像头对着发送 LED发送数据时应该能看到闪烁。如果看不到检查发送端电阻是不是太大或者 GPIO 是不是没配置成输出模式。4.2 误码率高接收数据随机跳变误码率高通常有三个原因阈值不合适、采样率不够、环境光干扰。先调阈值。在接收端串口打印原始 ADC 值观察无光时的基线范围和有光时的峰值范围。如果两个范围有重叠说明信号太弱需要缩短 LED 间距或者增大发送电流。如果范围分得很开但仍有误码可能是采样率不够漏掉了短脉冲。把采样循环里的analogRead换成更快的adc1_get_raw或者降低通信速率把 BIT_0_US 从 200 改成 400。环境光干扰也很常见。日光灯有 100Hz 闪烁会在 ADC 读数上叠加周期性波动。解决办法是给接收 LED 加遮光罩或者在软件里做差分连续采两个点一个在发送端 LED 灭时采一个在亮时采用差值判决。但这样需要发送端配合实现起来复杂。最简单的还是物理遮光。4.3 通信距离短稍微拉远就收不到LED 光强随距离平方衰减。1 厘米到 2 厘米信号强度降到四分之一。如果你需要 5 厘米以上要么加大发送电流要么用高亮 LED要么加透镜。加大电流受限于 GPIO 驱动能力可以用三极管或者 MOSFET 驱动 LED电流可以到 100mA 以上。但接收端光电流也会同比增大偏置电阻要相应减小否则 ADC 会饱和。另一个技巧是用脉冲驱动而不是直流驱动。发送端 LED 在极短时间内通过较大电流平均电流不大但瞬间光强很高。比如用 100 欧姆电阻5V 供电如果板子有 5V 引脚瞬间电流 30mA脉宽 200 微秒占空比很低平均电流只有几毫安。这样接收端信号强发送端也不会过热。但注意 ESP32 GPIO 不能直接接 5V需要用三极管开关。4.4 常见问题速查表现象可能原因排查方法解决措施ADC 读数始终为 0接收 LED 极性接反万用表测 LED 极性阴极接 3.3V阳极接 ADCADC 读数始终为 3.3V接收 LED 正向导通检查是否阴极接 GND改为反向偏置接法无光和有光读数无差异偏置电阻虚焊或阻值错误万用表测电阻重新焊接 1 兆欧电阻误码率高阈值不合适串口打印原始 ADC 值动态阈值暗电平加 80短脉冲丢失采样率不够测量循环周期降低通信速率或换快速 ADC 接口日光灯下误码环境光 100Hz 干扰关灯测试加遮光罩或软件差分距离稍远就收不到光强衰减缩短间距测试加大驱动电流或加透镜双向通信自干扰自己的发送 LED 照到自己的接收 LED遮挡测试加隔板或分时收发4.5 独家避坑技巧与经验总结第一个技巧用板载 LED 做快速验证。很多 ESP32 开发板 GPIO2 上有一颗板载 LED你可以先用它做发送端不用外接任何元件快速验证代码逻辑。接收端还是用外接 LED因为板载 LED 通常没有反向偏置电路当接收器不好用。第二个技巧串口绘图器看波形。Arduino IDE 的串口绘图器可以把 ADC 值实时画出来你一眼就能看出脉冲形状、噪声幅度、阈值位置。调阈值的时候把阈值也打印出来画在同一张图上非常直观。第三个技巧先慢后快。一开始把 BIT_0_US 设成 1000BIT_1_US 设成 3000BIT_GAP_US 设成 2000这样接收端有充足时间采样几乎不会误码。跑通后再逐步缩短时间参数找到稳定通信的极限速率。我实测在 1 厘米距离、1 兆欧偏置下BIT_0_US 可以降到 150 微秒对应约 1.6kbps 的速率再快就误码了。第四个技巧加一个起始同步头。除了帧起始脉冲我还在帧起始前加了 5 个 0 比特作为同步头让接收端的动态阈值有时间收敛。同步头不参与数据只用于训练。这个技巧在环境光缓慢变化时特别有用。第五个技巧用中断代替轮询。如果你觉得轮询采样占用 CPU 太多可以用 ESP32 的定时器中断每 50 微秒触发一次 ADC 采样在中断里做状态机。但注意中断里不要做Serial.print把数据存到缓冲区在主循环里打印。这样采样更均匀误码率更低。这个项目我断断续续玩了两周从最初的点亮 LED 到稳定传输字节中间踩了不少坑。最深的体会是通信协议不是凭空设计出来的而是根据物理层的实际特性一点点磨出来的。LED 的响应速度、ADC 的噪声、环境光的干扰这些因素共同决定了你能用的时间参数和判决策略。把 PacketLED 跑通之后再看 UART、I2C、SPI 这些标准协议你会对它们为什么那样设计有更具体的理解。后续如果你想继续折腾可以试试用两颗 RGB LED 做三通道并行通信或者用红外 LED 拉远距离甚至用太阳能电池板当接收器玩法很多核心原理都是相通的。