Хабрахабр

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

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

Дисклеймер

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

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

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

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

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

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

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

C#:

public class StubClass 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 как-то связан с указателем на вершину стека.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Но неявная передача через 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 } }
}

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

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть