目录

前言

一、知识梳理

1.1 帧格式

1.1.1 供应商动作帧格式

1.1.2 MAC地址

单播

广播

地址为NULL

1.1.3 特定供应商元素

1.1.4 整体帧格式

1.2 安全

1.2.1 PMK和LMK密钥

1.2.2 总结

1.3 发送接口

1.3.1 与通信对端的关系

1.3.2 一个示例

二、使用案例

2.1 now.c

2.2 now.h

2.3 技术说明

2.3.1 应用层帧格式

2.3.2 核心同步机制:ack_sem

2.3.3 调用流程

2.4 发送端调用

2.5 接收端调用

2.6 结果展示

总结


前言

前面的篇章中,我们学习了WiFi通信中常用的STA和soft AP模式,这是行业的通用模式,本文来介绍一种乐鑫公司定义的无连接 Wi-Fi 通信协议。在 ESP-NOW 中,应用程序数据被封装在各个供应商的动作帧中,然后在无连接的情况下,从一个 Wi-Fi 设备传输到另一个 Wi-Fi 设备。要实现该通信至少需要两ESP32带WiFi通信的ESP32设备。

一、知识梳理

ESP-NOW 是乐鑫(Espressif)推出的一种轻量级无线通信协议,专为 ESP8266 和 ESP32 系列芯片设计。它允许设备之间在没有 Wi-Fi 连接的情况下直接进行点对点或一对多通信,具有低延迟、低功耗、无需配对等优点。

与传统的 Wi-Fi 通信相比,ESP-NOW 不需要建立 TCP/IP 连接,数据包可以直接在设备之间传输,因此非常适合传感器数据采集、遥控器、智能家居控制等物联网场景。

1.1 帧格式

官方资料给了两种帧格式,分别是《供应商的动作帧格式》和《特定供应商元素的帧格式》,但是看文档可能不太好理解,这里我解释一下。

两者是包含关系:供应商动作帧(Vendor-specific Action Frame)是 ESP-NOW 在空口上传输时使用的完整 802.11 管理帧;特定供应商元素(Vendor-specific Element / IE)则是这个完整帧中承载 ESP-NOW 数据的内部小单元。可以理解为“信封”和“信封里的一个或多个信件的关系”。

1.1.1 供应商动作帧格式

供应商动作帧格式是Wi-Fi MAC 层可见的完整无线帧,由 Espressif 按 IEEE 802.11 的“Vendor-specific Public Action”机制构造和发送。它使设备即便未建立 AP/STA 的关联连接,也能直接通过 MAC 地址通信。内容为:

字段大小作用
MAC 报头24 字节指明帧类型、源 MAC、目的 MAC 等;ESP-NOW 的 ToDS 和 FromDS 均为 0,第三地址为广播地址
分类代码1 字节标识供应商的特定类别,ESP-NOW 使用类别0x7E
组织标识符(OUI)3 字节标识协议归属组织;默认可使用乐鑫的 0x18fe34
随机值4 字节用于降低攻击者重放旧报文的风险
供应商特定内容ESP-NOW 的协议数据区,内部由一个或多个 Vendor-specific IE 组成
FCS4 字节Wi-Fi MAC 层帧校验,由硬件/驱动处理

因此,在抓包工具(如 Wireshark 监控模式)中,通常会先看到一个 802.11 管理帧,类别表现为 Vendor Specific,而不是 IP、UDP 或普通 802.11 Data 帧。

这里的 24 字节 MAC 报头,是标准 802.11 管理帧的基本 MAC Header,通常可拆为 4 类、共 6 个逻辑字段。

字节偏移长度字段
0x002BFrame Control(帧控制)
0x022BDuration/ID(持续时间/标识)
0x046BAddress 1(地址 1)
0x0A6BAddress 2(地址 2)
0x106BAddress 3(地址 3)
0x162BSequence Control(序列控制)

Frame Control 占 2 字节,其中包含协议版本、帧类型、子类型,以及 ToDS、FromDS、重试、受保护帧等标志位。ESP-NOW 的关键点是它属于管理帧(Management Frame),并以供应商特定动作帧承载数据;其 ToDS=0、FromDS=0。

1.1.2 MAC地址

ESP-NOW 是无连接通信,ESP-IDF 文档明确规定其 MAC 报头的 ToDS 和 FromDS 均为 0;地址 1 为目的地址,地址 2 为源地址,地址 3 固定填广播 MAC FF:FF:FF:FF:FF:FF。

字段ESP-NOW 填写内容含义
Address 1目标设备 MAC,或 FF:FF:FF:FF:FF:FF接收者/目的地址
Address 2本机 Wi-Fi 接口 MAC发送者/源地址
Address 3FF:FF:FF:FF:FF:FF固定广播地址
ToDS / FromDS都为 0不通过 AP 与分发系统 DS 转发

这里的 Address 3 虽然是广播 MAC,不代表该 ESP-NOW 帧一定是广播发送;真正决定单播或广播的是 Address 1。

单播

把目标设备的 MAC 作为 peer 添加到 peer list,然后把同一个 MAC 传给 esp_now_send()。此时帧的 Address 1 就是该设备地址。地址帧对应关系如下:

Address 1 = 24:6F:28:12:34:56    ← 对端设备,单播
Address 2 = 本机 MAC              ← 源地址
Address 3 = FF:FF:FF:FF:FF:FF     ← ESP-NOW 固定值

广播

先将广播 MAC FF:FF:FF:FF:FF:FF 添加为一个 peer,再以它作为 peer_addr 调用发送函数。驱动于是把全 FF 写入 Address 1,处于同一信道且能接收 ESP-NOW 的设备都可能收到。广播帧不能使用 ESP-NOW 的 LMK/CCMP 加密;官方文档说明当前不支持加密的组播供应商动作帧。广播通常适合设备发现、配网请求和周期性公告,而不是敏感控制指令。

Address 1 = FF:FF:FF:FF:FF:FF     ← ESP-NOW 固定值
Address 2 = 本机 MAC              ← 源地址
Address 3 = FF:FF:FF:FF:FF:FF     ← ESP-NOW 固定值

地址为NULL

它表示让 ESP-NOW 向当前 peer list 中的所有已添加 peer 分别发送数据;若列表中有多个单播 peer,会产生多次单播发送。官方 API 也明确说明:peer_addr != NULL 时发给对应 MAC 的 peer,peer_addr == NULL 时发给 peer list 中所有 peer。

调用方式Address 1空口行为典型用途
esp_now_send(peer_mac, ...)指定设备 MAC单播传感器上报、控制命令、加密通信
esp_now_send(broadcast_mac, ...)FF:FF:FF:FF:FF:FF真正广播发现、公告、配网
esp_now_send(NULL, ...)取决于每个 peer遍历 peer list,逐个发送向已知设备群发

应用程序通过 esp_now_send() 的 peer_addr 参数选择目的 MAC;Wi-Fi 驱动将其填入 MAC Header 的 Address 1,具体设备 MAC 表示单播,全 FF 广播 MAC 表示广播。

1.1.3 特定供应商元素

特定供应商元素是供应商动作帧的一种特定内容,单个元素的长度字段只有 1 字节,因此每个元素帧 的正文最多为 250 字节。内容为:

字段大小作用

元素 ID

1 字节表示这是一个供应商特定信息元素。
长度1 字节从 OUI 到正文的字节数,最大 255 字节;不包含 元素 ID 和长度自身。
组织标识符(OUI)3 字节再次指出这个元素归属组织,默认可使用乐鑫的 0x18fe34
类型4 字节ESP-NOW 为 4,用于区分 ESP-NOW 与其他同一厂商的自定义机制。
版本1字节标识 ESP-NOW v1.0 或 v2.0。
正文250字节对应与传给 esp_now_send() 的应用层数据。

v1.0 只使用一个特定供应商元素,所以单次应用数据最多 250 字节;v2.0 可以在同一个供应商动作帧中放置多个特定供应商元素,每个特定供应商元素仍最多携带 250 字节正文,因此总应用数据最多可达 1470 字节。

特性ESP-NOW v1.0ESP-NOW v2.0
单个特定供应商元素的正文最大 250 字节最大 250 字节
一个动作帧内的元素数量1 个可有多个
More data 位(更多数据位)无有,表示后面是否还有后续元素
单次 esp_now_send() 最大数据250 字节1470 字节

1.1.4 整体帧格式

所以整体发送的帧格式为:

IEEE 802.11 管理帧:供应商动作帧
├─ MAC 报头
├─ 分类代码(Category = 127,表示供应商特定动作帧)
├─ OUI(组织标识符,例如乐鑫 18:FE:34)
├─ 随机值(防重放)
├─ 供应商特定内容(Vendor Specific Content)
│  ├─ 特定供应商元素 IE #1
│  ├─ 特定供应商元素 IE #2        ← v2.0 的长数据可分段放在多个 IE
│  └─ 特定供应商元素 IE #N
└─ FCS(帧校验序列)

以上的这么多字段内容除了正文部分,其他部分几乎都由 Wi-Fi 驱动自动填充,你无法也不应该碰它。但个别帧里有些字段可通过 API 间接决定的,如下:

帧内字段由什么决定
组织标识符(OUI)

可使用函数esp_now_set_user_oui更改

目标 MAC 地址可使用函数esp_now_send更改
长度正文发送数据的长度
正文用户的主要发送内容

其他字段均无法更改

在v2.0版本中,发送的数据大于250字节但不超过单次上限 1470 字节时,应用只提交一块连续缓冲区;ESP-NOW 底层会自动把它拆入多个 Vendor-specific IE,并封装到同一个供应商动作帧中发送。应用层不需要自行构造 IE,也不需要多次调用 esp_now_send()。

同样接收端的 esp_now_recv_cb_t 回调不会把这些 IE 分段暴露给你。驱动会完成底层解析与重组。

1.2 安全

ESP-NOW 的“安全”可以从应用角度理解为三个问题:别人能否偷看你的数据、能否伪造指令、能否重放旧指令。ESP-NOW 的 CCMP 加密主要保护单播通信;你为 peer 设置相同的 LMK 后,驱动会自动加密和校验帧,无需你手工调用 AES。

假设你做一个 ESP32 智能控制门锁,如果不启用加密,附近任何能监听相同 Wi-Fi 信道的人都可能:

  • 窃听:抓取到“开锁”“关锁”或设备状态等明文数据。
  • 伪造:自己构造一帧看似来自遥控器的 ESP-NOW 包,尝试让门锁执行开锁。
  • 重放:录下你一次合法的“开锁”报文,之后重复发射该旧报文。

文档中的随机值用于降低动作帧被重放的风险;CCMP 则用于保护供应商动作帧的机密性与完整性。

1.2.1 PMK和LMK密钥

先说明:LMK 是“真正用于加密 ESP-NOW 单播数据”的通信密钥;PMK 是“用于保护设备内部保存的 LMK”的根密钥。 两者均为 16 字节,但作用层次不同。

假设网关 G 要与两个智能门锁 T1、T2 加密通信,应该使用两份不同 LMK:

设备内部保存的 peer 信息用于加密哪条链路
Gpeer_addr = T1_MAC,lmk = LMK_G_T1G <-> T1
peer_addr = T2_MAC,lmk = LMK_G_T2G <-> T2

G、T1、T2 都可设置一个本机 PMK,例如 PMK_G、PMK_T1、PMK_T2;各设备用自己的 PMK 去保护自己 Flash/驱动中保存的 LMK。PMK 不要求像 LMK 那样在一条通信链路的双方严格相同,因为它不直接参与空口帧的 CCMP 加密。官方文档说明:一个 Wi-Fi 设备维护一个 PMK 和多个 LMK,且每个配对设备拥有一个 LMK。

要实现加密单播,最关键要求是双方保存同一份 LMK,所以传感器 T1、T2内部 peer 信息为:

设备设备内部保存的 peer 信息用于加密哪条链路
T1peer_addr = G_MAC,lmk = LMK_G_T1G <-> T1
T2peer_addr = G_MAC,lmk = LMK_G_T2G <-> T2

并且双方 peer 的 encrypt 均设置为 true。如果某个 peer 没有配置 LMK,则对应供应商动作帧不加密;

因此,即便攻击者知道门锁 MAC 地址,也不能只靠 esp_now_send() 发送一段明文 "UNLOCK" 就让设备接受它;没有正确密钥的帧无法通过底层的加密校验。

密钥应用层理解实际作用
PMK,16 字节用于保护设备内部保存的 peer 密钥IDF 使用 PMK 通过 AES-128 加密 LMK;不设置时会使用默认 PMK。
LMK,16 字节A 与 B 的通信密码启用 peer 加密时,用于 CCMP 加密 ESP-NOW 单播动作帧。

最重要的是 LMK 必须由你生成,且两端一致。PMK 也应显式设置为产品自己的随机值,而不应依赖默认 PMK。

广播的 Address 1 是 FF:FF:FF:FF:FF:FF,一个包可能由多台未知设备接收。ESP-NOW 当前不支持对这种组播/广播供应商动作帧进行加密,因此广播应仅用于设备发现、能力公告、配网入口等不敏感信息。

1.2.2 总结

ESP-NOW 的 peer list 本质上是本机发送配置表,其中保存目标 MAC、Wi-Fi 接口、信道、是否加密及 LMK 等参数。官方文档明确要求:向其他设备发送数据前,先用 esp_now_add_peer() 将其加入列表;若目标未在列表中,esp_now_send() 会返回 ESP_ERR_ESPNOW_NOT_FOUND。

对于接收端,若仅接收 ESP-NOW 广播包或未加密单播包,可以不调用 esp_now_add_peer();但若要接收加密单播包,必须将发送方加入 peer list,并配置与发送方一致的 LMK。对于发送端,无论发送广播、未加密单播还是加密单播,都应先将目标 MAC 地址加入 peer list;其中加密单播还必须启用 encrypt 并设置 LMK。

本机要做的事是否需要 esp_now_add_peer()原因
接收广播包否接收端可直接收到广播 ESP-NOW 数据。
接收未加密单播包否未登记对端也允许接收未加密单播。
接收加密单播包是必须有发送方对应的 peer 条目,并配置相同 LMK,才能解密与校验。
发送未加密单播包是发送端须知道目标 peer 的 MAC、接口和信道等配置。
发送广播包是应将广播 MAC FF:FF:FF:FF:FF:FF 作为一个未加密 peer 加入列表。
发送加密单播包是必须添加 peer,并设置 encrypt = true 与正确 LMK。

通信距离:

乐鑫官方给出的基准是"空旷环境 200 米+"(+21 dBm、板载天线、1 Mbps 默认速率),理想条件下单跳可接近 500 米,并可通过多跳转发进一步扩展覆盖;室内穿墙场景距离会明显缩短。

1.3 发送接口

在esp_now初始化前还要进行esp_wifi初始化,其中函数esp_wifi_set_mode设置的模式决定本机启用哪些 Wi-Fi 接口;esp_now_add_peer() 中的 peer.ifidx 则从这些已启用接口中选一个,作为向该 peer 发送 ESP-NOW 帧的出口。

注意,ifidx 不是“对端的接口类型”。例如 A 使用 STA 接口发送、B 使用 SoftAP 接口接收,完全可以正常通信;两台设备只要在同一无线信道上即可。可供选择表格如下:

esp_wifi_set_mode()当前启用的接口peer.ifidx 合法选择常见 ESP-NOW 用法
WIFI_MODE_STASTAWIFI_IF_STA最常用;设备接入路由器,或只做 ESP-NOW 节点
WIFI_MODE_APSoftAPWIFI_IF_AP设备开热点,同时用 ESP-NOW
WIFI_MODE_APSTASTA + SoftAPWIFI_IF_STA 或 WIFI_IF_AP网关、配网设备、需要兼顾两种角色
WIFI_MODE_NANNANWIFI_IF_NANNAN 相关场景;不属于常规 ESP-NOW 首选配置

若在 WIFI_MODE_STA 下把 peer.ifidx 配为 WIFI_IF_AP,相当于要求一个没有启用的 AP 接口发包,属于错误接口选择;官方 Wi-Fi API 对接口与当前模式不匹配的情况会返回 ESP_ERR_WIFI_IF。

1.3.1 与通信对端的关系

设设备 A 和 B 都只运行 ESP-NOW,不连接路由器,推荐都使用 STA 模式,例如

设备 A:WIFI_MODE_STA,peer.ifidx = WIFI_IF_STA,信道 1
设备 B:WIFI_MODE_STA,peer.ifidx = WIFI_IF_STA,信道 1

这时发送和接收两端 ifidx 相同,是因为两端都采用相同、最简单的配置,不是协议强制要求它们相同。ESP-NOW 是无连接通信,但 Wi-Fi 射频在某一时刻仍只能在特定信道收发;因此 A 发出的信道必须等于 B 当前监听的信道。当然也可以是:

设备 A:WIFI_MODE_STA,peer.ifidx = WIFI_IF_STA,信道 6
设备 B:WIFI_MODE_AP, peer.ifidx = WIFI_IF_AP, 信道 6

它们的接口类型不同,但同处信道 6,仍可以进行 ESP-NOW 通信。

注意,channel 比 ifidx 更关键

peer.channel 的语义是:给这个 peer 收发 ESP-NOW 时使用的 Wi-Fi 信道。设为 0 表示使用所选 STA 或 AP 接口的当前信道;设为 1 至 14 等固定值时,必须与该接口当前所在信道一致,否则无法正常通信。

函数esp_wifi_set_channel() 设置的是本机 Wi‑Fi 实际工作信道;peer.channel 描述目标 peer 所在信道。当 peer.channel 为 1~14 时,它必须与本机当前工作信道相同,否则 ESP-NOW 无法发送并可能返回 ESP_ERR_ESPNOW_CHAN。当 peer.channel = 0 时,ESP-NOW 自动使用本机当前信道;但通信对端仍必须处于相同信道。

1.3.2 一个示例

假设有以下这么一个工作模式:ESP设备A通过STA连接路由器B,同时通过ESPNOW连接ESP设备C;ESP设备C再同时使用softAP供手机连接。

这个拓扑中,全网只能使用一个 2.4 GHz Wi‑Fi 信道:由路由器决定。设备 A 的 STA 已连接路由器后,A 的 ESP‑NOW 必须跟随路由器信道;设备 C 的 SoftAP 也必须配置到同一信道,手机连接 C 的热点后自然也在该信道上。ESP‑NOW 数据不会经由路由器转发,A 与 C 仍是直接空口通信。必须满足:

路由器信道 = A 的 STA 当前信道 = A和C 的 ESP-NOW 信道 = C 的 SoftAP 信道 = 手机连接 C 后所在信道

A 需要连接路由器,所以采用 WIFI_MODE_STA,接口为 WIFI_IF_STA。假设路由器最终工作在信道 6,则 A 的当前 STA 信道就是 6;给 A 添加 peer 时,将 peer.channel 配为 0 最稳妥,使其自动跟随 A 当前 STA 信道。这里不要在 A 已连上路由器后再调用 esp_wifi_set_channel 函数试图强制换信道。A 必须保持在路由器选择的信道,否则 STA 关联会受影响,ESP-NOW 也无法继续稳定收发。

C 要开热点让手机连接,因此采用 WIFI_MODE_AP,peer.ifidx 使用 WIFI_IF_AP。关键点是:必须先获知 A 所连路由器的信道,再用同一个信道创建 C 的 SoftAP。

这个方案的限制不是 ESP-NOW,而是设备 C 的热点信道必须迁就 A 所连路由器的信道。若路由器自动换信道,A 与 C 的 ESP-NOW 会立刻失联。要解决这个问题需要固定路由器信道或这让设备C有检测信道换道的能力,所以现实中一般不采用这种方式去通信。

二、使用案例

前面说了那么多理论知识,下面来看一些具体的使用案例。

2.1 now.c

#include "esp_crc.h"
#include "esp_mac.h"
#include "now.h"
#include "wifi_event.h"
#include "esp_now.h"

/** @brief 日志标签 */
static const char *TAG = "WIFI_NOW";

/** @brief 广播 MAC 地址, 作为 espnow_send() 的 peer_mac 实现广播 */
uint8_t broadcast_mac[ESP_NOW_ETH_ALEN] = { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF };

/** @brief MAC 地址格式化: ESP_LOGI("MAC: " MAC_FMT, MAC_ARG(addr)) */
#define MAC_FMT "%02X:%02X:%02X:%02X:%02X:%02X"
#define MAC_ARG(m) (m)[0],(m)[1],(m)[2],(m)[3],(m)[4],(m)[5]

/* ====================================================================
 *  全局状态
 * ==================================================================== */

/**
 * @brief ACK 同步信号量 (二值)
 *
 * espnow_send() 发出数据帧后 xSemaphoreTake 阻塞于此,
 * espnow_recv_callback() 收到 ACK 帧后 xSemaphoreGive 唤醒。
 */
static SemaphoreHandle_t ack_sem = NULL;

/* ====================================================================
 *  内部收发函数
 * ==================================================================== */
/**
 * @brief 发送 ACK 帧
 *
 * 组装仅含帧头 (ver + len, 3 字节) 的控制帧, 不携带任何数据负载。
 *
 * @param dst 目的 MAC 地址
 */
static void send_ack(const uint8_t *dst)
{
    uint8_t buf[ESPNOW_APP_HEADER_LEN];
    esp_now_app_frame_t *f = (esp_now_app_frame_t *)buf;

    memset(buf, 0, sizeof(buf));
    f->version = ESPNOW_APP_VER;
    f->len     = 0;                              /* 0 = ACK */
    esp_now_send(dst, buf, ESPNOW_APP_HEADER_LEN);
}

/* ====================================================================
 *  ESP-NOW 官方回调
 * ==================================================================== */

/**
 * @brief ESP-NOW 硬件发送完成回调
 *
 * 由 Wi-Fi 驱动在帧实际从射频发出后调用, 仅记录日志。
 *
 * @note 此回调 ≠ 应用层 ACK 确认。ACK 由 espnow_recv_callback() 异步处理。
 *
 * @param ti 发送信息 (目的 MAC / 数据长度 / PHY 速率 / 状态)
 * @param st 发送结果
 */
void espnow_send_callback(const esp_now_send_info_t *ti, esp_now_send_status_t st)
{
    if (st == ESP_NOW_SEND_SUCCESS) {
        ESP_LOGD(TAG, "TX OK -> " MAC_FMT " len=%u", MAC_ARG(ti->des_addr), ti->data_len);
    } else {
        ESP_LOGW(TAG, "TX FAIL -> " MAC_FMT, MAC_ARG(ti->des_addr));
    }
}

/**
 * @brief ESP-NOW 接收回调 (官方接口)
 *
 * 所有空中帧的统一入口:
 *   - ACK 帧 (len == 0) → 释放 ack_sem, 唤醒阻塞中的 espnow_send()
 *   - 数据帧 (len > 0)  → 回复 ACK → 打印数据
 *
 * @param info     接收元信息 (源 MAC / 目的 MAC / 接收控制)
 * @param data     指向帧数据 (即 esp_now_app_frame_t)
 * @param data_len 帧总长 (字节)
 */
void espnow_recv_callback(const esp_now_recv_info_t *info, const uint8_t *data, int data_len)
{
    const esp_now_app_frame_t *f = (const esp_now_app_frame_t *)data;

    /* ACK 帧 → 唤醒发送方 */
    if (f->len == 0) {
        if (ack_sem) xSemaphoreGive(ack_sem);
        return;
    }

    /* 数据帧 → 回复 ACK → 打印数据 */
    send_ack(info->src_addr);
    ESP_LOGI(TAG, "RX <- " MAC_FMT " len=%u", MAC_ARG(info->src_addr), f->len);
    printf("接受的数据为:");
    for (int i = 0; i < f->len; i++) {
        printf("%c", f->data[i]);
    }
    printf("\n");
}

/* ====================================================================
 *  公开 API
 * ==================================================================== */

/**
 * @brief 初始化 ESP-NOW 协议栈
 *
 * 创建 ACK 信号量, 初始化并启动 Wi-Fi, 注册收发回调, 配置 PMK。
 * 函数内部已包含 Wi-Fi 启动流程, 调用前无需手动 esp_wifi_start()。
 *
 * @param mode       Wi-Fi 模式 (WIFI_MODE_STA / WIFI_MODE_AP / WIFI_MODE_APSTA)
 * @param channel    Wi-Fi 信道 (1 ~ 14)
 * @param power_save true 启用 ESP-NOW 低功耗 (缩短 DTIM 唤醒窗口)
 *
 * @return 0  成功
 * @return -1 信号量创建失败
 */
int espnow_init(wifi_mode_t mode, uint8_t channel, bool power_save)
{
    ack_sem = xSemaphoreCreateBinary();
    if (!ack_sem) {
        ESP_LOGE(TAG, "create semaphore failed");
        return ESP_FAIL;
    }

    /* 1. 初始化 NVS(分区损坏或版本不兼容时擦除重建) */
    esp_err_t ret = nvs_flash_init();
    if (ret == ESP_ERR_NVS_NO_FREE_PAGES ||
        ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
        ESP_ERROR_CHECK(nvs_flash_erase());
        ret = nvs_flash_init();
    }
    ESP_ERROR_CHECK(ret);

    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_storage(WIFI_STORAGE_RAM));
    ESP_ERROR_CHECK(esp_wifi_set_mode(mode));
    ESP_ERROR_CHECK(esp_wifi_start());
    ESP_ERROR_CHECK(esp_wifi_set_channel(channel, WIFI_SECOND_CHAN_NONE));

    ESP_ERROR_CHECK(esp_now_init());
    ESP_ERROR_CHECK(esp_now_register_send_cb(espnow_send_callback));
    ESP_ERROR_CHECK(esp_now_register_recv_cb(espnow_recv_callback));

    if (power_save) {
        ESP_ERROR_CHECK(esp_now_set_wake_window(50));
        ESP_ERROR_CHECK(esp_wifi_connectionless_module_set_wake_interval(100));
    }
    ESP_ERROR_CHECK(esp_now_set_pmk((uint8_t *)CONFIG_ESPNOW_PMK));

    uint8_t mac[6];
    ESP_ERROR_CHECK(esp_wifi_get_mac(mode - 1, mac));
    ESP_LOGI(TAG, "本机当前模式MAC: "MAC_FMT, MAC_ARG(mac));

    return ESP_OK;
}

/**
 * @brief 添加通信对端
 *
 * 广播地址只需添加一次; espnow_send() 通过 peer_mac 指定目标。
 *
 * @param mac     对端 MAC 地址 (6 字节), 广播用 broadcast_mac
 * @param lmk     本地主密钥 (16 字节), 未加密时可填全 0
 * @param ifidx   Wi-Fi 接口 (WIFI_IF_STA / WIFI_IF_AP)
 * @param channel 通信信道
 * @param encrypt 是否启用 CCMP 加密
 *
 * @return 0  成功
 * @return -1 内存分配或 ESP-NOW 内部错误
 */
int espnow_add_peers(const uint8_t *mac, const uint8_t *lmk,
                     wifi_interface_t ifidx, uint8_t channel, bool encrypt)
{
    esp_now_peer_info_t *p = calloc(1, sizeof(esp_now_peer_info_t));
    if (!p) 
        return ESP_FAIL;
    p->channel = channel;
    p->ifidx   = ifidx;
    p->encrypt = encrypt;
    memcpy(p->peer_addr, mac, ESP_NOW_ETH_ALEN);
    memcpy(p->lmk, lmk, ESP_NOW_KEY_LEN);
    ESP_ERROR_CHECK(esp_now_add_peer(p));
    free(p);
    return ESP_OK;
}

/**
 * @brief 发送数据 (阻塞等待 ACK)
 *
 * 组装帧并发出, 随后阻塞等待接收方 ACK, 超时时间由 time_ms 指定。
 * 单次调用数据量不得超过 1467 字节, 超限直接报错, 由调用者自行拆分后多次调用。
 *
 * @param data     待发送数据
 * @param len      数据长度 (1 ~ 1467 字节)
 * @param peer_mac 目的 MAC 地址, 广播用 broadcast_mac
 * @param time_ms  等待 ACK 超时 (毫秒)
 *
 * @return ESP_OK              发送成功 (收到 ACK)
 * @return ESP_ERR_INVALID_ARG 参数无效 (data==NULL / len==0 / len>1467)
 * @return ESP_ERR_INVALID_STATE 未调用 espnow_init
 * @return ESP_FAIL            ACK 超时 (time_ms 毫秒内未收到)
 */
esp_err_t espnow_send(const uint8_t *data, size_t len, const uint8_t *peer_mac, uint32_t time_ms)
{
    esp_err_t err;
    if (data == NULL || len == 0) {
        ESP_LOGE(TAG, "send failed: invalid arg");
        return ESP_ERR_INVALID_ARG;
    }
    if (len > ESPNOW_APP_MAX_DATA) {
        ESP_LOGE(TAG, "send failed: len %u > max %u", len, ESPNOW_APP_MAX_DATA);
        return ESP_ERR_INVALID_ARG;
    }
    if (ack_sem == NULL) {
        ESP_LOGE(TAG, "send failed: not initialized");
        return ESP_ERR_INVALID_STATE;
    }

    xSemaphoreTake(ack_sem, 0);                       /* 清除残留信号量 */

    uint8_t buf[ESPNOW_APP_HEADER_LEN + len];
    esp_now_app_frame_t *f = (esp_now_app_frame_t *)buf;
    f->version = ESPNOW_APP_VER;
    f->len     = len;
    memcpy(f->data, data, len);
    err = esp_now_send(peer_mac, buf, ESPNOW_APP_HEADER_LEN + len);
    switch(err)
    {
        case ESP_ERR_ESPNOW_NO_MEM:
            ESP_LOGE(TAG, "内存不足,请稍等后再发送");
            break;
        case ESP_ERR_ESPNOW_NOT_FOUND:
            ESP_LOGE(TAG, "对端未添加,请先调用 espnow_add_peers");
            break;
        case ESP_ERR_ESPNOW_IF:
            ESP_LOGE(TAG, "当前 Wi-Fi 接口与对端不匹配");
            break;
        case ESP_ERR_ESPNOW_CHAN:
            ESP_LOGE(TAG, "当前 Wi-Fi 信道与对端不匹配");
            break;
        case ESP_OK:
            if (xSemaphoreTake(ack_sem, pdMS_TO_TICKS(time_ms)) != pdTRUE) {
                ESP_LOGW(TAG, "ACK timeout");
            }
    }
    return err;
}

2.2 now.h

/**
 * @file    now.h
 * @brief   ESP-NOW 应用层协议帧定义与 API
 *
 * 单帧发送 + 应答确认, 无应用层 CRC (完整性依赖 Wi-Fi 链路层 FCS)。
 * 超过 @ref ESPNOW_APP_MAX_DATA 的数据由用户自行拆分后多次调用 espnow_send()。
 *
 * 帧格式 (3 字节帧头 + 可变长数据):
 *   [ver(1)|len(2)][data(len)]
 *
 *   len > 0: 数据帧
 *   len = 0: ACK 帧
 */

#ifndef __NOW_H
#define __NOW_H

#include <stdint.h>
#include <stddef.h>
#include "esp_err.h"
#include "esp_now.h"
#include "esp_wifi.h"

/* ------------------------------------------------------------------ */
/*  协议常量                                                           */
/* ------------------------------------------------------------------ */

/** @brief 协议版本, 收发双方必须一致 */
#define ESPNOW_APP_VER            2

/**
 * @brief 帧头长度 (不含 data)
 *        ver(1) + len(2) = 3 字节
 */
#define ESPNOW_APP_HEADER_LEN     3

/** @brief 单帧最大数据负载: 1470 - 3 = 1467 字节 */
#define ESPNOW_APP_MAX_DATA       (ESP_NOW_MAX_DATA_LEN_V2 - ESPNOW_APP_HEADER_LEN)

/** @brief 默认 PMK 密钥 (16 字节), 用于加密 LMK */
#define CONFIG_ESPNOW_PMK         "pmk1234567890123"
/** @brief 广播 MAC 地址 (FF:FF:FF:FF:FF:FF), 作为 espnow_send() 的 peer_mac 实现广播 */
extern uint8_t broadcast_mac[ESP_NOW_ETH_ALEN];

/* ------------------------------------------------------------------ */
/*  帧结构体                                                           */
/* ------------------------------------------------------------------ */

/**
 * @brief 应用层统一帧结构
 *
 * 数据帧和 ACK 帧共用此结构体, 由 len 字段区分。
 */
typedef struct {
    uint8_t  version;       /**< 协议版本, @ref ESPNOW_APP_VER */
    uint16_t len;           /**< data[] 长度, 0 表示 ACK 帧 */
    uint8_t  data[0];       /**< 负载数据, 变长 */
} __attribute__((packed)) esp_now_app_frame_t;

/* ------------------------------------------------------------------ */
/*  API                                                               */
/* ------------------------------------------------------------------ */

/**
 * @brief 初始化 ESP-NOW 协议栈
 *
 * 创建 ACK 信号量, 初始化并启动 Wi-Fi, 注册收发回调, 配置 PMK。
 * 函数内部已包含 Wi-Fi 启动流程, 调用前无需手动 esp_wifi_start()。
 *
 * @param mode       Wi-Fi 模式 (WIFI_MODE_STA / WIFI_MODE_AP / WIFI_MODE_APSTA)
 * @param channel    Wi-Fi 信道 (1 ~ 14)
 * @param power_save true 启用低功耗模式 (缩短唤醒窗口)
 *
 * @return 0 (ESP_OK) 成功
 * @return 非 0 (ESP_FAIL) 信号量创建失败
 */
int espnow_init(wifi_mode_t mode, uint8_t channel, bool power_save);

/**
 * @brief 添加通信对端
 *
 * 广播地址 (FF:FF:FF:FF:FF:FF) 只需添加一次; espnow_send()
 * 通过 peer_mac 指定目标, 广播时传入 broadcast_mac。
 *
 * @param mac     对端 MAC 地址 (6 字节)
 * @param lmk     本地主密钥 (16 字节, 未加密时可填全 0)
 * @param ifidx   Wi-Fi 接口类型 (WIFI_IF_STA / WIFI_IF_AP)
 * @param channel 通信信道
 * @param encrypt true 启用 CCMP 加密
 *
 * @return 0 成功
 * @return 非 0 失败 (内存不足或 ESP-NOW 内部错误)
 */
int espnow_add_peers(const uint8_t *mac, const uint8_t *lmk,
                     wifi_interface_t ifidx, uint8_t channel, bool encrypt);

/**
 * @brief 发送数据 (阻塞, 等待 ACK)
 *
 * 组装帧头 + 数据后发出, 阻塞等待接收方 ACK, 超时时间由 time_ms 指定。
 * 单次调用数据量不得超过 @ref ESPNOW_APP_MAX_DATA (1467 字节),
 * 超限返回 ESP_ERR_INVALID_ARG, 由调用者自行拆分。
 *
 * @param data     待发送数据
 * @param len      数据长度 (1 ~ 1467 字节)
 * @param peer_mac 目的 MAC 地址, 广播用 broadcast_mac
 * @param time_ms  等待 ACK 超时 (毫秒)
 *
 * @return ESP_OK              发送成功 (收到 ACK)
 * @return ESP_ERR_INVALID_ARG 参数无效 (data == NULL / len == 0 / len > 1467)
 * @return ESP_ERR_INVALID_STATE 未初始化 (需先调用 espnow_init)
 * @return ESP_FAIL            ACK 超时 (time_ms 毫秒内未收到)
 */
esp_err_t espnow_send(const uint8_t *data, size_t len, const uint8_t *peer_mac, uint32_t time_ms);

#endif

2.3 技术说明

以上 now.c 和 now.h 是一套封装在 ESP-NOW 底层传输之上的应用层可靠通信协议,核心目标:

  • 提供带 ACK 确认的单帧发送(解决原生 esp_now_send() 只保证"射频发出"、不保证"对端收到"的问题);
  • 通过自定义帧头区分数据帧与 ACK 帧;
  • 统一封装初始化、加 peer、发送三个接口,供上层(main.c)直接调用。

2.3.1 应用层帧格式

typedef struct {
    uint8_t  version;   // 协议版本, 必须一致
    uint16_t len;       // data[] 长度, 0 = ACK 帧
    uint8_t  data[0];   // 变长负载
} __attribute__((packed)) esp_now_app_frame_t;
字段大小说明
version1 字节协议版本号 ESPNOW_APP_VER = 2,收发双方需一致
len2 字节负载长度;len == 0 表示 ACK 帧,len > 0 表示数据帧
data变长实际数据,最大 1467 字节(ESP_NOW_MAX_DATA_LEN_V2 - 3)

data[0] 是柔性数组成员,结构体本身只占 3 字节(packed 取消对齐),实际内存由发送方按 3 + len 分配。

2.3.2 核心同步机制:ack_sem

static SemaphoreHandle_t ack_sem = NULL;  // 二值信号量

这是整个协议的心脏:

  • 发送方:espnow_send() 发出数据帧后,调用 xSemaphoreTake(ack_sem, timeout) 阻塞等待;
  • 接收方:espnow_recv_callback() 收到 ACK 帧后,调用 xSemaphoreGive(ack_sem) 唤醒发送方。

由此实现"发一帧 → 等确认 → 超时判定"的同步模型。

2.3.3 调用流程

  1. espnow_init(mode, channel, power_save) — 协议栈初始化
  2. espnow_add_peers(mac, lmk, ifidx, channel, encrypt) — 添加对端
  3. espnow_send(data, len, peer_mac, time_ms) — 阻塞发送(带 ACK)
  4. espnow_recv_callback(info, data, data_len) — 接收统一入口
  5. send_ack(dst) — 回送 ACK
  6. espnow_send_callback(ti, st) — 硬件发送完成回调

完整收发时序如下

2.4 发送端调用

#include <stdio.h>
#include "basic.h"
#include "station.h"
#include "soft_ap.h"
#include "now.h"

#define     LMK     "LMK1234567890123"  // Local Master Key (16 bytes)
static uint8_t peer_mac_dev[ESP_NOW_ETH_ALEN] = { 0x20, 0x6e, 0xf1, 0x11, 0x1F, 0x8C };
// ESP-NOW 发送任务: 每 2000ms 发送一次数据
static void espnow_send_task(void *arg)
{
    while (1)
    {
        espnow_send((const uint8_t *)"Hello ESP-NOW-DEV, I'm is ESP-NOW-Touch!", 40, peer_mac_dev, 1000);
        vTaskDelay(pdMS_TO_TICKS(2000));
    }
}

// ESP-NOW-Touch
void app_main(void)
{
    esp_log_level_set("wifi", ESP_LOG_WARN); // 设置 WiFi 模块日志级别为 ERROR,减少日志输出
    CONSOLE_REPL_INIT(); // 初始化控制台 REPL 交互环境
    espnow_init(WIFI_MODE_STA, 6, false);
    espnow_add_peers(peer_mac_dev, (const uint8_t *)LMK, WIFI_IF_STA, 6, true);
    xTaskCreate(espnow_send_task, "espnow_send_task", 4096, NULL, 5, NULL);
    while(1)
    {
        vTaskDelay(pdMS_TO_TICKS(1000));
    } 
}

2.5 接收端调用

#include <stdio.h>
#include "basic.h"
#include "station.h"
#include "soft_ap.h"
#include "now.h"

#define     LMK     "LMK1234567890123"  // Local Master Key (16 bytes)
static uint8_t peer_mac_touch[ESP_NOW_ETH_ALEN] = { 0xAC, 0xEB, 0xE6, 0x3A, 0xBA, 0xC8 };
// ESP-NOW 发送任务: 每 2000ms 发送一次数据
static void espnow_send_task(void *arg)
{
    while (1)
    {
        espnow_send((const uint8_t *)"Hello ESP-NOW-Touch, I'm is ESP-NOW-DEV!", 40, peer_mac_touch, 1000);
        vTaskDelay(pdMS_TO_TICKS(2000));
    }
}

// ESP32-C6-DEV
void app_main(void)
{
    esp_log_level_set("wifi", ESP_LOG_WARN); // 设置 WiFi 模块日志级别为 ERROR,减少日志输出
    CONSOLE_REPL_INIT(); // 初始化控制台 REPL 交互环境
    espnow_init(WIFI_MODE_STA, 6, false);
    espnow_add_peers(peer_mac_touch, (const uint8_t *)LMK, WIFI_IF_STA, 6, true);
    xTaskCreate(espnow_send_task, "espnow_send_task", 4096, NULL, 5, NULL);
    while(1)
    {
        vTaskDelay(pdMS_TO_TICKS(1000));
    }                 
}

发送端和接收端调用方式,只有对方MAC地址不同,采用的是单对单加密传输,来看一下传输结果

2.6 结果展示

左图为接收端,右图为发送端。

总结

从系统设计角度看,ESP-NOW 并非传统 Wi-Fi 联网的替代品,而是面向近距离、低时延设备协同的一种补充通信链路。它省去了 AP 关联和 TCP/IP 协议栈开销,但仍受制于单信道射频、Peer 数量、空口干扰及数据长度等约束,因此应根据业务的重要性划分通信策略:广播用于发现与公告,单播加密用于控制与敏感数据,关键指令则需在应用层加入 ACK、超时、重试、序号和幂等处理。本文的封装只是可靠传输的基础;若走向实际产品,还应进一步考虑密钥安全下发、设备身份认证、异常恢复、信道变更和多节点调度,使 ESP-NOW 从“能通信”升级为“可维护、可扩展、可信赖”的系统能力。

更多推荐