Приветствую, глубокоуважаемые!
Будем стараться делать хорошо, плохо само получится (С)
Любите ли вы NMEA0183, как люблю его я? Умеете ли? Практикуете ли?
Хочу поделиться универсальным, модульным, гибким, шустрым и исключительно нетребовательным к ресурсам парсером для работы с NMEA-сообщениями в Embedded.
Под катом подготовил для вас рассказ о том, как это работает, как использовать, онлайн-демку с пошаговым выполнением алгоритма и подсветкой выполняемых веток кода, а в качестве бонуса еще один парсер NMEA, я бы даже сказал убер-парсер - но уже не для Embedded.
0. Intro
Просто напомню, что NMEA0183 это ASCII-протокол, т.е. сообщения представляют собой, или, точнее рассматриваются как строки из ASCII-кодов. Вот пример сообщения:
$WIMTW,10.8,C*09<CR><LF>
│ │ │ │ │
| | | | └── CRC (0x09)
│ │ | └──── Единицы измерения (°C)
| | └─────-─ Температура (23.5)
│ └──────────── Sentence ID - Тип сообщения (MTW)
└────────────── Talker ID - Источник (WI)
Сообщения всегда начинаются с символа $, а заканчиваются \r\n (0x0D 0x0A).
Перед концом сообщения есть необязательное поле контрольной суммы, которое начинается со знака и содержит два шестнадцатеричных символа - побайтное xor всех байт между $ и исключительно.
Внутри сообщение устроено несложно - это просто список значений, разделенных запятыми. При этом самое первое поле - это идентификатор источника и/или сообщения.
Каждому сообщению строго соответствует формат полей - тип данных и как их интерпретировать, например, стандартное сообщение MTW - Mean Water Temperature, идентификатор источника WI - Weather Instrument, первое поле после заголовка содержит вещественное число, соответствующее измеренной температуре воды, а второе поле содержит единицы измерения, в данном случае C - градусы Цельсия.
Сообщения бывают стандартными, как в примере выше и "проприетарными", у которых после $ следует символ P с трехсимвольным идентификатором производителя (Manufacturer ID) и собственно идентификатором сообщения, которое бывают очень разными и зависят от прихотей производителя.
Я нахожу этот протокол исключительно удобным и почти во всем нашем оборудовании его применяем. Вот например, чтобы при помощи гидроакустического модема uWave запросить, скажем глубину другого такого же модема применяется команда такого формата:
$PUWV2,1,0,2*29<CR><LF>
| | | | | | |
| | | | | | └── CRC (0x29)
| | | | | └───── ID запрашиваемого параметра, для uWave здесь 2 - глубина
| | | | └─────── Номер канала, в котором ждать ответ (0)
| | | └───────── Номер канала, в котором ведет прием адресат
| | └─────────── ID команды, здесь 2 - Remote Request
| └───────────── Идентификатор системы команды (типа Manufacturer ID)
└─────────────── P - Proprietary
Если требуется передавать в рамках протокола строки или просто массивы байт, это также несложно сделать. Единственное ограничение на строки - они не должны содержать символы, используемые протоколом для управления: $, ,, *, и символы с кодами 0x0D и 0x0A.
Изначальный стандарт накладывает ограничения на максимальную длину сообщений, если мне не изменяет память, в 82 символа, но совершенно никто не сможет вам помешать использовать для своих целей сообщения большей длины, хотя, конечно, стоит помнить о восьмибитности и некоторой "ущербности" применяемого алгоритма контрольной суммы - все-таки побайтовый xor не самая стойкая к коллизиям конструкция. Можно подумать, что связь по проводам итак достаточно надежная, но нам в работе очень часто приходится передавать такие сообщения, например по радио - обычному, 433 МГц в режиме прозрачного канала, где никто вообще никаких гарантий ни на что не даст.
1. Какой нужен парсер
Задача в целом выглядит так, что надо бы из входного потока байт выбирать целые сообщения по признаку начала и конца, определять тип сообщения и согласно известному списку параметров разбирать их поочередно. Можно выбирать только те, которые интересуют - например, широту и долготу или текущее время.
По сути очевидная реализация - линейный автомат, который:
ждет
$накапливает строку до
\r\nпроверяет CRC
разбивает по запятым
заполняет структуру по полям
Можно сделать этот простейший "велосипед", можно взять что-то готовое, например, TinyGPS++ или microNMEA - там большое комьюнити и наверное все все постоянно проверяют и дорабатывают, и если нужно просто распарсить данные от GNSS-приемника, то наверное проще взять что-то из этого.
Меня не устроило:
использование
strcmp,strtokи прочих подобных (TinyGPS). Хоть там и нет явного выделения памяти, и в целом вопрос дискуссионный, но лучше знать наверняка, что происходит со стеком, чем не знать;Невозможность разбирать только то, что меня интересует: если вы сталкивались обработкой данных с GNSS-приемников, то наверняка знаете, что они вываливают огромную простыню данных, например, параметры DOP, список используемых в решении спутников и их эфемериды. Эти данные не всегда нужны и если решать задачу в лоб, то можно очень много времени тратить на разбор всей этой массы данных;
И главная причина - мне нужен универсальный парсер, я сам хочу формировать и разбирать свои сообщения. Т.е. по сути все равно придется делать что-то свое. И если делать велосипед, то надо делать хороший.
Первая идея нашего парсера состоит в том, что выбираются только те сообщения, которые нужны пользователю. Причем решение принимается как можно раньше: как только у нас уже есть полный идентификатор сообщения мы можем принять решение о том, анализируем мы это сообщение дальше или нет.
Например, если меня интересуют только сообщения, скажем, --RMC (здесь и далее "--" означает, что нам не важен Talker ID - GN, GP, GL и пр.) и --GGA, то приняв $--GSA парсер уже должен прекратить обработку, перейти к ожиданию начала следующего сообщения и не тратить время.
А еще, возможно, мне захочется прямо на лету включить или отключить обработку какого-либо сообщения, кто знает? Такая возможность тоже вполне пригодилась бы.
Напомню, что речь идет про Embedded - нужна надежность и низкие накладные расходы. Значит мы не будем использовать динамическое выделение памяти - только статический буфер.
2. Устройство парсера
Взглянув на сообщения NMEA можно заметить одну деталь: все стандартные сообщения имеют фиксированный размер Talker и Sentence ID - 2 и 3 байта. А как насчет того, чтобы идентификатор сообщениях хранить не в виде строки, а в виде 24-битного числа? И даже не только хранить, а искать, сравнивать.
Напомню, что сначала нам нужно как можно раньше отсечь сообщения, которые нас не интересуют. Поэтому первые шаги алгоритма могут быть такие:
ждем
$накапливаем uint16 - это будет Talker ID
накапливаем три байта и записываем их в uint32 - это будет Sentence ID
ищем в массиве обрабатываемых сообщений, присутствует ли такой Sentence ID
если не присутствует - переходим к ожиданию следующего сообщения
Вот список самых наиболее часто употребимых идентификаторов сообщений, которые парсер поддерживает "из коробки":
Sentence ID |
Значение |
|---|---|
RMC |
|
GGA |
|
GLL |
|
GSA |
|
GSV |
|
VTG |
|
HDT |
|
HDG |
|
ZDA |
|
MTW |
|
Возникает резонный вопрос: как быть с P-сообщениями? Здесь придется оставить место для компромисса. У нас есть две новости: плохая и хорошая.
Плохая заключается в том, что идентификатор проприетарных сообщений вообще никак не стандартизируется - каждый производитель может что угодно делать. Например, у GNSS-приемников с протоколом MTK (Например, Quectel) проприетарный команды имеют идентификаторы 001 - 399. Выглядит это как $PMTK314,....., у некоторых они вообще могут иметь разную длину.
Хорошая же состоит в том, что если требуется создание своего протокола, то это можно лекго учесть: трехбайтный Manufacturer ID вместе одним символом в качестве идентификатора команды дает uint32, который с то же легкостью можно искать в таблице обрабатываемых сообщений.
Можно также "отложить проблему на потом" - искать сообщение с идентификатором, скажем, MTK0 - это умещается в 32-битное целое, а уже при разборе полей анализировать оставшуюся часть этого идентификатора, и выяснить, например, что это сообщение MTK001 или какое-то другое.
Естественно, нужно в алгоритме учесть, обрабатывается ли стандартное или проприетарное сообщение. И еще о чем не стоит забывать - сразу после получения $ нужно начинать считать контрольную сумму: мы будем считать ее на лету и когда дойдем до * то у нас будет с чем сравнить. Если же источник был настолько ленив, что не снабдил сообщение контрольной суммой - это тоже нужно учесть и априори считать, что целостность не нарушена.
Ну и пора ввести необходимые структуры данных и обозначить модули.
У нас будет функция, которая принимает на вход очередной байт и возвращает значение типа enum, которого говорит о текущем состоянии парсера:
typedef enum {
NMEA_RESULT_PACKET_READY = 0, // В буфере лежит готовое сообщение
NMEA_RESULT_BYPASS_BYTE = 1, // пропустили этот байт
NMEA_RESULT_PACKET_STARTED = 2, // начался набор сообщения
NMEA_RESULT_PACKET_PROCESS = 3, // сообщение в процессе
NMEA_RESULT_PACKET_CHECKSUM_ERROR = 4, // сообщение с ошибкой контрольной суммы
NMEA_RESULT_PACKET_TOO_BIG = 5, // сообщение никак не кончается а буфер уже
NMEA_RESULT_PACKET_SKIPPING = 6, // это сообщение пропускаем
NMEA_RESULT_UNKNOWN // для порядка
} NMEA_Result_Enum;
Теперь опишем структуру состояния парсера, т.к. он у нас конченный автомат:
typedef struct {
uint8_t* buffer; // Указатель на буфер
uint8_t buffer_size; // Размер буфера
uint8_t idx; // Текущий индекс в буфере
bool isReady; // Признак готового сообщения
bool isStarted; // Признак того, что сообщение в процессе приема
bool isPSentence; // Признак P-сообщения
uint8_t chk_act; // Фактическое значение контрольной суммы
uint8_t chk_dcl; // Заявленное значение контрольной суммы
uint8_t chk_dcl_idx; // Индекс байта контрольной суммы
bool chk_present; // Признак наличия поля контрольной суммы
uint16_t tkrID; // Talker ID - идентификатор источника
uint32_t sntID; // Sentence ID - идентификатор сообщения
uint32_t* sntIDs; // Указатель на идентификаторы, которые мы обрабатываем
uint8_t sntIDs_size; // И размер этого буфера
} NMEA_State_Struct;
За вычетом размеров буфера и массива идентификаторов обрабатываемых сообщений, размер структуры составляет порядка 25-32 байт в зависимости от платформы и выравнивания.
Размер байтового буфера, куда мы складываем сообщение ограничен 256 байтами, но, как правило размеры стандартных сообщений не превышают 100 символов, например, сообщение RMC имеет длину 60 байт и если нас интересует только оно, то общий memory footprint составит порядка 140 байт, без учета стека и пр.
2.1. Пару слов о применении
Прежде чем мы рассмотрим саму функцию обработки входных данных, пару слов о том, как пользователь может инициализировать парсер:
void NMEA_InitStruct(NMEA_State_Struct* uState, uint8_t* buffer, uint8_t buffer_size, uint32_t* sntIDs, uint8_t sntIDs_size) {
uState->isReady = false;
uState->buffer = buffer;
uState->buffer_size = buffer_size;
uState->sntIDs = sntIDs;
uState->sntIDs_size = sntIDs_size;
uState->isStarted = false;
}
Выше функция для инициализации структуры. Как видим, в нее передается буфер под сообщение и его размер, а так же массив, содержащий интересующие нас идентификаторы сообщений с его размером.
Вот фактически пример из реального устройства:
// Объявления и переменные
#define PMTK0_SNT_ID (0x4D544B30) // MTK0
#define IN_BUFFER_SIZE (128)
#define GNSS_SNT_IDS_SIZE (2)
uint32_t gnss_sntIDs[GNSS_SNT_IDS_SIZE] = { NMEA_RMC_SNT_ID, PMTK0_SNT_ID };
uint8_t gnss_inbuffer[IN_BUFFER_SIZE];
UCNL_NMEA_State_Struct gnss_parser;
UCNL_NMEA_Result_Enum n_result;
// В инициализации
NMEA_InitStruct(&gnss_parser, gnss_inbuffer, IN_BUFFER_SIZE, gnss_sntIDs, GNSS_SNT_IDS_SIZE);
// При обработке входящего байта
n_result = UCNL_NMEA_Process_Byte(&gnss_parser, Ring_u8_Read(&gnss_iring));
if (n_result == UCNL_NMEA_RESULT_PACKET_READY) {
if (gnss_parser.sntID == UCNL_NMEA_RMC_SNT_ID) {
UCNL_NMEA_Parse_RMC(&rmc_data, gnss_parser.buffer, gnss_parser.idx);
CP_RMC_Received(&rmc_data);
} else if (gnss_parser.sntID == PMTK0_SNT_ID) {
CP_PMTK0_Received();
}
UCNL_NMEA_Release(&gnss_parser); // просто сброс флага isReady
}
}
Для пуристов предлагаю схему конечного автомата и таблицу переходов:
2.2. Конечный автомат
┌─────────────────────────────────────────────────────────────┐
│ СОСТОЯНИЯ АВТОМАТА │
├─────────────────────────────────────────────────────────────┤
│ 1. WAIT_START - Ожидание начала сообщения ($) │
│ 2. HEADER_PARSE - Разбор заголовка (байты 1-5) │
│ 3. DATA_PARSE - Разбор данных (байты 6-N) │
│ 4. CHECKSUM_PARSE - Разбор контрольной суммы (2 байта) │
│ 5. PACKET_READY - Сообщение готово (\n получен) │
│ 6. ERROR_STATES - Ошибочные состояния │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────┐
│ ИСХОДНОЕ СОСТОЯНИЕ │
│ WAIT_START (Ожидание начала) │
│ isStarted = false, isReady = false │
└─────────────────┬───────────────────┘
│
│ Получен байт == '$'
▼
┌────────────────────────────────────────────────────┐
│ START_PACKET (Начало сообщения) │
│ isStarted = true, result = PACKET_STARTED │
│ Очистка буфера и счетчиков, запись '$' в буфер │
└─────────────────┬──────────────────────────────────┘
│
│ Следующий байт (idx=1)
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ HEADER_PARSE STATE │
│ Разбор байтов 1-5: определение типа сообщения и источника │
│ Состояния: chk_dcl_idx = 0 (парсим данные заголовка) │
└───┬──────────────────────────────────────────────────────────────────┬──┘
│ │
│ │
│ idx=1: Проверка на 'P' (проприетарное сообщение) │
│ idx=2-5: Сбор sntID (24-битный идентификатор сообщения) │
│ │
│ После idx=5: Проверка наличия sntID в списке поддерживаемых │
│ Если НЕ найден → ERROR_SKIPPING │
│ │
▼ ▼
│ │
│ После idx>5 │
│ │
▼ │
┌─────────────────┐ │
│ DATA_PARSE STATE│ │
│ chk_dcl_idx = 0 │ │
│ Накопление XOR │ │
│ для контрольной │ │
│ суммы, запись │ │
│ в буфер │ │
└────────┬────────┘ │
│ │
│ Получен '*' (NMEA_CHK_SEP) │
│ │
▼ │
┌──────────────────┐ │
│ CHECKSUM_PARSE │ │
│ chk_dcl_idx = 1 │ │
│ Первый hex-символ│ │
└────────┬─────────┘ │
│ │
│ Второй hex-символ │
│ chk_dcl_idx = 2 │
│ │
▼ │
┌─────────────────┐ │
│ CHECKSUM_VERIFY │ │
│ chk_dcl_idx = 3 │ │
│ Сравнение CRC │ │
│ Если ошибка → │ │
│ ERROR_CHECKSUM │ │
└────────┬────────┘ │
│ │
│ │
▼ │
Получен '\n' │
(NMEA_SNT_END) │
│ │
▼ │
┌─────────────────┐ ┌─────────────────┐ │
│ PACKET_READY │ │ ERROR_STATES │◄────────────────┘
│ isStarted=false │ │ isStarted=false │
│ isReady=true │ │ result=ERROR │
│ result=READY │ └─────────────────┘
└─────────────────┘ ▲
│ │
│ Сброс парсера │ Ошибки:
│ NMEA_Release() │ 1. PACKET_TOO_BIG (буфер полон)
▼ │ 2. PACKET_SKIPPING (sntID не найден)
┌─────────────────┐ │ 3. CHECKSUM_ERROR (CRC не совпал)
│ WAIT_START │◄────────────────────────┘
└─────────────────┘
Таблица переходов состояний (STT)
Текущее состояние |
Условие перехода |
Действие |
Следующее состояние |
|---|---|---|---|
WAIT_START |
|
Сброс счетчиков, |
HEADER_PARSE |
WAIT_START |
Любой другой байт |
Возврат |
WAIT_START |
HEADER_PARSE |
|
Установка |
HEADER_PARSE |
HEADER_PARSE |
|
Запись в |
HEADER_PARSE |
HEADER_PARSE |
|
Запись в |
HEADER_PARSE |
HEADER_PARSE |
|
Сбор |
HEADER_PARSE |
HEADER_PARSE |
|
|
ERROR_SKIPPING |
HEADER_PARSE |
|
Переход к парсингу данных |
DATA_PARSE |
DATA_PARSE |
|
|
CHECKSUM_PARSE |
DATA_PARSE |
|
|
ERROR_TOO_BIG |
DATA_PARSE |
Любой другой байт |
|
DATA_PARSE |
CHECKSUM_PARSE |
|
Парсинг первого hex-символа |
CHECKSUM_PARSE |
CHECKSUM_PARSE |
|
Парсинг второго hex-символа, проверка CRC |
CHECKSUM_VERIFY |
CHECKSUM_VERIFY |
|
|
ERROR_CHECKSUM |
CHECKSUM_VERIFY |
|
Ожидание |
DATA_PARSE |
Любое активное |
|
|
PACKET_READY |
Поток данных через автомат выглядит следующим образом:
Байт → [WAIT_START] → [HEADER_PARSE] → [DATA_PARSE] → [CHECKSUM_PARSE] → [Готово]
↓ ↓ ↓ ↓
Пропуск Сбор ID XOR CRC Проверка CRC
(если не $) (1-5 байты) (6+ байты) (*XX)
А вот и код функции UCNL_NMEA_Process_Byte под спойлером:
NMEA_Result_Enum NMEA_Process_Byte(NMEA_State_Struct* uState, uint8_t newByte) {
NMEA_Result_Enum result = NMEA_RESULT_BYPASS_BYTE;
if (!uState->isReady) {
if (newByte == NMEA_SNT_STR) {
uState->isStarted = true;
result = NMEA_RESULT_PACKET_STARTED;
uint8_t i = 0;
for (i = 0; i < uState->buffer_size; i++)
uState->buffer[i] = 0;
uState->chk_act = 0;
uState->chk_dcl = 0;
uState->chk_dcl_idx = 0;
uState->idx = 0;
uState->tkrID = 0;
uState->sntID = 0;
uState->isPSentence = false;
uState->buffer[uState->idx] = newByte;
uState->idx++;
} else {
if (uState->isStarted) {
result = NMEA_RESULT_PACKET_PROCESS;
uState->buffer[uState->idx] = newByte;
if (newByte == NMEA_SNT_END) {
uState->isStarted = false;
uState->isReady = true;
result = NMEA_RESULT_PACKET_READY;
} else if (newByte == NMEA_CHK_SEP) {
uState->chk_dcl_idx = 1;
uState->chk_present = true;
} else {
if (uState->idx >= uState->buffer_size) {
uState->isStarted = false;
result = NMEA_RESULT_PACKET_TOO_BIG;
} else {
if (uState->chk_dcl_idx == 0) {
uState->chk_act ^= newByte;
if (uState->idx == 1)
if (newByte == NMEA_PSENTENCE_SYMBOL)
uState->isPSentence = true;
else
uState->tkrID = ((uint16_t)newByte) << 8;
else if (uState->idx == 2)
if (uState->isPSentence)
uState->sntID = ((uint32_t)newByte) << 24;
else
uState->tkrID |= newByte;
else if (uState->idx == 3)
if (uState->isPSentence)
uState->sntID |= (((uint32_t)newByte) << 16);
else
uState->sntID = (((uint32_t)newByte) << 16);
else if (uState->idx == 4)
uState->sntID |= (((uint32_t)newByte) << 8);
else if (uState->idx == 5) {
uState->sntID |= newByte;
uint8_t i = 0;
while ((i < uState->sntIDs_size) && (uState->sntID != uState->sntIDs[i]))
i++;
if (i >= uState->sntIDs_size) {
uState->isStarted = false;
result = NMEA_RESULT_PACKET_SKIPPING;
}
}
} else if (uState->chk_dcl_idx == 1) {
uState->chk_dcl = 16 * STR_HEXDIGIT2B(newByte);
uState->chk_dcl_idx++;
} else if (uState->chk_dcl_idx == 2) {
uState->chk_dcl += STR_HEXDIGIT2B(newByte);
if (uState->chk_act != uState->chk_dcl) {
uState->isStarted = false;
result = NMEA_RESULT_PACKET_CHECKSUM_ERROR;
}
uState->chk_dcl_idx++;
}
}
}
uState->idx++;
}
}
}
return result;
}
Нельзя, видимо, сказать, что код выглядит элегантно, но и не то чтобы прям совсем плохо. Все в статической памяти, никаких излишеств. В среднем на какой-то простой платформе на обработку одного байта требуется ~10-50 тактов.
Чтобы лучше понять работу алгоритма я подготовил онлайн-демку, которой можно задать сообщение и нажимая кнопку "ШАГ", побайтно скормить сообщение парсеру, наблюдая за активными ветками алгоритма и изменением состояния сознания структуры.
3. Разбор сообщений
Конечно, к этому моменту может возникнуть вопрос: а где же разбор сообщений? Ведь то, что было представлено до этого только с натяжкой является парсером - это скорее поиск и валидация.
Да. После того, как интересующее сообщение попало в буфер нужно достать из него информацию. Здесь я не предложу какие-то новаций.
У меня на каждое сообщение отдельная функция-парсер и соответственно стуктура, которая этой функцией заполняется.
Разберем на примере того же RMC, как наиболее часто употребимого. Вот структура, описывающая его:
typedef struct {
bool isValid;
uint8_t hour;
uint8_t minute;
float second;
uint8_t date;
uint8_t month;
uint8_t year;
float latitude_deg;
float longitude_deg;
float speed_kmh;
float course_deg;
} NMEA_RMC_RESULT_Struct;
А вот код функции-парсера
bool NMEA_Parse_RMC(NMEA_RMC_RESULT_Struct* rdata, const uint8_t* buffer, uint8_t idx) {
// Sentence example:
// $GPRMC,230540.00,A,5312.1329616,N,15942.6950884,E,4.9,217.1,290421,999.9,E,D*3C
bool isNotLastParam = false;
bool result = true;
uint8_t pIdx = 0, ndIdx = 0, stIdx = 0;
do {
isNotLastParam = NMEA_Get_NextParam(buffer, ndIdx + 1, idx, &stIdx, &ndIdx);
switch (pIdx) {
case 1: // Time
if (ndIdx < stIdx)
result = false;
else {
rdata->hour = STR_CC2B(buffer[stIdx], buffer[stIdx + 1]);
rdata->minute = STR_CC2B(buffer[stIdx + 2], buffer[stIdx + 3]);
rdata->second = STR_ParseFloat(buffer, stIdx + 4, ndIdx);
if (!NMEA_IS_VALID_HOUR(rdata->hour) ||
!NMEA_IS_VALID_MINSEC(rdata->minute) ||
!NMEA_IS_VALID_MINSEC(rdata->second))
result = false;
}
break;
case 2: // Time validity flag
if ((ndIdx < stIdx) || (buffer[stIdx] != NMEA_TD_VALID))
result = false;
break;
case 3: // Latitude
if (ndIdx < stIdx)
result = false;
else
rdata->latitude_deg = (float)STR_CC2B(buffer[stIdx], buffer[stIdx + 1]) +
STR_ParseFloat(buffer, stIdx + 2, ndIdx) / 60.0;
if (!NMEA_IS_VALID_LATDEG(rdata->latitude_deg))
result = false;
break;
case 4: // Latitude hemisphere
if (ndIdx < stIdx)
result = false;
else if (buffer[stIdx] == NMEA_SOUTH_SIGN)
rdata->latitude_deg = -rdata->latitude_deg;
break;
case 5: // Longitude
if (ndIdx <= stIdx)
result = false;
else
rdata->longitude_deg = (float)STR_CCC2B(buffer[stIdx], buffer[stIdx + 1], buffer[stIdx + 2]) +
STR_ParseFloat(buffer, stIdx + 3, ndIdx) / 60.0;
if (!NMEA_IS_VALID_LONDEG(rdata->longitude_deg))
result = false;
break;
case 6: // Longitude hemisphere
if (ndIdx < stIdx)
result = false;
else if (buffer[stIdx] == NMEA_WEST_SIGN)
rdata->longitude_deg = -rdata->longitude_deg;
break;
case 7: // Speed in knots
if (ndIdx >= stIdx)
rdata->speed_kmh = STR_ParseFloat(buffer, stIdx, ndIdx) * 1.852; //
break;
case 8: // Course in degrees
if (ndIdx >= stIdx)
rdata->course_deg = STR_ParseFloat(buffer, stIdx, ndIdx);
break;
case 9: // Date
if (ndIdx < stIdx)
result = false;
else {
rdata->date = STR_CC2B(buffer[stIdx], buffer[stIdx + 1]);
rdata->month = STR_CC2B(buffer[stIdx + 2], buffer[stIdx + 3]);
rdata->year = STR_CC2B(buffer[stIdx + 4], buffer[stIdx + 5]);
if (!NMEA_IS_VALID_DATE(rdata->date) ||
!NMEA_IS_VALID_MONTH(rdata->month) ||
!NMEA_IS_VALID_YEAR(rdata->year))
result = false;
}
break;
case 12: // Data validity flag
if ((ndIdx < stIdx) || (buffer[stIdx] == NMEA_DATA_NOT_VALID))
result = false;
break;
default:
break;
}
pIdx++;
} while (isNotLastParam && result);
rdata->isValid = result;
return result;
}
`
Конечно, этот код легко дорабатывается под конкретные нужды путем выкидывания не интересующих нас веток в switch-case.
Поиск индексов начала и конца текущего параметра выполняются такой функцией:
bool NMEA_Get_NextParam(...
bool NMEA_Get_NextParam(const uint8_t* buffer, uint8_t fromIdx, uint8_t size, uint8_t* stIdx, uint8_t* ndIdx) {
uint8_t i = fromIdx + 1;
*stIdx = fromIdx;
*ndIdx = *stIdx;
while ((i <= size) && (*ndIdx == *stIdx)) {
if ((buffer[i] == NMEA_PAR_SEP) ||
(buffer[i] == NMEA_CHK_SEP) ||
(buffer[i] == NMEA_SNT_END1) ||
(i == size)) {
*ndIdx = i;
} else {
i++;
}
}
(*stIdx)++;
(*ndIdx)--;
return ((buffer[i] != NMEA_CHK_SEP) && (i != size) && (buffer[i] != NMEA_SNT_END1));
}
На остальном не буду останавливаться подробно. Скажу лишь, что парсинг (и преобразование в строку) чисел вынесен в отдельный модуль. Так же, как уже было упомянуто, библиотека из коробки имеет арсенал для парсинга основных сообщений, которые можно получить от среднего GNSS-приемника и даже больше.
4. Резюмируем
4.1. Особенности
Среди особенностей реализации можно выделить такие (не все строго положительные - это не плюсы, а особенности):
Это Гибридный автомат - использует как явные флаги (
isStarted,isReady), так и неявное состояние черезidxиchk_dcl_idx;Ранний отсев - сразу после получения полного
sntID(байт 5) происходит проверка, нужно ли это сообщение;Инкрементальная проверка CRC - XOR накапливается на лету, а не после получения всего сообщения;
Обработка проприетарных сообщений - отдельная ветка для сообщений, начинающихся с 'P';
Защита от переполнения - проверка
idx >= buffer_sizeна каждом шаге;Поддержка сообщений без CRC - если
*не получен, сообщение считается валидным (при условии корректного\n).
В итоге мы пришли к реализации наших пожеланий к парсеру, а именно:
минимальный memory footprint
все делается за один проход (поиск и валидация сообщения, разбор полей - второй проход)
ненужное отсеивается сразу (хотя можно еще раньше в принципе)
полностью статическая память
4.2. Memory footprint
Если сравнивать по использованию памяти с самыми популярными парсерами, то получается такая табличка:
Решение / Конфигурация |
RAM (отдельные) |
RAM (с union) |
ROM |
Особенности |
|---|---|---|---|---|
1. Наш парсер (RMC) |
131 байт |
131 байт |
1.4 КБ |
Минимальная конфигурация |
2. Наш парсер (4 типа) |
270 байт |
170 байт |
2.8 КБ |
Как MicroNMEA |
3. Наш парсер (10 типов) |
454 байт |
210 байт |
5.2 КБ |
Все стандартные типы |
4. MicroNMEA (4 типа) |
~220 байт |
- |
2.5-3.0 КБ |
Фиксированный набор |
5. TinyGPS++ |
800-1000 байт |
- |
3.0-5.0 КБ |
Все включено, String классы |
6. Наш (TinyGPS режим) |
454 байт |
210 байт |
5.5 КБ |
Та же функциональность |
Дополнительно приведена колонка, если вдруг нам захочется оптимизировать полностью и мы похожие данные (например, координаты и время несколько раз встречаются в разных сообщениях) объединим.
4.3. Производительность
Я понимаю, что оправдание есть у каждого и у меня до замеров скорости на железе руки не дошли.
Но!
Мы можем волюнтаристически прикинуть производительность нашего парсера и наиболее популярных других.
Какие метрики хотелось бы проанализировать:
сколько тратится тактов на обработку одного байта
сколько тактов тратится на обработку одного какого-нибудь популярного сообщения
Итак,
Примерно прикинем для трех платформ - Cortex-M0, Cortex-M4 и AVR сколько тактов требуется на парсинг сообщения RMC.
Для указанных платформ основные операции имеют такую стоимость в тактах:
Операция |
Cortex-M0 |
Cortex-M4 |
AVR |
|---|---|---|---|
LDR/STR (загрузка/сохранение) |
2 |
1-2 |
2 |
CMP + условный переход |
2-4 |
1-3 |
2-3 |
ADD/SUB |
1 |
1 |
1 |
AND/ORR/EOR |
1 |
1 |
1 |
8-битный сдвиг |
1 такт/бит |
1 такт |
1 такт |
16-битный сдвиг |
1 такт/бит |
1 такт |
2 такта |
32-битный сдвиг |
1 такт/бит |
1 такт |
4+ такта |
Умножение 8×8 |
1-2 |
1 |
2-4 |
Вызов функции |
3-5 |
2-3 |
4-6 |
Цикл (на итерацию) |
2-3 |
1-2 |
2-3 |
Для нашего парсера отдельно посчитаем поиск с валидацией и собственно парсинг - разбор полей сообщений.
Для Cortex-M0
Байты 1-6 (заголовок):
'$': Инициализация: 40 тактов
'G': tkrID = byte << 8 (8 тактов): 30 тактов
'P': tkrID |= byte: 26 тактов
'R': sntID = byte << 16 (16 тактов): 50 тактов
'M': sntID |= byte << 8 (8 тактов): 40 тактов
'C': sntID |= byte + поиск в массиве: 45 тактов Итого заголовок: 40+30+26+50+40+45 = 231 такт
Байты 7-64 (58 байт данных):
Базовые проверки + XOR: 20 тактов/байт
58 × 20 = 1160 тактов
Байт 65 '*': Переход в режим CRC: 22 такта
Байт 66 '6': Первый hex CRC: 35 тактов
Байт 67 'A': Второй hex CRC: 35 тактов
Байт 68 '\n': Конец сообщения: 25 тактов
Итого валидация: 231+1160+22+35+35+25 = 1508 тактов
NMEA_Get_NextParam: 12 вызовов × 45 тактов = 540 тактов
STR_CC2B: 8 чисел × 15 тактов = 120 тактов
STR_CCC2B: 1 число × 20 тактов = 20 тактов
STR_ParseFloat: 5 чисел × 250 тактов = 1250 тактов
Проверки валидности: 12 × 12 = 144 тактов
Конвертации (узлы→км/ч): 1 × 50 = 50 тактов
Итого парсинг: 540+120+20+1250+144+50 = 2124 такта
Все в сумме: 1508+2124 = 3632 такта
Для Cortex-M4, имеющего т.н. barrel shifter, сдвиги происходят значительно легче - всего за 1 такт вместо по такту на бит. Поэтому валидация пройдет чуть быстрее (минус 200 тактов), а за счет более быстрых вызовов, примерно на 20% уменьшиться число тактов на разбор полей. Примерно это выльется в 3000+ тактов.
Для AVR, у которого все 32-битное и все, что связано с float сильно дороже, и валидация и разбор полей идут значительно медленнее - примерно должно быть 1800+ и 2500+ тактов соответственно и на все должно быть где-то 4300+ тактов.
Для TinyGPS++, который использует строковые операции с выделением памяти и для MicroNMEA прикинем еще более примерно:
TinyGPS++
Cortex-M0:
strtok() 12 токенов: 12 × 120 = 1440 тактов
atof() 6 чисел: 6 × 600 = 3600 тактов (float на M0 очень медленно!)
String операции: 500 тактов
Проверки: 300 тактов Итого: ~5840 тактов
Cortex-M4:
atof() с FPU: 6 × 150 = 900 тактов (в 4× быстрее)
Остальное: 1440+500+300 = 2240 тактов Итого: 3140 тактов
AVR:
atof() на AVR: 6 × 800 = 4800 тактов (очень медленно)
strtok(): 12 × 150 = 1800 тактов Итого: ~7100 тактов
MicroNMEA
Cortex-M0:
Ручной парсинг чисел (не atof): 6 × 200 = 1200 тактов
Разбивка полей: 12 × 70 = 840 тактов
Проверки и CRC: 400 тактов
Итого: ~2440 тактов
Cortex-M4:
Парсинг быстрее: 6 × 120 = 720 тактов
Итого: ~1960 тактов
AVR:
Ручной парсинг: 6 × 300 = 1800 тактов
Итого: ~3040 тактов
В итоге получаем такую таблицу, где описывается сколько времени какой парсер тратит на парсинг сообщения RMC:
Парсер |
Cortex-M0 |
Cortex-M4 |
AVR |
|---|---|---|---|
Наш |
3632 тактов |
3008 тактов |
4308 тактов |
MicroNMEA |
2440 тактов |
1960 тактов |
3040 тактов |
TinyGPS++ |
5840 тактов |
3140 тактов |
7100 тактов |
Да, мы некоторым образом медленнее MicroNMEA, но надо учитывать, что речь идет о сообщении RMC, а в реальности GNSS-приемник валит очень много всего.
Возьмем какой-нибудь типичный GNSS-приемник типа ublox/Quectel, он выдает примерно такой набор раз в секунду:
RMC - 1× (70 байт) - НУЖНО
GGA - 1× (75 байт) - НУЖНО
GSA - 1× (65 байт) - НЕ НУЖНО
GSV - 3× (70 байт) - НЕ НУЖНО (210 байт)
VTG - 1× (60 байт) - НЕ НУЖНО
GLL - 1× (~40 байт) - НЕ НУЖНО
Опять же, валюнтаристически примем, что парсинг GGA чуть дороже, чем RMC и тогда получается такая окончательная таблица:
Парсер |
Конфигурация |
Cortex-M0 (тактов/сек) |
|---|---|---|
Наш |
С фильтрацией (RMC+GGA) |
7,632 |
Наш |
Без фильтрации (все) |
18,132 |
MicroNMEA |
Все сообщения |
7 × 2440 = 17,080 |
TinyGPS++ |
Все сообщения |
7 × 5840 = 40,880 |
Конечно, прикидки очень грубые. Конечно, можно у приемника настроить вывод только нужных сообщений. Но в общем-то я пока даже не пытался заниматься низкоуровневой оптимизацией, а там есть где разгуляться.
На этом будем заканчивать и перейдем к обещанному бонусу.
5. Бонус
Я использую NMEA0183, страшно сказать, года с 2008-ого наверное. В 2011-2012 я написал, как мне тогда казалось, убер-библиотеку на эту тему, правда на C# - его я использую для десктопных задач.
Библиотека знает все стандартные сообщения и некоторые проприетарные. И не просто умеет их парсить, а содержит разные описания: что означает тот или иной Talker ID или Sentence ID.
Более того, она позволяет добавлять свои форматы сообщений.
В прошлом году я в полуавтоматическом режиме перевел ее и на JS, поэтому ей можно пользоваться онлайн - просто заходим, скармливаем любое NMEA-сообщение и получаем названия и значения полей.
Я стараюсь по мере сил и времени ее поддерживать в актуальном состоянии.
Все полезные ссылки из этой статьи в одном месте:
5. Outro
Что ж, Long read - long write!
Этой публикацией некоторым образом закрыл давний гештальт, поставил галочку, крестик, перевернул страницу, затянул кота в долгий ящик, сделал дело и гуляю смело.
Искренне благодарю вас за интерес к этой теме, буду рад выслушать конструктивную критику, предложения, пожелания, вопросы.
Комментарии (6)

slog2
12.12.2025 10:16А как насчет того, чтобы идентификатор сообщениях хранить не в виде строки, а в виде 24-битного числа? И даже не только хранить, а искать, сравнивать.
А какая процессору разница сравнивает он числа или буквы? А если 8-ми битный то и копить символы не надо, если пришёл первый не совпавший с ожидаемой строкой сразу бросаем парсить и ждём $.

LAutour
12.12.2025 10:16А какая процессору разница сравнивает он числа или буквы?
Разница будет хорошо заметна на 32 или 16 битном проце при сравнии с несколькоми вариантами.

slog2
12.12.2025 10:16С 8-ми битным процом вариант сравнивать 24-бит числа заведомо проигрышный по сравнению с вариантом сравнили поступивший байт, не совпало - выход. В чём разница для 16 или 32 битного, накопили 3 байта (зачем если первый уже не совпадает, ну да ладно) и сравнили с
0x524D43или с (uint32_t)(('R'<<16) | ('M'<<8) | 'C') ? Второй вариант в тексте нагляднее, вот и вся разница.
LAutour
12.12.2025 10:16Ну одним RMC не всегда ограничено, при необходимости там может быть потребность и других ид пакетов, различающихся лишь по последней букве или предпоследней букве. Можно конечно для минимизации проверок сделать дерево последовательных проверок по байтам, но наглядность кода будет плохая (при наличии возможности сравнить сразу всю строчку).
и
enum{S_RMC = ('R'<<16) | ('M'<<8) | 'C'};
еще нагляднее.
LAutour
С DMA приемом данных не все прокатит (актуален при высокой скорости приема NMEA по UART).
Автоматы, состояния, сдвиги - тоже сравнивал ID как числа, но обходился без этого.
По парсеру RMC: я просто делал массив указателей на подстроки (замена запятых на NULL), а потом обрабатывал нужные подстроки по индексам.
AlekDikarev Автор
Если верно понял ваше опасение, то скажу, что не суть какой способ получать данные - хоть прерывание, хоть ДМА, хоть флаг проверять в цикле: набираете (например) кольцевой буфер, и когда удобно просто его побайтно скармливаете парсеру.