汇编回滚

本文详细介绍了如何通过内联汇编更高效地实现 Solidity 中的回滚操作,深入探讨了 mstoremstore8 操作码的使用方式,并通过示例代码展示了如何在汇编中实现无消息回滚、自定义错误回滚以及带有原因字符串的回滚。

通过内联汇编恢复事务比使用高级 Solidity revertrequire 语句更省 gas。在本指南中,我们将探讨 Solidity 中不同类型的 revert是如何在底层工作的,通过模拟它们在汇编中的实现。

下面的示例显示,在汇编版本中,revert 语句将 gas 成本从 157 gas 降低到 126 gas,节省了 31 gas:

显示汇编中的 `revert` 比常规 Solidity 的 `revert` 更省 gas 的屏幕截图

作为先决条件,我们假设你已阅读 Try Catch and All the Ways Solidity Reverts 文章以及我们的 ABI 编码文章

在 EVM 中,内存是一个长的字节数组,以字节为索引。也就是说,我们可以根据它们的索引读取和写入字节。尽管内存是字节索引的,但我们通常每次读取和写入 32 字节。

汇编中的 mstore 及其工作原理

使用汇编进行 revert 很大程度上依赖于 Yul 中的 mstore 操作码来在内存中存储数据,因此我们先深入探讨该操作码。

mstore 操作码接受两个参数:

  1. 内存位置:数据将存储的字节地址。
  2. 数据:要存储的 32 字节数据。

以下是如何使用 mstore 的示例:

assembly {
     mstore(memoryLocation, dataToStore)
}

如果你想在内存位置 0x00 存储 32 字节的 0xFF,你可以写:

assembly {
    mstore(
        0x00, 
        0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
    )
}

这将在索引 0x00 开始存储完整的 32 字节值。如果你希望在内存位置 0x01 存储该值,则可以这样写:

assembly {
    mstore(
        0x01, 
        0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
    )
}

这将数据的起始位置向后移动一个字节,且 0x00 处的第一个字节将保持不变。以下图显示了 mstore 如何在内存中存储数据:

显示执行 `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)

内存中值的结果如下所示:

显示 0xff 在 EVM 内存的第 31 个字节中写入的图示

请记住,mstore 写入 32 字节,但在这种情况下,31 个字节为零,从 0th 字节到 30th 字节(包含)均为零。这意味着字节范围 0-31 内的任何数据将被零覆盖。

如果我们返回所存储的数据,看看内存中其表现如何,结果如下面的屏幕截图所示:

显示函数返回的 revert 值的屏幕截图

以下图示显示 mstore 隐式左填充各种十六进制值,第一行为我们刚刚查看的示例。

显示值小于 32 字节时如何左填充以存储到字节 0 的图示

使用 mstore8 在内存中存储数据

另外,我们可以使用 mstore8 操作码,它与 mstore 类似,但仅在特定内存位置存储一个字节的数据。

assembly {
     mstore8(memoryLocation, exactlyOneByteOfData)
}

例如,如果我们想在第 31 个字节处存储一个字节的数据(0xff),我们可以直接用 mstore8 存储,如下所示:

assembly {
    mstore8(31, 0xff)
}

输出将与使用 mstore 相同,0xff 占据最后一个字节。

字节中的内存返回值

mstore8 操作码示例图示

mstore8mstore 之间的主要区别在于,mstore8 不会添加 31 个额外的零,这些零本会覆盖从 0th31st 字节范围内以往存储的数据。

使用 mstore 右侧带零写入 0xff

如果你想使用 mstore 而不是 mstore80th 字节处写入 0xff,可以按如下方式存储 0xff 作为第一个字节,并用零填充其余的 31 个字节:

assembly {
    mstore(
        0x00, 0xff00000000000000000000000000000000000000000000000000000000000000
    )
}

这将精准地按你所指定的内容存储,0xff 在前面,其余字节为零:

mstore 操作码示意图

这是在 remix 中代码的测试运行:

使用 mstore 的 Solidity 代码

这看起来有很多零,对吧?另外,我们可以使用 mstore8 在特定内存位置存储一个字节的数据。在下面的示例中,我们使用 mstore8 在第 0th 字节处存储 0xff

使用 mstore8 的 Solidity 代码

这是更紧凑的代码,执行与先前截图中相同的操作,只不过它没有将零写入字节 1 到 31。

请注意:

  • mstore 存储整个 32 字节的数据,而 mstore8 仅在指定的内存位置存储单个字节。
  • 当使用 mstore 时,如果你的数据少于 32 字节,它会自动用左侧的零字节填充以填满 32 字节。这些零将覆盖之前在任何其他内存内容中。

如果多次写入同一位置,你可以覆盖内存中的数据

记住我们提到过 mstore 左填充的额外零将覆盖 0th30th 字节范围内的任何现有内容?让我们探讨该覆盖是如何发生的。

如果我们使用 mstore80xCC 写入 0th 字节:

assembly {
   mstore8(0, 0xCC)
}

我们现在将在 0th 位置有 0xCC,而内存的其余部分保持不变,如下面的图示所示。

mstore8 操作码用法示例

随后,如果我们使用 mstore(0, 0xFF)0xFF 写入,如下所示:

assembly {
   mstore8(0, 0xCC)
   mstore(0, 0xFF)
}

0xFF 将覆盖先前存储在 0th 字节的位置 0xCC,并将整个 32 字节槽(从 0th31st 字节)填充为 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。因此,它使用了最高有效位,这些位在左侧。

使用存储的数据进行 revert

我们已经看到了如何使用 mstore 在内存中存储数据。在 revert 过程中,我们需要将错误数据存储在内存中并将其作为 revert 错误消息返回。

revert 操作码接受两个参数:起始内存槽和我们打算返回的总数据大小。

revert(startingMemorySlot, totalMemorySize)

从这里起,我们将展示如何模拟 Solidity revert 在以下情况下的行为:

  1. 无理由字符串的 revert
  2. 使用自定义错误的 revert
  3. 使用自定义错误和理由字符串的 revert

1. 无理由(消息)的 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
        }
    }
}

下面的屏幕截图显示了从 ContractAContractB低级调用 以及低级调用如何返回 false 因为 ContractB 被 revert,并且由于我们使用 revert(0,0) 不返回任何数据。

失败低级调用的返回值

2a. 无参数的自定义 revert 示例

要演示如何使用汇编模拟没有参数的自定义错误,让我们以 revert Unauthorized() 为例。

我们将在内存中的特定位置(按约定为 0x00)存储自定义 revert 的 错误函数选择器,并 revert 将指向该内存位置。

以下是我们将使用的 Solidity 代码示例:

contract CustomError {
    error Unauthorized();

    function revertCustomError() {
        revert Unauthorized();
    }
}

我们将遵循以下步骤来实现汇编中的自定义 revert:

  1. 将函数选择器存储在内存中
  2. 触发 revert,传递选择器的内存位置和选择器大小(4 字节)作为 revert 的参数
assembly {
   mstore(memoryLocation, selector)
   revert(memoryLocation, sizeOfSelector)
}

1. 存储函数选择器

当 Solidity 触发自定义错误时,返回值是自定义错误本身的 ABI 编码,它包括自定义错误签名的哈希函数的前四个字节(也就是选择器)。

由于我们使用自定义错误 Unauthorized() 作为示例,我们首先存储函数选择器(Unauthorized() 的 keccak256 的前四个字节)将是 0x82b42900 并用额外的零进行填充以将值扩展至 32 字节,以确保函数选择器的实际四个字节从字节 0 写到字节 3 包含。如果没有这个填充,选择器将无法从内存索引 0 开始写入。

bytes32 selector = bytes32(abi.encodeWithSignature("Unauthorized()")); // 0x82b42900

assembly {    
    mstore(0x00, 0x82b4290000000000000000000000000000000000000000000000000000000000)
}

2. 触发 revert

然后我们使用下面的 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 单位。

使用和不使用汇编的 revert gas 使用情况差异

此外,在下面的代码中,callContractB 单独使用 try/catchcustomRevertWithAssemblycustomRevertWithoutAssembly 进行解析,显示它们的行为是一样的。

汇编和正常 Solidity 中 revert 显示相同错误输出

当自定义错误无参数时的另一种存储选择器的方法

当自定义错误没有参数时,函数选择器是返回的唯一相关数据。在这种情况下,我们可以在不手动添加额外零的情况下仅存储函数选择器,并从我们存储的特定内存区域 revert。

例如,而不是像这样用零填充:

assembly{
    mstore(
           0x00,
           0x82b4290000000000000000000000000000000000000000000000000000000000
    )
}

我们可以写成如下而无需手动填充零:

assembly{
    mstore(0x00,0x82b42900)
}

在内存中,零将添加在函数选择器的左侧,从 0th 字节到 27th 字节,而实际选择器将从 28th 字节到 31st 字节存储。

换句话说,0x82b42900 扩展为 0000000000000000000000000000000000000000000000000000000082b42900 并存储到字节 031,如下面所示:

使用 mstore 将函数选择器存储在内存中的图示

由于函数选择器现在位于 28th 字节(0x1c 十六进制),你可以从此位置而不是 0x00 执行 revert,如下所示:

assembly {
  mstore(0x00,0x82b42900)
  revert(0x1c, 0x04)
}

2b. 带参数的自定义 revert 示例

如果自定义错误带有参数,我们也需要 ABI 编码这些参数,因为它将是 revert 返回数据的一部分。假设它有一个地址作为参数,我们将在内存中存储该参数,并将 revert 参数指向内存中的选择器和地址。

作为示例,让我们在汇编中复制自定义 revert Unauthorized(address)

contract CustomError {
    error Unauthorized(address caller);

    function revertCustomError() {
        revert Unauthorized(msg.sender);
    }
}

在汇编中复制带参数的自定义 revert 步骤与不带参数的相似,唯一的区别是我们需要在返回数据中存储参数(在这种情况下为 address)。我们将遵循以下步骤:

  1. 为自定义错误在内存中存储函数选择器
  2. 在选择器之后将参数存储在内存中
  3. 通过传递起始内存位置和总大小(选择器的 4 字节 + 参数大小)触发 revert

1. 为自定义错误存储函数选择器

正如在不带参数的自定义错误中一样,我们需要先这样获取函数选择器:

bytes4 selector = bytes4(abi.encodeWithSignature("Unauthorized(address)")); 

选择器将是 0x8e4a23d6。我们将继续按如下方式使用 mstore0x00 内存位置存储选择器:

assembly{
    // 在内存位置 `0x00` 存储函数选择器
    mstore(0x00, 0x8e4a23d600000000000000000000000000000000000000000000000000000000)
}

2. 在选择器之后存储参数

在将函数选择器写入从 0th 字节开始的内存后,我们现在将在 4th 字节存储 address,如下所示:

assembly{
    //...
    // 存储地址
    // *注意:汇编中的 `caller()` 与 Solidity 中的 `msg.sender` 相同。*
    mstore(0x04, caller())
}

函数 caller() 会返回上cast 为 32 字节的地址。所以如果原始地址为 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4,那么 caller() 将返回 0x00000000000000000000000005B38Da6a701c568545dCfcB03FcB875f56beddC4,这将是 mstore 从字节 4 开始在内存中放置的 32 字节值。

3. 触发 revert

最后,我们现在可以通过传递起始内存位置及到目前为止所存储数据的总大小(选择器的 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

3. 在汇编中带理由的 revert

当触发带理由字符串 revert("reason") 的 revert 时,被 revert 合同返回 Error(string) 的 ABI 编码,以及字符串参数。这与 Solidity 中带理由的 require 的工作方式相同。

为了模拟带理由字符串的 revert 在汇编中的行为,我们需要在内存中将相同的函数和字符串参数进行 ABI 编码。

让我们以 revert(“Unauthorized”) 为例:

contract A {
    function revertWithAString() external pure {
        revert("Unauthorized");
    }
}

如果我们在上面的合约中触发 revert("Unauthorized"); 函数,结果将如下所示。

解释原始 revert 错误输出的图示

在这一部分,我们将通过遵循以下步骤来复制在 Solidity 中通过汇编带理由 revert 的行为:

  1. 在内存中存储 Error(string) 的函数选择器
  2. 存储错误消息字符串的偏移量
  3. 存储错误消息字符串的长度
  4. 存储实际错误消息
  5. 触发 revert

以下是在汇编代码中对上述步骤的快速表示:

contract RevertErrorExample {

    function revertWithAssembly() public pure {

        assembly {

            // 存储选择器
            mstore(
                0x00,
                0x08c379a000000000000000000000000000000000000000000000000000000000
            ) 
            mstore(0x04, 0x20) // 存储偏移量
            mstore(0x24, 0xc) // 存储字符串长度
            mstore(
                0x44,
                0x556E617574686F72697A65640000000000000000000000000000000000000000
            ) // 存储实际数据
            revert(0x00, 0x64) // 触发 revert
        }

    }
}

让我们逐行检查汇编块。

1. 存储 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 字节的要求。

在内存中存储函数选择器的图示

2. 存储错误消息字符串的偏移量

接下来,我们存储字符串错误的偏移量。偏移量为 32 字节(0x20 在十六进制中)。

mstore(0x04, 0x20) // 4 为 0x04

记住,我们提到如果两个内存位置重叠,可以覆盖内存,对吧?最初,函数选择器是存储在 0th 字节为起点的 32 字节单词。现在,我们在 4th 字节处存储偏移量。

这意味着函数选择器中剩余数据(在这种情况下即填充的零)将在第四个字节开始被替换,如下图示:

存储错误消息字符串的偏移量的图示

3. 存储错误消息字符串的长度

字符串数据的第三部分是字符串的长度。回想一下,我们在 0x00 位置存储了函数选择器,占用了 4 个字节。接下来,我们的偏移量在 0x04 内存位置占用 32 个字节。

这意味着选择器的 4 个字节 + 偏移量的 32 个字节告诉我们,接下来在 36 个字节位置的位置应该存储字符串长度。

字符串 Unauthorized 的长度为 12(0xc)字节。

mstore(0x24, 0xc) // 36 为 0x24

存储错误消息字符串长度的位置图示

4. 存储实际错误消息字符串

实际字符串 Unauthorized 从第 68 字节(0x44)开始存储,该字节是选择器的 4 字节 + 偏移量的 32 字节 + 字符串长度的 32 字节的位置。因此,我们已经写了 100 字节的数据。

mstore(0x44, "Unauthorized") //68 为 0x44
// 我们也可以作为十六进制存储 `Unauthorized`。`Unauthorized` 的十六进制是 ⤵️
// 0x556E617574686F72697A65640000000000000000000000000000000000000000

实际错误消息字符串的原始存储位置的图示

5. 触发 revert:

revert 操作使用起始内存位置和数据的总大小来触发 revert。

总大小将是 100(0x64)字节,通过将选择器的 4 字节、偏移量的 32 字节、字符串的长度 32 字节以及字符串内容 Unauthorized 的 32 字节相加得出。

记住汇编中 revert 的模板:

revert(StartingMemorySlot, totalMemorySize)

以下是我们将如何触发 revert:

revert(0x00, 0x64) // 100 为 0x64

因此,当触发时,revert 将返回以下数据(恰好 100 个字节):

经过 crafted 自定义 revert 的原始数据图示

即使字符串 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”) 时相同。

使用汇编的 revert 的原始输出

然而,两者之间在消耗的 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 成本差异:

使用和不使用汇编的 revert 成本差异

从上述测试结果来看,我们节省了 273 的 gas,因为不使用汇编的 revert 成本为 428 gas,而使用汇编的 revert 成本为 155 gas。差异为 273

为了进一步验证错误形成是否正确,我们可以尝试在 try/catch 块中捕获错误,如下所示的屏幕截图:

未授权错误

从上述屏幕截图中,我们可以看到错误在 Errorcatch 块中被捕获,原因如预期输出为 Unauthorized

结论

在本指南中,我们学习了通过手动实现 Solidity reverts 来了解 revert 的工作原理。

我们覆盖了:

  • mstoremstore8 的工作原理
  • 如何模拟以下类型的 revert:
    • 无理由的 revert
    • 自定义错误的 revert
    • 带理由的 revert

我们还看到通过使用汇编 revert 可以节省一些 gas。我鼓励你自行进行实验,因为这才是完全理解所有内容的最佳方法。

祝编码顺利

本文由 Eze Sunday 与 RareSkills 合作撰写。

  • 原文链接: rareskills.io/post/assem...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
RareSkills
RareSkills
https://www.rareskills.io/