Как научить операционную системы «выбрасывать» из системных вызовов исключения C++ и как это можно применять

c792ee364d7454e50a62b634e3199a08

Эта статья написана по мотивам дипломной работы, выполненной в ВУЗе. Мне показалось, что она могла бы быть интересна и другим людям, поэтому выкладываю пересказ. В этой работе я кратко рассмотрю, как вообще работают исключения в С++, опишу, как я добавил их поддержку в простую ОС, написанную для преподавания АКОСа, какой способ передачи исключений из ядра в программы я написал. А в конце посмотрим, в каких ещё случаях ОС может бросать пользователям С++ исключения.

Постановка задачи

Имеется простая ОС, написанная для преподавания курса АКОС в университете. Называется InfOS. На её примере предлагается исследовать, можно ли модифицировать операционную систему так, чтобы системные вызовы могли бросать С++ исключения вместо возврата отрицательных чисел, означающих ошибки. И операционная система, и пользовательские программы для неё написаны на С++ (конечно, в ОС есть ещё и ассемблер). Операционная система предназнычена для архитектуры amd64 и запускается с помощью Qemu.

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

Кратко о том, как в С++ работают исключения

Разобраться в части реализации поддержки исключения стандартной библиотеки мне очень сильно помогла другая статья на Хабре. С момента написания её оригинала прошло много времени, и код оттуда больше не работает в 64-битном Linux при использовании gcc, так что пришлось поразбираться в некоторых тонкостях самостоятельно. Кроме того, код оттуда достаточен для иллюстрации работы исключений, но, к примеру, он не вызывает деструктор исключения, когда его обработка завершается.

Итак, если кратко, поддержка исключений основывается на трёх вещах: коде, который генерирует компилятор, на libcxxapi и libunwind. Задача libcxxapi — сделать ту работу по обработке исключения, которая специфична для языка С++ и вызвать функции из libunwind, которые, с помощью libcxxapi, найдут catch-блок, который может обработать исключение и переключить исполнение на него, если такой блок имеется. libunwind может применяться при использовании исключений и в других языках программирования, если используется gcc. Среди этих языков D, Go, Java.

Теперь пойдём по порядку и начнём с компилятора.

Как компилятор компилирует throw

class A {
public:
    __attribute__((noinline)) A() {
        a = 7;
    }

private:
    int a;
};

void throw_A() {
    throw A();
}

Код выше превращается во что-то такое:

A::A() [base object constructor]:
        movl    $7, (%rdi)
        ret
throw_A():
        pushq   %rbx
        movl    $4, %edi
        call    __cxa_allocate_exception
        movq    %rax, %rbx
        movq    %rax, %rdi
        call    A::A() [complete object constructor]
        xorl    %edx, %edx
        movl    $typeinfo for A, %esi
        movq    %rbx, %rdi
        call    __cxa_throw

Как мы видим, когда код бросает исключение, происходит следующее:

  1. Вызывается __cxa_allocate_exception для выделения памяти для исключения

  2. Вызывается конструктор искючения

  3. Вызывается __cxa_throw

Функции, начинающиеся на __cxa лежат в libcxxapi.

Как компилятор компилирует catch

int catch_exception() {
    try {
        unsafe();
        return 0;
    } catch (int a) {
        return 8;
    } catch (A a) {
        return 6;
    }
}

Этот код превращается в:

catch_exception():
        pushq   %rcx
        call    unsafe()
        xorl    %eax, %eax
        jmp     .L4
        movq    %rax, %rdi
        cmpq    $1, %rdx
        je      .L6
        cmpq    $2, %rdx
        je      .L7
        call    _Unwind_Resume
.L6:
        call    __cxa_begin_catch
        call    __cxa_end_catch
        movl    $8, %eax
        jmp     .L4
.L7:
        call    __cxa_begin_catch
        call    __cxa_end_catch
        movl    $6, %eax
.L4:
        popq    %rdx
        ret

При компиляции try-catch блока, сначала компилятор записывает содержимое try блока. Потом идёт сравнение %rdx с разными числами. В зависимости от значения %rdx выполняется либо один catch блок, либо другой, либо вызывается _UnwindResume.

Дело в том, что исполнение может вернуться в нашу функцию catch_exception даже в том случае, если она не может обработать исключение. В этом случае она должна будет уничтожить объекты, созданные до try-catch блока и вызвать _UnwindResume, чтобы раскрутка стека вызовов продолжилась.

Что же делает функция __cxa_throw? Она подготавливает исключение, добавляя у нему необходимые header-ы и вызывает _Unwind-RaiseException

Мой код __cxa_throw

void __cxa_throw(
            void* thrown_exception, type_info *tinfo, void (*dest)(void*)) {
        __cxa_exception *header = (__cxa_exception *) thrown_exception - 1;
        header->exceptionDestructor = dest;
        header->exceptionTypeName = (char*)malloc(strlen(tinfo->getName()) + 1);
        if (!header->exceptionTypeName) {
            header->exceptionTypeName = (char*)fallback_alloc(strlen(tinfo->getName()) + 1);
        }
        header->nextException = nullptr;
        if (!header->exceptionTypeName) {
            printf("unable to allocate\n");
            exit(0);
        }
        memcpy(header->exceptionTypeName, tinfo->getName(), strlen(tinfo->getName()) + 1);
        _Unwind_RaiseException(&header->unwindHeader);
        exit(0);
    }

Что делает _Unwind-RaiseException? Она проходит по стеку вызовов до самого начала и для каждой функции, которая ей встретится, вызывает функцию __gxx_personality_v0. Эта функция читает информацию о функции и сообщает, может ли данная функция обработать данное исключение. Если не может, то сообщает, нужно ли выполнить очистку данной функции. В случае, если исключение поймать не может никто, _Unwind-RaiseException возвращается. В противном случае она снова проходит по стеку вызовов и выполняет очистку функций, которые требуют очистки и передаёт управление в обработчик исключения.

Зачем нужны __cxa_begin_catch и __cxa_end_catch? Они нужны для того, чтобы код catch блока смог получить доступ к исключению и чтобы уничтожить его после обработки.

Поддержка исключений в пользовательском пространстве InfOS

Весь код выложен здесь и здесь.

Пользуясь статьёй, в которой описывается работа исключений в С++, я написал собственную реализацию нужной мне части libcxxabi.

С libunwind всё сложнее. Я не нашёл внятного описания того, каким образом она просматривает стек вызовов и находит language specific data — информацию, которую компилятор генерирует для описания функций. Для всего этого используется секция .eh_frame из исполняемого ELF — файла, но в каком формате туда записываются данные я тоже не смог понять. Поэтому я пошёл другим путём: взял реализацию этой библиотеки из исходников gcc, умудрился её скомпилировать без стандартной библиотеки и заставил работать.

Как уже было сказано, для раскрутки стека libunwind использует .eh_frame. При этом, при запуске программы библиотека libunwind должна быть проинициальзирована — должна быть вызвана некоторая функция, при этом, ей передаётся адрес начала секции .eh_frame. Этот адрес является адресом некоторой переменной нулевого размера, которая определена где-то в коде libunwind. Каким-то магическим образом линкеру говорят поместить её ровно в начале секции.

Я пошёл немного другим путём — написал функцию, читающую ELF файл и находящую начало и конец .eh_frame. Эта функция потом мне пригодилась, когда я добавлял поддержку thread local переменных.

После переноса моей реализации части libcxxabi и libunwind в пользовательскую библиотеку InfOS, там наконец-то заработали исключения. Теперь уже не составило труда эти же файлы добавить и в ядро, чтобы там тоже работали исключения, но пока изолированно от пользовательских программ.

Как послать исключение через границу между ядром и пользовательской программой

В InfOS системные вызовы выполняются с помощью прерываний, как в 32-битном Linux. Быстрые инструкции syscall/sysret не поддерживаются.

Давайте подумаем, что будет, если системный вызов бросит исключение, которое не может быть поймано внутри ядра. Функция Unwind_RaiseException будет просматривать стек вызовов, пока не дойдёт до функции в пространстве ядра, которая начала исполняться, когда случилось прерывание. Во-первых, эта функция написана на ассемблере, и для неё в .eh_frame ничего не написано. Поэтому Unwind_RaiseException не сможет понять, откуда её «вызвали». Кроме того, инструкция int (вход в прерывание) записывает адрес возврата в немного другое место, чем call (вызов функции), что не дало бы Unwind_RaiseException понять, откуда обработчик прерывания был вызван, даже если информация в .eh_frame была. Впрочем, у нас в ядре есть информация об адресе инструкции в полльщовательской программе, которая вызвала прерывание, и мы можем эту информацию использовать. Но возникнет другая проблема: всякая информация, которую .eh_frame помогает восстановить, вроде адресов возврата, сохраняется на стеке. Ядро ОС использует другой стек, поэтому нужно как-то научить Unwind_RaiseException использовать другой стек. Но допустим, мы решили эту проблему. Какие ещё проблемы у нас возникнут? Для каждой функции в стеке вызовов Unwind_RaiseException должен вызвать функцию __gxx_personality_v0. Если функция находится в пользовательском пространстве, то и её __gxx_personality_v0, по-хорошему, находится там (__gxx_personality_v0 в ядре совершенно такая же, но правильней использовать всё же не её). Вызывать функцию из пользовательского пространства в ядре небезопасно, поэтому нам пришлось бы как-то понижать уровень привелегий, выполнять эту функцию и возвращаться обратно. Это звучит сложно и добавляет нам оверхеда на переключение контекстов. Те же самые сложности были бы потом при выполнении блоков очистки из функций — нам пришлось бы понижать уровень привелегий, выполнять эту очистку, а потом, при вызове Unwind_Resume, как-то возвращаться обратно.

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

struct X86Context
	{
		uint64_t previous_context;
		uint64_t fs, gs, r15, r14, r13, r12, r11, r10, r9, r8;
		uint64_t rbp, rdi, rsi, rdx, rcx, rbx, rax;
		uint64_t extra, rip, cs, rflags, rsp, ss;
	} __packed;

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

Остался один вопрос — откуда ядро узнает адрес функции Unwind_RaiseException в процессе? Я решил эту проблему так — при запуске каждый процесс выполняет специальный системный вызов и передаёт в него адрес Unwind_RaiseException.

Сделанное к этому моменту — почти победа. Теперь можно, например, открывать файлы вот так:

try {
  int fd = syscall(Syscall::SYS_OPEN, (unsigned long)name, 0);
} catch (FileAbsentException& exc) {
  printf("file does not exist\n");
} catch (AccessViolationException& exc) {
  printf("user is not allowed to read this file\n");
}

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

Производительность

Понятно, что бросить исключение — это дороже по времени, чем просто вернуть число. Измерим, насколько дольше. Добавим в ядро два системных вызова: один не делает ничего, другой — бросает исключение и вызовем каждый 100000 раз, измеряя число прошедших тиков. Выяснилось, что бросать исключение в 18 раз дольше.

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

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

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

Замена сигналов исключениями

Некоторые сигналы, которые ОС Linux посылает приложениям в случае проблем, можно заменить С++ исключениями. Небольшое изменение в коде обработчика Page Fault в InfOS, и мы можем делать так в программах:

volatile int *a = (volatile int *)0;
try {
  int x = *a;
} catch (const page_fault_exception& e) {
  printf("page fault exception thrown addr=%p!\n", 
            e.faulting_address());
}

Точнее, ровно так сделать нельзя из-за компилятора. Gcc не в курсе, что теперь разыменование указателя может вызывать исключение и убирает весь catch блок. Но если обмануть его, убрав разыменование в функцию в другом файле, то всё получится.

Ещё два примера возможной замены сигналов исключениями

long random() {
    try {
    	asm("rdrand %rax");
      return ;
    } catch (const IllegalInstructionException& exc) {
    	// rdrand not supported, we need to use another way
    	return generate_random_slow();
    }
}
int main() {
    try {
    	Server server;
      server.start();
    } catch (const AbortException exc) {
    	// destructor for server will be called before we get here
    	std::cout << "server terminated" << std::endl;
    }
}

Решаем некоторые проблемы с тем, как реализованы исключения

Когда была сделана работа, описанная выше, остались следующие проблемы:

  • Нельзя использовать исключения из нескольких потоков. Для этого нужна некоторая синхронизация. Кроме того, __cxa_begin_catch сохраняет исключение на некотором стеке исключений для того, чтобы __cxa_end_catch его потом удалил. Этот стек должен быть thread local. В InfOS нет ни мьютексов, ни thread local переменных.

  • В InfOS нет никакой возможности попросить у ядра ещё памяти, поэтому __cxa_allocate_exception выделяет пямять только из статического буфера.

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

Проблему с деструктором можно решать так:

  • Бросать только исключения с тривиальным деструктором

  • Сделать код ядра исполняемым для пользовательских программ. Кажется, что это небезопасно.

  • Создать для этого системный вызов.

То, как я реализовывал выделение памяти, mutex/futex и поддержку thread local переменных, особого интереса, наверное, не представляет. Но, реализовывая thread local, я наткнулся на ещё одно возможное применение исключений.

Завершение потоков с помощью исключений

Изначально, запуск потока в InfOS был устроен так: для создания потока вызывалась функция create_thread, которой передавался указатель на функцию, которую надо запустить в потоке и указатель на её аргумент. Эта функция делала системный вызов, просто передавая туда свои аргументы. При этом, функция, запускаемая в потоке, не должна никогда возвращаться — если работа завершена, она должна вызвать функцию stop_thread, что выполнит системный вызов, завершающий поток. Также функция stop_thread могла быть вызвана одним потоком для завершения другого. При этом, если функция создавала какие-то нетривиальные объекты, то их деструкторы не вызывались.

Реализуя thread local, мне пришлось переделать start_thread: теперь она передаёт в системный вызов функцию, которая сначала настраивает thread local переменные, потом вызывает функцию потока, потом освобождает память thread local переменных. При этом, если функция потока сама вызовет stop_thread или другой поток захочет завершить этот, то ни до какого освобождения памяти дело не дойдёт. Как же это решить?

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

Заключение

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

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

Весь код расположен в двух репозиториях: ядро, пользовательское пространство. Если при запуске по инструкции система не запускается, вероятно, дело в баге, который там был до меня. Надо использовать для компиляции gcc версии 10, заменив g++ на g++-10 в файле build/Makefile.include

© Habrahabr.ru