Что происходит за кулисами С#: основы работы со стеком

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

    Дисклеймер


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

    Весь код, кроющийся за высокоуровневым, представлен для режима отладки, именно он показывают концептуальную основу. JIT оптимизации — это отдельная и большая тема, которая здесь рассматриваться не будет.

    Также хотелось бы предупредить, что данная статья не содержит материал, который стоит применять в реальных проектах.

    Начинаем с теории


    Любой код в конечном итоге становится набором машинных комманд. Наиболее понятно их представление в виде инструкций языка Ассемблера, прямо соответсвующих одной (или нескольким) машинным инструкциям.


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

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

    C#:

    public class StubClass 
    {
        public static int StubMethod(int fromEcx, int fromEdx, int fromStack) 
        {
            int local = 5;
            return local + fromEcx + fromEdx + fromStack;
        }
        
        public static void CallingMethod()
        {
            int local1 = 7, local2 = 8, local3 = 9;
            int result = StubMethod(local1, local2, local3);
        }
    }
    

    Asm:

    StubClass.StubMethod(Int32, Int32, Int32)
        1: push ebp
        2: mov ebp, esp
        3: sub esp, 0x10
        4: mov [ebp-0x4], ecx
        5: mov [ebp-0x8], edx
        6: xor edx, edx
        7: mov [ebp-0xc], edx
        8: xor edx, edx
        9: mov [ebp-0x10], edx
        10: nop
        11: mov dword [ebp-0xc], 0x5
        12: mov eax, [ebp-0xc]
        13: add eax, [ebp-0x4]
        14: add eax, [ebp-0x8]
        15: add eax, [ebp+0x8]
        16: mov [ebp-0x10], eax
        17: mov eax, [ebp-0x10]
        18: mov esp, ebp
        19: pop ebp
        20: ret 0x4
    
    StubClass.CallingMethod()
        1: push ebp
        2: mov ebp, esp
        3: sub esp, 0x14
        4: xor eax, eax
        5: mov [ebp-0x14], eax
        6: xor edx, edx
        7: mov [ebp-0xc], edx
        8: xor edx, edx
        9: mov [ebp-0x8], edx
        10: xor edx, edx
        11: mov [ebp-0x4], edx
        12: xor edx, edx
        13: mov [ebp-0x10], edx
        14: nop
        15: mov dword [ebp-0x4], 0x7
        16: mov dword [ebp-0x8], 0x8
        17: mov dword [ebp-0xc], 0x9
        18: push dword [ebp-0xc]
        19: mov ecx, [ebp-0x4]
        20: mov edx, [ebp-0x8]
        21: call StubClass.StubMethod(Int32, Int32, Int32)
        22: mov [ebp-0x14], eax
        23: mov eax, [ebp-0x14]
        24: mov [ebp-0x10], eax
        25: nop
        26: mov esp, ebp
        27: pop ebp
        28: ret
    

    Первое, на что следует обратить внимание — это регистры EBP и ESP и операции с ними.

    Среди моих знакомых распространено заблуждение о том, что регистр EBP как-то связан с указателем на вершину стека. Сразу скажу, что это не так.

    За указатель на вершину стека отвечает регистр ESP. Соответсвенно при каждой комманде PUSH (заносит значение на верхушку стека) значение этого регистра декрементируется (стек растет в сторону меньших адресов), а при каждой операции POP инкрементируется. Также команда CALL заносит адрес возврата на стек, тем самым также декрементируя значение регистра ESP. На самом деле, изменение регистра ESP выполняется не только при выполнении этих инструкций (например еще при вызовах прерываний происходит аналогичное с тем, что происходит при выполнении инструкций CALL).

    Рассмотрим StubMethod.

    В первой строке содержимое регистра EBP сохраняется (кладется на стек). Перед возвращением из функции это значение будет восстановлено.

    Во второй строке выполняется запоминание текущего значения адреса верхушки стека (Значение регистра ESP заносится в EBP). При этом регистр EBP является своеобразным нулем в констексте текущего вызова. Адресация выполняется относительно него. Далее мы передвигаем верхушку стека на такое число позиций, какое понадобится нам для хранения локальных переменных и параметров (третья строка). Что-то вроде выделения памяти под все локальные нужды.

    Все вышесказанное называется прологом функции.

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

    Напоминалка про fastcall: в родном .net используется соглашении о вызове fastcall.
    Соглашение регламентирует расположение и порядок параметров, передаеваемых в функцию.
    При fastcall первый и второй параметры передаются соответсвенно через регистры ECX и EDX, последующие параметры передаются через стек.

    Для нестатических методов первый параметр является неявным и содержит адрес обьекта, на котором вызывается метод (адрес this).

    В строках 4 и 5 параметры, которые передавались через регистры (первые 2) сохраняются на стек.

    Далее идет чистка места на стеке под локальные переменные и инициализация локальных переменных.

    Стоит напомнить, что результат функции находится в регистре EAX.

    В строках 12-16 происходит сложение нужных переменных. Обращаю ваше внимание на строку 15. Идет обращение по адресу, больше чем начало стека, то есть к стеку предыдущего метода. Перед вызовом вызывающий метод пушит на вершину стека параметр. Здесь мы его считываем. Результат сложения достается из регистра EAX и помещается на стек. Так как это и есть возвращаемое значение StubMethod, он помещается снова в EAX. Разумеется, такие абсурдные наборы инструкций присущи лишь режиму отладки, но они показывают, как именно выглядит наш код без умного оптимизатора, выполняющего львиную долю работы.

    В строках 18 и 19 происходит восстановление предыдущего EBP (вызывающего метода) и указателя на верщину стека (на момент вызова метода).

    В последней строке происходит возврат. Про значение 0х4 я расскажу чуть ниже.
    Такая последовательность команд называется эпилогом функции.

    Теперь давайте взглянем на CallingMethod. Перейдем сразу к строке 18. Здесь мы помещаем третий параметр на вершину стека. Прошу обратить внимание, что делаем мы это используя инструкцию PUSH, то есть значение ESP декрементируется. Другие 2 параметра помещаются в регистры (fastcall). Далее идет вызов метода StubMethod. А теперь вспомним инструкцию RET 0x4. Здесь возможен следующий вопрос: что есть 0х4? Как я упомянул выше, мы запушили параметры вызываемой функци на стек. Но теперь они нам не нужны. 0х4 показывает, соклько байт нужно очистить со стека после вызова функции. Так как параметр был один, нужно очистить 4 байта.

    Вот примерное изображение стека:



    Таким образом, если мы обернемся и посмотрим, что же лежит сзади на стеке, сразу после вызова метода, первое, что мы увидим — пушнутый на стек EBP (фактически, это произошло первой строкой текущего метода). Далее будет лежать адрес возврата, куда ляжет результат функции. А через эти поля мы увидим сами парметры текущей функции (Начиная с 3го, параметры до этого передаются через регистры). А за ними прячется сам стек вызывающего метода!
    Упомянутые первое и второе поле объясняют смещение в +0х8 при обращении к параметрам.
    Соответсвенно, параметры должны лежать вверху стека в строго определенном порядке при вызове функции. Поэтому перед вызовом метода каждый параметр пушится на стек.
    Но что, если их не пушить, а функция все еще будет их принимать?

    Небольшой пример


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

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

    При обращении к параметрам, вычисление адреса того или иного параметра базируется лишь на том факте, что вызывающий метод зпушил их всех на стек.

    Но неявная передача через EDX параметра (кому интересно — прошлая статья) наводит на мысль, что мы можем перехитрить компилятор в некоторыз случаях.

    Инструмент, которым я это сделал, называется StructLayoutAttribute (фичи в первой статье). //Когда-нибудь я освою что-нибудь кроме этого атрибута, обещаю.

    Используем все тот же излюбленный мною прием с ссылочными типами.

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

    Но там он их не находит и начинает читать стек вызвающего метода.

    Код в спойлере
    using System;
    using System.Runtime.InteropServices;
    
    namespace Magic
    {
        public class StubClass
        {
            public StubClass(int id)
            {
                Id = id;
            }
    
            public int Id;
        }
    
        [StructLayout(LayoutKind.Explicit)]
        public class CustomStructWithLayout
        {
            [FieldOffset(0)]
            public Test1 Test1;
            [FieldOffset(0)]
            public Test2 Test2;
        }
        public class Test1
        {
            public virtual void Useless(int skipFastcall1, int skipFastcall2, StubClass adressOnStack)
            {
                adressOnStack.Id = 189;
            }
        }
        public class Test2
        {
            public virtual int Useless()
            {
                return 888;
            }
        }
    
        class Program
        {
            static void Main()
            {
                Test2 objectWithLayout = new CustomStructWithLayout
                {
                    Test2 = new Test2(),
                    Test1 = new Test1()
                }.Test2;
                StubClass adressOnStack = new StubClass(3);
                objectWithLayout.Useless();
                Console.WriteLine($"MAGIC - {adressOnStack.Id}"); // MAGIC - 189
            }
        }
    }
    


    Приводить код языка Ассемблера не стану, там все довольно понятно, но если возникнут вопросы, постараюсь ответить на них в комментариях

    Я прекрасно понимаю, что данный пример невозможно использовать на практике, однако на мой взгляд, для понимания общей схемы работы он может быть весьма полезен.
    • +22
    • 8,8k
    • 9
    Поделиться публикацией
    Комментарии 9
      0
      Забавно. А с практической точки зрения? Вылизывать производительность в узких местах или просто для фана?
        +1
        Пример чисто для фана. Думал о его применимости (возможно, для повышения привилегий, где вызов стандартных функций принимает интерфейс/делегат), но не нашел. Да и не особо искал, если честно, не ставил это целью. Ну а еще люблю понимать, как оно все там происходит.
        +2
        Однако… можно было бы до уровня IL расковырять (в том же linqpad) и остановиться, но нет… we need to go deeper! ))
          0
          Это в смысле visual studio позволяет дизассемблированный листинг просмотреть, или собственно откуда взят ассемблерный код?
          Просто с IL кодом как бы все понятно, а вот прям столь низко…
            0

            В первых своих исследованиях я использовал winDbg, но там надо учитывать, что для начала надо дождаться, пока функция вызовется хоть раз (до этого JIT ее просто не скомпелит) или предкомпелить. Разумеется, windbg очень мощный, но для таких примеров слишком громоздкий.
            И не так давно один добрый человек мне подсказал сайт https://sharplab.io. Я сверял с windbg (к нему было доверие), все совпадает. Так что для небольших примеров пользуюсь им.

              0
              За ссылку спасибо! Ресурс полезный.
            0
            Тут генерируется asm 32-битный, не 64, я думал должны быть 64 битные регистры? Хотя может потому что операнды Int32.
              0
              Как я выше упоминал, я использовал тот ресурс для получения кода Ассемблера. А они видимо решили использовать 32 бита. В этом плане я даже и согласен с ними. Это классика — 32 бита на ссылку и прочее.
              Когда я использовал windbg я наблюдал 64 битный код.
              Как я знаю, от Int32 он не зависит, как известно, int — просто элиас для Int32. А число 32 в названии Int32 означает, что мы используем 32 бита для представления числа. Ведь Int64 (он же long) доступен не только на 64 битной платформе. Мы, как разработчики, не должны об этом знать. Об этом должна заботиться среда, а мы просто работаем с 32 или 64 битным числом. И т.к. мы кроссплатформенны, это не наше дело, свалим это на плечи JIT'a
              0

              Не уловил, что в статье C#-специфичное; скорее описана общая для большинства компилируемых языков концепция. Аудитория читающих могла бы быть шире.

              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

              Самое читаемое