手里有两块ESP32,想让它们互相说句话,第一反应基本都是连WiFi:配网、连路由器、再走一轮握手,链路一长,延迟和掉线全来了。其实乐鑫在芯片里藏了个更直接的玩法——ESP-NOW,不连路由器、不配WiFi密码,两块板子只要知道对方的MAC地址,就能在空中直接喊话。这几天我把几份英文教程和踩坑帖翻了一遍,把整套流程理顺成一篇新手向的记录,照着走,一个晚上能跑通单向,再加半小时能跑通双向。

ESP-NOW是个啥:不走网络的点对点
打个比方,普通WiFi通信像寄快递:先连上路由器这个中转站,再走TCP/IP一整套握手流程,包裹才能送到。ESP-NOW更像两个人拿对讲机,频道对准了,喊一嗓子对方就听见,中间没有任何中转。它借用的还是WiFi那套2.4GHz射频硬件,但把上层网络协议整个绕开,直接在数据链路层发包,所以延迟压得非常低,做遥控、传感器同步这类场景特别合适。

乐鑫官方开发者博客上有一张对比图,把OSI七层模型和ESP-NOW的模型摆在一起:传统WiFi的数据要从应用层、传输层、网络层一层层往下穿,ESP-NOW直接坐在数据链路层上,底下还是802.11物理层。省掉的就是中间那一大坨协议开销,这就是它快的原因。
几个关键参数先记在心里:单包数据最大250字节(ESP-NOW v2.0设备能收1470字节,但新手一律按250算最稳);配对的peer数量上限在20个上下,再开加密限制更多,不同版本文档口径不完全一样,用前查一下你手上那版IDF文档;视距通信距离普遍能到100到200米,隔墙要打折。还有一点很省心:配对信息存在非易失存储里,板子重启之后通信自己就恢复了,不用每次重新配。
动手前:两块板、两根线、一个地址
环境这边先交代清楚:Arduino IDE里装好ESP32板卡支持包,3.0以上版本自带ESP-NOW库,不用额外装任何依赖。板子要挑带WiFi的型号,常见的ESP32-WROOM系列开发板都行,ESP32-H2这类天生没有WiFi射频的芯片玩不了这一套。两块板型号不用完全一样,但烧的核心版本建议升到同一版,省得回调签名对不上。
ESP-NOW认地址不认人,发包前必须知道对方的MAC地址。接收端的代码里已经把打印地址这步写好了,开机时串口(115200波特率)会直接报出来:

看这张串口截图,最后一行Receiver initialized后面跟着的A4:CF:12:9A:19:AC就是这块板的地址。新手第一个坑往往在这——两块板长得一模一样,谁是谁分不清。抄地址的时候顺手贴个标签在板子背面,后面填地址、排故障都靠它。
发送端:把结构体打包扔出去
ESP-NOW发的是裸数据块,惯用做法是收发双方定义一个一模一样的结构体,把要传的字段都塞进去。发送端代码如下,broadcastAddress换成你接收板的地址:
#include "esp_now.h"// https://github.com/espressif/esp-idf/blob/master/components/esp_wifi/include/esp_now.h
#include "WiFi.h"
uint8_t broadcastAddress[] = {0xA4, 0xCF, 0x12, 0x9A, 0x19, 0xAC};// REPLACE WITH RECEIVER MAC ADDRESS
// Structure example to send data
// Must match the receiver structure
typedef struct struct_message {
char a[32];
int b;
float c;
String d;
bool e;
} struct_message;
struct_message myData;
// callback when data is sent
void OnDataSent(const uint8_t *mac_addr, esp_now_send_status_t status) {
Serial.print(F("\r\n Master packet sent:\t"));
Serial.println(status == ESP_NOW_SEND_SUCCESS ? "Delivery Success" : "Delivery Fail");
}
void setup() {
// Init Serial Monitor
Serial.begin(115200);
// Set device as a Wi-Fi Station
WiFi.mode(WIFI_STA);
// Init ESP-NOW
if (esp_now_init() != ESP_OK) {
Serial.println(F("Error initializing ESP-NOW"));
return;
}
Serial.print(F("Transmitter initialized : "));
Serial.println(WiFi.macAddress());
// Define Send function
esp_now_register_send_cb(OnDataSent);
// Register peer
esp_now_peer_info_t peerInfo;
memcpy(peerInfo.peer_addr, broadcastAddress, 6);
peerInfo.channel = 0;
peerInfo.encrypt = false;
// Add peer
if (esp_now_add_peer(&peerInfo) != ESP_OK) {
Serial.println(F("Failed to add peer"));
return;
}
}
void loop() {
// Set values to send
strcpy(myData.a, "data type char");
myData.b = random(1, 20);
myData.c = 1.2;
myData.d = "hello";
myData.e = false;
// Send message via ESP-NOW
esp_err_t result = esp_now_send(broadcastAddress, (uint8_t *) &myData, sizeof(myData));
if (result == ESP_OK) {
Serial.println(F("Sent with success"));
}
else {
Serial.println(F("Error sending the data"));
}
delay(1000);
}流程拆开看就四步:WiFi设成STA模式,esp_now_init初始化,把对方MAC注册成peer,然后esp_now_send把整个结构体一把发出去。OnDataSent是发送回调,数据包在MAC层发完后会被自动调用,告诉你这次投递成功还是失败。烧进去之后,串口里应该能看到这样的节奏:

结构体五个字段的分工:a是32字节字符数组装文本,b装整数,c装小数,d用String装字符串,e装开关量。实际项目里把b换成温度、c换成电压,就是一个现成的传感器数据包。
记住一条铁律:收发两边的结构体必须一模一样,字段顺序、类型、数组长度差一个,解出来的数据就是乱码。稳妥的做法是把结构体单独放进一个头文件,两边的代码共同include,从根上杜绝两边改不同步的问题。
接收端:回调里拆包
接收端只要注册一个接收回调,数据到了就自动进函数。代码如下,结构体和发送端一字不差:
#include "esp_now.h"// https://github.com/espressif/esp-idf/blob/master/components/esp_wifi/include/esp_now.h
#include "WiFi.h"
// Structure example to receive data
// Must match the sender structure
typedef struct struct_message {
char a[32];
int b;
float c;
String d;
bool e;
} struct_message;
struct_message myData;
// callback when data is received
void OnDataRecv(const uint8_t * mac, const uint8_t *incomingData, int len) {
memcpy(&myData, incomingData, sizeof(myData));
Serial.print("Bytes received: ");
Serial.println(len);
Serial.print("Char: ");
Serial.println(myData.a);
Serial.print("Int: ");
Serial.println(myData.b);
Serial.print("Float: ");
Serial.println(myData.c);
Serial.print("String: ");
Serial.println(myData.d);
Serial.print("Bool: ");
Serial.println(myData.e);
Serial.println();
}
void setup() {
// Initialize Serial Monitor
Serial.begin(115200);
// Set device as a Wi-Fi Station
WiFi.mode(WIFI_STA);
// Init ESP-NOW
if (esp_now_init() != ESP_OK) {
Serial.println("Error initializing ESP-NOW");
return;
}
Serial.print(F("Receiver initialized : "));
Serial.println(WiFi.macAddress());
// Define receive function
//esp_now_register_recv_cb(OnDataRecv);
esp_now_register_recv_cb(esp_now_recv_cb_t(OnDataRecv));
}
void loop() {
}发送板跑起来后,接收板串口里就是一串拆包结果:

这里有个新手必踩的坎:Arduino ESP32核心升到3.x之后回调签名变了,注册回调那行要像代码里这样写成esp_now_register_recv_cb(esp_now_recv_cb_t(OnDataRecv));,强转一下才能编译过,老核心则两种写法都认。这不是你抄错了代码,是核心版本变了。另外注意len参数是收到的字节数,和sizeof(struct_message)对得上才算完整包,养成先核长度再memcpy的习惯,无线环境里什么包都可能飞过来。
进阶:同一份代码双向收发
单向通了之后,双向只差三步:两块板烧同一份收发一体的代码,同时注册发送和接收两个回调,再把对方的MAC填进broadcastAddress。下面这份代码两块板通用:
#include "esp_now.h"
#include "WiFi.h"
uint8_t broadcastAddress[] = {0xA4, 0xCF, 0x12, 0x9A, 0x19, 0xAC};// REPLACE WITH OTHER TRANSCEIVER MAC ADDRESS
// Structure example to send data
// Must match the receiver structure
typedef struct struct_message {
char a[32];
int b;
float c;
String d;
bool e;
} struct_message;
struct_message myData;
char dataRcv[15];
// callbacks for sending and receiving data
void OnDataSent(const uint8_t *mac_addr, esp_now_send_status_t status) {
Serial.print("\r\nMaster packet sent:\t");
Serial.println(status == ESP_NOW_SEND_SUCCESS ? "Delivery Success" : "Delivery Fail");
}
void OnDataRecv(const uint8_t * mac, const uint8_t *incomingData, int len) {
memcpy(&dataRcv, incomingData, sizeof(dataRcv));
Serial.print("\r\nBytes received: ");
Serial.println(len);
Serial.print("From slave: ");
Serial.println(dataRcv);
Serial.println();
}
void setup() {
// Init Serial Monitor
Serial.begin(115200);
// Set device as a Wi-Fi Station
WiFi.mode(WIFI_STA);
// Init ESP-NOW
if (esp_now_init() != ESP_OK) {
Serial.println(F("Error initializing ESP-NOW"));
return;
}
Serial.print(F("Transceiver initialized : "));
Serial.println(WiFi.macAddress());
// Define callback functions
esp_now_register_send_cb(OnDataSent);
esp_now_register_recv_cb(OnDataRecv);
// Register peer
esp_now_peer_info_t peerInfo;
memcpy(peerInfo.peer_addr, broadcastAddress, 6);
peerInfo.channel = 0;
peerInfo.encrypt = false;
// Add peer
if (esp_now_add_peer(&peerInfo) != ESP_OK) {
Serial.println(F("Failed to add peer"));
return;
}
}
void loop() {
// Set values to send
strcpy(myData.a, "from Master");
myData.b = random(1, 20);
myData.c = 1.2;
myData.d = "hello";
myData.e = false;
// Send message via ESP-NOW
esp_err_t result = esp_now_send(broadcastAddress, (uint8_t *) &myData, sizeof(myData));
delay(1000);
}两块板唯一的区别在loop里:一块strcpy发"from Master",另一块改成"from Slave",其他一行不动。烧完打开两个串口,就能看到两边你来我往地互报家门。原教程还提醒了一句:收发可以用不同的结构体,但接收方必须认得对方发来的结构,不然解不开。
避坑清单:新手最容易翻车的四个地方
- Failed to add peer。有些核心版本下直接注册peer会失败,教程评论区给的解法很管用:在声明esp_now_peer_info_t peerInfo之后补一句memset(&peerInfo, 0, sizeof(peerInfo));把结构体清零就能过,因为结构体里有填充字节没初始化,里面是脏值。
- 信道不一致。两块板哪怕放在同一个房间,固件里信道设得不一样就永远收不到。新手最稳的做法是别手动改信道,都用默认值;环境里路由器多的话,把两块板固定在同一个空闲信道上更稳。
- 供电。无线发送瞬时电流不小,USB口供电弱或者和电机共电源时,电压一塌就是丢包甚至复位。遥控小车这类项目记得给驱动板和主控分开供电。
- 回调里别干重活。收发回调跑在高优先级的WiFi任务里,在里面delay、刷大量串口、直接驱动电机都可能卡住协议栈。把数据拷进全局变量,回loop里慢慢处理。还要认清一点:发送回调报SUCCESS只代表MAC层送达,不代表对方程序处理了,重要指令要自己加序号、校验和重发机制。
整套跑下来,硬件就是两块ESP32加两根USB线,代码全在官方库里,一个第三方依赖都不用装。先用串口把单向收发打通,再切双向,最后按自己的项目改结构体,遥控车、传感器网络、双板状态同步都是从这一步长出去的。新手把MAC地址、结构体一致、信道一致这三件事盯牢,基本一次就能通。











延迟是真低,做过一个摇杆遥控小车,手感比走WiFi那套好太多。
两块板二十来块钱就能跑通无线,比堆一堆透传模块划算多了。
memset那句救命了,昨天正好卡在Failed to add peer,回头补一句试试。
上周刚用ESP-NOW给阳台的温湿度节点做过一次透传,这篇里的结构体问题太真实了。我当时两端分别改了字段顺序,收出来的Float一直是inf,查了一晚上才发现是结构体不一致。补充一个小经验:两边最好把struct单独放一个头文件共同引用,另外3.x核心的回调强转这个点很多老教程都没提,照抄老代码必卡编译。信道这个坑也踩过,家里2.4G设备一多就得手动锁同一个信道才稳。