Эта статья написана по мотивам моей курсовой работы, основной смысл которой описан здесь. В процессе работы над ней мне понадобилось добавить в учебной ОС, над которой я работал, поддержку 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 файле, где находятся соответствующие данные.
Соответственно, когда исполняемый файл запускается, необходимо:
Прочитать заголовок сегмента .data
Выделить участок памяти по указанному в заголовку адресу и указанному в заголовке размеру
Скопировать в этот участок памяти данные из .data сегмента ELF файла
Сделать шаги 1 и 2 для сегмента .bss, но вместо шага 3 занулить всю выделенную память.
По-хорошему, в случае с 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)

Apoheliy
03.12.2022 13:54Короткое контр-предложение: если у вас такие проблемы с fs, то сделайте функционал БЕЗ использования этого регистра. Вы же делаете свою реализацию под обособленную ОС. Сделайте же хорошо! (..., плохо само получится).
Более развёрнуто:
[Извините], по-моему исходные предпосылки статьи не совсем правильные. Вы берёте функционал C++ и говорите, что расскажите, как он работает. После этого компилируете это некоторым (неуказанным) компилятором (под неуказанную ОС) и героически пытаетесь сделать так, чтобы скомплированный код запускался на некоторой другой (какой?) "учебной ОС".
Если для запуска скомпилированного кода (обычного, пользовательского режима) нужно пропатчить ядро ОС, то либо компилятор "не-торт" (сомнительно), либо ОС не соответствует компилятору (более вероятно).
Так может проще было выбрать правильный компилятор? (для "учебной ОС" есть компилятор? Нет?)
Или написать статью в стиле: (например) рассказываю как работает thread_local в компиляторе ххх под ОС цццц на процессорах ччч. Иначе может быть нехорошо, что какой-нибудь ARM+Ubuntu - есть; компилятор C++ под него - есть; регистр fs - а что это?.
Прим.: регистры cs... ИЗНАЧАЛЬНО задумывались для того, чтобы 16-битная адресация могла добраться до 1 Мбайта памяти. И изначально в регистр cs... можно было записывать что хочешь (и не делать системный вызов) и получающийся результирующий адрес (например, cs * 16 + ip) уже покрывал 1 мб.

vda19999 Автор
03.12.2022 14:11Должно быть, вы правы в том, что название статьи не совсем точное.
Компилятор - gcc, запускаемый под Linux для компиляции без стандартной библиотеки. Естественно, про особенности ОС Infos ему ничего неизвестно - но писать собственный компилятор только для того, чтобы не вносить одно исправление в одну строчку в ядро мне не хотелось.
Да, из приведенного выше кода только две строки из ядра - остальное - это код пользовательской библиотеки.
trux
Похоже, Вы путаете сегменты и секции ELF. У сегментов нет ни имён, ни полей с префиксом
sh_, это всё как раз есть у секций. Если Вы делаете более-менее нормальную поддержку ELF, то для загрузки исполняемых файлов нужно использовать именно сегменты, а конкретно для TLS нужно искать сегмент с типомPT_TLS.vda19999 Автор
Действительно, я наверное перепутал сегменты и секции. Дело было некоторое время назад, кроме того, в устройстве ELF досконально не разбирался.