1. Тема дня
Структура ESP-IDF проекта: как правильно делить код на components, что класть в main, как оформлять CMakeLists.txt, где хранить пины платы, где держать бизнес-логику, а где - драйверы. Предыдущий урок был про архитектурное мышление. Сегодня переводим это в реальную структуру ESP-IDF проекта.
2. Зачем это нужно
Проект уже не маленький:
ESP32
├─ UART0: CLI / console
├─ UART2: Quectel EC25
├─ входы фаз светофора
├─ питание камеры / радара / Jetson
├─ MQTT / TCP / UDP
├─ watchdog
├─ логи
├─ диагностика
└─ будущие HIL-тесты через Raspberry PiЕсли всё лежит в main.c или в нескольких огромных .c-файлах, появляются проблемы:
- сложно понять, кто за что отвечает;
- страшно менять код;
- сложно писать тесты;
- тяжело переносить часть логики на STM32;
- появляются циклические зависимости;
- CLI напрямую меняет внутренние переменные сервисов;
- драйверы начинают знать про MQTT, фазы и бизнес-логику.
3. Что такое компонент ESP-IDF
Компонент - отдельный модуль прошивки. Пример структуры:
components/
modem_service/
phase_detector/
input_driver/
transport_service/
board/Обычно компонент содержит:
component_name/
├─ CMakeLists.txt
├─ include/
│ └─ component_name.h
└─ component_name.cМинимальный CMakeLists.txt:
idf_component_register(
SRCS "modem_service.c"
INCLUDE_DIRS "include"
)SRCS - исходные файлы. INCLUDE_DIRS - публичные заголовки. REQUIRES и PRIV_REQUIRES - зависимости.
4. Хорошая структура проекта
Плохой вариант:
main/
├─ main.c
├─ modem.c
├─ mqtt.c
├─ adc.c
├─ cli.c
├─ phase.c
└─ utils.cЛучше:
project/
├─ CMakeLists.txt
├─ sdkconfig.defaults
├─ partitions.csv
├─ main/
│ ├─ CMakeLists.txt
│ └─ app_main.c
└─ components/
├─ board/
├─ app_events/
├─ input_driver/
├─ phase_detector/
├─ modem_service/
├─ transport_service/
├─ cli_service/
├─ health_service/
└─ power_service/main должен быть тонким. Его задача - запустить систему, а не содержать всю систему. Плохой app_main.c:
void app_main(void)
{
init_gpio();
init_uart();
init_modem();
while (1) {
read_inputs();
detect_phase();
send_mqtt();
check_cli();
vTaskDelay(pdMS_TO_TICKS(10));
}
}Хороший app_main.c:
void app_main(void)
{
board_init();
app_events_init();
input_driver_init();
phase_detector_init();
modem_service_init();
transport_service_init();
power_service_init();
cli_service_init();
health_service_init();
ESP_LOGI("APP", "System started");
}5. Компонент board
Пины и особенности платы лучше хранить отдельно:
components/
board/
CMakeLists.txt
include/
board_pins.h
board_config.h
board.cПример board_pins.h:
#pragma once
#define BOARD_UART_CONSOLE_NUM UART_NUM_0
#define BOARD_UART_CONSOLE_TX_GPIO 1
#define BOARD_UART_CONSOLE_RX_GPIO 3
#define BOARD_UART_MODEM_NUM UART_NUM_2
#define BOARD_UART_MODEM_TX_GPIO 17
#define BOARD_UART_MODEM_RX_GPIO 16
#define BOARD_SYNC_OUT1_GPIO 25
#define BOARD_SYNC_OUT2_GPIO 14
#define BOARD_SYNC_PPS_GPIO 13
#define BOARD_TEMP_ONEWIRE_GPIO 33modem_service не должен знать, что EC25 сидит на GPIO17/GPIO16. Это должна знать board- конфигурация.
6. Компонент input_driver
Драйвер входов должен читать физические уровни, но не знать про фазы.
#pragma once
#include <stdint.h>
#include <stdbool.h>
typedef enum {
INPUT_CH_RED = 0,
INPUT_CH_YELLOW,
INPUT_CH_GREEN,
INPUT_CH_COUNT
} input_channel_t;
typedef struct {
bool level[INPUT_CH_COUNT];
uint32_t raw_mask;
int64_t timestamp_us;
} input_snapshot_t;
void input_driver_init(void);
bool input_driver_get_snapshot(input_snapshot_t *out);Важная мысль: input_driver не знает, что такое “фаза светофора”. Он знает только уровни входов.
7. Компонент phase_detector
Этот компонент желательно писать максимально чистым C:
typedef enum {
TRAFFIC_PHASE_UNKNOWN = 0,
TRAFFIC_PHASE_OFF,
TRAFFIC_PHASE_RED,
TRAFFIC_PHASE_YELLOW,
TRAFFIC_PHASE_GREEN,
TRAFFIC_PHASE_CONFLICT
} traffic_phase_t;
typedef struct {
bool red;
bool yellow;
bool green;
int64_t timestamp_us;
} phase_input_t;
typedef struct {
traffic_phase_t old_phase;
traffic_phase_t new_phase;
int64_t timestamp_us;
bool changed;
} phase_event_t;
void phase_detector_init(void);
phase_event_t phase_detector_update(const phase_input_t *input);Идеальный phase_detector не должен зависеть от:
#include "driver/gpio.h"
#include "esp_log.h"
#include "mqtt_client.h"Тогда его легко тестировать на ПК, гонять в CI, подключать к HIL и переносить на STM32.
8. Компонент modem_service
EC25 лучше мыслить как state machine:
MODEM_STATE_OFF
MODEM_STATE_BOOTING
MODEM_STATE_AT_SYNC
MODEM_STATE_SIM_CHECK
MODEM_STATE_NETWORK_WAIT
MODEM_STATE_PDP_ACTIVATE
MODEM_STATE_MQTT_OPEN
MODEM_STATE_MQTT_CONNECTED
MODEM_STATE_READY
MODEM_STATE_ERRORПубличный API:
typedef enum {
MODEM_STATUS_NOT_READY = 0,
MODEM_STATUS_READY,
MODEM_STATUS_ERROR
} modem_status_t;
void modem_service_init(void);
modem_status_t modem_service_get_status(void);
bool modem_service_publish(const char *topic, const char *payload);AT-команды должны быть скрыты внутри сервиса.
9. Компонент transport_service
Транспорт не должен знать про GPIO:
typedef enum {
TRANSPORT_KIND_MQTT,
TRANSPORT_KIND_TCP,
TRANSPORT_KIND_UDP
} transport_kind_t;
typedef struct {
int64_t timestamp_us;
uint32_t sequence_id;
uint8_t event_type;
uint8_t payload[64];
uint16_t payload_len;
} transport_event_t;
void transport_service_init(void);
bool transport_service_send_event(const transport_event_t *event);phase_detector не знает, чем отправляется событие.
10. REQUIRES и PRIV_REQUIRES
Пример:
idf_component_register(
SRCS "transport_service.c"
INCLUDE_DIRS "include"
REQUIRES app_events
PRIV_REQUIRES modem_service esp_timer
)Упрощённое правило:
- REQUIRES - то, что видно через публичный заголовок;
- PRIV_REQUIRES - то, что нужно только .c-файлам.
Чем меньше публичных зависимостей, тем легче поддерживать проект.
11. sdkconfig.defaults
Полезно хранить базовые настройки в репозитории:
CONFIG_FREERTOS_HZ=1000
CONFIG_ESP_TASK_WDT_EN=y
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"sdkconfig - локальный результат конфигурации. sdkconfig.defaults - базовый профиль проекта.
12. Типичные ошибки
- оставлять всю реальную логику в main;
- делать один огромный компонент device_service;
- позволять драйверу отправлять MQTT;
- создавать циклические зависимости;
- хардкодить GPIO в разных местах;
- класть внутренние заголовки в публичный include/.
13. Практическое задание
Создай ARCHITECTURE_COMPONENTS.md:
# ESP-IDF component layout
## main
Only starts the firmware and initializes components.
## components/board
Board-specific pins and hardware revision constants.
## components/app_events
Common event types and event queue.
## components/input_driver
Reads physical traffic-light inputs.
Does not know about phase logic.
## components/phase_detector
Converts stable input levels into traffic phase events.
No ESP-IDF hardware dependencies.
## components/modem_service
Controls Quectel EC25 through UART AT commands.
Owns modem state machine.
## components/transport_service
Sends events through UDP/TCP/MQTT.
Does not read GPIO or ADC.
## Dependency rules
1. Drivers do not depend on services.
2. Services communicate through app_events.
3. Board-specific constants are only in board.
4. Phase detector should be testable on PC.Затем создай минимальный компонент components/board и подключи его к main.
14. Короткий итог
ESP-IDF проект должен быть набором маленьких компонентов с понятными границами:
main/
app_main.c
components/
board/
app_events/
input_driver/
phase_detector/
modem_service/
transport_service/
cli_service/
health_service/
power_service/Задание
EC25 меняет UART-пины на новой ревизии платы. Укажите, где хранить новые номера пинов, что должно остаться в modem_service и как проверить, что phase_detector переносим на ПК.
Критерии самопроверки: Назовите board, протокол/автомат modem_service и минимум две платформенные зависимости, исключаемые из phase_detector.
Показать ответ автора
Пины меняются в board-конфигурации, которую получает нижний слой. modem_service сохраняет протокол AT и автомат состояний; он не хардкодит пины. phase_detector принимает значения/временные метки через свой API и не включает GPIO, MQTT или платформенное логирование. Соберите его отдельно для host-теста с искусственными входами.