Pull to refresh

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

Reading time 11 min
Views 7.7K
Я продолжаю публиковать цикл статей из «Книги знаний ОСРВ МАКС». Это неформальное руководство программиста, для тех, кто предпочитает живой язык сухому языку документации.

В этой части пришла пора положить теорию на реальный код. Рассмотрим, как всё сказанное раньше записывается на языке С++ (именно он является основным для разработки программ под ОСРВ МАКС). Здесь мы поговорим только о минимально необходимых вещах, без которых невозможна ни одна программа.

Содержание (опубликованные и неопубликованные статьи):

Часть 1. Общие сведения
Часть 2. Ядро ОСРВ МАКС
Часть 3. Структура простейшей программы (настоящая статья)
Часть 4. Полезная теория
Часть 5. Первое приложение
Часть 6. Средства синхронизации потоков
Часть 7. Средства обмена данными между задачами
Часть 8. Работа с прерываниями

Код


Так как у ОСРВ МАКС объектно-ориентированная модель, то и программа должна содержать классы. При этом базовые классы уже имеются в составе ОС, прикладной программист должен лишь создать от них наследников и дописать требуемую функциональность.

Для реализации приложения следует сделать наследника от класса Application (обязательно перекрыв в нём виртуальную функцию Initialize()) и один или несколько наследников класса Task (обязательно перекрыв в нём виртуальную функцию Execute()). И всем этим будет управлять планировщик, реализованный в классе Scheduler.

image

Рис. 1. Минимально необходимые для работы классы (серые — уже имеются, белые — следует дописать)

Класс Application


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

void VPortUSBApp::Initialize()
{
       Task::Add(vport = new VPortUSBTask, Task::PriorityNormal, Task::ModeUnprivileged, 400);
       Task::Add(new HelloTask, Task::PriorityNormal, Task::ModeUnprivileged, 400);
}

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

В первую очередь, именно через объект этого класса ОС находит приложение. Я хотел было написать, что «находит приложение без глобальных переменных», но если заняться крючкотворством, то статическая переменная-член класса Application

static Application * m_app;

всё-таки является глобальной. Но как там в классике: «Самоса — сукин сын, но это — наш сукин сын». Переменная — глобальная, но она хорошо структурирована и принадлежит классу приложения. Соответственно, её имя изолировано ото всех остальных классов. Для обращения к ней имеется функция

inline Application & App() { return * Application::m_app; }

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

class AlarmMngApp : public Application
{
...
public:
...
       static inline AlarmMngApp & App() { return * (AlarmMngApp *) m_app; }

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

Следующая неочевидная вещь при использовании класса Application — его конструктор. В конструкторе передаётся тип многозадачности.

       /// @brief Конструктор приложения
       /// @param use_preemption Режим многозадачности 
       ///       (true - вытесняющая, false - кооперативная).
       Application(bool use_preemption = true);

Класс Application содержит виртуальную функцию OnAlarm(). Она будет вызываться для информирования об исключительных ситуациях. Их перечень достаточно велик:

AR_NMI_RAISED,            ///< Произошло немаскируемое прерывание (Non Maskable Interrupt, NMI)
AR_HARD_FAULT,            ///< Аппаратная проблема (произошло прерывание Hard Fault)
AR_MEMORY_FAULT,          ///< Произошла ошибка доступа к памяти (MemManage interrupt)
AR_NOT_IN_PRIVILEGED,     ///< Произошла попытка выполнить привилегированную операцию в                                   непривилегированном режиме...

Развернуть
AR_NMI_RAISED,            ///< Произошло немаскируемое прерывание (Non Maskable Interrupt, NMI)
AR_HARD_FAULT,            ///< Аппаратная проблема (произошло прерывание Hard Fault)
AR_MEMORY_FAULT,          ///< Произошла ошибка доступа к памяти (MemManage interrupt)
AR_NOT_IN_PRIVILEGED,     ///< Произошла попытка выполнить привилегированную операцию в                                   непривилегированном режиме
AR_BAD_SVC_NUMBER,        ///< Произошла попытка использовать SVC с некорректным номером сервиса
AR_COUNTER_OVERFLOW,      ///< Произошло переполнение счетчика 
AR_STACK_CORRUPTED,       ///< Затерт маркер на верхней границе стека
AR_STACK_OVERFLOW,        ///< Произошло переполнение стека задачи     
AR_STACK_UNDERFLOW,       ///< Произошел выход за нижнюю границу стека 
AR_SCHED_NOT_ON_PAUSE,    ///< Произошла попытка продолжить выполнение планировщика не в состоянии паузы
AR_MEM_LOCKED,            ///< Менеджер памяти заблокирован
       
AR_USER_REQUEST,          ///< Вызвано пользователем

AR_ASSERT_FAILED,         ///< Условие проверки ASSERT не выполнилось
       
AR_STACK_ENLARGED,        ///< Возникла необходимость в увеличении стека задачи
AR_OUT_OF_MEMORY,         ///< Память "кучи" исчерпана
AR_SPRINTF_TRUNC,         ///< Вывод функции sprintf был урезан из-за нехватки места в буфере
AR_DOUBLE_PRN_FMT,        ///< Более одного последовательного вызова PrnFmt в одной и той же задаче
AR_NESTED_MUTEX_LOCK,     ///< Произошла попытка повторно заблокировать нерекурсивный мьютекс одной и той же задачей
AR_OWNED_MUTEX_DESTR,     ///< Мьютекс, захваченный одной из задач, удалён      
AR_BLOCKING_MUTEX_DESTR,  ///< Мьютекс, блокировавший одну или несколько задач, удалён  
AR_NO_GRAPH_GUARD,      ///< Попытка выполнить операцию рисования без использования GraphGuard
       
AR_UNKNOWN                ///< Неизвестная ошибка


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

AA_CONTINUE,       ///< Продолжить выполнение задачи
AA_RESTART_TASK,   ///< Перезапустить вызвавшую ошибку задачу
AA_KILL_TASK,      ///< Снять с выполнения задачу, вызвавшую ошибку
AA_CRASH           ///< Останов системы     

Далее рассмотрим метод Run(). Именно его следует вызвать для того, чтобы ОС начала работу приложения. Собственно, типовая функция main() должна выглядеть следующим образом:

#include "DefaultApp.h"

int main()
{
       MaksInit();
  
       static DefaultApp app;

       app.Run(); 

       return 0;
}

ОСРВ МАКС поддерживает только одно приложение. Поэтому следует объявлять только один экземпляр класса, унаследованного от Application.

Класс Task


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

Функция Execute()


Самая-самая главная функция в задаче — это, разумеется, виртуальная функция Execute().В классических процедурно-ориентированных ОС, программист должен реализовать функцию потока, а затем передать её в качестве аргумента для функции CreateThread(). При объектно-ориентированном подходе, алгоритм проще:

  1. Создать наследника от класса Task,
  2. Функция потока будет иметь имя Execute(). Достаточно перекрыть её. Ничего больше никуда передавать не требуется.

Те, кто привык работать с классическими ОС, заметят, что у функции Execute() нет аргументов, а в потоковую функцию традиционно принято передавать один аргумент. Чаще всего это указатель на целую структуру, содержащую те или иные параметры. Объектно-ориентированный подход избавляет от всех сложностей, связанных с подобным механизмом. Для задачи проектируется класс. Он может содержать неограниченное (в рамках системных ресурсов) число переменных-членов. В них можно размещать совершенно произвольные параметры любыми доступными способами, например, сделать публичные переменные и заполнять их, пока класс не поставлен на исполнение, или передать параметры в конструктор, а он сам заполнит поля, или сделать функции, которые заполняют параметры (сделать функцию инициализации или функции-сеттеры).

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

Итак. Первое правило разработки любого класса задачи: Следует создать класс-наследник от Task и перекрыть в нём функцию Execute().

При выходе из функции Execute() задача удаляется из планировщика, но не удаляется из памяти, так как она может быть как на куче, так и на стеке, а оператор delete, применённый к стековому объекту, вызовет ошибку. Таким образом, удаление объекта задачи по окончании работы с ним — прикладного программиста.

Конструктор класса


Теперь поговорим про конструктор класса. Все конструкторы класса Task находятся в секции protected, поэтому их нельзя вызвать напрямую. Для этого следует в классе-наследнике реализовать свой конструктор, который вызовет тот или иной конструктор класса Task.
Примеры таких конструкторов-наследников:

class TaskYieldBeforeTestTask_1: public Task
{
public:
000000explicit TaskYieldBeforeTestTask_1():
000000000000Task()
000000{
000000}


Вариант немного посложнее:

000000explicit MessageQueuePeekTestTask_1(const char* name):
000000000000Task(name)
000000{
000000}

Стоит обратить внимание на такой параметр, как «имя задачи». Этот параметр является необяза-тельным, но иногда весьма полезным. Более того, существует два альтернативных метода его хранения. Самый простой метод — объявить в файле maksconfig.h константу

#define MAKS_TASK_NAME_LENGTH   0

и игнорировать имя (по умолчанию, используется указатель nullptr). Очень часто этот вариант является самым удобным.

Второй вариант: не переопределять константу MAKS_TASK_NAME_LENGTH, в этом случае, при создании задачи память под хранение имени задачи будет выделяться в куче. Само же имя может использоваться, например, для занесения в журнал событий. Чем грозит использование динамической памяти — описано в одном из следующих разделов (правда, это не относится к случаю добавления задач на этапе инициализации).

Наконец, третий вариант: переопределить константу MAKS_TASK_NAME_LENGTH положительным числом. В этом случае имя задачи будет храниться в переменной-члене класса Task. Это избавляет от работы с кучей, но если ради одной задачи зарезервировано приблизительно 20 символов, то все задачи будут тратить столько же, пусть их имена и будут короче. Для «больших» машин безумное утверждение, там разработчики мыслят мегабайтами (имея в наличии гигабайты или даже десятки гигабайт). Но для слабых контроллеров экономия каждого байта до сих пор актуальна.

Теперь пора разобраться, какие конструкторы есть в классе Task. Их всего два. Первый выглядит следующим образом:

Task(const char * name = nullptr)

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

Однако не всегда стек задачи следует выделять из основной кучи. Дело в том, что микроконтроллер может работать с двумя и более физическими устройствами ОЗУ. Простейший случай — внутреннее статическое ОЗУ контроллера на десятки или сотни килобайт и внешнее динамическое ОЗУ на единицы или десятки мегабайт. Внутреннее ОЗУ будет работать быстрее, чем внешнее. Однако, в зависимости от ситуации, программист может разместить кучу во внешней или внутренней памяти, ведь это же замечательно, когда куча имеет размер в несколько мегабайт! Стек лучше поместить во внутренней памяти контроллера. Соответственно, иногда лучше не доверяться выделению в куче, а указать расположение стека задачи самостоятельно, будучи уверенным, что он расположен в быстром ОЗУ. И в этом поможет конструктор задачи второго вида:

Task(size_t stack_len, uint32_t * stack_mem, const char * name = nullptr)

По его аргументам ясно, что в него кроме имени задачи также передаётся указатель на ОЗУ, где будет размещён стек задачи, а также явно указан размер стека в 32-битных словах (не в байтах)

Пример использования:

Class MyTask : public Task
{
Private:
                uint32_t m_stack[100 * sizeof(uint32_t)];
public:
                MyTask() : Task(m_stack) {}
};

Указать компилятору, в какой памяти размещать переменные, объявленные в той или иной функции, довольно просто, но описание этого займёт несколько листов, и сильно запутает читателя. Поэтому вынесем эту информацию на уровень видео-урока/вебинара.

Таким образом, второе, что следует реализовать в классе-наследнике от Task — это конструктор. Можно даже конструктор-пустышку, который просто вызывает конструктор класса-предка.

Функция Add()


Минимально необходимая часть кода класса, реализующего задачу, написана. Можно добавлять его в планировщик. Для этого используется семейство функций Add(). Рассмотрим их более детально.

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

static Result Add(Task * task, size_t stack_size = Task::ENOUGH_STACK_SIZE)

Добавляет задачу с возможностью указать необходимый ей размер стека (в 32-битных словах).

Пример вызова:

Task::Add(new MessageQueueDebugTestTask_1("MessageQueueDebugTestTask_1"));

Если требуется явно задать режим работы задачи (привилегированный или непривилегированный), можно воспользоваться следующим вариантом функции Add:

static Result Add(Task * task, Task::Mode mode, size_t stack_size = Task::ENOUGH_STACK_SIZE)

Пример вызова:

Task::Add(new RF_SendTask, Task::ModePrivileged);

Для примера ещё один вариант с явным указанием размера стека:

Task::Add(new MutexIsLockedTestTask_1("MutexIsLockedTestTask "), Task::ModePrivileged, 0x200);

Существует также вариант добавления задачи с указанием приоритета:

static Result Add(Task * task, Task::Mode mode, Task::Priority priority, size_t stack_size = Task::ENOUGH_STACK_SIZE)

Пример вызова:

Task::Add(new EventBasicTestManagementTask(), Task::PriorityRealtime);

Самый полный вариант: и с указанием приоритета, и с указанием режима работы:

static Result Add(Task * task, Task::Priority priority, Task::Mode mode, size_t stack_size = Task::ENOUGH_STACK_SIZE);

Пример вызова:

Task::Add(new NeigbourDetectionService(), Task::PriorityAboveNormal, Task::ModePrivileged);

Напомним, что самым удобным местом для вызова функции Add() в типовом случае, является функция Initialize() класса задачи.

void RFApplication::Initialize()
{
       button.rise(&button_pressed);
       button.fall(&button_released);

       Task::Add(new SenderTask(), Task::PriorityNormal, Task::ModePrivileged, 0x100);
       Task::Add(new ReceiverTask(), Task::PriorityNormal, Task::ModePrivileged, 0x100);
}

Однако эта функция может быть вызвана в произвольном месте кода. В классах тестирования ОС можно встретить подобные конструкции:

int EventIntTestMain::RunStep(int step_num)
{
    switch ( step_num ) {
    default :
        _ASSERT(false);
    case 1 :
        Task::Add(new EventBasicTestManagementTask(), Task::PriorityRealtime);
        return 1;
    case 2 :
        Task::Add(new EventUnblockOrderTestManagementTask(), Task::PriorityRealtime);
        return 1;
    case 3 :
        Task::Add(new EventTypeTestManagementTask(), Task::PriorityRealtime);
        return 1;
    case 4 :
        Task::Add(new EventProcessingTestManagementTask(), Task::PriorityRealtime);
        return 1;
    }
}

И они вполне допустимы.

Таким образом, после того, как класс-наследник от класса Task создан, в нём переопределены конструктор (можно конструктор-пустышка, вызывающий конструктор класса Task) и функция Execute, этот класс следует подключить к планировщику при помощи функции Add(). Если планировщик работает, задача начнёт исполняться. Либо она начнёт исполняться с момента запуска планировщика.

Функции, которые удобно вызывать из класса задачи


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

Delay() Блокирует задачу на заданное время, заданное в миллисекундах.
CpuDelay() Выполняет задержку в миллисекундах, не блокируя задачу. Соответственно, управление другим задачам на время задержки принудительно не передаётся (у задачи может быть забрано управление при переключении по системному таймеру). Но при кооперативной многозадачности возможна только эта функция.
Yield() Принудительно отдаёт управление планировщику, чтобы он начал исполнение следующей задачи. При кооперативной многозадачности переключение задач осуществляется именно этой функцией. При вытесняющей — функция может быть вызвана, если задача видит, что ей больше нечего делать и можно отдать остаток кванта времени другим задачам.
GetPriority() Возвращает текущий приоритет задачи
SetPriority() Устанавливает текущий приоритет задачи. Если задача понижает свой приоритет, то при вытесняющей многозадачности она вполне может быть вытеснена, не дожидаясь завершения кванта времени.

Функции, обычно вызываемые извне


Некоторые функции, наоборот, предназначены для вызова извне. Например, функция, позволяющая узнать состояние задачи: если задача будет вызывать её, то всегда будет получать «Активно». Узнавать состояние задачи имеет смысл откуда-то извне. Аналогично и остальные функции данной группы.

GetState() Возвращает состояние задачи (активна, заблокирована и т. п.
GetName() Возвращает имя задачи.
Remove() Удаляет задачу. Может вызываться и из самой задачи, тогда она принудительно инициирует переключение контекста. Объект задачи остаётся в памяти.
Delete() То же, что и Remove(), но с удалением объекта. Соответственно, объект должен быть созданным при помощи оператора new, а не на стеке.
GetCurrent() Возвращает указатель на текущую задачу.


Класс Scheduler


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

GetInstance() Статическая функция, при помощи которой можно получить ссылку на объект планировщика для того, чтобы в дальнейшем обращаться к нему.
GetTickCount() Возвращает число системных тиков, прошедших с момента старта планировщика.
Pause() Приостанавливает переключение задач планировщиком, либо включает работу заново (конкретное действие передаётся в аргументе функции).
ProceedIrq Функция будет рассмотрена в разделе про прерывания.

Задавайте вопросы и оставляйте комментарии — это то, что вдохновляет на написание и публикацию статей.

Здесь мы остановимся, так как дальше следует большой блок полезной теории.
Tags:
Hubs:
+1
Comments 16
Comments Comments 16

Articles