Использование стека в CIL

98

В языках .NET более высокого уровня (вроде C#) низкоуровневые детали CIL обычно насколько возможно скрываются из виду. Одной из особенно хорошо скрываемых деталей является тот факт, что CIL на самом деле является языком программирования, основанным на использовании стека. В C# существует класс Stack<T>, который может применяться для помещения значения в стек, а также извлечения самого верхнего значения из стека для последующего использования. Конечно, разработчики на CIL не используют в буквальном смысле объект типа Stack<T> для загрузки и выгрузки вычисляемых значений, но применяют стиль помещения и извлечения из стека.

Формально сущность, используемая для хранения набора вычисляемых значений, называется виртуальным стеком выполнения (virtual execution stack). B CIL поддерживается набор таких кодов операций, которые могут применяться для помещения значения в стек; процесс помещения значения в стек называется загрузкой. Также в CIL поддерживаются дополнительные коды операций, служащих для перемещения самого верхнего значения из стека в память (например, в локальную переменную); процесс перемещения значения из стека в память называется сохранением.

В мире CIL получать доступ к элементам данных, в том числе к определенным локально переменным, входным аргументам методов и данным полей типа, не разрешено. Вместо этого требуется явно загружать их в стек и затем извлекать их оттуда для последующего использования (очень важно запомнить это, поскольку именно оно позволит понять, почему некоторые блоки кода CIL выглядят несколько громоздко).

Вспомните, что CIL-код не выполняется напрямую, а компилируется по требованию. Во время компиляции CIL-кода многие излишние детали реализации оптимизируются. Более того, если включена опция оптимизации кода для текущего проекта (на вкладке Build (Сборка) окна свойств проекта в Visual Studio), компилятор будет еще и удалять излишние детали CIL.

Чтобы увидеть, каким образом в CIL функционирует основанная на использовании стека модель обработки, давайте создадим на C# простой метод PrintMessage(), не принимающий аргументов и возвращающий void, и предусмотрим в его реализации вывод в стандартный поток значения локальной переменной:

public void PrintMessage ()
{
  string myMessage = "Hello, world";
  Console.WriteLine(myMessage) ;
}

CIL-код, в который компилятор C# преобразовал этот метод, содержит в методе PrintMessage() директиву .locals, определяющую ячейку для хранения локальной переменной. Локальная строка затем загружается и сохраняется в этой локальной переменной с использованием кодов операций ldstr (загрузка строки) и stloc.O (сохранение текущего значения в локальной переменной в ячейке 0).

После этого значение (по индексу 0) загружается в память с помощью кода операции ldloc.O (загрузка локального аргумента с индексом 0) для использования в вызове метода System.Console.WriteLine() (представленного кодом операции call). И, наконец, код операции ret обеспечивает возврат из функции. Ниже показан полный CIL-код, который компилятор C# генерирует для метода PrintMessage():

CIL-код метода

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

Лучший чат для C# программистов