本文详细介绍了如何通过内联汇编更高效地实现 Solidity 中的回滚操作,深入探讨了 mstore
和 mstore8
操作码的使用方式,并通过示例代码展示了如何在汇编中实现无消息回滚、自定义错误回滚以及带有原因字符串的回滚。
通过内联汇编恢复事务比使用高级 Solidity revert
或 require
语句更省 gas。在本指南中,我们将探讨 Solidity 中不同类型的 revert是如何在底层工作的,通过模拟它们在汇编中的实现。
下面的示例显示,在汇编版本中,revert
语句将 gas 成本从 157 gas 降低到 126 gas,节省了 31 gas:
作为先决条件,我们假设你已阅读 Try Catch and All the Ways Solidity Reverts 文章以及我们的 ABI 编码文章。
在 EVM 中,内存是一个长的字节数组,以字节为索引。也就是说,我们可以根据它们的索引读取和写入字节。尽管内存是字节索引的,但我们通常每次读取和写入 32 字节。
mstore
及其工作原理使用汇编进行 revert 很大程度上依赖于 Yul 中的 mstore
操作码来在内存中存储数据,因此我们先深入探讨该操作码。
mstore
操作码接受两个参数:
以下是如何使用 mstore
的示例:
assembly {
mstore(memoryLocation, dataToStore)
}
如果你想在内存位置 0x00
存储 32 字节的 0xFF
,你可以写:
assembly {
mstore(
0x00,
0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
)
}
这将在索引 0x00
开始存储完整的 32 字节值。如果你希望在内存位置 0x01
存储该值,则可以这样写:
assembly {
mstore(
0x01,
0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
)
}
这将数据的起始位置向后移动一个字节,且 0x00
处的第一个字节将保持不变。以下图显示了 mstore
如何在内存中存储数据:
请注意,尽管我们在 mstore(0, ...)
中指定了写入字节索引为 0
,但我们写入了 0
以及接下来的 31 个字节,mstore
每次写入 32 个字节。
mstore
中的隐式数据填充如果我们在第二个参数中指定的字节少于 32 字节(64 个十六进制字符),Solidity 编译器会用零(更显著的字节)进行左填充,直到值达到 32 字节长,然后将这 32 字节从 mstore
的第一个参数指定的字节索引写入。
考虑以下示例:
assembly {
mstore(0x00, 0xff)
}
上述代码将数据存储为 0x00000000000000000000000000000000000000000000000000000000000000ff
,其中 0xff
占据了最后一个字节,其余的 31 个字节用零填充。
换句话说,mstore(0x00, 0xff)
隐式变为 mstore(0x00, 0x00000000000000000000000000000000000000000000000000000000000000ff)
内存中值的结果如下所示:
请记住,mstore
写入 32 字节,但在这种情况下,31 个字节为零,从 0th
字节到 30th
字节(包含)均为零。这意味着字节范围 0-31 内的任何数据将被零覆盖。
如果我们返回所存储的数据,看看内存中其表现如何,结果如下面的屏幕截图所示:
以下图示显示 mstore
隐式左填充各种十六进制值,第一行为我们刚刚查看的示例。
mstore8
在内存中存储数据另外,我们可以使用 mstore8
操作码,它与 mstore
类似,但仅在特定内存位置存储一个字节的数据。
assembly {
mstore8(memoryLocation, exactlyOneByteOfData)
}
例如,如果我们想在第 31
个字节处存储一个字节的数据(0xff
),我们可以直接用 mstore8
存储,如下所示:
assembly {
mstore8(31, 0xff)
}
输出将与使用 mstore
相同,0xff
占据最后一个字节。
mstore8
和 mstore
之间的主要区别在于,mstore8
不会添加 31 个额外的零,这些零本会覆盖从 0th
到 31st
字节范围内以往存储的数据。
mstore
右侧带零写入 0xff
如果你想使用 mstore
而不是 mstore8
在 0th
字节处写入 0xff
,可以按如下方式存储 0xff
作为第一个字节,并用零填充其余的 31 个字节:
assembly {
mstore(
0x00, 0xff00000000000000000000000000000000000000000000000000000000000000
)
}
这将精准地按你所指定的内容存储,0xff
在前面,其余字节为零:
这是在 remix 中代码的测试运行:
这看起来有很多零,对吧?另外,我们可以使用 mstore8
在特定内存位置存储一个字节的数据。在下面的示例中,我们使用 mstore8
在第 0th
字节处存储 0xff
:
这是更紧凑的代码,执行与先前截图中相同的操作,只不过它没有将零写入字节 1 到 31。
请注意:
mstore
存储整个 32 字节的数据,而 mstore8
仅在指定的内存位置存储单个字节。mstore
时,如果你的数据少于 32 字节,它会自动用左侧的零字节填充以填满 32 字节。这些零将覆盖之前在任何其他内存内容中。记住我们提到过 mstore
左填充的额外零将覆盖 0th
到 30th
字节范围内的任何现有内容?让我们探讨该覆盖是如何发生的。
如果我们使用 mstore8
将 0xCC
写入 0th
字节:
assembly {
mstore8(0, 0xCC)
}
我们现在将在 0th
位置有 0xCC
,而内存的其余部分保持不变,如下面的图示所示。
随后,如果我们使用 mstore(0, 0xFF)
将 0xFF
写入,如下所示:
assembly {
mstore8(0, 0xCC)
mstore(0, 0xFF)
}
0xFF
将覆盖先前存储在 0th
字节的位置 0xCC
,并将整个 32 字节槽(从 0th
到 31st
字节)填充为 0xFF
。
回想一下 mstore
会将数据写入整个 32 字节槽,如果我们少于 32 字节,它会像下面这样用零填充剩余字节:
0x00000000000000000000000000000000000000000000000000000000000000**FF**
下面的动画展示了这个覆盖是如何发生的:
https://img.learnblockchain.cn/2025/02/28/Mstore8Scene.mp4
这证明了 mstore
赋值的 31 个填充零实际上更改了内存的内容。
mstore
填充与其记住 mstore
用零左填充十六进制值,我们可以认为 mstore(0, 0xff)
完全等同于 mstore(0, 255)
。
换句话说,mstore(0, 255)
是在说“在从字节 0 开始的 32 字节中存储数字 255,字节 0 中含有最高有效字节。”
由于 255 是与 32 字节数字所能包含的最大值相比的一个“小数字”(即 uint256 的最大值),因此只会使用最低有效位。最低有效位在右侧,最高位在左侧置为零。
类似的数字 0xff00000000000000000000000000000000000000000000000000000000000000
就相当大。
它的十进制是 115339776388732929035197660848497720713218148788040405586178452820382218977280
。因此,它使用了最高有效位,这些位在左侧。
我们已经看到了如何使用 mstore
在内存中存储数据。在 revert 过程中,我们需要将错误数据存储在内存中并将其作为 revert 错误消息返回。
revert
操作码接受两个参数:起始内存槽和我们打算返回的总数据大小。
revert(startingMemorySlot, totalMemorySize)
从这里起,我们将展示如何模拟 Solidity revert 在以下情况下的行为:
对于一个简单的无消息的 revert,汇编代码 revert(0,0)
在行为和 gas 成本上与 Solidity 中的 revert()
等价。它不会向调用者返回任何数据。
在底层,使用 revert(0,0)
意味着“使用无数据”,因为被引用的数据长度为零。通常使用内存位置 0 作为起始点,但由于我们不返回任何内容,我们可以使用 revert(1,0)
并实现同样的效果。
以下是使用汇编的无理由简单 revert 示例:
contract ContractA {
function zero() external {
assembly {
revert(0,0) //<--- 无理由简单 revert
}
}
}
下面的屏幕截图显示了从 ContractA
到 ContractB
的 低级调用 以及低级调用如何返回 false
因为 ContractB
被 revert,并且由于我们使用 revert(0,0)
不返回任何数据。
要演示如何使用汇编模拟没有参数的自定义错误,让我们以 revert Unauthorized()
为例。
我们将在内存中的特定位置(按约定为 0x00
)存储自定义 revert 的 错误函数选择器,并 revert 将指向该内存位置。
以下是我们将使用的 Solidity 代码示例:
contract CustomError {
error Unauthorized();
function revertCustomError() {
revert Unauthorized();
}
}
我们将遵循以下步骤来实现汇编中的自定义 revert:
revert
的参数assembly {
mstore(memoryLocation, selector)
revert(memoryLocation, sizeOfSelector)
}
当 Solidity 触发自定义错误时,返回值是自定义错误本身的 ABI 编码,它包括自定义错误签名的哈希函数的前四个字节(也就是选择器)。
由于我们使用自定义错误 Unauthorized()
作为示例,我们首先存储函数选择器(Unauthorized()
的 keccak256 的前四个字节)将是 0x82b42900
并用额外的零进行填充以将值扩展至 32 字节,以确保函数选择器的实际四个字节从字节 0 写到字节 3 包含。如果没有这个填充,选择器将无法从内存索引 0 开始写入。
bytes32 selector = bytes32(abi.encodeWithSignature("Unauthorized()")); // 0x82b42900
assembly {
mstore(0x00, 0x82b4290000000000000000000000000000000000000000000000000000000000)
}
然后我们使用下面的 revert 语句触发自定义错误。记住,revert 的模板是 revert(startingMemorySlot, totalMemorySize)
。
revert(0x00, 0x04)
0x00
是我们存储错误数据的内存位置,而 0x04
(以十六进制表示)是错误的大小,仅为 4 字节。整个 revert 代码应如下所示:
pragma solidity 0.8.27;
contract RevertErrorExample {
function revertWithAssembly() public pure {
assembly {
mstore(
0x00,
0x82b4290000000000000000000000000000000000000000000000000000000000
)
revert(0x0, 0x04)
}
}
}
这是我们将用于比较汇编实现和 Solidity 实现的代码:
// SPDX-License-Identifier: GPL-3.0
pragma solidity 0.8.27;
contract RevertErrorExample {
error Unauthorized();
// 汇编版本
function revertWithAssembly() public pure {
assembly {
mstore(
0x00,
0x82b4290000000000000000000000000000000000000000000000000000000000
)
revert(0x0, 0x04)
}
}
// Solidity 版本
function revertWithoutAssembly() public pure {
revert Unauthorized();
}
}
结果如下所示,屏幕截图显示,在触发通过汇编的 revert 时,我们节省了 54 个 gas 单位。
此外,在下面的代码中,callContractB
单独使用 try/catch
对 customRevertWithAssembly
和 customRevertWithoutAssembly
进行解析,显示它们的行为是一样的。
当自定义错误没有参数时,函数选择器是返回的唯一相关数据。在这种情况下,我们可以在不手动添加额外零的情况下仅存储函数选择器,并从我们存储的特定内存区域 revert。
例如,而不是像这样用零填充:
assembly{
mstore(
0x00,
0x82b4290000000000000000000000000000000000000000000000000000000000
)
}
我们可以写成如下而无需手动填充零:
assembly{
mstore(0x00,0x82b42900)
}
在内存中,零将添加在函数选择器的左侧,从 0th
字节到 27th
字节,而实际选择器将从 28th
字节到 31st
字节存储。
换句话说,0x82b42900
扩展为 0000000000000000000000000000000000000000000000000000000082b42900
并存储到字节 0
到 31
,如下面所示:
由于函数选择器现在位于 28th
字节(0x1c
十六进制),你可以从此位置而不是 0x00
执行 revert,如下所示:
assembly {
mstore(0x00,0x82b42900)
revert(0x1c, 0x04)
}
如果自定义错误带有参数,我们也需要 ABI 编码这些参数,因为它将是 revert 返回数据的一部分。假设它有一个地址作为参数,我们将在内存中存储该参数,并将 revert 参数指向内存中的选择器和地址。
作为示例,让我们在汇编中复制自定义 revert Unauthorized(address)
。
contract CustomError {
error Unauthorized(address caller);
function revertCustomError() {
revert Unauthorized(msg.sender);
}
}
在汇编中复制带参数的自定义 revert 步骤与不带参数的相似,唯一的区别是我们需要在返回数据中存储参数(在这种情况下为 address
)。我们将遵循以下步骤:
正如在不带参数的自定义错误中一样,我们需要先这样获取函数选择器:
bytes4 selector = bytes4(abi.encodeWithSignature("Unauthorized(address)"));
选择器将是 0x8e4a23d6
。我们将继续按如下方式使用 mstore
从 0x00
内存位置存储选择器:
assembly{
// 在内存位置 `0x00` 存储函数选择器
mstore(0x00, 0x8e4a23d600000000000000000000000000000000000000000000000000000000)
}
在将函数选择器写入从 0th
字节开始的内存后,我们现在将在 4th
字节存储 address
,如下所示:
assembly{
//...
// 存储地址
// *注意:汇编中的 `caller()` 与 Solidity 中的 `msg.sender` 相同。*
mstore(0x04, caller())
}
函数 caller()
会返回上cast 为 32 字节的地址。所以如果原始地址为 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4
,那么 caller()
将返回 0x00000000000000000000000005B38Da6a701c568545dCfcB03FcB875f56beddC4
,这将是 mstore
从字节 4 开始在内存中放置的 32 字节值。
最后,我们现在可以通过传递起始内存位置及到目前为止所存储数据的总大小(选择器的 4 字节 + 地址的 32 字节为 36 字节(hex 0x24))来触发 revert,如下所示:
function customRevertWithAssembly() public pure {
assembly {
//...
// 选择器的 4 字节 + 地址的 32 字节
revert(0x00, 0x24)
}
}
整段代码,现在在汇编中将带参数的自定义 revert 组织为:
// SPDX-License-Identifier: GPL-3.0
pragma solidity 0.8.27;
contract A {
function customRevertWithAssembly() public view {
assembly {
// 在内存位置 `0x00` 存储函数选择器
mstore(0x00, 0x8e4a23d600000000000000000000000000000000000000000000000000000000)
// 存储地址
// 注意:汇编中的 `caller()` 与 Solidity 中的 `msg.sender` 相同。
mstore(0x04, caller())
// 选择器的 4 字节 + 地址的 32 字节
revert(0x00, 0x24)
}
}
}
这就是 Solidity 将在内存中存储 revert 数据的方式,并最终返回 revert 数据的结果。
https://img.learnblockchain.cn/2025/02/28/Memoryanim.mp4
当触发带理由字符串 revert("reason")
的 revert 时,被 revert 合同返回 Error(string)
的 ABI 编码,以及字符串参数。这与 Solidity 中带理由的 require
的工作方式相同。
为了模拟带理由字符串的 revert 在汇编中的行为,我们需要在内存中将相同的函数和字符串参数进行 ABI 编码。
让我们以 revert(“Unauthorized”)
为例:
contract A {
function revertWithAString() external pure {
revert("Unauthorized");
}
}
如果我们在上面的合约中触发 revert("Unauthorized");
函数,结果将如下所示。
在这一部分,我们将通过遵循以下步骤来复制在 Solidity 中通过汇编带理由 revert
的行为:
Error(string)
的函数选择器以下是在汇编代码中对上述步骤的快速表示:
contract RevertErrorExample {
function revertWithAssembly() public pure {
assembly {
// 存储选择器
mstore(
0x00,
0x08c379a000000000000000000000000000000000000000000000000000000000
)
mstore(0x04, 0x20) // 存储偏移量
mstore(0x24, 0xc) // 存储字符串长度
mstore(
0x44,
0x556E617574686F72697A65640000000000000000000000000000000000000000
) // 存储实际数据
revert(0x00, 0x64) // 触发 revert
}
}
}
让我们逐行检查汇编块。
Error(string)
的函数选择器我们首先在起始内存位置(0x00
)存储 函数选择器:
assembly {
mstore(0x00, 0x08c379a000000000000000000000000000000000000000000000000000000000) // 存储函数选择器
}
你可以通过 abi.encodeWithSignature("Error(string)")
得出选择器(Error(string)
的 keccak256 的前 4 个字节),然后将其转换为 32 字节的单词,如下所示:
bytes32 selector = bytes32(abi.encodeWithSignature("Error(string)"));
结果将是:
0x08c379a000000000000000000000000000000000000000000000000000000000
前 4 个字节(0x08c379a0
)是选择器,填充了零以满足 32 字节的要求。
接下来,我们存储字符串错误的偏移量。偏移量为 32 字节(0x20
在十六进制中)。
mstore(0x04, 0x20) // 4 为 0x04
记住,我们提到如果两个内存位置重叠,可以覆盖内存,对吧?最初,函数选择器是存储在 0th
字节为起点的 32 字节单词。现在,我们在 4th
字节处存储偏移量。
这意味着函数选择器中剩余数据(在这种情况下即填充的零)将在第四个字节开始被替换,如下图示:
字符串数据的第三部分是字符串的长度。回想一下,我们在 0x00
位置存储了函数选择器,占用了 4 个字节。接下来,我们的偏移量在 0x04
内存位置占用 32 个字节。
这意味着选择器的 4 个字节 + 偏移量的 32 个字节告诉我们,接下来在 36 个字节位置的位置应该存储字符串长度。
字符串 Unauthorized
的长度为 12(0xc
)字节。
mstore(0x24, 0xc) // 36 为 0x24
实际字符串 Unauthorized
从第 68 字节(0x44
)开始存储,该字节是选择器的 4 字节 + 偏移量的 32 字节 + 字符串长度的 32 字节的位置。因此,我们已经写了 100 字节的数据。
mstore(0x44, "Unauthorized") //68 为 0x44
// 我们也可以作为十六进制存储 `Unauthorized`。`Unauthorized` 的十六进制是 ⤵️
// 0x556E617574686F72697A65640000000000000000000000000000000000000000
revert 操作使用起始内存位置和数据的总大小来触发 revert。
总大小将是 100(0x64
)字节,通过将选择器的 4 字节、偏移量的 32 字节、字符串的长度 32 字节以及字符串内容 Unauthorized
的 32 字节相加得出。
记住汇编中 revert 的模板:
revert(StartingMemorySlot, totalMemorySize)
以下是我们将如何触发 revert:
revert(0x00, 0x64) // 100 为 0x64
因此,当触发时,revert 将返回以下数据(恰好 100 个字节):
即使字符串 Unauthorized
没有使用整个 32 字节,接收者也会知道只需读取 12 字节的数据,这是由于长度参数 0x0c
。
将所有步骤整合在一起,我们将得出以下代码:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract ContractA {
function revertWithAssembly() external pure {
assembly {
mstore(
0x00,
0x08c379a000000000000000000000000000000000000000000000000000000000
) // 存储选择器
mstore(0x04, 0x20) // 存储偏移量
mstore(0x24, 0xc) // 存储字符串长度
mstore(
0x44,
0x556E617574686F72697A65640000000000000000000000000000000000000000
) // 存储实际数据
revert(0x00, 0x64) // 触发 revert
}
}
}
}
以下屏幕截图显示当你调用 revertWithAssembly()
函数时,revert 的输出。结果与我们看到的在 Solidity 中触发 revert(“Unauthorized”)
时相同。
然而,两者之间在消耗的 gas 数量上是不同的。运行以下合约中的 revert 以查看 gas 成本的差异。以下是我们用于测试 gas 成本的代码:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract ContractA {
function revertWithAssembly() external pure {
assembly {
mstore(
0x00,
0x08c379a000000000000000000000000000000000000000000000000000000000
) // 存储选择器
mstore(0x04, 0x20) // 存储偏移量
mstore(0x24, 0xc) // 存储字符串长度
mstore(
0x44,
0x556E617574686F72697A65640000000000000000000000000000000000000000
) // 存储实际数据
revert(0x00, 0x64) // 触发 revert
}
}
}
contract ContractB {
function revertWithoutAssembly() external pure {
revert("Unauthorized");
}
}
下面的插图显示了 revertWithAssembly
函数和 revertWithoutAssembly
的 gas 成本差异:
从上述测试结果来看,我们节省了 273
的 gas,因为不使用汇编的 revert 成本为 428
gas,而使用汇编的 revert 成本为 155
gas。差异为 273
。
为了进一步验证错误形成是否正确,我们可以尝试在 try/catch
块中捕获错误,如下所示的屏幕截图:
从上述屏幕截图中,我们可以看到错误在 Error
的 catch
块中被捕获,原因如预期输出为 Unauthorized
。
在本指南中,我们学习了通过手动实现 Solidity reverts 来了解 revert 的工作原理。
我们覆盖了:
mstore
和 mstore8
的工作原理我们还看到通过使用汇编 revert 可以节省一些 gas。我鼓励你自行进行实验,因为这才是完全理解所有内容的最佳方法。
祝编码顺利
本文由 Eze Sunday 与 RareSkills 合作撰写。
- 原文链接: rareskills.io/post/assem...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!