Перевод: пишем простой JIT на Rust
от Oak
От переводчика
Обновление от 2015-12-05
Создание простого JIT на RustНа днях я набросал простенький Just-In-Time компилятор, и мне показалось, что было бы неплохо рассказать о процессе пошагово. Эти шаги включают в себя создание страницы исполняемой памяти, запись некоего исполняемого кода в нее и затем его вызов как обычной функции Rust.Создание исполняемой памяти
Для нашего JIT-a сперва понадобиться выделить память, которая бы содержала наш будущий код. Самое главное — создать блок исполняемой памяти, чтобы мы могли перейти на него, запустить, а потом вернуться обратно.
Чтобы провернуть такое, нужны несколько функций из стандартной библиотеки С, которые можно подключить через внешний модуль libc:
- // добавить в Cargo.toml
- [dependencies]
- libc = "0.2.2"
- // main.rs
- extern crate libc;
Кроме подключения libc, нам еще понадобится выделенная память. Причем не любая, а выровненная. Некоторые ОС, например OS X, требуют, чтобы исполняемая память начиналась на определенном выравнивании. В нашем примере адрес начала памяти будет начинаться с любого произведения 0x1000.
- const PAGE_SIZE: usize = 4096;
- // ...
- unsafe {
- let mut page : *mut libc::c_void = mem::uninitialized();
- libc::posix_memalign(&mut page, PAGE_SIZE, size);
- }
Выделив память, мы можем перейти к ней из кода. Ну, почти. Чтобы выполнять код из свежеполученного блока, нам нужно пометить эту зону как исполняемую.
- unsafe {
- libc::mprotect(page, size, libc::PROT_EXEC | libc::PROT_READ | libc::PROT_WRITE);
- }
Вот теперь все готово. У нас есть нечто, куда бы мы могли записывать значения, а также что мы могли бы исполнять. Понятное дело, что запуск такого кода лишает нас абсолютно всех гарантий Rust, поэтому очень легко запутаться и найти на свою голову бесчисленное множество проблем. Я также заполняю всю эту память инструкциями RET, которые позволят вернуть управление из нашей функции, даже если мы случайно исполним не тот код в блоке памяти:
- extern {
- fn memset(s: *mut libc::c_void, c: libc::uint32_t, n: libc::size_t) -> *mut libc::c_void;
- }
- // ...
- unsafe {
- memset(page, 0xc3, size); // заполнить 'RET' вызовами (0xc3)
- }
Мы выделили память, выравняли ее, пометили как исполняемую и заполнили RET инструкциями. Думаю, теперь можно записать в нее какой-нибудь код.
На заметку
Готовимся писать нашу первую программу
Чтобы написать нашу первую JIT-программу, сперва хорошо бы упростить обращение с памятью. Сейчас, это просто сырой С void*, что нам не очень подходит, так как в Rust неудобно работать с такими указателями. Чтобы сделать доступ более естесственным, мы создадим новую структуру, которая будет содержать указатель и позволит использовать удобное индексирование (или итерирование, прим. переводчика). Тогда можно будет обращаться к памяти вот так: m[0] = 0x10
- use std::mem;
- struct JitMemory {
- contents : *mut u8
- }
- fn alloc() -> JitMemory {
- let contents: mut* u8;
- unsafe {
- //тут вышеописанное выделение памяти
- //трансформация
- contents = page фы *mut _;
- }
- JitMemory { contents: contents }
- }
Теперь у нас есть удобная структура JitMemory, которая будет содержать выделенную память. Для того, чтобы сконвертировать сырой С указатель в u8 используем вызов mem::transmute.
Создадим индексирующие функции для нашей структуры:
- use std::ops::{Index, IndexMut};
- impl Index<usize> for JitMemory {
- type Output = u8;
- fn index(&self, _index: usize) -> &u8 {
- unsafe {&*self.contents.offset(_index as isize) }
- }
- }
- impl IndexMut<usize> for JitMemory {
- fn index_mut(&mut self, _index: usize) -> &mut u8 {
- unsafe {&mut *self.contents.offset(_index as isize) }
- }
- }
С ними записывать инструкции в память будет намного легче. Перед тем, как начать, собственно говоря, писать код, давайте соберем все раннее написанное в конструктор для нашей структуры:
- impl JitMemory {
- fn new(num_pages: usize) -> JitMemory {
- let contents : *mut u8;
- unsafe {
- let size = num_pages * PAGE_SIZE;
- let mut _contents : *mut libc::c_void = mem::uninitialized(); // избежим предупреждения о неинициализированном значении
- libc::posix_memalign(&mut _contents, PAGE_SIZE, size);
- libc::mprotect(_contents, size, libc::PROT_EXEC | libc::PROT_READ | libc::PROT_WRITE);
- memset(_contents, 0xc3, size); // заполним 'RET'
- contents = mem::transmute(_contents);
- }
- JitMemory { contents: contents }
- }
- }
Пишем первую JIT-программу
Все функции написаны, можно приступить к написанию самого JIT-кода. Простейший "привет, мир", который я использую, когда проверяю JIT, это функция, не принимающая параметров и возвращающая простое значение.
Это легко делается двумя ассемблерными инструкциями:
- MOV RAX, 0x3 ; поместим значение (0x3) в RAX,
- ; регистр, который используется на x64, чтобы возвращать значения
- RET ; вернемся из функции
Великолепно, осталось только записать это в память. А, у нас же нет ассемблера
Не стоит волноваться, есть множество ассемблеров, так что свой писать не придется. Есть даже онлайн ассемблеры. Попробуем сассемблировать первую строчку MOV RAX, 0x3.
Первая линия результата — сырой шестнадцатеричный код, и именно он-то нам и нужен. Байты, которые мы запишем в память: 0x48C7C003000000.
Помня, что мы уже заполнили память RET инструкциями, понимаем, что получили готовую функцию. Используем уже написанные индексирующие функции:
- let mut jit : JitMemory = JitMemory::new(1); // выделим страницу памяти
- jit[0] = 0x48; // mov RAX, 0x3
- jit[1] = 0xc7;
- jit[2] = 0xc0;
- jit[3] = 0x03;
- jit[4] = 0x00;
- jit[5] = 0x00;
- jit[6] = 0x00;
Превращаем память в функцию
У нас уже есть исполняемый блок памяти с кодом нашей функции. Последний шаг — трансформировать его в Rust-функцию, которую мы бы могли просто вызвать. Сделаем это используя еще один mem::transmute:
- fn run_jit() -> (fn() -> i64) {
- let mut jit : JitMemory = JitMemory::new(1);
- jit[0] = 0x48; // mov RAX, 0x3
- jit[1] = 0xc7;
- jit[2] = 0xc0;
- jit[3] = 0x03;
- jit[4] = 0x00;
- jit[5] = 0x00;
- jit[6] = 0x00;
- unsafe { mem::transmute(jit.contents) }
- }
И наконец, после многих unsafe вызовов и трансформаций, мы получаем готовую функцию. Осталось только вызвать:
- fn main() {
- let fun = run_jit();
- println!("{}", fun());
- }
Лишь начало пути
От простенького JIT, что мы в итоге получили, двигаться можно в любую сторону, и пределов нет. Превращение исходного кода в исполняемый — сердцевина любого компилятора, а с описанными выше несколькими шагами компилятор может выдаавать код, который можно выпонять напрямую.
Оригинал от JONATHAN TURNER.