【问题标题】:What happens under the hood when one Method Calls Another?当一种方法调用另一种方法时,幕后会发生什么?
【发布时间】:2010-11-20 02:12:32
【问题描述】:

这类似于What happens when you run a program?,但不是骗子。

假设我有一个简单的控制台程序,有两种方法 A 和 B。

    public static void RunSnippet()
    {
        TestClass t = new TestClass();
        t.A(1, 2);

        t.B(3, 4);
    }

    public class TestClass
    {
        public void A(int param1, int param2)
        {
            //do something
            C();
        }

        private void C()
        {
            //do
        }

        public bool B(int param1, int param2)
        {
            //do something
            bool result = true;

            return result;
        }
    }

谁能详细解释一下(但请保持简单明了的英语),当 RunSnippet 调用方法 A 和方法 B(并且它们在内部调用一些其他方法)时真正发生了什么。我想了解幕后真正发生的事情......意味着参数如何传递,它们存储在哪里,本地变量会发生什么,返回值如何传递,如果另一个线程在 A 调用 C 时开始运行会发生什么,如果抛出异常会发生什么。

【问题讨论】:

  • 正如您链接到的问题所示,这是一个过于宽泛的问题。已就该主题编写了完整的章节(或整本书)。
  • @Neil 同意,但是通过一个非常简单的示例,我认为可以解释得体的概述。请参阅下面的尝试。

标签: language-agnostic computer-science


【解决方案1】:

我不太确定您正在寻找什么级别的详细信息,但这是我试图解释正在发生的事情:

  1. 为您的可执行文件创建了一个新进程。该进程有一个包含每个线程堆栈的堆栈段、一个用于静态变量的数据段以及一个用于动态分配内存的称为堆的内存块,以及一个包含已编译代码的代码段。
  2. 您的代码已加载到代码段中,指令指针设置为 main() 方法中的第一条指令,代码开始执行。
  3. 对象 t 从堆中分配。 t 的地址存储在堆栈中(每个线程都有自己的堆栈)。
  4. t.A() 通过将 main() 的返回地址放在堆栈上并将指令指针更改为 t.A() 代码的开头来调用。返回地址与值 1 和 2 一起放在堆栈中。
  5. t.A() 调用 t.C(),方法是将 t.A() 的返回地址放入堆栈,并将指令指针更改为 t.C() 代码的起始地址。
  6. t.C() 通过将 t.A() 的返回地址从堆栈中弹出并将指令指针设置为该值来返回。
  7. t.A() 的返回方式与 t.C() 类似。
  8. 对 t.B() 的调用与对 t.A() 的调用非常相似,只是它返回一个值。返回该值的确切机制取决于语言和平台。该值通常会在 CPU 寄存器中返回。

注意:由于您的方法非常小,现代编译器通常会“内联”它们,而不是进行经典调用。内联意味着从方法中获取代码并将它们直接注入到 main() 方法中,而不是通过进行函数调用的(少量)开销。

鉴于您的示例,我看不出线程如何直接进入图片。如果您要再次启动可执行文件,它将在新进程中运行。这意味着它将获得自己的代码段、数据段和堆栈段,将其与第一个进程完全隔离。

如果您的代码在一个在多个线程上调用 main() 的较大程序中运行,它的运行几乎与前面描述的完全一样。该代码是线程安全的,因为它不访问任何潜在的共享资源,例如静态变量。线程 1 无法“看到”线程 2,因为所有关键数据(值和指向对象的指针)都存储在线程的本地堆栈中。

【讨论】:

  • 就线程而言,这个问题与我的示例并不完全相关。但是,假设我的示例在工作线程上运行,并且主线程产生了另一个线程,那么在这种情况下,当前正在执行的方法会发生什么?
  • 如果 main 产生另一个线程,它将继续执行到最后的“}”并返回。到那时,结果不再与语言和平台无关(即,它在一定程度上取决于您的平台和语言),但通常当 main 方法退出时,进程会终止,并且仍在运行的任何线程都会突然终止。如果这不是您想要的,您需要等待您开始终止的线程。大多数语言都提供了一种优雅地做到这一点的方法。在 C# 中,您可以使用 thread.Join()。
【解决方案2】:

函数调用本质上是一个 goto 语句,只是在最后,它必须返回到它被调用的地方。

有一个函数调用堆栈,它基本上保存有关“返回”位置的信息。

函数调用需要:

  • 存储(推送)当前指令在堆栈上的位置,以供被调用函数在完成时使用。
  • 也将所有参数推入堆栈
  • 转到被调用函数中的第一条指令。

当被调用函数需要读取参数时,会从栈中读取。

当被调用的函数完成,或点击“return”语句时,它会找到它需要返回的地址,并“goto”到它。

【讨论】:

  • 将所有参数存储在堆栈上是 32 位 x86 上的常用方法,因为寄存器很少,但在大多数其他架构上,包括 x86-64,前几个参数将被传入CPU 寄存器而不是内存。
【解决方案3】:

(假设 x86) 首先您必须了解堆栈。 函数使用称为“堆栈”的内存区域。你可以把它想象成一堆盘子,每个盘子都包含一个 DWORD(32 位)数据。 CPU 中有一个寄存器来跟踪我们正在处理的堆栈中的当前位置(它只是一个虚拟内存地址)。它称为堆栈指针,通常存储在 esp 寄存器中。

当函数与堆栈交互时,它们通常会执行以下两种操作之一:pushpop。 “推”是指将某些内容放在堆栈顶部,包括将堆栈指针移动到下一个最高位置,然后将某些内容复制到该新位置(新顶部)。推送“增加了堆栈”,因为现在存储了更多数据(更多板)。

“pop”是指堆栈上最顶层的项目被“移除”,这包括将当前位于堆栈顶部的任何内容(由 esp 寄存器指向)复制到 cpu 寄存器(通常是 eax)然后将堆栈指针移动到堆栈中较低的一个位置。

所以现在我们可以谈谈设置调用函数了。

代码

t.B(3, 4);

组装

// here is a push we described above. The function we are in currently is
// pushing the value "4" onto the stack. This is one of the arguments to the
// B function we are calling. Note that we push the last argument first
push 4 
// here is another push. This time we are pushing the next argument to the 
// B function
push 3
call B  // this call sets up the context for the next function to run

调用 发生时,我们正在将上下文从当前函数转换到被调用的函数。函数需要运行的额外信息是参数,我们将其压入堆栈。

新函数现在将对堆栈进行一些内部管理,为它拥有的局部变量腾出空间,并将堆栈指针保存到寄存器中,以便在函数返回时可以恢复它。如果这没有发生,那么调用函数在重新获得控制权时就会完全迷失方向,不知道如何访问它之前放入堆栈的内容,例如它自己的局部变量或堆栈指针的上下文调用它的函数。

现在它正在组装中发生(从 Havenard 那里偷来的)。

// Here is the B function making sure that the calling function can get back to
// the it's stack context when B returns. 
push ebp
mov ebp, esp
// remember when I said that a push was growing the stack. Well you can also grow
// it just by moving the stack pointer higher, as if there were already more plates there
// you may wonder why we are subtracting (sub) from the stack pointer (esp) to grow it
// the reason is that the stack "grows down" in memory. In other words, as the stack grows
// the memory addresses of the stack grow smaller.
// the reason we are subtracting 4 is because we only need to grow the stack by one plate
// so that we can store the local variable 'result' there. If we had 2 local variables
// we would have subtracted 8
sub esp, 4 
// the instructions below are simply moving the static value 1 into the local variable
// 'result'. Local variables are always referenced relative to the bottom of the stack
// context for the current function. This value is stored in the ebp register, which we
// saw earlier in the function setup above.
// so now we think of the location where the 'result' variable is stored as "ebp-4"
// we know that because we put it there.
mov dword ptr [ebp-4], 1 // result = 1 (true)
// eax is a special register that contains the return value of the function. That is why
// you see the value of 'result' (which we know as [ebp-4] in the eax register
mov eax, dword ptr [ebp-4]
// We adjust the stack pointer back to it's previous location 
// before we subtracted to make room for our local variable
add esp, 4
// Our work is done now.. time to clean stuff up for our calling function and 
// leave things as we found them. Our trusty ebp register stores the old stack pointer
// that our calling function needs to resume it's stack context.
mov esp, ebp
pop ebp
ret

我确定我遗漏了一些细节,尤其是从 B 函数返回时,但我认为这是一个很好的概述。

【讨论】:

    【解决方案4】:

    您是指汇编语言级别,还是操作系统级别?

    就汇编而言,调用方法时发生的情况是所有参数都被压入堆栈,最后是方法的地址(如果是虚拟的,则需要额外的查表)。然后代码从方法的地址继续,直到“ret”指令被命中并且从调用的地方继续执行。您应该研究汇编以及如何编译 C 以更好地掌握该过程。

    在操作系统级别,调用该方法并没有什么特别之处,操作系统所做的只是为进程分配 CPU 时间,并且该进程负责在此期间做它想做的事情,无论是调用方法还是其他什么。然而,线程之间的切换是由操作系统完成的(不像您在 CPython 中使用软件线程)。

    【讨论】:

      【解决方案5】:

      如果您对汇编级别的解释感兴趣,我建议您观看来自斯坦福大学 CS107 的本讲座。我发现它很好地以非常简单的英语方式准确解释了函数调用的成本。

      http://www.youtube.com/watch?v=FvpxXmEG1F8&feature=PlayList&p=9D558D49CA734A02&index=9

      【讨论】:

        【解决方案6】:

        TestClass t = new A();

        我想你的意思是 new TestClass() 这里。

        至于幕后发生的事情,编译器会将这段代码转换为 Java 字节码。这是article 中关于“Java 虚拟机如何处理方法调用和返回”的摘录。

        当 Java 虚拟机调用 一个类方法,它选择方法 根据类型调用 对象引用,它总是 在编译时已知。在另一 手,当虚拟机调用 一个实例方法,它选择 根据实际调用的方法 对象的类,只能是 在运行时已知。

        JVM 使用两种不同的 指令,如下图所示 表,调用这两个不同的 方法种类:invokevirtual for 实例方法,并为 类方法。

        invokevirtual 的方法调用和 调用静态 操作码操作数说明

        调用虚拟 indexbyte1, indexbyte2 pop objectref 和 args,调用方法 在常量池索引处

        调用静态 indexbyte1, indexbyte2 弹出参数,调用静态方法 常量池索引

        【讨论】:

          【解决方案7】:

          t.B(3, 4) 做了什么:

          
          push 4
          push 3
          call B
          add esp, 8 // release memory used
          

          call将调用后的指令地址压入堆栈,然后将进程线程跳转到B()地址:

          
          push ebp // save EBP state, the caller will need it later
          mov ebp, esp // save ESP state
          // push registers I would use but EAX, I'm not using any
          sub esp, 4 // alloc 4 bytes in the stack to store "result"
          mov dword ptr [ebp-4], 1 // result = 1 (true)
          mov eax, dword ptr [ebp-4] // prepares return value o be "result"
          add esp, 4 // frees allocked space
          // pop registers
          mov esp, ebp
          pop ebp
          ret
          

          对象实现是共享的。当你声明一个新对象时,所有存储的都是对象变量。在这种情况下,没有任何引用。

          关于多线程,线程内存是分开的。当内核将处理器切换到另一个线程时,线程流中没有真正发生任何事情。内核只是冻结并恢复此流程。

          【讨论】:

          猜你喜欢
          • 1970-01-01
          • 1970-01-01
          • 2013-02-10
          • 1970-01-01
          • 1970-01-01
          • 2015-08-14
          • 1970-01-01
          • 2015-11-13
          • 1970-01-01
          相关资源
          最近更新 更多