logo NodeSeekbeta

从meme币开始认识智能合约(智能合约运行机制)

本帖仅做科普向,不推荐任何人参与挖矿/区块链交易等行为。

之前我们介绍了BTC,包括它的基本功能、记账出块机制,以及对应的UTXO交易模型。接下来我想带大家看看币圈另一个非常重要的阶段:以ETH为代表的、能够运行智能合约的公链时代。(当然,这个时代并没有落幕。)

所以这篇想聊的事情其实很简单:区块链是怎么从一套负责记账的系统,慢慢变成一套可以运行程序的系统的?

我们都知道,BTC区块里面记录的是一笔笔交易。那现在我们抽象一点:既然区块链能记录交易数据,那么我能不能把一段程序代码也当成数据,直接扔上去?

答案是可以,但这里有个问题:把代码存进区块链,和让区块链真正运行这段代码,完全是两码事。

举个例子,我往BTC某笔交易的数据里塞一段:

if (A给B转了1 BTC) {
    C给A转2 BTC;
}

这玩意当然可以作为某种数据被记录下来,但BTC节点并不会因为看见了一个if,就突然开始替你执行里面的逻辑。

严格来说,BTC也不是完全没有编程能力。上一篇介绍UTXO时我们就聊过锁定脚本和解锁数据,BTC的Script本身就是一套脚本系统,只不过它的设计比较保守,主要服务于交易输出的花费条件验证,并不是拿来运行任意复杂程序的。

那能不能换个思路,设计一条新的区块链,让它不仅能记录数据,还能让节点按照统一规则执行程序?

比如我写一段逻辑:

初始状态:
A = 100
B = 0

执行:
A = A - 20
B = B + 20

最终状态:
A = 80
B = 20

重点不在于这个加减法有多高级,而在于:即使没有一台所有人都信任的中央服务器,不同节点也能独立算出相同的结果。

这就是以ETH为代表的智能合约公链所追求的能力之一。

一、智能合约到底是什么东西?

首先大家需要明确一点:智能合约并不是什么具有人工智能的合同。 它既不一定“智能”,也不一定是我们现实生活中理解的那种合同。

你完全可以把它理解成:一段部署在区块链上,可以被调用,并按照事先规定的规则执行的程序。

比如我写一个非常简单的计数器:

contract Counter {
    uint public count = 0;

    function add() public {
        count += 1;
    }
}

整个程序就干一件事:

有人调用add() -> count加1

假设把这个合约部署到ETH上,它就有了自己的合约地址,count也成为了链上状态的一部分。

最开始:

count = 0

A发起一笔交易,调用Counter.add(),交易成功执行后:

count = 1

B又发起一笔交易,调用同一个函数:

count = 2

看起来是不是很像平时写的后端程序?但这里有一个很大的区别。

传统Web服务通常是:

用户 -> HTTP请求 -> 服务器执行代码 -> 修改数据库

而ETH里面,并没有一台所谓的“以太坊官方服务器”,负责替所有人执行智能合约并宣布最终结果。

它采用了一种看起来有点离谱的方式:让验证区块的执行节点按照相同规则重新执行相关交易,自己检查结果对不对。

啊?同一段代码,很多节点都要跑一遍?这不是纯纯浪费算力吗?

某种意义上,确实是重复计算。但别忘了我们前面讲过去中心化系统最重要的一点:你不需要相信某个节点说自己算对了,因为你可以自己验证。

那么问题来了:节点有的用Linux,有的用Windows,CPU也不一样,客户端甚至可能由不同语言编写。怎么保证大家跑同一段程序,最后算出来的结果一致?

二、从Solidity到EVM:代码到底是怎么跑起来的?

这里得先聊聊我们平时写的程序。

比如用C语言写一个加法:

int add(int a, int b) {
    return a + b;
}

我们人类一眼就知道这是两个数相加,但CPU并不能直接理解int、return这些词。一般需要编译器把高级语言转换成目标架构能够执行的机器码。

大概就是:

C源代码 -> 编译器 -> 机器码 -> CPU执行

汇编语言则可以看作机器指令的人类可读表示。例如在一种常见的x86-64调用约定下,上面那段代码可能对应:

mov eax, edi
add eax, esi
ret

当然,具体生成什么指令还得看编译器、优化参数和目标架构。这里大家只需要记住:我们写的是方便人看的高级语言,机器真正执行的是底层指令。

回到ETH,Solidity也是高级语言,但它通常不会被编译成直接交给Intel或者AMD CPU执行的机器码,而是通过solc等编译工具生成面向EVM的字节码(Bytecode)。

Solidity源代码
      ↓
编译器(例如solc)
      ↓
EVM字节码
      ↓
部署交易
      ↓
链上保存合约运行时代码
      ↓
后续交易调用合约,EVM执行字节码

这里的EVM,全称Ethereum Virtual Machine(以太坊虚拟机)。你可以把它想象成一台由协议规定了指令集和执行规则的“虚拟计算机”,而不是VMware那种装一整套操作系统的虚拟机。

EVM有自己的一套指令,例如:

PUSH1   把一个1字节的立即数压入栈
ADD     从栈中取两个数并相加
MUL     从栈中取两个数并相乘
SLOAD   读取合约持久化存储
SSTORE  修改合约持久化存储

EVM是一种基于栈(Stack)的虚拟机。我们直接手写一个最简单的例子,计算1 + 2:

PUSH1 0x01
PUSH1 0x02
ADD

第一条把1放进栈里,第二条再放入2,最后ADD取出两个数相加,再把3压回去。

这三条指令对应的字节码就是:

0x6001600201

拆开来看:

60 01  -> PUSH1 0x01
60 02  -> PUSH1 0x02
01     -> ADD

是不是突然就没那么神秘了?所谓EVM执行合约,本质上就是读取字节码、识别Opcode(操作码),再按照协议规定去操作栈、内存、存储等内容。

当然,真正的Solidity合约比这复杂得多。比如前面的Counter.add(),编译器还得处理函数调用分发、存储读写、溢出检查等逻辑。但最终仍然会落到EVM可以执行的操作上。

所以,EVM不是Solidity解释器,也不是一种编程语言,而是一套统一的执行规范。

只要节点遵守相同的协议规则,拥有相同的前置状态和交易、区块等执行上下文,那么不管客户端是Go写的还是Rust写的,都应该得到相同的执行结果。

不过这里又出现了一个新问题:既然EVM是不断执行指令,那如果有人故意让它无限循环呢?

执行A -> 执行B -> 跳回A -> 执行B -> ……

三、Gas:运行自己的代码,为什么还得交钱?

Gas可以简单理解成EVM执行操作时使用的一套资源计量单位。 协议会为不同操作规定Gas成本,EVM执行时不断计量消耗。

比如刚才那三条指令:

PUSH1 0x01
PUSH1 0x02
ADD

它们各自消耗3 Gas,所以这三条指令本身一共消耗:

3 + 3 + 3 = 9 Gas

注意,9 Gas只是这三条指令的执行成本,不是一笔完整交易的手续费。 实际交易还有基础成本、交易数据以及其他操作开销。

而且Gas也不是简单地“每条指令永远一个固定价格”。例如:

操作 典型Gas消耗
ADD(加法) 3 Gas
MUL(乘法) 5 Gas
SLOAD(读取存储) 常见100或2100 Gas,取决于访问状态
SSTORE(修改存储) 根据存储值和访问状态等因素动态变化

简单加减法相对便宜,持久化存储操作通常更贵。这并不代表Gas能够精确测量CPU实际用了多少纳秒,它是协议人为规定的一套资源定价规则。

那怎么用Gas防止死循环呢?

假设你发起一笔交易,设置:

Gas Limit = 100000

意思就是:这笔交易最多允许消耗100000 Gas。

执行过程中不断扣减Gas,如果代码在额度内执行完毕,那就继续按照协议完成交易;如果还没执行完就耗尽了Gas,那么EVM会抛出Out of Gas,本次执行的状态修改会被回滚。

但是这里有个非常重要的细节:执行失败,不代表手续费就不用交。

你想想,别人已经陪你跑了一大堆指令,最后发现你写了个死循环,难道还得免费陪你玩?

所以即使发生Out of Gas,消耗掉的Gas仍然需要付费。这样恶意用户就没办法通过无限执行程序,毫无成本地占用整个网络的资源。

另外,Gas本身并不是ETH,它只是资源单位。真正付手续费时,还是要用ETH。

比如某笔交易:

Gas Used = 50000
Effective Gas Price = 20 Gwei

那么手续费就是:

50000 × 20 Gwei = 0.001 ETH

其中:

1 ETH = 10^9 Gwei

以太坊的常规执行Gas费用中,还有两个经常被提到的概念:

  • Base Fee(基础费用):由协议根据区块Gas使用情况调整,支付的这部分会被销毁。
  • Priority Fee(优先费用):可以理解成给区块提议者的小费,帮助提高交易被纳入区块的吸引力。

实际使用时还有费用上限等参数,交易最终按实际消耗和有效Gas单价结算;没有用掉的Gas额度,不会因为你设置了较高的Gas Limit就全部收费。

所以总结一下:

Gas Used = 实际计量的资源消耗

Effective Gas Price = 每单位Gas实际支付的ETH价格

手续费 = Gas Used × Effective Gas Price

到这里,EVM和Gas之间的关系就清楚了:EVM负责按照规则执行指令,Gas负责限制并计量这次执行消耗的资源。

但是还有最后一个关键问题:谁来执行这些指令,又是谁来检查它有没有算错?

四、谁在执行智能合约?

假设现在Alice调用前面那个Counter.add()。

首先,Alice的钱包构造并签名一笔交易,然后把它广播到ETH网络。节点收到后,会先检查交易格式、签名、Nonce、费用参数等基本条件;符合要求的交易可以进入节点本地的待处理交易池,也就是常说的Mempool。

注意,这时候交易还没有正式写进区块链。

接下来,在ETH当前的PoS机制下,每个时隙会有被选出的区块提议者(Block Proposer)。为了方便理解,我们先把它叫做“记账出块节点”。

其实可以近似理解为POW中的那个第一个完成当前区块的记账权的节点

实际网络中,区块提议者可能自己构建执行载荷,也可能通过构建者等机制获取执行载荷。这里先不展开这些细节,统一把它理解成:有人负责提出一份包含交易和执行结果的候选区块。

假设候选区块里面有这么几笔交易:

Block N
├── TX1: Alice转账给Bob
├── TX2: 调用Counter.add()
├── TX3: 调用Token.transfer()
└── TX4: 调用Counter.add()

这里有个非常重要的细节:交易顺序会影响执行结果。

假设执行前:

Counter.count = 0

执行TX2以后:

Counter.count = 1

再执行TX4:

Counter.count = 2

因此执行节点必须按照区块中确定的交易顺序执行,不能想先跑哪个就先跑哪个。

执行客户端会根据前一个区块对应的状态,依次执行这些交易,计算新的状态、Gas使用量、交易收据等结果。

其中有一个非常重要的东西叫:

State Root(状态根)。

你可以先把它理解成:通过特定密码学数据结构,对当前链上状态作出的一个摘要式承诺。

注意,它不是把所有账户余额直接拼成一个字符串再Hash一下,而是基于协议规定的状态树结构计算出来的。

如果有人擅自改了合约状态,例如把:

Counter.count = 2

改成:

Counter.count = 114514

那么对应的状态承诺通常也会不同。

候选区块中会带上执行结果对应的State Root。但问题是:

出块节点自己说结果是2,其他人凭什么相信它?

毕竟我们前面说过,去中心化系统里面允许存在恶意节点。要是出块节点说什么就是什么,那折腾半天不又变成了一个中心化数据库?

五、验证节点:你说算对了,我就信?

假设某个区块提议者广播了候选区块:

Block N
├── TX1
├── TX2
├── TX3
├── TX4
└── State Root = 0xABC...

其他完整验证该区块的执行节点,并不会因为它是这一轮的区块提议者,就直接相信它写的结果。

相反,它们会从前一个区块对应的有效状态出发,按照区块中的顺序重新执行TX1、TX2、TX3、TX4,再独立计算状态根和其他需要验证的执行结果。

假设:

提议者声明:0xABC...

验证节点计算:0xABC...

OK,至少状态根这一项对上了。

但如果:

提议者声明:0xABC...

验证节点计算:0xDEF...

那就有问题了。

验证节点会认为这个区块不符合执行规则,拒绝将其作为有效区块接受。

当然,真实验证过程远不止比对State Root,还需要检查区块结构、交易执行、Gas、收据等一系列协议条件。

这里还需要区分两个容易混淆的概念:执行验证和共识投票,不是一回事。

当前ETH的架构里,主要涉及:

  • 执行层:负责交易执行、EVM、合约状态及执行结果验证。
  • 共识层:负责PoS验证者、区块提议、Attestation(证明/投票)、分叉选择和最终确定等工作。

完整参与PoS验证的节点,需要执行客户端和共识客户端等组件配合。而普通全节点可以独立验证区块,并不意味着它也拥有PoS投票权。

所以千万别理解成:

全网超过一半的节点投票说1 + 2 = 4,那么ETH就会认为1 + 2 = 4。

并不是。

合约执行结果对不对,是由确定性的协议规则决定的;共识机制则负责在不同候选区块和可能出现的分叉之间,决定网络接受哪段链历史。

这也是为什么一个恶意区块提议者,不能仅仅通过在区块里填入错误的State Root,就让诚实的完整验证节点承认错误结果。

整个流程简化一下就是:

用户签名并广播交易
        ↓
交易进入节点待处理交易池
        ↓
区块提议者提出候选区块
        ↓
执行载荷中的交易按顺序执行
        ↓
产生状态根、Gas等执行结果
        ↓
其他节点独立执行并验证
        ↓
共识层验证者对有效区块进行证明/投票
        ↓
区块被接受,并可能逐步达到最终确定

这时候再回头看开头那个问题:

同一段程序让这么多节点重复运行,不是很浪费算力吗?

答案依然是:确实存在重复执行的成本,但它换来的是不必相信某一台服务器给出的计算结果。

ETH并不是把一段程序拆成1000份,让1000台机器各算一部分来提高性能。它更像是让许多节点按照相同规则复现状态转换,用独立验证来约束区块提议者。

当然,也不是说Gas手续费会逐台支付给所有重复执行的节点。Gas主要是协议对链上资源的计量与定价机制,和节点实际运营成本、区块容量及网络对执行资源的需求有关,不能简单理解成“有1000台机器执行,所以手续费乘1000”。

六、所以智能合约到底“智能”在哪里?

讲到这里,大家应该也能理解了。

所谓智能合约,其实就是一段普通的程序逻辑,只不过它运行在一套很特殊的环境里。

我们写Solidity,编译器把它转换成EVM字节码;EVM按照统一的指令规则执行;Gas限制每笔交易能够消耗的资源;出块相关参与者提出交易执行结果,其他节点再按照同一套规则独立验证。

这样一来,我们之前聊过的BTC交易、UTXO,以及这一篇的EVM和Gas,其实就慢慢串起来了。

BTC主要围绕着一个问题展开:

在没有银行的情况下,怎么让大家共同维护一套可信的资金账本?

而ETH把这个问题进一步扩展成了:

在没有一台所有人都必须信任的中央服务器的情况下,怎么让大家共同维护程序的执行结果和状态?

当然,这台所谓的“世界计算机”既不是免费的,也不是无限性能的。为了让不同节点都能够验证执行结果,它付出了相当大的重复计算和状态维护成本,也必须通过Gas和区块资源限制来约束执行规模。

所以智能合约并不是用来替代我们平时写的所有后端程序的。它更适合那些参与者之间互不信任,却又希望按照公开规则共同维护状态的场景。

至于后来大家是怎么利用这套机制,搞出各种DApp、去中心化交易所、链上借贷,甚至让智能合约管理大量资产的——

留到下一篇再讲吧。

  • 请教一下,文中说不同节点执行同一段代码会得到相同结果,那么如果不同客户端存在实现 Bug,导致对同一区块的执行结果不一致,网络如何处理?这是不是比普通中心化服务器更难排查的问题?

  • 分两个问题,我挨个来解释。

    1. 客户端存在Bug,实现不一致:

    按照我们刚刚讲的执行和共识,其实这里需要区分两个概念,一个是节点对交易和智能合约执行结果的验证,另一个则是整个网络通过PoW决定接受哪条区块链。

    假设现在有两个ETH客户端,客户端A因为某个Bug,把1+1算成了3,而客户端B正常算出了2。

    那么问题来了,如果这时候有矿工打包了一笔涉及这段计算的交易,到底应该以谁的结果为准?

    我们分两种情况。

    第一种,矿工使用正常客户端,按照正确的EVM规则执行交易,最终得到结果2,并把对应的State Root写进新区块。

    这个时候,其他正常节点收到区块之后,自己重新执行一遍,发现结果也是2。

    OK,验证通过,接受这个区块。

    但是运行客户端A的节点就不一样了。

    因为它存在Bug,自己算出来的结果是3,和区块里面记录的State Root对不上。

    那么它就会认为这个区块不合法,直接拒绝。

    第二种,矿工自己使用的就是存在Bug的客户端。

    它把结果3对应的State Root写进区块,然后成功完成PoW挖矿,把区块广播出去。

    那么其他使用相同Bug客户端的节点,可能就会接受这个区块。

    但正常节点重新执行以后,发现结果应该是2,自然就会拒绝它。

    注意,这里有个非常重要的地方:

    即使矿工成功挖出了一个满足PoW难度要求的区块,也不代表这个区块一定合法。

    因为PoW只负责证明矿工确实付出了对应的计算工作量,而区块里面的交易、智能合约执行结果等内容,仍然需要满足协议规则。

    所以我们之前介绍BTC时候讲过的最长链规则 ,更准确地说,应该是:

    节点在自己认为有效的区块链中,选择累计工作量最大的那一条。

    而不是谁挖出来的链最长,其他节点就必须无条件接受。

    那么如果存在Bug的客户端占据了大部分矿工算力呢?

    这时候就比较麻烦了。

    因为这些矿工可能会不断在自己认为合法的区块上继续挖矿,而正常客户端却会拒绝这些区块。

    于是整个网络就可能出现两条不同的链:

                     Block N
                        │
              ┌─────────┴─────────┐
              │                   │
          正常客户端           Bug客户端
              │                   │
         执行结果 = 2         执行结果 = 3
              │                   │
         Block N+1            Block N+1'
              │                   │
         Block N+2            Block N+2'
              │                   │
             ...                 ...
    

    两边都认为自己遵守了协议规则,也都可能继续挖矿。

    而且即使Bug客户端那边拥有更多算力,正常节点也不会因为对方的累计工作量更大,就接受一条自己认为无效的链。

    反过来,如果正常矿工拥有更多算力,存在Bug的节点也不一定会自动恢复正常,因为它的软件实现仍然认为正确区块不合法。

    所以这类问题并不是简单的少数服从多数。

    PoW可以让遵守相同规则的节点,对区块历史达成共识,但它不能保证不同客户端对协议规则的实现永远一致。

    如果真的出现这种情况,通常就需要开发者修复客户端Bug,让受影响的节点升级以后重新验证区块,恢复到符合协议规则的链上。

    2. 排查问题难易度:

    结论在前: 排查问题很容易,但是修复问题往往很难

    这个问题其实还挺有意思的。

    如果单纯讨论Bug定位,那么区块链客户端在某些情况下,反而可能比我们平时开发的中心化服务器更容易排查。

    为什么?

    因为前面我们反复提到过,EVM有一个非常重要的特性:

    确定性执行。

    也就是说,只要初始状态、交易内容、执行环境以及协议规则完全一致,那么最终的执行结果就应该完全一致。

    假设现在两个客户端对同一个区块执行出了不同的结果:

    客户端A:
    执行Block N
    State Root = 0xABC
    
    客户端B:
    执行Block N
    State Root = 0xDEF
    

    那么开发者就可以把同一个区块、同一份初始状态拿出来,让两个客户端重新执行一遍。

    甚至可以把每一条Opcode执行之后的栈、内存、Gas消耗以及存储变化全部记录下来。

    比如:

    执行第100条Opcode:
    
    客户端A:
    Stack = [1, 2]
    Gas = 50000
    
    客户端B:
    Stack = [1, 2]
    Gas = 50000
    
    执行第101条Opcode:
    
    客户端A:
    Stack = [3]
    Gas = 49997
    
    客户端B:
    Stack = [4]
    Gas = 49997
    

    OK,问题不就出来了吗?

    前面100条指令都没问题,到了第101条指令,两个客户端突然出现了不同的结果。

    那么开发者就可以重点检查这条指令的实现,看看究竟是哪里出了问题。

    当然,实际排查肯定没有这么简单,可能涉及Gas计算、存储读取、内存扩展、协议升级等各种细节。

    但至少有一点非常重要:

    只要能够还原相同的执行条件,这类问题通常就可以被稳定复现。

    相比之下,我们平时开发的中心化Web服务,有些Bug可能和并发、网络延迟、数据库状态、第三方API等因素有关。

    线上出现一次,等你想排查的时候,可能怎么都复现不出来。

    所以单纯从定位问题的角度来说,区块链这种确定性执行机制,反而有一定优势。

    但是注意,定位Bug容易,不代表修复Bug也容易。

    这才是去中心化系统比较麻烦的地方。

    比如我们平时开发一个Web服务,发现线上有Bug怎么办?

    发现Bug
       ↓
    修改代码
       ↓
    重新编译
       ↓
    部署服务器
       ↓
    修复完成
    

    只要服务器是自己的,什么时候更新、什么时候重启,基本都是自己说了算。

    但是ETH不一样。

    假设现在某个EVM客户端存在Bug,导致一部分节点拒绝正常区块,甚至已经出现了链分叉。

    开发者修复Bug以后,难道就能直接让全网所有节点更新吗?

    显然不行。

    因为这些节点可能属于不同的矿池、公司、机构或者个人,根本没有一个中心管理员能够强制所有人升级。

    所以开发者只能发布修复版本,再由各个节点运营者自行决定什么时候更新。

    而且如果Bug已经造成了链分叉,那么问题就不只是修改一段代码这么简单了。

    还需要考虑受影响的节点升级以后,能不能重新验证正确的链,以及之前错误分支上的区块和交易应该如何处理。

    严重的时候,甚至可能需要整个社区协调恢复方案。

    当然社区也可能不支持恢复,沿着某分支开始继续朝着不同的方向进行打包交易,可以参考ETH的那几条分支的原因: DAO Fork和Paris分支

  • 高质量 xhj003

  • 牛

你好啊,陌生人!

我的朋友,看起来你是新来的,如果想参与到讨论中,点击下面的按钮!

📈用户数目📈

目前论坛共有73057位seeker

🎉欢迎新用户🎉