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.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 достаточно — прибор не зависает, если сервер лёг.
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(), а дальше ждёт того же события «получен адрес». Три
отличия важны на практике:
- Разрыв — обычное дело. Событие
WIFI_EVENT_STA_DISCONNECTEDприходит при каждом уходе из зоны роутера, и переподключаться программа должна сама, с паузой между попытками. - Радио делит время. Wi-Fi и Bluetooth работают через одно радио по очереди; ADC2 при включённом Wi-Fi недоступен (раздел 154).
- Экономия. В режиме станции ESP-IDF по умолчанию усыпляет радио между маяками роутера — это модемный сон из раздела 157, где и цифры тока.
Точка доступа (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 Итоги
esp_netif+ lwIP одинаковы для Ethernet и Wi-Fi; всё сетевое — события.- DHCP в эмуляторе: 10.0.2.15/24, шлюз 10.0.2.2; наружу — через NAT.
- SNTP:
time()от 1 до реальной даты,time_t64 бита; часов с батарейкой нет. - syslog:
<PRI>= источник × 8 + важность; local0.info — 134, local0.err — 131; UDP 514. - HTTP-сервер: обработчик на адрес и метод; 8 адресов и 7 сокетов по умолчанию; MSS 1440, окно 5760.
- MQTT: события CONNECTED, SUBSCRIBED, PUBLISHED, DATA; QoS 1 — PUBACK; keepalive 30 с.
- Образ с сетью — 606 196 байт, IRAM 45 %.
Полные исходники
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.