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.


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

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


  1. Costic
    21.05.2019 14:52

    Так где и какая ошибка-то была с массивом вашим?


    1. Amomum Автор
      21.05.2019 14:54

      А, там по классике — пока другое исправлял, та ошибка нашлась и минидамп не успел пригодиться.
      Как оказалось, я серверу кидал некорректный запрос и сервер мне возвращал свой собственный стектрейс :) Который мне массив для ответа и переполнял.


  1. moncruist
    22.05.2019 00:24

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


    1. Amomum Автор
      22.05.2019 01:03

      Если все равно выводить адреса, а не текстовые имена функций, то какая разница? Все равно копировать и отладчику скармливать. Разве что дамп будет поменьше.


      1. moncruist
        22.05.2019 09:30

        Я, кстати, не уверен какие артефакты генерит Keil во время сборки, но если есть наряду с бинарем на прошивку какой-нибудь elf с отладочной информацией (или без неё, неважно), то можно довольно быстро набросать скрипт на питоне, который подставляет имена функций вместо адресов. Можно даже написать, чтобы он делал это on the fly, то есть читал бы uart, находил бы стектрейс, заменял адреса на имена функций и выводил бы дальше в файл или std out.


        1. Amomum Автор
          22.05.2019 12:09

          Да, обычный кейловский бинарь axf это вроде просто elf. Но вот как раз на мысли парсить артефакты мне резко захотелось забить.


  1. Kabdim
    22.05.2019 11:32

    >> Адрес возврата из текущей функции находится в регистре LR
    В общем случае неверно, сильно зависит от компилятора и опций оптимизации. Например фукнция вызывает две фукнции, псевдокод:

    push lr
    bl A
    <- тут lr не содержит корректный адрес возврата для этой функции
    bl B
    pop pc
    


    Оптимизатор вообще творит всякое что не запрещено.

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


    1. Amomum Автор
      22.05.2019 12:11

      В общем случае неверно, сильно зависит от компилятора и опций оптимизации.

      Соглашусь, но если коллстек строит IDE, то это не принципиально. А руками стек разбирать мне все равно не очень хочется.


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

      Тоже соглашусь, просто мне это было не успело понадобиться.