Powered By Blogger

Friday, November 29, 2013

Исключения и... OS X, XNU, Mach



Вот так вот, совершенно внезапно. Ну что ж, раз по работе мне приходится иметь дело не только (и даже далеко не столько) с Linux, то... Почему бы, собственно, и нет?

Вообще, исключения - они и в Африке исключения. Они бывают и в Linux, и в Windows. Что же такого необычного в исключениях OS X? На самом деле, много чего. И дело именно в том, как всё это реализовано. Хотелось бы (и придётся) о многом в этой связи поговорить и написать, но начнём с самого начала, как говорится.

Обсуждаемая тема не является каким-то нововведением OS X и не является её эксклюзивным атрибутом, как формат исполняемых файлов Mach-O (напомню, что на данный момент в мире Unix везде воцарился ELF, Windows использует родственный формат - PE). Модель обработки исключений в OS X не является чем-то уникальным. Это прямое следствие того факта, что ядро OS X, XNU, построено на базе микроядра CMU Mach. Причём, XNU само по себе микроядром не является. Интересна в целом модель обработки исключений в Mach [3]. К слову, ядро XNU является открытым и его исходные коды даже доступны для скачивания вот здесь. Речь именно о ядре. Многие его расширения (kernel extensions, kext - драйверы или модули в терминологии Linux) являются проприетарными. Исходным кодом некоторых Apple всё же делится, но большинство закрыты. Не ищите там соусы iOS - она почти целиком и полностью закрыта, включая ядро, которым является всё тот же XNU, как ни странно. Обычно версия ядра настольной OS X отстаёт от версии оного для iOS. XNU iOS содержит специфичный для ARM код плюс новые чудесные плюшки. Код ARM в ядре настольной OS X почти не фигурирует, как и некоторые специфичные для iOS сервисы. Однако, об iOS я ничего писать не могу и не буду, ибо у меня нет ни одного устройства, которым она могла бы управлять и сама по себе она мне в меньшей степени интересна. Самым интересным, что может дать iOS - джейлбрейкингом, я также не занимаюсь по упомянутой причине.

Так вот, XNU в свою очередь является не полностью оригинальной разработкой Apple, а построен на микроядре Mach, хотя отказать в оригинальности разработчикам XNU значило бы сильно покривить душой против истины. Не смотря на родство с Mach, ядро OS X, XNU, микроядром не является. И более того, OS X никогда микроядра не имела, а большой и медленной была совсем по другой причине. XNU - это довольно интересный сплав Mach и BSD, работающих в одном адресном пространстве. Т.е., именно поэтому XNU не является чистым дериватом Mach и именно поэтому рассматриваемая тема не является исключительно OS X-центричной. Она могла бы в большой степени быть применимой и к GNU/Hurd по той простой причине, что GNU/Hurd работает на ядре GNU Mach. В некотором смысле это более каноничный вариант Mach. В отличие от XNU, в котором не стали пытаться сделать ОС на микроядре, в GNU/Hurd решили ничего особо радикально не менять в модели системы, для которой предусмотрено ядро такого типа.

Что же такого необычного и интересного с исключениями в частности и вообще всеми этими разновидностями Mach? Необычна идея Mach сама по себе. Она была необычной в 80-х, когда начался проект CMU Mach, и, я мог бы даже смело утверждать, остаётся таковой по сей день из-за малой распространённости ОС на микроядре. Микро - ключевой компонент. На данный момент автору вообще не приходит на ум ни одной коммерчески успешной или просто популярной реализации ОС на микроядре (Minix разве что? QNX весьма специфичен и даже на микроядро не тянет - скорее это уже наноядро и хоть проект действительно продемонстрировал, что уделом альтернативных архитектур не обязательно является безвестная смерть в академиях. В целом, ситуация всё же именно такова - микроядро не смогло найти или пробить себе дорогу на "компьютер в каждый дом"). Микроядерная архитектура per se даже спустя столько лет больше является экзотикой и просто интересностью. И тем не менее, хоть все популярные/успешные проекты используют либо классическую монолитную архитектуру (с поддержкой модульности - сейчас без неё уже никуда), либо гибридную, идеи и заветы микроядра не остались преданы полному забвению. Яркий пример - всё та же OS X.

Основа микроядерной архитектуры - обмен сообщениями, разновидность межпроцессного взаимодействия (IPC). Интересно отметить, что обмен сообщениями, как разновидность RPC в пределах хоста, есть и в Windows. В NT 3.5 был LPC - local procedure call, а ныне есть ALPC - advanced local procedure call, играющий если не ключевую, то значительную роль в работе некоторых критичных системных служб. В Mach сообщения - универсальный клей системы, который не только позволяет общаться процессам друг с другом (даже если процессы и вовсе работают на разных системах - таков был первоначальный дизайн Mach, но этот потенциал остался невостребованным в OS X), но и являются тем способом, которым приложение может запросить некий сервис системы. Если в ОС с монолитным/гибридным ядром сервисы ядра доступны через ловушки (трапы) и разнообразие этих сервисов весьма велико, как того требует время и технический прогресс в данной области, то в ОС на основе микроядра само ядро предоставляет лишь небольшое число таких сервисов, которые ориентированы именно на передачу сообщений. Так, в микроядерной архитектуре реализация файловой системы является не частью ядра. Довольно грубо говоря, в микроядерной ОС есть процесс, который умеет работать с данной файловой системой (сервер файловой системы) и есть процесс, заинтересованный в доступе к тому, что хранится на файловой системе. Второй процесс в таком случае тыкает в бок первый процесс и вежливо просит его прочитать вооон тот файлик с диска. Само ядро слыхом не слыхивало ни о каких таких файловых системах, да и о файлах оно, в общем-то, понятия не имеет. Для того, чтобы в нашем примере два процесса смогли между собой пообщаться и вообще, получить какую-то пользу друг от друга (а пользователь, таким образом, получит пользу от компьютера) и нужны сообщения в микроядерной архитектуре. Ядро является маршрутизатором этих сообщений и оно должно обеспечивать всю необходимую поддержку системы обмена сообщений, что включает управление памятью (копирование сообщений из адресного пространства клиента в адресное пространство сервера, а также обратно, или отображение памяти в случае больших объёмов передаваемых данных), планирование потоков и, наконец, саму подсистему IPC. Больше микроядро никому ничего не должно.

В Mach обмен сообщениями осуществляется через порты (Mach ports, не спутайте с MacPorts). Порт - это конечная точка коммуникационного канала. С точки зрения пользователя порт - это просто целое число, как дескриптор файла в Un*x, т.е., это совершенно непрозрачный объект, над которым можно производить чётко определённые операции. Порт локален по отношению к процессу (task, задаче, в терминологии Mach), опять же и так же, как файловые дескрипторы Un*x. Если рассматривать порты ближе - то обнаруживается, что порт - это ничто иное, как очередь сообщений, ассоциированная с пространством объектов IPC (IPC space в терминологии Mach) данной задачи и "защищаемая" специфичными правами доступа. Не каждый участник IPC может послать сообщение в порт, даже получив на него ссылку, и естественно не каждый участник может получить сообщение с любого существующего порта. Это низкоуровневой примитив IPC, жизненно важный для Mach. Механизм портов Mach довольно запутанный и эзотерический. В интернете часто сетуют на слабую и даже почти никакую документированность интерфейсов, а также на запутанность работы с сообщениями и портами. Увы, это чистая правда. Apple в лучшем случае предоставляет древние man-страницы из проекта CMU Mach, и, как правило, почти вообще никак не освещает специфичные для слоя Mach детали, хоть они и могут быть весьма важны для реализации новой функциональности даже в юзерлэнде. Сама Apple использует возможности Mach в полной мере - уберите обмен сообщениями и рухнет всё, что сделано в и для OS X, - мы убедимся в этом лишний раз на примере темы исключений, - даже при том, что OS X не является микроядерной - сервера, строго говоря, вообще не являются обязательными для неё, но порты и обмен сообщениями оказались неожиданно удобным и мощным механизмом, сфера применения которого к тому же вовсе не ограничивается одной лишь реализацией идеальной модели ОС на микроядре. Для полноты картины надо упомянуть, что ловушки Mach (mach traps), обеспечивающие механизм обмена сообщениями, являются лишь одним из классов системных сервисов (системных вызовов), обслуживаемых ядром (его Mach-сердцевиной) наряду с традиционными и более привычными системными вызовами BSD, поверх которых реализуется слой POSIX. Проект GNU/Hurd тоже не балует обилием информации, а об оригинальном проекте CMU Mach даже нечего и говорить. Он давно остановлен и то, что можно найти на ресурсах, имеющих отношение к GNU/Hurd и OS X оттуда в основном и было взято. Остаётся код ядра, который сам по себе не является хорошим источников документации, хоть и незаменим для понимания механизмов работы.

Итак, порт - это конечная точка однонаправленного канала коммуникации, вроде пайпа. Можно смотреть на порт, как на очередь сообщений. Порты имеют такие атрибуты, как имя и права (это является ещё одним источником терминологической путаницы - непонятно, что конкретно называют именем порта - право или всё же идентификатор порта - не будем заострять внимание на этой казуистике. Это просто пример неполноты информации о портах). Права не особо разнообразны. С портом может быть ассоциировано право на приём сообщений или на отправку в него (send/receive rights). Попросту, это права на извлечение/добавление сообщений в очередь сообщений и из неё. Право на приём сообщений из порта может иметь лишь одна задача. Чего-то вроде широковещательной передачи 1 ко многим здесь нет. Права на отправку сообщений в данный порт могут иметь разные задачи одновременно. Держателями портов могут быть и потоки, но пространство объектов IPC для них будет общим, если все эти потоки принадлежат одной задаче (задача или процесс, как контейнер общих ресурсов, доступных всем потокам, принадлежащим задаче).

Исторически обмен сообщениями в Mach - это не просто транспорт. Это нечто большее, являющееся аналогом RPC - remote procedure call, как говорилось выше. Клиент-серверная модель для Mach является весьма важной концепцией. Вспомните пример с сервером файловой системы. Сервер реализует некую подсистему, работающую по определённому протоколу. Протокол - это структура сообщений, которыми обмениваются клиент и сервер. Мы очень кратко взглянем на общую структуру сообщения чуть ниже, ведь нам придётся иметь непосредственное дело с сообщениями в коде примера обработки исключений. Пока важно сказать следующее: будь то файловая система, часы, таймеры, даже процессоры и наборы процессоров и проч., - всё это с точки зрения Mach объекты. С объектами определённого типа работает определённая подсистема, т.е., сервер. Сервисы, предоставляемые сервером, доступны через порты Mach. Имея порт объекта, путём обмена сообщениями с сервером вы можете манипулировать объектом. Так мы получаем ещё одну аналогию для порта - порт помимо прочего может рассматриваться как дескриптор объекта. Сервер вовсе не обязан быть самостоятельным процессом, выполняющимся в своём собственном адресном пространстве. Ядро Mach (а следовательно и XNU) имеет ряд встроенных подсистем. Само ядро является сервером. Впрочем, это не сильно противоречит идее процесса-сервера, потому что само ядро является задачей, хотя и не совсем обычной. Так, например, управление задачами возможно при получении так называемого порта задачи (task port). Процесс может получить свой собственный порт с помощью вызова mach_task_self(). Что важнее, порт данной задачи может получить и сторонний процесс. Например с помощью вызова task_for_pid(). Надо сказать, что возможности манипулирования задачами со стороны в Mach весьма обширны, а их результаты могут быть весьма плачевны и разрушительны для манипулируемого объекта. Поэтому операция получения порта задачи по её PID - т.е., сторонней задачи, как правило, является привилегированной. Кроме задач свои порты имеют потоки (thread port) и даже хост - как абстракция всей системы (host port). Аналогично существуют трапы mach_thread_self() и mach_host_self(). Данные трапы возвращают значения типов task_t, thread_t и host_t соответственно, которые, однако, являются алиасами обычного типа, представляющего порт в пространстве пользователя - mach_port_t. К слову, о типах. Внутри ядра task_t является алиасом для указателя на struct task - дескриптора задачи Mach, довольно объёмной и безусловно важной структуры.

Очень быстро пробежавшись по портам, коснёмся сообщений. Сообщение частично структурировано. У него должен быть как минимум хотя бы заголовок. Но в целом, формат сообщения определяется лишь соглашением между участниками коммуникации. Минимальное сообщение на отправку выглядит так:

struct some_msg {
        mach_msg_header_t head;
};

Такое сообщение несёт совсем немного информации помимо чисто служебной. А именно, в поле msgh_id структуры mach_msg_header_t. Это поле не интерпретируется ядром и может иметь любое произвольное значение. Более полную структуру заголовка можно увидеть здесь. Маловато, не правда ли? В качестве полезной нагрузки сообщение может нести предопределённые дескрипторы. Например, сами порты - как в Unix можно обмениваться файловыми дескрипторами посредством локальных Unix-сокетов, так же Mach позволяет обмен портами. Кроме того дескриптор может содержать описание OOL-данных (out-of-line) - большие куски данных, которые нельзя пропихнуть в сообщении. Это напоминает приложения к электронному письму или scatter-списки. Строго говоря, структура сообщения всё же достаточно произвольна и единственной обязательной его частью является заголовок, который должен быть первым в определяемом пользовательском типе. Ещё одно "но", специфичное для XNU, при приёме сообщения пользовательский тип должен иметь достаточный размер, чтобы содержать поле типа mach_msg_trailer_t - результат обработки сообщения ядром, "заказанный" получателем. Таким результатом может быть ссылка на маркер безопасности задачи-отправителя, токен аудита и тому подобное. Если со стороны отправителя минимальное сообщение состоит из одного лишь заголовка, то на стороне получателя нужно учесть место для минимального трейлера сообщения - mach_msg_trailer_t. Размер трейлера не учитывается в поле msgh_size структуры mach_msg_header_t (см. формат заголовка по ссылке выше). Он содержится в поле mach_msg_trailer_size_t структуры mach_msg_trailer_t. Похоже, трейлеры сообщений являются частью поддержки BSM (Basic Security Module - система аудита безопасности) и в оригинальных версиях CMU Mach, равно как и в GNU Mach трейлеры не предусмотрены (т.к. и BSM там не поддерживается).

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

mach_msg_return_t   mach_msg
                    (mach_msg_header_t        msg,
                     mach_msg_option_t     option,
                     mach_msg_size_t    send_size,
                     mach_msg_size_t eceive_limit,
                     mach_port_t     receive_name,
                     mach_msg_timeout_t   timeout,
                     mach_port_t           notify);
Универсальный трап - отправить или принять сообщение. msg - буфер для данных сообщения. В случае получения сообщения поле msgh_local_port заголовка сообщения и ссылка на порт receive_name имеют одинаковое значение. Флаги option - MACH_RCV_MSG, send_size - 0, receive_limit - размер пользовательского сообщения (в XNU сообщение должно включать хотя бы один трейлер), receive_name - порт, на который у нас есть право получения сообщений, timeout - сколько времени ждать сообщения (MACH_MSG_TIMEOUT_NONE - таймаута нет, вызов блокирует пока не будет получено сообщение), notify - порт уведомлений. Часто MACH_PORT_NULL.

В случае отправки поле msgh_local_port заголовка сообщения и ссылка на порт receive_name равны MACH_PORT_NULL. msg.msgh_remote_port содержит ссылку на порт получателя. Флаги option - MACH_SEND_MSG, send_size - полный размер сообщения ( = значению поля msgh_size структуры mach_msg_header_t), receive_limit - 0, timeout - сколько времени ждать доставки (MACH_MSG_TIMEOUT_NONE - таймаута нет, вызов блокирует, пока сообщение не будет доставлено, т.е., добавлено в очередь сообщений принимающего процесса. Блокирование на отправке возможно тогда, когда принимающая очередь переполнена. Таким образом, пока на принимающей стороне поток не удалит сообщение из очереди, освободив в ней место, mach_msg() не вернёт управление, если только не задан таймаут доставки), notify - см. чут выше.

http://web.mit.edu/darwin/src/modules/xnu/osfmk/man/mach_msg.html

kern_return_t   mach_port_allocate
                (ipc_space_t              task,
                 mach_port_right_t        right,
                 mach_port_name_t         *name);
Создать порт заданного типа. task - ссылка на порт (в данном случае - пространство IPC) задачи. Обычно в качестве значения этого аргумента передаётся то, что возвращает уже знакомый нам вызов mach_task_self(). right, права для создаваемого порта. Либо MACH_PORT_RIGHT_RECEIVE, либо MACH_PORT_RIGHT_PORT_SET. С первым вроде всё достаточно ясно. Второе - набор портов. См. ниже. name - указатель, куда сохранить ссылку на новый порт.

http://web.mit.edu/darwin/src/modules/xnu/osfmk/man/mach_port_allocate.html

kern_return_t   mach_port_insert_right
                (ipc_space_t                 task,
                 mach_port_name_t            name,
                 mach_port_poly_t            right,
                 mach_msg_type_name_         right_type);
Добавить в порт указанный тип прав. task - ссылка на порт (в данном случае - пространство IPC) задачи. Обычно в качестве значения этого аргумента передаётся то, что возвращает уже знакомый нам вызов mach_task_self(). name - порт, на который у нас есть ссылка, объект манипуляции с правами.right - это странно, но обычно то же, что и в name. right_type - MACH_MSG_TYPE_MAKE_SEND, похоже, это даёт право посылки сообщений через данный порт при получении ссылки на него. MACH_MSG_TYPE_MAKE_SEND_ONCE or MACH_MSG_TYPE_MOVE_SEND_ONCE - порт используется для посылки лишь однократно. После этого он больше не может быть использован.

http://web.mit.edu/darwin/src/modules/xnu/osfmk/man/mach_port_insert_right.html

kern_return_t   mach_port_deallocate
                (ipc_space_t                task,
                 mach_port_name_t           name);
Уменьшить счётчик ссылок на порт (вопреки названию трапа, он вовсе не обязательно уничтожает порт. Кстати, утечка портов, как и утечка любых ресурсов в любых ОС - вещь определённо не очень хорошая). task - ссылка на порт (в данном случае - пространство IPC) задачи. name - ссылка на порт, которым мы владеем.

http://web.mit.edu/darwin/src/modules/xnu/osfmk/man/mach_port_deallocate.html

Примечательно, что порт можно создать только с правом MACH_PORT_RIGHT_RECEIVE или MACH_PORT_RIGHT_PORT_SET, вопреки ожидаемой возможности задать право и на отправку. Не в том смысле, чтобы порт имел оба права одновременно - мы помним, что порт является конечной точкой однонаправленного канала, а чтобы он был предназначен именно для отправки сообщений. Но с другой стороны, если предположить, что порт может быть лишь принимающей стороной (ведь очереди исходящих сообщений нет - есть только очередь входящих и порт, как мы знаем, это очередь сообщений), а то, что держит отправитель - это ссылка (reference) на порт, то всё вроде становится на свои места. Остаётся выяснить, как отправитель может получить ссылку на порт. В нижеследующем примере получатель "публикует" ссылку на порт, который он держит, с помощью вызова thread_set_exception_ports(). На этом мы и остановимся пока, не углубляясь в такие детали, как bootstrap-сервер. Пока будет достаточно отметить, что практически каждый объект ядра, с которым программист имеет дело явно или неявно, и обслуживаемый слоем Mach (потоки, задачи, но не BSD-сокеты или файлы, например, о которых слой Mach совершенно ничего не знает) представлен портом. Есть порты, которые представляют часы, хост и даже процессоры (получить порт задачи на Mach означает получить абсолютно полный контроль над ней и творить совершенно невообразимые вещи, поэтому вызов task_for_pid(), возвращающий порт задачи для процесса с заданным PID в OS X требует привилегий суперпользователя). Помимо этого любая задача может создавать порты сама (приблизительно как ТСР/IP-сервер может создавать сокеты и принимать на них запросы). Согласно объектно-ориентированной парадигме задача, поток, часы и т.п. - это объекты. Порт объекта - это ссылка на объект. Ссылка может позволять лишь получить общую информацию об объекте, а может открывать возможности для манипулирования объектом - так называемые порты управления (control port). Такие порты могут требовать определённых привилегий и потому называются привелигированными или специальными портами.

Здесь я лишь вскользь упомянул самые важные API, не особо вдаваясь в их суть и дальнейшие пояснения. Это просто подсказка для тех, кого тема заинтересовала и кто захочет поискать материал самостоятельно. Но из приведённого выше уже видно, что интерфейсы Mach определённо не забава для скучающих школьников. Тема IPC в Mach сама по себе обширна, эзотерична и заслуживает отдельной статьи (которая, быть может, когда-нибудь и появится). Этих базовых знаний нам должно хватить для рассмотрения нашего примера обработки исключений.

Вернёмся непосредственно к исключениям. Как следует из определения, исключение - некая нештатная ситуация, возникающая в ходе выполнения потока. Mach предлагает интересный фреймворк для работы с исключениями, но никак не определяет логику обработки исключения. Именно так. В лучших традициях Mach, исключение - это просто ещё один тип сообщений, сама "обработка" исключений - полностью основана на межпроцессном взаимодействии. Наверно было бы правильнее говорить о доставке исключений Mach, а не обработке. Исключения преобразуются в сообщения. Сообщения кто-то должен принимать. Т.е., кто-то должен создать порт и ожидать на нём поступления сообщений. Такие порты называются портами исключений. Порты исключений могут быть у каждого отдельного потока, целой задачи или общие порты для всех задач в системе - порты исключений хоста. Обычно, если поток или задача явным образом не выказывают желания заниматься собственными исключениями, их порты исключений равны MACH_PORT_NULL, а в общем случае задачи и потоки как раз не вовлекаются активно в работу с исключениями. Т.е., не определяют свои порты исключений. Просматривается определённая иерархия. Если некий поток вызывает исключение, но он не имеет собственного порта исключений, то исключения может обработать единый для всех потоков задачи обработчик уровня задачи. Если же и задача не предоставляет портов исключений, наступает очередь хоста. Кроме иерархичности можно заметить ещё одну интересную деталь этой модели. Исключения - это сообщения. Сообщения - средство IPC, то, чем могут между собой обмениваться потоки, в том числе принадлежащие разным задачам... Нелокальность. Вот на что я намекаю. Поток, обрабатывающий исключение, совсем не обязан делить общее адресное пространство со сбойным потоком. Он может принадлежать другой задаче, а чисто теоретически вообще работать на другом хосте (но не практически, т.к. транспорт сообщений Mach по сети в OS X не поддерживается). Обработка исключения не обязана производиться в контексте потока, вызвавшего исключение.

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

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#include <mach/mach.h>

/* exception message we will receive from the kernel */
typedef struct exception_msg {
        mach_msg_header_t head;
        mach_msg_body_t msgh_body;  /* start of kernel-processed data */
        mach_msg_port_descriptor_t thread; /* victim thread */
        mach_msg_port_descriptor_t task; /* end of kernel-processed data */
        NDR_record_t ndr;   /* see osfmk/mach/ndr.h */
        exception_type_t exception;
        mach_msg_type_number_t codeCnt;  /* number of elements in code[] */
        exception_data_t code;   /* an array of integer_t */
        char pad [512];    /* for avoiding MACH_MSG_RCV_TOO_LARGE */
} exception_msg_t;

/* reply message we will send to the kernel */
typedef struct reply_msg {
        mach_msg_header_t head;
        NDR_record_t ndr;   /* see osfmk/mach/ndr.h */
        kern_return_t ret_code;   /* indicates to the kernel what to do */
} reply_msg_t;

mach_port_t exception_port;
void exception_handler (void);
extern boolean_t exc_server (mach_msg_header_t *request, mach_msg_header_t *reply);

typedef void(* funcptr_t)(void);
funcptr_t bad_fn;
kern_return_t repair_instruction (mach_port_t victim);
void exit_gracefully (void);

#define L_MARGIN "%-21s: "
#define fn_puts_n(msg) printf (L_MARGIN "%s", __FUNCTION__, msg)
#define fn_puts(msg) printf (L_MARGIN "%s\n", __FUNCTION__, msg)
#define fn_puts_ids(msg) printf (L_MARGIN "%s (task %#lx, thread %#lx)\n", \
        __FUNCTION__, msg, (long)mach_task_self (), (long)pthread_mach_thread_np (pthread_self ()));

#define EXIT_ON_MACH_ERROR(msg, retval) \
        if (kr != KERN_SUCCESS) { mach_error(msg ":" , kr); exit((retval)); }

#define OUT_ON_MACH_ERROR(msg, retval) \
        if (kr != KERN_SUCCESS) { mach_error(msg ":" , kr); goto out; }

int main (int argc, char** argv)
{
        kern_return_t kr;
        pthread_t exception_thread;
        mach_port_t this_tsk = mach_task_self ();
        mach_port_t this_thread = mach_thread_self ();

        fn_puts_ids("starting up");

        if (argc > 1 && strcmp (argv [1], "mach") == 0) {
                kr = mach_port_allocate (this_tsk, MACH_PORT_RIGHT_RECEIVE, &exception_port);
                EXIT_ON_MACH_ERROR("mach_port_allocate", kr);
                kr = mach_port_insert_right (this_tsk, exception_port, exception_port, MACH_MSG_TYPE_MAKE_SEND);
                OUT_ON_MACH_ERROR("mach_port_insert_right", kr);
                kr = thread_set_exception_ports (this_thread, EXC_MASK_BAD_ACCESS, exception_port, EXCEPTION_DEFAULT, THREAD_STATE_NONE);
                OUT_ON_MACH_ERROR("thread_set_exception_ports", kr);

                if ((pthread_create (&exception_thread, (pthread_attr_t *)0, (void *(*)(void *))exception_handler, (void *)0))) {
                        perror ("pthread_create");
                        goto out;
                }

                fn_puts("about to dispatch exception_handler pthread");
                pthread_detach(exception_thread);
        }

        fn_puts("about to call function_with_bad_instruction");
        bad_fn = (funcptr_t)exception_thread;
        bad_fn ();
        fn_puts("after function_with_bad_instruction");

out:
        /* not reached if no mach exception handler registered */
        mach_port_deallocate (this_tsk, this_thread);

        if (exception_port)
                mach_port_deallocate (this_tsk, exception_port);

        return 0;
}

void exception_handler (void)
{
        kern_return_t kr;
        exception_msg_t msg_recv;
        reply_msg_t msg_resp;

        fn_puts_ids("beginning");
        msg_recv.head.msgh_local_port = exception_port;
        msg_recv.head.msgh_size = sizeof(msg_recv);
        kr = mach_msg (&(msg_recv.head), MACH_RCV_MSG | MACH_RCV_LARGE, 0, sizeof(msg_recv), exception_port,
                MACH_MSG_TIMEOUT_NONE, MACH_PORT_NULL);
        EXIT_ON_MACH_ERROR("mach_msg_receive", kr);

        fn_puts("received message");
        fn_puts_n("victim thread is ");
        printf ("%#lx\n", (long)msg_recv.thread.name);
        fn_puts_n("victim thread's task is ");
        printf ("%#lx\n", (long)msg_recv.task.name);
        fn_puts("calling exc_server");
        exc_server (&msg_recv.head, &msg_resp.head);
        fn_puts("sending reply");
        kr = mach_msg (&(msg_resp.head), MACH_SEND_MSG, msg_resp.head.msgh_size, 0, MACH_PORT_NULL,
                MACH_MSG_TIMEOUT_NONE, MACH_PORT_NULL);
        EXIT_ON_MACH_ERROR("mach_msg_send", kr);

        pthread_exit ((void *)0);
}

kern_return_t catch_exception_raise (mach_port_t port, mach_port_t victim, mach_port_t task,
        exception_type_t exception, exception_data_t code, mach_msg_type_number_t code_count)
{
        fn_puts_ids("beginning");

        if (exception != EXC_BAD_ACCESS) {
                fn_puts("unexpected exception!\n");
                exit (-1);
        }

        return repair_instruction (victim);
}

#define GPR(p,pp, n)        \
        state.uts.ts ## p.__ ## pp ## n

#define GPR64(n)        GPR(64, r, n)
#define GPR32(n)        GPR(32, e, n)

void dump_registers (x86_thread_state_t state)
{
        if (state.tsh.flavor == 4) {
                printf ("\trip: 0x%016x, rax: 0x%016x, rbx: 0x%016x, rcx: 0x%016x, rdx: 0x%016x\n",
                GPR64(ip), GPR64(ax), GPR64(bx), GPR64(cx), GPR64(dx));
                printf ("\trsp: 0x%016x, rbp: 0x%016x, rdi: 0x%016x, rsi: 0x%016x\n",
                GPR64(sp), GPR64(bp), GPR64(di), GPR64(si));
        } else {
                printf ("\teip: 0x%08x, eax: 0x%08x, ebx: 0x%08x, ecx: 0x%08x, edx: 0x%08x\n",
                GPR32(ip), GPR32(ax), GPR32(bx), GPR32(cx), GPR32(dx));
                printf ("\tesp: 0x%08x, ebp: 0x%08x, edi: 0x%08x, esi: 0x%08x\n",
                GPR32(sp), GPR32(bp), GPR32(di), GPR32(si));
        }
}

kern_return_t repair_instruction (mach_port_t victim)
{
        kern_return_t kr;
        unsigned int count;
        x86_thread_state_t state;

        fn_puts_ids("fixing instruction");
        count = MACHINE_THREAD_STATE_COUNT;
        kr = thread_get_state (victim, MACHINE_THREAD_STATE, (thread_state_t)&state, &count);
        EXIT_ON_MACH_ERROR("thread_get_state", kr);
        dump_registers (state);

        if (state.tsh.flavor == 4)
                state.uts.ts64.__rip = (vm_address_t)exit_gracefully;
        else
                state.uts.ts32.__eip = (vm_address_t)exit_gracefully;

        kr = thread_set_state (victim, MACHINE_THREAD_STATE, (thread_state_t)&state, MACHINE_THREAD_STATE_COUNT);
        EXIT_ON_MACH_ERROR("thread_set_state", kr);
        fn_puts("after setting thread state");
        dump_registers (state);

        return KERN_SUCCESS;
}

void exit_gracefully (void)
{
        fn_puts_ids("dying graceful death");
}

Собираем, запускаем на Leopard без аргументов.

$ gcc -o exh exh.c
$ ./exh
main                 : starting up (task 0x807, thread 0x10b)
main                 : about to call function_with_bad_instruction
Bus error

Отхватили сигнал. Как и ожидалось. Теперь попробуем с аргументом:

$ ./exh mach
main                 : starting up (task 0x807, thread 0x10b)
main                 : about to dispatch exception_handler pthread
exception_handler    : beginning (task 0x807, thread 0xf03)
main                 : about to call function_with_bad_instruction
exception_handler    : received message
exception_handler    : victim thread is 0x10b
exception_handler    : victim thread's task is 0x807
exception_handler    : calling exc_server
catch_exception_raise: beginning (task 0x807, thread 0xf03)
repair_instruction   : fixing instruction (task 0x807, thread 0xf03)
        eip: 0xb008100c, eax: 0xb0080fff, ebx: 0x00002358, ecx: 0x00000000, edx: 0xb0081000
        esp: 0xbffffa55, ebp: 0xbffffab8, edi: 0x00002cc1, esi: 0xbffffb5f
repair_instruction   : after setting thread state
        eip: 0x00002c26, eax: 0xb0080fff, ebx: 0x00002358, ecx: 0x00000000, edx: 0xb0081000
        esp: 0xbffffa55, ebp: 0xbffffab8, edi: 0x00002cc1, esi: 0xbffffb5f
exception_handler    : sending reply
exit_gracefully      : dying graceful death (task 0x807, thread 0x10b)

Snow Leopard. Без аргумента то же самое, поэтому соответствующий фрагмент опущен. Сразу пробуем exh mach:

main                 : starting up (task 0x807, thread 0x903)
...
repair_instruction   : after setting thread state
        eip: 0x00002aa7, eax: 0xb0081000, ebx: 0x000021d2, ecx: 0x00000001, edx: 0xb0081000
        esp: 0xbffffabc, ebp: 0xbffffaf8, edi: 0x00000000, esi: 0x00000000
exception_handler    : sending reply
exit_gracefully      : dying graceful death (task 0x807, thread 0x903)
main                 : after function_with_bad_instruction

И, наконец, Lion:

$ ./exh mach
main                 : starting up (task 0x907, thread 0x707)
main                 : about to dispatch exception_handler pthread
main                 : about to call function_with_bad_instruction
exception_handler    : beginning (task 0x907, thread 0x1303)
exception_handler    : received message
exception_handler    : victim thread is 0x707
exception_handler    : victim thread's task is 0x907
exception_handler    : calling exc_server
catch_exception_raise: beginning (task 0x907, thread 0x1303)
repair_instruction   : fixing instruction (task 0x907, thread 0x1303)
        rip: 0x0000000001b81000, rax: 0x0000000001b81000, rbx: 0x0000000000000000, rcx: 0x00000000019bf0f8, rdx: 0x0000000000000000
        rsp: 0x00000000615bcb18, rbp: 0x00000000615bcba0, rdi: 0x00000000786742a0, rsi: 0x0000000000000303
repair_instruction   : after setting thread state
        rip: 0x00000000019be900, rax: 0x0000000001b81000, rbx: 0x0000000000000000, rcx: 0x00000000019bf0f8, rdx: 0x0000000000000000
        rsp: 0x00000000615bcb18, rbp: 0x00000000615bcba0, rdi: 0x00000000786742a0, rsi: 0x0000000000000303
exception_handler    : sending reply
exit_gracefully      : dying graceful death (task 0x907, thread 0x707)
main                 : after function_with_bad_instruction
$ ./exh
main                 : starting up (task 0x907, thread 0x707)
main                 : about to call function_with_bad_instruction
Segmentation fault: 11

Как видно из происходящего и из самого исходного кода, подготовка к обработке исключений в Mach-стиле происходит только тогда, когда наша программа вызвана с аргументом "mach". В противном случае ничего такого не происходит. Почему-то на Leopard (10.5.5) дело до сообщения "after function_with_bad_instruction" не доходит. Процесс повисает. Подозреваю, из-за проблем со стеком при возврате из exit_gracefully(). Впрочем, для нашего случая это особого значения не имеет. Главное - пруф концепции. Обращает на себя ещё одна небольшая мелочь. В Snow Leopard и Leopard процесс получает SIGBUS, в то время как на Lion - 11, SIGSEGV. Но опять же, для нас это особого значения не имеет, так как основной целью было продемонстрировать, что без назначенного обработчика исключений Mach процесс не имеет шансов выжить, не получив сигнал.

Что происходит в нашей программе? Если не задан аргумент командной строки mach, то всё очень просто. Указатель на функцию bad_fn содержит NULL или случайный мусор. Управление быстро доходит до вызова bad_fn, происходит нарушение доступа, как следствие, программа получает сигнал. Всё происходит в рамках семантики Unix.

В противном же случае, если задан аргумент mach, мы подготавливаем обработчик исключения. Для этого создаём порт с помощью уже известного нам трапа mach_port_allocate(), добавляем право на отправку сообщений в порт с помощью mach_port_insert_right() и, очень важный шаг - назначаем созданный порт портом исключений с помощью трапа thread_set_exception_ports(). На этом вызове мы сейчас остановимся чуть подробнее, так как это важный момент.

Вызов thread_set_exception_ports() имеет следующую сигнатуру:

kern_return_t   thread_set_exception_ports
                (thread_act_t              thread,
                 exception_mask_t exception_types,
                 mach_port_t       exception_port,
                 exception_behavior_t    behavior,
                 thread_state_flavor_t     flavor);
Здесь thread - порт потока, для которого мы назначаем обработчик исключения. exception_types - маска исключений, которые будут обработаны на данном порту. Маска соответствует исключению или комбинации исключений и напрямую соответствует типам исключений, обрабатываемым Mach. Создатели Mach определили категории исключений, не зависящих от платформы, что, в общем-то, вполне здраво и разумно. Отказы защиты памяти, арифметические ошибки - всё это в равной степени может иметь место и на платформах PowerPC, и на i386/x86-64. Вот группы исключений, с которыми работает XNU (приведены не полностью; также заметим, что общими для Mach в целом являются также не все категории из этого списка - общих категорий ещё меньше):

EXC_BAD_ACCESS Нарушение доступа к памяти
маска EXC_MASK_BAD_ACCESS
сигнал Unix: SIGBUS, SIGSEGV
EXC_BAD_INSTRUCTION Неизвестная или неверная инструкция/опкод
маска EXC_MASK_BAD_INSTRUCTION
сигнал Unix: SIGILL
EXC_ARITHMETICАрифметическое исключение
маска EXC_MASK_ARITHMETIC
сигнал Unix: SIGFPE
EXC_EMULATIONЭмуляция инструкций другой архитектуры
маска EXC_MASK_EMULATION
сигнал Unix: SIGEMT
EXC_SOFTWAREПрограммное исключение
маска EXC_MASK_SOFTWARE
сигнал Unix: SIGSYS, SIGPIPE, SIGABRT, SIGKILL
EXC_BREAKPOINTТочка останова
маска EXC_MASK_BREAKPOINT
сигнал Unix: SIGTRAP
EXC_CRASHКрах задачи, аварийное завершение
маска EXC_MASK_CRASH
EXC_RESOURCEПревышение лимита на ресурс
маска EXC_MASK_RESOURCE

Кроме упомянутых, можно задать EXC_MASK_ALL - все категории исключений. exception_port - Mach-порт, созданный ранее, с правом на отправку сообщений, на котором будут приниматься сообщения об исключениях данного типа (или все, в зависимости от маски). Полный список категорий исключений и их масок можно получить их заголовка osfmk/mach/exception_types.h для текущей версии XNU. Конкретное исключение уточняется кодом, передаваемым в сообщении об исключении. В таблице приведены соответствия между категориями и сигналами Unix. При обработке исключения в ядре категория исключения преобразуется в сигнал Unix функцией ux_exception() из bsd/uxkern/ux_exception.c. Здесь, разумеется, речь уже исключительно о XNU, ведь другие Mach не симулируют поведение BSD на уровне ядра. Также интересно, что конкретно в XNU исключения каждой категории могут быть обслужены на своём собственном порту. Т.е., для обработки каждой категории можно завести по отдельному порту и потоку. Следующий аргумент thread_set_exception_ports(), behavior - определяет, какая информация должна быть передана обработчику исключения и, собственно, какой обработчик должен быть вызван:

EXCEPTION_DEFAULT Обработчик получает идентификатор потока

Функция, реализующая обработчик исключения: catch_exception_raise()
EXCEPTION_STATE Обработчику передаётся состояние сбойного потока

Функция, реализующая обработчик исключения: catch_exception_raise_state()
EXCEPTION_STATE_IDENTITY EXCEPTION_DEFAULT + EXCEPTION_STATE

Функция, реализующая обработчик исключения: catch_exception_raise_state_identity()

На уровне хоста эти кэтчеры, кроме самого первого, являются заглушками, возвращающими KERN_INVALID_ARGUMENT. Итак, если на уровне задачи или потока никто не выразил готовности обработать исключение, оно попадает на обработку функции catch_mach_exception_raise() в bsd/uxkern/ux_exception.c - обработчику уровня хоста, т.е., ядру. Имя catch_mach_exception_raise() не является опечаткой. Определён и кэтчер catch_exception_raise(), который вызывает catch_mach_exception_raise(). Идентификаторы, содержащие в имени "_mach_", - 64-битные версии кэтчеров внутри ядра. Здесь решается окончательная судьба процесса, которому принадлежит сбойный поток или потока. Исключение Mach преобразуется в сигнал Unix, который доставляется адресату. Наконец,последний аргумент thread_set_exception_ports(), flavor - "разновидность" контекста потока, который нужно сообщить обработчику (кэтчеру). Учитывая то, что на уровне ядра реализован только один кэтчер (и, судя по всему, также обстоят дела в юзерланде, что уже интереснее; такои образом EXCEPTION_STATE и EXCEPTION_STATE_IDENTITY являются нонсенс-флагами), этот аргумент особого смысла не несёт. Теоретически эта разновидность может относиться к PPC, i386 или x86-64.

Наконец, когда создан и назначен порт, мы может запустить отдельный поток (exception_handler() в нашем коде), который и должен будет наше исключение обработать. Поток не делает ничего особенного. Он сразу же засыпает на порту (вызов mach_msg() без таймаута блокирует до тех пор, пока не будет получено хоть одно сообщение). Получив сообщение об исключении, поток вызывает exc_server() - этот вызов является "рабочим телом" сервера исключений. В нашем случае, сервер исключений - наш поток, но нам не нужно делать всю чёрную работу по обработке сообщений, подготовке ответа и всего такого. Для этого есть уже готовый код, который и скрывается за вызовом exc_server(). exc_server() на основе данных, которые ему передали, решает, какую функцию, кэтчер, ему нужно вызвать. И опять-таки, странное дело, но если верить своим глазам и коду из libsyscall, то выбор не особо велик, что ещё раз подтверждает бпрактическую бесполезность флагов EXCEPTION_STATE и EXCEPTION_STATE_IDENTITY при вызове thread_set_exception_ports(). Почему так, автору совершенно непонятно и неизвестно. Кстати, упомянутый кусочек кода разоблачает чёрную магию, как система узнаёт, какой кэтчер нужно вызвать. Рантайм резолвит символ в исполняемом образе приложения и вызывает его, если оный определён. В противном случае, если кэтчер с именем catch_exception_raise() не определён, обработка исключения неизбежно породит новое исключение.

Мы уже знаем, что исключение Mach - ещё одно сообщение. Кстати, вот как оно выглядит:

typedef struct exception_msg {
        mach_msg_header_t          head;
        mach_msg_body_t            msgh_body;
        mach_msg_port_descriptor_t thread;
        mach_msg_port_descriptor_t task;
        NDR_record_t               ndr;
        exception_type_t           exception;
        mach_msg_type_number_t     codeCnt;
        exception_data_t           code;
        char                pad [512];
} exception_msg_t;

Поле exception содержит код категории исключения. На самом деле, название поля может быть каким угодно. Оно не играет никакой роли, пока соблюдается структура сообщения. Поля thread и task - порты потока, в котором произошло исключение, и задачи, которой принадлежит поток. code - дополнительные данные об исключении, массив целых чисел, ну а codeCnt - их общий размер. Паддинг необходим для того, чтобы зарезервировать достаточно места при приёме сообщения - ведь размер может варьировать в зависмости от размера кодовой части. Иначе есть риск получить ошибку msg too large.

Таким образом, exc_server() "распарсив" сообщение, полученное потоком, вызывает кэтчер (который проверяет тип исключения и вызывает функцию repair_instruction(), слегка корректирующую контекст сбойного потока - с помощью перезаписи регистра [er]ip заставляет его перескочить на функцию exit_gracefully(), "забыв" о злополучной bad_fn).

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

/* reply message we will send to the kernel */
typedef struct reply_msg {
        mach_msg_header_t head;
        NDR_record_t ndr;   /* see osfmk/mach/ndr.h */
        kern_return_t ret_code;   /* indicates to the kernel what to do */
} reply_msg_t;

Это сообщение подготовлено кодом exc_server() и отослано нашим потоком с помощью mach_msg(). ret_code содержит код обработки исключения. KERN_SUCCESS уведомляет ядро о том, что исключение обработано и инцидент исчерпан.

Мы выяснили, как выглядят исключения в Mach на примере XNU и я уже не буду ещё раз описывать весь протокол обработки - он должен быть понятен из того, что уже написано выше. Единственное, чего не видно из примера - последовательного перебора портов от потока до хоста. Тут Вам придётся либо поверить мне на слово, либо самостоятельно ознакомиться с API thread_set_exception_ports() и поэкспериментировать с ним. Порты исключений наследуются процессами. Предком всех пользовательских процессов в системе в OS X является launchd. launchd оределяет обработчики исключений, на которые и происходит пересылка исключений в случае сбоя дочернего процесса. Мы также увидели, как API Mach позволяют, например, получить контекст потока. И, поверьте, это далеко не предел. Помните о великой силе портов в Mach? Собственно, на этом всём и построен, например, Crash Reporter в OS X - ведь поток, обрабатывающий исключение, может находиться и в другом процессе. Конечно же, механизм сбора данных об аварийных ситуациях в процессах можно реализовать и иначе, безо всяких там сообщений. Но всё-таки модель IPC в стиле Mach делает процесс более естественным и гибким. Надеюсь, мне удалось более или менее раскрыть эту интересную тему. Конечно, многое осталось за кадром. К сожалению, объяснить всё до последней точки я бы не смог в том объёме, в котором я сам знаком с темой, в котором мне не лень писать и на который вообще хватает времени. Поэтому за деталями я отсылаю Вас к упомянутым книгам [1] и [2] и вообще, к источникам. Обе книги, кстати, весьма полезны в плане помощи при исследовании внутренностей OS X.

Источники

[1] "Mac OS X Internals: A Systems Approach" By Amit Singh
[2] "Mac OS X and iOS Internals: To the Apple's Core" By Jonathan Levin
[3] "The Mach Exception Handling Facility" David L. Black, David B. Golub, Karl Hauth, Avadis Tevanian1, and Richard Sanzi
[4] Разнообразные исходные коды как XNU, так и GNU Mach, GNU/Hurd, CMU Mach K83/84, и ещё куча всякого хлама по всей сети.
[5] http://robert.sesek.com/thoughts/2012/1/debugging_mach_ports.html

ПОСЕТИТЕЛИ

free counters