GPS и сбоку бантик. Многофункциональный GPS Logger. Часть 2

в 19:53, , рубрики: arduino, diy или сделай сам, gps, gps logger, stm32, гаджеты, Носимая электроника

image

Всем привет! Некоторое время назад я загорелся идеей проапгрейдить свой верный и любимый GPS логгер Holux M241. Можно было бы поискать чего нибудь интересное на рынке, что могло бы удовлетворить мои потребности. Но мне было интереснее копнуть в сторону микроконтроллеров, NMEA GPS протокола, USB и SD Card премудростей, тем самым построив устройство своей мечты.

Что же именно я строю я детально описал в первой части. На том этапе я пристреливался к технологиям — щупал Arduino в контексте сравнительно большого проекта. Оказалось есть масса нюансов, которые в обычных туториалах особо не затрагивают. В комментариях я получил массу интересного инпута, за что очень благодарен читателям. Надеюсь и сегодня Вы найдете чего нибудь интересненькое.

Это вторая статья из серии. Как и предыдущая она является своего рода журналом постройки. Я стараюсь описывать технические решения, которые я принимаю по ходу работы над проектом. Сегодня мы будем подключать GPS. А еще переходить на более взрослые технологии — FreeRTOS и микроконтроллер STM32. Ну и как всегда будем дизассемблировать прошивку и смотреть что же там написано.

Прошу под кат.

GPS'им

К этому времени у меня уже был каркас приложения. Все крутилось на Arduino Nano на контроллере ATMega328. Пришло время подключать мой GPS приемник Beitan BN-880.

Мысли про UART

У меня есть некоторое предвзятое отношение к UART как к низкоскоростному протоколу из прошлого века. Разумом то, конечно, понимаю — интерфейс простой как 3 копейки, работает на всем что движется. Что еще нужно? Еще я предвзято отношусь к текстовым протоколам — сообщения же еще парсить нужно. Почему бы данные не гонять в бинарном виде? Да еще пакетами? Все равно их человек не читает. А бинарные пакеты могли бы значительно упростить обработку. Ну то я так, жужжу.

Увидев ноги SDA и SCK торчащие из модуля мне захотелось к ним прицепиться. Прицепился и…. понял, что данные получить не так то просто. Я даже и не знаю как. Если используется UART, то GPS приемник просто насыпает сообщения, а получатель парсит что ему нужно. I2C же передача инициируется только со стороны хоста. Т.е. нужно сформировать некий запрос, чтобы получить ответ. Но какой?

Гуглеж на тему BN-880 I2C в течении пары часов ничего полезного не дал. Народ просто использует UART, а большая часть ссылок вела на форумы квадрокоптерщиков и обсуждались там в основном квадрокоптерные проблемы.

На даташиты выйти было не так просто. Т.е. не совсем понятно было на какой модуль искать даташит. По косвенным признакам я выяснил, что за GPS отвечает модуль UBlox NEO-M8N. Оказалось что эта штука умеет такое количество фич, что мама не горюй (там даже встроенный одометр и логгер есть). Но читать нужно было ни много ни мало 350 страниц.

Полистав туда-сюда даташит я понял, что с наскоку этот модуль не взять. Пришлось наступить себе на горло и подключить к уже проверенному UART. И тут же вступить в другую проблему: на ардуине UART только один, и тот торчит в сторону компа (заливать прошивки). Пришлось смотреть в сторону библиотеки SoftwareSerial.

Написал простейший “переливатор” сообщений из порта GPS в UART.

Переливатор

SoftwareSerial gpsSerial(10, 11); // RX, TX
 
void setup()
{
        	Serial.begin(9600);
        	gpsSerial.begin(9600);
}
 
void loop()
{
        	if (gpsSerial.available()) {
                    	Serial.write(gpsSerial.read());
        	}
}

Посыпались сообщения, но спутники словить так и не смог. Хотя время было правильное.

$GNRMC,203954.00,V,,,,,,,,,,N*6A
$GNVTG,,,,,,,,,N*2E
$GNGGA,203954.00,,,,,0,00,99.99,,,,,,*71
$GNGSA,A,1,,,,,,,,,,,,,99.99,99.99,99.99*2E
$GNGSA,A,1,,,,,,,,,,,,,99.99,99.99,99.99*2E
$GPGSV,1,1,02,02,,,21,08,,,09*7B
$GLGSV,1,1,00*65
$GNGLL,,,,,203954.00,V,N*5D

GPS пролежал больше часа у окна 21 этажа прежде чем выдал вменяемые координаты. Причем большая часть обзора у меня не закрыта высокоэтажной застройкой. Есть подозрение, что на окнах нанесено некое напыление, которое ухудшает качество сигнала. Во всяком случае возле открытого окна спутники, как будто, ловятся быстрее.

Раз есть сигнал, значит можно парсить. На просторах интернета первой попалась библиотека TinyGPSPlus. Подключил не без хаков. В ArduinoIDE все работало, но в Atmel Studio не хотело. Пришлось вручную прописать пути к библиотеке.

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

Первое предположение было в том, что SoftwareSerial весьма серьезно жрал ресурсы процессора. А значит SoftwareSerial нужно отправлять в топку, т.к. для надежной коммуникации с GPS он не подходит (во всяком случае в том виде, в котором он в примерах). Я даже хотел вывернуть схему наизнанку: GPS подключить к аппаратному UART’у ардуины, а софтварный сериал использовать для дебага (хотя при наличии экрана дебажиться через UART может даже и не потребуется). Но при такой схеме загружать прошивку через UART уже не получится. Пришлось достать программатор USBAsp.

Но чуть позже я понял, что SoftwareSerial штука хоть и прожорливая, но в данном случае проблема совсем не в нем, а в функции рисования. Отрисовка текущего экрана занимает 50-75мс (плюс еще чуток на накладные расходы). SoftwareSerial работает на прием по прерыванию на ноге контроллера и много, в общем то, потреблять не должен. Но у него приемный буфер всего 64 байта, которые даже на скорости 9600 заполняются за 60мс. Получается, пока программа занимается отрисовкой экрана, часть сообщения от GPS уже успевает пройти мимо.

image
В первой половине статьи у меня получается очень много текста. Разбавлю ка я их картинками. На этом экране отображается текущая высота и вертикальная скорость

ARMируем

Итак. С текущим подходом я уперся сразу в несколько ограничений:

  • Флеш и ОЗУ. Не так чтобы много было занято, но приходилось постоянно об этом помнить
  • Всего один UART. Дополнительный SoftwareSerial ощутимо потребляет ресурсы процессора.
  • В один поток все делать явно не получается. Нужно думать о распараллеливании задач.

А еще нужно было проектировать с расчетом на будущее — мне же еще светит подключение USB и SD карты.

После выхода предыдущей части я получил много комментариев, что Ардуино отстой и будущее за ARM и контроллерами STM32. Мне не очень хотелось уходить с платформы Ардуино. Как я уже говорил фреймворк у них достаточно простой и понятный, да и контроллеры ATMega я тоже хорошо знаю.

В тоже время переход на STM32 скорее всего означал бы смену платформы в целом, микроконтроллера, компилятора, фреймворка, библиотек, IDE и кто знает чего еще. Т.е. почти всего и сразу. Для проекта это означало бы полностью остановится, долго штудировать документацию, изучать разные примеры, и только потом начинать переписывать все с нуля.

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

  • Подключил клон Sparkfun Pro Micro на ATMega32u4 (3.3В, 8МГц). В нем я хотел пощупать аппаратный USB. У меня довольно много ушло времени вообще завести эту штуковину. Штатный бутлоадер как то не очень хотел заводиться как ардуино, а уж fuse биты и вовсе были выставлены каким то загадочным образом. В итоге с помощью USBAsp вшил бутлоадер от Arduino Leonardo и все завелось.
  • Пришла отладочная плата на ATMega64. В ней в 2 раза больше памяти (и флеша и ОЗУ) и 2 uart. В принципе снимает ограничения. К сожалению к плате не прилагается схема и какой там кварц стоит тоже не ясно. Пока отложил.
  • Попробовал пощупать порт FreeRTOS под AVR. Но тут каку подложила Atmel Studio. Оказалось что у нее есть 2 вида проектов. В одном студия работает в режиме Ардуино, но в этом случае практически ничего нельзя менять в настройках проекта. Т.е. банально нельзя даже положить FreeRTOS в поддиректорию и прописать include path. Оно умеет только складывать все файлы в одну кучу, что лично меня бы раздражало.

    Второй вариант — тип проекта Generic C++ Executable. Подразумевается, что писать нужно на голом C++. Тут уже можно конфигурить как душе угодно. Но во-первых нужно как то прикрутить ардуиновский фреймворк, а во-вторых непонятно как прикрутить заливатор прошивки в контроллер. Avrdude упорно не хотел перегружать микроконтроллер в бутлоадер (хотя командную строку я подсмотрел у ArduinoIDE с помощью ProcessMonitor). У меня есть USBasp, но при наличии USB порта прямо на плате шиться через программатор как то не комильфо.

  • Наконец я распаял гребенку на плату с STM32F103C8T6 и по инструкции установил STM32duino. К моему удивлению моргалка на светодиодах сразу заработала. К еще большему удивлению портирование моего проекта на новый контроллер заняло меньше 10 минут!!! Всего то пару инклудов поменять да номера пинов поправить.

Это было то, что нужно. Я получал мощь STM32 (да! Функция рисования теперь занимала всего 18мс!) и при этом я мог продолжать пользоваться фреймоворком ардуино. Это дало возможность продолжать работу над проектом, при этом по необходимости плавно погружаться в новую платформу, почитывая в метро даташит на микроконтроллер.

Прирост флеша, на самом деле, весьма призрачное улучшение. Проект как занимал половину флеша на ATmega32, так и занимает почти половину на новом STM32 (ну ладно, 26к из 64к). Так что расслабляться не стоило. Тем более (как пишут в интернетах) скомпилированный код несколько более размашист и заполняет флеш быстрее чем на AVR. Так что на всякий случай заказал платку с 128к флеша.

Правда тут меня ждал еще один сюрприз. Народ в интернете пишет, что хотя контроллер по даташиту имеет 64к флеша на борту, по факту можно использовать 128к. Т.е. похоже ST производит один и тот же чип, только часть маркирует как STM32F103C8T6, а другую как STM32F103CBT6 (такой же контроллер, но с 128к флеша).

К слову (follow up после предыдущей статьи). В архитектуре ARM и флеш и ОЗУ находятся в одном адресном пространстве и читаются единым образом. Поэтому танцы с бубном и объявление констант с помощью PROGMEM уже не нужны. Поубирал ради чистоты кода. Таблицы виртуальных функций тоже никуда копировать не нужно, т.к. они также находятся в том же адресном пространстве.

image
Еще одна картинка для разбавления текста. Слева направо: направление движения (сейчас никуда не движемся), текущая скорость, текущая высота. Экран честно слизан с аналогичного у Holux M241

FreeRTOS'им

В комплекте STM32duino так же обнаружился порт FreeRTOS под мой контроллер (причем аж два — 7.0.1 и 8.2.1). Примеры с минимальными правками так же заработали. Так что можно было переходить на FreeRTOS не переписывая значительную часть проекта.

Прочитав пару статей (раз, два) я осознал какая мощь теперь мне доступна — потоки, мютексы, очереди, семафоры и прочая синхронизация. Все как на больших компах. Главное все правильно спроектировать.

Несмотря на то, что основной проблемой у меня был GPS, я все таки решил начать с чего попроще — кнопок. В каком то смысле FreeRTOS намного упрощает код — каждый поток может заниматься некоторой определенной задачей, и, при необходимости, нотифицировать другие потоки. Так, задача обслуживания кнопок отлично ложилась в эту идеологию — слушай себе кнопки и ни на что не отвлекайся. А уж как нажмется что нибудь — нотифицируй.

Шаг в сторону

Хотя потоки и очереди сообщений это классно, мне показалось что сам подход опроса кнопок в цикле не совсем корректный — ведь есть же прерывания по изменению значения на ножке! Ну а еще хотелось просто попробовать как это работает на STM32 :)

static void selButtonPinHandler()
{
       	static uint32 lastInterruptTime = 0;
 
       	if(digitalRead(SEL_BUTTON_PIN)) // Falling edge
       	{
               	uint32 cur = millis();
               	uint32 pressDuration = cur - lastInterruptTime;
 
               	Serial.print("DePressed at ");
               	Serial.println(lastInterruptTime);
                    	
               	if(pressDuration > LONG_PRESS_TIMEOUT)
                       	Serial.println("Sel Long Press");
               	else
               	if(pressDuration > SHORT_CLICK_TIMEOUT)
                       	Serial.println("Sel Short Click");
               	else
               	{
                       	Serial.print("Click was too short: ");           	
                       	Serial.println((int)pressDuration);
               	}
       	}
        	
        	
       	lastInterruptTime = millis();
 
       	if(!digitalRead(SEL_BUTTON_PIN)) // Raising edge
       	{
               	Serial.print("Pressed at ");
               	Serial.println(lastInterruptTime);
       	}
}

void initButtons()
{
      	// Set up button pins
      	pinMode(SEL_BUTTON_PIN, INPUT_PULLUP); // TODO: using PullUps is an AVR legacy. Consider changing this to pull down
      	pinMode(OK_BUTTON_PIN, INPUT_PULLUP);  //  so pin state match human logic expectations
        	
      	attachInterrupt(SEL_BUTTON_PIN, selButtonPinHandler, CHANGE);
}

Вместо принтов должны были быть отсылки сообщений о нажатой кнопке.

Но если честно, получилось громоздко (это обработка только одной кнопки) да еще и ужасно глючно. Наблюдались какие то ложные срабатывания, или наоборот не срабатывания. Такое впечатление, что функция millis() как то неправильно работала и могла возвращать одинаковые значения на протяжении довольно большого промежутка времени.

Напомню. В основном цикле программы у меня была большая стейт машина, которая управляла дисплеем и слушала кнопки. Добавление какой нибудь логики сопровождалось перекраиванием половины кода, а понять как это работает просто глядя на код под силу было только гуру программирования стейт машин. Но раз уж у меня есть РТОС, то все вышло значительно проще.

Обработчик кнопок

// Pins assignment
const uint8 SEL_BUTTON_PIN = PC14;
const uint8 OK_BUTTON_PIN = PC15;
 
// Timing constants
const uint32 DEBOUNCE_DURATION = 1 / portTICK_PERIOD_MS;
const uint32 LONG_PRESS_DURATION = 500 / portTICK_PERIOD_MS;
const uint32 VERY_LONG_PRESS_DURATION = 1000 / portTICK_PERIOD_MS;
const uint32 POWER_OFF_POLL_PERIOD = 1000 / portTICK_PERIOD_MS; // Polling very rare when power is off
const uint32 IDLE_POLL_PERIOD = 100 / portTICK_PERIOD_MS;                	// And little more frequent if we are on
const uint32 ACTIVE_POLL_PERIOD = 10 / portTICK_PERIOD_MS;             	// And very often when user actively pressing buttons
 
QueueHandle_t buttonsQueue;

// Reading button state (perform debounce first)
inline bool getButtonState(uint8 pin)
{
	if(digitalRead(pin))
	{
		// dobouncing
		vTaskDelay(DEBOUNCE_DURATION);
		if(digitalRead(pin))
			return true;
	}
	
	return false;
}

/// Return ID of the pressed button (perform debounce first)
ButtonID getPressedButtonID() 
{
	if(getButtonState(SEL_BUTTON_PIN))
		return SEL_BUTTON;

	if(getButtonState(OK_BUTTON_PIN))
		return OK_BUTTON;

	return NO_BUTTON;
}

// Initialize buttons related stuff
void initButtons()
{
	// Set up button pins
	pinMode(SEL_BUTTON_PIN, INPUT_PULLDOWN);
	pinMode(OK_BUTTON_PIN, INPUT_PULLDOWN);
	
	// Initialize buttons queue
	buttonsQueue = xQueueCreate(3, sizeof(ButtonMessage)); // 3 clicks more than enough
}
 
// Buttons polling thread function
void vButtonsTask(void *pvParameters)
{
	for (;;)
	{
		// Wait for a button
		ButtonID btn = getPressedButtonID();
		if (btn != NO_BUTTON)
		{
			// Button pressed. Waiting for release
			TickType_t startTime = xTaskGetTickCount();
			while(getPressedButtonID() != NO_BUTTON)
				vTaskDelay(ACTIVE_POLL_PERIOD);

			// Prepare message to send
			ButtonMessage msg;
			msg.button = btn;
				
			// calc duration
			TickType_t duration = xTaskGetTickCount() - startTime;
			if(duration > VERY_LONG_PRESS_DURATION)
				msg.event = BUTTON_VERY_LONG_PRESS;
			else
			if(duration > LONG_PRESS_DURATION)
				msg.event = BUTTON_LONG_PRESS;
			else
				msg.event = BUTTON_CLICK;
				
			// Send the message
			xQueueSend(buttonsQueue, &msg, 0);
		}
		
		// TODO: Use different polling periods depending on global system state (off/idle/active)
		vTaskDelay(ACTIVE_POLL_PERIOD);
	}
}

Получилось весьма компактно и понятно. Функции все очень линейные. Просто в цикле опрашиваем кнопки и на основе длительности нажатия отсылаем соответствующее сообщение.

Я решил что у меня будет 3 вида длительностей нажатия:

  • Короткое для выбора соответствующего пункта меню
  • Длинное для специального действия (например сброс выбранного параметра)
  • Очень длинное нажатие для включения и выключения устройства

Я, кстати, решил подключить кнопки не к плюсу, а к минусу. Естественно pull-up резисторы заменил на pull-down. Я не силен в электронике и могу тут ошибаться, но в целом я руководствовался следующими соображениями:

  • В отпущенном положении кнопки пин прижимается к нулю, а значит ток не течет (пускай даже мизерный)
  • При чтении значения с пина значение получается неинвертированным: 1 если кнопка нажата, 0 — отпущена

ScreenManager также значительно упростился. Больше небыло необходимости в глобальном состоянии дисплея. Поток отрисовки занимается исключительно отрисовкой и управляется сообщениями от кнопок. Он просто ждал сообщений в очереди и отрабатывал полученные команды. Причем сам цикл ожидания так же был сделан через очередь с помощью таймаута в функции xQueueReceive. Т.е. функция ждет сообщения, а если ничего не происходит долгое время — просто отрисовывает экран как есть

Поток дисплея

void vUserInteractionTask(void *pvParameters)
{
       	for (;;)
       	{
               	// Poll the buttons queue for an event. Process button if pressed, or show current screen as usual if no button pressed
               	ButtonMessage msg;
               	if(xQueueReceive(buttonsQueue, &msg, DISPLAY_CYCLE))
                       	processButton(msg);
                    	
               	// Do what we need for current state
               	drawDisplay();
       	}
}

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

Обработка кнопок так же тривиальна — просто парсим сообщение и вызываем необходимую функцию

Обработка кнопок

void processButton(const ButtonMessage &msg)
{
      	if(msg.button == SEL_BUTTON && msg.event == BUTTON_CLICK)
               	getCurrentScreen()->onSelButton();
                    	
       	if(msg.button == OK_BUTTON && msg.event == BUTTON_CLICK)
               	getCurrentScreen()->onOkButton();
 
       	// TODO: process long press here
}

Функция showMessageBox() так же сильно упростилась и стала теперь совсем линейной

окно сообщения

void showMessageBox(const char * text)
{
        //Center text
        uint8_t x = 128/2 - strlen_P(text)*6/2;
                               	
        // Draw the message
        display.clearDisplay();
        display.setFont(NULL);
        display.drawRect(2, 2, 126, 30, 1);
        display.setCursor(x, 12);
        display.print(text);
        display.display();
        	
        // Wait required duration
        vTaskDelay(MESSAGE_BOX_DURATION);
}

И напоследок. Что это за устройство, если у него нет моргающей лампочки? Нужно исправить. Как бы это ни смешно было, но по моргающему диоду удобно следить работает ли еще устройство, или давно повисло.

Hello FreeRTOS World!

void vLEDFlashTask(void *pvParameters) 
{
	for (;;) 
	{
		vTaskDelay(2000);
		digitalWrite(PC13, LOW);
		vTaskDelay(100);
		digitalWrite(PC13, HIGH);
	}
}

Опять GPS'им

Наконец, настало время терзать GPS. Теперь уже нет проблемы одновременно слушать GPS и делать все остальное. Для начала я опять написал переливатор:

Переливатор

void initGPS()
{
	// GPS is attached to Serial1
	Serial1.begin(9600);
}

void vGPSTask(void *pvParameters)
{
	for (;;)
	{
		while(Serial1.available()) 
		{
			int c = Serial1.read();
			gps.encode(c);
			Serial.write(c);
		}
			
		vTaskDelay(5);
	}
}

Но тут возникла проблема. Сообщения формально парсились, только вот даже время выкусить оттуда не получилось. Курение исходников TinyGPS и документации на приемник показало небольшое несоответствие сообщений от GPS модуля и тем что умеет парсить библиотека.

Модуль UBlox реализует некое расширение протокола NMEA. Каждое сообщение начинается с пятибуквенного идентификатора сообщения.

$GNGGA,181220.00,,,,,0,00,99.99,,,,,,*70

Первые 2 буквы кодируют подсистему, которая приготовила данные: GP для GPS, GL для GLONASS, GA для GALILLEO. А вот если используется комбинация систем позиционирования то сообщения будут начинаться с GN.

Библиотека TinyGPS+ на такое рассчитана не была — она умела парсить только сообщения GP. Пришлось ее чуток подправить — поменял соответствующую строку в парсере и время на экране побежало. Только вот это все попахивало каким то хаком.

Товарищ подсказал альтернативу — библиотеку NeoGPS. Это намного более фичастая библиотека. Помимо того, что она умеет парсить сообщения с разными префиксами, она еще позволяет парсить информацию о спутниках (лично мне нравятся такие штуки в GPS приемниках). Еще стоит отметить, что библиотека жутко конфигуряемая — можно включить/выключить парсинг отдельных сообщений и тем самым регулировать потребление памяти в зависимости от задач.

Подключить библиотеку к stm32duino труда не составило, правда чуток подпилить все же пришлось. Но как всегда в примерах все просто и понятно, а в реальном проекте оно сразу не заработало. В частности было неясно в какой момент времени правильно читать из GPS. Вот, например, попытка вычитать данные о спутниках.

Добывание информации о спутниках

for (;;)
{
	while(Serial1.available())
	{
		int c = Serial1.read();
		Serial.write(c);
		gpsParser.handle(c);
	}

       	if(gpsParser.available())
       	{
               	memcpy(satellites, gpsParser.satellites, sizeof(satellites));
               	sat_count = gpsParser.sat_count;
       	}

	vTaskDelay(10);
}

Время от времени парсер говорит, что данные прибыли — забирайте. Координаты всегда приезжают нормально, а вот со спутниками беда. Забираю, а там нули. Или не нули. Как повезет.

Оказалось нужно было внимательно прочитать документацию. Все дело в дизайне библиотеки. Во имя экономии памяти данные раскладываются по переменным по ходу парсинга. При чем побайтово — пришел байт, обновили переменную. Данные приходят пакетами по несколько сообщений. Библиотека NeoGPS должна знать когда начинается новый пакет, чтобы обнулить внутренние переменные. За это отвечает параметр конфигурации LAST_SENTENCE_IN_INTERVAL

//------------------------------------------------------
// Select which sentence is sent *last* by your GPS device
// in each update interval.  This can be used by your sketch
// to determine when the GPS quiet time begins, and thus
// when you can perform "some" time-consuming operations.
#define LAST_SENTENCE_IN_INTERVAL NMEAGPS::NMEA_RMC

Так вот сообщение RMC у меня приходит самым первым в пакете сообщений. Получается что мой код мог прочитать частично распаршеные данные (Возможно это были данные предыдущих пакетов, которые еще не успели обнулится). Или вычитывать нули, если прочитать в неудачное время. Лечится довольно просто: указываем, что в каждом пакете от GPS модуля последнее сообщение у нас GLL.

image
Спутников много, а фикса все нет и нет. Сверху вниз: количество спутников (отслеживаемые vs неотслеживаемые — не знаю что это значит), HDOP/VDOP, Статус GPS сигнала (словило/не словило)

Кстати, с библиотекой в комплекте обнаружились довольно удобные функции по работе с датой и временем. Так, например, очень легко было прикрутить часовой пояс. Я только храню временнОе смещение в минутах, а остальное легко высчитать по ходу.

Коррекция времени согласно выбраному часовому поясу

void TimeZoneScreen::drawScreen() const
{
	// Get the date/time adjusted by selected timezone value
	gps_fix gpsFix = gpsDataModel.getGPSFix();
	int16 timeZone = getCurrentTimeZone();
	NeoGPS::time_t dateTime = gpsFix.dateTime + timeZone * 60; //timeZone is in minutes

	...

	printNumber(dateBuf, dateTime.date, 2);
	printNumber(dateBuf+3, dateTime.month, 2);
	printNumber(dateBuf+6, dateTime.year, 2);

image
Экран выбора часового пояса честно слизан с Hulux'а

Model-View'им

При написания кода теперь нельзя забывать, что мы работам в многопоточной среде. Так, у меня есть поток, который обслуживает GPS: слушает Serial порт, побайтово парсит из него данные. Пакеты приходят раз в секунду. Библиотека знает, когда начинается следующий пакет и перед приемом обнуляет внутренние переменные. Когда пакет полностью принят выставляется флаг available. Данные приезжают на протяжении примерно за полсекунды (там байт 600 на скорости 9600). У нас есть еще полсекунды, чтобы их забрать, прежде чем начнется передача следующего пакета.

Второй поток занимается обслуживанием дисплея. Цикл отрисовки происходит каждые 100-120мс. На каждой итерации программа берет актуальные данные из GPS и отрисовывает то, что сейчас хочет видеть пользователь — координаты, скорость, высоту или что нибудь еще. И тут возникает противоречие: поток дисплея хочет получать данные всегда, тогда как в библиотеке они доступны только полсекунды, а потом перезатираются.

Решение достаточно очевидное: скопировать данные к себе в промежуточный буфер. Естественно данные в этом буфере нужно защитить мутексом (mutex), иначе данные могут быть вычитаны некорректно. Но вот в чем проблема. Данные в потоке GPS появляются хоть и редко, но вычитать их можно быстро (там всего полторы сотни байт после парсинга), мутекс надолго блокировать не нужно. А вот функция рисования может работать довольно долго (до 20мс). Блокировать мутекс на такое длительное время, в общем то, не сильно хорошо. Хотя и не смертельно, в этом конкретном проекте.

Можно, конечно, быстренько заблокировать мутекс, забрать данные в локальную переменную и отпустить мутекс. Но это чревато перерасходом памяти. Еще полторы сотни байт при 20 килобайтах это фигня, но лично меня напрягает сам факт тройной буферизации.

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

Вычитка данных о спутниках в функции рисования

NMEAGPS::satellite_view_t l_satellites[ NMEAGPS_MAX_SATELLITES ];
uint8_t l_sat_count;
 
void SatellitesScreen::drawScreen()
{
   	xSemaphoreTake(xGPSDataMutex, portMAX_DELAY);
   	memcpy(l_satellites, satellites, sizeof(l_satellites));
   	l_sat_count = sat_count;
   	xSemaphoreGive(xGPSDataMutex);
   	
 	display.draw(....)
 	...
}

С мгновенными значениями, которые можно достать прямо из NMEA потока все просто — библиотека NeoGPS их вычитывает и раскладывает по переменным. Каждый скрин может просто прочитать соответствующую переменную (не забывая про синхронизацию, конечно) и отобразить ее на экране. Но вот с переменными, которые нужно вычислять так просто не получилось.

После долгого размышления я пришел к классической model-view схеме.

Объекты-наследники screen являются вьюшками — они отображают различные данные из модели, но сами данные не производят. Вся логика лежит в классе GPSDataModel. Он отвечает за хранение мгновенных GPS данных (пока не приедут новые данные из NeoGPS). Так же он отвечает за вычисление новых данных, таких как одометры или вертикальная скорость. И последнее, но не менее важное — этот класс сам занимается всей синхронизацией для своих данных.

Класс модели

const uint8 ODOMERTERS_COUNT = 3;

/**
 * GPS data model. Encapsulates all the knowledge about various GPS related data in the device
 */
class GPSDataModel
{
public:
	GPSDataModel();
	
	void processNewGPSFix(const gps_fix & fix);
	void processNewSatellitesData(NMEAGPS::satellite_view_t * sattelites, uint8_t count);
	gps_fix getGPSFix() const;
	GPSSatellitesData getSattelitesData() const;
	
	float getVerticalSpeed() const;
	int timeDifference() const;
	
	// Odometers
	GPSOdometerData getOdometerData(uint8 idx) const;
	void resumeOdometer(uint8 idx);
	void pauseOdometer(uint8 idx);
	void resetOdometer(uint8 idx);
	void resumeAllOdometers();
	void pauseAllOdometers();
	void resetAllOdometers();
	
private:
	gps_fix cur_fix; /// most recent fix data
	gps_fix prev_fix; /// previously set fix data
	GPSSatellitesData sattelitesData; // Sattelites count and signal power
	GPSOdometer * odometers[ODOMERTERS_COUNT];
	bool odometerWasActive[ODOMERTERS_COUNT];
	
	SemaphoreHandle_t xGPSDataMutex;
	
	GPSDataModel( const GPSDataModel &c );
	GPSDataModel& operator=( const GPSDataModel &c );	
}; //GPSDataModel

/// A single instance of GPS data model
extern GPSDataModel gpsDataModel;

Т.к. класс модели отвечает за синхронизацию данных между потоками, то в нем живет мутекс, который регулирует доступ к внутренним полям класса. Мне было жутко неудобно (и некрасиво) пользоваться голыми xSemaphoreTake()/xSemaphoreGive(), так что я нарисовал классический автозахватыватель (точнее даже автоотпускатель).

Mutex Locker

class MutexLocker
{
public:
	MutexLocker(SemaphoreHandle_t mtx)
	{
		mutex = mtx;
		xSemaphoreTake(mutex, portMAX_DELAY);
	}
	
	~MutexLocker()
	{
		xSemaphoreGive(mutex);
	}

private:
	SemaphoreHandle_t mutex;	
};

Забрать текущее значение очень просто. Нужно просто вызвать функцию getGPSFix(), которая просто вернет копию данных.

Возвращатор данных

gps_fix GPSDataModel::getGPSFix() const
{
	MutexLocker lock(xGPSDataMutex);
	return cur_fix;	
}

Клиенту не нужно парится про блокировки и все такое. Просто забираем данные и рисуем как надо.

код клиента

void SpeedScreen::drawScreen() const
{
	// Get the gps fix data
	gps_fix gpsFix = gpsDataModel.getGPSFix();
	
	// Draw speed
	...
	printNumber(buf, gpsFix.speed_kph(), 4, true);

В классе модели хранится не только самые последние данные (cur_fix), но также предыдущее значение (prev_fix). Так что, вычисление вертикальной скорости становится тривиальной задачей.

Вычислятор вертикальной скорости

float GPSDataModel::getVerticalSpeed() const
{
	MutexLocker lock(xGPSDataMutex);
	
	// Return NAN to indicate vertical speed not available
	if(!cur_fix.valid.altitude || !prev_fix.valid.altitude)
		return NAN;
	
	return cur_fix.altitude() - prev_fix.altitude(); // Assuming that time difference between cur and prev fix is 1 second
}

С данными про спутники получилось весьма интересно. Данные про спутники живут в массиве структур NMEAGPS::satellite_view_t. Массив весит 150 байт и, как я уже писал, его необходимо несколько раз копировать. Не так, чтобы критично при наличии 20кб оперативы, но все равно это трижды по 150 байт.

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

Хранилка для данных о спутниках

class GPSSatellitesData
{
	// Partial copy of NMEAGPS::satellite_view_t trimmed to used data
	struct SatteliteData
	{
		uint8_t snr;
		bool tracked;
	};	
	
	SatteliteData satellitesData[SAT_ARRAY_SIZE];
	uint8_t sat_count;
	
public:
	GPSSatellitesData();

	void parseSatellitesData(NMEAGPS::satellite_view_t * sattelites, uint8_t count);
	
	uint8_t getSattelitesCount() const {return sat_count;}
	uint8_t getSatteliteSNR(uint8_t sat) const {return satellitesData[sat].snr;}
	bool isSatteliteTracked(uint8_t sat) const {return satellitesData[sat].tracked;}
};

Такой класс уже не так обидно лишний раз копировать — он занимает всего 40 байт.

Самой сложной частью схемы получился класс GPSOdometer. Как следует из названия он отвечает за все вычисления связанные с функциональностью одометра.

Классы одометра и его данных

// This class represents a single odometer data with no logic around
class GPSOdometerData
{
	// GPSOdometer and its data are basically a single object. The difference is only that data can be easily copied
	// while GPS odometer object is not supposed to. Additionally access to Odometer object is protected with a mutex 
	// in the model object
	// In order not to overcomplicte design I am allowing GPS Odometer to operate its data members directly.
	friend class GPSOdometer;
	
	bool active;
	
	NeoGPS::Location_t startLocation;
	NeoGPS::Location_t lastLocation;
	
	float odometer;
	int16 startAltitude;
	int16 curAltitude;
	
	clock_t startTime;				///! When odometer was turned on for the first time
	clock_t sessionStartTime;		///! When odometer was resumed for the current session
	clock_t totalTime;				///! Total time for the odometer (difference between now and startTime)
	clock_t activeTime;				///! Duration of the current session (difference between now and sessionStartTime)
	clock_t activeTimeAccumulator;	///! Sum of all active session duration (not including current one)
	
	float maxSpeed;

public:	
	GPSOdometerData();
	void reset();

	// getters
	bool isActive() const {return active;}
	float getOdometerValue() const {return odometer;}
	int16 getAltitudeDifference() const {return (curAltitude - startAltitude) / 100.;} // altitude is in cm
	clock_t getTotalTime() const {return totalTime;}
	clock_t getActiveTime() const {return activeTimeAccumulator + activeTime;}
	float getMaxSpeed() const {return maxSpeed;}
	float getAvgSpeed() const;
	float getDirectDistance() const;
};

// This is an active odometer object that operates on its odometer data
class GPSOdometer
{
	GPSOdometerData data;

public:
	GPSOdometer();

	// odometer control
	void processNewFix(const gps_fix & fix);
	void startOdometer();
	void pauseOdometer();
	void resetOdometer();
	
	// Some data getters
	GPSOdometerData getData() {return data;}
	bool isActive() const {return data.isActive();}
}; //GPSOdometer

Сложность вот в чем. Объект gps_fix, который поступает нам от GPS, может содержать какие то данные, а какие то может и нет. Например координата приедет, а высота нет. А в следующем фиксе может быть наоборот. Поэтому просто сохранять gps_fix не выйдет. Нужно каждый раз смотреть что доступно в новом фиксе, а что нет. Поэтому пришлось городить весьма сложный алгоритм, запоминать координаты, высоты и временные отметки по отдельности.

Ежесекундное обноврение данных одометра

void GPSOdometer::processNewFix(const gps_fix & fix)
{
	Serial.print("GPSOdometer: Processing new fix ");
	Serial.println((int32)this);

	if(data.active)
	{
		Serial.println("Active odometer: Processing new fix");
		
		// Fill starting position if needed
		if(fix.valid.location && !isValid(data.startLocation))
			data.startLocation = fix.location;
			
		// Fill starting altitude if neede
		if(fix.valid.altitude && !data.startAltitude) // I know altitude can be zero, but real zero cm altutude would be very rare condition. Hope this is not a big deal
			data.startAltitude = fix.altitude_cm();
			
		// Fill starting times if needed
		if(fix.valid.time)
		{
			if(!data.startTime)
				data.startTime = fix.dateTime;
			if(!data.sessionStartTime)
				data.sessionStartTime = fix.dateTime;
		}
			
		// Increment the odometer
		if(fix.valid.location)
		{
			// but only if previous location is really valid
			if(isValid(data.lastLocation))
				data.odometer += NeoGPS::Location_t::DistanceKm(fix.location, data.lastLocation);
				
			// In any case store current (valid) fix
			data.lastLocation = fix.location;
		}
		
		// Store current altitude
		if(fix.valid.altitude)
			data.curAltitude = fix.altitude_cm();
			
		// update active time values
		if(fix.valid.time)
			data.activeTime = fix.dateTime - data.sessionStartTime;
		
		// update max speed value
		if(fix.valid.speed && fix.speed_kph() > data.maxSpeed)
			data.maxSpeed = fix.speed_kph();
	}	
	
	 //Total time can be updated regardless of active state
	 if(fix.valid.time && data.startTime)
		data.totalTime = fix.dateTime - data.startTime;
}

В этом месте у меня резко увеличился размер флеша — почти на 10кб. В проект приползла куча математического кода — синусы, косинусы, тангенсы, квадратные корни и все такое прочее. Оказалось, что ноги растут из функции NeoGPS::Location_t::DistanceKm() — все это используется в вычислении расстояния на основе координат. Скрипя зубами пришлось согласится, но задумался о контроллере на Cortex M4 — там это хардварно должно вычисляться.

Управление одометром

void GPSOdometer::startOdometer()
{
	data.active = true;

	// Reset session values
	data.sessionStartTime = 0;
	data.activeTime = 0;
}

void GPSOdometer::pauseOdometer()
{
	data.active = false;
	
	data.activeTimeAccumulator += data.activeTime;
	data.activeTime = 0;
}

void GPSOdometer::resetOdometer()
{
	data.reset();
}

Обратите внимание, что в классе одометра нет никакой синхронизации. Это потому, что вся синхронизация происходит в классе GPSDataModel. Я просто не хотел городить по мутексу в каждом объекте. Но из-за этого мне пришлось усложнить сам класс одометра и разделить на 2 класса: объект с данными (GPSOdometerData) может копироваться по запросу клиентов, тогда как объект управления (GPSOdometer) создаются один раз на каждый одометр. Из-за этого также пришлось один класс сделать friend’ом другому. Возможно я пересмотрю этот дизайн в будущем.

image
Так выглядит основной экран одометра. Символ точки в шрифт еще не добавил — должно показывать 0.42км. Так же отображается перепад высот — лежа на месте на подоконнике запросто можно перепасть на 18 и более метров.

image
Другие полезные параметры, которые могут отображаться одометром. На один экран все даже не вместилось — буду делать 2 или даже 3 экрана.

GPSDataModel может управлять всеми одометрами сразу. Эта фича была предложена в коментариях и должна быть удобной — зашел в кафе, выключил все одометры сразу. Вышел — включил их опять.

Управление одометрами всеми сразу

void GPSDataModel::resumeAllOdometers()
{
	MutexLocker lock(xGPSDataMutex);

	if(odometerWasActive[0])
		odometers[0]->startOdometer();
	if(odometerWasActive[1])
		odometers[1]->startOdometer();
	if(odometerWasActive[2])
		odometers[2]->startOdometer();
}

void GPSDataModel::pauseAllOdometers()
{
	MutexLocker lock(xGPSDataMutex);

	odometerWasActive[0] = odometers[0]->isActive();
	odometerWasActive[1] = odometers[1]->isActive();
	odometerWasActive[2] = odometers[2]->isActive();
	
	odometers[0]->pauseOdometer();
	odometers[1]->pauseOdometer();
	odometers[2]->pauseOdometer();
}

void GPSDataModel::resetAllOdometers()
{
	MutexLocker lock(xGPSDataMutex);

	odometers[0]->resetOdometer();
	odometers[1]->resetOdometer();
	odometers[2]->resetOdometer();
	
	odometerWasActive[0] = false;
	odometerWasActive[1] = false;
	odometerWasActive[2] = false;
}

Опять FreeRTOS'им

В целях изучения возможностей FreeRTOS я попробовал посмотреть сколько же на самом деле времени процессор проводит в вычислениях. Для оценки можно использовать ApplicationIdleHook.

У любой РТОС есть так называемый idle поток. Если процессору нечем себя занять — крутится некий бесконечный цикл в отдельной задаче с наименьшим приоритетом. FreeRTOS позволяет добавить некоторой полезности в этот бесконечный цикл и запускать этот хук. Идея измерения загрузки процессора состоит в том, что чем больше процессор проводит времени в idle потоке — тем меньше он загружен другой (полезной) работой.

В интернетах я нашел несколько подходов как можно было бы измерять загрузку процессора.

Одни ребята предлагали в Idle Hook функции крутить некий счетчик и измерять скорость с которой он «наматывает». Чтобы перевести это в проценты нужно полученную скорость поделить на некие эталонное значение.

Но где взять эту эталонную скорость? Для этого нужно погасить все другие потоки и мерять только скорость счетчика в ненагруженной системе. Можно, например, на старте сделать задержку в 1-2 секунды для измерений, но лично меня жутко бесит когда достаточно простые устройства «грузятся» по 5-10 секунд (например, фотоаппараты-мыльницы. грррр).

В другом варианте из интернета предлагалось запустить отдельный таймер и перегрузить макрос входа и выхода в контекст потока. Идея в том, чтобы измерять разницу значений таймера на входе и на выходе в каждый поток и из этого делать вывод о загрузке процессора.

Да, я слышал о Run Time Stats в системе FreeRTOS. Но, как гласит инструкция, оно предназначено для другого. Эта функция позволяет в дебажных целях получить загрузку по каждому отдельному потоку и за весь период работы приложения. Я же хотел измерять мгновенную загрузку процессора.

Я решил попробовать сделать следующий вариант. Не знаю насколько это правильно и будет ли оно вообще работать, когда я прикручу sleep mode. Но на данном этапе работает неплохо.

измерение загрузки

static const uint8 periodLen = 9; // 2^periodLen ticks - 512 x 1ms ticks 
 
volatile TickType_t curIdleTicks = 0;
volatile TickType_t lastCountedTick = 0;
volatile TickType_t lastCountedPeriod = 0;
volatile TickType_t lastPeriodIdleValue = 0;
volatile TickType_t minIdleValue = 1 << periodLen;
 
extern "C" void vApplicationIdleHook( void )
{
   	// Process idle tick counter
   	volatile TickType_t curTick = xTaskGetTickCount();
   	if(curTick != lastCountedTick)
   	{
         	curIdleTicks++;
         	lastCountedTick = curTick;
   	}
   	
   	// Store idle metrics each ~0.5 seconds (512 ticks)
   	curTick >>= periodLen;
   	if(curTick >  lastCountedPeriod)
   	{
         	lastPeriodIdleValue = curIdleTicks;
         	curIdleTicks = 0;
   	   	lastCountedPeriod = curTick;
         	
         	// Store the max value
         	if(lastPeriodIdleValue < minIdleValue)
                	minIdleValue = lastPeriodIdleValue;
   	}
}

Функция может вызываться очень часто, много раз за один тик системы (system tick это 1мс). Поэтому первый блок отвечает за подсчет тиков (а не вызовов) в которых вызывался хук. Второй блок сохраняет счетчик каждые 512 системных тиков.

Загрузка процессора это отношение количества не-idle тиков к общему количеству тиков в измеряемом интервале.

Рассчет значений

float getCPULoad()
{
   	return 100. - 100. * lastPeriodIdleValue /  (1 << periodLen);
}
 
float getMaxCPULoad()
{
   	return 100. - 100. * minIdleValue /  (1 << periodLen);
}

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

К слову, в нормальном режиме загрузка составила около 12.5% и подпрыгивает до 15.5% когда приходят данные от GPS и их нужно парсить. При выключенном дисплее (хотя GPS продолжает парсится) загрузка падает до 0. Это странно. Видимо парсинг GPS на самом деле занимает меньше тика, поэтому каждый тик после этого сваливается в idle task. Всплеск загрузки на 3% возможно объясняется не самим парсингом данных, а пересылкой их в другой поток.

Хотя возможно я где-то тут попросту накосячил.

image
Показания текущей и максимальной загрузки процессора. Сам экран будет спрятан где нибудь в глубинах меню настроек.

Всяко разно

В этой секции я собрал отдельные проблемки, которые я решал на разных стадиях проекта. Без какой либо особенной последовательности.

  • Библиотечная реализация sprintf занимает огого сколько — 13k. Пришлось написать свою реализацию. Я написал небольшой классик, который реализует интерфейс Printable. Так можно “печатать” числа с нужным форматированием на экран и даже в Serial. Получилось весьма симпатичненько и всего пару экранов кода.
    Форматировщик чисел с плавающей точкой

    /// Helper class to print float numbers according to specified options
    class FloatPrinter : public Printable
    {
    	char buf[8]; // Print numbers no longer than 7 digits including sign and point symbols
    	uint8 pos; // position in the buffer with the first meaningful char
    	
    public:
    	FloatPrinter(float value, uint8 width, bool leadingZeros = false, bool alwaysPrintSign = false);
    	
    	virtual size_t printTo(Print& p) const;
    };
    
    FloatPrinter::FloatPrinter(float value, uint8 width, bool leadingZeros, bool alwaysPrintSign)
    {
        // reserve a space for sign
        uint8 minpos = 0;
        if(alwaysPrintSign || value < 0)
    		minpos++;
    
        // absolute value to print, deal with sign later
        float v = value;
        if(v < 0)
    		v = 0. - v;
        
        // floating point position will depend on the value
        uint8 precision = 0;
        if(v < 100)
        {
    	    v *= 10;
    	    precision++;
        }
        if(v < 100) // doing this twice
        {
    	    v *= 10;
    	    precision++;
        }
    
        uint32 iv = v + 0.5; // we will be operating with integers
    
        // Filling the buffer starting from the right
        pos = width;
        buf[pos] = '';
        bool onceMore = true; // Print at least one zero before dot
        while((iv > 0 || onceMore) && (pos > minpos))
        {
    	    pos--;
    	    onceMore = false;
    	    
    	    // Fill one digit
    	    buf[pos] = iv % 10 + '0';
    	    iv /= 10;
    	    
    	    // Special case for printing point
    	    // Trick used: if precision is 0 here it will become 255 and dot will never be printed (assuming the buffer size is less than 255)
    	    if(--precision == 0)
    	    {
    		    buf[--pos] = '.';
    		    onceMore = true;
    	    }
        }
        
        //Print sign
        if(value < 0)
    		buf[--pos] = '-';
        else if (alwaysPrintSign)
    	    buf[--pos] = '+';
    }
    
    size_t FloatPrinter::printTo(Print& p) const
    {
    	return p.print(buf+pos);
    }

  • Пока я писал эту функцию нужно было ее как то проверять. Как-то так получилось, что у меня на домашнем компе не установлено никаких компиляторов или IDE кроме ардуино (и Atmel Studio). Поэтому код я писал в браузере на cpp.sh. Я написал небольшую обертку вокруг этого кода, которая запускает функцию с разными параметрами и проверяет результат. Получился такой себе вариант юнит теста, только без необходимости создавать проект, втягивать туда какой нибудь тестовый фреймворк и все такое.

    Конечно же есть и минусы. Код теста нужно синхронизировать с “продакшен” кодом методом копи-пасты. Благо это не нужно делать очень часто.

    типа юнит тест

    #include <stdio.h>
    #include <string.h>
    
    typedef unsigned char uint8;
    typedef unsigned int uint32;
    
    // This is some kind of a unit test for float value print helper. Code under the test is injected into a test function below via simple copy/paste from FloatPrinter constructor.
    // This allows executing the code right at C++-in-browser service (such as http://cpp.sh)
    // I just did not want to set up a development toolchain, create a project file, deal with external libraries, do a dependency injection into tested class, etc :)
    
    void test(const char * expectedValue, float value, uint8 width, bool leadingZeros = false, bool alwaysPrintSign = false)
    {
        char buf[9];
        uint8 pos;
    
        printf("Printing %f... ", value);
    
    ////////////////////////////////////////////////////////
    // Begin copy from FloatPrinter
    ////////////////////////////////////////////////////////
    
    <Place Function Body Here>
    
    ////////////////////////////////////////////////////////
    // End copy from FloatPrinter
    ////////////////////////////////////////////////////////
    
        if(strcmp(expectedValue, buf+pos) == 0)
        {
            printf("%s - PASSEDn", buf+pos);
        }
        else
        {
            printf("%s - FAILEDn", expectedValue);
            printf("Got: %sn", buf+pos);
            
            printf("Buffer: ");
    	    for(int i=0; i<9; i++)
        	    printf("%2x ", buf[i]);
        	printf("npos=%dnn", pos);
        }
    }
    
    int main()
    {
        test("0", 0., 4);
    
        test("0.10", 0.1, 4);
        test("0.23", 0.23, 4);
        test("4.00", 4., 4);
        test("5.60", 5.6, 4);
        test("7.89", 7.89, 4);
        test("1.23", 1.234, 4);
        test("56.8", 56.78, 4);
        test("56.8", 56.78, 5);
        test("123", 123.4, 4);
        test("568", 567.8, 5);
        test("12345", 12345., 6);
    
        test("-0.10", -0.1, 5);
        test("-0.23", -0.23, 5);
        test("-4.00", -4., 5);
        test("-5.60", -5.6, 5);
        test("-7.89", -7.89, 5);
        test("-1.23", -1.234, 5);
        test("-56.8", -56.78, 5);
        test("-56.8", -56.78, 6);
        test("-123", -123.4, 5);
        test("-568", -567.8, 6);
        test("-12345", -12345., 7);
    }

  • Использовать Serial.print в конструкторах нельзя — МК уходит в циклический ребут. Скорее всего USB Serial инициализируется несколько позже конструкторов статически размещенных объектов. Из-за этого вызывается неинициализированный код.
  • Стандартный синглтончик Майерса принес в проект такое огромное количество кода, что мама не горюй. Более 40к! Там были и эксепшены, и type info, какие то куски C++ ABI и много чего я и слыхом не слыхивал за десятилетия работы программистом.
    ага, вот эти ребята

    GPSDataModel & GPSDataModel::instance()
    {
       	static GPSDataModel inst;
       	return inst;
    }

    Так что плюсы плюсами, а объекты пришлось разместить в глобальных переменных, прописав где нужно ссылки через extern.

  • Шрифты. В прошлой части я уже писал, что шрифты пришлось готовить самостоятельно. У меня есть скрипт, который из картинки генерит нужный код. Но в изначальном варианте данные лежали не в упакованном формате, а потому занимали больше места чем могли бы. Я доработал скрипт, и шрифты удалось упаковать, тем самым выиграв чуток флеша.

    Так, шрифт 8х12 похудел с 850 до 732 байта, а шрифт 16х22 (нарисовал самостоятельно используя Bodoni MT) уменьшился с 474 до 408. В этом шрифте только цифры, потому он так мало занимает.

  • Изначально шрифты у меня располагались в хедере в виде константных массивов. Хедеры инклудятся в соответствующие cpp-шники где я рисую этими шрифтами. Так вот, сцуко, компилер для каждого цппшника дублирует фонты. Я переместил фонты в cpp файл и объем флеша сразу сократился на 9к. 9 килобайт за константу в хедере! 9 килобайт, Карл! Вот и верь после этого отлаженным компиляторам!
  • С удивлением обнаружил, что у HardwareSerial нет метода attachInterrupt. У оригинального ардуино его, кстати, тоже нет. Можно использоваться NeoSWSerial, который рекомендуют в документации NeoGPS, но это как то странно использовать софтварный UART при наличии целой кучи хардварных.

    Я тут просто начитался документации по STM32 — DMA и все такое. Подумал, может и мне пригодится такой режим в целях экономии батареи. Ведь сейчас хоть и со sleep()’ами, но все таки идет постоянный опрос “а не пришло ли чего из GPS?”

  • Пришлось озадачится вопросами UX дизайна. Хочется вывести на экран кучу разной информации, но пикселей не так много. Даже с использованием самого маленького шрифта влазит не больше 3 строк по 21 символу.

    Так, вся информация по одометру попросту не влазит на один экран. И на 2 тоже. Пришлось сделать один основной экран с большими и красивыми буквами. А если пользователю интересно, то в режиме подменю можно будет доступится до более детальной информации на нескольких экранчиках.

  • GPS. Библиотека NeoGPS предоставляет некий статус соединения. В стиле “нет сигнала” -> “получили только время” -> “получили 2D Fix” -> “получили 3D Fix”. Возможно это работает с каким нибудь другим GPS модулем, но не с моим. У меня работают только первый и последний пункты.
  • GPS. Хотел побырику раздобыть параметр точности координаты. А не тут-то было. В явном виде получить его не удалось, а HDOP/VDOP это только косвенные показатели точности. Буду очень благодарен за толковое разъяснение по этим метрикам.
  • Высота может быть отрицательная. Очень удивился когда увидел высоту 65000м, оказалось ГПС после включения давал высоту -500м. Пришлось сделать специальный кейс у себя в коде для корректного отображения отрицательных высот.
  • Скорость позиционирования оставляет желать лучшего. Реклама гласит Time To First Fix < 30 секунд, но это, по всей видимости, означает поимку первого спутника, а не первых координат. Время регистрируется почти сразу после включения. А вот координат приходится ждать пару минут. Даже GPS включался несколько минут назад.

    Возможно модуль не нужно выключать «из розетки» а переводить в какой нибудь глубокий сон. Батарейка на модуле намекает.

  • Точность также под вопросом. Высота регистрируется неправильно: +-50м, и только потом медленно ползет куда надо. Да и потом заметно плавает
  • При плохом приеме могут возникать большие скачки скорости до 150км/ч, а сам модуль может накрутить на до 7км за час. Нужно будет проверить где нибудь в поле.

Оптимизируем

Напоследок пару слов про оптимизацию потребления. Да, контроллер мощнее, но проблемы все те же. Нужно очень внимательно следить за потреблением памяти ибо одно неосторожное движение может добавить к прошивке пару кило.

Как и ожидалось, на STM32 вылезли все те же проблемы что и на AVR.

  • константы, которым забыли написать слово const по прежнему размещаются в ОЗУ (там на полкило будет таких констант. В основном USB дескрипторы)
  • 512 байт картинки adafruit, которая загружается в буфер дисплея и никогда не показывается.
  • функции по работе с SPI, хотя ничего у меня по SPI не подключено — 512 байт
  • всякая фигня из NeoGPS — вычисление високосного года и все такое прочее. Кем-то косвенно юзается — 300 байт
  • класс TwoWire (ручная реализация I2C). Это точно не используется, но линкер ее все равно втюхивает — 650 байт
  • код по работе с АЦП. Пока не используется, но будет когда-то для измерения параметров батареи. Пока не трогал.

Список далеко не полон. Такое впечатление, что если какой-то объект (тот же TwoWire) объявлен в хедере, то линкер его притягивает в проект независимо от того, используется он реально или нет. Возможно, это можно регулировать настройками линкера, но билд система ардуино не позволяет ничего настраивать. В конце концов я просто закомментировал класс TwoWire в библиотеке Wire и все скомпилилось без проблем.

С кодом SPI чуть сложнее. Дело в том, что создатели библиотеки Adafruit_SSD1306 ничего не знают про С++ интерфейсы написали код и для SPI и для I2C. Причем выбор нужного происходит в рантайме. Поэтому компилятору ничего не остается, кроме как влепить обе реализации в код. Решается чуть более интеллектуальным комментированием кода в библиотеке.

Все остальное по мелочи. Где смог — пропатчил библиотеки, расставил const где нужно. Но в основном оставил все как есть. На данный момент занято 55кб флеша, из них моего кода чуть меньше 7к — все остальное библиотеки. Вот чуть более детально, если кому интересно

Потребление памяти по секциям

Name Size
.text section (Code in ROM)
System stuff 320
My code 212
NeoGPS 4056
Adafruit SSD1306 3108
FreeRTOS 3452
Arduino: Wire Library (I2C) 296
My Code 6744
Board init / system stuff 788
libmaple 3778
Arduino (HardwareSerial, Print) 1978
libmaple 280
libmaple USB CDC 2216
libmaple USB CoreLib 2388
math 12556
libc (malloc/free, memcpy, strcmp) 3456
Total: 45628
.data section (RAM)
libmaple constants & tables 820
USB stuff & descriptors (after cleanup) 84
Impure data (WTF? Used in FreeRTOS) 1068
malloc stuff 1044
Total: 3016
.rodata section (constants in ROM)
NeoGPS constants 140
Adafruit_SSD1306 constants 76
default font 1280
vtables 120
Monospace8x12 font 1512
vtables 42
My classes data + vtables 886
TimeFont 528
My classes data + vtables 168
Arduino + libmaple stuff 792
USB descriptors 260
Math constants 552
Total: 6356
.bss section (Zeroed variables in RAM)
stuff 28
display buffer 512
Heap 8288
FreeRTOS 192
My data 868
libmaple + arduino 168
usb 548
malloc stuff 56
usb 60
Total: 10720

Потребление ОЗУ моими классами и переменными

name Size
CurrentPositionScreen::drawScreen() const::longtitudeString 17
CurrentPositionScreen::drawScreen() const::latitudeString 19
timeZoneScreen 12
odometer1 52
odometer0 52
gpsDataModel 192
odometer2 52
gpsParser 292
lastPeriodIdleValue 4
curIdleTicks 4
lastCountedTick 4
lastCountedPeriod 4
debugScreen 12
speedScreen 12
positionScreen 8
timeScreen 12
screenStack 20
rootSettingsScreen 8
display 40
satellitesScreen 12
screenIdx 4
odometerScreen 24
altitudeScreen 8

Стоит отметить, что сам сгенерированый код получается довольно компактным (хоть и более размашистым, чем на AVR). Я не знаю ассемблера ARM, но выглядит он таким. Оптимизатор, кстати, не так лихо перемешивает код как в случае AVR. Все функции сгруппированы по их изначальному месторасположению — это значительно облегчает чтение.

А вот библиотечные функции libc занимают неприлично много. Я уже писал про 12к на sprintf. Это еще не все. Функции типа strcmp или memset занимают по нескольку экранов ассемблерного кода. Хотел бы я посмотреть что они там делают. Я даже скачал исходники newlib, где эти функции реализованы. Но там на ассемблере и написаны. С минимумом комментариев. Так что понятнее не стало. Можно было бы переписать самостоятельно, но, по моему, переписывать такие штуки это кощунство.

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

Единственная крупная и непонятная для меня часть — malloc/free. В своем коде я ее явно не использую. У FreeRTOS есть своя реализация. Откуда оно лезет неясно. Вызовов я не нашел. Я пробовал откатиться на самый первый коммит когда я спортировал свой проект на STM32 — этот код уже был в прошивке. Скажу больше. Если в пустом проекте подключить Adafruit_GFX уже будет malloc. Вряд ли библиотека тут виновата — я подключал совсем невинный хедер с тайпдефами. Скорее всего это какие то косяки билдсистемы.

В остальном все выглядит довольно прилично.

Послесловие

Ставлю бутылку тому, кто дочитал до этого места (С) студенческая байка

Проект медленно, но уверенно движется к цели. В этой части я переезжал на более мощную платформу ARM/STM32 и, если честно, мне это чертовски понравилось. По прежнему есть много недопонимания как все работает, даташит прочитан процентов на 20. Но это совершенно не мешает двигаться дальше.

Еще один крупный шаг, который я сделал — переезд на FreeRTOS. Код стал существенно проще и более структурированным. А самое главное его легко расширять дальше.

Наконец я подключил GPS приемник. С помощью библиотеки NeoGPS я смог получить все нужные данные и отобразить их на соответствующих экранах. Пришлось, правда, повозится с изобретением внутренней модели данных.

Сейчас я уперся в проблемы с билд системой ардуино. Она хороша для мелких проектов, но мне она жмет буквально со всех сторон. Система практически не конфигурируется и многие вещи происходят без моего ведома. К тому же у меня очень много вопросов со стороны конфигурационного менеджмента: как версионировать изменения в библиотеках? как разложить свои исходники по директориям, чтобы это было удобно? Как залить это в репозиторий, чтобы соратникам можно было с этим работать? В общем это будет первый приоритет в дальнейшей работе.

Только чует моя чуйка, что изменение билд системы повлечет за собой и другие вещи. По всей видимости придется переехать с Atmel Studio на CooCox или другую IDE. Возможно поменяется компилятор. Возможно придется отказываться от фреймворка Arduino. Пока сложно сказать что оно за собой потянет.

Ну а потом будет подключение SD карты, управление питанием, USB Mass Storage Device и много всего интересного.

Если кому понравилось — приглашаю присоединится к проекту. Я также буду рад конструктивным комментариям — они мне очень помогают.

Страничка прокта на гитхабе

Автор: grafalex

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js