关注

「区块链入门」理解 EVM 是如何执行合约

前置知识:

【清华大学区块链课】深入浅出讲web3:从比特币到区块链(全14讲)1讲到3讲 https://www.bilibili.com/video/BV1mL411a7jo?spm_id_from=333.788.videopod.episodes&vd_source=b76cb9f3bba1341731bf8848c7a94ce6&p=3

normal-case model、byzantine fault tolerance model、economic model

FLP定理、CAP定理

PoW、PoS、DPoS、PBFT

推荐先看:

10分钟理解智能合约运行原理以及EVM底层结构!web3/区块链开发核心知识 https://www.bilibili.com/video/BV1PJwAeyEgx/?spm_id_from=333.337.search-card.all.click&vd_source=b76cb9f3bba1341731bf8848c7a94ce6

从“共识层”到“执行层”

你大概已经有了这样的直觉:PoW / PoS 选出谁有权出块,BFT 思想保证大家最终看到同一份历史,经济模型让理性节点不值得作恶。

共识层回答的是:“下一个区块里有哪些交易?”——但链上真正要落实的,是另一件事:这些交易执行之后,世界状态会怎么变?谁来决定“A 扣了 1 ETH、B 加了 1 ETH”这类结果?

这就是执行层要干的事。以太坊可以看成两层的组合:共识层定区块顺序,EVM(执行层)按交易内容一步步算,得到新的余额、合约状态,再把这些结果写进“世界状态”。


什么是“世界状态”

在 Ethereum 中:

整个链的本质是一个巨大的 Key-Value 状态数据库。

你在钱包里看到的「某个地址有多少 ETH」、在区块浏览器里看到的「某个合约的代码」和「某次调用后某个变量变成了什么值」,在这个数据库里,最后都会变成一条条简单的「key → value」记录:

  • 账户地址 → 余额
  • 合约地址 → 合约代码
  • 合约地址 + 某个 storage 槽位 → 这个槽位当前的值

这个数据库包含:

  • 所有账户余额
  • 所有合约代码
  • 所有合约变量

它的存储结构叫:

Ethereum 使用的 State Trie(Merkle Patricia Trie)

State Trie 是一个:

  • 可验证
  • 可压缩
  • 可生成 root hash
  • 可用于 SPV 证明

的结构。

很多刚接触以太坊的人,都会对几件事情有疑问:

  • 链上的「状态」到底是长在什么样的数据结构里的?
  • 区块头里区区 32 字节的 stateRoot,怎么可能概括整个世界状态?
  • 轻节点为什么只拿到一小段证明,就敢相信某个余额是对的?
  • 节点之间是怎么保证「谁也没悄悄改过状态」的?

Merkle Patricia Trie(MPT) 正好把这些问题串在了一起。
下面我们只以 Ethereum 为例,从最朴素的余额表一步步推导到 MPT,每一步你都可以自己算一算、画一画。


从一个最基础的问题讲起

先从一个非常朴素的问题问起:为什么以太坊不能只用一个普通的 HashMap 来存所有账户和余额?

假设我们有一个最简单的状态:

地址 A → 10 ETH
地址 B → 5 ETH
地址 C → 20 ETH

你可能会想:

用一个普通的 HashMap 不就行了吗?

是的,但问题来了:


如果区块头里只存一个 “状态摘要”,怎么验证?

区块头里有:

stateRoot = ?

现在一个轻节点问:

A 的余额是多少?

如果我们只存 HashMap:

  • 你必须下载全部数据
  • 才能计算 hash
  • 才能验证

这太重了。


Merkle Tree 是怎么解决这个问题的?

🆕 新概念:Merkle Tree

Merkle Tree 是:

用 hash 逐层汇总数据的一棵树

假设有 4 个账户:

A → 10
B → 5
C → 20
D → 7

第一步:对每个数据做 hash

hA = hash(A:10)
hB = hash(B:5)
hC = hash(C:20)
hD = hash(D:7)

第二步:两两组合再 hash

hAB = hash(hA + hB)
hCD = hash(hC + hD)

第三步:

root = hash(hAB + hCD)

重要理解

如果你只想验证:

A 的余额是 10 吗?

你只需要:

hA
hB
hCD

就可以重新算出 root。

你不需要 B/C/D 的具体数据。

这叫:

Merkle Proof(默克尔证明)


Merkle Tree 的局限在哪里?

Merkle Tree 非常适合描述一批「相对固定」的数据,例如某个区块里的交易列表。但如果把以太坊的整个世界状态直接塞进一棵 Merkle Tree,就会暴露出明显的问题。

它更适合:

  • 数据项是固定的一批
  • 不需要频繁插入和删除

而 Ethereum 的状态是:

  • 不断增加新的账户
  • 旧账户的余额经常改变
  • 合约 storage 里到处在写入和更新

如果你用一棵「简单 Merkle Tree」直接挂所有账户:

  • 每次插入一个新账户
  • 很可能会让整棵树的结构发生连锁变化

这对一个需要持续处理新交易、不断更新状态的系统来说,代价太高了。


于是我们引入 Trie

🆕 新概念:Trie(前缀树)

Trie 是:

按 key 的“前缀”来组织数据的树

举例:

有 key:

dog
door
cat

Trie 结构:

      root
      /   \
     d     c
    /       \
   o         a
  / \         \
 g   o         t
      \
       r

优点:

  • 查找快
  • 插入只影响一条路径

普通 Trie 还不够用

问题:

  • Trie 不能生成全局 hash
  • 不能验证完整性

所以需要:

Trie + Merkle = Merkle Trie


Patricia:再压一层路径

🆕 新概念:Patricia(压缩前缀树)

普通 Trie 有个问题:

如果 key 很长,比如:

0x123456789abcdef...

那树会很深。

Patricia 的优化是:

把连续没有分叉的路径压缩

例如:

普通 Trie:

a
 \
  b
   \
    c
     \
      d

压缩后:

abcd

这样可以:

  • 节省空间
  • 提高效率

合起来:Merkle + Patricia + Trie

🆕 新概念:Merkle Patricia Trie(MPT)

它同时具备:

  1. Trie 的可插入性
  2. Patricia 的压缩性
  3. Merkle 的可验证性

Ethereum 里 MPT 存的是什么?

重点来了。

Ethereum 有三种 Trie:

  1. State Trie(账户)
  2. Storage Trie(合约内部变量)
  3. Transaction Trie(交易)

我们重点看 State Trie。


State Trie 大致长什么样?

key 是:

keccak256(地址)

为什么要 hash 地址?

  • 固定长度
  • 均匀分布
  • 防止攻击构造路径

假设:

地址 A → hash = abcd...
地址 B → hash = abef...

Trie 会按 hex 字符拆分:

a → b → c → d ...
a → b → e → f ...

前两个字符相同:

a
 \
  b
   / \
  c   e

这就是“前缀共享”。


节点类型

MPT 有三种节点:

1️⃣ Branch Node

  • 有 16 个子节点(0~f)
  • 代表 hex 分叉

2️⃣ Extension Node

  • 表示一段压缩路径

3️⃣ Leaf Node

  • 存储最终 value

状态是怎么更新的?

假设:

A 余额 10 → 变成 15

步骤:

  1. 找到 A 对应的 leaf
  2. 修改 value
  3. 从叶子往上重新 hash
  4. 一直到 root

这条路径之外的节点:

完全不变


这就是:

局部修改,局部重算

时间复杂度:

O(log n)


什么是 stateRoot?

stateRoot 是:

整个 State Trie 的根 hash

只要 root 不变:

→ 整个世界状态必然没变

只要 root 变了:

→ 某个地方必然发生变化


轻节点是怎么验证余额的?

假设你是轻节点。

全节点告诉你:

A 的余额是 15

并给你:

  • 相关路径节点
  • 每层的 hash

你:

  • 重新 hash
  • 看算出的 root 是否等于区块头里的 stateRoot

如果相等:

→ 数据真实


这就是:

不信任全节点,也能验证数据


更深一点:共识层和状态结构各管什么

你之前学到的是:

BFT 类算法解决的是「哪一个区块才是大家都承认的那一个」。

在这里你看到的是:

MPT 这样的状态结构,解决的是「在这个被选中的区块里,状态有没有在传播过程中被任何人悄悄改过」。

用非常粗略的话来说,可以把保障拆成两层:

1️⃣ 共识层保证顺序:大家最终对「区块链长成什么样」达成一致
2️⃣ 状态结构保证完整性:在这个前提下,任何人都可以用 root 和 proof 去检查「状态值是否被篡改」


思考实验:用两个小问题自测

下面这两个小问题,可以用来检查你对「局部修改,局部重算」和「root 代表整个状态」这两点是否真的有直觉了。

实验 1

如果有人在某个节点的本地,悄悄把:

A 余额 10 → 1000

会发生什么?

一种推演方式是:

  • leaf 里的 value 被改掉
  • 从 leaf 往上算的路径 hash 都会变
  • 最终整棵 State Trie 的 root 也会跟着变
  • 带着这个 root 计算出来的区块 hash 也不同了
  • 和其他诚实节点算出来的区块 hash 不一致 → 这个区块会被视为无效

实验 2

那如果攻击者「只改 leaf,不改 root」呢?

在诚实节点上这是做不到的:
root 不是随便写在某个字段里的常数,它是沿着整棵 Trie 一路 hash 上去「推出来」的结果。
只要你改了下面的某个节点,在重新计算时,root 必然会一起变。


为什么不直接用数据库?

传统数据库当然也能存「地址 → 余额」「地址 + 槽位 → storage」这些键值对,但它少了区块链特别在意的几样能力:

  • 很难直接从当前所有状态算出一个「谁也改不了」的摘要 root
  • 无法为单条记录生成短小、可独立验证的证明(Merkle proof)
  • 轻节点没法在不下载全量数据的情况下,独立验证某个值是不是可信

而区块链从设计第一天起,就要求:

任何一个不完全信任别人的节点,也能只凭公共数据检查「当前状态是不是一致」。

MPT 正是在「键值数据库」之上,加了一层这样的可验证性。


未来方向:从 MPT 到 Verkle

在以太坊社区里,状态数据结构本身也在演进:现在 Ethereum 正在从传统的 MPT,逐步规划过渡到 Verkle Tree(可以进一步缩小 proof 大小、降低带宽开销)。

目前主网仍然使用 MPT,所以理解这一节的内容,依然是读懂今天的 Ethereum 状态模型的基础。


到这里你可以回答什么问题

读到这里,你基本可以自己回答这些问题:

  1. 为什么区块头里只需要放一个 32 字节的 stateRoot
  2. 为什么轻节点只拿到一小段证明,就能验证某个余额是不是对的?
  3. 为什么修改一个 storage 槽,只会让从对应 leaf 到 root 的那条路径被重算?
  4. 为什么执行必须是 deterministic,否则共识层再强也没用?

如果你愿意,下一步可以继续往下走:

  • 手动画一个完整的 MPT 示例
  • 看看 storage trie 是怎么嵌套在账户里的
  • 或者沿着区块头里的 stateRoot / transactionsRoot / receiptsRoot,把三棵 Trie 的关系串起来

做完这些练习时,你对 Ethereum 状态模型的理解,已经远远超过「只知道有个 stateRoot」的阶段了。


账户模型

Ethereum 不是 UTXO。

它采用的是:

Account Model

可以粗略理解为「全局有一张巨大的账户表,每一行是一个地址,每一行都带着一些固定字段」。

每个账户有 4 个字段:

nonce
balance
storageRoot
codeHash

大致可以这样记:

  • nonce:这个账户已经发出过多少笔交易 / 创建过多少合约(防重放、防止同一笔交易被反复执行)
  • balance:账户当前持有多少 ETH
  • storageRoot:如果这是一个合约账户,这个字段指向它内部 storage 的 MPT 根;如果是外部账户,这里是一个固定值
  • codeHash:合约代码的哈希(外部账户没有代码,对应一个固定空代码哈希)

以太坊上存在两种账户:

  1. 外部账户(EOA):你用私钥控制的钱包地址,只能发交易,codeHash 为空
  2. 合约账户(Contract Account):部署在链上的合约地址,有自己的代码和 storage,不直接对应某个私钥

交易是怎么调用合约的?

这是最关键部分。

一个交易结构大致如下:

nonce
to
value
gasLimit
gasPrice
data
v,r,s (签名)

我们只关注:

data 字段


交易 data = 函数签名 + 参数编码

假设有合约:

function transfer(address to, uint amount)

当你调用:

transfer(0xabc..., 100)

data 并不是字符串。

它是:

4 字节函数选择器 + ABI 编码参数

1️⃣ 函数选择器

计算方法:

keccak256("transfer(address,uint256)")

取前 4 字节。

这 4 字节就是函数 ID。


思考问题

为什么只取 4 字节?

答案:
节省空间,同时冲突概率可接受。


2️⃣ 参数编码(ABI)

Ethereum 使用:

ABI 编码规则。

  • address → 32字节
  • uint256 → 32字节
  • 所有参数按顺序拼接

所以最终 data 是:

0xa9059cbb
000000000000000000000000abc...
0000000000000000000000000000000000000000000000000000000000000064

很好。

这一段如果只看个大概,后面写合约时很容易「能用,但不知道底层到底在做什么」。

接下来我们从 0 开始把这件事拆开:不预设你已经了解 ABI,也不预设你看过编码规则,只用一两个具体例子,把「函数名 + 参数」一步步变成最终的 data 字节串。

最终的目标是:你可以拿一笔具体的调用,把它的 data 按照规则手算出来,或者反过来从 data 中还原出函数和参数。


从一个具体问题开始

问题:EVM 怎么知道你想调用哪个函数?

Solidity 合约:

contract Bank {
 function deposit(uint amount) public {}
 function withdraw(uint amount) public {}
}

假设你发交易给这个合约。

交易里只有:

to = 合约地址
data = 一串十六进制

注意:

区块链上没有:

  • 函数名
  • 参数名
  • 类型信息

那 EVM 怎么知道你想调用 deposit 还是 withdraw?

答案:

靠 data 里的前 4 个字节。


函数签名(Function Signature)

函数签名是一个字符串:

函数名(参数类型1,参数类型2,...)

注意:

  • 只写类型
  • 不写参数名
  • 类型必须是完整形式

例如:

deposit(uint256)
withdraw(uint256)
transfer(address,uint256)

函数选择器(Function Selector)

函数选择器 =

keccak256(函数签名) 的前 4 字节

这里出现一个新概念:

🆕 keccak256

一种哈希函数。

作用:

任意长度输入 → 32 字节固定输出

例如:

keccak256("deposit(uint256)")
= 0xb6b55f25d8...

取前 4 字节:

0xb6b55f25

这 4 个字节就是:

函数选择器


为什么是 4 字节?

  • 4 字节 = 32 bit
  • 可表示 2^32 ≈ 42 亿种函数
  • 冲突概率极低
  • 交易 data 更小

小结:data 的前 4 字节

结构第一部分:

[4字节函数选择器]

接下来:参数怎么编码?

这是很多人第一次接触 ABI 编码时最容易卡住的一步。

先把最核心、最常见的那几条规则讲清楚。


ABI 编码规则

ABI = Application Binary Interface

它规定:

所有参数按 32 字节对齐编码。

关键规则:

  1. 每个参数占 32 字节
  2. 从左往右拼接
  3. 数值使用大端表示
  4. 不足 32 字节左补 0

先看最简单的例子

函数:

deposit(uint256)

调用:

deposit(100)

第一步:函数选择器

selector = keccak256("deposit(uint256)")[:4]

假设:

0xb6b55f25

第二步:参数编码

100 的十六进制是:

0x64

ABI 规则:

  • 必须 32 字节
  • 左补 0

所以变成:

0000000000000000000000000000000000000000000000000000000000000064

64 个十六进制字符 = 32 字节


第三步:拼接

最终 data:

0xb6b55f25
0000000000000000000000000000000000000000000000000000000000000064

用一眼就能看懂的结构再看一遍

把上面的例子抽象掉,data 的整体结构其实就是:

[4字节 selector]
[32字节 参数1]
[32字节 参数2]
...

再看一个稍微复杂一点的例子

函数:

transfer(address,uint256)

调用:

transfer(0x1111111111111111111111111111111111111111, 100)

第一步:函数选择器

keccak256("transfer(address,uint256)")

得到:

0xa9059cbb

第二步:编码 address

address 是:

20 字节

ABI 规定:

  • 仍然必须 32 字节
  • 左补 0

变成:

0000000000000000000000001111111111111111111111111111111111111111

第三步:编码 100

同上:

0000000000000000000000000000000000000000000000000000000000000064

第四步:拼接

0xa9059cbb
0000000000000000000000001111111111111111111111111111111111111111
0000000000000000000000000000000000000000000000000000000000000064

完成。


EVM 是怎么解析的?

EVM 执行时:

  1. 读取前 4 字节
  2. 在合约函数表里查找匹配 selector
  3. 如果找到 → 跳转执行
  4. 后面的数据按 32 字节切片
  5. 每 32 字节解析为一个参数

一个重要的对齐细节

为什么全部固定 32 字节?

因为:

EVM 是 256 bit 虚拟机。

它的基本字长是:

256 bit = 32 字节

所以 ABI 与 EVM 字长对齐。


再深入一点:动态类型怎么办?

例如:

function setName(string memory name)

string 是动态长度。

编码规则变为:

[selector]
[offset]
[data长度]
[data内容]

我们举例:

setName("abc")

第一步:selector

假设:

0x12345678

第二步:offset

第一个参数是动态类型。

ABI 规则:

参数位置放的是数据区域的偏移量

第一个参数的位置是:

紧接 selector 后 32 字节。

而数据区域从哪里开始?

从所有参数区之后开始。

这里只有一个参数。

所以:

offset = 0x20

(十进制 32)

编码成 32 字节:

0000000000000000000000000000000000000000000000000000000000000020

第三步:数据区

string = “abc”

长度 = 3

编码:

0000000000000000000000000000000000000000000000000000000000000003
6162630000000000000000000000000000000000000000000000000000000000

注意:

  • 先写长度
  • 再写内容
  • 右补 0

最终结构

selector
offset
length
data

再看一眼 data 的本质

data 本质是:

一个严格格式化的二进制结构体

不是字符串,不是 JSON,不是函数名。


再从头串一次完整执行流程

当你发交易:

to = 合约
data = 编码后的字节串

EVM 做:

  1. 读取 selector
  2. 查函数
  3. 切 32 字节读取参数
  4. 把参数压入栈
  5. 执行代码
  6. 修改状态

思考练习:检查一下自己是否吃透

你现在可以尝试回答:

  1. 为什么函数重载不会冲突?
  2. 为什么参数名完全无关?
  3. 如果 selector 不匹配会发生什么?
  4. fallback 函数什么时候触发?

换一个程序员更熟悉的比喻

交易 data 是:

调用指令 + 二进制参数

你可以把它理解成:

CPU 指令编号 + 操作数

这和传统程序本质是一样的。


如果你愿意,下一步我们可以:

  • 手算一个包含 2 个动态参数的例子
  • 或讲 calldata / memory / storage 的区别
  • 或讲 delegatecall 时 data 是怎么传递的
  • 或讲 selector 冲突攻击

你现在已经真正触碰到 EVM 执行模型的核心了。


实践建议

你可以用:

  • ethers.js
  • web3.js
  • solidity console

编码一次 transfer。

观察 data。


EVM 是如何执行这个交易的?

当区块生产者把交易打包进区块时,每个全节点都会独立地跑一遍这笔交易,大致会经历这样几个步骤:

  1. 读取交易本身(from / to / value / gasLimit / gasPrice / data 等)
  2. 检查签名,确认确实是 from 账户发出的
  3. 根据 gasLimit 预留一笔最大可能消耗的 Gas,并检查余额是否足够支付
  4. 在本地 EVM 里创建一个执行环境,开始按字节码一步步执行

执行过程(抽象层)

从更抽象一点的角度看,EVM 在执行一笔合约调用时,会做这些事:

  • 创建执行上下文:为这次调用单独准备栈、内存视图,以及指向当前合约 storage 的引用
  • 加载合约代码:把 codeHash 对应的字节码拉出来准备执行
  • 按字节码顺序执行:对每条指令按 op 的语义修改栈、内存或 storage
  • 修改 storage:当执行到 SSTORE 等指令时,真正落地到合约的持久化存储
  • 消耗 Gas:每执行一条指令、每次写 storage 都会按预先定义的 Gas 表扣费

如果:

  • Gas 用完 → revert
  • 显式 revert → 回滚
  • 执行成功 → 状态更新

Gas 的本质是什么?

很多人第一次接触以太坊时,会把 Gas 直接等同于「手续费」。
实际上更准确的说法是:

Gas 是:

对计算和存储资源的定价单位。

你可以把 Gas 理解成「EVM 世界里的 CPU 时间 / 磁盘写入」的计量方式,而真正你花出去的 ETH 数量,则是:

实际消耗的 Gas × 你愿意支付的单价(GasPrice + BaseFee)


为什么要有 Gas?

从分布式系统的角度看,你可能学过 FLP 定理:在一个异步网络里,不能保证「总能在有限时间内结束」。
如果区块链不对「每一步计算」收费,那任何人都可以在合约里写:

while(true){}

然后让整个网络免费帮他死循环下去。

Gas 机制解决的,正是:

计算资源 DoS 问题


Gas 模型的经济意义

在以太坊里,大致可以这样理解几项参数:

  • GasLimit 控制一笔交易或一个区块最多能消耗多少计算资源
  • GasPrice(以及 EIP-1559 之前的 gasPrice / 之后的 priorityFee)影响这笔交易被打包的优先级
  • BaseFee(EIP-1559)根据链上拥堵程度动态调整,平衡区块大小和费用水平

这一整套设计,让系统在经济上形成了这样的平衡:

  • 节点有动力打包「愿意支付合理费用」的交易
  • 用户滥用计算和存储资源的成本会迅速上升,从而失去攻击的性价比

状态如何被更新?

执行成功后:

EVM 会修改:

storageRoot
balance
nonce

所有修改最终都会:

更新 State Trie。

然后:

生成新的 stateRoot。

这个 stateRoot 会写进区块头。


为什么区块头只存 root?

因为:

Merkle Patricia Trie 允许:

  • 用 root hash 证明某个账户存在
  • 用 root hash 证明某个 storage 值

这就是:

轻节点验证的基础。


完整执行流程(一步步)

我们用一个实际例子。

假设:

Alice 调用 ERC20 合约的 transfer。

步骤如下:

  1. Alice 构造交易
  2. data = 函数选择器 + ABI 参数
  3. 签名
  4. 广播到网络
  5. 区块生产者选中
  6. 执行 EVM
  7. 扣除 Gas
  8. 修改 storage:
    • balances[Alice] -= 100
    • balances[Bob] += 100
  9. 更新 state trie
  10. 生成新的 stateRoot
  11. 写入区块头
  12. 区块达成共识

结合你学过的 BFT 思维再理解

Ethereum 现在是 PoS。

它保证:

  • 多数验证者诚实
  • 最终区块不可回滚

但:

执行层必须是:

完全确定性的。

否则:

不同节点执行结果不同 → 共识崩溃。

这就是:

EVM 禁止随机数、禁止时间依赖、禁止非确定 IO 的原因。


和 PBFT 的关系

在 PBFT 里:

所有节点必须:

执行同样的状态机。

Ethereum 的本质是:

状态机复制(State Machine Replication)

共识决定输入顺序
EVM 决定状态转换函数

🆕 新概念:状态机复制(State Machine Replication, SMR)

假设:

你有 4 台服务器:

Node A
Node B
Node C
Node D

它们:

  • 各自保存一份状态
  • 接收同样的输入
  • 执行同样的状态机

只要输入顺序一致:

→ 所有节点的状态永远一致。

这叫:

复制状态机

PBFT 解决的问题是:

如何让所有节点对“输入顺序”达成一致?

注意:

PBFT 不关心你执行什么代码。

它只做一件事:

所有节点同意:第 1 个输入是 A
第 2 个输入是 B
第 3 个输入是 C

然后每个节点:

for input in agreed_order:
    state = f(state, input)

这就是标准的 SMR。


思考环节

下面几个小问题,可以当作阅读过程中的「自测题」,不需要一次性全部写出完整证明,只要能给出一个让自己信服的推理过程即可。

思考 1

如果有两笔交易先后来到链上:

transfer(A → B 100)
transfer(A → C 100)

而 A 的余额只有 100。

谁会成功?谁会失败?

你可以先给出自己的判断,再对照这样一种理解:

  • 最终结果完全取决于这两笔交易出现在区块里的先后顺序
  • 这个顺序由当下的区块生产者在打包时决定(当然要受 GasPrice 等因素影响)

思考 2

为什么 Gas 要「预付」,而不是「执行完再结算」?

试着从攻击者的视角想一想:如果可以后付,他会怎么利用这一点来让全网白白帮他忙?

一个常见的思路是:

  • 先触发一大段复杂的计算或大量写入
  • 等大家都算完了,再在最后一步「拒付」或失败

预付 Gas 这个设计,就是为了让这种行为一开始就失去经济上的吸引力。


思考 3

为什么状态不直接存在一个普通数据库里,而是一定要套一层 Trie?

可以从「谁都不完全信任谁」的前提出发:
区块头必须给每个节点一个办法——只靠这 32 字节的 root,就能验证任意一条状态有没有被动过手脚,而不是把信任完全交给某个数据库运维者。


动手实践建议

如果你想把抽象概念和真实链上数据对上号,下面这 3 个小实验会很有帮助,可以按兴趣和时间挑着做:


实验 1:手动编码函数

用 ethers.js:

iface.encodeFunctionData("transfer", [addr, 100])

观察 data。

这 3 个实验更像是「围绕执行层的实验室作业」:
每一个都会让你亲眼看到前文提到的某个概念,在真实数据里是怎样呈现出来的。

下面给出完整可执行步骤:从环境准备,到每一步你会看到什么、为什么会这样、以及怎样自己验证。

我们统一使用:

  • Node.js
  • ethers.js
  • 公共 RPC(例如 Sepolia 测试网)

实验 1:手动编码函数(观察 transaction data 的真实样子)

目标:

亲眼看到 函数签名 + ABI 编码 的真实结果


第 1 步:准备环境

安装 node(如果已有跳过)

然后:

mkdir evm-lab
cd evm-lab
npm init -y
npm install ethers

创建文件:

touch encode.js

第 2 步:写完整代码

const { ethers } = require("ethers");

// 创建接口(只需要函数声明,不需要完整合约)
const iface = new ethers.Interface([
  "function transfer(address to, uint256 amount)"
]);

// 示例参数
const addr = "0x1111111111111111111111111111111111111111";
const amount = 100;

// 编码函数调用
const data = iface.encodeFunctionData("transfer", [addr, amount]);

console.log("Encoded Data:");
console.log(data);

运行:

node encode.js

第 3 步:你会看到类似输出

0xa9059cbb
0000000000000000000000001111111111111111111111111111111111111111
0000000000000000000000000000000000000000000000000000000000000064

(实际是一行)


第 4 步:拆解验证

前 4 字节:

a9059cbb

这是:

keccak256("transfer(address,uint256)") 前 4 字节

后面:

  • 前 32 字节 = address(左补 0)
  • 后 32 字节 = 100(0x64 左补 0)

你现在验证了什么?

做完这个实验,其实就是亲手确认了前面对 transaction.data 的那句抽象描述:

transaction.data =
selector + ABI编码参数

实验 2:查看 storage

用:

eth_getStorageAt

查看某个 slot。

实验 2:查看 storage(理解 storage slot)

目标:

理解 storage 是按 slot 存储的


第 1 步:准备 RPC

去 Alchemy 或 Infura 注册一个免费 RPC。

得到类似:

https://eth-sepolia.g.alchemy.com/v2/你的key

第 2 步:创建新文件

touch storage.js

写入:

const { ethers } = require("ethers");

// 替换成你的 RPC
const provider = new ethers.JsonRpcProvider("你的RPC地址");

// 示例:一个已知 ERC20 合约(Sepolia 上找一个)
const contractAddress = "0x..."; 

async function main() {
  // slot 0
  const slot0 = await provider.getStorage(contractAddress, 0);
  console.log("Slot 0:", slot0);
}

main();

第 3 步:运行

node storage.js

你会看到:

0x000000000000...

理解 storage slot

在 Solidity 中:

uint public totalSupply;   // slot 0
address public owner;      // slot 1

每个变量:

占一个 32 字节 slot。


深入理解

如果变量是:

mapping(address => uint) balances;

slot 不是线性存储。

计算方式是:

keccak256( key + slotIndex )

这就是为什么:

storage 是可验证的确定结构


实验 3:查看 stateRoot

查询某个区块头。

观察:

stateRoot
transactionsRoot
receiptsRoot

理解:

  • stateRoot = 世界状态
  • transactionsRoot = 本区块交易集合
  • receiptsRoot = 执行结果集合

实验 3:查看 stateRoot / transactionsRoot / receiptsRoot

目标:

理解区块头是如何概括整个区块的


第 1 步:创建文件

touch block.js

写入:

const { ethers } = require("ethers");

const provider = new ethers.JsonRpcProvider("你的RPC地址");

async function main() {
  const block = await provider.getBlock(5000000); // 任意区块号

  console.log("Block Number:", block.number);
  console.log("stateRoot:", block.stateRoot);
  console.log("transactionsRoot:", block.transactionsRoot);
  console.log("receiptsRoot:", block.receiptsRoot);
}

main();

运行:

node block.js

输出示例

stateRoot: 0xabc...
transactionsRoot: 0xdef...
receiptsRoot: 0x123...

三个 root 分别代表什么?


1️⃣ stateRoot

当前区块执行完所有交易之后的「世界状态」那棵 MPT 的根哈希

如果:

  • 有一个账户余额被改
  • 有一个 storage 槽被改

重新计算出来的 stateRoot 就会随之变化。


2️⃣ transactionsRoot

本区块所有交易构成的 Merkle Patricia Trie 根

作用主要在于:

  • 防止交易在传播或存储过程中被悄悄篡改或删改
  • 为单笔交易提供可独立验证的 proof

3️⃣ receiptsRoot

Receipt 大致长这样:

gasUsed
status
logs

它们也组成一棵 MPT。

用途:

  • 验证某条交易是否成功执行
  • 验证对应的事件日志是否真实存在于某个区块里

再看一眼:三棵树的关系

区块头结构:

parentHash
stateRoot
transactionsRoot
receiptsRoot
...

区块 hash 是:

hash(区块头)

所以:

只要你篡改:

  • 一个交易
  • 一个 receipt
  • 一个账户余额

都会导致:

root 改变 → 区块 hash 改变 → 区块无效

一张图串起来

区块
 ├── transactions → MPT → transactionsRoot
 ├── receipts → MPT → receiptsRoot
 └── state → MPT → stateRoot

建议你做的验证动作

验证 1

查一个区块号 A 和 A+1:

对比:

stateRoot 是否变化?

通常会变(除非区块空)。


验证 2

查一个区块的交易数量:

block.transactions.length

和:

transactionsRoot

理解:

root 代表的是集合的完整性


进阶理解(未来你会碰到)

当你继续深入,会接触:

  • Layer2 Rollup
  • Fraud Proof
  • zk Proof
  • EVM 兼容链
  • EIP-4844

但核心不变:

共识排序输入
EVM 执行状态机
Trie 生成 root
root 被共识确认


用一句话重新描述以太坊

如果把这一篇里的所有内容压成一句话,可以这样看待 Ethereum:

一个由经济模型保护的、确定性的、可验证的状态机复制系统。

转载自CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/qilie_32/article/details/158617664

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--