ESP32 综合教程:用 ESP-NOW 搭建多节点采集与 WiFi 网关
本文由 AI 协助整理,示例以 Arduino-ESP32 为主。不同芯片型号和 Arduino Core 版本的回调函数签名可能不同,请以当前版本示例为准。无线性能受天线、距离、遮挡、信道和供电影响,文中不虚构固定通信距离。

为什么需要一个 ESP-NOW 网关
做家庭传感器时,最直接的方法是每块 ESP32 都连接家里的 WiFi,然后把数据发到 MQTT。这个方案简单,但节点一多就会出现几个问题:每个节点都要保存 WiFi 密码,路由器换密码要逐个处理;远处信号弱的节点频繁重连;电池节点保持 WiFi 连接又比较耗电。
ESP-NOW 提供了另一种思路:采集节点不加入路由器,只和一块网关 ESP32 直接通信。网关统一连接 WiFi,把数据转给 MQTT、HTTP 服务或数据库。节点负责“采集和发送”,网关负责“接收和联网”,职责比较清楚。
它也不是魔法无线电。ESP-NOW 仍使用 2.4GHz WiFi 射频,信道、干扰和天线依然重要;发送成功回调也不等于业务系统已经保存数据。这个项目的重点不是宣称“又快又远”,而是搭建一条能识别节点、发现重复包、记录离线状态的数据链路。
项目目标与硬件
本教程先完成以下功能:
- 两个 ESP32 节点周期发送温度、湿度或开关量。
- 网关根据节点编号区分来源,并检查数据长度。
- 每个数据包带递增序号,网关可以发现重复或跳号。
- 网关同时连接家庭 WiFi,把有效数据发布到 MQTT。
- 网络断开时,ESP-NOW 接收不被长时间阻塞。
至少需要三块 ESP32:两块作为节点,一块作为网关。传感器可以先不接,用固定变化数据验证链路。初次调试时,少一个真实传感器就少一种变量,先让三块板互相认识,再给它们安排工作。
最关键的信道问题
ESP-NOW 节点和网关必须工作在相同 WiFi 信道。网关如果同时连接路由器,它的信道会跟随路由器,因此节点也必须使用该信道。
例如路由器 2.4GHz 网络工作在信道 6,网关连接后也位于信道 6,两个发送节点就要固定到信道 6。路由器若启用自动信道并在重启后切换,节点可能突然全部失联。
开发阶段可以在网关连接 WiFi 后打印信道:
Serial.printf("WiFi channel: %d\n", WiFi.channel());
然后节点使用相同的 CHANNEL。长期运行时,可以把家庭 2.4GHz WiFi 固定到一个干扰较少的信道,或者设计节点重新扫描网关信道的机制。教程先使用固定信道,便于理解和排错。
先定义数据包
无线数据包不要直接发送含有 String、指针或动态容器的对象。定义只包含固定长度字段的结构体:
#include <Arduino.h>
struct __attribute__((packed)) SensorPacket {
uint8_t version;
uint8_t nodeId;
uint32_t sequence;
float temperature;
float humidity;
uint16_t batteryMv;
};
static_assert(sizeof(SensorPacket) == 16, "Unexpected packet size");
version 用于以后升级格式,nodeId 区分节点,sequence 是发送序号。电池电压使用毫伏整数,避免传一个没有必要的浮点数。
发送端和接收端必须使用完全一致的字段顺序、类型和打包方式。协议升级时不要悄悄改变结构,至少提高 version,让网关知道自己收到的是新格式,而不是把新数据按旧格式认真解释一遍。
获取网关 MAC 地址
ESP-NOW 单播需要知道对端 MAC。先给网关烧录下面的最小程序,在串口中读取 STA MAC:
#include <WiFi.h>
void setup() {
Serial.begin(115200);
WiFi.mode(WIFI_STA);
Serial.println(WiFi.macAddress());
}
void loop() {}
把结果转换成六个十六进制字节,例如 24:6F:28:12:34:56 对应:
uint8_t gatewayMac[] = {0x24, 0x6F, 0x28, 0x12, 0x34, 0x56};
不要照抄文章里的示例地址,每块板都不一样。
发送节点最小程序
下面是 Arduino-ESP32 常用底层 API 的发送骨架。把 CHANNEL 改成网关连接路由器后的实际信道,把 MAC 改成自己的网关地址。
#include <WiFi.h>
#include <esp_now.h>
#include <esp_wifi.h>
constexpr uint8_t NODE_ID = 1;
constexpr uint8_t CHANNEL = 6;
uint8_t gatewayMac[] = {0x24, 0x6F, 0x28, 0x12, 0x34, 0x56};
uint32_t sequence = 0;
struct __attribute__((packed)) SensorPacket {
uint8_t version;
uint8_t nodeId;
uint32_t sequence;
float temperature;
float humidity;
uint16_t batteryMv;
};
void setup() {
Serial.begin(115200);
WiFi.mode(WIFI_STA);
WiFi.disconnect();
esp_wifi_set_channel(CHANNEL, WIFI_SECOND_CHAN_NONE);
if (esp_now_init() != ESP_OK) {
Serial.println("ESP-NOW init failed");
return;
}
esp_now_peer_info_t peer{};
memcpy(peer.peer_addr, gatewayMac, 6);
peer.channel = CHANNEL;
peer.encrypt = false;
if (esp_now_add_peer(&peer) != ESP_OK) {
Serial.println("Add peer failed");
}
}
void loop() {
SensorPacket packet{};
packet.version = 1;
packet.nodeId = NODE_ID;
packet.sequence = sequence++;
packet.temperature = 23.5f; // 换成真实传感器读数
packet.humidity = 51.0f;
packet.batteryMv = 4100;
esp_err_t result = esp_now_send(
gatewayMac,
reinterpret_cast<const uint8_t*>(&packet),
sizeof(packet));
Serial.printf("send seq=%lu result=%d\n",
static_cast<unsigned long>(packet.sequence), result);
delay(5000);
}
第二个节点只需要修改 NODE_ID,MAC 和信道保持一致。真正读取传感器时,先过滤 NaN 或明显越界值,不要把失败读数发给网关。
esp_now_send() 返回成功,只表示发送请求已被协议栈接受,不代表 MQTT 服务端已经收到。需要更严格可靠性时,应由网关发送应用层确认,并在节点端实现超时重发;重发又会制造重复包,所以序号正好派上用场。
网关的接收与转发
网关先连接路由器,再初始化 ESP-NOW。顺序很重要,因为连接路由器以后信道已经确定。
接收回调属于通信上下文,不适合在里面连接 MQTT、拼大段 JSON 或写文件。正确做法是快速检查并复制数据,让主循环慢慢处理。
#include <WiFi.h>
#include <esp_now.h>
const char* WIFI_SSID = "YOUR_WIFI";
const char* WIFI_PASS = "YOUR_PASSWORD";
struct __attribute__((packed)) SensorPacket {
uint8_t version;
uint8_t nodeId;
uint32_t sequence;
float temperature;
float humidity;
uint16_t batteryMv;
};
portMUX_TYPE packetMux = portMUX_INITIALIZER_UNLOCKED;
SensorPacket pendingPacket{};
volatile bool packetReady = false;
void onDataRecv(const esp_now_recv_info_t* info,
const uint8_t* data, int len) {
if (len != sizeof(SensorPacket)) return;
portENTER_CRITICAL(&packetMux);
memcpy(&pendingPacket, data, sizeof(SensorPacket));
packetReady = true;
portEXIT_CRITICAL(&packetMux);
}
void setup() {
Serial.begin(115200);
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS);
unsigned long started = millis();
while (WiFi.status() != WL_CONNECTED && millis() - started < 15000) {
delay(200);
}
Serial.printf("channel=%d ip=%s\n",
WiFi.channel(), WiFi.localIP().toString().c_str());
if (esp_now_init() != ESP_OK) {
Serial.println("ESP-NOW init failed");
return;
}
esp_now_register_recv_cb(onDataRecv);
}
void loop() {
SensorPacket local{};
bool hasPacket = false;
portENTER_CRITICAL(&packetMux);
if (packetReady) {
local = pendingPacket;
packetReady = false;
hasPacket = true;
}
portEXIT_CRITICAL(&packetMux);
if (hasPacket) {
if (local.version != 1 || local.nodeId == 0) {
Serial.println("Invalid packet");
return;
}
Serial.printf("node=%u seq=%lu temp=%.1f hum=%.1f battery=%umV\n",
local.nodeId,
static_cast<unsigned long>(local.sequence),
local.temperature, local.humidity, local.batteryMv);
// 在这里发布 MQTT,不要放到接收回调里。
}
}
上面只有一个 pendingPacket,如果数据来得很快,旧包可能被新包覆盖。低频学习项目够用;正式多节点项目应使用 FreeRTOS 队列,让每个回调数据包都有位置,并统计队列满的次数。

接入 MQTT
可以按节点划分主题:
home/sensors/node-01/state
home/sensors/node-02/state
home/sensors/gateway/status
网关收到节点 1 后组装 JSON:
{
"sequence": 103,
"temperature": 23.5,
"humidity": 51.0,
"battery_mv": 4100
}
MQTT 断线时不要阻塞 ESP-NOW 接收。主循环每隔几秒尝试重连,失败就继续接收和打印。需要离线缓存时,可以保存少量最新数据,但要设置容量上限。网络断一天产生的每个五秒采样都塞进内存,ESP32 很快会用沉默表达意见。
序号、重复包与离线判断
为每个节点保存 lastSequence 和 lastSeenMillis:
- 序号等于上次序号:可能是重发,按业务决定丢弃。
- 序号大于上次加一:中间可能丢包,增加丢包计数。
- 序号突然变小:节点可能重启或计数回绕,结合在线时间判断。
- 超过预定时间没收到数据:标记节点离线,而不是把旧数据继续当实时值。
节点重启后序号从零开始并不一定是错误。可以额外增加 bootId,每次开机生成随机值,网关便能区分“旧包重复”和“设备重新启动”。
建议的分阶段验证
第一阶段只使用一发一收,关闭 WiFi 和 MQTT,确认 MAC、信道和结构体长度正确。
第二阶段增加第二个节点,确认 nodeId 不冲突,串口能交替显示两路数据。
第三阶段让网关连接路由器,打印实际信道,再调整节点信道。此时先不接 MQTT。
第四阶段加入 MQTT,只发布固定测试消息,再发布真实节点消息。
第五阶段测试异常:重启路由器、断开 Broker、关闭一个节点、让节点快速发送,观察网关是否仍能运行并给出合理状态。
常见故障
| 现象 | 优先检查 |
|---|---|
| 完全收不到数据 | MAC、信道、STA 模式、ESP-NOW 初始化返回值 |
| 网关连接 WiFi 后节点失联 | 路由器信道与节点固定信道不一致 |
| 数据数值乱码 | 两端结构体、字段类型、长度和版本不一致 |
| 偶尔丢包 | 距离、天线方向、供电、2.4GHz 干扰、发送间隔 |
| MQTT 一断网网关就卡住 | 重连逻辑阻塞,或在接收回调中执行网络操作 |
| 两个节点数据互相覆盖 | 接收端只有单缓冲,应改用队列 |
安全与后续扩展
学习阶段代码把 encrypt 设为 false,只适合非敏感实验。ESP-NOW 支持加密通信,正式使用应根据当前 Espressif 文档配置密钥、管理配对设备,并保护网关中的 WiFi 与 MQTT 凭据。
后续可以加入深度睡眠,让电池节点唤醒、采样、发送后立即休眠;也可以让网关维护节点表,在网页中显示最后在线时间、信号信息和丢包计数。再往后增加双向命令与确认机制,就能做低功耗门磁、按钮、土壤湿度和远端告警节点。
总结
ESP-NOW 多节点网关的价值,是把低功耗本地采集与常规 IP 网络分开。节点不必都维护 WiFi 和 MQTT,网关则成为统一的协议翻译与状态管理入口。
真正容易踩坑的不是发送函数,而是信道一致、结构体版本、回调职责、重复包和离线状态。先把这些边界设计好,三块 ESP32 才不会只是“偶尔能互相说话”,而会逐渐成为一套能观察、能排错、能继续扩展的小型无线网络。
参考资料
打赏
如果这篇学习记录对你有帮助,欢迎扫码支持后续整理。

打赏支持:王庆岩
浙公网安备 33010602011771号