Solidity 定义了一种汇编语言,在没有 Solidity 的情况下也可以使用。这种汇编语言也可以嵌入到 Solidity 源代码中当作“内联汇编”使用。我们从如何使用内联汇编开始,介绍它如何区别于独立汇编语言,然后讲述这种汇编语言。
为了实现更细粒度的控制,尤其是为了通过编写库来增强语言,可以利用接近虚拟机的语言将内联汇编与Solidity语句结合在一起使用。
从一个例子直观的感受一下内联汇编。
pragma solidity ^0.5.0;
contract Assembly {
function example(uint num) public pure returns(uint256){
assembly{
num:=add(num,1)
}
return num;
}
}
这个例子用内联汇编实现了将传入的数加一的操作。
内联汇编程序由assembly{...}
来标记,表示大括号里面是内联汇编的代码。
操作码就是EVM指令序列,用来告诉EVM需要执行哪一条指令的意思。通俗的讲就是计算机程序中所规定的要执行操作的那一部分指令。
指令系统的每一条指令都有一个操作码,表示该指令应进行什么性质的操作。不同的指令用操作码这个字段的不同编码来表示,每一种编码代表一种指令。由于EVM的操作码被限制在一个字以内,所以EVM最多容纳256条指令,目前EVM已经定义了约142条指令,还有100多条用于以后的扩展。这142条指令包括了算法运算,密码学计算,栈操作,内存操作等。EVM的指令序列一直在不断的优化,有些指令会被逐渐废弃,也会引入一些新的指令。下面列出了一些比较稳定的指令,方便后面对Solidity编译后的汇编代码进行分析。
指令 | 解释 |
---|---|
stop | 停止执行,与 return(0,0) 等价 |
add(x, y) | x + y |
sub(x, y) | x - y |
mul(x, y) | x * y |
div(x, y) | x / y |
sdiv(x, y) | x / y,以二进制补码作为符号 |
mod(x, y) | x % y |
smod(x, y) | x % y,以二进制补码作为符号 |
exp(x, y) | x 的 y 次幂 |
not(x) | ~x,对 x 按位取反 |
lt(x, y) | 如果 x < y 为 1,否则为 0 |
gt(x, y) | 如果 x > y 为 1,否则为 0 |
slt(x, y) | 如果 x < y 为 1,否则为 0,以二进制补码作为符号 |
sgt(x, y) | 如果 x > y 为 1,否则为 0,以二进制补码作为符号 |
eq(x, y) | 如果 x == y 为 1,否则为 0 |
iszero(x) | 如果 x == 0 为 1,否则为 0 |
and(x, y) | x 和 y 的按位与 |
or(x, y) | x 和 y 的按位或 |
xor(x, y) | x 和 y 的按位异或 |
byte(n, x) | x 的第 n 个字节,这个索引是从 0 开始的 |
addmod(x, y, m) | 任意精度的 (x + y) % m |
mulmod(x, y, m) | 任意精度的 (x * y) % m |
signextend(i, x) | 对 x 的最低位到第 (i * 8 + 7) 进行符号扩展 |
keccak256(p, n) | keccak(mem[p…(p + n))) |
jump(label) | 跳转到标签 / 代码位置 |
jumpi(label, cond) | 如果条件为非零,跳转到标签 |
pc | 当前代码位置 |
pop(x) | 弹出栈顶的 x 个元素 |
dup1 … dup16 | 将栈内第 i 个元素(从栈顶算起)复制到栈顶 |
swap1 … swap16 | 将栈顶元素和其下第 i 个元素互换 |
mload(p) | mem[p…(p + 32)) |
mstore(p, v) | mem[p…(p + 32)) := v |
mstore8(p, v) | mem[p] := v & 0xff (仅修改一个字节) |
sload(p) | storage[p] |
sstore(p, v) | mstorage[p] := v |
msize | 内存大小,即最大可访问内存索引 |
gas | 执行可用的 gas |
address | 当前合约 / 执行上下文的地址 |
balance(a) | 地址 a 的余额,以 wei 为单位 |
caller | 调用发起者(不包括 delegatecall) |
callvalue | 随调用发送的 Wei 的数量 |
calldataload(p) | 位置 p 的调用数据(32 字节) |
calldatasize | 调用数据的字节数大小 |
calldatacopy(t, f, s) | 从调用数据的位置 f 的拷贝 s 个字节到内存的位置 t |
codesize | 当前合约 / 执行上下文地址的代码大小 |
codecopy(t, f, s) | 从代码的位置 f 开始拷贝 s 个字节到内存的位置 t |
extcodesize(a) | 地址 a 的代码大小 |
extcodecopy(a, t, f, s) | 和 codecopy(t, f, s) 类似,但从地址 a 获取代码小 |
create(v, p, s) | 用 mem[p…(p + s)) 中的代码创建一个新合约、发送 v wei 并返回 新地址 |
call(g, a, v, in, insize, out, outsize) | 使用 mem[in…(in + insize)) 作为输入数据, 提供 g gas 和 v wei 对地址 a 发起消息调用, 输出结果数据保存在 mem[out…(out + outsize)), 发生错误(比如 gas 不足)时返回 0,正确结束返回 1 |
callcode(g, a, v, in, insize, out, outsize) | 与 call 等价,但仅使用地址 a 中的代码 且保持当前合约的执行上下文 |
delegatecall(g, a, in, insize, out, outsize) | 与 callcode 等价且保留 caller 和 callvalue |
staticcall(g, a, in, insize, out, outsize) | 与 call(g, a, 0, in, insize, out, outsize) 等价 但不允许状态修改 |
return(p, s) | 终止运行,返回 mem[p…(p + s)) 的数据 |
revert(p, s) | 终止运行,撤销状态变化,返回 mem[p…(p + s)) 的数据 |
selfdestruct(a) | 终止运行,销毁当前合约并且把资金发送到地址 a |
invalid | 以无效指令终止运行 |
log0(p, s) | 以 mem[p…(p + s)) 的数据产生不带 topic 的日志 |
log1(p, s, t1) | 以 mem[p…(p + s)) 的数据和 topic t1 产生日志 |
log2(p, s, t1, t2) | 以 mem[p…(p + s)) 的数据和 topic t1、t2 产生日志 |
log3(p, s, t1, t2, t3) | 以 mem[p…(p + s)) 的数据和 topic t1、t2、t3 产生日志 |
log4(p, s, t1, t2, t3, t4) | 以 mem[p…(p + s)) 的数据和 topic t1、t2、t3 和 t4 产生日志 |
origin | 交易发起者地址 |
gasprice | 交易所指定的 gas 价格 |
blockhash(b) | 区块号 b 的哈希 - 目前仅适用于不包括当前区块的最后 256 个区块 |
coinbase | 当前的挖矿收益者地址 |
timestamp | 从当前 epoch 开始的当前区块时间戳(以秒为单位) |
number | 当前区块号 |
difficulty | 当前区块难度 |
gaslimit | 当前区块的 gas 上限 |
可以像使用字节码那样在操作码之后键入操作码。例如把3与内存位置0x80处的数据相加后再存储在0x80处,的操作码是3 0x80 mload add 0x80 mstore
,由于通常很难看到某些操作码的实际参数是什么,所以 Solidity内联汇编还提供了一种“函数风格”表示法,同样功能的代码可以写做mstore(0x80, add(mload(0x80), 3))
函数风格表达式内不能使用指令风格的写法,即1 2 mstore(0x80, add)
是无效汇编语句, 它必须写成mstore(0x80, add(2, 1))
这种形式。对于不带参数的操作码,括号可以省略。
注意,在函数风格写法中参数的顺序与指令风格相反。如果使用函数风格写法,第一个参数将会位于栈顶。
Solidity的变量和其他标识符可以通过使用名称来简单访问。但是在Storage存储的变量有些不同,Storage的值可能不会占据一个完整的storage片,所以它们的地址是由一个片地址和位偏移组成。为了得到变量x的片地址,使用x_slot,获取偏移使用x_offset。
pragma solidity ^0.5.0;
contract C {
uint b = 10;
function f(uint x) public view returns (uint r) {
assembly {
r := mul(x, sload(b_slot)) // 因为偏移量为 0,所以可以忽略
}
}
}
在上面的代码中,sload并不是直接按照�状态变量b的名字加载,而是通过b_slot获得b所在的位置加偏移量取得的值。因为偏移为0,可以省略所以并没有加偏移值。如果不加省略应该改为如下所示;
r := mul(x, sload(add(b_slot, b_offset)))
可以使用let关键字来声明只在内联汇编中可见的变量,实际上只在当前的 {...} 块中可见。下面发生的事情应该是:let 指令将创建一个为变量保留的新数据槽,并在到达块末尾时自动删除。需要为变量提供一个初始值,它可以只是 0,但它也可以是一个复杂的函数风格表达式。
pragma solidity ^0.5.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 会在这里被“清除”
b := add(b, v)
} // v 会在这里被“清除”
}
}
可以给汇编局部变量和函数局部变量赋值。请注意:当给指向内存或存储的变量赋值时,只是更改指针而不是数据。
有两种赋值方式:函数风格和指令风格。对于函数风格赋值(变量 := 值),需要在函数风格表达式中提供一个值,它恰好可以产生一个栈里的值; 对于指令风格赋值(=: 变量),则仅从栈顶部获取数据,指令风格的赋值方式官方已经不推荐使用。对于这两种方式,冒号均指向变量名称。赋值则是通过用新值替换栈中的变量值来实现的。
{
let v := 0 // 作为变量声明的函数风格赋值
let g := add(v, 2)
sload(10)
=: v // 指令风格的赋值,将 sload(10) 的结果赋给 v
}
if 语句可以用于有条件地执行代码,且没有“else”部分;如果需要多种选择,你可以考虑使用“switch”(见下文)。
{
if eq(value, 0) { revert(0, 0) }
}
代码主体的花括号是必需的。
作为“if/else”的非常初级的版本,可以使用switch语句。它计算表达式的值并与几个常量进行比较。选出与匹配常数对应的分支。 与某些编程语言容易出错的情况不同,控制流不会从一种情形继续执行到下一种情形。也可以设定一个default的默认情况。
{
let x := 0
switch calldataload(4)
case 0 {
x := calldataload(0x24)
}
default {
x := calldataload(0x44)
}
sstore(0, div(x, 2))
}
Case 列表里面不需要大括号,但 case 主体需要。
汇编语言支持一个简单的 for-style 循环。For-style 循环有一个头,它包含初始化部分、条件和迭代后处理部分。 条件必须是函数风格表达式,而另外两个部分都是语句块。如果起始部分声明了某个变量,这些变量的作用域将扩展到循环体中(包括条件和迭代后处理部分)。
下面例子是计算某个内存区域中的数值总和。
{
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)
的方式把它们赋值到一个元组。
下面例子是一个用汇编实现两数相加的函数。
pragma solidity ^0.5.0;
contract C {
function f() public pure returns (uint256) {
assembly {
function addNum(a, b) -> res {
res := mload(0x40) // 通过0x40加载空闲内存地址
mstore(res, add(a, b)) // 将a+b的结果存入空闲内存
return(res, 0x20) // 返回res...res+32内存地址的值, 0x20十进制为32
}
let r := addNum(5, 3) // 函数调用
return(r, 0x20) // 函数返回
}
}
}
在下面的例子中并不会执行到return a - b;
而是会在内联汇编中直接返回,需要注意的是return在内联汇编中是一个操作码,和其他操作码并无不同,在内联汇编的函数中也不不做强制要求。
pragma solidity ^0.5.0;
contract C {
constructor() public {}
function add(uint256 a, uint256 b) public pure returns (uint256) {
assembly {
let res:= mload(0x40) // 通过0x40加载空闲内存地址
mstore(res, add(a,b)) // 将a+b的结果存入空闲内存
return(res, 0x20) // 返回res...res+32内存地址的值, 0x20十进制为32
}
return a - b;
}
}
内联汇编语言看起来像高级语言,但实际上它是非常低级的编程语言。函数调用、循环、if 语句和 switch 语句通过简单的重写规则进行转换, 然后,汇编程序为做的唯一事情就是重新组织函数风格操作码、管理jump标签、计算访问变量的栈高度,还有在到达语句块末尾时删除局部汇编变量的栈数据。特别是对于最后两种情况,汇编程序仅会按照代码的顺序计算栈的高度,而不一定遵循控制流程;了解这一点非常重要。此外swap等操作只会交换栈内的数据,而不是变量位置。
与 EVM 汇编语言相比,Solidity 能够识别小于 256 位的类型,例如 uint24。为了提高效率,大多数算术运算只将它们视为 256 位数字, 仅在必要时才清除未使用的数据位,即在将它们写入内存或执行比较之前才会这么做。这意味着,如果从内联汇编中访问这样的变量,必须先手工清除那些未使用的数据位。
Solidity 以一种非常简单的方式管理内存:在 0x40 的位置有一个“空闲内存指针”。如果打算分配内存,只需从此处开始使用内存,然后相应地更新指针即可。
内存的开头 64 字节可以用来作为临时分配的“暂存空间”。“空闲内存指针”之后的 32 字节位置(即从 0x60 开始的位置)将永远为 0,可以用来初始化空的动态内存数组。
在 Solidity 中,内存数组的元素总是占用 32 个字节的倍数(是的,甚至对于 byte[] 都是这样,只有 bytes 和 string 不是这样)。 多维内存数组就是指向内存数组的指针。动态数组的长度存储在数组的第一个槽中,其后才是数组元素。
内联汇编描述的汇编语言也可以单独使用,实际上,计划是将其用作Solidity编译器的中间语言。在这种意义下,它试图实现以下几个目标:
1、即使代码是由 Solidity 的编译器生成的,用它编写的程序应该也是可读的。
2、从汇编到字节码的翻译应该尽可能少地包含“意外”。
3、控制流应该易于检测,以帮助进行形式化验证和优化。
为了实现第一个和最后一个目标,汇编提供了高级结构:如 for 循环、if 语句、switch 语句和函数调用。 应该可以编写不使用明确的 SWAP、DUP、JUMP 和 JUMPI 语句的汇编程序,因为前两个混淆了数据流,而最后两个混淆了控制流。 此外,形式为 mul(add(x, y), 7) 的函数风格语句优于如 7 y x add mul 的指令风格语句,因为在第一种形式中更容易查看哪个操作数用于哪个操作码。
第二个目标是通过采用一种非常规则的方式来将高级高级指令结构便以为字节码。 汇编程序执行的唯一非局部操作是用户自定义标识符(函数、变量、…)的名称查找,它遵循非常简单和固定的作用域规则并从栈中清除局部变量。