До сих пор в этой серии статей мы рассматривали, какие функции предоставляет Nucleus SE. Сейчас пришло время посмотреть, как ее можно использовать в реальном приложении встраиваемого ПО.
Мы знаем, что Nucleus SE — это ядро операционной системы реального времени, но нужно понимать, как оно вписывается в остальную часть приложения. А оно именно вписывается, так как в отличие от настольной операционной системы (например, Windows), приложение не запускается на Nucleus SE; ядро просто является частью программы, работающего на встраиваемом устройстве. Это наиболее распространенный вариант использования ОСРВ.
С высокоуровневой точки зрения, встраиваемое приложение — это некий код, который запускается вместе с запуском ЦП. При этом инициализируется аппаратная и программная среда, а затем вызывается функция main(), запускающая основной код приложения.
При использовании Nucleus SE (и многих других похожих ядер) отличие заключается в том, что функция main() является частью кода ядра. Эта функция просто инициализирует структуры данных ядра, а затем вызывает планировщик, что приводит к запуску кода приложений (задач). Пользователь может добавить в функцию main() любой собственный код инициализации.
Nucleus SE также включает набор функций – программный интерфейс приложения (API), предоставляющий набор функций, таких как связь и синхронизация задач, работа с таймерами, выделение памяти и т.д. Все функции API были описаны ранее в статьях данного цикла.
Вся программная часть Nucleus SE предоставляется в виде исходного кода (в основном на С). Для конфигурации кода в соответствии с требованиями конкретного приложения используется условная компиляция. Это подробно описывается в этой статье в разделе «Конфигурация».
После того как код был скомпилирован, получившиеся объектные модули Nucleus SE связываются с модулями кода приложения, в результате чего получается один двоичный образ, который обычно помещается во флеш-память встраиваемого устройства. Результатом такого статического связывания является то, что вся символьная информация остается доступной как из кода приложения, так и из кода ядра. Это полезно при отладке, однако, чтобы избежать некорректного использования данных Nucleus SE, требуется осторожность.
Так как Nucleus SE поставляется в виде исходного кода, она должна быть портируемой. Однако код, работающий на таком низком уровне (при использовании планировщиков, где требуется переключение контекста, то есть любой, кроме Run to Completion), не может быть абсолютно независимым от языка ассемблера. Я минимизировал эту зависимость, и для портирования на новый CPU низкоуровневого программирования почти не требуется. Использование нового набора инструментов разработки (компилятор, ассемблер, компоновщик и т.д.) также может привести к проблемам при портировании.
Ключом к эффективному использованию Nucleus SE является правильная настройка. Это может выглядеть сложным, но на самом деле все достаточно логично и лишь требует систематического подхода. Почти вся настройка выполняется путем редактирования двух файлов: nuse_config.h и nuse_config.c.
Этот файл – просто набор символов директивы #define, которым присваиваются соответствующие значения для получения необходимой конфигурации ядра. В файле nuse_config.h по умолчанию присутствуют все символы, но им присвоены минимальные настройки.
Счётчики объектов
Количество объектов ядра каждого типа устанавливается значениями символа вида NUSE_SEMAPHORE_NUMBER. Для большинства объектов это значение может варьироваться от 0 до 15. Задачи являются исключением, их должно быть не менее одной. Сигналы, по сути, не являются самостоятельными объектами, так как они ассоциируются с задачами и включаются путем присвоения NUSE_SIGNAL_SUPPORT значения TRUE.
Активаторы функций API
Каждая функция API Nucleus SE может быть активирована отдельно путем присвоения символу, имя которого совпадает с именем функции (например, NUSE_PIPE_JAM), значения TRUE. Это приводит к включению кода функции в приложение.
Выбор планировщика и его настройки
Nucleus SE поддерживает четыре типа планировщиков, как было описано в одной из предыдущих статей. Используемый планировщик задается при помощи присвоения NUSE_SCHEDULER_TYPE одного из следующих значений: NUSE_RUN_TO_COMPLETION_SCHEDULER, NUSE_TIME_SLICE_SCHEDULER, NUSE_ROUND_ROBIN_SCHEDULER или NUSE_PRIORITY_SCHEDULER.
Можно настраивать и другие параметры планировщика:
NUSE_TIME_SLICE_TICKS указывает количество тиков на слот для планировщика Time Slice. Если используется другой планировщик, этот параметр должен иметь значение 0.
NUSE_SCHEDULE_COUNT_SUPPORT может быть установлен в значение TRUE или FALSE для активирования/деактивирования механизма счётчика планировщика.
NUSE_SUSPEND_ENABLE включает блокировку задач (приостановку) для множества функций API. Это значит, что вызов такой функции может привести к приостановке вызывающей задачи до освобождения ресурса. Для выбора этого параметра необходимо, чтобы NUSE_SUSPEND_ENABLE тоже имел значение TRUE.
Другие параметры
Нескольким другим параметрам тоже можно присваивать значения TRUE или FALSE для активирования/деактивирования других функций ядра:
NUSE_API_PARAMETER_CHECKING добавляет код проверки параметров вызова функций API. Обычно используется при отладке.
NUSE_INITIAL_TASK_STATE_SUPPORT задает исходное состояние всех задач как NUSE_READY или NUSE_PURE_SUSPEND. Если этот параметр отключить, все задачи будут иметь исходное состояние NUSE_READY.
NUSE_SYSTEM_TIME_SUPPORT – поддержка системного времени.
NUSE_INCLUDE_EVERYTHING – параметр, добавляющий максимальное количество функций в конфигурацию Nucleus SE. Он приводит к активации всего опционального функционала и каждой функции API сконфигурированных объектов. Используется для быстрого создания конфигурации Nucleus SE для проверки нового портирования кода ядра.
После указания конфигурации ядра в nuse_config.h необходимо инициализировать различные структуры данных, хранящиеся в ПЗУ. Это выполняется в файле nuse_config.c. Определение структур данных контролируется условной компиляцией, поэтому все структуры содержатся в копии файла nuse_config.c по умолчанию.
Данные задач
Массив NUSE_Task_Start_Address[] должен быть инициализирован значением начальных адресов каждой задачи. Обычно это просто список имен функций, без скобок. Прототипы функций входа задач также должны быть видимыми. В файле по умолчанию задача конфигурируется с именем NUSE_Idle_Task(), это может быть изменено на задачу приложения.
Если используется любой планировщик, кроме Run to Completion, каждой задаче требуется собственный стек. Для каждого стека задачи необходимо создать массив в ОЗУ. Эти массивы должны иметь тип ADDR, а адрес каждого из них должен храниться в NUSE_Task_Stack_Base[]. Предугадать размер массива сложно, поэтому лучше воспользоваться измерениями (см. раздел «Отладка» далее в этой статье). Размер каждого массива (то есть количество слов в стеке) должно храниться в NUSE_Task_Stack_Size[].
Если была включена функция для указания исходного состояния задачи (при помощи параметра NUSE_INITIAL_TASK_STATE_SUPPORT), массив NUSE_Task_Initial_State[] должен инициализироваться с состоянием NUSE_READY или NUSE_PURE_SUSPEND.
Данные пулов разделов
Если сконфигурирован хотя бы один пул разделов, то для каждого из них должен быть создан массив (типа U8) в ПЗУ. Размер этих массивов вычисляется следующим образом: (количество разделов * (размер раздела + 1)). Адреса этих разделов (то есть их имена) должны быть присвоены соответствующим элементам NUSE_Partition_Pool_Data_Address[]. Для каждого пула количество разделов и их размер должен быть помещен в NUSE_Partition_Pool_Partition_Number[] и NUSE_Partition_Message_Size[], соответственно.
Данные очередей
Если сконфигурирована хотя бы одна очередь, то для каждой из них должен быть создан массив (типа ADDR) в ОЗУ. Размер этих массивов – число элементов в каждой очереди. Адреса этих массивов (то есть их имена) должны быть присвоены соответствующим элементам NUSE_Queue_Data[]. Размер каждой очереди должен быть присвоен соответствующему элементу NUSE_Queue_Size[].
Данные каналов передачи данных
Если сконфигурирован хотя бы один канал передачи данных, то для него (или для каждого из них) должен быть создан массив (типа U8) в ОЗУ. Размер этих массивов вычисляется следующим образом: (размер канала * размер сообщения в канале). Адреса этих массивов (то есть их имена) должны быть присвоены соответствующим элементам NUSE_Pipe_Data[]. Для каждого канала его размер и размер сообщения должен быть присвоен соответствующим элементам NUSE_Pipe_Size[] и NUSE_Pipe_Message_Size[], соответственно.
Данные семафоров
Если сконфигурирован хотя бы один семафор, массив NUSE_Semaphore_Initial_Value[] должен быть инициализирован начальными значениями обратного счетчика.
Данные таймеров приложения
Если сконфигурирован хотя бы один таймер, массив NUSE_Timer_Initial_Time[] должен быть инициализирован начальными значениями счетчиков. Кроме того, NUSE_Timer_Reschedule_Time[] должны быть присвоены значения перезапуска. Эти значения таймеров будут использоваться после того, как завершится первый цикл таймера. Если значениям перезапуска присвоены значения 0, счетчик будет остановлен после одного цикла.
Если настроена поддержка механизмов завершения счёта (присвоением параметру NUSE_TIMER_EXPIRATION_ROUTINE_SUPPORT значения TRUE), требуется создать еще два массива. Адреса механизмов завершения (просто список имен функций, без скобок) должны быть помещены в NUSE_Timer_Expiration_Routine_Address[]. Массив NUSE_Timer_Expiration_Routine_Parameter[] должен быть инициализирован значениями параметров завершения.
Все операционные системы в том или ином виде имеют API (программный интерфейс приложения). Nucleus SE не исключение, и функции, из которых состоит API, были подробно описаны в этом цикле статей.
Может показаться очевидным, что при написании приложения, использующего Nucleus SE, нужно использовать API так, как было описано в предыдущих статьях. Однако это не всегда так.
Для большинства пользователей API Nucleus SE будет чем-то новым, возможно даже их первым опытом использования API операционной системы. И так как он довольно простой, он может послужить хорошим введением в тему. В таком случае, порядок действий понятен.
Для некоторых же пользователей более привлекательным вариантом может быть альтернативный API. Существует три очевидных ситуации, когда это возможно.
Написание встраиваемого приложения, использующего многозадачное ядро, является сложной задачей. Убедиться, что код работает и обнаружить ошибки, может оказаться очень непростой задачей. Несмотря на то, что это всего лишь код, работающий на процессоре, одновременное выполнение нескольких задач приводит к тому, что сфокусироваться на конкретном потоке выполнения довольно трудно. Это еще больше осложняется, когда несколько задач имеют общий код. Хуже всего, когда две задачи имеют абсолютно одинаковый код (но работают с различными данными). Также сложности добавляет распутывание структур данных, которые используются для реализации объектов ядра, чтобы увидеть осмысленную информацию.
Чтобы отладить приложения, построенное при помощи Nucleus SE, не требуется дополнительных библиотек или других служб. Весь код ядра доступен для чтения отладчиком. Следовательно, вся символьная информация доступна для изучения. При работе с приложениями Nucleus SE может быть использован любой современный инструмент отладки.
Инструменты отладки, созданные специально для встраиваемых систем, за 30 лет, которые они существуют, стали очень мощными. Основной характеристикой встраиваемого приложения, по сравнению с настольной программой является то, что все встраиваемые системы разные (а все персональные компьютеры довольно похожи друг на друга). Хороший отладчик встраиваемых систем должен быть гибким и иметь достаточно настроек, чтобы соответствовать разнообразию встраиваемых систем и требованиям пользователей. Настраиваемость отладчика выражается в различных формах, однако обычно в нем присутствует возможность создания скриптов. Именно эта возможность позволяет отладчику хорошо работать с приложением уровня ядра. Ниже я рассмотрю некоторые случаи использования отладчика.
Стоит заметить, что обычно отладчик – семейство инструментов, а не одна программа. Отладчик может иметь различные режимы работы, посредством которых он помогает при разработке кода на виртуальной системе либо на реальной аппаратуре.
Если в программе есть общий для нескольких задач код, использование обычных точек останова при отладке усложняется. Скорее всего, вам нужно, чтобы код останавливался только при достижении точки останова в контексте определенной задачи, отладкой которой вы сейчас занимаетесь. Для этого вам необходима точка останова, которая будет учитывать задачу.
К счастью, возможность создания скриптов на современных отладчиках и доступность символьных данных Nucleus SE делают реализацию точек останова, учитывающих задачи, довольно простой вещью. Всё, что необходимо, это написать простой скрипт, который будет связан с точкой останова, которую вы хотите научить различать задачи. Этот скрипт будет принимать параметр: индекс (ID) интересующей вас задачи. Скрипт будет просто сравнивать это значение с индексом текущей задачи (NUSE_Task_Active). Если значения совпадают, выполнение программы приостанавливается. Если они различны, выполнение продолжается. Стоит заметить, что выполнение этого скрипта повлияет на выполнение приложения в реальном времени (прим. переводчика: имеется в виду, что исполнение программы будет подтормаживать относительно ее обычной работы). Однако, если скрипт не находится в цикле, который будет выполняться очень часто, это влияние будет минимальным.
Информация об объекте ядра
Очевидной необходимостью при отладке приложения Nucleus SE является возможность получения информации об объектах ядра: какие у них характеристики и каков их текущий статус. Это позволяет получить ответ на такие вопросы, как: «Насколько велика эта очередь и сколько в ней сейчас сообщений?»
Это можно использовать, добавив дополнительный код отладки в ваше приложение, который и будет использовать «информационные» вызовы API (такие как NUSE_Queue_Information). Само собой, это будет означать, что ваше приложение теперь содержит дополнительный код, в котором не будет необходимости после внедрения приложения. Использование #define для включения и выключения этого кода при помощи условной компиляции будет логичным решением.
Некоторые отладчики могут выполнять целевой вызов функции, то есть напрямую вызывать функцию API для получения информации. Это позволяет избавиться от необходимости в дополнительном коде, однако эта функция API должна быть сконфигурирована, чтобы отладчик мог ею воспользоваться.
Альтернативным более гибким, но менее «неустаревающим» подходом является прямой доступ к структурам данных объектов ядра. Вероятнее всего, лучше делать это при помощи скриптов отладчика. В нашем примере, размер очереди может быть получен из NUSE_Queue_Size[], а ее текущее использование из NUSE_Queue_Data[]. Кроме того, сообщения в очереди могут быть отображены при помощи адреса области данных очереди (из NUSE_Queue_Data[]).