06-Solidity8.0汇编(Solidity Assembly)

Solidity8.0

06-Solidity8.0汇编(Solidity Assembly)


在这里插入图片描述


前言

Solidity汇编(Solidity Assembly)
Solidity定义了一个汇编语言,可以不同Solidity一起使用。这个汇编语言还可以嵌入到Solidity源码中,以内联汇编的方式使用。下面我们将从内联汇编如何使用着手,介绍其与独立使用的汇编语言的不同,最后再介绍这门汇编语言。

内联汇编(Inline Assembly)

通常我们通过库代码,来增强语言我,实现一些精细化的控制,Solidity为我们提供了一种接近于EVM底层的语言,内联汇编,允许与Solidity结合使用。由于EVM是栈式的,所以有时定位栈比较麻烦,Solidty的内联汇编为我们提供了下述的特性,来解决手写底层代码带来的各种问题:

允许函数风格的操作码:mul(1, add(2, 3))等同于push1 3 push1 2 add push1 1 mul
内联局部变量:let x := add(2, 3) let y := mload(0x40) x := add(x, y)
可访问外部变量:function f(uint x) { assembly { x := sub(x, 1) } }
标签:let x := 10 repeat: x := sub(x, 1) jumpi(repeat, eq(x, 0))
循环:for { let i := 0 } lt(i, x) { i := add(i, 1) } { y := mul(2, y) }
switch语句:switch x case 0 { y := mul(x, 2) } default { y := 0 }
函数调用:function f(x) -> y { switch x case 0 { y := 1 } default { y := mul(x, f(sub(x, 1))) } }
下面将详细介绍内联编译(inline assembly)语言。

警告
需要注意的是内联编译是一种非常底层的方式来访问EVM虚拟机。他没有Solidity提供的多种安全机制。
注解
TODO:待补充内联汇编的变量作用域的不同,尤其是使用含internal的函数的库时所引入的复杂度。另外,还需补充,编译器定义的符号(symbols)。


一、Solidity8.0汇编(Solidity Assembly)

1.汇编(Solidity Assembly)

代码如下(示例):
下面的例子提供了一个库函数来访问另一个合约,并把它写入到一个bytes变量中。有一些不能通过常规的Solidity语言完成,内联库可以用来在某些方面增强语言的能力。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8;

library GetCode {
    
    
    function at(address _addr) public view returns (bytes o_code) {
    
    
        assembly {
    
    
            // 取得代码的大小,这需要汇编
            let size := extcodesize(_addr)
            // 分配输出字节数组
            // 也可以不使用汇编完成——o_code = new bytes(size)
            o_code := mload(0x40)
            // new "memory end" including padding
            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)
        }
    }
}

内联汇编在当编译器没办法得到有效率的代码时非常有用。但需要留意的是内联汇编写起来是比较难的,因为编译器不会进行一些检查,所以你应该只在复杂的,且你知道你在做什么的事情上使用它。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8;

library VectorSum {
    
    
    // 此函数效率较低,因为优化器当前无法
    // 移除数组访问中的边界检查。
    function sumSolidity(uint[] _data) public view returns (uint o_sum) {
    
    
        for (uint i = 0; i < _data.length; ++i)
            o_sum += _data[i];
    }

    // 我们知道我们只访问数组,所以我们可以避免检查。
    // 0x20需要添加到一个数组中,因为第一个位置包含
    // 数组长度。
    function sumAsm(uint[] _data) public view returns (uint o_sum) {
    
    
        for (uint i = 0; i < _data.length; ++i) {
    
    
            assembly {
    
    
                o_sum := add(o_sum, mload(add(add(_data, 0x20), mul(i, 0x20))))
            }
        }
    }

    // 功能与上述相同,但完全使用内联汇编。
    function sumPureAsm(uint[] _data) public view returns (uint o_sum) {
    
    
        assembly {
    
    
           // 加载长度(前32字节)
           let len := mload(_data)

           // 跳过长度字段。
           //
           // 保持临时变量,以便可以在适当位置增加。
           //
           // 注意:在这个汇编程序块之后,递增的数据会导致一个不可用的数据变量。
           let data := add(_data, 0x20)

           // Iterate until the bound is not met.
           for
               {
    
     let end := add(data, len) }
               lt(data, end)
               {
    
     data := add(data, 0x20) }
           {
    
    
               o_sum := add(o_sum, mload(data))
           }
        }
    }
}

语法

内联编译语言也会像Solidity一样解析注释,字面量和标识符。所以你可以使用//和/**/的方式注释。内联编译的在Solidity中的语法是包裹在assembly { … },下面是可用的语法,后续有更详细的内容。

	字面量。如0x123,42或"abc"(字符串最多是32个字符)
	操作码(指令的方式),如mload sload dup1 sstore,后面有可支持的指令列表
	函数风格的操作码,如add(1, mlod(0)
	标签,如name:
	变量定义,如let x := 7 、let x := add(y, 3)或let x(初始值为0)
	标识符(标签或内联局部变量或外部),如jump(name),3 x add
	赋值(指令风格),如,3 =: x。
	函数风格的赋值,如x := add(y, 3)
	支持块级的局部变量,如{ let x := 3 { let y := add(x, 1) } }

操作码

这个文档不想介绍EVM虚拟机的完整描述,但后面的列表可以做为EVM虚拟机的指令码的一个参考。

如果一个操作码有参数(通过在栈顶),那么他们会放在括号。需要注意的是参数的顺序可以颠倒(非函数风格,后面会详细说明)。用-标记的操作码不会将一个参数推到栈顶,而标记为*的是非常特殊的,所有其它的将且只将一个推到栈顶。用F、H、B或C标记的操作码分别来自Frontier、Homestead、Byzantium或Constantinople。Constantinople仍在计划中,所有这样的指令都会导致无效的指令异常。

在后面的例子中,mem[a…b)表示成位置a到位置b(不包含)的memory字节内容,storage[p]表示在位置p的strorage内容。

操作码pushi和jumpdest不能被直接使用。

在语法中,操作码被表示为预先定义的标识符。

操作码 说明

stop			F	停止执行,等同于return(0,0)
add(x, y)	 	F	x + y
sub(x, y)	 	F	x - y
mul(x, y)	 	F	x * y
div(x, y)	 	F	x / y
sdiv(x, y)	 	F	x / y, for signed numbers in two’s complement
mod(x, y)	 	F	x % y
smod(x, y)	 	F	x % y, for signed numbers in two’s complement
exp(x, y)	 	F	x to the power of y
not(x)	 		F	~x, every bit of x is negated
lt(x, y)	 	F	1 if x < y, 0 otherwise
gt(x, y)	 	F	1 if x > y, 0 otherwise
slt(x, y)	 	F	1 if x < y, 0 otherwise, for signed numbers in two’s complement
sgt(x, y)	 	F	1 if x > y, 0 otherwise, for signed numbers in two’s complement
eq(x, y)	 	F	1 if x == y, 0 otherwise
iszero(x)	 	F	1 if x == 0, 0 otherwise
and(x, y)	 	F	bitwise and of x and y
or(x, y)	 	F	bitwise or of x and y
xor(x, y)	 	F	bitwise xor of x and y
byte(n, x)	 	F	nth byte of x, where the most significant byte is the 0th byte
shl(x, y)	 	C	logical shift left y by x bits
shr(x, y)	 	C	logical shift right y by x bits
sar(x, y)	 	C	arithmetic shift right y by x bits
addmod(x, y, m)	 	F	(x + y) % m with arbitrary precision arithmetics
mulmod(x, y, m)	 	F	(x * y) % m with arbitrary precision arithmetics
signextend(i, x)	 	F	sign extend from (i*8+7)th bit counting from least significant
keccak256(p, n)	 	F	keccak(mem[p…(p+n)))
sha3(p, n)	 	F	keccak(mem[p…(p+n)))
jump(label)	-	F	jump to label / code position
jumpi(label, cond)	-	F	jump to label if cond is nonzero
pc	 	F	current position in code
pop(x)	-	F	remove the element pushed by x
dup1 … dup16	 	F	copy ith stack slot to the top (counting from top)
swap1 … swap16	*	F	swap topmost and ith stack slot below it
mload(p)	 	F	mem[p..(p+32))
mstore(p, v)	-	F	mem[p..(p+32)) := v
mstore8(p, v)	-	F	mem[p] := v & 0xff (only modifies a single byte)
sload(p)	 	F	storage[p]
sstore(p, v)	-	F	storage[p] := v
msize	 	F	size of memory, i.e. largest accessed memory index
gas	 	F	gas still available to execution
address	 	F	address of the current contract / execution context
balance(a)	 	F	wei balance at address a
caller	 	F	call sender (excluding delegatecall)
callvalue	 	F	wei sent together with the current call
calldataload(p)	 	F	call data starting from position p (32 bytes)
calldatasize	 	F	size of call data in bytes
calldatacopy(t, f, s)	-	F	copy s bytes from calldata at position f to mem at position t
codesize	 	F	size of the code of the current contract / execution context
codecopy(t, f, s)	-	F	copy s bytes from code at position f to mem at position t
extcodesize(a)	 	F	size of the code at address a
extcodecopy(a, t, f, s)	-	F	like codecopy(t, f, s) but take code at address a
returndatasize	 	B	size of the last returndata
returndatacopy(t, f, s)	-	B	copy s bytes from returndata at position f to mem at position t
create(v, p, s)	 	F	create new contract with code mem[p..(p+s)) and send v wei and return the new address
create2(v, n, p, s)	 	C	create new contract with code mem[p..(p+s)) at address keccak256(
. n . keccak256(mem[p..(p+s))) and send v wei and return the new address
call(g, a, v, in, insize, out, outsize)	 	F	call contract at address a with input mem[in..(in+insize)) providing g gas and v wei and output area mem[out..(out+outsize)) returning 0 on error (eg. out of gas) and 1 on success
callcode(g, a, v, in, insize, out, outsize)	 	F	identical to call but only use the code from a and stay in the context of the current contract otherwise
delegatecall(g, a, in, insize, out, outsize)	 	H	identical to callcode but also keep caller and callvalue
staticcall(g, a, in, insize, out, outsize)	 	B	identical to call(g, a, 0, in, insize, out, outsize) but do not allow state modifications
return(p, s)	-	F	end execution, return data mem[p..(p+s))
revert(p, s)	-	B	end execution, revert state changes, return data mem[p..(p+s))
selfdestruct(a)	-	F	end execution, destroy current contract and send funds to a
invalid	-	F	end execution with invalid instruction
log0(p, s)	-	F	log without topics and data mem[p..(p+s))
log1(p, s, t1)	-	F	log with topic t1 and data mem[p..(p+s))
log2(p, s, t1, t2)	-	F	log with topics t1, t2 and data mem[p..(p+s))
log3(p, s, t1, t2, t3)	-	F	log with topics t1, t2, t3 and data mem[p..(p+s))
log4(p, s, t1, t2, t3, t4)	-	F	log with topics t1, t2, t3, t4 and data mem[p..(p+s))
origin	 	F	transaction sender
gasprice	 	F	gas price of the transaction
blockhash(b)	 	F	hash of block nr b - only for last 256 blocks excluding current
coinbase	 	F	current mining beneficiary
timestamp	 	F	timestamp of the current block in seconds since the epoch
number	 	F	current block number
difficulty	 	F	difficulty of the current block
gaslimit	 	F	block gas limit of the current block

字面量(Literals)

你可以使用整数常量,通过直接以十进制或16进制的表示方式,将会自动生成恰当的pushi指令。

assembly {
    
     2 3 add "abc" and }

上面的例子中,将会先加2,3得到5,然后再与字符串abc进行与运算。字符串按左对齐存储,且不能超过32字节。

函数风格(Functional Style)

你可以在操作码后接着输入操作码,它们最终都会生成正确的字节码。比如下面将会添加3到memory中位置0x80:

3 0x80 mload add 0x80 mstore

由于经常很难直观的看到某个操作码真正的参数,Solidity内联编译提供了一个函数风格的表达式,上面的代码与下述等同:

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

函数风格的表达式不能在内部使用指令风格,如1 2 mstore(0x80, add)将不是合法的,必须被写为mstore(0x80, add(2, 1))。那些不带参数的操作码,括号可以忽略。

需要注意的是函数风格的参数与指令风格的参数是反的。如果使用函数风格,第一个参数将会出现在栈顶。

访问外部函数与变量(Access to External Variables and Functions)

Solidity中的变量和其它标识符,可以简单的通过名称引用。对于memory变量,这将会把地址而不是值推到栈上。Storage的则有所不同,由于对应的值不一定会占满整个storage槽位,所以它的地址由槽和实际存储位置相对起始字节偏移。要搜索变量x指向的槽位,使用x_slot,得到变量相对槽位起始位置的偏移使用x_offset。

在赋值中(见下文),我们甚至可以直接向Solidity变量赋值。

还可以访问内联编译的外部函数:内联编译会推入整个的入口的label(应用虚函数解析的方式)。Solidity中的调用语义如下:

调用者推入return label,arg1,arg2, … argn
调用返回ret1,ret2,…, retm
这个功能使用起来还是有点麻烦,因为堆栈偏移量在调用过程中基本上有变化,因此对局部变量的引用将是错误的。

contract C {
    
    
    uint b;
    function f(uint x) public returns (uint r) {
    
    
        assembly {
    
    
            r := mul(x, sload(b_slot)) // ignore the offset, we know it is zero
        }
    }
}

注解
如果访问小于256位的类型的变量(例如uint64, address, bytes16 或 byte),则不能对不属于该类型编码的位进行任何假设。特别是,不要假定它们为零。为了安全起见,在使用该数据之前,请始终正确清除数据:uint32 x = f(); assembly { x := and(x, 0xffffffff) /* now use x */ }。清除有符号的类型,您可以使用signextend操作码。

标签(Labels)

注解
标签被弃用。请使用函数、循环、if或switch语句来代替。
另一个在EVM的汇编的问题是jump和jumpi使用了绝对地址,可以很容易的变化。Solidity内联汇编提供了标签来让jump跳转更加容易。需要注意的是标签是非常底层的特性,尽量使用内联汇编函数,循环,Switch指令来代替。下面是一个求Fibonacci的例子:

{
    
    
    let n := calldataload(4)
    let a := 1
    let b := a
loop:
    jumpi(loopend, eq(n, 0))
    a add swap1
    n := sub(n, 1)
    jump(loop)
loopend:
    mstore(0, a)
    return(0, 0x20)
}

需要注意的是自动访问栈元素需要内联者知道当前的栈高。这在跳转的源和目标之间有不同栈高时将失败。当然你也仍然可以在这种情况下使用jump,但你最好不要在这种情况下访问栈上的变量(即使是内联变量)。

此外,栈高分析器会一个操作码接着一个操作码的分析代码(而不是根据控制流),所以在下面的情况下,汇编程序将对标签two的堆栈高度产生错误的判断:

{
    
    
    let x := 8
    jump(two)
    one:
        // Here the stack height is 2 (because we pushed x and 7),
        // but the assembler thinks it is 1 because it reads
        // from top to bottom.
        // Accessing the stack variable x here will lead to errors.
        x := 9
        jump(three)
    two:
        7 // push something onto the stack
        jump(one)
    three:
}

声明汇编-局部变量(Declaring Assembly-Local Variables)

你可以通过let关键字来定义在内联汇编中有效的变量,实际上它只是在{…}中有效。内部实现上是,在let指令出现时会在栈上创建一个新槽位,来保存定义的临时变量,在块结束时,会自动在栈上移除对应变量。你需要为变量提供一个初始值,比如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
    }
}

赋值(Assignments)

你可以向内联局部变量赋值,或者函数局部变量。需要注意的是当你向一个指向memory或storage赋值时,你只是修改了对应指针而不是对应的数据。

有两种方式的赋值方式:函数风格和指令风格。函数风格,比如variable := value,你必须在函数风格的表达式中提供一个变量,最终将得到一个栈变量。指令风格=: variable,值则直接从栈底取。以于两种方式冒号指向的都是变量名称。赋值的效果是将栈上的变量值替换为新值。

{
    
    
    let v := 0 // functional-style assignment as part of variable declaration
    let g := add(v, 2)
    sload(10)
    =: v // instruction style assignment, puts the result of sload(10) into v
}

注解
指令风格赋值被deprecated。

If

if语句可用于有条件地执行代码。没有“else”部分,如果需要多个备选方案,请考虑使用“switch”(见下文)

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

body的花括号是必须的。

Switch

你可以使用switch语句来作为一个基础版本的if/else语句。它需要取一个值,用它来与多个常量进行对比。每个分支对应的是对应切尔西到的常量。与某些语言容易出错的行为相反,控制流不会自动从一个判断情景到下一个场景(即默认是break的)。最后有个叫default的兜底。

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

可以有的case不需要包裹到大括号中,但每个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)
    }
}

函数

汇编语言允许定义底层的函数。这些需要在栈上取参数(以及一个返回的代码行),也会将结果存到栈上。调用一个函数与执行一个函数风格的操作码看起来是一样的。

函数可以在任何地方定义,可以在定义的块中可见。在函数内,你不能访问一个在函数外定义的一个局部变量。同时也没有明确的return语句。

如果你调用一个函数,并返回了多个值,你可以将他们赋值给一个元组,使用a, b := f(x)或let a, b := f(x)。

下面的例子中通过平方乘来实现一个指数函数。

{
    
    
    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) }
        }
    }
}

要注意的事(Things to Avoid)

内联汇编使用中需要一个比较高的视野,但它又是非常底层的语法。函数调用,循环,switch被转换为简单的重写规则,另外一个语言提供的是重安排函数风格的操作码,管理了jump标签,计算了栈高以方便变量的访问,同时在块结束时,移除块内定义的块内的局部变量。特别需要注意的是最后两个情况。你必须清醒的知道,汇编语言只提供了从开始到结束的栈高计算,它没有根据你的逻辑去计算栈高(译者注:这常常导致错误)。此外,像交换这样的操作,仅仅交换栈里的内容,并不是变量的位置。

Solidity中的惯例

与EVM汇编不同,Solidity知道类型少于256字节,如,uint24。为了让他们更高效,大多数的数学操作仅仅是把也们当成是一个256字节的数字进行计算,高位的字节只在需要的时候才会清理,比如在写入内存前,或者在需要比较时。这意味着如果你在内联汇编中访问这样的变量,你必须要手动清除高位的无效字节。

Solidity以非常简单的方式来管理内存:内部存在一个空间内存的指针在内存位置0x40。如果你想分配内存,可以直接使用从那个位置的内存,并相应的更新指针。

内存的前64个字节可以用作短期分配的“划痕空间”。空闲内存指针(即0x60开始)后的32字节永远为0,并用作空动态内存数组的初始值。

Solidity中的内存数组元素,总是占用多个32字节的内存(也就是说byte[]也是这样,但是bytes和string不是这样)。多维的memory的数组是指向memory的数组。一个动态数组的长度存储在数据的第一个槽位,紧接着就是数组的元素。

警告
静态大小的内存数组没有长度字段,但是它在未来很快会被添加,以便在静态和动态大小的数组之间允许更好的可互换性,所以请不要依赖于此。


总结

日拱一卒。

猜你喜欢

转载自blog.csdn.net/yyjava/article/details/125285002