Постановка задачи

В программировании микроконтроллеров часто нужно написать простые тестировочные прошивки. При этом надо некоторые функции вызывать чаще, а некоторые реже. Вот например как тут

Для этого конечно можно запустить FreeRTOS, однако тогда код не будет переносим на другие RTOS, например Zephyr RTOS/TI-RTOS/RTEMS/Keil RTX/Azure RTOS или SafeRTOS. Потом прошивку как код часто приходится частично отлаживать на PC а там никакой RTOS в помине нет.

Поэтому надо держать наготове какой-нибудь простенький универсальный переносимый кооперативный NoRTOS планировщик с минимальной диагностикой и возможностью в run-time отключать какие-то отдельные задачи для отладки оставшихся.

Проще говоря нужен диспетчер задач для микроконтроллера.

Определимся с терминологией

Кооперативный планировщик - это такой способ управления задачами, при котором задачи сами вручную передают управления другим задачам.

Супер-цикл - тело оператора бесконечного цикла в NoRTOS прошивках. Обычно бесконечный цикл в прошивках можно найти по таким операторам как for(;;){} или while(1){}

Bare-Bone сборка - это сборка прошивки на основе API какой-нибудь RTOS, где только один поток и этот поток прокручивает супер-цикл с кооперативным планировщиком. Эта сборка нужна главным образом только для отладки RTOS: настройки стека, очередей и прочего.

Ядром любого планировщика является генератор или источник стабильного тактирования. Желательно с высокой разрешающей способностью. Микросекундный таймер. Это может быть SysTick таймер с пересчетом в микросекунды или отдельный аппаратный таймер общего назначения. Обычно аппаратных таймеров от 3х до 14ти в зависимости от модели конкретного микроконтроллера. Также важно, чтобы таймер возрастал. Так проще и интуитивно понятнее писать код нам человекам, так как мы привыкли к тому что время оно всегда непрерывно идет вперед, а не назад.

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

 bool task_proc(void);

Сначала надо определить типы данных.

#ifndef TASK_GENERAL_TYPES_H
#define TASK_GENERAL_TYPES_H

/*Mainly for NoRtos builds but also for so-called RTOS Bare-Bone build*/
#include <stdbool.h>

#include "task_const.h"
#include "limiter.h"

typedef struct  {
    uint64_t period_us;
    bool init;
#ifdef HAS_LIMITER
    Limiter_t limiter;
#endif
    const char* const name;
} TaskConfig_t;

#endif /* TASK_GENERAL_TYPES_H */

Ключевым компонентом планировщика, его ядром является программный компонент называемый Limiter. Это такой программный компонент, который не позволит вызывать функцию function чаще чем установлено в конфиге. Например вызывать функцию не чаще чем раз в секунду или не чаще чем раз в 10 ms.

#ifndef LIMITER_TYPES_H
#define LIMITER_TYPES_H

#include <stdbool.h>
#include <stdint.h>

#include "data_types.h"

typedef bool (*TaskFunc_t)(void);

typedef struct {
    bool init;
    bool on_off;
    uint32_t call_cnt;
    uint64_t start_time_next_us;
    U64Value_t duration_us;
    U64Value_t start_period_us;
    uint64_t run_time_total_us;
    uint64_t start_time_prev_us;
    TaskFunc_t function;
} Limiter_t;

#endif /* LIMITER_TYPES_H */

Вот API планировщика. Механизм очень прост. Limiter измеряет время с момента подачи питания up_time_us, смотрит на расписание следующего запуска start_time_next_us и, если текущее время (up_time_us) больше времени запуска, назначает следующее время запуска и запускает задачу (limiter_task_frame).

bool inline limiter(Limiter_t* const Node, uint32_t period_us, uint64_t up_time_us) {
    bool res = false;
    if(Node->on_off) {
        if(Node->start_time_next_us < up_time_us) {
            Node->start_time_next_us = up_time_us + period_us;
            res = limiter_task_frame(Node);
        }

        if(up_time_us < Node->start_time_prev_us) {
            LOG_DEBUG(LIMITER, "UpTimeOverflow %llu", up_time_us);
            Node->start_time_next_us = up_time_us + period_us;
        }
        Node->start_time_prev_us = up_time_us;
    }
    return res;
}

Limiter также ведёт аналитику. Измеряет время старта и окончания задачи, вычисляет продолжительность исполнения задачи (duration), вычисляет минимум (run_time.min) и максимум (duration.max), суммирует общее время, которое данная задача исполнялась на процессоре (run_time_total).

static inline bool limiter_task_frame(Limiter_t* const Node) {
    bool res = false;
    if(Node) {
        uint64_t start_us = 0;
        uint64_t stop_us = 0;
        uint64_t duration_us = 0;
        uint64_t period_us = 0;

        start_us = limiter_get_time_us();

        if(Node->start_time_prev_us < start_us) {
            period_us = start_us - Node->start_time_prev_us;
            res = true;
        } else {
            period_us = 0; /*(0x1000000U + start) - TASK_ITEM.start_time_prev; */
            res = false;
        }

        Node->start_time_prev_us = start_us;
        if(res) {
           	data_u64_update(&Node->start_period_us, period_us);
        }
        

        res = true;
#ifdef HAS_FLASH
        res = is_flash_addr((uint32_t)Node->function);
#endif /*HAS_FLASH*/
        if(res) {
            Node->call_cnt++;
            res = Node->function();
        } else {
            res = false;
        }

        stop_us = limiter_get_time_us();

        if(start_us < stop_us) {
            duration_us = stop_us - start_us;
            res = true;
            data_u64_update(&Node->duration_us, duration_us);
            Node->run_time_total_us += duration_us;
        } else {
            duration_us = 0;
            res = false;
        }
    }
    return res;
}

Стоит заметить, что перед непосредственным запуском конкретной задачи Limiter может проверить, что указатель на функцию в самом деле принадлежит Nor-Flash памяти микроконтроллера.

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


bool inline tasks_proc(uint64_t loop_start_time_us){
    bool res = false;
    uint32_t cnt = task_get_cnt();
    uint32_t t = 0;
    for (t=0; t<cnt; t++) {
        if(TaskInstance[t].limiter.on_off) {
            res = limiter(&TaskInstance[t].limiter, TaskInstance[t].period_us, loop_start_time_us);
        }
    }
    return res;
}

bool super_cycle_iteration(void) {
    bool res = false;
    if(SuperCycle.init) {
        SuperCycle.spin_cnt++;
        res = true;
        SuperCycle.run = true;
        SuperCycle.start_time_us = time_get_us();
        LOG_DEBUG(SUPER_CYCLE, "Proc %f Spin:%u", USEC_2_SEC(SuperCycle.start_time_us),SuperCycle.spin_cnt);
        if(SuperCycle.prev_start_time_us < SuperCycle.start_time_us) {
        	SuperCycle.error++;
        }
      
        SuperCycle.duration_us.cur = (uint32_t)(SuperCycle.start_time_us - SuperCycle.prev_start_time_us);
        SuperCycle.duration_us.min = (uint32_t)MIN(SuperCycle.duration_us.min, SuperCycle.duration_us.cur);
        SuperCycle.duration_us.max = (uint32_t)MAX(SuperCycle.duration_us.max, SuperCycle.duration_us.cur);
       
        super_cycle_check_continuity(&SuperCycle, loop_start_time_us);

        tasks_proc(SuperCycle.start_time_us);
      
        SuperCycle.prev_start_time_us = SuperCycle.start_time_us;
    }

    return res;
}


Вот код запуска супер цикла. Из функции super_cycle_start и будет исполняться вся прошивка, за исключением вызова обработчиков прерываний ISR.

_Noreturn void super_cycle_start(void) {
    LOG_INFO(SUPER_CYCLE, "Start");
    super_cycle_init();

    SuperCycle.start_time_ms = time_get_ms();
    LOG_INFO(SUPER_CYCLE, "Started, UpTime: %u ms", SuperCycle.start_time_ms);
  
    for(;;) {
    	super_cycle_iteration();
    }
}

Такая сформировалась зависимость между программными компонентами данного планировщика.

Отладка планировщика

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

В данном скриншоте можно замерить, что больше всего процессорного времени потребляет задача DASHBOARD (приборная панель). Тут же видно, что были такие итерации супер цикла, что задача DASHBOARD непрерывно ис��олнялась аж 0.33 сек!

Можно измерить период с которым вызывалась каждая из задач и сопоставить с конфигом для каждой задачи. Тут видно, что в среднем реже всего вызывается задача FLASH_FS (менеджер файловой системы). Одновременно драйвер светодиода LED_MONO отрабатывает c частотой (44 Hz). А чаще всего происходит опрос DecaDriver(а).

Тут заметно даже, что накладные расходы на данный планировщик составляют 47.9% по времени. Это лишь потому, что в обработчиках самих задач не происходит пока никакой обработки. Прошивка работает вхолостую. Не было прерываний и флаги не устанавливаются. Не приходят пакетов в интерфейсы. Ничего не происходит.

Также высокое процентное значение SchedulerOverhead это признак того, что периоды у всех задач большие и процессору нечего делать. А значит можно смело добавлять в супер цикл больше кооперативных задач или уменьшать периоды у нынешних задач.

Анализируя эти ценнейшие метрики данного импровизированного планировщика можно принимать решения по оптимизации кода всего проекта. Получился своеобразный Code Coverage.

Достоинства данного планировщика

1--Простота, очевидность, прозрачность, мало кода.

2--Можно вычистить процент загрузки процессора по каждой задаче.

3--Переносимость. Можно его прокручивать хоть на микроконтроллере, хоть на BareBone потоке в RTOS, хоть в консольном приложении на LapTop PC.

4--Приоритет задачи задается периодом её запуска. Чем ниже период, тем выше приоритет.

5--Легко масштабировать прошивку. Просто добавляем новые строчки в super цикл.

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

7--Можно переназначать функции для узлов планировщика и таким образом перепрограммировать устройство далеко в Run-Time.

Недостатки данного планировщика

1--Если одна задача зависла, то считай что зависли все остальные задачи.

2--Надо проектировать задачи так, чтобы они что-то делали за один прогон и не тратили много времени внутри себя. Например переключили состояние конечного автомата и вышли. Совсем не здорово, если какая-то задача начнет расшифровывать 150kByte KeePass файл внутри общего супер цикла или вычислять обратную матрицу 100x100. У Вас перестанет мигать Heart Beat LED, перестанет отвечать CLI и пользователь будет с полной уверенностью считать, что прошивка просто взяла и зависла! А на самом деле программа через 57 секунд снова воспрянет.

3--Требуются накладные расходы (в виде процессорного времени) для вычисления метрик за которыми следит Limiter. Но это не такая и большая проблема, так как отладочные метрики можно включать или исключать на стадии препроцессора #ifdef(ами).

Вывод

Вот и Вы умеете делать кооперативный планировщик. Супер цикл это не такая уж и плохая вещь. Его можно отлично использовать и в RTOS прошивках. Есть код которому точно нужен RTOS. Это BLE/LwIP стек, однако всё остальное: LED, Button может отлично работать в пределах супер цикла в отдельном BareBone потоке. Благодаря супер циклу вы сэкономите на переключении контекста. Надеюсь, что этот текст поможет кому-нибудь писать прошивки и оценивать нагрузку на процессор.

Словарь

Акроним

Расшифровка

1

ISR

Interrupt Service Routine

2

RTOS

real-time operating system

3

UART

Universal asynchronous receiver/transmitter

4

CLI

command-line interface

5

API

Application Programming Interface

Links

Контрольные вопросы:

1--В какую сторону в ARM Cortex-M4 считает Sys Tick таймер?

2--Как измерить загруженность процессора в NoRTOS прошивке?

3--Сколько тактов процессора нужно для вызова Си-функции на микропроцессоре ARM Cortex-M4?

4--Сколько тактов процессора нужно для вызова обработчика прерываний на микропроцессоре ARM Cortex-M4?

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Как вы организовываете прошивки?
3.85%супер-цикл без прерываний4
26.92%RTOS28
25.96%супер-цикл + прерывания27
43.27%супер-цикл, который прокручивает конечные автоматы + прерывания45
Проголосовали 104 пользователя. Воздержались 14 пользователей.
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Какую RTOS вы использовали при программировании микроконтроллеров?
39.69%никакую52
2.29%Azure RTOS3
0.76%CHibi1
0%Contiki0
1.53%Embox2
54.96%FreeRTOS72
2.29%Keil RTX3
0.76%Nucleus RTOS1
0%OpenRTOS0
1.53%RTEMS2
0%SafeRTOS0
3.05%TI-RTOS4
1.53%TNKernel2
0.76%TinyOS1
1.53%vxWorks2
6.11%Zephyr8
1.53%NuttX2
3.05%QNX4
1.53%scmRTOS2
6.87%Я сам написал себе RTOS, вытесняющий планировщик, синхронизацию, ADT и модульные тесты9
Проголосовал 131 пользователь. Воздержались 18 пользователей.