内联汇编

你可以使用接近以太坊虚拟机语言的内联汇编将 Solidity 语句混合在一起。这使你可以更精细地控制,尤其是在编写库以增强语言时非常有用。

Solidity 中内联汇编使用的语言称为 Yul,它在单独的部分中进行了说明。本节只介绍内联汇编代码如何与周围的 Solidity 代码交互。

警告

内联汇编是访问以太坊虚拟机的低级方式。它绕过了 Solidity 的一些重要的安全特性和检查。你应该只在需要时使用它,而且只有在你对使用它有信心时才使用。

内联汇编块以 assembly { ... } 标记,其中大括号内的代码是 Yul 语言的代码。

内联汇编代码可以访问本地 Solidity 变量,如下所述。

不同的内联汇编块没有共享的命名空间,也就是说,无法调用 Yul 函数或访问在另一个内联汇编块中定义的 Yul 变量。

示例

以下示例提供了库代码来访问另一个合约的代码并将其加载到 bytes 变量中。这也可以通过使用 <address>.code 使用“纯 Solidity”来实现。但重点是,可重用的汇编库可以在不更改编译器的情况下增强 Solidity 语言。

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.16 <0.9.0;

library GetCode {
    function at(address addr) public view returns (bytes memory code) {
        assembly {
            // retrieve the size of the code, this needs assembly
            let size := extcodesize(addr)
            // allocate output byte array - this could also be done without assembly
            // by using code = new bytes(size)
            code := mload(0x40)
            // new "memory end" including padding
            mstore(0x40, add(code, and(add(add(size, 0x20), 0x1f), not(0x1f))))
            // store length in memory
            mstore(code, size)
            // actually retrieve the code, this needs assembly
            extcodecopy(addr, add(code, 0x20), 0, size)
        }
    }
}

内联汇编在优化器无法生成有效代码的情况下也很有用,例如

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.16 <0.9.0;


library VectorSum {
    // This function is less efficient because the optimizer currently fails to
    // remove the bounds checks in array access.
    function sumSolidity(uint[] memory data) public pure returns (uint sum) {
        for (uint i = 0; i < data.length; ++i)
            sum += data[i];
    }

    // We know that we only access the array in bounds, so we can avoid the check.
    // 0x20 needs to be added to an array because the first slot contains the
    // array length.
    function sumAsm(uint[] memory data) public pure returns (uint sum) {
        for (uint i = 0; i < data.length; ++i) {
            assembly {
                sum := add(sum, mload(add(add(data, 0x20), mul(i, 0x20))))
            }
        }
    }

    // Same as above, but accomplish the entire code within inline assembly.
    function sumPureAsm(uint[] memory data) public pure returns (uint sum) {
        assembly {
            // Load the length (first 32 bytes)
            let len := mload(data)

            // Skip over the length field.
            //
            // Keep temporary variable so it can be incremented in place.
            //
            // NOTE: incrementing data would result in an unusable
            //       data variable after this assembly block
            let dataElementLocation := add(data, 0x20)

            // Iterate until the bound is not met.
            for
                { let end := add(dataElementLocation, mul(len, 0x20)) }
                lt(dataElementLocation, end)
                { dataElementLocation := add(dataElementLocation, 0x20) }
            {
                sum := add(sum, mload(dataElementLocation))
            }
        }
    }
}

访问外部变量、函数和库

你可以使用标识符名称来访问 Solidity 变量和其他标识符。

值类型的本地变量可以直接在内联汇编中使用。它们既可以读取也可以赋值。

引用内存的本地变量会评估为内存中变量的地址,而不是其值本身。这些变量也可以赋值,但请注意,赋值只会改变指针,不会改变数据,并且你需要负责遵守 Solidity 的内存管理。请参阅 Solidity 中的约定

类似地,引用静态大小的 calldata 数组或 calldata 结构的本地变量会评估为 calldata 中变量的地址,而不是其值本身。变量也可以被分配一个新的偏移量,但请注意,不会执行任何验证来确保变量不会指向超出 calldatasize() 的范围。

对于外部函数指针,可以使用 x.addressx.selector 访问地址和函数选择器。选择器包含四个右对齐的字节。这两个值都可以赋值。例如

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.8.10 <0.9.0;

contract C {
    // Assigns a new selector and address to the return variable @fun
    function combineToFunctionPointer(address newAddress, uint newSelector) public pure returns (function() external fun) {
        assembly {
            fun.selector := newSelector
            fun.address  := newAddress
        }
    }
}

对于动态 calldata 数组,可以使用 x.offsetx.length 访问它们的 calldata 偏移量(以字节为单位)和长度(元素数量)。这两个表达式也可以赋值,但与静态情况一样,不会执行任何验证来确保结果数据区域在 calldatasize() 的范围内。

对于本地存储变量或状态变量,单个 Yul 标识符是不够的,因为它们不一定占用一个完整的存储槽。因此,它们的“地址”由一个槽和该槽内的字节偏移量组成。要检索变量 x 指向的槽,可以使用 x.slot,要检索字节偏移量,可以使用 x.offset。使用 x 本身会导致错误。

你还可以为本地存储变量指针的 .slot 部分赋值。对于这些(结构体、数组或映射),.offset 部分始终为零。虽然无法为状态变量的 .slot.offset 部分赋值。

本地 Solidity 变量可用于赋值,例如

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;

contract C {
    uint b;
    function f(uint x) public view returns (uint r) {
        assembly {
            // We ignore the storage slot offset, we know it is zero
            // in this special case.
            r := mul(x, sload(b.slot))
        }
    }
}

警告

如果你访问跨越少于 256 位的类型的变量(例如 uint64addressbytes16),则无法对类型编码中未包含的位进行任何假设。特别地,不要假设它们为零。为了安全起见,在使用它们之前始终清除数据,在这种情况下,清除数据很重要:uint32 x = f(); assembly { x := and(x, 0xffffffff) /* 现在 使用 x */ } 要清除有符号类型,可以使用 signextend 操作码:assembly { signextend(<num_bytes_of_x_minus_one>, x) }

从 Solidity 0.6.0 开始,内联汇编变量的名称不能屏蔽内联汇编块范围内的任何可见声明(包括变量、合约和函数声明)。

从 Solidity 0.7.0 开始,在内联汇编块内声明的变量和函数不能包含 .,但使用 . 有效地从内联汇编块外部访问 Solidity 变量。

需要注意的事项

内联汇编可能看起来相当高级,但实际上它非常低级。函数调用、循环、if 语句和 switch 语句通过简单的重写规则进行转换,之后,汇编器为你做的唯一事情就是重新排列函数式操作码,为变量访问计算堆栈高度,并在汇编本地变量的块结束时删除堆栈槽。

Solidity 中的约定

类型化变量的值

与 EVM 汇编不同,Solidity 有比 256 位更窄的类型,例如 uint24。为了效率,大多数算术运算会忽略类型可能小于 256 位的事实,并且在需要时会清除高位,即在它们被写入内存或进行比较之前不久。这意味着,如果从内联汇编中访问这样的变量,你可能需要先手动清除高位。

内存管理

Solidity 以以下方式管理内存。内存中位置 0x40 处有一个“空闲内存指针”。如果你要分配内存,请使用从该指针指向的位置开始的内存,并更新该指针。不能保证内存之前没有被使用过,因此你不能假设它的内容是零字节。没有内置的机制来释放或释放分配的内存。以下是一个你可以用来分配内存的汇编代码片段,它遵循上面概述的过程

function allocate(length) -> pos {
  pos := mload(0x40)
  mstore(0x40, add(pos, length))
}

内存的前 64 个字节可以用作短时间分配的“暂存空间”。空闲内存指针后的 32 个字节(即从 0x60 开始)应该永远为零,并用作空动态内存数组的初始值。这意味着可分配的内存从 0x80 开始,这是空闲内存指针的初始值。

Solidity 中内存数组中的元素始终占用 32 字节的倍数(即使对于 bytes1[] 也是如此,但对于 bytesstring 则不适用)。多维内存数组是指向内存数组的指针。动态数组的长度存储在数组的第一个槽中,紧随其后的是数组元素。

警告

静态大小的内存数组没有长度字段,但以后可能会添加它,以允许在静态大小和动态大小的数组之间更好地转换;因此,不要依赖于这一点。

内存安全

在不使用内联汇编的情况下,编译器可以依赖内存始终保持在定义良好的状态。这对于 通过 Yul IR 的新的代码生成管道 尤其重要:如果可以依赖内存使用的某些假设,这个代码生成路径可以将本地变量从堆栈移动到内存,以避免堆栈过深错误并执行其他内存优化。

虽然我们建议始终遵守 Solidity 的内存模型,但内联汇编允许你以不兼容的方式使用内存。因此,默认情况下,在包含内存操作或为内存中的 Solidity 变量赋值的任何内联汇编块中,将本地变量移动到内存和其他内存优化都将被全局禁用。

但是,你可以专门注释一个汇编块,以指示它实际上遵守 Solidity 的内存模型,如下所示

assembly ("memory-safe") {
    ...
}

特别是,一个内存安全的汇编块可能只访问以下内存范围

  • 使用像上面描述的 allocate 函数这样的机制自行分配的内存。

  • 由 Solidity 分配的内存,例如您引用的内存数组边界内的内存。

  • 上面提到的内存偏移量 0 到 64 之间的临时空间。

  • 位于汇编块开始时的空闲内存指针值之后的临时内存,即在不更新空闲内存指针的情况下,在空闲内存指针处“分配”的内存。

此外,如果汇编块为内存中的 Solidity 变量赋值,您需要确保对 Solidity 变量的访问仅访问这些内存范围。

由于这主要与优化器相关,即使汇编块回滚或终止,也需要遵循这些限制。例如,以下汇编片段不是内存安全的,因为returndatasize()的值可能会超过 64 字节的临时空间

assembly {
  returndatacopy(0, 0, returndatasize())
  revert(0, returndatasize())
}

另一方面,以下代码内存安全的,因为空闲内存指针指向的位置之外的内存可以安全地用作临时空间

assembly ("memory-safe") {
  let p := mload(0x40)
  returndatacopy(p, 0, returndatasize())
  revert(p, returndatasize())
}

请注意,如果后面没有分配,则无需更新空闲内存指针,但您只能使用从空闲内存指针给出的当前偏移量开始的内存。

如果内存操作使用长度为零,则也可以使用任何偏移量(不仅仅是在临时空间内)

assembly ("memory-safe") {
  revert(0, 0)
}

请注意,不仅内联汇编本身的内存操作可能会导致内存不安全,在内存中对引用类型的 Solidity 变量赋值也可能导致内存不安全。例如,以下操作不是内存安全的

bytes memory x;
assembly {
  x := 0x40
}
x[0x20] = 0x42;

既不涉及访问内存的任何操作也不为内存中的任何 Solidity 变量赋值的内联汇编会自动视为内存安全,无需注释。

警告

您有责任确保汇编实际上满足内存模型。如果您将汇编块注释为内存安全,但违反了内存假设之一,这导致无法通过测试轻松发现的错误和未定义行为。

如果您正在开发旨在与多个 Solidity 版本兼容的库,可以使用特殊注释将汇编块注释为内存安全

/// @solidity memory-safe-assembly
assembly {
    ...
}

请注意,我们将在未来破坏性版本中禁止通过注释进行注释;因此,如果您不关心与旧编译器版本的向后兼容性,建议使用方言字符串。