Что такое переключение контекста
Национальная библиотека им. Н. Э. Баумана
Bauman National Library
Персональные инструменты
Переключение контекста
Точное значение термина «переключение контекста» сильно зависит от области применения, чаще всего он означает «переключение потока или переключение процесса» или «только переключение процесса». Более точно можно выделить переключение потоков (переключение между двумя потоками в рамках данного процесса), переключение процесса (переключение между двумя процессами), переключение режима (переключение домена: между пользовательским режимом и режимом ядра в заданном потоке), переключение регистра и переключение адресного пространства (переключение между виртуальной и физической памятью). Затраты ресурсов сильно варьируются в зависимости от того, что за собой влечет переключение контекста, от вызова подпрограммы для выполнения небольших пользовательских задач до запуска требовательного к ресурсам процесса.
Содержание
Описание
При переключении контекста происходит сохранение и восстановление следующей информации [Источник 2] :
В ядре ОС с каждым потоком связаны следующие структуры:
Затраты
Переключение контекста обычно требует больших вычислительных затрат, и основной этап при проектировании операционной системы заключается в оптимизации использования переключения контекста. Переход от одного процесса к другому требует определенного времени для сохранения состояния регистров, обновления различных таблиц и списков и т.д. Например, в ядре Linux переключение контекста включает в себя переключение регистров, указателя стека, счетчика команд, но не включает в себя переключение адресного пространства.
Причины для переключения контекста
Выделяется три ситуации для переключения контекста:
При переключении контекста состояние выполняемого в данный момент процесса должно быть каким-то образом сохранено, чтобы в дальнейшем это состояние могло быть восстановлено.
Состояние процесса включает в себя все регистры, которые процесс может использовать, особенно счетчик команд (регистр, хранящий адрес текущей выполняемой команды), а также любые другие специфичные для операционной системы данные, которые могут быть необходимы. Эта информация хранится в структуре данных, называемой блоком управления процессом (англ. process control block, PCB ).
Блок управления процессом может храниться в стеке для каждого процесса в памяти ядра (в отличие от стека вызовов в пользовательском режиме) или в специально определенной операционной системой структуре данных. Дескриптор блока управления процессом добавляется в очередь готовых к выполнению процессов. Поскольку операционная система эффективно приостановила выполнение одного из процессов, она может затем переключать контекст, выбирая процесс из очереди готовых к выполнению процессов и восстанавливая его блок управления. При этом из блока управления запускается счетчик команд и может продолжаться выполнение выбранного процесса. Такие параметры, как приоритеты процесса и потоков внутри него, будут влиять на то, какой процесс будет выбран из очереди.
Процедура переключения контекста похожа на процедуры обработки прерываний и обращения к системным функциям, если не считать того, что ядро вместо предыдущего контекстного уровня текущего процесса восстанавливает контекстный уровень другого процесса. Причины, вызвавшие переключение контекста, при этом не имеют значения.
Производительность
Сам процесс переключения контекста занимает процессорное время из-за работы планировщика задач, использования буфера ассоциативной трансляции (англ. translation lookaside buffer, TLB ) и косвенно из-за необходимости распределения кэш памяти между несколькими задачами. Переключение между потоками внутри одного процесса может происходить быстрее, чем между двумя отдельными процессами, так как потоки используют общую виртуальную память вследствие этого буфер ассоциативной памяти не используется.
Аппаратная и программная реализация
Планирование процессов
Основным различием между preemptive и non-preemptive вариантами многозадачности является степень централизации механизма планирования задач. При вытесняющей многозадачности механизм планирования задач целиком сосредоточен в операционной системе, и программист пишет свое приложение, не заботясь о том, что оно будет выполняться параллельно с другими задачами. При этом операционная система выполняет следующие функции: определяет момент снятия с выполнения активной задачи, запоминает ее контекст, выбирает из очереди готовых задач следующую и запускает ее на выполнение, загружая ее контекст.
При невытесняющей многозадачности механизм планирования распределен между системой и прикладными программами. Прикладная программа, получив управление от операционной системы, сама определяет момент завершения своей очередной итерации и передает управление ОС с помощью какого-либо системного вызова, а ОС формирует очереди задач и выбирает в соответствии с некоторым алгоритмом (например, с учетом приоритетов) следующую задачу на выполнение. Такой механизм создает проблемы как для пользователей, так и для разработчиков.
Вся правда об ОСРВ от Колина Уоллса. Статья #4. Задачи, переключение контекста и прерывания
Идентификаторы задач (Task Identifiers)
Необходимо уметь идентифицировать каждую задачу в системе. Это требование важно и для других объектов ядра, но в задачах есть некоторые нюансы, которые соответствуют теме данной статьи.
Разработчики ОСРВ используют разные подходы к идентификации задач, но можно выделить четыре общие стратегии:
Переключение контекста
Переключение контекста — процедура передачи управления от одной задачи к другой. Эту тему стоит изучить поближе, поскольку то, как работает переключение контекста, является фундаментальным принципом работы ОСРВ.
Что такое задача?
Мы знаем, что задача — это квазинезависимая программа, которая делит процессорное время с рядом других задач под управлением ОСРВ. Но необходимо подумать о том, что действительно характеризует задачу.
Набор регистров
Задача — это, в конечном счете, уникальный набор значений регистра процессора. Они либо загружаются в регистры процессора (то есть задача является текущей), либо хранятся где-то до запланированного времени выполнения. В идеальном мире у центрального процессора было бы несколько наборов регистров, и каждый мог быть назначен для отдельной задачи. Подобное было реализовано для особых случаев. Много лет назад в серии TI 9900 от компании Texas Instruments было множество наборов регистров для каждого задания, но они были реализованы в основной памяти, что ограничивало производительность. Архитектура SPARC (раньше применялась в десктопных системах Unix) поддерживает множество наборов регистров в «кольцах доступа» (ring structure), но количество наборов все равно ограничено.
Внутренние данные
У задачи, вероятно, будет свой собственный стек, размер которого может быть задан отдельно для каждой задачи или может быть глобальным параметром для всех задач в системе. Это, наряду с регистрами, обеспечивает хранение данных конкретных задач. Могут быть другие области памяти для хранения данных, предназначенных для конкретной задачи.
Ресурсы общего пользования
Практически любые ресурсы могут быть разделены между задачами. Код может быть общим: либо определенные функции, либо целиком код задачи. Необходимо убедиться, что код является реентерабельным, в первую очередь, не должны использоваться статические переменные (указанные как статические или просто вне функции). Будьте осторожны со стандартными библиотечными модулями, которые не предназначены для встроенного использования; в них обычно много нереентерабельных функций.
Также возможно совместное использование данных, но необходимо обеспечить тщательный контроль доступа к ним. В идеале, только одна задача является «владельцем» данных в любой момент времени.
Как сохранить контекст
Когда задача перепланируется (то есть перестает быть текущей), ее набор регистров необходимо где-то сохранить. Есть как минимум две возможности:
Динамическое создание задач
Основной аспект архитектуры ОСРВ заключается в том, что ОСРВ является либо «статической», либо «динамической».
При использовании статической ОСРВ все определяется во время сборки приложения, в частности, количество задач в системе. Это логичный выход для встраиваемых приложений, которые обычно имеют ограниченную функциональность.
Динамическая ОСРВ запускает одну задачу (которая может быть специализированной «основной» задачей), а также создает и удаляет другие задачи по мере необходимости. Это позволяет системе адаптироваться к изменяющимся требованиям и является более близким аналогом десктопной системы, которая ведет себя именно таким образом. Статический/динамический вид также применяется к другим объектам ядра.
Требование к динамическому созданию задач
Эта возможность входит в большинство коммерческих ОСРВ. Однако только небольшая часть приложений действительно нуждается в динамическом режиме работы. Очень часто система запускается, создает все необходимые задачи (и другие объекты), а затем никогда больше ничего не создает и не уничтожает во время работы кода приложения. Возможность создания динамических задач стало формальностью. Один поставщик внедрил ее, все остальные последовали его примеру.
Примечательно, что стандарт OSEK / VDX требует статической архитектуры, даже при том, что это может относиться к довольно сложным приложениям. Результатом этих требований является невозможность реализовать OSEK / VDX с помощью враппера (wrapper), промежуточного слоя поверх обычной (динамической) ОСРВ.
Подводные камни динамического создания задач
Существует несколько проблем, связанных с динамическим режимом работы, которые могут вызывать беспокойство.
Во-первых, усложняется система, а это означает, что для структур данных, описывающих задачи (TCB), необходима дополнительная информация. Как правило, они реализованы в виде двунаправленных списков, что ведет к издержкам, связанным с объемом памяти.
Все данные, описывающие задачу, должны храниться в ОЗУ. Это неэффективно, так как большая часть из них может быть просто постоянными элементами данных, скопированными из ПЗУ. Кроме того, на процессорах более низкого уровня (микроконтроллерах) может не доставать оперативной памяти.
Вероятно, наибольшее беспокойство вызывает возможность непредсказуемого дефицита ресурсов, что может приводить к невозможности создания новых объектов. Поскольку суть системы реального времени заключается в ее предсказуемости, это неприемлемо. Таким образом, необходимо проявлять осторожность при использовании создания динамических задач (и других объектов).
Прерывания
Вполне возможно, что встраиваемая система реального времени может быть реализована без использования прерываний, но это нетипично.
Прерывания и ядро
Когда используется ОСРВ, обработчик прерываний (ISR) делается как можно легче, чтобы «красть» минимальное количество процессорного времени у запланированных задач. Часто устройство может просто обслуживаться, а любая требуемая задача будет поставлена в очередь для обработки. Помимо этого, трудно говорить в общем о прерываниях и их взаимодействии с ядрами, просто потому что они сильно варьируются. С одной стороны, разработчик ОСРВ может сделать так, что прерывания вообще не будут относиться к ядру, и программисту придется следить за тем, чтобы не слишком загружать планировщик задач, используя много процессорного времени в ISR. С другой стороны, ОСРВ может полностью контролировать всю подсистему прерываний. Ни один из описанных подходов не является правильным или неправильным, они просто разные.
Сохранение контекста
ISR всегда нужно сохранять «контекст», чтобы прерываемый код не подвергался воздействию вычислений ISR. В системе, реализованной без ОСРВ, это просто вопрос сохранения любых регистров, используемых ISR (обычно в стеке), и их восстановления перед возвратом. Некоторые процессоры имеют выделенный стек ISR, другие просто используют тот же стек, что и код приложения.
Когда используется ОСРВ, подход может быть точно таким же. Таким же образом стек, используемый ISR, может быть «заимствован» из текущей задачи, или это может быть другой стек, выделенный для прерываний. Некоторые ядра реализуют эту возможность, даже если сам процессор не поддерживает стек прерываний. Ситуация усложняется, если ISR делает вызов API, который влияет на планировщик задач. Это может привести к тому, что прерывание вернется к другой задаче от той, которая была запущена, когда произошло прерывание.
Прерывания и планировщик
Существует несколько обстоятельств, при которых код выполнения ISR может произвести возврат к другой задаче:
Тактовый таймер (Tick Clock)
Во встраиваемых системах часто встречается применение периодического «тактового таймера» (кванта времени). В некоторых ОСРВ он является обязательным компонентом. Как правило, наличие тактового таймера является опциональным, а его отсутствие просто исключает доступность определенных служб. Обработчик прерываний таймера обычно предоставляет четыре функциональные возможности:
Переключение контекста и простой вытесняющий планировщик для CortexM
С каждым годом курсовые для моих студентов становятся все объемнее. Например, в этом году одним из заданий была разработка метеостанции, ведь только ленивый не делает метеостанции, а студенты они по определению не ленивые, поэтому должны её сделать. Её можно быстро накидать в Cube или собрать на Ардуино, но задача курсового не в этом. Основная задача — самостоятельно, с нуля разобраться с модулями микроконтроллера, продумать архитектуру ПО и, собственно, закодировать все на С++, начиная от регистров и заканчивая задачами РТОС. Кому интересно, здесь пример отчета по такому курсовому
Так вот, появилась небольшая проблема, а именно, бесплатный IAR позволяет делать ПО размером не более 30 кБайт. А это уже впритык к размеру курсового в неоптимизированном виде. Анализ кода студентов выявил, что примерно 1/4 часть их приложения занимает FreeRtos — около 6 кБайт, хотя для того, чтобы сделать вытесняющую переключалку и управлялку задачами хватило бы, наверное… да байт 500 причем вместе с 3 задачами (светодиодными моргунчиками).
Эта статья будет посвящена тому, как можно реализовать Очень Простой Планировщик(он же SST), описанный в статье аж 2006 года и сейчас поддерживаемый Quantum Leaps в продукте Qp framework.
С помощью этого ядра очень просто реализовать конечный автомат, и оно очень хорошо может использоваться в небольших проектах студентами (и не только), которые могут получить дополнительно 5 кБайт в свое распоряжение.
Я попробую показать как можно реализовать такой планировщик самому. Чтобы не сильно перегружать статью, рассмотрю переключение контекста на CortexM0 у которого нет аппаратного модуля с плавающей точкой.
Все кто заинтересовался и хочет понять как можно переключать контекст, добро пожаловать под кат.
Небольшое отступление
Изначально я хотел описать, как работает планировщик в «нормальных» РТОС и потом уже описать, как он сделан в Простом Планировщике и показать пример такого планировщика на ядре CortexM4, но статья получалась довольно большой и непонятной, поэтому я решил её упросить (не уверен, что она стала понятнее, но точно меньше, хотя все равно большой). Поэтому я ввел небольшие ограничения и начальные условия:
И хотя такой планировщик в принципе можно запустить и на CortexM3 и даже на CortexM4 (с отключенным FPU блоком), для нормальной их поддержки, нужно будет внести небольшие изменения в обработчике PendSV и SVC исключений.
В общем делать его мы будем по-модному, на С++17, без указателей, интерфейсов, создания задач в рантайме и прочей «ерунды», а полагаться только на соmpile-time, чтобы всё-всё было определено, а по возможности проверено на этапе компиляции.
Введение
Собственно, в качестве введения наверное лучше всего подойдет цитата из выше указанной статьи 2006 года
Большую часть времени встроенные системы ждут какого-то события, такого как тик времени, нажатие кнопки, готовности АЦП или получения пакета данных. После распознавания события системы реагируют, выполняя соответствующие вычисления. Эта реакция может включать в себя работу с аппаратными модулями или создание вторичных событий бизнес логики, которые запускают другие внутренние функции. После завершения действия по обработке событий такие системы переходят в спящее состояние в ожидании следующего события.
Большинство RTOS для встроенных систем вынуждают программистов моделировать эти простые, дискретные реакции на события, используя задачи, разработанные как непрерывные бесконечные циклы.
По большому счету, вся программа — это один большой или небольшой конечный автомат. И наши старшие братья в мире ПО под «еще более нормальные» операционные системы давно уже имеют кучу механизмов для реализации конечных автоматов — потоки, корутины, фиберы — тому подтверждение.
В ПО же для микроконтроллеров каждый раз приходится либо использовать совсем неоптимальные вещи обычных операционных систем реального времени (передача событий от задачи к задаче, со всеми вытекающими (долгие переключения контекста, создание новых задач с большими стеками)), либо городить что-то свое, либо по старинке пользоваться обычным switchом.
В случае же с SST ядро и планировщик очень просты и ему не нужно управлять несколькими стеками. И основное отличие этого ядра является то, что оно требует чтобы все задачи выполнялись до завершения (Run to completion), используя один стек.
А это кстати решает одну из «вечных» возможных проблем с бесконечным циклом, ведь бесконечный цикл в С++ это вообще-то Undefined/Unspecified Behaviour (UB).
Спасибо Dubovik_a за уточнение: не все бесконечные циклы UB, в соответствии со стандартом, если внутри цикла есть одно из нижесказанного, то это уже не UB
Но в любом случае, нет таких циклов — нет UB, а заодно сделаем наш планировщик без единого указателя, чтобы, еще меньше UB проникли в код (не уверен, что код на С++ можно вообще написать без UB, но вдруг).
Перед тем как начинать статью, я хотел вначале найти простое объяснение, как переключить контекст на CortexM в интернете на русском языке, из более менее понятного и простого, нашел вот эту статью. Но я не уверен, что без дополнительного заглядывания в руководство по ядру CortexM3 из этого текста можно сразу все понять.
Есть еще статья на Хабре: Как сделать context switch на STM32.
Но даже если вы и прочитали эти статьи, все равно все выглядит как рисование совы.
Поэтому давайте вначале разберемся с алгоритмом переключения контекста, как это вообще происходит. И первым делом займемся изучением некоторых необходимых для создания планировщика понятий.
Команды CortexM микроконтроллеров
У CortexM бывает три набора команд:
Так вот наш CortexM0 поддерживает только Thumb набор, ну не считая парочки команд из Thumb-2, но закроем на это глаза.
На всякий случай, CortexM3 поддерживает Thumb-2 полностью.
Режимы работы процессора
Cortex-M имеет два режима работы: режим процесса (Thread) и режим обработчика (Handle):
Про стеки узнаем немного позже, а пока это вся информация по режимам, которую нужно знать для переключения контекста. И да, мы будет использовать только основной стек MSP.
CortexM0 регистры
CortexM0 имеет 16 регистров общего назначения:
И ряд регистров специального назначения:
Регистр указателя стека (r13/SP)
Я не буду подробно описывать что такое стек, есть множество статей на эту тему. Но для того, чтобы понять как он работает на CortexM архитектуре необходимо знать несколько моментов.
И хотя в нашей задаче нам не нужен стек процесса, для общего образования все таки уточню, что в каждый момент доступен только один из этих указателей. В режиме Handle указатель SP всегда указывает на MSP, а вот в режиме Thread указатель может указывать как на основной стек MSP, так и на стек процесса PSP. Какой именно сейчас стек используется, можно определить с помощью CONTROL регистра.
Выходя из режима Handle можно поменять стек указав волшебное значение при возврате из исключения или в регистре связи. Встречаем регистр связи.
Регистр связи (r14/LR)
У регистра связи две функции. Одна прямая — хранение адреса возврата:
И вторая не менее важная:
EXC_RETURN | Что значит |
---|---|
0xFFFFFFF1 | Возвращаемся в Handle режим, используем основной стек MSP |
0xFFFFFFF9 | Возвращаемся в Thread режим, используем основной стек MSP |
0xFFFFFFFD | Возвращаемся в Thread режим, используем стек процесса PSP |
Исключение
Исключение в ARM, это такой механизм, который позволяет прервать безмятежное течение программы. Исключение может быть вызвано программно с помощью инструкции вызова исключения или же вызвано в ответ на поведение системы, такое как прерывание, ошибка выравнивания или ошибка системы памяти.
Исключения бывают синхронные и асинхронные. Прерывания являются асинхронными исключениями. А вот например, ошибки связанные с доступом к памяти или выполнения инструкций — синхронные исключения.
И в целом разделяют две основные стадии исключения:
Момент, когда в микроконтроллере происходит некое важное событие, которое связано с исключением
Это когда микроконтроллер начинает выполнять определенную последовательность для входа в исключение, потом выполняет код обработчика исключения и в конце последовательность выхода из исключения. И в общем-то переход от состояния генерации исключения до состояния обработка исключения может быть мгновенным.
А теперь давайте поймем как происходит вход и выход из исключения, но для полноты картины прежде, посмотрим на кадр исключения.
Кадр исключения
Кадр исключения (Exception Frame). Так вот, это набор регистров, которые автоматически сохраняются при входе в исключение и восстанавливается из него при выходе из исключения. Кадр выглядит так:
В кадре исключения сохраняются регистры R0-R3, R12 и LR, PC, xPSR.
Остальные регистры R4-R11 не могут использоваться (в соответствии с C/C++ standard Procedure Call Standard for the ARM Architecture) в обработчике исключения и поэтому не входят в данный кадр.
Вход в Исключение
Это важный момент для понимания того, что происходит во время вхождения и выхода из прерывания.
Вход в прерывание возникает тогда, когда появляется ожидающее исключение с необходимым приоритетом и:
Когда микроконтроллер начинает обработку исключения он сохраняет кадр исключения в стеке. Эта операция по английски называется «stacking». По русски звучит странно, поэтому не буду переводить. При этом указатель стека перемещается на размер кадра исключения.
Как было уже сказано выше, стек исключения содержит кадр из 8 слов данных и подчиняется простым правилам.
Стек выравнен по 8 байтовому адресу (двум словам).
Стек содержит адрес возврата из исключения — адрес следующей инструкции в прерванной исключением подпрограмме. Это значение восстанавливается и загружается в PC во время возврата из исключения.
Микроконтроллер, а точнее контроллер прерывания считывает стартовый адрес обработчика исключения из таблицы векторов прерываний и когда «stacking» завершен, запускает выполнение обработчика этого прерывания. В то же время микроконтроллер записывает специальный код возврата — EXC_RETURN в регистр LR, как мы уже выяснили этот код показывает тип указателя стека (MSP или PSP) и в каком режиме был микроконтроллер до входа в исключение.
Если во время входа в исключение не произошло более высоко-приоритетного прерывания, процессор запускает выполнение обработчика исключения. Микроконтроллер автоматически изменяет статус исключения на активное.
Если более высокоприоритетное исключение произошло во время входа в исключение, то статус текущего исключения будет «ожидание». Так называемое «позднее прибытие».
В общем-то и все, исключение обработали, теперь надо из него выйти.
Возврат из исключения
Возврат из исключения происходит когда микроконтроллер находится в Handle режиме и выполняется одна и следующих инструкций, пытающихся установить PC в специальное EXC_RETURN значение :
Микроконтроллер сохраняет значение EXC_RETURN в LR при входе в исключение
Механизм исключений полагается на это значение, чтобы определить когда микроконтроллер завершит обработку исключения.
Биты[31:4]
Биты[3:0]
При возврате из исключения происходит обратная операция — unstacking, еще более странно переводящаяся на русский язык. При этом микроконтроллер загружает в PC адрес следующей инструкции из кадра исключения, и собственно переходит на её исполнение.
Я тут попытался нарисовать залипающую картинку, получилось не очень, но не пропадать же 2-часову труду зря.
Переключение контекста
В «нормальных» RTOS, идея работы с задачами состоит в том, чтобы PSP стек использовался отдельными задачами, а MSP стек использовался обработчиками исключений и ядром. Когда возникает исключение, контекст задачи помещается в текущий активный указатель стека PSP, а затем переключается на использование MSP для обработки исключения.
С одной стороны это хорошо — это подразумевает некое разделение между стеками обработчика исключений и задач, ваша задача всегда работает со стеком PSP и доступа к MSP нет.
С другой стороны, переключение контекста не такое быстрое, а из-за того, что каждая задача имеет свой стек, который обычно делают с запасом, возможен непреднамеренный расход ОЗУ.
Итак, контекст у нас должен переключаться по какому-то событию. Пусть это будет любое событие происходящее в прерывании, например, по таймеру, или приходу символа в UART, или любому другому, которое должно инициировать обработку чего-то. Как только произошло такое событие мы должны запустить планировщик, который найдет подходящую задачу и запустит её, при этом вытеснив уже запущенные менее приоритетные.
Логично, что такие события могут происходить из прерываний, т.е в режиме Handle, а вот планировщик и задачи должны быть запущены в режиме Thread. Как это сделать?
Каждый раз при выходе из любого прерывания в котором генерируется событие для переключения контекста мы будем генерировать исключение PendSV, и уже в нем делать магию по переключению контекста: в упрощенном виде это будет выглядеть примерно так:
Т.е. вместо того, чтобы в прерывании вызывать планировщик, мы сгенерируем исключение PendSV и уже при выходе из него запустим планировщик, который будет заниматься переключением задач.
Сразу же после выхода из прерывания, сгенерировавшего событие для какой-либо задачи, мы попадем в PendSV исключение, в котором должны:
На последнем пункте давайте остановимся поподробнее, потому что легко сказать, да как это сделать.
Вызов планировщика
Нам нужно вызвать планировщик из исключения PendSV, так чтобы он запустился в режиме Thread, но чтобы попасть в этот режим нужно выйти из PendSV.
Как вы помните при входе в исключение, микроконтроллер сохранил кадр исключения текущей задачи на стеке.
А если указатель стека так и останется на вершине этого кадра, то при вызове планировщика, т.е. выходе из прерывания, этот кадр пропадет, так как при выходе из исключения сделается unstacking.
Значит нам надо сделать так, чтобы, при вызове планировщика мы работали с другим кадром, не испортив при этом кадр вытесненной задачи. Т.е. к текущему указателю стека нужно добавить (а поскольку стек растет в сторону уменьшения адресов, то убавить) стек на размер еще одного такого же кадра, но с данными для вызова Планировщика.
И в этом кадре в PC мы положим адрес планировщика, в LR адрес возврата после работы планировщика, а в xPSR надо поставим 1 в бит T, который говорит о том, что мы работает с набором команд Thumb, а то выйдет исключение по ошибке выполнения инструкций.
Вот так мы руками поменяем наш стек в обработчике исключения PendSV для вызова планировщика:
Возврат из планировщика
Как только планировщик выполнил свою работу, нам нужно вернуться куда-то, где надо будет разрешить прерывания, а также сделать, что-то, что позволит вернуться к текущей прерванной задаче. Т.е. мы опять должны будем сгенерировать какое-то исключение, и в нем удалить тот кадр исключения, что мы добавили в предыдущем пункте. И уже при выходе из исключения, у нас сделается правильный unstacking с переходом на прерванную задачу.
Для лучшего понимания, я нарисовал целую картину, могут быть ошибки, но честно старался и в общем-то посыл передал верно.
Опа и все должно работать… Теперь тоже самое на lisp (не нашел, как вставить код на ассемблере, чтобы он корректно отображался, и были видны комментарии, поэтому вставил разметку как на lisp) ассемблере.
Планировщик
Ну а теперь посмотрим, как устроен Ооочень простой планировщик. Чтобы показать насколько он простой — сразу покажу картинку, она много прояснит: Всего 4 публичных метода, остальное скрыто от пользователя от греха подальше.
Как вы понимаете, вся суть тут заложена в методе Schedule() и он должен быть экстремально простым. Поэтому мы сделаем так, чтобы приоритет задачи определялся её положением в списке задач. Ну т. е., чтобы если мы задали бы задачи так:
То это бы означало, что приоритет HighPriorityTask — самый высокий, а idleTask — самый низкий. Это нам решит кучу проблем с сортировкой списка задач. Задачи всегда расположены в порядке уменьшения приоритета.
Тогда наш планировщик будет совсем-совсем простым.
Функция запуска задачи тоже проста как пять копеек:
Как видно, задача должна реализовывать метод OnEvent().
И да, мы же не хотели использовать указатели, поэтому задачи передаем через ссылки, как параметр шаблона.
И очень просто пробегаемся по этому списку, например, чтобы найти первую (самую высокоприоритетную) активную задачу:
Заметьте, никаких массивов указателей на задачи, а поэтому не существует даже теоретической возможности на выход за пределы массива
Собственно и запускаем на исполнение по такому же принципу:
Чтобы задача активировалась ей надо просигналить, ну например, случился таймаут канального уровня у какого-нибудь протокола (в Modbus RTU аж два таймера на 3,5 символа и 1,5 символ) и надо обработать событие по приему сообщения — да ради бога — посылаем из таймера задаче, обработчику приема сообщения, событие.
Выше я уже указывал, что нельзя просто так взять и запустить планировщик из прерывания, нужно из этого прерывания как-то выйти вначале, а потом уже запустить — и это мы делаем путем вызова PendSV.
В примере я сделал события от таймеров, которые построил на основе системного таймера. Обработчик прерывания системного таймера показан ниже:
А таймеры просто постят события
Задачи
Задачи должны наследоваться от TaskBase. В него я запихнул атрибут событие, чтобы у каждой задачи был такой атрибут, но так как он статический, то чтобы этот атрибут был разный для каждой задачи, применил странно-рекурсивный шаблон.
В коде это будет так:
Задачи сами просто так не запустятся, нужно, чтобы кто-то им запостил событие, а такими сущностями в моем примере являются таймеры. Их надо настроить для каждой задачи индивидуально.
Ну и все запускаем.
.
Все лежит в Github Исходный код. Можно просто папку открыть в Clion.
Заключение
4 задачи моргания светодиодом + сам планировщик занимает 564 байт кода + 14 байт константных данных и 17 байт ОЗУ без оптимизации.
Module | ro code | ro data | rw data |
---|---|---|---|
taskerschedule.cpp | 508 | 14 | 17 |
interrupthandlers.s | 56 | 0 | 0 |
При включенной оптимизации размер кода уменьшается на 120 байт.