Предлагаю посмотреть все то, что стоит за простыми строками инициализации объектов, вызова методов и передачи параметров. Ну и, разумеется, использование этих сведений на практике — вычитывание стека вызывающего метода.
Дисклеймер
Прежде, чем приступить к повествованию, настоятельно рекомендую ознакомиться с первым постом про 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
}
}
}
Приводить код языка Ассемблера не стану, там все довольно понятно, но если возникнут вопросы, постараюсь ответить на них в комментариях
Я прекрасно понимаю, что данный пример невозможно использовать на практике, однако на мой взгляд, для понимания общей схемы работы он может быть весьма полезен.
Автор: ZloyChert