news 2026/10/5 1:13:23

LED也能当光探测器?ESP32光通信项目PacketLED全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
LED也能当光探测器?ESP32光通信项目PacketLED全解析

如果告诉你,两个ESP32面对面,各自只用一个普通的5mm红色LED,不需要导线、不需要蓝牙模块、不需要WiFi,就能互相发送数据包——听起来是不是有点魔幻?PacketLED这个项目做的就是这件事:把LED既当发光器又当光探测器,两个板子各用一颗LED,在几厘米到十几厘米的距离上建立一条半双工光链路,传输的还不是简单的闪灯信号,而是带前导码、长度、校验的真实数据包。

这个玩法很适合两类人:一是刚入门ESP32、想搞点不一样小项目的玩家,二是已经被蓝牙、WiFi协议栈绕得头晕、想回到物理层亲手撸一遍通信协议的老手。整个项目成本不到十块钱,用Arduino框架就能跑,不需要额外模块,却能让你把光电效应、载波监听、帧结构、波特率同步这些概念一次性串起来。下面把我从第一版硬件到最终调试的经验完整写出来,包括踩过的坑和最终能跑的代码逻辑。

1. 为什么我会想用一颗LED去传数据

1.1 一切的起点:给两个隔离设备找个便宜又简单的信道

很长一段时间我在做一个小项目:两台ESP32要交换少量状态信息,但它们被放在两个电气上完全隔离的位置,中间不想拉线,也用不起无线模块。传统思路无非三种:串口线、蓝牙、WiFi。串口线最直接,但要求共地、走线;蓝牙和WiFi虽然方便,配对流程、协议栈、功耗、延迟全是事,为了传几个字节还要维护一套连接状态机,实在太重。

后来我想到红外遥控器的原理。电视遥控器就是靠一个红外LED发光,接收端用一个红外接收头解码。红外模块虽然便宜,但它最少也要两个器件:发射管和接收头,而且接收头一般只能单向接收。如果手边一个普通LED就能同时干这两件事呢?发光时自然是发射器,外部光线照在PN结上又会产生微弱电压,接ADC就能当光电探测器用。这样收发共用同一个元件,连光路切换都不需要额外硬件,整个通信链路本质上就是"两个LED面对面"。

于是项目名就很自然地叫PacketLED了。Packet是数据包,LED是那颗灯珠。它不是把LED当指示灯用,而是把它当物理层收发器:用一种速率把数据编码成一串亮灭,另一颗LED再用ADC读回这一串亮灭。这套思路放在学术圈叫LiFi可见光通信,我只是把它的最小可行样本做出来而已。

1.2 和现有方案比一下,优缺点就很清楚

做之前我做了一轮对比,想确认这个方向不是纯粹折腾自己。看下来大概是这样的:

方案硬件成本双向性隔离性配对/协议复杂度适合场景
UART有线串口低全双工需要共地低同板或近距离接线
蓝牙BLE中高全双工天然隔离高中远距离、需要穿墙
WiFi中高全双工天然隔离高联网场景
红外收发模块低一般单向天然隔离中遥控器场景
PacketLED光链路极低半双工天然隔离低近距离、极简场景

这个对比不是要否定蓝牙和WiFi,而是想说明:当你只需要在5厘米内传十几个字节、不在乎穿墙、又想彻底不共地的时候,LED光链路的成本低到几乎忽略不计,逻辑简单到一套Arduino代码就能讲清楚。当然它有明显短板——距离近、半双工、怕强光,但作为学习和轻量应用,正好合适。

2. LED的反向操作:从发光器到光探测器

2.1 同一个PN结,两个方向都能用

很多人第一次听到"LED还能当光敏传感器",第一反应是怀疑。我也是试过以后才彻底理解。LED内部就是一个PN结,正向偏置时电子和空穴在结区复合,能量以光子形式释放,这就是电致发光。反过来,光子照进PN结会被材料吸收,激发出电子-空穴对,在结区形成光生电压,这就是光伏效应。也就是说,同一个芯片既能把电变成光,也能把光变成电,只是工作在两种完全不同的模式。

你可以用最简单的方式验证这个现象:拿一颗红色5mm LED直接插到万用表的二极管挡位,LED正极接红表笔、负极接黑表笔,拿到台灯下照一照,屏幕上会跳出一个电压值,用手遮住马上掉下来。这个电压本质上就是光生电动势。把它接到ADC引脚上,就是一颗土生土长的光强传感器。

关键点是,LED作为探测器时,严格说是工作在"零偏置/反偏光伏模式",而不是正向导通模式。我们读取的是它被光激发后在阳极和阴极之间产生的电压,这个电压不需要外部供电。所以接收时引脚必须配置成高阻输入,千万不能开内部上拉,否则上拉电阻会把微弱的光生电压直接拉没,读数就废了。

2.2 为什么红色LED比蓝色LED更适合当接收器

同样的光照下,不同颜色的LED做探测器的灵敏度差别非常大。根本原因在于PN结材料的禁带宽度决定了它能吸收什么波长的光子。光子能量必须大于等于禁带宽度才能被吸收并产生电子-空穴对,而LED发光的波长又恰好对应它的禁带宽度,所以一颗LED对自己发出来的同色光最敏感。

红色LED的禁带宽度大约1.8eV,对应波长650-700nm左右,它不仅能吸收自己发出的红光,对波长更短的橙光、黄光、绿光也有一定响应,因为那些光子能量更高,也能激发载流子。蓝色LED的禁带宽度接近2.8eV,只能用能量更高的短波光子激发,你拿红光去照它,光子能量不够,基本没响应。绿色LED居中。所以在这个项目里,最稳的组合就是发射用红光LED,接收也用红光LED,两颗同型号的红色LED互相之间几乎就是一对"同频对讲机"。

我第一版测试时犯过个错:手边没红LED,拿了颗蓝色LED顶上,结果TMD完全收不到另一颗蓝LED发出来的信号,一度以为是光路设计错了。后来一查资料才反应过来是波长匹配问题。所以选型建议很明确:买一包最常见的5mm红色散光LED,多买几颗备着,个别差异没关系,但颜色一定要选红。

3. 硬件连接没有想象中复杂,但引脚选择有讲究

3.1 最小电路:GPIO + 一颗电阻 + 一颗LED

整个硬件部分只有四个东西:ESP32开发板、一颗红色LED、一颗330Ω电阻、几根杜邦线。连接方式如下:

GPIO32 ──[330Ω]──> LED正极(阳极) LED负极(阴极)──> GND

没错,就这一条回路。这颗LED同时承担发送和接收:发送时GPIO32配置为推挽输出,输出高电平LED亮,输出低电平LED灭;接收时GPIO32配置为ADC输入,外部光照射LED在阳极上产生光电压,ADC读这个电压。因为LED是双向元件,电路不需要做任何切换旁路,只需要在软件里改一下引脚模式。

为什么选330Ω而不是常见的220Ω?3.3V供电下红色LED正向压降大约1.8V,330Ω对应的电流是(3.3-1.8)/330≈4.5mA,亮度和驱动能力都足够。电流太小LED不够亮,远了根本收不到;电流太大虽然更亮但没必要,普通GPIO输出能力也没问题。如果你想让发射距离尽量远,可以把电阻降到100Ω,电流到15mA左右,亮度显著提升,代价是接收端偏置电压也会更高一点,实测影响不大。

3.2 为什么必须是GPIO32或GPIO33

这里有个非常多人踩过的坑:ESP32不是随便哪个引脚都能同时干"输出控制LED + ADC采样"这两件事。

ESP32的ADC分ADC1和ADC2两个单元。ADC1对应的引脚是GPIO32-39,但其中GPIO34-39是纯输入引脚,物理上不能配置成推挽输出,所以没法驱动LED发光;真正既能输出、又能输入ADC的引脚,ADC1通道里只剩GPIO32和GPIO33了。ADC2虽然有几个引脚既能输出又能采样,但ADC2在WiFi开启时会直接不可用,为了省心也避开。

综合下来,这个项目的最佳引脚就是GPIO32或者GPIO33。我最终选了GPIO32。还有一个细节,不能选板上自带的LED(通常接GPIO2),因为板载LED已经占用了电路,而且GPIO2属于ADC2通道,WiFi一开就废了。

3.3 供电与隔离:光链路带来的额外好处

两颗LED之间没有电气连接,所以两个ESP32完全不需要共地,甚至可以使用两套完全隔离的电源,这在某些工业场景下是个很实用的特性。不过要注意的是,两个板子各自供电时,ADC的地和电源要稳定,否则采样结果会抖动。

实际搭建时我还发现一个小问题:如果两个ESP32靠得很近,而开发板USB供电的纹波比较大,ADC读数会出现一个缓慢漂移的基线。排查了半天,最后是给LED那一侧的3.3V和GND之间加了一颗100nF电容,读数立刻稳定了很多。如果你用的是充电宝或开关电源,建议也顺手加上,不影响成本,但能省掉很多排查时间。


4. 数据包怎么在亮灭之间跑起来

4.1 物理层:最直观的OOK亮灭调制

物理层决定了"1"和"0"怎么用光来表达。我选的是最基础的OOK(On-Off Keying,开关键控):LED亮代表高电平1,LED灭代表低电平0。它和串口UART的电平思想完全一致,只是把电信号换成了光信号。

这里有个协议方向要特别注意:UART标准里空闲是高电平,起始位是低电平。但光链路和电链路不太一样——LED长期发光的功耗和热量都更高,所以我把空闲状态定为灭,也就是低电平,起始位定为亮,高电平。换句话说,这是一个极性反转的伪UART。自己在两头约定好就行,没有必须遵守的标准。

波特率我最终选的是4800bps,一个bit的时长约208us。为什么不是9600或者更高?两个制约因素:一是ESP32 Arduino框架下analogRead单次采样大概要几十微秒,一个bit周期内要留足采样窗口;二是软件协议栈里还要做起始位同步、阈值判断,太快的波特率会让时序稳定性急剧下降。4800bps对LED本身的物理响应(红光LED的开关速度可以到MHz级)来说远远没到极限,真正的瓶颈是ADC采样和Arduino层的时间抖动。

4.2 帧结构:前导码、类型、长度、载荷与校验

物理层只管发射"一串亮灭",要让这串亮灭成为能承载信息的"数据包",还必须定义帧格式。我采用的是非常接近常见串口协议的格式:

字段长度内容
前导码Preamble2字节0xA5 0x5A
类型Type1字节0x01 Ping / 0x02 Data / 0x03 ACK
长度Length1字节Payload的字节数
载荷PayloadN字节实际数据
校验XOR Checksum1字节从Preamble到Payload逐字节异或

Preamble用0xA5、0x5A不是随便选的。这两个字节的二进制分别是10100101和01011010,LSB优先发送时,波形上的1和0交替非常频繁,接收端拿到前导码后既可以确认"帧要开始了",又能用这段相对均匀的跳变来做阈值校准。后面的Type、Length、Payload按序发送,最后用整帧的异或和做校验,任何一位错了都会导致校验失败,接收端就能丢弃这帧。

4.3 半双工怎么约好谁先开口:先听后发

两个ESP32共用一条光链路,但物理上无法同时收和发——因为你自己的LED亮着的时候,它既是光源也是障碍物,你根本没法在发送的同时稳定读取对方的微弱光信号。所以链路天然是半双工,必须解决"谁先开口"的问题。

我的办法是从以太网学的:先听再说。每个节点默认都处于ADC监听模式。想发送数据时,先监听一小段时间,比如连续采样500us,如果发现光链路上有活跃信号(ADC值明显超过环境光基线),说明对方正在发,就随机退避几十毫秒再试;如果链路安静,就抢到信道,立刻把引脚切成输出模式,把整帧发完,发完后再切回监听。

这个"光载波监听"虽然简陋,但在两个节点的场景下非常可靠。它避免了最糟糕的情况:两个ESP32同时把LED点亮,接收方看到的光强是两者叠加,无法解析任何一方的信号。加上随机退避之后,实际使用中几乎没碰到过碰撞。

5. Arduino框架下的最小实现

5.1 初始化与发送端

代码用Arduino框架写,核心思路就是三件事:引脚能在输入输出间切换、按bit时序发灯灭、按起始位同步采样。初始化时要先设置ADC分辨率和衰减:

#define IO_LED 32 const uint32_t BIT_US = 208; // 4800bps 一个bit的时长 void setup() { Serial.begin(115200); pinMode(IO_LED, OUTPUT); digitalWrite(IO_LED, LOW); // 空闲保持灭 analogReadResolution(12); analogSetPinAttenuation(IO_LED, ADC_11db); // 这一点非常关键 }

ADC_11db衰减对应约3.3V量程,如果你不设置,默认衰减档位量程只有1.1V左右,而红色LED近距离被光照射时开路电压可能接近2V,直接削顶,永远读不到高电平。

发送一个字节的函数很直白,注意起始位是高、停止位是低:

void sendByte(uint8_t v) { digitalWrite(IO_LED, HIGH); // 起始位:亮 delayMicroseconds(BIT_US); for (int i = 0; i < 8; i++) { // 数据位:LSB first digitalWrite(IO_LED, (v >> i) & 0x01 ? HIGH : LOW); delayMicroseconds(BIT_US); } digitalWrite(IO_LED, LOW); // 停止位:灭 delayMicroseconds(BIT_US); }

发送帧之前先做载波监听,发送完再切回输入模式:

bool sendPacket(const uint8_t* payload, uint8_t len) { // 先听:连续采样,发现链路忙就退避 int idleCount = 0; for (int i = 0; i < 20; i++) { if (analogRead(IO_LED) < idleBase + 40) idleCount++; else idleCount = 0; delayMicroseconds(100); } if (idleCount < 15) return false; // 抢到信道,切输出,发帧 pinMode(IO_LED, OUTPUT); uint8_t xorSum = 0xA5 ^ 0x5A ^ 0x02 ^ len; sendByte(0xA5); sendByte(0x5A); sendByte(0x02); sendByte(len); for (uint8_t i = 0; i < len; i++) { sendByte(payload[i]); xorSum ^= payload[i]; } sendByte(xorSum); delayMicroseconds(BIT_US * 3); // 帧间隔 pinMode(IO_LED, INPUT); // 切回监听 return true; }

5.2 接收端同步与动态阈值

接收是整套实现里最考验细节的地方。核心问题有两个:一是怎么知道一个bit从哪一刻开始,二是怎么判断这个bit的ADC值是0还是1。

我采用的同步方式是UART式的起始位检测。接收端不断以高频轮询ADC,当检测到一个"从低到高"的跳变时,就认定这是起始位的上升沿。然后以这个上升沿为时间原点,推算出后面每个数据位的中点采样时刻。数字信号采样有个常识:在每个bit的中间时刻采样,抗干扰能力最强,因为电平在那个时刻最稳定。

阈值不能写死。白天和晚上、灯开和灯关,环境光导致的基线完全不同。所以我在检测到起始位后,上升沿之后半个bit时刻采一次样作为"亮"参考,空闲时刻的值作为"灭"参考,阈值取两者平均值:

uint8_t receiveByteAt(uint32_t t0) { int highAtStart = readSample(); // t0之后约半个bit采样 int threshold = (highAtStart + idleLow) / 2; uint8_t out = 0; for (int i = 0; i < 8; i++) { uint32_t sampleAt = t0 + BIT_US + i * BIT_US + BIT_US / 2; while (micros() < sampleAt) { } int raw = analogRead(IO_LED); if (raw > threshold) out |= (1 << i); } return out; }

这里有个隐藏问题:t0 + bit + i*bit + bit/2的公式里,t0是起始位上升沿时刻,起始位本身占1个bit,所以第一个数据位的中心在起始位结束后半bit处。采样时刻算准了,8个数据位就都能在正中间被采到。

readSample()我建议直接用analogRead()的raw值,不要用analogReadMilliVolts(),后者虽然直观但转换开销更大,在4800bps下容易拖垮时序。如果你非要用毫伏值,就把波特率降到2400bps。

5.3 完整收发流程和演示程序逻辑

我的演示程序逻辑是这样的:板子A上电后进入监听状态,按下按键时往光链路发一个"PING"数据包,payload是当前微秒数。板子B收到并校验通过后,自动切到发送模式,回一个"PONG"数据包。板子A收到PONG后,在串口打印出往返时间。

这个逻辑看似简单,但它把一次完整的数据链路走了一遍:载波监听、发送、起始位同步、阈值校准、字节解析、帧校验、自动应答。代码里两个角色可以烧同一份固件,靠一个按键区分:按下发PING,收到PONG就打印;收到PING就回PONG。这样你只需要烧两个板子,不需要维护两套代码。

5.4 时序抖动是最大的敌人

调试这个项目时,我最深的感受是:硬件很简单,真正难的是时序。Arduino的loop()不是实时系统,串口打印、WiFi后台任务、甚至analogRead本身的转换时间波动,都可能导致采样时刻偏移几十微秒。在104us(9600bps)这种级别,这点抖动足够让误码率飙升。

所以我的代码里反复强调:不要在接收每个bit的间隙里做任何耗时操作,尤其是Serial.print。串口打印一个字符可能就要几毫秒,等你打印完,整个帧早就过去了。正确做法是接收完成后,把打印放到帧解析之后。另外发送端也用micros()计算绝对时间而不是单纯等待,可以避免累积漂移。

6. 实测数据与那些没写在文档里的坑

6.1 距离、角度、亮度:一张表看懂

我把两个LED正对摆放,用330Ω限流电阻,在室内普通日光灯环境下测试,结果大致如下:

距离正对效果偏斜约5-10度说明
1-3cm非常稳定稳定信号裕量很大
5cm稳定偶发误码需要对准
8cm偶发误码基本不可用信号已经很弱
10cm以上不可用不可用需要换阻值或加透镜

这个结果告诉我们两件事:第一,几厘米的距离是这颗5mm LED的舒适区,除了玩,它更适合做"两个隔离板之间的短距离握手"而不是中距离通信;第二,角度比距离更敏感,LED不是激光笔,发射角度很散,接收端需要大致朝向光源。想要加大距离,最直接的办法是把限流电阻降到100Ω提升亮度,或者在LED外面套一个反光杯/透镜聚焦。

6.2 环境光和反射光的干扰

室内灯光的干扰让我头疼了一阵。日光灯和部分LED灯珠有100Hz甚至更高的频闪,虽然肉眼看不出来,但ADC会捕捉到这种波动。解决办法不是去硬件滤除,而是在软件上做动态阈值和基线跟踪:接收端在空闲状态下持续更新idleLow,用最近几十次采样的滑动平均值作为"灭"的基准。这样灯光缓慢变化时,阈值会跟着移动,不会突然把信号误判。

真正难搞的是强光直射。如果太阳光或台灯直接照到接收LED上,光生电压可能一直处于高位,整个链路的对比度会被压缩。最简单的办法是加一个遮光筒,把接收LED套上一个黑色热缩管或者反光杯,只让它"看到"对面的发射LED。实际测试中,一个简单的黑色胶带遮罩就能把抗强光能力提升一个档次。

6.3 用手机相机快速诊断光链路

分享一个非常实用的调试小技巧:眼睛看不出LED在高频闪烁,但手机相机的传感器能捕捉到。把手机相机对准发射LED,如果它正在发送数据,屏幕上能看到一条条明暗条纹甚至摩尔纹。如果有条纹,说明物理层在正常工作,问题多半出在时序或协议上;如果完全没有条纹,说明LED压根没在发数据,先查GPIO配置和发送条件。这个技巧比示波器直观多了,排查Bug时帮我节省了大量时间。

7. 尝到甜头之后我打算怎么继续扩展

7.1 加上ACK变成可靠传输

目前我的演示帧已经有Type字段,但接收端收到帧后只是打印,没有回确认包。要做成可靠传输,只需要在接收成功后回一个Type=0x03的ACK帧,发送端如果规定时间内没收到ACK就重发。这种机制和TCP的确认重传思想一模一样,只不过跑在光链路上。对于学习网络协议的人来说,这是特别直观的入门体验——你能亲眼看到"丢包"和"重传"是怎么回事。

7.2 更快的速率和更远的距离

如果想把波特率提到19200甚至更高,需要做两件事:一是改用ESP-IDF框架或者用ESP32的ADC连续采样DMA模式,减少单次采样的软件开销;二是去掉Arduino的delayMicroseconds循环,换成硬件定时器中断。这属于进阶玩法,但能让你清晰感受到软件框架对通信性能的天花板限制。

距离方面,除了降电阻和加透镜,还有一个方向是改用红色激光二极管——不过那就不是普通LED了,成本和安全性都得重新考虑。对这个项目来说,保持"一颗LED"的极简设计可能更有价值。

7.3 把USB串口桥接进去,让两台电脑隔空通信

我接下来的计划是给两个ESP32各接一个USB串口,让它们变成一对"光网卡":电脑A通过串口把数据发给ESP32-A,ESP32-A用LED发出去,ESP32-B收到后通过串口转发给电脑B。这样两台被光隔离的电脑就能通过两颗LED交换数据。想想还挺有意思的,等于是用最原始的可见光做了个极简版局域网物理层。


最后再分享一点个人体会:这个项目让我真正理解了为什么通信协议要分层。你看到的是一颗LED在闪,但背后有物理层的亮灭调制、链路层的帧结构、逻辑层的应答重传。这些概念平时藏在蓝牙协议栈和WiFi芯片里,你根本摸不着,PacketLED把它们全部剥了出来,用最便宜的硬件摆在你面前。如果你也想亲手感受一次"从零到一"的通信链路搭建,这个项目绝对值得搭一遍。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/5 1:13:23

图覆盖实战指南:从控制流图到主路径覆盖

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 1:13:07

PIC18F4620 与 MRAM 工业存储实战:SPI 驱动与掉电保护

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 1:12:24

Proteus中STM32F429/F407仿真难?用F401VE替代的完整移植指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 1:12:09

超节点上MoE模型部署实战:xDeepServe配置全解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 1:11:53

六脚三位数码管驱动实战:从TM1650协议到VS调试

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 1:11:25

STM32 DMA配置完全指南:从原理到串口收发与ADC采集

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华