这篇文章是对Noxx的EVM系列文章的学习和翻译。
https://noxx.substack.com/p/evm-deep-dives-the-path-to-shadowy?s=r
// SPDX-License-Identifier: MIT
pragma solidity >=0.7.0 <0.9.0;
contract Storage {
uint256 number;
function store(uint256 num) public {
number = num;
}
function retrieve() public view returns (uint256) {
return number;
}
}
操作码的长度为 1 个字节,有 256 种不同的可能操作码。 EVM 仅使用 140 个独特的操作码。
函数调用的过程:

使用参数 10 对存储函数进行合约调用。 abi.encodeWithSignature() 以所需格式获取calldata(调用数据)。
此时的calldata:
abi.encodeWithSignature()表示的是函数签名(Keccak 散列的前四个字节(function selector)+参数)
keccak256(“store(uint256)”) → first 4 bytes = 6057361d
keccak256(“retrieve()”) → first 4 bytes = 2e64cec1
上面的 calldata的前 4 个字节对应于 store(uint256) 函数计算的函数选择器。
十六进制两个数字表示一个字节。
这篇文章比之前的几篇文章,更加详细的讲道了堆栈的偏移量。
比如我们上面的calldata是36个字节,而栈顶是256位,只能存放32字节,这就需要进行偏移。
调用堆栈有一个称为程序计数器的东西,它指定下一个执行命令在字节码中的位置
在链接中可以进行上下操作体会字节码对stack的变化
60 00 = PUSH1 0x00 //向栈顶推入字节0x00,表示calldataload从0开始读取
35 = CALLDATALOAD //此时的calldata是36个字节,但是只能读取前32个字节在栈顶
60 e0 = PUSH1 0xe0 //向栈顶推入字节0xe0(224)
1c = SHR //从栈顶弹出两个元素,对第二个元素进行右移操作,将结果推送到栈顶,输出结果6057361d
80 = DUP1 //复制栈顶元素并将副本推送到栈顶
63 2e64cec1 = PUSH4 0x2e64cec1 //将四字节值(16进制表示的2e64cec1)推送到栈顶
14 = EQ //从栈顶弹出两个元素,如果它们相等,则将1推送到栈顶;否则,将0推送到栈顶
61 003b = PUSH2 0x003b //将两字节值(16进制表示的003b,十进制表示的59)推送到栈顶
57 = JUMPI //如果栈中第二个元素的值为真(非零),则跳转到栈顶表示的地址;否则,继续执行下一条指令
80 = DUP1
63 6057361d = PUSH4 0x6057361d
14 = EQ
61 0059 = PUSH2 0x0059
57 = JUMPI
合约内存是一个简单的字节数组,其中数据可以以32字节(256位)或1字节(8位)的块存储,并以32字节(256位)的块读取。下图说明了这种结构以及合约内存的读/写功能。

此功能由在内存上运行的 3 个操作码决定。
MSTORE (x, y) - 从内存位置“x”开始存储一个 32 字节(256 位)值“y”
MLOAD (x) - 将内存位置“x”开始的 32 字节(256 位)加载到调用堆栈
MSTORE8 (x, y) - 在内存位置“x”(32 字节堆栈值的最低有效字节)存储一个 1 字节(8 位)值“y”。
从链接可以操作联系:
// MSTORE 32 bytes 0x11...1 at memory location 0
PUSH32 0x1111111111111111111111111111111111111111111111111111111111111111
PUSH1 0x00
MSTORE
// MSTORE8 1 byte 0x22 at memory location 32 (0x20 in hex)
PUSH1 0x22
PUSH1 0x20
MSTORE8
// MSTORE8 1 byte 0x33 at memory location 33 (0x21 in hex)
PUSH1 0x33
PUSH1 0x21
MSTORE8
// MLOAD 32 bytes to the call stack from memory location 0 ie 0-32 bytes of memory
PUSH1 0x00
MLOAD
// MLOAD 32 bytes to the call stack from memory location 32 ie 32-64 bytes of memory
PUSH1 0x20
MLOAD
// MLOAD 32 to the call stack from memory location 33 ie 33-65 bytes of memory
PUSH1 0x21
MLOAD

上面的字节码操作完成之后的结果,memory中按照字节索引去存储和读取。
当你的合约写入内存时,你必须为写入的字节数付费。如果您正在写入以前未写入过的内存区域,则首次使用它会产生额外的内存扩展成本。写入以前未触及的内存空间时,内存以 32 字节(256 位)的增量扩展。所以当存入1个字节时,也通过补0扩展了32个字节。前 724 字节的内存扩展成本呈线性增长,之后呈二次方增长。
内存是线性的,可以在字节级别寻址。当MLOAD 0x21时,它并不是从32倍数的位置开始寻址,而是从给定的具体位置。
内存只能在函数中新建。它可以是新实例化的复杂类型,如数组/结构(例如通过 new int[...]),也可以是从存储引用变量中复制的。
空闲内存指针只是指向空闲内存开始位置的指针。它确保智能合约跟踪哪些内存位置已被写入,哪些尚未写入。这可以防止合约覆盖一些已分配给另一个变量的内存。
当一个变量被写入内存时,合约将首先引用空闲内存指针来确定数据应该存储在哪里。
然后它通过记录要写入新位置的数据量来更新空闲内存指针。这两个值的简单相加将产生新空闲内存的起始位置。
60 80 = PUSH1 0x80
60 40 = PUSH1 0x40
52 = MSTORE
//在memory中的0x40位置存入0x80
Solidity 的内存布局预留了四个 32 字节的槽:
0x00 - 0x3f(64 字节):暂存空间(临时存储区,用于在语句之间存储临时数据,如内联汇编和某些哈希操作。这些数据在使用后通常会被丢弃)
0x40 - 0x5f(32 字节):空闲内存指针(当前可用的内存起始位置。合约在运行期间可以动态地分配更多内存。初始时,空闲内存指针设置为0x80,表示前0x80字节(128字节)已被预留)
0x60 - 0x7f(32 字节):零槽(包含32字节全零数据的特殊内存区域。动态内存数组在初始化时使用零槽作为其初始值。这个槽应始终保持全零,以确保其可用性)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.3;
contract MemoryLane {
function memoryLane() public pure {
bytes32[5] memory a;
bytes32[2] memory b;
b[0] = bytes32(uint256(1));
}
}
此链接可以查看字节码对应memory的变化情况:
在memory中的0x40位置存入0x80,实际上是补30个字节的0,再加0x80,占满32字节。

截图中一共96个字节。前64个字节是暂存空间,后32个字节是空闲内存指针。
内存分配bytes32[5]给变量“a”,编译器将通过数组大小和默认数组元素大小确定需要多少空间,Solidity 中内存数组中的元素总是占用 32 字节的倍数,5 * 32 会产生 160 或十六进制的 0xa0。0x80(128)+0xa0(160)=0x120(288)

到这里,实现了分配内存和更新空闲内存指针。接着需要为变量“a”初始化内存空间。由于变量只是声明而没有赋值,它将被初始化为零值。使用CALLDATACOPY,它接受 3 个变量
memoryOffset(将数据复制到哪个内存位置)
calldataOffset(要复制的调用数据中的字节偏移量)
大小(要复制的字节大小)

这里288个字节,这里的0x120(288)表示的是空闲内存指针的位置。当给“b”分配bytes[2]时,需要64个字节,288+64=352(0x160)

此时我们已经做好了“a”“b”内存分配的工作,那么给b[0]=1,就是在b的两个32字节的第一个存入补零的1。
https://programtheblockchain.com/posts/2018/03/09/understanding-ethereum-smart-contract-storage/ 这篇文章高度概括了storage的知识,其中包含动态变量。
storage是32个字节的key映射32个字节的value,key是256位,就有2^256-1种可能,那就可以有2^256个key的存储,所有的值都初始化为0,但是没有真的存储为0,因为没有那么多内存去存储,已知可观察宇宙中的原子数就是2^256个。这也是将存储值设置为零会退还一些 gas 的原因,因为网络上的节点不再需要存储该键值。
存储变量的合约变量可以分为固定大小和动态大小2个阵营。我们将重点关注固定大小的变量以及 EVM 如何将多个变量打包到一个 32 字节的存储槽中。
鉴于所有这些变量都是固定大小,EVM 可以使用保留的存储位置(key),从slot0(二进制值 0 的key)开始,线性向前移动到slot1、2 等。


在此示例中,slot0 将保存变量“value1”,变量“value2”是固定大小的 2 数组,因此将占用slot1 和 2,最后,slot3 将保存变量“value3”。

这个例子只使用了slot0。之前所有变量都是 uint256 类型,表示 32 个字节的数据。这里我们使用 uint32、uint64 和 uint128 分别表示 4、8 和 16 字节的数据,挨着排列刚好占据256,一个slot.
solidity 编译器知道它可以在一个存储槽中存储 32 个字节的数据。这样一来,当“uint32 value1”这个只占4个字节的存储在slot 0时,编译器读取下一个变量时会看是否可以将其打包到当前storage slot中。

先进去的位置靠右。
SSTORE
它从调用堆栈中获取一个 32 字节的key和一个 32 字节的value,并将该 32 字节的value存储在该 32 字节的key位置
SLOAD
接下来,我们有 SLOAD,它从调用堆栈中获取一个 32 字节的key,并将存储在该 32 字节key位置的 32 字节value推送到调用堆栈
如果 SSTORE 和 SLOAD 仅处理 32 字节值,您如何提取已打包到 32 字节槽中的变量。
如果您采用上面的示例,当我们在slot 0 上运行 SLOAD 时,我们将获得存储在该位置的完整 32 字节value。
该值将包括 value1、value2、value3 和 value4 的数据。 EVM 如何提取该 32 字slot中的特定字节以返回我们需要的值?
如果我们每次都存储 32 个字节,那么当我们运行 SSTORE 时也是如此,EVM 如何确保在我们存储 value2 时它不会覆盖 value1。当我们存储 value3 时,它不会覆盖 value2 等。
这些是我们接下来要回答的问题。
在同一个slot中,用到了AND、OR、NOT等位操作来避免覆盖和提取对应的值。
在这个链接中可以看到练习。
单个合约的存储如何适应以太坊链的更广泛的“世界状态”

从这张图可以看出,在另一篇文章中我们提到以太坊的世界状态,以及三种Merkle Patricia Tries,State Root,Transaction Root,Receipt Root从这张图可以看出,他们都是存储在区块中的。
https://etherscan.io/block/14698834
这是一个区块的例子。
Prev Hash - Keccak hash of the parent block(父块的 Keccak 哈希)
Nonce - Used in proof of work computation (用于工作计算证明)
Timestamp - Scale value of the output of UNIX time( ) (UNIX time( ) 输出的刻度值)
Uncles Hash - Keccak hash for uncled blocks (叔块的 Keccak 哈希)
Beneficiary - Beneficiary address, mining fee recipient (受益人地址,矿工费接收人)
LogsBloom - Bloom filter of two fields, log address & log topic in the receipts(两个字段的布隆过滤器,收据中的日志地址和日志主题)
Difficulty - Scalar value of the difficulty of the previous block (前一个区块难度的标量值)
Extra Data - 32 byte data relevant to this block (与此块相关的 32 字节数据)
Block Num - Scalar value of the number of ancestor blocks (祖先块数量的标量值)
Gas Limit - Scalar value of the current limit of gas usage per block (当前每个区块的 gas 使用限制的标量值)
Gas Used - Scalar value of the total gas spent on transactions in this block(此区块中交易花费的总 gas 的标量值)
Mix Hash - 256-bit value used with a nonce to prove proof of work computation(256 位值与 nonce 一起用于证明工作计算证明)
State Root - Keccak Hash of the root node of state trie (post-execution) (状态 trie 根节点的 Keccak Hash(执行后))
Transaction Root - Keccak Hash of the root node of transaction trie (交易trie根节点的Keccak Hash)
Receipt Root - Keccak Hash of the root node of receipt trie (收据trie根节点的Keccak Hash)

State Root就像一个 merkle 根,因为它是一个散列,它依赖于位于它下面的所有数据片段。如果任何数据发生变化,根也会发生变化。State Root下的数据结构是 Merkle Patricia Trie 树,它为网络上的每个以太坊账户存储一个键值对,其中键是以太坊地址,值是以太坊账户对象。
实际上,键是以太坊地址的哈希值,值是 RLP 编码的以太坊帐户,但我们现在可以忽略这一点。
以太坊账户是以太坊地址的共识表示。它由 4 个项目组成。
Nonce - 账户进行的交易数量
Balance - 微账户余额
Code Hash - 存储在合约/账户中的字节码的哈希值
Storage Root - 存储 trie 根节点的 Keccak Hash(执行后)
Storage Root很像State Root,因为它下面是另一个 Merkle Patricia trie.不同的是,这次key是storage slots,value是每个槽中的数据。值的 RLP 编码和键的哈希值。
和前面一样,Storage Root是一个 merkle 根哈希,如果任何底层数据(合约存储)发生变化,它将受到影响。
合约存储的任何变化都会影响Storage Root,而Storage Root又会影响State Root,而State Root又会影响Block Header。
StateAccount是“以太坊账户”的以太坊共识表示。
stateObject 表示正在修改的“以太坊帐户”。
StateDB是以太坊协议中的 StateDB 结构用于存储 merkle trie 中的任何内容。检索通用查询接口:Contracts & Ethereum Accounts

StateDB 结构,我们可以看到它有一个 stateObjects 字段,它是地址到 stateObjects 的映射(记住“State Root”Merkle Patricia Trie 是以太坊地址到以太坊账户的映射,而 stateObject 是一个正在被修改的以太坊账户) ,这意味着StateDB保存了所有活动的以太坊账户(即,那些在当前状态中具有状态更改的账户)的信息。StateRoot是以太坊全局状态的根哈希。
stateObject 结构,我们可以看到它有一个类型为 StateAccount 的数据字段(请记住,在本文前面我们将以太坊帐户映射到 Geth 中的 StateAccount)
StateAccount 结构体,我们已经见过这个结构体,它代表一个以太坊账户,Root 字段代表我们之前讨论的“Storage Root”
初始化一个新的以太坊账户 (StateAccount)
要创建一个新的 StateAccount,我们需要与 statedb.go 文件和 StateDB 结构进行交互。
StateDB 有一个 createObject 函数,它创建一个新的 stateObject 并将一个空白的 StateAccount 传递给它。这实际上是在创建一个空白的“以太坊账户”。
下图详细说明了代码流。

StateDB 有一个 createObject 函数,它接受一个以太坊地址并返回一个 stateObject(记住 stateObject 代表一个正在被修改的以太坊账户。)
createObject 函数调用传入 stateDB 的 newObject 函数,地址和一个空的 StateAccount(记住 StateAccount = 以太坊账户)它返回一个 stateObject
newObject函数的return语句,我们可以看到有多个字段与stateObject相关联,address,data,dirtyStorage等。
stateObject 数据字段映射到函数中的空 StateAccount 输入 - 注意第 103 - 111 行的 StateAccount 中的 nil 值被替换
返回包含初始化 StateAccount 作为数据字段的创建的 stateObject

我们从定义所有 EVM 操作码的 instructions.go 文件开始。在这个文件中,我们找到了“opSstore”函数。
传递给函数的范围变量包含合约上下文,如堆栈、内存等。我们从堆栈中弹出 2 个值,并将它们标记为 loc(location 的缩写)和 val(value 的缩写)。
从堆栈中弹出的 2 个值然后与与 StateDB 关联的 SetState 函数的合约地址一起用作输入。 SetState 函数使用合约地址来检查该合约是否存在 stateObject,如果不存在,它将创建一个。然后它调用 SetState 传递给 StateDB 数据库、键和值的 stateObject。
stateObject SetState 函数对假存储进行一些检查以及值是否已更改,然后运行日志追加。
如果您查看有关日志结构的代码注释,您会看到日志用于跟踪状态修改,以便在执行异常或请求撤销时可以将它们还原。
更新日志后,使用键和值调用 storageObject setState 函数。这会更新 storageObjects dirtyStorage。
SSTORE

dirtyStorage 在 stateObject 结构中定义,它是 Storage 类型,被描述为“在当前事务执行中已被修改的存储条目”
dirtyStorage对应的Storage类型是common.Hash到common.Hash的简单映射
Hash 类型只是一个长度为 HashLength 的字节数组
dirtyStorage 字段上方的 stateObject 中的 pendingStorage 和 originStorage。它们都是相关的,在终结期间,dirtyStorage 被复制到 pendingStorage,而 pendingStorage 又在 trie 更新时被复制到 originStorage。
更新 trie 后,StateAccount 的“存储根”也将在 StateDB“提交”期间更新。这会将新状态写入底层内存中的特里数据库
SLOAD

我们再次从 instructions.go 文件开始,我们可以在其中找到“opSload”函数。我们使用 peek 从堆栈顶部获取 SLOAD 的位置(存储槽)。
我们调用 StateDB 上的 GetState 函数,传入合约地址和存储位置。 GetState 获取与该合约地址关联的 stateObject。如果 stateObject 不为 nil,它会对该 stateObject 调用 GetState。
stateObject 上的 GetState 函数检查 fakeStorage,然后检查 dirtyStorage
如果 dirtyStorage 存在,则返回 dirtyStorage 映射中关键位置的值。 (dirtyStorage 代表合约的最新状态,这就是我们尝试首先返回它的原因)
否则调用 GetCommitedState 函数来查找存储 trie 中的值。再次检查 fakeStorage。
如果 pendingStorage 存在,则返回 pendingStorage 映射中关键位置的值。
如果以上都没有返回,请转到 originStorage 并从那里检索并返回值。
您会注意到该函数首先尝试返回 dirtyStorage,然后是 pendingStorage,然后是 originStorage。这是有道理的,在执行期间,dirtyStorage 是最新的存储映射,其次是 pending,然后是 originStorage。
一个事务可以多次操作一个存储槽,因此我们必须确保我们拥有最新的值。
让我们想象一个 SSTORE 发生在同一slot和同一transaction中的 SLOAD 之前。在这种情况下,dirtyStorage 将在 SSTORE 中更新,并在 SLOAD 中返回。
到此为止,您现在了解了如何在 Geth 客户端级别实施 SSTORE 和 SLOAD。它们如何与状态和存储对象交互,以及更新存储槽如何与更广泛的以太坊“世界状态”相关。
下面将介绍这些操作码如何在 solidity 级别、EVM 级别和 Geth 客户端级别工作
Execution Context
当 EVM 执行智能合约时,会为其创建上下文。上下文包括以下内容。
The Code
合约字节码是不可变的,它存储在链上并使用合约地址进行引用。
The Stack
调用堆栈,为每个 EVM 合约执行初始化一个空堆栈。
The Memory
合约内存,为每个 EVM 合约执行初始化一个干净的内存。
The Storage
跨执行持久保存的合约存储,它存储在链上,并通过合约地址及其存储槽进行引用。
The Call Data
交易的输入数据。
The Return Data
从合约函数调用返回的数据。
Solidity Example
下图显示了在同一个合约上执行两个函数调用,一个使用 DELEGATECALL,另一个使用 CALL。
我们将遍历两者并比较它们的不同之处。

从上面截图可以看出,调用选择DELEGATECALL时,修改的是A合约的变量,调用选择CALL时,修改的是B合约的变量
Delegate Call & Storage Layout
代码中的注释:NOTE: storage layout must be the same as contract A
编译后的字节码看不到这些变量,而是看到了存储槽。声明的状态变量被映射到存储槽,如果我们看一下 Contract B setVars(uint256),特别是“num = _num”,这表示将值 _num 存储到存储槽 0 中。当我们查看 DELEGATECALL 中涉及的合约时,不要考虑 num → num、sender → sender 的映射。这不是它在字节码级别的工作方式。我们需要考虑映射 slot 0 → slot 0,slot 1 → slot 1

如果我们通过切换第 6 行和第 8 行来更新合约 B,我们将首先声明“value”状态变量,最后声明“num”状态变量。
这意味着 setVars(uint256) 中的第 11 行“num = _num”现在会说将值 _num 存储到存储槽 2。第 13 行“value = msg.value”现在会说将 msg.value 存储在存储槽 0 中。
这意味着我们在合约 A 和 B 之间的变量映射将不再与它们的存储槽相匹配。
对于 DELEGATECALL,我们有以下输入变量:
gas:发送到子上下文以执行的gas量。子上下文未使用的gas返回给这个。
address:执行哪个上下文的帐户。
argsOffset:字节在内存中的字节偏移量,子上下文的calldata。
argsSize:要复制的字节大小(call da t的大小)。
retOffset:字节在内存中的偏移量,以字节为单位,存放子上下文的return data。
retSize:要复制的字节大小(return data的大小)。
CALL 具有完全相同的输入变量,多一个变量:
value:发送到账户的 wei 值。 (仅限call)
委托调用不需要输入值,因为它是从其父调用继承而来的。回想一下,我们曾提到执行上下文与其父调用具有相同的存储、msg.sender 和 msg.value。
两者都有一个输出变量“success”,如果子上下文恢复则为 0,否则返回 1。

在 Solidity 代码的第 24 行,对值为 12 的合约 B setVars(unit256) 进行了“委托调用”。这导致执行 DELEGATECALL 操作码。
DELEGATECALL 操作码有 6 个输入,gas、address、argsOffset、argsSize、retOffset 和 retSize,它们从堆栈中取出。
让我们关注 argsOffset 和 argsSize,它们是将传递给合约 B 的调用数据。这两个值命令我们转到内存位置 0xc4 并复制下一个 0x24(十进制为 36)字节以获取我们的调用数据。
这产生0x646414B000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000C,可以分为0x64666414b的SETVARS(uint256)函数签名,这是SETVARS(uint256)和0x000000000000000000000000000000000000000000000000000000000000000c 这是十进制的 12,代表我们的 num 输入值
该值映射到 Solidity 代码 abi.encodeWithSignature("setVars(uint256)", _num) 中第 25 行生成的值
retSize 等于 0,因为 setVars(uint256) 不返回任何内容。如果是,则 retSize 值将被更新并且返回值将存储在 retOffset 中。
我们在左侧标记了 DELEGATECALL 和 CALL 操作码,在右下角标记了 SLOAD 操作码。让我们看看它们是如何连接的。
注意图中有两个 [1]。这些是在 instructions.go 中找到的操作码 DELEGATECALL 和 CALL 的 Geth 函数。我们可以看到我们之前讨论的值从堆栈弹出到变量中。在函数的后面,我们看到 interpreter.evm.DeleagteCall 和 interpreter.evm.Call 被调用时带有堆栈外的值、“to address”和当前合约范围。
注意图中有两个 [2]。 evm.DelegateCall 和 evm.Call 函数都被执行(在 evm.go 中找到)。我省略了函数的部分以专注于 NewContract 函数调用,它正在创建一个新的合约上下文供我们执行。
注意图中有两个 [3]。 evm.DelegateCall 和 evm.Call 的 NewContract 函数调用非常相似,除了 2 项。
在 DelegateCall 中,value 参数设置为 nil,请记住它从其父上下文继承其值,因此不接受此参数。
NewContract 函数的第二个输入是不同的。在 evm.DelegateCall caller.Address( ) 中传入(合约 A 地址)。在 evm.Call 中传递的 addrCopy 等于来自 opCall 函数的 toAddr(合约 B 地址)。这个区别在后面会很重要。注意两者都是 AccountRef 类型。
DelegateCall 的 NewContract 将返回一个 Contract 结构。 AsDelegate( ) 函数被调用(在 contract.go 中找到)。它将 msg.sender 和 msg.value 设置为原始调用的值(EOA 地址和 1000000000000000000 Wei)。这不是在 Call 实现上完成的。
evm.DelegateCall 和 evm.Call 都执行 NewContract 函数(在 contract.go 中找到)。注意“object ContractRef”是 NewContract 的第二个输入变量,它映射到我们在 [3] 中讨论的 AccountRef
此“对象 ContractRef”与许多其他值一起用于初始化合同。 “对象 ContractRef”映射到 Contract 结构中的“self”。
Contract 结构(在 contract.go 中找到)有一个字段“self”,这是我们感兴趣的。您可以看到一些其他字段与我们之前在讨论合同执行上下文时讨论的项目相关。
我们现在跳转到 Geth 中的 SLOAD 操作码实现(在 instructions.go 中找到)。它在 scope.Contract.Address( ) 上运行 GetState。本声明中的“Contract”指的是[7]中的Contract结构体。
Contract 对象(在 contract.go 中找到)的 Address() 的实现。它依次调用 self.Address()。
Self 属于 ContractRef 类型,因此 ContractRef 类型必须具有 Address() 函数。
ContractRef 是一个接口(在 contract.go 中找到)并定义要成为 ContractRef,它必须实现一个返回 common.Address 的 Address() 函数(common.Address 被定义为长度为 20 的字节数组,长度以太坊地址)。
如果我们回顾第 [3] 节,我们讨论了 evm.DelegateCall 和 evm.Call 中不同的 AccountRef 值,这些值在 Contract 对象上变成了“self”。我们可以看到 AccountRef 实际上只是一个 common.Address 但它实现了一个 Address() 函数。因此,AccountRef 满足 ContractRef 接口要求。
AccountRef 的 Address() 函数只是将 AccountRef 转换为一个 common.Address,在我们的例子中,它是合约 A 的 evm.DelegateCall 地址和合约 B 的 evm.Call 地址。这意味着我们在 [8] 中查看的 SLOAD 操作码正在查看合约 A 的 DELEGATECALL 操作码存储和合约 B 的 CALL 操作码存储。
