区块链 101:智能合约背后的架构

区块链 101:智能合约背后的架构

这是有关区块链的一系列文章的一部分。如果这是你看到的第一篇文章,我强烈建议从系列的开始开始阅读。

在我们讨论过的以太坊存储之后,现在是深入探讨现代区块链中最核心的主题之一:智能合约的时刻了。

正如我们之前在系列中提到的,这些是用户提交的程序,直接在区块链上运行——在以太坊的情况下,它们被存储在合约账户中。

不仅如此,它们还管理着一个独立的状态,这个状态只能通过合约本身定义的规则进行更改。

听起来很棒,对吧?然而,实现这样的功能面临着一系列挑战:我们如何建模一个可定制、灵活的状态?我们如何定义与合约相关联的操作——或者状态转换?

我们今天的计划是尝试解释以太坊如何回答这两个问题。它们的解决方案已经变得非常流行——以至于许多其他区块链努力与它们兼容。

你可能听说过 EVM 兼容的区块链。这正是它们的意思:它们以与以太坊相同的方式处理智能合约的状态和逻辑。

有趣的内容即将来临!

状态建模

让我们从上一篇文章结束的地方继续。我们谈到了状态是如何存储在一个简单的键值数据库中,但它通过使用(修改过的)帕特里夏默克树得以巩固。

表示账户是一个相对简单的任务,因为完全定义一个账户所需的属性集是静态的。智能合约则不同——每个合约都定义了自己的状态,这意味着我们不再拥有一个静态的属性集。

因此,首先要做的就是制定一个机制来组织状态。换句话说:

我们需要一些规则来正确定义状态

大多数编程语言也有这些“规则”——它们就是我们所称的类型,或者像数组这样的内置构造。为了实现可编程性的承诺,智能合约也需要提供一些这些。

从本质上讲,我们只需要弄清楚三件事:

  • 我们想支持哪些类型
  • 我们将如何在键值存储中存储这些信息
  • 一种确定性计算标识符的策略,用于将数据放置在帕特里夏默克树中

考虑到这一点,看看以太坊可以处理哪些类型。

原始类型

在类型系统的核心,有一个非常基本的构建块:256 位字。

撇去说唱笑话,计算机科学中的一个 是计算机处理器在单个操作中能够处理的数据基本单位。它通常也是可以适配到一个寄存器中的最大数据量。

但我们这里并不涉及物理计算机。我们为什么要关心物理硬件的架构决策?

我们的智能合约本质上是计算机程序,因此我们需要设计一个可以执行它们的架构。

如果我们可以构建出与计算机完全相同的软件,并且能在任何硬件上运行智能合约,那么无论它在哪里执行,给定相同的输入,结果都会一致。

从这个意义上说,以太坊就像一个巨大的分布式计算机——虚拟机。这就是 EVM 代表的意思:以太坊虚拟机!

决定字大小在所述虚拟机的设计中是重要的。

选择256位与存储大数字(如余额)和地址的必要性很好地对齐。它还与以太坊中使用的 哈希函数Keccak-256相一致。我们稍后会详细讨论这个选择。

这个选择也导致了一系列基于该字大小的原始类型

我在脑海中想到的“原始类型”

这些原始类型包括:

  • 整数:我们可以在可用的256位中存储有符号无符号整数。有符号整数使用一个位来指定符号,而无符号整数总是正值。我们可以表示的最大整数是2²⁵⁶ - 1。超出该范围的值将会有麻烦,并需要巧妙的解决方法。
  • 地址:由于地址在以太坊中占用160位(20字节),它们可以适配到一个字中!
  • 布尔值:一个10,分别表示。它们占用一个完整的字,尽管只使用1位的空间。
  • 字节:简单的字节值,没有明确的含义。

我们可以使用这些来定义变量。它们将表示智能合约状态的一部分——这意味着它们需要存储在合约的帕特里夏默克树中。

决定树位置

到目前为止,撰写 EVM 智能合约的最流行的领域特定语言(DSL)是 Solidity。还有一些替代方案,比如 Vyper——但我将出于舒适理由选择 Solidity 作为我们接下来的示例。

这是一个简单合约的片段:

contract SimpleStorage {  
    uint256 first;  
    address second;  
    bool third;  

    // ...  
}

如你所见,我们定义了一些变量(firstsecondthird),每个变量都被类型化为原始类型。

类型对于正确评估操作至关重要,但要记住,这些仅仅是 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

幸运的是,我们不需要能够阅读这段代码,因为它是为虚拟机执行而设计的。然而,理解它的构建块是重要的。

字节码分为三个部分,每个部分有不同的用途:

  • 创建代码:字节码的第一部分旨在仅运行一次,当合约被部署(创建)时。它是一个设置步骤——它处理变量的Slot分配,并在初始化期间存储必要的信息。重要的是,它返回下一部分,即…
  • 运行时代码:合约的实际逻辑,存储在合约的地址上。当有人调用 increment 函数时,应用该函数的逻辑就是在这里。我们稍后会对此进行更详细的讨论。
  • 元数据:最后,还有一个额外的部分,包含一些可能对不同目的有用的元数据。今天我们不关注该部分。

如果你仔细观察,有一小块指令在示例字节码中重复:6080604052。我将留给你自己的好奇心和研究去理解为什么会发生这种情况!

酷!我们理解了字节码的一般结构。不过它是如何工作的呢?让我们专注于运行时代码,深入到我们一直在讨论的指令中。

指令集

字节码由称为操作码的指令组成,简称opcode。每个操作码由一个字节表示——这意味着我们最多可以有2⁸ = 256 种不同的指令。

你可以在这里找到完整的操作码列表。请注意,它们也有相关的 gas 成本。

这些操作码涵盖了各种指令,例如逻辑16)操作,或者用于计算哈希的KECCAK25620)代码。其中一些,如ADD01),使用

栈!

EVM 实际上是一个栈机器,这意味着它跟踪一个栈以执行操作。 是一种数据结构,遵循后进先出(LIFO)原则——最后进入的元素将是第一个被处理的元素。

把它想象成一叠盘子:你只能将盘子放在顶部(推入)或从顶部移除盘子(弹出)。从底部移除虽然是可能的,但可能会导致混乱!

函数

以正确的顺序组合操作码允许我们构建程序。自然地,我们通常不会手动这样做,而是使用 Solidity 提供的更高的抽象层次。然后我们将合约编译成字节码。

一个合约可以有许多 函数。我们似乎唯一缺少的就是如何在字节码中识别这些函数,换句话说,我们需要能够知道一个函数从哪里开始,以及在哪里结束。

为此,函数通过 选择器 进行识别。这些选择器来源于函数 签名:可以说是它们的“文本表示”。

例如,我们的增量函数的签名就是 increment(),而带有参数的函数可能看起来像 transfer(address, uint256)

字节码中函数的 选择器(或 标识符),就是该 签名哈希 的前 4 个字节。

再次以我们的例子为例,签名是 increment(),其 keccak256 值是 d09de08ab1a874aadf0a76e6f99a2ec20e431f22bbc101a6c3f718e53646ed8dand,如果我们只取前四个字节,那么选择器就是 d09de08a。看看你能否在之前的字节码中找到它!

为了执行一个函数,与合约通信的用户必须发送合适的函数选择器。然后 EVM 需要将这个选择器与字节码中的已知选择器进行比较——在我们的例子中,就是 d09de08a。这也是通过操作码完成的:在字节码中的选择器后面,我们可以看到:

  • 有一个 EQ 操作(14),它弹出栈顶的两个值,并进行比较。如果匹配,则推送一个 1,否则推送一个 0
  • 然后,有一个 PUSH1 操作码(60),其值为 2a(实际上是 42)。这将该值推送到栈中。
  • 最后,我们找到了一个 JUMPI 指令(57),这是一个条件跳转。它从栈中弹出两个值,第一个是 目标,第二个是 条件。如果条件为真(用 1 表示),那么我们就跳转到指定的条件——如果不是,我们就继续执行下一个指令。

这可能感觉有些过于复杂,但这实际上就是计算机程序的工作方式!

以栈的形式思考对我来说并不是那么自然。如果你也有同样的感觉,请记住——你通常不需要深入到 这样的 深度,除非在非常特定的用例中。

最后,一个函数在达到 STOP(00)或 RETURN(F3)操作码时停止执行。

就这样,我们已经涵盖了字节码背后大多数重要的概念!

摘要

就这样!这些就是智能合约的内部运行机制。还不错,不是吗?

如果你之前对计算机程序的工作原理有所了解,这对你来说可能并不陌生。但如果这是你第一次看到这些,我想这可能会让人感到有些多。慢慢来!

好消息是,正如我在文章中多次暗示的那样,我们通常不需要 这么多参与——就像任何其他编程语言一样,Solidity 将许多这些复杂性抽象成更美观、更易处理的格式。

当然,你需要学习 Solidity 来编写智能合约。我们可能会在未来进行讨论。

但至少,你不会盲目使用它:现在你对后台发生的事情有了一个概念。有了这个,世界就是你的舞台。去吧,老虎。

虽然慢慢来,但事情正在逐渐变得清晰。我们已经讨论了如何处理存储和用于检查数据一致性的数据结构,现在我们知道了智能合约是如何工作的。

我们可以用智能合约做什么?下次,我们将深入探讨一些可以用它们构建的常见程序。知道可能发生的事情是智能合约开发的一个良好开端,可能会激励你构建惊人的应用程序。再见!

我是 AI 翻译官,为大家转译优秀英文文章,如有翻译不通的地方,在这里修改,还请包涵~

点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
Frank Mangone
Frank Mangone
Software developer based in Uruguay. Math and Cryptography enthusiast.