前置知识:
【清华大学区块链课】深入浅出讲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 catTrie 结构:
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)
它同时具备:
- Trie 的可插入性
- Patricia 的压缩性
- Merkle 的可验证性
Ethereum 里 MPT 存的是什么?
重点来了。
Ethereum 有三种 Trie:
- State Trie(账户)
- Storage Trie(合约内部变量)
- 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
步骤:
- 找到 A 对应的 leaf
- 修改 value
- 从叶子往上重新 hash
- 一直到 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 状态模型的基础。
到这里你可以回答什么问题
读到这里,你基本可以自己回答这些问题:
- 为什么区块头里只需要放一个 32 字节的
stateRoot?- 为什么轻节点只拿到一小段证明,就能验证某个余额是不是对的?
- 为什么修改一个 storage 槽,只会让从对应 leaf 到 root 的那条路径被重算?
- 为什么执行必须是 deterministic,否则共识层再强也没用?
如果你愿意,下一步可以继续往下走:
- 手动画一个完整的 MPT 示例
- 看看 storage trie 是怎么嵌套在账户里的
- 或者沿着区块头里的 stateRoot / transactionsRoot / receiptsRoot,把三棵 Trie 的关系串起来
做完这些练习时,你对 Ethereum 状态模型的理解,已经远远超过「只知道有个 stateRoot」的阶段了。
账户模型
Ethereum 不是 UTXO。
它采用的是:
Account Model
可以粗略理解为「全局有一张巨大的账户表,每一行是一个地址,每一行都带着一些固定字段」。
每个账户有 4 个字段:
nonce
balance
storageRoot
codeHash
大致可以这样记:
nonce:这个账户已经发出过多少笔交易 / 创建过多少合约(防重放、防止同一笔交易被反复执行)balance:账户当前持有多少 ETHstorageRoot:如果这是一个合约账户,这个字段指向它内部 storage 的 MPT 根;如果是外部账户,这里是一个固定值codeHash:合约代码的哈希(外部账户没有代码,对应一个固定空代码哈希)
以太坊上存在两种账户:
- 外部账户(EOA):你用私钥控制的钱包地址,只能发交易,
codeHash为空 - 合约账户(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 字节对齐编码。
关键规则:
- 每个参数占 32 字节
- 从左往右拼接
- 数值使用大端表示
- 不足 32 字节左补 0
先看最简单的例子
函数:
deposit(uint256)调用:
deposit(100)
第一步:函数选择器
selector = keccak256("deposit(uint256)")[:4]假设:
0xb6b55f25
第二步:参数编码
100 的十六进制是:
0x64ABI 规则:
- 必须 32 字节
- 左补 0
所以变成:
000000000000000000000000000000000000000000000000000000000000006464 个十六进制字符 = 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 执行时:
- 读取前 4 字节
- 在合约函数表里查找匹配 selector
- 如果找到 → 跳转执行
- 后面的数据按 32 字节切片
- 每 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 做:
- 读取 selector
- 查函数
- 切 32 字节读取参数
- 把参数压入栈
- 执行代码
- 修改状态
思考练习:检查一下自己是否吃透
你现在可以尝试回答:
- 为什么函数重载不会冲突?
- 为什么参数名完全无关?
- 如果 selector 不匹配会发生什么?
- fallback 函数什么时候触发?
换一个程序员更熟悉的比喻
交易 data 是:
调用指令 + 二进制参数
你可以把它理解成:
CPU 指令编号 + 操作数这和传统程序本质是一样的。
如果你愿意,下一步我们可以:
- 手算一个包含 2 个动态参数的例子
- 或讲 calldata / memory / storage 的区别
- 或讲 delegatecall 时 data 是怎么传递的
- 或讲 selector 冲突攻击
你现在已经真正触碰到 EVM 执行模型的核心了。
实践建议
你可以用:
- ethers.js
- web3.js
- solidity console
编码一次 transfer。
观察 data。
EVM 是如何执行这个交易的?
当区块生产者把交易打包进区块时,每个全节点都会独立地跑一遍这笔交易,大致会经历这样几个步骤:
- 读取交易本身(from / to / value / gasLimit / gasPrice / data 等)
- 检查签名,确认确实是 from 账户发出的
- 根据 gasLimit 预留一笔最大可能消耗的 Gas,并检查余额是否足够支付
- 在本地 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。
步骤如下:
- Alice 构造交易
- data = 函数选择器 + ABI 参数
- 签名
- 广播到网络
- 区块生产者选中
- 执行 EVM
- 扣除 Gas
- 修改 storage:
- balances[Alice] -= 100
- balances[Bob] += 100
- 更新 state trie
- 生成新的 stateRoot
- 写入区块头
- 区块达成共识
结合你学过的 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



