
内联汇编
在 Solidity 语句中,我们可以交错使用内联汇编,这种汇编语言接近以太坊虚拟机(EVM)底层语言。这为我们提供了更精细的控制,尤其在编写库或优化 gas 使用时非常有用。
Solidity 中使用的内联汇编语言是 Yul,它有独立的文档。在这一部分,我们主要讨论内联汇编代码如何与周围的 Solidity 代码交互。
内联汇编是一种低级别访问以太坊虚拟机的方式。它绕过了 Solidity 的一些重要安全特性和检查。因此,我们应仅在确实需要时使用内联汇编,并且在完全理解其影响的情况下使用它。
内联汇编代码块通过 assembly { … } 进行标记,花括号中的代码是 Yul 语言的代码。
不同的内联汇编块之间没有共享命名空间,也就是说,无法在不同的内联汇编块中调用 Yul 函数或访问 Yul 变量。
内联汇编代码可以访问局部 Solidity 变量。
示例
以下示例展示了一个库代码,用于访问另一个合约的代码并将其加载到 bytes 变量中。在“纯 Solidity”中,这也可以通过使用 <address>.code 来实现。不过,重点在于,可重用的汇编库能够在不需要修改编译器的情况下增强 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)
}
}
}
当 Solidity 优化器无法生成高效代码时,内联汇编就显得特别有用。如下例所示:
// 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 中,我们可以通过变量的名称直接访问值类型和引用类型的局部变量。
1.值类型局部变量可以直接在内联汇编中访问,无论是读取还是修改。例如,我们可以直接读取它们的值,也可以对它们赋值。
2.引用类型的局部变量量在内存中的地址会被传递,而不是其值本身。在内联汇编中,我们可以访问这些变量的内存地址,修改它们的值时需要特别小心,因为赋值操作只会更改指针地址,而不会直接改变数据。并且在这种情况下,必须遵守 Solidity 的内存管理规则。
3.calldata 数组和结构体的局部变量类似于引用类型的变量,calldata 中的局部变量会被解释为该变量在 calldata 中的地址。这意味着我们可以赋新值给这些变量的偏移量,但赋值操作不会验证新地址是否超出 calldata 的有效范围。
4.外部函数指针可以通过 x.address 和 x.selector 来访问外部函数的地址和选择器。函数选择器是通过对函数签名进行 Keccak256 哈希运算后取前四个字节生成的,这两个值(地址和选择器)都可以在内联汇编中赋值和访问。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.8.10 <0.9.0;
**contract C {**
// 将新的选择器和地址分配给返回变量 @fun
**function combineToFunctionPointer(address newAddress, uint newSelector) public pure returns (function() external fun) {**
**assembly {**
**fun.selector := newSelector**
**fun.address := newAddress**
**}**
**}**
**}**
对于动态 calldata 数组,我们可以通过 x.offset 和 x.length 来访问它们在 calldata 中的偏移量和长度:
-
x.offset:表示该数组在 calldata 中的偏移量(以字节为单位)。
-
x.length:表示数组的长度(即元素个数)。
这两个表达式不仅可以读取它们的值,还可以赋值。然而,赋值操作不会验证新的偏移量或长度是否在有效的 calldatasize() 范围内。因此,开发者需要确保赋值不会导致越界。
对于局部存储变量或状态变量(包括临时存储),我们不能仅依靠 Yul 标识符,因为这些变量可能不占用一个完整的存储槽。为了正确访问它们,我们需要通过槽和字节偏移来定位这些变量:
-
x.slot:表示变量 x 所在的存储槽地址。
-
x.offset:表示变量 x 在该存储槽内的字节偏移量。
在内联汇编中,直接使用 x(例如直接访问局部存储变量)将会导致错误,因为它需要一个完整的地址定位。
对于局部存储变量指针,x.slot 部分是可以赋值的,尤其是在处理结构体、数组或映射时,.offset 部分总是零。对于状态变量,x.slot 和 x.offset 部分不能赋值。
以下示例展示了如何为局部存储变量进行赋值操作:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.8.28 <0.9.0;
// 这将会报告一个警告
contract C {
bool transient a;
uint b;
function f(uint x) public returns (uint r) {
assembly {
// 我们忽略存储槽偏移量,在这种特殊情况下我们知道它是零
r := mul(x, sload(b.slot))
tstore(a.slot, true)
}
}
}
当我们访问跨度小于 256 位的类型(如 uint64、address 或 bytes16)时,不能假设这些位不属于该类型的编码部分,特别是不能假设它们是零。为了确保安全,使用这些类型之前,始终应正确清除数据。如下所示:
uint32 x = f();
assembly { x := and(x, 0xffffffff) /* 现在使用 x */ }
要清除带符号类型,可以使用 signextend 操作码:
assembly { signextend(<num_bytes_of_x_minus_one>, x) }
需要避免的事项
内联汇编看起来可能具有较高的抽象级别,但实际上它是极其低级的。函数调用、循环、条件语句和开关语句会通过简单的重写规则转换。之后,汇编器为我们做的唯一事情就是重新排列操作码,计算变量访问的栈高度,并在局部变量块结束时移除汇编局部变量的栈槽。
Solidity 中的约定
类型变量的值
与 EVM 汇编不同,Solidity 具有比 256 位更窄的类型(例如 uint24)。为了提高效率,大多数算术操作忽略了类型可能小于 256 位的事实,并在必要时会清除高位,例如在将它们写入内存或执行比较操作之前。这意味着,如果我们从内联汇编中访问此类变量,我们可能需要手动清除高位,以确保数据的正确性。
内存管理
Solidity 以以下方式管理内存。在内存位置 0x40 处有一个“空闲内存指针”。如果你想分配内存,使用这个指针指向的位置开始分配,并更新它。没有保证该内存位置之前没有被使用,因此不能假设其内容是零字节。Solidity 没有内置机制来释放或清理已分配的内存,并且它也不保证内存中的值会按照某个特定值的倍数对齐。
以下是我们可以用于分配内存的汇编代码段:
function allocate(length) -> pos {
pos := mload(0x40)
mstore(0x40, add(pos, length))
}
内存的前 64 字节可以用作“临时分配空间”。空闲内存指针后的 32 字节(即从 0x60 开始)永久保持零值,并用于动态内存数组的初始值。这意味着可分配内存从 0x80 开始,这是空闲内存指针的初始值。
在 Solidity 中,内存数组的元素总是占用 32 字节的倍数(即使是 bytes1[] 也是如此),但 bytes 和 string 例外。多维内存数组是指向内存数组的指针。动态数组的长度存储在数组的第一个槽中,后面是数组的元素。
对于静态大小的内存数组,虽然它们没有长度字段,但以后可能会添加该字段,以便更好地转换静态和动态大小数组,因此不应依赖这一特性。
内存安全
在没有使用内联汇编的情况下,编译器可以依赖内存始终保持在良好的定义状态。这对于通过 Yul IR 的新代码生成管道尤为重要:此代码生成路径可以将局部变量从栈移动到内存,以避免栈溢出错误,并执行额外的内存优化,前提是它可以依赖于对内存使用的某些假设。
尽管我们建议始终遵循 Solidity 的内存模型,内联汇编允许我们以不兼容的方式使用内存。因此,默认情况下,在包含内存操作或向 Solidity 变量分配内存的内联汇编块中,移动栈变量到内存和额外的内存优化是全球禁用的。
然而,我们可以特别标注一个汇编块,表示它实际上遵守 Solidity 的内存模型,如下所示:
assembly ("memory-safe") {
...
}
内存安全的汇编块只能访问以下内存范围:
1.我们自己使用如上所述的分配函数机制分配的内存。
2.Solidity 分配的内存,例如我们引用的内存数组的范围内的内存。
3.上述内存偏移量 0 和 64 之间的临时内存空间。
4.汇编块开始时空闲内存指针之后的临时内存,即在空闲内存指针的值上“分配”内存,而不更新空闲内存指针。
此外,如果汇编块向 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 {
...
}
高级内存安全使用
超出上述内存安全的严格定义外,还有一些情况,我们可能希望在内存偏移量 0 处使用超过 64 字节的临时空间。如果我们小心谨慎,在不包括偏移量 0x80 的情况下使用内存仍然可以被视为内存安全,并且可以将汇编块声明为内存安全。这在以下任一条件下是允许的:
1.在汇编块结束时,空闲内存指针(偏移量为 0x40)恢复到一个合理的值(即,它要么恢复到原始值,要么因为手动内存分配而有所增加),并且偏移量 0x60 处的内存字恢复为零值。
2.汇编块终止,即执行不能返回到高级 Solidity 代码中。例如,如果我们的汇编块无条件地以调用 revert 操作码结束,则会符合此条件。
此外,我们需要意识到,Solidity 中动态数组的默认值指向内存偏移量 0x60,因此,在暂时改变内存偏移量 0x60 的值时,我们将无法依赖读取动态数组时获得准确的长度值,直到我们恢复了 0x60 的零值。更准确地说,我们仅在覆盖零指针时保证安全,如果汇编代码的其余部分没有与高级 Solidity 对象的内存交互(包括读取之前存储在变量中的偏移量)。
2396

被折叠的 条评论
为什么被折叠?



