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

我建议您查看初始化对象,调用方法和传递参数的简单代码背后的所有内容。 好吧,当然,在实践中使用此信息是减去调用方法的堆栈。

免责声明


在开始讲故事之前,我强烈建议您阅读有关StructLayout的第一篇文章,因为 本文将使用一个示例。

更高级别的所有代码都在调试模式下提供,这是显示概念基础的地方。 此外,以上所有内容均考虑用于32位平台。 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命令 (它将值放置在堆栈的顶部),该寄存器的值将递减(堆栈向低位地址增长),并且对于每个POP操作,它都会递增。 CALL命令还将返回地址压入堆栈,从而也减小了ESP寄存器的值。 实际上,更改ESP寄存器不仅在执行这些指令时执行(例如,在执行中断调用时,在执行CALL指令时也会发生同样的事情)。

考虑StubMethod。

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

第二行存储堆栈地址顶部的当前值( ESP寄存器的值在EBP中输入)。 在这种情况下,在当前调用的上下文中, EBP寄存器为零。 寻址是相对于它执行的。 接下来,我们将堆栈的顶部移动到需要存储本地变量和参数的位置(第三行)。 诸如为所有本地需求分配内存之类的东西。

以上所有内容均称为序言功能。

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

关于fastcall的提醒:本机.net使用fastcall调用约定
该协议控制传递给该函数的参数的位置和顺序。
使用fastcall时,第一个和第二个参数分别通过ECXEDX寄存器传递,而后续的参数则通过堆栈传递。

对于非静态方法,第一个参数是隐式的,包含调用该方法的对象的地址(地址为this)。

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

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

值得回顾的是,函数的结果位于EAX寄存器中。

在第12-16行中,添加了必要的变量。 我将您的注意力吸引到第15行。这里有一个对地址的调用,它比堆栈的开始要多,即对先前方法的堆栈。 在调用之前,调用方法将参数推入堆栈的顶部。 在这里,我们阅读它。 从EAX寄存器中检索相加的结果并将其压入堆栈。 由于这是StubMethod的返回值,因此将其再次放置在EAX中 。 当然,这些荒谬的指令集仅在调试模式下才是固有的,但是它们显示了在没有智能优化器来完成大部分工作的情况下我们的代码的外观如何。

第18和19行恢复了先前的EBP (调用方法)和指向栈顶的指针(在调用该方法时)。

最后一行返回。 关于0x4的值,我会讲一点。
此命令序列称为功能结尾。

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

这是一个示例堆栈图像:



因此,如果我们在调用该方法后立即转过身来查看堆栈背面的内容,我们将首先看到的是将EBP推送到堆栈上(实际上,这发生在当前方法的第一行)。 接下来,将有一个返回地址,指出执行将在何处继续(由RET指令使用)。 通过这些字段,我们将看到当前函数的参数本身(从第3个开始,这些参数先通过寄存器传输)。 在它们后面的是调用方法本身的堆栈!
提及的第一和第二字段说明了在引用参数时在+ 0x8处的偏移。
因此,调用函数时,参数必须以严格定义的顺序位于堆栈的顶部。 因此,在调用该方法之前,每个参数都被压入堆栈。
但是,如果您不按它们,该功能仍会接受它们,该怎么办?

一个小例子


因此,上述所有事实使我对读取将调用我的函数的方法的堆栈具有不可抗拒的渴望。 我的想法是,从第三个参数开始的某个位置(它将最接近调用方法的堆栈)实际上是我想要获取的宝贵数据,这让我无法入睡。

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

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

但是隐式传递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 } } } 


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

我完全理解该示例不能在实践中使用,但是我认为,它对于理解一般的工作方案非常有用。

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


All Articles