☰
ESP32光通信实战:用LED和ADC实现无线数据传输
2026/10/4 12:49:10 网站建设 项目流程

1. 项目缘起:两块ESP32用一颗LED聊天的极简通信实验

PacketLED 这个项目,说白了就是让两块 ESP32 开发板各自带一颗 LED,一颗当“发射器”,一颗当“接收器”,中间不接任何数据线,靠光来传数据。发射端的 LED 快速闪烁,把信息编码成一串明暗脉冲;接收端用光敏元件或者直接拿另一颗 LED 当光电二极管来感应这些脉冲,再通过 ADC 采样还原出原始数据。整个系统没有 Wi-Fi、没有蓝牙、没有串口线,只有两颗 LED 隔空对望。

我第一次看到这个思路的时候,脑子里蹦出来的第一个念头是:这不就是最原始的光通信吗?小时候拿手电筒晃来晃去传暗号,原理上跟这个一模一样。但真动手做起来,你会发现里面藏着不少门道——LED 的响应速度、ADC 的采样时机、环境光的干扰、同步问题,每一个都能让你调半天。

这个项目适合谁呢?如果你已经玩过 Arduino 或者 ESP32 的基础点灯实验,想找一个既有硬件动手乐趣、又能深入理解 ADC 采样和信号处理的练手项目,PacketLED 非常合适。它不需要昂贵的设备,两块 ESP32、两颗 LED、几个电阻就能搭起来,但涉及的知识点覆盖了 GPIO 控制、ADC 采样、中断处理、简单的编解码协议设计,麻雀虽小五脏俱全。

我前后花了大概三个晚上把这个东西调通,中间踩了不少坑,也积累了一些文档里不会写的经验。下面我把整个项目的设计思路、硬件搭建、代码实现和调试过程完整地梳理一遍,希望能帮你少走弯路。

2. 整体设计思路:为什么选光通信而不是无线

2.1 光通信方案的取舍逻辑

ESP32 本身自带 Wi-Fi 和蓝牙,按理说两块板子之间传数据用无线是最省事的。但 PacketLED 这个项目的价值恰恰在于“不用无线”。为什么?因为无线通信对初学者来说太“黑盒”了——你调用一个esp_now_send()函数,数据就过去了,中间发生了什么你完全不知道。而光通信不一样,你能亲眼看到 LED 在闪,能用示波器或者逻辑分析仪抓到波形,每一个比特的传输过程都是透明的。

从技术角度讲,光通信有几个天然优势:第一,不需要配对和连接建立过程,发射端开机就发,接收端开机就收;第二,不受 2.4GHz 频段拥挤的影响,在 Wi-Fi 密集的环境下反而更稳定;第三,传输距离可控,近距离通信不会干扰到旁边的设备。当然劣势也很明显:需要视距传输、环境光会影响信噪比、传输速率受限于 LED 和传感器的响应速度。

我选择这个方案还有一个很实际的原因:它逼着你去理解 ADC 采样和信号阈值判断。你用无线模块的时候,根本不需要关心什么采样率、什么信噪比,但做光通信,这些全变成了必须解决的问题。对于想深入理解嵌入式信号处理的人来说,这是一个非常好的切入点。

2.2 核心架构拆解

整个系统分成两个完全独立的固件:发射端固件和接收端固件。发射端负责把要传的数据编码成光脉冲序列,接收端负责把光脉冲序列解码回数据。

发射端的核心任务很简单:控制 GPIO 的高低电平,让 LED 按照特定的时序亮灭。但这里有个关键问题——怎么让接收端知道一个比特什么时候开始、什么时候结束?这就涉及到编码协议的设计。我采用的是类似曼彻斯特编码的思路:每个比特周期分成两半,前半段亮后半段灭代表“1”,前半段灭后半段亮代表“0”。这样做的好处是每个比特中间都有一次电平跳变,接收端可以靠这个跳变来同步时钟,不需要额外的时钟线。

接收端的核心任务是 ADC 采样和阈值判断。我用的是另一颗 LED 作为光电传感器——LED 本质上就是一个 PN 结,光照到它上面会产生光生伏特效应,在两端产生微弱的电压。这个电压非常小,大概几十到几百毫伏,需要利用 ESP32 内部 ADC 来采集。ESP32 的 ADC 是 12 位的,理论分辨率是 4096 个等级,但实际上低几位噪声很大,有效位数大概只有 9 到 10 位。

采样率的选择也很关键。曼彻斯特编码的比特率我设定在 1kHz 左右,也就是说每个比特持续 1 毫秒,半比特 500 微秒。根据奈奎斯特采样定理,采样率至少要达到信号最高频率的两倍,但实际工程中通常要 5 到 10 倍才够用。所以我需要至少 10kHz 的采样率,也就是每 100 微秒采一次。ESP32 的 ADC 在 Arduino 环境下用analogRead()大概能做到 6 到 8 微秒一次,理论上够用,但实际测试中发现analogRead()的调用开销不稳定,后来我改用定时器中断来触发采样,保证了采样间隔的均匀性。

2.3 为什么选 ESP32 而不是 Arduino Uno

有人可能会问,这种项目用 Arduino Uno 不就行了吗?便宜又简单。我实际对比过,ESP32 在这个项目里有几个不可替代的优势。

第一是 ADC 的采样速度。Arduino Uno 的analogRead()每次转换需要大约 100 微秒,采样率上限大概 10kHz,而且这个速度是固定的,没法通过配置提高。ESP32 的 ADC 虽然精度一般,但采样速度快得多,配合定时器中断可以轻松做到 20kHz 以上的均匀采样。

第二是双核处理能力。ESP32 有两个核心,我可以把一个核心专门用来做 ADC 采样和信号处理,另一个核心处理串口输出和调试信息,互不干扰。Arduino Uno 只有一个核心,采样和输出只能分时复用,容易丢数据。

第三是中断响应速度。ESP32 的外部中断延迟在微秒级别,而 Arduino Uno 的中断响应加上上下文切换大概要几微秒,在高速采样场景下差距很明显。当然,如果你手头只有 Arduino Uno,这个项目也能做,只是比特率要降到 200Hz 左右,传输速度会慢很多。

3. 硬件搭建:从原理图到面包板

3.1 元器件清单与选型理由

先列一下我实际用到的元器件:

元器件型号/规格数量备注
ESP32 开发板ESP32-WROOM-322任意 ESP32 开发板均可
LED5mm 红色2红色 LED 的光电转换效率较高
电阻220Ω1发射端 LED 限流
电阻10kΩ1接收端 LED 偏置
电阻100kΩ1可选,提高接收灵敏度
面包板半尺寸2或者用一块大面包板
杜邦线公对公若干

发射端的 LED 选红色是因为红色 LED 的正向压降最低(大概 1.8V),在 3.3V 供电下可以用较小的限流电阻获得较大的电流,亮度更高。接收端的 LED 也用红色,因为红色 LED 的光电转换效率在可见光范围内相对较高,而且和发射端的光谱匹配。

限流电阻的计算:ESP32 的 GPIO 输出高电平是 3.3V,红色 LED 正向压降约 1.8V,想要 5mA 左右的电流,电阻值 = (3.3 - 1.8) / 0.005 = 300Ω。我手头有 220Ω 的,实际电流约 6.8mA,在 ESP32 GPIO 的安全范围之内(单个引脚最大 40mA,但建议不超过 20mA)。

接收端的 10kΩ 电阻是并联在 LED 两端的,作用是把光生电流转换成电压。LED 在光照下产生的电流非常小,大概几微安到几十微安,如果没有这个电阻,ADC 输入端的电压会飘忽不定。10kΩ 是一个折中值,太小了电压变化不明显,太大了热噪声会增大。

3.2 发射端电路连接

发射端的电路极其简单:ESP32 的 GPIO 18 接到 LED 的正极(长脚),LED 的负极(短脚)接 220Ω 电阻,电阻另一端接 GND。就这么简单,没有别的元件。

这里有个细节需要注意:ESP32 的 GPIO 在启动瞬间会有短暂的高电平脉冲,这会导致 LED 闪一下。如果你不希望上电时 LED 乱闪,可以在 GPIO 和 LED 之间加一个 NPN 三极管做缓冲,或者选择一个启动时默认为低电平的 GPIO。我试过 GPIO 18,上电瞬间确实会闪一下,但不影响后续通信,所以就没管它。

3.3 接收端电路连接

接收端的电路稍微复杂一点。LED 的两个引脚分别接到 ESP32 的 GPIO 34 和 GND。GPIO 34 是 ESP32 的 ADC1 通道 6,只能作为输入使用,正好适合这个场景。然后在 GPIO 34 和 GND 之间并联一个 10kΩ 电阻。

等等,这里有个问题:LED 是有极性的,接收端应该怎么接?理论上,LED 作为光电二极管使用时,应该反向偏置才能获得最好的线性度和响应速度。但在实际测试中我发现,正向接法(正极接 ADC,负极接 GND)也能工作,而且输出电压更大,更容易被 ADC 检测到。这是因为正向接法时,LED 工作在光伏模式,光照产生的电压直接叠加在 ADC 输入端。反向接法时,LED 工作在光导模式,需要外部偏置电压,响应更快但输出信号更小。

对于 1kHz 的比特率来说,光伏模式的响应速度完全够用,所以我选择了正向接法。如果你想把比特率提高到 10kHz 以上,建议改成反向接法,并加上偏置电路。

3.4 两块板子的摆放与遮光

两块 ESP32 之间的距离和角度对通信质量影响很大。我测试下来,5 到 10 厘米的距离最稳定,LED 正对 LED,中间不要有遮挡。距离太近(小于 2 厘米)时,发射端 LED 的光太强,接收端容易饱和;距离太远(大于 20 厘米)时,接收到的光信号太弱,信噪比下降。

环境光是一个大问题。室内的日光灯和窗户透进来的自然光都会在接收端产生直流偏置,严重时甚至会把有用的信号淹没。我的解决办法是用一个纸筒把接收端 LED 罩起来,只留一个小孔对着发射端。这个土办法效果出奇地好,信噪比提升了至少一倍。如果你追求更稳定的效果,可以用黑色的热缩管把接收端 LED 包起来,只露出顶部的半球面。

注意:千万不要用透明胶带或者透明热缩管,它们对光线的散射会让接收端收到大量环境光,反而适得其反。

4. 发射端固件实现:从比特到光脉冲

4.1 编码协议设计

我设计的编码协议非常简单,但足够可靠。每个比特占用 1 毫秒,分成两个 500 微秒的半比特。如果要发送“1”,前半比特 LED 亮,后半比特 LED 灭;如果要发送“0”,前半比特 LED 灭,后半比特 LED 亮。这样每个比特中间都有一次电平跳变,接收端可以用这个跳变来校准时钟。

帧结构是这样的:先发一个 4 毫秒的起始标志(LED 亮 2 毫秒,灭 2 毫秒),然后连续发 8 个比特(1 个字节),最后发一个 2 毫秒的停止标志(LED 灭)。一帧总共 4 + 8 + 2 = 14 毫秒,理论最大传输速率约 71 字节/秒。实际测试中,由于接收端的处理开销,稳定传输速率大概在 50 字节/秒左右。

为什么选 8 个比特一帧?因为我要传的数据很简单,就是一个递增的计数器,用来验证通信是否正常。如果你要传更复杂的数据,可以扩展帧长度,但要注意接收端的缓冲区大小和同步丢失的风险。

4.2 核心代码实现

发射端的代码非常直接,核心就是一个sendBit()函数和一个sendByte()函数:

#define LED_PIN 18 #define BIT_DURATION 1000 // 微秒 #define HALF_BIT 500 void sendBit(bool bit) { if (bit) { digitalWrite(LED_PIN, HIGH); delayMicroseconds(HALF_BIT); digitalWrite(LED_PIN, LOW); delayMicroseconds(HALF_BIT); } else { digitalWrite(LED_PIN, LOW); delayMicroseconds(HALF_BIT); digitalWrite(LED_PIN, HIGH); delayMicroseconds(HALF_BIT); } } void sendByte(uint8_t data) { // 起始标志 digitalWrite(LED_PIN, HIGH); delayMicroseconds(2000); digitalWrite(LED_PIN, LOW); delayMicroseconds(2000); // 发送 8 个比特,MSB 在前 for (int i = 7; i >= 0; i--) { sendBit((data >> i) & 1); } // 停止标志 digitalWrite(LED_PIN, LOW); delayMicroseconds(2000); }

这里用delayMicroseconds()来做时序控制,实测精度大概在 ±5% 左右,对于 1kHz 的比特率来说完全够用。如果你想要更精确的时序,可以用硬件定时器或者 RMT 外设,但代码会复杂很多,没必要。

主循环里就是一个简单的计数器,每次加一然后发出去,中间加 10 毫秒的间隔让接收端有时间处理:

void loop() { static uint8_t counter = 0; sendByte(counter); counter++; delay(10); }

4.3 时序精度实测与优化

我用逻辑分析仪抓了一下 GPIO 18 的实际波形,发现delayMicroseconds(500)的实际延时在 495 到 510 微秒之间波动,抖动大概 ±3%。这个抖动对于曼彻斯特编码来说是可以接受的,因为接收端在每个比特中间都会重新同步。但如果抖动超过 ±10%,接收端就可能误判。

如果你发现通信不稳定,可以尝试以下优化:第一,关闭 Wi-Fi 和蓝牙,减少系统中断对时序的干扰;第二,把发射端的代码跑在 ESP32 的第二个核心上,避免被其他任务打断;第三,用ets_delay_us()替代delayMicroseconds(),前者是 ESP32 的底层延时函数,精度更高。

我实测下来,关闭 Wi-Fi 后时序抖动从 ±5% 降到了 ±2%,通信成功率从 85% 提升到了 98% 以上。这个改进非常值得做。

5. 接收端固件实现:ADC 采样与解码

5.1 ADC 采样策略

接收端的核心是 ADC 采样。ESP32 的 ADC 有几个坑需要注意:第一,ADC1 和 ADC2 不能同时使用,而且 ADC2 在 Wi-Fi 开启时不可用,所以我选了 ADC1 的通道 6(GPIO 34);第二,ADC 的参考电压默认是 1.1V,但实际芯片之间有差异,需要校准;第三,ADC 的读数在低电压区间非线性很严重,低于 0.1V 的读数基本不可信。

我的采样策略是用定时器中断触发 ADC 采样,采样率设为 20kHz,也就是每 50 微秒采一次。这样每个半比特(500 微秒)可以采到 10 个样本,足够做阈值判断了。

hw_timer_t *timer = NULL; volatile int adcBuffer[20]; volatile int bufferIndex = 0; void IRAM_ATTR onTimer() { if (bufferIndex < 20) { adcBuffer[bufferIndex++] = analogRead(34); } } void setup() { timer = timerBegin(0, 80, true); // 80 分频,1MHz 计数频率 timerAttachInterrupt(timer, &onTimer, true); timerAlarmWrite(timer, 50, true); // 50 微秒触发一次 timerAlarmEnable(timer); }

这里用IRAM_ATTR把中断处理函数放到 IRAM 里,避免 Flash 访问延迟导致采样抖动。timerBegin(0, 80, true)的意思是使用定时器 0,80 分频,计数频率 1MHz,也就是每微秒计数一次。timerAlarmWrite(timer, 50, true)设置报警值为 50,也就是每 50 微秒触发一次中断。

5.2 阈值判断与信号整形

ADC 采到的原始数据是一串 0 到 4095 之间的整数。有光的时候读数大概在 200 到 500 之间,没光的时候读数在 50 到 150 之间。但这个范围会随环境光变化,所以不能用固定阈值。

我用的方法是动态阈值:取最近 20 个样本的最大值和最小值,阈值设为 (max + min) / 2。这样即使环境光缓慢变化,阈值也能自动跟随。

int getThreshold() { int maxVal = 0, minVal = 4095; for (int i = 0; i < 20; i++) { if (adcBuffer[i] > maxVal) maxVal = adcBuffer[i]; if (adcBuffer[i] < minVal) minVal = adcBuffer[i]; } return (maxVal + minVal) / 2; }

有了阈值之后,就可以把 ADC 样本转换成 0 和 1 的比特流。但这里还有一个问题:怎么判断一个比特从哪里开始、到哪里结束?我的做法是检测电平跳变。当信号从低变高或者从高变低时,记录时间戳,然后根据跳变之间的间隔来判断是半比特还是整比特。

5.3 解码状态机设计

解码部分我用了一个简单的状态机,有四个状态:等待起始标志、接收比特、校验、输出。

enum State { IDLE, START_DETECTED, RECEIVING, DONE }; State state = IDLE; uint8_t receivedByte = 0; int bitCount = 0; unsigned long lastEdgeTime = 0;

当检测到从低到高的跳变时,如果距离上一个跳变的时间在 1.5 到 2.5 毫秒之间,就认为是起始标志。然后进入 RECEIVING 状态,每检测到一次跳变就记录一个比特。曼彻斯特编码的规则是:从低到高的跳变代表“0”,从高到低的跳变代表“1”。收满 8 个比特后,进入 DONE 状态,把字节输出到串口。

实际调试中发现,单纯靠跳变检测容易受到噪声干扰。我在软件里加了一个简单的滤波:如果跳变间隔小于 200 微秒,就认为是噪声,忽略掉。这个简单的滤波把误码率从 10% 降到了 1% 以下。

5.4 串口输出与调试信息

接收端解码出来的字节通过串口输出到电脑,方便观察。我用的是 115200 波特率,每收到一个字节就打印一次:

Serial.print("Received: "); Serial.println(receivedByte);

调试阶段我还会打印一些额外的信息,比如当前阈值、采样缓冲区的内容、状态机的状态等。这些信息在正式运行时可以关掉,减少串口开销。

提示:ESP32 的串口输出会占用一定的时间,如果比特率较高,建议把串口输出放到另一个核心上,或者降低输出频率,避免影响采样时序。

6. 调试过程中踩过的坑与解决方案

6.1 接收端完全没反应

这是我最开始遇到的问题。发射端 LED 明明在闪,但接收端串口什么也不输出。排查过程如下:

第一步,用万用表量接收端 LED 两端的电压。有光的时候大概 0.3V,没光的时候 0.05V。电压确实在变化,说明 LED 作为光传感器是工作的。

第二步,用analogRead()直接读 GPIO 34 的值,发现读数在 100 到 300 之间跳动,变化幅度太小,被噪声淹没了。问题找到了:信号太弱。

解决方案是加了一个 100kΩ 的电阻并联在 LED 两端,把光生电流转换成更大的电压。同时用纸筒遮光,减少环境光干扰。这两个措施加起来,信号幅度从 200 提升到了 800 以上,解码成功率大幅提高。

6.2 误码率居高不下

信号有了,但解码出来的数据经常出错。我抓了一段串口输出,发现接收到的字节有时候是 0x00,有时候是 0xFF,明显是解码逻辑有问题。

分析后发现,问题出在起始标志的检测上。我的起始标志是 2 毫秒高电平加 2 毫秒低电平,但发射端在发送完停止标志后只等了 10 毫秒就发下一帧,接收端有时候会把上一帧的停止标志和下一帧的起始标志连在一起,导致同步错乱。

解决办法是在帧之间增加间隔时间,从 10 毫秒增加到 50 毫秒。同时修改接收端的起始检测逻辑,要求连续检测到两次符合特征的跳变才确认起始标志。这样虽然降低了一点传输速率,但稳定性大幅提升。

6.3 环境光变化导致通信中断

白天调试的时候一切正常,到了晚上开灯后通信就断了。原因是日光灯的光强变化被接收端当成了信号。日光灯的闪烁频率是 100Hz,正好落在我的信号频带内。

解决方法是加一个高通滤波器,把 100Hz 以下的低频成分滤掉。我在软件里实现了一个简单的一阶高通滤波器:

float filtered = 0; float alpha = 0.95; filtered = alpha * filtered + (1 - alpha) * adcValue; int highPass = adcValue - filtered;

这个滤波器把缓慢变化的环境光去掉了,只保留快速变化的信号成分。加上之后,日光灯干扰基本消失了。

6.4 常见问题速查表

问题现象可能原因排查方法解决方案
接收端无输出LED 接反万用表测电压交换 LED 引脚
信号幅度小缺少偏置电阻示波器看波形并联 100kΩ 电阻
误码率高帧间隔太短串口打印原始数据增加帧间隔到 50ms
白天正常晚上断环境光干扰遮挡环境光测试加高通滤波或遮光罩
时序抖动大Wi-Fi 干扰关闭 Wi-Fi 对比关闭 Wi-Fi 和蓝牙
ADC 读数不稳参考电压漂移读已知电压校准用外部参考或校准函数

7. 性能优化与进阶玩法

7.1 提高传输速率

目前 1kHz 的比特率对应约 50 字节/秒的实际吞吐量。如果你想提高速率,可以从以下几个方面入手。

第一,提高比特率。把半比特时间从 500 微秒降到 100 微秒,比特率提升到 5kHz。但这对接收端的 ADC 采样率要求更高,需要至少 50kHz 的采样率。ESP32 的 ADC 在 50kHz 下还能工作,但噪声会增大,需要更好的滤波算法。

第二,改用更高效的编码。曼彻斯特编码的效率只有 50%,因为每个比特需要两次电平跳变。可以改用 4B/5B 编码或者直接使用 UART 的异步串行格式,效率能提升到 80% 以上。

第三,用多个 LED 并行传输。比如用红、绿、蓝三颗 LED 分别传输不同的数据流,接收端用三个 ADC 通道同时采样,理论上可以把速率提升三倍。这个方案我还没试过,但原理上是可行的。

7.2 增加双向通信

目前的方案是单向传输,发射端只发不收,接收端只收不发。如果要实现双向通信,每块板子都需要同时具备发射和接收能力。这需要解决一个关键问题:如何避免自己的发射信号干扰自己的接收信号。

一个简单的办法是时分复用:把时间分成奇数帧和偶数帧,奇数帧 A 发 B 收,偶数帧 B 发 A 收。这样虽然速率减半,但实现起来最简单。另一个办法是用不同波长的 LED,比如 A 用红色发、B 用红外发,接收端用滤光片区分。这个方案更复杂,但可以实现全双工。

7.3 传输更复杂的数据

目前只传了一个字节的计数器,实际应用中可能需要传传感器数据、控制指令等。扩展帧结构就可以支持更长的数据包。比如在起始标志后面加一个长度字段,接收端根据长度字段决定收多少个字节。

但要注意,帧越长,同步丢失的风险越大。如果接收端在帧中间丢失了同步,整个帧就废了。所以长帧需要更可靠的同步机制,比如在帧中间插入周期性的同步标志,或者使用前向纠错编码。

7.4 用外部 ADC 提升精度

ESP32 内置 ADC 的精度和线性度都不太理想,如果你对信号质量要求高,可以考虑外接一颗专用的 ADC 芯片,比如 ADS1115 或者 MCP3204。这些芯片通过 I2C 或 SPI 接口和 ESP32 通信,采样精度可以达到 16 位,而且线性度好得多。

不过外接 ADC 会引入额外的通信延迟,采样率可能反而下降。所以这个方案适合低速高精度的场景,不适合高速通信。

8. 我在这个项目中的几点体会

做 PacketLED 这个项目最大的收获,是重新理解了“通信”这件事的本质。我们平时用 Wi-Fi、蓝牙、串口,都是站在巨人的肩膀上,底层的东西被封装得严严实实。但当你用一颗 LED 和一根 ADC 引脚从零搭建通信链路时,每一个环节都必须自己想清楚:怎么编码、怎么同步、怎么抗干扰、怎么纠错。这种从底层往上搭的经历,对理解更复杂的通信协议非常有帮助。

另外一个体会是关于“够用就好”的工程思维。我一开始总想着把比特率做高、把距离做远、把误码率做低,结果越调越复杂,反而哪一项都没做好。后来我退回到 1kHz 的比特率、5 厘米的距离,先把基本功能跑通,再逐步优化。这种迭代式的开发方式,比一开始就追求完美要高效得多。

最后分享一个小技巧:如果你在调试过程中发现接收端的数据时好时坏,不妨用手机慢动作录像拍一下发射端 LED 的闪烁。慢动作视频可以清晰地看到每个比特的亮灭时序,比用示波器还直观。我就是在慢动作视频里发现发射端在帧间隔期间有微弱余光的,后来加了一个下拉电阻才解决。这个办法不需要任何额外设备,非常实用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询