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

Разберем два основных метода трассировки стека в ядре Linux.

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


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

Представим, что исполняется некая программа на стеке в ОЗУ, и в какой-то момент мы ее прерываем и хотим вывести стек вызовов. Пусть у нас есть указатель на текущую инструкцию, которая выполняется процессором (PC), а также текущий указатель на вершину стека (SP). Теперь, чтобы “прыгнуть” вверх по стеку к предыдущей функции, нужно понять, что же это была за функция и в какое место этой функции мы должны прыгнуть. В ARM для этой цели используется Link Register (LR),
The Link Register (LR) is register R14. It stores the return information for subroutines, function calls, and exceptions. 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]

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

Также может возникнуть вопрос, что делать с ассемблерными функциями в этом случае. На самом деле, ничего хитрого — нужно вставлять специальные макросы. Из файла tools/objtool/Documentation/stack-validation.txt в ядре Linux:
Each callable function must be annotated as such with the ELF
function type. In asm code, this is typically done using the
ENTRY/ENDPROC macros.
Но в этом же документе обсуждается, что это является и очевидным минусом такого подхода. Утилита 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, и о ней мы поговорим чуть позже.

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

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

00008134 <my_func>:
    8134:   b084        sub sp, #16
    8136:   b580        push    {r7, lr}
    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.exidx и .ARM.extab. Вот так эта секция .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).

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

C флагом -funwind-frames:
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 0005a600 08000000 08000000 00004000 2**14
CONTENTS, ALLOC, LOAD, CODE
1 .ARM.exidx 00003fd8 0805a600 0805a600 0005e600 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
2 .ARM.extab 000049d0 0805e5d8 0805e5d8 000625d8 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
3 .rodata 0003e380 08062fc0 08062fc0 00066fc0 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 00000008 08058b1c 08058b1c 0005cb1c 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
2 .rodata 0003e380 08058b40 08058b40 0005cb40 2**5


Несложно подсчитать, что секции .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 фактически нигде не применяется. Вероятно, это следствие стремления сделать наиболее эффективный код во время работы, а действия по отладке (к которым относится и раскрутка стека) вынести в оффлайн. С другой стороны, если в ОС уже используется код на C++, то вполне можно воспользоваться реализацией трассировки через .ARM.exidx.

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

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


  1. ser-mk
    26.09.2018 01:19

    Спасибо за статью.
    А вы не разбирались как gdb догадывается как ему раскручивать стек по первому или второму методу?


    1. alexkalmuk Автор
      26.09.2018 10:23
      +1

      У gdb свой метод — через отладочную информацию в формате DWARF. Я сильно глубоко не разбирался в структуре формата, но знаю, что он более широкий, чем приведенные в статье (т.е. тот же ARM.exidx можно получить из DWARF). То есть когда вы собираете elf файл с отладочной информацией, компилятор создает там секцию .debug_frame, в которой лежит информация в формате DWARF, предназначенная для раскрутки стека. Посмотреть на то как она выглядит можно через readelf --debug [file]

      p.s.
      Немного дополню: Два метода приведенные в статье — это методы трассировки стека в обычном режиме работы программы. Без подключения отладчика.


  1. Rayslava
    27.09.2018 08:55

    По поводу fast unwind для Thumb-инструкций, это проблема конкретно GCC, в clang всё починили и регистры сохраняются в два этапа.
    Мы сделали такое же для GCC, чтобы заставить нормально работать Address Sanitizer для armv7l, однако ребята из ARM упёрлись, сказали, «clang is broken», и отказались принимать наш патч. Если нужен, можете его позаимствовать из мейллиста и положить на свой тулчейн.


    1. alexkalmuk Автор
      27.09.2018 12:40

      Спасибо.
      Я тоже находил эту багу в llvm когда разбирался с gcс, но вот Ваш патч тогда не нагуглился. Похоже ARM убрал фреймы в новом стандарте AAPCS. И насколько я понимаю, когда они переделывали APCS, то одним из аргументов отказаться от фреймов была производительность, поэтому они сейчас на такие патчи реагируют негативно. Ну и плюс для тех же Cortex-M, ARM активно внедрял аппаратную отладку и профилирование, те же ITM.

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