Pull to refresh

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

Reading time 12 min
Views 8.7K


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


В преддверии Нового года хочу продолжить рассказывать про использование С++ на микроконтроллерах, на этот раз попытаюсь рассказать про использование шаблона Наблюдатель (но далее я буду называть его Издатель-Подписчик или просто Подписчик, такой вот каламбур), а также реализацию статической подписки на С++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 кБайт у современных микроконтроллеров это норма, (например Cortex M4 микроконтроллер фирмы TI имеет 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)...) ;
  }
...
} ;

Как работает функция pass(..)

В методе Notify() есть вызов функции pass(), она используется для того, чтобы развернуть параметры шаблона с переменным количеством аргументов


 void Notify() const
  {
    pass((subscribers.HandleEvent() , true)...) ;
  }

Реализация функции pass() проста до невообразимости, это просто функция, принимающая переменное количество аргументов:


template<typename... Args>
  void pass(Args...)  const   { }
} ;

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


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


Строка (subscribers.HandleEvent() , true) использует оператор "," (запятая), который выполняет оба операнда (слева направо) и возвращает значение второго оператора, т.е здесь вначале выполнится subscribers.HandleEvent(), затем true и в функцию pass() будет подставлено true.


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


pass((subscribers.HandleEvent() , true)...) ; ->

pass((Led1.HandleEvent() , true), 
    (Led2.HandleEvent() , true), 
    (Led3.HandleEvent() , true)) ; -> 

Led1.HandleEvent() ; ->
pass(true,  
    (Led2.HandleEvent() , true), 
    (Led3.HandleEvent() , true)) ; -> 

Led2.HandleEvent() ; ->
pass(true,  
     true, 
    (Led3.HandleEvent() , true)) ; -> 

Led3.HandleEvent() ; ->
pass(true,  
     true, 
     true) ; 

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


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

Дополнение: На самом деле, спасибо vamireh, который указал на то, что все эти танцы с бубном pass функцией в С++17 не нужны. Так как оператор "," запятая поддерживается в fold expression (которые были введены в стандарт С++ 17), то код упрощается еще:


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

  void Notify() const
  {
    ((subscribers.HandleEvent()), ...) ;
  }
} ;

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



Я тут добавил еще 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
  {
    ((subscribers.HandleEvent()), ...) ;
  }  
} ;

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 лежит тут


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

Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
+18
Comments 22
Comments Comments 22

Articles