Solidity 内联汇编

       最近大致浏览了一下Aragon的DAO框架合约,Solidity编写的源代码里使用了很多内联汇编。虽然这种做法有待商榷,但它同时也表明了熟练使用Solidity内联汇编的必要性与紧迫性。CSDN上已经有很多人对Solidity汇编这一章进行了翻译并且翻译的很好,只是:纸上得来终觉浅,绝知此事要躬行, 并且版本有更新,还是需要自己动手才行。因此决定对照英文文档做一下简单的中文记录,目的是进行Solidity内联汇编的学习。这中间也参考了他人的翻译文档,结此表示感谢。

        本文以solidity 0.6.0 官方文档英文版为准,并且省略了独立汇编部分。

       在Solidity中,定义了一种既能在Solidity代码内部又能独立使用的汇编语言。本文主要介绍了怎么使用内联汇编及相关语法。

内联汇编

       你可以在Solidity语句中嵌入一种接近以太坊虚拟机(EVM)底层的汇编语言。它能给你更多控制,并且能增强你写的库的功能。
       EVM作为一种堆栈机,它很难寻址正确的栈插槽并将参数传递给操作码。Solidity内联汇编会帮助你进行这样的操作,并且避免手写汇编可能会带来的一些额外问题。
       对内联汇编来讲,堆栈是完全不可见的。但是你仔细观察,就会发现有一个直接从内联汇编到EVM操作码流转换的方法。

       内联汇编有如下几个特性:

  • 函数风格式的操作码:mul(1, add(2,3))
  • 汇编局部变量:let x := add(2,3)
  • 访问外部变量:function f(uint x) public { assembly { x := sub(x, 1) } }
  • 循环:for { let i := 0 } lt(i, x) { i := add(i, 1) } { y := mul(2, y) }
  • if 语句
  • switch 语句
  • 函数调用
       警告
       内联汇编是一种访问EVM的低级方法,它跳过了Solidity一些重要的安全特性和检查。你仅应该在确实需要并且很清楚的知道它的功能的时候使用它 。

语法

       内联汇编采用和Solidity相同的方式解析注释、字面值和标识符,你可以使用常用的//和/**/注释。这里有一个例外:内联汇编中的标识符可以含有.。内联汇编使用 assembly {...} 标记,在大括号内部,你可以使用下列语法:

  • 字面值,例如0x12342"abc" (字符串上限32个字符)
  • 函数风格式的操作码:add(1,mload(0))
  • 变量定义:let x := 7let x (默认初始化值为0)
  • 标识符(汇编局部变量或者外部变量):add(3,x)sstore(x_slot,2)
  • 赋值:x := add(y,3)
  • 定义代码块用来限定变量的作用域:{ let x := 3 { let y := add(x, 1) } }

       内联汇编管理着内部变量和控制流程。因此,有部分功能相冲突的操作码是无法使用的,它们包括dup,swapjump,同样不能使用的还有标签功能。

示例

       下面的例子演示了一个库访问另一个合约的代码并将它赋值给一个bytes变量。这在普通的Solidity代码中是无法实现的。这也表明可复用的汇编库能在不改变编译器的情况下增强Solidity语言的功能。

pragma solidity >=0.4.0 <0.7.0;

library GetCode {
    function at(address _addr) public view returns (bytes memory o_code) {
        assembly {
            // 获得_addr地址的代码大小
            let size := extcodesize(_addr)
            //分配输出字节数组,也可以不使用汇编
            // o_code = new bytes(size)
            o_code := mload(0x40)
            // 包含补位的新内存块
            mstore(0x40, add(o_code, and(add(add(size, 0x20), 0x1f), not(0x1f))))
            // 存储长度
            mstore(o_code, size)
            // 获得代码
            extcodecopy(_addr, add(o_code, 0x20), 0, size)
        }
    }
}

内联汇编在无法对代码进行优化时也能提供帮助(示例略)。

操作码

       本节并不是想提供对EVM操作码的完全描述,但是下面的列表可以作为一个快速索引。
       如果操作码需要参数,必须放在括号中。使用-标记的操作码不会返回一个结果;使用*标记的操作码视为特殊操作码;除此之外的其它操作码都会返回一个值。标记为F,H,B,C,I的操作码代表着从不同的版本开始可用,依次为前沿、家园、拜占庭、君士坦丁堡和伊斯坦布尔。(2019年12月,以太坊升级到宁静版本,这里并没有列出)。
       下面的语法中,mem[a...b)表示内存中从地址a开始但是不包括地址b的字节,storage[p]表示在插槽p的存储内容。
       在语法上,操作码被表示为预定义标识符(内置函数)

       因为表格过大,所以没有按照原文列出操作码的返回标记及版本标记,只是对操作码进行了一下简单解释,并且也没有使用表格。
  • stop() 退出执行,和return(0,0)相同。在0.6.0中,增加了一个leave语句用来退出当前的函数。
  • add(x,y),sub(x,y),mul(x,y),div(x,y),sdiv(x,y),mod(x,y),smod(x,y),exp(x,y) 这些都是算术运算。其中s前缀代表signed numbers,也就是有符号数。
  • not(x) 按位否
  • lt(x,y),gt(x,y),slt(x,y),sgt(x,y),eq(x,y),iszero(x) 比较操作,结果为真返回1,为假返回0,s前缀同算术运算符。
  • and(x,y),or(x,y),xor(x,y) 按位逻辑操作。
  • byte(n,x) 返回 x的第n个字节,首字节为第0字节
  • shl(x,y),shr(x,y),sar(x,y) 前两个分别是对y左移或者右移x位,第三个是对y有符号右移x位。这里shl是shift left的缩写,slr是shift right的缩写, sar是signed arithmetic shift right 的缩写。
  • addmod(x,y,m),mul(x,y,m)分别是加了或者乘了后再取模,保持任意精度
  • signextend(i,x) 对 x 的最低位到第 (i * 8 + 7) 进行符号扩展
  • keccak256(p,n) 对内存中位置p开始的n字节进行keccak256哈希
  • pc() 当前代码位置,pop(x) 丢弃x的值(弹出)
  • mload(p)读取内存中p位置开始的一个数据(32个字节),mstore(p,v)将v的值赋予内存中以p位置开始的32字节,mstore8(p,v)同前,不过仅修改单字节。注意这里的前缀m代表memory。
  • sload(p) 读取p插槽的值,sstore(p,v)将v的值存于p插槽中。注意这里的前缀s代表storage。
  • msize() 内存大小,用于非常大的内存访问索引时。
  • gas() 执行时可得到的gas大小, address()当前合约或者执行环境的地址 balance(a) a地址的eth数量,以wei为单位,以后同;selfbalance(),和balance(address())等同,不过更便宜。
  • caller() 当前调用者(不适用委托调用delegatecall) callvalue()当前调用发送的eth数量
  • calldataload(p) 从位置p开始的一个调用数据(32字节) calldatasize 调用数据字节数 calldatacopy(t,f,s) 从调用数据位置t开始,复制 s字节的数据到内存t位置。
  • codesize() 当前合约/执行环境的代码大小。
  • codecopy(t,f,s) 从代码f位置开始复制 s字节的数据到内存t位置。
  • extcodesize(a) a地址的代码大小(常用来判断是否合约)。
  • extcodecopy(a,t,f,s)codecopy(t,f,s) 类似,不过是从地址a的代码中复制
  • returndatasize() 最后返回数据的大小
  • returndatacopy(t,f,s)calldatacopy(t,f,s)类似,不过是从返回数据复制
  • extcodehash(a) 地址a 的代码哈希值
  • create(v,p,n) 从内存p位置开始,读取n字节的代码,然后创建一个新的合约并发送v数量的eth到新合约中,返回合约的地址。
  • create2(v,p,n,s) 同上,只不过合约地址是通过复杂计算得到的。
  • call(g,a,v,in,insize,out,outsize) 对a地址的合约进行调用,输入参数为内存中从in开始的inszie字节,g代表gas,v代表value,输出到内存out开始的outsize字节,返回0代表失败,返回1代表成功。
  • callcode(g,a,v,in,insize,out,outsize) 和call调用一样,不过仅使用地址a中的代码,保留了当前合约的上下文环境。
  • delegatecall(g,a,v,in,insize,out,outsize) 和callcode相等,不过保留caller和callvalue不变
  • staticcall(g,a,v,in,insize,out,outsize)call(g,a,0,in,insize,out,outsize) 等同,但是并不允许改变状态。
  • return(p,s) 结束执行,返回从内存中p开始的s字节
  • revert(p,s)return(p,s)类似,不过要重置状态改变
  • selfdestruct(a) 和Solidity中的自毁相同
  • invalid() 以无效的指令结束执行
  • log0(p,s)log1(p,s,t1)log2(p,s,t2)log3(p,s,t1,t2,t3)log4(p,s,t1,t2,t3,t4) 触发日志,topic数据(可索引)为对应的t0…t4,非索引数据为内存中p开始s字节的数据。
  • chainId() 当前网络的ID,比如主网是1,ropsten是3
  • origin() 交易的最初发起者,相当于Solidity中tx.origin
  • gasprice() 当前交易的gas价格,
  • blockhash(b) 块b的哈希值(同样只能取得最近256个块的哈希值)
  • coinbase() 当前矿工的地址
  • timestamp() 当前块的创建时间
  • number() 当前块高度
  • difficulty() 当前块难度
  • gaslimit() 当前块的gas上限

字面值

       你可以直接使用10进制或者16进制的字面值作为整形常量,这将自动产生一个PUSHI指令。下面的代码先将2和3相加得到5,然后和字符串"abc"按位与,最终的值被赋值给局部变量x,字符串存储时是左对齐,并且不能超过32字节。

assembly { let x := and("abc", add(3, 2)) }

函数风格

       对操作码序列来说,阅读并不是很直观(从右向左读会容易一些)。下面的例子中,3被加到内存中0x80的位置。

3 0x80 mload add 0x80 mstore

Solidity内联汇编使用了函数风格的记号,使代码更加容易阅读。

mstore(0x80, add(mload(0x80), 3))

       如果你从右向左阅读代码,你会得到相同序列的操作码和常量,但是它更清楚的表明了参数是在哪使用的。如果你关注实际的栈层级,你就会注意到函数或者操作码的第一个参数是位于栈顶。

访问外部变量、函数和库

       你可以直接通过名称来访问Solidity中的变量和其它标识符。对内存中的数据而言,它是压入的地址而不是实际的值。存储区域的数据有些不同,因为它们可能占不满一个完整的插槽空间,所以它的地址由插槽和插槽内的字节偏移量组成。使用x_slot获取变量x 的插槽,使用x_offset来获取插槽内的偏移量。

       在赋值语句中,我们可以使用Solidity的局部变量,示例如下:

pragma solidity >=0.4.11 <0.7.0;

contract C {
    uint b;
    function f(uint x) public view returns (uint r) {
        assembly {
            r := mul(x, sload(b_slot)) //  因为b是uint类型,所以它没有槽内偏移量
        }
    }
}
       警告
       然而你访问一个类型小于256位的变量时。你不能对不属于该类型数据编码后的位作任何假定,尤其是不能假设它们为0。为了安全起见,你需要对部分数据作清除处理。
uint32 x = f(); assembly { x := and(x, 0xffffffff) /* now use x */ }

为了清除有符号类型,你需要使用signextend操作码。

assembly { signextend(<num_bytes_of_x_minus_one>, x) }

声明汇编局部变量

       你可以使用let关键词来声明一个仅在内联汇编使用的变量,它的作用域在当前{...}块内。let声明会创建一个新的堆栈槽来保存这个变量,在到达作用域结束}时会被自动移除(和Rust语言类似)。默认的初始值(未提供)为0值,初始值也可以是一个复杂的函数风格的表达式。

       从0.6.0开始,变量的名字可以不以_offset或者_slot结尾并且它不再隐藏任何外部块的可见标识符(包括变量、合约和函数定义)。相似的,如果变量名称中包含.,前缀部分并不会和任何外部代码块中已有的变量名相冲突。(外部代码块是指Solidity中包含内联汇编的代码块)

pragma solidity >=0.4.16 <0.7.0;

contract C {
    function f(uint x) public view returns (uint b) {
        assembly {
            let v := add(x, 1)
            mstore(0x80, v)
            {
                let y := add(sload(v), 1)
                b := y
            } // y is "deallocated" here
            b := add(b, v)
        } // v is "deallocated" here
    }
}

赋值

       可以对汇编局部变量和函数局部变量进行赋值。注意,当给指向内存或者存储区域的变量赋值时,你只需要改变指针而不是数据本身。
       变量只可以从只返回一个值的表达式赋值,如果你想使用返回多个值的函数赋值,你需要提供多个变量(使用元组)。

{
    let v := 0
    let g := add(v, 2)
    function f() -> a, b { }
    let c, d := f()
}

If语句

       If语句用来条件选择执行,它并没有"else"部分。当你需要"else"功能时,考虑使用switch作为替代。

{
    if eq(value, 0) { revert(0, 0) }
}

       执行体的{}是必需的。

Switch语句

       你可以使用switch语句作为一个基础版的"if/else"。它将表达式的值和多个常量作比较,符合的分支会被执行。和某些编程语言不同的是,控制流程并不会从一个分支自动转到另一个分支(也就是没有case穿透)。相似的,默认分支叫default

{
    let x := 0
    switch calldataload(4)
    case 0 {
        x := calldataload(0x24)
    }
    default {
        x := calldataload(0x44)
    }
    sstore(0, div(x, 2))
}

       case列表不需要大括号,但是分支的执行体需要。

循环

       汇编支持一个简单的for循环。For循环有一个包含了初始条件、判定条件、末尾循环体的循环头。判定条件必须是函数风格式的表达式,其它两个是代码块。如果在初始条件中定义了任何变量,那么该变量在整个循环中有效(包括循环头和循环体)。
       下面的例子计算了内存中某一区域的和。

{
    let x := 0
    for { let i := 0 } lt(i, 0x100) { i := add(i, 0x20) } {
        x := add(x, mload(i))
    }
}

       For循环也可以用作while循环,只需要将初始条件和末尾循环体为空代码块即可。

{
    let x := 0
    let i := 0
    for { } lt(i, 0x100) { } {     // while(i < 0x100)
        x := add(x, mload(i))
        i := add(i, 0x20)
    }
}

函数

       汇编允许定义低等级函数。它们需要从堆栈中获取参数(和返回 PC)并且将结果放入堆栈。调用一个函数看上去和执行函数风格的操作码一样。
       函数能在任何地方被定义并且在定义它的代码块内始终可见。在函数里面,你不能访问函数外边定义的局部变量。
       如果调用多个返回值的函数进行赋值,你需要使用元组。a, b := f(x) 或者 let a, b := f(x)
       leave语句可以用来退出当前的函数,它相当于其它语言中不返回任何值的return。但是无论如何函数的返回参数会被返回。

       下面的函数实现了指数操作功能。

{
    function power(base, exponent) -> result {
        switch exponent
        case 0 { result := 1 }
        case 1 { result := base }
        default {
            result := power(mul(base, base), div(exponent, 2))
            switch mod(exponent, 2)
                case 1 { result := mul(base, result) }
        }
    }
}

注意事项

       内联汇编看上去像高等级语言,但是它实际上是相当低等级的。函数调用、循环、if和switch语句被转换为简单的重写规则,这在之后,汇编所做的事情只是重新安排函数风格的操作码,为变量统计堆栈高度并且在汇编局部变量到达作用域结束时进行释放。

Solidity惯例

       和EVM汇编不同的是,Solidity中的数据类型可以小于256位。为了效率,绝大多数算术操作符会忽视这些小于256位的变量的实际类型,在必要时也会清除高位数据。这就意味着如果你要从内联汇编中访问这些变量,你需要首先手动清除高位数据。
       Solidity使用下面的方式管理内存。在内存0x40位置存有一个空闲内存指针。如果你想分配内存,从这个指针指向的位置开始分配并且更新它的指向。这里没有任何保证内存以前未被使用并且所有的数据都为字节0(为空)。并没有内建的机制用来释放和自由分配内存。下面的代码片断是一个遵循了上述流程进行分配内存的简单示例:

function allocate(length) -> pos {
  pos := mload(0x40)
  mstore(0x40, add(pos, length))
}

       内存中开始64字节作为临时暂存空间。空闲内存指针后的32个字节(因为指针大小为32个字节,所以地址是从0x60开始)将永远为0被用来初始化空的动态内存数组。这就意味着实际内存分配是从0x80开始的,这也是空闲内存指针最初的值。
       在Solidity中,内存数组中的元素经常占用32个字节(甚至是byte[],并不包括bytesstring)。多维内存数组是指向内存数组的指针。动态数组的长度被存储在数组的第一个槽位,接下来才是数组元素。

       警告
       静态大小内存数组并没有长度字段。在未来可能被增加,用来使静态和动态大小的数组转换更加方便,因此不要依赖它。

独立汇编

(略)

编程要严谨,欢迎大家指出文章中存在的错误或者表达不清晰、不准确的地方。

这里参考了这篇文章的部分内容:以太坊:深入理解Solidity-Solidity汇编
对文章的作者表示感谢。

英文原文: https://solidity.readthedocs.io/en/v0.6.0/assembly.html

发布了15 篇原创文章 · 获赞 0 · 访问量 561

猜你喜欢

转载自blog.csdn.net/weixin_39430411/article/details/103760512