- в основном писал на C#;
- лишь примерно представлял, как он устроен и работает;
- заинтересовался ассемблером.
Эта, на первый взгляд, невинная смесь породила странную идею: а можно ли как-то совместить эти языки? Добавить в 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).Наконец, стоит обратить внимание на два неиспользуемых аргумента:
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 не имеет).
Рассказывать о процессе создания этого «чуда» я, пожалуй, не буду — нет в этой истории ничего интересного, а вот основные возможности кратко опишу. На данный момент поддерживается большинство инструкций x86. Инструкции математического сопроцессора для работы с числами с плавающей точкой и из расширений (MMX, SSE, AVX) пока не поддерживаются. Есть возможность объявления констант, процедур, локальных стековых переменных, глобальных переменных, память под которые выделяется в процессе трансляции непосредственно в массиве с двоичным кодом (если эти переменные именованы при помощи меток, то их значение можно получить и из C# после выполнения вставки путём вызова методов
GetBYTEVariable()
, GetWORDVariable()
, GetDWORDVariable()
, GetAStringVariable()
и GetWStringVariable()
объекта SASMCode
), присутствуют макросы addr
и invoke
. Одной из важных особенностей является поддержка импорта функций из внешних библиотек с помощью конструкции extern <имя функции> lib <имя библиотеки>
.Отдельного абзаца достоин макрос
asmret
. В процессе трансляции он разворачивается в 11 инструкций, образующих эпилог. В начало же транслируемого кода по умолчанию добавляется пролог. Их задача — сохранение/восстановление состояния процессора. Кроме того, пролог добавляет четыре константы — $first
, $second
, $this
и $return
. В процессе трансляции эти константы заменяются адресами в стеке, по которым находятся, соответственно, первый и второй аргументы, переданные ассемблерной вставке, адрес первой команды вставки и адрес возврата.Итог
Код скажет куда больше, чем слова, да и странно было бы не поделиться результатом довольно продолжительной работы, поэтому всех, кого мне удалось заинтересовать, приглашаю на GitHub.
Если же всё же попытаться как-то обобщить всё сделанное — то, на мой взгляд, получился интересный и даже, в какой-то степени, небесполезный проект. Например, идентичные алгоритмы сортировки вставками на C# и с помощью ассемблерной вставки по скорости отличаются более чем в два раза (разумеется, в пользу ассемблера). В серьёзных проектах, конечно, получившуюся библиотеку использовать не рекомендуется (возможны, хоть и не слишком вероятны, непредсказуемые побочные эффекты), но для себя — вполне можно.
Комментарии (36)
blanabrother
25.08.2019 16:51Чтобы Вы не тратили время на разработку и отладку генератора нативного кода, вот Вам ссылка на проект, в котором реализованы все инструкции x86/x64, даже векторизация.
BkmzSpb
25.08.2019 18:04+3Для работы с указателями и
ref
— структурами можно посмотреть в сторону
System.Runtime.CompilerServices.Unsafe. Класс доступен как вnetcore3.0
так и вnetstandard2.0
как самостоятельныйnuget
-пакет.
Позволяет кастовать (в определенных случаях) ссылки к указателям и обратно, а так же "безопасно" выполнять арифметику над указателями.Midiy Автор
25.08.2019 19:19Хм, да, он, возможно, заметно облегчил бы мне задачу. Спасибо, заинтересовался! Надо будет глянуть их реализацию.
ormoulu
25.08.2019 19:09+1Сударь знает толк…
Как насчет написать процедуру в байт-кодах и передать управление в нее?Midiy Автор
25.08.2019 19:21В IL-овских? Мысль. Попробую на досуге что-нибудь придумать. Глядишь, ещё в один пост выльется…
blanabrother
25.08.2019 20:55Кстати. Если Вам достаточно иметь метод с передаваемыми аргументами, то вполне достаточно аллоцировать память через Marshal.AllocHGlobal например, потом запротектить на выполнение (единственный хак вне платформы), затем в этот участок памяти перелить опкоды (тут iced либа в помощь), затем через тот же Marshal получить вполне законный управляемый делегат — Marshal.GetDelegateForFunctionPointer, который можно где-то сохранить и переиспользовать. Все законно, в рамках платформы.
DistortNeo
25.08.2019 21:15Генерация IL-кода на лету доступна "из коробки".
Google "ilgenerator", например: https://habr.com/ru/company/skbkontur/blog/262711/
А вот возиться с нативным ассемблером из C# не вижу смысла.
maximnik0q
25.08.2019 21:48А почему не использовали Mono?
Там что то упоминалось про вставки на ассемблере если вы компилируете в наитивный код (не знаю -не слежу, убрали ли эту возможность в связи с объединением кодовой базы с Net ).То есть получается исполняемый файл под конкретную архитектуру, если на размер плевать то можно сделать автономным, не завищищий есть ли у тебя mono -среда,.А если что вызывать из ехешника библиотеки среды исполнения, тогда размер поменьше.Midiy Автор
25.08.2019 22:01Потому что с Mono знаком исключительно понаслышке и про такую возможность услышал сейчас впервые. К тому же всё это делалось не ради практической пользы, а, скорее, из интереса. Интереса же в использовании готового функционала немного…
mjr27
25.08.2019 22:26+3Если уж извращаться, то почему бы не через expressions?
Типа
Action a = Asm.Compile((ptr)=>{
var label1 = Asm.Mov(Asm.Ecx, ptr);
Asm.Cmp(Asm.Ecx, Asm.Edx);
Asm.Je(label1);
})
Siemargl
25.08.2019 22:45-2У C# (да и прочих IL-языков) никаких шансов пробиться в топ по производительности.
Негативных факторов слишком много.
Насколько я помню, первые попытки отмазаться встроенным ассемблером начались в Дельфях. Хотя сильно не уверен в первенстве.
Но главный вопрос — а нафига?Alexx999
26.08.2019 10:15+1Вообще возможно собрать что-то на синтаксисе C# что будет компилироваться в идеально прилизанный нативный код.
У разработчиков Unity в блоге есть занимательный текст (на английском) как они перевели performance critical вещи с C++ на C# и вполне счастливы, суть претензий — в борьбе с компиляторами С++ где векторизация имеет склонность тихо отваливаться.
Но, конечно, уже это не совсем C#
makarychev13
26.08.2019 10:15+1В данной статье человек делает ассемблерные вставки не для того, чтобы повысить производительность
Fedorkov
26.08.2019 14:05Я однажды потратил неделю на то, чтобы оптимизировать на ассемблере функцию, которая бы работала быстрее, чем на Си/gcc. Программа есть на гитхабе, но я делал это на спор, поэтому там только бинарники.
А потом я переписал эту программу на C#/Mono, и она с ходу заработала ещё быстрее.
А вчера я начал баловаться с Rust, и переписанная на нём программа заработала ещё в полтора раза быстрее.
DistortNeo
25.08.2019 22:54У C# (да и прочих IL-языков) никаких шансов пробиться в топ по производительности.
Но это связано с компиляцией в процессе работы программы, а не с самим фактом существования IL-кода. Тот же LLVM, но со статической компиляцией, вполне себе быстр.
Но главный вопрос — а нафига?
Вот именно. Поначалу я пытался использовать ассемблер для SSE/AVX оптимизаций. Потом понял бесперспективность данного направления из-за необходимости писать ветки кода и для x86, и для x64 архитектуры и открыл для себя интринсики.
Alex141
26.08.2019 01:09У нас с другом была такая шутка. Что есть человек, который настолько любит ассемблер, что куда бы он ни устроился и на каком бы языке не писал, пишет код с ассемблерными вставочками :) И этим бесит коллег, потому что никто эти вставки не понимает)
Asen
26.08.2019 03:12Вставки по большому счету всем понятны, а вот то, как потом поддерживать код с такими вставками — коллегам не понятно
loginsin
Делал подобное на vb6 лет 15 назад. Правда там использовал известную для ВэБэшников api функцию CallWindowProc. Впрочем, метод (как тот, так и описанный здесь в статье) прибит гвоздями к x86.
kaleman
CallWindowProc использовали при царе Горохе (в 90-ые) а последние лет 15 — DispCallFunc