ESP-IDF+vscode开发ESP32 联网篇第四讲——ESP_NOW
目录
前言
前面的篇章中,我们学习了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 组成 | |
| FCS | 4 字节 | Wi-Fi MAC 层帧校验,由硬件/驱动处理 |
因此,在抓包工具(如 Wireshark 监控模式)中,通常会先看到一个 802.11 管理帧,类别表现为 Vendor Specific,而不是 IP、UDP 或普通 802.11 Data 帧。
这里的 24 字节 MAC 报头,是标准 802.11 管理帧的基本 MAC Header,通常可拆为 4 类、共 6 个逻辑字段。
| 字节偏移 | 长度 | 字段 |
|---|---|---|
| 0x00 | 2B | Frame Control(帧控制) |
| 0x02 | 2B | Duration/ID(持续时间/标识) |
| 0x04 | 6B | Address 1(地址 1) |
| 0x0A | 6B | Address 2(地址 2) |
| 0x10 | 6B | Address 3(地址 3) |
| 0x16 | 2B | Sequence 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 3 | FF: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.0 | ESP-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 信息 | 用于加密哪条链路 |
|---|---|---|
| G | peer_addr = T1_MAC,lmk = LMK_G_T1 | G <-> T1 |
peer_addr = T2_MAC,lmk = LMK_G_T2 | G <-> 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 信息 | 用于加密哪条链路 |
|---|---|---|
| T1 | peer_addr = G_MAC,lmk = LMK_G_T1 | G <-> T1 |
| T2 | peer_addr = G_MAC,lmk = LMK_G_T2 | G <-> 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_STA | STA | WIFI_IF_STA | 最常用;设备接入路由器,或只做 ESP-NOW 节点 |
WIFI_MODE_AP | SoftAP | WIFI_IF_AP | 设备开热点,同时用 ESP-NOW |
WIFI_MODE_APSTA | STA + SoftAP | WIFI_IF_STA 或 WIFI_IF_AP | 网关、配网设备、需要兼顾两种角色 |
WIFI_MODE_NAN | NAN | WIFI_IF_NAN | NAN 相关场景;不属于常规 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;
| 字段 | 大小 | 说明 |
|---|---|---|
version | 1 字节 | 协议版本号 ESPNOW_APP_VER = 2,收发双方需一致 |
len | 2 字节 | 负载长度;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 调用流程
espnow_init(mode, channel, power_save)— 协议栈初始化espnow_add_peers(mac, lmk, ifidx, channel, encrypt)— 添加对端espnow_send(data, len, peer_mac, time_ms)— 阻塞发送(带 ACK)espnow_recv_callback(info, data, data_len)— 接收统一入口send_ack(dst)— 回送 ACKespnow_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 从“能通信”升级为“可维护、可扩展、可信赖”的系统能力。
更多推荐


所有评论(0)