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

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

Имеется простая ОС, написанная для преподавания курса АКОС в университете. Называется 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

Комментарии (17)


  1. dmitryvolochaev
    02.08.2022 18:28
    +1

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

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

    Или у вас переключение потоков запрещено во время обработки любых API-вызовов в ядре?


    1. vda19999 Автор
      02.08.2022 18:44
      +1

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

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

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

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


      1. dmitryvolochaev
        02.08.2022 20:10
        +1

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

        Аппаратные прерывания происходят в случайном контексте. Вы только представьте: сидишь себе, никого не трогаешь, складываешь и умножаешь числа, и тут на тебе "файл не найден"


      1. qw1
        02.08.2022 23:01

        А деструктор как вызовется? Перейдёт в режим ядра и выполнится?
        Но тогда программа в блоке catch может переписать vptr и, если объект-исключение использует вирт. методы, в ядре можно выполнить что-то своё.

        В хорошем дизайне FileAbsentException должно быть не только тэгом, что возникла ситуация «файл не найден», но и где-то внутри иметь подробности: имя файла, или InnerException. Все эти подробности должны генерироваться внутри ядра и опасность что-то поломать возрастает.


        1. vda19999 Автор
          02.08.2022 23:04

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

          Но в общем случае вызов деструктора устроен так:

          1. Когда вызывается __cxa_throw, ему передаётся указатель на деструктор.

          2. __cxa_throw записывает этот указатель в структуру __cxa_exception

          3. __cxa_end_catch, используя указатель на деструктор, вызывает его

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

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

          В случае FileAbsentException, класс исключения может просто сожержать массив char, и никакой деструктор не будет нужен.

          P. S. В реальности, если вы бросите экземпляр виртуального класса, там С++ не будет особо разбираться, какой реальный тип вы бросили: https://pastebin.com/n8uTCanQ


          1. qw1
            02.08.2022 23:26

            Таким образом, кажется, что перезапись vptr не повлияет на то, что за деструктор вызовется.
            Если в объекте-исключении есть вложенные другие объекты (например, std::string), можно перезаписать их vptr (и не только vptr, а какие-то указатели и проэксплуатировать какие-то баги/фичи менеджера памяти в ядре. Например, чтобы free вызвался по адресу, который не принадлежит куче).
            В случае FileAbsentException, класс исключения может просто сожержать массив char, и никакой деструктор не будет нужен.
            Идея понятная — держать объекты-исключения как можно более простыми. Но для промышленного применения плохо подходит, когда код пишется большим числом людей, и каждый должен постоянно помнить, какие правила применяются в отношении классов-исключений.


            1. vda19999 Автор
              02.08.2022 23:27

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


              1. qw1
                02.08.2022 23:31

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


                1. qw1
                  02.08.2022 23:34

                  Но тут тоже свои подводные. Ядро Windows, например, разрешает длину путей к файлам до 32768 WCHAR-ов, т.е. на каждый exception надо держать буфер минимум 64КБ.


          1. qw1
            02.08.2022 23:29

            А ещё, если делать всё в парадигме C++, наследоваться от std::exception, там есть virtual what(), и virtual ~exception().


  1. mvv-rus
    02.08.2022 19:23
    +1

    Интересно, да.
    А вы не сравнивали при написании диплома этот ваш механизм с механизмом структурной обработки исключений в Windows (SEH)? Там хотя исключения — не совсем C++ (потому что механизм старый, он изначально был в NT ещё в начале 90-х), но выполняют сходную работу и используют сходные идеи (ту же двойную раскрутку стека, к примеру).
    PS В Visual C++ для поддержки этого механизма есть специфические для производителя расширения.


    1. vda19999 Автор
      02.08.2022 19:53
      +1

      Читал про SEH, но детально разобраться не успел.


  1. Helltraitor
    02.08.2022 22:54

    Осталось написать еще одну ОС, но с Result из Rust. Думаю, это будет и быстрее и надежнее, чем исключения и числа (соответственно)


    1. qw1
      02.08.2022 23:17
      +1

      Для этого желательна поддержка в языке, тот же unwrap().
      Проверять статус после каждого вызова утомительно, и ради этого применяют исключения, пусть и с потерей производительности в пессимистичной ветке.


    1. vda19999 Автор
      03.08.2022 14:49
      +2

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

      А если вы хотите, чтобы системный вызов вернул какую-то структуру, то ядро должно выделить для него память в пространстве пользователя, а потом надо как-то очень аккуратно уничтожать этот объект. Либо пользователь может выделить память перед системным вызовом, но тогда это будет память какого-то фиксированного размера, и снова нужно очень аккуратно его уничтожать.


      1. AnthonyMikh
        03.08.2022 22:48

        А если вы хотите, чтобы системный вызов вернул какую-то структуру...

        Эта "структура" может занимать всего два регистра. А то и один, если возможно применить niche optimisation.


        1. qw1
          04.08.2022 08:41
          +1

          Размер тут вообще не главное. Если структура, значит (потенциально) есть конструктор-деструктор-методы-трейты. Кто ими владеет — ядро или userspace? Где находится код этих методов?