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

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

    Большинство начинают с собственных интерпретаторов, которые представляют собой в общем виде огромный свитч команд в цикле. Интересно, вольготно, но муторно и весьма медленно. Хочется чего-то более шустрого, чтобы 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 байт.

    Запускаем его, и вуаля!
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 19

      +2
      Навеяло :) Правда из немного другой темы

      var writeLine = typeof(Console).GetMethod("WriteLine", new[] { typeof(object) });
      var proc = Expression.Lambda<Action>(Expression.Call(writeLine, Expression.Constant("Hello, imp"))).Compile();
      
      proc();
      
        0
        С приходом .NET 4 вообще можно даже компильнуть такое дело и получить не лямбду, а MethodBody =)
        Expression очень красиво и практично инкапсулирует методы рефлексии для кодогенерации.
          0
          насколько помню можно в итоге получить либо Func<>, либо передать MethodBuilder и записать байткод в него.
          все очень красиво, но только на уровне отдельных функций.
        0
        Мне кажется это немного «неполноценный» компилятор, но для начала очень неплохо… (если развить идею то получился бы весьма симпатичный велосипед)
          0
          Ну да, это скорее backend для компилятора, нежели действительно компилятор :) Если интересно, в следующий раз напишу что-нибудь более относящееся к компиляторам.
            0
            Тема более чем интересна, не столько в плане реального применения, сколько в образовательных целях.
          0
          А что-нибудь подобное но для винды есть?)
            +1
            так Mono.Cecil — это обычная .NET сборка, ее можно не только из Mono использовать.

            Я, к примеру, build task с Mono.Cecil наваял, теперь не надо PropertyChanged больше вызывать — само всё происходит
            0
            Ссылочку можно? :)
            Я не очень сведущь в теме .NET и всем что с неим связано, просто поэкспериментировать хочется))
            +2
            Ну, это и вовсе не компилятор в классическом понимании сего термина — ни тебе лексического анализатора, ни синтаксического, ни построения дерева. Это просто создание исполняемого файла, что не есть достаточным признаком компилятора.

            А вообще после второго-третьего своего на коленке написаного «языка» приходит понимание, что не фиг изобретать велосипед, поскольку те же Pyhon, LUA и прочие прекрасно встраиваются в любое приложение за очень короткое время.
              0
              Для .NET есть еще Cobra и Boo…
                0
                С удовольствием бы почитал статью о том как это делается, интересует в основном python. Не напишите?)
                  +1
                  До меня уже куча умных людей написали. Ну вот хотя бы magazine.sources.ru/2010/03/add_python/
                    0
                    спасибо! Уже успел разобраться! Но все равно спасибо!
                0
                Кстати, если Вы хотите написать компилятор и понять как он работает, то эта статья очень хорошо подходит для этой цели: habrahabr.ru/blogs/programming/99162/
                  0
                  А как насчет CodeDom?
                    0
                    книга дракона — наше все. Причем не только в плане разведения сферических коней в вакууме, но и с чисто практической точки зрения — скриптовый язык встроить в приложение простенький, например.

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

                  Самое читаемое