Эта статья написана по мотивам моей курсовой работы, основной смысл которой описан здесь. В процессе работы над ней мне понадобилось добавить в учебной ОС, над которой я работал, поддержку 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 досконально не разбирался.