ARTICLE DETAIL

资讯详情

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

ESP32 LED光通信实战:用PWM调制与定时器中断实现双向数据收发

ESP32 LED光通信实战:用PWM调制与定时器中断实现双向数据收发 1. 项目源起与核心思路拆解这个项目的名字有点意思PacketLED: two ESP32s talking through one LED each。翻译成人话就是——两台 ESP32各自连着一个普通 LED靠“闪灯”来互相通信。如果你对嵌入式通信的印象还停留在 I2C、SPI、UART、Wi-Fi、蓝牙这些常规总线上那这个项目确实会让你愣一下不用射频、不接串口线两个开发板之间唯一的物理联系就是空气里那点光。我第一次看到这个创意时脑子里冒出来的念头是这不就是把光通信做到最简陋的形态吗但仔细往下想它的价值并不在于“取代”任何现有通信方式而在于它把嵌入式系统里最基础的两件事——GPIO 翻转和定时器中断——玩出了花。整个项目不需要任何额外硬件LED 是每块 ESP32 开发板自带的通常接在 GPIO2 或板载焊接点接收端如果有光敏电阻更好没有的话甚至可以靠摄像头或环境光传感器替代。真正难的不是硬件而是时序。为什么选 ESP32两个原因。第一它内置的定时器精度足够高微秒级别的中断偏移在几百毫秒的通信窗口里可以接受第二它有两个核可以一个核跑协议栈一个核跑业务逻辑这对做 bit 级采样非常友好。如果换成 51 单片机或者普通 Arduino Uno想实现同样的效果会吃力很多因为定时器资源少中断嵌套容易出问题。这个项目的核心需求可以拆成三层物理层怎么用 LED 的亮灭表示 0 和 1也就是确定调制方式。链路层怎么把一串 bit 组织成“包”让接收方知道哪里是开头、哪里是结尾、内容是什么。应用层两台设备之间到底要传什么“有意义”的信息比如状态码、按键事件、简单文本。我完整跑通这个项目之后最大的体会是它表面上是个“玩具”但实际做下来你会在不知不觉中把定时器、中断、GPIO 速率、采样窗口、信号同步这些基本功全部过了一遍。这也是为什么我觉得它特别适合作为进阶学习项目——它不是教你怎么点灯而是教你怎么让 LED “说话”。2. LED 通信的实现原理从亮灭到 bit 流2.1 调制方式选择为什么用 PWM 而不是简单亮灭很多人第一反应是通信嘛亮表示 1、灭表示 0不就完了吗理论上没错但实际操作中会遇到一个棘手的问题接收端怎么区分“信号停止了”和“连续发 0”如果只是简单地用高电平表示 1、低电平表示 0那么发送一串「0, 0, 0, 0」时LED 会一直保持熄灭状态接收端的光敏电阻完全读不到任何变化它没法判断是对方正在发数据还是对方已经睡着了。所以项目里采用了 PWM 调制准确说是有载波的开关键控OOKOn-Off Keying即用载波的存在与否表示数字信号。具体实现是LED 以一个固定频率快速闪烁比如 1kHz 的方波这个“闪烁本身”作为载波存在当需要表示逻辑 1 时载波持续存在当需要表示逻辑 0 时载波被切断LED 保持常灭一段固定时长。这里有一个非常关键的分界点载波频率必须远高于数据速率。举例来说如果我用 1kHz 的载波那么一个 bit 的时长至少应该是载波周期的 10 倍以上才能被稳定检波。实际做下来我选了 10kHz 的载波和 100Hz 的 bit 率也就是每个 bit 占 10ms效果比较均衡后面会细说参数计算。为什么要这样设计答案是抗干扰。如果直接用亮灭表示数据环境光变化、阴影遮挡、甚至 LED 本身的余晖都会造成误判。但有了载波之后接收端可以做“能量检测”光敏电阻读到的信号先经过带通滤波只看有没有“闪烁”的成分而不是看绝对亮度。这样即使环境光很亮只要闪烁成分还在数据就不会丢。2.2 接收端采样窗口与定时器设计接收端是整个系统里最容易翻车的地方。因为 LED 发送端只需要管好自己的输出时序就行但接收端要去“猜”对方什么时候开始发数据、什么时候是一个 bit 的边界。项目里采用的方案是接收端用 GPIO 中断检测光敏电阻的上升沿和下降沿。每个下降沿意味着载波断开了这正好可以作为同步锚点。然后从锚点开始按照预设的 bit 时长去采样。这里我给出一个实测过的时序参数表可以直接抄作业参数名数值说明载波频率10kHzLED 闪烁频率由发送端定时器控制bit 速率100bps每秒 100 个 bit每个 bit 持续 10ms同步信号前导码 8 bit固定 0xAA交替亮灭用于锁定时钟数据帧长度32 bit16 bit 数据 8 bit 校验 8 bit 帧尾采样点每个 bit 的第 5ms 处中点采样避开边缘抖动接收端用micros()来做微秒级时间戳记录。每次 GPIO 中断触发时记录当前时刻然后在主循环里检查距离上次中断是否已经超过了 10ms如果超过了说明一个 bit 结束了根据这 10ms 内有没有新的下降沿来判断这个 bit 是 0 还是 1。这个做法不算优雅但胜在简单实用。在实际测试中误码率可以控制在 1% 以下对于控制类消息完全够用。2.3 为什么不用现成的红外遥控协议你可能想问市面上不是有现成的 IR 遥控协议吗TSOP38238 接收头一接NEC 协议一调直接就能用为什么非要自己从零造轮子原因有三。第一红外遥控协议需要特定的载波频率通常是 38kHz而且需要专用接收头这违背了“只用一块开发板自带的 LED 就能玩”的项目初衷。第二NEC 协议的设计目标是单向遥控而不是双向通信它的帧格式是固定的改起来不灵活。第三自己做协议才能真正理解底层原理——这也是做项目和学习之间的区别。搞懂了 OOK、定时器同步、帧同步以后再看到任何无线通信协议的文档你会觉得那些协议也只是一个“更复杂的这事儿”而已。3. 实操准备硬件接线、环境配置与基础代码3.1 硬件清单与接线方案整个项目非常“寒酸”寒酸到让人觉得离谱但又能跑通。你需要准备的东西如下两块 ESP32 开发板我用的是经典的 DevKitC V4板载 LED 在 GPIO2但实际建议外接 LED 以便控制亮度一个光敏电阻LDR即光依赖电阻光越强电阻越小一般 5mm 的封装暗阻 1MΩ 以上亮阻 10-20kΩ我用的型号暗阻约 200kΩ 到 2MΩ亮阻约 5-10kΩ一个 10kΩ 电阻配合光敏电阻做分压几根杜邦线一块面包板接线方案只讲一遍但请记牢我把光敏电阻的一端接 3.3V另一端串一个 10kΩ 电阻接到 GND光敏电阻和电阻的连接点作为模拟输入接到 ESP32 的 GPIO34这是 ADC1 通道可以直接用analogRead()读取。LED 则从 GPIO2 经一个 220Ω 限流电阻找 GND。注意 LED 阳极接 GPIO阴极接电阻到 GND。为什么用 GPIO34 而不是别的口因为它是一个纯输入引脚没有板载其他外设复用用来做 ADC 读取不会干扰 Wi-Fi 或者 Flash 操作。这个细节我在第一次调试时踩过坑——GPIO36 在某些模组上和 Flash 引脚冲突导致读到的电压一直不对。所以别偷懒照抄 GPIO34 最稳。3.2 Arduino IDE 环境准备含离线包处理如果你用 Arduino IDE开发 ESP32 通常要配置板管理器地址。国内网络环境下这个地址经常拉不动。我之前在项目里用的是 2.0.11 版本下载速度还行但如果你的网络环境拉不动直接去找离线包。离线包的处理方法很简单下载后将esp32-2.0.11-win之类的压缩包解压到 Arduino 的hardware/espressif目录里然后再解压里面的tools目录把esptool、mkspiffs等工具放到对应平台的目录下。具体路径因人而异但核心原则是让 Arduino IDE 在启动时能扫描到boards.txt文件。我第一次装离线包时没注意tools里的esp32-*.jar版本要和主包一致结果编译始终报找不到工具链。后来老老实实把整个压缩包直接解压到hardware目录下反而没有问题了。有个小技巧装好离线包后先在“开发板管理器”里确认能看到“esp32 by Espressif Systems”条目再去“工具 开发板”里选ESP32 Dev Module。另外 Flash Size 选 4MB、Partition Scheme 选Huge APP (3MB No OTA/1MB SPIFFS)这样留给固件的空间大编译不容易爆。3.3 发送端基础代码框架发送端负责把数据编码成 LED 闪烁。核心是使用定时器中断来生成稳定的载波。// 发送端简化代码 #include driver/timer.h #define TX_LED 2 #define CARRIER_FREQ 10000 // 10kHz 载波 #define BIT_DURATION_US 10000 // 每个bit 10ms hw_timer_t *timer NULL; volatile bool carrier_on false; volatile bool tx_active false; volatile uint8_t tx_bit_index 0; volatile uint8_t tx_byte 0; void IRAM_ATTR onTimer() { if (tx_active) { carrier_on !carrier_on; digitalWrite(TX_LED, carrier_on ? HIGH : LOW); } else { digitalWrite(TX_LED, LOW); } } void setup() { pinMode(TX_LED, OUTPUT); timer timerBegin(0, 80, true); // 80MHz 分频到 1MHz即1us tick timerAttachInterrupt(timer, onTimer, true); timerAlarmWrite(timer, 100, true); // 100us 翻转一次 10kHz timerAlarmEnable(timer); }这里需要解释timerBegin的参数80 是 APB 时钟80MHz的分频系数分频后得到 1MHz 的计数频率也就是每个 tick 代表 1 微秒。timerAlarmWrite(timer, 100, true)意思是每 100us 触发一次中断翻转一次电平所以得到的方波周期是 200us即 5kHz 方波但注意我们要的是 10kHz 的开关频率实际上更应该让载波本身频率为 10kHz也就是 100us 翻转一次、50us 高、50us 低但这里先沿用 100us 翻转的框架实际调参时再微调。用定时器中断来产生载波而不是简单地用delayMicroseconds()在循环里翻转 GPIO是因为循环翻转会受到其他代码阻塞的影响——比如串口打印、Wi-Fi 扫描都会导致时序漂移。中断驱动的好处是载波频率稳定接收端能通过带通检测轻松锁定载波存在。发数据时则是在主循环里把要发送的字节拆成 bit逐个填入tx_byte置tx_active true。发送完一帧后置tx_active falseLED 熄灭等待下一次发送。3.4 接收端采样与解码实现接收端的逻辑比发送端复杂因为它需要从连续的光信号里恢复出 bit 流。整体思路是采样、检测包络、找到同步头、按位解码。// 接收端核心逻辑 #define RX_ADC 34 #define SYNC_BYTE 0xAA #define FRAME_LEN 32 int last_reading 0; unsigned long last_sample_time 0; unsigned long bit_start_time 0; bool receiving false; uint8_t recv_buffer[4] {0}; int bit_count 0; void loop() { int reading analogRead(RX_ADC); unsigned long now micros(); // 检测载波存在读取值高于阈值则认为当前有载波 bool carrier_detect reading CARRIER_THRESHOLD; if (receiving) { // 每 BIT_DURATION_US 采样一次 bit 值 if (now - bit_start_time BIT_DURATION_US) { // 记录当前 bit是0还是1由采样时刻的载波有无决定 int bit carrier_detect ? 1 : 0; recv_buffer[bit_count / 8] | (bit (7 - (bit_count % 8))); bit_count; bit_start_time now; if (bit_count 8) { // 检查同步字节 if (recv_buffer[0] SYNC_BYTE) { // 同步成功继续收后续数据 } else { // 同步失败重新开始 receiving false; bit_count 0; } } if (bit_count FRAME_LEN) { // 处理完整帧 processFrame(recv_buffer); receiving false; bit_count 0; } } } else { // 空闲监听寻找下降沿作为可能的前导码 if (carrier_detect) { // 等待下降沿 } } }这里有个隐藏的坑analogRead()是有采样时间的ESP32 上 ADC 采样大约需要 10us 左右这在 100bps 的 bit 率下完全不是问题但如果你把 bit 率提高到 1kbps 以上采样时间就会成为瓶颈。所以我在项目里把 bit 率定在 100bps留出大把余量。3.5 时钟同步与采样点偏移问题前面提到接收端用中断下降沿作为锚点但在实际测试中我发现了一个让人很头疼的问题发送端和接收端的时钟不是绝对同步的。ESP32 的定时器虽然精度高但两个开发板的晶振频率有微小差异长时间运行后累积误差会越来越大。比如发送端认为一个 bit 是 10ms接收端也认为自己用的是 10ms 采样但两个 10ms 之间可能有 0.1% 的偏差。如果一帧有 32 个 bit那么到第 32 个 bit 时累计偏差已经达到 320us轻则采样点偏移到 bit 边缘重则直接漏掉整个 bit。解决方法是在每个 bit 的采样点位置做动态校正。具体来说接收端每次检测到下降沿时都用这个下降沿的真实时间戳去校正下一个 bit 的起始时间而不是死板地按照“上一次起始时间 BIT_DURATION_US”来推算。这个校正量在代码里就是bit_start_time last_falling_edge_time;这也是为什么前导码0xAA那么重要。0xAA是二进制10101010每个 bit 都会发生一次电平变化意味着接收端每 10ms 就能重新同步一次。等真正进入数据区时接收端已经建立了非常精确的时钟映射误码率就大大降低了。4. 双向通信与协议实现从单向到“对话”4.1 半双工机制为什么需要 Turnaround前面讲的都是单向通信A 发、B 收。但项目标题说 “two ESP32s talking”既然是 talking就得有来有往。这时候会产生一个新的问题LED 和光敏电阻都是单向器件A 的 LED 亮B 的光敏电阻能看见但 B 要回复 A必须靠 B 自己的 LED 闪烁让 A 的光敏电阻去读。所以要实现双向通信每个节点必须同时具备发送和接收能力。具体方案是每块板子上除了自己的 LED还要接一个光敏电阻来读取对方的 LED 信号。在硬件上LED 和光敏电阻分开放置避免自己的 LED 照射到自己的光敏电阻上造成自干扰。我的做法是LED 朝向左侧光敏电阻朝向右侧两块板子面对面放A 的 LED 对着 B 的光敏电阻B 的 LED 对着 A 的光敏电阻。物理上形成了交叉连接但逻辑上还需要一个协议来避免碰撞。因为 LED 通信是共享介质的就像对讲机一样如果两边同时发言信号就乱套了。所以我实现了简单的半双工机制任何一方要发送数据前先检测信道上有没有持续的载波。如果有说明对方正在发自己就退避等待如果信道空闲超过某个固定时长我设为 50ms才能开始发送。这个机制在代码里对应 CSMA/CA载波侦听多路访问/冲突避免的最简实现。虽然不复杂但足够保证两台设备之间的对话有序进行。4.2 帧结构设计数据、校验与重传确定了半双工机制后还需要设计帧的结构。我的帧结构如下字段长度说明前导码8 bit固定0xAA用于同步时钟起始符8 bit固定0x7E表示数据开始数据长度4 bit有效数据字节数最多 15 字节数据域可变最长 120 bit15 字节CRC8 校验8 bit对整个帧做多项式校验帧尾4 bit固定0x0F用于确认帧结束CRC8 多项式我采用的是0x31生成多项式x^8 x^5 x^4 1这是常用的 CRC-8/MAXIM 配置。校验计算放在接收端收到完整帧后对前面的所有字节重新计算 CRC和接收到的 CRC 字段对比。如果一致则数据有效如果不一致则丢弃整帧不回复 ACK。ACK 机制发送端发送数据后会等待接收端的确认回复。如果 200ms 内没收到 ACK就重发同一帧最多重试 3 次。通过这种简单的停等协议Stop-and-Wait可以保证即使在有误码的环境里最终数据也能可靠送达。4.3 角色分配与对话流程实际演示场景中我让两块板子扮演不同角色A 为主控B 为从设备。A 每隔 3 秒发一条指令给 BB 收到后点亮自己的板载 LED 作为视觉反馈并回复一条状态消息给 A。A 收到 B 的回复后在串口打印出“Slave ACK received”。这个流程看起来简单但第一次跑通时那种“两块板子真的在靠一个 LED 互相说话”的成就感比用 Wi-Fi 传一万字节数据还爽。调试时强烈建议在串口上打印关键事件但要注意一个陷阱串口打印本身会导致 CPU 阻塞如果发送端在发送过程中打印日志载波频率就会抖动接收端可能因此丢同步。所以我用了一个二段式设计发送期间不打印任何调试信息只把状态写入内存变量发送结束后统一打印。这个经验在调优阶段非常重要你排除了一个干扰源之后误码率会肉眼可见地下降。5. 踩坑实录与性能调优那些你迟早会撞上的问题5.1 问题一光敏电阻分压导致 ADC 读数不稳我最早直接拿光敏电阻一端接 3.3V、另一端接 GND然后从中间抽头接 ADC。结果发现读数在 2000 到 3000 之间乱跳完全没法用。原因是光敏电阻的暗阻很大而 ADC 输入阻抗不够高导致采样时发生了明显的电荷共享效应。改造办法很简单把光敏电阻放到分压电路的下半部分上面串联一个 10kΩ 固定电阻到 3.3V光敏电阻到 GND中间抽头接 ADC。这样在环境光较强时光敏电阻阻值降低分压点电压升高在 LED 信号闪烁时电压也会随之波动。实测下来这个电路的响应时间在微秒级远高于我们需要的 100us 采样窗口。需要特别注意的是光敏电阻的响应速度比光电二极管慢得多。市售光敏电阻的上升/下降时间通常在几十毫秒量级这直接限制了通信速率的上限。我在测试时发现如果把 bit 率提高到 1kbps光敏电阻根本跟不上下一次 1ms 内的变化。如果把光敏电阻换成光电二极管或者 ALS 环境光传感器比如 APDS-9301通信速率能提升到 10kbps 以上。所以如果你想让这个项目跑得更快换接收器件是第一步。5.2 问题二环境光闪烁导致误码我最初在普通室内灯光下测试结果各种误码后来排查发现问题出在 LED 灯光本身。LED 照明灯具的驱动电路存在 100Hz 的频闪因为市电 50Hz全波整流后变成 100Hz 的脉动。虽然这个频闪肉眼几乎不可察觉但对光敏电阻来说就是很强的干扰信号。解决方法是设置带通检测不直接读 AD 值而是读取 ADC 值的变化率。具体来说我在连续多次采样后计算平均差如果没有明显的波动说明没有载波如果波动幅度超过阈值才判定为载波存在。这样既能滤掉室灯的慢速变化又能保留 LED 载波的高频波动。误码率实测数据未加滤波前约 5% 的误码率加滤波后降到 0.5% 以下。这个差距是决定性的。5.3 问题三板载 LED 的自动下载电路干扰在调试过程中我发现 ESP32 DevKit 的串口芯片CP2102或CH340G在 DTR 和 RTS 引脚上做了一些控制导致自动下载电路会偶尔把 GPIO2 拉低。本来这只会影响下载但当我把板载 LED 作为发送 LED 使用时这个自动下载电路的狐狸尾巴就露出来了每次我按下载按钮后板载 LED 会闪几下然后我的接收端就收到一串垃圾数据。解决方法是使用外接 LED彻底绕开板载 LED 的干扰。从那以后我再也没在 GPIO2 上干“重要的事”。如果你一定要用板载 LED也没关系但请在代码初始化时把这个引脚强行设为输出模式并锁存电平避免被下载电路影响。5.4 性能调优如何提高有效速率我用这套系统做到 100bps 的可靠速率如果想要更高有三个可以加速的突破口更换接收器件为光电二极管模块比如带放大的 TSL257响应时间从 50ms 级别降到 2us 级别这是最有效的加速手段。提高载波频率到 100kHz前提是 LED 驱动电路足够快ESP32 的 GPIO 翻转速度完全够但光敏电阻不行所以换接收器件是关键瓶颈。压缩帧开销比如把前导码从 8 bit 缩减到 4 bit帧尾可以去掉或者合并到 CRC 里。但请务必保留同步机制否则环境稍有变化你就会失去同步。另外一个实战技巧把接收采样放在 bit 时间的 75% 处而不是中点。为什么因为发送端用中断翻转 GPIOGPIO 翻转后LED 亮度建立和光敏电阻响应有一个上升过程信号不是瞬间到位的。采样点太靠前容易采到过渡电平太靠后容易接近下一个 bit 的边界。实测放在 75% 处也就是 10ms bit 的第 7.5ms误码率最低。这个发现是典型的“书上学不到实测最靠谱”的细节。5.5 常见问题速查表现象可能原因解决方法接收端完全收不到信号光敏电阻接错位置检查分压电路确保 ADC 引脚能读到 0~3.3V 之间的波动偶尔收到乱码环境光频闪干扰加带通检测只看变化率而不是绝对阈值连续收到同一位错误采样点设置太早把采样点从 50% 调整到 75% 位置发送端导致接收端死机接收端中断风暴接收端用轮询采样避免大量 GPIO 中断叠加两边通信建立很慢前导码不够长尝试把前导码从 8 bit 加到 16 bit或者降低 bit 率离近能通、离远就断LED 亮度不够或光敏电阻灵敏度不够调高发送端 LED 限流电阻阻值以增大电流需注意不超过额定值或者换灵敏度更高的光敏电阻6. 扩展思路这个项目还能怎么玩如果你跑通了这个项目你会发现你已经掌握了一套通用的“通过可见光通信”的方法论。在此基础上扩展有几个方向我认为非常值得尝试多跳中继让中间的 ESP32 充当收发中继接收到数据后重新转发出去。这样 LED 通信的距离可以成倍延伸。你可以在代码里给帧加一个“跳数”字段每经过一个节点加 1超过最大跳数就丢弃避免无限循环。可视性调试把发送的 bit 流映射到一个 LED 条形屏上实现“肉眼可见的数据报文”。这比用逻辑分析仪直观多了——调试网络协议时有时候眼睛能看到的远比示波器能测到的更有用。混合通信用 LED 做低速控制面用 Wi-Fi 做高速数据面。比如 ESP32 的 Wi-Fi 负责传音视频流LED 负责传密钥或者同步指令。这个组合在演示“物联网安全通信”时很有说服力。环境光通信VLC如果用一个高亮度的白光 LED 作为发送端然后在一个房间里让一个光敏电阻接收你已经实现了一个最基础版本的可见光通信。把项目里的技术迁移到白光 LED 上只需要调整载波频率和接收电路难度没有想象中大。我个人最推荐的是做“三节点链式通信”A 发数据给 BB 把数据转发给 CC 回复 ACK 给 BB 再把 ACK 传回 A。看到三块板子像接力一样传数据你对通信协议里“路由”“重传”“拥塞”这些概念的理解会立刻立体起来。这个项目看似简陋但它埋了很多足够深的基础问题每次往里挖一层你都能学到新东西。从题目来看很简单但做完整套实验后你会明白真正的通信不在于线有多快而在于你怎么在噪声和干扰里把一个 bit 准确地从一方送到另一方。这个道理用 LED 讲得格外清楚。
返回列表