Привет, Хабр! Меня зовут Никита, и я разработчик в команде платформы iOS Иви.

В работе постоянно сталкиваешься с багами. В топе самых неприятных — крэш. Еще хуже — когда он неочевидный, и сразу сложно сказать, откуда «растут ноги». В этой статье разберём такой крэш пошагово и попробуем выяснить причину через LLDB.

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

Запуск демо-проекта и воспроизведение кейса
  1. Склонируйте репозиторий

  2. Откройте проект

  3. Запустите приложение

  4. Насладитесь обаянием Rick Astley

  5. Сверните приложение свайпом вверх (можно переоткрыть — тогда вылетит быстрее)

  6. Приложение упадёт — отладчик остановится в момент крэша

Важно: демо-проект собирался на Xcode 26.0, iPhone 17 Pro (Sim), iOS 26.0. При запуске под другие платформы или версии iOS поведение может различаться; смещения в дизассемблере (например, +84 вместо +76 в CALayerGetSuperlayer) тоже могут отличаться.

Для кого это написано

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

Материал основан на нашем опыте решения одной из проблем. Всё, о чём мы рассказываем ниже, — это не «правильный учебник» по LLDB, а опыт из эксперимента, который мы получили, пока работали над решением. В реальном проекте этот разбор может быть сложнее, поэтому, если у вас есть символизированный крэш-лог, или Address Sanitizer дает подсказку — скорее всего будет лучше/быстрее/качественнее разобраться с проблемой через них.

Оглавление


Предварительный диагноз

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

Время уходит, понимания почти нет. Отключать фичи по одной и смотреть, исчезнет ли крэш — тоже не вариант. Всё тесно связано. Жонглирование выключателями функционала в очередной раз ломает сценарий целиком... И наступает тот самый момент:

«Я бы хотел, чтобы это не случилось в мое время», — сказал Фродо. «И я», — сказал Гэндальф, «и все, кто доживает до таких времен. Но им не дано решать. Все, что нам дано решить, — что делать со временем, которое нам дано».

Дж. Р. Р. Толкин, «Властелин колец», книга первая, глава «Тень прошлого».

При крэше ОС сохраняет состояние процесса: где остановился CPU, регистры, память. LLDB даёт возможность посмотреть регистры, фреймы, дизассемблер и, при определенном уровне удачи, добраться до истины, сокрытой где-то в чертогах еще не затёртой памяти. Именно поэтому я решил исследовать проблему не с «начала», а с её конца.

Глоссарий

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

Терминология

  • Поток (thread). У приложения несколько потоков. Рассматриваемый крэш в main — падение в потоке, где идет работа с UI. Команды: thread list, thread select N.

  • Фрейм (stack frame). Один «этаж» в цепочке вызовов. frame #0 — место крэша; frame #1 — функция, которая вызвала код из frame 0. Команды: bt, frame select N, frame info.

  • Регистр. Ячейка в процессоре с одним значением — адрес или число.

Различие фреймов и регистров. У каждого фрейма свой «момент времени». Регистры фрейма N могут быть уже перезаписаны.

Команды

  1. backtrace/bt — отчет о состоянии стека вызовов функций в определенный момент времени

  2. frame select <N> + register read — регистры в месте падения

  3. disassemble — какая инструкция упала, и из какого регистра брался адрес

  4. po <expr> — напечатать результат выражения/объект в человекочитаемом виде


Этап 1: Крэш-лог

Откройте CrashExample, воспроизведите крэш (свернуть -> развернуть приложение). Первое, что видим — тип крэша, поток и место в коде.

«Точка невозврата»
«Точка невозврата»
  • Тип крэша: EXC_BAD_ACCESS (code=1, address=0x...)

  • Поток: thread #1, main

  • Место падения: frame #0QuartzCore CALayerGetSuperlayer + 76

Признаюсь честно. Раньше такие инструкции вызывали у меня только желание выполнить точное повторение одного и того же действия, раз за разом, в надежде на изменение. Ну или просто:

Полное непонимание происходящего
Полное непонимание происходящего

Ни вызовов, ни ворнингов санитайзеров (Thread и Address). Контекста нет — кто вызвал, зачем. Но имеем, что имеем, поэтому берём в работу.

Откуда начинаем, и чего не хватает

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


Этап 2: Чтение backtrace

backtrace в его естественной среде обитания
backtrace в его естественной среде обитания

На что смотреть, и что значат «числа» в конце

Формат вывода устроен следующим образом: frame #N: 0xАДРЕС Модуль, символ + смещение.

На «+ смещение» сделаем остановку. В ARM64 одна инструкция == 4 байта. +76 означает: крэш на 20-й инструкции внутри CALayerGetSuperlayer.

Грубая аналогия: как «строка 20 в файле», только в байтах машинного кода. Когда вызываешь disassemble, отладчик показывает каждую инструкцию с её смещением — <+72>, <+76>, <+80>. Ищем строку с <+76> — это и есть та инструкция, на которой процессор упал. По ней видно, из какого регистра брался адрес и какая операция привела к крэшу.

В ARM64 всё по 4?

ARM64 — архитектура с фиксированной длиной инструкции (fixed-length): каждая команда занимает ровно 4 байта (32 бита). Это упрощает декодирование: процессор всегда знает, где граница следующей инструкции, не нужно «разбирать» её по битам. У x86, например, инструкции переменной длины — от 1 до 15 байт. Поэтому, например, смещение +84 на ARM64 однозначно означает «22-я инструкция» (84 ÷ 4 = 21, нумерация с нуля).

Источник: Arm Developer Documentation

CALayerGetSuperlayer:
  +0   первая инструкция
  ...
  +76  ← здесь EXC_BAD_ACCESS

EXC_BAD_ACCESS (code=1, address=0x...) — процессор обратился к памяти, к которой нет доступа, или попытался использовать ее уже после высвобождения, что наводит на мысли о use-after-free.

Use-after...?

Use-after-free — ситуация, когда объект освобождён, но на него ещё остались ссылки. Кто-то пытается обратиться к уже несуществующей памяти. Классические сценарии: объект удалили из иерархии и освободили, а в subviews родителя осталась ссылка; или завершили анимацию, в completion-блоке обратились к self, но контроллер уже деинициализирован. Адрес в таком случае часто «странный»: память переиспользовали под другие данные, и по старому адресу теперь лежит мусор или чужой объект.

Источник: Wikipedia

Конечно, `EXC_BAD_ACCESS` не обязательно должен быть UAF. Причиной такого падения может выступать null derefernce, неинициализированное поле, нарушенная инвариантность объекта, какие-то иные коллизии, однако на момент расследования мы предполагали именно UAF. Про разные типы крэшей и их различия есть отдельная статья здесь.

Анализ backtrace

Чтобы лучше понять проблему, давайте посмотрим подробнее в вывод LLDB по команде bt и разберемся, что происходило в момент падения. Обратим взгляд ещё разок на скрин:

Диапазон фреймов

Что происходит

frames #32–#30

Точка входа. main, start_sim — запуск. крэш в main thread.

frames #27–#20

Run loop. UIApplicationMain, CFRunLoopRun — внутри главного цикла выполняется callback.

frame #19

Сцена. Не уход в фон и не снапшот. Срабатывает firstCommitBlock — первая транзакция в цикле (layout/trait update).

frames #18–#13

Коммит лейаута. CA::Transaction::commit, layout_and_display_if_needed, perform_update_ — QuartzCore обновляет слои; для вьюх вызывается performPreLayoutUpdateOfLayer.

frames #12–#5

Обновление трайтов. updateTraitCollectionAndProcessChangesWithBehavior, processChangesFromOldTraits, wrappedProcessTraitChanges (рекурсия по иерархии вьюх).

frames #4–#0

updateCachedTraitCollectionIfNeeded → parentTraitCollection → parentTraitEnvironment — для вьюхи нужна родительская trait environment, для этого вызывается getter superview.

Место крэша (#4–#0).

  • Frame 2: -[UIView _parentTraitEnvironment] — запрос родительского trait environment.

  • Frame 1: -[UIView superview] — внутри реализация идёт через layer/superlayer.

  • Frame 0: CALayerGetSuperlayer + 76 — чтение superlayer по невалидному адресу (0x8).

Если представить всё в виде последовательности действий:

Визуализация процесса, предшествовавшего крэшу
Визуализация процесса, предшествовавшего крэшу

Отлично. Теперь мы примерно понимаем, что произошло в системе от начала и до момента падения. Но контекста пока все равно нет. Для того, чтобы сузить круг поиска, попробуем найти указатель на сломанную вьюху в момент падения в регистре, чтобы через него попытаться подсветить ситуацию более точно. Этим и займемся. Два шага в нужном направлении уже сделаны. Идем дальше!


Этап 3: Регистры

Выбираем нужный фрейм

Если вернуться к нашим фреймам в крэше, кажется, что Frame 1 — [UIView superview] самый подходящий кандидат. В этом фрейме «self» — та самая вьюха, у которой вызвали getter superview.

Делаем frame select 1, затем po $x0...

Фиаско
Фиаско

...И не видим ничего полезного. Ошибка «Couldn't materialize» / «couldn't read the value of register x0» означает, что отладчик не может использовать текущее значение x0 как валидный указатель на объект.

По дизассемблеру видно: мы находимся на инструкции +88 — это уже эпилог функции (ldp x29, x30, [sp], #0x10), затем переход в другой метод. К этому моменту метод superview по сути завершён, регистры могли быть перезаписаны, и x0 больше не обязан содержать исходный self. Поэтому в большинстве практических случаев потенциально надёжное состояние при разборе крэша — регистры во frame 0, там мы ровно в месте падения.

Что такое "эпилог" и po $x0

Эпилог функции (function epilogue) — завершающая последовательность инструкций перед ret. В эпилоге восстанавливаются сохранённые регистры и локальный стек.

Для ARM64 (как в ldp x29, x30, [sp], #0x10):

  • x29 (FP) — frame pointer, база текущего стекового кадра

  • x30 (LR) — link register, адрес возврата в вызывающую функцию

  • ldp x29, x30, [sp], #0x10 — чтение сохранённых x29 и x30 из стека

po — команда LLDB print object (алиас к expression -O -). Она выполняет маленькое выражение и печатает его результат «по‑человечески» — через description/debugDescription, как если бы вы сделали print(...) в Swift/Objective‑C.

$x0 — обращение к машинн��му регистру x0 в текущем фрейме. В ARM64 в x0 лежит первый аргумент функции и/или self. В фрейме -[UIView superview] это как раз та самая вьюха, для которой вызывают геттер.

Вместе po $x0 значит: «возьми текущее значение регистра x0, интерпретируй его как указатель на объект и выведи этот объект так, как его напечатал бы код».

ИсточникProcedure Call Standard for the Arm 64-bit Architecture

После эпилога выполняется ret, то есть переход по адресу в x30. К этому моменту superview уже отработал, x0 мог быть перезаписан (возвращаемым значением или локальными вычислениями), поэтому x0 больше не обязан содержать исходный self. Делаем шаг назад и ищем дальше

Регистры во фрейме 0

Изначально, мы попались на привлекательную удочку в виде ссылки на self фрейма 1, но потерпели неудачу. Что еще можно посмотреть?

В frame 0 процессор остановился ровно в месте падения, регистры ещё могут быть не перезаписаны. Нам нужно понять, из какого регистра идёт обращение к памяти и какое значение там лежит — тогда можно увидеть невалидный указатель и, при удаче, символ нашего кода (класс вьюхи), который подскажет место бага.

Выполняем frame select 0, register read

Вот оно! Что-то читаемое в x15!
Вот оно! Что-то читаемое в x15!

Видим падающую инструкцию. Текущая строка (отмечена ->) — +76: ldr x8, [x8, #0x8]. Это загрузка по адресу (x8 + 0x8) в регистр x8. В выводе x8 = 0x0000000000000000 — нулевой указатель. Чтение по адресу 0x8 даёт EXC_BAD_ACCESS: источник крэша — невалидное значение в x8. Ничего не напоминает?

Связь c backtrace
Связь c backtrace

Наша вьюха осталась в памяти. В x15 отладчик показывает символ: _TtC12CrashExample10PlayerView — это mangled-имя класса CrashExample.PlayerView. То есть в момент крэша в регистрах фигурирует адрес, связанный с нашим плеерным view. Это не обязательно «вьюха из x8», но явная подсказка: проблема рядом с PlayerView и иерархией, в которой он участвует (например, его удалили или отвязали layer, а обход дерева ещё до него доходит), а это значит, что у нас есть "горячий след".

...Mangled?

Mangled-имя (name mangling) — внутреннее имя символа, которое компилятор генерирует из имени типа/функции и контекста (модуль, generic-параметры и т.п.). В нём закодированы имя типа, модуль и другие детали, поэтому оно выглядит как нечитаемая строка (например, _TtC12CrashExample10PlayerView). LLDB и другие инструменты деманглируют его обратно в человекочитаемый вид: CrashExample.PlayerView. В крэш-логах и выводе register read часто видно именно mangled-имя; по нему отладчик подсказывает класс (например, PlayerView).

ИсточникSwift name mangling.

И что теперь?

Backtrace задал направление: снэпшот, обход вьюх, вызов superview. Регистры в frame 0 довели до конкретики: x8 — невалидный указатель в падающей инструкции, x15 — подсказка на виновника, уложившего приложение (PlayerView).

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

Гипотеза: в subviews осталась ссылка на уже «освобождённую» PlayerView, и при обходе система обращается к её layer/superlayer и падает.

Говоря «освобожденную» мы немного лукавим. В ARC view не «освобождается», если она всё ещё в subviews, потому что superview держит strong reference.

Без лукавства

Фактически, на момент расследования проблемы мы предполагали:

  • Где-то ручками разрушается layer-иерархия

  • View остается

  • Система, в это время, ожидает согласованность между view и layer

  • Инвариант нарушается

  • QuartzCore падает

По сути это dangling reference внутри иерархии слоёв, вызванная ручным разрушением layer при сохранении view в дереве, т.е. view остаётся в иерархии, но её layer уже отсоединён, из-за чего нарушается ожидаемая согласованность между ними.

То есть, мы уже понимаем, что упало и в каком направлении копать в коде!

Но раз уж мы решили копать, то почему бы не пойти дальше и увидеть саму цепочку — какая инструкция что прочитала и откуда взялся мусор в x8? Причина не идти, конечно есть:

Узнали? Согласны?
Узнали? Согласны?

Тем не менее, в Этапе 4 смотрим дизассемблер — пошагово от валидного слоя до обращения по невалидному адресу и крэша.


Этап 4: Дизассемблер

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

Команда disassemble -n CALayerGetSuperlayer выводит код функции по имени символа: -n (name) — дизассемблировать функцию с указанным именем. LLDB находит символ и показывает все инструкции со смещениями (<+0>, <+76> и т.д.). Текущая инструкция (где остановился процессор) отмечена ->.

А что еще можно?
  • disassemble (без аргументов) — код вокруг текущего pc. Удобно, когда уже во фрейме крэша.

  • disassemble -a <адрес> — по адресу (например, -a $pc). Если символ не разрешился, но адрес известен.

  • disassemble -c <N> — вывести только N инструкций. Ограничивает вывод.

  • disassemble -f — показать исходные строки (если есть debug info). В системных фреймворках обычно нет.

Источник: LLDB Command Examples (Apple)

Словарь инструкций ARM64

Инструкция

Смысл

ldr xN, [xM, #offset]

Загрузить 8 байт по адресу (xM + offset) в xN

mov xA, xB

Скопировать xB в xA

cbz xN, 

Если xN == 0 — переход по адресу

bl 

Вызов функции (branch with link)

ret

Вернуться из функции

stp / ldp

Сохранить/загрузить пару регистров

Цепочка до крэша

В нашем билде (симулятор iPhone 17 Pro, iOS 26.0) процессор падает на +76. Цепочка:

  1. +12 — mov x20, x0. В x20 сохраняется первый аргумент — указатель на слой (layer).

  2. +72 — ldr x8, [x20, #0x10]. Из слоя по смещению 0x10 читается значение в x8. В падающем кейсе поле невалидно: в x8 попадает 0 (или указатель на освобождённую память).

  3. +76 — ldr x8, [x8, #0x8]. Чтение по адресу (x8 + 0x8). Если x8 = 0, это обращение к 0x8 → EXC_BAD_ACCESS. крэш происходит здесь; +80 и +84 не выполняются.

Итог: по слою (x20) читается поле по смещению 0x10; полученное значение (0 или мусор) используется как указатель при следующей загрузке. Цепочка layer → поле → разыменование подтверждает проблему с невалидным указателем в иерархии слоёв.

Важно: На других версиях iOS или устройствах смещение падающей инструкции может отличаться; смотрите на строку с -> в выводе disassemble.


Практический вывод

Как мы и предполагали, крэш происходит из-за dangling pointer в иерархии вьюх. При обходе иерархии система наткнулась на вьюху, которую мы пытались убрать, но она ещё числится в subviews.

Dangling pointer (висячий указатель)

Указатель, который больше не соответствует ожидаемой структуре данных (например, может указывать на освобождённую память или на объект в неконсистентном состоянии).

Сам указатель при этом не обнуляется и не обновляется. Это может случиться, когда память освобождается (free, dealloc и т.п.), но переменная или ссылка ещё указывает на старый адрес.

Разыменование такого указателя даёт неопределённое поведение: EXC_BAD_ACCESS, повреждение данных или уязвимости.

ИсточникDangling pointer — Wikipedia

В чём, собсна, баг?

Если мы зайдём в PlayerCell.performBuggyRelease(), то увидим, что удаляется playerLayer из суперслоя, но PlayerView остаётся в subviews родителя. У PlayerView есть layer; мы удалили этот layer из его суперслоя, но саму вьюху не убрали. В subviews остаётся ссылка на «полуразрушенную» вьюху — при обходе система обращается к её layer/superlayer и падает.

func performBuggyRelease() {
    player?.replaceCurrentItem(with: nil)
    playerView.playerLayer.player = nil
    playerView.playerLayer.removeFromSuperlayer()  // ← баг: layer удалён, view осталась
}

Фикс

Если удалять PlayerView целиком через removeFromSuperview, а не трогать layer вручную, тогда и view, и её layer корректно удаляются из иерархии.

func performCorrectRelease() {
    player?.replaceCurrentItem(with: nil)
    playerView.playerLayer.player = nil
    playerView.removeFromSuperview()  // ← правильно: удаляем view целиком
}

Чтобы можно было это воспроизвести, в SceneDelegate переключите useBuggyTeardown = false — крэш исчезнет.

Было/Стало
Было/Стало

Заключение

Мы прошли путь от крэш-лога без несистемных фреймов и трейсов до конкретного места в коде и исправления. Сначала (по типу исключения и месту падения) стало ясно, что это обращение к невалидной памяти.

Далее, команда bt дала контекст: сворачивание приложения, снапшот, обход дерева, вызов getter'а superview — значит, проблема в иерархии вьюх. Регистры показали виновника — невалидный указатель и подсказку — PlayerView.

disassemble -n подтвердил цепочку: слой → поле по смещению 0x10 → разыменование по нулевому или «мусорному» адресу на инструкции +76. Так гипотеза «освобождённая вьюха ещё в subviews» подтвердилась и привела к коду: performBuggyRelease, удаление только playerLayer из суперслоя при сохранении самой вьюхи в иерархии. Фикс — удалять view через removeFromSuperview, а не трогать слой вручную.

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

Умение читать backtrace снизу вверх, смотреть регистры и связывать все это с гипотезой позволяет не гадать, а (при определенных обстоятельствах) за несколько шагов выйти на конкретную вьюху и место в коде. В похожих кейсах цепочка та же: bt → frame select 0 + register read → disassemble по месту падения → интерпретация и поиск по коду.

Скорее всего, вам не придется использовать этот метод для разбора проблемы (благо у нас есть большое количество других методов локализации падений), но даже верхнеуровневое понимание того, как работать с LLDB может спасти драгоценные часы и нервы.


Что еще можно посмотреть?

  • Zombie Objects (Scheme → Run → Diagnostics): «message sent to deallocated instance».

  • Address Sanitizer: use-after-free, buffer overflow.

  • Instruments → Allocations с Malloc Stack Logging.

  • Breakpoints на инструкции из трейса.

Смежные материалы