ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ESP32光通信实战:用LED和ADC实现无线数据传输

ESP32光通信实战:用LED和ADC实现无线数据传输 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 的通道 6GPIO 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)的意思是使用定时器 080 分频计数频率 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 的闪烁。慢动作视频可以清晰地看到每个比特的亮灭时序比用示波器还直观。我就是在慢动作视频里发现发射端在帧间隔期间有微弱余光的后来加了一个下拉电阻才解决。这个办法不需要任何额外设备非常实用。
返回列表