Привет, Хабр! Меня зовут Матвей Быстрин, я старший инженер в команде BSP (Board Support Package) YADRO. Мы занимаемся системным софтом, который необходим для загрузки и работы SoC: от загрузчиков до драйверов блоков.
В этой статье я расскажу о странностях при обходе стека в RISC-V, которые мы обнаружили пару лет назад, о том, какие нашли ошибки и как их исправили. Поломка оказалась интересна тем, что проявлялась не всегда, но звезды сошлись нужным для нас образом, и мы смогли гарантированно воспроизводить странное поведение. Начну я с самой идеи механизма обхода стека и ее применения. Затем сфокусируюсь на том, как обход производится в архитектуре RISC-V. И наконец, перейду к той самой поломке, что легла в основу статьи, и к ее ремонту.
Обозначу термины, которые буду использовать по ходу статьи:
Стек (stack) — участок памяти для хранения локальных переменных функции.
Кадр стека (stack frame) — пространство стека, которое выделяется для нужд функции.
Листовая функция (leaf function) — функция, в коде которой нет вызовов других функции.
Определения не самые строгие, но их должно быть достаточно для понимания дальнейшего повествования. Рассмотрим механизм на конкретном примере.

Вот так выглядит стек. В учебниках его обычно рисуют сверху вниз, но в статье он будет расти снизу вверх. Обход стека — это перемещение по кадрам стека функций, и обход, как правило, делают для получения цепочки вызовов. Важно отметить, что в процессе обхода мы получаем адреса, а не имена функций. Техника превращения адресов в имена функций заслуживает отдельного внимания, и в этой статье описана не будет.
Зачем вообще нужен обход стека? Я смог выделить три группы задач:
Отладка. Порой знать, где мы сломались, бывает недостаточно — нужно еще уточнить, каким путем мы пришли к месту поломки.
Измерение производительности. Иногда нужно понять, где мы больше всего тормозим при вызове функции.
Продвинутые сценарии. Очень редко попадаются нетривиальные операции, когда мы хотим узнать, что находится в нашей цепочке вызовов. Хороший пример из ядра Linux — livepatching. Более подробно об этом можно узнать в серии статей Евгения Шатохина.
Механизм обхода стека используется широко. В явном виде или нет, но почти все с ним когда-либо сталкивались, да и мне за примером далеко ходить не надо. Как-то раз у меня поломался мой излюбленный почтовый клиент aerc, который написан на Go. В сообщении видна цепочка вызовов:
goroutine 1 [running]:
runtime/debug.Stack()
runtime/debug/stack.go:24 +0x5e
git.sr.ht/~rjarry/aerc/log.PanicHandler()
git.sr.ht/~rjarry/aerc/log/panic-logger.go:49 +0x66a
panic({0x55ccb6da6b20?, 0x55ccb7284600?})
runtime/panic.go:914 +0x21f
git.sr.ht/~rjarry/aerc/app.(\*PartSwitcher).Event(...)
git.sr.ht/~rjarry/aerc/app/partswitcher.go:83
...
main.main()
git.sr.ht/~rjarry/aerc/main.go:279 +0x8e7«Под капотом» это и есть обход стека, пусть и со своими особенностями. В Go даже существует специальная функция traceback, через которую можно получить цепочку вызовов.
Способы обхода стека
Перед тем как продолжить, обращу ваше внимание на то, что существует множество способов обойти стек. Рекомендую изучить статью Брендана Грэгга Return of the Frame Pointers, где эти способы приведены. Далее мы обратим наше внимание на обход стека с использованием frame pointer.
При обходе стека с использованием frame pointer нам не нужны отдельные секции в исполняемых файлах — все необходимые данные хранятся прямо на стеке. Это сам frame pointer и адрес возврата, который, как правило, уже и так в стеке лежит.
Этот способ не бесплатный. В преамбуле каждой функции нужно этот самый frame pointer сохранять, а также выделить для него отдельный регистр. По этой причине и в силу небольшого числа регистров общего назначения в i386, frame pointer были отключены по умолчанию в компиляторах в начале 2000-х. И только к 2023 году не без значительных усилий Брэндана Грэгга большие дистрибутивы включили сборку с frame pointer.
Принцип такого обхода довольно прост. Мы сохраняем на стеке пару указателей FP и RA — в спецификации RISC-V это называется frame record. Получаем связанный список и затем просто проходим по нему. Брендан Грэгг очень ратовал за включение frame pointer в больших дистрибутивах в том числе и потому, что штраф по производительности, если верить Грэггу, здесь очень небольшой — порядка 1%, хотя и не без исключений. Переходим к Linux.
Обход стека в Linux
До механизма обхода стека можно дотянуться даже из пользовательского пространства. В /proc есть два очень интересных файла, которые я не замечал раньше:
wchan in /proc/pid
$ sudo cat /proc/2767/wchan
do_epoll_wait
stack in /proc/pid
$ sudo cat /proc/2767/stack
[<0>] do_epoll_wait+0x64c/0x790
[<0>] __x64_sys_epoll_wait+0x71/0x110
[<0>] do_syscall_64+0x82/0x190
[<0>] entry_SYSCALL_64_after_hwframe+…Чтение файла wchan выведет 0, если процесс не заблокирован, или имя функции, в которой процесс заблокирован. Чтение файла stack выведет стек вызовов.
Теперь спустимся в пространство ядра. В Linux обход используется во множестве подсистем, например:
mm: kasan: kasan_save_stack (отладка);
kdb: kdb_show_stack (отладка);
ftrace: ftrace_trace_stack (отладка);
perf: perf_callchain_user, perf_callchain_kernel (измерение производительности);
livepatch: klp_check_stack (продвинутый сценарий).
Какой бы способ обхода ни использовался, мы приходим к функции arch_stack_walk:
noinline void arch_stack_walk (struct task_struct *task,
struct pt_regs *regs,
bool (*fn)(void *, unsigned long),
void *arg
)Здесь отмечу интересное архитектурное решение. Третьим аргументом мы передаем некую функцию, которая выполняет полезную нагрузку. Сама же arch_stack_walk занимается только тем, что обходит стек. Такой вот функциональный подход!
Если вам интересна работа с Linux, обратите внимание на наши вакансии:
Обход стека в RISC-V
Раз речь зашла об архитектуре, то стоит кратко пройтись по RISC-V. Прежде всего посмотрим на регистровый файл.
x0 zero Hard-wired zero _
x1 ra Return address Caller
x2 sp Stack pointer Callee
x3 gp Global pointer _
x4 tp Thread pointer _
x5–7 t0–2 Temporaries Caller
x8 s0/fp Saved register/frame pointer Callee
x9 s1 Saved register Callee
x10–11 a0–1 Function arguments/return values Caller
x12–17 a2–7 Function arguments Caller
x18–27 s2–11 Saved registers Callee
x28–31 t3–6 Temporaries CallerИнтересны нам будут три регистра — x1, x2 и x8. Регистр x8 a ABI указан как s0/fp не просто так. Все дело в спецификации. Она не накладывает жестких ограничений на регистр x8: он может быть как и регистром s0 (saved), так и хранить frame pointer.
Спецификация RISC-V не оставляет без внимания и стек. Я не буду дословно переводить положения (заинтересованным рекомендую ознакомиться самостоятельно), а приведу свою интерпретацию, чтобы упростить понимание для тех, кто с RISC-V не сталкивался.
Стек растет вниз, от старших адресов к младшим. Указатель стека выровнен по 16 байтам. Использование указателя стека опционально, и в листовых функциях адрес возврата может не сохраняться. Также там указано, что указатель кадра и адрес возврата лежат в «начале» кадра стека. Если на данном этапе непонятно — это нормально. Позже будут гифки с наглядными иллюстрациями.
Вооруженные новыми знаниями, переходим к самом интересному.
Наша проблема с обходом стека и ее решения
Мои коллеги запускались на FPGA-прототипе, в прошивке которого не было блока USB, но имелась запись в device tree. Ядро пыталось проинициализировать несуществующую аппаратуру, и возникал вполне закономерный oops.
[57.494476] Oops - load access fault [#1]
[57.499863] Modules linked in:
[57.503893] CPU: 1 PID: 1 Comm: swapper/0 Not tainted 6.1.52-g9b41689eacda #1
[57.513092] Hardware name: FPGA (DT)
[57.518648] epc : regmap_mmio_read32le+0xe/0x1c
[57.524723] ra : regmap_mmio_read+0x2c/0x4c
[57.530252] epc : ffffffff804a2f46 ra : ffffffff804a32f2 sp : ffffffc80200b9a0
[57.539418] gp : ffffffff817d76a8 tp : ffffffd801ba0000 t0 : ffffffd805d2a700
[57.548517] t1 : ffffffc80200ba48 t2 : ffffffff86000000 s0 : ffffffc80200b9b0
[57.557591] s1 : ffffffd805c1e280 a0 : ffffffc812b80100 a1 : 0000000000000100
[57.566688] a2 : ffffffc80200ba34 a3 : 0000000000000000 a4 : ffffffff804a32c6
[57.575785] a5 : ffffffff804a2f38 a6 : 0000000000000000 a7 : ffffffff80499c0c
[57.584880] s2 : 0000000000000100 s3 : ffffffc80200ba34 s4 : ffffffc80200ba34
[57.593972] s5 : 0000000000000001 s6 : 0000000000000000 s7 : 0000000000000000
[57.603042] s8 : 0000000000000008 s9 : ffffffff80a000ac s10: 0000000000000000
[57.612116] s11: 0000000000000000 t3 : 0000000000ff0000 t4 : 0000000000000000
[57.621203] t5 : 000000000000000c t6 : 000000000000000f
[57.627899] status: 0000000200000100 badaddr: 0000000000000000 cause: 0000000000000005
[57.637909] [<ffffffff804a2f46>] regmap_mmio_read32le+0xe/0x1c
[57.645543] ---[ end trace 0000000000000000 ]---Обратите внимание на вывод стека вызова. В нем содержится всего одна функция! Это явный признак ошибки. Более того, в регистре ra содержится валидный адрес, так что стек вызовов на худой конец должен был содержать хотя бы две функции.
Я стал искать виновных, и передо мной предстало трое подозреваемых:
Компилятор. Все собиралось на тот момент уже относительно старым GCC 12.
Битстрим (также известный как прошивка для FPGA).
Ядро Linux.

Если бы я сразу попробовал воспроизвести проблему с clang, то, вероятно, cмог бы локализовать проблему гораздо быстрее.
Начинаю расследование. В статье я постарался сохранить исходный порядок своих действий. Хоть он оказался не самым оптимальным, но привел меня к разгадке. Первое, что я предпринял, — воспроизвести проблему на QEMU. Я «отпаял» USB-блок из модели и запустился с аналогичным набором артефактов. Результат тот же:
[0.754270] Oops - load access fault [#1]
[0.754611] Modules linked in:
[0.754962] CPU: 3 PID: 1 Comm: swapper/0 Not tainted 6.1.52-00367-g9b41689eacda #1
[0.755642] Hardware name: QEMU (DT)
[0.756101] epc : regmap_mmio_read32le+0xe/0x1c
[0.756912] ra : regmap_mmio_read+0x2c/0x4c
[0.757297] epc : ffffffff804a2f46 ra : ffffffff804a32f2 sp : ffffffc80200b9a0
[0.757920] gp : ffffffff817d76a8 tp : ffffffd801b40000 t0 : ffffffd805a44000
[0.758697] t1 : ffffffc80200ba48 t2 : fffffffffe000000 s0 : ffffffc80200b9b0
[0.759432] s1 : ffffffd805a42080 a0 : ffffffc812b40100 a1 : 0000000000000100
[0.760014] a2 : ffffffc80200ba34 a3 : 0000000000000000 a4 : ffffffff804a32c6
[0.761080] a5 : ffffffff804a2f38 a6 : 0000000000000000 a7 : ffffffff80499c0c
[0.761833] s2 : 0000000000000100 s3 : ffffffc80200ba34 s4 : ffffffc80200ba34
[0.762338] s5 : 0000000000000001 s6 : 0000000000000000 s7 : 0000000000000000
[0.762771] s8 : 0000000000000008 s9 : ffffffff80a000ac s10: 0000000000000000
[0.763195] s11: 0000000000000000 t3 : 0000000000ff0000 t4 : 0000000000000000
[0.763611] t5 : 000000000000000c t6 : 000000000000000f
[0.763927] status: 0000000200000100 badaddr: ffffffc812b40100 cause: 0000000000000005
[0.764891] [<ffffffff804a2f46>] regmap_mmio_read32le+0xe/0x1c
[0.765939] ---[ end trace 0000000000000000 ]---Это было большим облегчением: значит, проблема не аппаратная. Можно смело вычеркивать битстрим из ряда подозреваемых. Все свои дальнейшие изыскания я проводил на QEMU.
Второе, о чем я подумал: может, выключены frame pointer? За это у компилятора отвечают специальные опции. Открыл cmd-файл .cmd.regmap-mmio.o, в котором пишутся команды и зависимости для сборки ядра:
-fno-asynchronous-unwind-tables -fno-unwind-tables -mno-riscv-attribute -Wa
-mno-arch-attr -mstrict-align -fno-delete-null-pointer-checks -no-frame-address
-Wno-format-truncation -Wno-format-overflow -Wno-address-of-packed-member -02
-fno-allow-store-data-races -Wframe-larger-than=2048 -fstack-protector-strong
-Wno-main -Wno-unused-but-set-variable -Wno-unused-const-variable
-Wno-dangling-pointer
Нужная опция -> -fno-omit-frame-pointer
-fno-optimize-sibling-calls -ftrivial-auto-var-init=zero -fno-stack-clash-protection
-Wdeclaration-after-statement –Wvla -Wno-pointer-sign -Wcast-function-type
-Wno-stringop-truncation -Wno-stringop-overflow -Wno-restrict
-Wno-maybe-uninitialized -Wno-array-bounds -Wno-alloc-size-larger-than
-Wimplicit-fallthrough=5 -fno-strict-overflow -fno-stack-check -fconserve-stack
-Werror=date-time -Werror=incompatible-pointer-types -Werror=designated-init
-Wno-packed-not-aligned -g -mstack-protectorguard=tls
-mstack-protector-guard-reg=tp -mstack-protector-guard-offset=1096
Все опции на месте. Что же дальше? Запускаемся, подключаемся с помощью gdb к QEMU и смотрим информацию о кадре:
(gdb) info f
Stack level 0, frame at 0xffffffc80200b9b0:
pc = 0xffffffff804a2f38 in regmap_mmio_read32le
.../linux/drivers/base/regmap/regmap-mmio.c:298);
saved pc = 0xffffffff804a32f2
called by frame at 0xffffffc80200b9e0
source language c.
Arglist at 0xffffffc80200b9b0, args: ctx=0xffffffd805900280, reg=256
Locals at 0xffffffc80200b9b0, Previous frame's sp is
0xffffffc80200b9b0Фрейм ...9b0 вызван фреймом ...9e0. А stack pointer предыдущего фрейма — опять ...9b0. Что-то тут нечисто... Посмотрим дамп функции, в которой происходит ошибка:
(gdb) disassemble regmap_mmio_read32le
Dump of assembler code for function regmap_mmio_read32le:
0xffffffff804a2f38 <+0>: addi sp,sp,-16
0xffffffff804a2f3a <+2>: sd s0,8(sp)
0xffffffff804a2f3c <+4>: addi s0,sp,16
0xffffffff804a2f3e <+6>: slli a1,a1,0x20
0xffffffff804a2f40 <+8>: sd s0,8(sp)
0xffffffff804a2f42 <+10>: srli a1,a1,0x20
0xffffffff804a2f44 <+12>: add a0,a0,a1
0xffffffff804a2f46 <+14>: lw a0,0(a0)
0xffffffff804a2f48 <+16>: sext.w a0,a0
0xffffffff804a2f4a <+18>: fence i,irЗаметили что-нибудь? Вот она, наша первая улика! Адрес возврата не сохраняется на стек, хотя frame pointer сохраняется. Для сравнения посмотрим на дамп другой функции — именно адрес из тела этой функции содержится в регистре ra в дампе:
(gdb) disassemble regmap_mmio_read
Dump of assembler code for function regmap_mmio_read:
0xffffffff804a32c6 <+0>: addi sp,sp,-48
0xffffffff804a32c8 <+2>: sd s0,32(sp)
0xffffffff804a32ca <+4>: sd s1,24(sp)
0xffffffff804a32cc <+6>: sd s2,16(sp)
0xffffffff804a32ce <+8>: sd s3,8(sp)
0xffffffff804a32d0 <+10>: sd ra,40(sp)
0xffffffff804a32d2 <+12>: addi s0,sp,48
0xffffffff804a32d4 <+14>: mv s1,a0
0xffffffff804a32d6 <+16>: ld a0,16(a0)
0xffffffff804a32d8 <+18>: lui a5,0xfffff ...В чем же разница? Во первом случае нет инструкции сохранения адреса возврата: sd ra, ...
Смотрим код regmap_mmio_read32le. Функция листовая! Я подумал, что разработчики ядра просто забыли учесть краевой случай с листовой функцией, когда адрес возврата не сохраняется на стек. Обрадовался, пошел ремонтировать и наткнулся на if, где все, на первый взгляд, уже отремонтировано:
arch/riscv/include/asm/stacktrace.h
struct stackframe {
unsigned long fp;
unsigned long ra;
};
arch/riscv/kernel/stacktrace.c
frame = (struct stackframe *)fp-1;
sp = fp;
if (regs && (regs->epc== pc) && (frame->fp& 0x7)) {
fp = frame->ra;
pc = regs->ra;
} else {
...
}А что хотел сказать автор? Смотрим git blame:
commit f766f77a74f5784d8d4d3c36b1900731f97d08d0
Author: Chen Huang <chenhuang5@huawei.com>
Date: Mon Jan 11 20:40:14 2021 +0800
riscv/stacktrace: Fix stack output without ra on the stack top
When a function doesn't have a callee, then it will not push ra into the stack, such as lkdtm_BUG() function,
addi sp,sp,-16
sd s0,8(sp)
addi s0,sp,16
ebreakВ 2021 году Чен Хуанг из Huawei столкнулся со схожей проблемой. Оставил даже маленький сниппет, который очень похож на преамбулу функции в моем случае. Тем не менее ошибка никуда не делась. Продолжаем копать дальше.
Далее меня занесло в сайд-квесты. Сильно к разгадке я не продвинулся, но узнал, что не всякая функция, которая не вызывает другие функции в коде на C, является листовой (привет от стековых канареек). В итоге я начал пристально и пошагово изучать поведение кода прямо перед падением.
Здесь я слегка поменяю порядок повествования, дабы и так уже уставший читатель смог понять, в чем же была ошибка.
(gdb) x/50g $sp
0xff…b9a0: 0xffffffc80200b9e0 0xffffffc80200b9e0
0xff…b9b0: 0xffffffff8116e988 0x0000000000000100
0xff…b9c0: 0xffffffd8058adc00 0xffffffd8058adc00
0xff…b9d0: 0xffffffc80200b9f0 0xffffffff80499d1e
0xff…b9e0: 0xffffffc80200ba30 0xffffffff8049d874
0xff…b9f0: 0xffffffff8116e988 0x0000000000000001
0xff…ba00: 0x0000000000000100 0x0000000000000000
0xff…ba10: 0x0000000000000000 0xffffffd8058adc00
0xff…ba20: 0xffffffc80200ba80 0xffffffff8049ddc2
0xff…ba30: 0x000000000200ba60 0xa7701cf889805500
0xff…ba40: 0x0000000000000000 0x0000000000000000
0xff…ba50: 0x0000000000000001 0x0000000000000100Вот так выглядит стек прямо перед возникновением ошибки. Но мы вернемся на несколько вызовов назад и будем «пошагово» выполнять нашу программу.
У нас есть stack pointer, где-то какая-то функция выполняется и вызывает следующую со своей преамбулой. Первым делом мы вычитаем из указателя размер стека, затем сохраняем адрес возврата и frame pointer:

Обратите внимание на структуру stackframe: ее использует ядро, когда обходит стек. Схематически она соответствует тому, как в памяти лежит frame record. Для простоты слева — frame pointer (fp), справа — return address (ra), и так во всех фреймах.

В итоге мы приходим в последнюю, листовую функцию:

В этой функции нет локальных переменных, поэтому мы зарезервируем только 16 байт, чтобы сохранить frame record. Хорошо, мы зарезервировали, но у нас не сохраняется адрес возврата, поэтому мы сохраняем только frame pointer. Что интересно: ранее frame pointer всегда был в левой части, а адрес возврата — в правой, но почему-то в последней функции frame pointer сохраняется в правой колонке вместо левой.
Но это не самое страшное. А самое страшное — что значение, выделенное ниже красным, мы вообще не трогаем, это неинициализированное значение на стеке. В моем случае оно просто совпало, повезло. Думаю, потому что такой же адрес остался в более ранней цепочке вызовов на стеке.

О чем это говорит? В ядре в этом краевом случае проверяется frame->fp, то есть как бы проверяется левый столбец. А надо именно в этом конкретном случае проверять правый. По сути, в этом и заключалась вся поломка. Компилятор кладет значение «не туда», и ядро этот случай не совсем правильно отрабатывает. Получается преступление по сговору лиц, два виновных.
Решение проблемы
Разобраться — это полдела, надо еще починить. Самый простой способ — просто убрать эту проверку.
--- a/arch/riscv/kernel/stacktrace.c
+++ b/arch/riscv/kernel/stacktrace.c
@@ -55,7 +55,7 @@ void notrace walk_stackframe(struct task_struct
*task, struct pt_regs *regs,
/* Unwind stack frame */
frame = (struct stackframe *)fp - 1;
sp = fp;
- if (regs && (regs->epc == pc) && (frame->fp & 0x7)) {
+ if (regs && regs->epc == pc) {
fp = frame->ra;
pc = regs->ra;
} else {Проверяем. В моем случае работает, но ломает другие. Это сейчас не особо важно.
[2.828258] [<ffffffff804a2f42>] regmap_mmio_read32le+0xe/0x1c
[2.829054] [<ffffffff804a32ee>] regmap_mmio_read+0x2c/0x4c
[2.829724] [<ffffffff80499d1a>] _regmap_bus_reg_read+0x1a/0x22
[2.830408] [<ffffffff8049d870>] _regmap_read+0x40/0xee
[2.831018] [<ffffffff8049ddbe>] _regmap_update_bits+0x94/0xbe
[2.831711] [<ffffffff8049ee8c>] regmap_update_bits_base+0x40/0x64
[2.832565] [<ffffffff803f1b36>] lscu_reset_deassert+0x24/0x2c
[2.833341] [<ffffffff803f13e6>] reset_control_deassert+0x3c/0xe2
[2.834019] [<ffffffff803f1426>] reset_control_deassert+0x7c/0xe2Делаем второй подход к снаряду. Проверим, не содержится ли адрес в frame->ra в секции текста. Это тоже не очень хорошее решение. Если адрес не лежит в секции текста, это не означает, что он лежит на стеке. Так что решение все-таки неверное, хотя в моем случае ошибка пропадала.
/* Unwind stack frame */
frame = (struct stackframe *)fp - 1;
sp = fp;
- if (regs && (regs->epc == pc) && (frame->fp & 0x7)) {
+ if (regs && (regs->epc == pc) && !__kernel_text_address(frame->ra)) {
/* We hit function where ra is not saved on the stack */
fp = frame->ra;
pc = regs->ra;
} else {Третья попытка была удачной. Я написал отдельную функцию, которой нужно передать указатель на стек и значение, которое мы хотим проверить. Функция fp_is_valid скажет, находится ли значение на стеке или нет.
/* Unwind stack frame */
frame = (struct stackframe *)fp - 1;
sp = fp;
-if (regs && (regs->epc == pc) && (frame->fp & 0x7)) {
+if (regs && (regs->epc == pc) && fp_is_valid(frame->ra, sp))
/* We hit function where ra is not saved on the stack */
fp = frame->ra;
pc = regs->ra;
} else {static inline int fp_is_valid(unsigned long fp, unsigned long sp)
{
unsigned long low, high;
low = sp + sizeof(struct stackframe);
high = ALIGN(sp, THREAD_SIZE);
return !(fp < low || fp > high || fp & 0x07);
}Проверка довольно грубая: оно просто убеждается, что значение, которое передали в функцию, находится между началом стека и stack pointer.
Этот патч приняли, и все заработало — красота!
Как это работает сегодня
Патч я сделал где-то два года назад, и за это время в подсистеме многое поменялось. В GCC 14 починили поведение компилятора. Интересно, что тот патч в GCC был нацелен немного на другое — добавление опции -omit-leave-frame-pointer для явного контроля действий с frame pointer в листовых функциях. И починка моей проблемы — сайд-эффект патча: если флажком не пользоваться, то по умолчанию все будет нормально.
@@ -5079,7 +5083,11 @@ riscv_save_reg_p (unsigned int regno)
if (regno == HARD_FRAME_POINTER_REGNUM && frame_pointer_needed)
return true;
- if (regno == RETURN_ADDR_REGNUM && crtl->calls_eh_return)
+ /* Need not to use ra for leaf when frame pointer is turned off by option
+ whatever the omit-leaf-frame's value. */
+ bool keep_leaf_ra = frame_pointer_needed && crtl->is_leaf
+ && !TARGET_OMIT_LEAF_FRAME_POINTER;
+ if (regno == RETURN_ADDR_REGNUM && (crtl->calls_eh_return || keep_leaf_ra))
return true;Позднее в ядро добавили возможность «пересечения границы» при обходе стека исключений. И поддержали обход стека пользовательских программ, что, в свою очередь, может использовать perf.
Не лишним будет подчеркнуть, что обход стека в ядре — вещь, на самом деле, нетривиальная, потому что стеков может быть несколько. Более того, обход стека существует в двух разновидностях. Помимо описанного в статье, есть «надежный» (reliable) обход стека, нужный для спецсценариев — например, для livepatching. Разница здесь, в том, что в «надежном» обходе у нас имеются гарантии покрытия всех краевых случаев.
Подведем итоги
Даже если вы не работаете с ядром Linux, взглянуть на этот механизм полезно: может, он пригодится в ваших приложениях. Для этого уже есть готовые библиотеки, а в некоторых языках механизм уже встроен. Возможно, это поможет улучшить отлаживаемость ваших продуктов.
Если вы работаете с Linux, RISC-V и древним GCC (< 14), проверьте, чтобы в дереве был патч riscv: stacktrace: fixed walk_stackframe().
Надеюсь, вам было интересно понаблюдать за моим ходом мыслей по ходу этого «детектива». Выражаю благодарность коллегам, которые сильно помогли мне улучшить мой рассказ, и коллегам-редакторам. Отдельное спасибо Эльвире Кадыровой за гифки.