C#幕后发生的事情:使用堆栈的基础知识

我建议研究一下初始化对象,调用方法和传递参数的简单过程背后的内部结构。 而且,当然,我们将在实践中使用此信息-我们将减去调用方法的堆栈。

免责声明


在继续讲故事之前,我强烈建议您阅读有关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 

首先要注意的是EBPESP寄存器及其操作。

在我的朋友中,人们普遍误认为EBP寄存器与指向堆栈顶部的指针有某种联系。 我必须说不是。

ESP寄存器负责指向栈顶。 相应地,每条PUSH指令(在栈顶放一个值), ESP寄存器的值都会递减(栈朝着较小的地址增长),而每条POP指令都会递增。 同样, CALL命令将返回地址压入堆栈,从而减小ESP寄存器的值。 实际上, ESP寄存器的更改不仅在执行这些指令时执行(例如,在进行中断调用时, CALL指令也会发生同样的事情)。

将考虑StubMethod()

在第一行中,保存了EBP寄存器的内容(将其放在堆栈中)。 从函数返回之前,将恢复该值。

第二行存储堆栈顶部地址的当前值(寄存器ESP的值移到EBP )。 接下来,我们将堆栈的顶部移动到需要存储本地变量和参数的位置(第三行)。 诸如为所有本地需求的内存分配之类的堆栈帧 。 同时, EBP寄存器是当前调用上下文中的起点。 寻址基于此值。

以上所有内容都称为函数序言

之后,通过存储的EBP寄存器访问堆栈上的变量,该寄存器指向该方法的变量开始的位置。 接下来是局部变量的初始化。

Fastcall提醒:在.net中,使用fastcall调用约定。
调用约定控制传递给函数的参数的位置和顺序。
第一个和第二个参数分别通过ECXEDX寄存器传递,后续的参数通过堆栈传递。 (与往常一样,这适用于32位系统。在64位系统中,四个参数通过寄存器( RCXRDXR8R9 )传递)

对于非静态方法,第一个参数是隐式的,并且包含在其上调用该方法的实例的地址(此地址)。

在第4和第5行中,通过寄存器(前2个)传递的参数存储在堆栈中。

接下来是清理堆栈上用于局部变量的空间( 堆栈框架 )并初始化局部变量。

值得一提的是,函数的结果在寄存器EAX中

在第12-16行中,发生了所需变量的添加。 我提请您注意第15行。该地址有一个访问值,该值大于堆栈的开头,即前一个方法的堆栈。 在调用之前,调用者将参数压入堆栈的顶部。 在这里,我们阅读它。 相加的结果是从寄存器EAX获得的,并放在堆栈上。 由于这是StubMethod()的返回值,因此将其再次放置在EAX中 。 当然,这种荒谬的指令集仅在调试模式下才是固有的,但它们准确地显示了没有智能优化器来完成大部分工作的代码的样子。

在第18和19行中,恢复了先前的EBP (调用方法)和指向堆栈顶部的指针(在调用该方法时)。 最后一行是从函数返回。 关于值0x4,我稍后再讲。

这样的命令序列称为功能结尾。

现在让我们看一下CallingMethod() 。 让我们直接转到第18行。在这里,我们将第三个参数放在堆栈的顶部。 请注意,我们使用PUSH指令执行此操作,即ESP值递减。 另外两个参数被放入寄存器( fastcall )。 接下来是StubMethod()方法调用。 现在,让我们记住RET 0x4指令。 这里可能出现以下问题:0x4是什么? 如前所述,我们已经将被调用函数的参数压入堆栈。 但是现在我们不需要它们了。 0x4表示函数调用后需要从堆栈中清除多少字节。 由于该参数为1,因此需要清除4个字节。

这是堆栈的粗略图像:



因此,如果我们转过头来看看在方法调用之后堆栈上的内容,我们将首先看到EBP ,它被推到堆栈上(实际上,这发生在当前方法的第一行中)。 接下来是寄信人地址。 它确定了我们的函数完成后(在RET处使用 )在哪里继续执行的位置。 在这些字段之后,我们将看到当前函数的参数(从第3个开始,前两个参数通过寄存器传递)。 在它们后面隐藏着调用方法的堆栈!

前面提到的第一个字段和第二个字段( EBP和返回地址)说明了我们访问参数时+ 0x8的偏移量。

相应地,在调用函数之前,参数必须以严格定义的顺序位于堆栈的顶部。 因此,在调用该方法之前,每个参数都被压入堆栈。
但是,如果它们不推动,该函数仍将它们接住怎么办?

小例子


因此,以上所有事实使我对读取将调用我的方法的方法的堆栈有强烈的渴望。 我只在第三个参数中处于一个位置(它将最接近调用方法的堆栈)的想法是我想要收到的如此珍贵的数据,却让我无法入睡。

因此,要读取调用方法的堆栈,我需要比参数更进一步。

当引用参数时,特定参数地址的计算仅基于调用者已将它们全部压入堆栈的事实。

但是隐式传递EDX参数(感兴趣的人- 上一篇文章 )使我认为在某些情况下我们可以超越编译器。

我用来执行此操作的工具称为StructLayoutAttribute(其他功能在第一篇文章中 )。 //我承诺,有一天,我将学到的不仅仅是这个属性

我们对重叠的引用类型使用相同的收藏夹方法。

同时,如果重叠的方法具有不同数量的参数,则编译器不会将所需的参数压入堆栈(至少是因为它不知道哪些参数)。
但是,实际上被调用的方法(与另一种类型具有相同的偏移量)相对于其堆栈(即计划查找参数的堆栈)变为正地址。

但是没有人传递参数,方法开始读取调用方法的堆栈。 并且对象的地址(具有Id属性,在WriteLine()中使用 )位于期望第三个参数的位置。

代码在剧透中
 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 } } } 


我不会提供汇编语言代码,那里的所有内容都很清楚,但是如果有任何问题,我会尝试在注释中回答

我完全理解该示例无法在实践中使用,但是我认为,它对于理解总体工作方案非常有用。

Source: https://habr.com/ru/post/zh-CN447274/


All Articles