← 返回教程库

ESP-NOW:让两块 ESP32-S3 不经路由器直接对话

最后更新 2026-06-22
L3 · 联网与 IoT ⏱ 约 22 分钟 🟢 软件/低风险
你将学到
  • 说清 ESP-NOW 解决什么问题,以及它和 WiFi/MQTT 方案的取舍边界
  • 用一对 ESP32-S3 跑通发送端与接收端,把一块板的动作传到另一块板
  • 学会用 esp_wifi_get_mac 读本机 MAC、esp_now_add_peer 加 peer、注册 recv_cb 收数据
  • 能照故障表自查发送失败、收不到、信道不一致等常见坑

你手上有两块 ESP32-S3:一块接着按钮放在门口,一块接着 LED 装在桌上。你想做的事很简单——按门口的按钮,桌上的灯亮。

WiFi 那篇 学的套路,你大概会这么干:两块板都连同一个路由器,再架个 MQTT broker,按钮板发消息、灯板订阅消息。能跑,但你停下来想想——这俩板子明明就隔了两米,数据却要先上路由器、绕到 broker、再绕回来。要是路由器重启了、broker 挂了,或者你把这套东西搬到没有 WiFi 的院子里,整条链路就断了。

为了让两块近在咫尺的板子说句话,搭这么一长串基础设施,太重了。

这一篇讲的 ESP-NOW,就是专门解决这种"设备对设备"场景的。两块 ESP32-S3 上电、知道彼此的 MAC 地址,就能直接通信,中间不需要任何路由器、不需要 broker、不需要联网。

读这篇前,你需要先跑通过 WiFi 那篇——倒不是因为 ESP-NOW 要联网(它恰恰不联网),而是因为它复用了 WiFi 的那套底层初始化(esp_netif_init / esp_event_loop_create_default / esp_wifi_init / esp_wifi_start),你得熟悉这几步是干什么的。代码全靠 idf.py build flash monitor 烧录、靠 ESP_LOGI 打的串口日志调试,这条链路要先通。


ESP-NOW 是什么

ESP-NOW 是乐鑫(Espressif,ESP32 的厂商)自己设计的一套轻量无线协议。它的几个关键特点:

  • 不连路由器:两块板之间直接发包,不经过任何 AP(接入点)。
  • 延迟极低:数据从一块板到另一块板通常是毫秒级,比走 WiFi + broker 那条路快一个数量级。
  • 省电:不用花几秒钟连 WiFi、拿 IP,上电几十毫秒就能发第一包数据,特别适合电池供电、需要长时间深睡的传感器节点。
  • 距离比想象中远:在开阔环境下能打到上百米,比同样功率下普通 WiFi 数据传输的稳定距离更长。
  • 支持一对多 / 多对一:一块板可以同时给多块板发,也可以接收来自多块板的数据。

原理:借 WiFi 的"腿",但不走 WiFi 的"路"

ESP-NOW 用的还是 WiFi 的物理层(2.4GHz 那套射频硬件),所以它和 WiFi 共用一根天线、一个射频模块。这也是为什么在 ESP-IDF 里,你必须先把 WiFi 初始化到 STA 模式并 esp_wifi_start() 把射频点起来,哪怕你压根不连路由器——ESP-NOW 借的就是这套射频的腿。但它绕开了 WiFi 协议里那些"重"的东西:

普通 WiFi 要先连 AP、握手认证、拿 DHCP 分配的 IP,之后所有数据靠 IP 地址寻址。这套流程换来的是能接入互联网,代价是连接慢、依赖路由器。

ESP-NOW 不走这一套。它直接用 MAC 地址(每块 ESP32-S3 出厂自带的硬件地址,全球唯一)来标识对方。你告诉发送端"对方的 MAC 是 XX:XX:XX:XX:XX:XX",它就直接往那个地址发包,没有连接、没有握手。这就是为什么它能"上电就通"。

和 MQTT 方案对比

维度 ESP-NOW WiFi + MQTT
需要路由器 不需要 需要
需要 broker 不需要 需要
上电到能通信 几十毫秒 几秒(连 WiFi + 连 broker)
单跳延迟 毫秒级 几十到几百毫秒
能上互联网 不能
适合场景 设备到设备、遥控器、传感器群 设备要上云、要远程访问

一句话:要联网上云,用 MQTT;只是几块板子之间互通,用 ESP-NOW。 两者不是替代关系,后面你会看到它们还能配合着用。


跑通第一对:按钮控灯

我们做最经典的演示:发送端接一个按钮,按下时给接收端发一个信号,接收端收到就点亮 LED。

两块板传的是结构化数据,所以两边都要定义一个一模一样的结构体。约定好结构,收发才对得上。

在 ESP-IDF 里,无论发送端还是接收端,都得先走一遍"把 WiFi 射频起到 STA 模式"的初始化。我们先把这段公共骨架拎出来——下面两份代码都会调它:

#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_wifi.h"
#include "esp_event.h"
#include "esp_netif.h"
#include "nvs_flash.h"
#include "esp_now.h"

// ESP-NOW 借 WiFi 的射频:必须先把 WiFi 起到 STA 并 start,但不连任何路由器
static void wifi_init_for_espnow(void) {
    ESP_ERROR_CHECK(nvs_flash_init());            // 射频校准数据存 NVS,起射频前必做
    ESP_ERROR_CHECK(esp_netif_init());
    ESP_ERROR_CHECK(esp_event_loop_create_default());
    wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
    ESP_ERROR_CHECK(esp_wifi_init(&cfg));
    ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));  // ESP-NOW 工作在 STA 模式
    ESP_ERROR_CHECK(esp_wifi_start());            // 关键:不连路由器也要 start,把射频点亮
}
📌 说明

注意这里和 WiFi 那篇的区别:那篇 esp_wifi_start() 之后还会 esp_wifi_connect() 去连路由器、等 IP_EVENT_STA_GOT_IP。ESP-NOW 这里esp_wifi_start() 就停——不连 AP、不拿 IP,只要射频活着、待在某个信道上就够了。

第一步:拿到接收端的 MAC 地址

发送端要知道往哪发,所以先得拿到接收端的 MAC。把这段烧进接收端那块板,打开 idf.py monitor 看一眼:

#include "esp_log.h"
#include "esp_mac.h"
#include "esp_wifi.h"
// 上面那段 wifi_init_for_espnow() 也贴进来

static const char *TAG = "mac";

void app_main(void) {
    wifi_init_for_espnow();                       // 起射频后才能读到 WiFi MAC
    uint8_t mac[6];
    ESP_ERROR_CHECK(esp_wifi_get_mac(WIFI_IF_STA, mac));   // 读 STA 接口的 MAC
    ESP_LOGI(TAG, "本机 MAC: %02X:%02X:%02X:%02X:%02X:%02X",
             mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
}

串口会打印类似 I (520) mac: 本机 MAC: 24:6F:28:AA:BB:CC 的一行。把这个地址抄下来,等下填进发送端。每块板的 MAC 都不一样,务必用你自己那块的真实值。

💡 提示

esp_wifi_get_mac() 读的是 WiFi 子系统当前用的 MAC(ESP-NOW 走的就是它)。还有个更底层的 esp_read_mac(mac, ESP_MAC_WIFI_STA),直接从 eFuse 读出厂烧的基址,不依赖 WiFi 是否 start——两个值在默认配置下一致。本篇统一用 esp_wifi_get_mac(),因为我们反正已经把 WiFi 起起来了。

第二步:发送端代码(按钮板)

按钮接在 GPIO 0 和 GND 之间(用内部上拉,按下读到低电平,这套接法在 按钮那篇 讲过)。把下面 peer_mac 里的值换成你上一步抄到的接收端 MAC。整段是一个完整的 main/main.c:

#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_wifi.h"
#include "esp_event.h"
#include "esp_netif.h"
#include "nvs_flash.h"
#include "esp_now.h"
#include "driver/gpio.h"

static const char *TAG = "sender";

// 换成你接收端那块板的真实 MAC
static uint8_t peer_mac[6] = {0x24, 0x6F, 0x28, 0xAA, 0xBB, 0xCC};

#define BUTTON_GPIO 0

// 两端必须字节级一致的数据结构
typedef struct {
    int command;   // 1 = 开灯, 0 = 关灯
} msg_t;

// ESP-NOW 借 WiFi 射频:先起 STA 并 start,不连路由器
static void wifi_init_for_espnow(void) {
    ESP_ERROR_CHECK(nvs_flash_init());
    ESP_ERROR_CHECK(esp_netif_init());
    ESP_ERROR_CHECK(esp_event_loop_create_default());
    wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
    ESP_ERROR_CHECK(esp_wifi_init(&cfg));
    ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
    ESP_ERROR_CHECK(esp_wifi_start());
}

// 发送结果回调:IDF 5.x 签名,确认有没有真发出去
static void send_cb(const uint8_t *mac, esp_now_send_status_t status) {
    ESP_LOGI(TAG, "发送状态: %s",
             status == ESP_NOW_SEND_SUCCESS ? "成功" : "失败");
}

void app_main(void) {
    wifi_init_for_espnow();

    // 按钮:输入 + 内部上拉,按下读到 0
    gpio_config_t io = {
        .pin_bit_mask = 1ULL << BUTTON_GPIO,
        .mode = GPIO_MODE_INPUT,
        .pull_up_en = GPIO_PULLUP_ENABLE,
    };
    ESP_ERROR_CHECK(gpio_config(&io));

    ESP_ERROR_CHECK(esp_now_init());                      // 射频已就绪,再初始化 ESP-NOW
    ESP_ERROR_CHECK(esp_now_register_send_cb(send_cb));   // 注册发送回执

    // 把接收端加为 peer
    esp_now_peer_info_t peer = {
        .channel = 0,             // 0 = 用当前信道
        .ifidx = WIFI_IF_STA,
        .encrypt = false,
    };
    memcpy(peer.peer_addr, peer_mac, 6);
    ESP_ERROR_CHECK(esp_now_add_peer(&peer));

    msg_t data;
    while (1) {
        if (gpio_get_level(BUTTON_GPIO) == 0) {           // 按钮按下
            data.command = 1;
            esp_now_send(peer_mac, (uint8_t *)&data, sizeof(data));
            vTaskDelay(pdMS_TO_TICKS(300));               // 简单消抖,避免一按发一串
        }
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

第三步:接收端代码(LED 板)

LED 接在 GPIO 2(很多 ESP32-S3 开发板的板载灯就在这一脚附近;具体看你板子丝印)。整段同样是一个完整的 main/main.c:

#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_wifi.h"
#include "esp_event.h"
#include "esp_netif.h"
#include "nvs_flash.h"
#include "esp_now.h"
#include "driver/gpio.h"

static const char *TAG = "recv";

#define LED_GPIO 2

// 与发送端字节级一致
typedef struct {
    int command;
} msg_t;

static void wifi_init_for_espnow(void) {
    ESP_ERROR_CHECK(nvs_flash_init());
    ESP_ERROR_CHECK(esp_netif_init());
    ESP_ERROR_CHECK(esp_event_loop_create_default());
    wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
    ESP_ERROR_CHECK(esp_wifi_init(&cfg));
    ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
    ESP_ERROR_CHECK(esp_wifi_start());
}

// 收到数据时被自动调用。IDF 5.x 签名:第一个参数是 recv_info,
// 发送方 MAC 在 info->src_addr 里
static void recv_cb(const esp_now_recv_info_t *info,
                    const uint8_t *data, int len) {
    if (len != sizeof(msg_t)) {
        ESP_LOGW(TAG, "收到长度异常的包:%d 字节,丢弃", len);
        return;
    }
    msg_t in;
    memcpy(&in, data, sizeof(in));                        // 回调里只做轻活:拷出来
    ESP_LOGI(TAG, "收到命令 %d (来自 %02X:%02X:%02X:%02X:%02X:%02X)",
             in.command,
             info->src_addr[0], info->src_addr[1], info->src_addr[2],
             info->src_addr[3], info->src_addr[4], info->src_addr[5]);
    gpio_set_level(LED_GPIO, in.command == 1 ? 1 : 0);
}

void app_main(void) {
    wifi_init_for_espnow();

    gpio_config_t io = {
        .pin_bit_mask = 1ULL << LED_GPIO,
        .mode = GPIO_MODE_OUTPUT,
    };
    ESP_ERROR_CHECK(gpio_config(&io));

    ESP_ERROR_CHECK(esp_now_init());
    ESP_ERROR_CHECK(esp_now_register_recv_cb(recv_cb));   // 注册接收回调
    ESP_LOGI(TAG, "接收端就绪,等数据…");
    // 主循环啥也不用干,收包全靠回调
    while (1) {
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}
💡 提示

这个 recv_cb 的签名——const esp_now_recv_info_t *info, const uint8_t *data, int len——是 ESP-IDF 5.x 的正式签名,info->src_addr 才能拿到发送方 MAC。这跟 网关那篇on_espnow_recv 是同一套签名,你后面搭网关时收包回调可以直接照搬。早期 IDF 4.x 的旧签名第一个参数是 const uint8_t *mac,如果你抄到老代码编译报参数不匹配,对照官方文档改成 5.x 这套即可。

你应该看到什么

两块板都烧好(idf.py build flash monitor)、都通电。打开接收端那块板的串口:

  • 接收端先打印 I (...) recv: 接收端就绪,等数据…
  • 按下发送端的按钮——接收端串口立刻滚出 I (...) recv: 收到命令 1 (来自 ...),LED 板上的灯马上亮起来,几乎感觉不到延迟。
  • 同时发送端串口会打印 I (...) sender: 发送状态: 成功
  • 这一切都和路由器、网络无关。你可以拔掉家里的路由器,甚至带到野外,只要两块板供电,照样通。

如果你把发送端逻辑改成"按一下发 1、再按一下发 0"交替,就能做成一个开关:一下亮、一下灭。


混合用法:一块板既走 ESP-NOW 又连 WiFi

实际项目里常有这种需求:院子里十个电池供电的传感器用 ESP-NOW 把数据发给一块"网关板",网关板再连 WiFi、把汇总数据上传到云。这样既享受了 ESP-NOW 的低功耗、免路由,又能最终上云。

技术上可行,但有个坑:ESP-NOW 用的信道必须和 WiFi 连接后的信道一致。 ESP32-S3 只有一套射频,同一时刻只能待在一个信道。网关板一旦 esp_wifi_connect() 连上路由器,它的信道就由路由器决定了(比如信道 6),这时所有给它发 ESP-NOW 的板子也必须在信道 6 上发,否则收不到。

所以网关那块板的 app_main 里顺序很讲究:wifi_init_sta() 连上 WiFi(信道就此定下),再 esp_now_init()——顺序反了,典型症状就是"网关一连上 WiFi,ESP-NOW 就再也收不到包了"。常见做法是用 esp_wifi_set_channel() 把信道钉死到一个固定值,让节点和网关严丝合缝对上。如果你打算搭这种网关,具体接法可以看 网关那篇,那里把这套顺序和信道对齐讲得很细。


故障排查表

现象 / 日志 可能原因 怎么查
esp_now_initESP_ERROR_CHECK WiFi 没先 start 确认 wifi_init_for_espnow()esp_wifi_start()esp_now_init() 之前调过
发送状态一直是"失败" MAC 地址填错 / peer 没加成功 核对 peer_mac 是不是接收端真实 MAC;看 esp_now_add_peer 有没有报错
发送显示"成功"但接收端收不到 两块板信道不一致 两块都不连 WiFi 时默认同信道;若一方连了 WiFi,用 esp_wifi_set_channel 对齐(见上节)
收到的包长度对不上、被丢弃 两端结构体定义不一致 recv_cblen != sizeof(msg_t) 这道闸就在拦它;检查 msg_t 两边字段、类型、顺序、对齐完全相同
接收端回调编译报参数不匹配 抄到 IDF 4.x 旧签名 改成 5.x 的 const esp_now_recv_info_t *info, ...(见上方提示框)
偶尔丢一两包 无线干扰 / 距离远 ESP-NOW 是"发了不保证收到";用 send_cb 看成功率,关键命令发两遍或加确认应答
单包发不出去、报 ESP_ERR_ESPNOW_ARG 负载超过 250 字节 ESP-NOW 单包负载上限 250 字节,大数据要自己分片,或改用 WiFi

两个常用变体

变体一:一对多广播

如果你想一块板同时给"附近所有 ESP-NOW 设备"喊话(比如一个总开关控全屋灯),可以用广播地址,省去逐个填 MAC。把 peer 的地址设成全 0xFF:

static uint8_t broadcast_mac[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};

esp_now_peer_info_t peer = {
    .channel = 0,
    .ifidx = WIFI_IF_STA,
    .encrypt = false,
};
memcpy(peer.peer_addr, broadcast_mac, 6);
ESP_ERROR_CHECK(esp_now_add_peer(&peer));
// 之后 esp_now_send(broadcast_mac, ...) 就是广播

所有同信道、跑着 ESP-NOW 接收的板子都会收到。代价是没法确认具体哪块收到了(广播没有针对性的成功回执),而且广播不能加密。

变体二:双向通信

ESP-NOW 是对等的,没有规定谁是"主"谁是"从"。只要两块板都同时 esp_now_register_send_cbesp_now_register_recv_cb,并互相把对方 esp_now_add_peer 加为 peer,就能你来我往。比如灯板收到开灯命令后,反过来给按钮板 esp_now_send 一个"已收到"的确认,按钮板上的指示灯亮一下表示送达——这就是最朴素的应答机制,比单向发送可靠得多。发送方的 MAC 在 recv_cbinfo->src_addr 里现成可取,正好拿来回发。


动手挑战:做一个无线遥控开关

把这一篇学到的拼起来,做一个真正能用的小东西:

  1. 遥控端(发送端):接一个按钮,实现"按一下发开、再按一下发关"的交替逻辑(提示:用一个 bool 变量记录当前状态,每次按下翻转它再 esp_now_send)。
  2. 执行端(接收端):收到命令后 gpio_set_level 控制 LED;有条件的话换成继电器,就能控真正的台灯。
  3. 加确认(进阶):用上面的双向通信变体,让执行端收到命令后回发一个确认,遥控端在自己的 recv_cb 里收到确认才让指示灯闪一下——这样你站在门口就知道屋里的灯到底亮没亮。

做完后试试把两块板拉开距离,看看在你家环境里能稳定通信到多远。把 send_cb 的成功/失败用 ESP_LOGI 打出来,你就有了一份真实的"可靠距离"数据。

本篇代码为参考实现,需结合你所用的最新 ESP-IDF 文档自校,尤其是 esp_now_recv_cb_t 的回调签名(5.x 第一个参数是 esp_now_recv_info_t *)、esp_now_peer_info_t 的字段随版本可能微调,以官方 esp_now 文档为准。


小结与下一步

ESP-NOW 让你跳过了路由器和 broker,用 MAC 地址在两块 ESP32-S3 之间直接、低延迟地传数据。它在 ESP-IDF 里的核心套路就四步:先把 WiFi 起到 STA 并 esp_wifi_start()(借射频)esp_now_init() 初始化 → esp_now_add_peer() 添加对方 → esp_now_send() 发数据;接收端只要 esp_now_register_recv_cb() 注册回调就能收。记住几个高频坑:WiFi 必须先 start、MAC 填对、信道一致、单包负载不超 250 字节

现在你能做点对点和一对多的设备通信了。如果你想把这些数据收上来、在网页上做成一个能实时看的面板,接着读 数据看板那篇;想把一群 ESP-NOW 节点的数据汇聚上云,看 网关那篇——那里的收包回调跟本篇是同一套签名。想回顾联网基础,回到 WiFi 那篇。完整的学习路径见 路线图

📄 来源 / 自校链接

本文为公开资料整理,非亲测。关键参数与代码请结合实物与下列官方来源验证。

内容有错、看不懂、或想看下一期?告诉我们 →

本文为公开资料的学习整理,非亲测。涉接线/花钱/合规的步骤请结合实物与官方最新资料验证,风险自负。见免责声明