Available Languages?:

Ошибка WinAVR

Вступление

Компилятор WinAVR GCC один из самых распространенных Си-компиляторов для микроконтроллеров фирмы AVR. Он был первым кандидатом на портирование OSA. В процессе изучения его особенностей (версия 20090313) была найдена ошибка, которая не позволяет полноценно портировать на него кооперативную ОС. Что значит "полноценно"? Это значит, что есть возможность использовать ОС только при отключенной оптимизации (ключ компилятору "-o0"). Это очень неприятно, т.к. при включенной оптимизации генерируется код в полтора и более раза компактнее, чем с выключенной. Кроме того, оптимизированный код получается более быстрым.

Примечание: В OSA версии 100531 найден механизм обхода проблемы

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

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

Описание ошибки

Стек в WinAVR

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

  • первым делом сохраняет пару регистров r28:r29 (Y-указатель);
  • затем выделяет стек для локальных переменных (это делается либо командами push для выделения одного байта, либо call $+1 для выделения сразу двух байт);
  • на последнем этапе указатель стека SP копируется в Y-указатель.

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

; void func (void)
    push    r28          // Сохраняем в стеке Y-указатель
    push    r29
    rcall   $+1          // Выделяем фрейм под 5 локальных переменных
    rcall   $+1
    push    r17
    in      r28, SPL     // Присваиваем Y = SP
    in      r29, SPH
    ...

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

Кооперативная ОС и локальные переменные

Для обеспечения параллельности выполнения нескольких функций (задач) ОС должна иметь механизм, способный прерывать выполнение функции в середине, передавая управление другим функциям для выполнения, а затем возвращать управление обратно прерванной функции так, чтобы она продолжила свое выполнение с того же места, на котором была прервана. Разумеется, этот же механизм должен заботиться о сохранении контекста, т.е. помимо программного счетчика должны быть сохранены/восстановлены прочие служебные регистры (указатель стека, регистр статуса, набор регистров общего назначения (РОН)) и локальные переменные. Со служебными регистрами и РОН все понятно: их набор определен изначально, и с сохранением/восстановлением сложностей не возникает. А вот с локальными переменными сложнее.

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

void Task (void)
{
    char x, y;
    lcd_init();               // инициализация модуля ЖКИ
 
    OS_Yield();               // передача управления другим задачам (фактически - планировщику)
 
    x = lcd_getx();           // вывод "Hello world" в строке под курсором
    y = lcd_gety();
    lcd_outtext(x, y + 1, "Hello world");
 
    OS_Yield();               // передача управления
 
    ...
}

В данном примере задача может потерять управление только в двух местах: при вызове системного сервиса OS_Yield(). Эта определенность позволяет минимизировать объем сохраняемых при переключении задачи данных. Объем этих данных зависит не только от типа микроконтроллера (программный счетчик, указатель стека и пр.), но и от особенностей конкретного компилятора. Например WinAVR предполагает, что регистры r2..r17 сохраняются самой функцией (если используются в ней), а регистры r18..r27, r30, r31 - применяются только для сиюминутных операций (т.е. подразумевается, что при возврате из вызываемой функции значения этих регистров будет утеряно). Это как минимум позволяет при переключении контекста не сохранять эти регистры. Кроме того, WinAVR предоставляет возможность определять некоторые функции с атрибутом __returns_twice__, который избавляет нас от необходимости сохранять и r2..r17. Т.е. весь сохраняемый контекст будет состоять из: программного счетчика и указателя фрейма (r28:r29).

Остается проблема с локальными переменными. Есть два пути:

  1. применить такой же подход, какой применяется в вытесняющих ОС (т.е. выделять каждой задаче свой стек);
  2. условиться, что время жизни локальных переменных ограничено не телом функции, а интервалом между двумя переключениями задач (в нашем примере выше - между двумя OS_Yield()).

Первый вариант требует наличия сравнительно большого объема оперативной памяти. Т.е. такого объема, который уже позволит использовать вытесняющую ОС, которая гораздо предпочтительнее кооперативной при прочих равных условиях. А вот второй вариант выглядит более приемлемым. Т.е. мы условились, что переменные x и y будут терять свои значения после каждого вызова OS_Yield() (т.е. после каждой передачи управления планировщику), и если нам понадобится текущая позиция экрана после вызова OS_Yield(), то мы уже не сможем воспользоваться прежними значениями x и y, т.к. они уже утеряны, и нам нужно будет снова воспользоваться функциями lcd_getx() и lcd_gety(). Почему значения x и y теряются? На этот вопрос мы уже отвечали: потому что в малоресурсных контроллерах приходится использовать одну область памяти для локальных переменных всех задач, и после переключения задачи значения локальных переменных текущей функции просто затрутся значениями локальных переменных параллельно выполняющейся задачи.

Как быть, если все-таки нужно сохранить значения переменных и после переключения контекста? Очень просто: объявлять из с квалификатором static:

void Task (void)
{
    static char x, y;         // Объявляем переменные вне стека
 
    lcd_init();               // инициализация модуля ЖКИ
 
    OS_Yield();               // передача управления другим задачам (фактически - планировщику)
 
    x = lcd_getx();           // вывод "Hello world" в строке под курсором
    y = lcd_gety();
    lcd_outtext(x, y + 1, "Hello world");
 
    OS_Yield();               // передача управления
 
    lcd_outtext(x, y + 2, "All animals are equal");
    ...
}

В этом примере мы не боимся потерять x и y, т.к. они имеют статические адреса и стек других задач их не сможет перетереть.

А теперь мы подошли к самой проблеме.

Проблема

Компилятор WinAVR в области локальных переменных, помимо переменных, объявленных пользователем, размещает еще и свои временные переменные (которые могут использоваться либо для хранения промежуточных результатов расчетов, либо для сокращения кода). Т.е. те переменные, к которым программист не имеет прямого доступа и которые он не может объявить как static. Это для кооперативных планировщиков плохо тем, что компилятор не знает, какие из подпрограмм, вызываемых из функции-задачи, передают управление планировщику. Вернее, есть способ ему об это сказать, объявив такие функции с атрибутом __returns_twice__, но конкретная реализация WinAVR при определенных условиях может это проигнорировать. В результате получается, что значения этих временных переменных изменяется после передачи управления планировщику, а компилятор об этом не знает и продолжает их использовать так, как будто они остались неизменными. В следующем параграфе я привел пример программы, синтезирующей эту ошибку.

Тестовая программа

Описание

Для описания ошибки я написал небольшую программу. Я умышленно не стал подключать OSA и сделал все исключительно средствами самого компилятора, а именно - воспользовался парой функций setjmp/longjmp. Суть программы проста: есть две функции task1 и task2, которые по очереди передают друг другу управление. Одна функция вызывает подпрограмму, копирующую в строковую переменную Hello слово "HELLO", а вторая вызывает подпрограмму, копирующую в строковую переменную World слово "WORLD".

Как работают функции setjmp() и longjmp, надеюсь, объяснять не нужно. В двух словах для тех, кто с ними не знаком:

  • функция setjmp() выполняет сохранение контекста (PC, SP, SREG, r2..r17);
  • функция longjmp() выполняет безусловный переход с восстановлением ранее сохраненного контекста.

Текст программы

(Можно скачать текст программы с файлами проекта для AVR Studio 4.18)

#include <avr/io.h>
#include <avr/interrupt.h>
#include <setjmp.h>
 
//******************************************************************************
//  Переменные
//******************************************************************************
 
char Hello[6];  // Буфер для хранения слова "HELLO"
char World[6];  // Буфер для хранения слова "WORLD"
 
 
jmp_buf     j_task1, j_task2;   // Контекст задач
 
 
//******************************************************************************
//  Прототипы функций
//******************************************************************************
 
void task1          (void);
void task2          (void);
void strcpy_hello   (char * data) __attribute__((noinline));
void strcpy_world   (char * data) __attribute__((noinline));
 
//******************************************************************************
//  Функции
//******************************************************************************
 
 
void main (void)
{
    task1();                // Инициализируем задачи
    task2();
 
    longjmp(j_task1, 1);    // Запускаем поочередное выполнение,
                            // начиная с задачи Taks1
}
 
//------------------------------------------------------------------------------
 
void strcpy_hello (char * str)      // Cкопировать слово "HELLO" в str
{
    str[0] = 'H';
    str[1] = 'E';
    str[2] = 'L';
    str[3] = 'L';
    str[4] = 'O';
    str[5] = 0;
}
 
//------------------------------------------------------------------------------
 
void strcpy_world (char * str)      // Cкопировать слово "WORLD" в str
{
    str[0] = 'W';
    str[1] = 'O';
    str[2] = 'R';
    str[3] = 'L';
    str[4] = 'D';
    str[5] = 0;
}
 
//------------------------------------------------------------------------------
 
void task1 (void)                                   // ЗАДАЧА 1
{
    if (!setjmp(j_task1)) return;                   // Инициализируем контекст
 
    for (;;)
    {
        Hello[1] ^= Hello[0];                       // Обращение к двум элементам массива
                                                    // на чтение и запись
        if (!setjmp(j_task1)) longjmp(j_task2, 1);  // Переключение контекста
        strcpy_hello(Hello);                        // Копируем константу "HELLO" в массив
    }
}
 
//------------------------------------------------------------------------------
 
void task2 (void)                                   //  ЗАДАЧА 2
{
    if (!setjmp(j_task2)) return;                   // Инициализируем контекст
 
    for (;;)
    {
        World[1] ^= World[0];                       // Обращение к двум элементам массива
                                                    // на чтение и запись
        if (!setjmp(j_task2)) longjmp(j_task1, 1);  // Переключение контекста
        strcpy_world(World);                        // Копируем константу "WORLD" в массив
    }
}

Обращу ваше внимание на некоторые моменты:

  • функции strcpy_hello() и strpy_world() объявлены с атрибутом __noinline__, чтобы предотвратить оптимизацию прямой подстановкой тела функции в код, т.к. для синтезирования ошибки нам обязательно нужно, чтобы это были именно вызываемые (через rcall) функции;
  • в функциях task1() и task2() есть бессмысленные строчки: "Hello[1] ^= Hello[0];" и "World[1] ^= World[0];". Они не несут никакого функционала, просто для синтезирования ошибки нужно, чтобы перед переключением контекста было произведено обращение к нулевому и первому элементам массива, причем нулевой должен быть на чтение, а первый - на запись.

Запуск программы

Откройте проект в AVR Studio и соберите его (F7). Установите две точки останова:

Запустите Start-Debugging (Ctlr+Shift+Alt+F5). Далее кнопкой F5 перемещайтесь по программе, а в окне Watch наблюдайте содержимое переменых Hello и World. Вы увидите, что программа постоянно переключается между функциями task1() и task2(). Но самое главное - вы увидите, что присваиваемые слова "HELLO" и "WORLD" копируются только в одну переменную - World, а переменная Hello остается нетронутой.

Почему?

Детальный разбор

Рассмотрим листинг функции task1() (листинг task2() аналогичен):

000000c2 <task1>:
}

//------------------------------------------------------------------------------

void task1 (void)                                   // ЗАДАЧА 1
{
  c2:   df 93           push    r29
  c4:   cf 93           push    r28
  c6:   00 d0           rcall   .+0
  c8:   cd b7           in      r28, SPL
  ca:   de b7           in      r29, SPH

  cc:   80 e6           ldi     r24, 0x60       ; if (!setjmp(j_task1)) return;
  ce:   90 e0           ldi     r25, 0x00
  d0:   28 d0           rcall   setjmp
  d2:   89 2b           or      r24, r25
  d4:   29 f4           brne    .+10

  d6:   0f 90           pop     r0
  d8:   0f 90           pop     r0
  da:   cf 91           pop     r28
  dc:   df 91           pop     r29
  de:   08 95           ret

                                                ; for (;;)
                                                ; {
  e0:   8d e7           ldi     r24, 0x7D       ;     примечание: 0x7D - адрес переменной Hello
  e2:   90 e0           ldi     r25, 0x00       ;
  e4:   89 83           std     Y+1, r24        ;
  e6:   9a 83           std     Y+2, r25        ;
  e8:   03 c0           rjmp    .+6             ;

  ea:   89 81           ldd     r24, Y+1        ;     strcpy_hello(Hello);
  ec:   9a 81           ldd     r25, Y+2        ;
  ee:   a5 df           rcall   strcpy_hello    ;

  f0:   ed e7           ldi     r30, 0x7D       ;     Hello[1] ^= Hello[0];
  f2:   f0 e0           ldi     r31, 0x00       ;
  f4:   80 81           ld      r24, Z
  f6:   ee e7           ldi     r30, 0x7E       ;
  f8:   f0 e0           ldi     r31, 0x00       ;
  fa:   90 81           ld      r25, Z
  fc:   89 27           eor     r24, r25
  fe:   80 83           st      Z, r24


 100:   80 e6           ldi     r24, 0x60       ; if (!setjmp(j_task1))
 102:   90 e0           ldi     r25, 0x00       ;
 104:   0e d0           rcall   setjmp          ;
 106:   89 2b           or      r24, r25
 108:   81 f7           brne    0xEA

 10a:   83 e8           ldi     r24, 0x83       ; longjmp(j_task2, 1);
 10c:   90 e0           ldi     r25, 0x00       ;
 10e:   61 e0           ldi     r22, 0x01       ;
 110:   70 e0           ldi     r23, 0x00       ;
 112:   28 d0           rcall   longjmp         ;

В адресах 0xC2..0xCA производится выделение 2-байтового фрейма в стеке. Далее, в адресах 0xCC..0xDE производится формирование контекста. Нас же интересует работа программы в адресах 0xE0..0x112, т.е. работа цикла.

Сразу же обратим внимание на код 0xE0..0xE6, в котором адрес строки Hello копируется во временную локальную переменную [Y+1]:[Y+2] (еще раз посмотрим код и убедимся, что сами мы локальных переменных не создавали). Далее (адрес 0xE8) происходит переход на операцию Hello[1]^=Hello[0] (адреса 0xF0..0xFE), затем попадаем на сохранение контекста setjmp() (0x100..0x104) и передачу управления задаче task2() через longjmp() (0x10A..0x112).

При передаче управления восстанавливается контекст для функции task2, в том числе указатель фрейма r28:r29 (т.е. Y). Т.к. функции идентичны, то и фрейм для них будет одного размера (2 байта) и расположен по одним и тем же адресам. Функция task2() выполняет абсолютно те же действия, что и task1(), а что самое главное - производит копирование адреса переменной World во временную переменную [Y+1]:[Y+2], затирая значение, записанное туда функцией task1(). Дойдя тем же путем, что и task1(), до вызова longjmp(), функция task2() передает управление task1() через сохраненный контекст j_task1.

Тут и начинаются проблемы. Программный счетчик восстанавливается и указывает на следующую за вызовом setjmp() команду, т.е. на адрес 0x89. После чего выполняется переход на адрес 0xEA (после перехода longjmp() r25 != r24), где и располагается вызов функции копирования строки strcpy_hello(). Обратите внимание, что в качестве параметра ей передается не фактический адрес переменной Hello, а значение, запомненное во временной переменной [Y+1]:[Y+2], т.е. то, которое было перетерто функцией task2(), и теперь по этим адресам хранится адрес не переменной Hello, а переменной World. Поэтому функция strcpy_hello запишет слово "HELLO" в переменную World, а не в Hello.

Вот такая проблема.

Заключение

Почему я это назвал ошибкой

Хоть я и создал пример, синтезирующий описанную ошибку, но мне так и не удалось четко сформулировать условия ее проявления. Попробуйте модифицировать код (например, убрать выражение Hello[1] ^= Hello[0], или поменять в нем индексы, или убрать setjmp/longjmp) и программа заработает, т.е. начнет передавать в функцию strcpy_hello() фактический адрес переменной, а не его копию. Т.е. я не могу точно сказать, что в таком-то случае ошибка будет проявляться, а в таком не будет. Заметить удалось только то, что между выражением, обращающимся к тому же адресу, что и вызов функции strcpy_hello(), должен находиться вызов функции с атрибутом __returns_twice__.

Учитывая, что атрибут функции __returns_twice__ говорит компилятору, что все значения регистров теряются после возврата из такой функции, предполагается, что он в курсе насчет того, что и локальные переменные могут быть утеряны. Из описания:

__returns_twice__:
  The returns_twice attribute tells the  compiler that a function may return
  more than one time. The compiler will ensure that  all registers are  dead
  before calling such a function and will emit a warning about the variables
  that may be clobbered after the second return from the function.  Examples
  of such functions are  setjmp  and  vfork. The longjmp-like counterpart of
  such function, if any, might need to be marked with the noreturn attribute.

Как решить проблему

Для OSA с версии 100531 найден механизм обхода этой проблемы

Я нашел только одно проявление этой проблемы, могут быть и другие. Как побороть компилятор и заставить его не пользоваться временными переменными при обнаружении вызовов функций __returns_twice__, я пока не придумал. Объявление переменных с квалификатором volatile не спасает (ни глобальных переменных, ни параметров функций).

Единственное решение, которое пока пришло в голову - это отключение оптимизации. Без оптимизации компилятор, если и задумает воспользоваться временной переменной, то все равно занесет в нее значение столько раз, сколько оно понадобится, а не один раз на все случаи, как с оптимизацией.

Если у кого-то есть какие-то соображения, буду рад любой помощи.

Виктор Тимофеев, март 2010

osa@pic24.ru

 
osa/articles/winavr_bug.txt · Последние изменения: 16.06.2010 09:15 От osa_chief
 
Creative Commons License Powered by PHP Valid XHTML 1.0 Valid CSS Driven by DokuWiki