Если при отладке зависшего ядра на AArch64 вы видите в
стеке __switch_to, не спешите искать ошибку в планировщике
— скорее всего, бэктрейс просто врёт. В статье разбираем, почему на
системах без поддержки NMI механизм dump_backtrace вынужден
читать устаревший контекст из task_struct, как именно
работает низкоуровневое переключение задач и почему
наличие __switch_to в выводе — это верный признак того, что
перед вами не реальный стек вызовов, а “цифровой призрак” из
прошлого.
__switch_to в бэктрейсе CPUТакое может быть, если стек выводит удалённый CPU.
Обычная практика для распечатки стека зависшего CPU — послать
немаскируемый IPI, который распечатает свой стек. Но на системах AArch64
и Linux ≤v6.5 (до FEAT_NMI и PseudoNMI) такая возможность
отсутствует даже гипотетически. Для большинства других систем, у которых
нет поддержки NMI IPI, эта статья тоже будет справедлива, но с немного
другими листингами.
Выход есть: спросить у планировщика, какая задача сейчас исполняется,
а затем распечатать её стек, взяв нужные данные из
struct task_struct этой задачи. Делается это с помощью
функции dump_cpu_task(cpu). Получить текущий
struct task_struct достаточно легко, указатель на него
лежит в per-CPU переменной runqueues в поле
(struct rq).curr. Затем достаточно передать этот
struct task_struct функции
dump_backtrace(regs, tsk), которая умеет работать как с
данными из регистров, так и только с данными из
struct task_struct.
Вот интересующий нас код из dump_backtrace() (в
arch/arm64/, v4.14):
if (tsk == current) {
...
} else {
/*
* task blocked in __switch_to
*/
frame.fp = thread_saved_fp(tsk);
frame.pc = thread_saved_pc(tsk);
}Т.е., если переданный struct task_struct не совпадает с
текущей исполняемой задачей, мы делаем предположение (функция распечатки
бэктрейса, которая обычно вызывается с текущего CPU), что задача
заблокирована, но не исполняется. Такое может быть, например, если мы
ждём какое-то событие, которое пока не произошло.
Макрос thread_saved_pc для регистра Program Counter
AArch64 разворачивается в конструкцию вида
tsk->thread.cpu_context.pc. Для регистра Frame Pointer
аналогично. Структура tsk->thread.cpu_context —
хранилище состояние, утилизируемое функцией
cpu_switch_to(prev, next), принимающей
struct task_struct текущей и следующей задач. Её задача
проста и состоит из двух этапов:
prev->thread.cpu_context.next->thread.cpu_context.И да, жирным выделено, потому что это единственное место, где происходит запись в эту стркутуру.
Выходит, многозадачность реализуется за счёт эксплуатации Call
Convention: входим в cpu_switch_to() как одна задача, а
выходим уже как другая задача. Например:
cpu_switch_to(), сохраняет свой
контекст и загружает контекст задачи Б.cpu_switch_to() и
продолжает работу.cpu_switch_to(), сохранит свой контекст и загрузит контекст
задачи А.cpu_switch_to().Т.е. с точки зрения одной задачи, вызов cpu_switch_to()
ни к чему не приводит. Она зашла, погоняла по памяти какое-то количество
байтов и вышла. Но только между входом и выходом исполняются другие
задачи, играющие по тем же правилам.
Этим небольшим крюком в повествовании мы выяснили, в какой момент
сохраняются PC и FP в struct task_struct. Следовательно,
теперь мы знаем условия, при которых они актуальны. Т.е. если искомая
задача в действительности не исполняется. Но, ведь когда мы вызывали
dump_backtrace(), мы передавали ему именно текущую
исполняемую задачу, полученную из struct rq целевого CPU.
Если планировщик уже выбрал эту задачу и записал её в
(struct rq).curr, то есть некоторый промежуток времени, в
течение которого задача уже в (struct rq).curr, но ещё не
вышла из своего cpu_switch_to().1 То
есть, промежуток достаточно короткий, чтобы пренебречь им.
Выходит, удалённая распечатка стека почти всегда врёт? Ведь
dump_backtrace() требует либо передачу регистров, либо
задачу, которая в данный момент не исполняется. А мы заведомо не даём
ему регистры, но даём ему задачу, которая исполняется. Следовательно,
dump_backtrace() воспользуется устаревшими данными.
Выходит, что да, почти всегда. Зачем его тогда сделали? Ну, например,
эта функция правильно выведет стек задачи, которая очень долго не
планируется на исполнение. Из такого бэктрейса мы, скорее всего, сможем
понять, что именно ожидает эта задача. Обычно переход в
TASK_[UN]INTERRUPTIBLE и перепланирование происходит в том
коде, который этого хочет.
С большой вероятностью бэктрейс удалённого CPU выведет взятый из LR
__switch_to() и какой-то мусор. Например:
[265288.847532] __switch_to+0xb8/0xe8
[265288.851018] 0xffff800433486200
[ 206.866912] __switch_to+0xc0/0x108
[ 206.870391] 0xffff8003c35d7d50
[ 206.873522] 0xffff8003c35d7d50 // ← бесконечно повторялся до WD
А может повести и по старому Stack Pointer будет лежать корректный
Frame Pointer. Тогда можно увидеть, что __switch_to()
вызывает функция, где её и в помине нет:
[401534.590958] __switch_to+0xb8/0xe8
[401534.594445] __handle_mm_fault+0xaa8/0xfe8
[401534.598624] __handle_mm_fault+0x250/0xfe8
[401534.602802] handle_mm_fault+0x134/0x1d8
[401534.606809] do_page_fault+0x128/0x3a8
[401534.610641] do_mem_abort+0x40/0xa0
[401534.671886] el0_da+0x24/0x28
Резюмирую: если __switch_to видно в
бэктрейсе при попытке распечатать бэктрейс удалённого CPU, то это
красный флаг. Стек не настоящий, в лучшем случае там будет мусор, а в
худшем — нерелевантный стек вызовов.
См. вызов context_switch() в функции
__schedule().↩︎