Приветствую, глубокоуважаемые!
Будем стараться делать хорошо, плохо само получится (С)
Любите ли вы 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!
Этой публикацией некоторым образом закрыл давний гештальт, поставил галочку, крестик, перевернул страницу, затянул кота в долгий ящик, сделал дело и гуляю смело.
Искренне благодарю вас за интерес к этой теме, буду рад выслушать конструктивную критику, предложения, пожелания, вопросы.