Разъяснение промежуточного языка Microsoft (Microsoft Intermediate Language). Часть 1 – Введение
Автор: Kamran Qamar
[Оригинал статьи] [Обсудить в форуме]
Перевод с английского: Виталий Готовцов
WWW: http://www.vitgot.narod.ru
В .NET Framework именно Стандартная Инфраструктура Языка (Common Language Infrastructure) обуславливает спецификации исполняемого кода и окружение исполнения (Виртуальную Систему Исполнения (Virtual Execution System, VES)), в которой она работает. Исполняемый код представляется в VES модулями. Модуль – это файл, содержащий исполняемый контент определенного формата.
Common Language Infrastructure использует Common Language Specification (Стандартную Спецификацию Языка), чтобы связать разные языки в соглашении с рамками доступа выполняя по меньшей мере те части Стандартной Системы Типов (Common Type System (CTS)), которые являются частью Стандартной Системы Языков (Common Language Specifications (CTS)). Поэтому все языки (C#, VB.NET, Effil.NET и т.д.), которые запланированы в рамках .NET, взаимодействуют с единым стандартным языком, известным как промежуточный язык Microsoft (Microsoft Intermediate Language (MSIL)).
Промежуточный язык Microsoft (Microsoft Intermediate Language (MSIL)) представляет собой промежуточный уровень в процессе взаимодействия исходного кода, написанного на любом языке .NET, с машинным языком, в виде кода на языке псевдо-ассемблера, который находится между исходным кодом, который написали вы, таким как VB.NET или C#, и Intel-базирующимся языком ассемблера или машинным кодом. Когда вы компилируете .NET-программу, компилятор переводит ваш исходный код в промежуточный язык Microsoft (Microsoft Intermediate Language (MSIL)), который является не зависимым от CPU набором инструкций, которые могут быть эффективно конвертированы в собственный (native) код. Когда мы запускаем код на исполнение, MSIL конвертируется в специфический код CPU, обычно компилятором времени выполнения (just-in-time (JIT) compiler). Благодаря тому, что CLR поддерживает один или больше JIT-компиляторов, один и тот же набор MSIL может быть откомпилирован и исполнен на разных поддерживаемых архитектурах.
Необходимость достичь мастерства в Промежуточном Языке неизбежна, потому что знание Промежуточного Языка дает знание, необходимое для программирования на любом языке программирования. MSIL (или, проще, IL) приводит к концу бесконечные войны среди программистского общества о преимуществах одного языка перед другими. С этой точки зрения, IL является великим уравнителем. В мире .NET одна часть кода может быть написана на Effil, в то время как остальное может быть написано на C# или VB.NET, но в итоге все будет конвертировано в IL. Это предоставляет огромную свободу и гибкость программистам в выборе для работы того языка, который известен ему лучше всего, и освобождает от необходимости постоянно изучать новый язык каждый день.
В этой серии статей я раскрою сложность окружения IL, представляя комплексные понятия в простой и всесторонней манере. Эти понятия будут дополнены детальными примерами. Чтобы облегчить понимание образцов программ, в каждом примере сначала я буду предоставлять исходный код программы на языке C# или VB.NET, а затем мы исследуем IL, произведенный соответствующим компилятором. В некоторых случаях я напишу тот же код полностью на IL. Мы сравним оба, чтобы лучше понимать ограниченность наших компиляторов и научимся писать код качественнее и быстрее.
Цель этой серии состоит в том, чтобы объяснить сложность, окружающую IL и сделать вас знатоком в понимании IL. Также я хочу уменьшить ваш страх перед языками низкого уровня. Мощь MSIL лежит в его простоте. Я хочу сейчас предостеречь вас от заблуждений, вызванных очевидной простотой примеров, которые я буду использовать повсюду в статье. Я хотел бы, чтобы вы применили их сами и убедились в результатах. Погрузитесь в него и я уверяю вас, в конце этого фантастического путешествия вы вернетесь победителем.
Простейшая программа, написанная на IL
Мы начнем наше путешествие с классического примера Hello World, но с небольшим завихрением. Откройте свой любимый текстовый редактор, создайте новый текстовый файл и назовите его HelloWorld.il и вставьте в него следующие строки кода:
.method void HelloWorld()
{
ret
}
Это простейшая неработающая программа, написанная на IL. Эта программа говорит нам две важные вещи:
1. В IL-программе каждая новая строка может начинаться с точки «.» или без нее.
Все, что начинается с точки «.» является директивой для ассемблера, заставляя его выполнить некоторое действие, такое как создание функции или класса. В тоже самое время, все то, что не начинается с точки «.», является действительной IL-инструкцией, в данном случае это «ret».
2. Структура метода определяется в IL.
Функция в IL создается директивой ассемблера method, которая сопровождается возвращаемым типом метода, в нашем случае «void», а затем название функции, HelloWorld с парой круглых скобок «()». Точка начала и точка конца функции обозначены фигурными скобками «{}».Хорошо оформленная функция, написанная на IL, всегда имеет инструкцию «конца функции», которой является «ret».
Мы можем скомпилировать эту программу, используя утилиту ILAsm.exe, предлагаемую Microsoft. Для компиляции мы можем использовать следующую команду. Удостоверьтесь, что переменная пути установлена правильно. Если у вас установлена VisualStudio.NET, воспользуйтесь ее утилитой командной строки, которая установит пути правильно. Если у вас установлено только .NET SDK, то установите путь, используя следующую команду:
Set path = c:\progra~1\microsoft.net\frameworksdk\bin
Давайте скомпилируем нашу программу с помощью следующей команды:
C:\>ilasm Helloworld.il
Во время ее выполнения генерируется следующая ошибка, которая показана на рисунке 1.
Это сообщение об ошибке вполне информативно. Оно говорит нам, что исходный файл имеет тип ANSI, но об этом позже. Также оно дает нам предупреждающее сообщение:
HelloWorld.il(2) : warning -- Non-static global method 'HelloWorld', made static
«HelloWorld.il(2): предупреждение – Нестатичный глобальный метод «HelloWorld» сделан статичным»
И это возбуждает одну ошибку:
Error: No entry point declared for executable
Could not create output file, error code=0x80004005
***** FAILURE *****
«Ошибка: Не объявлена точка входа для выполнения
Не мог создать исходящий файл, код ошибки=0х80004005
*****ОТКАЗ*****»
Один IL-файл может содержать много функций и ассемблер не имеет способа определить, какую из них нужно выполнить первой. В обычных языках, таких как C#, или VB, функция, которая должна быть выполнена первой, должна иметь определенное название, например в C# входом в процедуру является функция Main. Об этом и жалуется ассемблер. В IL первая функция, которая должна быть выполнена, называется функцией entrypoint.
Чтобы сказать ассемблеру, что функция HelloWorld является функцией entrypoint, нам нужна директива. И вот, директива – это ничто иное, как entrypoint. Добавьте эту директиву в наш код, как показано в листинге ниже. Между прочим, ассемблер не требует, чтобы эта директива была первым оператором в функции. Функция просто должна иметь эту директиву определенной где-нибудь в функции, в начале, середине или в конце, не имеет значения, где именно, следовательно, оба листинга кода, приведенные ниже, производят один и тот же эффект. Однако в одной сборке только одна функция может иметь эту директиву. Если более чем одна функция имеет эту директиву, программа никогда не будет скомпилирована.
.method void HelloWorld()
{
.entrypoint
ret
}
или
.method void HelloWorld()
{
ret
.entrypoint
}
На этот раз программа компилируется в файл HelloWorld.exe без единой ошибки. Однако, если мы попытаемся запустить эту программу, нас поприветствует сообщение об ошибке, показанное на рисунке 2:
Причиной этого сообщения является неподходящее форматирование нашего кода. IL-код всегда компилируется в один модуль, и модуль всегда связывается с одной сборкой. Понятие модулей и сборок является исключительно важным в мире .NET и должно быть полностью понятно. В последующих статьях мы исследуем архитектуру программы .NET и этой директивы. На мгновение я добавлю эту сборку (assembly) в нашу программу HelloWorld. Формат этой директивы – ключевое слово assembly, сопровождаемое названием и фигурными скобками
.assembly <name of assembly> {}
Следовательно, наш обновленный код будет выглядеть так:
.assembly DemystifyingILChapter1 {}
.method static void HelloWorld()
{
.entrypoint
ret
}
Эта программа запустится и откомпилируется без ошибок. Заметьте, я добавил ключевое слово «static» в директиве «method» чтобы предупредить сообщение, вызванное ассемблером в первый раз. Статические методы принадлежат классу. Помните, в C# мы используем определенную функцию Main как public static void Main(). Следовательно, любая функция, у которой есть директива entrypoint, должна быть декорирована атрибутом static.
Вау, мы создали наше первое приложение, которое ничего не производит, но не унывайте, мы поняли основы программы в IL. Мы будем основываться на этой структуре.
Затем нам нужно будет найти способ вызвать функцию, определенную в другой сборке. Используя ее, мы сможем вызвать знаменитую функцию WriteLine, чтобы получить на выходе в консоли нашу строку «HelloWorld». Догадайтесь, это будет директива или инструкция для ассемблера?
Да, вы правы, это будет инструкция. Чтобы найти ее, мы посмотрим в нашем мешке инструкций IL, и инструкция, предназначенная именно для этого, называется call. Формат этой функции:
call <return type> <namespace>.<class name>::<function name>
Когда он вызывает функцию, между IL и другими языками программирования обнаруживается существенное различие. В IL, когда мы вызываем функцию, мы должны полностью определить функцию, включая ее пространство имен, возвращаемый тип и тип данных ее параметра. Это гарантирует, что ассемблер сможет установить подлинность функции.
Теперь код выглядит так:
.assembly DemystifyingILChapter1 {}
.method static void HelloWorld()
{
.entrypoint
call void [mscorlib]System.Console::WriteLine(class System.String)
ret
}
Эта программа компилируется без ошибок, однако, она возбуждает исключение, если мы выполняем ее, как показано на рисунке 3:
Причиной, вызывающей это исключение, является упущение значения ожидаемого параметра. Программа ожидает строковый параметр, чтобы использовать его в вызываемой функции. Сейчас мы увидим, как передать параметр в функцию.
Все параметры передаются и помещаются в стек функции. IL имеет инструкцию, которая называется ldstr, являющуюся сокращением от «Load String». Эта инструкция загружает строку в стек. Стек – это область памяти, которая упрощает передачу параметров в функцию, мы поговорим об этом в следующих главах. Все функции получают свои параметры из стека. Следовательно, инструкции подобные ldstr обязательны. Формат этой инструкции:
ldstr <parameter string>
После использования этой инструкции листинг нашего обновленного кода выглядит так:
.assembly DemystifyingILChapter1 {}
.method static void HelloWorld()
{
.entrypoint
ldstr "Hello World."
call void [mscorlib]System.Console::WriteLine(class System.String)
ret
}
Эта программа компилируется и вырабатывает хорошо известный «Hello World».
Видите, IL-программирование совсем не трудное. Вы написали свою первую программу, которая приветствует вас. Вы также узнали о структуре IL-программы и узнали некоторые директивы и инструкции. Если вы сами выполнили эти примеры, вы должны были заметить, что IL – регистрозависимый язык. В следующем разделе мы усовершенствуем это приложение. Нет, мы не будем усовершенствовать его функции, но декорируем его некоторыми атрибутами. Эти атрибуты придадут нашему приложению вид, который эквивалентен продукту великой утилиты ILDasm.exe. Я объясню это позже.
Усовершенствование примера HelloWorld
Все языки, относимые к .NET framework, по своей природе являются объектно-ориентированными. Однако, наш пример приложения скорее является структурированной программой. Я преобразую его в объектно-ориентированную программу. В ООП мы все определяем в классе. Чтобы конвертировать нашу программу в ОО, нужно дать указание ассемблеру создать класс. Я могу сделать это с помощью директивы class, вроде этой:
.class HelloWorld
{
}
За директивой class следует название класса, которое опционально в IL. Мне также нужно декорировать эту директиву некоторыми атрибутами, которые определят ее доступность, размещение и опции взаимодействия. Таким образом, мой обновленный код выглядит так:
.assembly DemystifyingILChapter1 {}
.class public auto ansi HelloWorld
{
.method static void HelloWorld()
{
.entrypoint
ldstr "Hello World."
call void [mscorlib]System.Console::WriteLine(class System.String)
ret
}
}
Я использовал такие три атрибута:
Наша программа все еще компилируется и производит тот же результат. Но, смотрите-ка, теперь это объектно-ориентированное приложение. Прежде чем я пойду дальше, позвольте мне обработать еще один тонкий момент. Мы знаем, что все классы в .NET framework прямо или непрямо наследуют от класса System.Object. Мы не написали явно кода для этого, но IL-компилятор сделал это для нас. Чтобы прояснить это, давайте запишем этот код явно. Это тривиальная задача. Я могу сделать это, используя ключевое слово extends, за которым следует полное название класса. Позвольте мне использовать эту возможность и обновить наш код еще немного, украшая метод HelloWorld некоторыми атрибутами. Улучшенная версия кода HelloWorld приведена ниже:
.assembly DemystifyingILChapter1 {}
.class public auto ansi HelloWorld extends [mscorlib]System.Object
{
.method public hidebysig static void HelloWorld() cil managed
{
.entrypoint
ldstr "Hello World."
call void [mscorlib]System.Console::WriteLine(class System.String)
ret
}
}
Я расширил наш класс System.Object и добавил некоторые атрибуты в метод HelloWorld. Я объясню их, один за одним:
public: В C# или VB.NET, когда мы определяем метод, мы классифицируем его доступность используя доступные атрибуты. Один из них это public. Применение этого атрибута означает, что этот метод доступен каждой другой части программы. В «Части 3 – Базовый IL» мы детально рассмотрим атрибуты доступности.
hidebysig: Класс может быть получен из любого класса. Атрибут hidebysig гарантирует, что функция в родительском классе скрыта от полученного класса, имеющего то же имя и сигнатуру. В нашем примере этот атрибут гарантирует, что если функция HelloWorld представлена в базовом классе, то она не видима в полученном классе.
cil managed: Я объясню этот атрибут позже в этой статье.
Эти атрибуты не влияют на результат программы. Наша программа все еще компилируется и запускается, как и раньше. Проявите терпение ко мне, и я покажу вам эффекты от этих атрибутов через минуту.
Вы помните из вашего опыта работы с языком высокого уровня (C#, VB.NET и т.д.), что каждый класс должен иметь определенный конструктор. И первой строкой кода этого конструктора должен быть вызов конструктора нашего базового класса. Если нет никакого определенного конструктора, то конструктор основного класса будет вызван автоматически. Осуществить этот конструктор - обязанность компилятора нашего языка.
Так как я использую класс, который расширяет System.Object мне нужно определить конструктор, который вызовет конструктор моего основного класса. Чтобы создать конструктор, я должен определить специальный метод, который называется .ctor с атрибутами specialname, rtspecialname и instance. Наш обновленный код выглядит так:
.assembly DemystifyingILChapter1 {}
.class public auto ansi HelloWorld extends [mscorlib]System.Object
{
.method public hidebysig static void HelloWorld() cil managed
{
.entrypoint
ldstr "Hello World."
call void [mscorlib]System.Console::WriteLine(class System.String)
ret
}
.method public hidebysig specialname rtspecialname
instance void .ctor() cil managed
{
ldarg.0
call instance void [mscorlib]System.Object::.ctor()
ret
}
}
В следующих статьях, я представлю вам набор IL-инструкций, расскажу, как IL используется для выполнения базовых операций, таких как Выбор, Итерация, перегрузка и т.д. Мы также увидим, как создавать ссылки и типы значений. Определим методы, свойства и индексаторы. Я покажу вам основы обработки исключений и создание специальных классов, таких как делегаты, и определять пользовательские события. Я завершу этот цикл полнофункциональным GUI-приложением, написанным на IL.