有没有办法在C#中告诉调用函数的方法的参数?

6
我正在为我的c#应用程序开发一种无需干预的日志机制。
我希望它看起来像这样: 函数a(arg1,arg2,arg3 ...)调用函数b(arg4,arg5,arg6 ...),后者又调用log(),然后能够检测堆栈跟踪(可以通过Environment.StackTrace完成),以及调用堆栈中每个函数(例如ab)使用的值。
我希望它在调试和发布模式下都能工作(或者至少在调试模式下)。
在.net中是否有可能实现这一点?

有趣的问题...在C#中不可能,但也许可以使用IL或反射来实现? - John Weldon
2
我的赌注是这是不可能的。但它会成为一个好的非传统、开放式的面试问题(想象一下它是可能的——你认为它会怎么工作?),比大多数典型的“古怪问题”要好得多;这是揭示一个人对C#和CLR等整体知识的良好起点。 - Konrad Morawski
从理论上讲,这应该是可能的。序数堆栈跟踪确实返回有关正在调用的函数以及它们的参数类型的信息,并且用于调用函数的所有变量应该在堆栈中某个地方存储(尽管可能是作为局部变量)。 - Arsen Zahray
2
@ArsenZahray 没有理由让它们在堆栈中;如果它们之后不再使用,堆栈空间可以被重新用于另一个本地变量,并且 IL 堆栈不必对应于内存中的堆栈,因为本地变量只能存在于寄存器中。 - Jon Hanna
有没有办法在 .net 中访问调用函数的本地变量?我可以通过反射访问 IL 代码,但是否有一种方法可以访问实际值? - Arsen Zahray
1
这基本上就是我们刚才回答“不”的同样问题。我已经在我的答案中加入了一点更多的内容。 - Jon Hanna
4个回答

10

无法证明:

在调用b时,a的arg1使用的堆栈空间(即IL堆栈,因此可能从未放入堆栈,而是在调用时被注册)不保证仍由arg1使用。

扩展一下,如果arg1是引用类型,则其所引用的对象如果在调用b后没有使用,则不能保证不被垃圾回收。

编辑:

稍微详细解释一下,因为您的评论表明您还没有理解这个问题,并且仍然认为这应该是可能的。

Jitter使用的调用约定没有在任何相关标准的规范中指定,这使得实现者可以自由实现改进。它们确实在32位和64位版本以及不同的发布版之间存在差异。

但是,来自MS人员的文章表明所使用的约定类似于__fastcall约定。在调用a时,arg1将被放入ECX寄存器*中,arg2将被放入EDX寄存器中(我假设为32位x86,使用amd64时,甚至有更多参数被注册)。arg3将被推入堆栈中,并且确实存在于内存中。

请注意,在此时,不存在任何包含arg1arg2的内存位置,它们仅存在于CPU寄存器中。

在执行方法本身的过程中,寄存器和内存将根据需要使用。然后调用了b

现在,如果a需要arg1arg2,则必须在调用b之前将其推入。但是如果没有,那么它就不会 - 甚至可能会重新排序以减少这种需求。反之,这些寄存器在此时可能已经用于其他事情上了 - Jitter不是傻瓜,因此如果它需要一个寄存器或堆栈上的空间,并且在方法的其余部分中有未使用的空间,则会重复使用该空间。(同样,在此之上,C#编译器将重复使用IL生成使用的虚拟堆栈中的插槽)。

因此,当调用b时,arg4被放置在ECX寄存器中,arg5被放置在EDX中,并将arg6推送到堆栈中。此时,arg1arg2不存在了,您无法像读一本书变成厕纸后那样找到它们。

有趣的是,方法很常见,会以相同的参数在相同的位置调用另一个方法,在这种情况下,ECX和EDX可以保持不变。

然后,b返回,将其返回值放入EAX寄存器或EDX:EAX对或通过EAX指向它的内存,具体取决于大小,a在将其返回结果放入该寄存器之前做了更多的工作,以此类推。

现在,这是假设未进行任何优化的情况。实际上,可能并没有调用b,而是将其代码内联。在这种情况下,无论值是在寄存器还是在堆栈上 - 在后一种情况下,它们在堆栈上的位置与b的签名无关,而与a的执行过程中相关的值的位置有关,并且在另一个对b的“调用”或甚至在a的另一个“调用”的情况下会有所不同,因为在一种情况下,包括其对b的调用在内的整个a的调用已被内联,在另一种情况下未被内联,在另一种情况下以不同的方式内联。如果例如,arg4直接来自另一个调用返回的值,则此时它可能在EAX寄存器中,而arg5则在ECX中,因为它与arg1相同,arg6位于由使用的堆栈空间的中间某处。

另一种可能性是,对b的调用是一个已被消除的尾调用:因为a将立即通过b返回其返回值(或其他可能性),所以与其将值推送到堆栈上,不如替换正在被使用的值,并更改返回地址,使从b返回跳回调用a的方法,跳过一些工作(并减少内存使用,以致一些函数式方法可以避免溢出堆栈,而且确实很好用)。在这种情况下,在调用b期间,可能完全没有参数,即使那些已经在堆栈上。

这最后一种情况是否应该被认为是优化还存在争议;有些语言严重依赖它,如果不这样做,它们的性能会很差,甚至根本无法工作(而不是溢出堆栈)。

还有各种其他优化方式。理应进行各种其他优化方式——如果.NET团队或Mono团队采取了某些措施,使我的代码更快、占用更少的内存,但行为与之前相同,我个人是不会抱怨的!

这还假设编写C#代码的人从未更改过参数的值,这显然不是真的。考虑以下代码:

IEnumerable<T> RepeatedlyInvoke(Func<T> factory, int count)
{
  if(count < 0)
    throw new ArgumentOutOfRangeException();
  while(count-- != 0)
    yield return factory();
}

即使 C# 编译器和 Jitter (JIT编译器) 被设计成可以保证参数不会以上述方式发生变化,你怎么能知道在 factory 调用内部已经有哪些 count 值?即使在第一次调用时,它们也是不同的,而且上述代码并不奇怪。

因此,总结一下:

  1. Jitter: 参数通常被存储在寄存器内。您可以期望 x86 将 2 个指针、引用或整数参数存储在寄存器中,amd64 将 4 个指针、引用或整数参数和 4 个浮点参数存储在寄存器中。它们没有可读取的位置。
  2. Jitter: 栈上的参数经常被覆盖。
  3. Jitter: 可能根本不存在真正的调用,因此没有地方可以查找参数,因为它们可能出现在任何地方。
  4. Jitter: "调用" 可能正在重复使用与上一个相同的帧。
  5. 编译器: IL 可能会重用局部变量的空间。
  6. 人类:程序员可能会更改参数值。

从所有这些情况中,我们怎么可能知道 arg1 是什么?

现在,考虑垃圾收集的存在。想象一下,即使在所有这些情况下,我们都能神奇地知道 arg1 是什么。如果它是指向堆上对象的引用,这可能对我们毫无帮助,因为如果上述全部意味着栈上没有更多活动的引用(这很明显确实发生了),并且 GC 启动,则该对象可能已被回收。因此,我们所能神奇地掌握的只是对不再存在的东西的引用 - 的确很可能是对现在用于其他目的的堆中某个区域的引用,从而破坏了整个框架的类型安全性!

这与反射获取 IL 根本不可比较,因为:

  1. IL 是静态的,而不仅仅是给定时间点的状态。同样,我们可以更容易地从图书馆获取我们喜爱的书籍的副本,而不必像第一次阅读那样回到我们的反应。
  2. IL 也不反映内联等影响。如果一个调用每次实际使用时都被内联,然后我们使用反射来获得该方法的 MethodBody,那么它通常被内联的事实就是无关紧要的。

其他回答中有关于性能分析、AOP 和拦截的建议是你所能得到的最接近的。

*实际上,this 是实例成员的真正第一个参数。让我们假装一切都是静态的,这样我们就不必再指出这一点了。


3
在 .net 中是不可能的。在运行时,JITter 可能决定使用 CPU 寄存器而不是堆栈来存储方法参数,甚至重写堆栈中的初始(传递的)值。因此,在源代码的任何地方允许记录参数将非常耗费 .net 的性能成本。
据我所知,一般情况下唯一的方法是使用 .net CLR 分析 API。(例如 Typemock 框架就能够做到这一点,并且它使用 CLR 分析 API)
如果您只需要拦截虚拟函数/属性(包括接口方法/属性)调用,则可以使用任何拦截框架(例如 Unity 或 Castle)。
有关 .net 分析 API 的一些信息: MSDN Magazine MSDN Blogs Brian Long's blog

1

在C#中不可能实现这一点,您应该使用AOP方法并在每次调用方法时执行方法参数日志记录。这样,您可以集中您的日志记录代码,使其可重用,然后只需要标记哪些方法需要参数日志记录。

我相信使用像PostSharp这样的AOP框架可以很容易地实现这一点。


1
可能不会发生,除非进行类型模拟或使用一些ICorDebug的魔术。 即使StackFrame类仅列出允许获取有关源而不是参数信息的成员。
但是,您想要的功能存在于IntelliTrace中,具有方法记录。 您可以过滤需要审核的内容。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接