Huff语言
Huff语言
Huff是一个低级的、为Ethereum智能合约设计的编程语言,它允许开发者编写高度优化的EVM字节码。就像汇编一样,它高效又复杂。
这篇文章的代码部分着色可能很奇怪,因为这个语言冷门到md没有针对它的着色器,所以我都凑合用solidity……
合约的本质
在开始学之前我想到一个事,无论是最开始用的solidity还是后来的纯操作码,它们最终的目的都是编写合约。而合约这个东西真正要部署的时候其实就只需要那一串字节码,代表相应的操作。既然如此,能得到字节码就算能写合约,就像编程的最终目的也是要转化成机器01语言的。
基本结构
首先需要定义合约的接口。像Solidity的接口一样,需要使用#define
关键字:
#define function setValue(uint256) nonpayable returns ()
#define function getValue() view returns (uint256)
接下来需要声明存储槽位,就像在Solidity合约中声明状态变量一样。FREE_STORAGE_POINTER()
自动指向合约中未使用的存储槽:
#define constant VALUE_LOCATION = FREE_STORAGE_POINTER()
之后就可以开始写方法。
第一个要实现的是SET_VALUE()
,它能改变VALUE_LOCATION
存储的值:
#define macro SET_VALUE() = takes (0) returns (0) {
// 从calldata中读取变量的新值
0x04 calldataload // [value]
[VALUE_LOCATION] // [ptr, value],参数已经凑齐
// 把value存到ptr指的位置
sstore // []
stop // []
}
还有一个GET_VALUE()
,用来读取VALUE_LOCATION
存储的值:
#define macro GET_VALUE() = takes (0) returns (0) {
// 从存储中加载值
[VALUE_LOCATION] // [ptr]
sload // [value]
// 将值存入内存
0x00 mstore
// 返回值
0x20 0x00 return
}
最后是主宏MAIN()
,定义了合约的主入口。当对合约进行外部调用时,会运行这段代码来确定应该调用哪个函数:
#define macro MAIN() = takes (0) returns (0) {
// 读取calldata中的函数选择器,通过selector判断要调用哪个函数
0x00 calldataload 0xE0 shr
dup1 __FUNC_SIG(setValue) eq set jumpi // 匹配set就跳到SET_VALUE()
dup1 __FUNC_SIG(getValue) eq get jumpi // 匹配get就跳到GET_VALUE()
// 如果没有匹配的函数,就revert
0x00 0x00 revert
set:
SET_VALUE()
get:
GET_VALUE()
}
很不错,就像当初学c++一样,记了但是完全没懂细节,这部分还得后面慢慢补上。所以后边几段其实基本上都是在详细解释上边这些东西。
存储
之前说到EVM的存储结构是256位的键值对表,存储槽是其中的键。
声明
可以通过FREE_STORAGE_POINTER()
关键字来跟踪合约中未使用的存储槽:
#define constant STORAGE_SLOT0 = FREE_STORAGE_POINTER()
#define constant STORAGE_SLOT1 = FREE_STORAGE_POINTER()
编译器将在编译时从0开始分配自由存储槽。在上面的例子中,会将0
分配给STORAGE_SLOT0
,将1
分配给STORAGE_SLOT1
。
使用
#define macro MAIN() = takes(0) returns(0) {
0x69 // [0x69]
[STORAGE_SLOT0] // [value_slot0_pointer, 0x69]
sstore // []
0x420 // [0x420]
[STORAGE_SLOT1] // [value_slot1_pointer, 0x420]
sstore // []
}
sstore
上边的其实就是它的两个参数,值和存储位置。只是Huff帮你省掉了写push和手动定位存储槽的过程,上边的代码等价于这段操作码:
PUSH1 0x69
PUSH0
SSTORE
PUSH2 0x0420
PUSH1 0x01
SSTORE
常量
常量不会被包含在storage中,而是在编译时在合约内调用(包含在字节码中)。常量可以是最多32字节的数据或是FREE_STORAGE_POINTER()
关键字。
声明
可以使用constant
关键字在合约中声明常量:
#define constant NUM = 0x69
#define constant STORAGE_SLOT0 = FREE_STORAGE_POINTER()
使用
可以使用括号表示法[CONSTANT]
将常量压入堆栈:
#define macro MAIN() = takes(0) returns(0) {
[NUM] // [0x69]
[STORAGE_SLOT0] // [value_slot0_pointer, 0x69]
sstore // []
}
这一段就表示把0x69存到0号槽。注意,非保留字或者具体的数,压栈时要加中括号。
宏
Huff中有两种可以将字节码组合起来的方法,一种叫宏Macros
,另一种叫函数Functions
。两者之间有一些差异,但是大多数时候开发者应该使用宏,而不是函数。这是因为Huff 代码编译后直接转换为 EVM 字节码,而 函数调用(即 JUMP
和 JUMPDEST
)会产生额外的 Gas 开销和管理栈的复杂性。
定义
#define macro MACRO_NAME(arguments) = takes (1) returns (3) {
// ...
}
其中:
MACRO_NAME
: 宏的名称;
arguments
: 宏的参数,可以没有;
takes (1)
: 指定宏/函数接受的堆栈输入数量,可以没有,默认为0
;
returns (3)
: 指定宏/函数输出的堆栈元素数量,可以没有,默认为0
。
使用
在下面的例子中,SAVE()
宏接受一个参数value
,然后将它的值存储在存储槽STORAGE_SLOT0
。在宏中,我们使用<value>
来使用参数的值:
#define constant STORAGE_SLOT0 = FREE_STORAGE_POINTER()
// 这个宏接受一个参数 value,然后将它的值存储在 STORAGE_SLOT0
#define macro SAVE(value) = takes(0) returns(0) {
<value> // [value]
[STORAGE_SLOT0] // [value_slot0_pointer, value]
sstore // []
}
注意,宏指定的参数和堆栈输入量是两回事。参数只是一个替换符号,它值不一定在栈里,比如:
#define macro add_values(a, b) = takes (0) returns (1) {
ADD
}
这么写的话会报错,因为ADD
需要从栈里拿东西,并不会自动用哪个a和b。正确的写法是:
#define macro add_values(a, b) = {
<a>
<b>
ADD
}
或者如果两个加数已经被提前存入堆栈,也可以不传参:
#define macro add_values() = takes (2) returns (1) {
ADD
}
还要注意,这种写法也不那么合适:
#define macro add_values(a, b) = takes (2) returns (1) {
<a>
<b>
ADD
}
takes (2)
意思是调用之前栈里必须有两个值,但是这个宏中既然有压栈的过程,就不再需要栈里有东西。不过takes (2)
也不会自动弹出栈顶元素,写了也没危害,只会让别人看了觉得不专业。
Main宏
Main
宏是一个特殊的宏,作为合约的主入口,每个 Huff 合约必须有一个。它的作用类似于 Solidity 中的fallback
函数,当对合约进行外部调用时,会运行这段代码来确定应该调用哪个函数。
声明
#define macro MAIN() = takes (0) returns (0) {
// 这里是 EVM 指令,比如解析 calldata、执行函数等
}
注意,和c++不一样,这个main
宏是可以当做普通宏来用的,takes
和returns
都可以有值。
使用
可以在MAIN()
里调别人:
#define macro PUSH_69() = takes(0) returns(1) {
push1 0x69 // [0x69]
}
#define macro SAVE() = takes(1) returns(0) {
// [0x69]
[STORAGE_SLOT0] // [value_slot0_pointer, 0x69]
sstore // []
}
#define macro MAIN() = takes(0) returns(0) {
PUSH_69() // []
SAVE()
}
控制流
Huff提供了跳转标签,可以在宏或函数内定义,和c++中的goto
和label
一样,由冒号后跟一个单词表示:
#define macro MAIN() = takes (0) returns (0) {
// 从 calldata 读取值
0x00 calldataload // [calldata @ 0x00]
0x00 eq // 如果为0,则先跳转到jump_one
jump_one jumpi
// 如果到达此点,则revert
0x00 0x00 revert
// 跳转标签1
jump_one:
jump_two jump // 跳转到jump_two
// 如果到达此点,则revert
0x00 0x00 revert
// 跳转标签2
jump_two:
0x00 0x00 return
}
接口
类似Solidity,你可以在Huff合约的接口中定义函数functions
,事件events
,和错误errors
。
接口主要有两个作用:
- 定义接口后,函数名可以用作内置函数
__FUNC_SIG
(获取函数选择器),__EVENT_HASH
(事件选择器),和__ERROR
(错误选择器)的参数 - 生成 Solidity 接口/合约 ABI。
接口中的函数可以是view
、pure
、payable
或nonpayable
类型。并且,只有外部可见的函数需要在接口中定义,内部函数不需要。接口中的事件可以包含索引值(使用indexed
关键字)和非索引值。
在开头定义了两个接口:
#define function setValue(uint256) nonpayable returns ()
#define function getValue() view returns (uint256)
日后这个合约被部署了之后别人就可以调它们,且可以被EVM识别支持的函数来判断abi兼容性。如果没定义的话就算作内部函数,没法调。
使用huffc -g
命令将Huff合约的接口转为Solidity合约接口/ABI:
interface I07_Interface {
function getValue() external view returns (uint256);
function setValue(uint256) external;
}
事件
假设要实现:在调用SET_VALUE()
方法改变值的时候,会释放一个ValueChanged
事件,将新值记录到EVM日志中。
首先在接口中定义合约的事件:
#define event ValueChanged(uint256 indexed)
接下来我们在SET_VALUE()
方法中释放ValueChanged
事件。首先,要确定我们要用哪个LOG
指令来释放事件。因为我们事件只有一个被索引的数据,再加上事件哈希,就是2
个主题,应使用log2
,输入堆栈为[0, 0, sig, value]
。接下来,我们只需要在方法中构造所需的堆栈,再在结尾使用log2
输出日志即可。我们可以使用内置函数__EVENT_HASH()
将事件哈希压入堆栈:
#define macro SET_VALUE() = takes (0) returns (0) {
0x04 calldataload // [value]
dup1 // [value, value]
[VALUE_LOCATION] // [ptr, value, value]
sstore // [value]
// 释放事件
__EVENT_HASH(ValueChanged) // [sig, value]
push0 push0 // [0, 0, sig, value]
log2 // []
stop // []
}
如果此时输出solidity版本的接口,就会发现event
也包含于其中:
interface I08_Event {
event ValueChanged(uint256 indexed);
function getValue() external view returns (uint256);
function setValue(uint256) external;
}
ERROR
Solidity中有三种抛出异常的方法error
,require
和assert
,他们都是基于EVM
的revert
指令。在Huff中,我们可以直接使用revert
指令来抛出错误并返回错误信息。
定义
#define function getError() view returns (uint256)
// 在接口中定义
#define error CustomError(uint256)
使用
可以使用内置函数__ERROR()
将错误选择器(error selector)推到堆栈上,它会一次性推入错误码和错误的选择器。
#define macro GET_ERROR() = takes (0) returns (0) {
__ERROR(PanicError) // [panic_error_selector, panic_code]
0x00 mstore // [panic_code]
0x04 mstore // []
0x24 0x00 revert
}
写好触发错误的逻辑后就可以在MAIN
调用:
#define macro MAIN() = takes (0) returns (0) {
// 通过selector判断要调用哪个函数
0x00 calldataload 0xE0 shr
dup1 __FUNC_SIG(GET_ERROR) eq get_error jumpi
// 如果没有匹配的函数,就revert
0x00 0x00 revert
get_error:
GET_ERROR()
}
Constructor
Huff中的CONSTRUCTOR
宏和Solidity的构造函数类似,它不是必须的,但是可以在部署时用来初始化合约状态变量。下面的例子中,我们使用CONSTRUCTOR
宏在合约部署时将存储槽VALUE_LOCATION
的值初始化为0x69
。
#define constant VALUE_LOCATION = FREE_STORAGE_POINTER()
// Constructor
#define macro CONSTRUCTOR() = takes (0) returns (0) {
0x69
[VALUE_LOCATION]
sstore // []
}
CONSTRUCTOR
宏和其他宏的主要区别在于 它在合约部署时会自动执行,而且不会上链,而其他宏需要手动调用。Huff只允许一份合约有一个CONSTRUCTOR
。