Главная » Хабрахабр » Как работает stack trace на ARM

Как работает stack trace на ARM

Добрый день! Несколько дней назад столкнулся с небольшой проблемой в нашем проекте — в обработчике прерывания gdb неправильно выводил stack trace для Cortex-M. Поэтому в очередной раз полез выяснять, а какими способами можно получать stack trace для ARM? Какие флаги компиляции влияют на возможность трассировки стека на ARM? Как это реализовано в ядре Linux? По результатам исследований решил написать эту статью.
Разберем два основных метода трассировки стека в ядре Linux.

Stack unwind через фреймы

Начнем с простого подхода, который можно найти в ядре Линукс, но который на данный момент в GCC имеет статус deprecated.

Пусть у нас есть указатель на текущую инструкцию, которая выполняется процессором (PC), а также текущий указатель на вершину стека (SP). Представим, что исполняется некая программа на стеке в ОЗУ, и в какой-то момент мы ее прерываем и хотим вывести стек вызовов. В ARM для этой цели используется Link Register (LR),
Теперь, чтобы “прыгнуть” вверх по стеку к предыдущей функции, нужно понять, что же это была за функция и в какое место этой функции мы должны прыгнуть.

It stores the return information for subroutines, function calls, and exceptions. The Link Register (LR) is register R14. On reset, the processor sets the LR value to 0xFFFFFFFF

Далее нам нужно идти по стеку вверх и загружать новые значения регистров LR со стека. Структура стекового фрейма для компилятора выглядит следующим образом:

/* The stack backtrace structure is as follows: fp points to here: | save code pointer | [fp] | return link value | [fp, #-4] | return sp value | [fp, #-8] | return fp value | [fp, #-12] [| saved r10 value |] [| saved r9 value |] [| saved r8 value |] ... [| saved r0 value |] r0-r3 are not normally saved in a C function. */

Это описание взято из заголовочного файла GCC gcc/gcc/config/arm/arm.h.

компилятору (в нашем случае GCC) можно как-то сообщить, что мы хотим делать трассировку стека. Т.е. Можно заметить, что в этой структуре лежит нужное нам “следующее” значение регистра LR, и, что самое главное, в ней находится адрес следующего фрейма | return fp value | [fp, #-12] И тогда в прологе каждой функции компилятор будет подготавливать некую вспомогательную структуру.

В описании опции есть упоминание про “Specifying -fomit-frame-pointer with this option causes the stack frames not to be generated for leaf functions.” Здесь под leaf-функциями понимаются те, которые не делают никаких вызовов других функций, поэтому их можно сделать чуть более легкими. Такой режим компилятора задается опцией -mapcs-frame.

На самом деле, ничего хитрого — нужно вставлять специальные макросы. Также может возникнуть вопрос, что делать с ассемблерными функциями в этом случае. Из файла tools/objtool/Documentation/stack-validation.txt в ядре Linux:

In asm code, this is typically done using the
ENTRY/ENDPROC macros.
Each callable function must be annotated as such with the ELF
function type.

Но в этом же документе обсуждается, что это является и очевидным минусом такого подхода. Утилита objtool проверяет, все ли функции в ядре написаны в нужном формате для трассировки стека.

Ниже приведена функция раскручивания стека из ядра Linux:

#if defined(CONFIG_FRAME_POINTER) && !defined(CONFIG_ARM_UNWIND)
int notrace unwind_frame(struct stackframe *frame)
{ unsigned long high, low; unsigned long fp = frame->fp; /* Тут идут некоторые проверки, мы их опустим */ /* restore the registers from the stack frame */ frame->fp = *(unsigned long *)(fp - 12); frame->sp = *(unsigned long *)(fp - 8); frame->pc = *(unsigned long *)(fp - 4); return 0;
}
#endif

Но тут я хочу отметить строчку с defined(CONFIG_ARM_UNWIND). Она намекает, что в ядре Линукс используется и другая реализация unwind_frame, и о ней мы поговорим чуть позже.

Но известно, что у микроконтроллеров ARM есть и другой набор инструкций — Thumb (Thumb-1 и Thumb-2, если быть точнее), он используется в основном для серии Cortex-M. Опция -mapcs-frame верна только для набора инструкций ARM. Интересно, что эти опции на данный момент работают только для Cortex-M0/M1. Чтобы включить генерацию фреймов для режима Thumb следует использовать флаги -mtpcs-frame и -mtpcs-leaf-frame. По сути, это аналог -mapcs-frame. После того, как перечитал все опции gcc для ARM и поискал в интернете, понял, что это, вероятно, баг. Я какое-то время не мог разобраться, почему не получается скомпилировать нужный образ для Cortex-M3/M4/…. После изучения того, как компилятор генерируется фреймы для ARM, Thumb-1 и Thumb-2, я пришел к выводу, что они обошли стороной Thumb-2, т.е на данный момент фреймы генерируются только для Thumb-1 и ARM. Поэтому полез непосредственно в сами исходники компилятора arm-none-eabi-gcc. Ниже приведен дизассемблер функции, для которой сгененирован фрейм. После создания баги, разработчики GCC пояснили, что стандарт для ARM уже менялся несколько раз и эти флаги сильно устарели, но по некоторым причинам все они до сих пор существуют в компиляторе.

static int my_func(int a) { my_func2(7); return 0;
}

00008134 <my_func>: 8134: b084 sub sp, #16 8136: b580 push 8138: aa06 add r2, sp, #24 813a: 9203 str r2, [sp, #12] 813c: 467a mov r2, pc 813e: 9205 str r2, [sp, #20] 8140: 465a mov r2, fp 8142: 9202 str r2, [sp, #8] 8144: 4672 mov r2, lr 8146: 9204 str r2, [sp, #16] 8148: aa05 add r2, sp, #20 814a: 4693 mov fp, r2 814c: b082 sub sp, #8 814e: af00 add r7, sp, #0

Для сравнения, дизассемблер той же функции для инструкций ARM

000081f8 <my_func>: 81f8: e1a0c00d mov ip, sp 81fc: e92dd800 push {fp, ip, lr, pc} 8200: e24cb004 sub fp, ip, #4 8204: e24dd008 sub sp, sp, #8

На первый взгляд может показаться, что это совсем разные вещи. Но на самом деле фреймы абсолютно одинаковые, дело в том, что в Thumb режиме инструкция push разрешает укладывать на стек только low регистры (r0 — r7) и регистр lr. Для всех остальных регистров это приходится делать в два этапа через инструкции mov и str как в примере выше.

Stack unwind через исключения

Альтернативным подходом является раскручивание стека, основанное на стандарте “Exception Handling ABI for the ARM Architecture” (EHABI). По сути главным примером использования этого стандарта является обработка исключений в таких языках как С++. Информацию подготовленную компилятором для обработки исключений можно использовать также и для трассировки стека. Включается такой режим опцией GCC -fexceptions (или -funwind-frames).

Для начала, этот документ (EHABI) накладывает определенные требования на компилятор по генерации вспомогательных таблиц . Посмотрим подробнее на то, как это делается. ARM.extab. ARM.exidx и . ARM.exidx определяется в исходниках ядра Linux. Вот так эта секция . Из файла arch/arm/kernel/vmlinux.lds.h:

/* Stack unwinding tables */
#define ARM_UNWIND_SECTIONS \ . = ALIGN(8); \ .ARM.unwind_idx : { \ __start_unwind_idx = .; \ *(.ARM.exidx*) \ __stop_unwind_idx = .; \ } \

Стандарт “Exception Handling ABI for the ARM Architecture” определяет каждый элемент таблицы .ARM.exidx как следующую структуру:

struct unwind_idx { unsigned long addr_offset; unsigned long insn;
};

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

Описание этих инструкций приведено в уже упомянутом стандарте EHABI:

Далее, основная реализация этого интерпретатора в Linux находится в файле arch/arm/kernel/unwind.c

Реализация функции unwind_frame

int unwind_frame(struct stackframe *frame)
{ unsigned long low; const struct unwind_idx *idx; struct unwind_ctrl_block ctrl; /* Тут некоторые проверки, пропустим их */ /* В секции ARM.exidx бинарным поиском находим дескриптор, используя текущий PC */ idx = unwind_find_idx(frame->pc); if (!idx) { pr_warn("unwind: Index not found %08lx\n", frame->pc); return -URC_FAILURE; } ctrl.vrs[FP] = frame->fp; ctrl.vrs[SP] = frame->sp; ctrl.vrs[LR] = frame->lr; ctrl.vrs[PC] = 0; if (idx->insn == 1) /* can't unwind */ return -URC_FAILURE; else if ((idx->insn & 0x80000000) == 0) /* prel31 to the unwind table */ ctrl.insn = (unsigned long *)prel31_to_addr(&idx->insn); else if ((idx->insn & 0xff000000) == 0x80000000) /* only personality routine 0 supported in the index */ ctrl.insn = &idx->insn; else { pr_warn("unwind: Unsupported personality routine %08lx in the index at %p\n", idx->insn, idx); return -URC_FAILURE; } /* А вот здесь как раз анализируем таблицу, чтобы найти то кол-во
* инструкций, которое нужно выполнить для раскрутки стека */ /* check the personality routine */ if ((*ctrl.insn & 0xff000000) == 0x80000000) { ctrl.byte = 2; ctrl.entries = 1; } else if ((*ctrl.insn & 0xff000000) == 0x81000000) { ctrl.byte = 1; ctrl.entries = 1 + ((*ctrl.insn & 0x00ff0000) >> 16); } else { pr_warn("unwind: Unsupported personality routine %08lx at %p\n", *ctrl.insn, ctrl.insn); return -URC_FAILURE; } ctrl.check_each_pop = 0; /* Наконец, интерпретируем инструкции одна за одной */ while (ctrl.entries > 0) { int urc; if ((ctrl.sp_high - ctrl.vrs[SP]) < sizeof(ctrl.vrs)) ctrl.check_each_pop = 1; urc = unwind_exec_insn(&ctrl); if (urc < 0) return urc; if (ctrl.vrs[SP] < low || ctrl.vrs[SP] >= ctrl.sp_high) return -URC_FAILURE; } /* Некоторые проверки */ /* Наконец, обновляем значения следующего по стеку фрейма */ frame->fp = ctrl.vrs[FP]; frame->sp = ctrl.vrs[SP]; frame->lr = ctrl.vrs[LR]; frame->pc = ctrl.vrs[PC]; return URC_OK;
}

Эта реализация функции unwind_frame, которая используется, если включена опция CONFIG_ARM_UNWIND. Комментарии с объяснениями на русском я вставил прямо в исходный текст.

ARM.exidx для функции kernel_start в Embox: Ниже представлен пример того, как выглядит элемент таблицы .

$ arm-none-eabi-readelf -u build/base/bin/embox
Unwind table index '.ARM.exidx' at offset 0xaa6d4 contains 2806 entries:
<...>
0x1c3c <kernel_start>: @0xafe40 Compact model index: 1 0x9b vsp = r11 0x40 vsp = vsp - 4 0x84 0x80 pop {r11, r14} 0xb0 finish 0xb0 finish
<...>

А вот ее дизассемблер:

00001c3c <kernel_start>:
void kernel_start(void) { 1c3c: e92d4800 push {fp, lr} 1c40: e28db004 add fp, sp, #4
<...>

Давайте разберем по шагам. Видим присваивание vps = r11. (R11 это и есть FP) и далее vps = vps - 4. Это соответствует инструкции add fp, sp, #4. Далее идет pop {r11, r14}, что соответствует инструкции push {fp, lr}. Последняя инструкция finish сообщает о конце выполнения (честно говоря, до сих пор не понимаю, зачем там две инструкции finish).

Вот результаты objdump: Теперь давайте посмотрим, сколько памяти “отъедает” сборка с флагом -funwind-frames
Для эксперимента я скомпилировал Embox для платформы STM32F4-Discovery.

C флагом -funwind-frames:

ARM.exidx 00003fd8 0805a600 0805a600 0005e600 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
2 . Sections:
Idx Name Size VMA LMA File off Algn
0 .text 0005a600 08000000 08000000 00004000 2**14
CONTENTS, ALLOC, LOAD, CODE
1 . ARM.extab 000049d0 0805e5d8 0805e5d8 000625d8 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
3 .rodata 0003e380 08062fc0 08062fc0 00066fc0 2**5

Без флага:

ARM.exidx 00000008 08058b1c 08058b1c 0005cb1c 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
2 .rodata 0003e380 08058b40 08058b40 0005cb40 2**5

Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00058b1c 08000000 08000000 00004000 2**14
CONTENTS, ALLOC, LOAD, CODE
1 .

Несложно подсчитать, что секции .ARM.exidx и .ARM.extab занимают примерно 1/10 часть от размера .text. После этого я собрал образ побольше — для ARM Integrator CP на базе ARM9, и там эти секции составили 1/12 от размера секции .text. Но ясно, что такое соотношение может меняться от проекта к проекту. Также выяснилось, что размер образа, который добавляет флаг -macps-frame меньше, чем вариант с исключениями (что ожидаемо). Так, например, при размере секции .text в 600 Кб, суммарный размер .ARM.exidx + .ARM.extab составлял 50 Кб, а размер дополнительного кода c флагом -mapcs-frame всего 10 Кб. Но если мы посмотрим выше, какой большой пролог генерировался для Cortex-M1 (помните, через mov/str?), то становится понятно, что в этом случае разницы практически не будет, а значит для Thumb-режима использование -mtpcs-frame вряд ли имеет хоть какой-то смысл.

А нужен ли такой stack trace сейчас для ARM? Какие альтернативы?

Третьим подходом является трассировка стека при помощи отладчика. Похоже, многие ОС для работы с микроконтроллерами FreeRTOS, NuttX на данный момент предполагают именно этот вариант трассировки или предлагают смотреть дизассемблер.

Вероятно, это следствие стремления сделать наиболее эффективный код во время работы, а действия по отладке (к которым относится и раскрутка стека) вынести в оффлайн. В итоге мы пришли к выводу, что трассировка стека для армов в run time фактически нигде не применяется. ARM.exidx. С другой стороны, если в ОС уже используется код на C++, то вполне можно воспользоваться реализацией трассировки через .

Ну и да, проблема с неправильным выводом стека в прерывании в Embox, решилась очень просто, оказалось достаточно сохранить на стек регистр LR.


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

[Из песочницы] Разбор Memory Forensics с OtterCTF и знакомство с фреймворком Volatility

Привет, Хабр! Именно ее я хочу разобрать в этом посте, всем кому интересно — добро пожаловать под кат. Недавно закончился OtterCTF (для интересующихся — ссылка на ctftime), который в этом году меня, как человека, достаточно плотно связанного с железом откровенно ...

Манекен на турбореактивно-электрическом коптере-гибриде

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