基于 Solidity IR 的代码生成变更

Solidity 可以通过两种不同的方式生成 EVM 字节码:要么直接从 Solidity 生成 EVM 操作码(“旧代码生成”),要么通过 Yul 中的中间表示(“IR”)(“新代码生成”或“基于 IR 的代码生成”)。

基于 IR 的代码生成器引入的目的是不仅让代码生成更透明和可审计,而且还能实现跨函数的更强大的优化过程。

您可以使用命令行中的 --via-ir 选项或在 standard-json 中使用 {"viaIR": true} 选项来启用它,我们鼓励大家尝试它!

由于几个原因,旧代码生成器和基于 IR 的代码生成器之间存在细微的语义差异,主要是在我们预计人们不会依赖这种行为的领域。本节重点介绍了旧代码生成器和基于 IR 的代码生成器之间的主要差异。

语义变更

本节列出了仅为语义变更,因此可能隐藏了现有代码中的新行为和不同行为。

  • 在继承的情况下,状态变量初始化的顺序已发生变化。

    以前的顺序是

    • 所有状态变量在开始时都进行零初始化。

    • 从最派生合约到最基础合约评估基类构造函数参数。

    • 从最基础合约到最派生合约初始化整个继承层次结构中的所有状态变量。

    • 运行所有合约的构造函数(如果存在),这些合约在从最基础合约到最派生合约的线性化层次结构中。

    新顺序

    • 所有状态变量在开始时都进行零初始化。

    • 从最派生合约到最基础合约评估基类构造函数参数。

    • 对于线性化层次结构中从最基础合约到最派生合约的每个合约

      1. 初始化状态变量。

      2. 运行构造函数(如果存在)。

    这会导致合约之间的差异,其中状态变量的初始值依赖于另一个合约的构造函数的结果

    // SPDX-License-Identifier: GPL-3.0
    pragma solidity >=0.7.1;
    
    contract A {
        uint x;
        constructor() {
            x = 42;
        }
        function f() public view returns(uint256) {
            return x;
        }
    }
    contract B is A {
        uint public y = f();
    }
    

    以前,y 将被设置为 0。这是因为我们首先会初始化状态变量:首先,x 被设置为 0,在初始化 y 时,f() 将返回 0,导致 y 也为 0。使用新规则,y 将被设置为 42。我们首先将 x 初始化为 0,然后调用 A 的构造函数,它将 x 设置为 42。最后,在初始化 y 时,f() 返回 42,导致 y 为 42。

  • 当存储结构体被删除时,包含结构体成员的每个存储槽都将完全设置为零。以前,填充空间保持不变。因此,如果结构体内的填充空间用于存储数据(例如在合约升级的上下文中),您必须注意,现在 delete 也会清除添加的成员(而在过去它不会被清除)。

    // SPDX-License-Identifier: GPL-3.0
    pragma solidity >=0.7.1;
    
    contract C {
        struct S {
            uint64 y;
            uint64 z;
        }
        S s;
        function f() public {
            // ...
            delete s;
            // s occupies only first 16 bytes of the 32 bytes slot
            // delete will write zero to the full slot
        }
    }
    

    对于隐式删除,我们有相同的行为,例如当结构体数组缩短时。

  • 函数修饰符在处理函数参数和返回值方面的实现略有不同。这尤其影响了当占位符 _; 在修饰符中被多次评估时。在旧代码生成器中,每个函数参数和返回值在堆栈上都有一个固定插槽。如果函数由于 _; 被多次使用或在循环中使用而被多次运行,那么对函数参数或返回值的修改将对函数的下次执行可见。新代码生成器使用实际函数实现修饰符,并将函数参数传递给它们。这意味着,函数体的多次评估将获得相同的参数值,对返回值的影响是,在每次执行之前,它们都被重置为默认值(零)。

    // SPDX-License-Identifier: GPL-3.0
    pragma solidity >=0.7.0;
    contract C {
        function f(uint a) public pure mod() returns (uint r) {
            r = a++;
        }
        modifier mod() { _; _; }
    }
    

    如果您在旧代码生成器中执行 f(0),它将返回 1,而在使用新代码生成器时它将返回 0

    // SPDX-License-Identifier: GPL-3.0
    pragma solidity >=0.7.1 <0.9.0;
    
    contract C {
        bool active = true;
        modifier mod()
        {
            _;
            active = false;
            _;
        }
        function foo() external mod() returns (uint ret)
        {
            if (active)
                ret = 1; // Same as ``return 1``
        }
    }
    

    函数 C.foo() 返回以下值

    • 旧代码生成器:1,因为返回值仅在第一个 _; 评估之前被初始化为 0,然后被 return 1; 覆盖。它不会在第二个 _; 评估之前被重新初始化,并且 foo() 也没有显式地分配它(因为 active == false),因此它保留了第一个值。

    • 新代码生成器:0,因为所有参数(包括返回参数)都将在每次 _; 评估之前被重新初始化。

  • 对于旧代码生成器,表达式的评估顺序未指定。对于新代码生成器,我们尝试以源代码顺序(从左到右)进行评估,但不能保证这一点。这会导致语义差异。

    例如

    // SPDX-License-Identifier: GPL-3.0
    pragma solidity >=0.8.1;
    contract C {
        function preincr_u8(uint8 a) public pure returns (uint8) {
            return ++a + a;
        }
    }
    

    函数 preincr_u8(1) 返回以下值

    • 旧代码生成器:3 (1 + 2),但返回值通常未指定

    • 新代码生成器:4 (2 + 2),但返回值不能保证

    另一方面,两个代码生成器都以相同的顺序评估函数参数表达式,除了全局函数 addmodmulmod 之外。例如

    // SPDX-License-Identifier: GPL-3.0
    pragma solidity >=0.8.1;
    contract C {
        function add(uint8 a, uint8 b) public pure returns (uint8) {
            return a + b;
        }
        function g(uint8 a, uint8 b) public pure returns (uint8) {
            return add(++a + ++b, a + b);
        }
    }
    

    函数 g(1, 2) 返回以下值

    • 旧代码生成器:10 (add(2 + 3, 2 + 3)),但返回值通常未指定

    • 新代码生成器:10,但返回值不能保证

    全局函数 addmodmulmod 的参数由旧代码生成器从右到左评估,由新代码生成器从左到右评估。例如

    // SPDX-License-Identifier: GPL-3.0
    pragma solidity >=0.8.1;
    contract C {
        function f() public pure returns (uint256 aMod, uint256 mMod) {
            uint256 x = 3;
            // Old code gen: add/mulmod(5, 4, 3)
            // New code gen: add/mulmod(4, 5, 5)
            aMod = addmod(++x, ++x, x);
            mMod = mulmod(++x, ++x, x);
        }
    }
    

    函数 f() 返回以下值

    • 旧代码生成器:aMod = 0mMod = 2

    • 新代码生成器:aMod = 4mMod = 0

  • 新代码生成器对免费内存指针施加了 type(uint64).max (0xffffffffffffffff) 的硬性限制。超过此限制的分配将回滚。旧代码生成器没有此限制。

    例如

    // SPDX-License-Identifier: GPL-3.0
    pragma solidity >0.8.0;
    contract C {
        function f() public {
            uint[] memory arr;
            // allocation size: 576460752303423481
            // assumes freeMemPtr points to 0x80 initially
            uint solYulMaxAllocationBeforeMemPtrOverflow = (type(uint64).max - 0x80 - 31) / 32;
            // freeMemPtr overflows UINT64_MAX
            arr = new uint[](solYulMaxAllocationBeforeMemPtrOverflow);
        }
    }
    

    函数 f() 的行为如下

    • 旧代码生成器:在大型内存分配后将数组内容清零时耗尽了 gas

    • 新代码生成器:由于免费内存指针溢出而回滚(不会耗尽 gas)

内部

内部函数指针

旧代码生成器使用代码偏移量或标签作为内部函数指针的值。这尤其复杂,因为这些偏移量在构造时间和部署后是不同的,并且这些值可以通过存储跨越此边界。因此,这两个偏移量在构造时间被编码到同一个值中(编码到不同的字节中)。

在新代码生成器中,函数指针使用内部 ID,这些 ID 按顺序分配。由于无法通过跳转进行调用,因此通过函数指针的调用始终必须使用内部调度函数,该函数使用 switch 语句选择正确的函数。

ID 0 预留给未初始化的函数指针,这些函数指针在调用时会导致调度函数出现恐慌。

在旧代码生成器中,内部函数指针使用一个特殊的函数进行初始化,该函数总是会引发恐慌。这会导致在构造时间对存储中的内部函数指针进行存储写入。

清理

旧代码生成器仅在操作结果可能受到脏位值影响之前进行清理。新代码生成器在任何可能导致脏位的操作之后进行清理。希望优化器足够强大,能够消除冗余的清理操作。

例如

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.8.1;
contract C {
    function f(uint8 a) public pure returns (uint r1, uint r2)
    {
        a = ~a;
        assembly {
            r1 := a
        }
        r2 = a;
    }
}

函数 f(1) 返回以下值

  • 旧代码生成器:(fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe, 00000000000000000000000000000000000000000000000000000000000000fe)

  • 新代码生成器:(00000000000000000000000000000000000000000000000000000000000000fe, 00000000000000000000000000000000000000000000000000000000000000fe)

请注意,与新的代码生成器不同,旧的代码生成器在位非赋值(a = ~a)后不会执行清理。这会导致旧代码生成器和新代码生成器之间在内联汇编块中为返回值 r1 赋值不同的值。但是,两个代码生成器在将 a 的新值赋值给 r2 之前都会执行清理。