Хабрахабр

Post-mortem отладка на Cortex-M

Предыстория:

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

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

Стало легче, часть проблем решилась. На соплях был прикручен отладочный UART, в который я стал выводить логи. Но потом случился assert и все завертелось.

В моем случае макрос для ассерта выглядит как-то так:

#define USER_ASSERT( statement ) \ do \ \ } \ } while(0)

__BKPT(0xAB) — это программная точка останова; если ассерт происходит под отладкой, то отладчик просто останавливается на проблемной строчке, очень удобно.

По некоторым ассертам сразу понятно, что их вызвало – потому что в логе видно имя файла и номер строки, на котором ассерт сработал.

Из-за этого в логе было видно только имя файла “super_array.h” и номер строки в нем же. Но по происходившему ассерту было понятно только, что переполнился массив – точнее, самодельная обертка над массивом, которая проверяет выход за границы. Из окружающих логов тоже неясно. А какой конкретно массив – непонятно.

Конечно, можно было бы просто стиснуть зубы и пойти читать свой код, но мне было лень, да и статьи бы тогда не получилось.

Еще я использовал С++11, потому что уже 2019 год на дворе, пора уже. Поскольку я пишу в uVision Keil 5 с компилятором armcc, дальнейший код проверялся только под ним.

Stacktrace

Из стектрейса обычно можно понять, какая последовательность вызовов привела к ошибке.
Окей, значит мне тоже нужен стектрейс. Разумеется, первое, что приходит в голову – но блин, ведь когда на нормальном настольном компе происходит ассерт, в консоль выводится стектрейс, типа как на КДПВ. Как бы его сделать?

Может быть, если бросить исключение, он сам выведется?

Не прокатило, ну и ладно, не очень-то и хотелось исключения разрешать. Кидаем исключение и не ловим его, видим вывод “SIGABRT” и вызов _sys_exit.

Погуглить, как это другие люди делают.

Для Кейла не нашлось ничего внятного. Все способы платформозависимые (не слишком удивительно), для gcc под POSIX есть backtrace() и execinfo.h. Придется лезть в стек руками. Роняем скупую слезу.

Лезем в стек руками

Теоретически, все довольно просто.

  1. Адрес возврата из текущей функции находится в регистре LR, адрес текущей вершины стека (в смысле, последнего элемента в стеке) – в регистре SP, адрес текущей команды — в регистре РС.
  2. Каким-то образом находим размер стекового кадра для текущей функции, шагаем по стеку на такое расстояние, находим там адрес возврата для предыдущей функции и повторяем так, пока не прошагаем стек до конца.
  3. Как-то сопоставляем адреса возвратов с номерами строк в файлах с исходным кодом.

Окей, для начала – как узнать размер стекового кадра?

указатель на стековый кадр предыдущей функции. На опциях по-умолчанию – судя по всему, никак, он просто хардкодится компилятором в «пролог» и «эпилог» каждой функции, в команды, которые выделяют и освобождают кусок стека под кадр.
Но, к счастью, у armcc есть опция --use_frame_pointer, которая выделяет регистр R11 под Frame Pointer – т.е. Отлично, теперь можно будет прошагать по всем стековым кадрам.

Теперь – как сопоставить адреса возвратов со строками в файлах с исходниками?

Отладочная информация в микроконтроллер не прошивается (что неудивительно, ибо она занимает порядочно места). Черт, опять никак. Можно ли Кейл все же заставить ее туда прошиваться я не знаю, найти не смог.

Значит, честный стектрейс – такой, чтобы в отладочный вывод сразу выводились имена функций и номера строк – не выйдет. Вздыхаем. Но можно выводить адреса, а потом на компе их сопоставлять с функциями и номерами строк, благо отладочная инфа в проекте все-таки есть.

А потом еще отдельно парсить файлы с дизассемблированным кодом, чтобы найти конкретную строчку. Но это выглядит очень печально, потому что придется парсить .map-файл, в котором указаны диапазоны адресов, которые занимает каждая функция. Резко возникает желание забить.

Мда.
Ладно, думаем дальше. Плюс внимательное разглядывание документации на опцию --use_frame_pointer позволяет увидеть вот эту страницу, которая говорит, что эта опция может привести к падениям в HardFault в случайные моменты времени.

А как это делает отладчик?

Ну, понятно, как, у IDE ведь под рукой есть вся отладочная инфа, ей не составляет труда сопоставить адреса и имена функций. А ведь отладчик как-то показывает стек вызовов даже без frame pointer’a. Хм.

И можно все переменные рассмотреть, по стеку погулять с комфортом. При этом у той же Visual Studio есть такая штука – minidump – когда падающее приложение генерирует маленький файлик, который потом скармливаешь студии и она восстанавливает состояние приложения на момент падения. Хм еще раз.

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

Опять же, разбиваем эту идею на подзадачи.

  1. На микроконтроллере нужно пройти по стеку, для этого нужно получить текущее значение SP и адрес начала стека.
  2. На микроконтроллере нужно вывести значения регистров.
  3. В 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”.

А в окне Callstack+Locals видно стек вызовов. Теперь просто заходим в отладку на симуляторе и, не запуская отладку, вызываем в окне команд функцию __load_minidump().
И вуаля, нас телепортирует в функцию print_minidump на строку, в которой сохранился РС.

Примечание:

Стандарт С++ запрещает использовать имена с двумя подчеркиваниями в начале, поэтому вероятность совпадения имен снижается. Функция специально названа с двумя подчеркиваниями в начале, потому что если название функции или переменной в симуляторном скрипте случайно совпадет с названием в коде проекта, то Кейл не сможет ее вызвать.

Насколько я смог проверить, минидамп работает и для обычных функций и для обработчиков прерываний. В принципе, это все. Будет ли он работать для всяких извращений с setjmp/longjmp или alloca – не знаю, поскольку извращения не практикую.

При этом вся скучная работа по разбору стека легла на плечи IDE, где ей самое место. Тем, что получилось, я вполне доволен; кода мало, из накладных расходов — слегка распух макрос для ассерта.

Потом я еще немного погуглил и нашел похожую штуку для gcc и gdb – CrashCatcher.

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть