
前阵子做一个小项目需要让两块ESP32在不接路由器的情况下互相传数据。一开始想着用WiFiMQTT结果发现没有AP根本玩不转后来又试BLE配对流程和连接管理把人折腾得够呛。直到朋友提醒了一句你试试ESPNOW我才发现这个被很多人忽略的协议有多好用。这篇文章就聊一聊在MicroPython环境下用ESP32做ESPNOW双向通信的完整思路和踩坑记录从环境准备、固件要求到双向通信的代码实现再到实测过程中遇到的各类问题一次性说清楚。如果你刚好也在做无线控制、传感器组网或者单纯想摆脱路由器的束缚这篇应该能帮你少走不少弯路。1. ESP-NOW到底是什么为什么我放弃了WiFi和MQTT方案1.1 协议本质无连接、无ACK、低延迟ESP-NOW是乐鑫官方推出的一个无连接通信协议。注意无连接这三个字它和传统WiFi通信最大的区别在于两个设备之间不需要经过三次握手建立连接也不需要有一个AP作为中转节点。数据包发出去之后对端设备在物理层能收到就直接收收不到数据也不会有重传机制之类的负担。它基于IEEE 802.11的物理层所以本质上还是WiFi那套射频只是把上层的协议栈精简到了极致。我用一个类比来帮助理解传统的WiFiMQTT通信就像打电话必须先拨号、对方接听、建立会话然后才能说话而ESP-NOW更像对讲机按下按键直接说话对面在同一个频道上就能听到不需要任何握手。这个特性决定了它的延迟极低实测在空旷环境下数据包从发出到对端收到普遍在几毫秒级别。也正是因为无连接设备之间不需要维护会话状态任何一方随时上电随时能通信省事很多。1.2 和WiFi、BLE、MQTT的对比与选型边界先列一个对比表会更清楚方案是否需要路由器握手开销延迟典型功耗开发复杂度WiFi TCP/UDP需要高高高中MQTT需要高高高中BLE不需要中中低较高ESP-NOW不需要无极低低低我的使用场景是室内几十米范围、两个节点互传状态和控制指令数据量很小一次就几十字节。MQTT在这类场景下完全是杀鸡用牛刀——你得先配一个路由器或者树莓派跑broker设备上电之后还要等待连接、订阅主题链路一旦断了重连逻辑又是一堆代码。BLE的问题在于从设备peripheral的连接参数、MTU协商这些概念对于只想把数据从A送到B的需求来说太重了。当然我不是说ESP-NOW能替代所有方案。它单次数据包最大只能承载250字节左右不适合传大文件或者音频流也没有默认的加密机制通信距离受限于板载天线一般空旷环境几十米隔墙效果会明显下降。如果你要传大量数据、覆盖全屋的复杂网络、或者需要互联网远程访问那还是老老实实用TCP/IPMQTT那套。但如果是两个设备之间轻量、低延迟、无路由器依赖的数据交换ESP-NOW就是当前最省心的选择。2. 环境准备固件版本、MAC地址和测试拓扑2.1 MicroPython固件版本1.20是底线很多人拿到ESP32第一件事就是刷最新版MicroPython固件但这恰恰是第一个坑。ESP-NOW的MicroPython支持在比较晚的版本才引入具体来说你需要使用1.20或更高版本的固件才能稳定使用espnow模块。我最早用1.19的固件折腾了半天结果发现连import espnow都会报错白白浪费了一个晚上。所以第一步就是去MicroPython官网的Download页面选择ESP32端口下载1.20版本的固件。这里提醒一下官网的Release版本通常是最稳妥的选择那些带preview字样的开发版固件虽然可能有新特性但稳定性无法保证我建议直接用正式发布版。到写这篇文章时1.23.x已经比较普及价格也不贵一步到位刷最新稳定版就行。固件下载之后就是烧录。Windows下可以用乐鑫官方的Flash Download ToolsmacOS/Linux下直接用esptool命令esptool.py --port /dev/cu.usbserial-XXX erase_flash esptool.py --port /dev/cu.usbserial-XXX write_flash -z 0x1000 ESP32_GENERIC-20240105-v1.22.2.bin烧录完成之后我用Thonny作为日常开发IDE。选中MicroPython(ESP32)解释器它会自动识别串口连接之后就能在REPL里交互式测试了。Thonny的文件管理功能还可以直接把脚本保存到开发板上对这类嵌入式脚本小项目足够方便。2.2 获取MAC地址通信的唯一凭证ESP-NOW没有类似TCP的地址解析协议两台设备要想通信必须预先知道对方的MAC地址。MAC地址就是ESP32的物理地址可以看成是这台设备在网络世界里唯一的身份证。获取MAC地址的代码在MicroPython里非常简单import network wlan network.WLAN(network.STA_IF) wlan.active(True) mac wlan.config(mac) print(mac) print(:.join(%02x % b for b in mac))这里有个关键点你必须在wlan.active(True)之后才能拿到MAC地址而且这个WiFi接口必须一直保持激活状态后面ESP-NOW的数据收发都依赖于它。我第一次没激活就直接调用wlan.config(mac)结果拿到一个空值还以为板子坏了。注意wlan.config(mac)返回的是一个bytes对象打印出来是类似b\x34\x85\x18\xab\xcd\xef这样的格式。后面添加对端peer的时候需要的正是这个bytes对象本身而不是字符串。很多报peer MAC address format error的都是在把bytes转成字符串又试图转回bytes的时候出的错。2.3 测试拓扑两块板子怎么调试双向通信至少需要两块ESP32开发板。我用的是两块常见的ESP32 DevKitC板载天线。建议准备两个USB线因为烧录和调试需要同时连接两台设备。最好再准备一个移动电源或者第二组USB口方便把其中一台移到几米外去测试实际通信距离。在正式开始写通信代码之前先把两台设备的MAC地址都打印出来记在纸上。后面每台设备都需要把对方的MAC填进代码里。这个步骤虽然简单但一定别跳过否则后面写peer的时候只能临时去查看似省事实际更费时间。3. 双向通信的架构设计先想清楚这三件事再动手3.1 角色边界双向不等于对等ESP-NOW在API层面其实没有发送端和接收端的严格区分任何一个设备既可以发也可以收关键是你怎么组织代码逻辑。但双向通信并不意味着两台设备必须执行完全相同的逻辑你需要先想清楚每个节点的职责。常见的双向通信场景有三种主从模式主机发送指令从机执行并回传状态比如遥控开关。对等模式两台设备地位相同各自周期性发送自己的数据比如两个传感器节点互相交换读数。请求-响应模式A发请求B收到后立刻响应比如查询远程设备的当前运行参数。我的项目属于第三种A端每秒发送一条心跳消息B端收到之后立刻回一条包含当前状态的响应。这样两边既有自己主动发起的数据也有应对方请求产生的数据是最典型的双向通信模型。3.2 消息格式JSON还是二进制很多初学者的ESP-NOW示例代码都是直接发送字符串比如e.send(peer, bhello)这在玩具Demo里没问题但实际项目中你会立刻遇到两个痛点第一对端收到数据后怎么知道这条消息是什么意思第二一次要传多个字段的时候怎么拼接。我的建议是至少定义一种简单的协议格式哪怕刚开始只有一两个字段。最简单的方式是直接发JSON字符串import json payload json.dumps({type: heartbeat, val: 25}).encode() e.send(peer, payload)对端收到后用json.loads(data)解析。JSON的优点是可读性好、扩展容易缺点是体积偏大250字节的限制下字段一多就会捉襟见肘。如果数据是固定结构的数值我建议用ustruct模块打包成二进制体积小、解析速度快非常适合嵌入式场景import ustruct payload ustruct.pack(ifi, 1, 25.5, 100) # type float int e.send(peer, payload)3.3 确认与重传应用层自己造ACK前面提到了ESP-NOW是无连接、无ACK设计发送者把数据丢到射频层就不管了。这在大部分场景下没问题但如果你的数据丢不得比如控制指令就必须在应用层自己做确认机制。最简单的方案是请求-回执A向B发送指令后B收到就回一条确认消息A在一定超时时间内没收到确认就重新发送。这里的超时时间要根据你的实际通信周期来定我实测在室内环境下50~100ms的超时窗口已经足够保守但为了不给射频通道造成拥塞我一般把重发间隔设在这个区间最多重发3次。这套机制虽然原始但胜在可靠。4. 完整代码实现同一份代码跑通双向收发4.1 初始化WiFi接口和ESP-NOW的先后顺序无论哪一方代码启动时都必须完成两件事激活WiFi接口并初始化ESP-NOW。这是整个通信的基础。注意ESP-NOW不要求WiFi连接任何热点只要接口处于active状态即可。import network import espnow import time # 初始化WiFi STA接口 wlan network.WLAN(network.STA_IF) wlan.active(True) # 获取本机MAC print(本机MAC:, :.join(%02x % b for b in wlan.config(mac))) # 初始化ESP-NOW e espnow.ESPNow() e.active(True)有个细节你可能已经注意到了这段代码里WiFi和ESP-NOW是分成两步初始化的。如果先调用espnow.ESPNow()再激活WiFi部分固件版本上会出现ESP-NOW内部状态异常的情况所以我的习惯是固定顺序先wlan.active(True)再espnow.active(True)实测两种顺序对通信成功率影响不小。4.2 先跑通一发一收建议不要一上来就写双向先把单向链路跑通确认物理层没问题之后再扩展。A端发送代码# device_a_send.py import network import espnow import time wlan network.WLAN(network.STA_IF) wlan.active(True) e espnow.ESPNow() e.active(True) # 把device_b的MAC地址填到这里 PEER_B b\x34\x85\x18\x12\x34\x56 e.add_peer(PEER_B) count 0 while True: msg ping-%d % count e.send(PEER_B, msg.encode()) print(send:, msg) count 1 time.sleep(1)B端接收代码# device_b_recv.py import network import espnow wlan network.WLAN(network.STA_IF) wlan.active(True) e espnow.ESPNow() e.active(True) # 接收模式不需要添加peer只要espnow激活即可 while True: mac, data e.recv() if data: print(来自 %s: %s % (:.join(%02x % b for b in mac), data.decode()))注意接收端我甚至不需要add_peer因为ESP-NOW默认可以接收来自任何设备的数据前提是对方主动添加了你作为peer。这个不对称性很多人会困惑但实际上协议就是这么设计的发送方必须明确指定接收方接收方则处于来者不拒的状态。4.3 双向通信irq回调里的关键细节这是本文的核心。双向通信其实就是在4.2的基础上把A端的发送逻辑和B端的接收逻辑合并到同一份代码里两边既添加对方为peer又注册接收处理。下面这份代码我在A、B两块板上烧录了一模一样的版本唯一的区别是PEER_MAC字段各自填对方的地址。# espnow_duplex.py 两块ESP32都运行这份代码PEER_MAC填对方的MAC import network import espnow import time import json # 配置区 PEER_MAC b\x34\x85\x18\x12\x34\x56 # 改成对端MAC NODE_NAME A # 改成自己节点名 SEND_INTERVAL 1 # 主动发送间隔秒 # wlan network.WLAN(network.STA_IF) wlan.active(True) print(本机MAC:, :.join(%02x % b for b in wlan.config(mac))) e espnow.ESPNow() e.active(True) e.add_peer(PEER_MAC) counter 0 def on_receive(e): irq回调注意要用循环把缓冲区里所有消息都取出来 while True: mac, data e.recv() if data is None: break try: msg json.loads(data.decode()) except Exception: print(收到未知格式:, data) continue print([%s] 收到来自%s的消息: %s % (NODE_NAME, :.join(%02x % b for b in mac), msg)) # 收到消息后立刻回一条响应这就构成了双向 resp json.dumps({from: NODE_NAME, echo: msg.get(seq), val: counter}).encode() e.send(PEER_MAC, resp) e.irq(on_receive) while True: payload json.dumps({from: NODE_NAME, seq: counter, t: time.ticks_ms()}).encode() e.send(PEER_MAC, payload) print([%s] 主动发送: %s % (NODE_NAME, payload)) counter 1 time.sleep(SEND_INTERVAL)这段代码的逻辑不复杂我拆开讲一下设计思路。首先是e.irq(on_receive)。MicroPython的espnow模块支持中断回调当有数据到达时会调用你注册的函数。但这里有一个很重要的细节回调函数里必须用while True循环把接收缓冲区里的所有数据都取完data is None表示缓冲区空了否则下一批数据到达时如果缓冲区积压就会出现数据延迟甚至丢失。我一开始就是没加循环只在回调里取了一条数据导致两条消息里必丢一条排查了很久才发现是这个问题。其次是主动发送和被动响应共存的协调问题。这里有一个常见的疑问在同一个代码里主循环里的e.send()和回调里的e.send()会不会冲突实测下来不会。ESP-NOW的驱动内部对收发做了队列管理发送是异步的你只管调用驱动会自己排队。唯一要注意的是不要在一次回调里连续发太多条否则可能把发送队列塞满反而影响下一次发送。我现在的做法是收到消息最多回一条。最后是json的使用。前面说了消息格式的问题这里用JSON是因为字段不多、且希望调试时能肉眼读懂。如果对性能有更高要求完全可以换成ustruct二进制打包收发的逻辑不变。4.4 recv轮询模式什么时候不用回调除了irq你也可以不用回调改成在主循环里轮询e.recv()这在我项目里用来处理一些不适合放回调的复杂逻辑。轮询的写法更直白while True: # 先处理发送 if time.ticks_diff(time.ticks_ms(), last_send) SEND_INTERVAL: e.send(PEER_MAC, payload) last_send time.ticks_ms() # 再处理接收 mac, data e.recv(0) # 非阻塞接收 if data: handle_message(data)recv()的可选参数是超时毫秒数。recv(0)表示非阻塞立即返回没有数据时data是None。如果传入时间比如recv(100)就会阻塞等待100ms。轮询方式的好处是代码流程完全可控适合处理那些需要先收后发或者按顺序处理的业务逻辑缺点是主循环里如果有耗时操作比如读取传感器可能错过数据到达的时机。所以我的原则是简单回应类型用irq有复杂业务逻辑用轮询。5. 实测踩坑官方文档不会写的五个问题5.1 两边信道不一致发送等于白发ESP-NOW要求WiFi接口active但不需要连接AP。我调试时犯了一个经典错误在一个Demo里先尝试连接了家里的路由器然后又跑ESP-NOW发送结果发送总是超时。后来才发现当ESP32连接到AP之后它会锁定在当前WiFi信道而另一块板子没有连接任何AP默认停留在信道1两边信道不一致数据自然发不过去。解决方案是要么两边都保持不连接任何AP这样默认信道一致能通要么两边都连接同一个AP锁定同一信道要么手动在代码里指定信道。我在对等模式下就选择了最简单的都不连AP实测完全没问题。如果你家里只有一个路由器环境可以让两块板子都连上同一个热点ESP-NOW同样能正常工作。5.2 send()返回True了对端却什么都没收到这是我最郁闷的一次排查。e.send()返回True说明驱动已经把数据交给了底层发送队列但不代表对端真的收到了。对端没收到通常有三个原因一是对端的WiFi接口没激活二是信道不一致见5.1三是对端根本没有进入接收循环或回调没注册。这里特别说下第三点。MicroPython的espnow.recv()在某些固件版本里如果对端从来没有调用过recv()数据包到达后没有消费者驱动可能直接把包丢掉。所以哪怕你的板子只是接收方也必须确保主循环里有定时或持续调用recv()或者注册了irq回调。否则就会出现发送方显示成功、接收方静默的诡异现象。5.3 单包200字节和50ms间隔的软限制ESP-NOW单包最大250字节这个限制是否包含协议头、是否受加密影响在不同固件版本上实践结论略有差异。为了安全我自己定的规矩是单条消息不超过200字节给可能存在的协议头留出余量。另外发送间隔如果短于50ms实测会出现明显的丢包率上升这是因为驱动层的发送队列和射频通道的占用相互竞争。如果你需要高频率发送建议在应用层做小包合并把多条数据塞进一个包。5.4 供电和线材影响射频距离的隐形杀手项目调试到后面我发现发送距离缩短得很离谱两米都传不过去。排查到最后问题出在一块用面包板和杜邦线搭建的电路上。杜邦线松动导致WiFi天线处的电源纹波很大射频前端受干扰。换了一根短的粗导线重新供电距离立刻恢复正常。所以在做射频相关调试时供电稳定性和线材质量是第一优先级别一上来就怀疑代码有问题。5.5 终端打印乱码与异常处理这个坑和ESP-NOW本身无关但几乎所有用板子做调试的人都会遇到。两端用print打印接收到的字节串如果里面混了多字节字符在Thonny的终端里很容易显示乱码。排查办法是在收发代码里统一指定编码比如data.decode(utf-8)并把异常用try/except包住。我的建议是调试阶段消息内容全部用ASCII字符比如ping-12这种跑通之后再考虑中文内容。6. 从双机到多机ESP-NOW的扩展玩法6.1 广播模式和peer数量上限ESP-NOW天然支持一对多。发送方可以把多个peer都添加进来然后逐个e.send()也可以使用广播地址b\xff\xff\xff\xff\xff\xff一次性发给同信道的所有设备。广播模式下接收方不需要添加发送方为peer就能收到很适合所有节点同时收到同一指令的场景比如全屋灯光的全局开关。实测下来一个ESP32最多维护20个peer。如果节点数超过这个上限就要考虑组网拓扑让部分节点作为中继转发消息形成多跳网络。不过多跳转发会明显增加延迟和丢包概率我做实验的时候发现两跳以内还能接受超过两跳的可靠性就没什么保障了。6.2 传感器采集与执行器控制的闭环ESP-NOW最适合的项目形态是采集-传输-控制的小型闭环。我目前做的应用里一个节点连接温湿度传感器每5秒向另一个节点发送读数后者是一块带继电器和不间断电源的设备收到数据后解析、存储如果温度超过阈值就执行继电器动作并把执行结果回传给传感器节点形成一个完整的反馈回路。整个过程中路由器只存在于开发阶段实际运行时两块板子完全是点对点直连。这个结构再往下扩展就是小型无线传感网络。如果你愿意花点时间把每个节点的发送间隔错开比如A节点第0秒发、B节点第2秒发、C节点第4秒发就能在单个信道上让多个节点复用带宽而且几乎没有冲突实现在一个接收端上汇聚多路传感器数据。6.3 低功耗唤醒思路ESP-NOW的低延迟和无连接特性让它在电池供电的场景里很有潜力。我测试过最简单的省电策略发送节点只在需要上报时才唤醒WiFi接口发送完立刻调用wlan.active(False)然后进入machine.deepsleep()定时器唤醒后再重复。接收节点则用espnow的唤醒绑定功能配合GPIO外部唤醒。这块我还在继续优化目前能做到的是传感器节点5分钟一次上报三节AA电池粗估能用大半年等数据跑满再单独写一篇。最后说一点个人体会。ESP-NOW在MicroPython里的API本来就不复杂真正让很多项目卡壳的从来不是代码而是对协议机制的误解——无连接、无ACK、信道一致、WiFi必须active这几件事弄明白了双向通信的代码其实就是那几十行。如果你也正在做类似的双机通信项目建议按我先单向、后双向、再加确认重传的顺序去推进每跑通一步都打印日志留底后面出问题定位会快很多。希望这篇踩坑记录能帮你省下几个晚上的调试时间。