Содержание

OSA : Рекомендации по оптимизации

Здесь будут описаны правила, советы и рекомендации по конфигурированию OSA, а также по применению сервисов OSA с целью увеличения эффективности использования ресурсов микроконтроллера. Есть три ресурса, которые хотелось бы сэкономить: RAM, ROM и время. Сначала я предполагал разбить этот раздел на три части, посвятив каждую из них своему параметру. Однако, получилось так, что приемы и советы по экономии RAM и ROM практически идентичны, поэтому им будет посвящена одна часть.

Оптимизация памяти

Оптимизация памяти в основном производится за счет правильного конфигурирования, но есть и приемы, относящиеся к использованию сервисов. Начальная конфигурация по умолчанию (когда файл osacfg.h пустой) подобрана так, чтобы обеспечить программиста всем необходимым, не давая ничего лишнего. Но каждая программа индивидуальна и есть смысл для каждой подбирать оптимальную конфигурацию.

Отключение приоритетов

Во многих приложениях отключение приоритетности - очень полезная вещь. Как было описано выше (в разделе Как расставлять приоритеты), в большинстве приложений под кооперативной ОСРВ приоритетность не нужна. Когда мы отключаем приоритетность, код планировщика сильно упрощается и сокращается, т.к. он больше не содержит цикла предварительного перебора всех задач в поиске готовых и кода сравнения приоритетов. Кроме того, из прораммы выкидываются системные переменные, содержащие максимальный приоритет, адрес задачи с максимальным приоритетом и адрес последней выполненной задачи. Т.е., отключив приоритеты, мы экономим и RAM, и ROM, и время работы планировщика (см. ниже).

По умолчанию приоритетность включена. Отключить ее можно, задав в файле osacfg.h константу:

#define OS_DISABLE_PRIORITY

Размерность таймеров

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

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

По умолчанию размерность таймера равна двум байтам. Это покрывает большую часть потребностей в организации задержек в программах. Такие таймеры могут отсчитывать до 65535 системных тиков (периодов вызова OS_Timer). Больше обычно не нужно. Но зато часто встречаются случаи, когда все задержки (и ожидания событий с таймаутами) не длиннее 255 тиков. Например, OS_Timer вызывается в обработчике прерывания по TMR2, настроенного так, чтобы его период был 10 мс. В этом случае, используя отсчет таймера в 255 тиков, мы получаем задержку в 2.5 секунды. Для многих приложений этого более чем достаточно.

Поэтому во многих случаях есть смысл таймеры задач сделать однобайтовыми. Делается это заданием размерности таймера в файле osacfg.h:

#define OS_TTIMER_SIZE      1

Что мы выигрываем, уменьшив размерность таймеров задач?

Примечание 1. Размерность можно также менять и у статических, и у динамических таймеров.
Примечание 2. Для 16-разрядных контроллеров (PIC24 и dsPIC) уменьшение размерности таймера до одного байта не приведет к сокращению кода или экономии RAM.

Замена таймеров задач статическими таймерами

Иногда при дефиците ресурсов может оказаться полезным воспользоваться статическими таймерами, вместо таймеров задач. При включении таймеров задач (включаются определением константы OS_ENABLE_TTIMERS) появляется возможность использовать в задачах задержки (OS_Delay) и ожидание событий с выходом по таймауту (OS_xxx_Wait_TO). За каждым дескриптором задачи при этом закрепляется свой таймер (так называемый таймер задач), и их будет столько, сколько дескрипторов зарезервировано в osacfg.h (константа OS_TASKS). Получается, что если у нас 10 задач, а таймеры используются, например, только в трех, то мы имеем в памяти 7 переменных, которые не используются, но занимают место.

В таких случаях имеет смысл отключить таймеры задач и воспользоваться статическими таймерами. Для нашего примера мы определяем константу в osacfg.h:

#define OS_STIMERS     3

И теперь у нас только 3 ячейки памяти занято таймерами, а мы сэкономили 7 ячеек. При двухбайтовых таймерах это 14 байт оперативной памяти. Теперь задержки в задачах будут выглядеть так:

    OS_Stimer_Delay(ST_ID, 100);

где ST_ID - номер статического таймера (от 0 до 2 в нашем примере).

Как заменить сервисы ожидания с выходом по таймауту, если все сервисы OS_xxx_Wait_TO требуют наличия таймеров задач? Очень просто, воспользовавшись сервисом OS_Cond_Wait, который позволяет ожидать в задаче любое условие. Например, мы ждем сообщение из очереди:

    // С таймерами задач
    OS_Queue_Wait_TO(queue, msg, 100);
    if (!OS_IsTimeout)
    {
        /* обрабатываем сообщение */
    }
 
    // Без таймеров задач, но со статическими таймерами
    OS_Stimer_Run(0, 100);
    OS_Cond_Wait(OS_Queue_Check(queue) && !OS_Stimer_Check(0));
    if (!OS_Stimer_Check(0))
    {
        OS_Queue_Accept(queue, msg);
        /* обрабатываем сообщение */
    }

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

Итак, что мы выигрываем от замены таймеров задач статическими таймерами?

Теряем мы удобство и наглядность.

Еще одно применение статических таймеров можно рассмотреть в контексте предыдущего параграфа (Размерность таймеров). Там мы указали, что часто бывает так, что задержки в задачах не превышают 255 системных тиков. Но бывает и так, что в 9-ти задачах не превышают, а 10-ой, хоть тресни, нужен 32-разрядный таймер. Понятно, что из-за одной задачи не хотелось бы всем приделывать 4-байтовые таймеры. Поэтому удобно организовать один 4-байтовый статический таймер для этой задачи, а всем остальным определить 1-байтовые. Вот фрагмент файла osacfg.h:

#define OS_ENABLE_TTIMERS          // Разрешаем использование таймеров задач
#define OS_TTIMER_SIZE      1      // Размерность таймеров задач
#define OS_STIMERS          1      // Определяем один статический таймер
#define OS_STIMER_SIZE      4      // Размерность статического таймера

OS_Timer inline

Сервис OS_Timer вызывает системную функции _OS_Timer(). Учитывая, что чаще всего вызов этого сервиса производят по переполнению какого-либо таймера (TMR0, TMR1, TMR2), обычно он располагается внутри функции-прерывания. Компиляторы от HTSoft в таком случае ведут себя следующим образом: т.к. сама функция _OS_Timer() относительно функции прерывания находится во внешнем другом модуле, то функция прерывания ничего не знает о ресурсах, которые использует _OS_Timer(), поэтому при входе в прерывание сохраняются все критичные регистры, которые, по предположению компилятора, может изменить внешняя функция. К ним относятся: все FSR, пара PRODH:PROLH, 15 регистров btemp, и тройка регистров TABLAT, TBLPTRH:TBLPTRL. Возможно в самом прерывании они и не используются, а все прерывание выглядит так:

void interrupt myisr (void)
{
    OS_EnterInt();
    if (TMR2IF)
    {
        TMR2IF = 0;
        OS_Timer();
    }
    OS_LeaveInt();
}

Но на этапе компиляции файла, содержащего код прерывания, компилятор не знает, что творится внутри функции, вызываемой сервисом OS_Timer. Сохранение/восстановление при входе/выходе из прерывания занимает более 100 слов памяти ROM и, соответственно, более 100 машинных циклов. Т.е. если у нас таймер запрограммирован так, чтобы прерывание происходило каждые 256 циклов, то более 40% всего процессорного времени будет тратиться только на вход и выход из прерывания.

В ОСРВ OSA есть возможность вместо вызова функции _OS_Timer подставлять ее тело напрямую. Для этого нужно определить константу в файле osacfg.h:

#define OS_USE_INLINE_TIMER

Ограничение: если эта константа определена, то в программе OS_Timer() может вызываться в единственном месте. Возможно, в дальнейшем это ограничение будет снято.

Размещение переменных

(Этот параграф имеет отношение не столько к OSA, сколько к PIC18)

Для PIC18 некоторый выигрыш по программной памяти даст размещение переменных в ACCESS-банке (первые 128 или 96 байт RAM-памяти). Обращение к таким переменным производится без предварительной установки регистра BSR, следовательно, при частом обращении к ним можно сэкономить на всех инструкциях movlb. Свои переменные можно размещать в этом банке вручную. Например, для HT-PICC18:

near char a;
near int  i;

для MCC18:

#pragma udata access my_vars
near char a;
near int  i;

В ОСРВ OSA также есть возможность размещать все внутренние переменные в разных банках: дескрипторы задач, системные переменные, статический таймеры, бинарные семафоры. Для каждого типа этих данных можно указать свой банк (0 - access, 1 - остальная память):

#define OS_BANK_OS          0         // Размещение системных переменных
#define OS_BANK_TASKS       1         // Размещение дескрипторов задач
#define OS_BANK_BSEMS       0         // Размещение двоичных семафоров
#define OS_BANK_OS          1         // Размещение статических таймеров

Отправка сообщений

Обычно сообщения отправляются сервисом OS_Msg_Send. Как этот сервис работает? Предварительно он проверяет, занят дескриптор сообщения или свободен (т.е. обработано ли предыдущее сообщение или еще нет). Если дескриптор занят, то задача становится в режим ожидания и передает управление планировщику. Иногда бывает так, что не принципиально, обработалось предыдущее сообщение или нет. Например, задача измеряет напряжение бортовой сети автомобиля и сообщением отправляет его головной задаче. Головной задаче не важна последовательность изменений напряжения, ей нужно текущее значение. Поэтому иногда нет смысла выполнять лишние операции по проверки занятости дескриптора, по формированию адреса возврата, да еще задержки на неопределенное время. Для отправки сообщения, не дожидаясь освобождения дескриптора (т.е. фактически для перетирания старого тела сообщения новым), можно пользоваться сервисом:

    OS_Msg_Send_Now(msg_cb, msg);

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

Примечание. Это же относится к отправке коротких сообщений, а также к отправке сообщений и коротких сообщений в очередь.

Очереди сообщений

OSA предоставляет возможность программисту использовать два типа очередей в своей программе, причем их можно использовать одновременно, т.к. они независимы друг от друга. Однако, их независимость порождает небольшую проблему: в программе присутствуют отдельные функции по добавлению/извлечению сообщения для обоих типов очередей. Т.е. в программной памяти два экземпляра функции _OS_Queue_Send и два экземпляра функции _OS_Queue_Accept. Это позволяет программисту использовать в своей программе сообщения разных типов и размерностей. Но если в программе используются сообщения одинаковых размерностей? Т.е. размерность указателя на сообщения такая же, как размерность короткого сообщения (для PIC16, например, указатель на RAM однобайтовый). В этом случае использование двух экземпляров функций неоправданно.

В таких случаях программисту предоставляется возможность объединить эти функции, т.е., вернее, использовать одни и те же функции для сообщений разных типов. Для этого нужно в файле osacfg.h определить константу:

#define OS_QUEUE_SQUEUE_IDENTICAL

Так мы сэкономим около 100 слов программной памяти. Однако после применения такого приема следует помнить, что был сделан допуск относительно размерности сообщений. И если вдруг нужно будет поменять тип одного из сообщений (указателя на сообщение или короткого сообщения), то следует пересмотреть возможность использования одной функции для работы с очередями этих сообщений.

Оптимизация скорости

Выбор приоритетов

Давайте еще раз рассмотрим принцип работы планировщика в ОСРВ OSA. Планировщик по очереди перебирает все активные (созданные) задачи, проверяя их готовность. Одновременно он сравнивает приоритеты у всех готовых задач, для чего лучший из приоритетов, а также адрес задачи с лучшим приоритетам сохраняет в своих внутренних переменных. После прохода всех задач управление передается той, чей адрес сохранен во внутренней переменной, т.е. чей приоритет был высшим из всех готовых задач.

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

Учитывая, что полное время проверки готовности одной задачи (с выбором адреса задачи, с передачей ей управления для проверки готовности, с самой проверкой и с возвратом в планировщик) длится около 70 тактов. И если у нас 10 задач, то общий поиск будет длиться около 700 тактов. Если же присутствует задача с нулевым приоритетом, то в случае ее готовности время работы планировщика сократится в среднем вдвое (это время будет колебаться от 70 до 700 тактов в зависимости от того, с какой задачи планировщик начинает просмотр).

Итак, рекомендация такова: если используется приоритетный режим, то желательно самым приоритетным задачам устанавливать именно высший (а не просто самый высокий) приоритет, т.к. это ускорит работу планировщика.

Полное отключение приоритетов

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

Замена ожидания на задержку

Два слова о том, как проверяется условие при ожидании. Например, задача ожидает установку двоичного семафора. Что при этом происходит:

  1. задача переводит себя в режим ожидания (сбрасывает себе флаг bReady);
  2. задача сохраняет текущее значение программного счетчика;
  3. задача выполняет проверку семафора;
  4. если семафор установлен, то задача переводит себя в режим готовности (устанавливает флаг bReady);
  5. задача возвращает управление планировщику; (обратите внимание, что возврат в планировщик произойдет при любом значении семафора, т.к. среди задач может оказаться более приоритетная, которая ожидает тот же семафор);
  6. планировщик, проверив все остальные задачи, доходит до нашей;
  7. планировщик передает управление на тот адрес, который задача сохранила в шаге 2 (при этом мы попадаем в задачу как раз в то место, где производится проверка семафора);
  8. задача выполняет повторную проверку семафора (он за это время мог быть сброшен более приоритетной задачей);
  9. если семафор уже сброшен, то задача переводит себя в режим ожидания (сбрасывает флаг bReady);
  10. если семафор установлен и задача готова к выполнению, то она идет выполнять дальнейший код (который следует за ожиданием семафора);
  11. если семафор установлен, но задача еще в режиме ожидания, то идем на шаг 4.

Т.е. при ожидании события планировщик всегда заглядывает внутрь задачи (передает ей управление, чтобы она сама проверила свое условие). Это требует времени. Если семафор устанавливается довольно редко, а скорость реакции на него некритична (например, в будильнике, по которому мы просыпаемся на работу не так важно, зазвенит он в 07:00:00 или в 07:00:05), то очень много тактов процессора тратится впустую.

Теперь рассмотрим поведение планировщика, когда какая-либо задача находится в ожидании задержки OS_Delay. Что происходит, когда задача выполняет OS_Delay(100):

  1. таймер в дескрипторе задачи инициализируется указанным значением (в нашем примере 100);
  2. задача устанавливает себе бит bDelay (что говорит за то, что она выполняет задержку);
  3. задача запоминает текущее значение программного счетчика;
  4. задача возвращает управление планировщику;
  5. когда у планировщика доходит очередь до проверки этой задачи, он предварительно проверяет бит bDelay в ее дескрипторе;
  6. если этот бит установлен, то понятно, что задача еще не готова, и планировщик ее пропускает.

(Примечание. Бит bDelay сбрасывается обработчиком сервиса OS_Timer).

Т.е. при выполнении задачей задержки OS_Delay планировщик не передает управление задаче до конца задержки. Т.е. не теряет 50 тактов на заход внутрь задачи и проверку условия.

Таким образом, если есть некритичные к реакции события, можно ускорить работу планировщика, делая замены подобные этой:

    // Обычное ожидание события
    OS_Bsem_Wait(0);
 
    // Ожидание, которое ускорит работу планировщика
    do {
        OS_Delay(10);              // Проверяем условие с интервалом в 10 системных тиков
    } while (!OS_Check_Bsem(0));

Примечание. Эффективной будет только задержка с использованием таймеров задач. Статический и динамические таймеры здесь не подойдут.

Проверка условия перед ожиданием

Вернемся к описанию работы планировщика при ожидании события в какой-либо задаче, описанному в предыдущем параграфе. Как видно, вне зависимости от того, произошло событие на момент проверки или нет, мы по-любому передаем управление планировщику. Объяснение такому поведению уже приводилось: среди задач может оказаться более приоритетная, ожидающая этого же события, и управление должно быть сперва передано ей. Но допустим, что мы на 100% уверены, что второй задачи, ожидающей того же события нет. А нам жалко терять время на выход из задачи и на ожидание того, когда она станет самой приоритетной. Поэтому в таком случае можно прибегнуть к следующему приему:

    if (!OS_Bsem_Check(0)) OS_Bsem_Wait(0);

Т.е. мы сперва проверяем, не установлен ли уже семафор, и, только убедившись, что еще нет, возвращаемся в планировщик.

(К списку советов по оптимизации)