阅读(3811) (12)

基于 Solidity IR 的编码更改

2022-05-24 10:53:54 更新

Solidity可以通过两种不同的方式生成EVM字节码:直接从Solidity到EVM操作码(“旧codegen”),或者通过Yul(“new codegen”或“IR-based codegen”)中的中间表示(“IR”)。

引入基于 IR 的代码生成器,不仅使代码生成更加透明和可审计,而且还实现了跨功能的更强大的优化传递。

您可以使用标准 json 中的选项在命令行上启用它,我们鼓励每个人尝试一下!--via-ir{"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();
    }
    

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

  • 删除存储结构时,包含结构成员的每个存储槽都将完全设置为零。以前,填充空间保持不变。因此,如果结构中的填充空间用于存储数据(例如,在合约升级的上下文中),您必须知道现在也会清除添加的成员(虽然过去不会清除它)。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)21

    // 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()

    • 旧代码生成器:因为返回变量在第一次求值之前仅初始化为一次,然后被 .它不会在第二次评估中再次初始化,也不会显式分配它(由于 ),因此它保留其第一个值。10_;return 1;_;foo()active == false

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

  • 将数组从内存复制到存储以不同的方式实现。旧的代码生成器总是复制完整的单词,而新的代码生成器在其结束后剪切字节数组。旧行为可能导致在阵列结束后(但仍在同一存储插槽中)复制脏数据。这会导致某些合约的差异,例如:bytes

    // SPDX-License-Identifier: GPL-3.0
    pragma solidity >=0.8.1;
    
    contract C {
        bytes x;
        function f() public returns (uint r) {
            bytes memory m = "tmp";
            assembly {
                mstore(m, 8)
                mstore(add(m, 32), "deadbeef15dead")
            }
            x = m;
            assembly {
                r := sload(x.slot)
            }
        }
    }
    

    以前会返回(它具有正确的长度,并且正确的前8个元素,但随后它包含通过程序集设置的脏数据)。现在它正在返回(它具有正确的长度和正确的元素,但不包含多余的数据)。f()0x64656164626565663135646561640000000000000000000000000000000000100x6465616462656566000000000000000000000000000000000000000000000010

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

    例如:

    // 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)

    • 旧代码生成器:()但返回值通常未指定10add(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).max0xffffffffffffffff

    例如:

    // 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() 的行为如下:

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

    • 新的代码生成器:由于可用内存指针溢出而恢复(不会耗尽气体)

内部

内部函数指针

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

在新的代码生成器中,函数指针使用按顺序分配的内部 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)

  • 旧代码生成器:(,fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe00000000000000000000000000000000000000000000000000000000000000fe)

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

请注意,与新代码生成器不同,旧代码生成器在位不赋值 () 之后不执行清理。这会导致分配不同的值(在内联程序集块内)以在旧代码生成器和新代码生成器之间返回值。但是,在将 的新值 分配给 之前,这两个代码生成器都会执行清理。a = ~ar1ar2