1. Тема дня
UART-парсер для AT-модема Quectel EC25: поток байтов, сборка строк, отделение ответов на команды от URC, обработка OK, ERROR, +CME ERROR, +QMTOPEN, +QMTCONN, +QMTSTAT, переполнение буфера и частичные сообщения. Главная мысль: AT-парсер не должен “ждать строку OK”. Он должен непрерывно разбирать поток, в котором ответы на команды и асинхронные URC могут перемешиваться.
2. Зачем это нужно
Типичные проблемы:
- URC пришёл во время ожидания ответа;
- строка пришла кусками;
- в буфер попало echo команды;
- OK пришёл раньше финального MQTT-события;
- UART RX buffer переполнился;
- debug-команда вклинилась между рабочими AT-командами;
- state machine решила, что модем завис, хотя парсер потерял строку.
Правильный pipeline:
UART2 bytes
-> line assembler
-> AT tokenizer/parser
-> command response matcher
-> URC dispatcher
-> modem state machine3. UART - поток, а не сообщения
Нельзя думать, что один uart_read_bytes() вернёт одну AT-строку. Вызов может вернуть:
"AT\r\r\nO"следующий:
"K\r\n+QMTSTAT: 0,3\r\n"Поэтому нужно:
uart_read_bytes()
-> feed bytes one by one
-> line buffer
-> when CR/LF completed: parse full lineПлохо:
int len = uart_read_bytes(UART_NUM_2, buf, sizeof(buf), timeout);
if (strstr((char *)buf, "OK")) {
got_ok = true;
}4. URC ломают простой парсер
URC - unsolicited result code. Модем отправляет его самостоятельно. Пример:
ESP32 -> AT+QMTOPEN=0,"broker",1883
EC25 -> OK
EC25 -> +QMTOPEN: 0,0Но может быть:
ESP32 -> AT+QMTOPEN=0,"broker",1883
EC25 -> +CEREG: 1
EC25 -> OK
EC25 -> +QMTOPEN: 0,0Парсер должен различать:
terminal response:
OK
ERROR
+CME ERROR
+CMS ERROR
intermediate response:
+CSQ
+CPIN
+QIACT
URC:
+QMTOPEN
+QMTCONN
+QMTSTAT
+QIURC
+CEREG5. Слои AT-парсера
1. UART event layer
Читает байты из ESP-IDF UART driver.
2. Line assembler
Собирает строки по CR/LF, защищается от overflow.
3. AT line classifier
Определяет тип строки: OK, ERROR, CME, URC, prompt, echo.
4. Modem event dispatcher
Превращает строку в modem_event_t.6. UART event layer
Каркас:
static QueueHandle_t modem_uart_queue;
void modem_uart_init(void)
{
const int rx_buf_size = 4096;
const int tx_buf_size = 2048;
const int event_queue_size = 20;
uart_config_t cfg = {
.baud_rate = 115200,
.data_bits = UART_DATA_8_BITS,
.parity = UART_PARITY_DISABLE,
.stop_bits = UART_STOP_BITS_1,
.flow_ctrl = UART_HW_FLOWCTRL_DISABLE,
.source_clk = UART_SCLK_DEFAULT,
};
ESP_ERROR_CHECK(uart_driver_install(
UART_NUM_2,
rx_buf_size,
tx_buf_size,
event_queue_size,
&modem_uart_queue,
0
));
ESP_ERROR_CHECK(uart_param_config(UART_NUM_2, &cfg));
ESP_ERROR_CHECK(uart_set_pin(UART_NUM_2, 17, 16,
UART_PIN_NO_CHANGE,
UART_PIN_NO_CHANGE));
}Обработка событий:
Размер UART_DATA относится ко всему событию. Читаем его ограниченными порциями, уменьшая remaining на фактически полученное число байт. При нулевом или отрицательном результате не крутимся бесконечно: текущая AT-транзакция становится недостоверной. У UART один владелец; overflow/reset обрабатываются отдельно. Очереди и период обслуживания должны оставаться ограниченными по времени.
static bool uart_data_event_drain(size_t event_bytes)
{
uint8_t buf[256];
size_t remaining = event_bytes;
while (remaining != 0U) {
size_t request = remaining < sizeof(buf) ? remaining : sizeof(buf);
int received = uart_read_bytes(UART_NUM_2, buf, request, 0);
if (received <= 0) {
/* A short/error read invalidates the current transaction. */
modem_emit_event(MODEM_EV_UART_DESYNC);
return false;
}
at_parser_feed(buf, (size_t)received);
remaining -= (size_t)received;
}
return true;
}В режиме publish приглашение > разрешает ввод payload; ждать следующего CR/LF рискованно. Распознавайте его на уровне байтов только тогда, когда state machine ожидает publish prompt, текущая строка пуста и parser не находится в overflow/desync. Следующий необязательный пробел отделяется от нового ответа. Это не бинарный parser QMTRECV: длины payload и data mode требуют отдельного состояния.
static bool s_expect_publish_prompt;
static bool s_skip_prompt_space;
/* Call after submitting the publish command; clear on timeout/recovery. */
static void at_parser_expect_publish_prompt(bool expected)
{
s_expect_publish_prompt = expected;
s_skip_prompt_space = false;
}
/* Run at the start of the byte assembler, before CR/LF processing. */
static bool at_parser_consume_prompt_byte(uint8_t b, size_t line_length)
{
if (s_skip_prompt_space) {
s_skip_prompt_space = false;
if (b == ' ') return true;
}
if (s_expect_publish_prompt && line_length == 0U && b == '>') {
s_expect_publish_prompt = false;
s_skip_prompt_space = true;
modem_emit_event(MODEM_EV_PROMPT);
return true;
}
return false;
}ESP-IDF: UART, буфер и чтение событий
Quectel: MQTT data-mode publish
static void modem_uart_task(void *arg)
{
uart_event_t event;
uint8_t buf[256];
while (1) {
if (xQueueReceive(modem_uart_queue, &event, portMAX_DELAY) != pdTRUE) {
continue;
}
switch (event.type) {
case UART_DATA: {
int len = uart_read_bytes(UART_NUM_2,
buf,
MIN(event.size, sizeof(buf)),
0);
if (len > 0) {
at_parser_feed(buf, len);
}
break;
}
case UART_FIFO_OVF:
diag.uart_fifo_ovf++;
uart_flush_input(UART_NUM_2);
xQueueReset(modem_uart_queue);
at_parser_reset_line();
modem_emit_event(MODEM_EV_UART_OVERFLOW);
break;
case UART_BUFFER_FULL:
diag.uart_buffer_full++;
uart_flush_input(UART_NUM_2);
xQueueReset(modem_uart_queue);
at_parser_reset_line();
modem_emit_event(MODEM_EV_UART_OVERFLOW);
break;
default:
break;
}
}
}Если был overflow, часть строки могла потеряться. Текущий ответ на команду уже подозрителен.
7. Line assembler
#define AT_LINE_MAX 256
typedef struct {
char line[AT_LINE_MAX];
size_t len;
bool overflow;
} at_line_assembler_t;
static at_line_assembler_t s_at_line;
static void at_parser_feed_byte(uint8_t b)
{
if (b == '\r' || b == '\n') {
if (s_at_line.len > 0) {
s_at_line.line[s_at_line.len] = '\0';
if (!s_at_line.overflow) {
at_parser_handle_line(s_at_line.line);
} else {
diag.at_line_overflow++;
modem_emit_event(MODEM_EV_PARSE_OVERFLOW);
}
s_at_line.len = 0;
s_at_line.overflow = false;
}
return;
}
if (s_at_line.len + 1 < AT_LINE_MAX) {
s_at_line.line[s_at_line.len++] = (char)b;
} else {
s_at_line.overflow = true;
}
}
void at_parser_feed(const uint8_t *data, size_t len)
{
for (size_t i = 0; i < len; i++) {
at_parser_feed_byte(data[i]);
}
}Нельзя делать только strtok(buf, "\r\n"), потому что строка может прийти частями.
8. Классификация строк
typedef enum {
AT_LINE_EMPTY = 0,
AT_LINE_ECHO,
AT_LINE_OK,
AT_LINE_ERROR,
AT_LINE_CME_ERROR,
AT_LINE_CMS_ERROR,
AT_LINE_PROMPT,
AT_LINE_URC,
AT_LINE_RESPONSE,
AT_LINE_UNKNOWN,
} at_line_type_t;
static at_line_type_t at_classify_line(const char *line)
{
if (line[0] == '\0') return AT_LINE_EMPTY;
if (strcmp(line, "OK") == 0) return AT_LINE_OK;
if (strcmp(line, "ERROR") == 0) return AT_LINE_ERROR;
if (strncmp(line, "+CME ERROR:", 11) == 0) return AT_LINE_CME_ERROR;
if (strncmp(line, "+CMS ERROR:", 11) == 0) return AT_LINE_CMS_ERROR;
if (strcmp(line, ">") == 0 || strcmp(line, "> ") == 0) return AT_LINE_PROMPT;
if (strncmp(line, "AT", 2) == 0) return AT_LINE_ECHO;
if (at_is_known_urc(line)) return AT_LINE_URC;
if (line[0] == '+') return AT_LINE_RESPONSE;
return AT_LINE_UNKNOWN;
}9. URC whitelist
static bool at_is_known_urc(const char *line)
{
return
strncmp(line, "+QMTOPEN:", 9) == 0 ||
strncmp(line, "+QMTCONN:", 9) == 0 ||
strncmp(line, "+QMTSTAT:", 9) == 0 ||
strncmp(line, "+QMTRECV:", 9) == 0 ||
strncmp(line, "+QIURC:", 7) == 0 ||
strncmp(line, "+CEREG:", 7) == 0 ||
strncmp(line, "+CREG:", 6) == 0 ||
strncmp(line, "+CMTI:", 6) == 0 ||
strcmp(line, "RING") == 0;
}Whitelist лучше, чем “всё с плюсом - URC”, потому что ответы на команды тоже начинаются с +.
10. Prompt >
Некоторые команды имеют двухфазный ввод:
AT+QMTPUBEX=...
>
payloadПравильно:
state = WAIT_PUBLISH_PROMPT
если пришёл MODEM_EV_PROMPT:
отправить payload
state = WAIT_PUBLISH_RESULT
если timeout:
recovery11. Типичные ошибки
- искать OK через strstr() в куске UART-буфера;
- считать OK полным успехом операции;
- не обрабатывать UART_FIFO_OVF и UART_BUFFER_FULL;
- читать один UART из нескольких задач;
- делать парсер слишком “умным”, чтобы он сам делал recovery;
- логировать каждую строку всегда.
12. Практическое задание
Создай AT_PARSER.md:
# AT parser architecture
UART2 is owned only by modem_uart_task.
Pipeline:
1. ESP-IDF UART event queue
2. uart_read_bytes()
3. byte feed
4. line assembler
5. line classifier
6. command response parser
7. URC parser
8. modem_event_queue
9. modem_state_machine
Parser rules:
- UART is a byte stream, not message packets.
- `OK`/`ERROR` are terminal responses, but not always final operation result.
- URC may arrive at any time.
- Parser never performs recovery itself.
- Parser emits modem_event_t.
- UART overflow invalidates current command response.Мини-тест без модема:
static void test_at_parser_feed_chunks(void)
{
const char *chunks[] = {
"AT+QMTOPEN=0,\"broker\",1883\r",
"\r\nOK\r\n+QMT",
"OPEN: 0,0\r\n+CEREG: 1\r\n",
"+QMTSTAT: 0,3\r\n"
};
for (size_t i = 0; i < sizeof(chunks) / sizeof(chunks[0]); i++) {
at_parser_feed((const uint8_t *)chunks[i], strlen(chunks[i]));
}
}Ожидаемые события:
ECHO: AT+QMTOPEN=...
OK
QMTOPEN client=0 result=0
CEREG registered
QMTSTAT client=0 result=313. Короткий итог
UART event queue
-> read bytes
-> line assembler
-> line classifier
-> parse OK/ERROR/CME/CMS
-> parse URC
-> emit modem_event_t
-> modem state machineПравила:
- UART - поток байтов, а не готовые сообщения.
- Один UART читает одна задача.
- URC может прийти в любой момент.
- OK не всегда финальный успех.
- Overflow invalidates текущий ответ.
- Парсер не делает recovery.
- State machine решает, что делать дальше.
Задание
Строка превысила bounded line buffer до CR/LF. Почему нельзя разобрать только сохранённый prefix? Опишите bounded resynchronisation policy. Это редакционная самопроверка, не утверждение, что каждый source snippet её уже реализует.
Критерии самопроверки: Отклоните truncated input, пропустите до delimiter и сообщите о потере без modem recovery внутри parser.
Показать ответ автора
Обрезанный prefix может выглядеть valid reply, скрывая потерю данных. Пометьте текущий response invalid, перейдите в dropping/resynchronisation до delimiter, затем начните новую строку и передайте overflow event state machine. Остаток bytes не должен выглядеть новым успешным reply.