Перевод: пишем простой JIT на Rust

от
Прочие языки    rust, перевод

От переводчика
Обновление от 2015-12-05
Создание простого JIT на RustНа днях я набросал простенький Just-In-Time компилятор, и мне показалось, что было бы неплохо рассказать о процессе пошагово. Эти шаги включают в себя создание страницы исполняемой памяти, запись некоего исполняемого кода в нее и затем его вызов как обычной функции Rust.

Создание исполняемой памяти
Для нашего JIT-a сперва понадобиться выделить память, которая бы содержала наш будущий код. Самое главное — создать блок исполняемой памяти, чтобы мы могли перейти на него, запустить, а потом вернуться обратно.

Чтобы провернуть такое, нужны несколько функций из стандартной библиотеки С, которые можно подключить через внешний модуль libc:

  1. // добавить в Cargo.toml
  2. [dependencies]
  3. libc = "0.2.2"
  4.  
  5. // main.rs
  6. extern crate libc;

Кроме подключения libc, нам еще понадобится выделенная память. Причем не любая, а выровненная. Некоторые ОС, например OS X, требуют, чтобы исполняемая память начиналась на определенном выравнивании. В нашем примере адрес начала памяти будет начинаться с любого произведения 0x1000.

  1. const PAGE_SIZE: usize = 4096;
  2. // ...
  3. unsafe {
  4.   let mut page : *mut libc::c_void = mem::uninitialized();
  5.   libc::posix_memalign(&mut page, PAGE_SIZE, size);
  6. }

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

  1. unsafe {
  2.   libc::mprotect(page, size, libc::PROT_EXEC | libc::PROT_READ | libc::PROT_WRITE);
  3. }

Вот теперь все готово. У нас есть нечто, куда бы мы могли записывать значения, а также что мы могли бы исполнять. Понятное дело, что запуск такого кода лишает нас абсолютно всех гарантий Rust, поэтому очень легко запутаться и найти на свою голову бесчисленное множество проблем. Я также заполняю всю эту память инструкциями RET, которые позволят вернуть управление из нашей функции, даже если мы случайно исполним не тот код в блоке памяти:

  1. extern {
  2.     fn memset(s: *mut libc::c_void, c: libc::uint32_t, n: libc::size_t) -> *mut libc::c_void;
  3. }
  4. // ...
  5. unsafe {
  6.   memset(page, 0xc3, size);  // заполнить 'RET' вызовами (0xc3)
  7. }

Мы выделили память, выравняли ее, пометили как исполняемую и заполнили RET инструкциями. Думаю, теперь можно записать в нее какой-нибудь код.

На заметку

Готовимся писать нашу первую программу
Чтобы написать нашу первую JIT-программу, сперва хорошо бы упростить обращение с памятью. Сейчас, это просто сырой С void*, что нам не очень подходит, так как в Rust неудобно работать с такими указателями. Чтобы сделать доступ более естесственным, мы создадим новую структуру, которая будет содержать указатель и позволит использовать удобное индексирование (или итерирование, прим. переводчика). Тогда можно будет обращаться к памяти вот так: m[0] = 0x10

    
  1. use std::mem;
  2.  
  3.     struct JitMemory {
  4.         contents : *mut u8
  5.     }
  6.  
  7.     fn alloc() -> JitMemory {
  8.       let contents: mut* u8;
  9.       unsafe {
  10.         //тут вышеописанное выделение памяти
  11.  
  12.         //трансформация
  13.         contents = page фы *mut _;
  14.       }
  15.  
  16.       JitMemory { contents: contents }
  17.     }

Теперь у нас есть удобная структура JitMemory, которая будет содержать выделенную память. Для того, чтобы сконвертировать сырой С указатель в u8 используем вызов mem::transmute.

Создадим индексирующие функции для нашей структуры:

  1. use std::ops::{Index, IndexMut};
  2.  
  3. impl Index<usize> for JitMemory {
  4.     type Output = u8;
  5.  
  6.     fn index(&self, _index: usize) -> &u8 {
  7.         unsafe {&*self.contents.offset(_index as isize) }
  8.     }
  9. }
  10.  
  11. impl IndexMut<usize> for JitMemory {
  12.     fn index_mut(&mut self, _index: usize) -> &mut u8 {
  13.         unsafe {&mut *self.contents.offset(_index as isize) }
  14.     }
  15. }

С ними записывать инструкции в память будет намного легче. Перед тем, как начать, собственно говоря, писать код, давайте соберем все раннее написанное в конструктор для нашей структуры:

  1. impl JitMemory {
  2.   fn new(num_pages: usize) -> JitMemory {
  3.     let contents : *mut u8;
  4.     unsafe {
  5.         let size = num_pages * PAGE_SIZE;
  6.         let mut _contents : *mut libc::c_void = mem::uninitialized(); // избежим предупреждения о неинициализированном значении
  7.         libc::posix_memalign(&mut _contents, PAGE_SIZE, size);
  8.         libc::mprotect(_contents, size, libc::PROT_EXEC | libc::PROT_READ | libc::PROT_WRITE);
  9.  
  10.         memset(_contents, 0xc3, size);  // заполним 'RET'
  11.  
  12.         contents = mem::transmute(_contents);
  13.     }
  14.  
  15.     JitMemory { contents: contents }
  16.   }
  17. }

Пишем первую JIT-программу
Все функции написаны, можно приступить к написанию самого JIT-кода. Простейший "привет, мир", который я использую, когда проверяю JIT, это функция, не принимающая параметров и возвращающая простое значение.

Это легко делается двумя ассемблерными инструкциями:

  1. MOV RAX, 0x3    ; поместим значение (0x3) в RAX,
  2.                 ; регистр, который используется на x64, чтобы возвращать значения
  3. RET             ; вернемся из функции

Великолепно, осталось только записать это в память. А, у нас же нет ассемблера :)

Не стоит волноваться, есть множество ассемблеров, так что свой писать не придется. Есть даже онлайн ассемблеры. Попробуем сассемблировать первую строчку MOV RAX, 0x3.

Первая линия результата — сырой шестнадцатеричный код, и именно он-то нам и нужен. Байты, которые мы запишем в память: 0x48C7C003000000.

Помня, что мы уже заполнили память RET инструкциями, понимаем, что получили готовую функцию. Используем уже написанные индексирующие функции:

  1. let mut jit : JitMemory = JitMemory::new(1);  // выделим страницу памяти
  2.  
  3. jit[0] = 0x48;  // mov RAX, 0x3
  4. jit[1] = 0xc7;
  5. jit[2] = 0xc0;
  6. jit[3] = 0x03;
  7. jit[4] = 0x00;
  8. jit[5] = 0x00;
  9. jit[6] = 0x00;

Превращаем память в функцию
У нас уже есть исполняемый блок памяти с кодом нашей функции. Последний шаг — трансформировать его в Rust-функцию, которую мы бы могли просто вызвать. Сделаем это используя еще один mem::transmute:

  1. fn run_jit() -> (fn() -> i64) {
  2.   let mut jit : JitMemory = JitMemory::new(1);
  3.  
  4.   jit[0] = 0x48;  // mov RAX, 0x3
  5.   jit[1] = 0xc7;
  6.   jit[2] = 0xc0;
  7.   jit[3] = 0x03;
  8.   jit[4] = 0x00;
  9.   jit[5] = 0x00;
  10.   jit[6] = 0x00;
  11.  
  12.   unsafe { mem::transmute(jit.contents) }
  13. }

И наконец, после многих unsafe вызовов и трансформаций, мы получаем готовую функцию. Осталось только вызвать:

  1. fn main() {
  2.   let fun = run_jit();
  3.   println!("{}", fun());
  4. }

Лишь начало пути
От простенького JIT, что мы в итоге получили, двигаться можно в любую сторону, и пределов нет. Превращение исходного кода в исполняемый — сердцевина любого компилятора, а с описанными выше несколькими шагами компилятор может выдаавать код, который можно выпонять напрямую.

Оригинал от JONATHAN TURNER.
  • +8
  • views 5122