Статическая подписка с использованием шаблона Наблюдатель на примере С++ и микроконтроллера Cortext M4

от автора

Всем доброго здравия!

В преддверии Нового года хочу продолжить рассказывать про использование С++ на микроконтроллерах, на этот раз попытаюсь рассказать про использование шаблона Наблюдатель (но далее я буду называть его Издатель-Подписчик или просто Подписчик, такой вот каламбур), а также реализацию статической подписки на С++17 и преимущества этого подхода в некоторых приложениях.

Введение

Шаблон Подписчик один из самых распространенных шаблонов, которые используются в разработке ПО. С его помощью, например, делают обработку нажатия кнопок в Windows Form. Да и вообще в любом месте где нужно отреагировать как-то на изменения параметров системы, будь то изменения в файлах или обновление измеренного значения от датчика самое время не думая использовать шаблон Подписчик.

Преимущество шаблона заключается в том, что мы развязываем знания об Издателе и Подписчике, не привязываясь к конкретным объектам. Можем подписать кого угодно к кому угодно, при этом не затрагивая реализацию объектов Издателя и Подписчика.

Начальные условия

Перед тем как начнем знакомиться с шаблоном, давайте вначале договоримся, что мы хотим разрабатывать надежное ПО, в котором:

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

А теперь давайте рассмотрим стандартную реализацию шаблона Подписчик.

Стандартная реализация

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

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

Здесь ButtonController класс отвечающий за опрос кнопки и оповещение подписчиков о нажатии, а Led в данном случае подписчик. Эти два класса развязаны между собой посредством интерфейсов IPublisher и ISubsriber и ни один из классов не знает про другой. Таким образом, любой объект наследующий интерфейс ISubscriber может подписаться на событие от ButtonController.

Поскольку динамическое выделение памяти запрещено, то я объявил массив из 3 элементов для подписки. Т.е. максимум может быть 3 подписчика. Вот так в первом приближении может выглядеть метод оповещения подписчиков у класса ButttonsController

struct ButtonController : IPublisher  {     void Run()    {     for(;;)     {       if (UserButton::IsPressed())       {         Notify() ;       }     }   }    void Notify() const override   {     // Пробегаемся по списку подписчиков и вызываем у них метод HandleEvent()     for(auto it: pSubscribers)     {       if (it != nullptr)       {         it->HandleEvent() ;       }     }   } } ;

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

В нашем случае, светодиоду позволено делать все что угодно, поэтому он делает переключение своего состояния:

template <typename Port, std::uint32_t pinNum> struct Led: ISubscriber                           {   static void Toggle()   {     Port::ODR::Toggle(1 << pinNum);   }    void HandleEvent() override   {     //Собственно это то, ради чего все затевалось, моргнуть     Toggle() ;    } };

Полная реализация всех классов

 template<typename Port, std::size_t pinNum> struct Button {   static bool IsPressed()   {     bool result = false;     if ((Port::IDR::Read() & (1 << pinNum)) == 0) //если кнопка нажата     {       while ((Port::IDR::Read() & (1 << pinNum)) == 0) // ждем пока не отожмут       {       };       result = true;     }     return result;   } } ;  // Пользовательская кнопка на порте GPIOC.13 using UserButton = Button<GPIOC, 13> ;  struct ISubscriber {   virtual void HandleEvent() = 0; } ;  struct IPublisher {   virtual void Notify() const = 0;   virtual void Subscribe(ISubscriber* subscriber) = 0; } ;  template <typename Port, std::uint32_t pinNum> struct Led: ISubscriber                           {    static void Toggle()   {     Port::ODR::Toggle(1 << pinNum);   }    void HandleEvent() override   {     Toggle() ;   } };  struct ButtonController : IPublisher {     void Run()    {     for(; ;)     {       if (UserButton::IsPressed())       {         Notify() ;       }     }   }    void Notify() const override   {     for(auto it: pSubscribers)     {       if (it != nullptr)       {         it->HandleEvent() ;       }     }   }    void Subscribe(ISubscriber* subscriber) override   {     if (index < pSubscribers.size())      {       pSubscribers[index] = subscriber ;       index ++ ;     }    // Если больше 3 подписчиков то курить...чисто для примера   }  private:     std::array<ISubscriber*, 3> pSubscribers ;   std::size_t index = 0U ; } ; 

А как подписка может выглядеть в коде? А вот так:

 int main() {   // Светодиод Led1 подключен к выводу 5 порта GPIOC   static Led<GPIOC,5> Led1 ;     // Светодиод Led2 подключен к выводу 8 порта GPIOC   static Led<GPIOC,8> Led2 ;   // Светодиод Led3 подключен к выводу 9 порта GPIOC   static Led<GPIOC,9> Led3 ;    ButtonController buttonController ;    // Подписываем 3 светодиода   buttonController.Subscribe(&Led1) ;   buttonController.Subscribe(&Led2) ;   buttonController.Subscribe(&Led3) ;    // Запускаем контроллер на вечный опрос кнопки   buttonController.Run() ; }

Хорошая новость заключается здесь в том, что мы можем подписать любой объект, и время его создания нам неважно. Это может быть глобальный объект, статический или локальный. С одной стороны это хорошо, а с другой зачем в данном коде нам делать подписку в runtime. Ведь по сути в данном коде адрес объектов Led1, Led2, Led3 известен на этапе компиляции. Так почему нельзя подписаться еще на этапе компиляции и держать массив указателей на подписчиков в ПЗУ?

Кроме того, здесь есть риск потенциальных ошибок, например, многие ли задумывались, что произойдет при вызове метода Subsсribe(), если он будет вызваться из нескольких потоков? Мы ограничены всего 3 подписчиками, а что будет, если мы подпишем 4 светодиод?

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

Ну и совсем плохая новость, такое архитектурное решение занимает оооооочень много места и в ПЗУ и в ОЗУ. На всякий случай запишем, сколько ПЗУ и ОЗУ занимает это решение:

Module ro code ro data rw data
main.o 488 64 21

Т.е. в сумме 552 байта в ПЗУ и 21 байт в ОЗУ — скажем так не очень для того, чтобы нажать на кнопку и моргнуть тремя светодидами.

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

Статическая подписка

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

  • Традиционный — тот же самый подход, но с использованием constexpr конструктора и заданием списка подписчиков через него.
  • Нетрадиционный С использованием шаблонов — передать список подписчиков через параметры шаблона. (здесь шаблон — это определение из области метапрограммирования, а не шаблонов проектирования)

Традиционный подход к статической подписке

Попробуем сделать подписку на этапе компиляции. Для этого немного подправим нашу архитектуру:

Картинка мало чем отличается от изначальной, но есть несколько различий: удален метод Subscribe(), и теперь подписка будет осуществляться непосредственно в конструкторе. Конструктор должен принимать переменное число аргументов, а для того, чтобы можно подписаться статически на этапе компиляции он будет constexpr. В нем будет инициализироваться массив подписчиков и эта инициализация может быть проведена во время компиляции:

struct ButtonController : IPublisher {   template<typename... Args>   constexpr ButtonController(Args const*... args): pSubscribers()   {     std::initializer_list<ISubscriber const*> result = {args...} ;     std::size_t index = 0U;      for(auto it: result)     {       if (index < size)       {         pSubscribers[index] = const_cast<ISubscriber*>(it);       }       index ++ ;     }         }  private:     static constexpr std::size_t size = 3U;   ISubscriber* pSubscribers[size] ;   } ;

Полный код для такой реализации

struct ISubscriber {   virtual void HandleEvent() const  = 0; } ;  struct IPublisher {   virtual void Notify() const = 0; } ;  template<typename Port, std::size_t pinNum> struct Button {   static bool IsPressed()   {     bool result = false;     if ((Port::IDR::Read() & (1 << pinNum)) == 0) //если кнопка нажата     {       while ((Port::IDR::Read() & (1 << pinNum)) == 0) // ждем пока не отожмут       {       };       result = true;     }     return result;   } } ;  template <typename Port, std::uint32_t pinNum> struct Led: ISubscriber                           {   constexpr Led()   {   }    static void Toggle()   {     Port::ODR::Toggle(1<<pinNum);   }    void HandleEvent() const override   {     Toggle() ;   } };  // Пользовательская кнопка на порте GPIOC.13 using UserButton = Button<GPIOC, 13> ;  struct ButtonController : IPublisher {   template<typename... Args>   constexpr ButtonController(Args const*... args): pSubscribers()   {     std::initializer_list<ISubscriber const*> result = {args...} ;     std::size_t index = 0U;      for(auto it: result)     {       if (index < size)       {         pSubscribers[index] = const_cast<ISubscriber*>(it);       }       index ++ ;     }         }    void Run() const   {     for(; ;)     {       if (UserButton::IsPressed())       {         Notify() ;       }     }   }    void Notify() const override   {     for(auto it: pSubscribers)     {       if (it != nullptr)       {         it->HandleEvent() ;       }     }   }  private:     static constexpr std::size_t size = 3U;   ISubscriber* pSubscribers[size] ;   } ; 

Теперь подписку можно сделать во время компиляции:

int main() {    // Светодиод Led1 подключен к выводу 5 порта GPIOC    static constexpr Led<GPIOC,5> Led1 ;      // Светодиод Led2 подключен к выводу 8 порта GPIOC    static constexpr Led<GPIOC,8> Led2 ;    // Светодиод Led3 подключен к выводу 9 порта GPIOC    static constexpr Led<GPIOC,9> Led3 ;     static constexpr ButtonController buttonController(&Led1, &Led2, &Led3) ;       buttonController.Run() ;     return 0 ; } ;

Здесь объект buttonController полностью расположился в ПЗУ вместе с массивом указателей на подписчиков:

main::buttonController 0x800’1f04 0x10 Data main.o [1]

Все вроде бы ничего, за исключением того, что мы опять ограничены всего 3 подписчиками. А еще класс издателя должен иметь constexpr конструктор, и вообще быть полностью константным, чтобы гарантированно положить указатель на подписчиков в ПЗУ, иначе даже при известных адресах подписчиков наш объект вместе со всем содержим опять отправится в ОЗУ.

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

Посмотрим, как обстоят дела с памятью в этом решении:

Module ro code ro data rw data
main.o 172 76 0

И хотя здесь результат «ошеломляющий»: общее потребление ОЗУ — 0 байт, а ПЗУ 248 байт, что в два раза меньше, чем в первом решении, чувствуется, что есть еще потенциал для улучшений. Из этих 248 байт примерно байт 50 как раз занимают таблицы виртуальных методов.

Небольшое отступление:
Шаг в размере ПЗУ 256 кБайт у современных микроконтроллеров это норма, (например STM32L451 имеет 256 кБайт ПЗУ, а следующий вариант уже с 512 кБайт). И будет не очень хорошо, когда из-за 50 лишних байт нам придется брать контроллер с ПЗУ на 256 кБайт большего размера и дороже, поэтому отказавшись от виртуальных функций можно сэкономить… целых 50 центов (разница между микроконтроллером в 256 и 512 кБайт ПЗУ составляет около 50-60 центов).

Это звучит смешно для 1 микроконтроллера, но на партии в 400 000 датчиков в год, можно сэкономить 200 000 долларов. Уже не так смешно, а учитывая, что за такое рац. предложение могут наградить грамотой и подарочной картой на 3000 рублей, совсем не остается сомнений в правильности отказа от виртуальных функций и экономии лишних 50 байтов в ПЗУ.

Нетрадиционный подход

Давайте посмотрим, как можно сделать тоже самое без виртуальных функций и сэкономить еще немного ПЗУ.

Вначале прикинем как это может быть:

int main() {    // Светодиод Led1 подключен к выводу 5 порта GPIOC    static Led<GPIOC,5> Led1 ;      // Светодиод Led2 подключен к выводу 8 порта GPIOC    static Led<GPIOC,8> Led2 ;    // Светодиод Led3 подключен к выводу 9 порта GPIOC    static Led<GPIOC,9> Led3 ;    //Светодиоды подписываются на     ButtonController<Led1, Led2, Led3> buttonController ;       buttonController.Run() ;      return 0 ; }

Наша задача развязать два объекта Издатель(ButtonController) и Подписчик(Led) друг от друга, чтобы они знать про друг друга не знали, но при этом ButtonController мог оповестить Led.

Можно объявить класс ButtonController каким-то таким образом.

template <Led<GPIOC,5>& subscriber1,            Led<GPIOC,8>& subscriber2,            Led<GPIOC,9>& subscriber3> struct ButtonController {    void Run() const     {       for(; ;)       {         if (UserButton::IsPressed())         {           Notify() ;         }       }     }      void Notify() const     {       subscriber1.HandleEvent() ;       subscriber2.HandleEvent() ;       subscriber3.HandleEvent() ;     } ... } ;

Но сами понимаете, здесь мы привязываемся к конкретным типам, и нам придется каждый раз в новом проекте переделывать определение класса BbuttonController. А хотелось бы в новом проекте просто взять и использовать ButtonController без заморочек.

На помощь приходит С++17, где можно не указывать тип, а попросить компилятор вывести тип за вас — это как раз то, что надо. Мы можем точно также, как и в традиционном подходе развязать знания об Издателе и Подписчике, при этом количество подписчиков практически не ограничено.

template <auto& ... subscribers> struct ButtonController {    void Run() const   {     for(; ;)     {       if (UserButton::IsPressed())       {         Notify() ;       }     }   }    void Notify() const   {     pass((subscribers.HandleEvent() , true)...) ;   } ... } ;

Вместо ссылок можно использовать указатели:

template <auto* ... subscribers> struct ButtonController {  ... } ;

Архитектурно это выглядит вообще очень просто:

Я тут добавил еще LCD класс, но чисто для примера, чтобы показать, что теперь без разницы на тип и количество подписчиков, главное чтобы у него бы реализован метод HandleEvent().

Да и весь код в общем-то тоже теперь проще:

template<typename Port, std::size_t pinNum> struct Button {   static bool IsPressed()   {     bool result = false;     if ((Port::IDR::Read() & (1 << pinNum)) == 0) //если кнопка нажата     {       while ((Port::IDR::Read() & (1 << pinNum)) == 0) // ждем пока не отожмут       {       };       result = true;     }     return result;   } } ;  // Пользовательская кнопка на порте GPIOC.13 using UserButton = Button<GPIOC, 13> ;  template <typename Port, std::uint32_t pinNum> struct Led                           {    static void Toggle()   {     Port::ODR::Toggle(1<<pinNum);   }    void HandleEvent() const   {     Toggle() ;   } };  template <auto& ... subscribers> struct ButtonController {   void Run() const   {     for(; ;)     {       if (UserButton::IsPressed())       {         Notify() ;       }     }   }    void Notify() const   {     pass((subscribers.HandleEvent() , true)...) ;   }  private:   template<typename... Args>   void pass(Args...)  const   { } } ;  int main() {    // Светодиод Led1 подключен к выводу 5 порта GPIOC    static constexpr Led<GPIOC,5> Led1 ;      // Светодиод Led2 подключен к выводу 8 порта GPIOC    static constexpr Led<GPIOC,8> Led2 ;    // Светодиод Led3 подключен к выводу 9 порта GPIOC    static constexpr Led<GPIOC,9> Led3 ;    static constexpr ButtonController<Led1, Led2, Led3> buttonController ;       buttonController.Run() ;      return 0 ; }

Вызов Notify() в методе Run() вырождается в простой последовательный вызов

Led1.HandleEvent() ;  Led2.HandleEvent() ; Led3.HandleEvent() ;

Как же обстоят дела с памятью здесь?

Module ro code ro data rw data
main.o 186 4 0

ПЗУ всего 190 байт и 0 байт ОЗУ. Вот теперь порядок, это почти в 3 раза меньше по размеру чем стандартный вариант, при этом выполняет он ровно тоже самое.

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

Условия в начале статьи

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

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

Всех с наступающим! И удачи в новом году!

Код с примерами для IAR 8.40.2 лежит тут:


ссылка на оригинал статьи https://habr.com/ru/post/481606/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *