====== Ошибка 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 ... {{winavr_func_stack.png|}} Это очень удачная модель использования стека, поскольку допускает повторный вход в функцию (в том числе рекурсию). При каждом вызове функция сама себе будет выделять фрейм, гарантируя тем самым непересекаемость областей локальных переменных для любого пути в графе вызовов. ==== Кооперативная ОС и локальные переменные ==== Для обеспечения параллельности выполнения нескольких функций (задач) ОС должна иметь механизм, способный прерывать выполнение функции в середине, передавая управление другим функциям для выполнения, а затем возвращать управление обратно прерванной функции так, чтобы она продолжила свое выполнение с того же места, на котором была прервана. Разумеется, этот же механизм должен заботиться о сохранении контекста, т.е. помимо программного счетчика должны быть сохранены/восстановлены прочие служебные регистры (указатель стека, регистр статуса, набор регистров общего назначения (РОН)) и локальные переменные. Со служебными регистрами и РОН все понятно: их набор определен изначально, и с сохранением/восстановлением сложностей не возникает. А вот с локальными переменными сложнее. В вытесняющих ОС под каждую задачу выделяется своя область стека; это гарантирует сохранность всех локальных переменных и всей истории вызовов для текущей задачи. Но на малоресурсных контроллерах вытесняющая ОС не заработает из-за отсутствия достаточного объема оперативной памяти для сохранения контекста задач; для таких контроллеров можно использовать ОС с кооперативным планировщиком. В отличие от вытесняющей ОС, планировщик которой может прервать выполнение задачи в любой момент, в кооперативной ОС переключение задач может произойти только там, где укажет программист. Например: 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). Остается проблема с локальными переменными. Есть два пути: - применить такой же подход, какой применяется в вытесняющих ОС (т.е. выделять каждой задаче свой стек); - условиться, что время жизни локальных переменных ограничено не телом функции, а интервалом между двумя переключениями задач (в нашем примере выше - между двумя 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()** выполняет безусловный переход с восстановлением ранее сохраненного контекста. ==== Текст программы ==== (Можно ##{{winavr_bug.rar|скачать}}## текст программы с файлами проекта для AVR Studio 4.18) #include #include #include //****************************************************************************** // Переменные //****************************************************************************** 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). Установите две точки останова: {{winavr_breakpoints.png}} Запустите Start-Debugging (Ctlr+Shift+Alt+F5). Далее кнопкой F5 перемещайтесь по программе, а в окне Watch наблюдайте содержимое переменых **Hello** и **World**. Вы увидите, что программа постоянно переключается между функциями **task1()** и **task2()**. Но самое главное - вы увидите, что присваиваемые слова "HELLO" и "WORLD" копируются только в одну переменную - **World**, а переменная **Hello** остается нетронутой. Почему? ==== Детальный разбор ==== Рассмотрим листинг функции **task1()** (листинг **task2()** аналогичен): 000000c2 : } //------------------------------------------------------------------------------ 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@pic24.ru]]