Я пишу JIT-перекомпилятор для x86_64, и иногда сгенерированный код требует вызова функции из скомпилированного двоичного файла.
Из-за ASLR сегмент .text моей программы размещается по случайному адресу.
Могу ли я выделить 32 МБ кэша кода моего JIT таким образом, чтобы его адрес всегда был достаточно близок к сегменту .text, чтобы я мог безопасно выполнять относительные вызовы вместо абсолютных вызовов? Я в Линуксе.
Что вы подразумеваете под «в зависимости от относительных вызовов»? Здесь нет никакой зависимости, я прекрасно могу выполнять абсолютные вызовы. Я спрашиваю, есть ли способ оптимизировать эти вызовы для одной инструкции, а не для двух. Например, если пометить буфер кода как статический, он обычно должен располагаться рядом с кодом. Не могли бы вы уточнить?
Я бы поискал реализацию vtable или таблицы перехода guihao-liang.github.io/2020/05/30/what-is-vtable-in-cpp
Возможные дубликаты: Пользовательские диапазоны выделения кучи/памяти , Как выполнить malloc в диапазоне адресов > 4 ГиБ , Выделить по нижнему адресу памяти
@DanielA.White: Зачем вам так много косвенных действий, если вы просто хотите вызвать определенную статически скомпилированную функцию C, которая не требует полиморфизма? В худшем случае вам понадобится call [rel32]
с некоторыми статическими данными рядом с JIT-кодом (в том же распределении) или mov rax, imm64
/call rax
, как в Вызвать абсолютный указатель в машинном коде x86
mprotect
, чтобы сделать его исполняемым. Или другие трюки, такие как MMAP_32BIT, если вы создаете исполняемый файл, отличный от PIE. Потенциальный дубликат этого.
Как сказано в комментариях, лучше не зависеть от относительных вызовов.
Вы можете использовать mmap
, чтобы запросить сопоставление памяти по определенному адресу. Проблема в том, чтобы узнать, какие адреса свободны.
Что-то вроде этого может сработать:
#include <stdio.h>
#include <sys/mman.h>
#include <unistd.h>
extern char __executable_start[];
extern char __etext[];
extern char _end[];
#define OFFSET (32 * 1024 * 1024)
#define SIZE (32 * 1024 * 1024)
int main()
{
printf(".text start = %p\n", (void*)__executable_start);
printf(".text end = %p\n", (void*)__etext);
printf("binary end = %p\n", (void*)_end);
char* buffer = mmap(_end + OFFSET, SIZE, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
printf("buffer = %p\n", buffer);
printf("distance = %d\n", buffer - __etext);
}
Однако это не гарантирует работу. Если запрошенный регион уже содержит сопоставление, ядро может просто проигнорировать вашу подсказку (на самом деле Linux, похоже, так и делает).
Если вы действительно хотите это сделать, я предлагаю вам попробовать выделить память, близкую к .text
— если вам это удастся, в противном случае вы используете относительные вызовы или абсолютные вызовы.
Лучшей версией приведенной выше программы будет:
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <sys/mman.h>
#include <unistd.h>
extern char __executable_start[];
extern char __etext[];
extern char _end[];
#define OFFSET (32 * 1024 * 1024)
#define SIZE (32 * 1024 * 1024)
#define MAX_ATTEMPTS 10
int main()
{
printf(".text start = %p\n", (void*)__executable_start);
printf(".text end = %p\n", (void*)__etext);
printf("end = %p\n", (void*)_end);
// align to next page
char* buffer = (char*)(((uintptr_t)_end + 4095) & ~4095);
int attempts;
for (attempts = 0; attempts < MAX_ATTEMPTS; attempts++)
{
char* result = mmap(buffer, SIZE, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED_NOREPLACE, -1, 0);
if (result != MAP_FAILED)
{
printf("success at attempt #%d\n", attempts + 1);
break;
}
if (errno != EEXIST)
{
return 1;
}
buffer += OFFSET;
}
if (attempts == MAX_ATTEMPTS)
{
printf("failed after %d attempts, falling back to absolute instructions\n", attempts);
buffer = mmap(NULL, SIZE, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
}
printf("buffer = %p\n", buffer);
printf("distance = %d\n", buffer - __etext);
}
Теперь мы сообщаем ядру, что нам нужно отображение по точному адресу. Если сопоставление там уже существует, мы пытаемся использовать другой адрес. Если это не сработает определенное количество раз, мы можем просто вернуться к абсолютным инструкциям.
Альтернативно, вы можете проанализировать /proc/self/maps
и найти свободный регион рядом с .text
.
Однако сначала я бы попробовал проверить, действительно ли абсолютные вызовы медленнее относительных. Возможно, вам не стоит беспокоиться.
Если запрошенный регион уже содержит сопоставление, ядро может просто проигнорировать вашу подсказку — да, именно поэтому она и называется подсказкой. Если вы хотите, чтобы в этом случае произошел сбой, используйте MAP_FIXED_NOREPLACE
, специфичный для Linux. (Не MAP_FIXED
; в этом случае предыдущее сопоставление будет отменено!) Вероятно, лучший вариант использования OP — поместить JIT-буфер в BSS (статический массив) и использовать mprotect
, чтобы сделать часть его исполняемой, как в Обработка вызовов (потенциально) далеко скомпилированных заранее функций из JIT-кода
Выделение с помощью brk
было бы еще одним способом обеспечить смежность памяти с BSS и, таким образом, рядом со всем остальным. По крайней мере, рядом с BSS основного исполняемого файла, что не помогает, если это библиотека.
@PeterCordes Использование .bss
может быть хорошей идеей, хотя тогда размер будет фиксирован. brk
не сработает (я пробовала), потому что перерыв в программе часто рандомизирован.
Да, в этом случае ОП заявил, что их вариант использования — это JIT-кэш фиксированного размера размером 32 МБ. В других случаях может работать «достаточно большой» массив, часть которого вы используете, особенно если вы можете разместить его в конце вашего BSS (возможно, поместив его в специальный раздел), чтобы не повредить локальность dTLB. для глобальных переменных. Я не осознавал, что разрыв был рандомизирован, чтобы больше не совпадать с BSS. Это имеет смысл с точки зрения безопасности.
Я проверил, и в моей системе (ядро Linux 6.7.2) расстояние от выделения sbrk
до глобального в BSS случайным образом варьируется от 1 до 31 МБ. Так что этого вполне достаточно для данного варианта использования. (Но спасибо, что сообщили мне, что на самом деле это не смежные места: интересно). На самом деле проблема заключается в смешивании malloc с sbrk; видимо мой оптимизм был неуместен. Программам, использующим malloc, придется использовать другую реализацию malloc, которая избегает sbrk, если они хотят использовать разрыв.
Ближайший дубликат: Обработка вызовов (потенциально) удаленных, заранее скомпилированных функций из JIT-кода - в моем ответе предлагается выделить место поблизости, чтобы включить jmp/call rel32
, и предлагаются некоторые способы сделать это.
Один из хороших способов сделать это — использовать массив в BSS вместо динамического выделения. Поскольку у вас есть известный предел размера, который невелик (32 МБ) для вашего региона JIT, это идеально.
#include <sys/mman.h>
#include <stdalign.h> // for alignas instead of _Alignas, if you're not using C23
// page aligned to make sure it doesn't share a page with anything else
// And for easy use of mprotect (or Windows VirtualProtect)
alignas(4096) unsigned char JIT_buf[32ULL * 1024*1024];
void jit_init(){
// 31 MiB of code, for example, leaving 1 MiB of data READ|WRITE without exec
mprotect(JIT_buf, 31ULL * 1024*1024, PROT_READ|PROT_WRITE|PROT_EXEC);
}
В стандартных моделях кода для x86-64 весь статический код + данные для исполняемого или общего объекта находятся в пределах +-2 ГиБ друг от друга, что делает любое место доступным из любого места с помощью адресации rel32. В модели кода, отличной от PIE, он занимает 2 ГБ виртуального адресного пространства (поэтому mmap(MAP_32BIT)
будет работать). Но современные исполняемые файлы и общие библиотеки PIE отображаются за пределами минимального размера в 2 ГБ.
Если ваш размер не такой уж маленький, но его верхняя граница меньше пары ГиБ, вы все равно можете использовать массив в конце BSS. Возможно, с помощью __attribute__((section(".bss.jit"))
и при необходимости используйте собственный сценарий компоновщика, чтобы получить ссылку в конце BSS, если сценарий по умолчанию не соответствует всем именам .bss*
. Это позволяет избежать нарушения локальности dTLB для других глобальных переменных. В запусках, где вы никогда не используете больше, чем, скажем, 100 МБ, по сути это происходит так, как если бы массив составлял всего 100 МБ, а используемая вами часть граничит с другими глобальными объектами.
Память, к которой вы никогда не прикасаетесь, на самом деле ничего не стоит, поскольку Linux выполняет чрезмерную фиксацию, например, из mmap или BSS. (Ядро лениво обнуляет его при первом доступе в обработчике ошибок страницы.)
Если это находится в общей библиотеке, установите его static
или __attribute__((hidden))
(возможно, сделав его значением по умолчанию), чтобы он действительно находился в BSS этой общей библиотеки, а не в основном исполняемом файле.
Если это не общая библиотека, другой вариант — выделить память с помощью sbrk
, но только если вы никогда не используете malloc
/new
или не позволяете им использовать sbrk
. Это даст вам память ближе к концу BSS (основного исполняемого файла). Не совсем смежно: ASLR рандомизирует разрыв, но в моей системе (ядро Linux 6.7.2) расстояние от глобальной переменной до выделения sbrk
варьируется от 1 до 31 МБ (то же самое на Godbolt), что все равно легко помещается в пределах +-2 ГиБ rel32 статического кода/данных.
Но, возможно, это не так перспективно и портативно; будущие или другие текущие системы могут случайным образом отодвинуть разрыв от статического кода/данных. Если вы сделаете это, вам следует включить проверку, чтобы вы могли сообщить пользователю, что он должен сообщить, какая система не работает так, как вы ожидали.
Glibc malloc
использует sbrk
для небольших выделений. По словам @zwol в Почему использование функций malloc/calloc/realloc и brk приведет к неопределенному поведению?, «Реализация malloc
может предполагать, что никакой код, кроме него самого, не вызывает sbrk
с ненулевым аргументом». Например, он может думать, что владеет всей памятью от последнего вызова до нового прерывания, включая ваше выделение. Или более тонкая коррупция в его бухгалтерии. Я не знаю наверняка, сломается ли glibc malloc, но соответствующие стандарты (вероятно, POSIX) не требуют, чтобы он работал.
Если вы настроите Glibc malloc, чтобы никогда не использовать brk
/ sbrk
, с вами, вероятно, все будет в порядке. По умолчанию он использует mmap
только для больших выделений, поэтому может немедленно вернуть их в ОС бесплатно, даже если за это время были сделаны другие выделения. Или используйте другую библиотеку malloc, которая вообще не использует sbrk
. Я слышал, что malloc в glibc не так уж и хорош, а сторонние malloc могут оказаться более производительными в некоторых случаях использования.
Обратите внимание, что G++ new
/delete
внутри использует тот же распределитель, что и malloc
.
Как всегда, не забудьте вызвать __builtin___clear_cache(&JIT_buf[start], &JIT_buf[end])
в GNU C после того, как вы сохраните в нем несколько байтов, прежде чем вызывать указатель на функцию, которая использует этот массив в качестве машинного кода. На x86 он на самом деле не очищает кеш, а просто блокирует такие оптимизации, как устранение неработающих хранилищ (пример: https://godbolt.org/z/5671x3MYn ). Вероятно, это не будет проблемой, поскольку вызов mprotect
означает, что указатель на буфер «убежал» в код, который оптимизатор не видит, но рекомендуется делать это между сохранением кода и его вызовом. По крайней мере, это может облегчить портирование на архитектуры, которым действительно нужно что-то на ассемблере (большинство не x86). См. Как заставить код c выполнять шестнадцатеричный машинный код?подробнее.
Если ничего из этого не работает, вы можете поместить массив указателей на функции где-нибудь в JIT-буфере в качестве «статических» данных, к которым код может получить доступ с помощью режимов адресации RIP+rel32, таких как call [rel32]
. Или, если вы передаете своим функциям регистр, указывающий на какой-то адрес, call [reg+disp8]
или call [reg+disp32]
.
Я думаю (или надеюсь), что именно это пытался предложить Дэниел А. Уайт, когда упомянул vtable. Обычные виртуальные таблицы C++ подразумевают несколько уровней косвенности, начиная с указателя, хранящегося в объекте. Вам это не нужно, это просто массив указателей на функции. ПРТ было бы лучшей аналогией; call [rip+rel32]
с записью GOT — это то, как вызовы библиотечных функций компилируются с gcc -fno-plt
.
Или в качестве разового вызова mov rax, imm64
/call rax
всегда работает, но на самом деле он несколько менее эффективен, чем прямой вызов. Вызов абсолютного указателя в машинном коде x86. В зависимости от окружающего кода и частоты его запуска это может быть хуже, чем call [rel32]
с указателем на функцию, который должен загружаться как данные. Если часто используется пара строк указателей функций, чтобы они оставались горячими в кеше, это, вероятно, лучше, чем 10-байтовый movabs
.
Конечно, прямой call rel32
всегда самый эффективный; наименьший размер кода и адрес, доступный для внешнего интерфейса раньше всего на случай, если BTB не предскажет существование и назначение ветки до ее полного декодирования. (Или для косвенных ветвей перед выполнением.)
Вероятно, трудно провести микротестирование разницы, вызванной меньшим количеством непрямых ветвей, которые можно предсказать, поскольку, скорее всего, это будет иметь значение только для всей большой программы. Любой микробенчмарк будет идеально все предсказывать. Повышение производительности внешнего интерфейса при прямых вызовах может иметь или не иметь измеримое значение, в зависимости от того, во что вы их JIT-компиляторе.
я бы не стал зависеть от таких относительных звонков