Age of JIT compiling. Part I. Genesis

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

    Ну что ж, исправим это!

    Узнаем причины отсутствия наследования у структур, природу unbound delegates.

    А еще… вызов любых методов у любых объектов без reflection.

    Genesis of Value-types


    Структуры в .NET являются с одной стороны структурами в классическом понимании данного слова (layout, mutability и т.д.), с другой стороны имеют поддержку ООП и среды .NET в принципе (методы ToString, GetHashCode; наследование от System.ValueType, который в свою очередь от System.Object; и т.д.).

    Чтобы лучше понять почему структуры нельзя наследовать от других типов, необходимо перейти на уровень организации методов в CLR.

    Instance-level методы имеют неявный аргумент this. На самом деле он явный. JIT, компилируя код, создает сигнатуру следующего вида:

    ReturnType MethodName(Type this, …arguments…)

    Но это для ссылочных типов.

    Для значимых:

    ReturnType MethodName(ref Type this, …arguments…)

    Да-да! Сделано это для поддержки изменяемости структур, т.е. чтобы мы могли модифицировать this.

    Так почему же нельзя наследовать структуры от других типов?

    Ответим на вопрос: а если это будет виртуальный метод базового ссылочного класса? Как быть JIT-компилятору? Никак. Постоянно угадывать и генерировать различные специализации кода (с семантикой byval и byref), кроме еще и диспетчеризации таблицы виртуальных методов – неэффективно. Добавляется и boxing, чтобы правильно обслужить виртуальный метод.

    Но… Методы ToString, GetHashCode, Equals являются виртуальными методами ссылочного класса-предка System.Object ?!

    Это исключения. JIT знает об этом и генерирует привязку и специализацию только для этих методов.

    Unbound Delegates


    Reflection в .NET позволяет нам создать делегат как на статические методы, так и на экземпляров.
    Однако есть небольшая проблема – для экземпляров необходимо создавать делегату по новому.

    Рассмотрим пример:
    class Program
    {
        static void Main(string[] args)
        {
            var calc = new Calc() { FirstOperand = 2 };
    
            var addMethodInfo = typeof(Calc).GetMethod("Add", 
                                                BindingFlags.Public | BindingFlags.Instance);
    
            var addDelegate = (Func<int, int>)Delegate.CreateDelegate(
                                                            typeof(Func<int, int>), 
                                                            calc, 
                                                            addMethodInfo);
    
            Console.WriteLine(addDelegate(2)); // 4
        }
    }
    
    class Calc
    {
        public int FirstOperand = 0;
    
        public int Add(int secondOperand)
        {
            return FirstOperand + secondOperand;
        }
    }
    


    На помощь приходят unbound delegates, т.е. непривязанные. Однако у них есть одна особенность: иная сигнатура, где добавляется (да, Вы правильно догадались) первый аргумент – ссылка на экземпляр.

    Т.е. unbound delegates – это и есть ссылки на “реальный” метод.

    Так, сигнатура Add(int secondOperand) превратиться в Add(Calc this, int secondOperand).

    Проверим:
    class Program
    {
        static void Main(string[] args)
        {
            var addMethodInfo = typeof(Calc).GetMethod("Add", 
                                                BindingFlags.Public | BindingFlags.Instance);
    
            var addDelegate = (Func<Calc, int, int>)Delegate.CreateDelegate(
                                                            typeof(Func<Calc, int, int>), 
                                                            null, 
                                                            addMethodInfo);
    
            Console.WriteLine(addDelegate(new Calc(), 2)); // 2
        }
    }
    
    class Calc
    {
        public int FirstOperand = 0;
    
        public int Add(int secondOperand)
        {
            return FirstOperand + secondOperand;
        }
    }
    


    Помните вопрос про сигнатуры методов структур? Объявите тип Calc как struct и запустите. ArgumentException? Да?

    Нам нужно передать в Func<Calc,int,int> аргумент this byref, но как?!

    Объявим свой делегат FuncByRef
    delegate TResult FuncByRef<T1, in T2, out TResult>(ref T1 arg1, T2 arg2);
    


    Изменим код:
    class Program
    {
        delegate TResult FuncByRef<T1, in T2, out TResult>(ref T1 arg1, T2 arg2);
    
        static void Main(string[] args)
        {
            var addMethodInfo = typeof(Calc).GetMethod("Add", 
                                                BindingFlags.Public | BindingFlags.Instance);
    
            var addDelegate = (FuncByRef<Calc, int, int>)Delegate.CreateDelegate(
                                                            typeof(FuncByRef<Calc, int, int>),
                                                            null, 
                                                            addMethodInfo);
            var calc = new Calc();
            calc.FirstOperand = 123;
    
            Console.WriteLine(addDelegate(ref calc, 2)); // 125
        }
    }
    
    struct Calc
    {
        public int FirstOperand;
    
        public int Add(int secondOperand)
        {
            return FirstOperand + secondOperand;
        }
    }
    


    Verification evasion


    Рассмотрим простое приложение:
    
    class Program
    {
        static void Main(string[] args)
        {
            CallTest(new object());
            CallTestWithExlicitCasting(new object());
            Console.Read();
        }
    
        static void CallTest(object target)
        {
            Program p = target as Program;
            p.Test();
        }
    
        static void CallTestWithExlicitCasting(object target)
        {
            Program p = (Program)target;
            p.Test();
        }
    
        public void Test()
        {
            Console.WriteLine("Test");
        }
    }
    

    Как можно заметить, приложение упадет с NullReferenceException при вызове CallTest().

    Что ж, исправим данную ситуацию. Для этого запустим ildasm.

    Visual Studio Command Promt -> ildasm



    Далее File -> Dump -> Save as dialog -> msiltricks_patch.il

    Открываем сохраненный файл msiltricks_patch.il в любимом редакторе и на ходим тело метода CallTest:
    .method private hidebysig static void  CallTest(object target) cil managed
    {
      // Code size       14 (0xe)
      .maxstack  1
      .locals init ([0] class MSILTricks.Program p)
      IL_0000:  ldarg.0
      IL_0001:  isinst     MSILTricks.Program
      IL_0006:  stloc.0
      IL_0007:  ldloc.0
      IL_0008:  callvirt   instance void MSILTricks.Program::Test()
      IL_000d:  ret
    } // end of method Program::CallTest
    

    Удалим сроку IL_0001: isinst MSILTricks.Program, т.е. вызов оп-кода isinst (он же оператор as в C#).

    Проделываем то же самое и с методом CallTestWithExlicitCasting:
    .method private hidebysig static void  CallTestWithExlicitCasting(object target) cil managed
    {
      // Code size       14 (0xe)
      .maxstack  1
      .locals init ([0] class MSILTricks.Program p)
      IL_0000:  ldarg.0
      IL_0001:  castclass  MSILTricks.Program
      IL_0006:  stloc.0
      IL_0007:  ldloc.0
      IL_0008:  callvirt   instance void MSILTricks.Program::Test()
      IL_000d:  ret
    } // end of method Program::CallTestWithExlicitCasting
    

    Удалим сроку IL_0001: castclass MSILTricks.Program, т.е. вызов оп-кода castclass (он же оператор явного приведения в C#).

    Visual Studio Command Promt -> cd [your saved file dir]
    Visual Studio Command Promt -> ilasm msiltricks_patch.il

    Запустим msiltricks_patch.exe

    Ни одного исключения, даже AccessViolationException.
    Ха-ха!

    Дело в том, что наш метод Test не имеет побочных эффектов, а также не использует this в своем теле.

    Вывод: мы с Вами работаем с “железом” и переменные ссылочных типов являются просто адресами в памяти, т.е. DWORD; приведение типов и т.д. являются не более чем абстракцией и “защитой” на этапе компиляции. Центральный процессор работает именно с адресами в памяти. CLR предоставляет эти адреса, JIT компилирует код, учитывая их.

    Ваш КО :)

    И, да, инструкция callvirt не проверяет на “правильность” объекта.
    Чтобы получить AccessViolationException, можно добавить, например, виртуальный метод в класс Program и вызвать его в методе Test.
    Share post

    Similar posts

    Comments 18

      0
      Можно проще:

      [StructLayout(LayoutKind.Explicit)]
      struct Magic
      {
          [FieldOffset(0)]
          public object Obj;
      
          [FieldOffset(0)]
          public string Str;
      }
      
      static string ItIsString(object obj)
      {
          Magic m = new Magic();
          m.Obj = obj;
          return m.Str;
      }
      
      static void Main(string[] args)
      {
          Console.WriteLine(ItIsString(10).Length); // 10
          Console.WriteLine(ItIsString((long)'p' << 32 | 1)[0]); // p
      }
      
        +1
        ну, это известный пример :)
          +2
          Интересно, при получении где-то ссылки на такую «строку», не поплохеет ли потом GC.
            +1
            Вопрос, я думаю, открытый. Нужно исследовать )
              0
              GC абсолютно фиолетово, какие объекты куда передаются. Поплохеет, когда произойдет вызов метода.
        0
        Я раньше не слышал о такой штуке
        Для значимых ReturnType MethodName(ref Type this, …arguments…)
        Сделано это для поддержки изменяемости структур, т.е. чтобы мы могли модифицировать this.


        Можно ли привести пример кода на c#, который бы использовал эту особенность?
          +2
          Значимые типы при передаче в аргументах методах копируются по значению.
          struct Foo
          {
              public int Bar = 0;
          }
          
          void FooBar1(Foo foo, int bar)
          {
              // Работаем с копией
              foo.Bar = bar;
          }
          
          void FooBar2(ref Foo foo, int bar)
          {
              // Работаем с самим переданным объектом
              foo.Bar = bar;
          }
          
          void Main()
          {
              var foo = new Foo();
              Console.WriteLine(foo.Bar); // Выведет 0
              
              // Передали копию foo
              FooBar1(foo, 1);
              Console.WriteLine(foo.Bar); // Выведет 0
          
              // Передали адрес foo
              FooBar2(ref foo, 2);
              Console.WriteLine(foo.Bar); // Выведет 2
          }
            0
            Этот пример совсем на другую тему — он иллюстрирует копирование структуры при передаче в качестве аргумента метода. В нём нет вызова метода самой структуры.
            Мне интересен пример, когда вызывается метод у структуры, модифицирующий указатель this.
              0
              нет-нет! именно указатель не модифицируется. просто при передаче byval (как с сылками) структура бы копировалась, и соответственно, после вызова instance-метода структуры — эффект нулевой.

              это сделано для того, чтобы JIT не создавал дополнительные трамплины и избежать boxing'a (если рассматривать стратегию реализации).

              Можно ли привести пример кода на c#, который бы использовал эту особенность?

              В C# ничего заметить нельзя, т.к. первый аргумент всегда опускается на уровне сигнатур методов для всех instance-методов.

              А так, в статье я уже привел пример с unbound delegates для структур.
                0
                На уровне x86 никакой разницы. Т.е. в регистр ecx во всех случаях передаётся this.

                Скрытый текст
                namespace ConsoleApplication1
                {
                    struct MyStruct
                    {
                        public int x;
                        public int GetX() { return x; }
                    }
                
                    class MyClass
                    {
                        public int x;
                        public int GetX() { return x; }
                    }
                
                    class Program
                    {
                        static void Main(string[] args)
                        {
                            var s = new MyStruct();
                            s.GetX();
                
                            var c = new MyClass();
                            c.GetX();
                        }
                    }
                }

                Код GetX() идентичный для структуры и класса (различие только в том, что в классе смещение поля +4, в структуре +0):

                  +1
                  Ну тогда для чистоты эксперимента:
                  Ваш изменный код c MethodImplOptions.NoInlining
                  namespace ConsoleApplication1
                  {
                      struct MyStruct
                      {
                          public int x;
                  
                          [MethodImpl(MethodImplOptions.NoInlining)]
                          public int GetX()
                          {
                              return x;
                          }
                      }
                  
                      class MyClass
                      {
                          public int x;
                  
                          [MethodImpl(MethodImplOptions.NoInlining)]
                          public int GetX()
                          {
                              return x;
                          }
                      }
                  
                      class Program
                      {
                          unsafe static void Main(string[] args)
                          {
                              var s = new MyStruct();
                              s.GetX(); // breakpoint
                  
                              var c = new MyClass();
                              c.GetX(); // breakpoint
                          }
                      }
                  }
                  


                  Получается слудущий код:
                              var s = new MyStruct();
                  00000000  push        ebp 
                  00000001  mov         ebp,esp 
                  00000003  push        eax 
                  00000004  xor         eax,eax 
                  00000006  mov         dword ptr [ebp-4],eax 
                  00000009  xor         edx,edx 
                  0000000b  mov         dword ptr [ebp-4],edx 
                              s.GetX(); // breakpoint
                  0000000e  lea         ecx,[ebp-4] 
                  00000011  call        dword ptr ds:[00193828h] 
                  
                              var c = new MyClass();
                  00000017  mov         ecx,1938A0h 
                  0000001c  call        FFF820B0 
                  00000021  mov         dword ptr [eax+4],7Bh 
                              c.GetX(); // breakpoint
                  00000028  mov         ecx,eax 
                  0000002a  call        dword ptr ds:[00193894h] 
                  00000030  mov         esp,ebp 
                          }
                  00000032  pop         ebp 
                  00000033  ret 
                  


                  Не смущает строка
                  0000000e lea ecx,[ebp-4] ??? а это есть не что иное как загрузка адреса MyStruct s

                  p.s.
                  вижу, что товарищ kekekeks уже ответил и Вам стало понятно
            +3
            var point = new System.Drawing.Point();
            point.Offset(100, 500);


            Соответственно Offset имеет сигнатуру void (ref Point this, int dx, int dy)

            Ну и все сеттеры свойств структур, да.
              0
              Ага, теперь понял. Просто синтаксически компилятор обязан копировать структуру, если встречает (Point this), речь не идёт о передаче ссылки на указатель this.
            0
            И, да, инструкция callvirt не проверяет на “правильность” объекта.

            А можно поподробнее? Насколько я знаю, как раз-таки проверяет, и как раз-таки поэтому её используют для вызова методов, даже если они не являются виртуальными. Извиняюсь за многабукв, но для полноты информации процитирую всё:

            We can use a similar dispatch sequence to call non-virtual methods as well. However, for non-virtual methods,
            there is no need to use the method table for method dispatch: the code address of the invoked method (or at least
            its pre-JIT stub) is known when the JIT compiles the method dispatch. For example, if the stack location EBP-64
            contains the address of an Employeeobject, as before, then the following instruction sequence will call the
            TakeVacationmethod with the parameter 5:

            mov edx, 5 ;parameter passing through register – custom calling convention
            mov ecx, dword ptr [ebp-64] ;still required because ECX contains ‘this’ by convention
            call dword ptr [0x004a1260]

            It is still required to load the object’s address into the ECXregister – all instance methods expect to receive
            in ECXthe implicit thisparameter. However, there’s no longer any need to dereference the method table pointer
            and obtain the address from the method table. The JIT compiler still needs to be able to update the call site after
            performing the call; this is obtained by performing an indirect call through a memory location (0x004a1260in
            this example) that initially points to the pre-JIT stub and is updated by the JIT compiler as soon as the method is
            compiled.
            Unfortunately, the method dispatch sequence above suffers from a significant problem. It allows method
            calls on null object references to be dispatched successfully and possibly remain undetected until the instance
            method attempts to access an instance field or a virtual method, which would cause an access violation. In fact,
            this is the behavior for C++ instance method calls – the following code would execute unharmed in most C++
            environments, but would certainly make C# developers shift uneasily in their chairs:
            class Employee {
            public: void Work() { } //empty non-virtual method
            };
            Employee* pEmployee = NULL;
            pEmployee->Work(); //runs to completion

            If you inspect the actual sequence used by the JIT compiler to invoke non-virtual instance methods, it would
            contain an additional instruction:
            
            mov edx, 5 ;parameter passing through register – custom calling convention
            mov ecx, dword ptr [ebp-64] ;still required because ECX contains ‘this’ by convention
            cmp ecx, dword ptr [ecx]
            call dword ptr [0x004a1260]

            Recall that the CMPinstruction subtracts its second operand from the first and sets CPU flags according to the
            result of the operation. The code above does not use the comparison result stored in the CPU flags, so how would
            the CMPinstruction help prevent calling a method using a null object reference? Well, the CMPinstruction attempts
            to access the memory address in the ECXregister, which contains the object reference. If the object reference is
            null, this memory access would fail with an access violation, because accessing the address 0 is always illegal in
            Windows processes. This access violation is converted by the CLR to a NullReferenceExceptionwhich is thrown
            at the invocation point; a much better choice than emitting a null check inside the method after it has already
            been called. Furthermore, the CMPinstruction occupies only two bytes in memory, and has the advantage of
            being able to check for invalid addresses other than null.
              +1
              Имеется ввиду что callvirt не проверяет тип объекта.
                0
                ok, однако товарищ a553 успел ответить :)
                +2
                Приятно видеть что в потрохах кто-то еще ковыряется :))
                  +1
                  кстати, я так получаю адрес объекта и по адресу получаю .NET ссылку на объект: кладу на стек число, достаю ссылку нужного типа

                Only users with full accounts can post comments. Log in, please.