Pull to refresh
134.54
JUG Ru Group
Конференции для Senior-разработчиков

Java с ассемблерными вставками

Reading time11 min
Views25K



Как известно, на любом языке можно писать, как на Java, а первая любовь джависта — это написание Garbage Collectors и JIT Compilers. С этим связано множество восхитительных вопросов, например: каким образом можно из управляемого кода напрямую работать с машинным кодом и ассемблером?


Кроме того, в этой статье будет небольшой пример на C#. В какой-то момент стало понятно, что нельзя всегда изучать одну Java. Рантаймы динамических языков используют общую теорию и на практике работают в рамках похожих проблем. Самый простой способ продвинуть свою работу — посмотреть, как там у соседей, и скопировать себе что-нибудь хорошее.


Теперь про ассемблер и машинный код. Зачем это нужно — вопрос открытый. Например, вы наслушались о Meltdown и хотите написать для него красивое API :-) Ну, и не надо забывать, что Oracle — не боги, поддержку того же AVX-512 добавили только в Девятке, прямое управление аппаратной транзакционной памятью не ложится на язык, часть стандартных методов можно реализовать лучше, чем это сделали в SDK и т.п. — у нас всегда есть с чем покопаться!


В проблеме есть два уровня:


  • нельзя напрямую заинлайнить машинный код или x86-ассемблер в Java-коде (вставить конструкцию __asm())
  • нельзя напрямую выполнить их (впрочем, при отсутствии синтаксиса, до этой проблемы добраться сложновато)

Но можно проэксплуатировать какие-то хаки, о чём и будет далее.


Наивный вариант: а возьмём и запустим!


Как известно, можно запускать нативный код с помощью JNI.
Значит, мы в C++ можем спокойно динамически погенерить машкод и потом дёрнуть его.


Для этого надо создать сегмент памяти, доступный на запись и исполнение одновременно.


Вот пример для Windows с VirtualAllocEx (в posix есть mprotect(2), но мне лень).
Попробуйте понять, что он делает. Если не будет PAGE_EXECUTE_READWRITE, то этот код тут же прибьёт Data Execution Prevention в винде.


#include <stdio.h>
#include <windows.h>

typedef unsigned char byte;

int arg1;
int arg2;
int res1;

typedef void (*pfunc)(void);

union funcptr {
  pfunc x;
  byte* y;
};

int main( void ) {

  byte* buf = (byte*)VirtualAllocEx( GetCurrentProcess(), 0, 1<<16, MEM_COMMIT, PAGE_EXECUTE_READWRITE );

  if( buf==0 ) return 0;

  byte* p = buf;

  *p++ = 0x50; // push eax
  *p++ = 0x52; // push edx

  *p++ = 0xA1; // mov eax, [arg2]
  (int*&)p[0] = &arg2; p+=sizeof(int*);

  *p++ = 0x92; // xchg edx,eax

  *p++ = 0xA1; // mov eax, [arg1]
  (int*&)p[0] = &arg1; p+=sizeof(int*);

  *p++ = 0xF7; *p++ = 0xEA; // imul edx

  *p++ = 0xA3; // mov [res1],eax
  (int*&)p[0] = &res1; p+=sizeof(int*);

  *p++ = 0x5A; // pop edx
  *p++ = 0x58; // pop eax
  *p++ = 0xC3; // ret

  funcptr func;
  func.y = buf;

  arg1 = 123; arg2 = 321; res1 = 0;

  func.x(); // call generated code

  printf( "arg1=%i arg2=%i arg1*arg2=%i func(arg1,arg2)=%i\n", arg1,arg2,arg1*arg2,res1 );

}

Конечно, делать это вручную — не самая здравая идея, и нужно притащить какой-то AsmJit. Потом из Java-кода пробрасываем конкретные данные, чем нам заполнять буфер, и ух — покатились!


Проблема здесь в том, что от инлайна мы ждём несколько более жирной функциональности. Хочется иметь доступ ко всему контексту вызова, плюс дергать разные системные штуки из SDK. Сделать это самостоятельное, наверное, можно — но долго и болезненно. К счастью, всё уже украдено до нас.


Java Native Interface


Использовать JNI всё равно можно, но по-другому.


Допустим, у нас есть такой класс:


public class MyJNIClass {
    public native void printVersion();
}

Идея в том, чтобы назвать символ в соответствии с конвенцией имён в JNI, и дальше оно само всё сделает. В нашем случае это будет выглядеть примерно как Java_MyJNIClass_printVersion.


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


Сам асм нужно писать с пониманием конвенций вызова в используемой архитектуре (аргументы могут быть в регистрах, на стеке, в других структурах памяти и т.п.). Первый аргумент, который прилетит в функцию, будет указателем на JNIEnv, а оно, в свою очередь, — указатель на таблицу функций JNI.


Например, NASM под x86_64 будет выглядеть так:


global Java_MyJNIClass_printVersion

section .text

Java_MyJNIClass_printVersion:
    mov rax, [rdi]
    call [rax + 8*4]  ; pointer size in x86_64 * index of GetVersion
    ...  

Откуда взялся магический индекс GetVersion? Очень просто: они перечислены в документации.


Вот как выглядит описание GetVersion:


GetVersion
jint GetVersion(JNIEnv *env);

Returns the version of the native method interface.

LINKAGE:
Index 4 in the JNIEnv interface function table.

PARAMETERS:
env: the JNI interface pointer.

RETURNS:
Returns the major version number in the higher 16 bits and the minor version number in the lower 16 bits.

In JDK/JRE 1.1, GetVersion() returns 0x00010001.
In JDK/JRE 1.2, GetVersion() returns 0x00010002.
In JDK/JRE 1.4, GetVersion() returns 0x00010004.
In JDK/JRE 1.6, GetVersion() returns 0x00010006.

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


Второй аргумент — ссылка на тот класс или объект, который позвал функцию. Все следующие аргументы — параметры метода, объявленного как native в Java-коде.


Дальше нужно из ассемблера собрать объектник: nasm -f elf64 -o GetVersion.o GetVersion.asm
Из объектника — библиотеку: gcc -shared -z noexecstack -o libGetVersion.so GetVersion.o
И под конец собрать сам файл: javac MyJNIClass.java


Можно выполнять куда более сложные операции. Вот пример сложения элементов массива.


И кажется, что всё бы хорошо, но хочется нескольких вещей.


Во-первых, если мы кодим в Java, то хотелось бы иметь для создания ассемблера красивый синтаксис со статическими проверками (что бы это ни значило в данном случае) и т.п. Я блондинко и хочу регистры выбирать в автодополнении IDE, а не бояться опечататься в одной букве. Ну хотя бы, пусть это будет Java API.


Во-вторых, собирать библиотеку руками рядом с кодом — это не очень хорошая идея. Ну и собирать файлики по одиночке — полное дно. Нужна инфраструктура, которая позволит не заботиться о таких вещах. Например, пусть asm-код будет инлайном, или плагин в Maven, или распространение как часть модифицированного JDK.


В-третьих, непонятно, стоит ли выбирать абстракцией именно ассемблер, ибо разных представлений — множество.


Библиотеки


Сразу же написал чувакам из Oracle, которые занимаются машинным кодом на Java, и получил ответ: нормальных красивых библиотек они не знают.


Но всё равно надо сходить в Google, мы же не лентяи. Используем кейворд "java call x86 assembly library" и медитируем на результат.


Результаты показывают, что с точки зрения библиотек — действительно всё плохо. Гуглится несколько недопиленных вещей, включая The Machine Level Java.


И там даже есть красивое API (насколько оно может быть красивым при использовании конструкций из Java):


public class SimpleNativeDemo extends X86InlineAssembly // X86InlineAssembly is a successor of InlineAssembly
{
    static // static initializer
    {
        InlineAssemblyHelper.initializeNativeCode_deleteExistingLibraryAndNoExceptions(new SimpleNativeDemo(System.out));
    }

    // constructor, which defines x86 architecture as a native method's target
    public SimpleNativeDemo(OutputStream debugStream)
    { super(Architectures.X86.architecture, false, debugStream); }

    // native method declaration
    public static native long multiply(int x, int y);

    // native method implementation
    @Override
    public void writeNativeCode()
    {
        parameterIn(r.EAX,IP.In0.ordinal());
        parameterIn(r.EBX,IP.In1.ordinal());
        mul.x32(r.EBX);
    }
}

Оно даёт нам и синтаксис (без модификации парсера Java), и способ исполнения — это хорошо.


Чуть ли не главная проблема здесь в том, что внутри находится весьма сложный код, который нужно поддерживать. Он является частью jEmbryOS — проектом по созданию операционной системы целиком на Java. И по внешним признакам, проект не очень жив: он всё ещё на Sourceforge (и его нет на GitHub и других популярных современных хостингах), пустой форум с последним сообщением за 2014 год. Последним гвоздём в гроб было то, что в релизе 2015 года на Sourceforge нет файла лицензии — использовать код без лицензии нельзя (применяются дефолтные правила копирайта, делающие такой код read-only).


Ладно, тут не вышло. Но в будущем можно написать самостоятельно, благо что идея ясна.


Интринсики


По этому поводу есть целый доклад вот здесь:



Если на конференции вообще стоит ходить, то вот как раз на такие доклады. Фолькер жжот.
Кстати, он будет на следующем JBreak в Новосибе, с очередным отжигом про сlass data sharing.


Вкратце, в OpenJDK у нас есть магический файл src/share/vm/classfile/vmSymbols.hpp. Просто откройте его в браузере по ссылке и всё поймёте. Мы можем поймать конкретные методы и подменить их ассемблером. Ну, пересобрать OpenJDK с этими изменениями, конечно.


Имхо, если совсем упороться, можно сделать так: написать препроцессор для .java-классов, который будет ловить вызовы конструкций типа __asm("ret"), потом генерить из них патч для интринсиков и автоматически пересобирать OpenJDK.


Почему мне это решение кажется не очень приятным? Во-первых, изменение интринсиков приводит к пересборке значительной части OpenJDK. Значит, придётся очень часто пить чай, курить, напиваться с горя и убивать время другими способами, пока горячий как печка ноутбук переколбашивает C++.


Во-вторых, интринсики работают не совсем так же, как нативные методы. Если в JNI мы работаем в нормальном режиме и JVM всегда может откатиться до сейфпоинта, то в случае с интринсиками это не работает. Нужно будет попотеть, чтобы чего-нибудь фатально не сломать.


Ну и в-третьих, есть подозрение, что нормальному человеку связываться с этой частью OpenJDK будет не очень приятно. Большинство кода там состоит из серьёзного колдунства, в котором можно увязнуть.


А что там у .NET?


Некоторым шоком оказалось то, что у дотнетчиков есть совершенно другой подход. Они могут вообще не оборачивать ассемблер, но запускать нативный код прямо из C#!


Идею подал пример, написанный ещё в далёком 2005 году. К сожалению, код по ссылке не работает, потому что его тут же прибьёт DEP. Пришлось его немного доработать, притащив мусор из kernel32.dll: через pinvoke достать VirtualAllocEx и нужные ему флаги — AllocationType и MemoryProtection. Это совершенно тот же самый трюк, который мы использовали в примере для C++.


Для простоты примера, пусть будет метод, который возвращает ответ на самый главный вопрос Жизни, Вселенной и Всего Остального:


using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.InteropServices;

class Program
{
    [DllImport("kernel32.dll", SetLastError = true, ExactSpelling = true)]
    static extern IntPtr VirtualAllocEx(IntPtr hProcess, IntPtr lpAddress,
        uint dwSize, AllocationType flAllocationType, MemoryProtection flProtect);

    [Flags]
    public enum AllocationType
    {
        Commit = 0x1000,
        Reserve = 0x2000,
        Decommit = 0x4000,
        Release = 0x8000,
        Reset = 0x80000,
        Physical = 0x400000,
        TopDown = 0x100000,
        WriteWatch = 0x200000,
        LargePages = 0x20000000
    }

    [Flags]
    public enum MemoryProtection
    {
        Execute = 0x10,
        ExecuteRead = 0x20,
        ExecuteReadWrite = 0x40,
        ExecuteWriteCopy = 0x80,
        NoAccess = 0x01,
        ReadOnly = 0x02,
        ReadWrite = 0x04,
        WriteCopy = 0x08,
        GuardModifierflag = 0x100,
        NoCacheModifierflag = 0x200,
        WriteCombineModifierflag = 0x400
    }

    private delegate int IntReturner();

    private static void Main()

    {
        List<byte> bodyBuilder = new List<byte>();
        bodyBuilder.Add(0xb8);
        bodyBuilder.AddRange(BitConverter.GetBytes(42));
        bodyBuilder.Add(0xc3);
        byte[] body = bodyBuilder.ToArray();

        IntPtr buf = VirtualAllocEx(Process.GetCurrentProcess().Handle, (IntPtr) 0, Convert.ToUInt32(body.Length), AllocationType.Commit, MemoryProtection.ExecuteReadWrite);
        Marshal.Copy(body, 0, buf, body.Length);

        IntReturner ptr = (IntReturner) Marshal.GetDelegateForFunctionPointer(buf, typeof(IntReturner));
        Console.WriteLine(ptr());
        Console.ReadKey();        
    }
}

Если бы нам вдруг понадобился более умный пример с параметрами, Marshal умеет выделять unmanaged-память с помощью AllocHGlobal, чистить FreeHGlobal и ещё пачку методов на ту же тему.


Имея такие суперспособности, можно творить реальную дичь, например, заменять методы в классах. Перед публикацией этой статьи я просмотрел большое количество проектов на GitHub, но, к сожалению, ни один из этих хаков не обходился без C++, unsafe и, самое печальное, — очень объемного кода. Так что здесь всего этого писать не буду и вынесу в отдельную статью по хакам в .NET.


JVM Compiler Interface


Повернув мозги в правильном направлении, становится понятно, что в Java можно решить вопрос аналогичным образом. Дело в том, что в Java 9 реализован JEP 243: Java-Level JVM Compiler Interface.


Разработчики этой фичи понимали, что JIT-компилятор — это серьезный кусок говнокода софт, который хорошо бы разрабатывать отдельно, с использованием всех возможных фичей джавовой экосистемы, типа хороших бесплатных IDE. Далеко не все эти фичи можно использовать внутри OpenJDK — обычно ты открываешь его код в IDE, там всё красное и десять раз подчёркнуто как ошибка. Какое-то оправдание монолитной архитектуре есть в подсистемах, которым нужен прямой доступ к различным внутренним механизмам (например, это нужно интерпретатору байткода или сборщику мусора) — но компилятору это неважно.


Отсюда возникла идея выделить компилятор в отдельную сущность. Подключение должно производиться удобно, в виде плагина, включающегося из командной строки. Так родился JVMCI.


Если совсем коротко, то у нас есть простейший интерфейс:


interface JVMCICompiler {
    byte[] compileMethod(byte[] bytecode);
}

На вход приходит Java-байткод, на выход — нативный код. Очень походит на то, что было выше в C#. У нас это в чём-то даже лучше, ведь подобное использование не является грязным сайд-эффектом, а самым что ни на есть основным паттерном использования.


В реальности, просто байткода недостаточно. Это скорей некий CompilationRequest с дополнительными полями:


interface JVMCICompiler {
  void compileMethod(CompilationRequest request);
}

interface CompilationRequest {
    JavaMethod getMethod();
}

interface JavaMethod {
    byte[] getCode();
    int getMaxLocals();
    int getMaxStackSize();
    ProfilingInfo getProfilingInfo();
    ...
}

Долго ли, коротко ли, вы откомпилировали байткод, и его можно спокойно установить с помощью HotSpot.installCode(...);


В том числе, такой подход может решить изначальную проблему с интринсиками — необходиость пересобирать OpenJDK.


Проблемная часть здесь в том, что написать свою реализацию JVMCI — это не очень быстрая и простая задача. Документация по этой фиче почти отсутствует. Единственная всеобъемлющая документация — это код OpenJDK на C++, читать который ну очень не хочется.


Но и тут всё украдено за нас.


Graal и Truffle


В тёмных страшных подвалах Oracle Labs ведётся разработка нескольких крутых инструментов, которые в ближайшем будущем изменят картину для всех, кто расширяет OpenJDK. Эти проекты объединены под общим именем Graal и лежат вот в этом репозитории на GitHub.


В том числе:


  • Graal — оптимизирующий компилятор, написанный на Java и интегрирующийся с HotSpot JVM
  • Truffle — фреймворк для создания языков и тулов, использующих Graal как свой основной компилятор
  • Substrate VM — фреймворк, позволяющий делать ahead-of-time (AOT) компиляцию джавовых приложений и превращать их в исполняемые файлы

Интересно, что Graal и Truffle предоставляют свою реализацию JVMCI. И эта поддержка уже есть в OpenJDK 9 — достаточно подключить нужные флаги. Подключение этих флагов, конечно, не спасёт нас от пересборки самого Graal, но зато показывает, насколько серьёзно за дело взялись разработчики. Показывает, что всё это уже достаточно протестировано и созрело, чтобы превратиться в официальную фичу.


Очень хорошо о том, как работает Graal, рассказал Chris Seaton. Кстати, эта статья была написана по мотивам его выступления на Joker 2017.


Теперь к вопросу о том, насколько всё это хорошо работает и применимо на практике. На том же Joker выступал Christian Thalinger и рассказал о том, что значительная часть Twitter уже переведена на Graal. Использование его в качестве компилятора оказалось не только практичным, но и повысило производительность существующего кода на более чем 10%.


Кроме того, у нас есть JEP 317: Experimental Java-Based JIT Compiler, который с большой вероятностью войдёт в состав Java 10.


В этом разделе я хотел написать небольшой победоносный пример, который покажет, как можно использовать Graal в наших целях. К сожалению, пример всё ещё пишется, и похоже — будет писаться долго. Это тема для отдельной статьи.


Что здесь пропущено


Незаслуженно не были рассмотрены следующие вещи: VMStructs, Java Native Runtime (JNR-x86asm в данном случае), Project Panama в целом. В апреле после доклада apangin нужно будет написать спин-офф и раскрыть эти темы.


Заключение


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


Это только первая статья в серии. Каковы будут следующие шаги?


Вначале нужно провести интервью с Christian Thalinger, который как никто другой разбирается в Graal. Интервью будет опубликовано на Хабре в ближайшее время.


Кстати, он же приезжает на JBreak 2018 в Новосибирск с новым докладом "Graal: how to use the new JVM JIT compiler in real life" — на этот доклад стоит сходить.


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


Кроме того, можно попробовать связать современные материалы со старыми, но не утратившими полезности работами, повлиявшими на дизайн Graal. Например, одну такую статью (контекстно-зависимый инлайнинг трейсов) я уже публиковал на Хабре. Большое количество материала накопилось по связанным разработкам: например, текущий разработчик Graal, Doug Simon, раньше занимался Maxine VM, про которую есть внушительное количество публикаций.

Tags:
Hubs:
+68
Comments32

Articles

Information

Website
jugru.org
Registered
Founded
Employees
51–100 employees
Location
Россия
Representative
Алексей Федоров