Данный топик я решил написать после ознакомления со статьей «Два подхода к проектированию ПО для 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 системы
Аналогично для 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