Post-mortem отладка на Cortex-M
Предыстория:
Участвовал я недавно в разработке нетипичного для меня девайса из класса потребительской электроники. Вроде ничего сложного, коробка, которая должна иногда выходить из спящего режима, отчитываться серверу и засыпать обратно.
Практика быстро показала, что отладчик не слишком помогает при работе с микроконтроллером, который постоянно уходит в режим глубокого сна или вырубает себе питание. В основном, потому что коробка в тестовом режиме стояла без отладчика и без меня рядом и иногда глючила. Примерно раз в несколько суток.
На соплях был прикручен отладочный UART, в который я стал выводить логи. Стало легче, часть проблем решилась. Но потом случился assert и все завертелось.
#define USER_ASSERT( statement ) \
do \
{ \
if(! (statement) ) \
{ \
DEBUG_PRINTF_ERROR( "Assertion on line %d in file %s!\n", \
__LINE__, __FILE__ ); \
\
__disable_irq(); \
while(1) \
{ \
__BKPT(0xAB); \
if(0) \
break; \
} \
} \
} while(0)
__BKPT(0xAB)
— это программная точка останова; если ассерт происходит под отладкой, то отладчик просто останавливается на проблемной строчке, очень удобно.
По некоторым ассертам сразу понятно, что их вызвало – потому что в логе видно имя файла и номер строки, на котором ассерт сработал.
Но по происходившему ассерту было понятно только, что переполнился массив – точнее, самодельная обертка над массивом, которая проверяет выход за границы. Из-за этого в логе было видно только имя файла “super_array.h” и номер строки в нем же. А какой конкретно массив – непонятно. Из окружающих логов тоже неясно.
Конечно, можно было бы просто стиснуть зубы и пойти читать свой код, но мне было лень, да и статьи бы тогда не получилось.
Поскольку я пишу в uVision Keil 5 с компилятором armcc, дальнейший код проверялся только под ним. Еще я использовал С++11, потому что уже 2019 год на дворе, пора уже.
Stacktrace
Разумеется, первое, что приходит в голову – но блин, ведь когда на нормальном настольном компе происходит ассерт, в консоль выводится стектрейс, типа как на КДПВ. Из стектрейса обычно можно понять, какая последовательность вызовов привела к ошибке.
Окей, значит мне тоже нужен стектрейс. Как бы его сделать?
Может быть, если бросить исключение, он сам выведется?
Кидаем исключение и не ловим его, видим вывод “SIGABRT” и вызов _sys_exit
. Не прокатило, ну и ладно, не очень-то и хотелось исключения разрешать.
Погуглить, как это другие люди делают.
Все способы платформозависимые (не слишком удивительно), для gcc под POSIX есть backtrace()
и execinfo.h
. Для Кейла не нашлось ничего внятного. Роняем скупую слезу. Придется лезть в стек руками.
Лезем в стек руками
Теоретически, все довольно просто.
- Адрес возврата из текущей функции находится в регистре LR, адрес текущей вершины стека (в смысле, последнего элемента в стеке) – в регистре SP, адрес текущей команды — в регистре РС.
- Каким-то образом находим размер стекового кадра для текущей функции, шагаем по стеку на такое расстояние, находим там адрес возврата для предыдущей функции и повторяем так, пока не прошагаем стек до конца.
- Как-то сопоставляем адреса возвратов с номерами строк в файлах с исходным кодом.
Окей, для начала – как узнать размер стекового кадра?
На опциях по-умолчанию – судя по всему, никак, он просто хардкодится компилятором в «пролог» и «эпилог» каждой функции, в команды, которые выделяют и освобождают кусок стека под кадр.
Но, к счастью, у armcc есть опция --use_frame_pointer
, которая выделяет регистр R11 под Frame Pointer – т.е. указатель на стековый кадр предыдущей функции. Отлично, теперь можно будет прошагать по всем стековым кадрам.
Теперь – как сопоставить адреса возвратов со строками в файлах с исходниками?
Черт, опять никак. Отладочная информация в микроконтроллер не прошивается (что неудивительно, ибо она занимает порядочно места). Можно ли Кейл все же заставить ее туда прошиваться я не знаю, найти не смог.
Вздыхаем. Значит, честный стектрейс – такой, чтобы в отладочный вывод сразу выводились имена функций и номера строк – не выйдет. Но можно выводить адреса, а потом на компе их сопоставлять с функциями и номерами строк, благо отладочная инфа в проекте все-таки есть.
Но это выглядит очень печально, потому что придется парсить .map-файл, в котором указаны диапазоны адресов, которые занимает каждая функция. А потом еще отдельно парсить файлы с дизассемблированным кодом, чтобы найти конкретную строчку. Резко возникает желание забить.
Плюс внимательное разглядывание документации на опцию --use_frame_pointer
позволяет увидеть вот эту страницу, которая говорит, что эта опция может привести к падениям в HardFault в случайные моменты времени. Мда.
Ладно, думаем дальше.
А как это делает отладчик?
А ведь отладчик как-то показывает стек вызовов даже без frame pointer’a
. Ну, понятно, как, у IDE ведь под рукой есть вся отладочная инфа, ей не составляет труда сопоставить адреса и имена функций. Хм.
При этом у той же Visual Studio есть такая штука – minidump – когда падающее приложение генерирует маленький файлик, который потом скармливаешь студии и она восстанавливает состояние приложения на момент падения. И можно все переменные рассмотреть, по стеку погулять с комфортом. Хм еще раз.
А ведь это вроде как довольно просто. Надо всего лишь каждый день втирать в ягодицы густой советский продолжение по ссылке заполнить стек значениями, которые были там в момент падения и, видимо, восстановить состояние регистров. Да и все, вроде бы?
Опять же, разбиваем эту идею на подзадачи.
- На микроконтроллере нужно пройти по стеку, для этого нужно получить текущее значение SP и адрес начала стека.
- На микроконтроллере нужно вывести значения регистров.
- В IDE нужно как-то затолкать все значения из «минидампа» обратно в стек. И значения регистров тоже.
Как получить текущее значение SP?
Желательно, не марая рук об ассемблер. В Кейле, к счастью, есть специальная функция (intrinsic) — __current_sp()
. В gcc не сработает, но мне и не надо.
Как получить адрес начала стека? Поскольку я пользуюсь своим скриптом для защиты от переполнения (про который я писал здесь ), стек у меня лежит в отдельной линкерной секции, которую я называл REGION_STACK
.
Значит, его адрес начала можно узнать у линкера, с помощью странных переменных с долларами в названиях.
Методом проб и ошибок подбираем нужное имя — Image$$REGION_STACK$$ZI$$Limit
, проверяем, работает.
Это волшебный символ, который создается на этапе линковки, поэтому строго говоря, он не является константой этапа компиляции.
Чтобы им воспользоваться, нужно разыменование:
extern unsigned int Image$$REGION_STACK$$ZI$$Limit;
using MemPointer = const uint32_t *;
// чтобы получить значение, нужно разыменование
static const auto stack_upper_address = (MemPointer) &(
Image$$REGION_STACK$$ZI$$Limit );
Если так заморачиваться не хочется, то размер стека можно просто захардкодить, благо меняется он довольно редко. В худшем случае, увидим в окне стека вызовов не все вызовы, а огрызок.
Как вывести значения регистров?
Сперва я подумал, что нужно выводить вообще все регистры общего назначения, начал мутить мутки с ассемблером, но быстро понял, что толку от этого не будет. Ведь вывод минидампа у меня будет делать специальная функция, толку от значений регистров в ее контексте никакого.
Действительно нужны только Link Register (LR), который хранит адрес возврата из текущей функции, SP, с которым мы уже разобрались и Program Counter (PC), который хранит адрес текущей команды.
Опять же, я не смог найти варианта, который работал бы с любым компилятором, но для Кейла снова есть intrinsic-функции: __return_address()
для LR и __current_pc()
для РС.
Отлично. Осталось затолкать все значения из минидампа обратно в стек, а значения регистров – в регистры.
Как загрузить "минидамп" в память?
Сначала я планировал воспользоваться командой отладчика LOAD, которая позволяет загружать значения из .hex или .bin-файла в память, но быстро выяснил, что LOAD почему-то не загружает значения в RAM.
И регистры я бы этой командой заполнить все равно бы не смог.
Ну и ладно, это все равно потребовало бы слишком много телодвижений, конвертить текст в bin, конвертить bin в hex...
К счастью, у Кейла есть симулятор, а для симулятора можно писать скрипты на некоем убогом С-подобном языке. И в этом языке есть возможность писать в память! Для этого есть специальные функции типа _WDWORD
и _WBYTE
. Собираем все идеи в кучу, и получаем вот такой код.
#define USER_ASSERT( statement ) \
do \
{ \
if(! (statement) ) \
{ \
DEBUG_PRINTF_ERROR( "Assertion on line %d in file %s!\n", \
__LINE__, __FILE__ ); \
\
print_minidump(); \
__disable_irq(); \
while(1) \
{ \
__BKPT(0xAB); \
if(0) \
break; \
} \
} \
} while(0)
// это специальный символ, который генерирует линкер
// это размер стека, регион для которого я сам так назвал в scatter-файле
extern unsigned int Image$$REGION_STACK$$ZI$$Limit;
void print_minidump()
{
// если компилятор - armcc или arm-clang
#if __CC_ARM || ( (__ARMCC_VERSION) && (__ARMCC_VERSION >= 6010050))
using MemPointer = const uint32_t *;
// чтобы получить значение, нужно разыменование
static const auto stack_upper_address = (MemPointer) &(Image$$REGION_STACK$$ZI$$Limit );
// стек растет в сторону уменьшения адресов, т.е. в данный момент заполнен кусок
// между SP и stack_upper_address
auto LR = __return_address();
auto PC = __current_pc();
auto SP = __current_sp();
auto i = 0;
DEBUG_PRINTF("\nCopy the following function for simulator to .ini-file, \n"
"start fresh debug session in simulator and call __load_minidump() from command window.\n"
"You should be able to see the call stack in CallStack window\n\n");
DEBUG_PRINTF("func void __load_minidump() {\n ");
for( MemPointer stack = (MemPointer)SP; stack <= stack_upper_address; stack++ )
{
DEBUG_PRINTF("_WDWORD (0x%p, 0x%08x); ", stack, *stack );
// лень выдумывать нормальный способ выводить красивый столбик текста
if( i == 1 )
{
DEBUG_PRINTF("\n ");
i=0;
}
else
{
i++;
}
}
DEBUG_PRINTF("\n LR = 0x%08x;", LR );
DEBUG_PRINTF("\n PC = 0x%08x;", PC );
DEBUG_PRINTF("\n SP = 0x%08x;", SP );
DEBUG_PRINTF("\n}\n");
#endif
}
Для загрузки минидампа нам нужно создать .ini-файл, скопировать в него функцию __load_minidump
, добавить этот файл в автозапуск – Project -> Options for Target -> Debug
и на разделе Use Simulator прописать этот .ini-файл в графе “Initialization file”.
Теперь просто заходим в отладку на симуляторе и, не запуская отладку, вызываем в окне команд функцию __load_minidump()
.
И вуаля, нас телепортирует в функцию print_minidump
на строку, в которой сохранился РС. А в окне Callstack+Locals видно стек вызовов.
Функция специально названа с двумя подчеркиваниями в начале, потому что если название функции или переменной в симуляторном скрипте случайно совпадет с названием в коде проекта, то Кейл не сможет ее вызвать. Стандарт С++ запрещает использовать имена с двумя подчеркиваниями в начале, поэтому вероятность совпадения имен снижается.
В принципе, это все. Насколько я смог проверить, минидамп работает и для обычных функций и для обработчиков прерываний. Будет ли он работать для всяких извращений с setjmp/longjmp
или alloca
– не знаю, поскольку извращения не практикую.
Тем, что получилось, я вполне доволен; кода мало, из накладных расходов — слегка распух макрос для ассерта. При этом вся скучная работа по разбору стека легла на плечи IDE, где ей самое место.
Потом я еще немного погуглил и нашел похожую штуку для gcc и gdb – CrashCatcher.
Я понимаю, что ничего нового не изобрел, но найти готовый рецепт, приводящий к аналогичному результату, мне не удалось. Буду признателен, если мне подскажут, что можно было сделать лучше.