Pull to refresh

Фрэнки

Reading time12 min
Views5.2K

Доброго времени суток!

Меня зовут Александр, я работаю программистом микроконтроллеров, и это история о Фрэнки.

. . .Фрэнки родился чуднЫм. Родителями были пионэрский задор вашего автора и требования заказчика.

Когда младенца скомпилировали и по его венам потекли животворные байты, мой коллега процедил:

- Вы слепили монстра, герр Франкейштейн, но он не лишен некоторого очарования. . .

В то время я писал прошивку для станка с ЧПУ, причем заказчик наложил ограничения - строго Си, никаких сторонних библиотек, допускался только HAL с открытыми исходниками от производителя МК.

Был предоставлен забугорный образец станка, который мы должны были воспроизвести на собственных схемотехнике и ПО.

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

Ну хорошо, а какой тогда должна быть архитектура приложения, когда нет итогового ТЗ и при этом низзя(!) ни в какие сторонние РТОСы и диспетчеры? Как выстроить программу, чтобы не перелопачивать все исходники, если вдруг осознаешь в середине проекта, что логику нужно править?

Напрашивался принцип конструктора Лего. Детальки разных размеров и цветов с унифицированным креплением дают большое разнообразие форм и неограниченные перспективы расширения.

Объединив эту аналогию с возможностью корректно декомпозировать workflow станка на набор состояний, я сформулировал следующие принципы будущей программы:

  1. каждое состояние (стейт) - одна деталька Лего

  2. связь между стейтами через единый интерфейс

  3. что именно делает стейт должно быть понятно с первого взгляда

Пункт 3 требует детализации:

3.1. логика внутри стейтов будет описана независимыми модулями

3.2. модули должны быть самодокументированы

3.3. один модуль - одна строка кода

3.4. модули будут подписываться на уведомления от внешнего окружения и внутри стейта

3.5. модули общаются между собой и внешним окружением через message box

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

В итоге получился некий компактный фреймворк (помпезно, конечно, но человек слаб), удобный для разработки и отладки устройства в условиях, когда в силу ряда причин с его оптимальным дизайном нельзя определиться на берегу. Ну а с подачи моего ехидного коллеги мы обозвали Фрэнки.

Станок мы в итоге разработали, оттестировали и сдали. Пока, тьфу-тьфу, фурычит без нареканий.

На днях, поддавшись светлой ностальгии, я переписал Фрэнки на С++, и хотел бы поделиться результатом с хабросообществом. Намерения при этом у меня самые корыстные - из возможной обратной связи узнать что-то новое о себе о нашем ремесле. Начнем.

Плюсы настолько могучи, что весь бывший сишный код проекта я уместил всего в две строчки:

int main (void){
  
  //some vendor's HAL stuff
  
  Device device;
  
  while(1){
    
    device.action();
  
  }
}

Эх, с девочкой продакт-менеджером это бы прокатило, но вас-то не проведешь.

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

class StateSlow final: public StateInterface<StateSlow> {

private:

	void on_enter_impl (void) { 
    // some state's specific code
  }

	void tracks_go_impl (void) {

		Track< topic::no, 500, restart_blink_slow >::go(topic::green);

		Track< topic::green, 250, toggle_green >::go(topic::yellow);
		Track< topic::yellow, 250, toggle_yellow >::go(topic::red);
		Track< topic::red, 250, toggle_red >::go(topic::green);

		Track< topic::no, 5000, stop_blink >::go(topic::exit);

		Track< topic::exit, 0, breaker >::go(msg::exit);
	}

	States_e on_exit_impl (void) {

		States_e next_state = States_e::SF;

		if ( DeviceHandler::get_failed_track() ) {

			next_state = States_e::ES;

		} else if ( Post::get_msg(msg::exit).confirmed ){

			next_state = States_e::SS;
		}

		if (next_state != States_e::SF){

			DeviceHandler::set_state_status(status_e::stopped);
		}

		return next_state;
	}

	friend class StateInterface<StateSlow>;
};

using SS = StateInterface<StateSlow>;

Этот пример взят из тестового проекта, ссылку на который вы найдете в конце статьи.

Здесь стейт - это класс StateSlow, который наследует единый интерфейс от класса StateInterface. Для реализации статического полиморфизма применен паттерн CRTP, но об этом чуть позже.

В интерфейсе всего три метода:

  • on_enter_impl(). В нем при первом заходе в стейт выполняется работа, специфичная для конкретного стейта.

  • tracks_go_impl(). Здесь поочередно вызываются модули/Tracks, собственно и реализующие функционал стейта. У модулей есть общие черты с т.н. сопрограммами/корутинами. Но так как все удобные подходящие имена уже заняты (coroutines, fibers, threads), я окрестил их tracks в значении путь/тропа/курс. Track вызывается или по подписке, или по таймеру, или по обоим вместе; может быть вызван единожды или многократно. В качестве полезной нагрузки принимает в себя указатель на функцию с произвольным количеством аргументов.

  • one_exit_impl(). В этом методе мы разбираем результаты работы стейта, возникшие ошибки и по итогам определяем в какой стейт прыгаем дальше. Если работа стейта пока не окончена и все штатно, остаемся в текущем стейте. Конкретно в приведенном выше фрагменте кода, мы последовательно проверяем два условия:

  • есть ли "упавшие" track-и, и при наличии таковых переходим в ErrorState.

  • получено ли сообщение о выходе из стейта, и если да, переходим в следующий стейт. В противном случае остаемся в текущем.

Итак, что же конкретно происходит в приведенном стейте? Читаем сверху вниз.

Метод on_enter_impl() пуст - никакой специфичной работы при каждом входе в стейт выполнять не нужно.

В tracks_go_impl() выполняются 6 треков. Первый не подписан ни на какое уведомление (topic::no), запустится по таймеру через 500мс с момента первого входа в стейт и выполнит переданную параметром шаблона функцию restart_blink_slow(). Последняя принимает в качестве аргумента уведомление topic::green и при своем вызове отправляет его подписчику. По завершении restart_blink_slow() возвращает код ret_e::оk, отключая свой трек.

Второй трек подписан на уведомление topic::green, при его получении он запустится по таймеру через 250мс и выполнит переданную ему функцию toggle_green(), передав ей как аргумент уведомление topic::yellow. Функция toggle_green() выполнит свою работу, вышлет подписчикам уведомление topic::yellow и вернет код ret_e::repeat, перезапустив свой трек.

Функции, передаваемые в треки могут принимать произвольное количество аргументов, и по соглашению фреймворка должны возвращать один из трех кодов. Код ret_e::repeat перезапускает трек; код ret_e::ok отключает трек; код ret_e::err отключает трек и сохраняет в классе DeviceHandler (о нем чуть позже) номер "упавшего" трека для последующей обработки ошибок в методе on_exit_impl().

Оставшиеся треки считываем аналогично, а метод one_exit_impl() мы уже рассмотрели выше.

Ну и в конце мы назначаем короткий alias для имени стейта, что пригодится нам позднее.

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

Начнем с основного фрагмента ДНК Фрэнки - с состояния (стейта).

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

С++ помогает писать типобезопасный код, поэтому мы опишем стейт как класс. У стейтов должен быть единый интерфейс, поэтому заведем базовый тип, от которого унаследуем рабочие стейты.

Виртуальный интерфейс нам, как разработчикам встроенного ПО, не очень подходит, ибо vtable откусывает память, а сам виртуальный вызов стоит дорого. Поэтому воспользуемся комбинацией паттернов "CRTP" и "Шаблонный метод" (здесь и далее по тексту за разъяснением используемых техник направляю к этому автору). Еще раз взглянем, как рабочий стейт наследует базовому:

class StateSlow final: public StateInterface<StateSlow>{...}

Класс StateSlow наследует шаблонному классу StateInterface, параметризованному StateSlow. Этот рекурсивный трюк, определяющий саму природу паттерна CRTP, позволяет базовому классу еще на этапе компиляции получить информацию о классе-наследнике. Что дает нам возможность вызывать методы производного класса из объекта базового без дополнительных накладных расходов (пруф). Посмотрим на реализацию базового класса:

Class StateInterface
template <typename T>
class StateInterface{

public:
  
  using derived_t = T;
	using derived_ptr = T*;

	StateInterface(){}

	States_e operator() (void){

		on_enter();

		tracks_go();

		return on_exit();
	};

private:

	derived_ptr derived(void){
		return static_cast<derived_ptr>(this);
	}

	void on_enter_impl (void){}

	void tracks_go_impl (void){}

	void on_enter_base (void){
		derived()->on_enter_impl();
	}

	States_e on_exit_base (void) {

		return derived()->on_exit_impl();
	}

	void on_enter (void) {


		if (DeviceHandler::get_state_status() == status_e::stopped){


			Publisher::clear_box();

			Post::clear_box();

			DeviceHandler::clear_state_param();

			DeviceHandler::set_state_status(status_e::active)ж
        
      derived()->on_enter_base();
		}
	}

	void tracks_go (void) {
		derived()->tracks_go_impl();
	}

	States_e on_exit (void) {

		DeviceHandler::reset_track_cnt();

		return derived()->on_exit_base();
	}
};

Это шаблонный класс, он параметризуется типом рабочего стейта. Его интерфейс определен сигнатурой operator(), и он нам уже знаком:

метод on_enter() содержит обязательную логику для всех стейтов, а также вызывает метод унаследованного стейта derived()->on_enter_base().

метод on_exit() также содержит логику, общую для всех стейтов, и вызывает метод унаследованного стейта derived()->on_exit_base().

метод tracks_go() подменяется вызовом метода derived()->tracks_go_impl() в котором содержится логика стейта, выраженная модулями/track-ами (см. выше).

Метод on_exit() возвращает тип перечисления States_e, по значению которого будет осуществляться переключение между стейтами:

#define STATES	SF,SS,ES
enum class States_e : base_t{
  STATES
};

Единственный дефайн кода фреймворка содержит список названий типов стейтов. Дефайны в С++ стараются не использовать, но в данном случае он оправдан, так как сокращает точки настройки проекта. Второй раз дефайн STATES используется в конструкторе класса Device, о чем чуть позже.

В приватном сегменте класса StateInterface требуют особых пояснений два метода-пустышки:

void on_enter_impl (void){}
void tracks_go_impl (void){}

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

Общая обязательная логика при входе в стейт, реализованная в методе on_enter() самодокументирована и понятна.

Класс DeviceHandler предоставляет доступ к общим параметрам стейтов и треков. Класс Publisher ответственен за систему уведомлений. Класс Post процессирует систему сообщений. Сообщения, в отличие от уведомлений, могут нести payload - указатели на данные.

Эти три класса объединяет их дизайн - они реализованы через паттерн static Singleton. Действительно, мы пишем встроенное ПО, которое будет работать в одном конкретном устройстве, поэтому очевидно, что доступ к функционалу этих классов должен следовать принципу "одного окна".

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

Для примера приведу реализацию класса Post:

Class Post
// in message.hpp

typedef struct{
	void* body{nullptr};
	bool confirmed{false};
}MessageParam;

using post_t = std::array<MessageParam, MAX_MESSAGE_NUM>;

class Post{
public:
  template<typename T>
  static void push_msg (msg m, T* data);
  static void push_msg (msg m);
  static MessageParam& get_msg (msg m);
  static void clear_box(void;

private:
  Post() = delete;
	Post(const Post&)= delete;
	Post& operator=(const Post&)= delete;

	static post_t mbox;
	static MessageParam res;
};

// in message.cpp

post_t Post::mbox;
MessageParam Post::res;             

Отправка сообщения с данными может выглядеть примерно так:

void some_func (void){
  
// some code
  
  static UserType val{15, 20};
  
  Post::push_message(msg, &val);
}

Рассмотрим далее класс Track:

Class Track
template<topic N, base_t T, auto* Func>
struct Track{

	static_assert( !std::is_null_pointer_v<decltype(Func)> );

	template<typename... Args>
	static void go (Args... args){
		
    bm_clock::duration dur;

		TrackParam& p = DeviceHandler::get_track_param();

 		switch (p.entry){
    //track logic
    }
	}
};

Track параметризуется типом уведомлений, значением таймера и указателем на функцию. Класс имеет шаблонный метод go(), принимающий произвольное количество аргументов и в котором реализована несложная работа с таймером и сервисом уведомлений, а также в расчетный момент времени вызывается переданный функтор. Полное описание класса вы найдете в проекте по ссылке в конце статьи.

Так как мы пишем в плюсовом окружении, для работы с таймером сподручно использовать инструментарий стандартной библиотеки <chrono>. Для этого нам необходимо переопределить стандартную функцию now(), возвращающую локальное время вашей системы. Возможную реализацию этого приема вы найдете в хедере bm_chrono.hpp.

Итак, к этому моменту нам понятна основная идея фреймворка; мы рассмотрели реализацию стейта и треков; выяснили, что взаимодействие между сущностями фреймворка и внешней средой осуществляется через сервисы уведомлений и сообщений.

Осталось прояснить, кто и как жонглирует состояниями.

Class Device
class DeviceImpl;

class Device{

public:
	Device();
	void action(void);

private:
	Device(const Device&)= delete;
	Device& operator=(const Device&)= delete;

	static DeviceImpl& impl(void);
	DeviceImpl& impl_;
};

Класс Device реализован через паттерн Singleton в варианте Мейерса в комбинации с идиомой pimpl. Такой подход позволяет подчеркнуть разделение интерфейса класса от его реализации. Весь полезный функционал скрыт в классе DeviceImpl.

Class DeviceImpl
class DeviceImpl{

public:

	using state_p = type_pack<STATES>;
	using state_v = std::variant<STATES>;
	using state_a = std::array<state_v, size(state_p{})>;

	constexpr DeviceImpl() : idx(0) {
    set(state_p{});
  }

private:

	template <class... Ts>
	constexpr void set (type_pack<Ts...>){

		(set_impl(just_type<Ts>{}), ...);
	};

	template <typename T>
	constexpr void set_impl (just_type<T> t){

		using state_t = typename decltype(t)::type;

		std::size_t i = find(state_p{}, t);

		states[i].emplace<state_t>(state_t{});
	}
public:
	state_a states;
	base_t idx;
};

Класс DeviceImpl преимущественно в компайл тайм конструирует и сохраняет контейнер состояний(стейтов), переданных фреймворку. С помощью уже знакомого дефайна STATES мы выводим и алиасим необходимые типы. state_p - это просто пак типов стейтов. state_v - объект этого типа будет содержать конкретный стейт. state_a - тип контейнера стейтов.

В конструкторе DeviceImpl с помощью методов set() и set_impl() мы создаем объекты состояний и размещаем их в контейнере. Необходимые для этого размер контейнера и индексы мы вычисляем в компайл тайм с помощью constexpr функций size() и find(). Подробнее о них и других современных способах работы со списками типов рассказано здесь.

Вернемся к классу Device. В его конструкторе мы создаем объект DeviceImpl и сохраняем ссылку на него:

Device::Device() : impl_( impl() ){}

DeviceImpl& Device::impl(void){

	static DeviceImpl instance;
	return instance;
}

Переключение состояний реализовано в единственном методе класса Device - action().

void Device::action(void){

	base_t temp_idx;

	auto l = [&temp_idx](auto&& arg){
    temp_idx = static_cast<base_t>( arg() );
  };

	std::visit(l, impl_.states[impl_.idx]);

	impl_.idx = temp_idx;
}

В нем мы создаем объект лямбды, выполняющей единственное действие - вызов оператора () у текущего стейта и получение кода возврата, который будет в качестве индекса контейнера указывать на ячейку со следующим стейтом. Передав в качестве параметра сконструированный объект лямбды в std::visit, мы посетим объект std::variant с текущим стейтом и сохраним код возврата в виде индекса.

Что ж, с теорией покончено, переходим к практике. Как пользоваться Фрэнки? Совсем несложно.

В заголовочнике defs.hpp алиасите базовый тип своей платформы, задаете максимальные количества треков на один стейт, и сообщений/уведомлений - на весь проект. Там же, в определениях классов topic и msg в ходе работы над вашим проектом перечисляете нужные вам сообщения и уведомления. Выглядеть это будет примерно так:

defs.hpp
using base_t = std::uint32_t;

constexpr base_t MAX_SUBSCRIPTION_NUM = 8;
constexpr base_t MAX_MESSAGE_NUM = 8;
constexpr base_t MAX_TRACK_NUM = 8;

enum class topic: base_t{
	no,
	green,
	yellow,
	red,
	state,
};

enum class msg: base_t{
	no,
	stop,
	exit,
  test
};

Далее по примерам из статьи определяете классы своих рабочих стейтов, создаете им удобные псевдонимы и перечисляете их в дефайне STATES там же, в defs.hpp. Подключаете заголовочники получившихся стейтов в файл frankie.cpp. Ну и росчерком мастера вносите в main те самые две строчки, с которых мы начали.

Уфф, Уроборос наконец-то укусил свой хвост.

Если заинтересовались, забирайте Фрэнки здесь, не обижайте его. Там же выложен пример для отладки NUCLEO- H743ZI2 (-std=gnu++17, -O3, программа грузится из RAM). Весит проект с этими настройками примерно 12Kb, сам фреймворк - 5,5Kb.

В примере три стейта: быстрая змейкая, медленная змейка и аварийный стейт. Стартуем с быстрой змейкой, затем переключаемся на медленную. В стейте быстрой змейки первый трек веерно рассылает уведомления трекам с моргалками, в медленной - треки моргалок подписаны друг на друга. Медленная змейка завершается с ошибкой и происходит переход в аварийный стейт с красной моргалкой. И все по новой.

Берем отладочную плату, шьем и выпускаем magic smoke запускаем пример.

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

В то же время нужно учитывать, что это решение для soft real-time систем. Действительно, стейты и треки выполняются последовательно, время их реакции зависит о переданных функций. Если же в вашем проекте есть условия, при которых требуется быстрая реакция системы за константное время, Фрэнки можно прокачать context switcher-ом, например, как показал в своей статье уважаемый @lamerok.

На этом все, спасибо за внимание!

Tags:
Hubs:
Total votes 12: ↑10 and ↓2+13
Comments13

Articles