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