探索Solidity编译管道、优化假设,以及它们如何与内存安全汇编相关。
探索 Solidity 编译管道、优化假设,以及它们如何与内存安全汇编相关。
memory-safe
是什么意思?当你处理内联汇编时,Solidity 会提供哪些保证?这个文档提出了一些要求,但是违反这些要求的生产代码一定不安全吗?
在本文中,我们对 Solidity 编译器进行了高级概述。我们还将深入探讨优化管道、语言规范,并就内存安全的实际含义提出论证。
为了简洁起见,我们只介绍 v0.8.13 版本的YUL IR Solidity 编译管道。编译分为两个主要步骤:
if (m_viaIR || m_generateIR || m_generateEwasm)
generateIR(*contract);
if (m_generateEvmBytecode)
{
if (m_viaIR)
generateEVMFromIR(*contract);
else
compileContract(*contract, otherCompilers);
}
每个步骤都会应用自己的一组优化。入口点位于YulStack::optimize和Assembly::optimize。
总共有四个步骤:
正如 v0.8.13 版本公布中提到的,YUL 优化器能够执行更复杂的优化。与 Solidity 相比,YUL 包含详细的语义信息,理论上比操作码更容易进行优化。
新管道的性能并不总是优于旧管道,但它可以跨函数进行更高级别的优化,所以请尝试一下并给我们反馈!
重要的是,每个步骤都是独立发生的,并且不保留前一阶段的信息。
优化器无法更改生成的 IR 的行为。这意味着我们不需要担心潜在的棘手优化,例如函数重新排序、删除未使用的赋值或将堆栈变量移至内存。
当谈到安全性时,我们只需要考虑IR的生成。但这里的保证到底是什么?
Solidity内存布局仅在 YUL IR 生成时存在。YUL 优化器和后续步骤没有有关此布局的信息。
如果优化器想要使用内存进行优化传递怎么办?它如何知道 IR 生成器使用了哪些槽位?
引入memoryguard
. 如果你曾经查看过solc --ir
的输出,这个调用可能会很熟悉。它用于初始化空闲内存指针。
/// @src 0:26:371 "contract XXX {..."
store(64, memoryguard(0x80))
根据文档说明
调用者使用
let ptr := memoryguard(size)
(其中 size 必须是常量数字)承诺只在范围[0, size)
或以ptr
为起点的无界范围内使用内存。
例如,如果 YUL 优化器需要 32 字节内存,它可以让 memoryguard
返回 size + 32
。优化器获得了一个保证不会被触及的内存区域!
实际中使用这种优化的一个例子是 StackLimitEvader,它将变量从栈移动到内存中。顺便说一下,这也是目前唯一依赖于 memoryguard
传递的语义信息的优化传递。
不同编译阶段之间的模块化设计也意味着我们不会被绑定到任何特定的内存布局。在一些应用中,将整个内存字节用于空闲内存指针可能并不合理。
不用担心,我们可以完全移除这个指针,并改为调用memoryguard(0x60)
。其余的管道仍然可以正常工作。
那么内存安全是什么意思呢?
Solidity 文档提供了一组约束,而不是定义:
特别说明,内存安全汇编块只能访问以下内存范围:
- 通过类似上述分配函数的机制自行分配的内存。
- 由 Solidity 分配的内存,例如你引用的内存数组范围内的内存
- 上述提到的从内存偏移 0 到 64 之间的临时空间。
- 位于汇编块开头的空闲内存指针值之后的临时内存,即在空闲内存指针处“分配”的内存,而不更新空闲内存指针。
从编译器的角度来看,内存不安全的汇编代码的存在似乎会清除内存保护 。
注:有趣的是,
memoryguard
是一个不透明的函数,它阻止优化推理空闲内存指针。这导致一些相当反直觉的行为 --memory-unsafe
代码可以减少 gas 的消耗, 尤其是在 YUL 头中。参考
// bool creationInvolvesMemoryUnsafeAssembly = m_context.memoryUnsafeInlineAssemblySeen();
// t("memoryInitCreation", memoryInit(!creationInvolvesMemoryUnsafeAssembly));
string IRGenerator::memoryInit(bool _useMemoryGuard)
{
// This function should be called at the beginning of the EVM call frame
// and thus can assume all memory to be zero, including the contents of
// the "zero memory area" (the position CompilerUtils::zeroPointer points to).
return
Whiskers{
_useMemoryGuard ?
"mstore(<memPtr>, memoryguard(<freeMemoryStart>))" :
"mstore(<memPtr>, <freeMemoryStart>)"
}
solc --ir
命令将不再像预期那样包含memoryguard(0x80)
。
/// @src 0:26:371 "contract XXX {..."
mstore(64, 128)
从语义上讲,缺少memoryguard
意味着 IR 生成器告诉优化器它无法保证memoryguard
不变性。
调用
let ptr := memoryguard(size)
(其中 size 必须是一个字符数字)的调用者承诺只使用范围为[0, size)
或从ptr
开始的无界范围的内存。
这是有道理的。在程序员没有更严格的保证,内存不安全的汇编可以随意访问内存的任何位置。因为优化器不再具备这个保证,它不能在任何优化过程中使用内存。
内存安全有多严格?就memoryguard
而言,只有在 0x80 之后访问内存似乎很重要。在[0x40, 0x7f]
范围内访问内存的memory-safe
带注释汇编真的安全吗?
Solidity文档中三次提到了未定义行为。
悬空引用的存在
使用 verbatim 不当
很不幸,文档只提供了对 verbatim 字节码的“非穷尽列表限制”。实际上,使用不透明字节似乎很难保证行为。参考
使用标记为"memory-safe" 内联汇编违反内存模型。
这为什么重要?
假设程序代码可以实现强大的优化 - 这就是为什么有符号整数溢出是未定义的原因。严格遵循编译器模型是至关重要的。未定义行为会在多年后显现为棘手的错误。
回到 Solidity,规范明确指出了这一点。不得修改零槽。
零槽用作动态内存数组的初始值,不应对其进行写入(空闲内存指针最初指向 0x80)。
任何触及 0x60 处零槽的代码都明显违反了规范。但这有关系吗?这就是 Solidity 和 YUL 之间的语义变得棘手的地方。回想一下,零槽是 Solidity 中的用来构造。
即使在生成过程中没有明确保证内联汇编将被逐字地发出
bool IRGeneratorForStatements::visit(InlineAssembly const& _inlineAsm)
{
setLocation(_inlineAsm);
if (*_inlineAsm.annotation().hasMemoryEffects && !_inlineAsm.annotation().markedMemorySafe)
m_context.setMemoryUnsafeInlineAssemblySeen();
CopyTranslate bodyCopier{_inlineAsm.dialect(), m_context, _inlineAsm.annotation().externalReferences};
yul::Statement modified = bodyCopier(_inlineAsm.operations());`
只要在汇编块执行之前和之后保持不变性,代码就可能是安全的。
在这篇博文中,我们介绍了一个关于 Solidity 编译器的探索。这旨在为好奇者提供有用的参考。编译器非常复杂,有隐含和显式的假设。当有疑问时,请阅读源代码。那么什么是内存安全性呢?
它是 YUL 生成和优化之间的承诺。
原文链接:https://osec.io/blog/2023-07-28-solidity-compilers-memory-safety
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!