区块链 101:智能合约背后的架构
- 原文链接:medium.com/@francomango...
- 译者:AI翻译官,校对:翻译小组
- 本文链接:learnblockchain.cn/article…
这是有关区块链的一系列文章的一部分。如果这是你看到的第一篇文章,我强烈建议从系列的开始开始阅读。
在我们讨论过的以太坊存储之后,现在是深入探讨现代区块链中最核心的主题之一:智能合约的时刻了。
正如我们之前在系列中提到的,这些是用户提交的程序,直接在区块链上运行——在以太坊的情况下,它们被存储在合约账户中。
不仅如此,它们还管理着一个独立的状态,这个状态只能通过合约本身定义的规则进行更改。
听起来很棒,对吧?然而,实现这样的功能面临着一系列挑战:我们如何建模一个可定制、灵活的状态?我们如何定义与合约相关联的操作——或者状态转换?
我们今天的计划是尝试解释以太坊如何回答这两个问题。它们的解决方案已经变得非常流行——以至于许多其他区块链努力与它们兼容。
你可能听说过 EVM 兼容的区块链。这正是它们的意思:它们以与以太坊相同的方式处理智能合约的状态和逻辑。
有趣的内容即将来临!
让我们从上一篇文章结束的地方继续。我们谈到了状态是如何存储在一个简单的键值数据库中,但它通过使用(修改过的)帕特里夏默克树得以巩固。
表示账户是一个相对简单的任务,因为完全定义一个账户所需的属性集是静态的。智能合约则不同——每个合约都定义了自己的状态,这意味着我们不再拥有一个静态的属性集。
因此,首先要做的就是制定一个机制来组织状态。换句话说:
我们需要一些规则来正确定义状态
大多数编程语言也有这些“规则”——它们就是我们所称的类型,或者像数组这样的内置构造。为了实现可编程性的承诺,智能合约也需要提供一些这些。
从本质上讲,我们只需要弄清楚三件事:
考虑到这一点,看看以太坊可以处理哪些类型。
在类型系统的核心,有一个非常基本的构建块:256 位字。
撇去说唱笑话,计算机科学中的一个 字是计算机处理器在单个操作中能够处理的数据基本单位。它通常也是可以适配到一个寄存器中的最大数据量。
但我们这里并不涉及物理计算机。我们为什么要关心物理硬件的架构决策?
我们的智能合约本质上是计算机程序,因此我们需要设计一个可以执行它们的架构。
如果我们可以构建出与计算机完全相同的软件,并且能在任何硬件上运行智能合约,那么无论它在哪里执行,给定相同的输入,结果都会一致。
从这个意义上说,以太坊就像一个巨大的分布式计算机——虚拟机。这就是 EVM 代表的意思:以太坊虚拟机!
决定字大小在所述虚拟机的设计中是重要的。
选择256位与存储大数字(如余额)和地址的必要性很好地对齐。它还与以太坊中使用的 哈希函数:Keccak-256相一致。我们稍后会详细讨论这个选择。
这个选择也导致了一系列基于该字大小的原始类型。
我在脑海中想到的“原始类型”
这些原始类型包括:
我们可以使用这些来定义变量。它们将表示智能合约状态的一部分——这意味着它们需要存储在合约的帕特里夏默克树中。
到目前为止,撰写 EVM 智能合约的最流行的领域特定语言(DSL)是 Solidity。还有一些替代方案,比如 Vyper——但我将出于舒适理由选择 Solidity 作为我们接下来的示例。
这是一个简单合约的片段:
contract SimpleStorage {
uint256 first;
address second;
bool third;
// ...
}
如你所见,我们定义了一些变量(first,second和third),每个变量都被类型化为原始类型。
类型对于正确评估操作至关重要,但要记住,这些仅仅是 256 位大小的值。
我们需要从中实际构建一个树结构,这意味着每一个变量都需要与树中的一条路径关联。并且这个计算需要是确定性和可重复的。
策略是使用插槽。每个插槽的大小与我们的字相同(256位),并由一个256位的键标识。在我们简单的情况下,插槽是顺序分配的,从0开始。因此:
contract SimpleStorage {
uint256 first; // 这个占用插槽 0
address second; // 这个占用插槽 1
bool third; // 这个占用插槽 2
// ...
}
这些标识符实际上将成为每个存储空间在帕特里夏默克树中的键。
简单,对吧?
原始类型占用单个插槽,但在提供的可能性上有限。它们很重要,但也许不是很有趣。
许多时候,我们需要能够存储数据集合,或者将数据组织成更复杂的结构。为此,以太坊提供了几种更多类型:
现在,这些是如何存储的?
固定大小的数组很容易处理。由于长度是预先已知的(并且是静态的),我们可以简单地使用连续Slot。例如:
contract FixedSizeArrayExample {
uint256[3] fixedArray; // 占用Slot 0、1 和 2
uint256 otherVar; // 占用Slot 3
}
但是动态数组是另一种情况——我们无法使用连续Slot,因为那样的话,当数组增长时,会覆盖其他变量的Slot。需要采取不同的策略。
以太坊的解决方案非常聪明:
keccak256(slot) + index
正如之前提到的,Keccak256 是以太坊的哈希函数。其输出恰好是256 位长。
听起来很熟悉?当然——它的长度是我们的字长!同时,它也是一个有效的帕特里夏默克尔树路径。
通过使用哈希函数,我们为该过程引入了一些随机性,减少了数组项路径与其他现有变量路径冲突的概率。这真是太酷了!
Solidity 也有字符串,实际上是动态字节数组。这意味着它们遵循与动态数组相同的规则:长度存储在主槽中,实际数据存储在 keccak256(slot) 中。
这些在这里没有那么清晰的顺序——键可以是任何256位长的序列。这意味着我们之前找到起始点(哈希)然后添加索引的策略是行不通的。采用稍微不同的方法。
就像动态数组一样,映射也被分配了一个槽号。
contract MappingExample {
mapping(address => uint256) balances; // 这将占用Slot 0。
}
然后,对于映射中的任何键,我们通过简单地将其与槽号连接(||
)后进行哈希,找到其帕特里夏默克尔路径:keccak256(key || slot)
。
通过这样做,每个存储键(在树中)是确定性的,但同时不太可能与其他位置冲突。我们也可以允许读取不存在的键——我们得到的只是一个空值(零)。
映射也可以嵌套。键的计算是正常的,对于最内层的映射,然后我们使用此值替换下一个映射的槽。非常简单且有效!
最后,我们有结构,它看起来像这样:
contract StructExample {
struct User {
uint256 id;
address wallet;
}
User owner;
}
结构类型的变量占用的Slot数量与结构中的键数量相同。在上面的示例中,owner.id
将占用Slot 0,而 owner.wallet
将占用Slot 1。
所有这些类型可以组合成不同的模式,之前描述的规则将适用于计算帕特里夏默克尔路径。例如:
contract CompositionExample {
struct Person {
string name;
uint256 age;
address wallet;
}
mapping(address => Person[]) people;
}
你能找出
people[address][0].name
存储在哪里吗?我将这个作为练习留给你!
太棒了!我们有一个丰富的系统来表示简单和复杂类型,并计算它们在存储树中的路径。这实现了计算状态根的能力,它将合约的整个状态浓缩为一个单一的值。
然而,如果我们不能编写规则来确定状态的变化,合约将不会那么令人兴奋。没有这一点,我们只会有一种非常复杂的方式来存储一些无聊的静态数据。
回想一下,在讨论存储之前,我们提到以太坊就像一台巨大的分布式计算机。就像真实的计算机一样,它需要一种方法来表示程序,以便它理解和执行。
程序,实际上,只是指令序列。而我们的虚拟机理解的语言以字节表示。因此,你可能听说过的 Fancy 名称就是字节码。
我们的 Solidity 智能合约将编译为这种字节码,当然它不是为人类所理解的。例如,这里有一个非常简单的合约:
pragma solidity ^0.8.20;
contract Counter {
uint256 count = 0;
function increment() public {
count = count + 1;
}
}
编译后,我们得到这个字节码:
0x60806040525f5f553480156011575f5ffd5b50609b80601d5f395ff3fe6080604052348015600e575f5ffd5b50600436106026575f3560e01c8063d09de08a14602a575b5f5ffd5b60306032565b005b5f54603d9060016041565b5f55565b80820180821115605f57634e487b7160e01b5f52601160045260245ffd5b9291505056fea2646970667358221220e272cd3048050835d3aef9a668053e3787a187208e24b8d32376b227e8a3fb7c64736f6c634300081c0033
幸运的是,我们不需要能够阅读这段代码,因为它是为虚拟机执行而设计的。然而,理解它的构建块是重要的。
字节码分为三个部分,每个部分有不同的用途:
increment
函数时,应用该函数的逻辑就是在这里。我们稍后会对此进行更详细的讨论。如果你仔细观察,有一小块指令在示例字节码中重复:6080604052。我将留给你自己的好奇心和研究去理解为什么会发生这种情况!
酷!我们理解了字节码的一般结构。不过它是如何工作的呢?让我们专注于运行时代码,深入到我们一直在讨论的指令中。
字节码由称为操作码的指令组成,简称opcode。每个操作码由一个字节表示——这意味着我们最多可以有2⁸ = 256 种不同的指令。
你可以在这里找到完整的操作码列表。请注意,它们也有相关的 gas 成本。
这些操作码涵盖了各种指令,例如逻辑与(16
)操作,或者用于计算哈希的KECCAK256(20
)代码。其中一些,如ADD(01
),使用栈。
栈!
EVM 实际上是一个栈机器,这意味着它跟踪一个栈以执行操作。栈 是一种数据结构,遵循后进先出(LIFO)原则——最后进入的元素将是第一个被处理的元素。
把它想象成一叠盘子:你只能将盘子放在顶部(推入)或从顶部移除盘子(弹出)。从底部移除虽然是可能的,但可能会导致混乱!
以正确的顺序组合操作码允许我们构建程序。自然地,我们通常不会手动这样做,而是使用 Solidity 提供的更高的抽象层次。然后我们将合约编译成字节码。
一个合约可以有许多 函数。我们似乎唯一缺少的就是如何在字节码中识别这些函数,换句话说,我们需要能够知道一个函数从哪里开始,以及在哪里结束。
为此,函数通过 选择器 进行识别。这些选择器来源于函数 签名:可以说是它们的“文本表示”。
例如,我们的增量函数的签名就是
increment()
,而带有参数的函数可能看起来像transfer(address, uint256)
。
字节码中函数的 选择器(或 标识符),就是该 签名哈希 的前 4 个字节。
再次以我们的例子为例,签名是
increment()
,其 keccak256 值是d09de08ab1a874aadf0a76e6f99a2ec20e431f22bbc101a6c3f718e53646ed8dand
,如果我们只取前四个字节,那么选择器就是d09de08a
。看看你能否在之前的字节码中找到它!
为了执行一个函数,与合约通信的用户必须发送合适的函数选择器。然后 EVM 需要将这个选择器与字节码中的已知选择器进行比较——在我们的例子中,就是 d09de08a
。这也是通过操作码完成的:在字节码中的选择器后面,我们可以看到:
14
),它弹出栈顶的两个值,并进行比较。如果匹配,则推送一个 1,否则推送一个 0。60
),其值为 2a
(实际上是 42)。这将该值推送到栈中。57
),这是一个条件跳转。它从栈中弹出两个值,第一个是 目标,第二个是 条件。如果条件为真(用 1 表示),那么我们就跳转到指定的条件——如果不是,我们就继续执行下一个指令。这可能感觉有些过于复杂,但这实际上就是计算机程序的工作方式!
以栈的形式思考对我来说并不是那么自然。如果你也有同样的感觉,请记住——你通常不需要深入到 这样的 深度,除非在非常特定的用例中。
最后,一个函数在达到 STOP(00
)或 RETURN(F3
)操作码时停止执行。
就这样,我们已经涵盖了字节码背后大多数重要的概念!
就这样!这些就是智能合约的内部运行机制。还不错,不是吗?
如果你之前对计算机程序的工作原理有所了解,这对你来说可能并不陌生。但如果这是你第一次看到这些,我想这可能会让人感到有些多。慢慢来!
好消息是,正如我在文章中多次暗示的那样,我们通常不需要 这么多参与——就像任何其他编程语言一样,Solidity 将许多这些复杂性抽象成更美观、更易处理的格式。
当然,你需要学习 Solidity 来编写智能合约。我们可能会在未来进行讨论。
但至少,你不会盲目使用它:现在你对后台发生的事情有了一个概念。有了这个,世界就是你的舞台。去吧,老虎。
虽然慢慢来,但事情正在逐渐变得清晰。我们已经讨论了如何处理存储和用于检查数据一致性的数据结构,现在我们知道了智能合约是如何工作的。
我们可以用智能合约做什么?下次,我们将深入探讨一些可以用它们构建的常见程序。知道可能发生的事情是智能合约开发的一个良好开端,可能会激励你构建惊人的应用程序。再见!
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!