Embedded HandbookПриложения
Все разделы › XVIII. ESP32: второй микроконтроллер
ESP32 · уровень средний · 14 мин чтения
Открыть в интерактивном справочнике

156. Сеть на ESP32: esp_netif и lwIP, DHCP, SNTP, syslog, HTTP REST, MQTT

Ради сети ESP32 и выбирают: Wi-Fi на кристалле, стек TCP/IP lwIP в составе ESP-IDF и готовые клиенты и серверы на каждый случай. В части X сеть была разобрана снизу вверх на STM32 и на компьютере (разделы 105–108), MQTT — в разделах 87 и 91. Здесь то же самое на ESP32, но проверенное живым обменом. Программа в эмуляторе получает адрес по DHCP, берёт время у настоящего сервера chrony, шлёт журнал настоящему rsyslog, отвечает на запросы curl и публикует в брокер Mosquitto. Все серверы — соседние контейнеры одной сети Docker (_verify/esp156/).

Радио эмулятор не моделирует, поэтому сеть в нём — Ethernet: контроллер OpenCores на шине чипа. Для программы разницы почти нет: выше драйвера тот же esp_netif, тот же lwIP и те же сокеты, что при Wi-Fi. Что относится только к Wi-Fi, собрано в 156.7.

Как это называется в документации

Оригинал Перевод
esp_netif прослойка ESP-IDF между драйвером сети и стеком TCP/IP
lwIP (lightweight IP) стек TCP/IP для микроконтроллеров
event loop, IP_EVENT цикл событий, события сетевого уровня
station (STA), access point (AP) режимы Wi-Fi: клиент сети и своя точка доступа
SNTP (Simple Network Time Protocol) упрощённый протокол времени
syslog, facility, severity, PRI журнал по сети, источник, важность, приоритет
REST, resource, method стиль HTTP-интерфейса: ресурс и методы
MQTT broker, topic, QoS брокер сообщений, тема, уровень гарантии доставки
PUBACK подтверждение публикации при QoS 1
keepalive срок, за который клиент обязан подать признак жизни
MSS, TCP window наибольший сегмент, окно приёма TCP

156.1 Стенд: плата и три сервера

Три контейнера в одной сети Docker 10.156.0.0/24:

Кто Адрес Что делает
ESP32 в QEMU 10.0.2.15 внутри эмулятора программа раздела
сервер времени и журнала 10.156.0.10 chrony на порту 123, rsyslog на порту 514
брокер MQTT 10.156.0.11 Mosquitto на порту 1883

У эмулятора своя маленькая сеть 10.0.2.0/24: он раздаёт плате адрес по DHCP и переправляет её пакеты наружу от имени контейнера, как домашний роутер. Поэтому плата видит серверы по их адресам, а вот к самой плате снаружи достучаться можно только через проброшенный порт — так curl и приходит на HTTP-сервер.

Стенд раздела 156: ESP32 в QEMU получает адрес 10.0.2.15 и выходит через NAT эмулятора к серверу времени и журнала 10.156.0.10 и брокеру MQTT 10.156.0.11; curl обращается к плате через проброшенный порт 8080


156.2 esp_netif, события и адрес

Сеть в ESP-IDF поднимается одинаково для Ethernet и Wi-Fi: инициализация esp_netif, цикл событий, драйвер, ожидание события «получен адрес». В примерах ESP-IDF это спрятано в example_connect():

    ESP_ERROR_CHECK(nvs_flash_init());
    ESP_ERROR_CHECK(esp_netif_init());
    ESP_ERROR_CHECK(esp_event_loop_create_default());
    ESP_ERROR_CHECK(example_connect());                   /* Ethernet open_eth, DHCP */
адрес 10.0.2.15, маска 255.255.255.0, шлюз 10.0.2.2

Всё сетевое в ESP-IDF сообщает о себе событиями: IP_EVENT_ETH_GOT_IP или IP_EVENT_STA_GOT_IP у Wi-Fi, WIFI_EVENT_STA_DISCONNECTED, MQTT_EVENT_CONNECTED. Программа не опрашивает стек, а подписывается на события и ждёт. NVS нужна драйверу Wi-Fi и Bluetooth: там калибровка радио и настройки сети. Для одного Ethernet она не обязательна, но example_connect умеет и Wi-Fi, поэтому пример инициализирует её всегда.

Цена сети в памяти — видна по размеру:

│ Flash Code            │       423254 │    12.66 │        2919050 │       3342304 │
│ IRAM                  │        59043 │    45.05 │          72029 │        131072 │

Образ с сетью, SNTP, HTTP-сервером и клиентом MQTT — 606 196 байт против 179 144 у пустой программы раздела 151; ОЗУ команд занято на 45 %.


156.3 Время: SNTP

Часов с батарейкой у ESP32 нет: после включения time() отсчитывает секунды от нуля, и без сети плата не знает даты:

до SNTP: time() = 1, sizeof(time_t) = 8
SNTP 10.156.0.10: ESP_OK, год 2026, time() = 1789384076
    esp_sntp_config_t sc = ESP_NETIF_SNTP_DEFAULT_CONFIG(CONFIG_LAB_SERVER_IP);
    esp_netif_sntp_init(&sc);
    esp_err_t e = esp_netif_sntp_sync_wait(pdMS_TO_TICKS(20000));

Клиент SNTP шлёт серверу пакет по UDP на порт 123, получает время и выставляет системные часы; дальше time(), gettimeofday() и localtime_r() работают как на компьютере, и клиент сам подводит часы раз в час. time_t в ESP-IDF 5 — 64 бита, так что 2038 год программе не страшен. Часовой пояс задают переменной TZ через setenv и tzset; журналы и протоколы лучше вести в UTC.


156.4 Журнал по сети: syslog

Прибор без экрана пишет журнал на сервер. Формат syslog (RFC 3164) — одна строка в датаграмме UDP на порт 514:

    int pri = facility * 8 + severity;                     /* RFC 3164: <PRI> = facility·8 + severity */
    int n = snprintf(line, sizeof line, "<%d>%s esp32 %s: %s", pri, stamp, tag, msg);
syslog → 10.156.0.10:514, PRI 134, 42 байт: <134>Sep 14 11:07:56 esp32 net156: boot ok
syslog → 10.156.0.10:514, PRI 131, 49 байт: <131>Sep 14 11:07:56 esp32 net156: sensor timeout

Источник local0 — номер 16, важность «информация» — 6, «ошибка» — 3: 16 × 8 + 6 = 134 и 16 × 8 + 3 = 131. Сервер rsyslog разобрал обе строки:

pri=134 local0.info host=esp32 tag=net156: msg= boot ok
pri=131 local0.err host=esp32 tag=net156: msg= sensor timeout

UDP ничего не обещает: пропавшая строка пропала навсегда. Для важного журнала есть syslog по TCP и TLS (RFC 5425), а для записей «на всякий случай» UDP достаточно — прибор не зависает, если сервер лёг.

Две датаграммы syslog по полям: PRI в угловых скобках, метка времени, хост esp32, тег net156 и сообщение; PRI 134 — это local0 16 умножить на 8 плюс info 6, строка 42 байта; PRI 131 — local0 и err 3, строка 49 байт; сервер разобрал их как local0.info и local0.err


156.5 HTTP REST на плате

esp_http_server — HTTP-сервер из ESP-IDF: обработчик на пару «адрес и метод», ответ одним вызовом:

static esp_err_t led_put(httpd_req_t *req)
{
    char buf[8] = { 0 };
    int r = httpd_req_recv(req, buf, sizeof buf - 1);
    hits++;
    if (r <= 0 || (buf[0] != '0' && buf[0] != '1'))
        return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "body must be 0 or 1");
    led = buf[0] - '0';
    return led_reply(req);
}

Три запроса curl — прочитать, записать, записать ерунду:

{"led":0,"hits":1}
{"led":1,"hits":2}
HTTP 400
HTTP: порт 80, до 8 обработчиков, до 7 сокетов; MSS 1440, окно 5760

По умолчанию сервер держит до 8 адресов и до 7 одновременных соединений: восьмой клиент ждёт, пока освободится сокет. Для прибора в локальной сети этого хватает; для страницы, которая тянет картинки и скрипты, число сокетов поднимают в HTTPD_DEFAULT_CONFIG — но вместе с CONFIG_LWIP_MAX_SOCKETS в настройках lwIP. Там по умолчанию 10, три сокета сервер держит себе, и max_open_sockets больше 7 httpd_start не примет — вернёт ESP_ERR_INVALID_ARG. Браузер сам открывает несколько соединений сразу, поэтому «восьмой клиент» — часто всего второй браузер; lru_purge_enable = true закрывает соединение, которым дольше всех не пользовались, когда места нет. MSS 1440 и окно 5760 байт — настройки lwIP по умолчанию: четыре сегмента в окне, скромно, но ОЗУ съедает немного. Разбор HTTP как текста — в разделе 142.


156.6 MQTT

Клиент esp-mqtt из ESP-IDF держит соединение с брокером сам и сообщает обо всём событиями:

    esp_mqtt_client_config_t mc = { .broker.address.uri = CONFIG_LAB_MQTT_URI, .credentials.client_id = "esp32-156",
                                    .session.keepalive = 30 };
    esp_mqtt_client_handle_t cli = esp_mqtt_client_init(&mc);
    esp_mqtt_client_register_event(cli, ESP_EVENT_ANY_ID, on_mqtt, NULL);
    esp_mqtt_client_start(cli);

По событию «соединились» программа подписывается на свою командную тему, по «подписка подтверждена» публикует состояние и команду самой себе, обе с QoS 1:

MQTT: соединение с брокером, session_present 0
MQTT: подписка dev/esp32/cmd подтверждена, msg_id 37586
MQTT: PUBACK на msg_id 29042
MQTT: пришло dev/esp32/cmd = blink (QoS 1)
MQTT: PUBACK на msg_id 55183
MQTT: итог 3 (3 — всё прошло), keepalive 30 с

Брокер видит то же со своей стороны:

New client connected from 10.156.0.2:36664 as esp32-156 (p4, c1, k30).
Sending CONNACK to esp32-156 (0, 0)
Received PUBLISH from esp32-156 (d0, q1, r0, m29042, 'dev/esp32/status', ... (9 bytes))
Sending PUBACK to esp32-156 (m29042, rc0)

Плата пришла с адреса контейнера 10.156.0.2, а не со своего 10.0.2.15 — это и есть подмена адреса эмулятором из 156.1. p4 — версия протокола 3.1.1, c1 — чистая сессия, k30 — keepalive 30 с. Подписчик на брокере получил обе публикации:

dev/esp32/status {"led":1}
dev/esp32/cmd blink

Байты пакетов MQTT, keepalive и уровни QoS разобраны в разделе 87. Mosquitto 2 по умолчанию не пускает никого снаружи: без listener 1883 и allow_anonymous true в настройках плата получит отказ в соединении — в изделии вместо этого ставят пароли и TLS. Цена TLS разобрана в разделе 37а: клиент на Mbed TLS — десятки килобайт флеш-памяти и кучи. Сертификат проверяется по времени, поэтому SNTP (156.3) синхронизируют до первого защищённого соединения.


156.7 Wi-Fi: что меняется

Выше драйвера — ничего. Вместо example_connect() с Ethernet программа поднимает Wi-Fi сама: esp_netif_create_default_wifi_sta(), esp_wifi_init(), режим станции, имя сети и пароль, esp_wifi_start() и esp_wifi_connect(), а дальше ждёт того же события «получен адрес». Три отличия важны на практике:

Точка доступа (AP) нужна для первой настройки: прибор поднимает свою сеть, телефон подключается и вводит имя и пароль домашней сети в веб-форму на HTTP-сервере из 156.5. Открытым текстом пароль при этом лучше не передавать: в ESP-IDF для первой настройки есть компонент wifi_provisioning с шифрованием.


156.8 Грабли

Симптом Причина
esp_netif_init в порядке, а адреса нет не создан цикл событий; у Wi-Fi — ещё и не инициализирована NVS
время 1970 года в журнале SNTP ещё не синхронизировался — ждать esp_netif_sntp_sync_wait
сервер SNTP недоступен, программа висит ожидание без тайм-аута; задавать срок и работать без времени
строки syslog пропадают UDP без гарантий; важное — по TCP или копить на плате
новый клиент не открывает страницу заняты все 7 сокетов (max_open_sockets), а браузер держит по нескольку соединений; больше 7 — только вместе с CONFIG_LWIP_MAX_SOCKETS
MQTT: соединение отвергнуто Mosquitto 2 без listener и allow_anonymous пускает только localhost
PUBACK не приходит при QoS 0 так и задумано: подтверждение только при QoS 1 и 2
брокер видит «чужой» адрес платы NAT: эмулятор, роутер или провайдер подменяют адрес
после выхода из зоны Wi-Fi тишина нет переподключения по WIFI_EVENT_STA_DISCONNECTED
образ вырос на сотни килобайт стек TCP/IP, TLS и клиенты — ожидаемо; следить за IRAM

156.9 Итоги

Полные исходники

main.c — адрес по DHCP, время по SNTP, журнал по syslog, HTTP REST и клиент MQTT.

/* Раздел 156: сеть ESP32 в QEMU — адрес по DHCP, время по SNTP, журнал по syslog (UDP 514),
 * HTTP-сервер с REST и клиент MQTT. Серверы времени и журнала — в соседнем контейнере
 * CONFIG_LAB_SERVER_IP, брокер MQTT — CONFIG_LAB_MQTT_URI. */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/time.h>
#include <time.h>
#include "esp_event.h"
#include "esp_http_server.h"
#include "esp_log.h"
#include "esp_netif.h"
#include "esp_netif_sntp.h"
#include "lwip/sockets.h"
#include "mqtt_client.h"
#include "nvs_flash.h"
#include "protocol_examples_common.h"
#include "sdkconfig.h"

static int led;
static int hits;
static volatile int mqtt_state;                         /* 1 — соединились, 2 — PUBACK пришёл, 3 — своё сообщение вернулось */

static void syslog_send(int facility, int severity, const char *tag, const char *msg)
{
    char line[256];
    time_t now = time(NULL);
    struct tm tm;
    gmtime_r(&now, &tm);
    char stamp[20];
    strftime(stamp, sizeof stamp, "%b %e %H:%M:%S", &tm);
    int pri = facility * 8 + severity;                     /* RFC 3164: <PRI> = facility·8 + severity */
    int n = snprintf(line, sizeof line, "<%d>%s esp32 %s: %s", pri, stamp, tag, msg);
    int s = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
    struct sockaddr_in to = { .sin_family = AF_INET, .sin_port = htons(514) };
    inet_aton(CONFIG_LAB_SERVER_IP, &to.sin_addr);
    int sent = sendto(s, line, n, 0, (struct sockaddr *)&to, sizeof to);
    close(s);
    printf("syslog → %s:514, PRI %d, %d байт: %s\n", CONFIG_LAB_SERVER_IP, pri, sent, line);
}

static esp_err_t led_reply(httpd_req_t *req)
{
    char body[64];
    int n = snprintf(body, sizeof body, "{\"led\":%d,\"hits\":%d}", led, hits);
    httpd_resp_set_type(req, "application/json");
    return httpd_resp_send(req, body, n);
}

static esp_err_t led_get(httpd_req_t *req)
{
    hits++;
    return led_reply(req);
}

static esp_err_t led_put(httpd_req_t *req)
{
    char buf[8] = { 0 };
    int r = httpd_req_recv(req, buf, sizeof buf - 1);
    hits++;
    if (r <= 0 || (buf[0] != '0' && buf[0] != '1'))
        return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "body must be 0 or 1");
    led = buf[0] - '0';
    return led_reply(req);
}

static void on_mqtt(void *arg, esp_event_base_t base, int32_t id, void *data)
{
    esp_mqtt_event_handle_t ev = data;
    (void)arg;
    (void)base;
    switch ((esp_mqtt_event_id_t)id) {
    case MQTT_EVENT_CONNECTED:
        printf("MQTT: соединение с брокером, session_present %d\n", ev->session_present);
        esp_mqtt_client_subscribe(ev->client, "dev/esp32/cmd", 1);
        mqtt_state = 1;
        break;
    case MQTT_EVENT_SUBSCRIBED:
        printf("MQTT: подписка dev/esp32/cmd подтверждена, msg_id %d\n", ev->msg_id);
        esp_mqtt_client_publish(ev->client, "dev/esp32/status", "{\"led\":1}", 0, 1, 0);     /* QoS 1 — ждём PUBACK */
        esp_mqtt_client_publish(ev->client, "dev/esp32/cmd", "blink", 0, 1, 0);             /* своя же тема: вернётся через брокер */
        break;
    case MQTT_EVENT_PUBLISHED:
        printf("MQTT: PUBACK на msg_id %d\n", ev->msg_id);
        if (mqtt_state < 2) mqtt_state = 2;
        break;
    case MQTT_EVENT_DATA:
        printf("MQTT: пришло %.*s = %.*s (QoS %d)\n", ev->topic_len, ev->topic, ev->data_len, ev->data, ev->qos);
        mqtt_state = 3;
        break;
    case MQTT_EVENT_ERROR:
        printf("MQTT: ошибка, тип %d\n", ev->error_handle ? ev->error_handle->error_type : -1);
        break;
    default:
        break;
    }
}

void app_main(void)
{
    ESP_ERROR_CHECK(nvs_flash_init());
    ESP_ERROR_CHECK(esp_netif_init());
    ESP_ERROR_CHECK(esp_event_loop_create_default());
    ESP_ERROR_CHECK(example_connect());                   /* Ethernet open_eth, DHCP */

    esp_netif_ip_info_t ip;
    esp_netif_get_ip_info(get_example_netif(), &ip);
    printf("адрес " IPSTR ", маска " IPSTR ", шлюз " IPSTR "\n", IP2STR(&ip.ip), IP2STR(&ip.netmask), IP2STR(&ip.gw));
    printf("до SNTP: time() = %lld, sizeof(time_t) = %u\n", (long long)time(NULL), (unsigned)sizeof(time_t));

    esp_sntp_config_t sc = ESP_NETIF_SNTP_DEFAULT_CONFIG(CONFIG_LAB_SERVER_IP);
    esp_netif_sntp_init(&sc);
    esp_err_t e = esp_netif_sntp_sync_wait(pdMS_TO_TICKS(20000));
    time_t now = time(NULL);
    struct tm tm;
    gmtime_r(&now, &tm);
    printf("SNTP %s: %s, год %d, time() = %lld\n", CONFIG_LAB_SERVER_IP, esp_err_to_name(e), tm.tm_year + 1900, (long long)now);

    syslog_send(16, 6, "net156", "boot ok");              /* local0.info */
    syslog_send(16, 3, "net156", "sensor timeout");       /* local0.err */

    esp_mqtt_client_config_t mc = { .broker.address.uri = CONFIG_LAB_MQTT_URI, .credentials.client_id = "esp32-156",
                                    .session.keepalive = 30 };
    esp_mqtt_client_handle_t cli = esp_mqtt_client_init(&mc);
    esp_mqtt_client_register_event(cli, ESP_EVENT_ANY_ID, on_mqtt, NULL);
    esp_mqtt_client_start(cli);
    for (int i = 0; i < 100 && mqtt_state < 3; i++)
        vTaskDelay(pdMS_TO_TICKS(100));
    printf("MQTT: итог %d (3 — всё прошло), keepalive %d с\n", mqtt_state, mc.session.keepalive);

    httpd_config_t hc = HTTPD_DEFAULT_CONFIG();
    httpd_handle_t srv = NULL;
    ESP_ERROR_CHECK(httpd_start(&srv, &hc));
    httpd_uri_t g = { .uri = "/api/led", .method = HTTP_GET, .handler = led_get };
    httpd_uri_t p = { .uri = "/api/led", .method = HTTP_PUT, .handler = led_put };
    httpd_register_uri_handler(srv, &g);
    httpd_register_uri_handler(srv, &p);
    printf("HTTP: порт %d, до %d обработчиков, до %d сокетов; MSS %d, окно %d\n", hc.server_port, hc.max_uri_handlers, hc.max_open_sockets,
           CONFIG_LWIP_TCP_MSS, CONFIG_LWIP_TCP_WND_DEFAULT);
    printf("ready\n");
}

Дальше: раздел 157 — сон, память RTC и сопроцессор ULP.

Открыть в интерактивном справочнике