TVM虚拟机白皮书翻译中文文档(上)

提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档


前言

本文是作者对TVM虚拟机文档的精翻,我将编写简单的markdown文档,将一些注释通过括号[ ]表示,并结合自己的理解来做一些扩充,之所以选择CSDN平台,是因为这里的中国用户人口基数大,网站不需要代理即可访问,其次希望自己的热爱和分享可以为TON提供支持。

后续本人建立关于ton fift 拓展开发,fiftbase文档,以及程序设计的相关课程

wechat:ZxmLrZqyCxy999

tg:t.me/fift_develop

本文翻译为2020年,tvm白皮书

(Telegram Open Network Virtual Machine)
原作者: 尼古拉·杜罗夫 2020年3月23日
翻译 : krasha

摘要
本文旨在描述在TON区块链中执行智能合约的Telegram Open Network Virtual Machine(TON VM或TVM)。

引言
Telegram Open Network Virtual Machine(TON VM或TVM)的主要目的是在TON区块链中执行智能合约代码。TVM必须支持解析传入消息和持久数据以及创建新消息和修改持久数据所需的所有操作。
此外,TVM必须满足以下要求:

  • 它必须提供可能的未来扩展和改进,同时保持向后兼容性和互操作性,因为一旦智能合约的代码提交到区块链中,无论VM未来如何修改,它都必须以可预测的方式继续工作。
  • 它必须努力实现高“(虚拟)机器代码”密度,以便典型的智能合约代码占用尽可能少的持久区块链存储。
  • 它必须是完全确定性的。换句话说,相同的代码与相同的输入数据的每次运行都必须产生相同的结果,无论使用的具体软件和硬件如何。[例如,TVM中没有浮点算术运算(这些运算在大多数现代CPU上可以通过硬件支持的双精度类型高效实现),因为执行此类运算的结果取决于特定的底层硬件实现和舍入模式设置。相反,TVM支持特殊的整数算术运算,如果需要,可以用来模拟定点算术。]

这些要求指导了TVM的设计。虽然本文档描述的是TVM的初步和实验版本,但系统中内置的向后兼容性机制使我们对TVM代码在这一初步版本中使用的运算编码效率相对不太关心。·生产版本在发布前可能需要一些调整和修改,这些只有在在测试环境中使用实验版本一段时间后才会变得明显]
TVM不打算在硬件中实现(例如,在专用微处理器芯片中);相反,它应该在传统硬件上运行的软件中实现。这种考虑让我们可以在TVM中纳入一些高级概念和操作,这些在硬件实现中将需要复杂的微代码,但在软件实现中不会带来任何重大问题。这些操作对于实现高代码密度和最小化智能合约代码在TON区块链中部署时的字节(或存储单元)轮廓非常有用。

1 概述

本章提供了TVM的主要特点和设计原则的概述。后续章节将提供每个主题的更多细节。

1.0 位串的表示法

本文档中使用了以下表示法来表示位串(或位字符串)——即由二进制数字(位)0和1组成的有限字符串。

1.0.1 位串的十六进制表示法
当位串的长度是4的倍数时,我们将其分成每组四位,并用十六个十六进制数字0-9,A-F中的一个来表示每组:

例如:016 ↔ 0000,116 ↔ 0001,…,F16 ↔ 1111。得到的十六进制字符串是原始二进制字符串的等效表示。

1.0.2 长度不能被4整除的位串
如果二进制字符串的长度不能被4整除,我们通过在末尾增加一个1和若干(可能为零)0,使其长度能被4整除,然后按照上述方法将其转换为十六进制数字字符串。为了表示进行了这样的转换,我们在十六进制字符串的末尾添加一个特殊的“完成标记”_。反向转换(如果存在完成标记,则应用)包括首先将每个十六进制数字替换为相应的四个位,然后移除所有尾随的零(如果有的话)和它们前面的最后一个1(如果此时得到的位串非空)。

请注意,同一个位串可以有几种可接受的十六进制表示法。其中最短的一个被称为“规范”的。它可以通过上述程序确定性地获得。

例如,8A对应于二进制字符串10001010,而8A_和8A0_都对应于100010。空位串可以由‘’,‘8_’,‘0_’,‘’或‘00’表示。

1.0.3强调字符串是位串的十六进制表示
有时我们需要强调一串十六进制数字(末尾是否有_)是位串的十六进制表示。在这种情况下,我们要么在结果字符串前添加x(例如,x8A),要么在结果字符串前添加x{并在末尾添加}(例如,x{2D9_},即00101101100)。

1.0.4 将位串序列化为八位字节序列
当需要将位串表示为8位字节(octets)的序列时,这些字节的取值范围是0到255的整数,这基本上与上述方法相同:我们将位串分成每组八位,并将其解释为0到255的整数的二进制表示。如果位串的长度不是8的倍数,则在分成组之前,位串会通过增加一个二进制1和多达七个二进制0来补充。这种补充通常通过一个“完成标记”位来反映。

例如,00101101100对应于两个字节的序列(0x2d, 0x90)(十六进制),或(45, 144)(十进制),以及一个完成标记位等于1(意味着已应用补充),这必须单独存储。

在某些情况下,假设默认启用了补充,而不是单独存储一个额外的完成标记位更为方便。在这种约定下,n位的位串由n + 1个字节表示,最后一个字节总是等于0x80 = 128。

1.1 TVM是一个栈机

首先,TVM是一个栈机。这意味着,与在某些“变量”或“通用寄存器”中保持值不同,它们是从“低级”(TVM)的角度来看,至少是在一个(后进先出)栈中保持的。[高级智能合约语言可能为编程方便而创建变量的可见性;然而,使用变量的高级源代码将被翻译成保持所有这些变量值在TVM栈中的TVM机器代码。]

大多数操作和用户定义的函数都从栈的顶部获取参数,并用它们的结果替换它们。例如,整数加法原语(内置操作)ADD不接收任何参数来描述应该将哪些寄存器或立即值相加以及应该在哪里存储结果。相反,从栈中取出两个顶部的值,将它们相加,然后将它们的和推入栈中以取代它们的位置。

1.1.1 TVM值
可以存储在TVM栈中的实体将被称为TVM值,或简称为值。它们属于几种预定义值类型之一。每个值恰好属于一个值类型。值总是与唯一确定其类型的标签一起保持在栈上,所有内置的TVM操作(或原语)仅接受预定义类型的值。

例如,整数加法原语ADD仅接受两个整数值,并返回一个整数值作为结果。不能提供ADD两个字符串而不是两个整数,期望它连接这些字符串或将字符串隐式转换为它们的十进制整数值;任何这样做的尝试都将导致运行时类型检查异常。

1.1.2 静态类型、动态类型和运行时类型检查
在某些方面,TVM执行一种使用运行时类型检查的动态类型。然而,这并不使TVM代码成为像PHP或JavaScript这样的“动态类型语言”,因为所有原语接受值并返回预定义(值)类型的结果,每个值严格属于一个类型,并且值永远不会隐式地从一种类型转换为另一种类型。

另一方面,如果将TVM代码与常规微处理器机器代码进行比较,就会发现TVM的值标记机制可以防止例如使用字符串的地址作为数字——或者更灾难性地,使用数字作为字符串的地址——从而消除了与无效内存访问相关的各种错误和安全漏洞的可能性,这些通常会导致内存损坏和段错误。对于在区块链中执行智能合约的虚拟机来说,这个属性是非常理想的。在这方面,TVM坚持用适当的类型标记所有值,而不是根据操作的需要重新解释寄存器中的位序列,只是一个额外的运行时类型安全机制。

另一种选择是,在允许在虚拟机中执行智能合约代码之前,或甚至在允许将其上传到区块链作为智能合约代码之前,以某种方式分析智能合约代码的类型正确性和类型安全性。对于图灵完备机器的代码进行静态分析似乎是一个耗时且非平凡的问题(可能相当于图灵机的停机问题),这是我们在区块链智能合约环境中宁愿避免的。

应当记住,人们总是可以从静态类型的高级智能合约语言实现编译器到TVM代码(并且我们期望大多数TON智能合约将使用此类语言编写),就像可以将静态类型语言编译成常规机器代码(例如,x86架构)。如果编译器工作正常,生成的机器代码将永远不会产生任何运行时类型检查异常。所有由TVM处理的值附加的类型标签将始终具有预期的值,并且在分析生成的TVM代码时可以安全地忽略,除了TVM运行时生成和验证这些类型标签会稍微减慢TVM代码的执行。

1.1.3 值类型的初步列表
TVM支持的值类型的初步列表如下:

  • Integer — 有符号257位整数,表示-2256到2256-1范围内的整数,以及一个特殊的“非数字”值NaN。

  • Cell — 一个TVM单元包含最多1023位数据,以及最多四个对其他单元的引用。TON区块链中的所有持久数据(包括TVM代码)都表示为一组TVM单元(参见[1, 2.5.14])。

  • Tuple — 一个有序集合,最多包含255个组件,具有任意值类型,可能是不同的。可以用来表示任意代数数据类型的非持久值。

  • Null — 一个类型,恰好有一个值⊥,用于表示空列表、二叉树的空分支、某些情况下的缺省返回值等。

  • Slice — 一个TVM单元片段,或简称片段,是一个现有单元的连续“子单元”,包含它的一些数据位和一些引用。本质上,片段是单元的一个子单元的只读视图。片段用于解包先前存储(或序列化)在单元或单元树中的数据。

  • Builder — 一个TVM单元构建器,或简称构建器,是一个“不完整”的单元,支持在其末端快速追加位字符串和单元引用的操作。构建器用于将栈顶的数据打包(或序列化)到新单元中(例如,在将它们传输到持久存储之前)。

  • Continuation — 代表TVM的“执行令牌”,可能稍后被调用(执行)。因此,它泛化了函数地址(即,函数指针和引用)、子程序返回地址、指令指针地址、异常处理程序地址、闭包、部分应用、匿名函数等。

这个值类型的列表是不完整的,可能会在未来的TVM修订中扩展,而不会破坏旧的TVM代码,这主要是因为所有最初定义的原语只接受它们已知类型的值,如果在新类型的值上调用它们将会失败(生成一个类型检查异常)。此外,现有的值类型本身也可以在未来扩展:例如,257位整数可能会变成513位长整数,如果参数或结果不适合原始子类型整数,最初定义的算术原语将会失败。关于引入新值类型和扩展现有值类型的向后兼容性将在稍后更详细地讨论(参见5.1.4)。

1.2 TVM指令的类别

TVM指令,也称为原语,有时称为(内置)操作,是TVM原子执行的最小操作,可以出现在TVM代码中。根据它们处理的值的类型(参见1.1.3),它们分为几个类别。其中最重要的类别有:

  • 栈(操作)原语 — 在TVM栈中重新排列数据,以便其他原语和用户定义的函数可以稍后用正确的参数调用。与大多数其他原语不同,它们是多态的,即,可以与任意类型的值一起工作。
  • 元组(操作)原语 — 构造、修改和分解元组。与栈原语类似,它们是多态的。
  • 常量或文字原语 — 将一些嵌入到TVM代码本身中的“常量”或“文字”值推入栈中,从而为其他原语提供参数。它们与栈原语有些相似,但不那么通用,因为它们与特定类型的值一起工作。
  • 算术原语 — 对整数类型的值执行通常的整数算术运算。
  • 单元(操作)原语 — 创建新的单元并在其中存储数据(单元创建原语)或从先前创建的单元中读取数据(单元解析原语)。由于TVM的所有内存和持久存储由单元组成,这些单元操作原语实际上对应于其他架构的“内存访问指令”。单元创建原语通常与构建器类型的值一起工作,而单元解析原语与片段一起工作。
  • Continuation和控制流原语 — 创建和修改Continuation,以及以不同方式执行现有的Continuation,包括条件和重复执行。
  • 自定义或应用程序特定的原语 — 高效地执行应用程序(在我们的情况下,TON区块链)所需的特定高级操作,如计算哈希函数、执行椭圆曲线密码学、发送新的区块链消息、创建新的智能合约等。这些原语对应于标准库函数而不是微处理器指令。

1.3 控制寄存器

虽然TVM是栈机,但在几乎所有函数中都需要一些很少改变的值,最好通过某些特殊寄存器传递,而不是靠近栈顶。否则,将需要大量的栈重新排序操作来管理所有这些值。

为此,TVM模型包括除了栈之外的多达16个特殊控制寄存器,用c0到c15或c(0)到c(15)表示。TVM的原始版本只使用其中的一些寄存器;其余的可能会在以后支持。

1.3.1 控制寄存器中保持的值
控制寄存器中保持的值与栈中保持的值类型相同。然而,一些控制寄存器只接受特定类型的值,任何尝试加载不同类型值的操作都会导致异常。

1.3.2 控制寄存器列表
TVM的原始版本定义并使用了以下控制寄存器:

  • c0 — 包含下一个Continuation或返回Continuation(类似于传统设计中的子程序返回地址)。这个值必须是一个Continuation。
  • c1 — 包含替代(返回)Continuation;这个值必须是一个Continuation。它在一些(实验性的)控制流原语中使用,允许TVM定义和调用“具有两个退出点的子程序”。
  • c2 — 包含异常处理程序。这个值是一个Continuation,在每次触发异常时被调用。
  • c3 — 包含当前字典,本质上是一个包含程序中所有函数代码的哈希映射。由于后面4.6节解释的原因,这个值也是一个Continuation,而不是人们可能期望的Cell。
  • c4 — 包含持久数据的根,或简单地说,数据。这个值是一个Cell。当智能合约的代码被调用时,c4指向其在区块链状态中保持的持久数据的根单元。如果智能合约需要修改这些数据,它会在返回前更改c4。
  • c5 — 包含输出操作。它也是一个Cell,通过一个引用空单元的引用初始化,但其最终值被视为智能合约的输出之一。例如,特定于TON Blockchain的SENDMSG原语,简单地将消息插入存储在输出操作中的列表。
  • c7 — 包含临时数据的根。它是一个Tuple,在调用智能合约之前通过引用空Tuple初始化,并在合约终止后被丢弃。[在TON Blockchain的上下文中,c7用一个单例Tuple初始化,其中唯一的组件是一个包含区块链特定数据的Tuple。智能合约可以自由地修改c7以存储其临时数据,前提是这个Tuple的第一个组件保持不变。 ]

未来可能会根据特定TON Blockchain或高级编程语言的需要定义更多的控制寄存器。

1.4 TVM的总状态(SCCCG)

TVM的总状态由以下组件组成:

  • (参见1.1) — 包含零个或多个值(参见1.1.1),每个值属于1.1.3中列出的值类型之一。
  • 控制寄存器c0–c15 — 包含1.3.2中描述的一些特定值。(当前版本中只使用了七个控制寄存器。)
  • 当前Continuation cc — 包含当前Continuation(即,在当前原语完成后通常将执行的代码)。这个组件类似于其他架构中的指令指针寄存器(ip)。
  • 当前代码页cp — 一个特殊的有符号16位整数值,用于选择下一个TVM操作码的解码方式。例如,TVM的未来版本可能会使用不同的代码页来添加新的操作码,同时保持向后兼容性。
  • Gas限制gas — 包含四个有符号64位整数:当前Gas限制gl,最大Gas限制gm,剩余Gas gr,以及Gas信用gc。始终满足0 ≤ gl ≤ gm,gc ≥ 0,以及gr ≤ gl + gc;gc通常初始化为零,gr初始化为gl + gc,并随着TVM的运行逐渐减少。当gr变为负数或其最终值小于gc时,会触发Gas用尽异常。

请注意,没有包含所有先前调用但未完成的函数的返回地址的“返回栈”。相反,只使用了控制寄存器c0。这背后的原因将在4.1.9中解释。

还请注意,没有通用寄存器,因为TVM是栈机(参见1.1)。因此,上述列表可以概括为“栈,控制,Continuation,代码页和Gas”(SCCCG),类似于经典的SECD机器状态(“栈,环境,控制,转储”),确实是TVM的总状态。[ 严格来说,还有一个当前库上下文,它由一个具有256位键和单元值的字典组成,用于加载3.1.7中的库引用单元]

1.5 整数算术

TVM的所有算术原语都在栈顶取多个整数类型的参数,并返回相同类型的结果到栈中。回想一下,Integer代表所有在范围-2^256 ≤ x < 2^256内的整数值,并且还包含一个特殊的NaN值(“非数字”)。

如果一个结果不适合支持的整数范围 —— 或者如果其中一个参数是NaN —— 那么这个结果或所有结果都被NaN替换,并且(默认情况下)产生一个整数溢出异常。然而,特殊的“安静”版本的算术运算将简单地产生NaN并继续进行。如果这些NaN最终被用在“非安静”的算术运算中,或者用在非算术运算中,将发生整数溢出异常。

1.5.1 整数没有自动转换
请注意,TVM中的整数是“数学”整数,而不是根据不同原语的使用而有不同的解释的257位字符串,这与其他机器代码设计中常见的情况不同。例如,TVM只有一个乘法原语MUL,而不是像流行的x86架构那样有两个(无符号乘法的MUL和有符号乘法的IMUL)。

1.5.2 自动溢出检查
所有TVM算术原语都会对结果进行溢出检查。如果结果不适合Integer类型,则用NaN替换,并且(通常)会发生异常。特别是,结果不会像大多数硬件机器代码架构那样,自动对2^256 或2^257取模。

1.5.3 自定义溢出检查
除了自动溢出检查之外,TVM还包括自定义溢出检查,由原语FITS n和UFITS n执行,其中1 ≤ n ≤ 256。这些原语检查栈顶的值是否为整数x在范围-2^(n-1) ≤ x < 2^(n-1)或0 ≤ x < 2^n内,并在不符合这个条件的情况下用NaN替换该值,并(可选地)产生整数溢出异常。这大大简化了任意n位整数类型的实现,无论是有符号还是无符号:程序员或编译器必须在每次算术运算后(这是更合理的方法,但需要更多的检查)或在存储计算值和从函数返回它们之前插入适当的FITS或UFITS原语。这对智能合约非常重要,因为在智能合约中,意外的整数溢出往往是最常见的错误来源之一。

1.5.4 模2n的简化
TVM还提供了一个名为MODPOW2 n的原语,该原语将栈顶的整数对2n取模,得到的结果是在0到2n - 1范围内的一个整数。

1.5.5 整数为什么是257位而不是256位
现在可以理解为什么TVM中的整数类型是257位(有符号)而不是256位。原因在于,它是能够同时包含有符号和无符号256位整数的最小整数类型,并且不需要根据执行的操作不同而自动重新解释同一个256位的字符串(参见1.5.1节)。

1.5.6 除法和四舍五入
最重要的几个除法原语是DIV、MOD和DIVMOD。它们都从栈中取出两个数,x和y(y从栈顶取出,x在下面),计算x除以y的商q和余数r(即满足x = yq + r且|r| < |y|的两个整数),并返回q、r或两者。如果y为零,则所有预期的结果会被NaN替代,并且(通常)会触发一个整数溢出异常。

TVM中的除法实现与大多数其他实现在四舍五入方面有所不同。默认情况下,这些原语执行的是向负无穷大方向的舍入,即q = ⌊x/y⌋。,并且余数r与y的符号相同。(大多数传统的除法实现采用的是“向零舍入”方式,这意味着余数r与x的符号相同。)除了这种“向下取整舍入”之外,还有两种其他舍入模式可用,分别是“向上取整舍入”(q =⌈x/y⌉,且余数r与y的符号相反)和“最接近数舍入”(q = ⌊x/y+1/2⌋,且|r| ≤ |y|/2)。这些舍入模式可以通过使用带有字母C和R后缀的其他除法原语来选择。例如,DIVMODR使用最接近的整数舍入模式同时计算商和余数。

1.5.7 组合的乘除、乘移位和移位除操作
为了简化定点算术的实现,TVM支持组合的乘除、乘移位和移位除操作,这些操作具有双长度(即514位)的中间乘积。例如,MULDIVMODR从栈中取出三个整数参数a、b和c,首先使用514位的中间结果计算ab,然后使用最接近的整数舍入模式将ab除以c。如果c为零,或者如果商不适合Integer类型,则所有预期的结果会被NaN替代,并且(通常)会触发一个整数溢出异常。如果计算的结果无法适应Integer类型,根据是否使用了运算的“安静”版本,要么返回两个NaN,要么触发一个整数溢出异常。如果运算结果适合Integer类型,则将商和余数都推入栈中。
这种设计允许在执行更复杂的算术运算时,能够进行更细粒度的控制和错误处理,从而确保运算的准确性和安全性。

2 栈调用约定

本章将对寄存器和栈机进行一般性讨论和比较,并在附录C中进一步展开,同时描述TVM使用的两类栈操作原语:基本和复合栈操作原语。还将非正式解释它们足以满足调用其他原语和用户定义函数所需的所有栈重排序。最后,在2.3节讨论了有效实现TVM栈操作原语的问题。

2.1.1 “栈寄存器”的表示法
回想一下,与传统的寄存器不同,栈机没有通用寄存器。然而,我们可以将栈顶部附近的值视为一种“栈寄存器”。

我们用s0或s(0)表示栈顶部的值,用s1或s(1)表示紧挨着它的值,依此类推。栈中值的总数称为其深度。如果栈的深度是n,那么s(0), s(1), …, s(n −1)都是已定义的,而s(n)和所有后续的s(i)(i > n)则没有定义。任何尝试使用s(i)(i ≥ n)的行为都应该产生栈下溢异常。

编译器或者使用“TVM代码”的人类程序员会使用这些“栈寄存器”来保存所有声明的变量和中间值,类似于寄存器上通用寄存器的用法。

2.1.2 值的入栈和出栈
当一个值x被压入深度为n的栈时,它成为新的s0;同时,旧的s0变成新的s1,旧的s1变成新的s2,依此类推。结果栈的深度是n + 1。

同样地,当从深度为n ≥ 1的栈中弹出一个值x时,它是旧的s0(即栈顶部的旧值)。之后,它从栈中移除,旧的s1变成新的s0(栈顶部的新值),旧的s2变成新的s1,依此类推。结果栈的深度是n − 1。

如果最初n = 0,那么栈是空的,不能从中弹出值。如果一个原语尝试从空栈中弹出值,则会发生栈下溢异常。

2.1.3 假设的通用寄存器的表示法
为了比较栈机和足够通用的寄存器,我们将寄存器的通用寄存器表示为r0、r1等,或r(0)、r(1)、…、r(n − 1),其中n是寄存器的总数。当我们需要一个特定的n值时,我们将使用n = 16,对应于非常流行的x86-64架构。

2.1.4 栈顶寄存器s0与累加器寄存器r0
一些寄存器架构要求大多数算术和逻辑操作的一个参数位于一个称为累加器的特殊寄存器中。在我们的比较中,我们将假设累加器是通用寄存器r0;我们可以简单地重新编号寄存器。在这种情况下,累加器与栈机的栈顶“寄存器”s0有些相似,因为栈机的所有操作几乎都使用s0作为它们的一个参数,并将它们的结果作为s0返回。

2.1.5 寄存器调用约定
当为寄存器编译时,高级语言函数通常按预定义的顺序在某些寄存器中接收它们的参数。如果参数太多,这些函数从栈中获取其余参数(是的,寄存器通常也有栈!)。然而,一些寄存器调用约定根本不在寄存器中传递参数,只使用栈(例如,Pascal和C的原始实现中使用的调用约定,尽管现代的C实现也使用一些寄存器)。[为了简单起见,我们假设最多有m ≤ n个函数参数通过寄存器传递,这些寄存器是r0、r1、...、r(m − 1),按此顺序(如果使用其他寄存器,我们可以简单地重新编号)。]

2.1.6 函数参数的顺序

在 TVM中,函数或原语的参数是按照相同的顺序压入栈中的。这意味着第一个参数(x1)最先被压入,最后一个参数(xm)最后被压入。当函数被调用时,它的参数在栈中的位置是:

  • 第一个参数 x1 在 s(m-1) 中
  • 第二个参数 x2 在 s(m-2) 中
  • 最后一个参数 xm 在 s0 中(栈顶)

被调用的函数或原语负责在执行完毕后将其参数从栈中移除。

Pascal 和 Forth 也使用类似的栈调用约定,参数按照相同的顺序压入栈中,并且由被调用者负责清理。与 TVM、Pascal 和 Forth 相反,C 语言在调用函数时,参数是按照相反的顺序压入栈中的。也就是说,最后一个参数最先被压入,第一个参数最后被压入。在 C 中,通常是调用者在函数返回后负责清理堆栈。当然,TVM的高级语言实现可能为其函数选择一些其他调用约定,与默认的约定不同。这可能对某些函数很有用——例如,如果参数的总数取决于第一个参数的值,就像scanf和printf这样的“可变参数函数”。在这种情况下,最好将第一或几个参数放在栈顶附近,而不是在栈中的某个未知位置。

2.1.7 寄存器机上算术原语的参数
在栈机上,内置算术原语(如ADD或DIVMOD)遵循与用户定义函数相同的调用约定。在这方面,用户定义的函数(例如,计算数字平方根的函数)可以被视为栈机的“扩展”或“自定义升级”。这是栈机(和Forth等栈编程语言)相比寄存器机最明显的优势之一。

相比之下,寄存器机上的算术指令(内置操作)通常从操作码中编码的通用寄存器中获取它们的参数。一个二元操作,如SUB,因此需要两个参数r(i)和r(j),其中i和j由指令指定。还必须指定一个寄存器r(k)来存储结果。算术操作可以采取几种可能的形式,这取决于是否允许i、j和k取任意值:

  • 三地址形式 — 允许程序员任意选择两个源寄存器r(i)和r(j),以及一个单独的目的寄存器r(k)。这种形式在大多数RISC处理器中很常见,在x86-64架构的XMM和AVX SIMD指令集中也是如此。
  • 二地址形式 — 使用两个操作数寄存器之一(通常是r(i))来存储操作的结果,因此k = i永远不会明确指示。只有i和j编码在指令中。这是寄存器机上算术操作最常见的形式,并且在微处理器(包括x86系列)中非常流行。
  • 单地址形式 — 总是从累加器r0中取一个参数,并将结果存储在r0中;那么i = k = 0,只有j需要由指令指定。这种形式被一些简单的微处理器使用(如Intel 8080)。

请注意,这种灵活性只适用于内置操作,但不适用于用户定义的函数。在这方面,寄存器机不像栈机那样容易“升级”。[例如,如果有人编写了一个用于提取平方根的函数,这个函数将总是接受其参数并返回其结果到相同的寄存器中,这与一个假想的内置平方根指令相反,后者可能允许程序员任意选择源寄存器和目的寄存器。因此,用户定义的函数比寄存器机上的内置指令灵活性要小得多。[

2.1.8 函数的返回值
在像TVM这样的栈机中,当一个函数或原语需要返回一个结果值时,它只需将其推入栈中(所有函数的参数已经被移除)。因此,调用者将能够通过栈顶“寄存器”s0访问结果值。这与Forth调用约定完全一致,但与Pascal和C调用约定略有不同,在Pascal和C中,累加器寄存器r0通常用于返回值。

2.1.9 返回多个值
有些函数可能想要返回多个值y1, …, yk,其中k不一定等于一。在这些情况下,k个返回值按照自然顺序被压入栈中,从y1开始。

例如,“带余除法”原语DIVMOD需要返回两个值,商q和余数r。因此,DIVMOD按照该顺序将q和r压入栈中,这样商之后可以在s1中获取,余数在s0中。DIVMOD的净效果是将s1的原始值除以s0的原始值,并在s1中返回商,在s0中返回余数。在这个特定的例子中,栈的深度和所有其他“栈寄存器”的值保持不变,因为DIVMOD接收两个参数并返回两个结果。一般来说,位于传递参数和返回值之下的栈中的其他“栈寄存器”的值会根据栈深度的变化而移动。

原则上,一些原语和用户定义的函数可能返回可变数量的结果值。在这方面,上面关于可变参数函数的内容(参见2.1.6)适用:结果值的总数和它们的类型应由栈顶部附近的值决定。(例如,可以压入返回值y1, …, yk,然后压入它们的总数k作为一个整数。调用者随后通过检查s0来确定返回值的总数。)

2.1.10 栈表示法
当深度为n的栈包含值z1, …, zn,按该顺序,z1是最深层的元素,zn是栈顶,栈的内容通常由列表z1 z2 … zn表示,按该顺序。当原语将原始栈状态S′转换为新状态S′′时,这通常被写作S′ – S′′;这是所谓的栈表示法。

例如,除法原语DIV的作用可以描述为S x y – S ⌊x/y⌋,其中S是任何值的列表。这通常缩写为x y – ⌊x/y⌋,暗中假定栈中更深层的所有其他值保持不变。

或者,可以描述DIV为一个在深度为n ≥ 2的栈S′上运行的原语,将s1除以s0,并返回向下取整的商作为新栈S′′的s0,深度为n − 1。对于1 ≤ i < n − 1,新的s(i)值等于旧的s(i + 1)值。这些描述是等效的,但说DIV将x y转换为bx/yc,或者说… x y转换为… bx/yc,更为简洁。

栈表示法在附录A中广泛使用,其中列出了所有当前定义的TVM原语。

PS(后续我也会为大家详细的在fift汇编中讲解所有的原语)

2.1.11 明确定义函数的参数数量
栈机通常将当前栈整体传递给被调用的原语或函数。那个原语或函数只访问栈顶部的几个值,这些值代表它的参数,并按照约定用返回值替换它们的位置,同时保留所有更深层的值。

然后,结果栈再次整体返回给调用者。

大多数TVM原语都是这样表现的,我们希望大多数用户定义的函数也能遵循这样的约定。然而,TVM提供了机制来指定必须传递给被调用函数的参数数量(参见4.1.10)。当使用这些机制时,指定数量的值从调用者的栈移动到被调用函数的栈(通常最初是空的),而更深层的值保留在调用者的栈中,被调用者无法访问。调用者还可以指定它期望从被调用函数返回多少个值。
例如:
库函数:
参数检查机制对于调用作为参数传递给库函数的用户提供的函数特别有用。这确保了用户提供的函数被调用时带有正确数量的参数,无论它们在哪里定义。
安全性和可预测性:
这些机制通过确保函数被调用时带有预期的参数,并正确管理栈,从而有助于维护系统的安全性和可预测性。

2.2 栈操作原语

像TVM这样的栈机使用许多栈操作原语来重新排列其他原语和用户定义函数的参数,以便它们按正确顺序位于栈顶附近。本节讨论了实现这一目标所需的栈操作原语,以及TVM所使用的原语。可以在附录C中找到使用这些原语的一些代码示例。

2.2.1 基本栈操作原语
TVM使用的一些最重要的栈操作原语如下:

  • 栈顶交换操作:XCHG s0,s(i) 或 XCHG s(i) — 交换s0和s(i)的值。当i = 1时,操作XCHG s1传统上被表示为SWAP。当i = 0时,这是NOP(一个什么也不做的操作,至少在栈非空时)。

  • 任意交换操作:XCHG s(i),s(j) — 交换s(i)和s(j)的值。请注意,这个操作并不严格必要,因为它可以通过三次栈顶交换来模拟:XCHG s(i);XCHG s(j);XCHG s(i)。然而,将任意交换作为原语是有用的,因为它们经常被要求。

  • 入栈操作:PUSH s(i) — 将s(i)的(旧)值的副本压入栈中。传统上,PUSH s0也用DUP表示(它复制栈顶的值),PUSH s1用OVER表示。

  • 出栈操作:POP s(i) — 移除栈顶的值并将其放入(新的)s(i − 1),或旧的s(i)。传统上,POP s0也用DROP表示(它简单地丢弃栈顶的值),POP s1用NIP表示。

可能还会定义一些其他“非系统性”的栈操作操作(例如,ROT,用栈表示法a b c – b c a)。虽然这样的操作在像Forth这样的栈语言中定义(DUP、DROP、OVER、NIP和SWAP也存在),但它们并不严格必要,因为上面列出的基本栈操作原语足以重新排列栈寄存器,以允许正确调用任何算术原语和用户定义的函数。

批注:目前已经定义了大量的原语,截至2024年,fift语言比较流行于操作TVM汇编

2.2.2 基本栈操作原语足够
编译器或人类TVM代码程序员可能如下使用基本栈原语。

假设要调用的函数或原语要传递三个参数x、y和z,当前位于栈寄存器s(i)、s(j)和s(k)中。在这种情况下,编译器(或程序员)可能会发出操作PUSH s(i)(如果调用此原语后需要x的副本)或XCHG s(i)(如果之后不需要)以将第一个参数x放到栈顶。后,编译器(或程序员)可以使用PUSH s(j′)或XCHG s(j′),其中j′ = j或j + 1,将y放到新的栈顶。[当然,如果使用了第二种选择,这将破坏栈顶x的原始排列。在这种情况下,应该在XCHG s(j′)之前发出SWAP,或者将之前的XCHG s(i)操作替换为XCHG s1, s(i),以便从一开始就用s1与x交换。]

通过这种方式,我们可以看到,我们可以使用一系列入栈和交换操作(参见2.2.4和2.2.5以获得更详细的解释)将原始值x、y和z或它们的副本(如果需要)放入s2、s1和s0的位置。为了生成这个序列,编译器只需要知道三个值i、j和k,描述变量或临时值的旧位置,以及一些标志,描述每个值之后是否需要,或者是否仅在此原语或函数调用中需要。其他变量和临时值的位置将在这个过程中受到影响,但编译器(或人类程序员)可以轻松跟踪它们的新位置。

同样地,如果函数返回的结果需要被丢弃或移动到其他栈寄存器,适合的交换和弹出操作序列将完成这项工作。在s0中有一个返回值的典型情况下,这可以通过XCHG s(i)或POP s(i)(在大多数情况下,是DROP)操作实现。

在函数返回前重新排列结果值或多个值,本质上与为函数调用排列参数的问题相同,并且实现方式也类似。

2.2.3 复合栈操作原语
为了提高TVM代码的密度和简化编译器的开发,可以定义复合栈操作原语,每个原语结合了多达四个交换和入栈或交换和出栈的基本原语。这类复合栈操作可能包括:

  • XCHG2 s(i),s(j) — 相当于XCHG s1,s(i); XCHG s(j)。
  • PUSH2 s(i),s(j) — 相当于PUSH s(i); PUSH s(j + 1)。
  • XCPU s(i),s(j) — 相当于XCHG s(i); PUSH s(j)。
  • PUXC s(i),s(j) — 相当于PUSH s(i); SWAP; XCHG s(j + 1)。当j ≠ i且j ≠ 0时,它也等同于XCHG s(j); PUSH s(i); SWAP。
  • XCHG3 s(i),s(j),s(k) — 相当于XCHG s2,s(i); XCHG s1,s(j); XCHG s(k)。
  • PUSH3 s(i),s(j),s(k) — 相当于PUSH s(i); PUSH s(j + 1); PUSH s(k + 2)。

当然,只有当这些操作的编码比等效的基本操作序列更紧凑时,这样的操作才有意义。例如,如果所有的栈顶交换、XCHG s1,s(i)交换以及入栈和出栈操作都可以用单字节编码,那么上述建议的复合栈操作中,只有PUXC、XCHG3和PUSH3可能值得包含在栈操作原语的集合中。

注意,如果我们不坚持保持相同的临时值或变量始终在同一个栈位置,而是跟踪其后续位置,那么最常见的XCHG s(i)操作实际上并不需要。我们将在准备下一个原语或函数调用的参数时将其移动到其他位置。复合栈操作本质上是通过为代码中的其他原语(指令)增加其操作数的“真实”位置,来增强这些原语。这在某种程度上类似于在两地址或三地址寄存器机器代码中发生的情况。然而,与寄存器机器通常的做法不同,我们并不在算术指令或其他指令的操作码内部编码这些位置,而是在前面的复合栈操作中指示这些位置。正如在2.1.7节中已经描述的,这种方法的优点是,用户定义的函数(或TVM未来版本中添加的很少使用的特殊原语)也可以从中受益(参见C.3节,其中有更详细的讨论和示例)。

2.2.4 复合栈操作的助记符
复合栈操作的助记符,其中一些示例已在2.2.3中提供,按如下方式创建:

  • 这类操作O的形式参数s(i1), …, s(iγ)表示在执行该复合操作后,这些值最终将位于s(γ − 1), …, s0中,前提是所有iν (1 ≤ ν ≤ γ)都是不同的,并且至少有γ个。操作O的助记符本身是γ个两字母字符串PU和XC的序列,PU表示相应的参数将被PUshed(即,将创建一个副本),XC表示该值将被eXChanged(即,不创建原始值的其他副本)。多个PU或XC字符串序列可以缩写为一个PU或XC,后面跟着副本的数量。(例如,我们写PUXC2PU而不是PUXCXCPU。)

作为例外,如果助记符只由PU或XC字符串组成,使得复合操作相当于m个PUSH或eXCHanGes序列,那么使用PUSHm或XCHGm的表示法,而不是PUm或XCm。

2.2.5 复合栈操作的语义
每个复合γ元操作O s(i1), …, s(iγ)按如下方式通过归纳γ翻译成等效的基本栈操作序列:

  • 作为归纳的基础,如果γ = 0,唯一的零元复合栈操作对应于一个空的基本栈操作序列。
  • 等效地,我们可能从γ = 1开始归纳。那么PU s(i)对应于由一个基本操作PUSH s(i)组成的序列,XC s(i)对应于由XCHG s(i)组成的单元素序列。

对于γ ≥ 1(或如果我们使用γ = 1作为归纳基础,则为γ ≥ 2),有两种子情况:

  1. Os(i1), …, s(iγ),其中O = XCO′,O′是γ −1元的复合操作(即,O′的助记符由γ −1个字符串XC和PU组成)。设α是O中的PUsh总量,β是eXChanges的总量,使得α + β = γ。然后原始操作被翻译成XCHG s(β − 1),s(i1),后面跟着由归纳假设定义的O′s(i2), …, s(iγ)的翻译。
  2. Os(i1), …, s(iγ),其中O = PUO′,O′是γ −1元的复合操作。然后原始操作被翻译成PUSH s(i1); XCHG s(β),后面跟着由归纳假设定义的O′s(i2 + 1), …, s(iγ + 1)的翻译。[一个可能更好且可替代的PUO′s(i1), ..., s(iγ)的翻译包括翻译O′s(i2), ..., s(iγ),然后是PUSH s(i1 + α − 1); XCHG s(γ − 1)。 ]

2.2.6 栈操作指令是多态的
请注意,栈操作指令几乎是TVM中唯一的“多态”原语 —— 即,它们可以操作任意类型的值(包括将来TVM修订中才会出现的值类型)。例如,SWAP总是交换栈顶的两个值,即使一个是整数,另一个是单元。几乎所有其他指令,特别是数据处理指令(包括算术指令),都要求它们的每个参数是某种固定类型(对于不同的参数可能不同)。

2.3 栈操作原语的效率

栈机(如TVM)使用的栈操作原语必须非常高效地实现,因为它们构成了典型程序中使用的超过一半的指令。实际上,TVM以(小)恒定时间执行所有这些指令,无论涉及的值如何(即使它们代表非常大的整数或非常大的单元树)。

2.3.1 栈操作原语的实现:使用引用代替对象进行操作
TVM实现栈操作原语的效率来自于一个事实,即典型的TVM实现在栈中保持的不是值对象本身,而只是这些对象的引用(指针)。因此,SWAP指令只需要交换s0和s1处的引用,而不是它们引用的实际对象。

这种实现方式显著提高了栈操作的性能,因为操作引用(即指针)比操作它们指向的实际数据要快得多。特别是对于那些可能非常大的数据结构,如大型整数或复杂的单元树,这种方法避免了复制大量数据的开销,从而确保了操作的恒定执行时间。

通过这种方式,TVM能够在不牺牲性能的前提下,有效地管理栈中的值,无论是简单的数据还是复杂的数据结构。这种策略是TVM设计中用于优化性能和资源使用的关键方面之一。

2.3.2 使用写时复制高效实现DUP和PUSH指令
此外,DUP(或更一般地,PUSH s(i))指令,似乎要复制一个潜在的大对象,也以小恒定时间工作,因为它使用了延迟复制的写时复制技术:它只复制引用而不是对象本身,但增加了对象内的“引用计数器”,从而在两个引用之间共享对象。如果检测到试图修改引用计数大于一的对象,首先会制作该对象的一个单独副本(对于触发创建新副本的数据操作指令,会产生一定的“非唯一性罚金”或“复制罚金”)。

2.3.3 垃圾收集和引用计数
当TVM对象的引用计数变为零时(例如,因为对此类对象的最后一个引用已被DROP操作或算术指令使用),它将立即被释放。由于在TVM数据结构中不可能存在循环引用,这种引用计数方法提供了一种快速且方便的释放未使用对象的方式,代替了慢速和不可预测的垃圾收集器。

2.3.4 实现的透明性:栈值是“值”,不是“引用”
无论刚刚讨论的实现细节如何,从TVM程序员的角度来看,所有栈值实际上是“值”,不是“引用”,类似于函数编程语言中的所有类型的值。任何试图修改从任何其他对象或栈位置引用的现有对象的尝试,都会在实际执行修改之前,透明地将此对象替换为其完美副本。

换句话说,程序员应该始终表现得像对象本身直接被栈、算术和其他数据转换原语操作一样,并把前面的讨论只当作栈操作原语高效率的解释。

2.3.5 循环引用的预防

人们可能会尝试创建两个单元A和B之间的循环引用,过程如下:首先创建A并写入一些数据;然后创建B并写入一些数据,同时加入对之前构建的单元A的引用;最后,在A中加入对B的引用。尽管看起来经过这一系列操作后,我们得到了一个单元A,它引用B,而B反过来引用A,但实际上并非如此。

实际上,我们得到的是一个新的单元A’,它包含了最初存储在单元A中的数据的副本以及对单元B的引用,而单元B包含了对(原始)单元A的引用。

通过这种方式,透明的写时复制机制和“一切都是值”的范式使我们能够仅使用先前构建的单元来创建新单元,从而禁止循环引用的出现。这一特性也适用于所有其他数据结构:例如,循环引用的缺失使得TVM能够使用引用计数来立即释放未使用的内存,而不是依赖垃圾收集器。同样,这一特性对于在TON区块链中存储数据至关重要。

3 单元(cell)、内存(memory)和持久存储(persistent storage)

本章简要描述了TVM单元,它用于表示TVM内存及其持久存储中的所有数据结构,以及用于创建单元、将(或序列化)数据写入它们,以及从它们中读取(或反序列化)数据的基本操作。

3.1 单元概述

本节对单元类型进行了分类和一般性描述。

3.1.1 TVM 内存和持久存储由单元组成

回想一下,TVM 内存和持久存储由(TVM)单元组成。每个单元包含多达 1023 位的数据和多达四个对其他单元的引用。循环引用是禁止的,不能通过 TVM 创建(参见 2.3.5 节)。因此,所有保存在 TVM 内存和持久存储中的单元构成了一个有向无环图(DAG)。

3.1.2 普通单元和外来单元

除了数据和引用,单元还有一个由整数 -1 到 255 编码的单元类型。类型为 -1 的单元称为普通单元;这些单元不需要任何特殊处理。其他类型的单元称为外来单元,当尝试反序列化它们时(即,通过 CTOS 指令将它们转换为 Slice),它们可能会被其他单元自动替换。在计算它们的哈希时,它们也可能表现出非平凡的行为。

外来单元最常用的用途是代表其他一些单元 - 例如,存在于外部库中的单元,或者在创建 Merkle 证明时从原始单元树中剪下的单元。

外来单元的类型存储在其数据的前八位中。如果外来单元的数据位数少于八位,则该单元无效。

详细介绍

  • 单元作为数据结构:单元是 TVM 中数据结构的基本构建块。它们可以被看作是图中的节点,每个节点可以保存数据并指向其他节点。

  • 数据容量:每个单元具有多达 1023 位的容量,允许以灵活高效的方式存储数据。

  • 引用:单元可以引用多达四个其他单元,通过链接可以创建复杂的数据结构。

  • 禁止循环引用:为维护数据完整性并避免无限循环,TVM 不允许循环引用。这确保了单元图保持无环。

  • 普通单元:这些是大多数情况下使用的标准单元。它们没有与之相关的特殊规则或行为。

  • 外来单元:这些单元具有特殊属性和行为。当反序列化时它们可以被自动替换,这在合并外部数据或创建证明时非常有用。

  • 外来单元类型:外来单元的类型至关重要,因为它决定了单元的行为。类型存储在单元数据的前八位中,使其易于识别。

  • 无效的外来单元:如果外来单元不包含足够的数据来存储其类型,它被认为是无效的。这条规则确保了所有外来单元都能被正确识别和处理。

  • Merkle 证明和外部库:在需要单元代表不直接存储在 TVM 中的数据的场景中,外来单元特别有用,例如在外部库中或作为 Merkle 证明的一部分。

3.1.3 单元的级别

每个单元 c 都有一个称为其德布鲁因级别的属性 Lvl(c),它目前的取值范围是 0 到 3。

对于一个包含对其子单元 c1crr 个引用的普通单元 c,普通单元的级别总是等于其所有子单元 ci 的级别中的最大值:

L v l ( c ) = max ⁡ 1 ≤ i ≤ r L v l ( c i ) Lvl(c) = \max_{1 \leq i \leq r} Lvl(ci) Lvl(c)=1irmaxLvl(ci)

如果 r = 0,即单元 c 没有子单元,则 Lvl(c) = 0。外来单元可能有不同的级别设置规则。

单元的级别影响它拥有的更高哈希的数量。更精确地说,一个级别为 l 的单元除了其表示哈希 Hash(c) = Hash∞(c) 之外,还有 l 个更高级别的哈希 Hash1(c)Hashl(c)。非零级别的单元出现在 Merkle 证明和 Merkle 更新中,这是在代表抽象数据类型值的单元树的一些分支被剪枝之后。

3.1.4 标准单元表示

当单元需要通过网络协议传输或存储在磁盘文件中时,它必须被序列化。单元 c 的标准表示 CellRepr(c) 作为字节序列的构建如下:

  1. 描述符字节:首先序列化两个描述符字节 d1d2。字节 d1 等于 r + 8s + 32l,其中 0 ≤ r ≤ 4 是单元中包含的单元引用数量,0 ≤ l ≤ 3 是单元的级别,0 ≤ s ≤ 1 外来单元为 1,普通单元为0。字节 d2 等于 ⌊b/8 ⌋ + ⌈b/8⌉,其中 0 ≤ b ≤ 1023 是单元 c 中的数据位数。

  2. 数据位序列化:然后,将数据位序列化为 ⌈b/8⌉ 8 位字节(字节)。如果 b 不是 8 的倍数,那么在数据位后面追加一个二进制 1 和最多六个二进制 0,直到数据位数成为 8 的倍数。

  3. 单元引用序列化:最后,每个单元引用由 32 字节表示,包含被引用单元的 256 位表示哈希 Hash(ci)

通过这种方式,获得了 2 + ⌈b/8⌉ + 32r 字节的 CellRepr(c)

3.1.5 单元的表示哈希

单元 c 的 256 位表示哈希或简称哈希 Hash(c) 定义为单元 c 的标准表示的 sha256:

H a s h ( c ) : = s h a 256 ( C e l l R e p r ( c ) ) Hash(c) := sha256(CellRepr(c)) Hash(c):=sha256(CellRepr(c))

这种表示哈希是递归定义的,意味着一个单元的哈希是其序列化表示的 sha256 哈希。这个哈希用于在 Merkle 证明和 Merkle 更新中唯一标识单元。请注意,TVM不允许创建循环单元引用(参见2.3.5节),因此这种递归总会结束,任何单元的表示哈希都是明确定义的。

3.1.6 单元的更高哈希

回想一下,级别为 l 的单元 cl 个更高级别的哈希 Hashi(c),其中 1 ≤ i ≤ l。外来单元有它们自己计算更高哈希的规则。普通单元 c 的更高哈希 Hashi(c) 的计算方式与它的表示哈希类似,但是使用的是其子单元 cj 的更高哈希 Hashi(cj) 而不是它们的表示哈希 Hash(cj)。按照惯例,我们设置 Hash∞(c) := Hash(c),并且对于所有 i > lHashi(c) := Hash∞(c) = Hash(c)[从理论上讲,我们可能会说一个单元格c有一个无限序列的哈希值(Hashi(c)) i≥1,这个序列最终会稳定下来:Hashi(c) → Hash∞(c)。然后这个级别l就是最大的索引i,使得Hashi(c) ≠ Hash∞(c)。]

3.1.7 外来单元的类型

TVM目前支持以下单元类型:

  • 类型 -1:普通单元 - 包含多达 1023 位的数据和多达四个单元引用。

  • 类型 1:剪枝分支单元 c - 可以有任意级别 1 ≤ l ≤ 3。它包含恰好 8 + 256l 位数据位:首先是等于 1 的 8 位整数(表示单元的类型),然后是它的 l 个更高哈希 Hash_1(c), ..., Hash_l(c)。剪枝分支单元的级别 l 可以被称为它的德布鲁因索引,因为它决定了在构建外部 Merkle 证明或 Merkle 更新过程中剪枝的分支。

    • 尝试加载剪枝分支单元通常会导致异常。
  • 类型 2:库引用单元 - 总是有级别 0,并且包含 8 + 256 位数据位,包括它的 8 位类型整数 2 和被引用库单元的表示哈希 Hash(c')。加载时,如果当前库环境中找到了它引用的单元,库引用单元可能会被透明地替换为它引用的单元。

  • 类型 3:Merkle 证明单元 c - 恰好有一个引用 c1 和级别 0 ≤ l ≤ 3,这个级别必须比它唯一的子单元 c1 的级别少一个:
    L v l ( c ) = max ⁡ ( L v l ( c 1 ) − 1 , 0 ) Lvl(c) = \max(Lvl(c1) − 1, 0) Lvl(c)=max(Lvl(c1)1,0)
    这些类型的单元在TVM中用于不同的用途,从表示普通数据到处理复杂的数据结构和证明。外来单元的引入增加了TVM处理数据的灵活性和多样性。

默克尔证明单元格的8 + 256数据位包含其8位类型整数3,后面跟着Hash1(c1)(如果Lvl(c1) = 0,则假设等于Hash(c1))。c的更高哈希Hash_i( c )类似于普通单元格的更高哈希计算,但使用Hashi+1(c1)代替Hashi(c1)。加载时,默克尔证明单元格被c1替换。

  • 类型4:默克尔更新单元格c —— 有两个子单元格c1和c2。它的级别0 ≤ l ≤ 3由以下公式给出
    L v l ( c ) = max ⁡ ( L v l ( c 1 ) − 1 , L v l ( c 2 ) − 1 , 0 ) Lvl(c) = \max(Lvl(c1) - 1, Lvl(c2) - 1, 0) Lvl(c)=max(Lvl(c1)1,Lvl(c2)1,0)
    默克尔更新对c1和c2都像默克尔证明一样行为,并包含8 + 256 + 256数据位,带有Hash1(c1)和Hash1(c2)。然而,一个额外的要求是,所有被c绑定的、c2的后代且被修剪的分支单元格c′也必须是c1的后代。当加载默克尔更新单元格时,它被c2替换。

3.1.8所有代数数据类型的值都是单元格的树。

任意代数数据类型的任意值(例如,函数式编程语言中使用的所有类型)都可以序列化为单元格的树(级别为0),这种表示用于在TVM内表示这些值。写时复制机制(参见2.3.2)允许TVM识别包含相同数据和引用的单元格,并只保留这些单元格的一份副本。这实际上将单元格的树转换成了一个有向无环图(具有一个额外属性,即所有顶点都可以从称为“根”的标记顶点访问)。然而,这是一种存储优化,而不是TVM的基本属性。从TVM代码程序员的角度来看,应该将TVM数据结构视为单元格的树。

3.1.9TVM代码是单元格的树。

TVM代码本身也由单元格的树表示。实际上,TVM代码只是某种复杂代数数据类型的值,因此它可以序列化为单元格的树。

TVM代码(例如,TVM汇编代码)如何被转换成单元格的树将在稍后解释(参见4.1.45.2),在讨论控制流指令、延续和TVM指令编码的部分。

默克尔证明单元格的8 + 256数据位包含其8位类型整数3,后面跟着Hash1( c1 )(如果Lvl(c1) = 0,则假设等于Hash(c1))。c的更高哈希Hashi( c )类似于普通单元格的更高哈希计算,但使用Hashi+1(c1)代替Hashi(c1)。加载时,默克尔证明单元格被c1替换。

  • 类型4:默克尔更新单元格c —— 有两个子单元格c1和c2。它的级别0 ≤ l ≤ 3由以下公式给出
    L v l ( c ) = max ⁡ ( L v l ( c 1 ) − 1 , L v l ( c 2 ) − 1 , 0 ) Lvl(c) = \max(Lvl(c1) - 1, Lvl(c2) - 1, 0) Lvl(c)=max(Lvl(c1)1,Lvl(c2)1,0)
    默克尔更新对c1和c2都像默克尔证明一样行为,并包含8 + 256 + 256数据位,带有Hash1(c1)和Hash1(c2)。然而,一个额外的要求是,所有被c绑定的、c2的后代且被修剪的分支单元格c′也必须是c1的后代。当加载默克尔更新单元格时,它被c2替换。[如果从默克尔单元格c到其后代c′的路径上(包括c)恰好有l个默克尔单元格,那么级别为l的修剪分支单元格c′就被默克尔单元格c绑定。]

3.1.10 “一切都是单元格集合”的范式

如在[1, 2.5.14]中所述,TON区块链使用的所有数据,包括区块本身和区块链状态,都可以表示为集合,或者称为“单元格集合”。我们可以看到,TVM的数据结构(参见3.1.8)和代码(参见3.1.9)很好地适应了这种“一切都是单元格集合”的范式。通过这种方式,TVM可以自然地用于在TON区块链上执行智能合约,而TON区块链可以用来在TVM调用之间存储这些智能合约的代码和持久数据。(当然,TVM和TON区块链都是为了实现这一点而设计的。)

3.2 数据操作指令和单元格

下一组TVM指令包括数据操作指令,也称为单元格操作指令或简称单元格指令。它们对应于其他架构的内存访问指令。

3.2.1 单元格操作指令的类别

TVM单元格指令自然地被细分为两个主要类别:

  • 单元格创建指令序列化指令,用于从之前保存在栈中和之前构建的单元格中的值构建新的单元格。
  • 单元格解析指令反序列化指令,用于提取之前通过单元格创建指令存储到单元格中的数据。

此外,还有用于创建和检查特殊单元格的奇异单元格指令(参见3.1.2),这些指令特别用于表示修剪过的默克尔证明分支和默克尔证明本身。

3.2.2 构建器和切片值

单元格创建指令通常使用构建器值,这些值只能保存在栈中(参见1.1.3)。这些值代表部分构建的单元格,可以定义用于追加比特字符串、整数、其他单元格和对其他单元格的引用的快速操作。类似地,单元格解析指令大量使用切片值,这些值代表部分解析单元格的剩余部分,或者是通过解析指令从单元格中提取出来的值(子单元格)。

3.2.3 构建器和切片值仅存在于栈值中。请注意,构建器和切片对象仅作为TVM栈中的值出现。它们不能存储在“内存”(即单元格的树)或“持久存储”(也是一个单元格集合)中。从这个意义上说,TVM环境中的单元格对象比构建器或切片对象要多得多,但有些矛盾的是,TVM程序在栈中看到构建器和切片对象比单元格更频繁。实际上,TVM程序对单元格值没有太多用处,因为它们是不可变的和不透明的;所有单元格操作原语都要求单元格值首先转换成构建器或切片,然后才能进行修改或检查。

3.2.4 TVM没有单独的位字符串值类型。请注意,TVM没有提供单独的位字符串值类型。相反,位字符串由完全没有引用但仍然可以包含多达1023位数据的切片表示。

3.2.5 单元格和单元格原语面向位而非字节。一个重要的点是,TVM将存储在单元格中的数据视为位序列(字符串、流)(最多1023位),而不是字节。换句话说,TVM是面向位的机器,而不是面向字节的机器。如果需要,应用程序可以自由地使用,例如,在TVM单元格中序列化的记录内的21位整数字段,从而使用更少的持久存储字节来表示相同的数据。

3.2.6 单元格创建(序列化)原语的分类。单元格创建原语通常接受一个构建器参数和一个表示要序列化的值的参数。还可以提供控制序列化过程某些方面的附加参数(例如,序列化应该使用多少位),这些参数可以堆叠在栈中或作为指令内部的立即值。单元格创建原语的结果通常是另一个构建器,代表原始构建器和提供的值序列化的连接。

因此,可以根据对以下问题的回答对单元格序列化原语进行分类:

  • 正在序列化的值的类型是什么?
  • 序列化使用了多少位?如果这是一个可变数字,它来自栈还是来自指令本身?
  • 如果值不适合指定的位数,会发生什么?是生成异常,还是在栈顶静默返回一个等于零的成功标志?
  • 如果构建器中剩余空间不足,会发生什么?是生成异常,还是返回零成功标志以及未修改的原始构建器?

单元格序列化原语的助记符通常以ST开头。后续字母描述以下属性:

  • 正在序列化的值的类型和序列化格式(例如,I代表有符号整数,U代表无符号整数)。
  • 要使用的位字段宽度的来源(例如,整数序列化指令的X表示位宽度n在栈中提供;否则它必须作为立即值嵌入到指令中)。
  • 如果操作无法完成要执行的操作(默认情况下,生成异常;序列化指令的“安静”版本在其助记符中用Q字母标记)。

这种分类方案用于创建更完整的单元格序列化原语分类,可以在附录A.7.1中找到。

3.2.7 整数序列化原语
整数序列化原语也可以根据上述分类进行分类。例如:

  • 有有符号和无符号(大端)整数序列化原语。
  • 要使用的位字段大小n(有符号整数1 ≤ n ≤ 257,无符号整数0 ≤ n ≤ 256)可以来自栈顶或嵌入到指令本身中。
  • 如果要序列化的整数x不在范围-2^n-1 ≤ x < 2^n-1(对于有符号整数序列化)或0 ≤ x < 2^n(对于无符号整数序列化),通常会产生范围检查异常,如果n位不能存储在提供的构建器中,会产生单元格溢出异常。
  • 序列化指令的“安静”版本不会抛出异常;相反,它们在成功时将-1推到结果构建器的顶部,或者在失败时返回原始构建器,并在其顶部返回0以指示失败。
  • 整数序列化指令的助记符类似于STU 20(“存储一个20位的无符号整数值”)或STIXQ(“静默地存储一个长度可变且在栈中提供的整数值”)。这些指令的完整列表——包括它们的助记符、描述和操作码——在附录A.7.1中提供。

3.2.8 单元格中的整数默认为大端模式。请注意,默认情况下,序列化到单元格中的整数的位顺序是大端的,而不是小端的。在这方面,TVM是一台大端机器。
举例:

  • 假设有一个整数 0x12345678,它在大端序中的表示为 0x12 0x34 0x56 0x78。
  • 相反,在小端序中,它的表示会是 0x78 0x56 0x34 0x12。

TVM 是一台大端序机器,这仅影响单元内部整数的序列化。整数值类型的内部表示是依赖于实现的,并且对 TVM 的操作无关紧要。
然而,这只影响单元格内整数的序列化。整数值类型的内部表示是实现依赖的,并且与TVM的操作无关。此外,还有一些特殊的原语,如STULE用于(反)序列化小端整数,这些整数必须存储在整数个字节中(否则,“小端性”没有意义,除非还愿意反转八位字节内的位顺序)。这种原语对于与小端世界进行接口非常有用——例如,用于解析从外部世界到达TON区块链智能合约的自定义格式消息。

举例:

  • 整数 -17 的二进制补码表示(假设使用8位)是 11101001,即十六进制的 xEF。
  • 这个表示方法允许 TVM 以一致的方式处理正数和负数的加法和减法。

3.2.9 其他序列化原语。其他单元格创建原语序列化位字符串(即没有引用的单元格切片),要么从栈中取出,要么作为文字参数提供;单元格切片(以明显的方式连接到单元格构建器);其他构建器(也连接在一起);和单元格引用(STREF)。

3.2.10 其他单元格创建原语。除了上述描述的某些内置值类型的单元格序列化原语之外,还有一些简单的原语,它们创建一个新的空构建器并将其推入栈中(NEWC),或将构建器转换为单元格(ENDC),从而完成单元格创建过程。ENDC可以与STREF合并为一个单独的指令ENDCST,它完成单元格的创建并立即将对其的引用存储在“外部”构建器中。还有一些原语用于获取构建器中已经存储的数据位或引用的数量,并检查可以在构建器中存储多少数据位或引用。

3.2.11 单元格反序列化原语的分类。单元格解析或反序列化原语可以按照3.2.6节的描述进行分类,但有以下修改:

  • 它们使用切片(代表正在解析的单元格的剩余部分)而不是构建器。
  • 它们返回反序列化的值,而不是接受它们作为参数。
  • 它们可能有两种风格,这取决于它们是否从提供的切片中移除反序列化的部分(“提取操作”),或者将其保留不变(“预提取操作”)。
  • 它们的助记符通常以LD(或预提取操作的PLD)而不是ST开头。

例如,先前通过STU 20指令序列化到单元格中的无符号大端20位整数,很可能稍后会通过匹配的LDU 20指令进行反序列化。
更详细的关于这些指令的信息在附录A.7.2中提供。

3.2.12 其他单元格切片原语。除了上述概述的单元格反序列化原语外,TVM还提供了一些用于初始化和完成单元格反序列化过程的明显原语。例如,可以将单元格转换为切片(CTOS),以便可以开始其反序列化;或者检查切片是否为空,并在它不为空时生成异常(ENDS);或者反序列化单元格引用,并立即将其转换为切片(LDREFTOS,相当于两个指令LDREFCTOS)。

3.2.13 修改单元格中序列化的值。读者可能会想知道如何修改单元格内序列化的值。假设一个单元格包含三个序列化的29位整数(x,y,z),代表空间中点的坐标,我们想要用y' = y + 1替换y,而其他坐标保持不变。我们如何实现这一点?
TVM不提供任何修改现有值的方法(参见2.3.4和2.3.5),所以我们的例子只能通过以下一系列操作来完成:

  1. 将原始单元格反序列化为栈中的三个整数x,y,z(例如,通过CTOSLDI 29LDI 29LDI 29ENDS)。
  2. 将y增加一(例如,通过SWAPINCSWAP)。
  3. 最后,将结果整数序列化到一个新的单元格中(例如,通过XCHG s2NEWCSTI 29STI 29STI 29ENDC)。

3.2.14 修改智能合约的持久存储。如果TVM代码想要修改其持久存储,由以c4为根的单元格树表示,它只需要通过包含其持久存储新值的单元格树的根来重写控制寄存器c4。(如果只需要修改持久存储的一部分,参见3.2.13。)

PS:(这些操作展示了 TVM 中处理单元和 Slices 的复杂性。由于 TVM 不允许直接修改单元中的值,所以需要通过一系列的操作来实现值的更新。这些操作包括反序列化、修改值、然后将新值序列化回单元。对于持久存储的修改,通常涉及到更新控制寄存器来指向新的单元树根。这些机制确保了数据的一致性和合约逻辑的可控性。)

3.3 哈希映射(Hashmaps)或字典

哈希映射或字典是一种由单元树表示的特定数据结构。本质上,哈希映射是一种映射,它将键(位字符串,长度可以是固定的或可变的)映射到任意类型的值 X,并且能够快速查找和修改。虽然任何此类结构都可以通过通用的单元序列化和反序列化原语进行检查或修改,但 TVM 引入了特殊的原语来方便处理这些哈希映射。

3.3.1 基本哈希映射类型

TVM 预定义的两种最基本哈希映射类型是 HashmapE(n, X)Hashmap(n, X)

  • HashmapE(n, X):表示从 n 位字符串(称为键)到某种类型 X 的值的部分定义映射,其中 0 ≤ n ≤ 1023。这种映射可以为空。

  • Hashmap(n, X):与 HashmapE(n, X) 类似,但它不能为空(即,它必须至少包含一个键值对)。

还有其他哈希映射类型可用,例如,键的长度可以是任意的,但有一个预定义的上限(最多 1023 位)。

3.3.2 哈希映射作为 Patricia 树

在 TVM 中,哈希映射的抽象表示是 Patricia 树,或称为紧凑的二叉 trie。它是一个二叉树,其边被位字符串标记,使得从根到叶子的路径上所有边的标签串联起来等于哈希映射的键。对应的值存储在叶子中(对于键长度固定的哈希映射),或者也可以存储在中间顶点中(对于键长度可变的哈希映射)。

此外,任何中间顶点必须有两个子节点,左子节点的标签必须以二进制零开始,而右子节点的标签必须以二进制一开始。这使我们不必显式存储边标签的第一个比特。很容易看出,任何一组键值对(具有不同键)都由一个唯一的帕特里夏树表示。

小回顾:(

  • 哈希映射的效率:哈希映射的设计允许快速的查找和修改操作,这对于区块链环境中智能合约的性能至关重要。

  • 键的灵活性:哈希映射支持固定长度和可变长度的键,这为不同的数据结构需求提供了灵活性。

  • Patricia 树的特点

    • 紧凑性:Patricia 树通过省略公共前缀来节省空间。
    • 快速路径查找:由于键的路径从根到叶子,查找操作非常快速。
    • 动态结构:Patricia 树可以根据插入和删除操作动态调整其结构。
  • TVM 原语:TVM 提供了专门的原语来操作 Patricia 树,这些原语优化了智能合约中的数据存储和检索。)

这段描述涉及到 TL-B(Type-Level Boolean)方案的详细解释,这是一种用于定义数据结构序列化的方案,特别是在 TON 虚拟机(TVM)中用于序列化哈希映射(hashmaps)或字典。以下是对这段描述的详细讲解:

3.3.3. Serialization of hashmaps. The serialization of a hashmap into a
tree of cells (or, more generally, into a Slice) is defined by the following TL-B
scheme:

bit#_ _:(## 1) = Bit;
hm_edge#_ {n:#} {X:Type} {l:#} {m:#} label:(HmLabel ~l n)
{n = (~m) + l} node:(HashmapNode m X) = Hashmap n X;
hmn_leaf#_ {X:Type} value:X = HashmapNode 0 X;
hmn_fork#_ {n:#} {X:Type} left:^(Hashmap n X)
right:^(Hashmap n X) = HashmapNode (n + 1) X;
hml_short$0 {m:#} {n:#} len:(Unary ~n)
s:(n * Bit) = HmLabel ~n m;
hml_long$10 {m:#} n:(#<= m) s:(n * Bit) = HmLabel ~n m;
hml_same$11 {m:#} v:Bit n:(#<= m) = HmLabel ~n m;
unary_zero$0 = Unary ~0;
unary_succ$1 {n:#} x:(Unary ~n) = Unary ~(n + 1);
hme_empty$0 {n:#} {X:Type} = HashmapE n X;
hme_root$1 {n:#} {X:Type} root:^(Hashmap n X) = HashmapE n X;
true#_ = True;
_ {n:#} _:(Hashmap n True) = BitstringSet n;

TL-B 方案的简要解释

TL-B 方案包括以下组件:

  1. 类型定义

    • 每个“等式”的右侧定义了一个类型,可以是简单的(如 BitTrue)或参数化的(如 Hashmap n X)。
    • 类型的参数可以是自然数(即非负整数,通常需要在 32 位内)或其它类型。
  2. 构造器和标签

    • 每个构造器(如 hm_edgehml_long)定义了如何序列化一个值。
    • 构造器标签(如 #_$10)描述了用于编码(序列化)构造器的位字符串。
    • 标签可以是二进制或十六进制表示,如果不提供标签,TL-B 会通过特定方式计算默认的 32 位构造器标签。
  3. 字段定义

    • 每个字段定义的形式为 ident : type-expr,其中 ident 是字段的标识符,type-expr 是字段的类型。
    • 类型表达式可以包括简单类型或参数化类型。
  4. 隐式字段

    • 某些字段可能在序列化中不实际存在,但它们的值可以从其他数据中推断出来。
  5. 变量的使用

    • 变量(即之前定义的字段)在等式的左侧用于计算变量的值,在等式的右侧用于在反序列化过程中计算变量的值。
  6. 方程式

    • 方程式定义了变量之间的关系,这些关系在序列化和反序列化过程中必须满足。
  7. 引用类型

    • 使用 ^ 符号表示的类型(如 ^X)意味着值不是在当前单元内序列化,而是在单独的单元中序列化,然后在当前单元中添加引用。
  8. 参数化类型

    • 参数化类型 #<= p 表示自然数的一个子集,序列化为 dlog2(p + 1) 位的无符号大端整数。
    • 类型 # 本身序列化为无符号的 32 位整数。

在这个 TL-B 方案中,我们定义了如何将哈希映射(hashmap)序列化为单元树(或切片 Slice)。这个方案使用了一系列的构造器来表示哈希映射的不同部分。让我们通过一个例子来详细解释这个过程。

示例哈希映射:

{
  "apple": 1,
  "chanana": 2,
  "charry": 3
}

TL-B 方案定义:

  1. Bit:定义了一个位(Bit)。

  2. HashmapNode:定义了哈希映射节点,可以是叶子节点或分叉节点。

    • hmn_leaf:叶子节点,包含一个值。
    • hmn_fork:分叉节点,包含左右两个子节点。
  3. HmLabel:定义了哈希映射的标签,用于标识键的一部分。

    • hml_short:短标签,用于长度较小的键。
    • hml_long:长标签,用于长度较大的键。
    • hml_same:用于表示连续的键位。
  4. Unary:定义了递增的自然数类型。

    • unary_zero:表示数字 0。
    • unary_succ:表示自然数的后继。
  5. HashmapE:定义了哈希映射的空和非空版本。

    • hme_empty:表示一个空的哈希映射。
    • hme_root:表示哈希映射的根节点。
  6. BitstringSet:表示一个位字符串集合。

序列化过程:

  1. 序列化键

    • 每个键(如 “apple”)被序列化为一个位字符串(HmLabel)。
  2. 序列化值

    • 每个值(如 1, 2, 3)被序列化为一个无符号整数。
  3. 创建叶子节点

    • 对于每个键值对,我们使用 hmn_leaf 构造器创建一个叶子节点,包含键的位字符串和对应的值。
  4. 创建分叉节点

    • 如果有多个键共享相同的前缀,我们使用 hmn_fork 构造器创建一个分叉节点,包含左右两个子节点。
  5. 构建 Patricia 树

    • 我们递归地构建 Patricia 树,直到所有的键值对都被包含在树中。
  6. 序列化哈希映射

    • 最终,整个 Patricia 树被序列化为一个切片(Slice)。

示例分析:

  • 叶子节点

    • 对于 “apple”:1,我们创建一个叶子节点 hmn_leaf,包含值 1
  • 分叉节点

    • 如果 “chanana” 和 “charry” 共享相同的前缀 “cha”,我们可以创建一个分叉节点 hmn_fork,左子树包含 “chanana”,右子树包含 “charry”。
  • 根节点

    • 使用 hme_root 构造器,我们创建一个根节点,包含指向 Patricia 树的引用。
  • 序列化

    • 每个 HmLabel 被序列化为一个位字符串,每个值被序列化为一个无符号整数。
    • 每个 HashmapNode 被序列化为一个单元(cell),包含标签和值。
    • 整个哈希映射被序列化为一个切片(Slice),包含根节点和所有单元的引用。
      这段描述详细说明了如何使用 TL-B 方案来序列化哈希映射(hashmap),特别是当哈希映射以 Patricia 树的形式表示时。以下是对这个过程的详细解释:

3.3.5 哈希映射的序列化应用

假设我们想要序列化一个类型为 HashmapE n X 的值,其中 n 是一个整数(0 ≤ n ≤ 1023),X 是值的类型。这表示一个具有 n 位键和类型为 X 的值的字典(可以抽象地表示为一个 Patricia 树)。

  1. 空字典的序列化

    • 如果字典为空,它将被序列化为一个单一的二进制 0,这是空构造器 hme_empty 的标签。
  2. 非空字典的序列化

    • 如果字典非空,它的序列化包括一个二进制 1(构造器 hme_root 的标签)以及对包含类型为 Hashmap n X 的值的序列化的单元的引用(即,一个非空字典)。
  3. Patricia 树的序列化

    • Hashmap n X 类型的值只能通过 hm_edge 构造器来序列化,它指示我们首先序列化通往正在考虑的子树根的边的标签(即,我们(子)字典中所有键的公共前缀)。
    • 标签是 HmLabel l⊥ n 类型,意味着它是长度最多为 n 的位字符串,其真实长度 l(0 ≤ l ≤ n)可以从标签的序列化中得知。
  4. 叶子节点的序列化

    • 如果 m = 0HashmapNode 0 X 类型的值由 hmn_leaf 构造器给出,描述了 Patricia 树的一个叶子节点(或等效地,一个具有 0 位键的子字典)。
    • 叶子节点简单地由类型 X 的值组成,并相应地序列化。
  5. 分叉节点的序列化

    • 如果 m > 0HashmapNode m X 类型的值对应于 Patricia 树中的一个分叉(即,一个中间节点),由 hmn_fork 构造器给出。
    • 它的序列化包括 leftright,两个对包含 Hashmap m - 1 X 类型的值的单元的引用,这些值对应于中间节点的左和右子节点,或等效地,由以二进制 0 或二进制 1 开头的键值对组成的两个子字典。
    • 由于这些子字典中所有键的第一位是已知且固定的,它被移除,得到的(必然非空的)子字典被递归地序列化为 Hashmap m - 1 X 类型的值。

3.3.6 标签的序列化

在 TL-B 方案中,如果标签的最大长度为 n,而其确切长度为 l ≤ n,有几种方式可以序列化标签。标签的确切长度必须能够从标签本身的序列化中推断出来,而上限 n 在序列化或反序列化之前是已知的。这三种构造器 hml_shorthml_longhml_same 描述了 HmLabel l⊥ n 类型的序列化方式:

  • hml_short — 描述了序列化“短”标签的方式,长度为 l ≤ n。这种序列化包括一个二进制 0hml_short 的构造器标签),后面跟着 l 个二进制 1 和一个二进制 0(长度 l 的一元表示),然后是组成标签本身的 l 位。

  • hml_long — 描述了序列化“长”标签的方式,长度为任意 l ≤ n。这种序列化包括一个二进制 10hml_long 的构造器标签),后面跟着长度 0 ≤ l ≤ n 的大端序二进制表示,长度为 dlog2(n + 1) 位,然后是组成标签本身的 l 位。

  • hml_same — 描述了序列化由相同位 v 重复 l 次构成的“长”标签的方式。这种序列化包括二进制 11hml_same 的构造器标签),后面跟着位 v,然后是长度 l,同样以 dlog2(n + 1) 位大端序二进制存储。

每个标签至少可以用两种不同的方式序列化,使用 hml_shorthml_long 构造器。通常优先选择最短的序列化(如果长度相同,则选择字典序最小的序列化),而 TVM hashmap 原语生成的序列化方式,尽管其他变体也认为是有效的。

这种标签编码方案旨在对“随机”键(例如,某些数据的哈希)的字典以及“规则”键(例如,某个范围内整数的大端序表示)的字典都有效。

3.3.7 字典序列化示例
考虑一个包含三个16位键(13、17和239,作为大端字节序整数)的字典,以及相应的16位值(169、289和57121)。以二进制形式表示为:

  • 0000000000001101 => 0000000010101001
  • 0000000000010001 => 0000000100100001
  • 0000000011101111 => 1101111100100001

对应的 Patricia 树由根节点A、两个中间节点B和C以及三个叶节点D、E和F组成,分别对应13、17和239。根节点A只有一个子节点B;边AB的标签为00000000 = 08。节点B有两个子节点:其左子节点是中间节点C,边BC的标签为(0)00,而其右子节点是叶节点F,边BF的标签为(1)1101111。最后,C有两个叶子节点D和E,边CD的标签为(0)1101,边CE的标签为(1)0001

对应的类型为HashmapE 16(## 16)的值可以以人类可读的形式写成:

(hme_root$1
root:^(hm_edge label:(hml_same$11 v:0 n:8) node:(hm_fork
left:^(hm_edge label:(hml_short$0 len:$110 s:$00)
node:(hm_fork
left:^(hm_edge label:(hml_long$10 n:4 s:$1101)
node:(hm_leaf value:169))
right:^(hm_edge label:(hml_long$10 n:4 s:$0001)
node:(hm_leaf value:289))))
right:^(hm_edge label:(hml_long$10 n:7 s:$1101111)
node:(hm_leaf value:57121)))))

这个数据结构序列化成单元格树,包含六个单元格,其中包含以下二进制数据:

  • A := 1
  • A.0 := 11 0 01000
  • A.0.0 := 0 110 00
  • A.0.0.0 := 10 100 1101 0000000010101001
  • A.0.0.1 := 10 100 0001 0000000100100001
  • A.0.1 := 10 111 1101111 1101111100100001

这里A是根单元格,A.0是A的第一个引用处的单元格,A.1是A的第二个引用处的单元格,以此类推。这个单元格树可以用1.0中描述的十六进制表示法更紧凑地表示,使用缩进来反映单元格树的结构:

C_
C8
62_
A68054C_
A08090C_
BEFDF21

总共使用了93个数据位和6个单元格中的5个引用来序列化这个字典。请注意,三个16位键及其相应的16位值的直接表示就已经需要96位(尽管没有引用),因此这种特定的序列化结果非常有效。

3.3.8 描述类型X的序列化方式

请注意,内置的TVM原语用于字典操作需要了解一些关于类型X的序列化信息;否则,它们将无法正确地使用哈希映射n X,因为类型X的值立即包含在Patricia树的叶子单元格中。有几种选项可用于描述类型X的序列化:

最简单的情况

  • X = ˆY对于其他某种类型Y。在这种情况下,X本身的序列化总是由一个指向单元格的引用组成,实际上必须包含一个类型Y的值,这对字典操作原语来说是不相关的。

另一个简单的情况

  • 类型X的任何值的序列化总是由0 ≤ b ≤ 1023数据位和0 ≤ r ≤ 4个引用组成。整数b和r可以作为X的简单描述传递给字典操作原语。

更复杂的情况

  • 可以通过四个整数1 ≤ b0, b1 ≤ 1023, 0 ≤ r0, r1 ≤ 4来描述,当序列化的第一个比特等于i时使用bi和ri。当b0 = b1r0 = r1时,这种情况简化为前一种情况。

最一般的描述

  • 类型X的序列化最一般的描述是由一个分割函数splitX为X给出的,它接受一个切片参数s,并返回两个切片,s’和s’‘,其中s’是s的唯一前缀,它是类型X的一个值的序列化,s’'是s的剩余部分。如果没有这样的前缀存在,分割函数预期会抛出一个异常。

3.3.9 对X的序列化的一个简化假设

人们可能会注意到,类型X的值总是占据哈希映射E n X的序列化中的hm_edge/hme_leaf单元格的剩余部分。因此,如果我们不坚持严格验证所有访问的字典,我们可以假设在hm_edge/hme_leaf单元格中,除了其标签反序列化后未解析的部分之外,一切都是类型X的值。这大大简化了字典操作原语的创建,因为在大多数情况下,它们不需要任何关于X的信息。

3.3.10 基本字典操作

让我们对字典的基本操作进行分类(即,类型为HashmapE(n, X)的值D):

  • Get(D, k) — 给定D : HashmapE(n, X)和一个键k : n · bit,返回D中保持的对应值D[k] : X?

  • Set(D, k, x) — 给定D : HashmapE(n, X),一个键k : n · bit,和一个值x : X,在D的一个副本D’中将D'[k]设置为x,并返回结果字典D’(参见2.3.4)。

  • Add(D, k, x) — 类似于Set,但只有在D中不存在键k时才将键值对(k, x)添加到D。

  • Replace(D, k, x) — 类似于Set,但只有在D中已经存在键k时才将D'[k]更改为x。

  • GetSet, GetAdd, GetReplace — 分别类似于Set, Add, 和 Replace,但同时返回D[k]的旧值。

  • Delete(D, k) — 从字典D中删除键k,并返回结果字典D’。

  • GetMin(D), GetMax(D) — 从字典D中获取最小或最大键k,以及关联的值x : X。

  • RemoveMin(D), RemoveMax(D) — 类似于GetMin和GetMax,但同时也从字典D中删除所询问的键,并返回修改后的字典D’。可以用来遍历D的所有元素,有效地使用(D的一个副本)本身作为迭代器。

  • GetNext(D, k) — 计算大于k的最小键k’(或k’ ≥ k的一个变体)并返回它以及对应的值x’ : X。可以用来遍历D的所有元素。

  • GetPrev(D, k) — 计算小于k的最大键k’(或k’ ≤ k的一个变体)并返回它以及对应的值x’ : X。

  • Empty(n) — 创建一个空字典D : HashmapE(n, X)

  • IsEmpty(D) — 检查一个字典是否为空。

  • Create(n, {(ki, xi)}) — 给定n,从在栈中传递的键值对列表(ki, xi)创建一个字典。

  • GetSubdict(D, l, k0) — 给定D : HashmapE(n, X)和一个l位的字符串k0 : l · bit,对于0 ≤ l ≤ n,返回D的子字典D’ = D/k0,由以k0开头的键组成。结果D’可能是类型HashmapE(n, X)或类型HashmapE(n − l, X)

  • ReplaceSubdict(D, l, k0, D′) — 给定D : HashmapE(n, X)0 ≤ l ≤ n,k0 : l · bit,和D′ : HashmapE(n − l, X),用D′替换D中由以k0开头的键组成的子字典D/k0,并返回结果字典D’’ : HashmapE(n, X)。某些ReplaceSubdict的变体也可能返回子字典D/k0的旧值。

  • DeleteSubdict(D, l, k0) — 相当于用一个空字典替换D′的ReplaceSubdict。

  • Split(D) — 给定D : HashmapE(n, X),返回D0 := D/0和D1 := D/1 : HashmapE(n − 1, X),D的两个子字典,分别由以0和1开头的所有键组成。

  • Merge(D0, D1) — 给定D0和D1 : HashmapE(n − 1, X),计算D : HashmapE(n, X),使得D/0 = D0且D/1 = D1。

  • Foreach(D, f ) — 对字典D中的所有键值对(k, x)按字典顺序执行一个带有两个参数k和x的函数f。

  • ForeachRev(D, f ) — 类似于Foreach,但是以相反的顺序处理所有键值对。

  • TreeReduce(D, o, f, g) — 给定D : HashmapE(n, X),一个值o : X,和两个函数f : X → Y和g : Y × Y → Y,通过首先对所有叶子应用f,然后使用g计算从其子节点分配的值开始的分叉对应的值,对D执行“树归约”。
    PS:

  • Foreach(D, f ) — 实际上,函数f可以接收m个额外的参数,并返回m个修改后的值,这些值将被传递给f的下一次调用。这可以用来实现字典上的“map”和“reduce”操作。

  • TreeReduce(D, o, f, g) — 这个操作的版本可能会被引入,其中f和g接收一个额外的位字符串参数,对于叶子节点等于键,对于分叉等于对应子树中所有键的公共前缀。

3.3.11 字典原语的分类

在A.10节中详细描述的字典原语可以根据以下类别进行分类:

  • 执行哪种字典操作?(参见3.3.10)
  • 它们是否专门针对X = ˆY的情况?如果是,它们是通过单元格Cell s还是通过切片Slices来表示类型Y的值?(通用版本总是将类型X的值表示为Slices。)
  • 字典本身是作为单元格Cell s还是作为切片Slices传递和返回的?(大多数原语将字典表示为Slices。)
  • 原语内部的键长n是固定的,还是通过栈传递的
  • 键是由切片Slices表示的,还是由有符号或无符号整数表示的

此外,TVM包括一些特殊的序列化/反序列化原语,如STDICTLDDICTPLDDICT。它们可以用来从包含对象的序列化中提取字典,或者将字典插入到这样的序列化中。

3.4 可变长度键的哈希映射

除了在3.3节中描述的对固定长度键字典的支持外,TVM还提供对可变长度键字典或哈希映射的一些支持。

3.4.1 可变长度键字典的序列化

VarHashmap序列化为单元格树(或更一般地,序列化为切片)的过程是通过TL-B方案定义的,类似于3.3.3节中描述的方案:

  • vhm_edge#_ `{n:#} {X:Type} {l:#} {m:#} label:(HmLabel ~l n) {n = (~m) + l} node:(VarHashmapNode m X) = VarHashmap n X;

    • 表示边,其中n是节点数,X是值的类型,l是标签长度,m是分支数。标签和节点一起定义了VarHashmap
  • vhmn_leaf$00 `{n:#} {X:Type} value:X = VarHashmapNode n X;

    • 表示叶子节点,其中包含一个值value
  • vhmn_fork$01 `{n:#} {X:Type} left:^(VarHashmap n X) right:^(VarHashmap n X) value:(Maybe X) = VarHashmapNode (n + 1) X;

    • 表示分叉节点,有两个子节点leftright,以及一个可选值value
  • vhmn_cont$1 `{n:#} {X:Type} branch:bit child:^(VarHashmap n X) value:X = VarHashmapNode (n + 1) X;

    • 表示继续节点,有一个子节点child,一个分支位branch和一个值value
  • nothing$0 `{X:Type} = Maybe X;

    • 表示没有值的情况。
  • just$1 `{X:Type} value:X = Maybe X;

    • 表示有一个值value
  • vhme_empty$0 `{n:#} {X:Type} = VarHashmapE n X;

    • 表示空字典。
  • vhme_root$1 `{n:#} {X:Type} root:^(VarHashmap n X) = VarHashmapE n X;

    • 表示以一个根节点root开始的字典。

这些构造定义了如何将可变长度键的字典序列化为一个结构化的格式,这个格式可以被有效地存储和处理。

3.4.2 前缀码的序列化

可变长度键字典的一个特殊情况是前缀码,其中键不能是彼此的前缀。在这样的字典中,值只能出现在Patricia树的叶子节点。

前缀码的序列化通过以下TL-B方案定义:

  • phm_edge#_ `{n:#} {X:Type} {l:#} {m:#} label:(HmLabel ~l n) {n = (~m) + l} node:(PfxHashmapNode m X) = PfxHashmap n X;

    • 表示前缀码的边,其中n是节点数,X是值的类型,l是标签长度,m是分支数。标签和节点一起定义了PfxHashmap
  • phmn_leaf$0 `{n:#} {X:Type} value:X = PfxHashmapNode n X;

    • 表示前缀码的叶子节点,其中包含一个值value
  • phmn_fork$1 `{n:#} {X:Type} left:^(PfxHashmap n X) right:^(PfxHashmap n X) = PfxHashmapNode (n + 1) X;

    • 表示前缀码的分叉节点,有两个子节点leftright
  • phme_empty$0 `{n:#} {X:Type} = PfxHashmapE n X;

    • 表示空的前缀码字典。
  • phme_root$1 `{n:#} {X:Type} root:^(PfxHashmap n X) = PfxHashmapE n X;

    • 表示以前缀码的根节点root开始的字典。

这些构造定义了如何将前缀码序列化为一个结构化的格式,这个格式可以被有效地存储和处理。

您好!要在虚拟机上安装 LLVM 和 TVM,您可以按照以下步骤进行操作: 1. 首先,在虚拟机中打开终端或命令提示符窗口。 2. 接下来,使用包管理工具(如apt、yum等)安装必要的依赖项。例如,在 Ubuntu 系统上,可以运行以下命令来安装所需的软件包: ```shell sudo apt-get update sudo apt-get install build-essential cmake python3-dev llvm ``` 这将安装构建工具、CMake、Python 3 开发环境和 LLVM 编译器。 3. 完成上述步骤后,您可以开始安装 TVM。首先,克隆 TVM 仓库到本地: ```shell git clone --recursive https://github.com/apache/tvm.git ``` 4. 进入 TVM 仓库目录: ```shell cd tvm ``` 5. 在 TVM 仓库目录中,运行以下命令来构建和安装 TVM: ```shell mkdir build cp cmake/config.cmake build/ cd build # 使用编辑器打开 config.cmake 文件,并进行以下修改: # 将 set(USE_LLVM OFF) 改为 set(USE_LLVM /usr/bin/llvm-config)(确保路径正确) # 保存并关闭文件 cmake .. make -j$(nproc) ``` 这将构建 TVM 并生成可执行文件。 6. 安装完成后,您可以进行 TVM 的配置。在 TVM 仓库目录中,运行以下命令: ```shell cp ../python/tvm/* python/tvm/ -r export PYTHONPATH=$(pwd)/python:${PYTHONPATH} ``` 这将配置您的 Python 环境以使用 TVM。 现在,您已经成功在虚拟机上安装了 LLVM 和 TVM。您可以开始使用 TVM 进行深度学习和机器学习任务了。如果您有其他问题,请随时提问!
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值