Об анатомии крэшей на iOS «по-взрослому»

в 15:53, , рубрики: crashes, iOS, iPadOS, objective-c, swift

Привет. Меня зовут Давид Чупреев, я разработчик мобильных приложений в команде Core iOS ОК. 

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

В этой статье я расскажу, как устроены крэши в iOS, откуда они берутся и как с ними взаимодействовать.

Об анатомии крэшей на iOS «по-взрослому» - 1

Начнём с азов

Крэш — ситуация, при которой приложение неожиданно для пользователя завершает свою работу. Крэш может возникать:

  • на старте, когда мы только нажали на иконку приложения в меню;

  • в процессе взаимодействия с приложением;

  • когда мы свернули приложение свайпом, но оно еще находится в памяти.

Можно выделить следующие причины возникновения креша:

  • ошибки на уровне кода приложения;

  • конфликты между приложениями;

  • нехватка ресурсов (памяти, дискового пространства);

  • отказ оборудования.

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

Технические азы

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

Об анатомии крэшей на iOS «по-взрослому» - 2

Адресное пространство процесса хранит (с начала адресов снизу вверх):

  • read‑only сегмент кода;

  • сегменты инициализированных и неинициализированных данных;

  • выделенный сегмент под кучу;

  • сегмент под отображение динамических библиотек, которые прилинкованы к нашему приложению;

  • сегмент для стека;

Красным подсвечена область адресов ядра, недоступная для нашего приложения. Синим — неиспользуемые области адресов. Стрелки отображают, в какую сторону происходит рост по мере выделения памяти. Так, если мы вызываем malloc, то граница сегмента кучи сдвигается по направлению к сегменту отображений. И наоборот. В отличие от вышесказанного, рост стека ограничен.

На уровне кода выполнение процесса начинается с первой команды в функции main.

Примечание: На самом деле функция main вызывается не первой — существует также предварительная обработка аргументов командной строки, которые попадают в виде параметров в функцию. Но это не так важно.

Команды выполняются друг за другом, согласно описанию в исходниках (опустим здесь моменты оптимизаций компилятора, инлайн функции и другие нюансы). Чтобы процессор знал, какую инструкцию выполнять следующей, у него есть специальный регистр — program counter (счетчик команд), в котором всегда хранится адрес следующей команды для выполнения.

Выполнение приложения на CPU выглядит, как цикл последовательности шагов:

  • прочитать команду из памяти;

  • вычислить длину этой команды;

  • сдвинуть указатель на эту длину (по сути, перейти к следующей команде);

  • выполнить текущую.

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

Однако:

  • широко используемый метод UIView.addSubview отображает view на экране;

  • методы FileManager записывают в файлы на диске;

  • методы URLSession могут ходить в сеть и скачивать файлы с удаленных хранилищ;

  • методы AVAudioSession позволяют проигрывать звук в динамиках.

Как же тогда работают эти методы, если все инструкции, которые составляют наше приложение, не имеют прямого доступа к «железу»?

На самом деле все просто. Поскольку мы сами не можем этого делать, нужно найти компонент, способный выполнить эту задачу. Таким компонентом является ядро ОС. Нам достаточно только попросить его об услуге.

Одновременно с этим, мы не можем напрямую записать на диск, отобразить view, сходить за новой порцией JSON в сеть. Дисплей/диск/динамик нашего девайса один на все процессы, поэтому над ним должен быть контроль со стороны операционной системы, которая координирует доступы между всеми задачами. В связи с этим все действия мы производим благодаря системным вызовам — обращениям прикладной программы к ядру операционной системы для выполнения какой‑либо операции. Поскольку желающих много, а ресурс (дисплей/диск/динамик и так далее) один, ОС с помощью системных вызовов контролирует доступ между процессами.

С этим разобрались.

Нюансы доступа к CPU

Но значит ли возможность прямого доступа к CPU, что можно выполнить любую инструкцию по своему усмотрению? Ответ — нет.

Причина в следующем. Современные процессоры оснащены «кольцами» или режимами защиты, среди которых нам интересны два: привилегированный режим и пользовательский (ограниченный).

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

  • В ограниченном режиме CPU выполняют только те инструкции, которые не влияют на систему целиком и никак не могут ее сломать.

Наши приложения работают в ограниченном режиме, а ядро iOS — в привилегированном. Перед запуском нашего приложения ядро переключает режим CPU в ограниченный, тем самым наше приложение «сковано» в рамках доступных инструкций.

Нюансы доступа к памяти

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

Таким образом, если CPU попросить выполнить недоступную в ограниченном режиме команду или обратиться по недоступному адресу, произойдет прерывание — CPU переходит на конкретный адрес (вектор прерывания), который настроен ядром на выполнение его кода. Получив управление, ядро посылает соответствующий сигнал процессу-виновнику.

Поговорим о сигналах

Сигнал представляет собой число или именованную константу, которая начинается с префикса SIG. Некоторые из них приведены на картинке.

Об анатомии крэшей на iOS «по-взрослому» - 3

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

Чаще всего встречается несколько типов сигналов:

  • SIGTRAP — сигнал отладчика. Генерируется при возникновении определенных ситуаций во время отладки программы.

  • SIGILL — сигнал об ошибке выполнения. Генерируется ядром операционной системы при попытке выполнить недопустимую инструкцию процессора.

  • SIGABRT — сигнал аварийного завершения. Генерируется процессом для себя самого.

  • SIGKILL — сигнал завершения, который нельзя игнорировать или перехватить.

  • SIGBUS/SIGSEGV — сигнал ошибки обращения к памяти. Генерируется ядром операционной системы при попытке доступа к памяти, которая не может быть использована по каким‑либо причинам.

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

Обычный выход из обработчика на сигналы выше приводит процесс в состояние undefined. Связано это с тем, что данные сигналы порождаются неконсистентностью структур внутри приложения или недействительной инструкцией. То есть, продолжать выполнение после обработки не имеет смысла, поскольку повторное обращение к памяти или выполнение той же команды приведет к новому сигналу — обработчик тем самым будет вызываться бесконечно. Поэтому в конце обработки мы должны завершить процесс.

Что касается обработки сигналов

Сигналы в UNIX‑подобных системах могут обрабатываться двумя способами:

  • через функцию signal;

  • через функцию sigaction.

Первый появился раньше, имеет проще интерфейс и поначалу страдал некоторыми недостатками. В то время как второй, ныне рекомендуемый к использованию, имеет более сложную структуру, при этом обладает большей функциональностью. Несмотря на это, интерфейс самого обработчика никак не меняется при переходе от одной функции к другой.

Примечание: На самом деле далее мы увидим, что интерфейс обработчика поменяется, но на то есть причины. Вместе с тем, в классических сигналах изменений на уровне обработчика действительно не происходит. Если сигналы realtime, которые появились позднее, то изменения связаны с дополнительной семантикой у самих realtime‑сигналов.

Теперь остановимся непосредственно на интерфейсе обработчика.

Интерфейс обработчика

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

void customHandler(int sig) {
    signal(sig, SIG_DFL);
    char str[] = "Crashn";
    write(STDOUT_FILENO, str, strlen(str));
}

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

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

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

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

Например:

  • Если сигнал поступает в тот момент, когда мы работаем с вводом‑выводом функций stdio, которые внутри себя содержат буфера, а внутри обработчика мы снова обращаемся к функции этого семейства, то параллельный доступ к буферам может привести к плачевным последствиям.

  • Аналогичные проблемы могут возникнуть, если мы вызываем метод alloc в обработчике, потому что он может быть вызван именно на моменте работы с кучей в основной программе.

  • Параллельный доступ может привести к крэшу уже в самом обработчике. Кроме этого, есть риск создать дедлок, если, например, какая‑то функция делает лок в основной программе, а мы в обработке еще раз дергаем эту функцию. Поэтому не все вызовы функций могут быть позволены внутри обработчика.

Отсюда появились async-signal-safe-функции — функции, которые не завязаны на глобальные структуры данных и могут безопасно использоваться внутри сигнала. Но их не так много.

Отмечу, что printf и прочие библиотечные функции ввода-вывода небезопасны именно потому, что используют глобальные буферы. При этом функции open, close, которые нужны нам для записи репорта, легально использовать внутри обработчика.

Примечательно, что по причине дедлока функция obj_msgSend тоже небезопасна, из-за чего в рамках обработчика мы не можем дергать Objective-C методы. Так же мы не можем использовать функцию backtrace(3), который позволяет нам снять стектрейс.

Обратная сторона решения данной проблемы состоит в том, чтобы блокировать сигналы пока мы находимся внутри async-unsafe кода в приложении. Блокирование сигнала означает, что данный сигнал приходит процессу, но остается в списке ожидающих, пока мы его не разблокируем.

Но так делать опасно, потому что тот же сигнал SIGSEGV, который может возникнуть в ходе стартаприложения, остановит выполнения процесса пока не сработает обработчик. А поскольку этот сигнал заблокирован, то обработчик не вызывается. Вследствие этого возможно несколько вариантов:

  • мы породим дедлок и спустя какое‑то время система нас убьет (например, системный вотчдог может убить нас через 10 секунд);

  • система убьет нас сразу;

  • пользователь самостоятельно закроет зависшее приложение.

Установка диспозиции

Вернемся к установке диспозиции. Выделим определённые сигналы, которые нам интересны.

int signals[] = { SIGILL, SIGTRAP, SIGABRT, SIGSEGV, SIGBUS };

Затем пишем:

int i;
for (i = 0; i < sizeof(signals) / sizeof(int); i++) {
   signal(signals[i], customHandler);
}

Первым параметром вызов принимает номер сигнала, вторым — наш обработчик. В случае успеха функция возвращает текущий обработчик. Эта функциональность позволяет создавать очереди из обработчиков. Но об этом чуть позже.

Отлично, мы подписались на сигналы, самое время докинуть пару крэшей и проверить. Важное замечание — запускать приложение СТРОГО БЕЗ дебаггера. На выбор доступно несколько вариантов, каждый со своим сигналом:

// SIGABRT
NSMutableString *str = [NSArray new];
[str appendString:@"str"];

// SIGSEGV (!)
- (void)recursive {
    [self recursive];
}

// SIGTRAP
print(Int("5")! / Int("0")!)

// SIGILL
static const uint32_t sTest[] = {
    0x00000001,
};
typedef void (*FuncPtr)(void);
FuncPtr f = (FuncPtr) sTest;
f();

Ранее мы говорили, что signal имеет некоторые особенности. Так, ранние версии signal позволяли самому себе прерывать обработчик сигнала, если обработчик долго чем‑то занят — например, записывает информацию в файл. Учитывая, что обработчик работает с глобальными переменными, это к добру не приводит.

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

Со временем поведение signal исправили. Теперь диспозиция не сбрасывается и сигнал с тем же номером блокируется на время выполнения обработчика. Но при этом интерфейс функции остался тот же. Это означало, что у данной функции было две различные семантики. К тому же, в последних версиях стандартная библиотека C под видом signal внутри вызывает системный вызов sigaction, который везде работает одинаково.

Поменяем немного код установки диспозиции.

struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_flags = 0;
sigemptyset(&sa.sa_mask);
sa.sa_handler = &customHandler;

Сброс диспозиции происходит аналогично подписке, только вместо нашей функции в поле sa_handler устанавливается SIG_DFL.

Чуть выше мы описывали, что для обработчика специальным образом создается фрейм на стеке, в рамках которого будет выполняться хэндлер. Однако представим ситуацию, когда наш стек «вылазит» за пределы дозволенных размеров (к примеру, бесконечный рекурсивный метод породит ошибку Stack Overflow). В примерах выше был такой кейс, при событии ничего не выводится. Потому что в таком случае для нашего обработчика просто не хватит места на стеке. Как следствие, обработчик не вызывается, а мы «крэшимся», не успев толком ничего сделать.

Решение этой проблемы кроется в самостоятельном выделении на куче участка памяти, именуемого как альтернативный стек сигнала — именно на этом стеке будет создан фрейм для обработчика. После выделения мы должны передать наш стек в системный вызов sigaltstack. В завершении, при установке диспозиции через sigaction, мы должны прописать флаг SA_ONSTACK, означающий, что обработка будет происходить на выделенной области. Но надо следить, чтобы места на кастомном стеке было достаточно — его исчерпание может привести к негативным последствиям.

Немного поменяеем конфигурацию структуры sigaction.

stack_t _stack;
_stack.ss_size = MAX(MINSIGSTKSZ, 64 * 1024);
_stack.ss_sp = malloc(_stack.ss_size);
_stack.ss_flags = 0;
sigaltstack(&_stack, 0);
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_flags = SA_ONSTACK;
sigemptyset(&sa.sa_mask);
sa.sa_handler = &customHandler;

Теперь при ошибке Stack Overflow наш обработчик срабатывает.

Одной из ценностей крэш репорта являются стектрейсы с потоков.

Но если мы возьмем стектрейс со стека, на котором выполняется обработчик, он будет пустой, ведь мы его только что создали специально для хэндлера. В качестве проверки можете вывести текущий стектрейс с помощью [NSThread callStackSymbols]. Да, это небезопасно, но в рамках тестирования можно.

Решением данной проблемы являются кастомные stack walkers, которые позволяют «снять» стек со всех потоков, включая тот, что породил крэш.

То, что мы изучили выше, относится к так называемым классическим сигналам.

С недавних пор появились и так называемые realtime‑сигналы. Они несут в себе дополнительную информацию, а их поддержку осуществляет только вызов sigaction. Для таких сигналов необходимо видоизменить интерфейс обработчика.

void customHandler(int sig, siginfo_t *info, void *context) {}

И снова придется поменять конфигурацию sigaction. В параметр флагов дописываем SA_SIGINFO, а сам обработчик теперь присваивается в поле sa_sigaction.

struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_flags = SA_ONSTACK|SA_SIGINFO;
sigemptyset(&sa.sa_mask);
sa.sa_sigaction = &customHandler;

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

Сразу стоит напомнить, что у каждого потока свой стек. При этом все стеки лежат в адресном пространстве процесса. Кроме того, диспозиция сигнала одна на процесс, поэтому она одинакова для всех потоков в задаче.

В целом, обработчики сигналов могут быть вызваны на любом потоке, однако SIGBUS, SIGSEGV, SIGILL и т. д. будут вызваны на породившем данное исключение треде.

Альтернативный стек для обработчиков — свойство конкретного потока. Так, если мы вызываем sigaltstack на главном потоке, данное свойство будет учитывать только ошибки в главном треде. При создании потоков это свойство также не наследуется — поскольку ошибка может случиться на любом потоке в приложении, мы должны на каждом треде вызывать эту функцию.

Mach Exceptions

Сигналы — не единственный механизм для ловли критических ситуаций.

Ядро iOS позволяет «вешаться» на такие события посредством Mach Exceptions API.

Концептуально оно выглядит как средство, основанное на Mach‑портах, которые используются в роли IPC. Процессы и ядро общаются между собой посредством этих портов, используя права доступа и передавая друг другу сообщения. При этом:

  • доступы бывают либо с правами на чтение (Receive Right), либо с правами на запись (Send right);

  • сообщения (в общем случае — случайный набор данных) нам интересны, как источник информации о крэше.

Чтобы не заморачиваться с парсингом сообщений от портов, была придумала утилита MIG (Mach Interface Generator), которая на основе описания рутин генерирует код для получения сообщений и респонса на них. Но, к сожалению, на iOS она официально не задокументирована, поэтому хэндлинг сообщений происходит в «сыром» варианте.

Вместе с тем, логичен вопрос: «Если Mach исключения не задокументированы, почему они тогда используются?»

Ранее мы уже упоминали ошибки Stack Overflow и sigaltstack, как их решение. Там же мы говорили, что sigaltstack будет работать только с вызывающим потоком, что означает необходимость вызова этой функции на каждом потоке в процессе. Учитывая, что некий тред пул внутри приложения создается за нас системой, нам бы пришлось пробегать по всем потокам и везде создавать альтернативный стек. Как мы увидим позже, ожидание сообщения на порт происходит на отдельном, выделенном потоке. Вследствие этого, Machисключения уже избегают такой проблемы.

Однако, это еще полбеды. До недавних пор, sigaltstack вообще не работал на iOS. Даже был создан баг репорт, который в Apple благополучно проигнорировали. Вследствие этого, ошибку переполнения стека можно было перехватить только с помощью незадокументированных Mach‑исключений. На момент iOS 15 данная проблема уже не воспроизводится, однако версии младше могут до сих пор вести себя по‑другому.

Кроме этого, одним из преимуществ Mach‑исключений является механизм респонса на сообщения. Дело в том, что подписываться на них можно и out‑of‑process. Что‑то похожее происходит с официальным Crash Reporter от Apple — когда мы отвечаем на сообщения, респонс может уходить даже другим слушателям, которые находятся вне нашего процесса. Это позволяет нашему кастомному перехватчику не конфликтовать с системным репортером.

Что касается примеров использования, тут все сложнее, нежели чем с сигналами. Дело в том, что интерфейсы mach‑функций, даже для мало‑мальского сэмпла, довольно объемны и тянут за собой дополнительную логику, которая выходит за рамки этой статьи.

Весь процесс отлавливания крэшей упрощенно состоит из нескольких этапов:

  • создание порта с помощью функции mach_port_allocate

  • установка право записи на этот порт с помощью mach_port_insert_right

  • передать порт в функцию регистрации на получение exceptions task_set_exception_ports

  • на отдельном потоке вызвать функцию mach_msg, который блокирует вызывающий поток до прихода сообщения;

  • обработать крэш;

  • вызвать снова mach_msg, только уже как респонс для последующей обработки.

Описание работы PLCrashReporter

С вашего позволения рассмотрим принцип работы одной из библиотек для «ловли» крэшей под названием PLCrashReporter.

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

Затем, поскольку мы ограничены asyncsignalsafe функциями (функции, которые не используют глобальные структуры данных и параллельный доступ к которым не приведет к плачевным последствиям) внутри обработчика сигналов, мы должны заранее собрать как можно больше информации о нашем окружении. В частности, здесь мы записываем:

  • версию приложения;

  • данные процесса (время старта, имя, ID его и родителя, путь на диске для локализации места крэша);

  • CPU и версию ОС девайса.

Все это в дальнейшем будет отображаться в отчете.

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

  • Если мы выбираем BSD сигналы, то с помощью sigaction подписываемся на интересующие нас сигналы, попутно используя альтернативный стек, как дополнительную опцию. При установке диспозиции достаём предыдущую диспозицию, которую сохраняем для создания очереди из обработчиков. В качестве сигналов выбираем расширенную версию, то есть сигналы реального времени, чтобы извлечь больше информации. На каждый сигнал у нас одинаковый обработчик.

    /**
    	* @indernal
    	* Fatal signals to be monitores.
    	* /
    	static int monitored_signals[] = {
    		SIGABRT,
    		SIGBUS,
    		SIGFPE,
    		SIGILL,
    		SIGSEGV,
    		SIGTRAP
    	};
  • Если мы выбираем подписку на Mach‑исключения, то создаем отдельный порт, прописываем ему права, регистрируем его на получение исключений и вызываем mach_msg. Как уже говорилось выше, mach_msg блокирует текущий поток до наступления сообщения, поэтому создается отдельный поток, в рамках которого и происходит данный вызов. Кроме исключений, мы также дополнительно подписываемся на сигнал SIGABRT.

    exception_mask_t exc_mask = EXC_MASK_BAD_ACCESS |	/* Memory access fail */
    					EXC_MASK_BAD_INSTRUCTION |	/* Illegal instruction */
    					EXC_MASK_ARITHMETIC |		/* Arithmetic exception (eg, divide by zero) */
    					EXC_MASK_SOFTWARE |		/* Software exception (eg, as triggered by x86’s 							bound instruction) */	
    					EXC_MASK_BREAKPOINT |		/* Trace or breakpoint */

После регистрации на исключения, которые происходят на уровне железа, мы подписываемся на NSException. Подписка на NSSetUncaughtExceptionHandler не отменяет самого факта крэша, однако, как известно, в крэш репорте содержится стек исключения ObjC, если крэш случился по причине необработанного исключения. Сам стектрейс находится в объекте исключения, который прилетает в обработчик. В момент события мы достаем адреса вызовов из объекта и сохраняем также в структурах для будущей записи.

Кроме этого, в начале рассказа я упоминал про сегмент, куда попадают все прилинкованные динамические фреймворки к нашему приложению. С помощью методов подписки _dyld_register_func_(add/remove)_image мы «вешаемся» на каждый новый бинарь, который мапится в наше пространство. В методах подписки мы достаем:

  • имя и путь бинаря;

  • адрес, по которому он загружен;

  • uuid.

Все эти данные мы сохраняем в глобальном списке бинарей. Данный список отобразится внутри отчета события. В дальнейшем это пригодится для символизации стектрейса.

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

Что происходит во время вызова обработчика

Действия во время вызова обработчика зависят от того, какая была конфигурация (сигналы или mach-exceptions).

Подходы в целом общие, но отличия всё же есть.

Сначала затронем обычные сигналы.

Говоря о sigaction, мы обсуждали, что после установки диспозиции она не сбрасывается по умолчанию. Однако, если у нас в рамках обработчика возникнут ошибки, нам не надо, чтобы сигналы приходили повторно. Для этого есть тип диспозиции «по умолчанию», обозначаемый как SIG_DFL. Поэтому первым же действием мы сбрасываем все наши сигналы в дефолтный режим, как выше в примерах.

Сброс в дефолтный режим нужен еще и по другой причине. Как было отмечено ранее, после обработки хардварных исключений нет смысла продолжать работу — нам прилетит повторный сигнал с тем же номером. Когда он прилетит, будет действовать уже диспозиция по умолчанию, которая и прибьёт процесс.

После этого из параметра контекста, который прилетает в обработчик, достаем значения регистров. Сами регистры хранятся в виде структуры.

_STRUCT_ARM_THREAD_STATE64
{
	_ _uint64_t _ _x[29];	/* General purpose registers x0-x28 */
	_ _uint64_t _ _fp;	/* Frame pointer x29 */
	_ _uint64_t _ _lr;	/* Link register x30 */
	_ _uint64_t _ _sp;	/* Stack pointer x31 */
	_ _uint64_t _ _pc;	/* Program counter */
	_ _uint64_t _ _cpsr;	/* Current program status register */
	_ _uint64_t _ _pad];	/* Same size for 32-bit or 64-bit clients */
};

Кроме этого, из параметра информации о сигнале нам интересны номер, код и адрес инструкции, где случилось исключение.

/* Set up the BSD signal info */
bsd_signal_info.signo = info->si_signo;
bsd_signal_info.code = info->si_code;
bsd_signal_info.address = info->si_addr;

В конце происходит сама запись всех глобальных структур в файл, попутно запуская кастомный frame walker на каждый поток приложения. Запись происходит через protobuf.

Ранее мы говорили, что у нас есть некая очередь из обработчиков сигналов. Так реализовано специально, чтобы исключить «перетирание» навешанных предыдущих обработчиков. В таком случае мы пробегаемся по списку всех обработчиков, вызывая их по очереди. Таким образом, все заинтересованные процессы получат шанс на обработку.

Если говорить про обработчик Mach‑исключений, то в нем по типу исключения мы создаем структуру о сигнале, как если бы мы получили ее через BSD-обработчики.

// ...
case EXC_BAD_INSTRUCTION:
	siginfo->si_signo = SIGILL;
	siginfo->si_addr = (void *)subcode;
	break;
case EXC_ARITHMETIC:
	siginfo->si_signo = SIGFRE;
	siginfo->si_addr = (void *)subcode;
	break;
case EXC_EMULATION:
	siginfo->si_signo = SIGEMT;
	siginfo->si_addr = (void *)subcode;
	break;
// ...

Выглядит это аналогично проверке исключений через switch и записи информации о сигнале. Кроме того, сам тип исключения мы также записываем в наши структуры — он будет отображаться в финальном отчете. После этого, как и с сигналами, достаем регистры CPU. И так далее.

Также не стоит забывать про проброс сообщениям выше уровнем, возможно, в другие процессы.

Где отслеживать крэши

Существует несколько сервисов для трекинга сбоев. Это и box‑решение от Apple под названием Organizer, которое доступно прямо из‑под Xcode. Это и сторонние сервисы, такие как Crashlytics, Sentry и доживающий свои последние дни AppCenter. Мы же далее будем рассматривать сервис Tracer от OK.Tech для сбора и анализа ошибок в мобильных приложениях под iOS и Android. Более подробную информацию можно получить по ссылке, там же вы найдете документацию по подключению и использованию.

Crash Report: что внутри

Крэш‑репорт делится на несколько секций:

  • Header;

  • Exception Information;

  • Diagnostic messages;

  • Last Exception Backtrace;

  • Backtraces;

  • Thread State;

  • Binary Images.

Примечание: Стоит упомянуть, что отчет Tracer отличается от тех, что предлагает Apple (хэдером, свифтовыми ошибками, стектрейсами). Вы можете видеть отчеты Apple как файлы с расширением.ips в жалобах юзеров. Кроме этого, в Tracer по умолчанию отчет отображается в «красивом» виде, где много информации недоступно. Но при желании каждый может посмотреть полный отчет. Для этого достаточно перейти на вкладку Обычный текст на странице события.

Об анатомии крэшей на iOS «по-взрослому» - 4

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

Header

Об анатомии крэшей на iOS «по-взрослому» - 5
  • Incident identifier — уникальный ID репорта.

  • Hardware model — «внутренняя» модель девайса, на котором произошло событие. Но надо понимать, что конкретные цифры не отображают привычную для нас модель. Так, iPhone 12.1 (как на слайде) на самом деле — iPhone 11.

  • Process — имя процесса, который «закрэшился», и его ID. Имя процесса достаётся из Info.plist, поле CFBundleExecutable. В квадратных скобках есть ID. Если у вас есть, к примеру, share extension и событие пришло в нём — здесь это отобразится.

  • Path и Identifier — путь и ID (значение CFBundleIdentifier) процесса соответственно.

  • Version — основная версия и номер билда.

  • Code‑Type — архитектура CPU, где выполнялся процесс. Во времена Mac Intel помогал различать симулятор или девайс.

  • Date/Time — время крэша.

  • OS Version — версия iOS + build number.

Exception Information

Об анатомии крэшей на iOS «по-взрослому» - 6

Несмотря на название, раздел напрямую не связан с исключениями в Objective‑C. Он содержит информацию о Mach Exception, которое убило наше приложение. При этом Exception Information не обладает исчерпывающей информацией о том, почему именно данное исключение было послано нашему процессу.

  • Exception Type — сигнал.

  • Triggered by Thread или Crashed Thread — поток, на котором случился крэш.

Exception Type

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

  • EXC_BREAKPOINT (SIGTRAP) & EXC_BAD_INSTRUCTION (SIGILL) — так называемый trace trap (возможность приаттаченного к процессу дебаггера «встрять» внутрь выполнения кода) и выполнение недоступной инструкции соответственно. Причиной являются Swift runtime ошибки, cвифтовые trycatch. Некоторые системные фреймворки тоже используют данный тип с подробным описанием в графе отчета Additional Diagnostic Information.

  • EXC_CRASH (SIGABRT) — причиной являются «непойманные» ObjC исключения и долгий запуск экстеншена.

  • EXC_BAD_ACCESS (SIGBUS | SIGSEGV) — memory access крэши.

  • EXC_CRASH (SIGKILL) — поскольку сигнал SIGKILL является неперехватываемым, данные крэши, как таковые, не являются результатом работы крэш репортера. Однако здесь нам на помощь приходит MetricKit out‑of‑box решение от Apple, которое работает вне процесса, которому послан сигнал, и которое способно выдавать диагностики крэшей из‑за данного сигнала.

Поле отчета Termination Reason содержит код, который может рассказать о причине сигнала. Перечислим основные:

  • 0×8badf00d — вотчдог убил приложение. Вотчдог мониторит main thread на предмет длительного лока. Понять вотчдог можно по описанию в Termination Description. Бэктрейс потока не всегда покажет источник вотчдога. Допустим, нам надо выполнить сложную функцию, у нас есть только 10 секунд (в среднем лимит вотчдога, хотя я видел и события с 20 секундами). Какой‑то блок кода выполняется 8 секунд, в итоге когда приложение крэшнется, мы уже покинем тот блок и будем на другой инструкции.

  • 0xdead10cc — дедлок.

  • 0xd00d2bad — очень много пожираем ресурсов.

  • 0xc0010ff — слишком горячий девайс.

Diagnostic messages

Об анатомии крэшей на iOS «по-взрослому» - 7
Об анатомии крэшей на iOS «по-взрослому» - 8

В раздел попадают дополнительные сообщения, описывающие причины крэша. В полях crash_entry записывается дополнительная информация о причинах крэша, предоставляемая системой. В приведенном примере они содержат описания исключений от objc.

Last Exception Backtrace

Об анатомии крэшей на iOS «по-взрослому» - 9

Опциональный раздел об исключении Objective-C, если таково было. Идентичен backtraces. В разделе показан стектрейс потока, который породил икслючение.

Backtraces

Об анатомии крэшей на iOS «по-взрослому» - 10

Стектрейсы потоков. Первая строка — номер потока и его имя. Из‑за соображений безопасности имя не всегда отображается. Пометка «Crashed» показывает, какой поток вызвал крэш.

Описание каждого фрейма (по порядку слева направо):

  • номер фрейма (начиная с 0, самого верхнего в стеке);

  • имя бинаря;

  • адрес команды (на верхнем фрейме означает, где на тот момент находилось управление, на других фреймах — адрес, который будет исполняться после возврата из текущего метода);

  • начало бинаря в памяти;

  • оффсет от начала до инструкции;

  • имя файла;

  • имя класса и метода;

  • номер строки.

Поскольку при трансляции исходного кода, компилятор применяет некоторые оптимизации, что‑то добавляя в стектрейс, что‑то наоборот схлопывая (например, инлайн‑функции), позиция в строке не всегда совпадает с исходниками.

Thread State

Об анатомии крэшей на iOS «по-взрослому» - 11

Список регистров CPU и того, что они содержали на момент крэша. Дополнительная информация при memory access ошибках. Так, например, выделенный регистр pc содержит адрес на момент события. Если адрес относится к инструкции, значит крэш случился из-за недопустимой команды. Если адрес относится к памяти, значит осуществлялась попытка чтения/записи по недоступному адресу.

Binary Images

Об анатомии крэшей на iOS «по-взрослому» - 12

Весь загруженный код на момент крэша: фреймворки, экстеншены, основное приложение и т. д. Описание каждой строки:

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

  • Имя бинаря, префикс. «+» означает, что бинарь не часть ОС.

  • CPU процессора бинаря, который загрузила ОС.

  • buildUuid данного бинаря.

  • Путь к бинарю. buildUuid и промежуток адресов используются при символизации крэш репорта

Краткие выводы

В качестве эпилога хотелось бы дать несколько рекомендаций по работе с крэшами.

  • Поскольку запись репорта происходит в момент события, отправка происходит на старте в следующий заход. Отсюда важное следствие, что стартовать крэш репортер и отправлять отчеты надо, как можно быстрее. Чуть хуже ситуация, если крэш был на старте запуска, в каком‑нибудь из первичных глобальных сервисов. В таком случае, можно инициализировать сервисы с отложенным таймером, либо ждать, пока загрузится репорт. Кроме этого, поскольку запись отчета зависит от количества доступного пространства на диске, необходимо отслеживать хранилище юзера на предмет того, хватит ли места — в противном случае крэши таких юзеров не отследятся. Остается надеяться только на MetricKit.

  • Помимо крэшей, крайне желательно смотреть события MetricKitа, особенно отфильтровывая именно те, которые крэш репортер отловить не может. В Tracer есть документация на эту тему.

  • При А/Б‑эксперименте между несколькими крэш‑репортерами, в рамках одного запуска приложения по‑честному должен работать только один из них, как раз‑таки по причине возможной перезаписи обработчиков.

Автор: thedave497

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js