По следам одного бага

Для многих программистов начавших путь в эмбеддед с Си рано или поздно встает вопрос: а нужно ли учить ассемблер? И правда зачем? Ведь объективно Си гораздо удобнее ассемблера.
Признаться честно, для меня такого вопроса никогда не стояло, так как волею судьбы я начал изучение программирования именно с ассемблера и до сих пор об этом ни капли не жалею. В этой небольшой статье я хочу привести пример того, как знание ассемблера мне помогло отловить баг, который иначе я возможно ловил бы очень долго.

Начало.


На работе поставили задачу написать софт для контроллера панели управления. Панель представляла собой набор кнопок, энкодеров, светодиодов и прочего добра. В качестве контроллера был выбран ATmega16. Суть работы заключалась в следующем: при нажатии на кнопку или изменении положения энкодера панель передает скан коды кнопок и энкодеров хосту по физическому каналу RS485; при нажатии на кнопку дополнительно генерируется звуковой сигнал. В общем, ничего сложного. Я написал необходимый софт, проверил отдельно работу клавиатуры — работает. Следом, тоже отдельно проверил работу энкодеров — тоже все работает. Соединил все вместе… и тут появились странности. Контроллер периодически посылал в сеть какие-то произвольные данные.

Поиски.

Стал разбираться. Поскольку на плате не был выведен разъем для JTAG, решил запустить симуляцию в Proteus, чтобы хоть от чего-то оттолкнуться. Собрал схемку, подключил исходник, запустил симуляцию и в виртуальном терменале Proteus получил тот же мусор, что наблюдал в железе.
Стал рыть дальше. При помощи сложной магии, отладчика Proteus и множества точек останова удалось локализовать участок кода в котором творилось безобразие. Это оказалась функция генерации скан кода нажатых кнопок в матричной клавиатуре, а конкретно — сторка:

press = (special_scancode_previes[col] ^ scancode_now[col]) & special_scancode_previes[col];


Причем первое появление бага начиналось именно с этой строки при значении переменной col = 5 по прошествии 10,487 сек с начала симуляции.

Неправильность работы заключалось в неверных значения переменных в этот момент времени:

special_scancode_previes[col] = 0x01FF
scancode_now[col] = 0x01FF


Следовательно переменная press должна равняться 0. Так и было до момента времени 10,485. А в этот момент переменная press вдруг принимала значение 511.



Промучившись еще где-то минут десять и увидев, что дело как-то не движется, я решил прибегнуть к последнему и самому мощному оружию отладки. Я решил посмотреть что же происходит на этом участке кода на уровне ассемблера. Запустив дизассемблер я увидел следующую картину.
При значении переменной col = 4, все работало отлично. Но при col = 5 начинались непонятки. При выполнении команды EOR R26, R16 Неожиданно обнулялись регистры R16 и R17. Таким образом программа вместо того, чтобы «проксорить» регистры R26 = 0xFF и R16 = 0xFF и получить результат R26 = 0, «ксорила» R26 = 0xFF и R16 = 0x00 и получала результат R26 = 0xFF.

Вот так должно быть на самом деле:

А вот, что выходит:

Тут на сцену выходит его величество Случай. Совершая в очередной раз пошаговую отладку проблемного участка я использовал клавишу F11, а не F10.
Примечание: клавиша F11 в Proteus используется для, так называемой отладки «с заходом в функцию», т.е если на вашем пути встречается некая функция, то отладчик заглядывает внутрь этой функции позволяя вам так же пошагово выполнять тело этой функции. При использовании клавиши F10, отладчик этого не делает. Он как бы перешагивает через функцию.

В результате применения секретного оружия (клавиши F11) выяснилось, что на команде EOR R26, R16 в работу функции вклинивается прерывание.


Пожалуй, я не буду подробно описывать, какими словами я самозабвенно ругал себя за невнимательность.
Наконец успокоившись я предположил, что само по себе прерывание не должно стать источником подобного бага. Ведь в прерывании должны сохраняться все регистры процессора, а при выходе из прерывания эти регистры должны восстанавливаться (как, человек, пришедший из ассемблера, я был в этом свято уверен. — Прим. Автор). Следовательно, прерывание не должно обнулять регистры R16 и R17. Для подтверждения этой теории я решил и тут применить дизассемблер. Сделав это, я увидел следующую картину:

Все честь по чести, регистры R16 и R17 исправно сохраняются и восстанавливаются.
Начал снова исполнять программу по шагам и вот тут меня ждал самый большой сюрприз. Оказалось, что при сохранении регистров, когда очередь доходит до команды ST -Y, R17, стек уже заполнен, а указатель стека (в качестве которого выступают регистры YH и YL) находится, на самом дне.

Вот и получается, что программа сохраняет наши регистры R16 и R17 неизвестно куда, а восстанавливает потом нули.


Т.е. мы имеем дело с банальной ошибкой «срыв стека». Но почему так происходит? Пытаюсь выяснить каким значением инициализируется регистр Y.
В прерывании команд его инициализации нет. Раз в прерывании нет команд инициализации регистра Y, то логично предположить, что он инициализируется один раз в начале программы и используется только для указателя стека. Запускаю программу сначала и вижу, что YL = 0x20 и YH = 0x01. Следовательно размер нашего стека равен 0x20 и его не хватает.
И тут в мою светлую голову приходит очередная догадка. Я вспоминаю, что в IARе есть такой параметр, как CSTACK. Лезу в настройки IDE — и точно: этот параметр равен 0x20. Увеличиваю этот параметр в два раза, снова запускаю отладку и… вуаля! Баг исчез!

Вывод.

Выводов тут два:
  1. Нужно посвящать время изучению документации на используемый компилятор.
  2. Знание ассемблера облегчает жизнь.

Комментарии (39)

RSS свернуть / развернуть
Простой и поучительный пример. А вот как-то я протупил, каким образом все это завязалось на 10,487 секунд от начала работы?
0
Видимо, именно в этот момент прерывание прилетает в неудачном месте.
0
Уважаемый Vga прав. Получается, что именно в этот момент размера стека не хватает, чтобы сохранить все 16 регистров в прерывании. И регистры R16 и R17 остаются за бортом. Потом некоторое время снова все как бы хорошо и примерно на 26-й секунде опять вылезает этот баг.
0
Каждый про своё, а вшивый про баню… (это я прежде всего про себя)

Много лет назад, когда я прочно (казалось бы!) сидел в Виндовс и компилил свои мега-проги в крякнутом IAR-e я тоже иногда сталкивался с такими же ошибками. Так что тема мне известна. Но я о другом.

Я не знаю почему, но в Виндовых кросс-компиляторах для AVR, отличие от родных комповых компиляторов (для Виндовс), при генерации кода используется двух-стековая система — один стек содержит адреса возвратов, другой — используется для данных. В традиционных программах для компов, и в кросс-компиляторах для других микроконтроллеров (MSP430, ARM) — используется единый стек на всё-про-всё.

Мы знаем, что стек «растет» от старших адресов к младшим — навстречу к данным. Когда эта встреча происходит — это очень неприятное известие. Это значит, вы исчерпали ВСЮ оперативную память и теперь у вас два пути — либо менять процессор на более мощный, либо заниматься оптимизацией программы вручную. Как говориться — то ещё счастье!

Но когда судьба прижимает к стенке, можно и поднапрячься. Обычно получается немного выкроить памяти. Но тут особых проблем нет, так условно мы имеем одну свободную область в памяти, которую захватываем с двух сторон — со стороны данных, и со стороны стека.

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

Приходится решать головоломку: какая область память быстрее исчерпается — та, где стек возвратов, или та, где стек данных? Куда что двигать?

Честно говоря, это какой-то дурдом! Хотя может быть я чего-то и не понял в этом архитектурном ансамбле.

Когда же я перешел в Линух и начал знакомиться с gcc (в частности — с arv-gcc), то меня для меня был реально праздник, когда я увидел, что gcc использует один стек для хранения данных и адресов возврата. Это произвело на меня настолько мощное положительное впечатление, что я даже сейчас не могу удержаться и не сообщить об этом.

И на самом деле, когда система создает единый стек, то танцы с бубном сами собой становятся как-то ни к чему. Возможно, это надо понять не умом, а прочувствовать на практике.
0
Т.е. такое поведение характерно для всех компиляторов под AVR? И для IAR и для WinAVR?
0
У WinAVR'а один стек. Еще куча есть, но то немного другое.
0
WinAVR — это тот же avr-gcc только для платформы Виндовс.

Вообще в мире есть еще CodeVision. Я на нем тоже по первости создавал свои крутые мега-программы. У него тоже было два стека.
0
Отдельный стек данных в IAR нужен как бы для оптимизации. Указатель стека в AVR находится в области портов ввода-вывода, и чтоб получить кадр стека нужно его атомарно сдвинуть, а потом атомарно вернуть на место. Атомарно — потому, что в любой момонт может возникнуть прерывание и изменить указатель стека. А когда стек данных отделный с указателем в регистровой паре, то его можно атомарно изменять одной инструкцией. Так что разработчики IAR посчитали, что эффективность использования стека перевешивает неудобство от двух раздельных областей стека.
0
Спасибо, но я не до конца понял про опасность «неатомарности сдвига стека».

На сколько я понимаю, как только возникает прерывание и процессор начинает подготовку его обслуживанию (запрещает прерывания, уходит по вектору и т.д.), то с точки зрения прерванной программы — тут вроде бы не должно быть подводных камней — программа (если у нее нет часов абсолютного времени, конечно) вообще не должна заметить, что ей «стукнули по башке» и она пролежала в бессознании 10 миллисекунд. Ведь по возврату из прерывания все регистры и флаги должны быть восстановлены. (Иначе это нужно считать кривой реализацией обработчика прерывания.)

Я не вижу опасности при работе со стеком. Можете чуть-чуть подробнее сказать или привести пример.

Спасибо.
0
Вот так выглядит подготовка кадра стека в avr-gcc 4.6.2:
push	r28
push	r29
in	r28, 0x3d	; SP_HI
in	r29, 0x3e	; SP_LO
sbiw	r28, 0x0a	; 10
in	r0, 0x3f	; SREG
cli
out	0x3e, r29	; SP_LO
out	0x3f, r0	; SREG
out	0x3d, r28	; SP_HI

Указатель стека двухбайтный — читается-пишется двумя инструкциями в резистры r28-r29. Если между двумя инструкциями записи указателя стека произойдет прерывание, оно будет использовать указатель стека записанный на половину. Таким образом может затереть что-то нужное в стеке. По этому при записи указателя стека прерывания всегда запрещаются.
+1
А-а! Да-да-да! Точно! Спасибо, neiver.

Я, работая в последнее время в основном с STM32 и MSP430, как-то забыл про этот AVR-кошмар — особенность регистра SP.

Действительно, регистр указателя стека у AVR состоит из двух однобайтовых бестий (ячеек). Эти ячейки точно такие же байтовые, как и другие, которые тоже располжены в адресном пространстве данных. Разница лишь в том, что регистры — это физически другая область на кремниевом чипе в отличие от обнобайтовых ячеек памяти ОЗУ. Хотя те и другие находятся в адресном пространстве данных.
Более того, даже регистр состояния — это ячейка в области памяти данных. Это даже не смешно!

А поскольку эта область (область памяти данных) имеет минимальную грануляцию — байтовую, то организовать в ней двухбайтовый регистр (в частности — регистр SP) ну никак нельзя. Отсюда и получилось, что регистр стека оказался состоящим из двух независимых половинок, которые приходится читать/писать индивидуально. Это уязвимость архитектуры.

Да. Это еще одна бомба, заложенная в архитектуру AVR изначально. Забыл я о ней.

Другая же бомба — это есть Гарвардская архитектура с разными областями памяти: память программ и память данных. Как следствие — невозможность использовать функции стандартной библиотеки Си для обработки данных, находящихся в программной памяти. Например, константные строки для сообщений об ошибках.

Возвращаюсь к разговору. Ну, хорошо. Ну, лоханулись разработчики AVR-архитектуры немного. Бывает. Заложили парочку фугасов в фундамент. Теперь пользователям этой архитектуры пришлось натянуть черно-оранжево-полосатые ленточки и оградить себя от случайного барабума. Программисты со временем смирились, привыкли к глупым ограничениям. За ленточки не заходят. Хотя, этот топик показывает, что иногда люди — да, бывает забывают или просто не ведают об опасностях. Но дело не в этом.

Дело в том, кто (какие фирмы) и как борются с косяками, заложенными в этой архитектуре. Одни создают два стека, и тем самым проблему переводят из одной плоскости в другую (суть этого топика). Другие — пытаются работать обеспечить жизнь в одностековой вселенной. При этом терпят несколько больший расход памяти (дополнительные «скобочки» CLI/SEI и танцы с копированием Y-регистра в SP). Ну и другие «прелести».

Ну, вот, теперь всё встает на свои места. И теперь понятно, откуда растут ноги.

C
0
Эта «уязвимость архитектуры» т.с. также является основным достоинством AVR, позволяя выполнять большинство инструкций за 1такт. В любой архитектуре есть достоинства и недостатки (включая Ваши и мои STM32 и MSP430).
0
Абсолютно согласен. Серебряной пули не бывает.

В свое время AVR был очень даже хорош на фоне других типов микроконтроллеров (ядер), чем и завоевал себе признательность широких масс. Этого никто не умаляет. Но мир не стоит на месте.

Но сейчас, сравнивая 20-рублевую STM32F030F4 в кузове TSSOP-20 (я позиционирую эту букашку как продвинутый ATTINY2313), я прихожу к мысли, что по многим параметрам (если не по всем) рациональнее использовать STM32, а не AVR.

Если же опираясь на одинаковую цену (надо же от чего-то отталкиваться? Тогда пусть это будет — цена.) сравнивать любые Меги и Тайни, то результаты будут такими же.

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

А по тем временам — да, такты имели значение. Но сейчас это уже не играет никакой роли. По нынешним временам более важна цена, энергопотребление, и другие показатели.

Меня еще что привлекает в STM32 в противовес AVR-кам — при переходе от одного МК к другому периферия (регистры, расположение битов) — остаются неизменными.
Ну и очень приятно, когда я могу программно изменять частоту ядра, переводя процессор в режим спячки и дополнительно снижая ему частоту.

В прочем. Разговор уже пошёл ни о чём. Надо закрывать дискуссию.
0
Уже на абзаце «начало» понял, что косяк был в прерываниях.
0
Молодец! На, возьми:
0
Не в прерывании, а в срыве стека. Прерывание его только опрокинуло, но это могло быть и глубокое зарывание в функции. Правда легче бы отловилось, рушилось бы в одном и том же месте. Хотя на какой-нибудь RTOS поди еще поймай.
0
Тоже, работая в IAR, неоднократно натыкался на грабли с нехваткой стека. В этом случае контроллировать потребление стека помогает файл листинга (настраивается в опциях проекта в разделе компилятора). В нём в самом конце для каждой функции (в т.ч. прерывания) указывается потребление обоих стеков — «Maximum stack usage in bytes:». Надо только просуммировать все возможные вложенные вызовы, не забыв про прерывания.
+1
  • avatar
  • uRTOS
  • 11 декабря 2013, 14:04
Спасибо за совет. Нужно будет использовать эту опцию.
0
Я кстате прочитав сей пост сразу же задался мыслью «а нет ли какихто инструментов которые на этапе компиляции просигнализируют о нехватке места в стеке?». По моему в современных IDE это должно быть. или в embedded всё очень печально с подобными штуками?
0
Я про такие инструменты не знаю. Если бы знал, то не столкнулся с проблемой, которую описывал. :)
Один способ описал uRTOS, но его трудно назвать достаточно удобным. :)
Вообще, Вы правы, в embedded с удобными и дружелюбными IDE проблемы. Я конечно, не со всеми IDE работал, но все же. Однако совсем недавно я заново открыл для себя AVRStudio. Шестая версия этой среды разработки, это та программа о которой я с уверенностью могу сказать, что хочу с ней работать. Очень меня порадовала и впечатлила. Там и встроенная в IDE система контроля версий (чего я раньше не мог найти в средах разработки для микроконтроллеров), и богатая библиотека уже готовых исходных кодов, который очень быстро и просто подключаются к IDE (та же библиотека LUFA). Свой проект можно залить на облако, которое любезно предоставляет компания Atmel и на этом плюшки не заканчиваются. В общем, я решил кинуть IAR и перейти на AVRStudio 6.

P.S. Надеюсь данный коммент не примут за рекламу. :)
0
Хмм, таки да, инструментов таких нет. И изучив проблему поподробнее я понял почему их нет.
Дело в том, что я по роду деятельности программист, специалист по Android (кстате да, если комуто чтото надо в этой сфере — могу помочь/подсказать :) ). И такие проблемы редко встречаются в моей практике по весьма определённым причинам отсутствуют. Но с другой стороны за что я люблю embedded так это за то, что реально надо думать головой когда пишешь код. Вообще это С достаточно мощный инструмент, но надо понимать что любой инструмент надо применять правильно, и не забывать что наш код будет выполнятся в среде с весьма ограниченными ресурсами. Кстате, я когдато развлекался тем, что смотрел как тот же GCC преобразует C код в ассемблерный (там ключ есть специальный для того чтоб он такой листинг отрыгивал), очень рекомендую посмотреть и попробовать:)

Немного оффтопа про с/с++ и ассемблер.
Я когдато думал что особо хардкорные штуки по определению должны быть на асме. Пока не углубился немного в демосцены и то что с ними связяно. Народ их до их пор делает, и вполне успешно обходится с++ с небольшими ассемблерными вставками там где производительность критична. кстатае если интересно — вот ссылочка на некий русскоязычный «обзор» того как ребята из .kkrieger делают свои творения
+1
Ух, на практике до такого не дошел, но прочитал с удовольствием. Получается, что незнание ассемблера, компиляторов и принципов работы чипа может повернутся багом, который в принципе не сможешь раскопать.
0
  • avatar
  • Ozze
  • 20 декабря 2013, 14:20
Не думаю, что его нельзя было как-то по-другому раскопать. Просто знание ассемблера, компиляторов и принципов работы чипа значительно сокращает время раскопок, :) а в идеале позволяет их полностью избежать. :)
+1
Скорей даже знание ассемблера позволяет существенно снизить требования к знанию компилятора. Т.к. всех опций компилятора и не упомнишь, где что забыл выставить, а ассемблер он прост и легко позволяет дать докопаться до баги снизу.
0
Да и компиляторов много, а ассемблер, для конкретного камня один. Хотя, конечно, знание асма не освобождает от ответственности учить компилятор. :)
0
Да и компиляторов много, а ассемблер, для конкретного камня один
Не верно (буквоедствую наверное или занудствую)…
Система команд у МК одно (Instruction set)… хотя у ARM7 — две — ARM и Thumb. А к каждому компилятору как правило идёт свой ассемблер, со своими директивами, правилами вызова асм. функция из Си иСи-функций из асм. и проч.
0
Да, это справедливо для ассемблерных вставок у компиляторов, но при диззассеблировании все равно идут команды из раздела «Instruction Set Summary» даташита. А в контексте данной статьи меня больше интересует именно дизассемблирование.
0
Знание ассемблера облегчает жизнь.
Знание, вообще — сила…
Но причём тут ассемблер? Непонятно…
По делу — использую такую конструкцию, когда размеры стека не ясны:

#include <string.h>
#include <ioavr.h>
extern "C" {
	unsigned char *GetY(void)
	{
		asm("mov R16, R28");
		asm("mov R17, R29");
		asm("ret");
	}
};
#pragma segment="CSTACK"
#pragma segment="RSTACK"
unsigned char *dbg_ptr, *dbg_begin;
#pragma inline=forced 
inline void FillStacks()
{
	unsigned short stack_size;
	// заполнение стека данных
	stack_size=(unsigned char *)__segment_end("CSTACK")-(unsigned char *)__segment_begin("CSTACK");
	memset(__segment_begin("CSTACK"), 'D', stack_size-10);
	//// очистка стека возвратов
	dbg_ptr=reinterpret_cast<unsigned char *>(*((volatile unsigned short*)0x3D));
	dbg_ptr-=6;	
	dbg_begin=(unsigned char *)__segment_begin("RSTACK");
	// заполнение стека возвратов символом 'R'
	memset(dbg_begin, 'R', dbg_ptr-dbg_begin);
}
__noreturn int main(void)
{
    FillStacks();
    // .....
    while (1) {
         // .....
    }
}

Для GCC такое же сваять несложно…
0
Ассемблер тут притом, что он мне помог докопаться до этого бага.
0
Ассемблер тут притом, что он мне помог докопаться до этого бага.
А… ошибку можно найти разными путями…
Вы так нашли, я бы иначе искал. Кто как умеет.
Задача вообще довольно сложная — размер стека(ов) точно определить довольно сложно, а иногда невозможно (рекурсия, выделение памяти динамически). Хотя тулзы и пытаются предоставить к этому средства — например IAR выдаёт call graph функций и сколько каждая из них использует стека. По нему можно построить дерево вызовов и оценить нужный объём. В теории… А на практике — нету такой программы (у меня точно нету).
Нахожу объём стека вот таким куском кода. Идея позаимствована из scmrtos. Кажется… или ещё откуда-то…
+1
Кстати, по поводу куска кода. Что-то я на ночь глядя в него «въехать» не могу.))
0
Что-то я на ночь глядя в него «въехать» не могу.
Бывает — дело житейское…
Объясню.
Функция FillStacks заполняет стек данных и возвратов заранее известным значениями('D' и 'R' соответственно). Если есть отладчик, то можно остановить МК и посмотреть содержимое стеков (меню Debug — Memory — выбрать тип памяти(SRAM) и стартовый адрес) и увидеть сколько осталось неиспорченным.
На этапе отладки в настройках проекта стеки, естественно, выделяются по-максимуму и по ходу дела можно смотреть какие значения какому стеку выделить.
Или можно написать функцию которая перидически или по запросу будет считывать данные их стеков и считать сколько байт изменили значения с 'R' или 'D' и выводить полученный размер куда-нибудь (в терминал или ещё куда). Это и будет минимально необходимый размер стека. Так сделано в scmrtos.
Если стека явно не хватает, то программа начнёт глючить (вплоть до сброса МК — если стек возвратов кончился). Опять же можно тормознуть МК и глянуть есть ли в нужных областях рама буквы R и D. Если нет, то увеличивать. Или если МК сбрасывается, то поставить breakpoint на старт программы, чтобы расмотреть содержимое до очистки SRAM-а и вызова FillStacks.
+1
Для рабочей версии прошивки можно еще поставить маркеры в конце стека. Это не особо повлияет на расход памяти, зато будет сразу же видно, если маркер затерся (в том числе можно проверять это прямо из прошивки и глохнуть, выдавая ошибку «провал стека»). Подобные приемы нередко используются продвинутыми менеджерами памяти в прикладном программировании (скажем, FastMM для Delphi).
+1
можно еще поставить маркеры в конце стека
Маркеры — это дело. Хорошая мысль.
0
Спасибо за разъяснения. Т.е. данный метод применяется на этапе отладки, чтобы определить хватает ли стека программе?
0
Т.е. данный метод применяется на этапе отладки, чтобы определить хватает ли стека программе?
Зависит от ситуации: на этапе отладки само собой, но в рабочем девайсе тоже ведь можно, ели ресурсы позволяют. Например есть устройство с GPRS/Ethrnet/CAN/JTAG(нужное подчеркнуть, недостающее добавить), на которое разработчик может послать недокументированную команду и получить ответ на вопрос сколько стека реально использовано за N, или M месяцев работы.
0
у иара кроме RSTACK есть еще и DSTACK… еще не натыкались? И еще куча есть…
0
  • avatar
  • Kino
  • 22 декабря 2013, 20:57
у иара кроме RSTACK есть еще и DSTACK
Не D, а C.
еще не натыкались?
Да, там теже проблемы могут возникать.
0
Еще не сталкивался, но уже предвкушаю. :)
0
Только зарегистрированные и авторизованные пользователи могут оставлять комментарии.