Как стать автором
Обновить

Ассемблерные вставки… в C#?

Время на прочтение9 мин
Количество просмотров36K
Итак, эта история началась с совпадения трёх факторов. Я:

  1. в основном писал на C#;
  2. лишь примерно представлял, как он устроен и работает;
  3. заинтересовался ассемблером.

Эта, на первый взгляд, невинная смесь породила странную идею: а можно ли как-то совместить эти языки? Добавить в C# возможность делать ассемблерные вставки, примерно как в C++.

Если вам интересно, к каким последствиям это привело, — добро пожаловать под кат.



Первые сложности


Уже в тот момент я понимал, что очень вряд ли есть стандартные инструменты для вызова ассемблерного кода из кода C# — это слишком сильно противоречит одной из важных концепций языка: безопасности работы с памятью. После поверхностного изучения вопроса (которое, кроме всего прочего, подтвердило изначальную догадку — «из коробки» такая возможность отсутствует) стало понятно, что кроме проблемы идейного характера есть и проблема чисто техническая: C#, как известно, компилируется в промежуточный байт-код, который в дальнейшем интерпретируется виртуальной машиной CLR. И как раз здесь перед нами в полный рост встаёт та самая проблема: с одной стороны, компилятор (здесь и дальше я буду подразумевать Roslyn от Microsoft, поскольку он де-факто является стандартом в области C#-компиляторов), очевидно, не умеет распознавать и транслировать ассемблерные команды из текстового вида в какое-либо двоичное представление, а значит, в качестве вставки мы должны использовать непосредственно машинные команды в их бинарном виде, а с другой — виртуальная машина имеет свой собственный байт-код и не может распознать и выполнить тот набор команд, который предложим ей мы.

Теоретическое решение этой проблемы очевидно — нужно сделать так, чтобы двоичный код вставки выполнялся процессором, минуя интерпретацию виртуальной машиной. Самое простое, что приходит в голову, — хранить двоичный код в виде массива байт, на который в нужный момент каким-то образом будет передано управление. Отсюда вырисовывается первая задача: нужно придумать способ передачи управления тому, что содержится в произвольной области памяти.

Первый прототип: «вызов» массива


Задача эта — пожалуй, самое серьёзное препятствие на пути к вставкам. Средствами языка несложно получить указатель на наш массив, но в мире C# указатели существуют только на данные и превратить его в указатель на, скажем, функцию, чтобы её потом вызвать, невозможно (ну или, по крайней мере, мне не удалось придумать, как это сделать).

К счастью (или к сожалению), ничто не ново под луной и быстрый поиск в Яндексе по словам «C#» и «ассемблерные вставки» привёл меня к статье в декабрьском выпуске журнала "][акер" за 2007 год. Честно скопипастив оттуда функцию и подогнав её к своим нуждам, получил

[DllImport("kernel32.dll")]
extern bool VirtualProtect(int* lpAddress, uint dwSize, uint flNewProtect, uint* lpflOldProtect);

public void* InvokeAsm(void* firstAsmArg, void* secondAsmArg, byte[] code)
{
    int i = 0;
    int* p = &i;
    p += 0x14 / 4 + 1;
    i = *p;
    fixed (byte* b = code)
    {
        *p = (int)b;
        uint prev;
        VirtualProtect((int*)b, (uint)code.Length, 0x40, &prev);
    }
    return (void*)i;
}

Основная идея этого кода — подменить в стеке адрес возврата из функции InvokeAsm() на адрес массива байт, которому требуется передать управление. Тогда после выхода из функции вместо продолжения выполнения программы начнётся выполнение нашего двоичного кода.

Разберёмся с магией, творящейся в InvokeAsm(), поподробнее. Сначала мы объявляем локальную переменную, которая, разумеется, оказывается в стеке, потом получаем её адрес (получая тем самым адрес верхушки стека). Дальше добавляем к нему некую магическую константу, полученную путём кропотливого подсчёта в отладчике смещения адреса возврата относительно верхушки стека, сохраняем адрес возврата и записываем вместо него адрес нашего массива байт. Сакральный смысл сохранения адреса возврата очевиден — нам же нужно продолжить выполнение программы после нашей вставки, а значит, надо знать, куда после неё передавать управление. Дальше идёт вызов WinAPI-функции из библиотеки kernel32.dll — VirtualProtect(). Он нужен для того, чтобы изменить атрибуты страницы памяти, на которой находится код вставки. Он, разумеется, при компиляции программы оказывается в секции данных, а соответствующая страница памяти имеет доступ для чтения и записи. Нам же нужно добавить ещё и разрешение на исполнение её содержимого. Наконец, мы возвращаем сохранённый настоящий адрес возврата. Разумеется, в код, вызвавший InvokeAsm(), этот адрес не вернётся, т.к. выполнение сразу же после return (void*)i; «провалится» во вставку. Однако, используемые виртуальной машиной соглашения о вызове (stdcall с отключенной оптимизацией и fastcall со включенной) подразумевают возврат значения через регистр EAX, т.е. для возврата из вставки нам понадобится выполнить две инструкции: push eax (код 0x50) и ret (код 0xC3).

Уточнение
В дальнейшем речь пойдёт об архитектуре x86 (или, вернее, IA-32) — банально из-за того, что на тот момент именно с ней я был хоть как-то знаком, в отличие от, скажем, x86-64. Однако описанный выше способ передачи управления должен работать и для 64-битного кода.

Наконец, стоит обратить внимание на два неиспользуемых аргумента: void* firstAsmArg и void* secondAsmArg. Они нужны для передачи произвольных пользовательских данных в ассемблерную вставку. Расположены эти аргументы будут либо в известном месте стека (stdcall), либо в, опять же, известных регистрах (fastcall).

Немного об оптимизации
Поскольку с точки зрения компилятора в коде творится не пойми что, он может ненароком выкинуть какой-то принципиально важный вызов/что-то заинлайнить/не сохранить какой-то «неиспользуемый» аргумент/ещё как-то помешать осуществлению нашего плана. Частично это решается атрибутом [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)], однако даже такие меры предосторожности не дают нужного эффекта: например, ключевая для всей функции локальная переменная i внезапно оказывается не стековой, а регистровой, что, очевидно, всё портит. Поэтому чтобы полностью исключить вероятность того, что что-то пойдёт не так, следует собирать библиотеку с отключенной оптимизацией (либо отключить её в свойствах проекта, либо использовать конфигурацию Debug). Следовательно, использоваться будет stdcall, поэтому в дальнейшем я буду исходить именно из этого соглашения о вызовах.

Улучшения


«Безопасное» лучше небезопасного


Разумеется, ни о какой безопасности (в том смысле, с котором это слово используется в C#) здесь не может быть и речи. Однако вышеописанный метод InvokeAsm() оперирует указателями, а значит, вызываться может только из блока, помеченного ключевым словом unsafe, что не всегда удобно — как минимум, это требует компиляции с ключом /unsafe (ну или соответствующей галочки в свойствах проекта в VS). Поэтому логичным кажется предоставить оболочку, оперирующую хотя бы IntPtr (на худой конец), а в идеале — и вовсе позволяющую пользователю указывать передаваемые и возвращаемые типы. Что ж, это звучит как generic, пишем generic, о чём тут ещё, спрашивается, говорить? На самом деле — есть о чём.

Самое очевидное: а как получить указатель на аргумент, тип которого неизвестен? Конструкции типа T* ptr = &arg в C# не допускаются, и, в общем-то, несложно понять причину: пользователь вполне может в качестве параметра типа использовать один из управляемх типов, указатель на который получить невозможно. Решением могло бы стать ограничение параметра типа unmanaged, но оно, во-первых, появилось только в C# 7.3, а во-вторых — не позволяет передавать в качестве аргументов строки и массивы, хотя оператор fixed их использовать позволяет (указатель при этом получаем на первый символ или элемент массива соответственно). Ну и, к тому же, хотелось бы дать пользователю возможность оперировать в том числе и управляемыми типами — раз уж мы взялись нарушать правила языка, то будем их нарушать до конца!

Получение указателя на управляемый объект и объекта по указателю


И вновь после не особо плодотворных раздумий я начал искать готорвые решения. На этот раз помогла мне статья на Хабре. Если вкратце, один из предлагаемых в ней методов заключается в том, чтобы написать вспомогательную библиотеку, причём не на C#, а непосредственно на IL. Её задача — помещать в стек виртуальной машины объект (фактически — ссылку на объект), передаваемый в качестве аргумента, после чего извлекать из стека что-то другое — например, число или IntPtr. Проделав те же действия в обратной последовательности, можно преобразовать указатель (например, возвращённый из ассемблерной вставки) в объект. Этот способ хорош тем, что всё происходящее понятно и прозрачно. Но есть и минус: я хотел обойтись как можно меньшим количеством файлов, поэтому вместо написания отдельной библиотеки решил встроить IL-код в основную. Единственный найденный мной способ — написать на C# методы-заглушки, собрать проект, дизассемблировать бинарник с помошью ildasm, переписать код методов-заглушек и собрать всё это обратно с помощью ilasm. Это довольно много дополнительных действий, а если учесть, что проделывать их нужно при каждой сборке после внесения в код любых изменений… В общем, довольно быстро мне это надоело, и я начал искать альтернативы.

Как раз в это время мне в руки попала замечательная книга, благодаря которой я узнал для себя много нового — «CLR via C#» Джеффри Рихтера. В ней, где-то в районе двадцатой главы, речь зашла про структуру GCHandle, у которой есть метод Alloc(), принимающий объект и один из элементов перечисления GCHandleType. Так вот, если вызвать этот метод передав ему желаемый объект и GCHandle.Pinned, то можно получить адрес этого объекта в памяти. Более того, до вызова GCHandle.Free() объект фиксируется, т.е. полностью защищается от воздействия сборщика мусора. Однако и тут есть определённые проблемы. Во-первых, GCHandle никоим образом не помогает совершить преобразование «указатель → объект», только «объект → указатель». Что важнее, для использования GCHandleType.Pinned класс или структура объекта, адрес которого мы хотим получить, должны иметь атрибут [StructLayout(LayoutKind.Sequential)], в то время как по умолчанию используется LayoutKind.Auto. Так что такой способ подойдёт лишь для некоторых стандартных типов и для тех пользовательских типов, которые изначально проектировались с учётом этой особенности. Не совсем тот универсальный метод, который мы хотели бы найти, верно?

Что ж, попробуем ещё раз. Теперь обратим внимание на две недокументированные функции, которые, тем не менее, поддерживаются Roslyn-ом: __makeref() и __refvalue(). Первая из них принимает объект и возвращает экземпляр структуры TypedReference, хранящей ссылку на объект и его тип, вторая же извлекает объект из передаваемого экземпляра typedReference. Почему эти функции так для нас важны? Потому что TypedReference — структура! В контексте обсуждения это значит, что мы можем получить указатель на неё, который, по совместительству, будет являться указателем на первое поле этой структуры. А именно в нём хранится та самая интересующая нас ссылка на объект. Тогда для получения указателя на управляемый объект нам нужно прочитать значение по указателю на то, что вернёт __makeref() и сконвертировать это в указатель. Для получения же объекта по указателю необходимо вызвать __makeref() от, условно, пустого объекта требуемого типа, получить указатель на возвращаемый экземпляр TypedReference, записать по нему указатель на объект, после чего вызвать __refvalue(). В итоге получился примерно такой код:

public static Tout ToInstance<Tout>(IntPtr ptr)
{
    Tout temp = default;
    TypedReference tr = __makeref(temp);
    Marshal.WriteIntPtr(*(IntPtr*)(&tr), ptr);
    Tout instance = __refvalue(tr, Tout);
    return instance;
}

public static void* ToPointer<T>(ref T obj)
{
    if (typeof(T).IsValueType)
    {
        return *(void**)&tr;
    }
    else
    {
        return **(void***)&tr;
    }
}

Замечание
Возвращаясь к задаче написания безопасной обёртки для InvokeAsm(), следует отметить, что метод получения указателей с помощью __makeref() и __refvalue(), в отличие от использования GCHandle.Alloc(GCHandleType.Pinned), не гарантирует того, что сборщик мусора никуда наш объект не передвинет. Поэтому обёртка должна начинаться с отключения сборщика мусора и заканчиваться восстановлением его функциональности. Решение довольно грубое, но эффективное.

Для тех, кто не помнит опкоды


Итак, мы узнали, как вызывать двоичный код, научились передавать ему в качестве аргументов не только непосредственные значения, но и указатели на что угодно… Осталась лишь одна проблема. Где взять тот самый двоичный код? Можно вооружиться карандашом, блокнотом и таблицей опкодов (например, этой) или взять шестнадцатеричный редактор с поддержкой ассемблера x86 или даже полноценный транслятор, но все эти варианты подразумевают, что пользователю придётся использовать ещё что-то кроме библиотеки. Это — не совсем то, чего мне хотелось, поэтому я решил включить в состав библиотеки свой транслятор, который по устоявшейся традиции был назван SASM (сокращение от Stack Assembler; никакого отношения к IDE не имеет).

Disclaimer
Я не силён в парсинге строк, поэтому код транслятора… ну, неидеален, мягко говоря. Кроме того, не силён я и в регулярных выражениях, поэтому их там нет. И вообще — парсер итеративный.

Рассказывать о процессе создания этого «чуда» я, пожалуй, не буду — нет в этой истории ничего интересного, а вот основные возможности кратко опишу. На данный момент поддерживается большинство инструкций x86. Инструкции математического сопроцессора для работы с числами с плавающей точкой и из расширений (MMX, SSE, AVX) пока не поддерживаются. Есть возможность объявления констант, процедур, локальных стековых переменных, глобальных переменных, память под которые выделяется в процессе трансляции непосредственно в массиве с двоичным кодом (если эти переменные именованы при помощи меток, то их значение можно получить и из C# после выполнения вставки путём вызова методов GetBYTEVariable(), GetWORDVariable(), GetDWORDVariable(), GetAStringVariable() и GetWStringVariable() объекта SASMCode), присутствуют макросы addr и invoke. Одной из важных особенностей является поддержка импорта функций из внешних библиотек с помощью конструкции extern <имя функции> lib <имя библиотеки>.

Отдельного абзаца достоин макрос asmret. В процессе трансляции он разворачивается в 11 инструкций, образующих эпилог. В начало же транслируемого кода по умолчанию добавляется пролог. Их задача — сохранение/восстановление состояния процессора. Кроме того, пролог добавляет четыре константы — $first, $second, $this и $return. В процессе трансляции эти константы заменяются адресами в стеке, по которым находятся, соответственно, первый и второй аргументы, переданные ассемблерной вставке, адрес первой команды вставки и адрес возврата.

Итог


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

Если же всё же попытаться как-то обобщить всё сделанное — то, на мой взгляд, получился интересный и даже, в какой-то степени, небесполезный проект. Например, идентичные алгоритмы сортировки вставками на C# и с помощью ассемблерной вставки по скорости отличаются более чем в два раза (разумеется, в пользу ассемблера). В серьёзных проектах, конечно, получившуюся библиотеку использовать не рекомендуется (возможны, хоть и не слишком вероятны, непредсказуемые побочные эффекты), но для себя — вполне можно.
Теги:
Хабы:
+54
Комментарии37

Публикации

Изменить настройки темы

Истории

Работа

Ближайшие события

PG Bootcamp 2024
Дата16 апреля
Время09:30 – 21:00
Место
МинскОнлайн
EvaConf 2024
Дата16 апреля
Время11:00 – 16:00
Место
МоскваОнлайн
Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн