为什么bool比uint256消耗更多的gas

之前遇到有朋友这么问:“为什么bool比uint256消耗更多的gas”。首先要说明的是这个说法是不准确的,并不是简单的bool和uint256这个类型差异造成的。我们在看OpenZeppelin的ReentrancyGuard.sol的源码时,会看到下面的写法,我们从底层机制上分析

// Booleans are more expensive than uint256 or any type that takes up a full
    // word because each write operation emits an extra SLOAD to first read the
    // slot's contents, replace the bits taken up by the boolean, and then write
    // back. This is the compiler's defense against contract upgrades and
    // pointer aliasing, and it cannot be disabled.
//.....
    uint256 private constant NOT_ENTERED = 1;
    uint256 private constant ENTERED = 2;


一. 首先,slot存储槽基本最小长度是256bit,EVM每次会按照整个slot进行读取,这个可以在instructions.go中找到“opSload”方法。

https://github.com/ethereum/go-ethereum/blob/master/core/vm/instructions.go

val := interpreter.evm.StateDB.GetState(scope.Contract.Address(), hash)
// 此处就是从slot中获取值,并不会判断变量的类型,直接返回整个32字节的内容

二. bool是1bit,如果是连续定义多个bool变量,编译器会把多个bool变量打包进同一个slot,用offset来定义每个变量的在slot中的位置。我们可以编译一段代码

contract Test {
    bool a;
    bool b;
    uint8 f;
    uint256 c;
    bool d;
    uint256 e;

    function getA() public view returns (bool){
      return a;
    }

    function setA(bool _a) public {
      a = _a;
    }

    function getC() public view returns (uint256) {
        return c;
    }

    function setC(uint256 _c) public {
        c = _c;
    }

    function getD() public view returns (bool) {
        return d;
    }

    function setD(bool _d) public {
        d = _d;
    }
}


在“Solidity Compile Details”中可以看到”STORAGELAYOUT”

{
    "storage": [
        {
            "astId": 3,
            "contract": "Test.sol:Test",
            "label": "a",
            "offset": 0,
            "slot": "0",
            "type": "t_bool"
        },
        {
            "astId": 5,
            "contract": "Test.sol:Test",
            "label": "b",
            "offset": 1,
            "slot": "0",
            "type": "t_bool"
        },
        {
            "astId": 7,
            "contract": "Test.sol:Test",
            "label": "f",
            "offset": 2,
            "slot": "0",
            "type": "t_uint8"
        },
        {
            "astId": 9,
            "contract": "Test.sol:Test",
            "label": "c",
            "offset": 0,
            "slot": "1",
            "type": "t_uint256"
        },
        {
            "astId": 11,
            "contract": "Test.sol:Test",
            "label": "d",
            "offset": 0,
            "slot": "2",
            "type": "t_bool"
        },
        {
            "astId": 13,
            "contract": "Test.sol:Test",
            "label": "e",
            "offset": 0,
            "slot": "3",
            "type": "t_uint256"
        }
    ],
    "types": {
        "t_bool": {
            "encoding": "inplace",
            "label": "bool",
            "numberOfBytes": "1"
        },
        "t_uint256": {
            "encoding": "inplace",
            "label": "uint256",
            "numberOfBytes": "32"
        },
        "t_uint8": {
            "encoding": "inplace",
            "label": "uint8",
            "numberOfBytes": "1"
        }
    }
}


我们可以看到a,b,f 所在slot的编号都是0,由此可以知道都被打包进了一个slot。但是d又是独占slot2。这里有个编译规则,如果是多个变量挤压在同一个slot中,则写的过程是SLOAD-SSTORE,如是变量独占一个slot,则写的过程就是SSTORE。前一个操作多了一个SLOAD,因此gas消费更多。

由此可以总结出

  • 多个小变量打包进同一个 slot(如多个 bool、uint8、uint16),“单独写入”某个变量时,必须先 SLOAD slot(读出旧值)、在内存中修改对应 bit/byte、再 SSTORE(整体写回 slot)。

  • 如果变量独占一个 slot(如 uint256、单独的 bool),写操作通常可以直接 SSTORE,不需要预读。

这个在初学的时候是非常容易混淆和不好理解的概念。