{ 跳至内容 }

Solidity 团队问答第一期回顾

发布于 2020 年 11 月 4 日 由 Solidity 团队

公告

上周我们在 Reddit 上举办了第一场 Solidity 团队 AMA!我们想借此机会在这篇文章中总结一下最有趣和投票最多的问题和答案。

如果您想浏览完整的 AMA 线程,可以点击这里.

一般问题

路线图展望:Solidity 团队认为其中期和长期的最重要的功能目标是什么?实现这些目标的最大障碍是什么?

就编译器而言,目前最大的任务是将 Yul IR 的覆盖率提高到 100%,我们应该能够在年底前完成。因此,到 2021 年年中,我们应该有两个相同的编译器管道。

就语言特性而言,我们可以列出一些突出的主题及其当前的实现障碍:我们希望支持模板(障碍:复杂性),使副本和引用更加明确(障碍:接受度),更多地转向函数式编程(默认不可变,基于范围的循环,代数数据类型...),更有效地使用内存(消除“栈溢出”,释放内存),为调试器提供有关内部结构的更多数据,以及在优化器中使用 SMT 求解器和其他强大的工具(障碍:确保正确性)。

总的来说,最大的困难通常是获得有关语言特性的适当反馈,并协调所有这些变化。

用户:您希望人们停止使用 Solidity 的哪些事项/功能?

关于语言特性的使用,只要人们知道自己在做什么,他们就可以随意使用。不幸的是,用于源代码验证的源代码扁平化非常流行。扁平化的源代码结构性较差,并且会阻止您使用导入和其他功能的模块化。可以使用 sourcify 验证脚本 从 truffle 或 builder/hardhat 项目目录中提取所需的所有内容,以非扁平化形式重新编译您的智能合约。

栈溢出:是否可以改变访问栈的方式,以消除“栈溢出”问题?

可以!事实上,我们目前正在进行这项工作。

您可以查看 这里 的进度。这只会进入新的代码生成器,如果您从内联汇编访问内存,则需要进行一些更改,但从长远来看,它通常不再是一个问题。

Yul/内联汇编:是否有文档详细解释汇编中每个指令的工作原理?

内置函数表 应该提供一个很好的总结。如果您需要了解有关 EVM 的更多信息,可以尝试 关于 EVM 的部分。如果您遗漏了什么,请随时在 Solidity 存储库 中打开一个问题!

汇总:您对 optimism 的 solc 自定义有什么看法?这是一种可行的实现容器化的方式吗?

我希望他们在代码生成器完成之后切换到基于 yul 的方法。通过 IR 生成的代码不包含任何动态跳转,可以通过一个非常简单的工具轻松地重写它来完成 optimism 所需的操作。

Solidity 优化器

Solidity 优化器优化的是什么(大小或成本或其他?)?它是如何实现的?

Solidity 编译器使用两个不同的优化器模块:“旧”优化器在操作码级别运行,“新”优化器在 Yul IR 代码级别运行。基于操作码的优化器从 列表 中应用简化规则到彼此相邻的操作码。它还会合并相等的代码集,删除未使用的代码和其他一些内容。基于 Yul 的优化器功能更强大,因为它可以跨函数调用工作:在 Yul 中,无法执行任意跳转,因此例如可以计算每个函数的副作用:如果一个函数不修改存储,对它的调用可以替换为一个不修改存储的函数。如果一个函数没有副作用并且它的结果乘以零,则可以完全删除函数调用。

优化器试图简化复杂的表达式(这会降低大小和执行成本),但也专门化或内联函数。特别是函数内联是一个可以导致更大代码的操作,但它通常被执行,因为它会带来更多简化的机会。

在基于操作码的优化器中,只有一个阶段(“常量优化器”)会考虑部署时间和运行时间成本之间的权衡。这个阶段试图找到源代码中每个数字的“更好”表示,例如0x10000000000 可以编码为 PUSH6 0x10000000000(7 字节,几乎没有运行时成本),但它也可以编码为 PUSH1 1 PUSH1 40 SHL(5 字节,运行时成本略高)。大多数情况下,差异并不重要。

基于 Yul 的优化器的一大优势是,每个步骤都可以独立查看:每个步骤接收 Yul 代码作为输入,并生成 Yul 代码作为输出,而不会与其他步骤或分析代码有任何紧密依赖。此外,我们尝试使每个步骤尽可能简单,以便这些步骤中的错误不太可能出现。只要每个简单步骤都没有错误,整个 Yul 优化器也一样。

它是如何工作的?

优化器执行了几个步骤。一个简单的例子是在运行时已知值的表达式,例如,x + 0 被评估为 x,其中 x 可能是仅在运行时已知的参数。一个更复杂的例子是识别在循环中保持不变的表达式,并将它们移到循环之外,从而节省燃气。另一个有趣的例子是避免多次从存储中访问相同的值,即,在某些情况下,多个对同一槽位的 sload 可以减少为单个 sload

参考资料

优化后的代码和未优化的代码之间有哪些典型的差异?

通常,最明显的差异是常量表达式的评估。当涉及 ASM 输出时,还可以注意到等效/重复的“代码块”减少(比较标志 --asm--asm --optimize 的输出)。但是,当涉及 Yul/中间表示时,可能会有很大的差异,例如,函数可以被内联、组合、重写以消除冗余等(比较标志 --ir--optimize --ir-optimized 之间的输出)。

优化如何受到运行次数 (--optimize-runs) 的影响?是否存在一个最大数量,超过该数量优化就不再起作用?或者 --optimize-runs=20000 的效率低于 --optimize-runs=500000

该参数大致指定在合约的生命周期中,部署代码的每个操作码将被执行多少次。“运行”参数为“1”将生成较短但代价高昂的代码。最大值为 2**64-1

当多个小变量被打包到一个存储槽中时,我们如何确保对它们的访问都使用单个 SSTORE/SLOAD 完成?访问之间需要多么“接近”?除了检查汇编代码之外,还有没有更好的方法来确保这一点?

只要这仍然使用非 yul 代码生成器,它实际上就相当有限。最好在访问之间没有任何分支,因此将内存结构分配给存储应该可以最好地工作。

在从头到尾循环遍历数组时,将 arr.length 缓存到堆栈变量中是否有意义?或者如果它看到循环体不会更改它,它只会 SLOAD/MLOAD 一次?

在 Yul 优化器(用于新的代码生成器)中,有一个名为 LoopInvariantCodeMotion 的优化步骤,旨在检测在循环中保持不变的表达式并将它们移到循环之外。请看以下查找存储中动态整数数组总和的 Solidity 示例。

uint sum = 0;
for (uint i = 0; i < arr.length; ++i)
{
    sum += arr[i];
}

优化步骤可以正确识别 arr.length 保持不变,并将它移到循环之外。因此,对于此示例,无需手动缓存长度。

要了解是否需要手动缓存长度或循环内的任何其他存储/内存值,我们将描述该步骤的工作原理。

  1. 该步骤仅处理保持不变的表达式,因此,在上面的示例中,arr[i] 甚至不会被考虑移动。
  2. 如果这些表达式可以移动,即,这些表达式没有任何副作用,它们将立即被移到外面。此类指令的示例将是算术运算,例如 add 或不读取/修改内存、存储或区块链状态的指令,例如 address。非示例将是 keccak256(从内存中读取)、sload(从存储中读取)、call(可以修改区块链状态和合约存储)。
  3. 如果这些表达式有副作用,但只有 读取 类型的副作用,即从存储或内存中读取,例如 sloadmloadextcodesize 等,那么如果循环不写入相应的位置,它们就可以被移出循环。在上面的示例中,即使 arr.length 从存储中读取,由于循环中没有其他表达式可以写入存储,我们可以将 arr.length 移到循环之外。请注意,该步骤无法推断细粒度的存储或内存位置,即,写入存储槽(例如 0)意味着无法将 sload(1) 移到外面。这可能会在将来得到改进。

简而言之,对于新的代码生成器,如果不存在对存储(或内存)的写入,则无需缓存对存储(或内存)的读取。仅在以下情况下手动缓存才会有益:如果循环包含写入,但如果合约作者可以推断出写入不会修改已读取的变量。此情况的示例如下

// Copying storage arrays arr1 into arr2, assuming arr2 is big enough.
// Example where caching is helpful:
// uint len = arr1.length
// and replacing arr1.length with len will save gas
for (uint i = 0; i < arr1.length; ++i)
{
    arr2[i] = arr1[i];
}

请注意,新的代码生成器尚未激活。对于旧的生成器,存在一些低效之处,因此手动将长度缓存到堆栈中可能更便宜。

编译器是否曾经内联函数?用户是否可以要求编译器这样做?即使这样做不值得减少调用开销,这也可以为常量折叠提供新的机会。

当前的代码生成器不会内联函数,但新的代码生成器会,正是为了这个目的。我们不打算让用户控制这些功能。根据函数的调用方式,内联函数也可能是有意义的,但总的来说,小型函数很可能被内联。

为什么你认为人们普遍对优化器持怀疑态度,他们的怀疑是合理的嗎?

优化器在几年前曾经非常复杂。在此期间,我们禁用了大多数复杂的例程并修复了几个错误。虽然优化器中可能存在错误,就像任何代码中都可能存在错误一样,但它们通常以一种易于检测的方式表现出来。像 **ABIEncoderV2** 这样的新的编译器代码更注重正确性而不是效率,并且是在优化器会被使用的假设下编写的。因此,对于最新版本的 Solidity,我们建议始终使用优化器,除非你真的不在乎 Gas 成本。

安全性

你计划如何使 Solidity 成为一种更安全的语言?(特别是关于在 这篇文章 中提出的论点)。

链接文章中提到的绝大多数问题在几个月甚至几年前就已修复。在我们的设计决策中,我们始终关注安全性。Solidity 所做的大部分工作都是为了消除 EVM 的限制和怪异之处。一个例子是,调用一个不存在的合约被认为是成功的。因此,Solidity 始终检查要调用的合约是否存在。此外,内置的 SMT 检查器(pragma experimental SMTChecker)每周都在改进,并且可以在你编写代码时检测到许多问题。

让我们一步一步地解决提到的问题

  1. 重入性:以太坊社区对于重入性是特性还是缺陷没有明确的共识。多年来,许多工具已经发展起来,用来标记存在重入性问题的代码,而完全阻止重入调用不仅成本高昂,还会产生一类新的错误。

  2. 访问控制:可视性问题在几年前就已解决,通过使其变得显式。在最近的 Solidity 版本中,你甚至可以将函数移动到文件级别(在合约之外),在那里很明显它们无法从外部调用,也无法访问存储变量(除非明确提供为参数)。

  3. 上溢和下溢:安全数学作为一种语言特性已经实现,并将成为下一个重大版本中的默认设置(参见Solidity 0.8.x 预览版本).

  4. 对低级别调用的未检查返回值:编译器多年来一直在标记未检查的低级别调用。

  5. 拒绝服务:随着每次重大版本的发布,我们都在越来越限制对无限大小的对象可以执行的操作。在这方面最大的变化是 使复制的语义更加可见,但我们还没有收到太多关于此功能是否有助于而非令人讨厌的反馈。

  6. 不良随机性:这更多是一个通用的区块链问题,而不是在 Solidity 中要解决的问题。你怎么能确定你在区块链上看到的任何数字都是真正的随机数 ? 更重要的是,你怎么能确保生成随机数的人在提前知道这个数字方面没有不公平的优势?当然,有一些方法可以实现这一点,例如可验证随机函数,但将它烘焙到语言中存在两个主要问题。首先,这一切都是新的加密技术,而且还没有标准化。新的方法一直在不断开发,新的漏洞也不断被发现。与通常的密码学研究速度相比,它正在以惊人的速度发展,在承诺任何特定方案方面存在很大风险。其次,你无法真正地在线上进行操作。即使可以做到,成本也会非常高(证明生成在计算方面臭名昭著地密集)。最好依靠一种解决方案,其中随机性由预言机提供,只在线上进行证明验证。

  7. 时间操纵:在 Solidity 级别上,你无法阻止这些攻击。时间戳来自底层的区块链,由矿工设置。 时钟同步 即使在不考虑恶意行为者的情况下也很难。客户端确实有验证规则,可以防止这些时间戳偏离客户端机器上的时钟太远,但这不足以阻止矿工引入微小的差异,这些差异会被此攻击利用。比特币和以太坊都受到了它的影响。GovernMental 攻击的额外复杂之处在于,合约作者是攻击者。因此,阻止它不仅需要提供更好的功能和人体工程学,还要主动限制程序员。作为一般规则,我们试图在 Solidity 级别上提供尽可能多的安全性,但仍然让你能够降到内联汇编级别,并执行几乎任何你想要的操作。区块时间戳有比恶意使用更多的合法用途,因此阻止你完全使用它将是不可接受的。

  8. 短地址攻击:从 Solidity 5.0 开始,内置了一个保护机制,如果 calldata 太短,它会回滚交易。如果你使用 ABIEncoderV2,你甚至可以防止不适合提供类型的输入。此外,请注意,Solidity ABI **是**强类型化的,如果客户端使用的 ABI 定义与合约的实际 ABI 不匹配,或者如果客户端存在错误并且没有真正遵循它,那么仅凭这一点无法保护你。

我们希望你喜欢第一个 AMA,并期待在下一期中收到更多问题!同时,欢迎加入 语言设计邮件列表 或查看我们新推出的 Solidity 语言门户

上一篇

下一篇

参与进来

GitHub

Twitter

Mastodon

Matrix

了解更多

博客文档用例贡献关于论坛

2024Solidity 团队

安全策略

行为准则