Макет загрузки процедуры в динамических компиляторах

Введение

Создание собственного динамического компилятора можно разделить на две части:

По первой части существует довольно много материалов, а вот найти что-либо по второй части уже несколько сложнее. Поэтому я решил записать простейшие примеры подгрузки и запуска исполняемого кода. Для меня это несколько неродная область, поэтому заметка будет представлять собой краткий конспект этой статьи (советую читать оригинал, т.к. её автор специализируется на 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.* ) 
    }
}

Рассмотрим что происходит в данном сценарии:

Теперь скомпилируем процедуру:

$ 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;
}

Сам по себе код тоже довольно простой и от первого примера отличается только источником получения кода процедуры. Основная сложность данного примера - это извлечение исполняемого кода из скомпилированного файла.

Заключение

На этом небольшой конспект с примерами можно закончить. Они хорошо подходят для демонстрации работы динамических компиляторов и быстрого старта в их разработке.