Альтернативный подход к проектированию ПО для Embedded

в 12:53, , рубрики: embedded, rtos, встраиваемые системы, микроконтроллеры, Программинг микроконтроллеров, метки: , , ,

Данный топик я решил написать после ознакомления со статьей «Два подхода к проектированию ПО для embedded». При прочтении которой я наткнулся на фразу: «Если же система собирается стать большой, соединяющей в себе много разных действий и реакций, которые к тому же критичны ко времени – то альтернативы использования ОС реального времени нет». «Как это нет?», — подумал я. Конечно, если речь идет о больших высоконагруженных системах реального времени, где используются большие процессоры, то без ОС может не обойтись, а вот для более скромных микроконтроллерных решений вполне существует альтернативный вариант. Ведь задачки можно выполнять при помощи обычного switch-case и при этом обеспечивать необходимое время реакции.

Почему лично я не использую RTOS

Товарищ olekl рассказал про RTOS, не буду заострять на этом внимание. Отмечу пару пунктов, которые я лично для себя выделил — почему я не использую RTOS:

  • Операционка для своей работы требует ресурсы микроконтроллера: память и системное время их и так немного. Пустить бы их на задачки, но придется отдавать диспетчеру. Пожалуй, это самый основной для меня пункт.
  • Не простой для меня способ организации задач. Мьютексы, семафоры, приоритеты и т.п. — заблудиться можно.
  • Некоторые RTOS стоят денег. Причем не маленьких.
  • Есть некоторые сомнения по поводу поддержки RTOS контроллерами. Вдруг захочу перенести проект на новейший контроллер, а для него еще нет поддержки этой операционки.
  • Сомнение: а вдруг в ядре ошибка? Наверняка предлагаемые RTOS оттестированы на миллион раз, но кто его знает: вдруг что-нибудь вылетит в миллион первый раз.

Подход со switch-case

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

В устройстве присутствуют два датчика температуры. Время опроса первого датчика не критично: «опросили, да и ладно», пускай будет периодичность в 0.2 мс. По превышению заданного порога температуры будем зажигать светодиод. Показания второго датчика для нас напротив очень важны. Его нужно опрашивать как можно чаще и по превышению заданного порога выдавать «1» на пин, для того чтобы дискретным сигналом включить вентилятор охлаждения. При понижении температуры до другого порога выключаем вентилятор. Где-то каждые 100 мс значение со второго датчика необходимо записывать в ПЗУ.
Для реализации потребуется прерывание аппаратного таймера. Ведь только так мы сможем гарантировать выполнение задач в отведенное им время. В таком случае возможности для использования других прерываний резко сокращаются.
Работу с большей частью периферии можно сделать без прерываний, а очень важные коммуникационные прерывания (например: UART/SCI) обычно имеют более высокий приоритет, чем таймер и обычно служат для фиксирования принятых/отправленный байт, т.е. много времени не отнимут.
Подход, когда в таймере только отсчитывается время для задач, а сами задачи выполняются в фоне (или суперцикле while) без запрета прерываний не гарантирует нужной реакции выполнения.

Для начала сделаем драйвер датчика температуры. Основная его функция – это считывание значения температуры по SPI.

Структура:

typedef struct 
   {
	unsigned char SpiCh;   // Используемый модуль SPI (A, B, C)
	unsigned int SpiBrr;     // Частота SPI-модуля
	unsigned int Value;              // Значение с датчика
	void (*ChipSelect) (unsigned char level_);    // Callback Функция выбора микросхемы
		…				   	          // Что-нибудь еще
   }TSensor;

Функция опроса датчика температуры:

void SensorDriver(TSensor *p)
{
   p->ChipSelect(0);			// Выбрали микросхему
   p->Value = SpiRead(p->SpiCh, p->SpiBrr);	// Считали значение по SPI
   p->ChipSelect(1);				// Сняли выбор микросхемы
}

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

TSensor Sensor1;
TSensor Sensor2;

void Init(void)
{
   Sensor1.ChipSelect = &ChipSelectFunc1;		// Ссылка на функцию выбора микросхемы
   Sensor1.SpiCh = 0;					// Линия SPI
   Sensor1.SpiBrr = 1000;				// Частота SPI
   Sensor2.ChipSelect = &ChipSelectFunc2;
   Sensor2.SpiCh = 0;
   Sensor2.SpiBrr = 1000;
}

Основная функция драйвера – это чтение температуры. Что с этими данными делать будем решать вне драйвера.

Зажигаем светодиод:

void SensorLed(void)
{
   if (Sensor1.Value >= SENSOR_LED_LIMIT)
      LedPin = 1;
   else If (Sensor1.Value < SENSOR_LED_LIMIT)
      LedPin = 0;
}

Включаем/выключаем вентилятор дискретной ножкой:

void SensorCooler(void)
{
   if (Sensor2.Value >= SENSOR_LED_LIMIT)
      CoolerPin = 1;
   else if (Sensor1.Value < SENSOR_LED_LIMIT)
      CoolerPin = 0;
}

Странно, но функции получились на удивление похожими :)
Записывать в ПЗУ будем следующим образом:
функция драйвера ПЗУ будет циклично выполняться на частоте 1кГц, при этом ожидая данные для записи, инструкцию «что с ними нужно сделать» и по какому адресу в памяти. Т.е. нам достаточно проверять готовность памяти и направлять ей данные с инструкцией из любого места программы.

void SensorValueRecord()
{
   unsigned int Data = Sensor2.Value;			// Значение температуры с датчика
   unsigned int Address = 0;				// Адрес в памяти

   if (EepromReady())				        // Проверяем готовность ПЗУ
   {
	// Отправляем данные, адрес и указание, что данные нужно записать
      EepromFunction(Address, Data, WRITE);
   }
}

Данные мы отправили и когда драйвер памяти вступит в работу (а делает он это в 100 раз быстрее, чем функция SensorValueRecord), то он уже будет знать, что ему делать.
Наши функции готовы. Теперь их нужно правильно организовать.
Для этого заведем прерывание таймера с частотой 10кГц (100 мкс). Это будет наша максимальная гарантированная частота вызова задач. Пусть этого будет достаточно. Создаем функции планировщика задач, в которых будем определять, когда какую задачу запускать.

#define MAIN_HZ         10000
#define TASK0_FREQ   1000
#define TASK1_FREQ   50
#define TASK2_FREQ   10

// Основная функция диспетпчера
void AlternativeTaskManager(void)
{
   SensorDriver(&Sensor2);	// Важная задачка опроса второго датчика
   SensorCooler();			// Важная задачка включения вентилятора
   Task0_Execute();		// Запускаем задачи нулевого цикла
}

// Задачи 1кГц
void Task0_Execute(void)
{
   switch (TaskIndex0)
   {
      case 0:  EepromDriver(&Eeprom);	break;
      case 1:  Task1_Execute(); 	break;
      case 2:  Task2_Execute();		break;
   }

   // Зацикливаем задачки
   if (++TaskIndex0 >= MAIN_HZ / TASK0_FREQ)
   TaskIndex0 = 0;
}

// Задачи с частотой 50 Гц
void Task1_Execute(void)
{
   switch (TaskIndex1)
   {
      case 0: SensorDriver(&Sensor1);	break;
      case 1: SensorLed(); 		break;
   }

   if (++TaskIndex1 >= TASK0_FREQ / TASK1_FREQ)
      TaskIndex1 = 0;
}

// Задачи с частотой 10 Гц
void Task2_Execute(void)
{
   switch (TaskIndex2)
   {
   case 0: SensorValueRecord();		break;
   case 1:	 			break;
   }

   if (++TaskIndex2 >= TASK0_FREQ / TASK2_FREQ)
   TaskIndex2 = 0;
}

Теперь осталось запустить планировщик в прерывании таймера и готово.

interrupt void Timer1_Handler(void)
{
   AlternativeTaskManager();
}

Данная система выглядит как эдакий механизм с шестеренками: самая главная шестеренка непосредственно на валу двигателя и она крутит остальные шестеренкии.
Задачки выполняются «по кольцу». Частота их выполнения зависит от места вызова. Функция Task0_Execute будет выполнятся с частотой 10кГц, поскольку вызываем ее непосредственно в прерывании таймера (наша главная шестеренка). В ней происходит деление частоты и с помощью switch-case с TaskIndex0 определяется для какой задачи пришло время. Частота вызова задачек должна быть меньше, чем 10кГц.
Мы установили частоту задач для цикла Task0_Execute равную 1кГц, значит в нем может быть выполнено 10 задач с частотой в 1кГц:

#define MAIN_HZ	   10000
#define TASK0_FREQ  1000

if (++TaskIndex0 >= MAIN_HZ / TASK0_FREQ)

Структура switch-case системы
Альтернативный подход к проектированию ПО для Embedded

Аналогично для Task1_Execute и Task2_Execute. Вызываем их с частотой в 1кГц. В первом цикле задачи должны выполняться с частотой в 50Гц, а во втором — 10Гц. Получаем, что всего будет 20 и 100 задач соответственно.
После выполнения задач диспетчера программа возвращается в фон (суперцикл background).
Какие-нибудь не критичные по времени реакции, то их вполне можно поместить туда.

void main(void)
{
   Init();

   while (1)
   {
      DoSomething();
   }
}

К устройству добавляется ЦАП и вместе с зажиганием светодиода нужно генерировать сигнал 4-20? Не вопрос. Создаем драйвер ЦАП и запускаем его. В функцию SensorLed добавляем две строчки, которые будут указывать драйверу какое ему значение выдавать на выход и диспетчере вызываем функцию драйвера.

void SensorLed(void)
{
   if (Sensor1.Value >= SENSOR_LED_LIMIT)
   {
      LedPin = 1;
      Dac.Value = 20;					// Значение на выходе ЦАП
   }
   else If (Sensor1.Value < SENSOR_LED_LIMIT)
   {
      LedPin = 0;
      Dac.Value = 4;                                     // Значение на выходе ЦАП
   }
}

// Задачи с частотой 50 Гц
void Task1_Execute(void)
{
   switch (TaskIndex1)
   {
      case 0: SensorDriver(&Sensor1);	break;
      case 1: SensorLed(); 		break;
      case 2: DacDriver(&Dac) 		break;             // Функция драйвера ЦАП
   }

   if (++TaskIndex1 >= TASK0_FREQ / TASK1_FREQ)
      TaskIndex1 = 0;
}

Добавили двухстрочный индикатор? Тоже не проблема. Запускаем его драйвер на частоте 1кГц, т.к. символы нужно передавать быстро, а в других более медленных функциях указываем драйверу какие именно символы и строки нужно будет отображать.

Оценка загрузки

Для того, чтобы оценить загрузку необходимо включить второй аппаратный таймер, который работает с такой же частотой как и первый таймер. По хорошему бы сделать так, чтобы период таймеров был не впритык.
Перед запуском менеджера задач сбросили счетчик таймера, а после работы считали его значение. Оценка загрузки проводится по периоду таймера. Например, период первого таймера равен 100. Т.е., счетчик досчитает до 100 и возникнет прерывание. Если счетчик второго таймера (CpuTime) насчитал меньше 100 значит — хорошо. Если впритык или больше – плохо: время реакции задач поплывет.

unsigned int CpuTime = 0;
unsigned int CpuTimeMax = 0;
interrupt void Timer1_Handler(void)
{
	Timer2.TimerValue = 0;			// Сбросили таймер

	AlternativeTaskManager();              // Наш switch-case диспетчер задач

	CpuTime = Timer2.Value;		// Считали значение таймера = загрузка
	if (CpuTime > CpuTimeMax )		// Определяем пиковую загрузку
		CpuTime = CpuTimeMax;
	
}

Что в результате

Какие лично я получил преимущества по сравнению с RTOS:
— Расход ресурсов при работе диспетчера мизерный.
— Организация задач хоть и не простая, но она сводится к определению: где какую функцию запустить. Нет никаких семафоров, мьютексов и т.п. Не нужно читать многостраничные мануалы к RTOS. Не сказать, чтобы преимущество, но я так привык.
— Код можно легко перенести с одного контроллера на другой. Главное не забыть про типы, которые используются.

Недостаток:
— Усложнение ПО. Если в случае RTOS можно написать функцию и тут же ее запустить, если хватит ресурсов, то в случае со switch-case придется более плотно подходить к оптимизации. Придется думать как повлияет то или иное действие на производительность всей системы. Лишний набор действий может привести к нарушению «движения шестеренок». Чем больше система, тем сложнее ПО. Если для операционки функция может быть выполнена в один заход, то здесь возможно придется разбивать по шагам более детально (конечный автомат). Например, драйвер индикатора не сразу пересылает все символы, а по строчкам:
1) выставил строб, переслал верхнюю строку, вышел;
2) переслал нижнюю строку, убрал строб, чтобы символы отобразились, вышел.
Если наработок мало, то такой подход повлияет на скорость разработки.
Я пользуюсь таким подходом не первый год. Есть много наработок, библиотек. А вот новичкам будет сложновато.

В данном материале я попробовал раскрыть альтернативный подход к проектированию ПО для встраиваемых систем без использования RTOS. Надеюсь кто-нибудь почерпнул что-нибудь полезное.

Автор: renoize


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


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