【问题标题】:How parameter passing works with the call stack on nested function calls参数传递如何与嵌套函数调用的调用堆栈一起工作
【发布时间】:2020-03-26 09:16:59
【问题描述】:

假设您有这些调用(JavaScript,只是为文章补充):

function start() {
  let a = 10
  let x = doX(a)
  let b = 20
  doY(a, b + x)
  b = 30
  x--
  return doZ(a + (b * x))
}

function doX(x) {
  let a = x * 2
  let b = doZ(x) + 2
  return doZ(a + b)
}

function doY(x, y) {
  fs.writeFileSync(`${x + y}.txt`, 'hello world')
}

function doZ(x) {
  return Math.pow(2, x)
}

所以不要过多地讨论编译器对此的确切表示,我主要感兴趣的是演示一些嵌套变量以及它们如何在函数调用之间使用。

所以基本上我们在每个“帧”都有这个(我的意思是在即将到来的函数调用之前定义的所有变量):

1. a = 10 [call doX(a)]
  1. a = x * 2 [call doZ(x)]
  2. a = x * 2, b = doZ(x) + 2 [call doZ(a + b)]
2. x = ?1, a = 10, b = 20 [call doY(a, b + x)]
  1. ... ignored, just something outside of our scope
3. x = ?2, a = 10, b = 30 [call doZ(a + (b * x))]
  1. just a return

所以在顶层,xb 都会更改它们的值。否则,所有其他变量只声明一次。但实际上函数调用可能有 20 个深度,每个有 20 个变量,其中一些被分配了 10 次以上。所以这种情况会出现 10 次,如果不是更多的话。

基本上,我想知道每个帧中的调用堆栈是什么样的。特别是局部变量在某些点之后如何存储/恢复。

例如,点 (2) 和 (3) 之间会发生什么? xb 变量都被重新定义。在 (2) 处调用的函数之前,调用堆栈中的内容是什么?什么进入(2)内部的调用堆栈? (2)之后的调用堆栈是什么?

假设我们的情况要复杂得多:

let a = 10
let b = 20
draw(a++, b)
draw(a++, b)
draw(a++, b)
draw(a++, b)
...x100

然后会发生什么? b 是否每次调用draw 时都被推送和弹出?或者它是否以某种方式进行了优化,因此它不必每次都存储在堆栈中?这种东西……

几乎我只是想了解如何从头开始构建调用堆栈,并且对您实际放入调用堆栈的内容以及函数返回时实际弹出的时间/方式/内容感到困惑.因为在我看来,在我的脑海中,当你执行let b = 10 时,它只是“停留在函数范围内”直到函数完成,但这是不现实的。我没有关注调用堆栈,主要是因为高级语言也不需要你,所以我没有任何感觉。我想获得的是对调用堆栈外观的直觉就像在这些点/帧。我已经看到了堆栈矩形图的维基百科(和其他)图表,但它们并不是很有帮助。我真正认为有用的是一些伪代码,所以我可以看到,也许在“框架”数组中,每个“步骤”或“框架”在 JavaScript 对象或结构(某种东西)方面看起来像什么,喜欢:

var callStackAtEachFrame = [
  {
    a: 10
  },
  [
    {
      a: 10
    },
    {
      a: x * 2
    }
  ],
  [
    {
      a: 10
    },
    {
      a: x * 2,
      b: doZ(x) + 2
    },
    {
      something: Math.pow(2, x)
    }
  ]
]

我真的不知道,但似乎以像这样更代码的方式可视化事物如何从调用堆栈中推送和弹出,将有助于了解如何构建一个。

现实中是这样的吗?

function start() {
  let a = 10
  PUSH(a)
  let x = doX(a)
  POP(a, x)
  let b = 20
  PUSH(a, x, b)
  doY(a, b + x)
  POP(a, x, b)
  b = 30
  x--
  PUSH(a, x, b)
  return doZ(a + (b * x))
}

function doX(x) {
  let a = x * 2
  PUSH(a)
  let b = doZ(x) + 2
  POP(a, b)
  return doZ(a + b)
}

...

另外,我对 JavaScript 本身的工作方式并不感兴趣,我对与语言无关的方式感兴趣。

【问题讨论】:

  • doY(a, b + x) 仍然会传递两个参数,因为 doY 期望这样。编译器将push(a, b + x)。此外,大多数调用约定在寄存器中返回结果,因此调用者不会pop 那。函数参数通常被视为被调用者的局部变量。预计不会保留它们的值,因此如果您想多次调用draw(a++, b),则需要为每个参数设置两个参数。

标签: javascript function assembly process operating-system


【解决方案1】:

首先,在大多数语言中,局部变量(模闭包)不能在声明它们的函数范围之外进行修改或访问,因此另一个函数中的变量是完全独立的,即使它们的名称与其他函数中的变量名。我这样说是因为您的示例在不同的函数中重用了变量名,我认为这留下了一些混淆的空间。

正如您所注意到的,堆栈不仅用于函数调用和参数传递——它还存储局部变量。然而,虽然函数调用参数和返回地址有时会被压入堆栈,但局部变量通常是在一个组中分配而不是被压入。

大多数语言实现都会在函数顶部创建函数所需的所有本地存储(用于局部变量和临时变量),称为 function prologue

实际上,您可能为局部变量设想的所有推送和弹出操作都被展平并组合到一个组分配中,尽管局部变量可能在同一函数内的不同范围内。

fn(x) {
    int a = ...;
    if (...) {
        int b = ...;
    }
    while (...) {
        int c = ...;
    }
}

这里假设abc 需要本地存储,该语言实现将在序言中分配一次最多3 个int 的本地存储,并在结尾处释放一次。 (例如,如果语言意识到 bc 的活动/存储持续时间不重叠,则它们可以共享相同的存储位置。)

我所描述的组分配通常在序言中通过从堆栈指针中减去而不是将值压入堆栈来完成。存储块本质上是未初始化的(尽管各种语言实现有不同的技术来确保变量在执行期间被正确初始化)。


形参类似于局部变量,但它们是由一些调用者用值初始化的,而局部变量是在使用它们的同一个函数中初始化的。

fn(x) {
    a = fn2(x+1,x-1);
    ... a ..;
}

fn2(y,z) { return y * z; }

在上面,理论上,x+1x-1 在函数 fn 的上下文中进行评估。当fn2 被实际调用时,我们将看到y:=x+1z:=x-1 并且这发生在fn2 的第一行实际运行之前。形式参数yz 的这种初始化实际上发生在fn2 被实际调用之前,因此,必须使用某种机制来允许它们在技术上作为形式参数存在于被调用函数fn2 中之前。

对于基于栈的传参机,这个机制就是push。

每次推送都会分配一个变量,该变量将从为实际参数评估的值转换为被调用者的形式参数。

为了在基于堆栈的机器上完成上述调用,我们将评估x-1,然后将其压入堆栈;接下来评估x+1 并将其推入堆栈。最后,调用fn2,它将返回地址压入堆栈。当fn2 启动时,堆栈上有3 个可以依赖的东西:返回地址yz

(参数由调用者从右到左推送,因此它们最终在内存中从左到右 - 这与 C 中的可变参数有关。)

如果fn2 想要本地存储,它将通过从堆栈指针中减去来分配它。当它这样做时,堆栈上现在有 4 个东西:本地存储块、返回地址、yz

fn2 完成时,它会释放其本地存储,然后使用调用提供的返回地址返回给调用者,该返回地址被压入堆栈。执行 return 会将返回地址从堆栈中弹出,因此通常调用者会弹出它为调用函数而创建的形式参数。

你可能会问:返回值去哪里了?如果它进入堆栈,则调用者必须在参数旁边为其提供空间——但是,通常将返回值放在 CPU 寄存器中以避免这种情况。

【讨论】:

  • 这就到了,谢谢! :) 那为什么function parameterslocal variables 有区别呢?
  • 参数的值在一个函数(调用者)中定义,但在另一个函数(被调用者)中使用,而局部变量在同一个函数中定义和使用。
  • 介意显示更多伪代码,包括堆栈如何工作的函数参数和局部变量?不是汇编代码,可能是 C 或 JS 风格的伪代码来展示堆栈是如何工作的 ㋛㋛㋛
  • @LancePollard 和 Erik:在 asm 中,访问外部范围内变量的嵌套函数需要一个指向父堆栈帧的指针。我认为这被称为“静态链指针”。如果你想创建一个指向嵌套函数的函数指针,这只会变得棘手; C 不支持。 (GNU C 可以作为一个扩展,但它很麻烦。)如果你想支持它,我建议你设计它,这样你就可以对指向嵌套函数的指针与没有的普通函数指针有一流的支持外部范围上下文作为额外的参数。)
猜你喜欢
  • 1970-01-01
  • 2023-03-04
  • 2014-08-30
  • 1970-01-01
  • 1970-01-01
  • 2020-10-31
  • 2020-04-21
  • 1970-01-01
  • 2018-05-08
相关资源
最近更新 更多