如果你用ESP32做过WiFi抓包,多半遇见过一类很奇怪的短帧:不是Beacon,不是Probe Request,显示成Data帧又解不出内容,在Wireshark里只能看到一截不明所以的载荷。我第一次看到它们时还以为是邻居家的私有协议,后来查了一圈才明白,这是ESP32片内WiFi基带里一条被官方API半遮半掩的“无线电通路”——ESP-NOW。说它“没写进手册”,是指《ESP32技术参考手册》通篇在讲MAC/BB/RF寄存器,对这条免连接的点对点数据通路几乎不提,而SDK里又确实留了完整接口,连帧格式都不会给你系统讲清楚。这篇文章把我从偶然抓包到自建链路、再到跑通传感器数据上报的完整过程记录下来,包括参数细节和踩坑记录,适合想深入玩ESP32无线底层的开发者、嵌入式爱好者和物联网工程师。
1. 先搞清楚:这条“隐藏”通路到底是什么
1.1 一次意外抓包,让我注意到那些短帧
事情发生得很偶然。当时我在一个房间里调试八个ESP32节点,这些节点通过TCP Socket上报状态,但总有几台设备延迟特别高。我怀疑是2.4GHz频段被附近AP挤爆了,顺手开了一下WiFi混杂模式(Promiscuous Mode),想看看空气里到底在跑什么帧。
结果除了常见的Beacon、Probe Request之外,我注意到一组长度固定、源MAC地址前缀为乐鑫OUI的短帧,它们不是普通的IP数据帧,因为载荷里看不到TCP/UDP头,也没有我熟悉的HTTP报文。更奇怪的是,这些短帧出现得非常规律,每秒一次,和我的节点心跳周期完全对得上。那一刻我反应过来:这就是我那些ESP32在互相通信,而且走的不是传统网络栈。
后来我确认,这些帧就是ESP-NOW。ESP-NOW是乐鑫在WiFi协议栈里提供的一种无连接通信协议,应用数据会被直接封装进802.11帧,从射频口发出去。它在官方API文档里有专门章节,但芯片技术参考手册确实没有系统描述其帧结构和实现机制。所以用“没写进手册的无线电通路”来形容它,一点都不过分。
1.2 这其实是一条“裸奔”的链路层通路
理解ESP-NOW的关键在于“无连接”和“链路直通”这两个词。
常规的WiFi通信,节点要先扫描、认证、关联,拿到IP地址之后还要跑TCP三次握手;每次发数据,协议栈层层封装,接收端再层层解包,中间任何一环有问题就重传。这套流程适合互联网,但对局域网里“两个设备之间传一个布尔值”这种场景实在太重了。
ESP-NOW把中间这些步骤全砍了。应用程序的数据经过极简封装直接送进WiFi驱动,由基带把它按802.11帧的格式打到空中;对端只要在同一个信道、注册过发送方的MAC地址,就能直接从基带把帧提出来。整个过程没有关联、没有IP、没有TCP握手,数据像从一条管道里直接滑过去。
我用一个生活化类比:普通WiFi通信像你打电话,要先拨号、等待接通、说“喂”确认对方在听,再开始说话。ESP-NOW则像用对讲机,按下PTT就喊,对方在同一个信道且开着机器就能听到。好处是极低的延迟和极小的开销,代价是没有完善的连接管理,链路质量、重传策略都要自己兜底。
1.3 官方为什么没把它写进芯片手册
很多人听到“隐藏通路”会觉得是什么后门,其实真不是。ESP-NOW是官方SDK公开支持的功能,只是它没有出现在技术参考手册里而已。
这背后的原因并不复杂。芯片技术参考手册的定位是描述硬件模块的行为:WiFi基带怎么配置、MAC控制器有哪些寄存器、射频前端如何校准。ESP-NOW的实现并不在这些硬件模块的固定寄存器里,它更大程度上是ROM固件和WiFi驱动软件的功劳。对芯片厂商来说,软件层面的协议栈迭代太快,手册根本跟不上。今天写进去,明天驱动改一版帧结构,手册就过时了。
另外还有一个很少被提到的原因:ESP-NOW这种帧结构在普通802.11抓包里看起来就像“奇怪的私有数据帧”,如果厂商把它当作正规无线协议来宣传,用户可能会误以为它能和普通WiFi设备互联,进而引发大量兼容性投诉。所以乐鑫的做法很务实:SDK里API文档写得清清楚楚,但在芯片手册这种偏硬件的资料里,几乎不给它名分。加上很多人在网上分享时总带着“发现新大陆”的语气,这条通路在社区里就越传越神秘了。
2. 动手之前:环境、板子和烧录基本功
2.1 开发环境怎么选:Arduino、ESP-IDF还是PlatformIO
想玩转ESP-NOW,第一步是选对开发环境。我自己三个环境都用过,简单对比一下:
| 环境 | 优点 | 缺点 | 适合人群 |
|---|---|---|---|
| Arduino IDE | 上手快,ESP-NOW封装简单,示例多 | 类型安全弱,底层控制不够细 | 新手、快速验证想法的玩家 |
| PlatformIO | 项目管理规范,跨平台构建,库管理好用 | 需要了解平台配置,离线包需要单独准备 | 中高级开发者、多人协作项目 |
| ESP-IDF | 官方维护,最接近底层,API最完整 | 学习曲线陡峭,Windows下编译速度慢 | 想要深入理解WiFi协议栈的工程师 |
Arduino IDE是目前最省事的。安装ESP32开发板支持包后,直接在库管理器里搜索ESP-NOW相关示例即可。如果你在公司内网或者网络不稳定,可以下载离线包手动安装,Arduino IDE和PlatformIO都有对应的离线支持包,这能省掉很多“下载开发板索引卡住”的烦恼。
PlatformIO其实更适合做正经项目,因为它能清楚地区分库依赖、环境配置和源码,而且命令行编译方便后续脚本化。平台装好之后,在platformio.ini里声明一下board = esp32dev,再引入Arduino框架,就能用同一套代码跑起来。
ESP-IDF是终极选项。如果你以后想改WiFi驱动甚至调试空口行为,IDF是唯一能让你碰到底层的地方。缺点是Windows环境下全量编译一套工程经常要等好几分钟,我建议把编译输出目录放到SSD上,并把CCACHE_ENABLE打开,实测下来能快不少。
2.2 板子选型和烧录姿势
实验用的板子其实只要是真ESP32芯片就行。原版ESP32 DevKit、D1 R32、ESP32-S3 Super Mini都可以跑通ESP-NOW。Super Mini这类小板子引脚少,但胜在便宜、体积小,适合做节点;原版ESP32的双核和足够的RAM让它当网关更舒服。ESP32-S3的USB口可以直接烧录,比老款省事。
烧录这件事,看起来简单,其实坑不少。大多数开发板上电的时刻,如果GPIO0保持低电平,芯片就会进入下载模式。所以常见做法是:先把IO0接地(按着BOOT键),再按一下EN复位,最后松开BOOT。很多板子设计了自动下载电路,CH340或CP2102这类USB转串口芯片会通过DTR/RTS信号控制EN和IO0的时序,IDE点烧录的瞬间就能自动进入下载模式。
我用过的板子里,CP2102的自动下载电路通常比CH340更稳定。如果你发现点烧录后总是“连接超时”,先别怀疑芯片坏了,大概率是USB转串口芯片的驱动没装好,或者自动下载电路的电容老化导致时序不对。这时候手动按BOOT再点烧录,基本都能救回来。
3. 打开这条通路的三个核心实验
3.1 实验一:两片ESP32隔空互发自定义数据
第一个实验很直接:让两片ESP32用ESP-NOW互发一条结构体数据。这里我会用Arduino环境,因为它封装得最干净。
发送端代码如下:
#include <WiFi.h> #include <esp_now.h> // 接收端的MAC地址 uint8_t peerMac[] = {0x24, 0x6F, 0x28, 0x12, 0x34, 0x56}; typedef struct sensor_msg { float temperature; float humidity; uint32_t seq; } sensor_msg; sensor_msg msg; 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); esp_now_init(); esp_now_register_send_cb(OnDataSent); esp_now_peer_info_t peer = {}; memcpy(peer.peer_addr, peerMac, 6); peer.channel = 1; // 双方必须都在信道1 peer.encrypt = false; // 先不加密,方便抓包观察 esp_now_add_peer(&peer); msg.temperature = 26.5; msg.humidity = 60.1; msg.seq = 0; } void loop() { msg.temperature += 0.1; msg.humidity += 0.05; msg.seq++; esp_now_send(peerMac, (uint8_t *)&msg, sizeof(msg)); delay(1000); }接收端只需要初始化WiFi、注册接收回调:
#include <WiFi.h> #include <esp_now.h> typedef struct sensor_msg { float temperature; float humidity; uint32_t seq; } sensor_msg; sensor_msg msg; void OnDataRecv(const uint8_t *mac_addr, const uint8_t *data, int data_len) { if (data_len == sizeof(msg)) { memcpy(&msg, data, sizeof(msg)); Serial.printf("温度: %.2f, 湿度: %.2f, seq: %u\n", msg.temperature, msg.humidity, msg.seq); } } void setup() { Serial.begin(115200); WiFi.mode(WIFI_STA); esp_now_init(); esp_now_register_recv_cb(OnDataRecv); } void loop() {}我当时的实测结果:两片板子相隔五米,没有任何障碍物,发送成功率接近100%,回调里几乎每次都是ESP_NOW_SEND_SUCCESS。整套链路延迟非常低,从发送端调用函数到接收端回调执行,体感上就是毫秒级。需要注意的是,发送间隔设置得再短,也不要小于无线链路的实际传输时间;ESP-NOW的单帧载荷上限是250字节,这个限制后面还会提到。
3.2 实验二:用混杂模式“亲眼”看到通路上跑的帧
实验一跑通之后,我更想知道空中到底长什么样。于是把其中一块板子改成混杂模式,让它把所有收到的802.11帧都打印出来:
#include <WiFi.h> #include <esp_wifi.h> void promiscuous_cb(void *buf, wifi_promiscuous_pkt_type_t type) { wifi_promiscuous_pkt_t *pkt = (wifi_promiscuous_pkt_t *)buf; wifi_ieee80211_packet_t *frame = (wifi_ieee80211_packet_t *)pkt->payload; // 只关心发往广播地址或对我们板卡MAC的帧 uint8_t *dst = frame->addr1; if (dst[0] & 0x01) { // 组播/广播帧 Serial.printf("类型=%d len=%u dst=FF:FF:FF:FF:FF:FF\n", type, pkt->rx_ctrl.sig_len); } } void setup() { Serial.begin(115200); WiFi.mode(WIFI_STA); esp_wifi_set_promiscuous(true); esp_wifi_set_promiscuous_rx_cb(promiscuous_cb); esp_wifi_set_channel(1, WIFI_SECOND_CHAN_NONE); } void loop() {}跑起来之后,串口会刷出一堆广播帧,其中就包含实验一里发送端发出的ESP-NOW帧。由于我们没有开加密,把载荷以十六进制打印出来,能看到里面就是sensor_msg结构体的原始字节,温度、湿度、序号一览无余。这个现象也提醒了我一个很重要的安全点:如果ESP-NOW不开加密,任何在同一个信道里的WiFi设备都能看到你的载荷。所以传敏感数据时,绝对不能裸奔。
3.3 实验三:把温湿度传感器数据塞进帧里
实验二让我确信ESP-NOW是一条透明的数据管道。接下来的问题很自然:能不能把真实传感器数据塞进去?
我把一个SHT30温湿度传感器接到ESP32上,在循环里读取数据,更新同一个结构体,再通过ESP-NOW发走。代码和实验一几乎一样,只是在loop里加了真实的传感器读取:
float temp = sht30.readTemperature(); float rh = sht30.readHumidity(); msg.temperature = temp; msg.humidity = rh; msg.seq++; esp_now_send(peerMac, (uint8_t *)&msg, sizeof(msg));有一个很重要的细节:结构体在发送和接收两端的内存布局必须完全一致。如果发送端用float数组,接收端以为收到的是int数组,解析出来就是乱七八糟的数字。建议在结构体里使用固定大小的整数或浮点数,并且不要依赖于编译器自动对齐。如果结构体里有字符串,最好约定好最大长度,在接收端做长度校验之后再使用。我在代码里判断了data_len == sizeof(msg),这个判断虽然简单,但能挡住大部分格式不匹配的意外帧。
4. 参数、原理与取舍
4.1 信道为什么必须一致:2.4GHz频段的规矩
ESP-NOW工作在2.4GHz频段,这个频段被划分成多个信道。实验一的代码里,我在配对时指定了peer.channel = 1,这行代码很容易被忽略,但它特别重要。
ESP-NOW本身没有独立的频点选择逻辑,它复用WiFi射频的当前信道。如果发送端和接收端的WiFi信道不一致,帧发出去之后接收端根本不会去解调那个信道上的信号,看起来就像数据凭空消失在空气里。更麻烦的是,如果接收端以STA模式连接着一个路由器,一旦路由器发生信道跳变,接收端也会跟着跳,而发送端还傻傻地停在原来的信道,通信就断了。
所以做ESP-NOW实验时,我通常会让设备以WIFI_STA模式运行,但不要去连接任何AP,然后显式调用esp_wifi_set_channel(1, WIFI_SECOND_CHAN_NONE)来锁死信道。有些项目里用WIFI_AP_STA模式,要特别注意AP本身占了哪个信道,别让AP自动选的信道打乱了ESP-NOW的收发。
4.2 能跑多快:吞吐量实测与场景判断
ESP-NOW速度怎么样?这个问题的答案取决于你使用哪种发送模式。
我实测过两种方式。第一种是普通模式,也就是发送之后不管结果,只管把结构体交给驱动,吞吐量能到几百kbps到1Mbps左右。第二种是带ACK的模式,发送回调会返回成功或失败,底层会做重传,但这种模式会明显降低有效吞吐,更适用于小数据量的可靠传输。
做个粗略估算:单帧最多250字节,加上帧头开销,如果每毫秒发一帧,理论速率就超过2Mbps。但空口环境不是实验室,2.4GHz频段干扰很重,实际稳定速率能到1Mbps已经算不错了。这个吞吐量决定了ESP-NOW不适合传视频这类大流量数据,但非常适合传传感器状态、遥控指令、设备间握手信号。传固件升包也可以,就是得耐心拆包,后面我会讲到OTA思路。
4.3 帧结构里有什么:加密、MAC地址和载荷
虽然官方没有公开一份完整的ESP-NOW帧格式文档,但通过抓包和经验总结,可以大致看出它的组成:802.11头部(帧控制、时长、目的MAC、源MAC、BSSID、序列号)、帧体里的数据载荷,以及帧尾的FCS校验。
我抓包时最直接的感受是:只要关闭加密,载荷就是赤裸裸的明文。Wireshark里看到的是Data类型的802.11帧,但发给谁、数据是什么,一眼就能看出来。这也解释了为什么ESP-NOW可以用在局域网内的高效通信场景,却不太适合直接在公网或者陌生环境中传播。
如果你担心数据被旁边的人截获,配对时可以开启加密模式。在esp_now_peer_info_t里把encrypt设为true,并配置16字节的psk密钥,双方密钥相同才能正常解密。开启加密后,抓包工具看到的就是一串密文,基本没办法直接还原结构体。需要提醒的是,密钥管理是应用层的事,如果你把密钥硬编码在固件里,别人读出来一样能解密。
5. 进阶玩法与避坑手册
5.1 从点对点升级成Mesh和OTA
实验做完后,我把它用在了更大的项目里。最值得尝试的两个方向是Mesh和OTA。
ESP-NOW本身是点对点的,但借助MAC地址寻址的特点,可以做多跳接力。每一个节点除了发送自己的传感器数据,还转发别的节点发来的数据。配置一个简单的路由表,让数据朝网关方向逐跳传递,就能构建一个低功耗的无线Mesh网络。这里最大的设计难点是防止数据风暴,因为广播转发很容易产生环路。我的做法是给每个数据包加一个源节点ID和序号,收到重复序号就丢弃,实测下来能有效控制广播风暴。
OTA方面,固件通常几百KB,需要拆包发送。接收端收到分片后先写入临时分区,等全部收齐再校验CRC并切换启动分区。用ESP-IDF里完善的OTA接口配合ESP-NOW,可以做一个完全不需要TCP/IP的无线升级链路。这个方案的好处是网关和节点可以处于无AP的环境中,直接用私有通路升级,非常适合野外部署的节点。
5.2 低功耗、共存和其他坑
低功耗场景下最常遇到的坑是:设备从Deep Sleep醒来后,RAM内容全部丢失,ESP-NOW的配对信息、回调函数全部没了。所以每次唤醒都必须重新执行esp_now_init()和esp_now_add_peer()。如果你保存了关键状态,记得放在RTC内存里,否则醒来后连重发序号都会丢失。
和蓝牙共存是另一个问题。ESP32的WiFi和蓝牙共享同一个射频前端,同时开BLE广播和ESP-NOW收发时,因为射频切换调度,偶尔会有丢包。这是我调试时最头疼的部分,症状非常随机:有时连续发100包全成功,有时突然丢两包,然后又完全恢复。排查下来,不是代码逻辑问题,而是两个协议在抢射频资源。对策是降低发送频率、错开BLE广播的调度,或者干脆在关键控制链路里只用ESP-NOW、关闭BLE。
还有一个容易忽略的坑:在混杂模式的回调函数里,不能做耗时操作,比如调用Serial.print刷屏、写Flash、动态分配大内存,否则会导致WiFi驱动栈溢出甚至系统崩溃。回调函数应该只做字节搬运,把数据拷到缓冲区里,交给主循环去处理。
5.3 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 两端配对成功,但收不到数据 | 信道不一致 | 两边都显式设置同一个信道 |
| 偶尔丢包,看起来毫无规律 | 射频共存或2.4GHz干扰 | 降低发包频率,错开BLE调度,换信道 |
| 开启混杂模式后系统卡死 | 回调里做了耗时操作 | 回调里只拷贝,不打印不写Flash |
| Deep Sleep唤醒后收不到数据 | RAM丢失,配对信息没了 | RTC保存参数,重新初始化ESP-NOW |
| 载荷解析出来是乱码 | 结构体布局不一致 | 接收端加长度校验,避免依赖编译器对齐 |
| 烧录总失败 | 自动下载电路/驱动问题 | 手动进入下载模式,检查驱动,换USB线 |
| 发送回调一直返回失败 | 目标不在信道,或MAC错误 | 核对MAC地址,确认目标处于同一信道 |
5.4 迁移到ESP32-C6/S3的注意事项
新出的ESP32-S3、ESP32-C6同样支持ESP-NOW,但有一些细节值得注意。ESP32-S3的USB烧录功能很香,很多板子可以直接通过USB口拖拽或串口烧录,不用额外买USB转串口模块。ESP32-C6则更激进,它同时支持2.4GHz WiFi6、BLE、Zigbee和Thread,射频硬件能力更强。
C6上的Zigbee和Thread通信也会占用2.4GHz频段,和ESP-NOW放在同一块射频前端上时,共存调度变得更复杂。我建议在正式项目里把无线协议按优先级划分好,让ESP-NOW负责关键控制,Zigbee负责周期性的低速率采集,二者通过应用层的调度错开时间片。
无论用哪款芯片,都别忽略无线电发射合规问题。ESP-NOW发射功率跟普通WiFi相当,虽然实验环境里短距离低功率问题不大,但如果你要做产品或者长期部署,一定要查一下当地对2.4GHz设备发射功率的限制,尽量把发射功率配置到满足应用需求的最小值,既省电,又少惹麻烦。
最后再分享一个小技巧。排查ESP-NOW链路是否真的在工作时,与其反复看串口日志,不如准备第三块板子专门开混杂模式,过滤源MAC地址,这样能在一堆Beacon里精准看到目标设备的每一帧。我喜欢在拆包调试时顺便观察时间戳:如果相邻两帧间隔稳定,说明链路健康;如果间隔突然拉长,多半是射频被占或者系统在忙别的事。这个观察法帮我解决过好几次诡异丢包问题,比闷头改代码效率高得多。