Инструкции

ESP-NOW: связь двух ESP32 без роутера

12 мин чтения·Август 2026·RoboArm R1

Две платы могут общаться напрямую, без роутера и без Wi-Fi-сети. Протокол Espressif ESP-NOW соединяет устройства по MAC-адресам: нет подключения, нет получения адреса, нет ожидания в несколько секунд. Пакет уходит за миллисекунды, что делает протокол идеальным для пультов, датчиков на батарейках и связи между узлами робота.

Примеры проверены на пакете плат esp32 by Espressif Systems 3.x.

Чем это лучше Wi-Fi

Wi-FiESP-NOW
Нужен роутерДаНет
Время до первой отправкисекундымиллисекунды
Расход энергиивысокийнизкий
Размер сообщениялюбой250 байт (1470 во второй версии)
Дальностькак у Wi-Fiсравнима, иногда больше
Выход в интернетЕстьНет

Главный выигрыш — время. Датчику, который просыпается раз в полчаса, подключение к роутеру обходится в несколько секунд работы радио. С ESP-NOW он отправляет пакет и засыпает обратно почти мгновенно, и батарея живёт в разы дольше. Про режимы сна — в статье Питание ESP32 и режимы сна.

Узнать MAC-адрес

Адресация в ESP-NOW идёт по MAC, поэтому первым делом нужно узнать адрес приёмника:

#include <WiFi.h>

void setup() {
  Serial.begin(115200);
  WiFi.mode(WIFI_STA);
  Serial.print("MAC этой платы: ");
  Serial.println(WiFi.macAddress());
}

void loop() {
}

Адрес выглядит как A0:B7:65:12:34:56. Запишите его — он понадобится в скетче отправителя.

⚠️

У платы два MAC-адреса: у интерфейса клиента (WiFi.macAddress()) и у точки доступа (WiFi.softAPmacAddress()). Они разные. Указывать нужно адрес того интерфейса, через который реально пойдёт обмен — обычно это интерфейс клиента.

Отправитель

#include <WiFi.h>
#include <esp_now.h>

uint8_t receiverAddress[] = {0xA0, 0xB7, 0x65, 0x12, 0x34, 0x56};

struct Message {
  uint8_t axis;
  int16_t angle;
};

void setup() {
  Serial.begin(115200);

  WiFi.mode(WIFI_STA);

  if (esp_now_init() != ESP_OK) {
    Serial.println("Не удалось запустить ESP-NOW");
    return;
  }

  esp_now_peer_info_t peer = {};
  memcpy(peer.peer_addr, receiverAddress, 6);
  peer.channel = 0;          // 0 — текущий канал
  peer.encrypt = false;

  if (esp_now_add_peer(&peer) != ESP_OK) {
    Serial.println("Не удалось добавить получателя");
    return;
  }
}

void loop() {
  Message message = {0, 90};
  esp_err_t result = esp_now_send(receiverAddress, (uint8_t*) &message, sizeof(message));

  // ESP_OK — пакет принят в очередь, а не доставлен получателю
  Serial.println(result == ESP_OK ? "Поставлено в очередь" : "Ошибка отправки");
  delay(2000);
}

Данные передаются как есть — структуру можно отправить целиком, без сериализации. Главное, чтобы на обеих сторонах она была объявлена одинаково: тот же порядок и те же типы полей.

Вызов WiFi.mode(WIFI_STA) перед esp_now_init() обязателен: радио должно быть запущено до инициализации протокола.

Приёмник

#include <WiFi.h>
#include <esp_now.h>

struct Message {
  uint8_t axis;
  int16_t angle;
};

// Всё, что нужно знать о пакете: содержимое плюс адрес отправителя
struct ReceivedMessage {
  Message payload;
  uint8_t sender[6];
};

QueueHandle_t messageQueue;

void onDataReceived(const esp_now_recv_info_t* info, const uint8_t* data, int len) {
  // 1. Обязательно проверяем длину: короткий или битый пакет иначе
  //    заставит memcpy прочитать чужую память.
  if (len != sizeof(Message)) {
    return;
  }

  // 2. Складываем в очередь и сразу выходим. Никакой печати и обработки здесь.
  ReceivedMessage item;
  memcpy(&item.payload, data, sizeof(Message));
  memcpy(item.sender, info->src_addr, 6);
  xQueueSend(messageQueue, &item, 0);   // 0 — не ждать, если очередь полна
}

void setup() {
  Serial.begin(115200);
  WiFi.mode(WIFI_STA);

  messageQueue = xQueueCreate(10, sizeof(ReceivedMessage));

  if (esp_now_init() != ESP_OK) {
    Serial.println("Не удалось запустить ESP-NOW");
    return;
  }

  esp_now_register_recv_cb(onDataReceived);
}

void loop() {
  ReceivedMessage item;
  while (xQueueReceive(messageQueue, &item, 0) == pdTRUE) {
    Serial.printf("Ось %u, угол %d, от %02X:%02X:%02X:%02X:%02X:%02X\n",
                  item.payload.axis, item.payload.angle,
                  item.sender[0], item.sender[1], item.sender[2],
                  item.sender[3], item.sender[4], item.sender[5]);
  }
}
⚠️

Обработчик приёма выполняется внутри высокоприоритетной задачи Wi-Fi, а не в loop(). Документация Espressif требует, чтобы он только проверил пакет, скопировал данные и вышел. Печать в порт, разбор JSON, работа с файлами или моторами прямо в обработчике тормозят радиостек и рвут связь.

Второе обязательное правило — проверять len. Он приходит именно для того, чтобы вы не копировали в свою структуру пакет неизвестного размера: злонамеренный или просто битый пакет иначе приведёт к чтению за границей буфера.

Обратите внимание, почему здесь именно очередь, а не пара глобальных переменных с флагом. Обработчик и loop() выполняются в разных задачах, поэтому пока loop() читает структуру, обработчик может начать записывать в неё следующий пакет — и получится смесь двух сообщений. Атрибут volatile от этого не спасает: он запрещает компилятору кешировать значение, но не делает копирование составной структуры неделимым.

Очередь FreeRTOS решает обе проблемы разом: она копирует данные под собственной блокировкой и хранит несколько сообщений, если они пришли пачкой. Именно этот способ рекомендует и документация Espressif. Подробнее про очереди — в статье Два ядра ESP32 и FreeRTOS.

⚠️

Сигнатура колбэка приёма менялась. В ядре 2.x первым аргументом был const uint8_t* mac, в 3.x — структура const esp_now_recv_info_t* info, где адрес отправителя лежит в поле src_addr. Если пример из интернета не компилируется с ошибкой о несовместимом типе аргумента — вы нашли код под старую версию. Колбэк отправки в поздних версиях 3.x тоже сменил сигнатуру, так что подтверждение доставки лучше писать по документации своей версии.

Указатели внутри info действительны только пока выполняется колбэк — сохранять их наружу нельзя, нужно копировать значения.

Широковещательная рассылка

Чтобы отправить сообщение всем сразу, используется специальный адрес из одних единиц. Его тоже нужно добавить как получателя:

uint8_t broadcastAddress[] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};

esp_now_peer_info_t peer = {};
memcpy(peer.peer_addr, broadcastAddress, 6);
peer.channel = 0;
peer.encrypt = false;      // для широковещательной рассылки шифрование недоступно
esp_now_add_peer(&peer);

esp_now_send(broadcastAddress, (uint8_t*) &message, sizeof(message));

Удобно для сценария «пульт управляет несколькими устройствами»: одна отправка — все получатели услышали.

Ограничения

  • Размер пакета — 250 байт в первой версии протокола и до 1470 байт во второй. Если хоть одно устройство в сети работает по версии 1, ориентируйтесь на 250: это предел совместимости.
  • Число получателей — до 20 всего.
  • Шифрованных получателей меньше: около шести-семи при настройках по умолчанию.
  • ESP_OK от esp_now_send() не означает доставку. Это лишь подтверждение, что пакет принят в очередь на отправку. Факт ухода в эфир приходит отдельно, в колбэк отправки, а дошло ли сообщение до программы на той стороне — не знает никто: квитирование при необходимости делают сами.

Совмещение с обычным Wi-Fi

Здесь главная сложность, из-за которой проекты ломаются. Радиомодуль один, и он работает на одном канале за раз. Пока плата подключена к роутеру, канал диктует роутер, и произвольно выбрать свой для ESP-NOW уже нельзя.

Три рабочие стратегии:

Подстроиться под роутер. Подключиться к сети, узнать канал через WiFi.channel() и задать этот же канал всем узлам ESP-NOW. Самый надёжный путь, если шлюзу нужен интернет.

Развести по устройствам. Узлы работают только с ESP-NOW на фиксированном канале, а отдельная плата-шлюз занимается Wi-Fi и передаёт данные дальше. Проще в отладке и устойчивее.

Разнести по времени. Собрать данные по ESP-NOW, затем поднять Wi-Fi, отправить всё разом и выключить. Подходит для устройств с редкой отправкой.

ℹ️

Если узлы «то видят друг друга, то нет» — в девяти случаях из десяти дело именно в канале. Начните с того, чтобы явно задать один и тот же канал на всех платах через WiFi.setChannel() и убедиться, что ни одна из них не подключается к роутеру.

Новый интерфейс в ядре 3.x

Помимо привычных функций, в ядре 3.x появилась объектная обёртка — библиотека ESP_NOW с классами ESP_NOW_Class и ESP_NOW_Peer. Она скрывает работу с колбэками: получатель описывается классом-наследником, у которого переопределяются методы onReceive() и onSent().

Обёртка удобнее для проектов с несколькими типами узлов, но требует наследования: конструктор и методы отправки объявлены защищёнными, напрямую создать объект нельзя. Готовые примеры лежат в среде разработки — File → Examples → ESP_NOW.

Функции esp_now_init() и esp_now_send() при этом никуда не делись и работают по-прежнему: обёртка построена поверх них.

Что дальше

Рекомендуемые продукты

Связанные статьи