Разбирая этот урок, мы убедимся в том, что переменные внутри функций-задач нужно объявлять как static. На простом примере будет показано, к чему может привести пренебрежение этим правилом.
Создадим проект, следуя инструкциям, описанным в первом уроке, только создадим его в папке "c:\tutor\t2" и назовем файл "tutor2.c".
Текст нашей второй программы будет очень похож на текст первой: те же две задачи, то же переключение с помощью сервиса OS_Yield().
char m_cTest1; char m_cTest2; void Task_T1 (void) { char cTemp1; for (;;) { cTemp1 = 1; // Значение этой переменной будет потеряно // после передачи управления системе. OS_Yield(); m_cTest1 = cTemp1; } } void Task_T2 (void) { char cTemp2; for (;;) { cTemp2 = 2; // Значение этой переменной будет потеряно // после передачи управления системе. OS_Yield(); m_cTest2 = cTemp2; } }
Переменные m_cTest1 и m_cTest2 сделаны глобальными для того, чтобы их было удобнее отслеживать при отладке в симуляторе. В каждой задаче объявлено по одной локальной переменной. Цель данного урока - продемонстрировать, что значения локальных переменных могут быть потеряны (затерты) после передачи управления ядру операционной системы.
Допишем к программе функцию main(), которая:
void main (void) { OS_Init(); OS_Task_Create(3, Task_T1); OS_Task_Create(3, Task_T2); OS_Run(); }
Итак, полный текст нашей программы теперь будет выглядеть так:
#include <osa.h> //****************************************************************************** // Глобальные переменные //****************************************************************************** char m_cTest1; char m_cTest2; //****************************************************************************** // Функции-задачи //****************************************************************************** void Task_T1 (void) { char cTemp1; m_cTest1 = 0; for (;;) { cTemp1 = 1; // Значение этой переменной будет потеряно // после передачи управления системе. OS_Yield(); m_cTest1 = cTemp1; } } //------------------------------------------------------------------------------ void Task_T2 (void) { char cTemp2; m_cTest2 = 0; for (;;) { cTemp2 = 2; // Значение этой переменной будет потеряно // после передачи управления системе. OS_Yield(); m_cTest2 = cTemp2; } } //****************************************************************************** // main //****************************************************************************** void main (void) { OS_Init(); // Инициализация переменных системы OS_Task_Create(3, Task_T1); // Добавление задач в список OS_Task_Create(3, Task_T2); OS_Run(); // Запуск планировщика } //****************************************************************************** // end of file //******************************************************************************
Порядок переключения между задачами идентичен порядку, описанному в первом уроке. Разница только в содержимом функций-задач: если в первом уроке мы при каждом запуске задачи увеличивали переменную-счетчик, то теперь мы некоей тестовой переменной (m_cTest1 или m_cTest2) присваиваем значение локальной переменной (cTemp1 или cTemp2, соответственно).
Включим симулятор через меню "Debugger\Select Tool\MPLAB SIM". Соберем проект по Ctrl+F10. Для наблюдения за состоянием переменных откроем два окна: Watch и Local (оба окна открываются через меню "View"). В окно Watch добавляем две глобальные переменные m_cTest1 и m_cTest2. Установим точки останова так, как показано на рисунке ниже:
Теперь нажимаем F9 (Run) и попадаем на первую точку останова в задаче Task_T1, где локальной переменной cTemp1 присваивается значение "1". Нажимаем F8 (Step Over) и убеждаемся, что локальная переменная cTemp1 приняла значение "1" (ее значение будет отображаться в окне Local).
Итак, в данный момент курсор симулятора установлен на строке, содержащей вызов OS_Yield() из задачи Task_T1. Следующей строкой у нас присваивание m_cTest1 = cTemp1, а сама переменная cTemp1 у нас имеет значение "1". Мы подошли к главному моменту урока. Как было описано в параграфе "Введение. Особенности отладки.", сервисы, переключающие контекст нужно выполнять командой Run (F9), предварительно установив точку останова на следующей за сервисом строке. У нас точка останова уже стоит, так что смело давим F9 (Run).
Сервис OS_Yield() выполнился, и курсор симулятора теперь стоит на строке присваивания m_cTest1 = cTemp1. Но Обратим внимание на самый важный момент: значение переменной cTemp1 изменилось и стало равно "2".
Разберемся, что с ней произошло. Для начала разберемся с тем, где размещаются локальные переменные.
В этих двух компиляторах локальные переменные создаются в стеке, причем в MCC18 стек эмулируется программно с помощью указателей FSR1 и FSR2, а в MCC30 используется общий стек, он адресуется регистрами WREG14 и WREG15.
Рассмотрим, как выделяется память под локальные переменные в этих компиляторах. На этапе компиляции производится подсчет, какой объем памяти требуется под локальные переменные для каждой функции. В начало каждой функции, содержащей локальные переменные, компилятором автоматически вставляется код, который увеличивает указатель стека на значение, соответствующее объему локальных переменных функции. Например, если в функции используются 3 локальных переменных типа unsigned int, то указатель стека будет увеличен на 6.
Рассмотрим рисунок:
На рисунке приведен порядок изменения указателей стека для MCC18 и MCC30. Теперь обращение к локальным переменным внутри функции будет производиться через указатель фрейма FSR2 для MCC18 или WREG14 для MCC30. Если из функции func() будет вызвана другая функция func2(), локальные переменные которой занимают 4 байта, то стек будет выглядеть так:
Как видно, локальные переменные функций не пересекаются, когда одна вызывает другую. Более того, при такой организации возможна рекурсия.
Теперь важный момент: если одна функция по очереди вызывает две другие, то при попадании в каждую из них значение регистров указателей стека будет одинаковым. Т.е. если бы функция func1() после вызова func2() вызывала бы еще некую func3():
void func1 (void) { ... func2(); ... func3(); ... }
, то для локальных переменных func3() использовалась бы область памяти, начинающаяся с указателя FSR2, т.е. та же самая область, которая была занята под локальные переменные функции func2().
Функция func1 по очереди вызывает func2 и func3. При входе в func2 происходит следующее:
После того, как функция func2() отработает, перед выполнением return производятся следующие опреации:
Таким образом, к моменту вызова func3 значения регистров FSR1 и FSR2 те же, что и перед вызовом func2. Очевидно, что при входе в функцию func3 под ее локальные переменные будут заняты те же ячейки памяти, что были заняты и под func2().
Стратегия распределения памяти под локальные переменные в этих компиляторах несколько отличается от стратегий MCC. На этапе линковки строится граф вызовов, который содержит информацию о том, какие функции из каких функций вызываются и сколько каждая функция требует памяти под свои локальные переменные. Такой граф может выглядеть, например, так (в квадратных скобках указан объем памяти под локальные переменные):
Далее линкер, опираясь на эту информацию, строит все возможные цепочки вызовов от верхушки графа до концевого узла (в нашем случае их 7), и для каждой цепочки строится своя схема выделения локальных переменных. Обратим внимание на функцию func4(), которая встречается в двух цепочках, причем, количество элементов в этих цепях различное. Это также учитывается линкером при распределении памяти под локальные переменные. Рассмотрим для примера три цепочки:
Для первой цепочки никаких коллизий нет и локальные переменные разных уровней графа вызовов будут следовать непрерывно друг за другом. А вот вторая и третья цепочки графа имеют две общие вершины: main() и func4().
Не вдаваясь в подробности, сосредоточим внимание на том, что под локальные переменные функций, вызываемых из функции main(), выделяется одна и та же область памяти (она может быть разного объема, но начинается для всех с одного и того же адреса).
Примечание. Из-за такой стратегии размещения локальных переменных PICC и PICC18 не позволяют делать рекурсивные вызовы.
Вернемся к нашему примеру. В ОСРВ OSA все функции задачи вызываются (хоть и не напрямую) планировщиком OS_Run, который расположен в функции main(). Следовательно, вне зависимости от стратегии выделения памяти под локальные переменные, получается так, что локальные переменные всех функций-задач будут начинаться по одному и тому же адресу. Для MCC при вызове из main() мы в любую задачу попадаем с одними и теми же значениями регистров-указателей стека; для PICC линкер, построив граф вызовов, разместит все задачи на одном уровне после main().
Итак, что же происходит с переменной cTemp1 при выполнении OS_Yield()? Сперва производится возврат в планировщик, который, в свою очередь, принимает решение, что нужно запускать задачу Task_T2. Получив управление Task_T2 своей локальной переменной cTemp2, которая оказывается расположенной в той же области памяти (в той же ячейке), что и локальная переменная cTemp1 из задачи Task_T1, присваивает значение "2". Т.к. cTemp1 и cTemp2 имеют один и тот же адрес, то при записи в любую из этих переменных произойдет запись и во вторую. Далее Task_T2 вызывает сервис OS_Yield, который возвращает управление планировщику, а планировщик передает управление задаче Task_T1 на строчку, следующую за вызовом OS_Yield(), т.е. на присваивание m_cTest1 = cTemp1. При этом, как мы уже поняли, значение переменной cTemp1 изменилось задачей Task_T2.
Перепишем задачу Task_T1 так:
void Task_T1 (void) { static char s_cTemp1; m_cTest1 = 0; for (;;) { s_cTemp1 = 1; OS_Yield(); m_cTest1 = s_cTemp1; } }
Установим брейкпоинты на тех же местах и попробуем выполнить программу в симуляторе. Дойдя до строки присваивания m_cTest1 = s_cTemp1, мы можем убедиться, что значение переменной s_cTemp1 осталось неизменным после того, как отработала задача Task_T2. Все дело в квалификаторе static, стоящим перед объявлением локальной переменной. Этот квалификатор говорит компилятору, что переменная должна сохранять свое значение после выхода из функции до следующего в нее входа. Эта переменная не будет размещаться в стеке (для MCC) или в области локальных переменных (для PICC), она будет помещена в отдельную область, где за ней на все время выполнения программы закрепится одна ячейка памяти.
В данном уроке мы рассмотрели важное свойство локальных переменных: их время жизни ограничено с момента входа в задачу до момента возврата в планировщик. Локальные переменные можно применять только в пределах одного сеанса работы задачи, иначе последствия непредсказуемы. Вот типичная ошибка:
void Task (void) { char i; // Ошибка здесь. Эта переменная должна быть объявлена // как static for (;;) { i = 20; while (--i) OS_Yield(); ... } }
Мы можем подвиснуть в этом цикле навсегда, а можем выйти из него на первом же шаге, в зависимости от того, как область памяти, занимаемая авто-переменной i, используется другими задачами.
Не лишним будет на первых порах работы с ОСРВ все локальные переменные внутри задачи объявлять как статические.