1. ESP-NOW到底是什么
1.1 从一个通信痛点说起
先聊聊我为什么会对ESP-NOW这么上心。
做了几年的嵌入式项目,最烦的事情之一就是设备之间的数据通信。常见的做法是用WiFi,走TCP/IP协议栈,把采集到的传感器数据从A设备发给B设备,或者让多个设备互相汇报状态。但说实话,在很多场景里,用完整的WiFi通信栈就像开着卡车去菜市场买菜——能到达目的地,但太重了,延迟高、功耗大、代码复杂,有时候还会被复杂的配网流程折腾得头疼。
后来接触ESP32之后,开始注意到它自带一个叫做ESP-NOW的协议。官方文档对它的定位很清晰:一种基于802.11数据链路层的无连接通信协议,不需要TCP/IP握手、不需要路由器转发,两个(或多个)ESP32设备之间可以直接交换数据。一开始我理解得比较粗浅,觉得也就是个“轻量通信方案”,但真正用它做完两三个项目之后,才发现这东西在特定场景下比想象中强得多。
这篇文章我就把ESP-NOW从原理到实际应用整体拆一遍,重点讲清楚它能做什么、不能做什么,以及怎么用最少的代码把它跑起来。想让两个ESP32互相通信、做个传感器数据采集网络、遥控小车或者智能家居控制开关的,都可以直接参考这里面的思路。
1.2 它和WiFi、蓝牙的本质区别
要搞清楚ESP-NOW的工作方式,首先得知道它在协议栈中处于什么位置。
平时用的WiFi,走的是完整的网络栈流程。设备要先扫描、认证、关联到路由器,然后通过TCP/UDP封装数据,经过IP寻址,再到应用层处理。整个过程有几个明显的拖累:配网需要时间,连接状态需要维护,传输过程中还有大量协议头开销。好处是通用、灵活、兼容性好,适合接入互联网或者跟手机互动。
蓝牙的问题也类似,本身要建立链路,还要考虑配对、服务发现这些机制,虽然BLE在功耗上很有优势,但它的实时性和吞吐量并不适合作为高频数据传输的主要通道。
ESP-NOW呢?它的思路是砍掉中间层。两个芯片之间协商好之后,直接把数据封装成帧,从物理层发出去。整个过程没有路由器参与,不需要SSID、密码、IP地址这一套东西,双方只需要知道对方的MAC地址就行。那么带来的直接优势是:
- 连接建立极快,发送端和接收端一上电就能通信,没有复杂握手。
- 协议的头部开销很小,最大有效负载250字节,对于传感器数据、控制指令这种小包完全够用。
- 低延迟。用ESP-NOW发一个包的速度通常以毫秒甚至微秒级计算,适合实时控制。
- 功耗可控。ESP32本身的功耗就比手机等设备低很多,加上ESP-NOW不需要维持路由连接,在低占空比的应用中非常省电。
我常用一个比喻来解释:WiFi通信就像是两个人非要通过总机转接才能通话,而ESP-NOW是两个人在同一房间里直接喊一嗓子,对方听到了就听到了,回不回应都行,纯粹靠约定好的暗号(数据结构)来理解彼此。
1.3 ESP-NOW适合什么场景
根据我的使用经验,有几种场景是ESP-NOW的“舒适区”:
第一类是多点传感器采集。比如你在家里放了几个温度湿度传感器节点,每个节点定时把数据发给一个主控设备汇总。这类数据包通常就几十个字节,频率也不高,完全没必要在每对节点之间建立完整的TCP连接,ESP-NOW的广播模式很合适,一发一收,代码量小,稳定性也不错。
第二类是遥控类项目,比如遥控小车、机器人、机械臂。这类场景最看重延迟,你按下按键那一刻,到电机动作,中间隔的时间越短体验越好。ESP-NOW的实测延迟可以做到非常低,体感上几乎感觉不到延迟。
第三类是简单的设备联动。比如门口的光线传感器检测到天黑,直接发指令给灯光控制器开灯。设备少、动作简单、不需要互联网参与,用ESP-NOW就是最简单的方案,比配置MQTT服务器、路由器转发省事太多了。
当然,它也有不适合的场景。如果需要跨互联网传输、需要设备接入云平台、需要高吞吐量比如传音频视频,那还是老老实实用WiFi TCP/IP或者蓝牙方案。250字节的payload限制,注定了它不是为大数据设计的。
2. 用Arduino IDE搭建ESP32开发环境
2.1 为什么我选Arduino,而不是ESP-IDF
很多入门ESP32的朋友会纠结一个问题:到底用乐鑫官方的ESP-IDF,还是用Arduino框架?
从我个人的经验看,如果目的是快速验证方案、做原型、或者日常做一些小项目,Arduino无疑效率更高。ESP-IDF功能更底层、更全面,但配置工程、编写CMake文件、管理组件这些工作,确实会把很多精力消耗在非核心逻辑上。Arduino的好处是社区生态成熟,库多,拿来就用,代码风格相对简洁,尤其是在用ESP-NOW这种通信协议时,Arduino的封装层已经做得足够完善,一个esp_now_init()就能完成初始化,不需要深究内部细节。
当然,如果你是做量产产品,对功耗、内存、底层控制有极高要求,那确实应该考虑ESP-IDF。我的建议很简单:先拿Arduino跑通功能,再决定要不要迁移,这能让你最快看到效果,也方便调整产品思路。
2.2 安装ESP32开发板支持包
Arduino IDE下载安装很简单,官网下载对应版本,一路下一步就行。安装完之后,需要在“首选项”里的“附加开发板管理器网址”中添加ESP32的板卡支持地址:
https://espressif.github.io/arduino-esp32/package_esp32_index.json添加好之后,打开“开发板管理器”,搜索“ESP32”,找到“esp32 by Espressif Systems”,点击安装。这个安装包比较大,几百MB,耐心等一下就好。国内网络有时候下载比较慢,可以尝试配置代理工具,或者等网络状况好的时候再安装。
装完开发板支持包之后,在“工具 -> 开发板”里就能看到ESP32系列的各种型号。我常用的是ESP32 Dev Module,兼容市面上大多数ESP32开发板。
建议在把板子插上电脑之后,先选好对应的COM口,随便写一个简单的点灯程序,编译上传一次,确认环境没问题再继续。这个过程能排除一大半的“怎么我的板子没反应”问题。
2.3 确认ESP-NOW库就绪
Arduino环境下使用ESP-NOW不需要额外安装第三方库。ESP32的Arduino内核中已经集成了esp_now.h头文件,直接从代码里包含进去就能用:
#include <esp_now.h> #include <WiFi.h>真正需要注意的一点是:使用ESP-NOW前必须先把WiFi模式设置成WIFI_STA或WIFI_AP_STA。因为ESP-NOW是复用无线射频硬件的,WiFi协议栈处于未初始化状态时,ESP-NOW也没法工作。
这里有一个容易踩坑的细节:WiFi.mode(WIFI_STA)之后,无线基站的通信通道需要双方保持一致。默认情况下,ESP32会使用一个默认的通道,但如果你在代码里没有明确设置,接收端和发送端可能会因为通道不一致而收不到数据。后面我会详细说这个问题。
3. 第一个实战:点对点数据传输
3.1 读取自己的MAC地址
ESP-NOW发送数据的时候,接收方是靠MAC地址来识别的。所以第一步往往都是获取设备的MAC地址。ESP32的MAC地址是6字节,出厂时已经固化在芯片中,我们可以用一个小程序把它读出来。
打开Arduino IDE,新建一个工程,写入如下代码:
#include <WiFi.h> void setup() { Serial.begin(115200); WiFi.mode(WIFI_STA); Serial.print("ESP32 Board MAC Address: "); Serial.println(WiFi.macAddress()); } void loop() { }编译上传并打开串口监视器,波特率选115200,就能看到一行形如24:6F:28:XX:XX:XX的地址,把它记下来,这个地址就是设备在无线网络中的“身份证号”。
如果是多台设备,建议每台都烧录一次这个程序,顺便在串口输出前面加个设备编号,比如“Sender MAC:”,省得搞混。我习惯把接收端的MAC地址直接用记号笔写在模块标签上,方便以后查找。
3.2 发送端实现
假设我们有一台设备A要发送数据,设备B是接收方。发送端的代码逻辑是:初始化ESP-NOW,注册发送回调,然后周期性地发数据。
下面是一个最简单的发送端示例,它每秒发送一个递增的计数值,数据结构里同时包含一个设备ID字段和一个数值字段:
#include <esp_now.h> #include <WiFi.h> // 接收端的MAC地址,根据实际情况修改 uint8_t remoteMac[] = {0x24, 0x6F, 0x28, 0x12, 0x34, 0x56}; // 自定义数据结构 typedef struct struct_message { uint8_t deviceId; uint32_t counter; } struct_message; struct_message myData; // 发送回调 void OnDataSent(const uint8_t *mac_addr, esp_now_send_status_t status) { Serial.print("发送状态: "); Serial.println(status == ESP_NOW_SEND_SUCCESS ? "成功" : "失败"); } void setup() { Serial.begin(115200); WiFi.mode(WIFI_STA); if (esp_now_init() != ESP_OK) { Serial.println("ESP-NOW初始化失败"); return; } esp_now_register_send_cb(OnDataSent); // 添加对端设备 esp_now_peer_info_t peerInfo = {}; memcpy(peerInfo.peer_addr, remoteMac, 6); peerInfo.channel = 0; peerInfo.encrypt = false; if (esp_now_add_peer(&peerInfo) != ESP_OK) { Serial.println("添加对端失败"); return; } myData.deviceId = 1; myData.counter = 0; } void loop() { myData.counter++; esp_now_send(remoteMac, (uint8_t *)&myData, sizeof(myData)); delay(1000); }这里有几个地方要解释一下:
esp_now_peer_info_t peerInfo = {};是用来描述对端设备的参数结构体,包括MAC地址、通信通道和是否加密。peerInfo.channel = 0表示使用当前WiFi的默认通道,如果你想固定某个通道,可以显式写1~13。esp_now_send函数第一个参数是接收端MAC地址,如果要使用广播发送,可以用ESPNOW_BROADCAST_ADDR宏,这个后面会讲到。sizeof(myData)是发送的数据字节数,这里正好是两个字段(1字节 + 4字节),结构体可能还会自动对齐,后面我会专门说对齐的问题。
3.3 接收端实现
接收端的逻辑更加简单,主要工作是初始化后注册一个接收回调函数。数据到达时,这个回调会被自动触发。
#include <esp_now.h> #include <WiFi.h> typedef struct struct_message { uint8_t deviceId; uint32_t counter; } struct_message; struct_message incomingData; void OnDataRecv(const uint8_t *mac_addr, const uint8_t *data, int data_len) { memcpy(&incomingData, data, sizeof(incomingData)); Serial.print("收到来自设备 "); Serial.print(incomingData.deviceId); Serial.print(" 的数据,计数器: "); Serial.println(incomingData.counter); } void setup() { Serial.begin(115200); WiFi.mode(WIFI_STA); if (esp_now_init() != ESP_OK) { Serial.println("ESP-NOW初始化失败"); return; } esp_now_register_recv_cb(OnDataRecv); } void loop() { // 接收是在回调中处理的,这里可以做其他事情 }注意,在接收端我们不需要添加对端设备的MAC地址,因为它是一个被动收包方。也就是说,任意一个ESP-NOW设备只要匹配了这个通道和速率,就能把数据传给它,不要求事前构建连接关系。这跟WiFi客户端必须加入同一个路由器网络是本质不同的。
烧录好接收端,再烧录发送端,打开两个串口监视器,你应该能看到接收端每秒打印一条计数值递增的消息。整个过程不需要路由器、不需要密码,两台设备上电即通。
3.4 数据的验证技巧
刚接触ESP-NOW时,最容易出现“代码上传了但收不到数据”的情况,这时候千万不要盲目改代码,建议按顺序排查:
- 确认两块板子都满足“先设置WiFi模式再初始化ESP-NOW”这一顺序。
- 确认发送端添加的接收端的MAC地址跟实际板子MAC一致(大小写和冒号分隔符无影响)。
- 接上串口监视器,观察发送端是否报“发送失败”。
- 如果只发送不接收,检查接收端的波特率是否跟发送端一致,串口监视器有没有打开。
- 确认两块板子之间的距离不要太远,室内隔几堵墙也可能导致丢包。
软件逻辑没问题的情况下,ESP-NOW调通只需要几分钟。
4. 收发数据背后的关键细节
4.1 通道对齐的坑
前面提到,ESP-NOW的通信频率依赖于WiFi通道。ESP32在STA模式下默认通道是1,但是如果你先前设置过WiFi连接某个路由器,或者调过WiFi.channel(),那么通道值可能会变化。
当发送端和接收端的通道不一样时,数据帧在物理层直接就没发到同一个频点上,接收方自然什么都听不到。我在做多设备项目时曾遇到过一个问题:接收端既连着一个路由器,又使用ESP-NOW接收数据,结果路由器重新连接后通道发生跳变,接收端就再也收不到来自独立节点发来的数据了。
这里有一个解决办法:在初始化ESP-NOW之前强制设置WiFi通道。比如:
WiFi.mode(WIFI_STA); WiFi.channel(1);然后在esp_now_add_peer时,把peerInfo.channel也设置为1。
需要特别注意的是,WiFi.channel(1)不一定总能成功,因为如果此时WiFi有连接任务,它可能会自动跳回路由器使用的通道。于是更可靠的做法是:接收端如果既做STA又做AP,就在AP模式下使用同样的通道。比如ESP32做软AP时为设备提供一个配置页面,同时用AP接口收发ESP-NOW。这样通道是可控的,不会因为外部路由器的变化而干扰。
4.2 结构体对齐与数据长度
很多人习惯用一个结构体来定义ESP-NOW的传输内容,但一定要清楚数据传输不是“类型安全”的,它只负责把你给它的那串字节原样发过去,接收端拿到字节后再自己解释。因此发送端和接收端的结构体定义必须保持一致,这非常重要。
与此同时,C/C++的结构体在最坏情况下会有对齐填充问题。举个例子:
typedef struct { uint8_t a; // 1字节 uint32_t b; // 4字节 } msg;这个结构体理论上只占5字节,但编译器为了对齐,可能会在后面填充3个字节,导致sizeof(msg)为8。这在同一平台、同一开发框架下发送端和接收端的sizeof是一致的,所以通常不会有问题。但如果你的数据是要跨平台解析,或者之后要和串口、SD卡等其他系统对接,就要小心。可以使用#pragma pack(push, 1)或者__attribute__((packed))来取消填充,保证传输内容紧凑可控。
另外,ESP-NOW单包最大负载是250字节。如果结构体超过这个大小,esp_now_send会返回ESP_ERR_ESPNOW_NO_MEM或者直接发送失败。遇到这种情况,就得把数据拆分到多个包里,在应用层自己定义分包序号和重组逻辑,属于进阶工作量了。
4.3 回调机制到底怎么用
接收端代码里用到的esp_now_register_recv_cb是一个中断回调函数。它的实质是:当无线底层收到一个完整的数据帧后,把它解析好,放进一个缓冲里,然后调用你注册的函数。因此这个回调的运行时机并不确定,程序的主循环可能正在干别的事情。
这带来两个问题:
第一,回调函数里尽量不要做耗时操作,比如Serial.print本身虽然不算特别慢,但如果在高速收包场景下,大量打印会让CPU占用率飙升,还可能丢包。更好的做法是只把数据存到全局变量,或者设一个“数据到达”标志位,然后在主循环里处理。
第二,接收回调中拷贝数据时要注意长度。回调给的data_len是实际收到的字节数,但如果你不确定发包端传了多少字节,可以用这个长度来限制拷贝,防止越界读内存。像上面例子中我写的memcpy(&incomingData, data, sizeof(incomingData)),如果发送的实际字节数小于sizeof(incomingData),内存里剩余部分会是旧值,这个要自己判断。
4.4 数据长度与丢包率之间的关系
我在实际测试中发现,ESP-NOW的丢包率和单个数据包的长度有一定的关系。负载越短,碰撞概率越低,丢包率相对越小;负载接近250字节时,高频率发送的情况下,丢包率会明显上升,尤其是在多个节点同时广播的场景中。
所以我的经验是:如果数据内容本身不多,尽量精简结构体,不要图方便填充一堆冗余字段。比如测量温度,就放一个float或者放大10倍的uint16_t,别放字符串加各种元信息。这不仅是节省带宽的问题,也是提升通信可靠性的手段。
5. 常见问题与排查技巧
5.1 发送状态一直失败
如果你通过OnDataSent回调发现发送失败,大概率是以下原因:
- MAC地址错误。检查字节顺序和冒号分隔是否正确,尤其注意不要少写一个字节。
- 对端没有初始化ESP-NOW。接收端如果还没执行
esp_now_init(),发送端自然发不出去。 - 通道不匹配。就像前面说的,通道不一致,底层就投递不成功。
- 对端设备已经下线或者断电。ESP-NOW本身没有重传保证,只能靠应用层做超时重试。
我自己调试时,习惯先发送广播包试试,因为广播不需要添加对端设备,只要通道对就能收到。方式是把发送地址改为ESPNOW_BROADCAST_ADDR,同时不需要调用esp_now_add_peer。
5.2 接收端频繁丢包
丢包问题比较隐蔽,通常表现为“偶尔收到一次,中间隔了几秒才再次收到”。可能的原因有:
- 发送频率过高。比如每10ms发一次,接收端如果正在处理其他任务,回调可能来不及处理积压的数据。ESP-NOW的缓冲区是有限的,缓冲区满了新包就丢了。
- 附近其他WiFi设备在相同信道产生干扰。这在公寓环境中很常见,很多路由器都自动选择了1、6、11这些信道,如果你的模块正好也在这个信道,会跟大量流量竞争。
- 天线位置和距离问题。ESP32内置天线是全向的,但放在金属外壳里或者紧贴人体、桌面金属,信号会大幅衰减。
减少丢包的手段:调低发送频率、切换一个相对干净的信道(比如3、8、13)、优化天线环境、接收端loop()里不要做阻塞任务。
5.3 多个ESP32之间互相干扰
ESP-NOW支持一个发送方对多个接收方,也支持多个发送方对一个接收方。但要注意,多个发送方同时发送时,无线碰撞不可避免。虽然没有完整的CSMA/CA机制那么复杂,但ESP-NOW底层的MAC层也具备一定的退避策略,所以偶尔碰撞丢包是正常的,不需要太震惊。
应对方法是引入分时抢占:每个发送节点错开发送时间。比如节点1每500ms发一次,节点2每700ms发一次,节点3每1100ms发一次,尽量让它们的发送时刻不重叠。工程上可以用简单的随机延时来错峰,不必做到精确同步。
5.4 如何判断ESP-NOW是否正常工作
判断通信是否正常,最直观的方法是看串口输出。但我建议做一个链路质量统计:
接收端可以维护一个连续接收次数的计数器,并记录上一次收到数据的时间戳。如果超过设定时间没有新的数据包,就认为链路断了,输出警告信息。同理发送端可以统计发送成功和失败的次数,算出成功率。
这些统计信息不仅帮你验证功能,也为后续上线监控提供了参考。
6. 更强的玩法:广播、加密与多节点组网
6.1 用广播解决“一对多”和“多对多”的需求
有些项目不需要明确指定接收端,比如一个控制主机要给所有从机发指令。这时候可以不走“添加对端”的标准流程,而是使用广播地址FF:FF:FF:FF:FF:FF,这样ESP-NOW会把包发到当前通道内的所有设备上。
广播模式下,发送端不需要调用esp_now_add_peer,直接发就行:
esp_now_send(ESPNOW_BROADCAST_ADDR, (uint8_t *)&myData, sizeof(myData));接收端还是走原来的接收回调,正常情况下就能收到广播帧。因此广播很适合做“开灯、关灯”这种点火指令,一个按钮控制整个屋子的所有灯。
但要注意,广播没有接收确认机制,发送端无法知道自己成功被谁接收了。如果业务上必须确认每个设备都执行了,就需要接收方在收到广播后单播回复一个ACK。这需要给每个从机分配一个唯一的设备ID,并在自定义结构体中带上这个ID,否则主机无法区分是谁回复的。
6.2 加密通信:防止数据被劫持
出厂状态下,ESP-NOW默认不加密,数据在空口是明文传输。如果项目运行在公共环境,且有安全顾虑,可以开启加密模式。
ESP-NOW使用WPA2级别的加密机制。开启方法是在发送端的esp_now_peer_info_t结构体中设置encrypt = true,并把一个16字节的PMK密钥写入esp_now_set_pmk(),然后为每个对端设置一组Local Master Key(LMK)。
需要注意,加密模式下每个对端的LMK需要预先双方约定,并且接收端也要做同样的设置。这实际操作起来并不复杂,但会增加很多管理成本。对于绝大多数学习项目、原型验证或者家庭自动化场景,不加密完全够用。但如果要做商业产品、传输控制命令且涉及人身或财产安全,建议开启加密,或者至少在应用层做数据签名和校验。
6.3 ESP-NOW和WiFi共存的问题
经常有人问:ESP32的ESP-NOW和WiFi能不能同时工作?答案是可以,但有条件。
从硬件看,ESP32只有一个射频子系统,WiFi和ESP-NOW共用同一套天线和基带,所以本质上是分时复用的。也就是说,你不能一边用ESP-NOW满速率发送数据,一边又用WiFi跑大流量下载,两者会互相抢占。但如果你只是偶尔用WiFi发送HTTP请求,或者设备作为AP被手机访问,大多数场景下问题不大,切换的调度由底层固件自动完成。
我踩过的坑是:ESP32同时作为WiFi STA连接路由器,又作为ESP-NOW接收节点时,如果路由器信号不稳定导致WiFi重连,ESP-NOW的通道可能随之漂移,出现短暂收不到数据的情况。解决办法是让WiFi ST固定连接一个信道稳定的路由器,或者干脆让ESP32使用AP模式而不是STA模式。
6.4 多跳网络与复杂组网
严格来说,ESP-NOW本身不支持路由和多跳转发。它只是一个2层的直连通信协议,两个节点之间物理距离太远就没法互通。
不过我们可以借助每个节点同时作为接收方和转发方的能力,做一个简单的多跳中继。A节点把数据发给B节点,B节点收到后不仅会执行自身逻辑,还会将同样的数据帧转发给C节点。这样B就变成了中继器。在分布式传感网络中,这种方案简单有效。
但我要提醒一句,多跳转发会引入明显的延迟和丢包累积,而且由于ESP-NOW没有路由表机制,如果真的需要复杂的自组网,更好的选择是使用ESP-MESH(比如Espressif的ESP-MESH协议栈)或者干脆用LoRa。ESP-NOW的多跳更适合“手动指定路径、节点数量少、拓扑固定”的项目。
7. 一个完整的项目示例:多节点温湿度采集
7.1 项目需求与硬件清单
我拿之前做的一个项目来完整演示一遍:用多个ESP32节点采集不同位置的温湿度数据,通过ESP-NOW发给一个主控节点,主控节点汇总后在OLED屏上显示,同时通过串口把数据转发给上位机。
硬件清单大概如下:
| 设备 | 数量 | 说明 |
|---|---|---|
| ESP32 Dev Module | 3个 | 两个采集节点,一个主控节点 |
| DHT22温湿度传感器 | 2个 | 也可以使用DHT11,价格更便宜 |
| OLED SSD1306 128x64 | 1个 | 主控显示用 |
| 面包板、杜邦线若干 | - | 连接电路 |
7.2 采集节点端代码
每个采集节点要做的事情是:读取DHT22数据,发给主控。不同的采集节点通过deviceId区分。
#include <esp_now.h> #include <WiFi.h> #include <DHT.h> #define DHTPIN 4 #define DHTTYPE DHT22 DHT dht(DHTPIN, DHTTYPE); // 主控的MAC地址 uint8_t masterMac[] = {0x24, 0x6F, 0x28, 0x11, 0x22, 0x33}; typedef struct { uint8_t deviceId; float temperature; float humidity; } sensor_data_t; sensor_data_t data; void OnDataSent(const uint8_t *mac_addr, esp_now_send_status_t status) { // 调试时可以打开打印,正式运行时建议注释掉 } void setup() { Serial.begin(115200); dht.begin(); WiFi.mode(WIFI_STA); WiFi.channel(1); esp_now_init(); esp_now_register_send_cb(OnDataSent); esp_now_peer_info_t peerInfo = {}; memcpy(peerInfo.peer_addr, masterMac, 6); peerInfo.channel = 1; peerInfo.encrypt = false; esp_now_add_peer(&peerInfo); data.deviceId = 2; // 第二个节点修改成自己的编号 } void loop() { float h = dht.readHumidity(); float t = dht.readTemperature(); if (!isnan(h) && !isnan(t)) { data.temperature = t; data.humidity = h; esp_now_send(masterMac, (uint8_t *)&data, sizeof(data)); } delay(2000); }7.3 主控节点端代码
主控节点负责接收所有采集节点发来的数据,并显示在OLED上。为了方便,我用了一个简单的数组来保存每个节点的最新数据。
#include <esp_now.h> #include <WiFi.h> #include <U8g2lib.h> #include <Wire.h> typedef struct { uint8_t deviceId; float temperature; float humidity; } sensor_data_t; sensor_data_t latestData[4]; bool dataUpdated[4] = {false}; U8G2_SSD1306_128X64_NONAME_F_SW_I2C u8g2(U8G2_R0, /* clock=*/ 15, /* data=*/ 4, /* reset=*/ U8X8_PIN_NONE); void updateDisplay() { u8g2.clearBuffer(); u8g2.setFont(u8g2_font_ncenB08_tr); u8g2.drawStr(0, 12, "Temp/Humi Monitor"); char line[32]; for (int i = 0; i < 4; i++) { if (dataUpdated[i]) { snprintf(line, sizeof(line), "Node%d: %.1fC %.1f%%", latestData[i].deviceId, latestData[i].temperature, latestData[i].humidity); u8g2.drawStr(0, 28 + i * 12, line); } } u8g2.sendBuffer(); } void OnDataRecv(const uint8_t *mac_addr, const uint8_t *data, int data_len) { sensor_data_t rd; memcpy(&rd, data, sizeof(rd)); if (rd.deviceId < 4) { latestData[rd.deviceId] = rd; dataUpdated[rd.deviceId] = true; Serial.print("Device "); Serial.print(rd.deviceId); Serial.print(" T= "); Serial.print(rd.temperature); Serial.print(" H= "); Serial.println(rd.humidity); } } void setup() { Serial.begin(115200); WiFi.mode(WIFI_STA); WiFi.channel(1); esp_now_init(); esp_now_register_recv_cb(OnDataRecv); u8g2.begin(); } void loop() { updateDisplay(); delay(500); }这个项目跑起来后,每个DHT22节点每2秒上报一次数据,OLED实时显示,整体代码量不到100行。数据收发的稳定性在室内环境非常可靠,偶尔会有个别包丢失,但下个周期就会补上,对这类监控场景完全无感。
我后来把采集节点做成了电池供电,在低功耗模式下,每10分钟唤醒一次发送数据,然后继续睡眠。实测用两节18650电池供电,跑了一个多月还没换过电池,这得益于ESP-NOW不需要长时间维持连接,醒来即发、发完即睡的特性。
8. 基于实际测试的性能参考
我把自己在不同条件下的测试数据整理了一下,供大家参考。需要说明的是,这个数据受具体开发板、天线环境、电源质量、周围电磁干扰的影响,不同环境下会有差异,但数量级是有参考意义的。
| 测试场景 | 发送频率 | 包大小 | 实测丢包率 | 备注 |
|---|---|---|---|---|
| 开发者工位,2米 | 10包/秒 | 20字节 | 0% ~ 0.2% | 环境较干净 |
| 室内隔一堵墙,10米 | 10包/秒 | 20字节 | 0.5% ~ 2% | 普通砖墙 |
| 室内多路由信道干扰,10米 | 10包/秒 | 200字节 | 5% ~ 10% | 公寓环境 |
| 空旷地,50米 | 1包/秒 | 20字节 | 0% ~ 1% | 天线竖直朝上 |
| 空旷地,100米 | 1包/秒 | 20字节 | 5% ~ 20% | 增益受限 |
数据本身不算惊艳,但是配合低频率上报(如每5秒一次),丢包的影响几乎可以忽略。如果项目对实时性要求很高,应该优先优化发送频率和包大小;如果对覆盖率要求很高,就要考虑增加节点数量或者调整天线方案。
另外,别忘了电源的稳定性。ESP32的射频发射瞬间需要较大的电流,如果供电不足,发送时的电压跌落会导致系统复位或者发送失败。平时用USB供电问题不大,但用电池或稳压模块时务必保证足够的输出电流,建议至少500mA以上。
9. 聊聊我在项目中的几点体会
代码跑通是一回事,真正在项目中稳定运行又是另一回事。这里想多说几句踩坑后的反思。
第一点是“能用”和“好用”的差距。ESP-NOW上手非常快,两三个函数就能通,但这恰恰是迷惑性的地方。设计数据包时一定要提前考虑扩展性:要不要加设备类型字段、要不要加序列号、时间戳、是否需要校验和。我一开始图省事,数据包里只有数值,后来设备种类增加,不得不升级协议,结果前后端代码都大改,真是悔不当初。
第二点是日志的重要性。ESP-NOW本身没有统一的上层调试工具,出了问题只能靠串口日志。建议从第一天就养成记录MAC地址、发送时间、发送结果的习惯。别等到需要追查问题时,才发现日志里只有数据、没有任何可供定位的元信息。
第三点是升级固件和部署的流程。用ESP-NOW的项目,设备大多分布在物理空间的不同角落。每次改完功能,要去现场给每台设备重新烧录程序,非常麻烦。所以尽量在设计初期预留支持OTA的能力,哪怕先不实现远程升级,也要在硬件上留出方便烧录的接口(比如一键下载电路、外露的TX/RX引脚等),省得后期痛苦。
最后再分享一个实用小技巧:代码里可以定义一个宏来切换“调试模式”和“运行模式”。调试模式下打开所有串口打印,同时把发送间隔调小,方便观察;运行模式下关闭打印、调大并发间隔,降低功耗和干扰。这样同一个固件既能在工位上快速验证,也能直接部署到现场,不用反复修改代码。
10. ESP-NOW项目还能怎么延伸
如果你已经照着上面的例子把点对点通信跑通了,下一步想玩点更有意思的,可以考虑这些方向。
一个是做简化版的多点遥控系统。比如用一块ESP32加摇杆模块做遥控器,另一块ESP32接电机驱动板做小车,通过ESP-NOW传输摇杆的X、Y值。延迟体感极低,响应非常跟手,比用蓝牙串口模块体验好不少。
另一个是搭建一个屋内环境自动感知系统。多个ESP32采集光照、温度、湿度、PM2.5等数据,用ESP-NOW汇聚到一个中控屏,中控屏可以通过继电器模块控制空调、加湿器、灯光。这套系统完全不需要互联网,不依赖云平台,隐私性好,断网也不受影响。
还可以把ESP-NOW作为桥梁,把离线数据转发到一个带WiFi的节点,然后由这个节点通过MQTT上报到服务器。这种方式兼具低功耗本地通信和广域互联网接入的能力,是典型的“本地组网 + 边缘网关”架构雏形,在工业数据采集场景也有不少应用。
核心思路就一句话:ESP-NOW解决的是“短距离、低延迟、低数据量、多节点”的通信诉求,凡是符合这个画像的项目,都可以优先考虑用它作为设备间直连骨干。