Available Languages?:

OSA : Учебник. Урок 2 - Локальные переменные

Тема

Разбирая этот урок, мы убедимся в том, что переменные внутри функций-задач нужно объявлять как 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(), которая:

  • вызовет сервис инициализации ОС;
  • объявит системе, что Task_T1 и Task_T2 являются функциями-задачами;
  • запустит планировщик на выполнение.
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 и MCC30

В этих двух компиляторах локальные переменные создаются в стеке, причем в 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 происходит следующее:

  1. FSR2 → [FSR1++] - сохранение текущего значения FSR2
  2. FSR2 = FSR1 - FSR2 становится указателем фрейма локальных переменных
  3. FSR1 += x - указаетль стека увеличивается на количество байт, занимаемых под локальные переменные функции func2.

После того, как функция func2() отработает, перед выполнением return производятся следующие опреации:

  1. FSR1 -= x - восстанавливаем прежнее знанчение укаателя стека
  2. FSR2 = [–FSR1] - восстанавливаем указатель фрейма

Таким образом, к моменту вызова func3 значения регистров FSR1 и FSR2 те же, что и перед вызовом func2. Очевидно, что при входе в функцию func3 под ее локальные переменные будут заняты те же ячейки памяти, что были заняты и под func2().

Локальные переменные в PICC и PICC18

Стратегия распределения памяти под локальные переменные в этих компиляторах несколько отличается от стратегий MCC. На этапе линковки строится граф вызовов, который содержит информацию о том, какие функции из каких функций вызываются и сколько каждая функция требует памяти под свои локальные переменные. Такой граф может выглядеть, например, так (в квадратных скобках указан объем памяти под локальные переменные):

Далее линкер, опираясь на эту информацию, строит все возможные цепочки вызовов от верхушки графа до концевого узла (в нашем случае их 7), и для каждой цепочки строится своя схема выделения локальных переменных. Обратим внимание на функцию func4(), которая встречается в двух цепочках, причем, количество элементов в этих цепях различное. Это также учитывается линкером при распределении памяти под локальные переменные. Рассмотрим для примера три цепочки:

  • Task_T1 → func1
  • Task_T2 → func3 → func4
  • Task_T3 → 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.

Эксперимент 1

Перепишем задачу 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, используется другими задачами.

Компилятор не предполагает одновременной работы нескольких функций, расположенных на одном уровне в графе вызовов, поэтому, управляя операционной системой как надстройкой над компилятором, программист должен сообщать ему, какие переменные не должны пересекаться в памяти программы, объявляя их с квалификатором static.

Не лишним будет на первых порах работы с ОСРВ все локальные переменные внутри задачи объявлять как статические.

 
osa/tutorial/tutor2.txt · Последние изменения: 20.03.2012 14:23 От osa_chief
 
Creative Commons License Powered by PHP Valid XHTML 1.0 Valid CSS Driven by DokuWiki