Формально сущность, используемая для хранения набора вычисляемых значений, называется виртуальным стеком выполнения. Вы увидите, что CIL предоставляет несколько кодов операций, которые служат для помещения значения в стек; такой процесс именуется загрузкой. Кроме того, в CIL определены дополнительные коды операций, которые перемещают самое верхнее значение из стека в память (скажем, в локальную переменную), применяя процесс под названием сохранение.
В мире CIL невозможно напрямую получать доступ к элементам данных, включая локально определенные переменные, входные аргументы методов и данные полей типа. Вместо этого элемент данных должен быть явно загружен в стек и затем извлекаться оттуда для использования в более позднее время (запомните упомянутое требование, поскольку оно содействует пониманию того, почему блок кода CIL может выглядеть несколько избыточным).
На заметку! Вспомните, что код CIL не выполняется напрямую, а компилируется по требованию. Во время компиляции кода CIL многие избыточные аспекты реализации оптимизируются. Более того, если для текущего проекта включена оптимизация кода (на вкладке Build (Сборка) окна свойств проекта в Visual Studio), то компилятор будет также удалять разнообразные избыточные детали CIL.
Чтобы понять, каким образом CIL задействует модель обработки на основе стека, создайте простой метод C# по имени
PrintMessage()
, который не принимает аргументов и возвращает
void
. Внутри его реализации будет просто выводиться значение локальной переменной в стандартный выходной поток:
void PrintMessage()
{
string myMessage = "Hello.";
Console.WriteLine(myMessage);
}
Если просмотреть код CIL, который получился в результате трансляции метода
PrintMessage()
компилятором С#, то первым делом обнаружится, что в нем определяется ячейка памяти для локальной переменной с помощью директивы
.locals
. Затем локальная строка загружается и сохраняется в этой локальной переменной с применением кодов операций
ldstr
(загрузить строку) и
stloc.0
(сохранить текущее значение в локальной переменной, находящейся в ячейке
0
).
Далее с помощью кода операции
ldloc.0
(загрузить локальный аргумент по индексу
0
) значение (по индексу 0) загружается в память для использования в вызове метода
System.Console.WriteLine()
, представленном кодом операции
call
. Наконец, посредством кода операции
ret
производится возвращение из функции. Ниже показан (прокомментированный) код CIL для метода
PrintMessage()
(ради краткости из листинга были удалены коды операций
nop
):
.method assembly hidebysig static void PrintMessage() cil managed
{
.maxstack 1
<b> // Определить локальную переменную типа string (по индексу 0).</b>
.locals init ([0] string V_0)
<b> // Загрузить в стек строку со значением "Hello."</b>
ldstr " Hello."
<b> // Сохранить строковое значение из стека в локальной переменной.</b>
stloc.0
<b> // Загрузить значение по индексу 0.</b>
ldloc.0
<b> // Вызвать метод с текущим значением.</b>
call void [System.Console]System.Console::WriteLine(string)
ret
}
На заметку! Как видите, язык CIL поддерживает синтаксис комментариев в виде двойной косой черты (и вдобавок синтаксис
/*...*/
). Подобно компилятору C# компилятор CIL игнорирует комментарии в коде.
Теперь, когда вы знаете основы директив, атрибутов и кодов операций CIL, давайте приступим к практическому программированию на CIL, начав с рассмотрения темы возвратного проектирования.
Возвратное проектирование
В главе 1 было показано, как применять утилиту
ildasm.exe
для просмотра кода CIL, сгенерированного компилятором С#. Тем не менее, вы можете даже не подозревать, что эта утилита позволяет сбрасывать код CIL, содержащийся внутри загруженной в нее сборки, во внешний файл. Полученный подобным образом код CIL можно редактировать и компилировать заново с помощью компилятора CIL (
ilasm.exe
).
Выражаясь формально, такой прием называется возвратным проектированием и может быть полезен в избранных обстоятельствах, которые перечислены ниже.
• Вам необходимо модифицировать сборку, исходный код которой больше не доступен.
• Вы работаете с далеким от идеала компилятором языка .NET Core, который генерирует неэффективный (или явно некорректный) код CIL, поэтому нужно изменять кодовую базу.
• Вы конструируете библиотеку взаимодействия с СОМ и хотите учесть ряд атрибутов COM IDL, которые были утрачены во время процесса преобразования (такие как COM-атрибут
[helpstring]
).
Чтобы ознакомиться с процессом возвратного проектирования, создайте новый проект консольного приложения .NET Core на языке C# по имени
RoundTrip
посредством интерфейса командной строки .NET Core (CLI):
dotnet new console -lang c# -n RoundTrip -o .\RoundTrip -f net5.0
Модифицируйте операторы верхнего уровня, как показано ниже:
<b>// Простое консольное приложение С#.</b>
Console.WriteLine("Hello CIL code!");
Console.ReadLine();
Скомпилируйте программу с применением интерфейса CLI:
dotnet build
На заметку! Вспомните из главы 1, что результатом компиляции всех сборок .NET Core (библиотек классов и консольных приложений) будут файлы с расширением
*.dll
, которые выполняются с применением интерфейса .NET Core CLI. Нововведением .NET Core 3+ (и последующих версий) является то, что файл
dotnet.exe
копируется в выходной каталог и переименовывается согласно имени сборки. Таким образом, хотя выглядит так, что ваш проект был скомпилирован в
RoundTrip.exe
, на самом деле он компилируется в
RoundTrip.dll
, а файл
dotnet.exe
копируется в
RoundTrip.exe
вместе с обязательными аргументами командной строки, необходимыми для запуска
Roundtrip.dll
.