Pull to refresh

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

Reading time 7 min
Views 5.2K

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. Для Кейла не нашлось ничего внятного. Роняем скупую слезу. Придется лезть в стек руками.


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


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


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

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


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


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


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


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


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


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


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


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


При этом у той же 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”.


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


Примечание:

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


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


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


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


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

Tags:
Hubs:
+24
Comments 8
Comments Comments 8

Articles