Один из разработчиков нашей операционной системы реального времени МАКС написал серию статей о ее создании и особенностях. Получилась своеобразная «Книга знаний», неформальное руководство программиста.
В первой статье мы рассказали об отличии ОС от ОСРВ, представили архитектуру ОСРВ МАКС и ее специфику. Вторая статья была посвящена ядру системы и приоритету задач, третья — структуре простейшей программы, в четвертой мы представили необходимую теорию, а в настоящей статье мы приступим к практике. Так же статья размещена на Хабре.
При начале работы с контроллерами, принято мигать светодиодами. Я нарушу эту традицию.
Во-первых, это банально надоело. Во-вторых, светодиоды потребляют слишком большой ток. Не спешите думать, что я экономлю каждый милливатт, просто по ходу работ, нам эта экономия будет крайне важна. Опять же, то, что мы увидим ниже — на светодиодах увидеть практически невозможно.
Итак, пока нет генератора проектов, берём проект по умолчанию для своей макетной платы и своего любимого компилятора (я взял ...\maksRTOS\Compilers\STM32F4xx\MDK-ARM 5\Projects\Default) и копируем его под иным именем (у меня получилось ...\maksRTOS\Compilers\STM32F4xx\MDK-ARM 5\Projects\Test1) . Также следует снять со всех файлов атрибут «Только для чтения».
Каталог файлов проекта весьма спартанский.
DefaultApp.cpp
DefaultApp.h
main.cpp
MaksConfig.h
Файл main.cpp относится к каноническому примеру, файлы DefaultApp.cpp и DefaultApp.h описывают пустой класс-наследник от Application. Файл MaksConfig.h мы будем использовать для изменения опций системы.
Если открыть проект, то окажется, что к нему подключено огромное количество файлов операционной системы.
В свойствах проекта также имеется большое количество настроек.
Так что не стоит даже надеяться создать проект «с нуля». Придётся смириться с тем, что его надо или копировать из пустого проекта по умолчанию, или создавать при помощи автоматических утилит.
Для дальнейшего изложения, я разрываюсь между «правильно» и «читаемо». Дело в том, что правильно — это начать создавать файлы для задач, причём — отдельно заголовочный файл, отдельно — файл с кодом. Однако, читатель запутается в том, что автор натворит. Такой подход хорош при создании видеоуроков. Поэтому я пойду другим путём — начну добавлять новые классы в файл DefaultApp.h. Это в корне неверно при практической работе, но зато код получится более-менее читаемым в документе.
Итак. Мы не будем мигать светодиодами. Мы будем изменять состояние пары выводов контроллера, а результаты наблюдать — на осциллографе.
Сделаем класс задачи, которая занимается этим шевелением. Драйверы мы использовать пока не умеем, поэтому будем обращаться к портам по-старинке. Выберем пару свободных портов на плате. Пусть это будут PE2 и PE3. Что они свободны, я вывел из следующей таблицы, содержащейся в описании платы STM32F429-DISCO:
Сначала сделаем класс, шевелящий ножкой PE2, потом — переделаем его на шаблонный вид. Идём в файл DefaultApp.h (как мы помним, это неправильно для реальной работы, но зато наглядно для текста) и создаём класс-наследник от Task. Что туда нужно добавить? Правильно, конструктор и функцию Execute(). Прекрасно, пишем (первая и последняя строки оставлены, как реперные, чтобы было ясно, куда именно пишем):
#include "maksRTOS.h"
class Blinker : public Task
{
public:
Blinker (const char * name = nullptr) : Task (name){}
virtual void Execute()
{
while (true)
{
GPIOE->BSRR = (1<<2);
GPIOE->BSRR = (1<<(2+16));
}
}
};
class DefaultApp : public Application
void DefaultApp::Initialize()
void DefaultApp::Initialize()
{
/* Начните код приложения здесь */
// Включили тактирование порта E
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOEEN;
// Линии PE2 и PE3 сделали выходами
GPIOE->MODER = GPIO_MODER_MODER2_0 | GPIO_MODER_MODER3_0;
// Подключили поток к планировщику
Task::Add (new Blinker ("Blink_PE2"));
}
Пытаемя трассировать — по шагам прекрасно работает. Что за чудо? Ну, это не проблемы ОС, это проблемы микроконтроллера, ему не нравятся рядом стоящие команды записи в порт. Разгадка этого эффекта – в настройках тока выходных транзисторов. Выше ток – больше звон, но выше быстродействие. По умолчанию, все выходы настроены на минимальном быстродействии. А у нас оптимизатор всё хорошо умял:
0x08004092 6182 STR r2,[r0,#0x18]
0x08004094 6181 STR r1,[r0,#0x18]
0x08004096 E7FC B 0x08004092
// Максимальный ток выходных транзисторов
GPIOE->OSPEEDR |= (3<<(2*2))|(3<<(3*2));
Но у сигнала будет явно неправильная скважность (Вверх, затем – вниз, затем – задержка на переход). Для улучшения сигнала поправим код основного цикла следующим образом:
virtual void Execute()
{
while (true)
{
GPIOE->BSRR = (1<<2);
asm {nop}
GPIOE->BSRR = (1<<(2+16));
}
}
};
Здесь получается вверх, затем – задержка на NOP, затем – вниз, затем – задержка на переход, что обеспечивает скважность, близкую к 50% (в комментариях ниже было точно вычислено, что реально 3 такта в единице и 5 тактов в нуле, но это ближе к 50%, чем 1 к 5, да и на имеющемся осциллографе разницу в верхней и нижней частях импульсов всё равно практически невозможно заметить). И быстродействия выхода уже хватает даже в малошумящем режиме. Частота выходного сигнала стала 168/(3+5) = 21 МГц.
Это мы наблюдаем работу планировщика. Задача у нас одна, но периодически у неё отбирают управление, чтобы проверить, нельзя ли передать его кому-то другому. При наличии отсутствия других задач, управление возвращается той единственной, которая есть. Что ж, добавляем вторую задачу, которая будет дёргать PE3. Поместим номер бита в переменную-член класса, а настраивать его будем через конструктор
class Blinker : public Task
{
int m_nBit;
public:
Blinker (int nBit,const char * name = nullptr) : Task (name),m_nBit(nBit){}
virtual void Execute()
{
while (true)
{
GPIOE->BSRR = (1<<m_nBit);
GPIOE->BSRR = (1<<(m_nBit+16));
}
}
};
void DefaultApp::Initialize()
{
/* Начните код приложения здесь */
// Включили тактирование порта E
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOEEN;
// Линии PE2 и PE3 сделали выходами
GPIOE->MODER = GPIO_MODER_MODER2_0 | GPIO_MODER_MODER3_0;
// Подключили поток к планировщику
Task::Add (new Blinker (2,"Blink_PE2"));
Task::Add (new Blinker (3,"Blink_PE3"));
}
template <int nBit>
class Blinker : public Task
{
public:
Blinker (const char * name = nullptr) : Task (name){}
virtual void Execute()
{
while (true)
{
GPIOE->BSRR = (1<<nBit);
asm {nop}
GPIOE->BSRR = (1<<(nBit+16));
}
}
};
Task::Add (new Blinker<2> ("Blink_PE2"));
Task::Add (new Blinker<3> ("Blink_PE3"));
Можно выбрать масштаб, на котором видны кванты времени. Заодно произведём замер и убедимся, что один квант действительно равен одной миллисекунде (с точностью до масштаба экрана осциллографа)
Можно убедиться, что планировщику всё так же нужно время для переключения задач (причём больше, чем в те времена, когда задача была одна)
Теперь давайте рассмотрим работу потоков с разными приоритетами. Добавим забавную задачу, которая «то потухнет, то погаснет»
public:
Funny (const char * name = nullptr) : Task (name){}
virtual void Execute()
{
while (true)
{
Delay (5);
CpuDelay (5);
}
}
};
Task::Add (new Blinker<2> ("Blink_PE2"));
Task::Add (new Blinker<3> ("Blink_PE3"));
Task::Add (new Funny ("FunnyTask"),Task::PriorityHigh);
Эта задача половину времени выполняет задержку без переключения контекста. Так как её приоритет выше остальных, то управление не будет передано никому другому. Половину времени задача спит. То есть, находится в заблокированном состоянии. То есть, в это время будут работать потоки с нормальным приоритетом. Проверим?
Собственно, что и требовалось доказать. Пауза равна пяти миллисекундам (выделено курсорами), а во время работы нормальных задач, контекст успевает 5 раз переключиться между ними. Вот другой масштаб, чтобы было видно, что это не случайность, а статистика
Наконец, переведём дёрганье порта из режима «Совсем дёрганный» в более реальный. До светодиодного доводить не будем. Скажем, сделаем период величиной в 10 миллисекунд
class Blinker : public Task
{
public:
Blinker (const char * name = nullptr) : Task (name){}
virtual void Execute()
{
while (true)
{
GPIOE->BSRR = (1<<nBit);
Delay (5);
GPIOE->BSRR = (1<<(nBit+16));
Delay (5);
}
}
};
Теперь подключаем амперметр. Для платы STM32F429-DISCO надо снять перемычку JP3 и включить прибор вместо неё, о чём сказано в документации:
Таааак, ещё одну теоретическую вещь проверили на практике. Тоже работает. А вот если бы мы мигали светодиодом, то он бы то потреблял, то не потреблял 10 мА, что на фоне измеренных значений — вполне существенно.
Ну, и напоследок заменим многозадачность на кооперативную. Для этого добавим конструктор к классу приложения
class DefaultApp : public Application
{
public:
DefaultApp() : Application (false){}
private:
virtual void Initialize();
};
И сделаем так, чтобы задачи после трёх импульсов в порт передавали друг другу управление. Также добавим задержки, чтобы на осциллографе задержка планировщика не уводила бы изображение другой задачи за экран.
template <int nBit>
class Blinker : public Task
{
public:
Blinker (const char * name = nullptr) : Task (name){}
virtual void Execute()
{
while (true)
{
for (int i=0;i<3;i++)
{
GPIOE->BSRR = (1<<nBit);
CpuDelay (1);
GPIOE->BSRR = (1<<(nBit+16));
CpuDelay (1);
}
Yield();
}
}
};
class DefaultApp : public Application
{
public:
DefaultApp() : Application (true){}
virtual ALARM_ACTION OnAlarm(ALARM_REASON reason)
{
while (true)
{
volatile ALARM_REASON r = reason;
}
}
private:
virtual void Initialize();
};
и попробуем вызвать какую-либо проблему. Например, создадим критическую секцию в задаче с обычным уровнем привилегий.
Blinker (const char * name = nullptr) : Task (name){}
virtual void Execute()
{
CriticalSection cs;
while (true)
{
GPIOE->BSRR = (1<<nBit);
Delay (5);
GPIOE->BSRR = (1<<(nBit+16));
Delay (5);
}
после чего запускаем на исполнение (F5). Моментально получаем останов (если не сработало — щёлкаем по пиктограмме «Stop»).
В строке, на которой произошёл останов, наводим курсор на переменную reason. Получаем следующий результат:
Ну что же, проверку первых основных теоретических выкладок мы завершили, можно переходить к следующему большому сложному разделу.