Немного о RTOS

Когда необходимо выполнять несколько действий (процессов/задач) одновременно на микроконтроллере, обычно мы задумываемся об использовании RTOS (Real Time Operating System). RTOS обычно занимает несколько дополнительных килобайт памяти. В то же время для приложений RTOS может добавить больше сложности, в том числе при отладке.

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

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

Есть ли способ обойтись без RTOS, и в то же время обеспечить многозадачность?

Можно ли выполнять десятки различных задач одновременно на простых микроконтроллерах, не прибегая к помощи RTOS? Сегодня мы рассмотрим подход, который позволит выполнять несколько заданий одновременно, при этом дополнительно использовав очень маленький объем памяти микроконтроллера. Подход (назову его BezRTOS, хотел назвать NoRTOS, но это имя уже используется Texas Instruments для другого), который позволит оставлять процедуры прерываний быстрыми и в то же время контролировать многозадачность простым и прозрачным способом.

Несколько ключевых моментов, которые предполагают использование этого подхода:

  • Если отклик в реальном времени не обязателен и может быть задержан на несколько микросекунд или миллисекунд. В большинстве задач моментальный отклик на события не играет большой роли. Например, если дверь начнет открываться через одну миллисекунду, или светодиод включится через 1 миллисекунду, или нажатие кнопки сработает через одну миллисекунду, это не будет играть большой роли. Иногда, наоборот, считывание событий с задержкой более надежно, например, в случае дребезга контакта при нажатии кнопки.

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

  • Никакая RTOS не портирована на Вашу платформу, например, в случае контроллеров с маленьким объёмом памяти и дешевых микроконтроллеров (старых, в том числе)

  •  У Вас недостаточно времени для изучения RTOS 

  •  Вы хотите простой путь получения многозадачности 

  •  У Вас нет свободных ресурсов для разворачивания RTOS

Суть метода

  1. Разделите все Ваши задачи на группы: медленно исполняемые (необходимы длительные ожидания событий  внутри), средние (кратковременные задержки внутри) и быстрейшие (простые, быстро исполняемые - линейные и без задержек).

  2. Создайте для каждого типа задач очередь указателей на эти задачи (указателей на функции/процедуры).

  3. Исполняйте "быстрые" функции/процедуры вместо ожиданий внутри "средних" функций.

  4. Используйте средние и быстрые для ожидания внутри медленных.

На рисунке ниже схематично изображена медленная функция, средняя функция и быстрая функция и три очереди указателей на эти функции (очереди FIFO):

Примеры быстрых задач, исполняющихся в течение нескольких микросекунд:

  • установка инструкций прямого доступа к памяти переноса данных из массива

  • инструкции реакции на события, обычно используемые внутри прерывания

  • простые вычисления

Примеры средних задач: 

  • чтение периферии, например, АЦП polling,  SPI, I2C, UART… коммуникации с ожиданием ответа (температурные, барометрические, газовые... сенсоры)

  • сложные вычисления

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

  • задачи с ожиданием усреднения результата (АЦП, дребезг клавиш)

Примеры медленных задач: 

  • ожидание реакции пользователя на событие, например, нажатие клавиши 

  • ожидание ответа модема на АТ-команды в ходе коммуникации с удаленным абонентом

  • передача данных по WiFi на удаленный сервер 

  • отображение информации на дисплее

Реализация на Си

На фрагменте ниже приведен пример простейшей имплементации на языке Cи для одной очереди.

#define Q_SIZE_FAST 16

volatile int F1_last; 	// последний элемент в очереди 
int F1_first; 		// первый элемент в очереди

void (*F1_Queue[Q_SIZE_FAST])(); //очередь указателей на функции(процедуры)

void DummyF(void){;}

void F1_QueueIni(void){ // инициализация очереди
  F1_last = 0;
  F1_first = 0;
}

int F1_push(void (*pointerQ)(void)){ // добавить элемент в очередь
  if ((F1_last+1)%Q_SIZE_FAST == F1_first)return 1;
  F1_Queue[F1_last++] = pointerQ;
  F1_last %= Q_SIZE_FAST;
  return 0;
}

void (*F1_pull(void))(void){ // взять элемент из очереди -
                             //возвращает указатель
  void (*pullVar)(void);
  if (F1_last == F1_first)return DummyF;
  pullVar = F1_Queue[F1_first++];
  F1_first %= Q_SIZE_FAST;
  return pullVar;
}

Как видим, здесь всего несколько строчек кода, и этого, в общем-то, достаточно для разворачивания многозадачной системы. Здесь объявлена очередь указателей на функции, функция инициализации, функция добавления указателей на функции в очередь, функция взятия указателя из очереди. Вот всё, что нужно для использования одной очереди. 

Формирование задержек можно обеспечить, ожидая на очереди. Пример реализации функции ожидания ниже:

void DelayOnF1(uint64_t delay){
 uint64_t targetTime = delay + millis();
  while(millis() < targetTime) F1_pull()();
}

Здесь millis() возвращает значения счётчика миллисекунд с начала запуска программы (volatile uint64_t, инкрементируемое по таймеру раз в 1 миллисекунду).

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

Простейший пример использования

в main.c:

F1_QueueIni();
F2_QueueIni();
F3_QueueIni();
F4_QueueIni();
while(1){
  F1_pull()();
  F2_pull()();
  F3_pull()();
  F4_pull()();
}

Внутри обработчика одного прерывания: 

 F1_push(LED_On_Off);
 F1_push(ReadChannelsVoltage);

Внутри другого: 

F2_push(CalculateTemperatureMiddleValue);
F2_push(CalculateHumidityMiddleValue);

Внутри третьего:

F3_push(Display_ScreenInfo);
F3_push(ResetSensor);

Внутри четвёртого: 

F4_push(ScanKeyBoard);
F4_push(ReadTouchScr);

Если прерывания имеют одинаковый приоритет и не вытесняют друг друга (not nested, tail chaining interrupts) - можно добавлять указатели в одну и ту же очередь.

Передача аргументов

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

Пример имплементации ниже:

struct fParams {  // структура для передачи аргументов
  int IntVar;
  float FloatVar;
};

volatile int FP_last;  // последний элемент в очереди
int FP_first;  // первый элемент в очереди
void (*FP_Queue[Q_SIZE_FAST])(struct fParams *); //очередь указателей 
                                                 //на функции с параметрами

struct fParams PARAMS_array[Q_SIZE_FAST];        //очередь для хранения аргументов

void FP_QueueIni(void){ // инициализация очереди
  FP_last = 0;
  FP_first = 0;
}

int FP_push(void (*pointerQ)(struct fParams *), struct fParams * parameterQ){ // добавить функцию в очередь
  if ((FP_last+1)%Q_SIZE_FAST == FP_first)return 1;
  FP_Queue[FP_last] = pointerQ;
  PARAMS_array[FP_last++] = *parameterQ;
  FP_last %= Q_SIZE_FAST;
  return 0;
}

void FP_pull(void){ // взять и выполнить функцию из очереди, передав ей аргументы 
  void (*pullVar)(struct fParams *);
  struct fParams * Params;
  if (FP_last == FP_first)return;
  Params = &PARAMS_array[FP_first];
  pullVar = FP_Queue[FP_first++];
  FP_first %= Q_SIZE_FAST;
  pullVar(Params);
}

Небольшой пример:

main.c:

FPQueueIni();  

while(1){  
 FPpull(); 
}  

Внутри прерывания:

FP_push(ApmControl,&(struct fParams){1,7.18}); //добавляем функцию AmpControl                                                          //  и передаём параметры 1 и 7.18

Ещё примеры

На фрагменте кода ниже часть медленной задачи, которая использует ожидания на “средней” очереди:

void SIM800_IniCMD(void) {
 __HAL_UART_ENABLE_IT(shuart2, UART_IT_IDLE); //разрешаем IDLE
 __HAL_UART_ENABLE_IT(shuart2, UART_IT_RXNE); //разрешаем RXNE
 ResParse.bytes = 3; //сбрасываем прочитанные лог. уровни 
 Delay_ms_OnMediumQ(32); // ожидаем на "средней" очереди заданий 32 мс
 SIM800_AddCMD((char *) GSM_ATcmd, sizeof (GSM_ATcmd)); // команда для SIM800 через DMA 
 Delay_ms_OnMediumQ(4000); // ожидаем на "средней" очереди заданий 4 c
 SIM800_AddCMD((char *) GSM_ATcmd_Disable_Echo, sizeof (GSM_ATcmd_Disable_Echo));
...

Не теряйте время на ожидание, если даже Вам необходимо ждать! Исполняйте задачи, вброшенные по прерываниям и иным событиям, ожидая своего события.

Пример утилизации времени ожидания с BezRTOS подходом:

void blink(int count) { //поморгать LED
	while (count--) {
		HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_SET); // пин в лог.1
		HAL_Delay(100);// задержка 100 мс.
		HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_RESET);// пин в лог.0
	}
}

Переписано как:

void blink(int count) { //поморгать LED
	while (count--) {
		HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_SET); // пин в лог.1
		Delay_ms_OnMediumQ(100); 	// задержка 100 мс. выполняя задания 
    													//из "средней" очереди
		HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_RESET); // пин в лог.0
	}
}

Ещё пример:

было:

while(SignalNotStable);

стало:

while(SignalNotStable){
	S1_pull()(); //исполняем задания из очереди S1 пока ждём
}

C++ реализация

Реализация на С++ позволяет сократить количество кода в случае использования множества очередей, с помощью конструктора можно создать очередь необходимой длины.

typedef void(*fP)(void);

class fQ {
private:
    int first;
    int last;
    fP * fQueue;
    int lengthQ;
public:
    fQ(int sizeQ);
    ~fQ();
    int push(fP);    // добавить процедуру в очередь
    int pull(void);  // взять и выполнить процедуру из очереди
};

fQ::fQ(int sizeQ){ // инициализация очереди
  fQueue = new fP[sizeQ];
  last = 0;
  first = 0;
  lengthQ = sizeQ;
}

fQ::~fQ(){ // деинициализация очереди
  delete [] fQueue;
}

int fQ::push(fP pointerF){ // добавить элемент в очередь
  if ((last+1)%lengthQ == first){
            return 1;
  }
  fQueue[last++] = pointerF;
  last = last%lengthQ;
  return 0;
}

int fQ::pull(void){ // взять элемент из очереди
  if (last != first){
  fQueue[first++]();
  first = first%lengthQ;
  return 0;
  }
  else{
   return 1;
  }
}

Пример использования:

в main.cpp:

fQ F1(16); //инициализируем очереди различной длины
fQ F2(12);
fQ A1(8);

int main(){ 
  for(;;){ 
   A1.pull(); 
   usleep(10000); //спим по 10 миллисеунд
         } 
 return 0; 
 }

Где-то в прерывании №1: 

if(pin10.in == 1) F1.push(SwithOnRelay);
else F1.push(SwithOffRelay);

Где-то в прерывании №2:

F2.push(ToggleLED);

Где-то в прерывании по таймеру - раз в 100 миллисекунд:

A1.push(UpdateUI);

Внутри UpdateUI:

void UpdateUI(void){
  pin_CS_DISP.out = 0;
  Delay_ms_OnF1(2); //задержка на 2 мс. на очереди - исполняем задания из очереди F1
  DispLCD(Voltage);
  Delay_ms_OnF2(2); //задержка на 2 мс. на очереди - исполняем задания из очереди F2
  pin_CS_DISP.out = 1;
}

Преимущества

  • Нетребовательный к ресурсам подход, позволяющий выполнять множество задач “параллельно”

  • Задания не прерывают друг друга, простота отладки

  • Легко масштабируется, прерывания с разными приоритетами могут иметь “свои” очереди

  • Платформонезависимость - легкость разворачивания даже на самых простых микроконтроллерах

  • Не требует длительного изучения

  • Не требует дополнительных источников прерываний (нет планировщика заданий)

  • Отсутствуют издержки, связанные с вытеснением заданий друг другом и сохранением их контекстов - существенная экономия времени и памяти

  • Задания легко вбрасываются в очереди, сохраняя прерывания и другие важные фрагменты кода, где задержки не желательны, максимально быстрыми

  • “Задания” могут добавлять сами себя в другие очереди заданий

  • Позволяет эффективно утилизировать время ожидания, исполняя задания из очередей, при этом, в зависимости от времени предполагаемого ожидания. Можно ожидать, например на “средних” и “быстрых” функциях/процедурах внутри “медленных”,  на “быстрых” внутри “средних”

В качестве заключения несколько напутствий:

Ранжирование задач может быть более глубоким, например: “быстрейшие”, “быстрые’, “средние”, “медленные”, “очень медленные”. Каждого вида очередей может быть по несколько штук.

Делайте очереди достаточной длины, чтобы задания не терялись.

Для вложенных прерываний - вбрасывайте задания из обработчиков таких прерываний в разные очереди.

BezRTOS будет работать там, где полноценный RTOS трудно или даже невозможно интегрировать (например, ATTINYxx, PIC16FXXX).

Применяя данный подход успешно уже несколько лет, не мог не написать об этом статью.

BezRTOS это не RTOS, это метод обеспечения эффекта многозадачности и эффективной утилизации времени ожиданий, позволяющий сохранить обработчики прерываний быстрыми (там останутся лишь короткие инструкции, которые требуют безотлагательного выполнения, основное "задание" чуть-чуть подождёт в очереди...)