Макет загрузки процедуры в динамических компиляторах
Введение
Создание собственного динамического компилятора можно разделить на две части:
- Создание непосредственно компилятора
- Создание среды исполнения, способной запустить полученный код в контексте исполнения текущей программы
По первой части существует довольно много материалов, а вот найти что-либо по второй части уже несколько сложнее. Поэтому я решил записать простейшие примеры подгрузки и запуска исполняемого кода. Для меня это несколько неродная область, поэтому заметка будет представлять собой краткий конспект этой статьи (советую читать оригинал, т.к. её автор специализируется на jit-системах) и два ответа на SO: 1, 2.
В результате мы получим две программы-макета, демонстрирующие загрузку исполняемого кода прямо во время исполнения.
Постановка задачи
Итак, предположим мы имеем процедуру, которую создали во время исполнения программы, и нам необходимо не прерывая программы запустить эту процедуру, получить и использовать её результат. Сама процедура будет очень простой, например:
int incr(int i)
{
return i + 1;
}
Если бы мы писали обычную программу, то использование процедуры выглядело бы так:
#include <assert.h>
int main()
{
assert( (1 - incr(0)) == 0 );
return 0;
}
Однако в нашем случае никакой процедуры incr
, которая заранее
скомпонована с программой не существует.
Исполнение готового кода
Получение кода процедуры incr
- это задача компилятора, поэтому
в данном случае полагаем что нам этот код уже известен и находится в специально
созданном массиве Code
. Наша задача - создать область памяти с
правами на исполнение, перенести туда исполняемый код и запустить на исполнение:
#include <string.h>
#include <sys/mman.h>
#include <assert.h>
unsigned char Code[] = {
0x55, // push %rbp
0x48, 0x89, 0xe5, // mov %rsp,%rbp
0x89, 0x7d, 0xfc, // mov %edi,-0x4(%rbp)
0x8b, 0x45, 0xfc, // mov -0x4(%rbp),%eax
0x83, 0xc0, 0x01, // add $0x1,%eax
0x5d, // pop %rbp
0xc3 // ret
};
int main(void)
{
unsigned char * incr;
size_t size = sizeof(Code);
// Выделение памяти с правами на исполнение
incr = mmap(0, size,
PROT_READ | PROT_WRITE | PROT_EXEC,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
// Копирование исполняемого кода
memcpy(incr, Code, size);
// Устранение прав на запись
mprotect(incr, size, PROT_READ | PROT_EXEC);
// Вызов записанного кода
int res = ((int(*)(int))incr)(0);
assert( 1 - res == 0);
return 0;
}
В этом коде массив Code
содержит исполняемый код процедуры. Далее
при помощи mmap
мы выделяем память для исполняемой области памяти.
Следует обратить внимание что далее используется вызов mprotect
.
Он необходим для устранения проблемы безопасности, связанной с перезаписью
исполняемой области злоумышленником. Оставшийся код вопросов вызывать не должен.
Загрузка кода из файла
Теперь представим ситуацию что наша программа создаёт код на Си и хочет сразу же компилировать и подгружать его в собственный контекст исполнения. Обычно динамические компиляторы по такой схеме не работают и используют собственные кодогенераторы. Но бывают случаи когда необходимо использовать именно штатные средства получения исполняемого кода в системе.
Итак, имеем файл с целевой процедурой:
$ cat incr.c
int incr(int i)
{
return i + 1;
}
Компиляция этого файла будет не совсем стандартной. Она потребует особого сценария компоновки:
$ cat script.ld
ENTRY(_dummy_start)
SECTIONS
{
_dummy_start = 0;
_GLOBAL_OFFSET_TABLE_ = 0;
.all : {
_all = .;
LONG(incr - _all);
*( .text .text.* .data .data.* .rodata .rodata.* )
}
}
Рассмотрим что происходит в данном сценарии:
ENTRY(_dummy_start)
позволяет избежать предупреждения компоновщика об отсутствии точки входа._dummy_start = 0;
определяет символ_dummy_start
который мы подаём в качестве точки входа._GLOBAL_OFFSET_TABLE_ = 0;
на всякий случай..all
секция, в которую будут помещены интересующие нас процедуры. В данном случае это.text
,.data
и.rodata
, но при необходимости можно добавить и другие.QUAD(incr - _all);
позволит узнать смещение целевой процедуры
Теперь скомпилируем процедуру:
$ gcc -c -fPIC incr.c -o incr.o
$ ld -T script.ld incr.o -o incr.elf
$ objcopy -j .all -O binary incr.elf incr.bin
Исполняемый код лежит в файле incr.bin
. Теперь рассмотрим код
макета, который загружает и запускает его на исполнение:
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <sys/mman.h>
#include <assert.h>
int main(void)
{
// Выделение памяти для исполняемого кода
size_t size = 1024;
unsigned char * blob = mmap(0, size,
PROT_READ | PROT_WRITE | PROT_EXEC,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
// Чтение исполняемого файла с процедурой
FILE *f = fopen("incr.bin", "rb");
fread(blob, 1, size, f);
fclose(f);
// Установка указателя на начало процедуры
unsigned offs = *(unsigned*)blob;
int(*incr)(int) = (int(*)(int))(blob + offs);
// Устранение права на запись в область памяти
mprotect(blob, size, PROT_READ | PROT_EXEC);
// Вызов записанного кода
int res = incr(0);
assert( 1 - res == 0);
return 0;
}
Сам по себе код тоже довольно простой и от первого примера отличается только источником получения кода процедуры. Основная сложность данного примера - это извлечение исполняемого кода из скомпилированного файла.
Заключение
На этом небольшой конспект с примерами можно закончить. Они хорошо подходят для демонстрации работы динамических компиляторов и быстрого старта в их разработке.