ESP32 ESP-NOW实战:无路由器设备直连通信与组网指南
发布时间:2026/10/7 1:41:43 锦皓数字建站

最近在搞一个多节点环境监测的小项目三块 ESP32 分布在房间不同角落想做个简易的无线传感器网络。一开始按习惯直接走 MQTT 路由器但现场根本没有现成的无线网络环境额外拉一台路由器又觉得太笨重。后来试了试 ESP-NOW发现这套方案在特定场景下真的非常省事——不需要配 IP、不需要握手建链、不需要路由器转发两块板子上电就能互相发数据延迟还低。这篇文章就基于我实际踩坑和调通的经历把 ESP-NOW 的原理、环境搭建、点对点通信、组网设计以及常见的坑一次说清楚给正在用 Arduino IDE 玩 ESP32 的朋友一份能直接照着抄的参考。适合谁看如果你手头有两块以上 ESP32想在不依赖 WiFi 路由器的情况下做设备间通信或者正在做遥控小车、无线传感器节点、多板协同控制这类项目那这篇内容应该能帮你少走不少弯路。就算你刚接触 ESP32只要跟着步骤把环境配好也能在十几分钟内跑通第一组 ESP-NOW 通信。1. 先搞明白 ESP-NOW 是什么以及为什么选它1.1 ESP-NOW 的核心机制ESP-NOW 是乐鑫针对 ESP8266 / ESP32 系列芯片设计的一种轻量级无线通信协议。你可以把它理解成一种“无连接”的数据报服务发送方只要知道接收方的 MAC 地址就能直接把数据包发过去不需要像常规 WiFi 那样先扫描、关联、通过路由器协商加密和分配 IP 地址。这种设计带来的直接好处有三个第一是快连接建立几乎零耗时数据从应用层到发出一般在毫秒级第二是省资源不需要维护复杂的 TCP/IP 协议栈状态内存占用和 CPU 开销都很低第三是稳在没有路由器信号的环境下ESP-NOW 依靠设备之间的直连也能工作特别适合临时组网和移动场景。从协议栈角度看ESP-NOW 工作在数据链路层之上底层仍然使用 2.4GHz WiFi 的物理层所以它的通信距离和普通 WiFi 大致相当空旷环境下几十米没问题室内隔一两堵墙也能跑具体取决于天线和发射功率。每包数据最大可以承载 250 字节这个大小正好容纳十几到几十个传感器数据非常合适。1.2 和 WiFi、蓝牙、MQTT 相比怎么选很多刚开始接触 ESP32 的朋友会有个疑问既然 ESP32 支持 WiFi 和蓝牙为什么还要专门学 ESP-NOW我的建议是不要把它当成 WiFi 的替代品而是当成一个“短距离设备直连”的补充方案。如果做的是需要上云、需要远程访问、需要手机随时连接的项目那常规 WiFi MQTT 依然是首选毕竟 ESP-NOW 没有 IP 层无法直接对接互联网服务。但如果只是几块开发板之间互传数据比如遥控器控制小车、多个传感器节点往主控回传数据、两块板子做双向对话ESP-NOW 的体验要比 WiFi 和蓝牙都舒服很多。我自己整理过一个选型对照表基本能代表我的实际感受通信方式是否需要路由器连接耗时典型延迟单包大小适合场景ESP-NOW不需要几乎为零毫秒级250 字节板间直连、传感器组网、遥控常规 WiFi TCP/UDP需要数秒级几十毫秒接近 MTU上云、局域网大量数据传输MQTT需要数秒级几十毫秒以上取决于 Broker物联网云端采集蓝牙 BLE不需要秒级十几到几十毫秒20 字节左右手机交互、低功耗外设除了连接速度还有一点非常重要ESP-NOW 在发送端和接收端都不需要复杂的握手协议。WiFi 模式下如果路由器重启所有设备要重新关联应用层可能还要处理重连逻辑而 ESP-NOW 直接把数据丢给对方发完就完事逻辑简单太多了。2. 准备工作与开发环境搭建2.1 硬件选型建议玩 ESP-NOW 最省心的硬件就是 ESP32 开发板。市面上常见的 NodeMCU-32S、ESP32 DevKitC、ESP32-S3、ESP32-C3 等都可以跑 ESP-NOW。有一点要注意ESP32-S2 和部分 ESP32-C 系列虽然也能用 ESP-NOW但官方支持情况会略有差异有些版本需要单独更新底层库。如果不确定直接买经典的 ESP32 DevKitC 或者 NodeMCU-32S资料最多踩坑最少。另外建议至少准备两块开发板因为 ESP-NOW 的本质是设备间通信单块板子只能做自我测试没法完整验证收发链路。如果只是先了解概念也可以在一块板子上写一个“自发自收”的测试程序让模块同时注册发送和接收回调把数据发给自己但这只能验证协议栈是否正常不能代表真实通信环境。天线和电源这种细节容易被忽略。ESP32 板载天线附近尽量不要布置金属物体和密集排线否则信号衰减会很厉害。供电方面ESP-NOW 发送瞬间电流比较大如果用劣质 USB 线或者从电脑前置 USB 口供电可能出现电压跌落导致重启或丢包建议使用质量好一点的 USB 线或者外接 5V 电源。2.2 Arduino IDE 安装 ESP32 支持我平时主要用 Arduino IDE 开发 ESP32虽然 PlatformIO 功能更强但从零起步还是 Arduino IDE 最快。打开 Arduino IDE在“文件 - 首选项 - 附加开发板管理器网址”里添加 Espressif 官方地址https://espressif.github.io/arduino-esp32/package_esp32_index.json然后打开“工具 - 开发板 - 开发板管理器”搜索 esp32找到 Espressif Systems 官方发布的 esp32 包点安装。这步会下载比较多的文件耗时取决于网络情况耐心等它装完。安装完成后在“开发板”菜单里就能看到 ESP32 系列了选自己手上对应的型号即可。很多人会卡在这一步最常见的表现是开发板管理器里搜索不到 esp32或者安装到一半报错。建议优先检查附加开发板管理器网址是不是完整复制、前后有没有多余空格如果网址没问题但还是失败可以试着把 Arduino IDE 升级到较新版本旧版 IDE 对 JSON 格式的支持不太好容易解析失败。2.3 用一个小例子验证环境环境装好后不要急着写 ESP-NOW 程序。先烧一个最简单的 LED 闪烁例程确认编译下载链路没问题。在 Arduino IDE 里选择板子型号和一个未被占用的串口打开“文件 - 示例 - 01.Basics - Blink”把 LED_BUILTIN 换成板载 LED 引脚点上传。这一步通过之后再写一个简单的串口打印程序确认开发板的串口监视器能正常输出信息。串口波特率我一般设 115200这也是 ESP32 默认日志输出的常用波特率。环境验证通过后再进入下一节的内容不然程序写好了却烧不进去排查起来会非常痛苦。3. ESP-NOW 发射端与接收端代码解析3.1 发射端怎么写ESP-NOW 的使用套路很固定总共分四步初始化 WiFi 模式、初始化 ESP-NOW、添加对端设备、发送数据。我先贴一个最精简的发送端示例#include WiFi.h #include esp_now.h // 接收端设备的 MAC 地址 uint8_t peerMac[] {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; typedef struct message { int id; float value; } message; 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, peerMac, 6); peerInfo.channel 0; peerInfo.encrypt false; peerInfo.ifidx (wifi_interface_t)ESP_IF_WIFI_STA; if (esp_now_add_peer(peerInfo) ! ESP_OK) { Serial.println(添加对端失败); return; } myData.id 1; myData.value 25.6f; } void loop() { esp_now_send(peerMac, (uint8_t *)myData, sizeof(myData)); delay(1000); }这个例子里有几个关键点。第一WiFi.mode(WIFI_STA)是必须的即使不连接任何路由器也要把 WiFi 初始化成 station 模式ESP-NOW 依赖这个底层接口。第二esp_now_peer_info_t结构体用来描述对端信息里面最重要的就是 MAC 地址。第三发送用esp_now_send参数是对端 MAC、数据指针和数据长度。有一个细节很容易踩坑数据长度不能超过 250 字节。如果自定义结构体太大要么拆包发送要么改用更紧凑的数据格式。我在项目里一般用手动排列的uint8_t数组或者精心设计过的结构体控制好字节数。3.2 接收端怎么写接收端同样需要先初始化 WiFi 和 ESP-NOW然后注册一个接收回调。数据到达时会自动触发回调在回调里解析数据即可。示例代码#include WiFi.h #include esp_now.h typedef struct message { int id; float value; } message; message myData; void OnDataRecv(const uint8_t *mac, const uint8_t *incomingData, int len) { memcpy(myData, incomingData, sizeof(myData)); Serial.print(收到节点 ); Serial.print(myData.id); Serial.print( 数值 ); Serial.println(myData.value); } 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() { // 接收回调会自动运行这里可以处理其他逻辑 }注意接收端不需要调用esp_now_add_peer因为 ESP-NOW 的数据包本身包含了发送方 MAC 地址接收方可以直接根据来源 MAC 来决定要不要处理。这在实际组网时很灵活既可以所有节点都收也可以只处理白名单里的设备。结构体对齐也是一个容易忽略的坑。我用自定义结构体传输数据时发送端和接收端如果编译器对齐方式不一致解析出来的数据就会错位。解决办法有两个一是两端使用完全相同的结构体定义在同一个 Arduino 环境下编译基本没问题二是在结构体上加上__attribute__((packed))强制紧凑排列这样更稳妥。3.3 双向通信带应答模式单向发送跑通后双向通信其实只是把发送逻辑和接收逻辑同时放到两块板上。每块板子既注册发送回调也注册接收回调既是发送端也是接收端。这种模式非常适合做遥控器和执行端之间的双向状态确认。比如遥控器发送指令给小车小车收到后立刻回一个状态包遥控器收到回包后再更新 UI 或 LED 状态。因为 ESP-NOW 本身没有 ACK 机制发送回调里的ESP_NOW_SEND_SUCCESS只代表数据已经交给了底层协议栈并发出去了并不代表对端一定成功解析。所以如果业务上要求严格确认需要在应用层自己设计应答包。我自己做双向通信时有一个习惯发送端每秒钟发一次数据接收端每收到一次就立刻回一个确认包。发送端检查如果连续三秒没收到确认包就认为链路异常触发本地告警或者重连逻辑。这套方案虽然简单但在实际测试中很可靠。4. 从点对点扩展到组网设计4.1 发送失败与重发机制ESP-NOW 的esp_now_send返回值本身只代表数据是否成功提交给协议栈真正的发送结果要靠发送回调里的status参数判断。如果状态是ESP_NOW_SEND_FAIL表示数据在传输过程中出了问题常见原因是信道干扰、接收方不在线或者距离过远。默认情况下ESP-NOW 不会自动重发。这一点非常重要很多教程没有强调导致实际项目里数据丢失却不自知。如果我做的是传感器数据采集这类允许偶尔丢包的应用就不做重发靠提高发送频率来弥补但如果做的是遥控指令这类不能丢的应用我一般会在应用层做超时重发比如连续 5 秒没有收到接收端确认就把指令再发一遍。发送间隔也要控制好。虽然 ESP-NOW 本身很轻量但如果你在一个for循环里疯狂调用esp_now_send发送大量小包底层协议栈会来不及处理回调里会频繁报失败。我的经验是保守一点单设备建议发送间隔不要低于 10 毫秒实际项目里经常用到 20 到 50 毫秒。如果数据量大可以考虑批量组包发送比如把 5 个传感器数据拼成一个结构体一次发出。4.2 一对多、多对一、多对多拓扑ESP-NOW 的优势之一就是组网灵活同一块板子可以同时添加多个对端设备。目前官方资料显示ESP32 作为发送端最多支持大约 10 个加密对端如果关闭加密可以支持更多对端。实际使用中控制在对端 10 个以内最稳妥。一对多是最简单的场景主控板把所有子节点的 MAC 地址都添加为 peer然后逐个调用esp_now_send就可以像点名一样给每个节点发数据。多对一更简单所有子节点把主控的 MAC 地址添加为 peer主控只需要注册接收回调不需要添加任何 peer就能收到所有子节点的数据。多对多的实现方式可以用广播地址FF:FF:FF:FF:FF:FF。把数据发给广播地址所有同一信道下注册了接收回调的 ESP32 都能收到。这种方式的优点是简单但缺点是所有设备都会处理这份数据需要在数据包结构里包含接收者标识非目标设备收到后直接丢弃。比如在结构体里加一个targetId字段每块板子根据自己的编号决定是否处理。4.3 与 WiFi 同时使用时的注意事项ESP32 的 WiFi 和 ESP-NOW 共用同一个 2.4GHz 射频前端所以理论上可以同时使用但有一个容易踩的坑信道必须一致。如果你让 ESP32 连接了一个 WiFi 路由器路由器工作在第 6 信道那么 ESP-NOW 发送也必须使用第 6 信道否则对方收不到。这里有一个很隐蔽的细节当你调用WiFi.begin()连接路由器后ESP32 会自动切换到路由器所在的信道。此时如果你之前手动初始化 ESP-NOW 时给peerInfo.channel设置了其他值就可能出现 ESP-NOW 发送失败。解决方法是把peerInfo.channel设置成 0表示自动跟随当前 WiFi 信道。如果两块板子都不连路由器它们都会使用默认的 0 信道通常也能正常通信。我在实际项目中经常让主控同时做两件事通过 WiFi 把采集到的数据上传到云端同时通过 ESP-NOW 和各子节点通信。这种场景下一定要用WiFi.mode(WIFI_STA)模式不要进入WIFI_AP_STA混合模式除非你确实需要同时开热点。混合模式会占用更多资源某些固件版本下还会和 ESP-NOW 产生冲突。5. 实战一个传感器数据回传小项目5.1 需求与协议设计光讲 API 不落地没什么意义我拿一个实际做过的温湿度采集项目举例。项目需求很简单两个传感器节点分别放在阳台和客厅每隔 5 秒采集一次温湿度把数据回传给一个主控节点主控节点用 OLED 显示屏显示最新读数。因为要传两个浮点数和一个节点编号我设计了这样一个结构体typedef struct sensor_data { uint8_t nodeId; // 节点编号 float temperature; // 温度 float humidity; // 湿度 uint32_t timestamp; // 采集时间戳毫秒 } sensor_data;算一下大小uint8_t占 1 字节两个float各占 4 字节uint32_t占 4 字节总共 13 字节。就算加上编译器对齐的网络封包开销也远小于 250 字节的限制所以不需要考虑分包。这里有个设计经验时间戳字段很多人会省略但我强烈建议保留。有了发送端的本地时间戳接收端才能判断数据是不是太久没更新从而识别“链路还通但传感器已经挂了”这类异常情况。这在长时间运行的项目里非常实用。5.2 完整代码框架传感器节点发送端的代码框架如下#include WiFi.h #include esp_now.h #include DHT.h #define DHTPIN 4 #define DHTTYPE DHT22 DHT dht(DHTPIN, DHTTYPE); uint8_t masterMac[] {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}; typedef struct sensor_data { uint8_t nodeId; float temperature; float humidity; uint32_t timestamp; } sensor_data; sensor_data data; void OnDataSent(const uint8_t *mac_addr, esp_now_send_status_t status) { if (status ! ESP_NOW_SEND_SUCCESS) { Serial.println(上次发送失败); } } void setup() { Serial.begin(115200); dht.begin(); WiFi.mode(WIFI_STA); esp_now_init(); esp_now_register_send_cb(OnDataSent); esp_now_peer_info_t peerInfo; memcpy(peerInfo.peer_addr, masterMac, 6); peerInfo.channel 0; peerInfo.encrypt false; esp_now_add_peer(peerInfo); data.nodeId 1; } void loop() { data.temperature dht.readTemperature(); data.humidity dht.readHumidity(); data.timestamp millis(); esp_now_send(masterMac, (uint8_t *)data, sizeof(data)); delay(5000); }主控节点的代码就是在前面接收端例程基础上加上 OLED 显示逻辑。OLED 我一般用 U8g2 库初始化之后在接收回调里更新显示变量主循环只负责display.refresh()。这里有一个注意点接收回调是在协议栈的上下文中执行的不要在回调里跑耗时的操作比如delay、Serial.print大段内容或者动态分配内存否则会影响后续数据包的接收。我通常只用回调把数据拷贝到全局变量并置一个dataUpdated标志具体显示逻辑放到loop()里处理。只要注意上述几点两三个节点同时回传数据基本不会互相干扰。因为每个节点有不同的nodeId主控完全可以区分数据来源即使两个节点同一瞬间发数据由于底层射频的 CSMA 机制通常也不会发生严重碰撞万一偶尔撞包下一轮 5 秒后的重传就会补上。5.3 数据校验与协议扩展中文环境下做数据传输很多朋友会忽略字节序和数据一致性的问题。比如用结构体直接传输时结构体里的uint32_t在发送端和接收端都按小端字节序存储正常情况下没问题但如果你混合使用了不同平台或不同编译器或者将来想用 Lua、Python 等解析数据就需要自己定义字段顺序和字节序不能直接套结构体。所以我提供一个更通用的建议关键数据可以用数组手动打包比如用memcpy把浮点数转成字节数组再拼接到发送缓冲区。这种方式写起来稍微麻烦一点但可以保证两端协议完全一致也方便做校验。校验字段的设计也是一个改进思路。如果传输的是传感器数据可以在结构体里加一个简单的 CRC8 或者累加和字段接收端解析完后先校验再决定是否丢弃。ESP-NOW 底层其实有 CRC 校验射频层会丢弃损坏的包所以大多数时候不需要额外校验但在强干扰环境下应用层校验多一层保险。6. 常见问题排查表与避坑心得6.1 发送失败最常见的三种原因我把实际使用中遇到过的情况整理成一张速查表省得大家出问题时漫无目的乱试。现象可能原因解决办法esp_now_init()返回失败WiFi 模式未设置先调用WiFi.mode(WIFI_STA)发送回调一直报FAILMAC 地址填错用WiFi.macAddress()或esp_efuse_mac_get_default()打印确认发送回调报FAIL但 MAC 正确信道不一致让所有设备信道统一或在不连路由器时保持默认信道上电后一段时间能收以后收不到供电不稳导致射频异常换电源线、加外部稳压电容离远一点就丢包天线位置、发射功率尽量调整板子方向必要时设置WiFi.setTxPower(WIFI_POWER_19_5dBm)其中 MAC 地址填错是新手最常犯的错。ESP32 的 MAC 地址形如AA:BB:CC:DD:EE:FF在代码里写成{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}必须保持顺序一致。我一开始就试过把某个地址的冒号位置看错结果发送回调稳定报失败查了大半天才发现是0xCE写成了0xDE非常憋屈。发射功率也值得一提。默认状态下 ESP32 的 WiFi 发射功率通常不是最大值如果你在做远距离传输测试可以用WiFi.setTxPower(WIFI_POWER_19_5dBm);把它提高到最高档。注意最大功率意味着更高功耗电池供电的节点要仔细权衡。我测下来从默认功率提到最高通信距离大约能增加两成到三成但发送电流会明显上升电池项目里不太划算。6.2 接收端收不到数据怎么排查接收端收不到数据多数情况下是这几个原因第一接收端没有初始化 ESP-NOW漏了esp_now_init()。这看起来像废话但真的有朋友把发送端初始化成功后就以为接收端不用初始化结果回调一直不触发。第二接收端和发送端不在同一个信道。如果发送端连接了一个 WiFi 路由器路由器自动跳到第 6 信道而接收端没有连接任何路由器它可能仍然停留在默认信道两端就“错过”了。解决方法是把两块板子同时连同一个路由器或者都用esp_wifi_set_channel固定信道。第三接收回调里做了耗时操作。前面说过回调函数中不要用delay或Print大字符串否则底层协议栈缓冲可能溢出后续数据就无法及时处理。正确的做法是回调内只做标志位设置和内存拷贝。我还遇到过一种比较隐蔽的情况OLED 或者其他外设使用了和 WiFi 射频冲突的引脚导致 ESP-NOW 接收质量严重下降。这种问题通常只影响特定板型和特定外设组合排查时要先把外设断开看能不能正常接收再用最小化方式逐步恢复。6.3 关于加密、配对与安全ESP-NOW 支持对传输数据加密使用方式是生成一个 PSK添加到 peer 时把peerInfo.encrypt设为true并填充lmk密钥。加密能防止同信道的其他设备直接解析你的数据但如果只是个人项目加密意义不大反而多一道配置容易出错。我一般建议先在无加密模式下跑通后续再按需升级。还有一个容易被忽略的地方ESP-NOW 对端信息保存在 RAM 里板子重启后需要重新添加 peer。如果项目要求掉电恢复后自动通信代码里就没有什么特殊逻辑因为setup()里本来就会重新初始化Peers 重新添加即可不需要持久化到 Flash。安全方面如果你在公开场合部署 ESP-NOW 网络至少要做到两点一是数据包中加入设备标识和校验防止误收二是如果传输敏感指令一定要开启加密。虽然 ESP-NOW 短距离直连本身暴露面不大但 2.4GHz 频段很容易被嗅探不要心存侥幸。6.4 内存、栈空间和稳定性ESP-NOW 本身内存开销不大但它和 WiFi 共存时会占用一定的协议栈缓冲区。如果程序里同时启用 WiFi 连接、MQTT 客户端和 ESP-NOW某些库定义的大缓冲区会把内存吃得很紧严重时导致启动失败或重启。我的做法是尽量精简库的依赖比如 MQTT 客户端可以换轻量级的 PubSubClient而不是一上来就装重量级的全功能库。另外 Arduino 的loop()里避免用大量动态字符串拼接字符串操作最容易引入内存碎片长期运行后会莫名其妙地崩溃。如果你做的是 7x24 小时长期运行的项目建议加一个看门狗定时器比如每 60 秒检查一次最近的发送/接收时间戳如果超时没有活动就主动重启。这种“粗放但有效”的手段在无人工值守的环境中很管用。7. 我用 ESP-NOW 半年后的一些体会聊了这么多最后分享一点个人感受。ESP-NOW 最大的价值不是替代 WiFi也不是替代蓝牙而是在“设备少、距离近、要求快、不想配置路由器”的场景里提供了几乎零成本的通信方案。它的学习曲线比 MQTT 平缓很多代码量少调试也直观特别适合刚开始接触无线通信的玩家。我踩过最大的坑其实不是技术本身而是思维惯性。一开始总觉得所有无线通信都应该先连网、再建连接、再传数据这是被 WiFi 和手机 App 的开发习惯惯出来的。ESP-NOW 用下来之后我发现很多本地场景根本不需要走 IP 栈直接裸传反而更稳定。这种“最多余一层就会多一些麻烦”的经验也让我在设计其他项目时多了一层“协议最小化”的思考。如果你正好手头有两块 ESP32建议别光看文章直接动手烧一个发送端和一个接收端把上面第一组代码跑通再慢慢改成自己的协议。通信这种东西光看文档和自己上手跑一遍理解深度完全不一样。等跑通了那一下你会发现原来设备之间聊天可以这么简单。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。