Хабрахабр

[Из песочницы] Ассемблерные вставки… в C#?

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

  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)
return (void*)i;
}

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

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

Уточнение

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

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

Немного об оптимизации

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

Улучшения

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

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

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

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

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

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

Замечание

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

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

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

Disclaimer

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

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

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

Итог

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

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Проверьте также

Закрыть
Кнопка «Наверх»
Закрыть