Эта статья написана по мотивам моей курсовой работы, основной смысл которой описан здесь. В процессе работы над ней мне понадобилось добавить в учебной ОС, над которой я работал, поддержку thread_local переменных, о чём я и хочу здесь рассказать в надежде что кому-то это окажется полезно.

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

Код расположен в двух репозиториях.

Что такое thread_local переменные?

Язык C++ позволяет объявлять глобальные переменные thread_local. Переменная, объявленная таким образом, будет разной в разных потоках программы: разные потоки могут её использовать, не используя никакой синхронизации, и каждого из них будет быть своя копия этой переменной.

Как они работают?

Давайте напишем какой-нибудь код, который что-то делает с thread_local переменной и посмотрим, в какой ассемблер он компилируется. Также посмотрим, во что превращается такой же код, но с обычной переменной, а не thread_local:

extern "C" { // чтобы имя функции в ассемблерном коде было понятным
thread_local int value = 5;
void fn() {
    value++;
}
}

превращается в

fn:
        addl    $1, %fs:value@tpoff
        ret
value:
        .long   5

Если убрать thread_local, то будет:

fn:                                     # @fn
        addl    $1, value(%rip)
        retq
value:
        .long   5                               # 0x5

Итак, что мы видим? Переменная объявляется в ассемблерном коде (забегая вперёд, скажу, что в бинарнике (ELF-файле) эта переменная будет обявляться в сегменте .data (или .tdata)). Если thread_local нет, то обращение к переменной происходит просто по её адресу в памяти. Если же переменная thread_local, то происходит обращение по какому-то адресу %fs:value. Эта запись означает обращение по адресу (%fs + value). %fs - это segment register. В данном случае достаточно знать, что это регистр, который используется только для доступа к thread local storage - данным конкретного потока.

Следовательно, чтобы поток имел доступ к своему thread local storage (TLS), при его запуске в регистр %fs необходимо записать указатель на конец участка памяти, выделенного под TLS. Осталось разобраться, как в этот регистр что-то записать, как понять, какой должен быть размер TLS и как его инициализировать.

Как что-то записать в сегментные регистры

Всего есть 6 сегментных регистров: %cs, %ds, %ss, %es, %gs и собственно интересующий нас %fs. Поскольку изначально эти регистры задумывались для настройки виртуальной памяти операционной системой, по умолчанию в эти регистры писать может только операционная система (в реальности в настоящее время виртуальная память настраивается с помощью таблиц страниц, и первые 4 перечисленные регистра не используются). Поскольку делать системный вызов просто для того, чтобы что-то записать в регистр довольно неэффективно, то в современных процессорах есть специальные инструкции для этого: readfsbase, writefsbase, readgsbase и writegsbase. Однако использовать их просто так невозможно: дело в том, что некоторые ядра ОС полагаются на то, что пользовательские программы не могут изменять регистры %fs и %gs, поэтому для того, чтобы процессор позволил выполнить такую инструкцию, ядро ОС должно это явно разрешить, выставив флаг в регистре %cr4.

Итак, есть два варианта, как можно писать в регистр %fs для настройки TLS:

  • Добавить системные вызовы для чтений и записи регистров %fs и %gs. Ядро ОС может писать в них просто инструкцией mov. Способ не эффективен по времени, так как системный вызов делать долго, зато будет работать на всех процессорах.

  • Разрешить инструкции readfsbase, writefsbase, readgsbase и writegsbase и их использовать. Эти инструкции есть только на некоторых процессорах, но меня это не волнует, так как данная учебная ОС запускается через qemu без аппаратной виртуализации, и можно выбрать процессор с поддержкой этих инструкций.

Я выбрал второй вариант, поэтому сначала надо, чтобы ядро ОС при запуске разрешило нужные инструкции. Для этого заменяем

mov $0x6b0, %eax
mov %eax, %cr4

на

mov $0x106b0, %eax
mov %eax, %cr4

Выделяем память для TLS и инициализируем её

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

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

Соответственно, когда исполняемый файл запускается, необходимо:

  1. Прочитать заголовок сегмента .data

  2. Выделить участок памяти по указанному в заголовку адресу и указанному в заголовке размеру

  3. Скопировать в этот участок памяти данные из .data сегмента ELF файла

  4. Сделать шаги 1 и 2 для сегмента .bss, но вместо шага 3 занулить всю выделенную память.

  5. По-хорошему, в случае с C++ до вызова функции main ещё и должен произойти вызов конструкторов глобальных объектов.

С thread local storage всё устроено довольно похоже. Инициализированные и неинициализированные переменные в ELF-файле находятся в сегментах .tdata и .tbss, соответственно. При запуске потока необходимо выделить память для TLS размера сегментов .tdata и .tbss, инициализировать эту память и записать адрес её конца в регистр %fs. В случае с С++ ещё надо позвать конструкторы thread_local объектов, а при завершении потока надо позвать деструкторы.

Посмотрим на код. Объявляем функции для записи и чтения регистров %fs, %gs.

обёртки
asm(
    ".global rdfsbase\n"
    "rdfsbase:\n"
    "   rdfsbase    %rax\n"
    "   ret\n"
);

asm(
    ".global rdgsbase\n"
    "rdgsbase:\n"
    "   rdgsbase    %rax\n"
    "   ret\n"
);

asm(
    ".global wrfsbase\n"
    "wrfsbase:\n"
    "   wrfsbase    %rdi\n"
    "   ret\n"
);

asm(
    ".global wrgsbase\n"
    "wrgsbase:\n"
    "   wrgsbase    %rdi\n"
    "   ret\n"
);

Собственно подготовка thread local storage:

// в tbss и tdata записаны заголовки сегментов .tdata, .tbss из ELF 
// файла. Перед вызовом этой функции должна быть вызвана функция чтения ELF файла

void prepare_thread_local() {
    thread_local_size = tbss.sh_size + tdata.sh_size;
    if (thread_local_size == 0) {
        return ; // ничего делать не нужно
    }
    uint64_t alignment = tdata.sh_addralign;
    if (tbss.sh_addralign > alignment) {
        alignment = tbss.sh_addralign;
    } // c alignment мог немного напутать, но на моих тестах всё работает
    thread_local_size = alignup(thread_local_size, alignment);
    // выделяем память
    auto addr = (char *)malloc(thread_local_size + sizeof(long) + alignment);
    // здесь может в зависимости от реализации malloc можеть быть нужна 
    // проверка на выровненность адреса
    // записываем конец памяти в %fs
    wrfsbase((uint64_t)(addr + thread_local_size));
    // по соглашению с компилятором, в конце TLS должен быть записан указатель
    // на него самого для того, чтобы прочитать значение %fs
    *(long *)(addr + thread_local_size) = (addr + thread_local_size);

    // now we need to initialize memory
    int fd = open(ELF_NAME, 0);
    // инициализируем иницаилизированные переменные данными из .tdata
    pread(fd, addr, tdata.sh_size, tdata.sh_offset);
    close(fd);
    // обнуляем данные из .tbss
    memset(addr + tdata.sh_size, 0, tbss.sh_size);
}

В конце работы потока надо освободить выделенное хранилище:

void clean_up_thread_local() {
    if (thread_local_size == 0) {
        return;
    }
    free((void *)(rdfsbase() - thread_local_size));
}

Первую функцию надо запустить в начале работы потока, вторую - в конце:

class ThreadLocalHolder {
public:
    ThreadLocalHolder(thread_entry_arg* arg) {
        prepare_thread_local(); // готовим TLS. Чтение ELF файла к этому моменту
                                // уже произошло, оно происходит при запуске процесса
        ptr = arg;
    }

    ~ThreadLocalHolder() {
        run_thread_atexit(); // эта функция запускает все задачи, которые 
                            // должны выполниться в конце работы потока, то есть деструкторы
        clean_up_thread_local();
        delete ptr;
        syscall(Syscall::SYS_THREAD_LEAVE);
    }
private:
    thread_entry_arg* ptr;
};

// эта функция вызывается ядром при запуске нового потока. 
void thread_func(thread_entry_arg* arg) {
    ThreadLocalHolder holder(arg); // вызывается конструктор
    try {
        arg->f(arg->ptr); // вызываем функцию, переданную при создании потока с аргументом
    } catch (...) {
        printf("thread leaving due to exception of type %s\n", __cxa_last_exception_name());
    }
    // вызывается деструктор
}

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

Если мы заведём thread local переменную, имеющую тип, требующий вызова конструктора и деструктора, то компилятор сгенерирует функцию __tls_init, которая, если вызвана в потоке в первый раз, вызывает конструктор этого объекта и вызывает функцию __cxa_thread_atexit, передавая ей деструктор и объект. __cxa_thread_atexit добавит вызов деструктора в список дел, которые должны быть сделаны перед окончанием работы потока. Функция __tls_init будет вызываться при первом обращении к объекту, требующему инициализации. Чтобы всё коректно работало, нам надо реализовать функцию __cxa_thread_atexit.

Заключение

Вроде вот и всё. Вопросы?

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


  1. trux
    01.12.2022 14:02

    Похоже, Вы путаете сегменты и секции ELF. У сегментов нет ни имён, ни полей с префиксом sh_, это всё как раз есть у секций. Если Вы делаете более-менее нормальную поддержку ELF, то для загрузки исполняемых файлов нужно использовать именно сегменты, а конкретно для TLS нужно искать сегмент с типом PT_TLS.


    1. vda19999 Автор
      01.12.2022 14:03

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


  1. Apoheliy
    03.12.2022 13:54

    Короткое контр-предложение: если у вас такие проблемы с fs, то сделайте функционал БЕЗ использования этого регистра. Вы же делаете свою реализацию под обособленную ОС. Сделайте же хорошо! (..., плохо само получится).

    Более развёрнуто:

    [Извините], по-моему исходные предпосылки статьи не совсем правильные. Вы берёте функционал C++ и говорите, что расскажите, как он работает. После этого компилируете это некоторым (неуказанным) компилятором (под неуказанную ОС) и героически пытаетесь сделать так, чтобы скомплированный код запускался на некоторой другой (какой?) "учебной ОС".

    Если для запуска скомпилированного кода (обычного, пользовательского режима) нужно пропатчить ядро ОС, то либо компилятор "не-торт" (сомнительно), либо ОС не соответствует компилятору (более вероятно).

    Так может проще было выбрать правильный компилятор? (для "учебной ОС" есть компилятор? Нет?)

    Или написать статью в стиле: (например) рассказываю как работает thread_local в компиляторе ххх под ОС цццц на процессорах ччч. Иначе может быть нехорошо, что какой-нибудь ARM+Ubuntu - есть; компилятор C++ под него - есть; регистр fs - а что это?.

    Прим.: регистры cs... ИЗНАЧАЛЬНО задумывались для того, чтобы 16-битная адресация могла добраться до 1 Мбайта памяти. И изначально в регистр cs... можно было записывать что хочешь (и не делать системный вызов) и получающийся результирующий адрес (например, cs * 16 + ip) уже покрывал 1 мб.


    1. vda19999 Автор
      03.12.2022 14:11

      Должно быть, вы правы в том, что название статьи не совсем точное.

      Компилятор - gcc, запускаемый под Linux для компиляции без стандартной библиотеки. Естественно, про особенности ОС Infos ему ничего неизвестно - но писать собственный компилятор только для того, чтобы не вносить одно исправление в одну строчку в ядро мне не хотелось.

      Да, из приведенного выше кода только две строки из ядра - остальное - это код пользовательской библиотеки.