Solidity错误Stack Too Deep

当一个人开始在Solidity编写智能合约时,他/她迟早会遇到一个非常烦人的障碍。“Stack Too Deep”错误。很容易陷入这个陷阱,当发生这种情况时,通常很难找到出路。公平地说,根本原因不在于Solidity本身,而在于以太坊虚拟机(EVM),因此可能会影响编译成EVM的其他语言(即LLL,Serpent,Viper),但这是一个微妙的区别在编写智能合约的日常工作中。

令人惊讶的是,考虑到这可能导致的烦恼程度,很难找到如何处理它的好资源,所以我决定写这篇文章试图对它有所启发,为了我自己的利益和其他任何人谁可能对它绝望。

一般而言,当代码需要访问堆栈中比第16个元素更深的slot(从顶部向下计数)时,似乎会生成此错误。但是,我们如何实现这一目标可以通过多种方式完成。这篇文章的目的不是提供关于如何产生这种错误的完整理论:根据我的经验,有太多的方法可以做到这一点。但它将为一个共同的触发器提供一个很好的理由,并希望让读者更多地了解EVM如何管理它的堆栈。甚至可以将相同的逻辑扩展到发生错误的其他情况,并寻找避免它的方法。

在Solidity中,大多数类型(例如基本类型,例如数字,地址和布尔值,但不是数组,结构和映射)都通过值传递给函数:当调用函数时,堆栈的一部分(即stock frame)被分配用于保存程序在函数返回时返回的地址和函数值类型输入和输出参数的副本。每个参数通常在堆栈中保存一个slot,其中每个slot为256位。

这提供了命中Stack Too Deep错误的最基本方法:总共有超过16个输入和输出参数。但实际上,如果我们希望该函数做一些有用的事情,我们必须非常小心,并且可能必须减少参数的数量。

为了测试这个,我在Remix中创建了一个小合约,如下所示:

1
2
3
4
5
6
7
pragma solidity ^0.4.24;
contract TestStackError {
event LogValue(uint);
function logArg(uint a1) public {
emit LogValue(a1);
}
}

Remix非常适合这样的调查,因为我们可以快速编写合约并对其进行查询,但基本上是因为Remix提供了一个功能强大的调试器,其中包含操作码反汇编以及堆栈,内存和存储的完整列表。代码中前后移动也很容易,这是我用任何语言提供的最佳调试体验之一。

这个合约非常简单:它没有状态变量,只有一个函数,它也非常简单。此函数只接受一个参数并记录它。

我将此合约复制到Remix中的新文件,编译并部署它。应该没有错误和警告,因此我转到Run选项卡,然后点击Deploy

然后,我扩展SimpleFunction合约的列表,并在logArg前面的框中输入单个值。我按下按钮并检查控制台中的输出:

如你所见,我输入了值7,并将其作为日志中的唯一元素返回。 虽然日志值得另外发布,但我在这里应该提到一些事情。

这是此调用的JSON格式的日志对象:

1
2
3
4
5
6
7
8
9
10
logs [
{
"from": "0xef55bfac4228981e850936aaf042951f7b146e41",
"topic": "0xfcf771399d75a67a6d0e730ae98d34c40b6bfe6ebf8053b98ddf4da8c2706250",
"event": "LogValue",
"args": {
"0": "7",
"length": 1
}
}
  • 日志由emit关键字在solidity中创建,这会引发一个solidity event并对应于LOGn操作码。
  • 日志可以通过离线运行的客户端应用程序进行过滤。过滤器是日志中可用主题的条件。
  • 日志始终具有topic0,这是事件event签名的编码。
  • 可以通过索引参数来创建更多主题。最多可以有3个索引参数。其余的被视为事件数据。

在这个简单的例子中,我们可以很容易地确定只有一个主题0xfcf771399d75a67a6d0e730ae98d34c40b6bfe6ebf8053b98ddf4da2c2706250并且数据显示为日志对象的args成员的一部分。我们还可以验证代码是否按预期工作。

现在让我们测试这个合约的限制,并改变函数以接受最大数量的参数。

1
2
3
4
5
6
7
8
9
10
11
pragma solidity ^0.4.24;
contract TestStackError {
event LogValue(uint);
function logArg(uint a1, uint a2, uint a3, uint a4,
uint a5, uint a6, uint a7, uint a8,
uint a9, uint a10, uint a11, uint a12,
uint a13, uint a14, uint a15, uint a16
) public {
emit LogValue(a16);
}
}

我有16个输入变量,没有输出变量,因此我只需要使用16个堆栈槽。 我调用传递值1到16的函数并发出最后一个值。 我查看日志并查看值16.很棒,这有效!

然后,我对我的合约做了一个非常小的改动:我记录了第一个参数:

等等,什么?! 简单地记录一个不同的参数已经将完美的合约变成了“Stack Too Deep”错误。 哇,这里发生了什么?

这不是Solidity可以阐明的。 在那个层面上,这种变化看起来完全无害。 我需要深入了解EVM字节码以了解发生了什么。 但在我这样做之前,我想再做一次测试,收集一些线索。 我创建了此合约的第三个版本,但是记录了a2:

1
2
3
4
5
6
7
8
9
10
11
pragma solidity ^0.4.24;
contract TestStackError {
event LogValue(uint);
function logArg(uint a1, uint a2, uint a3, uint a4,
uint a5, uint a6, uint a7, uint a8,
uint a9, uint a10, uint a11, uint a12,
uint a13, uint a14, uint a15, uint a16
) public {
emit LogValue(a2);
}
}

这有效,并记录正确的值。当我记录a3时也会发生同样的情况。我假设a2和a16之间的所有参数都可以正确记录。生成的操作码位于以下三个文件中:

我比较了它们之间的所有3个日志,并且让我印象深刻的是它们的大小(行数)都不同。第二件事是它们在第237行之前非常相等,只有一个例外。这一行之后的代码非常不同,显然是不可预测的。但是,由于这似乎是在函数返回之后,我将忽略它。

然后我专注于第237行之间的一个区别,发生在第198行。我很高兴地确认一个我认为可以解释堆栈太深的错误的想法: 在代码的某些地方我们逻辑上需要调用一些不存在DUP或SWAP操作码。这确实是这样的情况:除了第198行的一个单一差异外,所有3个版本都是相同的,直到第237行:

  • log(a2):DUP16
  • log(a3):DUP15
  • log(a16):DUP2

操作码DUPn复制堆栈第n级的值。从DUP1到DUP16只有16个这样的操作码。DUP1将当前值的副本推送到堆栈,DUP16复制堆栈中的第16个最高值。参数列表中变量的位置与此行中的DUPn值之间存在明显的关系,如果我将其外推到案例日志(a1),则此规则意味着我们需要一个操作码DUP17。但是这样的操作码不存在,它指向堆栈中的值低于我们可以达到的值,这证明了错误消息“Stack Too Deep”。

对此感到满意,我天生的好奇心提出了一个问题:这个DUP操作码在这里扮演什么角色?它的目的是什么?

字典是令人生畏的。我最后一次看到汇编代码的时候有一定程度的理解它是在我十几岁的时候,使用Spectrum的Z80处理器。我没有任何使用EVM的经验,所以我不打算在脑海中解析200行类似于汇编的列表。但Remix确实在这方面提供了很好的工具。在调试选项卡中,我们可以通过操作码重放事务操作码,并一目了然地查看堆栈的内容,内存和存储等。

在我继续之前,我想指出一下由Alejandro Santander撰写的关于组装EVM代码结构的Zeppelin博客中的这一系列帖子。这是对EVM组装的无价介绍,将使我免于解释样板。另一个非常有用的链接是EVM操作码列表,这是我最喜欢的参考,可以找到每个操作码的功能。我强烈推荐它。

这个函数并不多,大多数字节码都是重复的。操作码CALLDATALOAD有17次出现。第一个出现在代码的第一个块中,在函数调度之前。它检查calldata是否太短(第12行),在这种情况下函数将恢复。在此之后,它将函数选择器与合约中已知的方法(在这种情况下,只有一个:e898288f)进行比较,如果它匹配any,则将流指向实现该函数的地址。否则,调用将恢复。

在这种情况下,代码调用了唯一的现有函数,因此流程跳转到地址70(第25行)来处理它。

剩下的16个CALLDATALOAD实例正好是我们拥有的数字或参数,它们恰好以9行间隔出现,并且可能负责处理函数的每个参数。因此,我使用Remix调试器运行这些行并观察到它们确实将每个连续的参数加载到堆栈上(我并不担心这9个操作码如何复制这些数据)。接下来是3个POP指令,它们清除我们不再需要的堆栈部分(用于计算要读取的下一个参数的调用数据中的位置)。此时,堆栈的顶部保存第16个参数,第二个元素保存第15个参数,依此类推。在这个阶段,堆栈的第16个元素是第一个参数。接下来是函数的返回地址(0x109)和函数选择器。

然后代码将topic0的32字节标识符fcf771399d75a67a6d0e730ae98d34c40b6bfe6ebf8053b98ddf4da8c2706250推入堆栈中,该标识符将第一个输入从堆栈的前16个元素中推出,然后使用DUP操作码将其置于堆栈顶部,该参数为记录事件(例如a2或a16)。

接下来的20行左右准备内存以将日志事件的参数保存在内存位置0x80,并保证堆栈在其前两位置具有此地址和数据长度(0x20)。然后,它调用操作码LOG1,它使用堆栈中前3个位置的数据发出一个带有一个参数和一个主题的日志事件:

  • 0: 0x0000000000000000000000000000000000000000000000000000000000000080
  • 1: 0x0000000000000000000000000000000000000000000000000000000000000020
  • 2: 0xfcf771399d75a67a6d0e730ae98d34c40b6bfe6ebf8053b98ddf4da8c2706250

总共有五个LOGn操作码,LOG0LOG4,其中n表示日志中的主题数。Topic0始终是事件类型的标识符,由其签名的哈希定义,但可以使用LOG0跳过它,它指定匿名事件。每个附加主题都需要堆栈中的另一个插槽,从可访问列表中推出更多参数。

此分析显示具有一个参数的事件会阻止使用一个变量,因为topic0在事件数据之前放置在堆栈中。这提出了几个问题:

  • 如果我们有更多主题怎么办?它们是否也在数据之前放入堆栈中?
  • 更多事件争论的影响是什么,他们是在主题之后还是之前被推?

为了测试,我将再次更改合约。请注意,事件可以包含任意数量的参数,并且最多可以将其中的3个参数编入索引。索引参数成为主题,而其他参数则集中在数据部分。在此阶段,我的假设是每个主题(索引参数)将在数据之前放置在堆栈中,因此将阻止访问更多早期变量。

在我的测试中,我介绍了几个场景,但它们都得出了相同的结论,因此我将为你节省细节。我将用另一个有趣且反直觉的案例进行说明,然后得出最终结论。

首先,让我们尝试这个版本的合约,其中事件有一个索引值和两个非索引值。

1
2
3
4
5
6
7
8
9
10
11
pragma solidity ^0.4.24;
contract TestStackError {
event LogValue(uint indexed a1, uint a2, uint a3);
function logArg(uint a1, uint a2, uint a3, uint a4,
uint a5, uint a6, uint a7, uint a8,
uint a9, uint a10, uint a11, uint a12,
uint a13, uint a14, uint a15, uint a16
) public {
emit LogValue(a2, a3, a4);
}
}

此函数的字节码(在函数调度之后)直到发出事件为止:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
265 JUMPDEST
266 DUP15
267 PUSH32 a5397a5faa0ec7cfb89428503b91a13bbd737592f7561e6773fa3e1458c8735c
300 DUP16
301 DUP16
302 PUSH1 40
304 MLOAD
305 DUP1
306 DUP4
307 DUP2
308 MSTORE
309 PUSH1 20
311 ADD
312 DUP3
313 DUP2
314 MSTORE
315 PUSH1 20
317 ADD
318 SWAP3
319 POP
320 POP
321 POP
322 PUSH1 40
324 MLOAD
325 DUP1
326 SWAP2
327 SUB
328 SWAP1
329 LOG2

发出事件的操作码是LOG2。这意味着我们有两个主题,一个是默认的topic0(即事件签名),另一个是事件签名中唯一的索引参数。其余两个值分组在内存中。如果我们检查Ethervm是否有这个操作码,我们会看到从堆栈中读取的最后一个值,以及第一个被推送到它上面的值是topic1,即索引参数a2。最初,它被放置在堆栈的第15位。操作码DUP15将值的副本放在堆栈的顶部,从而将所有其他参数向下推。从现在开始,例如,a2处于位置16,a1处于位置17。

下一条指令将32位值推送到堆栈,这简单地对应于topic0.此值是硬编码的。这也有助于再次推倒论点。现在,a2位于第17位。

以下说明是两个DUP16操作码。第一个复制第16个位置的值,这是第三个参数a3。但是由于这会将一个新元素推送到堆栈上,当下一个操作码被调用时,DUP16会将第四个参数复制到函数a4。在这个阶段,在堆栈的顶部,我们有事件的数据(两个单词),索引参数和事件唯一标识符。

以下行将前两个值复制到内存:

  • (302-305):将内存0x40的内容放在堆栈顶部,两次。这是事件数据所在的内存位置(在我的执行中为0x80)。
  • (306-308):将第一个数据字放在内存中的第一个空闲位置(即将a3放在位置0x80)。
  • (309-311):将下一个空闲位置放在堆栈顶部的内存中。
  • (312-314):将第二个数据字放在存储器中的下一个空闲位置(即将位置放置在位置0xa0中)。
  • (315-321):在消除不再需要的值之后,计算内存中的下一个空闲位置并将其保留在堆栈的顶部。
  • (322-327):通过从该位置的当前值(保持在堆栈顶部)中减去内存中下一个空闲位置的初始地址,找到提交给事件的数据的长度。
  • (328):重新排序堆栈的前两个元素,使第一个元素成为事件数据的开头,第二个元素表示该数据的长度。
  • (329):最后调用日志操作码。

我给出了详细的解释,以便你可以了解此过程的工作原理,如果你愿意的话。在那种情况下,也许你现在可以解释下一个明显的怪异。仅将事件的签名更改为:

1
event LogValue(uint a1, uint indexed a2, uint a3);

是的,另一个Stack Too Deep错误。你能看出是什么造成的吗?

字节码变化不大。我们仍然拥有相同数量的主题,因此最后的操作码仍然是LOG2。并且它仍然希望以相同的顺序接收它的参数,即首先是主题,然后是数据。

现在,必须首先加载第二个主题,因此a3将是使用DUP14推送到堆栈的第一个值。然后将推送topic0。现在,EVM将在堆栈的顶部放置它需要存储在内存中的两个参数a2a4。这些最初位于第15和第13位。但是,EVM已经进行了两次推送,这使得这些位置为17和15.不可能将第一个值放在堆栈中(DUP17不存在),因此编译错误。

所以现在我们理解了这一点,我尝试再改变一件事,将日志功能改为:

1
emit LogValue(a3, a2, a4);

此代码有效,因为它在更改索引参数的顺序之前与最后一个块非常接近。在该代码中,使用a2调用事件的索引值。在这个版本中,它仍然是传递到该位置的a2,而其他版本保持不变。字节码解释几乎相同。

这是一个很长的帖子。如果你到目前为止,有必要让你有条理地了解正在发生的事情,这样你就可以回到你的程序并思考你的“Stack Too Deep”错误是否可能是由类似的行为造成的。虽然这篇文章仅涉及发出事件的情况,但是其他函数将使用其他操作码,但在​​需要进行某些计算时将函数参数(或中间值)复制到堆栈中仍然具有相同的逻辑。

所以这里有一些流水线的笔记要记住:

调用函数时,会创建堆栈帧。这包括从下到上:

  • 功能选择器。
  • 退货地址。
  • 函数最左边的value-type参数。
  • 函数最右边的value-type参数。
  • “Stack Too Deep”错误取决于动作的中央操作码(例如算术,散列,调用另一个函数,发出事件等)。
  • 如果对纯函数参数执行这些中心操作,则它们传递给函数的顺序可能决定“Stack Too Deep”错误的发生。(堆栈槽也可用于中间计算和局部变量,但我打算在后面的文章中研究它们。)
  • 了解操作码的参数的数量和顺序至关重要。这些参数通常从堆栈中读取(唯一的例外是PUSH操作码)。
  • 在执行操作码之前,必须将操作码参数推送到堆栈。每个PUSH将函数参数向下移动至少一个插槽。堆栈中更深层次的函数参数是首先处理的函数参数,即函数签名中最左边的函数参数。
  • 如果在操作码操作中没有使用某些函数参数,那么它们应首先出现在函数签名中,以减少操作码参数在需要堆叠时无法触及的可能性。
  • 操作码使用堆栈中不同级别的参数。首先推动更深层次。如果一个参数被推到另一个参数之后,它也应该出现在前者之后的函数签名中,否则它会在它被使用之前将另一个推入堆栈。例:

    • 1.考虑一个带有两个索引参数t1t2的事件,这个参数在一个带有多个参数的函数内调用,其中a1a2之前出现。
    • 2.如果以t1 = a1t2=a2发出事件,则将调用操作码LOG3
    • 3.在调用此操作码之前,t2=a2将首先被推入堆栈。
    • 4.这将推动a1下降,并在时间到来推动t1=a1的值时将其置于无法到达的风险中。
    • 5.如果a1位于函数签名中的a2之后,则可以避免这种情况,因为它在堆栈中比a2更高。假设a2在推动时可以到达,那么之后也是a1

上面的帖子只集中在LOGn操作码上,特别是在堆栈中需要3或4个参数的版本上。更困难的情况是调用其他合约或库中的函数,因为操作码CALLDELEGATECALL各自带有7或6个输入参数,操作码和函数参数之间的交互可能性更大。

我希望这能为你提供有关如何调试和处理“Stack Too Deep”错误的一些线索。还有很多话要说,但这将需要等待其他机会。

直到下一次。

======================================================================

分享一些以太坊、EOS、比特币等区块链相关的交互式在线编程实战教程:

  • java以太坊开发教程,主要是针对java和android程序员进行区块链以太坊开发的web3j详解。
  • php以太坊,主要是介绍使用php进行智能合约开发交互,进行账号创建、交易、转账、代币开发以及过滤器和交易等内容。
  • python以太坊,主要是针对python工程师使用web3.py进行区块链以太坊开发的详解。
  • 以太坊入门教程,主要介绍智能合约与dapp应用开发,适合入门。
  • 以太坊开发进阶教程,主要是介绍使用node.js、mongodb、区块链、ipfs实现去中心化电商DApp实战,适合进阶。
  • C#以太坊,主要讲解如何使用C#开发基于.Net的以太坊应用,包括账户管理、状态与交易、智能合约开发与交互、过滤器和交易等。
  • EOS教程,本课程帮助你快速入门EOS区块链去中心化应用的开发,内容涵盖EOS工具链、账户与钱包、发行代币、智能合约开发与部署、使用代码与智能合约交互等核心知识点,最后综合运用各知识点完成一个便签DApp的开发。
  • java比特币开发教程,本课程面向初学者,内容即涵盖比特币的核心概念,例如区块链存储、去中心化共识机制、密钥与脚本、交易与UTXO等,同时也详细讲解如何在Java代码中集成比特币支持功能,例如创建地址、管理钱包、构造裸交易等,是Java工程师不可多得的比特币开发学习课程。
  • php比特币开发教程,本课程面向初学者,内容即涵盖比特币的核心概念,例如区块链存储、去中心化共识机制、密钥与脚本、交易与UTXO等,同时也详细讲解如何在Php代码中集成比特币支持功能,例如创建地址、管理钱包、构造裸交易等,是Php工程师不可多得的比特币开发学习课程。
  • tendermint区块链开发详解,本课程适合希望使用tendermint进行区块链开发的工程师,课程内容即包括tendermint应用开发模型中的核心概念,例如ABCI接口、默克尔树、多版本状态库等,也包括代币发行等丰富的实操代码,是go语言工程师快速入门区块链开发的最佳选择。

汇智网原创翻译,转载请标明出处。这里是Solidity错误Stack Too Deep