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

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

И ваш автор не исключение.

Как по мне - дело не то чтобы запредельно сложное, сколько кропотливое. Если у вас, как и у меня, увлечение или карьера крутится вокруг Arm Cortex-M серии, то вооружаемся стволами (раз, два и три) и выдвигаемся за Джеффом.

Но, написав и запустив ядро своей "best of the best" оси около года назад, я вскоре забросил разработку. Ибо как я ни креативил, вместо Сокола Тысячелетия у меня получался крепенький, но банальный и скучный велосипед.

А ведь хотелось оригинальности и бесстыдного выпендрёжа.

И тут в 20-й стандарт завезли корутины.

Вот это вот всё:

#include <coroutine>

Coro task(){
  
  foo();
  
  co_await awaitable_1();
  
  bar();
  
  auto res = co_await awaitable_2();
  
  func(res); 
}

Тут ваша чуйка эмбеддера должна триггернуть: "А если то же самое, но с перламутровыми пуговицами?" :

#include <coroutine>

Coro task1() {

  while (true) {
    // ожидаем некое событие
    co_await event.get();
    // по его наступлению блинкаем
    toggle_led();
    // запускаем таймер на 250 мс/с/мин и ждем
    co_await timer.get(250);
    // по истечению времени задержки снова блинкаем
    toggle_led();
    // и все по новой 
  }
}

Действительно, получилась сигнатура типичной задачи в РТОС. Причем в случае с корутинами компилятор возьмет на себя расчет требуемой памяти под задачу. Вероятно, эти данные будет несложно получить и учесть. Нам останется только контролировать объем памяти, выделенной суммарно под все задачи. Уже неплохо.

Фантазируем дальше. Будет удобно, если оператор co_await сможет выступить единым окном обмена данными между корутиной, диспетчером и примитивами синхронизации (эвенты, мьютексы, таймеры, очереди etc.). Тогда мы сможем выиграть в композиции и читабельности кода.

Хорошо, а что можно сделать с приоритетами задач? А можно дерзнуть, вывернуть все наизнанку и внезапно получить задачу с динамическим приоритетом времени выполнения в зависимости от ожидаемого события :

#include <coroutine>

Coro task2(){
  
  while(true){
    // ждем сигнала от очереди с нормальным приоритетом
    co_await queue.get<CoPrio::normal>();
    // выгружаем значение в режиме нормального приоритета
    auto payload = queue.unload();
    // пробуем захватить мютекс. Если успешно, то продолжаем
    // выполнение сразу. Если нет - ждем его освобождения 
    co_await mutex.get<CoPrio::low>();
    // работаем с неким общим ресурсом в режиме низкого приоритета
    shared_bus_send(res);
    // свобождаем мьютекс
    mutex.give();
    // ждем событие с высоким приоритетом
    co_await event.get<CoPrio::high>();
    // выполняем срочную работу в режиме высокого приоритета
    very_urgent_func();
  }
}

Выглядит заманчиво.

Останется навесить сюда диспетчер, жонглирующий нашими корутинами, плюс context switcher, и может получиться нечто любопытное. Похоже, у нас есть материал, с которым интересно поработать. Ну и хайпануть на горяченькой еще теме корутин - как же без этого :)

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

  • я предполагаю, что вы более-менее знакомы с инструментарием корутин, предоставляемым языком на данный момент. Если необходимо освежить представления, рекомендую к прочтению отличную статью. Также забуриться в тему поглубже можно здесь.

  • чтобы упростить восприятие примеров с кодом и сэкономить вам время прочтения, я буду опускать квалификаторы и ключевые слова из описаний методов классов/функций. Ссылки на рабочую реализацию я дам в конце статьи.

  • важным элементом статьи являются комментарии в примерах кода.

Далее - вдумчивый лонгрид. Все-таки ось пишем, а не моргалку ардуиновскую.

Перво-наперво нам понадобится некий синхро-объект, с помощью которого мы будем обмениваться данными между корутиной и внешним миром. Определим его:

#include "co_types.hpp"

struct CoSync{
  co_mutex_t mutex;  // объект с параметрами мьютекса
  								   // рассмотрим его подробнее в разделе о CoMutex
  void* co_addr;     // адрес coroutine_handle
  CoState state;     // состояние корутины (выполняется, приостановлена etc.)
  CoPrio prio;       // приоритет
  base_t id;         // уникальный идентификатор
  base_t size;       // размер выделенной для корутины памяти
  co_act_t expected; // ожидаемое корутиной событие
};

// также зададим алиас на указатель объекта CoSync
using co_sync_t = CoSync*;

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

Тогда promise_type корутины может быть определен следующим образом. Опять же, для упрощения чтения я приведу только методы, содержащие логику нашей программы. Минимальный требуемый стандартом набор методов объекта Promise всегда можно посмотреть здесь.

#include <coroutine>
#include <limits>

#include "co_proxy.hpp"
#include "co_alloc.hpp"

struct Coro {
  
  using promise_type = Coro;
  
  CoSync sync {
    .mutex{.ptr = nullptr, .is_taken = false},
    .co_addr{nullptr},
    .state{CoState::stopped},
    .prio{CoPrio::lowest},
    .id{ indexer_t{}() }, // присваиваем уникальный id
    // в момент создания корутины
    .size{0},
    .expected{std::numeric_limits<co_act_t>::max()},
  };
  
  auto get_return_object() { return Coro{}; }
  
  std::suspend_never initial_suspend() {
    // корутина создана,
    // меняем состояние на "готова"
    sync.state = CoState::ready;
    // сохраняем размер выделенной корутине памяти
    sync.size = CoAlloc::get_current_size();
    return {};
  }
  
  template<co_act_t ID, CoPrio P>
  auto yield_value ( co_proxy_t<ID, P> p) {
    
    struct Awaitable{ /* см. определение ниже */ };
    
    return Awaitable{p};
	}
  
  template<co_act_t ID, CoPrio P>
  auto await_transform(co_proxy_t<ID, P> p) {
    return yield_value<ID, P>(p);
  }
  
  void* operator new(std::size_t sz){
    // переопределяем для корутины стандартный
    // оператор new, стобы аллоцировать память
    // кастомным аллокатором. Как и где мы хотим.
    return CoAlloc::allocate(sz);
	}

	void operator delete( void* p){
    CoAlloc::deallocate(p);
  }
};

Пробежимся сверху вниз и разберем новые типы и функции, встретившиеся в promise_type.

В момент создания корутины мы присваиваем ей идентификатор. Это просто число от 0 до суммарного количества задач, запущенных в программе. Его основное назначение - служить индексом массива, в котором будут храниться указатели на синхро-объекты CoSync. Индексируем мы корутины объектом типа indexer_t :

using indexer_t = decltype( []{ static base_t i; return ++i - 1; } );

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

Структура Awaitable:

#include <coroutine>

struct Awaitable{
  // объект proxy при инстанцировании Awaitable в
  // методе yield_value сохранит значение аргумента p,
  // переданного ему примитивом синхронизации (эвент, очередь etc.).
  // proxy - легковесный объект шаблонного типа co_proxy_t(см. ниже), 
  // параметризованный индексом ожидаемого события и приоритетом
  // он служит каналом передачи инфо между объектом синхронизации
  // и корутиной.
  co_proxy_t<ID, P> proxy;
  
  // проверяем в объекте proxy параметры мьютекса.
  // по результатам приостанавливаем корутину или продолжаем
  // выполнение текущей задачи
  bool await_ready () {
    // если мьютекс вообще не захватывался - по умолчанию
    // приостанавливаемся.
    if (not proxy.mutex.ptr) return false;
    
    // иначе возвращаем флаг захвата мьютекса и действуем по
    // его значению
    return proxy.mutex.is_taken;
  }
  
  void await_suspend (std::coroutine_handle<promise_type> coro) {
    // получаем адрес поля sync из объекта promise корутины 
    co_sync_t sync = &coro.promise().sync;
    
    // при первом вызове co_await сохраняем указатель
    // на sync в диспетчере. co_proxy_t знает о типе
    // диспетчера, поэтому имеет доступ к его статическим
    // методам
    if (CoState::ready == sync->state)
                    decltype(proxy)::store_sync(sync);
    
    // последовательно сохраняем в sync: адрес cooutine_handle,
    // новые параметры мьютекса, новые приоритет и ожидаемое событие.
    // также меняем состояние корутины на приостановленное 
    sync->co_addr = coro.address();
    sync->mutex = proxy.mutex;
    sync->state = CoState::suspended;
    sync->prio = P;
    sync->expected = ID;
  }
  
  // в данной версии оси я пока не решил, что можно и нужно
  // возвращать через оператор co_yield, поэтому возвращаем пока 0
  auto await_resume () { return 0; }
};

Как известно, корутины динамически аллоцируют память в куче. Для встроенных решений слово "куча" почти ругательство. Хороший разработчик встроенного ПО, как правило, сам планирует кому, где и сколько выделить памяти. Мы хотим быть хорошими, поэтому реализуем собственный аллокатор. Воспользуемся готовым инструментом из стандарта и заюзаем std::pmr::monotonic_buffer_resource. Он быстрый, принимает в конструкторе указатель на определенный нами фрагмент памяти и имеет необходимые методы (de)allocate() :

// in co_alloc.cpp

#include <memory_resource>
#include "co_alloc.hpp"

byte_t raw_buf[CoParam::CORO_STORAGE_SIZE];

std::pmr::monotonic_buffer_resource mbr{raw_buf, CoParam::CORO_STORAGE_SIZE};

// переменная current_size содержит кэшированное значение
// кол-ва байт последней аллокации.
// max_size - суммарный размер памяти, аллоцированной всеми
// корутинами в программе
std::size_t current_size, max_size;

Реализация CoAlloc тривиальна(второстепенные детали опущены):

// in co_alloc.hpp

struct CoAlloc{
  static void* allocate (std::size_t size);
  static void deallocate (void *p);
  static std::size_t get_current_size();
  static std::size_t check_memory();
};

// in co_alloc.cpp

void* CoAlloc::allocate(std::size_t size){
  current_size = size;
  max_size += size;
  return mbr.allocate(size);
}

void CoAlloc::deallocate([[maybe_unused]] void *p){
  // метод release() класса monotonic_buffer_resource
  // высвобождает сразу всю аллоцированную объектом mbr
  // память. Но так как наши задачи крутятся
  // в infinite loop, мы вообще не должны сюда попасть.
  // но если попали, то значит сушим весла.
  mbr.release();
  std::abort();
}

std::size_t CoAlloc::get_current_size(){
  return current_size;
}

std::size_t CoAlloc::check_memory(){
  return max_size;
}

Теперь рассмотрим подробнее механизм взаимодействия корутины с объектами синхронизации.

Точка контакта корутины и события(или таймера, очереди, мьютекса etc.) - это вызов оператора co_await. Через него объект синхронизации передает уже знакомый нам аргумент типа co_proxy_t. Это алиас на следующую структуру:

// in co_proxy.hpp

template<co_act_t ID, CoPrio P>
struct CoProxyData final : public CoManager {
  co_mutex_t mutex{
    .ptr = nullptr,
    .is_taken = false
    };
};

template<co_act_t ID, CoPrio P>
using co_proxy_t = CoProxyData<ID, P>;

Как и указывалось ранее, тип co_proxy_t аккумулирует знание о типе диспетчера (структура CoManager, о ней чуть позже), параметрах мьютекса, а также идентификаторе события (шаблонный параметр А) и приоритете (шаблонный параметр P). Но как может выглядить единый интерфейс для всех примитивов синхронизации?

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

// in co_proxy.hpp

#include "co_util.hpp"
#include "co_manager.hpp"

template<typename T>
class CoProxy : public CoManager {
  
  public:
  using derived_ptr = T*; // алиас указателя на наследуемый класс
  
  // метод give() будет передавать в диспетчер идентификатор
  // наступившего события. метод обеспечивает двусторонний 
  // канал связи - при необходимости мы передадим через pack
  // аргументов необходимые данные источнику события. Пример увидим
  // в реализации таймера
  template<typename ...Args>
	void give(Args&& ...args) {
    
    // получаем от класса-наследника id события
    co_act_t action = 
      derived()->give_impl(co_detail::forward<Args>(args) ...);
    // обрабатываем его в методе диспетчера
      set_action(action);
	}
  
  // метод get() формирует из данных контекста и сведений 
  // объекта синхронизации объект типа co_proxy_t,
  // передаваемый корутине при каждом вызове оператора co_await.
  template<CoPrio P, typename ...Args>
  auto get (Args&& ...args) {
    return derived()->template get_impl<P>(co_detail::forward<Args>(args) ...);
	}
  // метод ready() сигнализирует о готовности объекта
  // синхронизации к определенному действию.
  // Пример увидим в реализации очереди. 
  bool ready() {
    return derived()->ready_impl();
	}
  
  // если объект синхронизации несет полезную нагрузку,
  // выгружаем ее методом unload(). Смотрим в примере очереди ниже.
  auto unload() {
    return derived()->unload_impl();
	}

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

Здесь вы наверняка обратили на конструкцию co_detail::forward<Args>(args). Действительно, пока наша ОС в зачаточном состянии, мы не знаем всех направлений ее развития. Поэтому разумно на этом этапе заложить в ключевом интерфейсе максимум вариативности. Исполним это через инструментарий шаблонов и perfect forwarding. Ну а чтобы не инклюдить сквозь весь проект нехилый такой хедер <utility>, я определил move(), forward() в компактном заголовочнике"co_util.hpp", в нэймспейсе co_detail, благо их реализации рассмотрены во многих источниках(пример).

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

Настало время разработать примитивы синхронизации. Начнем с очереди. Класс CoQueue может быть определен так:

// in "co_queue.hpp"

#include "co_variant.hpp"
#include "co_queue_impl.hpp"
#include "co_proxy.hpp"

template<co_act_t A>
class CoQueue final: public CoProxy<CoQueue<A>>{
  
  public:
  // шаблонный класс CoQueueImpl реализует собственно логику
  // очереди. Параметризуем его типом полезной нагрузки
  // размером и типом отвечающим за атомарность операций
  using co_queue_t = 
    CoQueueImpl<co_payload_t, CoParam::CORO_QUEUE_SIZE, co_critical_t>;
  
  // в методе give_impl() помещаем данные в очередь и
  // возвращаем id данной конкретной очереди. 
  // как помните, в методе give() базового класса этот
  // id передается диспетчеру для сигнала возобновления
  // целевой корутины
  co_act_t give_impl(const co_payload_t& payload) {
    
    instance().push(payload);
    return A;
  }
  
  // конструируем и передаем корутине сведения о
  // событии, приоритете, параметрах мьютекса (по умолчанию - пустые)
  // и типе диспетчера
  template<CoPrio P>
  co_proxy_t<A, P> get_impl() { return {}; }
  
  // проверяем, содержит ли очередь данные
  bool ready_impl() {
    return !instance().is_empty();
  }
  
  // выгружаем очередь
  co_payload_t unload_impl() {
    return instance().pop();
  }
  
  private:
  // приватным методом instance() при первом конструировании
  // объекта co_queue создаем статический объект queue_impl
  // и возвращаем ссылку на него при всех последующих операциях.
  [[gnu::always_inline]] co_queue_t& instance() {
    static  co_queue_t queue_impl;
    return queue_impl;
  }
};

// дефайн упрощащющий задание пользовательских типов очередей
#define CO_QUEUE(q)   using q = CoProxy<CoQueue<__COUNTER__>>

/*  USER SECTION START  */
// в пользовательской секции задаем типы очередей и 
// и далее инстанцируем и пользуем где необходимо. При этом 
// каждый вновь созданный объект этого же типа будет
// помнить историю операций с ним.
CO_QUEUE(spi_queue_t);
CO_QUEUE(uart_queue_t);
/*  USER SECTION end  */

С классом CoQueueImpl я вас ничем не удивлю, его реализация на данном этапе разработки ОС элементарна:

// in "co_queue_impl.hpp"

#include "critical_section.hpp"
#include "co_types.hpp"

template<typename P, CoParam D, typename CS>
class CoQueueImpl{

public:
  
	void push (const P& payload) {
  	CS critical_section;
  
  	queue[head] = payload;
  
  	++head;
  
  	if (D == head) head = 0;
	}

	P pop () {
  	CS critical_section;
    
    base_t current = tail;
    
    ++tail;
    
    if (D == tail) tail = 0;
    
    return queue[current];
	}
  
  P back() {
    	return queue[tail];
  }

	bool is_empty() {
		return head == tail;
	}

	auto& get_instance() {
		return queue;
	}

private:
	P queue[D];
  base_t head{0}, tail{0};
};

В рассмотренном выше классе CoQueue в качестве элемента очереди задан некий тип co_payload_t. Это алиас на облегченный (отсылка к моему бзику об экономии времени компиляции) аналог std::variant - класс CoVariant. В его основе использован т.н. tagged union. Если вы не знакомы с этой конструкцией, то продемонстрирую основную идею урезанной имплементацей CoVariant ниже. Полную реализацию сможете найти в примере в конце статьи. Пока наш вариант готов принимать только типы uint32_t и void*. Расширение его новыми типами - вопрос аккуратного копипаста. Ну а если вас не тревожит время сборки проекта, его легко можно заменить на std::variant.

// in "co_variant.hpp"

class CoVariant{
  
  public:
  CoVariant(const CoVariant& other) : tag(other.tag){
    
    switch(tag){
        
      case Tag::NONE:
        val = 0;
        break;
      
      case Tag::BASE_T:
        val = other.val;
        break;
      
      case Tag::VOID_PTR:
        ptr = other.ptr;
        break;
    }
  }
  
  private:
  
  	enum class Tag{NONE, VOID_PTR, BASE_T};
  	Tag tag{Tag::NONE};

    union{
        void* ptr;
        base_t val;
    };
};

Интерфейс класса CoMutex следует той же логике, что и рассмотренный ранее класс CoQueue. Существенные детали реализации, связанные именно с функционалом мьютекса, прокомментированы в примере кода:

// in "co_types.hpp"

// структура параметров мьютекса
struct CoMutexData{
    bool* ptr; // указатель на мьютекс
    bool is_taken; // флаг успешности взятия мьютекса
};

using co_mutex_t = CoMutexData;

// in "co_mutex.hpp"

#include "critical_section.hpp"
#include "co_proxy.hpp"

template<typename CS>
class CoMutexImpl{

public:
	
  co_mutex_t get_mutex() {
  	CS critical_section;
    // если мьютекс свободен, флаг is_taken = true
    bool is_taken = !mutex;
    // забираем мьтекс
    if (is_taken) mutex = true;
    
    return {&mutex, is_taken};
  }
	
  void give_mutex() { mutex = false; }

private:
	bool mutex{false};
};

template<co_act_t A>
class CoMutex final : public CoProxy<CoMutex<A>>{
  
  public:
  
  co_act_t give_impl() {
    instance().give_mutex();
    return A;
	};
  
  template<CoPrio P>
  co_proxy_t<A, P> get_impl() {
    // передаем корутине параметры мьютекса
    return {.mutex = instance().get_mutex(),};
  }
  
  private:
  using mutex_impl_t = CoMutexImpl<co_critical_t>;
  
  [[gnu::always_inline]] mutex_impl_t& instance() {
    static mutex_impl_t mutex_impl;
    return mutex_impl;
  }
};

#define CO_MUTEX(n)   using n = CoProxy<CoMutex<__COUNTER__>>

/*  USER SECTION START  */
CO_MUTEX(dma_mutex_t);
/*  USER SECTION END  */

Для имплементации таймера мы будем использовать стандартный инструментарий из std::chrono. Но сначала определим наш ресурс локального времени:

// in "co_chrono.сpp"

#include <chrono>
#include <tuple>

struct PlatformClock{
  using duration = std::chrono::duration<base_t, std::milli>;
  using rep = duration::rep;
  using period =  duration::period;
  using time_point = std::chrono::time_point<PlatformClock, duration>;
  
  static constexpr bool is_steady = false;
  
  static time_point now() {
    // пример к статье будет реализован на stm-ке,
    // поэтому, не мудрствуя лукаво, воспользуемся халовской функцией
    auto millisecond_tick = HAL_GetTick();
    
    return time_point(duration(millisecond_tick));
  }
};

Далее определим вспомогательный класс CoChrono:

// in "co_chrono.hpp"

struct CoChrono{
  // заводим и регистрируем таймер
  static void set_timer (co_act_t A, base_t delay);
  // проверяем зарегистрированные таймеры
  static void check_if_expired();
};

// in "co_chrono.cpp"

#include <chrono>
#include <tuple>
#include "co_proxy.hpp"
#include "co_queue_impl.hpp"
#include "co_chrono.hpp"

using namespace std::chrono;

// наследуемся от CoProxy, чтобы иметь возможность
// сигналить о наступлении заданного времени
class CoChronoImpl final: public CoProxy<CoChronoImpl>{
  public:
  co_act_t give_impl(co_act_t A) { return A; }
};

using co_chrono_t = CoProxy<CoChronoImpl>;

// задаем тип и удобоваримый алиас регистрационной записи таймера.
// она будет содержать id таймера, стартовое время и величину задержки
using chrono_entry_t = std::tuple<co_act_t, PlatformClock::time_point, base_t>;

// хранить записи будем в очереди; задаем ее тип и алиас
using chrono_queue_t = 
  CoQueueImpl<chrono_entry_t, CoParam::CORO_TIMER_NUM, co_critical_t>;

// инстанцируем очередь
chrono_queue_t chrono_queue;


void CoChrono::set_timer (co_act_t A, base_t delay) {
  // сохраняем запись с установкой времени момента регистрации
  chrono_queue.push( {A, PlatformClock::now(), delay} );
}

// этот метод вызываем в обработчике прерывания
// таймера, назначенного в микроконтроллере
void CoChrono::check_if_expired() {
  auto& q = chrono_queue.get_instance();
  co_chrono_t chrono;
  
  // пробегаемся по очереди
  for (auto& [act, start_point, delay] : q){
    
    // если задержка не установлена, пропускаем итерацию
    if (not delay) continue;
    
    // считаем пройденное время с момента регистрации таймера
    auto res = 
      duration_cast<milliseconds>(PlatformClock::now() - start_point).count();
    
    // если время вышло, сигналим диспетчеру и зачищаем поле delay,
    // чтобы избежать повторного срабатывания
    if (res > delay) {
      
      chrono.give(act);
      
      delay = 0;
    }
  }
}

Теперь мы готовы дать определение класса CoTimer:

// in "co_timer.hpp"

#include "co_proxy.hpp"
#include "co_chrono.hpp"

template<co_act_t A>
class CoTimer final : public CoChrono, public CoProxy<CoTimer<A>>{
  public:
  
  template<CoPrio P>
  co_proxy_t<A, P> get_impl(base_t delay) {
    // регистрируем и запускаем таймер
    set_timer(A, delay);
    return {};
  }
};

#define CO_TIMER(n)   using n = CoProxy<CoTimer<__COUNTER__>>

/*  USER SECTION START  */
CO_TIMER(app_timer_t);
/*  USER SECTION END  */

Класс CoEvent здесь приводить не буду, он не несет ничего нового к рассмотренному. Его реализацию вы сможете посмотреть по ссылке на пример в конце статьи.

Теперь рассмотрим подробнее диспетчер нашей ОС - класс CoManager и его интерфейс:

// in "co_manager.hpp"

struct CoManager{
    static void set_action(co_act_t act);
    static void store_sync(co_sync_t s);
    static void run();
};

// in "co_manager.cpp"

#include <coroutine>

#include "critical_section.hpp"
#include "co_queue_impl.hpp"
#include "co_manager.hpp"

// если событие, возобновляющее корутину, это сигнал от мьютекса,
// то обрабатываем параметры мьютекса, сохраненные в объекте CoSync корутины
// локальной функцией mutex_take()
static bool mutex_take (co_sync_t sync);
//объявляем локальную функцию, ответственную за возобновление корутины
static void co_resume (co_sync_t sync);

// на базе разработанного ранее класса создаем очередь
// в которую будем складывать указатели на синхрообъекты
// готовых к возобновлению корутин
using sync_queue_t = 
  CoQueueImpl<co_sync_t, CoParam::CORO_TASK_NUM, co_critical_t>;

// создаем массив указателей на синхрообъекты всех корутин программы
co_sync_t co_repo[CoParam::CORO_TASK_NUM];

// создаем массив очередей указателей синхрообъектов корутин
// получивших сигнал к возобновлению.
// наименьший индекс массива соотвтетствует наивысшему приоритету
sync_queue_t co_queue_repo[CoPrio::num];

// указатель на синхрообъект корутины, выполняемой в данный момент времени
co_sync_t current;
// кэшированное значение памяти, выделенной аллокатором для корутин
base_t current_memory;


void CoManager::set_action(co_act_t act) {
  
  // пробегаемся по массиву указателей на синхрообъекты
  for (auto sync : co_repo){
    // если корутина с таким индексом не создана - 
    // пропускаем итерацию
    if (not sync) continue;
    // если id наступившего события совпадает с id ожидаемого
    // события, то помещаем в очередь готовых к возобновлению
    // в соответствии с назначенным событию приоритетом
    if(act == sync->expected)
      co_queue_repo[sync->prio].push(sync);
  }
};

// в методе co_yield() сохраняем указатель
// на синхрообъект корутины
void CoManager::store_sync(co_sync_t s) {
  co_repo[s->id] = s;
}

void CoManager::run(){
  
  // пробегаемся по массиву очередей готовых к выполнению
  // корутин
  for (auto& queue : co_queue_repo){
    
    // обрабатываем очередь пока она не опустеет 
    while ( not queue.is_empty() ) {
      
      co_sync_t sync = queue.back();
      
      // если в данный момент нет выполняемых корутин
      // сохраняем указатель из очереди в переменную current
      // и возобновляем корутину
      if ( not current ||
           CoState::suspended == current->state ){
       
        current = sync;
        
        co_resume(current); 
      
      // если в данный момент выполняется какая-то корутина
      // и ее приоритет ниже, чем у данной, то вытесняем ее,
      // сохранив ее указатель в переменную preemted.
      // по завершению более срочной корутины, продолжаем выполнение
      // вытесненной
      } else if (CoState::running == current->state  &&
                 sync->prio < current->prio          ){
        
        current->state = CoState::blocked;
        co_sync_t preemted = current;
        current = sync;
        
        co_resume(current);
        
        preemted->state = CoState::running;
        current = preemted;
        
      } else {
        return;
      }
    }
  }
}

static void co_resume (co_sync_t sync){
  // если корутина ждала сигнала от корутины,
  // но он пока захвачен, то не возобновляемся
  if( not mutex_take(sync) ) return; 
  
  // меняем состояние на "выполняется"
  sync->state = CoState::running;
  // сбрасываем id ожидаемого события
  sync->expected = std::numeric_limits<co_act_t>::max();
  
  // в период выполнения метода CoManager::run() прерывания
  // запрещены, поэтому при возобновлении корутины мы их разрешаем
  // (чтобы корутина могла быть вытеснена более приоритетной)...
  co_detail::enable_irq();
  
  std::coroutine_handle<>::from_address(sync->co_addr).resume(); 
  
  // ... а по завершении - вновь запрещаем
  co_detail::disable_irq();
}

static bool mutex_take (co_sync_t sync){
  
  bool *mutex_ptr = sync->mutex.ptr;
  // если mutex_ptr != nullptr и мьютекс захвачен
  // корутину не возобновляем
  if ( mutex_ptr && (*mutex_ptr) ) return false;
  // иначе захватываем мьютекс и возобновляем
  if (mutex_ptr) *mutex_ptr = true;
  
  return true;
}

Настало время поговорить о переключении контекста в arm cortex-m. На мой взгляд эту тему практически полностью закрыл замечательный материал уважаемого @lamerok. Я сам по ней закрывал белые пятна в своем понимании темы

Если вы не очень разбираетесь в этом вопросе, настоятельно рекомендую проштудировать сначала указанную статью.

Здесь же я ограничусь схематичным описанием процедуры пререключения контекста через призму взаимодействия с ОС:

  1. в МК назначаем таймер - источник тиков для ОС (обычно 1 или 10 мс)

  2. в обработчике прерываний этого таймера генерим PendSV request

  3. из обработчика PendSV IRQ, предварительно запретив прерывания и сохранив на стеке "снимок" значений системных регистров вытесненного контекста, вызываем в thread mode метод CoManager::run()

  4. из метода CoManager::run() последовательно, в соответствии с заданным приоритетом, возобновляем корутины. Они могут быть вновь прерваны системным таймером и тогда мы по методу матрешки вновь пробегаемся по п.1 - 4.

  5. из метода CoManager::run() возвращаемся в промежуточную функцию ManagerReturn() в которой генерируем NMI request.

  6. в обработчике NMI IRQ восстанавливаем кадр вытесненного контекста

  7. возвращаемся в вытесненный контекст в thread mode

Ну что ж, вся концепция и теория позади, переходим к примерам. Онлайн можно посмотреть здесь. На трех задачах потестированы все рассмотренные примитивы синхронизации. В демонстрационно-образовательных целях сделал отладочный вывод из корутин; можно понаблюдать порядок вызова методов при их приостановке/возобновлении. Как именно работает пример можно уточнить из комментариев в коде.

Рабочий пример на STM32F412 Discovery можно забрать отсюда. Там сделан акцент на вытеснение задач более приоритетными. task_1 стартует и ожидает event из обработчика прерывания TIM14, запущенного в режиме one pulse mode. Получив event, task_1 возобновляет работу и через 1 секунду загружает данные в очередь task_2. Получив сигнал от очереди, task_2 запускается и вытесняет task_1, так как имеет более высокий приоритет. Также отработав одну секунду, task_2 выгружает значение из очереди, инкрементирует его и загружает в очередь task_3. Последняя по схожему сценарию вытесняет task_2. По завершению работы task_3, возобновляется task_2, а следом и task_1. В конце task_1 рестартует TIM14 и описанный цикл повторяется. Работа задач демонстрируется через светодиоды и отладочный вывод через SWO.

И несколько слов в заключение. В статье описан именно концепт ОС на корутинах. Он работает, но пока опробован на самых простых задачах. Требуется обкатка на разных сценариях, наверняка я что-то зевнул и потребуется существенная доработка и модификация кода. К примеру, в некоторых ситуациях будет полезен механизм наследования приоритетов. Также логика диспетчера сейчас самая примитивная, точно по ходу тестов она будет оттачиваться и усложняться. Буду потихоньку допиливать в свободное от работы время.

Тем не менее, буду рад, если идеи и подходы, изложенные в этой статье вам показались небезынтересными.

Как и всегда, очень рассчитываю на конструктивную критику и встречные идеи.

Спасибо за внимание!

Комментарии (28)


  1. anonymous
    00.00.0000 00:00


    1. DrBulkin
      07.11.2021 23:39
      +3

      Присоединюсь к предыдущему оратору. Автор показал применимость инструмента, но не показал его преимуществ в сравнении с другими. Даже ожиданий не сформулировал. Таковыми были бы, например,

      "А вот если сравнивать с RTOS, то компилятор дает на 11% меньше бинарник"
      или

      "Новый инструмент дает возможность писать примерно на 25% меньше кода, проще в отладке потому и потому"

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


      1. Saalur Автор
        08.11.2021 10:28

        Спасибо за внимание к моей статье и комментарий.

        Действительно, писал статью по ощущением моряка на мачте: "Земля! Земля!"

        Захватила сама идея: воспроизвести базовый функционал ОС на новейшем стандартном инструменте С++20 - корутинах. Ранее аналогичных материалов не встречал, торопился первым ступить на новые земли... Тщеславен немного, каюсь)

        Оценка преимуществ моего подхода (и есть ли таковые по факту) - чуть позже или отдельным материалом, или дополнением к этой статье.


        1. khim
          08.11.2021 18:56

          Лучше отдельным. Потому что это действительно очень важно, но это, в общем, совсем другой тип статьи будет.


    1. Akon32
      07.11.2021 23:56
      +7

      В нашем случае, мы сначала придумываем задачу, а уже потом разрабатываем программно-аппаратный алгоритм ее оптимального решения.

      Так ОС не разработать... Либо под каждую железку будет отдельная ОС, с соответствующими затратами.

      Не силён в embedded, но видел WinCE как в тахеометрах, так и в автомагнитолах. Это ли не embedded с применением относительно универсальной ОС? Назначение устройств разное - ОС одна. Вряд ли разработчики этой ОС подозревали о конкретных устройствах.


      1. count_enable
        08.11.2021 00:11
        +4

        В первую очередь надо задать себе вопрос "А нужна ли вообще ОСРВ?"

        Если у нас есть куча асинхронных задач, общий HAL который используется различной бизнес-логикой, или большой проект который делается различными субподрядчиками - ОСРВ может быть полезной.

        Если мы делаем промышленный термометр с интерфейсом RS-485, то ось только усложнит задачу. Если же мы хотим чтобы такой термометр писал данные в облако, то вполне разумно взять ту же самую Амазоновскую FreeRTOS с готовым сетевым стеком.

        Статья больше о фичах С++20, чем о ОСРВ. Действительно, не хватает сравнения по скорости переключения, размеру контекста потока и т.д.


        1. smart_pic
          08.11.2021 06:56
          +5

          Если мы делаем промышленный термометр с интерфейсом RS-485, то ось только усложнит задачу

          Понимание того нужна ли ОСРВ или нет - зависит от задачи. Во многих подобных приборах, до того момента как память МК стала значительно больше и тактовая частота достигла 50МГц и выше, не применяли ОС при разработке. И эта тенденция массово появилась после 2010г. (это мое наблюдение). Сложность , функционал подобных приборов остался на том же уровне. Задание нужных параметров с кнопок , контроль на индикаторе, неспешное выполнение нескольких алгоритмов регулирования и передача на мастер по сети RS485, CAN , LAN. Сетевой стек передачи на сервер работает без ОС на восьмибитном МК без проблем. Чтобы не быть голословным, вот контроллер http://kvest-led.ru/asuno АСУНО на PIC18F97J60 управление уличным освещением и передача телеметрии на сервер. Информация передается через модем SIM900 или по ЛАН, если есть возможность подключения. Более 20 городов в системе.

          Так зачем теперь в подобные проекты тащить ОС?


          1. smart_pic
            08.11.2021 08:02
            +1

            Уважаемые, хотел бы увидеть реальные проекты на МК тех, кто ставит минус. И если у вас нет опыта в разработке - то лучше пройти мимо и промолчать.


          1. nixtonixto
            08.11.2021 08:09
            +1

            Ваш фонарь выполняет одну функцию и работает по линейному алгоритму, ОС там не нужна. А вот если этот контроллер фонаря по единице на какой-то ножке должен переключиться в режим стиральной машины, а потом по команде из центра — начать майнить биткоины — как вы разрулите эти задачи? Будете на бумажке высчитывать, сколько процессорного времени выделить майнингу, сколько — стиралке? А потом вам перед релизом изменят ТЗ и добавится ещё 10 функций, и опять на бумажке пересчитывать? Да проще натянуть ОС, раздать приоритеты, и пусть задачи сами переключаются по системным тикам.


          1. borisxm
            08.11.2021 10:09
            +1

            Так зачем теперь в подобные проекты тащить ОС?
            Необходимость в ОС обычно появляется с момента, когда нужно реализовать длительно выполняющийся алгоритм в конкуренции с основным потоком и прерываниями. Например, если мы непрерывно рисуем что-то на графическом индикаторе и параллельно должны вести расчеты и отправлять результаты, то использование ОС сильно упрощает по крайней мере один из процессов.

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


            1. Albert2009ru
              09.11.2021 16:53

              Для описанной Вами ситуации, абсолютно справедливо, у меня задачи попроще и опять же данные опроса трех каналов одного АЦП и небольших расчетов из правил Кирхгофа я отправляю по CAN, где его при прототипировании читает P-CAN с виндовым пакетом CAN Explorer (сиреч настоящая полноценная операционка), а в конечном продукте стоит пром.ПК с виндой и софтом, которым я не занимаюсь, который кроме данных с моей платы берёт ещё кучу разных параметров и также везде передаёт и собирает на центральный сервер логи. Так что да, SoC и Lynux минимум (тем более, что всякие "рокчипы" вроде не так уж и дороги) :))))


        1. Saalur Автор
          08.11.2021 10:33

          Спасибо за внимание и комментарий!

          Вы верно указали на пробелы в статье. Концепт ОС - ну совсем новорожденный. Торопился в публикации поделиться самой идеей "корутины + примитивы синхронизации с универсальным интерфейсом на базе co_await + переключение контекста". Вопросы сравнения с существующими ОС интересуют не меньше вашего, обязательно потестирую, главное чтобы работа оставила время...


        1. Albert2009ru
          08.11.2021 23:35

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


      1. Saalur Автор
        08.11.2021 10:28

        Спасиибо за внимание к моей статье и поддержку)


      1. IGR2014
        09.11.2021 18:12

        Разве WinCE уже не официально мертва в плане поддержки?


  1. anonymous
    00.00.0000 00:00


    1. Saalur Автор
      08.11.2021 10:21

      Спасибо за отзыв!

      После планируемых тестов и доработок, послесловием к статье, добавлю более приближенный к реальному применению пример.


    1. fracturizer
      09.11.2021 09:08

      Это раньше такой ход мысли был - поэтому разработка и занимала годы - а это просто нерентабельно. Если иметь готовые кубики "embedded размера" то можно в разы быстрее решать конкретную задачу.


  1. Gerrero
    08.11.2021 04:12
    +2

    И каково оно, юзать С++ в задачах эмбедед? Я может быть отстал от жизни, но, как мне кажется, С++ (именно подход ООП со всеми вытекающими) в мире встраиваемых систем не очень уж подходит. Я к тому, что на С++ гораздо легче себе в ногу пальнуть, нежели с чистым Си.

    К чему вопрос. Бытует мнение что работа с динамическими сущнастями, в мире embedded, это очень опасное занятие и нужно всё делать статическими единицами. Речь, конечно же, про память и объекты. Ну и есть вопросы к объему кода. Хотя, вроде бы говорят (говорят. Я не проверял) сейчас компиляторы все так хорошо ужимают, что разницы практически нет: на сях или на плюсах код написан.

    Короче, вопрос для меня остается открытым. Где и когда выгодно применять С++ в проектах, или всё колбасить по-старинке на сях и в плюсах выгоды нет?


    1. rsashka
      08.11.2021 09:01

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

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


      1. le2
        08.11.2021 16:50

        главный минус C++ — слабопредсказуемое количество необходимой памяти в ОЗУ. То есть для мелких проектов ломается экономика — нужна внешняя память или жирный контроллер.

        Вторая проблема C++ — этот язык не знает никто до конца. Разработчики такие также дороже. Исходя из этого — на С++ легко написать плохо. Так что когда «отец» увольняется, то у последователей возникает непреодолимое желание выкинуть его работу в помойку. Была история из жизни, когда крупный международный проект развалился, с официальной формулировкой «надо было писать на Java, а не на C++» проект просто не мог масштабироваться, быстро развиваться в силу того что команда не смогла быстро реагировать на потребности бизнеса.

        Третья проблема — низкая детерминированность во времени. Конечно и про аппаратную детерминированность Cortex-M также сложно говорить, так как суперскаляр, два АЛУ, могут быть кэши и прочее. Но даже если использовать MISRA C++, а также не использовать STL, boost то от C++ мало что остается. В моём представлении C++ это все же про создание и разрушение объектов во времени.

        Итого, на мой взгляд, и по опыту откастрированный C++ вполне годен как верхний уровень для бизнес-логики, для графики в эмбеддед, для некритичного по времени. Когда железка некритична по цене, а такие программисты есть в наличии. Ну или когда железка настолько серьезная что и так используются внешние тяжелые библиотеки, типа Тензорфлоу, OpenCV и прочее.
        Нижний слой хардверной абстракции стоит оставить на Сях.


        1. Saalur Автор
          08.11.2021 17:30
          +7

          В далеком уже 2010 г. на плюсах для AVR-ок(!) писали так:

          // создаем произвольный список пинов с разных(!) портов
          PinList<Pa1, Pa2, Pa3, Pb3, Pb4> MyPins;
          
          // записываем значение в этот список
          MyPins::Write(0x55);
          
          // смотрим ассемблерный выхлоп:
          
          //вывод в PORTA
          	in	r24, 0x1b
          	andi	r24, 0xF1
          	ori	r24, 0x0A
          	out	0x1b, r24
          //вывод в PORTB
          	in	r24, 0x18
          	andi	r24, 0xE7
          	ori	r24, 0x10
          	out	0x18, r24

          Компилятор в компайл тайм сам рассчитал все битовые маски и логические значения, не оставив ничего лишнего на рантайм. Никаких динамических аллокаций, предельная детерминированность и нулевой оверхед. И это при возможностях языка и компиляторов 10-ти летней давности. Источник здесь.

          Возможно этот простой пример подтолкнет Вас провести ревизию предубеждений касаемо обоснованности С++ во встроенных решениях.


        1. khim
          08.11.2021 19:13
          +3

          Вторая проблема C++ — этот язык не знает никто до конца.

          О! Спешу вас обрадовать! Стараниями разработчиков компиляторов у C++ теперь паритет с C — ибо C, как его понимают современные компиляторы, тоже никто не знает до конца!

          В моём представлении C++ это все же про создание и разрушение объектов во времени.

          C++ (и Rust) — это про создание абстракций. Если у вас в проекте людей, способных это грамотно сделать, нету (или то, что вы пишите, слишком просто, чтобы требовать абстракций), то ни C++, ни Rust , действительно, не нужны.

          Нижний слой хардверной абстракции стоит оставить на Сях.

          Если это абстрации (любые), то C++ (или Rust) там уместен почти наверняка.

          В принципе C++ и Rust идут в одну точку, но с разных сторон: C++ позволяет создавать удобные, красивые, но небезопасные абстракции — и, со временем, старается сделать их использование безопасным, а Rust — сразу говорит, что все абстракции должны быть безопасными или их не должно быть — но это приводит к тому, что некоторые вещи там вообще сделать нельзя ибо никто пока не придумал как их сделать безопасными.


    1. khim
      08.11.2021 19:04
      +2

      Я может быть отстал от жизни, но, как мне кажется, С++ (именно подход ООП со всеми вытекающими) в мире встраиваемых систем не очень уж подходит.

      Вы не просто отстали от жизни, а очень сильно отстали.

      Потому что C++ это уже очень давно не “OOP с наследованием и виртуальными функциями” это уже давным-давно очень малоиспользуемая часть C++ (хотя иногда оно бывает и нужно, но чаще нужны совсем другие вещи: RAII, метапрограммирование и т.д. и т.п.)

      Где и когда выгодно применять С++ в проектах, или всё колбасить по-старинке на сях и в плюсах выгоды нет?

      Существует ровно одна причина не использовать C++: у вас нет людей, которые его хорошо знают.

      Хотя если вы всё ещё пользуетесь C, то я бы рекомендовал на Rust глянуть: пользы будет больше, а самая типичная причина продолжать использовать C++ (у нас куча библиотек на C++ и мы не хотим их переписывать) к вам не относится.


      1. Gerrero
        09.11.2021 04:44

        Спасибо за ответ! Если есть под рукой, можете дать ссылку на гит какого-либо проекта на плюсах для контроллера (любого), где используется мета программирование, RAII и прочие фишки плюсов?


        1. DSarovsky
          09.11.2021 09:56
          +1

          Наверно, основоположником (по крайней мере в рунете) можно считать упомянутого выше Чижова, ВОТ его проект на github.


        1. mctMaks
          11.11.2021 11:25

          вот тут можно посмотреть, https://habr.com/ru/post/540064/. У автора и свой гит есть, весьма интересный.


    1. mctMaks
      11.11.2021 11:21

      И каково оно, юзать С++ в задачах эмбедед? Я может быть отстал от жизни, но, как мне кажется, С++ (именно подход ООП со всеми вытекающими) в мире встраиваемых систем не очень уж подходит.

      смотря какой эмбеддед . Возьмем например одноплатник, допустим dragon 410c (я с ним немного работал), ставим на него линукс и qt, используем С++ в разработке. И это будет эмбеддед. И тут гораздо проще работать на С++ и даже можно в динамическую память.

      Где и когда выгодно применять С++ в проектах, или всё колбасить по-старинке на сях и в плюсах выгоды нет?

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

      Хотя, вроде бы говорят (говорят. Я не проверял) сейчас компиляторы все так хорошо ужимают, что разницы практически нет: на сях или на плюсах код написан

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

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

      Да ладно. Тут скорее так надо говорить "На плюсах сложнее попасть себе в ногу. Но если попал, то отстреливаешь ногу целиком". Разве чистый Си делает строгую проверку на соответствие типов? Вполне можно вместо указателя дать число и отгрести по полной.