Статьи

Как JIT: введение

Когда я написал вступительную статью для libjit , я нацелился на программистов, которые знают, что такое JIT, по крайней мере, до некоторой степени. Я упоминал, что такое JIT, но очень кратко. Цель этой статьи — предоставить лучший вводный обзор JITing с примерами кода, которые не зависят от каких-либо библиотек.

Определение JIT

JIT — это просто сокращение от «Just In Time». Само по себе это мало помогает — термин довольно загадочный и, похоже, не имеет ничего общего с программированием. Во-первых, давайте определим, что на самом деле означает «JIT». Я считаю следующий способ думать об этом полезным:

Всякий раз, когда программа во время работы создает и запускает некоторый новый исполняемый код, который не был частью программы, когда она хранилась на диске, это JIT.

А как насчет исторического использования термина «JIT»? К счастью, Джон Айкок из Университета Калгари написал очень интересную статью под названием «Краткая история своевременности» (Google, PDF-файлы доступны онлайн), в которой рассматриваются методы JIT с исторической точки зрения. Согласно статье Айкока, первое упоминание о генерации и выполнении кода во время выполнения программы стало очевидным еще в статье Маккарти LISP от 1960 года. В более поздней работе, такой как статья регулярных выражений Томпсона 1968 года, это было еще более очевидно (регулярные выражения скомпилированы в машинный код). и выполняется на лету).

Термин JIT был впервые введен в компьютерную литературу Джеймсом Гослингом для Java. Айкок упоминает, что Гослинг заимствовал этот термин из области производства и начал использовать его в начале 1990-х годов.

Это насколько я войду в историю здесь. Прочитайте статью Aycock, если вы заинтересованы в более подробной информации. Посмотрим теперь, что означает приведенное выше определение на практике.

JIT — создайте машинный код, затем запустите его

Я думаю, что технологию JIT легче объяснить, когда она разделена на две отдельные фазы:

  • Этап 1: создание машинного кода во время выполнения программы.
  • Этап 2: выполнение этого машинного кода, также во время выполнения программы.

Фаза 1, где 99% проблем JITing. Но это также и менее мистическая часть процесса, потому что это именно то, что делает компилятор. Хорошо известные компиляторы, такие как gcc и clang, переводят исходный код C / C ++ в машинный код. Машинный код выводится в выходной поток, но его вполне можно просто сохранить в памяти (и на самом деле, и gcc, и clang / llvm имеют строительные блоки для хранения кода в памяти для выполнения JIT). Этап 2 — это то, на чем я хочу сосредоточиться в этой статье.

Запуск динамически сгенерированного кода

Современные операционные системы требовательны к тому, что они позволяют программе делать во время выполнения. Прошлые времена на Диком Западе подошли к концу с появлением защищенного режима , который позволяет ОС ограничивать порции виртуальной памяти различными разрешениями. Таким образом, в «нормальном» коде вы можете динамически создавать новые данные в куче, но вы не можете просто запустить материал из кучи, не попросив ОС явно разрешить это.

На данный момент, я надеюсь, очевидно, что машинный код — это просто данные — поток байтов. Итак, это:

unsigned char[] code = {0x48, 0x89, 0xf8};

На самом деле зависит от глаз смотрящего. Для некоторых это просто некоторые данные, которые могут представлять что угодно. Для других это двоичная кодировка реального действительного машинного кода x86-64:

mov %rdi, %rax

Таким образом, получить машинный код в память легко. Но как сделать его работоспособным, а затем запустить его?

Давайте посмотрим код

Остальная часть этой статьи содержит примеры кода для POSIX-совместимой ОС Unix (особенно Linux). В других ОС (например, в Windows) код будет другим по деталям, но не по духу. Все современные ОС имеют удобные API для реализации одного и того же.

Без лишних слов, вот как мы динамически создаем функцию в памяти и выполняем ее. Функция намеренно очень проста, реализуя этот код C:

long add4(long num) {
  return num + 4;
}

Вот первая попытка (полный код с Makefile доступен в этом репо ):

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>


// Allocates RWX memory of given size and returns a pointer to it. On failure,
// prints out the error and returns NULL.
void* alloc_executable_memory(size_t size) {
  void* ptr = mmap(0, size,
                   PROT_READ | PROT_WRITE | PROT_EXEC,
                   MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
  if (ptr == (void*)-1) {
    perror("mmap");
    return NULL;
  }
  return ptr;
}

void emit_code_into_memory(unsigned char* m) {
  unsigned char code[] = {
    0x48, 0x89, 0xf8,                   // mov %rdi, %rax
    0x48, 0x83, 0xc0, 0x28,             // add $4, %rax
    0xc3                                // ret
  };
  memcpy(m, code, sizeof(code));
}

const size_t SIZE = 1024;
typedef long (*JittedFunc)(long);

// Allocates RWX memory directly.
void run_from_rwx() {
  void* m = alloc_executable_memory(SIZE);
  emit_code_into_memory(m);

  JittedFunc func = m;
  int result = func(2);
  printf("result = %d\n", result);
}

Основные 3 шага, выполняемые этим кодом:

  1. Используйте mmap для выделения читаемой, записываемой и исполняемой части памяти в куче.
  2. Скопируйте машинный код, реализующий add4, в этот чанк.
  3. Выполните код из этого чанка, приведя его к указателю на функцию и вызвав его.

Обратите внимание, что шаг 3 может быть выполнен только потому, что блок памяти, содержащий машинный код, является исполняемым . Без установки правильного разрешения этот вызов приведет к ошибке во время выполнения ОС (скорее всего, к ошибке сегментации). Это произойдет, если, например, мы выделим m с помощью регулярного вызова malloc, который выделяет читаемую и доступную для записи, но не исполняемую память.

Отступление — куча, malloc и mmap

Добросовестные читатели, возможно, заметили половину скольжения, которое я сделал в предыдущем разделе, обращаясь к памяти, возвращаемой из mmap, как кучи памяти. Строго говоря, «куча» — это имя, которое обозначает память, используемую malloc, free et. и др. управлять выделенной во время выполнения памятью, в отличие от «стека», который неявно управляется компилятором.

Тем не менее, это не так просто. :-)Хотя традиционно (т.е. давно) malloc использовал только один источник для своей памяти (системный вызов sbrk), в наши дни большинство реализаций malloc во многих случаях используют mmap. Детали отличаются между операционными системами и реализациями, но часто mmap используется для больших чанков и sbrk для маленьких чанков. Компромиссы связаны с относительной эффективностью двух методов запроса большего количества памяти у ОС.

Так что называть память, предоставленную mmap, «кучей памяти» не является ошибкой, ИМХО, и это то, что я намерен продолжать делать.

Забота о безопасности

Приведенный выше код имеет проблему — это дыра в безопасности. Причина в том, что он выделяет часть памяти RWX (читабельная, записываемая, исполняемая) — рай для атак и эксплойтов. Так что давайте будем немного более ответственными об этом. Вот немного измененный код:

// Allocates RW memory of given size and returns a pointer to it. On failure,
// prints out the error and returns NULL. Unlike malloc, the memory is allocated
// on a page boundary so it's suitable for calling mprotect.
void* alloc_writable_memory(size_t size) {
  void* ptr = mmap(0, size,
                   PROT_READ | PROT_WRITE,
                   MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
  if (ptr == (void*)-1) {
    perror("mmap");
    return NULL;
  }
  return ptr;
}

// Sets a RX permission on the given memory, which must be page-aligned. Returns
// 0 on success. On failure, prints out the error and returns -1.
int make_memory_executable(void* m, size_t size) {
  if (mprotect(m, size, PROT_READ | PROT_EXEC) == -1) {
    perror("mprotect");
    return -1;
  }
  return 0;
}

// Allocates RW memory, emits the code into it and sets it to RX before
// executing.
void emit_to_rw_run_from_rx() {
  void* m = alloc_writable_memory(SIZE);
  emit_code_into_memory(m);
  make_memory_executable(m, SIZE);

  JittedFunc func = m;
  int result = func(2);
  printf("result = %d\n", result);
}

Это эквивалентно более раннему фрагменту во всех отношениях, кроме одного: память сначала выделяется с разрешениями RW (как это сделал бы обычный malloc). Это все, что нам действительно нужно, чтобы записать в нее наш машинный код. Когда код есть, мы используем mprotect, чтобы изменить разрешение чанка с RW на RX, делая его исполняемым, но больше не доступным для записи . Таким образом, эффект тот же, но ни в одной точке выполнения нашей программы блок не может быть записан и выполнен, что хорошо с точки зрения безопасности.

Что насчет malloc?

Можем ли мы использовать malloc вместо mmap для выделения фрагмента в предыдущем фрагменте? В конце концов, RW-память — это именно то, что обеспечивает malloc. Да, мы могли бы. Однако на самом деле это больше проблем, чем стоит. Причина в том, что защитные биты могут быть установлены только на границах страницы виртуальной памяти. Поэтому, если бы мы использовали malloc, нам пришлось бы вручную убедиться, что выделение выровнено по границе страницы. В противном случае mprotect может иметь нежелательные последствия от сбоя до включения / выключения большего, чем фактически требуется. mmap заботится об этом за нас, размещая только на границах страниц (потому что mmap по своему дизайну отображает целые страницы).

Связывать свободные концы

Эта статья началась с общего обзора того, что мы имеем в виду, когда говорим JIT, и закончилась практическими фрагментами кода, которые показывают, как динамически передавать машинный код в память и выполнять его.

Методика, показанная здесь, в значительной степени показывает, как реальные движки JIT (например, LLVM и libjit) испускают и запускают исполняемый машинный код из памяти. Остается лишь «простой» вопрос синтеза этого машинного кода из чего-то еще.

LLVM имеет полный доступный компилятор, так что он может фактически преобразовывать код C и C ++ (через LLVM IR) в машинный код во время выполнения, а затем выполнять его. libjit поднимает мяч на гораздо более низком уровне — он может служить бэкендом для компилятора. Фактически, моя вводная статья о libjit уже демонстрирует, как создавать и запускать нетривиальный код с помощью libjit. Но JITing является более общей концепцией. Выдача кода во время выполнения может быть выполнена для структур данных , регулярных выражений и даже для доступа к C из языковых виртуальных машин . Поиск в архивах моего блога помог мне найти упоминание о JITing, который я делал 8 лет назад, Это был код Perl, генерирующий больше кода Perl во время выполнения (из XML-описания формата сериализации), но идея та же.

Вот почему я чувствовал, что разделение концепции JITing на две фазы важно. Для фазы 2 (которая была объяснена в этой статье) реализация является относительно очевидной и использует четко определенные API-интерфейсы ОС. На первом этапе возможности безграничны, и то, что вы делаете, в конечном итоге зависит от приложения, которое вы разрабатываете.