Привет, Хабр! Меня зовут Никита, и я разработчик в команде платформы iOS Иви.
В работе постоянно сталкиваешься с багами. В топе самых неприятных — крэш. Еще хуже — когда он неочевидный, и сразу сложно сказать, откуда «растут ноги». В этой статье разберём такой крэш пошагово и попробуем выяснить причину через LLDB.
Будет нагляднее если вы сразу откроете демо-проект и пройдете все шаги вместе со мной. Если сейчас такой возможности нет, то можно будет смотреть скриншоты для каждого этапа.
Запуск демо-проекта и воспроизведение кейса
Откройте проект
Запустите приложение
Насладитесь обаянием Rick Astley
Сверните приложение свайпом вверх (можно переоткрыть — тогда вылетит быстрее)
Приложение упадёт — отладчик остановится в момент крэша
Важно: демо-проект собирался на 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могут быть уже перезаписаны.
Команды
backtrace/bt— отчет о состоянии стека вызовов функций в определенный момент времени
frame select <N>+register read— регистры в месте падения
disassemble— какая инструкция упала, и из какого регистра брался адрес
po <expr>— напечатать результат выражения/объект в человекочитаемом виде
Этап 1: Крэш-лог
Откройте CrashExample, воспроизведите крэш (свернуть -> развернуть приложение). Первое, что видим — тип крэша, поток и место в коде.

Тип крэша:
EXC_BAD_ACCESS (code=1, address=0x...)Поток:
thread #1, mainМесто падения:
frame #0—QuartzCore CALayerGetSuperlayer + 76
Признаюсь честно. Раньше такие инструкции вызывали у меня только желание выполнить точное повторение одного и того же действия, раз за разом, в надежде на изменение. Ну или просто:

Ни вызовов, ни ворнингов санитайзеров (Thread и Address). Контекста нет — кто вызвал, зачем. Но имеем, что имеем, поэтому берём в работу.
Откуда начинаем, и чего не хватает
Крэш-лог — единственный вход. Без него дорога к решению проблемы представляется выложенной Сизифовыми валунами. Смотрим на тип исключения, адрес, поток. Все есть. Пробуем копнуть поглубже, чтобы восстановить цепочку событий. Для этого снимаем стек вызовов командой bt и начинаем читать его снизу вверх.
Этап 2: Чтение 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!Видим падающую инструкцию. Текущая строка (отмечена ->) — +76: ldr x8, [x8, #0x8]. Это загрузка по адресу (x8 + 0x8) в регистр x8. В выводе x8 = 0x0000000000000000 — нулевой указатель. Чтение по адресу 0x8 даёт EXC_BAD_ACCESS: источник крэша — невалидное значение в x8. Ничего не напоминает?

Наша вьюха осталась в памяти. В 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. Цепочка:
+12 —
mov x20, x0. В x20 сохраняется первый аргумент — указатель на слой (layer).+72 —
ldr x8, [x20, #0x10]. Из слоя по смещению 0x10 читается значение в x8. В падающем кейсе поле невалидно: в x8 попадает 0 (или указатель на освобождённую память).+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 на инструкции из трейса.
Смежные материалы
Technical Note TN2151: Understanding and Analyzing Application Crash Reports
Статья по брейкпоинтам в LLDB: https://habr.com/ru/articles/431506/
Статья по крэшам в iOS: https://habr.com/ru/companies/odnoklassniki/articles/858302/
Ещё про дебаг в Xcode: https://tproger.ru/articles/advanced-debuggin-in-xcode
