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

kqjoqxpr1itcdx-_kq4luowkpqe.jpeg

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

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


Введение

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

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


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

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


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

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


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

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

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

zu8gro30gxgngc8w36oesxy9w2i.png

Здесь 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 
struct Led: ISubscriber                          
{
  static void Toggle()
  {
    Port::ODR::Toggle(1 << pinNum);
  }

  void HandleEvent() override
  {
    //Собственно это то, ради чего все затевалось, моргнуть
    Toggle() ; 
  }
};


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

template
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 ;

struct ISubscriber
{
  virtual void HandleEvent() = 0;
} ;

struct IPublisher
{
  virtual void Notify() const = 0;
  virtual void Subscribe(ISubscriber* subscriber) = 0;
} ;

template 
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 pSubscribers ;
  std::size_t index = 0U ;
} ;

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


int main()
{
  // Светодиод Led1 подключен к выводу 5 порта GPIOC
  static Led Led1 ;  
  // Светодиод Led2 подключен к выводу 8 порта GPIOC
  static Led Led2 ;
  // Светодиод Led3 подключен к выводу 9 порта GPIOC
  static Led Led3 ;

  ButtonController buttonController ;

  // Подписываем 3 светодиода
  buttonController.Subscribe(&Led1) ;
  buttonController.Subscribe(&Led2) ;
  buttonController.Subscribe(&Led3) ;

  // Запускаем контроллер на вечный опрос кнопки
  buttonController.Run() ;
}

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

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

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

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

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

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


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

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


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


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

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

lr_kgyobfgkebxpz9gfyqiod34u.png

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

struct ButtonController : IPublisher
{
  template
  constexpr ButtonController(Args const*... args): pSubscribers()
  {
    std::initializer_list result = {args...} ;
    std::size_t index = 0U;

    for(auto it: result)
    {
      if (index < size)
      {
        pSubscribers[index] = const_cast(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
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 
struct Led: ISubscriber                          
{
  constexpr Led()
  {
  }

  static void Toggle()
  {
    Port::ODR::Toggle(1< ;

struct ButtonController : IPublisher
{
  template
  constexpr ButtonController(Args const*... args): pSubscribers()
  {
    std::initializer_list result = {args...} ;
    std::size_t index = 0U;

    for(auto it: result)
    {
      if (index < size)
      {
        pSubscribers[index] = const_cast(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 Led1 ;  
   // Светодиод Led2 подключен к выводу 8 порта GPIOC
   static constexpr Led Led2 ;
   // Светодиод Led3 подключен к выводу 9 порта GPIOC
   static constexpr Led Led3 ;

   static constexpr ButtonController buttonController(&Led1, &Led2, &Led3) ;  

   buttonController.Run() ;

   return 0 ;
} ;

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


main: buttonController 0×800'1f04 0×10 Data main.o [1]

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

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

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

И хотя здесь результат «ошеломляющий»: общее потребление ОЗУ — 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 Led1 ;  
   // Светодиод Led2 подключен к выводу 8 порта GPIOC
   static Led Led2 ;
   // Светодиод Led3 подключен к выводу 9 порта GPIOC
   static Led Led3 ;
   //Светодиоды подписываются на 
   ButtonController buttonController ;  

   buttonController.Run() ;  

  return 0 ;
}

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

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

template & subscriber1, 
          Led& subscriber2, 
          Led& subscriber3>
struct ButtonController
{ 
  void Run() const
    {
      for(; ;)
      {
        if (UserButton::IsPressed())
        {
          Notify() ;
        }
      }
    }

    void Notify() const
    {
      subscriber1.HandleEvent() ;
      subscriber2.HandleEvent() ;
      subscriber3.HandleEvent() ;
    }
...
} ;

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

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

template 
struct ButtonController
{ 
  void Run() const
  {
    for(; ;)
    {
      if (UserButton::IsPressed())
      {
        Notify() ;
      }
    }
  }

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

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

template 
struct ButtonController
{ 
...
} ;

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

hxdijj1xdfvncxtdsr04a1qp_ve.png

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

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

template
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 ;

template 
struct Led                          
{

  static void Toggle()
  {
    Port::ODR::Toggle(1<
struct ButtonController
{
  void Run() const
  {
    for(; ;)
    {
      if (UserButton::IsPressed())
      {
        Notify() ;
      }
    }
  }

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

private:
  template
  void pass(Args...)  const   { }
} ;

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

   buttonController.Run() ;  
   return 0 ;
}

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

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

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

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

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


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

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

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

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

© Habrahabr.ru