因此,这个故事始于三个因素的巧合。 我:
- 大多用C#编写;
- 仅粗略地想象它是如何安排和工作的;
- 对汇编程序感兴趣。
这种看似无害的混合方式引起了一个奇怪的想法:是否有可能以某种方式组合这些语言? 在C#中添加执行汇编程序插入的功能,就像在C ++中一样。
如果您对这将导致什么后果感兴趣,欢迎与猫联系。

最初的困难
即使在那一刻,我也意识到几乎没有标准的工具可以从C#代码中调用汇编程序代码-这与该语言的重要概念之一:内存安全性相矛盾。 在对该问题进行了肤浅的研究之后(除其他外,证实了最初的预感-“开箱即用”,没有这种可能性),很明显,除了意识形态问题之外,还有一个纯粹的技术问题:如您所知,C#被编译为中间字节码,由CLR虚拟机进一步解释。 正是在这里,我们面临着一个非常大的问题:一方面,编译器(在下文中,我指微软的Roslyn,因为它实际上是C#编译器领域的标准),显然无法识别和将汇编程序命令从文本视图转换为二进制表示形式,这意味着我们必须直接以二进制形式将机器指令用作插入,而另一方面,虚拟机具有自己的字节码,并且无法识别和执行该指令 捆绑的命令,我们提供了她。
从理论上讲,此问题的解决方案是显而易见的-您需要确保由处理器执行二进制插入代码,从而绕过虚拟机的解释。 想到的最简单的事情是将二进制代码存储为字节数组,控制将在适当的时间以某种方式转移到该字节。 从这里开始的第一项任务是:您需要想出一种将控制权转移到任意存储区中所包含内容的方法。
第一个原型:“调用”数组
此任务可能是插入的最严重障碍。 使用语言工具,很容易获得指向我们数组的指针,但是在C#世界中,指针仅存在于数据上,因此无法将其转换为指向某个函数的指针,以便以后可以调用它(嗯,或者至少我不知道该怎么做)。去做)。
幸运的是(或不幸的是)月球下没有什么新鲜的东西,在Yandex中快速搜索“ C#”和“汇编插入”一词,使我在
[]杂志的2007年12月号上找到了一篇文章。
] [Aker] 。 从那里诚实地复制了功能并使其适应了我的需求,我得到了
[DllImport("kernel32.dll")] extern bool VirtualProtect(int* lpAddress, uint dwSize, uint flNewProtect, uint* lpflOldProtect); public void* InvokeAsm(void* firstAsmArg, void* secondAsmArg, byte[] code) { int i = 0; int* p = &i; p += 0x14 / 4 + 1; i = *p; fixed (byte* b = code) { *p = (int)b; uint prev; VirtualProtect((int*)b, (uint)code.Length, 0x40, &prev); } return (void*)i; }
该代码的主要思想是将堆栈上
InvokeAsm()
函数的返回地址替换为您要将控制转移到的字节数组的地址。 然后,在退出函数后,而不是继续执行程序,而是开始执行我们的二进制代码。
我们将更详细地处理
InvokeAsm()
中
InvokeAsm()
的魔术。 首先,我们声明一个局部变量,该变量当然会出现在堆栈上,然后获取其地址(从而获得堆栈顶部的地址)。 接下来,我们在其中添加一个特定的魔术常数,该常数是通过在调试器中精心计算返回地址相对于堆栈顶部的偏移量而获得的,保存返回地址并改为写入字节数组的地址。 保存返回地址的神圣含义是显而易见的-在插入后,我们需要继续执行程序,这意味着我们需要知道在其后将控制权转移到哪里。 接下来是来自kernel32.dll库
VirtualProtect()
的WinAPI函数调用。 为了更改插入代码所在的内存页的属性,这是必需的。 当然,在编译程序时,它会出现在数据部分,并且相应的内存页具有读写访问权限。 我们还需要添加权限以执行其内容。 最后,我们返回存储的真实返回地址。 当然,此地址不会返回到调用
InvokeAsm()
的代码中,因为
return (void*)i;
后立即执行
return (void*)i;
插入中的“失败”。 但是,虚拟机使用的调用约定(禁用优化的stdcall和启用了fastcall的意思是通过EAX寄存器返回该值,即 要从插入返回,我们需要遵循两个指令:
push eax
(代码0x50)和
ret
(代码0xC3)。
澄清度将来,我们将讨论x86(或更确切地说是IA-32)的体系结构,因为与例如x86-64相比,那时我至少在某种程度上对它熟悉。 但是,上述控制传递方法应适用于64位代码。
最后,您应该注意两个未使用的参数:
void* firstAsmArg
和
void* secondAsmArg
。 它们是将任意用户数据传输到汇编程序插入所必需的。 这些参数将位于堆栈上的某个已知位置(stdcall),或者位于著名的寄存器中(fastcall)。
关于优化的一点由于从编译器的角度来看,代码不了解其含义,因此可能会无意间抛出一些根本上重要的调用/内联某些东西/未保存一些“未使用”的参数/以某种方式干扰了我们计划的实施。 这可以通过[MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)]
属性部分解决,但是,即使这样的预防措施也无法达到预期的效果:例如,对于整个函数至关重要的局部变量i
突然被注册,这显然破坏了所有内容。 因此,为了完全消除出现问题的可能性,您应该构建一个禁用优化的库(可以在项目属性中禁用它,也可以使用“调试”配置)。 因此,将使用stdcall,因此将来我将继续使用该调用约定。
增强功能
安全胜于不安全
当然,毫无疑问有任何安全性(在C#中使用该词的意义)。 但是,上述
InvokeAsm()
方法对指针进行操作,这意味着只能从标有
unsafe
关键字的块中调用它,这并不总是很方便-至少它需要使用/ unsafe开关(或VS中项目属性中的相应检查标记)进行编译。 因此,提供至少运行IntPtr的外壳(在最坏的情况下)似乎是合乎逻辑的,并且理想情况下,它允许用户指定要传输和返回的类型。 好吧,这听起来像是通用的,我们写的是通用的,有人问,还有什么要谈的? 实际上-有些东西。
最明显的是:如何获得类型未知的参数的指针?
T* ptr = &arg
#
T* ptr = &arg
的类型在C#中
T* ptr = &arg
不允许的,并且通常不难理解原因:用户可以将其中一种托管类型用作类型参数,无法获得指向该类型的指针。 解决方案可能是限制类型为
unmanaged
的参数,但首先,它仅出现在C#7.3中,其次,它不允许将字符串和数组作为参数传递,尽管
fixed
运算符允许使用它们(我们将指针指向第一个字符或数组元素)。 好吧,此外,我想为用户提供包括受控类型在内的操作机会-由于我们开始违反语言规则,因此我们将最终违反它们!
获取指向托管对象的指针和通过指针的对象
再一次,在审议不很成功之后,我开始寻找最终的解决方案。 这次
有关哈布雷的
文章对我有所帮助。 简而言之,其中提出的一种方法是编写一个辅助库,而不是用C#,而是直接用IL。 它的任务是将一个对象(实际上是对该对象的引用)推入虚拟机堆栈,作为参数传递,然后从堆栈中检索其他内容,例如数字或
IntPtr
。 通过以相反的顺序执行相同的步骤,可以将指针(例如,从汇编插入返回的指针)转换为对象。 这种方法之所以有用,是因为发生的所有事情都是清晰透明的。 但是有一个缺点:我想尽可能少地获取文件,因此,我决定不编写单独的库,而是决定将IL代码嵌入主库中。 我发现的唯一方法是用C#编写存根方法,构建项目,使用ildasm分解二进制文件,重写存根方法代码,然后将它们全部与ilasm放在一起。 这些是相当多的附加操作,并且考虑到对代码进行任何更改之后,您每次构建它们时都需要执行这些操作。总的来说,我很快就厌倦了它,因此我开始寻找替代方法。
就在那时,一本精彩的书落入了我的手,这本书让我为自己学到了很多东西-杰弗里·里希特(Jeffrey Richter)的“通过C#进行CLR”。 在其中的第二十章中,我们讨论了
GCHandle
结构,该结构具有一个
Alloc()
方法,该方法采用一个对象和一个
GCHandleType
枚举
GCHandleType
。 因此,如果调用此方法传递所需的对象和
GCHandle.Pinned
,则可以获取该对象在内存中的地址。 而且,在调用
GCHandle.Free()
之前
GCHandle.Free()
对象是固定的,即 充分保护免受垃圾收集器的影响。 但是,存在某些问题。 首先,
GCHandle
不能以任何方式帮助完成“指针→对象”的转换,仅帮助“对象→指针”。 更重要的是,要使用
GCHandleType.Pinned
要获取其地址的对象
GCHandleType.Pinned
类或结构必须具有
[StructLayout(LayoutKind.Sequential)]
属性,而
LayoutKind.Auto
使用
LayoutKind.Auto
。 因此,此方法仅适用于某些标准类型以及最初出于此目的设计的那些自定义类型。 不是我们想找到的通用方法,对吗?
好吧,再试一次。 现在,让我们注意Roslyn支持的两个未记录的函数:
__makeref()
和
__refvalue()
。 它们中的第一个获取一个对象,并返回
TypedReference
结构的实例,该
TypedReference
存储对该对象及其类型的引用,而第二个对象则从传输的
typedReference
实例中提取该对象。 为什么这些功能对我们很重要? 因为
TypedReference
是一个结构! 在讨论的上下文中,这意味着我们可以得到一个指向它的指针,该指针将组合起来指向该结构的第一个字段。 即,它存储了指向我们感兴趣的对象的链接。 然后,要获取指向托管对象的指针,我们需要通过指向
__makeref()
返回值的指针读取该值,并将其转换为指针。 要通过指针获取对象,必须从所需类型的条件空对象中调用
__makeref()
,获取指向返回的
TypedReference
实例的指针,在其上写入指向该对象的指针,然后调用
__refvalue()
。 结果是这样的代码:
public static Tout ToInstance<Tout>(IntPtr ptr) { Tout temp = default; TypedReference tr = __makeref(temp); Marshal.WriteIntPtr(*(IntPtr*)(&tr), ptr); Tout instance = __refvalue(tr, Tout); return instance; } public static void* ToPointer<T>(ref T obj) { if (typeof(T).IsValueType) { return *(void**)&tr; } else { return **(void***)&tr; } }
备注返回为InvokeAsm()
安全编写包装的任务,应注意,与使用GCHandle.Alloc(GCHandleType.Pinned)
不同,使用__makeref()
和__refvalue()
获取指针的方法不能保证垃圾回收器无处不在。该对象将不会移动。 因此,包装程序应先关闭垃圾收集器,然后再恢复其功能。 该解决方案虽然很粗鲁,但却很有效。
对于那些不记得操作码的人
因此,我们学会了如何调用二进制代码,学会了不仅传递即时值,而且还传递指向任何东西的指针作为参数……只有一个问题。 在哪里获得相同的二进制代码? 您可以用铅笔,记事本和操作码表来武装自己(例如,
这个 ),也可以使用具有x86汇编器支持甚至是成熟翻译器的十六进制编辑器,但是所有这些选项意味着用户将不得不使用库以外的其他功能。 这不是我想要的,所以我决定将翻译程序包括在库中,该库通常称为SASM(Stack Assembler的缩写;与
IDE无关)。
免责声明我不擅长解析字符串,因此翻译器代码...至少可以说是不完美的。 另外,我的正则表达式不强,因此它们不存在。 通常来说-迭代解析器。
我可能不会谈论创建这个“奇迹”的过程-这个故事没有什么有趣的,但我将简要介绍其主要功能。 当前支持大多数x86指令。 尚不支持用于处理浮点数和扩展名(MMX,SSE,AVX)的数学协处理器指令。 可以声明常量,过程,局部栈变量,全局变量,这些变量的内存在转换过程中直接在带有二进制代码的数组中直接分配(如果这些变量是使用标签命名的,则它们的值也可以在通过调用方法执行插入后从C#获取
GetBYTEVariable()
,
GetWORDVariable()
,
GetDWORDVariable()
,
GetAStringVariable()
和
GetWStringVariable()
),
addr
和
invoke
宏。 重要功能之一是支持使用
extern < > lib < >
结构从外部库导入功能。
asmret
宏值得一个单独的段落。 在翻译过程中,它以11条指令的形式展开,形成了结尾。 默认情况下,序言将添加到翻译后的代码的开头。 他们的任务是保存/恢复处理器的状态。 此外,序言还添加了四个常量-
$first
,
$second
,
$this
和
$return
。 在转换期间,这些常量由堆栈上的地址替换,在堆栈上的地址分别是传递给汇编器插入的第一个和第二个参数,第一个插入命令的地址和返回地址。
总结
代码讲的不只是单词,而且不分享冗长的工作结果也很奇怪,因此我邀请所有感兴趣的人加入
GitHub 。
但是,如果我尝试以某种方式概括已完成的所有工作,那么在我看来,结果是一个有趣的,甚至在某种程度上并非没有用的项目。 例如,用于在C#中对插入进行排序和使用汇编程序插入的相同算法的速度相差两倍以上(当然,有利于汇编程序)。 当然,在严肃的项目中,不建议使用结果库(虽然不太可能发生不可预料的副作用),但您自己很有可能。