operating system concept for microcontrollers based on C ++ 20 coroutines

Hello! My name is Alexander and I work as a microcontroller programmer.

Probably any embedded system designer from time to time thinks of writing his own axis. Yes, such that others were discouraged!

And your author is no exception.

As for me, it’s not that prohibitively difficult, but painstaking. If, like me, your hobby or career revolves around the Arm Cortex-M series, then we arm ourselves with barrels (once, two and three) and move forward for Jeff

But after writing and running the core of my “best of the best” axis about a year ago, I soon gave up development. For no matter how creative I was, instead of the Millennium Falcon I got a sturdy, but banal and boring bike.

But I wanted originality and shameless show off.

And then coroutines were brought to the 20th standard.

This is all:

#include <coroutine>

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

Here your embedder chuyka should trigger: “What if the same thing, but with mother-of-pearl buttons?” :

#include <coroutine>

Coro task1() {

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

Indeed, the signature of a typical task in RTOS has turned out. Moreover, in the case of coroutines, the compiler will take care of calculating the required memory for the task. Probably, these data will be easy to obtain and take into account. We will only have to control the amount of memory allocated in total for all tasks. Not bad already.

We fantasize further. It will be convenient if the co_await operator can act as a single data exchange window between the coroutine, the dispatcher and the synchronization primitives (events, mutexes, timers, queues, etc.). Then we can win in composition and code readability.

Okay, so what can you do with task priorities? Or you can dare, turn everything inside out and suddenly get a task with dynamic runtime priority depending on the event expected:

#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();
  }
}

Looks tempting.

All that remains is to add a dispatcher here, juggling our coroutines, plus a context switcher, and you may end up with something curious. Looks like we have some interesting stuff to work with. Well, hyip on the hot topic corutin – how can you do without it 🙂

For readers interested in this topic, I will give several introductory notes, which I will adhere to further along the article:

  • I assume that you are more or less familiar with the coroutine toolkit provided by the language at the moment. If you need to refresh your ideas, I recommend reading this excellent article. You can also dig deeper into the topic here.

  • to make the code examples easier to read and save you reading time, I will omit qualifiers and keywords from class / function method descriptions. I will provide links to a working implementation at the end of the article.

  • An important element of the article is the comments in the code examples.

Next up is a thoughtful longread. After all, we write the axis, and not the Arduin’s blinker.

First of all, we need some kind of synchro-object with which we will exchange data between the coroutine and the outside world. Let’s define it:

#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*;

The object is customizable; we are free to expand the project with new data fields when developing a project.

Then the promise_type of the coroutine can be defined as follows. Again, for ease of reading, I will only provide methods that contain the logic of our program. The minimum set of methods of the Promise object required by the standard can always be viewed here

#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);
  }
};

Let’s go over the top and look at the new types and functions found in promise_type.

At the time of creating a coroutine, we assign an identifier to it. It is simply a number between 0 and the total number of tasks running in the program. Its main purpose is to serve as an index to an array that will store pointers to CoSync sync objects. We index the coroutines with an object of type indexer_t:

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

Perversion? Perhaps, because the desired result can be obtained through an ordinary function. But I am now an unhealthy fan of expressing logic through types. Types can be instantiated at the place of use without cluttering your code with global variables. Types can be shoved into templates, helping the compiler to inline the code, drag and drop part of the program’s functionality into compile time. So be patient 🙂

Awaitable structure:

#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 (nullptr == 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; }
};

As you know, coroutines dynamically allocate memory on the heap. For built-in solutions, the word “heap” is almost a dirty word. A good firmware developer usually plans who, where, and how much memory to allocate. We want to be good, so we implement our own allocator. Let’s use a ready-made tool from the standard, and use std :: pmr :: monotonic_buffer_resource. It is fast, takes in the constructor a pointer to the memory fragment we defined and has the necessary (de) allocate () methods:

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

The CoAlloc implementation is trivial (minor details omitted):

// 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;
}

Now let’s take a closer look at the mechanism of interaction of the coroutine with synchronization objects.

The point of contact between the coroutine and the event (or timer, queue, mutex, etc.) is the call to the co_await operator. Through it, the synchronization object passes the already familiar argument of the co_proxy_t type. This is an alias for the following structure:

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

As mentioned earlier, the co_proxy_t type accumulates knowledge about the type of dispatcher (the CoManager structure, about it a little later), mutex parameters, as well as the event identifier (template parameter A) and priority (template parameter P). But what would a single interface look like for all synchronization primitives?

Let’s implement the base CoProxy class. From it in the future, using CRTP pattern, in the compile time we will inherit the classes of concrete primitives.

// 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);
	}
};

Here you probably turned to the co_detail :: forward (args) construct. Indeed, while our OS is in its infancy, we do not know all the directions of its development. Therefore, it is reasonable at this stage to lay the maximum variability in the key interface. Let’s do this through the template toolkit and perfect forwarding. Well, in order not to include such a sickly header through the whole project, I defined move (), forward () in the compact header “co_util.hpp”, in the co_detail namespace, since their implementation is considered in many sources (example).

In principle, I will continue to avoid including “heavy” standard library headers in my headers whenever possible (I will only include them in .cpp files or use my lightweight implementation of the required tools as an alternative). The goal is simple and noble – to save time for yourself and a potential user on project assembly. It is clear that in this case we are talking about seconds, but still …

Now is the time to develop the synchronization primitives. Let’s start with the queue. The CoQueue class can be defined like this:

// 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  */

I won’t surprise you with the CoQueueImpl class, its implementation at this stage of OS development is elementary:

// 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};
};

In the CoQueue class discussed above, a certain type co_payload_t is specified as a queue element. This is an alias for the lightweight (a reference to my bzik about saving compilation time) analogue of std :: variant – the CoVariant class. It is based on the so-called. tagged union. If you are not familiar with this construction, then I will demonstrate the main idea with a stripped-down CoVariant implementation below. The complete implementation can be found in the example at the end of the article. So far, our version is ready to accept only the uint32_t and void * types. Expanding it with new types is a matter of neat copy-paste. Well, if you are not worried about the build time of the project, you can easily replace it with 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;
    };
};

The CoMutex class interface follows the same logic as the CoQueue class discussed earlier. The essential implementation details related specifically to the functionality of the mutex are commented out in the example code:

// 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  */

We’ll use the standard std :: chrono toolkit to implement the timer. But first, let’s define our local time resource:

// 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));
  }
};

Next, we define the CoChrono helper class:

// 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;
    }
  }
}

We are now ready to define the CoTimer class:

// 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  */

I will not bring the CoEvent class here, it does not bring anything new to the considered one. You can see its implementation by following the link to an example at the end of the article.

Now let’s take a closer look at the dispatcher of our OS – the CoManager class and its interface:

// 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;
}

Now is the time to talk about context switching in arm cortex-m. In my opinion, this topic was almost completely closed by the wonderful material of the respected @lamerok. I myself covered the blank spots in my understanding of the topic.

If you are not very versed in this issue, I strongly recommend that you study the specified article first.

Here I will limit myself to a schematic description of the context switching procedure through the prism of interaction with the OS:

  1. in the MC we assign a timer – the source of ticks for the OS (usually 1 or 10 ms)

  2. in the interrupt handler of this timer, generate a PendSV request

  3. from the PendSV IRQ handler, after disabling interrupts and saving a “snapshot” of the values ​​of the system registers of the preempted context on the stack, call the CoManager :: run () method in thread mode

  4. from the CoManager :: run () method, sequentially, in accordance with the specified priority, we resume the coroutines. They can be interrupted again by the system timer, and then we, using the matryoshka method, again run through items 1 – 4.

  5. from the CoManager :: run () method, we return to the intermediate function ManagerReturn () in which we generate the NMI request.

  6. in the NMI IRQ handler, restore the preempted context frame

  7. return to the preempted context in thread mode

Well, the whole concept and theory is behind us, let’s move on to examples. Online you can watch here… All the considered synchronization primitives have been tested on three tasks. For demonstration and educational purposes, I made debug output from coroutines; you can observe the order of calling methods when they are suspended / resumed. How exactly the example works can be clarified from the comments in the code.

Working example on STM32F412 Discovery can be picked up from here… There is an emphasis on crowding out tasks with higher priorities. task_1 starts and waits for an event from a TIM14 interrupt handler running in one pulse mode. After receiving event, task_1 resumes and after 1 second loads data into the task_2 queue. After receiving a signal from the queue, task_2 starts and preempts task_1, since it has a higher priority. Also, after one second, task_2 unloads the value from the queue, increments it and loads it into the task_3 queue. The latter supersedes task_2 according to a similar scenario. Upon completion of task_3, task_2 is resumed, followed by task_1. At the end of task_1, TIM14 is restarted and the described cycle is repeated. Work of tasks is demonstrated through LEDs and debug output through SWO.

And a few words in conclusion. The article describes exactly the concept of OS based on coroutines. It works, but so far it has been tested on the most basic tasks. It requires a run-in on different scenarios, for sure I yawned something and a significant revision and modification of the code will be required. For example, in some situations the priority inheritance mechanism will be useful. Also, the dispatcher’s logic is now the most primitive, as if in the course of tests it will be refined and complicated. I will finish slowly in my free time.

Nevertheless, I will be glad if the ideas and approaches presented in this article seemed interesting to you.

As always, I really count on constructive criticism and counter ideas.

Thank you for the attention!

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *