Pull to refresh

Mono.Cecil: делаем свой «компилятор»

Reading time4 min
Views13K
Одной из самых роскошных тем для программистов, балующихся изобретением велосипедов, является написание собственных языков, интерпретаторов и компиляторов. Действительно, программа, способная создавать или исполнять другие программы, инстинктивно вселяет благоговейный трепет в сердца кодеров — потому что сложно, объемно, но безумно увлекательно.

Большинство начинают с собственных интерпретаторов, которые представляют собой в общем виде огромный свитч команд в цикле. Интересно, вольготно, но муторно и весьма медленно. Хочется чего-то более шустрого, чтобы JIT'ить умело и, желательно, само следило за памятью.

Отличным решением данной проблемы является выбор .NET в качестве целевой платформы. Оставим лексический разбор на следующий раз, а сегодня давайте попробуем сделать простейшую программу, которая создает работающий исполняемый экзешник:




Программа будет требовать имя, и выводить в консоли Hello, %username%.

Для создания экзешника существует много способов, например:
  • Трансляция в C#-код и вызов csc.exe: просто, но неспортивно
  • Генерация IL-кода в текстовой форме и компиляция ilasm.exe: неудобно по причине необходимости писать руками огромный манифест
  • Генерация сборки напрямую с помощью Reflection или Cecil

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

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

Предмет беседы


Вот, собственно, готовый код (без описания класса, формы и всего прочего, кроме собственно метода-генератора):

using Mono.Cecil;
using Mono.Cecil.Cil;

public void Compile(string str)
{
  // создаем библиотеку и задаем ее название, версию и тип: консольное приложение
  var name = new AssemblyNameDefinition("SuperGreeterBinary", new Version(1, 0, 0, 0));
  var asm = AssemblyDefinition.CreateAssembly(name, "greeter.exe", ModuleKind.Console);

  // импортируем в библиотеку типы string и void
  asm.MainModule.Import(typeof(String));
  var void_import = asm.MainModule.Import(typeof(void));

  // создаем метод Main, статический, приватный, возвращающий void
  var method = new MethodDefinition("Main", MethodAttributes.Static | MethodAttributes.Private | MethodAttributes.HideBySig, void_import);
  // сохраняем короткую ссылку на генератор кода
  var ip = method.Body.GetILProcessor();

  // магия ленор!
  ip.Emit(OpCodes.Ldstr, "Hello, ");
  ip.Emit(OpCodes.Ldstr, str);
  ip.Emit(OpCodes.Call, asm.MainModule.Import(typeof(String).GetMethod("Concat", new Type[] { typeof(string), typeof(string) })));
  ip.Emit(OpCodes.Call, asm.MainModule.Import(typeof(Console).GetMethod("WriteLine", new Type[] { typeof(string) })));
  ip.Emit(OpCodes.Call, asm.MainModule.Import(typeof(Console).GetMethod("ReadLine", new Type[] { })));
  ip.Emit(OpCodes.Pop);
  ip.Emit(OpCodes.Ret);

  // регистрируем тип, к которому будет привязан данный метод: все параметры выбраны
  // опытным путем из дизассемблированного экзешника
  var type = new TypeDefinition("supergreeter", "Program", TypeAttributes.AutoClass | TypeAttributes.Public | TypeAttributes.AnsiClass | TypeAttributes.BeforeFieldInit, asm.MainModule.Import(typeof(object)));
  // добавляем тип в сборку
  asm.MainModule.Types.Add(type);
  // привязываем метод к типу
  type.Methods.Add(method);

  // указываем точку входа для исполняемого файла
  asm.EntryPoint = method;

  // сохраняем сборку на диск
  asm.Write("greeter.exe");
}


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

Что же там творится?


Написанная на С#, та же самая программа выглядела бы вот так (описание класса я опущу):

static public void Main()
{
  Console.WriteLine("Hello, " + "username");
  Console.ReadLine();
}


Для этого мы берем две строки, первая — константа, вторая определяется на этапе компиляции и тоже становится константой, кладем их в стек. String.Concat складывает эти строки и оставляет на вершине стека результат, который берется Console.WriteLine и выводится на экран.

После этого, чтобы программа не закрылась прежде, чем мы успеем что-то прочитать, требуем Console.ReadLine() — а поскольку оно возвращает считанную строку, которая нам не нужна, выкидываем ее из стека, после чего с чувством выполненного долга покидаем уже ставшую почти родной функцию Main.

О байткоде


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

LDSTR загружает в стек строку. Очевидно, в качестве параметра ему требуется строка. По сути, «загружает строку в стек» означает, что не сама строка в стек кладется, а только указатель на то место в памяти, где она располагается — но для нас, как для программистом IL, это не важно. Важно только то, что следующие команды смогут ее оттуда взять и использовать.

CALL, как можно догадаться из названия, вызывает метод. Для этого ему необходимо передать ссылку на объект с описанием этого самого метода, который предварительно нужно импортировать. Для импорта следует «найти» метод в типе, передав имя и список типов его параметров в виде массива — вот почему запись такая ужасная. По-хорошему, тут нужно было бы написать какой-нибудь обработчик, который преобразует строку вида «String.Concat(string, string)» вот в этот ужас — можете попробовать этим заняться.

POP выкидывает из стека верхний элемент. Ничего особенного. Нужен нам потому, что Console.ReadLine() возвращает значение, а наша функция — void, следовательно мы не можем там его оставить и должны очистить.

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

Результаты работы



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

Запускаем его, и вуаля!
Tags:
Hubs:
Total votes 48: ↑36 and ↓12+24
Comments19

Articles