Как работает callvirt под капотом?

Я пытаюсь понять, как CLR реализует ссылочные типы и полиморфизм. Я сослался на Essential .Net Vol. 1 от Don Box, который помогает справиться с большинством вещей. Но я застрял / смутился из-за следующей проблемы, когда попытался поиграться с некоторым кодом IL, чтобы лучше понять.

Я постараюсь объяснить проблему как можно лучше. Рассмотрим следующий код

class Base
{
    public void m()
    {
        Console.WriteLine("Base.m");
    }
}
class Derived : Base
{
    public void m()
    {
        Console.WriteLine("Derived.m");
    }
}

Теперь рассмотрим простое консольное приложение с IL основного метода, показанного ниже. Я настроил IL, созданный компилятором вручную, чтобы понять и снова собрать с ILAsm.exe

.class private auto ansi beforefieldinit Console1.Program
       extends [mscorlib]System.Object
{
    .method private hidebysig static void  Main(string[] args) cil managed
    {
      .entrypoint
      // Code size       44 (0x2c)
      .maxstack  1
      .locals init ([0] class Console1.Base d)
      nop
      newobj     instance void Console1.Base::.ctor()
      stloc.0
      ldloc.0
      callvirt   instance void Console1.Derived::m()
      nop
      call       string [mscorlib]System.Console::ReadLine()
      pop
      ret
    } // end of method Program::Main
} // end of class Console1.Program

Я ожидал, что этот код НЕ будет выполняться, так как ссылка на объект указывает на объект Base, и нет способа, чтобы таблица методов базового объекта имела запись для метода m(), определенного в классе Derived.

Но волшебным образом этот код выполняет Derived.m()!!

Итак, есть два вопроса, которые я не понимаю в приведенном выше коде:

  1. Какое значение имеет тип, указанный в приведенном ниже коде IL? Я попытался поэкспериментировать, изменив это на различные типы (например, System.Exception!!), и об ошибках не сообщается. Зачем??

    .locals init ([0] класс Console1.Base d)

  2. Как именно работает callvirt? Как звонок был перенаправлен на Derived.m()?

Заранее спасибо!!

С уважением, Ajay

5 ответов

Решение

Я думаю, что джиттер понимает, что Derived.m не является виртуальным и, следовательно, никогда не может указывать куда-либо еще. Итак callvirt сводится к нулевой проверке и вызову вместо вызова через v-таблицу.

Попробуйте сделать Derived.m виртуальная. Могу поспорить, что потом бросит

Компилятор C# испускает callvirt инструкции даже при вызове не виртуальных методов, если он не может доказать это this!=null так что он получает нулевую проверку. И в этом случае джиттер достаточно умен, чтобы заменить виртуальный вызов обычным вызовом с фиксированным адресом (или даже встроенным).

И вы должны проверить, если ваш код проверяемый. Я думаю, что нет.

Ваш код не поддается проверке (запустите его через peverify). Я написал сообщение в блоге о том, как callvirt работает скрытно, что может помочь вам понять, что он делает, и как выполняется ваш код.

Имейте в виду, что CLR пытается выполнить код, который невозможно проверить, если он запускается как обычная программа; только если это на самом деле вызывает проблемы, это не сработает.

В вашем примере звоните Derived.m() на примере Base работает, потому что фактическое двоичное представление экземпляров объекта одинаково; this Объект в основном такой же, и к полям экземпляров объектов нет доступа.

Попробуйте поместить доступ к полю экземпляра в оба метода и посмотрите, что произойдет...

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

Назначение типа в выражении locals состоит в том, чтобы объявить тип локальной переменной. Это предоставляет информацию, необходимую верификатору типа для проверки того, что доступ к элементам локальной переменной работает с объектом правильного типа.

Callvirt может быть реализовано несколькими способами. Наиболее вероятный способ заключается в том, как реализованы C++ vtables: объект содержит таблицу указателей на функции. Каждая функция расположена с заранее заданным смещением в таблице. Для вызова функции загружается и вызывается адрес с заранее заданным смещением. Обратите внимание, что в некоторых случаях CLR может выполнять дополнительную оптимизацию, если известен тип объекта. Сделано ли это, я не знаю.

Я думаю, что это побочный эффект оптимизации компилятора JIT. Если бы метод m() был виртуальным, он должен был бы сгенерировать машинный код, чтобы вытащить указатель таблицы методов из объекта, а затем сделать виртуальный вызов. Но этот метод не является виртуальным, и JIT-компилятор уже знает указатель таблицы методов для класса Derived. Таким образом, он обходит указатель извлечения и поставляет его напрямую. Звонок работает так, как вы заметили. Вы можете проверить мои предположения, проверив сгенерированный машинный код.

Да, верификатор IL здесь не набирает ни одного очка. Вы могли бы сделать это более интересным, если бы метод Derived.m() повозился с полем, которое объявлено только в Derived. Я видел слишком много сбоев кода Reflection.Emit с AccessViolation, чтобы сильно удивиться этому. Однако это вполне может быть преднамеренным, нет необходимости проверять IL, который все равно падает. Не уверен, что использование таких проверочных лазеек не является (пока) распространенным явлением. К счастью.

Для получения дополнительной информации о том, как это работает еще глубже, посмотрите этот вопрос / ответ StackExchange: Как работает инструкция callvirt .NET для интерфейсов?

Другие вопросы по тегам