Эта статья написана по мотивам моей курсовой работы, основной смысл которой описан здесь. В процессе работы над ней мне понадобилось добавить в учебной ОС, над которой я работал, поддержку 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.
Заключение
Вроде вот и всё. Вопросы?
ссылка на оригинал статьи https://habr.com/ru/post/702814/
Добавить комментарий