FreeRTOS: практическое применение, часть 4 (управление ресурсами) |
![]() |
Добавил(а) microsin | ||||||||
[4.1. Введение: о чем говорится в части 4] Имеется потенциальная возможность возникновения конфликта в многозадачной системе, если одна задача начинает получать доступ к ресурсу, но не завершила этот доступ до выхода из состояния Running. Если задача оставила ресурс в незавершенном состоянии, то доступ к тому же самому ресурсу со стороны другой задачи или прерывания может привести к порче данных или другой аналогичной ошибке. [Предыдущая часть FreeRTOS: практическое применение, часть 3 (управление прерываниями)] Вот несколько примеров: 1. Доступ к периферийным устройствам Рассмотрим следующий сценарий, когда две задачи пытаются записать данные в LCD (жидкокристаллический дисплей): · Выполняется задача A, и начинает запись в LCD строки "Hello world". Теперь LCD будет отображать испорченную строку “Hello wAbort, Retry, Fail?orld”. 2. Операции Read, Modify, Write (чтение, модификация, запись) Листинг 57 показывает строки кода C и соответствующие им операторы ассемблера (архитектура ARM7). Можно видеть, что значение PORTA сначала прочитано из памяти в регистр, модифицировано в регистре, и затем записано обратно в память. Такая стандартная последовательность действий называется операцией Read, Modify, Write.
Листинг 57. Пример последовательности Read, Modify, Write (чтение, модификация, запись) Эта последовательность действий является 'не атомарной', поскольку занимает больше одной инструкции до полного завершения, и может быть прервана посередине. Рассмотрим следующий сценарий, когда две задачи пытаются обновить память, привязанную к регистру под именем PORTA: · Задача A загружает в регистр значение из PORTA - это часть операции Read. В результате задача A обновляет и записывает обратно устаревшее, недостоверное значение PORTA. Задача B успела изменить PORTA между получением копии PORTA задачей A и моментом времени, когда задача A изменяет эту свою копию и записывает её обратно в PORTA. В результате задача A перезаписывает модификацию PORTA, которую сделала задача B, и значение PORTA оказывается испорченным. Этот пример использует регистр периферии, но тот же самый принцип может быть применен для операций Read, Modify, Write над глобальными переменными приложения. 3. Неатомарный доступ к переменным Обновление несколько полей в структуре или обновление переменной с размером больше, чем натуральный размер слова архитектуры микроконтроллера (например, обновление 32-битной переменной на 16-битном процессоре) - это все примеры неатомарных операций. Если эта операция будет прервана, то в результате получится потеря или порча данных. 4. Реентерабельность (reentrant) функции Функция является реентерабельной, если её можно безопасно вызвать из более чем одной задачи, или как из задач, так и из прерываний одновременно. Каждая задача имеет свой собственный стек и свой собственный набор значений регистров. Если функция не делает попыток доступа к другим данным, кроме как к собственным, выделенным в стеке, или если она сохраняет используемые ею регистры, то она является реентерабельной. Листинг 58 показывает пример реентерабельной функции. Листинг 59 показывает пример функции, которая не является реентерабельной.
Листинг 58. Пример реентерабельной (reentrant) функции
Листинг 59. Пример функции, которая не реентерабельна Взаимное исключение (mutual exclusion, mutex) Доступ к ресурсу, который является либо общим для задач, либо общим для задач и прерываний, нуждается в управлении с использованием техники 'взаимного исключения' (mutual exclusion), чтобы обеспечить целостность данных в любой момент времени. Цель состоит в том, чтобы обеспечить только для одной задачи эксклюзивное использование общего ресурса, начиная с момента обращения к нему до момента окончания доступа (до возвращения ресурса в непротиворечивое состояние). FreeRTOS предоставляет несколько возможностей, которые можно использовать для реализации взаимного исключения (mutual exclusion), однако самый лучший метод взаимного исключения (когда это возможно) - разработка приложения таким способом, что ресурсы не делаются общими, и к каждому ресурсу получает доступ только одна задача. Общий обзор части 4 Эта часть дает читателям хорошее понимание следующего: · Когда и почему необходимы менеджмент ресурсов и управление ими. [4.2. Критические секции и приостановка шедулера] Основные понятия по критической секции В общем случае критическая секция - это регион кода, окруженный вызовами макросов taskENTER_CRITICAL() и taskEXIT_CRITICAL(), как показано в листинге 60. Критические секции также известны как критические регионы.
Листинг 60. Использование критической секции для защиты доступа к регистру Проекты примеров, которые сопровождают эту книгу, используют функцию под названием vPrintString(), чтобы вывести строки на стандартный вывод - такой как окно терминала для выполняемых приложений Open Watcom DOS. Функция vPrintString() вызывается из многих различных задач, так что в теории её реализация должна использовать защиту доступа к стандартному выводу с использованием критической секции, как показано в листинге 61.
Листинг 61. Одна из возможных реализаций функции vPrintString() Критические секции, реализованные таким способом, являются очень грубым методом обеспечения взаимного исключения. Они работают просто путем запрета прерываний либо полностью, либо до уровня приоритета прерываний, установленного константой configMAX_SYSCALL_INTERRUPT_PRIORITY - в зависимости от используемого порта FreeRTOS. Вытесняющее переключение контекста может произойти только изнутри прерывания, так что пока прерывания запрещены, задача вызвавшая taskENTER_CRITICAL() гарантированно останется в состоянии Running до выхода из критической секции. Критические секции должны быть удержаны как можно более короткими, потому что иначе они повлияют на время ответа на прерывание. Каждый вызов taskENTER_CRITICAL() должен быть полностью парным с вызовом taskEXIT_CRITICAL(). По этой причине стандартный вывод не должен быть защищен с использованием критической секции (как показано в листинге 61), потому что запись в терминал может быть относительно долгой операцией. Также эмулятор DOS и поддержка вывода терминала Open Watcom не совместимы с этой формой взаимного исключения, так как вызовы библиотеки оставляют прерывания разрешенными. Примеры в этой части рассматривают альтернативные решения этой проблемы. Критические секции безопасны для взаимного вкладывания, потому что ядро подсчитывает глубину вложения. Выход из критической секции произойдет только тогда, когда глубина вложения вернется к нулю - только когда один вызов taskEXIT_CRITICAL() выполняется с предшествующим ему вызовом taskENTER_CRITICAL(). Приостановка (или, иначе говоря, блокировка) шедулера Критические секции могут быть также созданы приостановкой шедулера. Приостановка шедулера иногда также известна как 'блокировка' шедулера. Обычно критические секции защищают область кода от доступа со стороны других задач и прерываний. Критическая секция, реализованная путем приостановки шедулера, защищает область кода только от доступа других задач, прерывания при этом останутся разрешенными. Критическая секция, которая слишком длинная для реализации путем запрета прерываний, может вместо этого быть реализована путем приостановки шедулера, однако возобновление (отмена приостановки) шедулера может быть потенциально относительно долгой операцией, так что для каждого случая нужно тщательно проанализировать, какой метод лучше использовать. API функция vTaskSuspendAll()
Листинг 62. Прототип API функции vTaskSuspendAll() Шедулер приостанавливается путем вызова vTaskSuspendAll(). Приостановка шедулера защищает от переключения контекста, однако оставляет разрешенными прерывания. Если прерывания запрашивают переключения контекста, пока шедулер приостановлен, то запрос удерживается в ожидании и будет выполнен только после возобновления работы шедулера. Функции API FreeRTOS не должны вызываться, когда шедулер приостановлен. API-функции, которые потенциально могут привести к переключению контекста (такие как vTaskDelayUntil(), xQueueSend(), и т. п.), также не должны вызываться во время приостановленного состояния планировщика. Обратите внимание, что нельзя даже вызывать функцию osDelay, потому что отсчет времени в средствами RTOS прекращается! Однако пользоваться функцией HAL_Delay внутри критической секции (когда планировщик приостановлен) все еще можно. API функция xTaskResumeAll()
Листинг 63. Прототип API функции xTaskResumeAll() Шедулер прекращает приостановку (возобновляет работу) после вызова xTaskResumeAll(). Функцию xTaskResumeAll можно вызывать только из задачи, т. е. она не может быть вызвана тогда, когда планировщик находится на стадии инициализации (до запуска планировщика). Таблица 18. Значение возврата функции xTaskResumeAll()
Можно безопасно делать вложенные друг в друга вызовы vTaskSuspendAll() и xTaskResumeAll(), потому что ядро подсчитывает глубину вложений. Шедулер возобновит работу только тогда, когда счетчик глубины вложений вернется к нулю - гарантия того, что каждый вызов xTaskResumeAll() был обязательно после вызова vTaskSuspendAll(). Листинг 64 показывает реальную реализацию vPrintString(), которая приостанавливает шедулер для защиты доступа к выводу на терминал.
Листинг 64. Реализация функции vPrintString() [4.3. Мьютексы (и двоичные семафоры)] Мьютекс - это специальный тип двоичного семафора, который используется для управлением доступа к ресурсу, который используется совместно двумя или большим количеством задач. Слово MUTEX произошло от "MUTual EXclusion" (взаимное исключение). Когда используется сценарий взаимного исключения, мьютекс можно концептуально рассмотреть как токен, связанный с совместно используемым ресурсом. Для законного доступа к ресурсу задача должна сначала 'взять' токен (стать держателем токена). Когда задача - держатель токена завершила работу с ресурсом, она должна 'отдать' токен обратно. Только когда токен свободен, другая задача может взять токен и тогда получить доступ к общему ресурсу. Задаче не разрешено обращаться к общему ресурсу, за исключением того случая, когда она держит токен. Этот механизм показан на рисунке 36. Несмотря на то, что мьютексы и семафоры имеют много общих характеристик, сценарий на рисунке 36 (где мьютексы используется для взаимного исключения) полностью отличается от сценария рисунка 30 (где двоичные семафоры используются для синхронизации). Главное отличие - в том, что происходит с семафором после того, как он был получен: · Семафор, который используется для взаимного исключения, должен быть всегда возвращен. Рис. 36. Взаимное исключение, реализованное с использованием мьютекса Механизм работает только по инициативе разработчика приложения. Нет никаких причин, почему бы задаче не получить доступ к ресурсу в любой момент, однако каждая задача "подчиняется" правилу доступа, что она обязательно должна сначала стать держателем мьютекса. API функция xSemaphoreCreateMutex() Мьютекс является типом семафора. Хендлы к различным типам семафоров FreeRTOS сохраняются в переменной типа xSemaphoreHandle. Перед тем, как мьютекс может быть реально использован, он сначала должен быть создан. Для создания семафора типа мьютекс используется API функция xSemaphoreCreateMutex().
Листинг 65. Прототип API функции xSemaphoreCreateMutex() Таблица 19. Значение возврата функции xSemaphoreCreateMutex()
Пример 15. Написание заново функции vPrintString() для использования семафора Этот пример создает новую версию функции vPrintString() под именем prvNewPrintString(). В примере вызывается новая функция prvNewPrintString() из нескольких задач. Функция prvNewPrintString() функционально идентична vPrintString(), но использует мьютекс для управления доступом к стандартному выводу вместо обычной критической секции. Реализация prvNewPrintString() показана в листинге 66.
Листинг 66. Реализация функции prvNewPrintString() Функция prvNewPrintString() постоянно вызывается двумя экземплярами задачи под названием prvPrintTask(). Между вызовами используется случайное время задержки. Параметр задачи используется для передачи уникальной строки в каждый экземпляр задачи. Реализация prvPrintTask() показана в листинге 67.
Листинг 67. Реализация функции prvPrintTask() для примера 15 Как обычно, функция main() просто создает мьютекс, создает задачи, и запускает шедулер. Реализация показана в листинге 68. Два экземпляра prvPrintTask() создаются с разными приоритетами, так чтобы задача с меньшим приоритетом могла быть вытеснена задачей с более высоким приоритетом. Мьютекс используется для того, чтобы обеспечить взаимоисключающий доступ каждой задачи к выводу на терминал, даже когда одна задача вытеснена другой - выводимые строки будут отображаться корректно и не будут испорчены. Частота вытеснений может быть повышена путем уменьшения максимального времени нахождения задач в состоянии Blocked, которое по умолчанию установлено в значение 0x1FF тиков.
Листинг 68. Реализация функции main() для примера 15 Вывод, производимый примером 15, показан на рисунке 37. Возможная последовательность выполнения описана на рисунке 38. Рис. 37. Вывод, который производит при выполнении пример 15 На рисунке 37 видно, что как и ожидалось, строки выводятся на терминал без искажений. Случайный порядок строк - результат случайного периода задержки, используемого задачами. Рис. 38. Возможная последовательность выполнения для примера 15 Инверсия приоритета Рисунок 38 демонстрирует одну потенциальную ловушку, в которую можно попасть, используя мьютекс для обеспечения взаимного исключения. Рассмотренная последовательность выполнения показывает, что задача 2 с более высоким приоритетом ждет низкоприоритетную задачу 1, чтобы получить контроль над мьютексом. Такой случай, когда выполнение высокоприоритетной задачи откладывается низкоприоритетной задачей, называется 'инверсией приоритета' (priority inversion). Это нежелательное поведение может быть еще более ухудшено, если запустится задача со средним приоритетом в то время, как высокоприоритетная задача ждет семафор - в результате получится, что высокоприоритетная задача ждет низкоприоритетную задачу, когда низкоприоритетная задача даже не имеет возможности запуститься! Такой наихудший сценарий показан на рисунке 39. Рис. 39. Наихудший возможный сценарий инверсии приоритетов Наследование приоритета Мьютексы и двоичные семафоры FreeRTOS очень просты, и различаются друг от друга только тем, что мьютексы предоставляют базовый механизм 'наследования приоритета'. Наследование приоритета - схема, которая минимизирует негативный эффект от инверсии приоритета. Наследование приоритета не 'исправляет' инверсию приоритета, а просто значительно снижает её воздействие. Применение наследования приоритета делает математический анализ поведения системы более сложным, поэтому не рекомендуется использовать наследование приоритета, если этого можно как-то избежать. Наследование приоритета работает через временное повышение приоритета держателя мьютекса до приоритета задачи с самым высоким приоритетом, которая пытается получить тот же самый мьютекс. Низкоприоритетная задача, которая удерживает мьютекс, 'наследует' приоритет задачи, которая ждет мьютекс. Это демонстрируется на рисунке 40. Приоритет держателя мьютекса автоматически сбрасывается обратно в свое исходное значение, когда держатель мьютекса возвращает мьютекс обратно. Рис. 40. Наследование приоритета минимизирует эффект от инверсии приоритета. Поскольку, во-первых, необходимо избегать инверсии приоритета, и поскольку, во-вторых, FreeRTOS предназначена для микроконтроллеров с ограниченными ресурсами памяти, механизм наследования приоритетов реализован мьютексами только в базовой форме, которая подразумевает, что задача в каждый момент времени удерживает только один мьютекс. Глухая блокировка, deadlock (или "мертвые объятия") 'Deadlock' - другая потенциальная ловушка в использовании мьютексов для взаимного исключения. Deadlock иногда известен под более драматическим именем 'смертельные объятия'. Deadlock произойдет, когда две задачи не могут работать, поскольку они обе ждут ресурса, который удерживается третьей задачей. Представим себе следующий сценарий, где задача A и задача B обе хотят получить мьютекс X и мьютекс Y в следующем порядке действий: 1. Задача A выполняется и успешно получает мьютекс X. В конце этого сценария задача A ждет мьютекса, удерживаемого задачей B, а задача B ждет мьютекса, удерживаемого задачей A. Deadlock произошел, потому что ни одна из задач дальше не может выполняться. Как и с инверсией приоритетов, наилучший метод избежать деадлока - рассмотреть такую возможность на этапе разработки приложения, и разработать систему так, чтобы такая ситуация в принципе не смогла бы возникнуть. На практике деадлок небольшая проблема для малых встраиваемых систем, поскольку их разработчики могут иметь хорошее понимание работы всего приложения в целом, и могут быстро идентифицировать и устранить проблему, где она возникает. [4.4. Задачи привратника (GATEKEEPER)] Задачи гейткипера предоставляют чистый метод реализации взаимного исключения без всякого беспокойства о инверсии приоритета или деадлоке. Задача гейткипера - это задача, единолично владеющая ресурсом. Только задача гейткипера может напрямую получить доступ к ресурсу - любая другая задача, нуждающаяся в доступе к ресурсу, может это делать только косвенно, используя службы гейткипера. Пример 16. Написание заново функции vPrintString() для использования задачи гейткипера Пример 16 также предоставляет альтернативную реализацию vPrintString(), на этот раз используется задача гейткипера для получения доступа к стандартному выводу. Когда задача хочет записать сообщение в терминал, она не использует напрямую функцию печати, но вместо этого отправляет сообщение привратнику. Гейткипер использует очередь FreeRTOS для организации последовательного доступа к терминалу. Внутренней реализации задачи не требуется взаимное исключение, так как только одна задача может обращаться к терминалу напрямую. Задача гейткипера почти все свое время проводит в состоянии Blocked, ожидая появления сообщений на очереди. Когда сообщение поступило в очередь, гейткипер просто выводит его в stdout перед возвратом в состояние Blocked для ожидания следующего сообщения. Реализация задачи гейткипера показана в листинге 70. Прерывания могут отправить данные в очередь, так что ISR могут безопасно использовать службы гейткипера для вывода сообщений в терминал. В этом примере используется функция хука тиков для вывода сообщения каждые 200 тиков. Хук тиков (или callback) - функия, которая вызывается ядром во время каждого прерывания тика. Для использования функции хука тика нужно: · Установить configUSE_TICK_HOOK в 1 в файле FreeRTOSConfig.h.
Листинг 69. Имя и прототип для функции хука тиков (tick hook) Функции хука тика исполняются в контексте прерывания тика, так что она должна быть как можно проще и короче, использовать только управляемое пространство стека, и не должна вызывать никакие функции API FreeRTOS, имя которых не оканчивается на 'FromISR()'.
Листинг 70. Задача привратника (gatekeeper) Задача, которая выводит сообщение, сделана так же, как в примере 15, за исключением того, что на этот раз строка отправляется в очередь гейткипера, а не выводится напрямую. Реализация задачи показана в листинге 71. Как и в прошлый раз, создаются два разных экземпляра одной и той же задачи, каждый из экземпляров печатает свою уникальную строку, переданную через параметр задачи.
Листинг 71. Реализация задачи печати для примера 16 Функция хука тиков просто считает количество её вызовов, отправляя свое сообщение задаче гейткипера, как только счетчик достигнет 200. Только в целях демонстрации хук тика пишет в начало очереди, и задача prvPrintTask пишет в конец очереди. Реализация хука тиков показана в листинге 72.
Листинг 72. Реализация хука тиков Как обычно, функция main() просто создает очередь и задачи, необходимые для работы примера, и затем запускает шедулер. Реализация main() показана в листинге 73.
Листинг 73. Реализация функции main() для примера 16 Вывод, производимый примером 16, показан на рисунке 41. Как можно видеть, строки, приходящие из задач, и строки, приходящие из прерывания, все выводятся корректно, без порчи своего содержимого. Рис. 41. Вывод, который производит при выполнении пример 16 Задача привратника получила приоритет меньше, чем приоритеты печатающих задач - так что сообщения, которые отправлены гейткиперу, остаются в очереди, пока обе задачи печати не были в состоянии Blocked. В некоторых ситуациях может потребоваться назначить привратнику более высокий приоритет, чтобы сообщения были обработаны быстрее - но сделать так означает, что привратник будет задерживать выполнение менее приоритетных задач, пока он не завершит доступ к защищенному ресурсу. [Следующая часть FreeRTOS: практическое применение, часть 5 (управление памятью)] [Ссылки] 1. Обзор FreeRTOS на русском языке - Андрей Курниц, статья в журнале «Компоненты и технологии» (2..10 номера 2011 года). |