OneSwap系列四之ABI并非小透明

620 阅读15分钟

在编程语言的发展历史上,ABI(Application Binary Interface)是非常重要的。源代码中的函数调用语法,是程序员能够理解的,但机器理解不了。机器能理解的是二进制数据,当一个函数调用另一个函数的时候,参数应该通过寄存器还是通过栈传递,顺序如何,结构体参数的成员如何排列,这些事项必须定义清楚,这就是ABI。如果遵循同一个ABI的话,来自不同厂家的编译器所生成的二进制文件,其中的函数可以互相调用,甚至不同语言的函数之间也能互相调用,例如C语言和Pascal语言。

ABI是如此重要,但绝大多数程序员对于ABI都非常陌生。这是为什么呢?因为编译器把ABI包装得太好了,程序员几乎不用自己去关注ABI的细节,把它当作是小透明即可。

对于Solidity和EVM,ABI也是非常重要的。例如,以下这些重要事项都是ABI定义的:调用合约函数时的参数如何传递进去;合约的各个返回值如何传递出去(Solidity支持多返回值);Event的参数如何传递出去。

作为一个Solidity程序员,比较麻烦的一点是:你不能像C语言程序员一样,把ABI当成小透明了。Solidity语言的世界里,ABI为什么存在感这么强呢?主要有两个原因:

  1. Solidity还不足够成熟,没有把底层的ABI包装得足够好,程序员需要自己去关注ABI的细节
  2. ABI会影响到Gas消耗,不懂ABI细节,可能会无意间造成很大的Gas消耗

接下来我们先简单描述一下Solidity的ABI的细节,接着介绍一些需要关注ABI细节的场景。

Solidity的ABI

一个合约总是在一个EVM实例当中执行的,这个EVM和外部是相对隔绝的,它的栈和内存,外部都不可读写;同时它专门开辟了calldata区域和returndata区域进行输入输出,这两个区域里可以保存任意长度的字节串。详细的交互机制是:

  1. 当一个外部账户调用合约时,transaction里面的calldata数据被复制到被调用的EVM的calldata区域作为输入
  2. 当合约调用合约时,需要指定EVM的内存中一个片段,此片段被复制到被调用的EVM的calldata区域作为输入
  3. 当一个合约执行完毕时,需要指定EVM的内存中一个片段,此片段被复制到调用者的returndata区域

EVM提供专门的指令,用来:

  1. 将calldata区域的数据拷贝到栈上或内存中,以便了解外部用怎样的参数调用了自己
  2. 将returndata区域的数据拷贝到内存中,以便了解自己所调用的合约返回了什么数据

Solidity的ABI约定了参数应当如何排列以构成字节串。输入参数如何在calldata区域中排列,输出参数如何在returndata区域中排列,Event的参数如何被编码为外界看到的日志数据,都是由这一个ABI来约定的。要想深入了解ABI,需要阅读其规范,这里我们先简单介绍一下:

  1. 按照参数出现的顺序对其进行编码,编码结果分两部分,第一部分是定长数据的内容和偏移量,第二部分是变长数据的内容
  2. 编码后的数据以32字节为单位,每32字节的数据算做一个Slot
  3. 整数和bool值占用一个Slot,如果长度不足32字节,在高位补零
  4. 长度为N的整数数组或bool数组占用N个Slot,每个成员占用1个Slot
  5. 变长数据类型需要用三个部分来表示:偏移量、长度和内容
    1. 偏移量放在第一部分,作为一个指针,指向变长数据的内容的起始位置
    2. 长度占用1个slot,表示内容的长度;内容占用整数个slot,它们都被放在第二部分
    3. stirng和bytes类型的长度是它们的字节数,内容密集排列在N个Slot中,不能占满整数个slot的话,最后一个slot需要补零
    4. 变长的整数数组或bool数组的长度是它们的成员个数,每个成员占用一个Slot

对于合约调用的calldata,除了包含参数之外,在最开头的四个字节,还会包含一个“函数选择符”(function selector),下文会详细描述它。

如果实际传给一个合约的calldata比预期的短,Solidity所插入的解析calldata的逻辑会发现这一点,并且报错。如果比预期的更长呢?Solidity只是无视这些多余的数据,并不会报错。

这是一个很有趣的feature,像EOS、CoinEx Chain这样的区块链,都支持在一个交易之后附加个memo,它不会改变交易的语义,仅仅是把一些信息附加在交易上,并且被打包进链,作为永久的存证。以太坊并不支持在交易格式中显式支持memo,但这种在calldata背后附加无害信息的方式,可以达到类似memo的效果。

外部账户调用合约时的Gas消耗

Solidity这套ABI给人的第一印象就是:会不会补太多的零了?一个32位的整数参数要补28个零,而一个bool值需要补31个零。这样一来,调用合约的transaction当中,绝大部分字节都是用来补零的了。

的确是这样。为了减少这些补零给用户造成的Gas损耗,以太坊规定,交易中一个非零字节收68 Gas,而一个零字节收4 Gas,相差17倍,这样补零的字节就不至于让你那么肉痛了。

但我们仍然要注意,external函数的参数定义对于Gas消耗仍然是举足轻重的。比如,你要传递256个bool值进去,用过bool[256]类型,将消耗4*32*256=32768 Gas,而如果你使用过一个uint256作为bit mask来传递,只需消耗68*32=2176 Gas。

在OneSwap的合约当中,在补零的字节数可能会比较多的情况下,都使用了参数压缩。例如OneSwapToken向多个地址转账的函数:

    function multiTransfer(uint256[] calldata mixedAddrVal) public override returns (bool) {
        for (uint i = 0; i < mixedAddrVal.length; i++) {
            address to = address(mixedAddrVal[i]>>96);
            uint256 value = mixedAddrVal[i]&(2**96-1);
            _transfer(msg.sender, to, value);
        }
        return true;
    }

这里的mixedAddrVal数组,每个成员的高160位是收款地址,低96位是打款的金额。这里没有使用两个变长数组来分别保存收款地址列表和转账金额列表,因为那样会有非常多的补零的字节。

又比如,在交易对的批量删除订单的函数,是如此定义的:

    function removeOrders(uint[] calldata rmList) external override lock {
        uint[5] memory proxyData;
        uint expectedCallDataSize = 4+32*(ProxyData.COUNT+2+rmList.length);
        ProxyData.fill(proxyData, expectedCallDataSize);
        for(uint i = 0; i < rmList.length; i++) {
            uint rmInfo = rmList[i];
            bool isBuy = uint8(rmInfo) != 0;
            uint32 id = uint32(rmInfo>>8);
            uint72 prevKey = uint72(rmInfo>>40);
            _removeOrder(isBuy, id, prevKey, proxyData);
        }
    }

这里的rmList数组,每个成员包含了一个bool值、一个32位整数和一个72位整数,共14个非零字节。这里每个成员仍然有18个零字节,如果要进一步压缩,可以让每个数组成员对应两个待删除的订单,但考虑到代码的可读性,并没有这么做。

关键字calldata

在上面的两个例子中,大家可能注意到了参数被标记了calldata属性。Solidity中,但凡是传引用的参数,包括bytes、string、以及定长和变长数组,都需要标注起来源究竟是storagememorycalldata中的哪一个。如果是storage,则参数来自持久化的存储;如果是memory,则来自内存;如果是calldata,则来自calldata区域。一个函数如果有external属性,则它不能接收storage类型的参数,因为外部访问不了合约里面的storage。下面的代码片段,编译后会报错:

function try2(uint[] storage aList) external returns (uint) {
	return aList[0]+aList[1];
}
// Error: Data location must be "memory" or "calldata" for parameter in external function, but "storage" was given.

一个internal函数,如果它的参数是memory,那么其他函数可以修改一个memory属性的参数之后,再调用它;如果它的参数是calldata,那么意味着这个参数是只读的,其他函数无法修改calldata的内容。

对于一个external函数,它的参数被标注为memory还是calldata,似乎关系不大,二者用起来效果都一样。但其实二者的gas消耗是不同的。在一个external函数开始执行之前,会先执行一些Solidity自行插入的ABI解析逻辑,它们负责把calldata区域的数据复制出来,具体而言:

  1. 对于普通的值类型(整数、bool、地址),要复制到栈上
  2. 对于memory类型,要复制到内存中
  3. 对于calldata类型,不进行复制;external函数开始执行后,自行决定何时复制、复制哪些

由此可见,对于external函数,最好还是把参数设置为calldata类型,而不是memory类型,这样可以按需复制,节约Gas。

上面第1种“复制到栈上”的做法,有时会引起让程序员非常头痛的问题。例如下面这段代码:

function try1(uint a1, uint a2, uint a3, uint a4, uint a5, uint a6, uint a7, uint a8, uint a9) public pure returns (uint) {
	return try2(a1, a2, a3, a4, a5, a6, a7, a8, a9);
}

function try2(uint a1, uint a2, uint a3, uint a4, uint a5, uint a6, uint a7, uint a8, uint a9) public pure returns (uint) {
	return a1+a2+a3+a4+a5+a6+a7+a8+a9;
}

这两个函数看起来人畜无害,在其他编程语言中,类似的代码是100%能编译执行的。但在Solidity这里,就是无法编译,报错:

Compiler error: Stack too deep, try removing local variables. 

原因在于,在Solidity中,代码中任意一处,位于栈上的活动(Active)的局部变量的数目不能超过16个。在函数内部声明的变量算局部变量,输入参数也算,调用其他函数的参数也算,上面的try1函数有9+9=18>16个局部变量了,就引发了错误。

所以,当你因为输入参数过多而遭遇到类似烦恼时,可以考虑把一些参数打包成calldata类型的数组,这样就不占用栈空间了,类似这样:

function try3(uint[9] calldata a) public pure returns (uint) {
	return a[0]+a[1]+a[2]+a[3]+a[4]+a[5]+a[6]+a[7]+a[8];
}

Event参数的Gas消耗

仔细推敲起来,在四种场景下,ABI编码的数据可能会造成Gas消耗:

  1. 外部账户调用合约,生产calldata造成的Gas消耗
  2. 合约调用合约,生产calldata造成的Gas消耗
  3. 外部账户调用合约,生产returndata造成的Gas消耗
  4. 合约调用合约,生产returndata造成的Gas消耗
  5. Event的参数所造成的Gas消耗

按照以太坊的规范,上面列的2、3、4三点并不会造成Gas消耗,当然calldata和returndata被消费即被读取的时候,肯定还是有Gas消耗的,但生产它们,真的没有。

上面的第1点消耗,刚才我们介绍过了。第5点在这里介绍一下:Event的参数被编码为data segment之后,按照每字节8 Gas来计费,而且不区分零字节和非零字节。这样一来,一个bool变量就可能会造成32*8=256 Gas的消耗。为了节省Gas,对于Event的参数,有必要做一些压缩,我们在后续的文章中,会详细介绍这一点。

必须进行底层call的时候,你需要理解ABI

在OneSwap的合约当中,有这样一些奇怪的常量定义:

bytes4 private constant _SELECTOR = bytes4(keccak256(bytes("transfer(address,uint256)")));
bytes4 private constant _SELECTOR2 = bytes4(keccak256(bytes("transferFrom(address,address,uint256)")));
bytes4 private constant _APPROVE_SELECTOR = bytes4(keccak256(bytes("approve(address,uint256)")));

这些bytes4类型的变量是什么呢?原来,它们就是刚才提到过的,calldata最开头的四个字节,即函数选择符。从上面的语句当中,可以看出函数选择符的生成机制:将函数的签名(即函数名和参数的类型列表)作为字符串丢给哈希函数keccak256,得到的哈希值的最低4个字节,就是函数选择符了。

不同于JVM和WebAssembly,EVM自身并没有函数的概念。Solidity里面的函数,无非是一段字节码的片段。在EVM被启动之初,会执行一段Solidity插入的函数选择逻辑,它根据calldata开头四字节的函数选择符,通过一段if-else风格的判断,决定跳转到哪个函数所对应的字节码片段的起始位置。

在OneSwap的合约当中,这样使用了函数选择符:

    function _safeTransferToMe(address token, address from, uint value) internal {
        (bool success, bytes memory data) = token.call(abi.encodeWithSelector(_SELECTOR2, from, address(this), value));
        require(success && (data.length == 0 || abi.decode(data, (bool))), "LockSend: TRANSFER_TO_ME_FAILED");
    }
    function _safeTransfer(address token, address to, uint value) internal {
        (bool success, bytes memory data) = token.call(abi.encodeWithSelector(_SELECTOR, to, value));
        require(success && (data.length == 0 || abi.decode(data, (bool))), "LockSend: TRANSFER_FAILED");
    }
    function _safeApprove(address token, address spender, uint value) internal {
       token.call(abi.encodeWithSelector(_APPROVE_SELECTOR, spender, value)); // result does not matter
    }

我们可以对一个合约的地址施加call函数,它的参数就是调用这个合约的calldata。我们使用abi.encodeWithSelector来生成calldata,它的第一个参数是函数选择符,后续参数会按照ABI规范被编码为字节串,附加在选择符的后面,这样就构成了calldata。合约调用之后,会返回两个值:一个是bool类型的调用结果,表明被调用的合约是否成功结束了;另一个是bytes类型的合约返回值,即returndata。这个returndata可以用abi.decode来解码出返回的一个或多个参数。

这种调用合约的方式,可以说非常贴近底层了,把EVM合约调用的机制,以及ABI规范的细节,全部暴露给用户了。您可能会问,Solidity原本提供了用户友好的语法来调用其他合约的函数,它非常类似调用本合约内部的函数:uint256 govOnes = IERC20(ones).balanceOf(address(this));。为何还要大费周章,用底层的call函数?

原因在于,用户友好的机制缺乏灵活性。比如上面的_safeApprove,我们完全忽略了call所返回的两个参数,也就是说,即使调用失败了(即bool类型的调用结果为false),也不做任何处理,这是业务逻辑的需求。如果改用用户友好的语法,那么调用失败的话,当前交易也会执行失败。又比如,上面的_safeTransfer,它认为合约返回的returndata长度为零(data.length == 0)或者可以解析出为True的bool值(abi.decode(data, (bool))),都算是合约成功执行了;如果改用用户友好的语法,那么只有后一种情况会被视为成功执行。

如果不能确定被调用的合约是事先就确认安全的,那么最好使用call函数来进行底层的调用,然后根据业务逻辑的需求,仔细分析它的两个返回值,以确定调用的状态,再决定如何应对。

使用汇编构造变长数组

Solidity对memory中的变长数组支持非常有限,比如,它不像storage中的变长数组,支持用push和pop动态修改其长度,下面的函数无法通过编译:

    function copyArr(uint[] calldata a) public pure returns (uint[] memory b) {
        for(uint i=0; i<a.length; i++) b.push(a[i]);
        return b;
    }
    //Error: Member "push" is not available in uint256[] memory outside of storage.

我们必须在创建变长数组的同时,就确定它的长度,修改成这样就可以正常编译和执行了:

    function copyArr(uint[] calldata a) public pure returns (uint[] memory b) {
        b = new uint[](a.length);
        for(uint i=0; i<a.length; i++) b[i] = a[i];
        return b;
    }

又比如,它不像calldata中的变长数组,支持slicing,下面代码中的sliceArrMemory无法通过编译,而sliceArrCalldata可以。

    function sliceArrCalldata(uint[] calldata a) public pure returns (uint[] calldata b) {
        return a[:1];
    }
    function sliceArrMemory(uint[] memory a) public pure returns (uint[] memory b) {
        return a[:1];
    }

但有的时候,我们的确非常希望能够把一个长度恰好的变长数组作为返回值,例如查询订单簿的getOrderList函数,它返回值的每个成员都对应一个订单(除了第零个)。事先申请一个足够大的memory数组然后做slicing取有效的片段,可以吗?不行,Solidity不支持。事先申请一个小的memory数组然后动态增加呢?Solidity也不支持。

所以,唯一的办法就是根据ABI的规范,自己用汇编实现一个以变长数组作为返回值的函数,这也就是最终OneSwap实现getOrderList函数的方式:

    // Get the orderbook's content, starting from id, to get no more than maxCount orders
    function getOrderList(bool isBuy, uint32 id, uint32 maxCount) external override view returns (uint[] memory) {
        if(id == 0) {
            if(isBuy) {
                id = uint32(_bookedStockAndMoneyAndFirstBuyID>>224);
            } else {
                id = uint32(_reserveStockAndMoneyAndFirstSellID>>224);
            }
        }
        uint[1<<22] storage orderbook;
        if(isBuy) {
            orderbook = _buyOrders;
        } else {
            orderbook = _sellOrders;
        }
        //record block height at the first entry
        uint order = (block.number<<24) | id;
        uint addrOrig; // start of returned data
        uint addrLen; // the slice's length is written at this address
        uint addrStart; // the address of the first entry of returned slice
        uint addrEnd; // ending address to write the next order
        uint count = 0; // the slice's length
        assembly {
            addrOrig := mload(0x40) // There is a “free memory pointer” at address 0x40 in memory
            mstore(addrOrig, 32) //the meaningful data start after offset 32
        }
        addrLen = addrOrig + 32;
        addrStart = addrLen + 32;
        addrEnd = addrStart;
        while(count < maxCount) {
            assembly {
                mstore(addrEnd, order) //write the order
            }
            addrEnd += 32;
            count++;
            if(id == 0) {break;}
            order = orderbook[id];
            require(order!=0, "OneSwap: INCONSISTENT_BOOK");
            id = uint32(order&_MAX_ID);
        }
        assembly {
            mstore(addrLen, count) // record the returned slice's length
            let byteCount := sub(addrEnd, addrOrig)
            return(addrOrig, byteCount)
        }
    }

我们希望在内存中构造一个符合ABI规范的变长数组作为返回值。之前我们介绍过,一个变长数组被编码后分三部分:偏移量,成员数量,以及数组的内容。这里的返回值只有一个变长数组,没有其他参数,所以偏移量固定为32,表示第二部分(即成员数量)开始于第32个字节。

Solidity会在内存0x40的位置上保存当前memory allocator所有已经分配的内存空间的终止位置,大于这个位置的内存空间,都是尚未分配的,也是我们可以用来构造变长数组的returndata的空间。这段空间的第一个uint256,用来保存偏移量32(mstore(addrOrig, 32)),这段空间的第二个uint256,用来保存成员的数量(mstore(addrLen, count)),之后的若干个uint256,用来保存数组成员,这里用变量addrEnd指向下一个数组成员应该被保存的位置(mstore(addrEnd, order)),每保存一个新的数组成员,它的值就递增32(addrEnd += 32;)。

原文:《OneSwap Series 4 - ABI is not the invisible man》

链接::medium.com/@OneSwap/on…

翻译:OneSwap中文社区