11.1 汇编概述
尽管智能合约的大部分业务需求我们都能通过solidity提供的功能和模块完成,但一些特殊的功能需求用solidity本身却不好实现甚至是根本实现不了。一个最简单的例子就是是判断指定地址是普通账户还是合约账户该功能直接用solidity是不能实现的,但如果用底层的汇编语言却很容易办到。相对于用上层的solidity语言直接编写智能合约,用汇编语言最大的问题还是可读性大大降低的问题,同时能绕开编译器提供的一些安全检测机制增加漏洞风险。
另外一个常见的场景是一些库函数的底层实现。通常我们通过库合约来丰富和增强solidity语言的功能和特性,但底层的执行虚拟机是基于栈结构实现的,在solidity中进行栈相关的位置定位时不是那么方便。此时如果用汇编进行相关操作就比较方便而且能实现一些精细的控制。还有一种情况是针对特别熟悉solidity语言和底层汇编语言特性的程序员来说,有时编译器自动生成的代码不够优化导致执行效率低下,因此在关键地方通过汇编改造进行手工优化和调整。
solidity中引入内联汇编的本意是除了对处理细节得到更细化的控制外进而产生更有效的执行代码外,更多的建议使用场景是通过在库函数的实现中使用内联汇编来增强或扩充solidity语言的功能。特别是一些用直接用solidity语言本身不方便实现或根本没法实现的功能需求。
总之使用汇编会大大降低智能合约的可读性并增加安全漏洞风险,但如果合理运用也能产生良好的效果,因此solidity语言中也提供了对汇编编程的直接支持。目前版本的solidity中对汇编编程的支持大致分三种:内联汇编、独立汇编以及与底层执行虚拟机无关的中间独立汇编(官方叫法为JULIA)。目前比较常用的第一种与上层solidity直接进行混用的内联汇编,并且remix中也提供了直接支持;其他两种汇编使用模式在进行实际的智能合约编写时使用场景比较少见,但在底层的编译器优化、漏洞扫描以及合约形式化验证等场景应用比较多。由于本书主题是用solidity进行智能合约编写因此重点介绍汇编的第一种使用方式,如果读者对其他两种场景下的汇编使用感兴趣请自行查询相关资料或专著。
11.2 语法特性
solidity中以关键字assembly开头,并以花括号包围的代码块被视为嵌入汇编语句块。虽然一个函数体实现中可以包含多个嵌入汇编语句块,但建议实际编程时函数体实现最多一个汇编语句块并只用在直接用solidity不方便实现的关键功能点。嵌入汇编实现体中不仅可以访问外部的变量定义也可以定义汇编内部使用的局部变量;同时也支持函数调用、变量赋值以及常量定义等;汇编中也可以使用分支、循环等控制结构,其最大的威力来自于其可以调用面向底层虚拟机的特定操作码来完成特殊功能,其正式文法定义如下:
InlineAssemblyStatement = 'assembly' StringLiteral? InlineAssemblyBlock
InlineAssemblyBlock = '{' AssemblyItem* '}'
AssemblyItem = Identifier | FunctionalAssemblyExpression |
InlineAssemblyBlock | AssemblyLocalBinding |
AssemblyAssignment | AssemblyLabel |
NumberLiteral | StringLiteral | HexLiteral
AssemblyLabel = Identifier ':
AssemblyLocalBinding = 'let' Identifier ':=' FunctionalAssemblyExpression
AssemblyAssignment = ( Identifier ':=' FunctionalAssemblyExpression ) |
( '=:' Identifier )
FunctionalAssemblyExpression =
Identifier '(' AssemblyItem? ( ',' AssemblyItem )* ‘)'
从嵌入汇编的正式文法定义可知允许空函数体实现的嵌入汇编体,同时其内部使用的标识符定义、整数、字符串以及16进制字面量定义方式和上层的solidity一致。不过需要额外注意的是嵌入汇编中变量赋值方式是通过:=来完成的,而变量定义是通过关键字let来完成的,这点和上层的solidity语言是完全不同的地方。嵌入汇编体实现中也支持各种类型的注释,包括//引导的单行注释、//包围的多行注释以及/*/包围的标准文档注释,这点和上层的solidity一致。
solidity中嵌入汇编的主要组成部分为各种常量或变量定义、访问外部变量或函数、通过标签进行无条件跳转、分支和循环控制以及核心的调用底层操作码完成特定的功能需求。相对于手工书写原始的汇编语言,内联汇编中提供如下额外的特性来简化用汇编进行智能合约的编写:
- 标识符定义(标签、内部变量、外部变量等)
- 整数、字符串以及16进制字面量定义
- 函数风格操作码及其赋值如x := add(y, 3)
- 汇编块内部的变量定义 let abc
- 支持常见的逻辑运算lg、gt等
- 支持常见的位运算and、or等
一个常见的使用场景是目标地址是普通账户还是合约账户,在当前版本的solidity实现中就只能通过嵌入汇编实现。其主要实现思路就是通过汇编指令得到目标地址的代码长度,如果为0则是普通账户否则为合约账户,示意代码如下:
contract AddrTest {
function isHuman(address target) public view returns(bool) {
uint codeLength;
assembly {codeLength := extcodesize(target)}
return (codeLength == 0);
}
}
再次提醒内联汇编中的赋值语句与上层solidity中是不同的,并且内联汇编语句不需要用分号结尾。具体实现时先在汇编外部定义存储目标账户可执行代码大小的外部变量,然后在嵌入汇编中通过底层操作码得到指定地址的可执行代码大小并赋值给外部变量并做进一步处理。这样的功能需求如果要在solidity中实现是非常复杂或者说根本不可能的。
11.3 调用方式
当前版本的solidity嵌入汇编不再支持老版本中的逆波兰形式,而只支持现代的函数调用风格。所谓逆波兰表达式也被成为操作符后缀表达式,也就是操作数在前面而具体的操作符在后面,在虚拟机基于堆栈架构时逆波兰表达式会简化实现,各种编程语言在将语法树转为实际的操作码流时大都会进行逆波兰表达式的转换。但对上层用户来说直接以逆波兰风格进行编码就不那么直观了。如下两种写法的汇编语句都是有效的而且实现功能一样,不过前者已经不再支持,示意代码如下:
contract CallType {
function test() public pure returns(uint256) {
assembly {
// 1 0x80 mload add 0x80 mstore
mstore(0x80, add(mload(0x80), 1))
// mstore(0x80, add(0x80 mload , 1)) // compile err
}
return 1;
}
}
从实现代码中可以看出用函数风格的汇编实现更直观简洁,逆波兰表达式方式不直观而且也容易出错。
11.4 字面量
嵌入汇编支持各种字面量如整数、字符串以及16进制字面量,其定义方式也和上层的solidity要求一致。当代码中使用整数字面量时内部会自动生成对应的pushi指令完成相关的入栈操作。示意代码如下:
contract ConstType {
function test() public pure returns(uint256) {
uint256 ret;
assembly {
ret := add(add(11, 22), 0x1234)
}
return ret;
}
}
字符串字面量在逻辑运算中可以直接使用,定长字节数组也可以直接使用和赋值但动态数组string不能直接进行赋值,示意代码如下:
contract StringType {
function test() public pure returns(bool, bytes32) {
bool ret;
bytes32 abc;
string memory aaa;
assembly {
ret := and("12345", "111")
// ret := "012345678901234567890123456789012" and "111"// err
abc := "12354"
// aaa := "1112345" // compile err
}
return (ret, abc);
}
}
另外需要注意的是内联汇编中的字符常量长度不能超过32字节长度,并且不能直接赋值给动态数组string,否则都会引起编译错误。
网友评论