Два подхода к проектированию ПО для embedded

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

Хочу немного рассказать о двух подходах проектирования ПО в embedded. Два подхода эти – c использованием суперцикла или же с использованием RTOS (Real-Time Operation System, операционная система реального времени).

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

Надеюсь, будет интересно всем тем, кто хочет заглянуть в мир разработки для встраиваемых систем. Для тех, кто в embedded уже собаку съел, скорее всего, не будет ничего нового.

Совсем немного теории (для тех, кто делает самые первые шаги).

Есть у нас микроконтроллер, представляющий из себя собственно процессор, немного памяти и различную периферию, например: аналого-цифровые преобразователи, таймеры, Ethernet, USB, SPI – все это сильно зависит от контроллера и решаемых задач.

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

А к выходу контроллера, называемому GPIO (General Purpose Input-Output) можно, к примеру, подключить светодиод (или же что-нибудь более мощное вроде моторчика, но уже через усилитель).

Через SPI, RS232, USB и т.п. контроллер может связываться с внешним миром более сложным способом – получая и отсылая сообщения по заранее заданному протоколу.

В 90% случаев ПО пишется на С, иногда может использоваться С++ или ассемблер. Хотя все чаще появляются возможности писать на чем-нибудь более высокоуровневом, если это не касается непосредственной работы с периферией и не требуется максимально возможное быстродействие.

Чтобы лучше представить, с чем приходится иметь дело, вот пара примеров окружений, с которыми приходится работать: размер FLASH контроллера (аналог жесткого диска) – 16-256 килобайт, размер RAM – 64-256 килобайт! И в таком окружении реально запустить не только приложение, а еще и операционную систему реального времени с полноценной поддержкой многозадачности!

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

Итак, подход «суперцикл».

Программа в этом подходе выглядит проще простого:

int main()
{
  while(1)
  {
    doSomething();
    doSomethingElse();
    doSomethingMore();
  }
}

Бесконечный цикл, в котором контроллер последовательно делает все, что он должен делать.

Самое интересное, конечно же, во встраиваемых системах – это работа с периферией (теми самыми АЦП, SPI, GPIO и т.д.). С внешней периферией контроллер может работать двумя способами: опрашивая или используя прерывания. В первом случае, если мы хотим, например, прочитать символ из RS232 консоли, то мы будем периодически проверять, нет ли там символа, до тех пор, пока его не получим. Во втором же случае мы настраиваем RS232 контроллер так, чтобы он генерировал прерывание в тот момент, когда появится новый символ.

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

int main()
{
  init_adc();
  init_gpio_as_out();
  while (1)
  {
    int temperature = readTemperature();
    if (temperature > TEMPERATURE_LIMIT)
    {
      turnLedOn();
    }
    else
    {
      turnLedOff();
    }
}

Пока все должно быть просто и понятно. (Функции чтения температуры и манипуляций со светодиодом приводить не буду – это не цель данной статьи).

Но что, если нам нужно делать что-то с заданной периодичностью? В примере выше температура будет проверяться с максимально возможной частотой. А если нам, например, нужно мигать светодиодом раз в секунду? Или опрашивать датчик строго с интервалом в 10 миллисекунд?

Тогда на помощь приходят таймеры (которые есть практически у любого микроконтроллера). Работают они так, что генерируют прерывание с заданной частотой. Мигание светодиода тогда будет выглядеть как-то так:

volatile int interrupt_happened = 0;
interrupt void timer_int_handler()
{
  interrupt_happened = 1;
  clear_interrupt_condition();
}

int main()
{
  init_timer(1_SECOND_INTERVAL, timer_int_handler);
  while (1)
  {
    if (interrupt_happened)
    {
      ledToggle();
      interrupt_happened = 0;
    }
  }
}

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

Эта самая глобальная переменная обязательно должна быть объявлена с идентификатором volatile – иначе оптимизатор может банально «выбросить» неиспользуемый с его точки зрения код.

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

volatile uint millisecond_counter = 0;
interrupt void timer_int_handler()
{
  ++millisecond_counter;
  clear_interrupt_condition();
}

int main()
{
  init_timer(1_MILLISECOND_INTERVAL, timer_int_handler);
  while (1)
  {
    uint timestart1 = millisecond_counter;
    uint timestart2 = millisecond_counter;
    if (millisecond_counter – timestart1 > 1000) // 1 second interval
    {
      led1Toggle();
      timestart1 = millisecond_counter;
    }

    if (millisecond_counter – timestart2 > 333) // 1/3 second interval
    {
      led2Toggle();
      timestart2 = millisecond_counter;
    }
  }
}

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

Представим теперь, что у нас есть отладочная консоль, реализованная поверх интерфейса RS232 (самое распространенное решение в мире embedded!). И мы хотим выводить туда отладочные сообщения (которые будут видны, если наш контроллер подключить к компу через COM-порт). А одновременно с этим нам нужно со строго заданной (при этом высокой) частотой опрашивать датчик, подключенный к контроллеру.

И вот здесь возникнет вопрос – как реализовать такую банальную вещь, как вывод строки в консоль? Очевидное решение вроде

void sendString(char * str)
{
  foreach (ch in str)
  {
    put_ch(ch);
  }
}

будет в данном случае недопустимым. Строку-то оно выведет, но при этом необратимо нарушит требование опрашивать датчик со строго заданной частотой. Мы же все это делаем в одном большом цикле, где все действия выполняются последовательно, помните? А консоль – устройство медленное, и вывод строки может занять гораздо больше времени, чем требуемый интервал между последовательными опросами датчика. Пример ниже — то, как делать не надо!

int main
{
  while (1)
  {
    …
    if (something)
    {
      send_string("something_happened");
    }
    …
    if (10_millisecond_timeout())
    {
      value = readADC();
    }
  }
}

Еще пример – захотите вы реализовать программную защиту от перегрузки. Добавите измеритель тока, подключите его к АЦП контроллера, управление предохранительным реле заведете на один из пинов входа-выхода. И естественно захотите, чтобы защита срабатывала как можно быстрее после наступления события перегрузки (а иначе все просто сгорит). А у вас – все тот же общий цикл, в котором все действия выполняются строго по порядку. И гарантированное время реакции на событие никак не может быть меньше, чем время выполнения одной итерации цикла. И если в этом цикле будут операции, требующие для своего завершения длительного времени – то собственно все, именно они и будут задавать время реакции системы на все остальное.

А если вдруг где-то в этом цикле закрадется ошибка – то «ляжет» вся система. В том числе и реакция на перегрузку (чего допускать очень не хотелось бы, не так ли?).

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

int position = 0;
int send_string(char * str)
{
  if (position < strlen(str)
  {
    put_ch(str[position];
    ++position;
    return 1;
  }
  else
  {
    return 0;
  }
}

А простой вызов этой функции на что-то вроде:

int main
{
  while (1)
  {
    …
    if (something)
    {
      do_print = 1;
      position = 0;
    }
    if (do_print)
    {
      do_print = send_string("something_happened");
    }
    …
    if (10_millisecond_timeout())
    {
      value = readADC();
    }
  }
}

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

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

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

Еще одна проблема дизайна «общий цикл» — сложность измерения загруженности системы. Предположим, у вас есть код:

interrupt void external_interrupt_handler()
{
  interrupt_happened = 1;
  clear_interrupt_condition();
}

int main()
{
  while (1)
   {
      if (interrupt_happened)
      {
        doSomething();
        interrupt_happened = 0;
      }
   }
}

Система как-то реагирует на прерывание, приходящее извне. И вопрос – сколько таких прерываний в секунду система сможет обработать? Насколько будет занят процессор при обработке 100 событий в секунду?

Вам будет очень сложно измерить, сколько времени было потрачено на обработку событий, а сколько – на опрос переменной «А не произошло ли прерывание?». Ведь все выполняется в одном цикле!

И вот здесь на помощь приходит второй подход.

Применение операционной системы реального времени.

Проще всего ее применение проиллюстрировать на том же примере: одновременный опрос датчика с заданной частотой и вывод на консоль длинной отладочной строки.

void SensorPollingTask()
{
   while (1)
   {
     value = SensorRead();
     if (value > LIMIT)
     {
       doSomething();
     }
     taskDelay(10_MILLISECOND_DELAY);
  }
}


void DebugTask()
{
    dbg_task_queue = os_queue_create();
    while   (1)
    {
      char * str = os_queue_read(dbg_task_queue);
      foreach (ch in str)
      {
         put_ch(ch);
      }
    }
}

void OtherTask()
{
    other_task_init();
    …
    while(1)
    {
       …
       // we want to do a dbg_printout here
       os_queue_put("Long Debug Output String");
       …
     }
}

int main()
{
    os_task_create(SensorPollingTask, HIGH_PRIORITY);
    os_task_create(DebugTask, LOW_PRIORITY);
    os_task_create(OtherTask, OTHER_PRIORITY);
    os_start_sheduler();
}

Как видите, в главной функции больше нет одного главного бесконечного цикла. Вместо него – отдельный бесконечный цикл в каждой задаче. (Да-да, функция os_start_sheduler(); никогда не вернет управление!). И что самое главное – у этих задач есть приоритеты. Операционная система сама обеспечит то, что нам нужно – чтобы задача с высоким приоритетом выполнялась прежде всего, а с низким – только лишь тогда, когда ей остается время.

И если время реакции на, например, прерывание в дизайне с суперциклом будет равно в худшем случае времени выполнения всего цикла (прерывание-то случится, конечно же, сразу же, но далеко не всегда необходимые действия можно сделать непосредственно в обработчике), то время реакции в случае ОС реального времени будет равно времени переключения между задачами (которое достаточно мало, чтобы считать, что это происходит сразу же!). Т.е. прерывание произойдет в одной задаче, а сразу по его завершению мы переключимся на другую задачу, ожидающую события, «запущенного» из прерывания.

interrupt void overcurrent_handler()
{
  os_semaphore_give(overcurrent_semaphore);
  clear_interrupt_condition();
}

void OvercurrentTask()
{
  os_sem_create(overcurrent_semaphore);
  while (1)
    {
      os_semaphore_take(overcurrent_semaphore);
      DoOvercurrentActions();
    }
}

Что касается измерения загрузки процессора – то и эта задача с применением ОС становится тривиальной. По умолчанию каждая ОС имеет самую прожорливую (но и самую низкоприоритетную) задачу Idle, которая выполняет пустой бесконечный цикл и получает управление лишь тогда, когда все остальные задачи неактивны. И подсчет времени, проведенного в Idle, обычно тоже уже реализован. Остается лишь его вывести в консоль.

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

И подводя итог: если система очень простая и нетребовательная ко времени реакции, ее проще сделать по образцу «суперцикл». Если же система собирается стать большой, соединяющей в себе много разных действий и реакций, которые к тому же критичны ко времени – то альтернативы использования ОС реального времени нет.

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

Минус же использования ОС – для ее использования требуется больше места, памяти, опыта и знаний (хотя ничего сложного там и нет, все же многозадачность априори сложнее и непредсказуемее, чем последовательно выполняющийся код). Обязательно хорошее понимание принципов работы в многозадачной среде, принципов потокобезопасного кода, синхронизации данных и многого другого.

Для «поиграться» можно взять FreeRTOS – бесплатный проект с открытым кодом, при этом достаточно стабильно работающий и простой в освоении. Хотя не редкость и коммерческие проекты с использованием именно этой операционки.

Автор: olekl

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


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