Переключение контекста и простой вытесняющий планировщик для CortexM

image

С каждым годом курсовые для моих студентов становятся все объемнее. Например, в этом году одним из заданий была разработка метеостанции, ведь только ленивый не делает метеостанции, а студенты они по определению не ленивые, поэтому должны её сделать. Её можно быстро накидать в Cube или собрать на Ардуино, но задача курсового не в этом. Основная задача — самостоятельно, с нуля разобраться с модулями микроконтроллера, продумать архитектуру ПО и, собственно, закодировать все на С++, начиная от регистров и заканчивая задачами РТОС. Кому интересно, здесь пример отчета по такому курсовому


Так вот, появилась небольшая проблема, а именно, бесплатный IAR позволяет делать ПО размером не более 30 кБайт. А это уже впритык к размеру курсового в неоптимизированном виде. Анализ кода студентов выявил, что примерно 1/4 часть их приложения занимает FreeRtos — около 6 кБайт, хотя для того, чтобы сделать вытесняющую переключалку и управлялку задачами хватило бы, наверное… да байт 500 причем вместе с 3 задачами (светодиодными моргунчиками).


Эта статья будет посвящена тому, как можно реализовать Очень Простой Планировщик(он же SST), описанный в статье аж 2006 года и сейчас поддерживаемый Quantum Leaps в продукте Qp framework.


С помощью этого ядра очень просто реализовать конечный автомат, и оно очень хорошо может использоваться в небольших проектах студентами (и не только), которые могут получить дополнительно 5 кБайт в свое распоряжение.


Я попробую показать как можно реализовать такой планировщик самому. Чтобы не сильно перегружать статью, рассмотрю переключение контекста на CortexM0 у которого нет аппаратного модуля с плавающей точкой.


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


Небольшое отступление


Изначально я хотел описать, как работает планировщик в "нормальных" РТОС и потом уже описать, как он сделан в Простом Планировщике и показать пример такого планировщика на ядре CortexM4, но статья получалась довольно большой и непонятной, поэтому я решил её упросить (не уверен, что она стала понятнее, но точно меньше, хотя все равно большой). Поэтому я ввел небольшие ограничения и начальные условия:


  • Рассматриваем ядро CortexM0, ну или микроконтроллеры с ARM архитектурой, поддерживающие только
    • Thumb набор команд
    • Имеющие только привилегированный режим
    • Не имеющие аппаратного блока с плавающей точкой
  • Используем только основной стек MSP
  • Считаем, что микроконтроллер имеет как минимум двухстадийный конвейер
  • Не используем указатели
  • Не используем виртуальные функции (абстрактных классов), только статический полиморфизм

И хотя такой планировщик в принципе можно запустить и на CortexM3 и даже на CortexM4 (с отключенным FPU блоком), для нормальной их поддержки, нужно будет внести небольшие изменения в обработчике PendSV и SVC исключений.


В общем делать его мы будем по-модному, на С++17, без указателей, интерфейсов, создания задач в рантайме и прочей "ерунды", а полагаться только на соmpile-time, чтобы всё-всё было определено, а по возможности проверено на этапе компиляции.


Введение


Собственно, в качестве введения наверное лучше всего подойдет цитата из выше указанной статьи 2006 года


Большую часть времени встроенные системы ждут какого-то события, такого как тик времени, нажатие кнопки, готовности АЦП или получения пакета данных. После распознавания события системы реагируют, выполняя соответствующие вычисления. Эта реакция может включать в себя работу с аппаратными модулями или создание вторичных событий бизнес логики, которые запускают другие внутренние функции. После завершения действия по обработке событий такие системы переходят в спящее состояние в ожидании следующего события.
Большинство RTOS для встроенных систем вынуждают программистов моделировать эти простые, дискретные реакции на события, используя задачи, разработанные как непрерывные бесконечные циклы.

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


В случае же с SST ядро и планировщик очень просты и ему не нужно управлять несколькими стеками. И основное отличие этого ядра является то, что оно требует чтобы все задачи выполнялись до завершения (Run to completion), используя один стек.


А это кстати решает одну из "вечных" возможных проблем с бесконечным циклом, ведь бесконечный цикл в С++ это вообще-то Undefined/Unspecified Behaviour (UB).
Спасибо Dubovik_a за уточнение: не все бесконечные циклы UB, в соответствии со стандартом, если внутри цикла есть одно из нижесказанного, то это уже не UB


The implementation may assume that any thread will eventually do one of the following:
  • terminate,
  • make a call to a library I/O function,
  • access or modify a volatile object, or
  • perform a synchronization operation or an atomic operation

Но в любом случае, нет таких циклов — нет UB, а заодно сделаем наш планировщик без единого указателя, чтобы, еще меньше UB проникли в код (не уверен, что код на С++ можно вообще написать без UB, но вдруг).


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


Есть еще статья на Хабре: Как сделать context switch на STM32.
Но даже если вы и прочитали эти статьи, все равно все выглядит как рисование совы.


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


Команды CortexM микроконтроллеров


У CortexM бывает три набора команд:


  • ARM — Основной 32 битный набор команд.
  • Thumb — Сокращённая система 16 битных команд.
  • Thumb-2 — 16 битный Thumb набор + немного 32 битных команд, эдакая смесь ARM и Thumb, чтобы получить преимущества обоих систем команд.

Так вот наш CortexM0 поддерживает только Thumb набор, ну не считая парочки команд из Thumb-2, но закроем на это глаза.
На всякий случай, CortexM3 поддерживает Thumb-2 полностью.


Режимы работы процессора


Cortex-M имеет два режима работы: режим процесса (Thread) и режим обработчика (Handle):


  • Режим Handle используется при обработке исключений(все обработчики прерываний работают в этом режиме, хотя прерывания — это лишь подмножество исключений) и работает только с основным MSP стеком
  • Режим Thread используется для выполнения пользовательского кода и может работать с основным стеком(MSP) или стеком процесса (PSP)
    Переключение из одного режима в другой происходит автоматически в момент входа или выхода из исключения.

Про стеки узнаем немного позже, а пока это вся информация по режимам, которую нужно знать для переключения контекста. И да, мы будет использовать только основной стек MSP.


CortexM0 регистры


CortexM0 имеет 16 регистров общего назначения:


image


  • Младшие регистры (r0-r7)
    • Старшие регистры (r8-r12)
    • Регистр указателя стека SP (r13) для текущего контекста (r8-r12)
    В зависимости от контекста может быть либо MSP (указателем основного стека) либо PSP ( указателем стека процесса). Но мы же не заморачиваемся, используем только MSP.
  • Регистр связи LR (r14)
  • Регистр счетчика команд PC(r15)


И ряд регистров специального назначения:


  • Регистр состояния xPSR, он содержит в себе флаги результатов выполнения арифметических действий, состояние выполнение программы и номер обрабатываемого в данный момент исключения. Доступ к полям регистра может осуществляться через три псевдорегистра, позволяющие обращаться к определенным областям xPSR:
    • Регистр состояния приложения APSR содержит флаги результатов выполнения арифметических операций
    • Регистр состояния прерывания EPSR содержит номер обрабатываемого исключения
    • Регистр состояния выполнения IPSR содержит бит показывающий в каком режиме исполняются команды микроконтроллера Thumb или ARM, а так как, мы выяснили, что
      CortexM0 может работать только в Thumb режиме, то этот бит всегда должен быть равен 1, иначе микроконтроллер допустит недопустимое.
  • Регистр PRIMASK, в нем всего один бит, запрещающий все прерывания с конфигурируемым приоритетом
  • Регистр CONTROL, управляющий выбором режима (Привилегированный или нет(Это еще что такое? Да сколько этих режимов?, не волнуйтесь, для CortexM0 режим всегда привилегированный, поэтому просто не обращайте на это внимание)) и выбором стека (основной MSP или стек процесса PSP)

Регистр указателя стека (r13/SP)


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


  • Указатель стека всегда выравнен по слову и его два младшие бита должны быть равны 0.
  • Стек всегда двигается от старших адресов к младшим.
  • Указатель стека используется для доступа к стеку с помощью инструкций POP и PUSH.
  • Указатель стека может быть модифицирован с помощью инструкций LDR, STR, SUB, ADD и так далее
  • Имеет двойное назначение и может являться:
    • MSP(Main Stack Pointer) — указателем на основной стек,
    • PSP (Program Stack Pointer) — указателем на стек процесс PSP.

И хотя в нашей задаче нам не нужен стек процесса, для общего образования все таки уточню, что в каждый момент доступен только один из этих указателей. В режиме Handle указатель SP всегда указывает на MSP, а вот в режиме Thread указатель может указывать как на основной стек MSP, так и на стек процесса PSP. Какой именно сейчас стек используется, можно определить с помощью CONTROL регистра.


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


Регистр связи (r14/LR)


У регистра связи две функции. Одна прямая — хранение адреса возврата:


  • Регистр связи используется для хранения адреса возврата из подпрограмм и функций, вызванных командой BL.

И вторая не менее важная:


  • Во время входа и возврата из исключения в LR сохраняется EXC_RETURN код, который указывает какой режим и какой стек нужно использовать после возврата из исключения.

EXC_RETURN Что значит
0xFFFFFFF1 Возвращаемся в Handle режим, используем основной стек MSP
0xFFFFFFF9 Возвращаемся в Thread режим, используем основной стек MSP
0xFFFFFFFD Возвращаемся в Thread режим, используем стек процесса PSP

Исключение


Исключение в ARM, это такой механизм, который позволяет прервать безмятежное течение программы. Исключение может быть вызвано программно с помощью инструкции вызова исключения или же вызвано в ответ на поведение системы, такое как прерывание, ошибка выравнивания или ошибка системы памяти.
Исключения бывают синхронные и асинхронные. Прерывания являются асинхронными исключениями. А вот например, ошибки связанные с доступом к памяти или выполнения инструкций — синхронные исключения.


И в целом разделяют две основные стадии исключения:


  • Генерация исключения

Момент, когда в микроконтроллере происходит некое важное событие, которое связано с исключением


  • Обработка или активация исключения

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


А теперь давайте поймем как происходит вход и выход из исключения, но для полноты картины прежде, посмотрим на кадр исключения.


Кадр исключения


Кадр исключения (Exception Frame). Так вот, это набор регистров, которые автоматически сохраняются при входе в исключение и восстанавливается из него при выходе из исключения. Кадр выглядит так:


image В кадре исключения сохраняются регистры R0-R3, R12 и LR, PC, xPSR.


Остальные регистры R4-R11 не могут использоваться (в соответствии с C/C++ standard Procedure Call Standard for the ARM Architecture) в обработчике исключения и поэтому не входят в данный кадр.


Вход в Исключение


Это важный момент для понимания того, что происходит во время вхождения и выхода из прерывания.
Вход в прерывание возникает тогда, когда появляется ожидающее исключение с необходимым приоритетом и:


  • Микроконтроллер находится в Thread режиме
  • Исключение имеет приоритет выше, чем обрабатывающееся в данный момент исключение. В таком случае исключение с высшим приоритетом вытесняет текущее исключение, по другому это называется вложенными исключениями.

Когда микроконтроллер начинает обработку исключения он сохраняет кадр исключения в стеке. Эта операция по английски называется "stacking". По русски звучит странно, поэтому не буду переводить. При этом указатель стека перемещается на размер кадра исключения.



Как было уже сказано выше, стек исключения содержит кадр из 8 слов данных и подчиняется простым правилам.


  • Стек выравнен по 8 байтовому адресу (двум словам).


  • Стек содержит адрес возврата из исключения — адрес следующей инструкции в прерванной исключением подпрограмме. Это значение восстанавливается и загружается в PC во время возврата из исключения.



Микроконтроллер, а точнее контроллер прерывания считывает стартовый адрес обработчика исключения из таблицы векторов прерываний и когда "stacking" завершен, запускает выполнение обработчика этого прерывания. В то же время микроконтроллер записывает специальный код возврата — EXC_RETURN в регистр LR, как мы уже выяснили этот код показывает тип указателя стека (MSP или PSP) и в каком режиме был микроконтроллер до входа в исключение.


Если во время входа в исключение не произошло более высоко-приоритетного прерывания, процессор запускает выполнение обработчика исключения. Микроконтроллер автоматически изменяет статус исключения на активное.


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


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


Возврат из исключения


Возврат из исключения происходит когда микроконтроллер находится в Handle режиме и выполняется одна и следующих инструкций, пытающихся установить PC в специальное EXC_RETURN значение :


  • POP инструкция которая загружает значение из стека в PC.
  • BX инструкция, использующая любой регистр

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


Биты[31:4]


  • EXC_RETURN значения должны быть установлены в 0xFFFFFFF. Когда микроконтроллер загружает эти биты в PC, это дает понять ядру, что операция не является обычной, а означает завершение обработки прерывания. Как результат такого "оповещения" запускается последовательность возврата из исключения.

Биты[3:0]


  • EXC_RETURN значения указывают на требуемый стек возврата и режим процессора.

При возврате из исключения происходит обратная операция — unstacking, еще более странно переводящаяся на русский язык. При этом микроконтроллер загружает в PC адрес следующей инструкции из кадра исключения, и собственно переходит на её исполнение.


Я тут попытался нарисовать залипающую картинку, получилось не очень, но не пропадать же 2-часову труду зря.


Залипающая картинка


Но я люблю статику, поэтому вот обычная картинка:

Переключение контекста


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

В "нормальных" RTOS, идея работы с задачами состоит в том, чтобы PSP стек использовался отдельными задачами, а MSP стек использовался обработчиками исключений и ядром. Когда возникает исключение, контекст задачи помещается в текущий активный указатель стека PSP, а затем переключается на использование MSP для обработки исключения.


С одной стороны это хорошо — это подразумевает некое разделение между стеками обработчика исключений и задач, ваша задача всегда работает со стеком PSP и доступа к MSP нет.


С другой стороны, переключение контекста не такое быстрое, а из-за того, что каждая задача имеет свой стек, который обычно делают с запасом, возможен непреднамеренный расход ОЗУ.


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


Логично, что такие события могут происходить из прерываний, т.е в режиме Handle, а вот планировщик и задачи должны быть запущены в режиме Thread. Как это сделать?


Каждый раз при выходе из любого прерывания в котором генерируется событие для переключения контекста мы будем генерировать исключение PendSV, и уже в нем делать магию по переключению контекста: в упрощенном виде это будет выглядеть примерно так:


 static void OnTimerExpired()
 {
    // Послать событие нужно задаче, в данном примере targerThread
    Tasker::PostEvent<targetThread>(eventsToPost) ; 
    // Вызвать исключение PendSV для запуска планировщика и вытеснения текущей задачи
    Tasker::IsrExit() ;  
 }
 .....// Tasker::IsrExit()
 static void IsrExit()
 {
    // Генерируем исключение PendSV
    SCB::ICSR::PENDSVSET::PendingState::Set();  
 }

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


Сразу же после выхода из прерывания, сгенерировавшего событие для какой либо задачи, мы попадем в PendSV исключение, в котором должны:


  • Скинуть флаг генерации исключения PendSV
  • Запретить все прерывания
  • Вызвать планировщик

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


Вызов планировщика


Нам нужно вызвать планировщик из исключения PendSV, так чтобы он запустился в режиме Thread, но чтобы попасть в этот режим нужно выйти из PendSV.
Как вы помните при входе в исключение, микроконтроллер сохранил кадр исключения текущей задачи на стеке.
А если указатель стека так и останется на вершине этого кадра, то при вызове планировщика, т.е. выходе из прерывания, этот кадр пропадет, так как при выходе из исключения сделается unstacking.


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


И в этом кадре в PC мы положим адрес планировщика, в LR адрес возврата после работы планировщика, а в xPSR надо поставим 1 в бит T, который говорит о том, что мы работает с набором команд Thumb, а то выйдет исключение по ошибке выполнения инструкций.


Вот так мы руками поменяем наш стек в обработчике исключения PendSV для вызова планировщика:



Возврат из планировщика


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


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



  • Вначале работает SomeTask
  • Какое-то прерывание посылает HighPriorityTask задаче событие и вызывает PendSV
  • Начинается стадия входа в исключение, выполняется stacking, формируется кадр исключения и сохраняется на MSP стеке
  • Запускается обработчик исключения PendSV
  • В нем мы добавляем к стеку еще один рукотворно-созданный кадр исключения, куда записываем адрес адрес планировщика, адрес возврата после работы планировщика и набор команд Thumb
  • Выходим из исключения, выполняем стадию unstacking, в которой микроконтроллер вытаскивает наши подменные PC, LR и xPSR, и в соответствии с ними переходит на планировщик.
  • Планировщик делает свою работу уже в режиме Thread — ищет новую высокоприоритетную задачу, и если нашел — просто запускает ее, также в режиме Thread, она тоже может вытесниться еще более высокоприоритетной.
  • Выполняется высокоприоритетная задача
  • По завершению возвращается в планировщик
  • А планировщик возвращается в место, где вместе с разрешением прерываний атомарно генерируется SVC исключение
  • И снова начинается процесс входа в исключение SVC
  • В обработчике исключения SVC мы убираем со стека тот фиктивный кадр исключения, что добавили в PendSV и оставляем только кадр от вытесненной задачи
  • Выходим из SVC, выполняем unstacking и попадаем обратно в вытесненную задачу

Опа и все должно работать… Теперь тоже самое на lisp (не нашел, как вставить код на ассемблере, чтобы он корректно отображался, и были видны комментарии, поэтому вставил разметку как на lisp) ассемблере.


  RSEG CODE:CODE:NOROOT(2)

  PUBLIC  HandlePendSv
  PUBLIC  HandleSvc

  EXTERN  Schedule

; Исключение выполняется в Handle режим, но планировщик должен запускаться в
; Thread режиме. Этот обработчик резервирует кадр исключения и переключается в
; Thread режим через этап выхода из исключения для перехода в Schedule функцию
HandlePendSv: 
  ; Очищаем бит запроса на PendSv, путем установки PendSvClr бита(#27) в ICSR регистре
  ; Адрес ICSR(Interrupt Control and State Register) регистра 0xE000ED04
  LDR     r3,=0xE000ED04
  LDR     r1,=1<<27
  ; Запрещаем все прерывания
  CPSID   i
  ; Собственно устанавливаем бит PendSvClr 
  STR     r1,[r3]
  ; Когда мы вернемся из прерывания XPSR должен стоять T - bit(1 << 24), говорящий
  ; о том, что у нас Thumb набор команд, а то если он не будет стоять, исполнение
  ; команд накроется медным тазом.
  LDR     r3,=1<<24           
  ; Когда мы вернемся из прерывания, мы должны попасть в Schedule, поэтому в PC должен
  ; быть записан адрес функции Schedule и он должен быть четным
  LDR     r2,=Schedule - 1
  ; А завершив функцию Schedule мы должны вернуться по адресу ScheduleReturn
  ; Запишем этот адрес в LR
  LDR     r1,=ScheduleReturn
  ; Теперь резервируем место под кадр исключения для выхода из исключения
  SUB     sp,sp,#8*4            
  ADD     r0,sp,#5*4 ; и перемещаемся в место для сохранения XPSR, PC, LR
  ; И сохраняем в него новые XPSR, PC, LR ( r3- xPSR, R2 - PC, r1-LR)
  STM     r0!,{r1-r3}           
  ; r0 = 0xFFFFFFF9 - Thread режим и используем MSP стек
  LDR     r0,=0xFFFFFFF9              
  ; Выходим из исключения со значением 0xFFFFFFF9 и попадаем после этого в Schedule
  BX      r0

; Возвращаемся сюдя из функции Schedule, разрешаем прерывание и
; и вызываем исключение SVC, чтобы вернуться в вытесненную задачу
ScheduleReturn:
  CPSIE   i
  ; SVC будет выполнен вместе с CPSIE, так как Cortext M0 имеет двухстадийный конвейер.
  ; Между командой разрешения прерывания и генерации SVC чисто теоретически может 
  ; вклиниться еще прерывание, но у нас двух-стадийный конвейер и поэтому обе команды
  ; уже в нем, ничто не может прерывать вызов SVC,  ребята с Quantum Leaps используют тут 
  ; генерацию  NMI, чтобы сделать запрос на исключение NMI перед разрешением прерываний
  SVC #0

; Собственно после запроса на SVC исключение попадаем сюда
HandleSvc:
  ; Удаляем кадр исключения который мы добавили в PendSV,
  ; нам нужно оставить только кадр исключения от вытесненной задачи
  ADD     sp,sp,#(8*4)

; Возвращаемся в вытесненную задачу
  BX      lr
  END

Планировщик


Ну а теперь посмотрим, как устроен Ооочень простой планировщик. Чтобы показать насколько он простой — сразу покажу картинку, она много прояснит: Всего 4 публичных метода, остальное скрыто от пользователя от греха подальше.



Как вы понимаете, вся суть тут заложена в методе Schedule() и он должен быть экстремально простым. Поэтому мы сделаем так, чтобы приоритет задачи определялся её положением в списке задач. Ну т. е., чтобы если мы задали бы задачи так:


struct myTasker: Tasker<HighPriorityTask, NormalPriorityTask, LowPriorityTask,  idleTask> {} ;

То это бы означало, что приоритет HighPriorityTask — самый высокий, а idleTask — самый низкий. Это нам решит кучу проблем с сортировкой списка задач. Задачи всегда расположены в порядке уменьшения приоритета.


Тогда наш планировщик будет совсем совсем простым.



  static void Schedule()
  {
      const auto preemptedTaskId = activeTaskId; // сохраним номер текущей задачи
      auto nextTaskId = GetFirstActiveTaskId(); // получить номер первой активной задачи

      // Если номер задачи меньше номера текущей задачи,
      // то у неё выше приоритет и её надо запустить
      while (nextTaskId < activeTaskId)
      {
        activeTaskId = nextTaskId;
        CallTask(nextTaskId); // вызываем задачу и сбрасываем установленное событие
        nextTaskId = GetFirstActiveTaskId(); // вдруг есть еще активные задачи
      }
      activeTaskId = preemptedTaskId; //восстановим номер текущей задачи    
  }

Функция запуска задачи тоже проста как пять копеек:



__forceinline  template<const auto& task>
static void CallTaskHelper()
{
  task.events = noEvents;   // скидываем событие
  __enable_interrupt() ;    // разрешаем прерывание, чтобы задачу можно было вытеснить
  task.OnEvent();           // запускаем задачу
  __disable_interrupt() ;   // запрещаем снова прерывание
}

Как видно, задача должна реализовывать метод OnEvent().
И да, мы же не хотели использовать указатели, поэтому задачи передаем через ссылки, как параметр шаблона.


template<const auto& ...tasks>
class Tasker
{
...
}

И очень просто пробегаемся по этому списку, например, чтобы найти первую (самую высокоприоритетную) активную задачу:


 static constexpr size_t GetFirstActiveTaskId()
 {
   return GetFirstActiveTask<tasks...>(0U);
 }

 __forceinline template<const auto& task, const auto& ...args>
 static constexpr size_t GetFirstActiveTask(size_t result)
 {
   if constexpr (sizeof...(args) != 0U)
   {
     if (task.events != noEvents)
     {
       return result;
     }
     else
     {
       auto res = result + 1 ;
       return GetFirstActiveTask<args...>(res);
     }
   }
   else
   {
     if (task.events != noEvents)
     {
       return result;
     } else
     {
        return sizeof...(tasks); // возвращаем несуществующую задачу.
     }
   }
 }

Заметьте, никаких массивов указателей на задачи, а поэтому не существует даже теоретической возможности на выход за пределы массива


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


static void CallTask(size_t id)
{
  return CallTaskById<tasks...>(id, 0U);
}

__forceinline template<const auto& task, const auto& ...args>
static void CallTaskById(size_t id, size_t result)
{
   if constexpr (sizeof...(args) != 0U)
   {
     if (result == id)
     {
       CallTaskHelper<task>() ;
     }
     else
     {
       auto res = result + 1 ;
       CallTaskById<args...>(id, res);
     }
   }
   else
   {
     if (result == id)
     {
       CallTaskHelper<task>() ;
     }
     else
     {
          //если не нашли задачу, то ничего и не вызываем, но можно например уйти в спячку
     }
   }
}

Чтобы задача активировалась ей надо просигналить, ну например, случился таймаут канального уровня у какого-нибудь протокола (в Modbus RTU аж два таймера на 3,5 символа и 1,5 символ) и надо обработать событие по приему сообщения — да ради бога — посылаем из таймера задаче, обработчику приема сообщения, событие.


//сделаем возможность посылать событие сразу нескольким задачам
template<const auto& ...targetTasks>  
static void PostEvent(const tStateEvents events)
{
     const CriticalSection cs;
     (targetTasks.events |= events, ...);
     if (scheduleLockedCounter == 0U)
     {
         Schedule();
     }
 }

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


 __forceinline static void IsrEntry()
 {
   assert(scheduleLockedCounter != 255U);
   ++scheduleLockedCounter;
 }

 __forceinline static void IsrExit()
 {
   assert(scheduleLockedCounter != 0U);
   --scheduleLockedCounter;
   SCB::ICSR::PENDSVSET::PendingState::Set(); 
 }

В примере я сделал события от таймеров, которые построил на основе системного таймера. Обработчик прерывания системного таймера показан ниже:


template <typename Tasker, typename ...Timers>
struct TaskerTimerService
{
  static void OnSystemTick()
  {
     Tasker::IsrEntry() ;
     (Timers::OnTick(), ...) ;
     Tasker::IsrExit() ;
  }
} ;

А таймеры просто постят события


template <typename Tasker, std::uint32_t TimerFrequency, std::uint32_t msPeriod, tStateEvents eventsToPost, const auto& ...targetThreads>
class TaskerTimer {
  public:
    static void OnTick()
    {
      --ticksRemain ;
      if (ticksRemain == 0U)
      {       
        ticksRemain = ticksReload ;
        Tasker::template PostEvent<targetThreads...>(eventsToPost) ;
      }
    }
...
}

Задачи


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



В коде это будет так:


struct TargetThread: public TaskBase<TargetThread>
{
}

Также я сделал 3, нет 4 задачи, 3 из которых моргают светодиодами, а одна ничем не моргает, хотя должна, но её все время вытесняют .


Задачи
struct TargetThread: public TaskBase<TargetThread>
{
    void OnEvent() const
    {
      // Когда кто-то нам просигналил, мы переключим светодиод.
      GPIOC::ODR::Toggle(1<<8);    // светодиод PortC.8
    }

};

template<typename SimpleTasker, auto& threadToSignal>
struct Thread1 : public TaskBase<Thread1<SimpleTasker, threadToSignal>>
{
  void OnEvent() const
  {
    GPIOC::ODR::Toggle(1<<9);  //светодиод PortC.9
    SimpleTasker::PostEvent<threadToSignal>(1); // Посылаем сигнал какой-то другой задаче
  }
};

template<typename SimpleTasker, auto& threadToSignal>
struct Thread2 : public TaskBase<Thread2<SimpleTasker, threadToSignal>>
{
    void OnEvent() const
    {
        GPIOC::ODR::Toggle(1<<5); // светодиод PortC.5
        for (int i = 0; i < 4000000; ++i)  // имитация бурной деятельности
        {
        };
        SimpleTasker::PostEvent<threadToSignal>(1); // Посылаем сигнал какой-то другой задаче
        test ++ ;
    }
 private:
    inline static int test ;
};

class myTasker;
inline constexpr TargetThread targetThread;
// Задаем, что сигналить хотим targetThread
inline constexpr Thread1<myTasker, targetThread> myThread1; 
inline constexpr Thread2<myTasker, targetThread> myThread2;

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



//Для myThread1, пусть таймер будет на 1001 мс
using MyThread1Timer = TaskerTimer<myTasker, 1'000UL,
                                   1001UL, // time in ms
                                   1,
                                   myThread1>;

//Для myThread1, пусть таймер будет ровно на 1000 мс
using MyThread2Timer = TaskerTimer<myTasker, 1'000UL,
                                   1000UL, // time in ms
                                   1,
                                   myThread2>;
//Для idleTask, пусть таймер будет ровно на 100 мс, но она будет постоянно вытесняться
using IdleTimer = TaskerTimer<myTasker, 1'000UL,
                                   100UL, // time in ms
                                   1,
                                   idleTask
                                   >;

using tRtosTimerService = TaskerTimerService<myTasker, MyThread1Timer, MyThread2Timer, IdleTimer>;

Ну и все запускаем...



.
Все лежит в Github Исходный код. Можно просто папку открыть в Clion.


А тут можно посмотреть Полный код с примером под IAR 8.40.2


Заключение


4 задачи моргания светодиодом + сам планировщик занимает 564 байт кода + 14 байт константных данных и 17 байт ОЗУ без оптимизации.


Module ro code ro data rw data
taskerschedule.cpp 508 14 17
interrupthandlers.s 56 0 0

При включенной оптимизации размер кода уменьшается на 120 байт.


Module ro code ro data rw data
taskerschedule.cpp 388 11 17
interrupthandlers.s 56 0 0

Ресурсы:
Build a Super Simple Tasker


ARMv6-M Architecture Reference Manual


QuantumLeaps/qpc


QuantumLeaps/Preemption Scenarios in QK on ARM Cortex-M


Multitasking on Cortex-M(0)class MCU


Cortex-M0 Technical Reference Manual


Планировщик задач для ARM Cortex-M3: пример реализации

AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 31

    +1
    Спасибо за статью, сам изобретал недавно велосипед на эту тему. Теперь знаю как лучше сделать.
    Упомяну также, что надо следить за нечетностью адресов, которая возникает из-за отличия команд ARM и Thumb-2.
      0

      Да точно, там в ассемблере я как раз это уточнил.

      0
      В «нормальных» RTOS, идея работы с задачами состоит в том, чтобы PSP стек использовался отдельными задачами, а MSP стек использовался обработчиками исключений и ядром.
      [...]
      С другой стороны, переключение контекста не такое быстрое, а из-за того, что каждая задача имеет свой стек — дополнительный расходуется ОЗУ.

      А в вашем случае откуда берётся экономия ОЗУ?
        0

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

          0

          Хотя, есть небольшой выигрыш, не надо сохранять контекст задачи, регистры R4-R11 и если с форматом работаем регистры флоат модуля тоже...

          +3

          Очень крутая статья. И очень сложный материал.
          А почему вы нормальные бесплатные компиляторы не используете, gcc то бишь? Там нет никаких искусственных ограничений по размеру

            0

            Спасибо, да так исторически сложилось, на работе только IAR (сертификат безопасности IEC 61508 у него есть https://www.iar.com/iar-embedded-workbench/certified-tools-for-functional-safety/, а это значит можно использовать библиотечные функции и оптимизацию, а то бы пришлось все функции покрывать юнит тестами, либо самим писать), ну и студентов на него же подсадил, но вариант с gcc для студентов рассматриваю...

            0
            "… нам надо немного подредактировать стек" понятно, в следующий раз вначале статьи укажите, что бы не терять время.
              0

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

                +1
                А как иначе-то? Как вынырнуть из обработчика исключения не туда откуда нырнул в этот обработчик, а в другое место, при этом не трогая стек «вручную»?
                0
                Здесь тоже пошагово создать свою RTOS:
                www.edx.org/course/real-time-bluetooth-networks-shape-the-world
                  0
                  А почему планировщик не может работать непосредственно в прерывании?
                    0

                    Тогда нельзя будет вызвать высокопритетную задачу синхронно.


                    В примере, например, Thread1Task постит событие TargetTask синхронно, т.е. прямо из Thread режима и тут же вызывает планировщик, который сразу вызывает задачу TargetThread без всяких PendSv. Т. Е. на переключение на высокопритетную задачу тут не тратится ничего, переключение практически мгновенное. Если планировщик вызывать из прерывания, то для такого случая придется вызывать исключение, которое вызовет планировщик. А зачем эти лишние телодвижения?


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


                    А из прерывания в любом случае надо будет на задачу переходить и переключаться в Thread режим.

                    0
                    Нужно больше асмы!!!
                    github.com/AVI-crak/Rtos_cortex
                      0

                      Только начал читать, и сразу непонятка: UB — это что? UncleBob, UnsignedBit, UnversalBatut?

                        0

                        Подправил… Undefined/Unspecified Behaviour (UB)

                        0
                        Спасибо за статью!
                        В кадре исключения сохраняются регистры R0-R3, R12 и LR, PC, xPSR.
                        -все верно! Но если мы вручную прерываем задачу, которая работала с регистрами R4-R7, то нам их тоже необходимо сохранить в стеке, иначе при возврате из прерывания данные регистры могут быть испорчены и программа развалится.
                          0

                          Я добавил там "Остальные регистры R4-R11 не могут использоваться (в соответствии с C/C++ standard Procedure Call Standard for the ARM Architecture) в обработчике исключения и поэтому не входят в данный кадр."

                            +1

                            так как..

                              0

                              Пока правил, что-то нажал не так и все пропало… не удобно на телефоне писать.
                              Еще раз.


                              А я понял вопрос: Это и есть вся идея вообще этого планировщика. Дело в том, что для компилятора все выглядит так, что вы начали работать в SomeTask, пришло прерывание и вы как бы продолжили в ней работать (хотя на самом деле попали в Планировщик) и как бы из SomeTask вы вызываете HighPriority Task,


                              Т.е. для компилятора все выглядит как прерывание задачи SomeTask, возврат в SometTask, вызов HightPriority Task из SomeTask, обратный возврат в SomeTask снова прерывание SomeTask и снова возврат в SomeTask.


                              Мы не вызываем Планировщик, мы просто возвращаемся из прерывания в место, где находится код планировщика, но по сути мы выполняем, всю ту же SomeTask, с её же регистрами и стеком.


                              Т.е. по сути Планировщик — это часть SomeTask..., поэтому никакие регистры мы не порушим, потому что когда мы вызовем HighPriority task компилятор будет использовать соглашение об вызове C++ и все сделает сам. В этом и есть фишка такого неблокирующего, Run to completion планировщика, там в статье про это написано более понятно :).
                              https://www.embedded.com/build-a-super-simple-tasker/


                              P.S. Если бы это была "нормальная" РТОС с задачами с бесконечным циклом (не Run Completion), то да — там надо было бы все это дело сохранить, а в нашем случае, все делается автоматом:


                              using the machine's natural stack protocol
                                0

                                Я походу пьяный был — такой бред написал.
                                Теперь правильный ответ:


                                Все функции используют соглашения об вызовах Си и С++, при входе в функцию, она должна сохранить preserved registers (R4-R11), если эта функция их использует. Так вот, например, функцию Schedule() компилятор переведет во что-то такое:


                                Schedule:
                                  PUSH   {R3-R5, LR}  ; Собираемся использовать R4,R5, поэтому сохраняем их
                                  LDR.N   R5, [PC, #0x1c]
                                  LDR      R4, R5   ; А вот и использование
                                  ...
                                  POP {R0, R4, R5, PC} ; Восстанавливаем R4, R5

                                После PendSV и вызова


                                ; Выходим из исключения со значением 0xFFFFFFF9 и попадаем после этого в Schedule
                                  BX      r0

                                Попадаем прямиком на


                                Schedule: 
                                  PUSH   {R3-R5, LR}  ; Собираемся использовать R4,R5, поэтому сохраняем их

                                Вот и весь фокус с регистрами R4-R11, управление и слежение за ними — отдаем на откуп компилятору, он точно сделает все как надо.

                                  0
                                  Да, компилятор все сделает за Вас если у Вас ассемблерная вставка в сишный файл. А если функция написана в ассемблерном файле (так конечно давно уже никто не делает), с компилятором ARMCC(Keil), приходится в ручную делать PUSH и POP регистров R4-R11.
                                    0

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

                              0

                              del

                                +1
                                Спасибо. Приятно читать статьи от профессионалов. Отличный слог, простое и понятное изложение, фактическая точность — просто отлично. А современное железо делает ее крайне актуальной. Будет куда отправлять тех, кто кричит что хочу современное железо, а не абстрактный или устаревший процессор. И да — решение совершенно не перегружено. В той же FreeFTOS понять что-то становится сложно. Кросплатформенность просто так не дается. Поэтому просто отлично.

                                Одно жаль — почему-то с телефона комметарий не пошел, потому пишу как только смог добраться до компьютера. Не буду про студентов — опять ветка не туда уйдет. А вот про указатели таки скажу. Мне кажется, что задача отказа от указателей (как минимум применительно к данной задаче) — она фантомная. И фантомно же решена. В том смысле, что указатели никуда не делись. Просто код написан так, что операции с ними явно не упоминаются. Вам удалось сделать это просто классно. Снимаю шляпу. Но в целом — не кажется Вам что это несколько… хм… неправильно. Архитектурно процессор оперирует указателями. Вся адресная арифметика это именно они. И если мы пишем код непосредственно на нем исполняющися, то… по мне знание и понимание адресной арифметики один из краеугольных камней. Ладно, когда речь идет о Java, Python или ком-то подобном. Но С/С++? Да по голому железу. В конце-концов PC и SP тоже указатели…
                                  0

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


                                  Я хочу, чтобы программист использовал возможности компилятора, который сам сделает так как оптимальнее на данной машине и проверит все ваши задумки на этапе компиляции. В иделе — чтобы и бизнес логику проверил тоже(но это уже больше для языков поддерживающие формальные спецификации и формальные проверки и доказательства типа Haskell, да даже Rust и Ada более подходят для этого, чем С++, но к сожалению их мало кто знает, а С++ программистов пока много)


                                  На Си, да там не обойтись без указателей, там это столп на котором все построено, но вот на С++ можно частично все это переложить на компилятор. Самое интересное то, что при оптимизации компилятор превратит ваш код без указателей на С++ в такой компактный, что на Си будет трудно такое написано без тонкого продумывания алгоритма. Т.е. на самом деле во многих случаях код будет не только надежнее, но еще и компактнее. У меня есть идея написания небольшой статьи по этому поводу, показать, как на Си и С++ одна и та же задача решается, при этом на С++ при оптимизации все получается компактнее, чем на таком же уровне оптимизации на Си. Я уже пытался это один раз сделать, но там все равно есть массив и указатель на объект интерфейса… https://habr.com/ru/post/347980/
                                  А ведь можно обойтись и без массива и без циклов и без указателей вообще.

                                    0
                                    Ну, мы с Вами так или иначе не первый раз пересекаемся в комментариях. Я бы очень хотел работать с Вами или Вашими студентами. Очень может быть что код выпускаемый компанией стал бы надежнее (что главное), а может даже и быстрее и проще сопровождаемым. Увы, но пока понимание как написать такие вещи красиво есть у очень небольшого числа людей. Я приверженец «классического C» как «платформо независимого ассемблера с элементами структурного синтаксиса» (с) OCTAGRAM Увы, мой опыт говорит о том, что по крайней мере пока от этого подхода не уйти. Да и плюсы от ухода сомнительны. Должна прийти «та молодая шпана, что сотрет нас с лица земли» (с) Чайф и своим результатам покажет динозаврам на дверь. Но строго по тексту от Чайф'а: «Ее нет, нет, не-ет. Hет, нет, не-ет...» Так что жду Ваших студентов. И это вполне серьезно. Лучше таких знатоков плюсов, чем тех кто кричит «дайте Linux, а лучше Windows — без них не могем».

                                    Но жизнь забавно поворачивается. Два моих учителя спорили о том, что лучше С или ассемблер. Один кричал: «ты на асме пишешь как на С — у тебя сплошные макросы», второй отвечал: «за то указателями я кручу куда как более виртуозно и производительно». Первый парировал: «а вот о сохранении регистров и целостности стека я даже не думаю, да и в критических местах ассемблерные вставки никто не запрещал». И не было числа тем спорам. А сейчас вот уже и С с пьедестала низкоуровневого языка двигают. Сначала Java замахнулась, но не смогла. Теперь вот то Rust, то С++. И ведь подвинут. Вопрос только кто именно. И, самое главное, на что мне на старости лет переучиваться придется. И нужен ли я буду хоть кому-то со своим С, как нужны сейчас спецы по коболу (и частично фортрану).

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

                                    Но все равно пишите. Вас приятно читать. Мне плюсы не сильно знакомы, но пока вроде ничего неподьемного нет. Мало ли пригодится где. И еще раз — статья просто шикарна.
                                      +1
                                      Упомянутая песня написана не «Чайфом», а Борисом Гребенщиковым. Она вышла в «Синем альбоме» Аквариума в 81-году, за 4 года до образования «Чайфа». А ошибки в компиляторах, бывают весьма серьёзные — но от языка это не сильно зависит. Потому что компиляторы давно уже не компилируют отдельные операторы, а выделяют из кода более сложные конструкции для лучшей оптимизации. Даже простой switch иногда неправильно компилируется на высоком уровне оптимизации.
                                        0
                                        Спасибо за поправку. Да, я в курсе что БГ был первым. Но мне в 81-ом было всего три, и, как следствие мне она знакома в исполнении Чайфа. Однако фактическая точность безусловно важна. Абсолютно согласен и принимаю.

                                        Про ошибки в компиляторах и уровни оптимизации. Тот же компилятор С от Sun позволял себе оптимизировать даже ассемблерные вставки. Крайне сомнительная функциональность, которая к счастью отключалась. Вообще я бы поспорил с утверждением «от языка не сильно зависит». Зависит довольно серьезно. Чем проще «внутренний мир» языка, тем меньше простора для оптимизции. Тот же С сам по себе прост. Его за то и любят. По сути его «внутренний мир» эквивалентен «внутреннему миру» практически любого современного процессора. И именно по этой причине не все можно пихать в switch() и в нем настолько неудобная работа со строками. Это обратная сторона переносимости. Да и потом — всем котроллерщикам известна простая истинна — самый большой уровень оптимизации совсем не значит самый лучший. Все ответственные места просто необходимо проверять на ассемблере. Благо JTAG/SWD с современными IDE это позволяют без утомительного изучения промежуточного ассемблера как это было некоторое время назад. И да, очень часто приходится бороться с «шибко умным» компилятором. Впрочем, как правило код пишется так что его уму особо разгуляться негде.

                                        Хотя надо еще посмотреть что будет получаться на выходе у того же Rust'а когда код на нем не будет содержать unsafe блоков (за что ратуют фанаты Rust, и без чего он превращается просто в очередной диалект С/С++).
                                  0
                                  Кому интересно, здесь пример отчета по такому курсовому
                                  Решил вспомнить молодость и почитал. Возник вопрос к Вам как к преподавателю: почему позволяете студентам так небрежно относиться к курсовым работам? Количество ошибок правописания зашкаливает.
                                    +1

                                    Да, согласен, оформление там не очень, но — 1. это черновая, первая версия, которую они прислали на почту для проверки, подписанная и проверенная на нормоконтроле уже лучше. 2. — да я не очень строго отношусь к оформлению, возможно зря. Буду строже относится.

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

                                  Самое читаемое