TL; DR Простой компилятор JIT на языке golang. Прокрутите вниз, чтобы увидеть рабочий код.

JIT-компилятор (Just-in-Time) - это любая программа, которая запускает машинный код, сгенерированный во время выполнения. Разница между кодом JIT и другим кодом (например, fmt.Println) заключается в том, что код JIT создается во время выполнения.

Программы, написанные на Golang, имеют статическую типизацию и заранее компилируются. Может показаться невозможным сгенерировать произвольный код, не говоря уже о выполнении указанного кода. Однако можно передавать инструкции в запущенный процесс go. Это делается с помощью Type Magic - способности преобразовывать любой тип в любой другой тип.

В качестве примечания: если вы хотите узнать больше о Type Magic, оставьте комментарий ниже, и я напишу об этом позже.

Генерация кода для x64

Машинный код - это последовательность байтов, которые имеют особое значение для процессора. Машина, на которой был написан этот блог и протестирован код, использует x64 процессор, поэтому я использовал x64 набор инструкций.

Этот код не будет работать, если вы не запустите его на x64 процессоре.

Создание x64 code to print "Hello World!"

Чтобы напечатать «Hello World», необходимо выполнить системный вызов, чтобы дать процессору команду распечатать данные. Системный вызов для печати данных - write(int fd, const void *buf, size_t count).

Первым параметром этого системного вызова является место для записи, представленное как файловый дескриптор. Вывод вывода на консоль достигается записью в стандартный файловый дескриптор stdout. Номер файлового дескриптора для stdout - 1.

Второй параметр - это расположение данных, которые необходимо записать. Более подробная информация об этом представлена ​​в следующем разделе.

Третий операнд - это количество, то есть количество байтов для записи. В случае «Hello World!» Количество байтов для записи составляет 12. Чтобы выполнить системный вызов, три операнда должны быть сохранены в определенных регистрах. Вот таблица, показывающая регистры для сохранения операндов.

+----------+--------+--------+--------+--------+--------+--------+
| Syscall #| Param 1| Param 2| Param 3| Param 4| Param 5| Param 6|    
+----------+--------+--------+--------+--------+--------+--------+
| rax      |  rdi   |  rsi   |   rdx  |   r10  |   r8   |   r9   |    
+----------+--------+--------+--------+--------+--------+--------+

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

0:  48 c7 c0 01 00 00 00    mov    rax,0x1 
7:  48 c7 c7 01 00 00 00    mov    rdi,0x1
e:  48 c7 c2 0c 00 00 00    mov    rdx,0xc
  • Первая инструкция устанавливает rax в 1 - для обозначения системного вызова write.
  • Вторая инструкция устанавливает rdi в 1 - для обозначения дескриптора файла для stdout
  • Третья инструкция устанавливает rdx в 12 для обозначения количества байтов для печати.
  • Местоположение данных отсутствует, как и фактический вызов write

Чтобы указать местоположение данных, содержащих «Hello World!», Данные сначала должны иметь местоположение, то есть они должны храниться где-то в памяти.

Последовательность байтов, представляющая «Hello World!» это 48 65 6c 6c 6f 20 57 6f 72 6c 64 21. Это должно быть сохранено в месте, где процессор не будет пытаться выполнить его. В противном случае программа выдаст ошибку ошибки сегментации.

В этом случае данные могут быть сохранены в конце исполняемых инструкций, то есть после инструкции return. Сохранять данные после инструкции return безопасно, потому что процессор "перескакивает" на другой адрес при обнаружении return и больше не будет выполняться последовательно.

Поскольку адрес после return неизвестен до тех пор, пока не будет выложена инструкция return, для него можно использовать временный заполнитель, а затем заменить его правильным адресом, как только станет известен адрес данных. Это точная процедура, которой следуют компоновщики. Процесс связывания просто заполняет эти адреса, чтобы указать на правильные данные или функцию.

15: 48 8d 35 00 00 00 00    lea    rsi,[rip+0x0]      # 0x15
1c: 0f 05                   syscall
1e: c3                      ret

В приведенном выше коде инструкция lea для загрузки адреса «Hello World!» указывает на себя (на место, которое находится на расстоянии 0 байтов от rip). Это связано с тем, что данные еще не были сохранены, и адрес данных неизвестен.

Сам системный вызов представлен последовательностью байтов 0F 05.

Теперь данные могут быть сохранены, поскольку инструкция return выложена.

1f: 48 65 6c 6c 6f 20 57 6f 72 6c 64 21   // Hello World!

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

0:  48 c7 c0 01 00 00 00    mov    rax,0x1
7:  48 c7 c7 01 00 00 00    mov    rdi,0x1
e:  48 c7 c2 0c 00 00 00    mov    rdx,0xc
15: 48 8d 35 03 00 00 00    lea    rsi,[rip+0x3]        # 0x1f
1c: 0f 05                   syscall
1e: c3                      ret
1f: 48 65 6c 6c 6f 20 57 6f 72 6c 64 21   // Hello World! 

Приведенный выше код может быть представлен в Golang как срез любого примитивного типа.

Массив / фрагмент uint16 - отличный выбор, потому что он может содержать пары слов с прямым порядком байтов, оставаясь при этом читаемыми. Вот []uint16 структура данных, содержащая указанную выше программу.

printFunction := []uint16{
         0x48c7, 0xc001, 0x0,                // mov %rax,$0x1
         0x48, 0xc7c7, 0x100, 0x0,           // mov %rdi,$0x1
         0x48c7, 0xc20c, 0x0,                // mov 0x13, %rdx
         0x48, 0x8d35, 0x400, 0x0,           // lea 0x4(%rip), %rsi
         0xf05,                              // syscall
         0xc3cc,                             // ret
         0x4865, 0x6c6c, 0x6f20,             // Hello_(whitespace)
         0x576f, 0x726c, 0x6421, 0xa,        // World!
} 

В приведенных выше байтах есть небольшое отклонение по сравнению с байтами, указанными выше. Это потому, что данные «Hello World!» Проще (легче читать и отлаживать). когда он выровнен по началу записи среза.

Поэтому я использовал инструкцию заполнения cc (no-op), чтобы переместить начало раздела данных к следующей записи в слайсе. Я также обновил lea, чтобы указать на местоположение в 4 байтах, чтобы отразить это изменение.

Примечание. Вы можете найти номера системных вызовов для различных системных вызовов по этой ссылке.

Преобразование слайса в функцию

Инструкции в структуре данных []uint16 должны быть преобразованы в функцию, чтобы ее можно было вызвать. Код ниже демонстрирует это преобразование.

type printFunc func()
unsafePrintFunc := (uintptr)(unsafe.Pointer(&printFunction)) 
printer := *(*printFunc)(unsafe.Pointer(&unsafePrintFunc)) 
printer()

Значение функции Голанга - это просто указатель на указатель функции C (обратите внимание на два уровня указателей). Преобразование слайса в функцию начинается с извлечения указателя на структуру данных, которая содержит исполняемый код. Это хранится в unsafePrintFunc. Указатель на unsafePrintFunc может быть преобразован в нужный тип функции.

Этот подход работает только для функций без аргументов или возвращаемых значений. Необходимо создать фрейм стека для вызова функций с аргументами или возвращаемыми значениями. Определение функции всегда должно начинаться с инструкций по динамическому выделению кадра стека для поддержки вариативных функций. Более подробная информация о различных типах функций доступна здесь.

Если вы хотите, чтобы я написал о создании более сложных функций в Golang, оставьте комментарий ниже.

Делаем функцию исполняемой

Вышеупомянутая функция фактически не будет работать. Это связано с тем, что Golang хранит все структуры данных в разделе данных двоичного файла. Для данных в этом разделе установлен флаг No-Execute, предотвращающий их выполнение.

Данные в printFunction срезе должны храниться в исполняемой части памяти. Этого можно достичь, сняв флаг No-Execute на printFunction срезе или скопировав его в область памяти, которая является исполняемой.

В приведенном ниже коде данные были скопированы во вновь выделенную часть памяти (с использованием mmap), которая является исполняемой. Этот подход предпочтительнее, поскольку установка флага невыполнения возможна только на целых страницах - легко можно непреднамеренно сделать исполняемыми другие части раздела данных.

executablePrintFunc, err := syscall.Mmap(
     -1,
      0,
      128,  
      syscall.PROT_READ | syscall.PROT_WRITE | syscall.PROT_EXEC, 
      syscall.MAP_PRIVATE|syscall.MAP_ANONYMOUS
      )
 if err != nil {
  fmt.Printf("mmap err: %v", err)
 }
j := 0
 for i := range printFunction {
  executablePrintFunc[j] = byte(printFunction[i] >> 8)
  executablePrintFunc[j+1] = byte(printFunction[i])
  j = j + 2
 }

Флаг syscall.PROT_EXEC гарантирует, что вновь выделенные адреса памяти являются исполняемыми. Преобразование этой структуры данных в функцию обеспечит бесперебойную работу. Вот полный код, который вы можете опробовать на своем x64 компьютере.

package main
import (
 "fmt"
 "syscall"
 "unsafe"
)
type printFunc func()
func main() {
 printFunction := []uint16{
  0x48c7, 0xc001, 0x0,          // mov %rax,$0x1
  0x48, 0xc7c7, 0x100, 0x0,     // mov %rdi,$0x1
  0x48c7, 0xc20c, 0x0,          // mov 0x13, %rdx
  0x48, 0x8d35, 0x400, 0x0,     // lea 0x4(%rip), %rsi
  0xf05,                        // syscall
  0xc3cc,                       // ret
  0x4865, 0x6c6c, 0x6f20,       // Hello_(whitespace)
  0x576f, 0x726c, 0x6421, 0xa,  // World!
 }
 executablePrintFunc, err := syscall.Mmap(
  -1,
  0,
  128,
  syscall.PROT_READ|syscall.PROT_WRITE|syscall.PROT_EXEC,
  syscall.MAP_PRIVATE|syscall.MAP_ANONYMOUS)
 if err != nil {
  fmt.Printf("mmap err: %v", err)
 }
j := 0
 for i := range printFunction {
  executablePrintFunc[j] = byte(printFunction[i] >> 8)
  executablePrintFunc[j+1] = byte(printFunction[i])
  j = j + 2
 }
type printFunc func()
 unsafePrintFunc := (uintptr)(unsafe.Pointer(&executablePrintFunc))
 printer := *(*printFunc)(unsafe.Pointer(&unsafePrintFunc))
 printer()
}

Заключение

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