Реклама
Селектел, перетяжка, 22.06
Селектел, перетяжка, 22.06
Селектел, перетяжка, 22.06

Протоколы связи микроконтроллеров: проектирование и парсинг

Как построить надёжный канал связи между ПК и микроконтроллером: разбираем формат кадра, адресацию, checksum и парсер на конечном автомате с рабочими примерами кода.

Обложка: Протоколы связи микроконтроллеров: проектирование и парсинг

Протокол связи микроконтроллера — это набор правил формирования кадров поверх физического интерфейса (UART, RS-485). Если вы когда-либо писали прошивку, которой нужно «разговаривать» с компьютером, то знаете: надёжность связи важнее скорости. Один потерянный байт или сбой в синхронизации — и устройство зависает или выполняет чужую команду. В этой статье разберём, как проектировать собственные протоколы связи микроконтроллеров: от структуры кадра до парсера на конечном автомате.

Что такое протокол передачи данных

Под протоколом передачи данных здесь понимается формат пакетов (кадров), которые строятся поверх физического уровня. Физический уровень — это уже выбранный интерфейс: RS-232, RS-485, инфракрасный канал, радиомодуль или оптоволокно. От него мы получаем две базовые операции: отправить один байт и принять один байт. Всё остальное — наша задача.

Данные передаются пакетами — кадрами. Хороший кадр позволяет приёмнику понять: где начало сообщения, кому оно адресовано, сколько в нём полезных данных и не повредились ли они по дороге.

Ключевые выводы

Кадр состоит из заголовка, адресов, типа данных, длины, полезной нагрузки, контрольной суммы и концевика.

Для защиты от случайных совпадений заголовок делают многобайтовым, а контрольную сумму считают по всей значащей части кадра.

Парсинг удобно реализовывать конечным автоматом: каждый принятый байт переводит машину в новое состояние.

На хосте данные почти всегда буферизуются; на простых МК часто выгоднее передавать напрямую, чтобы сэкономить ОЗУ.

Структура кадра: из чего собирать пакет

Надёжный протокол обычно включает семь логических полей. Не все обязательны — выбор зависит от задачи.

Заголовок и концевик

Заголовок (frame header) и концевик (frame tail) отмечают границы кадра. Главное требование — минимизировать вероятность случайного совпадения этих байтов в потоке данных. Есть два подхода:

  • Подбор характерных байтов. Если данные предсказуемы (например, только ASCII-текст), можно выбрать заголовок из диапазона непечатаемых символов.
  • Увеличение длины. Для случайных данных лучше сделать заголовок многобайтовым — например, 0x55 0xAA 0x7E. Вероятность случайного совпадения трёх конкретных байтов подряд падает экспоненциально. Даже если совпадение произойдёт, его отловит контрольная сумма.

Адресация

Адрес назначения нужен в системах типа «один к многим» — например, когда один хост управляет несколькими датчиками по общей шине RS-485. В сложных сетях добавляют ещё и адрес источника, чтобы получатель знал, от кого пришёл пакет.

Тип, длина и данные

Тип данных (data type) говорит, что дальше идёт команда или полезная нагрузка. Длина (data length) указывает число значащих байтов в блоке данных. Вместе они образуют «тело» кадра — ту часть, которую мы действительно хотим доставить.

Контрольная сумма

Контрольная сумма проверяет целостность кадра. Простейший вариант — арифметическая сумма всех байтов тела. Для более серьёзной защиты применяют CRC (циклический избыточный код): он ловит пакетные ошибки и перестановки битов, которые простая сумма пропустит. Выбор алгоритма — компромисс между скоростью вычисления на МК и требуемой надёжностью.

Передача: хост и микроконтроллер

На физическом уровне отправка сводится к посылке байтов один за другим. Но способ организации этой посылки сильно влияет на производительность.

Передача с микроконтроллера

На простых контроллерах вроде семейства 8051 часто используют прямую передачу: процессор загружает байт в буфер UART и ждёт флага готовности. Плюс — данные моментально оказываются на линии. Минус — процессор занят на всё время отправки.

Альтернатива — передача по прерыванию: байты складываются в кольцевой буфер, а прерывание UART отправляет их фоном. Экономит процессорное время, но требует ОЗУ под буфер. На 8051 с его скудной памятью прямая передача часто выигрывает.

			void SendByte(unsigned char ch)
{
    SBUF = ch;
    while (TI == 0);  // ждём окончания передачи
    TI = 0;           // сбрасываем флаг
}
		

Передача с хоста

На ПК данные почти всегда буферизуются операционной системой. Программист работает с тремя уровнями абстракции:

  1. Контролы ОС. В Windows — компоненты вроде MSComm (устаревший 32-bit ActiveX, не рекомендуется для новых проектов). Просто, но нужно следить за блокировками при приёме и многопоточностью.
  2. Системные API. В Windows и Linux последовательный порт — это файл. Открываем через CreateFile или open(), но перед чтением и записью требуется настройка параметров порта (в Linux — termios со скоростью, чётностью и размером слова).
  3. Класс-обёртка. Например, CSerialPort для Windows: он инкапсулирует инициализацию, поток приёма и отправку. После открытия порта вызов WriteToPort отправляет массив байтов, а внутренний поток следит за входящими данными и шлёт сообщения родительскому окну.

Приём и парсинг на конечном автомате

Приём данных на стороне микроконтроллера тоже бывает двух видов: опрос (поллинг) и прерывание. Опрос проще, но отнимает процессорное время. Прерывание эффективнее: байт пришёл — сработала процедура обработки прерывания (ISR, Interrupt Service Routine), процессор отвлёкся на доли миллисекунды и вернулся к задаче.

Где размещать парсер протокола? Если протокол простой, его можно держать прямо в обработчике прерывания: приняли корректный кадр — установили флаг, основной цикл реагирует. Для сложных протоколов лучше писать байты в буфер и разбирать их в главном цикле. Гибридный подход тоже возможен: в прерывании ищем только «команду подключения», а остальное парсим фоном.

Пример: разбор кадра по состояниям

Рассмотрим конкретный формат кадра:

			0x55, 0xAA, 0x7E, 0x12, 0xF0, 0x02, 0x23, 0x45, SUM, XOR, 0x0D
# Заголовок:        0x55 0xAA 0x7E
# Адрес получателя:  0x12
# Адрес отправителя: 0xF0
# Длина данных:     0x02
# Данные:           0x23 0x45
# Контрольные суммы: SUM (арифметическая), XOR (побитовое исключающее ИЛИ)
# Концевик:         0x0D
		

Парсер реализуем через переменную state_machine. Каждое состояние соответствует ожидаемому байту. Если пришёл не тот байт — автомат сбрасывается в ноль. Это защищает от «залипания» в промежуточном состоянии при обрыве связи или помехах.

			// Переменные, разделяемые между ISR и main, в production должны быть volatile
volatile unsigned char state_machine = 0;
volatile unsigned char retval = 0;
unsigned char sumchkm = 0, xorchkm = 0;
unsigned char lencnt = 0, rcvcount = 0;
unsigned char m_ucData[32];
unsigned char m_SrcAdr = 0xF0;  // наш адрес (отправитель)
unsigned char m_DstAdr = 0x12;  // адрес получателя

// Вызывается для КАЖДОГО принятого байта (обычно из ISR)
void ProtocolParser(unsigned char rcvdat)
{
    switch (state_machine) {
        case 0:
            state_machine = (rcvdat == 0x55) ? 1 : 0;
            break;
        case 1:
            state_machine = (rcvdat == 0xAA) ? 2 : 0;
            break;
        case 2:
            state_machine = (rcvdat == 0x7E) ? 3 : 0;
            break;
        case 3:
            sumchkm = rcvdat;
            xorchkm = rcvdat;
            state_machine = (rcvdat == m_DstAdr) ? 4 : 0;
            break;
        case 4:
            sumchkm += rcvdat;
            xorchkm ^= rcvdat;
            state_machine = (rcvdat == m_SrcAdr) ? 5 : 0;
            break;
        case 5:
            lencnt = 0;
            rcvcount = rcvdat;
            sumchkm += rcvdat;
            xorchkm ^= rcvdat;
            state_machine = 6;
            break;
        case 6:
        case 7:
            if (lencnt >= sizeof(m_ucData)) {
                state_machine = 0;
                break;
            }
            m_ucData[lencnt++] = rcvdat;
            sumchkm += rcvdat;
            xorchkm ^= rcvdat;
            state_machine = (lencnt == rcvcount) ? 8 : 7;
            break;
        case 8:
            state_machine = (sumchkm == rcvdat) ? 9 : 0;
            break;
        case 9:
            state_machine = (xorchkm == rcvdat) ? 10 : 0;
            break;
        case 10:
            if (rcvdat == 0x0D) {
                retval = 0xAA;  // кадр принят и проверен
            }
            state_machine = 0;
            break;
    }
}
		
Совет по стабильности:
Сброс автомата при несовпадении заголовка, адресов, длины, checksum или концевика — ключевая техника. Без неё при обрыве кадра парсер застрянет в промежуточном состоянии, и следующие кадры будут отвергнуты.

Приём на стороне хоста

На ПК приём организуется проще: операционная система уже буферизует входящие байты. Для неблокирующего чтения запускают отдельный поток, который ждёт данных из порта и передаёт их в основной процесс через сообщения или обратный вызов. Класс CSerialPort, например, отправляет родительскому окну сообщение WM_COMM_RXCHAR с каждым новым байтом. Обработчик этого сообщения просто вызывает тот же парсер, что и на микроконтроллере.

Таким образом, логика разбора протокола единая для обеих сторон. Различается только способ доставки байтов в парсер: прерывание ISR на МК и поток ОС на хосте.

Часто задаваемые вопросы
1
Нужен ли мне собственный протокол, если есть Modbus и MQTT?

Для встроенных устройств с простой топологией «точка-точка» или небольшая шина собственный лёгкий протокол часто предпочтительнее: меньше накладных расходов, проще отладка, нет лицензий. Modbus и MQTT выигрывают в сложных сетях и IoT-инфраструктуре.

2
Почему не хватит одной контрольной суммы?

Две независимые checksum ловят больше паттернов ошибок, чем одна. Например, замена двух байтов a, b на a+1, b-1 сохранит арифметическую сумму, но изменит XOR. Арифметическое сложение и XOR — обе коммутативные, поэтому простая перестановка байтов не влияет ни на одну из них, но комбинация двух алгоритмов отсекает разные классы ошибок.

3
Какой длины делать заголовок?

Для случайных данных достаточно 2–3 байта. Вероятность случайного совпадения трёх конкретных значений в потоке случайных байтов — примерно 1 / 2^24 (оценка). При этом контрольная сумма отсеет оставшиеся ложные срабатывания.

4
Можно ли использовать этот подход на Arduino или STM32?

Да, логика конечного автомата не привязана к платформе. На Arduino вместо прямой записи в регистр UART можно использовать Serial.write() — это высокоуровневая функция с внутренней буферизацией, упрощённый аналог прямого доступа к регистрам. На STM32 — HAL_UART_Transmit() или DMA. Главное — сохранить структуру кадра и порядок переходов состояний.

5
Что делать, если кадр всё равно теряется?

Введите тайм-аут и механизм повторной передачи: хост посылает кадр и ждёт подтверждения (ACK) в течение N миллисекунд. Если ответа нет — отправляет кадр повторно с тем же порядковым номером. Источник отмечает, что в простых системах потерь он не встречал, но в production такой механизм обязателен.

Выводы

Проектирование протокола связи для микроконтроллера — это не ракетостроение, но требует дисциплины. Хороший кадр защищает границы (заголовок + концевик), адресует получателя, указывает длину и проверяет целостность. Парсинг на конечном автомате делает код предсказуемым и устойчивым к сбоям.

Главный принцип — сбрасывать автомат при любом нарушении ожидаемого шаблона. Это предотвращает «залипание» и позволяет системе быстро восстановиться после помехи. На основе этой базы можно наращивать надёжность: добавлять повторные передачи, порядковые номера, шифрование — в зависимости от требований проекта.

Хороший протокол — это не тот, который передаёт быстрее всех, а тот, который не ломается при помехах.
Leo Liuинженер-встроенщик, Sienovo Engineering

Источник: оригинальная статья Leo Liu — Microcontroller Communication Protocol Design (DEV Community).